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 { useCallback, useState, useMemo } from 'react';
|
||||||
import { Table, Tag, Button, Modal, Form, Input, DatePicker, message, Popconfirm, Space } from 'antd';
|
import { Table, Tag, Button, Modal, Form, Input, DatePicker, message, Popconfirm, Space, Card } from 'antd';
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
import { PlusOutlined, EditOutlined, DeleteOutlined, AuditOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||||
import { dayjs } from '../../../utils/dayjs';
|
import { dayjs } from '../../../utils/dayjs';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { healthDataApi } from '../../../api/health/healthData';
|
import { healthDataApi } from '../../../api/health/healthData';
|
||||||
@@ -8,16 +8,41 @@ import type { LabReport } from '../../../api/health/healthData';
|
|||||||
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
||||||
import { AuthButton } from '../../../components/AuthButton';
|
import { AuthButton } from '../../../components/AuthButton';
|
||||||
import { handleApiError } from '../../../api/client';
|
import { handleApiError } from '../../../api/client';
|
||||||
|
import { startAnalysis } from '../../../api/ai/analysisSse';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
patientId: string;
|
patientId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { color: string; label: string }> = {
|
||||||
|
pending: { color: 'orange', label: '待审核' },
|
||||||
|
reviewed: { color: 'green', label: '已审核' },
|
||||||
|
};
|
||||||
|
|
||||||
export function LabReportsTab({ patientId }: Props) {
|
export function LabReportsTab({ patientId }: Props) {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editingRecord, setEditingRecord] = useState<LabReport | null>(null);
|
const [editingRecord, setEditingRecord] = useState<LabReport | null>(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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(
|
const fetcher = useCallback(
|
||||||
async (page: number, pageSize: number) => {
|
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(() => [
|
const columns = useMemo(() => [
|
||||||
{ title: '报告日期', dataIndex: 'report_date', key: 'report_date', width: 120 },
|
{ 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: '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: '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: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
width: 120,
|
width: 180,
|
||||||
render: (_: unknown, record: LabReport) => (
|
render: (_: unknown, record: LabReport) => (
|
||||||
<AuthButton code="health.health-data.manage">
|
<AuthButton code="health.health-data.manage">
|
||||||
<Space size={0}>
|
<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 type="link" size="small" icon={<EditOutlined />} onClick={() => openEditModal(record)}>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
@@ -134,6 +202,11 @@ export function LabReportsTab({ patientId }: Props) {
|
|||||||
style: { margin: 0 },
|
style: { margin: 0 },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{analysisContent && (
|
||||||
|
<Card title="AI 解读结果" style={{ marginTop: 16 }} size="small">
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap' }}>{analysisContent}</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
<Modal
|
<Modal
|
||||||
title={editingRecord ? '编辑化验报告' : '添加化验报告'}
|
title={editingRecord ? '编辑化验报告' : '添加化验报告'}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
@@ -155,6 +228,41 @@ export function LabReportsTab({ patientId }: Props) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user