From 8a972f8f4d5f94e9188c0df6cd972100cf66b60c Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 1 May 2026 18:21:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20SSE=20=E5=88=86=E6=9E=90=20API=20?= =?UTF-8?q?=E5=B0=81=E8=A3=85=20+=20=E5=8C=96=E9=AA=8C=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E9=A1=B5=20AI=20=E8=A7=A3=E8=AF=BB=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 analysisSse.ts SSE 流式分析 API 封装(ReadableStream 解析) - 化验报告页操作列添加 AI 解读按钮(SSE 实时流式输出) - 分析结果展示在 Table 下方的 Card 中 --- apps/web/src/api/ai/analysisSse.ts | 96 +++++++++++++++ .../pages/health/components/LabReportsTab.tsx | 114 +++++++++++++++++- 2 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/api/ai/analysisSse.ts diff --git a/apps/web/src/api/ai/analysisSse.ts b/apps/web/src/api/ai/analysisSse.ts new file mode 100644 index 0000000..cecf879 --- /dev/null +++ b/apps/web/src/api/ai/analysisSse.ts @@ -0,0 +1,96 @@ +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; +} diff --git a/apps/web/src/pages/health/components/LabReportsTab.tsx b/apps/web/src/pages/health/components/LabReportsTab.tsx index a04b2ca..95fb470 100644 --- a/apps/web/src/pages/health/components/LabReportsTab.tsx +++ b/apps/web/src/pages/health/components/LabReportsTab.tsx @@ -1,6 +1,6 @@ import { useCallback, useState, useMemo } from 'react'; -import { Table, Tag, Button, Modal, Form, Input, DatePicker, message, Popconfirm, Space } from 'antd'; -import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; +import { Table, Tag, Button, Modal, Form, Input, DatePicker, message, Popconfirm, Space, Card } from 'antd'; +import { PlusOutlined, EditOutlined, DeleteOutlined, AuditOutlined, ThunderboltOutlined } from '@ant-design/icons'; import { dayjs } from '../../../utils/dayjs'; import type { Dayjs } from 'dayjs'; import { healthDataApi } from '../../../api/health/healthData'; @@ -8,16 +8,41 @@ import type { LabReport } from '../../../api/health/healthData'; import { usePaginatedData } from '../../../hooks/usePaginatedData'; import { AuthButton } from '../../../components/AuthButton'; import { handleApiError } from '../../../api/client'; +import { startAnalysis } from '../../../api/ai/analysisSse'; interface Props { patientId: string; } +const STATUS_MAP: Record = { + pending: { color: 'orange', label: '待审核' }, + reviewed: { color: 'green', label: '已审核' }, +}; + export function LabReportsTab({ patientId }: Props) { const [modalOpen, setModalOpen] = useState(false); const [editingRecord, setEditingRecord] = useState(null); const [form] = Form.useForm(); const [submitting, setSubmitting] = useState(false); + const [reviewOpen, setReviewOpen] = useState(false); + const [reviewRecord, setReviewRecord] = useState(null); + const [reviewNotes, setReviewNotes] = useState(''); + const [reviewSubmitting, setReviewSubmitting] = useState(false); + 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: () => { + message.success('AI 分析完成'); + setAnalyzingReportId(null); + }, + }); + }; const fetcher = useCallback( async (page: number, pageSize: number) => { @@ -88,18 +113,61 @@ export function LabReportsTab({ patientId }: Props) { } }; + const openReviewModal = (record: LabReport) => { + setReviewRecord(record); + setReviewNotes(record.doctor_notes || ''); + setReviewOpen(true); + }; + + const handleReview = async () => { + if (!reviewRecord) return; + setReviewSubmitting(true); + try { + await healthDataApi.reviewLabReport(patientId, reviewRecord.id, { + version: reviewRecord.version, + doctor_notes: reviewNotes || undefined, + }); + message.success('审核完成'); + setReviewOpen(false); + setReviewRecord(null); + setReviewNotes(''); + refresh(); + } catch (err) { + handleApiError(err, '审核失败'); + } finally { + setReviewSubmitting(false); + } + }; + const columns = useMemo(() => [ { title: '报告日期', dataIndex: 'report_date', key: 'report_date', width: 120 }, { title: '报告类型', dataIndex: 'report_type', key: 'report_type', width: 120, render: (v: string) => {v} }, + { + title: '状态', dataIndex: 'status', key: 'status', width: 90, + render: (v: string) => { + const m = STATUS_MAP[v]; + return m ? {m.label} : {v}; + }, + }, { title: '医生解读', dataIndex: 'doctor_interpretation', key: 'doctor_interpretation', ellipsis: true }, { title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') }, { title: '操作', key: 'actions', - width: 120, + width: 180, render: (_: unknown, record: LabReport) => ( + + + + {record.status === 'pending' && ( + + )} @@ -134,6 +202,11 @@ export function LabReportsTab({ patientId }: Props) { style: { margin: 0 }, }} /> + {analysisContent && ( + +
{analysisContent}
+
+ )} + + { setReviewOpen(false); setReviewRecord(null); }} + onOk={handleReview} + confirmLoading={reviewSubmitting} + destroyOnClose + width={480} + > + {reviewRecord && ( +
+

+ 报告类型:{reviewRecord.report_type} +

+

+ 报告日期:{reviewRecord.report_date} +

+ {reviewRecord.doctor_interpretation && ( +

+ 医生解读:{reviewRecord.doctor_interpretation} +

+ )} +
+ + setReviewNotes(e.target.value)} + placeholder="填写审核意见(可选)" + /> +
+
+ )} +
); }