feat(web/miniapp): LAMP 检测管理页与小程序录入(#14)+ web 构建代码拆分

This commit is contained in:
weijuesen
2026-08-12 17:52:07 +08:00
parent c6e5a76e71
commit d626f9d442
11 changed files with 716 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
import { get, post, patch, del } from '../api/http';
export interface LampTest {
id: string;
roomId?: string;
batchId?: string;
diseases?: string[];
status: string;
sampleInfo?: string;
result?: string;
resultImageUrl?: string;
operatorId?: string;
resultedAt?: string;
note?: string;
createdAt?: string;
}
export interface LampTestStep {
id: string;
lampTestId: string;
stepNo: number;
name: string;
done: boolean;
note?: string;
}
export const listLampTests = (params?: any) => get<LampTest[]>('/lamp-tests', { params });
export const createLampTest = (data: Partial<LampTest>) => post<LampTest>('/lamp-tests', data);
export const updateLampTest = (id: string, data: Partial<LampTest>) =>
patch<LampTest>(`/lamp-tests/${id}`, data);
export const deleteLampTest = (id: string) => del(`/lamp-tests/${id}`);
export const listLampSteps = (id: string) => get<LampTestStep[]>(`/lamp-tests/${id}/steps`);
export const updateLampStep = (id: string, stepNo: number, data: Partial<LampTestStep>) =>
patch<LampTestStep>(`/lamp-tests/${id}/steps/${stepNo}`, data);
export const uploadLampResultImage = (id: string, file: File) => {
const fd = new FormData();
fd.append('file', file);
return post<{ url: string }>(`/lamp-tests/${id}/result-image`, fd);
};
+2
View File
@@ -9,6 +9,7 @@ import {
FileTextOutlined,
BookOutlined,
ProfileOutlined,
ExperimentOutlined,
SettingOutlined,
LogoutOutlined,
UserOutlined,
@@ -34,6 +35,7 @@ const menuData = [
{ path: '/logs', name: '控制日志', icon: <FileTextOutlined />, permission: 'log:read' },
{ 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' },
];
// 角色中文名
+283
View File
@@ -0,0 +1,283 @@
import { useEffect, useRef, useState } from 'react';
import {
Button, Checkbox, Form, Input, Modal, Popconfirm, Radio, Select, Switch, Tabs, Tag, Upload, message,
} from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd';
import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
import dayjs from 'dayjs';
import {
listLampTests,
createLampTest,
updateLampTest,
deleteLampTest,
listLampSteps,
updateLampStep,
uploadLampResultImage,
type LampTest,
type LampTestStep,
} from '../dal/lamp';
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> = {
pending: '待检测',
testing: '检测中',
resulted: '已出结果',
};
const RESULT_LABELS: Record<string, string> = {
positive: '阳性',
negative: '阴性',
invalid: '无效',
};
const canWrite = () => authService.hasPermission('lamp:write');
function LampTab() {
const [rooms, setRooms] = useState<SilkwormHouse[]>([]);
const [batches, setBatches] = useState<Batch[]>([]);
const [diseaseOptions, setDiseaseOptions] = useState<{ value: string; label: string }[]>([]);
const actionRef = useRef<ActionType>();
const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm<Partial<LampTest>>();
const [resultOpen, setResultOpen] = useState(false);
const [resultForm] = Form.useForm<Partial<LampTest>>();
const [resultTarget, setResultTarget] = useState<LampTest | null>(null);
const [imageList, setImageList] = useState<UploadFile[]>([]);
const [stepsOpen, setStepsOpen] = useState(false);
const [stepsTarget, setStepsTarget] = useState<LampTest | null>(null);
const [steps, setSteps] = useState<LampTestStep[]>([]);
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);
setDiseaseOptions(diseaseRes.map((d: any) => ({ value: d.name, label: d.name })));
});
}, []);
const roomName = (id?: string) => rooms.find((r) => r.id === id)?.name || id || '-';
const roomEnum = Object.fromEntries(rooms.map((r) => [r.id, { text: r.name }]));
const openSteps = async (t: LampTest) => {
setStepsTarget(t);
setSteps(await listLampSteps(t.id).catch(() => [] as LampTestStep[]));
setStepsOpen(true);
};
const openResult = (t: LampTest) => {
setResultTarget(t);
resultForm.setFieldsValue({ result: t.result });
setImageList(
t.resultImageUrl ? [{ uid: '-1', name: '结果照片', status: 'done', url: t.resultImageUrl }] : [],
);
setResultOpen(true);
};
const columns: ProColumns<LampTest>[] = [
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
{ title: '蚕房', dataIndex: 'roomId', valueType: 'select', valueEnum: roomEnum, render: (_, r) => roomName(r.roomId) },
{ title: '检测病种', dataIndex: 'diseases', search: false, render: (_, r) => (r.diseases || []).join('、') || '-' },
{ title: '采样信息', dataIndex: 'sampleInfo', search: false, render: (_, r) => r.sampleInfo || '-' },
{
title: '状态',
dataIndex: 'status',
valueType: 'select',
valueEnum: Object.fromEntries(Object.entries(STATUS_LABELS).map(([k, v]) => [k, { text: v }])),
render: (_, r) => <Tag color={r.status === 'resulted' ? 'green' : 'default'}>{STATUS_LABELS[r.status] || r.status}</Tag>,
},
{ title: '结果', dataIndex: 'result', search: false, render: (_, r) => (r.result ? RESULT_LABELS[r.result] || 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="steps" onClick={() => openSteps(r)}>
</a>,
<a key="result" onClick={() => openResult(r)}>
</a>,
...(canWrite()
? [
<Popconfirm
key="del"
title="确认删除该任务单?"
onConfirm={async () => {
await deleteLampTest(r.id);
message.success('已删除');
actionRef.current?.reload();
}}
>
<a style={{ color: '#ff4d4f' }}></a>
</Popconfirm>,
]
: []),
],
},
];
return (
<>
<ProTable<LampTest>
actionRef={actionRef}
rowKey="id"
columns={columns}
search={{ labelWidth: 'auto' }}
request={async (params) => {
const res = await listLampTests({ roomId: params.roomId, status: params.status });
return { data: res, total: res.length, success: true };
}}
toolBarRender={() =>
canWrite()
? [
<Button
key="new"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
setCreateOpen(true);
}}
>
</Button>,
]
: []
}
/>
<Modal
title="新建 LAMP 检测任务单"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
await createLampTest(v);
message.success('已创建(自动生成 5 步流程)');
setCreateOpen(false);
actionRef.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="diseases" rules={[{ required: true, message: '请选择检测病种' }]}>
<Checkbox.Group options={diseaseOptions} />
</Form.Item>
<Form.Item label="采样信息" name="sampleInfo">
<Input placeholder="如:5龄第3天,1号匾" />
</Form.Item>
<Form.Item label="备注" name="note">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
<Modal
title={`结果录入 - ${resultTarget ? roomName(resultTarget.roomId) : ''}`}
open={resultOpen}
onCancel={() => setResultOpen(false)}
onOk={async () => {
if (!resultTarget) return;
const v = await resultForm.validateFields();
await updateLampTest(resultTarget.id, { result: v.result });
message.success('结果已保存');
setResultOpen(false);
actionRef.current?.reload();
}}
>
<Form form={resultForm} layout="vertical">
<Form.Item label="检测结果" name="result" rules={[{ required: true, message: '请选择结果' }]}>
<Radio.Group
options={[
{ value: 'positive', label: '阳性(天蓝色)' },
{ value: 'negative', label: '阴性(紫罗兰色)' },
{ value: 'invalid', label: '无效' },
]}
/>
</Form.Item>
<Form.Item label="结果照片">
<Upload
listType="picture-card"
maxCount={1}
accept=".jpg,.jpeg,.png,.webp"
fileList={imageList}
customRequest={async ({ file, onSuccess, onError }) => {
if (!resultTarget) return;
try {
const res = await uploadLampResultImage(resultTarget.id, file as File);
setImageList([{ uid: '-1', name: '结果照片', status: 'done', url: res.url }]);
onSuccess?.(res);
} catch (e) {
onError?.(e as Error);
}
}}
onRemove={() => setImageList([])}
>
{imageList.length >= 1 ? null : (
<div>
<PlusOutlined />
<div style={{ marginTop: 8 }}></div>
</div>
)}
</Upload>
</Form.Item>
</Form>
</Modal>
<Modal
title={`操作步骤 - ${stepsTarget ? roomName(stepsTarget.roomId) : ''}`}
open={stepsOpen}
onCancel={() => setStepsOpen(false)}
footer={null}
>
{steps.map((s) => (
<div
key={s.id}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 0',
borderBottom: '1px solid #f0f0f0',
}}
>
<Tag>{`${s.stepNo}. ${s.name}`}</Tag>
<Switch
checked={s.done}
onChange={async (done) => {
await updateLampStep(s.lampTestId, s.stepNo, { done });
setSteps(await listLampSteps(s.lampTestId));
message.success('已更新');
}}
/>
</div>
))}
</Modal>
</>
);
}
export default function LampTestsPage() {
return (
<Tabs
defaultActiveKey="lamp"
items={[{ key: 'lamp', label: 'LAMP 检测', children: <LampTab /> }]}
/>
);
}
+2
View File
@@ -12,6 +12,7 @@ import Video from './pages/Video';
import Log from './pages/Log';
import Knowledge from './pages/Knowledge';
import Batches from './pages/Batches';
import LampTests from './pages/LampTests';
import NotFound from './pages/NotFound';
import { BasicLayout } from './layout/BasicLayout';
import { authService } from './services/auth';
@@ -50,6 +51,7 @@ export const router = createBrowserRouter([
{ path: 'logs', element: <RequirePermission permission="log:read"><Log /></RequirePermission> },
{ 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: '404', element: <NotFound /> },
],
},
+24
View File
@@ -3,6 +3,30 @@ import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks(id: string) {
if (id.includes('node_modules/react') || id.includes('node_modules/react-dom') || id.includes('node_modules/react-router')) {
return 'react';
}
if (id.includes('node_modules/@ant-design/pro-components')) {
return 'pro';
}
if (id.includes('node_modules/antd') || id.includes('node_modules/@ant-design')) {
return 'antd';
}
if (id.includes('node_modules/echarts')) {
return 'echarts';
}
if (id.includes('node_modules')) {
return 'vendor';
}
return undefined;
},
},
},
},
server: {
host: '0.0.0.0',
port: 5174,