| README.md | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| multiple/assets/favicon/HCMYico.ico | 补丁 | 查看 | 原始文档 | blame | 历史 | |
| multiple/assets/favicon/HGJJico.ico | 补丁 | 查看 | 原始文档 | blame | 历史 | |
| multiple/assets/favicon/HSMYico.ico | 补丁 | 查看 | 原始文档 | blame | 历史 | |
| multiple/assets/favicon/MKZSico.ico | 补丁 | 查看 | 原始文档 | blame | 历史 | |
| multiple/assets/logo/HCMYLogo.png | 补丁 | 查看 | 原始文档 | blame | 历史 | |
| multiple/assets/logo/HGJJLogo.png | 补丁 | 查看 | 原始文档 | blame | 历史 | |
| multiple/assets/logo/HSMYLogo.png | 补丁 | 查看 | 原始文档 | blame | 历史 | |
| multiple/assets/logo/MKZSLogo.png | 补丁 | 查看 | 原始文档 | blame | 历史 | |
| multiple/assets/screen/HCMYView.png | 补丁 | 查看 | 原始文档 | blame | 历史 | |
| multiple/config.json | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/api/equipment/monitoring/equipment.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/equipment/monitoring/equipment.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/equipment/smartInspection/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | 
README.md
@@ -32,8 +32,8 @@ yarn dev # æå»ºæµè¯ç¯å¢ yarn build:stage # æå»ºç产ç¯å¢ yarn build:prod # æå»ºç产ç¯å¢ yarn build:prod -- --company="AAA" # æå»ºç产ç¯å¢ yarn build # æå»ºç产ç¯å¢ yarn build -- --company="AAA" # å端访é®å°å http://localhost:80 ```  multiple/assets/favicon/HCMYico.ico
 multiple/assets/favicon/HGJJico.ico
 multiple/assets/favicon/HSMYico.ico
 multiple/assets/favicon/MKZSico.ico
 multiple/assets/logo/HCMYLogo.png
 multiple/assets/logo/HGJJLogo.png
 multiple/assets/logo/HSMYLogo.png
 multiple/assets/logo/MKZSLogo.png
 multiple/assets/screen/HCMYView.png
 multiple/config.json
@@ -108,6 +108,46 @@ "logo": "logo/JLSNLogo.png", "favicon": "favicon/JLSNico.ico" }, "HCMY": { "env": { "VITE_APP_TITLE": "浩æç ¤ä¸ä¿¡æ¯ç®¡çç³»ç»", "VITE_BASE_API": "http://114.132.189.42:9105", "VITE_JAVA_API": "http://114.132.189.42:9092" }, "screen": "screen/HCMYView.png", "logo": "logo/HCMYLogo.png", "favicon": "favicon/HCMYico.ico" }, "HGJJ": { "env": { "VITE_APP_TITLE": "æ±å½æ´ååç ¤ä¿¡æ¯ç®¡çç³»ç»", "VITE_BASE_API": "http://114.132.189.42:9036", "VITE_JAVA_API": "http://114.132.189.42:9094" }, "screen": "screen/HGJJView.png", "logo": "logo/HGJJLogo.png", "favicon": "favicon/HGJJico.ico" }, "MKZS": { "env": { "VITE_APP_TITLE": "模å¯åçä¿¡æ¯ç®¡çç³»ç»", "VITE_BASE_API": "http://114.132.189.42:9036", "VITE_JAVA_API": "http://114.132.189.42:9094" }, "screen": "screen/MKZSView.png", "logo": "logo/MKZSLogo.png", "favicon": "favicon/MKZSico.ico" }, "HSMY": { "env": { "VITE_APP_TITLE": "å顺éä¸ä¿¡æ¯ç®¡çç³»ç»", "VITE_BASE_API": "http://114.132.189.42:9036", "VITE_JAVA_API": "http://114.132.189.42:9094" }, "screen": "screen/HSMYView.png", "logo": "logo/HSMYLogo.png", "favicon": "favicon/HSMYico.ico" }, "screen": "/src/assets/images/login-background.png", "logo": "/src/assets/logo/logo.png", "favicon": "/public/favicon.ico" 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 }  src/views/equipment/monitoring/equipment.vue
¶Ô±ÈÐÂÎļþ @@ -0,0 +1,304 @@ <template> <div class="app-container"> <el-row :gutter="12" class="mb12"> <el-col :span="18"> <el-card class="compact-card"> <template #header> <div class="card-header"> <span>设å¤å®æ¶çæµ</span> <div class="header-actions"> <span class="mr8">åè¦ééï¼</span> <el-switch v-model="alarmChannels.platform" active-text="å¹³å°" @change="onSaveChannels" /> <el-switch v-model="alarmChannels.sms" class="ml8" active-text="çä¿¡" @change="onSaveChannels" /> <el-switch v-model="alarmChannels.voice" class="ml8" active-text="è¯é³" @change="onSaveChannels" /> </div> </div> </template> <el-table :data="equipmentRows" size="small" :border="true" :height="tableHeight"> <el-table-column prop="name" label="设å¤" min-width="240" /> <el-table-column prop="location" label="ä½ç½®" min-width="200" /> <el-table-column label="ç¶æ" width="110"> <template #default="scope"> <el-tag :type="statusTagType(scope.row.status)">{{ renderStatus(scope.row.status) }}</el-tag> </template> </el-table-column> <el-table-column label="温度(â)" min-width="140"> <template #default="scope"> <span :class="overClass(scope.row, 'temperatureC')">{{ fmt(scope.row.metrics.temperatureC) }}</span> </template> </el-table-column> <el-table-column label="åå(bar)" min-width="140"> <template #default="scope"> <span :class="overClass(scope.row, 'pressureBar')">{{ fmt(scope.row.metrics.pressureBar) }}</span> </template> </el-table-column> <el-table-column label="çµæµ(A)" min-width="140"> <template #default="scope"> <span :class="overClass(scope.row, 'currentA')">{{ fmt(scope.row.metrics.currentA) }}</span> </template> </el-table-column> <el-table-column label="çµå(V)" min-width="140"> <template #default="scope"> <span :class="overClass(scope.row, 'voltageV')">{{ fmt(scope.row.metrics.voltageV) }}</span> </template> </el-table-column> <el-table-column label="åçå æ°" min-width="160"> <template #default="scope"> <span :class="overClass(scope.row, 'powerFactor', true)">{{ fmt(scope.row.metrics.powerFactor) }}</span> </template> </el-table-column> <el-table-column label="æ§å¶" width="220"> <template #default="scope"> <el-button type="success" size="small" plain icon="VideoPlay" @click="onControl(scope.row, 'START')" v-hasPermi="['monitor:equipment:control']" >å¯ç¨</el-button> <el-button type="danger" size="small" plain icon="VideoPause" @click="onControl(scope.row, 'STOP')" v-hasPermi="['monitor:equipment:control']" >åæº</el-button> </template> </el-table-column> </el-table> </el-card> </el-col> <el-col :span="6"> <el-card class="mb12 compact-card"> <template #header> <span>éå¼è®¾ç½®</span> </template> <el-form label-width="100px" :inline="false" size="small"> <el-form-item label="温度ä¸é(â)"> <el-input-number v-model="thresholds.temperatureC" :min="20" :max="150" :step="1" /> </el-form-item> <el-form-item label="ååä¸é(bar)"> <el-input-number v-model="thresholds.pressureBar" :min="0" :max="40" :step="0.5" /> </el-form-item> <el-form-item label="çµæµä¸é(A)"> <el-input-number v-model="thresholds.currentA" :min="1" :max="500" :step="1" /> </el-form-item> <el-form-item label="çµåä¸é(V)"> <el-input-number v-model="thresholds.voltageV" :min="360" :max="500" :step="1" /> </el-form-item> <el-form-item label="åçå æ°ä¸é"> <el-input-number v-model="thresholds.powerFactor" :min="0.5" :max="1" :step="0.01" :precision="2" /> </el-form-item> <el-form-item> <el-button type="primary" icon="Check" @click="onSaveThresholds">ä¿å</el-button> <el-button type="warning" icon="Refresh" @click="onResetThresholds">éç½®</el-button> </el-form-item> </el-form> </el-card> <el-card class="compact-card"> <template #header> <span>åè¦äºä»¶</span> </template> <el-scrollbar max-height="480px"> <el-empty v-if="alarmEvents.length === 0" description="ææ åè¦" /> <el-timeline v-else> <el-timeline-item v-for="(ev, idx) in alarmEvents" :key="idx" :timestamp="formatTs(ev.ts)" :type="ev.level === 'CRITICAL' ? 'danger' : 'warning'" > <div class="alarm-entry"> <div class="alarm-title">{{ ev.name }}ï¼{{ ev.location }}ï¼</div> <div class="alarm-body"> <div>ææ ï¼{{ renderField(ev.field) }} = {{ ev.value }}ï¼éå¼ {{ ev.comparator }} {{ ev.threshold }}</div> <div>ééï¼{{ renderChannels(ev.channels) }}</div> </div> </div> </el-timeline-item> </el-timeline> </el-scrollbar> </el-card> </el-col> </el-row> </div> </template> <script setup> import { ElNotification } from 'element-plus' import { onMounted, onBeforeUnmount, reactive, ref, computed } from 'vue' import { subscribeEquipmentData, detectAlarms, getThresholds, saveThresholds, getAlarmChannels, saveAlarmChannels, listEquipments, sendControlCommand } from '@/api/equipment/monitoring/equipment.js' const thresholds = reactive(getThresholds()) const alarmChannels = reactive(getAlarmChannels()) const equipmentDataMap = reactive(new Map()) const equipmentRows = computed(() => Array.from(equipmentDataMap.values())) const alarmEvents = ref([]) let unsubscribe = null const tableHeight = ref(520) function updateHeights() { // é¢çé¡¶é¨å¯¼èªãå¡ç头ãå è¾¹è·ç空é´ï¼é¿å åºç°å¤é¨æ»å¨æ¡ const reserve = 260 const h = window.innerHeight - reserve tableHeight.value = Math.max(420, h) } function pushAlarm(equipmentRow, overItem) { const channelUsed = { ...alarmChannels } const event = { ts: Date.now(), equipmentId: equipmentRow.id, name: equipmentRow.name, location: equipmentRow.location, field: overItem.field, value: +overItem.value.toFixed ? +overItem.value.toFixed(2) : overItem.value, threshold: overItem.threshold, comparator: overItem.field === 'powerFactor' ? '<' : '>', level: 'CRITICAL', channels: channelUsed } alarmEvents.value.unshift(event) if (alarmEvents.value.length > 100) alarmEvents.value.pop() if (channelUsed.platform) { ElNotification({ title: '设å¤åè¦', message: `${event.name} ${renderField(event.field)} è¶ é`, type: 'error', duration: 3000 }) } if (channelUsed.sms) { /* eslint-disable no-console */ console.log(`[SMS] åéè³å¼çåï¼${event.name} ${renderField(event.field)} è¶ é`) } if (channelUsed.voice) { /* eslint-disable no-console */ console.log(`[VOICE] IVRå¤å¼ï¼${event.name} ${renderField(event.field)} è¶ é`) } } function startStream() { const baseList = listEquipments() baseList.forEach(e => { equipmentDataMap.set(e.id, { id: e.id, name: e.name, location: e.location, status: 'RUNNING', metrics: { temperatureC: 0, pressureBar: 0, currentA: 0, voltageV: 0, powerFactor: 0 } }) }) unsubscribe = subscribeEquipmentData((msg) => { const row = equipmentDataMap.get(msg.equipmentId) if (!row) return row.status = msg.status row.metrics = msg.metrics // åè¦æ£æµ const overs = detectAlarms(row.metrics, thresholds) overs.forEach(item => pushAlarm(row, item)) }, { streaming: false }) } function onSaveThresholds() { saveThresholds({ ...thresholds }) ElNotification({ title: 'ä¿åæå', message: 'éå¼å·²æ´æ°', type: 'success' }) } function onResetThresholds() { const latest = getThresholds() Object.assign(thresholds, latest) } function onSaveChannels() { saveAlarmChannels({ ...alarmChannels }) } async function onControl(row, action) { const res = await sendControlCommand(row.id, action) if (res && res.code === 200) { ElNotification({ title: 'æ§å¶æä»¤ä¸å', message: `${row.name} ${action === 'START' ? 'å¯ç¨' : 'åæº'} æä»¤å·²åç`, type: 'success' }) } } function statusTagType(status) { if (status === 'RUNNING') return 'success' if (status === 'STOPPED') return 'info' return 'warning' } function renderStatus(status) { if (status === 'RUNNING') return 'è¿è¡' if (status === 'STOPPED') return 'åæº' return 'å¼å¸¸' } function fmt(v) { if (v === null || v === undefined) return '-' return typeof v === 'number' ? v.toFixed(2) : v } function overClass(row, field, reverse = false) { const v = row.metrics[field] const t = thresholds[field] if (v === undefined || t === undefined) return '' const over = reverse ? v < t : v > t return over ? 'text-danger' : '' } function renderField(field) { switch (field) { case 'temperatureC': return '温度' case 'pressureBar': return 'åå' case 'currentA': return 'çµæµ' case 'voltageV': return 'çµå' case 'powerFactor': return 'åçå æ°' default: return field } } 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())}` } onMounted(() => { startStream() updateHeights() window.addEventListener('resize', updateHeights) }) onBeforeUnmount(() => { if (typeof unsubscribe === 'function') unsubscribe() window.removeEventListener('resize', updateHeights) }) </script> <style scoped> .mb12 { margin-bottom: 12px; } .mr8 { margin-right: 8px; } .ml8 { margin-left: 8px; } .card-header { display: flex; align-items: center; justify-content: space-between; } .header-actions { display: flex; align-items: center; } .alarm-entry { line-height: 1.6; } .alarm-title { font-weight: 600; margin-bottom: 2px; } .text-danger { color: #f56c6c; font-weight: 600; } .compact-card :deep(.el-card__body) { padding: 12px; } :deep(.el-table__cell) { padding: 6px 8px; } :deep(.el-form-item) { margin-bottom: 8px; } </style>  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 }}ï¼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>