feat: 建立统一检测任务、样本链与发病事件
This commit is contained in:
@@ -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);
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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> },
|
||||
|
||||
Reference in New Issue
Block a user