Compare commits
12
Commits
737244fa9e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf0a0d4d31 | ||
|
|
9d3bb13ecf | ||
|
|
be91d04431 | ||
|
|
184b8d03e2 | ||
|
|
22d27a940d | ||
|
|
41765cdf44 | ||
|
|
f72644c24f | ||
|
|
3596462eca | ||
|
|
f8117e33b6 | ||
|
|
850cd92584 | ||
|
|
77891bf4f2 | ||
|
|
d12fa91ec2 |
No files matched your search
@@ -0,0 +1,121 @@
|
|||||||
|
# 空间拓扑与用电架构 · 实现计划
|
||||||
|
|
||||||
|
## Context(为什么做)
|
||||||
|
|
||||||
|
现有「空间拓扑与用电架构」页面(`client/src/views/customer/topology/index.vue`)只实现了「客户 → 建筑 → 房间」三层树 + 建筑/房间增删改,**"用电架构"部分完全缺失**:数据库与后端没有任何承载"用电回路"的表或接口,页面上也没有配电拓扑可视化。
|
||||||
|
|
||||||
|
设计稿 `系统设计/UI/空间拓扑与用电架构/code.html` 定义了三栏工业级布局(左空间树 / 中配电单线拓扑流 / 右节点属性面板)。本次目标:在新表 + 新后端接口的基础上,把该页面补齐为三栏结构,让"空间"与"用电回路"打通。
|
||||||
|
|
||||||
|
## 已确认的边界(用户决策)
|
||||||
|
|
||||||
|
| 项 | 结论 |
|
||||||
|
| ------ | ---------------------------------------- |
|
||||||
|
| 目标形态 | 按设计稿做完整三栏(左树 / 中单线图 / 右属性面板) |
|
||||||
|
| 左树层级 | 客户 → 建筑 → **楼层** → 房间 → **回路**(不要"配电箱"层) |
|
||||||
|
| 楼层 | 由房间/回路按 `floor_no` 分组生成的**虚拟层**,不建表 |
|
||||||
|
| 中间图上层 | 进线柜 / 变压器:**只读展示**,不落库、不做 CRUD |
|
||||||
|
| 中间图末端 | **回路卡片**(一个回路一张卡) |
|
||||||
|
| 实时电气量 | 电压/电流/功率/温度等**一律占位**(显示 `—`),不造假数据、不接采集 |
|
||||||
|
| ICC 映射 | **不做**(设计稿里的网关层、ICC 设备码、告警阈值联动均不实现) |
|
||||||
|
| 覆盖端 | 仅 Web 管理端 `client/` |
|
||||||
|
| 数据来源 | 新建表 + 后端 CRUD 接口 |
|
||||||
|
|
||||||
|
## 数据模型(新增 1 张表)
|
||||||
|
|
||||||
|
`customer_circuit`(客户用电回路)—— 参照 `customer_room` 的建表风格(`database/db_init.sql#L364-L387`):
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
| -------------- | --------------------- | ----------------------------------- |
|
||||||
|
| id | BIGSERIAL PK | <br /> |
|
||||||
|
| customer\_id | BIGINT NOT NULL | → `customer(id)` |
|
||||||
|
| building\_id | BIGINT NOT NULL | → `customer_building(id)` |
|
||||||
|
| room\_id | BIGINT NULL | → `customer_room(id)`,允许挂建筑/楼层而不挂房间 |
|
||||||
|
| floor\_no | VARCHAR(16) | 楼层(冗余,便于按楼层分组) |
|
||||||
|
| circuit\_code | VARCHAR(64) NOT NULL | 回路编号,全局唯一 |
|
||||||
|
| circuit\_name | VARCHAR(128) NOT NULL | 回路名称 |
|
||||||
|
| loop\_spec | VARCHAR(128) | 回路规格(如 `三相380V / 铜芯4×25+1×16`) |
|
||||||
|
| load\_type | VARCHAR(32) | 负荷类型(照明/动力/空调/插座…) |
|
||||||
|
| rated\_voltage | VARCHAR(32) | 额定电压(配置值) |
|
||||||
|
| rated\_current | VARCHAR(32) | 额定电流(配置值) |
|
||||||
|
| rated\_power | NUMERIC(10,2) | 额定功率 kW(配置值) |
|
||||||
|
| status | SMALLINT DEFAULT 1 | 1 正常 / 0 停用 |
|
||||||
|
| remark | TEXT | 备注 |
|
||||||
|
| sort\_order | INT DEFAULT 0 | 排序 |
|
||||||
|
| created\_at | TIMESTAMP | <br /> |
|
||||||
|
|
||||||
|
索引:`customer_id`、`building_id`、`room_id`、唯一键 `circuit_code`。
|
||||||
|
|
||||||
|
> 命名跟随现有习惯:表 `customer_circuit`、entity `CustomerCircuit`、路由 `/api/customer-circuits`、权限码 `customer:circuit`(与 `customer:building` / `customer:room` 同级)。
|
||||||
|
|
||||||
|
## 后端改动(Midway.js + sequelize-typescript)
|
||||||
|
|
||||||
|
参照样板:`customer_room` 全链路。
|
||||||
|
|
||||||
|
1. **迁移**:`database/migrations/20261011_customer_circuit.sql`(幂等 `CREATE TABLE IF NOT EXISTS`),并在 `database/db_init.sql` 同步该表定义。
|
||||||
|
2. **Entity**:`server/src/entity/customer_circuit.entity.ts`(照抄 `customer_building.entity.ts` 的 `@Table/@Column/@BelongsTo/@CreatedAt` 写法)。
|
||||||
|
3. **DTO**:`server/src/dto/customer_circuit.dto.ts`(Query/Create/Update,class-validator 风格照抄 `customer_room.dto.ts`)。
|
||||||
|
4. **Service**:`server/src/service/customer_circuit.service.ts`
|
||||||
|
|
||||||
|
* `list` / `detail` / `create` / `update` / `delete`(照抄 `customer_room.service.ts`,统一返回 `{ code, message, data }`)。
|
||||||
|
|
||||||
|
* `topology(customerId)`:**一次请求**返回整棵「建筑 → 楼层 → 房间 → 回路」结构,避免前端 N+1(现有页面是逐建筑拉房间)。返回形如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "buildings": [ { "id":1, "buildingName":"…", "floors":[ { "floorNo":"2", "rooms":[ { "id":9, "roomName":"…", "circuits":[…] } ], "circuits":[…] } ] } ] }
|
||||||
|
```
|
||||||
|
5. **Controller**:`server/src/controller/customer_circuit.controller.ts`(`@Controller("/api/customer-circuits")`,含 `GET /topology`)。
|
||||||
|
6. **权限注册**:`server/src/middleware/rbac.middleware.ts` 的 `PERMISSION_MAP` 新增 `"/api/customer-circuits": "customer:circuit"`。
|
||||||
|
7. **菜单/权限种子**:在 `database/db_seed.sql` 复用现有「空间拓扑与用电架构」菜单(id=32, `/customer/topology`, 权限码 `customer:topology`),并**参照** **`customer:room`** **的登记方式**新增按钮级权限 `customer:circuit`。
|
||||||
|
|
||||||
|
## 前端改动(Vue3 + Ant Design Vue)
|
||||||
|
|
||||||
|
1. **新增 API 封装** `client/src/api/customerCircuit.ts`:照抄 `customerRoom.ts` 风格(`getList/getDetail/create/update/delete/getTopology` + 类型定义 + 常量:负荷类型选项)。
|
||||||
|
2. **重写** `client/src/views/customer/topology/index.vue` 为三栏:
|
||||||
|
|
||||||
|
* **顶部**:面包屑 + 客户切换器 + 指标条 + 「新增回路」按钮。指标只展示**可由现有数据算出**的项(已接入建筑数、监测用电回路数、空间建模覆盖率 = 有回路的房间数/房间总数);无数据源的项(变电所/配电室、ICC 边缘网关)**不展示**。
|
||||||
|
|
||||||
|
* **左栏(≈280px)**:`a-tree` 渲染「客户 → 建筑 → 楼层 → 房间 → 回路」;节点徽章显示回路数;节点悬停操作:建筑/房间沿用现有增删改(`customerBuildingApi` / `customerRoomApi`),回路可新增/编辑/删除。
|
||||||
|
|
||||||
|
* **中栏**:单线图。上层为**只读示意**(10kV 进线柜 → 变压器 → 低压母线,按客户/建筑信息填充文案)、下层为**回路卡片网格**(编号/名称/所属房间/规格/额定参数,点击选中;实时电气量显示 `—`)。用 div 网格 + 内联 SVG 母线渲染(与设计稿一致),**不引入新依赖**(echarts 已在依赖中但不必要)。
|
||||||
|
|
||||||
|
* **右栏(≈320px)**:选中节点的属性面板(回路基本信息可编辑并保存;房间/建筑节点展示只读属性)。
|
||||||
|
|
||||||
|
* 样式沿用页面既有基线:背景 `#eef3f8`、主色 `#0054cd`、卡片圆角 `12px`、等宽字体用 `Consolas/Menlo`。
|
||||||
|
3. **路由/权限**:沿用现有 `customer/topology` 路由与 `customer:topology` 菜单,**不新增页面路由**。
|
||||||
|
|
||||||
|
## 不做(明确排除)
|
||||||
|
|
||||||
|
* ICC 网关节点、ICC 设备码映射、告警阈值联动、下发同步
|
||||||
|
|
||||||
|
* 实时电气量采集与展示(一律 `—`)
|
||||||
|
|
||||||
|
* 「从 ICC 同步拓扑」「CAD/BIM 导入」「导出高清拓扑图」按钮 → 先不实现(可留占位,若留则点击提示"暂未开放")
|
||||||
|
|
||||||
|
* 「空间三维映射」视图切换
|
||||||
|
|
||||||
|
* 配电箱/配电柜层
|
||||||
|
|
||||||
|
* Flutter 管理端
|
||||||
|
|
||||||
|
## 假设(如不认可请指出)
|
||||||
|
|
||||||
|
1. 中间图的上层(进线柜/变压器)**纯前端静态示意**,文案按所选客户/建筑动态填充,不落库。
|
||||||
|
2. `circuit_code` **全局唯一**(与设备台账 `equip_code` 一致);若你希望"客户内唯一",需改唯一约束。
|
||||||
|
3. 回路可以**不挂房间**(只挂建筑+楼层),以适应公共区域回路。
|
||||||
|
4. 顶部指标只做能算出来的 3 项,其余留白而非显示假数据。
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
1. 执行迁移:`database/migrations/20261011_customer_circuit.sql`(以及 db\_seed 的权限登记),确认表与权限数据落库。
|
||||||
|
2. 后端:启动 `server/`,用带 token 的请求验证 `GET /api/customer-circuits/topology?customerId=1`、以及 create/update/delete;无 token 应 401、无权限应 403。
|
||||||
|
3. 前端:`client/` 执行 `npm run check`(vue-tsc)通过;`npm run dev` 后进入「客户管理 → 空间拓扑与用电架构」:
|
||||||
|
|
||||||
|
* 选客户 → 左树按「建筑 → 楼层 → 房间 → 回路」正确展开;
|
||||||
|
|
||||||
|
* 新增/编辑/删除回路后树与中间卡片同步刷新;
|
||||||
|
|
||||||
|
* 点击回路卡片 → 右栏属性正确回填、可保存;
|
||||||
|
|
||||||
|
* 中间图上层的进线柜/变压器为只读示意。
|
||||||
|
4. 回归:确认原有建筑/房间增删改仍可用(房间功能未被破坏)。
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Checklist
|
||||||
|
|
||||||
|
- [x] customer 表迁移与 db_init.sql 均包含 icc_code 字段与唯一索引,且结构一致(migrations/20261016_icc_customer_sync.sql ↔ db_init.sql L194/L232/L213 ↔ customer.entity.ts)
|
||||||
|
- [x] customer 实体含 iccCode 字段定义(field=icc_code,STRING(64),可空)
|
||||||
|
- [x] ICC OAuth2 鉴权客户端可获取公钥并完成 RSA 加密、换取 access_token,token 有进程内缓存与过期续签(icc_customer_auth.ts,RSA 分块加解密冒烟验证通过)
|
||||||
|
- [x] 字段映射器正确映射 orgCode→iccCode、orgName→customerName、province/city/county,并解析分页响应(icc_customer_mapper.ts,冒烟验证映射与 PageVO/数组兼容解析均正确)
|
||||||
|
- [x] 同步服务按 org_base 过滤、分页全量拉取(maxRangeId=0、pageSize=1000 循环至末页),按 icc_code 幂等 upsert
|
||||||
|
- [x] 新增客户自动生成 customer_no(复用 CustomerService.generateCustomerNo,KH+YYYYMMDD+3位序号);已存在客户先比较四个映射字段,仅不一致才更新,其余字段保持不变
|
||||||
|
- [x] 每次同步写 icc_sync_log(sync_type=customer,status/record_count/error_message),失败路径仅记日志不抛业务错误
|
||||||
|
- [x] 定时任务已注册(job_registry L5/L37/L64 + db_seed.sql L411 sys_job 种子),默认 cron `*/10 * * * *`,调度器运行时已接线(job_scheduler.service.ts syncIccCustomers)
|
||||||
|
- [x] server/.env 含完整 ICC 配置键(BASE_URL/VERSION/CLIENT_ID/CLIENT_SECRET/USERNAME/PASSWORD)
|
||||||
|
- [x] `yarn build` 编译通过(mapper 修复后复验通过),任务代码路径(task → runtime → service)类型正确可运行;端到端写库同步建议在部署环境首次执行时由真实 ICC 接口确认
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# ICC 客户信息同步(org_base)Spec
|
||||||
|
|
||||||
|
## Why
|
||||||
|
客户档案目前依赖手工维护,鑫潼 ICC 平台已存在基础组织(org_base)客户主数据。需按 ICC 平台接口文档,通过定时任务从 ICC 拉取 org_base 组织数据,按字段映射对 customer 客户表执行新增/变更,实现客户主数据自动化同步。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
- **新增** `customer` 表 `icc_code` 字段(ICC 客户编码,唯一索引),作为 ICC 同步的幂等匹配键;同步修改实体、迁移脚本与 db_seed.sql(幂等一致)
|
||||||
|
- **新增** ICC OAuth2 密码模式鉴权客户端:获取公钥 → RSA(ECB/PKCS1) 加密密码 → `extend/token` 换取 access_token(2h 有效,进程内缓存过期自动续签)
|
||||||
|
- **新增** `IccCustomerSyncService`:从 `.env` 读取 ICC 配置,分页全量拉取 `organization/page`(`maxRangeId=0`,过滤 `orgType=org_base`),按字段映射对 customer 幂等 upsert(已存在记录先比较映射字段,不一致才变更),写 `icc_sync_log`
|
||||||
|
- **新增** 定时任务:注册 `icc_customer_sync.task` 到任务注册表与 `sys_job`(默认每 10 分钟,可配置)
|
||||||
|
- **安全**:全部 SQL 走 Sequelize 参数绑定;接口凭据经 `.env` 读取,不硬编码到代码;ICC 拉取失败仅记录 `icc_sync_log` 错误日志,不抛业务错误
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- Affected specs: ICC 数据同步(含现有 icc_line_sync 模式)、客户管理(customer 主数据)
|
||||||
|
- Affected code:
|
||||||
|
- server/src/entity/customer.entity.ts(新增 iccCode)
|
||||||
|
- server/src/task/icc_customer_mapper.ts(新增:字段映射集中配置)
|
||||||
|
- server/src/service/icc_customer_sync.service.ts(新增:OAuth2 鉴权 + 拉取 + upsert + 日志)
|
||||||
|
- server/src/task/icc_customer_sync.task.ts(新增:定时任务定义)
|
||||||
|
- server/src/task/job_registry.ts、server/src/service/job_scheduler.service.ts(注册任务与运行时)
|
||||||
|
- server/.env(新增 ICC 凭据配置项)
|
||||||
|
- database/migrations/(新增迁移文件)、database/db_seed.sql(customer 表结构与 sys_job 种子)
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: ICC 客户信息同步(org_base 定时任务)
|
||||||
|
系统 SHALL 通过定时任务从 ICC 平台同步 org_base 客户信息到 customer 表:OAuth2 鉴权获取 access_token → 分页全量拉取组织数据(maxRangeId=0,pageSize≤1000,循环至末页)→ 过滤 `orgType=org_base` → 按字段映射(`orgCode→iccCode`、`orgName→customerName`、`province→province`、`city→city`、`county→district`)幂等 upsert,并写 `icc_sync_log`(sync_type=`customer`)。
|
||||||
|
|
||||||
|
#### Scenario: 定时同步成功
|
||||||
|
- **WHEN** 定时任务触发(默认 `*/10 * * * *`,可配置)
|
||||||
|
- **THEN** 拉取 ICC org_base 组织并新增/变更对应 customer 记录;新增客户自动生成 `customer_no`(沿用 KH+YYYYMMDD+序号规则);已存在客户(按 `icc_code` 匹配)先比较 customer_name/province/city/district 四个映射字段与现有值,仅当任一字段不一致时才执行更新,一致则跳过,其余手工字段(status/salesman_id/联系方式等)保持不变;`icc_sync_log` 写入成功记录
|
||||||
|
|
||||||
|
#### Scenario: 重复执行幂等
|
||||||
|
- **WHEN** 同一 orgCode 连续两次同步均存在且 ICC 数据未变化
|
||||||
|
- **THEN** 第二次执行按 icc_code 命中已有记录,比较后字段一致不执行 update,不产生重复客户、不产生无意义变更
|
||||||
|
|
||||||
|
#### Scenario: 全量分页拉取
|
||||||
|
- **WHEN** 组织数据超过一页
|
||||||
|
- **THEN** 按 pageNum 递增循环请求直至 currentPage==totalPage,全部合并后统一 upsert
|
||||||
|
|
||||||
|
#### Scenario: ICC 鉴权失败 / 接口异常
|
||||||
|
- **WHEN** 公钥获取失败、token 换取失败或组织接口返回非 0 code / HTTP 非 2xx
|
||||||
|
- **THEN** 同步失败仅写 `icc_sync_log`(status=0、error_message),业务不抛错;access_token 过期时自动重新走 OAuth2 鉴权
|
||||||
|
|
||||||
|
### Requirement: customer 表 icc_code 字段
|
||||||
|
系统 SHALL 在 customer 表新增 `icc_code` 字段(字符串,可空,唯一索引)用于承载 ICC 客户编码;已有客户在首次同步时按 orgCode 匹配后回填该字段;该字段不参与客户档案页面手工编辑逻辑(同步来源字段)。
|
||||||
|
|
||||||
|
### Requirement: ICC 凭据环境配置
|
||||||
|
系统 SHALL 通过 `.env` 读取 ICC 平台连接配置:`ICC_BASE_URL`(host)、`ICC_VERSION`、`ICC_CLIENT_ID`、`ICC_CLIENT_SECRET`、`ICC_USERNAME`、`ICC_PASSWORD`;业务代码不出现明文凭据。
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
(无既有功能被修改;现有 icc_line_sync 任务保持原行为不变)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [x] Task 1: customer 表新增 icc_code 字段(数据库层)
|
||||||
|
- [x] 新建迁移文件 `database/migrations/20261016_icc_customer_sync.sql`:`ALTER TABLE customer ADD COLUMN icc_code`(幂等)+ 唯一索引
|
||||||
|
- [x] 同步更新 `database/db_init.sql` 中 customer 建表结构(含 icc_code 与唯一索引),保持幂等一致
|
||||||
|
- [x] Task 2: customer 实体新增 iccCode 字段
|
||||||
|
- [x] `server/src/entity/customer.entity.ts` 增加 `iccCode`(STRING(64),field=icc_code,不设 unique 避免与迁移脚本索引重复)
|
||||||
|
- [x] Task 3: ICC OAuth2 鉴权客户端
|
||||||
|
- [x] 封装公钥获取(GET `/evo-apigw/evo-oauth/{version}/oauth/public-key`)
|
||||||
|
- [x] 封装 RSA/ECB/PKCS1 分块加密(Node `crypto.publicEncrypt`,块长 117,Base64 输出,对齐文档 Java 示例)— 已冒烟验证加解密往返一致
|
||||||
|
- [x] 封装 token 获取(POST `/evo-apigw/evo-oauth/{version}/oauth/extend/token`),进程内缓存 access_token,按 expires_in 过期自动续签
|
||||||
|
- [x] Task 4: ICC 客户字段映射器
|
||||||
|
- [x] `server/src/task/icc_customer_mapper.ts`:集中配置接口端点、org_base 过滤条件、字段映射(orgCode→iccCode、orgName→customerName、province/city/county)、PageVO 解析 — 修复读取 bug 后冒烟验证通过
|
||||||
|
- [x] Task 5: IccCustomerSyncService 同步服务
|
||||||
|
- [x] `server/src/service/icc_customer_sync.service.ts`:读取 `.env` 配置(缺失视为失败仅记日志)
|
||||||
|
- [x] 分页全量拉取(maxRangeId=0,pageSize=1000,循环至末页),过滤 orgType=org_base
|
||||||
|
- [x] 预取现有客户 icc_code 映射,按 iccCode 幂等 upsert:先比较 customer_name/province/city/district 四个映射字段,仅任一字段不一致才 update,一致则跳过;新增客户复用 CustomerService.generateCustomerNo(已改 public,KH+YYYYMMDD+3位序号);其余字段(status/salesman_id/联系方式等)不动
|
||||||
|
- [x] 每次同步写 `icc_sync_log`(sync_type=customer,status/record_count/error_message);失败仅记录不抛出
|
||||||
|
- [x] Task 6: 定时任务注册与调度器接线
|
||||||
|
- [x] `server/src/task/icc_customer_sync.task.ts`:jobKey=`icc_customer_sync`,默认 cron `*/10 * * * *`
|
||||||
|
- [x] `server/src/task/job_registry.ts`:注册到 BUILTIN_JOBS,JobRuntime 增加 syncIccCustomers
|
||||||
|
- [x] `server/src/service/job_scheduler.service.ts`:buildRuntime 增加 `syncIccCustomers`,经匿名上下文注入 IccCustomerSyncService
|
||||||
|
- [x] `database/db_seed.sql` 新增 sys_job 种子(job_key=icc_customer_sync,按 job_key 幂等)
|
||||||
|
- [x] Task 7: .env 配置
|
||||||
|
- [x] `server/.env` 增加 ICC_BASE_URL / ICC_VERSION / ICC_CLIENT_ID / ICC_CLIENT_SECRET / ICC_USERNAME / ICC_PASSWORD(凭据取自接口文档)
|
||||||
|
- [x] Task 8: 编译与运行验证
|
||||||
|
- [x] `server` 目录执行 `yarn build` 通过(修复 mapper 后复验通过)
|
||||||
|
- [x] 冒烟验证:RSA 分块加解密往返一致、映射器字段映射与分页解析正确(无副作用,未连库);端到端写库同步需在部署环境首次执行时由真实 ICC 接口 + 数据库实际确认
|
||||||
|
|
||||||
|
# Task Dependencies
|
||||||
|
- [Task 1] 无依赖,可先行
|
||||||
|
- [Task 2] 依赖 [Task 1](字段定义与库结构一致)
|
||||||
|
- [Task 3] 无依赖,可与 [Task 1] 并行
|
||||||
|
- [Task 4] 依赖 [Task 3](需确认响应结构/鉴权头),也可并行开发
|
||||||
|
- [Task 5] 依赖 [Task 1][Task 2][Task 3][Task 4]
|
||||||
|
- [Task 6] 依赖 [Task 5]
|
||||||
|
- [Task 7] 依赖 [Task 3](凭据键名一致),可并行
|
||||||
|
- [Task 8] 依赖全部前置任务
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Checklist
|
||||||
|
|
||||||
|
- [x] electric_gateway 表已建(迁移 20261024_icc_gateway.sql),字段集符合需求方确认:自增主键 id、icc_imei/device_code 双唯一、customer_id→customer、equip_id→customer_equipment 可空、org_code/org_name/manufacturer_code/manufacturer_name/type_code/type_name/product_model/is_online/synced_at/created_at;不含 companyId/companyName/dcpsId/softwareVersion/systemAddress/address/description;与 db_seed.sql 结构一致 —— 迁移已执行到远程生产库(信息模式校验 16 列),db_seed.sql 451-522 行同步
|
||||||
|
- [x] 菜单 62「智电改造网关台账」挂设备监控(4)(permission device:list)、ADMIN role_menu、sys_job 种子 icc_gateway_sync(cron `*/10 * * * *`)已写入迁移与 db_seed.sql 且幂等 —— 生产库已存在(menu62 查询命中、role_menu(1,62)、job 行 status=1)
|
||||||
|
- [x] ElectricGateway 实体与迁移结构一致(field 映射、索引)—— 实体含 @ForeignKey Customer/CustomerEquipment、@BelongsTo、3 索引
|
||||||
|
- [x] icc_gateway_mapper.ts 正确映射 DeviceInfoVO 字段(imei/deviceCode/deviceName/orgCode/orgName/manufacturerCode/manufacturerName/typeCode/typeName/productModel/isOnline)并解析分页响应(PageVO{nextPage,totalCount,pageData})—— 实测映射正确(typeName=组合式电气火灾监控探测器、manufacturerName=大华、productModel=DH-…)
|
||||||
|
- [x] IccGatewaySyncService 复用 getIccAccessToken/iccFetch 真实鉴权,POST 分页全量拉取(pageSize=50,循环至短页,最大页数保护),orgCode→customer.icc_code 解析 customer_id —— 实测 pulled=2,customerId 解析为 3(KH20261016001)
|
||||||
|
- [x] 同步按 icc_imei(缺省 device_code)幂等 upsert,跳过缺双键或匹配不到客户的行,更新 synced_at,写 icc_sync_log(sync_type=electric_gateway);失败仅记录不抛业务错误 —— 实测首次 inserted=2,重跑 updated=2 skipped=0;icc_sync_log 2 条 status=1 record_count=2
|
||||||
|
- [x] GET /api/electric-gateways 列表/详情、POST /api/electric-gateways/sync 已实现;RBAC `device:list` 已映射;非 ADMIN 按客户归属限定数据范围 —— 无 token 401、admin 列表 200
|
||||||
|
- [x] 定时任务 icc_gateway_sync 已注册(job_registry BUILTIN_JOBS + job_scheduler buildRuntime syncIccGateways + db_seed.sql 种子)—— 调度器启动日志"已加载 6 个任务"含 icc_gateway_sync cron=*/10 * * * *
|
||||||
|
- [x] server/.env 含 ICC_BASE_URL=https://222.243.55.92:1443、ICC_VERSION=1.0.0、ICC_CLIENT_ID/ICC_CLIENT_SECRET/ICC_USERNAME/ICC_PASSWORD —— 已确认齐全
|
||||||
|
- [x] 前端 views/device/gateway/index.vue(搜索/同步按钮/表格/在线状态 tag/分页)、路由 /device/gateway、api/device.ts electricGatewayApi 已实现 —— 浏览器实测渲染/同步/筛选/重置全部正常
|
||||||
|
- [x] 后端编译(tsc --noEmit)与前端类型检查(vue-tsc -b)通过;运行验证:401/200、sync 真实拉取成功不 500、菜单与页面渲染正常 —— 全部实测通过
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 智电改造网关台账(ICC 消防物联设备同步)Spec
|
||||||
|
|
||||||
|
## Why
|
||||||
|
系统缺少"智电改造网关设备"台账。网关设备数据由 ICC 平台消防物联设备接口(`/evo-apigw/evo-fdbu/{version}/device/subsystem/page`)提供,需定时拉取并落库到新建的 `electric_gateway` 表,运营侧可按客户查看网关设备台账、手动触发同步。本表在设计文档中未定义,需依据接口文档新增表结构(已与需求方确认字段集)。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
- **新增** `electric_gateway` 表(数据库迁移 + db_seed.sql 同步):自增主键 `id`、`icc_imei`/`device_code` 双唯一键、`device_name`、`customer_id`(→ customer,由接口 orgCode→customer.icc_code 解析)、`equip_id`(→ customer_equipment,可空,同步不填充)、`org_code`/`org_name`、`manufacturer_code`/`manufacturer_name`、`type_code`/`type_name`、`product_model`、`is_online`、`synced_at`、`created_at`;**不含** companyId/companyName/dcpsId/softwareVersion/systemAddress/address/description(经需求方确认)
|
||||||
|
- **新增** ICC 网关设备同步:`IccGatewaySyncService` 复用真实 OAuth2 鉴权(`icc_customer_auth.ts` 的 `getIccAccessToken` + `iccFetch`,走 `.env` 已配置凭据),POST 分页拉取消防物联设备全量数据(pageNum/pageSize=50,循环至短页,全量不传 typeCode),按 imei(缺省时 deviceCode)幂等 upsert,写 `icc_sync_log`(sync_type=electric_gateway)
|
||||||
|
- **新增** 后端接口:`GET /api/electric-gateways` 列表、`GET /api/electric-gateways/:id` 详情、`POST /api/electric-gateways/sync` 手动同步
|
||||||
|
- **新增** 定时任务:注册 `icc_gateway_sync`(job_registry + job_scheduler buildRuntime + db_seed.sql sys_job 种子,默认 cron `*/10 * * * *`,与既有 ICC 同步任务口径一致)
|
||||||
|
- **新增** RBAC 映射:`/api/electric-gateways → device:list`
|
||||||
|
- **新增** 前端"智电改造网关台账"页面(`views/device/gateway/index.vue`),路由 `/device/gateway`,含"同步网关"手动按钮;菜单挂设备监控目录(parent=4)
|
||||||
|
- **新增** `.env` ICC 网关同步配置(ICC_BASE_URL=https://222.243.55.92:1443、ICC_VERSION=1.0.0、ICC_CLIENT_ID/ICC_CLIENT_SECRET/ICC_USERNAME/ICC_PASSWORD,取自接口文档)
|
||||||
|
- **安全**:全部 SQL 走 Sequelize 参数绑定;前端不用 v-html(防 XSS);接口返回统一 `code/message/data`
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- Affected specs: 设备监控(设备台账、智电改造线路台账)、ICC 数据同步、RBAC 权限
|
||||||
|
- Affected code: server/src(entity/dto/service/controller/task/middleware)、client/src(api/router/views)、database(db_seed.sql、migrations/)
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
### Requirement: 网关台账查询(JWT+RBAC)
|
||||||
|
系统 SHALL 提供网关台账列表与详情查询,仅登录且有 `device:list` 权限的用户可访问;查询支持按 customer_id/equip_id/status(在线状态)/关键词(device_name/device_code/icc_imei)过滤分页;非 ADMIN 业务员按客户归属自动限定数据范围。
|
||||||
|
#### Scenario: 成功查询
|
||||||
|
- **WHEN** 已登录用户 GET `/api/electric-gateways?page=1&pageSize=10&customerId=1`
|
||||||
|
- **THEN** 返回 `code=0,data{list,total}`,含设备名称/编码/imei/客户/设备型号/主机类型/厂商/在线状态/同步时间;无 token 返回 401,无权限返回 403
|
||||||
|
|
||||||
|
### Requirement: ICC 网关设备拉取同步(真实鉴权 + 定时 + 手动)
|
||||||
|
系统 SHALL 提供 ICC 网关设备拉取同步能力:`IccGatewaySyncService` 读取 `.env` 的 ICC 配置(复用 `icc_customer_auth` 真实 OAuth2 密码模式鉴权),POST `/evo-apigw/evo-fdbu/{version}/device/subsystem/page` 分页拉取消防物联设备全量数据(不传 typeCode,pageSize 50,循环至短页,带最大页数保护),字段映射集中在 `icc_gateway_mapper`,按 icc_imei(缺省用 device_code)幂等 upsert 写入 `electric_gateway`,`customer_id` 由 orgCode→customer.icc_code 解析(匹配不到或缺 icc_imei 与 device_code 则跳过),更新 `synced_at`,写 `icc_sync_log`(sync_type=electric_gateway,status/record_count/error_message)。同步入口:① 定时任务(jobKey=`icc_gateway_sync`,默认 `*/10 * * * *`);② 手动 `POST /api/electric-gateways/sync`(JWT+RBAC `device:list`)。
|
||||||
|
#### Scenario: 定时/手动拉取成功
|
||||||
|
- **WHEN** 定时任务触发或运营点击"同步网关"
|
||||||
|
- **THEN** 拉取 ICC 数据并 upsert 对应网关行,`icc_sync_log` 记录成功;返回 `code=0,data{pulled,synced,inserted,updated,skipped}`
|
||||||
|
#### Scenario: 手动同步鉴权失败
|
||||||
|
- **WHEN** 未登录或无 `device:list` 权限调用 `/api/electric-gateways/sync`
|
||||||
|
- **THEN** 返回 401/403,不触发拉取
|
||||||
|
#### Scenario: ICC 拉取失败(不可达/鉴权失败/接口异常)
|
||||||
|
- **WHEN** ICC 地址不可达或返回异常
|
||||||
|
- **THEN** 同步日志记录失败状态与错误信息,业务接口不报错,仅返回已拉取计数
|
||||||
|
|
||||||
|
### Requirement: 前端网关台账页
|
||||||
|
系统 SHALL 提供 `/device/gateway` 页面:搜索栏(设备名称/编码/imei、客户、在线状态)、"同步网关"按钮(调 `/api/electric-gateways/sync`,成功后刷新)、表格(设备名称/编码/imei/客户/厂商/主机类型/设备型号/在线状态 tag/同步时间)、分页;样式与智电改造线路台账一致,全部文本经 Vue 插值转义。
|
||||||
|
#### Scenario: 打开页面
|
||||||
|
- **WHEN** 有 `device:list` 权限的用户访问设备监控→智电改造网关台账
|
||||||
|
- **THEN** 渲染列表并展示分页与筛选
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
(无既有功能被修改)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [x] Task 1: 数据库层:新建迁移 `database/migrations/20261024_icc_gateway.sql` 建 `electric_gateway`(字段集经需求方确认),并把建表/菜单/角色菜单/定时任务种子同步进 `db_seed.sql`;幂等(IF NOT EXISTS / ON CONFLICT)
|
||||||
|
- [x] SubTask 1.1: 核对生产库是否已有 electric_gateway,避免重复;确认 sys_menu 现有最大 id(迁移用 id=62 并 setval)—— 已核对:表无先例、菜单 id 62 空闲;迁移已执行到远程生产库
|
||||||
|
- [x] SubTask 1.2: 迁移内容:建表 electric_gateway(自增 id;icc_imei/device_code 唯一;customer_id→customer;equip_id→customer_equipment 可空;org_code/org_name/manufacturer_code/manufacturer_name/type_code/type_name/product_model/is_online/synced_at/created_at;索引 customer_id/equip_id/icc_imei);菜单 62「智电改造网关台账」挂设备监控(4)(permission device:list);ADMIN role_menu;sys_job 种子 icc_gateway_sync(cron `*/10 * * * *`)
|
||||||
|
- [x] SubTask 1.3: db_seed.sql 同步对应语句(保持幂等一致)—— 已追加段落(451-522 行)
|
||||||
|
- [x] Task 2: 后端实体与映射器:
|
||||||
|
- [x] SubTask 2.1: `server/src/entity/electric_gateway.entity.ts`(timestamps:false,结构对齐迁移文件)
|
||||||
|
- [x] SubTask 2.2: `server/src/task/icc_gateway_mapper.ts`:集中配置端点 `/evo-apigw/evo-fdbu/{version}/device/subsystem/page`、DeviceInfoVO 字段映射(id/imei/deviceCode/deviceName/orgCode/orgName/manufacturerCode/manufacturerName/typeCode/typeName/productModel/isOnline)、分页响应解析(PageVO{nextPage,totalCount,pageData})
|
||||||
|
- [x] Task 3: 后端同步服务 `server/src/service/icc_gateway_sync.service.ts`:
|
||||||
|
- [x] SubTask 3.1: 复用 `icc_customer_auth.ts` 的 `getIccAccessToken`/`iccFetch` 真实鉴权;读取 `.env`(ICC_BASE_URL/ICC_VERSION),配置缺失视为失败仅记日志
|
||||||
|
- [x] SubTask 3.2: POST 分页拉取(pageSize=50、不传 typeCode、循环至短页、最大页数保护),预取 orgCode→customer(id) 映射(customer.icc_code)
|
||||||
|
- [x] SubTask 3.3: 按 icc_imei(缺省 device_code)幂等 upsert;缺 imei 与 device_code 或匹配不到客户则跳过;更新 synced_at;写 icc_sync_log(sync_type=electric_gateway);拉取失败仅记录不抛业务错误 —— 实测同步成功(pulled=2),重跑 updated=2 幂等
|
||||||
|
- [x] Task 4: 后端查询服务/Controller/DTO + RBAC:
|
||||||
|
- [x] SubTask 4.1: `server/src/service/electric_gateway.service.ts`(list/detail,过滤:keyword=device_name/device_code/icc_imei、customer_id、equip_id、is_online;非 ADMIN 按 salesmanId 限定客户范围;decorate 补全客户/设备展示字段)
|
||||||
|
- [x] SubTask 4.2: `server/src/dto/electric_gateway.dto.ts`(查询/同步入参校验)
|
||||||
|
- [x] SubTask 4.3: `server/src/controller/electric_gateway.controller.ts`:GET `/api/electric-gateways`、GET `/:id`、POST `/sync`
|
||||||
|
- [x] SubTask 4.4: `rbac.middleware.ts` 追加 `/api/electric-gateways → device:list`
|
||||||
|
- [x] Task 5: 定时任务注册:
|
||||||
|
- [x] SubTask 5.1: `server/src/task/icc_gateway_sync.task.ts`(jobKey=`icc_gateway_sync`,默认 cron `*/10 * * * *`,handler 调 runtime.syncIccGateways)
|
||||||
|
- [x] SubTask 5.2: `job_registry.ts`:JobRuntime 增加 `syncIccGateways`,BUILTIN_JOBS 追加 iccGatewaySyncJob
|
||||||
|
- [x] SubTask 5.3: `job_scheduler.service.ts` buildRuntime 增加 `syncIccGateways`(匿名上下文注入 IccGatewaySyncService)
|
||||||
|
- [x] SubTask 5.4: `server/.env` 补全 ICC 网关同步凭据 —— 已确认 .env 中配置齐全(ICC_BASE_URL=https://222.243.55.92:1443、ICC_VERSION=1.0.0、ICC_CLIENT_ID=xtzdapp、ICC_CLIENT_SECRET、ICC_USERNAME=XTZD接口、ICC_PASSWORD)
|
||||||
|
- [x] Task 6: 前端:
|
||||||
|
- [x] SubTask 6.1: `client/src/api/device.ts` 新增 electricGatewayApi.list/get/sync
|
||||||
|
- [x] SubTask 6.2: `client/src/views/device/gateway/index.vue`(搜索栏+同步按钮+表格+在线状态 tag+分页,样式对齐 views/device/line/index.vue)
|
||||||
|
- [x] SubTask 6.3: `client/src/router/index.ts` 注册 `/device/gateway`(挂 device 目录)
|
||||||
|
- [x] Task 7: 验证:
|
||||||
|
- [x] SubTask 7.1: 后端 `tsc --noEmit`、前端 `vue-tsc -b` 类型检查通过 —— 均通过(exit 0)
|
||||||
|
- [x] SubTask 7.2: 启动服务:无 token 401(实测 401)、admin 列表 200(实测 total=2)、`POST /sync` 真实拉取成功(pulled=2→落库→重跑 updated=2)、定时任务 icc_gateway_sync 已注册(调度器加载 6 个任务,含 icc_gateway_sync cron */10 * * * *)
|
||||||
|
- [x] SubTask 7.3: 浏览器验证菜单出现、页面渲染、筛选/分页/同步按钮可用 —— 实测:菜单出现、9 列表格渲染 2 条、同步按钮提示"拉取 2 条,新增 0,更新 2"并刷新、关键词筛选/重置正常
|
||||||
|
|
||||||
|
# Task Dependencies
|
||||||
|
- Task 2/6 依赖 Task 1(表存在)
|
||||||
|
- Task 3 依赖 Task 2(映射器)
|
||||||
|
- Task 4 依赖 Task 1/2
|
||||||
|
- Task 5 依赖 Task 3
|
||||||
|
- Task 6 依赖 Task 4(接口可用)
|
||||||
|
- Task 7 依赖全部
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Checklist
|
||||||
|
|
||||||
|
- [x] electric_line / line_room / icc_sync_log 表存在(生产库 + db_seed.sql 一致,幂等)
|
||||||
|
- [x] ElectricLine / IccSyncLog 实体与 DTO 实现,ElectricLine 仅 created_at(timestamps:false)
|
||||||
|
- [x] 列表查询使用参数绑定(Op.like/Op.in),无字符串拼接 SQL
|
||||||
|
- [x] GET /api/electric-lines:无 token 401、有 device:list 权限 200 返回 code/message/data 结构
|
||||||
|
- [x] POST /api/electric-lines/sync:JWT+RBAC 鉴权;定时任务服务内直调
|
||||||
|
- [x] IccLineSyncService 读 ICC_BASE_URL/ICC_TOKEN 占位配置,按 line_code upsert + 写 icc_sync_log;ICC 拉取失败记录 error 不抛业务错
|
||||||
|
- [x] icc_line_sync 定时任务已注册(@midwayjs/cron)
|
||||||
|
- [x] RBAC 映射含 /api/electric-lines → device:list
|
||||||
|
- [x] 前端 /device/line 页面渲染(搜索/同步按钮/表格/分页/状态 tag),无 v-html
|
||||||
|
- [x] 菜单 id=61 挂设备监控(4) 且 role_menu 绑定,db_seed.sql 与生产库一致
|
||||||
|
- [x] 后端 tsc --noEmit、前端 vue-tsc -b 均通过
|
||||||
|
- [x] 浏览器端登录后菜单可见、页面可用、同步按钮可用
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 智电改造线路台帐(设备监控)Spec
|
||||||
|
|
||||||
|
## Why
|
||||||
|
设备监控模块缺少"智电改造线路"台账。线路数据由 ICC 平台同步到本系统,运营侧需按客户/设备/建筑查看线路及状态。需在既有设备模块最小套路基础上新增后端接口(含鉴权与防注入)与前端页面,并兼容 APP。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
- **新增** `electric_line` 表后端实体/DTO/Service/Controller(查询接口 `GET /api/electric-lines` 列表 + 详情,手动同步 `POST /api/electric-lines/sync`)
|
||||||
|
- **新增** ICC 拉取同步:`IccLineSyncService` 从 `.env`(`ICC_BASE_URL`/`ICC_TOKEN`)读取占位配置,请求 ICC 线路列表接口(路径与字段映射集中定义),幂等 upsert 写入 `electric_line`,记录 `icc_sync_log`
|
||||||
|
- **新增** 定时任务:注册 `icc_line_sync.task`(@midwayjs/cron,周期默认 10 分钟,可配置),服务内直调拉取
|
||||||
|
- **新增** RBAC 映射:`/api/electric-lines → device:list`
|
||||||
|
- **新增** 前端"智电改造线路台账"页面(`views/device/line/index.vue`),路由 `/device/line`,含"同步线路"手动按钮;菜单挂设备监控目录(parent=4,新 id=61)
|
||||||
|
- **同步** db_seed.sql 与迁移文件(建表 + 菜单 + role_menu),保持幂等一致
|
||||||
|
- **安全**:全部 SQL 走 Sequelize 参数绑定;前端不用 v-html(防 XSS);接口返回统一 `code/message/data`(兼容 APP)
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- Affected specs: 设备监控(设备台账、智电设备型号)、ICC 数据同步、RBAC 权限
|
||||||
|
- Affected code: server/src(entity/dto/service/controller/middleware)、client/src(api/router/views)、database(db_seed.sql、migrations/)
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
### Requirement: 线路台账查询(JWT+RBAC)
|
||||||
|
系统 SHALL 提供线路台账列表与详情查询,仅登录且有 `device:list` 权限的用户可访问;查询支持按 customer_id/equip_id/building_id/status/关键词(line_name/line_code)过滤分页;非 ADMIN 业务员按客户归属自动限定数据范围。
|
||||||
|
#### Scenario: 成功查询
|
||||||
|
- **WHEN** 已登录用户 GET `/api/electric-lines?page=1&pageSize=10&customerId=1`
|
||||||
|
- **THEN** 返回 `code=0,data{list,total}`,含线路编码/名称/所属设备/客户/建筑/楼层/位置/状态/同步时间;无 token 返回 401,无权限返回 403
|
||||||
|
|
||||||
|
### Requirement: ICC 拉取同步(占位配置 + 定时 + 手动)
|
||||||
|
系统 SHALL 提供 ICC 线路拉取同步能力:`IccLineSyncService` 读取 `.env` 的 `ICC_BASE_URL`/`ICC_TOKEN`(占位,字段映射集中在 `icc_line_mapper`),向 ICC 拉取线路列表,按 `line_code` upsert(幂等)写入 `electric_line`,更新 `synced_at`,写 `icc_sync_log`(status/record_count/error_message)。同步入口:① 定时任务(@midwayjs/cron,默认每 10 分钟,服务内直调);② 手动 `POST /api/electric-lines/sync`(JWT+RBAC `device:list`)。
|
||||||
|
#### Scenario: 定时/手动拉取成功
|
||||||
|
- **WHEN** 定时任务触发或运营点击"同步线路"
|
||||||
|
- **THEN** 拉取 ICC 数据并 upsert 对应线路行,`icc_sync_log` 记录成功;返回 `code=0,data{syncedCount,updatedCount}`
|
||||||
|
#### Scenario: 手动同步鉴权失败
|
||||||
|
- **WHEN** 未登录或无 `device:list` 权限调用 `/api/electric-lines/sync`
|
||||||
|
- **THEN** 返回 401/403,不触发拉取
|
||||||
|
#### Scenario: ICC 拉取失败
|
||||||
|
- **WHEN** ICC 地址不可达或返回异常
|
||||||
|
- **THEN** 同步日志记录失败状态与错误信息,业务接口不报错
|
||||||
|
|
||||||
|
### Requirement: 前端线路台账页
|
||||||
|
系统 SHALL 提供 `/device/line` 页面:搜索栏(线路名称/编码、客户、状态)、"同步线路"按钮(调 `/api/electric-lines/sync`,成功后刷新)、表格(编码/名称/客户/设备/建筑/楼层/位置/状态 tag/同步时间)、分页;样式与设备台账一致,全部文本经 Vue 插值转义。
|
||||||
|
#### Scenario: 打开页面
|
||||||
|
- **WHEN** 有 `device:list` 权限的用户访问设备监控→智电改造线路台账
|
||||||
|
- **THEN** 渲染列表并展示分页与筛选
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
(无既有功能被修改)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [ ] Task 1: 数据库层:新建 migration 建 `electric_line`(按 db_design.html:575-589 字段)、`line_room`、`icc_sync_log`(若不存在),并把建表语句同步进 db_seed.sql;幂等(IF NOT EXISTS / 增量)
|
||||||
|
- [ ] SubTask 1.1: 核对生产库是否已有 electric_line/line_room/icc_sync_log,避免重复
|
||||||
|
- [ ] SubTask 1.2: 写 migration(migrations/20261011_icc_line_ledger.sql)并执行到远程生产库
|
||||||
|
- [ ] Task 2: 后端实体+DTO:新增 `ElectricLine` entity(timestamps:false,仅 created_at)、`IccSyncLog` entity;`electric_line.dto.ts` 定义查询/同步入参(line_code 必填、字段校验、长度限制)
|
||||||
|
- [ ] Task 3: 后端 Service:
|
||||||
|
- [ ] SubTask 3.1: `ElectricLineService.list(page, filter)`(Op.like/Op.in 参数绑定,非 ADMIN 限定 customer_id)
|
||||||
|
- [ ] SubTask 3.2: `IccLineSyncService.syncFromIcc()`:读 `.env` 的 ICC_BASE_URL/ICC_TOKEN 占位配置,请求 ICC 线路列表(路径与字段映射集中在 `icc_line_mapper`),按 line_code 批量 upsert + 写 icc_sync_log + 失败捕获不抛业务错误
|
||||||
|
- [ ] Task 4: 后端 Controller + RBAC:
|
||||||
|
- [ ] SubTask 4.1: `electric_line.controller.ts`:GET /api/electric-lines(列表/详情)、POST /api/electric-lines/sync(调 IccLineSyncService)
|
||||||
|
- [ ] SubTask 4.2: rbac.middleware.ts 追加 `/api/electric-lines → device:list`
|
||||||
|
- [ ] SubTask 4.3: `.env` 增加 ICC_BASE_URL、ICC_TOKEN(占位);定时任务注册 `icc_line_sync.task`(@midwayjs/cron,默认每 10 分钟)
|
||||||
|
- [ ] Task 5: 前端 API:`client/src/api/device.ts`(或新 `line.ts`)新增 `electricLineApi.list/get/sync`
|
||||||
|
- [ ] Task 6: 前端页面:`views/device/line/index.vue`(搜索+同步按钮+表格+分页+状态 tag),路由 `/device/line`(permission device:list),菜单 id=61 挂设备监控(4) + role_menu(1,61),同步 db_seed.sql
|
||||||
|
- [ ] Task 7: 验证:
|
||||||
|
- [ ] SubTask 7.1: 后端 `tsc --noEmit`、前端 `vue-tsc -b` 类型检查
|
||||||
|
- [ ] SubTask 7.2: 启动后端/前端,接口实测(无 token 401、有权限 200、无权限 403、sync 接口鉴权、ICC 拉取失败不报错)
|
||||||
|
- [ ] SubTask 7.3: 浏览器验证菜单出现、页面渲染、筛选/分页/同步按钮可用
|
||||||
|
|
||||||
|
# Task Dependencies
|
||||||
|
- Task 2/5 依赖 Task 1(表存在)
|
||||||
|
- Task 3 依赖 Task 2
|
||||||
|
- Task 4 依赖 Task 3
|
||||||
|
- Task 6 依赖 Task 5
|
||||||
|
- Task 7 依赖全部
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { Client } = require('pg');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const client = new Client({
|
||||||
|
host: '222.243.55.42',
|
||||||
|
port: 26532,
|
||||||
|
database: 'xintong',
|
||||||
|
user: 'postgres',
|
||||||
|
password: '7F0d9T7SOroYqgXqtZ7tHL2SbnvUljHe'
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
const sql = fs.readFileSync(
|
||||||
|
path.join(__dirname, 'database', 'migrations', '20261006_worker_password_manage.sql'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await client.query(sql);
|
||||||
|
const menu = await client.query('SELECT id, parent_id, menu_name, menu_type, permission FROM sys_menu WHERE id = 57');
|
||||||
|
const bind = await client.query('SELECT role_id, menu_id FROM sys_role_menu WHERE menu_id = 57');
|
||||||
|
console.log('menu:', JSON.stringify(menu.rows));
|
||||||
|
console.log('bind:', JSON.stringify(bind.rows));
|
||||||
|
await client.end();
|
||||||
|
})().catch((e) => {
|
||||||
|
console.error('FAILED:', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# 开发环境变量
|
# 开发环境变量
|
||||||
VITE_API_BASE_URL=/api
|
VITE_API_BASE_URL=/api
|
||||||
VITE_APP_TITLE=智能物联网设备管理系统
|
VITE_APP_TITLE=鑫潼智电管理系统
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# 生产环境变量
|
# 生产环境变量
|
||||||
VITE_API_BASE_URL=/api
|
VITE_API_BASE_URL=/api
|
||||||
VITE_APP_TITLE=智能物联网设备管理系统
|
VITE_APP_TITLE=鑫潼智电管理系统
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>My Trae Project</title>
|
<title>鑫潼智电管理系统</title>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
if (import.meta.hot?.on) {
|
if (import.meta.hot?.on) {
|
||||||
import.meta.hot.on('vite:error', (error) => {
|
import.meta.hot.on('vite:error', (error) => {
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export const customerApi = {
|
|||||||
|
|
||||||
getCustomerStats: () => request.get<CustomerStats>('/customers/stats'),
|
getCustomerStats: () => request.get<CustomerStats>('/customers/stats'),
|
||||||
|
|
||||||
|
syncIccCustomers: () => request.post('/customers/sync/icc'),
|
||||||
|
|
||||||
createCustomer: (data: CustomerCreateParams) =>
|
createCustomer: (data: CustomerCreateParams) =>
|
||||||
request.post('/customers', data),
|
request.post('/customers', data),
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface CustomerBuildingInfo {
|
|||||||
basicInfo?: string;
|
basicInfo?: string;
|
||||||
purpose?: string;
|
purpose?: string;
|
||||||
floorCount?: number;
|
floorCount?: number;
|
||||||
|
floorDownCount?: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export interface CustomerBuildingCreateParams {
|
|||||||
basicInfo?: string;
|
basicInfo?: string;
|
||||||
purpose?: string;
|
purpose?: string;
|
||||||
floorCount?: number;
|
floorCount?: number;
|
||||||
|
floorDownCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerBuildingUpdateParams {
|
export interface CustomerBuildingUpdateParams {
|
||||||
@@ -38,6 +40,7 @@ export interface CustomerBuildingUpdateParams {
|
|||||||
basicInfo?: string;
|
basicInfo?: string;
|
||||||
purpose?: string;
|
purpose?: string;
|
||||||
floorCount?: number;
|
floorCount?: number;
|
||||||
|
floorDownCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const customerBuildingApi = {
|
export const customerBuildingApi = {
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
|
||||||
|
export interface CustomerCircuitInfo {
|
||||||
|
id: number;
|
||||||
|
customerId: number;
|
||||||
|
buildingId: number;
|
||||||
|
roomId?: number | null;
|
||||||
|
floorNo?: string;
|
||||||
|
circuitCode: string;
|
||||||
|
circuitName: string;
|
||||||
|
loopSpec?: string;
|
||||||
|
loadType?: string;
|
||||||
|
ratedVoltage?: string;
|
||||||
|
ratedCurrent?: string;
|
||||||
|
ratedPower?: number | string | null;
|
||||||
|
status: number;
|
||||||
|
remark?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerCircuitQuery {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
customerId?: number;
|
||||||
|
buildingId?: number;
|
||||||
|
roomId?: number;
|
||||||
|
floorNo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerCircuitListResult {
|
||||||
|
list: CustomerCircuitInfo[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerCircuitCreateParams {
|
||||||
|
circuitCode: string;
|
||||||
|
customerId: number;
|
||||||
|
buildingId: number;
|
||||||
|
circuitName: string;
|
||||||
|
roomId?: number;
|
||||||
|
floorNo?: string;
|
||||||
|
loopSpec?: string;
|
||||||
|
loadType?: string;
|
||||||
|
ratedVoltage?: string;
|
||||||
|
ratedCurrent?: string;
|
||||||
|
ratedPower?: number;
|
||||||
|
status?: number;
|
||||||
|
remark?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerCircuitUpdateParams {
|
||||||
|
circuitCode?: string;
|
||||||
|
customerId?: number;
|
||||||
|
buildingId?: number;
|
||||||
|
circuitName?: string;
|
||||||
|
roomId?: number;
|
||||||
|
floorNo?: string;
|
||||||
|
loopSpec?: string;
|
||||||
|
loadType?: string;
|
||||||
|
ratedVoltage?: string;
|
||||||
|
ratedCurrent?: string;
|
||||||
|
ratedPower?: number;
|
||||||
|
status?: number;
|
||||||
|
remark?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 空间拓扑(一次返回整棵树) ====================
|
||||||
|
export interface TopologyRoom {
|
||||||
|
id: number;
|
||||||
|
roomCode: string;
|
||||||
|
roomName: string;
|
||||||
|
buildingId: number;
|
||||||
|
floorNo?: string;
|
||||||
|
roomPurpose?: string;
|
||||||
|
deptId?: number;
|
||||||
|
circuits: CustomerCircuitInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopologyFloor {
|
||||||
|
floorNo: string;
|
||||||
|
rooms: TopologyRoom[];
|
||||||
|
circuits: CustomerCircuitInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopologyBuilding {
|
||||||
|
id: number;
|
||||||
|
customerId: number;
|
||||||
|
buildingName: string;
|
||||||
|
purpose?: string;
|
||||||
|
basicInfo?: string;
|
||||||
|
floorCount?: number;
|
||||||
|
floorDownCount?: number;
|
||||||
|
floors: TopologyFloor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerTopologyResult {
|
||||||
|
buildings: TopologyBuilding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 常量 ====================
|
||||||
|
export const CIRCUIT_LOAD_TYPES = ['照明', '动力', '空调', '插座', '电梯', '消防', '其他'];
|
||||||
|
|
||||||
|
export const CIRCUIT_STATUS_OPTIONS = [
|
||||||
|
{ label: '正常', value: 1 },
|
||||||
|
{ label: '停用', value: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const customerCircuitApi = {
|
||||||
|
getList: (params: CustomerCircuitQuery) =>
|
||||||
|
request.get<CustomerCircuitListResult>('/customer-circuits', { params }),
|
||||||
|
|
||||||
|
getTopology: (customerId: number) =>
|
||||||
|
request.get<CustomerTopologyResult>('/customer-circuits/topology', { params: { customerId } }),
|
||||||
|
|
||||||
|
getDetail: (id: number) =>
|
||||||
|
request.get<CustomerCircuitInfo>(`/customer-circuits/${id}`),
|
||||||
|
|
||||||
|
create: (data: CustomerCircuitCreateParams) =>
|
||||||
|
request.post('/customer-circuits', data),
|
||||||
|
|
||||||
|
update: (id: number, data: CustomerCircuitUpdateParams) =>
|
||||||
|
request.put(`/customer-circuits/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: number) =>
|
||||||
|
request.delete(`/customer-circuits/${id}`),
|
||||||
|
};
|
||||||
@@ -143,4 +143,124 @@ export const smartDeviceApi = {
|
|||||||
|
|
||||||
delete: (id: number) =>
|
delete: (id: number) =>
|
||||||
request.delete(`/smart-devices/${id}`),
|
request.delete(`/smart-devices/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 智电改造线路台账 ====================
|
||||||
|
|
||||||
|
export interface ElectricLine {
|
||||||
|
id: number;
|
||||||
|
lineCode: string;
|
||||||
|
equipId: number;
|
||||||
|
iccDeviceCode?: string;
|
||||||
|
lineName?: string;
|
||||||
|
customerId: number;
|
||||||
|
buildingId?: number;
|
||||||
|
floorNo?: string;
|
||||||
|
location?: string;
|
||||||
|
status: number;
|
||||||
|
syncedAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
// 联表补全字段
|
||||||
|
customerNo?: string;
|
||||||
|
customerName?: string;
|
||||||
|
equipCode?: string;
|
||||||
|
equipName?: string;
|
||||||
|
buildingName?: 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
@@ -9,11 +9,33 @@ export interface JobParamField {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 频率类型:每N分钟 / 每N小时 / 每天 / 每周 / 每月 / 自定义 cron */
|
||||||
|
export type FreqType = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'custom';
|
||||||
|
|
||||||
|
/** 频率配置(字段按 freqType 取用) */
|
||||||
|
export interface FreqConfig {
|
||||||
|
/** minute/hour:间隔数量 */
|
||||||
|
interval?: number;
|
||||||
|
/** hour:每小时的第几分钟 */
|
||||||
|
minute?: number;
|
||||||
|
/** day/week/month:执行时间点 HH:mm */
|
||||||
|
times?: string[];
|
||||||
|
/** week:星期,0=周日 ... 6=周六 */
|
||||||
|
weekdays?: number[];
|
||||||
|
/** month:日期 1-31 */
|
||||||
|
days?: number[];
|
||||||
|
/** custom:自定义 cron 表达式 */
|
||||||
|
expression?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface JobItem {
|
export interface JobItem {
|
||||||
id: number;
|
id: number;
|
||||||
jobKey: string;
|
jobKey: string;
|
||||||
jobName: string;
|
jobName: string;
|
||||||
cronExpression: string;
|
cronExpression: string;
|
||||||
|
freqType: FreqType;
|
||||||
|
freqConfig: FreqConfig;
|
||||||
|
cronDescription?: string | null;
|
||||||
params: Record<string, any>;
|
params: Record<string, any>;
|
||||||
retryTimes: number;
|
retryTimes: number;
|
||||||
retryInterval: number;
|
retryInterval: number;
|
||||||
@@ -71,13 +93,23 @@ export interface JobLogQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateJobPayload {
|
export interface UpdateJobPayload {
|
||||||
cronExpression: string;
|
freqType: FreqType;
|
||||||
|
freqConfig: FreqConfig;
|
||||||
params: Record<string, any>;
|
params: Record<string, any>;
|
||||||
retryTimes: number;
|
retryTimes: number;
|
||||||
retryInterval: number;
|
retryInterval: number;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 频率预览结果:由后端由频率配置推导 cron、中文描述与未来执行时间 */
|
||||||
|
export interface CronPreviewResult {
|
||||||
|
valid: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
cronExpression?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
nextRuns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface JobListResult {
|
export interface JobListResult {
|
||||||
list: JobItem[];
|
list: JobItem[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -99,6 +131,10 @@ export const jobApi = {
|
|||||||
updateJob: (id: number, data: UpdateJobPayload) =>
|
updateJob: (id: number, data: UpdateJobPayload) =>
|
||||||
request.put<JobItem>(`/jobs/${id}`, data),
|
request.put<JobItem>(`/jobs/${id}`, data),
|
||||||
|
|
||||||
|
/** 频率预览:人性化配置 → cron / 中文描述 / 未来 5 次执行时间 */
|
||||||
|
previewCron: (data: { freqType: FreqType; freqConfig: FreqConfig }) =>
|
||||||
|
request.post<CronPreviewResult>('/jobs/cron/preview', data),
|
||||||
|
|
||||||
changeJobStatus: (id: number, status: 0 | 1) =>
|
changeJobStatus: (id: number, status: 0 | 1) =>
|
||||||
request.put<JobItem>(`/jobs/${id}/status`, { status }),
|
request.put<JobItem>(`/jobs/${id}/status`, { status }),
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ export const SURVEY_PRIORITY_OPTIONS = [
|
|||||||
{ value: 3, label: '常规 · P3' }
|
{ value: 3, label: '常规 · P3' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// SOP 规范模板编码与名称
|
|
||||||
export const SOP_TEMPLATE_CODE = 'standard_dist';
|
|
||||||
export const SOP_TEMPLATE_LABEL = '标准配电改造模板';
|
|
||||||
|
|
||||||
// 报酬规则(后端 dispatch_price 未配置时的前端兜底展示值)
|
// 报酬规则(后端 dispatch_price 未配置时的前端兜底展示值)
|
||||||
export const SURVEY_BASE_FEE = 350;
|
export const SURVEY_BASE_FEE = 350;
|
||||||
export const SURVEY_LOOP_FEE = 5;
|
export const SURVEY_LOOP_FEE = 5;
|
||||||
@@ -61,35 +57,22 @@ export interface SurveyOrderWorker {
|
|||||||
completedCount?: number;
|
completedCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SurveySopItem {
|
// 勘察明细:一条 = 建筑 + 楼层 + 房间(可空) + 现场照片
|
||||||
id: number;
|
export interface SurveyOrderDetail {
|
||||||
templateCode: string;
|
id?: number;
|
||||||
sortOrder: number;
|
surveyOrderId?: number;
|
||||||
itemName: string;
|
buildingId?: number | null;
|
||||||
itemDesc?: string;
|
// 建筑名称(冗余存储,客户拓扑变更后仍可读)
|
||||||
// 回执形式:photo 现场照片 / form 清单表单 / meter 测仪截图
|
buildingName?: string | null;
|
||||||
receiptType: 'photo' | 'form' | 'meter' | string;
|
// 楼层:正数为地上、负数为地下,如 '1' / '-1'
|
||||||
receiptRequire?: string;
|
floorNo?: string | null;
|
||||||
required: boolean;
|
roomId?: number | null;
|
||||||
}
|
roomName?: string | null;
|
||||||
|
// 现场照片对象 key
|
||||||
export interface SurveyOrderSopResult {
|
photos?: string[];
|
||||||
id: number;
|
// 详情接口附带:照片预签名访问 URL(与 photos 顺序一致)
|
||||||
surveyOrderId: number;
|
photoUrls?: string[];
|
||||||
sopItemId: number;
|
remark?: string | null;
|
||||||
checked: boolean;
|
|
||||||
// 回执内容:photo/meter 类为照片对象 key 数组,form 类为单元素文本值数组
|
|
||||||
receiptValue?: string[];
|
|
||||||
// 详情/回执接口附带:照片类回执的预签名访问 URL(与 receiptValue 顺序一致)
|
|
||||||
receiptUrls?: string[];
|
|
||||||
remark?: string;
|
|
||||||
checkedAt?: string;
|
|
||||||
// 由后端 join 的检查项信息
|
|
||||||
sopItem?: SurveySopItem;
|
|
||||||
itemName?: string;
|
|
||||||
receiptType?: string;
|
|
||||||
receiptRequire?: string;
|
|
||||||
sortOrder?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SurveyOrderInfo {
|
export interface SurveyOrderInfo {
|
||||||
@@ -141,8 +124,8 @@ export interface SurveyOrderInfo {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
// 详情附带:SOP 回执
|
// 详情附带:勘察明细(建筑/楼层/房间 + 现场照片)
|
||||||
sopResults?: SurveyOrderSopResult[];
|
details?: SurveyOrderDetail[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SurveyOrderQuery {
|
export interface SurveyOrderQuery {
|
||||||
@@ -209,7 +192,8 @@ export interface SurveyOrderCreateParams {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
isDraft?: number;
|
isDraft?: number;
|
||||||
templateCode?: string;
|
// 勘察明细(创建/编辑时全量提交,新增/编辑/删除统一走此数组)
|
||||||
|
details?: SurveyOrderDetail[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SurveyOrderUpdateParams = Partial<SurveyOrderCreateParams>;
|
export type SurveyOrderUpdateParams = Partial<SurveyOrderCreateParams>;
|
||||||
@@ -247,10 +231,6 @@ export const surveyOrderApi = {
|
|||||||
// KPI 统计卡
|
// KPI 统计卡
|
||||||
getStats: () => request.get<SurveyOrderStats>('/survey-orders/stats'),
|
getStats: () => request.get<SurveyOrderStats>('/survey-orders/stats'),
|
||||||
|
|
||||||
// SOP 规范模板检查项
|
|
||||||
getSopItems: (templateCode: string = SOP_TEMPLATE_CODE) =>
|
|
||||||
request.get<SurveySopItem[]>('/survey-orders/sop-items', { params: { templateCode } }),
|
|
||||||
|
|
||||||
// 报酬测算:基础出场工时费 + 回路数 × 单价
|
// 报酬测算:基础出场工时费 + 回路数 × 单价
|
||||||
getQuote: (loopCount: number) =>
|
getQuote: (loopCount: number) =>
|
||||||
request.get<SurveyQuote>('/survey-orders/quote', { params: { loopCount } }),
|
request.get<SurveyQuote>('/survey-orders/quote', { params: { loopCount } }),
|
||||||
@@ -264,6 +244,10 @@ export const surveyOrderApi = {
|
|||||||
update: (id: number, data: SurveyOrderUpdateParams) =>
|
update: (id: number, data: SurveyOrderUpdateParams) =>
|
||||||
request.put<SurveyOrderInfo>(`/survey-orders/${id}`, data),
|
request.put<SurveyOrderInfo>(`/survey-orders/${id}`, data),
|
||||||
|
|
||||||
|
// 勘察明细:全量替换(新增/编辑/删除统一提交完整数组)
|
||||||
|
saveDetails: (id: number, details: SurveyOrderDetail[]) =>
|
||||||
|
request.put<SurveyOrderDetail[]>(`/survey-orders/${id}/details`, { details }),
|
||||||
|
|
||||||
// 指派 / 改派共享电工
|
// 指派 / 改派共享电工
|
||||||
assign: (id: number, data: SurveyOrderAssignParams) =>
|
assign: (id: number, data: SurveyOrderAssignParams) =>
|
||||||
request.put<SurveyOrderInfo>(`/survey-orders/${id}/assign`, data),
|
request.put<SurveyOrderInfo>(`/survey-orders/${id}/assign`, data),
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export interface WorkerRecord {
|
|||||||
auditBy?: number;
|
auditBy?: number;
|
||||||
auditAt?: string;
|
auditAt?: string;
|
||||||
lastLoginAt?: string;
|
lastLoginAt?: string;
|
||||||
|
// 是否已开通电工 APP 登录账号
|
||||||
|
hasAccount?: boolean;
|
||||||
certStatus?: WorkerCertStatus;
|
certStatus?: WorkerCertStatus;
|
||||||
certs?: WorkerCertSummary[];
|
certs?: WorkerCertSummary[];
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
@@ -119,6 +121,9 @@ export const workerRegistryApi = {
|
|||||||
|
|
||||||
audit: (id: number, data: WorkerAuditPayload) => request.put(`/workers/${id}/audit`, data),
|
audit: (id: number, data: WorkerAuditPayload) => request.put(`/workers/${id}/audit`, data),
|
||||||
|
|
||||||
|
// 重置 APP 登录密码(后端固定重置为手机号后 6 位)
|
||||||
|
resetPassword: (id: number) => request.post(`/workers/${id}/reset-password`),
|
||||||
|
|
||||||
addCert: (id: number, data: WorkerCertPayload) => request.post(`/workers/${id}/certs`, data),
|
addCert: (id: number, data: WorkerCertPayload) => request.post(`/workers/${id}/certs`, data),
|
||||||
|
|
||||||
updateCert: (id: number, certId: number, data: WorkerCertPayload) =>
|
updateCert: (id: number, certId: number, data: WorkerCertPayload) =>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<!-- Logo区域 -->
|
<!-- Logo区域 -->
|
||||||
<div class="logo" @click="$router.push('/dashboard')">
|
<div class="logo" @click="$router.push('/dashboard')">
|
||||||
<ThunderboltOutlined class="logo-img" />
|
<ThunderboltOutlined class="logo-img" />
|
||||||
<h1 v-show="!collapsed" class="logo-title">智能电网IoT</h1>
|
<h1 v-show="!collapsed" class="logo-title">鑫潼智电</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 菜单 -->
|
<!-- 菜单 -->
|
||||||
@@ -402,8 +402,8 @@ const NOTICE_TYPE_ROUTE: Record<string, string> = {
|
|||||||
const NOTICE_BIZ_ROUTE: Record<string, string> = {
|
const NOTICE_BIZ_ROUTE: Record<string, string> = {
|
||||||
repair_dispatch: '/dispatch/repair',
|
repair_dispatch: '/dispatch/repair',
|
||||||
maintenance_order: '/dispatch/maintenance',
|
maintenance_order: '/dispatch/maintenance',
|
||||||
install_order: '/project/install',
|
install_order: '/dispatch/install',
|
||||||
survey_order: '/project/survey',
|
survey_order: '/dispatch/survey',
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveNoticeRoute(item: NoticeInfo): string | null {
|
function resolveNoticeRoute(item: NoticeInfo): string | null {
|
||||||
|
|||||||
+68
-56
@@ -88,39 +88,9 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: "project",
|
path: "project",
|
||||||
name: "Project",
|
name: "Project",
|
||||||
redirect: "/project/survey",
|
redirect: "/project/plan",
|
||||||
meta: { requiresAuth: true, title: "项目管理", permission: "project:list" }
|
meta: { requiresAuth: true, title: "项目管理", permission: "project:list" }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "project/survey",
|
|
||||||
name: "ProjectSurvey",
|
|
||||||
component: () => import("@/views/project/survey/index.vue"),
|
|
||||||
meta: { requiresAuth: true, title: "项目勘察", permission: "project:survey" }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "project/survey/create",
|
|
||||||
name: "ProjectSurveyCreate",
|
|
||||||
component: () => import("@/views/project/survey/form.vue"),
|
|
||||||
meta: {
|
|
||||||
requiresAuth: true,
|
|
||||||
title: "新建勘察派单",
|
|
||||||
permission: "project:survey",
|
|
||||||
parentTitle: "现场勘察派单",
|
|
||||||
parentPath: "/project/survey"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "project/survey/edit/:id",
|
|
||||||
name: "ProjectSurveyEdit",
|
|
||||||
component: () => import("@/views/project/survey/form.vue"),
|
|
||||||
meta: {
|
|
||||||
requiresAuth: true,
|
|
||||||
title: "编辑勘察派单",
|
|
||||||
permission: "project:survey",
|
|
||||||
parentTitle: "现场勘察派单",
|
|
||||||
parentPath: "/project/survey"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "project/plan",
|
path: "project/plan",
|
||||||
name: "ProjectPlan",
|
name: "ProjectPlan",
|
||||||
@@ -145,18 +115,54 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/project/visit/index.vue"),
|
component: () => import("@/views/project/visit/index.vue"),
|
||||||
meta: { requiresAuth: true, title: "回访记录", permission: "project:visit" }
|
meta: { requiresAuth: true, title: "回访记录", permission: "project:visit" }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "project/install",
|
|
||||||
name: "ProjectInstall",
|
|
||||||
component: () => import("@/views/project/install/index.vue"),
|
|
||||||
meta: { requiresAuth: true, title: "安装派单", permission: "project:install" }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "dispatch",
|
path: "dispatch",
|
||||||
name: "Dispatch",
|
name: "Dispatch",
|
||||||
redirect: "/dispatch/repair",
|
redirect: "/dispatch/repair",
|
||||||
meta: { requiresAuth: true, title: "运营派单", permission: "dispatch:list" }
|
meta: { requiresAuth: true, title: "运营派单", permission: "dispatch:list" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "dispatch/worker",
|
||||||
|
name: "DispatchWorker",
|
||||||
|
component: () => import("@/views/dispatch/worker/index.vue"),
|
||||||
|
meta: { requiresAuth: true, title: "电工登记管理", permission: "dispatch:worker" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "dispatch/survey",
|
||||||
|
name: "DispatchSurvey",
|
||||||
|
component: () => import("@/views/dispatch/survey/index.vue"),
|
||||||
|
meta: { requiresAuth: true, title: "项目勘察", permission: "project:survey" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "dispatch/survey/create",
|
||||||
|
name: "DispatchSurveyCreate",
|
||||||
|
component: () => import("@/views/dispatch/survey/form.vue"),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
title: "新建勘察派单",
|
||||||
|
permission: "project:survey",
|
||||||
|
parentTitle: "现场勘察派单",
|
||||||
|
parentPath: "/dispatch/survey"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "dispatch/survey/edit/:id",
|
||||||
|
name: "DispatchSurveyEdit",
|
||||||
|
component: () => import("@/views/dispatch/survey/form.vue"),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
title: "编辑勘察派单",
|
||||||
|
permission: "project:survey",
|
||||||
|
parentTitle: "现场勘察派单",
|
||||||
|
parentPath: "/dispatch/survey"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "dispatch/install",
|
||||||
|
name: "DispatchInstall",
|
||||||
|
component: () => import("@/views/dispatch/install/index.vue"),
|
||||||
|
meta: { requiresAuth: true, title: "安装派单", permission: "project:install" }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "dispatch/repair",
|
path: "dispatch/repair",
|
||||||
name: "DispatchRepair",
|
name: "DispatchRepair",
|
||||||
@@ -169,24 +175,6 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/dispatch/maintenance/index.vue"),
|
component: () => import("@/views/dispatch/maintenance/index.vue"),
|
||||||
meta: { requiresAuth: true, title: "维保派单", permission: "dispatch:maintenance" }
|
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",
|
path: "device",
|
||||||
name: "Device",
|
name: "Device",
|
||||||
@@ -198,6 +186,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: "DeviceEquipment",
|
name: "DeviceEquipment",
|
||||||
component: () => import("@/views/device/equipment/index.vue"),
|
component: () => import("@/views/device/equipment/index.vue"),
|
||||||
meta: { requiresAuth: true, title: "设备管理台账", permission: "device:list" }
|
meta: { requiresAuth: true, title: "设备管理台账", permission: "device:list" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "device/line",
|
||||||
|
name: "DeviceLine",
|
||||||
|
component: () => import("@/views/device/line/index.vue"),
|
||||||
|
meta: { requiresAuth: true, title: "智电改造线路台账", permission: "device:list" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "device/gateway",
|
||||||
|
name: "GatewayLedger",
|
||||||
|
component: () => import("@/views/device/gateway/index.vue"),
|
||||||
|
meta: { requiresAuth: true, title: "智电改造网关台账", permission: "device:list" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "base",
|
path: "base",
|
||||||
@@ -211,6 +211,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/device/smart-device/index.vue"),
|
component: () => import("@/views/device/smart-device/index.vue"),
|
||||||
meta: { requiresAuth: true, title: "智电设备型号", permission: "device:model" }
|
meta: { requiresAuth: true, title: "智电设备型号", permission: "device:model" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "base/price",
|
||||||
|
name: "BasePrice",
|
||||||
|
component: () => import("@/views/base/price/index.vue"),
|
||||||
|
meta: { requiresAuth: true, title: "运维费定价", permission: "dispatch:price" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "base/cost",
|
||||||
|
name: "BaseCost",
|
||||||
|
component: () => import("@/views/base/cost/index.vue"),
|
||||||
|
meta: { requiresAuth: true, title: "勘察费用", permission: "dispatch:cost" }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "report",
|
path: "report",
|
||||||
name: "Report",
|
name: "Report",
|
||||||
@@ -274,7 +286,7 @@ const router = createRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.beforeEach(async (to, _from, next) => {
|
router.beforeEach(async (to, _from, next) => {
|
||||||
document.title = to.meta.title ? `${to.meta.title} - 智能物联网设备管理系统` : "智能物联网设备管理系统";
|
document.title = to.meta.title ? `${to.meta.title} - 鑫潼智电管理系统` : "鑫潼智电管理系统";
|
||||||
|
|
||||||
const permissionStore = usePermissionStore();
|
const permissionStore = usePermissionStore();
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
|||||||
File renamed without changes.
File renamed without changes.
@@ -19,6 +19,10 @@
|
|||||||
<p class="desc">统一维护客户的基础资料、联系信息与业务阶段,支持快速检索与档案管理。</p>
|
<p class="desc">统一维护客户的基础资料、联系信息与业务阶段,支持快速检索与档案管理。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<a-button :loading="syncing" @click="handleSync">
|
||||||
|
<template #icon><SyncOutlined /></template>
|
||||||
|
同步客户
|
||||||
|
</a-button>
|
||||||
<a-button type="primary" @click="handleAdd">
|
<a-button type="primary" @click="handleAdd">
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
新增客户
|
新增客户
|
||||||
@@ -323,13 +327,14 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, computed } from 'vue';
|
import { ref, reactive, onMounted, computed } from 'vue';
|
||||||
import { message } from 'ant-design-vue';
|
import { message, Modal } from 'ant-design-vue';
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
|
SyncOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
AimOutlined,
|
AimOutlined,
|
||||||
FileProtectOutlined,
|
FileProtectOutlined,
|
||||||
@@ -352,6 +357,7 @@ const permissionStore = usePermissionStore();
|
|||||||
|
|
||||||
// ==================== 数据 ====================
|
// ==================== 数据 ====================
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const syncing = ref(false);
|
||||||
const dataSource = ref<CustomerInfo[]>([]);
|
const dataSource = ref<CustomerInfo[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const editNo = ref<string>('');
|
const editNo = ref<string>('');
|
||||||
@@ -568,6 +574,38 @@ function getSalesmanName(id?: number) {
|
|||||||
return found?.label ?? '-';
|
return found?.label ?? '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== ICC 手动同步 ====================
|
||||||
|
function handleSync() {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '同步客户',
|
||||||
|
content: '将从 ICC 平台拉取客户信息,并按 ICC 客户编号幂等新增/更新本地客户档案,是否继续?',
|
||||||
|
okText: '开始同步',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
syncing.value = true;
|
||||||
|
try {
|
||||||
|
const res: any = await customerApi.syncIccCustomers();
|
||||||
|
if (res.code === 200) {
|
||||||
|
const d = res.data || {};
|
||||||
|
const pulled = d.pulled ?? 0;
|
||||||
|
const inserted = d.inserted ?? 0;
|
||||||
|
const updated = d.updated ?? 0;
|
||||||
|
const skipped = d.skipped ?? 0;
|
||||||
|
message.success(`同步完成:拉取 ${pulled} 条,新增 ${inserted} 条,更新 ${updated} 条,跳过 ${skipped} 条`);
|
||||||
|
fetchList();
|
||||||
|
fetchStats();
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '同步失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('同步失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
syncing.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 搜索重置 ====================
|
// ==================== 搜索重置 ====================
|
||||||
function handleSearch() {
|
function handleSearch() {
|
||||||
searchForm.page = 1;
|
searchForm.page = 1;
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -798,7 +798,7 @@ function handleFollow(record: CustomerVisitInfo) {
|
|||||||
// 一键发起勘察派单:跳转勘察派单页并预填客户(不创建、不回写状态)
|
// 一键发起勘察派单:跳转勘察派单页并预填客户(不创建、不回写状态)
|
||||||
function handleSurvey(record: CustomerVisitInfo) {
|
function handleSurvey(record: CustomerVisitInfo) {
|
||||||
if (!record.customerId) return;
|
if (!record.customerId) return;
|
||||||
router.push({ path: '/project/survey', query: { customerId: String(record.customerId) } });
|
router.push({ path: '/dispatch/survey', query: { customerId: String(record.customerId) } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查看关联工程
|
// 查看关联工程
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="card-title-row">
|
<div class="card-title-row">
|
||||||
<h3 class="card-title">近7日业务增长趋势</h3>
|
<h3 class="card-title">近7日业务增长趋势</h3>
|
||||||
<span class="card-tag">REALTIME 新增统计</span>
|
<span class="card-tag">实时新增统计</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="card-sub">每日新增托管客户与新增合同的对比走势</p>
|
<p class="card-sub">每日新增托管客户与新增合同的对比走势</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,12 +138,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="card-title-row">
|
<div class="card-title-row">
|
||||||
<h3 class="card-title">待办勘察派单流水</h3>
|
<h3 class="card-title">待办勘察派单流水</h3>
|
||||||
<span class="live-alerts">LIVE ORDERS</span>
|
<span class="live-alerts">实时单量</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="card-sub">最新勘察派单与承接状态的实时跟踪</p>
|
<p class="card-sub">最新勘察派单与承接状态的实时跟踪</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="ghost-btn" @click="handleRefresh">
|
<button class="ghost-btn" @click="handleRefresh">
|
||||||
<span class="material-symbols-outlined">filter_list</span>
|
<ReloadOutlined />
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,8 +184,14 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="ta-r">
|
<td class="ta-r">
|
||||||
<div class="ops">
|
<div class="ops">
|
||||||
<button class="icon-btn" title="催办"><PhoneOutlined /></button>
|
<a-dropdown trigger="click" placement="bottomRight">
|
||||||
<button class="icon-btn" title="更多"><MoreOutlined /></button>
|
<button class="icon-btn" title="更多"><MoreOutlined /></button>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu @click="({ key }) => handleRowAction(key as string, row)">
|
||||||
|
<a-menu-item key="detail">查看详情</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -196,7 +202,7 @@
|
|||||||
|
|
||||||
<div class="card-foot">
|
<div class="card-foot">
|
||||||
<span class="mono muted">显示 {{ stats?.surveyList?.length || 0 }} / {{ formatNum(stats?.surveyPending) }} 个待办勘察单</span>
|
<span class="mono muted">显示 {{ stats?.surveyList?.length || 0 }} / {{ formatNum(stats?.surveyPending) }} 个待办勘察单</span>
|
||||||
<a class="card-link">进入勘察派单工作流<ArrowRightOutlined /></a>
|
<a class="card-link" @click="goSurveyWorkflow">进入勘察派单工作流<ArrowRightOutlined /></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -241,7 +247,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="card-title-row">
|
<div class="card-title-row">
|
||||||
<h3 class="card-title">电工资质到期提醒</h3>
|
<h3 class="card-title">电工资质到期提醒</h3>
|
||||||
<span class="card-tag">CERT ALERT</span>
|
<span class="card-tag">证照预警</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="card-sub">临期(30 天内)与已过期的电工资质证照清单,已过期优先展示</p>
|
<p class="card-sub">临期(30 天内)与已过期的电工资质证照清单,已过期优先展示</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -297,7 +303,6 @@ import {
|
|||||||
ScheduleOutlined,
|
ScheduleOutlined,
|
||||||
FieldTimeOutlined,
|
FieldTimeOutlined,
|
||||||
DeploymentUnitOutlined,
|
DeploymentUnitOutlined,
|
||||||
PhoneOutlined,
|
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
RiseOutlined,
|
RiseOutlined,
|
||||||
@@ -305,7 +310,7 @@ import {
|
|||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { dashboardApi, type DashboardStats } from '@/api/dashboard';
|
import { dashboardApi, type DashboardStats, type SurveyOrderItem } from '@/api/dashboard';
|
||||||
import { workerRegistryApi } from '@/api/workerRegistry';
|
import { workerRegistryApi } from '@/api/workerRegistry';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -515,6 +520,17 @@ function handleRefresh() {
|
|||||||
loadStats();
|
loadStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 勘察流水行操作
|
||||||
|
function handleRowAction(key: string, row: SurveyOrderItem) {
|
||||||
|
if (key === 'detail') {
|
||||||
|
router.push({ name: 'DispatchSurveyEdit', params: { id: String(row.id) } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goSurveyWorkflow() {
|
||||||
|
router.push('/dispatch/survey');
|
||||||
|
}
|
||||||
|
|
||||||
function initCharts() {
|
function initCharts() {
|
||||||
if (trendChartRef.value) trendChart = echarts.init(trendChartRef.value);
|
if (trendChartRef.value) trendChart = echarts.init(trendChartRef.value);
|
||||||
if (donutChartRef.value) donutChart = echarts.init(donutChartRef.value);
|
if (donutChartRef.value) donutChart = echarts.init(donutChartRef.value);
|
||||||
@@ -905,6 +921,5 @@ function disposeCharts() {
|
|||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.4; }
|
50% { opacity: 0.4; }
|
||||||
}
|
}
|
||||||
.material-symbols-outlined { font-size: 16px; line-height: 1; }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
<template>
|
||||||
|
<div class="device-gateway">
|
||||||
|
<!-- 顶部标题 -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<span>运营管控体系</span>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span>设备监控</span>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span class="cur">智电改造网关台账</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-main">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="header-row">
|
||||||
|
<span class="title-bar"></span>
|
||||||
|
<span class="main-title">智电改造网关台账</span>
|
||||||
|
<span class="sub-title">ELECTRIC GATEWAY LEDGER</span>
|
||||||
|
</div>
|
||||||
|
<p class="desc">由 ICC 平台同步的智电改造网关台账,按客户、设备查看网关及在线状态。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 主体卡片 -->
|
||||||
|
<div class="content-card">
|
||||||
|
<!-- 筛选区 -->
|
||||||
|
<div class="filter-card">
|
||||||
|
<div class="filter-grid">
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label">所属客户</label>
|
||||||
|
<a-select
|
||||||
|
v-model:value="searchForm.customerId"
|
||||||
|
placeholder="全部客户"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
option-filter-prop="label"
|
||||||
|
:options="customerOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label">在线状态</label>
|
||||||
|
<a-select v-model:value="searchForm.isOnline" placeholder="全部状态" allow-clear>
|
||||||
|
<a-select-option :value="1">在线</a-select-option>
|
||||||
|
<a-select-option :value="0">离线</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label">关键词检索</label>
|
||||||
|
<a-input
|
||||||
|
v-model:value="searchForm.keyword"
|
||||||
|
placeholder="设备名称 / 设备编码 / IMEI"
|
||||||
|
allow-clear
|
||||||
|
@pressEnter="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix><SearchOutlined /></template>
|
||||||
|
</a-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-bottom">
|
||||||
|
<div class="filter-actions">
|
||||||
|
<a-button type="primary" @click="handleSearch">
|
||||||
|
<template #icon><SearchOutlined /></template>
|
||||||
|
查询
|
||||||
|
</a-button>
|
||||||
|
<a-button @click="handleReset">
|
||||||
|
<template #icon><ReloadOutlined /></template>
|
||||||
|
重置
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表区 -->
|
||||||
|
<div class="list-section">
|
||||||
|
<div class="list-header">
|
||||||
|
<div class="list-title">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="t">网关台账清单</span>
|
||||||
|
<span class="count">{{ total }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="list-actions">
|
||||||
|
<a-button type="primary" :loading="syncing" @click="handleSync">
|
||||||
|
<template #icon><SyncOutlined /></template>
|
||||||
|
同步网关
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrapper" ref="wrapperRef">
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="dataSource"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
row-key="id"
|
||||||
|
:scroll="{ x: 'max-content', y: tableHeight }"
|
||||||
|
@change="handleTableChange"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'deviceCode'">
|
||||||
|
<span class="mono">{{ record.deviceCode || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'deviceName'">
|
||||||
|
<span class="device-name">{{ record.deviceName || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'iccImei'">
|
||||||
|
<span class="mono">{{ record.iccImei || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'customerId'">
|
||||||
|
{{ record.customerName ? `${record.customerName}(${record.customerNo})` : '-' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'manufacturerName'">
|
||||||
|
{{ record.manufacturerName || '-' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'typeName'">
|
||||||
|
{{ record.typeName || '-' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'productModel'">
|
||||||
|
{{ record.productModel || '-' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'isOnline'">
|
||||||
|
<a-tag :color="record.isOnline === 1 ? 'success' : 'error'">
|
||||||
|
{{ record.isOnline === 1 ? '在线' : '离线' }}
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'syncedAt'">
|
||||||
|
<span>{{ formatTime(record.syncedAt) }}</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #emptyText>
|
||||||
|
<a-empty description="暂无网关数据,可点击右上角「同步网关」从 ICC 拉取" />
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, computed } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import {
|
||||||
|
electricGatewayApi,
|
||||||
|
type ElectricGatewayListItem,
|
||||||
|
type ElectricGatewayQuery,
|
||||||
|
} from '@/api/device';
|
||||||
|
import { customerApi, type CustomerInfo } from '@/api/customer';
|
||||||
|
import { useTableScroll } from '@/composables/useTableScroll';
|
||||||
|
|
||||||
|
const { wrapperRef, tableHeight } = useTableScroll();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const syncing = ref(false);
|
||||||
|
const dataSource = ref<ElectricGatewayListItem[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
|
||||||
|
const searchForm = reactive<ElectricGatewayQuery>({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
keyword: '',
|
||||||
|
customerId: undefined as any,
|
||||||
|
isOnline: undefined as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerOptions = ref<{ label: string; value: number }[]>([]);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '设备名称', dataIndex: 'deviceName', key: 'deviceName', ellipsis: true },
|
||||||
|
{ title: '设备编码', dataIndex: 'deviceCode', key: 'deviceCode', ellipsis: true, width: 170 },
|
||||||
|
{ title: '设备 IMEI', dataIndex: 'iccImei', key: 'iccImei', ellipsis: true, width: 170 },
|
||||||
|
{ title: '客户名称', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
|
||||||
|
{ title: '厂商名称', dataIndex: 'manufacturerName', key: 'manufacturerName', ellipsis: true, width: 140 },
|
||||||
|
{ title: '主机类型', dataIndex: 'typeName', key: 'typeName', ellipsis: true, width: 120 },
|
||||||
|
{ title: '设备型号', dataIndex: 'productModel', key: 'productModel', ellipsis: true, width: 140 },
|
||||||
|
{ title: '在线状态', dataIndex: 'isOnline', key: 'isOnline', align: 'center' as const, width: 90 },
|
||||||
|
{ title: '同步时间', dataIndex: 'syncedAt', key: 'syncedAt', ellipsis: true, width: 170 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const pagination = computed(() => ({
|
||||||
|
current: searchForm.page,
|
||||||
|
pageSize: searchForm.pageSize,
|
||||||
|
total: total.value,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showQuickJumper: true,
|
||||||
|
showTotal: (t: number) => `共 ${t} 条数据`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchList();
|
||||||
|
fetchCustomerOptions();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchList() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res: any = await electricGatewayApi.getList(searchForm);
|
||||||
|
if (res.code === 200) {
|
||||||
|
dataSource.value = res.data.list;
|
||||||
|
total.value = res.data.total;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取网关列表失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCustomerOptions() {
|
||||||
|
try {
|
||||||
|
const res: any = await customerApi.getCustomerOptions();
|
||||||
|
if (res.code === 200) {
|
||||||
|
const list: CustomerInfo[] = res.data;
|
||||||
|
customerOptions.value = list.map((item) => ({
|
||||||
|
label: `${item.customerNo} ${item.customerName}`,
|
||||||
|
value: item.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取客户选项失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
searchForm.page = 1;
|
||||||
|
fetchList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
searchForm.keyword = '';
|
||||||
|
searchForm.customerId = undefined as any;
|
||||||
|
searchForm.isOnline = undefined as any;
|
||||||
|
searchForm.page = 1;
|
||||||
|
fetchList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTableChange(pag: any) {
|
||||||
|
searchForm.page = pag.current;
|
||||||
|
searchForm.pageSize = pag.pageSize;
|
||||||
|
fetchList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSync() {
|
||||||
|
syncing.value = true;
|
||||||
|
try {
|
||||||
|
const res: any = await electricGatewayApi.sync();
|
||||||
|
if (res.code === 200) {
|
||||||
|
const d = res.data || {};
|
||||||
|
message.success(`同步完成:拉取 ${d.pulled ?? 0} 条,新增 ${d.inserted ?? 0},更新 ${d.updated ?? 0}`);
|
||||||
|
fetchList();
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '同步失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('同步失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
syncing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(val?: string) {
|
||||||
|
if (!val) return '-';
|
||||||
|
const d = new Date(val);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.device-gateway {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
|
||||||
|
padding: 20px 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #5b6b7e;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb .sep {
|
||||||
|
color: #c2c6d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb .cur {
|
||||||
|
color: #0054cd;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar {
|
||||||
|
width: 5px;
|
||||||
|
height: 22px;
|
||||||
|
background: #0054cd;
|
||||||
|
border-radius: 3px;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1c2c;
|
||||||
|
line-height: 1.4;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0054cd;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5b6b7e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.07);
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #f8faff;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.filter-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.filter-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #5b6b7e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #dbe4ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-section {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 2px 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title .dot {
|
||||||
|
width: 4px;
|
||||||
|
height: 16px;
|
||||||
|
background: #0054cd;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title .t {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title .count {
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #eef4ff;
|
||||||
|
color: #0054cd;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: Consolas, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: Consolas, Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d1c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0d1c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-table) {
|
||||||
|
font-size: 14px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-table-thead > tr > th) {
|
||||||
|
background: #f4f7ff;
|
||||||
|
color: #5b6b7e;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-btn-primary) {
|
||||||
|
background: #0054cd;
|
||||||
|
border-color: #0054cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-btn-primary:not(:disabled):hover),
|
||||||
|
:deep(.ant-btn-primary:not(:disabled):focus) {
|
||||||
|
background: #136bfb;
|
||||||
|
border-color: #136bfb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
<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="线路名称 / 线路编码"
|
||||||
|
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 === 'customerId'">
|
||||||
|
{{ record.customerName ? `${record.customerName}(${record.customerNo})` : '-' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'equipId'">
|
||||||
|
{{ record.equipName ? `${record.equipName}(${record.equipCode})` : '-' }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="column.key === 'buildingId'">
|
||||||
|
{{ record.buildingName || '-' }}
|
||||||
|
</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: '所属客户', dataIndex: 'customerId', key: 'customerId', ellipsis: true, width: 180 },
|
||||||
|
{ title: '所属设备', dataIndex: 'equipId', key: 'equipId', ellipsis: true, width: 180 },
|
||||||
|
{ title: '所属建筑', dataIndex: 'buildingId', key: 'buildingId', ellipsis: true, width: 140 },
|
||||||
|
{ title: '所在楼层', dataIndex: 'floorNo', key: 'floorNo', ellipsis: true, width: 100 },
|
||||||
|
{ title: '所在位置', dataIndex: 'location', key: 'location', ellipsis: true },
|
||||||
|
{ title: '状态', dataIndex: '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>
|
||||||
File renamed without changes.
+445
-217
@@ -229,34 +229,135 @@
|
|||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<span class="bar bar-secondary"></span>
|
<span class="bar bar-secondary"></span>
|
||||||
<FileDoneOutlined class="sec-icon clr-secondary" />
|
<FileDoneOutlined class="sec-icon clr-secondary" />
|
||||||
<h2>第 2 步:勘察技术要点与回执标准</h2>
|
<h2>第 2 步:勘察明细</h2>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isReadonly" class="tmpl-box">
|
<div v-if="!isReadonly" class="head-actions">
|
||||||
<span class="head-hint">SOP规范模板:</span>
|
<span class="head-hint">按建筑 / 楼层 / 房间逐条登记,可附现场照片</span>
|
||||||
<button type="button" class="tmpl-btn" @click="applySopTemplate">
|
<a-button size="small" type="primary" ghost @click="addDetailRow">
|
||||||
<PlusOutlined />
|
<template #icon><PlusOutlined /></template>
|
||||||
{{ SOP_TEMPLATE_LABEL }}(已应用 {{ sopItems.length }} 项)
|
添加明细
|
||||||
</button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sop-grid" :class="{ 'is-empty': !sopLoading && sopItems.length === 0 }">
|
<div class="detail-body">
|
||||||
<div v-for="item in sopItems" :key="item.id" class="sop-card">
|
<!-- 编辑态:明细编辑行 -->
|
||||||
<div class="sop-top">
|
<template v-if="!isReadonly">
|
||||||
<span class="sop-name">{{ item.sortOrder }}. {{ item.itemName }}</span>
|
<div v-if="detailRows.length === 0" class="detail-empty">
|
||||||
<span v-if="item.required" class="sop-required">必填</span>
|
暂无勘察明细,点击右上角「添加明细」逐条登记建筑、楼层与房间
|
||||||
</div>
|
</div>
|
||||||
<div class="sop-desc">{{ item.itemDesc || '—' }}</div>
|
<div v-else class="detail-rows">
|
||||||
<div class="sop-foot">
|
<div v-for="(row, idx) in detailRows" :key="row.uid" class="detail-row">
|
||||||
<span>{{ receiptTypeLabel(item.receiptType) }}</span>
|
<div class="detail-row-head">
|
||||||
<span>{{ item.receiptRequire || '—' }}</span>
|
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
||||||
|
<a-button type="text" size="small" danger @click="removeDetailRow(idx)">
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="dfield">
|
||||||
|
<label class="dfield-label">建筑 <b class="req">*</b></label>
|
||||||
|
<a-select
|
||||||
|
v-model:value="row.buildingId"
|
||||||
|
placeholder="选择建筑"
|
||||||
|
:loading="topologyLoading"
|
||||||
|
:options="buildingSelectOptions"
|
||||||
|
:not-found-content="topologyLoading ? null : '该客户暂无建筑'"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="handleRowBuildingChange(row)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="dfield">
|
||||||
|
<label class="dfield-label">楼层 <b class="req">*</b></label>
|
||||||
|
<a-select
|
||||||
|
v-model:value="row.floorNo"
|
||||||
|
placeholder="选择楼层"
|
||||||
|
:disabled="!row.buildingId"
|
||||||
|
:options="floorOptionsFor(row.buildingId)"
|
||||||
|
:not-found-content="row.buildingId ? '该建筑未配置楼层数' : '请先选择建筑'"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="handleRowFloorChange(row)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="dfield">
|
||||||
|
<label class="dfield-label">房间 <span class="opt">选填</span></label>
|
||||||
|
<a-select
|
||||||
|
v-model:value="row.roomId"
|
||||||
|
placeholder="可不选"
|
||||||
|
allow-clear
|
||||||
|
:disabled="!row.floorNo"
|
||||||
|
:options="roomOptionsFor(row)"
|
||||||
|
:not-found-content="row.floorNo ? '该楼层暂无房间' : '请先选择楼层'"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="handleRowRoomChange(row)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="dfield span-all">
|
||||||
|
<label class="dfield-label">现场照片</label>
|
||||||
|
<div class="detail-photos">
|
||||||
|
<a-image-preview-group>
|
||||||
|
<div v-for="(p, pi) in row.photos" :key="pi" class="photo-thumb">
|
||||||
|
<a-image :src="p.url" :width="76" :height="57" />
|
||||||
|
<span class="photo-del" @click.stop="removePhoto(row, pi)">×</span>
|
||||||
|
</div>
|
||||||
|
</a-image-preview-group>
|
||||||
|
<a-upload
|
||||||
|
:show-upload-list="false"
|
||||||
|
:custom-request="(o: any) => handlePhotoUpload(o, row)"
|
||||||
|
:before-upload="beforePhotoUpload"
|
||||||
|
accept="image/*"
|
||||||
|
>
|
||||||
|
<button type="button" class="photo-add" :disabled="row.photos.length >= MAX_DETAIL_PHOTOS">
|
||||||
|
<PlusOutlined />
|
||||||
|
<span>上传照片</span>
|
||||||
|
</button>
|
||||||
|
</a-upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dfield span-all">
|
||||||
|
<label class="dfield-label">备注</label>
|
||||||
|
<input
|
||||||
|
v-model="row.remark"
|
||||||
|
class="ipt"
|
||||||
|
type="text"
|
||||||
|
maxlength="500"
|
||||||
|
placeholder="该条明细的现场说明,如设备位置、隐患点等"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<div v-if="!isReadonly" class="sop-add" @click="handleAddCustomItem">
|
<!-- 只读态:明细展示 -->
|
||||||
<PlusOutlined class="sop-add-icon" />
|
<template v-else>
|
||||||
<span>+ 自定义勘察检查项</span>
|
<div v-if="detailRows.length === 0" class="detail-empty">暂无勘察明细</div>
|
||||||
</div>
|
<div v-else class="detail-rows">
|
||||||
|
<div v-for="(row, idx) in detailRows" :key="row.uid" class="detail-ro-row">
|
||||||
|
<div class="detail-ro-head">
|
||||||
|
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
||||||
|
<span class="detail-ro-scope">
|
||||||
|
{{ row.buildingName || '—' }} · {{ floorLabel(row.floorNo) }}
|
||||||
|
<template v-if="row.roomName"> · {{ row.roomName }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.photos.length" class="detail-ro-photos">
|
||||||
|
<a-image-preview-group>
|
||||||
|
<a-image
|
||||||
|
v-for="(p, pi) in row.photos"
|
||||||
|
:key="pi"
|
||||||
|
:src="p.url"
|
||||||
|
:width="110"
|
||||||
|
:height="82"
|
||||||
|
style="object-fit: cover; border-radius: 8px"
|
||||||
|
/>
|
||||||
|
</a-image-preview-group>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.remark" class="detail-ro-remark">备注:{{ row.remark }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -362,10 +463,6 @@
|
|||||||
<span class="ro-k">测绘进度</span>
|
<span class="ro-k">测绘进度</span>
|
||||||
<span class="ro-v mono">{{ roProgressDone }} / {{ roProgressTotal }} 路</span>
|
<span class="ro-v mono">{{ roProgressDone }} / {{ roProgressTotal }} 路</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ro-fact">
|
|
||||||
<span class="ro-k">SOP 回执</span>
|
|
||||||
<span class="ro-v mono">{{ roSopChecked }} / {{ roSopResults.length }} 项</span>
|
|
||||||
</div>
|
|
||||||
<div class="ro-fact">
|
<div class="ro-fact">
|
||||||
<span class="ro-k">现场照片</span>
|
<span class="ro-k">现场照片</span>
|
||||||
<span class="ro-v mono">{{ roPhotos.length }} 张</span>
|
<span class="ro-v mono">{{ roPhotos.length }} 张</span>
|
||||||
@@ -387,25 +484,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SOP 回执明细 -->
|
|
||||||
<div class="ro-block">
|
|
||||||
<div class="ro-block-title">SOP 回执明细</div>
|
|
||||||
<div v-if="roSopResults.length === 0" class="ro-empty">暂无 SOP 回执记录</div>
|
|
||||||
<div v-else class="ro-list">
|
|
||||||
<div v-for="(r, idx) in roSopResults" :key="r.id || idx" class="ro-sop-row">
|
|
||||||
<span class="ro-sop-check" :class="{ done: r.checked }">
|
|
||||||
<CheckOutlined v-if="r.checked" />
|
|
||||||
</span>
|
|
||||||
<span class="ro-sop-name">{{ r.itemName || r.sopItem?.itemName || `检查项 #${r.sopItemId}` }}</span>
|
|
||||||
<span class="ro-sop-tag">{{ receiptTypeLabel(r.receiptType || r.sopItem?.receiptType) }}</span>
|
|
||||||
<div v-if="r.receiptUrls && r.receiptUrls.length" class="ro-sop-thumbs">
|
|
||||||
<a-image v-for="(url, i) in r.receiptUrls" :key="i" :src="url" :width="56" :height="42" class="ro-sop-thumb" />
|
|
||||||
</div>
|
|
||||||
<span v-else class="ro-sop-value mono">{{ r.receiptValue?.length ? r.receiptValue.join('、') : r.remark || '—' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 现场取证照片 -->
|
<!-- 现场取证照片 -->
|
||||||
<div class="ro-block">
|
<div class="ro-block">
|
||||||
<div class="ro-block-title">现场取证照片</div>
|
<div class="ro-block-title">现场取证照片</div>
|
||||||
@@ -500,26 +578,27 @@ import {
|
|||||||
CheckCircleFilled,
|
CheckCircleFilled,
|
||||||
StarFilled,
|
StarFilled,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
ApartmentOutlined,
|
ApartmentOutlined,
|
||||||
FileDoneOutlined,
|
FileDoneOutlined,
|
||||||
ToolOutlined,
|
ToolOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import {
|
import {
|
||||||
surveyOrderApi,
|
surveyOrderApi,
|
||||||
SOP_TEMPLATE_CODE,
|
|
||||||
SOP_TEMPLATE_LABEL,
|
|
||||||
SURVEY_STATUS,
|
SURVEY_STATUS,
|
||||||
SURVEY_PRIORITY,
|
SURVEY_PRIORITY,
|
||||||
SURVEY_PRIORITY_OPTIONS,
|
SURVEY_PRIORITY_OPTIONS,
|
||||||
SURVEY_BASE_FEE,
|
SURVEY_BASE_FEE,
|
||||||
SURVEY_LOOP_FEE,
|
SURVEY_LOOP_FEE,
|
||||||
type SurveySopItem,
|
|
||||||
type SurveyQuote,
|
type SurveyQuote,
|
||||||
type SurveyOrderCreateParams,
|
type SurveyOrderCreateParams,
|
||||||
type SurveyOrderInfo,
|
type SurveyOrderInfo,
|
||||||
|
type SurveyOrderDetail,
|
||||||
} from '@/api/surveyOrder';
|
} from '@/api/surveyOrder';
|
||||||
import { workerApi, type WorkerRecommend } from '@/api/worker';
|
import { workerApi, type WorkerRecommend } from '@/api/worker';
|
||||||
import { customerApi, type CustomerInfo } from '@/api/customer';
|
import { customerApi, type CustomerInfo } from '@/api/customer';
|
||||||
|
import { customerCircuitApi, type CustomerTopologyResult, type TopologyBuilding } from '@/api/customerCircuit';
|
||||||
|
import { uploadApi } from '@/api/upload';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -527,16 +606,10 @@ const route = useRoute();
|
|||||||
// ==================== 步骤元数据 ====================
|
// ==================== 步骤元数据 ====================
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ value: 1, name: '基本信息与现场环境', sub: '目标客户、区域与回路' },
|
{ value: 1, name: '基本信息与现场环境', sub: '目标客户、区域与回路' },
|
||||||
{ value: 2, name: '技术要点与回执标准', sub: '标准SOP模版、拍照指标' },
|
{ value: 2, name: '勘察明细', sub: '建筑、楼层、房间与照片' },
|
||||||
{ value: 3, name: '指派电工与服务报酬', sub: '匹配电工、资质与费率' },
|
{ value: 3, name: '指派电工与服务报酬', sub: '匹配电工、资质与费率' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const RECEIPT_TYPE_LABEL: Record<string, string> = {
|
|
||||||
photo: '现场照片',
|
|
||||||
form: '清单格式',
|
|
||||||
meter: '手持测仪',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SLA_OPTIONS = [
|
const SLA_OPTIONS = [
|
||||||
{ value: 'SLA-GRADE A (24h回传)', label: 'SLA-GRADE A (24h回传)' },
|
{ value: 'SLA-GRADE A (24h回传)', label: 'SLA-GRADE A (24h回传)' },
|
||||||
{ value: 'SLA-GRADE B (48h回传)', label: 'SLA-GRADE B (48h回传)' },
|
{ value: 'SLA-GRADE B (48h回传)', label: 'SLA-GRADE B (48h回传)' },
|
||||||
@@ -571,8 +644,6 @@ const STATUS_META: Record<number, { label: string; color: string; bg: string }>
|
|||||||
|
|
||||||
const statusMeta = computed(() => (detailRecord.value ? STATUS_META[detailRecord.value.status] || null : null));
|
const statusMeta = computed(() => (detailRecord.value ? STATUS_META[detailRecord.value.status] || null : null));
|
||||||
const roPhotos = computed(() => (detailRecord.value?.photoUrls || []).filter((u) => !!u));
|
const roPhotos = computed(() => (detailRecord.value?.photoUrls || []).filter((u) => !!u));
|
||||||
const roSopResults = computed(() => detailRecord.value?.sopResults || []);
|
|
||||||
const roSopChecked = computed(() => roSopResults.value.filter((r) => r.checked).length);
|
|
||||||
const roProgressDone = computed(() => Number(detailRecord.value?.progressDone) || 0);
|
const roProgressDone = computed(() => Number(detailRecord.value?.progressDone) || 0);
|
||||||
const roProgressTotal = computed(() => Number(detailRecord.value?.progressTotal) || 0);
|
const roProgressTotal = computed(() => Number(detailRecord.value?.progressTotal) || 0);
|
||||||
const roProgressPercent = computed(() => {
|
const roProgressPercent = computed(() => {
|
||||||
@@ -625,13 +696,165 @@ const customerSelectOptions = computed(() =>
|
|||||||
);
|
);
|
||||||
const selectedCustomer = computed(() => customerOptions.value.find((c) => c.id === formState.customerId) || null);
|
const selectedCustomer = computed(() => customerOptions.value.find((c) => c.id === formState.customerId) || null);
|
||||||
|
|
||||||
// ==================== SOP 检查项 ====================
|
// ==================== 客户空间拓扑 ====================
|
||||||
const sopItems = ref<SurveySopItem[]>([]);
|
const topology = ref<CustomerTopologyResult | null>(null);
|
||||||
const sopLoading = ref(false);
|
const topologyLoading = ref(false);
|
||||||
|
const buildingSelectOptions = computed(() =>
|
||||||
|
(topology.value?.buildings || []).map((b) => ({ label: b.buildingName, value: b.id }))
|
||||||
|
);
|
||||||
|
|
||||||
function receiptTypeLabel(type?: string) {
|
function buildingById(id?: number): TopologyBuilding | undefined {
|
||||||
if (!type) return '—';
|
if (!id) return undefined;
|
||||||
return RECEIPT_TYPE_LABEL[type] || type;
|
return (topology.value?.buildings || []).find((b) => b.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 楼层选项:地上 1..floorCount,地下 负1..负floorDownCount;value 为字符串(地上 '1' / 地下 '-1')
|
||||||
|
function buildFloorOptions(b?: TopologyBuilding): { label: string; value: string }[] {
|
||||||
|
if (!b) return [];
|
||||||
|
const up = b.floorCount ?? 0;
|
||||||
|
const down = b.floorDownCount ?? 0;
|
||||||
|
const opts: { label: string; value: string }[] = [];
|
||||||
|
for (let i = down; i >= 1; i--) opts.push({ label: `负${i}层`, value: String(-i) });
|
||||||
|
for (let i = 1; i <= up; i++) opts.push({ label: `${i}层`, value: String(i) });
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function floorOptionsFor(buildingId?: number) {
|
||||||
|
return buildFloorOptions(buildingById(buildingId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function floorLabel(floorNo?: string | null) {
|
||||||
|
if (floorNo === undefined || floorNo === null || floorNo === '') return '未标注楼层';
|
||||||
|
const n = Number(floorNo);
|
||||||
|
if (Number.isNaN(n)) return String(floorNo);
|
||||||
|
return n < 0 ? `负${Math.abs(n)}层` : `${n}层`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomOptionsFor(row: DetailRow) {
|
||||||
|
const b = buildingById(row.buildingId);
|
||||||
|
if (!b || !row.floorNo) return [];
|
||||||
|
return b.floors
|
||||||
|
.filter((f) => (f.floorNo || '') === row.floorNo)
|
||||||
|
.flatMap((f) => f.rooms)
|
||||||
|
.map((r) => ({ label: r.roomName, value: r.id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTopology() {
|
||||||
|
if (!formState.customerId) {
|
||||||
|
topology.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
topologyLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res: any = await customerCircuitApi.getTopology(formState.customerId);
|
||||||
|
if (res.code === 200 && res.data) {
|
||||||
|
topology.value = res.data;
|
||||||
|
} else {
|
||||||
|
topology.value = { buildings: [] };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
topology.value = { buildings: [] };
|
||||||
|
} finally {
|
||||||
|
topologyLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 勘察明细 ====================
|
||||||
|
interface DetailPhoto {
|
||||||
|
// 对象存储 key(提交时使用)
|
||||||
|
key: string;
|
||||||
|
// 预签名访问 URL(预览使用)
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailRow {
|
||||||
|
uid: number;
|
||||||
|
buildingId?: number;
|
||||||
|
buildingName?: string;
|
||||||
|
floorNo?: string;
|
||||||
|
roomId?: number;
|
||||||
|
roomName?: string;
|
||||||
|
photos: DetailPhoto[];
|
||||||
|
remark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DETAIL_PHOTOS = 9;
|
||||||
|
let detailUidSeed = 0;
|
||||||
|
const nextDetailUid = () => ++detailUidSeed;
|
||||||
|
|
||||||
|
const detailRows = ref<DetailRow[]>([]);
|
||||||
|
|
||||||
|
function addDetailRow() {
|
||||||
|
detailRows.value.push({ uid: nextDetailUid(), photos: [], remark: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDetailRow(index: number) {
|
||||||
|
detailRows.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowBuildingChange(row: DetailRow) {
|
||||||
|
row.buildingName = buildingById(row.buildingId)?.buildingName;
|
||||||
|
row.floorNo = undefined;
|
||||||
|
row.roomId = undefined;
|
||||||
|
row.roomName = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowFloorChange(row: DetailRow) {
|
||||||
|
row.roomId = undefined;
|
||||||
|
row.roomName = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowRoomChange(row: DetailRow) {
|
||||||
|
const room = roomOptionsFor(row).find((r) => r.value === row.roomId);
|
||||||
|
row.roomName = room?.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beforePhotoUpload(file: File) {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('仅支持上传图片文件');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
message.error('图片大小不能超过 10MB');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePhotoUpload(options: any, row: DetailRow) {
|
||||||
|
const { file, onSuccess, onError } = options;
|
||||||
|
try {
|
||||||
|
const res: any = await uploadApi.uploadImage(file as File);
|
||||||
|
if (res.code === 200 && res.data) {
|
||||||
|
row.photos.push({ key: res.data.key, url: res.data.url });
|
||||||
|
onSuccess?.(res.data);
|
||||||
|
} else {
|
||||||
|
throw new Error(res.message || '上传失败');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '照片上传失败');
|
||||||
|
onError?.(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePhoto(row: DetailRow, index: number) {
|
||||||
|
row.photos.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅保留建筑与楼层均完整的明细行(后端同样会丢弃缺项行)
|
||||||
|
function buildDetails(): SurveyOrderDetail[] {
|
||||||
|
return detailRows.value
|
||||||
|
.filter((row) => !!row.buildingId && !!row.floorNo)
|
||||||
|
.map((row) => ({
|
||||||
|
buildingId: row.buildingId,
|
||||||
|
buildingName: row.buildingName || buildingById(row.buildingId)?.buildingName || undefined,
|
||||||
|
floorNo: row.floorNo,
|
||||||
|
roomId: row.roomId ?? undefined,
|
||||||
|
roomName: row.roomName || undefined,
|
||||||
|
photos: row.photos.filter((p) => !!p.key).map((p) => p.key),
|
||||||
|
remark: row.remark.trim() || undefined,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 报酬与电工 ====================
|
// ==================== 报酬与电工 ====================
|
||||||
@@ -676,7 +899,7 @@ function clearError(key: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goLedger() {
|
function goLedger() {
|
||||||
router.push({ name: 'ProjectSurvey' });
|
router.push({ name: 'DispatchSurvey' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCustomerChange() {
|
function handleCustomerChange() {
|
||||||
@@ -690,6 +913,9 @@ function handleCustomerChange() {
|
|||||||
formState.techContact = `${customer.contactPerson}${customer.contactPhone ? ` - ${customer.contactPhone}` : ''}`;
|
formState.techContact = `${customer.contactPerson}${customer.contactPhone ? ` - ${customer.contactPhone}` : ''}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 客户变更后拓扑重建,清空已填明细避免残留失效的建筑/楼层
|
||||||
|
detailRows.value = [];
|
||||||
|
loadTopology();
|
||||||
loadWorkers();
|
loadWorkers();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,18 +924,6 @@ function selectWorker(w: WorkerRecommend) {
|
|||||||
formState.workerId = w.id;
|
formState.workerId = w.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySopTemplate() {
|
|
||||||
if (sopItems.value.length === 0) {
|
|
||||||
loadSopItems();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
message.success(`已应用${SOP_TEMPLATE_LABEL} ${sopItems.value.length} 项检查标准`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAddCustomItem() {
|
|
||||||
message.info('自定义勘察检查项功能暂未开放');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 数据加载 ====================
|
// ==================== 数据加载 ====================
|
||||||
async function loadCustomers() {
|
async function loadCustomers() {
|
||||||
try {
|
try {
|
||||||
@@ -723,20 +937,6 @@ async function loadCustomers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSopItems() {
|
|
||||||
sopLoading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await surveyOrderApi.getSopItems(SOP_TEMPLATE_CODE);
|
|
||||||
if (res.code === 200 && res.data) {
|
|
||||||
sopItems.value = res.data;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error('SOP 检查项加载失败');
|
|
||||||
} finally {
|
|
||||||
sopLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadQuote() {
|
async function loadQuote() {
|
||||||
const count = Number(formState.loopCount) || 0;
|
const count = Number(formState.loopCount) || 0;
|
||||||
try {
|
try {
|
||||||
@@ -802,6 +1002,17 @@ async function loadDetail() {
|
|||||||
d.planEnd ? dayjs(d.planEnd) : null,
|
d.planEnd ? dayjs(d.planEnd) : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
// 回填勘察明细:photos 为对象 key,photoUrls 为预览地址(顺序一致)
|
||||||
|
detailRows.value = (d.details || []).map((dt) => ({
|
||||||
|
uid: nextDetailUid(),
|
||||||
|
buildingId: dt.buildingId ?? undefined,
|
||||||
|
buildingName: dt.buildingName ?? undefined,
|
||||||
|
floorNo: dt.floorNo ?? undefined,
|
||||||
|
roomId: dt.roomId ?? undefined,
|
||||||
|
roomName: dt.roomName ?? undefined,
|
||||||
|
photos: (dt.photos || []).map((key, i) => ({ key, url: dt.photoUrls?.[i] || '' })),
|
||||||
|
remark: dt.remark || '',
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
message.error(res.message || '获取勘察派单详情失败');
|
message.error(res.message || '获取勘察派单详情失败');
|
||||||
}
|
}
|
||||||
@@ -857,7 +1068,7 @@ function buildPayload(isDraft: number): SurveyOrderCreateParams {
|
|||||||
remark: formState.remark.trim() || undefined,
|
remark: formState.remark.trim() || undefined,
|
||||||
source: 'ledger',
|
source: 'ledger',
|
||||||
isDraft,
|
isDraft,
|
||||||
templateCode: SOP_TEMPLATE_CODE,
|
details: buildDetails(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,15 +1122,16 @@ async function handleSubmit(draft: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 客户拜访台账「一键发起勘察派单」跳转预填:/project/survey/create?customerId=xx
|
// 客户拜访台账「一键发起勘察派单」跳转预填:/dispatch/survey/create?customerId=xx
|
||||||
const preCustomerId = Number(route.query.customerId || 0);
|
const preCustomerId = Number(route.query.customerId || 0);
|
||||||
if (preCustomerId && !isEdit.value) {
|
if (preCustomerId && !isEdit.value) {
|
||||||
formState.customerId = preCustomerId;
|
formState.customerId = preCustomerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadCustomers();
|
await loadCustomers();
|
||||||
await Promise.all([loadSopItems(), loadQuote(), loadWorkers()]);
|
// 编辑态先取详情(含 customerId 与明细回填),再按客户加载空间拓扑
|
||||||
await loadDetail();
|
await loadDetail();
|
||||||
|
await Promise.all([loadTopology(), loadQuote(), loadWorkers()]);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1338,107 +1550,178 @@ onMounted(async () => {
|
|||||||
background: #0054cd;
|
background: #0054cd;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 第 2 步:SOP ---------- */
|
/* ---------- 第 2 步:勘察明细 ---------- */
|
||||||
.tmpl-box {
|
.head-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.tmpl-btn {
|
.detail-body {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 4px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: rgba(113, 42, 226, 0.1);
|
|
||||||
color: #712ae2;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
.tmpl-btn:hover {
|
|
||||||
background: rgba(113, 42, 226, 0.2);
|
|
||||||
}
|
|
||||||
.sop-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 18px 20px 20px;
|
padding: 18px 20px 20px;
|
||||||
}
|
}
|
||||||
.sop-grid.is-empty {
|
.detail-empty {
|
||||||
grid-template-columns: 1fr;
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5b6b7e;
|
||||||
|
background: #eef4ff;
|
||||||
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.sop-card {
|
.detail-rows {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
gap: 12px;
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid rgba(219, 228, 238, 0.5);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #eef4ff;
|
|
||||||
}
|
}
|
||||||
.sop-top {
|
.detail-row {
|
||||||
display: flex;
|
border: 1px solid #dbe4ee;
|
||||||
align-items: flex-start;
|
border-radius: 10px;
|
||||||
justify-content: space-between;
|
background: #f8fafc;
|
||||||
gap: 8px;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.sop-name {
|
.detail-row-head {
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0d1c2c;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.sop-required {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: rgba(220, 38, 38, 0.1);
|
|
||||||
color: #dc2626;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.sop-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: #5b6b7e;
|
|
||||||
}
|
|
||||||
.sop-foot {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding-top: 8px;
|
gap: 8px;
|
||||||
border-top: 1px solid rgba(219, 228, 238, 0.5);
|
padding: 8px 14px;
|
||||||
font-family: 'JetBrains Mono', Consolas, monospace;
|
background: #eef4ff;
|
||||||
|
border-bottom: 1px solid #e2eaf5;
|
||||||
|
}
|
||||||
|
.detail-row-idx {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #0054cd;
|
color: #0054cd;
|
||||||
}
|
}
|
||||||
.sop-add {
|
.detail-grid {
|
||||||
min-height: 96px;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.dfield {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.dfield.span-all {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.dfield-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1c2c;
|
||||||
|
}
|
||||||
|
.dfield-label .opt {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #8a97a8;
|
||||||
|
}
|
||||||
|
.detail-photos {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.detail-photos :deep(.ant-image) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.detail-photos :deep(.ant-image img) {
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
}
|
||||||
|
.photo-thumb {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.photo-del {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(13, 28, 44, 0.72);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.photo-del:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
.photo-add {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
|
width: 76px;
|
||||||
|
height: 57px;
|
||||||
border: 1px dashed #c2c6d8;
|
border: 1px dashed #c2c6d8;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
color: #5b6b7e;
|
color: #5b6b7e;
|
||||||
font-size: 13px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
.sop-add:hover {
|
.photo-add:hover:not(:disabled) {
|
||||||
border-color: rgba(0, 84, 205, 0.5);
|
border-color: rgba(0, 84, 205, 0.5);
|
||||||
color: #0054cd;
|
color: #0054cd;
|
||||||
}
|
}
|
||||||
.sop-add-icon {
|
.photo-add:disabled {
|
||||||
font-size: 22px;
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
/* 只读态明细 */
|
||||||
|
.detail-ro-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #dbe4ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.detail-ro-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.detail-ro-scope {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d1c2c;
|
||||||
|
}
|
||||||
|
.detail-ro-photos {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.detail-ro-photos :deep(.ant-image img) {
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.detail-ro-remark {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #5b6b7e;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 第 3 步:电工与报酬 ---------- */
|
/* ---------- 第 3 步:电工与报酬 ---------- */
|
||||||
@@ -1719,61 +2002,6 @@ onMounted(async () => {
|
|||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.ro-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.ro-sop-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 20px 1fr 88px 1.2fr;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f8fafc;
|
|
||||||
font-size: 12.5px;
|
|
||||||
color: #0d1c2c;
|
|
||||||
}
|
|
||||||
.ro-sop-check {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 1px solid #dbe4ee;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #fff;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.ro-sop-check.done {
|
|
||||||
background: #00685f;
|
|
||||||
border-color: #00685f;
|
|
||||||
}
|
|
||||||
.ro-sop-name {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.ro-sop-tag {
|
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #712ae2;
|
|
||||||
}
|
|
||||||
.ro-sop-value {
|
|
||||||
font-size: 11.5px;
|
|
||||||
color: #5b6b7e;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.ro-sop-thumbs {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.ro-sop-thumb :deep(img) {
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid #dbe4ee;
|
|
||||||
}
|
|
||||||
.ro-photo-list {
|
.ro-photo-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1868,7 +2096,7 @@ onMounted(async () => {
|
|||||||
.field-grid {
|
.field-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.sop-grid {
|
.detail-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.cand-grid {
|
.cand-grid {
|
||||||
+59
-46
@@ -514,29 +514,31 @@
|
|||||||
<div v-else class="detail-text">—</div>
|
<div v-else class="detail-text">—</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="detailRecord.sopResults?.length" class="detail-block">
|
<div v-if="detailRecord.details?.length" class="detail-block">
|
||||||
<div class="detail-label">
|
<div class="detail-label">勘察明细({{ detailRecord.details.length }} 条)</div>
|
||||||
勘察回执({{ detailSopDone }} / {{ detailRecord.sopResults.length }} 项已完成)
|
<div class="detail-list">
|
||||||
</div>
|
<div v-for="(row, idx) in detailRecord.details" :key="row.id ?? idx" class="detail-row">
|
||||||
<div v-for="row in detailRecord.sopResults" :key="row.id" class="sop-item">
|
<div class="detail-row-head">
|
||||||
<div class="sop-item-head">
|
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
|
||||||
<span class="sop-item-name">{{ row.itemName || row.sopItem?.itemName || '检查项' }}</span>
|
<span class="detail-row-scope">
|
||||||
<span class="chip" :style="sopChipStyle(row.checked)">{{ row.checked ? '已回执' : '未回执' }}</span>
|
{{ row.buildingName || '—' }} · {{ floorLabel(row.floorNo) }}
|
||||||
|
<template v-if="row.roomName"> · {{ row.roomName }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.photoUrls?.length" class="photo-list">
|
||||||
|
<a-image-preview-group>
|
||||||
|
<a-image
|
||||||
|
v-for="(url, pi) in row.photoUrls"
|
||||||
|
:key="pi"
|
||||||
|
:src="url"
|
||||||
|
:width="110"
|
||||||
|
:height="80"
|
||||||
|
style="object-fit: cover; border-radius: 8px"
|
||||||
|
/>
|
||||||
|
</a-image-preview-group>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.remark" class="detail-row-remark">备注:{{ row.remark }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="row.receiptUrls?.length" class="photo-list">
|
|
||||||
<a-image
|
|
||||||
v-for="(url, idx) in row.receiptUrls"
|
|
||||||
:key="idx"
|
|
||||||
:src="url"
|
|
||||||
:width="110"
|
|
||||||
:height="80"
|
|
||||||
style="object-fit: cover; border-radius: 8px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="row.receiptValue?.length" class="detail-text">
|
|
||||||
{{ row.receiptValue.join('、') }}
|
|
||||||
</div>
|
|
||||||
<div v-if="row.remark" class="sop-item-remark">备注:{{ row.remark }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -809,7 +811,7 @@ onMounted(() => {
|
|||||||
fetchWorkerOptions();
|
fetchWorkerOptions();
|
||||||
const preCustomerId = Number(route.query.customerId || 0);
|
const preCustomerId = Number(route.query.customerId || 0);
|
||||||
if (preCustomerId) {
|
if (preCustomerId) {
|
||||||
router.replace({ name: 'ProjectSurveyCreate', query: { customerId: String(preCustomerId) } });
|
router.replace({ name: 'DispatchSurveyCreate', query: { customerId: String(preCustomerId) } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -848,7 +850,7 @@ function handleExport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleAdd() {
|
function handleAdd() {
|
||||||
router.push({ name: 'ProjectSurveyCreate' });
|
router.push({ name: 'DispatchSurveyCreate' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 行内操作 ====================
|
// ==================== 行内操作 ====================
|
||||||
@@ -1120,9 +1122,6 @@ async function handleCancelSubmit() {
|
|||||||
// ==================== 详情 / 编辑 ====================
|
// ==================== 详情 / 编辑 ====================
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
const detailRecord = ref<SurveyOrderInfo | null>(null);
|
const detailRecord = ref<SurveyOrderInfo | null>(null);
|
||||||
const detailSopDone = computed(
|
|
||||||
() => (detailRecord.value?.sopResults || []).filter((row) => row.checked).length
|
|
||||||
);
|
|
||||||
|
|
||||||
async function openDetail(record: SurveyOrderInfo) {
|
async function openDetail(record: SurveyOrderInfo) {
|
||||||
detailRecord.value = record;
|
detailRecord.value = record;
|
||||||
@@ -1136,7 +1135,7 @@ async function openDetail(record: SurveyOrderInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goDetail(record: SurveyOrderInfo) {
|
function goDetail(record: SurveyOrderInfo) {
|
||||||
router.push({ name: 'ProjectSurveyEdit', params: { id: record.id } });
|
router.push({ name: 'DispatchSurveyEdit', params: { id: record.id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 展示辅助 ====================
|
// ==================== 展示辅助 ====================
|
||||||
@@ -1152,12 +1151,13 @@ function formatRange(start?: string, end?: string) {
|
|||||||
return `${s} ~ ${e}`;
|
return `${s} ~ ${e}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sopChipStyle = (checked?: boolean) =>
|
// 楼层:正数为地上、负数为地下(value 为字符串,如 '1' / '-1')
|
||||||
chipStyle(
|
function floorLabel(floorNo?: string | null) {
|
||||||
checked
|
if (floorNo === undefined || floorNo === null || floorNo === '') return '未标注楼层';
|
||||||
? { color: '#059669', bg: '#d1fae5', border: '#a7f3d0' }
|
const n = Number(floorNo);
|
||||||
: { color: '#94a3b8', bg: '#f8fafc', border: '#e2e8f0' }
|
if (Number.isNaN(n)) return String(floorNo);
|
||||||
);
|
return n < 0 ? `负${Math.abs(n)}层` : `${n}层`;
|
||||||
|
}
|
||||||
|
|
||||||
function money(value?: number | string) {
|
function money(value?: number | string) {
|
||||||
if (value == null || value === '') return '—';
|
if (value == null || value === '') return '—';
|
||||||
@@ -1926,33 +1926,46 @@ function maskPhone(phone?: string) {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sop-item {
|
.detail-list {
|
||||||
padding: 10px 0;
|
display: flex;
|
||||||
border-bottom: 1px dashed #eef1f6;
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sop-item:last-child {
|
.detail-row {
|
||||||
border-bottom: none;
|
display: flex;
|
||||||
padding-bottom: 0;
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #eef1f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sop-item-head {
|
.detail-row-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
margin-bottom: 6px;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sop-item-name {
|
.detail-row-idx {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0054cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row-scope {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #0d1c2c;
|
color: #0d1c2c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sop-item-remark {
|
.detail-row-remark {
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
color: #8a97a8;
|
color: #8a97a8;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* AntD 覆盖 */
|
/* AntD 覆盖 */
|
||||||
@@ -165,6 +165,19 @@
|
|||||||
<template #icon><AuditOutlined /></template>
|
<template #icon><AuditOutlined /></template>
|
||||||
审核
|
审核
|
||||||
</a-button>
|
</a-button>
|
||||||
|
<a-popconfirm
|
||||||
|
v-if="canResetPassword"
|
||||||
|
title="确定重置该电工的 APP 登录密码吗?"
|
||||||
|
description="重置后密码为手机号后 6 位"
|
||||||
|
ok-text="确定"
|
||||||
|
cancel-text="取消"
|
||||||
|
@confirm="handleResetPassword(record.id)"
|
||||||
|
>
|
||||||
|
<a-button type="link" size="small">
|
||||||
|
<template #icon><KeyOutlined /></template>
|
||||||
|
重置密码
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
v-if="canDelete"
|
v-if="canDelete"
|
||||||
title="确定要删除该电工吗?"
|
title="确定要删除该电工吗?"
|
||||||
@@ -240,8 +253,11 @@
|
|||||||
<a-form-item label="服务评分" name="rating">
|
<a-form-item label="服务评分" name="rating">
|
||||||
<a-input-number v-model:value="formData.rating" :min="0" :max="5" :step="0.1" style="width: 160px" />
|
<a-input-number v-model:value="formData.rating" :min="0" :max="5" :step="0.1" style="width: 160px" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item v-if="!isEdit" label="APP 登录账号">
|
<a-form-item label="APP 登录账号">
|
||||||
<div class="switch-line">
|
<div v-if="isEdit && formData.hasAccount" class="switch-line">
|
||||||
|
<span class="form-tip">该电工已开通 APP 登录账号,如需重置密码请使用列表中的「重置密码」。</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="switch-line">
|
||||||
<a-switch v-model:checked="formData.withAccount" />
|
<a-switch v-model:checked="formData.withAccount" />
|
||||||
<span class="form-tip">开启后同时创建电工 APP 登录密码,默认密码为手机号后 6 位</span>
|
<span class="form-tip">开启后同时创建电工 APP 登录密码,默认密码为手机号后 6 位</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -381,6 +397,7 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
AuditOutlined,
|
AuditOutlined,
|
||||||
|
KeyOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
@@ -425,6 +442,7 @@ const canCreate = computed(() => permissionStore.hasPermission('worker:create'))
|
|||||||
const canUpdate = computed(() => permissionStore.hasPermission('worker:update'));
|
const canUpdate = computed(() => permissionStore.hasPermission('worker:update'));
|
||||||
const canDelete = computed(() => permissionStore.hasPermission('worker:delete'));
|
const canDelete = computed(() => permissionStore.hasPermission('worker:delete'));
|
||||||
const canAudit = computed(() => permissionStore.hasPermission('worker:audit'));
|
const canAudit = computed(() => permissionStore.hasPermission('worker:audit'));
|
||||||
|
const canResetPassword = computed(() => permissionStore.hasPermission('worker:reset-password'));
|
||||||
|
|
||||||
// ==================== 列表数据 ====================
|
// ==================== 列表数据 ====================
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -501,7 +519,7 @@ const columns = [
|
|||||||
{ title: '在岗状态', key: 'status', align: 'center' as const, width: 110 },
|
{ title: '在岗状态', key: 'status', align: 'center' as const, width: 110 },
|
||||||
{ title: '评分', key: 'rating', align: 'center' as const, width: 90 },
|
{ title: '评分', key: 'rating', align: 'center' as const, width: 90 },
|
||||||
{ title: '完成数', dataIndex: 'completedCount', key: 'completedCount', align: 'center' as const, width: 90 },
|
{ title: '完成数', dataIndex: 'completedCount', key: 'completedCount', align: 'center' as const, width: 90 },
|
||||||
{ title: '操作', key: 'action', width: 210, fixed: 'right' as const },
|
{ title: '操作', key: 'action', width: 300, fixed: 'right' as const },
|
||||||
];
|
];
|
||||||
|
|
||||||
const pagination = computed(() => ({
|
const pagination = computed(() => ({
|
||||||
@@ -529,6 +547,8 @@ const formData = reactive({
|
|||||||
status: 1,
|
status: 1,
|
||||||
rating: 5,
|
rating: 5,
|
||||||
withAccount: false,
|
withAccount: false,
|
||||||
|
// 编辑时用于判断该电工是否已开通 APP 账号
|
||||||
|
hasAccount: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const formRules = {
|
const formRules = {
|
||||||
@@ -664,6 +684,8 @@ async function handleEdit(record: WorkerRecord) {
|
|||||||
formData.workerTitle = record.workerTitle || '';
|
formData.workerTitle = record.workerTitle || '';
|
||||||
formData.status = record.status;
|
formData.status = record.status;
|
||||||
formData.rating = Number(record.rating ?? 5);
|
formData.rating = Number(record.rating ?? 5);
|
||||||
|
formData.hasAccount = record.hasAccount === true;
|
||||||
|
formData.withAccount = false;
|
||||||
|
|
||||||
modalVisible.value = true;
|
modalVisible.value = true;
|
||||||
await loadCertRows(record.id);
|
await loadCertRows(record.id);
|
||||||
@@ -674,6 +696,7 @@ async function loadCertRows(workerId: number) {
|
|||||||
try {
|
try {
|
||||||
const res: any = await workerRegistryApi.getDetail(workerId);
|
const res: any = await workerRegistryApi.getDetail(workerId);
|
||||||
if (res.code === 200 && res.data) {
|
if (res.code === 200 && res.data) {
|
||||||
|
formData.hasAccount = res.data.hasAccount === true;
|
||||||
const certs: WorkerCert[] = res.data.certs || [];
|
const certs: WorkerCert[] = res.data.certs || [];
|
||||||
certRows.value = certs.map((c) => ({
|
certRows.value = certs.map((c) => ({
|
||||||
_key: certKeySeed++,
|
_key: certKeySeed++,
|
||||||
@@ -700,6 +723,7 @@ function resetForm() {
|
|||||||
formData.status = 1;
|
formData.status = 1;
|
||||||
formData.rating = 5;
|
formData.rating = 5;
|
||||||
formData.withAccount = false;
|
formData.withAccount = false;
|
||||||
|
formData.hasAccount = false;
|
||||||
certRows.value = [];
|
certRows.value = [];
|
||||||
|
|
||||||
if (formRef.value) {
|
if (formRef.value) {
|
||||||
@@ -822,6 +846,8 @@ async function handleSubmit() {
|
|||||||
let res: any;
|
let res: any;
|
||||||
if (isEdit.value && editId.value) {
|
if (isEdit.value && editId.value) {
|
||||||
payload.status = formData.status;
|
payload.status = formData.status;
|
||||||
|
// 补开 APP 账号:仅对尚未开通的电工生效
|
||||||
|
payload.withAccount = formData.withAccount;
|
||||||
res = await workerRegistryApi.update(editId.value, payload);
|
res = await workerRegistryApi.update(editId.value, payload);
|
||||||
} else {
|
} else {
|
||||||
payload.withAccount = formData.withAccount;
|
payload.withAccount = formData.withAccount;
|
||||||
@@ -880,6 +906,21 @@ async function handleDelete(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 重置密码 ====================
|
||||||
|
async function handleResetPassword(id: number) {
|
||||||
|
try {
|
||||||
|
const res: any = await workerRegistryApi.resetPassword(id);
|
||||||
|
if (res.code === 200) {
|
||||||
|
message.success('密码已重置为手机号后 6 位');
|
||||||
|
fetchList();
|
||||||
|
} else {
|
||||||
|
message.error(res.message || '重置密码失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('重置密码失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 审核 ====================
|
// ==================== 审核 ====================
|
||||||
function handleAudit(record: WorkerRecord) {
|
function handleAudit(record: WorkerRecord) {
|
||||||
auditRecord.value = record;
|
auditRecord.value = record;
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
<div class="logo-icon">
|
<div class="logo-icon">
|
||||||
<ThunderboltOutlined />
|
<ThunderboltOutlined />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="title">智能电网设备管理系统</h1>
|
<h1 class="title">鑫潼智电管理系统</h1>
|
||||||
<p class="subtitle">Smart Grid IoT Management System</p>
|
<p class="subtitle">XINTONG SMART POWER MANAGEMENT SYSTEM</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-divider />
|
<a-divider />
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="copyright">
|
<div class="copyright">
|
||||||
<p>© 2026 智能电网设备管理系统 All Rights Reserved</p>
|
<p>© 2026 鑫潼智电管理系统 All Rights Reserved</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -53,6 +53,12 @@
|
|||||||
<span>{{ record.jobName }}</span>
|
<span>{{ record.jobName }}</span>
|
||||||
<a-tag v-if="record.registered === false" color="red" style="margin-left: 6px">未注册</a-tag>
|
<a-tag v-if="record.registered === false" color="red" style="margin-left: 6px">未注册</a-tag>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="column.key === 'frequency'">
|
||||||
|
<div class="freq-cell">
|
||||||
|
<span>{{ record.cronDescription || record.cronExpression }}</span>
|
||||||
|
<code class="freq-cron">{{ record.cronExpression }}</code>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<template v-else-if="column.key === 'params'">
|
<template v-else-if="column.key === 'params'">
|
||||||
<a-tooltip :title="formatParams(record.params, true)">
|
<a-tooltip :title="formatParams(record.params, true)">
|
||||||
<span class="ellipsis-text">{{ formatParams(record.params) }}</span>
|
<span class="ellipsis-text">{{ formatParams(record.params) }}</span>
|
||||||
@@ -111,7 +117,7 @@
|
|||||||
title="编辑任务"
|
title="编辑任务"
|
||||||
:confirm-loading="saving"
|
:confirm-loading="saving"
|
||||||
:mask-closable="false"
|
:mask-closable="false"
|
||||||
width="640px"
|
width="720px"
|
||||||
@ok="handleSave"
|
@ok="handleSave"
|
||||||
@cancel="handleCancelEdit"
|
@cancel="handleCancelEdit"
|
||||||
>
|
>
|
||||||
@@ -125,9 +131,110 @@
|
|||||||
<a-form-item label="任务名称">
|
<a-form-item label="任务名称">
|
||||||
<span>{{ currentJob?.jobName || '-' }}</span>
|
<span>{{ currentJob?.jobName || '-' }}</span>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="cron 表达式" name="cronExpression">
|
|
||||||
<a-input v-model:value="formData.cronExpression" placeholder="如 0 3 * * *" :maxlength="64" />
|
<a-divider orientation="left" class="form-divider">执行频率</a-divider>
|
||||||
|
|
||||||
|
<a-form-item label="频率类型">
|
||||||
|
<a-select v-model:value="formData.freqType" :options="freqTypeOptions" style="width: 100%" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="formData.freqType === 'minute'" label="间隔">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="formData.interval"
|
||||||
|
:min="1"
|
||||||
|
:max="59"
|
||||||
|
addon-after="分钟"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<template v-if="formData.freqType === 'hour'">
|
||||||
|
<a-form-item label="间隔">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="formData.interval"
|
||||||
|
:min="1"
|
||||||
|
:max="23"
|
||||||
|
addon-after="小时"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="第几分钟">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="formData.minute"
|
||||||
|
:min="0"
|
||||||
|
:max="59"
|
||||||
|
addon-after="分"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-form-item v-if="formData.freqType === 'week'" label="星期">
|
||||||
|
<a-checkbox-group v-model:value="formData.weekdays" :options="weekdayOptions" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="formData.freqType === 'month'" label="日期">
|
||||||
|
<a-select
|
||||||
|
v-model:value="formData.days"
|
||||||
|
mode="multiple"
|
||||||
|
:options="dayOptions"
|
||||||
|
:max-tag-count="6"
|
||||||
|
placeholder="请选择每月的执行日期"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="hasTimeList" label="执行时间">
|
||||||
|
<div class="time-list">
|
||||||
|
<div v-for="(_, index) in formData.times" :key="index" class="time-row">
|
||||||
|
<a-time-picker v-model:value="formData.times[index]" format="HH:mm" :allow-clear="false" />
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
:disabled="formData.times.length <= 1"
|
||||||
|
@click="removeTime(index)"
|
||||||
|
>
|
||||||
|
<template #icon><MinusCircleOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<a-button type="dashed" block :disabled="formData.times.length >= 10" @click="addTime">
|
||||||
|
<template #icon><PlusOutlined /></template>
|
||||||
|
添加时间
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item v-if="formData.freqType === 'custom'" label="cron 表达式">
|
||||||
|
<a-input v-model:value="formData.expression" placeholder="如 0 3 * * *(分 时 日 月 周)" :maxlength="64" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item label="执行预览">
|
||||||
|
<a-spin :spinning="previewLoading" size="small">
|
||||||
|
<div class="freq-preview">
|
||||||
|
<a-alert v-if="preview.error" type="warning" :message="preview.error" show-icon />
|
||||||
|
<template v-else>
|
||||||
|
<div class="preview-line">
|
||||||
|
<span class="preview-label">表达式</span>
|
||||||
|
<code>{{ preview.cronExpression || '-' }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="preview-line">
|
||||||
|
<span class="preview-label">说明</span>
|
||||||
|
<span>{{ preview.description || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview-line">
|
||||||
|
<span class="preview-label">下次执行</span>
|
||||||
|
<span v-if="!preview.nextRuns.length">-</span>
|
||||||
|
<ol v-else class="next-list">
|
||||||
|
<li v-for="(time, index) in preview.nextRuns" :key="index">{{ formatDateTime(time) }}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</a-spin>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-divider v-if="paramFields.length" orientation="left" class="form-divider">任务参数</a-divider>
|
||||||
|
|
||||||
<a-form-item
|
<a-form-item
|
||||||
v-for="field in paramFields"
|
v-for="field in paramFields"
|
||||||
:key="field.key"
|
:key="field.key"
|
||||||
@@ -149,6 +256,9 @@
|
|||||||
:placeholder="field.placeholder || `请输入${field.label}`"
|
:placeholder="field.placeholder || `请输入${field.label}`"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-divider orientation="left" class="form-divider">失败重试与备注</a-divider>
|
||||||
|
|
||||||
<a-form-item label="重试次数" name="retryTimes">
|
<a-form-item label="重试次数" name="retryTimes">
|
||||||
<a-input-number v-model:value="formData.retryTimes" :min="0" :max="5" style="width: 100%" />
|
<a-input-number v-model:value="formData.retryTimes" :min="0" :max="5" style="width: 100%" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -228,7 +338,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import {
|
import {
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
@@ -237,9 +347,18 @@ import {
|
|||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
ProfileOutlined,
|
ProfileOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
MinusCircleOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import dayjs from 'dayjs'
|
import dayjs, { type Dayjs } from 'dayjs'
|
||||||
import { jobApi, type JobItem, type JobParamField, type JobLogItem } from '@/api/job'
|
import {
|
||||||
|
jobApi,
|
||||||
|
type JobItem,
|
||||||
|
type JobParamField,
|
||||||
|
type JobLogItem,
|
||||||
|
type FreqType,
|
||||||
|
type FreqConfig,
|
||||||
|
} from '@/api/job'
|
||||||
import { useTableScroll } from '@/composables/useTableScroll'
|
import { useTableScroll } from '@/composables/useTableScroll'
|
||||||
|
|
||||||
const { wrapperRef, tableHeight } = useTableScroll()
|
const { wrapperRef, tableHeight } = useTableScroll()
|
||||||
@@ -260,7 +379,7 @@ const queryParams = reactive({
|
|||||||
const columns = [
|
const columns = [
|
||||||
{ title: '任务名称', dataIndex: 'jobName', key: 'jobName', width: 180 },
|
{ title: '任务名称', dataIndex: 'jobName', key: 'jobName', width: 180 },
|
||||||
{ title: '任务键', dataIndex: 'jobKey', key: 'jobKey', width: 150, ellipsis: true },
|
{ title: '任务键', dataIndex: 'jobKey', key: 'jobKey', width: 150, ellipsis: true },
|
||||||
{ title: 'cron 表达式', dataIndex: 'cronExpression', key: 'cronExpression', width: 150 },
|
{ title: '执行频率', key: 'frequency', width: 200 },
|
||||||
{ title: '参数', dataIndex: 'params', key: 'params', width: 180, ellipsis: true },
|
{ title: '参数', dataIndex: 'params', key: 'params', width: 180, ellipsis: true },
|
||||||
{ title: '状态', key: 'status', width: 100, align: 'center' as const },
|
{ title: '状态', key: 'status', width: 100, align: 'center' as const },
|
||||||
{ title: '上次执行时间', dataIndex: 'lastRunAt', key: 'lastRunAt', width: 170 },
|
{ title: '上次执行时间', dataIndex: 'lastRunAt', key: 'lastRunAt', width: 170 },
|
||||||
@@ -286,24 +405,174 @@ const currentJob = ref<JobItem | null>(null)
|
|||||||
const paramFields = ref<JobParamField[]>([])
|
const paramFields = ref<JobParamField[]>([])
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
|
|
||||||
|
/** HH:mm 文本 → Dayjs(TimePicker 取值),不依赖 dayjs 自定义解析插件 */
|
||||||
|
function toTimeValue(text: string): Dayjs {
|
||||||
|
const [hour, minute] = text.split(':')
|
||||||
|
return dayjs().hour(Number(hour)).minute(Number(minute)).second(0).millisecond(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人性化频率表单:字段按 freqType 取用,提交时由 buildFreqConfig 组装成频率配置 */
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
cronExpression: '',
|
freqType: 'day' as FreqType,
|
||||||
|
interval: 5,
|
||||||
|
minute: 0,
|
||||||
|
times: [toTimeValue('08:00')] as Dayjs[],
|
||||||
|
weekdays: [1] as number[],
|
||||||
|
days: [1] as number[],
|
||||||
|
expression: '',
|
||||||
params: {} as Record<string, any>,
|
params: {} as Record<string, any>,
|
||||||
retryTimes: 0,
|
retryTimes: 0,
|
||||||
retryInterval: 60,
|
retryInterval: 60,
|
||||||
remark: '',
|
remark: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const freqTypeOptions: { value: FreqType; label: string }[] = [
|
||||||
|
{ value: 'minute', label: '每 N 分钟' },
|
||||||
|
{ value: 'hour', label: '每 N 小时' },
|
||||||
|
{ value: 'day', label: '每天' },
|
||||||
|
{ value: 'week', label: '每周' },
|
||||||
|
{ value: 'month', label: '每月' },
|
||||||
|
{ value: 'custom', label: '自定义(cron 表达式)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const weekdayOptions = [
|
||||||
|
{ value: 1, label: '周一' },
|
||||||
|
{ value: 2, label: '周二' },
|
||||||
|
{ value: 3, label: '周三' },
|
||||||
|
{ value: 4, label: '周四' },
|
||||||
|
{ value: 5, label: '周五' },
|
||||||
|
{ value: 6, label: '周六' },
|
||||||
|
{ value: 0, label: '周日' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const dayOptions = Array.from({ length: 31 }, (_, index) => ({
|
||||||
|
value: index + 1,
|
||||||
|
label: `${index + 1} 日`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** day/week/month 需要执行时间点列表 */
|
||||||
|
const hasTimeList = computed(() => ['day', 'week', 'month'].includes(formData.freqType))
|
||||||
|
|
||||||
|
/** 执行预览状态(由后端计算 cron / 中文描述 / 未来执行时间) */
|
||||||
|
const previewLoading = ref(false)
|
||||||
|
const preview = reactive({
|
||||||
|
valid: true,
|
||||||
|
error: '',
|
||||||
|
cronExpression: '',
|
||||||
|
description: '',
|
||||||
|
nextRuns: [] as string[],
|
||||||
|
})
|
||||||
|
|
||||||
const formRules = {
|
const formRules = {
|
||||||
cronExpression: [
|
|
||||||
{ required: true, message: '请输入 cron 表达式', trigger: 'blur' },
|
|
||||||
{ max: 64, message: 'cron 表达式长度不能超过 64', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
retryTimes: [{ required: true, type: 'number' as const, message: '请输入重试次数', trigger: 'change' }],
|
retryTimes: [{ required: true, type: 'number' as const, message: '请输入重试次数', trigger: 'change' }],
|
||||||
retryInterval: [{ required: true, type: 'number' as const, message: '请输入重试间隔', trigger: 'change' }],
|
retryInterval: [{ required: true, type: 'number' as const, message: '请输入重试间隔', trigger: 'change' }],
|
||||||
remark: [{ max: 255, message: '备注长度不能超过 255', trigger: 'blur' }],
|
remark: [{ max: 255, message: '备注长度不能超过 255', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 表单 → 频率配置(提交给后端的结构) */
|
||||||
|
function buildFreqConfig(): FreqConfig {
|
||||||
|
switch (formData.freqType) {
|
||||||
|
case 'minute':
|
||||||
|
return { interval: formData.interval }
|
||||||
|
case 'hour':
|
||||||
|
return { interval: formData.interval, minute: formData.minute }
|
||||||
|
case 'day':
|
||||||
|
return { times: timeTexts() }
|
||||||
|
case 'week':
|
||||||
|
return { weekdays: [...formData.weekdays].sort((a, b) => a - b), times: timeTexts() }
|
||||||
|
case 'month':
|
||||||
|
return { days: [...formData.days].sort((a, b) => a - b), times: timeTexts() }
|
||||||
|
default:
|
||||||
|
return { expression: formData.expression.trim() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeTexts(): string[] {
|
||||||
|
return formData.times.map(item => item.format('HH:mm'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 频率配置 → 表单(编辑回填) */
|
||||||
|
function applyFreqConfig(job: JobItem) {
|
||||||
|
const config: FreqConfig = job.freqConfig || {}
|
||||||
|
const type: FreqType = job.freqType || 'custom'
|
||||||
|
|
||||||
|
formData.freqType = type
|
||||||
|
formData.interval = config.interval ?? (type === 'hour' ? 1 : 5)
|
||||||
|
formData.minute = config.minute ?? 0
|
||||||
|
formData.times = (config.times?.length ? config.times : ['08:00']).map(item => toTimeValue(item))
|
||||||
|
formData.weekdays = config.weekdays?.length ? [...config.weekdays] : [1]
|
||||||
|
formData.days = config.days?.length ? [...config.days] : [1]
|
||||||
|
formData.expression = config.expression || job.cronExpression || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端校验之外的本地必填提示,避免空表单直接提交 */
|
||||||
|
function validateFreqConfig(): string {
|
||||||
|
if (hasTimeList.value && !formData.times.length) return '请至少添加 1 个执行时间'
|
||||||
|
if (formData.freqType === 'week' && !formData.weekdays.length) return '请至少选择 1 个星期'
|
||||||
|
if (formData.freqType === 'month' && !formData.days.length) return '请至少选择 1 个日期'
|
||||||
|
if (formData.freqType === 'custom' && !formData.expression.trim()) return '请输入 cron 表达式'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTime() {
|
||||||
|
if (formData.times.length >= 10) return
|
||||||
|
formData.times.push(toTimeValue('12:00'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTime(index: number) {
|
||||||
|
if (formData.times.length <= 1) return
|
||||||
|
formData.times.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let previewTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
/** 已预览过(且成功过)的频率签名,避免打开弹窗时重复请求 */
|
||||||
|
let previewSignature = ''
|
||||||
|
|
||||||
|
async function fetchPreview() {
|
||||||
|
if (!editVisible.value) return
|
||||||
|
|
||||||
|
const signature = JSON.stringify({ type: formData.freqType, config: buildFreqConfig() })
|
||||||
|
if (signature === previewSignature) return
|
||||||
|
|
||||||
|
previewLoading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await jobApi.previewCron({
|
||||||
|
freqType: formData.freqType,
|
||||||
|
freqConfig: buildFreqConfig(),
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
preview.valid = !!res.data.valid
|
||||||
|
preview.error = res.data.error || ''
|
||||||
|
preview.cronExpression = res.data.cronExpression || ''
|
||||||
|
preview.description = res.data.description || ''
|
||||||
|
preview.nextRuns = res.data.nextRuns || []
|
||||||
|
previewSignature = signature
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 预览失败不打断编辑,保存时后端仍会兜底校验
|
||||||
|
previewSignature = ''
|
||||||
|
} finally {
|
||||||
|
previewLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 频率任一字段变化 → 防抖后重新预览 */
|
||||||
|
watch(
|
||||||
|
() => JSON.stringify({ type: formData.freqType, config: buildFreqConfig() }),
|
||||||
|
() => {
|
||||||
|
clearTimeout(previewTimer)
|
||||||
|
previewTimer = setTimeout(fetchPreview, 300)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function resetPreview() {
|
||||||
|
preview.valid = true
|
||||||
|
preview.error = ''
|
||||||
|
preview.cronExpression = ''
|
||||||
|
preview.description = ''
|
||||||
|
preview.nextRuns = []
|
||||||
|
}
|
||||||
|
|
||||||
const logVisible = ref(false)
|
const logVisible = ref(false)
|
||||||
const logLoading = ref(false)
|
const logLoading = ref(false)
|
||||||
const logList = ref<JobLogItem[]>([])
|
const logList = ref<JobLogItem[]>([])
|
||||||
@@ -441,13 +710,16 @@ async function handleStatusChange(record: JobItem, checked: boolean) {
|
|||||||
function handleEdit(record: JobItem) {
|
function handleEdit(record: JobItem) {
|
||||||
currentJob.value = record
|
currentJob.value = record
|
||||||
paramFields.value = record.paramFields || []
|
paramFields.value = record.paramFields || []
|
||||||
formData.cronExpression = record.cronExpression || ''
|
applyFreqConfig(record)
|
||||||
formData.params = { ...(record.params || {}) }
|
formData.params = { ...(record.params || {}) }
|
||||||
formData.retryTimes = record.retryTimes ?? 0
|
formData.retryTimes = record.retryTimes ?? 0
|
||||||
formData.retryInterval = record.retryInterval ?? 60
|
formData.retryInterval = record.retryInterval ?? 60
|
||||||
formData.remark = record.remark || ''
|
formData.remark = record.remark || ''
|
||||||
|
resetPreview()
|
||||||
|
previewSignature = ''
|
||||||
editVisible.value = true
|
editVisible.value = true
|
||||||
nextTick(() => formRef.value?.clearValidate())
|
nextTick(() => formRef.value?.clearValidate())
|
||||||
|
fetchPreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancelEdit() {
|
function handleCancelEdit() {
|
||||||
@@ -462,10 +734,22 @@ async function handleSave() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const freqError = validateFreqConfig()
|
||||||
|
if (freqError) {
|
||||||
|
message.warning(freqError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preview.valid) {
|
||||||
|
message.warning(preview.error || '频率配置不合法')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await jobApi.updateJob(currentJob.value.id, {
|
const res: any = await jobApi.updateJob(currentJob.value.id, {
|
||||||
cronExpression: formData.cronExpression,
|
freqType: formData.freqType,
|
||||||
|
freqConfig: buildFreqConfig(),
|
||||||
params: { ...formData.params },
|
params: { ...formData.params },
|
||||||
retryTimes: formData.retryTimes,
|
retryTimes: formData.retryTimes,
|
||||||
retryInterval: formData.retryInterval,
|
retryInterval: formData.retryInterval,
|
||||||
@@ -572,6 +856,10 @@ function handleClearLogs() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchJobList()
|
fetchJobList()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimeout(previewTimer)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
@@ -635,6 +923,17 @@ onMounted(() => {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.freq-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 20px;
|
||||||
|
|
||||||
|
.freq-cron {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.error-stack {
|
.error-stack {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -646,4 +945,51 @@ onMounted(() => {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 弹窗内容被 teleport 到 body,样式不能嵌套在 .job-management 下 */
|
||||||
|
.form-divider {
|
||||||
|
margin: 4px 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #595959;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-list {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.time-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.freq-preview {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 22px;
|
||||||
|
|
||||||
|
.preview-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 56px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
color: #c41d7f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -66,6 +66,7 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
|
|||||||
+43
-266
@@ -41,7 +41,7 @@
|
|||||||
resolved "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz"
|
resolved "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz"
|
||||||
integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==
|
integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==
|
||||||
|
|
||||||
"@babel/core@^7.23.0":
|
"@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.23.0":
|
||||||
version "7.29.7"
|
version "7.29.7"
|
||||||
resolved "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz"
|
resolved "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz"
|
||||||
integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==
|
integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==
|
||||||
@@ -275,6 +275,14 @@
|
|||||||
"@babel/types" "^7.29.7"
|
"@babel/types" "^7.29.7"
|
||||||
debug "^4.3.1"
|
debug "^4.3.1"
|
||||||
|
|
||||||
|
"@babel/types@^7.28.2", "@babel/types@^7.29.7":
|
||||||
|
version "7.29.7"
|
||||||
|
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz"
|
||||||
|
integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==
|
||||||
|
dependencies:
|
||||||
|
"@babel/helper-string-parser" "^7.29.7"
|
||||||
|
"@babel/helper-validator-identifier" "^7.29.7"
|
||||||
|
|
||||||
"@babel/types@7.23.0":
|
"@babel/types@7.23.0":
|
||||||
version "7.23.0"
|
version "7.23.0"
|
||||||
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.23.0.tgz"
|
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.23.0.tgz"
|
||||||
@@ -284,14 +292,6 @@
|
|||||||
"@babel/helper-validator-identifier" "^7.22.20"
|
"@babel/helper-validator-identifier" "^7.22.20"
|
||||||
to-fast-properties "^2.0.0"
|
to-fast-properties "^2.0.0"
|
||||||
|
|
||||||
"@babel/types@^7.28.2", "@babel/types@^7.29.7":
|
|
||||||
version "7.29.7"
|
|
||||||
resolved "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz"
|
|
||||||
integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==
|
|
||||||
dependencies:
|
|
||||||
"@babel/helper-string-parser" "^7.29.7"
|
|
||||||
"@babel/helper-validator-identifier" "^7.29.7"
|
|
||||||
|
|
||||||
"@ctrl/tinycolor@^3.4.0", "@ctrl/tinycolor@^3.5.0":
|
"@ctrl/tinycolor@^3.4.0", "@ctrl/tinycolor@^3.5.0":
|
||||||
version "3.6.1"
|
version "3.6.1"
|
||||||
resolved "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz"
|
resolved "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz"
|
||||||
@@ -307,116 +307,6 @@
|
|||||||
resolved "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.8.1.tgz"
|
resolved "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.8.1.tgz"
|
||||||
integrity sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==
|
integrity sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==
|
||||||
|
|
||||||
"@esbuild/aix-ppc64@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f"
|
|
||||||
integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==
|
|
||||||
|
|
||||||
"@esbuild/android-arm64@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052"
|
|
||||||
integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==
|
|
||||||
|
|
||||||
"@esbuild/android-arm@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28"
|
|
||||||
integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==
|
|
||||||
|
|
||||||
"@esbuild/android-x64@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e"
|
|
||||||
integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==
|
|
||||||
|
|
||||||
"@esbuild/darwin-arm64@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a"
|
|
||||||
integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==
|
|
||||||
|
|
||||||
"@esbuild/darwin-x64@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22"
|
|
||||||
integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==
|
|
||||||
|
|
||||||
"@esbuild/freebsd-arm64@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e"
|
|
||||||
integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==
|
|
||||||
|
|
||||||
"@esbuild/freebsd-x64@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261"
|
|
||||||
integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==
|
|
||||||
|
|
||||||
"@esbuild/linux-arm64@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b"
|
|
||||||
integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==
|
|
||||||
|
|
||||||
"@esbuild/linux-arm@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9"
|
|
||||||
integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==
|
|
||||||
|
|
||||||
"@esbuild/linux-ia32@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2"
|
|
||||||
integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==
|
|
||||||
|
|
||||||
"@esbuild/linux-loong64@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df"
|
|
||||||
integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==
|
|
||||||
|
|
||||||
"@esbuild/linux-mips64el@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe"
|
|
||||||
integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==
|
|
||||||
|
|
||||||
"@esbuild/linux-ppc64@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4"
|
|
||||||
integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==
|
|
||||||
|
|
||||||
"@esbuild/linux-riscv64@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc"
|
|
||||||
integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==
|
|
||||||
|
|
||||||
"@esbuild/linux-s390x@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de"
|
|
||||||
integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==
|
|
||||||
|
|
||||||
"@esbuild/linux-x64@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0"
|
|
||||||
integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==
|
|
||||||
|
|
||||||
"@esbuild/netbsd-x64@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047"
|
|
||||||
integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==
|
|
||||||
|
|
||||||
"@esbuild/openbsd-x64@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70"
|
|
||||||
integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==
|
|
||||||
|
|
||||||
"@esbuild/sunos-x64@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b"
|
|
||||||
integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==
|
|
||||||
|
|
||||||
"@esbuild/win32-arm64@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d"
|
|
||||||
integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==
|
|
||||||
|
|
||||||
"@esbuild/win32-ia32@0.21.5":
|
|
||||||
version "0.21.5"
|
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b"
|
|
||||||
integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==
|
|
||||||
|
|
||||||
"@esbuild/win32-x64@0.21.5":
|
"@esbuild/win32-x64@0.21.5":
|
||||||
version "0.21.5"
|
version "0.21.5"
|
||||||
resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz"
|
resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz"
|
||||||
@@ -515,7 +405,7 @@
|
|||||||
"@nodelib/fs.stat" "2.0.5"
|
"@nodelib/fs.stat" "2.0.5"
|
||||||
run-parallel "^1.1.9"
|
run-parallel "^1.1.9"
|
||||||
|
|
||||||
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
|
"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
|
||||||
version "2.0.5"
|
version "2.0.5"
|
||||||
resolved "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
|
resolved "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
|
||||||
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
|
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
|
||||||
@@ -528,121 +418,6 @@
|
|||||||
"@nodelib/fs.scandir" "2.1.5"
|
"@nodelib/fs.scandir" "2.1.5"
|
||||||
fastq "^1.6.0"
|
fastq "^1.6.0"
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz#3a04f01e9f01392bbef5920b94aa3b88794be7ab"
|
|
||||||
integrity sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==
|
|
||||||
|
|
||||||
"@rollup/rollup-android-arm64@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz#e371b653ceabc900790ae73f5548a0fd7cd63a70"
|
|
||||||
integrity sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==
|
|
||||||
|
|
||||||
"@rollup/rollup-darwin-arm64@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz#2a5aa70432e39816d666d79287a7324cfc3b4e72"
|
|
||||||
integrity sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==
|
|
||||||
|
|
||||||
"@rollup/rollup-darwin-x64@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz#c3b5b49629379cd9cdc5d841bf00ed44ebf393dd"
|
|
||||||
integrity sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==
|
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-arm64@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz#f929d8e0462fae6602fc960beeabd7287d859283"
|
|
||||||
integrity sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==
|
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-x64@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz#c01cb58031226f95d0900b1ec847f4fb32c6e809"
|
|
||||||
integrity sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-gnueabihf@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz#f29d890c4858c8e0d3be01677eef4f6a359eed9d"
|
|
||||||
integrity sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-musleabihf@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz#1ebfc8eb9f66136ed2faae5f44995add5ca3c964"
|
|
||||||
integrity sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-gnu@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz#c1fa823c2c4ce46ba7f61de1a4c3fdadd4fb4e7b"
|
|
||||||
integrity sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-musl@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz#a7f18854d0471b78bda8ea38f0891a4e059b571d"
|
|
||||||
integrity sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-loong64-gnu@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz#83658a9a4576bcce8cef85b2c78b9b649d2200c4"
|
|
||||||
integrity sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-loong64-musl@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz#fd2af677ae3417bb58d57ae37dd0d84686e40244"
|
|
||||||
integrity sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-ppc64-gnu@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz#6481647181c4cf8f1ddbd99f62c84cfc56c1a94a"
|
|
||||||
integrity sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-ppc64-musl@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz#18610a1a1550e28a5042ca916f898419540f17f4"
|
|
||||||
integrity sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-gnu@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz#597bb80465a2621dbe0de0a41c66394a8a7e9a6e"
|
|
||||||
integrity sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-musl@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz#a2a919a9f927ef7f24a60af77e3cb55f1ad59e4d"
|
|
||||||
integrity sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-s390x-gnu@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz#3166f6ceae7df9bbfddf9f36be1937231e13e3c6"
|
|
||||||
integrity sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-gnu@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz#23c9bf79771d804fb87415eb0767569f273261e5"
|
|
||||||
integrity sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-musl@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz#97941c6b94d67fe25cde0f027c10a19f2d1fdd39"
|
|
||||||
integrity sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==
|
|
||||||
|
|
||||||
"@rollup/rollup-openbsd-x64@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz#7aeb7d92e2cd1d399f56daf75c39040b777b6c77"
|
|
||||||
integrity sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==
|
|
||||||
|
|
||||||
"@rollup/rollup-openharmony-arm64@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz#925de61ae83bf99aa636e8acea87432e8c0ffaab"
|
|
||||||
integrity sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-arm64-msvc@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz#888ab83842721491044c46a7407e1f38f3235bb4"
|
|
||||||
integrity sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-ia32-msvc@4.60.4":
|
|
||||||
version "4.60.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz#fa30ac24e3f0232139d2a47500560a28695764d4"
|
|
||||||
integrity sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-gnu@4.60.4":
|
"@rollup/rollup-win32-x64-gnu@4.60.4":
|
||||||
version "4.60.4"
|
version "4.60.4"
|
||||||
resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz"
|
resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz"
|
||||||
@@ -666,7 +441,7 @@
|
|||||||
resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz"
|
resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz"
|
||||||
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
|
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
|
||||||
|
|
||||||
"@types/node@^22.15.30":
|
"@types/node@^18.0.0 || >=20.0.0", "@types/node@^22.15.30":
|
||||||
version "22.19.19"
|
version "22.19.19"
|
||||||
resolved "https://registry.npmmirror.com/@types/node/-/node-22.19.19.tgz"
|
resolved "https://registry.npmmirror.com/@types/node/-/node-22.19.19.tgz"
|
||||||
integrity sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==
|
integrity sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==
|
||||||
@@ -688,7 +463,7 @@
|
|||||||
natural-compare "^1.4.0"
|
natural-compare "^1.4.0"
|
||||||
ts-api-utils "^1.3.0"
|
ts-api-utils "^1.3.0"
|
||||||
|
|
||||||
"@typescript-eslint/parser@^7.0.1":
|
"@typescript-eslint/parser@^7.0.0", "@typescript-eslint/parser@^7.0.1":
|
||||||
version "7.18.0"
|
version "7.18.0"
|
||||||
resolved "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-7.18.0.tgz"
|
resolved "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-7.18.0.tgz"
|
||||||
integrity sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==
|
integrity sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==
|
||||||
@@ -764,14 +539,14 @@
|
|||||||
resolved "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz"
|
resolved "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz"
|
||||||
integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==
|
integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==
|
||||||
|
|
||||||
"@volar/language-core@1.11.1", "@volar/language-core@~1.11.1":
|
"@volar/language-core@~1.11.1", "@volar/language-core@1.11.1":
|
||||||
version "1.11.1"
|
version "1.11.1"
|
||||||
resolved "https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz"
|
resolved "https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz"
|
||||||
integrity sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==
|
integrity sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@volar/source-map" "1.11.1"
|
"@volar/source-map" "1.11.1"
|
||||||
|
|
||||||
"@volar/source-map@1.11.1", "@volar/source-map@~1.11.1":
|
"@volar/source-map@~1.11.1", "@volar/source-map@1.11.1":
|
||||||
version "1.11.1"
|
version "1.11.1"
|
||||||
resolved "https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz"
|
resolved "https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz"
|
||||||
integrity sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==
|
integrity sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==
|
||||||
@@ -828,7 +603,7 @@
|
|||||||
estree-walker "^2.0.2"
|
estree-walker "^2.0.2"
|
||||||
source-map-js "^1.2.1"
|
source-map-js "^1.2.1"
|
||||||
|
|
||||||
"@vue/compiler-dom@3.5.34", "@vue/compiler-dom@^3.3.0", "@vue/compiler-dom@^3.3.4":
|
"@vue/compiler-dom@^3.3.0", "@vue/compiler-dom@^3.3.4", "@vue/compiler-dom@3.5.34":
|
||||||
version "3.5.34"
|
version "3.5.34"
|
||||||
resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz"
|
resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz"
|
||||||
integrity sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==
|
integrity sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==
|
||||||
@@ -836,7 +611,7 @@
|
|||||||
"@vue/compiler-core" "3.5.34"
|
"@vue/compiler-core" "3.5.34"
|
||||||
"@vue/shared" "3.5.34"
|
"@vue/shared" "3.5.34"
|
||||||
|
|
||||||
"@vue/compiler-sfc@3.5.34", "@vue/compiler-sfc@^3.5.18":
|
"@vue/compiler-sfc@^3.5.18", "@vue/compiler-sfc@3.5.34":
|
||||||
version "3.5.34"
|
version "3.5.34"
|
||||||
resolved "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz"
|
resolved "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz"
|
||||||
integrity sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==
|
integrity sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==
|
||||||
@@ -921,7 +696,7 @@
|
|||||||
"@vue/reactivity" "3.5.34"
|
"@vue/reactivity" "3.5.34"
|
||||||
"@vue/shared" "3.5.34"
|
"@vue/shared" "3.5.34"
|
||||||
|
|
||||||
"@vue/runtime-dom@3.5.34", "@vue/runtime-dom@^3.4.15":
|
"@vue/runtime-dom@^3.4.15", "@vue/runtime-dom@3.5.34":
|
||||||
version "3.5.34"
|
version "3.5.34"
|
||||||
resolved "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz"
|
resolved "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz"
|
||||||
integrity sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==
|
integrity sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==
|
||||||
@@ -939,7 +714,7 @@
|
|||||||
"@vue/compiler-ssr" "3.5.34"
|
"@vue/compiler-ssr" "3.5.34"
|
||||||
"@vue/shared" "3.5.34"
|
"@vue/shared" "3.5.34"
|
||||||
|
|
||||||
"@vue/shared@3.5.34", "@vue/shared@^3.3.0", "@vue/shared@^3.5.18":
|
"@vue/shared@^3.3.0", "@vue/shared@^3.5.18", "@vue/shared@3.5.34":
|
||||||
version "3.5.34"
|
version "3.5.34"
|
||||||
resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz"
|
resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz"
|
||||||
integrity sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==
|
integrity sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==
|
||||||
@@ -954,7 +729,7 @@ acorn-jsx@^5.3.2:
|
|||||||
resolved "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
|
resolved "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
|
||||||
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
|
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
|
||||||
|
|
||||||
acorn@^8.14.0, acorn@^8.9.0:
|
"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.14.0, acorn@^8.9.0:
|
||||||
version "8.16.0"
|
version "8.16.0"
|
||||||
resolved "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz"
|
resolved "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz"
|
||||||
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
|
integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
|
||||||
@@ -1127,7 +902,7 @@ braces@^3.0.3, braces@~3.0.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
fill-range "^7.1.1"
|
fill-range "^7.1.1"
|
||||||
|
|
||||||
browserslist@^4.24.0, browserslist@^4.28.2:
|
browserslist@^4.24.0, browserslist@^4.28.2, "browserslist@>= 4.21.0":
|
||||||
version "4.28.2"
|
version "4.28.2"
|
||||||
resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz"
|
resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz"
|
||||||
integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==
|
integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==
|
||||||
@@ -1281,7 +1056,7 @@ de-indent@^1.0.2:
|
|||||||
resolved "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz"
|
resolved "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz"
|
||||||
integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==
|
integrity sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==
|
||||||
|
|
||||||
debug@4, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4:
|
debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@4:
|
||||||
version "4.4.3"
|
version "4.4.3"
|
||||||
resolved "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz"
|
resolved "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz"
|
||||||
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
||||||
@@ -1341,7 +1116,7 @@ dunder-proto@^1.0.1:
|
|||||||
es-errors "^1.3.0"
|
es-errors "^1.3.0"
|
||||||
gopd "^1.2.0"
|
gopd "^1.2.0"
|
||||||
|
|
||||||
echarts@^6.1.0:
|
echarts@^6.0.0, echarts@^6.1.0:
|
||||||
version "6.1.0"
|
version "6.1.0"
|
||||||
resolved "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz"
|
resolved "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz"
|
||||||
integrity sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==
|
integrity sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==
|
||||||
@@ -1459,7 +1234,7 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4
|
|||||||
resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz"
|
resolved "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz"
|
||||||
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
|
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
|
||||||
|
|
||||||
eslint@^8.56.0:
|
"eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", eslint@^8.56.0, eslint@>=6.0.0:
|
||||||
version "8.57.1"
|
version "8.57.1"
|
||||||
resolved "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz"
|
resolved "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz"
|
||||||
integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==
|
integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==
|
||||||
@@ -1641,11 +1416,6 @@ fs.realpath@^1.0.0:
|
|||||||
resolved "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz"
|
resolved "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz"
|
||||||
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
||||||
|
|
||||||
fsevents@~2.3.2, fsevents@~2.3.3:
|
|
||||||
version "2.3.3"
|
|
||||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
|
||||||
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
|
|
||||||
|
|
||||||
function-bind@^1.1.2:
|
function-bind@^1.1.2:
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz"
|
resolved "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz"
|
||||||
@@ -1881,7 +1651,7 @@ isexe@^2.0.0:
|
|||||||
resolved "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz"
|
resolved "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz"
|
||||||
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
||||||
|
|
||||||
jiti@^1.21.7:
|
jiti@^1.21.7, jiti@>=1.21.0:
|
||||||
version "1.21.7"
|
version "1.21.7"
|
||||||
resolved "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz"
|
resolved "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz"
|
||||||
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
|
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
|
||||||
@@ -1935,7 +1705,7 @@ kolorist@^1.8.0:
|
|||||||
resolved "https://registry.npmmirror.com/kolorist/-/kolorist-1.8.0.tgz"
|
resolved "https://registry.npmmirror.com/kolorist/-/kolorist-1.8.0.tgz"
|
||||||
integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==
|
integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==
|
||||||
|
|
||||||
less@^4.6.4:
|
less@*, less@^4.6.4:
|
||||||
version "4.6.4"
|
version "4.6.4"
|
||||||
resolved "https://registry.npmmirror.com/less/-/less-4.6.4.tgz"
|
resolved "https://registry.npmmirror.com/less/-/less-4.6.4.tgz"
|
||||||
integrity sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==
|
integrity sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==
|
||||||
@@ -2067,7 +1837,14 @@ minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion "^1.1.7"
|
brace-expansion "^1.1.7"
|
||||||
|
|
||||||
minimatch@^9.0.3, minimatch@^9.0.4:
|
minimatch@^9.0.3:
|
||||||
|
version "9.0.9"
|
||||||
|
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz"
|
||||||
|
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
|
||||||
|
dependencies:
|
||||||
|
brace-expansion "^2.0.2"
|
||||||
|
|
||||||
|
minimatch@^9.0.4:
|
||||||
version "9.0.9"
|
version "9.0.9"
|
||||||
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz"
|
resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz"
|
||||||
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
|
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
|
||||||
@@ -2238,7 +2015,7 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
|
|||||||
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz"
|
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz"
|
||||||
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
|
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
|
||||||
|
|
||||||
picomatch@^4.0.4:
|
"picomatch@^3 || ^4", picomatch@^4.0.4:
|
||||||
version "4.0.4"
|
version "4.0.4"
|
||||||
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz"
|
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz"
|
||||||
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
|
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
|
||||||
@@ -2308,7 +2085,7 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0:
|
|||||||
resolved "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
|
resolved "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
|
||||||
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
||||||
|
|
||||||
postcss@^8.4.35, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.5.14:
|
postcss@^8.0.0, postcss@^8.1.0, postcss@^8.2.14, postcss@^8.4.21, postcss@^8.4.35, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.5.14, postcss@>=8.0.9:
|
||||||
version "8.5.15"
|
version "8.5.15"
|
||||||
resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz"
|
resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz"
|
||||||
integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==
|
integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==
|
||||||
@@ -2639,16 +2416,16 @@ ts-interface-checker@^0.1.9:
|
|||||||
resolved "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz"
|
resolved "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz"
|
||||||
integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
|
integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
|
||||||
|
|
||||||
tslib@2.3.0:
|
|
||||||
version "2.3.0"
|
|
||||||
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz"
|
|
||||||
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
|
|
||||||
|
|
||||||
tslib@^2.8.1:
|
tslib@^2.8.1:
|
||||||
version "2.8.1"
|
version "2.8.1"
|
||||||
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz"
|
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz"
|
||||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||||
|
|
||||||
|
tslib@2.3.0:
|
||||||
|
version "2.3.0"
|
||||||
|
resolved "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz"
|
||||||
|
integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==
|
||||||
|
|
||||||
type-check@^0.4.0, type-check@~0.4.0:
|
type-check@^0.4.0, type-check@~0.4.0:
|
||||||
version "0.4.0"
|
version "0.4.0"
|
||||||
resolved "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz"
|
resolved "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz"
|
||||||
@@ -2661,7 +2438,7 @@ type-fest@^0.20.2:
|
|||||||
resolved "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz"
|
resolved "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz"
|
||||||
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
|
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
|
||||||
|
|
||||||
typescript@~5.3.3:
|
typescript@*, typescript@>=4.2.0, typescript@>=4.5.0, typescript@~5.3.3, typescript@5.x:
|
||||||
version "5.3.3"
|
version "5.3.3"
|
||||||
resolved "https://registry.npmmirror.com/typescript/-/typescript-5.3.3.tgz"
|
resolved "https://registry.npmmirror.com/typescript/-/typescript-5.3.3.tgz"
|
||||||
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
|
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
|
||||||
@@ -2731,7 +2508,7 @@ vite-plugin-vue-dev-locator@1.0.3:
|
|||||||
kolorist "^1.8.0"
|
kolorist "^1.8.0"
|
||||||
magic-string "^0.30.4"
|
magic-string "^0.30.4"
|
||||||
|
|
||||||
vite@^5.0.12:
|
"vite@^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0", "vite@^5.0.0 || ^6.0.0", vite@^5.0.12, vite@>=4.0.0:
|
||||||
version "5.4.21"
|
version "5.4.21"
|
||||||
resolved "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz"
|
resolved "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz"
|
||||||
integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==
|
integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==
|
||||||
@@ -2791,7 +2568,7 @@ vue-types@^3.0.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-plain-object "3.0.1"
|
is-plain-object "3.0.1"
|
||||||
|
|
||||||
vue@^3.4.15:
|
vue@^3.0.0, vue@^3.2.25, vue@^3.3.0, vue@^3.4.0, vue@^3.4.15, vue@^3.5.0, vue@^3.5.11, vue@>=3.0.1, vue@>=3.0.3, vue@>=3.2.0, vue@3.5.34:
|
||||||
version "3.5.34"
|
version "3.5.34"
|
||||||
resolved "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz"
|
resolved "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz"
|
||||||
integrity sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==
|
integrity sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==
|
||||||
|
|||||||
+68
-44
@@ -191,6 +191,7 @@ DROP TABLE IF EXISTS customer CASCADE;
|
|||||||
CREATE TABLE customer (
|
CREATE TABLE customer (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
customer_no VARCHAR(64) NOT NULL UNIQUE,
|
customer_no VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
icc_code VARCHAR(64),
|
||||||
customer_name VARCHAR(128) NOT NULL,
|
customer_name VARCHAR(128) NOT NULL,
|
||||||
province VARCHAR(64),
|
province VARCHAR(64),
|
||||||
city VARCHAR(64),
|
city VARCHAR(64),
|
||||||
@@ -209,6 +210,7 @@ CREATE TABLE customer (
|
|||||||
COMMENT ON TABLE customer IS '客户信息表';
|
COMMENT ON TABLE customer IS '客户信息表';
|
||||||
COMMENT ON COLUMN customer.id IS '自增主键';
|
COMMENT ON COLUMN customer.id IS '自增主键';
|
||||||
COMMENT ON COLUMN customer.customer_no IS '客户业务编号(KH+YYYYMMDD+3位序号)';
|
COMMENT ON COLUMN customer.customer_no IS '客户业务编号(KH+YYYYMMDD+3位序号)';
|
||||||
|
COMMENT ON COLUMN customer.icc_code IS 'ICC客户编码(ICC平台 orgCode,同步来源)';
|
||||||
COMMENT ON COLUMN customer.customer_name IS '客户名称';
|
COMMENT ON COLUMN customer.customer_name IS '客户名称';
|
||||||
COMMENT ON COLUMN customer.province IS '省';
|
COMMENT ON COLUMN customer.province IS '省';
|
||||||
COMMENT ON COLUMN customer.city IS '市';
|
COMMENT ON COLUMN customer.city IS '市';
|
||||||
@@ -227,6 +229,7 @@ CREATE INDEX idx_customer_name ON customer (customer_name);
|
|||||||
CREATE INDEX idx_customer_salesman ON customer (salesman_id);
|
CREATE INDEX idx_customer_salesman ON customer (salesman_id);
|
||||||
CREATE INDEX idx_customer_status ON customer (status);
|
CREATE INDEX idx_customer_status ON customer (status);
|
||||||
CREATE INDEX idx_customer_region ON customer (province, city, district);
|
CREATE INDEX idx_customer_region ON customer (province, city, district);
|
||||||
|
CREATE UNIQUE INDEX uk_customer_icc_code ON customer (icc_code);
|
||||||
CREATE TRIGGER trg_customer_updated_at BEFORE UPDATE ON customer
|
CREATE TRIGGER trg_customer_updated_at BEFORE UPDATE ON customer
|
||||||
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
||||||
|
|
||||||
@@ -386,6 +389,47 @@ COMMENT ON COLUMN customer_room.created_at IS '创建时间';
|
|||||||
CREATE INDEX idx_room_customer ON customer_room (customer_id);
|
CREATE INDEX idx_room_customer ON customer_room (customer_id);
|
||||||
CREATE INDEX idx_room_building ON customer_room (building_id);
|
CREATE INDEX idx_room_building ON customer_room (building_id);
|
||||||
|
|
||||||
|
-- 12.1 客户用电回路表(空间拓扑末端节点:建筑 → 楼层 → 房间 → 回路)
|
||||||
|
DROP TABLE IF EXISTS customer_circuit CASCADE;
|
||||||
|
CREATE TABLE customer_circuit (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
||||||
|
building_id BIGINT NOT NULL REFERENCES customer_building(id),
|
||||||
|
room_id BIGINT REFERENCES customer_room(id),
|
||||||
|
floor_no VARCHAR(16),
|
||||||
|
circuit_code VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
circuit_name VARCHAR(128) NOT NULL,
|
||||||
|
loop_spec VARCHAR(128),
|
||||||
|
load_type VARCHAR(32),
|
||||||
|
rated_voltage VARCHAR(32),
|
||||||
|
rated_current VARCHAR(32),
|
||||||
|
rated_power NUMERIC(10,2),
|
||||||
|
status SMALLINT NOT NULL DEFAULT 1,
|
||||||
|
remark TEXT,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE customer_circuit IS '客户用电回路表(空间拓扑末端节点)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.id IS '自增主键';
|
||||||
|
COMMENT ON COLUMN customer_circuit.customer_id IS '客户 ID(→ customer)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.building_id IS '所属建筑 ID(→ customer_building)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.room_id IS '所属房间 ID(→ customer_room,可空)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.floor_no IS '所在楼层';
|
||||||
|
COMMENT ON COLUMN customer_circuit.circuit_code IS '回路编号(全局唯一)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.circuit_name IS '回路名称';
|
||||||
|
COMMENT ON COLUMN customer_circuit.loop_spec IS '回路规格';
|
||||||
|
COMMENT ON COLUMN customer_circuit.load_type IS '负荷类型';
|
||||||
|
COMMENT ON COLUMN customer_circuit.rated_voltage IS '额定电压(配置值)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.rated_current IS '额定电流(配置值)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.rated_power IS '额定功率 kW(配置值)';
|
||||||
|
COMMENT ON COLUMN customer_circuit.status IS '状态:1 正常 / 0 停用';
|
||||||
|
COMMENT ON COLUMN customer_circuit.remark IS '备注';
|
||||||
|
COMMENT ON COLUMN customer_circuit.sort_order IS '排序值';
|
||||||
|
COMMENT ON COLUMN customer_circuit.created_at IS '创建时间';
|
||||||
|
CREATE INDEX idx_circuit_customer ON customer_circuit (customer_id);
|
||||||
|
CREATE INDEX idx_circuit_building ON customer_circuit (building_id);
|
||||||
|
CREATE INDEX idx_circuit_room ON customer_circuit (room_id);
|
||||||
|
|
||||||
-- 13. 客户设备信息表(配电间、配电井、配电箱)
|
-- 13. 客户设备信息表(配电间、配电井、配电箱)
|
||||||
DROP TABLE IF EXISTS customer_equipment CASCADE;
|
DROP TABLE IF EXISTS customer_equipment CASCADE;
|
||||||
CREATE TABLE customer_equipment (
|
CREATE TABLE customer_equipment (
|
||||||
@@ -707,56 +751,36 @@ CREATE INDEX idx_survey_plan_start ON survey_order (plan_start);
|
|||||||
CREATE TRIGGER trg_survey_order_updated_at BEFORE UPDATE ON survey_order
|
CREATE TRIGGER trg_survey_order_updated_at BEFORE UPDATE ON survey_order
|
||||||
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
||||||
|
|
||||||
-- 21.1 勘察 SOP 模板检查项表
|
-- 21.1 勘察工单明细表(建筑/楼层/房间 + 现场照片)
|
||||||
DROP TABLE IF EXISTS survey_sop_item CASCADE;
|
DROP TABLE IF EXISTS survey_detail CASCADE;
|
||||||
CREATE TABLE survey_sop_item (
|
CREATE TABLE survey_detail (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
template_code VARCHAR(64) NOT NULL DEFAULT 'standard_dist',
|
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
||||||
item_name VARCHAR(128) NOT NULL,
|
|
||||||
item_desc VARCHAR(255),
|
|
||||||
receipt_type VARCHAR(16) NOT NULL DEFAULT 'photo',
|
|
||||||
receipt_require VARCHAR(64),
|
|
||||||
required BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
COMMENT ON TABLE survey_sop_item IS '勘察 SOP 模板检查项表';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.id IS '自增主键';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.template_code IS 'SOP模板编码:standard_dist标准配电改造模板';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.sort_order IS '检查项序号';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.item_name IS '检查项名称';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.item_desc IS '检查项说明';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.receipt_type IS '回执形式:photo现场照片 form清单表单 meter测仪截图';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.receipt_require IS '回执指标要求,如「照片 ≥ 2 张」';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.required IS '是否必填';
|
|
||||||
COMMENT ON COLUMN survey_sop_item.created_at IS '创建时间';
|
|
||||||
CREATE UNIQUE INDEX uk_sop_template_order ON survey_sop_item (template_code, sort_order);
|
|
||||||
|
|
||||||
-- 21.2 勘察 SOP 工单回执表
|
|
||||||
DROP TABLE IF EXISTS survey_sop_result CASCADE;
|
|
||||||
CREATE TABLE survey_sop_result (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
survey_order_id BIGINT NOT NULL REFERENCES survey_order(id) ON DELETE CASCADE,
|
survey_order_id BIGINT NOT NULL REFERENCES survey_order(id) ON DELETE CASCADE,
|
||||||
sop_item_id BIGINT NOT NULL REFERENCES survey_sop_item(id),
|
building_id BIGINT,
|
||||||
checked BOOLEAN NOT NULL DEFAULT FALSE,
|
building_name VARCHAR(128),
|
||||||
receipt_value VARCHAR(1024),
|
floor_no VARCHAR(16),
|
||||||
|
room_id BIGINT,
|
||||||
|
room_name VARCHAR(128),
|
||||||
|
photos TEXT,
|
||||||
remark VARCHAR(255),
|
remark VARCHAR(255),
|
||||||
checked_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
COMMENT ON TABLE survey_sop_result IS '勘察 SOP 工单回执表';
|
COMMENT ON TABLE survey_detail IS '勘察工单明细表(建筑/楼层/房间 + 现场照片)';
|
||||||
COMMENT ON COLUMN survey_sop_result.id IS '自增主键';
|
COMMENT ON COLUMN survey_detail.id IS '自增主键';
|
||||||
COMMENT ON COLUMN survey_sop_result.survey_order_id IS '勘察工单ID(→ survey_order)';
|
COMMENT ON COLUMN survey_detail.survey_order_id IS '勘察工单ID(→ survey_order)';
|
||||||
COMMENT ON COLUMN survey_sop_result.sop_item_id IS 'SOP检查项ID(→ survey_sop_item)';
|
COMMENT ON COLUMN survey_detail.building_id IS '建筑ID(→ customer_building)';
|
||||||
COMMENT ON COLUMN survey_sop_result.checked IS '是否已完成回执';
|
COMMENT ON COLUMN survey_detail.building_name IS '建筑名称(冗余,客户拓扑变更后仍可读)';
|
||||||
COMMENT ON COLUMN survey_sop_result.receipt_value IS '回执内容(照片对象key数组/表单值/截图key)';
|
COMMENT ON COLUMN survey_detail.floor_no IS '所在楼层:正数为地上、负数为地下,如 1 / -1';
|
||||||
COMMENT ON COLUMN survey_sop_result.remark IS '回执备注';
|
COMMENT ON COLUMN survey_detail.room_id IS '房间ID(→ customer_room,可空)';
|
||||||
COMMENT ON COLUMN survey_sop_result.checked_at IS '回执时间';
|
COMMENT ON COLUMN survey_detail.room_name IS '房间名称(冗余,可空)';
|
||||||
COMMENT ON COLUMN survey_sop_result.created_at IS '创建时间';
|
COMMENT ON COLUMN survey_detail.photos IS '现场照片对象key(JSON数组字符串)';
|
||||||
COMMENT ON COLUMN survey_sop_result.updated_at IS '更新时间';
|
COMMENT ON COLUMN survey_detail.remark IS '备注';
|
||||||
CREATE UNIQUE INDEX uk_sop_result_order_item ON survey_sop_result (survey_order_id, sop_item_id);
|
COMMENT ON COLUMN survey_detail.created_at IS '创建时间';
|
||||||
CREATE INDEX idx_sop_result_order ON survey_sop_result (survey_order_id);
|
COMMENT ON COLUMN survey_detail.updated_at IS '更新时间';
|
||||||
|
CREATE INDEX idx_survey_detail_order ON survey_detail (survey_order_id);
|
||||||
|
CREATE TRIGGER trg_survey_detail_updated_at BEFORE UPDATE ON survey_detail
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
|
||||||
|
|
||||||
-- 22. 安装派单表
|
-- 22. 安装派单表
|
||||||
DROP TABLE IF EXISTS install_order CASCADE;
|
DROP TABLE IF EXISTS install_order CASCADE;
|
||||||
|
|||||||
+152
-36
@@ -16,7 +16,7 @@
|
|||||||
-- - 显式重置序列,确保 id 不漂移
|
-- - 显式重置序列,确保 id 不漂移
|
||||||
--
|
--
|
||||||
-- 内容:
|
-- 内容:
|
||||||
-- 1. 初始角色(ADMIN / SALESMAN / WORKER / CUSTOMER)
|
-- 1. 初始角色(ADMIN / SALESMAN)
|
||||||
-- 2. 初始菜单(顶级 6 + 系统管理子菜单 6 + 运营派单子菜单 13 等,共 50 条)
|
-- 2. 初始菜单(顶级 6 + 系统管理子菜单 6 + 运营派单子菜单 13 等,共 50 条)
|
||||||
-- 3. 初始字典(客户/设备/派单/支付等业务字典)
|
-- 3. 初始字典(客户/设备/派单/支付等业务字典)
|
||||||
-- 4. 管理员账户(admin / admin123456)
|
-- 4. 管理员账户(admin / admin123456)
|
||||||
@@ -26,21 +26,24 @@
|
|||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
-- 1. 初始角色(显式指定 id:1=ADMIN 2=SALESMAN 3=WORKER 4=CUSTOMER)
|
-- 1. 初始角色(显式指定 id:1=ADMIN 2=SALESMAN)
|
||||||
|
-- 后台仅存在系统用户(sys_user)角色;电工(worker 表)与客户(customer_account 表)
|
||||||
|
-- 各自独立账号体系,不作为后台角色出现。
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
INSERT INTO sys_role (id, role_code, role_name, description, sort_order) VALUES
|
INSERT INTO sys_role (id, role_code, role_name, description, sort_order) VALUES
|
||||||
(1, 'ADMIN', '运营管理员', '全部菜单权限,管理所有客户', 1),
|
(1, 'ADMIN', '运营管理员', '全部菜单权限,管理所有客户', 1),
|
||||||
(2, 'SALESMAN', '业务员', '客户/项目/方案/合同/回访', 2),
|
(2, 'SALESMAN', '业务员', '客户/项目/方案/合同/回访', 2)
|
||||||
(3, 'WORKER', '共享电工', 'APP 接单处理,无后台菜单', 3),
|
|
||||||
(4, 'CUSTOMER', '客户', '小程序查看用电/报警/报修', 4)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
role_code = EXCLUDED.role_code,
|
role_code = EXCLUDED.role_code,
|
||||||
role_name = EXCLUDED.role_name,
|
role_name = EXCLUDED.role_name,
|
||||||
description = EXCLUDED.description,
|
description = EXCLUDED.description,
|
||||||
sort_order = EXCLUDED.sort_order;
|
sort_order = EXCLUDED.sort_order;
|
||||||
|
|
||||||
|
-- 清理历史遗留的 APP 端角色(电工/客户不参与后台角色体系)
|
||||||
|
DELETE FROM sys_role WHERE role_code IN ('WORKER', 'CUSTOMER');
|
||||||
|
|
||||||
-- 重置 sys_role 自增序列
|
-- 重置 sys_role 自增序列
|
||||||
SELECT setval('sys_role_id_seq', 4, true);
|
SELECT setval('sys_role_id_seq', 2, true);
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
-- 2. 初始菜单(顶级菜单,显式指定 id:1-6,系统管理/运营派单为目录类型 menu_type=1,其余为 menu_type=2)
|
-- 2. 初始菜单(顶级菜单,显式指定 id:1-6,系统管理/运营派单为目录类型 menu_type=1,其余为 menu_type=2)
|
||||||
@@ -106,18 +109,19 @@ ON CONFLICT (id) DO UPDATE SET
|
|||||||
permission = EXCLUDED.permission,
|
permission = EXCLUDED.permission,
|
||||||
visible = EXCLUDED.visible;
|
visible = EXCLUDED.visible;
|
||||||
|
|
||||||
-- 初始菜单(项目域子菜单 + 客户扩展子菜单,id:24-31 + 空间拓扑 32 + 安装派单 33)
|
-- 初始菜单(项目域子菜单 + 客户扩展子菜单,id:24-31 + 空间拓扑 32)
|
||||||
-- 项目域:客户拜访(customer:visit) / 项目勘察(project:survey) / 项目方案(project:plan)
|
-- 项目域:客户拜访(customer:visit) / 项目方案(project:plan)
|
||||||
-- 合同管理(project:contract) / 安装派单(project:install)
|
-- 合同管理(project:contract)
|
||||||
-- 项目验收(project:acceptance) / 回访记录(project:visit)
|
-- 项目验收(project:acceptance) / 回访记录(project:visit)
|
||||||
-- 客户域:空间拓扑与用电架构(customer:topology)
|
-- 客户域:空间拓扑与用电架构(customer:topology)
|
||||||
-- 注:客户拜访归属“项目管理”,但仍沿用 /customer/visit 路径与 customer:visit 权限码
|
-- 注:客户拜访归属“项目管理”,但仍沿用 /customer/visit 路径与 customer:visit 权限码
|
||||||
|
-- 注:项目勘察(27)/项目安装(33) 已迁至运营派单 parent_id=3(见下方运营派单块注释)
|
||||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||||
(24, 2, '客户拜访', '/customer/visit', 'customer/visit/index', 'xt-jihuabaifang', 1, 2, 'customer:visit', 1),
|
(24, 2, '客户拜访', '/customer/visit', 'customer/visit/index', 'xt-jihuabaifang', 1, 2, 'customer:visit', 1),
|
||||||
(27, 2, '项目勘察', '/project/survey', 'project/survey/index', 'xt-xiangmukancha', 2, 2, 'project:survey', 1),
|
(27, 3, '项目勘察', '/dispatch/survey', 'dispatch/survey/index', 'xt-xiangmukancha', 2, 2, 'project:survey', 1),
|
||||||
(28, 2, '项目方案', '/project/plan', 'project/plan/index', 'xt-xiangmufangan', 3, 2, 'project:plan', 1),
|
(28, 2, '项目方案', '/project/plan', 'project/plan/index', 'xt-xiangmufangan', 3, 2, 'project:plan', 1),
|
||||||
(29, 2, '合同管理', '/project/contract', 'project/contract/index', 'FileDoneOutlined', 4, 2, 'project:contract', 1),
|
(29, 2, '合同管理', '/project/contract', 'project/contract/index', 'FileDoneOutlined', 4, 2, 'project:contract', 1),
|
||||||
(33, 2, '项目安装', '/project/install', 'project/install/index', 'ToolOutlined', 5, 2, 'project:install', 1),
|
(33, 3, '项目安装', '/dispatch/install', 'dispatch/install/index', 'ToolOutlined', 3, 2, 'project:install', 1),
|
||||||
(30, 2, '项目验收', '/project/acceptance', 'project/acceptance/index', 'ThunderboltOutlined', 6, 2, 'project:acceptance',1),
|
(30, 2, '项目验收', '/project/acceptance', 'project/acceptance/index', 'ThunderboltOutlined', 6, 2, 'project:acceptance',1),
|
||||||
(31, 2, '回访记录', '/project/visit', 'project/visit/index', 'AuditOutlined', 7, 2, 'project:visit', 1),
|
(31, 2, '回访记录', '/project/visit', 'project/visit/index', 'AuditOutlined', 7, 2, 'project:visit', 1),
|
||||||
(32, 1, '空间拓扑与用电架构', '/customer/topology', 'customer/topology/index', 'xt-topology', 5, 2, 'customer:topology',1)
|
(32, 1, '空间拓扑与用电架构', '/customer/topology', 'customer/topology/index', 'xt-topology', 5, 2, 'customer:topology',1)
|
||||||
@@ -132,11 +136,13 @@ ON CONFLICT (id) DO UPDATE SET
|
|||||||
permission = EXCLUDED.permission,
|
permission = EXCLUDED.permission,
|
||||||
visible = EXCLUDED.visible;
|
visible = EXCLUDED.visible;
|
||||||
|
|
||||||
-- 初始菜单(运营派单子菜单,parent_id=3,id:55-56)
|
-- 初始菜单(运营派单子菜单,parent_id=3)
|
||||||
-- 运营派单域统一 id 分配(避免历史上 36/37 被两段 INSERT 复用而互相覆盖):
|
-- 运营派单域统一 id 分配(避免历史上 36/37 被两段 INSERT 复用而互相覆盖):
|
||||||
-- 34 运维费定价 / 35 勘察费用(归属项目管理 parent_id=2) / 36 电工登记管理
|
-- 27 项目勘察 / 33 项目安装(自项目管理 parent_id=2 迁入)
|
||||||
|
-- 34 运维费定价 / 35 勘察费用(均归属基础数据 parent_id=59) / 36 电工登记管理
|
||||||
-- 37-46 按钮权限
|
-- 37-46 按钮权限
|
||||||
-- 55 报修派单 / 56 维保派单
|
-- 55 报修派单 / 56 维保派单
|
||||||
|
-- 子菜单排序:36 电工登记管理(1) → 27 项目勘察(2) → 33 项目安装(3) → 55 报修派单(4) → 56 维保派单(5)
|
||||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||||
(55, 3, '报修派单', '/dispatch/repair', 'dispatch/repair/index', 'ToolOutlined', 4, 2, 'dispatch:repair', 1),
|
(55, 3, '报修派单', '/dispatch/repair', 'dispatch/repair/index', 'ToolOutlined', 4, 2, 'dispatch:repair', 1),
|
||||||
(56, 3, '维保派单', '/dispatch/maintenance', 'dispatch/maintenance/index', 'ToolOutlined', 5, 2, 'dispatch:maintenance',1)
|
(56, 3, '维保派单', '/dispatch/maintenance', 'dispatch/maintenance/index', 'ToolOutlined', 5, 2, 'dispatch:maintenance',1)
|
||||||
@@ -151,21 +157,22 @@ ON CONFLICT (id) DO UPDATE SET
|
|||||||
permission = EXCLUDED.permission,
|
permission = EXCLUDED.permission,
|
||||||
visible = EXCLUDED.visible;
|
visible = EXCLUDED.visible;
|
||||||
|
|
||||||
-- 运营派单子菜单(parent_id=3;其中 35 勘察费用及其按钮 40/41/42 已归属项目管理 parent_id=2;id:34/36 为菜单项,37-39/43-46 为按钮权限)
|
-- 运营派单子菜单(parent_id=3;其中 34 运维费定价及按钮 37-39、35 勘察费用及按钮 40-42 已归属基础数据 parent_id=59;id:36 为菜单项,43-46 为按钮权限)
|
||||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||||
(34, 3, '运维费定价', '/dispatch/price', 'dispatch/price/index', 'MoneyCollectOutlined', 2, 2, 'dispatch:price', 1),
|
(34, 59, '运维费定价', '/base/price', 'base/price/index', 'MoneyCollectOutlined', 2, 2, 'dispatch:price', 1),
|
||||||
(35, 2, '勘察费用', '/dispatch/cost', 'dispatch/cost/index', 'AccountBookOutlined', 8, 2, 'dispatch:cost', 1),
|
(35, 59, '勘察费用', '/base/cost', 'base/cost/index', 'AccountBookOutlined', 8, 2, 'dispatch:cost', 1),
|
||||||
(36, 3, '电工登记管理', '/dispatch/worker', 'dispatch/worker/index', 'ToolOutlined', 1, 2, 'dispatch:worker', 1),
|
(36, 3, '电工登记管理', '/dispatch/worker', 'dispatch/worker/index', 'ToolOutlined', 1, 2, 'dispatch:worker', 1),
|
||||||
(37, 3, '新增价目', NULL, NULL, NULL, 1, 3, 'price:create', 1),
|
(37, 34, '新增价目', NULL, NULL, NULL, 1, 3, 'price:create', 1),
|
||||||
(38, 3, '编辑价目', NULL, NULL, NULL, 2, 3, 'price:update', 1),
|
(38, 34, '编辑价目', NULL, NULL, NULL, 2, 3, 'price:update', 1),
|
||||||
(39, 3, '删除价目', NULL, NULL, NULL, 3, 3, 'price:delete', 1),
|
(39, 34, '删除价目', NULL, NULL, NULL, 3, 3, 'price:delete', 1),
|
||||||
(40, 2, '新增勘察费用', NULL, NULL, NULL, 9, 3, 'cost:create', 1),
|
(40, 35, '新增勘察费用', NULL, NULL, NULL, 9, 3, 'cost:create', 1),
|
||||||
(41, 2, '编辑勘察费用', NULL, NULL, NULL, 10, 3, 'cost:update', 1),
|
(41, 35, '编辑勘察费用', NULL, NULL, NULL, 10, 3, 'cost:update', 1),
|
||||||
(42, 2, '删除勘察费用', NULL, NULL, NULL, 11, 3, 'cost:delete', 1),
|
(42, 35, '删除勘察费用', NULL, NULL, NULL, 11, 3, 'cost:delete', 1),
|
||||||
(43, 3, '新增电工', NULL, NULL, NULL, 7, 3, 'worker:create', 1),
|
(43, 3, '新增电工', NULL, NULL, NULL, 7, 3, 'worker:create', 1),
|
||||||
(44, 3, '编辑电工', NULL, NULL, NULL, 8, 3, 'worker:update', 1),
|
(44, 3, '编辑电工', NULL, NULL, NULL, 8, 3, 'worker:update', 1),
|
||||||
(45, 3, '删除电工', NULL, NULL, NULL, 9, 3, 'worker:delete', 1),
|
(45, 3, '删除电工', NULL, NULL, NULL, 9, 3, 'worker:delete', 1),
|
||||||
(46, 3, '审核电工', NULL, NULL, NULL, 10, 3, 'worker:audit', 1)
|
(46, 3, '审核电工', NULL, NULL, NULL, 10, 3, 'worker:audit', 1),
|
||||||
|
(57, 3, '重置电工密码', NULL, NULL, NULL, 11, 3, 'worker:reset-password', 1)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
parent_id = EXCLUDED.parent_id,
|
parent_id = EXCLUDED.parent_id,
|
||||||
menu_name = EXCLUDED.menu_name,
|
menu_name = EXCLUDED.menu_name,
|
||||||
@@ -254,14 +261,13 @@ INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1)
|
|||||||
ON CONFLICT (user_id, role_id) DO NOTHING;
|
ON CONFLICT (user_id, role_id) DO NOTHING;
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
-- 6. 角色-菜单绑定:ADMIN 拥有全部 46 条菜单,SALESMAN 拥有客户域+项目+统计报表
|
-- 6. 角色-菜单绑定:ADMIN 拥有全部菜单,SALESMAN 拥有客户域+项目+统计报表
|
||||||
-- WORKER、CUSTOMER 不绑定任何菜单
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||||
(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7), (1, 8), (1, 9), (1, 10), (1, 11),
|
(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7), (1, 8), (1, 9), (1, 10), (1, 11),
|
||||||
(1, 12), (1, 13), (1, 14), (1, 15), (1, 16), (1, 17), (1, 18), (1, 19), (1, 20), (1, 21), (1, 22), (1, 23),
|
(1, 12), (1, 13), (1, 14), (1, 15), (1, 16), (1, 17), (1, 18), (1, 19), (1, 20), (1, 21), (1, 22), (1, 23),
|
||||||
(1, 24), (1, 27), (1, 28), (1, 29), (1, 30), (1, 31), (1, 32), (1, 33), (1, 55), (1, 56),
|
(1, 24), (1, 27), (1, 28), (1, 29), (1, 30), (1, 31), (1, 32), (1, 33), (1, 55), (1, 56),
|
||||||
(1, 34), (1, 35), (1, 36), (1, 37), (1, 38), (1, 39), (1, 40), (1, 41), (1, 42), (1, 43), (1, 44), (1, 45), (1, 46),
|
(1, 34), (1, 35), (1, 36), (1, 37), (1, 38), (1, 39), (1, 40), (1, 41), (1, 42), (1, 43), (1, 44), (1, 45), (1, 46), (1, 57),
|
||||||
(2, 1), (2, 2), (2, 5),
|
(2, 1), (2, 2), (2, 5),
|
||||||
(2, 12), (2, 13), (2, 14), (2, 15), (2, 16), (2, 17), (2, 18), (2, 19), (2, 20), (2, 21), (2, 22), (2, 23),
|
(2, 12), (2, 13), (2, 14), (2, 15), (2, 16), (2, 17), (2, 18), (2, 19), (2, 20), (2, 21), (2, 22), (2, 23),
|
||||||
(2, 24), (2, 27), (2, 28), (2, 29), (2, 30), (2, 31), (2, 32), (2, 33), (2, 55), (2, 56)
|
(2, 24), (2, 27), (2, 28), (2, 29), (2, 30), (2, 31), (2, 32), (2, 33), (2, 55), (2, 56)
|
||||||
@@ -316,21 +322,42 @@ ON CONFLICT (id) DO UPDATE SET
|
|||||||
permission = EXCLUDED.permission,
|
permission = EXCLUDED.permission,
|
||||||
visible = EXCLUDED.visible;
|
visible = EXCLUDED.visible;
|
||||||
|
|
||||||
-- 角色-菜单绑定:ADMIN(role_id=1)拥有 58-60
|
-- 角色-菜单绑定:ADMIN(role_id=1)拥有 58-61
|
||||||
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||||
(1, 58), (1, 59), (1, 60)
|
(1, 58), (1, 59), (1, 60), (1, 61)
|
||||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||||
|
|
||||||
-- 重置 sys_menu 自增序列(覆盖到 60)
|
-- 重置 sys_menu 自增序列(覆盖到 61)
|
||||||
SELECT setval('sys_menu_id_seq', 60, true);
|
SELECT setval('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 幂等)
|
-- 9. 定时任务种子(sys_job,按 job_key 幂等)
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
|
INSERT INTO sys_job (job_key, job_name, cron_expression, freq_type, freq_config, params, retry_times, retry_interval, status, remark) VALUES
|
||||||
('demo_heartbeat', '示例心跳', '*/5 * * * *', '{"message":"heartbeat"}'::jsonb, 0, 60, 1,
|
('demo_heartbeat', '示例心跳', '*/5 * * * *', 'minute', '{"interval":5}'::jsonb, '{"message":"heartbeat"}'::jsonb, 0, 60, 1,
|
||||||
'示例心跳任务:用于验证调度链路,默认每 5 分钟写入一条 info 日志'),
|
'示例心跳任务:用于验证调度链路,默认每 5 分钟写入一条 info 日志'),
|
||||||
('log_clean', '日志清理', '0 3 * * *', '{"keepDays":90}'::jsonb, 2, 60, 1,
|
('log_clean', '日志清理', '0 3 * * *', 'day', '{"times":["03:00"]}'::jsonb, '{"keepDays":90}'::jsonb, 2, 60, 1,
|
||||||
'日志清理任务:每日 03:00 清理早于 keepDays 天的操作日志与任务日志,默认保留 90 天')
|
'日志清理任务:每日 03:00 清理早于 keepDays 天的操作日志与任务日志,默认保留 90 天')
|
||||||
ON CONFLICT (job_key) DO NOTHING;
|
ON CONFLICT (job_key) DO NOTHING;
|
||||||
|
|
||||||
@@ -359,16 +386,32 @@ INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
|||||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||||
|
|
||||||
-- 重置 sys_menu 自增序列
|
-- 重置 sys_menu 自增序列
|
||||||
SELECT setval('sys_menu_id_seq', 56, true);
|
SELECT setval('sys_menu_id_seq', 57, true);
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
-- 10. 通知规则扫描任务种子(sys_job,按 job_key 幂等)
|
-- 10. 通知规则扫描任务种子(sys_job,按 job_key 幂等)
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
|
INSERT INTO sys_job (job_key, job_name, cron_expression, freq_type, freq_config, params, retry_times, retry_interval, status, remark) VALUES
|
||||||
('notice_scan', '通知规则扫描', '0 0 8 * * *', '{}'::jsonb, 1, 300, 1,
|
('notice_scan', '通知规则扫描', '0 8 * * *', 'day', '{"times":["08:00"]}'::jsonb, '{}'::jsonb, 1, 300, 1,
|
||||||
'遍历启用的扫描式通知规则,按模板与接收对象生成站内通知(幂等,默认每日 08:00)')
|
'遍历启用的扫描式通知规则,按模板与接收对象生成站内通知(幂等,默认每日 08:00)')
|
||||||
ON CONFLICT (job_key) DO NOTHING;
|
ON CONFLICT (job_key) DO NOTHING;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 10a. 智电线路同步任务种子(sys_job,按 job_key 幂等;默认每 10 分钟)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
|
||||||
|
('icc_line_sync', '智电线路同步', '*/10 * * * *', '{}'::jsonb, 1, 300, 1,
|
||||||
|
'从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert(默认每 10 分钟)')
|
||||||
|
ON CONFLICT (job_key) DO NOTHING;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 10b. 客户信息同步任务种子(sys_job,按 job_key 幂等;默认每 10 分钟)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
|
||||||
|
('icc_customer_sync', '客户信息同步', '*/10 * * * *', '{}'::jsonb, 1, 300, 1,
|
||||||
|
'从 ICC 平台拉取 org_base 组织数据并按 iccCode 幂等新增/变更客户(默认每 10 分钟)')
|
||||||
|
ON CONFLICT (job_key) DO NOTHING;
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
-- 11. 通知规则种子(sys_notice_rule,按 rule_key 幂等;接收对象可在「通知管理」页调整)
|
-- 11. 通知规则种子(sys_notice_rule,按 rule_key 幂等;接收对象可在「通知管理」页调整)
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
@@ -405,6 +448,79 @@ INSERT INTO sys_notice_rule (rule_key, rule_name, event_key, trigger_mode, enabl
|
|||||||
'扫描派单后超过 advance_days 天仍未接单的工单,通知 ADMIN')
|
'扫描派单后超过 advance_days 天仍未接单的工单,通知 ADMIN')
|
||||||
ON CONFLICT (rule_key) DO NOTHING;
|
ON CONFLICT (rule_key) DO NOTHING;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 11a. 智电改造网关台账(ICC 消防物联设备同步)
|
||||||
|
-- 建表 electric_gateway(与迁移 20261024_icc_gateway.sql 一致)
|
||||||
|
-- 设备监控(id=4)下新增菜单 62「智电改造网关台账」(sort 3,排在智电改造线路台账之后)
|
||||||
|
-- ADMIN 绑定新菜单;注册 icc_gateway_sync 定时任务(默认每 10 分钟)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS electric_gateway (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
icc_imei VARCHAR(64) UNIQUE,
|
||||||
|
device_code VARCHAR(64) UNIQUE,
|
||||||
|
device_name VARCHAR(128),
|
||||||
|
customer_id BIGINT NOT NULL REFERENCES customer(id),
|
||||||
|
equip_id BIGINT REFERENCES customer_equipment(id),
|
||||||
|
org_code VARCHAR(64),
|
||||||
|
org_name VARCHAR(128),
|
||||||
|
manufacturer_code VARCHAR(64),
|
||||||
|
manufacturer_name VARCHAR(128),
|
||||||
|
type_code VARCHAR(32),
|
||||||
|
type_name VARCHAR(64),
|
||||||
|
product_model VARCHAR(64),
|
||||||
|
is_online SMALLINT NOT NULL DEFAULT 1,
|
||||||
|
synced_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
COMMENT ON TABLE electric_gateway IS '智电改造网关设备表(ICC 同步)';
|
||||||
|
COMMENT ON COLUMN electric_gateway.id IS '自增主键';
|
||||||
|
COMMENT ON COLUMN electric_gateway.icc_imei IS 'ICC 设备 IMEI(可空唯一,ICC 数据可能缺失)';
|
||||||
|
COMMENT ON COLUMN electric_gateway.device_code IS 'ICC 设备编码(可空唯一,ICC 数据可能缺失)';
|
||||||
|
COMMENT ON COLUMN electric_gateway.device_name IS '设备名称(ICC 同步)';
|
||||||
|
COMMENT ON COLUMN electric_gateway.customer_id IS '客户 ID(由 orgCode→customer.icc_code 解析,逻辑隔离键)';
|
||||||
|
COMMENT ON COLUMN electric_gateway.equip_id IS '客户设备 ID(→ customer_equipment,同步不填充)';
|
||||||
|
COMMENT ON COLUMN electric_gateway.org_code IS 'ICC 组织编码';
|
||||||
|
COMMENT ON COLUMN electric_gateway.org_name IS '组织名称(ICC 同步)';
|
||||||
|
COMMENT ON COLUMN electric_gateway.manufacturer_code IS '厂商编码';
|
||||||
|
COMMENT ON COLUMN electric_gateway.manufacturer_name IS '厂商名称';
|
||||||
|
COMMENT ON COLUMN electric_gateway.type_code IS '设备类型编码';
|
||||||
|
COMMENT ON COLUMN electric_gateway.type_name IS '设备类型名称';
|
||||||
|
COMMENT ON COLUMN electric_gateway.product_model IS '产品型号';
|
||||||
|
COMMENT ON COLUMN electric_gateway.is_online IS '在线状态:1在线 0离线';
|
||||||
|
COMMENT ON COLUMN electric_gateway.synced_at IS 'ICC 同步时间';
|
||||||
|
COMMENT ON COLUMN electric_gateway.created_at IS '创建时间';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gateway_customer ON electric_gateway (customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gateway_equip ON electric_gateway (equip_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gateway_imei ON electric_gateway (icc_imei);
|
||||||
|
|
||||||
|
-- 菜单 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;
|
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;
|
||||||
@@ -22,3 +22,12 @@ RUSTFS_BUCKET=xintong
|
|||||||
REDIS_HOST=222.243.55.42
|
REDIS_HOST=222.243.55.42
|
||||||
REDIS_PORT=26678
|
REDIS_PORT=26678
|
||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# ICC 平台配置(智电线路同步沿用 ICC_BASE_URL / ICC_TOKEN;客户信息同步走 OAuth2 密码模式)
|
||||||
|
ICC_BASE_URL=https://222.243.55.92:1443
|
||||||
|
ICC_TOKEN=
|
||||||
|
ICC_VERSION=1.0.0
|
||||||
|
ICC_CLIENT_ID=xtzdapp
|
||||||
|
ICC_CLIENT_SECRET=1c7e440a-660c-4a5d-a7b8-82b58d9dca4e
|
||||||
|
ICC_USERNAME=XTZD接口
|
||||||
|
ICC_PASSWORD=Xtzdkj@20260801
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"sequelize": "^6.37.5",
|
"sequelize": "^6.37.5",
|
||||||
"sequelize-typescript": "^2.1.6",
|
"sequelize-typescript": "^2.1.6",
|
||||||
|
"undici": "^8.12.3",
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ if (existsSync(envFile)) {
|
|||||||
loadEnv({ path: envFile, override: true });
|
loadEnv({ path: envFile, override: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// midway logger 默认日志目录为 join(MIDWAY_LOGGER_WRITEABLE_DIR ?? appInfo.root, "logs", appName),
|
||||||
|
// 且启动时 loggerService 会用该默认值强制覆盖用户 logger.dir 配置(addObject reverse=true)。
|
||||||
|
// 显式指定项目目录,使日志最终落在 server/logs 下,避免写入用户主目录触发 EPERM 权限报错。
|
||||||
|
if (!process.env.MIDWAY_LOGGER_WRITEABLE_DIR) {
|
||||||
|
process.env.MIDWAY_LOGGER_WRITEABLE_DIR = envDir;
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
keys: "smart-grid-iot-secret-key",
|
keys: "smart-grid-iot-secret-key",
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
||||||
import { Context } from "@midwayjs/koa";
|
import { Context } from "@midwayjs/koa";
|
||||||
import { CustomerService } from "../service/customer.service";
|
import { CustomerService } from "../service/customer.service";
|
||||||
|
import { IccCustomerSyncService } from "../service/icc_customer_sync.service";
|
||||||
|
|
||||||
@Controller("/api/customers")
|
@Controller("/api/customers")
|
||||||
export class CustomerController {
|
export class CustomerController {
|
||||||
@@ -10,6 +11,9 @@ export class CustomerController {
|
|||||||
@Inject()
|
@Inject()
|
||||||
customerService!: CustomerService;
|
customerService!: CustomerService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
iccCustomerSyncService!: IccCustomerSyncService;
|
||||||
|
|
||||||
@Get("/")
|
@Get("/")
|
||||||
async list(@Query() query: any) {
|
async list(@Query() query: any) {
|
||||||
const result = await this.customerService.list(query, this.ctx);
|
const result = await this.customerService.list(query, this.ctx);
|
||||||
@@ -28,6 +32,16 @@ export class CustomerController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("/sync/icc")
|
||||||
|
async syncIccCustomers() {
|
||||||
|
const result = await this.iccCustomerSyncService.syncFromIcc(this.ctx);
|
||||||
|
return {
|
||||||
|
code: 200,
|
||||||
|
message: "同步完成",
|
||||||
|
data: result
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Get("/:id")
|
@Get("/:id")
|
||||||
async detail(@Param("id") id: string) {
|
async detail(@Param("id") id: string) {
|
||||||
const result = await this.customerService.detail(Number(id), this.ctx);
|
const result = await this.customerService.detail(Number(id), this.ctx);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
||||||
|
import { Context } from "@midwayjs/koa";
|
||||||
|
import { CustomerCircuitService } from "../service/customer_circuit.service";
|
||||||
|
|
||||||
|
@Controller("/api/customer-circuits")
|
||||||
|
export class CustomerCircuitController {
|
||||||
|
@Inject() ctx!: Context;
|
||||||
|
@Inject() customerCircuitService!: CustomerCircuitService;
|
||||||
|
|
||||||
|
@Get("/") async list(@Query() query: any) { return this.customerCircuitService.list(query, this.ctx); }
|
||||||
|
// 注意:静态路径 /topology 必须声明在 /:id 之前,避免被参数路由捕获
|
||||||
|
@Get("/topology") async topology(@Query() query: any) { return this.customerCircuitService.topology(query, this.ctx); }
|
||||||
|
@Get("/:id") async detail(@Param("id") id: string) { return this.customerCircuitService.detail(Number(id), this.ctx); }
|
||||||
|
@Post("/") async create(@Body() body: any) { return this.customerCircuitService.create(body, this.ctx); }
|
||||||
|
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.customerCircuitService.update(Number(id), body, this.ctx); }
|
||||||
|
@Del("/:id") async delete(@Param("id") id: string) { return this.customerCircuitService.delete(Number(id), this.ctx); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
||||||
import { JobService } from "../service/job.service";
|
import { JobService } from "../service/job.service";
|
||||||
import { UpdateJobDTO, JobStatusDTO, JobLogQueryDTO } from "../dto/job.dto";
|
import { UpdateJobDTO, JobStatusDTO, JobLogQueryDTO, CronPreviewDTO } from "../dto/job.dto";
|
||||||
|
|
||||||
@Controller("/api/jobs")
|
@Controller("/api/jobs")
|
||||||
export class JobController {
|
export class JobController {
|
||||||
@@ -19,6 +19,12 @@ export class JobController {
|
|||||||
return this.jobService.clearLogs(parsed);
|
return this.jobService.clearLogs(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 频率预览:人性化配置 → cron / 中文描述 / 未来执行时间 */
|
||||||
|
@Post("/cron/preview")
|
||||||
|
async previewCron(@Body() dto: CronPreviewDTO) {
|
||||||
|
return this.jobService.previewCron(dto.freqType, dto.freqConfig);
|
||||||
|
}
|
||||||
|
|
||||||
@Put("/:id")
|
@Put("/:id")
|
||||||
async update(@Param("id") id: string, @Body() dto: UpdateJobDTO) {
|
async update(@Param("id") id: string, @Body() dto: UpdateJobDTO) {
|
||||||
return this.jobService.update(Number(id), dto);
|
return this.jobService.update(Number(id), dto);
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ export class SurveyOrderController {
|
|||||||
|
|
||||||
// 以下静态路径必须声明在 /:id 之前,否则会被动态路由吞掉
|
// 以下静态路径必须声明在 /:id 之前,否则会被动态路由吞掉
|
||||||
@Get("/stats") async stats() { return this.surveyOrderService.stats(this.ctx); }
|
@Get("/stats") async stats() { return this.surveyOrderService.stats(this.ctx); }
|
||||||
@Get("/sop-items") async listSopItems(@Query("templateCode") templateCode: string) {
|
|
||||||
return this.surveyOrderService.listSopItems(templateCode);
|
|
||||||
}
|
|
||||||
@Get("/quote") async quote(@Query("loopCount") loopCount: string) { return this.surveyOrderService.quote(loopCount); }
|
@Get("/quote") async quote(@Query("loopCount") loopCount: string) { return this.surveyOrderService.quote(loopCount); }
|
||||||
|
|
||||||
// 批量指派
|
// 批量指派
|
||||||
@@ -23,9 +20,9 @@ export class SurveyOrderController {
|
|||||||
@Post("/") async create(@Body() body: any) { return this.surveyOrderService.create(body, this.ctx); }
|
@Post("/") async create(@Body() body: any) { return this.surveyOrderService.create(body, this.ctx); }
|
||||||
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.surveyOrderService.update(Number(id), body, this.ctx); }
|
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.surveyOrderService.update(Number(id), body, this.ctx); }
|
||||||
|
|
||||||
// 现场回执:按 SOP 检查项增量录入(照片/截图 key 数组、表单值、备注)
|
// 勘察明细:全量替换(新增/编辑/删除统一走此接口)
|
||||||
@Put("/:id/sop-results") async saveSopResults(@Param("id") id: string, @Body() body: any) {
|
@Put("/:id/details") async saveDetails(@Param("id") id: string, @Body() body: any) {
|
||||||
return this.surveyOrderService.saveSopResults(Number(id), body, this.ctx);
|
return this.surveyOrderService.saveDetails(Number(id), body, this.ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 指派 / 改派共享电工
|
// 指派 / 改派共享电工
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export class WorkerController {
|
|||||||
}
|
}
|
||||||
@Del("/:id") async remove(@Param("id") id: string) { return this.workerService.remove(Number(id)); }
|
@Del("/:id") async remove(@Param("id") id: string) { return this.workerService.remove(Number(id)); }
|
||||||
|
|
||||||
|
// 重置电工 APP 登录密码(重置为手机号后 6 位)
|
||||||
|
@Post("/:id/reset-password") async resetPassword(@Param("id") id: string) {
|
||||||
|
return this.workerService.resetPassword(Number(id));
|
||||||
|
}
|
||||||
|
|
||||||
// 资质审核:操作人取自登录态
|
// 资质审核:操作人取自登录态
|
||||||
@Put("/:id/audit") async audit(@Param("id") id: string, @Body() body: WorkerAuditDTO) {
|
@Put("/:id/audit") async audit(@Param("id") id: string, @Body() body: WorkerAuditDTO) {
|
||||||
return this.workerService.audit(Number(id), body, this.ctx.state.user?.userId);
|
return this.workerService.audit(Number(id), body, this.ctx.state.user?.userId);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Controller, Get, Post, Put, Del, Inject, Body, Param, Query } from "@midwayjs/core";
|
import { Controller, Get, Post, Put, Del, Inject, Body, Param, Query, Files } from "@midwayjs/core";
|
||||||
import { Context } from "@midwayjs/koa";
|
import { Context } from "@midwayjs/koa";
|
||||||
|
import { UploadFileInfo } from "@midwayjs/upload";
|
||||||
import { WorkerAppService } from "../service/worker_app.service";
|
import { WorkerAppService } from "../service/worker_app.service";
|
||||||
import { NoticeService } from "../service/notice.service";
|
import { NoticeService } from "../service/notice.service";
|
||||||
|
import { UploadService } from "../service/upload.service";
|
||||||
import {
|
import {
|
||||||
WorkerRegisterDTO,
|
WorkerRegisterDTO,
|
||||||
WorkerLoginDTO,
|
WorkerLoginDTO,
|
||||||
@@ -20,6 +22,24 @@ export class WorkerAppController {
|
|||||||
@Inject()
|
@Inject()
|
||||||
noticeService!: NoticeService;
|
noticeService!: NoticeService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
uploadService!: UploadService;
|
||||||
|
|
||||||
|
// 勘察现场照片上传:电工 token 走 /api/worker-app/* 通道,复用对象存储上传能力
|
||||||
|
@Post("/upload/image")
|
||||||
|
async uploadImage(@Files() files: UploadFileInfo<string>[]) {
|
||||||
|
const file = files && files[0];
|
||||||
|
if (!file) {
|
||||||
|
return { code: 400, message: "请选择要上传的图片", data: null };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await this.uploadService.uploadFile(file);
|
||||||
|
return { code: 200, message: "上传成功", data };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { code: 500, message: `图片上传失败:${err?.message || "对象存储不可用"}`, data: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 注册(匿名)
|
// 注册(匿名)
|
||||||
@Post("/auth/register")
|
@Post("/auth/register")
|
||||||
async register(@Body() body: WorkerRegisterDTO) {
|
async register(@Body() body: WorkerRegisterDTO) {
|
||||||
@@ -130,6 +150,15 @@ export class WorkerAppController {
|
|||||||
return this.workerAppService.completeOrder(this.currentWorkerId(), source, Number(id));
|
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")
|
@Get("/earnings/summary")
|
||||||
async earningsSummary() {
|
async earningsSummary() {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export class CustomerBuildingCreateDTO {
|
|||||||
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) basicInfo?: string;
|
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) basicInfo?: string;
|
||||||
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) purpose?: string;
|
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) purpose?: string;
|
||||||
@IsOptional() @IsInt() @Min(0) floorCount?: number;
|
@IsOptional() @IsInt() @Min(0) floorCount?: number;
|
||||||
|
@IsOptional() @IsInt() @Min(0) floorDownCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CustomerBuildingUpdateDTO {
|
export class CustomerBuildingUpdateDTO {
|
||||||
@@ -21,4 +22,5 @@ export class CustomerBuildingUpdateDTO {
|
|||||||
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) basicInfo?: string;
|
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) basicInfo?: string;
|
||||||
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) purpose?: string;
|
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) purpose?: string;
|
||||||
@IsOptional() @IsInt() @Min(0) floorCount?: number;
|
@IsOptional() @IsInt() @Min(0) floorCount?: number;
|
||||||
|
@IsOptional() @IsInt() @Min(0) floorDownCount?: number;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { IsNotEmpty, IsOptional, IsString, IsInt, IsNumber, Matches, Min, Max, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
export class CustomerCircuitQueryDTO {
|
||||||
|
@IsOptional() @IsInt() @Min(1) page?: number = 1;
|
||||||
|
@IsOptional() @IsInt() @Min(1) @Max(500) pageSize?: number = 10;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
|
||||||
|
@IsOptional() @IsInt() customerId?: number;
|
||||||
|
@IsOptional() @IsInt() buildingId?: number;
|
||||||
|
@IsOptional() @IsInt() roomId?: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(16) floorNo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CustomerCircuitCreateDTO {
|
||||||
|
@IsNotEmpty({ message: '回路编号不能为空' }) @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) circuitCode!: string;
|
||||||
|
@IsNotEmpty({ message: '所属客户不能为空' }) @IsInt() customerId!: number;
|
||||||
|
@IsNotEmpty({ message: '所属建筑不能为空' }) @IsInt() buildingId!: number;
|
||||||
|
@IsNotEmpty({ message: '回路名称不能为空' }) @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) circuitName!: string;
|
||||||
|
@IsOptional() @IsInt() roomId?: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(16) @Matches(/^[^<>]*$/) floorNo?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) loopSpec?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) loadType?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedVoltage?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedCurrent?: string;
|
||||||
|
@IsOptional() @IsNumber() @Min(0) ratedPower?: number;
|
||||||
|
@IsOptional() @IsInt() status?: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(2000) remark?: string;
|
||||||
|
@IsOptional() @IsInt() sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CustomerCircuitUpdateDTO {
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) circuitCode?: string;
|
||||||
|
@IsOptional() @IsInt() customerId?: number;
|
||||||
|
@IsOptional() @IsInt() buildingId?: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) circuitName?: string;
|
||||||
|
@IsOptional() @IsInt() roomId?: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(16) @Matches(/^[^<>]*$/) floorNo?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) loopSpec?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) loadType?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedVoltage?: string;
|
||||||
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedCurrent?: string;
|
||||||
|
@IsOptional() @IsNumber() @Min(0) ratedPower?: number;
|
||||||
|
@IsOptional() @IsInt() status?: number;
|
||||||
|
@IsOptional() @IsString() @MaxLength(2000) remark?: string;
|
||||||
|
@IsOptional() @IsInt() sortOrder?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsOptional, IsString, IsInt, MaxLength, Min, Max } from "class-validator";
|
||||||
|
|
||||||
|
export class ElectricGatewayQueryDTO {
|
||||||
|
@IsOptional() @IsInt() @Min(1) page?: number = 1;
|
||||||
|
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
|
||||||
|
@IsOptional() @IsInt() customerId?: number;
|
||||||
|
@IsOptional() @IsInt() equipId?: number;
|
||||||
|
@IsOptional() @IsInt() @Min(0) @Max(1) isOnline?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsOptional, IsString, IsInt, MaxLength, Min, Max } from "class-validator";
|
||||||
|
|
||||||
|
export class ElectricLineQueryDTO {
|
||||||
|
@IsOptional() @IsInt() @Min(1) page?: number = 1;
|
||||||
|
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
|
||||||
|
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
|
||||||
|
@IsOptional() @IsInt() customerId?: number;
|
||||||
|
@IsOptional() @IsInt() equipId?: number;
|
||||||
|
@IsOptional() @IsInt() buildingId?: number;
|
||||||
|
@IsOptional() @IsInt() @Min(0) @Max(1) status?: number;
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
import { IsNotEmpty, IsOptional, IsString, IsInt, IsObject, Min, Max, MaxLength } from "class-validator";
|
import { IsNotEmpty, IsOptional, IsString, IsInt, IsObject, Min, Max, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
/** 频率配置:字段按 freqType 取用,由 util/cron_schedule 统一校验与翻译 */
|
||||||
export class UpdateJobDTO {
|
export class UpdateJobDTO {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty({ message: "cron 表达式不能为空" })
|
@IsNotEmpty({ message: "频率类型不能为空" })
|
||||||
@MaxLength(64, { message: "cron 表达式最大 64 字符" })
|
@MaxLength(16, { message: "频率类型非法" })
|
||||||
cronExpression!: string;
|
freqType!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
freqConfig?: Record<string, any>;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@@ -26,6 +31,18 @@ export class UpdateJobDTO {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 频率预览入参:只传频率配置,cron 与下次执行时间由后端计算 */
|
||||||
|
export class CronPreviewDTO {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: "频率类型不能为空" })
|
||||||
|
@MaxLength(16, { message: "频率类型非法" })
|
||||||
|
freqType!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
freqConfig?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
export class JobStatusDTO {
|
export class JobStatusDTO {
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0, { message: "状态值非法" })
|
@Min(0, { message: "状态值非法" })
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ export class SurveyOrderCreateDTO {
|
|||||||
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string;
|
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string;
|
||||||
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) source?: string;
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) source?: string;
|
||||||
@IsOptional() @IsInt() @Min(0) @Max(1) isDraft?: number;
|
@IsOptional() @IsInt() @Min(0) @Max(1) isDraft?: number;
|
||||||
// 向导选用的 SOP 模板编码,用于生成回执记录(非持久化列)
|
// 勘察明细(建筑/楼层/房间 + 现场照片),非持久化列,仅用于创建时一并写入
|
||||||
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) templateCode?: string;
|
@IsOptional() @IsArray() details?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
|
export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
|
||||||
@@ -70,7 +70,7 @@ export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
|
|||||||
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) declare remark?: string;
|
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) declare remark?: string;
|
||||||
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) declare source?: string;
|
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) declare source?: string;
|
||||||
@IsOptional() @IsInt() @Min(0) @Max(1) declare isDraft?: number;
|
@IsOptional() @IsInt() @Min(0) @Max(1) declare isDraft?: number;
|
||||||
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) declare templateCode?: string;
|
@IsOptional() @IsArray() declare details?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 指派 / 改派(0待指派→指派,1现场勘察中→改派)
|
// 指派 / 改派(0待指派→指派,1现场勘察中→改派)
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export class WorkerUpdateDTO {
|
|||||||
@IsOptional() @IsInt() @IsIn([0, 1]) status?: number;
|
@IsOptional() @IsInt() @IsIn([0, 1]) status?: number;
|
||||||
@IsOptional() @IsNumber() @Min(0) @Max(5) rating?: number;
|
@IsOptional() @IsNumber() @Min(0) @Max(5) rating?: number;
|
||||||
@IsOptional() @IsString() @MaxLength(128) certInfo?: string;
|
@IsOptional() @IsString() @MaxLength(128) certInfo?: string;
|
||||||
|
// 为尚未开通 APP 账号的电工补开账号(默认密码为手机号后 6 位;已开通时忽略)
|
||||||
|
@IsOptional() @IsBoolean() withAccount?: boolean;
|
||||||
// 存在时整体覆盖该电工的全部证书
|
// 存在时整体覆盖该电工的全部证书
|
||||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => WorkerCertDTO) certs?: WorkerCertDTO[];
|
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => WorkerCertDTO) certs?: WorkerCertDTO[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ export default class Customer extends Model {
|
|||||||
})
|
})
|
||||||
customerNo!: string;
|
customerNo!: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.STRING(64),
|
||||||
|
field: "icc_code",
|
||||||
|
comment: "ICC客户编码(ICC平台 orgCode,同步来源)"
|
||||||
|
})
|
||||||
|
iccCode!: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: DataType.STRING(128),
|
type: DataType.STRING(128),
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|||||||
@@ -26,9 +26,12 @@ export default class CustomerBuilding extends Model {
|
|||||||
@Column({ type: DataType.STRING(128), comment: "用途" })
|
@Column({ type: DataType.STRING(128), comment: "用途" })
|
||||||
purpose!: string;
|
purpose!: string;
|
||||||
|
|
||||||
@Column({ type: DataType.INTEGER, field: "floor_count", comment: "层数" })
|
@Column({ type: DataType.INTEGER, field: "floor_count", comment: "地上楼层数" })
|
||||||
floorCount!: number;
|
floorCount!: number;
|
||||||
|
|
||||||
|
@Column({ type: DataType.INTEGER, field: "floordown_count", comment: "地下楼层数", defaultValue: 0 })
|
||||||
|
floorDownCount!: number;
|
||||||
|
|
||||||
@BelongsTo(() => Customer)
|
@BelongsTo(() => Customer)
|
||||||
customer!: 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
|
||||||
|
import CustomerEquipment from "./customer_equipment.entity";
|
||||||
|
import Customer from "./customer.entity";
|
||||||
|
import CustomerBuilding from "./customer_building.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_icc", fields: ["icc_device_code"] }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
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: "线路编码(唯一)" })
|
||||||
|
lineCode!: string;
|
||||||
|
|
||||||
|
@ForeignKey(() => CustomerEquipment)
|
||||||
|
@Column({ type: DataType.BIGINT, allowNull: false, field: "equip_id", comment: "客户设备 ID(→ customer_equipment)" })
|
||||||
|
equipId!: number;
|
||||||
|
|
||||||
|
@Column({ type: DataType.STRING(128), field: "icc_device_code", comment: "ICC 智电设备编号" })
|
||||||
|
iccDeviceCode!: string;
|
||||||
|
|
||||||
|
@Column({ type: DataType.STRING(128), field: "line_name", comment: "线路名称(ICC 同步)" })
|
||||||
|
lineName!: string;
|
||||||
|
|
||||||
|
@ForeignKey(() => Customer)
|
||||||
|
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户 ID(逻辑隔离键)" })
|
||||||
|
customerId!: number;
|
||||||
|
|
||||||
|
@ForeignKey(() => CustomerBuilding)
|
||||||
|
@Column({ type: DataType.BIGINT, field: "building_id", comment: "所属建筑 ID(→ customer_building)" })
|
||||||
|
buildingId!: number;
|
||||||
|
|
||||||
|
@Column({ type: DataType.STRING(16), field: "floor_no", comment: "所在楼层" })
|
||||||
|
floorNo!: string;
|
||||||
|
|
||||||
|
@Column({ type: DataType.STRING(256), comment: "所在位置" })
|
||||||
|
location!: string;
|
||||||
|
|
||||||
|
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1在线 0离线" })
|
||||||
|
status!: number;
|
||||||
|
|
||||||
|
@Column({ type: DataType.DATE, field: "synced_at", comment: "ICC 同步时间" })
|
||||||
|
syncedAt!: Date;
|
||||||
|
|
||||||
|
@BelongsTo(() => CustomerEquipment, { foreignKey: "equipId", as: "equipment" })
|
||||||
|
equipment!: CustomerEquipment;
|
||||||
|
|
||||||
|
@BelongsTo(() => Customer, { foreignKey: "customerId", as: "customer" })
|
||||||
|
customer!: Customer;
|
||||||
|
|
||||||
|
@BelongsTo(() => CustomerBuilding, { foreignKey: "buildingId", as: "building" })
|
||||||
|
building!: CustomerBuilding;
|
||||||
|
|
||||||
|
@CreatedAt
|
||||||
|
created_at!: Date;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -36,6 +36,20 @@ export default class SysJob extends Model {
|
|||||||
})
|
})
|
||||||
cronExpression!: string;
|
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({
|
@Column({
|
||||||
type: DataType.JSONB,
|
type: DataType.JSONB,
|
||||||
field: "params",
|
field: "params",
|
||||||
|
|||||||
@@ -23,8 +23,13 @@ const PERMISSION_MAP: Record<string, string> = {
|
|||||||
"/api/upload": "customer:visit",
|
"/api/upload": "customer:visit",
|
||||||
"/api/customer-buildings": "customer:building",
|
"/api/customer-buildings": "customer:building",
|
||||||
"/api/customer-rooms": "customer:room",
|
"/api/customer-rooms": "customer:room",
|
||||||
|
// 用电回路接口复用页面级权限码 customer:topology(该菜单已在 sys_menu 登记并分配给角色),
|
||||||
|
// 因此不再新增按钮级权限码,避免非 ADMIN 角色因权限码未登记而被 403。
|
||||||
|
"/api/customer-circuits": "customer:topology",
|
||||||
"/api/customer-equipments": "device:list",
|
"/api/customer-equipments": "device:list",
|
||||||
"/api/smart-devices": "device:model",
|
"/api/smart-devices": "device:model",
|
||||||
|
"/api/electric-lines": "device:list",
|
||||||
|
"/api/electric-gateways": "device:list",
|
||||||
"/api/survey-orders": "project:survey",
|
"/api/survey-orders": "project:survey",
|
||||||
"/api/project-plans": "project:plan",
|
"/api/project-plans": "project:plan",
|
||||||
"/api/contracts": "project:contract",
|
"/api/contracts": "project:contract",
|
||||||
|
|||||||
@@ -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 now = new Date();
|
||||||
const offsetMs = now.getTime() + 8 * 60 * 60 * 1000;
|
const offsetMs = now.getTime() + 8 * 60 * 60 * 1000;
|
||||||
const dateLocal = new Date(offsetMs);
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
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 Customer from "../entity/customer.entity";
|
||||||
|
import CustomerEquipment from "../entity/customer_equipment.entity";
|
||||||
|
import CustomerBuilding from "../entity/customer_building.entity";
|
||||||
|
import SysUserRole from "../entity/sysUserRole.entity";
|
||||||
|
import SysRole from "../entity/sysRole.entity";
|
||||||
|
|
||||||
|
@Provide()
|
||||||
|
export class ElectricLineService {
|
||||||
|
@InjectRepository(ElectricLine)
|
||||||
|
lineModel!: Repository<ElectricLine>;
|
||||||
|
|
||||||
|
@InjectRepository(Customer)
|
||||||
|
customerModel!: Repository<Customer>;
|
||||||
|
|
||||||
|
@InjectRepository(CustomerEquipment)
|
||||||
|
equipmentModel!: Repository<CustomerEquipment>;
|
||||||
|
|
||||||
|
@InjectRepository(CustomerBuilding)
|
||||||
|
buildingModel!: Repository<CustomerBuilding>;
|
||||||
|
|
||||||
|
@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}%` } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (query.customerId !== undefined && query.customerId !== "") where.customerId = Number(query.customerId);
|
||||||
|
if (query.equipId !== undefined && query.equipId !== "") where.equipId = Number(query.equipId);
|
||||||
|
if (query.buildingId !== undefined && query.buildingId !== "") where.buildingId = Number(query.buildingId);
|
||||||
|
if (query.status !== undefined && query.status !== "" && query.status !== null) where.status = Number(query.status);
|
||||||
|
|
||||||
|
// 非管理员按客户归属自动限定数据范围
|
||||||
|
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 buildingIds = [...new Set(rows.map((r) => r.buildingId).filter(Boolean))];
|
||||||
|
|
||||||
|
const [customers, equips, buildings] = 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([]),
|
||||||
|
buildingIds.length
|
||||||
|
? this.buildingModel.findAll({
|
||||||
|
where: { id: { [Op.in]: buildingIds } },
|
||||||
|
attributes: ["id", "buildingName"]
|
||||||
|
})
|
||||||
|
: Promise.resolve([])
|
||||||
|
]);
|
||||||
|
|
||||||
|
const customerMap = new Map(customers.map((c) => [c.id, c]));
|
||||||
|
const equipMap = new Map(equips.map((e) => [e.id, e]));
|
||||||
|
const buildingMap = new Map(buildings.map((b) => [b.id, b]));
|
||||||
|
|
||||||
|
return rows.map((row) => {
|
||||||
|
const json: any = row.toJSON();
|
||||||
|
const customer: any = customerMap.get(row.customerId);
|
||||||
|
const equip: any = equipMap.get(row.equipId);
|
||||||
|
const building: any = row.buildingId ? buildingMap.get(row.buildingId) : undefined;
|
||||||
|
json.customerNo = customer?.customerNo || "";
|
||||||
|
json.customerName = customer?.customerName || "";
|
||||||
|
json.equipCode = equip?.equipCode || "";
|
||||||
|
json.equipName = equip?.equipName || "";
|
||||||
|
json.buildingName = building?.buildingName || "";
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
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 CustomerEquipment from "../entity/customer_equipment.entity";
|
||||||
|
import CustomerBuilding from "../entity/customer_building.entity";
|
||||||
|
import { ICC_LINE_ENDPOINT, mapIccLineRecord, IccLineRawRecord } from "../task/icc_line_mapper";
|
||||||
|
|
||||||
|
export interface IccLineSyncResult {
|
||||||
|
/** ICC 拉取到的原始条数 */
|
||||||
|
pulled: number;
|
||||||
|
/** 解析落库的线路条数(成功 upsert,含新增/更新) */
|
||||||
|
synced: number;
|
||||||
|
/** 新增的线路条数 */
|
||||||
|
inserted: number;
|
||||||
|
/** 更新的线路条数 */
|
||||||
|
updated: number;
|
||||||
|
/** 因缺少合法 line_code 或无法匹配客户/设备而跳过的条数 */
|
||||||
|
skipped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYNC_TYPE = "electric_line";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ICC 智电改造线路拉取同步:
|
||||||
|
* 1) 从 .env 读取 ICC_BASE_URL / ICC_TOKEN 占位配置
|
||||||
|
* 2) 请求 ICC 线路列表(路径与字段映射集中在 icc_line_mapper)
|
||||||
|
* 3) 按 line_code 幂等 upsert 写入 electric_line,更新 synced_at
|
||||||
|
* 4) 每次同步写一条 icc_sync_log;ICC 拉取失败仅记录 error,不抛业务错误
|
||||||
|
*/
|
||||||
|
@Provide()
|
||||||
|
export class IccLineSyncService {
|
||||||
|
@InjectRepository(ElectricLine)
|
||||||
|
lineModel!: Repository<ElectricLine>;
|
||||||
|
|
||||||
|
@InjectRepository(IccSyncLog)
|
||||||
|
syncLogModel!: Repository<IccSyncLog>;
|
||||||
|
|
||||||
|
@InjectRepository(Customer)
|
||||||
|
customerModel!: Repository<Customer>;
|
||||||
|
|
||||||
|
@InjectRepository(CustomerEquipment)
|
||||||
|
equipmentModel!: Repository<CustomerEquipment>;
|
||||||
|
|
||||||
|
@InjectRepository(CustomerBuilding)
|
||||||
|
buildingModel!: Repository<CustomerBuilding>;
|
||||||
|
|
||||||
|
async syncFromIcc(ctx?: Context): Promise<IccLineSyncResult> {
|
||||||
|
const startTime = new Date();
|
||||||
|
const baseUrl = process.env.ICC_BASE_URL?.trim();
|
||||||
|
const token = process.env.ICC_TOKEN?.trim();
|
||||||
|
|
||||||
|
const result: IccLineSyncResult = { pulled: 0, synced: 0, inserted: 0, updated: 0, skipped: 0 };
|
||||||
|
|
||||||
|
// 占位配置缺失视为拉取失败:记录日志,不抛业务错误
|
||||||
|
if (!baseUrl) {
|
||||||
|
await this.writeLog(startTime, 0, SYNC_TYPE, 0, "未配置 ICC_BASE_URL,请检查 server/.env");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${baseUrl.replace(/\/$/, "")}${ICC_LINE_ENDPOINT}`;
|
||||||
|
const headers: Record<string, string> = { Accept: "application/json" };
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(60000) // 按项目约定 HTTP 超时 60s
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`ICC 线路接口返回异常:HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: any = await res.json();
|
||||||
|
// 兼容缓存工具包裹(data/list 字段)返回格式
|
||||||
|
const rawList: IccLineRawRecord[] = Array.isArray(payload)
|
||||||
|
? payload
|
||||||
|
: Array.isArray(payload.data)
|
||||||
|
? payload.data
|
||||||
|
: Array.isArray(payload.list)
|
||||||
|
? payload.list
|
||||||
|
: [];
|
||||||
|
result.pulled = rawList.length;
|
||||||
|
|
||||||
|
const { inserted, updated, skipped } = await this.batchUpsert(rawList);
|
||||||
|
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?.code === "ERR_MODULE_NOT_FOUND" || err?.message
|
||||||
|
? String(err?.message || err)
|
||||||
|
: String(err);
|
||||||
|
await this.writeLog(startTime, 0, SYNC_TYPE, result.pulled, message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量按 line_code upsert(幂等),并做客户/设备/建筑外键反查。 */
|
||||||
|
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 };
|
||||||
|
|
||||||
|
// 预取外键映射,避免 N+1
|
||||||
|
const equipCodes = [...new Set(records.map((r) => mapIccLineRecord(r).equipCode).filter(Boolean))];
|
||||||
|
const customerNos = [...new Set(records.map((r) => mapIccLineRecord(r).customerNo).filter(Boolean))];
|
||||||
|
const buildingNames = [...new Set(records.map((r) => mapIccLineRecord(r).buildingName).filter(Boolean))];
|
||||||
|
|
||||||
|
const [equips, customers, buildings] = await Promise.all([
|
||||||
|
equipCodes.length
|
||||||
|
? this.equipmentModel.findAll({ where: { equipCode: { [Op.in]: equipCodes } }, attributes: ["id", "equipCode", "customerId", "buildingId"] })
|
||||||
|
: Promise.resolve([]),
|
||||||
|
customerNos.length
|
||||||
|
? this.customerModel.findAll({ where: { customerNo: { [Op.in]: customerNos } }, attributes: ["id", "customerNo"] })
|
||||||
|
: Promise.resolve([]),
|
||||||
|
buildingNames.length
|
||||||
|
? this.buildingModel.findAll({ where: { buildingName: { [Op.in]: buildingNames } }, attributes: ["id", "buildingName", "customerId"] })
|
||||||
|
: Promise.resolve([])
|
||||||
|
]);
|
||||||
|
|
||||||
|
const equipByCode = new Map(equips.map((e) => [e.equipCode, e]));
|
||||||
|
const customerByNo = new Map(customers.map((c) => [c.customerNo, c]));
|
||||||
|
const buildingByName = new Map(buildings.map((b) => [b.buildingName, b]));
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const raw of records) {
|
||||||
|
const mapped = mapIccLineRecord(raw);
|
||||||
|
if (!mapped.lineCode) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const equip = mapped.equipCode ? equipByCode.get(mapped.equipCode) : undefined;
|
||||||
|
// equip_id 与 customer_id 为 NOT NULL,无法解析时跳过该行
|
||||||
|
if (!equip) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let customerId: number = equip.customerId;
|
||||||
|
if (mapped.customerNo) {
|
||||||
|
const byNo = customerByNo.get(mapped.customerNo);
|
||||||
|
if (byNo) customerId = byNo.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let buildingId: number | null = equip.buildingId || null;
|
||||||
|
if (mapped.buildingName) {
|
||||||
|
const building = buildingByName.get(mapped.buildingName);
|
||||||
|
if (building) buildingId = building.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
lineCode: mapped.lineCode,
|
||||||
|
equipId: equip.id,
|
||||||
|
customerId,
|
||||||
|
buildingId,
|
||||||
|
iccDeviceCode: mapped.iccDeviceCode || null,
|
||||||
|
lineName: mapped.lineName || null,
|
||||||
|
floorNo: mapped.floorNo || null,
|
||||||
|
location: mapped.location || null,
|
||||||
|
status: mapped.status,
|
||||||
|
syncedAt: now
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const existing = await this.lineModel.findOne({ where: { lineCode: mapped.lineCode } });
|
||||||
|
if (existing) {
|
||||||
|
await existing.update(payload);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ import SysJobLog from "../entity/sys_job_log.entity";
|
|||||||
import { JobSchedulerService } from "./job_scheduler.service";
|
import { JobSchedulerService } from "./job_scheduler.service";
|
||||||
import { getBuiltinJob } from "../task/job_registry";
|
import { getBuiltinJob } from "../task/job_registry";
|
||||||
import { UpdateJobDTO, JobLogQueryDTO } from "../dto/job.dto";
|
import { UpdateJobDTO, JobLogQueryDTO } from "../dto/job.dto";
|
||||||
|
import { FreqConfig, FreqType, FREQ_TYPES, buildSchedule, describeCron } from "../util/cron_schedule";
|
||||||
|
|
||||||
|
/** 频率预览返回的未来执行次数 */
|
||||||
|
const PREVIEW_RUN_COUNT = 5;
|
||||||
|
|
||||||
@Provide()
|
@Provide()
|
||||||
export class JobService {
|
export class JobService {
|
||||||
@@ -20,6 +24,29 @@ export class JobService {
|
|||||||
@Inject()
|
@Inject()
|
||||||
scheduler!: JobSchedulerService;
|
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> {
|
async list(query: { page?: any; pageSize?: any; jobName?: string; status?: any }): Promise<any> {
|
||||||
const page = Number(query.page) || 1;
|
const page = Number(query.page) || 1;
|
||||||
@@ -45,9 +72,13 @@ export class JobService {
|
|||||||
const list = rows.map(job => {
|
const list = rows.map(job => {
|
||||||
const builtin = getBuiltinJob(job.jobKey);
|
const builtin = getBuiltinJob(job.jobKey);
|
||||||
const registered = !!builtin;
|
const registered = !!builtin;
|
||||||
|
const freq = this.resolveFreq(job);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...job.toJSON(),
|
...job.toJSON(),
|
||||||
|
freqType: freq.freqType,
|
||||||
|
freqConfig: freq.freqConfig,
|
||||||
|
cronDescription: freq.description,
|
||||||
description: builtin ? builtin.description : null,
|
description: builtin ? builtin.description : null,
|
||||||
paramFields: builtin ? builtin.paramFields : [],
|
paramFields: builtin ? builtin.paramFields : [],
|
||||||
registered,
|
registered,
|
||||||
@@ -62,7 +93,7 @@ export class JobService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 更新任务配置:cron 合法性 + 参数白名单/范围校验,白名单之外的 key 直接丢弃 */
|
/** 更新任务配置:频率配置翻译 cron 并校验合法性 + 参数白名单/范围校验,白名单之外的 key 直接丢弃 */
|
||||||
async update(id: number, dto: UpdateJobDTO): Promise<any> {
|
async update(id: number, dto: UpdateJobDTO): Promise<any> {
|
||||||
const job = await this.jobModel.findByPk(id);
|
const job = await this.jobModel.findByPk(id);
|
||||||
|
|
||||||
@@ -75,7 +106,14 @@ export class JobService {
|
|||||||
return { code: 400, message: "任务实现不存在", data: null };
|
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 };
|
return { code: 400, message: "cron 表达式不合法", data: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +160,9 @@ export class JobService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await job.update({
|
await job.update({
|
||||||
cronExpression: dto.cronExpression,
|
cronExpression,
|
||||||
|
freqType: dto.freqType,
|
||||||
|
freqConfig: schedule.freqConfig ?? {},
|
||||||
params,
|
params,
|
||||||
retryTimes: dto.retryTimes,
|
retryTimes: dto.retryTimes,
|
||||||
retryInterval: dto.retryInterval,
|
retryInterval: dto.retryInterval,
|
||||||
@@ -134,6 +174,37 @@ export class JobService {
|
|||||||
return { code: 200, message: "更新定时任务成功", data: null };
|
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> {
|
async changeStatus(id: number, status: number): Promise<any> {
|
||||||
const job = await this.jobModel.findByPk(id);
|
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 SysJobLog from "../entity/sys_job_log.entity";
|
||||||
import { LogService } from "./log.service";
|
import { LogService } from "./log.service";
|
||||||
import { NoticeRuleService } from "./notice_rule.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";
|
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 天的记录,返回删除条数 */
|
/** 清理 sys_job_log 中早于 keepDays 天的记录,返回删除条数 */
|
||||||
async cleanJobLogs(keepDays: number): Promise<number> {
|
async cleanJobLogs(keepDays: number): Promise<number> {
|
||||||
const cutoff = dayjs().subtract(keepDays, "day").toDate();
|
const cutoff = dayjs().subtract(keepDays, "day").toDate();
|
||||||
@@ -430,6 +448,24 @@ export class JobSchedulerService {
|
|||||||
const ctx = this.app.createAnonymousContext();
|
const ctx = this.app.createAnonymousContext();
|
||||||
const noticeRuleService = (await ctx.requestContext.getAsync(NoticeRuleService)) as NoticeRuleService;
|
const noticeRuleService = (await ctx.requestContext.getAsync(NoticeRuleService)) as NoticeRuleService;
|
||||||
return noticeRuleService.executeScan();
|
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}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import { Context } from "@midwayjs/koa";
|
|||||||
import SurveyOrder from "../entity/survey_order.entity";
|
import SurveyOrder from "../entity/survey_order.entity";
|
||||||
import Customer from "../entity/customer.entity";
|
import Customer from "../entity/customer.entity";
|
||||||
import Worker from "../entity/worker.entity";
|
import Worker from "../entity/worker.entity";
|
||||||
import SurveySopItem from "../entity/survey_sop_item.entity";
|
import SurveyDetail from "../entity/survey_detail.entity";
|
||||||
import SurveySopResult from "../entity/survey_sop_result.entity";
|
|
||||||
import DispatchPrice from "../entity/dispatch_price.entity";
|
import DispatchPrice from "../entity/dispatch_price.entity";
|
||||||
import SysUser from "../entity/sysUser.entity";
|
import SysUser from "../entity/sysUser.entity";
|
||||||
import SysUserRole from "../entity/sysUserRole.entity";
|
import SysUserRole from "../entity/sysUserRole.entity";
|
||||||
@@ -18,8 +17,6 @@ import { NoticeRuleService } from "./notice_rule.service";
|
|||||||
|
|
||||||
// 勘察工单状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消
|
// 勘察工单状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消
|
||||||
const STATUS = { PENDING_ASSIGN: 0, SURVEYING: 1, PENDING_REVIEW: 2, ARCHIVED: 3, RESERVED: 4, CANCELED: 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 缺失时兜底,不写库)
|
// 默认到场时限(分钟)与缺省报价(dispatch_price 缺失时兜底,不写库)
|
||||||
const DEFAULT_EXPECT_MINUTES = 45;
|
const DEFAULT_EXPECT_MINUTES = 45;
|
||||||
const DEFAULT_BASE_FEE = 350;
|
const DEFAULT_BASE_FEE = 350;
|
||||||
@@ -38,11 +35,8 @@ export class SurveyOrderService {
|
|||||||
@InjectRepository(Worker)
|
@InjectRepository(Worker)
|
||||||
workerModel!: Repository<Worker>;
|
workerModel!: Repository<Worker>;
|
||||||
|
|
||||||
@InjectRepository(SurveySopItem)
|
@InjectRepository(SurveyDetail)
|
||||||
sopItemModel!: Repository<SurveySopItem>;
|
detailModel!: Repository<SurveyDetail>;
|
||||||
|
|
||||||
@InjectRepository(SurveySopResult)
|
|
||||||
sopResultModel!: Repository<SurveySopResult>;
|
|
||||||
|
|
||||||
@InjectRepository(DispatchPrice)
|
@InjectRepository(DispatchPrice)
|
||||||
priceModel!: Repository<DispatchPrice>;
|
priceModel!: Repository<DispatchPrice>;
|
||||||
@@ -257,13 +251,50 @@ export class SurveyOrderService {
|
|||||||
return { baseFee, loopFee, loopCount: count, total };
|
return { baseFee, loopFee, loopCount: count, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按模板批量生成回执记录(checked=false)
|
// 明细入参清洗:photos 数组转 JSON 字符串,建筑/楼层缺失的行丢弃
|
||||||
private async createSopResults(orderId: number, templateCode: string): Promise<void> {
|
private normalizeDetails(input: any): any[] {
|
||||||
const items = await this.sopItemModel.findAll({ where: { templateCode: templateCode || DEFAULT_TEMPLATE } });
|
if (!Array.isArray(input)) return [];
|
||||||
if (items.length === 0) return;
|
return input
|
||||||
await this.sopResultModel.bulkCreate(
|
.map((row) => {
|
||||||
items.map((item) => ({ surveyOrderId: orderId, sopItemId: item.id, checked: false })) as any
|
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> {
|
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> {
|
async quote(loopCount?: string): Promise<any> {
|
||||||
const data = await this.calcFee(Number(loopCount) || 0);
|
const data = await this.calcFee(Number(loopCount) || 0);
|
||||||
@@ -362,7 +386,7 @@ export class SurveyOrderService {
|
|||||||
|
|
||||||
const vo = this.toVO(record);
|
const vo = this.toVO(record);
|
||||||
await this.decorate([vo]);
|
await this.decorate([vo]);
|
||||||
vo.sopResults = await this.getSopResults(id);
|
vo.details = await this.getDetails(id);
|
||||||
// 现场取证照片:把对象 key 批量换取预签名访问 URL,供详情只读视图直接渲染
|
// 现场取证照片:把对象 key 批量换取预签名访问 URL,供详情只读视图直接渲染
|
||||||
const photoKeys: string[] = vo.photos || [];
|
const photoKeys: string[] = vo.photos || [];
|
||||||
const urlMap = photoKeys.length ? await this.uploadService.getPresignedUrlMap(photoKeys) : {};
|
const urlMap = photoKeys.length ? await this.uploadService.getPresignedUrlMap(photoKeys) : {};
|
||||||
@@ -370,84 +394,17 @@ export class SurveyOrderService {
|
|||||||
return { code: 200, message: "获取勘察派单详情成功", data: vo };
|
return { code: 200, message: "获取勘察派单详情成功", data: vo };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 工单回执 + 检查项信息(手动 join,避免额外关联);receiptValue 以 key 数组出参,并补预签名访问 URL
|
// 保存工单明细(勘察明细整体替换:新增/编辑/删除均由前端以全量数组提交)
|
||||||
private async getSopResults(orderId: number): Promise<any[]> {
|
async saveDetails(id: number, dto: any, ctx: Context): 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> {
|
|
||||||
const record = await this.surveyModel.findByPk(id);
|
const record = await this.surveyModel.findByPk(id);
|
||||||
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
|
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
|
||||||
if (!(await this.isOrderVisible(ctx.state.user.userId, record))) return { code: 403, 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 };
|
if (record.status !== STATUS.PENDING_ASSIGN && 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
await this.replaceDetails(id, dto?.details);
|
||||||
code: 200,
|
return { code: 200, message: "保存勘察明细成功", data: await this.getDetails(id) };
|
||||||
message: "保存勘察回执成功",
|
|
||||||
data: { updated, skipped, sopResults: await this.getSopResults(id) }
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(data: any, ctx: Context): Promise<any> {
|
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 (!(await this.isCustomerOwner(userId, data.customerId))) return { code: 403, message: "无权限为该客户创建勘察派单", data: null };
|
||||||
if (!isAdmin) data.salesmanId = userId;
|
if (!isAdmin) data.salesmanId = userId;
|
||||||
|
|
||||||
const templateCode = data.templateCode || DEFAULT_TEMPLATE;
|
|
||||||
const payload = this.normalizePayload(data);
|
const payload = this.normalizePayload(data);
|
||||||
delete payload.templateCode;
|
delete payload.details;
|
||||||
|
|
||||||
payload.surveyNo = await this.generateSurveyNo();
|
payload.surveyNo = await this.generateSurveyNo();
|
||||||
if (payload.progressTotal === undefined || payload.progressTotal === null) payload.progressTotal = payload.loopCount ?? 0;
|
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);
|
const vo = this.toVO(created);
|
||||||
await this.decorate([vo]);
|
await this.decorate([vo]);
|
||||||
|
vo.details = await this.getDetails(created.id);
|
||||||
await this.logService.logAction(ctx, "勘察派单", "创建", created.surveyNo, `创建勘察派单 ${created.surveyNo}`);
|
await this.logService.logAction(ctx, "勘察派单", "创建", created.surveyNo, `创建勘察派单 ${created.surveyNo}`);
|
||||||
return { code: 200, message: "创建勘察派单成功", data: vo };
|
return { code: 200, message: "创建勘察派单成功", data: vo };
|
||||||
}
|
}
|
||||||
@@ -503,13 +463,19 @@ export class SurveyOrderService {
|
|||||||
delete data.salesmanId;
|
delete data.salesmanId;
|
||||||
}
|
}
|
||||||
delete data.surveyNo;
|
delete data.surveyNo;
|
||||||
delete data.templateCode;
|
|
||||||
|
|
||||||
|
const details = data.details;
|
||||||
|
delete data.details;
|
||||||
const payload = this.normalizePayload(data);
|
const payload = this.normalizePayload(data);
|
||||||
await record.update(payload);
|
await record.update(payload);
|
||||||
|
// 仅当显式传入 details 时才整体替换明细,避免只改主表字段时误清空
|
||||||
|
if (Array.isArray(details)) {
|
||||||
|
await this.replaceDetails(id, details);
|
||||||
|
}
|
||||||
const fresh = await this.surveyModel.findByPk(id);
|
const fresh = await this.surveyModel.findByPk(id);
|
||||||
const vo = this.toVO(fresh!);
|
const vo = this.toVO(fresh!);
|
||||||
await this.decorate([vo]);
|
await this.decorate([vo]);
|
||||||
|
vo.details = await this.getDetails(id);
|
||||||
await this.logService.logAction(ctx, "勘察派单", "更新", record.surveyNo, `更新勘察派单 ${record.surveyNo}`);
|
await this.logService.logAction(ctx, "勘察派单", "更新", record.surveyNo, `更新勘察派单 ${record.surveyNo}`);
|
||||||
return { code: 200, message: "更新勘察派单成功", data: vo };
|
return { code: 200, message: "更新勘察派单成功", data: vo };
|
||||||
}
|
}
|
||||||
@@ -643,8 +609,8 @@ export class SurveyOrderService {
|
|||||||
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
|
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
|
||||||
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该勘察派单", data: null };
|
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该勘察派单", data: null };
|
||||||
const surveyNo = record.surveyNo;
|
const surveyNo = record.surveyNo;
|
||||||
// 级联清理回执后删除工单
|
// 级联清理明细后删除工单
|
||||||
await this.sopResultModel.destroy({ where: { surveyOrderId: id } });
|
await this.detailModel.destroy({ where: { surveyOrderId: id } });
|
||||||
await record.destroy();
|
await record.destroy();
|
||||||
await this.logService.logAction(ctx, "勘察派单", "删除", surveyNo, `删除勘察派单 ${surveyNo}`);
|
await this.logService.logAction(ctx, "勘察派单", "删除", surveyNo, `删除勘察派单 ${surveyNo}`);
|
||||||
return { code: 200, message: "删除勘察派单成功", data: null };
|
return { code: 200, message: "删除勘察派单成功", data: null };
|
||||||
|
|||||||
@@ -104,10 +104,12 @@ export class WorkerService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 剔除敏感字段
|
// 剔除敏感字段;额外返回是否已开通 APP 登录账号
|
||||||
private cleanWorker(worker: Worker): any {
|
private cleanWorker(worker: Worker): any {
|
||||||
const data = worker.toJSON ? worker.toJSON() : { ...worker };
|
const data = worker.toJSON ? worker.toJSON() : { ...worker };
|
||||||
|
const hasAccount = !!data.passwordHash;
|
||||||
delete data.passwordHash;
|
delete data.passwordHash;
|
||||||
|
data.hasAccount = hasAccount;
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +173,6 @@ export class WorkerService {
|
|||||||
if (certStatus) {
|
if (certStatus) {
|
||||||
const rows = await this.workerModel.findAll({
|
const rows = await this.workerModel.findAll({
|
||||||
where,
|
where,
|
||||||
attributes: { exclude: ["passwordHash"] },
|
|
||||||
order: [["rating", "DESC"]]
|
order: [["rating", "DESC"]]
|
||||||
});
|
});
|
||||||
const certMap = await this.loadCertMap(rows.map((r) => Number(r.id)));
|
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({
|
const { count, rows } = await this.workerModel.findAndCountAll({
|
||||||
where,
|
where,
|
||||||
attributes: { exclude: ["passwordHash"] },
|
|
||||||
limit: pageSize,
|
limit: pageSize,
|
||||||
offset: (page - 1) * pageSize,
|
offset: (page - 1) * pageSize,
|
||||||
order: [["rating", "DESC"]],
|
order: [["rating", "DESC"]],
|
||||||
@@ -240,7 +240,7 @@ export class WorkerService {
|
|||||||
|
|
||||||
// 详情:含完整证书数组
|
// 详情:含完整证书数组
|
||||||
async detail(id: number): Promise<any> {
|
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) {
|
if (!worker) {
|
||||||
return { code: 404, message: "电工不存在", data: null };
|
return { code: 404, message: "电工不存在", data: null };
|
||||||
}
|
}
|
||||||
@@ -310,6 +310,10 @@ export class WorkerService {
|
|||||||
if (dto.status !== undefined) payload.status = dto.status;
|
if (dto.status !== undefined) payload.status = dto.status;
|
||||||
if (dto.rating !== undefined) payload.rating = dto.rating;
|
if (dto.rating !== undefined) payload.rating = dto.rating;
|
||||||
if (dto.certInfo !== undefined) payload.certInfo = dto.certInfo;
|
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);
|
await worker.update(payload);
|
||||||
|
|
||||||
@@ -326,6 +330,21 @@ export class WorkerService {
|
|||||||
return { code: 200, message: "更新电工成功", data: this.cleanWorker(worker) };
|
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> {
|
async remove(id: number): Promise<any> {
|
||||||
const worker = await this.workerModel.findByPk(id);
|
const worker = await this.workerModel.findByPk(id);
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import RepairDispatch from "../entity/repair_dispatch.entity";
|
|||||||
import InstallOrder from "../entity/install_order.entity";
|
import InstallOrder from "../entity/install_order.entity";
|
||||||
import MaintenanceOrder from "../entity/maintenance_order.entity";
|
import MaintenanceOrder from "../entity/maintenance_order.entity";
|
||||||
import SurveyOrder from "../entity/survey_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 Customer from "../entity/customer.entity";
|
||||||
import { TokenUtil } from "../util/token";
|
import { TokenUtil } from "../util/token";
|
||||||
|
import { SurveyOrderService } from "./survey_order.service";
|
||||||
import { hashPassword, comparePassword } from "../util/crypto";
|
import { hashPassword, comparePassword } from "../util/crypto";
|
||||||
import {
|
import {
|
||||||
WorkerRegisterDTO,
|
WorkerRegisterDTO,
|
||||||
@@ -69,6 +72,15 @@ export class WorkerAppService {
|
|||||||
@InjectRepository(SurveyOrder)
|
@InjectRepository(SurveyOrder)
|
||||||
surveyModel!: Repository<SurveyOrder>;
|
surveyModel!: Repository<SurveyOrder>;
|
||||||
|
|
||||||
|
@InjectRepository(CustomerBuilding)
|
||||||
|
buildingModel!: Repository<CustomerBuilding>;
|
||||||
|
|
||||||
|
@InjectRepository(CustomerRoom)
|
||||||
|
roomModel!: Repository<CustomerRoom>;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
surveyOrderService!: SurveyOrderService;
|
||||||
|
|
||||||
// 接单/完成需要多表原子更新,使用事务保证并发下不重复接单、不重复计数
|
// 接单/完成需要多表原子更新,使用事务保证并发下不重复接单、不重复计数
|
||||||
@InjectDataSource()
|
@InjectDataSource()
|
||||||
sequelize!: Sequelize;
|
sequelize!: Sequelize;
|
||||||
@@ -346,7 +358,51 @@ export class WorkerAppService {
|
|||||||
async orderDetail(workerId: number, source: string, id: number): Promise<any> {
|
async orderDetail(workerId: number, source: string, id: number): Promise<any> {
|
||||||
const row: any = await this.sourceModel(source).findByPk(id, { include: [this.customerInclude()] });
|
const row: any = await this.sourceModel(source).findByPk(id, { include: [this.customerInclude()] });
|
||||||
if (!row) return { code: 404, message: "订单不存在" };
|
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) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================= 接单 / 完成 =================
|
// ================= 接单 / 完成 =================
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* ICC 网关设备数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)。
|
||||||
|
*
|
||||||
|
* ICC 返回的原始字段名 → 本系统 electric_gateway 字段。接入真实 ICC 平台时
|
||||||
|
* 仅需调整本文件与 fetch 路径即可,业务层无需改动。纯映射逻辑,无 SQL 无 DOM。
|
||||||
|
*/
|
||||||
|
export interface IccGatewayRawRecord {
|
||||||
|
[field: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设备分页列表接口路径({version} 由调用方按 ICC_VERSION 替换) */
|
||||||
|
export const ICC_GATEWAY_ENDPOINT = "/evo-apigw/evo-fdbu/{version}/device/subsystem/page";
|
||||||
|
|
||||||
|
/** 原始记录关键字段映射:本系统字段 → ICC 返回字段名 */
|
||||||
|
export const ICC_GATEWAY_FIELDS = {
|
||||||
|
imei: "imei", // 设备唯一标识码(imei)
|
||||||
|
deviceCode: "deviceCode", // 设备编码
|
||||||
|
deviceName: "deviceName", // 设备名称
|
||||||
|
orgCode: "orgCode", // 所属组织编码(→ customer.icc_code 反查客户)
|
||||||
|
orgName: "orgName", // 所属组织名称
|
||||||
|
manufacturerCode: "manufacturerCode", // 厂商编码
|
||||||
|
manufacturerName: "manufacturerName", // 厂商名称
|
||||||
|
typeCode: "typeCode", // 设备类型编码
|
||||||
|
typeName: "typeName", // 设备类型名称
|
||||||
|
productModel: "productModel", // 产品型号
|
||||||
|
isOnline: "isOnline" // 在线状态
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** 离线标记集合:命中即归一化为离线(0),其余(含空)均视为在线(1) */
|
||||||
|
const OFFLINE_MARKERS = new Set(["0", "02", "false", "no", "off", "离线"]);
|
||||||
|
|
||||||
|
/** 字符串 trim;null / undefined / 空串统一归一为 undefined。 */
|
||||||
|
function toOptionalString(value: unknown): string | undefined {
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
const s = String(value).trim();
|
||||||
|
return s === "" ? undefined : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 ICC 原始设备记录映射为本系统 electric_gateway 落库字段(不含联表外键)。 */
|
||||||
|
export function mapIccGatewayRecord(raw: IccGatewayRawRecord): {
|
||||||
|
imei: string;
|
||||||
|
deviceCode: string;
|
||||||
|
deviceName?: string;
|
||||||
|
orgCode?: string;
|
||||||
|
orgName?: string;
|
||||||
|
manufacturerCode?: string;
|
||||||
|
manufacturerName?: string;
|
||||||
|
typeCode?: string;
|
||||||
|
typeName?: string;
|
||||||
|
productModel?: string;
|
||||||
|
isOnline: number;
|
||||||
|
} {
|
||||||
|
const result: Record<string, any> = {
|
||||||
|
// 匹配键保持空串语义,由调用方判跳
|
||||||
|
imei: String(raw[ICC_GATEWAY_FIELDS.imei] ?? "").trim(),
|
||||||
|
deviceCode: String(raw[ICC_GATEWAY_FIELDS.deviceCode] ?? "").trim()
|
||||||
|
};
|
||||||
|
// 可选字符串字段
|
||||||
|
for (const key of ["deviceName", "orgCode", "orgName", "manufacturerCode", "manufacturerName", "typeCode", "typeName", "productModel"] as const) {
|
||||||
|
result[key] = toOptionalString(raw[ICC_GATEWAY_FIELDS[key]]);
|
||||||
|
}
|
||||||
|
// 在线状态归一化:离线标记集合 → 0,其余(含空)→ 1
|
||||||
|
const onlineRaw = String(raw[ICC_GATEWAY_FIELDS.isOnline] ?? "").trim();
|
||||||
|
result.isOnline = OFFLINE_MARKERS.has(onlineRaw) ? 0 : 1;
|
||||||
|
return result as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析设备分页响应(兼容三种形态):
|
||||||
|
* - payload 为数组 → 直接作为列表
|
||||||
|
* - payload.data.pageData 为数组 → 取 pageData
|
||||||
|
* - payload.data.list 为数组 → 取 list
|
||||||
|
* - 结构不匹配或解析异常 → 返回空列表
|
||||||
|
*/
|
||||||
|
export function parseGatewayPage(payload: any): {
|
||||||
|
list: IccGatewayRawRecord[];
|
||||||
|
nextPage?: number;
|
||||||
|
totalCount?: number;
|
||||||
|
} {
|
||||||
|
try {
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return { list: payload };
|
||||||
|
}
|
||||||
|
const page = payload?.data;
|
||||||
|
if (page && Array.isArray(page.pageData)) {
|
||||||
|
return {
|
||||||
|
list: page.pageData,
|
||||||
|
nextPage: Number(page.nextPage) > 0 ? Number(page.nextPage) : undefined,
|
||||||
|
totalCount: Number(page.totalCount) > 0 ? Number(page.totalCount) : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (page && Array.isArray(page.list)) {
|
||||||
|
return {
|
||||||
|
list: page.list,
|
||||||
|
nextPage: Number(page.nextPage) > 0 ? Number(page.nextPage) : undefined,
|
||||||
|
totalCount: Number(page.totalCount) > 0 ? Number(page.totalCount) : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { list: [] };
|
||||||
|
} catch {
|
||||||
|
return { list: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { BuiltinJob } from "./job_registry";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智电网关同步任务:从 ICC 平台拉取智电改造网关设备并按 imei/deviceCode 幂等 upsert,
|
||||||
|
* 写 icc_sync_log。默认每 10 分钟执行一次。
|
||||||
|
*/
|
||||||
|
export const iccGatewaySyncJob: BuiltinJob = {
|
||||||
|
jobKey: "icc_gateway_sync",
|
||||||
|
jobName: "智电网关同步",
|
||||||
|
description: "从 ICC 平台拉取智电改造网关设备并按 imei/deviceCode 幂等 upsert(写 icc_sync_log)",
|
||||||
|
defaultCron: "*/10 * * * *",
|
||||||
|
defaultParams: {},
|
||||||
|
paramFields: [],
|
||||||
|
handler: async (_params, ctx) => {
|
||||||
|
return ctx.runtime.syncIccGateways();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* ICC 线路数据字段映射(集中配置,便于按 ICC 平台实际返回结构调整)
|
||||||
|
*
|
||||||
|
* ICC 返回的原始字段名 → 本系统 electric_line 字段名。当前为占位映射,
|
||||||
|
* 接入真实 ICC 平台时仅需调整本文件与 fetch 路径即可,业务层无需改动。
|
||||||
|
*/
|
||||||
|
export interface IccLineRawRecord {
|
||||||
|
[field: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 原始记录关键字段名(ICC 平台约定,占位) */
|
||||||
|
export const ICC_LINE_FIELDS = {
|
||||||
|
lineCode: "lineCode", // ICC 线路编码(唯一键)
|
||||||
|
iccDeviceCode: "deviceCode", // ICC 智电设备编号
|
||||||
|
equipCode: "equipCode", // 关联的客户设备编码(用于反查 equip_id)
|
||||||
|
lineName: "lineName", // 线路名称
|
||||||
|
customerNo: "customerNo", // 关联客户编号(用于反查 customer_id)
|
||||||
|
buildingName: "buildingName", // 建筑名称(占位,反查 building_id)
|
||||||
|
floorNo: "floorNo", // 所在楼层
|
||||||
|
location: "location", // 所在位置
|
||||||
|
status: "status" // 1在线 0离线
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** 渲染列表路径与 token 鉴权头(占位,接入真实平台时配置 .env)。 */
|
||||||
|
export const ICC_LINE_ENDPOINT = "/api/grid/lines";
|
||||||
|
|
||||||
|
/** 将 ICC 原始记录映射为本系统 electric_line 落库字段(不含联表外键)。 */
|
||||||
|
export function mapIccLineRecord(raw: IccLineRawRecord): {
|
||||||
|
lineCode: string;
|
||||||
|
iccDeviceCode: string;
|
||||||
|
equipCode?: string;
|
||||||
|
lineName?: string;
|
||||||
|
customerNo?: string;
|
||||||
|
buildingName?: string;
|
||||||
|
floorNo?: string;
|
||||||
|
location?: string;
|
||||||
|
status: number;
|
||||||
|
} {
|
||||||
|
const f = ICC_LINE_FIELDS;
|
||||||
|
return {
|
||||||
|
lineCode: String(raw[f.lineCode] ?? "").trim(),
|
||||||
|
iccDeviceCode: String(raw[f.iccDeviceCode] ?? "").trim(),
|
||||||
|
equipCode: raw[f.equipCode] ? String(raw[f.equipCode]).trim() : undefined,
|
||||||
|
lineName: raw[f.lineName] ? String(raw[f.lineName]).trim() : undefined,
|
||||||
|
customerNo: raw[f.customerNo] ? String(raw[f.customerNo]).trim() : undefined,
|
||||||
|
buildingName: raw[f.buildingName] ? String(raw[f.buildingName]).trim() : undefined,
|
||||||
|
floorNo: raw[f.floorNo] ? String(raw[f.floorNo]).trim() : undefined,
|
||||||
|
location: raw[f.location] ? String(raw[f.location]).trim() : undefined,
|
||||||
|
status: Number(raw[f.status]) === 0 ? 0 : 1
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { BuiltinJob } from "./job_registry";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智电线路同步任务:从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert,
|
||||||
|
* 写 icc_sync_log。默认每 10 分钟执行一次。
|
||||||
|
*/
|
||||||
|
export const iccLineSyncJob: BuiltinJob = {
|
||||||
|
jobKey: "icc_line_sync",
|
||||||
|
jobName: "智电线路同步",
|
||||||
|
description: "从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert(写 icc_sync_log)",
|
||||||
|
defaultCron: "*/10 * * * *",
|
||||||
|
defaultParams: {},
|
||||||
|
paramFields: [],
|
||||||
|
handler: async (_params, ctx) => {
|
||||||
|
return ctx.runtime.syncIccLines();
|
||||||
|
}
|
||||||
|
};
|
||||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user