feat(web): SSE 分析 API 封装 + 化验报告页 AI 解读按钮
- 新增 analysisSse.ts SSE 流式分析 API 封装(ReadableStream 解析) - 化验报告页操作列添加 AI 解读按钮(SSE 实时流式输出) - 分析结果展示在 Table 下方的 Card 中
This commit is contained in:
96
apps/web/src/api/ai/analysisSse.ts
Normal file
96
apps/web/src/api/ai/analysisSse.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user