feat: 补齐消毒、种源与二维码身份链

This commit is contained in:
weijuesen
2026-08-14 08:39:56 +08:00
parent cc93c9a053
commit 6b222abb7b
24 changed files with 1344 additions and 9 deletions
+61
View File
@@ -0,0 +1,61 @@
import { get, post, patch } from '../api/http';
export interface SeedSource {
id: string;
publicId?: string;
batchId?: string;
parentId?: string;
supplier: string;
seedBatchNo: string;
quarantineNo?: string;
variety?: string;
certificateUrl?: string;
entryAt?: string;
note?: string;
createdAt?: string;
}
export interface DisinfectionRecord {
id: string;
roomId?: string;
batchId?: string;
kind: 'plan' | 'execution';
planId?: string;
agent: string;
concentration: string;
amount?: string;
plannedAt?: string;
executedAt?: string;
executorId?: string;
reviewedAt?: string;
reviewerId?: string;
photoUrl?: string;
note?: string;
createdAt?: string;
}
export interface QRIdentity {
publicId: string;
entityType: string;
entityId: string;
payload: string;
}
export const listSeedSources = (params?: any) =>
get<SeedSource[]>('/biosecurity/seed-sources', { params });
export const createSeedSource = (data: Partial<SeedSource>) =>
post<SeedSource>('/biosecurity/seed-sources', data);
export const updateSeedSource = (id: string, data: Partial<SeedSource>) =>
patch<SeedSource>(`/biosecurity/seed-sources/${id}`, data);
export const listDisinfectionRecords = (params?: any) =>
get<DisinfectionRecord[]>('/biosecurity/disinfection-records', { params });
export const createDisinfectionRecord = (data: Partial<DisinfectionRecord>) =>
post<DisinfectionRecord>('/biosecurity/disinfection-records', data);
export const updateDisinfectionRecord = (id: string, data: Partial<DisinfectionRecord>) =>
patch<DisinfectionRecord>(`/biosecurity/disinfection-records/${id}`, data);
export const issueQR = (entityType: string, entityId: string) =>
post<QRIdentity>('/biosecurity/qr', { entityType, entityId });
export const resolveQR = (payload: string) =>
post<{ entityType: string; publicId: string; entity: any }>('/biosecurity/qr/resolve', { payload });
+2
View File
@@ -14,6 +14,7 @@ import {
CameraOutlined,
TeamOutlined,
DeploymentUnitOutlined,
SafetyCertificateOutlined,
SettingOutlined,
LogoutOutlined,
UserOutlined,
@@ -42,6 +43,7 @@ const menuData = [
{ 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: '/biosecurity', name: '生物安全', icon: <SafetyCertificateOutlined />, permission: 'biosecurity:read' },
{ path: '/inspections', name: '巡检记录', icon: <CameraOutlined />, permission: 'inspection:read' },
{ path: '/consultations', name: '专家会诊', icon: <TeamOutlined />, permission: 'consultation:read' },
{ path: '/traces', name: '疫病溯源', icon: <DeploymentUnitOutlined />, permission: 'trace:read' },
+272
View File
@@ -0,0 +1,272 @@
import { useEffect, useRef, useState } from 'react';
import {
Button, DatePicker, 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 {
createDisinfectionRecord,
createSeedSource,
issueQR,
listDisinfectionRecords,
listSeedSources,
resolveQR,
updateDisinfectionRecord,
type DisinfectionRecord,
type SeedSource,
} from '../dal/biosecurity';
import { listHouses, type SilkwormHouse } from '../dal/silkworm';
import { listBatches, type Batch } from '../dal/trayBatch';
import { authService } from '../services/auth';
const KIND_LABELS: Record<string, string> = {
plan: '计划',
execution: '执行',
};
const canWrite = () => authService.hasPermission('biosecurity:write');
export default function BiosecurityPage() {
const [rooms, setRooms] = useState<SilkwormHouse[]>([]);
const [batches, setBatches] = useState<Batch[]>([]);
const [seedSources, setSeedSources] = useState<SeedSource[]>([]);
const seedAction = useRef<ActionType>();
const disinfectionAction = useRef<ActionType>();
const [seedOpen, setSeedOpen] = useState(false);
const [seedForm] = Form.useForm();
const [disinfectionOpen, setDisinfectionOpen] = useState(false);
const [disinfectionForm] = Form.useForm();
const [qrIssued, setQrIssued] = useState<any>(null);
const [qrResolved, setQrResolved] = useState<any>(null);
const [qrForm] = Form.useForm();
const [resolveForm] = Form.useForm();
useEffect(() => {
Promise.all([
listHouses().catch(() => ({ items: [] as SilkwormHouse[] })),
listBatches().catch(() => [] as Batch[]),
listSeedSources().catch(() => [] as SeedSource[]),
]).then(([houseRes, batchRes, seedRes]) => {
setRooms(houseRes.items);
setBatches(batchRes);
setSeedSources(seedRes);
});
}, []);
const roomName = (id?: string) => rooms.find((r) => r.id === id)?.name || id || '-';
const batchName = (id?: string) => batches.find((b) => b.id === id)?.name || id || '-';
const seedColumns: ProColumns<SeedSource>[] = [
{ title: '供应商', dataIndex: 'supplier' },
{ title: '批号', dataIndex: 'seedBatchNo' },
{ title: '检疫证', dataIndex: 'quarantineNo', search: false, render: (_, r) => r.quarantineNo || '-' },
{ title: '批次', dataIndex: 'batchId', search: false, render: (_, r) => batchName(r.batchId) },
{ title: '上链来源', dataIndex: 'parentId', search: false, render: (_, r) => r.parentId || '-' },
{ title: '创建时间', dataIndex: 'createdAt', search: false, render: (_, r) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') },
];
const disinfectionColumns: ProColumns<DisinfectionRecord>[] = [
{ title: '类型', dataIndex: 'kind', valueEnum: Object.fromEntries(Object.entries(KIND_LABELS).map(([k, v]) => [k, { text: v }])) },
{ title: '蚕房', dataIndex: 'roomId', search: false, render: (_, r) => roomName(r.roomId) },
{ title: '批次', dataIndex: 'batchId', search: false, render: (_, r) => batchName(r.batchId) },
{ title: '药剂', dataIndex: 'agent' },
{ title: '浓度', dataIndex: 'concentration' },
{ title: '计划时间', dataIndex: 'plannedAt', search: false, render: (_, r) => (r.plannedAt ? dayjs(r.plannedAt).format('YYYY-MM-DD HH:mm') : '-') },
{ title: '执行时间', dataIndex: 'executedAt', search: false, render: (_, r) => (r.executedAt ? dayjs(r.executedAt).format('YYYY-MM-DD HH:mm') : '-') },
{
title: '操作',
valueType: 'option',
render: (_, r) => [
...(canWrite() && !r.executedAt
? [
<a key="execute" onClick={async () => {
await updateDisinfectionRecord(r.id, { executedAt: new Date().toISOString() });
message.success('已记录执行');
disinfectionAction.current?.reload();
}}></a>,
]
: []),
],
},
];
return (
<>
<Tabs
items={[
{
key: 'seeds',
label: '种源与检疫链',
children: (
<ProTable<SeedSource>
actionRef={seedAction}
rowKey="id"
columns={seedColumns}
search={false}
request={async () => {
const res = await listSeedSources();
setSeedSources(res);
return { data: res, total: res.length, success: true };
}}
toolBarRender={() =>
canWrite()
? [
<Button key="new" type="primary" icon={<PlusOutlined />} onClick={() => { seedForm.resetFields(); setSeedOpen(true); }}></Button>,
]
: []
}
/>
),
},
{
key: 'disinfection',
label: '消毒记录',
children: (
<ProTable<DisinfectionRecord>
actionRef={disinfectionAction}
rowKey="id"
columns={disinfectionColumns}
search={false}
request={async (params) => {
const res = await listDisinfectionRecords({ kind: params.kind });
return { data: res, total: res.length, success: true };
}}
toolBarRender={() =>
canWrite()
? [
<Button key="new" type="primary" icon={<PlusOutlined />} onClick={() => { disinfectionForm.resetFields(); setDisinfectionOpen(true); }}></Button>,
]
: []
}
/>
),
},
{
key: 'qr',
label: '二维码身份',
children: (
<Space direction="vertical" size="large" style={{ width: '100%', maxWidth: 720 }}>
<Form form={qrForm} layout="inline" onFinish={async (v) => {
setQrIssued(await issueQR(v.entityType, v.entityId));
}}>
<Form.Item name="entityType" initialValue="batch" rules={[{ required: true }]}>
<Select style={{ width: 140 }} options={[
{ value: 'batch', label: '批次' },
{ value: 'tray', label: '蚕匾' },
{ value: 'sample', label: '样本' },
]} />
</Form.Item>
<Form.Item name="entityId" rules={[{ required: true, message: '请输入实体 ID' }]}>
<Input placeholder="实体 ID" style={{ width: 260 }} />
</Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
{qrIssued ? (
<div>
<Tag color="green"></Tag>
<pre>{qrIssued.payload}</pre>
</div>
) : null}
<Form form={resolveForm} layout="inline" onFinish={async (v) => {
setQrResolved(await resolveQR(v.payload));
}}>
<Form.Item name="payload" rules={[{ required: true, message: '请输入二维码内容' }]} style={{ minWidth: 420 }}>
<Input placeholder="粘贴二维码内容" />
</Form.Item>
<Button htmlType="submit"></Button>
</Form>
{qrResolved ? (
<div>
<Tag color="blue"></Tag>
<pre>{JSON.stringify(qrResolved, null, 2)}</pre>
</div>
) : null}
</Space>
),
},
]}
/>
<Modal
title="新增种源"
open={seedOpen}
onCancel={() => setSeedOpen(false)}
onOk={async () => {
const v = await seedForm.validateFields();
await createSeedSource({ ...v, entryAt: v.entryAt?.toISOString() });
message.success('种源已创建');
setSeedOpen(false);
seedAction.current?.reload();
}}
>
<Form form={seedForm} layout="vertical">
<Form.Item label="供应商" name="supplier" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label="蚕种批号" name="seedBatchNo" rules={[{ required: true }]}>
<Input />
</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="parentId">
<Select allowClear options={seedSources.map((s) => ({ value: s.id, label: `${s.supplier} ${s.seedBatchNo}` }))} />
</Form.Item>
<Form.Item label="检疫证号" name="quarantineNo">
<Input />
</Form.Item>
<Form.Item label="品种" name="variety">
<Input />
</Form.Item>
<Form.Item label="入场时间" name="entryAt">
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="检疫凭证 URL" name="certificateUrl">
<Input />
</Form.Item>
<Form.Item label="备注" name="note">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
<Modal
title="新增消毒记录"
open={disinfectionOpen}
onCancel={() => setDisinfectionOpen(false)}
onOk={async () => {
const v = await disinfectionForm.validateFields();
await createDisinfectionRecord(v);
message.success('消毒记录已创建');
setDisinfectionOpen(false);
disinfectionAction.current?.reload();
}}
>
<Form form={disinfectionForm} layout="vertical">
<Form.Item label="类型" name="kind" initialValue="plan" rules={[{ required: true }]}>
<Select options={Object.entries(KIND_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<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="agent" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label="浓度" name="concentration" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label="用量" name="amount">
<Input />
</Form.Item>
<Form.Item label="备注" name="note">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
</>
);
}
+2
View File
@@ -15,6 +15,7 @@ import Batches from './pages/Batches';
import LampTests from './pages/LampTests';
import DetectionTasks from './pages/DetectionTasks';
import Consumables from './pages/Consumables';
import Biosecurity from './pages/Biosecurity';
import Inspections from './pages/Inspections';
import Consultations from './pages/Consultations';
import Traces from './pages/Traces';
@@ -59,6 +60,7 @@ export const router = createBrowserRouter([
{ 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: 'biosecurity', element: <RequirePermission permission="biosecurity:read"><Biosecurity /></RequirePermission> },
{ path: 'inspections', element: <RequirePermission permission="inspection:read"><Inspections /></RequirePermission> },
{ path: 'consultations', element: <RequirePermission permission="consultation:read"><Consultations /></RequirePermission> },
{ path: 'traces', element: <RequirePermission permission="trace:read"><Traces /></RequirePermission> },