feat(miniapp): 拍照巡检页(拍照上传 + AI 结果 + 历史记录,#8)
This commit is contained in:
@@ -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 });
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user