From fe9b7707e2c9a3e7f043ab211587ed25f1df00ef Mon Sep 17 00:00:00 2001
From: gaoluyang <2820782392@qq.com>
Date: 星期四, 23 十月 2025 13:20:20 +0800
Subject: [PATCH] 智能巡检前端页面

---
 src/api/equipment/monitoring/equipment.js     |  203 ++++++++++++++++
 src/views/equipment/smartInspection/index.vue |  470 +++++++++++++++++++++++++++++++++++++++
 2 files changed, 673 insertions(+), 0 deletions(-)

diff --git a/src/api/equipment/monitoring/equipment.js b/src/api/equipment/monitoring/equipment.js
new file mode 100644
index 0000000..4276b98
--- /dev/null
+++ b/src/api/equipment/monitoring/equipment.js
@@ -0,0 +1,203 @@
+// 妯℃嫙宸ヤ笟浠ュお缃�/5G 缃戝叧鏁版嵁鎺ュ叆涓庤繙绋嬫帶鍒讹紙鍓嶇閫犳暟锛�
+// 璁惧鍥寸粫鐓ょ偔鍔犲伐鍦烘櫙锛氬甫寮忚緭閫佹満銆佺牬纰庢満銆佺毊甯︾Г銆侀櫎灏橀鏈恒�佺粰鐓ゆ満绛�
+
+const DEFAULT_THRESHOLDS = {
+  temperatureC: 85, // 鈩�
+  pressureBar: 12, // bar
+  currentA: 180, // A
+  voltageV: 420, // V锛堜笁鐩哥嚎鐢靛帇锛�
+  powerFactor: 0.85 // cos蠁
+}
+
+const STORAGE_KEYS = {
+  thresholds: 'monitor_equipment_thresholds',
+  channels: 'monitor_equipment_alarm_channels'
+}
+
+const DEFAULT_CHANNELS = {
+  platform: true,
+  sms: false,
+  voice: false
+}
+
+const BASE_EQUIPMENTS = [
+  { id: 'conv-01', name: '涓荤叅娴佸甫寮忚緭閫佹満#1', location: '绛涘垎杞﹂棿涓�绾�', type: 'conveyor' },
+  { id: 'crusher-01', name: '榻胯緤鐮寸鏈�#1', location: '鐮寸宸ユ', type: 'crusher' },
+  { id: 'feeder-01', name: '鐢垫満鎸姩缁欑叅鏈�#1', location: '鍘熺叅浠撲笅鍙�', type: 'feeder' },
+  { id: 'blower-01', name: '闄ゅ皹绂诲績椋庢満#1', location: '瑁呰溅闄ゅ皹鐐�', type: 'blower' },
+  { id: 'scale-01', name: '鐨甫绉�#1', location: '璁¢噺娈�', type: 'beltScale' }
+]
+
+function getRandomAround(base, fluct = 0.05) {
+  const delta = base * fluct
+  return +(base + (Math.random() * 2 - 1) * delta).toFixed(2)
+}
+
+function seededInitMetrics(type) {
+  switch (type) {
+    case 'conveyor':
+      return { temperatureC: 55, pressureBar: 6.5, currentA: 95, voltageV: 400, powerFactor: 0.92 }
+    case 'crusher':
+      return { temperatureC: 65, pressureBar: 10.5, currentA: 140, voltageV: 405, powerFactor: 0.9 }
+    case 'feeder':
+      return { temperatureC: 50, pressureBar: 5.5, currentA: 60, voltageV: 398, powerFactor: 0.93 }
+    case 'blower':
+      return { temperatureC: 70, pressureBar: 8.2, currentA: 120, voltageV: 402, powerFactor: 0.88 }
+    case 'beltScale':
+      return { temperatureC: 48, pressureBar: 0.0, currentA: 35, voltageV: 401, powerFactor: 0.95 }
+    default:
+      return { temperatureC: 55, pressureBar: 6, currentA: 90, voltageV: 400, powerFactor: 0.9 }
+  }
+}
+
+function nextMetrics(current, type) {
+  const drift = {
+    temperatureC: getRandomAround(current.temperatureC, 0.03),
+    pressureBar: getRandomAround(current.pressureBar, 0.06),
+    currentA: getRandomAround(current.currentA, 0.08),
+    voltageV: getRandomAround(current.voltageV, 0.01),
+    powerFactor: Math.max(0.6, Math.min(0.99, getRandomAround(current.powerFactor, 0.02)))
+  }
+  if (type === 'crusher') drift.currentA += Math.random() * 4 - 2
+  if (type === 'blower') drift.powerFactor -= Math.random() * 0.01
+  return {
+    temperatureC: +drift.temperatureC.toFixed(2),
+    pressureBar: +drift.pressureBar.toFixed(2),
+    currentA: +drift.currentA.toFixed(2),
+    voltageV: +drift.voltageV.toFixed(2),
+    powerFactor: +drift.powerFactor.toFixed(2)
+  }
+}
+
+export function getThresholds() {
+  try {
+    const raw = localStorage.getItem(STORAGE_KEYS.thresholds)
+    if (!raw) return { ...DEFAULT_THRESHOLDS }
+    const obj = JSON.parse(raw)
+    return { ...DEFAULT_THRESHOLDS, ...obj }
+  } catch (e) {
+    return { ...DEFAULT_THRESHOLDS }
+  }
+}
+
+export function saveThresholds(thresholds) {
+  const merged = { ...DEFAULT_THRESHOLDS, ...thresholds }
+  localStorage.setItem(STORAGE_KEYS.thresholds, JSON.stringify(merged))
+  return merged
+}
+
+export function getAlarmChannels() {
+  try {
+    const raw = localStorage.getItem(STORAGE_KEYS.channels)
+    if (!raw) return { ...DEFAULT_CHANNELS }
+    const obj = JSON.parse(raw)
+    return { ...DEFAULT_CHANNELS, ...obj }
+  } catch (e) {
+    return { ...DEFAULT_CHANNELS }
+  }
+}
+
+export function saveAlarmChannels(channels) {
+  const merged = { ...DEFAULT_CHANNELS, ...channels }
+  localStorage.setItem(STORAGE_KEYS.channels, JSON.stringify(merged))
+  return merged
+}
+
+// 璁㈤槄瀹炴椂鏁版嵁锛堟ā鎷熶綆鏃跺欢銆侀珮鍙潬閾捐矾锛�
+export function subscribeEquipmentData(callback, options = {}) {
+  const { intervalMs = 1000, streaming = true } = options
+  let isStopped = false
+
+  const equipmentStates = BASE_EQUIPMENTS.map(e => ({
+    ...e,
+    status: 'RUNNING',
+    metrics: seededInitMetrics(e.type)
+  }))
+
+  // 闈炴祦妯″紡锛氫粎鎺ㄩ�佷竴娆″揩鐓�
+  if (!streaming) {
+    const now = Date.now()
+    equipmentStates.forEach(es => {
+      callback({
+        ts: now,
+        equipmentId: es.id,
+        name: es.name,
+        location: es.location,
+        status: es.status,
+        metrics: { ...es.metrics }
+      })
+    })
+    return () => {}
+  }
+
+  function tick() {
+    if (isStopped) return
+    const now = Date.now()
+    equipmentStates.forEach(es => {
+      if (Math.random() < 0.01) {
+        es.status = es.status === 'RUNNING' ? 'STOPPED' : 'RUNNING'
+      }
+      if (es.status === 'RUNNING') {
+        es.metrics = nextMetrics(es.metrics, es.type)
+      } else {
+        es.metrics = {
+          ...es.metrics,
+          currentA: +(es.metrics.currentA * 0.1).toFixed(2),
+          powerFactor: Math.max(0.5, +(es.metrics.powerFactor * 0.8).toFixed(2))
+        }
+      }
+      callback({
+        ts: now,
+        equipmentId: es.id,
+        name: es.name,
+        location: es.location,
+        status: es.status,
+        metrics: { ...es.metrics }
+      })
+    })
+    timer = setTimeout(tick, intervalMs)
+  }
+
+  let timer = setTimeout(tick, intervalMs)
+  return () => {
+    isStopped = true
+    if (timer) clearTimeout(timer)
+  }
+}
+
+export function sendControlCommand(equipmentId, action) {
+  return new Promise(resolve => {
+    const delay = 50 + Math.round(Math.random() * 70)
+    setTimeout(() => {
+      resolve({ code: 200, msg: 'OK', data: { equipmentId, action, acceptedAt: Date.now() } })
+      /* eslint-disable no-console */
+      console.log(`[CONTROL] equipment=${equipmentId} action=${action}`)
+    }, delay)
+  })
+}
+
+export function detectAlarms(metrics, thresholds) {
+  const over = []
+  if (metrics.temperatureC > thresholds.temperatureC) over.push({ field: 'temperatureC', value: metrics.temperatureC, threshold: thresholds.temperatureC })
+  if (metrics.pressureBar > thresholds.pressureBar) over.push({ field: 'pressureBar', value: metrics.pressureBar, threshold: thresholds.pressureBar })
+  if (metrics.currentA > thresholds.currentA) over.push({ field: 'currentA', value: metrics.currentA, threshold: thresholds.currentA })
+  if (metrics.voltageV > thresholds.voltageV) over.push({ field: 'voltageV', value: metrics.voltageV, threshold: thresholds.voltageV })
+  if (metrics.powerFactor < thresholds.powerFactor) over.push({ field: 'powerFactor', value: metrics.powerFactor, threshold: thresholds.powerFactor })
+  return over
+}
+
+export function listEquipments() {
+  return BASE_EQUIPMENTS.map(e => ({ id: e.id, name: e.name, location: e.location, type: e.type }))
+}
+
+export default {
+  subscribeEquipmentData,
+  sendControlCommand,
+  detectAlarms,
+  getThresholds,
+  saveThresholds,
+  getAlarmChannels,
+  saveAlarmChannels,
+  listEquipments
+}
+
diff --git a/src/views/equipment/smartInspection/index.vue b/src/views/equipment/smartInspection/index.vue
new file mode 100644
index 0000000..ab8c5c4
--- /dev/null
+++ b/src/views/equipment/smartInspection/index.vue
@@ -0,0 +1,470 @@
+<template>
+	<div class="app-container">
+		<el-row :gutter="12" class="mb12">
+			<el-col :span="16">
+				<el-card class="compact-card">
+					<template #header>
+						<div class="card-header">
+							<span>鏅鸿兘宸℃锛堢叅鐐涓氾級</span>
+							<div class="header-actions">
+								<el-button type="primary" size="small" icon="Guide" @click="planRoute">鐢熸垚鏈�浼樺贰妫�璺嚎</el-button>
+								<el-button size="small" class="ml8" icon="Refresh" @click="randomizeRisks">鍒锋柊椋庨櫓</el-button>
+								<el-button type="success" size="small" class="ml8" icon="Document" @click="openReport">鐢熸垚宸℃鎶ュ憡</el-button>
+							</div>
+						</div>
+					</template>
+					
+					<div class="map-toolbar">
+						<div class="toolbar-item">
+							<span>椋庨櫓浼樺厛鏉冮噸 伪锛�</span>
+							<el-slider v-model="alpha" :min="0" :max="2" :step="0.1" show-input :show-input-controls="false" input-size="small" style="width: 260px" />
+						</div>
+						<div class="toolbar-item">
+							<span>璧风偣锛�</span>
+							<el-select v-model="startNodeId" placeholder="閫夋嫨璧风偣" size="small" style="width: 220px">
+								<el-option v-for="n in nodes" :key="n.id" :label="n.name" :value="n.id" />
+							</el-select>
+						</div>
+					</div>
+					
+					<div class="plant-map-wrapper">
+						<svg class="plant-map" :viewBox="viewBox">
+							<defs>
+								<marker id="arrow" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto" markerUnits="strokeWidth">
+									<path d="M0,0 L0,6 L6,3 z" fill="#409EFF" />
+								</marker>
+							</defs>
+							<g>
+								<rect x="0" y="0" :width="mapWidth" :height="mapHeight" fill="#0b1d2a" stroke="#1f2d3d" />
+								<g v-for="n in nodes" :key="n.id" class="node" @click="startNodeId = n.id" :transform="`translate(${n.x}, ${n.y})`">
+									<circle :r="12" :fill="riskColor(n.risk)" stroke="#fff" stroke-width="1" />
+									<text x="16" y="4" class="node-text">{{ n.name }}锛圧{{ n.risk }}锛�</text>
+								</g>
+								<g v-if="routeOrder.length > 1">
+									<template v-for="(pair, idx) in routeSegments" :key="idx">
+										<line :x1="pair.a.x" :y1="pair.a.y" :x2="pair.b.x" :y2="pair.b.y" stroke="#409EFF" stroke-width="2" marker-end="url(#arrow)" />
+										<text :x="(pair.a.x + pair.b.x) / 2" :y="(pair.a.y + pair.b.y) / 2 - 6" class="route-idx">{{ idx + 1 }}</text>
+									</template>
+								</g>
+							</g>
+						</svg>
+					</div>
+					
+					<div class="route-summary" v-if="routeOrder.length">
+						<div>璺嚎椤哄簭锛�
+							<el-tag v-for="id in routeOrder" :key="id" class="mr4" size="small">{{ nodeMap.get(id)?.name }}</el-tag>
+						</div>
+						<div class="mt6">缁煎悎浠d环锛堣�冭檻椋庨櫓锛夛細{{ effectiveDistance.toFixed(1) }}锛� 瀹為檯璺緞闀垮害锛歿{ realDistance.toFixed(1) }}</div>
+					</div>
+				</el-card>
+			</el-col>
+			
+			<el-col :span="8">
+				<el-card class="mb12 compact-card">
+					<template #header>
+						<span>鍥惧儚璇嗗埆涓庡紓甯稿垎鏋�</span>
+					</template>
+					<div class="vision-panel">
+						<div class="vision-toolbar">
+							<el-upload :show-file-list="false" :auto-upload="false" accept="image/*" :on-change="onImageSelected">
+								<el-button type="primary" size="small" icon="Picture">涓婁紶鐜板満鍥剧墖</el-button>
+							</el-upload>
+							<el-select v-model="sampleImage" placeholder="閫夋嫨绀轰緥鍥剧墖" size="small" class="ml8" style="width: 220px" @change="loadSample">
+								<el-option v-for="img in sampleImages" :key="img.src" :label="img.label" :value="img.src" />
+							</el-select>
+							<el-button size="small" class="ml8" icon="Search" @click="analyzeImage" :disabled="!imageEl">璇嗗埆</el-button>
+						</div>
+						<div class="vision-canvas" v-loading="analyzing">
+							<canvas ref="canvasRef" :width="visionWidth" :height="visionHeight" />
+							<img ref="imgRef" :src="hiddenImgSrc" alt="hidden" class="hidden-img" @load="drawBase" />
+							<div class="result-list" v-if="analysisResults.length">
+								<div v-for="(r, idx) in analysisResults" :key="idx" class="result-item">
+									<el-tag :type="r.level === 'CRITICAL' ? 'danger' : 'warning'">{{ r.type }}</el-tag>
+									<span class="ml8">缃俊搴� {{ Math.round(r.score * 100) }}%</span>
+								</div>
+							</div>
+						</div>
+					</div>
+				</el-card>
+				
+				<el-card class="compact-card">
+					<template #header>
+						<span>鏃犱汉鏈�/鏈哄櫒浜鸿仈鍔ㄩ噰闆�</span>
+					</template>
+					<div class="uav-panel">
+						<div class="mb8">
+							<el-switch v-model="uavConnected" active-text="杩炴帴鏃犱汉鏈�" />
+							<el-button size="small" class="ml8" @click="toggleCapture" :disabled="!uavConnected" :type="isCapturing ? 'danger' : 'success'">
+								{{ isCapturing ? '鍋滄閲囬泦' : '寮�濮嬮噰闆�' }}
+							</el-button>
+							<el-switch v-model="autoUpload" class="ml8" active-text="鑷姩涓婁紶" :disabled="!uavConnected" />
+						</div>
+						<div class="frames">
+							<div class="frame">
+								<div class="frame-title">楂樻竻瑙嗛</div>
+								<img :src="currentFrameUrl" alt="hd" />
+							</div>
+							<div class="frame">
+								<div class="frame-title">绾㈠鍥惧儚</div>
+								<img :src="currentFrameUrl" alt="ir" class="infrared" />
+							</div>
+						</div>
+						<div class="uploaded-list" v-if="uploadedMedia.length">
+							<div class="mb6">宸蹭笂浼狅細{{ uploadedMedia.length }} 寮�</div>
+							<el-scrollbar height="120px">
+								<div v-for="(m, i) in uploadedMedia" :key="i" class="uploaded-item">
+									<el-tag size="small" type="info">{{ formatTs(m.ts) }}</el-tag>
+									<span class="ml8">{{ m.type }}</span>
+								</div>
+							</el-scrollbar>
+						</div>
+					</div>
+				</el-card>
+			</el-col>
+		</el-row>
+		
+		<el-dialog v-model="reportVisible" title="鐢靛瓙宸℃鎶ュ憡" width="760px">
+			<div class="report">
+				<div>宸℃鏃堕棿锛歿{ formatTs(reportData.time) }}</div>
+				<div class="mt6">宸℃瀵硅薄锛歿{ nodes.length }} 涓澶�</div>
+				<div class="mt6">璺嚎锛�
+					<el-tag v-for="id in routeOrder" :key="id" size="small" class="mr4">{{ nodeMap.get(id)?.name }}</el-tag>
+				</div>
+				<div class="mt6">鍙戠幇闂锛�
+					<div v-if="analysisResults.length === 0">鏈娴嬪埌鏄庢樉闅愭偅</div>
+					<ul v-else class="issue-list">
+						<li v-for="(r, idx) in analysisResults" :key="idx">
+							<b>{{ r.type }}</b>锛堢疆淇″害 {{ Math.round(r.score * 100) }}%锛� - 寤鸿锛歿{ suggestionFor(r.type) }}
+						</li>
+					</ul>
+				</div>
+				<div class="mt6">閲囬泦绱犳潗锛氶珮娓�/绾㈠鍏� {{ uploadedMedia.length }} 寮�</div>
+			</div>
+			<template #footer>
+				<el-button @click="exportReportJson" icon="Download">瀵煎嚭JSON</el-button>
+				<el-button type="primary" @click="reportVisible = false">鍏抽棴</el-button>
+			</template>
+		</el-dialog>
+	</div>
+</template>
+
+<script setup>
+import { onMounted, onBeforeUnmount, reactive, ref, computed, nextTick } from 'vue'
+import { ElMessage } from 'element-plus'
+
+// 鈥斺�斺�斺�斺�斺�斺�斺�斺�斺�斺�斺�� 鍦板浘涓庤矾寰勮鍒掞紙椋庨櫓鍔犳潈鏈�杩戦偦锛� 鈥斺�斺�斺�斺�斺�斺�斺�斺�斺�斺�斺��
+const mapWidth = 920
+const mapHeight = 520
+const viewBox = computed(() => `0 0 ${mapWidth} ${mapHeight}`)
+
+const nodes = reactive([
+	{ id: 'gate', name: '宸℃璧风偣锛堝�肩彮瀹わ級', x: 40, y: 460, risk: 1, type: 'start' },
+	{ id: 'conv-01', name: '涓荤叅娴佽緭閫佹満#1', x: 160, y: 420, risk: 3, type: 'conveyor' },
+	{ id: 'conv-02', name: '杞浇鐐�#3', x: 320, y: 400, risk: 4, type: 'transfer' },
+	{ id: 'crusher-01', name: '榻胯緤鐮寸鏈�#1', x: 540, y: 420, risk: 5, type: 'crusher' },
+	{ id: 'dust-01', name: '闄ゅ皹椋庢満#1', x: 780, y: 380, risk: 2, type: 'blower' },
+	{ id: 'silo-01', name: '鍘熺叅浠揂', x: 220, y: 220, risk: 3, type: 'silo' },
+	{ id: 'feeder-01', name: '缁欑叅鏈�#1', x: 420, y: 220, risk: 4, type: 'feeder' },
+	{ id: 'scale-01', name: '鐨甫绉�#1', x: 660, y: 240, risk: 3, type: 'beltScale' },
+	{ id: 'stacker-01', name: '鍫嗗彇鏂欐満#1', x: 160, y: 120, risk: 2, type: 'stacker' },
+	{ id: 'pump-01', name: '娑插帇娉电珯', x: 420, y: 100, risk: 4, type: 'pump' },
+	{ id: 'sub-01', name: '閰嶇數瀹�', x: 760, y: 120, risk: 3, type: 'substation' }
+])
+const nodeMap = computed(() => new Map(nodes.map(n => [n.id, n])))
+const alpha = ref(1.0)
+const startNodeId = ref('gate')
+const routeOrder = ref([])
+
+function riskColor(r) {
+	if (r >= 5) return '#ff4d4f'
+	if (r === 4) return '#ff7a45'
+	if (r === 3) return '#faad14'
+	if (r === 2) return '#13c2c2'
+	return '#52c41a'
+}
+
+function distance(a, b) {
+	const dx = a.x - b.x
+	const dy = a.y - b.y
+	return Math.sqrt(dx * dx + dy * dy)
+}
+
+function planRoute() {
+	const start = nodeMap.value.get(startNodeId.value)
+	const toVisit = nodes.filter(n => n.id !== start.id)
+	const order = [start.id]
+	let current = start
+	while (toVisit.length) {
+		// 椋庨櫓鍔犳潈鏈夋晥璺濈锛歞 / (1 + 伪 * risk)
+		let bestIdx = 0
+		let bestScore = Infinity
+		for (let i = 0; i < toVisit.length; i++) {
+			const n = toVisit[i]
+			const d = distance(current, n)
+			const eff = d / (1 + alpha.value * n.risk)
+			if (eff < bestScore) {
+				bestScore = eff
+				bestIdx = i
+			}
+		}
+		const next = toVisit.splice(bestIdx, 1)[0]
+		order.push(next.id)
+		current = next
+	}
+	routeOrder.value = order
+	computeDistances()
+}
+
+const routeSegments = computed(() => {
+	const segs = []
+	for (let i = 0; i < routeOrder.value.length - 1; i++) {
+		const a = nodeMap.value.get(routeOrder.value[i])
+		const b = nodeMap.value.get(routeOrder.value[i + 1])
+		if (a && b) segs.push({ a, b })
+	}
+	return segs
+})
+
+const effectiveDistance = ref(0)
+const realDistance = ref(0)
+function computeDistances() {
+	let eff = 0
+	let real = 0
+	for (let i = 0; i < routeOrder.value.length - 1; i++) {
+		const a = nodeMap.value.get(routeOrder.value[i])
+		const b = nodeMap.value.get(routeOrder.value[i + 1])
+		const d = distance(a, b)
+		real += d
+		eff += d / (1 + alpha.value * b.risk)
+	}
+	effectiveDistance.value = eff
+	realDistance.value = real
+}
+
+function randomizeRisks() {
+	nodes.forEach(n => {
+		if (n.id === 'gate') return
+		const base = n.risk
+		const delta = Math.random() < 0.5 ? -1 : 1
+		n.risk = Math.min(5, Math.max(1, base + delta))
+	})
+	if (routeOrder.value.length) planRoute()
+}
+
+// 鈥斺�斺�斺�斺�斺�斺�斺�斺�斺�斺�斺�� 鍥惧儚璇嗗埆锛堢畝鍗曞儚绱犵粺璁� + 瑙勫垯寮曟搸锛� 鈥斺�斺�斺�斺�斺�斺�斺�斺�斺�斺�斺��
+const canvasRef = ref(null)
+const imgRef = ref(null)
+const visionWidth = 360
+const visionHeight = 220
+const analyzing = ref(false)
+const analysisResults = ref([])
+const imageEl = computed(() => imgRef.value)
+const hiddenImgSrc = ref('')
+
+const sampleImages = [
+	{ label: '杈撻�佹満鎵樿緤鍖哄煙', src: new URL('@/assets/images/Logo3Back.jpg', import.meta.url).href },
+	{ label: '杞浇鐐瑰爢绉�', src: new URL('@/multiple/assets/screen/Logo2Back.jpg', import.meta.url).href },
+	{ label: '娉电珯娓楁紡', src: new URL('@/multiple/assets/screen/Logo1Back.jpg', import.meta.url).href },
+	{ label: '闄ゅ皹椋庢満鍖哄煙', src: new URL('@/multiple/assets/screen/Logo4Back.jpg', import.meta.url).href }
+]
+const sampleImage = ref('')
+
+function onImageSelected(file) {
+	const raw = file.raw
+	if (!raw) return
+	const reader = new FileReader()
+	reader.onload = () => {
+		hiddenImgSrc.value = reader.result
+	}
+	reader.readAsDataURL(raw)
+}
+
+function loadSample(val) {
+	hiddenImgSrc.value = val
+}
+
+function drawBase() {
+	const canvas = canvasRef.value
+	const img = imgRef.value
+	if (!canvas || !img) return
+	const ctx = canvas.getContext('2d')
+	ctx.fillStyle = '#0f172a'
+	ctx.fillRect(0, 0, visionWidth, visionHeight)
+	const ratio = Math.min(visionWidth / img.naturalWidth, visionHeight / img.naturalHeight)
+	const w = Math.max(1, Math.round(img.naturalWidth * ratio))
+	const h = Math.max(1, Math.round(img.naturalHeight * ratio))
+	const x = Math.floor((visionWidth - w) / 2)
+	const y = Math.floor((visionHeight - h) / 2)
+	ctx.drawImage(img, x, y, w, h)
+}
+
+function analyzeImage() {
+	const canvas = canvasRef.value
+	const img = imgRef.value
+	if (!canvas || !img || !hiddenImgSrc.value) {
+		ElMessage.warning('璇峰厛閫夋嫨鍥剧墖')
+		return
+	}
+	analyzing.value = true
+	nextTick(() => {
+		drawBase()
+		const ctx = canvas.getContext('2d')
+		const data = ctx.getImageData(0, 0, visionWidth, visionHeight).data
+		let darkPixels = 0
+		let redDominant = 0
+		let clutter = 0
+		const total = visionWidth * visionHeight
+		for (let i = 0; i < data.length; i += 4) {
+			const r = data[i], g = data[i+1], b = data[i+2]
+			const v = 0.2126 * r + 0.7152 * g + 0.0722 * b // 浜害
+			const sat = Math.max(r, g, b) - Math.min(r, g, b)
+			if (v < 40 && sat < 20) darkPixels++ // 娼箍/娓楁紡鍊惧悜锛氭殫涓斾綆楗卞拰
+			if (r > 150 && r > g + 20 && r > b + 20) redDominant++ // 閿堣殌/楂樻俯鍊惧悜
+			if (sat > 60) clutter++ // 鏉傜墿/鍫嗙Н锛氳壊褰╁彉鍖栧ぇ
+		}
+		const leakScore = Math.min(1, darkPixels / (total * 0.12))
+		const hotScore = Math.min(1, redDominant / (total * 0.08))
+		const debrisScore = Math.min(1, clutter / (total * 0.35))
+		const results = []
+		if (leakScore > 0.35) results.push({ type: '鍙兘娓楁紡/婕忔恫', score: leakScore, level: leakScore > 0.65 ? 'CRITICAL' : 'WARN' })
+		if (hotScore > 0.3) results.push({ type: '鍙枒杩囩儹/鍙戠孩', score: hotScore, level: hotScore > 0.6 ? 'CRITICAL' : 'WARN' })
+		if (debrisScore > 0.4) results.push({ type: '鏉傜墿/鐓ゅ皹鍫嗙Н', score: debrisScore, level: debrisScore > 0.7 ? 'CRITICAL' : 'WARN' })
+		analysisResults.value = results
+		analyzing.value = false
+	})
+}
+
+function suggestionFor(type) {
+	switch (type) {
+		case '鍙兘娓楁紡/婕忔恫': return '妫�鏌ユ补璺�/瀵嗗皝浠讹紝娓呯悊骞舵洿鎹㈠灚鍦堝瘑灏併��'
+		case '鍙枒杩囩儹/鍙戠孩': return '妫�娴嬭酱鎵�/鐢垫満娓╁崌锛屽畨鎺掑仠鏈哄妫�鍜屾鼎婊戙��'
+		case '鏉傜墿/鐓ゅ皹鍫嗙Н': return '绔嬪嵆娓呯悊鍫嗙Н锛屾鏌ラ櫎灏樼郴缁熶笌闃叉姢缃┿��'
+		default: return '瀹夋帓鐜板満澶嶆牳锛屽繀瑕佹椂鍋滄満澶勭悊銆�'
+	}
+}
+
+// 鈥斺�斺�斺�斺�斺�斺�斺�斺�斺�斺�斺�� 鏃犱汉鏈�/鏈哄櫒浜鸿仈鍔紙甯ц疆鎾� + 妯℃嫙涓婁紶锛� 鈥斺�斺�斺�斺�斺�斺�斺�斺�斺�斺�斺��
+const framePool = [
+	new URL('@/multiple/assets/screen/RZNYView.png', import.meta.url).href,
+	new URL('@/multiple/assets/screen/TJXMView.png', import.meta.url).href,
+	new URL('@/multiple/assets/screen/XYHBView.png', import.meta.url).href,
+	new URL('@/multiple/assets/screen/HYSNView.png', import.meta.url).href
+]
+const uavConnected = ref(false)
+const isCapturing = ref(false)
+const autoUpload = ref(true)
+const currentFrameIdx = ref(0)
+const currentFrameUrl = computed(() => framePool[currentFrameIdx.value % framePool.length])
+const uploadedMedia = reactive([])
+let captureTimer = null
+
+function toggleCapture() {
+	if (!isCapturing.value) {
+		isCapturing.value = true
+		stepCapture()
+	} else {
+		isCapturing.value = false
+		if (captureTimer) clearTimeout(captureTimer)
+	}
+}
+
+function stepCapture() {
+	if (!isCapturing.value) return
+	currentFrameIdx.value = (currentFrameIdx.value + 1) % framePool.length
+	if (autoUpload.value) doUpload()
+	captureTimer = setTimeout(stepCapture, 1200)
+}
+
+function doUpload() {
+	const url = currentFrameUrl.value
+	// 妯℃嫙鍙岄�氶亾涓婁紶锛氶珮娓� + 绾㈠
+	uploadedMedia.unshift({ ts: Date.now(), type: 'HD', url })
+	uploadedMedia.unshift({ ts: Date.now(), type: 'IR', url })
+	if (uploadedMedia.length > 60) uploadedMedia.length = 60
+}
+
+// 鈥斺�斺�斺�斺�斺�斺�斺�斺�斺�斺�斺�� 鎶ュ憡 鈥斺�斺�斺�斺�斺�斺�斺�斺�斺�斺�斺��
+const reportVisible = ref(false)
+const reportData = reactive({ time: Date.now() })
+function openReport() {
+	reportData.time = Date.now()
+	reportVisible.value = true
+}
+
+function exportReportJson() {
+	const payload = {
+		generatedAt: new Date(reportData.time).toISOString(),
+		route: routeOrder.value.map(id => ({ id, name: nodeMap.value.get(id)?.name })),
+		effectiveDistance: +effectiveDistance.value.toFixed(1),
+		realDistance: +realDistance.value.toFixed(1),
+		anomalies: analysisResults.value,
+		mediaCount: uploadedMedia.length
+	}
+	const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
+	const a = document.createElement('a')
+	a.href = URL.createObjectURL(blob)
+	a.download = `inspection-report-${Date.now()}.json`
+	a.click()
+	URL.revokeObjectURL(a.href)
+}
+
+function formatTs(ts) {
+	const d = new Date(ts)
+	const p2 = (n) => n.toString().padStart(2, '0')
+	return `${d.getFullYear()}-${p2(d.getMonth()+1)}-${p2(d.getDate())} ${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`
+}
+
+function updateSizes() {}
+
+onMounted(() => {
+	// 鍒濆璺嚎
+	planRoute()
+	updateSizes()
+	window.addEventListener('resize', updateSizes)
+})
+
+onBeforeUnmount(() => {
+	if (captureTimer) clearTimeout(captureTimer)
+	window.removeEventListener('resize', updateSizes)
+})
+</script>
+
+<style scoped>
+.mb12 { margin-bottom: 12px; }
+.mb8 { margin-bottom: 8px; }
+.mb6 { margin-bottom: 6px; }
+.mt6 { margin-top: 6px; }
+.mr4 { margin-right: 4px; }
+.ml8 { margin-left: 8px; }
+.card-header { display: flex; align-items: center; justify-content: space-between; }
+.header-actions { display: flex; align-items: center; }
+.compact-card :deep(.el-card__body) { padding: 12px; }
+
+.map-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 8px; }
+.map-toolbar .toolbar-item { display: flex; align-items: center; }
+
+.plant-map-wrapper { width: 100%; background: #0b1d2a; border: 1px solid #1f2d3d; border-radius: 4px; }
+.plant-map { width: 100%; height: 520px; display: block; }
+.node { cursor: pointer; }
+.node-text { fill: #e5eaf3; font-size: 12px; }
+.route-idx { fill: #93c5fd; font-size: 12px; }
+
+.route-summary { padding: 8px 0 0; color: #606266; }
+
+.vision-panel { }
+.vision-toolbar { display: flex; align-items: center; }
+.vision-canvas { position: relative; border: 1px dashed #dcdfe6; border-radius: 4px; height: 228px; margin-top: 8px; background: #0f172a; display: flex; align-items: center; justify-content: center; }
+.vision-canvas canvas { position: absolute; top: 4px; left: 4px; }
+.hidden-img { display: none; }
+.result-list { position: absolute; right: 8px; bottom: 6px; background: rgba(0,0,0,.35); padding: 6px 8px; border-radius: 4px; color: #fff; }
+.result-item { margin: 4px 0; font-size: 12px; }
+
+.uav-panel .frames { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
+.uav-panel .frame { background: #0b1d2a; border: 1px solid #1f2d3d; border-radius: 4px; padding: 6px; text-align: center; }
+.uav-panel .frame-title { color: #9ca3af; font-size: 12px; margin-bottom: 4px; }
+.uav-panel img { width: 100%; height: 160px; object-fit: cover; border-radius: 2px; }
+.uav-panel img.infrared { filter: hue-rotate(300deg) saturate(2.2) contrast(1.1); mix-blend-mode: screen; }
+.uploaded-item { display: flex; align-items: center; padding: 4px 0; font-size: 12px; }
+</style>
+
+

--
Gitblit v1.9.3