feat(web): 疫病溯源页(一级初报/二级清单/三级记录,#21)
This commit is contained in:
@@ -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 });
|
||||||
@@ -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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 角色中文名
|
// 角色中文名
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user