merge: 合并 xtzd/main 的报警监控、ICC 网关/模拟量/报警订阅与菜单修复提交

冲突解决:auth.middleware.ts 与 rbac.middleware.ts 的 ignore 白名单取并集,同时保留 /api/customer-app/*(小程序客户鉴权)与 /api/icc-event/callback(ICC 事件回调 URL token 校验)。
This commit is contained in:
andyesfly authored and trae-solo-agent committed 2026-10-24 21:21:16 +08:00
commit 6aea23c070
72 files changed
+6256 -639

No files matched your search

@@ -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 行);无前端改动
+63
View File
@@ -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)。
+32
View File
@@ -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
@@ -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 全绿;模拟回调端到端验证通过(入库→页面展示→报警灯/声音→发起派单→站内通知→台账可见)
+20
View File
@@ -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 行);无前端改动
+55
View File
@@ -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 依赖全部
@@ -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、菜单与页面渲染正常 —— 全部实测通过
+46
View File
@@ -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
(无既有功能被修改)
+39
View File
@@ -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 依赖全部
@@ -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;浏览器页面新列渲染正常
+71
View File
@@ -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 重写。
+25
View File
@@ -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 依赖全部
@@ -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] 浏览器菜单管理页:编辑名称保存成功、树实时刷新、刷新页面后名称保持;新增菜单可用
+31
View File
@@ -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
(无)
+16
View File
@@ -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]
@@ -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] 浏览器端到端验证通过(默认位置、拖动、刷新记忆、点击跳转、无重置按钮)
@@ -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` 集合与即时响去重逻辑保留,不再提供清空入口。
@@ -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
@@ -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] 浏览器端到端验证通过(警灯旋转、点击跳转、派单完成取消、遗留重新触发、重置、警笛触发)
@@ -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,同步迁移)。
@@ -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
+142
View File
@@ -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<DeviceAlarmListResult>('/device-alarms', { params }),
dispatch: (id: number, body: DeviceAlarmDispatchParams) =>
request.post<DeviceAlarmDispatchResult>(`/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<AlarmDispatchListResult>('/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<IccEventStatus>('/icc-event/subscribe-status'),
};
+99 -4
View File
@@ -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<IccLineSyncResult>('/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<ElectricGatewayPageResult>('/electric-gateways', { params }),
getDetail: (id: number) =>
request.get<ElectricGatewayListItem>(`/electric-gateways/${id}`),
sync: () =>
request.post<IccGatewaySyncResult>('/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<ElectricityLatestSnapshot>('/electricity-data/latest', { params: { lineId } }),
};
+371
View File
@@ -0,0 +1,371 @@
<template>
<!-- 全局悬浮立体警灯(活跃严重报警指示,登录后所有页面可见;点击跳转报警监控页,可拖拽调整位置) -->
<div
ref="sirenRef"
class="floating-siren"
:class="{ dragging: isDragging }"
@mousedown="onMouseDown"
>
<a-tooltip :title="activeSevereIds.length ? `存在 ${activeSevereIds.length} 条未派单严重报警,点击查看` : '无未派单严重报警'">
<div
class="siren-light"
:class="{ 'is-on': activeSevereIds.length > 0 }"
>
<span class="siren-dome"></span>
<span class="siren-beam"></span>
<span class="siren-bulb bulb-red"></span>
<span class="siren-bulb bulb-blue"></span>
</div>
</a-tooltip>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { deviceAlarmApi } from '@/api/alarm';
const router = useRouter();
const route = useRoute();
// 警灯容器 ref(用于读取尺寸与设置/保存位置)
const sirenRef = ref<HTMLDivElement | null>(null);
// 拖动中标志(用于 grabbing 光标样式)
const isDragging = ref(false);
// 当前活跃严重报警 id(grade=1 且无已完成派单),非空时警灯持续旋转显示
const activeSevereIds = ref<number[]>([]);
// 已即时触发过警笛的严重报警 id(用于去重,避免重复提醒)
const notifiedIds = ref<Set<number>>(new Set());
let audioCtx: AudioContext | null = null;
// 拖动状态:起始鼠标坐标、容器起始位置、是否已发生拖动(位移 >5px)
let dragging = false;
let moved = false;
let startX = 0;
let startY = 0;
let originLeft = 0;
let originTop = 0;
const POS_KEY = 'alarm-siren-pos';
// 扫描活跃严重报警 id:新出现且未提醒过的即时触发警笛
function scanActiveSevere(ids: number[]) {
const fresh = ids.filter((id) => !notifiedIds.value.has(id));
if (!fresh.length) return;
fresh.forEach((id) => notifiedIds.value.add(id));
playBeep();
}
// 轮询拉取活跃严重报警 id(轻量接口,不受分页/筛选影响)
async function fetchActiveSevere() {
try {
const res = await deviceAlarmApi.activeSevere();
const ids = (res.data?.activeSevereIds || []).map(Number);
activeSevereIds.value = ids;
scanActiveSevere(ids);
} catch {
// 拦截器已提示,静默忽略
}
}
// 警灯点击(未拖动时由 mouseup 触发):非本页时跳转到设备报警监控页
function goAlarmPage() {
if (route.path !== '/device/alarm') {
router.push('/device/alarm');
}
}
// 拖动开始:记录起始鼠标坐标与容器当前 left/top
function onMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
const el = sirenRef.value;
if (!el) return;
dragging = true;
moved = false;
startX = e.clientX;
startY = e.clientY;
originLeft = el.offsetLeft;
originTop = el.offsetTop;
isDragging.value = true;
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
// 拖动中:容器跟随鼠标移动(起始位置 + 当前位移),位移超过 5px 视为拖动;限制不越出视口
function onMouseMove(e: MouseEvent) {
if (!dragging) return;
const el = sirenRef.value;
if (!el) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moved && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) moved = true;
if (!moved) return;
const maxLeft = window.innerWidth - el.offsetWidth;
const maxTop = window.innerHeight - el.offsetHeight;
el.style.left = `${Math.min(Math.max(originLeft + dx, 0), maxLeft)}px`;
el.style.top = `${Math.min(Math.max(originTop + dy, 0), maxTop)}px`;
}
// 拖动结束:移除监听;未拖动(位移 ≤5px)视为点击跳转,已拖动则保存位置到 localStorage
function onMouseUp() {
if (!dragging) return;
dragging = false;
isDragging.value = false;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
const el = sirenRef.value;
if (moved) {
if (!el) return;
try {
localStorage.setItem(POS_KEY, JSON.stringify({ left: el.offsetLeft, top: el.offsetTop }));
} catch {
// 存储失败静默忽略
}
return;
}
goAlarmPage();
}
// 恢复警灯位置:优先读取本地记录(数值合法且不越界则应用),无记录/解析失败用默认右上角
function restorePosition() {
const el = sirenRef.value;
if (!el) return;
let left = window.innerWidth - el.offsetWidth - 24;
let top = 120;
try {
const raw = localStorage.getItem(POS_KEY);
if (raw) {
const saved = JSON.parse(raw);
const maxLeft = window.innerWidth - el.offsetWidth;
const maxTop = window.innerHeight - el.offsetHeight;
if (
typeof saved?.left === 'number' &&
typeof saved?.top === 'number' &&
Number.isFinite(saved.left) &&
Number.isFinite(saved.top) &&
saved.left >= 0 &&
saved.left <= maxLeft &&
saved.top >= 0 &&
saved.top <= maxTop
) {
left = saved.left;
top = saved.top;
}
}
} catch {
// 解析失败走默认右上角
}
el.style.left = `${left}px`;
el.style.top = `${top}px`;
}
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(() => playSiren(ctx)).catch(() => { /* 静默降级 */ });
} else {
playSiren(ctx);
}
} catch {
// 静默降级:不阻塞页面
}
}
// 警笛合成:700Hz↔900Hz 高低音交替 3 轮(每轮 0.4s 切换频率,共约 2.4s),gain 包络防爆音
function playSiren(ctx: AudioContext) {
const now = ctx.currentTime;
const FREQS = [700, 900];
const ROUNDS = 3;
for (let i = 0; i < ROUNDS * 2; i++) {
const start = now + i * 0.4;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = FREQS[i % 2];
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.4);
}
}
// 轮询(30 秒)与警笛重复提醒(3 分钟)
let pollTimer: number | undefined;
let sirenTimer: number | undefined;
onMounted(() => {
restorePosition();
fetchActiveSevere();
pollTimer = window.setInterval(fetchActiveSevere, 30000);
// 存在活跃严重报警时每 3 分钟重复播放一次警笛提醒
sirenTimer = window.setInterval(() => {
if (activeSevereIds.value.length > 0) playBeep();
}, 180000);
});
onUnmounted(() => {
if (pollTimer) window.clearInterval(pollTimer);
if (sirenTimer) window.clearInterval(sirenTimer);
audioCtx?.close().catch(() => {});
});
</script>
<style scoped>
/* 悬浮立体警灯(活跃严重报警指示),全局固定定位覆盖其他页面内容 */
.floating-siren {
position: fixed;
left: 0;
top: 120px;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: grab;
}
/* 拖动中的光标样式(含警灯主体,覆盖其 pointer 光标) */
.floating-siren.dragging,
.floating-siren.dragging .siren-light {
cursor: grabbing;
}
/* 警灯主体:半球灯罩 + 旋转光束 + 红蓝双灯芯 */
.siren-light {
position: relative;
width: 56px;
height: 56px;
border-radius: 50%;
background:
radial-gradient(circle at 32% 24%, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.08) 40%, transparent 48%),
linear-gradient(160deg, #334155 0%, #0f172a 78%);
box-shadow:
inset 0 2px 6px rgba(255, 255, 255, 0.28),
inset 0 -6px 12px rgba(0, 0, 0, 0.55),
0 10px 22px rgba(15, 23, 42, 0.35);
cursor: pointer;
overflow: hidden;
user-select: none;
transition: box-shadow 0.3s ease;
}
.siren-light:hover {
box-shadow:
inset 0 2px 6px rgba(255, 255, 255, 0.3),
inset 0 -6px 12px rgba(0, 0, 0, 0.55),
0 12px 26px rgba(15, 23, 42, 0.45);
}
/* 灯罩高光:模拟玻璃半球顶部反光 */
.siren-dome {
position: absolute;
inset: 0;
border-radius: 50%;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.35) 0%, transparent 40%);
pointer-events: none;
z-index: 2;
}
/* 旋转光束:红蓝扇形交替扫光,模拟警灯旋转 */
.siren-beam {
position: absolute;
inset: -10px;
border-radius: 50%;
background: conic-gradient(
from 0deg,
rgba(255, 77, 79, 0.5) 0deg 45deg,
transparent 45deg 135deg,
rgba(22, 119, 255, 0.5) 135deg 180deg,
transparent 180deg 360deg
);
opacity: 0;
transition: opacity 0.3s ease;
}
/* 红蓝双灯芯 */
.siren-bulb {
position: absolute;
top: 50%;
width: 15px;
height: 15px;
border-radius: 50%;
transform: translateY(-50%);
opacity: 0.3;
z-index: 1;
}
.bulb-red {
left: 9px;
background: radial-gradient(circle at 35% 30%, #fff, #ff4d4f 55%, #a8071a);
}
.bulb-blue {
right: 9px;
background: radial-gradient(circle at 35% 30%, #fff, #1677ff 55%, #003eb3);
}
/* 激活状态:红蓝交替闪烁 + 光束旋转 + 整体内发光 */
.siren-light.is-on {
box-shadow:
inset 0 2px 6px rgba(255, 255, 255, 0.3),
inset 0 -6px 12px rgba(0, 0, 0, 0.5),
0 0 16px rgba(255, 77, 79, 0.55),
0 10px 22px rgba(15, 23, 42, 0.4);
}
.siren-light.is-on .siren-beam {
opacity: 0.9;
animation: siren-rotate 1.2s linear infinite;
}
.siren-light.is-on .bulb-red {
animation: siren-blink-red 1s ease-in-out infinite;
}
.siren-light.is-on .bulb-blue {
animation: siren-blink-blue 1s ease-in-out infinite;
}
@keyframes siren-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes siren-blink-red {
0%,
45% {
opacity: 1;
box-shadow: 0 0 12px 3px rgba(255, 77, 79, 0.9);
}
55%,
100% {
opacity: 0.15;
box-shadow: none;
}
}
@keyframes siren-blink-blue {
0%,
45% {
opacity: 0.15;
box-shadow: none;
}
55%,
100% {
opacity: 1;
box-shadow: 0 0 12px 3px rgba(22, 119, 255, 0.9);
}
}
</style>
+4
View File
@@ -125,6 +125,9 @@
</a-spin>
</div>
</a-drawer>
<!-- 全局悬浮警灯(登录后所有页面共享,活跃严重报警提示) -->
<AlarmSirenLight />
</a-layout>
</template>
@@ -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()
+18
View File
@@ -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",
+941
View File
@@ -0,0 +1,941 @@
<template>
<div class="alarm-page">
<!-- 顶部标题 -->
<div class="page-header">
<div class="breadcrumb">
<span>运营管控体系</span>
<span class="sep">/</span>
<span>设备监控</span>
<span class="sep">/</span>
<span class="cur">设备报警监控</span>
</div>
<div class="header-main">
<div class="header-left">
<div class="header-row">
<span class="title-bar"></span>
<span class="main-title">设备报警监控</span>
<span class="sub-title">DEVICE ALARM MONITOR</span>
</div>
<p class="desc">实时监控接入线路的设备报警与事件,支持按客户、等级检索并快速发起报警派单。</p>
</div>
<div class="header-right">
<a-button size="small" @click="handleShowSubStatus">
<template #icon><ApiOutlined /></template>
订阅状态
</a-button>
</div>
</div>
</div>
<!-- 统计卡片 -->
<div class="kpi-section">
<div class="kpi-grid">
<div v-for="k in kpiCards" :key="k.key" class="kpi-card" :style="{ borderColor: k.tint }">
<div class="kpi-main">
<div class="kpi-label">
<span v-if="k.dot" class="kpi-dot" :style="{ background: k.color }"></span>
{{ k.label }}
</div>
<div class="kpi-value" :style="{ color: k.color }">{{ k.value }}</div>
<div v-if="k.sub" class="kpi-sub">{{ k.sub }}</div>
</div>
<div class="kpi-icon" :style="{ background: k.tintBg, color: k.color }">
<component :is="k.icon" />
</div>
</div>
</div>
</div>
<!-- 主体卡片 -->
<div class="content-card">
<!-- 筛选区 -->
<div class="filter-card">
<div class="filter-grid">
<div class="filter-item">
<label class="filter-label">所属客户</label>
<a-select
v-model:value="searchForm.customerId"
placeholder="全部客户"
allow-clear
show-search
option-filter-prop="label"
:options="customerOptions"
/>
</div>
<div class="filter-item">
<label class="filter-label">报警等级</label>
<a-select
v-model:value="searchForm.alarmGrade"
placeholder="全部等级"
allow-clear
:options="gradeOptions"
/>
</div>
<div class="filter-item">
<label class="filter-label">关键词检索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="线路名称 / 编码 / 设备名"
allow-clear
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</div>
</div>
<div class="filter-bottom">
<div class="filter-actions">
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</div>
</div>
</div>
<!-- 列表区 -->
<div class="list-section">
<div class="list-header">
<div class="list-title">
<span class="dot"></span>
<span class="t">报警列表</span>
<span class="count">{{ total }} 条</span>
</div>
<div class="list-actions">
<a-button type="text" size="small" title="刷新列表" @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
</a-button>
</div>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'alarmTime'">
<span>{{ formatTime(record.alarmTime) }}</span>
</template>
<template v-else-if="column.key === 'lineName'">
<span class="line-name">{{ record.lineName || record.lineCode || '-' }}</span>
</template>
<template v-else-if="column.key === 'lineCode'">
<span class="mono">{{ record.lineCode || '-' }}</span>
</template>
<template v-else-if="column.key === 'customerId'">
{{ record.customerName || record.customerId || '-' }}
</template>
<template v-else-if="column.key === 'deviceName'">
<span class="device-name">{{ record.deviceName || '-' }}</span>
</template>
<template v-else-if="column.key === 'alarmType'">
<span class="alarm-type">{{ record.alarmType ?? '-' }}</span>
<span v-if="record.isEvent === 1" class="event-tag">事件</span>
</template>
<template v-else-if="column.key === 'alarmGrade'">
<a-tag :color="gradeMeta(record.alarmGrade).color">
{{ gradeMeta(record.alarmGrade).label }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="openDispatch(record)">
发起报警派单
</a-button>
</template>
</template>
<template #emptyText>
<a-empty description="暂无报警数据" />
</template>
</a-table>
</div>
</div>
</div>
<!-- 发起报警派单 -->
<a-modal
v-model:open="dispatchVisible"
title="发起报警派单"
:confirm-loading="dispatchLoading"
width="560px"
ok-text="确认派单"
@ok="handleDispatchSubmit"
>
<div v-if="currentAlarm" class="dispatch-box">
<div class="alarm-summary">
<div class="summary-item"><span class="k">报警线路</span><span class="v">{{ currentAlarm.lineName || currentAlarm.lineCode || '-' }}</span></div>
<div class="summary-item"><span class="k">所属客户</span><span class="v">{{ currentAlarm.customerName || currentAlarm.customerId || '-' }}</span></div>
<div class="summary-item"><span class="k">报警时间</span><span class="v">{{ formatTime(currentAlarm.alarmTime) }}</span></div>
<div class="summary-item"><span class="k">报警等级</span><span class="v"><a-tag :color="gradeMeta(currentAlarm.alarmGrade).color">{{ gradeMeta(currentAlarm.alarmGrade).label }}</a-tag></span></div>
<div class="summary-item"><span class="k">报警类型</span><span class="v">{{ currentAlarm.alarmType ?? '-' }}</span></div>
</div>
<a-form :label-col="{ span: 5 }" :wrapper-col="{ span: 17 }">
<a-form-item label="指派电工" required>
<a-select
v-model:value="dispatchForm.workerId"
placeholder="请选择共享电工"
show-search
option-filter-prop="label"
:options="workerOptions"
/>
</a-form-item>
<a-form-item label="故障描述">
<a-textarea
v-model:value="dispatchForm.faultDesc"
:rows="3"
maxlength="500"
placeholder="选填,描述故障现象"
/>
</a-form-item>
</a-form>
</div>
</a-modal>
<!-- 订阅状态 -->
<a-modal v-model:open="subStatusVisible" title="ICC 事件订阅状态" :footer="null" width="520px">
<a-spin :spinning="subStatusLoading">
<div v-if="subStatusInfo" class="sub-status-box">
<div class="summary-item"><span class="k">订阅名称</span><span class="v">{{ subStatusInfo.name || '-' }}</span></div>
<div class="summary-item"><span class="k">监控地址</span><span class="v mono">{{ subStatusInfo.monitorUrl || '-' }}</span></div>
<div class="summary-item"><span class="k">订阅标识</span><span class="v mono">{{ subStatusInfo.magic || '-' }}</span></div>
<div class="summary-item"><span class="k">当前状态</span><span class="v">{{ subStatusInfo.status || '-' }}</span></div>
<div class="summary-item"><span class="k">最近注册</span><span class="v">{{ subStatusInfo.registerResult || '-' }}</span></div>
<div class="summary-item"><span class="k">注册时间</span><span class="v">{{ formatTime(subStatusInfo.registeredAt) }}</span></div>
<div v-if="subStatusInfo.lastError" class="summary-item"><span class="k">最近异常</span><span class="v error-text">{{ subStatusInfo.lastError }}</span></div>
</div>
<div v-else class="sub-status-empty">暂无订阅信息</div>
</a-spin>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
import { message } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
ApiOutlined,
WarningOutlined,
AlertOutlined,
ThunderboltOutlined,
} from '@ant-design/icons-vue';
import {
deviceAlarmApi,
iccEventApi,
type DeviceAlarmRow,
type DeviceAlarmQuery,
type IccEventStatus,
} from '@/api/alarm';
import { customerApi, type CustomerInfo } from '@/api/customer';
import { workerApi, type WorkerOption } from '@/api/worker';
import { useTableScroll } from '@/composables/useTableScroll';
const { wrapperRef, tableHeight } = useTableScroll();
// ==================== 列表数据 ====================
const loading = ref(false);
const dataSource = ref<DeviceAlarmRow[]>([]);
const total = ref(0);
const searchForm = reactive<DeviceAlarmQuery>({
page: 1,
pageSize: 10,
keyword: '',
customerId: undefined as any,
alarmGrade: undefined as any,
});
const customerOptions = ref<{ label: string; value: number }[]>([]);
const gradeOptions = [
{ label: '严重', value: 1 },
{ label: '一般', value: 2 },
{ label: '提示', value: 3 },
];
const GRADE_META: Record<number, { label: string; color: string }> = {
1: { label: '严重', color: 'error' },
2: { label: '一般', color: 'warning' },
3: { label: '提示', color: 'default' },
};
function gradeMeta(v?: number) {
return GRADE_META[v ?? 0] || { label: '未知', color: 'default' };
}
const columns = [
{ title: '报警时间', dataIndex: 'alarmTime', key: 'alarmTime', ellipsis: true, width: 170 },
{ title: '线路名称', dataIndex: 'lineName', key: 'lineName', ellipsis: true },
{ title: '线路编码', dataIndex: 'lineCode', key: 'lineCode', ellipsis: true, width: 150 },
{ title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
{ title: '设备名称', dataIndex: 'deviceName', key: 'deviceName', ellipsis: true, width: 160 },
{ title: '报警类型', dataIndex: 'alarmType', key: 'alarmType', ellipsis: true, width: 120 },
{ title: '等级', dataIndex: 'alarmGrade', key: 'alarmGrade', align: 'center' as const, width: 90 },
{ title: '操作', key: 'action', align: 'center' as const, fixed: 'right' as const, width: 130 },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
// ==================== 统计卡片 ====================
const summary = reactive({ todayCount: 0, gradeOneCount: 0, undispatchedCount: 0 });
const kpiCards = computed(() => [
{
key: 'today',
label: '今日报警',
value: summary.todayCount,
sub: '今日产生的报警数量',
color: '#0054cd',
tint: '#cfe0ff',
tintBg: 'rgba(0,84,205,0.08)',
icon: WarningOutlined,
dot: true,
},
{
key: 'gradeOne',
label: '严重报警',
value: summary.gradeOneCount,
sub: '等级为「严重」的报警',
color: '#dc2626',
tint: '#fecaca',
tintBg: 'rgba(220,38,38,0.08)',
icon: AlertOutlined,
dot: true,
},
{
key: 'undispatched',
label: '未派单',
value: summary.undispatchedCount,
sub: '尚未发起派单的报警',
color: '#d97706',
tint: '#fde68a',
tintBg: 'rgba(217,119,6,0.10)',
icon: ThunderboltOutlined,
dot: true,
},
]);
// ==================== 共享电工选项 ====================
const workerOptions = ref<{ label: string; value: number }[]>([]);
async function fetchWorkerOptions() {
try {
const res = await workerApi.getOptions();
workerOptions.value = (res.data || []).map((w: WorkerOption) => ({
label: `${w.workerName}(${w.workerTitle || '共享电工'} / ★${w.rating ?? '-'})`,
value: w.id,
}));
} catch {
workerOptions.value = [];
}
}
// ==================== 订阅状态 ====================
const subStatusVisible = ref(false);
const subStatusLoading = ref(false);
const subStatusInfo = ref<IccEventStatus | null>(null);
async function handleShowSubStatus() {
subStatusVisible.value = true;
subStatusLoading.value = true;
subStatusInfo.value = null;
try {
const res = await iccEventApi.status();
subStatusInfo.value = res.data || null;
} catch {
subStatusInfo.value = null;
} finally {
subStatusLoading.value = false;
}
}
// ==================== 数据请求 ====================
async function fetchList() {
loading.value = true;
try {
const res = await deviceAlarmApi.getList(searchForm);
dataSource.value = res.data.list || [];
total.value = res.data.total || 0;
const s = res.data.summary;
if (s) {
summary.todayCount = s.todayCount ?? 0;
summary.gradeOneCount = s.gradeOneCount ?? 0;
summary.undispatchedCount = s.undispatchedCount ?? 0;
}
} catch {
// 拦截器已提示
} finally {
loading.value = false;
}
}
async function fetchCustomerOptions() {
try {
const res: any = await customerApi.getCustomerOptions();
if (res.code === 200) {
const list: CustomerInfo[] = res.data;
customerOptions.value = list.map((item) => ({
label: `${item.customerNo} ${item.customerName}`,
value: item.id,
}));
}
} catch (error) {
console.error('获取客户选项失败:', error);
}
}
// ==================== 轮询(30 秒,仅刷新列表) ====================
let pollTimer: number | undefined;
function startPolling() {
pollTimer = window.setInterval(() => {
fetchList();
}, 30000);
}
onMounted(() => {
fetchList();
fetchCustomerOptions();
fetchWorkerOptions();
startPolling();
});
onUnmounted(() => {
if (pollTimer) window.clearInterval(pollTimer);
});
// ==================== 筛选与分页 ====================
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.customerId = undefined as any;
searchForm.alarmGrade = undefined as any;
searchForm.page = 1;
fetchList();
}
function handleRefresh() {
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchList();
}
// ==================== 发起报警派单 ====================
const dispatchVisible = ref(false);
const dispatchLoading = ref(false);
const currentAlarm = ref<DeviceAlarmRow | null>(null);
const dispatchForm = reactive({
workerId: undefined as number | undefined,
faultDesc: '',
});
function openDispatch(record: DeviceAlarmRow) {
currentAlarm.value = record;
dispatchForm.workerId = undefined;
dispatchForm.faultDesc = '';
dispatchVisible.value = true;
}
async function handleDispatchSubmit() {
if (!currentAlarm.value) return;
if (!dispatchForm.workerId) {
message.warning('请选择共享电工');
return;
}
dispatchLoading.value = true;
try {
const res: any = await deviceAlarmApi.dispatch(currentAlarm.value.id, {
workerId: dispatchForm.workerId,
faultDesc: dispatchForm.faultDesc || undefined,
});
if (res?.data) {
message.success('派单成功');
dispatchVisible.value = false;
fetchList();
} else if (res?.message) {
// 已有未完成派单等业务提示
message.info(res.message);
}
} catch (error: any) {
// 拦截器已提示;此处兜底展示后端 message
if (error?.response?.data?.message) {
message.error(error.response.data.message);
}
} finally {
dispatchLoading.value = false;
}
}
// ==================== 展示辅助 ====================
function formatTime(val?: string) {
if (!val) return '-';
const d = new Date(val);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
</script>
<style scoped>
.alarm-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
padding: 20px 24px;
box-sizing: border-box;
}
/* 顶部标题 */
.page-header {
flex-shrink: 0;
margin-bottom: 16px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #5b6b7e;
margin-bottom: 10px;
}
.breadcrumb .sep {
color: #c2c6d8;
}
.breadcrumb .cur {
color: #0054cd;
font-weight: 700;
}
.header-main {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.header-left {
display: flex;
flex-direction: column;
}
.header-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.title-bar {
width: 5px;
height: 22px;
background: #0054cd;
border-radius: 3px;
align-self: center;
}
.main-title {
font-size: 22px;
font-weight: 700;
color: #0d1c2c;
line-height: 1.4;
letter-spacing: -0.02em;
}
.sub-title {
font-size: 12px;
font-weight: 700;
color: #0054cd;
letter-spacing: 2px;
text-transform: uppercase;
}
.desc {
margin: 6px 0 0;
font-size: 13px;
color: #5b6b7e;
}
.header-right {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
}
/* 统计卡片 */
.kpi-section {
flex-shrink: 0;
margin-bottom: 16px;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
@media (max-width: 1200px) {
.kpi-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.kpi-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.kpi-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
padding: 14px 16px;
box-sizing: border-box;
transition: box-shadow 0.2s ease;
}
.kpi-card:hover {
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08);
}
.kpi-icon {
width: 38px;
height: 38px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.kpi-icon :deep(svg) {
font-size: 18px;
}
.kpi-main {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
}
.kpi-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
font-weight: 600;
color: #1e293b;
}
.kpi-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.kpi-value {
font-size: 24px;
font-weight: 700;
line-height: 1.1;
font-family: Consolas, Menlo, monospace;
letter-spacing: -0.01em;
}
.kpi-sub {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #8a97a8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 主体卡片 */
.content-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
background: #fff;
border: 1px solid #dbe4ee;
border-radius: 12px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
padding: 20px;
box-sizing: border-box;
overflow: hidden;
}
/* 筛选区 */
.filter-card {
flex-shrink: 0;
background: #f8faff;
border: 1px solid #dbe4ee;
border-radius: 10px;
padding: 16px;
box-sizing: border-box;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px 16px;
}
@media (max-width: 1200px) {
.filter-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.filter-grid {
grid-template-columns: 1fr;
}
}
.filter-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.filter-label {
font-size: 12px;
font-weight: 600;
color: #5b6b7e;
}
.filter-bottom {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #dbe4ee;
}
.filter-actions {
display: flex;
gap: 8px;
}
/* 列表区 */
.list-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 4px 12px;
}
.list-title {
display: flex;
align-items: center;
gap: 10px;
}
.list-title .dot {
width: 4px;
height: 16px;
background: #0054cd;
border-radius: 2px;
}
.list-title .t {
font-size: 15px;
font-weight: 700;
color: #0d1c2c;
}
.list-title .count {
padding: 1px 8px;
border-radius: 6px;
background: #eef4ff;
color: #0054cd;
font-size: 12px;
font-weight: 600;
font-family: Consolas, Menlo, monospace;
}
.list-actions {
display: flex;
align-items: center;
gap: 8px;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
/* 单元格 */
.mono {
font-family: Consolas, Menlo, monospace;
font-size: 13px;
color: #0d1c2c;
}
.line-name {
font-weight: 600;
color: #0d1c2c;
}
.device-name {
font-weight: 600;
color: #0d1c2c;
}
.alarm-type {
font-family: Consolas, Menlo, monospace;
font-size: 13px;
color: #0d1c2c;
margin-right: 6px;
}
.event-tag {
display: inline-block;
padding: 0 6px;
border-radius: 4px;
background: #e0f2fe;
color: #0284c7;
font-size: 11px;
font-weight: 600;
line-height: 18px;
}
/* 派单弹窗 */
.dispatch-box {
display: flex;
flex-direction: column;
gap: 12px;
}
.alarm-summary {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 16px;
padding: 12px;
border: 1px solid #eef1f6;
border-radius: 8px;
background: #f8fafc;
}
.summary-item {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.summary-item .k {
flex-shrink: 0;
width: 60px;
font-size: 12px;
color: #5b6b7e;
}
.summary-item .v {
font-size: 13px;
color: #0d1c2c;
word-break: break-all;
}
.error-text {
color: #dc2626;
}
/* 订阅状态弹窗 */
.sub-status-box {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 0;
}
.sub-status-empty {
padding: 32px 0;
text-align: center;
color: #94a3b8;
font-size: 13px;
}
/* AntD 覆盖 */
:deep(.ant-table) {
font-size: 14px;
height: 100%;
}
:deep(.ant-table-thead > tr > th) {
background: #f4f7ff;
color: #5b6b7e;
font-weight: 600;
}
:deep(.ant-btn-primary) {
background: #0054cd;
border-color: #0054cd;
}
:deep(.ant-btn-primary:not(:disabled):hover),
:deep(.ant-btn-primary:not(:disabled):focus) {
background: #136bfb;
border-color: #136bfb;
}
</style>
+510
View File
@@ -0,0 +1,510 @@
<template>
<div class="device-gateway">
<!-- 顶部标题 -->
<div class="page-header">
<div class="breadcrumb">
<span>运营管控体系</span>
<span class="sep">/</span>
<span>设备监控</span>
<span class="sep">/</span>
<span class="cur">智电改造网关台账</span>
</div>
<div class="header-main">
<div class="header-left">
<div class="header-row">
<span class="title-bar"></span>
<span class="main-title">智电改造网关台账</span>
<span class="sub-title">ELECTRIC GATEWAY LEDGER</span>
</div>
<p class="desc">由 ICC 平台同步的智电改造网关台账,按客户、设备查看网关及在线状态。</p>
</div>
</div>
</div>
<!-- 主体卡片 -->
<div class="content-card">
<!-- 筛选区 -->
<div class="filter-card">
<div class="filter-grid">
<div class="filter-item">
<label class="filter-label">所属客户</label>
<a-select
v-model:value="searchForm.customerId"
placeholder="全部客户"
allow-clear
show-search
option-filter-prop="label"
:options="customerOptions"
/>
</div>
<div class="filter-item">
<label class="filter-label">在线状态</label>
<a-select v-model:value="searchForm.isOnline" placeholder="全部状态" allow-clear>
<a-select-option :value="1">在线</a-select-option>
<a-select-option :value="0">离线</a-select-option>
</a-select>
</div>
<div class="filter-item">
<label class="filter-label">关键词检索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="设备名称 / 设备编码 / IMEI"
allow-clear
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</div>
</div>
<div class="filter-bottom">
<div class="filter-actions">
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</div>
</div>
</div>
<!-- 列表区 -->
<div class="list-section">
<div class="list-header">
<div class="list-title">
<span class="dot"></span>
<span class="t">网关台账清单</span>
<span class="count">{{ total }} 条</span>
</div>
<div class="list-actions">
<a-button type="primary" :loading="syncing" @click="handleSync">
<template #icon><SyncOutlined /></template>
同步网关
</a-button>
</div>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'deviceCode'">
<span class="mono">{{ record.deviceCode || '-' }}</span>
</template>
<template v-else-if="column.key === 'deviceName'">
<span class="device-name">{{ record.deviceName || '-' }}</span>
</template>
<template v-else-if="column.key === 'iccImei'">
<span class="mono">{{ record.iccImei || '-' }}</span>
</template>
<template v-else-if="column.key === 'customerId'">
{{ record.customerName ? `${record.customerName}(${record.customerNo})` : '-' }}
</template>
<template v-else-if="column.key === 'manufacturerName'">
{{ record.manufacturerName || '-' }}
</template>
<template v-else-if="column.key === 'typeName'">
{{ record.typeName || '-' }}
</template>
<template v-else-if="column.key === 'productModel'">
{{ record.productModel || '-' }}
</template>
<template v-else-if="column.key === 'isOnline'">
<a-tag :color="record.isOnline === 1 ? 'success' : 'error'">
{{ record.isOnline === 1 ? '在线' : '离线' }}
</a-tag>
</template>
<template v-else-if="column.key === 'syncedAt'">
<span>{{ formatTime(record.syncedAt) }}</span>
</template>
</template>
<template #emptyText>
<a-empty description="暂无网关数据,可点击右上角「同步网关」从 ICC 拉取" />
</template>
</a-table>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
SyncOutlined,
} from '@ant-design/icons-vue';
import {
electricGatewayApi,
type ElectricGatewayListItem,
type ElectricGatewayQuery,
} from '@/api/device';
import { customerApi, type CustomerInfo } from '@/api/customer';
import { useTableScroll } from '@/composables/useTableScroll';
const { wrapperRef, tableHeight } = useTableScroll();
const loading = ref(false);
const syncing = ref(false);
const dataSource = ref<ElectricGatewayListItem[]>([]);
const total = ref(0);
const searchForm = reactive<ElectricGatewayQuery>({
page: 1,
pageSize: 10,
keyword: '',
customerId: undefined as any,
isOnline: undefined as any,
});
const customerOptions = ref<{ label: string; value: number }[]>([]);
const columns = [
{ title: '设备名称', dataIndex: 'deviceName', key: 'deviceName', ellipsis: true },
{ title: '设备编码', dataIndex: 'deviceCode', key: 'deviceCode', ellipsis: true, width: 170 },
{ title: '设备 IMEI', dataIndex: 'iccImei', key: 'iccImei', ellipsis: true, width: 170 },
{ title: '客户名称', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
{ title: '厂商名称', dataIndex: 'manufacturerName', key: 'manufacturerName', ellipsis: true, width: 140 },
{ title: '主机类型', dataIndex: 'typeName', key: 'typeName', ellipsis: true, width: 120 },
{ title: '设备型号', dataIndex: 'productModel', key: 'productModel', ellipsis: true, width: 140 },
{ title: '在线状态', dataIndex: 'isOnline', key: 'isOnline', align: 'center' as const, width: 90 },
{ title: '同步时间', dataIndex: 'syncedAt', key: 'syncedAt', ellipsis: true, width: 170 },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
onMounted(() => {
fetchList();
fetchCustomerOptions();
});
async function fetchList() {
loading.value = true;
try {
const res: any = await electricGatewayApi.getList(searchForm);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
message.error('获取网关列表失败');
} finally {
loading.value = false;
}
}
async function fetchCustomerOptions() {
try {
const res: any = await customerApi.getCustomerOptions();
if (res.code === 200) {
const list: CustomerInfo[] = res.data;
customerOptions.value = list.map((item) => ({
label: `${item.customerNo} ${item.customerName}`,
value: item.id,
}));
}
} catch (error) {
console.error('获取客户选项失败:', error);
}
}
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.customerId = undefined as any;
searchForm.isOnline = undefined as any;
searchForm.page = 1;
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchList();
}
async function handleSync() {
syncing.value = true;
try {
const res: any = await electricGatewayApi.sync();
if (res.code === 200) {
const d = res.data || {};
message.success(`同步完成:拉取 ${d.pulled ?? 0} 条,新增 ${d.inserted ?? 0},更新 ${d.updated ?? 0}`);
fetchList();
} else {
message.error(res.message || '同步失败');
}
} catch (error) {
message.error('同步失败,请稍后重试');
} finally {
syncing.value = false;
}
}
function formatTime(val?: string) {
if (!val) return '-';
const d = new Date(val);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
</script>
<style scoped>
.device-gateway {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
padding: 20px 24px;
box-sizing: border-box;
}
.page-header {
flex-shrink: 0;
margin-bottom: 16px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #5b6b7e;
margin-bottom: 10px;
}
.breadcrumb .sep {
color: #c2c6d8;
}
.breadcrumb .cur {
color: #0054cd;
font-weight: 700;
}
.header-main {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.header-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.title-bar {
width: 5px;
height: 22px;
background: #0054cd;
border-radius: 3px;
align-self: center;
}
.main-title {
font-size: 22px;
font-weight: 700;
color: #0d1c2c;
line-height: 1.4;
letter-spacing: -0.02em;
}
.sub-title {
font-size: 12px;
font-weight: 700;
color: #0054cd;
letter-spacing: 2px;
text-transform: uppercase;
}
.desc {
margin: 6px 0 0;
font-size: 13px;
color: #5b6b7e;
}
.content-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
background: #fff;
border: 1px solid #dbe4ee;
border-radius: 12px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
padding: 20px;
box-sizing: border-box;
overflow: hidden;
}
.filter-card {
flex-shrink: 0;
background: #f8faff;
border: 1px solid #dbe4ee;
border-radius: 10px;
padding: 16px;
box-sizing: border-box;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px 16px;
}
@media (max-width: 1200px) {
.filter-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.filter-grid {
grid-template-columns: 1fr;
}
}
.filter-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.filter-label {
font-size: 12px;
font-weight: 600;
color: #5b6b7e;
}
.filter-bottom {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #dbe4ee;
}
.filter-actions {
display: flex;
gap: 8px;
}
.list-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 4px 12px;
}
.list-title {
display: flex;
align-items: center;
gap: 10px;
}
.list-title .dot {
width: 4px;
height: 16px;
background: #0054cd;
border-radius: 2px;
}
.list-title .t {
font-size: 15px;
font-weight: 700;
color: #0d1c2c;
}
.list-title .count {
padding: 1px 8px;
border-radius: 6px;
background: #eef4ff;
color: #0054cd;
font-size: 12px;
font-weight: 600;
font-family: Consolas, Menlo, monospace;
}
.mono {
font-family: Consolas, Menlo, monospace;
font-size: 13px;
color: #0d1c2c;
}
.device-name {
font-weight: 600;
color: #0d1c2c;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
:deep(.ant-table) {
font-size: 14px;
height: 100%;
}
:deep(.ant-table-thead > tr > th) {
background: #f4f7ff;
color: #5b6b7e;
font-weight: 600;
}
:deep(.ant-btn-primary) {
background: #0054cd;
border-color: #0054cd;
}
:deep(.ant-btn-primary:not(:disabled):hover),
:deep(.ant-btn-primary:not(:disabled):focus) {
background: #136bfb;
border-color: #136bfb;
}
</style>
+116 -12
View File
@@ -16,7 +16,7 @@
<span class="main-title">智电改造线路台账</span>
<span class="sub-title">ELECTRIC LINE LEDGER</span>
</div>
<p class="desc">由 ICC 平台同步的智电改造线路台账,按客户、设备、建筑查看线路及在线状态。</p>
<p class="desc">由 ICC 平台同步的智电改造线路台账,按客户、网关查看线路、探测器及在线状态。</p>
</div>
</div>
</div>
@@ -47,7 +47,7 @@
<label class="filter-label">关键词检索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="线路名称 / 线路编码"
placeholder="线路名称 / 编码 / IMEI"
allow-clear
@pressEnter="handleSearch"
>
@@ -103,16 +103,32 @@
<span class="line-name">{{ record.lineName || '-' }}</span>
</template>
<template v-else-if="column.key === 'imei'">
<span class="mono">{{ record.imei || '-' }}</span>
</template>
<template v-else-if="column.key === 'customerId'">
{{ record.customerName ? `${record.customerName}(${record.customerNo})` : '-' }}
</template>
<template v-else-if="column.key === 'equipId'">
{{ record.equipName ? `${record.equipName}(${record.equipCode})` : '-' }}
<template v-else-if="column.key === 'gatewayId'">
{{ record.gatewayName ? `${record.gatewayName}(${record.gatewayCode})` : (record.gatewayCode || '-') }}
</template>
<template v-else-if="column.key === 'buildingId'">
{{ record.buildingName || '-' }}
<template v-else-if="column.key === 'typeName'">
{{ record.typeName || '-' }}
</template>
<template v-else-if="column.key === 'loopChannel'">
{{ [record.partUnitLoopCode, record.channelNo].filter(Boolean).join('/') || '-' }}
</template>
<template v-else-if="column.key === 'fireSystemName'">
{{ record.fireSystemName || '-' }}
</template>
<template v-else-if="column.key === 'produceDate'">
{{ record.produceDate || '-' }}
</template>
<template v-else-if="column.key === 'status'">
@@ -124,6 +140,11 @@
<template v-else-if="column.key === 'syncedAt'">
<span>{{ formatTime(record.syncedAt) }}</span>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="handleViewData(record)">
线路数据
</a-button>
</template>
</template>
<template #emptyText>
@@ -133,9 +154,31 @@
</div>
</div>
</div>
<a-modal
v-model:open="analogModal.open"
:title="analogModal.title"
:width="720"
:footer="null"
>
<div v-if="analogModal.loading" class="modal-tip">加载中...</div>
<div v-else-if="!analogModal.snapshot || !analogModal.snapshot.hasData" class="modal-tip">暂无模拟量数据,可点击「同步线路」或等待定时同步后查看</div>
<template v-else>
<div class="modal-meta">
<span>线路:{{ analogModal.snapshot.lineName || '-' }}</span>
<span>编码:{{ analogModal.snapshot.lineCode || '-' }}</span>
<span>采集时间:{{ formatTime(analogModal.snapshot.collectTime) }}</span>
</div>
<a-table
:columns="analogColumns"
:data-source="analogModal.snapshot.list"
:pagination="false"
row-key="analogType"
size="small"
/>
</template>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
@@ -146,8 +189,10 @@ import {
} from '@ant-design/icons-vue';
import {
electricLineApi,
electricityDataApi,
type ElectricLine,
type ElectricLineQuery,
type ElectricityLatestSnapshot,
} from '@/api/device';
import { customerApi, type CustomerInfo } from '@/api/customer';
import { useTableScroll } from '@/composables/useTableScroll';
@@ -156,6 +201,25 @@ const { wrapperRef, tableHeight } = useTableScroll();
const loading = ref(false);
const syncing = ref(false);
const analogModal = reactive<{
open: boolean;
loading: boolean;
title: string;
snapshot: ElectricityLatestSnapshot | null;
}>({
open: false,
loading: false,
title: '线路模拟数据',
snapshot: null,
});
const analogColumns = [
{ title: '模拟量名称', dataIndex: 'analogName', key: 'analogName', ellipsis: true },
{ title: '类型', dataIndex: 'analogType', key: 'analogType', ellipsis: true, width: 100 },
{ title: '数值', dataIndex: 'analogValue', key: 'analogValue', align: 'center' as const, width: 120 },
{ title: '单位', dataIndex: 'analogUnit', key: 'analogUnit', align: 'center' as const, width: 80 },
];
const dataSource = ref<ElectricLine[]>([]);
const total = ref(0);
@@ -172,13 +236,16 @@ const customerOptions = ref<{ label: string; value: number }[]>([]);
const columns = [
{ title: '线路编码', dataIndex: 'lineCode', key: 'lineCode', ellipsis: true, width: 160 },
{ title: '线路名称', dataIndex: 'lineName', key: 'lineName', ellipsis: true },
{ title: 'IMEI', dataIndex: 'imei', key: 'imei', ellipsis: true, width: 180 },
{ title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
{ title: '所属设备', dataIndex: 'equipId', key: 'equipId', ellipsis: true, width: 180 },
{ title: '所属建筑', dataIndex: 'buildingId', key: 'buildingId', ellipsis: true, width: 140 },
{ title: '所在楼层', dataIndex: 'floorNo', key: 'floorNo', ellipsis: true, width: 100 },
{ title: '所在位置', dataIndex: 'location', key: 'location', ellipsis: true },
{ title: '所属网关', dataIndex: 'gatewayId', key: 'gatewayId', ellipsis: true, width: 180 },
{ title: '探测器类型', dataIndex: 'typeName', key: 'typeName', ellipsis: true, width: 120 },
{ title: '回路/通道', dataIndex: 'loopChannel', key: 'loopChannel', ellipsis: true, width: 160 },
{ title: '消防系统', dataIndex: 'fireSystemName', key: 'fireSystemName', ellipsis: true, width: 140 },
{ title: '生产日期', dataIndex: 'produceDate', key: 'produceDate', ellipsis: true, width: 120 },
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, width: 80 },
{ title: '同步时间', dataIndex: 'syncedAt', key: 'syncedAt', ellipsis: true, width: 170 },
{ title: '操作', key: 'action', align: 'center' as const, fixed: 'right' as const, width: 100 },
];
const pagination = computed(() => ({
@@ -262,6 +329,25 @@ async function handleSync() {
}
}
async function handleViewData(record: ElectricLine) {
analogModal.open = true;
analogModal.loading = true;
analogModal.title = `线路数据 - ${record.lineName || record.lineCode}`;
analogModal.snapshot = null;
try {
const res: any = await electricityDataApi.getLatestByLine(record.id);
if (res.code === 200) {
analogModal.snapshot = res.data;
} else {
message.error(res.message || '获取模拟数据失败');
}
} catch (error) {
message.error('获取模拟数据失败,请稍后重试');
} finally {
analogModal.loading = false;
}
}
function formatTime(val?: string) {
if (!val) return '-';
const d = new Date(val);
@@ -499,4 +585,22 @@ function formatTime(val?: string) {
background: #136bfb;
border-color: #136bfb;
}
</style>
.modal-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
font-size: 13px;
color: #5b6b7e;
margin-bottom: 12px;
}
.modal-meta span {
font-weight: 600;
color: #0d1c2c;
}
.modal-tip {
padding: 40px 0;
text-align: center;
color: #5b6b7e;
}
</style>
+511
View File
@@ -0,0 +1,511 @@
<template>
<div class="alarm-dispatch-page">
<!-- 顶部标题 -->
<div class="page-header">
<div class="breadcrumb">
<span>运营管控体系</span>
<span class="sep">/</span>
<span>运营派单</span>
<span class="sep">/</span>
<span class="cur">报警派单台账</span>
</div>
<div class="header-main">
<div class="header-left">
<div class="header-row">
<span class="title-bar"></span>
<span class="main-title">报警派单台账</span>
<span class="sub-title">ALARM DISPATCH LEDGER</span>
</div>
<p class="desc">统一管理报警派单全过程台账,支持按派单状态检索,跟踪派发、处理与完成情况。</p>
</div>
</div>
</div>
<!-- 主体卡片 -->
<div class="content-card">
<!-- 筛选区 -->
<div class="filter-card">
<div class="filter-grid">
<div class="filter-item">
<label class="filter-label">派单状态</label>
<a-select
v-model:value="searchForm.status"
placeholder="全部状态"
allow-clear
:options="statusOptions"
/>
</div>
<div class="filter-item">
<label class="filter-label">关键词检索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="派单号 / 线路名称 / 线路编码"
allow-clear
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</div>
</div>
<div class="filter-bottom">
<div class="filter-actions">
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</div>
</div>
</div>
<!-- 列表区 -->
<div class="list-section">
<div class="list-header">
<div class="list-title">
<span class="dot"></span>
<span class="t">报警派单数据台账</span>
<span class="count">{{ total }} 条</span>
</div>
<div class="list-actions">
<a-button type="text" size="small" title="刷新列表" @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
</a-button>
</div>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dispatchNo'">
<span class="mono dispatch-no">{{ record.dispatchNo || '-' }}</span>
</template>
<template v-else-if="column.key === 'alarmTime'">
<span>{{ formatTime(record.alarmTime) }}</span>
</template>
<template v-else-if="column.key === 'lineName'">
<span class="line-name">{{ record.lineName || record.lineCode || '-' }}</span>
</template>
<template v-else-if="column.key === 'lineCode'">
<span class="mono">{{ record.lineCode || '-' }}</span>
</template>
<template v-else-if="column.key === 'customerId'">
{{ record.customerName || record.customerId || '-' }}
</template>
<template v-else-if="column.key === 'workerName'">
<span>{{ record.workerName || '未指派' }}</span>
</template>
<template v-else-if="column.key === 'alarmGrade'">
<a-tag :color="gradeMeta(record.alarmGrade).color">
{{ gradeMeta(record.alarmGrade).label }}
</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="statusMeta(record.status).color">
{{ statusMeta(record.status).label }}
</a-tag>
</template>
<template v-else-if="column.key === 'dispatcher'">
<span>{{ record.dispatcher || '-' }}</span>
</template>
<template v-else-if="column.key === 'createdAt'">
<span>{{ formatTime(record.createdAt) }}</span>
</template>
</template>
<template #emptyText>
<a-empty description="暂无报警派单数据" />
</template>
</a-table>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue';
import { alarmDispatchApi, type AlarmDispatchRow, type AlarmDispatchQuery } from '@/api/alarm';
import { useTableScroll } from '@/composables/useTableScroll';
const { wrapperRef, tableHeight } = useTableScroll();
// ==================== 列表数据 ====================
const loading = ref(false);
const dataSource = ref<AlarmDispatchRow[]>([]);
const total = ref(0);
const searchForm = reactive<AlarmDispatchQuery>({
page: 1,
pageSize: 10,
keyword: '',
status: undefined as any,
});
const statusOptions = [
{ label: '已派发', value: 0 },
{ label: '已处理', value: 1 },
];
const STATUS_META: Record<number, { label: string; color: string }> = {
0: { label: '已派发', color: 'processing' },
1: { label: '已处理', color: 'success' },
};
function statusMeta(v?: number) {
return STATUS_META[v ?? -1] || { label: '未知', color: 'default' };
}
const GRADE_META: Record<number, { label: string; color: string }> = {
1: { label: '严重', color: 'error' },
2: { label: '一般', color: 'warning' },
3: { label: '提示', color: 'default' },
};
function gradeMeta(v?: number) {
return GRADE_META[v ?? 0] || { label: '未知', color: 'default' };
}
const columns = [
{ title: '派单号', dataIndex: 'dispatchNo', key: 'dispatchNo', ellipsis: true, width: 160 },
{ title: '报警时间', dataIndex: 'alarmTime', key: 'alarmTime', ellipsis: true, width: 170 },
{ title: '线路名称', dataIndex: 'lineName', key: 'lineName', ellipsis: true },
{ title: '线路编码', dataIndex: 'lineCode', key: 'lineCode', ellipsis: true, width: 150 },
{ title: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
{ title: '指派电工', dataIndex: 'workerName', key: 'workerName', ellipsis: true, width: 120 },
{ title: '报警等级', dataIndex: 'alarmGrade', key: 'alarmGrade', align: 'center' as const, width: 90 },
{ title: '状态', dataIndex: 'status', key: 'status', align: 'center' as const, width: 100 },
{ title: '派发人', dataIndex: 'dispatcher', key: 'dispatcher', ellipsis: true, width: 120 },
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', ellipsis: true, width: 170 },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
// ==================== 数据请求 ====================
async function fetchList() {
loading.value = true;
try {
const res = await alarmDispatchApi.getList(searchForm);
dataSource.value = res.data.list || [];
total.value = res.data.total || 0;
} catch {
// 拦截器已提示
} finally {
loading.value = false;
}
}
onMounted(() => {
fetchList();
});
// ==================== 筛选与分页 ====================
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.status = undefined as any;
searchForm.page = 1;
fetchList();
}
function handleRefresh() {
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchList();
}
// ==================== 展示辅助 ====================
function formatTime(val?: string) {
if (!val) return '-';
const d = new Date(val);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
</script>
<style scoped>
.alarm-dispatch-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: #f4f7fb;
padding: 20px 24px;
box-sizing: border-box;
}
/* 顶部标题 */
.page-header {
flex-shrink: 0;
margin-bottom: 16px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #5b6b7e;
margin-bottom: 10px;
}
.breadcrumb .sep {
color: #c2c6d8;
}
.breadcrumb .cur {
color: #0054cd;
font-weight: 700;
}
.header-main {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.header-left {
display: flex;
flex-direction: column;
}
.header-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.title-bar {
width: 5px;
height: 22px;
background: #0054cd;
border-radius: 3px;
align-self: center;
}
.main-title {
font-size: 22px;
font-weight: 700;
color: #0d1c2c;
line-height: 1.4;
letter-spacing: -0.02em;
}
.sub-title {
font-size: 12px;
font-weight: 700;
color: #0054cd;
letter-spacing: 2px;
text-transform: uppercase;
}
.desc {
margin: 6px 0 0;
font-size: 13px;
color: #5b6b7e;
}
/* 主体卡片 */
.content-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
padding: 16px 18px;
box-sizing: border-box;
overflow: hidden;
}
/* 筛选区 */
.filter-card {
flex-shrink: 0;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 14px 16px;
box-sizing: border-box;
}
.filter-grid {
display: grid;
grid-template-columns: 1fr minmax(220px, 2fr);
gap: 12px 16px;
}
@media (max-width: 1200px) {
.filter-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.filter-grid {
grid-template-columns: 1fr;
}
}
.filter-item {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.filter-label {
font-size: 12px;
font-weight: 600;
color: #5b6b7e;
}
.filter-bottom {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #eef1f6;
}
.filter-actions {
display: flex;
gap: 8px;
}
/* 列表区 */
.list-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 4px 12px;
}
.list-title {
display: flex;
align-items: center;
gap: 10px;
}
.list-title .dot {
width: 4px;
height: 16px;
background: #0054cd;
border-radius: 2px;
}
.list-title .t {
font-size: 15px;
font-weight: 700;
color: #0d1c2c;
}
.list-title .count {
padding: 1px 8px;
border-radius: 6px;
background: #eef4ff;
color: #0054cd;
font-size: 12px;
font-weight: 600;
font-family: Consolas, Menlo, monospace;
}
.list-actions {
display: flex;
align-items: center;
gap: 8px;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
/* 单元格 */
.mono {
font-family: Consolas, Menlo, monospace;
font-size: 13px;
color: #0d1c2c;
}
.dispatch-no {
color: #0054cd;
font-weight: 700;
}
.line-name {
font-weight: 600;
color: #0d1c2c;
}
/* AntD 覆盖 */
:deep(.ant-table) {
font-size: 14px;
height: 100%;
}
:deep(.ant-table-thead > tr > th) {
background: #f8fafc;
color: #5b6b7e;
font-weight: 600;
}
:deep(.ant-btn-primary) {
background: #0054cd;
border-color: #0054cd;
}
:deep(.ant-btn-primary:not(:disabled):hover),
:deep(.ant-btn-primary:not(:disabled):focus) {
background: #136bfb;
border-color: #136bfb;
}
</style>
+43 -266
View File
@@ -41,7 +41,7 @@
resolved "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz"
integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==
"@babel/core@^7.23.0":
"@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.23.0":
version "7.29.7"
resolved "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz"
integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==
@@ -275,6 +275,14 @@
"@babel/types" "^7.29.7"
debug "^4.3.1"
"@babel/types@^7.28.2", "@babel/types@^7.29.7":
version "7.29.7"
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz"
integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==
dependencies:
"@babel/helper-string-parser" "^7.29.7"
"@babel/helper-validator-identifier" "^7.29.7"
"@babel/types@7.23.0":
version "7.23.0"
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.23.0.tgz"
@@ -284,14 +292,6 @@
"@babel/helper-validator-identifier" "^7.22.20"
to-fast-properties "^2.0.0"
"@babel/types@^7.28.2", "@babel/types@^7.29.7":
version "7.29.7"
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz"
integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==
dependencies:
"@babel/helper-string-parser" "^7.29.7"
"@babel/helper-validator-identifier" "^7.29.7"
"@ctrl/tinycolor@^3.4.0", "@ctrl/tinycolor@^3.5.0":
version "3.6.1"
resolved "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz"
@@ -307,116 +307,6 @@
resolved "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.8.1.tgz"
integrity sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==
"@esbuild/aix-ppc64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f"
integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==
"@esbuild/android-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052"
integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==
"@esbuild/android-arm@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28"
integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==
"@esbuild/android-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e"
integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==
"@esbuild/darwin-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a"
integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==
"@esbuild/darwin-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22"
integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==
"@esbuild/freebsd-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e"
integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==
"@esbuild/freebsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261"
integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==
"@esbuild/linux-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b"
integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==
"@esbuild/linux-arm@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9"
integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==
"@esbuild/linux-ia32@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2"
integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==
"@esbuild/linux-loong64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df"
integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==
"@esbuild/linux-mips64el@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe"
integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==
"@esbuild/linux-ppc64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4"
integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==
"@esbuild/linux-riscv64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc"
integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==
"@esbuild/linux-s390x@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de"
integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==
"@esbuild/linux-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0"
integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==
"@esbuild/netbsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047"
integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==
"@esbuild/openbsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70"
integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==
"@esbuild/sunos-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b"
integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==
"@esbuild/win32-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d"
integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==
"@esbuild/win32-ia32@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b"
integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==
"@esbuild/win32-x64@0.21.5":
version "0.21.5"
resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz"
@@ -515,7 +405,7 @@
"@nodelib/fs.stat" "2.0.5"
run-parallel "^1.1.9"
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
version "2.0.5"
resolved "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -528,121 +418,6 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@rollup/rollup-android-arm-eabi@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz#3a04f01e9f01392bbef5920b94aa3b88794be7ab"
integrity sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==
"@rollup/rollup-android-arm64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz#e371b653ceabc900790ae73f5548a0fd7cd63a70"
integrity sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==
"@rollup/rollup-darwin-arm64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz#2a5aa70432e39816d666d79287a7324cfc3b4e72"
integrity sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==
"@rollup/rollup-darwin-x64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz#c3b5b49629379cd9cdc5d841bf00ed44ebf393dd"
integrity sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==
"@rollup/rollup-freebsd-arm64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz#f929d8e0462fae6602fc960beeabd7287d859283"
integrity sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==
"@rollup/rollup-freebsd-x64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz#c01cb58031226f95d0900b1ec847f4fb32c6e809"
integrity sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==
"@rollup/rollup-linux-arm-gnueabihf@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz#f29d890c4858c8e0d3be01677eef4f6a359eed9d"
integrity sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==
"@rollup/rollup-linux-arm-musleabihf@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz#1ebfc8eb9f66136ed2faae5f44995add5ca3c964"
integrity sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==
"@rollup/rollup-linux-arm64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz#c1fa823c2c4ce46ba7f61de1a4c3fdadd4fb4e7b"
integrity sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==
"@rollup/rollup-linux-arm64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz#a7f18854d0471b78bda8ea38f0891a4e059b571d"
integrity sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==
"@rollup/rollup-linux-loong64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz#83658a9a4576bcce8cef85b2c78b9b649d2200c4"
integrity sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==
"@rollup/rollup-linux-loong64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz#fd2af677ae3417bb58d57ae37dd0d84686e40244"
integrity sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==
"@rollup/rollup-linux-ppc64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz#6481647181c4cf8f1ddbd99f62c84cfc56c1a94a"
integrity sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==
"@rollup/rollup-linux-ppc64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz#18610a1a1550e28a5042ca916f898419540f17f4"
integrity sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==
"@rollup/rollup-linux-riscv64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz#597bb80465a2621dbe0de0a41c66394a8a7e9a6e"
integrity sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==
"@rollup/rollup-linux-riscv64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz#a2a919a9f927ef7f24a60af77e3cb55f1ad59e4d"
integrity sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==
"@rollup/rollup-linux-s390x-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz#3166f6ceae7df9bbfddf9f36be1937231e13e3c6"
integrity sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==
"@rollup/rollup-linux-x64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz#23c9bf79771d804fb87415eb0767569f273261e5"
integrity sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==
"@rollup/rollup-linux-x64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz#97941c6b94d67fe25cde0f027c10a19f2d1fdd39"
integrity sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==
"@rollup/rollup-openbsd-x64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz#7aeb7d92e2cd1d399f56daf75c39040b777b6c77"
integrity sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==
"@rollup/rollup-openharmony-arm64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz#925de61ae83bf99aa636e8acea87432e8c0ffaab"
integrity sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==
"@rollup/rollup-win32-arm64-msvc@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz#888ab83842721491044c46a7407e1f38f3235bb4"
integrity sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==
"@rollup/rollup-win32-ia32-msvc@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz#fa30ac24e3f0232139d2a47500560a28695764d4"
integrity sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==
"@rollup/rollup-win32-x64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz"
@@ -666,7 +441,7 @@
resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
"@types/node@^22.15.30":
"@types/node@^18.0.0 || >=20.0.0", "@types/node@^22.15.30":
version "22.19.19"
resolved "https://registry.npmmirror.com/@types/node/-/node-22.19.19.tgz"
integrity sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==
@@ -688,7 +463,7 @@
natural-compare "^1.4.0"
ts-api-utils "^1.3.0"
"@typescript-eslint/parser@^7.0.1":
"@typescript-eslint/parser@^7.0.0", "@typescript-eslint/parser@^7.0.1":
version "7.18.0"
resolved "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-7.18.0.tgz"
integrity sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==
@@ -764,14 +539,14 @@
resolved "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz"
integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==
"@volar/language-core@1.11.1", "@volar/language-core@~1.11.1":
"@volar/language-core@~1.11.1", "@volar/language-core@1.11.1":
version "1.11.1"
resolved "https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz"
integrity sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==
dependencies:
"@volar/source-map" "1.11.1"
"@volar/source-map@1.11.1", "@volar/source-map@~1.11.1":
"@volar/source-map@~1.11.1", "@volar/source-map@1.11.1":
version "1.11.1"
resolved "https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz"
integrity sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==
@@ -828,7 +603,7 @@
estree-walker "^2.0.2"
source-map-js "^1.2.1"
"@vue/compiler-dom@3.5.34", "@vue/compiler-dom@^3.3.0", "@vue/compiler-dom@^3.3.4":
"@vue/compiler-dom@^3.3.0", "@vue/compiler-dom@^3.3.4", "@vue/compiler-dom@3.5.34":
version "3.5.34"
resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz"
integrity sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==
@@ -836,7 +611,7 @@
"@vue/compiler-core" "3.5.34"
"@vue/shared" "3.5.34"
"@vue/compiler-sfc@3.5.34", "@vue/compiler-sfc@^3.5.18":
"@vue/compiler-sfc@^3.5.18", "@vue/compiler-sfc@3.5.34":
version "3.5.34"
resolved "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz"
integrity sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==
@@ -921,7 +696,7 @@
"@vue/reactivity" "3.5.34"
"@vue/shared" "3.5.34"
"@vue/runtime-dom@3.5.34", "@vue/runtime-dom@^3.4.15":
"@vue/runtime-dom@^3.4.15", "@vue/runtime-dom@3.5.34":
version "3.5.34"
resolved "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz"
integrity sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==
@@ -939,7 +714,7 @@
"@vue/compiler-ssr" "3.5.34"
"@vue/shared" "3.5.34"
"@vue/shared@3.5.34", "@vue/shared@^3.3.0", "@vue/shared@^3.5.18":
"@vue/shared@^3.3.0", "@vue/shared@^3.5.18", "@vue/shared@3.5.34":
version "3.5.34"
resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz"
integrity sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==
@@ -954,7 +729,7 @@ acorn-jsx@^5.3.2:
resolved "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn@^8.14.0, acorn@^8.9.0:
"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.14.0, acorn@^8.9.0:
version "8.16.0"
resolved "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz"
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
@@ -1127,7 +902,7 @@ braces@^3.0.3, braces@~3.0.2:
dependencies:
fill-range "^7.1.1"
browserslist@^4.24.0, browserslist@^4.28.2:
browserslist@^4.24.0, browserslist@^4.28.2, "browserslist@>= 4.21.0":
version "4.28.2"
resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz"
integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==
@@ -1281,7 +1056,7 @@ de-indent@^1.0.2:
resolved "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz"
integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==
debug@4, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4:
debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@4:
version "4.4.3"
resolved "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
@@ -1341,7 +1116,7 @@ dunder-proto@^1.0.1:
es-errors "^1.3.0"
gopd "^1.2.0"
echarts@^6.1.0:
echarts@^6.0.0, echarts@^6.1.0:
version "6.1.0"
resolved "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz"
integrity sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==
@@ -1459,7 +1234,7 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4
resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz"
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
eslint@^8.56.0:
"eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", eslint@^8.56.0, eslint@>=6.0.0:
version "8.57.1"
resolved "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz"
integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==
@@ -1641,11 +1416,6 @@ fs.realpath@^1.0.0:
resolved "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz"
@@ -1881,7 +1651,7 @@ isexe@^2.0.0:
resolved "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
jiti@^1.21.7:
jiti@^1.21.7, jiti@>=1.21.0:
version "1.21.7"
resolved "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz"
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
@@ -1935,7 +1705,7 @@ kolorist@^1.8.0:
resolved "https://registry.npmmirror.com/kolorist/-/kolorist-1.8.0.tgz"
integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==
less@^4.6.4:
less@*, less@^4.6.4:
version "4.6.4"
resolved "https://registry.npmmirror.com/less/-/less-4.6.4.tgz"
integrity sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==
@@ -2067,7 +1837,14 @@ minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
dependencies:
brace-expansion "^1.1.7"
minimatch@^9.0.3, minimatch@^9.0.4:
minimatch@^9.0.3:
version "9.0.9"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz"
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
dependencies:
brace-expansion "^2.0.2"
minimatch@^9.0.4:
version "9.0.9"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz"
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
@@ -2238,7 +2015,7 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz"
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
picomatch@^4.0.4:
"picomatch@^3 || ^4", picomatch@^4.0.4:
version "4.0.4"
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
@@ -2308,7 +2085,7 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
resolved "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.4.35, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.5.14:
postcss@^8.0.0, postcss@^8.1.0, postcss@^8.2.14, postcss@^8.4.21, postcss@^8.4.35, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.5.14, postcss@>=8.0.9:
version "8.5.15"
resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz"
integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==
@@ -2639,16 +2416,16 @@ ts-interface-checker@^0.1.9:
resolved "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz"
integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
tslib@2.3.0:
version "2.3.0"
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
tslib@^2.8.1:
version "2.8.1"
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
tslib@2.3.0:
version "2.3.0"
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz"
@@ -2661,7 +2438,7 @@ type-fest@^0.20.2:
resolved "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz"
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
typescript@~5.3.3:
typescript@*, typescript@>=4.2.0, typescript@>=4.5.0, typescript@~5.3.3, typescript@5.x:
version "5.3.3"
resolved "https://registry.npmmirror.com/typescript/-/typescript-5.3.3.tgz"
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
@@ -2731,7 +2508,7 @@ vite-plugin-vue-dev-locator@1.0.3:
kolorist "^1.8.0"
magic-string "^0.30.4"
vite@^5.0.12:
"vite@^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0", "vite@^5.0.0 || ^6.0.0", vite@^5.0.12, vite@>=4.0.0:
version "5.4.21"
resolved "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz"
integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==
@@ -2791,7 +2568,7 @@ vue-types@^3.0.0:
dependencies:
is-plain-object "3.0.1"
vue@^3.4.15:
vue@^3.0.0, vue@^3.2.25, vue@^3.3.0, vue@^3.4.0, vue@^3.4.15, vue@^3.5.0, vue@^3.5.11, vue@>=3.0.1, vue@>=3.0.3, vue@>=3.2.0, vue@3.5.34:
version "3.5.34"
resolved "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz"
integrity sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==
+115 -139
View File
@@ -614,35 +614,47 @@ CREATE INDEX idx_contract_xt ON contract (xt_contract_no);
-- 19. 智电改造线路表(ICC 同步 + 业务分配)
DROP TABLE IF EXISTS electric_line CASCADE;
CREATE TABLE electric_line (
id BIGSERIAL PRIMARY KEY,
line_code VARCHAR(64) NOT NULL UNIQUE,
equip_id BIGINT NOT NULL REFERENCES customer_equipment(id),
icc_device_code VARCHAR(128),
line_name VARCHAR(128),
customer_id BIGINT NOT NULL REFERENCES customer(id),
building_id BIGINT REFERENCES customer_building(id),
floor_no VARCHAR(16),
location VARCHAR(256),
status SMALLINT NOT NULL DEFAULT 1,
synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
id BIGSERIAL PRIMARY KEY,
line_code VARCHAR(64) NOT NULL UNIQUE,
equip_id BIGINT,
gateway_id BIGINT,
customer_id BIGINT NOT NULL REFERENCES customer(id),
line_name VARCHAR(128),
imei VARCHAR(64),
type_code VARCHAR(32),
type_name VARCHAR(64),
fire_system_code VARCHAR(64),
fire_system_name VARCHAR(128),
part_unit_loop_code VARCHAR(64),
channel_no VARCHAR(64),
channel_sn VARCHAR(64),
produce_date VARCHAR(16),
status SMALLINT NOT NULL DEFAULT 1,
synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE electric_line IS '智电改造线路信息表(ICC 同步)';
COMMENT ON COLUMN electric_line.id IS '自增主键';
COMMENT ON COLUMN electric_line.line_code IS '线路编码(唯一)';
COMMENT ON COLUMN electric_line.equip_id IS '客户设备 ID(→ customer_equipment)';
COMMENT ON COLUMN electric_line.icc_device_code IS 'ICC 智电设备编号';
COMMENT ON COLUMN electric_line.line_name IS '线路名称(ICC 同步)';
COMMENT ON COLUMN electric_line.customer_id IS '客户 ID(逻辑隔离键)';
COMMENT ON COLUMN electric_line.building_id IS '所属建筑 ID(→ customer_building)';
COMMENT ON COLUMN electric_line.floor_no IS '所在楼层';
COMMENT ON COLUMN electric_line.location IS '所在位置';
COMMENT ON COLUMN electric_line.status IS '状态:1在线 0离线';
COMMENT ON COLUMN electric_line.synced_at IS 'ICC 同步时间';
COMMENT ON COLUMN electric_line.created_at IS '创建时间';
COMMENT ON TABLE electric_line IS '智电改造线路信息表(ICC 探测器同步)';
COMMENT ON COLUMN electric_line.line_code IS '探测器编码(ICC detectorCode,唯一幂等键)';
COMMENT ON COLUMN electric_line.line_name IS '探测器名称(ICC detectorName)';
COMMENT ON COLUMN electric_line.imei IS '设备唯一标识码(ICC imei)';
COMMENT ON COLUMN electric_line.equip_id IS '客户设备 ID(→ customer_equipment,可空,同步不填充)';
COMMENT ON COLUMN electric_line.gateway_id IS '网关设备 ID(→ electric_gateway,外键由迁移/db_seed 建立)';
COMMENT ON COLUMN electric_line.customer_id IS '客户 ID(orgCode→customer.icc_code 解析,逻辑隔离键)';
COMMENT ON COLUMN electric_line.type_code IS '探测器类型编码';
COMMENT ON COLUMN electric_line.type_name IS '探测器类型名称';
COMMENT ON COLUMN electric_line.fire_system_code IS '消防系统编码';
COMMENT ON COLUMN electric_line.fire_system_name IS '消防系统名称';
COMMENT ON COLUMN electric_line.part_unit_loop_code IS '探测器回路号';
COMMENT ON COLUMN electric_line.channel_no IS '探测器通道号';
COMMENT ON COLUMN electric_line.channel_sn IS '通道唯一标识码';
COMMENT ON COLUMN electric_line.produce_date IS '生产日期(yyyy-MM-dd)';
COMMENT ON COLUMN electric_line.status IS '探测器状态:1在线 0离线(detectorStatus 01→1 / 02→0)';
COMMENT ON COLUMN electric_line.synced_at IS 'ICC 同步时间';
COMMENT ON COLUMN electric_line.created_at IS '创建时间';
CREATE INDEX idx_line_customer ON electric_line (customer_id);
CREATE INDEX idx_line_equip ON electric_line (equip_id);
CREATE INDEX idx_line_icc ON electric_line (icc_device_code);
CREATE INDEX idx_line_gateway ON electric_line (gateway_id);
CREATE INDEX idx_line_imei ON electric_line (imei);
-- 20. 线路所属房间关联表
DROP TABLE IF EXISTS line_room CASCADE;
@@ -909,101 +921,58 @@ COMMENT ON COLUMN fault_type_detail.remark IS '备注';
COMMENT ON COLUMN fault_type_detail.created_at IS '创建时间';
CREATE INDEX idx_ftd_type ON fault_type_detail (fault_type_id);
-- 27. 设备报警信息表(ICC 拉取)
-- 27. 设备报警信息表(ICC 事件订阅)
DROP TABLE IF EXISTS alarm_dispatch CASCADE;
DROP TABLE IF EXISTS device_alarm CASCADE;
CREATE TABLE device_alarm (
id BIGSERIAL PRIMARY KEY,
alarm_code VARCHAR(128) NOT NULL UNIQUE,
line_id BIGINT,
equip_id BIGINT,
icc_device_code VARCHAR(128),
line_name VARCHAR(128),
customer_id BIGINT,
building_id BIGINT,
floor_no VARCHAR(16),
location VARCHAR(256),
alarm_date DATE,
fault_type VARCHAR(128),
alarm_value NUMERIC(14,2),
history_count INT DEFAULT 0,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
id BIGSERIAL PRIMARY KEY,
alarm_code VARCHAR(128) NOT NULL UNIQUE, -- ICC 报警编码(幂等键)
uuid VARCHAR(128), -- ICC 事件 uuid
line_id BIGINT, -- 智电线路 ID(→ electric_line,nodeCode/deviceCode 匹配 line_code)
line_code VARCHAR(64), -- 线路编码(electric_line.line_code)
line_name VARCHAR(128), -- 线路名称
customer_id BIGINT, -- 客户 ID(由 line_id 联出)
node_code VARCHAR(64), -- 探测器节点编码(info.nodeCode)
device_code VARCHAR(64), -- 设备编码(info.deviceCode)
device_name VARCHAR(128), -- 设备名称(info.deviceName)
org_code VARCHAR(64), -- 组织编码(info.orgCode)
org_name VARCHAR(128), -- 组织名称(info.orgName)
alarm_type INT, -- 报警类型(info.alarmType)
alarm_grade SMALLINT, -- 报警等级 1/2/3(1 最严重)
alarm_stat SMALLINT, -- 报警状态(info.alarmStat)
is_event BOOLEAN DEFAULT FALSE, -- 是否事件(info.isEvent)
alarm_time TIMESTAMPTZ, -- 报警发生时间(info.alarmDate 秒级时间戳)
alarm_value NUMERIC(14,4), -- 报警数值(部分事件带值,可空)
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- 入库时间
);
COMMENT ON TABLE device_alarm IS '设备报警信息表(ICC 拉取)';
COMMENT ON COLUMN device_alarm.id IS '自增主键';
COMMENT ON COLUMN device_alarm.alarm_code IS '报警编码(唯一)';
COMMENT ON COLUMN device_alarm.line_id IS '线路 ID';
COMMENT ON COLUMN device_alarm.equip_id IS '客户设备 ID';
COMMENT ON COLUMN device_alarm.icc_device_code IS 'ICC 设备编号';
COMMENT ON COLUMN device_alarm.line_name IS '线路名称';
COMMENT ON COLUMN device_alarm.customer_id IS '客户 ID';
COMMENT ON COLUMN device_alarm.building_id IS '所属建筑 ID';
COMMENT ON COLUMN device_alarm.floor_no IS '所在楼层';
COMMENT ON COLUMN device_alarm.location IS '所在位置';
COMMENT ON COLUMN device_alarm.alarm_date IS '报警日期';
COMMENT ON COLUMN device_alarm.fault_type IS 'ICC 预警类型';
COMMENT ON COLUMN device_alarm.alarm_value IS '报警数值';
COMMENT ON COLUMN device_alarm.history_count IS '历史出现次数';
COMMENT ON COLUMN device_alarm.synced_at IS 'ICC 同步时间';
CREATE INDEX idx_alarm_customer ON device_alarm (customer_id);
CREATE INDEX idx_alarm_date ON device_alarm (alarm_date);
COMMENT ON TABLE device_alarm IS '设备报警信息表(ICC 事件订阅)';
CREATE INDEX idx_alarm_time ON device_alarm (alarm_time DESC);
CREATE INDEX idx_alarm_line ON device_alarm (line_id);
CREATE INDEX idx_alarm_customer ON device_alarm (customer_id);
CREATE INDEX idx_alarm_grade ON device_alarm (alarm_grade);
-- 28. 报警派单信息表
DROP TABLE IF EXISTS alarm_dispatch CASCADE;
CREATE TABLE alarm_dispatch (
id BIGSERIAL PRIMARY KEY,
dispatch_no VARCHAR(64) NOT NULL UNIQUE,
alarm_id BIGINT,
line_id BIGINT,
equip_id BIGINT,
icc_device_code VARCHAR(128),
line_name VARCHAR(128),
customer_id BIGINT NOT NULL REFERENCES customer(id),
contact_person VARCHAR(64),
contact_phone VARCHAR(20),
building_id BIGINT,
floor_no VARCHAR(16),
location VARCHAR(256),
fault_type_id BIGINT REFERENCES fault_type(id),
fault_desc TEXT,
dispatcher VARCHAR(64),
fault_reason TEXT,
process_flow TEXT,
worker_id BIGINT,
status SMALLINT NOT NULL DEFAULT 0,
process_photos JSONB,
rating SMALLINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
id BIGSERIAL PRIMARY KEY,
dispatch_no VARCHAR(64) NOT NULL UNIQUE, -- 报警派单号(如 AD20261024001,生成规则由后端实现)
alarm_id BIGINT, -- 报警 ID(→ device_alarm)
line_id BIGINT, -- 线路 ID
line_code VARCHAR(64), -- 线路编码
line_name VARCHAR(128), -- 线路名称
customer_id BIGINT REFERENCES customer(id), -- 客户 ID
alarm_time TIMESTAMPTZ, -- 报警时间(快照)
alarm_grade SMALLINT, -- 报警等级(快照)
fault_desc TEXT, -- 故障事项(报警类型描述)
dispatcher VARCHAR(64), -- 派发人(当前登录用户 realName)
worker_id BIGINT, -- 共享电工 ID(→ worker)
status SMALLINT NOT NULL DEFAULT 0, -- 状态:0已派发 1已处理
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
COMMENT ON TABLE alarm_dispatch IS '报警派单信息表';
COMMENT ON COLUMN alarm_dispatch.id IS '自增主键';
COMMENT ON COLUMN alarm_dispatch.dispatch_no IS '报警派单号(唯一)';
COMMENT ON COLUMN alarm_dispatch.alarm_id IS '报警 ID(→ device_alarm)';
COMMENT ON COLUMN alarm_dispatch.line_id IS '线路 ID';
COMMENT ON COLUMN alarm_dispatch.equip_id IS '客户设备 ID';
COMMENT ON COLUMN alarm_dispatch.icc_device_code IS 'ICC 设备编号';
COMMENT ON COLUMN alarm_dispatch.line_name IS '线路名称';
COMMENT ON COLUMN alarm_dispatch.customer_id IS '客户 ID(→ customer)';
COMMENT ON COLUMN alarm_dispatch.contact_person IS '联系人';
COMMENT ON COLUMN alarm_dispatch.contact_phone IS '电话';
COMMENT ON COLUMN alarm_dispatch.building_id IS '建筑 ID';
COMMENT ON COLUMN alarm_dispatch.floor_no IS '所在楼层';
COMMENT ON COLUMN alarm_dispatch.location IS '所在位置';
COMMENT ON COLUMN alarm_dispatch.fault_type_id IS '故障类型 ID(→ fault_type)';
COMMENT ON COLUMN alarm_dispatch.fault_desc IS '故障事项(类型+数值)';
COMMENT ON COLUMN alarm_dispatch.dispatcher IS '派发人';
COMMENT ON COLUMN alarm_dispatch.fault_reason IS '故障原因(来自明细)';
COMMENT ON COLUMN alarm_dispatch.process_flow IS '处理流程(来自明细)';
COMMENT ON COLUMN alarm_dispatch.worker_id IS '共享电工 ID';
COMMENT ON COLUMN alarm_dispatch.status IS '状态:0已派发 1已处理';
COMMENT ON COLUMN alarm_dispatch.process_photos IS '报警处理照片(JSONB 多张)';
COMMENT ON COLUMN alarm_dispatch.rating IS '派单处理评分';
COMMENT ON COLUMN alarm_dispatch.created_at IS '创建时间';
COMMENT ON COLUMN alarm_dispatch.completed_at IS '完成时间';
CREATE INDEX idx_alarm_disp_customer ON alarm_dispatch (customer_id);
CREATE INDEX idx_alarm_disp_worker ON alarm_dispatch (worker_id);
CREATE INDEX idx_alarm_disp_status ON alarm_dispatch (status);
COMMENT ON TABLE alarm_dispatch IS '报警派单信息表';
CREATE INDEX idx_alarm_disp_status ON alarm_dispatch (status);
CREATE INDEX idx_alarm_disp_worker ON alarm_dispatch (worker_id);
CREATE INDEX idx_alarm_disp_alarm ON alarm_dispatch (alarm_id);
-- ============================================================
-- 八、报修域(2 张表)
@@ -1266,43 +1235,50 @@ CREATE INDEX idx_wid_worker ON worker_income_detail (worker_id);
-- 十一、用电采集数据(1 张表,按月 RANGE 分区)
-- ============================================================
-- 37. 线路用电信息表(按月分区,时序数据)
-- 37. 线路用电信息表(按月分区,时序数据,ICC 模拟量同步)
DROP TABLE IF EXISTS electricity_data CASCADE;
CREATE TABLE electricity_data (
id BIGSERIAL,
line_id BIGINT NOT NULL,
line_code VARCHAR(64) NOT NULL,
equip_id BIGINT,
icc_device_code VARCHAR(128),
line_name VARCHAR(128),
customer_id BIGINT NOT NULL,
electricity NUMERIC(14,4),
collect_time TIMESTAMPTZ NOT NULL,
PRIMARY KEY (id, collect_time)
id BIGSERIAL,
line_id BIGINT NOT NULL REFERENCES electric_line(id),
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,
PRIMARY KEY (id, collect_time),
CONSTRAINT uk_ed_line_type_time UNIQUE (line_code, analog_type, collect_time)
) PARTITION BY RANGE (collect_time);
COMMENT ON TABLE electricity_data IS '线路用电信息表(按月分区,时序数据)';
COMMENT ON COLUMN electricity_data.id IS '自增(复合主键含 collect_time)';
COMMENT ON COLUMN electricity_data.line_id IS '线路 ID(→ electric_line)';
COMMENT ON COLUMN electricity_data.line_code IS '线路编码(冗余,加速查询)';
COMMENT ON COLUMN electricity_data.equip_id IS '客户设备 ID';
COMMENT ON COLUMN electricity_data.icc_device_code IS 'ICC 设备编号';
COMMENT ON COLUMN electricity_data.line_name IS '线路名称';
COMMENT ON COLUMN electricity_data.customer_id IS '客户 ID(逻辑隔离键)';
COMMENT ON COLUMN electricity_data.electricity IS '用电量';
COMMENT ON COLUMN electricity_data.collect_time IS '采集时间(分区键)';
COMMENT ON TABLE electricity_data IS '线路用电信息表(ICC 模拟量同步,按月分区时序数据)';
COMMENT ON COLUMN electricity_data.id IS '自增(复合主键含 collect_time)';
COMMENT ON COLUMN electricity_data.line_id IS '线路 ID(→ electric_line)';
COMMENT ON COLUMN electricity_data.line_code IS '线路编码(ICC deviceCode,冗余 + 幂等键)';
COMMENT ON COLUMN electricity_data.imei IS '设备唯一标识码(ICC uniqueCode)';
COMMENT ON COLUMN electricity_data.line_name IS '线路名称(冗余,加速展示)';
COMMENT ON COLUMN electricity_data.customer_id IS '客户 ID(逻辑隔离键)';
COMMENT ON COLUMN electricity_data.analog_type IS '模拟量类型(ICC analogType)';
COMMENT ON COLUMN electricity_data.analog_name IS '模拟量名称(ICC analogName,如:剩余电流)';
COMMENT ON COLUMN electricity_data.analog_unit IS '模拟量单位(ICC analogUnit,如:mA)';
COMMENT ON COLUMN electricity_data.analog_value IS '模拟量值(ICC analogValue)';
COMMENT ON COLUMN electricity_data.collect_time IS '采集时间(同步时间,分区键)';
-- 初始分区(2026 年 8~12 月)
CREATE TABLE electricity_data_2026_08 PARTITION OF electricity_data
FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
CREATE TABLE electricity_data_2026_09 PARTITION OF electricity_data
FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');
-- 初始分区(2026 年 10 月 ~ 2027 年 3 月)
CREATE TABLE electricity_data_2026_10 PARTITION OF electricity_data
FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
CREATE TABLE electricity_data_2026_11 PARTITION OF electricity_data
FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');
CREATE TABLE electricity_data_2026_12 PARTITION OF electricity_data
FOR VALUES FROM ('2026-12-01') TO ('2027-01-01');
CREATE TABLE electricity_data_2027_01 PARTITION OF electricity_data
FOR VALUES FROM ('2027-01-01') TO ('2027-02-01');
CREATE TABLE electricity_data_2027_02 PARTITION OF electricity_data
FOR VALUES FROM ('2027-02-01') TO ('2027-03-01');
CREATE TABLE electricity_data_2027_03 PARTITION OF electricity_data
FOR VALUES FROM ('2027-03-01') TO ('2027-04-01');
-- 分区索引(在父表创建,自动继承到各分区)
CREATE INDEX idx_ed_customer ON electricity_data (customer_id);
+133 -5
View File
@@ -43,7 +43,7 @@ ON CONFLICT (id) DO UPDATE SET
DELETE FROM sys_role WHERE role_code IN ('WORKER', 'CUSTOMER');
-- 重置 sys_role 自增序列
SELECT setval('sys_role_id_seq', 2, true);
SELECT setval(pg_get_serial_sequence('sys_role', 'id'), GREATEST((SELECT MAX(id) FROM sys_role), 2), true);
-- ------------------------------------------------------------
-- 2. 初始菜单(顶级菜单,显式指定 id:1-6,系统管理/运营派单为目录类型 menu_type=1,其余为 menu_type=2)
@@ -185,7 +185,7 @@ ON CONFLICT (id) DO UPDATE SET
visible = EXCLUDED.visible;
-- 重置 sys_menu 自增序列
SELECT setval('sys_menu_id_seq', 50, true);
SELECT setval(pg_get_serial_sequence('sys_menu', 'id'), GREATEST((SELECT MAX(id) FROM sys_menu), 50), true);
-- ------------------------------------------------------------
-- 3. 初始字典(sys_dict 无业务唯一键,清空重建以支持幂等执行)
@@ -252,7 +252,7 @@ ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status;
-- 重置 sys_user 自增序列
SELECT setval('sys_user_id_seq', 1, true);
SELECT setval(pg_get_serial_sequence('sys_user', 'id'), GREATEST((SELECT MAX(id) FROM sys_user), 1), true);
-- ------------------------------------------------------------
-- 5. 用户-角色绑定:admin → ADMIN
@@ -328,7 +328,7 @@ INSERT INTO sys_role_menu (role_id, menu_id) VALUES
ON CONFLICT (role_id, menu_id) DO NOTHING;
-- 重置 sys_menu 自增序列(覆盖到 61)
SELECT setval('sys_menu_id_seq', 61, true);
SELECT setval(pg_get_serial_sequence('sys_menu', 'id'), GREATEST((SELECT MAX(id) FROM sys_menu), 61), true);
-- ------------------------------------------------------------
-- 8a. 设备监控下的智电改造线路台账菜单(id=61,挂设备监控 parent_id=4)
@@ -386,7 +386,7 @@ INSERT INTO sys_role_menu (role_id, menu_id) VALUES
ON CONFLICT (role_id, menu_id) DO NOTHING;
-- 重置 sys_menu 自增序列
SELECT setval('sys_menu_id_seq', 57, true);
SELECT setval(pg_get_serial_sequence('sys_menu', 'id'), GREATEST((SELECT MAX(id) FROM sys_menu), 57), true);
-- ------------------------------------------------------------
-- 10. 通知规则扫描任务种子(sys_job,按 job_key 幂等)
@@ -448,6 +448,134 @@ INSERT INTO sys_notice_rule (rule_key, rule_name, event_key, trigger_mode, enabl
'扫描派单后超过 advance_days 天仍未接单的工单,通知 ADMIN')
ON CONFLICT (rule_key) DO NOTHING;
-- ------------------------------------------------------------
-- 11a. 智电改造网关台账(ICC 消防物联设备同步)
-- 建表 electric_gateway(与迁移 20261024_icc_gateway.sql 一致)
-- 设备监控(id=4)下新增菜单 62「智电改造网关台账」(sort 3,排在智电改造线路台账之后)
-- ADMIN 绑定新菜单;注册 icc_gateway_sync 定时任务(默认每 10 分钟)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS electric_gateway (
id BIGSERIAL PRIMARY KEY,
icc_imei VARCHAR(64) UNIQUE,
device_code VARCHAR(64) UNIQUE,
device_name VARCHAR(128),
customer_id BIGINT NOT NULL REFERENCES customer(id),
equip_id BIGINT REFERENCES customer_equipment(id),
org_code VARCHAR(64),
org_name VARCHAR(128),
manufacturer_code VARCHAR(64),
manufacturer_name VARCHAR(128),
type_code VARCHAR(32),
type_name VARCHAR(64),
product_model VARCHAR(64),
is_online SMALLINT NOT NULL DEFAULT 1,
synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE electric_gateway IS '智电改造网关设备表(ICC 同步)';
COMMENT ON COLUMN electric_gateway.id IS '自增主键';
COMMENT ON COLUMN electric_gateway.icc_imei IS 'ICC 设备 IMEI(可空唯一,ICC 数据可能缺失)';
COMMENT ON COLUMN electric_gateway.device_code IS 'ICC 设备编码(可空唯一,ICC 数据可能缺失)';
COMMENT ON COLUMN electric_gateway.device_name IS '设备名称(ICC 同步)';
COMMENT ON COLUMN electric_gateway.customer_id IS '客户 ID(由 orgCode→customer.icc_code 解析,逻辑隔离键)';
COMMENT ON COLUMN electric_gateway.equip_id IS '客户设备 ID(→ customer_equipment,同步不填充)';
COMMENT ON COLUMN electric_gateway.org_code IS 'ICC 组织编码';
COMMENT ON COLUMN electric_gateway.org_name IS '组织名称(ICC 同步)';
COMMENT ON COLUMN electric_gateway.manufacturer_code IS '厂商编码';
COMMENT ON COLUMN electric_gateway.manufacturer_name IS '厂商名称';
COMMENT ON COLUMN electric_gateway.type_code IS '设备类型编码';
COMMENT ON COLUMN electric_gateway.type_name IS '设备类型名称';
COMMENT ON COLUMN electric_gateway.product_model IS '产品型号';
COMMENT ON COLUMN electric_gateway.is_online IS '在线状态:1在线 0离线';
COMMENT ON COLUMN electric_gateway.synced_at IS 'ICC 同步时间';
COMMENT ON COLUMN electric_gateway.created_at IS '创建时间';
CREATE INDEX IF NOT EXISTS idx_gateway_customer ON electric_gateway (customer_id);
CREATE INDEX IF NOT EXISTS idx_gateway_equip ON electric_gateway (equip_id);
CREATE INDEX IF NOT EXISTS idx_gateway_imei ON electric_gateway (icc_imei);
-- 智电改造线路表:gateway_id 外键补充(FK 由迁移/本段建立;db_init.sql 中为普通 BIGINT)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_line_gateway' AND conrelid = 'electric_line'::regclass) THEN
ALTER TABLE electric_line ADD CONSTRAINT fk_line_gateway FOREIGN KEY (gateway_id) REFERENCES electric_gateway(id);
END IF;
END $$;
-- 菜单 62:设备监控下的智电改造网关台账菜单
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
(62, 4, '智电改造网关台账', '/device/gateway', 'device/gateway/index', 'ApiOutlined', 3, 2, 'device:list', 1)
ON CONFLICT (id) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
menu_name = EXCLUDED.menu_name,
menu_path = EXCLUDED.menu_path,
component = EXCLUDED.component,
icon = EXCLUDED.icon,
sort_order = EXCLUDED.sort_order,
menu_type = EXCLUDED.menu_type,
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- 角色-菜单绑定:ADMIN(role_id=1)拥有 62
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
(1, 62)
ON CONFLICT (role_id, menu_id) DO NOTHING;
-- 重置 sys_menu 自增序列(覆盖到 62)
SELECT setval(pg_get_serial_sequence('sys_menu', 'id'), GREATEST((SELECT MAX(id) FROM sys_menu), 62), true);
-- 智电网关同步任务种子(sys_job,按 job_key 幂等;默认每 10 分钟)
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
('icc_gateway_sync', '智电网关同步', '*/10 * * * *', '{}'::jsonb, 1, 300, 1,
'从 ICC 平台拉取消防物联设备并按 icc_imei 幂等 upsert(默认每 10 分钟)')
ON CONFLICT (job_key) DO NOTHING;
-- 模拟量数据同步任务种子(sys_job,按 job_key 幂等;默认每 5 分钟)
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
('icc_analog_sync', '模拟量数据同步', '*/5 * * * *', '{}'::jsonb, 1, 300, 1,
'从 ICC 平台拉取探测器最新模拟量,以同步时间作采集时间写入 electricity_data(默认每 5 分钟)')
ON CONFLICT (job_key) DO UPDATE SET
cron_expression = EXCLUDED.cron_expression,
job_name = EXCLUDED.job_name,
remark = EXCLUDED.remark;
-- ------------------------------------------------------------
-- 12. ICC 事件订阅保活任务种子(sys_job,按 job_key 幂等;默认每 30 分钟)
-- ------------------------------------------------------------
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
('icc_event_alarm_keepalive', 'ICC事件订阅保活', '*/30 * * * *', '{}'::jsonb, 1, 300, 1,
'向 ICC 平台重新注册报警事件回调订阅(同名覆盖保活,默认每 30 分钟)')
ON CONFLICT (job_key) DO UPDATE SET
cron_expression = EXCLUDED.cron_expression,
job_name = EXCLUDED.job_name,
remark = EXCLUDED.remark;
-- ------------------------------------------------------------
-- 12a. 报警域菜单(设备监控下「设备报警监控」/ 运营派单下「报警派单台账」,id:63-64)
-- 63 挂设备监控 parent_id=4(sort 4,排在网关台账之后)
-- 64 挂运营派单 parent_id=3(sort 6,排在维保派单之后)
-- ------------------------------------------------------------
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
(63, 4, '设备报警监控', '/device/alarm', 'device/alarm/index', 'AlertOutlined', 4, 2, 'alarm:list', 1),
(64, 3, '报警派单台账', '/dispatch/alarm', 'dispatch/alarm/index', 'ScheduleOutlined', 6, 2, 'alarm-dispatch:list', 1)
ON CONFLICT (id) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
menu_name = EXCLUDED.menu_name,
menu_path = EXCLUDED.menu_path,
component = EXCLUDED.component,
icon = EXCLUDED.icon,
sort_order = EXCLUDED.sort_order,
menu_type = EXCLUDED.menu_type,
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- 角色-菜单绑定:ADMIN(role_id=1)拥有 63-64
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
(1, 63), (1, 64)
ON CONFLICT (role_id, menu_id) DO NOTHING;
-- 重置 sys_menu 自增序列(覆盖到 64)
SELECT setval(pg_get_serial_sequence('sys_menu', 'id'), GREATEST((SELECT MAX(id) FROM sys_menu), 64), true);
COMMIT;
-- ============================================================
@@ -0,0 +1,76 @@
-- ICC 模拟量数据同步:重建 electricity_data 表结构(按 ICC AnalogInfoVO)
-- 1) 旧表无数据(0 行),直接 DROP 重建;2) 删除 equip_id/electricity、icc_device_code 更名 imei;
-- 3) 新增 analog_type/analog_value;4) line_id 外键 → electric_line;5) 唯一键 (line_code,analog_type,collect_time);
-- 6) 按月分区 2026_10~2027_03;7) 索引/注释;8) sys_job 种子 icc_analog_sync(默认每 1 分钟)
BEGIN;
-- ------------------------------------------------------------
-- 1) 重建 electricity_data
-- ------------------------------------------------------------
DROP TABLE IF EXISTS electricity_data CASCADE;
CREATE TABLE electricity_data (
id BIGSERIAL,
line_id BIGINT NOT NULL REFERENCES electric_line(id),
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,
PRIMARY KEY (id, collect_time),
CONSTRAINT uk_ed_line_type_time UNIQUE (line_code, analog_type, collect_time)
) PARTITION BY RANGE (collect_time);
COMMENT ON TABLE electricity_data IS '线路用电信息表(ICC 模拟量同步,按月分区时序数据)';
COMMENT ON COLUMN electricity_data.id IS '自增(复合主键含 collect_time)';
COMMENT ON COLUMN electricity_data.line_id IS '线路 ID(→ electric_line)';
COMMENT ON COLUMN electricity_data.line_code IS '线路编码(ICC deviceCode,冗余 + 幂等键)';
COMMENT ON COLUMN electricity_data.imei IS '设备唯一标识码(ICC uniqueCode)';
COMMENT ON COLUMN electricity_data.line_name IS '线路名称(冗余,加速展示)';
COMMENT ON COLUMN electricity_data.customer_id IS '客户 ID(逻辑隔离键)';
COMMENT ON COLUMN electricity_data.analog_type IS '模拟量类型(ICC analogType)';
COMMENT ON COLUMN electricity_data.analog_name IS '模拟量名称(ICC analogName,如:剩余电流)';
COMMENT ON COLUMN electricity_data.analog_unit IS '模拟量单位(ICC analogUnit,如:mA)';
COMMENT ON COLUMN electricity_data.analog_value IS '模拟量值(ICC analogValue)';
COMMENT ON COLUMN electricity_data.collect_time IS '采集时间(同步时间,分区键)';
-- ------------------------------------------------------------
-- 2) 初始分区(2026 年 10 月 ~ 2027 年 3 月)
-- ------------------------------------------------------------
CREATE TABLE electricity_data_2026_10 PARTITION OF electricity_data
FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
CREATE TABLE electricity_data_2026_11 PARTITION OF electricity_data
FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');
CREATE TABLE electricity_data_2026_12 PARTITION OF electricity_data
FOR VALUES FROM ('2026-12-01') TO ('2027-01-01');
CREATE TABLE electricity_data_2027_01 PARTITION OF electricity_data
FOR VALUES FROM ('2027-01-01') TO ('2027-02-01');
CREATE TABLE electricity_data_2027_02 PARTITION OF electricity_data
FOR VALUES FROM ('2027-02-01') TO ('2027-03-01');
CREATE TABLE electricity_data_2027_03 PARTITION OF electricity_data
FOR VALUES FROM ('2027-03-01') TO ('2027-04-01');
-- ------------------------------------------------------------
-- 3) 分区索引(在父表创建,自动继承到各分区)
-- ------------------------------------------------------------
CREATE INDEX idx_ed_customer ON electricity_data (customer_id);
CREATE INDEX idx_ed_line ON electricity_data (line_id);
CREATE INDEX idx_ed_time ON electricity_data (collect_time);
CREATE INDEX idx_ed_customer_time ON electricity_data (customer_id, collect_time);
-- ------------------------------------------------------------
-- 4) sys_job 种子:icc_analog_sync(默认每 5 分钟,按 job_key 幂等更新 cron)
-- ------------------------------------------------------------
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
('icc_analog_sync', '模拟量数据同步', '*/5 * * * *', '{}'::jsonb, 1, 300, 1,
'从 ICC 平台拉取探测器最新模拟量,以同步时间作采集时间写入 electricity_data(默认每 5 分钟)')
ON CONFLICT (job_key) DO UPDATE SET
cron_expression = EXCLUDED.cron_expression,
job_name = EXCLUDED.job_name,
remark = EXCLUDED.remark;
COMMIT;
@@ -0,0 +1,52 @@
-- ICC 报警事件订阅:按 ICC 平台事件订阅推送的通用事件格式重建 device_alarm / alarm_dispatch
-- 两表原为旧设计(equip_id/building_id/floor_no/location/history_count/fault_type/alarm_date 等与 ICC 事件结构不符),现为空表,DROP 重建
DROP TABLE IF EXISTS alarm_dispatch CASCADE;
DROP TABLE IF EXISTS device_alarm CASCADE;
CREATE TABLE device_alarm (
id BIGSERIAL PRIMARY KEY,
alarm_code VARCHAR(128) NOT NULL UNIQUE, -- ICC 报警编码(幂等键)
uuid VARCHAR(128), -- ICC 事件 uuid
line_id BIGINT, -- 智电线路 ID(→ electric_line,nodeCode/deviceCode 匹配 line_code)
line_code VARCHAR(64), -- 线路编码(electric_line.line_code)
line_name VARCHAR(128), -- 线路名称
customer_id BIGINT, -- 客户 ID(由 line_id 联出)
node_code VARCHAR(64), -- 探测器节点编码(info.nodeCode)
device_code VARCHAR(64), -- 设备编码(info.deviceCode)
device_name VARCHAR(128), -- 设备名称(info.deviceName)
org_code VARCHAR(64), -- 组织编码(info.orgCode)
org_name VARCHAR(128), -- 组织名称(info.orgName)
alarm_type INT, -- 报警类型(info.alarmType)
alarm_grade SMALLINT, -- 报警等级 1/2/3(1 最严重)
alarm_stat SMALLINT, -- 报警状态(info.alarmStat)
is_event BOOLEAN DEFAULT FALSE, -- 是否事件(info.isEvent)
alarm_time TIMESTAMPTZ, -- 报警发生时间(info.alarmDate 秒级时间戳)
alarm_value NUMERIC(14,4), -- 报警数值(部分事件带值,可空)
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- 入库时间
);
COMMENT ON TABLE device_alarm IS '设备报警信息表(ICC 事件订阅)';
CREATE INDEX idx_alarm_time ON device_alarm (alarm_time DESC);
CREATE INDEX idx_alarm_line ON device_alarm (line_id);
CREATE INDEX idx_alarm_customer ON device_alarm (customer_id);
CREATE INDEX idx_alarm_grade ON device_alarm (alarm_grade);
CREATE TABLE alarm_dispatch (
id BIGSERIAL PRIMARY KEY,
dispatch_no VARCHAR(64) NOT NULL UNIQUE, -- 报警派单号(如 AD20261024001,生成规则由后端实现)
alarm_id BIGINT, -- 报警 ID(→ device_alarm)
line_id BIGINT, -- 线路 ID
line_code VARCHAR(64), -- 线路编码
line_name VARCHAR(128), -- 线路名称
customer_id BIGINT REFERENCES customer(id), -- 客户 ID
alarm_time TIMESTAMPTZ, -- 报警时间(快照)
alarm_grade SMALLINT, -- 报警等级(快照)
fault_desc TEXT, -- 故障事项(报警类型描述)
dispatcher VARCHAR(64), -- 派发人(当前登录用户 realName)
worker_id BIGINT, -- 共享电工 ID(→ worker)
status SMALLINT NOT NULL DEFAULT 0, -- 状态:0已派发 1已处理
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
COMMENT ON TABLE alarm_dispatch IS '报警派单信息表';
CREATE INDEX idx_alarm_disp_status ON alarm_dispatch (status);
CREATE INDEX idx_alarm_disp_worker ON alarm_dispatch (worker_id);
CREATE INDEX idx_alarm_disp_alarm ON alarm_dispatch (alarm_id);
@@ -0,0 +1,84 @@
-- 智电改造网关台账(ICC 消防物联设备同步):
-- 1) 幂等建表 electric_gateway(若 db_init.sql / db_seed.sql 已建则跳过)
-- 2) 设备监控(id=4)下新增菜单 62「智电改造网关台账」
-- 3) ADMIN 角色绑定新菜单
-- 4) 注册 icc_gateway_sync 定时任务(默认每 10 分钟)
BEGIN;
-- ------------------------------------------------------------
-- 1) 幂等建表(与 db_seed.sql 定义保持一致)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS electric_gateway (
id BIGSERIAL PRIMARY KEY,
icc_imei VARCHAR(64) UNIQUE,
device_code VARCHAR(64) UNIQUE,
device_name VARCHAR(128),
customer_id BIGINT NOT NULL REFERENCES customer(id),
equip_id BIGINT REFERENCES customer_equipment(id),
org_code VARCHAR(64),
org_name VARCHAR(128),
manufacturer_code VARCHAR(64),
manufacturer_name VARCHAR(128),
type_code VARCHAR(32),
type_name VARCHAR(64),
product_model VARCHAR(64),
is_online SMALLINT NOT NULL DEFAULT 1,
synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE electric_gateway IS '智电改造网关设备表(ICC 同步)';
COMMENT ON COLUMN electric_gateway.id IS '自增主键';
COMMENT ON COLUMN electric_gateway.icc_imei IS 'ICC 设备 IMEI(可空唯一,ICC 数据可能缺失)';
COMMENT ON COLUMN electric_gateway.device_code IS 'ICC 设备编码(可空唯一,ICC 数据可能缺失)';
COMMENT ON COLUMN electric_gateway.device_name IS '设备名称(ICC 同步)';
COMMENT ON COLUMN electric_gateway.customer_id IS '客户 ID(由 orgCode→customer.icc_code 解析,逻辑隔离键)';
COMMENT ON COLUMN electric_gateway.equip_id IS '客户设备 ID(→ customer_equipment,同步不填充)';
COMMENT ON COLUMN electric_gateway.org_code IS 'ICC 组织编码';
COMMENT ON COLUMN electric_gateway.org_name IS '组织名称(ICC 同步)';
COMMENT ON COLUMN electric_gateway.manufacturer_code IS '厂商编码';
COMMENT ON COLUMN electric_gateway.manufacturer_name IS '厂商名称';
COMMENT ON COLUMN electric_gateway.type_code IS '设备类型编码';
COMMENT ON COLUMN electric_gateway.type_name IS '设备类型名称';
COMMENT ON COLUMN electric_gateway.product_model IS '产品型号';
COMMENT ON COLUMN electric_gateway.is_online IS '在线状态:1在线 0离线';
COMMENT ON COLUMN electric_gateway.synced_at IS 'ICC 同步时间';
COMMENT ON COLUMN electric_gateway.created_at IS '创建时间';
CREATE INDEX IF NOT EXISTS idx_gateway_customer ON electric_gateway (customer_id);
CREATE INDEX IF NOT EXISTS idx_gateway_equip ON electric_gateway (equip_id);
CREATE INDEX IF NOT EXISTS idx_gateway_imei ON electric_gateway (icc_imei);
-- ------------------------------------------------------------
-- 2) 设备监控(id=4)下新增菜单 62「智电改造网关台账」(sort 3,排在智电改造线路台账之后)
-- ------------------------------------------------------------
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
(62, 4, '智电改造网关台账', '/device/gateway', 'device/gateway/index', 'ApiOutlined', 3, 2, 'device:list', 1)
ON CONFLICT (id) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
menu_name = EXCLUDED.menu_name,
menu_path = EXCLUDED.menu_path,
component = EXCLUDED.component,
icon = EXCLUDED.icon,
sort_order = EXCLUDED.sort_order,
menu_type = EXCLUDED.menu_type,
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- ------------------------------------------------------------
-- 3) 角色-菜单绑定:ADMIN(role_id=1) 拥有 62
-- ------------------------------------------------------------
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
(1, 62)
ON CONFLICT (role_id, menu_id) DO NOTHING;
-- ------------------------------------------------------------
-- 4) 定时任务种子:icc_gateway_sync(默认每 10 分钟,可按 job_key 幂等)
-- ------------------------------------------------------------
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
('icc_gateway_sync', '智电网关同步', '*/10 * * * *', '{}'::jsonb, 1, 300, 1,
'从 ICC 平台拉取消防物联设备并按 icc_imei 幂等 upsert(默认每 10 分钟)')
ON CONFLICT (job_key) DO NOTHING;
-- 重置序列(覆盖到 62)
SELECT setval('sys_menu_id_seq', GREATEST((SELECT MAX(id) FROM sys_menu), 62), true);
COMMIT;
@@ -0,0 +1,48 @@
-- 智电改造线路台账真实 ICC 同步:重建 electric_line 表结构(按 ICC DetectorVO)
-- 1) 增列/删列/改可空(幂等);2) gateway_id 外键 → electric_gateway;3) 索引与注释同步
BEGIN;
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS imei VARCHAR(64);
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS gateway_id BIGINT;
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS type_code VARCHAR(32);
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS type_name VARCHAR(64);
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS fire_system_code VARCHAR(64);
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS fire_system_name VARCHAR(128);
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS part_unit_loop_code VARCHAR(64);
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS channel_no VARCHAR(64);
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS channel_sn VARCHAR(64);
ALTER TABLE electric_line ADD COLUMN IF NOT EXISTS produce_date VARCHAR(16);
ALTER TABLE electric_line DROP COLUMN IF EXISTS icc_device_code;
ALTER TABLE electric_line DROP COLUMN IF EXISTS building_id;
ALTER TABLE electric_line DROP COLUMN IF EXISTS floor_no;
ALTER TABLE electric_line DROP COLUMN IF EXISTS location;
ALTER TABLE electric_line ALTER COLUMN equip_id DROP NOT NULL;
ALTER TABLE electric_line ADD CONSTRAINT fk_line_gateway FOREIGN KEY (gateway_id) REFERENCES electric_gateway(id);
DROP INDEX IF EXISTS idx_line_icc;
CREATE INDEX IF NOT EXISTS idx_line_gateway ON electric_line (gateway_id);
CREATE INDEX IF NOT EXISTS idx_line_imei ON electric_line (imei);
COMMENT ON TABLE electric_line IS '智电改造线路信息表(ICC 探测器同步)';
COMMENT ON COLUMN electric_line.line_code IS '探测器编码(ICC detectorCode,唯一幂等键)';
COMMENT ON COLUMN electric_line.line_name IS '探测器名称(ICC detectorName)';
COMMENT ON COLUMN electric_line.imei IS '设备唯一标识码(ICC imei)';
COMMENT ON COLUMN electric_line.equip_id IS '客户设备 ID(→ customer_equipment,可空,同步不填充)';
COMMENT ON COLUMN electric_line.gateway_id IS '网关设备 ID(→ electric_gateway,由 parentCode↔device_code 匹配)';
COMMENT ON COLUMN electric_line.customer_id IS '客户 ID(orgCode→customer.icc_code 解析,逻辑隔离键)';
COMMENT ON COLUMN electric_line.type_code IS '探测器类型编码';
COMMENT ON COLUMN electric_line.type_name IS '探测器类型名称';
COMMENT ON COLUMN electric_line.fire_system_code IS '消防系统编码';
COMMENT ON COLUMN electric_line.fire_system_name IS '消防系统名称';
COMMENT ON COLUMN electric_line.part_unit_loop_code IS '探测器回路号';
COMMENT ON COLUMN electric_line.channel_no IS '探测器通道号';
COMMENT ON COLUMN electric_line.channel_sn IS '通道唯一标识码';
COMMENT ON COLUMN electric_line.produce_date IS '生产日期(yyyy-MM-dd)';
COMMENT ON COLUMN electric_line.status IS '探测器状态:1在线 0离线(detectorStatus 01→1 / 02→0)';
COMMENT ON COLUMN electric_line.synced_at IS 'ICC 同步时间';
COMMENT ON COLUMN electric_line.created_at IS '创建时间';
COMMIT;
+1 -1
View File
@@ -38,7 +38,7 @@
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.5",
"sequelize-typescript": "^2.1.6",
"undici": "^8.12.2",
"undici": "^8.12.3",
"uuid": "^10.0.0"
},
"devDependencies": {
+8
View File
@@ -118,6 +118,14 @@ export default {
keyPrefix: "customer-visit"
},
// ICC 事件订阅配置(回调地址需 ICC 服务器可访问;订阅保活每 30 分钟向 ICC 重新注册)
iccEvent: {
monitorUrl: "http://222.243.55.42:7001/api/icc-event/callback", // 回调地址(ICC 服务器需可访问)
callbackToken: "xtzd_icc_event_2026", // 回调 URL 固定 token
subscriberName: "222.243.55.42_7001_alarm", // 订阅者唯一名
subscriberMagic: "222.243.55.42_7001" // IP_端口
},
cors: {
origin: "*",
allowMethods: "GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS",
@@ -0,0 +1,72 @@
import { Controller, Get, Inject, Query } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import AlarmDispatch from "../entity/alarm_dispatch.entity";
import Worker from "../entity/worker.entity";
import Customer from "../entity/customer.entity";
@Controller("/api/alarm-dispatches")
export class AlarmDispatchController {
@Inject() ctx!: Context;
@InjectRepository(AlarmDispatch)
dispatchRepo!: Repository<AlarmDispatch>;
@InjectRepository(Worker)
workerRepo!: Repository<Worker>;
@InjectRepository(Customer)
customerRepo!: Repository<Customer>;
/** 报警派单台账分页列表(联出电工姓名/客户名称) */
@Get("/") async list(@Query() query: any) {
const page = Math.max(Number(query.page) || 1, 1);
const rawPageSize = Number(query.pageSize) || 10;
const pageSize = Math.min(Math.max(rawPageSize, 1), 200);
const where: any = {};
if (query.status !== undefined && query.status !== "") where.status = Number(query.status);
if (query.keyword) {
const kw = String(query.keyword).trim();
const ors: any[] = [
{ dispatchNo: { [Op.like]: `%${kw}%` } },
{ lineName: { [Op.like]: `%${kw}%` } },
{ lineCode: { [Op.like]: `%${kw}%` } }
];
// 关键字命中电工姓名时联出匹配的电工 ID 参与过滤
const workers = await this.workerRepo.findAll({ where: { workerName: { [Op.like]: `%${kw}%` } }, attributes: ["id"] });
if (workers.length) ors.push({ workerId: { [Op.in]: workers.map((w) => w.id) } });
where[Op.or] = ors;
}
const { count, rows } = await this.dispatchRepo.findAndCountAll({
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["createdAt", "DESC"]]
});
// 联出电工姓名与客户名称(二次查询组装)
const workerIds = [...new Set(rows.map((r) => Number(r.workerId)).filter(Boolean))];
const customerIds = [...new Set(rows.map((r) => Number(r.customerId)).filter(Boolean))];
const [workers, customers] = await Promise.all([
workerIds.length ? this.workerRepo.findAll({ where: { id: { [Op.in]: workerIds } }, attributes: ["id", "workerName"] }) : Promise.resolve<Worker[]>([]),
customerIds.length ? this.customerRepo.findAll({ where: { id: { [Op.in]: customerIds } }, attributes: ["id", "customerName"] }) : Promise.resolve<Customer[]>([])
]);
const workerMap = new Map(workers.map((w) => [Number(w.id), w.workerName]));
const customerMap = new Map(customers.map((c) => [Number(c.id), c.customerName]));
const list = rows.map((r) => ({
...r.toJSON(),
workerName: workerMap.get(Number(r.workerId)) || "",
customerName: customerMap.get(Number(r.customerId)) || ""
}));
return {
code: 200,
message: "获取报警派单台账成功",
data: { list, total: count, page, pageSize }
};
}
}
@@ -0,0 +1,216 @@
import { Controller, Get, Post, Inject, Query, Body, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op, UniqueConstraintError } from "sequelize";
import DeviceAlarm from "../entity/device_alarm.entity";
import AlarmDispatch from "../entity/alarm_dispatch.entity";
import Worker from "../entity/worker.entity";
import Customer from "../entity/customer.entity";
import SysUser from "../entity/sysUser.entity";
import { NoticeService } from "../service/notice.service";
@Controller("/api/device-alarms")
export class DeviceAlarmController {
@Inject() ctx!: Context;
@Inject() noticeService!: NoticeService;
@InjectRepository(DeviceAlarm)
alarmRepo!: Repository<DeviceAlarm>;
@InjectRepository(AlarmDispatch)
dispatchRepo!: Repository<AlarmDispatch>;
@InjectRepository(Worker)
workerRepo!: Repository<Worker>;
@InjectRepository(Customer)
customerRepo!: Repository<Customer>;
@InjectRepository(SysUser)
userRepo!: Repository<SysUser>;
/** 报警分页列表(仅 line_id 非空的记录)+ 统计 */
@Get("/") async list(@Query() query: any) {
const page = Math.max(Number(query.page) || 1, 1);
const rawPageSize = Number(query.pageSize) || 10;
const pageSize = Math.min(Math.max(rawPageSize, 1), 200);
const where: any = { lineId: { [Op.ne]: null } };
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
if (query.alarmGrade !== undefined && query.alarmGrade !== "") where.alarmGrade = Number(query.alarmGrade);
if (query.keyword) {
where[Op.or] = [
{ lineName: { [Op.like]: `%${query.keyword}%` } },
{ lineCode: { [Op.like]: `%${query.keyword}%` } },
{ deviceName: { [Op.like]: `%${query.keyword}%` } }
];
}
const { count, rows } = await this.alarmRepo.findAndCountAll({
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["alarmTime", "DESC"]]
});
// 统计:今日报警数(alarm_time >= 当天0点)/ 严重报警数(grade=1)/ 未派单数(无 status=0 派单)
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({
where: {
lineId: { [Op.ne]: null },
id: dispatchedIds.length ? { [Op.notIn]: dispatchedIds } : { [Op.ne]: null }
}
});
return {
code: 200,
message: "获取报警列表成功",
data: {
list: rows.map((r) => r.toJSON()),
total: count,
page,
pageSize,
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<number[]> {
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);
if (!alarmId || Number.isNaN(alarmId)) return { code: 400, message: "缺少有效的报警 ID", data: null };
const alarm = await this.alarmRepo.findByPk(alarmId);
if (!alarm) return { code: 404, message: "报警记录不存在", data: null };
if (!alarm.lineId) return { code: 400, message: "该报警未关联线路,无法派单", data: null };
const existing = await this.dispatchRepo.findOne({ where: { alarmId, status: 0 } });
if (existing) return { code: 400, message: "该报警已有未完成的派单", data: null };
const workerId = Number(body.workerId);
if (!workerId || Number.isNaN(workerId)) return { code: 400, message: "缺少有效的 workerId 参数", data: null };
const worker = await this.workerRepo.findByPk(workerId);
if (!worker) return { code: 400, message: "共享电工不存在", data: null };
if (Number(worker.status) !== 1) return { code: 400, message: "该电工不在岗,无法派单", data: null };
if (Number(worker.auditStatus) !== 1) return { code: 400, message: "该电工审核未通过,无法派单", data: null };
// 派发人:当前登录用户 realName(缺失时回退 username)
const loginUserId = (this.ctx.state.user as any)?.userId;
const loginUser = loginUserId ? await this.userRepo.findByPk(loginUserId, { attributes: ["realName", "username"] }) : null;
const dispatcher = loginUser?.realName || (loginUser as any)?.username || "未知";
// 客户名称(通知文案用,查不到时回退客户 ID)
let customerName = `#${alarm.customerId}`;
if (alarm.customerId) {
const customer = await this.customerRepo.findByPk(alarm.customerId, { attributes: ["customerName"] });
if (customer) customerName = customer.customerName;
}
const payload: any = {
alarmId: alarm.id,
lineId: alarm.lineId,
lineCode: alarm.lineCode,
lineName: alarm.lineName,
customerId: alarm.customerId,
alarmTime: alarm.alarmTime,
alarmGrade: alarm.alarmGrade,
faultDesc: body.faultDesc || "",
dispatcher,
workerId: worker.id,
status: 0
};
// 派单号生成 + 写入(唯一冲突时重试一次)
let dispatch: AlarmDispatch;
try {
payload.dispatchNo = await this.generateDispatchNo();
dispatch = await this.dispatchRepo.create(payload);
} catch (err: any) {
if (err instanceof UniqueConstraintError) {
payload.dispatchNo = await this.generateDispatchNo();
dispatch = await this.dispatchRepo.create(payload);
} else {
throw err;
}
}
// 派单站内通知(通知失败不得阻断派单)
try {
await this.noticeService.deliverToReceivers(
{
noticeType: "alarm_dispatch",
title: `新报警派单:${alarm.lineName}`,
content: `线路:${alarm.lineName}\n客户:${customerName}\n报警时间:${this.formatTime(alarm.alarmTime)}\n报警等级:${alarm.alarmGrade}级`,
bizType: "alarm_dispatch",
bizId: Number(dispatch.id)
},
{ workers: { mode: "ids", ids: [worker.id] } }
);
} catch (err) {
console.error("[notice] 报警派单通知失败:", err);
}
return { code: 200, message: "报警派单成功", data: dispatch.toJSON() };
}
// 派单号:AD + 东八区 yyyyMMdd + 3 位当日序号(当天已创建数量 + 1,冲突则递增重试)
private async generateDispatchNo(): Promise<string> {
const d = new Date(new Date().getTime() + 8 * 60 * 60 * 1000);
const yyyymmdd = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, "0")}${String(d.getUTCDate()).padStart(2, "0")}`;
const prefix = `AD${yyyymmdd}`;
const dayStart = new Date(`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}T00:00:00+08:00`);
const count = await this.dispatchRepo.count({ where: { createdAt: { [Op.gte]: dayStart } } });
for (let i = 1; i <= 100; i++) {
const no = `${prefix}${String(count + i).padStart(3, "0")}`;
const exists = await this.dispatchRepo.findOne({ where: { dispatchNo: no }, attributes: ["id"] });
if (!exists) return no;
}
throw new Error("派单号生成冲突,请重试");
}
private formatTime(t?: Date): string {
if (!t) return "-";
return new Date(t).toLocaleString("zh-CN", { hour12: false });
}
}
@@ -0,0 +1,18 @@
import { Controller, Get, Post, Inject, Query, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { ElectricGatewayService } from "../service/electric_gateway.service";
import { IccGatewaySyncService } from "../service/icc_gateway_sync.service";
@Controller("/api/electric-gateways")
export class ElectricGatewayController {
@Inject() ctx!: Context;
@Inject() electricGatewayService!: ElectricGatewayService;
@Inject() iccGatewaySyncService!: IccGatewaySyncService;
@Get("/") async list(@Query() query: any) { return this.electricGatewayService.list(query, this.ctx); }
@Get("/:id") async detail(@Param("id") id: string) { return this.electricGatewayService.detail(Number(id), this.ctx); }
@Post("/sync") async sync() {
const result = await this.iccGatewaySyncService.syncFromIcc(this.ctx);
return { code: 200, message: "网关设备同步完成", data: result };
}
}
@@ -0,0 +1,54 @@
import { Controller, Post, Get, Query, Inject } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { IccAnalogSyncService } from "../service/icc_analog_sync.service";
import ElectricityData from "../entity/electricity_data.entity";
@Controller("/api/electricity-data")
export class ElectricityDataController {
@Inject() ctx!: Context;
@Inject() iccAnalogSyncService!: IccAnalogSyncService;
@InjectRepository(ElectricityData)
electricityDataRepo!: Repository<ElectricityData>;
@Post("/sync") async sync() {
const result = await this.iccAnalogSyncService.syncFromIcc(this.ctx);
return { code: 200, message: "模拟量数据同步完成", data: result };
}
/** 查询某线路最近一次同步的模拟量快照(取最大 collect_time 的全部模拟量行) */
@Get("/latest") async latest(@Query("lineId") lineId?: string) {
const id = Number(lineId);
if (!id || Number.isNaN(id)) {
return { code: 400, message: "缺少有效的 lineId 参数" };
}
const latest = await this.electricityDataRepo.findOne({
where: { lineId: id },
order: [["collectTime", "DESC"]],
attributes: ["lineCode", "lineName", "imei", "customerId", "collectTime"],
});
if (!latest) {
return { code: 200, message: "该线路暂无模拟量数据", data: { lineId: id, hasData: false, collectTime: null, list: [] } };
}
const rows = await this.electricityDataRepo.findAll({
where: { lineId: id, collectTime: latest.getDataValue("collectTime") as Date },
order: [["analogType", "ASC"]],
attributes: ["analogType", "analogName", "analogUnit", "analogValue"],
});
return {
code: 200,
message: "ok",
data: {
lineId: id,
lineCode: latest.getDataValue("lineCode"),
lineName: latest.getDataValue("lineName"),
imei: latest.getDataValue("imei"),
customerId: latest.getDataValue("customerId"),
collectTime: latest.getDataValue("collectTime"),
hasData: true,
list: rows,
},
};
}
}
@@ -0,0 +1,54 @@
import { Controller, Post, Get, Inject, Body, Query, Config } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { IccEventSubscribeService } from "../service/icc_event_subscribe.service";
/**
* ICC 事件订阅相关接口:
* - POST /api/icc-event/callback ICC 报警事件回调(不走登录鉴权/RBAC,靠 URL token 校验)
* - POST /api/icc-event/subscribe 手动触发订阅注册/保活
* - GET /api/icc-event/subscribe-status 查询订阅配置与最近一次注册结果
*/
@Controller("/api/icc-event")
export class IccEventController {
@Inject()
ctx!: Context;
@Inject()
iccEventSubscribeService!: IccEventSubscribeService;
@Config("iccEvent")
iccEventConfig!: { monitorUrl: string; callbackToken: string; subscriberName: string; subscriberMagic: string };
/** ICC 回调入口:仅校验 URL token,校验通过后解析入库报警事件 */
@Post("/callback")
async callback(@Query() query: any, @Body() body: any) {
if (query.token !== this.iccEventConfig.callbackToken) {
this.ctx.status = 401;
return { code: 401, message: "invalid token" };
}
await this.iccEventSubscribeService.handleCallback(body);
// ICC 文档要求的返回体
return { success: true, code: "0", errMsg: "" };
}
/** 手动触发订阅注册(保活:ICC 同名覆盖) */
@Post("/subscribe")
async subscribe() {
const result = await this.iccEventSubscribeService.ensureSubscribe();
return {
code: result.success ? 200 : 500,
message: result.success ? "ICC 事件订阅成功" : `ICC 事件订阅失败:${result.errMsg ?? ""}`,
data: result
};
}
/** 查询订阅配置与最近一次注册结果 */
@Get("/subscribe-status")
async status() {
return {
code: 200,
message: "获取 ICC 事件订阅状态成功",
data: await this.iccEventSubscribeService.getSubscribeStatus()
};
}
}
@@ -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) {
+10
View File
@@ -0,0 +1,10 @@
import { IsOptional, IsString, IsInt, MaxLength, Min, Max } from "class-validator";
export class ElectricGatewayQueryDTO {
@IsOptional() @IsInt() @Min(1) page?: number = 1;
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
@IsOptional() @IsInt() customerId?: number;
@IsOptional() @IsInt() equipId?: number;
@IsOptional() @IsInt() @Min(0) @Max(1) isOnline?: number;
}
@@ -0,0 +1,62 @@
import { Column, Model, Table, DataType, BelongsTo } from "sequelize-typescript";
import DeviceAlarm from "./device_alarm.entity";
@Table({
tableName: "alarm_dispatch",
comment: "报警派单信息表",
timestamps: false,
indexes: [
{ name: "idx_alarm_disp_status", fields: ["status"] },
{ name: "idx_alarm_disp_worker", fields: ["worker_id"] },
{ name: "idx_alarm_disp_alarm", fields: ["alarm_id"] }
]
})
export default class AlarmDispatch extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "dispatch_no", comment: "报警派单号(唯一)" })
dispatchNo!: string;
@Column({ type: DataType.BIGINT, field: "alarm_id", comment: "报警 ID(→ device_alarm)" })
alarmId!: number;
@Column({ type: DataType.BIGINT, field: "line_id", comment: "线路 ID" })
lineId!: number;
@Column({ type: DataType.STRING(64), field: "line_code", comment: "线路编码" })
lineCode!: string;
@Column({ type: DataType.STRING(128), field: "line_name", comment: "线路名称" })
lineName!: string;
@Column({ type: DataType.BIGINT, field: "customer_id", comment: "客户 ID(→ customer)" })
customerId!: number;
@Column({ type: DataType.DATE, field: "alarm_time", comment: "报警时间(快照)" })
alarmTime!: Date;
@Column({ type: DataType.SMALLINT, field: "alarm_grade", comment: "报警等级(快照)" })
alarmGrade!: number;
@Column({ type: DataType.TEXT, field: "fault_desc", comment: "故障事项(报警类型描述)" })
faultDesc!: string;
@Column({ type: DataType.STRING(64), comment: "派发人(当前登录用户 realName)" })
dispatcher!: string;
@Column({ type: DataType.BIGINT, field: "worker_id", comment: "共享电工 ID(→ worker)" })
workerId!: number;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, comment: "状态:0已派发 1已处理" })
status!: number;
@Column({ type: DataType.DATE, field: "created_at", defaultValue: DataType.NOW, comment: "创建时间" })
createdAt!: Date;
@Column({ type: DataType.DATE, field: "completed_at", comment: "完成时间" })
completedAt!: Date;
@BelongsTo(() => DeviceAlarm, { foreignKey: "alarmId", as: "alarm" })
alarm!: DeviceAlarm;
}
+71
View File
@@ -0,0 +1,71 @@
import { Column, Model, Table, DataType } from "sequelize-typescript";
@Table({
tableName: "device_alarm",
comment: "设备报警信息表(ICC 事件订阅)",
timestamps: false,
indexes: [
{ name: "idx_alarm_time", fields: [{ name: "alarm_time", order: "DESC" }] },
{ name: "idx_alarm_line", fields: ["line_id"] },
{ name: "idx_alarm_customer", fields: ["customer_id"] },
{ name: "idx_alarm_grade", fields: ["alarm_grade"] }
]
})
export default class DeviceAlarm extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(128), allowNull: false, unique: true, field: "alarm_code", comment: "ICC 报警编码(幂等键)" })
alarmCode!: string;
@Column({ type: DataType.STRING(128), comment: "ICC 事件 uuid" })
uuid!: string;
@Column({ type: DataType.BIGINT, field: "line_id", comment: "智电线路 ID(→ electric_line,nodeCode/deviceCode 匹配 line_code)" })
lineId!: number;
@Column({ type: DataType.STRING(64), field: "line_code", comment: "线路编码(electric_line.line_code)" })
lineCode!: string;
@Column({ type: DataType.STRING(128), field: "line_name", comment: "线路名称" })
lineName!: string;
@Column({ type: DataType.BIGINT, field: "customer_id", comment: "客户 ID(由 line_id 联出)" })
customerId!: number;
@Column({ type: DataType.STRING(64), field: "node_code", comment: "探测器节点编码(info.nodeCode)" })
nodeCode!: string;
@Column({ type: DataType.STRING(64), field: "device_code", comment: "设备编码(info.deviceCode)" })
deviceCode!: string;
@Column({ type: DataType.STRING(128), field: "device_name", comment: "设备名称(info.deviceName)" })
deviceName!: string;
@Column({ type: DataType.STRING(64), field: "org_code", comment: "组织编码(info.orgCode)" })
orgCode!: string;
@Column({ type: DataType.STRING(128), field: "org_name", comment: "组织名称(info.orgName)" })
orgName!: string;
@Column({ type: DataType.INTEGER, field: "alarm_type", comment: "报警类型(info.alarmType)" })
alarmType!: number;
@Column({ type: DataType.SMALLINT, field: "alarm_grade", comment: "报警等级 1/2/3(1 最严重)" })
alarmGrade!: number;
@Column({ type: DataType.SMALLINT, field: "alarm_stat", comment: "报警状态(info.alarmStat)" })
alarmStat!: number;
@Column({ type: DataType.BOOLEAN, field: "is_event", comment: "是否事件(info.isEvent)" })
isEvent!: boolean;
@Column({ type: DataType.DATE, field: "alarm_time", comment: "报警发生时间(info.alarmDate 秒级时间戳)" })
alarmTime!: Date;
@Column({ type: DataType.DECIMAL(14, 4), field: "alarm_value", comment: "报警数值(部分事件带值,可空)" })
alarmValue!: number;
@Column({ type: DataType.DATE, allowNull: false, field: "synced_at", comment: "入库时间" })
syncedAt!: Date;
}
@@ -0,0 +1,71 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import Customer from "./customer.entity";
import CustomerEquipment from "./customer_equipment.entity";
@Table({
tableName: "electric_gateway",
comment: "智电改造网关设备表(ICC 同步)",
timestamps: false,
indexes: [
{ name: "idx_gateway_customer", fields: ["customer_id"] },
{ name: "idx_gateway_equip", fields: ["equip_id"] },
{ name: "idx_gateway_imei", fields: ["icc_imei"] }
]
})
export default class ElectricGateway extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), unique: true, field: "icc_imei", comment: "设备唯一标识码(imei)" })
iccImei!: string;
@Column({ type: DataType.STRING(64), unique: true, field: "device_code", comment: "设备编码" })
deviceCode!: string;
@Column({ type: DataType.STRING(128), field: "device_name", comment: "设备名称" })
deviceName!: string;
@ForeignKey(() => Customer)
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户 ID(由 orgCode→customer.icc_code 解析)" })
customerId!: number;
@ForeignKey(() => CustomerEquipment)
@Column({ type: DataType.BIGINT, field: "equip_id", comment: "客户设备 ID(→ customer_equipment,可手动关联)" })
equipId!: number;
@Column({ type: DataType.STRING(64), field: "org_code", comment: "ICC 组织编码" })
orgCode!: string;
@Column({ type: DataType.STRING(128), field: "org_name", comment: "ICC 组织名称" })
orgName!: string;
@Column({ type: DataType.STRING(64), field: "manufacturer_code", comment: "厂商编码" })
manufacturerCode!: string;
@Column({ type: DataType.STRING(128), field: "manufacturer_name", comment: "厂商名称" })
manufacturerName!: string;
@Column({ type: DataType.STRING(32), field: "type_code", comment: "设备类型编码" })
typeCode!: string;
@Column({ type: DataType.STRING(64), field: "type_name", comment: "设备类型名称" })
typeName!: string;
@Column({ type: DataType.STRING(64), field: "product_model", comment: "产品型号" })
productModel!: string;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "is_online", comment: "在线状态:1在线 0离线" })
isOnline!: number;
@Column({ type: DataType.DATE, field: "synced_at", comment: "ICC 同步时间" })
syncedAt!: Date;
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer;
@BelongsTo(() => CustomerEquipment, { foreignKey: "equipId", as: "equipment" })
equipment!: CustomerEquipment;
@CreatedAt
created_at!: Date;
}
+43 -24
View File
@@ -1,7 +1,7 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import CustomerEquipment from "./customer_equipment.entity";
import ElectricGateway from "./electric_gateway.entity";
import Customer from "./customer.entity";
import CustomerBuilding from "./customer_building.entity";
@Table({
tableName: "electric_line",
@@ -10,41 +10,60 @@ import CustomerBuilding from "./customer_building.entity";
indexes: [
{ name: "idx_line_customer", fields: ["customer_id"] },
{ name: "idx_line_equip", fields: ["equip_id"] },
{ name: "idx_line_icc", fields: ["icc_device_code"] }
{ name: "idx_line_gateway", fields: ["gateway_id"] },
{ name: "idx_line_imei", fields: ["imei"] }
]
})
export default class ElectricLine extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "line_code", comment: "线路编码(唯一)" })
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "line_code", comment: "探测器编码(detectorCode)唯一幂等键" })
lineCode!: string;
@ForeignKey(() => CustomerEquipment)
@Column({ type: DataType.BIGINT, allowNull: false, field: "equip_id", comment: "客户设备 ID(→ customer_equipment)" })
equipId!: number;
@Column({ type: DataType.STRING(128), field: "icc_device_code", comment: "ICC 智电设备编号" })
iccDeviceCode!: string;
@Column({ type: DataType.STRING(128), field: "line_name", comment: "线路名称(ICC 同步)" })
@Column({ type: DataType.STRING(128), field: "line_name", comment: "探测器名称(detectorName)" })
lineName!: string;
@Column({ type: DataType.STRING(64), field: "imei", comment: "探测器 IMEI" })
imei!: string;
@ForeignKey(() => CustomerEquipment)
@Column({ type: DataType.BIGINT, field: "equip_id", comment: "客户设备 ID(→ customer_equipment,同步不填充)" })
equipId!: number;
@ForeignKey(() => ElectricGateway)
@Column({ type: DataType.BIGINT, field: "gateway_id", comment: "网关 ID(→ electric_gateway,由 parentCode↔device_code 匹配)" })
gatewayId!: number;
@ForeignKey(() => Customer)
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户 ID(逻辑隔离键)" })
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户 ID(由 orgCode→customer.icc_code 解析)" })
customerId!: number;
@ForeignKey(() => CustomerBuilding)
@Column({ type: DataType.BIGINT, field: "building_id", comment: "所属建筑 ID(→ customer_building)" })
buildingId!: number;
@Column({ type: DataType.STRING(32), field: "type_code", comment: "探测器类型编码(typeCode)" })
typeCode!: string;
@Column({ type: DataType.STRING(16), field: "floor_no", comment: "所在楼层" })
floorNo!: string;
@Column({ type: DataType.STRING(64), field: "type_name", comment: "探测器类型名称(typeName)" })
typeName!: string;
@Column({ type: DataType.STRING(256), comment: "所在位置" })
location!: string;
@Column({ type: DataType.STRING(64), field: "fire_system_code", comment: "消防系统编码(fireSystemCode)" })
fireSystemCode!: string;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1在线 0离线" })
@Column({ type: DataType.STRING(128), field: "fire_system_name", comment: "消防系统名称(fireSystemName)" })
fireSystemName!: string;
@Column({ type: DataType.STRING(64), field: "part_unit_loop_code", comment: "部件/单元/回路编码(partUnitLoopCode)" })
partUnitLoopCode!: string;
@Column({ type: DataType.STRING(64), field: "channel_no", comment: "通道号(channelNo)" })
channelNo!: string;
@Column({ type: DataType.STRING(64), field: "channel_sn", comment: "通道序列号(channelSn)" })
channelSn!: string;
@Column({ type: DataType.STRING(16), field: "produce_date", comment: "生产日期(produceDate)" })
produceDate!: string;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "status", comment: "上报状态:1在线 0离线(detectorStatus 归一)" })
status!: number;
@Column({ type: DataType.DATE, field: "synced_at", comment: "ICC 同步时间" })
@@ -53,12 +72,12 @@ export default class ElectricLine extends Model {
@BelongsTo(() => CustomerEquipment, { foreignKey: "equipId", as: "equipment" })
equipment!: CustomerEquipment;
@BelongsTo(() => ElectricGateway, { foreignKey: "gatewayId", as: "gateway" })
gateway!: ElectricGateway;
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer;
@BelongsTo(() => CustomerBuilding, { foreignKey: "buildingId", as: "building" })
building!: CustomerBuilding;
@CreatedAt
created_at!: Date;
}
}
@@ -0,0 +1,56 @@
import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from "sequelize-typescript";
import ElectricLine from "./electric_line.entity";
import Customer from "./customer.entity";
@Table({
tableName: "electricity_data",
comment: "线路用电信息表(ICC 模拟量同步,按月分区时序数据)",
timestamps: false,
indexes: [
{ name: "idx_ed_customer", fields: ["customer_id"] },
{ name: "idx_ed_line", fields: ["line_id"] },
{ name: "idx_ed_time", fields: ["collect_time"] }
]
})
export default class ElectricityData extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@ForeignKey(() => ElectricLine)
@Column({ type: DataType.BIGINT, allowNull: false, field: "line_id", comment: "线路 ID(→ electric_line)" })
lineId!: number;
@Column({ type: DataType.STRING(64), allowNull: false, field: "line_code", comment: "线路编码(ICC deviceCode,冗余 + 幂等键)" })
lineCode!: string;
@Column({ type: DataType.STRING(64), field: "imei", comment: "设备唯一标识码(ICC uniqueCode)" })
imei!: string;
@Column({ type: DataType.STRING(128), field: "line_name", comment: "线路名称(冗余,加速展示)" })
lineName!: string;
@ForeignKey(() => Customer)
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户 ID(逻辑隔离键)" })
customerId!: number;
@Column({ type: DataType.STRING(64), field: "analog_type", comment: "模拟量类型(ICC analogType)" })
analogType!: string;
@Column({ type: DataType.STRING(128), field: "analog_name", comment: "模拟量名称(ICC analogName,如:剩余电流)" })
analogName!: string;
@Column({ type: DataType.STRING(32), field: "analog_unit", comment: "模拟量单位(ICC analogUnit,如:mA)" })
analogUnit!: string;
@Column({ type: DataType.DECIMAL(14, 4), field: "analog_value", comment: "模拟量值(ICC analogValue)" })
analogValue!: number;
@Column({ type: DataType.DATE, allowNull: false, field: "collect_time", comment: "采集时间(同步时间,分区键)" })
collectTime!: Date;
@BelongsTo(() => ElectricLine, { foreignKey: "lineId", as: "line" })
line!: ElectricLine;
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer;
}
+2 -2
View File
@@ -5,8 +5,8 @@ import { TokenUtil } from '../util/token';
@Provide()
export class AuthMiddleware implements IMiddleware<Context, NextFunction> {
// 中间件匹配范围:全局生效,但跳过公开接口、电工 APP 与小程序客户接口(各自走独立鉴权)
ignore = ['/api/auth/*', '/api/health', '/api/worker-app/*', '/api/customer-app/*'];
// 中间件匹配范围:全局生效,但跳过公开接口、ICC 事件回调、电工 APP 与小程序客户接口(各自走独立鉴权)
ignore = ['/api/auth/*', '/api/health', '/api/worker-app/*', '/api/customer-app/*', '/api/icc-event/callback'];
@Inject()
tokenUtil!: TokenUtil;
+8 -2
View File
@@ -29,6 +29,12 @@ const PERMISSION_MAP: Record<string, string> = {
"/api/customer-equipments": "device:list",
"/api/smart-devices": "device:model",
"/api/electric-lines": "device:list",
"/api/electric-gateways": "device:list",
"/api/electricity-data": "device:list",
"/api/icc-event/subscribe": "device:list",
"/api/icc-event/subscribe-status": "device:list",
"/api/device-alarms": "alarm:list",
"/api/alarm-dispatches": "alarm-dispatch:list",
"/api/survey-orders": "project:survey",
"/api/project-plans": "project:plan",
"/api/contracts": "project:contract",
@@ -48,10 +54,10 @@ const ADMIN_ROLE_CODE = "ADMIN";
@Provide()
export class RbacMiddleware implements IMiddleware<Context, NextFunction> {
// 中间件匹配范围:全局生效,跳过公开接口、仪表盘与电工 APP 接口
// 中间件匹配范围:全局生效,跳过公开接口、ICC 事件回调(URL token 校验)、仪表盘、电工 APP 与小程序客户接口
// /api/workers/options 与 /api/workers/recommend 放行:业务员在「项目勘察派单」页
// 仍可调用电工下拉与推荐接口,不受 dispatch:worker 权限限制(本次需求有意设计)
ignore = ["/api/auth/*", "/api/health", "/api/dashboard/*", "/api/worker-app/*", "/api/customer-app/*", "/api/workers/options", "/api/workers/recommend"];
ignore = ["/api/auth/*", "/api/health", "/api/dashboard/*", "/api/worker-app/*", "/api/customer-app/*", "/api/workers/options", "/api/workers/recommend", "/api/icc-event/callback"];
@Inject()
permissionService!: PermissionService;
@@ -0,0 +1,130 @@
import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import { Context } from "@midwayjs/koa";
import ElectricGateway from "../entity/electric_gateway.entity";
import Customer from "../entity/customer.entity";
import CustomerEquipment from "../entity/customer_equipment.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
@Provide()
export class ElectricGatewayService {
@InjectRepository(ElectricGateway)
gatewayModel!: Repository<ElectricGateway>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(CustomerEquipment)
equipmentModel!: Repository<CustomerEquipment>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
}
async list(query: any, ctx: Context): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const userId = ctx.state.user.userId;
const where: any = {};
if (query.keyword) {
where[Op.or] = [
{ deviceName: { [Op.like]: `%${query.keyword}%` } },
{ deviceCode: { [Op.like]: `%${query.keyword}%` } },
{ iccImei: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
if (query.equipId !== undefined && query.equipId !== "") where.equipId = Number(query.equipId);
if (query.isOnline !== undefined && query.isOnline !== "" && query.isOnline !== null) where.isOnline = Number(query.isOnline);
// 非管理员按客户归属自动限定数据范围
if (!(await this.isAdmin(userId))) {
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
const ids = myCustomers.map((c) => c.id);
if (ids.length === 0) {
return { code: 200, message: "获取网关台账列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
}
if (where.customerId !== undefined) {
if (!ids.includes(where.customerId)) {
return { code: 200, message: "获取网关台账列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
}
} else {
where.customerId = { [Op.in]: ids };
}
}
const { count, rows } = await this.gatewayModel.findAndCountAll({
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["created_at", "DESC"], ["id", "DESC"]],
distinct: true
});
const list = await this.decorate(rows);
return { code: 200, message: "获取网关台账列表成功", data: { list, total: count, page: Number(page), pageSize: Number(pageSize) } };
}
async detail(id: number, ctx: Context): Promise<any> {
const record = await this.gatewayModel.findByPk(id);
if (!record) return { code: 404, message: "网关设备不存在", data: null };
if (!(await this.isAdmin(ctx.state.user.userId))) {
const customer = await this.customerModel.findByPk(record.customerId);
if (!customer || customer.salesmanId !== ctx.state.user.userId) {
return { code: 403, message: "无权限访问该网关设备", data: null };
}
}
const decorated = await this.decorate([record]);
return { code: 200, message: "获取网关设备台账详情成功", data: decorated[0] };
}
/** 补全客户/设备展示信息,避免 N+1。 */
private async decorate(rows: ElectricGateway[]): Promise<any[]> {
if (rows.length === 0) return rows as any;
const customerIds = [...new Set(rows.map((r) => r.customerId).filter(Boolean))];
const equipIds = [...new Set(rows.map((r) => r.equipId).filter(Boolean))];
const [customers, equips] = await Promise.all([
customerIds.length
? this.customerModel.findAll({
where: { id: { [Op.in]: customerIds } },
attributes: ["id", "customerNo", "customerName"]
})
: Promise.resolve([]),
equipIds.length
? this.equipmentModel.findAll({
where: { id: { [Op.in]: equipIds } },
attributes: ["id", "equipCode", "equipName"]
})
: Promise.resolve([])
]);
const customerMap = new Map(customers.map((c) => [c.id, c]));
const equipMap = new Map(equips.map((e) => [e.id, e]));
return rows.map((row) => {
const json: any = row.toJSON();
const customer: any = customerMap.get(row.customerId);
const equip: any = row.equipId ? equipMap.get(row.equipId) : undefined;
json.customerNo = customer?.customerNo || "";
json.customerName = customer?.customerName || "";
json.equipCode = equip?.equipCode || "";
json.equipName = equip?.equipName || "";
return json;
});
}
}
+17 -16
View File
@@ -4,9 +4,9 @@ import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import { Context } from "@midwayjs/koa";
import ElectricLine from "../entity/electric_line.entity";
import ElectricGateway from "../entity/electric_gateway.entity";
import Customer from "../entity/customer.entity";
import CustomerEquipment from "../entity/customer_equipment.entity";
import CustomerBuilding from "../entity/customer_building.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
@@ -21,8 +21,8 @@ export class ElectricLineService {
@InjectRepository(CustomerEquipment)
equipmentModel!: Repository<CustomerEquipment>;
@InjectRepository(CustomerBuilding)
buildingModel!: Repository<CustomerBuilding>;
@InjectRepository(ElectricGateway)
gatewayModel!: Repository<ElectricGateway>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@@ -44,12 +44,12 @@ export class ElectricLineService {
if (query.keyword) {
where[Op.or] = [
{ lineName: { [Op.like]: `%${query.keyword}%` } },
{ lineCode: { [Op.like]: `%${query.keyword}%` } }
{ lineCode: { [Op.like]: `%${query.keyword}%` } },
{ imei: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
if (query.equipId !== undefined && query.equipId !== "") where.equipId = Number(query.equipId);
if (query.buildingId !== undefined && query.buildingId !== "") where.buildingId = Number(query.buildingId);
if (query.status !== undefined && query.status !== "" && query.status !== null) where.status = Number(query.status);
// 非管理员按客户归属自动限定数据范围
@@ -95,15 +95,15 @@ export class ElectricLineService {
return { code: 200, message: "获取线路台账详情成功", data: decorated[0] };
}
/** 补全客户/设备/建筑展示信息,避免 N+1。 */
/** 补全客户/设备/网关展示信息,避免 N+1。 */
private async decorate(rows: ElectricLine[]): Promise<any[]> {
if (rows.length === 0) return rows as any;
const customerIds = [...new Set(rows.map((r) => r.customerId).filter(Boolean))];
const equipIds = [...new Set(rows.map((r) => r.equipId).filter(Boolean))];
const buildingIds = [...new Set(rows.map((r) => r.buildingId).filter(Boolean))];
const gatewayIds = [...new Set(rows.map((r) => r.gatewayId).filter(Boolean))];
const [customers, equips, buildings] = await Promise.all([
const [customers, equips, gateways] = await Promise.all([
customerIds.length
? this.customerModel.findAll({
where: { id: { [Op.in]: customerIds } },
@@ -116,28 +116,29 @@ export class ElectricLineService {
attributes: ["id", "equipCode", "equipName"]
})
: Promise.resolve([]),
buildingIds.length
? this.buildingModel.findAll({
where: { id: { [Op.in]: buildingIds } },
attributes: ["id", "buildingName"]
gatewayIds.length
? this.gatewayModel.findAll({
where: { id: { [Op.in]: gatewayIds } },
attributes: ["id", "deviceCode", "deviceName"]
})
: Promise.resolve([])
]);
const customerMap = new Map(customers.map((c) => [c.id, c]));
const equipMap = new Map(equips.map((e) => [e.id, e]));
const buildingMap = new Map(buildings.map((b) => [b.id, b]));
const gatewayMap = new Map(gateways.map((g) => [g.id, g]));
return rows.map((row) => {
const json: any = row.toJSON();
const customer: any = customerMap.get(row.customerId);
const equip: any = equipMap.get(row.equipId);
const building: any = row.buildingId ? buildingMap.get(row.buildingId) : undefined;
const equip: any = row.equipId ? equipMap.get(row.equipId) : undefined;
const gateway: any = row.gatewayId ? gatewayMap.get(row.gatewayId) : undefined;
json.customerNo = customer?.customerNo || "";
json.customerName = customer?.customerName || "";
json.equipCode = equip?.equipCode || "";
json.equipName = equip?.equipName || "";
json.buildingName = building?.buildingName || "";
json.gatewayCode = gateway?.deviceCode || "";
json.gatewayName = gateway?.deviceName || "";
return json;
});
}
@@ -0,0 +1,223 @@
import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Context } from "@midwayjs/koa";
import ElectricityData from "../entity/electricity_data.entity";
import ElectricLine from "../entity/electric_line.entity";
import IccSyncLog from "../entity/icc_sync_log.entity";
import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
import { ICC_ANALOG_ENDPOINT, mapIccAnalogRecord, parseAnalogPage, IccAnalogRawRecord } from "../task/icc_analog_mapper";
export interface IccAnalogSyncResult {
/** ICC 拉取到的原始模拟量条数 */
pulled: number;
/** 成功落库的模拟量条数(新增+更新) */
synced: number;
/** 新增的模拟量条数 */
inserted: number;
/** 更新的模拟量条数 */
updated: number;
/** 跳过的条数(线路未同步等) */
skipped: number;
}
const SYNC_TYPE = "electricity_data";
/** 模拟量接口每页上限(短页判定阈值,ICC 接口约定) */
const PAGE_SIZE = 500;
/** deviceCodes 每批最多 50 个(ICC 模拟量接口约定) */
const DEVICE_BATCH = 50;
/** 最大分页数保护,防止异常接口导致无限拉取 */
const MAX_PAGES = 100;
/** ICC_VERSION 缺省值(与 icc_customer_auth 保持一致) */
const DEFAULT_VERSION = "1.0.0";
/** HTTP 超时 60s(项目约定) */
const HTTP_TIMEOUT_MS = 60000;
/** 线路预取信息(用于模拟量落库时回填 lineId / lineName / customerId,避免 N+1) */
interface LineMatchInfo {
lineId: number;
lineName?: string;
customerId: number;
}
/**
* ICC 探测器模拟量数据同步(默认每 5 分钟执行一次):
* 1) 复用 icc_customer_auth 的真实 OAuth2 密码模式鉴权(getIccAccessToken / iccFetch),
* 从 .env 读取 ICC_BASE_URL / ICC_VERSION
* 2) 先读本系统已同步的 electric_line(line_code = ICC deviceCode),把 line_code 每批 ≤50
* 作为 deviceCodes 分批 POST 模拟量接口(/analog/subsystem/page,pageSize=500 短页停止)
* 3) 以同步时间作采集时间,按唯一键 (line_code, analog_type, collect_time) 幂等 upsert
* 写入 electricity_data(同一次同步内重复则覆盖更新)
* 4) 每次同步写一条 icc_sync_log(sync_type=electricity_data);拉取失败仅记录 error,不抛业务错误
*/
@Provide()
export class IccAnalogSyncService {
@InjectRepository(ElectricityData)
electricityModel!: Repository<ElectricityData>;
@InjectRepository(ElectricLine)
lineModel!: Repository<ElectricLine>;
@InjectRepository(IccSyncLog)
syncLogModel!: Repository<IccSyncLog>;
async syncFromIcc(ctx?: Context): Promise<IccAnalogSyncResult> {
const startTime = new Date();
// 采集时间 = 同步拉取时间(ICC 最新模拟量接口实际不返回 reportTime,经确认以同步时间作采集时间/分区键)
const collectTime = startTime;
const baseUrl = process.env.ICC_BASE_URL?.trim();
const result: IccAnalogSyncResult = { pulled: 0, synced: 0, inserted: 0, updated: 0, skipped: 0 };
// 基础配置缺失视为拉取失败:记录日志,不抛业务错误
if (!baseUrl) {
await this.writeLog(startTime, 0, SYNC_TYPE, 0, "未配置 ICC_BASE_URL,请检查 server/.env");
return result;
}
try {
// 预取本系统已同步线路 → line_code → {lineId,lineName,customerId} 映射,避免 N+1 查询
const lines = await this.lineModel.findAll({
attributes: ["id", "lineCode", "lineName", "customerId"]
});
const lineMap = new Map<string, LineMatchInfo>();
for (const line of lines) {
if (!line.lineCode) continue;
lineMap.set(line.lineCode, {
lineId: line.id,
lineName: line.lineName || undefined,
customerId: line.customerId
});
}
// 本系统尚未同步任何线路(无 deviceCode 可查),直接写成功日志返回
if (lineMap.size === 0) {
await this.writeLog(startTime, 1, SYNC_TYPE, 0, null);
return result;
}
const token = await getIccAccessToken();
const url = `${baseUrl.replace(/\/$/, "")}${ICC_ANALOG_ENDPOINT.replace("{version}", process.env.ICC_VERSION?.trim() || DEFAULT_VERSION)}`;
// lineCodes 按 ≤DEVICE_BATCH 分批,逐批拉取模拟量
const allRaw: IccAnalogRawRecord[] = [];
const lineCodes = [...lineMap.keys()];
for (let i = 0; i < lineCodes.length; i += DEVICE_BATCH) {
const batch = lineCodes.slice(i, i + DEVICE_BATCH);
let pageNum = 1;
while (pageNum <= MAX_PAGES) {
const res = await iccFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `bearer ${token}`
},
body: JSON.stringify({ pageNum, pageSize: PAGE_SIZE, deviceCodes: batch }),
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
});
if (!res.ok) {
throw new Error(`ICC 模拟量接口返回异常:HTTP ${res.status}`);
}
const payload: any = await res.json();
const page = parseAnalogPage(payload);
allRaw.push(...page.list);
result.pulled += page.list.length;
// 短页判定:本页不满 pageSize 视为无下一页;另有最大页数保护
if (page.list.length < PAGE_SIZE) break;
pageNum++;
}
}
const { inserted, updated, skipped } = await this.batchUpsert(allRaw, lineMap, collectTime);
result.inserted = inserted;
result.updated = updated;
result.synced = inserted + updated;
result.skipped = skipped;
await this.writeLog(startTime, 1, SYNC_TYPE, result.synced, null);
return result;
} catch (err: any) {
const message = err instanceof Error ? err.message : String(err);
await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message);
return result;
}
}
/** 按 (line_code, analog_type, collect_time) 幂等 upsert(collectTime 为本次同步时间);线路未同步的行跳过。 */
private async batchUpsert(
records: IccAnalogRawRecord[],
lineMap: Map<string, LineMatchInfo>,
collectTime: Date
): Promise<{ inserted: number; updated: number; skipped: number }> {
let inserted = 0;
let updated = 0;
let skipped = 0;
if (records.length === 0) return { inserted, updated, skipped };
for (const raw of records) {
const mapped = mapIccAnalogRecord(raw);
// deviceCode 必须在本系统已同步的线路中(line_code = ICC deviceCode),否则跳过
const lineInfo = lineMap.get(mapped.deviceCode);
if (!lineInfo) {
skipped++;
continue;
}
const payload = {
lineId: lineInfo.lineId,
lineCode: mapped.deviceCode,
imei: mapped.uniqueCode || null,
lineName: lineInfo.lineName || null,
customerId: lineInfo.customerId,
analogType: mapped.analogType || null,
analogName: mapped.analogName || null,
analogUnit: mapped.analogUnit || null,
analogValue: mapped.analogValue,
collectTime
} as any;
// 幂等 upsert:唯一键 (line_code, analog_type, collect_time)(collectTime 为本次同步时间),
// 同一次同步内同一 device+type 重复出现(如跨页)则覆盖更新
const existing = await this.electricityModel.findOne({
where: { lineCode: mapped.deviceCode, analogType: mapped.analogType || null, collectTime }
});
if (existing) {
await existing.update({
analogValue: mapped.analogValue,
imei: mapped.uniqueCode || null,
lineName: lineInfo.lineName || null,
analogName: mapped.analogName || null,
analogUnit: mapped.analogUnit || null
} as any);
updated++;
} else {
await this.electricityModel.create(payload);
inserted++;
}
}
return { inserted, updated, skipped };
}
private async writeLog(startTime: Date, status: number, syncType: string, recordCount: number, errorMessage: string | null): Promise<void> {
try {
await this.syncLogModel.create({
syncType,
startTime,
endTime: new Date(),
recordCount,
status,
errorMessage
} as any);
} catch (err) {
console.error("[ICC同步] 同步日志写入失败:", err);
}
}
}
@@ -0,0 +1,313 @@
import { Provide, Inject, Config } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import DeviceAlarm from "../entity/device_alarm.entity";
import ElectricLine from "../entity/electric_line.entity";
import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
/** 订阅注册结果 */
export interface IccRegisterResult {
success: boolean;
code?: string;
errMsg?: string;
raw?: any;
}
/** 回调事件处理统计 */
export interface IccEventHandleResult {
/** 收到的通用事件总数 */
received: number;
/** 进入处理(category=alarm 且有 info)的事件数 */
processed: number;
/** 新增报警数 */
inserted: number;
/** 更新报警数 */
updated: number;
/** 未匹配到本系统线路的报警数 */
unmatched: number;
}
/** 线路预取信息(回填 lineId/lineCode/lineName/customerId,避免 N+1) */
interface LineMatchInfo {
lineId: number;
lineName?: string;
customerId: number;
}
/** ICC 事件订阅接口路径(version 固定 1.0.0,与 ICC 事件订阅文档一致) */
const ICC_EVENT_SUBSCRIBE_ENDPOINT = "/evo-apigw/evo-event/1.0.0/subscribe/mqinfo";
/** 查询订阅列表接口路径(version 固定 1.0.0) */
const ICC_EVENT_LIST_ENDPOINT = "/evo-apigw/evo-event/1.0.0/subscribe/subscribe-list";
/** HTTP 超时 60s(项目约定) */
const HTTP_TIMEOUT_MS = 60000;
/** 秒级时间戳转 Date(< 1e12 视为秒级 *1000;已毫秒级则原样);非法返回 null */
function secondsToDate(value: unknown): Date | null {
if (value === undefined || value === null || String(value).trim() === "") return null;
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return null;
return new Date(n < 1e12 ? n * 1000 : n);
}
/** 数值解析:空 / null / 非法统一归一为 null */
function toInt(value: unknown): number | null {
if (value === undefined || value === null || String(value).trim() === "") return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
/** 字符串 trim;空串归一为 null */
function toOptionalString(value: unknown): string | null {
if (value === undefined || value === null) return null;
const s = String(value).trim();
return s === "" ? null : s;
}
/** 布尔解析:兼容 boolean / 0|1 / "true"|"false" / "1"|"0";空值归一为 null */
function toBool(value: unknown): boolean | null {
if (value === undefined || value === null || String(value).trim() === "") return null;
if (typeof value === "boolean") return value;
const s = String(value).trim().toLowerCase();
if (s === "1" || s === "true" || s === "yes") return true;
if (s === "0" || s === "false" || s === "no") return false;
return null;
}
/**
* ICC 事件订阅服务:
* 1) registerSubscribe:向 ICC 事件订阅接口注册报警事件回调(monitor=monitorUrl?token=callbackToken,
* 子系统 name/magic 取自配置 iccEvent),同名重复注册 ICC 会自动覆盖(保活语义)
* 2) ensureSubscribe:保活入口,直接 registerSubscribe 同名覆盖(ICC 同名覆盖即保活),
* 由每 30 分钟定时任务与手动接口调用
* 3) handleCallback:处理 ICC 回调推送的通用事件,仅入库 category=alarm 的报警事件,
* 按 alarm_code 幂等 upsert,uuid 相同重复推送跳过,按 nodeCode/deviceCode 匹配本系统线路
*/
@Provide()
export class IccEventSubscribeService {
@Inject()
ctx!: Context;
@Config("iccEvent")
iccEventConfig!: { monitorUrl: string; callbackToken: string; subscriberName: string; subscriberMagic: string };
@InjectRepository(DeviceAlarm)
alarmModel!: Repository<DeviceAlarm>;
@InjectRepository(ElectricLine)
lineModel!: Repository<ElectricLine>;
/** 最近一次订阅注册结果(内存,供 subscribe-status 接口查询) */
private lastRegisterResult: IccRegisterResult | null = null;
private lastRegisterAt: Date | null = null;
/**
* 向 ICC 注册报警事件订阅(同名重复注册 ICC 覆盖,保活语义)。
* 网络异常 / HTTP 错误 / ICC 业务失败均不抛错,返回失败状态并记录到内存。
*/
async registerSubscribe(): Promise<IccRegisterResult> {
const baseUrl = process.env.ICC_BASE_URL?.trim();
if (!baseUrl) {
return this.remember({ success: false, code: "500", errMsg: "未配置 ICC_BASE_URL,请检查 server/.env" });
}
try {
const token = await getIccAccessToken();
const url = `${baseUrl.replace(/\/$/, "")}${ICC_EVENT_SUBSCRIBE_ENDPOINT}`;
const body = {
param: {
monitors: [
{
monitor: `${this.iccEventConfig.monitorUrl}?token=${this.iccEventConfig.callbackToken}`,
monitorType: "url",
events: [{ category: "alarm", subscribeAll: 1, domainSubscribe: 2, eventType: 2 }]
}
],
subsystem: {
subsystemType: 0,
name: this.iccEventConfig.subscriberName,
magic: this.iccEventConfig.subscriberMagic
}
}
};
const res = await iccFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `bearer ${token}`
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
});
const payload: any = await res.json().catch(() => null);
if (!res.ok) {
return this.remember({ success: false, code: String(res.status), errMsg: `ICC 订阅接口 HTTP ${res.status}`, raw: payload });
}
const success = payload?.success === true || String(payload?.code ?? "") === "0";
if (!success) {
return this.remember({ success: false, code: String(payload?.code ?? ""), errMsg: String(payload?.errMsg ?? JSON.stringify(payload)), raw: payload });
}
return this.remember({ success: true, code: "0", errMsg: "", raw: payload });
} catch (err: any) {
const message = err instanceof Error ? err.message : String(err);
return this.remember({ success: false, code: "500", errMsg: message });
}
}
/**
* 保活入口:ICC 同名订阅会覆盖,因此直接 registerSubscribe 即可(保活语义)。
* 返回与 registerSubscribe 相同的注册结果。
*/
async ensureSubscribe(): Promise<IccRegisterResult> {
return this.registerSubscribe();
}
/** 查询订阅状态:配置 + 最近一次注册结果(内存) */
async getSubscribeStatus(): Promise<{
config: { monitorUrl: string; subscriberName: string; subscriberMagic: string };
lastRegisterResult: IccRegisterResult | null;
lastRegisterAt: Date | null;
}> {
return {
config: {
monitorUrl: this.iccEventConfig.monitorUrl,
subscriberName: this.iccEventConfig.subscriberName,
subscriberMagic: this.iccEventConfig.subscriberMagic
},
lastRegisterResult: this.lastRegisterResult,
lastRegisterAt: this.lastRegisterAt
};
}
/**
* 处理 ICC 回调推送的通用事件:仅处理 category=alarm 且有 info 的事件。
* - 按 info.nodeCode || info.deviceCode 匹配 electric_line.line_code,命中回填 lineId/lineCode/lineName/customerId
* - uuid 相同(同一事件重复推送)跳过
* - 按 alarm_code 幂等 upsert:存在则更新 alarmStat/isEvent/alarmTime/syncedAt 等,不存在则新增
*/
async handleCallback(body: any): Promise<IccEventHandleResult> {
const result: IccEventHandleResult = { received: 0, processed: 0, inserted: 0, updated: 0, unmatched: 0 };
const events = this.extractEvents(body);
result.received = events.length;
if (events.length === 0) return result;
// 预取本系统已同步线路 → line_code → {lineId,lineName,customerId} 映射,避免 N+1
const lines = await this.lineModel.findAll({
attributes: ["id", "lineCode", "lineName", "customerId"]
});
const lineMap = new Map<string, LineMatchInfo>();
for (const line of lines) {
if (!line.lineCode) continue;
lineMap.set(line.lineCode, {
lineId: line.id,
lineName: line.lineName || undefined,
customerId: line.customerId
});
}
for (const event of events) {
// 通用事件格式:仅处理 category=alarm 且有 info 对象的事件
if (!event || event.category !== "alarm") continue;
const info = event.info;
if (!info || typeof info !== "object") continue;
result.processed++;
const alarmCode = toOptionalString(info.alarmCode);
if (!alarmCode) continue;
// uuid 去重:同一 uuid 重复推送直接跳过(uuid 非唯一列,按存在性判断)
const uuid = toOptionalString(event.uuid ?? info.uuid);
if (uuid) {
const byUuid = await this.alarmModel.findOne({ where: { uuid } });
if (byUuid) continue;
}
// 按 nodeCode || deviceCode 匹配本系统线路(line_code)
const lineFields: { lineId: number | null; lineCode: string | null; lineName: string | null; customerId: number | null } = {
lineId: null,
lineCode: null,
lineName: null,
customerId: null
};
const matchKey = toOptionalString(info.nodeCode) || toOptionalString(info.deviceCode);
if (matchKey) {
const lineInfo = lineMap.get(matchKey);
if (lineInfo) {
lineFields.lineId = lineInfo.lineId;
lineFields.lineCode = matchKey;
lineFields.lineName = lineInfo.lineName || null;
lineFields.customerId = lineInfo.customerId;
} else {
result.unmatched++;
}
} else {
result.unmatched++;
}
const base = {
uuid,
nodeCode: toOptionalString(info.nodeCode),
deviceCode: toOptionalString(info.deviceCode),
deviceName: toOptionalString(info.deviceName),
orgCode: toOptionalString(info.orgCode),
orgName: toOptionalString(info.orgName),
alarmType: toInt(info.alarmType),
alarmGrade: toInt(info.alarmGrade),
alarmStat: toInt(info.alarmStat),
isEvent: toBool(info.isEvent),
alarmValue: toInt(info.alarmValue),
alarmTime: secondsToDate(info.alarmDate)
};
// 按 alarm_code 幂等 upsert
const existing = await this.alarmModel.findOne({ where: { alarmCode } });
if (existing) {
await existing.update({
...base,
...lineFields,
syncedAt: new Date()
} as any);
result.updated++;
} else {
await this.alarmModel.create({
alarmCode,
...base,
...lineFields,
syncedAt: new Date()
} as any);
result.inserted++;
}
}
return result;
}
/** 从回调 body 提取通用事件数组(兼容 data.events / events / data 数组 / body 数组 / body 单条事件 五种形态) */
private extractEvents(body: any): any[] {
try {
if (!body || typeof body !== "object") return [];
if (Array.isArray(body)) return body;
if (Array.isArray(body.events)) return body.events;
const data = body.data;
if (data && Array.isArray(data.events)) return data.events;
if (Array.isArray(data)) return data;
// 兼容 ICC 直接推送单条通用事件对象(body 本身即事件)
if (typeof body.category === "string" && body.info && typeof body.info === "object") return [body];
return [];
} catch {
return [];
}
}
/** 记录注册结果到内存并返回 */
private remember(result: IccRegisterResult): IccRegisterResult {
this.lastRegisterResult = result;
this.lastRegisterAt = new Date();
return result;
}
}
@@ -0,0 +1,198 @@
import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import { Context } from "@midwayjs/koa";
import ElectricGateway from "../entity/electric_gateway.entity";
import IccSyncLog from "../entity/icc_sync_log.entity";
import Customer from "../entity/customer.entity";
import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
import { ICC_GATEWAY_ENDPOINT, mapIccGatewayRecord, parseGatewayPage, IccGatewayRawRecord } from "../task/icc_gateway_mapper";
export interface IccGatewaySyncResult {
/** ICC 拉取到的原始设备条数 */
pulled: number;
/** 成功落库的设备条数(新增+更新) */
synced: number;
/** 新增的设备条数 */
inserted: number;
/** 更新的设备条数 */
updated: number;
/** 跳过的条数(缺 imei/deviceCode / orgCode 匹配不到客户) */
skipped: number;
}
const SYNC_TYPE = "electric_gateway";
/** 分页拉取参数(ICC 设备分页接口约定,短页判定阈值) */
const PAGE_SIZE = 50;
/** 最大分页数保护,防止异常接口导致无限拉取 */
const MAX_PAGES = 100;
/** ICC_VERSION 缺省值(与 icc_customer_auth 保持一致) */
const DEFAULT_VERSION = "1.0.0";
/** HTTP 超时 60s(项目约定) */
const HTTP_TIMEOUT_MS = 60000;
/**
* ICC 智电改造网关设备拉取同步:
* 1) 复用 icc_customer_auth 的真实 OAuth2 密码模式鉴权(getIccAccessToken / iccFetch),
* 从 .env 读取 ICC_BASE_URL / ICC_VERSION
* 2) POST 分页请求 ICC 设备列表(/device/subsystem/page,pageNum/pageSize=50,全量拉取)
* 3) 预取 orgCode→customer.id 映射(customer.icc_code),避免 N+1 查询客户
* 4) 按 imei/deviceCode 幂等 upsert 写入 electric_gateway,更新 synced_at
* 5) 每次同步写一条 icc_sync_log;拉取失败仅记录 error,不抛业务错误
*/
@Provide()
export class IccGatewaySyncService {
@InjectRepository(ElectricGateway)
gatewayModel!: Repository<ElectricGateway>;
@InjectRepository(IccSyncLog)
syncLogModel!: Repository<IccSyncLog>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
async syncFromIcc(ctx?: Context): Promise<IccGatewaySyncResult> {
const startTime = new Date();
const baseUrl = process.env.ICC_BASE_URL?.trim();
const result: IccGatewaySyncResult = { pulled: 0, synced: 0, inserted: 0, updated: 0, skipped: 0 };
// 基础配置缺失视为拉取失败:记录日志,不抛业务错误
if (!baseUrl) {
await this.writeLog(startTime, 0, SYNC_TYPE, 0, "未配置 ICC_BASE_URL,请检查 server/.env");
return result;
}
try {
const token = await getIccAccessToken();
const url = `${baseUrl.replace(/\/$/, "")}${ICC_GATEWAY_ENDPOINT.replace("{version}", process.env.ICC_VERSION?.trim() || DEFAULT_VERSION)}`;
const allRaw: IccGatewayRawRecord[] = [];
let pageNum = 1;
while (pageNum <= MAX_PAGES) {
const res = await iccFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `bearer ${token}`
},
body: JSON.stringify({ pageNum, pageSize: PAGE_SIZE }), // 不传 typeCode,全量拉取
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
});
if (!res.ok) {
throw new Error(`ICC 网关设备接口返回异常:HTTP ${res.status}`);
}
const payload: any = await res.json();
const page = parseGatewayPage(payload);
allRaw.push(...page.list);
result.pulled += page.list.length;
// 短页判定:本页不满 pageSize 视为无下一页;另有最大页数保护
if (page.list.length < PAGE_SIZE) break;
pageNum++;
}
const { inserted, updated, skipped } = await this.batchUpsert(allRaw);
result.inserted = inserted;
result.updated = updated;
result.synced = inserted + updated;
result.skipped = skipped;
await this.writeLog(startTime, 1, SYNC_TYPE, result.synced, null);
return result;
} catch (err: any) {
const message = err instanceof Error ? err.message : String(err);
await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message);
return result;
}
}
/** 按 imei/deviceCode 幂等 upsert(幂等),customer_id 解析失败的行跳过。 */
private async batchUpsert(records: IccGatewayRawRecord[]): Promise<{ inserted: number; updated: number; skipped: number }> {
let inserted = 0;
let updated = 0;
let skipped = 0;
if (records.length === 0) return { inserted, updated, skipped };
// 预取 orgCode → customer.id 映射,避免 N+1 查询客户
const orgCodes = [...new Set(records.map((r) => mapIccGatewayRecord(r).orgCode).filter(Boolean))];
const customers = orgCodes.length
? await this.customerModel.findAll({ where: { iccCode: { [Op.in]: orgCodes } }, attributes: ["id", "iccCode"] })
: [];
const customerIdByOrgCode = new Map(customers.map((c) => [c.iccCode, c.id]));
const now = new Date();
for (const raw of records) {
const mapped = mapIccGatewayRecord(raw);
// imei 与 deviceCode 都为空则无同步匹配键,跳过
if (!mapped.imei && !mapped.deviceCode) {
skipped++;
continue;
}
// customer_id NOT NULL:orgCode 必须能解析到客户,否则跳过
if (!mapped.orgCode) {
skipped++;
continue;
}
const customerId = customerIdByOrgCode.get(mapped.orgCode);
if (!customerId) {
skipped++;
continue;
}
const payload = {
iccImei: mapped.imei || null,
deviceCode: mapped.deviceCode || null,
deviceName: mapped.deviceName || null,
customerId,
orgCode: mapped.orgCode || null,
orgName: mapped.orgName || null,
manufacturerCode: mapped.manufacturerCode || null,
manufacturerName: mapped.manufacturerName || null,
typeCode: mapped.typeCode || null,
typeName: mapped.typeName || null,
productModel: mapped.productModel || null,
isOnline: mapped.isOnline,
syncedAt: now
} as any;
// 幂等 upsert:按非空键匹配(仅包含非空键)
const matchWhere: any[] = [];
if (mapped.imei) matchWhere.push({ iccImei: mapped.imei });
if (mapped.deviceCode) matchWhere.push({ deviceCode: mapped.deviceCode });
const existing = await this.gatewayModel.findOne({ where: { [Op.or]: matchWhere } });
if (existing) {
await existing.update({ ...payload, syncedAt: now });
updated++;
} else {
await this.gatewayModel.create(payload);
inserted++;
}
}
return { inserted, updated, skipped };
}
private async writeLog(startTime: Date, status: number, syncType: string, recordCount: number, errorMessage: string | null): Promise<void> {
try {
await this.syncLogModel.create({
syncType,
startTime,
endTime: new Date(),
recordCount,
status,
errorMessage
} as any);
} catch (err) {
console.error("[ICC同步] 同步日志写入失败:", err);
}
}
}
+92 -86
View File
@@ -6,31 +6,41 @@ import { Context } from "@midwayjs/koa";
import ElectricLine from "../entity/electric_line.entity";
import IccSyncLog from "../entity/icc_sync_log.entity";
import Customer from "../entity/customer.entity";
import CustomerEquipment from "../entity/customer_equipment.entity";
import CustomerBuilding from "../entity/customer_building.entity";
import { ICC_LINE_ENDPOINT, mapIccLineRecord, IccLineRawRecord } from "../task/icc_line_mapper";
import ElectricGateway from "../entity/electric_gateway.entity";
import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
import { ICC_LINE_ENDPOINT, mapIccLineRecord, parseLinePage, IccLineRawRecord } from "../task/icc_line_mapper";
export interface IccLineSyncResult {
/** ICC 拉取到的原始条数 */
/** ICC 拉取到的原始探测器条数 */
pulled: number;
/** 解析落库的线路条数(成功 upsert,含新增/更新) */
/** 成功落库的线路条数(新增+更新) */
synced: number;
/** 新增的线路条数 */
inserted: number;
/** 更新的线路条数 */
updated: number;
/** 因缺少合法 line_code 或无法匹配客户/设备而跳过的条数 */
/** 跳过的条数(缺 lineCode / orgCode 匹配不到客户) */
skipped: number;
}
const SYNC_TYPE = "electric_line";
/** 分页拉取参数(ICC 探测器分页接口约定,短页判定阈值) */
const PAGE_SIZE = 50;
/** 最大分页数保护,防止异常接口导致无限拉取 */
const MAX_PAGES = 100;
/** ICC_VERSION 缺省值(与 icc_customer_auth 保持一致) */
const DEFAULT_VERSION = "1.0.0";
/** HTTP 超时 60s(项目约定) */
const HTTP_TIMEOUT_MS = 60000;
/**
* ICC 智电改造线路拉取同步:
* 1) 从 .env 读取 ICC_BASE_URL / ICC_TOKEN 占位配置
* 2) 请求 ICC 线路列表(路径与字段映射集中在 icc_line_mapper)
* 3) 按 line_code 幂等 upsert 写入 electric_line,更新 synced_at
* 4) 每次同步写一条 icc_sync_log;ICC 拉取失败仅记录 error,不抛业务错误
* ICC 智电改造线路(消防探测器)拉取同步:
* 1) 复用 icc_customer_auth 的真实 OAuth2 密码模式鉴权(getIccAccessToken / iccFetch),
* 从 .env 读取 ICC_BASE_URL / ICC_VERSION
* 2) POST 分页请求 ICC 探测器列表(/detector/subsystem/page,pageNum/pageSize=50,全量拉取)
* 3) 预取 orgCode→customer.id 映射(customer.icc_code)与 parentCode→gateway.id 映射(electric_gateway.device_code),避免 N+1 查询
* 4) 按 line_code(detectorCode)幂等 upsert 写入 electric_line,更新 synced_at
* 5) 每次同步写一条 icc_sync_log;拉取失败仅记录 error,不抛业务错误
*/
@Provide()
export class IccLineSyncService {
@@ -43,52 +53,55 @@ export class IccLineSyncService {
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(CustomerEquipment)
equipmentModel!: Repository<CustomerEquipment>;
@InjectRepository(CustomerBuilding)
buildingModel!: Repository<CustomerBuilding>;
@InjectRepository(ElectricGateway)
gatewayModel!: Repository<ElectricGateway>;
async syncFromIcc(ctx?: Context): Promise<IccLineSyncResult> {
const startTime = new Date();
const baseUrl = process.env.ICC_BASE_URL?.trim();
const token = process.env.ICC_TOKEN?.trim();
const result: IccLineSyncResult = { pulled: 0, synced: 0, inserted: 0, updated: 0, skipped: 0 };
// 占位配置缺失视为拉取失败:记录日志,不抛业务错误
// 基础配置缺失视为拉取失败:记录日志,不抛业务错误
if (!baseUrl) {
await this.writeLog(startTime, 0, SYNC_TYPE, 0, "未配置 ICC_BASE_URL,请检查 server/.env");
return result;
}
try {
const url = `${baseUrl.replace(/\/$/, "")}${ICC_LINE_ENDPOINT}`;
const headers: Record<string, string> = { Accept: "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
const token = await getIccAccessToken();
const url = `${baseUrl.replace(/\/$/, "")}${ICC_LINE_ENDPOINT.replace("{version}", process.env.ICC_VERSION?.trim() || DEFAULT_VERSION)}`;
const res = await fetch(url, {
method: "GET",
headers,
signal: AbortSignal.timeout(60000) // 按项目约定 HTTP 超时 60s
});
const allRaw: IccLineRawRecord[] = [];
let pageNum = 1;
if (!res.ok) {
throw new Error(`ICC 线路接口返回异常:HTTP ${res.status}`);
while (pageNum <= MAX_PAGES) {
const res = await iccFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `bearer ${token}`
},
body: JSON.stringify({ pageNum, pageSize: PAGE_SIZE }), // 不传额外筛选条件,全量拉取
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
});
if (!res.ok) {
throw new Error(`ICC 探测器接口返回异常:HTTP ${res.status}`);
}
const payload: any = await res.json();
const page = parseLinePage(payload);
allRaw.push(...page.list);
result.pulled += page.list.length;
// 短页判定:本页不满 pageSize 视为无下一页;另有最大页数保护
if (page.list.length < PAGE_SIZE) break;
pageNum++;
}
const payload: any = await res.json();
// 兼容缓存工具包裹(data/list 字段)返回格式
const rawList: IccLineRawRecord[] = Array.isArray(payload)
? payload
: Array.isArray(payload.data)
? payload.data
: Array.isArray(payload.list)
? payload.list
: [];
result.pulled = rawList.length;
const { inserted, updated, skipped } = await this.batchUpsert(rawList);
const { inserted, updated, skipped } = await this.batchUpsert(allRaw);
result.inserted = inserted;
result.updated = updated;
result.synced = inserted + updated;
@@ -97,15 +110,13 @@ export class IccLineSyncService {
await this.writeLog(startTime, 1, SYNC_TYPE, result.synced, null);
return result;
} catch (err: any) {
const message = err?.code === "ERR_MODULE_NOT_FOUND" || err?.message
? String(err?.message || err)
: String(err);
const message = err instanceof Error ? err.message : String(err);
await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message);
return result;
}
}
/** 批量按 line_code upsert(幂等),并做客户/设备/建筑外键反查。 */
/** 按 line_code(detectorCode)幂等 upsert,customer_id 解析失败的行跳过。 */
private async batchUpsert(records: IccLineRawRecord[]): Promise<{ inserted: number; updated: number; skipped: number }> {
let inserted = 0;
let updated = 0;
@@ -113,71 +124,66 @@ export class IccLineSyncService {
if (records.length === 0) return { inserted, updated, skipped };
// 预取外键映射,避免 N+1
const equipCodes = [...new Set(records.map((r) => mapIccLineRecord(r).equipCode).filter(Boolean))];
const customerNos = [...new Set(records.map((r) => mapIccLineRecord(r).customerNo).filter(Boolean))];
const buildingNames = [...new Set(records.map((r) => mapIccLineRecord(r).buildingName).filter(Boolean))];
// 预取 orgCode → customer.id 映射,避免 N+1 查询客户
const orgCodes = [...new Set(records.map((r) => mapIccLineRecord(r).orgCode).filter(Boolean))];
const customers = orgCodes.length
? await this.customerModel.findAll({ where: { iccCode: { [Op.in]: orgCodes } }, attributes: ["id", "iccCode"] })
: [];
const customerIdByOrgCode = new Map(customers.map((c) => [c.iccCode, c.id]));
const [equips, customers, buildings] = await Promise.all([
equipCodes.length
? this.equipmentModel.findAll({ where: { equipCode: { [Op.in]: equipCodes } }, attributes: ["id", "equipCode", "customerId", "buildingId"] })
: Promise.resolve([]),
customerNos.length
? this.customerModel.findAll({ where: { customerNo: { [Op.in]: customerNos } }, attributes: ["id", "customerNo"] })
: Promise.resolve([]),
buildingNames.length
? this.buildingModel.findAll({ where: { buildingName: { [Op.in]: buildingNames } }, attributes: ["id", "buildingName", "customerId"] })
: Promise.resolve([])
]);
const equipByCode = new Map(equips.map((e) => [e.equipCode, e]));
const customerByNo = new Map(customers.map((c) => [c.customerNo, c]));
const buildingByName = new Map(buildings.map((b) => [b.buildingName, b]));
// 预取 parentCode → gateway.id 映射(electric_gateway.device_code),避免 N+1 查询网关
const parentCodes = [...new Set(records.map((r) => mapIccLineRecord(r).parentCode).filter(Boolean))];
const gateways = parentCodes.length
? await this.gatewayModel.findAll({ where: { deviceCode: { [Op.in]: parentCodes } }, attributes: ["id", "deviceCode"] })
: [];
const gatewayIdByParentCode = new Map(gateways.map((g) => [g.deviceCode, g.id]));
const now = new Date();
for (const raw of records) {
const mapped = mapIccLineRecord(raw);
// line_code(detectorCode)为空则无同步匹配键,跳过
if (!mapped.lineCode) {
skipped++;
continue;
}
const equip = mapped.equipCode ? equipByCode.get(mapped.equipCode) : undefined;
// equip_id 与 customer_id 为 NOT NULL,无法解析时跳过该行
if (!equip) {
// customer_id NOT NULL:orgCode 必须能解析到客户,否则跳过
if (!mapped.orgCode) {
skipped++;
continue;
}
const customerId = customerIdByOrgCode.get(mapped.orgCode);
if (!customerId) {
skipped++;
continue;
}
let customerId: number = equip.customerId;
if (mapped.customerNo) {
const byNo = customerByNo.get(mapped.customerNo);
if (byNo) customerId = byNo.id;
}
let buildingId: number | null = equip.buildingId || null;
if (mapped.buildingName) {
const building = buildingByName.get(mapped.buildingName);
if (building) buildingId = building.id;
}
// gateway_id 可空:parentCode 匹配不到 electric_gateway.device_code 时置空
const gatewayId = mapped.parentCode ? (gatewayIdByParentCode.get(mapped.parentCode) ?? null) : null;
const payload = {
lineCode: mapped.lineCode,
equipId: equip.id,
customerId,
buildingId,
iccDeviceCode: mapped.iccDeviceCode || null,
lineName: mapped.lineName || null,
floorNo: mapped.floorNo || null,
location: mapped.location || null,
imei: mapped.imei || null,
// equipId 同步不填充
gatewayId,
customerId,
typeCode: mapped.typeCode || null,
typeName: mapped.typeName || null,
fireSystemCode: mapped.fireSystemCode || null,
fireSystemName: mapped.fireSystemName || null,
partUnitLoopCode: mapped.partUnitLoopCode || null,
channelNo: mapped.channelNo || null,
channelSn: mapped.channelSn || null,
produceDate: mapped.produceDate || null,
status: mapped.status,
syncedAt: now
} as any;
// 幂等 upsert:按 line_code 唯一键匹配
const existing = await this.lineModel.findOne({ where: { lineCode: mapped.lineCode } });
if (existing) {
await existing.update(payload);
await existing.update({ ...payload, syncedAt: now });
updated++;
} else {
await this.lineModel.create(payload);
@@ -202,4 +208,4 @@ export class IccLineSyncService {
console.error("[ICC同步] 同步日志写入失败:", err);
}
}
}
}
@@ -10,6 +10,9 @@ import { LogService } from "./log.service";
import { NoticeRuleService } from "./notice_rule.service";
import { IccLineSyncService } from "./icc_line_sync.service";
import { IccCustomerSyncService } from "./icc_customer_sync.service";
import { IccGatewaySyncService } from "./icc_gateway_sync.service";
import { IccAnalogSyncService } from "./icc_analog_sync.service";
import { IccEventSubscribeService } from "./icc_event_subscribe.service";
import { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry";
/** 任务执行结果(一次尝试) */
@@ -459,6 +462,24 @@ export class JobSchedulerService {
const iccCustomerService = (await ctx.requestContext.getAsync(IccCustomerSyncService)) as IccCustomerSyncService;
const result = await iccCustomerService.syncFromIcc();
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
},
syncIccGateways: async () => {
const ctx = this.app.createAnonymousContext();
const iccGatewayService = (await ctx.requestContext.getAsync(IccGatewaySyncService)) as IccGatewaySyncService;
const result = await iccGatewayService.syncFromIcc();
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
},
syncIccAnalogs: async () => {
const ctx = this.app.createAnonymousContext();
const iccAnalogService = (await ctx.requestContext.getAsync(IccAnalogSyncService)) as IccAnalogSyncService;
const result = await iccAnalogService.syncFromIcc();
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
},
ensureIccEventSubscribe: async () => {
const ctx = this.app.createAnonymousContext();
const iccEventService = (await ctx.requestContext.getAsync(IccEventSubscribeService)) as IccEventSubscribeService;
const result = await iccEventService.ensureSubscribe();
return result.success ? `ICC 事件订阅成功(code=${result.code ?? "0"})` : `ICC 事件订阅失败:${result.errMsg ?? ""}`;
}
};
}
+10 -10
View File
@@ -43,13 +43,13 @@ export class MenuService {
}
const menu = await this.menuModel.create({
parent_id: parentId,
menu_name: menuName,
menu_path: menuPath,
parentId,
menuName,
menuPath,
component,
icon,
sort_order: sortOrder,
menu_type: menuType,
sortOrder,
menuType,
permission,
visible,
});
@@ -73,13 +73,13 @@ export class MenuService {
}
const updateData: any = {};
if (data.parentId !== undefined) updateData.parent_id = data.parentId;
if (data.menuName !== undefined) updateData.menu_name = data.menuName;
if (data.menuPath !== undefined) updateData.menu_path = data.menuPath;
if (data.parentId !== undefined) updateData.parentId = data.parentId;
if (data.menuName !== undefined) updateData.menuName = data.menuName;
if (data.menuPath !== undefined) updateData.menuPath = data.menuPath;
if (data.component !== undefined) updateData.component = data.component;
if (data.icon !== undefined) updateData.icon = data.icon;
if (data.sortOrder !== undefined) updateData.sort_order = data.sortOrder;
if (data.menuType !== undefined) updateData.menu_type = data.menuType;
if (data.sortOrder !== undefined) updateData.sortOrder = data.sortOrder;
if (data.menuType !== undefined) updateData.menuType = data.menuType;
if (data.permission !== undefined) updateData.permission = data.permission;
if (data.visible !== undefined) updateData.visible = data.visible;
+17
View File
@@ -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<RepairDispatch>;
@InjectRepository(AlarmDispatch)
alarmDispatchModel!: Repository<AlarmDispatch>;
@InjectRepository(InstallOrder)
installModel!: Repository<InstallOrder>;
@@ -503,6 +507,19 @@ export class WorkerAppService {
return { code: 200, message: "已完成" };
}
// 报警派单完成确认:校验归属(不属于当前电工返回 403),幂等(已处理不重复更新)
async completeAlarmDispatch(workerId: number, id: number): Promise<any> {
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 条流水
+104
View File
@@ -0,0 +1,104 @@
/**
* ICC 探测器模拟量数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)。
*
* ICC 返回的原始字段名 → 本系统 electricity_data 字段。接入真实 ICC 平台时
* 仅需调整本文件与 fetch 路径即可,业务层无需改动。纯映射逻辑,无 SQL 无 DOM。
*/
export interface IccAnalogRawRecord {
[field: string]: any;
}
/** 模拟量分页列表接口路径({version} 由调用方按 ICC_VERSION 替换) */
export const ICC_ANALOG_ENDPOINT = "/evo-apigw/evo-fdbu/{version}/analog/subsystem/page";
/** 原始记录关键字段映射:本系统字段 → ICC 返回字段名 */
export const ICC_ANALOG_FIELDS = {
deviceCode: "deviceCode", // 探测器编码(→ electric_line.line_code 幂等键)
uniqueCode: "uniqueCode", // 设备唯一标识码(→ electricity_data.imei)
analogType: "analogType", // 模拟量类型
analogName: "analogName", // 模拟量名称(→ electricity_data.analog_name)
analogUnit: "analogUnit", // 模拟量单位(→ electricity_data.analog_unit)
analogValue: "analogValue", // 模拟量值
reportTime: "reportTime" // 上报/采集时间(yyyy-MM-dd HH:mm:ss,接口文档声明字段,保留映射)
} as const;
/** 字符串 trim;null / undefined / 空串统一归一为 undefined。 */
function toOptionalString(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
const s = String(value).trim();
return s === "" ? undefined : s;
}
/**
* 将 ICC 原始模拟量记录映射为本系统 electricity_data 落库字段。
* analogValue 用 Number 解析,空值/非法值统一归一为 null(可空落库)。
*/
export function mapIccAnalogRecord(raw: IccAnalogRawRecord): {
deviceCode: string;
uniqueCode?: string;
analogType?: string;
analogName?: string;
analogUnit?: string;
analogValue: number | null;
reportTime?: string;
} {
const f = ICC_ANALOG_FIELDS;
// 匹配键保持空串语义,由调用方判跳
const deviceCode = String(raw[f.deviceCode] ?? "").trim();
// 数值解析:空 / null / 非数值统一返回 null
let analogValue: number | null = null;
const rawValue = raw[f.analogValue];
if (rawValue !== undefined && rawValue !== null && String(rawValue).trim() !== "") {
const num = Number(rawValue);
analogValue = Number.isFinite(num) ? num : null;
}
return {
deviceCode,
uniqueCode: toOptionalString(raw[f.uniqueCode]),
analogType: toOptionalString(raw[f.analogType]),
analogName: toOptionalString(raw[f.analogName]),
analogUnit: toOptionalString(raw[f.analogUnit]),
analogValue,
// reportTime 保留映射(接口文档仍声明该字段;当前采集时间以同步时间为准,见同步服务)
reportTime: toOptionalString(raw[f.reportTime])
};
}
/**
* 解析模拟量分页响应(兼容三种形态):
* - payload 为数组 → 直接作为列表
* - payload.data.pageData 为数组 → 取 pageData
* - payload.data.list 为数组 → 取 list
* - 结构不匹配或解析异常 → 返回空列表
*/
export function parseAnalogPage(payload: any): {
list: IccAnalogRawRecord[];
nextPage?: number;
totalCount?: number;
} {
try {
if (Array.isArray(payload)) {
return { list: payload };
}
const page = payload?.data;
if (page && Array.isArray(page.pageData)) {
return {
list: page.pageData,
nextPage: Number(page.nextPage) > 0 ? Number(page.nextPage) : undefined,
totalCount: Number(page.totalCount) > 0 ? Number(page.totalCount) : undefined
};
}
if (page && Array.isArray(page.list)) {
return {
list: page.list,
nextPage: Number(page.nextPage) > 0 ? Number(page.nextPage) : undefined,
totalCount: Number(page.totalCount) > 0 ? Number(page.totalCount) : undefined
};
}
return { list: [] };
} catch {
return { list: [] };
}
}
+18
View File
@@ -0,0 +1,18 @@
import type { BuiltinJob } from "./job_registry";
/**
* 模拟量数据同步任务:从 ICC 平台按 deviceCode 分批拉取探测器最新模拟量,
* 以同步时间作采集时间写入 electricity_data(按 line_code/analog_type/collect_time 幂等),
* 写 icc_sync_log。默认每 5 分钟执行一次。
*/
export const iccAnalogSyncJob: BuiltinJob = {
jobKey: "icc_analog_sync",
jobName: "模拟量数据同步",
description: "拉取 ICC 平台探测器最新模拟量,以同步时间作采集时间写入 electricity_data(写 icc_sync_log)",
defaultCron: "*/5 * * * *",
defaultParams: {},
paramFields: [],
handler: async (_params, ctx) => {
return ctx.runtime.syncIccAnalogs();
}
};
@@ -0,0 +1,17 @@
import type { BuiltinJob } from "./job_registry";
/**
* ICC 事件订阅保活任务:每 30 分钟向 ICC 平台重新注册报警事件订阅(同名覆盖即保活)。
* 失败仅记录日志返回摘要,不抛业务错误(参照 icc_analog_sync 容错约定)。
*/
export const iccEventAlarmKeepaliveJob: BuiltinJob = {
jobKey: "icc_event_alarm_keepalive",
jobName: "ICC事件订阅保活",
description: "向 ICC 平台重新注册报警事件回调订阅(同名覆盖保活,默认每 30 分钟)",
defaultCron: "*/30 * * * *",
defaultParams: {},
paramFields: [],
handler: async (_params, ctx) => {
return ctx.runtime.ensureIccEventSubscribe();
}
};
+103
View File
@@ -0,0 +1,103 @@
/**
* ICC 网关设备数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)。
*
* ICC 返回的原始字段名 → 本系统 electric_gateway 字段。接入真实 ICC 平台时
* 仅需调整本文件与 fetch 路径即可,业务层无需改动。纯映射逻辑,无 SQL 无 DOM。
*/
export interface IccGatewayRawRecord {
[field: string]: any;
}
/** 设备分页列表接口路径({version} 由调用方按 ICC_VERSION 替换) */
export const ICC_GATEWAY_ENDPOINT = "/evo-apigw/evo-fdbu/{version}/device/subsystem/page";
/** 原始记录关键字段映射:本系统字段 → ICC 返回字段名 */
export const ICC_GATEWAY_FIELDS = {
imei: "imei", // 设备唯一标识码(imei)
deviceCode: "deviceCode", // 设备编码
deviceName: "deviceName", // 设备名称
orgCode: "orgCode", // 所属组织编码(→ customer.icc_code 反查客户)
orgName: "orgName", // 所属组织名称
manufacturerCode: "manufacturerCode", // 厂商编码
manufacturerName: "manufacturerName", // 厂商名称
typeCode: "typeCode", // 设备类型编码
typeName: "typeName", // 设备类型名称
productModel: "productModel", // 产品型号
isOnline: "isOnline" // 在线状态
} as const;
/** 离线标记集合:命中即归一化为离线(0),其余(含空)均视为在线(1) */
const OFFLINE_MARKERS = new Set(["0", "02", "false", "no", "off", "离线"]);
/** 字符串 trim;null / undefined / 空串统一归一为 undefined。 */
function toOptionalString(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
const s = String(value).trim();
return s === "" ? undefined : s;
}
/** 将 ICC 原始设备记录映射为本系统 electric_gateway 落库字段(不含联表外键)。 */
export function mapIccGatewayRecord(raw: IccGatewayRawRecord): {
imei: string;
deviceCode: string;
deviceName?: string;
orgCode?: string;
orgName?: string;
manufacturerCode?: string;
manufacturerName?: string;
typeCode?: string;
typeName?: string;
productModel?: string;
isOnline: number;
} {
const result: Record<string, any> = {
// 匹配键保持空串语义,由调用方判跳
imei: String(raw[ICC_GATEWAY_FIELDS.imei] ?? "").trim(),
deviceCode: String(raw[ICC_GATEWAY_FIELDS.deviceCode] ?? "").trim()
};
// 可选字符串字段
for (const key of ["deviceName", "orgCode", "orgName", "manufacturerCode", "manufacturerName", "typeCode", "typeName", "productModel"] as const) {
result[key] = toOptionalString(raw[ICC_GATEWAY_FIELDS[key]]);
}
// 在线状态归一化:离线标记集合 → 0,其余(含空)→ 1
const onlineRaw = String(raw[ICC_GATEWAY_FIELDS.isOnline] ?? "").trim();
result.isOnline = OFFLINE_MARKERS.has(onlineRaw) ? 0 : 1;
return result as any;
}
/**
* 解析设备分页响应(兼容三种形态):
* - payload 为数组 → 直接作为列表
* - payload.data.pageData 为数组 → 取 pageData
* - payload.data.list 为数组 → 取 list
* - 结构不匹配或解析异常 → 返回空列表
*/
export function parseGatewayPage(payload: any): {
list: IccGatewayRawRecord[];
nextPage?: number;
totalCount?: number;
} {
try {
if (Array.isArray(payload)) {
return { list: payload };
}
const page = payload?.data;
if (page && Array.isArray(page.pageData)) {
return {
list: page.pageData,
nextPage: Number(page.nextPage) > 0 ? Number(page.nextPage) : undefined,
totalCount: Number(page.totalCount) > 0 ? Number(page.totalCount) : undefined
};
}
if (page && Array.isArray(page.list)) {
return {
list: page.list,
nextPage: Number(page.nextPage) > 0 ? Number(page.nextPage) : undefined,
totalCount: Number(page.totalCount) > 0 ? Number(page.totalCount) : undefined
};
}
return { list: [] };
} catch {
return { list: [] };
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { BuiltinJob } from "./job_registry";
/**
* 智电网关同步任务:从 ICC 平台拉取智电改造网关设备并按 imei/deviceCode 幂等 upsert,
* 写 icc_sync_log。默认每 10 分钟执行一次。
*/
export const iccGatewaySyncJob: BuiltinJob = {
jobKey: "icc_gateway_sync",
jobName: "智电网关同步",
description: "从 ICC 平台拉取智电改造网关设备并按 imei/deviceCode 幂等 upsert(写 icc_sync_log)",
defaultCron: "*/10 * * * *",
defaultParams: {},
paramFields: [],
handler: async (_params, ctx) => {
return ctx.runtime.syncIccGateways();
}
};
+91 -33
View File
@@ -1,51 +1,109 @@
/**
* ICC 线路数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)
* ICC 智电改造线路(消防探测器)数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)。
*
* ICC 返回的原始字段名 → 本系统 electric_line 字段名。当前为占位映射,
* 接入真实 ICC 平台时仅需调整本文件与 fetch 路径即可,业务层无需改动。
* ICC 返回的原始字段名 → 本系统 electric_line 字段。接入真实 ICC 平台时
* 仅需调整本文件与 fetch 路径即可,业务层无需改动。纯映射逻辑,无 SQL 无 DOM。
*/
export interface IccLineRawRecord {
[field: string]: any;
}
/** 原始记录关键字段名(ICC 平台约定,占位) */
/** 探测器分页列表接口路径({version} 由调用方按 ICC_VERSION 替换) */
export const ICC_LINE_ENDPOINT = "/evo-apigw/evo-fdbu/{version}/detector/subsystem/page";
/** 原始记录关键字段映射:本系统字段 → ICC 返回字段名 */
export const ICC_LINE_FIELDS = {
lineCode: "lineCode", // ICC 线路编码(唯一键)
iccDeviceCode: "deviceCode", // ICC 智电设备编号
equipCode: "equipCode", // 关联的客户设备编码(用于反查 equip_id)
lineName: "lineName", // 线路名称
customerNo: "customerNo", // 关联客户编号(用于反查 customer_id)
buildingName: "buildingName", // 建筑名称(占位,反查 building_id)
floorNo: "floorNo", // 所在楼层
location: "location", // 所在位置
status: "status" // 1在线 0离线
lineCode: "detectorCode", // 探测器编码(唯一幂等键)
lineName: "detectorName", // 探测器名称
imei: "imei", // 探测器 IMEI
orgCode: "orgCode", // 所属组织编码(→ customer.icc_code 反查客户,不落库)
parentCode: "parentCode", // 上级网关编码(→ electric_gateway.device_code 反查网关,不落库)
typeCode: "typeCode", // 探测器类型编码
typeName: "typeName", // 探测器类型名称
fireSystemCode: "fireSystemCode", // 消防系统编码
fireSystemName: "fireSystemName", // 消防系统名称
partUnitLoopCode: "partUnitLoopCode", // 部件/单元/回路编码
channelNo: "channelNo", // 通道号
channelSn: "channelSn", // 通道序列号
produceDate: "produceDate", // 生产日期
detectorStatus: "detectorStatus" // 上报状态 01=在线 02=离线
} as const;
/** 渲染列表路径与 token 鉴权头(占位,接入真实平台时配置 .env)。 */
export const ICC_LINE_ENDPOINT = "/api/grid/lines";
/** 离线标记集合:命中即归一化为离线(0),其余(含空)均视为在线(1) */
export const OFFLINE_MARKERS = new Set(["02", "0", "false", "off", "离线"]);
/** 将 ICC 原始记录映射为本系统 electric_line 落库字段(不含联表外键)。 */
/** 字符串 trim;null / undefined / 空串统一归一为 undefined。 */
export function toOptionalString(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
const s = String(value).trim();
return s === "" ? undefined : s;
}
/** 将 ICC 原始探测器记录映射为本系统 electric_line 落库字段(不含联表外键)。 */
export function mapIccLineRecord(raw: IccLineRawRecord): {
lineCode: string;
iccDeviceCode: string;
equipCode?: string;
lineName?: string;
customerNo?: string;
buildingName?: string;
floorNo?: string;
location?: string;
imei?: string;
orgCode?: string;
parentCode?: string;
typeCode?: string;
typeName?: string;
fireSystemCode?: string;
fireSystemName?: string;
partUnitLoopCode?: string;
channelNo?: string;
channelSn?: string;
produceDate?: string;
status: number;
} {
const f = ICC_LINE_FIELDS;
return {
lineCode: String(raw[f.lineCode] ?? "").trim(),
iccDeviceCode: String(raw[f.iccDeviceCode] ?? "").trim(),
equipCode: raw[f.equipCode] ? String(raw[f.equipCode]).trim() : undefined,
lineName: raw[f.lineName] ? String(raw[f.lineName]).trim() : undefined,
customerNo: raw[f.customerNo] ? String(raw[f.customerNo]).trim() : undefined,
buildingName: raw[f.buildingName] ? String(raw[f.buildingName]).trim() : undefined,
floorNo: raw[f.floorNo] ? String(raw[f.floorNo]).trim() : undefined,
location: raw[f.location] ? String(raw[f.location]).trim() : undefined,
status: Number(raw[f.status]) === 0 ? 0 : 1
const result: Record<string, any> = {
// 匹配键保持空串语义,由调用方判跳
lineCode: String(raw[f.lineCode] ?? "").trim()
};
}
// 可选字符串字段
for (const key of ["lineName", "imei", "orgCode", "parentCode", "typeCode", "typeName", "fireSystemCode", "fireSystemName", "partUnitLoopCode", "channelNo", "channelSn", "produceDate"] as const) {
result[key] = toOptionalString(raw[f[key]]);
}
// 上报状态归一化:离线标记集合 → 0,其余(含空)→ 1
const statusRaw = String(raw[f.detectorStatus] ?? "").trim();
result.status = OFFLINE_MARKERS.has(statusRaw) ? 0 : 1;
return result as any;
}
/**
* 解析探测器分页响应(兼容三种形态):
* - payload 为数组 → 直接作为列表
* - payload.data.pageData 为数组 → 取 pageData
* - payload.data.list 为数组 → 取 list
* - 结构不匹配或解析异常 → 返回空列表
*/
export function parseLinePage(payload: any): {
list: IccLineRawRecord[];
nextPage?: number;
totalCount?: number;
} {
try {
if (Array.isArray(payload)) {
return { list: payload };
}
const page = payload?.data;
if (page && Array.isArray(page.pageData)) {
return {
list: page.pageData,
nextPage: Number(page.nextPage) > 0 ? Number(page.nextPage) : undefined,
totalCount: Number(page.totalCount) > 0 ? Number(page.totalCount) : undefined
};
}
if (page && Array.isArray(page.list)) {
return {
list: page.list,
nextPage: Number(page.nextPage) > 0 ? Number(page.nextPage) : undefined,
totalCount: Number(page.totalCount) > 0 ? Number(page.totalCount) : undefined
};
}
return { list: [] };
} catch {
return { list: [] };
}
}
+10 -1
View File
@@ -3,6 +3,9 @@ import { logCleanJob } from "./log_clean.task";
import { noticeScanJob } from "./notice_scan.task";
import { iccLineSyncJob } from "./icc_line_sync.task";
import { iccCustomerSyncJob } from "./icc_customer_sync.task";
import { iccGatewaySyncJob } from "./icc_gateway_sync.task";
import { iccAnalogSyncJob } from "./icc_analog_sync.task";
import { iccEventAlarmKeepaliveJob } from "./icc_event_keepalive.task";
export type JobParamType = "number" | "string";
@@ -35,6 +38,12 @@ export interface JobRuntime {
syncIccLines(): Promise<string>;
/** 从 ICC 平台拉取 org_base 组织并按 iccCode 幂等新增/变更客户,返回执行摘要 */
syncIccCustomers(): Promise<string>;
/** 从 ICC 平台拉取智电改造网关设备并按 imei/deviceCode 幂等 upsert,返回执行摘要 */
syncIccGateways(): Promise<string>;
/** 从 ICC 平台按 deviceCode 分批拉取探测器模拟量并按 (line_code,analog_type,collect_time) 幂等 upsert,返回执行摘要 */
syncIccAnalogs(): Promise<string>;
/** 向 ICC 平台注册报警事件回调订阅(同名覆盖保活),返回注册结果摘要 */
ensureIccEventSubscribe(): Promise<string>;
}
export interface JobHandlerContext {
@@ -61,7 +70,7 @@ export interface BuiltinJob {
handler: JobHandler;
}
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob, iccLineSyncJob, iccCustomerSyncJob];
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob, iccLineSyncJob, iccCustomerSyncJob, iccGatewaySyncJob, iccAnalogSyncJob, iccEventAlarmKeepaliveJob];
export function getBuiltinJob(jobKey: string): BuiltinJob | undefined {
return BUILTIN_JOBS.find(job => job.jobKey === jobKey);
+4 -4
View File
@@ -4537,10 +4537,10 @@ undici-types@~6.21.0:
resolved "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz"
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
undici@^8.12.2:
version "8.12.2"
resolved "https://registry.yarnpkg.com/undici/-/undici-8.12.2.tgz#e869237430adcc5de6bca798c94edd7db9dac5ac"
integrity sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==
undici@^8.12.3:
version "8.12.3"
resolved "https://registry.yarnpkg.com/undici/-/undici-8.12.3.tgz#e76a91feee9c275ad0f43bfc0eb632b6124d3bb6"
integrity sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==
universalify@^0.1.0:
version "0.1.2"
+33 -34
View File
@@ -196,7 +196,7 @@ footer p{text-align:center;font-size:13px;color:var(--muted)}
<div class="dom-body">
<div class="tbl"><span class="tn">fault_type</span><span class="tc">报警故障类型</span></div>
<div class="tbl"><span class="tn">fault_type_detail</span><span class="tc">故障类型明细</span></div>
<div class="tbl"><span class="tn">device_alarm</span><span class="tc">设备报警(ICC 拉取)</span></div>
<div class="tbl"><span class="tn">device_alarm</span><span class="tc">设备报警(ICC 事件订阅)</span></div>
<div class="tbl"><span class="tn">alarm_dispatch</span><span class="tc">报警派单</span></div>
</div>
</div>
@@ -732,49 +732,44 @@ erDiagram
<tr><td><code>created_at</code></td><td>TIMESTAMPTZ</td><td></td><td>创建时间</td></tr>
</tbody></table></div>
<div class="tbl-title"><code>device_alarm</code><span class="td">— 设备报警信息表(ICC 拉取)</span></div>
<div class="tbl-title"><code>device_alarm</code><span class="td">— 设备报警信息表(ICC 事件订阅)</span></div>
<div class="table-wrap"><table><thead><tr><th>字段</th><th>类型</th><th>键</th><th>说明</th></tr></thead><tbody>
<tr><td><code>id</code></td><td>BIGSERIAL</td><td><span class="pill p-pk">PK</span></td><td>自增主键</td></tr>
<tr><td><code>alarm_code</code></td><td>VARCHAR(128)</td><td>UNIQUE</td><td>报警编码</td></tr>
<tr><td><code>line_id</code></td><td>BIGINT</td><td><span class="pill p-pi">IDX</span></td><td>线路 ID</td></tr>
<tr><td><code>equip_id</code></td><td>BIGINT</td><td></td><td>客户设备 ID</td></tr>
<tr><td><code>icc_device_code</code></td><td>VARCHAR(128)</td><td></td><td>ICC 设备编号</td></tr>
<tr><td><code>alarm_code</code></td><td>VARCHAR(128)</td><td>UNIQUE</td><td>ICC 报警编码(幂等键)</td></tr>
<tr><td><code>uuid</code></td><td>VARCHAR(128)</td><td></td><td>ICC 事件 uuid</td></tr>
<tr><td><code>line_id</code></td><td>BIGINT</td><td><span class="pill p-pi">IDX</span></td><td>智电线路 ID(→ electric_line)</td></tr>
<tr><td><code>line_code</code></td><td>VARCHAR(64)</td><td></td><td>线路编码(electric_line.line_code)</td></tr>
<tr><td><code>line_name</code></td><td>VARCHAR(128)</td><td></td><td>线路名称</td></tr>
<tr><td><code>customer_id</code></td><td>BIGINT</td><td><span class="pill p-pi">IDX</span></td><td>客户 ID</td></tr>
<tr><td><code>building_id</code></td><td>BIGINT</td><td></td><td>所属建筑 ID</td></tr>
<tr><td><code>floor_no</code></td><td>VARCHAR(16)</td><td></td><td>所在楼层</td></tr>
<tr><td><code>location</code></td><td>VARCHAR(256)</td><td></td><td>所在位置</td></tr>
<tr><td><code>alarm_date</code></td><td>DATE</td><td><span class="pill p-pi">IDX</span></td><td>报警日期</td></tr>
<tr><td><code>fault_type</code></td><td>VARCHAR(128)</td><td></td><td>ICC 预警类型</td></tr>
<tr><td><code>alarm_value</code></td><td>NUMERIC(14,2)</td><td></td><td>报警数值</td></tr>
<tr><td><code>history_count</code></td><td>INT</td><td></td><td>历史出现次数</td></tr>
<tr><td><code>synced_at</code></td><td>TIMESTAMPTZ</td><td></td><td>ICC 同步时间</td></tr>
<tr><td><code>customer_id</code></td><td>BIGINT</td><td><span class="pill p-pi">IDX</span></td><td>客户 ID(由 line_id 联出)</td></tr>
<tr><td><code>node_code</code></td><td>VARCHAR(64)</td><td></td><td>探测器节点编码(info.nodeCode)</td></tr>
<tr><td><code>device_code</code></td><td>VARCHAR(64)</td><td></td><td>设备编码(info.deviceCode)</td></tr>
<tr><td><code>device_name</code></td><td>VARCHAR(128)</td><td></td><td>设备名称(info.deviceName)</td></tr>
<tr><td><code>org_code</code></td><td>VARCHAR(64)</td><td></td><td>组织编码(info.orgCode)</td></tr>
<tr><td><code>org_name</code></td><td>VARCHAR(128)</td><td></td><td>组织名称(info.orgName)</td></tr>
<tr><td><code>alarm_type</code></td><td>INT</td><td></td><td>报警类型(info.alarmType)</td></tr>
<tr><td><code>alarm_grade</code></td><td>SMALLINT</td><td><span class="pill p-pi">IDX</span></td><td>报警等级 1/2/3(1 最严重)</td></tr>
<tr><td><code>alarm_stat</code></td><td>SMALLINT</td><td></td><td>报警状态(info.alarmStat)</td></tr>
<tr><td><code>is_event</code></td><td>BOOLEAN</td><td></td><td>是否事件(info.isEvent)</td></tr>
<tr><td><code>alarm_time</code></td><td>TIMESTAMPTZ</td><td><span class="pill p-pi">IDX</span></td><td>报警发生时间(info.alarmDate 秒级时间戳)</td></tr>
<tr><td><code>alarm_value</code></td><td>NUMERIC(14,4)</td><td></td><td>报警数值(部分事件带值,可空)</td></tr>
<tr><td><code>synced_at</code></td><td>TIMESTAMPTZ</td><td></td><td>入库时间</td></tr>
</tbody></table></div>
<div class="tbl-title"><code>alarm_dispatch</code><span class="td">— 报警派单信息表</span></div>
<div class="table-wrap"><table><thead><tr><th>字段</th><th>类型</th><th>键</th><th>说明</th></tr></thead><tbody>
<tr><td><code>id</code></td><td>BIGSERIAL</td><td><span class="pill p-pk">PK</span></td><td>自增主键</td></tr>
<tr><td><code>dispatch_no</code></td><td>VARCHAR(64)</td><td>UNIQUE</td><td>报警派单号</td></tr>
<tr><td><code>alarm_id</code></td><td>BIGINT</td><td><span class="pill p-fk">FK</span></td><td>报警 ID(→ device_alarm)</td></tr>
<tr><td><code>dispatch_no</code></td><td>VARCHAR(64)</td><td>UNIQUE</td><td>报警派单号(如 AD20261024001)</td></tr>
<tr><td><code>alarm_id</code></td><td>BIGINT</td><td><span class="pill p-pi">IDX</span></td><td>报警 ID(→ device_alarm)</td></tr>
<tr><td><code>line_id</code></td><td>BIGINT</td><td></td><td>线路 ID</td></tr>
<tr><td><code>equip_id</code></td><td>BIGINT</td><td></td><td>客户设备 ID</td></tr>
<tr><td><code>icc_device_code</code></td><td>VARCHAR(128)</td><td></td><td>ICC 设备编号</td></tr>
<tr><td><code>line_code</code></td><td>VARCHAR(64)</td><td></td><td>线路编码</td></tr>
<tr><td><code>line_name</code></td><td>VARCHAR(128)</td><td></td><td>线路名称</td></tr>
<tr><td><code>customer_id</code></td><td>BIGINT</td><td><span class="pill p-fk">FK</span></td><td>客户 ID</td></tr>
<tr><td><code>contact_person</code></td><td>VARCHAR(64)</td><td></td><td>联系人</td></tr>
<tr><td><code>contact_phone</code></td><td>VARCHAR(20)</td><td></td><td>电话</td></tr>
<tr><td><code>building_id</code></td><td>BIGINT</td><td></td><td>建筑 ID</td></tr>
<tr><td><code>floor_no</code></td><td>VARCHAR(16)</td><td></td><td>所在楼层</td></tr>
<tr><td><code>location</code></td><td>VARCHAR(256)</td><td></td><td>所在位置</td></tr>
<tr><td><code>fault_type_id</code></td><td>BIGINT</td><td><span class="pill p-fk">FK</span></td><td>故障类型 ID(→ fault_type)</td></tr>
<tr><td><code>fault_desc</code></td><td>TEXT</td><td></td><td>故障事项(类型+数值)</td></tr>
<tr><td><code>dispatcher</code></td><td>VARCHAR(64)</td><td></td><td>派发人</td></tr>
<tr><td><code>fault_reason</code></td><td>TEXT</td><td></td><td>故障原因(来自明细)</td></tr>
<tr><td><code>process_flow</code></td><td>TEXT</td><td></td><td>处理流程(来自明细)</td></tr>
<tr><td><code>worker_id</code></td><td>BIGINT</td><td><span class="pill p-pi">IDX</span></td><td>共享电工 ID</td></tr>
<tr><td><code>customer_id</code></td><td>BIGINT</td><td><span class="pill p-fk">FK</span></td><td>客户 ID(→ customer)</td></tr>
<tr><td><code>alarm_time</code></td><td>TIMESTAMPTZ</td><td></td><td>报警时间(快照)</td></tr>
<tr><td><code>alarm_grade</code></td><td>SMALLINT</td><td></td><td>报警等级(快照)</td></tr>
<tr><td><code>fault_desc</code></td><td>TEXT</td><td></td><td>故障事项(报警类型描述)</td></tr>
<tr><td><code>dispatcher</code></td><td>VARCHAR(64)</td><td></td><td>派发人(当前登录用户 realName)</td></tr>
<tr><td><code>worker_id</code></td><td>BIGINT</td><td><span class="pill p-pi">IDX</span></td><td>共享电工 ID(→ worker)</td></tr>
<tr><td><code>status</code></td><td>SMALLINT</td><td><span class="pill p-pi">IDX</span></td><td>状态:0已派发 1已处理</td></tr>
<tr><td><code>process_photos</code></td><td>JSONB</td><td></td><td>报警处理照片(多张)</td></tr>
<tr><td><code>rating</code></td><td>SMALLINT</td><td></td><td>派单处理评分</td></tr>
<tr><td><code>created_at</code></td><td>TIMESTAMPTZ</td><td></td><td>创建时间</td></tr>
<tr><td><code>completed_at</code></td><td>TIMESTAMPTZ</td><td></td><td>完成时间</td></tr>
</tbody></table></div>
@@ -971,9 +966,13 @@ erDiagram
<tr><td>electric_line</td><td><code>idx_line_customer</code></td><td>B-Tree</td><td>按客户查线路(逻辑隔离)</td></tr>
<tr><td>electric_line</td><td><code>idx_line_icc</code></td><td>B-Tree</td><td>ICC 设备码匹配同步</td></tr>
<tr><td>electricity_data</td><td><code>idx_ed_customer_time</code></td><td>B-Tree 组合</td><td>按客户+时间查用电(月报核心查询)</td></tr>
<tr><td>device_alarm</td><td><code>idx_alarm_date</code></td><td>B-Tree</td><td>按报警日期查询</td></tr>
<tr><td>device_alarm</td><td><code>idx_alarm_time</code></td><td>B-Tree DESC</td><td>按报警时间倒序(列表默认排序)</td></tr>
<tr><td>device_alarm</td><td><code>idx_alarm_line</code></td><td>B-Tree</td><td>按线路查报警</td></tr>
<tr><td>device_alarm</td><td><code>idx_alarm_customer</code></td><td>B-Tree</td><td>按客户查报警</td></tr>
<tr><td>device_alarm</td><td><code>idx_alarm_grade</code></td><td>B-Tree</td><td>按报警等级过滤(严重报警)</td></tr>
<tr><td>alarm_dispatch</td><td><code>idx_alarm_disp_status</code></td><td>B-Tree</td><td>按派单状态过滤</td></tr>
<tr><td>alarm_dispatch</td><td><code>idx_alarm_disp_worker</code></td><td>B-Tree</td><td>电工查自己的派单(接口数据权限)</td></tr>
<tr><td>alarm_dispatch</td><td><code>idx_alarm_disp_alarm</code></td><td>B-Tree</td><td>按报警查派单</td></tr>
<tr><td>repair_dispatch</td><td><code>idx_repair_disp_worker</code></td><td>B-Tree</td><td>电工查报修派单</td></tr>
<tr><td>sys_dict</td><td><code>idx_sys_dict_type</code></td><td>B-Tree</td><td>按字典类型查询</td></tr>
<tr><td>icc_sync_log</td><td><code>idx_icc_sync_time</code></td><td>B-Tree</td><td>按时间查同步日志</td></tr>
+128
View File
@@ -15,3 +15,131 @@
| <br /> | <br /> | <br /> | <br /> |
| <br /> | <br /> | <br /> | <br /> |
智电改造线路表:electric_line(ICC探测器同步)
| 字段 | 类型 | 键 | 说明 |
| :------------------ | :----------- | :----- | :------------------------------------------------------- |
| id | BIGSERIAL | PK | 自增主键 |
| line_code | VARCHAR(64) | UNIQUE | 探测器编码(ICC detectorCode,唯一幂等键) |
| equip_id | BIGINT | FK | 客户设备 ID(→ customer_equipment,可空,同步不填充) |
| gateway_id | BIGINT | FK | 网关设备 ID(→ electric_gateway,由 parentCode↔device_code 匹配) |
| customer_id | BIGINT | FK | 客户 ID(orgCode→customer.icc_code 解析,逻辑隔离键) |
| line_name | VARCHAR(128) | | 探测器名称(ICC detectorName) |
| imei | VARCHAR(64) | | 设备唯一标识码(ICC imei) |
| type_code | VARCHAR(32) | | 探测器类型编码 |
| type_name | VARCHAR(64) | | 探测器类型名称 |
| fire_system_code | VARCHAR(64) | | 消防系统编码 |
| fire_system_name | VARCHAR(128) | | 消防系统名称 |
| part_unit_loop_code | VARCHAR(64) | | 探测器回路号 |
| channel_no | VARCHAR(64) | | 探测器通道号 |
| channel_sn | VARCHAR(64) | | 通道唯一标识码 |
| produce_date | VARCHAR(16) | | 生产日期(yyyy-MM-dd) |
| status | SMALLINT | | 探测器状态:1在线 0离线(detectorStatus 01→1 / 02→0) |
| synced_at | TIMESTAMPTZ | | ICC 同步时间 |
| created_at | TIMESTAMPTZ | | 创建时间 |
electricity_data 表结构重建(ICC 模拟量同步)
原 `electricity_data`(线路用电信息表)仅有一版占位定义(单列 `electricity`),与 ICC 平台「消防最新模拟量信息分页查询」接口(`POST /evo-apigw/evo-fdbu/{version}/analog/subsystem/page`,实际返回 `AnalogInfoVO{deviceCode, uniqueCode, analogType, analogValue, analogUnit, analogName}`,无 reportTime)不对应,本次按接口返回数据重建表结构,并实现真实同步服务与定时任务。
| 字段 | 类型 | 键 | 说明 |
| :---------- | :------------ | :----------------- | :---------------------------------------------- |
| id | BIGSERIAL | PK(复合) | 自增主键(与 collect_time 组成复合主键) |
| line_id | BIGINT NOT NULL | FK | 线路 ID(→ electric_line.id) |
| line_code | VARCHAR(64) NOT NULL | UNIQUE(复合) | 线路编码(= ICC deviceCode,冗余 + 幂等键) |
| imei | VARCHAR(64) | | 设备唯一标识码(= ICC uniqueCode) |
| line_name | VARCHAR(128) | | 线路名称(冗余展示) |
| customer_id | BIGINT NOT NULL | | 客户 ID(逻辑隔离键 → customer.id) |
| analog_type | VARCHAR(64) | | 模拟量类型(= ICC analogType) |
| analog_name | VARCHAR(128) | | 模拟量名称(= ICC analogName,如"剩余电流") |
| analog_unit | VARCHAR(32) | | 模拟量单位(= ICC analogUnit,如"mA") |
| analog_value | NUMERIC(14,4) | | 模拟量值(= ICC analogValue) |
| collect_time | TIMESTAMPTZ NOT NULL | PK(复合)/分区键 | 采集时间 = 同步拉取时间(ICC 最新模拟量接口实际不返回 reportTime,经确认以同步时间作为分区键与采集时间) |
键与分区:
- 主键:`(id, collect_time)`(复合主键,兼容按月分区)
- 唯一键:`UNIQUE(line_code, analog_type, collect_time)`,作幂等键(同键重复上报覆盖更新)
- 分区:按 `collect_time` 按月 RANGE 分区,初始分区 `2026_10`~`2027_03`(`electricity_data_2026_10`~`electricity_data_2027_03`,共 6 个),后续按月自动/手动创建
- 索引:`idx_ed_customer(customer_id)`、`idx_ed_line(line_id)`、`idx_ed_time(collect_time)`、`idx_ed_customer_time(customer_id, collect_time)`(父表创建,自动继承到各分区)
关联关系:
- `line_id` → `electric_line.id`(再由 electric_line 关联 customer 与 customer_equipment)
- `customer_id` → `customer.id`(逻辑隔离键)
- **明确注明:按需求确认,`equip_id` 字段已删除,本表不再直接关联 customer_equipment。**
变更说明:
- 删除旧占位列 `electricity`(重建为通用模拟量行:analog_type / analog_value)与 `equip_id`
- 原 `icc_device_code` 更名 `imei`(映射 uniqueCode)
- 迁移文件:`database/migrations/20261024_icc_analog_data.sql`(建表/分区/索引/注释 + sys_job 种子,2026-10-24 已执行到生产库),`database/db_init.sql` 同步更新
同步服务说明:
- 默认每 5 分钟执行一次;复用 ICC OAuth2 鉴权(`getIccAccessToken`/`iccFetch`)
- 先读取已同步 `electric_line` 的 line_code(= detectorCode),按 deviceCodes 分批(每批 ≤50 个)POST 模拟量接口(pageSize=500,短页停止全量拉取)
- 字段映射:imei=uniqueCode、analog_type=analogType、analog_name=analogName、analog_unit=analogUnit、analog_value=analogValue;collect_time = 同步拉取时间(ICC 最新模拟量接口实际不返回 reportTime,经确认以同步时间作为分区键与采集时间);预取 deviceCode→(line_id, customer_id) 映射避免 N+1
- 按 `(line_code, analog_type, collect_time)` 幂等 upsert(同一次同步内重复则覆盖更新);写 `icc_sync_log`(sync_type=electricity_data);拉取失败仅记日志不抛业务错
定时任务:
- jobKey=`icc_analog_sync`,cron `*/5 * * * *`(默认每 5 分钟,sys_job 种子幂等,注册进 job_registry/JobRuntime)
- 手动同步接口:`POST /api/electricity-data/sync`(JWT+RBAC `device:list`,返回 pulled/inserted/updated/skipped/synced)
- 不做前端页面/菜单
2026-10-24 ICC 报警事件订阅
`device_alarm`(设备报警信息表)与 `alarm_dispatch`(报警派单信息表)原为旧设计(含 equip_id/building_id/floor_no/location/history_count/fault_type/alarm_date 等与 ICC 事件结构不符的字段),本次按 ICC 平台事件订阅推送的通用事件格式重建。两表均为空表,已 DROP 重建。迁移文件:`database/migrations/20261024_icc_event_alarm.sql`(2026-10-24 已执行到生产库),`database/db_init.sql` 同步更新。
device_alarm 表(设备报警信息表,ICC 事件订阅)
| 字段 | 类型 | 键 | 说明 |
| :---------- | :------------ | :----- | :-------------------------------------------------- |
| id | BIGSERIAL | PK | 自增主键 |
| alarm_code | VARCHAR(128) | UNIQUE | ICC 报警编码(幂等键) |
| uuid | VARCHAR(128) | | ICC 事件 uuid |
| line_id | BIGINT | IDX | 智电线路 ID(→ electric_line,nodeCode/deviceCode 匹配 line_code) |
| line_code | VARCHAR(64) | | 线路编码(electric_line.line_code) |
| line_name | VARCHAR(128) | | 线路名称 |
| customer_id | BIGINT | IDX | 客户 ID(由 line_id 联出) |
| node_code | VARCHAR(64) | | 探测器节点编码(info.nodeCode) |
| device_code | VARCHAR(64) | | 设备编码(info.deviceCode) |
| device_name | VARCHAR(128) | | 设备名称(info.deviceName) |
| org_code | VARCHAR(64) | | 组织编码(info.orgCode) |
| org_name | VARCHAR(128) | | 组织名称(info.orgName) |
| alarm_type | INT | | 报警类型(info.alarmType) |
| alarm_grade | SMALLINT | IDX | 报警等级 1/2/3(1 最严重) |
| alarm_stat | SMALLINT | | 报警状态(info.alarmStat) |
| is_event | BOOLEAN | | 是否事件(info.isEvent) |
| alarm_time | TIMESTAMPTZ | IDX | 报警发生时间(info.alarmDate 秒级时间戳) |
| alarm_value | NUMERIC(14,4) | | 报警数值(部分事件带值,可空) |
| synced_at | TIMESTAMPTZ | | 入库时间 |
alarm_dispatch 表(报警派单信息表)
| 字段 | 类型 | 键 | 说明 |
| :----------- | :------------ | :----- | :-------------------------------------- |
| id | BIGSERIAL | PK | 自增主键 |
| dispatch_no | VARCHAR(64) | UNIQUE | 报警派单号(如 AD20261024001,生成规则由后端实现) |
| alarm_id | BIGINT | IDX | 报警 ID(→ device_alarm) |
| line_id | BIGINT | | 线路 ID |
| line_code | VARCHAR(64) | | 线路编码 |
| line_name | VARCHAR(128) | | 线路名称 |
| customer_id | BIGINT | FK | 客户 ID(→ customer) |
| alarm_time | TIMESTAMPTZ | | 报警时间(快照) |
| alarm_grade | SMALLINT | | 报警等级(快照) |
| fault_desc | TEXT | | 故障事项(报警类型描述) |
| dispatcher | VARCHAR(64) | | 派发人(当前登录用户 realName) |
| worker_id | BIGINT | IDX | 共享电工 ID(→ worker) |
| status | SMALLINT | IDX | 状态:0已派发 1已处理 |
| created_at | TIMESTAMPTZ | | 创建时间 |
| completed_at | TIMESTAMPTZ | | 完成时间 |
订阅机制:
- 回调接口:`POST /api/icc-event/callback`,接收 ICC 平台事件订阅推送的报警事件,按 `alarm_code` 幂等入库 device_alarm(重复事件覆盖更新)
- 保活任务:每 30 分钟执行一次(向 ICC 平台上报回调地址,保活事件订阅)
- 字段映射:info.nodeCode→node_code、info.deviceCode→device_code、info.deviceName→device_name、info.orgCode→org_code、info.orgName→org_name、info.alarmType→alarm_type、info.alarmGrade→alarm_grade、info.alarmStat→alarm_stat、info.isEvent→is_event、info.alarmDate(秒级时间戳)→alarm_time;line_id/line_code/customer_id 由 nodeCode/deviceCode 匹配 electric_line.line_code 联出
- 报警派单:从 device_alarm 派单写入 alarm_dispatch,dispatch_no 生成规则由后端实现(如 AD20261024001)