908555743c2c36d7e13a129f4ad78f3f69602489..47add25f6e7edf1b20d2fddb4919c1d97e4da294
2025-10-23 gaoluyang
新公司部署相关配置修改
47add2 对比 | 目录
2025-10-23 gaoluyang
智能巡检前端页面
fe9b77 对比 | 目录
2025-10-23 gaoluyang
设备监控前端页面
4d5e97 对比 | 目录
已添加12个文件
已修改2个文件
1021 ■■■■■ 文件已修改
README.md 4 ●●●● 补丁 | 查看 | 原始文档 | 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 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/equipment/monitoring/equipment.js 203 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/equipment/monitoring/equipment.vue 304 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/equipment/smartInspection/index.vue 470 ●●●●● 补丁 | 查看 | 原始文档 | 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>