docs: 三端审计修复实施计划 Phase 3 — 6 个 Task (#12-#15)
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

SSE 分析 API 包装器、AI 触发按钮、家庭成员 Tab、E2E 清理夹具、统计验证
This commit is contained in:
iven
2026-05-01 17:25:29 +08:00
parent 73119fe026
commit 95d7989a9f

View File

@@ -1108,3 +1108,596 @@ git commit -m "feat(miniprogram): 个人中心添加我的预约+在线咨询入
- [ ] 小程序验证:消息页通知 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** |