Files
silk/web/src/pages/LampTests.tsx
T

489 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 /> },
]}
/>
);
}