Files
hms/apps/web/src/pages/health/components/LabReportsTab.tsx
iven 8a972f8f4d
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
feat(web): SSE 分析 API 封装 + 化验报告页 AI 解读按钮
- 新增 analysisSse.ts SSE 流式分析 API 封装(ReadableStream 解析)
- 化验报告页操作列添加 AI 解读按钮(SSE 实时流式输出)
- 分析结果展示在 Table 下方的 Card 中
2026-05-01 18:21:40 +08:00

269 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useState, useMemo } from 'react';
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';
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) => {
return healthDataApi.listLabReports(patientId, { page, page_size: pageSize });
},
[patientId],
);
const { data, total, page, loading, refresh } = usePaginatedData<LabReport>(fetcher, 10);
const openCreateModal = () => {
setEditingRecord(null);
form.resetFields();
setModalOpen(true);
};
const openEditModal = (record: LabReport) => {
setEditingRecord(record);
form.setFieldsValue({
report_date: dayjs(record.report_date),
report_type: record.report_type,
doctor_interpretation: record.doctor_interpretation,
});
setModalOpen(true);
};
const handleSubmit = async (values: {
report_date: Dayjs;
report_type: string;
doctor_interpretation?: string;
}) => {
setSubmitting(true);
try {
if (editingRecord) {
await healthDataApi.updateLabReport(patientId, editingRecord.id, {
report_date: values.report_date.format('YYYY-MM-DD'),
report_type: values.report_type,
doctor_interpretation: values.doctor_interpretation,
version: editingRecord.version,
});
message.success('化验报告更新成功');
} else {
await healthDataApi.createLabReport(patientId, {
report_date: values.report_date.format('YYYY-MM-DD'),
report_type: values.report_type,
doctor_interpretation: values.doctor_interpretation,
});
message.success('化验报告添加成功');
}
setModalOpen(false);
setEditingRecord(null);
form.resetFields();
refresh();
} catch (err) {
handleApiError(err, editingRecord ? '更新失败' : '添加失败');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (record: LabReport) => {
try {
await healthDataApi.deleteLabReport(patientId, record.id);
message.success('化验报告删除成功');
refresh();
} catch (err) {
handleApiError(err, '删除失败');
}
};
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: 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>
<Popconfirm title="确认删除该化验报告?" onConfirm={() => handleDelete(record)} okText="确认" cancelText="取消">
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
</AuthButton>
),
},
], [openEditModal, handleDelete]);
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button icon={<PlusOutlined />} onClick={openCreateModal}>
</Button>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: page, total, pageSize: 10,
onChange: (p) => refresh(p),
showTotal: (t) => `${t}`,
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}
onCancel={() => { setModalOpen(false); setEditingRecord(null); }}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnClose
width={520}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="report_date" label="报告日期" rules={[{ required: true, message: '请选择日期' }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="report_type" label="报告类型" rules={[{ required: true, message: '请选择类型' }]}>
<Input placeholder="如:血常规、生化全套" />
</Form.Item>
<Form.Item name="doctor_interpretation" label="医生解读">
<Input.TextArea rows={3} placeholder="检查结果解读备注" />
</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>
);
}