feat(web): SSE 分析 API 封装 + 化验报告页 AI 解读按钮
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

- 新增 analysisSse.ts SSE 流式分析 API 封装(ReadableStream 解析)
- 化验报告页操作列添加 AI 解读按钮(SSE 实时流式输出)
- 分析结果展示在 Table 下方的 Card 中
This commit is contained in:
iven
2026-05-01 18:21:40 +08:00
parent a1fa51206f
commit 8a972f8f4d
2 changed files with 207 additions and 3 deletions

View File

@@ -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<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;
}

View File

@@ -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<string, { color: string; label: string }> = {
pending: { color: 'orange', label: '待审核' },
reviewed: { color: 'green', label: '已审核' },
};
export function LabReportsTab({ patientId }: Props) {
const [modalOpen, setModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<LabReport | null>(null);
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const [reviewOpen, setReviewOpen] = useState(false);
const [reviewRecord, setReviewRecord] = useState<LabReport | null>(null);
const [reviewNotes, setReviewNotes] = useState('');
const [reviewSubmitting, setReviewSubmitting] = useState(false);
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: () => {
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) => <Tag>{v}</Tag> },
{
title: '状态', dataIndex: 'status', key: 'status', width: 90,
render: (v: string) => {
const m = STATUS_MAP[v];
return m ? <Tag color={m.color}>{m.label}</Tag> : <Tag>{v}</Tag>;
},
},
{ 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) => (
<AuthButton code="health.health-data.manage">
<Space size={0}>
<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>
{record.status === 'pending' && (
<Button type="link" size="small" icon={<AuditOutlined />} onClick={() => openReviewModal(record)}>
</Button>
)}
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEditModal(record)}>
</Button>
@@ -134,6 +202,11 @@ export function LabReportsTab({ patientId }: Props) {
style: { margin: 0 },
}}
/>
{analysisContent && (
<Card title="AI 解读结果" style={{ marginTop: 16 }} size="small">
<div style={{ whiteSpace: 'pre-wrap' }}>{analysisContent}</div>
</Card>
)}
<Modal
title={editingRecord ? '编辑化验报告' : '添加化验报告'}
open={modalOpen}
@@ -155,6 +228,41 @@ export function LabReportsTab({ patientId }: Props) {
</Form.Item>
</Form>
</Modal>
<Modal
title="审核化验报告"
open={reviewOpen}
onCancel={() => { setReviewOpen(false); setReviewRecord(null); }}
onOk={handleReview}
confirmLoading={reviewSubmitting}
destroyOnClose
width={480}
>
{reviewRecord && (
<div>
<p style={{ marginBottom: 8 }}>
<strong></strong>{reviewRecord.report_type}
</p>
<p style={{ marginBottom: 8 }}>
<strong></strong>{reviewRecord.report_date}
</p>
{reviewRecord.doctor_interpretation && (
<p style={{ marginBottom: 8 }}>
<strong></strong>{reviewRecord.doctor_interpretation}
</p>
)}
<div style={{ marginTop: 16 }}>
<label style={{ display: 'block', marginBottom: 4 }}></label>
<Input.TextArea
rows={3}
value={reviewNotes}
onChange={(e) => setReviewNotes(e.target.value)}
placeholder="填写审核意见(可选)"
/>
</div>
</div>
)}
</Modal>
</div>
);
}