feat: 建立统一检测任务、样本链与发病事件

This commit is contained in:
weijuesen
2026-08-14 03:31:02 +08:00
parent a25bc7abc6
commit cc93c9a053
26 changed files with 1716 additions and 32 deletions
+88
View File
@@ -0,0 +1,88 @@
import { get, post, patch } from '../api/http';
export interface DetectionTask {
id: string;
sourceKey?: string;
sourceType: string;
sourceId?: string;
roomId?: string;
roomName?: string;
batchId?: string;
inspectionId?: string;
disease: string;
recommendedMethod?: string;
method?: string;
priority: string;
status: string;
assigneeId?: string;
assignedAt?: string;
result?: string;
resultedAt?: string;
cancelledReason?: string;
createdBy?: string;
note?: string;
createdAt?: string;
}
export interface Sample {
id: string;
detectionTaskId: string;
sampleNo: string;
roomId?: string;
batchId?: string;
trayId?: string;
sampledBy?: string;
sampledAt?: string;
collectedAt?: string;
handedOverAt?: string;
receivedAt?: string;
testingStartedAt?: string;
consumedAt?: string;
disposedAt?: string;
state: string;
note?: string;
}
export interface DiseaseEvent {
id: string;
sourceKey?: string;
roomId?: string;
roomName?: string;
batchId?: string;
detectionTaskId?: string;
lampTestId?: string;
consultationId?: string;
inspectionId?: string;
disease: string;
status: string;
evidence?: any;
confirmedAt?: string;
confirmedBy?: string;
lossSummary?: string;
measure?: string;
note?: string;
createdAt?: string;
}
export const listDetectionTasks = (params?: any) =>
get<DetectionTask[]>('/detection-tasks', { params });
export const getDetectionTask = (id: string) =>
get<DetectionTask>(`/detection-tasks/${id}`);
export const createDetectionTask = (data: Partial<DetectionTask>) =>
post<DetectionTask>('/detection-tasks', data);
export const updateDetectionTask = (id: string, data: Partial<DetectionTask>) =>
patch<DetectionTask>(`/detection-tasks/${id}`, data);
export const listSamples = (taskId: string) =>
get<Sample[]>(`/detection-tasks/${taskId}/samples`);
export const createSample = (taskId: string, data: Partial<Sample>) =>
post<Sample>(`/detection-tasks/${taskId}/samples`, data);
export const updateSample = (id: string, data: Partial<Sample>) =>
patch<Sample>(`/samples/${id}`, data);
export const listDiseaseEvents = (params?: any) =>
get<DiseaseEvent[]>('/disease-events', { params });
export const createDiseaseEvent = (data: Partial<DiseaseEvent>) =>
post<DiseaseEvent>('/disease-events', data);
export const updateDiseaseEvent = (id: string, data: Partial<DiseaseEvent>) =>
patch<DiseaseEvent>(`/disease-events/${id}`, data);
+1
View File
@@ -40,6 +40,7 @@ const menuData = [
{ path: '/knowledge', name: '知识库', icon: <BookOutlined />, permission: 'knowledge:read' },
{ path: '/batches', name: '批次管理', icon: <ProfileOutlined />, permission: 'batch:read' },
{ path: '/lamp-tests', name: 'LAMP 检测', icon: <ExperimentOutlined />, permission: 'lamp:read' },
{ path: '/detection-tasks', name: '检测任务', icon: <ExperimentOutlined />, permission: 'lamp:read' },
{ path: '/consumables', name: '耗材管理', icon: <ShoppingOutlined />, permission: 'consumable:read' },
{ path: '/inspections', name: '巡检记录', icon: <CameraOutlined />, permission: 'inspection:read' },
{ path: '/consultations', name: '专家会诊', icon: <TeamOutlined />, permission: 'consultation:read' },
+402
View File
@@ -0,0 +1,402 @@
import { useEffect, useRef, useState } from 'react';
import {
Button, Drawer, Form, Input, Modal, Select, Space, Tabs, Tag, 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 {
createDetectionTask,
createDiseaseEvent,
createSample,
listDetectionTasks,
listDiseaseEvents,
listSamples,
updateDetectionTask,
updateDiseaseEvent,
updateSample,
type DetectionTask,
type DiseaseEvent,
type Sample,
} from '../dal/detectionTask';
import { listDiseases } from '../dal/knowledge';
import { listHouses, type SilkwormHouse } from '../dal/silkworm';
import { listBatches, type Batch } from '../dal/trayBatch';
import { authService } from '../services/auth';
const STATUS_LABELS: Record<string, string> = {
draft: '草稿',
pending: '待确认',
assigned: '已分派',
sampling: '采样中',
testing: '检测中',
review: '复核中',
completed: '已完成',
cancelled: '已取消',
};
const SAMPLE_LABELS: Record<string, string> = {
created: '已登记',
collected: '已采集',
handed_over: '已交接',
received: '已接收',
testing: '检测中',
consumed: '已消耗',
disposed: '已废弃',
};
const EVENT_LABELS: Record<string, string> = {
suspected: '疑似',
confirmed: '确诊',
controlled: '已控制',
closed: '已关闭',
reopened: '已重开',
};
const METHOD_LABELS: Record<string, string> = {
lamp: 'LAMP',
qpcr: 'qPCR',
sers: 'SERS',
hyperspectral: '高光谱',
};
const canWrite = () => authService.hasPermission('lamp:write') && authService.hasPermission('trace:write');
export default function DetectionTasksPage() {
const [rooms, setRooms] = useState<SilkwormHouse[]>([]);
const [batches, setBatches] = useState<Batch[]>([]);
const [diseases, setDiseases] = useState<{ value: string; label: string }[]>([]);
const taskAction = useRef<ActionType>();
const eventAction = useRef<ActionType>();
const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm();
const [resultOpen, setResultOpen] = useState(false);
const [resultForm] = Form.useForm();
const [resultTarget, setResultTarget] = useState<DetectionTask | null>(null);
const [eventOpen, setEventOpen] = useState(false);
const [eventForm] = Form.useForm();
const [sampleTask, setSampleTask] = useState<DetectionTask | null>(null);
const [samples, setSamples] = useState<Sample[]>([]);
const [sampleState, setSampleState] = useState('collected');
useEffect(() => {
Promise.all([
listHouses().catch(() => ({ items: [] as SilkwormHouse[] })),
listBatches().catch(() => [] as Batch[]),
listDiseases().catch(() => [] as any[]),
]).then(([houseRes, batchRes, diseaseRes]) => {
setRooms(houseRes.items);
setBatches(batchRes);
setDiseases(diseaseRes.map((d: any) => ({ value: d.name, label: d.name })));
});
}, []);
const roomName = (id?: string) => rooms.find((r) => r.id === id)?.name || id || '-';
const assignToMe = async (task: DetectionTask) => {
const uid = authService.getUser()?.id;
if (!uid) {
message.error('无法获取当前用户 ID');
return;
}
await updateDetectionTask(task.id, { assigneeId: uid, status: 'assigned' });
message.success('已指派给当前用户');
taskAction.current?.reload();
};
const advanceTask = async (task: DetectionTask) => {
if (task.status === 'review') {
resultForm.resetFields();
setResultTarget(task);
setResultOpen(true);
return;
}
const next = {
pending: 'assigned',
assigned: 'sampling',
sampling: 'testing',
testing: 'review',
}[task.status];
if (!next) return;
if (next === 'assigned') {
await assignToMe(task);
return;
}
await updateDetectionTask(task.id, { status: next });
message.success(`已更新为${STATUS_LABELS[next]}`);
taskAction.current?.reload();
};
const openSamples = async (task: DetectionTask) => {
setSampleTask(task);
setSamples(await listSamples(task.id).catch(() => [] as Sample[]));
};
const createSampleForTask = async () => {
if (!sampleTask) return;
await createSample(sampleTask.id, {});
setSamples(await listSamples(sampleTask.id));
};
const moveSample = async (sample: Sample) => {
await updateSample(sample.id, { state: sampleState });
setSamples(await listSamples(sampleTask!.id));
message.success(`样本已更新为${SAMPLE_LABELS[sampleState]}`);
};
const taskColumns: ProColumns<DetectionTask>[] = [
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
{ title: '蚕房', dataIndex: 'roomId', search: false, render: (_, r) => roomName(r.roomId) },
{ title: '病种', dataIndex: 'disease', search: false },
{ title: '来源', dataIndex: 'sourceType', search: false, render: (_, r) => r.sourceType || 'manual' },
{ title: '方式', dataIndex: 'method', search: false, render: (_, r) => (r.method ? METHOD_LABELS[r.method] || r.method : '-') },
{ title: '优先级', dataIndex: 'priority', search: false, render: (_, r) => (r.priority === 'urgent' ? <Tag color="red"></Tag> : <Tag></Tag>) },
{
title: '状态',
dataIndex: 'status',
valueType: 'select',
valueEnum: Object.fromEntries(Object.entries(STATUS_LABELS).map(([k, v]) => [k, { text: v }])),
render: (_, r) => <Tag>{STATUS_LABELS[r.status] || r.status}</Tag>,
},
{ title: '结果', dataIndex: 'result', search: false, render: (_, r) => r.result || '-' },
{ 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="samples" onClick={() => openSamples(r)}></a>,
...(canWrite()
? [
<a key="advance" onClick={() => advanceTask(r)}>
{r.status === 'review' ? '录入结果' : r.status === 'pending' ? '派给我' : '推进'}
</a>,
]
: []),
],
},
];
const eventColumns: ProColumns<DiseaseEvent>[] = [
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
{ title: '蚕房', dataIndex: 'roomId', search: false, render: (_, r) => roomName(r.roomId) },
{ title: '病种', dataIndex: 'disease', search: false },
{
title: '状态',
dataIndex: 'status',
valueType: 'select',
valueEnum: Object.fromEntries(Object.entries(EVENT_LABELS).map(([k, v]) => [k, { text: v }])),
render: (_, r) => <Tag color={r.status === 'confirmed' ? 'red' : 'default'}>{EVENT_LABELS[r.status] || r.status}</Tag>,
},
{ title: '损失', dataIndex: 'lossSummary', search: false, render: (_, r) => r.lossSummary || '-' },
{ title: '措施', dataIndex: 'measure', search: false, render: (_, r) => r.measure || '-' },
{ title: '创建时间', dataIndex: 'createdAt', search: false, render: (_, r) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') },
{
title: '操作',
valueType: 'option',
render: (_, r) => [
...(canWrite()
? [
r.status === 'confirmed' ? (
<a key="control" onClick={async () => { await updateDiseaseEvent(r.id, { status: 'controlled' }); eventAction.current?.reload(); }}></a>
) : r.status === 'controlled' ? (
<a key="close" onClick={async () => { await updateDiseaseEvent(r.id, { status: 'closed' }); eventAction.current?.reload(); }}></a>
) : (
<a key="confirm" onClick={async () => { await updateDiseaseEvent(r.id, { status: 'confirmed' }); eventAction.current?.reload(); }}></a>
),
]
: []),
],
},
];
return (
<>
<Tabs
items={[
{
key: 'tasks',
label: '检测任务',
children: (
<ProTable<DetectionTask>
actionRef={taskAction}
rowKey="id"
columns={taskColumns}
search={{ labelWidth: 'auto' }}
request={async (params) => {
const res = await listDetectionTasks({ status: params.status, roomId: params.roomId });
return { data: res, total: res.length, success: true };
}}
toolBarRender={() =>
canWrite()
? [
<Button key="new" type="primary" icon={<PlusOutlined />} onClick={() => { createForm.resetFields(); setCreateOpen(true); }}>
</Button>,
]
: []
}
/>
),
},
{
key: 'events',
label: '发病事件',
children: (
<ProTable<DiseaseEvent>
actionRef={eventAction}
rowKey="id"
columns={eventColumns}
search={false}
request={async (params) => {
const res = await listDiseaseEvents({ status: params.status, roomId: params.roomId });
return { data: res, total: res.length, success: true };
}}
toolBarRender={() =>
canWrite()
? [
<Button key="new" type="primary" icon={<PlusOutlined />} onClick={() => { eventForm.resetFields(); setEventOpen(true); }}>
</Button>,
]
: []
}
/>
),
},
]}
/>
<Modal
title="新建检测任务"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
await createDetectionTask({ ...v, sourceType: 'manual' });
message.success('检测任务已创建');
setCreateOpen(false);
taskAction.current?.reload();
}}
>
<Form form={createForm} layout="vertical">
<Form.Item label="蚕房" name="roomId">
<Select allowClear options={rooms.map((r) => ({ value: r.id, label: r.name }))} />
</Form.Item>
<Form.Item label="批次" name="batchId">
<Select allowClear options={batches.map((b) => ({ value: b.id, label: b.name }))} />
</Form.Item>
<Form.Item label="病种" name="disease" rules={[{ required: true, message: '请选择病种' }]}>
<Select options={diseases} showSearch optionFilterProp="label" />
</Form.Item>
<Form.Item label="推荐方式" name="recommendedMethod">
<Select options={Object.entries(METHOD_LABELS).map(([value, label]) => ({ value, label }))} allowClear />
</Form.Item>
<Form.Item label="优先级" name="priority" initialValue="routine">
<Select options={[{ value: 'routine', label: '常规' }, { value: 'urgent', label: '紧急' }]} />
</Form.Item>
<Form.Item label="备注" name="note">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
<Modal
title="录入检测结果"
open={resultOpen}
onCancel={() => setResultOpen(false)}
onOk={async () => {
if (!resultTarget) return;
const v = await resultForm.validateFields();
await updateDetectionTask(resultTarget.id, { status: 'completed', result: v.result });
message.success('结果已录入并完成');
setResultOpen(false);
taskAction.current?.reload();
}}
>
<Form form={resultForm} layout="vertical">
<Form.Item label="结果" name="result" rules={[{ required: true, message: '请选择结果' }]}>
<Select options={[
{ value: 'positive', label: '阳性' },
{ value: 'negative', label: '阴性' },
{ value: 'invalid', label: '无效' },
{ value: 'indeterminate', label: '待复核' },
]} />
</Form.Item>
</Form>
</Modal>
<Modal
title="新建发病事件"
open={eventOpen}
onCancel={() => setEventOpen(false)}
onOk={async () => {
const v = await eventForm.validateFields();
let evidence = v.evidence;
if (evidence && typeof evidence === 'string') {
try {
evidence = JSON.parse(evidence);
} catch {
evidence = { note: evidence };
}
}
if (v.status === 'confirmed' && !evidence) {
message.error('确诊必须填写证据');
return;
}
await createDiseaseEvent({ ...v, evidence });
message.success('发病事件已创建');
setEventOpen(false);
eventAction.current?.reload();
}}
>
<Form form={eventForm} layout="vertical">
<Form.Item label="蚕房" name="roomId">
<Select allowClear options={rooms.map((r) => ({ value: r.id, label: r.name }))} />
</Form.Item>
<Form.Item label="病种" name="disease" rules={[{ required: true, message: '请选择病种' }]}>
<Select options={diseases} showSearch optionFilterProp="label" />
</Form.Item>
<Form.Item label="状态" name="status" initialValue="suspected">
<Select options={Object.entries(EVENT_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item label="证据" name="evidence">
<Input.TextArea rows={3} placeholder="可填写 JSON 或文本证据" />
</Form.Item>
<Form.Item label="损失情况" name="lossSummary">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item label="处置措施" name="measure">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
<Drawer
title={sampleTask ? `样本:${sampleTask.disease}` : '样本'}
open={!!sampleTask}
onClose={() => setSampleTask(null)}
width={520}
>
{samples.length === 0 ? (
<Button type="primary" onClick={createSampleForTask}></Button>
) : (
samples.map((s) => (
<Space key={s.id} direction="vertical" style={{ width: '100%', marginBottom: 12 }}>
<div>{s.sampleNo}</div>
<div>{SAMPLE_LABELS[s.state] || s.state}</div>
<Space>
<Select
value={sampleState}
onChange={setSampleState}
options={Object.entries(SAMPLE_LABELS).map(([value, label]) => ({ value, label }))}
style={{ width: 160 }}
/>
<Button onClick={() => moveSample(s)}></Button>
</Space>
</Space>
))
)}
</Drawer>
</>
);
}
+2
View File
@@ -13,6 +13,7 @@ import Log from './pages/Log';
import Knowledge from './pages/Knowledge';
import Batches from './pages/Batches';
import LampTests from './pages/LampTests';
import DetectionTasks from './pages/DetectionTasks';
import Consumables from './pages/Consumables';
import Inspections from './pages/Inspections';
import Consultations from './pages/Consultations';
@@ -56,6 +57,7 @@ export const router = createBrowserRouter([
{ path: 'knowledge', element: <RequirePermission permission="knowledge:read"><Knowledge /></RequirePermission> },
{ path: 'batches', element: <RequirePermission permission="batch:read"><Batches /></RequirePermission> },
{ path: 'lamp-tests', element: <RequirePermission permission="lamp:read"><LampTests /></RequirePermission> },
{ path: 'detection-tasks', element: <RequirePermission permission="lamp:read"><DetectionTasks /></RequirePermission> },
{ path: 'consumables', element: <RequirePermission permission="consumable:read"><Consumables /></RequirePermission> },
{ path: 'inspections', element: <RequirePermission permission="inspection:read"><Inspections /></RequirePermission> },
{ path: 'consultations', element: <RequirePermission permission="consultation:read"><Consultations /></RequirePermission> },