From 2d55947762075bdca1b29b54c9310db821b70bea Mon Sep 17 00:00:00 2001 From: wangkai <10903508@qq.com> Date: Sat, 24 Oct 2026 18:40:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(alarm):=20=E8=AE=BE=E5=A4=87=E6=8A=A5?= =?UTF-8?q?=E8=AD=A6=E7=9B=91=E6=8E=A7=E6=8A=A5=E8=AD=A6=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E4=B8=8E=E8=AD=A6=E7=81=AF=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20-=20=E6=8A=A5=E8=AD=A6=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=EF=BC=9A=E5=90=8E=E7=AB=AF=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=8A=A5=E8=AD=A6=E6=B4=BE=E5=8D=95=E5=AE=8C=E6=88=90=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=20POST=20/api/worker-app/alarm-dispatches/:id/complet?= =?UTF-8?q?e=EF=BC=88=E4=BE=9B=E5=85=B1=E4=BA=AB=E7=94=B5=E5=B7=A5APP?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E5=AE=8C=E6=88=90=EF=BC=8C=E5=BD=92=E5=B1=9E?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C403/=E4=B8=8D=E5=AD=98=E5=9C=A8404/=E5=B7=B2?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=B9=82=E7=AD=89=EF=BC=8Cstatus=E7=BD=AE1?= =?UTF-8?q?=E5=B9=B6=E5=86=99completed=5Fat=EF=BC=89=EF=BC=9BGET=20/api/de?= =?UTF-8?q?vice-alarms=20=E7=9A=84=20summary=20=E7=94=B1=20severeIds=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20activeSevereIds=EF=BC=88grade=3D1=20?= =?UTF-8?q?=E4=B8=94=E6=97=A0=E5=B7=B2=E5=AE=8C=E6=88=90=E6=B4=BE=E5=8D=95?= =?UTF-8?q?=E7=9A=84=E6=8A=A5=E8=AD=A6id=EF=BC=8C=E5=80=92=E5=BA=8F100?= =?UTF-8?q?=E6=9D=A1=EF=BC=8C=E6=B4=BE=E5=8D=95=E5=AE=8C=E6=88=90=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=A7=BB=E5=87=BA=E5=8D=B3=E6=8A=A5=E8=AD=A6=E5=8F=96?= =?UTF-8?q?=E6=B6=88=EF=BC=89=E5=B9=B6=E6=96=B0=E5=A2=9E=E8=BD=BB=E9=87=8F?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=20GET=20/api/device-alarms/active-severe?= =?UTF-8?q?=EF=BC=9B=E5=89=8D=E7=AB=AF=E6=96=B0=E5=BB=BA=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E6=82=AC=E6=B5=AE=E7=AB=8B=E4=BD=93=E8=AD=A6=E7=81=AF=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=20AlarmSirenLight=EF=BC=88=E7=BA=A2=E8=93=9D=E5=8F=8C?= =?UTF-8?q?=E8=89=B23D=E8=AD=A6=E7=81=AF=EF=BC=8C=E6=8C=82=E8=BD=BD?= =?UTF-8?q?=E4=B8=BB=E5=B8=83=E5=B1=80=E6=89=80=E6=9C=89=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=8F=AF=E8=A7=81=EF=BC=8C=E7=82=B9=E5=87=BB=E8=B7=B3=E8=BD=AC?= =?UTF-8?q?=E6=8A=A5=E8=AD=A6=E7=9B=91=E6=8E=A7=E9=A1=B5=EF=BC=8C30?= =?UTF-8?q?=E7=A7=92=E8=BD=AE=E8=AF=A2activeSevereIds=E6=96=B0id=E5=8D=B3?= =?UTF-8?q?=E6=97=B6=E5=93=8D+=E6=AF=8F3=E5=88=86=E9=92=9F=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E5=93=8D=EF=BC=8CWeb=20Audio=E5=90=88=E6=88=90?= =?UTF-8?q?=E8=AD=A6=E7=AC=9B700/900Hz=E4=BA=A4=E6=9B=BF=EF=BC=8C=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E8=87=AA=E5=8A=A8=E6=92=AD=E6=94=BE=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E9=99=8D=E7=BA=A7=EF=BC=89=EF=BC=9Bindex.vue=20?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=8E=9F10=E7=A7=92=E6=8C=87=E7=A4=BA?= =?UTF-8?q?=E7=81=AF=E4=B8=8E880Hz=20beep=E3=80=81=E7=98=A6=E8=BA=AB?= =?UTF-8?q?=E4=B8=BA=E7=BA=AF=E5=88=97=E8=A1=A8=E9=A1=B5=EF=BC=9B=E8=AD=A6?= =?UTF-8?q?=E7=81=AF=E4=BA=A4=E4=BA=92=E4=BC=98=E5=8C=96=EF=BC=9A=E8=AD=A6?= =?UTF-8?q?=E7=81=AF=E5=8F=AF=E9=BC=A0=E6=A0=87=E6=8B=96=E5=8A=A8=EF=BC=88?= =?UTF-8?q?=E4=BD=8D=E7=A7=BB>5px=E5=88=A4=E4=B8=BA=E6=8B=96=E5=8A=A8?= =?UTF-8?q?=E4=B8=8D=E8=A7=A6=E5=8F=91=E8=B7=B3=E8=BD=AC=EF=BC=89=E3=80=81?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E7=BB=8FlocalStorage=E8=B7=A8=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E8=AE=B0=E5=BF=86=EF=BC=88=E9=BB=98=E8=AE=A4=E5=8F=B3?= =?UTF-8?q?=E4=B8=8A=E8=A7=92=EF=BC=89=E3=80=81=E5=8F=96=E6=B6=88=E3=80=8C?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=E6=8A=A5=E8=AD=A6=E6=8F=90=E7=A4=BA=E3=80=8D?= =?UTF-8?q?=E6=8C=89=E9=92=AE=EF=BC=9B=E5=90=8E=E7=AB=AFtsc=E4=B8=8E?= =?UTF-8?q?=E5=89=8D=E7=AB=AFvue-tsc=E7=BC=96=E8=AF=91=E9=80=9A=E8=BF=87?= =?UTF-8?q?=EF=BC=8C=E6=8E=A5=E5=8F=A3=E5=AE=9E=E6=B5=8B=E6=B4=BE=E5=8D=95?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=8E=92=E9=99=A4=E9=80=BB=E8=BE=91=E7=94=9F?= =?UTF-8?q?=E6=95=88=EF=BC=8C=E6=B5=8F=E8=A7=88=E5=99=A8=E7=AB=AF=E5=88=B0?= =?UTF-8?q?=E7=AB=AF=E9=AA=8C=E8=AF=81=E9=80=9A=E8=BF=87=EF=BC=88=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E6=98=BE=E7=A4=BA=E3=80=81=E7=82=B9=E5=87=BB=E8=B7=B3?= =?UTF-8?q?=E8=BD=AC=E3=80=81=E6=8B=96=E5=8A=A8=E3=80=81=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E8=AE=B0=E5=BF=86=E3=80=81=E9=87=8D=E7=BD=AE=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E6=97=A0=E6=AE=8B=E7=95=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../make-alarm-siren-draggable/checklist.md | 11 + .../specs/make-alarm-siren-draggable/spec.md | 46 +++ .../specs/make-alarm-siren-draggable/tasks.md | 15 + .../upgrade-alarm-monitor-alert/checklist.md | 17 + .../specs/upgrade-alarm-monitor-alert/spec.md | 95 +++++ .../upgrade-alarm-monitor-alert/tasks.md | 54 +++ client/src/api/alarm.ts | 6 + client/src/components/AlarmSirenLight.vue | 371 ++++++++++++++++++ client/src/layouts/index.vue | 4 + client/src/views/device/alarm/index.vue | 115 +----- .../src/controller/device_alarm.controller.ts | 34 +- .../src/controller/worker_app.controller.ts | 6 + server/src/service/worker_app.service.ts | 17 + 13 files changed, 676 insertions(+), 115 deletions(-) create mode 100644 .trae/specs/make-alarm-siren-draggable/checklist.md create mode 100644 .trae/specs/make-alarm-siren-draggable/spec.md create mode 100644 .trae/specs/make-alarm-siren-draggable/tasks.md create mode 100644 .trae/specs/upgrade-alarm-monitor-alert/checklist.md create mode 100644 .trae/specs/upgrade-alarm-monitor-alert/spec.md create mode 100644 .trae/specs/upgrade-alarm-monitor-alert/tasks.md create mode 100644 client/src/components/AlarmSirenLight.vue diff --git a/.trae/specs/make-alarm-siren-draggable/checklist.md b/.trae/specs/make-alarm-siren-draggable/checklist.md new file mode 100644 index 0000000..47bdc0d --- /dev/null +++ b/.trae/specs/make-alarm-siren-draggable/checklist.md @@ -0,0 +1,11 @@ +# Checklist + +- [x] 警灯容器支持鼠标拖动,拖动过程不触发页面跳转 +- [x] 拖动松开后警灯停驻在当前位置 +- [x] 拖动位置经 localStorage(`alarm-siren-pos`)记忆,刷新后警灯显示在拖动处 +- [x] 首次进入(无记录)警灯显示默认右上角 +- [x] 未拖动的点击警灯仍跳转 /device/alarm(不在该页时) +- [x] 「重置报警提示」按钮已移除(模板/函数/样式/tooltip 无残留) +- [x] 即时响/3 分钟重复响/警灯激活(is-on)等原有逻辑不受影响 +- [x] 前端 vue-tsc 编译通过 +- [x] 浏览器端到端验证通过(默认位置、拖动、刷新记忆、点击跳转、无重置按钮) diff --git a/.trae/specs/make-alarm-siren-draggable/spec.md b/.trae/specs/make-alarm-siren-draggable/spec.md new file mode 100644 index 0000000..9d087a6 --- /dev/null +++ b/.trae/specs/make-alarm-siren-draggable/spec.md @@ -0,0 +1,46 @@ +# 报警灯可拖动 + 取消重置报警提示 Spec + +## Why +上一版升级(upgrade-alarm-monitor-alert)中悬浮立体警灯固定右上角、带「重置报警提示」按钮;用户希望警灯可鼠标自由拖动(位置跨刷新记忆),并取消重置报警提示按钮。 + +## What Changes +- **前端 [AlarmSirenLight.vue](file:///F:/NewProject/鑫潼Git/smart_grid_iot/client/src/components/AlarmSirenLight.vue)**: + - 警灯改为**可鼠标拖动**:鼠标按下拖动、松开停驻;初始默认位置为右上角(现 fixed top:120px/right:24px),拖动后位置经 `localStorage` 记忆(刷新浏览器后停在拖动处)。 + - 拖动与点击跳转区分:拖动位移超过阈值(约 5px)视为拖动不跳转;未拖动视为点击,保留「不在 /device/alarm 则跳转」行为。 + - **移除「重置报警提示」按钮**及其逻辑(resetAlarmPrompt/按钮模板/相关 tooltip);`notifiedIds` 已通知集合保留(去重即时响仍需),仅删除清空入口。 +- 布局挂载(layouts/index.vue)、后端接口、触发模型、警笛逻辑均不变。 + +## Impact +- Affected specs: 设备报警监控报警提示升级(upgrade-alarm-monitor-alert) +- Affected code: + - [client/src/components/AlarmSirenLight.vue](file:///F:/NewProject/鑫潼Git/smart_grid_iot/client/src/components/AlarmSirenLight.vue)(拖动 + 位置记忆 + 移除重置按钮) + +## 明确假设 +1. **拖动范围**:任意位置均可停驻(fixed 定位,拖动后以 left/top 定位);跨页面由全局组件天然保持,刷新由 localStorage 恢复。 +2. **localStorage key**:`alarm-siren-pos`,存 `{ left, top }`(px);读取异常或首次进入用默认右上角。 +3. **点击语义**:警灯本体点击仍跳转 `/device/alarm`(若不在该页);拖动过程不触发跳转。 + +## ADDED Requirements +### Requirement: 报警灯可拖动 +系统 SHALL 允许用户用鼠标拖动悬浮警灯到任意位置,位置跨刷新记忆。 + +#### Scenario: 拖动警灯 +- **WHEN** 用户在任意页面按住警灯并移动鼠标 +- **THEN** 警灯跟随鼠标移动,松开后停驻在当前位置,且不触发页面跳转 + +#### Scenario: 刷新后位置保持 +- **WHEN** 用户刷新浏览器或重新登录 +- **THEN** 警灯显示在上次拖动停留的位置;首次使用时显示在默认右上角 + +#### Scenario: 点击仍可跳转 +- **WHEN** 用户点击警灯(未拖动),且当前不在 /device/alarm +- **THEN** 跳转到设备报警监控页面 + +## MODIFIED Requirements +### Requirement: 悬浮警灯位置与重置按钮(原「固定右上角 + 重置报警提示」) +原实现为 fixed 右上角(top:120px/right:24px)固定位置,警灯旁有「重置报警提示」按钮(清空已通知集合重新触发)。现修改为:警灯可拖动且位置跨刷新记忆;移除「重置报警提示」按钮及其清空逻辑(已通知集合仅在页面生命周期内累积去重)。 + +## REMOVED Requirements +### Requirement: 「重置报警提示」按钮 +**Reason**: 用户明确取消该功能。 +**Migration**: 删除按钮模板、`resetAlarmPrompt()` 函数与相关样式/tooltip;`notifiedIds` 集合与即时响去重逻辑保留,不再提供清空入口。 diff --git a/.trae/specs/make-alarm-siren-draggable/tasks.md b/.trae/specs/make-alarm-siren-draggable/tasks.md new file mode 100644 index 0000000..f8f7a94 --- /dev/null +++ b/.trae/specs/make-alarm-siren-draggable/tasks.md @@ -0,0 +1,15 @@ +# Tasks + +- [x] Task 1: 报警灯拖动 + 位置记忆 + 移除重置按钮(client/src/components/AlarmSirenLight.vue) + - 移除「重置报警提示」按钮模板、`resetAlarmPrompt()` 函数、相关 tooltip 与 `.siren-reset` 样式 + - 拖动实现:警灯容器(.floating-siren)`position: fixed`,改为 left/top 定位;mousedown 记录起点与容器坐标 → mousemove(window 监听)更新 left/top → mouseup 移除监听并保存位置 + - 拖动与点击区分:mousedown 至 mouseup 位移 ≤5px 视为点击(保留「不在 /device/alarm 则 router.push」),超过则视为拖动 + - 位置记忆:localStorage key `alarm-siren-pos` 存 `{ left, top }`;onMounted 读取恢复,无记录或解析失败用默认右上角(换算为 left/top,约 right:24px 对应 left = viewportWidth - 警灯宽度 - 24px) + - 保留:30 秒轮询 activeSevereIds、即时响、3 分钟重复响、警灯激活(is-on)、AudioContext 懒创建与清理 + +- [x] Task 2: 编译 + 浏览器验证 + - client/ `npx vue-tsc --noEmit` 通过 + - 浏览器验证:登录后警灯默认右上角;拖动到新位置;刷新页面警灯停在拖动处;点击(未拖动)跳转 /device/alarm;无「重置报警提示」按钮残留 + +# Task Dependencies +- Task 2 依赖 Task 1 diff --git a/.trae/specs/upgrade-alarm-monitor-alert/checklist.md b/.trae/specs/upgrade-alarm-monitor-alert/checklist.md new file mode 100644 index 0000000..597a434 --- /dev/null +++ b/.trae/specs/upgrade-alarm-monitor-alert/checklist.md @@ -0,0 +1,17 @@ +# Checklist + +- [x] 后端报警派单完成接口 `POST /api/worker-app/alarm-dispatches/:id/complete`:归属校验(403)、不存在(404)、status 0→1 + completedAt 写入、已完成幂等 +- [x] 后端 summary 返回 activeSevereIds(grade=1 且无 status=1 派单的报警 id 数组),接口实测为数组且不含已完成派单报警 +- [x] 原 severeIds 字段已替换为 activeSevereIds(后端 + 前端类型同步,无残留引用) +- [x] 前端顶部旧「严重报警」指示灯已移除,替换为右上角固定悬浮立体警灯(红蓝双色旋转闪烁、CSS 3D 效果) +- [x] activeSevereIds 非空时悬浮警灯出现并旋转闪烁 +- [x] 不在 `/device/alarm` 页面时点击警灯,跳转到设备报警监控页面 +- [x] 严重报警派单完成后(status=1)报警移出 activeSevereIds,不再触发警灯/警笛,列表记录保留 +- [x] 全部严重报警派单完成后警灯停止旋转、3 分钟重复响停止 +- [x] 派单完成时该线路存在遗留未派单严重报警:继续/重新触发灯与声音(新入库遗留报警即时触发) +- [x] 「重置报警提示」按钮清空已通知集合,当前未派单完成的严重报警重新触发一次 +- [x] 新未派单完成的严重报警即时播放合成警笛声(700Hz↔900Hz 高低音交替) +- [x] 存在未派单完成的严重报警时每 3 分钟重复响一次警笛 +- [x] 3 分钟警笛定时器与 30 秒列表轮询在页面卸载时正确清理(无泄漏) +- [x] 后端 tsc 编译通过、前端 vue-tsc 编译通过 +- [x] 浏览器端到端验证通过(警灯旋转、点击跳转、派单完成取消、遗留重新触发、重置、警笛触发) diff --git a/.trae/specs/upgrade-alarm-monitor-alert/spec.md b/.trae/specs/upgrade-alarm-monitor-alert/spec.md new file mode 100644 index 0000000..176c880 --- /dev/null +++ b/.trae/specs/upgrade-alarm-monitor-alert/spec.md @@ -0,0 +1,95 @@ +# 设备报警监控报警提示升级 Spec + +## Why +现有报警提示仅「严重报警亮 10 秒指示灯 + 880Hz 三声 beep」,与派单状态无关联,提示不够醒目;需升级为悬浮立体警灯样式、警车警报声,并与派单联动:同线路报警派单完成后取消提示,未派单完成则每 3 分钟重复报警声。 + +## What Changes +- **后端**:新增**报警派单完成接口** `POST /api/worker-app/alarm-dispatches/:id/complete`(供共享电工 APP 确认完成报警派单):校验派单归属当前电工、状态为未完成(status=0),更新 status→1 并写入 completed_at;已完成的重复调用幂等提示。 +- **后端**:`GET /api/device-alarms` 的 summary 由 `severeIds`(全量 grade=1)改为 **`activeSevereIds`**:grade=1 且**无已完成派单(status=1)**的报警 id 集合(倒序取最近 100 条)。派单完成后报警自动移出集合即取消提示;未派单完成的严重报警始终在集合内持续触发。 +- **前端**:替换现有顶部「严重报警」指示灯为**悬浮立体警灯**(红蓝双色旋转闪烁、CSS 3D 效果),固定悬浮页面右上角;存在未派单完成的严重报警时警灯旋转闪烁。 +- **前端**:点击悬浮警灯时,若当前路由不是设备报警监控页(`/device/alarm`),直接 `router.push('/device/alarm')`。 +- **前端**:报警声由 880Hz beep 改为 **Web Audio 合成警笛声**(高低音交替,如 700Hz↔900Hz,循环 2~3 轮)。 +- **前端**:报警声触发策略改为「**即时响一次 + 每 3 分钟重复响一次**」:新严重报警即时响;之后只要存在未派单完成的严重报警,每 3 分钟重复响一次警笛,直到全部派单完成。新增独立 3 分钟定时器(现有 30 秒列表轮询保持不变)。 +- **前端**:新增「重置报警提示」小按钮(悬浮警灯旁):清空已通知集合,使当前所有未派单完成的严重报警重新触发一次灯/声音。 + +## 与派单联动的报警取消/重新触发语义(用户确认) +- **派单完成** = 管理端发起报警派单(status=0)→ 共享电工到现场处理完成派单任务 → 电工在共享电工 APP 点击确认完成(调用本系统新增的完成接口,status→1 + completed_at)。 +- **派单完成后报警取消**:严重报警派单完成后移出 activeSevereIds → 不再触发灯/声音,3 分钟重复响(若无其他活跃严重报警)停止。 +- **有遗留严重报警才重新触发**:派单完成时若该线路仍存在未派单完成的严重报警(遗留),该遗留报警持续(重新)触发灯/声音;其后同线路新严重报警正常触发。 +- **手动重置**:保留「重置报警提示」按钮,清空已通知集合后当前所有未派单完成的严重报警重新触发一次。 + +## Impact +- Affected specs: 设备报警监控(ICC 报警事件订阅)、共享电工 APP +- Affected code: + - [server/src/controller/device_alarm.controller.ts](file:///F:/NewProject/鑫潼Git/smart_grid_iot/server/src/controller/device_alarm.controller.ts)(summary `severeIds` → `activeSevereIds`) + - [server/src/controller/worker_app.controller.ts](file:///F:/NewProject/鑫潼Git/smart_grid_iot/server/src/controller/worker_app.controller.ts) + [server/src/service/worker_app.service.ts](file:///F:/NewProject/鑫潼Git/smart_grid_iot/server/src/service/worker_app.service.ts)(报警派单完成接口) + - [client/src/views/device/alarm/index.vue](file:///F:/NewProject/鑫潼Git/smart_grid_iot/client/src/views/device/alarm/index.vue)(警灯替换、警笛、activeSevereIds 驱动、3 分钟定时器、点击跳转、重置按钮) + - [client/src/api/alarm.ts](file:///F:/NewProject/鑫潼Git/smart_grid_iot/client/src/api/alarm.ts)(`DeviceAlarmSummary` 的 `severeIds` → `activeSevereIds`) + +## 明确假设(需用户知悉) +1. **「派单完成」业务链路与判定**:确认动作在共享电工 APP 发起,本系统后端新增完成接口承接(status→1 + completed_at);`alarm_dispatch.status = 1` 即「派单完成」判定。共享电工 APP 端 UI 的确认按钮不在本次范围(由 APP 端对接本接口)。 +2. **悬浮警灯位置** = 设备报警监控页面右上角固定悬浮(position: fixed),z-index 高于页面内容。 +3. **警笛声**为合成警笛(高频交替),不受浏览器自动播放策略影响时正常发声;首次加载无用户交互仍遵循浏览器自动播放限制(静默降级只亮灯/旋转)。 +4. **手动重置**作用于本地已通知集合(无后端持久化),刷新页面后自动重建。 + +## ADDED Requirements +### Requirement: 报警派单完成接口 +系统 SHALL 提供共享电工 APP 确认完成报警派单的接口:校验派单归属当前电工且未完成,更新状态为已完成(status=1)并写入完成时间(completed_at)。 + +#### Scenario: 电工确认完成报警派单 +- **WHEN** 共享电工 APP 调用 `POST /api/worker-app/alarm-dispatches/:id/complete`,且该派单归属当前电工、状态为未完成(status=0) +- **THEN** 派单 status 更新为 1、completed_at 写入当前时间,返回完成成功;管理端报警监控页轮询后该报警移出 activeSevereIds,不再触发灯/声音 + +#### Scenario: 归属校验与幂等 +- **WHEN** 派单不归属当前电工,或派单不存在 +- **THEN** 返回 403/404;派单已完成(status=1)时重复调用返回幂等提示(不重复计数、不报错) + +### Requirement: 悬浮立体警灯 +系统 SHALL 在设备报警监控页面右上角以固定悬浮方式展示立体警灯(红蓝双色旋转闪烁、CSS 3D 效果),替换现有「严重报警」指示灯;当存在未派单完成的严重报警时警灯旋转闪烁。 + +#### Scenario: 严重报警触发警灯 +- **WHEN** activeSevereIds 非空(存在未派单完成的严重报警) +- **THEN** 悬浮警灯出现并旋转闪烁 + +#### Scenario: 点击警灯跳转页面 +- **WHEN** 页面不在设备报警监控页时用户点击悬浮警灯 +- **THEN** 跳转到 `/device/alarm` 并打开设备报警监控页面 + +### Requirement: 派单完成后报警取消与遗留重新触发 +系统 SHALL 将「已完成派单(status=1)关联的报警」从触发集合中移除,实现报警取消;同线路未派单完成的严重报警持续触发,派单完成时若有遗留未派单严重报警则重新触发。 + +#### Scenario: 派单完成后报警取消 +- **WHEN** 某严重报警的派单完成后(status=1) +- **THEN** 该报警移出 activeSevereIds,不再触发警灯旋转与警笛声;若无其他活跃严重报警,3 分钟重复响停止;KPI 统计不受影响(仅提示取消,列表记录保留) + +#### Scenario: 遗留严重报警重新触发 +- **WHEN** 派单完成时该线路仍存在未派单完成的严重报警 +- **THEN** 遗留报警继续/重新触发灯与声音(新入库的遗留报警即时触发一次;已触发过的按 3 分钟重复响) + +#### Scenario: 手动重置 +- **WHEN** 用户点击「重置报警提示」按钮 +- **THEN** 已通知集合清空,当前所有未派单完成的严重报警重新触发一次灯/声音 + +### Requirement: 警笛声与轮询策略 +系统 SHALL 在出现未派单完成的严重报警时即时播放一次合成警笛声,并每 3 分钟重复播放一次(只要仍存在未派单完成的严重报警);报警声为 Web Audio 合成警笛(高低音交替)。 + +#### Scenario: 即时响 +- **WHEN** 新未派单完成的严重报警出现 +- **THEN** 立即播放一次警笛声,同时警灯旋转闪烁 + +#### Scenario: 3 分钟重复响 +- **WHEN** 存在未派单完成的严重报警,且距上次响声满 3 分钟 +- **THEN** 重复播放一次警笛声 + +#### Scenario: 全部派单完成后停止 +- **WHEN** 所有严重报警均派单完成(activeSevereIds 为空) +- **THEN** 不再触发警笛声,警灯停止旋转 + +## MODIFIED Requirements +### Requirement: 严重报警提示(原「严重报警指示灯 + 880Hz beep」) +原实现为顶部 `alarm-light` 指示灯点亮 10 秒 + 880Hz×3 次 beep,触发扫描基于后端 summary.severeIds 全量 grade=1 报警。现修改为:悬浮立体警灯(替换指示灯,存在未派单完成的严重报警时持续旋转闪烁)+ 合成警笛声(即时 + 每 3 分钟重复);触发扫描改为 summary.activeSevereIds(过滤掉已派单完成的报警)。 + +## REMOVED Requirements +### Requirement: 880Hz beep、10 秒报警灯计时、全量 severeIds +**Reason**: 报警提示样式与声音升级为悬浮警灯与警笛声,10 秒指示灯逻辑不再适用;触发集合需排除已派单完成的报警,severeIds(全量 grade=1)语义不再满足。 +**Migration**: 删除 `alarmLight` 10 秒 setTimeout 逻辑与 doBeep;保留 Web Audio 自动播放策略静默降级(resume 失败仅亮灯不发声);summary 字段 `severeIds` 改为 `activeSevereIds`(前端唯一使用方为 device/alarm/index.vue,同步迁移)。 diff --git a/.trae/specs/upgrade-alarm-monitor-alert/tasks.md b/.trae/specs/upgrade-alarm-monitor-alert/tasks.md new file mode 100644 index 0000000..0f2288c --- /dev/null +++ b/.trae/specs/upgrade-alarm-monitor-alert/tasks.md @@ -0,0 +1,54 @@ +# Tasks + +- [x] Task 1: 后端报警派单完成接口(供共享电工 APP 确认完成) + - [worker_app.controller.ts](file:///F:/NewProject/鑫潼Git/smart_grid_iot/server/src/controller/worker_app.controller.ts) 新增 `@Post("/alarm-dispatches/:id/complete")`,注入 AlarmDispatch 仓储 + - [worker_app.service.ts](file:///F:/NewProject/鑫潼Git/smart_grid_iot/server/src/service/worker_app.service.ts) 新增 `completeAlarmDispatch(workerId, id)`: + - 查派单:不存在 → 404;`workerId` 不归属当前电工 → 403 + - status 已是 1 → 幂等提示「已完成」(不重复更新) + - 事务内更新 status→1、completedAt=new Date() + - 验证:curl 实测归属校验 / 完成更新 / 幂等(编译 + 接口) + +- [x] Task 2: 后端 summary 改为 activeSevereIds(未派单完成的严重报警集合) + - [device_alarm.controller.ts](file:///F:/NewProject/鑫潼Git/smart_grid_iot/server/src/controller/device_alarm.controller.ts) `list()`: + - 查 `dispatchRepo.findAll({ where: { status: 1 }, attributes: ["alarmId"], raw: true })` 得已完成派单的 alarmId 集合 + - `activeSevereIds` = grade=1、lineId 非空、id 不在已完成集合中的报警 id(alarmTime DESC limit 100) + - summary 返回 `activeSevereIds`(替换并删除原 `severeIds` 查询) + - 验证:接口返回 summary.activeSevereIds 为数组,且不含已完成派单的报警 id + +- [x] Task 3: 前端悬浮立体警灯替换现有指示灯(样式独立可先行) + - [index.vue](file:///F:/NewProject/鑫潼Git/smart_grid_iot/client/src/views/device/alarm/index.vue) 删除 header-right 中现有 `.alarm-light` 指示灯(保留「订阅状态」按钮) + - 新增悬浮警灯:position: fixed 右上角 + z-index;CSS 3D 造型(警灯灯罩 + 红蓝双色灯芯 + 旋转/闪烁动画) + - 警灯可见/旋转条件:`activeSevereIds` 非空(由 Task 4 触发模型提供) + - 点击警灯:若 `route.path !== '/device/alarm'` 则 `router.push('/device/alarm')` + - 警灯旁加「重置报警提示」小按钮(tooltip:清空已通知集合,重新触发当前严重报警) + +- [x] Task 4: 前端触发模型(依赖 Task 2 的 activeSevereIds) + - `scanSevereAlarms` 语义改为:新 id(不在已通知集合 `notifiedIds`)→ 即时触发一次;触发过的 id 追加进集合 + - 警灯旋转条件:activeSevereIds 非空(而非「新触发」) + - 「重置报警提示」:清空 notifiedIds 后重新扫描当前 activeSevereIds 触发 + - 30 秒列表轮询保持,每次同步 activeSevereIds;派单完成后报警移出集合即取消 + +- [x] Task 5: 警笛合成 + 即时响 + 3 分钟重复响(依赖 Task 4) + - 移除 doBeep(880Hz×3),新增 `playSiren(ctx)`:700Hz↔900Hz 高低音交替 2~3 轮(每轮 0.4s),gain 包络防爆音 + - playBeep 逻辑保留(AudioContext 懒创建 + suspended resume + 静默降级),仅替换发声函数 + - 新增 3 分钟定时器 `sirenTimer`(`setInterval` 180000ms):activeSevereIds 非空则播放警笛;onUnmounted 清理 + +- [x] Task 6: 编译验证 + - 后端 `npx tsc --noEmit`(server/) + - 前端 `npx vue-tsc --noEmit`(client/) + - 接口实测:登录后 `GET /api/device-alarms` 返回 summary.activeSevereIds;完成接口归属/幂等实测 + +- [x] Task 7: 浏览器端到端验证 + - 注入 grade=1 报警(无已完成派单)后:警灯出现并旋转(class/动画状态捕获) + - 点击警灯在非报警监控页跳转 `/device/alarm` + - 调用完成接口将该报警派单置 status=1 后:报警移出 activeSevereIds,警灯停止、重复响停止 + - 该线路注入新严重报警:重新触发警灯/警笛 + - 点击「重置报警提示」:当前未派单完成严重报警重新触发 + - 3 分钟重复响逻辑(通过缩短定时器或代码检查确认) + +# Task Dependencies +- Task 1、Task 2 相互独立可并行 +- Task 3 样式部分不依赖 Task 2,可与 Task 1/2 并行;触发条件与 Task 4 联动 +- Task 4 依赖 Task 2(activeSevereIds 数据源) +- Task 5 依赖 Task 4 +- Task 6 / Task 7 依赖 Task 1-5 diff --git a/client/src/api/alarm.ts b/client/src/api/alarm.ts index 00a5c4e..0cd3626 100644 --- a/client/src/api/alarm.ts +++ b/client/src/api/alarm.ts @@ -32,6 +32,8 @@ export interface DeviceAlarmSummary { todayCount: number; gradeOneCount: number; undispatchedCount: number; + // 活跃严重报警 id(grade=1 且未派单完成),不受分页筛选影响,供前端全局警灯/警笛触发 + activeSevereIds: number[]; } export interface DeviceAlarmListResult { @@ -72,6 +74,10 @@ export const deviceAlarmApi = { dispatch: (id: number, body: DeviceAlarmDispatchParams) => request.post(`/device-alarms/${id}/dispatch`, body), + + // 活跃严重报警 id 轻量接口(供全局悬浮警灯轮询) + activeSevere: () => + request.get<{ activeSevereIds: number[] }>('/device-alarms/active-severe'), }; // ==================== 报警派单台账(Task7 使用) ==================== diff --git a/client/src/components/AlarmSirenLight.vue b/client/src/components/AlarmSirenLight.vue new file mode 100644 index 0000000..e5b6f4d --- /dev/null +++ b/client/src/components/AlarmSirenLight.vue @@ -0,0 +1,371 @@ + + + + diff --git a/client/src/layouts/index.vue b/client/src/layouts/index.vue index ed8ddf7..fe8afd4 100644 --- a/client/src/layouts/index.vue +++ b/client/src/layouts/index.vue @@ -125,6 +125,9 @@ + + + @@ -157,6 +160,7 @@ import { } from '@ant-design/icons-vue' import { usePermissionStore, type MenuNode } from '@/stores/permission' import { noticeApi, type NoticeInfo } from '@/api/notice' +import AlarmSirenLight from '@/components/AlarmSirenLight.vue' const router = useRouter() const route = useRoute() diff --git a/client/src/views/device/alarm/index.vue b/client/src/views/device/alarm/index.vue index d0ccb52..9651ec3 100644 --- a/client/src/views/device/alarm/index.vue +++ b/client/src/views/device/alarm/index.vue @@ -19,10 +19,6 @@

实时监控接入线路的设备报警与事件,支持按客户、等级检索并快速发起报警派单。

-
- - 严重报警 -
订阅状态 @@ -357,66 +353,6 @@ async function fetchWorkerOptions() { } } -// ==================== 严重报警灯 + 声音 ==================== -const notifiedSevereIds = ref>(new Set()); -const alarmLight = ref(false); -let alarmLightTimer: number | undefined; -let audioCtx: AudioContext | null = null; - -function scanSevereAlarms(list: DeviceAlarmRow[]) { - const fresh = list.filter((r) => r.alarmGrade === 1 && !notifiedSevereIds.value.has(r.id)); - if (!fresh.length) return; - fresh.forEach((r) => notifiedSevereIds.value.add(r.id)); - triggerAlarmAlert(); -} - -function triggerAlarmAlert() { - // 报警灯:点亮并保持 10 秒 - alarmLight.value = true; - if (alarmLightTimer) window.clearTimeout(alarmLightTimer); - alarmLightTimer = window.setTimeout(() => { - alarmLight.value = false; - }, 10000); - // 声音:Web Audio 生成 880Hz 蜂鸣(3 次,每次 0.2s,间隔 0.1s) - playBeep(); -} - -function playBeep() { - try { - const Ctx = window.AudioContext || (window as any).webkitAudioContext; - if (!Ctx) return; - if (!audioCtx) audioCtx = new Ctx(); - const ctx = audioCtx; - // 浏览器自动播放策略:处于 suspended 时尝试 resume,失败则静默降级只亮灯 - if (ctx.state === 'suspended') { - ctx.resume().then(() => doBeep(ctx)).catch(() => { /* 静默降级 */ }); - } else { - doBeep(ctx); - } - } catch { - // 静默降级:只亮灯,不阻塞 - } -} - -function doBeep(ctx: AudioContext) { - const now = ctx.currentTime; - for (let i = 0; i < 3; i++) { - const start = now + i * 0.3; - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = 'sine'; - osc.frequency.value = 880; - gain.gain.setValueAtTime(0.0001, start); - gain.gain.exponentialRampToValueAtTime(0.3, start + 0.02); - gain.gain.setValueAtTime(0.3, start + 0.15); - gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.2); - osc.connect(gain); - gain.connect(ctx.destination); - osc.start(start); - osc.stop(start + 0.2); - } -} - // ==================== 订阅状态 ==================== const subStatusVisible = ref(false); const subStatusLoading = ref(false); @@ -449,7 +385,6 @@ async function fetchList() { summary.gradeOneCount = s.gradeOneCount ?? 0; summary.undispatchedCount = s.undispatchedCount ?? 0; } - scanSevereAlarms(dataSource.value); } catch { // 拦截器已提示 } finally { @@ -472,7 +407,7 @@ async function fetchCustomerOptions() { } } -// ==================== 轮询(30 秒) ==================== +// ==================== 轮询(30 秒,仅刷新列表) ==================== let pollTimer: number | undefined; function startPolling() { @@ -490,8 +425,6 @@ onMounted(() => { onUnmounted(() => { if (pollTimer) window.clearInterval(pollTimer); - if (alarmLightTimer) window.clearTimeout(alarmLightTimer); - audioCtx?.close().catch(() => {}); }); // ==================== 筛选与分页 ==================== @@ -663,52 +596,6 @@ function formatTime(val?: string) { gap: 10px; } -/* 严重报警灯 */ -.alarm-light { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 12px; - border-radius: 9999px; - background: #f1f5f9; - border: 1px solid #e2e8f0; - font-size: 12px; - font-weight: 600; - color: #64748b; - user-select: none; -} - -.alarm-light .alarm-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: #cbd5e1; - flex-shrink: 0; -} - -.alarm-light.active { - background: #fef2f2; - border-color: #fecaca; - color: #dc2626; -} - -.alarm-light.active .alarm-dot { - background: #dc2626; - animation: alarm-blink 1s ease-in-out infinite; -} - -@keyframes alarm-blink { - 0%, - 100% { - opacity: 1; - box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.5); - } - 50% { - opacity: 0.3; - box-shadow: 0 0 0 6px rgba(220, 38, 38, 0); - } -} - /* 统计卡片 */ .kpi-section { flex-shrink: 0; diff --git a/server/src/controller/device_alarm.controller.ts b/server/src/controller/device_alarm.controller.ts index 7416f66..133baf5 100644 --- a/server/src/controller/device_alarm.controller.ts +++ b/server/src/controller/device_alarm.controller.ts @@ -58,10 +58,13 @@ export class DeviceAlarmController { const d = new Date(new Date().getTime() + 8 * 60 * 60 * 1000); const today = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`; const todayStart = new Date(`${today}T00:00:00+08:00`); + // 统计:今日报警数(alarm_time >= 当天0点)/ 严重报警数(grade=1) const [todayCount, gradeOneCount] = await Promise.all([ this.alarmRepo.count({ where: { lineId: { [Op.ne]: null }, alarmTime: { [Op.gte]: todayStart } } }), this.alarmRepo.count({ where: { lineId: { [Op.ne]: null }, alarmGrade: 1 } }) ]); + // 活跃严重报警 id 集合(grade=1 且无已完成派单,按报警时间倒序取最近 100 条,供前端全局报警灯/声音触发,不受分页筛选影响) + const activeSevereIds = await this.getActiveSevereIds(); const dispatchedAlarms = await this.dispatchRepo.findAll({ where: { status: 0 }, attributes: ["alarmId"], raw: true }); const dispatchedIds = [...new Set(dispatchedAlarms.map((r: any) => Number(r.alarmId)))]; const undispatchedCount = await this.alarmRepo.count({ @@ -79,11 +82,40 @@ export class DeviceAlarmController { total: count, page, pageSize, - summary: { todayCount, gradeOneCount, undispatchedCount } + summary: { todayCount, gradeOneCount, undispatchedCount, activeSevereIds } } }; } + /** 活跃严重报警 id 轻量接口(供全局悬浮警灯轮询,rbac 前缀权限 alarm:list 已覆盖) */ + @Get("/active-severe") async activeSevere() { + const activeSevereIds = await this.getActiveSevereIds(); + return { + code: 200, + message: "获取活跃严重报警成功", + data: { activeSevereIds } + }; + } + + // 活跃严重报警 id 集合:grade=1、lineId 非空、且无 status=1 已完成派单的报警, + // 按报警时间倒序取最近 100 条,供前端全局报警灯/声音触发,不受分页筛选影响 + private async getActiveSevereIds(): Promise { + const completedAlarms = await this.dispatchRepo.findAll({ where: { status: 1 }, attributes: ["alarmId"], raw: true }); + const completedAlarmIds = [...new Set(completedAlarms.map((r: any) => Number(r.alarmId)))]; + const activeSevereAlarms = await this.alarmRepo.findAll({ + where: { + lineId: { [Op.ne]: null }, + alarmGrade: 1, + id: completedAlarmIds.length ? { [Op.notIn]: completedAlarmIds } : { [Op.ne]: null } + }, + attributes: ["id"], + order: [["alarmTime", "DESC"]], + limit: 100, + raw: true + }); + return activeSevereAlarms.map((r: any) => Number(r.id)); + } + /** 发起派单:校验报警/未完成派单/电工,生成派单号,写入派单并给电工发站内通知 */ @Post("/:id/dispatch") async dispatch(@Param("id") id: string, @Body() body: any) { const alarmId = Number(id); diff --git a/server/src/controller/worker_app.controller.ts b/server/src/controller/worker_app.controller.ts index b390274..9c2518f 100644 --- a/server/src/controller/worker_app.controller.ts +++ b/server/src/controller/worker_app.controller.ts @@ -150,6 +150,12 @@ export class WorkerAppController { return this.workerAppService.completeOrder(this.currentWorkerId(), source, Number(id)); } + // 报警派单完成确认 + @Post("/alarm-dispatches/:id/complete") + async completeAlarmDispatch(@Param("id") id: string) { + return this.workerAppService.completeAlarmDispatch(this.currentWorkerId(), Number(id)); + } + // 勘察明细维护(仅勘察单):全量替换,含建筑/楼层/房间与现场照片 @Put("/orders/:source/:id/details") async saveDetails(@Param("source") source: string, @Param("id") id: string, @Body() body: any) { diff --git a/server/src/service/worker_app.service.ts b/server/src/service/worker_app.service.ts index 8f45a96..7b3451b 100644 --- a/server/src/service/worker_app.service.ts +++ b/server/src/service/worker_app.service.ts @@ -8,6 +8,7 @@ import WorkerCert from "../entity/worker_cert.entity"; import SysNotice from "../entity/sys_notice.entity"; import RepairRequest from "../entity/repair_request.entity"; import RepairDispatch from "../entity/repair_dispatch.entity"; +import AlarmDispatch from "../entity/alarm_dispatch.entity"; import InstallOrder from "../entity/install_order.entity"; import MaintenanceOrder from "../entity/maintenance_order.entity"; import SurveyOrder from "../entity/survey_order.entity"; @@ -63,6 +64,9 @@ export class WorkerAppService { @InjectRepository(RepairDispatch) dispatchModel!: Repository; + @InjectRepository(AlarmDispatch) + alarmDispatchModel!: Repository; + @InjectRepository(InstallOrder) installModel!: Repository; @@ -503,6 +507,19 @@ export class WorkerAppService { return { code: 200, message: "已完成" }; } + // 报警派单完成确认:校验归属(不属于当前电工返回 403),幂等(已处理不重复更新) + async completeAlarmDispatch(workerId: number, id: number): Promise { + const dispatch = await this.alarmDispatchModel.findByPk(id); + if (!dispatch) return { code: 404, message: "派单不存在" }; + if (Number(dispatch.workerId) !== Number(workerId)) return { code: 403, message: "无权操作该派单" }; + if (Number(dispatch.status) === 1) return { code: 200, message: "该派单已完成" }; + + await this.sequelize.transaction(async t => { + await dispatch.update({ status: 1, completedAt: new Date() }, { transaction: t }); + }); + return { code: 200, message: "报警派单完成确认成功" }; + } + // ================= 收益汇总 ================= // 收益汇总:累计/可提现/本月/近 7 日趋势/最近 20 条流水