Compare commits

..
13 Commits
Author SHA1 Message Date
wk 160ad85051 feat(line): 智电改造线路台账接入 ICC 消防探测器真实同步
- 重建 electric_line 表结构(迁移20261024_icc_line_rebuild.sql并同步db_init/db_seed):删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,gateway_id外键→electric_gateway
- 同步服务IccLineSyncService复用OAuth2真实鉴权分页拉取探测器(POST /detector/subsystem/page,pageSize=50短页停止),按line_code(detectorCode)幂等upsert写icc_sync_log;customer_id由orgCode→customer.icc_code解析、gateway_id由parentCode→electric_gateway.device_code匹配、equip_id同步不填充
- 列表接口返回gateway/type/imei等新字段,keyword检索含imei;前端台账页更新为新列(IMEI/所属网关/探测器类型/回路通道/消防系统/生产日期),保留手动同步按钮与筛选
- 更新系统设计文档electric_line表定义与spec(add-icc-line-sync)
2026-10-24 13:50:32 +08:00
wkandClaude Sonnet 4.6 noreply@anthropic.com cf0a0d4d31 feat(gateway): 新增智电改造网关台账功能(ICC消防物联设备同步) - 新建electric_gateway表(迁移20261024_icc_gateway.sql并同步db_seed:自增id、icc_imei/device_code双唯一、orgCode经customer.icc_code解析customer_id、equip_id可空、is_online/synced_at等);同步服务IccGatewaySyncService复用OAuth2真实鉴权分页拉取消防物联设备(POST /device/subsystem/page)并按imei幂等upsert写icc_sync_log;新增GET /api/electric-gateways列表/详情与POST /sync手动同步(RBAC device:list);注册icc_gateway_sync每10分钟定时任务(菜单62/role_menu/sys_job种子);前端网关台账页(搜索/筛选/同步按钮/在线状态tag/分页)与路由;server补装undici依赖
Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com
2026-10-24 12:37:35 +08:00
wk 9d3bb13ecf feat(customer): 新增ICC客户信息同步功能 - OAuth2密码模式鉴权拉取org_base客户、按icc_code幂等upsert写入并记录icc_sync_log,注册每10分钟定时任务;客户档案管理页新增手动'同步客户'按钮(POST /api/customers/sync/icc),同步后自动刷新列表与统计;customer表新增icc_code字段及唯一索引,同步db_seed与设计文档 2026-10-23 18:20:25 +08:00
andyesfly be91d04431 refactor(dashboard): 将页面英文文本替换为中文并优化操作功能
替换页面中的英文标签为中文,新增勘察流水行详情操作按钮,为工作流链接添加点击事件处理,移除未使用的图标组件。
2026-10-21 16:55:00 +08:00
andyesfly 184b8d03e2 Merge branch 'main' of http://100.64.131.105:3000/GY/smart_grid_iot 2026-10-17 10:08:54 +08:00
andyesfly 22d27a940d feat: 重构勘察业务为明细模式并新增定时任务人性化配置
- 重构勘察业务:移除SOP相关表,新增勘察明细表支持建筑/楼层/房间/照片管理
- 新增定时任务人性化频率配置,支持分钟/小时/天/周/月/自定义六种模式
- 新增频率预览接口,支持配置转换为cron表达式与执行时间预览
- 更新系统品牌名称为鑫潼智电,修改全局标题与登录页文案
2026-10-17 10:08:42 +08:00
wk 41765cdf44 feat: 新增智电改造线路台账功能(ICC拉取同步)
- 数据库:新增迁移脚本20261011_icc_line_ledger.sql(electric_line/line_room/icc_sync_log三表、菜单61、role_menu(1,61)、定时任务icc_line_sync),同步db_seed.sql
- 后端:新增ElectricLine/IccSyncLog实体、查询DTO、线路台账服务与ICC拉取同步服务、/api/electric-lines控制器,接入RBAC(device:list)与定时任务
- 前端:新增智电改造线路台账页面及路由,API封装electricLineApi
- ICC同步采用本系统主动拉取模式,失败仅记日志不抛错,接口统一code/message/data格式兼容APP
2026-10-12 15:57:02 +08:00
andyesfly f72644c24f feat: 新增客户用电回路全链路功能
新增客户用电回路表及相关数据库迁移、后端服务、控制器、DTO、实体类和权限配置;新增前端API封装,同时调整路由名称以匹配新业务模块,复用现有菜单权限避免403错误。
2026-10-12 15:20:08 +08:00
andyesfly 3596462eca Merge branch 'main' of http://git.dibox.tech/parttime/smart_grid_iot 2026-10-11 15:07:49 +08:00
andyesfly f8117e33b6 feat: 新增电工 APP 账号开通与密码重置功能
- 新增后端重置密码接口及前端对应操作入口
- 新增补开电工 APP 账号的更新参数
- 新增 hasAccount 字段标识电工是否开通 APP 账号
- 新增数据库菜单及角色绑定支持密码重置权限
- 移除后台角色中错误的 WORKER 和 CUSTOMER 角色
2026-10-11 15:07:45 +08:00
wk 850cd92584 refactor(menu): 菜单模块归属调整 - 勘察费用/运维费定价迁至基础数据(/base/*),项目勘察/项目安装迁至运营派单(/dispatch/*)并按序排列(电工→勘察→安装→报修→维保);同步前端路由/页面git mv/通知跳转与db_seed,生产库已执行迁移 2026-10-11 12:21:30 +08:00
wk 77891bf4f2 feat(topology): 建筑支持地下楼层数,房间所在楼层下拉自动按地上+地下生成 - 建筑表新增floordown_count,建筑表单拆分地上/地下楼层数;房间所在楼层改下拉,按地上(1层/2层)与地下(负1层/负2层)自动生成、负楼层在前,选项采用数字格式 2026-10-08 16:49:56 +08:00
wk d12fa91ec2 feat(topology): 房间新增/编辑弹窗增加所属客户部门选择项 - 按当前客户加载部门下拉,提交与编辑回填deptId 2026-10-08 11:07:34 +08:00
109 changed files with 8416 additions and 1246 deletions

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,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 接口确认
+52
View File
@@ -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,13 @@
# Checklist
- [x] electric_gateway 表已建(迁移 20261024_icc_gateway.sql),字段集符合需求方确认:自增主键 id、icc_imei/device_code 双唯一、customer_id→customer、equip_id→customer_equipment 可空、org_code/org_name/manufacturer_code/manufacturer_name/type_code/type_name/product_model/is_online/synced_at/created_at;不含 companyId/companyName/dcpsId/softwareVersion/systemAddress/address/description;与 db_seed.sql 结构一致 —— 迁移已执行到远程生产库(信息模式校验 16 列),db_seed.sql 451-522 行同步
- [x] 菜单 62「智电改造网关台账」挂设备监控(4)(permission device:list)、ADMIN role_menu、sys_job 种子 icc_gateway_sync(cron `*/10 * * * *`)已写入迁移与 db_seed.sql 且幂等 —— 生产库已存在(menu62 查询命中、role_menu(1,62)、job 行 status=1)
- [x] ElectricGateway 实体与迁移结构一致(field 映射、索引)—— 实体含 @ForeignKey Customer/CustomerEquipment、@BelongsTo、3 索引
- [x] icc_gateway_mapper.ts 正确映射 DeviceInfoVO 字段(imei/deviceCode/deviceName/orgCode/orgName/manufacturerCode/manufacturerName/typeCode/typeName/productModel/isOnline)并解析分页响应(PageVO{nextPage,totalCount,pageData})—— 实测映射正确(typeName=组合式电气火灾监控探测器、manufacturerName=大华、productModel=DH-…)
- [x] IccGatewaySyncService 复用 getIccAccessToken/iccFetch 真实鉴权,POST 分页全量拉取(pageSize=50,循环至短页,最大页数保护),orgCode→customer.icc_code 解析 customer_id —— 实测 pulled=2,customerId 解析为 3(KH20261016001)
- [x] 同步按 icc_imei(缺省 device_code)幂等 upsert,跳过缺双键或匹配不到客户的行,更新 synced_at,写 icc_sync_log(sync_type=electric_gateway);失败仅记录不抛业务错误 —— 实测首次 inserted=2,重跑 updated=2 skipped=0;icc_sync_log 2 条 status=1 record_count=2
- [x] GET /api/electric-gateways 列表/详情、POST /api/electric-gateways/sync 已实现;RBAC `device:list` 已映射;非 ADMIN 按客户归属限定数据范围 —— 无 token 401、admin 列表 200
- [x] 定时任务 icc_gateway_sync 已注册(job_registry BUILTIN_JOBS + job_scheduler buildRuntime syncIccGateways + db_seed.sql 种子)—— 调度器启动日志"已加载 6 个任务"含 icc_gateway_sync cron=*/10 * * * *
- [x] server/.env 含 ICC_BASE_URL=https://222.243.55.92:1443、ICC_VERSION=1.0.0、ICC_CLIENT_ID/ICC_CLIENT_SECRET/ICC_USERNAME/ICC_PASSWORD —— 已确认齐全
- [x] 前端 views/device/gateway/index.vue(搜索/同步按钮/表格/在线状态 tag/分页)、路由 /device/gateway、api/device.ts electricGatewayApi 已实现 —— 浏览器实测渲染/同步/筛选/重置全部正常
- [x] 后端编译(tsc --noEmit)与前端类型检查(vue-tsc -b)通过;运行验证:401/200、sync 真实拉取成功不 500、菜单与页面渲染正常 —— 全部实测通过
+46
View File
@@ -0,0 +1,46 @@
# 智电改造网关台账(ICC 消防物联设备同步)Spec
## Why
系统缺少"智电改造网关设备"台账。网关设备数据由 ICC 平台消防物联设备接口(`/evo-apigw/evo-fdbu/{version}/device/subsystem/page`)提供,需定时拉取并落库到新建的 `electric_gateway` 表,运营侧可按客户查看网关设备台账、手动触发同步。本表在设计文档中未定义,需依据接口文档新增表结构(已与需求方确认字段集)。
## What Changes
- **新增** `electric_gateway` 表(数据库迁移 + db_seed.sql 同步):自增主键 `id`、`icc_imei`/`device_code` 双唯一键、`device_name`、`customer_id`(→ customer,由接口 orgCode→customer.icc_code 解析)、`equip_id`(→ customer_equipment,可空,同步不填充)、`org_code`/`org_name`、`manufacturer_code`/`manufacturer_name`、`type_code`/`type_name`、`product_model`、`is_online`、`synced_at`、`created_at`;**不含** companyId/companyName/dcpsId/softwareVersion/systemAddress/address/description(经需求方确认)
- **新增** ICC 网关设备同步:`IccGatewaySyncService` 复用真实 OAuth2 鉴权(`icc_customer_auth.ts` 的 `getIccAccessToken` + `iccFetch`,走 `.env` 已配置凭据),POST 分页拉取消防物联设备全量数据(pageNum/pageSize=50,循环至短页,全量不传 typeCode),按 imei(缺省时 deviceCode)幂等 upsert,写 `icc_sync_log`(sync_type=electric_gateway)
- **新增** 后端接口:`GET /api/electric-gateways` 列表、`GET /api/electric-gateways/:id` 详情、`POST /api/electric-gateways/sync` 手动同步
- **新增** 定时任务:注册 `icc_gateway_sync`(job_registry + job_scheduler buildRuntime + db_seed.sql sys_job 种子,默认 cron `*/10 * * * *`,与既有 ICC 同步任务口径一致)
- **新增** RBAC 映射:`/api/electric-gateways → device:list`
- **新增** 前端"智电改造网关台账"页面(`views/device/gateway/index.vue`),路由 `/device/gateway`,含"同步网关"手动按钮;菜单挂设备监控目录(parent=4)
- **新增** `.env` ICC 网关同步配置(ICC_BASE_URL=https://222.243.55.92:1443、ICC_VERSION=1.0.0、ICC_CLIENT_ID/ICC_CLIENT_SECRET/ICC_USERNAME/ICC_PASSWORD,取自接口文档)
- **安全**:全部 SQL 走 Sequelize 参数绑定;前端不用 v-html(防 XSS);接口返回统一 `code/message/data`
## Impact
- Affected specs: 设备监控(设备台账、智电改造线路台账)、ICC 数据同步、RBAC 权限
- Affected code: server/src(entity/dto/service/controller/task/middleware)、client/src(api/router/views)、database(db_seed.sql、migrations/)
## ADDED Requirements
### Requirement: 网关台账查询(JWT+RBAC)
系统 SHALL 提供网关台账列表与详情查询,仅登录且有 `device:list` 权限的用户可访问;查询支持按 customer_id/equip_id/status(在线状态)/关键词(device_name/device_code/icc_imei)过滤分页;非 ADMIN 业务员按客户归属自动限定数据范围。
#### Scenario: 成功查询
- **WHEN** 已登录用户 GET `/api/electric-gateways?page=1&pageSize=10&customerId=1`
- **THEN** 返回 `code=0,data{list,total}`,含设备名称/编码/imei/客户/设备型号/主机类型/厂商/在线状态/同步时间;无 token 返回 401,无权限返回 403
### Requirement: ICC 网关设备拉取同步(真实鉴权 + 定时 + 手动)
系统 SHALL 提供 ICC 网关设备拉取同步能力:`IccGatewaySyncService` 读取 `.env` 的 ICC 配置(复用 `icc_customer_auth` 真实 OAuth2 密码模式鉴权),POST `/evo-apigw/evo-fdbu/{version}/device/subsystem/page` 分页拉取消防物联设备全量数据(不传 typeCode,pageSize 50,循环至短页,带最大页数保护),字段映射集中在 `icc_gateway_mapper`,按 icc_imei(缺省用 device_code)幂等 upsert 写入 `electric_gateway`,`customer_id` 由 orgCode→customer.icc_code 解析(匹配不到或缺 icc_imei 与 device_code 则跳过),更新 `synced_at`,写 `icc_sync_log`(sync_type=electric_gateway,status/record_count/error_message)。同步入口:① 定时任务(jobKey=`icc_gateway_sync`,默认 `*/10 * * * *`);② 手动 `POST /api/electric-gateways/sync`(JWT+RBAC `device:list`)。
#### Scenario: 定时/手动拉取成功
- **WHEN** 定时任务触发或运营点击"同步网关"
- **THEN** 拉取 ICC 数据并 upsert 对应网关行,`icc_sync_log` 记录成功;返回 `code=0,data{pulled,synced,inserted,updated,skipped}`
#### Scenario: 手动同步鉴权失败
- **WHEN** 未登录或无 `device:list` 权限调用 `/api/electric-gateways/sync`
- **THEN** 返回 401/403,不触发拉取
#### Scenario: ICC 拉取失败(不可达/鉴权失败/接口异常)
- **WHEN** ICC 地址不可达或返回异常
- **THEN** 同步日志记录失败状态与错误信息,业务接口不报错,仅返回已拉取计数
### Requirement: 前端网关台账页
系统 SHALL 提供 `/device/gateway` 页面:搜索栏(设备名称/编码/imei、客户、在线状态)、"同步网关"按钮(调 `/api/electric-gateways/sync`,成功后刷新)、表格(设备名称/编码/imei/客户/厂商/主机类型/设备型号/在线状态 tag/同步时间)、分页;样式与智电改造线路台账一致,全部文本经 Vue 插值转义。
#### Scenario: 打开页面
- **WHEN** 有 `device:list` 权限的用户访问设备监控→智电改造网关台账
- **THEN** 渲染列表并展示分页与筛选
## MODIFIED Requirements
(无既有功能被修改)
+39
View File
@@ -0,0 +1,39 @@
# Tasks
- [x] Task 1: 数据库层:新建迁移 `database/migrations/20261024_icc_gateway.sql` 建 `electric_gateway`(字段集经需求方确认),并把建表/菜单/角色菜单/定时任务种子同步进 `db_seed.sql`;幂等(IF NOT EXISTS / ON CONFLICT)
- [x] SubTask 1.1: 核对生产库是否已有 electric_gateway,避免重复;确认 sys_menu 现有最大 id(迁移用 id=62 并 setval)—— 已核对:表无先例、菜单 id 62 空闲;迁移已执行到远程生产库
- [x] SubTask 1.2: 迁移内容:建表 electric_gateway(自增 id;icc_imei/device_code 唯一;customer_id→customer;equip_id→customer_equipment 可空;org_code/org_name/manufacturer_code/manufacturer_name/type_code/type_name/product_model/is_online/synced_at/created_at;索引 customer_id/equip_id/icc_imei);菜单 62「智电改造网关台账」挂设备监控(4)(permission device:list);ADMIN role_menu;sys_job 种子 icc_gateway_sync(cron `*/10 * * * *`)
- [x] SubTask 1.3: db_seed.sql 同步对应语句(保持幂等一致)—— 已追加段落(451-522 行)
- [x] Task 2: 后端实体与映射器:
- [x] SubTask 2.1: `server/src/entity/electric_gateway.entity.ts`(timestamps:false,结构对齐迁移文件)
- [x] SubTask 2.2: `server/src/task/icc_gateway_mapper.ts`:集中配置端点 `/evo-apigw/evo-fdbu/{version}/device/subsystem/page`、DeviceInfoVO 字段映射(id/imei/deviceCode/deviceName/orgCode/orgName/manufacturerCode/manufacturerName/typeCode/typeName/productModel/isOnline)、分页响应解析(PageVO{nextPage,totalCount,pageData})
- [x] Task 3: 后端同步服务 `server/src/service/icc_gateway_sync.service.ts`:
- [x] SubTask 3.1: 复用 `icc_customer_auth.ts` 的 `getIccAccessToken`/`iccFetch` 真实鉴权;读取 `.env`(ICC_BASE_URL/ICC_VERSION),配置缺失视为失败仅记日志
- [x] SubTask 3.2: POST 分页拉取(pageSize=50、不传 typeCode、循环至短页、最大页数保护),预取 orgCode→customer(id) 映射(customer.icc_code)
- [x] SubTask 3.3: 按 icc_imei(缺省 device_code)幂等 upsert;缺 imei 与 device_code 或匹配不到客户则跳过;更新 synced_at;写 icc_sync_log(sync_type=electric_gateway);拉取失败仅记录不抛业务错误 —— 实测同步成功(pulled=2),重跑 updated=2 幂等
- [x] Task 4: 后端查询服务/Controller/DTO + RBAC:
- [x] SubTask 4.1: `server/src/service/electric_gateway.service.ts`(list/detail,过滤:keyword=device_name/device_code/icc_imei、customer_id、equip_id、is_online;非 ADMIN 按 salesmanId 限定客户范围;decorate 补全客户/设备展示字段)
- [x] SubTask 4.2: `server/src/dto/electric_gateway.dto.ts`(查询/同步入参校验)
- [x] SubTask 4.3: `server/src/controller/electric_gateway.controller.ts`:GET `/api/electric-gateways`、GET `/:id`、POST `/sync`
- [x] SubTask 4.4: `rbac.middleware.ts` 追加 `/api/electric-gateways → device:list`
- [x] Task 5: 定时任务注册:
- [x] SubTask 5.1: `server/src/task/icc_gateway_sync.task.ts`(jobKey=`icc_gateway_sync`,默认 cron `*/10 * * * *`,handler 调 runtime.syncIccGateways)
- [x] SubTask 5.2: `job_registry.ts`:JobRuntime 增加 `syncIccGateways`,BUILTIN_JOBS 追加 iccGatewaySyncJob
- [x] SubTask 5.3: `job_scheduler.service.ts` buildRuntime 增加 `syncIccGateways`(匿名上下文注入 IccGatewaySyncService)
- [x] SubTask 5.4: `server/.env` 补全 ICC 网关同步凭据 —— 已确认 .env 中配置齐全(ICC_BASE_URL=https://222.243.55.92:1443、ICC_VERSION=1.0.0、ICC_CLIENT_ID=xtzdapp、ICC_CLIENT_SECRET、ICC_USERNAME=XTZD接口、ICC_PASSWORD)
- [x] Task 6: 前端:
- [x] SubTask 6.1: `client/src/api/device.ts` 新增 electricGatewayApi.list/get/sync
- [x] SubTask 6.2: `client/src/views/device/gateway/index.vue`(搜索栏+同步按钮+表格+在线状态 tag+分页,样式对齐 views/device/line/index.vue)
- [x] SubTask 6.3: `client/src/router/index.ts` 注册 `/device/gateway`(挂 device 目录)
- [x] Task 7: 验证:
- [x] SubTask 7.1: 后端 `tsc --noEmit`、前端 `vue-tsc -b` 类型检查通过 —— 均通过(exit 0)
- [x] SubTask 7.2: 启动服务:无 token 401(实测 401)、admin 列表 200(实测 total=2)、`POST /sync` 真实拉取成功(pulled=2→落库→重跑 updated=2)、定时任务 icc_gateway_sync 已注册(调度器加载 6 个任务,含 icc_gateway_sync cron */10 * * * *)
- [x] SubTask 7.3: 浏览器验证菜单出现、页面渲染、筛选/分页/同步按钮可用 —— 实测:菜单出现、9 列表格渲染 2 条、同步按钮提示"拉取 2 条,新增 0,更新 2"并刷新、关键词筛选/重置正常
# Task Dependencies
- Task 2/6 依赖 Task 1(表存在)
- Task 3 依赖 Task 2(映射器)
- Task 4 依赖 Task 1/2
- Task 5 依赖 Task 3
- Task 6 依赖 Task 4(接口可用)
- Task 7 依赖全部
@@ -0,0 +1,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] 浏览器端登录后菜单可见、页面可用、同步按钮可用
+45
View File
@@ -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
(无既有功能被修改)
+26
View File
@@ -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;浏览器页面新列渲染正常
+71
View File
@@ -0,0 +1,71 @@
# 智电线路真实 ICC 同步 Spec(add-icc-line-sync)
## Why
现有 electric_line(智电改造线路台账)为占位实现:表结构是手工设计的占位字段(building/floor/location 等),同步链路 `icc_line_sync.service.ts` 走假的 `ICC_TOKEN` 配置、无真实鉴权。本次依据 ICC 平台接口文档第三节「消防探测器分页查询」(`POST /evo-apigw/evo-fdbu/{version}/detector/subsystem/page`)将线路台账升级为**真实数据同步**:重建表结构(按 DetectorVO 落库字段 + 与 customer/customer_equipment/electric_gateway 三张表的关联),后端替换为真实 OAuth2 拉取(复用 icc_customer_auth),前端台账页补充新字段展示并保留手动同步按钮。
## What Changes
- **表结构变更(BREAKING)**:`electric_line` 按 DetectorVO 重建字段集:
- 删除:`building_id`、`floor_no`、`location`、`icc_device_code`(占位字段无真实数据源)
- `equip_id` 由 `NOT NULL` 改为**可空**(→ customer_equipment,同步不填充,留待手动关联)
- 新增:`imei`、`gateway_id`(→ electric_gateway)、`type_code`、`type_name`、`fire_system_code`、`fire_system_name`、`part_unit_loop_code`、`channel_no`、`channel_sn`、`produce_date`
- 保留:`line_code`(= detectorCode,唯一幂等键)、`line_name`(= detectorName)、`customer_id`(NOT NULL)、`status`(= detectorStatus:01→1 在线 / 02→0 离线)、`synced_at`、`created_at`
- 不落库(仅作关联匹配键):`orgCode`→customer.icc_code 反查 customer_id;`parentCode`→electric_gateway.device_code 反查 gateway_id
- **后端同步真实化**:重写 `icc_line_mapper.ts`(DetectorVO 映射 + 分页解析)与 `icc_line_sync.service.ts`(复用 `getIccAccessToken`/`iccFetch`,POST 分页 pageSize=50 短页停止、全量拉取、按 line_code 幂等 upsert、写 icc_sync_log sync_type=electric_line、失败仅记日志不抛)
- **关联规则**:customer_id 由 orgCode 匹配;gateway_id 由 parentCode 匹配 device_code;equip_id 同步不填充
- **前端台账页更新**:`views/device/line/index.vue` 移除楼层/建筑/位置列,新增探测器类型、IMEI、回路/通道、消防系统、生产日期、网关等列;保留「同步线路」手动按钮与筛选
- **设计文档同步**:更新 `系统设计/表结构新增及变更.md` 补充 electric_line 新结构
- 定时任务 `icc_line_sync`(jobKey 不变,cron */10)、菜单 61、RBAC `/api/electric-lines → device:list`、前端路由 `/device/line` 均沿用既有,无需新增
## Impact
- Affected specs:add-icc-line-ledger(本 spec 升级其占位实现)、add-icc-gateway-sync(复用其真实同步模式与关联表)
- Affected code:
- 后端:`server/src/entity/electric_line.entity.ts`、`server/src/task/icc_line_mapper.ts`、`server/src/service/icc_line_sync.service.ts`、`server/src/service/electric_line.service.ts`(decorate 去掉 building、新增 gateway/类型展示)、`server/src/controller/electric_line.controller.ts`(不动)
- 迁移:`database/migrations/20261011_icc_line_ledger.sql`(重建 electric_line 结构)或新增增量迁移;`database/db_seed.sql` 同步
- 前端:`client/src/views/device/line/index.vue`、`client/src/api/device.ts`(ElectricLine 类型)
- 设计文档:`系统设计/表结构新增及变更.md`
## ADDED Requirements
### Requirement: electric_line 表结构按 DetectorVO 重建
系统 SHALL 将 `electric_line` 重建为与 DetectorVO 对应的字段集,且:
- `line_code` 存 detectorCode(唯一,幂等键)
- `customer_id` NOT NULL,由 orgCode→customer.icc_code 反查
- `gateway_id` 可空,由 parentCode→electric_gateway.device_code 反查
- `equip_id` 可空,同步不填充
#### Scenario: 迁移执行成功
- **WHEN** 执行迁移到生产库
- **THEN** electric_line 包含新字段集,旧字段(building_id/floor_no/location/icc_device_code)被移除,equip_id 可空,索引重建;db_seed.sql 与生产库一致
### Requirement: 真实 ICC 探测器拉取同步
系统 SHALL 复用 `getIccAccessToken`/`iccFetch` 真实鉴权,POST `{baseUrl}/evo-apigw/evo-fdbu/{version}/detector/subsystem/page` 分页全量拉取(pageNum 递增、pageSize=50、短页停止、MAX_PAGES 保护),按 line_code 幂等 upsert,写 icc_sync_log(sync_type=electric_line)。
#### Scenario: 同步成功
- **WHEN** 手动点击「同步线路」或定时任务触发
- **THEN** 返回 {pulled, inserted, updated, skipped, synced};新探测器落库、重复 detectorCode 更新;customer_id 解析失败的行跳过;写成功日志
#### Scenario: 同步失败
- **WHEN** ICC 拉取异常(超时/HTTP 错误/鉴权失败)
- **THEN** 仅写 icc_sync_log 失败日志,不抛业务错误,接口返回 code 200 与当前计数
### Requirement: 前端台账页按新字段展示
系统 SHALL 更新 `views/device/line/index.vue`:删除建筑/楼层/位置列,新增探测器类型、IMEI、回路/通道、消防系统、生产日期、所属网关列;保留筛选(客户/状态/关键词)与「同步线路」手动按钮。
#### Scenario: 页面渲染
- **WHEN** 登录 admin 打开 /device/line
- **THEN** 表格展示新字段列,无 v-html,同步按钮触发 POST /api/electric-lines/sync
## MODIFIED Requirements
### Requirement: 线路台账列表接口(原有)
`GET /api/electric-lines` 保留分页/筛选/RBAC(非 ADMIN 限定 customer_id),decorate 从 building 改为 gateway/类型/IMEI 展示字段,删除 customer_building 关联。
#### Scenario: 列表返回
- **WHEN** 带权限请求 GET /api/electric-lines
- **THEN** 返回行包含 lineCode/lineName/imei/customer/gateway/type/loop/channel/fireSystem/produceDate/status/syncedAt
## REMOVED Requirements
### Requirement: 占位 ICC 同步实现(原 icc_line_sync.service / icc_line_mapper 假配置映射)
**Reason**:无真实鉴权与接口,与 ICC 文档字段不对应。
**Migration**:删除 `ICC_TOKEN` 占位用法,改为复用 icc_customer_auth 真实鉴权;映射按 DetectorVO 重写。
+25
View File
@@ -0,0 +1,25 @@
# Tasks
- [x] Task 1: 数据库迁移:重建 `electric_line` 结构(删 building_id/floor_no/location/icc_device_code;equip_id 改可空;新增 imei/gateway_id/type_code/type_name/fire_system_code/fire_system_name/part_unit_loop_code/channel_no/channel_sn/produce_date),迁移脚本 `database/migrations/20261024_icc_line_rebuild.sql`,同步更新 `database/db_seed.sql`(fk_line_gateway 幂等补充)与 `database/db_init.sql`(基线建表);`gateway_id` 建 `REFERENCES electric_gateway(id)` 与索引
- [x] SubTask 1.1: 核对生产库 electric_line 现状(旧结构迁移而来,无占位数据阻碍),确认 ALTER 方案幂等
- [x] SubTask 1.2: 执行迁移到远程生产库并验证字段/索引/外键(columns 18 个、idx_line_gateway/imei、fk_line_gateway 均就绪)
- [x] SubTask 1.3: 同步 `系统设计/表结构新增及变更.md`(补充 electric_line 新表结构定义)
- [x] Task 2: 后端实体:按新字段重写 `server/src/entity/electric_line.entity.ts`(gatewayId FK→ElectricGateway,删除 building/floor/location/iccDeviceCode)
- [x] Task 3: 映射层:重写 `server/src/task/icc_line_mapper.ts`
- [x] SubTask 3.1: 定义 `ICC_LINE_ENDPOINT = /evo-apigw/evo-fdbu/{version}/detector/subsystem/page` 与 DetectorVO 字段映射(lineCode=detectorCode、lineName=detectorName、imei、typeCode/typeName、fireSystemCode/Name、partUnitLoopCode、channelNo、channelSn、produceDate、status 归一 01→1/02→0、orgCode、parentCode 作为匹配键透出)
- [x] SubTask 3.2: `parseLinePage(payload)` 解析 PageVO{nextPage,totalCount,pageData}(兼容数组形态)
- [x] Task 4: 同步服务:重写 `server/src/service/icc_line_sync.service.ts`,复用 `getIccAccessToken`/`iccFetch`,POST 分页 pageSize=50 短页停止全量拉取;预取 orgCode→customer.id(customer.icc_code)与 parentCode→gateway.id(electric_gateway.device_code)映射避免 N+1;按 line_code 幂等 upsert(equip_id 不填);写 icc_sync_log(sync_type=electric_line);拉取失败仅记日志不抛
- [x] SubTask 4.1: 定时任务 `icc_line_sync.task.ts`(jobKey/cron 不变)与 job_registry/JobRuntime(job_scheduler.service.ts syncIccLines)链路可用
- [x] SubTask 4.2: 控制器 `POST /api/electric-lines/sync` 复用返回结构(pulled/inserted/updated/skipped/synced)
- [x] Task 5: 列表服务:`server/src/service/electric_line.service.ts` 更新 decorate(删除 building 反查,新增 gateway/type/imei 展示字段);keyword 检索范围补充 imei
- [x] Task 6: 前端:`client/src/api/device.ts` 更新 `ElectricLine` 类型(新字段);`client/src/views/device/line/index.vue` 删除建筑/楼层/位置列,新增 IMEI/探测器类型/回路/通道/消防系统/生产日期/所属网关列,筛选/分页/同步按钮保留
- [x] Task 7: 验证:
- [x] SubTask 7.1: 后端 `tsc --noEmit`、前端 `vue-tsc -b` 均 0 错误
- [x] SubTask 7.2: 真实拉取 ICC 探测器(admin token,拉取 4 条插入 4 条;重跑幂等 updated=4;icc_sync_log 3 条 status=1;列表接口返回新字段)
- [x] SubTask 7.3: 浏览器 admin 登录验证 /device/line 页面新列渲染与手动同步按钮(11 列齐全、同步提示「拉取 4 条,新增 0,更新 4」)
# Task Dependencies
- Task 2/3/6 依赖 Task 1(表结构先就绪)
- Task 4 依赖 Task 2/3
- Task 5 依赖 Task 2
- Task 7 依赖全部
+27
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
# 开发环境变量
VITE_API_BASE_URL=/api
VITE_APP_TITLE=智能物联网设备管理系统
VITE_APP_TITLE=鑫潼智电管理系统
+1 -1
View File
@@ -1,3 +1,3 @@
# 生产环境变量
VITE_API_BASE_URL=/api
VITE_APP_TITLE=智能物联网设备管理系统
VITE_APP_TITLE=鑫潼智电管理系统
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Trae Project</title>
<title>鑫潼智电管理系统</title>
<script type="module">
if (import.meta.hot?.on) {
import.meta.hot.on('vite:error', (error) => {
+2
View File
@@ -86,6 +86,8 @@ export const customerApi = {
getCustomerStats: () => request.get<CustomerStats>('/customers/stats'),
syncIccCustomers: () => request.post('/customers/sync/icc'),
createCustomer: (data: CustomerCreateParams) =>
request.post('/customers', data),
+3
View File
@@ -7,6 +7,7 @@ export interface CustomerBuildingInfo {
basicInfo?: string;
purpose?: string;
floorCount?: number;
floorDownCount?: number;
createdAt: string;
}
@@ -30,6 +31,7 @@ export interface CustomerBuildingCreateParams {
basicInfo?: string;
purpose?: string;
floorCount?: number;
floorDownCount?: number;
}
export interface CustomerBuildingUpdateParams {
@@ -38,6 +40,7 @@ export interface CustomerBuildingUpdateParams {
basicInfo?: string;
purpose?: string;
floorCount?: number;
floorDownCount?: number;
}
export const customerBuildingApi = {
+132
View File
@@ -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}`),
};
+129
View File
@@ -144,3 +144,132 @@ export const smartDeviceApi = {
delete: (id: number) =>
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'),
};
+37 -1
View File
@@ -9,11 +9,33 @@ export interface JobParamField {
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 {
id: number;
jobKey: string;
jobName: string;
cronExpression: string;
freqType: FreqType;
freqConfig: FreqConfig;
cronDescription?: string | null;
params: Record<string, any>;
retryTimes: number;
retryInterval: number;
@@ -71,13 +93,23 @@ export interface JobLogQuery {
}
export interface UpdateJobPayload {
cronExpression: string;
freqType: FreqType;
freqConfig: FreqConfig;
params: Record<string, any>;
retryTimes: number;
retryInterval: number;
remark?: string;
}
/** 频率预览结果:由后端由频率配置推导 cron、中文描述与未来执行时间 */
export interface CronPreviewResult {
valid: boolean;
error?: string | null;
cronExpression?: string | null;
description?: string | null;
nextRuns: string[];
}
export interface JobListResult {
list: JobItem[];
total: number;
@@ -99,6 +131,10 @@ export const jobApi = {
updateJob: (id: number, data: UpdateJobPayload) =>
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) =>
request.put<JobItem>(`/jobs/${id}/status`, { status }),
+24 -40
View File
@@ -30,10 +30,6 @@ export const SURVEY_PRIORITY_OPTIONS = [
{ value: 3, label: '常规 · P3' }
];
// SOP 规范模板编码与名称
export const SOP_TEMPLATE_CODE = 'standard_dist';
export const SOP_TEMPLATE_LABEL = '标准配电改造模板';
// 报酬规则(后端 dispatch_price 未配置时的前端兜底展示值)
export const SURVEY_BASE_FEE = 350;
export const SURVEY_LOOP_FEE = 5;
@@ -61,35 +57,22 @@ export interface SurveyOrderWorker {
completedCount?: number;
}
export interface SurveySopItem {
id: number;
templateCode: string;
sortOrder: number;
itemName: string;
itemDesc?: string;
// 回执形式:photo 现场照片 / form 清单表单 / meter 测仪截图
receiptType: 'photo' | 'form' | 'meter' | string;
receiptRequire?: string;
required: boolean;
}
export interface SurveyOrderSopResult {
id: number;
surveyOrderId: number;
sopItemId: number;
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 SurveyOrderDetail {
id?: number;
surveyOrderId?: number;
buildingId?: number | null;
// 建筑名称(冗余存储,客户拓扑变更后仍可读)
buildingName?: string | null;
// 楼层:正数为地上、负数为地下,如 '1' / '-1'
floorNo?: string | null;
roomId?: number | null;
roomName?: string | null;
// 现场照片对象 key
photos?: string[];
// 详情接口附带:照片预签名访问 URL(与 photos 顺序一致)
photoUrls?: string[];
remark?: string | null;
}
export interface SurveyOrderInfo {
@@ -141,8 +124,8 @@ export interface SurveyOrderInfo {
updatedAt?: string;
created_at?: string;
updated_at?: string;
// 详情附带:SOP 回执
sopResults?: SurveyOrderSopResult[];
// 详情附带:勘察明细(建筑/楼层/房间 + 现场照片)
details?: SurveyOrderDetail[];
}
export interface SurveyOrderQuery {
@@ -209,7 +192,8 @@ export interface SurveyOrderCreateParams {
remark?: string;
source?: string;
isDraft?: number;
templateCode?: string;
// 勘察明细(创建/编辑时全量提交,新增/编辑/删除统一走此数组)
details?: SurveyOrderDetail[];
}
export type SurveyOrderUpdateParams = Partial<SurveyOrderCreateParams>;
@@ -247,10 +231,6 @@ export const surveyOrderApi = {
// KPI 统计卡
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) =>
request.get<SurveyQuote>('/survey-orders/quote', { params: { loopCount } }),
@@ -264,6 +244,10 @@ export const surveyOrderApi = {
update: (id: number, data: SurveyOrderUpdateParams) =>
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) =>
request.put<SurveyOrderInfo>(`/survey-orders/${id}/assign`, data),
+5
View File
@@ -43,6 +43,8 @@ export interface WorkerRecord {
auditBy?: number;
auditAt?: string;
lastLoginAt?: string;
// 是否已开通电工 APP 登录账号
hasAccount?: boolean;
certStatus?: WorkerCertStatus;
certs?: WorkerCertSummary[];
createdAt?: string;
@@ -119,6 +121,9 @@ export const workerRegistryApi = {
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),
updateCert: (id: number, certId: number, data: WorkerCertPayload) =>
+3 -3
View File
@@ -7,7 +7,7 @@
<!-- Logo区域 -->
<div class="logo" @click="$router.push('/dashboard')">
<ThunderboltOutlined class="logo-img" />
<h1 v-show="!collapsed" class="logo-title">智能电网IoT</h1>
<h1 v-show="!collapsed" class="logo-title">鑫潼智电</h1>
</div>
<!-- 菜单 -->
@@ -402,8 +402,8 @@ const NOTICE_TYPE_ROUTE: Record<string, string> = {
const NOTICE_BIZ_ROUTE: Record<string, string> = {
repair_dispatch: '/dispatch/repair',
maintenance_order: '/dispatch/maintenance',
install_order: '/project/install',
survey_order: '/project/survey',
install_order: '/dispatch/install',
survey_order: '/dispatch/survey',
};
function resolveNoticeRoute(item: NoticeInfo): string | null {
+68 -56
View File
@@ -88,39 +88,9 @@ const routes: RouteRecordRaw[] = [
{
path: "project",
name: "Project",
redirect: "/project/survey",
redirect: "/project/plan",
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",
name: "ProjectPlan",
@@ -145,18 +115,54 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/project/visit/index.vue"),
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",
name: "Dispatch",
redirect: "/dispatch/repair",
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",
name: "DispatchRepair",
@@ -169,24 +175,6 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/dispatch/maintenance/index.vue"),
meta: { requiresAuth: true, title: "维保派单", permission: "dispatch:maintenance" }
},
{
path: "dispatch/price",
name: "DispatchPrice",
component: () => import("@/views/dispatch/price/index.vue"),
meta: { requiresAuth: true, title: "运维费定价", permission: "dispatch:price" }
},
{
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",
name: "Device",
@@ -198,6 +186,18 @@ const routes: RouteRecordRaw[] = [
name: "DeviceEquipment",
component: () => import("@/views/device/equipment/index.vue"),
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: "base",
@@ -211,6 +211,18 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/device/smart-device/index.vue"),
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",
name: "Report",
@@ -274,7 +286,7 @@ const router = createRouter({
});
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 token = getToken();
+39 -1
View File
@@ -19,6 +19,10 @@
<p class="desc">统一维护客户的基础资料、联系信息与业务阶段,支持快速检索与档案管理。</p>
</div>
<div class="header-actions">
<a-button :loading="syncing" @click="handleSync">
<template #icon><SyncOutlined /></template>
同步客户
</a-button>
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增客户
@@ -323,13 +327,14 @@
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import { message, Modal } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
DeleteOutlined,
EditOutlined,
SyncOutlined,
TeamOutlined,
AimOutlined,
FileProtectOutlined,
@@ -352,6 +357,7 @@ const permissionStore = usePermissionStore();
// ==================== 数据 ====================
const loading = ref(false);
const syncing = ref(false);
const dataSource = ref<CustomerInfo[]>([]);
const total = ref(0);
const editNo = ref<string>('');
@@ -568,6 +574,38 @@ function getSalesmanName(id?: number) {
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() {
searchForm.page = 1;
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -798,7 +798,7 @@ function handleFollow(record: CustomerVisitInfo) {
// 一键发起勘察派单:跳转勘察派单页并预填客户(不创建、不回写状态)
function handleSurvey(record: CustomerVisitInfo) {
if (!record.customerId) return;
router.push({ path: '/project/survey', query: { customerId: String(record.customerId) } });
router.push({ path: '/dispatch/survey', query: { customerId: String(record.customerId) } });
}
// 查看关联工程
+24 -9
View File
@@ -70,7 +70,7 @@
<div>
<div class="card-title-row">
<h3 class="card-title">近7日业务增长趋势</h3>
<span class="card-tag">REALTIME 新增统计</span>
<span class="card-tag">实时新增统计</span>
</div>
<p class="card-sub">每日新增托管客户与新增合同的对比走势</p>
</div>
@@ -138,12 +138,12 @@
<div>
<div class="card-title-row">
<h3 class="card-title">待办勘察派单流水</h3>
<span class="live-alerts">LIVE ORDERS</span>
<span class="live-alerts">实时单量</span>
</div>
<p class="card-sub">最新勘察派单与承接状态的实时跟踪</p>
</div>
<button class="ghost-btn" @click="handleRefresh">
<span class="material-symbols-outlined">filter_list</span>
<ReloadOutlined />
刷新
</button>
</div>
@@ -184,8 +184,14 @@
</td>
<td class="ta-r">
<div class="ops">
<button class="icon-btn" title="催办"><PhoneOutlined /></button>
<a-dropdown trigger="click" placement="bottomRight">
<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>
</td>
</tr>
@@ -196,7 +202,7 @@
<div class="card-foot">
<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>
@@ -241,7 +247,7 @@
<div>
<div class="card-title-row">
<h3 class="card-title">电工资质到期提醒</h3>
<span class="card-tag">CERT ALERT</span>
<span class="card-tag">证照预警</span>
</div>
<p class="card-sub">临期(30 天内)与已过期的电工资质证照清单,已过期优先展示</p>
</div>
@@ -297,7 +303,6 @@ import {
ScheduleOutlined,
FieldTimeOutlined,
DeploymentUnitOutlined,
PhoneOutlined,
MoreOutlined,
UserOutlined,
RiseOutlined,
@@ -305,7 +310,7 @@ import {
} from '@ant-design/icons-vue';
import * as echarts from 'echarts';
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';
const router = useRouter();
@@ -515,6 +520,17 @@ function handleRefresh() {
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() {
if (trendChartRef.value) trendChart = echarts.init(trendChartRef.value);
if (donutChartRef.value) donutChart = echarts.init(donutChartRef.value);
@@ -905,6 +921,5 @@ function disposeCharts() {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.material-symbols-outlined { font-size: 16px; line-height: 1; }
}
</style>
+510
View File
@@ -0,0 +1,510 @@
<template>
<div class="device-gateway">
<!-- 顶部标题 -->
<div class="page-header">
<div class="breadcrumb">
<span>运营管控体系</span>
<span class="sep">/</span>
<span>设备监控</span>
<span class="sep">/</span>
<span class="cur">智电改造网关台账</span>
</div>
<div class="header-main">
<div class="header-left">
<div class="header-row">
<span class="title-bar"></span>
<span class="main-title">智电改造网关台账</span>
<span class="sub-title">ELECTRIC GATEWAY LEDGER</span>
</div>
<p class="desc">由 ICC 平台同步的智电改造网关台账,按客户、设备查看网关及在线状态。</p>
</div>
</div>
</div>
<!-- 主体卡片 -->
<div class="content-card">
<!-- 筛选区 -->
<div class="filter-card">
<div class="filter-grid">
<div class="filter-item">
<label class="filter-label">所属客户</label>
<a-select
v-model:value="searchForm.customerId"
placeholder="全部客户"
allow-clear
show-search
option-filter-prop="label"
:options="customerOptions"
/>
</div>
<div class="filter-item">
<label class="filter-label">在线状态</label>
<a-select v-model:value="searchForm.isOnline" placeholder="全部状态" allow-clear>
<a-select-option :value="1">在线</a-select-option>
<a-select-option :value="0">离线</a-select-option>
</a-select>
</div>
<div class="filter-item">
<label class="filter-label">关键词检索</label>
<a-input
v-model:value="searchForm.keyword"
placeholder="设备名称 / 设备编码 / IMEI"
allow-clear
@pressEnter="handleSearch"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</div>
</div>
<div class="filter-bottom">
<div class="filter-actions">
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</div>
</div>
</div>
<!-- 列表区 -->
<div class="list-section">
<div class="list-header">
<div class="list-title">
<span class="dot"></span>
<span class="t">网关台账清单</span>
<span class="count">{{ total }} 条</span>
</div>
<div class="list-actions">
<a-button type="primary" :loading="syncing" @click="handleSync">
<template #icon><SyncOutlined /></template>
同步网关
</a-button>
</div>
</div>
<div class="table-wrapper" ref="wrapperRef">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
:scroll="{ x: 'max-content', y: tableHeight }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'deviceCode'">
<span class="mono">{{ record.deviceCode || '-' }}</span>
</template>
<template v-else-if="column.key === 'deviceName'">
<span class="device-name">{{ record.deviceName || '-' }}</span>
</template>
<template v-else-if="column.key === 'iccImei'">
<span class="mono">{{ record.iccImei || '-' }}</span>
</template>
<template v-else-if="column.key === 'customerId'">
{{ record.customerName ? `${record.customerName}(${record.customerNo})` : '-' }}
</template>
<template v-else-if="column.key === 'manufacturerName'">
{{ record.manufacturerName || '-' }}
</template>
<template v-else-if="column.key === 'typeName'">
{{ record.typeName || '-' }}
</template>
<template v-else-if="column.key === 'productModel'">
{{ record.productModel || '-' }}
</template>
<template v-else-if="column.key === 'isOnline'">
<a-tag :color="record.isOnline === 1 ? 'success' : 'error'">
{{ record.isOnline === 1 ? '在线' : '离线' }}
</a-tag>
</template>
<template v-else-if="column.key === 'syncedAt'">
<span>{{ formatTime(record.syncedAt) }}</span>
</template>
</template>
<template #emptyText>
<a-empty description="暂无网关数据,可点击右上角「同步网关」从 ICC 拉取" />
</template>
</a-table>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import {
SearchOutlined,
ReloadOutlined,
SyncOutlined,
} from '@ant-design/icons-vue';
import {
electricGatewayApi,
type ElectricGatewayListItem,
type ElectricGatewayQuery,
} from '@/api/device';
import { customerApi, type CustomerInfo } from '@/api/customer';
import { useTableScroll } from '@/composables/useTableScroll';
const { wrapperRef, tableHeight } = useTableScroll();
const loading = ref(false);
const syncing = ref(false);
const dataSource = ref<ElectricGatewayListItem[]>([]);
const total = ref(0);
const searchForm = reactive<ElectricGatewayQuery>({
page: 1,
pageSize: 10,
keyword: '',
customerId: undefined as any,
isOnline: undefined as any,
});
const customerOptions = ref<{ label: string; value: number }[]>([]);
const columns = [
{ title: '设备名称', dataIndex: 'deviceName', key: 'deviceName', ellipsis: true },
{ title: '设备编码', dataIndex: 'deviceCode', key: 'deviceCode', ellipsis: true, width: 170 },
{ title: '设备 IMEI', dataIndex: 'iccImei', key: 'iccImei', ellipsis: true, width: 170 },
{ title: '客户名称', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
{ title: '厂商名称', dataIndex: 'manufacturerName', key: 'manufacturerName', ellipsis: true, width: 140 },
{ title: '主机类型', dataIndex: 'typeName', key: 'typeName', ellipsis: true, width: 120 },
{ title: '设备型号', dataIndex: 'productModel', key: 'productModel', ellipsis: true, width: 140 },
{ title: '在线状态', dataIndex: 'isOnline', key: 'isOnline', align: 'center' as const, width: 90 },
{ title: '同步时间', dataIndex: 'syncedAt', key: 'syncedAt', ellipsis: true, width: 170 },
];
const pagination = computed(() => ({
current: searchForm.page,
pageSize: searchForm.pageSize,
total: total.value,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (t: number) => `共 ${t} 条数据`,
}));
onMounted(() => {
fetchList();
fetchCustomerOptions();
});
async function fetchList() {
loading.value = true;
try {
const res: any = await electricGatewayApi.getList(searchForm);
if (res.code === 200) {
dataSource.value = res.data.list;
total.value = res.data.total;
}
} catch (error) {
message.error('获取网关列表失败');
} finally {
loading.value = false;
}
}
async function fetchCustomerOptions() {
try {
const res: any = await customerApi.getCustomerOptions();
if (res.code === 200) {
const list: CustomerInfo[] = res.data;
customerOptions.value = list.map((item) => ({
label: `${item.customerNo} ${item.customerName}`,
value: item.id,
}));
}
} catch (error) {
console.error('获取客户选项失败:', error);
}
}
function handleSearch() {
searchForm.page = 1;
fetchList();
}
function handleReset() {
searchForm.keyword = '';
searchForm.customerId = undefined as any;
searchForm.isOnline = undefined as any;
searchForm.page = 1;
fetchList();
}
function handleTableChange(pag: any) {
searchForm.page = pag.current;
searchForm.pageSize = pag.pageSize;
fetchList();
}
async function handleSync() {
syncing.value = true;
try {
const res: any = await electricGatewayApi.sync();
if (res.code === 200) {
const d = res.data || {};
message.success(`同步完成:拉取 ${d.pulled ?? 0} 条,新增 ${d.inserted ?? 0},更新 ${d.updated ?? 0}`);
fetchList();
} else {
message.error(res.message || '同步失败');
}
} catch (error) {
message.error('同步失败,请稍后重试');
} finally {
syncing.value = false;
}
}
function formatTime(val?: string) {
if (!val) return '-';
const d = new Date(val);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
</script>
<style scoped>
.device-gateway {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
padding: 20px 24px;
box-sizing: border-box;
}
.page-header {
flex-shrink: 0;
margin-bottom: 16px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #5b6b7e;
margin-bottom: 10px;
}
.breadcrumb .sep {
color: #c2c6d8;
}
.breadcrumb .cur {
color: #0054cd;
font-weight: 700;
}
.header-main {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.header-row {
display: flex;
align-items: baseline;
gap: 12px;
}
.title-bar {
width: 5px;
height: 22px;
background: #0054cd;
border-radius: 3px;
align-self: center;
}
.main-title {
font-size: 22px;
font-weight: 700;
color: #0d1c2c;
line-height: 1.4;
letter-spacing: -0.02em;
}
.sub-title {
font-size: 12px;
font-weight: 700;
color: #0054cd;
letter-spacing: 2px;
text-transform: uppercase;
}
.desc {
margin: 6px 0 0;
font-size: 13px;
color: #5b6b7e;
}
.content-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 16px;
background: #fff;
border: 1px solid #dbe4ee;
border-radius: 12px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
padding: 20px;
box-sizing: border-box;
overflow: hidden;
}
.filter-card {
flex-shrink: 0;
background: #f8faff;
border: 1px solid #dbe4ee;
border-radius: 10px;
padding: 16px;
box-sizing: border-box;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px 16px;
}
@media (max-width: 1200px) {
.filter-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.filter-grid {
grid-template-columns: 1fr;
}
}
.filter-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.filter-label {
font-size: 12px;
font-weight: 600;
color: #5b6b7e;
}
.filter-bottom {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #dbe4ee;
}
.filter-actions {
display: flex;
gap: 8px;
}
.list-section {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 4px 12px;
}
.list-title {
display: flex;
align-items: center;
gap: 10px;
}
.list-title .dot {
width: 4px;
height: 16px;
background: #0054cd;
border-radius: 2px;
}
.list-title .t {
font-size: 15px;
font-weight: 700;
color: #0d1c2c;
}
.list-title .count {
padding: 1px 8px;
border-radius: 6px;
background: #eef4ff;
color: #0054cd;
font-size: 12px;
font-weight: 600;
font-family: Consolas, Menlo, monospace;
}
.mono {
font-family: Consolas, Menlo, monospace;
font-size: 13px;
color: #0d1c2c;
}
.device-name {
font-weight: 600;
color: #0d1c2c;
}
.table-wrapper {
flex: 1;
min-height: 0;
overflow: hidden;
}
:deep(.ant-table) {
font-size: 14px;
height: 100%;
}
:deep(.ant-table-thead > tr > th) {
background: #f4f7ff;
color: #5b6b7e;
font-weight: 600;
}
:deep(.ant-btn-primary) {
background: #0054cd;
border-color: #0054cd;
}
:deep(.ant-btn-primary:not(:disabled):hover),
:deep(.ant-btn-primary:not(:disabled):focus) {
background: #136bfb;
border-color: #136bfb;
}
</style>
+520
View File
@@ -0,0 +1,520 @@
<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>
<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 {
electricLineApi,
type ElectricLine,
type ElectricLineQuery,
} 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<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 },
];
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;
}
}
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;
}
</style>
@@ -229,34 +229,135 @@
<div class="section-title">
<span class="bar bar-secondary"></span>
<FileDoneOutlined class="sec-icon clr-secondary" />
<h2>第 2 步:勘察技术要点与回执标准</h2>
<h2>第 2 步:勘察明细</h2>
</div>
<div v-if="!isReadonly" class="tmpl-box">
<span class="head-hint">SOP规范模板:</span>
<button type="button" class="tmpl-btn" @click="applySopTemplate">
<div v-if="!isReadonly" class="head-actions">
<span class="head-hint">按建筑 / 楼层 / 房间逐条登记,可附现场照片</span>
<a-button size="small" type="primary" ghost @click="addDetailRow">
<template #icon><PlusOutlined /></template>
添加明细
</a-button>
</div>
</div>
<div class="detail-body">
<!-- 编辑态:明细编辑行 -->
<template v-if="!isReadonly">
<div v-if="detailRows.length === 0" class="detail-empty">
暂无勘察明细,点击右上角「添加明细」逐条登记建筑、楼层与房间
</div>
<div v-else class="detail-rows">
<div v-for="(row, idx) in detailRows" :key="row.uid" class="detail-row">
<div class="detail-row-head">
<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 />
{{ SOP_TEMPLATE_LABEL }}(已应用 {{ sopItems.length }} 项)
<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>
</template>
<div class="sop-grid" :class="{ 'is-empty': !sopLoading && sopItems.length === 0 }">
<div v-for="item in sopItems" :key="item.id" class="sop-card">
<div class="sop-top">
<span class="sop-name">{{ item.sortOrder }}. {{ item.itemName }}</span>
<span v-if="item.required" class="sop-required">必填</span>
<!-- 只读态:明细展示 -->
<template v-else>
<div v-if="detailRows.length === 0" class="detail-empty">暂无勘察明细</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 class="sop-desc">{{ item.itemDesc || '—' }}</div>
<div class="sop-foot">
<span>{{ receiptTypeLabel(item.receiptType) }}</span>
<span>{{ item.receiptRequire || '—' }}</span>
<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>
<div v-if="!isReadonly" class="sop-add" @click="handleAddCustomItem">
<PlusOutlined class="sop-add-icon" />
<span>+ 自定义勘察检查项</span>
</div>
</template>
</div>
</section>
@@ -362,10 +463,6 @@
<span class="ro-k">测绘进度</span>
<span class="ro-v mono">{{ roProgressDone }} / {{ roProgressTotal }} 路</span>
</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">
<span class="ro-k">现场照片</span>
<span class="ro-v mono">{{ roPhotos.length }} 张</span>
@@ -387,25 +484,6 @@
</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-title">现场取证照片</div>
@@ -500,26 +578,27 @@ import {
CheckCircleFilled,
StarFilled,
PlusOutlined,
DeleteOutlined,
ApartmentOutlined,
FileDoneOutlined,
ToolOutlined,
} from '@ant-design/icons-vue';
import {
surveyOrderApi,
SOP_TEMPLATE_CODE,
SOP_TEMPLATE_LABEL,
SURVEY_STATUS,
SURVEY_PRIORITY,
SURVEY_PRIORITY_OPTIONS,
SURVEY_BASE_FEE,
SURVEY_LOOP_FEE,
type SurveySopItem,
type SurveyQuote,
type SurveyOrderCreateParams,
type SurveyOrderInfo,
type SurveyOrderDetail,
} from '@/api/surveyOrder';
import { workerApi, type WorkerRecommend } from '@/api/worker';
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 route = useRoute();
@@ -527,16 +606,10 @@ const route = useRoute();
// ==================== 步骤元数据 ====================
const STEPS = [
{ value: 1, name: '基本信息与现场环境', sub: '目标客户、区域与回路' },
{ value: 2, name: '技术要点与回执标准', sub: '标准SOP模版、拍照指标' },
{ value: 2, name: '勘察明细', sub: '建筑、楼层、房间与照片' },
{ value: 3, name: '指派电工与服务报酬', sub: '匹配电工、资质与费率' },
];
const RECEIPT_TYPE_LABEL: Record<string, string> = {
photo: '现场照片',
form: '清单格式',
meter: '手持测仪',
};
const SLA_OPTIONS = [
{ value: 'SLA-GRADE A (24h回传)', label: 'SLA-GRADE A (24h回传)' },
{ 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 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 roProgressTotal = computed(() => Number(detailRecord.value?.progressTotal) || 0);
const roProgressPercent = computed(() => {
@@ -625,13 +696,165 @@ const customerSelectOptions = computed(() =>
);
const selectedCustomer = computed(() => customerOptions.value.find((c) => c.id === formState.customerId) || null);
// ==================== SOP 检查项 ====================
const sopItems = ref<SurveySopItem[]>([]);
const sopLoading = ref(false);
// ==================== 客户空间拓扑 ====================
const topology = ref<CustomerTopologyResult | null>(null);
const topologyLoading = ref(false);
const buildingSelectOptions = computed(() =>
(topology.value?.buildings || []).map((b) => ({ label: b.buildingName, value: b.id }))
);
function receiptTypeLabel(type?: string) {
if (!type) return '—';
return RECEIPT_TYPE_LABEL[type] || type;
function buildingById(id?: number): TopologyBuilding | undefined {
if (!id) return undefined;
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() {
router.push({ name: 'ProjectSurvey' });
router.push({ name: 'DispatchSurvey' });
}
function handleCustomerChange() {
@@ -690,6 +913,9 @@ function handleCustomerChange() {
formState.techContact = `${customer.contactPerson}${customer.contactPhone ? ` - ${customer.contactPhone}` : ''}`;
}
}
// 客户变更后拓扑重建,清空已填明细避免残留失效的建筑/楼层
detailRows.value = [];
loadTopology();
loadWorkers();
}
@@ -698,18 +924,6 @@ function selectWorker(w: WorkerRecommend) {
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() {
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() {
const count = Number(formState.loopCount) || 0;
try {
@@ -802,6 +1002,17 @@ async function loadDetail() {
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 {
message.error(res.message || '获取勘察派单详情失败');
}
@@ -857,7 +1068,7 @@ function buildPayload(isDraft: number): SurveyOrderCreateParams {
remark: formState.remark.trim() || undefined,
source: 'ledger',
isDraft,
templateCode: SOP_TEMPLATE_CODE,
details: buildDetails(),
};
}
@@ -911,15 +1122,16 @@ async function handleSubmit(draft: boolean) {
}
onMounted(async () => {
// 客户拜访台账「一键发起勘察派单」跳转预填:/project/survey/create?customerId=xx
// 客户拜访台账「一键发起勘察派单」跳转预填:/dispatch/survey/create?customerId=xx
const preCustomerId = Number(route.query.customerId || 0);
if (preCustomerId && !isEdit.value) {
formState.customerId = preCustomerId;
}
await loadCustomers();
await Promise.all([loadSopItems(), loadQuote(), loadWorkers()]);
// 编辑态先取详情(含 customerId 与明细回填),再按客户加载空间拓扑
await loadDetail();
await Promise.all([loadTopology(), loadQuote(), loadWorkers()]);
});
</script>
@@ -1338,107 +1550,178 @@ onMounted(async () => {
background: #0054cd;
}
/* ---------- 第 2 步:SOP ---------- */
.tmpl-box {
/* ---------- 第 2 步:勘察明细 ---------- */
.head-actions {
display: flex;
align-items: center;
gap: 8px;
gap: 12px;
flex-shrink: 0;
}
.tmpl-btn {
display: inline-flex;
align-items: center;
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);
.detail-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px 20px 20px;
}
.sop-grid.is-empty {
grid-template-columns: 1fr;
.detail-empty {
padding: 24px;
text-align: center;
font-size: 13px;
color: #5b6b7e;
background: #eef4ff;
border-radius: 10px;
}
.sop-card {
.detail-rows {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
padding: 12px;
border: 1px solid rgba(219, 228, 238, 0.5);
border-radius: 8px;
background: #eef4ff;
gap: 12px;
}
.sop-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
.detail-row {
border: 1px solid #dbe4ee;
border-radius: 10px;
background: #f8fafc;
overflow: hidden;
}
.sop-name {
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 {
.detail-row-head {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 8px;
border-top: 1px solid rgba(219, 228, 238, 0.5);
font-family: 'JetBrains Mono', Consolas, monospace;
gap: 8px;
padding: 8px 14px;
background: #eef4ff;
border-bottom: 1px solid #e2eaf5;
}
.detail-row-idx {
font-size: 12px;
font-weight: 700;
color: #0054cd;
}
.sop-add {
min-height: 96px;
.detail-grid {
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;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
gap: 4px;
width: 76px;
height: 57px;
border: 1px dashed #c2c6d8;
border-radius: 8px;
border-radius: 6px;
background: #fff;
color: #5b6b7e;
font-size: 13px;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
}
.sop-add:hover {
.photo-add:hover:not(:disabled) {
border-color: rgba(0, 84, 205, 0.5);
color: #0054cd;
}
.sop-add-icon {
font-size: 22px;
.photo-add:disabled {
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 步:电工与报酬 ---------- */
@@ -1719,61 +2002,6 @@ onMounted(async () => {
background: #f8fafc;
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 {
display: flex;
flex-wrap: wrap;
@@ -1868,7 +2096,7 @@ onMounted(async () => {
.field-grid {
grid-template-columns: 1fr;
}
.sop-grid {
.detail-grid {
grid-template-columns: 1fr;
}
.cand-grid {
@@ -514,29 +514,31 @@
<div v-else class="detail-text">—</div>
</div>
<div v-if="detailRecord.sopResults?.length" class="detail-block">
<div class="detail-label">
勘察回执({{ detailSopDone }} / {{ detailRecord.sopResults.length }} 项已完成)
<div v-if="detailRecord.details?.length" class="detail-block">
<div class="detail-label">勘察明细({{ detailRecord.details.length }} 条)</div>
<div class="detail-list">
<div v-for="(row, idx) in detailRecord.details" :key="row.id ?? idx" class="detail-row">
<div class="detail-row-head">
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
<span class="detail-row-scope">
{{ row.buildingName || '—' }} · {{ floorLabel(row.floorNo) }}
<template v-if="row.roomName"> · {{ row.roomName }}</template>
</span>
</div>
<div v-for="row in detailRecord.sopResults" :key="row.id" class="sop-item">
<div class="sop-item-head">
<span class="sop-item-name">{{ row.itemName || row.sopItem?.itemName || '检查项' }}</span>
<span class="chip" :style="sopChipStyle(row.checked)">{{ row.checked ? '已回执' : '未回执' }}</span>
</div>
<div v-if="row.receiptUrls?.length" class="photo-list">
<div v-if="row.photoUrls?.length" class="photo-list">
<a-image-preview-group>
<a-image
v-for="(url, idx) in row.receiptUrls"
:key="idx"
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-else-if="row.receiptValue?.length" class="detail-text">
{{ row.receiptValue.join('、') }}
<div v-if="row.remark" class="detail-row-remark">备注:{{ row.remark }}</div>
</div>
<div v-if="row.remark" class="sop-item-remark">备注:{{ row.remark }}</div>
</div>
</div>
</div>
@@ -809,7 +811,7 @@ onMounted(() => {
fetchWorkerOptions();
const preCustomerId = Number(route.query.customerId || 0);
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() {
router.push({ name: 'ProjectSurveyCreate' });
router.push({ name: 'DispatchSurveyCreate' });
}
// ==================== 行内操作 ====================
@@ -1120,9 +1122,6 @@ async function handleCancelSubmit() {
// ==================== 详情 / 编辑 ====================
const detailVisible = ref(false);
const detailRecord = ref<SurveyOrderInfo | null>(null);
const detailSopDone = computed(
() => (detailRecord.value?.sopResults || []).filter((row) => row.checked).length
);
async function openDetail(record: SurveyOrderInfo) {
detailRecord.value = record;
@@ -1136,7 +1135,7 @@ async function openDetail(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}`;
}
const sopChipStyle = (checked?: boolean) =>
chipStyle(
checked
? { color: '#059669', bg: '#d1fae5', border: '#a7f3d0' }
: { color: '#94a3b8', bg: '#f8fafc', border: '#e2e8f0' }
);
// 楼层:正数为地上、负数为地下(value 为字符串,如 '1' / '-1')
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 money(value?: number | string) {
if (value == null || value === '') return '—';
@@ -1926,33 +1926,46 @@ function maskPhone(phone?: string) {
gap: 10px;
}
.sop-item {
padding: 10px 0;
border-bottom: 1px dashed #eef1f6;
.detail-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.sop-item:last-child {
border-bottom: none;
padding-bottom: 0;
.detail-row {
display: flex;
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;
align-items: center;
gap: 8px;
margin-bottom: 6px;
gap: 10px;
flex-wrap: wrap;
}
.sop-item-name {
.detail-row-idx {
font-size: 12px;
font-weight: 600;
color: #0054cd;
}
.detail-row-scope {
font-size: 13px;
font-weight: 600;
color: #0d1c2c;
}
.sop-item-remark {
margin-top: 6px;
.detail-row-remark {
font-size: 12px;
line-height: 1.7;
color: #8a97a8;
white-space: pre-wrap;
}
/* AntD 覆盖 */
+44 -3
View File
@@ -165,6 +165,19 @@
<template #icon><AuditOutlined /></template>
审核
</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
v-if="canDelete"
title="确定要删除该电工吗?"
@@ -240,8 +253,11 @@
<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-form-item>
<a-form-item v-if="!isEdit" label="APP 登录账号">
<div class="switch-line">
<a-form-item label="APP 登录账号">
<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" />
<span class="form-tip">开启后同时创建电工 APP 登录密码,默认密码为手机号后 6 位</span>
</div>
@@ -381,6 +397,7 @@ import {
DeleteOutlined,
EditOutlined,
AuditOutlined,
KeyOutlined,
TeamOutlined,
ClockCircleOutlined,
WarningOutlined,
@@ -425,6 +442,7 @@ const canCreate = computed(() => permissionStore.hasPermission('worker:create'))
const canUpdate = computed(() => permissionStore.hasPermission('worker:update'));
const canDelete = computed(() => permissionStore.hasPermission('worker:delete'));
const canAudit = computed(() => permissionStore.hasPermission('worker:audit'));
const canResetPassword = computed(() => permissionStore.hasPermission('worker:reset-password'));
// ==================== 列表数据 ====================
const loading = ref(false);
@@ -501,7 +519,7 @@ const columns = [
{ title: '在岗状态', key: 'status', align: 'center' as const, width: 110 },
{ title: '评分', key: 'rating', 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(() => ({
@@ -529,6 +547,8 @@ const formData = reactive({
status: 1,
rating: 5,
withAccount: false,
// 编辑时用于判断该电工是否已开通 APP 账号
hasAccount: false,
});
const formRules = {
@@ -664,6 +684,8 @@ async function handleEdit(record: WorkerRecord) {
formData.workerTitle = record.workerTitle || '';
formData.status = record.status;
formData.rating = Number(record.rating ?? 5);
formData.hasAccount = record.hasAccount === true;
formData.withAccount = false;
modalVisible.value = true;
await loadCertRows(record.id);
@@ -674,6 +696,7 @@ async function loadCertRows(workerId: number) {
try {
const res: any = await workerRegistryApi.getDetail(workerId);
if (res.code === 200 && res.data) {
formData.hasAccount = res.data.hasAccount === true;
const certs: WorkerCert[] = res.data.certs || [];
certRows.value = certs.map((c) => ({
_key: certKeySeed++,
@@ -700,6 +723,7 @@ function resetForm() {
formData.status = 1;
formData.rating = 5;
formData.withAccount = false;
formData.hasAccount = false;
certRows.value = [];
if (formRef.value) {
@@ -822,6 +846,8 @@ async function handleSubmit() {
let res: any;
if (isEdit.value && editId.value) {
payload.status = formData.status;
// 补开 APP 账号:仅对尚未开通的电工生效
payload.withAccount = formData.withAccount;
res = await workerRegistryApi.update(editId.value, payload);
} else {
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) {
auditRecord.value = record;
+3 -3
View File
@@ -11,8 +11,8 @@
<div class="logo-icon">
<ThunderboltOutlined />
</div>
<h1 class="title">智能电网设备管理系统</h1>
<p class="subtitle">Smart Grid IoT Management System</p>
<h1 class="title">鑫潼智电管理系统</h1>
<p class="subtitle">XINTONG SMART POWER MANAGEMENT SYSTEM</p>
</div>
<a-divider />
@@ -59,7 +59,7 @@
</div>
<div class="copyright">
<p>&copy; 2026 智能电网设备管理系统 All Rights Reserved</p>
<p>&copy; 2026 鑫潼智电管理系统 All Rights Reserved</p>
</div>
</div>
</template>
+360 -14
View File
@@ -53,6 +53,12 @@
<span>{{ record.jobName }}</span>
<a-tag v-if="record.registered === false" color="red" style="margin-left: 6px">未注册</a-tag>
</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'">
<a-tooltip :title="formatParams(record.params, true)">
<span class="ellipsis-text">{{ formatParams(record.params) }}</span>
@@ -111,7 +117,7 @@
title="编辑任务"
:confirm-loading="saving"
:mask-closable="false"
width="640px"
width="720px"
@ok="handleSave"
@cancel="handleCancelEdit"
>
@@ -125,9 +131,110 @@
<a-form-item label="任务名称">
<span>{{ currentJob?.jobName || '-' }}</span>
</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 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
v-for="field in paramFields"
:key="field.key"
@@ -149,6 +256,9 @@
:placeholder="field.placeholder || `请输入${field.label}`"
/>
</a-form-item>
<a-divider orientation="left" class="form-divider">失败重试与备注</a-divider>
<a-form-item label="重试次数" name="retryTimes">
<a-input-number v-model:value="formData.retryTimes" :min="0" :max="5" style="width: 100%" />
</a-form-item>
@@ -228,7 +338,7 @@
</template>
<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 {
SearchOutlined,
@@ -237,9 +347,18 @@ import {
PlayCircleOutlined,
ProfileOutlined,
DeleteOutlined,
PlusOutlined,
MinusCircleOutlined,
} from '@ant-design/icons-vue'
import dayjs from 'dayjs'
import { jobApi, type JobItem, type JobParamField, type JobLogItem } from '@/api/job'
import dayjs, { type Dayjs } from 'dayjs'
import {
jobApi,
type JobItem,
type JobParamField,
type JobLogItem,
type FreqType,
type FreqConfig,
} from '@/api/job'
import { useTableScroll } from '@/composables/useTableScroll'
const { wrapperRef, tableHeight } = useTableScroll()
@@ -260,7 +379,7 @@ const queryParams = reactive({
const columns = [
{ title: '任务名称', dataIndex: 'jobName', key: 'jobName', width: 180 },
{ 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: '状态', key: 'status', width: 100, align: 'center' as const },
{ title: '上次执行时间', dataIndex: 'lastRunAt', key: 'lastRunAt', width: 170 },
@@ -286,24 +405,174 @@ const currentJob = ref<JobItem | null>(null)
const paramFields = ref<JobParamField[]>([])
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({
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>,
retryTimes: 0,
retryInterval: 60,
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 = {
cronExpression: [
{ required: true, message: '请输入 cron 表达式', trigger: 'blur' },
{ max: 64, message: 'cron 表达式长度不能超过 64', trigger: 'blur' },
],
retryTimes: [{ 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' }],
}
/** 表单 → 频率配置(提交给后端的结构) */
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 logLoading = ref(false)
const logList = ref<JobLogItem[]>([])
@@ -441,13 +710,16 @@ async function handleStatusChange(record: JobItem, checked: boolean) {
function handleEdit(record: JobItem) {
currentJob.value = record
paramFields.value = record.paramFields || []
formData.cronExpression = record.cronExpression || ''
applyFreqConfig(record)
formData.params = { ...(record.params || {}) }
formData.retryTimes = record.retryTimes ?? 0
formData.retryInterval = record.retryInterval ?? 60
formData.remark = record.remark || ''
resetPreview()
previewSignature = ''
editVisible.value = true
nextTick(() => formRef.value?.clearValidate())
fetchPreview()
}
function handleCancelEdit() {
@@ -462,10 +734,22 @@ async function handleSave() {
return
}
const freqError = validateFreqConfig()
if (freqError) {
message.warning(freqError)
return
}
if (!preview.valid) {
message.warning(preview.error || '频率配置不合法')
return
}
saving.value = true
try {
const res: any = await jobApi.updateJob(currentJob.value.id, {
cronExpression: formData.cronExpression,
freqType: formData.freqType,
freqConfig: buildFreqConfig(),
params: { ...formData.params },
retryTimes: formData.retryTimes,
retryInterval: formData.retryInterval,
@@ -572,6 +856,10 @@ function handleClearLogs() {
onMounted(() => {
fetchJobList()
})
onBeforeUnmount(() => {
clearTimeout(previewTimer)
})
</script>
<style scoped lang="less">
@@ -635,6 +923,17 @@ onMounted(() => {
margin-bottom: 16px;
}
.freq-cell {
display: flex;
flex-direction: column;
line-height: 20px;
.freq-cron {
font-size: 12px;
color: #8c8c8c;
}
}
.error-stack {
margin: 0;
padding: 8px 12px;
@@ -646,4 +945,51 @@ onMounted(() => {
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>
+1
View File
@@ -66,6 +66,7 @@ export default defineConfig({
}
},
server: {
host: "0.0.0.0",
port: 3000,
proxy: {
"/api": {
+43 -266
View File
@@ -41,7 +41,7 @@
resolved "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz"
integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==
"@babel/core@^7.23.0":
"@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.23.0":
version "7.29.7"
resolved "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz"
integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==
@@ -275,6 +275,14 @@
"@babel/types" "^7.29.7"
debug "^4.3.1"
"@babel/types@^7.28.2", "@babel/types@^7.29.7":
version "7.29.7"
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz"
integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==
dependencies:
"@babel/helper-string-parser" "^7.29.7"
"@babel/helper-validator-identifier" "^7.29.7"
"@babel/types@7.23.0":
version "7.23.0"
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.23.0.tgz"
@@ -284,14 +292,6 @@
"@babel/helper-validator-identifier" "^7.22.20"
to-fast-properties "^2.0.0"
"@babel/types@^7.28.2", "@babel/types@^7.29.7":
version "7.29.7"
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz"
integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==
dependencies:
"@babel/helper-string-parser" "^7.29.7"
"@babel/helper-validator-identifier" "^7.29.7"
"@ctrl/tinycolor@^3.4.0", "@ctrl/tinycolor@^3.5.0":
version "3.6.1"
resolved "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz"
@@ -307,116 +307,6 @@
resolved "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.8.1.tgz"
integrity sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==
"@esbuild/aix-ppc64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f"
integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==
"@esbuild/android-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052"
integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==
"@esbuild/android-arm@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28"
integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==
"@esbuild/android-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e"
integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==
"@esbuild/darwin-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a"
integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==
"@esbuild/darwin-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22"
integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==
"@esbuild/freebsd-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e"
integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==
"@esbuild/freebsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261"
integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==
"@esbuild/linux-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b"
integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==
"@esbuild/linux-arm@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9"
integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==
"@esbuild/linux-ia32@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2"
integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==
"@esbuild/linux-loong64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df"
integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==
"@esbuild/linux-mips64el@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe"
integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==
"@esbuild/linux-ppc64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4"
integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==
"@esbuild/linux-riscv64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc"
integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==
"@esbuild/linux-s390x@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de"
integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==
"@esbuild/linux-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0"
integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==
"@esbuild/netbsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047"
integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==
"@esbuild/openbsd-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70"
integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==
"@esbuild/sunos-x64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b"
integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==
"@esbuild/win32-arm64@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d"
integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==
"@esbuild/win32-ia32@0.21.5":
version "0.21.5"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b"
integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==
"@esbuild/win32-x64@0.21.5":
version "0.21.5"
resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz"
@@ -515,7 +405,7 @@
"@nodelib/fs.stat" "2.0.5"
run-parallel "^1.1.9"
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
version "2.0.5"
resolved "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -528,121 +418,6 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
"@rollup/rollup-android-arm-eabi@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz#3a04f01e9f01392bbef5920b94aa3b88794be7ab"
integrity sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==
"@rollup/rollup-android-arm64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz#e371b653ceabc900790ae73f5548a0fd7cd63a70"
integrity sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==
"@rollup/rollup-darwin-arm64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz#2a5aa70432e39816d666d79287a7324cfc3b4e72"
integrity sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==
"@rollup/rollup-darwin-x64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz#c3b5b49629379cd9cdc5d841bf00ed44ebf393dd"
integrity sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==
"@rollup/rollup-freebsd-arm64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz#f929d8e0462fae6602fc960beeabd7287d859283"
integrity sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==
"@rollup/rollup-freebsd-x64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz#c01cb58031226f95d0900b1ec847f4fb32c6e809"
integrity sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==
"@rollup/rollup-linux-arm-gnueabihf@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz#f29d890c4858c8e0d3be01677eef4f6a359eed9d"
integrity sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==
"@rollup/rollup-linux-arm-musleabihf@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz#1ebfc8eb9f66136ed2faae5f44995add5ca3c964"
integrity sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==
"@rollup/rollup-linux-arm64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz#c1fa823c2c4ce46ba7f61de1a4c3fdadd4fb4e7b"
integrity sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==
"@rollup/rollup-linux-arm64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz#a7f18854d0471b78bda8ea38f0891a4e059b571d"
integrity sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==
"@rollup/rollup-linux-loong64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz#83658a9a4576bcce8cef85b2c78b9b649d2200c4"
integrity sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==
"@rollup/rollup-linux-loong64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz#fd2af677ae3417bb58d57ae37dd0d84686e40244"
integrity sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==
"@rollup/rollup-linux-ppc64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz#6481647181c4cf8f1ddbd99f62c84cfc56c1a94a"
integrity sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==
"@rollup/rollup-linux-ppc64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz#18610a1a1550e28a5042ca916f898419540f17f4"
integrity sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==
"@rollup/rollup-linux-riscv64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz#597bb80465a2621dbe0de0a41c66394a8a7e9a6e"
integrity sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==
"@rollup/rollup-linux-riscv64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz#a2a919a9f927ef7f24a60af77e3cb55f1ad59e4d"
integrity sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==
"@rollup/rollup-linux-s390x-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz#3166f6ceae7df9bbfddf9f36be1937231e13e3c6"
integrity sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==
"@rollup/rollup-linux-x64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz#23c9bf79771d804fb87415eb0767569f273261e5"
integrity sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==
"@rollup/rollup-linux-x64-musl@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz#97941c6b94d67fe25cde0f027c10a19f2d1fdd39"
integrity sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==
"@rollup/rollup-openbsd-x64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz#7aeb7d92e2cd1d399f56daf75c39040b777b6c77"
integrity sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==
"@rollup/rollup-openharmony-arm64@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz#925de61ae83bf99aa636e8acea87432e8c0ffaab"
integrity sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==
"@rollup/rollup-win32-arm64-msvc@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz#888ab83842721491044c46a7407e1f38f3235bb4"
integrity sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==
"@rollup/rollup-win32-ia32-msvc@4.60.4":
version "4.60.4"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz#fa30ac24e3f0232139d2a47500560a28695764d4"
integrity sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==
"@rollup/rollup-win32-x64-gnu@4.60.4":
version "4.60.4"
resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz"
@@ -666,7 +441,7 @@
resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
"@types/node@^22.15.30":
"@types/node@^18.0.0 || >=20.0.0", "@types/node@^22.15.30":
version "22.19.19"
resolved "https://registry.npmmirror.com/@types/node/-/node-22.19.19.tgz"
integrity sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==
@@ -688,7 +463,7 @@
natural-compare "^1.4.0"
ts-api-utils "^1.3.0"
"@typescript-eslint/parser@^7.0.1":
"@typescript-eslint/parser@^7.0.0", "@typescript-eslint/parser@^7.0.1":
version "7.18.0"
resolved "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-7.18.0.tgz"
integrity sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==
@@ -764,14 +539,14 @@
resolved "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz"
integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==
"@volar/language-core@1.11.1", "@volar/language-core@~1.11.1":
"@volar/language-core@~1.11.1", "@volar/language-core@1.11.1":
version "1.11.1"
resolved "https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz"
integrity sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==
dependencies:
"@volar/source-map" "1.11.1"
"@volar/source-map@1.11.1", "@volar/source-map@~1.11.1":
"@volar/source-map@~1.11.1", "@volar/source-map@1.11.1":
version "1.11.1"
resolved "https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz"
integrity sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==
@@ -828,7 +603,7 @@
estree-walker "^2.0.2"
source-map-js "^1.2.1"
"@vue/compiler-dom@3.5.34", "@vue/compiler-dom@^3.3.0", "@vue/compiler-dom@^3.3.4":
"@vue/compiler-dom@^3.3.0", "@vue/compiler-dom@^3.3.4", "@vue/compiler-dom@3.5.34":
version "3.5.34"
resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz"
integrity sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==
@@ -836,7 +611,7 @@
"@vue/compiler-core" "3.5.34"
"@vue/shared" "3.5.34"
"@vue/compiler-sfc@3.5.34", "@vue/compiler-sfc@^3.5.18":
"@vue/compiler-sfc@^3.5.18", "@vue/compiler-sfc@3.5.34":
version "3.5.34"
resolved "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz"
integrity sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==
@@ -921,7 +696,7 @@
"@vue/reactivity" "3.5.34"
"@vue/shared" "3.5.34"
"@vue/runtime-dom@3.5.34", "@vue/runtime-dom@^3.4.15":
"@vue/runtime-dom@^3.4.15", "@vue/runtime-dom@3.5.34":
version "3.5.34"
resolved "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz"
integrity sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==
@@ -939,7 +714,7 @@
"@vue/compiler-ssr" "3.5.34"
"@vue/shared" "3.5.34"
"@vue/shared@3.5.34", "@vue/shared@^3.3.0", "@vue/shared@^3.5.18":
"@vue/shared@^3.3.0", "@vue/shared@^3.5.18", "@vue/shared@3.5.34":
version "3.5.34"
resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz"
integrity sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==
@@ -954,7 +729,7 @@ acorn-jsx@^5.3.2:
resolved "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn@^8.14.0, acorn@^8.9.0:
"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.14.0, acorn@^8.9.0:
version "8.16.0"
resolved "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz"
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
@@ -1127,7 +902,7 @@ braces@^3.0.3, braces@~3.0.2:
dependencies:
fill-range "^7.1.1"
browserslist@^4.24.0, browserslist@^4.28.2:
browserslist@^4.24.0, browserslist@^4.28.2, "browserslist@>= 4.21.0":
version "4.28.2"
resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz"
integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==
@@ -1281,7 +1056,7 @@ de-indent@^1.0.2:
resolved "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz"
integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==
debug@4, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4:
debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@4:
version "4.4.3"
resolved "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
@@ -1341,7 +1116,7 @@ dunder-proto@^1.0.1:
es-errors "^1.3.0"
gopd "^1.2.0"
echarts@^6.1.0:
echarts@^6.0.0, echarts@^6.1.0:
version "6.1.0"
resolved "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz"
integrity sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==
@@ -1459,7 +1234,7 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4
resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz"
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
eslint@^8.56.0:
"eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", eslint@^8.56.0, eslint@>=6.0.0:
version "8.57.1"
resolved "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz"
integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==
@@ -1641,11 +1416,6 @@ fs.realpath@^1.0.0:
resolved "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2:
version "1.1.2"
resolved "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz"
@@ -1881,7 +1651,7 @@ isexe@^2.0.0:
resolved "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
jiti@^1.21.7:
jiti@^1.21.7, jiti@>=1.21.0:
version "1.21.7"
resolved "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz"
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
@@ -1935,7 +1705,7 @@ kolorist@^1.8.0:
resolved "https://registry.npmmirror.com/kolorist/-/kolorist-1.8.0.tgz"
integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==
less@^4.6.4:
less@*, less@^4.6.4:
version "4.6.4"
resolved "https://registry.npmmirror.com/less/-/less-4.6.4.tgz"
integrity sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==
@@ -2067,7 +1837,14 @@ minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
dependencies:
brace-expansion "^1.1.7"
minimatch@^9.0.3, minimatch@^9.0.4:
minimatch@^9.0.3:
version "9.0.9"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz"
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
dependencies:
brace-expansion "^2.0.2"
minimatch@^9.0.4:
version "9.0.9"
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz"
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
@@ -2238,7 +2015,7 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz"
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
picomatch@^4.0.4:
"picomatch@^3 || ^4", picomatch@^4.0.4:
version "4.0.4"
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
@@ -2308,7 +2085,7 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
resolved "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.4.35, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.5.14:
postcss@^8.0.0, postcss@^8.1.0, postcss@^8.2.14, postcss@^8.4.21, postcss@^8.4.35, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.5.14, postcss@>=8.0.9:
version "8.5.15"
resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz"
integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==
@@ -2639,16 +2416,16 @@ ts-interface-checker@^0.1.9:
resolved "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz"
integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
tslib@2.3.0:
version "2.3.0"
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
tslib@^2.8.1:
version "2.8.1"
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
tslib@2.3.0:
version "2.3.0"
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz"
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz"
@@ -2661,7 +2438,7 @@ type-fest@^0.20.2:
resolved "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz"
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
typescript@~5.3.3:
typescript@*, typescript@>=4.2.0, typescript@>=4.5.0, typescript@~5.3.3, typescript@5.x:
version "5.3.3"
resolved "https://registry.npmmirror.com/typescript/-/typescript-5.3.3.tgz"
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
@@ -2731,7 +2508,7 @@ vite-plugin-vue-dev-locator@1.0.3:
kolorist "^1.8.0"
magic-string "^0.30.4"
vite@^5.0.12:
"vite@^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0", "vite@^5.0.0 || ^6.0.0", vite@^5.0.12, vite@>=4.0.0:
version "5.4.21"
resolved "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz"
integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==
@@ -2791,7 +2568,7 @@ vue-types@^3.0.0:
dependencies:
is-plain-object "3.0.1"
vue@^3.4.15:
vue@^3.0.0, vue@^3.2.25, vue@^3.3.0, vue@^3.4.0, vue@^3.4.15, vue@^3.5.0, vue@^3.5.11, vue@>=3.0.1, vue@>=3.0.3, vue@>=3.2.0, vue@3.5.34:
version "3.5.34"
resolved "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz"
integrity sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==
+98 -62
View File
@@ -191,6 +191,7 @@ DROP TABLE IF EXISTS customer CASCADE;
CREATE TABLE customer (
id BIGSERIAL PRIMARY KEY,
customer_no VARCHAR(64) NOT NULL UNIQUE,
icc_code VARCHAR(64),
customer_name VARCHAR(128) NOT NULL,
province VARCHAR(64),
city VARCHAR(64),
@@ -209,6 +210,7 @@ CREATE TABLE customer (
COMMENT ON TABLE customer IS '客户信息表';
COMMENT ON COLUMN customer.id IS '自增主键';
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.province 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_status ON customer (status);
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
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_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. 客户设备信息表(配电间、配电井、配电箱)
DROP TABLE IF EXISTS customer_equipment CASCADE;
CREATE TABLE customer_equipment (
@@ -572,33 +616,45 @@ DROP TABLE IF EXISTS electric_line CASCADE;
CREATE TABLE electric_line (
id BIGSERIAL PRIMARY KEY,
line_code VARCHAR(64) NOT NULL UNIQUE,
equip_id BIGINT NOT NULL REFERENCES customer_equipment(id),
icc_device_code VARCHAR(128),
line_name VARCHAR(128),
equip_id BIGINT,
gateway_id BIGINT,
customer_id BIGINT NOT NULL REFERENCES customer(id),
building_id BIGINT REFERENCES customer_building(id),
floor_no VARCHAR(16),
location VARCHAR(256),
line_name VARCHAR(128),
imei VARCHAR(64),
type_code VARCHAR(32),
type_name VARCHAR(64),
fire_system_code VARCHAR(64),
fire_system_name VARCHAR(128),
part_unit_loop_code VARCHAR(64),
channel_no VARCHAR(64),
channel_sn VARCHAR(64),
produce_date VARCHAR(16),
status SMALLINT NOT NULL DEFAULT 1,
synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE electric_line IS '智电改造线路信息表(ICC 同步)';
COMMENT ON COLUMN electric_line.id IS '自增主键';
COMMENT ON COLUMN electric_line.line_code IS '线路编码(唯一)';
COMMENT ON COLUMN electric_line.equip_id IS '客户设备 ID(→ customer_equipment)';
COMMENT ON COLUMN electric_line.icc_device_code IS 'ICC 智电设备编号';
COMMENT ON COLUMN electric_line.line_name IS '线路名称(ICC 同步)';
COMMENT ON COLUMN electric_line.customer_id IS '客户 ID(逻辑隔离键)';
COMMENT ON COLUMN electric_line.building_id IS '所属建筑 ID(→ customer_building)';
COMMENT ON COLUMN electric_line.floor_no IS '所在楼层';
COMMENT ON COLUMN electric_line.location IS '所在位置';
COMMENT ON COLUMN electric_line.status IS '状态:1在线 0离线';
COMMENT ON TABLE electric_line IS '智电改造线路信息表(ICC 探测器同步)';
COMMENT ON COLUMN electric_line.line_code IS '探测器编码(ICC detectorCode,唯一幂等键)';
COMMENT ON COLUMN electric_line.line_name IS '探测器名称(ICC detectorName)';
COMMENT ON COLUMN electric_line.imei IS '设备唯一标识码(ICC imei)';
COMMENT ON COLUMN electric_line.equip_id IS '客户设备 ID(→ customer_equipment,可空,同步不填充)';
COMMENT ON COLUMN electric_line.gateway_id IS '网关设备 ID(→ electric_gateway,外键由迁移/db_seed 建立)';
COMMENT ON COLUMN electric_line.customer_id IS '客户 ID(orgCode→customer.icc_code 解析,逻辑隔离键)';
COMMENT ON COLUMN electric_line.type_code IS '探测器类型编码';
COMMENT ON COLUMN electric_line.type_name IS '探测器类型名称';
COMMENT ON COLUMN electric_line.fire_system_code IS '消防系统编码';
COMMENT ON COLUMN electric_line.fire_system_name IS '消防系统名称';
COMMENT ON COLUMN electric_line.part_unit_loop_code IS '探测器回路号';
COMMENT ON COLUMN electric_line.channel_no IS '探测器通道号';
COMMENT ON COLUMN electric_line.channel_sn IS '通道唯一标识码';
COMMENT ON COLUMN electric_line.produce_date IS '生产日期(yyyy-MM-dd)';
COMMENT ON COLUMN electric_line.status IS '探测器状态:1在线 0离线(detectorStatus 01→1 / 02→0)';
COMMENT ON COLUMN electric_line.synced_at IS 'ICC 同步时间';
COMMENT ON COLUMN electric_line.created_at IS '创建时间';
CREATE INDEX idx_line_customer ON electric_line (customer_id);
CREATE INDEX idx_line_equip ON electric_line (equip_id);
CREATE INDEX idx_line_icc ON electric_line (icc_device_code);
CREATE INDEX idx_line_gateway ON electric_line (gateway_id);
CREATE INDEX idx_line_imei ON electric_line (imei);
-- 20. 线路所属房间关联表
DROP TABLE IF EXISTS line_room CASCADE;
@@ -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
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
-- 21.1 勘察 SOP 模板检查项表
DROP TABLE IF EXISTS survey_sop_item CASCADE;
CREATE TABLE survey_sop_item (
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 (
-- 21.1 勘察工单明细表(建筑/楼层/房间 + 现场照片)
DROP TABLE IF EXISTS survey_detail CASCADE;
CREATE TABLE survey_detail (
id BIGSERIAL PRIMARY KEY,
survey_order_id BIGINT NOT NULL REFERENCES survey_order(id) ON DELETE CASCADE,
sop_item_id BIGINT NOT NULL REFERENCES survey_sop_item(id),
checked BOOLEAN NOT NULL DEFAULT FALSE,
receipt_value VARCHAR(1024),
building_id BIGINT,
building_name VARCHAR(128),
floor_no VARCHAR(16),
room_id BIGINT,
room_name VARCHAR(128),
photos TEXT,
remark VARCHAR(255),
checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE survey_sop_result IS '勘察 SOP 工单回执表';
COMMENT ON COLUMN survey_sop_result.id IS '自增主键';
COMMENT ON COLUMN survey_sop_result.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_sop_result.checked IS '是否已完成回执';
COMMENT ON COLUMN survey_sop_result.receipt_value IS '回执内容(照片对象key数组/表单值/截图key)';
COMMENT ON COLUMN survey_sop_result.remark IS '回执备注';
COMMENT ON COLUMN survey_sop_result.checked_at IS '回执时间';
COMMENT ON COLUMN survey_sop_result.created_at IS '创建时间';
COMMENT ON COLUMN survey_sop_result.updated_at IS '更新时间';
CREATE UNIQUE INDEX uk_sop_result_order_item ON survey_sop_result (survey_order_id, sop_item_id);
CREATE INDEX idx_sop_result_order ON survey_sop_result (survey_order_id);
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 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. 安装派单表
DROP TABLE IF EXISTS install_order CASCADE;
+160 -36
View File
@@ -16,7 +16,7 @@
-- - 显式重置序列,确保 id 不漂移
--
-- 内容:
-- 1. 初始角色(ADMIN / SALESMAN / WORKER / CUSTOMER)
-- 1. 初始角色(ADMIN / SALESMAN)
-- 2. 初始菜单(顶级 6 + 系统管理子菜单 6 + 运营派单子菜单 13 等,共 50 条)
-- 3. 初始字典(客户/设备/派单/支付等业务字典)
-- 4. 管理员账户(admin / admin123456)
@@ -26,21 +26,24 @@
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
(1, 'ADMIN', '运营管理员', '全部菜单权限,管理所有客户', 1),
(2, 'SALESMAN', '业务员', '客户/项目/方案/合同/回访', 2),
(3, 'WORKER', '共享电工', 'APP 接单处理,无后台菜单', 3),
(4, 'CUSTOMER', '客户', '小程序查看用电/报警/报修', 4)
(2, 'SALESMAN', '业务员', '客户/项目/方案/合同/回访', 2)
ON CONFLICT (id) DO UPDATE SET
role_code = EXCLUDED.role_code,
role_name = EXCLUDED.role_name,
description = EXCLUDED.description,
sort_order = EXCLUDED.sort_order;
-- 清理历史遗留的 APP 端角色(电工/客户不参与后台角色体系)
DELETE FROM sys_role WHERE role_code IN ('WORKER', 'CUSTOMER');
-- 重置 sys_role 自增序列
SELECT setval('sys_role_id_seq', 4, true);
SELECT setval('sys_role_id_seq', 2, true);
-- ------------------------------------------------------------
-- 2. 初始菜单(顶级菜单,显式指定 id:1-6,系统管理/运营派单为目录类型 menu_type=1,其余为 menu_type=2)
@@ -106,18 +109,19 @@ ON CONFLICT (id) DO UPDATE SET
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- 初始菜单(项目域子菜单 + 客户扩展子菜单,id:24-31 + 空间拓扑 32 + 安装派单 33)
-- 项目域:客户拜访(customer:visit) / 项目勘察(project:survey) / 项目方案(project:plan)
-- 合同管理(project:contract) / 安装派单(project:install)
-- 初始菜单(项目域子菜单 + 客户扩展子菜单,id:24-31 + 空间拓扑 32)
-- 项目域:客户拜访(customer:visit) / 项目方案(project:plan)
-- 合同管理(project:contract)
-- 项目验收(project:acceptance) / 回访记录(project:visit)
-- 客户域:空间拓扑与用电架构(customer:topology)
-- 注:客户拜访归属“项目管理”,但仍沿用 /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
(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),
(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),
(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)
@@ -132,11 +136,13 @@ ON CONFLICT (id) DO UPDATE SET
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- 初始菜单(运营派单子菜单,parent_id=3,id:55-56)
-- 初始菜单(运营派单子菜单,parent_id=3)
-- 运营派单域统一 id 分配(避免历史上 36/37 被两段 INSERT 复用而互相覆盖):
-- 34 运维费定价 / 35 勘察费用(归属项目管理 parent_id=2) / 36 电工登记管理
-- 27 项目勘察 / 33 项目安装(自项目管理 parent_id=2 迁入)
-- 34 运维费定价 / 35 勘察费用(均归属基础数据 parent_id=59) / 36 电工登记管理
-- 37-46 按钮权限
-- 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
(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)
@@ -151,21 +157,22 @@ ON CONFLICT (id) DO UPDATE SET
permission = EXCLUDED.permission,
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
(34, 3, '运维费定价', '/dispatch/price', 'dispatch/price/index', 'MoneyCollectOutlined', 2, 2, 'dispatch:price', 1),
(35, 2, '勘察费用', '/dispatch/cost', 'dispatch/cost/index', 'AccountBookOutlined', 8, 2, 'dispatch:cost', 1),
(34, 59, '运维费定价', '/base/price', 'base/price/index', 'MoneyCollectOutlined', 2, 2, 'dispatch:price', 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),
(37, 3, '新增价目', NULL, NULL, NULL, 1, 3, 'price:create', 1),
(38, 3, '编辑价目', NULL, NULL, NULL, 2, 3, 'price:update', 1),
(39, 3, '删除价目', NULL, NULL, NULL, 3, 3, 'price:delete', 1),
(40, 2, '新增勘察费用', NULL, NULL, NULL, 9, 3, 'cost:create', 1),
(41, 2, '编辑勘察费用', NULL, NULL, NULL, 10, 3, 'cost:update', 1),
(42, 2, '删除勘察费用', NULL, NULL, NULL, 11, 3, 'cost:delete', 1),
(37, 34, '新增价目', NULL, NULL, NULL, 1, 3, 'price:create', 1),
(38, 34, '编辑价目', NULL, NULL, NULL, 2, 3, 'price:update', 1),
(39, 34, '删除价目', NULL, NULL, NULL, 3, 3, 'price:delete', 1),
(40, 35, '新增勘察费用', NULL, NULL, NULL, 9, 3, 'cost:create', 1),
(41, 35, '编辑勘察费用', NULL, NULL, NULL, 10, 3, 'cost:update', 1),
(42, 35, '删除勘察费用', NULL, NULL, NULL, 11, 3, 'cost:delete', 1),
(43, 3, '新增电工', NULL, NULL, NULL, 7, 3, 'worker:create', 1),
(44, 3, '编辑电工', NULL, NULL, NULL, 8, 3, 'worker:update', 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
parent_id = EXCLUDED.parent_id,
menu_name = EXCLUDED.menu_name,
@@ -254,14 +261,13 @@ INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1)
ON CONFLICT (user_id, role_id) DO NOTHING;
-- ------------------------------------------------------------
-- 6. 角色-菜单绑定:ADMIN 拥有全部 46 条菜单,SALESMAN 拥有客户域+项目+统计报表
-- WORKER、CUSTOMER 不绑定任何菜单
-- 6. 角色-菜单绑定:ADMIN 拥有全部菜单,SALESMAN 拥有客户域+项目+统计报表
-- ------------------------------------------------------------
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, 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, 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, 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)
@@ -316,21 +322,42 @@ ON CONFLICT (id) DO UPDATE SET
permission = EXCLUDED.permission,
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
(1, 58), (1, 59), (1, 60)
(1, 58), (1, 59), (1, 60), (1, 61)
ON CONFLICT (role_id, menu_id) DO NOTHING;
-- 重置 sys_menu 自增序列(覆盖到 60)
SELECT setval('sys_menu_id_seq', 60, true);
-- 重置 sys_menu 自增序列(覆盖到 61)
SELECT setval('sys_menu_id_seq', 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 幂等)
-- ------------------------------------------------------------
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
('demo_heartbeat', '示例心跳', '*/5 * * * *', '{"message":"heartbeat"}'::jsonb, 0, 60, 1,
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 * * * *', 'minute', '{"interval":5}'::jsonb, '{"message":"heartbeat"}'::jsonb, 0, 60, 1,
'示例心跳任务:用于验证调度链路,默认每 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 天')
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;
-- 重置 sys_menu 自增序列
SELECT setval('sys_menu_id_seq', 56, true);
SELECT setval('sys_menu_id_seq', 57, true);
-- ------------------------------------------------------------
-- 10. 通知规则扫描任务种子(sys_job,按 job_key 幂等)
-- ------------------------------------------------------------
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
('notice_scan', '通知规则扫描', '0 0 8 * * *', '{}'::jsonb, 1, 300, 1,
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 8 * * *', 'day', '{"times":["08:00"]}'::jsonb, '{}'::jsonb, 1, 300, 1,
'遍历启用的扫描式通知规则,按模板与接收对象生成站内通知(幂等,默认每日 08:00)')
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 幂等;接收对象可在「通知管理」页调整)
-- ------------------------------------------------------------
@@ -405,6 +448,87 @@ INSERT INTO sys_notice_rule (rule_key, rule_name, event_key, trigger_mode, enabl
'扫描派单后超过 advance_days 天仍未接单的工单,通知 ADMIN')
ON CONFLICT (rule_key) DO NOTHING;
-- ------------------------------------------------------------
-- 11a. 智电改造网关台账(ICC 消防物联设备同步)
-- 建表 electric_gateway(与迁移 20261024_icc_gateway.sql 一致)
-- 设备监控(id=4)下新增菜单 62「智电改造网关台账」(sort 3,排在智电改造线路台账之后)
-- ADMIN 绑定新菜单;注册 icc_gateway_sync 定时任务(默认每 10 分钟)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS electric_gateway (
id BIGSERIAL PRIMARY KEY,
icc_imei VARCHAR(64) UNIQUE,
device_code VARCHAR(64) UNIQUE,
device_name VARCHAR(128),
customer_id BIGINT NOT NULL REFERENCES customer(id),
equip_id BIGINT REFERENCES customer_equipment(id),
org_code VARCHAR(64),
org_name VARCHAR(128),
manufacturer_code VARCHAR(64),
manufacturer_name VARCHAR(128),
type_code VARCHAR(32),
type_name VARCHAR(64),
product_model VARCHAR(64),
is_online SMALLINT NOT NULL DEFAULT 1,
synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE electric_gateway IS '智电改造网关设备表(ICC 同步)';
COMMENT ON COLUMN electric_gateway.id IS '自增主键';
COMMENT ON COLUMN electric_gateway.icc_imei IS 'ICC 设备 IMEI(可空唯一,ICC 数据可能缺失)';
COMMENT ON COLUMN electric_gateway.device_code IS 'ICC 设备编码(可空唯一,ICC 数据可能缺失)';
COMMENT ON COLUMN electric_gateway.device_name IS '设备名称(ICC 同步)';
COMMENT ON COLUMN electric_gateway.customer_id IS '客户 ID(由 orgCode→customer.icc_code 解析,逻辑隔离键)';
COMMENT ON COLUMN electric_gateway.equip_id IS '客户设备 ID(→ customer_equipment,同步不填充)';
COMMENT ON COLUMN electric_gateway.org_code IS 'ICC 组织编码';
COMMENT ON COLUMN electric_gateway.org_name IS '组织名称(ICC 同步)';
COMMENT ON COLUMN electric_gateway.manufacturer_code IS '厂商编码';
COMMENT ON COLUMN electric_gateway.manufacturer_name IS '厂商名称';
COMMENT ON COLUMN electric_gateway.type_code IS '设备类型编码';
COMMENT ON COLUMN electric_gateway.type_name IS '设备类型名称';
COMMENT ON COLUMN electric_gateway.product_model IS '产品型号';
COMMENT ON COLUMN electric_gateway.is_online IS '在线状态:1在线 0离线';
COMMENT ON COLUMN electric_gateway.synced_at IS 'ICC 同步时间';
COMMENT ON COLUMN electric_gateway.created_at IS '创建时间';
CREATE INDEX IF NOT EXISTS idx_gateway_customer ON electric_gateway (customer_id);
CREATE INDEX IF NOT EXISTS idx_gateway_equip ON electric_gateway (equip_id);
CREATE INDEX IF NOT EXISTS idx_gateway_imei ON electric_gateway (icc_imei);
-- 智电改造线路表:gateway_id 外键补充(FK 由迁移/本段建立;db_init.sql 中为普通 BIGINT)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_line_gateway' AND conrelid = 'electric_line'::regclass) THEN
ALTER TABLE electric_line ADD CONSTRAINT fk_line_gateway FOREIGN KEY (gateway_id) REFERENCES electric_gateway(id);
END IF;
END $$;
-- 菜单 62:设备监控下的智电改造网关台账菜单
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
(62, 4, '智电改造网关台账', '/device/gateway', 'device/gateway/index', 'ApiOutlined', 3, 2, 'device:list', 1)
ON CONFLICT (id) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
menu_name = EXCLUDED.menu_name,
menu_path = EXCLUDED.menu_path,
component = EXCLUDED.component,
icon = EXCLUDED.icon,
sort_order = EXCLUDED.sort_order,
menu_type = EXCLUDED.menu_type,
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- 角色-菜单绑定:ADMIN(role_id=1)拥有 62
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
(1, 62)
ON CONFLICT (role_id, menu_id) DO NOTHING;
-- 重置 sys_menu 自增序列(覆盖到 62)
SELECT setval('sys_menu_id_seq', 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;
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,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;
+9
View File
@@ -22,3 +22,12 @@ RUSTFS_BUCKET=xintong
REDIS_HOST=222.243.55.42
REDIS_PORT=26678
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
+1
View File
@@ -38,6 +38,7 @@
"reflect-metadata": "^0.2.2",
"sequelize": "^6.37.5",
"sequelize-typescript": "^2.1.6",
"undici": "^8.12.3",
"uuid": "^10.0.0"
},
"devDependencies": {
+7
View File
@@ -16,6 +16,13 @@ if (existsSync(envFile)) {
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 {
keys: "smart-grid-iot-secret-key",
@@ -1,6 +1,7 @@
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { CustomerService } from "../service/customer.service";
import { IccCustomerSyncService } from "../service/icc_customer_sync.service";
@Controller("/api/customers")
export class CustomerController {
@@ -10,6 +11,9 @@ export class CustomerController {
@Inject()
customerService!: CustomerService;
@Inject()
iccCustomerSyncService!: IccCustomerSyncService;
@Get("/")
async list(@Query() query: any) {
const result = await this.customerService.list(query, this.ctx);
@@ -28,6 +32,16 @@ export class CustomerController {
return result;
}
@Post("/sync/icc")
async syncIccCustomers() {
const result = await this.iccCustomerSyncService.syncFromIcc(this.ctx);
return {
code: 200,
message: "同步完成",
data: result
};
}
@Get("/:id")
async detail(@Param("id") id: string) {
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,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 };
}
}
+7 -1
View File
@@ -1,6 +1,6 @@
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
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")
export class JobController {
@@ -19,6 +19,12 @@ export class JobController {
return this.jobService.clearLogs(parsed);
}
/** 频率预览:人性化配置 → cron / 中文描述 / 未来执行时间 */
@Post("/cron/preview")
async previewCron(@Body() dto: CronPreviewDTO) {
return this.jobService.previewCron(dto.freqType, dto.freqConfig);
}
@Put("/:id")
async update(@Param("id") id: string, @Body() dto: UpdateJobDTO) {
return this.jobService.update(Number(id), dto);
@@ -11,9 +11,6 @@ export class SurveyOrderController {
// 以下静态路径必须声明在 /:id 之前,否则会被动态路由吞掉
@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); }
// 批量指派
@@ -23,9 +20,9 @@ export class SurveyOrderController {
@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); }
// 现场回执:按 SOP 检查项增量录入(照片/截图 key 数组、表单值、备注)
@Put("/:id/sop-results") async saveSopResults(@Param("id") id: string, @Body() body: any) {
return this.surveyOrderService.saveSopResults(Number(id), body, this.ctx);
// 勘察明细:全量替换(新增/编辑/删除统一走此接口)
@Put("/:id/details") async saveDetails(@Param("id") id: string, @Body() body: any) {
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)); }
// 重置电工 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) {
return this.workerService.audit(Number(id), body, this.ctx.state.user?.userId);
+30 -1
View File
@@ -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 { UploadFileInfo } from "@midwayjs/upload";
import { WorkerAppService } from "../service/worker_app.service";
import { NoticeService } from "../service/notice.service";
import { UploadService } from "../service/upload.service";
import {
WorkerRegisterDTO,
WorkerLoginDTO,
@@ -20,6 +22,24 @@ export class WorkerAppController {
@Inject()
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")
async register(@Body() body: WorkerRegisterDTO) {
@@ -130,6 +150,15 @@ export class WorkerAppController {
return this.workerAppService.completeOrder(this.currentWorkerId(), source, 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")
async earningsSummary() {
+2
View File
@@ -13,6 +13,7 @@ export class CustomerBuildingCreateDTO {
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) basicInfo?: string;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) purpose?: string;
@IsOptional() @IsInt() @Min(0) floorCount?: number;
@IsOptional() @IsInt() @Min(0) floorDownCount?: number;
}
export class CustomerBuildingUpdateDTO {
@@ -21,4 +22,5 @@ export class CustomerBuildingUpdateDTO {
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) basicInfo?: string;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) purpose?: string;
@IsOptional() @IsInt() @Min(0) floorCount?: number;
@IsOptional() @IsInt() @Min(0) floorDownCount?: number;
}
+45
View File
@@ -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;
}
+10
View File
@@ -0,0 +1,10 @@
import { IsOptional, IsString, IsInt, MaxLength, Min, Max } from "class-validator";
export class ElectricGatewayQueryDTO {
@IsOptional() @IsInt() @Min(1) page?: number = 1;
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
@IsOptional() @IsInt() customerId?: number;
@IsOptional() @IsInt() equipId?: number;
@IsOptional() @IsInt() @Min(0) @Max(1) isOnline?: number;
}
+11
View File
@@ -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;
}
+20 -3
View File
@@ -1,10 +1,15 @@
import { IsNotEmpty, IsOptional, IsString, IsInt, IsObject, Min, Max, MaxLength } from "class-validator";
/** 频率配置:字段按 freqType 取用,由 util/cron_schedule 统一校验与翻译 */
export class UpdateJobDTO {
@IsString()
@IsNotEmpty({ message: "cron 表达式不能为空" })
@MaxLength(64, { message: "cron 表达式最大 64 字符" })
cronExpression!: string;
@IsNotEmpty({ message: "频率类型不能为空" })
@MaxLength(16, { message: "频率类型非法" })
freqType!: string;
@IsOptional()
@IsObject()
freqConfig?: Record<string, any>;
@IsOptional()
@IsObject()
@@ -26,6 +31,18 @@ export class UpdateJobDTO {
remark?: string;
}
/** 频率预览入参:只传频率配置,cron 与下次执行时间由后端计算 */
export class CronPreviewDTO {
@IsString()
@IsNotEmpty({ message: "频率类型不能为空" })
@MaxLength(16, { message: "频率类型非法" })
freqType!: string;
@IsOptional()
@IsObject()
freqConfig?: Record<string, any>;
}
export class JobStatusDTO {
@IsInt()
@Min(0, { message: "状态值非法" })
+3 -3
View File
@@ -48,8 +48,8 @@ export class SurveyOrderCreateDTO {
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) source?: string;
@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 {
@@ -70,7 +70,7 @@ export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) declare remark?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) declare source?: string;
@IsOptional() @IsInt() @Min(0) @Max(1) declare isDraft?: number;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) declare templateCode?: string;
@IsOptional() @IsArray() declare details?: any[];
}
// 指派 / 改派(0待指派→指派,1现场勘察中→改派)
+2
View File
@@ -72,6 +72,8 @@ export class WorkerUpdateDTO {
@IsOptional() @IsInt() @IsIn([0, 1]) status?: number;
@IsOptional() @IsNumber() @Min(0) @Max(5) rating?: number;
@IsOptional() @IsString() @MaxLength(128) certInfo?: string;
// 为尚未开通 APP 账号的电工补开账号(默认密码为手机号后 6 位;已开通时忽略)
@IsOptional() @IsBoolean() withAccount?: boolean;
// 存在时整体覆盖该电工的全部证书
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => WorkerCertDTO) certs?: WorkerCertDTO[];
}
+7
View File
@@ -32,6 +32,13 @@ export default class Customer extends Model {
})
customerNo!: string;
@Column({
type: DataType.STRING(64),
field: "icc_code",
comment: "ICC客户编码(ICC平台 orgCode,同步来源)"
})
iccCode!: string;
@Column({
type: DataType.STRING(128),
allowNull: false,
@@ -26,9 +26,12 @@ export default class CustomerBuilding extends Model {
@Column({ type: DataType.STRING(128), comment: "用途" })
purpose!: string;
@Column({ type: DataType.INTEGER, field: "floor_count", comment: "层数" })
@Column({ type: DataType.INTEGER, field: "floor_count", comment: "地上楼层数" })
floorCount!: number;
@Column({ type: DataType.INTEGER, field: "floordown_count", comment: "地下楼层数", defaultValue: 0 })
floorDownCount!: number;
@BelongsTo(() => Customer)
customer!: Customer;
@@ -0,0 +1,78 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import Customer from "./customer.entity";
import CustomerBuilding from "./customer_building.entity";
import CustomerRoom from "./customer_room.entity";
@Table({
tableName: "customer_circuit",
comment: "客户用电回路表",
timestamps: true,
createdAt: "created_at",
updatedAt: false,
indexes: [
{ name: "idx_circuit_customer", fields: ["customer_id"] },
{ name: "idx_circuit_building", fields: ["building_id"] },
{ name: "idx_circuit_room", fields: ["room_id"] }
]
})
export default class CustomerCircuit extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@ForeignKey(() => Customer)
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" })
customerId!: number;
@ForeignKey(() => CustomerBuilding)
@Column({ type: DataType.BIGINT, allowNull: false, field: "building_id", comment: "所属建筑ID" })
buildingId!: number;
@ForeignKey(() => CustomerRoom)
@Column({ type: DataType.BIGINT, field: "room_id", comment: "所属房间ID(可空)" })
roomId!: number;
@Column({ type: DataType.STRING(16), field: "floor_no", comment: "所在楼层" })
floorNo!: string;
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "circuit_code", comment: "回路编号(全局唯一)" })
circuitCode!: string;
@Column({ type: DataType.STRING(128), allowNull: false, field: "circuit_name", comment: "回路名称" })
circuitName!: string;
@Column({ type: DataType.STRING(128), field: "loop_spec", comment: "回路规格" })
loopSpec!: string;
@Column({ type: DataType.STRING(32), field: "load_type", comment: "负荷类型" })
loadType!: string;
@Column({ type: DataType.STRING(32), field: "rated_voltage", comment: "额定电压" })
ratedVoltage!: string;
@Column({ type: DataType.STRING(32), field: "rated_current", comment: "额定电流" })
ratedCurrent!: string;
@Column({ type: DataType.DECIMAL(10, 2), field: "rated_power", comment: "额定功率kW" })
ratedPower!: number;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1正常/0停用" })
status!: number;
@Column({ type: DataType.TEXT, comment: "备注" })
remark!: string;
@Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0, field: "sort_order", comment: "排序值" })
sortOrder!: number;
@BelongsTo(() => Customer)
customer!: Customer;
@BelongsTo(() => CustomerBuilding)
building!: CustomerBuilding;
@BelongsTo(() => CustomerRoom)
room!: CustomerRoom;
@CreatedAt
created_at!: Date;
}
@@ -0,0 +1,71 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import Customer from "./customer.entity";
import CustomerEquipment from "./customer_equipment.entity";
@Table({
tableName: "electric_gateway",
comment: "智电改造网关设备表(ICC 同步)",
timestamps: false,
indexes: [
{ name: "idx_gateway_customer", fields: ["customer_id"] },
{ name: "idx_gateway_equip", fields: ["equip_id"] },
{ name: "idx_gateway_imei", fields: ["icc_imei"] }
]
})
export default class ElectricGateway extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), unique: true, field: "icc_imei", comment: "设备唯一标识码(imei)" })
iccImei!: string;
@Column({ type: DataType.STRING(64), unique: true, field: "device_code", comment: "设备编码" })
deviceCode!: string;
@Column({ type: DataType.STRING(128), field: "device_name", comment: "设备名称" })
deviceName!: string;
@ForeignKey(() => Customer)
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户 ID(由 orgCode→customer.icc_code 解析)" })
customerId!: number;
@ForeignKey(() => CustomerEquipment)
@Column({ type: DataType.BIGINT, field: "equip_id", comment: "客户设备 ID(→ customer_equipment,可手动关联)" })
equipId!: number;
@Column({ type: DataType.STRING(64), field: "org_code", comment: "ICC 组织编码" })
orgCode!: string;
@Column({ type: DataType.STRING(128), field: "org_name", comment: "ICC 组织名称" })
orgName!: string;
@Column({ type: DataType.STRING(64), field: "manufacturer_code", comment: "厂商编码" })
manufacturerCode!: string;
@Column({ type: DataType.STRING(128), field: "manufacturer_name", comment: "厂商名称" })
manufacturerName!: string;
@Column({ type: DataType.STRING(32), field: "type_code", comment: "设备类型编码" })
typeCode!: string;
@Column({ type: DataType.STRING(64), field: "type_name", comment: "设备类型名称" })
typeName!: string;
@Column({ type: DataType.STRING(64), field: "product_model", comment: "产品型号" })
productModel!: string;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "is_online", comment: "在线状态:1在线 0离线" })
isOnline!: number;
@Column({ type: DataType.DATE, field: "synced_at", comment: "ICC 同步时间" })
syncedAt!: Date;
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer;
@BelongsTo(() => CustomerEquipment, { foreignKey: "equipId", as: "equipment" })
equipment!: CustomerEquipment;
@CreatedAt
created_at!: Date;
}
+83
View File
@@ -0,0 +1,83 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import CustomerEquipment from "./customer_equipment.entity";
import ElectricGateway from "./electric_gateway.entity";
import Customer from "./customer.entity";
@Table({
tableName: "electric_line",
comment: "智电改造线路信息表(ICC 同步)",
timestamps: false,
indexes: [
{ name: "idx_line_customer", fields: ["customer_id"] },
{ name: "idx_line_equip", fields: ["equip_id"] },
{ name: "idx_line_gateway", fields: ["gateway_id"] },
{ name: "idx_line_imei", fields: ["imei"] }
]
})
export default class ElectricLine extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "line_code", comment: "探测器编码(detectorCode)唯一幂等键" })
lineCode!: string;
@Column({ type: DataType.STRING(128), field: "line_name", comment: "探测器名称(detectorName)" })
lineName!: string;
@Column({ type: DataType.STRING(64), field: "imei", comment: "探测器 IMEI" })
imei!: string;
@ForeignKey(() => CustomerEquipment)
@Column({ type: DataType.BIGINT, field: "equip_id", comment: "客户设备 ID(→ customer_equipment,同步不填充)" })
equipId!: number;
@ForeignKey(() => ElectricGateway)
@Column({ type: DataType.BIGINT, field: "gateway_id", comment: "网关 ID(→ electric_gateway,由 parentCode↔device_code 匹配)" })
gatewayId!: number;
@ForeignKey(() => Customer)
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户 ID(由 orgCode→customer.icc_code 解析)" })
customerId!: number;
@Column({ type: DataType.STRING(32), field: "type_code", comment: "探测器类型编码(typeCode)" })
typeCode!: string;
@Column({ type: DataType.STRING(64), field: "type_name", comment: "探测器类型名称(typeName)" })
typeName!: string;
@Column({ type: DataType.STRING(64), field: "fire_system_code", comment: "消防系统编码(fireSystemCode)" })
fireSystemCode!: string;
@Column({ type: DataType.STRING(128), field: "fire_system_name", comment: "消防系统名称(fireSystemName)" })
fireSystemName!: string;
@Column({ type: DataType.STRING(64), field: "part_unit_loop_code", comment: "部件/单元/回路编码(partUnitLoopCode)" })
partUnitLoopCode!: string;
@Column({ type: DataType.STRING(64), field: "channel_no", comment: "通道号(channelNo)" })
channelNo!: string;
@Column({ type: DataType.STRING(64), field: "channel_sn", comment: "通道序列号(channelSn)" })
channelSn!: string;
@Column({ type: DataType.STRING(16), field: "produce_date", comment: "生产日期(produceDate)" })
produceDate!: string;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "status", comment: "上报状态:1在线 0离线(detectorStatus 归一)" })
status!: number;
@Column({ type: DataType.DATE, field: "synced_at", comment: "ICC 同步时间" })
syncedAt!: Date;
@BelongsTo(() => CustomerEquipment, { foreignKey: "equipId", as: "equipment" })
equipment!: CustomerEquipment;
@BelongsTo(() => ElectricGateway, { foreignKey: "gatewayId", as: "gateway" })
gateway!: ElectricGateway;
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
customer!: Customer;
@CreatedAt
created_at!: Date;
}
+36
View File
@@ -0,0 +1,36 @@
import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript";
@Table({
tableName: "icc_sync_log",
comment: "ICC 数据同步日志表",
timestamps: false,
indexes: [
{ name: "idx_icc_sync_type", fields: ["sync_type"] },
{ name: "idx_icc_sync_time", fields: ["created_at"] }
]
})
export default class IccSyncLog extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(32), allowNull: false, field: "sync_type", comment: "同步类型:客户/设备/用电/报警" })
syncType!: string;
@Column({ type: DataType.DATE, field: "start_time", comment: "开始时间" })
startTime!: Date;
@Column({ type: DataType.DATE, field: "end_time", comment: "结束时间" })
endTime!: Date;
@Column({ type: DataType.INTEGER, field: "record_count", comment: "同步记录数" })
recordCount!: number;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1成功 0失败" })
status!: number;
@Column({ type: DataType.TEXT, field: "error_message", comment: "错误信息" })
errorMessage!: string;
@CreatedAt
created_at!: Date;
}
+49
View File
@@ -0,0 +1,49 @@
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import SurveyOrder from "./survey_order.entity";
@Table({
tableName: "survey_detail",
comment: "勘察工单明细表(建筑/楼层/房间 + 现场照片)",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [{ name: "idx_survey_detail_order", fields: ["survey_order_id"] }]
})
export default class SurveyDetail extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@ForeignKey(() => SurveyOrder)
@Column({ type: DataType.BIGINT, allowNull: false, field: "survey_order_id", comment: "勘察工单ID(→ survey_order)" })
surveyOrderId!: number;
@Column({ type: DataType.BIGINT, field: "building_id", comment: "建筑ID(→ customer_building)" })
buildingId!: number;
@Column({ type: DataType.STRING(128), field: "building_name", comment: "建筑名称(冗余,客户拓扑变更后仍可读)" })
buildingName!: string;
@Column({ type: DataType.STRING(16), field: "floor_no", comment: "所在楼层:正数为地上、负数为地下,如 1 / -1" })
floorNo!: string;
@Column({ type: DataType.BIGINT, field: "room_id", comment: "房间ID(→ customer_room,可空)" })
roomId!: number;
@Column({ type: DataType.STRING(128), field: "room_name", comment: "房间名称(冗余,可空)" })
roomName!: string;
@Column({ type: DataType.TEXT, field: "photos", comment: "现场照片对象key(JSON数组字符串)" })
photos!: string;
@Column({ type: DataType.STRING(255), comment: "备注" })
remark!: string;
@BelongsTo(() => SurveyOrder, { foreignKey: "surveyOrderId", as: "surveyOrder" })
surveyOrder!: SurveyOrder;
@CreatedAt
created_at!: Date;
@UpdatedAt
updated_at!: Date;
}
@@ -1,40 +0,0 @@
import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript";
@Table({
tableName: "survey_sop_item",
comment: "勘察 SOP 模板检查项表",
timestamps: true,
createdAt: "created_at",
updatedAt: false,
indexes: [
{ name: "uk_sop_template_order", unique: true, fields: ["template_code", "sort_order"] }
]
})
export default class SurveySopItem extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), allowNull: false, defaultValue: "standard_dist", field: "template_code", comment: "SOP模板编码:standard_dist标准配电改造模板" })
templateCode!: string;
@Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0, field: "sort_order", comment: "检查项序号" })
sortOrder!: number;
@Column({ type: DataType.STRING(128), allowNull: false, field: "item_name", comment: "检查项名称" })
itemName!: string;
@Column({ type: DataType.STRING(255), field: "item_desc", comment: "检查项说明" })
itemDesc!: string;
@Column({ type: DataType.STRING(16), allowNull: false, defaultValue: "photo", field: "receipt_type", comment: "回执形式:photo现场照片 form清单表单 meter测仪截图" })
receiptType!: string;
@Column({ type: DataType.STRING(64), field: "receipt_require", comment: "回执指标要求,如「照片 ≥ 2 张」" })
receiptRequire!: string;
@Column({ type: DataType.BOOLEAN, allowNull: false, defaultValue: true, comment: "是否必填" })
required!: boolean;
@CreatedAt
created_at!: Date;
}
@@ -1,45 +0,0 @@
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey } from "sequelize-typescript";
import SurveyOrder from "./survey_order.entity";
import SurveySopItem from "./survey_sop_item.entity";
@Table({
tableName: "survey_sop_result",
comment: "勘察 SOP 工单回执表",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [
{ name: "uk_sop_result_order_item", unique: true, fields: ["survey_order_id", "sop_item_id"] },
{ name: "idx_sop_result_order", fields: ["survey_order_id"] }
]
})
export default class SurveySopResult extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@ForeignKey(() => SurveyOrder)
@Column({ type: DataType.BIGINT, allowNull: false, field: "survey_order_id", comment: "勘察工单ID(→ survey_order)" })
surveyOrderId!: number;
@ForeignKey(() => SurveySopItem)
@Column({ type: DataType.BIGINT, allowNull: false, field: "sop_item_id", comment: "SOP检查项ID(→ survey_sop_item)" })
sopItemId!: number;
@Column({ type: DataType.BOOLEAN, allowNull: false, defaultValue: false, comment: "是否已完成回执" })
checked!: boolean;
@Column({ type: DataType.TEXT, field: "receipt_value", comment: "回执内容(照片对象key的JSON数组字符串/表单文本值/测仪截图key的JSON数组字符串)" })
receiptValue!: string;
@Column({ type: DataType.STRING(255), comment: "回执备注" })
remark!: string;
@Column({ type: DataType.DATE, field: "checked_at", comment: "回执时间" })
checkedAt!: Date;
@CreatedAt
created_at!: Date;
@UpdatedAt
updated_at!: Date;
}
+14
View File
@@ -36,6 +36,20 @@ export default class SysJob extends Model {
})
cronExpression!: string;
@Column({
type: DataType.STRING(16),
field: "freq_type",
comment: "频率类型: minute/hour/day/week/month/custom"
})
freqType!: string;
@Column({
type: DataType.JSONB,
field: "freq_config",
comment: "频率配置(人性化配置表单回填用)"
})
freqConfig!: Record<string, any>;
@Column({
type: DataType.JSONB,
field: "params",
+5
View File
@@ -23,8 +23,13 @@ const PERMISSION_MAP: Record<string, string> = {
"/api/upload": "customer:visit",
"/api/customer-buildings": "customer:building",
"/api/customer-rooms": "customer:room",
// 用电回路接口复用页面级权限码 customer:topology(该菜单已在 sys_menu 登记并分配给角色),
// 因此不再新增按钮级权限码,避免非 ADMIN 角色因权限码未登记而被 403。
"/api/customer-circuits": "customer:topology",
"/api/customer-equipments": "device:list",
"/api/smart-devices": "device:model",
"/api/electric-lines": "device:list",
"/api/electric-gateways": "device:list",
"/api/survey-orders": "project:survey",
"/api/project-plans": "project:plan",
"/api/contracts": "project:contract",
+1 -1
View File
@@ -114,7 +114,7 @@ export class CustomerService {
});
}
private async generateCustomerNo(retry: number = 0): Promise<string> {
public async generateCustomerNo(retry: number = 0): Promise<string> {
const now = new Date();
const offsetMs = now.getTime() + 8 * 60 * 60 * 1000;
const dateLocal = new Date(offsetMs);
@@ -0,0 +1,178 @@
import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import { Context } from "@midwayjs/koa";
import CustomerCircuit from "../entity/customer_circuit.entity";
import Customer from "../entity/customer.entity";
import CustomerBuilding from "../entity/customer_building.entity";
import CustomerRoom from "../entity/customer_room.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
// 楼层排序:数值楼层升序(-1 < 1 < 2 …),"未标注楼层"(空串)排最后,其余按字符串排序
function compareFloor(a: string, b: string): number {
if (a === b) return 0;
if (a === "") return 1;
if (b === "") return -1;
const na = Number(a);
const nb = Number(b);
const aIsNum = !Number.isNaN(na);
const bIsNum = !Number.isNaN(nb);
if (aIsNum && bIsNum) return na - nb;
if (aIsNum) return -1;
if (bIsNum) return 1;
return a.localeCompare(b);
}
@Provide()
export class CustomerCircuitService {
@InjectRepository(CustomerCircuit)
circuitModel!: Repository<CustomerCircuit>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(CustomerBuilding)
buildingModel!: Repository<CustomerBuilding>;
@InjectRepository(CustomerRoom)
roomModel!: Repository<CustomerRoom>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
}
private async isCustomerOwner(userId: number, customerId: number): Promise<boolean> {
if (await this.isAdmin(userId)) return true;
const customer = await this.customerModel.findByPk(customerId);
return !!customer && customer.salesmanId === userId;
}
async list(query: any, ctx: Context): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const userId = ctx.state.user.userId;
const where: any = {};
if (query.keyword) {
where[Op.or] = [
{ circuitName: { [Op.like]: `%${query.keyword}%` } },
{ circuitCode: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.customerId !== undefined) where.customerId = query.customerId;
if (query.buildingId !== undefined) where.buildingId = query.buildingId;
if (query.roomId !== undefined) where.roomId = query.roomId;
if (query.floorNo !== undefined) where.floorNo = query.floorNo;
const emptyResult = { code: 200, message: "获取用电回路列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
if (!(await this.isAdmin(userId))) {
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
const ids = myCustomers.map((c) => c.id);
if (ids.length === 0) return emptyResult;
if (where.customerId !== undefined) {
if (!ids.includes(where.customerId)) return emptyResult;
} else {
where.customerId = { [Op.in]: ids };
}
}
const { count, rows } = await this.circuitModel.findAndCountAll({
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["sort_order", "ASC"], ["created_at", "DESC"]]
});
const list = rows.map((r) => r.toJSON());
return { code: 200, message: "获取用电回路列表成功", data: { list, total: count, page: Number(page), pageSize: Number(pageSize) } };
}
// 一次返回整棵「建筑 → 楼层 → 房间 → 回路」结构,避免前端逐建筑请求(N+1)
async topology(query: any, ctx: Context): Promise<any> {
const customerId = Number(query.customerId);
if (!customerId) return { code: 400, message: "缺少 customerId 参数", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, customerId))) {
return { code: 403, message: "无权限访问该客户的空间拓扑", data: null };
}
const [buildings, rooms, circuits] = await Promise.all([
this.buildingModel.findAll({ where: { customerId }, order: [["id", "ASC"]] }),
this.roomModel.findAll({ where: { customerId }, order: [["id", "ASC"]] }),
this.circuitModel.findAll({ where: { customerId }, order: [["sort_order", "ASC"], ["id", "ASC"]] })
]);
const circuitJson = circuits.map((c) => c.toJSON()) as any[];
const data = buildings.map((b) => {
const bRooms = rooms.filter((r) => r.buildingId === b.id);
const bCircuits = circuitJson.filter((c) => c.buildingId === b.id);
const floorSet = Array.from(
new Set([...bRooms.map((r) => r.floorNo || ""), ...bCircuits.map((c) => c.floorNo || "")])
).sort(compareFloor);
return {
...b.toJSON(),
floors: floorSet.map((floorNo) => ({
floorNo,
rooms: bRooms
.filter((r) => (r.floorNo || "") === floorNo)
.map((r) => ({
...r.toJSON(),
circuits: bCircuits.filter((c) => c.roomId === r.id)
})),
// 公共区域回路:未挂房间,直接归属楼层
circuits: bCircuits.filter((c) => !c.roomId && (c.floorNo || "") === floorNo)
}))
};
});
return { code: 200, message: "获取空间用电拓扑成功", data: { buildings: data } };
}
async detail(id: number, ctx: Context): Promise<any> {
const record = await this.circuitModel.findByPk(id);
if (!record) return { code: 404, message: "用电回路不存在", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限访问该回路", data: null };
return { code: 200, message: "获取用电回路详情成功", data: record.toJSON() };
}
async create(data: any, ctx: Context): Promise<any> {
if (!(await this.isCustomerOwner(ctx.state.user.userId, data.customerId))) return { code: 403, message: "无权限为该客户创建回路", data: null };
const exists = await this.circuitModel.findOne({ where: { circuitCode: data.circuitCode } });
if (exists) return { code: 400, message: "回路编号已存在,请更换", data: null };
const created = await this.circuitModel.create(data);
return { code: 200, message: "创建用电回路成功", data: created.toJSON() };
}
async update(id: number, data: any, ctx: Context): Promise<any> {
const record = await this.circuitModel.findByPk(id);
if (!record) return { code: 404, message: "用电回路不存在", data: null };
if (!(await this.isAdmin(ctx.state.user.userId))) {
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限修改该回路", data: null };
delete data.customerId;
}
if (data.circuitCode && data.circuitCode !== record.circuitCode) {
const exists = await this.circuitModel.findOne({ where: { circuitCode: data.circuitCode, id: { [Op.ne]: id } } });
if (exists) return { code: 400, message: "回路编号已存在,请更换", data: null };
}
await record.update(data);
return { code: 200, message: "更新用电回路成功", data: record.toJSON() };
}
async delete(id: number, ctx: Context): Promise<any> {
const record = await this.circuitModel.findByPk(id);
if (!record) return { code: 404, message: "用电回路不存在", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该回路", data: null };
await record.destroy();
return { code: 200, message: "删除用电回路成功", data: null };
}
}
@@ -0,0 +1,130 @@
import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import { Context } from "@midwayjs/koa";
import ElectricGateway from "../entity/electric_gateway.entity";
import Customer from "../entity/customer.entity";
import CustomerEquipment from "../entity/customer_equipment.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
@Provide()
export class ElectricGatewayService {
@InjectRepository(ElectricGateway)
gatewayModel!: Repository<ElectricGateway>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(CustomerEquipment)
equipmentModel!: Repository<CustomerEquipment>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
}
async list(query: any, ctx: Context): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const userId = ctx.state.user.userId;
const where: any = {};
if (query.keyword) {
where[Op.or] = [
{ deviceName: { [Op.like]: `%${query.keyword}%` } },
{ deviceCode: { [Op.like]: `%${query.keyword}%` } },
{ iccImei: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
if (query.equipId !== undefined && query.equipId !== "") where.equipId = Number(query.equipId);
if (query.isOnline !== undefined && query.isOnline !== "" && query.isOnline !== null) where.isOnline = Number(query.isOnline);
// 非管理员按客户归属自动限定数据范围
if (!(await this.isAdmin(userId))) {
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
const ids = myCustomers.map((c) => c.id);
if (ids.length === 0) {
return { code: 200, message: "获取网关台账列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
}
if (where.customerId !== undefined) {
if (!ids.includes(where.customerId)) {
return { code: 200, message: "获取网关台账列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
}
} else {
where.customerId = { [Op.in]: ids };
}
}
const { count, rows } = await this.gatewayModel.findAndCountAll({
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["created_at", "DESC"], ["id", "DESC"]],
distinct: true
});
const list = await this.decorate(rows);
return { code: 200, message: "获取网关台账列表成功", data: { list, total: count, page: Number(page), pageSize: Number(pageSize) } };
}
async detail(id: number, ctx: Context): Promise<any> {
const record = await this.gatewayModel.findByPk(id);
if (!record) return { code: 404, message: "网关设备不存在", data: null };
if (!(await this.isAdmin(ctx.state.user.userId))) {
const customer = await this.customerModel.findByPk(record.customerId);
if (!customer || customer.salesmanId !== ctx.state.user.userId) {
return { code: 403, message: "无权限访问该网关设备", data: null };
}
}
const decorated = await this.decorate([record]);
return { code: 200, message: "获取网关设备台账详情成功", data: decorated[0] };
}
/** 补全客户/设备展示信息,避免 N+1。 */
private async decorate(rows: ElectricGateway[]): Promise<any[]> {
if (rows.length === 0) return rows as any;
const customerIds = [...new Set(rows.map((r) => r.customerId).filter(Boolean))];
const equipIds = [...new Set(rows.map((r) => r.equipId).filter(Boolean))];
const [customers, equips] = await Promise.all([
customerIds.length
? this.customerModel.findAll({
where: { id: { [Op.in]: customerIds } },
attributes: ["id", "customerNo", "customerName"]
})
: Promise.resolve([]),
equipIds.length
? this.equipmentModel.findAll({
where: { id: { [Op.in]: equipIds } },
attributes: ["id", "equipCode", "equipName"]
})
: Promise.resolve([])
]);
const customerMap = new Map(customers.map((c) => [c.id, c]));
const equipMap = new Map(equips.map((e) => [e.id, e]));
return rows.map((row) => {
const json: any = row.toJSON();
const customer: any = customerMap.get(row.customerId);
const equip: any = row.equipId ? equipMap.get(row.equipId) : undefined;
json.customerNo = customer?.customerNo || "";
json.customerName = customer?.customerName || "";
json.equipCode = equip?.equipCode || "";
json.equipName = equip?.equipName || "";
return json;
});
}
}
+145
View File
@@ -0,0 +1,145 @@
import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import { Context } from "@midwayjs/koa";
import ElectricLine from "../entity/electric_line.entity";
import ElectricGateway from "../entity/electric_gateway.entity";
import Customer from "../entity/customer.entity";
import CustomerEquipment from "../entity/customer_equipment.entity";
import SysUserRole from "../entity/sysUserRole.entity";
import SysRole from "../entity/sysRole.entity";
@Provide()
export class ElectricLineService {
@InjectRepository(ElectricLine)
lineModel!: Repository<ElectricLine>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(CustomerEquipment)
equipmentModel!: Repository<CustomerEquipment>;
@InjectRepository(ElectricGateway)
gatewayModel!: Repository<ElectricGateway>;
@InjectRepository(SysUserRole)
userRoleModel!: Repository<SysUserRole>;
@InjectRepository(SysRole)
roleModel!: Repository<SysRole>;
private async isAdmin(userId: number): Promise<boolean> {
const userRoles = await this.userRoleModel.findAll({ where: { userId }, include: [this.roleModel] });
return userRoles.some((ur) => (ur as any).role?.roleCode === "ADMIN");
}
async list(query: any, ctx: Context): Promise<any> {
const page = Number(query.page) || 1;
const pageSize = Number(query.pageSize) || 10;
const userId = ctx.state.user.userId;
const where: any = {};
if (query.keyword) {
where[Op.or] = [
{ lineName: { [Op.like]: `%${query.keyword}%` } },
{ lineCode: { [Op.like]: `%${query.keyword}%` } },
{ imei: { [Op.like]: `%${query.keyword}%` } }
];
}
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
if (query.equipId !== undefined && query.equipId !== "") where.equipId = Number(query.equipId);
if (query.status !== undefined && query.status !== "" && query.status !== null) where.status = Number(query.status);
// 非管理员按客户归属自动限定数据范围
if (!(await this.isAdmin(userId))) {
const myCustomers = await this.customerModel.findAll({ where: { salesmanId: userId }, attributes: ["id"] });
const ids = myCustomers.map((c) => c.id);
if (ids.length === 0) {
return { code: 200, message: "获取线路台账列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
}
if (where.customerId !== undefined) {
if (!ids.includes(where.customerId)) {
return { code: 200, message: "获取线路台账列表成功", data: { list: [], total: 0, page: Number(page), pageSize: Number(pageSize) } };
}
} else {
where.customerId = { [Op.in]: ids };
}
}
const { count, rows } = await this.lineModel.findAndCountAll({
where,
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["created_at", "DESC"], ["id", "DESC"]],
distinct: true
});
const list = await this.decorate(rows);
return { code: 200, message: "获取线路台账列表成功", data: { list, total: count, page: Number(page), pageSize: Number(pageSize) } };
}
async detail(id: number, ctx: Context): Promise<any> {
const record = await this.lineModel.findByPk(id);
if (!record) return { code: 404, message: "线路不存在", data: null };
if (!(await this.isAdmin(ctx.state.user.userId))) {
const customer = await this.customerModel.findByPk(record.customerId);
if (!customer || customer.salesmanId !== ctx.state.user.userId) {
return { code: 403, message: "无权限访问该线路", data: null };
}
}
const decorated = await this.decorate([record]);
return { code: 200, message: "获取线路台账详情成功", data: decorated[0] };
}
/** 补全客户/设备/网关展示信息,避免 N+1。 */
private async decorate(rows: ElectricLine[]): Promise<any[]> {
if (rows.length === 0) return rows as any;
const customerIds = [...new Set(rows.map((r) => r.customerId).filter(Boolean))];
const equipIds = [...new Set(rows.map((r) => r.equipId).filter(Boolean))];
const gatewayIds = [...new Set(rows.map((r) => r.gatewayId).filter(Boolean))];
const [customers, equips, gateways] = await Promise.all([
customerIds.length
? this.customerModel.findAll({
where: { id: { [Op.in]: customerIds } },
attributes: ["id", "customerNo", "customerName"]
})
: Promise.resolve([]),
equipIds.length
? this.equipmentModel.findAll({
where: { id: { [Op.in]: equipIds } },
attributes: ["id", "equipCode", "equipName"]
})
: Promise.resolve([]),
gatewayIds.length
? this.gatewayModel.findAll({
where: { id: { [Op.in]: gatewayIds } },
attributes: ["id", "deviceCode", "deviceName"]
})
: Promise.resolve([])
]);
const customerMap = new Map(customers.map((c) => [c.id, c]));
const equipMap = new Map(equips.map((e) => [e.id, e]));
const gatewayMap = new Map(gateways.map((g) => [g.id, g]));
return rows.map((row) => {
const json: any = row.toJSON();
const customer: any = customerMap.get(row.customerId);
const equip: any = row.equipId ? equipMap.get(row.equipId) : undefined;
const gateway: any = row.gatewayId ? gatewayMap.get(row.gatewayId) : undefined;
json.customerNo = customer?.customerNo || "";
json.customerName = customer?.customerName || "";
json.equipCode = equip?.equipCode || "";
json.equipName = equip?.equipName || "";
json.gatewayCode = gateway?.deviceCode || "";
json.gatewayName = gateway?.deviceName || "";
return json;
});
}
}
@@ -0,0 +1,282 @@
import { Provide, App } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op, UniqueConstraintError } from "sequelize";
import { Context, Application } from "@midwayjs/koa";
import Customer from "../entity/customer.entity";
import IccSyncLog from "../entity/icc_sync_log.entity";
import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
import { ICC_ORG_ENDPOINT, ICC_ORG_TYPE_BASE, mapIccOrgRecord, parseOrgPage, IccOrgRawRecord } from "../task/icc_customer_mapper";
import { CustomerService } from "./customer.service";
export interface IccCustomerSyncResult {
/** ICC 拉取到的原始组织条数(含非 org_base) */
pulled: number;
/** 成功落库的客户条数(新增+更新) */
synced: number;
/** 新增的客户条数 */
inserted: number;
/** 更新的客户条数 */
updated: number;
/** 跳过的条数(非 org_base / 缺 iccCode / 缺客户名 / 四字段无变化 / 编号冲突无法解决) */
skipped: number;
}
const SYNC_TYPE = "customer";
/** 分页拉取参数(ICC 组织分页接口约定) */
const PAGE_SIZE = 1000;
/** ICC_VERSION 缺省值(与 icc_customer_auth 保持一致) */
const DEFAULT_VERSION = "1.0.0";
/** mapIccOrgRecord 的返回类型别名 */
type MappedOrg = ReturnType<typeof mapIccOrgRecord>;
/**
* ICC 客户信息拉取同步:
* 1) 从 .env 读取 ICC_BASE_URL / ICC_VERSION,经 getIccAccessToken 走 OAuth2 密码模式鉴权
* 2) 分页请求 ICC 组织列表(/organization/page,pageNum/pageSize/maxRangeId)
* 3) 仅保留 orgType=org_base 的组织(对应客户),按 iccCode 幂等 upsert
* 4) 每次同步写一条 icc_sync_log;拉取失败仅记录 error,不抛业务错误
*
* 客户编号生成:复用 CustomerService.generateCustomerNo(已由 private 改为 public,
* 保持与手工建客户完全一致的 KH+YYYYMMDD+3位序号 规则),经匿名请求上下文获取实例。
*/
@Provide()
export class IccCustomerSyncService {
@App()
app!: Application;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(IccSyncLog)
syncLogModel!: Repository<IccSyncLog>;
async syncFromIcc(ctx?: Context): Promise<IccCustomerSyncResult> {
const startTime = new Date();
const baseUrl = process.env.ICC_BASE_URL?.trim();
const result: IccCustomerSyncResult = { pulled: 0, synced: 0, inserted: 0, updated: 0, skipped: 0 };
// 基础配置缺失视为拉取失败:记录日志,不抛业务错误
if (!baseUrl) {
await this.writeLog(startTime, 0, SYNC_TYPE, 0, "未配置 ICC_BASE_URL,请检查 server/.env");
return result;
}
try {
const token = await getIccAccessToken();
const customerService = await this.getCustomerService();
const url = `${baseUrl.replace(/\/$/, "")}${ICC_ORG_ENDPOINT.replace("{version}", process.env.ICC_VERSION?.trim() || DEFAULT_VERSION)}`;
const allRaw: IccOrgRawRecord[] = [];
let pageNum = 1;
let maxRangeId = 0;
let currentPage = 1;
let totalPage = 1;
while (pageNum <= totalPage) {
const params = new URLSearchParams({
pageNum: String(pageNum),
pageSize: String(PAGE_SIZE),
maxRangeId: String(maxRangeId)
});
const res = await iccFetch(`${url}?${params.toString()}`, {
method: "GET",
headers: { Accept: "application/json", Authorization: `bearer ${token}` },
signal: AbortSignal.timeout(60000) // 按项目约定 HTTP 超时 60s
});
if (!res.ok) {
throw new Error(`ICC 组织接口返回异常:HTTP ${res.status}`);
}
const payload: any = await res.json();
const page = parseOrgPage(payload);
allRaw.push(...page.list);
result.pulled += page.list.length;
currentPage = page.currentPage;
totalPage = page.totalPage;
// currentPage 达到 totalPage 或返回空列表即停止
if (page.list.length === 0 || currentPage >= totalPage) break;
pageNum++;
}
const { inserted, updated, skipped } = await this.batchUpsert(allRaw, customerService);
result.inserted = inserted;
result.updated = updated;
result.synced = inserted + updated;
result.skipped = skipped;
await this.writeLog(startTime, 1, SYNC_TYPE, result.synced, null);
return result;
} catch (err: any) {
const message = err instanceof Error ? err.message : String(err);
await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message);
return result;
}
}
/** 从匿名请求上下文获取请求作用域的 CustomerService(参照 job_scheduler.buildRuntime) */
private async getCustomerService(): Promise<CustomerService> {
const ctx = this.app.createAnonymousContext();
return (await ctx.requestContext.getAsync(CustomerService)) as CustomerService;
}
/** 构建行政区划字典:org_province/org_city/org_county 的 编码字段 → orgName(名称)。 */
private buildRegionDict(allRaw: IccOrgRawRecord[]): {
provinceDict: Record<string, string>;
cityDict: Record<string, string>;
countyDict: Record<string, string>;
} {
const provinceDict: Record<string, string> = {};
const cityDict: Record<string, string> = {};
const countyDict: Record<string, string> = {};
for (const raw of allRaw) {
const orgType = String(raw.orgType ?? "");
const orgName = String(raw.orgName ?? "").trim();
if (!orgName) continue;
if (orgType === "org_province" && raw.province) {
provinceDict[String(raw.province).trim()] = orgName;
} else if (orgType === "org_city" && raw.city) {
cityDict[String(raw.city).trim()] = orgName;
} else if (orgType === "org_county" && raw.county) {
countyDict[String(raw.county).trim()] = orgName;
}
}
return { provinceDict, cityDict, countyDict };
}
/** 按 iccCode 幂等 upsert(新增/变更四字段:客户名、省、市、区)。 */
private async batchUpsert(allRaw: IccOrgRawRecord[], customerService: CustomerService): Promise<{ inserted: number; updated: number; skipped: number }> {
let skipped = 0;
// 从全量组织树构建行政区划字典(ICC 的 org_base 记录 province/city/county 为
// 国标行政区划编码,客户表存名称;org_province/org_city/org_county 记录的
// province/city/county 编码字段 + orgName 即为 编码→名称 映射)
const { provinceDict, cityDict, countyDict } = this.buildRegionDict(allRaw);
// 仅保留 org_base 组织(客户);组织类型缺失或非 org_base 均跳过,避免误同步非客户组织
const mappedList: MappedOrg[] = [];
for (const raw of allRaw) {
const mapped = mapIccOrgRecord(raw);
if (mapped.orgType !== ICC_ORG_TYPE_BASE) {
skipped++;
continue;
}
// 行政区划编码 → 名称;字典未命中时保留编码原值
if (mapped.province && provinceDict[mapped.province]) mapped.province = provinceDict[mapped.province];
if (mapped.city && cityDict[mapped.city]) mapped.city = cityDict[mapped.city];
if (mapped.district && countyDict[mapped.district]) mapped.district = countyDict[mapped.district];
mappedList.push(mapped);
}
// 预取已存在客户(按 iccCode),避免 N+1;需四字段现值用于变更比较
const iccCodes = [...new Set(mappedList.map((m) => m.iccCode).filter((c) => c !== ""))];
const existingRows = iccCodes.length
? await this.customerModel.findAll({
where: { iccCode: { [Op.in]: iccCodes } },
attributes: ["id", "iccCode", "customerName", "province", "city", "district"]
})
: [];
const customerByIcc = new Map(existingRows.map((c) => [c.iccCode, c]));
let inserted = 0;
let updated = 0;
for (const mapped of mappedList) {
if (!mapped.iccCode) {
skipped++; // iccCode 缺失,无同步匹配键
continue;
}
if (!mapped.customerName) {
skipped++; // 客户名必填(customer.customer_name NOT NULL),缺失跳过避免脏数据
continue;
}
const existing = customerByIcc.get(mapped.iccCode);
if (existing) {
if (this.hasFieldDiff(existing, mapped)) {
await existing.update({
customerName: mapped.customerName,
province: mapped.province ?? null,
city: mapped.city ?? null,
district: mapped.district ?? null
});
updated++;
} else {
skipped++; // 四字段均无变化,不重复更新
}
} else {
if (await this.createCustomer(mapped, customerService)) {
inserted++;
} else {
skipped++;
}
}
}
return { inserted, updated, skipped };
}
/** 四字段变更比较:字符串 trim 后比较,undefined/null 视为空串。 */
private hasFieldDiff(existing: Customer, mapped: MappedOrg): boolean {
const norm = (v: string | null | undefined): string => (v ?? "").trim();
return (
norm(existing.customerName) !== norm(mapped.customerName) ||
norm(existing.province) !== norm(mapped.province) ||
norm(existing.city) !== norm(mapped.city) ||
norm(existing.district) !== norm(mapped.district)
);
}
/**
* 新增客户:编号走 CustomerService.generateCustomerNo(KH+YYYYMMDD+3位序号)。
* 编号唯一冲突(UniqueConstraintError)时重试一次;仍失败返回 false 由调用方计 skipped。
* 其余异常上抛,由外层 catch 记录同步失败。
*/
private async createCustomer(mapped: MappedOrg, customerService: CustomerService): Promise<boolean> {
const payload: any = {
customerName: mapped.customerName,
province: mapped.province ?? null,
city: mapped.city ?? null,
district: mapped.district ?? null,
iccCode: mapped.iccCode,
status: 1
};
try {
payload.customerNo = await customerService.generateCustomerNo();
await this.customerModel.create(payload);
return true;
} catch (err: any) {
if (err instanceof UniqueConstraintError && err.errors?.some((e) => e.path === "customer_no" || e.path === "uk_customer_no")) {
try {
payload.customerNo = await customerService.generateCustomerNo();
await this.customerModel.create(payload);
return true;
} catch (_e) {
return false;
}
}
throw err;
}
}
private async writeLog(startTime: Date, status: number, syncType: string, recordCount: number, errorMessage: string | null): Promise<void> {
try {
await this.syncLogModel.create({
syncType,
startTime,
endTime: new Date(),
recordCount,
status,
errorMessage
} as any);
} catch (err) {
console.error("[ICC同步] 同步日志写入失败:", err);
}
}
}
@@ -0,0 +1,198 @@
import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import { Context } from "@midwayjs/koa";
import ElectricGateway from "../entity/electric_gateway.entity";
import IccSyncLog from "../entity/icc_sync_log.entity";
import Customer from "../entity/customer.entity";
import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
import { ICC_GATEWAY_ENDPOINT, mapIccGatewayRecord, parseGatewayPage, IccGatewayRawRecord } from "../task/icc_gateway_mapper";
export interface IccGatewaySyncResult {
/** ICC 拉取到的原始设备条数 */
pulled: number;
/** 成功落库的设备条数(新增+更新) */
synced: number;
/** 新增的设备条数 */
inserted: number;
/** 更新的设备条数 */
updated: number;
/** 跳过的条数(缺 imei/deviceCode / orgCode 匹配不到客户) */
skipped: number;
}
const SYNC_TYPE = "electric_gateway";
/** 分页拉取参数(ICC 设备分页接口约定,短页判定阈值) */
const PAGE_SIZE = 50;
/** 最大分页数保护,防止异常接口导致无限拉取 */
const MAX_PAGES = 100;
/** ICC_VERSION 缺省值(与 icc_customer_auth 保持一致) */
const DEFAULT_VERSION = "1.0.0";
/** HTTP 超时 60s(项目约定) */
const HTTP_TIMEOUT_MS = 60000;
/**
* ICC 智电改造网关设备拉取同步:
* 1) 复用 icc_customer_auth 的真实 OAuth2 密码模式鉴权(getIccAccessToken / iccFetch),
* 从 .env 读取 ICC_BASE_URL / ICC_VERSION
* 2) POST 分页请求 ICC 设备列表(/device/subsystem/page,pageNum/pageSize=50,全量拉取)
* 3) 预取 orgCode→customer.id 映射(customer.icc_code),避免 N+1 查询客户
* 4) 按 imei/deviceCode 幂等 upsert 写入 electric_gateway,更新 synced_at
* 5) 每次同步写一条 icc_sync_log;拉取失败仅记录 error,不抛业务错误
*/
@Provide()
export class IccGatewaySyncService {
@InjectRepository(ElectricGateway)
gatewayModel!: Repository<ElectricGateway>;
@InjectRepository(IccSyncLog)
syncLogModel!: Repository<IccSyncLog>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
async syncFromIcc(ctx?: Context): Promise<IccGatewaySyncResult> {
const startTime = new Date();
const baseUrl = process.env.ICC_BASE_URL?.trim();
const result: IccGatewaySyncResult = { pulled: 0, synced: 0, inserted: 0, updated: 0, skipped: 0 };
// 基础配置缺失视为拉取失败:记录日志,不抛业务错误
if (!baseUrl) {
await this.writeLog(startTime, 0, SYNC_TYPE, 0, "未配置 ICC_BASE_URL,请检查 server/.env");
return result;
}
try {
const token = await getIccAccessToken();
const url = `${baseUrl.replace(/\/$/, "")}${ICC_GATEWAY_ENDPOINT.replace("{version}", process.env.ICC_VERSION?.trim() || DEFAULT_VERSION)}`;
const allRaw: IccGatewayRawRecord[] = [];
let pageNum = 1;
while (pageNum <= MAX_PAGES) {
const res = await iccFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `bearer ${token}`
},
body: JSON.stringify({ pageNum, pageSize: PAGE_SIZE }), // 不传 typeCode,全量拉取
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
});
if (!res.ok) {
throw new Error(`ICC 网关设备接口返回异常:HTTP ${res.status}`);
}
const payload: any = await res.json();
const page = parseGatewayPage(payload);
allRaw.push(...page.list);
result.pulled += page.list.length;
// 短页判定:本页不满 pageSize 视为无下一页;另有最大页数保护
if (page.list.length < PAGE_SIZE) break;
pageNum++;
}
const { inserted, updated, skipped } = await this.batchUpsert(allRaw);
result.inserted = inserted;
result.updated = updated;
result.synced = inserted + updated;
result.skipped = skipped;
await this.writeLog(startTime, 1, SYNC_TYPE, result.synced, null);
return result;
} catch (err: any) {
const message = err instanceof Error ? err.message : String(err);
await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message);
return result;
}
}
/** 按 imei/deviceCode 幂等 upsert(幂等),customer_id 解析失败的行跳过。 */
private async batchUpsert(records: IccGatewayRawRecord[]): Promise<{ inserted: number; updated: number; skipped: number }> {
let inserted = 0;
let updated = 0;
let skipped = 0;
if (records.length === 0) return { inserted, updated, skipped };
// 预取 orgCode → customer.id 映射,避免 N+1 查询客户
const orgCodes = [...new Set(records.map((r) => mapIccGatewayRecord(r).orgCode).filter(Boolean))];
const customers = orgCodes.length
? await this.customerModel.findAll({ where: { iccCode: { [Op.in]: orgCodes } }, attributes: ["id", "iccCode"] })
: [];
const customerIdByOrgCode = new Map(customers.map((c) => [c.iccCode, c.id]));
const now = new Date();
for (const raw of records) {
const mapped = mapIccGatewayRecord(raw);
// imei 与 deviceCode 都为空则无同步匹配键,跳过
if (!mapped.imei && !mapped.deviceCode) {
skipped++;
continue;
}
// customer_id NOT NULL:orgCode 必须能解析到客户,否则跳过
if (!mapped.orgCode) {
skipped++;
continue;
}
const customerId = customerIdByOrgCode.get(mapped.orgCode);
if (!customerId) {
skipped++;
continue;
}
const payload = {
iccImei: mapped.imei || null,
deviceCode: mapped.deviceCode || null,
deviceName: mapped.deviceName || null,
customerId,
orgCode: mapped.orgCode || null,
orgName: mapped.orgName || null,
manufacturerCode: mapped.manufacturerCode || null,
manufacturerName: mapped.manufacturerName || null,
typeCode: mapped.typeCode || null,
typeName: mapped.typeName || null,
productModel: mapped.productModel || null,
isOnline: mapped.isOnline,
syncedAt: now
} as any;
// 幂等 upsert:按非空键匹配(仅包含非空键)
const matchWhere: any[] = [];
if (mapped.imei) matchWhere.push({ iccImei: mapped.imei });
if (mapped.deviceCode) matchWhere.push({ deviceCode: mapped.deviceCode });
const existing = await this.gatewayModel.findOne({ where: { [Op.or]: matchWhere } });
if (existing) {
await existing.update({ ...payload, syncedAt: now });
updated++;
} else {
await this.gatewayModel.create(payload);
inserted++;
}
}
return { inserted, updated, skipped };
}
private async writeLog(startTime: Date, status: number, syncType: string, recordCount: number, errorMessage: string | null): Promise<void> {
try {
await this.syncLogModel.create({
syncType,
startTime,
endTime: new Date(),
recordCount,
status,
errorMessage
} as any);
} catch (err) {
console.error("[ICC同步] 同步日志写入失败:", err);
}
}
}
+211
View File
@@ -0,0 +1,211 @@
import { Provide } from "@midwayjs/core";
import { InjectRepository } from "@midwayjs/sequelize";
import { Repository } from "sequelize-typescript";
import { Op } from "sequelize";
import { Context } from "@midwayjs/koa";
import ElectricLine from "../entity/electric_line.entity";
import IccSyncLog from "../entity/icc_sync_log.entity";
import Customer from "../entity/customer.entity";
import ElectricGateway from "../entity/electric_gateway.entity";
import { getIccAccessToken, iccFetch } from "../task/icc_customer_auth";
import { ICC_LINE_ENDPOINT, mapIccLineRecord, parseLinePage, IccLineRawRecord } from "../task/icc_line_mapper";
export interface IccLineSyncResult {
/** ICC 拉取到的原始探测器条数 */
pulled: number;
/** 成功落库的线路条数(新增+更新) */
synced: number;
/** 新增的线路条数 */
inserted: number;
/** 更新的线路条数 */
updated: number;
/** 跳过的条数(缺 lineCode / orgCode 匹配不到客户) */
skipped: number;
}
const SYNC_TYPE = "electric_line";
/** 分页拉取参数(ICC 探测器分页接口约定,短页判定阈值) */
const PAGE_SIZE = 50;
/** 最大分页数保护,防止异常接口导致无限拉取 */
const MAX_PAGES = 100;
/** ICC_VERSION 缺省值(与 icc_customer_auth 保持一致) */
const DEFAULT_VERSION = "1.0.0";
/** HTTP 超时 60s(项目约定) */
const HTTP_TIMEOUT_MS = 60000;
/**
* ICC 智电改造线路(消防探测器)拉取同步:
* 1) 复用 icc_customer_auth 的真实 OAuth2 密码模式鉴权(getIccAccessToken / iccFetch),
* 从 .env 读取 ICC_BASE_URL / ICC_VERSION
* 2) POST 分页请求 ICC 探测器列表(/detector/subsystem/page,pageNum/pageSize=50,全量拉取)
* 3) 预取 orgCode→customer.id 映射(customer.icc_code)与 parentCode→gateway.id 映射(electric_gateway.device_code),避免 N+1 查询
* 4) 按 line_code(detectorCode)幂等 upsert 写入 electric_line,更新 synced_at
* 5) 每次同步写一条 icc_sync_log;拉取失败仅记录 error,不抛业务错误
*/
@Provide()
export class IccLineSyncService {
@InjectRepository(ElectricLine)
lineModel!: Repository<ElectricLine>;
@InjectRepository(IccSyncLog)
syncLogModel!: Repository<IccSyncLog>;
@InjectRepository(Customer)
customerModel!: Repository<Customer>;
@InjectRepository(ElectricGateway)
gatewayModel!: Repository<ElectricGateway>;
async syncFromIcc(ctx?: Context): Promise<IccLineSyncResult> {
const startTime = new Date();
const baseUrl = process.env.ICC_BASE_URL?.trim();
const result: IccLineSyncResult = { pulled: 0, synced: 0, inserted: 0, updated: 0, skipped: 0 };
// 基础配置缺失视为拉取失败:记录日志,不抛业务错误
if (!baseUrl) {
await this.writeLog(startTime, 0, SYNC_TYPE, 0, "未配置 ICC_BASE_URL,请检查 server/.env");
return result;
}
try {
const token = await getIccAccessToken();
const url = `${baseUrl.replace(/\/$/, "")}${ICC_LINE_ENDPOINT.replace("{version}", process.env.ICC_VERSION?.trim() || DEFAULT_VERSION)}`;
const allRaw: IccLineRawRecord[] = [];
let pageNum = 1;
while (pageNum <= MAX_PAGES) {
const res = await iccFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `bearer ${token}`
},
body: JSON.stringify({ pageNum, pageSize: PAGE_SIZE }), // 不传额外筛选条件,全量拉取
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
});
if (!res.ok) {
throw new Error(`ICC 探测器接口返回异常:HTTP ${res.status}`);
}
const payload: any = await res.json();
const page = parseLinePage(payload);
allRaw.push(...page.list);
result.pulled += page.list.length;
// 短页判定:本页不满 pageSize 视为无下一页;另有最大页数保护
if (page.list.length < PAGE_SIZE) break;
pageNum++;
}
const { inserted, updated, skipped } = await this.batchUpsert(allRaw);
result.inserted = inserted;
result.updated = updated;
result.synced = inserted + updated;
result.skipped = skipped;
await this.writeLog(startTime, 1, SYNC_TYPE, result.synced, null);
return result;
} catch (err: any) {
const message = err instanceof Error ? err.message : String(err);
await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message);
return result;
}
}
/** 按 line_code(detectorCode)幂等 upsert,customer_id 解析失败的行跳过。 */
private async batchUpsert(records: IccLineRawRecord[]): Promise<{ inserted: number; updated: number; skipped: number }> {
let inserted = 0;
let updated = 0;
let skipped = 0;
if (records.length === 0) return { inserted, updated, skipped };
// 预取 orgCode → customer.id 映射,避免 N+1 查询客户
const orgCodes = [...new Set(records.map((r) => mapIccLineRecord(r).orgCode).filter(Boolean))];
const customers = orgCodes.length
? await this.customerModel.findAll({ where: { iccCode: { [Op.in]: orgCodes } }, attributes: ["id", "iccCode"] })
: [];
const customerIdByOrgCode = new Map(customers.map((c) => [c.iccCode, c.id]));
// 预取 parentCode → gateway.id 映射(electric_gateway.device_code),避免 N+1 查询网关
const parentCodes = [...new Set(records.map((r) => mapIccLineRecord(r).parentCode).filter(Boolean))];
const gateways = parentCodes.length
? await this.gatewayModel.findAll({ where: { deviceCode: { [Op.in]: parentCodes } }, attributes: ["id", "deviceCode"] })
: [];
const gatewayIdByParentCode = new Map(gateways.map((g) => [g.deviceCode, g.id]));
const now = new Date();
for (const raw of records) {
const mapped = mapIccLineRecord(raw);
// line_code(detectorCode)为空则无同步匹配键,跳过
if (!mapped.lineCode) {
skipped++;
continue;
}
// customer_id NOT NULL:orgCode 必须能解析到客户,否则跳过
if (!mapped.orgCode) {
skipped++;
continue;
}
const customerId = customerIdByOrgCode.get(mapped.orgCode);
if (!customerId) {
skipped++;
continue;
}
// gateway_id 可空:parentCode 匹配不到 electric_gateway.device_code 时置空
const gatewayId = mapped.parentCode ? (gatewayIdByParentCode.get(mapped.parentCode) ?? null) : null;
const payload = {
lineCode: mapped.lineCode,
lineName: mapped.lineName || null,
imei: mapped.imei || null,
// equipId 同步不填充
gatewayId,
customerId,
typeCode: mapped.typeCode || null,
typeName: mapped.typeName || null,
fireSystemCode: mapped.fireSystemCode || null,
fireSystemName: mapped.fireSystemName || null,
partUnitLoopCode: mapped.partUnitLoopCode || null,
channelNo: mapped.channelNo || null,
channelSn: mapped.channelSn || null,
produceDate: mapped.produceDate || null,
status: mapped.status,
syncedAt: now
} as any;
// 幂等 upsert:按 line_code 唯一键匹配
const existing = await this.lineModel.findOne({ where: { lineCode: mapped.lineCode } });
if (existing) {
await existing.update({ ...payload, syncedAt: now });
updated++;
} else {
await this.lineModel.create(payload);
inserted++;
}
}
return { inserted, updated, skipped };
}
private async writeLog(startTime: Date, status: number, syncType: string, recordCount: number, errorMessage: string | null): Promise<void> {
try {
await this.syncLogModel.create({
syncType,
startTime,
endTime: new Date(),
recordCount,
status,
errorMessage
} as any);
} catch (err) {
console.error("[ICC同步] 同步日志写入失败:", err);
}
}
}
+74 -3
View File
@@ -8,6 +8,10 @@ import SysJobLog from "../entity/sys_job_log.entity";
import { JobSchedulerService } from "./job_scheduler.service";
import { getBuiltinJob } from "../task/job_registry";
import { UpdateJobDTO, JobLogQueryDTO } from "../dto/job.dto";
import { FreqConfig, FreqType, FREQ_TYPES, buildSchedule, describeCron } from "../util/cron_schedule";
/** 频率预览返回的未来执行次数 */
const PREVIEW_RUN_COUNT = 5;
@Provide()
export class JobService {
@@ -20,6 +24,29 @@ export class JobService {
@Inject()
scheduler!: JobSchedulerService;
/**
* 解析任务的频率配置:非法的频率类型或残缺的配置统一降级为「自定义」,
* 保证配置界面每次都能拿到可原样回填、可再次保存的配置。
*/
private resolveFreq(job: SysJob): { freqType: FreqType; freqConfig: FreqConfig; description: string } {
const storedType = FREQ_TYPES.includes(job.freqType as FreqType) ? (job.freqType as FreqType) : "custom";
const schedule = buildSchedule(storedType, job.freqConfig);
if (schedule.valid) {
return {
freqType: storedType,
freqConfig: schedule.freqConfig ?? {},
description: schedule.description ?? describeCron(job.cronExpression)
};
}
return {
freqType: "custom",
freqConfig: { expression: job.cronExpression },
description: describeCron(job.cronExpression)
};
}
/** 任务列表:按任务名称模糊 + 状态筛选,附带注册表信息与下次执行时间 */
async list(query: { page?: any; pageSize?: any; jobName?: string; status?: any }): Promise<any> {
const page = Number(query.page) || 1;
@@ -45,9 +72,13 @@ export class JobService {
const list = rows.map(job => {
const builtin = getBuiltinJob(job.jobKey);
const registered = !!builtin;
const freq = this.resolveFreq(job);
return {
...job.toJSON(),
freqType: freq.freqType,
freqConfig: freq.freqConfig,
cronDescription: freq.description,
description: builtin ? builtin.description : null,
paramFields: builtin ? builtin.paramFields : [],
registered,
@@ -62,7 +93,7 @@ export class JobService {
};
}
/** 更新任务配置:cron 合法性 + 参数白名单/范围校验,白名单之外的 key 直接丢弃 */
/** 更新任务配置:频率配置翻译 cron 并校验合法性 + 参数白名单/范围校验,白名单之外的 key 直接丢弃 */
async update(id: number, dto: UpdateJobDTO): Promise<any> {
const job = await this.jobModel.findByPk(id);
@@ -75,7 +106,14 @@ export class JobService {
return { code: 400, message: "任务实现不存在", data: null };
}
if (!this.scheduler.validateCron(dto.cronExpression)) {
// 频率配置 → cron:非法返回具体错误,合法则同时拿到规范化配置用于落库
const schedule = buildSchedule(dto.freqType, dto.freqConfig);
if (!schedule.valid) {
return { code: 400, message: schedule.error ?? "频率配置不合法", data: null };
}
const cronExpression = schedule.cronExpression ?? "";
if (!this.scheduler.validateCron(cronExpression)) {
return { code: 400, message: "cron 表达式不合法", data: null };
}
@@ -122,7 +160,9 @@ export class JobService {
}
await job.update({
cronExpression: dto.cronExpression,
cronExpression,
freqType: dto.freqType,
freqConfig: schedule.freqConfig ?? {},
params,
retryTimes: dto.retryTimes,
retryInterval: dto.retryInterval,
@@ -134,6 +174,37 @@ export class JobService {
return { code: 200, message: "更新定时任务成功", data: null };
}
/**
* 频率预览:由「人性化频率配置」计算 cron 表达式、中文描述与未来若干次执行时间。
* 配置不完整/表达式非法时仍返回 200,通过 valid/error 让前端就地提示,避免弹窗报错打断填写。
*/
async previewCron(freqType: string, freqConfig?: Record<string, any>): Promise<any> {
const schedule = buildSchedule(freqType, freqConfig);
if (!schedule.valid) {
return {
code: 200,
message: "频率配置不完整",
data: { valid: false, error: schedule.error, cronExpression: null, description: null, nextRuns: [] }
};
}
const cronExpression = schedule.cronExpression ?? "";
const valid = this.scheduler.validateCron(cronExpression);
return {
code: 200,
message: "预览成功",
data: {
valid,
error: valid ? null : "cron 表达式不合法",
cronExpression,
description: schedule.description ?? null,
nextRuns: valid ? this.scheduler.nextRunTimes(cronExpression, PREVIEW_RUN_COUNT) : []
}
};
}
/** 启停任务:更新状态后即时重载调度 */
async changeStatus(id: number, status: number): Promise<any> {
const job = await this.jobModel.findByPk(id);
@@ -8,6 +8,9 @@ import SysJob from "../entity/sys_job.entity";
import SysJobLog from "../entity/sys_job_log.entity";
import { LogService } from "./log.service";
import { NoticeRuleService } from "./notice_rule.service";
import { IccLineSyncService } from "./icc_line_sync.service";
import { IccCustomerSyncService } from "./icc_customer_sync.service";
import { IccGatewaySyncService } from "./icc_gateway_sync.service";
import { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry";
/** 任务执行结果(一次尝试) */
@@ -138,6 +141,21 @@ export class JobSchedulerService {
}
}
/** 计算未来 count 次执行时间(配置界面预览用);表达式非法返回空数组 */
nextRunTimes(cronExpression: string, count: number): Date[] {
if (!this.validateCron(cronExpression)) {
return [];
}
try {
return new CronTime(cronExpression, TIMEZONE)
.sendAt(count)
.map(date => date.toJSDate());
} catch {
return [];
}
}
/** 清理 sys_job_log 中早于 keepDays 天的记录,返回删除条数 */
async cleanJobLogs(keepDays: number): Promise<number> {
const cutoff = dayjs().subtract(keepDays, "day").toDate();
@@ -430,6 +448,24 @@ export class JobSchedulerService {
const ctx = this.app.createAnonymousContext();
const noticeRuleService = (await ctx.requestContext.getAsync(NoticeRuleService)) as NoticeRuleService;
return noticeRuleService.executeScan();
},
syncIccLines: async () => {
const ctx = this.app.createAnonymousContext();
const iccService = (await ctx.requestContext.getAsync(IccLineSyncService)) as IccLineSyncService;
const result = await iccService.syncFromIcc();
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
},
syncIccCustomers: async () => {
const ctx = this.app.createAnonymousContext();
const iccCustomerService = (await ctx.requestContext.getAsync(IccCustomerSyncService)) as IccCustomerSyncService;
const result = await iccCustomerService.syncFromIcc();
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
},
syncIccGateways: async () => {
const ctx = this.app.createAnonymousContext();
const iccGatewayService = (await ctx.requestContext.getAsync(IccGatewaySyncService)) as IccGatewaySyncService;
const result = await iccGatewayService.syncFromIcc();
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
}
};
}
+69 -103
View File
@@ -6,8 +6,7 @@ import { Context } from "@midwayjs/koa";
import SurveyOrder from "../entity/survey_order.entity";
import Customer from "../entity/customer.entity";
import Worker from "../entity/worker.entity";
import SurveySopItem from "../entity/survey_sop_item.entity";
import SurveySopResult from "../entity/survey_sop_result.entity";
import SurveyDetail from "../entity/survey_detail.entity";
import DispatchPrice from "../entity/dispatch_price.entity";
import SysUser from "../entity/sysUser.entity";
import SysUserRole from "../entity/sysUserRole.entity";
@@ -18,8 +17,6 @@ import { NoticeRuleService } from "./notice_rule.service";
// 勘察工单状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消
const STATUS = { PENDING_ASSIGN: 0, SURVEYING: 1, PENDING_REVIEW: 2, ARCHIVED: 3, RESERVED: 4, CANCELED: 5 };
// 默认 SOP 模板编码
const DEFAULT_TEMPLATE = "standard_dist";
// 默认到场时限(分钟)与缺省报价(dispatch_price 缺失时兜底,不写库)
const DEFAULT_EXPECT_MINUTES = 45;
const DEFAULT_BASE_FEE = 350;
@@ -38,11 +35,8 @@ export class SurveyOrderService {
@InjectRepository(Worker)
workerModel!: Repository<Worker>;
@InjectRepository(SurveySopItem)
sopItemModel!: Repository<SurveySopItem>;
@InjectRepository(SurveySopResult)
sopResultModel!: Repository<SurveySopResult>;
@InjectRepository(SurveyDetail)
detailModel!: Repository<SurveyDetail>;
@InjectRepository(DispatchPrice)
priceModel!: Repository<DispatchPrice>;
@@ -257,13 +251,50 @@ export class SurveyOrderService {
return { baseFee, loopFee, loopCount: count, total };
}
// 按模板批量生成回执记录(checked=false)
private async createSopResults(orderId: number, templateCode: string): Promise<void> {
const items = await this.sopItemModel.findAll({ where: { templateCode: templateCode || DEFAULT_TEMPLATE } });
if (items.length === 0) return;
await this.sopResultModel.bulkCreate(
items.map((item) => ({ surveyOrderId: orderId, sopItemId: item.id, checked: false })) as any
);
// 明细入参清洗:photos 数组转 JSON 字符串,建筑/楼层缺失的行丢弃
private normalizeDetails(input: any): any[] {
if (!Array.isArray(input)) return [];
return input
.map((row) => {
const photos = this.parsePhotos(row?.photos);
const buildingId = Number(row?.buildingId) || null;
const floorNo = row?.floorNo === undefined || row?.floorNo === null ? "" : String(row.floorNo);
const roomId = Number(row?.roomId) || null;
return {
buildingId,
buildingName: row?.buildingName ? String(row.buildingName).slice(0, 128) : null,
floorNo: floorNo.slice(0, 16),
roomId,
roomName: row?.roomName ? String(row.roomName).slice(0, 128) : null,
photos: photos.length ? JSON.stringify(photos) : null,
remark: row?.remark ? String(row.remark).slice(0, 255) : null
};
})
// 建筑与楼层是明细的定位要素,缺失的行直接丢弃
.filter((row) => row.buildingId && row.floorNo);
}
// 全量替换工单明细(管理端与电工端共用;调用方负责权限与状态校验)
async replaceDetails(orderId: number, details: any): Promise<void> {
const rows = this.normalizeDetails(details);
await this.detailModel.destroy({ where: { surveyOrderId: orderId } });
if (rows.length === 0) return;
await this.detailModel.bulkCreate(rows.map((row) => ({ ...row, surveyOrderId: orderId })) as any);
}
// 工单明细 + 照片预签名访问 URL(管理端详情与电工端订单详情共用)
async getDetails(orderId: number): Promise<any[]> {
const results = await this.detailModel.findAll({ where: { surveyOrderId: orderId }, order: [["id", "ASC"]] });
if (results.length === 0) return [];
const rows = results.map((r) => {
const json: any = r.toJSON();
return { ...json, photos: this.parsePhotos(json.photos) };
});
const keys = [...new Set<string>(rows.flatMap((r) => r.photos))];
const urlMap = keys.length ? await this.uploadService.getPresignedUrlMap(keys) : {};
return rows.map((row) => ({ ...row, photoUrls: row.photos.map((k: string) => urlMap[k] || "") }));
}
async list(query: any, ctx: Context): Promise<any> {
@@ -342,13 +373,6 @@ export class SurveyOrderService {
};
}
// SOP 模板检查项
async listSopItems(templateCode?: string): Promise<any> {
const code = templateCode || DEFAULT_TEMPLATE;
const rows = await this.sopItemModel.findAll({ where: { templateCode: code }, order: [["sort_order", "ASC"]] });
return { code: 200, message: "获取勘察SOP检查项成功", data: rows.map((r) => r.toJSON()) };
}
// 报酬试算
async quote(loopCount?: string): Promise<any> {
const data = await this.calcFee(Number(loopCount) || 0);
@@ -362,7 +386,7 @@ export class SurveyOrderService {
const vo = this.toVO(record);
await this.decorate([vo]);
vo.sopResults = await this.getSopResults(id);
vo.details = await this.getDetails(id);
// 现场取证照片:把对象 key 批量换取预签名访问 URL,供详情只读视图直接渲染
const photoKeys: string[] = vo.photos || [];
const urlMap = photoKeys.length ? await this.uploadService.getPresignedUrlMap(photoKeys) : {};
@@ -370,84 +394,17 @@ export class SurveyOrderService {
return { code: 200, message: "获取勘察派单详情成功", data: vo };
}
// 工单回执 + 检查项信息(手动 join,避免额外关联);receiptValue 以 key 数组出参,并补预签名访问 URL
private async getSopResults(orderId: number): Promise<any[]> {
const results = await this.sopResultModel.findAll({ where: { surveyOrderId: orderId }, order: [["id", "ASC"]] });
if (results.length === 0) return [];
const itemIds = [...new Set(results.map((r) => r.sopItemId).filter(Boolean))];
const items = await this.sopItemModel.findAll({ where: { id: { [Op.in]: itemIds } } });
const itemMap = new Map(items.map((i) => [i.id, i]));
const rows = results.map((r) => {
const json: any = r.toJSON();
const item: any = itemMap.get(json.sopItemId);
return {
...json,
receiptValue: this.parsePhotos(json.receiptValue),
itemName: item?.itemName || "",
itemDesc: item?.itemDesc || "",
receiptType: item?.receiptType || "",
receiptRequire: item?.receiptRequire || "",
required: item?.required ?? false
};
});
// photo/meter 类回执是照片 key,批量换取预签名 URL 供前端直接渲染;form 类是文本值,不参与换取
const keys = [...new Set<string>(rows.filter((r) => r.receiptType !== "form").flatMap((r) => r.receiptValue))];
const urlMap = keys.length ? await this.uploadService.getPresignedUrlMap(keys) : {};
return rows.map((row) => ({
...row,
receiptUrls: row.receiptType === "form" ? [] : row.receiptValue.map((k: string) => urlMap[k] || "")
}));
}
// 保存 SOP 工单回执(增量:只更新传入的检查项,未传入项保持原值)
async saveSopResults(id: number, dto: any, ctx: Context): Promise<any> {
// 保存工单明细(勘察明细整体替换:新增/编辑/删除均由前端以全量数组提交)
async saveDetails(id: number, dto: any, ctx: Context): Promise<any> {
const record = await this.surveyModel.findByPk(id);
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
if (!(await this.isOrderVisible(ctx.state.user.userId, record))) return { code: 403, message: "无权操作该工单", data: null };
if (record.status !== STATUS.SURVEYING) return { code: 400, message: "仅现场勘察中的工单可录入回执", data: null };
const incoming: any[] = Array.isArray(dto?.results) ? dto.results : [];
if (incoming.length === 0) return { code: 400, message: "回执内容不能为空", data: null };
// 仅允许更新本工单已生成的回执记录,防止跨工单/跨检查项写入
const existing = await this.sopResultModel.findAll({ where: { surveyOrderId: id } });
const resultMap = new Map(existing.map((r) => [Number(r.sopItemId), r]));
let updated = 0;
const skipped: number[] = [];
for (const item of incoming) {
const sopItemId = Number(item?.sopItemId);
const target = resultMap.get(sopItemId);
if (!target) {
skipped.push(sopItemId);
continue;
if (record.status !== STATUS.PENDING_ASSIGN && record.status !== STATUS.SURVEYING) {
return { code: 400, message: "仅待指派或现场勘察中的工单可维护勘察明细", data: null };
}
const patch: any = {};
if (item.checked !== undefined) {
const checked = !!item.checked;
patch.checked = checked;
// 首次标记完成时记录回执时间,回退未完成则清空
patch.checkedAt = checked ? target.checkedAt || new Date() : null;
}
if (item.receiptValue !== undefined) {
const keys = this.parsePhotos(item.receiptValue);
patch.receiptValue = keys.length ? JSON.stringify(keys) : null;
}
if (item.remark !== undefined) patch.remark = item.remark || null;
if (Object.keys(patch).length === 0) continue;
await target.update(patch);
updated++;
}
return {
code: 200,
message: "保存勘察回执成功",
data: { updated, skipped, sopResults: await this.getSopResults(id) }
};
await this.replaceDetails(id, dto?.details);
return { code: 200, message: "保存勘察明细成功", data: await this.getDetails(id) };
}
async create(data: any, ctx: Context): Promise<any> {
@@ -456,9 +413,8 @@ export class SurveyOrderService {
if (!(await this.isCustomerOwner(userId, data.customerId))) return { code: 403, message: "无权限为该客户创建勘察派单", data: null };
if (!isAdmin) data.salesmanId = userId;
const templateCode = data.templateCode || DEFAULT_TEMPLATE;
const payload = this.normalizePayload(data);
delete payload.templateCode;
delete payload.details;
payload.surveyNo = await this.generateSurveyNo();
if (payload.progressTotal === undefined || payload.progressTotal === null) payload.progressTotal = payload.loopCount ?? 0;
@@ -484,9 +440,13 @@ export class SurveyOrderService {
}
}
await this.createSopResults(created.id, templateCode);
// 创建时若携带勘察明细则一并写入
if (Array.isArray(data.details) && data.details.length) {
await this.replaceDetails(created.id, data.details);
}
const vo = this.toVO(created);
await this.decorate([vo]);
vo.details = await this.getDetails(created.id);
await this.logService.logAction(ctx, "勘察派单", "创建", created.surveyNo, `创建勘察派单 ${created.surveyNo}`);
return { code: 200, message: "创建勘察派单成功", data: vo };
}
@@ -503,13 +463,19 @@ export class SurveyOrderService {
delete data.salesmanId;
}
delete data.surveyNo;
delete data.templateCode;
const details = data.details;
delete data.details;
const payload = this.normalizePayload(data);
await record.update(payload);
// 仅当显式传入 details 时才整体替换明细,避免只改主表字段时误清空
if (Array.isArray(details)) {
await this.replaceDetails(id, details);
}
const fresh = await this.surveyModel.findByPk(id);
const vo = this.toVO(fresh!);
await this.decorate([vo]);
vo.details = await this.getDetails(id);
await this.logService.logAction(ctx, "勘察派单", "更新", record.surveyNo, `更新勘察派单 ${record.surveyNo}`);
return { code: 200, message: "更新勘察派单成功", data: vo };
}
@@ -643,8 +609,8 @@ export class SurveyOrderService {
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该勘察派单", data: null };
const surveyNo = record.surveyNo;
// 级联清理回执后删除工单
await this.sopResultModel.destroy({ where: { surveyOrderId: id } });
// 级联清理明细后删除工单
await this.detailModel.destroy({ where: { surveyOrderId: id } });
await record.destroy();
await this.logService.logAction(ctx, "勘察派单", "删除", surveyNo, `删除勘察派单 ${surveyNo}`);
return { code: 200, message: "删除勘察派单成功", data: null };
+23 -4
View File
@@ -104,10 +104,12 @@ export class WorkerService {
};
}
// 剔除敏感字段
// 剔除敏感字段;额外返回是否已开通 APP 登录账号
private cleanWorker(worker: Worker): any {
const data = worker.toJSON ? worker.toJSON() : { ...worker };
const hasAccount = !!data.passwordHash;
delete data.passwordHash;
data.hasAccount = hasAccount;
return data;
}
@@ -171,7 +173,6 @@ export class WorkerService {
if (certStatus) {
const rows = await this.workerModel.findAll({
where,
attributes: { exclude: ["passwordHash"] },
order: [["rating", "DESC"]]
});
const certMap = await this.loadCertMap(rows.map((r) => Number(r.id)));
@@ -195,7 +196,6 @@ export class WorkerService {
// 常规分页:仅对当前页电工批量加载证书
const { count, rows } = await this.workerModel.findAndCountAll({
where,
attributes: { exclude: ["passwordHash"] },
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["rating", "DESC"]],
@@ -240,7 +240,7 @@ export class WorkerService {
// 详情:含完整证书数组
async detail(id: number): Promise<any> {
const worker = await this.workerModel.findByPk(id, { attributes: { exclude: ["passwordHash"] } });
const worker = await this.workerModel.findByPk(id);
if (!worker) {
return { code: 404, message: "电工不存在", data: null };
}
@@ -310,6 +310,10 @@ export class WorkerService {
if (dto.status !== undefined) payload.status = dto.status;
if (dto.rating !== undefined) payload.rating = dto.rating;
if (dto.certInfo !== undefined) payload.certInfo = dto.certInfo;
// 补开 APP 账号:仅对尚未开通(无密码哈希)的电工生效,默认密码为手机号后 6 位
if (dto.withAccount === true && !worker.passwordHash) {
payload.passwordHash = await hashPassword(getDefaultPassword(dto.phone || worker.phone));
}
await worker.update(payload);
@@ -326,6 +330,21 @@ export class WorkerService {
return { code: 200, message: "更新电工成功", data: this.cleanWorker(worker) };
}
// 重置 APP 登录密码:固定重置为手机号后 6 位;未开通账号时一并开通
async resetPassword(id: number): Promise<any> {
const worker = await this.workerModel.findByPk(id);
if (!worker) {
return { code: 404, message: "电工不存在", data: null };
}
if (!worker.phone) {
return { code: 400, message: "该电工未登记手机号,无法重置密码", data: null };
}
await worker.update({ passwordHash: await hashPassword(getDefaultPassword(worker.phone)) });
return { code: 200, message: "重置密码成功", data: this.cleanWorker(worker) };
}
// 删除电工:存在派单记录则拒绝;否则级联删除证书并清理通知
async remove(id: number): Promise<any> {
const worker = await this.workerModel.findByPk(id);
+57 -1
View File
@@ -11,8 +11,11 @@ import RepairDispatch from "../entity/repair_dispatch.entity";
import InstallOrder from "../entity/install_order.entity";
import MaintenanceOrder from "../entity/maintenance_order.entity";
import SurveyOrder from "../entity/survey_order.entity";
import CustomerBuilding from "../entity/customer_building.entity";
import CustomerRoom from "../entity/customer_room.entity";
import Customer from "../entity/customer.entity";
import { TokenUtil } from "../util/token";
import { SurveyOrderService } from "./survey_order.service";
import { hashPassword, comparePassword } from "../util/crypto";
import {
WorkerRegisterDTO,
@@ -69,6 +72,15 @@ export class WorkerAppService {
@InjectRepository(SurveyOrder)
surveyModel!: Repository<SurveyOrder>;
@InjectRepository(CustomerBuilding)
buildingModel!: Repository<CustomerBuilding>;
@InjectRepository(CustomerRoom)
roomModel!: Repository<CustomerRoom>;
@Inject()
surveyOrderService!: SurveyOrderService;
// 接单/完成需要多表原子更新,使用事务保证并发下不重复接单、不重复计数
@InjectDataSource()
sequelize!: Sequelize;
@@ -346,7 +358,51 @@ export class WorkerAppService {
async orderDetail(workerId: number, source: string, id: number): Promise<any> {
const row: any = await this.sourceModel(source).findByPk(id, { include: [this.customerInclude()] });
if (!row) return { code: 404, message: "订单不存在" };
return { code: 200, message: "获取成功", data: this.toOrderView(row.toJSON(), source) };
const data = this.toOrderView(row.toJSON(), source);
// 勘察单追加现场明细(含照片预签名 URL)与客户空间拓扑,供电工现场查看与录入
if (source === "survey") {
const [details, topology] = await Promise.all([
this.surveyOrderService.getDetails(Number(row.id)),
this.customerTopology(Number(row.customerId))
]);
data.details = details;
data.topology = topology;
}
return { code: 200, message: "获取成功", data };
}
// 客户空间拓扑(建筑 + 房间),供勘察现场选择建筑/楼层/房间
private async customerTopology(customerId: number): Promise<any> {
if (!customerId) return { buildings: [], rooms: [] };
const [buildings, rooms] = await Promise.all([
this.buildingModel.findAll({ where: { customerId }, order: [["id", "ASC"]] }),
this.roomModel.findAll({ where: { customerId }, order: [["id", "ASC"]] })
]);
return {
buildings: buildings.map((b) => ({
id: b.id,
buildingName: b.buildingName,
floorCount: Number(b.floorCount) || 0,
floorDownCount: Number(b.floorDownCount) || 0
})),
rooms: rooms.map((r) => ({
id: r.id,
roomName: r.roomName,
buildingId: r.buildingId,
floorNo: r.floorNo || ""
}))
};
}
// 电工维护勘察明细:仅本人已接单且处于「现场勘察中(1)」的勘察单可写入(全量替换)
async saveSurveyDetails(workerId: number, id: number, dto: any): Promise<any> {
const order = await this.surveyModel.findByPk(id);
if (!order) return { code: 404, message: "订单不存在" };
if (Number(order.workerId) !== Number(workerId)) return { code: 403, message: "无权操作该订单" };
if (Number(order.status) !== 1) return { code: 400, message: "仅现场勘察中的订单可维护勘察明细" };
await this.surveyOrderService.replaceDetails(id, dto?.details);
return { code: 200, message: "保存勘察明细成功", data: await this.surveyOrderService.getDetails(id) };
}
// ================= 接单 / 完成 =================
+177
View File
@@ -0,0 +1,177 @@
/**
* ICC 平台(大华 ICC)OAuth2 密码模式鉴权客户端。
*
* 流程(对齐鑫潼 ICC 平台接口文档,host 形如 222.243.55.92:1443):
* 1) GET /evo-apigw/evo-oauth/{version}/oauth/public-key 获取公钥(base64 编码的 X509 SPKI DER)
* 2) RSA/ECB/PKCS1Padding 分块加密密码(每块 ≤117 字节),分块密文 Base64 拼接
* 3) POST /evo-apigw/evo-oauth/{version}/oauth/extend/token 换取 access_token
* 4) 进程内缓存 token,过期前 60 秒视为失效并自动重新鉴权
*
* 配置项(server/.env,均为必填,ICC_VERSION 缺省 1.0.0):
* ICC_BASE_URL / ICC_VERSION / ICC_CLIENT_ID / ICC_CLIENT_SECRET / ICC_USERNAME / ICC_PASSWORD
*/
import { createPublicKey, publicEncrypt, constants } from "node:crypto";
import { fetch as undiciFetch, Agent } from "undici";
/**
* ICC 平台的 HTTPS 证书为非公开受信(自签名)证书,Node 默认 TLS 校验会拒绝。
* 这里为 ICC 请求单独创建关闭证书校验的 Agent(仅作用于 ICC 请求,不影响系统其他 HTTPS 连接)。
* 注意:Node 全局 fetch 不支持外部 undici Agent dispatcher(协议不兼容),故统一使用 undici fetch 封装。
*/
let iccAgent: Agent | null = null;
function getIccAgent(): Agent {
if (!iccAgent) {
iccAgent = new Agent({ connect: { rejectUnauthorized: false } });
}
return iccAgent;
}
/** 带 ICC 专用 Agent 的 fetch:自动附加 dispatcher,调用方无需再传。 */
export function iccFetch(input: any, init?: any): ReturnType<typeof undiciFetch> {
return undiciFetch(input, { ...init, dispatcher: getIccAgent() });
}
/** 鉴权结果:accessToken 与本地过期时间戳(毫秒) */
export interface IccAuthResult {
accessToken: string;
expiresAt: number;
}
/** 获取公钥接口路径({version} 运行时替换) */
const PUBLIC_KEY_ENDPOINT = "/evo-apigw/evo-oauth/{version}/oauth/public-key";
/** 换取 token 接口路径({version} 运行时替换) */
const TOKEN_ENDPOINT = "/evo-apigw/evo-oauth/{version}/oauth/extend/token";
/** 默认 version(.env 未配置 ICC_VERSION 时使用) */
const DEFAULT_VERSION = "1.0.0";
/** RSA/ECB/PKCS1Padding 单块最大明文长度(字节) */
const RSA_MAX_CHUNK_BYTES = 117;
/** token 过期前多少毫秒视为失效并自动续签 */
const REFRESH_LEAD_TIME_MS = 60 * 1000;
/** 响应未返回 expires_in 时的默认有效期(毫秒) */
const DEFAULT_EXPIRES_IN_MS = 7200 * 1000;
/** HTTP 超时 60s(项目约定) */
const HTTP_TIMEOUT_MS = 60000;
/** 换取 token 的请求体(对应 ICC extend/token 接口字段名) */
interface TokenRequestBody {
grant_type: string;
username: string;
password: string;
client_id: string;
client_secret: string;
public_key: string;
}
/** 进程内 token 缓存 */
let cachedAuth: IccAuthResult | null = null;
/** 读取必填配置,缺失时抛出明确提示(指明缺哪个配置键)。 */
function requireConfig(key: string): string {
const value = process.env[key]?.trim();
if (!value) {
throw new Error(`缺少 ICC 鉴权必需配置 ${key},请检查 server/.env`);
}
return value;
}
/** 拼接 baseUrl(去末尾斜杠)与接口路径,并替换 {version} 占位。 */
function buildUrl(pathTemplate: string): string {
const baseUrl = requireConfig("ICC_BASE_URL").replace(/\/$/, "");
const version = process.env.ICC_VERSION?.trim() || DEFAULT_VERSION;
return `${baseUrl}${pathTemplate.replace("{version}", version)}`;
}
/** 获取公钥:返回 base64 编码的 X509 SPKI DER 公钥原文。 */
async function fetchPublicKey(): Promise<string> {
const res = await iccFetch(buildUrl(PUBLIC_KEY_ENDPOINT), {
method: "GET",
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
});
if (!res.ok) {
throw new Error(`获取 ICC 公钥失败:HTTP ${res.status} ${res.statusText}`);
}
const payload: any = await res.json();
if (String(payload?.code ?? "") !== "0" || !payload?.data) {
throw new Error(`获取 ICC 公钥失败:${JSON.stringify(payload)}`);
}
// ICC 返回结构为 { data: { publicKey: "<base64 编码的 X509 SPKI DER 公钥>" } }
return String(payload.data.publicKey ?? payload.data);
}
/**
* RSA 分块加密密码(对齐文档 Java 示例:Cipher.getInstance("RSA") 即
* RSA/ECB/PKCS1Padding,单块最大明文 117 字节),分块密文拼接后整体 Base64。
* 防御:明文为空时直接返回空字符串。
*/
function rsaEncryptPassword(password: string, publicKeyB64: string): string {
const input = Buffer.from(password, "utf8");
if (input.length === 0) return "";
// ICC 返回的 publicKey 为 base64 编码的 X509 SPKI DER 公钥,故 type 用 "spki"
const key = createPublicKey({
key: Buffer.from(publicKeyB64, "base64"),
format: "der",
type: "spki"
});
const chunks: Buffer[] = [];
for (let i = 0; i < input.length; i += RSA_MAX_CHUNK_BYTES) {
const chunk = input.subarray(i, i + RSA_MAX_CHUNK_BYTES);
chunks.push(publicEncrypt({ key, padding: constants.RSA_PKCS1_PADDING }, chunk));
}
return Buffer.concat(chunks).toString("base64");
}
/** 用 RSA 加密后的密码换取 access_token(data.expires_in 单位秒)。 */
async function fetchToken(body: TokenRequestBody): Promise<IccAuthResult> {
const res = await iccFetch(buildUrl(TOKEN_ENDPOINT), {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
});
if (!res.ok) {
throw new Error(`换取 ICC token 失败:HTTP ${res.status} ${res.statusText}`);
}
const payload: any = await res.json();
if (String(payload?.code ?? "") !== "0" || !payload?.data?.access_token) {
throw new Error(`换取 ICC token 失败:${JSON.stringify(payload)}`);
}
const expiresIn = Number(payload.data.expires_in);
const expiresAt =
Date.now() + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1000 : DEFAULT_EXPIRES_IN_MS);
return { accessToken: String(payload.data.access_token), expiresAt };
}
/**
* 获取 ICC access_token(进程内缓存,过期前 60 秒自动重新鉴权)。
* 配置缺失或鉴权失败时抛 Error,由调用方(同步服务)决定如何处理。
*/
export async function getIccAccessToken(): Promise<string> {
// 一次性校验全部必填配置,缺失时明确提示
requireConfig("ICC_BASE_URL");
requireConfig("ICC_CLIENT_ID");
requireConfig("ICC_CLIENT_SECRET");
requireConfig("ICC_USERNAME");
requireConfig("ICC_PASSWORD");
if (cachedAuth && cachedAuth.expiresAt - Date.now() > REFRESH_LEAD_TIME_MS) {
return cachedAuth.accessToken;
}
const publicKey = await fetchPublicKey();
const auth = await fetchToken({
grant_type: "password",
username: requireConfig("ICC_USERNAME"),
password: rsaEncryptPassword(requireConfig("ICC_PASSWORD"), publicKey),
client_id: requireConfig("ICC_CLIENT_ID"),
client_secret: requireConfig("ICC_CLIENT_SECRET"),
public_key: publicKey
});
cachedAuth = auth;
console.log(`[ICC鉴权] access_token 获取成功,有效期至 ${new Date(auth.expiresAt).toISOString()}`);
return auth.accessToken;
}
+87
View File
@@ -0,0 +1,87 @@
/**
* ICC 客户(组织)数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)。
*
* ICC 返回的原始字段名 → 本系统 customer 字段。接入真实 ICC 平台时仅需
* 调整本文件与 fetch 路径即可,业务层无需改动。纯映射逻辑,无 SQL 无 DOM。
*/
export interface IccOrgRawRecord {
[field: string]: any;
}
/** 组织分页列表接口路径({version} 由调用方按 ICC_VERSION 替换) */
export const ICC_ORG_ENDPOINT = "/evo-apigw/evo-brm/{version}/organization/page";
/** 分页请求参数名约定:pageNum / pageSize / maxRangeId(调用方构造请求时使用) */
/** 组织类型过滤常量:仅同步 base 组织(对应客户) */
export const ICC_ORG_TYPE_BASE = "org_base";
/** 原始记录关键字段映射:ICC 返回字段名 → 本系统 customer 字段 */
export const ICC_ORG_FIELDS = {
orgCode: "iccCode", // ICC 客户编码(同步匹配键)
orgName: "customerName", // 组织名称 → 客户名称
province: "province", // 省
city: "city", // 市
county: "district" // 县/区 → 区县
} as const;
/** 将 ICC 原始组织记录映射为本系统 customer 落库字段(字符串 trim,空串转 undefined)。 */
export function mapIccOrgRecord(raw: IccOrgRawRecord): {
iccCode: string;
customerName: string;
province?: string;
city?: string;
district?: string;
orgType?: string;
orgNameRaw?: string;
} {
const result: Record<string, any> = {
orgType: toOptionalString(raw.orgType), // 组织类型,用于 org_base 过滤判断
orgNameRaw: toOptionalString(raw.orgName) // 原始组织名称,便于核对
};
// 遍历 ICC_ORG_FIELDS(ICC 返回字段名 → 本系统字段名),从 raw 按 ICC 字段名取值
for (const [iccField, targetField] of Object.entries(ICC_ORG_FIELDS)) {
const value = raw[iccField];
result[targetField] =
iccField === "orgCode" || iccField === "orgName"
? String(value ?? "").trim() // 匹配键/必填名保持空串语义,由调用方判跳
: toOptionalString(value);
}
return result as any;
}
/** 字符串 trim;null / undefined / 空串统一归一为 undefined。 */
function toOptionalString(value: unknown): string | undefined {
if (value === undefined || value === null) return undefined;
const s = String(value).trim();
return s === "" ? undefined : s;
}
/**
* 解析组织分页响应:
* - payload 为数组 → 直接作为列表
* - payload.data 为 PageVO({ currentPage, totalPage, pageSize, totalRows, pageData })→ 取 pageData
* - 结构不匹配或解析异常 → 返回空列表
*/
export function parseOrgPage(payload: any): {
list: IccOrgRawRecord[];
currentPage: number;
totalPage: number;
} {
try {
if (Array.isArray(payload)) {
return { list: payload, currentPage: 1, totalPage: 1 };
}
const page = payload?.data;
if (page && Array.isArray(page.pageData)) {
return {
list: page.pageData,
currentPage: Number(page.currentPage) || 1,
totalPage: Number(page.totalPage) || 1
};
}
return { list: [], currentPage: 1, totalPage: 1 };
} catch {
return { list: [], currentPage: 1, totalPage: 1 };
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { BuiltinJob } from "./job_registry";
/**
* 客户信息同步任务:从 ICC 平台拉取 org_base 组织并按 iccCode 幂等新增/变更客户,
* 写 icc_sync_log。默认每 10 分钟执行一次。
*/
export const iccCustomerSyncJob: BuiltinJob = {
jobKey: "icc_customer_sync",
jobName: "客户信息同步",
description: "从 ICC 平台拉取 org_base 组织数据并按 iccCode 幂等新增/变更客户(写 icc_sync_log)",
defaultCron: "*/10 * * * *",
defaultParams: {},
paramFields: [],
handler: async (_params, ctx) => {
return ctx.runtime.syncIccCustomers();
}
};
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more