feat(miniapp): 拍照巡检页(拍照上传 + AI 结果 + 历史记录,#8)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '拍照巡检',
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
@use '@/styles/variables.scss' as *;
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.uploadCard {
|
||||
background: $color-bg-card;
|
||||
border-radius: $radius-lg;
|
||||
padding: $spacing-lg;
|
||||
box-shadow: $shadow-card;
|
||||
}
|
||||
|
||||
.preview {
|
||||
width: 100%;
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 360rpx;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 2rpx dashed rgba(0, 0, 0, 0.12);
|
||||
border-radius: $radius-md;
|
||||
}
|
||||
|
||||
.placeholderIcon {
|
||||
font-size: 80rpx;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.placeholderText {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-tertiary;
|
||||
}
|
||||
|
||||
.btnRow {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
margin-top: $spacing-lg;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
@include button-reset;
|
||||
}
|
||||
|
||||
.btnPrimary {
|
||||
background: $color-primary;
|
||||
color: $color-text-white;
|
||||
border-radius: $radius-button;
|
||||
height: $button-height-md;
|
||||
font-size: $font-size-md;
|
||||
}
|
||||
|
||||
.resultCard {
|
||||
border-radius: $radius-lg;
|
||||
padding: $spacing-lg;
|
||||
margin-top: $spacing-lg;
|
||||
}
|
||||
|
||||
.resultOk {
|
||||
background: rgba(0, 180, 42, 0.1);
|
||||
}
|
||||
|
||||
.resultWarn {
|
||||
background: rgba(255, 125, 0, 0.12);
|
||||
}
|
||||
|
||||
.resultTitle {
|
||||
display: block;
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $color-text-primary;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.detectionList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.detectionRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detectionClass {
|
||||
font-size: $font-size-md;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
.detectionConf {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.resultTip {
|
||||
display: block;
|
||||
font-size: $font-size-xs;
|
||||
color: $color-warning;
|
||||
line-height: $line-height-normal;
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
display: block;
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $color-text-primary;
|
||||
margin: $spacing-lg 0 $spacing-md;
|
||||
}
|
||||
|
||||
.historyList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.historyCard {
|
||||
background: $color-bg-card;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-lg;
|
||||
box-shadow: $shadow-card;
|
||||
}
|
||||
|
||||
.historyHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
.historyStatus {
|
||||
font-size: $font-size-xs;
|
||||
padding: 4rpx $spacing-sm;
|
||||
border-radius: $radius-round;
|
||||
}
|
||||
|
||||
.statusOk {
|
||||
color: $color-success;
|
||||
background: rgba(0, 180, 42, 0.1);
|
||||
}
|
||||
|
||||
.statusWarn {
|
||||
color: $color-warning;
|
||||
background: rgba(255, 125, 0, 0.12);
|
||||
}
|
||||
|
||||
.historyTime {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-tertiary;
|
||||
}
|
||||
|
||||
.historyDetail {
|
||||
display: block;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-tertiary;
|
||||
padding: $spacing-xl 0;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Image, ScrollView, Text, View } from '@tarojs/components';
|
||||
import Taro, { usePullDownRefresh } from '@tarojs/taro';
|
||||
import styles from './index.module.scss';
|
||||
import { listInspections, uploadInspection } from '@/api/inspections';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
import type { InspectionRecord } from '@/types';
|
||||
|
||||
const CLASS_LABELS: Record<string, string> = {
|
||||
healthy: '健康',
|
||||
sick: '疑似异常(病蚕)',
|
||||
};
|
||||
|
||||
const classLabel = (cls: string) => CLASS_LABELS[cls] || cls;
|
||||
|
||||
const InspectionPage: React.FC = () => {
|
||||
const [imagePath, setImagePath] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [result, setResult] = useState<InspectionRecord | null>(null);
|
||||
const [history, setHistory] = useState<InspectionRecord[]>([]);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
setLoadingHistory(true);
|
||||
try {
|
||||
const list = await listInspections(10).catch(() => [] as InspectionRecord[]);
|
||||
setHistory(list);
|
||||
} finally {
|
||||
setLoadingHistory(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
}, [loadHistory]);
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
loadHistory().then(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
const handleChooseImage = () => {
|
||||
Taro.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['camera', 'album'],
|
||||
success: (res) => {
|
||||
const path = res.tempFilePaths[0];
|
||||
if (path) {
|
||||
setImagePath(path);
|
||||
setResult(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!imagePath) {
|
||||
Taro.showToast({ title: '请先拍照或选择图片', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const rec = await uploadInspection(imagePath);
|
||||
setResult(rec);
|
||||
Taro.showToast({ title: rec.aiStatus === 'done' ? '检测完成' : '检测失败', icon: 'none' });
|
||||
loadHistory();
|
||||
} catch (err) {
|
||||
Taro.showToast({
|
||||
title: err instanceof Error ? err.message : '巡检失败',
|
||||
icon: 'none',
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasRisk = (rec: InspectionRecord) =>
|
||||
(rec.detections || []).some((d) => d.class !== 'healthy');
|
||||
|
||||
return (
|
||||
<ScrollView className={styles.page} scrollY>
|
||||
<View className={styles.uploadCard}>
|
||||
{imagePath ? (
|
||||
<Image className={styles.preview} src={imagePath} mode="widthFix" />
|
||||
) : (
|
||||
<View className={styles.placeholder} onClick={handleChooseImage}>
|
||||
<Text className={styles.placeholderIcon}>📷</Text>
|
||||
<Text className={styles.placeholderText}>拍照或从相册选择蚕体照片</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className={styles.btnRow}>
|
||||
<Button className={`${styles.btn} ${styles.btnPrimary}`} onClick={handleChooseImage}>
|
||||
{imagePath ? '重新拍照' : '拍照 / 选择'}
|
||||
</Button>
|
||||
{imagePath ? (
|
||||
<Button
|
||||
className={`${styles.btn} ${styles.btnPrimary}`}
|
||||
loading={uploading}
|
||||
disabled={uploading}
|
||||
onClick={handleUpload}
|
||||
>
|
||||
{uploading ? '检测中...' : '开始 AI 检测'}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{result ? (
|
||||
<View className={`${styles.resultCard} ${hasRisk(result) ? styles.resultWarn : styles.resultOk}`}>
|
||||
<Text className={styles.resultTitle}>
|
||||
{result.aiStatus === 'done'
|
||||
? hasRisk(result)
|
||||
? '检测到疑似异常'
|
||||
: '未见明显异常'
|
||||
: 'AI 检测失败'}
|
||||
</Text>
|
||||
{result.aiStatus === 'done' && result.detections && result.detections.length > 0 ? (
|
||||
<View className={styles.detectionList}>
|
||||
{result.detections.map((d, idx) => (
|
||||
<View key={idx} className={styles.detectionRow}>
|
||||
<Text className={styles.detectionClass}>{classLabel(d.class)}</Text>
|
||||
<Text className={styles.detectionConf}>
|
||||
置信度 {(d.confidence * 100).toFixed(1)}%
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{hasRisk(result) ? (
|
||||
<Text className={styles.resultTip}>
|
||||
建议结合「知识库」比对症状,必要时做 LAMP/qPCR 分子检测确认。
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<Text className={styles.sectionTitle}>巡检记录</Text>
|
||||
<View className={styles.historyList}>
|
||||
{history.map((rec) => (
|
||||
<View key={rec.id} className={styles.historyCard}>
|
||||
<View className={styles.historyHeader}>
|
||||
<Text className={`${styles.historyStatus} ${hasRisk(rec) ? styles.statusWarn : styles.statusOk}`}>
|
||||
{rec.aiStatus === 'done'
|
||||
? hasRisk(rec)
|
||||
? '疑似异常'
|
||||
: '正常'
|
||||
: '检测失败'}
|
||||
</Text>
|
||||
<Text className={styles.historyTime}>{formatRelativeTime(rec.createdAt)}</Text>
|
||||
</View>
|
||||
{rec.detections && rec.detections.length > 0 ? (
|
||||
<Text className={styles.historyDetail}>
|
||||
{rec.detections
|
||||
.map((d) => `${classLabel(d.class)} ${(d.confidence * 100).toFixed(1)}%`)
|
||||
.join('、')}
|
||||
</Text>
|
||||
) : (
|
||||
<Text className={styles.historyDetail}>无检测结果</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
{!loadingHistory && history.length === 0 ? (
|
||||
<Text className={styles.emptyText}>暂无巡检记录</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default InspectionPage;
|
||||
@@ -50,6 +50,9 @@ const SettingsPage: React.FC = () => {
|
||||
case 'knowledge':
|
||||
Taro.navigateTo({ url: '/pages/knowledge/index' });
|
||||
break;
|
||||
case 'inspection':
|
||||
Taro.navigateTo({ url: '/pages/inspection/index' });
|
||||
break;
|
||||
case 'about':
|
||||
Taro.showModal({
|
||||
title: '关于',
|
||||
@@ -166,6 +169,11 @@ const SettingsPage: React.FC = () => {
|
||||
<Text className={styles.menuLabel}>知识库</Text>
|
||||
<Text className={styles.menuArrow}>›</Text>
|
||||
</View>
|
||||
<View className={styles.menuItem} onClick={() => handleMenuTap('inspection')}>
|
||||
<Text className={styles.menuIcon}>📷</Text>
|
||||
<Text className={styles.menuLabel}>拍照巡检</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