feat(web/miniapp): LAMP 检测管理页与小程序录入(#14)+ web 构建代码拆分
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: 'LAMP 检测',
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
@use '@/styles/variables.scss' as *;
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: $color-bg-card;
|
||||
border-radius: $radius-lg;
|
||||
padding: $spacing-lg;
|
||||
box-shadow: $shadow-card;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cardInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
display: block;
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
.cardMeta {
|
||||
display: block;
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-secondary;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.cardArrow {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-tertiary;
|
||||
margin-left: $spacing-sm;
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
margin-top: $spacing-md;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
display: block;
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-tertiary;
|
||||
margin: $spacing-md 0 $spacing-xs;
|
||||
}
|
||||
|
||||
.stepRow {
|
||||
padding: $spacing-xs 0;
|
||||
}
|
||||
|
||||
.stepName {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
.photoRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.photo {
|
||||
width: 100%;
|
||||
border-radius: $radius-sm;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin: 0;
|
||||
@include button-reset;
|
||||
border: 1rpx solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: $radius-button;
|
||||
height: $button-height-md;
|
||||
font-size: $font-size-md;
|
||||
color: $color-text-primary;
|
||||
background: $color-bg-card;
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
margin-top: $spacing-md;
|
||||
background: $color-primary;
|
||||
color: $color-text-white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.radioRow {
|
||||
display: flex;
|
||||
gap: $spacing-lg;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.resultHint {
|
||||
display: block;
|
||||
font-size: $font-size-xs;
|
||||
color: $color-success;
|
||||
margin-top: $spacing-xs;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-tertiary;
|
||||
padding: $spacing-xl 0;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
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 LampPage: React.FC = () => {
|
||||
const [tests, setTests] = useState<LampTest[]>([]);
|
||||
const [stepsMap, setStepsMap] = useState<Record<string, LampTestStep[]>>({});
|
||||
const [expandedId, setExpandedId] = useState('');
|
||||
const [result, setResult] = useState('');
|
||||
const [imagePath, setImagePath] = 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 || '');
|
||||
setImagePath('');
|
||||
};
|
||||
|
||||
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];
|
||||
setImagePath(path);
|
||||
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}>
|
||||
{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 value={result} onChange={(e) => setResult(e.detail.value)}>
|
||||
<View className={styles.radioRow}>
|
||||
<Radio value="positive">阳性</Radio>
|
||||
<Radio value="negative">阴性</Radio>
|
||||
<Radio value="invalid">无效</Radio>
|
||||
</View>
|
||||
</RadioGroup>
|
||||
{t.result ? <Text className={styles.resultHint}>当前结果:{RESULT_LABELS[t.result]}</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;
|
||||
@@ -56,6 +56,9 @@ const SettingsPage: React.FC = () => {
|
||||
case 'notification':
|
||||
Taro.navigateTo({ url: '/pages/notification/index' });
|
||||
break;
|
||||
case 'lamp':
|
||||
Taro.navigateTo({ url: '/pages/lamp/index' });
|
||||
break;
|
||||
case 'about':
|
||||
Taro.showModal({
|
||||
title: '关于',
|
||||
@@ -182,6 +185,11 @@ const SettingsPage: React.FC = () => {
|
||||
<Text className={styles.menuLabel}>消息订阅</Text>
|
||||
<Text className={styles.menuArrow}>›</Text>
|
||||
</View>
|
||||
<View className={styles.menuItem} onClick={() => handleMenuTap('lamp')}>
|
||||
<Text className={styles.menuIcon}>🧪</Text>
|
||||
<Text className={styles.menuLabel}>LAMP 检测</Text>
|
||||
<Text className={styles.menuArrow}>›</Text>
|
||||
</View>
|
||||
<View className={styles.menuItem} onClick={() => handleMenuTap('wsStatus')}>
|
||||
<Text className={styles.menuIcon}>🔗</Text>
|
||||
<Text className={styles.menuLabel}>连接状态</Text>
|
||||
|
||||
Reference in New Issue
Block a user