Files
hms/docs/superpowers/plans/2026-05-01-tri-platform-audit-fix-plan.md
iven 95d7989a9f
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
docs: 三端审计修复实施计划 Phase 3 — 6 个 Task (#12-#15)
SSE 分析 API 包装器、AI 触发按钮、家庭成员 Tab、E2E 清理夹具、统计验证
2026-05-01 17:25:29 +08:00

1704 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 三端联调审计问题修复实施计划
> **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-#6Phase 2 补全用户体验(#7-#11Phase 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logo_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sidebar_style: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub brand_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub brand_slogan: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub brand_features: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub brand_copyright: Option<String>,
}
```
- [ ] **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<ApiResponse<PublicBrandResp>> {
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<BrandConfig> {
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<void>;
}
export const useAppStore = create<AppState>((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<string, unknown>) => {
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 (
<div>
<Typography.Title level={5} style={{ marginBottom: 16 }}></Typography.Title>
<Form form={form} onFinish={handleSave} layout="vertical" style={{ maxWidth: 480 }}>
<Form.Item name="primary_color" label="主色调">
<ColorPicker format="hex" />
</Form.Item>
<Form.Item name="logo_url" label="Logo URL">
<Input placeholder="https://example.com/logo.png" />
</Form.Item>
<Form.Item name="sidebar_style" label="侧边栏风格">
<Select options={[{ label: '亮色', value: 'light' }, { label: '暗色', value: 'dark' }]} />
</Form.Item>
<Divider></Divider>
<Form.Item name="brand_name" label="品牌名称">
<Input placeholder="HMS 健康管理平台" />
</Form.Item>
<Form.Item name="brand_slogan" label="品牌标语">
<Input placeholder="新一代健康管理平台" />
</Form.Item>
<Form.Item name="brand_features" label="功能亮点">
<Input placeholder="患者管理 · 健康监测 · 随访管理 · AI 智能分析" />
</Form.Item>
<Form.Item name="brand_copyright" label="版权信息">
<Input placeholder="HMS 健康管理平台 · ©汕头市智界科技有限公司" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={saving}></Button>
</Form.Item>
</Form>
</div>
);
}
```
- [ ] **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
<h1 className="brand-title">{brandConfig?.brand_name || 'HMS 健康管理平台'}</h1>
<p className="brand-desc">{brandConfig?.brand_slogan || '新一代健康管理平台'}</p>
<p className="brand-sub-desc">{brandConfig?.brand_features || '患者管理 · 健康监测 · 随访管理 · AI 智能分析'}</p>
```
替换第 106 行版权文字:
```tsx
<div className="form-footer">
{brandConfig?.brand_copyright || 'HMS 健康管理平台 · ©汕头市智界科技有限公司'}
</div>
```
- [ ] **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
<span className="erp-sidebar-logo-text">{themeConfig?.brand_name || 'HMS 健康'}</span>
```
3. 替换第 527 行 Footer 文字:
```tsx
<Footer className="erp-footer">
{themeConfig?.brand_copyright || 'HMS 健康管理平台'}
</Footer>
```
- [ ] **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: <AlertOutlined />,
BellOutlined: <BellOutlined />,
ControlOutlined: <ControlOutlined />,
InboxOutlined: <InboxOutlined />,
ApiOutlined: <ApiOutlined />,
ReadOutlined: <ReadOutlined />,
ExperimentOutlined: <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 行(患者信息卡片 `</Card>` 闭合标签)和第 224 行Tabs `<Card>` 开始标签)之间插入:
```tsx
{/* 快捷导航 */}
<Card style={{ ...cardStyle, marginBottom: 16, padding: '12px 16px' }}>
<Space size={8} wrap>
<Text type="secondary" style={{ marginRight: 8 }}>:</Text>
<Button type="link" size="small" onClick={() => navigate(`/health/appointments?patient_id=${id}`)}></Button>
<Button type="link" size="small" onClick={() => navigate(`/health/consultations?patient_id=${id}`)}></Button>
<Button type="link" size="small" onClick={() => navigate(`/health/dialysis?patient_id=${id}`)}></Button>
<Button type="link" size="small" onClick={() => navigate(`/health/follow-up-tasks?patient_id=${id}`)}>访</Button>
<Button type="link" size="small" onClick={() => navigate(`/health/ai-analysis?patient_id=${id}`)}>AI </Button>
</Space>
</Card>
```
确认 `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) => (
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v.slice(0, 8)}</span>
),
},
```
改为:
```tsx
{
title: '患者',
dataIndex: 'patient_id',
key: 'patient_id',
width: 140,
render: (v: string) => (
<Link to={`/health/patients/${v}`} style={{ fontFamily: 'monospace', fontSize: 12 }}>
{v.slice(0, 8)}
</Link>
),
},
```
- [ ] **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
<View className='ai-suggestion-card' onClick={() => Taro.navigateTo({ url: '/pages/pkg-profile/settings/index' })}>
```
改为:
```tsx
<View className='ai-suggestion-card' onClick={() => {
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<string, string | number | undefined>),
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<AnalysisType, string> = {
'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<AbortController> {
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 行)的 `<Space>` 内,在"审核"按钮之前添加:
```tsx
<AuthButton code="ai.analysis.manage">
<Button
type="link"
size="small"
icon={<ThunderboltOutlined />}
loading={analyzingReportId === record.id}
onClick={(e) => { e.stopPropagation(); handleAiAnalysis(record.id); }}
>
AI
</Button>
</AuthButton>
```
- [ ] **Step 2: 添加分析状态和处理函数**
在组件函数体中添加:
```typescript
import { startAnalysis } from '../../../api/ai/analysisSse';
import { ThunderboltOutlined } from '@ant-design/icons';
const [analyzingReportId, setAnalyzingReportId] = useState<string | null>(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 && (
<Card title="AI 解读结果" style={{ marginTop: 16 }} size="small">
<div style={{ whiteSpace: 'pre-wrap' }}>{analysisContent}</div>
</Card>
)}
```
- [ ] **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 ? (
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<AuthButton code="ai.analysis.manage">
<Button size="small" onClick={() => triggerAnalysis('trends')}>
</Button>
<Button size="small" onClick={() => triggerAnalysis('checkup-plan')}>
</Button>
</AuthButton>
</Space>
<AiSuggestionTab patientId={id} />
{analysisResult && (
<Card title="分析结果" size="small">
<div style={{ whiteSpace: 'pre-wrap' }}>{analysisResult}</div>
</Card>
)}
</Space>
) : 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<FamilyMember[]>([]);
const [loading, setLoading] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [editingMember, setEditingMember] = useState<FamilyMember | null>(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) => (
<Space>
<AuthButton code="health.patient.manage">
<Button type="link" size="small" onClick={() => openEdit(record)}></Button>
</AuthButton>
<AuthButton code="health.patient.manage">
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger></Button>
</Popconfirm>
</AuthButton>
</Space>
),
},
];
return (
<>
<div style={{ marginBottom: 16 }}>
<AuthButton code="health.patient.manage">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</AuthButton>
</div>
<Table
columns={columns}
dataSource={members}
rowKey="id"
loading={loading}
pagination={false}
size="small"
/>
<Drawer
title={editingMember ? '编辑家属' : '添加家属'}
open={drawerOpen}
onClose={() => { setDrawerOpen(false); setEditingMember(null); }}
width={400}
>
<Form form={form} onFinish={handleSubmit} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Input />
</Form.Item>
<Form.Item name="relationship" label="关系" rules={[{ required: true, message: '请选择关系' }]}>
<Select options={RELATIONSHIP_OPTIONS} placeholder="选择关系" />
</Form.Item>
<Form.Item name="phone" label="电话">
<Input />
</Form.Item>
<Form.Item name="id_number" label="身份证号">
<Input />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">{editingMember ? '更新' : '添加'}</Button>
</Form.Item>
</Form>
</Drawer>
</>
);
}
```
- [ ] **Step 2: 在 PatientDetail.tsx 注册家属管理标签**
`PatientDetail.tsx` 的 Tabs items 数组中,在 "基本信息"key='info')之后添加:
```typescript
import { FamilyMembersTab } from './components/FamilyMembersTab';
// 在 items 数组中 key='info' 项之后添加
{
key: 'family',
label: '家属管理',
children: id ? <FamilyMembersTab patientId={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<void> {
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 被遗漏
<Statistic title="异常体征患者" value={personalStats.abnormal_vital_signs || 0} />
```
- [ ] **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** |