feat(web): 疫病溯源页(一级初报/二级清单/三级记录,#21)

This commit is contained in:
weijuesen
2026-08-12 23:13:14 +08:00
parent dc97d1cf56
commit 5ce99abeb2
4 changed files with 348 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
import { get, post, patch, del } from '../api/http';
export interface TraceRecord {
id: string;
roomId?: string;
roomName?: string;
lampTestId?: string;
consultationId?: string;
disease: string;
status: string;
origin?: string;
confidence?: number;
autoReport?: any;
checklist?: any;
analysisReport?: any;
expertNote?: string;
labNote?: string;
createdAt?: string;
}
export interface ChecklistItem {
key: string;
label: string;
internalBias: boolean;
}
export const listTraceRecords = (params?: any) => get<TraceRecord[]>('/trace-records', { params });
export const getTraceRecord = (id: string) => get<TraceRecord>(`/trace-records/${id}`);
export const createTraceRecord = (data: Partial<TraceRecord>) =>
post<TraceRecord>('/trace-records', data);
export const updateTraceRecord = (id: string, data: Partial<TraceRecord>) =>
patch<TraceRecord>(`/trace-records/${id}`, data);
export const deleteTraceRecord = (id: string) => del(`/trace-records/${id}`);
export const autoTrace = (id: string) => post<TraceRecord>(`/trace-records/${id}/auto`);
export const getTraceChecklist = (id: string) =>
get<ChecklistItem[]>(`/trace-records/${id}/checklist`);
export const submitTraceChecklist = (id: string, answers: { key: string; answer: string }[]) =>
post<TraceRecord>(`/trace-records/${id}/checklist`, { answers });
+2
View File
@@ -13,6 +13,7 @@ import {
ShoppingOutlined, ShoppingOutlined,
CameraOutlined, CameraOutlined,
TeamOutlined, TeamOutlined,
DeploymentUnitOutlined,
SettingOutlined, SettingOutlined,
LogoutOutlined, LogoutOutlined,
UserOutlined, UserOutlined,
@@ -42,6 +43,7 @@ const menuData = [
{ path: '/consumables', name: '耗材管理', icon: <ShoppingOutlined />, permission: 'consumable:read' }, { path: '/consumables', name: '耗材管理', icon: <ShoppingOutlined />, permission: 'consumable:read' },
{ path: '/inspections', name: '巡检记录', icon: <CameraOutlined />, permission: 'inspection:read' }, { path: '/inspections', name: '巡检记录', icon: <CameraOutlined />, permission: 'inspection:read' },
{ path: '/consultations', name: '专家会诊', icon: <TeamOutlined />, permission: 'consultation:read' }, { path: '/consultations', name: '专家会诊', icon: <TeamOutlined />, permission: 'consultation:read' },
{ path: '/traces', name: '疫病溯源', icon: <DeploymentUnitOutlined />, permission: 'trace:read' },
]; ];
// 角色中文名 // 角色中文名
+306
View File
@@ -0,0 +1,306 @@
import { useRef, useState } from 'react';
import {
Button, Drawer, Form, Input, Modal, Popconfirm, Radio, Select, Space, Tag, Typography, message,
} from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
import dayjs from 'dayjs';
import {
listTraceRecords,
createTraceRecord,
updateTraceRecord,
deleteTraceRecord,
autoTrace,
getTraceChecklist,
submitTraceChecklist,
type TraceRecord,
type ChecklistItem,
} from '../dal/trace';
import { authService } from '../services/auth';
const STATUS_LABELS: Record<string, { color: string; text: string }> = {
pending: { color: 'gold', text: '待溯源' },
reported: { color: 'blue', text: '已出初报' },
analysis: { color: 'green', text: '已出报告' },
archived: { color: 'default', text: '已归档' },
};
const canWrite = () => authService.hasPermission('trace:write');
export default function TracesPage() {
const actionRef = useRef<ActionType>();
const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm<Partial<TraceRecord>>();
const [detail, setDetail] = useState<TraceRecord | null>(null);
const [checklist, setChecklist] = useState<ChecklistItem[]>([]);
const [answers, setAnswers] = useState<Record<string, string>>({});
const openDetail = async (r: TraceRecord) => {
setDetail(r);
if (r.status !== 'archived') {
setChecklist(await getTraceChecklist(r.id).catch(() => [] as ChecklistItem[]));
} else {
setChecklist([]);
}
setAnswers({});
};
const columns: ProColumns<TraceRecord>[] = [
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
{ title: '病种', dataIndex: 'disease' },
{ title: '蚕房', dataIndex: 'roomName', search: false, render: (_, r) => r.roomName || '-' },
{
title: '状态',
dataIndex: 'status',
valueType: 'select',
valueEnum: Object.fromEntries(Object.entries(STATUS_LABELS).map(([k, v]) => [k, { text: v.text }])),
render: (_, r) => <Tag color={STATUS_LABELS[r.status]?.color}>{STATUS_LABELS[r.status]?.text || r.status}</Tag>,
},
{
title: '来源判定',
dataIndex: 'origin',
search: false,
render: (_, r) =>
r.origin === 'internal' ? (
<Tag color="orange"></Tag>
) : r.origin === 'external' ? (
<Tag color="blue"></Tag>
) : (
'-'
),
},
{ title: '创建时间', dataIndex: 'createdAt', search: false, render: (_, r) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') },
{
title: '操作',
valueType: 'option',
render: (_, r) => [
<a key="view" onClick={() => openDetail(r)}>
</a>,
...(canWrite()
? [
r.status === 'pending' ? (
<a
key="auto"
onClick={async () => {
await autoTrace(r.id);
message.success('一级自动溯源完成,已生成初报');
actionRef.current?.reload();
setDetail(null);
}}
>
</a>
) : null,
r.status === 'reported' || r.status === 'analysis' ? (
<a key="archive" onClick={() => openDetail(r)}>
</a>
) : null,
<Popconfirm
key="del"
title="确认删除该溯源记录?"
onConfirm={async () => {
await deleteTraceRecord(r.id);
message.success('已删除');
actionRef.current?.reload();
}}
>
<a style={{ color: '#ff4d4f' }}></a>
</Popconfirm>,
]
: []),
],
},
];
return (
<>
<ProTable<TraceRecord>
actionRef={actionRef}
rowKey="id"
columns={columns}
search={{ labelWidth: 'auto' }}
request={async (params) => {
const res = await listTraceRecords({ status: params.status });
return { data: res, total: res.length, success: true };
}}
toolBarRender={() =>
canWrite()
? [
<Button
key="new"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
setCreateOpen(true);
}}
>
</Button>,
]
: []
}
/>
<Modal
title="发起疫病溯源"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
await createTraceRecord(v);
message.success('溯源记录已创建');
setCreateOpen(false);
actionRef.current?.reload();
}}
>
<Form form={createForm} layout="vertical">
<Form.Item label="病种" name="disease" rules={[{ required: true, message: '请输入病种' }]}>
<Select
showSearch
options={[
'核型多角体病', '白僵病', '软化病', '微粒子病', '质型多角体病',
'浓核病/传染性软化病', '细菌性败血病', '猝倒病', '其他真菌病(绿僵病/黄僵病/曲霉病)',
].map((d) => ({ value: d, label: d }))}
/>
</Form.Item>
<Form.Item label="蚕房 ID" name="roomId">
<Input placeholder="可选" />
</Form.Item>
<Form.Item label="LAMP 任务 ID" name="lampTestId">
<Input placeholder="可选" />
</Form.Item>
<Form.Item label="会诊单 ID" name="consultationId">
<Input placeholder="可选" />
</Form.Item>
</Form>
</Modal>
<Drawer title={`溯源详情 - ${detail?.disease || ''}`} open={!!detail} width={680} onClose={() => setDetail(null)}>
{detail ? (
<div>
<Typography.Paragraph>
{detail.roomName || '-'} ·
<Tag color={STATUS_LABELS[detail.status]?.color}>{STATUS_LABELS[detail.status]?.text || detail.status}</Tag>
{detail.origin ? ` · 来源:${detail.origin === 'internal' ? '内源' : detail.origin === 'external' ? '外源' : detail.origin}` : ''}
{detail.confidence ? ` · 置信度 ${(detail.confidence * 100).toFixed(0)}%` : ''}
</Typography.Paragraph>
{detail.autoReport ? (
<>
<Typography.Title level={5}></Typography.Title>
<Typography.Paragraph>
{detail.autoReport.environment?.summary}
<br />
{detail.autoReport.history?.continuous ? '同病种既往发病(连发性)' : '未见同病种历史发病'}
90 {detail.autoReport.history?.pastCount ?? 0}
<br />
{detail.autoReport.transmission?.mode}{detail.autoReport.transmission?.source}
<br />
{detail.autoReport.origin === 'internal' ? '内源扩散' : detail.autoReport.origin === 'external' ? '外源侵入' : '待确认'}
{(detail.autoReport.confidence * 100).toFixed(0)}%
</Typography.Paragraph>
</>
) : null}
{detail.analysisReport ? (
<>
<Typography.Title level={5}></Typography.Title>
<Typography.Paragraph>
{detail.analysisReport.conclusion}
<br />
{detail.analysisReport.origin === 'internal' ? '内源' : detail.analysisReport.origin === 'external' ? '外源' : '未知'}
{(detail.analysisReport.confidence * 100).toFixed(0)}%
</Typography.Paragraph>
</>
) : null}
{checklist.length > 0 ? (
<>
<Typography.Title level={5}></Typography.Title>
<Space direction="vertical" style={{ width: '100%' }}>
{checklist.map((item) => (
<div key={item.key}>
<div>{item.label}</div>
<Radio.Group
value={answers[item.key]}
onChange={(e) => setAnswers((p) => ({ ...p, [item.key]: e.target.value }))}
options={[
{ value: 'yes', label: '是' },
{ value: 'no', label: '否' },
{ value: 'unknown', label: '不清楚' },
]}
/>
</div>
))}
<Button
type="primary"
onClick={async () => {
const list = checklist.map((item) => ({ key: item.key, answer: answers[item.key] || 'unknown' }));
const updated = await submitTraceChecklist(detail.id, list);
message.success('已生成溯源分析报告');
setDetail(updated);
setChecklist([]);
actionRef.current?.reload();
}}
>
</Button>
</Space>
</>
) : null}
{detail.expertNote || detail.labNote ? (
<>
<Typography.Title level={5}></Typography.Title>
{detail.expertNote ? <Typography.Paragraph>{detail.expertNote}</Typography.Paragraph> : null}
{detail.labNote ? <Typography.Paragraph>{detail.labNote}</Typography.Paragraph> : null}
</>
) : null}
{canWrite() ? (
<Space style={{ marginTop: 16 }}>
<Button
onClick={async () => {
const expertNote = prompt('专家记录(病原分型/母蛾镜检等):');
if (expertNote === null) return;
const updated = await updateTraceRecord(detail.id, { expertNote });
setDetail(updated);
message.success('已保存');
}}
>
-
</Button>
<Button
onClick={async () => {
const labNote = prompt('实验室记录:');
if (labNote === null) return;
const updated = await updateTraceRecord(detail.id, { labNote });
setDetail(updated);
message.success('已保存');
}}
>
-
</Button>
{detail.status !== 'archived' ? (
<Button
onClick={async () => {
await updateTraceRecord(detail.id, { status: 'archived' });
message.success('已归档');
setDetail(null);
actionRef.current?.reload();
}}
>
</Button>
) : null}
</Space>
) : null}
</div>
) : null}
</Drawer>
</>
);
}
+2
View File
@@ -16,6 +16,7 @@ import LampTests from './pages/LampTests';
import Consumables from './pages/Consumables'; import Consumables from './pages/Consumables';
import Inspections from './pages/Inspections'; import Inspections from './pages/Inspections';
import Consultations from './pages/Consultations'; import Consultations from './pages/Consultations';
import Traces from './pages/Traces';
import NotFound from './pages/NotFound'; import NotFound from './pages/NotFound';
import { BasicLayout } from './layout/BasicLayout'; import { BasicLayout } from './layout/BasicLayout';
import { authService } from './services/auth'; import { authService } from './services/auth';
@@ -58,6 +59,7 @@ export const router = createBrowserRouter([
{ path: 'consumables', element: <RequirePermission permission="consumable:read"><Consumables /></RequirePermission> }, { path: 'consumables', element: <RequirePermission permission="consumable:read"><Consumables /></RequirePermission> },
{ path: 'inspections', element: <RequirePermission permission="inspection:read"><Inspections /></RequirePermission> }, { path: 'inspections', element: <RequirePermission permission="inspection:read"><Inspections /></RequirePermission> },
{ path: 'consultations', element: <RequirePermission permission="consultation:read"><Consultations /></RequirePermission> }, { path: 'consultations', element: <RequirePermission permission="consultation:read"><Consultations /></RequirePermission> },
{ path: 'traces', element: <RequirePermission permission="trace:read"><Traces /></RequirePermission> },
{ path: '404', element: <NotFound /> }, { path: '404', element: <NotFound /> },
], ],
}, },