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:
commit
6aea23c070
72 files changed
+6226
-609
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 行);无前端改动
|
||||
@@ -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)。
|
||||
@@ -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 全绿;模拟回调端到端验证通过(入库→页面展示→报警灯/声音→发起派单→站内通知→台账可见)
|
||||
@@ -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 行);无前端改动
|
||||
|
||||
@@ -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、菜单与页面渲染正常 —— 全部实测通过
|
||||
@@ -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
|
||||
(无既有功能被修改)
|
||||
@@ -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;浏览器页面新列渲染正常
|
||||
@@ -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 重写。
|
||||
@@ -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] 浏览器菜单管理页:编辑名称保存成功、树实时刷新、刷新页面后名称保持;新增菜单可用
|
||||
@@ -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
|
||||
(无)
|
||||
@@ -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
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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 {
|
||||
@@ -203,3 +212,89 @@ 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 } }),
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
.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>
|
||||
@@ -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
@@ -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==
|
||||
|
||||
+93
-117
@@ -616,33 +616,45 @@ 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),
|
||||
equip_id BIGINT,
|
||||
gateway_id BIGINT,
|
||||
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
||||
building_id BIGINT REFERENCES customer_building(id),
|
||||
floor_no VARCHAR(16),
|
||||
location VARCHAR(256),
|
||||
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 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()
|
||||
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,
|
||||
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);
|
||||
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_id BIGINT NOT NULL REFERENCES electric_line(id),
|
||||
line_code VARCHAR(64) NOT NULL,
|
||||
equip_id BIGINT,
|
||||
icc_device_code VARCHAR(128),
|
||||
imei VARCHAR(64),
|
||||
line_name VARCHAR(128),
|
||||
customer_id BIGINT NOT NULL,
|
||||
electricity NUMERIC(14,4),
|
||||
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)
|
||||
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 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 '线路编码(冗余,加速查询)';
|
||||
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.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.electricity IS '用电量';
|
||||
COMMENT ON COLUMN electricity_data.collect_time IS '采集时间(分区键)';
|
||||
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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
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}`);
|
||||
throw new Error(`ICC 探测器接口返回异常:HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
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 page = parseLinePage(payload);
|
||||
allRaw.push(...page.list);
|
||||
result.pulled += page.list.length;
|
||||
|
||||
const { inserted, updated, skipped } = await this.batchUpsert(rawList);
|
||||
// 短页判定:本页不满 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? ""}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 条流水
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user