# 三端联调审计问题修复实施计划 > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 修复三端联调审计发现的 15 项问题,恢复系统统一性和可用性。 **Architecture:** 按优先级分 3 个 Phase 执行。Phase 1 快速修复基础设施和统一性问题(#1-#6),Phase 2 补全用户体验(#7-#11),Phase 3 实现功能闭环(#12-#15)。每个 Task 独立可提交。 **Tech Stack:** Rust/Axum/SeaORM (后端) · React 19/Ant Design 6/Zustand (Web 前端) · Taro 4.2 (小程序) · PostgreSQL (数据库) **Spec:** `docs/superpowers/specs/2026-05-01-tri-platform-audit-fix-design.md` --- ## Chunk 1: Phase 1 — 快速修复 (#1-#6) ### Task 1: erp-plugin 测试编译失败修复 **Files:** - Modify: `crates/erp-plugin/src/plugin_validator.rs:227` - [ ] **Step 1: 添加缺失的 import** 在第 227 行 `use super::*;` 之后添加一行: ```rust #[cfg(test)] mod tests { use super::*; use crate::manifest::parse_manifest; ``` - [ ] **Step 2: 验证编译通过** Run: `cargo test -p erp-plugin --no-run` Expected: 编译成功,无错误 - [ ] **Step 3: 运行测试** Run: `cargo test -p erp-plugin` Expected: 所有测试通过 - [ ] **Step 4: 提交** ```bash git add crates/erp-plugin/src/plugin_validator.rs git commit -m "fix(plugin): 修复测试编译失败 — 补充 parse_manifest 导入" ``` --- ### Task 2: 后端 ThemeResp 增加品牌字段 **Files:** - Modify: `crates/erp-config/src/dto.rs:220-227` - Modify: `crates/erp-config/src/handler/theme_handler.rs:15-21` - Modify: `crates/erp-config/src/handler/theme_handler.rs:110-135`(测试) - [ ] **Step 1: 扩展 ThemeResp DTO** 在 `crates/erp-config/src/dto.rs` 第 227 行 `sidebar_style` 字段之后添加 4 个品牌字段: ```rust #[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] pub struct ThemeResp { #[serde(skip_serializing_if = "Option::is_none")] pub primary_color: Option, #[serde(skip_serializing_if = "Option::is_none")] pub logo_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub sidebar_style: Option, #[serde(skip_serializing_if = "Option::is_none")] pub brand_name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub brand_slogan: Option, #[serde(skip_serializing_if = "Option::is_none")] pub brand_features: Option, #[serde(skip_serializing_if = "Option::is_none")] pub brand_copyright: Option, } ``` - [ ] **Step 2: 更新 default_theme 函数** 在 `crates/erp-config/src/handler/theme_handler.rs` 第 15-21 行,更新 `default_theme()` 函数: ```rust fn default_theme() -> ThemeResp { ThemeResp { primary_color: None, logo_url: None, sidebar_style: None, brand_name: Some("HMS 健康管理平台".into()), brand_slogan: Some("新一代健康管理平台".into()), brand_features: Some("患者管理 · 健康监测 · 随访管理 · AI 智能分析".into()), brand_copyright: Some("HMS 健康管理平台 · ©汕头市智界科技有限公司".into()), } } ``` - [ ] **Step 3: 更新测试** 在 `crates/erp-config/src/handler/theme_handler.rs` 的测试模块中,更新 `default_theme_all_fields_none` 测试(因为 default_theme 现在品牌字段有值了)和 `theme_resp_serde_roundtrip` 测试: ```rust #[test] fn default_theme_has_brand_defaults() { let theme = default_theme(); assert!(theme.primary_color.is_none()); assert!(theme.logo_url.is_none()); assert!(theme.sidebar_style.is_none()); assert_eq!(theme.brand_name, Some("HMS 健康管理平台".to_string())); assert_eq!(theme.brand_slogan, Some("新一代健康管理平台".to_string())); assert!(theme.brand_features.is_some()); assert!(theme.brand_copyright.is_some()); } #[test] fn theme_resp_serde_roundtrip() { let theme = ThemeResp { primary_color: Some("#1890ff".to_string()), logo_url: None, sidebar_style: Some("dark".to_string()), brand_name: Some("测试平台".to_string()), brand_slogan: None, brand_features: None, brand_copyright: None, }; let json = serde_json::to_string(&theme).unwrap(); let back: ThemeResp = serde_json::from_str(&json).unwrap(); assert_eq!(back.primary_color, Some("#1890ff".to_string())); assert_eq!(back.brand_name, Some("测试平台".to_string())); assert!(back.brand_slogan.is_none()); } ``` - [ ] **Step 4: 添加公开品牌信息端点** 在 `crates/erp-config/src/handler/theme_handler.rs` 中新增公开端点 handler(无需认证): ```rust /// 品牌信息公开响应(不含内部配置) #[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] pub struct PublicBrandResp { pub brand_name: String, pub brand_slogan: String, pub brand_features: String, pub brand_copyright: String, } /// GET /api/v1/public/brand — 公开品牌信息(无需认证) pub async fn get_public_brand() -> JsonResponse> { let defaults = default_theme(); JsonResponse(ApiResponse::ok(PublicBrandResp { brand_name: defaults.brand_name.unwrap_or_else(|| "HMS 健康管理平台".into()), brand_slogan: defaults.brand_slogan.unwrap_or_else(|| "新一代健康管理平台".into()), brand_features: defaults.brand_features.unwrap_or_else(|| "患者管理 · 健康监测 · 随访管理 · AI 智能分析".into()), brand_copyright: defaults.brand_copyright.unwrap_or_else(|| "HMS 健康管理平台 · ©汕头市智界科技有限公司".into()), })) } ``` > **注意**: 此端点需要在 `erp-server` 的公开路由中注册。先完成此 Task 的代码修改,路由注册在单独步骤中处理。公开端点暂时返回默认值,后续可通过 settings 读取租户自定义配置。 - [ ] **Step 5: 验证编译** Run: `cargo check` Expected: 编译通过 - [ ] **Step 6: 注册公开路由** 在 `crates/erp-server/src/main.rs`(或对应的路由注册文件)中,找到公开路由组(通常包含 `/login`、`/register` 等),添加: ```rust // 在公开路由区域添加 .route("/api/v1/public/brand", get(config_handler::get_public_brand)) ``` 需要导入 handler:`use erp_config::handler as config_handler;`(或按项目已有模式导入)。 > 在 `erp-config/src/module.rs` 的 `ErpModule::public_routes()` 中注册此路由。 - [ ] **Step 7: 验证端点可用** Run: `cargo run`(启动后端) Run: `curl http://localhost:3000/api/v1/public/brand` Expected: `{"success":true,"data":{"brand_name":"HMS 健康管理平台",...}}` - [ ] **Step 8: 提交** ```bash git add crates/erp-config/src/dto.rs crates/erp-config/src/handler/theme_handler.rs git commit -m "feat(config): ThemeResp 增加品牌字段 + 公开品牌信息端点" ``` --- ### Task 3: 前端主题设置联动 — API + Store + 主题设置页面 **Files:** - Modify: `apps/web/src/api/themes.ts` - Modify: `apps/web/src/stores/app.ts` - Modify: `apps/web/src/pages/settings/ThemeSettings.tsx` - [ ] **Step 1: 扩展 ThemeConfig 接口** 修改 `apps/web/src/api/themes.ts`: ```typescript import client from './client'; export interface ThemeConfig { primary_color?: string; logo_url?: string; sidebar_style?: 'light' | 'dark'; brand_name?: string; brand_slogan?: string; brand_features?: string; brand_copyright?: string; } export async function getTheme() { const { data } = await client.get<{ success: boolean; data: ThemeConfig }>( '/config/themes', ); return data.data; } export async function updateTheme(theme: ThemeConfig) { const { data } = await client.put<{ success: boolean; data: ThemeConfig }>( '/config/themes', theme, ); return data.data; } export interface BrandConfig { brand_name: string; brand_slogan: string; brand_features: string; brand_copyright: string; } const BRAND_DEFAULTS: BrandConfig = { brand_name: 'HMS 健康管理平台', brand_slogan: '新一代健康管理平台', brand_features: '患者管理 · 健康监测 · 随访管理 · AI 智能分析', brand_copyright: 'HMS 健康管理平台 · ©汕头市智界科技有限公司', }; export async function getPublicBrand(): Promise { try { const res = await fetch('/api/v1/public/brand'); const json = await res.json(); if (json?.success && json?.data) return json.data; } catch {} return BRAND_DEFAULTS; } ``` - [ ] **Step 2: 扩展 app store — 添加 themeConfig 缓存** 修改 `apps/web/src/stores/app.ts`,添加 themeConfig 和 loadThemeConfig: ```typescript import { create } from 'zustand'; import type { ThemeConfig } from '../api/themes'; import { getTheme } from '../api/themes'; export type ThemeName = 'blue' | 'warm' | 'dark' | 'emerald'; // ... (THEME_OPTIONS 保持不变) const STORAGE_KEY = 'hms-theme'; const THEME_CONFIG_KEY = 'hms-theme-config'; function loadTheme(): ThemeName { try { const saved = localStorage.getItem(STORAGE_KEY); if (saved && THEME_OPTIONS.some((t) => t.key === saved)) return saved as ThemeName; } catch {} return 'blue'; } interface AppState { theme: ThemeName; sidebarCollapsed: boolean; themeConfig: ThemeConfig | null; toggleSidebar: () => void; setTheme: (theme: ThemeName) => void; loadThemeConfig: () => Promise; } export const useAppStore = create((set) => ({ theme: loadTheme(), sidebarCollapsed: false, themeConfig: null, toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })), setTheme: (theme) => { try { localStorage.setItem(STORAGE_KEY, theme); } catch {} set({ theme }); }, loadThemeConfig: async () => { try { const config = await getTheme(); try { localStorage.setItem(THEME_CONFIG_KEY, JSON.stringify(config)); } catch {} set({ themeConfig: config }); } catch {} }, })); ``` - [ ] **Step 3: 在 MainLayout mount 时加载 themeConfig** 修改 `apps/web/src/layouts/MainLayout.tsx`,在组件中添加 loadThemeConfig 调用: 在组件函数体内,现有 useEffect 附近添加: ```typescript const loadThemeConfig = useAppStore((s) => s.loadThemeConfig); useEffect(() => { loadThemeConfig(); }, [loadThemeConfig]); ``` - [ ] **Step 4: 扩展 ThemeSettings 页面** 修改 `apps/web/src/pages/settings/ThemeSettings.tsx`,在现有表单的保存按钮之前,添加品牌信息区域: ```tsx import { useEffect, useState, useCallback } from 'react'; import { Form, Input, Select, Button, ColorPicker, message, Typography, Divider } from 'antd'; import { getTheme, updateTheme } from '../../api/themes'; export default function ThemeSettings() { const [form] = Form.useForm(); const [, setLoading] = useState(false); const [saving, setSaving] = useState(false); const fetchTheme = useCallback(async () => { setLoading(true); try { const theme = await getTheme(); form.setFieldsValue({ primary_color: theme.primary_color || '#1677ff', logo_url: theme.logo_url || '', sidebar_style: theme.sidebar_style || 'light', brand_name: theme.brand_name || '', brand_slogan: theme.brand_slogan || '', brand_features: theme.brand_features || '', brand_copyright: theme.brand_copyright || '', }); } catch { form.setFieldsValue({ primary_color: '#1677ff', logo_url: '', sidebar_style: 'light', }); } setLoading(false); }, [form]); useEffect(() => { fetchTheme(); }, [fetchTheme]); const handleSave = async (values: Record) => { setSaving(true); try { const primaryColor = typeof values.primary_color === 'string' ? values.primary_color : (values.primary_color as { toHexString?: () => string })?.toHexString?.() ?? String(values.primary_color); await updateTheme({ primary_color: primaryColor, logo_url: values.logo_url as string, sidebar_style: values.sidebar_style as 'light' | 'dark', brand_name: (values.brand_name as string) || undefined, brand_slogan: (values.brand_slogan as string) || undefined, brand_features: (values.brand_features as string) || undefined, brand_copyright: (values.brand_copyright as string) || undefined, }); message.success('主题设置已保存'); } catch (err: unknown) { const errorMsg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || '保存失败'; message.error(errorMsg); } setSaving(false); }; return (
主题设置
); } ``` - [ ] **Step 5: 验证前端编译** Run: `cd apps/web && pnpm build` Expected: 构建成功 - [ ] **Step 6: 提交** ```bash git add apps/web/src/api/themes.ts apps/web/src/stores/app.ts apps/web/src/pages/settings/ThemeSettings.tsx git commit -m "feat(web): 主题设置增加品牌信息字段 + store 缓存 + 公开品牌 API" ``` --- ### Task 4: Login.tsx 和 MainLayout.tsx 从主题配置读取品牌信息 **Files:** - Modify: `apps/web/src/pages/Login.tsx:35-56,105-107` - Modify: `apps/web/src/layouts/MainLayout.tsx:434,526-528` - [ ] **Step 1: 修改 Login.tsx — 从公开端点读取品牌信息** 在 `apps/web/src/pages/Login.tsx` 中: 1. 添加 `useEffect, useState` 导入(如尚未导入) 2. 添加 brandConfig 状态和 useEffect 获取逻辑 3. 替换硬编码品牌文字 在组件函数体开头添加: ```typescript const [brandConfig, setBrandConfig] = useState<{ brand_name?: string; brand_slogan?: string; brand_features?: string; brand_copyright?: string; } | null>(null); useEffect(() => { const cached = localStorage.getItem('hms-theme-config'); if (cached) { try { setBrandConfig(JSON.parse(cached)); } catch {} } fetch('/api/v1/public/brand') .then(res => res.json()) .then(data => { if (data?.success && data?.data) { setBrandConfig(data.data); localStorage.setItem('hms-theme-config', JSON.stringify(data.data)); } }) .catch(() => {}); }, []); ``` 替换第 40-42 行硬编码文字: ```tsx

{brandConfig?.brand_name || 'HMS 健康管理平台'}

{brandConfig?.brand_slogan || '新一代健康管理平台'}

{brandConfig?.brand_features || '患者管理 · 健康监测 · 随访管理 · AI 智能分析'}

``` 替换第 106 行版权文字: ```tsx
{brandConfig?.brand_copyright || 'HMS 健康管理平台 · ©汕头市智界科技有限公司'}
``` - [ ] **Step 2: 修改 MainLayout.tsx — Footer 和 Logo 从配置读取** 在 `apps/web/src/layouts/MainLayout.tsx` 中: 1. 获取 themeConfig:在组件函数体内添加: ```typescript const themeConfig = useAppStore((s) => s.themeConfig); ``` 2. 替换第 434 行侧边栏 Logo 文字: ```tsx {themeConfig?.brand_name || 'HMS 健康'} ``` 3. 替换第 527 行 Footer 文字: ```tsx
{themeConfig?.brand_copyright || 'HMS 健康管理平台'}
``` - [ ] **Step 3: 验证前端编译** Run: `cd apps/web && pnpm build` Expected: 构建成功 - [ ] **Step 4: 浏览器验证** 1. 打开登录页,确认显示 "HMS 健康管理平台"(默认值) 2. 登录后进入系统设置 → 主题设置 3. 修改品牌名称为 "XX 医院健康管理" 4. 保存后刷新页面,确认侧边栏/页脚更新 5. 登出后确认登录页显示新品牌名称 - [ ] **Step 5: 提交** ```bash git add apps/web/src/pages/Login.tsx apps/web/src/layouts/MainLayout.tsx git commit -m "feat(web): Login/MainLayout 从主题配置读取品牌信息" ``` --- ### Task 5: 告警和行动收件箱侧边栏菜单入口 **Files:** - Modify: `apps/web/src/layouts/MainLayout.tsx:1-32,51-75` - [ ] **Step 1: 在 MainLayout.tsx 补充图标导入** 在 `apps/web/src/layouts/MainLayout.tsx` 的 import 区域(第 31 行 `BarChartOutlined` 之后)添加: ```typescript AlertOutlined, BellOutlined, ControlOutlined, InboxOutlined, ApiOutlined, ReadOutlined, ExperimentOutlined, ``` - [ ] **Step 2: 在 iconMap 中补充图标映射** 在 `iconMap` 对象(第 74 行 `BarChartOutlined` 之后)添加: ```typescript AlertOutlined: , BellOutlined: , ControlOutlined: , InboxOutlined: , ApiOutlined: , ReadOutlined: , ExperimentOutlined: , ``` - [ ] **Step 3: 验证前端编译** Run: `cd apps/web && pnpm build` Expected: 构建成功 - [ ] **Step 4: 提交** ```bash git add apps/web/src/layouts/MainLayout.tsx git commit -m "fix(web): 补充告警/行动收件箱/设备等菜单图标映射" ``` --- ### Task 6: 行动收件箱菜单种子迁移 **Files:** - Create: `crates/erp-server/migration/src/m20260501_000100_seed_action_inbox_menu.rs` - Modify: `crates/erp-server/migration/src/lib.rs:207` - [ ] **Step 1: 创建迁移文件** 创建 `crates/erp-server/migration/src/m20260501_000100_seed_action_inbox_menu.rs`: ```rust use sea_orm_migration::prelude::*; #[derive(DeriveMigrationName)] pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { let db = manager.get_connection(); // 插入行动收件箱菜单 db.execute_unprepared( r#" INSERT INTO menus (id, tenant_id, parent_id, title, path, icon, sort_order, created_at, updated_at, is_active) SELECT 'b0000003-0000-7000-8000-000000000020'::uuid, t.id, (SELECT id FROM menus WHERE path = '/health' AND tenant_id = t.id LIMIT 1), '行动收件箱', '/health/action-inbox', 'InboxOutlined', 36, NOW(), NOW(), true FROM tenants t WHERE NOT EXISTS ( SELECT 1 FROM menus WHERE path = '/health/action-inbox' AND tenant_id = t.id ) "#, ) .await?; // 关联权限码 db.execute_unprepared( r#" INSERT INTO role_permissions (role_id, permission_code, tenant_id) SELECT r.id, 'health.action-inbox.list', t.id FROM tenants t JOIN roles r ON r.tenant_id = t.id AND r.code = 'admin' WHERE NOT EXISTS ( SELECT 1 FROM role_permissions rp WHERE rp.permission_code = 'health.action-inbox.list' AND rp.role_id = r.id ) "#, ) .await?; Ok(()) } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { let db = manager.get_connection(); db.execute_unprepared( "DELETE FROM menus WHERE path = '/health/action-inbox'", ) .await?; Ok(()) } } ``` - [ ] **Step 2: 在 lib.rs 中注册迁移** 在 `crates/erp-server/migration/src/lib.rs` 中: 1. 添加模块声明(在文件末尾 `mod` 声明区域): ```rust mod m20260501_000100_seed_action_inbox_menu; ``` 2. 在 migration list 的最后一项 `m20260501_000099_create_ai_risk_threshold::Migration` 之后添加: ```rust Box::new(m20260501_000100_seed_action_inbox_menu::Migration), ``` - [ ] **Step 3: 验证编译** Run: `cargo check` Expected: 编译通过 - [ ] **Step 4: 提交** ```bash git add crates/erp-server/migration/src/m20260501_000100_seed_action_inbox_menu.rs crates/erp-server/migration/src/lib.rs git commit -m "feat(migration): 行动收件箱菜单种子数据 + 权限关联" ``` --- ### Task 7: 危急值告警端点 500 修复 **Files:** - Modify: `crates/erp-health/src/service/critical_alert_service.rs` - [ ] **Step 1: 添加 tracing 日志** 在 `crates/erp-health/src/service/critical_alert_service.rs` 的 `list_pending_alerts` 函数中,找到查询执行的 `.await`,在错误处理链中添加 tracing: ```rust // 找到类似以下代码,添加 tracing::error! .all(&state.db) .await .map_err(|e| { tracing::error!(error = %e, "查询危急值告警列表失败"); HealthError::DbError(e.to_string()) })?; ``` 对 `get_alert` 函数同理添加。 - [ ] **Step 2: 验证数据库状态并测试** 启动后端 `cargo run`,用 curl 测试端点,查看 tracing 日志确认根因。 根据日志输出决定下一步: - 如果是表不存在 → 确认迁移 m000090 是否执行 - 如果是 RLS 策略缺失 → 创建 Task 7b RLS 补齐迁移 - 如果是其他错误 → 根据日志修复 > **此步骤需要运行时验证,根据实际错误决定是否需要创建 RLS 补齐迁移。** - [ ] **Step 3: 提交** ```bash git add crates/erp-health/src/service/critical_alert_service.rs git commit -m "fix(health): 危急值告警查询添加 tracing 日志" ``` --- ### Task 8: domain_events 堆积清理 **Files:** - Modify: `crates/erp-server/src/tasks.rs:26` - [ ] **Step 1: 修改事件保留期** 在 `crates/erp-server/src/tasks.rs` 第 26 行,将 90 天改为 7 天: ```rust // 修改前 "SELECT cleanup_old_published_events(90, 1000)" // 修改后 "SELECT cleanup_old_published_events(7, 1000)" ``` 同时更新第 6 行注释: ```rust /// - 调用 `cleanup_old_published_events()` 归档 >7 天的已发布事件 ``` - [ ] **Step 2: 手动清理当前堆积** 在数据库中执行 SQL 清理已发布事件: ```sql INSERT INTO domain_events_archive (id, tenant_id, event_type, payload, correlation_id, status, attempts, last_error, created_at, published_at) SELECT id, tenant_id, event_type, payload, correlation_id, status, attempts, last_error, created_at, published_at FROM domain_events WHERE status = 'published'; DELETE FROM domain_events WHERE status = 'published' AND id IN (SELECT id FROM domain_events_archive); ``` > **此步骤需要手动执行 SQL,不在代码中自动化。** - [ ] **Step 3: 验证编译** Run: `cargo check` Expected: 编译通过 - [ ] **Step 4: 提交** ```bash git add crates/erp-server/src/tasks.rs git commit -m "fix(server): 事件清理保留期从 90 天调整为 7 天" ``` --- ## Phase 1 验证清单 完成所有 Task 后执行: - [ ] `cargo check` — 全 workspace 编译通过 - [ ] `cargo test -p erp-plugin` — 测试通过 - [ ] `cd apps/web && pnpm build` — 前端构建通过 - [ ] 浏览器验证:登录页品牌信息、侧边栏菜单图标、主题设置品牌编辑 - [ ] `git push` — 推送到远程 --- ## Chunk 2: Phase 2 — 体验补全 (#7-#11) ### Task 9: 患者详情快捷导航卡片 **Files:** - Modify: `apps/web/src/pages/health/PatientDetail.tsx:222-224` - [ ] **Step 1: 在 PatientDetail.tsx 插入快捷导航卡片** 在第 222 行(患者信息卡片 `` 闭合标签)和第 224 行(Tabs `` 开始标签)之间插入: ```tsx {/* 快捷导航 */} 快捷跳转: ``` 确认 `Button`、`Space`、`Text` 已在文件顶部 import(通常已导入)。确认 `navigate` 来自 `useNavigate()`。 - [ ] **Step 2: 验证编译** Run: `cd apps/web && pnpm build` Expected: 构建成功 - [ ] **Step 3: 提交** ```bash git add apps/web/src/pages/health/PatientDetail.tsx git commit -m "feat(web): 患者详情页增加快捷导航卡片 — 预约/咨询/透析/随访/AI" ``` --- ### Task 10: 列表页面支持 URL 参数 patient_id 过滤 **Files:** - Modify: `apps/web/src/pages/health/AppointmentList.tsx` - Modify: `apps/web/src/pages/health/ConsultationList.tsx` - Modify: `apps/web/src/pages/health/DialysisManageList.tsx` - Modify: `apps/web/src/pages/health/FollowUpTaskList.tsx` - Modify: `apps/web/src/pages/health/AiAnalysisList.tsx` 这 5 个页面需要支持从 URL 读取 `patient_id` 参数作为默认筛选条件。统一模式如下: - [ ] **Step 1: 逐一检查各页面当前的筛选逻辑** 对每个页面,确认: - 是否已使用 `useSearchParams()` 或 `useLocation()` 读取 URL 参数 - 筛选器状态如何初始化(useState 初始值) - API 调用的参数传递方式 > 此步骤为调研,不修改代码。根据调研结果决定每个页面的具体修改方案。 - [ ] **Step 2: 为每个页面添加 URL patient_id 读取** 统一模式:在组件初始化时从 URL 读取 `patient_id`,设为筛选器默认值。 ```typescript import { useSearchParams } from 'react-router-dom'; // 在组件函数体内 const [searchParams] = useSearchParams(); const urlPatientId = searchParams.get('patient_id'); // 在筛选器状态中设置默认值 const [filters, setFilters] = useState({ // ... 已有筛选器 patient_id: urlPatientId || undefined, }); ``` 如果页面使用 `PatientSelect` 组件,将 `urlPatientId` 作为初始值传入。 - [ ] **Step 3: 验证编译** Run: `cd apps/web && pnpm build` Expected: 构建成功 - [ ] **Step 4: 提交** ```bash git add apps/web/src/pages/health/AppointmentList.tsx \ apps/web/src/pages/health/ConsultationList.tsx \ apps/web/src/pages/health/DialysisManageList.tsx \ apps/web/src/pages/health/FollowUpTaskList.tsx \ apps/web/src/pages/health/AiAnalysisList.tsx git commit -m "feat(web): 5 个列表页支持 URL 参数 patient_id 自动筛选" ``` --- ### Task 11: AI 分析列表添加患者 Link **Files:** - Modify: `apps/web/src/pages/health/AiAnalysisList.tsx:1-2,316-323` - [ ] **Step 1: 添加 Link 导入** 在 `apps/web/src/pages/health/AiAnalysisList.tsx` 顶部 import 区域添加: ```typescript import { Link } from 'react-router-dom'; ``` - [ ] **Step 2: 修改 patient_id 列渲染** 将第 316-323 行的列定义从: ```tsx { title: '患者 ID', dataIndex: 'patient_id', key: 'patient_id', width: 120, render: (v: string) => ( {v.slice(0, 8)} ), }, ``` 改为: ```tsx { title: '患者', dataIndex: 'patient_id', key: 'patient_id', width: 140, render: (v: string) => ( {v.slice(0, 8)} ), }, ``` - [ ] **Step 3: 验证编译** Run: `cd apps/web && pnpm build` Expected: 构建成功 - [ ] **Step 4: 提交** ```bash git add apps/web/src/pages/health/AiAnalysisList.tsx git commit -m "feat(web): AI 分析列表患者 ID 改为可点击 Link 跳转详情" ``` --- ### Task 12: 小程序 AI 建议跳转修复 **Files:** - Modify: `apps/miniprogram/src/pages/health/index.tsx:178` - [ ] **Step 1: 修改 AI 建议卡片 onClick** 在 `apps/miniprogram/src/pages/health/index.tsx` 第 178 行,将: ```tsx Taro.navigateTo({ url: '/pages/pkg-profile/settings/index' })}> ``` 改为: ```tsx { const firstSuggestion = aiSuggestions[0]; if (firstSuggestion?.suggestion_type === 'appointment') { Taro.navigateTo({ url: `/pages/pkg-appointment/create/index?patientId=${firstSuggestion.patient_id}` }); } else if (firstSuggestion?.suggestion_type === 'followup') { Taro.navigateTo({ url: `/pages/pkg-profile/followups/index` }); } else { Taro.navigateTo({ url: '/pages/health/index' }); } }}> ``` > **注意**: 卡片整体只有一个 onClick,跳转基于第一条建议的类型。如果需要每条建议单独点击,需要将 onClick 移到 `ai-suggestion-item` 上并传入具体 item。 - [ ] **Step 2: 验证小程序编译** Run: `cd apps/miniprogram && pnpm build:weapp` Expected: 构建成功 - [ ] **Step 3: 提交** ```bash git add apps/miniprogram/src/pages/health/index.tsx git commit -m "fix(miniprogram): AI 建议卡片按 suggestion_type 跳转 — 而非统一跳设置页" ``` --- ### Task 13: 小程序通知 Tab 对接消息 API **Files:** - Create: `apps/miniprogram/src/services/notification.ts` - Modify: `apps/miniprogram/src/pages/messages/index.tsx:34-38` - [ ] **Step 1: 创建通知服务** 创建 `apps/miniprogram/src/services/notification.ts`: ```typescript import { api } from './request'; export const notificationService = { list: (params?: { page?: number; page_size?: number }) => api.get('/messages', params as Record), markRead: (id: string) => api.put(`/messages/${id}/read`), markAllRead: () => api.put('/messages/read-all'), getUnreadCount: () => api.get('/messages/unread-count'), }; ``` - [ ] **Step 2: 替换硬编码空数组** 在 `apps/miniprogram/src/pages/messages/index.tsx` 中: 1. 在顶部添加导入: ```typescript import { notificationService } from '../../services/notification'; ``` 2. 将第 35-38 行的 `else` 分支从: ```typescript } else { // 通知目前从咨询中提取状态变化作为示例 // 后续可对接独立通知 API setNotifications([]); } ``` 改为: ```typescript } else { const res = await notificationService.list({ page: 1, page_size: 20 }); setNotifications(res?.data || []); } ``` - [ ] **Step 3: 验证小程序编译** Run: `cd apps/miniprogram && pnpm build:weapp` Expected: 构建成功 - [ ] **Step 4: 提交** ```bash git add apps/miniprogram/src/services/notification.ts apps/miniprogram/src/pages/messages/index.tsx git commit -m "feat(miniprogram): 通知 Tab 对接 erp-message 消息 API — 替换空壳" ``` --- ### Task 14: 小程序咨询功能入口 **Files:** - Modify: `apps/miniprogram/src/pages/profile/index.tsx:8-22,24-38` - [ ] **Step 1: 在 MENU_ITEMS 添加在线咨询** 在 `apps/miniprogram/src/pages/profile/index.tsx` 的 `MENU_ITEMS` 数组(第 21 行 "设置" 之前)添加: ```typescript { label: '我的预约', icon: '📅', bg: '#E8F0F8' }, { label: '在线咨询', icon: '💬', bg: '#E8F0E8' }, ``` - [ ] **Step 2: 在 MENU_PATHS 添加路径映射** 在 `MENU_PATHS`(第 24-38 行)添加对应路径: ```typescript '我的预约': '/pages/pkg-appointment/index', '在线咨询': '/pages/consultation/index', ``` > **注意**: 需确认 `/pages/pkg-appointment/index` 路径存在。如果预约页面路径不同,按实际路径填写。 - [ ] **Step 3: 验证小程序编译** Run: `cd apps/miniprogram && pnpm build:weapp` Expected: 构建成功 - [ ] **Step 4: 提交** ```bash git add apps/miniprogram/src/pages/profile/index.tsx git commit -m "feat(miniprogram): 个人中心添加我的预约+在线咨询入口" ``` --- ## Phase 2 验证清单 完成所有 Task 后执行: - [ ] `cd apps/web && pnpm build` — Web 前端构建通过 - [ ] `cd apps/miniprogram && pnpm build:weapp` — 小程序构建通过 - [ ] 浏览器验证:患者详情快捷导航 → 目标页面自动过滤 - [ ] 浏览器验证:AI 分析列表患者 Link 跳转正常 - [ ] 小程序验证:AI 建议卡片跳转到正确页面(非设置页) - [ ] 小程序验证:消息页通知 Tab 展示后端消息数据 - [ ] 小程序验证:个人中心出现"在线咨询"入口 - [ ] `git push` — 推送到远程 --- ## Chunk 3: Phase 3 — 功能闭环 (#12-#15) ### Task 15: SSE 分析 API 封装 **Files:** - Create: `apps/web/src/api/ai/analysisSse.ts` - [ ] **Step 1: 创建 SSE 分析 API 文件** 创建 `apps/web/src/api/ai/analysisSse.ts`: ```typescript import client from '../client'; export type AnalysisType = 'lab-report' | 'trends' | 'checkup-plan' | 'report-summary'; interface AnalyzeBody { report_id?: string; patient_id?: string; metrics?: string[]; } const ENDPOINT_MAP: Record = { 'lab-report': '/ai/analyze/lab-report', 'trends': '/ai/analyze/trends', 'checkup-plan': '/ai/analyze/checkup-plan', 'report-summary': '/ai/analyze/report-summary', }; export interface SseCallbacks { onChunk: (content: string, index: number) => void; onError: (message: string) => void; onDone: (analysisId: string) => void; } export async function startAnalysis( type: AnalysisType, body: AnalyzeBody, callbacks: SseCallbacks, ): Promise { const controller = new AbortController(); const endpoint = ENDPOINT_MAP[type]; const token = localStorage.getItem('hms-token'); const resp = await fetch(`/api/v1${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify(body), signal: controller.signal, }); if (!resp.ok) { const err = await resp.json().catch(() => ({ message: '分析请求失败' })); callbacks.onError(err?.message || `HTTP ${resp.status}`); return controller; } const reader = resp.body?.getReader(); if (!reader) { callbacks.onError('无法读取响应流'); return controller; } const decoder = new TextDecoder(); let chunkIndex = 0; let buffer = ''; (async () => { try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(6); if (data === '[DONE]') { continue; } try { const event = JSON.parse(data); if (event.type === 'chunk' && event.content) { callbacks.onChunk(event.content, chunkIndex++); } else if (event.type === 'done' && event.analysis_id) { callbacks.onDone(event.analysis_id); } else if (event.type === 'error') { callbacks.onError(event.message || '分析出错'); } } catch { // 非 JSON 行,跳过 } } } } } catch (err) { if (!controller.signal.aborted) { callbacks.onError(err instanceof Error ? err.message : '连接中断'); } } })(); return controller; } ``` - [ ] **Step 2: 验证编译** Run: `cd apps/web && npx tsc --noEmit` Expected: 无类型错误 - [ ] **Step 3: 提交** ```bash git add apps/web/src/api/ai/analysisSse.ts git commit -m "feat(web): SSE 分析 API 封装 — fetch ReadableStream 解析" ``` --- ### Task 16: AI 分析触发按钮 — 化验报告页 **Files:** - Modify: `apps/web/src/pages/health/components/LabReportsTab.tsx:138-161` - [ ] **Step 1: 在操作列 Space 中添加 "AI 解读" 按钮** 在 `LabReportsTab.tsx` 操作列(第 138-161 行)的 `` 内,在"审核"按钮之前添加: ```tsx ``` - [ ] **Step 2: 添加分析状态和处理函数** 在组件函数体中添加: ```typescript import { startAnalysis } from '../../../api/ai/analysisSse'; import { ThunderboltOutlined } from '@ant-design/icons'; const [analyzingReportId, setAnalyzingReportId] = useState(null); const [analysisContent, setAnalysisContent] = useState(''); const handleAiAnalysis = async (reportId: string) => { setAnalyzingReportId(reportId); setAnalysisContent(''); await startAnalysis('lab-report', { report_id: reportId }, { onChunk: (content) => setAnalysisContent(prev => prev + content), onError: (msg) => { message.error(msg); setAnalyzingReportId(null); }, onDone: (id) => { message.success('AI 分析完成'); setAnalyzingReportId(null); }, }); }; ``` - [ ] **Step 3: 添加 AI 分析结果展示** 在 Table 下方添加条件渲染区域: ```tsx {analysisContent && (
{analysisContent}
)} ``` - [ ] **Step 4: 验证编译** Run: `cd apps/web && pnpm build` Expected: 构建成功 - [ ] **Step 5: 提交** ```bash git add apps/web/src/pages/health/components/LabReportsTab.tsx git commit -m "feat(web): 化验报告页添加 AI 解读按钮 — SSE 流式分析" ``` --- ### Task 17: AI 分析触发按钮 — 患者详情页 **Files:** - Modify: `apps/web/src/pages/health/PatientDetail.tsx` - [ ] **Step 1: 在 AI 建议标签页上方添加分析触发按钮** 在 `PatientDetail.tsx` 的 Tabs 卡片中,AI 建议标签页(key='ai')的 children 内,或通过修改 `AiSuggestionTab` 组件添加操作按钮。 推荐方案:在 PatientDetail 的 tabs items 中,AI 标签页之前添加操作: 在 `ai` tab 的 children 中包裹一个带按钮的 Fragment: ```tsx { key: 'ai', label: 'AI 建议', children: id ? ( {analysisResult && (
{analysisResult}
)}
) : null, }, ``` 添加对应的状态和处理函数: ```typescript import { startAnalysis, type AnalysisType } from '../../api/ai/analysisSse'; const [analysisResult, setAnalysisResult] = useState(''); const [analyzing, setAnalyzing] = useState(false); const triggerAnalysis = async (type: AnalysisType) => { if (!id) return; setAnalyzing(true); setAnalysisResult(''); await startAnalysis(type, { patient_id: id }, { onChunk: (content) => setAnalysisResult(prev => prev + content), onError: (msg) => { message.error(msg); setAnalyzing(false); }, onDone: () => { message.success('分析完成'); setAnalyzing(false); }, }); }; ``` - [ ] **Step 2: 验证编译** Run: `cd apps/web && pnpm build` Expected: 构建成功 - [ ] **Step 3: 提交** ```bash git add apps/web/src/pages/health/PatientDetail.tsx git commit -m "feat(web): 患者详情 AI 标签页添加趋势分析+体检方案触发按钮" ``` --- ### Task 18: 家属管理 Tab 组件 **Files:** - Create: `apps/web/src/pages/health/components/FamilyMembersTab.tsx` - Modify: `apps/web/src/pages/health/PatientDetail.tsx:228-306` - [ ] **Step 1: 创建 FamilyMembersTab 组件** 创建 `apps/web/src/pages/health/components/FamilyMembersTab.tsx`: ```tsx import { useCallback, useEffect, useState } from 'react'; import { Table, Button, Form, Input, Select, Drawer, message, Popconfirm, Space } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; import { patientApi, type FamilyMember, type CreateFamilyMemberReq } from '../../../api/health/patients'; import { AuthButton } from '../../../components/AuthButton'; const RELATIONSHIP_OPTIONS = [ { label: '父母', value: 'parent' }, { label: '配偶', value: 'spouse' }, { label: '子女', value: 'child' }, { label: '兄弟姐妹', value: 'sibling' }, { label: '其他', value: 'other' }, ]; interface Props { patientId: string; } export function FamilyMembersTab({ patientId }: Props) { const [members, setMembers] = useState([]); const [loading, setLoading] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); const [editingMember, setEditingMember] = useState(null); const [form] = Form.useForm(); const fetchMembers = useCallback(async () => { setLoading(true); try { const data = await patientApi.listFamilyMembers(patientId); setMembers(data); } catch { message.error('加载家属列表失败'); } setLoading(false); }, [patientId]); useEffect(() => { fetchMembers(); }, [fetchMembers]); const handleSubmit = async (values: CreateFamilyMemberReq) => { try { if (editingMember) { await patientApi.updateFamilyMember(patientId, editingMember.id, { ...values, version: editingMember.version }); message.success('家属信息已更新'); } else { await patientApi.createFamilyMember(patientId, values); message.success('家属已添加'); } setDrawerOpen(false); setEditingMember(null); form.resetFields(); fetchMembers(); } catch (err: unknown) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败'; message.error(msg); } }; const handleDelete = async (memberId: string) => { try { await patientApi.deleteFamilyMember(patientId, memberId); message.success('已删除'); fetchMembers(); } catch { message.error('删除失败'); } }; const openCreate = () => { setEditingMember(null); form.resetFields(); setDrawerOpen(true); }; const openEdit = (member: FamilyMember) => { setEditingMember(member); form.setFieldsValue({ name: member.name, relationship: member.relationship, phone: member.phone, id_number: member.id_number, notes: member.notes, }); setDrawerOpen(true); }; const columns = [ { title: '姓名', dataIndex: 'name', key: 'name' }, { title: '关系', dataIndex: 'relationship', key: 'relationship', render: (v: string) => RELATIONSHIP_OPTIONS.find(r => r.value === v)?.label || v, }, { title: '电话', dataIndex: 'phone', key: 'phone' }, { title: '身份证号', dataIndex: 'id_number', key: 'id_number' }, { title: '备注', dataIndex: 'notes', key: 'notes', ellipsis: true }, { title: '操作', key: 'actions', width: 150, render: (_: unknown, record: FamilyMember) => ( handleDelete(record.id)}> ), }, ]; return ( <>
{ setDrawerOpen(false); setEditingMember(null); }} width={400} >
); } ``` - [ ] **Step 2: 在 PatientDetail.tsx 注册家属管理标签** 在 `PatientDetail.tsx` 的 Tabs items 数组中,在 "基本信息"(key='info')之后添加: ```typescript import { FamilyMembersTab } from './components/FamilyMembersTab'; // 在 items 数组中 key='info' 项之后添加 { key: 'family', label: '家属管理', children: id ? : null, }, ``` - [ ] **Step 3: 验证编译** Run: `cd apps/web && pnpm build` Expected: 构建成功 - [ ] **Step 4: 提交** ```bash git add apps/web/src/pages/health/components/FamilyMembersTab.tsx apps/web/src/pages/health/PatientDetail.tsx git commit -m "feat(web): 家属管理 Tab — 列表+添加/编辑/删除家属" ``` --- ### Task 19: E2E 测试数据清理 **Files:** - Create: `apps/web/e2e/fixtures/cleanup.ts` - Modify: `apps/web/e2e/flows/patient-journey.spec.ts` - [ ] **Step 1: 创建清理 fixture** 创建 `apps/web/e2e/fixtures/cleanup.ts`: ```typescript import type { ApiClient } from './api-client'; export async function cleanupE2EData(api: ApiClient): Promise { try { const res = await api.get('/api/v1/health/patients?keyword=E2E'); const patients = res?.data?.data || []; for (const p of patients) { if (p.name?.startsWith('E2E')) { await api.delete(`/api/v1/health/patients/${p.id}`); } } } catch { // 静默失败,不阻塞测试 } } ``` - [ ] **Step 2: 在 patient-journey.spec.ts 中添加 afterAll** 在 `apps/web/e2e/flows/patient-journey.spec.ts` 中添加清理导入和 afterAll 钩子: ```typescript import { cleanupE2EData } from '../fixtures/cleanup'; afterAll(async () => { // 使用测试中已有的 api client 实例 if (apiClient) await cleanupE2EData(apiClient); }); ``` > 对其他创建 E2E 数据的 spec 文件(follow-up-flow、appointment-flow 等)同样添加 afterAll 清理。 - [ ] **Step 3: 手动清理现有污染数据** 在数据库中执行: ```sql UPDATE patients SET deleted_at = NOW(), updated_at = NOW() WHERE name LIKE 'E2E患者_%' AND deleted_at IS NULL; ``` > 此步骤需要手动执行 SQL。 - [ ] **Step 4: 提交** ```bash git add apps/web/e2e/fixtures/cleanup.ts apps/web/e2e/flows/patient-journey.spec.ts git commit -m "test(e2e): 添加 E2E 测试数据清理 fixture — afterAll 自动 teardown" ``` --- ### Task 20: 统计仪表盘消费验证 **Files:** - Read-only verification (可能修改 `DoctorDashboard.tsx` 或 `NurseDashboard.tsx`) - [ ] **Step 1: 对比后端 personal_stats DTO 与前端展示** 读取后端 `crates/erp-health/src/handler/points_handler.rs`(或 stats_handler.rs)中 `personal_stats` 返回的字段列表。 对比 `DoctorDashboard.tsx`(第 43 行起)和 `NurseDashboard.tsx`(第 20 行起)实际展示的字段。 记录哪些字段被展示、哪些被遗漏。 - [ ] **Step 2: 补充遗漏字段(如有)** 如果有遗漏字段,在对应仪表盘组件中补充展示。例如: ```tsx // 如果 abnormal_vital_signs 被遗漏 ``` - [ ] **Step 3: 提交(如有修改)** ```bash git add apps/web/src/pages/health/StatisticsDashboard/ git commit -m "feat(web): 补充统计仪表盘遗漏的个人统计指标" ``` 如果无遗漏则无需提交,记录验证结果即可。 --- ## Phase 3 验证清单 完成所有 Task 后执行: - [ ] `cd apps/web && pnpm build` — Web 前端构建通过 - [ ] 浏览器验证:化验报告页"AI 解读"按钮 → SSE 流式显示分析结果 - [ ] 浏览器验证:患者详情"趋势分析"按钮 → SSE 分析完成 - [ ] 浏览器验证:患者详情"家属管理"Tab → 添加/编辑/删除家属 - [ ] 浏览器验证:统计报表页所有指标正常展示 - [ ] E2E 清理 fixture 可正常工作 - [ ] `git push` — 推送到远程 --- ## 总工作量汇总 | Phase | Tasks | 工作量 | |-------|-------|--------| | Phase 1 | #1-#6 (Task 1-8) | ~5h | | Phase 2 | #7-#11 (Task 9-14) | ~5.5h | | Phase 3 | #12-#15 (Task 15-20) | ~9h | | **总计** | **15 项修复 / 20 个 Task** | **~19.5h** |