feat: 补齐消毒、种源与二维码身份链
This commit is contained in:
@@ -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 });
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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> },
|
||||
|
||||
Reference in New Issue
Block a user