From 8274cc8c148bf331602c9c8cfb630a5796295070 Mon Sep 17 00:00:00 2001 From: weijuesen Date: Wed, 12 Aug 2026 17:00:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(miniapp):=20=E6=8B=8D=E7=85=A7=E5=B7=A1?= =?UTF-8?q?=E6=A3=80=E9=A1=B5=EF=BC=88=E6=8B=8D=E7=85=A7=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=20+=20AI=20=E7=BB=93=E6=9E=9C=20+=20=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=EF=BC=8C#8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniapp/src/api/inspections.ts | 42 +++++ miniapp/src/app.config.ts | 1 + miniapp/src/pages/inspection/index.config.ts | 3 + .../src/pages/inspection/index.module.scss | 176 ++++++++++++++++++ miniapp/src/pages/inspection/index.tsx | 170 +++++++++++++++++ miniapp/src/pages/settings/index.tsx | 8 + miniapp/src/types/index.ts | 20 ++ 7 files changed, 420 insertions(+) create mode 100644 miniapp/src/api/inspections.ts create mode 100644 miniapp/src/pages/inspection/index.config.ts create mode 100644 miniapp/src/pages/inspection/index.module.scss create mode 100644 miniapp/src/pages/inspection/index.tsx diff --git a/miniapp/src/api/inspections.ts b/miniapp/src/api/inspections.ts new file mode 100644 index 0000000..7988676 --- /dev/null +++ b/miniapp/src/api/inspections.ts @@ -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 => { + const token = Taro.getStorageSync('token'); + const idempotencyKey = `insp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const header: Record = { '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 => + get('/inspections', { limit }); diff --git a/miniapp/src/app.config.ts b/miniapp/src/app.config.ts index 4cae80f..09d618f 100644 --- a/miniapp/src/app.config.ts +++ b/miniapp/src/app.config.ts @@ -13,6 +13,7 @@ export default defineAppConfig({ 'pages/settings/index', 'pages/knowledge/index', 'pages/knowledge/detail/index', + 'pages/inspection/index', ], window: { backgroundTextStyle: 'dark', diff --git a/miniapp/src/pages/inspection/index.config.ts b/miniapp/src/pages/inspection/index.config.ts new file mode 100644 index 0000000..c9cd2d2 --- /dev/null +++ b/miniapp/src/pages/inspection/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '拍照巡检', +}); diff --git a/miniapp/src/pages/inspection/index.module.scss b/miniapp/src/pages/inspection/index.module.scss new file mode 100644 index 0000000..78e1b13 --- /dev/null +++ b/miniapp/src/pages/inspection/index.module.scss @@ -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; +} diff --git a/miniapp/src/pages/inspection/index.tsx b/miniapp/src/pages/inspection/index.tsx new file mode 100644 index 0000000..2ee0137 --- /dev/null +++ b/miniapp/src/pages/inspection/index.tsx @@ -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 = { + 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(null); + const [history, setHistory] = useState([]); + 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 ( + + + {imagePath ? ( + + ) : ( + + 📷 + 拍照或从相册选择蚕体照片 + + )} + + + {imagePath ? ( + + ) : null} + + + + {result ? ( + + + {result.aiStatus === 'done' + ? hasRisk(result) + ? '检测到疑似异常' + : '未见明显异常' + : 'AI 检测失败'} + + {result.aiStatus === 'done' && result.detections && result.detections.length > 0 ? ( + + {result.detections.map((d, idx) => ( + + {classLabel(d.class)} + + 置信度 {(d.confidence * 100).toFixed(1)}% + + + ))} + + ) : null} + {hasRisk(result) ? ( + + 建议结合「知识库」比对症状,必要时做 LAMP/qPCR 分子检测确认。 + + ) : null} + + ) : null} + + 巡检记录 + + {history.map((rec) => ( + + + + {rec.aiStatus === 'done' + ? hasRisk(rec) + ? '疑似异常' + : '正常' + : '检测失败'} + + {formatRelativeTime(rec.createdAt)} + + {rec.detections && rec.detections.length > 0 ? ( + + {rec.detections + .map((d) => `${classLabel(d.class)} ${(d.confidence * 100).toFixed(1)}%`) + .join('、')} + + ) : ( + 无检测结果 + )} + + ))} + {!loadingHistory && history.length === 0 ? ( + 暂无巡检记录 + ) : null} + + + ); +}; + +export default InspectionPage; diff --git a/miniapp/src/pages/settings/index.tsx b/miniapp/src/pages/settings/index.tsx index f48d4f1..02f91ab 100644 --- a/miniapp/src/pages/settings/index.tsx +++ b/miniapp/src/pages/settings/index.tsx @@ -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 = () => { 知识库 + handleMenuTap('inspection')}> + 📷 + 拍照巡检 + + handleMenuTap('wsStatus')}> 🔗 连接状态 diff --git a/miniapp/src/types/index.ts b/miniapp/src/types/index.ts index f580ab8..82db964 100644 --- a/miniapp/src/types/index.ts +++ b/miniapp/src/types/index.ts @@ -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 { data: T; message?: string;