feat(miniapp): 拍照巡检页(拍照上传 + AI 结果 + 历史记录,#8)

This commit is contained in:
weijuesen
2026-08-12 17:00:13 +08:00
parent 22b6179c85
commit 8274cc8c14
7 changed files with 420 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
import Taro from '@tarojs/taro';
import { getApiBaseUrl } from './config';
import { get } from './request';
import type { InspectionRecord } from '@/types';
export const uploadInspection = (
filePath: string,
roomId?: string,
): Promise<InspectionRecord> => {
const token = Taro.getStorageSync('token');
const idempotencyKey = `insp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const header: Record<string, string> = { 'Idempotency-Key': idempotencyKey };
if (token) {
header['Authorization'] = `Bearer ${token}`;
}
return new Promise((resolve, reject) => {
Taro.uploadFile({
url: `${getApiBaseUrl()}/inspections`,
filePath,
name: 'file',
header,
formData: roomId ? { roomId } : {},
timeout: 60000,
success: (res) => {
try {
const body = JSON.parse(res.data || '{}');
if (res.statusCode >= 400) {
reject(new Error(body?.error || `上传失败 (${res.statusCode})`));
return;
}
resolve(body as InspectionRecord);
} catch (e) {
reject(new Error('解析巡检结果失败'));
}
},
fail: (err) => reject(new Error(err?.errMsg || '上传失败')),
});
});
};
export const listInspections = (limit = 10): Promise<InspectionRecord[]> =>
get<InspectionRecord[]>('/inspections', { limit });
+1
View File
@@ -13,6 +13,7 @@ export default defineAppConfig({
'pages/settings/index',
'pages/knowledge/index',
'pages/knowledge/detail/index',
'pages/inspection/index',
],
window: {
backgroundTextStyle: 'dark',
@@ -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;
}
+170
View File
@@ -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;
+8
View File
@@ -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>
+20
View File
@@ -209,6 +209,26 @@ export interface KnowledgeArticle {
sortOrder?: number;
}
export interface AIDetection {
bbox: { x: number; y: number; w: number; h: number };
class: string;
confidence: number;
}
export interface InspectionRecord {
id: string;
userId?: string;
roomId?: string;
imageUrl?: string;
detections?: AIDetection[];
riskScore?: number;
riskLevel?: string;
aiStatus: string;
idempotencyKey?: string;
createdAt?: string;
updatedAt?: string;
}
export interface ApiResponse<T> {
data: T;
message?: string;