feat(web/miniapp): 分子检测扩展(qPCR/SERS/光谱库,#19)
This commit is contained in:
@@ -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
@@ -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 /> },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user