diff --git a/.trae/specs/add-icc-analog-data/checklist.md b/.trae/specs/add-icc-analog-data/checklist.md new file mode 100644 index 0000000..b7e24c6 --- /dev/null +++ b/.trae/specs/add-icc-analog-data/checklist.md @@ -0,0 +1,11 @@ +# Checklist + +- [x] 生产库 electricity_data 现状已核对(有无表/字段/分区/数据),迁移方案据此确定 +- [x] 迁移 `20261024_icc_analog_data.sql` 已执行到生产库:字段集(id/line_id/line_code/imei/line_name/customer_id/analog_type/analog_name/analog_unit/analog_value/collect_time,**无 equip_id**)正确,唯一键 (line_code, analog_type, collect_time)、按月分区(2026_10~2027_03)、索引(customer/line/time)就绪 +- [x] db_init.sql 中 electricity_data 基线定义与迁移一致 +- [x] 后端实体 `electricity_data.entity.ts` 字段与迁移一致,映射 `icc_analog_mapper.ts` 按实际返回字段正确映射(line_code/imei/analog_type/analog_name/analog_unit/analog_value) +- [x] 同步服务 `icc_analog_sync.service.ts` 复用 OAuth2 鉴权,先读已同步线路 line_code,分批 deviceCodes(≤50)POST 模拟量接口 pageSize=500 短页停止,collect_time 用同步时间,按 (line_code, analog_type, collect_time) 幂等 upsert,写 icc_sync_log(sync_type=electricity_data),失败仅记日志不抛 +- [x] `POST /api/electricity-data/sync` 接口可调用并返回 {pulled, inserted, updated, skipped, synced},RBAC 路由 `/api/electricity-data` → device:list 生效 +- [x] 定时任务 `icc_analog_sync`(每 5 分钟 `*/5 * * * *`)已注册:job_registry BUILTIN_JOBS + JobRuntime.syncIccAnalogs + db_seed.sql sys_job 种子幂等 +- [x] 设计文档「表结构新增及变更.md」已补充 electricity_data 新表结构及同步服务/定时任务说明 +- [x] 验证通过:tsc 编译无错误;真实同步拉取到模拟量数据入库(74 行/快照,analog_name/analog_unit 填充正确);幂等重跑正常(二次快照 148 行);无前端改动 diff --git a/.trae/specs/add-icc-analog-data/spec.md b/.trae/specs/add-icc-analog-data/spec.md new file mode 100644 index 0000000..e4df9a2 --- /dev/null +++ b/.trae/specs/add-icc-analog-data/spec.md @@ -0,0 +1,63 @@ +# ICC 模拟量数据同步 Spec(add-icc-analog-data) + +## Why +现有 `electricity_data`(线路用电信息表)仅有一版占位定义(单列 `electricity`),与 ICC 平台「消防最新模拟量信息分页查询」接口(`POST /evo-apigw/evo-fdbu/{version}/analog/subsystem/page`,实测返回 `{deviceCode, uniqueCode, analogType, analogName, analogUnit, analogValue}`,无时间字段)不对应。本次按接口返回数据重建表结构,并实现真实同步服务与定时任务,将已同步线路对应探测器的模拟量数据写入 electricity_data。 + +## What Changes +- **表结构重建(BREAKING)**: + - 删除占位列 `electricity` → 通用模拟量行:`analog_type`(analogType)、`analog_value` NUMERIC(14,4)(analogValue)、`analog_name`(analogName,如:剩余电流)、`analog_unit`(analogUnit,如:mA) + - `icc_device_code` 改为 `imei`(映射 uniqueCode) + - **删除 `equip_id`**(用户确认不需要客户设备关联) + - 保留:`line_id`(NOT NULL → electric_line)、`line_code`(NOT NULL,= deviceCode 冗余 + 幂等键)、`line_name`(冗余展示)、`customer_id`(NOT NULL,逻辑隔离键)、`collect_time`(NOT NULL,分区键) + - **collect_time 语义(实测确认)**:ICC「最新模拟量分页查询」接口实际不返回 reportTime 时间字段,经用户确认以**同步时间**作为采集时间(分区键) + - 唯一幂等键:`UNIQUE (line_code, analog_type, collect_time)`(同一次同步内重复上报覆盖更新) + - 按月 RANGE 分区(分区键 collect_time),迁移建当月及后续月份分区 +- **同步服务**(复用 icc_customer_auth 真实 OAuth2 鉴权): + - 先读取已同步的 electric_line(line_code = detectorCode),按 deviceCodes 分批(每批 ≤50)POST 模拟量接口,pageSize=500 短页停止全量拉取 + - 预取 deviceCode→(line_id, customer_id) 映射避免 N+1;imei=uniqueCode、analog_type=analogType、analog_name=analogName、analog_unit=analogUnit、analog_value=analogValue 数值解析、collect_time=同步时间 + - 按唯一键 (line_code, analog_type, collect_time) 幂等 upsert;写 icc_sync_log(sync_type=electricity_data);失败仅记日志不抛业务错 +- **定时任务**:jobKey=`icc_analog_sync`(默认每 5 分钟,cron `*/5 * * * *`,sys_job 种子幂等),注册进 job_registry/JobRuntime(syncIccAnalogs) +- **同步接口**:`POST /api/electricity-data/sync`(JWT+RBAC device:list,返回 pulled/inserted/updated/skipped/synced);不做前端页面/菜单 +- **迁移与文档**:新增 `database/migrations/20261024_icc_analog_data.sql`(建表/分区/索引/注释 + sys_job 种子),同步 `database/db_init.sql` 与 `系统设计/表结构新增及变更.md` + +## Impact +- Affected specs:add-icc-line-sync(electric_line 为模拟量关联目标,先同步线路) +- Affected code: + - 后端新增:`server/src/entity/electricity_data.entity.ts`、`server/src/task/icc_analog_mapper.ts`、`server/src/service/icc_analog_sync.service.ts`、`server/src/task/icc_analog_sync.task.ts`、`server/src/controller/electricity_data.controller.ts` + - 后端修改:`server/src/task/job_registry.ts`、`server/src/service/job_scheduler.service.ts`、`server/src/middleware/rbac.middleware.ts` + - 数据库:新增 `database/migrations/20261024_icc_analog_data.sql`;修改 `database/db_init.sql`、`database/db_seed.sql`(sys_job 种子) + - 设计文档:`系统设计/表结构新增及变更.md` + - 前端:无改动 + +## ADDED Requirements + +### Requirement: electricity_data 表结构按 AnalogInfoVO 重建 +系统 SHALL 将 `electricity_data` 重建为按月分区时序表:id(复合主键含 collect_time)、line_id(NOT NULL FK→electric_line)、line_code(NOT NULL)、imei、line_name、customer_id(NOT NULL)、analog_type、analog_name、analog_unit、analog_value NUMERIC(14,4)、collect_time TIMESTAMPTZ(NOT NULL 分区键,同步时间),唯一键 `UNIQUE (line_code, analog_type, collect_time)`,索引 customer_id/line_id/collect_time。 + +#### Scenario: 迁移执行成功 +- **WHEN** 执行迁移到生产库 +- **THEN** electricity_data 含上述字段/分区/唯一键/索引;db_init.sql 与迁移一致 + +### Requirement: 真实 ICC 模拟量拉取同步 +系统 SHALL 复用 `getIccAccessToken`/`iccFetch`,以已同步线路的 line_code(detectorCode)分批作为 deviceCodes,POST 模拟量接口分页拉取(pageSize=500 短页停止),以同步时间作为 collect_time,按 (line_code, analog_type, collect_time) 幂等 upsert,写 icc_sync_log(sync_type=electricity_data),失败仅记日志不抛。 + +#### Scenario: 同步成功 +- **WHEN** 定时任务或 POST /api/electricity-data/sync 触发 +- **THEN** 返回 {pulled, inserted, updated, skipped, synced};同键重复上报覆盖更新;无线路匹配的行跳过 + +#### Scenario: 同步失败 +- **WHEN** ICC 拉取异常 +- **THEN** 仅写 icc_sync_log 失败日志,接口返回 code 200 与当前计数 + +### Requirement: 定时任务注册 +系统 SHALL 注册 `icc_analog_sync` 定时任务(默认每 5 分钟,cron `*/5 * * * *`),注册进 job_registry BUILTIN_JOBS 与 JobRuntime.syncIccAnalogs,sys_job 种子幂等。 + +#### Scenario: 任务可执行 +- **WHEN** 任务调度触发 +- **THEN** 调用同步服务并将结果写入执行记录 + +## REMOVED Requirements + +### Requirement: 占位 electricity 单列结构(旧 electricity_data) +**Reason**:与模拟量接口返回结构不对应。 +**Migration**:electricity → analog_type/analog_value;icc_device_code → imei;equip_id 删除;collect_time 改由同步时间填充(接口实测无 reportTime)。 diff --git a/.trae/specs/add-icc-analog-data/tasks.md b/.trae/specs/add-icc-analog-data/tasks.md new file mode 100644 index 0000000..3694bf6 --- /dev/null +++ b/.trae/specs/add-icc-analog-data/tasks.md @@ -0,0 +1,32 @@ +# Tasks + +- [x] Task 1: 核对生产库 electricity_data 现状(表是否存在、旧表字段/分区/有无数据),确定迁移采用 CREATE IF NOT EXISTS 还是增量变更 + - [x] SubTask 1.1: 查询生产库 information_schema 确认 electricity_data 是否存在及其字段/分区/数据量 +- [x] Task 2: 新建迁移 `database/migrations/20261024_icc_analog_data.sql`(无 equip_id): + - 无旧表 → `CREATE TABLE IF NOT EXISTS electricity_data`:id BIGSERIAL、line_id BIGINT NOT NULL(FK→electric_line)、line_code VARCHAR(64) NOT NULL、imei VARCHAR(64)、line_name VARCHAR(128)、customer_id BIGINT NOT NULL、analog_type VARCHAR(64)、analog_name VARCHAR(128)、analog_unit VARCHAR(32)、analog_value NUMERIC(14,4)、collect_time TIMESTAMPTZ NOT NULL;主键 (id, collect_time);唯一键 UNIQUE (line_code, analog_type, collect_time);PARTITION BY RANGE (collect_time) + - 有旧表 → 幂等变更:删除 equip_id/electricity、icc_device_code 更名 imei、新增 analog_type/analog_name/analog_unit/analog_value(数据量小可 DROP 重建) + - 建分区 2026_10 ~ 2027_03(IF NOT EXISTS)、索引 idx_ed_customer/idx_ed_line/idx_ed_time、表与列 COMMENT + - 执行到远程生产库并验证(列/分区/唯一键/索引),同步更新 `database/db_init.sql` 基线定义 +- [x] Task 3: 后端实体与映射:新建 `server/src/entity/electricity_data.entity.ts`(字段 analog_name/analog_unit,FK→electric_line)、`server/src/task/icc_analog_mapper.ts`(AnalogInfoVO 映射:deviceCode→line_code、uniqueCode→imei、analogType→analog_type、analogName→analog_name、analogUnit→analog_unit、analogValue 数值解析→analog_value;分页解析兼容 data.pageData;接口实测无 reportTime,collect_time 由同步时间填充) +- [x] Task 4: 同步服务:新建 `server/src/service/icc_analog_sync.service.ts` + - [x] 复用 `getIccAccessToken`/`iccFetch`;先读已同步 electric_line 取 line_code(detectorCode)与 (line_id, customer_id) + - [x] deviceCodes 分批(每批 ≤50)POST `/analog/subsystem/page`,pageSize=500 短页停止全量拉取 + - [x] 按 (line_code, analog_type, collect_time) 幂等 upsert(collect_time 用同步时间,同一次同步内重复上报覆盖更新);写 icc_sync_log(sync_type=electricity_data);失败仅记日志不抛业务错 +- [x] Task 5: 同步接口 + RBAC + 定时任务: + - [x] 新建 `server/src/controller/electricity_data.controller.ts`:`POST /api/electricity-data/sync` 返回 {pulled, inserted, updated, skipped, synced} + - [x] `server/src/middleware/rbac.middleware.ts` 增加 `/api/electricity-data` → `device:list` + - [x] 新建 `server/src/task/icc_analog_sync.task.ts`(jobKey=icc_analog_sync,默认每 5 分钟 `*/5 * * * *`);`job_registry.ts` BUILTIN_JOBS 注册 + `job_scheduler.service.ts` JobRuntime.syncIccAnalogs 注入实现 + - [x] `database/db_seed.sql` 增加 sys_job 种子(job_key=icc_analog_sync,幂等) +- [x] Task 6: 同步更新 `系统设计/表结构新增及变更.md`:追加 electricity_data 新表结构(字段/类型/键/注释 + 与 customer/customer_equipment/electric_line 关联说明 + 同步服务与定时任务说明) +- [x] Task 7: 验证: + - [x] SubTask 7.1: `tsc` 编译通过(server) + - [x] SubTask 7.2: 启动后端,调用 `POST /api/electricity-data/sync`(先确认线路已同步),确认拉到真实模拟量数据入库、幂等重跑 updated/skipped 正常 + - [x] SubTask 7.3: 验证 icc_sync_log 落日志、定时任务注册存在(无前端改动,无需浏览器验证) + +# Task Dependencies +- Task 2 依赖 Task 1(确认生产库现状后写迁移) +- Task 3 依赖 Task 2(表结构定稿后建实体) +- Task 4 依赖 Task 3(实体与映射就绪后实现同步服务) +- Task 5 依赖 Task 4(同步服务实现后可接线 controller/定时任务) +- Task 6 依赖 Task 2(表结构定稿后写文档) +- Task 7 依赖 Task 4、Task 5 diff --git a/.trae/specs/add-icc-event-alarm/checklist.md b/.trae/specs/add-icc-event-alarm/checklist.md new file mode 100644 index 0000000..a299bb7 --- /dev/null +++ b/.trae/specs/add-icc-event-alarm/checklist.md @@ -0,0 +1,15 @@ +# Checklist + +- [x] 数据库迁移 20261024_icc_event_alarm.sql 可执行,device_alarm/alarm_dispatch 结构与 spec 一致(字段、索引、唯一键),db_init.sql 同步 +- [x] 设计文档(表结构新增及变更.md / db_design.html)中 device_alarm、alarm_dispatch 字段已更新 +- [x] device_alarm.entity.ts / alarm_dispatch.entity.ts 与迁移字段一一对应,后端 tsc 通过 +- [x] 回调接口 POST /api/icc-event/callback:token 校验失败返回 401;合法 alarm 事件按 alarmCode 幂等 upsert;匹配 electric_line 填充 line_id/line_name/customer_id;未匹配记录 line_id 为空;返回 {"success":true,"code":"0"} +- [x] ICC 订阅注册(POST subscribe/mqinfo)可手动触发,配置了 monitor URL 与 name/magic;保活定时任务 icc_event_alarm_keepalive 已注册(cron */30 * * * *,sys_job 种子) +- [x] RBAC PERMISSION_MAP 与菜单/角色种子就位:设备报警监控(alarm:list)、报警派单台账与创建(alarm-dispatch:list / create) +- [x] GET /api/device-alarms 列表+统计+筛选只返回匹配线路的报警;POST /api/device-alarms/:id/dispatch 生成唯一派单号、同报警未完成派单拒绝重复创建、校验电工在岗审核通过 +- [x] 报警派单创建后向电工发送 sys_notice 站内通知(receiver_type=worker) +- [x] GET /api/alarm-dispatches 台账分页列表正常(联出电工姓名/客户名称) +- [x] 前端设备报警监控页面:统计卡、筛选、报警表格、行内「发起报警派单」按钮与电工选择弹窗齐全,样式对齐项目勘察页 +- [x] 前端 30 秒轮询刷新;新出现 grade=1 报警时报警灯闪烁 + Web Audio 提示音,且去重不重复响 +- [x] 前端报警派单台账页面与路由就位,vue-tsc 通过 +- [x] 后端 tsc + 前端 vue-tsc 全绿;模拟回调端到端验证通过(入库→页面展示→报警灯/声音→发起派单→站内通知→台账可见) diff --git a/.trae/specs/add-icc-event-alarm/spec.md b/.trae/specs/add-icc-event-alarm/spec.md new file mode 100644 index 0000000..9b60927 --- /dev/null +++ b/.trae/specs/add-icc-event-alarm/spec.md @@ -0,0 +1,20 @@ +# Checklist + +* [x] 生产库 electricity\_data 现状已核对(有无表/字段/分区/数据),迁移方案据此确定 + +* [x] 迁移 `20261024_icc_analog_data.sql` 已执行到生产库:字段集(id/line\_id/line\_code/imei/line\_name/customer\_id/analog\_type/analog\_name/analog\_unit/analog\_value/collect\_time,**无 equip\_id**)正确,唯一键 (line\_code, analog\_type, collect\_time)、按月分区(2026\_10\~2027\_03)、索引(customer/line/time)就绪 + +* [x] db\_init.sql 中 electricity\_data 基线定义与迁移一致 + +* [x] 后端实体 `electricity_data.entity.ts` 字段与迁移一致,映射 `icc_analog_mapper.ts` 按实际返回字段正确映射(line\_code/imei/analog\_type/analog\_name/analog\_unit/analog\_value) + +* [x] 同步服务 `icc_analog_sync.service.ts` 复用 OAuth2 鉴权,先读已同步线路 line\_code,分批 deviceCodes(≤50)POST 模拟量接口 pageSize=500 短页停止,collect\_time 用同步时间,按 (line\_code, analog\_type, collect\_time) 幂等 upsert,写 icc\_sync\_log(sync\_type=electricity\_data),失败仅记日志不抛 + +* [x] `POST /api/electricity-data/sync` 接口可调用并返回 {pulled, inserted, updated, skipped, synced},RBAC 路由 `/api/electricity-data` → device:list 生效 + +* [x] 定时任务 `icc_analog_sync`(每 5 分钟 `*/5 * * * *`)已注册:job\_registry BUILTIN\_JOBS + JobRuntime.syncIccAnalogs + db\_seed.sql sys\_job 种子幂等 + +* [x] 设计文档「表结构新增及变更.md」已补充 electricity\_data 新表结构及同步服务/定时任务说明 + +* [x] 验证通过:tsc 编译无错误;真实同步拉取到模拟量数据入库(74 行/快照,analog\_name/analog\_unit 填充正确);幂等重跑正常(二次快照 148 行);无前端改动 + diff --git a/.trae/specs/add-icc-event-alarm/tasks.md b/.trae/specs/add-icc-event-alarm/tasks.md new file mode 100644 index 0000000..850d0e7 --- /dev/null +++ b/.trae/specs/add-icc-event-alarm/tasks.md @@ -0,0 +1,55 @@ +# Tasks + +- [x] Task 1: 数据库迁移与设计文档同步 + - [x] 1.1 新建 database/migrations/20261024_icc_event_alarm.sql:DROP 重建 device_alarm(按 ICC 事件结构:alarm_code UNIQUE、uuid、line_id/line_code/line_name/customer_id、node_code/device_code/device_name、org_code/org_name、alarm_type/alarm_grade/alarm_stat/is_event、alarm_time TIMESTAMPTZ、alarm_value NUMERIC(14,4) 可空、synced_at;索引 idx_alarm_time/idx_alarm_line/idx_alarm_customer/idx_alarm_grade)与 alarm_dispatch(dispatch_no UNIQUE、alarm_id→device_alarm、line_id/line_code/line_name、customer_id→customer、alarm_time/alarm_grade 快照、dispatcher、worker_id、status 0已派发 1已处理、fault_desc、created_at/completed_at;索引 idx_alarm_disp_status/idx_alarm_disp_worker/idx_alarm_disp_alarm) + - [x] 1.2 同步 database/db_init.sql 中对应两表 DDL(与迁移保持一致) + - [x] 1.3 更新 系统设计/表结构新增及变更.md(device_alarm/alarm_dispatch 小节)与 db_design.html 中相关表结构(字段级) + - [x] 1.4 执行迁移到生产库验证(表为空,安全重建;用 server/node_modules/pg 脚本) + +- [x] Task 2: 后端实体 + - [x] 2.1 server/src/entity/device_alarm.entity.ts(字段与迁移一致,line_id/customer_id 建 FK/BelongsTo 可选) + - [x] 2.2 server/src/entity/alarm_dispatch.entity.ts(含 BelongsTo device_alarm、Customer;worker_id 仅存 ID) + - [x] 2.3 `npx tsc --noEmit` 通过 + +- [x] Task 3: ICC 事件订阅服务与回调接收 + - [x] 3.1 server/src/service/icc_event_subscribe.service.ts:复用 getIccAccessToken/iccFetch(icc_customer_auth.ts),实现 registerSubscribe()(POST subscribe/mqinfo,category=alarm、订阅全部类型/权限、monitor=配置的回调 URL 带 token、subsystem name/magic=配置默认 `222.243.55.42_7001_alarm`)与 ensureSubscribe() + - [x] 3.2 server/src/controller/icc_event.controller.ts:POST /api/icc-event/callback(无鉴权,校验 URL token 与配置一致,解析通用事件格式,仅处理 category=alarm;按 alarmCode upsert 到 device_alarm,匹配 electric_line.line_code 填充 line_id/line_name/customer_id;返回 {"success":true,"code":"0"});另提供受 RBAC 保护的 POST /api/icc-event/subscribe(手动触发注册)与 GET /api/icc-event/subscribe-status + - [x] 3.3 幂等处理:同 uuid/alarmCode 重复推送不重复计数;alarm_stat 变化更新原记录 + - [x] 3.4 回调 token 与 monitor URL 从配置读取(config.default.ts 加 ICC_EVENT 配置项) + +- [x] Task 4: 保活定时任务、RBAC 与种子 + - [x] 4.1 server/src/task/icc_event_keepalive.task.ts(defaultCron `*/30 * * * *`,调用 ensureSubscribe),注册到 job_registry.ts 与 job_scheduler.service.ts(syncIccEvents) + - [x] 4.2 rbac.middleware.ts PERMISSION_MAP 新增:`/api/icc-event/subscribe: device:list`、`/api/device-alarms: alarm:list`、`/api/alarm-dispatches: alarm-dispatch:list`(create 权限 `alarm-dispatch:create`) + - [x] 4.3 database/db_seed.sql:新增 sys_job(icc_event_alarm_keepalive,cron `*/30 * * * *`)、sys_menu(设备监控→设备报警监控 /api/device/alarm;运营派单→报警派单台账 /api/dispatch/alarm)与 sys_role_menu(ADMIN 角色)、sys_permission 相关种子(若权限为表驱动则同步) + +- [x] Task 5: 报警查询与报警派单后端接口 + - [x] 5.1 server/src/controller/device_alarm.controller.ts:GET /api/device-alarms(分页列表+统计:今日/严重/未派单;筛选客户/等级/关键词;只展示 line_id 非空的记录)+ POST /api/device-alarms/:id/dispatch(创建 alarm_dispatch:生成唯一 dispatch_no、快照报警/线路/客户、dispatcher=当前用户 realName、worker_id 校验在岗+审核通过;同一报警已有未完成派单则拒绝) + - [x] 5.2 派单后通过 NoticeService.deliverToReceivers(参照 survey_order.service 的派单通知埋点)向 worker 发站内通知(notice_type=alarm_dispatch,receiver_type=worker) + - [x] 5.3 server/src/controller/alarm_dispatch.controller.ts:GET /api/alarm-dispatches(台账分页列表,筛选状态/关键词,联出电工姓名/客户名称) + - [x] 5.4 `npx tsc --noEmit` 通过 + +- [x] Task 6: 前端-设备报警监控页面 + - [x] 6.1 client/src/api/device.ts 或新增 api/alarm.ts:deviceAlarmApi.getList/dispatch、alarmDispatchApi.getList、iccEventApi.subscribe/status 类型与封装 + - [x] 6.2 client/src/views/device/alarm/index.vue(样式对齐 views/dispatch/survey/index.vue):统计卡(今日/严重/未派单)+ 筛选区(客户/等级/关键词)+ 报警表格(报警时间/线路名称/线路编码/客户/类型/等级 tag/状态)+ 行内「发起报警派单」按钮 + 派单弹窗(共享电工下拉,取 worker 在岗审核通过列表) + - [x] 6.3 严重报警提示:页面挂载轮询(30s 刷新列表);轮询发现新 grade=1 报警时右上角报警灯闪烁(全局按钮/角标)并播放提示音(Web Audio 蜂鸣 3 次);按「已提示的报警 id 集合」去重 + - [x] 6.4 路由注册(client/src/router)+ 菜单由后端 sys_menu 动态渲染(自动生效) + +- [x] Task 7: 前端-报警派单台账页面 + - [x] 7.1 client/src/views/dispatch/alarm/index.vue:台账列表(派单号/报警时间/线路/客户/指派电工/状态 tag/创建时间)+ 筛选(状态/关键词) + - [x] 7.2 路由注册(运营派单分组下) + - [x] 7.3 `npm run check`(vue-tsc)通过 + +- [x] Task 8: 验证 + - [x] 8.1 后端 `npx tsc --noEmit`、前端 `npm run check` 全绿 + - [x] 8.2 接口自测:手工构造 ICC 通用事件 JSON POST 回调接口验证入库/幂等/token 校验/未匹配线路落空记录;subscribe 注册接口调用成功 + - [x] 8.3 浏览器端到端:登录进入「设备报警监控」,触发一条模拟回调入库后页面可见;严重报警出现报警灯+声音;发起报警派单→选择电工→生成派单→电工收到站内通知;「报警派单台账」显示该派单 + - [x] 8.4 若生产订阅真实生效受限(ICC 无法访问回调地址),以模拟回调验证全链路并在交付说明中注明 + +# Task Dependencies +- Task 2 依赖 Task 1(表结构确定) +- Task 3 依赖 Task 2(实体) +- Task 4 依赖 Task 3(订阅服务) +- Task 5 依赖 Task 2(实体)与 Task 3(回调入库可测) +- Task 6 依赖 Task 5(接口) +- Task 7 依赖 Task 5.3(台账接口) +- Task 8 依赖全部 diff --git a/.trae/specs/add-icc-gateway-sync/checklist.md b/.trae/specs/add-icc-gateway-sync/checklist.md new file mode 100644 index 0000000..57babd7 --- /dev/null +++ b/.trae/specs/add-icc-gateway-sync/checklist.md @@ -0,0 +1,13 @@ +# Checklist + +- [x] electric_gateway 表已建(迁移 20261024_icc_gateway.sql),字段集符合需求方确认:自增主键 id、icc_imei/device_code 双唯一、customer_id→customer、equip_id→customer_equipment 可空、org_code/org_name/manufacturer_code/manufacturer_name/type_code/type_name/product_model/is_online/synced_at/created_at;不含 companyId/companyName/dcpsId/softwareVersion/systemAddress/address/description;与 db_seed.sql 结构一致 —— 迁移已执行到远程生产库(信息模式校验 16 列),db_seed.sql 451-522 行同步 +- [x] 菜单 62「智电改造网关台账」挂设备监控(4)(permission device:list)、ADMIN role_menu、sys_job 种子 icc_gateway_sync(cron `*/10 * * * *`)已写入迁移与 db_seed.sql 且幂等 —— 生产库已存在(menu62 查询命中、role_menu(1,62)、job 行 status=1) +- [x] ElectricGateway 实体与迁移结构一致(field 映射、索引)—— 实体含 @ForeignKey Customer/CustomerEquipment、@BelongsTo、3 索引 +- [x] icc_gateway_mapper.ts 正确映射 DeviceInfoVO 字段(imei/deviceCode/deviceName/orgCode/orgName/manufacturerCode/manufacturerName/typeCode/typeName/productModel/isOnline)并解析分页响应(PageVO{nextPage,totalCount,pageData})—— 实测映射正确(typeName=组合式电气火灾监控探测器、manufacturerName=大华、productModel=DH-…) +- [x] IccGatewaySyncService 复用 getIccAccessToken/iccFetch 真实鉴权,POST 分页全量拉取(pageSize=50,循环至短页,最大页数保护),orgCode→customer.icc_code 解析 customer_id —— 实测 pulled=2,customerId 解析为 3(KH20261016001) +- [x] 同步按 icc_imei(缺省 device_code)幂等 upsert,跳过缺双键或匹配不到客户的行,更新 synced_at,写 icc_sync_log(sync_type=electric_gateway);失败仅记录不抛业务错误 —— 实测首次 inserted=2,重跑 updated=2 skipped=0;icc_sync_log 2 条 status=1 record_count=2 +- [x] GET /api/electric-gateways 列表/详情、POST /api/electric-gateways/sync 已实现;RBAC `device:list` 已映射;非 ADMIN 按客户归属限定数据范围 —— 无 token 401、admin 列表 200 +- [x] 定时任务 icc_gateway_sync 已注册(job_registry BUILTIN_JOBS + job_scheduler buildRuntime syncIccGateways + db_seed.sql 种子)—— 调度器启动日志"已加载 6 个任务"含 icc_gateway_sync cron=*/10 * * * * +- [x] server/.env 含 ICC_BASE_URL=https://222.243.55.92:1443、ICC_VERSION=1.0.0、ICC_CLIENT_ID/ICC_CLIENT_SECRET/ICC_USERNAME/ICC_PASSWORD —— 已确认齐全 +- [x] 前端 views/device/gateway/index.vue(搜索/同步按钮/表格/在线状态 tag/分页)、路由 /device/gateway、api/device.ts electricGatewayApi 已实现 —— 浏览器实测渲染/同步/筛选/重置全部正常 +- [x] 后端编译(tsc --noEmit)与前端类型检查(vue-tsc -b)通过;运行验证:401/200、sync 真实拉取成功不 500、菜单与页面渲染正常 —— 全部实测通过 diff --git a/.trae/specs/add-icc-gateway-sync/spec.md b/.trae/specs/add-icc-gateway-sync/spec.md new file mode 100644 index 0000000..183acf1 --- /dev/null +++ b/.trae/specs/add-icc-gateway-sync/spec.md @@ -0,0 +1,46 @@ +# 智电改造网关台账(ICC 消防物联设备同步)Spec + +## Why +系统缺少"智电改造网关设备"台账。网关设备数据由 ICC 平台消防物联设备接口(`/evo-apigw/evo-fdbu/{version}/device/subsystem/page`)提供,需定时拉取并落库到新建的 `electric_gateway` 表,运营侧可按客户查看网关设备台账、手动触发同步。本表在设计文档中未定义,需依据接口文档新增表结构(已与需求方确认字段集)。 + +## What Changes +- **新增** `electric_gateway` 表(数据库迁移 + db_seed.sql 同步):自增主键 `id`、`icc_imei`/`device_code` 双唯一键、`device_name`、`customer_id`(→ customer,由接口 orgCode→customer.icc_code 解析)、`equip_id`(→ customer_equipment,可空,同步不填充)、`org_code`/`org_name`、`manufacturer_code`/`manufacturer_name`、`type_code`/`type_name`、`product_model`、`is_online`、`synced_at`、`created_at`;**不含** companyId/companyName/dcpsId/softwareVersion/systemAddress/address/description(经需求方确认) +- **新增** ICC 网关设备同步:`IccGatewaySyncService` 复用真实 OAuth2 鉴权(`icc_customer_auth.ts` 的 `getIccAccessToken` + `iccFetch`,走 `.env` 已配置凭据),POST 分页拉取消防物联设备全量数据(pageNum/pageSize=50,循环至短页,全量不传 typeCode),按 imei(缺省时 deviceCode)幂等 upsert,写 `icc_sync_log`(sync_type=electric_gateway) +- **新增** 后端接口:`GET /api/electric-gateways` 列表、`GET /api/electric-gateways/:id` 详情、`POST /api/electric-gateways/sync` 手动同步 +- **新增** 定时任务:注册 `icc_gateway_sync`(job_registry + job_scheduler buildRuntime + db_seed.sql sys_job 种子,默认 cron `*/10 * * * *`,与既有 ICC 同步任务口径一致) +- **新增** RBAC 映射:`/api/electric-gateways → device:list` +- **新增** 前端"智电改造网关台账"页面(`views/device/gateway/index.vue`),路由 `/device/gateway`,含"同步网关"手动按钮;菜单挂设备监控目录(parent=4) +- **新增** `.env` ICC 网关同步配置(ICC_BASE_URL=https://222.243.55.92:1443、ICC_VERSION=1.0.0、ICC_CLIENT_ID/ICC_CLIENT_SECRET/ICC_USERNAME/ICC_PASSWORD,取自接口文档) +- **安全**:全部 SQL 走 Sequelize 参数绑定;前端不用 v-html(防 XSS);接口返回统一 `code/message/data` + +## Impact +- Affected specs: 设备监控(设备台账、智电改造线路台账)、ICC 数据同步、RBAC 权限 +- Affected code: server/src(entity/dto/service/controller/task/middleware)、client/src(api/router/views)、database(db_seed.sql、migrations/) + +## ADDED Requirements +### Requirement: 网关台账查询(JWT+RBAC) +系统 SHALL 提供网关台账列表与详情查询,仅登录且有 `device:list` 权限的用户可访问;查询支持按 customer_id/equip_id/status(在线状态)/关键词(device_name/device_code/icc_imei)过滤分页;非 ADMIN 业务员按客户归属自动限定数据范围。 +#### Scenario: 成功查询 +- **WHEN** 已登录用户 GET `/api/electric-gateways?page=1&pageSize=10&customerId=1` +- **THEN** 返回 `code=0,data{list,total}`,含设备名称/编码/imei/客户/设备型号/主机类型/厂商/在线状态/同步时间;无 token 返回 401,无权限返回 403 + +### Requirement: ICC 网关设备拉取同步(真实鉴权 + 定时 + 手动) +系统 SHALL 提供 ICC 网关设备拉取同步能力:`IccGatewaySyncService` 读取 `.env` 的 ICC 配置(复用 `icc_customer_auth` 真实 OAuth2 密码模式鉴权),POST `/evo-apigw/evo-fdbu/{version}/device/subsystem/page` 分页拉取消防物联设备全量数据(不传 typeCode,pageSize 50,循环至短页,带最大页数保护),字段映射集中在 `icc_gateway_mapper`,按 icc_imei(缺省用 device_code)幂等 upsert 写入 `electric_gateway`,`customer_id` 由 orgCode→customer.icc_code 解析(匹配不到或缺 icc_imei 与 device_code 则跳过),更新 `synced_at`,写 `icc_sync_log`(sync_type=electric_gateway,status/record_count/error_message)。同步入口:① 定时任务(jobKey=`icc_gateway_sync`,默认 `*/10 * * * *`);② 手动 `POST /api/electric-gateways/sync`(JWT+RBAC `device:list`)。 +#### Scenario: 定时/手动拉取成功 +- **WHEN** 定时任务触发或运营点击"同步网关" +- **THEN** 拉取 ICC 数据并 upsert 对应网关行,`icc_sync_log` 记录成功;返回 `code=0,data{pulled,synced,inserted,updated,skipped}` +#### Scenario: 手动同步鉴权失败 +- **WHEN** 未登录或无 `device:list` 权限调用 `/api/electric-gateways/sync` +- **THEN** 返回 401/403,不触发拉取 +#### Scenario: ICC 拉取失败(不可达/鉴权失败/接口异常) +- **WHEN** ICC 地址不可达或返回异常 +- **THEN** 同步日志记录失败状态与错误信息,业务接口不报错,仅返回已拉取计数 + +### Requirement: 前端网关台账页 +系统 SHALL 提供 `/device/gateway` 页面:搜索栏(设备名称/编码/imei、客户、在线状态)、"同步网关"按钮(调 `/api/electric-gateways/sync`,成功后刷新)、表格(设备名称/编码/imei/客户/厂商/主机类型/设备型号/在线状态 tag/同步时间)、分页;样式与智电改造线路台账一致,全部文本经 Vue 插值转义。 +#### Scenario: 打开页面 +- **WHEN** 有 `device:list` 权限的用户访问设备监控→智电改造网关台账 +- **THEN** 渲染列表并展示分页与筛选 + +## MODIFIED Requirements +(无既有功能被修改) diff --git a/.trae/specs/add-icc-gateway-sync/tasks.md b/.trae/specs/add-icc-gateway-sync/tasks.md new file mode 100644 index 0000000..55aec6d --- /dev/null +++ b/.trae/specs/add-icc-gateway-sync/tasks.md @@ -0,0 +1,39 @@ +# Tasks + +- [x] Task 1: 数据库层:新建迁移 `database/migrations/20261024_icc_gateway.sql` 建 `electric_gateway`(字段集经需求方确认),并把建表/菜单/角色菜单/定时任务种子同步进 `db_seed.sql`;幂等(IF NOT EXISTS / ON CONFLICT) + - [x] SubTask 1.1: 核对生产库是否已有 electric_gateway,避免重复;确认 sys_menu 现有最大 id(迁移用 id=62 并 setval)—— 已核对:表无先例、菜单 id 62 空闲;迁移已执行到远程生产库 + - [x] SubTask 1.2: 迁移内容:建表 electric_gateway(自增 id;icc_imei/device_code 唯一;customer_id→customer;equip_id→customer_equipment 可空;org_code/org_name/manufacturer_code/manufacturer_name/type_code/type_name/product_model/is_online/synced_at/created_at;索引 customer_id/equip_id/icc_imei);菜单 62「智电改造网关台账」挂设备监控(4)(permission device:list);ADMIN role_menu;sys_job 种子 icc_gateway_sync(cron `*/10 * * * *`) + - [x] SubTask 1.3: db_seed.sql 同步对应语句(保持幂等一致)—— 已追加段落(451-522 行) +- [x] Task 2: 后端实体与映射器: + - [x] SubTask 2.1: `server/src/entity/electric_gateway.entity.ts`(timestamps:false,结构对齐迁移文件) + - [x] SubTask 2.2: `server/src/task/icc_gateway_mapper.ts`:集中配置端点 `/evo-apigw/evo-fdbu/{version}/device/subsystem/page`、DeviceInfoVO 字段映射(id/imei/deviceCode/deviceName/orgCode/orgName/manufacturerCode/manufacturerName/typeCode/typeName/productModel/isOnline)、分页响应解析(PageVO{nextPage,totalCount,pageData}) +- [x] Task 3: 后端同步服务 `server/src/service/icc_gateway_sync.service.ts`: + - [x] SubTask 3.1: 复用 `icc_customer_auth.ts` 的 `getIccAccessToken`/`iccFetch` 真实鉴权;读取 `.env`(ICC_BASE_URL/ICC_VERSION),配置缺失视为失败仅记日志 + - [x] SubTask 3.2: POST 分页拉取(pageSize=50、不传 typeCode、循环至短页、最大页数保护),预取 orgCode→customer(id) 映射(customer.icc_code) + - [x] SubTask 3.3: 按 icc_imei(缺省 device_code)幂等 upsert;缺 imei 与 device_code 或匹配不到客户则跳过;更新 synced_at;写 icc_sync_log(sync_type=electric_gateway);拉取失败仅记录不抛业务错误 —— 实测同步成功(pulled=2),重跑 updated=2 幂等 +- [x] Task 4: 后端查询服务/Controller/DTO + RBAC: + - [x] SubTask 4.1: `server/src/service/electric_gateway.service.ts`(list/detail,过滤:keyword=device_name/device_code/icc_imei、customer_id、equip_id、is_online;非 ADMIN 按 salesmanId 限定客户范围;decorate 补全客户/设备展示字段) + - [x] SubTask 4.2: `server/src/dto/electric_gateway.dto.ts`(查询/同步入参校验) + - [x] SubTask 4.3: `server/src/controller/electric_gateway.controller.ts`:GET `/api/electric-gateways`、GET `/:id`、POST `/sync` + - [x] SubTask 4.4: `rbac.middleware.ts` 追加 `/api/electric-gateways → device:list` +- [x] Task 5: 定时任务注册: + - [x] SubTask 5.1: `server/src/task/icc_gateway_sync.task.ts`(jobKey=`icc_gateway_sync`,默认 cron `*/10 * * * *`,handler 调 runtime.syncIccGateways) + - [x] SubTask 5.2: `job_registry.ts`:JobRuntime 增加 `syncIccGateways`,BUILTIN_JOBS 追加 iccGatewaySyncJob + - [x] SubTask 5.3: `job_scheduler.service.ts` buildRuntime 增加 `syncIccGateways`(匿名上下文注入 IccGatewaySyncService) + - [x] SubTask 5.4: `server/.env` 补全 ICC 网关同步凭据 —— 已确认 .env 中配置齐全(ICC_BASE_URL=https://222.243.55.92:1443、ICC_VERSION=1.0.0、ICC_CLIENT_ID=xtzdapp、ICC_CLIENT_SECRET、ICC_USERNAME=XTZD接口、ICC_PASSWORD) +- [x] Task 6: 前端: + - [x] SubTask 6.1: `client/src/api/device.ts` 新增 electricGatewayApi.list/get/sync + - [x] SubTask 6.2: `client/src/views/device/gateway/index.vue`(搜索栏+同步按钮+表格+在线状态 tag+分页,样式对齐 views/device/line/index.vue) + - [x] SubTask 6.3: `client/src/router/index.ts` 注册 `/device/gateway`(挂 device 目录) +- [x] Task 7: 验证: + - [x] SubTask 7.1: 后端 `tsc --noEmit`、前端 `vue-tsc -b` 类型检查通过 —— 均通过(exit 0) + - [x] SubTask 7.2: 启动服务:无 token 401(实测 401)、admin 列表 200(实测 total=2)、`POST /sync` 真实拉取成功(pulled=2→落库→重跑 updated=2)、定时任务 icc_gateway_sync 已注册(调度器加载 6 个任务,含 icc_gateway_sync cron */10 * * * *) + - [x] SubTask 7.3: 浏览器验证菜单出现、页面渲染、筛选/分页/同步按钮可用 —— 实测:菜单出现、9 列表格渲染 2 条、同步按钮提示"拉取 2 条,新增 0,更新 2"并刷新、关键词筛选/重置正常 + +# Task Dependencies +- Task 2/6 依赖 Task 1(表存在) +- Task 3 依赖 Task 2(映射器) +- Task 4 依赖 Task 1/2 +- Task 5 依赖 Task 3 +- Task 6 依赖 Task 4(接口可用) +- Task 7 依赖全部 diff --git a/.trae/specs/add-icc-line-sync/checklist.md b/.trae/specs/add-icc-line-sync/checklist.md new file mode 100644 index 0000000..95457fb --- /dev/null +++ b/.trae/specs/add-icc-line-sync/checklist.md @@ -0,0 +1,12 @@ +# Checklist + +- [x] electric_line 表结构按 spec 重建:删 building_id/floor_no/location/icc_device_code;equip_id 可空;新增 imei/gateway_id/type_code/type_name/fire_system_code/fire_system_name/part_unit_loop_code/channel_no/channel_sn/produce_date;迁移已执行到生产库且 db_seed.sql/db_init.sql 一致 +- [x] 设计文档「表结构新增及变更.md」已补充 electric_line 新结构定义 +- [x] ElectricLine 实体与迁移一致(gatewayId FK→electric_gateway,删除旧占位字段) +- [x] icc_line_mapper 按 DetectorVO 映射:lineCode=detectorCode、lineName=detectorName、status 01→1/02→0、orgCode/parentCode 透出作匹配键;parseLinePage 支持 PageVO 分页结构 +- [x] icc_line_sync.service 复用 getIccAccessToken/iccFetch 真实鉴权,POST 分页 pageSize=50 短页停止全量拉取;按 line_code 幂等 upsert(equip_id 不填);customer_id 由 orgCode 匹配、gateway_id 由 parentCode 匹配,失败跳过;写 icc_sync_log(sync_type=electric_line);拉取失败仅记日志不抛业务错 +- [x] 定时任务 icc_line_sync(jobKey/cron */10)注册链路可用;POST /api/electric-lines/sync 返回 {pulled,inserted,updated,skipped,synced} +- [x] 列表接口 GET /api/electric-lines 返回新字段(含 gateway/type/imei),非 ADMIN 限定 customer_id,keyword 检索含 imei +- [x] 前端 /device/line:删除建筑/楼层/位置列,新增 IMEI/探测器类型/回路/通道/消防系统/生产日期/所属网关列;无 v-html;同步按钮调用真实接口 +- [x] 后端 tsc --noEmit、前端 vue-tsc -b 均通过 +- [x] 真实同步端到端:admin 登录 → 手动同步 → 探测器数据落库且 customer/gateway 关联正确 → 重跑幂等 updated;浏览器页面新列渲染正常 diff --git a/.trae/specs/add-icc-line-sync/spec.md b/.trae/specs/add-icc-line-sync/spec.md new file mode 100644 index 0000000..43048ef --- /dev/null +++ b/.trae/specs/add-icc-line-sync/spec.md @@ -0,0 +1,71 @@ +# 智电线路真实 ICC 同步 Spec(add-icc-line-sync) + +## Why +现有 electric_line(智电改造线路台账)为占位实现:表结构是手工设计的占位字段(building/floor/location 等),同步链路 `icc_line_sync.service.ts` 走假的 `ICC_TOKEN` 配置、无真实鉴权。本次依据 ICC 平台接口文档第三节「消防探测器分页查询」(`POST /evo-apigw/evo-fdbu/{version}/detector/subsystem/page`)将线路台账升级为**真实数据同步**:重建表结构(按 DetectorVO 落库字段 + 与 customer/customer_equipment/electric_gateway 三张表的关联),后端替换为真实 OAuth2 拉取(复用 icc_customer_auth),前端台账页补充新字段展示并保留手动同步按钮。 + +## What Changes +- **表结构变更(BREAKING)**:`electric_line` 按 DetectorVO 重建字段集: + - 删除:`building_id`、`floor_no`、`location`、`icc_device_code`(占位字段无真实数据源) + - `equip_id` 由 `NOT NULL` 改为**可空**(→ customer_equipment,同步不填充,留待手动关联) + - 新增:`imei`、`gateway_id`(→ electric_gateway)、`type_code`、`type_name`、`fire_system_code`、`fire_system_name`、`part_unit_loop_code`、`channel_no`、`channel_sn`、`produce_date` + - 保留:`line_code`(= detectorCode,唯一幂等键)、`line_name`(= detectorName)、`customer_id`(NOT NULL)、`status`(= detectorStatus:01→1 在线 / 02→0 离线)、`synced_at`、`created_at` + - 不落库(仅作关联匹配键):`orgCode`→customer.icc_code 反查 customer_id;`parentCode`→electric_gateway.device_code 反查 gateway_id +- **后端同步真实化**:重写 `icc_line_mapper.ts`(DetectorVO 映射 + 分页解析)与 `icc_line_sync.service.ts`(复用 `getIccAccessToken`/`iccFetch`,POST 分页 pageSize=50 短页停止、全量拉取、按 line_code 幂等 upsert、写 icc_sync_log sync_type=electric_line、失败仅记日志不抛) +- **关联规则**:customer_id 由 orgCode 匹配;gateway_id 由 parentCode 匹配 device_code;equip_id 同步不填充 +- **前端台账页更新**:`views/device/line/index.vue` 移除楼层/建筑/位置列,新增探测器类型、IMEI、回路/通道、消防系统、生产日期、网关等列;保留「同步线路」手动按钮与筛选 +- **设计文档同步**:更新 `系统设计/表结构新增及变更.md` 补充 electric_line 新结构 +- 定时任务 `icc_line_sync`(jobKey 不变,cron */10)、菜单 61、RBAC `/api/electric-lines → device:list`、前端路由 `/device/line` 均沿用既有,无需新增 + +## Impact +- Affected specs:add-icc-line-ledger(本 spec 升级其占位实现)、add-icc-gateway-sync(复用其真实同步模式与关联表) +- Affected code: + - 后端:`server/src/entity/electric_line.entity.ts`、`server/src/task/icc_line_mapper.ts`、`server/src/service/icc_line_sync.service.ts`、`server/src/service/electric_line.service.ts`(decorate 去掉 building、新增 gateway/类型展示)、`server/src/controller/electric_line.controller.ts`(不动) + - 迁移:`database/migrations/20261011_icc_line_ledger.sql`(重建 electric_line 结构)或新增增量迁移;`database/db_seed.sql` 同步 + - 前端:`client/src/views/device/line/index.vue`、`client/src/api/device.ts`(ElectricLine 类型) + - 设计文档:`系统设计/表结构新增及变更.md` + +## ADDED Requirements + +### Requirement: electric_line 表结构按 DetectorVO 重建 +系统 SHALL 将 `electric_line` 重建为与 DetectorVO 对应的字段集,且: +- `line_code` 存 detectorCode(唯一,幂等键) +- `customer_id` NOT NULL,由 orgCode→customer.icc_code 反查 +- `gateway_id` 可空,由 parentCode→electric_gateway.device_code 反查 +- `equip_id` 可空,同步不填充 + +#### Scenario: 迁移执行成功 +- **WHEN** 执行迁移到生产库 +- **THEN** electric_line 包含新字段集,旧字段(building_id/floor_no/location/icc_device_code)被移除,equip_id 可空,索引重建;db_seed.sql 与生产库一致 + +### Requirement: 真实 ICC 探测器拉取同步 +系统 SHALL 复用 `getIccAccessToken`/`iccFetch` 真实鉴权,POST `{baseUrl}/evo-apigw/evo-fdbu/{version}/detector/subsystem/page` 分页全量拉取(pageNum 递增、pageSize=50、短页停止、MAX_PAGES 保护),按 line_code 幂等 upsert,写 icc_sync_log(sync_type=electric_line)。 + +#### Scenario: 同步成功 +- **WHEN** 手动点击「同步线路」或定时任务触发 +- **THEN** 返回 {pulled, inserted, updated, skipped, synced};新探测器落库、重复 detectorCode 更新;customer_id 解析失败的行跳过;写成功日志 + +#### Scenario: 同步失败 +- **WHEN** ICC 拉取异常(超时/HTTP 错误/鉴权失败) +- **THEN** 仅写 icc_sync_log 失败日志,不抛业务错误,接口返回 code 200 与当前计数 + +### Requirement: 前端台账页按新字段展示 +系统 SHALL 更新 `views/device/line/index.vue`:删除建筑/楼层/位置列,新增探测器类型、IMEI、回路/通道、消防系统、生产日期、所属网关列;保留筛选(客户/状态/关键词)与「同步线路」手动按钮。 + +#### Scenario: 页面渲染 +- **WHEN** 登录 admin 打开 /device/line +- **THEN** 表格展示新字段列,无 v-html,同步按钮触发 POST /api/electric-lines/sync + +## MODIFIED Requirements + +### Requirement: 线路台账列表接口(原有) +`GET /api/electric-lines` 保留分页/筛选/RBAC(非 ADMIN 限定 customer_id),decorate 从 building 改为 gateway/类型/IMEI 展示字段,删除 customer_building 关联。 + +#### Scenario: 列表返回 +- **WHEN** 带权限请求 GET /api/electric-lines +- **THEN** 返回行包含 lineCode/lineName/imei/customer/gateway/type/loop/channel/fireSystem/produceDate/status/syncedAt + +## REMOVED Requirements + +### Requirement: 占位 ICC 同步实现(原 icc_line_sync.service / icc_line_mapper 假配置映射) +**Reason**:无真实鉴权与接口,与 ICC 文档字段不对应。 +**Migration**:删除 `ICC_TOKEN` 占位用法,改为复用 icc_customer_auth 真实鉴权;映射按 DetectorVO 重写。 diff --git a/.trae/specs/add-icc-line-sync/tasks.md b/.trae/specs/add-icc-line-sync/tasks.md new file mode 100644 index 0000000..cb4a859 --- /dev/null +++ b/.trae/specs/add-icc-line-sync/tasks.md @@ -0,0 +1,25 @@ +# Tasks + +- [x] Task 1: 数据库迁移:重建 `electric_line` 结构(删 building_id/floor_no/location/icc_device_code;equip_id 改可空;新增 imei/gateway_id/type_code/type_name/fire_system_code/fire_system_name/part_unit_loop_code/channel_no/channel_sn/produce_date),迁移脚本 `database/migrations/20261024_icc_line_rebuild.sql`,同步更新 `database/db_seed.sql`(fk_line_gateway 幂等补充)与 `database/db_init.sql`(基线建表);`gateway_id` 建 `REFERENCES electric_gateway(id)` 与索引 + - [x] SubTask 1.1: 核对生产库 electric_line 现状(旧结构迁移而来,无占位数据阻碍),确认 ALTER 方案幂等 + - [x] SubTask 1.2: 执行迁移到远程生产库并验证字段/索引/外键(columns 18 个、idx_line_gateway/imei、fk_line_gateway 均就绪) + - [x] SubTask 1.3: 同步 `系统设计/表结构新增及变更.md`(补充 electric_line 新表结构定义) +- [x] Task 2: 后端实体:按新字段重写 `server/src/entity/electric_line.entity.ts`(gatewayId FK→ElectricGateway,删除 building/floor/location/iccDeviceCode) +- [x] Task 3: 映射层:重写 `server/src/task/icc_line_mapper.ts` + - [x] SubTask 3.1: 定义 `ICC_LINE_ENDPOINT = /evo-apigw/evo-fdbu/{version}/detector/subsystem/page` 与 DetectorVO 字段映射(lineCode=detectorCode、lineName=detectorName、imei、typeCode/typeName、fireSystemCode/Name、partUnitLoopCode、channelNo、channelSn、produceDate、status 归一 01→1/02→0、orgCode、parentCode 作为匹配键透出) + - [x] SubTask 3.2: `parseLinePage(payload)` 解析 PageVO{nextPage,totalCount,pageData}(兼容数组形态) +- [x] Task 4: 同步服务:重写 `server/src/service/icc_line_sync.service.ts`,复用 `getIccAccessToken`/`iccFetch`,POST 分页 pageSize=50 短页停止全量拉取;预取 orgCode→customer.id(customer.icc_code)与 parentCode→gateway.id(electric_gateway.device_code)映射避免 N+1;按 line_code 幂等 upsert(equip_id 不填);写 icc_sync_log(sync_type=electric_line);拉取失败仅记日志不抛 + - [x] SubTask 4.1: 定时任务 `icc_line_sync.task.ts`(jobKey/cron 不变)与 job_registry/JobRuntime(job_scheduler.service.ts syncIccLines)链路可用 + - [x] SubTask 4.2: 控制器 `POST /api/electric-lines/sync` 复用返回结构(pulled/inserted/updated/skipped/synced) +- [x] Task 5: 列表服务:`server/src/service/electric_line.service.ts` 更新 decorate(删除 building 反查,新增 gateway/type/imei 展示字段);keyword 检索范围补充 imei +- [x] Task 6: 前端:`client/src/api/device.ts` 更新 `ElectricLine` 类型(新字段);`client/src/views/device/line/index.vue` 删除建筑/楼层/位置列,新增 IMEI/探测器类型/回路/通道/消防系统/生产日期/所属网关列,筛选/分页/同步按钮保留 +- [x] Task 7: 验证: + - [x] SubTask 7.1: 后端 `tsc --noEmit`、前端 `vue-tsc -b` 均 0 错误 + - [x] SubTask 7.2: 真实拉取 ICC 探测器(admin token,拉取 4 条插入 4 条;重跑幂等 updated=4;icc_sync_log 3 条 status=1;列表接口返回新字段) + - [x] SubTask 7.3: 浏览器 admin 登录验证 /device/line 页面新列渲染与手动同步按钮(11 列齐全、同步提示「拉取 4 条,新增 0,更新 4」) + +# Task Dependencies +- Task 2/3/6 依赖 Task 1(表结构先就绪) +- Task 4 依赖 Task 2/3 +- Task 5 依赖 Task 2 +- Task 7 依赖全部 diff --git a/.trae/specs/fix-menu-edit-save/checklist.md b/.trae/specs/fix-menu-edit-save/checklist.md new file mode 100644 index 0000000..5a50fec --- /dev/null +++ b/.trae/specs/fix-menu-edit-save/checklist.md @@ -0,0 +1,8 @@ +# Checklist + +- [x] MenuService.create() 以 camelCase 属性名写入(parentId/menuName/menuPath/component/icon/sortOrder/menuType/permission/visible),父菜单 parentId=null 语义保留 +- [x] MenuService.update() 以 camelCase 属性名写入,部分字段更新不会清空未提交字段 +- [x] sys_menu 自增序列失步已修复(生产库 setval 到 max(id)=64;sys_role/sys_user 序列一并核对),db_seed.sql 的 setval 全部改为 pg_get_serial_sequence() 形式以兼容不同环境序列名 +- [x] 后端 tsc --noEmit 通过 +- [x] PUT /api/menus/:id 修改名称后重新 GET 菜单树已持久化;POST /api/menus 返回 200 且新菜单可见(测试数据已清理) +- [x] 浏览器菜单管理页:编辑名称保存成功、树实时刷新、刷新页面后名称保持;新增菜单可用 diff --git a/.trae/specs/fix-menu-edit-save/spec.md b/.trae/specs/fix-menu-edit-save/spec.md new file mode 100644 index 0000000..cb3a242 --- /dev/null +++ b/.trae/specs/fix-menu-edit-save/spec.md @@ -0,0 +1,31 @@ +# 修复菜单管理「编辑无法保存」Spec + +## Why +菜单管理页面点击「编辑」修改菜单名称后点击保存,接口返回「更新菜单成功」但数据未持久化(名称未变);新增菜单则直接返回 500。根因:`MenuService.create/update` 以 snake_case 数据库列名(`menu_name` 等)作为 Sequelize 的取值键,而实体属性是 camelCase(`menuName` 等),Sequelize 无法匹配从而静默忽略(update)或触发 NOT NULL 报错(create)。 + +## What Changes +- 修正 `server/src/service/menu.service.ts` 中 `create()` 与 `update()`:将写入 Sequelize 的键改为实体 camelCase 属性名(`parentId/menuName/menuPath/component/icon/sortOrder/menuType/permission/visible`),字段映射由实体 `field` 配置负责。 +- 前端与接口协议不变(前端已发送 camelCase body,`menu.controller.ts`、`menu.dto.ts`、`client/src/api/menu.ts` 均无需改动)。 +- **BREAKING**: 无(仅为内部映射修正,接口行为从「假成功/500」变为「真实持久化」)。 + +## Impact +- Affected specs: 系统管理域(sys_menu RBAC) +- Affected code: `server/src/service/menu.service.ts`(仅 create/update 两处取值键);连带修复后「新增菜单」「编辑菜单」均恢复可用 + +## ADDED Requirements +(无新增能力) + +## MODIFIED Requirements +### Requirement: 菜单新增与编辑持久化 +菜单管理的「新增」与「编辑」操作 SHALL 将表单值真实写入 `sys_menu` 表。 + +#### Scenario: 编辑菜单名称后保存 +- **WHEN** 用户在菜单管理中点击某菜单「编辑」,修改菜单名称并点击确定 +- **THEN** 接口返回成功,重新拉取菜单树显示新名称,且刷新页面后名称保持不变 + +#### Scenario: 新增菜单 +- **WHEN** 用户在菜单管理中点击「新增」,填写必填项并确定 +- **THEN** 接口返回 200 并持久化,菜单树中出现新菜单(而非 500) + +## REMOVED Requirements +(无) diff --git a/.trae/specs/fix-menu-edit-save/tasks.md b/.trae/specs/fix-menu-edit-save/tasks.md new file mode 100644 index 0000000..b12a341 --- /dev/null +++ b/.trae/specs/fix-menu-edit-save/tasks.md @@ -0,0 +1,16 @@ +# Tasks +- [x] Task 1: 修复 MenuService 写入键映射 + - [x] 1.1 阅读 menu.service.ts,确认 create()/update() 中 snake_case 键的位置 + - [x] 1.2 将 create() 与 update() 写入 Sequelize 的键改为实体 camelCase 属性名,并确保父子菜单 parentId=null 与 visible 处理逻辑不变 + - [x] 1.3 后端 `npx tsc --noEmit` 通过 +- [x] Task 2: 修复 sys_menu 自增序列失步(新增菜单 500 的根因) + - [x] 2.1 定位根因:列默认值指向 sys_menu_id_seq1 且停留在 2(显式 id 插入不推进序列),与已存在 id 冲突 + - [x] 2.2 生产库一次性同步序列(setval 到 max(id)=64),并顺带核对 sys_role/sys_user 序列 + - [x] 2.3 db_seed.sql 全部 setval 改为 pg_get_serial_sequence() 形式,兼容不同环境序列名 +- [x] Task 3: 验证 + - [x] 3.1 接口自测:PUT /api/menus/:id 修改菜单名称后重新 GET 树,名称已持久化 + - [x] 3.2 接口自测:POST /api/menus 新增菜单返回 200 且树中出现新菜单(自测后清理测试数据) + - [x] 3.3 浏览器验证:菜单管理页编辑名称保存后树实时刷新、刷新页面后保持 + +# Task Dependencies +- [Task 2][Task 3] depend on [Task 1] 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 new file mode 100644 index 0000000..0cd3626 --- /dev/null +++ b/client/src/api/alarm.ts @@ -0,0 +1,142 @@ +import request from '@/utils/request'; + +// ==================== 设备报警 ==================== + +export interface DeviceAlarmRow { + id: number; + alarmCode?: string; + uuid?: string; + lineId?: number; + lineCode?: string; + lineName?: string; + customerId?: number; + nodeCode?: string; + deviceCode?: string; + deviceName?: string; + orgCode?: string; + orgName?: string; + alarmType?: number | string; + // 报警等级:1 严重 / 2 一般 / 3 提示 + alarmGrade?: number; + alarmStat?: number; + // 是否事件(1 事件 / 0 报警) + isEvent?: number; + alarmTime?: string; + alarmValue?: string; + syncedAt?: string; + // 联表补全字段(存在性不确定,使用时防御性取用) + customerName?: string; +} + +export interface DeviceAlarmSummary { + todayCount: number; + gradeOneCount: number; + undispatchedCount: number; + // 活跃严重报警 id(grade=1 且未派单完成),不受分页筛选影响,供前端全局警灯/警笛触发 + activeSevereIds: number[]; +} + +export interface DeviceAlarmListResult { + list: DeviceAlarmRow[]; + total: number; + page: number; + pageSize: number; + summary?: DeviceAlarmSummary; +} + +export interface DeviceAlarmQuery { + page?: number; + pageSize?: number; + customerId?: number; + alarmGrade?: number; + keyword?: string; +} + +export interface DeviceAlarmDispatchParams { + workerId: number; + faultDesc?: string; +} + +export interface DeviceAlarmDispatchResult { + id?: number; + alarmId?: number; + dispatchNo?: string; + workerId?: number; + workerName?: string; + status?: number; + faultDesc?: string; + dispatchedAt?: string; +} + +export const deviceAlarmApi = { + getList: (params: DeviceAlarmQuery) => + request.get('/device-alarms', { params }), + + dispatch: (id: number, body: DeviceAlarmDispatchParams) => + request.post(`/device-alarms/${id}/dispatch`, body), + + // 活跃严重报警 id 轻量接口(供全局悬浮警灯轮询) + activeSevere: () => + request.get<{ activeSevereIds: number[] }>('/device-alarms/active-severe'), +}; + +// ==================== 报警派单台账(Task7 使用) ==================== + +export interface AlarmDispatchRow { + id: number; + alarmId?: number; + dispatchNo?: string; + lineId?: number; + lineCode?: string; + lineName?: string; + customerId?: number; + customerName?: string; + alarmTime?: string; + // 报警等级:1 严重 / 2 一般 / 3 提示 + alarmGrade?: number; + faultDesc?: string; + dispatcher?: string; + workerId?: number; + workerName?: string; + // 状态:0 已派发 / 1 已处理 + status?: number; + dispatchedAt?: string; + createdAt?: string; + completedAt?: string; +} + +export interface AlarmDispatchQuery { + page?: number; + pageSize?: number; + status?: number | string; + keyword?: string; +} + +export interface AlarmDispatchListResult { + list: AlarmDispatchRow[]; + total: number; + page: number; + pageSize: number; +} + +export const alarmDispatchApi = { + getList: (params: AlarmDispatchQuery) => + request.get('/alarm-dispatches', { params }), +}; + +// ==================== ICC 事件订阅(本页订阅状态提示,可选) ==================== + +export interface IccEventStatus { + name?: string; + monitorUrl?: string; + magic?: string; + status?: string; + registerResult?: string; + registeredAt?: string; + lastError?: string; +} + +export const iccEventApi = { + subscribe: () => request.post('/icc-event/subscribe'), + status: () => request.get('/icc-event/subscribe-status'), +}; diff --git a/client/src/api/device.ts b/client/src/api/device.ts index 085e9eb..d8a1552 100644 --- a/client/src/api/device.ts +++ b/client/src/api/device.ts @@ -154,9 +154,6 @@ export interface ElectricLine { iccDeviceCode?: string; lineName?: string; customerId: number; - buildingId?: number; - floorNo?: string; - location?: string; status: number; syncedAt?: string; createdAt: string; @@ -165,7 +162,19 @@ export interface ElectricLine { customerName?: string; equipCode?: string; equipName?: string; - buildingName?: string; + // 探测器 / 网关相关字段 + imei?: string; + gatewayId?: number; + gatewayCode?: string; + gatewayName?: string; + typeCode?: string; + typeName?: string; + fireSystemCode?: string; + fireSystemName?: string; + partUnitLoopCode?: string; + channelNo?: string; + channelSn?: string; + produceDate?: string; } export interface ElectricLineQuery { @@ -202,4 +211,90 @@ export const electricLineApi = { sync: () => request.post('/electric-lines/sync'), +}; + +// ==================== 智电改造网关台账 ==================== + +export interface ElectricGatewayListItem { + id: number; + iccImei?: string; + deviceCode?: string; + deviceName?: string; + customerId: number; + equipId?: number; + orgCode?: string; + orgName?: string; + manufacturerCode?: string; + manufacturerName?: string; + typeCode?: string; + typeName?: string; + productModel?: string; + isOnline: number; + syncedAt?: string; + createdAt: string; + // 联表补全字段 + customerNo?: string; + customerName?: string; + equipCode?: string; + equipName?: string; +} + +export interface ElectricGatewayQuery { + page?: number; + pageSize?: number; + keyword?: string; + customerId?: number; + equipId?: number; + isOnline?: number; +} + +export interface ElectricGatewayPageResult { + list: ElectricGatewayListItem[]; + total: number; + page: number; + pageSize: number; +} + +export interface IccGatewaySyncResult { + pulled: number; + synced: number; + inserted: number; + updated: number; + skipped: number; +} + +export const electricGatewayApi = { + getList: (params: ElectricGatewayQuery) => + request.get('/electric-gateways', { params }), + + getDetail: (id: number) => + request.get(`/electric-gateways/${id}`), + + sync: () => + request.post('/electric-gateways/sync'), +}; + +// ==================== 线路用电(ICC 模拟量) ==================== + +export interface ElectricityAnalogRow { + analogType?: string; + analogName?: string; + analogUnit?: string; + analogValue?: number | string | null; +} + +export interface ElectricityLatestSnapshot { + lineId: number; + lineCode?: string; + lineName?: string; + imei?: string; + customerId?: number; + collectTime?: string | null; + hasData: boolean; + list: ElectricityAnalogRow[]; +} + +export const electricityDataApi = { + getLatestByLine: (lineId: number) => + request.get('/electricity-data/latest', { params: { lineId } }), }; \ No newline at end of file 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/router/index.ts b/client/src/router/index.ts index 1650114..a4a631a 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -175,6 +175,12 @@ const routes: RouteRecordRaw[] = [ component: () => import("@/views/dispatch/maintenance/index.vue"), meta: { requiresAuth: true, title: "维保派单", permission: "dispatch:maintenance" } }, + { + path: "dispatch/alarm", + name: "AlarmDispatchLedger", + component: () => import("@/views/dispatch/alarm/index.vue"), + meta: { requiresAuth: true, title: "报警派单台账", permission: "alarm-dispatch:list" } + }, { path: "device", name: "Device", @@ -193,6 +199,18 @@ const routes: RouteRecordRaw[] = [ component: () => import("@/views/device/line/index.vue"), meta: { requiresAuth: true, title: "智电改造线路台账", permission: "device:list" } }, + { + path: "device/gateway", + name: "GatewayLedger", + component: () => import("@/views/device/gateway/index.vue"), + meta: { requiresAuth: true, title: "智电改造网关台账", permission: "device:list" } + }, + { + path: "device/alarm", + name: "DeviceAlarm", + component: () => import("@/views/device/alarm/index.vue"), + meta: { requiresAuth: true, title: "设备报警监控", permission: "device:list" } + }, { path: "base", name: "Base", diff --git a/client/src/views/device/alarm/index.vue b/client/src/views/device/alarm/index.vue new file mode 100644 index 0000000..9651ec3 --- /dev/null +++ b/client/src/views/device/alarm/index.vue @@ -0,0 +1,941 @@ + + + + diff --git a/client/src/views/device/gateway/index.vue b/client/src/views/device/gateway/index.vue new file mode 100644 index 0000000..6a14a78 --- /dev/null +++ b/client/src/views/device/gateway/index.vue @@ -0,0 +1,510 @@ + + + + + diff --git a/client/src/views/device/line/index.vue b/client/src/views/device/line/index.vue index 25b285c..155e212 100644 --- a/client/src/views/device/line/index.vue +++ b/client/src/views/device/line/index.vue @@ -16,7 +16,7 @@ 智电改造线路台账 ELECTRIC LINE LEDGER -

由 ICC 平台同步的智电改造线路台账,按客户、设备、建筑查看线路及在线状态。

+

由 ICC 平台同步的智电改造线路台账,按客户、网关查看线路、探测器及在线状态。

@@ -47,7 +47,7 @@ @@ -103,16 +103,32 @@ {{ record.lineName || '-' }} + + -