| pnpm-workspace.yaml | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/api/environmentAccess/deviceMonitor.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/environmentAccess/deviceRemoteMonitoring/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/environmentAccess/intelligentInspectionManagement/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 |
pnpm-workspace.yaml
@@ -1,5 +1,5 @@ allowBuilds: '@vue-office/docx': set this to true or false '@vue-office/excel': set this to true or false esbuild: set this to true or false vue-demi: set this to true or false '@vue-office/docx': true '@vue-office/excel': true esbuild: true vue-demi: true src/api/environmentAccess/deviceMonitor.js
@@ -1,4 +1,5 @@ import request from "@/utils/request"; import { getToken } from "@/utils/auth"; export const listDeviceMonitor = () => request({ url: "/environmentAccess/deviceMonitor/list", method: "get" }); export const collectTelemetry = () => request({ url: "/environmentAccess/deviceMonitor/collect", method: "post" }); @@ -9,3 +10,68 @@ data: { deviceId, action }, }); /** * 使用带 Authorization 请求头的 fetch 订阅 SSE 报警流。 * 原生 EventSource 无法携带本项目的 Bearer Token,因此在这里解析事件流。 */ export function subscribeDeviceAlarms(onAlarm, onState = () => {}) { let stopped = false; let controller; let retryTimer; const processBlock = (block) => { let event = "message"; const data = []; block.split("\n").forEach((line) => { if (line.startsWith("event:")) event = line.slice(6).trim(); if (line.startsWith("data:")) data.push(line.slice(5).trim()); }); if (event !== "alarm" || !data.length) return; try { onAlarm(JSON.parse(data.join("\n"))); } catch (error) { console.warn("报警数据解析失败", error); } }; const connect = async () => { if (stopped) return; controller = new AbortController(); onState("connecting"); try { const response = await fetch(`${import.meta.env.VITE_APP_BASE_API}/environmentAccess/deviceMonitor/alarm-stream`, { headers: { Authorization: `Bearer ${getToken()}` }, signal: controller.signal, cache: "no-store", }); if (!response.ok || !response.body) throw new Error(`SSE ${response.status}`); onState("connected"); const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; while (!stopped) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }).replace(/\r/g, ""); let boundary; while ((boundary = buffer.indexOf("\n\n")) >= 0) { processBlock(buffer.slice(0, boundary)); buffer = buffer.slice(boundary + 2); } } } catch (error) { if (!stopped && error?.name !== "AbortError") console.warn("报警推送连接中断", error); } if (!stopped) { onState("reconnecting"); retryTimer = window.setTimeout(connect, 3000); } }; connect(); return { close() { stopped = true; window.clearTimeout(retryTimer); controller?.abort(); onState("closed"); }, }; } src/views/environmentAccess/deviceRemoteMonitoring/index.vue
@@ -3,6 +3,9 @@ <div class="page-head"> <div><h2>设备远程监控</h2><p>实时采集温度、压力、振动与能耗,异常自动告警并支持远程启停。</p></div> <div class="head-actions"> <el-tag :type="pushState === 'connected' ? 'success' : 'warning'" effect="plain"> 告警推送{{ pushState === 'connected' ? '已连接' : '连接中' }} </el-tag> <el-tag type="success" effect="dark">{{ onlineCount }} 台在线</el-tag> <el-tag v-if="alarmCount" type="danger" effect="dark">{{ alarmCount }} 条告警</el-tag> <el-button :loading="loading" type="primary" @click="collect">立即采集</el-button> @@ -33,7 +36,12 @@ <span>采集:{{ item.collectTime || '暂无数据' }}</span> <el-popconfirm :title="`${item.controlStatus === 'running' ? '停止' : '启动'}该设备?`" @confirm="toggle(item)"> <template #reference> <el-button :type="item.controlStatus === 'running' ? 'danger' : 'success'" size="small" plain> <el-button :type="item.controlStatus === 'running' ? 'danger' : 'success'" :loading="!!controlLoading[item.deviceId]" size="small" plain > {{ item.controlStatus === 'running' ? '远程停止' : '远程启动' }} </el-button> </template> @@ -47,15 +55,21 @@ </template> <script setup> import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { collectTelemetry, controlDevice, listDeviceAlarms, listDeviceMonitor } from "@/api/environmentAccess/deviceMonitor"; import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue"; import { ElMessage, ElNotification } from "element-plus"; import { collectTelemetry, controlDevice, listDeviceAlarms, listDeviceMonitor, subscribeDeviceAlarms, } from "@/api/environmentAccess/deviceMonitor"; const devices = ref([]); const alarms = ref([]); const loading = ref(false); const pushState = ref("connecting"); const controlLoading = reactive({}); const onlineCount = computed(() => devices.value.filter((i) => i.status !== "offline").length); const alarmCount = computed(() => devices.value.filter((i) => ["warning", "alarm"].includes(i.status)).length); let timer; let alarmSubscription; const payload = (res) => res?.data?.records || res?.data || res?.records || []; async function load() { @@ -66,16 +80,50 @@ alarms.value = payload(alarmRes); } finally { loading.value = false; } } async function collect() { await collectTelemetry(); await load(); } async function collect() { await collectTelemetry(); await load(); ElMessage.success("实时参数采集完成"); } async function toggle(item) { await controlDevice(item.deviceId, item.controlStatus === "running" ? "stop" : "start"); await collect(); const action = item.controlStatus === "running" ? "stop" : "start"; controlLoading[item.deviceId] = true; try { const response = await controlDevice(item.deviceId, action); const result = response?.data || {}; item.controlStatus = result.controlStatus || (action === "start" ? "running" : "stopped"); ElMessage.success(result.message || (action === "start" ? "设备启动成功" : "设备停止成功")); await load(); } finally { controlLoading[item.deviceId] = false; } } function receiveAlarm(alarm) { const deviceIndex = devices.value.findIndex((item) => item.deviceId === alarm.deviceId); if (deviceIndex >= 0) devices.value.splice(deviceIndex, 1, { ...devices.value[deviceIndex], ...alarm }); const alarmIndex = alarms.value.findIndex((item) => item.deviceId === alarm.deviceId); if (alarmIndex >= 0) alarms.value.splice(alarmIndex, 1, alarm); else alarms.value.unshift(alarm); ElNotification({ title: `${alarm.status === "alarm" ? "设备报警" : "设备预警"} · ${alarm.deviceName || alarm.deviceId}`, message: alarm.alarmContent || "设备参数超过设定阈值", type: alarm.status === "alarm" ? "error" : "warning", duration: 10000, position: "top-right", }); } const value = (v) => v === null || v === undefined ? "--" : Number(v).toFixed(2); const statusText = (v) => ({ normal: "正常", warning: "预警", alarm: "报警", offline: "离线" }[v] || "未知"); const statusType = (v) => ({ normal: "success", warning: "warning", alarm: "danger", offline: "info" }[v] || "info"); onMounted(() => { load(); timer = window.setInterval(load, 30000); }); onBeforeUnmount(() => window.clearInterval(timer)); onMounted(() => { load(); alarmSubscription = subscribeDeviceAlarms(receiveAlarm, (state) => { pushState.value = state; }); timer = window.setInterval(load, 30000); }); onBeforeUnmount(() => { window.clearInterval(timer); alarmSubscription?.close(); }); </script> <style scoped lang="scss"> src/views/environmentAccess/intelligentInspectionManagement/index.vue
@@ -327,12 +327,51 @@ } async function copyReport() { try { await navigator.clipboard.writeText(current.value.reportContent || ""); proxy.$modal.msgSuccess("报告已复制"); } catch (e) { proxy.$modal.msgWarning("复制失败,请手动选择文本"); const text = String(current.value.reportContent || ""); if (!text) { proxy.$modal.msgWarning("暂无可复制的报告内容"); return; } if (await copyText(text)) { proxy.$modal.msgSuccess("报告已复制"); } else { proxy.$modal.msgError("复制失败,请检查浏览器剪贴板权限"); } } async function copyText(text) { // HTTPS/localhost 优先使用标准剪贴板接口。 if (window.isSecureContext && navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); return true; } catch (e) { // 权限被拒绝时继续使用兼容方案。 } } const textarea = document.createElement("textarea"); const activeElement = document.activeElement; textarea.value = text; textarea.setAttribute("readonly", ""); textarea.style.position = "fixed"; textarea.style.left = "-9999px"; textarea.style.top = "0"; textarea.style.opacity = "0"; document.body.appendChild(textarea); textarea.focus(); textarea.select(); textarea.setSelectionRange(0, textarea.value.length); let copied = false; try { copied = document.execCommand("copy"); } catch (e) { copied = false; } finally { document.body.removeChild(textarea); activeElement?.focus?.(); } return copied; } const sourceText = v => ({ video: "视频分析", drone: "无人机", robot: "巡检机器人", manual: "人工终端" }[v] || "人工终端");