From 5ce99abeb25d83f107d3935924a95cc398acc8a3 Mon Sep 17 00:00:00 2001 From: weijuesen Date: Wed, 12 Aug 2026 23:13:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E7=96=AB=E7=97=85=E6=BA=AF?= =?UTF-8?q?=E6=BA=90=E9=A1=B5=EF=BC=88=E4=B8=80=E7=BA=A7=E5=88=9D=E6=8A=A5?= =?UTF-8?q?/=E4=BA=8C=E7=BA=A7=E6=B8=85=E5=8D=95/=E4=B8=89=E7=BA=A7?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=EF=BC=8C#21=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/dal/trace.ts | 38 ++++ web/src/layout/BasicLayout.tsx | 2 + web/src/pages/Traces.tsx | 306 +++++++++++++++++++++++++++++++++ web/src/router.tsx | 2 + 4 files changed, 348 insertions(+) create mode 100644 web/src/dal/trace.ts create mode 100644 web/src/pages/Traces.tsx diff --git a/web/src/dal/trace.ts b/web/src/dal/trace.ts new file mode 100644 index 0000000..9237223 --- /dev/null +++ b/web/src/dal/trace.ts @@ -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('/trace-records', { params }); +export const getTraceRecord = (id: string) => get(`/trace-records/${id}`); +export const createTraceRecord = (data: Partial) => + post('/trace-records', data); +export const updateTraceRecord = (id: string, data: Partial) => + patch(`/trace-records/${id}`, data); +export const deleteTraceRecord = (id: string) => del(`/trace-records/${id}`); +export const autoTrace = (id: string) => post(`/trace-records/${id}/auto`); +export const getTraceChecklist = (id: string) => + get(`/trace-records/${id}/checklist`); +export const submitTraceChecklist = (id: string, answers: { key: string; answer: string }[]) => + post(`/trace-records/${id}/checklist`, { answers }); diff --git a/web/src/layout/BasicLayout.tsx b/web/src/layout/BasicLayout.tsx index 2657676..903df60 100644 --- a/web/src/layout/BasicLayout.tsx +++ b/web/src/layout/BasicLayout.tsx @@ -13,6 +13,7 @@ import { ShoppingOutlined, CameraOutlined, TeamOutlined, + DeploymentUnitOutlined, SettingOutlined, LogoutOutlined, UserOutlined, @@ -42,6 +43,7 @@ const menuData = [ { path: '/consumables', name: '耗材管理', icon: , permission: 'consumable:read' }, { path: '/inspections', name: '巡检记录', icon: , permission: 'inspection:read' }, { path: '/consultations', name: '专家会诊', icon: , permission: 'consultation:read' }, + { path: '/traces', name: '疫病溯源', icon: , permission: 'trace:read' }, ]; // 角色中文名 diff --git a/web/src/pages/Traces.tsx b/web/src/pages/Traces.tsx new file mode 100644 index 0000000..b326925 --- /dev/null +++ b/web/src/pages/Traces.tsx @@ -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 = { + 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(); + const [createOpen, setCreateOpen] = useState(false); + const [createForm] = Form.useForm>(); + const [detail, setDetail] = useState(null); + const [checklist, setChecklist] = useState([]); + const [answers, setAnswers] = useState>({}); + + 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[] = [ + { 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) => {STATUS_LABELS[r.status]?.text || r.status}, + }, + { + title: '来源判定', + dataIndex: 'origin', + search: false, + render: (_, r) => + r.origin === 'internal' ? ( + 内源 + ) : r.origin === 'external' ? ( + 外源 + ) : ( + '-' + ), + }, + { title: '创建时间', dataIndex: 'createdAt', search: false, render: (_, r) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') }, + { + title: '操作', + valueType: 'option', + render: (_, r) => [ + openDetail(r)}> + 详情 + , + ...(canWrite() + ? [ + r.status === 'pending' ? ( + { + await autoTrace(r.id); + message.success('一级自动溯源完成,已生成初报'); + actionRef.current?.reload(); + setDetail(null); + }} + > + 自动溯源 + + ) : null, + r.status === 'reported' || r.status === 'analysis' ? ( + openDetail(r)}> + 归档 + + ) : null, + { + await deleteTraceRecord(r.id); + message.success('已删除'); + actionRef.current?.reload(); + }} + > + 删除 + , + ] + : []), + ], + }, + ]; + + return ( + <> + + 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() + ? [ + , + ] + : [] + } + /> + + setCreateOpen(false)} + onOk={async () => { + const v = await createForm.validateFields(); + await createTraceRecord(v); + message.success('溯源记录已创建'); + setCreateOpen(false); + actionRef.current?.reload(); + }} + > +
+ + + + + + + + + +
+
+ + setDetail(null)}> + {detail ? ( +
+ + 蚕房:{detail.roomName || '-'} · 状态: + {STATUS_LABELS[detail.status]?.text || detail.status} + {detail.origin ? ` · 来源:${detail.origin === 'internal' ? '内源' : detail.origin === 'external' ? '外源' : detail.origin}` : ''} + {detail.confidence ? ` · 置信度 ${(detail.confidence * 100).toFixed(0)}%` : ''} + + + {detail.autoReport ? ( + <> + 一级溯源初报 + + 环境:{detail.autoReport.environment?.summary} +
+ 历史:{detail.autoReport.history?.continuous ? '同病种既往发病(连发性)' : '未见同病种历史发病'} + (近 90 天 {detail.autoReport.history?.pastCount ?? 0} 条) +
+ 传播:{detail.autoReport.transmission?.mode}——{detail.autoReport.transmission?.source} +
+ 初步判定:{detail.autoReport.origin === 'internal' ? '内源扩散' : detail.autoReport.origin === 'external' ? '外源侵入' : '待确认'} + ,置信度 {(detail.autoReport.confidence * 100).toFixed(0)}% +
+ + ) : null} + + {detail.analysisReport ? ( + <> + 二级溯源分析报告 + + {detail.analysisReport.conclusion} +
+ 来源判定:{detail.analysisReport.origin === 'internal' ? '内源' : detail.analysisReport.origin === 'external' ? '外源' : '未知'} + ,置信度 {(detail.analysisReport.confidence * 100).toFixed(0)}% +
+ + ) : null} + + {checklist.length > 0 ? ( + <> + 二级排查清单 + + {checklist.map((item) => ( +
+
{item.label}
+ setAnswers((p) => ({ ...p, [item.key]: e.target.value }))} + options={[ + { value: 'yes', label: '是' }, + { value: 'no', label: '否' }, + { value: 'unknown', label: '不清楚' }, + ]} + /> +
+ ))} + +
+ + ) : null} + + {detail.expertNote || detail.labNote ? ( + <> + 三级记录 + {detail.expertNote ? 专家:{detail.expertNote} : null} + {detail.labNote ? 实验室:{detail.labNote} : null} + + ) : null} + + {canWrite() ? ( + + + + {detail.status !== 'archived' ? ( + + ) : null} + + ) : null} +
+ ) : null} +
+ + ); +} diff --git a/web/src/router.tsx b/web/src/router.tsx index f1c7f09..4cd1582 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -16,6 +16,7 @@ import LampTests from './pages/LampTests'; import Consumables from './pages/Consumables'; import Inspections from './pages/Inspections'; import Consultations from './pages/Consultations'; +import Traces from './pages/Traces'; import NotFound from './pages/NotFound'; import { BasicLayout } from './layout/BasicLayout'; import { authService } from './services/auth'; @@ -58,6 +59,7 @@ export const router = createBrowserRouter([ { path: 'consumables', element: }, { path: 'inspections', element: }, { path: 'consultations', element: }, + { path: 'traces', element: }, { path: '404', element: }, ], },