feat(web/miniapp): 分子检测扩展(qPCR/SERS/光谱库,#19)
This commit is contained in:
@@ -4,6 +4,7 @@ import { getApiBaseUrl } from './config';
|
|||||||
|
|
||||||
export interface LampTest {
|
export interface LampTest {
|
||||||
id: string;
|
id: string;
|
||||||
|
method?: string;
|
||||||
roomId?: string;
|
roomId?: string;
|
||||||
diseases?: string[];
|
diseases?: string[];
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ const RESULT_LABELS: Record<string, string> = {
|
|||||||
invalid: '无效',
|
invalid: '无效',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const METHOD_LABELS: Record<string, string> = {
|
||||||
|
lamp: 'LAMP',
|
||||||
|
qpcr: 'qPCR',
|
||||||
|
sers: 'SERS',
|
||||||
|
hyperspectral: '高光谱',
|
||||||
|
};
|
||||||
|
|
||||||
const LampPage: React.FC = () => {
|
const LampPage: React.FC = () => {
|
||||||
const [tests, setTests] = useState<LampTest[]>([]);
|
const [tests, setTests] = useState<LampTest[]>([]);
|
||||||
const [stepsMap, setStepsMap] = useState<Record<string, LampTestStep[]>>({});
|
const [stepsMap, setStepsMap] = useState<Record<string, LampTestStep[]>>({});
|
||||||
@@ -120,7 +127,7 @@ const LampPage: React.FC = () => {
|
|||||||
<View className={styles.cardInfo}>
|
<View className={styles.cardInfo}>
|
||||||
<Text className={styles.cardTitle}>{(t.diseases || []).join('、') || 'LAMP 检测'}</Text>
|
<Text className={styles.cardTitle}>{(t.diseases || []).join('、') || 'LAMP 检测'}</Text>
|
||||||
<Text className={styles.cardMeta}>
|
<Text className={styles.cardMeta}>
|
||||||
{STATUS_LABELS[t.status] || t.status}
|
{METHOD_LABELS[t.method || 'lamp']} · {STATUS_LABELS[t.status] || t.status}
|
||||||
{t.sampleInfo ? ` · ${t.sampleInfo}` : ''} · {formatRelativeTime(t.createdAt)}
|
{t.sampleInfo ? ` · ${t.sampleInfo}` : ''} · {formatRelativeTime(t.createdAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { get, post, patch, del } from '../api/http';
|
|||||||
|
|
||||||
export interface LampTest {
|
export interface LampTest {
|
||||||
id: string;
|
id: string;
|
||||||
|
method?: string;
|
||||||
roomId?: string;
|
roomId?: string;
|
||||||
batchId?: string;
|
batchId?: string;
|
||||||
diseases?: string[];
|
diseases?: string[];
|
||||||
@@ -13,6 +14,7 @@ export interface LampTest {
|
|||||||
resultedAt?: string;
|
resultedAt?: string;
|
||||||
crossStatus?: string;
|
crossStatus?: string;
|
||||||
crossReason?: string;
|
crossReason?: string;
|
||||||
|
extraData?: any;
|
||||||
note?: string;
|
note?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
@@ -40,3 +42,32 @@ export const uploadLampResultImage = (id: string, file: File) => {
|
|||||||
fd.append('file', file);
|
fd.append('file', file);
|
||||||
return post<{ url: string }>(`/lamp-tests/${id}/result-image`, fd);
|
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,
|
listLampSteps,
|
||||||
updateLampStep,
|
updateLampStep,
|
||||||
uploadLampResultImage,
|
uploadLampResultImage,
|
||||||
|
judgeQPCR,
|
||||||
|
uploadLampSpectrum,
|
||||||
|
listSpectrumEntries,
|
||||||
|
createSpectrumEntry,
|
||||||
|
uploadSpectrumEntryFile,
|
||||||
|
deleteSpectrumEntry,
|
||||||
type LampTest,
|
type LampTest,
|
||||||
type LampTestStep,
|
type LampTestStep,
|
||||||
|
type SpectrumEntry,
|
||||||
} from '../dal/lamp';
|
} from '../dal/lamp';
|
||||||
import { listDiseases } from '../dal/knowledge';
|
import { listDiseases } from '../dal/knowledge';
|
||||||
import { listHouses, type SilkwormHouse } from '../dal/silkworm';
|
import { listHouses, type SilkwormHouse } from '../dal/silkworm';
|
||||||
@@ -34,6 +41,13 @@ const RESULT_LABELS: Record<string, string> = {
|
|||||||
invalid: '无效',
|
invalid: '无效',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const METHOD_LABELS: Record<string, string> = {
|
||||||
|
lamp: 'LAMP',
|
||||||
|
qpcr: 'qPCR',
|
||||||
|
sers: 'SERS',
|
||||||
|
hyperspectral: '高光谱',
|
||||||
|
};
|
||||||
|
|
||||||
const canWrite = () => authService.hasPermission('lamp:write');
|
const canWrite = () => authService.hasPermission('lamp:write');
|
||||||
|
|
||||||
function LampTab() {
|
function LampTab() {
|
||||||
@@ -50,6 +64,8 @@ function LampTab() {
|
|||||||
const [stepsOpen, setStepsOpen] = useState(false);
|
const [stepsOpen, setStepsOpen] = useState(false);
|
||||||
const [stepsTarget, setStepsTarget] = useState<LampTest | null>(null);
|
const [stepsTarget, setStepsTarget] = useState<LampTest | null>(null);
|
||||||
const [steps, setSteps] = useState<LampTestStep[]>([]);
|
const [steps, setSteps] = useState<LampTestStep[]>([]);
|
||||||
|
const [ctValues, setCtValues] = useState('');
|
||||||
|
const [threshold, setThreshold] = useState<number>(35);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -83,6 +99,7 @@ function LampTab() {
|
|||||||
|
|
||||||
const columns: ProColumns<LampTest>[] = [
|
const columns: ProColumns<LampTest>[] = [
|
||||||
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
|
{ 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: 'roomId', valueType: 'select', valueEnum: roomEnum, render: (_, r) => roomName(r.roomId) },
|
||||||
{ title: '检测病种', dataIndex: 'diseases', search: false, render: (_, r) => (r.diseases || []).join('、') || '-' },
|
{ title: '检测病种', dataIndex: 'diseases', search: false, render: (_, r) => (r.diseases || []).join('、') || '-' },
|
||||||
{ title: '采样信息', dataIndex: 'sampleInfo', search: false, render: (_, r) => r.sampleInfo || '-' },
|
{ title: '采样信息', dataIndex: 'sampleInfo', search: false, render: (_, r) => r.sampleInfo || '-' },
|
||||||
@@ -183,6 +200,9 @@ function LampTab() {
|
|||||||
<Form.Item label="蚕房" name="roomId">
|
<Form.Item label="蚕房" name="roomId">
|
||||||
<Select allowClear options={rooms.map((r) => ({ value: r.id, label: r.name }))} />
|
<Select allowClear options={rooms.map((r) => ({ value: r.id, label: r.name }))} />
|
||||||
</Form.Item>
|
</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">
|
<Form.Item label="蚕种批次" name="batchId">
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
@@ -216,14 +236,67 @@ function LampTab() {
|
|||||||
>
|
>
|
||||||
<Form form={resultForm} layout="vertical">
|
<Form form={resultForm} layout="vertical">
|
||||||
<Form.Item label="检测结果" name="result" rules={[{ required: true, message: '请选择结果' }]}>
|
<Form.Item label="检测结果" name="result" rules={[{ required: true, message: '请选择结果' }]}>
|
||||||
<Radio.Group
|
{resultTarget?.method === 'qpcr' ? (
|
||||||
options={[
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
{ value: 'positive', label: '阳性(天蓝色)' },
|
<Input
|
||||||
{ value: 'negative', label: '阴性(紫罗兰色)' },
|
placeholder="Ct 值,逗号分隔,如 32,35"
|
||||||
{ value: 'invalid', label: '无效' },
|
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>
|
</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' ? (
|
{resultTarget?.crossStatus && resultTarget.crossStatus !== 'pending' ? (
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<Tag color={resultTarget.crossStatus === 'consistent' ? 'green' : 'red'}>
|
<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() {
|
export default function LampTestsPage() {
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultActiveKey="lamp"
|
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