<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>
|