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