489 lines
17 KiB
TypeScript
489 lines
17 KiB
TypeScript
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,
|
||
judgeQPCR,
|
||
uploadLampSpectrum,
|
||
listSpectrumEntries,
|
||
createSpectrumEntry,
|
||
uploadSpectrumEntryFile,
|
||
deleteSpectrumEntry,
|
||
type LampTest,
|
||
type LampTestStep,
|
||
type SpectrumEntry,
|
||
} 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 METHOD_LABELS: Record<string, string> = {
|
||
lamp: 'LAMP',
|
||
qpcr: 'qPCR',
|
||
sers: 'SERS',
|
||
hyperspectral: '高光谱',
|
||
};
|
||
|
||
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[]>([]);
|
||
const [ctValues, setCtValues] = useState('');
|
||
const [threshold, setThreshold] = useState<number>(35);
|
||
|
||
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: 'method', search: false, width: 90, render: (_, r) => <Tag>{METHOD_LABELS[r.method || 'lamp']}</Tag> },
|
||
{ 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: 'crossStatus',
|
||
search: false,
|
||
render: (_, r) =>
|
||
r.crossStatus === 'consistent' ? (
|
||
<Tag color="green">一致</Tag>
|
||
) : r.crossStatus === 'inconsistent' ? (
|
||
<Tag color="red">不一致</Tag>
|
||
) : (
|
||
'-'
|
||
),
|
||
},
|
||
{ 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="method" initialValue="lamp">
|
||
<Select options={Object.entries(METHOD_LABELS).map(([value, label]) => ({ value, label }))} />
|
||
</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: '请选择结果' }]}>
|
||
{resultTarget?.method === 'qpcr' ? (
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<Input
|
||
placeholder="Ct 值,逗号分隔,如 32,35"
|
||
value={ctValues}
|
||
onChange={(e) => setCtValues(e.target.value)}
|
||
style={{ flex: 1 }}
|
||
/>
|
||
<Input
|
||
type="number"
|
||
placeholder="阈值"
|
||
value={threshold}
|
||
onChange={(e) => setThreshold(Number(e.target.value))}
|
||
style={{ width: 90 }}
|
||
/>
|
||
<Button
|
||
onClick={async () => {
|
||
if (!resultTarget) return;
|
||
const cts = ctValues
|
||
.split(/[,,\s]+/)
|
||
.filter(Boolean)
|
||
.map(Number);
|
||
const updated = await judgeQPCR(resultTarget.id, cts, threshold);
|
||
message.success(`自动判读:${RESULT_LABELS[updated.result || ''] || updated.result}`);
|
||
resultForm.setFieldsValue({ result: updated.result });
|
||
setResultTarget({ ...resultTarget, ...updated });
|
||
}}
|
||
>
|
||
自动判读
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<Radio.Group
|
||
options={[
|
||
{ value: 'positive', label: '阳性' },
|
||
{ value: 'negative', label: '阴性' },
|
||
{ value: 'invalid', label: '无效' },
|
||
]}
|
||
/>
|
||
)}
|
||
</Form.Item>
|
||
{resultTarget?.method === 'sers' ? (
|
||
<Form.Item label="光谱数据文件">
|
||
<Upload
|
||
maxCount={1}
|
||
accept=".csv,.txt,.json"
|
||
customRequest={async ({ file, onSuccess, onError }) => {
|
||
if (!resultTarget) return;
|
||
try {
|
||
const res = await uploadLampSpectrum(resultTarget.id, file as File);
|
||
message.success('光谱已上传');
|
||
onSuccess?.(res);
|
||
} catch (e) {
|
||
onError?.(e as Error);
|
||
}
|
||
}}
|
||
>
|
||
<Button icon={<PlusOutlined />}>上传光谱(csv/txt/json)</Button>
|
||
</Upload>
|
||
</Form.Item>
|
||
) : null}
|
||
{resultTarget?.crossStatus && resultTarget.crossStatus !== 'pending' ? (
|
||
<div style={{ marginBottom: 12 }}>
|
||
<Tag color={resultTarget.crossStatus === 'consistent' ? 'green' : 'red'}>
|
||
{resultTarget.crossStatus === 'consistent' ? '交叉验证:一致' : '交叉验证:不一致'}
|
||
</Tag>
|
||
{resultTarget.crossReason ? (
|
||
<span style={{ marginLeft: 8, color: '#666' }}>{resultTarget.crossReason}</span>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
<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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function SpectrumTab() {
|
||
const actionRef = useRef<ActionType>();
|
||
const [modalOpen, setModalOpen] = useState(false);
|
||
const [form] = Form.useForm<Partial<SpectrumEntry>>();
|
||
const [uploadedUrl, setUploadedUrl] = useState('');
|
||
|
||
const columns: ProColumns<SpectrumEntry>[] = [
|
||
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
|
||
{ title: '病种', dataIndex: 'disease' },
|
||
{ title: '来源', dataIndex: 'source', search: false, render: (_, r) => r.source || '-' },
|
||
{ title: '数据文件', dataIndex: 'dataUrl', search: false, render: (_, r) => (r.dataUrl ? <a href={r.dataUrl} target="_blank" rel="noreferrer">查看</a> : '-') },
|
||
{ title: '备注', dataIndex: 'note', search: false, render: (_, r) => r.note || '-' },
|
||
{
|
||
title: '操作',
|
||
valueType: 'option',
|
||
render: (_, r) => [
|
||
<Popconfirm
|
||
key="del"
|
||
title="确认删除该光谱条目?"
|
||
onConfirm={async () => {
|
||
await deleteSpectrumEntry(r.id);
|
||
message.success('已删除');
|
||
actionRef.current?.reload();
|
||
}}
|
||
>
|
||
<a style={{ color: '#ff4d4f' }}>删除</a>
|
||
</Popconfirm>,
|
||
],
|
||
},
|
||
];
|
||
|
||
return (
|
||
<>
|
||
<ProTable<SpectrumEntry>
|
||
actionRef={actionRef}
|
||
rowKey="id"
|
||
columns={columns}
|
||
search={false}
|
||
request={async () => {
|
||
const res = await listSpectrumEntries();
|
||
return { data: res, total: res.length, success: true };
|
||
}}
|
||
toolBarRender={() => [
|
||
<Button
|
||
key="new"
|
||
type="primary"
|
||
icon={<PlusOutlined />}
|
||
onClick={() => {
|
||
form.resetFields();
|
||
setUploadedUrl('');
|
||
setModalOpen(true);
|
||
}}
|
||
>
|
||
新增光谱
|
||
</Button>,
|
||
]}
|
||
/>
|
||
<Modal
|
||
title="新增光谱库条目"
|
||
open={modalOpen}
|
||
onCancel={() => setModalOpen(false)}
|
||
onOk={async () => {
|
||
const v = await form.validateFields();
|
||
await createSpectrumEntry({ ...v, dataUrl: uploadedUrl || v.dataUrl });
|
||
message.success('已保存');
|
||
setModalOpen(false);
|
||
actionRef.current?.reload();
|
||
}}
|
||
>
|
||
<Form form={form} layout="vertical">
|
||
<Form.Item label="病种" name="disease" rules={[{ required: true, message: '请输入病种' }]}>
|
||
<Input />
|
||
</Form.Item>
|
||
<Form.Item label="来源" name="source">
|
||
<Input placeholder="如:西南大学 / 本地采样" />
|
||
</Form.Item>
|
||
<Form.Item label="数据文件">
|
||
<Upload
|
||
maxCount={1}
|
||
accept=".csv,.txt,.json"
|
||
customRequest={async ({ file, onSuccess, onError }) => {
|
||
try {
|
||
const res = await uploadSpectrumEntryFile(file as File);
|
||
setUploadedUrl(res.url);
|
||
message.success('文件已上传');
|
||
onSuccess?.(res);
|
||
} catch (e) {
|
||
onError?.(e as Error);
|
||
}
|
||
}}
|
||
>
|
||
<Button icon={<PlusOutlined />}>上传光谱文件</Button>
|
||
</Upload>
|
||
</Form.Item>
|
||
<Form.Item label="数据 URL" name="dataUrl">
|
||
<Input placeholder="上传文件后自动填充,或手动填写" />
|
||
</Form.Item>
|
||
<Form.Item label="备注" name="note">
|
||
<Input.TextArea rows={2} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default function LampTestsPage() {
|
||
return (
|
||
<Tabs
|
||
defaultActiveKey="lamp"
|
||
items={[
|
||
{ key: 'lamp', label: '分子检测', children: <LampTab /> },
|
||
{ key: 'spectrum', label: '光谱库', children: <SpectrumTab /> },
|
||
]}
|
||
/>
|
||
);
|
||
}
|