feat(web/miniapp): LAMP 检测管理页与小程序录入(#14)+ web 构建代码拆分
This commit is contained in:
@@ -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);
|
||||
};
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
// 角色中文名
|
||||
|
||||
@@ -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 /> }]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user