feat(web/miniapp): 分子检测扩展(qPCR/SERS/光谱库,#19)

This commit is contained in:
weijuesen
2026-08-12 19:33:57 +08:00
parent 7bb87dd89c
commit 3cd1b824fb
4 changed files with 230 additions and 9 deletions
+31
View File
@@ -2,6 +2,7 @@ import { get, post, patch, del } from '../api/http';
export interface LampTest {
id: string;
method?: string;
roomId?: string;
batchId?: string;
diseases?: string[];
@@ -13,6 +14,7 @@ export interface LampTest {
resultedAt?: string;
crossStatus?: string;
crossReason?: string;
extraData?: any;
note?: string;
createdAt?: string;
}
@@ -40,3 +42,32 @@ export const uploadLampResultImage = (id: string, file: File) => {
fd.append('file', file);
return post<{ url: string }>(`/lamp-tests/${id}/result-image`, fd);
};
export const judgeQPCR = (id: string, ctValues: number[], threshold?: number) =>
post<LampTest>(`/lamp-tests/${id}/judge-qpcr`, { ctValues, threshold });
export const uploadLampSpectrum = (id: string, file: File) => {
const fd = new FormData();
fd.append('file', file);
return post<{ url: string }>(`/lamp-tests/${id}/spectrum`, fd);
};
export interface SpectrumEntry {
id: string;
disease: string;
source?: string;
dataUrl?: string;
note?: string;
createdAt?: string;
}
export const listSpectrumEntries = (params?: any) =>
get<SpectrumEntry[]>('/spectrum-entries', { params });
export const createSpectrumEntry = (data: Partial<SpectrumEntry>) =>
post<SpectrumEntry>('/spectrum-entries', data);
export const uploadSpectrumEntryFile = (file: File) => {
const fd = new FormData();
fd.append('file', file);
return post<{ url: string }>('/spectrum-entries/upload', fd);
};
export const deleteSpectrumEntry = (id: string) => del(`/spectrum-entries/${id}`);
+190 -8
View File
@@ -14,8 +14,15 @@ import {
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';
@@ -34,6 +41,13 @@ const RESULT_LABELS: Record<string, string> = {
invalid: '无效',
};
const METHOD_LABELS: Record<string, string> = {
lamp: 'LAMP',
qpcr: 'qPCR',
sers: 'SERS',
hyperspectral: '高光谱',
};
const canWrite = () => authService.hasPermission('lamp:write');
function LampTab() {
@@ -50,6 +64,8 @@ function LampTab() {
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([
@@ -83,6 +99,7 @@ function LampTab() {
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 || '-' },
@@ -183,6 +200,9 @@ function LampTab() {
<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
@@ -216,14 +236,67 @@ function LampTab() {
>
<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: '无效' },
]}
/>
{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'}>
@@ -296,11 +369,120 @@ function LampTab() {
);
}
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: 'LAMP 检测', children: <LampTab /> }]}
items={[
{ key: 'lamp', label: '分子检测', children: <LampTab /> },
{ key: 'spectrum', label: '光谱库', children: <SpectrumTab /> },
]}
/>
);
}