fix: E2E 测试发现的 10 项 BUG 修复 — 全栈验证通过

P0 修复:
- 侧边栏路由不稳定: Content 区域添加 key={currentPath} 强制重渲染
- 轮播图缩略图不显示: BannerManage 导入 resolveMediaUrl + 反斜杠转正斜杠
- 超长名称导致 500: patient_handler 添加 name.len() > 255 校验
- 迁移 m20260515_000146: version 乐观锁 version+1 修复

P1 修复:
- 排班路由被冻结: routeConfig.ts 移除 /health/schedules 的 frozen 标记
- 轮播图 Switch 切换无效: 切换前先 GET 最新 version 避免乐观锁冲突
- thumbnail_url 反斜杠: media_service 存储时统一 replace('\', '/')

P2 修复:
- 预约类型 follow_up 未映射: APPOINTMENT_TYPE_MAP 补充 '随访'
- 日期选择器未汉化: DatePicker.RangePicker 添加中文 placeholder
- 轮播图 title 必填校验: banner_handler 添加空标题拒绝
- 文章分类重名: article_category_service 添加同名检查
This commit is contained in:
iven
2026-05-15 21:13:49 +08:00
parent 41515e5bec
commit d44c6167b1
10 changed files with 38 additions and 8 deletions

View File

@@ -69,6 +69,15 @@ export const bannerApi = {
return data.data; return data.data;
}, },
/** 获取单个轮播图 */
get: async (id: string) => {
const { data } = await client.get<{
success: boolean;
data: BannerItem;
}>(`/health/banners/${id}`);
return data.data;
},
/** 创建轮播图 */ /** 创建轮播图 */
create: async (req: CreateBannerReq) => { create: async (req: CreateBannerReq) => {
const { data } = await client.post<{ const { data } = await client.post<{

View File

@@ -7,6 +7,7 @@ import {
SearchOutlined, SearchOutlined,
AppstoreOutlined, AppstoreOutlined,
RightOutlined, RightOutlined,
UserOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useAppStore } from '../stores/app'; import { useAppStore } from '../stores/app';
@@ -493,7 +494,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
{/* 内容区域 */} {/* 内容区域 */}
<Content style={{ padding: 20, minHeight: 'calc(100vh - 56px - 48px)' }}> <Content style={{ padding: 20, minHeight: 'calc(100vh - 56px - 48px)' }}>
{children} <div key={currentPath}>{children}</div>
</Content> </Content>
{/* 底部 */} {/* 底部 */}

View File

@@ -46,6 +46,7 @@ const APPOINTMENT_TYPE_MAP: Record<string, string> = {
health_checkup: '体检', health_checkup: '体检',
consultation: '咨询', consultation: '咨询',
dialysis: '透析', dialysis: '透析',
follow_up: '随访',
}; };
/** 状态筛选选项 */ /** 状态筛选选项 */
@@ -383,6 +384,7 @@ export default function AppointmentList() {
<DatePicker.RangePicker <DatePicker.RangePicker
value={filters.dateRange as [Dayjs, Dayjs] | null} value={filters.dateRange as [Dayjs, Dayjs] | null}
onChange={(dates) => handleFilterChange('dateRange', dates)} onChange={(dates) => handleFilterChange('dateRange', dates)}
placeholder={['开始日期', '结束日期']}
allowClear allowClear
/> />
<Input <Input

View File

@@ -30,6 +30,7 @@ import { mediaApi, type MediaItem } from '../../api/health/media';
import { AuthButton } from '../../components/AuthButton'; import { AuthButton } from '../../components/AuthButton';
import { PageContainer } from '../../components/PageContainer'; import { PageContainer } from '../../components/PageContainer';
import { formatDateTime } from '../../utils/format'; import { formatDateTime } from '../../utils/format';
import { resolveMediaUrl } from '../../utils/media';
import { dayjs } from '../../utils/dayjs'; import { dayjs } from '../../utils/dayjs';
// --- 常量 --- // --- 常量 ---
@@ -208,9 +209,10 @@ export default function BannerManage() {
const handleToggleStatus = useCallback(async (record: BannerItem) => { const handleToggleStatus = useCallback(async (record: BannerItem) => {
try { try {
const newStatus = record.status === 'active' ? 'inactive' : 'active'; const newStatus = record.status === 'active' ? 'inactive' : 'active';
const latest = await bannerApi.get(record.id);
await bannerApi.update(record.id, { await bannerApi.update(record.id, {
status: newStatus, status: newStatus,
version: record.version, version: latest.version,
}); });
message.success(newStatus === 'active' ? '已启用' : '已停用'); message.success(newStatus === 'active' ? '已启用' : '已停用');
fetchData(); fetchData();
@@ -259,7 +261,7 @@ export default function BannerManage() {
if (!src) return '-'; if (!src) return '-';
return ( return (
<Image <Image
src={src} src={resolveMediaUrl(src.replace(/\\/g, '/'))}
width={60} width={60}
height={40} height={40}
style={{ borderRadius: 4, objectFit: 'cover' }} style={{ borderRadius: 4, objectFit: 'cover' }}

View File

@@ -235,7 +235,6 @@ const ENTRIES: RoutePermissionEntry[] = [
{ {
path: "/health/schedules", path: "/health/schedules",
permissions: ["health.appointment.list", "health.appointment.manage"], permissions: ["health.appointment.list", "health.appointment.manage"],
frozen: true,
}, },
]; ];

View File

@@ -61,6 +61,9 @@ where
{ {
require_permission(&ctx, "health.banners.manage")?; require_permission(&ctx, "health.banners.manage")?;
req.sanitize(); req.sanitize();
if req.title.as_ref().is_none_or(|t| t.trim().is_empty()) {
return Err(AppError::Validation("轮播图标题不能为空".into()));
}
let result = banner_service::create_banner(&state, ctx.tenant_id, ctx.user_id, req.0).await?; let result = banner_service::create_banner(&state, ctx.tenant_id, ctx.user_id, req.0).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))
} }

View File

@@ -70,6 +70,9 @@ where
if req.name.trim().is_empty() { if req.name.trim().is_empty() {
return Err(AppError::Validation("患者姓名不能为空".into())); return Err(AppError::Validation("患者姓名不能为空".into()));
} }
if req.name.len() > 255 {
return Err(AppError::Validation("患者姓名长度不能超过255个字符".into()));
}
let result = let result =
patient_service::create_patient(&state, ctx.tenant_id, Some(ctx.user_id), req).await?; patient_service::create_patient(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
Ok(Json(ApiResponse::ok(result))) Ok(Json(ApiResponse::ok(result)))

View File

@@ -48,6 +48,17 @@ pub async fn create_category(
operator_id: Option<Uuid>, operator_id: Option<Uuid>,
req: CreateCategoryReq, req: CreateCategoryReq,
) -> HealthResult<CategoryResp> { ) -> HealthResult<CategoryResp> {
// 同名分类检查
let duplicate = article_category::Entity::find()
.filter(article_category::Column::TenantId.eq(tenant_id))
.filter(article_category::Column::Name.eq(&req.name))
.filter(article_category::Column::DeletedAt.is_null())
.one(&state.db)
.await?;
if duplicate.is_some() {
return Err(HealthError::Validation("同名分类已存在".into()));
}
let now = Utc::now(); let now = Utc::now();
let active = article_category::ActiveModel { let active = article_category::ActiveModel {
id: Set(Uuid::now_v7()), id: Set(Uuid::now_v7()),

View File

@@ -144,7 +144,7 @@ pub async fn upload_media(
folder_id: Set(folder_id), folder_id: Set(folder_id),
filename: Set(filename.to_string()), filename: Set(filename.to_string()),
storage_path: Set(relative_path.clone()), storage_path: Set(relative_path.clone()),
thumbnail_path: Set(thumbnail_path.map(|p| p.to_string_lossy().to_string())), thumbnail_path: Set(thumbnail_path.map(|p| p.to_string_lossy().replace('\\', "/"))),
content_type: Set(content_type.to_string()), content_type: Set(content_type.to_string()),
file_size: Set(file_data.len() as i64), file_size: Set(file_data.len() as i64),
width: Set(width), width: Set(width),
@@ -404,7 +404,7 @@ pub async fn crop_media(
let mut active: media_item::ActiveModel = model.into(); let mut active: media_item::ActiveModel = model.into();
active.storage_path = Set(cropped_relative); active.storage_path = Set(cropped_relative);
active.thumbnail_path = Set(thumbnail_path.map(|p| p.to_string_lossy().to_string())); active.thumbnail_path = Set(thumbnail_path.map(|p| p.to_string_lossy().replace('\\', "/")));
active.width = Set(new_width); active.width = Set(new_width);
active.height = Set(new_height); active.height = Set(new_height);
active.updated_at = Set(Utc::now()); active.updated_at = Set(Utc::now());

View File

@@ -30,7 +30,7 @@ impl MigrationTrait for Migration {
for &(path, perm) in menu_perms { for &(path, perm) in menu_perms {
db.execute_unprepared(&format!( db.execute_unprepared(&format!(
"UPDATE menus SET permission = '{perm}', updated_at = NOW() \ "UPDATE menus SET permission = '{perm}', updated_at = NOW(), version = version + 1 \
WHERE path = '{path}' AND deleted_at IS NULL AND (permission IS NULL OR permission = '')" WHERE path = '{path}' AND deleted_at IS NULL AND (permission IS NULL OR permission = '')"
)) ))
.await?; .await?;
@@ -57,7 +57,7 @@ impl MigrationTrait for Migration {
for &path in paths { for &path in paths {
db.execute_unprepared(&format!( db.execute_unprepared(&format!(
"UPDATE menus SET permission = NULL, updated_at = NOW() \ "UPDATE menus SET permission = NULL, updated_at = NOW(), version = version + 1 \
WHERE path = '{path}' AND deleted_at IS NULL" WHERE path = '{path}' AND deleted_at IS NULL"
)) ))
.await?; .await?;