| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| | | |
| | | |