feat(web/miniapp): LAMP 检测管理页与小程序录入(#14)+ web 构建代码拆分

This commit is contained in:
weijuesen
2026-08-12 17:52:07 +08:00
parent c6e5a76e71
commit d626f9d442
11 changed files with 716 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: 'LAMP 检测',
});
+117
View File
@@ -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;
}
+174
View File
@@ -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;
+8
View File
@@ -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>