186 lines
6.5 KiB
TypeScript
186 lines
6.5 KiB
TypeScript
import React, { useCallback, useEffect, useState } from 'react';
|
||
import { Button, Image, Radio, RadioGroup, ScrollView, Text, View } from '@tarojs/components';
|
||
import Taro, { usePullDownRefresh } from '@tarojs/taro';
|
||
import styles from './index.module.scss';
|
||
import {
|
||
listLampSteps,
|
||
listLampTests,
|
||
updateLampResult,
|
||
updateLampStep,
|
||
uploadLampResultImage,
|
||
type LampTest,
|
||
type LampTestStep,
|
||
} from '@/api/lamp';
|
||
import { formatRelativeTime } from '@/utils/format';
|
||
|
||
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 LampPage: React.FC = () => {
|
||
const [tests, setTests] = useState<LampTest[]>([]);
|
||
const [stepsMap, setStepsMap] = useState<Record<string, LampTestStep[]>>({});
|
||
const [expandedId, setExpandedId] = useState('');
|
||
const [result, setResult] = useState('');
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
const fetchData = useCallback(async () => {
|
||
try {
|
||
const list = await listLampTests().catch(() => [] as LampTest[]);
|
||
setTests(list);
|
||
const map: Record<string, LampTestStep[]> = {};
|
||
await Promise.all(
|
||
list.map(async (t) => {
|
||
map[t.id] = await listLampSteps(t.id).catch(() => [] as LampTestStep[]);
|
||
}),
|
||
);
|
||
setStepsMap(map);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetchData();
|
||
}, [fetchData]);
|
||
|
||
usePullDownRefresh(() => {
|
||
fetchData().then(() => Taro.stopPullDownRefresh());
|
||
});
|
||
|
||
const toggleExpand = (t: LampTest) => {
|
||
if (expandedId === t.id) {
|
||
setExpandedId('');
|
||
return;
|
||
}
|
||
setExpandedId(t.id);
|
||
setResult(t.result || '');
|
||
};
|
||
|
||
const handleStepToggle = async (t: LampTest, s: LampTestStep, done: boolean) => {
|
||
await updateLampStep(t.id, s.stepNo, done);
|
||
setStepsMap((prev) => ({
|
||
...prev,
|
||
[t.id]: (prev[t.id] || []).map((x) => (x.stepNo === s.stepNo ? { ...x, done } : x)),
|
||
}));
|
||
};
|
||
|
||
const handleChooseImage = (t: LampTest) => {
|
||
Taro.chooseImage({
|
||
count: 1,
|
||
sizeType: ['compressed'],
|
||
success: async (res) => {
|
||
const path = res.tempFilePaths[0];
|
||
try {
|
||
const up = await uploadLampResultImage(t.id, path);
|
||
setTests((prev) =>
|
||
prev.map((x) => (x.id === t.id ? { ...x, resultImageUrl: up.url } : x)),
|
||
);
|
||
Taro.showToast({ title: '照片已上传', icon: 'success' });
|
||
} catch (err) {
|
||
Taro.showToast({
|
||
title: err instanceof Error ? err.message : '上传失败',
|
||
icon: 'none',
|
||
});
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const handleSaveResult = async (t: LampTest) => {
|
||
if (!result) {
|
||
Taro.showToast({ title: '请选择检测结果', icon: 'none' });
|
||
return;
|
||
}
|
||
await updateLampResult(t.id, result);
|
||
Taro.showToast({ title: '结果已保存', icon: 'success' });
|
||
fetchData();
|
||
};
|
||
|
||
return (
|
||
<ScrollView className={styles.page} scrollY>
|
||
{tests.map((t) => {
|
||
const steps = stepsMap[t.id] || [];
|
||
const doneCount = steps.filter((s) => s.done).length;
|
||
const expanded = expandedId === t.id;
|
||
return (
|
||
<View key={t.id} className={styles.card}>
|
||
<View className={styles.cardHeader} onClick={() => toggleExpand(t)}>
|
||
<View className={styles.cardInfo}>
|
||
<Text className={styles.cardTitle}>{(t.diseases || []).join('、') || 'LAMP 检测'}</Text>
|
||
<Text className={styles.cardMeta}>
|
||
{METHOD_LABELS[t.method || 'lamp']} · {STATUS_LABELS[t.status] || t.status}
|
||
{t.sampleInfo ? ` · ${t.sampleInfo}` : ''} · {formatRelativeTime(t.createdAt)}
|
||
</Text>
|
||
</View>
|
||
<Text className={styles.cardArrow}>{expanded ? '▲' : '▼'}</Text>
|
||
</View>
|
||
{expanded ? (
|
||
<View className={styles.cardBody}>
|
||
<Text className={styles.sectionLabel}>操作步骤({doneCount}/{steps.length})</Text>
|
||
{steps.map((s) => (
|
||
<View key={s.id} className={styles.stepRow} onClick={() => handleStepToggle(t, s, !s.done)}>
|
||
<Text className={styles.stepName}>
|
||
{s.done ? '✅' : '⬜'} {s.stepNo}. {s.name}
|
||
</Text>
|
||
</View>
|
||
))}
|
||
|
||
<Text className={styles.sectionLabel}>结果照片</Text>
|
||
<View className={styles.photoRow}>
|
||
{t.resultImageUrl ? (
|
||
<Image className={styles.photo} src={t.resultImageUrl} mode="widthFix" />
|
||
) : null}
|
||
<Button className={styles.btn} onClick={() => handleChooseImage(t)}>
|
||
拍照上传
|
||
</Button>
|
||
</View>
|
||
|
||
<Text className={styles.sectionLabel}>检测结果(天蓝=阳性,紫罗兰=阴性)</Text>
|
||
<RadioGroup onChange={(e) => setResult(e.detail.value[0])}>
|
||
<View className={styles.radioRow}>
|
||
<Radio value="positive" checked={result === 'positive'}>阳性</Radio>
|
||
<Radio value="negative" checked={result === 'negative'}>阴性</Radio>
|
||
<Radio value="invalid" checked={result === 'invalid'}>无效</Radio>
|
||
</View>
|
||
</RadioGroup>
|
||
{t.result ? <Text className={styles.resultHint}>当前结果:{RESULT_LABELS[t.result]}</Text> : null}
|
||
{t.crossStatus && t.crossStatus !== 'pending' ? (
|
||
<Text className={t.crossStatus === 'consistent' ? styles.crossOk : styles.crossWarn}>
|
||
{t.crossStatus === 'consistent'
|
||
? '交叉验证:一致(确认诊断)✓'
|
||
: `交叉验证:不一致 ⚠ ${t.crossReason || '建议专家会诊'}`}
|
||
</Text>
|
||
) : null}
|
||
<Button className={`${styles.btn} ${styles.btnPrimary}`} onClick={() => handleSaveResult(t)}>
|
||
保存结果
|
||
</Button>
|
||
</View>
|
||
) : null}
|
||
</View>
|
||
);
|
||
})}
|
||
{!loading && tests.length === 0 ? (
|
||
<Text className={styles.emptyText}>暂无检测任务单</Text>
|
||
) : null}
|
||
</ScrollView>
|
||
);
|
||
};
|
||
|
||
export default LampPage;
|