Compare commits
18
Commits
737244fa9e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d55947762 | ||
|
|
7ff2405634 | ||
|
|
44fc9a1d54 | ||
|
|
c8a4330f3b | ||
|
|
c92fc7f298 | ||
|
|
160ad85051 | ||
|
|
cf0a0d4d31 | ||
|
|
9d3bb13ecf | ||
|
|
be91d04431 | ||
|
|
184b8d03e2 | ||
|
|
22d27a940d | ||
|
|
41765cdf44 | ||
|
|
f72644c24f | ||
|
|
3596462eca | ||
|
|
f8117e33b6 | ||
|
|
850cd92584 | ||
|
|
77891bf4f2 | ||
|
|
d12fa91ec2 |
No files matched your search
@@ -0,0 +1,121 @@
|
|||||||
|
# 空间拓扑与用电架构 · 实现计划
|
||||||
|
|
||||||
|
## Context(为什么做)
|
||||||
|
|
||||||
|
现有「空间拓扑与用电架构」页面(`client/src/views/customer/topology/index.vue`)只实现了「客户 → 建筑 → 房间」三层树 + 建筑/房间增删改,**"用电架构"部分完全缺失**:数据库与后端没有任何承载"用电回路"的表或接口,页面上也没有配电拓扑可视化。
|
||||||
|
|
||||||
|
设计稿 `系统设计/UI/空间拓扑与用电架构/code.html` 定义了三栏工业级布局(左空间树 / 中配电单线拓扑流 / 右节点属性面板)。本次目标:在新表 + 新后端接口的基础上,把该页面补齐为三栏结构,让"空间"与"用电回路"打通。
|
||||||
|
|
||||||
|
## 已确认的边界(用户决策)
|
||||||
|
|
||||||
|
| 项 | 结论 |
|
||||||
|
| ------ | ---------------------------------------- |
|
||||||
|
| 目标形态 | 按设计稿做完整三栏(左树 / 中单线图 / 右属性面板) |
|
||||||
|
| 左树层级 | 客户 → 建筑 → **楼层** → 房间 → **回路**(不要"配电箱"层) |
|
||||||
|
| 楼层 | 由房间/回路按 `floor_no` 分组生成的**虚拟层**,不建表 |
|
||||||
|
| 中间图上层 | 进线柜 / 变压器:**只读展示**,不落库、不做 CRUD |
|
||||||
|
| 中间图末端 | **回路卡片**(一个回路一张卡) |
|
||||||
|
| 实时电气量 | 电压/电流/功率/温度等**一律占位**(显示 `—`),不造假数据、不接采集 |
|
||||||
|
| ICC 映射 | **不做**(设计稿里的网关层、ICC 设备码、告警阈值联动均不实现) |
|
||||||
|
| 覆盖端 | 仅 Web 管理端 `client/` |
|
||||||
|
| 数据来源 | 新建表 + 后端 CRUD 接口 |
|
||||||
|
|
||||||
|
## 数据模型(新增 1 张表)
|
||||||
|
|
||||||
|
`customer_circuit`(客户用电回路)—— 参照 `customer_room` 的建表风格(`database/db_init.sql#L364-L387`):
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| -------------- | --------------------- | ----------------------------------- |
|
||||||
|
| id | BIGSERIAL PK | <br /> |
|
||||||
|
| customer\_id | BIGINT NOT NULL | → `customer(id)` |
|
||||||
|
| building\_id | BIGINT NOT NULL | → `customer_building(id)` |
|
||||||
|
| room\_id | BIGINT NULL | → `customer_room(id)`,允许挂建筑/楼层而不挂房间 |
|
||||||
|
| floor\_no | VARCHAR(16) | 楼层(冗余,便于按楼层分组) |
|
||||||
|
| circuit\_code | VARCHAR(64) NOT NULL | 回路编号,全局唯一 |
|
||||||
|
| circuit\_name | VARCHAR(128) NOT NULL | 回路名称 |
|
||||||
|
| loop\_spec | VARCHAR(128) | 回路规格(如 `三相380V / 铜芯4×25+1×16`) |
|
||||||
|
| load\_type | VARCHAR(32) | 负荷类型(照明/动力/空调/插座…) |
|
||||||
|
| rated\_voltage | VARCHAR(32) | 额定电压(配置值) |
|
||||||
|
| rated\_current | VARCHAR(32) | 额定电流(配置值) |
|
||||||
|
| rated\_power | NUMERIC(10,2) | 额定功率 kW(配置值) |
|
||||||
|
| status | SMALLINT DEFAULT 1 | 1 正常 / 0 停用 |
|
||||||
|
| remark | TEXT | 备注 |
|
||||||
|
| sort\_order | INT DEFAULT 0 | 排序 |
|
||||||
|
| created\_at | TIMESTAMP | <br /> |
|
||||||
|
|
||||||
|
索引:`customer_id`、`building_id`、`room_id`、唯一键 `circuit_code`。
|
||||||
|
|
||||||
|
> 命名跟随现有习惯:表 `customer_circuit`、entity `CustomerCircuit`、路由 `/api/customer-circuits`、权限码 `customer:circuit`(与 `customer:building` / `customer:room` 同级)。
|
||||||
|
|
||||||
|
## 后端改动(Midway.js + sequelize-typescript)
|
||||||
|
|
||||||
|
参照样板:`customer_room` 全链路。
|
||||||
|
|
||||||
|
1. **迁移**:`database/migrations/20261011_customer_circuit.sql`(幂等 `CREATE TABLE IF NOT EXISTS`),并在 `database/db_init.sql` 同步该表定义。
|
||||||
|
2. **Entity**:`server/src/entity/customer_circuit.entity.ts`(照抄 `customer_building.entity.ts` 的 `@Table/@Column/@BelongsTo/@CreatedAt` 写法)。
|
||||||
|
3. **DTO**:`server/src/dto/customer_circuit.dto.ts`(Query/Create/Update,class-validator 风格照抄 `customer_room.dto.ts`)。
|
||||||
|
4. **Service**:`server/src/service/customer_circuit.service.ts`
|
||||||
|
|
||||||
|
* `list` / `detail` / `create` / `update` / `delete`(照抄 `customer_room.service.ts`,统一返回 `{ code, message, data }`)。
|
||||||
|
|
||||||
|
* `topology(customerId)`:**一次请求**返回整棵「建筑 → 楼层 → 房间 → 回路」结构,避免前端 N+1(现有页面是逐建筑拉房间)。返回形如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "buildings": [ { "id":1, "buildingName":"…", "floors":[ { "floorNo":"2", "rooms":[ { "id":9, "roomName":"…", "circuits":[…] } ], "circuits":[…] } ] } ] }
|
||||||
|
```
|
||||||
|
5. **Controller**:`server/src/controller/customer_circuit.controller.ts`(`@Controller("/api/customer-circuits")`,含 `GET /topology`)。
|
||||||
|
6. **权限注册**:`server/src/middleware/rbac.middleware.ts` 的 `PERMISSION_MAP` 新增 `"/api/customer-circuits": "customer:circuit"`。
|
||||||
|
7. **菜单/权限种子**:在 `database/db_seed.sql` 复用现有「空间拓扑与用电架构」菜单(id=32, `/customer/topology`, 权限码 `customer:topology`),并**参照** **`customer:room`** **的登记方式**新增按钮级权限 `customer:circuit`。
|
||||||
|
|
||||||
|
## 前端改动(Vue3 + Ant Design Vue)
|
||||||
|
|
||||||
|
1. **新增 API 封装** `client/src/api/customerCircuit.ts`:照抄 `customerRoom.ts` 风格(`getList/getDetail/create/update/delete/getTopology` + 类型定义 + 常量:负荷类型选项)。
|
||||||
|
2. **重写** `client/src/views/customer/topology/index.vue` 为三栏:
|
||||||
|
|
||||||
|
* **顶部**:面包屑 + 客户切换器 + 指标条 + 「新增回路」按钮。指标只展示**可由现有数据算出**的项(已接入建筑数、监测用电回路数、空间建模覆盖率 = 有回路的房间数/房间总数);无数据源的项(变电所/配电室、ICC 边缘网关)**不展示**。
|
||||||
|
|
||||||
|
* **左栏(≈280px)**:`a-tree` 渲染「客户 → 建筑 → 楼层 → 房间 → 回路」;节点徽章显示回路数;节点悬停操作:建筑/房间沿用现有增删改(`customerBuildingApi` / `customerRoomApi`),回路可新增/编辑/删除。
|
||||||
|
|
||||||
|
* **中栏**:单线图。上层为**只读示意**(10kV 进线柜 → 变压器 → 低压母线,按客户/建筑信息填充文案)、下层为**回路卡片网格**(编号/名称/所属房间/规格/额定参数,点击选中;实时电气量显示 `—`)。用 div 网格 + 内联 SVG 母线渲染(与设计稿一致),**不引入新依赖**(echarts 已在依赖中但不必要)。
|
||||||
|
|
||||||
|
* **右栏(≈320px)**:选中节点的属性面板(回路基本信息可编辑并保存;房间/建筑节点展示只读属性)。
|
||||||
|
|
||||||
|
* 样式沿用页面既有基线:背景 `#eef3f8`、主色 `#0054cd`、卡片圆角 `12px`、等宽字体用 `Consolas/Menlo`。
|
||||||
|
3. **路由/权限**:沿用现有 `customer/topology` 路由与 `customer:topology` 菜单,**不新增页面路由**。
|
||||||
|
|
||||||
|
## 不做(明确排除)
|
||||||
|
|
||||||
|
* ICC 网关节点、ICC 设备码映射、告警阈值联动、下发同步
|
||||||
|
|
||||||
|
* 实时电气量采集与展示(一律 `—`)
|
||||||
|
|
||||||
|
* 「从 ICC 同步拓扑」「CAD/BIM 导入」「导出高清拓扑图」按钮 → 先不实现(可留占位,若留则点击提示"暂未开放")
|
||||||
|
|
||||||
|
* 「空间三维映射」视图切换
|
||||||
|
|
||||||
|
* 配电箱/配电柜层
|
||||||
|
|
||||||
|
* Flutter 管理端
|
||||||
|
|
||||||
|
## 假设(如不认可请指出)
|
||||||
|
|
||||||
|
1. 中间图的上层(进线柜/变压器)**纯前端静态示意**,文案按所选客户/建筑动态填充,不落库。
|
||||||
|
2. `circuit_code` **全局唯一**(与设备台账 `equip_code` 一致);若你希望"客户内唯一",需改唯一约束。
|
||||||
|
3. 回路可以**不挂房间**(只挂建筑+楼层),以适应公共区域回路。
|
||||||
|
4. 顶部指标只做能算出来的 3 项,其余留白而非显示假数据。
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
1. 执行迁移:`database/migrations/20261011_customer_circuit.sql`(以及 db\_seed 的权限登记),确认表与权限数据落库。
|
||||||
|
2. 后端:启动 `server/`,用带 token 的请求验证 `GET /api/customer-circuits/topology?customerId=1`、以及 create/update/delete;无 token 应 401、无权限应 403。
|
||||||
|
3. 前端:`client/` 执行 `npm run check`(vue-tsc)通过;`npm run dev` 后进入「客户管理 → 空间拓扑与用电架构」:
|
||||||
|
|
||||||
|
* 选客户 → 左树按「建筑 → 楼层 → 房间 → 回路」正确展开;
|
||||||
|
|
||||||
|
* 新增/编辑/删除回路后树与中间卡片同步刷新;
|
||||||
|
|
||||||
|
* 点击回路卡片 → 右栏属性正确回填、可保存;
|
||||||
|
|
||||||
|
* 中间图上层的进线柜/变压器为只读示意。
|
||||||
|
4. 回归:确认原有建筑/房间增删改仍可用(房间功能未被破坏)。
|
||||||
|
|
||||||
@@ -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,12 @@
|
|||||||
|
# Checklist
|
||||||
|
|
||||||
|
- [x] customer 表迁移与 db_init.sql 均包含 icc_code 字段与唯一索引,且结构一致(migrations/20261016_icc_customer_sync.sql ↔ db_init.sql L194/L232/L213 ↔ customer.entity.ts)
|
||||||
|
- [x] customer 实体含 iccCode 字段定义(field=icc_code,STRING(64),可空)
|
||||||
|
- [x] ICC OAuth2 鉴权客户端可获取公钥并完成 RSA 加密、换取 access_token,token 有进程内缓存与过期续签(icc_customer_auth.ts,RSA 分块加解密冒烟验证通过)
|
||||||
|
- [x] 字段映射器正确映射 orgCode→iccCode、orgName→customerName、province/city/county,并解析分页响应(icc_customer_mapper.ts,冒烟验证映射与 PageVO/数组兼容解析均正确)
|
||||||
|
- [x] 同步服务按 org_base 过滤、分页全量拉取(maxRangeId=0、pageSize=1000 循环至末页),按 icc_code 幂等 upsert
|
||||||
|
- [x] 新增客户自动生成 customer_no(复用 CustomerService.generateCustomerNo,KH+YYYYMMDD+3位序号);已存在客户先比较四个映射字段,仅不一致才更新,其余字段保持不变
|
||||||
|
- [x] 每次同步写 icc_sync_log(sync_type=customer,status/record_count/error_message),失败路径仅记日志不抛业务错误
|
||||||
|
- [x] 定时任务已注册(job_registry L5/L37/L64 + db_seed.sql L411 sys_job 种子),默认 cron `*/10 * * * *`,调度器运行时已接线(job_scheduler.service.ts syncIccCustomers)
|
||||||
|
- [x] server/.env 含完整 ICC 配置键(BASE_URL/VERSION/CLIENT_ID/CLIENT_SECRET/USERNAME/PASSWORD)
|
||||||
|
- [x] `yarn build` 编译通过(mapper 修复后复验通过),任务代码路径(task → runtime → service)类型正确可运行;端到端写库同步建议在部署环境首次执行时由真实 ICC 接口确认
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# ICC 客户信息同步(org_base)Spec
|
||||||
|
|
||||||
|
## Why
|
||||||
|
客户档案目前依赖手工维护,鑫潼 ICC 平台已存在基础组织(org_base)客户主数据。需按 ICC 平台接口文档,通过定时任务从 ICC 拉取 org_base 组织数据,按字段映射对 customer 客户表执行新增/变更,实现客户主数据自动化同步。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
- **新增** `customer` 表 `icc_code` 字段(ICC 客户编码,唯一索引),作为 ICC 同步的幂等匹配键;同步修改实体、迁移脚本与 db_seed.sql(幂等一致)
|
||||||
|
- **新增** ICC OAuth2 密码模式鉴权客户端:获取公钥 → RSA(ECB/PKCS1) 加密密码 → `extend/token` 换取 access_token(2h 有效,进程内缓存过期自动续签)
|
||||||
|
- **新增** `IccCustomerSyncService`:从 `.env` 读取 ICC 配置,分页全量拉取 `organization/page`(`maxRangeId=0`,过滤 `orgType=org_base`),按字段映射对 customer 幂等 upsert(已存在记录先比较映射字段,不一致才变更),写 `icc_sync_log`
|
||||||
|
- **新增** 定时任务:注册 `icc_customer_sync.task` 到任务注册表与 `sys_job`(默认每 10 分钟,可配置)
|
||||||
|
- **安全**:全部 SQL 走 Sequelize 参数绑定;接口凭据经 `.env` 读取,不硬编码到代码;ICC 拉取失败仅记录 `icc_sync_log` 错误日志,不抛业务错误
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- Affected specs: ICC 数据同步(含现有 icc_line_sync 模式)、客户管理(customer 主数据)
|
||||||
|
- Affected code:
|
||||||
|
- server/src/entity/customer.entity.ts(新增 iccCode)
|
||||||
|
- server/src/task/icc_customer_mapper.ts(新增:字段映射集中配置)
|
||||||
|
- server/src/service/icc_customer_sync.service.ts(新增:OAuth2 鉴权 + 拉取 + upsert + 日志)
|
||||||
|
- server/src/task/icc_customer_sync.task.ts(新增:定时任务定义)
|
||||||
|
- server/src/task/job_registry.ts、server/src/service/job_scheduler.service.ts(注册任务与运行时)
|
||||||
|
- server/.env(新增 ICC 凭据配置项)
|
||||||
|
- database/migrations/(新增迁移文件)、database/db_seed.sql(customer 表结构与 sys_job 种子)
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: ICC 客户信息同步(org_base 定时任务)
|
||||||
|
系统 SHALL 通过定时任务从 ICC 平台同步 org_base 客户信息到 customer 表:OAuth2 鉴权获取 access_token → 分页全量拉取组织数据(maxRangeId=0,pageSize≤1000,循环至末页)→ 过滤 `orgType=org_base` → 按字段映射(`orgCode→iccCode`、`orgName→customerName`、`province→province`、`city→city`、`county→district`)幂等 upsert,并写 `icc_sync_log`(sync_type=`customer`)。
|
||||||
|
|
||||||
|
#### Scenario: 定时同步成功
|
||||||
|
- **WHEN** 定时任务触发(默认 `*/10 * * * *`,可配置)
|
||||||
|
- **THEN** 拉取 ICC org_base 组织并新增/变更对应 customer 记录;新增客户自动生成 `customer_no`(沿用 KH+YYYYMMDD+序号规则);已存在客户(按 `icc_code` 匹配)先比较 customer_name/province/city/district 四个映射字段与现有值,仅当任一字段不一致时才执行更新,一致则跳过,其余手工字段(status/salesman_id/联系方式等)保持不变;`icc_sync_log` 写入成功记录
|
||||||
|
|
||||||
|
#### Scenario: 重复执行幂等
|
||||||
|
- **WHEN** 同一 orgCode 连续两次同步均存在且 ICC 数据未变化
|
||||||
|
- **THEN** 第二次执行按 icc_code 命中已有记录,比较后字段一致不执行 update,不产生重复客户、不产生无意义变更
|
||||||
|
|
||||||
|
#### Scenario: 全量分页拉取
|
||||||
|
- **WHEN** 组织数据超过一页
|
||||||
|
- **THEN** 按 pageNum 递增循环请求直至 currentPage==totalPage,全部合并后统一 upsert
|
||||||
|
|
||||||
|
#### Scenario: ICC 鉴权失败 / 接口异常
|
||||||
|
- **WHEN** 公钥获取失败、token 换取失败或组织接口返回非 0 code / HTTP 非 2xx
|
||||||
|
- **THEN** 同步失败仅写 `icc_sync_log`(status=0、error_message),业务不抛错;access_token 过期时自动重新走 OAuth2 鉴权
|
||||||
|
|
||||||
|
### Requirement: customer 表 icc_code 字段
|
||||||
|
系统 SHALL 在 customer 表新增 `icc_code` 字段(字符串,可空,唯一索引)用于承载 ICC 客户编码;已有客户在首次同步时按 orgCode 匹配后回填该字段;该字段不参与客户档案页面手工编辑逻辑(同步来源字段)。
|
||||||
|
|
||||||
|
### Requirement: ICC 凭据环境配置
|
||||||
|
系统 SHALL 通过 `.env` 读取 ICC 平台连接配置:`ICC_BASE_URL`(host)、`ICC_VERSION`、`ICC_CLIENT_ID`、`ICC_CLIENT_SECRET`、`ICC_USERNAME`、`ICC_PASSWORD`;业务代码不出现明文凭据。
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
(无既有功能被修改;现有 icc_line_sync 任务保持原行为不变)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [x] Task 1: customer 表新增 icc_code 字段(数据库层)
|
||||||
|
- [x] 新建迁移文件 `database/migrations/20261016_icc_customer_sync.sql`:`ALTER TABLE customer ADD COLUMN icc_code`(幂等)+ 唯一索引
|
||||||
|
- [x] 同步更新 `database/db_init.sql` 中 customer 建表结构(含 icc_code 与唯一索引),保持幂等一致
|
||||||
|
- [x] Task 2: customer 实体新增 iccCode 字段
|
||||||
|
- [x] `server/src/entity/customer.entity.ts` 增加 `iccCode`(STRING(64),field=icc_code,不设 unique 避免与迁移脚本索引重复)
|
||||||
|
- [x] Task 3: ICC OAuth2 鉴权客户端
|
||||||
|
- [x] 封装公钥获取(GET `/evo-apigw/evo-oauth/{version}/oauth/public-key`)
|
||||||
|
- [x] 封装 RSA/ECB/PKCS1 分块加密(Node `crypto.publicEncrypt`,块长 117,Base64 输出,对齐文档 Java 示例)— 已冒烟验证加解密往返一致
|
||||||
|
- [x] 封装 token 获取(POST `/evo-apigw/evo-oauth/{version}/oauth/extend/token`),进程内缓存 access_token,按 expires_in 过期自动续签
|
||||||
|
- [x] Task 4: ICC 客户字段映射器
|
||||||
|
- [x] `server/src/task/icc_customer_mapper.ts`:集中配置接口端点、org_base 过滤条件、字段映射(orgCode→iccCode、orgName→customerName、province/city/county)、PageVO 解析 — 修复读取 bug 后冒烟验证通过
|
||||||
|
- [x] Task 5: IccCustomerSyncService 同步服务
|
||||||
|
- [x] `server/src/service/icc_customer_sync.service.ts`:读取 `.env` 配置(缺失视为失败仅记日志)
|
||||||
|
- [x] 分页全量拉取(maxRangeId=0,pageSize=1000,循环至末页),过滤 orgType=org_base
|
||||||
|
- [x] 预取现有客户 icc_code 映射,按 iccCode 幂等 upsert:先比较 customer_name/province/city/district 四个映射字段,仅任一字段不一致才 update,一致则跳过;新增客户复用 CustomerService.generateCustomerNo(已改 public,KH+YYYYMMDD+3位序号);其余字段(status/salesman_id/联系方式等)不动
|
||||||
|
- [x] 每次同步写 `icc_sync_log`(sync_type=customer,status/record_count/error_message);失败仅记录不抛出
|
||||||
|
- [x] Task 6: 定时任务注册与调度器接线
|
||||||
|
- [x] `server/src/task/icc_customer_sync.task.ts`:jobKey=`icc_customer_sync`,默认 cron `*/10 * * * *`
|
||||||
|
- [x] `server/src/task/job_registry.ts`:注册到 BUILTIN_JOBS,JobRuntime 增加 syncIccCustomers
|
||||||
|
- [x] `server/src/service/job_scheduler.service.ts`:buildRuntime 增加 `syncIccCustomers`,经匿名上下文注入 IccCustomerSyncService
|
||||||
|
- [x] `database/db_seed.sql` 新增 sys_job 种子(job_key=icc_customer_sync,按 job_key 幂等)
|
||||||
|
- [x] Task 7: .env 配置
|
||||||
|
- [x] `server/.env` 增加 ICC_BASE_URL / ICC_VERSION / ICC_CLIENT_ID / ICC_CLIENT_SECRET / ICC_USERNAME / ICC_PASSWORD(凭据取自接口文档)
|
||||||
|
- [x] Task 8: 编译与运行验证
|
||||||
|
- [x] `server` 目录执行 `yarn build` 通过(修复 mapper 后复验通过)
|
||||||
|
- [x] 冒烟验证:RSA 分块加解密往返一致、映射器字段映射与分页解析正确(无副作用,未连库);端到端写库同步需在部署环境首次执行时由真实 ICC 接口 + 数据库实际确认
|
||||||
|
|
||||||
|
# Task Dependencies
|
||||||
|
- [Task 1] 无依赖,可先行
|
||||||
|
- [Task 2] 依赖 [Task 1](字段定义与库结构一致)
|
||||||
|
- [Task 3] 无依赖,可与 [Task 1] 并行
|
||||||
|
- [Task 4] 依赖 [Task 3](需确认响应结构/鉴权头),也可并行开发
|
||||||
|
- [Task 5] 依赖 [Task 1][Task 2][Task 3][Task 4]
|
||||||
|
- [Task 6] 依赖 [Task 5]
|
||||||
|
- [Task 7] 依赖 [Task 3](凭据键名一致),可并行
|
||||||
|
- [Task 8] 依赖全部前置任务
|
||||||
@@ -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,14 @@
|
|||||||
|
# Checklist
|
||||||
|
|
||||||
|
- [x] electric_line / line_room / icc_sync_log 表存在(生产库 + db_seed.sql 一致,幂等)
|
||||||
|
- [x] ElectricLine / IccSyncLog 实体与 DTO 实现,ElectricLine 仅 created_at(timestamps:false)
|
||||||
|
- [x] 列表查询使用参数绑定(Op.like/Op.in),无字符串拼接 SQL
|
||||||
|
- [x] GET /api/electric-lines:无 token 401、有 device:list 权限 200 返回 code/message/data 结构
|
||||||
|
- [x] POST /api/electric-lines/sync:JWT+RBAC 鉴权;定时任务服务内直调
|
||||||
|
- [x] IccLineSyncService 读 ICC_BASE_URL/ICC_TOKEN 占位配置,按 line_code upsert + 写 icc_sync_log;ICC 拉取失败记录 error 不抛业务错
|
||||||
|
- [x] icc_line_sync 定时任务已注册(@midwayjs/cron)
|
||||||
|
- [x] RBAC 映射含 /api/electric-lines → device:list
|
||||||
|
- [x] 前端 /device/line 页面渲染(搜索/同步按钮/表格/分页/状态 tag),无 v-html
|
||||||
|
- [x] 菜单 id=61 挂设备监控(4) 且 role_menu 绑定,db_seed.sql 与生产库一致
|
||||||
|
- [x] 后端 tsc --noEmit、前端 vue-tsc -b 均通过
|
||||||
|
- [x] 浏览器端登录后菜单可见、页面可用、同步按钮可用
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 智电改造线路台帐(设备监控)Spec
|
||||||
|
|
||||||
|
## Why
|
||||||
|
设备监控模块缺少"智电改造线路"台账。线路数据由 ICC 平台同步到本系统,运营侧需按客户/设备/建筑查看线路及状态。需在既有设备模块最小套路基础上新增后端接口(含鉴权与防注入)与前端页面,并兼容 APP。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
- **新增** `electric_line` 表后端实体/DTO/Service/Controller(查询接口 `GET /api/electric-lines` 列表 + 详情,手动同步 `POST /api/electric-lines/sync`)
|
||||||
|
- **新增** ICC 拉取同步:`IccLineSyncService` 从 `.env`(`ICC_BASE_URL`/`ICC_TOKEN`)读取占位配置,请求 ICC 线路列表接口(路径与字段映射集中定义),幂等 upsert 写入 `electric_line`,记录 `icc_sync_log`
|
||||||
|
- **新增** 定时任务:注册 `icc_line_sync.task`(@midwayjs/cron,周期默认 10 分钟,可配置),服务内直调拉取
|
||||||
|
- **新增** RBAC 映射:`/api/electric-lines → device:list`
|
||||||
|
- **新增** 前端"智电改造线路台账"页面(`views/device/line/index.vue`),路由 `/device/line`,含"同步线路"手动按钮;菜单挂设备监控目录(parent=4,新 id=61)
|
||||||
|
- **同步** db_seed.sql 与迁移文件(建表 + 菜单 + role_menu),保持幂等一致
|
||||||
|
- **安全**:全部 SQL 走 Sequelize 参数绑定;前端不用 v-html(防 XSS);接口返回统一 `code/message/data`(兼容 APP)
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- Affected specs: 设备监控(设备台账、智电设备型号)、ICC 数据同步、RBAC 权限
|
||||||
|
- Affected code: server/src(entity/dto/service/controller/middleware)、client/src(api/router/views)、database(db_seed.sql、migrations/)
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
### Requirement: 线路台账查询(JWT+RBAC)
|
||||||
|
系统 SHALL 提供线路台账列表与详情查询,仅登录且有 `device:list` 权限的用户可访问;查询支持按 customer_id/equip_id/building_id/status/关键词(line_name/line_code)过滤分页;非 ADMIN 业务员按客户归属自动限定数据范围。
|
||||||
|
#### Scenario: 成功查询
|
||||||
|
- **WHEN** 已登录用户 GET `/api/electric-lines?page=1&pageSize=10&customerId=1`
|
||||||
|
- **THEN** 返回 `code=0,data{list,total}`,含线路编码/名称/所属设备/客户/建筑/楼层/位置/状态/同步时间;无 token 返回 401,无权限返回 403
|
||||||
|
|
||||||
|
### Requirement: ICC 拉取同步(占位配置 + 定时 + 手动)
|
||||||
|
系统 SHALL 提供 ICC 线路拉取同步能力:`IccLineSyncService` 读取 `.env` 的 `ICC_BASE_URL`/`ICC_TOKEN`(占位,字段映射集中在 `icc_line_mapper`),向 ICC 拉取线路列表,按 `line_code` upsert(幂等)写入 `electric_line`,更新 `synced_at`,写 `icc_sync_log`(status/record_count/error_message)。同步入口:① 定时任务(@midwayjs/cron,默认每 10 分钟,服务内直调);② 手动 `POST /api/electric-lines/sync`(JWT+RBAC `device:list`)。
|
||||||
|
#### Scenario: 定时/手动拉取成功
|
||||||
|
- **WHEN** 定时任务触发或运营点击"同步线路"
|
||||||
|
- **THEN** 拉取 ICC 数据并 upsert 对应线路行,`icc_sync_log` 记录成功;返回 `code=0,data{syncedCount,updatedCount}`
|
||||||
|
#### Scenario: 手动同步鉴权失败
|
||||||
|
- **WHEN** 未登录或无 `device:list` 权限调用 `/api/electric-lines/sync`
|
||||||
|
- **THEN** 返回 401/403,不触发拉取
|
||||||
|
#### Scenario: ICC 拉取失败
|
||||||
|
- **WHEN** ICC 地址不可达或返回异常
|
||||||
|
- **THEN** 同步日志记录失败状态与错误信息,业务接口不报错
|
||||||
|
|
||||||
|
### Requirement: 前端线路台账页
|
||||||
|
系统 SHALL 提供 `/device/line` 页面:搜索栏(线路名称/编码、客户、状态)、"同步线路"按钮(调 `/api/electric-lines/sync`,成功后刷新)、表格(编码/名称/客户/设备/建筑/楼层/位置/状态 tag/同步时间)、分页;样式与设备台账一致,全部文本经 Vue 插值转义。
|
||||||
|
#### Scenario: 打开页面
|
||||||
|
- **WHEN** 有 `device:list` 权限的用户访问设备监控→智电改造线路台账
|
||||||
|
- **THEN** 渲染列表并展示分页与筛选
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
(无既有功能被修改)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [ ] Task 1: 数据库层:新建 migration 建 `electric_line`(按 db_design.html:575-589 字段)、`line_room`、`icc_sync_log`(若不存在),并把建表语句同步进 db_seed.sql;幂等(IF NOT EXISTS / 增量)
|
||||||
|
- [ ] SubTask 1.1: 核对生产库是否已有 electric_line/line_room/icc_sync_log,避免重复
|
||||||
|
- [ ] SubTask 1.2: 写 migration(migrations/20261011_icc_line_ledger.sql)并执行到远程生产库
|
||||||
|
- [ ] Task 2: 后端实体+DTO:新增 `ElectricLine` entity(timestamps:false,仅 created_at)、`IccSyncLog` entity;`electric_line.dto.ts` 定义查询/同步入参(line_code 必填、字段校验、长度限制)
|
||||||
|
- [ ] Task 3: 后端 Service:
|
||||||
|
- [ ] SubTask 3.1: `ElectricLineService.list(page, filter)`(Op.like/Op.in 参数绑定,非 ADMIN 限定 customer_id)
|
||||||
|
- [ ] SubTask 3.2: `IccLineSyncService.syncFromIcc()`:读 `.env` 的 ICC_BASE_URL/ICC_TOKEN 占位配置,请求 ICC 线路列表(路径与字段映射集中在 `icc_line_mapper`),按 line_code 批量 upsert + 写 icc_sync_log + 失败捕获不抛业务错误
|
||||||
|
- [ ] Task 4: 后端 Controller + RBAC:
|
||||||
|
- [ ] SubTask 4.1: `electric_line.controller.ts`:GET /api/electric-lines(列表/详情)、POST /api/electric-lines/sync(调 IccLineSyncService)
|
||||||
|
- [ ] SubTask 4.2: rbac.middleware.ts 追加 `/api/electric-lines → device:list`
|
||||||
|
- [ ] SubTask 4.3: `.env` 增加 ICC_BASE_URL、ICC_TOKEN(占位);定时任务注册 `icc_line_sync.task`(@midwayjs/cron,默认每 10 分钟)
|
||||||
|
- [ ] Task 5: 前端 API:`client/src/api/device.ts`(或新 `line.ts`)新增 `electricLineApi.list/get/sync`
|
||||||
|
- [ ] Task 6: 前端页面:`views/device/line/index.vue`(搜索+同步按钮+表格+分页+状态 tag),路由 `/device/line`(permission device:list),菜单 id=61 挂设备监控(4) + role_menu(1,61),同步 db_seed.sql
|
||||||
|
- [ ] Task 7: 验证:
|
||||||
|
- [ ] SubTask 7.1: 后端 `tsc --noEmit`、前端 `vue-tsc -b` 类型检查
|
||||||
|
- [ ] SubTask 7.2: 启动后端/前端,接口实测(无 token 401、有权限 200、无权限 403、sync 接口鉴权、ICC 拉取失败不报错)
|
||||||
|
- [ ] SubTask 7.3: 浏览器验证菜单出现、页面渲染、筛选/分页/同步按钮可用
|
||||||
|
|
||||||
|
# Task Dependencies
|
||||||
|
- Task 2/5 依赖 Task 1(表存在)
|
||||||
|
- Task 3 依赖 Task 2
|
||||||
|
- Task 4 依赖 Task 3
|
||||||
|
- Task 6 依赖 Task 5
|
||||||
|
- 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,27 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { Client } = require('pg');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const client = new Client({
|
||||||
|
host: '222.243.55.42',
|
||||||
|
port: 26532,
|
||||||
|
database: 'xintong',
|
||||||
|
user: 'postgres',
|
||||||
|
password: '7F0d9T7SOroYqgXqtZ7tHL2SbnvUljHe'
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
const sql = fs.readFileSync(
|
||||||
|
path.join(__dirname, 'database', 'migrations', '20261006_worker_password_manage.sql'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await client.query(sql);
|
||||||
|
const menu = await client.query('SELECT id, parent_id, menu_name, menu_type, permission FROM sys_menu WHERE id = 57');
|
||||||
|
const bind = await client.query('SELECT role_id, menu_id FROM sys_role_menu WHERE menu_id = 57');
|
||||||
|
console.log('menu:', JSON.stringify(menu.rows));
|
||||||
|
console.log('bind:', JSON.stringify(bind.rows));
|
||||||
|
await client.end();
|
||||||
|
})().catch((e) => {
|
||||||
|
console.error('FAILED:', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# 开发环境变量
|
# 开发环境变量
|
||||||
VITE_API_BASE_URL=/api
|
VITE_API_BASE_URL=/api
|
||||||
VITE_APP_TITLE=智能物联网设备管理系统
|
VITE_APP_TITLE=鑫潼智电管理系统
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# 生产环境变量
|
# 生产环境变量
|
||||||
VITE_API_BASE_URL=/api
|
VITE_API_BASE_URL=/api
|
||||||
VITE_APP_TITLE=智能物联网设备管理系统
|
VITE_APP_TITLE=鑫潼智电管理系统
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>My Trae Project</title>
|
<title>鑫潼智电管理系统</title>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
if (import.meta.hot?.on) {
|
if (import.meta.hot?.on) {
|
||||||
import.meta.hot.on('vite:error', (error) => {
|
import.meta.hot.on('vite:error', (error) => {
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
};
|
||||||
@@ -86,6 +86,8 @@ export const customerApi = {
|
|||||||
|
|
||||||
getCustomerStats: () => request.get<CustomerStats>('/customers/stats'),
|
getCustomerStats: () => request.get<CustomerStats>('/customers/stats'),
|
||||||
|
|
||||||
|
syncIccCustomers: () => request.post('/customers/sync/icc'),
|
||||||
|
|
||||||
createCustomer: (data: CustomerCreateParams) =>
|
createCustomer: (data: CustomerCreateParams) =>
|
||||||
request.post('/customers', data),
|
request.post('/customers', data),
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface CustomerBuildingInfo {
|
|||||||
basicInfo?: string;
|
basicInfo?: string;
|
||||||
purpose?: string;
|
purpose?: string;
|
||||||
floorCount?: number;
|
floorCount?: number;
|
||||||
|
floorDownCount?: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export interface CustomerBuildingCreateParams {
|
|||||||
basicInfo?: string;
|
basicInfo?: string;
|
||||||
purpose?: string;
|
purpose?: string;
|
||||||
floorCount?: number;
|
floorCount?: number;
|
||||||
|
floorDownCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerBuildingUpdateParams {
|
export interface CustomerBuildingUpdateParams {
|
||||||
@@ -38,6 +40,7 @@ export interface CustomerBuildingUpdateParams {
|
|||||||
basicInfo?: string;
|
basicInfo?: string;
|
||||||
purpose?: string;
|
purpose?: string;
|
||||||
floorCount?: number;
|
floorCount?: number;
|
||||||
|
floorDownCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const customerBuildingApi = {
|
export const customerBuildingApi = {
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
|
||||||
|
export interface CustomerCircuitInfo {
|
||||||
|
id: number;
|
||||||
|
customerId: number;
|
||||||
|
buildingId: number;
|
||||||
|
roomId?: number | null;
|
||||||
|
floorNo?: string;
|
||||||
|
circuitCode: string;
|
||||||
|
circuitName: string;
|
||||||
|
loopSpec?: string;
|
||||||
|
loadType?: string;
|
||||||
|
ratedVoltage?: string;
|
||||||
|
ratedCurrent?: string;
|
||||||
|
ratedPower?: number | string | null;
|
||||||
|
status: number;
|
||||||
|
remark?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerCircuitQuery {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
customerId?: number;
|
||||||
|
buildingId?: number;
|
||||||
|
roomId?: number;
|
||||||
|
floorNo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerCircuitListResult {
|
||||||
|
list: CustomerCircuitInfo[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerCircuitCreateParams {
|
||||||
|
circuitCode: string;
|
||||||
|
customerId: number;
|
||||||
|
buildingId: number;
|
||||||
|
circuitName: string;
|
||||||
|
roomId?: number;
|
||||||
|
floorNo?: string;
|
||||||
|
loopSpec?: string;
|
||||||
|
loadType?: string;
|
||||||
|
ratedVoltage?: string;
|
||||||
|
ratedCurrent?: string;
|
||||||
|
ratedPower?: number;
|
||||||
|
status?: number;
|
||||||
|
remark?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerCircuitUpdateParams {
|
||||||
|
circuitCode?: string;
|
||||||
|
customerId?: number;
|
||||||
|
buildingId?: number;
|
||||||
|
circuitName?: string;
|
||||||
|
roomId?: number;
|
||||||
|
floorNo?: string;
|
||||||
|
loopSpec?: string;
|
||||||
|
loadType?: string;
|
||||||
|
ratedVoltage?: string;
|
||||||
|
ratedCurrent?: string;
|
||||||
|
ratedPower?: number;
|
||||||
|
status?: number;
|
||||||
|
remark?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 空间拓扑(一次返回整棵树) ====================
|
||||||
|
export interface TopologyRoom {
|
||||||
|
id: number;
|
||||||
|
roomCode: string;
|
||||||
|
roomName: string;
|
||||||
|
buildingId: number;
|
||||||
|
floorNo?: string;
|
||||||
|
roomPurpose?: string;
|
||||||
|
deptId?: number;
|
||||||
|
circuits: CustomerCircuitInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopologyFloor {
|
||||||
|
floorNo: string;
|
||||||
|
rooms: TopologyRoom[];
|
||||||
|
circuits: CustomerCircuitInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopologyBuilding {
|
||||||
|
id: number;
|
||||||
|
customerId: number;
|
||||||
|
buildingName: string;
|
||||||
|
purpose?: string;
|
||||||
|
basicInfo?: string;
|
||||||
|
floorCount?: number;
|
||||||
|
floorDownCount?: number;
|
||||||
|
floors: TopologyFloor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerTopologyResult {
|
||||||
|
buildings: TopologyBuilding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 常量 ====================
|
||||||
|
export const CIRCUIT_LOAD_TYPES = ['照明', '动力', '空调', '插座', '电梯', '消防', '其他'];
|
||||||
|
|
||||||
|
export const CIRCUIT_STATUS_OPTIONS = [
|
||||||
|
{ label: '正常', value: 1 },
|
||||||
|
{ label: '停用', value: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const customerCircuitApi = {
|
||||||
|
getList: (params: CustomerCircuitQuery) =>
|
||||||
|
request.get<CustomerCircuitListResult>('/customer-circuits', { params }),
|
||||||
|
|
||||||
|
getTopology: (customerId: number) =>
|
||||||
|
request.get<CustomerTopologyResult>('/customer-circuits/topology', { params: { customerId } }),
|
||||||
|
|
||||||
|
getDetail: (id: number) =>
|
||||||
|
request.get<CustomerCircuitInfo>(`/customer-circuits/${id}`),
|
||||||
|
|
||||||
|
create: (data: CustomerCircuitCreateParams) =>
|
||||||
|
request.post('/customer-circuits', data),
|
||||||
|
|
||||||
|
update: (id: number, data: CustomerCircuitUpdateParams) =>
|
||||||
|
request.put(`/customer-circuits/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: number) =>
|
||||||
|
request.delete(`/customer-circuits/${id}`),
|
||||||
|
};
|
||||||
@@ -144,3 +144,157 @@ export const smartDeviceApi = {
|
|||||||
delete: (id: number) =>
|
delete: (id: number) =>
|
||||||
request.delete(`/smart-devices/${id}`),
|
request.delete(`/smart-devices/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==================== 智电改造线路台账 ====================
|
||||||
|
|
||||||
|
export interface ElectricLine {
|
||||||
|
id: number;
|
||||||
|
lineCode: string;
|
||||||
|
equipId: number;
|
||||||
|
iccDeviceCode?: string;
|
||||||
|
lineName?: string;
|
||||||
|
customerId: number;
|
||||||
|
status: number;
|
||||||
|
syncedAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
// 联表补全字段
|
||||||
|
customerNo?: string;
|
||||||
|
customerName?: string;
|
||||||
|
equipCode?: string;
|
||||||
|
equipName?: 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 {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
customerId?: number;
|
||||||
|
equipId?: number;
|
||||||
|
buildingId?: number;
|
||||||
|
status?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ElectricLineListResult {
|
||||||
|
list: ElectricLine[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IccLineSyncResult {
|
||||||
|
pulled: number;
|
||||||
|
synced: number;
|
||||||
|
inserted: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const electricLineApi = {
|
||||||
|
getList: (params: ElectricLineQuery) =>
|
||||||
|
request.get<ElectricLineListResult>('/electric-lines', { params }),
|
||||||
|
|
||||||
|
getDetail: (id: number) =>
|
||||||
|
request.get<ElectricLine>(`/electric-lines/${id}`),
|
||||||
|
|
||||||
|
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 } }),
|
||||||
|
};
|
||||||
+37
-1
@@ -9,11 +9,33 @@ export interface JobParamField {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 频率类型:每N分钟 / 每N小时 / 每天 / 每周 / 每月 / 自定义 cron */
|
||||||
|
export type FreqType = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'custom';
|
||||||
|
|
||||||
|
/** 频率配置(字段按 freqType 取用) */
|
||||||
|
export interface FreqConfig {
|
||||||
|
/** minute/hour:间隔数量 */
|
||||||
|
interval?: number;
|
||||||
|
/** hour:每小时的第几分钟 */
|
||||||
|
minute?: number;
|
||||||
|
/** day/week/month:执行时间点 HH:mm */
|
||||||
|
times?: string[];
|
||||||
|
/** week:星期,0=周日 ... 6=周六 */
|
||||||
|
weekdays?: number[];
|
||||||
|
/** month:日期 1-31 */
|
||||||
|
days?: number[];
|
||||||
|
/** custom:自定义 cron 表达式 */
|
||||||
|
expression?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JobItem {
|
export interface JobItem {
|
||||||
id: number;
|
id: number;
|
||||||
jobKey: string;
|
jobKey: string;
|
||||||
jobName: string;
|
jobName: string;
|
||||||
cronExpression: string;
|
cronExpression: string;
|
||||||
|
freqType: FreqType;
|
||||||
|
freqConfig: FreqConfig;
|
||||||
|
cronDescription?: string | null;
|
||||||
params: Record<string, any>;
|
params: Record<string, any>;
|
||||||
retryTimes: number;
|
retryTimes: number;
|
||||||
retryInterval: number;
|
retryInterval: number;
|
||||||
@@ -71,13 +93,23 @@ export interface JobLogQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateJobPayload {
|
export interface UpdateJobPayload {
|
||||||
cronExpression: string;
|
freqType: FreqType;
|
||||||
|
freqConfig: FreqConfig;
|
||||||
params: Record<string, any>;
|
params: Record<string, any>;
|
||||||
retryTimes: number;
|
retryTimes: number;
|
||||||
retryInterval: number;
|
retryInterval: number;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 频率预览结果:由后端由频率配置推导 cron、中文描述与未来执行时间 */
|
||||||
|
export interface CronPreviewResult {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
cronExpression?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
nextRuns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface JobListResult {
|
export interface JobListResult {
|
||||||
list: JobItem[];
|
list: JobItem[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -99,6 +131,10 @@ export const jobApi = {
|
|||||||
updateJob: (id: number, data: UpdateJobPayload) =>
|
updateJob: (id: number, data: UpdateJobPayload) =>
|
||||||
request.put<JobItem>(`/jobs/${id}`, data),
|
request.put<JobItem>(`/jobs/${id}`, data),
|
||||||
|
|
||||||
|
/** 频率预览:人性化配置 → cron / 中文描述 / 未来 5 次执行时间 */
|
||||||
|
previewCron: (data: { freqType: FreqType; freqConfig: FreqConfig }) =>
|
||||||
|
request.post<CronPreviewResult>('/jobs/cron/preview', data),
|
||||||
|
|
||||||
changeJobStatus: (id: number, status: 0 | 1) =>
|
changeJobStatus: (id: number, status: 0 | 1) =>
|
||||||
request.put<JobItem>(`/jobs/${id}/status`, { status }),
|
request.put<JobItem>(`/jobs/${id}/status`, { status }),
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ export const SURVEY_PRIORITY_OPTIONS = [
|
|||||||
{ value: 3, label: '常规 · P3' }
|
{ value: 3, label: '常规 · P3' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// SOP 规范模板编码与名称
|
|
||||||
export const SOP_TEMPLATE_CODE = 'standard_dist';
|
|
||||||
export const SOP_TEMPLATE_LABEL = '标准配电改造模板';
|
|
||||||
|
|
||||||
// 报酬规则(后端 dispatch_price 未配置时的前端兜底展示值)
|
// 报酬规则(后端 dispatch_price 未配置时的前端兜底展示值)
|
||||||
export const SURVEY_BASE_FEE = 350;
|
export const SURVEY_BASE_FEE = 350;
|
||||||
export const SURVEY_LOOP_FEE = 5;
|
export const SURVEY_LOOP_FEE = 5;
|
||||||
@@ -61,35 +57,22 @@ export interface SurveyOrderWorker {
|
|||||||
completedCount?: number;
|
completedCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SurveySopItem {
|
// 勘察明细:一条 = 建筑 + 楼层 + 房间(可空) + 现场照片
|
||||||
id: number;
|
export interface SurveyOrderDetail {
|
||||||
templateCode: string;
|
id?: number;
|
||||||
sortOrder: number;
|
surveyOrderId?: number;
|
||||||
itemName: string;
|
buildingId?: number | null;
|
||||||
itemDesc?: string;
|
// 建筑名称(冗余存储,客户拓扑变更后仍可读)
|
||||||
// 回执形式:photo 现场照片 / form 清单表单 / meter 测仪截图
|
buildingName?: string | null;
|
||||||
receiptType: 'photo' | 'form' | 'meter' | string;
|
// 楼层:正数为地上、负数为地下,如 '1' / '-1'
|
||||||
receiptRequire?: string;
|
floorNo?: string | null;
|
||||||
required: boolean;
|
roomId?: number | null;
|
||||||
}
|
roomName?: string | null;
|
||||||
|
// 现场照片对象 key
|
||||||
export interface SurveyOrderSopResult {
|
photos?: string[];
|
||||||
id: number;
|
// 详情接口附带:照片预签名访问 URL(与 photos 顺序一致)
|
||||||
surveyOrderId: number;
|
photoUrls?: string[];
|
||||||
sopItemId: number;
|
remark?: string | null;
|
||||||
checked: boolean;
|
|
||||||
// 回执内容:photo/meter 类为照片对象 key 数组,form 类为单元素文本值数组
|
|
||||||
receiptValue?: string[];
|
|
||||||
// 详情/回执接口附带:照片类回执的预签名访问 URL(与 receiptValue 顺序一致)
|
|
||||||
receiptUrls?: string[];
|
|
||||||
remark?: string;
|
|
||||||
checkedAt?: string;
|
|
||||||
// 由后端 join 的检查项信息
|
|
||||||
sopItem?: SurveySopItem;
|
|
||||||
itemName?: string;
|
|
||||||
receiptType?: string;
|
|
||||||
receiptRequire?: string;
|
|
||||||
sortOrder?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SurveyOrderInfo {
|
export interface SurveyOrderInfo {
|
||||||
@@ -141,8 +124,8 @@ export interface SurveyOrderInfo {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
// 详情附带:SOP 回执
|
// 详情附带:勘察明细(建筑/楼层/房间 + 现场照片)
|
||||||
sopResults?: SurveyOrderSopResult[];
|
details?: SurveyOrderDetail[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SurveyOrderQuery {
|
export interface SurveyOrderQuery {
|
||||||
@@ -209,7 +192,8 @@ export interface SurveyOrderCreateParams {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
isDraft?: number;
|
isDraft?: number;
|
||||||
templateCode?: string;
|
// 勘察明细(创建/编辑时全量提交,新增/编辑/删除统一走此数组)
|
||||||
|
details?: SurveyOrderDetail[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SurveyOrderUpdateParams = Partial<SurveyOrderCreateParams>;
|
export type SurveyOrderUpdateParams = Partial<SurveyOrderCreateParams>;
|
||||||
@@ -247,10 +231,6 @@ export const surveyOrderApi = {
|
|||||||
// KPI 统计卡
|
// KPI 统计卡
|
||||||
getStats: () => request.get<SurveyOrderStats>('/survey-orders/stats'),
|
getStats: () => request.get<SurveyOrderStats>('/survey-orders/stats'),
|
||||||
|
|
||||||
// SOP 规范模板检查项
|
|
||||||
getSopItems: (templateCode: string = SOP_TEMPLATE_CODE) =>
|
|
||||||
request.get<SurveySopItem[]>('/survey-orders/sop-items', { params: { templateCode } }),
|
|
||||||
|
|
||||||
// 报酬测算:基础出场工时费 + 回路数 × 单价
|
// 报酬测算:基础出场工时费 + 回路数 × 单价
|
||||||
getQuote: (loopCount: number) =>
|
getQuote: (loopCount: number) =>
|
||||||
request.get<SurveyQuote>('/survey-orders/quote', { params: { loopCount } }),
|
request.get<SurveyQuote>('/survey-orders/quote', { params: { loopCount } }),
|
||||||
@@ -264,6 +244,10 @@ export const surveyOrderApi = {
|
|||||||
update: (id: number, data: SurveyOrderUpdateParams) =>
|
update: (id: number, data: SurveyOrderUpdateParams) =>
|
||||||
request.put<SurveyOrderInfo>(`/survey-orders/${id}`, data),
|
request.put<SurveyOrderInfo>(`/survey-orders/${id}`, data),
|
||||||
|
|
||||||
|
// 勘察明细:全量替换(新增/编辑/删除统一提交完整数组)
|
||||||
|
saveDetails: (id: number, details: SurveyOrderDetail[]) =>
|
||||||
|
request.put<SurveyOrderDetail[]>(`/survey-orders/${id}/details`, { details }),
|
||||||
|
|
||||||
// 指派 / 改派共享电工
|
// 指派 / 改派共享电工
|
||||||
assign: (id: number, data: SurveyOrderAssignParams) =>
|
assign: (id: number, data: SurveyOrderAssignParams) =>
|
||||||
request.put<SurveyOrderInfo>(`/survey-orders/${id}/assign`, data),
|
request.put<SurveyOrderInfo>(`/survey-orders/${id}/assign`, data),
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export interface WorkerRecord {
|
|||||||
auditBy?: number;
|
auditBy?: number;
|
||||||
auditAt?: string;
|
auditAt?: string;
|
||||||
lastLoginAt?: string;
|
lastLoginAt?: string;
|
||||||
|
// 是否已开通电工 APP 登录账号
|
||||||
|
hasAccount?: boolean;
|
||||||
certStatus?: WorkerCertStatus;
|
certStatus?: WorkerCertStatus;
|
||||||
certs?: WorkerCertSummary[];
|
certs?: WorkerCertSummary[];
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
@@ -119,6 +121,9 @@ export const workerRegistryApi = {
|
|||||||
|
|
||||||
audit: (id: number, data: WorkerAuditPayload) => request.put(`/workers/${id}/audit`, data),
|
audit: (id: number, data: WorkerAuditPayload) => request.put(`/workers/${id}/audit`, data),
|
||||||
|
|
||||||
|
// 重置 APP 登录密码(后端固定重置为手机号后 6 位)
|
||||||
|
resetPassword: (id: number) => request.post(`/workers/${id}/reset-password`),
|
||||||
|
|
||||||
addCert: (id: number, data: WorkerCertPayload) => request.post(`/workers/${id}/certs`, data),
|
addCert: (id: number, data: WorkerCertPayload) => request.post(`/workers/${id}/certs`, data),
|
||||||
|
|
||||||
updateCert: (id: number, certId: number, data: WorkerCertPayload) =>
|
updateCert: (id: number, certId: number, data: WorkerCertPayload) =>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<!-- Logo区域 -->
|
<!-- Logo区域 -->
|
||||||
<div class="logo" @click="$router.push('/dashboard')">
|
<div class="logo" @click="$router.push('/dashboard')">
|
||||||
<ThunderboltOutlined class="logo-img" />
|
<ThunderboltOutlined class="logo-img" />
|
||||||
<h1 v-show="!collapsed" class="logo-title">智能电网IoT</h1>
|
<h1 v-show="!collapsed" class="logo-title">鑫潼智电</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 菜单 -->
|
<!-- 菜单 -->
|
||||||
@@ -125,6 +125,9 @@
|
|||||||
</a-spin>
|
</a-spin>
|
||||||
</div>
|
</div>
|
||||||
</a-drawer>
|
</a-drawer>
|
||||||
|
|
||||||
|
<!-- 全局悬浮警灯(登录后所有页面共享,活跃严重报警提示) -->
|
||||||
|
<AlarmSirenLight />
|
||||||
</a-layout>
|
</a-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -157,6 +160,7 @@ import {
|
|||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { usePermissionStore, type MenuNode } from '@/stores/permission'
|
import { usePermissionStore, type MenuNode } from '@/stores/permission'
|
||||||
import { noticeApi, type NoticeInfo } from '@/api/notice'
|
import { noticeApi, type NoticeInfo } from '@/api/notice'
|
||||||
|
import AlarmSirenLight from '@/components/AlarmSirenLight.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -402,8 +406,8 @@ const NOTICE_TYPE_ROUTE: Record<string, string> = {
|
|||||||
const NOTICE_BIZ_ROUTE: Record<string, string> = {
|
const NOTICE_BIZ_ROUTE: Record<string, string> = {
|
||||||
repair_dispatch: '/dispatch/repair',
|
repair_dispatch: '/dispatch/repair',
|
||||||
maintenance_order: '/dispatch/maintenance',
|
maintenance_order: '/dispatch/maintenance',
|
||||||
install_order: '/project/install',
|
install_order: '/dispatch/install',
|
||||||
survey_order: '/project/survey',
|
survey_order: '/dispatch/survey',
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveNoticeRoute(item: NoticeInfo): string | null {
|
function resolveNoticeRoute(item: NoticeInfo): string | null {
|
||||||
|
|||||||
+78
-54
@@ -88,39 +88,9 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: "project",
|
path: "project",
|
||||||
name: "Project",
|
name: "Project",
|
||||||
redirect: "/project/survey",
|
redirect: "/project/plan",
|
||||||
meta: { requiresAuth: true, title: "项目管理", permission: "project:list" }
|
meta: { requiresAuth: true, title: "项目管理", permission: "project:list" }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "project/survey",
|
|
||||||
name: "ProjectSurvey",
|
|
||||||
component: () => import("@/views/project/survey/index.vue"),
|
|
||||||
meta: { requiresAuth: true, title: "项目勘察", permission: "project:survey" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "project/survey/create",
|
|
||||||
name: "ProjectSurveyCreate",
|
|
||||||
component: () => import("@/views/project/survey/form.vue"),
|
|
||||||
meta: {
|
|
||||||
requiresAuth: true,
|
|
||||||
title: "新建勘察派单",
|
|
||||||
permission: "project:survey",
|
|
||||||
parentTitle: "现场勘察派单",
|
|
||||||
parentPath: "/project/survey"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "project/survey/edit/:id",
|
|
||||||
name: "ProjectSurveyEdit",
|
|
||||||
component: () => import("@/views/project/survey/form.vue"),
|
|
||||||
meta: {
|
|
||||||
requiresAuth: true,
|
|
||||||
title: "编辑勘察派单",
|
|
||||||
permission: "project:survey",
|
|
||||||
parentTitle: "现场勘察派单",
|
|
||||||
parentPath: "/project/survey"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "project/plan",
|
path: "project/plan",
|
||||||
name: "ProjectPlan",
|
name: "ProjectPlan",
|
||||||
@@ -145,18 +115,54 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/project/visit/index.vue"),
|
component: () => import("@/views/project/visit/index.vue"),
|
||||||
meta: { requiresAuth: true, title: "回访记录", permission: "project:visit" }
|
meta: { requiresAuth: true, title: "回访记录", permission: "project:visit" }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "project/install",
|
|
||||||
name: "ProjectInstall",
|
|
||||||
component: () => import("@/views/project/install/index.vue"),
|
|
||||||
meta: { requiresAuth: true, title: "安装派单", permission: "project:install" }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "dispatch",
|
path: "dispatch",
|
||||||
name: "Dispatch",
|
name: "Dispatch",
|
||||||
redirect: "/dispatch/repair",
|
redirect: "/dispatch/repair",
|
||||||
meta: { requiresAuth: true, title: "运营派单", permission: "dispatch:list" }
|
meta: { requiresAuth: true, title: "运营派单", permission: "dispatch:list" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "dispatch/worker",
|
||||||
|
name: "DispatchWorker",
|
||||||
|
component: () => import("@/views/dispatch/worker/index.vue"),
|
||||||
|
meta: { requiresAuth: true, title: "电工登记管理", permission: "dispatch:worker" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "dispatch/survey",
|
||||||
|
name: "DispatchSurvey",
|
||||||
|
component: () => import("@/views/dispatch/survey/index.vue"),
|
||||||
|
meta: { requiresAuth: true, title: "项目勘察", permission: "project:survey" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "dispatch/survey/create",
|
||||||
|
name: "DispatchSurveyCreate",
|
||||||
|
component: () => import("@/views/dispatch/survey/form.vue"),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
title: "新建勘察派单",
|
||||||
|
permission: "project:survey",
|
||||||
|
parentTitle: "现场勘察派单",
|
||||||
|
parentPath: "/dispatch/survey"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "dispatch/survey/edit/:id",
|
||||||
|
name: "DispatchSurveyEdit",
|
||||||
|
component: () => import("@/views/dispatch/survey/form.vue"),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
title: "编辑勘察派单",
|
||||||
|
permission: "project:survey",
|
||||||
|
parentTitle: "现场勘察派单",
|
||||||
|
parentPath: "/dispatch/survey"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "dispatch/install",
|
||||||
|
name: "DispatchInstall",
|
||||||
|
component: () => import("@/views/dispatch/install/index.vue"),
|
||||||
|
meta: { requiresAuth: true, title: "安装派单", permission: "project:install" }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "dispatch/repair",
|
path: "dispatch/repair",
|
||||||
name: "DispatchRepair",
|
name: "DispatchRepair",
|
||||||
@@ -170,22 +176,10 @@ const routes: RouteRecordRaw[] = [
|
|||||||
meta: { requiresAuth: true, title: "维保派单", permission: "dispatch:maintenance" }
|
meta: { requiresAuth: true, title: "维保派单", permission: "dispatch:maintenance" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "dispatch/price",
|
path: "dispatch/alarm",
|
||||||
name: "DispatchPrice",
|
name: "AlarmDispatchLedger",
|
||||||
component: () => import("@/views/dispatch/price/index.vue"),
|
component: () => import("@/views/dispatch/alarm/index.vue"),
|
||||||
meta: { requiresAuth: true, title: "运维费定价", permission: "dispatch:price" }
|
meta: { requiresAuth: true, title: "报警派单台账", permission: "alarm-dispatch:list" }
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "dispatch/cost",
|
|
||||||
name: "DispatchCost",
|
|
||||||
component: () => import("@/views/dispatch/cost/index.vue"),
|
|
||||||
meta: { requiresAuth: true, title: "勘察费用", permission: "dispatch:cost" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "dispatch/worker",
|
|
||||||
name: "DispatchWorker",
|
|
||||||
component: () => import("@/views/dispatch/worker/index.vue"),
|
|
||||||
meta: { requiresAuth: true, title: "电工登记管理", permission: "dispatch:worker" }
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "device",
|
path: "device",
|
||||||
@@ -198,6 +192,24 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: "DeviceEquipment",
|
name: "DeviceEquipment",
|
||||||
component: () => import("@/views/device/equipment/index.vue"),
|
component: () => import("@/views/device/equipment/index.vue"),
|
||||||
meta: { requiresAuth: true, title: "设备管理台账", permission: "device:list" }
|
meta: { requiresAuth: true, title: "设备管理台账", permission: "device:list" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "device/line",
|
||||||
|
name: "DeviceLine",
|
||||||
|
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",
|
path: "base",
|
||||||
@@ -211,6 +223,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/device/smart-device/index.vue"),
|
component: () => import("@/views/device/smart-device/index.vue"),
|
||||||
meta: { requiresAuth: true, title: "智电设备型号", permission: "device:model" }
|
meta: { requiresAuth: true, title: "智电设备型号", permission: "device:model" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "base/price",
|
||||||
|
name: "BasePrice",
|
||||||
|
component: () => import("@/views/base/price/index.vue"),
|
||||||
|
meta: { requiresAuth: true, title: "运维费定价", permission: "dispatch:price" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "base/cost",
|
||||||
|
name: "BaseCost",
|
||||||
|
component: () => import("@/views/base/cost/index.vue"),
|
||||||
|
meta: { requiresAuth: true, title: "勘察费用", permission: "dispatch:cost" }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "report",
|
path: "report",
|
||||||
name: "Report",
|
name: "Report",
|
||||||
@@ -274,7 +298,7 @@ const router = createRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.beforeEach(async (to, _from, next) => {
|
router.beforeEach(async (to, _from, next) => {
|
||||||
document.title = to.meta.title ? `${to.meta.title} - 智能物联网设备管理系统` : "智能物联网设备管理系统";
|
document.title = to.meta.title ? `${to.meta.title} - 鑫潼智电管理系统` : "鑫潼智电管理系统";
|
||||||
|
|
||||||
const permissionStore = usePermissionStore();
|
const permissionStore = usePermissionStore();
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
|||||||
File renamed without changes.
File renamed without changes.
@@ -19,6 +19,10 @@
|
|||||||
<p class="desc">统一维护客户的基础资料、联系信息与业务阶段,支持快速检索与档案管理。</p>
|
<p class="desc">统一维护客户的基础资料、联系信息与业务阶段,支持快速检索与档案管理。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<a-button :loading="syncing" @click="handleSync">
|
||||||
|
<template #icon><SyncOutlined /></template>
|
||||||
|
同步客户
|
||||||
|
</a-button>
|
||||||
<a-button type="primary" @click="handleAdd">
|
<a-button type="primary" @click="handleAdd">
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
新增客户
|
新增客户
|
||||||
@@ -323,13 +327,14 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, computed } from 'vue';
|
import { ref, reactive, onMounted, computed } from 'vue';
|
||||||
import { message } from 'ant-design-vue';
|
import { message, Modal } from 'ant-design-vue';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
|
SyncOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
AimOutlined,
|
AimOutlined,
|
||||||
FileProtectOutlined,
|
FileProtectOutlined,
|
||||||
@@ -352,6 +357,7 @@ const permissionStore = usePermissionStore();
|
|||||||
|
|
||||||
// ==================== 数据 ====================
|
// ==================== 数据 ====================
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const syncing = ref(false);
|
||||||
const dataSource = ref<CustomerInfo[]>([]);
|
const dataSource = ref<CustomerInfo[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const editNo = ref<string>('');
|
const editNo = ref<string>('');
|
||||||
@@ -568,6 +574,38 @@ function getSalesmanName(id?: number) {
|
|||||||
return found?.label ?? '-';
|
return found?.label ?? '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== ICC 手动同步 ====================
|
||||||
|
function handleSync() {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '同步客户',
|
||||||
|
content: '将从 ICC 平台拉取客户信息,并按 ICC 客户编号幂等新增/更新本地客户档案,是否继续?',
|
||||||
|
okText: '开始同步',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
syncing.value = true;
|
||||||
|
try {
|
||||||
|
const res: any = await customerApi.syncIccCustomers();
|
||||||
|
if (res.code === 200) {
|
||||||
|
const d = res.data || {};
|
||||||
|
const pulled = d.pulled ?? 0;
|
||||||
|
const inserted = d.inserted ?? 0;
|
||||||
|
const updated = d.updated ?? 0;
|
||||||
|
const skipped = d.skipped ?? 0;
|
||||||
|
message.success(`同步完成:拉取 ${pulled} 条,新增 ${inserted} 条,更新 ${updated} 条,跳过 ${skipped} 条`);
|
||||||
|
fetchList();
|
||||||
|
fetchStats();
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '同步失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('同步失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
syncing.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 搜索重置 ====================
|
// ==================== 搜索重置 ====================
|
||||||
function handleSearch() {
|
function handleSearch() {
|
||||||
searchForm.page = 1;
|
searchForm.page = 1;
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -798,7 +798,7 @@ function handleFollow(record: CustomerVisitInfo) {
|
|||||||
// 一键发起勘察派单:跳转勘察派单页并预填客户(不创建、不回写状态)
|
// 一键发起勘察派单:跳转勘察派单页并预填客户(不创建、不回写状态)
|
||||||
function handleSurvey(record: CustomerVisitInfo) {
|
function handleSurvey(record: CustomerVisitInfo) {
|
||||||
if (!record.customerId) return;
|
if (!record.customerId) return;
|
||||||
router.push({ path: '/project/survey', query: { customerId: String(record.customerId) } });
|
router.push({ path: '/dispatch/survey', query: { customerId: String(record.customerId) } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查看关联工程
|
// 查看关联工程
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="card-title-row">
|
<div class="card-title-row">
|
||||||
<h3 class="card-title">近7日业务增长趋势</h3>
|
<h3 class="card-title">近7日业务增长趋势</h3>
|
||||||
<span class="card-tag">REALTIME 新增统计</span>
|
<span class="card-tag">实时新增统计</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="card-sub">每日新增托管客户与新增合同的对比走势</p>
|
<p class="card-sub">每日新增托管客户与新增合同的对比走势</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,12 +138,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="card-title-row">
|
<div class="card-title-row">
|
||||||
<h3 class="card-title">待办勘察派单流水</h3>
|
<h3 class="card-title">待办勘察派单流水</h3>
|
||||||
<span class="live-alerts">LIVE ORDERS</span>
|
<span class="live-alerts">实时单量</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="card-sub">最新勘察派单与承接状态的实时跟踪</p>
|
<p class="card-sub">最新勘察派单与承接状态的实时跟踪</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="ghost-btn" @click="handleRefresh">
|
<button class="ghost-btn" @click="handleRefresh">
|
||||||
<span class="material-symbols-outlined">filter_list</span>
|
<ReloadOutlined />
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,8 +184,14 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="ta-r">
|
<td class="ta-r">
|
||||||
<div class="ops">
|
<div class="ops">
|
||||||
<button class="icon-btn" title="催办"><PhoneOutlined /></button>
|
<a-dropdown trigger="click" placement="bottomRight">
|
||||||
<button class="icon-btn" title="更多"><MoreOutlined /></button>
|
<button class="icon-btn" title="更多"><MoreOutlined /></button>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu @click="({ key }) => handleRowAction(key as string, row)">
|
||||||
|
<a-menu-item key="detail">查看详情</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -196,7 +202,7 @@
|
|||||||
|
|
||||||
<div class="card-foot">
|
<div class="card-foot">
|
||||||
<span class="mono muted">显示 {{ stats?.surveyList?.length || 0 }} / {{ formatNum(stats?.surveyPending) }} 个待办勘察单</span>
|
<span class="mono muted">显示 {{ stats?.surveyList?.length || 0 }} / {{ formatNum(stats?.surveyPending) }} 个待办勘察单</span>
|
||||||
<a class="card-link">进入勘察派单工作流<ArrowRightOutlined /></a>
|
<a class="card-link" @click="goSurveyWorkflow">进入勘察派单工作流<ArrowRightOutlined /></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -241,7 +247,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="card-title-row">
|
<div class="card-title-row">
|
||||||
<h3 class="card-title">电工资质到期提醒</h3>
|
<h3 class="card-title">电工资质到期提醒</h3>
|
||||||
<span class="card-tag">CERT ALERT</span>
|
<span class="card-tag">证照预警</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="card-sub">临期(30 天内)与已过期的电工资质证照清单,已过期优先展示</p>
|
<p class="card-sub">临期(30 天内)与已过期的电工资质证照清单,已过期优先展示</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -297,7 +303,6 @@ import {
|
|||||||
ScheduleOutlined,
|
ScheduleOutlined,
|
||||||
FieldTimeOutlined,
|
FieldTimeOutlined,
|
||||||
DeploymentUnitOutlined,
|
DeploymentUnitOutlined,
|
||||||
PhoneOutlined,
|
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
RiseOutlined,
|
RiseOutlined,
|
||||||
@@ -305,7 +310,7 @@ import {
|
|||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { dashboardApi, type DashboardStats } from '@/api/dashboard';
|
import { dashboardApi, type DashboardStats, type SurveyOrderItem } from '@/api/dashboard';
|
||||||
import { workerRegistryApi } from '@/api/workerRegistry';
|
import { workerRegistryApi } from '@/api/workerRegistry';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -515,6 +520,17 @@ function handleRefresh() {
|
|||||||
loadStats();
|
loadStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 勘察流水行操作
|
||||||
|
function handleRowAction(key: string, row: SurveyOrderItem) {
|
||||||
|
if (key === 'detail') {
|
||||||
|
router.push({ name: 'DispatchSurveyEdit', params: { id: String(row.id) } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goSurveyWorkflow() {
|
||||||
|
router.push('/dispatch/survey');
|
||||||
|
}
|
||||||
|
|
||||||
function initCharts() {
|
function initCharts() {
|
||||||
if (trendChartRef.value) trendChart = echarts.init(trendChartRef.value);
|
if (trendChartRef.value) trendChart = echarts.init(trendChartRef.value);
|
||||||
if (donutChartRef.value) donutChart = echarts.init(donutChartRef.value);
|
if (donutChartRef.value) donutChart = echarts.init(donutChartRef.value);
|
||||||
@@ -905,6 +921,5 @@ function disposeCharts() {
|
|||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.4; }
|
50% { opacity: 0.4; }
|
||||||
}
|
}
|
||||||
.material-symbols-outlined { font-size: 16px; line-height: 1; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
<template>
|
||||||
|
<div class="device-line">
|
||||||
|
<!-- 顶部标题 -->
|
||||||
|
<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 LINE 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.status" 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 === 'lineCode'">
|
||||||
|
<span class="mono">{{ record.lineCode || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'lineName'">
|
||||||
|
<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 === 'gatewayId'">
|
||||||
|
{{ record.gatewayName ? `${record.gatewayName}(${record.gatewayCode})` : (record.gatewayCode || '-') }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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'">
|
||||||
|
<a-tag :color="record.status === 1 ? 'success' : 'error'">
|
||||||
|
{{ record.status === 1 ? '在线' : '离线' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<a-empty description="暂无线路数据,可点击右上角「同步线路」从 ICC 拉取" />
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</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';
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
} 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';
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const searchForm = reactive<ElectricLineQuery>({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
keyword: '',
|
||||||
|
customerId: undefined as any,
|
||||||
|
status: undefined as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '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(() => ({
|
||||||
|
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 electricLineApi.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.status = 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 electricLineApi.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
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-line {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-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;
|
||||||
|
}
|
||||||
|
.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>
|
||||||
File renamed without changes.
+445
-217
@@ -229,34 +229,135 @@
|
|||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<span class="bar bar-secondary"></span>
|
<span class="bar bar-secondary"></span>
|
||||||
<FileDoneOutlined class="sec-icon clr-secondary" />
|
<FileDoneOutlined class="sec-icon clr-secondary" />
|
||||||
<h2>第 2 步:勘察技术要点与回执标准</h2>
|
<h2>第 2 步:勘察明细</h2>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isReadonly" class="tmpl-box">
|
<div v-if="!isReadonly" class="head-actions">
|
||||||
<span class="head-hint">SOP规范模板:</span>
|
<span class="head-hint">按建筑 / 楼层 / 房间逐条登记,可附现场照片</span>
|
||||||
<button type="button" class="tmpl-btn" @click="applySopTemplate">
|
<a-button size="small" type="primary" ghost @click="addDetailRow">
|
||||||
<PlusOutlined />
|
<template #icon><PlusOutlined /></template>
|
||||||
{{ SOP_TEMPLATE_LABEL }}(已应用 {{ sopItems.length }} 项)
|
添加明细
|
||||||
</button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sop-grid" :class="{ 'is-empty': !sopLoading && sopItems.length === 0 }">
|
<div class="detail-body">
|
||||||
<div v-for="item in sopItems" :key="item.id" class="sop-card">
|
<!-- 编辑态:明细编辑行 -->
|
||||||
<div class="sop-top">
|
<template v-if="!isReadonly">
|
||||||
<span class="sop-name">{{ item.sortOrder }}. {{ item.itemName }}</span>
|
<div v-if="detailRows.length === 0" class="detail-empty">
|
||||||
<span v-if="item.required" class="sop-required">必填</span>
|
暂无勘察明细,点击右上角「添加明细」逐条登记建筑、楼层与房间
|
||||||
</div>
|
</div>
|
||||||
<div class="sop-desc">{{ item.itemDesc || '—' }}</div>
|
<div v-else class="detail-rows">
|
||||||
<div class="sop-foot">
|
<div v-for="(row, idx) in detailRows" :key="row.uid" class="detail-row">
|
||||||
<span>{{ receiptTypeLabel(item.receiptType) }}</span>
|
<div class="detail-row-head">
|
||||||
<span>{{ item.receiptRequire || '—' }}</span>
|
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
||||||
|
<a-button type="text" size="small" danger @click="removeDetailRow(idx)">
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="dfield">
|
||||||
|
<label class="dfield-label">建筑 <b class="req">*</b></label>
|
||||||
|
<a-select
|
||||||
|
v-model:value="row.buildingId"
|
||||||
|
placeholder="选择建筑"
|
||||||
|
:loading="topologyLoading"
|
||||||
|
:options="buildingSelectOptions"
|
||||||
|
:not-found-content="topologyLoading ? null : '该客户暂无建筑'"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="handleRowBuildingChange(row)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="dfield">
|
||||||
|
<label class="dfield-label">楼层 <b class="req">*</b></label>
|
||||||
|
<a-select
|
||||||
|
v-model:value="row.floorNo"
|
||||||
|
placeholder="选择楼层"
|
||||||
|
:disabled="!row.buildingId"
|
||||||
|
:options="floorOptionsFor(row.buildingId)"
|
||||||
|
:not-found-content="row.buildingId ? '该建筑未配置楼层数' : '请先选择建筑'"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="handleRowFloorChange(row)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="dfield">
|
||||||
|
<label class="dfield-label">房间 <span class="opt">选填</span></label>
|
||||||
|
<a-select
|
||||||
|
v-model:value="row.roomId"
|
||||||
|
placeholder="可不选"
|
||||||
|
allow-clear
|
||||||
|
:disabled="!row.floorNo"
|
||||||
|
:options="roomOptionsFor(row)"
|
||||||
|
:not-found-content="row.floorNo ? '该楼层暂无房间' : '请先选择楼层'"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="handleRowRoomChange(row)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="dfield span-all">
|
||||||
|
<label class="dfield-label">现场照片</label>
|
||||||
|
<div class="detail-photos">
|
||||||
|
<a-image-preview-group>
|
||||||
|
<div v-for="(p, pi) in row.photos" :key="pi" class="photo-thumb">
|
||||||
|
<a-image :src="p.url" :width="76" :height="57" />
|
||||||
|
<span class="photo-del" @click.stop="removePhoto(row, pi)">×</span>
|
||||||
|
</div>
|
||||||
|
</a-image-preview-group>
|
||||||
|
<a-upload
|
||||||
|
:show-upload-list="false"
|
||||||
|
:custom-request="(o: any) => handlePhotoUpload(o, row)"
|
||||||
|
:before-upload="beforePhotoUpload"
|
||||||
|
accept="image/*"
|
||||||
|
>
|
||||||
|
<button type="button" class="photo-add" :disabled="row.photos.length >= MAX_DETAIL_PHOTOS">
|
||||||
|
<PlusOutlined />
|
||||||
|
<span>上传照片</span>
|
||||||
|
</button>
|
||||||
|
</a-upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dfield span-all">
|
||||||
|
<label class="dfield-label">备注</label>
|
||||||
|
<input
|
||||||
|
v-model="row.remark"
|
||||||
|
class="ipt"
|
||||||
|
type="text"
|
||||||
|
maxlength="500"
|
||||||
|
placeholder="该条明细的现场说明,如设备位置、隐患点等"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<div v-if="!isReadonly" class="sop-add" @click="handleAddCustomItem">
|
<!-- 只读态:明细展示 -->
|
||||||
<PlusOutlined class="sop-add-icon" />
|
<template v-else>
|
||||||
<span>+ 自定义勘察检查项</span>
|
<div v-if="detailRows.length === 0" class="detail-empty">暂无勘察明细</div>
|
||||||
</div>
|
<div v-else class="detail-rows">
|
||||||
|
<div v-for="(row, idx) in detailRows" :key="row.uid" class="detail-ro-row">
|
||||||
|
<div class="detail-ro-head">
|
||||||
|
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
||||||
|
<span class="detail-ro-scope">
|
||||||
|
{{ row.buildingName || '—' }} · {{ floorLabel(row.floorNo) }}
|
||||||
|
<template v-if="row.roomName"> · {{ row.roomName }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.photos.length" class="detail-ro-photos">
|
||||||
|
<a-image-preview-group>
|
||||||
|
<a-image
|
||||||
|
v-for="(p, pi) in row.photos"
|
||||||
|
:key="pi"
|
||||||
|
:src="p.url"
|
||||||
|
:width="110"
|
||||||
|
:height="82"
|
||||||
|
style="object-fit: cover; border-radius: 8px"
|
||||||
|
/>
|
||||||
|
</a-image-preview-group>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.remark" class="detail-ro-remark">备注:{{ row.remark }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -362,10 +463,6 @@
|
|||||||
<span class="ro-k">测绘进度</span>
|
<span class="ro-k">测绘进度</span>
|
||||||
<span class="ro-v mono">{{ roProgressDone }} / {{ roProgressTotal }} 路</span>
|
<span class="ro-v mono">{{ roProgressDone }} / {{ roProgressTotal }} 路</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ro-fact">
|
|
||||||
<span class="ro-k">SOP 回执</span>
|
|
||||||
<span class="ro-v mono">{{ roSopChecked }} / {{ roSopResults.length }} 项</span>
|
|
||||||
</div>
|
|
||||||
<div class="ro-fact">
|
<div class="ro-fact">
|
||||||
<span class="ro-k">现场照片</span>
|
<span class="ro-k">现场照片</span>
|
||||||
<span class="ro-v mono">{{ roPhotos.length }} 张</span>
|
<span class="ro-v mono">{{ roPhotos.length }} 张</span>
|
||||||
@@ -387,25 +484,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SOP 回执明细 -->
|
|
||||||
<div class="ro-block">
|
|
||||||
<div class="ro-block-title">SOP 回执明细</div>
|
|
||||||
<div v-if="roSopResults.length === 0" class="ro-empty">暂无 SOP 回执记录</div>
|
|
||||||
<div v-else class="ro-list">
|
|
||||||
<div v-for="(r, idx) in roSopResults" :key="r.id || idx" class="ro-sop-row">
|
|
||||||
<span class="ro-sop-check" :class="{ done: r.checked }">
|
|
||||||
<CheckOutlined v-if="r.checked" />
|
|
||||||
</span>
|
|
||||||
<span class="ro-sop-name">{{ r.itemName || r.sopItem?.itemName || `检查项 #${r.sopItemId}` }}</span>
|
|
||||||
<span class="ro-sop-tag">{{ receiptTypeLabel(r.receiptType || r.sopItem?.receiptType) }}</span>
|
|
||||||
<div v-if="r.receiptUrls && r.receiptUrls.length" class="ro-sop-thumbs">
|
|
||||||
<a-image v-for="(url, i) in r.receiptUrls" :key="i" :src="url" :width="56" :height="42" class="ro-sop-thumb" />
|
|
||||||
</div>
|
|
||||||
<span v-else class="ro-sop-value mono">{{ r.receiptValue?.length ? r.receiptValue.join('、') : r.remark || '—' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 现场取证照片 -->
|
<!-- 现场取证照片 -->
|
||||||
<div class="ro-block">
|
<div class="ro-block">
|
||||||
<div class="ro-block-title">现场取证照片</div>
|
<div class="ro-block-title">现场取证照片</div>
|
||||||
@@ -500,26 +578,27 @@ import {
|
|||||||
CheckCircleFilled,
|
CheckCircleFilled,
|
||||||
StarFilled,
|
StarFilled,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
ApartmentOutlined,
|
ApartmentOutlined,
|
||||||
FileDoneOutlined,
|
FileDoneOutlined,
|
||||||
ToolOutlined,
|
ToolOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import {
|
import {
|
||||||
surveyOrderApi,
|
surveyOrderApi,
|
||||||
SOP_TEMPLATE_CODE,
|
|
||||||
SOP_TEMPLATE_LABEL,
|
|
||||||
SURVEY_STATUS,
|
SURVEY_STATUS,
|
||||||
SURVEY_PRIORITY,
|
SURVEY_PRIORITY,
|
||||||
SURVEY_PRIORITY_OPTIONS,
|
SURVEY_PRIORITY_OPTIONS,
|
||||||
SURVEY_BASE_FEE,
|
SURVEY_BASE_FEE,
|
||||||
SURVEY_LOOP_FEE,
|
SURVEY_LOOP_FEE,
|
||||||
type SurveySopItem,
|
|
||||||
type SurveyQuote,
|
type SurveyQuote,
|
||||||
type SurveyOrderCreateParams,
|
type SurveyOrderCreateParams,
|
||||||
type SurveyOrderInfo,
|
type SurveyOrderInfo,
|
||||||
|
type SurveyOrderDetail,
|
||||||
} from '@/api/surveyOrder';
|
} from '@/api/surveyOrder';
|
||||||
import { workerApi, type WorkerRecommend } from '@/api/worker';
|
import { workerApi, type WorkerRecommend } from '@/api/worker';
|
||||||
import { customerApi, type CustomerInfo } from '@/api/customer';
|
import { customerApi, type CustomerInfo } from '@/api/customer';
|
||||||
|
import { customerCircuitApi, type CustomerTopologyResult, type TopologyBuilding } from '@/api/customerCircuit';
|
||||||
|
import { uploadApi } from '@/api/upload';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -527,16 +606,10 @@ const route = useRoute();
|
|||||||
// ==================== 步骤元数据 ====================
|
// ==================== 步骤元数据 ====================
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ value: 1, name: '基本信息与现场环境', sub: '目标客户、区域与回路' },
|
{ value: 1, name: '基本信息与现场环境', sub: '目标客户、区域与回路' },
|
||||||
{ value: 2, name: '技术要点与回执标准', sub: '标准SOP模版、拍照指标' },
|
{ value: 2, name: '勘察明细', sub: '建筑、楼层、房间与照片' },
|
||||||
{ value: 3, name: '指派电工与服务报酬', sub: '匹配电工、资质与费率' },
|
{ value: 3, name: '指派电工与服务报酬', sub: '匹配电工、资质与费率' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const RECEIPT_TYPE_LABEL: Record<string, string> = {
|
|
||||||
photo: '现场照片',
|
|
||||||
form: '清单格式',
|
|
||||||
meter: '手持测仪',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SLA_OPTIONS = [
|
const SLA_OPTIONS = [
|
||||||
{ value: 'SLA-GRADE A (24h回传)', label: 'SLA-GRADE A (24h回传)' },
|
{ value: 'SLA-GRADE A (24h回传)', label: 'SLA-GRADE A (24h回传)' },
|
||||||
{ value: 'SLA-GRADE B (48h回传)', label: 'SLA-GRADE B (48h回传)' },
|
{ value: 'SLA-GRADE B (48h回传)', label: 'SLA-GRADE B (48h回传)' },
|
||||||
@@ -571,8 +644,6 @@ const STATUS_META: Record<number, { label: string; color: string; bg: string }>
|
|||||||
|
|
||||||
const statusMeta = computed(() => (detailRecord.value ? STATUS_META[detailRecord.value.status] || null : null));
|
const statusMeta = computed(() => (detailRecord.value ? STATUS_META[detailRecord.value.status] || null : null));
|
||||||
const roPhotos = computed(() => (detailRecord.value?.photoUrls || []).filter((u) => !!u));
|
const roPhotos = computed(() => (detailRecord.value?.photoUrls || []).filter((u) => !!u));
|
||||||
const roSopResults = computed(() => detailRecord.value?.sopResults || []);
|
|
||||||
const roSopChecked = computed(() => roSopResults.value.filter((r) => r.checked).length);
|
|
||||||
const roProgressDone = computed(() => Number(detailRecord.value?.progressDone) || 0);
|
const roProgressDone = computed(() => Number(detailRecord.value?.progressDone) || 0);
|
||||||
const roProgressTotal = computed(() => Number(detailRecord.value?.progressTotal) || 0);
|
const roProgressTotal = computed(() => Number(detailRecord.value?.progressTotal) || 0);
|
||||||
const roProgressPercent = computed(() => {
|
const roProgressPercent = computed(() => {
|
||||||
@@ -625,13 +696,165 @@ const customerSelectOptions = computed(() =>
|
|||||||
);
|
);
|
||||||
const selectedCustomer = computed(() => customerOptions.value.find((c) => c.id === formState.customerId) || null);
|
const selectedCustomer = computed(() => customerOptions.value.find((c) => c.id === formState.customerId) || null);
|
||||||
|
|
||||||
// ==================== SOP 检查项 ====================
|
// ==================== 客户空间拓扑 ====================
|
||||||
const sopItems = ref<SurveySopItem[]>([]);
|
const topology = ref<CustomerTopologyResult | null>(null);
|
||||||
const sopLoading = ref(false);
|
const topologyLoading = ref(false);
|
||||||
|
const buildingSelectOptions = computed(() =>
|
||||||
|
(topology.value?.buildings || []).map((b) => ({ label: b.buildingName, value: b.id }))
|
||||||
|
);
|
||||||
|
|
||||||
function receiptTypeLabel(type?: string) {
|
function buildingById(id?: number): TopologyBuilding | undefined {
|
||||||
if (!type) return '—';
|
if (!id) return undefined;
|
||||||
return RECEIPT_TYPE_LABEL[type] || type;
|
return (topology.value?.buildings || []).find((b) => b.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 楼层选项:地上 1..floorCount,地下 负1..负floorDownCount;value 为字符串(地上 '1' / 地下 '-1')
|
||||||
|
function buildFloorOptions(b?: TopologyBuilding): { label: string; value: string }[] {
|
||||||
|
if (!b) return [];
|
||||||
|
const up = b.floorCount ?? 0;
|
||||||
|
const down = b.floorDownCount ?? 0;
|
||||||
|
const opts: { label: string; value: string }[] = [];
|
||||||
|
for (let i = down; i >= 1; i--) opts.push({ label: `负${i}层`, value: String(-i) });
|
||||||
|
for (let i = 1; i <= up; i++) opts.push({ label: `${i}层`, value: String(i) });
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function floorOptionsFor(buildingId?: number) {
|
||||||
|
return buildFloorOptions(buildingById(buildingId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function floorLabel(floorNo?: string | null) {
|
||||||
|
if (floorNo === undefined || floorNo === null || floorNo === '') return '未标注楼层';
|
||||||
|
const n = Number(floorNo);
|
||||||
|
if (Number.isNaN(n)) return String(floorNo);
|
||||||
|
return n < 0 ? `负${Math.abs(n)}层` : `${n}层`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomOptionsFor(row: DetailRow) {
|
||||||
|
const b = buildingById(row.buildingId);
|
||||||
|
if (!b || !row.floorNo) return [];
|
||||||
|
return b.floors
|
||||||
|
.filter((f) => (f.floorNo || '') === row.floorNo)
|
||||||
|
.flatMap((f) => f.rooms)
|
||||||
|
.map((r) => ({ label: r.roomName, value: r.id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTopology() {
|
||||||
|
if (!formState.customerId) {
|
||||||
|
topology.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
topologyLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res: any = await customerCircuitApi.getTopology(formState.customerId);
|
||||||
|
if (res.code === 200 && res.data) {
|
||||||
|
topology.value = res.data;
|
||||||
|
} else {
|
||||||
|
topology.value = { buildings: [] };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
topology.value = { buildings: [] };
|
||||||
|
} finally {
|
||||||
|
topologyLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 勘察明细 ====================
|
||||||
|
interface DetailPhoto {
|
||||||
|
// 对象存储 key(提交时使用)
|
||||||
|
key: string;
|
||||||
|
// 预签名访问 URL(预览使用)
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailRow {
|
||||||
|
uid: number;
|
||||||
|
buildingId?: number;
|
||||||
|
buildingName?: string;
|
||||||
|
floorNo?: string;
|
||||||
|
roomId?: number;
|
||||||
|
roomName?: string;
|
||||||
|
photos: DetailPhoto[];
|
||||||
|
remark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DETAIL_PHOTOS = 9;
|
||||||
|
let detailUidSeed = 0;
|
||||||
|
const nextDetailUid = () => ++detailUidSeed;
|
||||||
|
|
||||||
|
const detailRows = ref<DetailRow[]>([]);
|
||||||
|
|
||||||
|
function addDetailRow() {
|
||||||
|
detailRows.value.push({ uid: nextDetailUid(), photos: [], remark: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDetailRow(index: number) {
|
||||||
|
detailRows.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowBuildingChange(row: DetailRow) {
|
||||||
|
row.buildingName = buildingById(row.buildingId)?.buildingName;
|
||||||
|
row.floorNo = undefined;
|
||||||
|
row.roomId = undefined;
|
||||||
|
row.roomName = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowFloorChange(row: DetailRow) {
|
||||||
|
row.roomId = undefined;
|
||||||
|
row.roomName = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowRoomChange(row: DetailRow) {
|
||||||
|
const room = roomOptionsFor(row).find((r) => r.value === row.roomId);
|
||||||
|
row.roomName = room?.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beforePhotoUpload(file: File) {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('仅支持上传图片文件');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
message.error('图片大小不能超过 10MB');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePhotoUpload(options: any, row: DetailRow) {
|
||||||
|
const { file, onSuccess, onError } = options;
|
||||||
|
try {
|
||||||
|
const res: any = await uploadApi.uploadImage(file as File);
|
||||||
|
if (res.code === 200 && res.data) {
|
||||||
|
row.photos.push({ key: res.data.key, url: res.data.url });
|
||||||
|
onSuccess?.(res.data);
|
||||||
|
} else {
|
||||||
|
throw new Error(res.message || '上传失败');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '照片上传失败');
|
||||||
|
onError?.(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePhoto(row: DetailRow, index: number) {
|
||||||
|
row.photos.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅保留建筑与楼层均完整的明细行(后端同样会丢弃缺项行)
|
||||||
|
function buildDetails(): SurveyOrderDetail[] {
|
||||||
|
return detailRows.value
|
||||||
|
.filter((row) => !!row.buildingId && !!row.floorNo)
|
||||||
|
.map((row) => ({
|
||||||
|
buildingId: row.buildingId,
|
||||||
|
buildingName: row.buildingName || buildingById(row.buildingId)?.buildingName || undefined,
|
||||||
|
floorNo: row.floorNo,
|
||||||
|
roomId: row.roomId ?? undefined,
|
||||||
|
roomName: row.roomName || undefined,
|
||||||
|
photos: row.photos.filter((p) => !!p.key).map((p) => p.key),
|
||||||
|
remark: row.remark.trim() || undefined,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 报酬与电工 ====================
|
// ==================== 报酬与电工 ====================
|
||||||
@@ -676,7 +899,7 @@ function clearError(key: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goLedger() {
|
function goLedger() {
|
||||||
router.push({ name: 'ProjectSurvey' });
|
router.push({ name: 'DispatchSurvey' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCustomerChange() {
|
function handleCustomerChange() {
|
||||||
@@ -690,6 +913,9 @@ function handleCustomerChange() {
|
|||||||
formState.techContact = `${customer.contactPerson}${customer.contactPhone ? ` - ${customer.contactPhone}` : ''}`;
|
formState.techContact = `${customer.contactPerson}${customer.contactPhone ? ` - ${customer.contactPhone}` : ''}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 客户变更后拓扑重建,清空已填明细避免残留失效的建筑/楼层
|
||||||
|
detailRows.value = [];
|
||||||
|
loadTopology();
|
||||||
loadWorkers();
|
loadWorkers();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,18 +924,6 @@ function selectWorker(w: WorkerRecommend) {
|
|||||||
formState.workerId = w.id;
|
formState.workerId = w.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySopTemplate() {
|
|
||||||
if (sopItems.value.length === 0) {
|
|
||||||
loadSopItems();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
message.success(`已应用${SOP_TEMPLATE_LABEL} ${sopItems.value.length} 项检查标准`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAddCustomItem() {
|
|
||||||
message.info('自定义勘察检查项功能暂未开放');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 数据加载 ====================
|
// ==================== 数据加载 ====================
|
||||||
async function loadCustomers() {
|
async function loadCustomers() {
|
||||||
try {
|
try {
|
||||||
@@ -723,20 +937,6 @@ async function loadCustomers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSopItems() {
|
|
||||||
sopLoading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await surveyOrderApi.getSopItems(SOP_TEMPLATE_CODE);
|
|
||||||
if (res.code === 200 && res.data) {
|
|
||||||
sopItems.value = res.data;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error('SOP 检查项加载失败');
|
|
||||||
} finally {
|
|
||||||
sopLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadQuote() {
|
async function loadQuote() {
|
||||||
const count = Number(formState.loopCount) || 0;
|
const count = Number(formState.loopCount) || 0;
|
||||||
try {
|
try {
|
||||||
@@ -802,6 +1002,17 @@ async function loadDetail() {
|
|||||||
d.planEnd ? dayjs(d.planEnd) : null,
|
d.planEnd ? dayjs(d.planEnd) : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
// 回填勘察明细:photos 为对象 key,photoUrls 为预览地址(顺序一致)
|
||||||
|
detailRows.value = (d.details || []).map((dt) => ({
|
||||||
|
uid: nextDetailUid(),
|
||||||
|
buildingId: dt.buildingId ?? undefined,
|
||||||
|
buildingName: dt.buildingName ?? undefined,
|
||||||
|
floorNo: dt.floorNo ?? undefined,
|
||||||
|
roomId: dt.roomId ?? undefined,
|
||||||
|
roomName: dt.roomName ?? undefined,
|
||||||
|
photos: (dt.photos || []).map((key, i) => ({ key, url: dt.photoUrls?.[i] || '' })),
|
||||||
|
remark: dt.remark || '',
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
message.error(res.message || '获取勘察派单详情失败');
|
message.error(res.message || '获取勘察派单详情失败');
|
||||||
}
|
}
|
||||||
@@ -857,7 +1068,7 @@ function buildPayload(isDraft: number): SurveyOrderCreateParams {
|
|||||||
remark: formState.remark.trim() || undefined,
|
remark: formState.remark.trim() || undefined,
|
||||||
source: 'ledger',
|
source: 'ledger',
|
||||||
isDraft,
|
isDraft,
|
||||||
templateCode: SOP_TEMPLATE_CODE,
|
details: buildDetails(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,15 +1122,16 @@ async function handleSubmit(draft: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 客户拜访台账「一键发起勘察派单」跳转预填:/project/survey/create?customerId=xx
|
// 客户拜访台账「一键发起勘察派单」跳转预填:/dispatch/survey/create?customerId=xx
|
||||||
const preCustomerId = Number(route.query.customerId || 0);
|
const preCustomerId = Number(route.query.customerId || 0);
|
||||||
if (preCustomerId && !isEdit.value) {
|
if (preCustomerId && !isEdit.value) {
|
||||||
formState.customerId = preCustomerId;
|
formState.customerId = preCustomerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadCustomers();
|
await loadCustomers();
|
||||||
await Promise.all([loadSopItems(), loadQuote(), loadWorkers()]);
|
// 编辑态先取详情(含 customerId 与明细回填),再按客户加载空间拓扑
|
||||||
await loadDetail();
|
await loadDetail();
|
||||||
|
await Promise.all([loadTopology(), loadQuote(), loadWorkers()]);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1338,107 +1550,178 @@ onMounted(async () => {
|
|||||||
background: #0054cd;
|
background: #0054cd;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 第 2 步:SOP ---------- */
|
/* ---------- 第 2 步:勘察明细 ---------- */
|
||||||
.tmpl-box {
|
.head-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.tmpl-btn {
|
.detail-body {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 4px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: rgba(113, 42, 226, 0.1);
|
|
||||||
color: #712ae2;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
.tmpl-btn:hover {
|
|
||||||
background: rgba(113, 42, 226, 0.2);
|
|
||||||
}
|
|
||||||
.sop-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 18px 20px 20px;
|
padding: 18px 20px 20px;
|
||||||
}
|
}
|
||||||
.sop-grid.is-empty {
|
.detail-empty {
|
||||||
grid-template-columns: 1fr;
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5b6b7e;
|
||||||
|
background: #eef4ff;
|
||||||
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.sop-card {
|
.detail-rows {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
gap: 12px;
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid rgba(219, 228, 238, 0.5);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #eef4ff;
|
|
||||||
}
|
}
|
||||||
.sop-top {
|
.detail-row {
|
||||||
display: flex;
|
border: 1px solid #dbe4ee;
|
||||||
align-items: flex-start;
|
border-radius: 10px;
|
||||||
justify-content: space-between;
|
background: #f8fafc;
|
||||||
gap: 8px;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.sop-name {
|
.detail-row-head {
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0d1c2c;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.sop-required {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: rgba(220, 38, 38, 0.1);
|
|
||||||
color: #dc2626;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.sop-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: #5b6b7e;
|
|
||||||
}
|
|
||||||
.sop-foot {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding-top: 8px;
|
gap: 8px;
|
||||||
border-top: 1px solid rgba(219, 228, 238, 0.5);
|
padding: 8px 14px;
|
||||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
background: #eef4ff;
|
||||||
|
border-bottom: 1px solid #e2eaf5;
|
||||||
|
}
|
||||||
|
.detail-row-idx {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #0054cd;
|
color: #0054cd;
|
||||||
}
|
}
|
||||||
.sop-add {
|
.detail-grid {
|
||||||
min-height: 96px;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.dfield {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.dfield.span-all {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.dfield-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1c2c;
|
||||||
|
}
|
||||||
|
.dfield-label .opt {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #8a97a8;
|
||||||
|
}
|
||||||
|
.detail-photos {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.detail-photos :deep(.ant-image) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.detail-photos :deep(.ant-image img) {
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
}
|
||||||
|
.photo-thumb {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.photo-del {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(13, 28, 44, 0.72);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.photo-del:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
.photo-add {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
|
width: 76px;
|
||||||
|
height: 57px;
|
||||||
border: 1px dashed #c2c6d8;
|
border: 1px dashed #c2c6d8;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
color: #5b6b7e;
|
color: #5b6b7e;
|
||||||
font-size: 13px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
.sop-add:hover {
|
.photo-add:hover:not(:disabled) {
|
||||||
border-color: rgba(0, 84, 205, 0.5);
|
border-color: rgba(0, 84, 205, 0.5);
|
||||||
color: #0054cd;
|
color: #0054cd;
|
||||||
}
|
}
|
||||||
.sop-add-icon {
|
.photo-add:disabled {
|
||||||
font-size: 22px;
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
/* 只读态明细 */
|
||||||
|
.detail-ro-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.detail-ro-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.detail-ro-scope {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1c2c;
|
||||||
|
}
|
||||||
|
.detail-ro-photos {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.detail-ro-photos :deep(.ant-image img) {
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.detail-ro-remark {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #5b6b7e;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 第 3 步:电工与报酬 ---------- */
|
/* ---------- 第 3 步:电工与报酬 ---------- */
|
||||||
@@ -1719,61 +2002,6 @@ onMounted(async () => {
|
|||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.ro-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.ro-sop-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 20px 1fr 88px 1.2fr;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f8fafc;
|
|
||||||
font-size: 12.5px;
|
|
||||||
color: #0d1c2c;
|
|
||||||
}
|
|
||||||
.ro-sop-check {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 1px solid #dbe4ee;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #fff;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.ro-sop-check.done {
|
|
||||||
background: #00685f;
|
|
||||||
border-color: #00685f;
|
|
||||||
}
|
|
||||||
.ro-sop-name {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.ro-sop-tag {
|
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #712ae2;
|
|
||||||
}
|
|
||||||
.ro-sop-value {
|
|
||||||
font-size: 11.5px;
|
|
||||||
color: #5b6b7e;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.ro-sop-thumbs {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.ro-sop-thumb :deep(img) {
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid #dbe4ee;
|
|
||||||
}
|
|
||||||
.ro-photo-list {
|
.ro-photo-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1868,7 +2096,7 @@ onMounted(async () => {
|
|||||||
.field-grid {
|
.field-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.sop-grid {
|
.detail-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.cand-grid {
|
.cand-grid {
|
||||||
+59
-46
@@ -514,29 +514,31 @@
|
|||||||
<div v-else class="detail-text">—</div>
|
<div v-else class="detail-text">—</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="detailRecord.sopResults?.length" class="detail-block">
|
<div v-if="detailRecord.details?.length" class="detail-block">
|
||||||
<div class="detail-label">
|
<div class="detail-label">勘察明细({{ detailRecord.details.length }} 条)</div>
|
||||||
勘察回执({{ detailSopDone }} / {{ detailRecord.sopResults.length }} 项已完成)
|
<div class="detail-list">
|
||||||
</div>
|
<div v-for="(row, idx) in detailRecord.details" :key="row.id ?? idx" class="detail-row">
|
||||||
<div v-for="row in detailRecord.sopResults" :key="row.id" class="sop-item">
|
<div class="detail-row-head">
|
||||||
<div class="sop-item-head">
|
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
||||||
<span class="sop-item-name">{{ row.itemName || row.sopItem?.itemName || '检查项' }}</span>
|
<span class="detail-row-scope">
|
||||||
<span class="chip" :style="sopChipStyle(row.checked)">{{ row.checked ? '已回执' : '未回执' }}</span>
|
{{ row.buildingName || '—' }} · {{ floorLabel(row.floorNo) }}
|
||||||
|
<template v-if="row.roomName"> · {{ row.roomName }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.photoUrls?.length" class="photo-list">
|
||||||
|
<a-image-preview-group>
|
||||||
|
<a-image
|
||||||
|
v-for="(url, pi) in row.photoUrls"
|
||||||
|
:key="pi"
|
||||||
|
:src="url"
|
||||||
|
:width="110"
|
||||||
|
:height="80"
|
||||||
|
style="object-fit: cover; border-radius: 8px"
|
||||||
|
/>
|
||||||
|
</a-image-preview-group>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.remark" class="detail-row-remark">备注:{{ row.remark }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="row.receiptUrls?.length" class="photo-list">
|
|
||||||
<a-image
|
|
||||||
v-for="(url, idx) in row.receiptUrls"
|
|
||||||
:key="idx"
|
|
||||||
:src="url"
|
|
||||||
:width="110"
|
|
||||||
:height="80"
|
|
||||||
style="object-fit: cover; border-radius: 8px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="row.receiptValue?.length" class="detail-text">
|
|
||||||
{{ row.receiptValue.join('、') }}
|
|
||||||
</div>
|
|
||||||
<div v-if="row.remark" class="sop-item-remark">备注:{{ row.remark }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -809,7 +811,7 @@ onMounted(() => {
|
|||||||
fetchWorkerOptions();
|
fetchWorkerOptions();
|
||||||
const preCustomerId = Number(route.query.customerId || 0);
|
const preCustomerId = Number(route.query.customerId || 0);
|
||||||
if (preCustomerId) {
|
if (preCustomerId) {
|
||||||
router.replace({ name: 'ProjectSurveyCreate', query: { customerId: String(preCustomerId) } });
|
router.replace({ name: 'DispatchSurveyCreate', query: { customerId: String(preCustomerId) } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -848,7 +850,7 @@ function handleExport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleAdd() {
|
function handleAdd() {
|
||||||
router.push({ name: 'ProjectSurveyCreate' });
|
router.push({ name: 'DispatchSurveyCreate' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 行内操作 ====================
|
// ==================== 行内操作 ====================
|
||||||
@@ -1120,9 +1122,6 @@ async function handleCancelSubmit() {
|
|||||||
// ==================== 详情 / 编辑 ====================
|
// ==================== 详情 / 编辑 ====================
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
const detailRecord = ref<SurveyOrderInfo | null>(null);
|
const detailRecord = ref<SurveyOrderInfo | null>(null);
|
||||||
const detailSopDone = computed(
|
|
||||||
() => (detailRecord.value?.sopResults || []).filter((row) => row.checked).length
|
|
||||||
);
|
|
||||||
|
|
||||||
async function openDetail(record: SurveyOrderInfo) {
|
async function openDetail(record: SurveyOrderInfo) {
|
||||||
detailRecord.value = record;
|
detailRecord.value = record;
|
||||||
@@ -1136,7 +1135,7 @@ async function openDetail(record: SurveyOrderInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goDetail(record: SurveyOrderInfo) {
|
function goDetail(record: SurveyOrderInfo) {
|
||||||
router.push({ name: 'ProjectSurveyEdit', params: { id: record.id } });
|
router.push({ name: 'DispatchSurveyEdit', params: { id: record.id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 展示辅助 ====================
|
// ==================== 展示辅助 ====================
|
||||||
@@ -1152,12 +1151,13 @@ function formatRange(start?: string, end?: string) {
|
|||||||
return `${s} ~ ${e}`;
|
return `${s} ~ ${e}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sopChipStyle = (checked?: boolean) =>
|
// 楼层:正数为地上、负数为地下(value 为字符串,如 '1' / '-1')
|
||||||
chipStyle(
|
function floorLabel(floorNo?: string | null) {
|
||||||
checked
|
if (floorNo === undefined || floorNo === null || floorNo === '') return '未标注楼层';
|
||||||
? { color: '#059669', bg: '#d1fae5', border: '#a7f3d0' }
|
const n = Number(floorNo);
|
||||||
: { color: '#94a3b8', bg: '#f8fafc', border: '#e2e8f0' }
|
if (Number.isNaN(n)) return String(floorNo);
|
||||||
);
|
return n < 0 ? `负${Math.abs(n)}层` : `${n}层`;
|
||||||
|
}
|
||||||
|
|
||||||
function money(value?: number | string) {
|
function money(value?: number | string) {
|
||||||
if (value == null || value === '') return '—';
|
if (value == null || value === '') return '—';
|
||||||
@@ -1926,33 +1926,46 @@ function maskPhone(phone?: string) {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sop-item {
|
.detail-list {
|
||||||
padding: 10px 0;
|
display: flex;
|
||||||
border-bottom: 1px dashed #eef1f6;
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sop-item:last-child {
|
.detail-row {
|
||||||
border-bottom: none;
|
display: flex;
|
||||||
padding-bottom: 0;
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #eef1f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sop-item-head {
|
.detail-row-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
margin-bottom: 6px;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sop-item-name {
|
.detail-row-idx {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0054cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row-scope {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #0d1c2c;
|
color: #0d1c2c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sop-item-remark {
|
.detail-row-remark {
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
color: #8a97a8;
|
color: #8a97a8;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* AntD 覆盖 */
|
/* AntD 覆盖 */
|
||||||
@@ -165,6 +165,19 @@
|
|||||||
<template #icon><AuditOutlined /></template>
|
<template #icon><AuditOutlined /></template>
|
||||||
审核
|
审核
|
||||||
</a-button>
|
</a-button>
|
||||||
|
<a-popconfirm
|
||||||
|
v-if="canResetPassword"
|
||||||
|
title="确定重置该电工的 APP 登录密码吗?"
|
||||||
|
description="重置后密码为手机号后 6 位"
|
||||||
|
ok-text="确定"
|
||||||
|
cancel-text="取消"
|
||||||
|
@confirm="handleResetPassword(record.id)"
|
||||||
|
>
|
||||||
|
<a-button type="link" size="small">
|
||||||
|
<template #icon><KeyOutlined /></template>
|
||||||
|
重置密码
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
v-if="canDelete"
|
v-if="canDelete"
|
||||||
title="确定要删除该电工吗?"
|
title="确定要删除该电工吗?"
|
||||||
@@ -240,8 +253,11 @@
|
|||||||
<a-form-item label="服务评分" name="rating">
|
<a-form-item label="服务评分" name="rating">
|
||||||
<a-input-number v-model:value="formData.rating" :min="0" :max="5" :step="0.1" style="width: 160px" />
|
<a-input-number v-model:value="formData.rating" :min="0" :max="5" :step="0.1" style="width: 160px" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="!isEdit" label="APP 登录账号">
|
<a-form-item label="APP 登录账号">
|
||||||
<div class="switch-line">
|
<div v-if="isEdit && formData.hasAccount" class="switch-line">
|
||||||
|
<span class="form-tip">该电工已开通 APP 登录账号,如需重置密码请使用列表中的「重置密码」。</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="switch-line">
|
||||||
<a-switch v-model:checked="formData.withAccount" />
|
<a-switch v-model:checked="formData.withAccount" />
|
||||||
<span class="form-tip">开启后同时创建电工 APP 登录密码,默认密码为手机号后 6 位</span>
|
<span class="form-tip">开启后同时创建电工 APP 登录密码,默认密码为手机号后 6 位</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -381,6 +397,7 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
AuditOutlined,
|
AuditOutlined,
|
||||||
|
KeyOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
@@ -425,6 +442,7 @@ const canCreate = computed(() => permissionStore.hasPermission('worker:create'))
|
|||||||
const canUpdate = computed(() => permissionStore.hasPermission('worker:update'));
|
const canUpdate = computed(() => permissionStore.hasPermission('worker:update'));
|
||||||
const canDelete = computed(() => permissionStore.hasPermission('worker:delete'));
|
const canDelete = computed(() => permissionStore.hasPermission('worker:delete'));
|
||||||
const canAudit = computed(() => permissionStore.hasPermission('worker:audit'));
|
const canAudit = computed(() => permissionStore.hasPermission('worker:audit'));
|
||||||
|
const canResetPassword = computed(() => permissionStore.hasPermission('worker:reset-password'));
|
||||||
|
|
||||||
// ==================== 列表数据 ====================
|
// ==================== 列表数据 ====================
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -501,7 +519,7 @@ const columns = [
|
|||||||
{ title: '在岗状态', key: 'status', align: 'center' as const, width: 110 },
|
{ title: '在岗状态', key: 'status', align: 'center' as const, width: 110 },
|
||||||
{ title: '评分', key: 'rating', align: 'center' as const, width: 90 },
|
{ title: '评分', key: 'rating', align: 'center' as const, width: 90 },
|
||||||
{ title: '完成数', dataIndex: 'completedCount', key: 'completedCount', align: 'center' as const, width: 90 },
|
{ title: '完成数', dataIndex: 'completedCount', key: 'completedCount', align: 'center' as const, width: 90 },
|
||||||
{ title: '操作', key: 'action', width: 210, fixed: 'right' as const },
|
{ title: '操作', key: 'action', width: 300, fixed: 'right' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
const pagination = computed(() => ({
|
const pagination = computed(() => ({
|
||||||
@@ -529,6 +547,8 @@ const formData = reactive({
|
|||||||
status: 1,
|
status: 1,
|
||||||
rating: 5,
|
rating: 5,
|
||||||
withAccount: false,
|
withAccount: false,
|
||||||
|
// 编辑时用于判断该电工是否已开通 APP 账号
|
||||||
|
hasAccount: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const formRules = {
|
const formRules = {
|
||||||
@@ -664,6 +684,8 @@ async function handleEdit(record: WorkerRecord) {
|
|||||||
formData.workerTitle = record.workerTitle || '';
|
formData.workerTitle = record.workerTitle || '';
|
||||||
formData.status = record.status;
|
formData.status = record.status;
|
||||||
formData.rating = Number(record.rating ?? 5);
|
formData.rating = Number(record.rating ?? 5);
|
||||||
|
formData.hasAccount = record.hasAccount === true;
|
||||||
|
formData.withAccount = false;
|
||||||
|
|
||||||
modalVisible.value = true;
|
modalVisible.value = true;
|
||||||
await loadCertRows(record.id);
|
await loadCertRows(record.id);
|
||||||
@@ -674,6 +696,7 @@ async function loadCertRows(workerId: number) {
|
|||||||
try {
|
try {
|
||||||
const res: any = await workerRegistryApi.getDetail(workerId);
|
const res: any = await workerRegistryApi.getDetail(workerId);
|
||||||
if (res.code === 200 && res.data) {
|
if (res.code === 200 && res.data) {
|
||||||
|
formData.hasAccount = res.data.hasAccount === true;
|
||||||
const certs: WorkerCert[] = res.data.certs || [];
|
const certs: WorkerCert[] = res.data.certs || [];
|
||||||
certRows.value = certs.map((c) => ({
|
certRows.value = certs.map((c) => ({
|
||||||
_key: certKeySeed++,
|
_key: certKeySeed++,
|
||||||
@@ -700,6 +723,7 @@ function resetForm() {
|
|||||||
formData.status = 1;
|
formData.status = 1;
|
||||||
formData.rating = 5;
|
formData.rating = 5;
|
||||||
formData.withAccount = false;
|
formData.withAccount = false;
|
||||||
|
formData.hasAccount = false;
|
||||||
certRows.value = [];
|
certRows.value = [];
|
||||||
|
|
||||||
if (formRef.value) {
|
if (formRef.value) {
|
||||||
@@ -822,6 +846,8 @@ async function handleSubmit() {
|
|||||||
let res: any;
|
let res: any;
|
||||||
if (isEdit.value && editId.value) {
|
if (isEdit.value && editId.value) {
|
||||||
payload.status = formData.status;
|
payload.status = formData.status;
|
||||||
|
// 补开 APP 账号:仅对尚未开通的电工生效
|
||||||
|
payload.withAccount = formData.withAccount;
|
||||||
res = await workerRegistryApi.update(editId.value, payload);
|
res = await workerRegistryApi.update(editId.value, payload);
|
||||||
} else {
|
} else {
|
||||||
payload.withAccount = formData.withAccount;
|
payload.withAccount = formData.withAccount;
|
||||||
@@ -880,6 +906,21 @@ async function handleDelete(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 重置密码 ====================
|
||||||
|
async function handleResetPassword(id: number) {
|
||||||
|
try {
|
||||||
|
const res: any = await workerRegistryApi.resetPassword(id);
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('密码已重置为手机号后 6 位');
|
||||||
|
fetchList();
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '重置密码失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('重置密码失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 审核 ====================
|
// ==================== 审核 ====================
|
||||||
function handleAudit(record: WorkerRecord) {
|
function handleAudit(record: WorkerRecord) {
|
||||||
auditRecord.value = record;
|
auditRecord.value = record;
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
<div class="logo-icon">
|
<div class="logo-icon">
|
||||||
<ThunderboltOutlined />
|
<ThunderboltOutlined />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="title">智能电网设备管理系统</h1>
|
<h1 class="title">鑫潼智电管理系统</h1>
|
||||||
<p class="subtitle">Smart Grid IoT Management System</p>
|
<p class="subtitle">XINTONG SMART POWER MANAGEMENT SYSTEM</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-divider />
|
<a-divider />
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="copyright">
|
<div class="copyright">
|
||||||
<p>© 2026 智能电网设备管理系统 All Rights Reserved</p>
|
<p>© 2026 鑫潼智电管理系统 All Rights Reserved</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -53,6 +53,12 @@
|
|||||||
<span>{{ record.jobName }}</span>
|
<span>{{ record.jobName }}</span>
|
||||||
<a-tag v-if="record.registered === false" color="red" style="margin-left: 6px">未注册</a-tag>
|
<a-tag v-if="record.registered === false" color="red" style="margin-left: 6px">未注册</a-tag>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="column.key === 'frequency'">
|
||||||
|
<div class="freq-cell">
|
||||||
|
<span>{{ record.cronDescription || record.cronExpression }}</span>
|
||||||
|
<code class="freq-cron">{{ record.cronExpression }}</code>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template v-else-if="column.key === 'params'">
|
<template v-else-if="column.key === 'params'">
|
||||||
<a-tooltip :title="formatParams(record.params, true)">
|
<a-tooltip :title="formatParams(record.params, true)">
|
||||||
<span class="ellipsis-text">{{ formatParams(record.params) }}</span>
|
<span class="ellipsis-text">{{ formatParams(record.params) }}</span>
|
||||||
@@ -111,7 +117,7 @@
|
|||||||
title="编辑任务"
|
title="编辑任务"
|
||||||
:confirm-loading="saving"
|
:confirm-loading="saving"
|
||||||
:mask-closable="false"
|
:mask-closable="false"
|
||||||
width="640px"
|
width="720px"
|
||||||
@ok="handleSave"
|
@ok="handleSave"
|
||||||
@cancel="handleCancelEdit"
|
@cancel="handleCancelEdit"
|
||||||
>
|
>
|
||||||
@@ -125,9 +131,110 @@
|
|||||||
<a-form-item label="任务名称">
|
<a-form-item label="任务名称">
|
||||||
<span>{{ currentJob?.jobName || '-' }}</span>
|
<span>{{ currentJob?.jobName || '-' }}</span>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="cron 表达式" name="cronExpression">
|
|
||||||
<a-input v-model:value="formData.cronExpression" placeholder="如 0 3 * * *" :maxlength="64" />
|
<a-divider orientation="left" class="form-divider">执行频率</a-divider>
|
||||||
|
|
||||||
|
<a-form-item label="频率类型">
|
||||||
|
<a-select v-model:value="formData.freqType" :options="freqTypeOptions" style="width: 100%" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="formData.freqType === 'minute'" label="间隔">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="formData.interval"
|
||||||
|
:min="1"
|
||||||
|
:max="59"
|
||||||
|
addon-after="分钟"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<template v-if="formData.freqType === 'hour'">
|
||||||
|
<a-form-item label="间隔">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="formData.interval"
|
||||||
|
:min="1"
|
||||||
|
:max="23"
|
||||||
|
addon-after="小时"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="第几分钟">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="formData.minute"
|
||||||
|
:min="0"
|
||||||
|
:max="59"
|
||||||
|
addon-after="分"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-form-item v-if="formData.freqType === 'week'" label="星期">
|
||||||
|
<a-checkbox-group v-model:value="formData.weekdays" :options="weekdayOptions" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="formData.freqType === 'month'" label="日期">
|
||||||
|
<a-select
|
||||||
|
v-model:value="formData.days"
|
||||||
|
mode="multiple"
|
||||||
|
:options="dayOptions"
|
||||||
|
:max-tag-count="6"
|
||||||
|
placeholder="请选择每月的执行日期"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="hasTimeList" label="执行时间">
|
||||||
|
<div class="time-list">
|
||||||
|
<div v-for="(_, index) in formData.times" :key="index" class="time-row">
|
||||||
|
<a-time-picker v-model:value="formData.times[index]" format="HH:mm" :allow-clear="false" />
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
:disabled="formData.times.length <= 1"
|
||||||
|
@click="removeTime(index)"
|
||||||
|
>
|
||||||
|
<template #icon><MinusCircleOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<a-button type="dashed" block :disabled="formData.times.length >= 10" @click="addTime">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
添加时间
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="formData.freqType === 'custom'" label="cron 表达式">
|
||||||
|
<a-input v-model:value="formData.expression" placeholder="如 0 3 * * *(分 时 日 月 周)" :maxlength="64" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="执行预览">
|
||||||
|
<a-spin :spinning="previewLoading" size="small">
|
||||||
|
<div class="freq-preview">
|
||||||
|
<a-alert v-if="preview.error" type="warning" :message="preview.error" show-icon />
|
||||||
|
<template v-else>
|
||||||
|
<div class="preview-line">
|
||||||
|
<span class="preview-label">表达式</span>
|
||||||
|
<code>{{ preview.cronExpression || '-' }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="preview-line">
|
||||||
|
<span class="preview-label">说明</span>
|
||||||
|
<span>{{ preview.description || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-line">
|
||||||
|
<span class="preview-label">下次执行</span>
|
||||||
|
<span v-if="!preview.nextRuns.length">-</span>
|
||||||
|
<ol v-else class="next-list">
|
||||||
|
<li v-for="(time, index) in preview.nextRuns" :key="index">{{ formatDateTime(time) }}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-divider v-if="paramFields.length" orientation="left" class="form-divider">任务参数</a-divider>
|
||||||
|
|
||||||
<a-form-item
|
<a-form-item
|
||||||
v-for="field in paramFields"
|
v-for="field in paramFields"
|
||||||
:key="field.key"
|
:key="field.key"
|
||||||
@@ -149,6 +256,9 @@
|
|||||||
:placeholder="field.placeholder || `请输入${field.label}`"
|
:placeholder="field.placeholder || `请输入${field.label}`"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-divider orientation="left" class="form-divider">失败重试与备注</a-divider>
|
||||||
|
|
||||||
<a-form-item label="重试次数" name="retryTimes">
|
<a-form-item label="重试次数" name="retryTimes">
|
||||||
<a-input-number v-model:value="formData.retryTimes" :min="0" :max="5" style="width: 100%" />
|
<a-input-number v-model:value="formData.retryTimes" :min="0" :max="5" style="width: 100%" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -228,7 +338,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
@@ -237,9 +347,18 @@ import {
|
|||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
ProfileOutlined,
|
ProfileOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
MinusCircleOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs, { type Dayjs } from 'dayjs'
|
||||||
import { jobApi, type JobItem, type JobParamField, type JobLogItem } from '@/api/job'
|
import {
|
||||||
|
jobApi,
|
||||||
|
type JobItem,
|
||||||
|
type JobParamField,
|
||||||
|
type JobLogItem,
|
||||||
|
type FreqType,
|
||||||
|
type FreqConfig,
|
||||||
|
} from '@/api/job'
|
||||||
import { useTableScroll } from '@/composables/useTableScroll'
|
import { useTableScroll } from '@/composables/useTableScroll'
|
||||||
|
|
||||||
const { wrapperRef, tableHeight } = useTableScroll()
|
const { wrapperRef, tableHeight } = useTableScroll()
|
||||||
@@ -260,7 +379,7 @@ const queryParams = reactive({
|
|||||||
const columns = [
|
const columns = [
|
||||||
{ title: '任务名称', dataIndex: 'jobName', key: 'jobName', width: 180 },
|
{ title: '任务名称', dataIndex: 'jobName', key: 'jobName', width: 180 },
|
||||||
{ title: '任务键', dataIndex: 'jobKey', key: 'jobKey', width: 150, ellipsis: true },
|
{ title: '任务键', dataIndex: 'jobKey', key: 'jobKey', width: 150, ellipsis: true },
|
||||||
{ title: 'cron 表达式', dataIndex: 'cronExpression', key: 'cronExpression', width: 150 },
|
{ title: '执行频率', key: 'frequency', width: 200 },
|
||||||
{ title: '参数', dataIndex: 'params', key: 'params', width: 180, ellipsis: true },
|
{ title: '参数', dataIndex: 'params', key: 'params', width: 180, ellipsis: true },
|
||||||
{ title: '状态', key: 'status', width: 100, align: 'center' as const },
|
{ title: '状态', key: 'status', width: 100, align: 'center' as const },
|
||||||
{ title: '上次执行时间', dataIndex: 'lastRunAt', key: 'lastRunAt', width: 170 },
|
{ title: '上次执行时间', dataIndex: 'lastRunAt', key: 'lastRunAt', width: 170 },
|
||||||
@@ -286,24 +405,174 @@ const currentJob = ref<JobItem | null>(null)
|
|||||||
const paramFields = ref<JobParamField[]>([])
|
const paramFields = ref<JobParamField[]>([])
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
|
|
||||||
|
/** HH:mm 文本 → Dayjs(TimePicker 取值),不依赖 dayjs 自定义解析插件 */
|
||||||
|
function toTimeValue(text: string): Dayjs {
|
||||||
|
const [hour, minute] = text.split(':')
|
||||||
|
return dayjs().hour(Number(hour)).minute(Number(minute)).second(0).millisecond(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人性化频率表单:字段按 freqType 取用,提交时由 buildFreqConfig 组装成频率配置 */
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
cronExpression: '',
|
freqType: 'day' as FreqType,
|
||||||
|
interval: 5,
|
||||||
|
minute: 0,
|
||||||
|
times: [toTimeValue('08:00')] as Dayjs[],
|
||||||
|
weekdays: [1] as number[],
|
||||||
|
days: [1] as number[],
|
||||||
|
expression: '',
|
||||||
params: {} as Record<string, any>,
|
params: {} as Record<string, any>,
|
||||||
retryTimes: 0,
|
retryTimes: 0,
|
||||||
retryInterval: 60,
|
retryInterval: 60,
|
||||||
remark: '',
|
remark: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const freqTypeOptions: { value: FreqType; label: string }[] = [
|
||||||
|
{ value: 'minute', label: '每 N 分钟' },
|
||||||
|
{ value: 'hour', label: '每 N 小时' },
|
||||||
|
{ value: 'day', label: '每天' },
|
||||||
|
{ value: 'week', label: '每周' },
|
||||||
|
{ value: 'month', label: '每月' },
|
||||||
|
{ value: 'custom', label: '自定义(cron 表达式)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const weekdayOptions = [
|
||||||
|
{ value: 1, label: '周一' },
|
||||||
|
{ value: 2, label: '周二' },
|
||||||
|
{ value: 3, label: '周三' },
|
||||||
|
{ value: 4, label: '周四' },
|
||||||
|
{ value: 5, label: '周五' },
|
||||||
|
{ value: 6, label: '周六' },
|
||||||
|
{ value: 0, label: '周日' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const dayOptions = Array.from({ length: 31 }, (_, index) => ({
|
||||||
|
value: index + 1,
|
||||||
|
label: `${index + 1} 日`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** day/week/month 需要执行时间点列表 */
|
||||||
|
const hasTimeList = computed(() => ['day', 'week', 'month'].includes(formData.freqType))
|
||||||
|
|
||||||
|
/** 执行预览状态(由后端计算 cron / 中文描述 / 未来执行时间) */
|
||||||
|
const previewLoading = ref(false)
|
||||||
|
const preview = reactive({
|
||||||
|
valid: true,
|
||||||
|
error: '',
|
||||||
|
cronExpression: '',
|
||||||
|
description: '',
|
||||||
|
nextRuns: [] as string[],
|
||||||
|
})
|
||||||
|
|
||||||
const formRules = {
|
const formRules = {
|
||||||
cronExpression: [
|
|
||||||
{ required: true, message: '请输入 cron 表达式', trigger: 'blur' },
|
|
||||||
{ max: 64, message: 'cron 表达式长度不能超过 64', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
retryTimes: [{ required: true, type: 'number' as const, message: '请输入重试次数', trigger: 'change' }],
|
retryTimes: [{ required: true, type: 'number' as const, message: '请输入重试次数', trigger: 'change' }],
|
||||||
retryInterval: [{ required: true, type: 'number' as const, message: '请输入重试间隔', trigger: 'change' }],
|
retryInterval: [{ required: true, type: 'number' as const, message: '请输入重试间隔', trigger: 'change' }],
|
||||||
remark: [{ max: 255, message: '备注长度不能超过 255', trigger: 'blur' }],
|
remark: [{ max: 255, message: '备注长度不能超过 255', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 表单 → 频率配置(提交给后端的结构) */
|
||||||
|
function buildFreqConfig(): FreqConfig {
|
||||||
|
switch (formData.freqType) {
|
||||||
|
case 'minute':
|
||||||
|
return { interval: formData.interval }
|
||||||
|
case 'hour':
|
||||||
|
return { interval: formData.interval, minute: formData.minute }
|
||||||
|
case 'day':
|
||||||
|
return { times: timeTexts() }
|
||||||
|
case 'week':
|
||||||
|
return { weekdays: [...formData.weekdays].sort((a, b) => a - b), times: timeTexts() }
|
||||||
|
case 'month':
|
||||||
|
return { days: [...formData.days].sort((a, b) => a - b), times: timeTexts() }
|
||||||
|
default:
|
||||||
|
return { expression: formData.expression.trim() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeTexts(): string[] {
|
||||||
|
return formData.times.map(item => item.format('HH:mm'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 频率配置 → 表单(编辑回填) */
|
||||||
|
function applyFreqConfig(job: JobItem) {
|
||||||
|
const config: FreqConfig = job.freqConfig || {}
|
||||||
|
const type: FreqType = job.freqType || 'custom'
|
||||||
|
|
||||||
|
formData.freqType = type
|
||||||
|
formData.interval = config.interval ?? (type === 'hour' ? 1 : 5)
|
||||||
|
formData.minute = config.minute ?? 0
|
||||||
|
formData.times = (config.times?.length ? config.times : ['08:00']).map(item => toTimeValue(item))
|
||||||
|
formData.weekdays = config.weekdays?.length ? [...config.weekdays] : [1]
|
||||||
|
formData.days = config.days?.length ? [...config.days] : [1]
|
||||||
|
formData.expression = config.expression || job.cronExpression || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端校验之外的本地必填提示,避免空表单直接提交 */
|
||||||
|
function validateFreqConfig(): string {
|
||||||
|
if (hasTimeList.value && !formData.times.length) return '请至少添加 1 个执行时间'
|
||||||
|
if (formData.freqType === 'week' && !formData.weekdays.length) return '请至少选择 1 个星期'
|
||||||
|
if (formData.freqType === 'month' && !formData.days.length) return '请至少选择 1 个日期'
|
||||||
|
if (formData.freqType === 'custom' && !formData.expression.trim()) return '请输入 cron 表达式'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTime() {
|
||||||
|
if (formData.times.length >= 10) return
|
||||||
|
formData.times.push(toTimeValue('12:00'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTime(index: number) {
|
||||||
|
if (formData.times.length <= 1) return
|
||||||
|
formData.times.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let previewTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
/** 已预览过(且成功过)的频率签名,避免打开弹窗时重复请求 */
|
||||||
|
let previewSignature = ''
|
||||||
|
|
||||||
|
async function fetchPreview() {
|
||||||
|
if (!editVisible.value) return
|
||||||
|
|
||||||
|
const signature = JSON.stringify({ type: formData.freqType, config: buildFreqConfig() })
|
||||||
|
if (signature === previewSignature) return
|
||||||
|
|
||||||
|
previewLoading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await jobApi.previewCron({
|
||||||
|
freqType: formData.freqType,
|
||||||
|
freqConfig: buildFreqConfig(),
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
preview.valid = !!res.data.valid
|
||||||
|
preview.error = res.data.error || ''
|
||||||
|
preview.cronExpression = res.data.cronExpression || ''
|
||||||
|
preview.description = res.data.description || ''
|
||||||
|
preview.nextRuns = res.data.nextRuns || []
|
||||||
|
previewSignature = signature
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 预览失败不打断编辑,保存时后端仍会兜底校验
|
||||||
|
previewSignature = ''
|
||||||
|
} finally {
|
||||||
|
previewLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 频率任一字段变化 → 防抖后重新预览 */
|
||||||
|
watch(
|
||||||
|
() => JSON.stringify({ type: formData.freqType, config: buildFreqConfig() }),
|
||||||
|
() => {
|
||||||
|
clearTimeout(previewTimer)
|
||||||
|
previewTimer = setTimeout(fetchPreview, 300)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function resetPreview() {
|
||||||
|
preview.valid = true
|
||||||
|
preview.error = ''
|
||||||
|
preview.cronExpression = ''
|
||||||
|
preview.description = ''
|
||||||
|
preview.nextRuns = []
|
||||||
|
}
|
||||||
|
|
||||||
const logVisible = ref(false)
|
const logVisible = ref(false)
|
||||||
const logLoading = ref(false)
|
const logLoading = ref(false)
|
||||||
const logList = ref<JobLogItem[]>([])
|
const logList = ref<JobLogItem[]>([])
|
||||||
@@ -441,13 +710,16 @@ async function handleStatusChange(record: JobItem, checked: boolean) {
|
|||||||
function handleEdit(record: JobItem) {
|
function handleEdit(record: JobItem) {
|
||||||
currentJob.value = record
|
currentJob.value = record
|
||||||
paramFields.value = record.paramFields || []
|
paramFields.value = record.paramFields || []
|
||||||
formData.cronExpression = record.cronExpression || ''
|
applyFreqConfig(record)
|
||||||
formData.params = { ...(record.params || {}) }
|
formData.params = { ...(record.params || {}) }
|
||||||
formData.retryTimes = record.retryTimes ?? 0
|
formData.retryTimes = record.retryTimes ?? 0
|
||||||
formData.retryInterval = record.retryInterval ?? 60
|
formData.retryInterval = record.retryInterval ?? 60
|
||||||
formData.remark = record.remark || ''
|
formData.remark = record.remark || ''
|
||||||
|
resetPreview()
|
||||||
|
previewSignature = ''
|
||||||
editVisible.value = true
|
editVisible.value = true
|
||||||
nextTick(() => formRef.value?.clearValidate())
|
nextTick(() => formRef.value?.clearValidate())
|
||||||
|
fetchPreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancelEdit() {
|
function handleCancelEdit() {
|
||||||
@@ -462,10 +734,22 @@ async function handleSave() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const freqError = validateFreqConfig()
|
||||||
|
if (freqError) {
|
||||||
|
message.warning(freqError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preview.valid) {
|
||||||
|
message.warning(preview.error || '频率配置不合法')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await jobApi.updateJob(currentJob.value.id, {
|
const res: any = await jobApi.updateJob(currentJob.value.id, {
|
||||||
cronExpression: formData.cronExpression,
|
freqType: formData.freqType,
|
||||||
|
freqConfig: buildFreqConfig(),
|
||||||
params: { ...formData.params },
|
params: { ...formData.params },
|
||||||
retryTimes: formData.retryTimes,
|
retryTimes: formData.retryTimes,
|
||||||
retryInterval: formData.retryInterval,
|
retryInterval: formData.retryInterval,
|
||||||
@@ -572,6 +856,10 @@ function handleClearLogs() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchJobList()
|
fetchJobList()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimeout(previewTimer)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
@@ -635,6 +923,17 @@ onMounted(() => {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.freq-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
.freq-cron {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.error-stack {
|
.error-stack {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -646,4 +945,51 @@ onMounted(() => {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 弹窗内容被 teleport 到 body,样式不能嵌套在 .job-management 下 */
|
||||||
|
.form-divider {
|
||||||
|
margin: 4px 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #595959;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-list {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.time-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.freq-preview {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 22px;
|
||||||
|
|
||||||
|
.preview-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 56px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: #c41d7f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -66,6 +66,7 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
|
|||||||
+43
-266
@@ -41,7 +41,7 @@
|
|||||||
resolved "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz"
|
resolved "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz"
|
||||||
integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==
|
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"
|
version "7.29.7"
|
||||||
resolved "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz"
|
resolved "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz"
|
||||||
integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==
|
integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==
|
||||||
@@ -275,6 +275,14 @@
|
|||||||
"@babel/types" "^7.29.7"
|
"@babel/types" "^7.29.7"
|
||||||
debug "^4.3.1"
|
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":
|
"@babel/types@7.23.0":
|
||||||
version "7.23.0"
|
version "7.23.0"
|
||||||
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.23.0.tgz"
|
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.23.0.tgz"
|
||||||
@@ -284,14 +292,6 @@
|
|||||||
"@babel/helper-validator-identifier" "^7.22.20"
|
"@babel/helper-validator-identifier" "^7.22.20"
|
||||||
to-fast-properties "^2.0.0"
|
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":
|
"@ctrl/tinycolor@^3.4.0", "@ctrl/tinycolor@^3.5.0":
|
||||||
version "3.6.1"
|
version "3.6.1"
|
||||||
resolved "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz"
|
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"
|
resolved "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.8.1.tgz"
|
||||||
integrity sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==
|
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":
|
"@esbuild/win32-x64@0.21.5":
|
||||||
version "0.21.5"
|
version "0.21.5"
|
||||||
resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz"
|
resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz"
|
||||||
@@ -515,7 +405,7 @@
|
|||||||
"@nodelib/fs.stat" "2.0.5"
|
"@nodelib/fs.stat" "2.0.5"
|
||||||
run-parallel "^1.1.9"
|
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"
|
version "2.0.5"
|
||||||
resolved "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
|
resolved "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
|
||||||
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
|
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
|
||||||
@@ -528,121 +418,6 @@
|
|||||||
"@nodelib/fs.scandir" "2.1.5"
|
"@nodelib/fs.scandir" "2.1.5"
|
||||||
fastq "^1.6.0"
|
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":
|
"@rollup/rollup-win32-x64-gnu@4.60.4":
|
||||||
version "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"
|
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"
|
resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz"
|
||||||
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
|
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"
|
version "22.19.19"
|
||||||
resolved "https://registry.npmmirror.com/@types/node/-/node-22.19.19.tgz"
|
resolved "https://registry.npmmirror.com/@types/node/-/node-22.19.19.tgz"
|
||||||
integrity sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==
|
integrity sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==
|
||||||
@@ -688,7 +463,7 @@
|
|||||||
natural-compare "^1.4.0"
|
natural-compare "^1.4.0"
|
||||||
ts-api-utils "^1.3.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"
|
version "7.18.0"
|
||||||
resolved "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-7.18.0.tgz"
|
resolved "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-7.18.0.tgz"
|
||||||
integrity sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==
|
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"
|
resolved "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz"
|
||||||
integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==
|
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"
|
version "1.11.1"
|
||||||
resolved "https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz"
|
resolved "https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz"
|
||||||
integrity sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==
|
integrity sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==
|
||||||
dependencies:
|
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", "@volar/source-map@1.11.1":
|
||||||
version "1.11.1"
|
version "1.11.1"
|
||||||
resolved "https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz"
|
resolved "https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz"
|
||||||
integrity sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==
|
integrity sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==
|
||||||
@@ -828,7 +603,7 @@
|
|||||||
estree-walker "^2.0.2"
|
estree-walker "^2.0.2"
|
||||||
source-map-js "^1.2.1"
|
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"
|
version "3.5.34"
|
||||||
resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz"
|
resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz"
|
||||||
integrity sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==
|
integrity sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==
|
||||||
@@ -836,7 +611,7 @@
|
|||||||
"@vue/compiler-core" "3.5.34"
|
"@vue/compiler-core" "3.5.34"
|
||||||
"@vue/shared" "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"
|
version "3.5.34"
|
||||||
resolved "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz"
|
resolved "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz"
|
||||||
integrity sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==
|
integrity sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==
|
||||||
@@ -921,7 +696,7 @@
|
|||||||
"@vue/reactivity" "3.5.34"
|
"@vue/reactivity" "3.5.34"
|
||||||
"@vue/shared" "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"
|
version "3.5.34"
|
||||||
resolved "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz"
|
resolved "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz"
|
||||||
integrity sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==
|
integrity sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==
|
||||||
@@ -939,7 +714,7 @@
|
|||||||
"@vue/compiler-ssr" "3.5.34"
|
"@vue/compiler-ssr" "3.5.34"
|
||||||
"@vue/shared" "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"
|
version "3.5.34"
|
||||||
resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz"
|
resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz"
|
||||||
integrity sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==
|
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"
|
resolved "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
|
||||||
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
|
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"
|
version "8.16.0"
|
||||||
resolved "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz"
|
resolved "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz"
|
||||||
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
|
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
|
||||||
@@ -1127,7 +902,7 @@ braces@^3.0.3, braces@~3.0.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
fill-range "^7.1.1"
|
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"
|
version "4.28.2"
|
||||||
resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz"
|
resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz"
|
||||||
integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==
|
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"
|
resolved "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz"
|
||||||
integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==
|
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"
|
version "4.4.3"
|
||||||
resolved "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz"
|
resolved "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz"
|
||||||
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
||||||
@@ -1341,7 +1116,7 @@ dunder-proto@^1.0.1:
|
|||||||
es-errors "^1.3.0"
|
es-errors "^1.3.0"
|
||||||
gopd "^1.2.0"
|
gopd "^1.2.0"
|
||||||
|
|
||||||
echarts@^6.1.0:
|
echarts@^6.0.0, echarts@^6.1.0:
|
||||||
version "6.1.0"
|
version "6.1.0"
|
||||||
resolved "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz"
|
resolved "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz"
|
||||||
integrity sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==
|
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"
|
resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz"
|
||||||
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
|
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"
|
version "8.57.1"
|
||||||
resolved "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz"
|
resolved "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz"
|
||||||
integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==
|
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"
|
resolved "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz"
|
||||||
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
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:
|
function-bind@^1.1.2:
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz"
|
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"
|
resolved "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz"
|
||||||
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
||||||
|
|
||||||
jiti@^1.21.7:
|
jiti@^1.21.7, jiti@>=1.21.0:
|
||||||
version "1.21.7"
|
version "1.21.7"
|
||||||
resolved "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz"
|
resolved "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz"
|
||||||
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
|
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"
|
resolved "https://registry.npmmirror.com/kolorist/-/kolorist-1.8.0.tgz"
|
||||||
integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==
|
integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==
|
||||||
|
|
||||||
less@^4.6.4:
|
less@*, less@^4.6.4:
|
||||||
version "4.6.4"
|
version "4.6.4"
|
||||||
resolved "https://registry.npmmirror.com/less/-/less-4.6.4.tgz"
|
resolved "https://registry.npmmirror.com/less/-/less-4.6.4.tgz"
|
||||||
integrity sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==
|
integrity sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==
|
||||||
@@ -2067,7 +1837,14 @@ minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion "^1.1.7"
|
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"
|
version "9.0.9"
|
||||||
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz"
|
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz"
|
||||||
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
|
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"
|
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz"
|
||||||
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
|
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
|
||||||
|
|
||||||
picomatch@^4.0.4:
|
"picomatch@^3 || ^4", picomatch@^4.0.4:
|
||||||
version "4.0.4"
|
version "4.0.4"
|
||||||
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz"
|
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz"
|
||||||
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
|
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"
|
resolved "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
|
||||||
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
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"
|
version "8.5.15"
|
||||||
resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz"
|
resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz"
|
||||||
integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==
|
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"
|
resolved "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz"
|
||||||
integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
|
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:
|
tslib@^2.8.1:
|
||||||
version "2.8.1"
|
version "2.8.1"
|
||||||
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz"
|
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz"
|
||||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
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:
|
type-check@^0.4.0, type-check@~0.4.0:
|
||||||
version "0.4.0"
|
version "0.4.0"
|
||||||
resolved "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz"
|
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"
|
resolved "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz"
|
||||||
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
|
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"
|
version "5.3.3"
|
||||||
resolved "https://registry.npmmirror.com/typescript/-/typescript-5.3.3.tgz"
|
resolved "https://registry.npmmirror.com/typescript/-/typescript-5.3.3.tgz"
|
||||||
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
|
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
|
||||||
@@ -2731,7 +2508,7 @@ vite-plugin-vue-dev-locator@1.0.3:
|
|||||||
kolorist "^1.8.0"
|
kolorist "^1.8.0"
|
||||||
magic-string "^0.30.4"
|
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"
|
version "5.4.21"
|
||||||
resolved "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz"
|
resolved "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz"
|
||||||
integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==
|
integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==
|
||||||
@@ -2791,7 +2568,7 @@ vue-types@^3.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-plain-object "3.0.1"
|
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"
|
version "3.5.34"
|
||||||
resolved "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz"
|
resolved "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz"
|
||||||
integrity sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==
|
integrity sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==
|
||||||
|
|||||||
+183
-183
@@ -191,6 +191,7 @@ DROP TABLE IF EXISTS customer CASCADE;
|
|||||||
CREATE TABLE customer (
|
CREATE TABLE customer (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
customer_no VARCHAR(64) NOT NULL UNIQUE,
|
customer_no VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
icc_code VARCHAR(64),
|
||||||
customer_name VARCHAR(128) NOT NULL,
|
customer_name VARCHAR(128) NOT NULL,
|
||||||
province VARCHAR(64),
|
province VARCHAR(64),
|
||||||
city VARCHAR(64),
|
city VARCHAR(64),
|
||||||
@@ -209,6 +210,7 @@ CREATE TABLE customer (
|
|||||||
COMMENT ON TABLE customer IS '客户信息表';
|
COMMENT ON TABLE customer IS '客户信息表';
|
||||||
COMMENT ON COLUMN customer.id IS '自增主键';
|
COMMENT ON COLUMN customer.id IS '自增主键';
|
||||||
COMMENT ON COLUMN customer.customer_no IS '客户业务编号(KH+YYYYMMDD+3位序号)';
|
COMMENT ON COLUMN customer.customer_no IS '客户业务编号(KH+YYYYMMDD+3位序号)';
|
||||||
|
COMMENT ON COLUMN customer.icc_code IS 'ICC客户编码(ICC平台 orgCode,同步来源)';
|
||||||
COMMENT ON COLUMN customer.customer_name IS '客户名称';
|
COMMENT ON COLUMN customer.customer_name IS '客户名称';
|
||||||
COMMENT ON COLUMN customer.province IS '省';
|
COMMENT ON COLUMN customer.province IS '省';
|
||||||
COMMENT ON COLUMN customer.city IS '市';
|
COMMENT ON COLUMN customer.city IS '市';
|
||||||
@@ -227,6 +229,7 @@ CREATE INDEX idx_customer_name ON customer (customer_name);
|
|||||||
CREATE INDEX idx_customer_salesman ON customer (salesman_id);
|
CREATE INDEX idx_customer_salesman ON customer (salesman_id);
|
||||||
CREATE INDEX idx_customer_status ON customer (status);
|
CREATE INDEX idx_customer_status ON customer (status);
|
||||||
CREATE INDEX idx_customer_region ON customer (province, city, district);
|
CREATE INDEX idx_customer_region ON customer (province, city, district);
|
||||||
|
CREATE UNIQUE INDEX uk_customer_icc_code ON customer (icc_code);
|
||||||
CREATE TRIGGER trg_customer_updated_at BEFORE UPDATE ON customer
|
CREATE TRIGGER trg_customer_updated_at BEFORE UPDATE ON customer
|
||||||
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
||||||
|
|
||||||
@@ -386,6 +389,47 @@ COMMENT ON COLUMN customer_room.created_at IS '创建时间';
|
|||||||
CREATE INDEX idx_room_customer ON customer_room (customer_id);
|
CREATE INDEX idx_room_customer ON customer_room (customer_id);
|
||||||
CREATE INDEX idx_room_building ON customer_room (building_id);
|
CREATE INDEX idx_room_building ON customer_room (building_id);
|
||||||
|
|
||||||
|
-- 12.1 客户用电回路表(空间拓扑末端节点:建筑 → 楼层 → 房间 → 回路)
|
||||||
|
DROP TABLE IF EXISTS customer_circuit CASCADE;
|
||||||
|
CREATE TABLE customer_circuit (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
||||||
|
building_id BIGINT NOT NULL REFERENCES customer_building(id),
|
||||||
|
room_id BIGINT REFERENCES customer_room(id),
|
||||||
|
floor_no VARCHAR(16),
|
||||||
|
circuit_code VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
circuit_name VARCHAR(128) NOT NULL,
|
||||||
|
loop_spec VARCHAR(128),
|
||||||
|
load_type VARCHAR(32),
|
||||||
|
rated_voltage VARCHAR(32),
|
||||||
|
rated_current VARCHAR(32),
|
||||||
|
rated_power NUMERIC(10,2),
|
||||||
|
status SMALLINT NOT NULL DEFAULT 1,
|
||||||
|
remark TEXT,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE customer_circuit IS '客户用电回路表(空间拓扑末端节点)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.id IS '自增主键';
|
||||||
|
COMMENT ON COLUMN customer_circuit.customer_id IS '客户 ID(→ customer)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.building_id IS '所属建筑 ID(→ customer_building)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.room_id IS '所属房间 ID(→ customer_room,可空)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.floor_no IS '所在楼层';
|
||||||
|
COMMENT ON COLUMN customer_circuit.circuit_code IS '回路编号(全局唯一)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.circuit_name IS '回路名称';
|
||||||
|
COMMENT ON COLUMN customer_circuit.loop_spec IS '回路规格';
|
||||||
|
COMMENT ON COLUMN customer_circuit.load_type IS '负荷类型';
|
||||||
|
COMMENT ON COLUMN customer_circuit.rated_voltage IS '额定电压(配置值)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.rated_current IS '额定电流(配置值)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.rated_power IS '额定功率 kW(配置值)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.status IS '状态:1 正常 / 0 停用';
|
||||||
|
COMMENT ON COLUMN customer_circuit.remark IS '备注';
|
||||||
|
COMMENT ON COLUMN customer_circuit.sort_order IS '排序值';
|
||||||
|
COMMENT ON COLUMN customer_circuit.created_at IS '创建时间';
|
||||||
|
CREATE INDEX idx_circuit_customer ON customer_circuit (customer_id);
|
||||||
|
CREATE INDEX idx_circuit_building ON customer_circuit (building_id);
|
||||||
|
CREATE INDEX idx_circuit_room ON customer_circuit (room_id);
|
||||||
|
|
||||||
-- 13. 客户设备信息表(配电间、配电井、配电箱)
|
-- 13. 客户设备信息表(配电间、配电井、配电箱)
|
||||||
DROP TABLE IF EXISTS customer_equipment CASCADE;
|
DROP TABLE IF EXISTS customer_equipment CASCADE;
|
||||||
CREATE TABLE customer_equipment (
|
CREATE TABLE customer_equipment (
|
||||||
@@ -570,35 +614,47 @@ CREATE INDEX idx_contract_xt ON contract (xt_contract_no);
|
|||||||
-- 19. 智电改造线路表(ICC 同步 + 业务分配)
|
-- 19. 智电改造线路表(ICC 同步 + 业务分配)
|
||||||
DROP TABLE IF EXISTS electric_line CASCADE;
|
DROP TABLE IF EXISTS electric_line CASCADE;
|
||||||
CREATE TABLE electric_line (
|
CREATE TABLE electric_line (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
line_code VARCHAR(64) NOT NULL UNIQUE,
|
line_code VARCHAR(64) NOT NULL UNIQUE,
|
||||||
equip_id BIGINT NOT NULL REFERENCES customer_equipment(id),
|
equip_id BIGINT,
|
||||||
icc_device_code VARCHAR(128),
|
gateway_id BIGINT,
|
||||||
line_name VARCHAR(128),
|
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
||||||
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
line_name VARCHAR(128),
|
||||||
building_id BIGINT REFERENCES customer_building(id),
|
imei VARCHAR(64),
|
||||||
floor_no VARCHAR(16),
|
type_code VARCHAR(32),
|
||||||
location VARCHAR(256),
|
type_name VARCHAR(64),
|
||||||
status SMALLINT NOT NULL DEFAULT 1,
|
fire_system_code VARCHAR(64),
|
||||||
synced_at TIMESTAMPTZ,
|
fire_system_name VARCHAR(128),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
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 TABLE electric_line IS '智电改造线路信息表(ICC 探测器同步)';
|
||||||
COMMENT ON COLUMN electric_line.id IS '自增主键';
|
COMMENT ON COLUMN electric_line.line_code IS '探测器编码(ICC detectorCode,唯一幂等键)';
|
||||||
COMMENT ON COLUMN electric_line.line_code IS '线路编码(唯一)';
|
COMMENT ON COLUMN electric_line.line_name IS '探测器名称(ICC detectorName)';
|
||||||
COMMENT ON COLUMN electric_line.equip_id IS '客户设备 ID(→ customer_equipment)';
|
COMMENT ON COLUMN electric_line.imei IS '设备唯一标识码(ICC imei)';
|
||||||
COMMENT ON COLUMN electric_line.icc_device_code IS 'ICC 智电设备编号';
|
COMMENT ON COLUMN electric_line.equip_id IS '客户设备 ID(→ customer_equipment,可空,同步不填充)';
|
||||||
COMMENT ON COLUMN electric_line.line_name IS '线路名称(ICC 同步)';
|
COMMENT ON COLUMN electric_line.gateway_id IS '网关设备 ID(→ electric_gateway,外键由迁移/db_seed 建立)';
|
||||||
COMMENT ON COLUMN electric_line.customer_id IS '客户 ID(逻辑隔离键)';
|
COMMENT ON COLUMN electric_line.customer_id IS '客户 ID(orgCode→customer.icc_code 解析,逻辑隔离键)';
|
||||||
COMMENT ON COLUMN electric_line.building_id IS '所属建筑 ID(→ customer_building)';
|
COMMENT ON COLUMN electric_line.type_code IS '探测器类型编码';
|
||||||
COMMENT ON COLUMN electric_line.floor_no IS '所在楼层';
|
COMMENT ON COLUMN electric_line.type_name IS '探测器类型名称';
|
||||||
COMMENT ON COLUMN electric_line.location IS '所在位置';
|
COMMENT ON COLUMN electric_line.fire_system_code IS '消防系统编码';
|
||||||
COMMENT ON COLUMN electric_line.status IS '状态:1在线 0离线';
|
COMMENT ON COLUMN electric_line.fire_system_name IS '消防系统名称';
|
||||||
COMMENT ON COLUMN electric_line.synced_at IS 'ICC 同步时间';
|
COMMENT ON COLUMN electric_line.part_unit_loop_code IS '探测器回路号';
|
||||||
COMMENT ON COLUMN electric_line.created_at 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_customer ON electric_line (customer_id);
|
||||||
CREATE INDEX idx_line_equip ON electric_line (equip_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. 线路所属房间关联表
|
-- 20. 线路所属房间关联表
|
||||||
DROP TABLE IF EXISTS line_room CASCADE;
|
DROP TABLE IF EXISTS line_room CASCADE;
|
||||||
@@ -707,56 +763,36 @@ CREATE INDEX idx_survey_plan_start ON survey_order (plan_start);
|
|||||||
CREATE TRIGGER trg_survey_order_updated_at BEFORE UPDATE ON survey_order
|
CREATE TRIGGER trg_survey_order_updated_at BEFORE UPDATE ON survey_order
|
||||||
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
||||||
|
|
||||||
-- 21.1 勘察 SOP 模板检查项表
|
-- 21.1 勘察工单明细表(建筑/楼层/房间 + 现场照片)
|
||||||
DROP TABLE IF EXISTS survey_sop_item CASCADE;
|
DROP TABLE IF EXISTS survey_detail CASCADE;
|
||||||
CREATE TABLE survey_sop_item (
|
CREATE TABLE survey_detail (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
template_code VARCHAR(64) NOT NULL DEFAULT 'standard_dist',
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
item_name VARCHAR(128) NOT NULL,
|
|
||||||
item_desc VARCHAR(255),
|
|
||||||
receipt_type VARCHAR(16) NOT NULL DEFAULT 'photo',
|
|
||||||
receipt_require VARCHAR(64),
|
|
||||||
required BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
COMMENT ON TABLE survey_sop_item IS '勘察 SOP 模板检查项表';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.id IS '自增主键';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.template_code IS 'SOP模板编码:standard_dist标准配电改造模板';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.sort_order IS '检查项序号';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.item_name IS '检查项名称';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.item_desc IS '检查项说明';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.receipt_type IS '回执形式:photo现场照片 form清单表单 meter测仪截图';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.receipt_require IS '回执指标要求,如「照片 ≥ 2 张」';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.required IS '是否必填';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.created_at IS '创建时间';
|
|
||||||
CREATE UNIQUE INDEX uk_sop_template_order ON survey_sop_item (template_code, sort_order);
|
|
||||||
|
|
||||||
-- 21.2 勘察 SOP 工单回执表
|
|
||||||
DROP TABLE IF EXISTS survey_sop_result CASCADE;
|
|
||||||
CREATE TABLE survey_sop_result (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
survey_order_id BIGINT NOT NULL REFERENCES survey_order(id) ON DELETE CASCADE,
|
survey_order_id BIGINT NOT NULL REFERENCES survey_order(id) ON DELETE CASCADE,
|
||||||
sop_item_id BIGINT NOT NULL REFERENCES survey_sop_item(id),
|
building_id BIGINT,
|
||||||
checked BOOLEAN NOT NULL DEFAULT FALSE,
|
building_name VARCHAR(128),
|
||||||
receipt_value VARCHAR(1024),
|
floor_no VARCHAR(16),
|
||||||
|
room_id BIGINT,
|
||||||
|
room_name VARCHAR(128),
|
||||||
|
photos TEXT,
|
||||||
remark VARCHAR(255),
|
remark VARCHAR(255),
|
||||||
checked_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE survey_sop_result IS '勘察 SOP 工单回执表';
|
COMMENT ON TABLE survey_detail IS '勘察工单明细表(建筑/楼层/房间 + 现场照片)';
|
||||||
COMMENT ON COLUMN survey_sop_result.id IS '自增主键';
|
COMMENT ON COLUMN survey_detail.id IS '自增主键';
|
||||||
COMMENT ON COLUMN survey_sop_result.survey_order_id IS '勘察工单ID(→ survey_order)';
|
COMMENT ON COLUMN survey_detail.survey_order_id IS '勘察工单ID(→ survey_order)';
|
||||||
COMMENT ON COLUMN survey_sop_result.sop_item_id IS 'SOP检查项ID(→ survey_sop_item)';
|
COMMENT ON COLUMN survey_detail.building_id IS '建筑ID(→ customer_building)';
|
||||||
COMMENT ON COLUMN survey_sop_result.checked IS '是否已完成回执';
|
COMMENT ON COLUMN survey_detail.building_name IS '建筑名称(冗余,客户拓扑变更后仍可读)';
|
||||||
COMMENT ON COLUMN survey_sop_result.receipt_value IS '回执内容(照片对象key数组/表单值/截图key)';
|
COMMENT ON COLUMN survey_detail.floor_no IS '所在楼层:正数为地上、负数为地下,如 1 / -1';
|
||||||
COMMENT ON COLUMN survey_sop_result.remark IS '回执备注';
|
COMMENT ON COLUMN survey_detail.room_id IS '房间ID(→ customer_room,可空)';
|
||||||
COMMENT ON COLUMN survey_sop_result.checked_at IS '回执时间';
|
COMMENT ON COLUMN survey_detail.room_name IS '房间名称(冗余,可空)';
|
||||||
COMMENT ON COLUMN survey_sop_result.created_at IS '创建时间';
|
COMMENT ON COLUMN survey_detail.photos IS '现场照片对象key(JSON数组字符串)';
|
||||||
COMMENT ON COLUMN survey_sop_result.updated_at IS '更新时间';
|
COMMENT ON COLUMN survey_detail.remark IS '备注';
|
||||||
CREATE UNIQUE INDEX uk_sop_result_order_item ON survey_sop_result (survey_order_id, sop_item_id);
|
COMMENT ON COLUMN survey_detail.created_at IS '创建时间';
|
||||||
CREATE INDEX idx_sop_result_order ON survey_sop_result (survey_order_id);
|
COMMENT ON COLUMN survey_detail.updated_at IS '更新时间';
|
||||||
|
CREATE INDEX idx_survey_detail_order ON survey_detail (survey_order_id);
|
||||||
|
CREATE TRIGGER trg_survey_detail_updated_at BEFORE UPDATE ON survey_detail
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
||||||
|
|
||||||
-- 22. 安装派单表
|
-- 22. 安装派单表
|
||||||
DROP TABLE IF EXISTS install_order CASCADE;
|
DROP TABLE IF EXISTS install_order CASCADE;
|
||||||
@@ -885,101 +921,58 @@ COMMENT ON COLUMN fault_type_detail.remark IS '备注';
|
|||||||
COMMENT ON COLUMN fault_type_detail.created_at IS '创建时间';
|
COMMENT ON COLUMN fault_type_detail.created_at IS '创建时间';
|
||||||
CREATE INDEX idx_ftd_type ON fault_type_detail (fault_type_id);
|
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;
|
DROP TABLE IF EXISTS device_alarm CASCADE;
|
||||||
CREATE TABLE device_alarm (
|
CREATE TABLE device_alarm (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
alarm_code VARCHAR(128) NOT NULL UNIQUE,
|
alarm_code VARCHAR(128) NOT NULL UNIQUE, -- ICC 报警编码(幂等键)
|
||||||
line_id BIGINT,
|
uuid VARCHAR(128), -- ICC 事件 uuid
|
||||||
equip_id BIGINT,
|
line_id BIGINT, -- 智电线路 ID(→ electric_line,nodeCode/deviceCode 匹配 line_code)
|
||||||
icc_device_code VARCHAR(128),
|
line_code VARCHAR(64), -- 线路编码(electric_line.line_code)
|
||||||
line_name VARCHAR(128),
|
line_name VARCHAR(128), -- 线路名称
|
||||||
customer_id BIGINT,
|
customer_id BIGINT, -- 客户 ID(由 line_id 联出)
|
||||||
building_id BIGINT,
|
node_code VARCHAR(64), -- 探测器节点编码(info.nodeCode)
|
||||||
floor_no VARCHAR(16),
|
device_code VARCHAR(64), -- 设备编码(info.deviceCode)
|
||||||
location VARCHAR(256),
|
device_name VARCHAR(128), -- 设备名称(info.deviceName)
|
||||||
alarm_date DATE,
|
org_code VARCHAR(64), -- 组织编码(info.orgCode)
|
||||||
fault_type VARCHAR(128),
|
org_name VARCHAR(128), -- 组织名称(info.orgName)
|
||||||
alarm_value NUMERIC(14,2),
|
alarm_type INT, -- 报警类型(info.alarmType)
|
||||||
history_count INT DEFAULT 0,
|
alarm_grade SMALLINT, -- 报警等级 1/2/3(1 最严重)
|
||||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
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 TABLE device_alarm IS '设备报警信息表(ICC 事件订阅)';
|
||||||
COMMENT ON COLUMN device_alarm.id IS '自增主键';
|
CREATE INDEX idx_alarm_time ON device_alarm (alarm_time DESC);
|
||||||
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);
|
|
||||||
CREATE INDEX idx_alarm_line ON device_alarm (line_id);
|
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. 报警派单信息表
|
-- 28. 报警派单信息表
|
||||||
DROP TABLE IF EXISTS alarm_dispatch CASCADE;
|
|
||||||
CREATE TABLE alarm_dispatch (
|
CREATE TABLE alarm_dispatch (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
dispatch_no VARCHAR(64) NOT NULL UNIQUE,
|
dispatch_no VARCHAR(64) NOT NULL UNIQUE, -- 报警派单号(如 AD20261024001,生成规则由后端实现)
|
||||||
alarm_id BIGINT,
|
alarm_id BIGINT, -- 报警 ID(→ device_alarm)
|
||||||
line_id BIGINT,
|
line_id BIGINT, -- 线路 ID
|
||||||
equip_id BIGINT,
|
line_code VARCHAR(64), -- 线路编码
|
||||||
icc_device_code VARCHAR(128),
|
line_name VARCHAR(128), -- 线路名称
|
||||||
line_name VARCHAR(128),
|
customer_id BIGINT REFERENCES customer(id), -- 客户 ID
|
||||||
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
alarm_time TIMESTAMPTZ, -- 报警时间(快照)
|
||||||
contact_person VARCHAR(64),
|
alarm_grade SMALLINT, -- 报警等级(快照)
|
||||||
contact_phone VARCHAR(20),
|
fault_desc TEXT, -- 故障事项(报警类型描述)
|
||||||
building_id BIGINT,
|
dispatcher VARCHAR(64), -- 派发人(当前登录用户 realName)
|
||||||
floor_no VARCHAR(16),
|
worker_id BIGINT, -- 共享电工 ID(→ worker)
|
||||||
location VARCHAR(256),
|
status SMALLINT NOT NULL DEFAULT 0, -- 状态:0已派发 1已处理
|
||||||
fault_type_id BIGINT REFERENCES fault_type(id),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
fault_desc TEXT,
|
completed_at TIMESTAMPTZ
|
||||||
dispatcher VARCHAR(64),
|
|
||||||
fault_reason TEXT,
|
|
||||||
process_flow TEXT,
|
|
||||||
worker_id BIGINT,
|
|
||||||
status SMALLINT NOT NULL DEFAULT 0,
|
|
||||||
process_photos JSONB,
|
|
||||||
rating SMALLINT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
completed_at TIMESTAMPTZ
|
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE alarm_dispatch IS '报警派单信息表';
|
COMMENT ON TABLE alarm_dispatch IS '报警派单信息表';
|
||||||
COMMENT ON COLUMN alarm_dispatch.id IS '自增主键';
|
CREATE INDEX idx_alarm_disp_status ON alarm_dispatch (status);
|
||||||
COMMENT ON COLUMN alarm_dispatch.dispatch_no IS '报警派单号(唯一)';
|
CREATE INDEX idx_alarm_disp_worker ON alarm_dispatch (worker_id);
|
||||||
COMMENT ON COLUMN alarm_dispatch.alarm_id IS '报警 ID(→ device_alarm)';
|
CREATE INDEX idx_alarm_disp_alarm ON alarm_dispatch (alarm_id);
|
||||||
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);
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 八、报修域(2 张表)
|
-- 八、报修域(2 张表)
|
||||||
@@ -1242,43 +1235,50 @@ CREATE INDEX idx_wid_worker ON worker_income_detail (worker_id);
|
|||||||
-- 十一、用电采集数据(1 张表,按月 RANGE 分区)
|
-- 十一、用电采集数据(1 张表,按月 RANGE 分区)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
-- 37. 线路用电信息表(按月分区,时序数据)
|
-- 37. 线路用电信息表(按月分区,时序数据,ICC 模拟量同步)
|
||||||
DROP TABLE IF EXISTS electricity_data CASCADE;
|
DROP TABLE IF EXISTS electricity_data CASCADE;
|
||||||
CREATE TABLE electricity_data (
|
CREATE TABLE electricity_data (
|
||||||
id BIGSERIAL,
|
id BIGSERIAL,
|
||||||
line_id BIGINT NOT NULL,
|
line_id BIGINT NOT NULL REFERENCES electric_line(id),
|
||||||
line_code VARCHAR(64) NOT NULL,
|
line_code VARCHAR(64) NOT NULL,
|
||||||
equip_id BIGINT,
|
imei VARCHAR(64),
|
||||||
icc_device_code VARCHAR(128),
|
line_name VARCHAR(128),
|
||||||
line_name VARCHAR(128),
|
customer_id BIGINT NOT NULL,
|
||||||
customer_id BIGINT NOT NULL,
|
analog_type VARCHAR(64),
|
||||||
electricity NUMERIC(14,4),
|
analog_name VARCHAR(128),
|
||||||
collect_time TIMESTAMPTZ NOT NULL,
|
analog_unit VARCHAR(32),
|
||||||
PRIMARY KEY (id, collect_time)
|
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);
|
) 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.id IS '自增(复合主键含 collect_time)';
|
||||||
COMMENT ON COLUMN electricity_data.line_id IS '线路 ID(→ electric_line)';
|
COMMENT ON COLUMN electricity_data.line_id IS '线路 ID(→ electric_line)';
|
||||||
COMMENT ON COLUMN electricity_data.line_code IS '线路编码(冗余,加速查询)';
|
COMMENT ON COLUMN electricity_data.line_code IS '线路编码(ICC deviceCode,冗余 + 幂等键)';
|
||||||
COMMENT ON COLUMN electricity_data.equip_id IS '客户设备 ID';
|
COMMENT ON COLUMN electricity_data.imei IS '设备唯一标识码(ICC uniqueCode)';
|
||||||
COMMENT ON COLUMN electricity_data.icc_device_code IS 'ICC 设备编号';
|
COMMENT ON COLUMN electricity_data.line_name IS '线路名称(冗余,加速展示)';
|
||||||
COMMENT ON COLUMN electricity_data.line_name IS '线路名称';
|
COMMENT ON COLUMN electricity_data.customer_id IS '客户 ID(逻辑隔离键)';
|
||||||
COMMENT ON COLUMN electricity_data.customer_id IS '客户 ID(逻辑隔离键)';
|
COMMENT ON COLUMN electricity_data.analog_type IS '模拟量类型(ICC analogType)';
|
||||||
COMMENT ON COLUMN electricity_data.electricity IS '用电量';
|
COMMENT ON COLUMN electricity_data.analog_name IS '模拟量名称(ICC analogName,如:剩余电流)';
|
||||||
COMMENT ON COLUMN electricity_data.collect_time IS '采集时间(分区键)';
|
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 月)
|
-- 初始分区(2026 年 10 月 ~ 2027 年 3 月)
|
||||||
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');
|
|
||||||
CREATE TABLE electricity_data_2026_10 PARTITION OF electricity_data
|
CREATE TABLE electricity_data_2026_10 PARTITION OF electricity_data
|
||||||
FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
|
FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
|
||||||
CREATE TABLE electricity_data_2026_11 PARTITION OF electricity_data
|
CREATE TABLE electricity_data_2026_11 PARTITION OF electricity_data
|
||||||
FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');
|
FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');
|
||||||
CREATE TABLE electricity_data_2026_12 PARTITION OF electricity_data
|
CREATE TABLE electricity_data_2026_12 PARTITION OF electricity_data
|
||||||
FOR VALUES FROM ('2026-12-01') TO ('2027-01-01');
|
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);
|
CREATE INDEX idx_ed_customer ON electricity_data (customer_id);
|
||||||
|
|||||||
+209
-38
@@ -16,7 +16,7 @@
|
|||||||
-- - 显式重置序列,确保 id 不漂移
|
-- - 显式重置序列,确保 id 不漂移
|
||||||
--
|
--
|
||||||
-- 内容:
|
-- 内容:
|
||||||
-- 1. 初始角色(ADMIN / SALESMAN / WORKER / CUSTOMER)
|
-- 1. 初始角色(ADMIN / SALESMAN)
|
||||||
-- 2. 初始菜单(顶级 6 + 系统管理子菜单 6 + 运营派单子菜单 13 等,共 50 条)
|
-- 2. 初始菜单(顶级 6 + 系统管理子菜单 6 + 运营派单子菜单 13 等,共 50 条)
|
||||||
-- 3. 初始字典(客户/设备/派单/支付等业务字典)
|
-- 3. 初始字典(客户/设备/派单/支付等业务字典)
|
||||||
-- 4. 管理员账户(admin / admin123456)
|
-- 4. 管理员账户(admin / admin123456)
|
||||||
@@ -26,21 +26,24 @@
|
|||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
-- 1. 初始角色(显式指定 id:1=ADMIN 2=SALESMAN 3=WORKER 4=CUSTOMER)
|
-- 1. 初始角色(显式指定 id:1=ADMIN 2=SALESMAN)
|
||||||
|
-- 后台仅存在系统用户(sys_user)角色;电工(worker 表)与客户(customer_account 表)
|
||||||
|
-- 各自独立账号体系,不作为后台角色出现。
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
INSERT INTO sys_role (id, role_code, role_name, description, sort_order) VALUES
|
INSERT INTO sys_role (id, role_code, role_name, description, sort_order) VALUES
|
||||||
(1, 'ADMIN', '运营管理员', '全部菜单权限,管理所有客户', 1),
|
(1, 'ADMIN', '运营管理员', '全部菜单权限,管理所有客户', 1),
|
||||||
(2, 'SALESMAN', '业务员', '客户/项目/方案/合同/回访', 2),
|
(2, 'SALESMAN', '业务员', '客户/项目/方案/合同/回访', 2)
|
||||||
(3, 'WORKER', '共享电工', 'APP 接单处理,无后台菜单', 3),
|
|
||||||
(4, 'CUSTOMER', '客户', '小程序查看用电/报警/报修', 4)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
role_code = EXCLUDED.role_code,
|
role_code = EXCLUDED.role_code,
|
||||||
role_name = EXCLUDED.role_name,
|
role_name = EXCLUDED.role_name,
|
||||||
description = EXCLUDED.description,
|
description = EXCLUDED.description,
|
||||||
sort_order = EXCLUDED.sort_order;
|
sort_order = EXCLUDED.sort_order;
|
||||||
|
|
||||||
|
-- 清理历史遗留的 APP 端角色(电工/客户不参与后台角色体系)
|
||||||
|
DELETE FROM sys_role WHERE role_code IN ('WORKER', 'CUSTOMER');
|
||||||
|
|
||||||
-- 重置 sys_role 自增序列
|
-- 重置 sys_role 自增序列
|
||||||
SELECT setval('sys_role_id_seq', 4, 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)
|
-- 2. 初始菜单(顶级菜单,显式指定 id:1-6,系统管理/运营派单为目录类型 menu_type=1,其余为 menu_type=2)
|
||||||
@@ -106,18 +109,19 @@ ON CONFLICT (id) DO UPDATE SET
|
|||||||
permission = EXCLUDED.permission,
|
permission = EXCLUDED.permission,
|
||||||
visible = EXCLUDED.visible;
|
visible = EXCLUDED.visible;
|
||||||
|
|
||||||
-- 初始菜单(项目域子菜单 + 客户扩展子菜单,id:24-31 + 空间拓扑 32 + 安装派单 33)
|
-- 初始菜单(项目域子菜单 + 客户扩展子菜单,id:24-31 + 空间拓扑 32)
|
||||||
-- 项目域:客户拜访(customer:visit) / 项目勘察(project:survey) / 项目方案(project:plan)
|
-- 项目域:客户拜访(customer:visit) / 项目方案(project:plan)
|
||||||
-- 合同管理(project:contract) / 安装派单(project:install)
|
-- 合同管理(project:contract)
|
||||||
-- 项目验收(project:acceptance) / 回访记录(project:visit)
|
-- 项目验收(project:acceptance) / 回访记录(project:visit)
|
||||||
-- 客户域:空间拓扑与用电架构(customer:topology)
|
-- 客户域:空间拓扑与用电架构(customer:topology)
|
||||||
-- 注:客户拜访归属“项目管理”,但仍沿用 /customer/visit 路径与 customer:visit 权限码
|
-- 注:客户拜访归属“项目管理”,但仍沿用 /customer/visit 路径与 customer:visit 权限码
|
||||||
|
-- 注:项目勘察(27)/项目安装(33) 已迁至运营派单 parent_id=3(见下方运营派单块注释)
|
||||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||||
(24, 2, '客户拜访', '/customer/visit', 'customer/visit/index', 'xt-jihuabaifang', 1, 2, 'customer:visit', 1),
|
(24, 2, '客户拜访', '/customer/visit', 'customer/visit/index', 'xt-jihuabaifang', 1, 2, 'customer:visit', 1),
|
||||||
(27, 2, '项目勘察', '/project/survey', 'project/survey/index', 'xt-xiangmukancha', 2, 2, 'project:survey', 1),
|
(27, 3, '项目勘察', '/dispatch/survey', 'dispatch/survey/index', 'xt-xiangmukancha', 2, 2, 'project:survey', 1),
|
||||||
(28, 2, '项目方案', '/project/plan', 'project/plan/index', 'xt-xiangmufangan', 3, 2, 'project:plan', 1),
|
(28, 2, '项目方案', '/project/plan', 'project/plan/index', 'xt-xiangmufangan', 3, 2, 'project:plan', 1),
|
||||||
(29, 2, '合同管理', '/project/contract', 'project/contract/index', 'FileDoneOutlined', 4, 2, 'project:contract', 1),
|
(29, 2, '合同管理', '/project/contract', 'project/contract/index', 'FileDoneOutlined', 4, 2, 'project:contract', 1),
|
||||||
(33, 2, '项目安装', '/project/install', 'project/install/index', 'ToolOutlined', 5, 2, 'project:install', 1),
|
(33, 3, '项目安装', '/dispatch/install', 'dispatch/install/index', 'ToolOutlined', 3, 2, 'project:install', 1),
|
||||||
(30, 2, '项目验收', '/project/acceptance', 'project/acceptance/index', 'ThunderboltOutlined', 6, 2, 'project:acceptance',1),
|
(30, 2, '项目验收', '/project/acceptance', 'project/acceptance/index', 'ThunderboltOutlined', 6, 2, 'project:acceptance',1),
|
||||||
(31, 2, '回访记录', '/project/visit', 'project/visit/index', 'AuditOutlined', 7, 2, 'project:visit', 1),
|
(31, 2, '回访记录', '/project/visit', 'project/visit/index', 'AuditOutlined', 7, 2, 'project:visit', 1),
|
||||||
(32, 1, '空间拓扑与用电架构', '/customer/topology', 'customer/topology/index', 'xt-topology', 5, 2, 'customer:topology',1)
|
(32, 1, '空间拓扑与用电架构', '/customer/topology', 'customer/topology/index', 'xt-topology', 5, 2, 'customer:topology',1)
|
||||||
@@ -132,11 +136,13 @@ ON CONFLICT (id) DO UPDATE SET
|
|||||||
permission = EXCLUDED.permission,
|
permission = EXCLUDED.permission,
|
||||||
visible = EXCLUDED.visible;
|
visible = EXCLUDED.visible;
|
||||||
|
|
||||||
-- 初始菜单(运营派单子菜单,parent_id=3,id:55-56)
|
-- 初始菜单(运营派单子菜单,parent_id=3)
|
||||||
-- 运营派单域统一 id 分配(避免历史上 36/37 被两段 INSERT 复用而互相覆盖):
|
-- 运营派单域统一 id 分配(避免历史上 36/37 被两段 INSERT 复用而互相覆盖):
|
||||||
-- 34 运维费定价 / 35 勘察费用(归属项目管理 parent_id=2) / 36 电工登记管理
|
-- 27 项目勘察 / 33 项目安装(自项目管理 parent_id=2 迁入)
|
||||||
|
-- 34 运维费定价 / 35 勘察费用(均归属基础数据 parent_id=59) / 36 电工登记管理
|
||||||
-- 37-46 按钮权限
|
-- 37-46 按钮权限
|
||||||
-- 55 报修派单 / 56 维保派单
|
-- 55 报修派单 / 56 维保派单
|
||||||
|
-- 子菜单排序:36 电工登记管理(1) → 27 项目勘察(2) → 33 项目安装(3) → 55 报修派单(4) → 56 维保派单(5)
|
||||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||||
(55, 3, '报修派单', '/dispatch/repair', 'dispatch/repair/index', 'ToolOutlined', 4, 2, 'dispatch:repair', 1),
|
(55, 3, '报修派单', '/dispatch/repair', 'dispatch/repair/index', 'ToolOutlined', 4, 2, 'dispatch:repair', 1),
|
||||||
(56, 3, '维保派单', '/dispatch/maintenance', 'dispatch/maintenance/index', 'ToolOutlined', 5, 2, 'dispatch:maintenance',1)
|
(56, 3, '维保派单', '/dispatch/maintenance', 'dispatch/maintenance/index', 'ToolOutlined', 5, 2, 'dispatch:maintenance',1)
|
||||||
@@ -151,21 +157,22 @@ ON CONFLICT (id) DO UPDATE SET
|
|||||||
permission = EXCLUDED.permission,
|
permission = EXCLUDED.permission,
|
||||||
visible = EXCLUDED.visible;
|
visible = EXCLUDED.visible;
|
||||||
|
|
||||||
-- 运营派单子菜单(parent_id=3;其中 35 勘察费用及其按钮 40/41/42 已归属项目管理 parent_id=2;id:34/36 为菜单项,37-39/43-46 为按钮权限)
|
-- 运营派单子菜单(parent_id=3;其中 34 运维费定价及按钮 37-39、35 勘察费用及按钮 40-42 已归属基础数据 parent_id=59;id:36 为菜单项,43-46 为按钮权限)
|
||||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||||
(34, 3, '运维费定价', '/dispatch/price', 'dispatch/price/index', 'MoneyCollectOutlined', 2, 2, 'dispatch:price', 1),
|
(34, 59, '运维费定价', '/base/price', 'base/price/index', 'MoneyCollectOutlined', 2, 2, 'dispatch:price', 1),
|
||||||
(35, 2, '勘察费用', '/dispatch/cost', 'dispatch/cost/index', 'AccountBookOutlined', 8, 2, 'dispatch:cost', 1),
|
(35, 59, '勘察费用', '/base/cost', 'base/cost/index', 'AccountBookOutlined', 8, 2, 'dispatch:cost', 1),
|
||||||
(36, 3, '电工登记管理', '/dispatch/worker', 'dispatch/worker/index', 'ToolOutlined', 1, 2, 'dispatch:worker', 1),
|
(36, 3, '电工登记管理', '/dispatch/worker', 'dispatch/worker/index', 'ToolOutlined', 1, 2, 'dispatch:worker', 1),
|
||||||
(37, 3, '新增价目', NULL, NULL, NULL, 1, 3, 'price:create', 1),
|
(37, 34, '新增价目', NULL, NULL, NULL, 1, 3, 'price:create', 1),
|
||||||
(38, 3, '编辑价目', NULL, NULL, NULL, 2, 3, 'price:update', 1),
|
(38, 34, '编辑价目', NULL, NULL, NULL, 2, 3, 'price:update', 1),
|
||||||
(39, 3, '删除价目', NULL, NULL, NULL, 3, 3, 'price:delete', 1),
|
(39, 34, '删除价目', NULL, NULL, NULL, 3, 3, 'price:delete', 1),
|
||||||
(40, 2, '新增勘察费用', NULL, NULL, NULL, 9, 3, 'cost:create', 1),
|
(40, 35, '新增勘察费用', NULL, NULL, NULL, 9, 3, 'cost:create', 1),
|
||||||
(41, 2, '编辑勘察费用', NULL, NULL, NULL, 10, 3, 'cost:update', 1),
|
(41, 35, '编辑勘察费用', NULL, NULL, NULL, 10, 3, 'cost:update', 1),
|
||||||
(42, 2, '删除勘察费用', NULL, NULL, NULL, 11, 3, 'cost:delete', 1),
|
(42, 35, '删除勘察费用', NULL, NULL, NULL, 11, 3, 'cost:delete', 1),
|
||||||
(43, 3, '新增电工', NULL, NULL, NULL, 7, 3, 'worker:create', 1),
|
(43, 3, '新增电工', NULL, NULL, NULL, 7, 3, 'worker:create', 1),
|
||||||
(44, 3, '编辑电工', NULL, NULL, NULL, 8, 3, 'worker:update', 1),
|
(44, 3, '编辑电工', NULL, NULL, NULL, 8, 3, 'worker:update', 1),
|
||||||
(45, 3, '删除电工', NULL, NULL, NULL, 9, 3, 'worker:delete', 1),
|
(45, 3, '删除电工', NULL, NULL, NULL, 9, 3, 'worker:delete', 1),
|
||||||
(46, 3, '审核电工', NULL, NULL, NULL, 10, 3, 'worker:audit', 1)
|
(46, 3, '审核电工', NULL, NULL, NULL, 10, 3, 'worker:audit', 1),
|
||||||
|
(57, 3, '重置电工密码', NULL, NULL, NULL, 11, 3, 'worker:reset-password', 1)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
parent_id = EXCLUDED.parent_id,
|
parent_id = EXCLUDED.parent_id,
|
||||||
menu_name = EXCLUDED.menu_name,
|
menu_name = EXCLUDED.menu_name,
|
||||||
@@ -178,7 +185,7 @@ ON CONFLICT (id) DO UPDATE SET
|
|||||||
visible = EXCLUDED.visible;
|
visible = EXCLUDED.visible;
|
||||||
|
|
||||||
-- 重置 sys_menu 自增序列
|
-- 重置 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 无业务唯一键,清空重建以支持幂等执行)
|
-- 3. 初始字典(sys_dict 无业务唯一键,清空重建以支持幂等执行)
|
||||||
@@ -245,7 +252,7 @@ ON CONFLICT (id) DO UPDATE SET
|
|||||||
status = EXCLUDED.status;
|
status = EXCLUDED.status;
|
||||||
|
|
||||||
-- 重置 sys_user 自增序列
|
-- 重置 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
|
-- 5. 用户-角色绑定:admin → ADMIN
|
||||||
@@ -254,14 +261,13 @@ INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1)
|
|||||||
ON CONFLICT (user_id, role_id) DO NOTHING;
|
ON CONFLICT (user_id, role_id) DO NOTHING;
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
-- 6. 角色-菜单绑定:ADMIN 拥有全部 46 条菜单,SALESMAN 拥有客户域+项目+统计报表
|
-- 6. 角色-菜单绑定:ADMIN 拥有全部菜单,SALESMAN 拥有客户域+项目+统计报表
|
||||||
-- WORKER、CUSTOMER 不绑定任何菜单
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||||
(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7), (1, 8), (1, 9), (1, 10), (1, 11),
|
(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7), (1, 8), (1, 9), (1, 10), (1, 11),
|
||||||
(1, 12), (1, 13), (1, 14), (1, 15), (1, 16), (1, 17), (1, 18), (1, 19), (1, 20), (1, 21), (1, 22), (1, 23),
|
(1, 12), (1, 13), (1, 14), (1, 15), (1, 16), (1, 17), (1, 18), (1, 19), (1, 20), (1, 21), (1, 22), (1, 23),
|
||||||
(1, 24), (1, 27), (1, 28), (1, 29), (1, 30), (1, 31), (1, 32), (1, 33), (1, 55), (1, 56),
|
(1, 24), (1, 27), (1, 28), (1, 29), (1, 30), (1, 31), (1, 32), (1, 33), (1, 55), (1, 56),
|
||||||
(1, 34), (1, 35), (1, 36), (1, 37), (1, 38), (1, 39), (1, 40), (1, 41), (1, 42), (1, 43), (1, 44), (1, 45), (1, 46),
|
(1, 34), (1, 35), (1, 36), (1, 37), (1, 38), (1, 39), (1, 40), (1, 41), (1, 42), (1, 43), (1, 44), (1, 45), (1, 46), (1, 57),
|
||||||
(2, 1), (2, 2), (2, 5),
|
(2, 1), (2, 2), (2, 5),
|
||||||
(2, 12), (2, 13), (2, 14), (2, 15), (2, 16), (2, 17), (2, 18), (2, 19), (2, 20), (2, 21), (2, 22), (2, 23),
|
(2, 12), (2, 13), (2, 14), (2, 15), (2, 16), (2, 17), (2, 18), (2, 19), (2, 20), (2, 21), (2, 22), (2, 23),
|
||||||
(2, 24), (2, 27), (2, 28), (2, 29), (2, 30), (2, 31), (2, 32), (2, 33), (2, 55), (2, 56)
|
(2, 24), (2, 27), (2, 28), (2, 29), (2, 30), (2, 31), (2, 32), (2, 33), (2, 55), (2, 56)
|
||||||
@@ -316,21 +322,42 @@ ON CONFLICT (id) DO UPDATE SET
|
|||||||
permission = EXCLUDED.permission,
|
permission = EXCLUDED.permission,
|
||||||
visible = EXCLUDED.visible;
|
visible = EXCLUDED.visible;
|
||||||
|
|
||||||
-- 角色-菜单绑定:ADMIN(role_id=1)拥有 58-60
|
-- 角色-菜单绑定:ADMIN(role_id=1)拥有 58-61
|
||||||
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||||
(1, 58), (1, 59), (1, 60)
|
(1, 58), (1, 59), (1, 60), (1, 61)
|
||||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||||
|
|
||||||
-- 重置 sys_menu 自增序列(覆盖到 60)
|
-- 重置 sys_menu 自增序列(覆盖到 61)
|
||||||
SELECT setval('sys_menu_id_seq', 60, 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)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||||
|
(61, 4, '智电改造线路台账', '/device/line', 'device/line/index', 'ApartmentOutlined', 2, 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)拥有 61
|
||||||
|
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||||
|
(1, 61)
|
||||||
|
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
-- 9. 定时任务种子(sys_job,按 job_key 幂等)
|
-- 9. 定时任务种子(sys_job,按 job_key 幂等)
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
|
INSERT INTO sys_job (job_key, job_name, cron_expression, freq_type, freq_config, params, retry_times, retry_interval, status, remark) VALUES
|
||||||
('demo_heartbeat', '示例心跳', '*/5 * * * *', '{"message":"heartbeat"}'::jsonb, 0, 60, 1,
|
('demo_heartbeat', '示例心跳', '*/5 * * * *', 'minute', '{"interval":5}'::jsonb, '{"message":"heartbeat"}'::jsonb, 0, 60, 1,
|
||||||
'示例心跳任务:用于验证调度链路,默认每 5 分钟写入一条 info 日志'),
|
'示例心跳任务:用于验证调度链路,默认每 5 分钟写入一条 info 日志'),
|
||||||
('log_clean', '日志清理', '0 3 * * *', '{"keepDays":90}'::jsonb, 2, 60, 1,
|
('log_clean', '日志清理', '0 3 * * *', 'day', '{"times":["03:00"]}'::jsonb, '{"keepDays":90}'::jsonb, 2, 60, 1,
|
||||||
'日志清理任务:每日 03:00 清理早于 keepDays 天的操作日志与任务日志,默认保留 90 天')
|
'日志清理任务:每日 03:00 清理早于 keepDays 天的操作日志与任务日志,默认保留 90 天')
|
||||||
ON CONFLICT (job_key) DO NOTHING;
|
ON CONFLICT (job_key) DO NOTHING;
|
||||||
|
|
||||||
@@ -359,16 +386,32 @@ INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
|||||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||||
|
|
||||||
-- 重置 sys_menu 自增序列
|
-- 重置 sys_menu 自增序列
|
||||||
SELECT setval('sys_menu_id_seq', 56, true);
|
SELECT setval(pg_get_serial_sequence('sys_menu', 'id'), GREATEST((SELECT MAX(id) FROM sys_menu), 57), true);
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
-- 10. 通知规则扫描任务种子(sys_job,按 job_key 幂等)
|
-- 10. 通知规则扫描任务种子(sys_job,按 job_key 幂等)
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
|
INSERT INTO sys_job (job_key, job_name, cron_expression, freq_type, freq_config, params, retry_times, retry_interval, status, remark) VALUES
|
||||||
('notice_scan', '通知规则扫描', '0 0 8 * * *', '{}'::jsonb, 1, 300, 1,
|
('notice_scan', '通知规则扫描', '0 8 * * *', 'day', '{"times":["08:00"]}'::jsonb, '{}'::jsonb, 1, 300, 1,
|
||||||
'遍历启用的扫描式通知规则,按模板与接收对象生成站内通知(幂等,默认每日 08:00)')
|
'遍历启用的扫描式通知规则,按模板与接收对象生成站内通知(幂等,默认每日 08:00)')
|
||||||
ON CONFLICT (job_key) DO NOTHING;
|
ON CONFLICT (job_key) DO NOTHING;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 10a. 智电线路同步任务种子(sys_job,按 job_key 幂等;默认每 10 分钟)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
|
||||||
|
('icc_line_sync', '智电线路同步', '*/10 * * * *', '{}'::jsonb, 1, 300, 1,
|
||||||
|
'从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert(默认每 10 分钟)')
|
||||||
|
ON CONFLICT (job_key) DO NOTHING;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 10b. 客户信息同步任务种子(sys_job,按 job_key 幂等;默认每 10 分钟)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
|
||||||
|
('icc_customer_sync', '客户信息同步', '*/10 * * * *', '{}'::jsonb, 1, 300, 1,
|
||||||
|
'从 ICC 平台拉取 org_base 组织数据并按 iccCode 幂等新增/变更客户(默认每 10 分钟)')
|
||||||
|
ON CONFLICT (job_key) DO NOTHING;
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
-- 11. 通知规则种子(sys_notice_rule,按 rule_key 幂等;接收对象可在「通知管理」页调整)
|
-- 11. 通知规则种子(sys_notice_rule,按 rule_key 幂等;接收对象可在「通知管理」页调整)
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
@@ -405,6 +448,134 @@ INSERT INTO sys_notice_rule (rule_key, rule_name, event_key, trigger_mode, enabl
|
|||||||
'扫描派单后超过 advance_days 天仍未接单的工单,通知 ADMIN')
|
'扫描派单后超过 advance_days 天仍未接单的工单,通知 ADMIN')
|
||||||
ON CONFLICT (rule_key) DO NOTHING;
|
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;
|
COMMIT;
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- 移除后台角色中的「共享电工(WORKER)」「客户(CUSTOMER)」
|
||||||
|
-- 背景:电工走 worker 表、客户走 customer_account 表,均为独立账号体系,
|
||||||
|
-- 不存在对应的 sys_user,因此这两个角色挂在后台「用户管理」中是语义错误
|
||||||
|
-- (选中后永远不会关联到任何后台用户)。后台角色仅保留 ADMIN / SALESMAN。
|
||||||
|
-- 影响:sys_user_role / sys_role_menu 通过外键 ON DELETE CASCADE 自动清理。
|
||||||
|
-- 幂等:按 role_code 删除;重复执行无副作用。
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DELETE FROM sys_role WHERE role_code IN ('WORKER', 'CUSTOMER');
|
||||||
|
|
||||||
|
-- 重置 sys_role 自增序列到当前最大 id,避免后续插入冲突
|
||||||
|
SELECT setval('sys_role_id_seq', COALESCE((SELECT MAX(id) FROM sys_role), 1), true);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- 电工登录密码维护
|
||||||
|
-- 背景:worker 表已有 password_hash,但后台「电工登记管理」仅有新增/编辑/审核/删除,
|
||||||
|
-- 无法为存量电工重置密码,也无法为当初未开通 APP 账号的电工补开账号。
|
||||||
|
-- 本次新增按钮权限 worker:reset-password,用于控制「重置密码」操作入口。
|
||||||
|
-- 幂等:菜单用 ON CONFLICT (id) DO UPDATE;角色绑定用 ON CONFLICT DO NOTHING。
|
||||||
|
-- 注意:菜单 id 57,避开 34-56(34/36/51 为菜单项,其余为按钮/子项)。
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||||
|
(57, 3, '重置电工密码', NULL, NULL, NULL, 11, 3, 'worker:reset-password', 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)拥有 57
|
||||||
|
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||||
|
(1, 57)
|
||||||
|
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 重置 sys_menu 自增序列,避免后续插入 id 冲突
|
||||||
|
SELECT setval('sys_menu_id_seq', 57, true);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 客户建筑信息表增加地下楼层数 floordown_count(INT,默认0)
|
||||||
|
-- 语义:floor_count 为地上楼层数,floordown_count 为地下楼层数
|
||||||
|
-- 幂等:ADD COLUMN IF NOT EXISTS 重复执行安全
|
||||||
|
ALTER TABLE customer_building
|
||||||
|
ADD COLUMN IF NOT EXISTS floordown_count INT DEFAULT 0;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- 空间拓扑与用电架构:客户用电回路表 customer_circuit
|
||||||
|
-- 语义:左树最末端节点(建筑 → 楼层 → 房间 → 回路),其中"楼层"为按 floor_no 聚合的虚拟层,不单独建表。
|
||||||
|
-- 幂等:CREATE TABLE IF NOT EXISTS / CREATE INDEX IF NOT EXISTS / COMMENT 均可重复执行。
|
||||||
|
-- 说明:rated_* 为配置额定值;实时电气量(电压/电流/功率/温度)本系统暂无采集数据源,不落库。
|
||||||
|
CREATE TABLE IF NOT EXISTS customer_circuit (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
||||||
|
building_id BIGINT NOT NULL REFERENCES customer_building(id),
|
||||||
|
room_id BIGINT REFERENCES customer_room(id),
|
||||||
|
floor_no VARCHAR(16),
|
||||||
|
circuit_code VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
circuit_name VARCHAR(128) NOT NULL,
|
||||||
|
loop_spec VARCHAR(128),
|
||||||
|
load_type VARCHAR(32),
|
||||||
|
rated_voltage VARCHAR(32),
|
||||||
|
rated_current VARCHAR(32),
|
||||||
|
rated_power NUMERIC(10,2),
|
||||||
|
status SMALLINT NOT NULL DEFAULT 1,
|
||||||
|
remark TEXT,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE customer_circuit IS '客户用电回路表(空间拓扑末端节点)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.id IS '自增主键';
|
||||||
|
COMMENT ON COLUMN customer_circuit.customer_id IS '客户 ID(→ customer)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.building_id IS '所属建筑 ID(→ customer_building)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.room_id IS '所属房间 ID(→ customer_room,可空:公共区域回路不挂房间)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.floor_no IS '所在楼层(冗余,便于按楼层分组)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.circuit_code IS '回路编号(全局唯一,如 CIR-0219)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.circuit_name IS '回路名称';
|
||||||
|
COMMENT ON COLUMN customer_circuit.loop_spec IS '回路规格(如 三相380V / 铜芯4×25+1×16)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.load_type IS '负荷类型(照明/动力/空调/插座等)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.rated_voltage IS '额定电压(配置值)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.rated_current IS '额定电流(配置值)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.rated_power IS '额定功率 kW(配置值)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.status IS '状态:1 正常 / 0 停用';
|
||||||
|
COMMENT ON COLUMN customer_circuit.remark IS '备注';
|
||||||
|
COMMENT ON COLUMN customer_circuit.sort_order IS '排序值';
|
||||||
|
COMMENT ON COLUMN customer_circuit.created_at IS '创建时间';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_circuit_customer ON customer_circuit (customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_circuit_building ON customer_circuit (building_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_circuit_room ON customer_circuit (room_id);
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
-- 智电改造线路台账(设备监控):
|
||||||
|
-- 1) 幂等建表 electric_line / line_room / icc_sync_log(若 db_init.sql 已建则跳过)
|
||||||
|
-- 2) 设备监控(id=4)下新增菜单 61「智电改造线路台账」
|
||||||
|
-- 3) ADMIN 角色绑定新菜单
|
||||||
|
-- 4) 注册 icc_line_sync 定时任务(默认每 10 分钟)
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 1) 幂等建表(与 db_init.sql 定义保持一致)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS electric_line (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
line_code VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
equip_id BIGINT NOT NULL REFERENCES customer_equipment(id),
|
||||||
|
icc_device_code VARCHAR(128),
|
||||||
|
line_name VARCHAR(128),
|
||||||
|
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
||||||
|
building_id BIGINT REFERENCES customer_building(id),
|
||||||
|
floor_no VARCHAR(16),
|
||||||
|
location VARCHAR(256),
|
||||||
|
status SMALLINT NOT NULL DEFAULT 1,
|
||||||
|
synced_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE electric_line IS '智电改造线路信息表(ICC 同步)';
|
||||||
|
COMMENT ON COLUMN electric_line.id IS '自增主键';
|
||||||
|
COMMENT ON COLUMN electric_line.line_code IS '线路编码(唯一)';
|
||||||
|
COMMENT ON COLUMN electric_line.equip_id IS '客户设备 ID(→ customer_equipment)';
|
||||||
|
COMMENT ON COLUMN electric_line.icc_device_code IS 'ICC 智电设备编号';
|
||||||
|
COMMENT ON COLUMN electric_line.line_name IS '线路名称(ICC 同步)';
|
||||||
|
COMMENT ON COLUMN electric_line.customer_id IS '客户 ID(逻辑隔离键)';
|
||||||
|
COMMENT ON COLUMN electric_line.building_id IS '所属建筑 ID(→ customer_building)';
|
||||||
|
COMMENT ON COLUMN electric_line.floor_no IS '所在楼层';
|
||||||
|
COMMENT ON COLUMN electric_line.location IS '所在位置';
|
||||||
|
COMMENT ON COLUMN electric_line.status IS '状态:1在线 0离线';
|
||||||
|
COMMENT ON COLUMN electric_line.synced_at IS 'ICC 同步时间';
|
||||||
|
COMMENT ON COLUMN electric_line.created_at IS '创建时间';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_line_customer ON electric_line (customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_line_equip ON electric_line (equip_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_line_icc ON electric_line (icc_device_code);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS line_room (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
line_id BIGINT NOT NULL REFERENCES electric_line(id) ON DELETE CASCADE,
|
||||||
|
room_id BIGINT NOT NULL REFERENCES customer_room(id),
|
||||||
|
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE line_room IS '线路所属房间关联表';
|
||||||
|
COMMENT ON COLUMN line_room.id IS '自增主键';
|
||||||
|
COMMENT ON COLUMN line_room.line_id IS '线路 ID(→ electric_line)';
|
||||||
|
COMMENT ON COLUMN line_room.room_id IS '房间 ID(→ customer_room)';
|
||||||
|
COMMENT ON COLUMN line_room.customer_id IS '客户 ID(→ customer)';
|
||||||
|
COMMENT ON COLUMN line_room.created_at IS '创建时间';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lr_line ON line_room (line_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lr_room ON line_room (room_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lr_customer ON line_room (customer_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS icc_sync_log (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
sync_type VARCHAR(32) NOT NULL,
|
||||||
|
start_time TIMESTAMPTZ,
|
||||||
|
end_time TIMESTAMPTZ,
|
||||||
|
record_count INT,
|
||||||
|
status SMALLINT NOT NULL DEFAULT 1,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE icc_sync_log IS 'ICC 数据同步日志表';
|
||||||
|
COMMENT ON COLUMN icc_sync_log.id IS '自增主键';
|
||||||
|
COMMENT ON COLUMN icc_sync_log.sync_type IS '同步类型:客户/设备/用电/报警';
|
||||||
|
COMMENT ON COLUMN icc_sync_log.start_time IS '开始时间';
|
||||||
|
COMMENT ON COLUMN icc_sync_log.end_time IS '结束时间';
|
||||||
|
COMMENT ON COLUMN icc_sync_log.record_count IS '同步记录数';
|
||||||
|
COMMENT ON COLUMN icc_sync_log.status IS '状态:1成功 0失败';
|
||||||
|
COMMENT ON COLUMN icc_sync_log.error_message IS '错误信息';
|
||||||
|
COMMENT ON COLUMN icc_sync_log.created_at IS '创建时间';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_icc_sync_type ON icc_sync_log (sync_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_icc_sync_time ON icc_sync_log (created_at);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 2) 设备监控(id=4)下新增菜单 61「智电改造线路台账」(sort 2,排在设备管理台账之后)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||||
|
(61, 4, '智电改造线路台账', '/device/line', 'device/line/index', 'ApartmentOutlined', 2, 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) 拥有 61
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||||
|
(1, 61)
|
||||||
|
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 4) 定时任务种子:icc_line_sync(默认每 10 分钟,可按 job_key 幂等)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
|
||||||
|
('icc_line_sync', '智电线路同步', '*/10 * * * *', '{}'::jsonb, 1, 300, 1,
|
||||||
|
'从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert(默认每 10 分钟)')
|
||||||
|
ON CONFLICT (job_key) DO NOTHING;
|
||||||
|
|
||||||
|
-- 重置序列(覆盖到 61)
|
||||||
|
SELECT setval('sys_menu_id_seq', GREATEST((SELECT MAX(id) FROM sys_menu), 61), true);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- 将勘察费用(id=35)、运维费定价(id=34) 从项目管理(2)/运营派单(3) 迁移到 基础数据(parent_id=59)
|
||||||
|
-- 1) 两个页面菜单改挂到逻辑基础数据(59),路径/组件改为 /base/*
|
||||||
|
-- 2) 各自按钮(parent 原为模块 id)改挂到所属页面菜单下,保证随页面迁移
|
||||||
|
-- 幂等:仅更新存在的行;重复执行结果一致
|
||||||
|
UPDATE sys_menu
|
||||||
|
SET parent_id = 59,
|
||||||
|
menu_path = '/base/price',
|
||||||
|
component = 'base/price/index',
|
||||||
|
sort_order = 2
|
||||||
|
WHERE id = 34;
|
||||||
|
|
||||||
|
UPDATE sys_menu
|
||||||
|
SET parent_id = 59,
|
||||||
|
menu_path = '/base/cost',
|
||||||
|
component = 'base/cost/index',
|
||||||
|
sort_order = 3
|
||||||
|
WHERE id = 35;
|
||||||
|
|
||||||
|
-- 运维费定价按钮(price:create/update/delete) 挂到菜单 34
|
||||||
|
UPDATE sys_menu SET parent_id = 34 WHERE id IN (37, 38, 39);
|
||||||
|
|
||||||
|
-- 勘察费用按钮(cost:create/update/delete) 挂到菜单 35
|
||||||
|
UPDATE sys_menu SET parent_id = 35 WHERE id IN (40, 41, 42);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- 将项目勘察(id=27)、项目安装(id=33) 从项目管理(parent_id=2) 迁移到 运营派单(parent_id=3)
|
||||||
|
-- 目标子菜单顺序:电工登记管理(sort=1) → 项目勘察(27,sort=2) → 项目安装(33,sort=3) → 报修派单(sort=4) → 维保派单(sort=5)
|
||||||
|
-- 路径/组件改为 /dispatch/*(按用户确认,URL 与菜单归属一致)
|
||||||
|
-- 幂等:仅更新存在的行;重复执行结果一致
|
||||||
|
UPDATE sys_menu
|
||||||
|
SET parent_id = 3,
|
||||||
|
menu_path = '/dispatch/survey',
|
||||||
|
component = 'dispatch/survey/index',
|
||||||
|
sort_order = 2
|
||||||
|
WHERE id = 27;
|
||||||
|
|
||||||
|
UPDATE sys_menu
|
||||||
|
SET parent_id = 3,
|
||||||
|
menu_path = '/dispatch/install',
|
||||||
|
component = 'dispatch/install/index',
|
||||||
|
sort_order = 3
|
||||||
|
WHERE id = 33;
|
||||||
|
|
||||||
|
-- 报修派单(55)/维保派单(56) 排序后移至 4/5(若已是 4/5 则不变,保证顺序稳定)
|
||||||
|
UPDATE sys_menu SET sort_order = 4 WHERE id = 55;
|
||||||
|
UPDATE sys_menu SET sort_order = 5 WHERE id = 56;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- 定时任务人性化频率配置
|
||||||
|
-- 背景:定时任务配置目前只有裸 cron 输入框,不直观。本次为 sys_job 增加
|
||||||
|
-- freq_type / freq_config 结构化字段,cron_expression 仍作为调度唯一真值,
|
||||||
|
-- freq_type / freq_config 仅用于配置界面回填与展示(不参与调度判断)。
|
||||||
|
-- 幂等:列用 ADD COLUMN IF NOT EXISTS;存量回填按 job_key 且仅在该行仍是默认值时更新。
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 1) sys_job 新增频率配置字段
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
ALTER TABLE sys_job
|
||||||
|
ADD COLUMN IF NOT EXISTS freq_type VARCHAR(16) NOT NULL DEFAULT 'custom', -- 频率类型: minute/hour/day/week/month/custom
|
||||||
|
ADD COLUMN IF NOT EXISTS freq_config JSONB NOT NULL DEFAULT '{}'::jsonb; -- 频率配置(人性化配置表单回填用)
|
||||||
|
|
||||||
|
COMMENT ON COLUMN sys_job.freq_type IS '频率类型: minute每N分钟 hour每N小时 day每天 week每周 month每月 custom自定义';
|
||||||
|
COMMENT ON COLUMN sys_job.freq_config IS '频率配置(JSONB,仅用于配置界面回填与展示,不参与调度判断)';
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 2) 存量任务回填
|
||||||
|
-- 仅在仍是默认值(custom + 空配置)时更新,避免覆盖运维已在页面上的修改;
|
||||||
|
-- 未命中的行保持 custom,配置界面会以「自定义」展示原 cron 表达式。
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
UPDATE sys_job
|
||||||
|
SET freq_type = 'minute', freq_config = '{"interval":5}'::jsonb
|
||||||
|
WHERE job_key = 'demo_heartbeat' AND freq_type = 'custom' AND freq_config = '{}'::jsonb
|
||||||
|
AND cron_expression = '*/5 * * * *';
|
||||||
|
|
||||||
|
UPDATE sys_job
|
||||||
|
SET freq_type = 'day', freq_config = '{"times":["03:00"]}'::jsonb
|
||||||
|
WHERE job_key = 'log_clean' AND freq_type = 'custom' AND freq_config = '{}'::jsonb
|
||||||
|
AND cron_expression IN ('0 3 * * *', '0 0 3 * * *');
|
||||||
|
|
||||||
|
UPDATE sys_job
|
||||||
|
SET freq_type = 'day', freq_config = '{"times":["08:00"]}'::jsonb
|
||||||
|
WHERE job_key = 'notice_scan' AND freq_type = 'custom' AND freq_config = '{}'::jsonb
|
||||||
|
AND cron_expression IN ('0 8 * * *', '0 0 8 * * *');
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 3) cron 段数归一:notice_scan 原为 6 段带秒表达式,秒=0 时与 5 段等价,
|
||||||
|
-- 统一为 5 段便于配置界面回填(行为不变,仍为每天 08:00)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
UPDATE sys_job
|
||||||
|
SET cron_expression = '0 8 * * *'
|
||||||
|
WHERE job_key = 'notice_scan' AND cron_expression = '0 0 8 * * *';
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 4) 兜底:自定义类型的 freq_config 同步为当前表达式,保证配置界面可回填
|
||||||
|
-- (应用侧每次保存都会同时写入两者,此处仅覆盖历史数据)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
UPDATE sys_job
|
||||||
|
SET freq_config = jsonb_build_object('expression', cron_expression)
|
||||||
|
WHERE freq_type = 'custom';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- 勘察改造:移除 SOP(勘察技术要点与回执标准),新增「勘察明细」survey_detail
|
||||||
|
-- 语义:勘察明细按「客户空间拓扑」登记,一条明细 = 建筑 + 楼层 + 房间(可空) + 现场照片 + 备注。
|
||||||
|
-- 幂等:CREATE TABLE IF NOT EXISTS / DROP TABLE IF EXISTS / 触发器先 DROP 后建,可重复执行。
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1. 新增勘察明细表
|
||||||
|
CREATE TABLE IF NOT EXISTS survey_detail (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
survey_order_id BIGINT NOT NULL REFERENCES survey_order(id) ON DELETE CASCADE,
|
||||||
|
building_id BIGINT,
|
||||||
|
building_name VARCHAR(128),
|
||||||
|
floor_no VARCHAR(16),
|
||||||
|
room_id BIGINT,
|
||||||
|
room_name VARCHAR(128),
|
||||||
|
photos TEXT,
|
||||||
|
remark VARCHAR(255),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE survey_detail IS '勘察工单明细表(建筑/楼层/房间 + 现场照片)';
|
||||||
|
COMMENT ON COLUMN survey_detail.id IS '自增主键';
|
||||||
|
COMMENT ON COLUMN survey_detail.survey_order_id IS '勘察工单ID(→ survey_order)';
|
||||||
|
COMMENT ON COLUMN survey_detail.building_id IS '建筑ID(→ customer_building)';
|
||||||
|
COMMENT ON COLUMN survey_detail.building_name IS '建筑名称(冗余,客户拓扑变更后仍可读)';
|
||||||
|
COMMENT ON COLUMN survey_detail.floor_no IS '所在楼层:正数为地上、负数为地下,如 1 / -1';
|
||||||
|
COMMENT ON COLUMN survey_detail.room_id IS '房间ID(→ customer_room,可空)';
|
||||||
|
COMMENT ON COLUMN survey_detail.room_name IS '房间名称(冗余,可空)';
|
||||||
|
COMMENT ON COLUMN survey_detail.photos IS '现场照片对象key(JSON数组字符串)';
|
||||||
|
COMMENT ON COLUMN survey_detail.remark IS '备注';
|
||||||
|
COMMENT ON COLUMN survey_detail.created_at IS '创建时间';
|
||||||
|
COMMENT ON COLUMN survey_detail.updated_at IS '更新时间';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_survey_detail_order ON survey_detail (survey_order_id);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_survey_detail_updated_at ON survey_detail;
|
||||||
|
CREATE TRIGGER trg_survey_detail_updated_at BEFORE UPDATE ON survey_detail
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
||||||
|
|
||||||
|
-- 2. 移除 SOP 两张表(回执表先删,避免外键依赖)
|
||||||
|
DROP TABLE IF EXISTS survey_sop_result CASCADE;
|
||||||
|
DROP TABLE IF EXISTS survey_sop_item CASCADE;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- ICC 客户信息同步(第一阶段):
|
||||||
|
-- 1) 幂等新增 customer.icc_code 列(ICC 平台 orgCode,作为 ICC 同步的幂等匹配键)
|
||||||
|
-- 2) 幂等创建唯一索引 uk_customer_icc_code(icc_code 可空,PostgreSQL 下多个 NULL 不冲突)
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 1) 新增列 icc_code(可空,幂等;与 db_init.sql / customer.entity.ts 保持一致)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
ALTER TABLE customer ADD COLUMN IF NOT EXISTS icc_code VARCHAR(64);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 2) 唯一索引(icc_code 可空,PostgreSQL 中多个 NULL 不冲突,唯一索引安全)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_customer_icc_code ON customer (icc_code);
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 3) 列注释
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
COMMENT ON COLUMN customer.icc_code IS 'ICC客户编码(ICC平台 orgCode,同步来源)';
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -22,3 +22,12 @@ RUSTFS_BUCKET=xintong
|
|||||||
REDIS_HOST=222.243.55.42
|
REDIS_HOST=222.243.55.42
|
||||||
REDIS_PORT=26678
|
REDIS_PORT=26678
|
||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# ICC 平台配置(智电线路同步沿用 ICC_BASE_URL / ICC_TOKEN;客户信息同步走 OAuth2 密码模式)
|
||||||
|
ICC_BASE_URL=https://222.243.55.92:1443
|
||||||
|
ICC_TOKEN=
|
||||||
|
ICC_VERSION=1.0.0
|
||||||
|
ICC_CLIENT_ID=xtzdapp
|
||||||
|
ICC_CLIENT_SECRET=1c7e440a-660c-4a5d-a7b8-82b58d9dca4e
|
||||||
|
ICC_USERNAME=XTZD接口
|
||||||
|
ICC_PASSWORD=Xtzdkj@20260801
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.5",
|
||||||
"sequelize-typescript": "^2.1.6",
|
"sequelize-typescript": "^2.1.6",
|
||||||
|
"undici": "^8.12.3",
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ if (existsSync(envFile)) {
|
|||||||
loadEnv({ path: envFile, override: true });
|
loadEnv({ path: envFile, override: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// midway logger 默认日志目录为 join(MIDWAY_LOGGER_WRITEABLE_DIR ?? appInfo.root, "logs", appName),
|
||||||
|
// 且启动时 loggerService 会用该默认值强制覆盖用户 logger.dir 配置(addObject reverse=true)。
|
||||||
|
// 显式指定项目目录,使日志最终落在 server/logs 下,避免写入用户主目录触发 EPERM 权限报错。
|
||||||
|
if (!process.env.MIDWAY_LOGGER_WRITEABLE_DIR) {
|
||||||
|
process.env.MIDWAY_LOGGER_WRITEABLE_DIR = envDir;
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
keys: "smart-grid-iot-secret-key",
|
keys: "smart-grid-iot-secret-key",
|
||||||
|
|
||||||
@@ -105,6 +112,14 @@ export default {
|
|||||||
keyPrefix: "customer-visit"
|
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: {
|
cors: {
|
||||||
origin: "*",
|
origin: "*",
|
||||||
allowMethods: "GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS",
|
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 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
||||||
import { Context } from "@midwayjs/koa";
|
import { Context } from "@midwayjs/koa";
|
||||||
import { CustomerService } from "../service/customer.service";
|
import { CustomerService } from "../service/customer.service";
|
||||||
|
import { IccCustomerSyncService } from "../service/icc_customer_sync.service";
|
||||||
|
|
||||||
@Controller("/api/customers")
|
@Controller("/api/customers")
|
||||||
export class CustomerController {
|
export class CustomerController {
|
||||||
@@ -10,6 +11,9 @@ export class CustomerController {
|
|||||||
@Inject()
|
@Inject()
|
||||||
customerService!: CustomerService;
|
customerService!: CustomerService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
iccCustomerSyncService!: IccCustomerSyncService;
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
async list(@Query() query: any) {
|
async list(@Query() query: any) {
|
||||||
const result = await this.customerService.list(query, this.ctx);
|
const result = await this.customerService.list(query, this.ctx);
|
||||||
@@ -28,6 +32,16 @@ export class CustomerController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("/sync/icc")
|
||||||
|
async syncIccCustomers() {
|
||||||
|
const result = await this.iccCustomerSyncService.syncFromIcc(this.ctx);
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "同步完成",
|
||||||
|
data: result
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Get("/:id")
|
@Get("/:id")
|
||||||
async detail(@Param("id") id: string) {
|
async detail(@Param("id") id: string) {
|
||||||
const result = await this.customerService.detail(Number(id), this.ctx);
|
const result = await this.customerService.detail(Number(id), this.ctx);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
||||||
|
import { Context } from "@midwayjs/koa";
|
||||||
|
import { CustomerCircuitService } from "../service/customer_circuit.service";
|
||||||
|
|
||||||
|
@Controller("/api/customer-circuits")
|
||||||
|
export class CustomerCircuitController {
|
||||||
|
@Inject() ctx!: Context;
|
||||||
|
@Inject() customerCircuitService!: CustomerCircuitService;
|
||||||
|
|
||||||
|
@Get("/") async list(@Query() query: any) { return this.customerCircuitService.list(query, this.ctx); }
|
||||||
|
// 注意:静态路径 /topology 必须声明在 /:id 之前,避免被参数路由捕获
|
||||||
|
@Get("/topology") async topology(@Query() query: any) { return this.customerCircuitService.topology(query, this.ctx); }
|
||||||
|
@Get("/:id") async detail(@Param("id") id: string) { return this.customerCircuitService.detail(Number(id), this.ctx); }
|
||||||
|
@Post("/") async create(@Body() body: any) { return this.customerCircuitService.create(body, this.ctx); }
|
||||||
|
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.customerCircuitService.update(Number(id), body, this.ctx); }
|
||||||
|
@Del("/:id") async delete(@Param("id") id: string) { return this.customerCircuitService.delete(Number(id), this.ctx); }
|
||||||
|
}
|
||||||
@@ -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,18 @@
|
|||||||
|
import { Controller, Get, Post, Inject, Query, Param } from "@midwayjs/core";
|
||||||
|
import { Context } from "@midwayjs/koa";
|
||||||
|
import { ElectricLineService } from "../service/electric_line.service";
|
||||||
|
import { IccLineSyncService } from "../service/icc_line_sync.service";
|
||||||
|
|
||||||
|
@Controller("/api/electric-lines")
|
||||||
|
export class ElectricLineController {
|
||||||
|
@Inject() ctx!: Context;
|
||||||
|
@Inject() electricLineService!: ElectricLineService;
|
||||||
|
@Inject() iccLineSyncService!: IccLineSyncService;
|
||||||
|
|
||||||
|
@Get("/") async list(@Query() query: any) { return this.electricLineService.list(query, this.ctx); }
|
||||||
|
@Get("/:id") async detail(@Param("id") id: string) { return this.electricLineService.detail(Number(id), this.ctx); }
|
||||||
|
@Post("/sync") async sync() {
|
||||||
|
const result = await this.iccLineSyncService.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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
||||||
import { JobService } from "../service/job.service";
|
import { JobService } from "../service/job.service";
|
||||||
import { UpdateJobDTO, JobStatusDTO, JobLogQueryDTO } from "../dto/job.dto";
|
import { UpdateJobDTO, JobStatusDTO, JobLogQueryDTO, CronPreviewDTO } from "../dto/job.dto";
|
||||||
|
|
||||||
@Controller("/api/jobs")
|
@Controller("/api/jobs")
|
||||||
export class JobController {
|
export class JobController {
|
||||||
@@ -19,6 +19,12 @@ export class JobController {
|
|||||||
return this.jobService.clearLogs(parsed);
|
return this.jobService.clearLogs(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 频率预览:人性化配置 → cron / 中文描述 / 未来执行时间 */
|
||||||
|
@Post("/cron/preview")
|
||||||
|
async previewCron(@Body() dto: CronPreviewDTO) {
|
||||||
|
return this.jobService.previewCron(dto.freqType, dto.freqConfig);
|
||||||
|
}
|
||||||
|
|
||||||
@Put("/:id")
|
@Put("/:id")
|
||||||
async update(@Param("id") id: string, @Body() dto: UpdateJobDTO) {
|
async update(@Param("id") id: string, @Body() dto: UpdateJobDTO) {
|
||||||
return this.jobService.update(Number(id), dto);
|
return this.jobService.update(Number(id), dto);
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ export class SurveyOrderController {
|
|||||||
|
|
||||||
// 以下静态路径必须声明在 /:id 之前,否则会被动态路由吞掉
|
// 以下静态路径必须声明在 /:id 之前,否则会被动态路由吞掉
|
||||||
@Get("/stats") async stats() { return this.surveyOrderService.stats(this.ctx); }
|
@Get("/stats") async stats() { return this.surveyOrderService.stats(this.ctx); }
|
||||||
@Get("/sop-items") async listSopItems(@Query("templateCode") templateCode: string) {
|
|
||||||
return this.surveyOrderService.listSopItems(templateCode);
|
|
||||||
}
|
|
||||||
@Get("/quote") async quote(@Query("loopCount") loopCount: string) { return this.surveyOrderService.quote(loopCount); }
|
@Get("/quote") async quote(@Query("loopCount") loopCount: string) { return this.surveyOrderService.quote(loopCount); }
|
||||||
|
|
||||||
// 批量指派
|
// 批量指派
|
||||||
@@ -23,9 +20,9 @@ export class SurveyOrderController {
|
|||||||
@Post("/") async create(@Body() body: any) { return this.surveyOrderService.create(body, this.ctx); }
|
@Post("/") async create(@Body() body: any) { return this.surveyOrderService.create(body, this.ctx); }
|
||||||
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.surveyOrderService.update(Number(id), body, this.ctx); }
|
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.surveyOrderService.update(Number(id), body, this.ctx); }
|
||||||
|
|
||||||
// 现场回执:按 SOP 检查项增量录入(照片/截图 key 数组、表单值、备注)
|
// 勘察明细:全量替换(新增/编辑/删除统一走此接口)
|
||||||
@Put("/:id/sop-results") async saveSopResults(@Param("id") id: string, @Body() body: any) {
|
@Put("/:id/details") async saveDetails(@Param("id") id: string, @Body() body: any) {
|
||||||
return this.surveyOrderService.saveSopResults(Number(id), body, this.ctx);
|
return this.surveyOrderService.saveDetails(Number(id), body, this.ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 指派 / 改派共享电工
|
// 指派 / 改派共享电工
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export class WorkerController {
|
|||||||
}
|
}
|
||||||
@Del("/:id") async remove(@Param("id") id: string) { return this.workerService.remove(Number(id)); }
|
@Del("/:id") async remove(@Param("id") id: string) { return this.workerService.remove(Number(id)); }
|
||||||
|
|
||||||
|
// 重置电工 APP 登录密码(重置为手机号后 6 位)
|
||||||
|
@Post("/:id/reset-password") async resetPassword(@Param("id") id: string) {
|
||||||
|
return this.workerService.resetPassword(Number(id));
|
||||||
|
}
|
||||||
|
|
||||||
// 资质审核:操作人取自登录态
|
// 资质审核:操作人取自登录态
|
||||||
@Put("/:id/audit") async audit(@Param("id") id: string, @Body() body: WorkerAuditDTO) {
|
@Put("/:id/audit") async audit(@Param("id") id: string, @Body() body: WorkerAuditDTO) {
|
||||||
return this.workerService.audit(Number(id), body, this.ctx.state.user?.userId);
|
return this.workerService.audit(Number(id), body, this.ctx.state.user?.userId);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Controller, Get, Post, Put, Del, Inject, Body, Param, Query } from "@midwayjs/core";
|
import { Controller, Get, Post, Put, Del, Inject, Body, Param, Query, Files } from "@midwayjs/core";
|
||||||
import { Context } from "@midwayjs/koa";
|
import { Context } from "@midwayjs/koa";
|
||||||
|
import { UploadFileInfo } from "@midwayjs/upload";
|
||||||
import { WorkerAppService } from "../service/worker_app.service";
|
import { WorkerAppService } from "../service/worker_app.service";
|
||||||
import { NoticeService } from "../service/notice.service";
|
import { NoticeService } from "../service/notice.service";
|
||||||
|
import { UploadService } from "../service/upload.service";
|
||||||
import {
|
import {
|
||||||
WorkerRegisterDTO,
|
WorkerRegisterDTO,
|
||||||
WorkerLoginDTO,
|
WorkerLoginDTO,
|
||||||
@@ -20,6 +22,24 @@ export class WorkerAppController {
|
|||||||
@Inject()
|
@Inject()
|
||||||
noticeService!: NoticeService;
|
noticeService!: NoticeService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
uploadService!: UploadService;
|
||||||
|
|
||||||
|
// 勘察现场照片上传:电工 token 走 /api/worker-app/* 通道,复用对象存储上传能力
|
||||||
|
@Post("/upload/image")
|
||||||
|
async uploadImage(@Files() files: UploadFileInfo<string>[]) {
|
||||||
|
const file = files && files[0];
|
||||||
|
if (!file) {
|
||||||
|
return { code: 400, message: "请选择要上传的图片", data: null };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await this.uploadService.uploadFile(file);
|
||||||
|
return { code: 200, message: "上传成功", data };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { code: 500, message: `图片上传失败:${err?.message || "对象存储不可用"}`, data: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 注册(匿名)
|
// 注册(匿名)
|
||||||
@Post("/auth/register")
|
@Post("/auth/register")
|
||||||
async register(@Body() body: WorkerRegisterDTO) {
|
async register(@Body() body: WorkerRegisterDTO) {
|
||||||
@@ -130,6 +150,21 @@ export class WorkerAppController {
|
|||||||
return this.workerAppService.completeOrder(this.currentWorkerId(), source, Number(id));
|
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) {
|
||||||
|
if (source !== "survey") {
|
||||||
|
return { code: 400, message: "仅勘察订单支持维护明细" };
|
||||||
|
}
|
||||||
|
return this.workerAppService.saveSurveyDetails(this.currentWorkerId(), Number(id), body);
|
||||||
|
}
|
||||||
|
|
||||||
// 收益汇总
|
// 收益汇总
|
||||||
@Get("/earnings/summary")
|
@Get("/earnings/summary")
|
||||||
async earningsSummary() {
|
async earningsSummary() {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export class CustomerBuildingCreateDTO {
|
|||||||
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) basicInfo?: string;
|
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) basicInfo?: string;
|
||||||
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) purpose?: string;
|
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) purpose?: string;
|
||||||
@IsOptional() @IsInt() @Min(0) floorCount?: number;
|
@IsOptional() @IsInt() @Min(0) floorCount?: number;
|
||||||
|
@IsOptional() @IsInt() @Min(0) floorDownCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CustomerBuildingUpdateDTO {
|
export class CustomerBuildingUpdateDTO {
|
||||||
@@ -21,4 +22,5 @@ export class CustomerBuildingUpdateDTO {
|
|||||||
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) basicInfo?: string;
|
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) basicInfo?: string;
|
||||||
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) purpose?: string;
|
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) purpose?: string;
|
||||||
@IsOptional() @IsInt() @Min(0) floorCount?: number;
|
@IsOptional() @IsInt() @Min(0) floorCount?: number;
|
||||||
|
@IsOptional() @IsInt() @Min(0) floorDownCount?: number;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { IsNotEmpty, IsOptional, IsString, IsInt, IsNumber, Matches, Min, Max, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
export class CustomerCircuitQueryDTO {
|
||||||
|
@IsOptional() @IsInt() @Min(1) page?: number = 1;
|
||||||
|
@IsOptional() @IsInt() @Min(1) @Max(500) pageSize?: number = 10;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
|
||||||
|
@IsOptional() @IsInt() customerId?: number;
|
||||||
|
@IsOptional() @IsInt() buildingId?: number;
|
||||||
|
@IsOptional() @IsInt() roomId?: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(16) floorNo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CustomerCircuitCreateDTO {
|
||||||
|
@IsNotEmpty({ message: '回路编号不能为空' }) @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) circuitCode!: string;
|
||||||
|
@IsNotEmpty({ message: '所属客户不能为空' }) @IsInt() customerId!: number;
|
||||||
|
@IsNotEmpty({ message: '所属建筑不能为空' }) @IsInt() buildingId!: number;
|
||||||
|
@IsNotEmpty({ message: '回路名称不能为空' }) @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) circuitName!: string;
|
||||||
|
@IsOptional() @IsInt() roomId?: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(16) @Matches(/^[^<>]*$/) floorNo?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) loopSpec?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) loadType?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedVoltage?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedCurrent?: string;
|
||||||
|
@IsOptional() @IsNumber() @Min(0) ratedPower?: number;
|
||||||
|
@IsOptional() @IsInt() status?: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(2000) remark?: string;
|
||||||
|
@IsOptional() @IsInt() sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CustomerCircuitUpdateDTO {
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) circuitCode?: string;
|
||||||
|
@IsOptional() @IsInt() customerId?: number;
|
||||||
|
@IsOptional() @IsInt() buildingId?: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) circuitName?: string;
|
||||||
|
@IsOptional() @IsInt() roomId?: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(16) @Matches(/^[^<>]*$/) floorNo?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) loopSpec?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) loadType?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedVoltage?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedCurrent?: string;
|
||||||
|
@IsOptional() @IsNumber() @Min(0) ratedPower?: number;
|
||||||
|
@IsOptional() @IsInt() status?: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(2000) remark?: string;
|
||||||
|
@IsOptional() @IsInt() sortOrder?: number;
|
||||||
|
}
|
||||||
@@ -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,11 @@
|
|||||||
|
import { IsOptional, IsString, IsInt, MaxLength, Min, Max } from "class-validator";
|
||||||
|
|
||||||
|
export class ElectricLineQueryDTO {
|
||||||
|
@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() buildingId?: number;
|
||||||
|
@IsOptional() @IsInt() @Min(0) @Max(1) status?: number;
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import { IsNotEmpty, IsOptional, IsString, IsInt, IsObject, Min, Max, MaxLength } from "class-validator";
|
import { IsNotEmpty, IsOptional, IsString, IsInt, IsObject, Min, Max, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
/** 频率配置:字段按 freqType 取用,由 util/cron_schedule 统一校验与翻译 */
|
||||||
export class UpdateJobDTO {
|
export class UpdateJobDTO {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty({ message: "cron 表达式不能为空" })
|
@IsNotEmpty({ message: "频率类型不能为空" })
|
||||||
@MaxLength(64, { message: "cron 表达式最大 64 字符" })
|
@MaxLength(16, { message: "频率类型非法" })
|
||||||
cronExpression!: string;
|
freqType!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
freqConfig?: Record<string, any>;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@@ -26,6 +31,18 @@ export class UpdateJobDTO {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 频率预览入参:只传频率配置,cron 与下次执行时间由后端计算 */
|
||||||
|
export class CronPreviewDTO {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: "频率类型不能为空" })
|
||||||
|
@MaxLength(16, { message: "频率类型非法" })
|
||||||
|
freqType!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
freqConfig?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
export class JobStatusDTO {
|
export class JobStatusDTO {
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0, { message: "状态值非法" })
|
@Min(0, { message: "状态值非法" })
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ export class SurveyOrderCreateDTO {
|
|||||||
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string;
|
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string;
|
||||||
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) source?: string;
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) source?: string;
|
||||||
@IsOptional() @IsInt() @Min(0) @Max(1) isDraft?: number;
|
@IsOptional() @IsInt() @Min(0) @Max(1) isDraft?: number;
|
||||||
// 向导选用的 SOP 模板编码,用于生成回执记录(非持久化列)
|
// 勘察明细(建筑/楼层/房间 + 现场照片),非持久化列,仅用于创建时一并写入
|
||||||
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) templateCode?: string;
|
@IsOptional() @IsArray() details?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
|
export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
|
||||||
@@ -70,7 +70,7 @@ export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
|
|||||||
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) declare remark?: string;
|
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) declare remark?: string;
|
||||||
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) declare source?: string;
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) declare source?: string;
|
||||||
@IsOptional() @IsInt() @Min(0) @Max(1) declare isDraft?: number;
|
@IsOptional() @IsInt() @Min(0) @Max(1) declare isDraft?: number;
|
||||||
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) declare templateCode?: string;
|
@IsOptional() @IsArray() declare details?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 指派 / 改派(0待指派→指派,1现场勘察中→改派)
|
// 指派 / 改派(0待指派→指派,1现场勘察中→改派)
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export class WorkerUpdateDTO {
|
|||||||
@IsOptional() @IsInt() @IsIn([0, 1]) status?: number;
|
@IsOptional() @IsInt() @IsIn([0, 1]) status?: number;
|
||||||
@IsOptional() @IsNumber() @Min(0) @Max(5) rating?: number;
|
@IsOptional() @IsNumber() @Min(0) @Max(5) rating?: number;
|
||||||
@IsOptional() @IsString() @MaxLength(128) certInfo?: string;
|
@IsOptional() @IsString() @MaxLength(128) certInfo?: string;
|
||||||
|
// 为尚未开通 APP 账号的电工补开账号(默认密码为手机号后 6 位;已开通时忽略)
|
||||||
|
@IsOptional() @IsBoolean() withAccount?: boolean;
|
||||||
// 存在时整体覆盖该电工的全部证书
|
// 存在时整体覆盖该电工的全部证书
|
||||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => WorkerCertDTO) certs?: WorkerCertDTO[];
|
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => WorkerCertDTO) certs?: WorkerCertDTO[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user