diff --git a/docs/superpowers/plans/2026-05-01-tri-platform-audit-fix-plan.md b/docs/superpowers/plans/2026-05-01-tri-platform-audit-fix-plan.md index ccb923c..a8e1693 100644 --- a/docs/superpowers/plans/2026-05-01-tri-platform-audit-fix-plan.md +++ b/docs/superpowers/plans/2026-05-01-tri-platform-audit-fix-plan.md @@ -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 = { + '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** |