liu
8 天以前 112912730c477a64f7b27086794a3bc30e6787c7
feat: 设备实时报警订阅与监控/巡检页面优化

- 设备监控通过带鉴权的 fetch 订阅 SSE 报警流
- 设备远程监控页接入实时报警
- 巡检任务查询筛选与页面优化
- 完善 pnpm 构建授权配置

Co-Authored-By: Claude <noreply@anthropic.com>
已修改4个文件
187 ■■■■■ 文件已修改
pnpm-workspace.yaml 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/environmentAccess/deviceMonitor.js 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/environmentAccess/deviceRemoteMonitoring/index.vue 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/environmentAccess/intelligentInspectionManagement/index.vue 49 ●●●●● 补丁 | 查看 | 原始文档 | 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] || "人工终端");