| ¶Ô±ÈÐÂÎļþ | 
 |  |  | 
 |  |  | <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 }}ï¼R{{ 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">综å代价ï¼èèé£é©ï¼ï¼{{ 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: 'åç
¤ä»A', 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) { | 
 |  |  |         // é£é©å æææè·ç¦»ï¼d / (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> | 
 |  |  |  | 
 |  |  |  |