Compare commits

...
10 Commits
Author SHA1 Message Date
andyesfly be91d04431 refactor(dashboard): 将页面英文文本替换为中文并优化操作功能
替换页面中的英文标签为中文,新增勘察流水行详情操作按钮,为工作流链接添加点击事件处理,移除未使用的图标组件。
2026-10-21 16:55:00 +08:00
andyesfly 184b8d03e2 Merge branch 'main' of http://100.64.131.105:3000/GY/smart_grid_iot 2026-10-17 10:08:54 +08:00
andyesfly 22d27a940d feat: 重构勘察业务为明细模式并新增定时任务人性化配置
- 重构勘察业务:移除SOP相关表,新增勘察明细表支持建筑/楼层/房间/照片管理
- 新增定时任务人性化频率配置,支持分钟/小时/天/周/月/自定义六种模式
- 新增频率预览接口,支持配置转换为cron表达式与执行时间预览
- 更新系统品牌名称为鑫潼智电,修改全局标题与登录页文案
2026-10-17 10:08:42 +08:00
wk 41765cdf44 feat: 新增智电改造线路台账功能(ICC拉取同步)
- 数据库:新增迁移脚本20261011_icc_line_ledger.sql(electric_line/line_room/icc_sync_log三表、菜单61、role_menu(1,61)、定时任务icc_line_sync),同步db_seed.sql
- 后端:新增ElectricLine/IccSyncLog实体、查询DTO、线路台账服务与ICC拉取同步服务、/api/electric-lines控制器,接入RBAC(device:list)与定时任务
- 前端:新增智电改造线路台账页面及路由,API封装electricLineApi
- ICC同步采用本系统主动拉取模式,失败仅记日志不抛错,接口统一code/message/data格式兼容APP
2026-10-12 15:57:02 +08:00
andyesfly f72644c24f feat: 新增客户用电回路全链路功能
新增客户用电回路表及相关数据库迁移、后端服务、控制器、DTO、实体类和权限配置;新增前端API封装,同时调整路由名称以匹配新业务模块,复用现有菜单权限避免403错误。
2026-10-12 15:20:08 +08:00
andyesfly 3596462eca Merge branch 'main' of http://git.dibox.tech/parttime/smart_grid_iot 2026-10-11 15:07:49 +08:00
andyesfly f8117e33b6 feat: 新增电工 APP 账号开通与密码重置功能
- 新增后端重置密码接口及前端对应操作入口
- 新增补开电工 APP 账号的更新参数
- 新增 hasAccount 字段标识电工是否开通 APP 账号
- 新增数据库菜单及角色绑定支持密码重置权限
- 移除后台角色中错误的 WORKER 和 CUSTOMER 角色
2026-10-11 15:07:45 +08:00
wk 850cd92584 refactor(menu): 菜单模块归属调整 - 勘察费用/运维费定价迁至基础数据(/base/*),项目勘察/项目安装迁至运营派单(/dispatch/*)并按序排列(电工→勘察→安装→报修→维保);同步前端路由/页面git mv/通知跳转与db_seed,生产库已执行迁移 2026-10-11 12:21:30 +08:00
wk 77891bf4f2 feat(topology): 建筑支持地下楼层数,房间所在楼层下拉自动按地上+地下生成 - 建筑表新增floordown_count,建筑表单拆分地上/地下楼层数;房间所在楼层改下拉,按地上(1层/2层)与地下(负1层/负2层)自动生成、负楼层在前,选项采用数字格式 2026-10-08 16:49:56 +08:00
wk d12fa91ec2 feat(topology): 房间新增/编辑弹窗增加所属客户部门选择项 - 按当前客户加载部门下拉,提交与编辑回填deptId 2026-10-08 11:07:34 +08:00
73 changed files with 5572 additions and 1033 deletions

No files matched your search

@@ -0,0 +1,121 @@
# 空间拓扑与用电架构 · 实现计划
## Context(为什么做)
现有「空间拓扑与用电架构」页面(`client/src/views/customer/topology/index.vue`)只实现了「客户 → 建筑 → 房间」三层树 + 建筑/房间增删改,**"用电架构"部分完全缺失**:数据库与后端没有任何承载"用电回路"的表或接口,页面上也没有配电拓扑可视化。
设计稿 `系统设计/UI/空间拓扑与用电架构/code.html` 定义了三栏工业级布局(左空间树 / 中配电单线拓扑流 / 右节点属性面板)。本次目标:在新表 + 新后端接口的基础上,把该页面补齐为三栏结构,让"空间"与"用电回路"打通。
## 已确认的边界(用户决策)
| 项 | 结论 |
| ------ | ---------------------------------------- |
| 目标形态 | 按设计稿做完整三栏(左树 / 中单线图 / 右属性面板) |
| 左树层级 | 客户 → 建筑 → **楼层** → 房间 → **回路**(不要"配电箱"层) |
| 楼层 | 由房间/回路按 `floor_no` 分组生成的**虚拟层**,不建表 |
| 中间图上层 | 进线柜 / 变压器:**只读展示**,不落库、不做 CRUD |
| 中间图末端 | **回路卡片**(一个回路一张卡) |
| 实时电气量 | 电压/电流/功率/温度等**一律占位**(显示 `—`),不造假数据、不接采集 |
| ICC 映射 | **不做**(设计稿里的网关层、ICC 设备码、告警阈值联动均不实现) |
| 覆盖端 | 仅 Web 管理端 `client/` |
| 数据来源 | 新建表 + 后端 CRUD 接口 |
## 数据模型(新增 1 张表)
`customer_circuit`(客户用电回路)—— 参照 `customer_room` 的建表风格(`database/db_init.sql#L364-L387`):
| 字段 | 类型 | 说明 |
| -------------- | --------------------- | ----------------------------------- |
| id | BIGSERIAL PK | <br /> |
| customer\_id | BIGINT NOT NULL | → `customer(id)` |
| building\_id | BIGINT NOT NULL | → `customer_building(id)` |
| room\_id | BIGINT NULL | → `customer_room(id)`,允许挂建筑/楼层而不挂房间 |
| floor\_no | VARCHAR(16) | 楼层(冗余,便于按楼层分组) |
| circuit\_code | VARCHAR(64) NOT NULL | 回路编号,全局唯一 |
| circuit\_name | VARCHAR(128) NOT NULL | 回路名称 |
| loop\_spec | VARCHAR(128) | 回路规格(如 `三相380V / 铜芯4×25+1×16`) |
| load\_type | VARCHAR(32) | 负荷类型(照明/动力/空调/插座…) |
| rated\_voltage | VARCHAR(32) | 额定电压(配置值) |
| rated\_current | VARCHAR(32) | 额定电流(配置值) |
| rated\_power | NUMERIC(10,2) | 额定功率 kW(配置值) |
| status | SMALLINT DEFAULT 1 | 1 正常 / 0 停用 |
| remark | TEXT | 备注 |
| sort\_order | INT DEFAULT 0 | 排序 |
| created\_at | TIMESTAMP | <br /> |
索引:`customer_id`、`building_id`、`room_id`、唯一键 `circuit_code`。
> 命名跟随现有习惯:表 `customer_circuit`、entity `CustomerCircuit`、路由 `/api/customer-circuits`、权限码 `customer:circuit`(与 `customer:building` / `customer:room` 同级)。
## 后端改动(Midway.js + sequelize-typescript)
参照样板:`customer_room` 全链路。
1. **迁移**:`database/migrations/20261011_customer_circuit.sql`(幂等 `CREATE TABLE IF NOT EXISTS`),并在 `database/db_init.sql` 同步该表定义。
2. **Entity**:`server/src/entity/customer_circuit.entity.ts`(照抄 `customer_building.entity.ts` 的 `@Table/@Column/@BelongsTo/@CreatedAt` 写法)。
3. **DTO**:`server/src/dto/customer_circuit.dto.ts`(Query/Create/Update,class-validator 风格照抄 `customer_room.dto.ts`)。
4. **Service**:`server/src/service/customer_circuit.service.ts`
* `list` / `detail` / `create` / `update` / `delete`(照抄 `customer_room.service.ts`,统一返回 `{ code, message, data }`)。
* `topology(customerId)`:**一次请求**返回整棵「建筑 → 楼层 → 房间 → 回路」结构,避免前端 N+1(现有页面是逐建筑拉房间)。返回形如:
```json
{ "buildings": [ { "id":1, "buildingName":"…", "floors":[ { "floorNo":"2", "rooms":[ { "id":9, "roomName":"…", "circuits":[…] } ], "circuits":[…] } ] } ] }
```
5. **Controller**:`server/src/controller/customer_circuit.controller.ts`(`@Controller("/api/customer-circuits")`,含 `GET /topology`)。
6. **权限注册**:`server/src/middleware/rbac.middleware.ts` 的 `PERMISSION_MAP` 新增 `"/api/customer-circuits": "customer:circuit"`。
7. **菜单/权限种子**:在 `database/db_seed.sql` 复用现有「空间拓扑与用电架构」菜单(id=32, `/customer/topology`, 权限码 `customer:topology`),并**参照** **`customer:room`** **的登记方式**新增按钮级权限 `customer:circuit`。
## 前端改动(Vue3 + Ant Design Vue)
1. **新增 API 封装** `client/src/api/customerCircuit.ts`:照抄 `customerRoom.ts` 风格(`getList/getDetail/create/update/delete/getTopology` + 类型定义 + 常量:负荷类型选项)。
2. **重写** `client/src/views/customer/topology/index.vue` 为三栏:
* **顶部**:面包屑 + 客户切换器 + 指标条 + 「新增回路」按钮。指标只展示**可由现有数据算出**的项(已接入建筑数、监测用电回路数、空间建模覆盖率 = 有回路的房间数/房间总数);无数据源的项(变电所/配电室、ICC 边缘网关)**不展示**。
* **左栏(≈280px)**:`a-tree` 渲染「客户 → 建筑 → 楼层 → 房间 → 回路」;节点徽章显示回路数;节点悬停操作:建筑/房间沿用现有增删改(`customerBuildingApi` / `customerRoomApi`),回路可新增/编辑/删除。
* **中栏**:单线图。上层为**只读示意**(10kV 进线柜 → 变压器 → 低压母线,按客户/建筑信息填充文案)、下层为**回路卡片网格**(编号/名称/所属房间/规格/额定参数,点击选中;实时电气量显示 `—`)。用 div 网格 + 内联 SVG 母线渲染(与设计稿一致),**不引入新依赖**(echarts 已在依赖中但不必要)。
* **右栏(≈320px)**:选中节点的属性面板(回路基本信息可编辑并保存;房间/建筑节点展示只读属性)。
* 样式沿用页面既有基线:背景 `#eef3f8`、主色 `#0054cd`、卡片圆角 `12px`、等宽字体用 `Consolas/Menlo`。
3. **路由/权限**:沿用现有 `customer/topology` 路由与 `customer:topology` 菜单,**不新增页面路由**。
## 不做(明确排除)
* ICC 网关节点、ICC 设备码映射、告警阈值联动、下发同步
* 实时电气量采集与展示(一律 `—`)
* 「从 ICC 同步拓扑」「CAD/BIM 导入」「导出高清拓扑图」按钮 → 先不实现(可留占位,若留则点击提示"暂未开放")
* 「空间三维映射」视图切换
* 配电箱/配电柜层
* Flutter 管理端
## 假设(如不认可请指出)
1. 中间图的上层(进线柜/变压器)**纯前端静态示意**,文案按所选客户/建筑动态填充,不落库。
2. `circuit_code` **全局唯一**(与设备台账 `equip_code` 一致);若你希望"客户内唯一",需改唯一约束。
3. 回路可以**不挂房间**(只挂建筑+楼层),以适应公共区域回路。
4. 顶部指标只做能算出来的 3 项,其余留白而非显示假数据。
## 验证方式
1. 执行迁移:`database/migrations/20261011_customer_circuit.sql`(以及 db\_seed 的权限登记),确认表与权限数据落库。
2. 后端:启动 `server/`,用带 token 的请求验证 `GET /api/customer-circuits/topology?customerId=1`、以及 create/update/delete;无 token 应 401、无权限应 403。
3. 前端:`client/` 执行 `npm run check`(vue-tsc)通过;`npm run dev` 后进入「客户管理 → 空间拓扑与用电架构」:
* 选客户 → 左树按「建筑 → 楼层 → 房间 → 回路」正确展开;
* 新增/编辑/删除回路后树与中间卡片同步刷新;
* 点击回路卡片 → 右栏属性正确回填、可保存;
* 中间图上层的进线柜/变压器为只读示意。
4. 回归:确认原有建筑/房间增删改仍可用(房间功能未被破坏)。
@@ -0,0 +1,14 @@
# Checklist
- [x] electric_line / line_room / icc_sync_log 表存在(生产库 + db_seed.sql 一致,幂等)
- [x] ElectricLine / IccSyncLog 实体与 DTO 实现,ElectricLine 仅 created_at(timestamps:false)
- [x] 列表查询使用参数绑定(Op.like/Op.in),无字符串拼接 SQL
- [x] GET /api/electric-lines:无 token 401、有 device:list 权限 200 返回 code/message/data 结构
- [x] POST /api/electric-lines/sync:JWT+RBAC 鉴权;定时任务服务内直调
- [x] IccLineSyncService 读 ICC_BASE_URL/ICC_TOKEN 占位配置,按 line_code upsert + 写 icc_sync_log;ICC 拉取失败记录 error 不抛业务错
- [x] icc_line_sync 定时任务已注册(@midwayjs/cron)
- [x] RBAC 映射含 /api/electric-lines → device:list
- [x] 前端 /device/line 页面渲染(搜索/同步按钮/表格/分页/状态 tag),无 v-html
- [x] 菜单 id=61 挂设备监控(4) 且 role_menu 绑定,db_seed.sql 与生产库一致
- [x] 后端 tsc --noEmit、前端 vue-tsc -b 均通过
- [x] 浏览器端登录后菜单可见、页面可用、同步按钮可用
+45
View File
@@ -0,0 +1,45 @@
# 智电改造线路台帐(设备监控)Spec
## Why
设备监控模块缺少"智电改造线路"台账。线路数据由 ICC 平台同步到本系统,运营侧需按客户/设备/建筑查看线路及状态。需在既有设备模块最小套路基础上新增后端接口(含鉴权与防注入)与前端页面,并兼容 APP。
## What Changes
- **新增** `electric_line` 表后端实体/DTO/Service/Controller(查询接口 `GET /api/electric-lines` 列表 + 详情,手动同步 `POST /api/electric-lines/sync`)
- **新增** ICC 拉取同步:`IccLineSyncService` 从 `.env`(`ICC_BASE_URL`/`ICC_TOKEN`)读取占位配置,请求 ICC 线路列表接口(路径与字段映射集中定义),幂等 upsert 写入 `electric_line`,记录 `icc_sync_log`
- **新增** 定时任务:注册 `icc_line_sync.task`(@midwayjs/cron,周期默认 10 分钟,可配置),服务内直调拉取
- **新增** RBAC 映射:`/api/electric-lines → device:list`
- **新增** 前端"智电改造线路台账"页面(`views/device/line/index.vue`),路由 `/device/line`,含"同步线路"手动按钮;菜单挂设备监控目录(parent=4,新 id=61)
- **同步** db_seed.sql 与迁移文件(建表 + 菜单 + role_menu),保持幂等一致
- **安全**:全部 SQL 走 Sequelize 参数绑定;前端不用 v-html(防 XSS);接口返回统一 `code/message/data`(兼容 APP)
## Impact
- Affected specs: 设备监控(设备台账、智电设备型号)、ICC 数据同步、RBAC 权限
- Affected code: server/src(entity/dto/service/controller/middleware)、client/src(api/router/views)、database(db_seed.sql、migrations/)
## ADDED Requirements
### Requirement: 线路台账查询(JWT+RBAC)
系统 SHALL 提供线路台账列表与详情查询,仅登录且有 `device:list` 权限的用户可访问;查询支持按 customer_id/equip_id/building_id/status/关键词(line_name/line_code)过滤分页;非 ADMIN 业务员按客户归属自动限定数据范围。
#### Scenario: 成功查询
- **WHEN** 已登录用户 GET `/api/electric-lines?page=1&pageSize=10&customerId=1`
- **THEN** 返回 `code=0,data{list,total}`,含线路编码/名称/所属设备/客户/建筑/楼层/位置/状态/同步时间;无 token 返回 401,无权限返回 403
### Requirement: ICC 拉取同步(占位配置 + 定时 + 手动)
系统 SHALL 提供 ICC 线路拉取同步能力:`IccLineSyncService` 读取 `.env` 的 `ICC_BASE_URL`/`ICC_TOKEN`(占位,字段映射集中在 `icc_line_mapper`),向 ICC 拉取线路列表,按 `line_code` upsert(幂等)写入 `electric_line`,更新 `synced_at`,写 `icc_sync_log`(status/record_count/error_message)。同步入口:① 定时任务(@midwayjs/cron,默认每 10 分钟,服务内直调);② 手动 `POST /api/electric-lines/sync`(JWT+RBAC `device:list`)。
#### Scenario: 定时/手动拉取成功
- **WHEN** 定时任务触发或运营点击"同步线路"
- **THEN** 拉取 ICC 数据并 upsert 对应线路行,`icc_sync_log` 记录成功;返回 `code=0,data{syncedCount,updatedCount}`
#### Scenario: 手动同步鉴权失败
- **WHEN** 未登录或无 `device:list` 权限调用 `/api/electric-lines/sync`
- **THEN** 返回 401/403,不触发拉取
#### Scenario: ICC 拉取失败
- **WHEN** ICC 地址不可达或返回异常
- **THEN** 同步日志记录失败状态与错误信息,业务接口不报错
### Requirement: 前端线路台账页
系统 SHALL 提供 `/device/line` 页面:搜索栏(线路名称/编码、客户、状态)、"同步线路"按钮(调 `/api/electric-lines/sync`,成功后刷新)、表格(编码/名称/客户/设备/建筑/楼层/位置/状态 tag/同步时间)、分页;样式与设备台账一致,全部文本经 Vue 插值转义。
#### Scenario: 打开页面
- **WHEN** 有 `device:list` 权限的用户访问设备监控→智电改造线路台账
- **THEN** 渲染列表并展示分页与筛选
## MODIFIED Requirements
(无既有功能被修改)
+26
View File
@@ -0,0 +1,26 @@
# Tasks
- [ ] Task 1: 数据库层:新建 migration 建 `electric_line`(按 db_design.html:575-589 字段)、`line_room`、`icc_sync_log`(若不存在),并把建表语句同步进 db_seed.sql;幂等(IF NOT EXISTS / 增量)
- [ ] SubTask 1.1: 核对生产库是否已有 electric_line/line_room/icc_sync_log,避免重复
- [ ] SubTask 1.2: 写 migration(migrations/20261011_icc_line_ledger.sql)并执行到远程生产库
- [ ] Task 2: 后端实体+DTO:新增 `ElectricLine` entity(timestamps:false,仅 created_at)、`IccSyncLog` entity;`electric_line.dto.ts` 定义查询/同步入参(line_code 必填、字段校验、长度限制)
- [ ] Task 3: 后端 Service:
- [ ] SubTask 3.1: `ElectricLineService.list(page, filter)`(Op.like/Op.in 参数绑定,非 ADMIN 限定 customer_id)
- [ ] SubTask 3.2: `IccLineSyncService.syncFromIcc()`:读 `.env` 的 ICC_BASE_URL/ICC_TOKEN 占位配置,请求 ICC 线路列表(路径与字段映射集中在 `icc_line_mapper`),按 line_code 批量 upsert + 写 icc_sync_log + 失败捕获不抛业务错误
- [ ] Task 4: 后端 Controller + RBAC:
- [ ] SubTask 4.1: `electric_line.controller.ts`:GET /api/electric-lines(列表/详情)、POST /api/electric-lines/sync(调 IccLineSyncService)
- [ ] SubTask 4.2: rbac.middleware.ts 追加 `/api/electric-lines → device:list`
- [ ] SubTask 4.3: `.env` 增加 ICC_BASE_URL、ICC_TOKEN(占位);定时任务注册 `icc_line_sync.task`(@midwayjs/cron,默认每 10 分钟)
- [ ] Task 5: 前端 API:`client/src/api/device.ts`(或新 `line.ts`)新增 `electricLineApi.list/get/sync`
- [ ] Task 6: 前端页面:`views/device/line/index.vue`(搜索+同步按钮+表格+分页+状态 tag),路由 `/device/line`(permission device:list),菜单 id=61 挂设备监控(4) + role_menu(1,61),同步 db_seed.sql
- [ ] Task 7: 验证:
- [ ] SubTask 7.1: 后端 `tsc --noEmit`、前端 `vue-tsc -b` 类型检查
- [ ] SubTask 7.2: 启动后端/前端,接口实测(无 token 401、有权限 200、无权限 403、sync 接口鉴权、ICC 拉取失败不报错)
- [ ] SubTask 7.3: 浏览器验证菜单出现、页面渲染、筛选/分页/同步按钮可用
# Task Dependencies
- Task 2/5 依赖 Task 1(表存在)
- Task 3 依赖 Task 2
- Task 4 依赖 Task 3
- Task 6 依赖 Task 5
- Task 7 依赖全部
+27
View File
@@ -0,0 +1,27 @@
const fs = require('fs');
const path = require('path');
const { Client } = require('pg');
(async () => {
const client = new Client({
host: '222.243.55.42',
port: 26532,
database: 'xintong',
user: 'postgres',
password: '7F0d9T7SOroYqgXqtZ7tHL2SbnvUljHe'
});
await client.connect();
const sql = fs.readFileSync(
path.join(__dirname, 'database', 'migrations', '20261006_worker_password_manage.sql'),
'utf8'
);
await client.query(sql);
const menu = await client.query('SELECT id, parent_id, menu_name, menu_type, permission FROM sys_menu WHERE id = 57');
const bind = await client.query('SELECT role_id, menu_id FROM sys_role_menu WHERE menu_id = 57');
console.log('menu:', JSON.stringify(menu.rows));
console.log('bind:', JSON.stringify(bind.rows));
await client.end();
})().catch((e) => {
console.error('FAILED:', e.message);
process.exit(1);
});
+1 -1
View File
@@ -1,3 +1,3 @@
# 开发环境变量
VITE_API_BASE_URL=/api
VITE_APP_TITLE=智能物联网设备管理系统
VITE_APP_TITLE=鑫潼智电管理系统
+1 -1
View File
@@ -1,3 +1,3 @@
# 生产环境变量
VITE_API_BASE_URL=/api
VITE_APP_TITLE=智能物联网设备管理系统
VITE_APP_TITLE=鑫潼智电管理系统
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Trae Project</title>
<title>鑫潼智电管理系统</title>
<script type="module">
if (import.meta.hot?.on) {
import.meta.hot.on('vite:error', (error) => {
+3
View File
@@ -7,6 +7,7 @@ export interface CustomerBuildingInfo {
basicInfo?: string;
purpose?: string;
floorCount?: number;
floorDownCount?: number;
createdAt: string;
}
@@ -30,6 +31,7 @@ export interface CustomerBuildingCreateParams {
basicInfo?: string;
purpose?: string;
floorCount?: number;
floorDownCount?: number;
}
export interface CustomerBuildingUpdateParams {
@@ -38,6 +40,7 @@ export interface CustomerBuildingUpdateParams {
basicInfo?: string;
purpose?: string;
floorCount?: number;
floorDownCount?: number;
}
export const customerBuildingApi = {
+132
View File
@@ -0,0 +1,132 @@
import request from '@/utils/request';
export interface CustomerCircuitInfo {
id: number;
customerId: number;
buildingId: number;
roomId?: number | null;
floorNo?: string;
circuitCode: string;
circuitName: string;
loopSpec?: string;
loadType?: string;
ratedVoltage?: string;
ratedCurrent?: string;
ratedPower?: number | string | null;
status: number;
remark?: string;
sortOrder?: number;
createdAt?: string;
}
export interface CustomerCircuitQuery {
page?: number;
pageSize?: number;
keyword?: string;
customerId?: number;
buildingId?: number;
roomId?: number;
floorNo?: string;
}
export interface CustomerCircuitListResult {
list: CustomerCircuitInfo[];
total: number;
page: number;
pageSize: number;
}
export interface CustomerCircuitCreateParams {
circuitCode: string;
customerId: number;
buildingId: number;
circuitName: string;
roomId?: number;
floorNo?: string;
loopSpec?: string;
loadType?: string;
ratedVoltage?: string;
ratedCurrent?: string;
ratedPower?: number;
status?: number;
remark?: string;
sortOrder?: number;
}
export interface CustomerCircuitUpdateParams {
circuitCode?: string;
customerId?: number;
buildingId?: number;
circuitName?: string;
roomId?: number;
floorNo?: string;
loopSpec?: string;
loadType?: string;
ratedVoltage?: string;
ratedCurrent?: string;
ratedPower?: number;
status?: number;
remark?: string;
sortOrder?: number;
}
// ==================== 空间拓扑(一次返回整棵树) ====================
export interface TopologyRoom {
id: number;
roomCode: string;
roomName: string;
buildingId: number;
floorNo?: string;
roomPurpose?: string;
deptId?: number;
circuits: CustomerCircuitInfo[];
}
export interface TopologyFloor {
floorNo: string;
rooms: TopologyRoom[];
circuits: CustomerCircuitInfo[];
}
export interface TopologyBuilding {
id: number;
customerId: number;
buildingName: string;
purpose?: string;
basicInfo?: string;
floorCount?: number;
floorDownCount?: number;
floors: TopologyFloor[];
}
export interface CustomerTopologyResult {
buildings: TopologyBuilding[];
}
// ==================== 常量 ====================
export const CIRCUIT_LOAD_TYPES = ['照明', '动力', '空调', '插座', '电梯', '消防', '其他'];
export const CIRCUIT_STATUS_OPTIONS = [
{ label: '正常', value: 1 },
{ label: '停用', value: 0 },
];
export const customerCircuitApi = {
getList: (params: CustomerCircuitQuery) =>
request.get<CustomerCircuitListResult>('/customer-circuits', { params }),
getTopology: (customerId: number) =>
request.get<CustomerTopologyResult>('/customer-circuits/topology', { params: { customerId } }),
getDetail: (id: number) =>
request.get<CustomerCircuitInfo>(`/customer-circuits/${id}`),
create: (data: CustomerCircuitCreateParams) =>
request.post('/customer-circuits', data),
update: (id: number, data: CustomerCircuitUpdateParams) =>
request.put(`/customer-circuits/${id}`, data),
delete: (id: number) =>
request.delete(`/customer-circuits/${id}`),
};
+59
View File
@@ -143,4 +143,63 @@ export const smartDeviceApi = {
delete: (id: number) =>
request.delete(`/smart-devices/${id}`),
};
// ==================== 智电改造线路台账 ====================
export interface ElectricLine {
id: number;
lineCode: string;
equipId: number;
iccDeviceCode?: string;
lineName?: string;
customerId: number;
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'),
};
+37 -1
View File
@@ -9,11 +9,33 @@ export interface JobParamField {
placeholder?: string;
}
/** 频率类型:每N分钟 / 每N小时 / 每天 / 每周 / 每月 / 自定义 cron */
export type FreqType = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'custom';
/** 频率配置(字段按 freqType 取用) */
export interface FreqConfig {
/** minute/hour:间隔数量 */
interval?: number;
/** hour:每小时的第几分钟 */
minute?: number;
/** day/week/month:执行时间点 HH:mm */
times?: string[];
/** week:星期,0=周日 ... 6=周六 */
weekdays?: number[];
/** month:日期 1-31 */
days?: number[];
/** custom:自定义 cron 表达式 */
expression?: string;
}
export interface JobItem {
id: number;
jobKey: string;
jobName: string;
cronExpression: string;
freqType: FreqType;
freqConfig: FreqConfig;
cronDescription?: string | null;
params: Record<string, any>;
retryTimes: number;
retryInterval: number;
@@ -71,13 +93,23 @@ export interface JobLogQuery {
}
export interface UpdateJobPayload {
cronExpression: string;
freqType: FreqType;
freqConfig: FreqConfig;
params: Record<string, any>;
retryTimes: number;
retryInterval: number;
remark?: string;
}
/** 频率预览结果:由后端由频率配置推导 cron、中文描述与未来执行时间 */
export interface CronPreviewResult {
valid: boolean;
error?: string | null;
cronExpression?: string | null;
description?: string | null;
nextRuns: string[];
}
export interface JobListResult {
list: JobItem[];
total: number;
@@ -99,6 +131,10 @@ export const jobApi = {
updateJob: (id: number, data: UpdateJobPayload) =>
request.put<JobItem>(`/jobs/${id}`, data),
/** 频率预览:人性化配置 → cron / 中文描述 / 未来 5 次执行时间 */
previewCron: (data: { freqType: FreqType; freqConfig: FreqConfig }) =>
request.post<CronPreviewResult>('/jobs/cron/preview', data),
changeJobStatus: (id: number, status: 0 | 1) =>
request.put<JobItem>(`/jobs/${id}/status`, { status }),
+24 -40
View File
@@ -30,10 +30,6 @@ export const SURVEY_PRIORITY_OPTIONS = [
{ value: 3, label: '常规 · P3' }
];
// SOP 规范模板编码与名称
export const SOP_TEMPLATE_CODE = 'standard_dist';
export const SOP_TEMPLATE_LABEL = '标准配电改造模板';
// 报酬规则(后端 dispatch_price 未配置时的前端兜底展示值)
export const SURVEY_BASE_FEE = 350;
export const SURVEY_LOOP_FEE = 5;
@@ -61,35 +57,22 @@ export interface SurveyOrderWorker {
completedCount?: number;
}
export interface SurveySopItem {
id: number;
templateCode: string;
sortOrder: number;
itemName: string;
itemDesc?: string;
// 回执形式:photo 现场照片 / form 清单表单 / meter 测仪截图
receiptType: 'photo' | 'form' | 'meter' | string;
receiptRequire?: string;
required: boolean;
}
export interface SurveyOrderSopResult {
id: number;
surveyOrderId: number;
sopItemId: number;
checked: boolean;
// 回执内容:photo/meter 类为照片对象 key 数组,form 类为单元素文本值数组
receiptValue?: string[];
// 详情/回执接口附带:照片类回执的预签名访问 URL(与 receiptValue 顺序一致)
receiptUrls?: string[];
remark?: string;
checkedAt?: string;
// 由后端 join 的检查项信息
sopItem?: SurveySopItem;
itemName?: string;
receiptType?: string;
receiptRequire?: string;
sortOrder?: number;
// 勘察明细:一条 = 建筑 + 楼层 + 房间(可空) + 现场照片
export interface SurveyOrderDetail {
id?: number;
surveyOrderId?: number;
buildingId?: number | null;
// 建筑名称(冗余存储,客户拓扑变更后仍可读)
buildingName?: string | null;
// 楼层:正数为地上、负数为地下,如 '1' / '-1'
floorNo?: string | null;
roomId?: number | null;
roomName?: string | null;
// 现场照片对象 key
photos?: string[];
// 详情接口附带:照片预签名访问 URL(与 photos 顺序一致)
photoUrls?: string[];
remark?: string | null;
}
export interface SurveyOrderInfo {
@@ -141,8 +124,8 @@ export interface SurveyOrderInfo {
updatedAt?: string;
created_at?: string;
updated_at?: string;
// 详情附带:SOP 回执
sopResults?: SurveyOrderSopResult[];
// 详情附带:勘察明细(建筑/楼层/房间 + 现场照片)
details?: SurveyOrderDetail[];
}
export interface SurveyOrderQuery {
@@ -209,7 +192,8 @@ export interface SurveyOrderCreateParams {
remark?: string;
source?: string;
isDraft?: number;
templateCode?: string;
// 勘察明细(创建/编辑时全量提交,新增/编辑/删除统一走此数组)
details?: SurveyOrderDetail[];
}
export type SurveyOrderUpdateParams = Partial<SurveyOrderCreateParams>;
@@ -247,10 +231,6 @@ export const surveyOrderApi = {
// KPI 统计卡
getStats: () => request.get<SurveyOrderStats>('/survey-orders/stats'),
// SOP 规范模板检查项
getSopItems: (templateCode: string = SOP_TEMPLATE_CODE) =>
request.get<SurveySopItem[]>('/survey-orders/sop-items', { params: { templateCode } }),
// 报酬测算:基础出场工时费 + 回路数 × 单价
getQuote: (loopCount: number) =>
request.get<SurveyQuote>('/survey-orders/quote', { params: { loopCount } }),
@@ -264,6 +244,10 @@ export const surveyOrderApi = {
update: (id: number, data: SurveyOrderUpdateParams) =>
request.put<SurveyOrderInfo>(`/survey-orders/${id}`, data),
// 勘察明细:全量替换(新增/编辑/删除统一提交完整数组)
saveDetails: (id: number, details: SurveyOrderDetail[]) =>
request.put<SurveyOrderDetail[]>(`/survey-orders/${id}/details`, { details }),
// 指派 / 改派共享电工
assign: (id: number, data: SurveyOrderAssignParams) =>
request.put<SurveyOrderInfo>(`/survey-orders/${id}/assign`, data),
+5
View File
@@ -43,6 +43,8 @@ export interface WorkerRecord {
auditBy?: number;
auditAt?: string;
lastLoginAt?: string;
// 是否已开通电工 APP 登录账号
hasAccount?: boolean;
certStatus?: WorkerCertStatus;
certs?: WorkerCertSummary[];
createdAt?: string;
@@ -119,6 +121,9 @@ export const workerRegistryApi = {
audit: (id: number, data: WorkerAuditPayload) => request.put(`/workers/${id}/audit`, data),
// 重置 APP 登录密码(后端固定重置为手机号后 6 位)
resetPassword: (id: number) => request.post(`/workers/${id}/reset-password`),
addCert: (id: number, data: WorkerCertPayload) => request.post(`/workers/${id}/certs`, data),
updateCert: (id: number, certId: number, data: WorkerCertPayload) =>
+3 -3
View File
@@ -7,7 +7,7 @@
<!-- Logo区域 -->
<div class="logo" @click="$router.push('/dashboard')">
<ThunderboltOutlined class="logo-img" />
<h1 v-show="!collapsed" class="logo-title">智能电网IoT</h1>
<h1 v-show="!collapsed" class="logo-title">鑫潼智电</h1>
</div>
<!-- 菜单 -->
@@ -402,8 +402,8 @@ const NOTICE_TYPE_ROUTE: Record<string, string> = {
const NOTICE_BIZ_ROUTE: Record<string, string> = {
repair_dispatch: '/dispatch/repair',
maintenance_order: '/dispatch/maintenance',
install_order: '/project/install',
survey_order: '/project/survey',
install_order: '/dispatch/install',
survey_order: '/dispatch/survey',
};
function resolveNoticeRoute(item: NoticeInfo): string | null {
+62 -56
View File
@@ -88,39 +88,9 @@ const routes: RouteRecordRaw[] = [
{
path: "project",
name: "Project",
redirect: "/project/survey",
redirect: "/project/plan",
meta: { requiresAuth: true, title: "项目管理", permission: "project:list" }
},
{
path: "project/survey",
name: "ProjectSurvey",
component: () => import("@/views/project/survey/index.vue"),
meta: { requiresAuth: true, title: "项目勘察", permission: "project:survey" }
},
{
path: "project/survey/create",
name: "ProjectSurveyCreate",
component: () => import("@/views/project/survey/form.vue"),
meta: {
requiresAuth: true,
title: "新建勘察派单",
permission: "project:survey",
parentTitle: "现场勘察派单",
parentPath: "/project/survey"
}
},
{
path: "project/survey/edit/:id",
name: "ProjectSurveyEdit",
component: () => import("@/views/project/survey/form.vue"),
meta: {
requiresAuth: true,
title: "编辑勘察派单",
permission: "project:survey",
parentTitle: "现场勘察派单",
parentPath: "/project/survey"
}
},
{
path: "project/plan",
name: "ProjectPlan",
@@ -145,18 +115,54 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/project/visit/index.vue"),
meta: { requiresAuth: true, title: "回访记录", permission: "project:visit" }
},
{
path: "project/install",
name: "ProjectInstall",
component: () => import("@/views/project/install/index.vue"),
meta: { requiresAuth: true, title: "安装派单", permission: "project:install" }
},
{
path: "dispatch",
name: "Dispatch",
redirect: "/dispatch/repair",
meta: { requiresAuth: true, title: "运营派单", permission: "dispatch:list" }
},
{
path: "dispatch/worker",
name: "DispatchWorker",
component: () => import("@/views/dispatch/worker/index.vue"),
meta: { requiresAuth: true, title: "电工登记管理", permission: "dispatch:worker" }
},
{
path: "dispatch/survey",
name: "DispatchSurvey",
component: () => import("@/views/dispatch/survey/index.vue"),
meta: { requiresAuth: true, title: "项目勘察", permission: "project:survey" }
},
{
path: "dispatch/survey/create",
name: "DispatchSurveyCreate",
component: () => import("@/views/dispatch/survey/form.vue"),
meta: {
requiresAuth: true,
title: "新建勘察派单",
permission: "project:survey",
parentTitle: "现场勘察派单",
parentPath: "/dispatch/survey"
}
},
{
path: "dispatch/survey/edit/:id",
name: "DispatchSurveyEdit",
component: () => import("@/views/dispatch/survey/form.vue"),
meta: {
requiresAuth: true,
title: "编辑勘察派单",
permission: "project:survey",
parentTitle: "现场勘察派单",
parentPath: "/dispatch/survey"
}
},
{
path: "dispatch/install",
name: "DispatchInstall",
component: () => import("@/views/dispatch/install/index.vue"),
meta: { requiresAuth: true, title: "安装派单", permission: "project:install" }
},
{
path: "dispatch/repair",
name: "DispatchRepair",
@@ -169,24 +175,6 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/dispatch/maintenance/index.vue"),
meta: { requiresAuth: true, title: "维保派单", permission: "dispatch:maintenance" }
},
{
path: "dispatch/price",
name: "DispatchPrice",
component: () => import("@/views/dispatch/price/index.vue"),
meta: { requiresAuth: true, title: "运维费定价", permission: "dispatch:price" }
},
{
path: "dispatch/cost",
name: "DispatchCost",
component: () => import("@/views/dispatch/cost/index.vue"),
meta: { requiresAuth: true, title: "勘察费用", permission: "dispatch:cost" }
},
{
path: "dispatch/worker",
name: "DispatchWorker",
component: () => import("@/views/dispatch/worker/index.vue"),
meta: { requiresAuth: true, title: "电工登记管理", permission: "dispatch:worker" }
},
{
path: "device",
name: "Device",
@@ -198,6 +186,12 @@ const routes: RouteRecordRaw[] = [
name: "DeviceEquipment",
component: () => import("@/views/device/equipment/index.vue"),
meta: { requiresAuth: true, title: "设备管理台账", permission: "device:list" }
},
{
path: "device/line",
name: "DeviceLine",
component: () => import("@/views/device/line/index.vue"),
meta: { requiresAuth: true, title: "智电改造线路台账", permission: "device:list" }
},
{
path: "base",
@@ -211,6 +205,18 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/device/smart-device/index.vue"),
meta: { requiresAuth: true, title: "智电设备型号", permission: "device:model" }
},
{
path: "base/price",
name: "BasePrice",
component: () => import("@/views/base/price/index.vue"),
meta: { requiresAuth: true, title: "运维费定价", permission: "dispatch:price" }
},
{
path: "base/cost",
name: "BaseCost",
component: () => import("@/views/base/cost/index.vue"),
meta: { requiresAuth: true, title: "勘察费用", permission: "dispatch:cost" }
},
{
path: "report",
name: "Report",
@@ -274,7 +280,7 @@ const router = createRouter({
});
router.beforeEach(async (to, _from, next) => {
document.title = to.meta.title ? `${to.meta.title} - 智能物联网设备管理系统` : "智能物联网设备管理系统";
document.title = to.meta.title ? `${to.meta.title} - 鑫潼智电管理系统` : "鑫潼智电管理系统";
const permissionStore = usePermissionStore();
const token = getToken();
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -798,7 +798,7 @@ function handleFollow(record: CustomerVisitInfo) {
// 一键发起勘察派单:跳转勘察派单页并预填客户(不创建、不回写状态)
function handleSurvey(record: CustomerVisitInfo) {
if (!record.customerId) return;
router.push({ path: '/project/survey', query: { customerId: String(record.customerId) } });
router.push({ path: '/dispatch/survey', query: { customerId: String(record.customerId) } });
}
// 查看关联工程
+25 -10
View File
@@ -70,7 +70,7 @@
<div>
<div class="card-title-row">
<h3 class="card-title">近7日业务增长趋势</h3>
<span class="card-tag">REALTIME 新增统计</span>
<span class="card-tag">实时新增统计</span>
</div>
<p class="card-sub">每日新增托管客户与新增合同的对比走势</p>
</div>
@@ -138,12 +138,12 @@
<div>
<div class="card-title-row">
<h3 class="card-title">待办勘察派单流水</h3>
<span class="live-alerts">LIVE ORDERS</span>
<span class="live-alerts">实时单量</span>
</div>
<p class="card-sub">最新勘察派单与承接状态的实时跟踪</p>
</div>
<button class="ghost-btn" @click="handleRefresh">
<span class="material-symbols-outlined">filter_list</span>
<ReloadOutlined />
刷新
</button>
</div>
@@ -184,8 +184,14 @@
</td>
<td class="ta-r">
<div class="ops">
<button class="icon-btn" title="催办"><PhoneOutlined /></button>
<button class="icon-btn" title="更多"><MoreOutlined /></button>
<a-dropdown trigger="click" placement="bottomRight">
<button class="icon-btn" title="更多"><MoreOutlined /></button>
<template #overlay>
<a-menu @click="({ key }) => handleRowAction(key as string, row)">
<a-menu-item key="detail">查看详情</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</td>
</tr>
@@ -196,7 +202,7 @@
<div class="card-foot">
<span class="mono muted">显示 {{ stats?.surveyList?.length || 0 }} / {{ formatNum(stats?.surveyPending) }} 个待办勘察单</span>
<a class="card-link">进入勘察派单工作流<ArrowRightOutlined /></a>
<a class="card-link" @click="goSurveyWorkflow">进入勘察派单工作流<ArrowRightOutlined /></a>
</div>
</div>
@@ -241,7 +247,7 @@
<div>
<div class="card-title-row">
<h3 class="card-title">电工资质到期提醒</h3>
<span class="card-tag">CERT ALERT</span>
<span class="card-tag">证照预警</span>
</div>
<p class="card-sub">临期(30 天内)与已过期的电工资质证照清单,已过期优先展示</p>
</div>
@@ -297,7 +303,6 @@ import {
ScheduleOutlined,
FieldTimeOutlined,
DeploymentUnitOutlined,
PhoneOutlined,
MoreOutlined,
UserOutlined,
RiseOutlined,
@@ -305,7 +310,7 @@ import {
} from '@ant-design/icons-vue';
import * as echarts from 'echarts';
import dayjs from 'dayjs';
import { dashboardApi, type DashboardStats } from '@/api/dashboard';
import { dashboardApi, type DashboardStats, type SurveyOrderItem } from '@/api/dashboard';
import { workerRegistryApi } from '@/api/workerRegistry';
const router = useRouter();
@@ -515,6 +520,17 @@ function handleRefresh() {
loadStats();
}
// 勘察流水行操作
function handleRowAction(key: string, row: SurveyOrderItem) {
if (key === 'detail') {
router.push({ name: 'DispatchSurveyEdit', params: { id: String(row.id) } });
}
}
function goSurveyWorkflow() {
router.push('/dispatch/survey');
}
function initCharts() {
if (trendChartRef.value) trendChart = echarts.init(trendChartRef.value);
if (donutChartRef.value) donutChart = echarts.init(donutChartRef.value);
@@ -905,6 +921,5 @@ function disposeCharts() {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.material-symbols-outlined { font-size: 16px; line-height: 1; }
}
</style>
+502
View File
@@ -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>
@@ -229,34 +229,135 @@
<div class="section-title">
<span class="bar bar-secondary"></span>
<FileDoneOutlined class="sec-icon clr-secondary" />
<h2>第 2 步:勘察技术要点与回执标准</h2>
<h2>第 2 步:勘察明细</h2>
</div>
<div v-if="!isReadonly" class="tmpl-box">
<span class="head-hint">SOP规范模板:</span>
<button type="button" class="tmpl-btn" @click="applySopTemplate">
<PlusOutlined />
{{ SOP_TEMPLATE_LABEL }}(已应用 {{ sopItems.length }} 项)
</button>
<div v-if="!isReadonly" class="head-actions">
<span class="head-hint">按建筑 / 楼层 / 房间逐条登记,可附现场照片</span>
<a-button size="small" type="primary" ghost @click="addDetailRow">
<template #icon><PlusOutlined /></template>
添加明细
</a-button>
</div>
</div>
<div class="sop-grid" :class="{ 'is-empty': !sopLoading && sopItems.length === 0 }">
<div v-for="item in sopItems" :key="item.id" class="sop-card">
<div class="sop-top">
<span class="sop-name">{{ item.sortOrder }}. {{ item.itemName }}</span>
<span v-if="item.required" class="sop-required">必填</span>
<div class="detail-body">
<!-- 编辑态:明细编辑行 -->
<template v-if="!isReadonly">
<div v-if="detailRows.length === 0" class="detail-empty">
暂无勘察明细,点击右上角「添加明细」逐条登记建筑、楼层与房间
</div>
<div class="sop-desc">{{ item.itemDesc || '—' }}</div>
<div class="sop-foot">
<span>{{ receiptTypeLabel(item.receiptType) }}</span>
<span>{{ item.receiptRequire || '—' }}</span>
<div v-else class="detail-rows">
<div v-for="(row, idx) in detailRows" :key="row.uid" class="detail-row">
<div class="detail-row-head">
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
<a-button type="text" size="small" danger @click="removeDetailRow(idx)">
<template #icon><DeleteOutlined /></template>
删除
</a-button>
</div>
<div class="detail-grid">
<div class="dfield">
<label class="dfield-label">建筑 <b class="req">*</b></label>
<a-select
v-model:value="row.buildingId"
placeholder="选择建筑"
:loading="topologyLoading"
:options="buildingSelectOptions"
:not-found-content="topologyLoading ? null : '该客户暂无建筑'"
style="width: 100%"
@change="handleRowBuildingChange(row)"
/>
</div>
<div class="dfield">
<label class="dfield-label">楼层 <b class="req">*</b></label>
<a-select
v-model:value="row.floorNo"
placeholder="选择楼层"
:disabled="!row.buildingId"
:options="floorOptionsFor(row.buildingId)"
:not-found-content="row.buildingId ? '该建筑未配置楼层数' : '请先选择建筑'"
style="width: 100%"
@change="handleRowFloorChange(row)"
/>
</div>
<div class="dfield">
<label class="dfield-label">房间 <span class="opt">选填</span></label>
<a-select
v-model:value="row.roomId"
placeholder="可不选"
allow-clear
:disabled="!row.floorNo"
:options="roomOptionsFor(row)"
:not-found-content="row.floorNo ? '该楼层暂无房间' : '请先选择楼层'"
style="width: 100%"
@change="handleRowRoomChange(row)"
/>
</div>
<div class="dfield span-all">
<label class="dfield-label">现场照片</label>
<div class="detail-photos">
<a-image-preview-group>
<div v-for="(p, pi) in row.photos" :key="pi" class="photo-thumb">
<a-image :src="p.url" :width="76" :height="57" />
<span class="photo-del" @click.stop="removePhoto(row, pi)">×</span>
</div>
</a-image-preview-group>
<a-upload
:show-upload-list="false"
:custom-request="(o: any) => handlePhotoUpload(o, row)"
:before-upload="beforePhotoUpload"
accept="image/*"
>
<button type="button" class="photo-add" :disabled="row.photos.length >= MAX_DETAIL_PHOTOS">
<PlusOutlined />
<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>
</template>
<div v-if="!isReadonly" class="sop-add" @click="handleAddCustomItem">
<PlusOutlined class="sop-add-icon" />
<span>+ 自定义勘察检查项</span>
</div>
<!-- 只读态:明细展示 -->
<template v-else>
<div v-if="detailRows.length === 0" class="detail-empty">暂无勘察明细</div>
<div v-else class="detail-rows">
<div v-for="(row, idx) in detailRows" :key="row.uid" class="detail-ro-row">
<div class="detail-ro-head">
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
<span class="detail-ro-scope">
{{ row.buildingName || '—' }} · {{ floorLabel(row.floorNo) }}
<template v-if="row.roomName"> · {{ row.roomName }}</template>
</span>
</div>
<div 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>
</section>
@@ -362,10 +463,6 @@
<span class="ro-k">测绘进度</span>
<span class="ro-v mono">{{ roProgressDone }} / {{ roProgressTotal }} 路</span>
</div>
<div class="ro-fact">
<span class="ro-k">SOP 回执</span>
<span class="ro-v mono">{{ roSopChecked }} / {{ roSopResults.length }} 项</span>
</div>
<div class="ro-fact">
<span class="ro-k">现场照片</span>
<span class="ro-v mono">{{ roPhotos.length }} 张</span>
@@ -387,25 +484,6 @@
</div>
</div>
<!-- SOP 回执明细 -->
<div class="ro-block">
<div class="ro-block-title">SOP 回执明细</div>
<div v-if="roSopResults.length === 0" class="ro-empty">暂无 SOP 回执记录</div>
<div v-else class="ro-list">
<div v-for="(r, idx) in roSopResults" :key="r.id || idx" class="ro-sop-row">
<span class="ro-sop-check" :class="{ done: r.checked }">
<CheckOutlined v-if="r.checked" />
</span>
<span class="ro-sop-name">{{ r.itemName || r.sopItem?.itemName || `检查项 #${r.sopItemId}` }}</span>
<span class="ro-sop-tag">{{ receiptTypeLabel(r.receiptType || r.sopItem?.receiptType) }}</span>
<div v-if="r.receiptUrls && r.receiptUrls.length" class="ro-sop-thumbs">
<a-image v-for="(url, i) in r.receiptUrls" :key="i" :src="url" :width="56" :height="42" class="ro-sop-thumb" />
</div>
<span v-else class="ro-sop-value mono">{{ r.receiptValue?.length ? r.receiptValue.join('、') : r.remark || '—' }}</span>
</div>
</div>
</div>
<!-- 现场取证照片 -->
<div class="ro-block">
<div class="ro-block-title">现场取证照片</div>
@@ -500,26 +578,27 @@ import {
CheckCircleFilled,
StarFilled,
PlusOutlined,
DeleteOutlined,
ApartmentOutlined,
FileDoneOutlined,
ToolOutlined,
} from '@ant-design/icons-vue';
import {
surveyOrderApi,
SOP_TEMPLATE_CODE,
SOP_TEMPLATE_LABEL,
SURVEY_STATUS,
SURVEY_PRIORITY,
SURVEY_PRIORITY_OPTIONS,
SURVEY_BASE_FEE,
SURVEY_LOOP_FEE,
type SurveySopItem,
type SurveyQuote,
type SurveyOrderCreateParams,
type SurveyOrderInfo,
type SurveyOrderDetail,
} from '@/api/surveyOrder';
import { workerApi, type WorkerRecommend } from '@/api/worker';
import { customerApi, type CustomerInfo } from '@/api/customer';
import { customerCircuitApi, type CustomerTopologyResult, type TopologyBuilding } from '@/api/customerCircuit';
import { uploadApi } from '@/api/upload';
const router = useRouter();
const route = useRoute();
@@ -527,16 +606,10 @@ const route = useRoute();
// ==================== 步骤元数据 ====================
const STEPS = [
{ value: 1, name: '基本信息与现场环境', sub: '目标客户、区域与回路' },
{ value: 2, name: '技术要点与回执标准', sub: '标准SOP模版、拍照指标' },
{ value: 2, name: '勘察明细', sub: '建筑、楼层、房间与照片' },
{ value: 3, name: '指派电工与服务报酬', sub: '匹配电工、资质与费率' },
];
const RECEIPT_TYPE_LABEL: Record<string, string> = {
photo: '现场照片',
form: '清单格式',
meter: '手持测仪',
};
const SLA_OPTIONS = [
{ value: 'SLA-GRADE A (24h回传)', label: 'SLA-GRADE A (24h回传)' },
{ value: 'SLA-GRADE B (48h回传)', label: 'SLA-GRADE B (48h回传)' },
@@ -571,8 +644,6 @@ const STATUS_META: Record<number, { label: string; color: string; bg: string }>
const statusMeta = computed(() => (detailRecord.value ? STATUS_META[detailRecord.value.status] || null : null));
const roPhotos = computed(() => (detailRecord.value?.photoUrls || []).filter((u) => !!u));
const roSopResults = computed(() => detailRecord.value?.sopResults || []);
const roSopChecked = computed(() => roSopResults.value.filter((r) => r.checked).length);
const roProgressDone = computed(() => Number(detailRecord.value?.progressDone) || 0);
const roProgressTotal = computed(() => Number(detailRecord.value?.progressTotal) || 0);
const roProgressPercent = computed(() => {
@@ -625,13 +696,165 @@ const customerSelectOptions = computed(() =>
);
const selectedCustomer = computed(() => customerOptions.value.find((c) => c.id === formState.customerId) || null);
// ==================== SOP 检查项 ====================
const sopItems = ref<SurveySopItem[]>([]);
const sopLoading = ref(false);
// ==================== 客户空间拓扑 ====================
const topology = ref<CustomerTopologyResult | null>(null);
const topologyLoading = ref(false);
const buildingSelectOptions = computed(() =>
(topology.value?.buildings || []).map((b) => ({ label: b.buildingName, value: b.id }))
);
function receiptTypeLabel(type?: string) {
if (!type) return '—';
return RECEIPT_TYPE_LABEL[type] || type;
function buildingById(id?: number): TopologyBuilding | undefined {
if (!id) return undefined;
return (topology.value?.buildings || []).find((b) => b.id === id);
}
// 楼层选项:地上 1..floorCount,地下 负1..负floorDownCount;value 为字符串(地上 '1' / 地下 '-1')
function buildFloorOptions(b?: TopologyBuilding): { label: string; value: string }[] {
if (!b) return [];
const up = b.floorCount ?? 0;
const down = b.floorDownCount ?? 0;
const opts: { label: string; value: string }[] = [];
for (let i = down; i >= 1; i--) opts.push({ label: `负${i}层`, value: String(-i) });
for (let i = 1; i <= up; i++) opts.push({ label: `${i}层`, value: String(i) });
return opts;
}
function floorOptionsFor(buildingId?: number) {
return buildFloorOptions(buildingById(buildingId));
}
function floorLabel(floorNo?: string | null) {
if (floorNo === undefined || floorNo === null || floorNo === '') return '未标注楼层';
const n = Number(floorNo);
if (Number.isNaN(n)) return String(floorNo);
return n < 0 ? `负${Math.abs(n)}层` : `${n}层`;
}
function roomOptionsFor(row: DetailRow) {
const b = buildingById(row.buildingId);
if (!b || !row.floorNo) return [];
return b.floors
.filter((f) => (f.floorNo || '') === row.floorNo)
.flatMap((f) => f.rooms)
.map((r) => ({ label: r.roomName, value: r.id }));
}
async function loadTopology() {
if (!formState.customerId) {
topology.value = null;
return;
}
topologyLoading.value = true;
try {
const res: any = await customerCircuitApi.getTopology(formState.customerId);
if (res.code === 200 && res.data) {
topology.value = res.data;
} else {
topology.value = { buildings: [] };
}
} catch {
topology.value = { buildings: [] };
} finally {
topologyLoading.value = false;
}
}
// ==================== 勘察明细 ====================
interface DetailPhoto {
// 对象存储 key(提交时使用)
key: string;
// 预签名访问 URL(预览使用)
url: string;
}
interface DetailRow {
uid: number;
buildingId?: number;
buildingName?: string;
floorNo?: string;
roomId?: number;
roomName?: string;
photos: DetailPhoto[];
remark: string;
}
const MAX_DETAIL_PHOTOS = 9;
let detailUidSeed = 0;
const nextDetailUid = () => ++detailUidSeed;
const detailRows = ref<DetailRow[]>([]);
function addDetailRow() {
detailRows.value.push({ uid: nextDetailUid(), photos: [], remark: '' });
}
function removeDetailRow(index: number) {
detailRows.value.splice(index, 1);
}
function handleRowBuildingChange(row: DetailRow) {
row.buildingName = buildingById(row.buildingId)?.buildingName;
row.floorNo = undefined;
row.roomId = undefined;
row.roomName = undefined;
}
function handleRowFloorChange(row: DetailRow) {
row.roomId = undefined;
row.roomName = undefined;
}
function handleRowRoomChange(row: DetailRow) {
const room = roomOptionsFor(row).find((r) => r.value === row.roomId);
row.roomName = room?.label;
}
function beforePhotoUpload(file: File) {
const isImage = file.type.startsWith('image/');
if (!isImage) {
message.error('仅支持上传图片文件');
return false;
}
if (file.size > 10 * 1024 * 1024) {
message.error('图片大小不能超过 10MB');
return false;
}
return true;
}
async function handlePhotoUpload(options: any, row: DetailRow) {
const { file, onSuccess, onError } = options;
try {
const res: any = await uploadApi.uploadImage(file as File);
if (res.code === 200 && res.data) {
row.photos.push({ key: res.data.key, url: res.data.url });
onSuccess?.(res.data);
} else {
throw new Error(res.message || '上传失败');
}
} catch (e: any) {
message.error(e?.message || '照片上传失败');
onError?.(e);
}
}
function removePhoto(row: DetailRow, index: number) {
row.photos.splice(index, 1);
}
// 仅保留建筑与楼层均完整的明细行(后端同样会丢弃缺项行)
function buildDetails(): SurveyOrderDetail[] {
return detailRows.value
.filter((row) => !!row.buildingId && !!row.floorNo)
.map((row) => ({
buildingId: row.buildingId,
buildingName: row.buildingName || buildingById(row.buildingId)?.buildingName || undefined,
floorNo: row.floorNo,
roomId: row.roomId ?? undefined,
roomName: row.roomName || undefined,
photos: row.photos.filter((p) => !!p.key).map((p) => p.key),
remark: row.remark.trim() || undefined,
}));
}
// ==================== 报酬与电工 ====================
@@ -676,7 +899,7 @@ function clearError(key: string) {
}
function goLedger() {
router.push({ name: 'ProjectSurvey' });
router.push({ name: 'DispatchSurvey' });
}
function handleCustomerChange() {
@@ -690,6 +913,9 @@ function handleCustomerChange() {
formState.techContact = `${customer.contactPerson}${customer.contactPhone ? ` - ${customer.contactPhone}` : ''}`;
}
}
// 客户变更后拓扑重建,清空已填明细避免残留失效的建筑/楼层
detailRows.value = [];
loadTopology();
loadWorkers();
}
@@ -698,18 +924,6 @@ function selectWorker(w: WorkerRecommend) {
formState.workerId = w.id;
}
function applySopTemplate() {
if (sopItems.value.length === 0) {
loadSopItems();
return;
}
message.success(`已应用${SOP_TEMPLATE_LABEL} ${sopItems.value.length} 项检查标准`);
}
function handleAddCustomItem() {
message.info('自定义勘察检查项功能暂未开放');
}
// ==================== 数据加载 ====================
async function loadCustomers() {
try {
@@ -723,20 +937,6 @@ async function loadCustomers() {
}
}
async function loadSopItems() {
sopLoading.value = true;
try {
const res = await surveyOrderApi.getSopItems(SOP_TEMPLATE_CODE);
if (res.code === 200 && res.data) {
sopItems.value = res.data;
}
} catch {
message.error('SOP 检查项加载失败');
} finally {
sopLoading.value = false;
}
}
async function loadQuote() {
const count = Number(formState.loopCount) || 0;
try {
@@ -802,6 +1002,17 @@ async function loadDetail() {
d.planEnd ? dayjs(d.planEnd) : null,
];
}
// 回填勘察明细:photos 为对象 key,photoUrls 为预览地址(顺序一致)
detailRows.value = (d.details || []).map((dt) => ({
uid: nextDetailUid(),
buildingId: dt.buildingId ?? undefined,
buildingName: dt.buildingName ?? undefined,
floorNo: dt.floorNo ?? undefined,
roomId: dt.roomId ?? undefined,
roomName: dt.roomName ?? undefined,
photos: (dt.photos || []).map((key, i) => ({ key, url: dt.photoUrls?.[i] || '' })),
remark: dt.remark || '',
}));
} else {
message.error(res.message || '获取勘察派单详情失败');
}
@@ -857,7 +1068,7 @@ function buildPayload(isDraft: number): SurveyOrderCreateParams {
remark: formState.remark.trim() || undefined,
source: 'ledger',
isDraft,
templateCode: SOP_TEMPLATE_CODE,
details: buildDetails(),
};
}
@@ -911,15 +1122,16 @@ async function handleSubmit(draft: boolean) {
}
onMounted(async () => {
// 客户拜访台账「一键发起勘察派单」跳转预填:/project/survey/create?customerId=xx
// 客户拜访台账「一键发起勘察派单」跳转预填:/dispatch/survey/create?customerId=xx
const preCustomerId = Number(route.query.customerId || 0);
if (preCustomerId && !isEdit.value) {
formState.customerId = preCustomerId;
}
await loadCustomers();
await Promise.all([loadSopItems(), loadQuote(), loadWorkers()]);
// 编辑态先取详情(含 customerId 与明细回填),再按客户加载空间拓扑
await loadDetail();
await Promise.all([loadTopology(), loadQuote(), loadWorkers()]);
});
</script>
@@ -1338,107 +1550,178 @@ onMounted(async () => {
background: #0054cd;
}
/* ---------- 第 2 步:SOP ---------- */
.tmpl-box {
/* ---------- 第 2 步:勘察明细 ---------- */
.head-actions {
display: flex;
align-items: center;
gap: 8px;
gap: 12px;
flex-shrink: 0;
}
.tmpl-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border: none;
border-radius: 9999px;
background: rgba(113, 42, 226, 0.1);
color: #712ae2;
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: background 0.2s;
}
.tmpl-btn:hover {
background: rgba(113, 42, 226, 0.2);
}
.sop-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
.detail-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px 20px 20px;
}
.sop-grid.is-empty {
grid-template-columns: 1fr;
.detail-empty {
padding: 24px;
text-align: center;
font-size: 13px;
color: #5b6b7e;
background: #eef4ff;
border-radius: 10px;
}
.sop-card {
.detail-rows {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
padding: 12px;
border: 1px solid rgba(219, 228, 238, 0.5);
border-radius: 8px;
background: #eef4ff;
gap: 12px;
}
.sop-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
.detail-row {
border: 1px solid #dbe4ee;
border-radius: 10px;
background: #f8fafc;
overflow: hidden;
}
.sop-name {
font-size: 13px;
font-weight: 700;
color: #0d1c2c;
line-height: 1.5;
}
.sop-required {
flex-shrink: 0;
padding: 2px 8px;
border-radius: 4px;
background: rgba(220, 38, 38, 0.1);
color: #dc2626;
font-size: 11px;
font-weight: 700;
}
.sop-desc {
font-size: 12px;
line-height: 1.7;
color: #5b6b7e;
}
.sop-foot {
.detail-row-head {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 8px;
border-top: 1px solid rgba(219, 228, 238, 0.5);
font-family: 'JetBrains Mono', Consolas, monospace;
gap: 8px;
padding: 8px 14px;
background: #eef4ff;
border-bottom: 1px solid #e2eaf5;
}
.detail-row-idx {
font-size: 12px;
font-weight: 700;
color: #0054cd;
}
.sop-add {
min-height: 96px;
.detail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
padding: 14px;
}
.dfield {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.dfield.span-all {
grid-column: 1 / -1;
}
.dfield-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: #0d1c2c;
}
.dfield-label .opt {
font-size: 11px;
font-weight: 400;
color: #8a97a8;
}
.detail-photos {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.detail-photos :deep(.ant-image) {
display: block;
}
.detail-photos :deep(.ant-image img) {
object-fit: cover;
border-radius: 6px;
border: 1px solid #dbe4ee;
}
.photo-thumb {
position: relative;
}
.photo-del {
position: absolute;
top: -6px;
right: -6px;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(13, 28, 44, 0.72);
color: #fff;
font-size: 13px;
line-height: 1;
cursor: pointer;
z-index: 2;
transition: background 0.2s;
}
.photo-del:hover {
background: #dc2626;
}
.photo-add {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
gap: 4px;
width: 76px;
height: 57px;
border: 1px dashed #c2c6d8;
border-radius: 8px;
border-radius: 6px;
background: #fff;
color: #5b6b7e;
font-size: 13px;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
}
.sop-add:hover {
.photo-add:hover:not(:disabled) {
border-color: rgba(0, 84, 205, 0.5);
color: #0054cd;
}
.sop-add-icon {
font-size: 22px;
.photo-add:disabled {
cursor: not-allowed;
opacity: 0.6;
}
/* 只读态明细 */
.detail-ro-row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
border: 1px solid #dbe4ee;
border-radius: 10px;
background: #f8fafc;
}
.detail-ro-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.detail-ro-scope {
font-size: 13px;
font-weight: 700;
color: #0d1c2c;
}
.detail-ro-photos {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.detail-ro-photos :deep(.ant-image img) {
object-fit: cover;
border-radius: 8px;
}
.detail-ro-remark {
font-size: 12px;
line-height: 1.7;
color: #5b6b7e;
white-space: pre-wrap;
}
/* ---------- 第 3 步:电工与报酬 ---------- */
@@ -1719,61 +2002,6 @@ onMounted(async () => {
background: #f8fafc;
border-radius: 10px;
}
.ro-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.ro-sop-row {
display: grid;
grid-template-columns: 20px 1fr 88px 1.2fr;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 8px;
background: #f8fafc;
font-size: 12.5px;
color: #0d1c2c;
}
.ro-sop-check {
width: 16px;
height: 16px;
border: 1px solid #dbe4ee;
border-radius: 4px;
background: #fff;
color: #fff;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.ro-sop-check.done {
background: #00685f;
border-color: #00685f;
}
.ro-sop-name {
font-weight: 700;
}
.ro-sop-tag {
font-size: 11.5px;
font-weight: 700;
color: #712ae2;
}
.ro-sop-value {
font-size: 11.5px;
color: #5b6b7e;
word-break: break-all;
}
.ro-sop-thumbs {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ro-sop-thumb :deep(img) {
object-fit: cover;
border-radius: 6px;
border: 1px solid #dbe4ee;
}
.ro-photo-list {
display: flex;
flex-wrap: wrap;
@@ -1868,7 +2096,7 @@ onMounted(async () => {
.field-grid {
grid-template-columns: 1fr;
}
.sop-grid {
.detail-grid {
grid-template-columns: 1fr;
}
.cand-grid {
@@ -514,29 +514,31 @@
<div v-else class="detail-text">—</div>
</div>
<div v-if="detailRecord.sopResults?.length" class="detail-block">
<div class="detail-label">
勘察回执({{ detailSopDone }} / {{ detailRecord.sopResults.length }} 项已完成)
</div>
<div v-for="row in detailRecord.sopResults" :key="row.id" class="sop-item">
<div class="sop-item-head">
<span class="sop-item-name">{{ row.itemName || row.sopItem?.itemName || '检查项' }}</span>
<span class="chip" :style="sopChipStyle(row.checked)">{{ row.checked ? '已回执' : '未回执' }}</span>
<div v-if="detailRecord.details?.length" class="detail-block">
<div class="detail-label">勘察明细({{ detailRecord.details.length }} 条)</div>
<div class="detail-list">
<div v-for="(row, idx) in detailRecord.details" :key="row.id ?? idx" class="detail-row">
<div class="detail-row-head">
<span class="detail-row-idx">明细 {{ idx + 1 }}</span>
<span class="detail-row-scope">
{{ row.buildingName || '—' }} · {{ floorLabel(row.floorNo) }}
<template v-if="row.roomName"> · {{ row.roomName }}</template>
</span>
</div>
<div v-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 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>
@@ -809,7 +811,7 @@ onMounted(() => {
fetchWorkerOptions();
const preCustomerId = Number(route.query.customerId || 0);
if (preCustomerId) {
router.replace({ name: 'ProjectSurveyCreate', query: { customerId: String(preCustomerId) } });
router.replace({ name: 'DispatchSurveyCreate', query: { customerId: String(preCustomerId) } });
}
});
@@ -848,7 +850,7 @@ function handleExport() {
}
function handleAdd() {
router.push({ name: 'ProjectSurveyCreate' });
router.push({ name: 'DispatchSurveyCreate' });
}
// ==================== 行内操作 ====================
@@ -1120,9 +1122,6 @@ async function handleCancelSubmit() {
// ==================== 详情 / 编辑 ====================
const detailVisible = ref(false);
const detailRecord = ref<SurveyOrderInfo | null>(null);
const detailSopDone = computed(
() => (detailRecord.value?.sopResults || []).filter((row) => row.checked).length
);
async function openDetail(record: SurveyOrderInfo) {
detailRecord.value = record;
@@ -1136,7 +1135,7 @@ async function openDetail(record: SurveyOrderInfo) {
}
function goDetail(record: SurveyOrderInfo) {
router.push({ name: 'ProjectSurveyEdit', params: { id: record.id } });
router.push({ name: 'DispatchSurveyEdit', params: { id: record.id } });
}
// ==================== 展示辅助 ====================
@@ -1152,12 +1151,13 @@ function formatRange(start?: string, end?: string) {
return `${s} ~ ${e}`;
}
const sopChipStyle = (checked?: boolean) =>
chipStyle(
checked
? { color: '#059669', bg: '#d1fae5', border: '#a7f3d0' }
: { color: '#94a3b8', bg: '#f8fafc', border: '#e2e8f0' }
);
// 楼层:正数为地上、负数为地下(value 为字符串,如 '1' / '-1')
function floorLabel(floorNo?: string | null) {
if (floorNo === undefined || floorNo === null || floorNo === '') return '未标注楼层';
const n = Number(floorNo);
if (Number.isNaN(n)) return String(floorNo);
return n < 0 ? `负${Math.abs(n)}层` : `${n}层`;
}
function money(value?: number | string) {
if (value == null || value === '') return '—';
@@ -1926,33 +1926,46 @@ function maskPhone(phone?: string) {
gap: 10px;
}
.sop-item {
padding: 10px 0;
border-bottom: 1px dashed #eef1f6;
.detail-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.sop-item:last-child {
border-bottom: none;
padding-bottom: 0;
.detail-row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border: 1px solid #eef1f6;
border-radius: 8px;
background: #f8fafc;
}
.sop-item-head {
.detail-row-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
gap: 10px;
flex-wrap: wrap;
}
.sop-item-name {
.detail-row-idx {
font-size: 12px;
font-weight: 600;
color: #0054cd;
}
.detail-row-scope {
font-size: 13px;
font-weight: 600;
color: #0d1c2c;
}
.sop-item-remark {
margin-top: 6px;
.detail-row-remark {
font-size: 12px;
line-height: 1.7;
color: #8a97a8;
white-space: pre-wrap;
}
/* AntD 覆盖 */
+44 -3
View File
@@ -165,6 +165,19 @@
<template #icon><AuditOutlined /></template>
审核
</a-button>
<a-popconfirm
v-if="canResetPassword"
title="确定重置该电工的 APP 登录密码吗?"
description="重置后密码为手机号后 6 位"
ok-text="确定"
cancel-text="取消"
@confirm="handleResetPassword(record.id)"
>
<a-button type="link" size="small">
<template #icon><KeyOutlined /></template>
重置密码
</a-button>
</a-popconfirm>
<a-popconfirm
v-if="canDelete"
title="确定要删除该电工吗?"
@@ -240,8 +253,11 @@
<a-form-item label="服务评分" name="rating">
<a-input-number v-model:value="formData.rating" :min="0" :max="5" :step="0.1" style="width: 160px" />
</a-form-item>
<a-form-item v-if="!isEdit" label="APP 登录账号">
<div class="switch-line">
<a-form-item label="APP 登录账号">
<div v-if="isEdit && formData.hasAccount" class="switch-line">
<span class="form-tip">该电工已开通 APP 登录账号,如需重置密码请使用列表中的「重置密码」。</span>
</div>
<div v-else class="switch-line">
<a-switch v-model:checked="formData.withAccount" />
<span class="form-tip">开启后同时创建电工 APP 登录密码,默认密码为手机号后 6 位</span>
</div>
@@ -381,6 +397,7 @@ import {
DeleteOutlined,
EditOutlined,
AuditOutlined,
KeyOutlined,
TeamOutlined,
ClockCircleOutlined,
WarningOutlined,
@@ -425,6 +442,7 @@ const canCreate = computed(() => permissionStore.hasPermission('worker:create'))
const canUpdate = computed(() => permissionStore.hasPermission('worker:update'));
const canDelete = computed(() => permissionStore.hasPermission('worker:delete'));
const canAudit = computed(() => permissionStore.hasPermission('worker:audit'));
const canResetPassword = computed(() => permissionStore.hasPermission('worker:reset-password'));
// ==================== 列表数据 ====================
const loading = ref(false);
@@ -501,7 +519,7 @@ const columns = [
{ title: '在岗状态', key: 'status', align: 'center' as const, width: 110 },
{ title: '评分', key: 'rating', align: 'center' as const, width: 90 },
{ title: '完成数', dataIndex: 'completedCount', key: 'completedCount', align: 'center' as const, width: 90 },
{ title: '操作', key: 'action', width: 210, fixed: 'right' as const },
{ title: '操作', key: 'action', width: 300, fixed: 'right' as const },
];
const pagination = computed(() => ({
@@ -529,6 +547,8 @@ const formData = reactive({
status: 1,
rating: 5,
withAccount: false,
// 编辑时用于判断该电工是否已开通 APP 账号
hasAccount: false,
});
const formRules = {
@@ -664,6 +684,8 @@ async function handleEdit(record: WorkerRecord) {
formData.workerTitle = record.workerTitle || '';
formData.status = record.status;
formData.rating = Number(record.rating ?? 5);
formData.hasAccount = record.hasAccount === true;
formData.withAccount = false;
modalVisible.value = true;
await loadCertRows(record.id);
@@ -674,6 +696,7 @@ async function loadCertRows(workerId: number) {
try {
const res: any = await workerRegistryApi.getDetail(workerId);
if (res.code === 200 && res.data) {
formData.hasAccount = res.data.hasAccount === true;
const certs: WorkerCert[] = res.data.certs || [];
certRows.value = certs.map((c) => ({
_key: certKeySeed++,
@@ -700,6 +723,7 @@ function resetForm() {
formData.status = 1;
formData.rating = 5;
formData.withAccount = false;
formData.hasAccount = false;
certRows.value = [];
if (formRef.value) {
@@ -822,6 +846,8 @@ async function handleSubmit() {
let res: any;
if (isEdit.value && editId.value) {
payload.status = formData.status;
// 补开 APP 账号:仅对尚未开通的电工生效
payload.withAccount = formData.withAccount;
res = await workerRegistryApi.update(editId.value, payload);
} else {
payload.withAccount = formData.withAccount;
@@ -880,6 +906,21 @@ async function handleDelete(id: number) {
}
}
// ==================== 重置密码 ====================
async function handleResetPassword(id: number) {
try {
const res: any = await workerRegistryApi.resetPassword(id);
if (res.code === 200) {
message.success('密码已重置为手机号后 6 位');
fetchList();
} else {
message.error(res.message || '重置密码失败');
}
} catch (error) {
message.error('重置密码失败');
}
}
// ==================== 审核 ====================
function handleAudit(record: WorkerRecord) {
auditRecord.value = record;
+3 -3
View File
@@ -11,8 +11,8 @@
<div class="logo-icon">
<ThunderboltOutlined />
</div>
<h1 class="title">智能电网设备管理系统</h1>
<p class="subtitle">Smart Grid IoT Management System</p>
<h1 class="title">鑫潼智电管理系统</h1>
<p class="subtitle">XINTONG SMART POWER MANAGEMENT SYSTEM</p>
</div>
<a-divider />
@@ -59,7 +59,7 @@
</div>
<div class="copyright">
<p>&copy; 2026 智能电网设备管理系统 All Rights Reserved</p>
<p>&copy; 2026 鑫潼智电管理系统 All Rights Reserved</p>
</div>
</div>
</template>
+360 -14
View File
@@ -53,6 +53,12 @@
<span>{{ record.jobName }}</span>
<a-tag v-if="record.registered === false" color="red" style="margin-left: 6px">未注册</a-tag>
</template>
<template v-else-if="column.key === 'frequency'">
<div class="freq-cell">
<span>{{ record.cronDescription || record.cronExpression }}</span>
<code class="freq-cron">{{ record.cronExpression }}</code>
</div>
</template>
<template v-else-if="column.key === 'params'">
<a-tooltip :title="formatParams(record.params, true)">
<span class="ellipsis-text">{{ formatParams(record.params) }}</span>
@@ -111,7 +117,7 @@
title="编辑任务"
:confirm-loading="saving"
:mask-closable="false"
width="640px"
width="720px"
@ok="handleSave"
@cancel="handleCancelEdit"
>
@@ -125,9 +131,110 @@
<a-form-item label="任务名称">
<span>{{ currentJob?.jobName || '-' }}</span>
</a-form-item>
<a-form-item label="cron 表达式" name="cronExpression">
<a-input v-model:value="formData.cronExpression" placeholder="如 0 3 * * *" :maxlength="64" />
<a-divider orientation="left" class="form-divider">执行频率</a-divider>
<a-form-item label="频率类型">
<a-select v-model:value="formData.freqType" :options="freqTypeOptions" style="width: 100%" />
</a-form-item>
<a-form-item v-if="formData.freqType === 'minute'" label="间隔">
<a-input-number
v-model:value="formData.interval"
:min="1"
:max="59"
addon-after="分钟"
style="width: 100%"
/>
</a-form-item>
<template v-if="formData.freqType === 'hour'">
<a-form-item label="间隔">
<a-input-number
v-model:value="formData.interval"
:min="1"
:max="23"
addon-after="小时"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="第几分钟">
<a-input-number
v-model:value="formData.minute"
:min="0"
:max="59"
addon-after="分"
style="width: 100%"
/>
</a-form-item>
</template>
<a-form-item v-if="formData.freqType === 'week'" label="星期">
<a-checkbox-group v-model:value="formData.weekdays" :options="weekdayOptions" />
</a-form-item>
<a-form-item v-if="formData.freqType === 'month'" label="日期">
<a-select
v-model:value="formData.days"
mode="multiple"
:options="dayOptions"
:max-tag-count="6"
placeholder="请选择每月的执行日期"
style="width: 100%"
/>
</a-form-item>
<a-form-item v-if="hasTimeList" label="执行时间">
<div class="time-list">
<div v-for="(_, index) in formData.times" :key="index" class="time-row">
<a-time-picker v-model:value="formData.times[index]" format="HH:mm" :allow-clear="false" />
<a-button
type="text"
danger
:disabled="formData.times.length <= 1"
@click="removeTime(index)"
>
<template #icon><MinusCircleOutlined /></template>
</a-button>
</div>
<a-button type="dashed" block :disabled="formData.times.length >= 10" @click="addTime">
<template #icon><PlusOutlined /></template>
添加时间
</a-button>
</div>
</a-form-item>
<a-form-item v-if="formData.freqType === 'custom'" label="cron 表达式">
<a-input v-model:value="formData.expression" placeholder="如 0 3 * * *(分 时 日 月 周)" :maxlength="64" />
</a-form-item>
<a-form-item label="执行预览">
<a-spin :spinning="previewLoading" size="small">
<div class="freq-preview">
<a-alert v-if="preview.error" type="warning" :message="preview.error" show-icon />
<template v-else>
<div class="preview-line">
<span class="preview-label">表达式</span>
<code>{{ preview.cronExpression || '-' }}</code>
</div>
<div class="preview-line">
<span class="preview-label">说明</span>
<span>{{ preview.description || '-' }}</span>
</div>
<div class="preview-line">
<span class="preview-label">下次执行</span>
<span v-if="!preview.nextRuns.length">-</span>
<ol v-else class="next-list">
<li v-for="(time, index) in preview.nextRuns" :key="index">{{ formatDateTime(time) }}</li>
</ol>
</div>
</template>
</div>
</a-spin>
</a-form-item>
<a-divider v-if="paramFields.length" orientation="left" class="form-divider">任务参数</a-divider>
<a-form-item
v-for="field in paramFields"
:key="field.key"
@@ -149,6 +256,9 @@
:placeholder="field.placeholder || `请输入${field.label}`"
/>
</a-form-item>
<a-divider orientation="left" class="form-divider">失败重试与备注</a-divider>
<a-form-item label="重试次数" name="retryTimes">
<a-input-number v-model:value="formData.retryTimes" :min="0" :max="5" style="width: 100%" />
</a-form-item>
@@ -228,7 +338,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
SearchOutlined,
@@ -237,9 +347,18 @@ import {
PlayCircleOutlined,
ProfileOutlined,
DeleteOutlined,
PlusOutlined,
MinusCircleOutlined,
} from '@ant-design/icons-vue'
import dayjs from 'dayjs'
import { jobApi, type JobItem, type JobParamField, type JobLogItem } from '@/api/job'
import dayjs, { type Dayjs } from 'dayjs'
import {
jobApi,
type JobItem,
type JobParamField,
type JobLogItem,
type FreqType,
type FreqConfig,
} from '@/api/job'
import { useTableScroll } from '@/composables/useTableScroll'
const { wrapperRef, tableHeight } = useTableScroll()
@@ -260,7 +379,7 @@ const queryParams = reactive({
const columns = [
{ title: '任务名称', dataIndex: 'jobName', key: 'jobName', width: 180 },
{ title: '任务键', dataIndex: 'jobKey', key: 'jobKey', width: 150, ellipsis: true },
{ title: 'cron 表达式', dataIndex: 'cronExpression', key: 'cronExpression', width: 150 },
{ title: '执行频率', key: 'frequency', width: 200 },
{ title: '参数', dataIndex: 'params', key: 'params', width: 180, ellipsis: true },
{ title: '状态', key: 'status', width: 100, align: 'center' as const },
{ title: '上次执行时间', dataIndex: 'lastRunAt', key: 'lastRunAt', width: 170 },
@@ -286,24 +405,174 @@ const currentJob = ref<JobItem | null>(null)
const paramFields = ref<JobParamField[]>([])
const formRef = ref()
/** HH:mm 文本 → Dayjs(TimePicker 取值),不依赖 dayjs 自定义解析插件 */
function toTimeValue(text: string): Dayjs {
const [hour, minute] = text.split(':')
return dayjs().hour(Number(hour)).minute(Number(minute)).second(0).millisecond(0)
}
/** 人性化频率表单:字段按 freqType 取用,提交时由 buildFreqConfig 组装成频率配置 */
const formData = reactive({
cronExpression: '',
freqType: 'day' as FreqType,
interval: 5,
minute: 0,
times: [toTimeValue('08:00')] as Dayjs[],
weekdays: [1] as number[],
days: [1] as number[],
expression: '',
params: {} as Record<string, any>,
retryTimes: 0,
retryInterval: 60,
remark: '',
})
const freqTypeOptions: { value: FreqType; label: string }[] = [
{ value: 'minute', label: '每 N 分钟' },
{ value: 'hour', label: '每 N 小时' },
{ value: 'day', label: '每天' },
{ value: 'week', label: '每周' },
{ value: 'month', label: '每月' },
{ value: 'custom', label: '自定义(cron 表达式)' },
]
const weekdayOptions = [
{ value: 1, label: '周一' },
{ value: 2, label: '周二' },
{ value: 3, label: '周三' },
{ value: 4, label: '周四' },
{ value: 5, label: '周五' },
{ value: 6, label: '周六' },
{ value: 0, label: '周日' },
]
const dayOptions = Array.from({ length: 31 }, (_, index) => ({
value: index + 1,
label: `${index + 1} 日`,
}))
/** day/week/month 需要执行时间点列表 */
const hasTimeList = computed(() => ['day', 'week', 'month'].includes(formData.freqType))
/** 执行预览状态(由后端计算 cron / 中文描述 / 未来执行时间) */
const previewLoading = ref(false)
const preview = reactive({
valid: true,
error: '',
cronExpression: '',
description: '',
nextRuns: [] as string[],
})
const formRules = {
cronExpression: [
{ required: true, message: '请输入 cron 表达式', trigger: 'blur' },
{ max: 64, message: 'cron 表达式长度不能超过 64', trigger: 'blur' },
],
retryTimes: [{ required: true, type: 'number' as const, message: '请输入重试次数', trigger: 'change' }],
retryInterval: [{ required: true, type: 'number' as const, message: '请输入重试间隔', trigger: 'change' }],
remark: [{ max: 255, message: '备注长度不能超过 255', trigger: 'blur' }],
}
/** 表单 → 频率配置(提交给后端的结构) */
function buildFreqConfig(): FreqConfig {
switch (formData.freqType) {
case 'minute':
return { interval: formData.interval }
case 'hour':
return { interval: formData.interval, minute: formData.minute }
case 'day':
return { times: timeTexts() }
case 'week':
return { weekdays: [...formData.weekdays].sort((a, b) => a - b), times: timeTexts() }
case 'month':
return { days: [...formData.days].sort((a, b) => a - b), times: timeTexts() }
default:
return { expression: formData.expression.trim() }
}
}
function timeTexts(): string[] {
return formData.times.map(item => item.format('HH:mm'))
}
/** 频率配置 → 表单(编辑回填) */
function applyFreqConfig(job: JobItem) {
const config: FreqConfig = job.freqConfig || {}
const type: FreqType = job.freqType || 'custom'
formData.freqType = type
formData.interval = config.interval ?? (type === 'hour' ? 1 : 5)
formData.minute = config.minute ?? 0
formData.times = (config.times?.length ? config.times : ['08:00']).map(item => toTimeValue(item))
formData.weekdays = config.weekdays?.length ? [...config.weekdays] : [1]
formData.days = config.days?.length ? [...config.days] : [1]
formData.expression = config.expression || job.cronExpression || ''
}
/** 后端校验之外的本地必填提示,避免空表单直接提交 */
function validateFreqConfig(): string {
if (hasTimeList.value && !formData.times.length) return '请至少添加 1 个执行时间'
if (formData.freqType === 'week' && !formData.weekdays.length) return '请至少选择 1 个星期'
if (formData.freqType === 'month' && !formData.days.length) return '请至少选择 1 个日期'
if (formData.freqType === 'custom' && !formData.expression.trim()) return '请输入 cron 表达式'
return ''
}
function addTime() {
if (formData.times.length >= 10) return
formData.times.push(toTimeValue('12:00'))
}
function removeTime(index: number) {
if (formData.times.length <= 1) return
formData.times.splice(index, 1)
}
let previewTimer: ReturnType<typeof setTimeout> | undefined
/** 已预览过(且成功过)的频率签名,避免打开弹窗时重复请求 */
let previewSignature = ''
async function fetchPreview() {
if (!editVisible.value) return
const signature = JSON.stringify({ type: formData.freqType, config: buildFreqConfig() })
if (signature === previewSignature) return
previewLoading.value = true
try {
const res: any = await jobApi.previewCron({
freqType: formData.freqType,
freqConfig: buildFreqConfig(),
})
if (res.code === 200) {
preview.valid = !!res.data.valid
preview.error = res.data.error || ''
preview.cronExpression = res.data.cronExpression || ''
preview.description = res.data.description || ''
preview.nextRuns = res.data.nextRuns || []
previewSignature = signature
}
} catch (error) {
// 预览失败不打断编辑,保存时后端仍会兜底校验
previewSignature = ''
} finally {
previewLoading.value = false
}
}
/** 频率任一字段变化 → 防抖后重新预览 */
watch(
() => JSON.stringify({ type: formData.freqType, config: buildFreqConfig() }),
() => {
clearTimeout(previewTimer)
previewTimer = setTimeout(fetchPreview, 300)
}
)
function resetPreview() {
preview.valid = true
preview.error = ''
preview.cronExpression = ''
preview.description = ''
preview.nextRuns = []
}
const logVisible = ref(false)
const logLoading = ref(false)
const logList = ref<JobLogItem[]>([])
@@ -441,13 +710,16 @@ async function handleStatusChange(record: JobItem, checked: boolean) {
function handleEdit(record: JobItem) {
currentJob.value = record
paramFields.value = record.paramFields || []
formData.cronExpression = record.cronExpression || ''
applyFreqConfig(record)
formData.params = { ...(record.params || {}) }
formData.retryTimes = record.retryTimes ?? 0
formData.retryInterval = record.retryInterval ?? 60
formData.remark = record.remark || ''
resetPreview()
previewSignature = ''
editVisible.value = true
nextTick(() => formRef.value?.clearValidate())
fetchPreview()
}
function handleCancelEdit() {
@@ -462,10 +734,22 @@ async function handleSave() {
return
}
const freqError = validateFreqConfig()
if (freqError) {
message.warning(freqError)
return
}
if (!preview.valid) {
message.warning(preview.error || '频率配置不合法')
return
}
saving.value = true
try {
const res: any = await jobApi.updateJob(currentJob.value.id, {
cronExpression: formData.cronExpression,
freqType: formData.freqType,
freqConfig: buildFreqConfig(),
params: { ...formData.params },
retryTimes: formData.retryTimes,
retryInterval: formData.retryInterval,
@@ -572,6 +856,10 @@ function handleClearLogs() {
onMounted(() => {
fetchJobList()
})
onBeforeUnmount(() => {
clearTimeout(previewTimer)
})
</script>
<style scoped lang="less">
@@ -635,6 +923,17 @@ onMounted(() => {
margin-bottom: 16px;
}
.freq-cell {
display: flex;
flex-direction: column;
line-height: 20px;
.freq-cron {
font-size: 12px;
color: #8c8c8c;
}
}
.error-stack {
margin: 0;
padding: 8px 12px;
@@ -646,4 +945,51 @@ onMounted(() => {
word-break: break-all;
}
}
/* 弹窗内容被 teleport 到 body,样式不能嵌套在 .job-management 下 */
.form-divider {
margin: 4px 0 16px;
font-size: 13px;
color: #595959;
}
.time-list {
width: 100%;
.time-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
}
.freq-preview {
padding: 8px 12px;
background: #fafafa;
border-radius: 4px;
font-size: 13px;
line-height: 22px;
.preview-line {
display: flex;
align-items: flex-start;
gap: 8px;
}
.preview-label {
flex-shrink: 0;
width: 56px;
color: #8c8c8c;
}
code {
color: #c41d7f;
}
.next-list {
margin: 0;
padding-left: 18px;
}
}
</style>
+65 -44
View File
@@ -386,6 +386,47 @@ COMMENT ON COLUMN customer_room.created_at IS '创建时间';
CREATE INDEX idx_room_customer ON customer_room (customer_id);
CREATE INDEX idx_room_building ON customer_room (building_id);
-- 12.1 客户用电回路表(空间拓扑末端节点:建筑 → 楼层 → 房间 → 回路)
DROP TABLE IF EXISTS customer_circuit CASCADE;
CREATE TABLE customer_circuit (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customer(id),
building_id BIGINT NOT NULL REFERENCES customer_building(id),
room_id BIGINT REFERENCES customer_room(id),
floor_no VARCHAR(16),
circuit_code VARCHAR(64) NOT NULL UNIQUE,
circuit_name VARCHAR(128) NOT NULL,
loop_spec VARCHAR(128),
load_type VARCHAR(32),
rated_voltage VARCHAR(32),
rated_current VARCHAR(32),
rated_power NUMERIC(10,2),
status SMALLINT NOT NULL DEFAULT 1,
remark TEXT,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE customer_circuit IS '客户用电回路表(空间拓扑末端节点)';
COMMENT ON COLUMN customer_circuit.id IS '自增主键';
COMMENT ON COLUMN customer_circuit.customer_id IS '客户 ID(→ customer)';
COMMENT ON COLUMN customer_circuit.building_id IS '所属建筑 ID(→ customer_building)';
COMMENT ON COLUMN customer_circuit.room_id IS '所属房间 ID(→ customer_room,可空)';
COMMENT ON COLUMN customer_circuit.floor_no IS '所在楼层';
COMMENT ON COLUMN customer_circuit.circuit_code IS '回路编号(全局唯一)';
COMMENT ON COLUMN customer_circuit.circuit_name IS '回路名称';
COMMENT ON COLUMN customer_circuit.loop_spec IS '回路规格';
COMMENT ON COLUMN customer_circuit.load_type IS '负荷类型';
COMMENT ON COLUMN customer_circuit.rated_voltage IS '额定电压(配置值)';
COMMENT ON COLUMN customer_circuit.rated_current IS '额定电流(配置值)';
COMMENT ON COLUMN customer_circuit.rated_power IS '额定功率 kW(配置值)';
COMMENT ON COLUMN customer_circuit.status IS '状态:1 正常 / 0 停用';
COMMENT ON COLUMN customer_circuit.remark IS '备注';
COMMENT ON COLUMN customer_circuit.sort_order IS '排序值';
COMMENT ON COLUMN customer_circuit.created_at IS '创建时间';
CREATE INDEX idx_circuit_customer ON customer_circuit (customer_id);
CREATE INDEX idx_circuit_building ON customer_circuit (building_id);
CREATE INDEX idx_circuit_room ON customer_circuit (room_id);
-- 13. 客户设备信息表(配电间、配电井、配电箱)
DROP TABLE IF EXISTS customer_equipment CASCADE;
CREATE TABLE customer_equipment (
@@ -707,56 +748,36 @@ CREATE INDEX idx_survey_plan_start ON survey_order (plan_start);
CREATE TRIGGER trg_survey_order_updated_at BEFORE UPDATE ON survey_order
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
-- 21.1 勘察 SOP 模板检查项表
DROP TABLE IF EXISTS survey_sop_item CASCADE;
CREATE TABLE survey_sop_item (
id BIGSERIAL PRIMARY KEY,
template_code VARCHAR(64) NOT NULL DEFAULT 'standard_dist',
sort_order INTEGER NOT NULL DEFAULT 0,
item_name VARCHAR(128) NOT NULL,
item_desc VARCHAR(255),
receipt_type VARCHAR(16) NOT NULL DEFAULT 'photo',
receipt_require VARCHAR(64),
required BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE survey_sop_item IS '勘察 SOP 模板检查项表';
COMMENT ON COLUMN survey_sop_item.id IS '自增主键';
COMMENT ON COLUMN survey_sop_item.template_code IS 'SOP模板编码:standard_dist标准配电改造模板';
COMMENT ON COLUMN survey_sop_item.sort_order IS '检查项序号';
COMMENT ON COLUMN survey_sop_item.item_name IS '检查项名称';
COMMENT ON COLUMN survey_sop_item.item_desc IS '检查项说明';
COMMENT ON COLUMN survey_sop_item.receipt_type IS '回执形式:photo现场照片 form清单表单 meter测仪截图';
COMMENT ON COLUMN survey_sop_item.receipt_require IS '回执指标要求,如「照片 ≥ 2 张」';
COMMENT ON COLUMN survey_sop_item.required IS '是否必填';
COMMENT ON COLUMN survey_sop_item.created_at IS '创建时间';
CREATE UNIQUE INDEX uk_sop_template_order ON survey_sop_item (template_code, sort_order);
-- 21.2 勘察 SOP 工单回执表
DROP TABLE IF EXISTS survey_sop_result CASCADE;
CREATE TABLE survey_sop_result (
-- 21.1 勘察工单明细表(建筑/楼层/房间 + 现场照片)
DROP TABLE IF EXISTS survey_detail CASCADE;
CREATE TABLE survey_detail (
id BIGSERIAL PRIMARY KEY,
survey_order_id BIGINT NOT NULL REFERENCES survey_order(id) ON DELETE CASCADE,
sop_item_id BIGINT NOT NULL REFERENCES survey_sop_item(id),
checked BOOLEAN NOT NULL DEFAULT FALSE,
receipt_value VARCHAR(1024),
building_id BIGINT,
building_name VARCHAR(128),
floor_no VARCHAR(16),
room_id BIGINT,
room_name VARCHAR(128),
photos TEXT,
remark VARCHAR(255),
checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE survey_sop_result IS '勘察 SOP 工单回执表';
COMMENT ON COLUMN survey_sop_result.id IS '自增主键';
COMMENT ON COLUMN survey_sop_result.survey_order_id IS '勘察工单ID(→ survey_order)';
COMMENT ON COLUMN survey_sop_result.sop_item_id IS 'SOP检查项ID(→ survey_sop_item)';
COMMENT ON COLUMN survey_sop_result.checked IS '是否已完成回执';
COMMENT ON COLUMN survey_sop_result.receipt_value IS '回执内容(照片对象key数组/表单值/截图key)';
COMMENT ON COLUMN survey_sop_result.remark IS '回执备注';
COMMENT ON COLUMN survey_sop_result.checked_at IS '回执时间';
COMMENT ON COLUMN survey_sop_result.created_at IS '创建时间';
COMMENT ON COLUMN survey_sop_result.updated_at IS '更新时间';
CREATE UNIQUE INDEX uk_sop_result_order_item ON survey_sop_result (survey_order_id, sop_item_id);
CREATE INDEX idx_sop_result_order ON survey_sop_result (survey_order_id);
COMMENT ON TABLE survey_detail IS '勘察工单明细表(建筑/楼层/房间 + 现场照片)';
COMMENT ON COLUMN survey_detail.id IS '自增主键';
COMMENT ON COLUMN survey_detail.survey_order_id IS '勘察工单ID(→ survey_order)';
COMMENT ON COLUMN survey_detail.building_id IS '建筑ID(→ customer_building)';
COMMENT ON COLUMN survey_detail.building_name IS '建筑名称(冗余,客户拓扑变更后仍可读)';
COMMENT ON COLUMN survey_detail.floor_no IS '所在楼层:正数为地上、负数为地下,如 1 / -1';
COMMENT ON COLUMN survey_detail.room_id IS '房间ID(→ customer_room,可空)';
COMMENT ON COLUMN survey_detail.room_name IS '房间名称(冗余,可空)';
COMMENT ON COLUMN survey_detail.photos IS '现场照片对象key(JSON数组字符串)';
COMMENT ON COLUMN survey_detail.remark IS '备注';
COMMENT ON COLUMN survey_detail.created_at IS '创建时间';
COMMENT ON COLUMN survey_detail.updated_at IS '更新时间';
CREATE INDEX idx_survey_detail_order ON survey_detail (survey_order_id);
CREATE TRIGGER trg_survey_detail_updated_at BEFORE UPDATE ON survey_detail
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();
-- 22. 安装派单表
DROP TABLE IF EXISTS install_order CASCADE;
+71 -36
View File
@@ -16,7 +16,7 @@
-- - 显式重置序列,确保 id 不漂移
--
-- 内容:
-- 1. 初始角色(ADMIN / SALESMAN / WORKER / CUSTOMER)
-- 1. 初始角色(ADMIN / SALESMAN)
-- 2. 初始菜单(顶级 6 + 系统管理子菜单 6 + 运营派单子菜单 13 等,共 50 条)
-- 3. 初始字典(客户/设备/派单/支付等业务字典)
-- 4. 管理员账户(admin / admin123456)
@@ -26,21 +26,24 @@
BEGIN;
-- ------------------------------------------------------------
-- 1. 初始角色(显式指定 id:1=ADMIN 2=SALESMAN 3=WORKER 4=CUSTOMER)
-- 1. 初始角色(显式指定 id:1=ADMIN 2=SALESMAN)
-- 后台仅存在系统用户(sys_user)角色;电工(worker 表)与客户(customer_account 表)
-- 各自独立账号体系,不作为后台角色出现。
-- ------------------------------------------------------------
INSERT INTO sys_role (id, role_code, role_name, description, sort_order) VALUES
(1, 'ADMIN', '运营管理员', '全部菜单权限,管理所有客户', 1),
(2, 'SALESMAN', '业务员', '客户/项目/方案/合同/回访', 2),
(3, 'WORKER', '共享电工', 'APP 接单处理,无后台菜单', 3),
(4, 'CUSTOMER', '客户', '小程序查看用电/报警/报修', 4)
(2, 'SALESMAN', '业务员', '客户/项目/方案/合同/回访', 2)
ON CONFLICT (id) DO UPDATE SET
role_code = EXCLUDED.role_code,
role_name = EXCLUDED.role_name,
description = EXCLUDED.description,
sort_order = EXCLUDED.sort_order;
-- 清理历史遗留的 APP 端角色(电工/客户不参与后台角色体系)
DELETE FROM sys_role WHERE role_code IN ('WORKER', 'CUSTOMER');
-- 重置 sys_role 自增序列
SELECT setval('sys_role_id_seq', 4, true);
SELECT setval('sys_role_id_seq', 2, true);
-- ------------------------------------------------------------
-- 2. 初始菜单(顶级菜单,显式指定 id:1-6,系统管理/运营派单为目录类型 menu_type=1,其余为 menu_type=2)
@@ -106,18 +109,19 @@ ON CONFLICT (id) DO UPDATE SET
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- 初始菜单(项目域子菜单 + 客户扩展子菜单,id:24-31 + 空间拓扑 32 + 安装派单 33)
-- 项目域:客户拜访(customer:visit) / 项目勘察(project:survey) / 项目方案(project:plan)
-- 合同管理(project:contract) / 安装派单(project:install)
-- 初始菜单(项目域子菜单 + 客户扩展子菜单,id:24-31 + 空间拓扑 32)
-- 项目域:客户拜访(customer:visit) / 项目方案(project:plan)
-- 合同管理(project:contract)
-- 项目验收(project:acceptance) / 回访记录(project:visit)
-- 客户域:空间拓扑与用电架构(customer:topology)
-- 注:客户拜访归属“项目管理”,但仍沿用 /customer/visit 路径与 customer:visit 权限码
-- 注:项目勘察(27)/项目安装(33) 已迁至运营派单 parent_id=3(见下方运营派单块注释)
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
(24, 2, '客户拜访', '/customer/visit', 'customer/visit/index', 'xt-jihuabaifang', 1, 2, 'customer:visit', 1),
(27, 2, '项目勘察', '/project/survey', 'project/survey/index', 'xt-xiangmukancha', 2, 2, 'project:survey', 1),
(27, 3, '项目勘察', '/dispatch/survey', 'dispatch/survey/index', 'xt-xiangmukancha', 2, 2, 'project:survey', 1),
(28, 2, '项目方案', '/project/plan', 'project/plan/index', 'xt-xiangmufangan', 3, 2, 'project:plan', 1),
(29, 2, '合同管理', '/project/contract', 'project/contract/index', 'FileDoneOutlined', 4, 2, 'project:contract', 1),
(33, 2, '项目安装', '/project/install', 'project/install/index', 'ToolOutlined', 5, 2, 'project:install', 1),
(33, 3, '项目安装', '/dispatch/install', 'dispatch/install/index', 'ToolOutlined', 3, 2, 'project:install', 1),
(30, 2, '项目验收', '/project/acceptance', 'project/acceptance/index', 'ThunderboltOutlined', 6, 2, 'project:acceptance',1),
(31, 2, '回访记录', '/project/visit', 'project/visit/index', 'AuditOutlined', 7, 2, 'project:visit', 1),
(32, 1, '空间拓扑与用电架构', '/customer/topology', 'customer/topology/index', 'xt-topology', 5, 2, 'customer:topology',1)
@@ -132,11 +136,13 @@ ON CONFLICT (id) DO UPDATE SET
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- 初始菜单(运营派单子菜单,parent_id=3,id:55-56)
-- 初始菜单(运营派单子菜单,parent_id=3)
-- 运营派单域统一 id 分配(避免历史上 36/37 被两段 INSERT 复用而互相覆盖):
-- 34 运维费定价 / 35 勘察费用(归属项目管理 parent_id=2) / 36 电工登记管理
-- 27 项目勘察 / 33 项目安装(自项目管理 parent_id=2 迁入)
-- 34 运维费定价 / 35 勘察费用(均归属基础数据 parent_id=59) / 36 电工登记管理
-- 37-46 按钮权限
-- 55 报修派单 / 56 维保派单
-- 子菜单排序:36 电工登记管理(1) → 27 项目勘察(2) → 33 项目安装(3) → 55 报修派单(4) → 56 维保派单(5)
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
(55, 3, '报修派单', '/dispatch/repair', 'dispatch/repair/index', 'ToolOutlined', 4, 2, 'dispatch:repair', 1),
(56, 3, '维保派单', '/dispatch/maintenance', 'dispatch/maintenance/index', 'ToolOutlined', 5, 2, 'dispatch:maintenance',1)
@@ -151,21 +157,22 @@ ON CONFLICT (id) DO UPDATE SET
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- 运营派单子菜单(parent_id=3;其中 35 勘察费用及其按钮 40/41/42 已归属项目管理 parent_id=2;id:34/36 为菜单项,37-39/43-46 为按钮权限)
-- 运营派单子菜单(parent_id=3;其中 34 运维费定价及按钮 37-39、35 勘察费用及按钮 40-42 已归属基础数据 parent_id=59;id:36 为菜单项,43-46 为按钮权限)
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
(34, 3, '运维费定价', '/dispatch/price', 'dispatch/price/index', 'MoneyCollectOutlined', 2, 2, 'dispatch:price', 1),
(35, 2, '勘察费用', '/dispatch/cost', 'dispatch/cost/index', 'AccountBookOutlined', 8, 2, 'dispatch:cost', 1),
(34, 59, '运维费定价', '/base/price', 'base/price/index', 'MoneyCollectOutlined', 2, 2, 'dispatch:price', 1),
(35, 59, '勘察费用', '/base/cost', 'base/cost/index', 'AccountBookOutlined', 8, 2, 'dispatch:cost', 1),
(36, 3, '电工登记管理', '/dispatch/worker', 'dispatch/worker/index', 'ToolOutlined', 1, 2, 'dispatch:worker', 1),
(37, 3, '新增价目', NULL, NULL, NULL, 1, 3, 'price:create', 1),
(38, 3, '编辑价目', NULL, NULL, NULL, 2, 3, 'price:update', 1),
(39, 3, '删除价目', NULL, NULL, NULL, 3, 3, 'price:delete', 1),
(40, 2, '新增勘察费用', NULL, NULL, NULL, 9, 3, 'cost:create', 1),
(41, 2, '编辑勘察费用', NULL, NULL, NULL, 10, 3, 'cost:update', 1),
(42, 2, '删除勘察费用', NULL, NULL, NULL, 11, 3, 'cost:delete', 1),
(37, 34, '新增价目', NULL, NULL, NULL, 1, 3, 'price:create', 1),
(38, 34, '编辑价目', NULL, NULL, NULL, 2, 3, 'price:update', 1),
(39, 34, '删除价目', NULL, NULL, NULL, 3, 3, 'price:delete', 1),
(40, 35, '新增勘察费用', NULL, NULL, NULL, 9, 3, 'cost:create', 1),
(41, 35, '编辑勘察费用', NULL, NULL, NULL, 10, 3, 'cost:update', 1),
(42, 35, '删除勘察费用', NULL, NULL, NULL, 11, 3, 'cost:delete', 1),
(43, 3, '新增电工', NULL, NULL, NULL, 7, 3, 'worker:create', 1),
(44, 3, '编辑电工', NULL, NULL, NULL, 8, 3, 'worker:update', 1),
(45, 3, '删除电工', NULL, NULL, NULL, 9, 3, 'worker:delete', 1),
(46, 3, '审核电工', NULL, NULL, NULL, 10, 3, 'worker:audit', 1)
(46, 3, '审核电工', NULL, NULL, NULL, 10, 3, 'worker:audit', 1),
(57, 3, '重置电工密码', NULL, NULL, NULL, 11, 3, 'worker:reset-password', 1)
ON CONFLICT (id) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
menu_name = EXCLUDED.menu_name,
@@ -254,14 +261,13 @@ INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1)
ON CONFLICT (user_id, role_id) DO NOTHING;
-- ------------------------------------------------------------
-- 6. 角色-菜单绑定:ADMIN 拥有全部 46 条菜单,SALESMAN 拥有客户域+项目+统计报表
-- WORKER、CUSTOMER 不绑定任何菜单
-- 6. 角色-菜单绑定:ADMIN 拥有全部菜单,SALESMAN 拥有客户域+项目+统计报表
-- ------------------------------------------------------------
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
(1, 1), (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7), (1, 8), (1, 9), (1, 10), (1, 11),
(1, 12), (1, 13), (1, 14), (1, 15), (1, 16), (1, 17), (1, 18), (1, 19), (1, 20), (1, 21), (1, 22), (1, 23),
(1, 24), (1, 27), (1, 28), (1, 29), (1, 30), (1, 31), (1, 32), (1, 33), (1, 55), (1, 56),
(1, 34), (1, 35), (1, 36), (1, 37), (1, 38), (1, 39), (1, 40), (1, 41), (1, 42), (1, 43), (1, 44), (1, 45), (1, 46),
(1, 34), (1, 35), (1, 36), (1, 37), (1, 38), (1, 39), (1, 40), (1, 41), (1, 42), (1, 43), (1, 44), (1, 45), (1, 46), (1, 57),
(2, 1), (2, 2), (2, 5),
(2, 12), (2, 13), (2, 14), (2, 15), (2, 16), (2, 17), (2, 18), (2, 19), (2, 20), (2, 21), (2, 22), (2, 23),
(2, 24), (2, 27), (2, 28), (2, 29), (2, 30), (2, 31), (2, 32), (2, 33), (2, 55), (2, 56)
@@ -316,21 +322,42 @@ ON CONFLICT (id) DO UPDATE SET
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- 角色-菜单绑定:ADMIN(role_id=1)拥有 58-60
-- 角色-菜单绑定:ADMIN(role_id=1)拥有 58-61
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
(1, 58), (1, 59), (1, 60)
(1, 58), (1, 59), (1, 60), (1, 61)
ON CONFLICT (role_id, menu_id) DO NOTHING;
-- 重置 sys_menu 自增序列(覆盖到 60)
SELECT setval('sys_menu_id_seq', 60, true);
-- 重置 sys_menu 自增序列(覆盖到 61)
SELECT setval('sys_menu_id_seq', 61, true);
-- ------------------------------------------------------------
-- 8a. 设备监控下的智电改造线路台账菜单(id=61,挂设备监控 parent_id=4)
-- ------------------------------------------------------------
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
(61, 4, '智电改造线路台账', '/device/line', 'device/line/index', 'ApartmentOutlined', 2, 2, 'device:list', 1)
ON CONFLICT (id) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
menu_name = EXCLUDED.menu_name,
menu_path = EXCLUDED.menu_path,
component = EXCLUDED.component,
icon = EXCLUDED.icon,
sort_order = EXCLUDED.sort_order,
menu_type = EXCLUDED.menu_type,
permission = EXCLUDED.permission,
visible = EXCLUDED.visible;
-- 角色-菜单绑定:ADMIN(role_id=1)拥有 61
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
(1, 61)
ON CONFLICT (role_id, menu_id) DO NOTHING;
-- ------------------------------------------------------------
-- 9. 定时任务种子(sys_job,按 job_key 幂等)
-- ------------------------------------------------------------
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
('demo_heartbeat', '示例心跳', '*/5 * * * *', '{"message":"heartbeat"}'::jsonb, 0, 60, 1,
INSERT INTO sys_job (job_key, job_name, cron_expression, freq_type, freq_config, params, retry_times, retry_interval, status, remark) VALUES
('demo_heartbeat', '示例心跳', '*/5 * * * *', 'minute', '{"interval":5}'::jsonb, '{"message":"heartbeat"}'::jsonb, 0, 60, 1,
'示例心跳任务:用于验证调度链路,默认每 5 分钟写入一条 info 日志'),
('log_clean', '日志清理', '0 3 * * *', '{"keepDays":90}'::jsonb, 2, 60, 1,
('log_clean', '日志清理', '0 3 * * *', 'day', '{"times":["03:00"]}'::jsonb, '{"keepDays":90}'::jsonb, 2, 60, 1,
'日志清理任务:每日 03:00 清理早于 keepDays 天的操作日志与任务日志,默认保留 90 天')
ON CONFLICT (job_key) DO NOTHING;
@@ -359,16 +386,24 @@ INSERT INTO sys_role_menu (role_id, menu_id) VALUES
ON CONFLICT (role_id, menu_id) DO NOTHING;
-- 重置 sys_menu 自增序列
SELECT setval('sys_menu_id_seq', 56, true);
SELECT setval('sys_menu_id_seq', 57, true);
-- ------------------------------------------------------------
-- 10. 通知规则扫描任务种子(sys_job,按 job_key 幂等)
-- ------------------------------------------------------------
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
('notice_scan', '通知规则扫描', '0 0 8 * * *', '{}'::jsonb, 1, 300, 1,
INSERT INTO sys_job (job_key, job_name, cron_expression, freq_type, freq_config, params, retry_times, retry_interval, status, remark) VALUES
('notice_scan', '通知规则扫描', '0 8 * * *', 'day', '{"times":["08:00"]}'::jsonb, '{}'::jsonb, 1, 300, 1,
'遍历启用的扫描式通知规则,按模板与接收对象生成站内通知(幂等,默认每日 08:00)')
ON CONFLICT (job_key) DO NOTHING;
-- ------------------------------------------------------------
-- 10a. 智电线路同步任务种子(sys_job,按 job_key 幂等;默认每 10 分钟)
-- ------------------------------------------------------------
INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES
('icc_line_sync', '智电线路同步', '*/10 * * * *', '{}'::jsonb, 1, 300, 1,
'从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert(默认每 10 分钟)')
ON CONFLICT (job_key) DO NOTHING;
-- ------------------------------------------------------------
-- 11. 通知规则种子(sys_notice_rule,按 rule_key 幂等;接收对象可在「通知管理」页调整)
-- ------------------------------------------------------------
@@ -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;
+4
View File
@@ -22,3 +22,7 @@ RUSTFS_BUCKET=xintong
REDIS_HOST=222.243.55.42
REDIS_PORT=26678
REDIS_PASSWORD=
# ICC 平台配置(结算所述智电改造线路数据由 ICC 同步,占位;接入真实平台时填写)
ICC_BASE_URL=
ICC_TOKEN=
@@ -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 { ElectricLineService } from "../service/electric_line.service";
import { IccLineSyncService } from "../service/icc_line_sync.service";
@Controller("/api/electric-lines")
export class ElectricLineController {
@Inject() ctx!: Context;
@Inject() electricLineService!: ElectricLineService;
@Inject() iccLineSyncService!: IccLineSyncService;
@Get("/") async list(@Query() query: any) { return this.electricLineService.list(query, this.ctx); }
@Get("/:id") async detail(@Param("id") id: string) { return this.electricLineService.detail(Number(id), this.ctx); }
@Post("/sync") async sync() {
const result = await this.iccLineSyncService.syncFromIcc(this.ctx);
return { code: 200, message: "线路同步完成", data: result };
}
}
+7 -1
View File
@@ -1,6 +1,6 @@
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
import { JobService } from "../service/job.service";
import { UpdateJobDTO, JobStatusDTO, JobLogQueryDTO } from "../dto/job.dto";
import { UpdateJobDTO, JobStatusDTO, JobLogQueryDTO, CronPreviewDTO } from "../dto/job.dto";
@Controller("/api/jobs")
export class JobController {
@@ -19,6 +19,12 @@ export class JobController {
return this.jobService.clearLogs(parsed);
}
/** 频率预览:人性化配置 → cron / 中文描述 / 未来执行时间 */
@Post("/cron/preview")
async previewCron(@Body() dto: CronPreviewDTO) {
return this.jobService.previewCron(dto.freqType, dto.freqConfig);
}
@Put("/:id")
async update(@Param("id") id: string, @Body() dto: UpdateJobDTO) {
return this.jobService.update(Number(id), dto);
@@ -11,9 +11,6 @@ export class SurveyOrderController {
// 以下静态路径必须声明在 /:id 之前,否则会被动态路由吞掉
@Get("/stats") async stats() { return this.surveyOrderService.stats(this.ctx); }
@Get("/sop-items") async listSopItems(@Query("templateCode") templateCode: string) {
return this.surveyOrderService.listSopItems(templateCode);
}
@Get("/quote") async quote(@Query("loopCount") loopCount: string) { return this.surveyOrderService.quote(loopCount); }
// 批量指派
@@ -23,9 +20,9 @@ export class SurveyOrderController {
@Post("/") async create(@Body() body: any) { return this.surveyOrderService.create(body, this.ctx); }
@Put("/:id") async update(@Param("id") id: string, @Body() body: any) { return this.surveyOrderService.update(Number(id), body, this.ctx); }
// 现场回执:按 SOP 检查项增量录入(照片/截图 key 数组、表单值、备注)
@Put("/:id/sop-results") async saveSopResults(@Param("id") id: string, @Body() body: any) {
return this.surveyOrderService.saveSopResults(Number(id), body, this.ctx);
// 勘察明细:全量替换(新增/编辑/删除统一走此接口)
@Put("/:id/details") async saveDetails(@Param("id") id: string, @Body() body: any) {
return this.surveyOrderService.saveDetails(Number(id), body, this.ctx);
}
// 指派 / 改派共享电工
@@ -23,6 +23,11 @@ export class WorkerController {
}
@Del("/:id") async remove(@Param("id") id: string) { return this.workerService.remove(Number(id)); }
// 重置电工 APP 登录密码(重置为手机号后 6 位)
@Post("/:id/reset-password") async resetPassword(@Param("id") id: string) {
return this.workerService.resetPassword(Number(id));
}
// 资质审核:操作人取自登录态
@Put("/:id/audit") async audit(@Param("id") id: string, @Body() body: WorkerAuditDTO) {
return this.workerService.audit(Number(id), body, this.ctx.state.user?.userId);
+30 -1
View File
@@ -1,7 +1,9 @@
import { Controller, Get, Post, Put, Del, Inject, Body, Param, Query } from "@midwayjs/core";
import { Controller, Get, Post, Put, Del, Inject, Body, Param, Query, Files } from "@midwayjs/core";
import { Context } from "@midwayjs/koa";
import { UploadFileInfo } from "@midwayjs/upload";
import { WorkerAppService } from "../service/worker_app.service";
import { NoticeService } from "../service/notice.service";
import { UploadService } from "../service/upload.service";
import {
WorkerRegisterDTO,
WorkerLoginDTO,
@@ -20,6 +22,24 @@ export class WorkerAppController {
@Inject()
noticeService!: NoticeService;
@Inject()
uploadService!: UploadService;
// 勘察现场照片上传:电工 token 走 /api/worker-app/* 通道,复用对象存储上传能力
@Post("/upload/image")
async uploadImage(@Files() files: UploadFileInfo<string>[]) {
const file = files && files[0];
if (!file) {
return { code: 400, message: "请选择要上传的图片", data: null };
}
try {
const data = await this.uploadService.uploadFile(file);
return { code: 200, message: "上传成功", data };
} catch (err: any) {
return { code: 500, message: `图片上传失败:${err?.message || "对象存储不可用"}`, data: null };
}
}
// 注册(匿名)
@Post("/auth/register")
async register(@Body() body: WorkerRegisterDTO) {
@@ -130,6 +150,15 @@ export class WorkerAppController {
return this.workerAppService.completeOrder(this.currentWorkerId(), source, Number(id));
}
// 勘察明细维护(仅勘察单):全量替换,含建筑/楼层/房间与现场照片
@Put("/orders/:source/:id/details")
async saveDetails(@Param("source") source: string, @Param("id") id: string, @Body() body: any) {
if (source !== "survey") {
return { code: 400, message: "仅勘察订单支持维护明细" };
}
return this.workerAppService.saveSurveyDetails(this.currentWorkerId(), Number(id), body);
}
// 收益汇总
@Get("/earnings/summary")
async earningsSummary() {
+2
View File
@@ -13,6 +13,7 @@ export class CustomerBuildingCreateDTO {
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) basicInfo?: string;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) purpose?: string;
@IsOptional() @IsInt() @Min(0) floorCount?: number;
@IsOptional() @IsInt() @Min(0) floorDownCount?: number;
}
export class CustomerBuildingUpdateDTO {
@@ -21,4 +22,5 @@ export class CustomerBuildingUpdateDTO {
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) basicInfo?: string;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) purpose?: string;
@IsOptional() @IsInt() @Min(0) floorCount?: number;
@IsOptional() @IsInt() @Min(0) floorDownCount?: number;
}
+45
View File
@@ -0,0 +1,45 @@
import { IsNotEmpty, IsOptional, IsString, IsInt, IsNumber, Matches, Min, Max, MaxLength } from "class-validator";
export class CustomerCircuitQueryDTO {
@IsOptional() @IsInt() @Min(1) page?: number = 1;
@IsOptional() @IsInt() @Min(1) @Max(500) pageSize?: number = 10;
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
@IsOptional() @IsInt() customerId?: number;
@IsOptional() @IsInt() buildingId?: number;
@IsOptional() @IsInt() roomId?: number;
@IsOptional() @IsString() @MaxLength(16) floorNo?: string;
}
export class CustomerCircuitCreateDTO {
@IsNotEmpty({ message: '回路编号不能为空' }) @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) circuitCode!: string;
@IsNotEmpty({ message: '所属客户不能为空' }) @IsInt() customerId!: number;
@IsNotEmpty({ message: '所属建筑不能为空' }) @IsInt() buildingId!: number;
@IsNotEmpty({ message: '回路名称不能为空' }) @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) circuitName!: string;
@IsOptional() @IsInt() roomId?: number;
@IsOptional() @IsString() @MaxLength(16) @Matches(/^[^<>]*$/) floorNo?: string;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) loopSpec?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) loadType?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedVoltage?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedCurrent?: string;
@IsOptional() @IsNumber() @Min(0) ratedPower?: number;
@IsOptional() @IsInt() status?: number;
@IsOptional() @IsString() @MaxLength(2000) remark?: string;
@IsOptional() @IsInt() sortOrder?: number;
}
export class CustomerCircuitUpdateDTO {
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) circuitCode?: string;
@IsOptional() @IsInt() customerId?: number;
@IsOptional() @IsInt() buildingId?: number;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) circuitName?: string;
@IsOptional() @IsInt() roomId?: number;
@IsOptional() @IsString() @MaxLength(16) @Matches(/^[^<>]*$/) floorNo?: string;
@IsOptional() @IsString() @MaxLength(128) @Matches(/^[^<>]*$/) loopSpec?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) loadType?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedVoltage?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) ratedCurrent?: string;
@IsOptional() @IsNumber() @Min(0) ratedPower?: number;
@IsOptional() @IsInt() status?: number;
@IsOptional() @IsString() @MaxLength(2000) remark?: string;
@IsOptional() @IsInt() sortOrder?: number;
}
+11
View File
@@ -0,0 +1,11 @@
import { IsOptional, IsString, IsInt, MaxLength, Min, Max } from "class-validator";
export class ElectricLineQueryDTO {
@IsOptional() @IsInt() @Min(1) page?: number = 1;
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
@IsOptional() @IsInt() customerId?: number;
@IsOptional() @IsInt() equipId?: number;
@IsOptional() @IsInt() buildingId?: number;
@IsOptional() @IsInt() @Min(0) @Max(1) status?: number;
}
+20 -3
View File
@@ -1,10 +1,15 @@
import { IsNotEmpty, IsOptional, IsString, IsInt, IsObject, Min, Max, MaxLength } from "class-validator";
/** 频率配置:字段按 freqType 取用,由 util/cron_schedule 统一校验与翻译 */
export class UpdateJobDTO {
@IsString()
@IsNotEmpty({ message: "cron 表达式不能为空" })
@MaxLength(64, { message: "cron 表达式最大 64 字符" })
cronExpression!: string;
@IsNotEmpty({ message: "频率类型不能为空" })
@MaxLength(16, { message: "频率类型非法" })
freqType!: string;
@IsOptional()
@IsObject()
freqConfig?: Record<string, any>;
@IsOptional()
@IsObject()
@@ -26,6 +31,18 @@ export class UpdateJobDTO {
remark?: string;
}
/** 频率预览入参:只传频率配置,cron 与下次执行时间由后端计算 */
export class CronPreviewDTO {
@IsString()
@IsNotEmpty({ message: "频率类型不能为空" })
@MaxLength(16, { message: "频率类型非法" })
freqType!: string;
@IsOptional()
@IsObject()
freqConfig?: Record<string, any>;
}
export class JobStatusDTO {
@IsInt()
@Min(0, { message: "状态值非法" })
+3 -3
View File
@@ -48,8 +48,8 @@ export class SurveyOrderCreateDTO {
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) remark?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) source?: string;
@IsOptional() @IsInt() @Min(0) @Max(1) isDraft?: number;
// 向导选用的 SOP 模板编码,用于生成回执记录(非持久化列)
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) templateCode?: string;
// 勘察明细(建筑/楼层/房间 + 现场照片),非持久化列,仅用于创建时一并写入
@IsOptional() @IsArray() details?: any[];
}
export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
@@ -70,7 +70,7 @@ export class SurveyOrderUpdateDTO extends SurveyOrderCreateDTO {
@IsOptional() @IsString() @MaxLength(2000) @Matches(/^[^<>]*$/) declare remark?: string;
@IsOptional() @IsString() @MaxLength(32) @Matches(/^[^<>]*$/) declare source?: string;
@IsOptional() @IsInt() @Min(0) @Max(1) declare isDraft?: number;
@IsOptional() @IsString() @MaxLength(64) @Matches(/^[^<>]*$/) declare templateCode?: string;
@IsOptional() @IsArray() declare details?: any[];
}
// 指派 / 改派(0待指派→指派,1现场勘察中→改派)
+2
View File
@@ -72,6 +72,8 @@ export class WorkerUpdateDTO {
@IsOptional() @IsInt() @IsIn([0, 1]) status?: number;
@IsOptional() @IsNumber() @Min(0) @Max(5) rating?: number;
@IsOptional() @IsString() @MaxLength(128) certInfo?: string;
// 为尚未开通 APP 账号的电工补开账号(默认密码为手机号后 6 位;已开通时忽略)
@IsOptional() @IsBoolean() withAccount?: boolean;
// 存在时整体覆盖该电工的全部证书
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => WorkerCertDTO) certs?: WorkerCertDTO[];
}
@@ -26,9 +26,12 @@ export default class CustomerBuilding extends Model {
@Column({ type: DataType.STRING(128), comment: "用途" })
purpose!: string;
@Column({ type: DataType.INTEGER, field: "floor_count", comment: "层数" })
@Column({ type: DataType.INTEGER, field: "floor_count", comment: "地上楼层数" })
floorCount!: number;
@Column({ type: DataType.INTEGER, field: "floordown_count", comment: "地下楼层数", defaultValue: 0 })
floorDownCount!: number;
@BelongsTo(() => Customer)
customer!: Customer;
@@ -0,0 +1,78 @@
import { Column, Model, Table, DataType, CreatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import Customer from "./customer.entity";
import CustomerBuilding from "./customer_building.entity";
import CustomerRoom from "./customer_room.entity";
@Table({
tableName: "customer_circuit",
comment: "客户用电回路表",
timestamps: true,
createdAt: "created_at",
updatedAt: false,
indexes: [
{ name: "idx_circuit_customer", fields: ["customer_id"] },
{ name: "idx_circuit_building", fields: ["building_id"] },
{ name: "idx_circuit_room", fields: ["room_id"] }
]
})
export default class CustomerCircuit extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@ForeignKey(() => Customer)
@Column({ type: DataType.BIGINT, allowNull: false, field: "customer_id", comment: "客户ID" })
customerId!: number;
@ForeignKey(() => CustomerBuilding)
@Column({ type: DataType.BIGINT, allowNull: false, field: "building_id", comment: "所属建筑ID" })
buildingId!: number;
@ForeignKey(() => CustomerRoom)
@Column({ type: DataType.BIGINT, field: "room_id", comment: "所属房间ID(可空)" })
roomId!: number;
@Column({ type: DataType.STRING(16), field: "floor_no", comment: "所在楼层" })
floorNo!: string;
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "circuit_code", comment: "回路编号(全局唯一)" })
circuitCode!: string;
@Column({ type: DataType.STRING(128), allowNull: false, field: "circuit_name", comment: "回路名称" })
circuitName!: string;
@Column({ type: DataType.STRING(128), field: "loop_spec", comment: "回路规格" })
loopSpec!: string;
@Column({ type: DataType.STRING(32), field: "load_type", comment: "负荷类型" })
loadType!: string;
@Column({ type: DataType.STRING(32), field: "rated_voltage", comment: "额定电压" })
ratedVoltage!: string;
@Column({ type: DataType.STRING(32), field: "rated_current", comment: "额定电流" })
ratedCurrent!: string;
@Column({ type: DataType.DECIMAL(10, 2), field: "rated_power", comment: "额定功率kW" })
ratedPower!: number;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1正常/0停用" })
status!: number;
@Column({ type: DataType.TEXT, comment: "备注" })
remark!: string;
@Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0, field: "sort_order", comment: "排序值" })
sortOrder!: number;
@BelongsTo(() => Customer)
customer!: Customer;
@BelongsTo(() => CustomerBuilding)
building!: CustomerBuilding;
@BelongsTo(() => CustomerRoom)
room!: CustomerRoom;
@CreatedAt
created_at!: Date;
}
+64
View File
@@ -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;
}
+36
View File
@@ -0,0 +1,36 @@
import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript";
@Table({
tableName: "icc_sync_log",
comment: "ICC 数据同步日志表",
timestamps: false,
indexes: [
{ name: "idx_icc_sync_type", fields: ["sync_type"] },
{ name: "idx_icc_sync_time", fields: ["created_at"] }
]
})
export default class IccSyncLog extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(32), allowNull: false, field: "sync_type", comment: "同步类型:客户/设备/用电/报警" })
syncType!: string;
@Column({ type: DataType.DATE, field: "start_time", comment: "开始时间" })
startTime!: Date;
@Column({ type: DataType.DATE, field: "end_time", comment: "结束时间" })
endTime!: Date;
@Column({ type: DataType.INTEGER, field: "record_count", comment: "同步记录数" })
recordCount!: number;
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1成功 0失败" })
status!: number;
@Column({ type: DataType.TEXT, field: "error_message", comment: "错误信息" })
errorMessage!: string;
@CreatedAt
created_at!: Date;
}
+49
View File
@@ -0,0 +1,49 @@
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey, BelongsTo } from "sequelize-typescript";
import SurveyOrder from "./survey_order.entity";
@Table({
tableName: "survey_detail",
comment: "勘察工单明细表(建筑/楼层/房间 + 现场照片)",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [{ name: "idx_survey_detail_order", fields: ["survey_order_id"] }]
})
export default class SurveyDetail extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@ForeignKey(() => SurveyOrder)
@Column({ type: DataType.BIGINT, allowNull: false, field: "survey_order_id", comment: "勘察工单ID(→ survey_order)" })
surveyOrderId!: number;
@Column({ type: DataType.BIGINT, field: "building_id", comment: "建筑ID(→ customer_building)" })
buildingId!: number;
@Column({ type: DataType.STRING(128), field: "building_name", comment: "建筑名称(冗余,客户拓扑变更后仍可读)" })
buildingName!: string;
@Column({ type: DataType.STRING(16), field: "floor_no", comment: "所在楼层:正数为地上、负数为地下,如 1 / -1" })
floorNo!: string;
@Column({ type: DataType.BIGINT, field: "room_id", comment: "房间ID(→ customer_room,可空)" })
roomId!: number;
@Column({ type: DataType.STRING(128), field: "room_name", comment: "房间名称(冗余,可空)" })
roomName!: string;
@Column({ type: DataType.TEXT, field: "photos", comment: "现场照片对象key(JSON数组字符串)" })
photos!: string;
@Column({ type: DataType.STRING(255), comment: "备注" })
remark!: string;
@BelongsTo(() => SurveyOrder, { foreignKey: "surveyOrderId", as: "surveyOrder" })
surveyOrder!: SurveyOrder;
@CreatedAt
created_at!: Date;
@UpdatedAt
updated_at!: Date;
}
@@ -1,40 +0,0 @@
import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript";
@Table({
tableName: "survey_sop_item",
comment: "勘察 SOP 模板检查项表",
timestamps: true,
createdAt: "created_at",
updatedAt: false,
indexes: [
{ name: "uk_sop_template_order", unique: true, fields: ["template_code", "sort_order"] }
]
})
export default class SurveySopItem extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@Column({ type: DataType.STRING(64), allowNull: false, defaultValue: "standard_dist", field: "template_code", comment: "SOP模板编码:standard_dist标准配电改造模板" })
templateCode!: string;
@Column({ type: DataType.INTEGER, allowNull: false, defaultValue: 0, field: "sort_order", comment: "检查项序号" })
sortOrder!: number;
@Column({ type: DataType.STRING(128), allowNull: false, field: "item_name", comment: "检查项名称" })
itemName!: string;
@Column({ type: DataType.STRING(255), field: "item_desc", comment: "检查项说明" })
itemDesc!: string;
@Column({ type: DataType.STRING(16), allowNull: false, defaultValue: "photo", field: "receipt_type", comment: "回执形式:photo现场照片 form清单表单 meter测仪截图" })
receiptType!: string;
@Column({ type: DataType.STRING(64), field: "receipt_require", comment: "回执指标要求,如「照片 ≥ 2 张」" })
receiptRequire!: string;
@Column({ type: DataType.BOOLEAN, allowNull: false, defaultValue: true, comment: "是否必填" })
required!: boolean;
@CreatedAt
created_at!: Date;
}
@@ -1,45 +0,0 @@
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey } from "sequelize-typescript";
import SurveyOrder from "./survey_order.entity";
import SurveySopItem from "./survey_sop_item.entity";
@Table({
tableName: "survey_sop_result",
comment: "勘察 SOP 工单回执表",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [
{ name: "uk_sop_result_order_item", unique: true, fields: ["survey_order_id", "sop_item_id"] },
{ name: "idx_sop_result_order", fields: ["survey_order_id"] }
]
})
export default class SurveySopResult extends Model {
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
id!: number;
@ForeignKey(() => SurveyOrder)
@Column({ type: DataType.BIGINT, allowNull: false, field: "survey_order_id", comment: "勘察工单ID(→ survey_order)" })
surveyOrderId!: number;
@ForeignKey(() => SurveySopItem)
@Column({ type: DataType.BIGINT, allowNull: false, field: "sop_item_id", comment: "SOP检查项ID(→ survey_sop_item)" })
sopItemId!: number;
@Column({ type: DataType.BOOLEAN, allowNull: false, defaultValue: false, comment: "是否已完成回执" })
checked!: boolean;
@Column({ type: DataType.TEXT, field: "receipt_value", comment: "回执内容(照片对象key的JSON数组字符串/表单文本值/测仪截图key的JSON数组字符串)" })
receiptValue!: string;
@Column({ type: DataType.STRING(255), comment: "回执备注" })
remark!: string;
@Column({ type: DataType.DATE, field: "checked_at", comment: "回执时间" })
checkedAt!: Date;
@CreatedAt
created_at!: Date;
@UpdatedAt
updated_at!: Date;
}
+14
View File
@@ -36,6 +36,20 @@ export default class SysJob extends Model {
})
cronExpression!: string;
@Column({
type: DataType.STRING(16),
field: "freq_type",
comment: "频率类型: minute/hour/day/week/month/custom"
})
freqType!: string;
@Column({
type: DataType.JSONB,
field: "freq_config",
comment: "频率配置(人性化配置表单回填用)"
})
freqConfig!: Record<string, any>;
@Column({
type: DataType.JSONB,
field: "params",
+4
View File
@@ -23,8 +23,12 @@ const PERMISSION_MAP: Record<string, string> = {
"/api/upload": "customer:visit",
"/api/customer-buildings": "customer:building",
"/api/customer-rooms": "customer:room",
// 用电回路接口复用页面级权限码 customer:topology(该菜单已在 sys_menu 登记并分配给角色),
// 因此不再新增按钮级权限码,避免非 ADMIN 角色因权限码未登记而被 403。
"/api/customer-circuits": "customer:topology",
"/api/customer-equipments": "device:list",
"/api/smart-devices": "device:model",
"/api/electric-lines": "device:list",
"/api/survey-orders": "project:survey",
"/api/project-plans": "project:plan",
"/api/contracts": "project:contract",
@@ -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 };
}
}
+144
View File
@@ -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;
});
}
}
+205
View File
@@ -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);
}
}
}
+74 -3
View File
@@ -8,6 +8,10 @@ import SysJobLog from "../entity/sys_job_log.entity";
import { JobSchedulerService } from "./job_scheduler.service";
import { getBuiltinJob } from "../task/job_registry";
import { UpdateJobDTO, JobLogQueryDTO } from "../dto/job.dto";
import { FreqConfig, FreqType, FREQ_TYPES, buildSchedule, describeCron } from "../util/cron_schedule";
/** 频率预览返回的未来执行次数 */
const PREVIEW_RUN_COUNT = 5;
@Provide()
export class JobService {
@@ -20,6 +24,29 @@ export class JobService {
@Inject()
scheduler!: JobSchedulerService;
/**
* 解析任务的频率配置:非法的频率类型或残缺的配置统一降级为「自定义」,
* 保证配置界面每次都能拿到可原样回填、可再次保存的配置。
*/
private resolveFreq(job: SysJob): { freqType: FreqType; freqConfig: FreqConfig; description: string } {
const storedType = FREQ_TYPES.includes(job.freqType as FreqType) ? (job.freqType as FreqType) : "custom";
const schedule = buildSchedule(storedType, job.freqConfig);
if (schedule.valid) {
return {
freqType: storedType,
freqConfig: schedule.freqConfig ?? {},
description: schedule.description ?? describeCron(job.cronExpression)
};
}
return {
freqType: "custom",
freqConfig: { expression: job.cronExpression },
description: describeCron(job.cronExpression)
};
}
/** 任务列表:按任务名称模糊 + 状态筛选,附带注册表信息与下次执行时间 */
async list(query: { page?: any; pageSize?: any; jobName?: string; status?: any }): Promise<any> {
const page = Number(query.page) || 1;
@@ -45,9 +72,13 @@ export class JobService {
const list = rows.map(job => {
const builtin = getBuiltinJob(job.jobKey);
const registered = !!builtin;
const freq = this.resolveFreq(job);
return {
...job.toJSON(),
freqType: freq.freqType,
freqConfig: freq.freqConfig,
cronDescription: freq.description,
description: builtin ? builtin.description : null,
paramFields: builtin ? builtin.paramFields : [],
registered,
@@ -62,7 +93,7 @@ export class JobService {
};
}
/** 更新任务配置:cron 合法性 + 参数白名单/范围校验,白名单之外的 key 直接丢弃 */
/** 更新任务配置:频率配置翻译 cron 并校验合法性 + 参数白名单/范围校验,白名单之外的 key 直接丢弃 */
async update(id: number, dto: UpdateJobDTO): Promise<any> {
const job = await this.jobModel.findByPk(id);
@@ -75,7 +106,14 @@ export class JobService {
return { code: 400, message: "任务实现不存在", data: null };
}
if (!this.scheduler.validateCron(dto.cronExpression)) {
// 频率配置 → cron:非法返回具体错误,合法则同时拿到规范化配置用于落库
const schedule = buildSchedule(dto.freqType, dto.freqConfig);
if (!schedule.valid) {
return { code: 400, message: schedule.error ?? "频率配置不合法", data: null };
}
const cronExpression = schedule.cronExpression ?? "";
if (!this.scheduler.validateCron(cronExpression)) {
return { code: 400, message: "cron 表达式不合法", data: null };
}
@@ -122,7 +160,9 @@ export class JobService {
}
await job.update({
cronExpression: dto.cronExpression,
cronExpression,
freqType: dto.freqType,
freqConfig: schedule.freqConfig ?? {},
params,
retryTimes: dto.retryTimes,
retryInterval: dto.retryInterval,
@@ -134,6 +174,37 @@ export class JobService {
return { code: 200, message: "更新定时任务成功", data: null };
}
/**
* 频率预览:由「人性化频率配置」计算 cron 表达式、中文描述与未来若干次执行时间。
* 配置不完整/表达式非法时仍返回 200,通过 valid/error 让前端就地提示,避免弹窗报错打断填写。
*/
async previewCron(freqType: string, freqConfig?: Record<string, any>): Promise<any> {
const schedule = buildSchedule(freqType, freqConfig);
if (!schedule.valid) {
return {
code: 200,
message: "频率配置不完整",
data: { valid: false, error: schedule.error, cronExpression: null, description: null, nextRuns: [] }
};
}
const cronExpression = schedule.cronExpression ?? "";
const valid = this.scheduler.validateCron(cronExpression);
return {
code: 200,
message: "预览成功",
data: {
valid,
error: valid ? null : "cron 表达式不合法",
cronExpression,
description: schedule.description ?? null,
nextRuns: valid ? this.scheduler.nextRunTimes(cronExpression, PREVIEW_RUN_COUNT) : []
}
};
}
/** 启停任务:更新状态后即时重载调度 */
async changeStatus(id: number, status: number): Promise<any> {
const job = await this.jobModel.findByPk(id);
@@ -8,6 +8,7 @@ import SysJob from "../entity/sys_job.entity";
import SysJobLog from "../entity/sys_job_log.entity";
import { LogService } from "./log.service";
import { NoticeRuleService } from "./notice_rule.service";
import { IccLineSyncService } from "./icc_line_sync.service";
import { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry";
/** 任务执行结果(一次尝试) */
@@ -138,6 +139,21 @@ export class JobSchedulerService {
}
}
/** 计算未来 count 次执行时间(配置界面预览用);表达式非法返回空数组 */
nextRunTimes(cronExpression: string, count: number): Date[] {
if (!this.validateCron(cronExpression)) {
return [];
}
try {
return new CronTime(cronExpression, TIMEZONE)
.sendAt(count)
.map(date => date.toJSDate());
} catch {
return [];
}
}
/** 清理 sys_job_log 中早于 keepDays 天的记录,返回删除条数 */
async cleanJobLogs(keepDays: number): Promise<number> {
const cutoff = dayjs().subtract(keepDays, "day").toDate();
@@ -430,6 +446,12 @@ export class JobSchedulerService {
const ctx = this.app.createAnonymousContext();
const noticeRuleService = (await ctx.requestContext.getAsync(NoticeRuleService)) as NoticeRuleService;
return noticeRuleService.executeScan();
},
syncIccLines: async () => {
const ctx = this.app.createAnonymousContext();
const iccService = (await ctx.requestContext.getAsync(IccLineSyncService)) as IccLineSyncService;
const result = await iccService.syncFromIcc();
return `拉取 ${result.pulled} 条,新增 ${result.inserted},更新 ${result.updated},跳过 ${result.skipped}`;
}
};
}
+69 -103
View File
@@ -6,8 +6,7 @@ import { Context } from "@midwayjs/koa";
import SurveyOrder from "../entity/survey_order.entity";
import Customer from "../entity/customer.entity";
import Worker from "../entity/worker.entity";
import SurveySopItem from "../entity/survey_sop_item.entity";
import SurveySopResult from "../entity/survey_sop_result.entity";
import SurveyDetail from "../entity/survey_detail.entity";
import DispatchPrice from "../entity/dispatch_price.entity";
import SysUser from "../entity/sysUser.entity";
import SysUserRole from "../entity/sysUserRole.entity";
@@ -18,8 +17,6 @@ import { NoticeRuleService } from "./notice_rule.service";
// 勘察工单状态:0待指派 1现场勘察中 2待技术复核 3已归档 4预留 5已取消
const STATUS = { PENDING_ASSIGN: 0, SURVEYING: 1, PENDING_REVIEW: 2, ARCHIVED: 3, RESERVED: 4, CANCELED: 5 };
// 默认 SOP 模板编码
const DEFAULT_TEMPLATE = "standard_dist";
// 默认到场时限(分钟)与缺省报价(dispatch_price 缺失时兜底,不写库)
const DEFAULT_EXPECT_MINUTES = 45;
const DEFAULT_BASE_FEE = 350;
@@ -38,11 +35,8 @@ export class SurveyOrderService {
@InjectRepository(Worker)
workerModel!: Repository<Worker>;
@InjectRepository(SurveySopItem)
sopItemModel!: Repository<SurveySopItem>;
@InjectRepository(SurveySopResult)
sopResultModel!: Repository<SurveySopResult>;
@InjectRepository(SurveyDetail)
detailModel!: Repository<SurveyDetail>;
@InjectRepository(DispatchPrice)
priceModel!: Repository<DispatchPrice>;
@@ -257,13 +251,50 @@ export class SurveyOrderService {
return { baseFee, loopFee, loopCount: count, total };
}
// 按模板批量生成回执记录(checked=false)
private async createSopResults(orderId: number, templateCode: string): Promise<void> {
const items = await this.sopItemModel.findAll({ where: { templateCode: templateCode || DEFAULT_TEMPLATE } });
if (items.length === 0) return;
await this.sopResultModel.bulkCreate(
items.map((item) => ({ surveyOrderId: orderId, sopItemId: item.id, checked: false })) as any
);
// 明细入参清洗:photos 数组转 JSON 字符串,建筑/楼层缺失的行丢弃
private normalizeDetails(input: any): any[] {
if (!Array.isArray(input)) return [];
return input
.map((row) => {
const photos = this.parsePhotos(row?.photos);
const buildingId = Number(row?.buildingId) || null;
const floorNo = row?.floorNo === undefined || row?.floorNo === null ? "" : String(row.floorNo);
const roomId = Number(row?.roomId) || null;
return {
buildingId,
buildingName: row?.buildingName ? String(row.buildingName).slice(0, 128) : null,
floorNo: floorNo.slice(0, 16),
roomId,
roomName: row?.roomName ? String(row.roomName).slice(0, 128) : null,
photos: photos.length ? JSON.stringify(photos) : null,
remark: row?.remark ? String(row.remark).slice(0, 255) : null
};
})
// 建筑与楼层是明细的定位要素,缺失的行直接丢弃
.filter((row) => row.buildingId && row.floorNo);
}
// 全量替换工单明细(管理端与电工端共用;调用方负责权限与状态校验)
async replaceDetails(orderId: number, details: any): Promise<void> {
const rows = this.normalizeDetails(details);
await this.detailModel.destroy({ where: { surveyOrderId: orderId } });
if (rows.length === 0) return;
await this.detailModel.bulkCreate(rows.map((row) => ({ ...row, surveyOrderId: orderId })) as any);
}
// 工单明细 + 照片预签名访问 URL(管理端详情与电工端订单详情共用)
async getDetails(orderId: number): Promise<any[]> {
const results = await this.detailModel.findAll({ where: { surveyOrderId: orderId }, order: [["id", "ASC"]] });
if (results.length === 0) return [];
const rows = results.map((r) => {
const json: any = r.toJSON();
return { ...json, photos: this.parsePhotos(json.photos) };
});
const keys = [...new Set<string>(rows.flatMap((r) => r.photos))];
const urlMap = keys.length ? await this.uploadService.getPresignedUrlMap(keys) : {};
return rows.map((row) => ({ ...row, photoUrls: row.photos.map((k: string) => urlMap[k] || "") }));
}
async list(query: any, ctx: Context): Promise<any> {
@@ -342,13 +373,6 @@ export class SurveyOrderService {
};
}
// SOP 模板检查项
async listSopItems(templateCode?: string): Promise<any> {
const code = templateCode || DEFAULT_TEMPLATE;
const rows = await this.sopItemModel.findAll({ where: { templateCode: code }, order: [["sort_order", "ASC"]] });
return { code: 200, message: "获取勘察SOP检查项成功", data: rows.map((r) => r.toJSON()) };
}
// 报酬试算
async quote(loopCount?: string): Promise<any> {
const data = await this.calcFee(Number(loopCount) || 0);
@@ -362,7 +386,7 @@ export class SurveyOrderService {
const vo = this.toVO(record);
await this.decorate([vo]);
vo.sopResults = await this.getSopResults(id);
vo.details = await this.getDetails(id);
// 现场取证照片:把对象 key 批量换取预签名访问 URL,供详情只读视图直接渲染
const photoKeys: string[] = vo.photos || [];
const urlMap = photoKeys.length ? await this.uploadService.getPresignedUrlMap(photoKeys) : {};
@@ -370,84 +394,17 @@ export class SurveyOrderService {
return { code: 200, message: "获取勘察派单详情成功", data: vo };
}
// 工单回执 + 检查项信息(手动 join,避免额外关联);receiptValue 以 key 数组出参,并补预签名访问 URL
private async getSopResults(orderId: number): Promise<any[]> {
const results = await this.sopResultModel.findAll({ where: { surveyOrderId: orderId }, order: [["id", "ASC"]] });
if (results.length === 0) return [];
const itemIds = [...new Set(results.map((r) => r.sopItemId).filter(Boolean))];
const items = await this.sopItemModel.findAll({ where: { id: { [Op.in]: itemIds } } });
const itemMap = new Map(items.map((i) => [i.id, i]));
const rows = results.map((r) => {
const json: any = r.toJSON();
const item: any = itemMap.get(json.sopItemId);
return {
...json,
receiptValue: this.parsePhotos(json.receiptValue),
itemName: item?.itemName || "",
itemDesc: item?.itemDesc || "",
receiptType: item?.receiptType || "",
receiptRequire: item?.receiptRequire || "",
required: item?.required ?? false
};
});
// photo/meter 类回执是照片 key,批量换取预签名 URL 供前端直接渲染;form 类是文本值,不参与换取
const keys = [...new Set<string>(rows.filter((r) => r.receiptType !== "form").flatMap((r) => r.receiptValue))];
const urlMap = keys.length ? await this.uploadService.getPresignedUrlMap(keys) : {};
return rows.map((row) => ({
...row,
receiptUrls: row.receiptType === "form" ? [] : row.receiptValue.map((k: string) => urlMap[k] || "")
}));
}
// 保存 SOP 工单回执(增量:只更新传入的检查项,未传入项保持原值)
async saveSopResults(id: number, dto: any, ctx: Context): Promise<any> {
// 保存工单明细(勘察明细整体替换:新增/编辑/删除均由前端以全量数组提交)
async saveDetails(id: number, dto: any, ctx: Context): Promise<any> {
const record = await this.surveyModel.findByPk(id);
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
if (!(await this.isOrderVisible(ctx.state.user.userId, record))) return { code: 403, message: "无权操作该工单", data: null };
if (record.status !== STATUS.SURVEYING) return { code: 400, message: "仅现场勘察中的工单可录入回执", data: null };
const incoming: any[] = Array.isArray(dto?.results) ? dto.results : [];
if (incoming.length === 0) return { code: 400, message: "回执内容不能为空", data: null };
// 仅允许更新本工单已生成的回执记录,防止跨工单/跨检查项写入
const existing = await this.sopResultModel.findAll({ where: { surveyOrderId: id } });
const resultMap = new Map(existing.map((r) => [Number(r.sopItemId), r]));
let updated = 0;
const skipped: number[] = [];
for (const item of incoming) {
const sopItemId = Number(item?.sopItemId);
const target = resultMap.get(sopItemId);
if (!target) {
skipped.push(sopItemId);
continue;
}
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++;
if (record.status !== STATUS.PENDING_ASSIGN && record.status !== STATUS.SURVEYING) {
return { code: 400, message: "仅待指派或现场勘察中的工单可维护勘察明细", data: null };
}
return {
code: 200,
message: "保存勘察回执成功",
data: { updated, skipped, sopResults: await this.getSopResults(id) }
};
await this.replaceDetails(id, dto?.details);
return { code: 200, message: "保存勘察明细成功", data: await this.getDetails(id) };
}
async create(data: any, ctx: Context): Promise<any> {
@@ -456,9 +413,8 @@ export class SurveyOrderService {
if (!(await this.isCustomerOwner(userId, data.customerId))) return { code: 403, message: "无权限为该客户创建勘察派单", data: null };
if (!isAdmin) data.salesmanId = userId;
const templateCode = data.templateCode || DEFAULT_TEMPLATE;
const payload = this.normalizePayload(data);
delete payload.templateCode;
delete payload.details;
payload.surveyNo = await this.generateSurveyNo();
if (payload.progressTotal === undefined || payload.progressTotal === null) payload.progressTotal = payload.loopCount ?? 0;
@@ -484,9 +440,13 @@ export class SurveyOrderService {
}
}
await this.createSopResults(created.id, templateCode);
// 创建时若携带勘察明细则一并写入
if (Array.isArray(data.details) && data.details.length) {
await this.replaceDetails(created.id, data.details);
}
const vo = this.toVO(created);
await this.decorate([vo]);
vo.details = await this.getDetails(created.id);
await this.logService.logAction(ctx, "勘察派单", "创建", created.surveyNo, `创建勘察派单 ${created.surveyNo}`);
return { code: 200, message: "创建勘察派单成功", data: vo };
}
@@ -503,13 +463,19 @@ export class SurveyOrderService {
delete data.salesmanId;
}
delete data.surveyNo;
delete data.templateCode;
const details = data.details;
delete data.details;
const payload = this.normalizePayload(data);
await record.update(payload);
// 仅当显式传入 details 时才整体替换明细,避免只改主表字段时误清空
if (Array.isArray(details)) {
await this.replaceDetails(id, details);
}
const fresh = await this.surveyModel.findByPk(id);
const vo = this.toVO(fresh!);
await this.decorate([vo]);
vo.details = await this.getDetails(id);
await this.logService.logAction(ctx, "勘察派单", "更新", record.surveyNo, `更新勘察派单 ${record.surveyNo}`);
return { code: 200, message: "更新勘察派单成功", data: vo };
}
@@ -643,8 +609,8 @@ export class SurveyOrderService {
if (!record) return { code: 404, message: "勘察派单不存在", data: null };
if (!(await this.isCustomerOwner(ctx.state.user.userId, record.customerId))) return { code: 403, message: "无权限删除该勘察派单", data: null };
const surveyNo = record.surveyNo;
// 级联清理回执后删除工单
await this.sopResultModel.destroy({ where: { surveyOrderId: id } });
// 级联清理明细后删除工单
await this.detailModel.destroy({ where: { surveyOrderId: id } });
await record.destroy();
await this.logService.logAction(ctx, "勘察派单", "删除", surveyNo, `删除勘察派单 ${surveyNo}`);
return { code: 200, message: "删除勘察派单成功", data: null };
+23 -4
View File
@@ -104,10 +104,12 @@ export class WorkerService {
};
}
// 剔除敏感字段
// 剔除敏感字段;额外返回是否已开通 APP 登录账号
private cleanWorker(worker: Worker): any {
const data = worker.toJSON ? worker.toJSON() : { ...worker };
const hasAccount = !!data.passwordHash;
delete data.passwordHash;
data.hasAccount = hasAccount;
return data;
}
@@ -171,7 +173,6 @@ export class WorkerService {
if (certStatus) {
const rows = await this.workerModel.findAll({
where,
attributes: { exclude: ["passwordHash"] },
order: [["rating", "DESC"]]
});
const certMap = await this.loadCertMap(rows.map((r) => Number(r.id)));
@@ -195,7 +196,6 @@ export class WorkerService {
// 常规分页:仅对当前页电工批量加载证书
const { count, rows } = await this.workerModel.findAndCountAll({
where,
attributes: { exclude: ["passwordHash"] },
limit: pageSize,
offset: (page - 1) * pageSize,
order: [["rating", "DESC"]],
@@ -240,7 +240,7 @@ export class WorkerService {
// 详情:含完整证书数组
async detail(id: number): Promise<any> {
const worker = await this.workerModel.findByPk(id, { attributes: { exclude: ["passwordHash"] } });
const worker = await this.workerModel.findByPk(id);
if (!worker) {
return { code: 404, message: "电工不存在", data: null };
}
@@ -310,6 +310,10 @@ export class WorkerService {
if (dto.status !== undefined) payload.status = dto.status;
if (dto.rating !== undefined) payload.rating = dto.rating;
if (dto.certInfo !== undefined) payload.certInfo = dto.certInfo;
// 补开 APP 账号:仅对尚未开通(无密码哈希)的电工生效,默认密码为手机号后 6 位
if (dto.withAccount === true && !worker.passwordHash) {
payload.passwordHash = await hashPassword(getDefaultPassword(dto.phone || worker.phone));
}
await worker.update(payload);
@@ -326,6 +330,21 @@ export class WorkerService {
return { code: 200, message: "更新电工成功", data: this.cleanWorker(worker) };
}
// 重置 APP 登录密码:固定重置为手机号后 6 位;未开通账号时一并开通
async resetPassword(id: number): Promise<any> {
const worker = await this.workerModel.findByPk(id);
if (!worker) {
return { code: 404, message: "电工不存在", data: null };
}
if (!worker.phone) {
return { code: 400, message: "该电工未登记手机号,无法重置密码", data: null };
}
await worker.update({ passwordHash: await hashPassword(getDefaultPassword(worker.phone)) });
return { code: 200, message: "重置密码成功", data: this.cleanWorker(worker) };
}
// 删除电工:存在派单记录则拒绝;否则级联删除证书并清理通知
async remove(id: number): Promise<any> {
const worker = await this.workerModel.findByPk(id);
+57 -1
View File
@@ -11,8 +11,11 @@ import RepairDispatch from "../entity/repair_dispatch.entity";
import InstallOrder from "../entity/install_order.entity";
import MaintenanceOrder from "../entity/maintenance_order.entity";
import SurveyOrder from "../entity/survey_order.entity";
import CustomerBuilding from "../entity/customer_building.entity";
import CustomerRoom from "../entity/customer_room.entity";
import Customer from "../entity/customer.entity";
import { TokenUtil } from "../util/token";
import { SurveyOrderService } from "./survey_order.service";
import { hashPassword, comparePassword } from "../util/crypto";
import {
WorkerRegisterDTO,
@@ -69,6 +72,15 @@ export class WorkerAppService {
@InjectRepository(SurveyOrder)
surveyModel!: Repository<SurveyOrder>;
@InjectRepository(CustomerBuilding)
buildingModel!: Repository<CustomerBuilding>;
@InjectRepository(CustomerRoom)
roomModel!: Repository<CustomerRoom>;
@Inject()
surveyOrderService!: SurveyOrderService;
// 接单/完成需要多表原子更新,使用事务保证并发下不重复接单、不重复计数
@InjectDataSource()
sequelize!: Sequelize;
@@ -346,7 +358,51 @@ export class WorkerAppService {
async orderDetail(workerId: number, source: string, id: number): Promise<any> {
const row: any = await this.sourceModel(source).findByPk(id, { include: [this.customerInclude()] });
if (!row) return { code: 404, message: "订单不存在" };
return { code: 200, message: "获取成功", data: this.toOrderView(row.toJSON(), source) };
const data = this.toOrderView(row.toJSON(), source);
// 勘察单追加现场明细(含照片预签名 URL)与客户空间拓扑,供电工现场查看与录入
if (source === "survey") {
const [details, topology] = await Promise.all([
this.surveyOrderService.getDetails(Number(row.id)),
this.customerTopology(Number(row.customerId))
]);
data.details = details;
data.topology = topology;
}
return { code: 200, message: "获取成功", data };
}
// 客户空间拓扑(建筑 + 房间),供勘察现场选择建筑/楼层/房间
private async customerTopology(customerId: number): Promise<any> {
if (!customerId) return { buildings: [], rooms: [] };
const [buildings, rooms] = await Promise.all([
this.buildingModel.findAll({ where: { customerId }, order: [["id", "ASC"]] }),
this.roomModel.findAll({ where: { customerId }, order: [["id", "ASC"]] })
]);
return {
buildings: buildings.map((b) => ({
id: b.id,
buildingName: b.buildingName,
floorCount: Number(b.floorCount) || 0,
floorDownCount: Number(b.floorDownCount) || 0
})),
rooms: rooms.map((r) => ({
id: r.id,
roomName: r.roomName,
buildingId: r.buildingId,
floorNo: r.floorNo || ""
}))
};
}
// 电工维护勘察明细:仅本人已接单且处于「现场勘察中(1)」的勘察单可写入(全量替换)
async saveSurveyDetails(workerId: number, id: number, dto: any): Promise<any> {
const order = await this.surveyModel.findByPk(id);
if (!order) return { code: 404, message: "订单不存在" };
if (Number(order.workerId) !== Number(workerId)) return { code: 403, message: "无权操作该订单" };
if (Number(order.status) !== 1) return { code: 400, message: "仅现场勘察中的订单可维护勘察明细" };
await this.surveyOrderService.replaceDetails(id, dto?.details);
return { code: 200, message: "保存勘察明细成功", data: await this.surveyOrderService.getDetails(id) };
}
// ================= 接单 / 完成 =================
+51
View File
@@ -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
};
}
+17
View File
@@ -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();
}
};
+4 -1
View File
@@ -1,6 +1,7 @@
import { demoHeartbeatJob } from "./demo_heartbeat.task";
import { logCleanJob } from "./log_clean.task";
import { noticeScanJob } from "./notice_scan.task";
import { iccLineSyncJob } from "./icc_line_sync.task";
export type JobParamType = "number" | "string";
@@ -29,6 +30,8 @@ export interface JobRuntime {
cleanJobLogs(keepDays: number): Promise<number>;
/** 执行全部启用的扫描式通知规则,返回本次执行摘要 */
runNoticeRules(): Promise<string>;
/** 从 ICC 平台拉取智电改造线路并按 line_code 幂等 upsert,返回执行摘要 */
syncIccLines(): Promise<string>;
}
export interface JobHandlerContext {
@@ -55,7 +58,7 @@ export interface BuiltinJob {
handler: JobHandler;
}
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob];
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob, noticeScanJob, iccLineSyncJob];
export function getBuiltinJob(jobKey: string): BuiltinJob | undefined {
return BUILTIN_JOBS.find(job => job.jobKey === jobKey);
+512
View File
@@ -0,0 +1,512 @@
/**
* 定时任务频率模型:把「人性化频率配置」翻译成 cron 表达式,并提供中文描述。
*
* 约定:cron_expression 始终是调度的唯一真值;freq_type / freq_config 只服务于
* 配置界面的回填与展示,不做任何调度判断。
*/
/** 频率类型:minute 每 N 分钟 / hour 每 N 小时 / day 每天 / week 每周 / month 每月 / custom 自定义 */
export type FreqType = "minute" | "hour" | "day" | "week" | "month" | "custom";
export const FREQ_TYPES: 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 ScheduleResult {
valid: boolean;
error?: string;
cronExpression?: string;
/** 规范化后的频率配置(去重/排序),用于原样落库保证下次编辑可回填 */
freqConfig?: FreqConfig;
description?: string;
}
/** 执行时间点上限,避免表达式过长 */
const MAX_TIMES = 10;
/** 星期文案(cron 取值 0=周日 ... 6=周六) */
const WEEKDAY_LABELS: Record<number, string> = {
0: "周日",
1: "周一",
2: "周二",
3: "周三",
4: "周四",
5: "周五",
6: "周六"
};
function isIntegerInRange(value: unknown, min: number, max: number): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
}
function isAllField(field: string): boolean {
return field === "*" || field === "?";
}
function isPlainNumber(field: string): boolean {
return /^\d+$/.test(field);
}
/** 解析「星号斜杠 + 数字」的步长写法,非步长返回 null */
function parseStepField(field: string): number | null {
const matched = /^\*\/(\d+)$/.exec(field);
return matched ? Number(matched[1]) : null;
}
/** 解析 HH:mm,非法返回 null */
function parseTimeOfDay(text: unknown): { hour: number; minute: number } | null {
if (typeof text !== "string") {
return null;
}
const matched = /^(\d{1,2}):(\d{2})$/.exec(text.trim());
if (!matched) {
return null;
}
const hour = Number(matched[1]);
const minute = Number(matched[2]);
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
return null;
}
return { hour, minute };
}
function pad2(value: number): string {
return String(value).padStart(2, "0");
}
/** 时间点列表规范化:去重 + 按时间升序 */
function normalizeTimes(times: unknown): { list: string[] } | { error: string } {
if (!Array.isArray(times) || times.length === 0) {
return { error: "请至少选择 1 个执行时间" };
}
if (times.length > MAX_TIMES) {
return { error: `执行时间最多 ${MAX_TIMES} 个` };
}
const minutes = new Set<number>();
for (const item of times) {
const parsed = parseTimeOfDay(item);
if (!parsed) {
return { error: "执行时间格式应为 HH:mm(如 08:30)" };
}
minutes.add(parsed.hour * 60 + parsed.minute);
}
const list = [...minutes]
.sort((a, b) => a - b)
.map(total => `${pad2(Math.floor(total / 60))}:${pad2(total % 60)}`);
return { list };
}
/** 由时间点列表得到 cron 的分钟段与小时段(各自去重升序) */
function timesToCronFields(list: string[]): { minutes: string; hours: string } {
const minutes = new Set<number>();
const hours = new Set<number>();
for (const item of list) {
const [hourText, minuteText] = item.split(":");
hours.add(Number(hourText));
minutes.add(Number(minuteText));
}
return {
minutes: [...minutes].sort((a, b) => a - b).join(","),
hours: [...hours].sort((a, b) => a - b).join(",")
};
}
/** 星期集合规范化:去重 + 升序(cron 段内保持标准升序) */
function normalizeWeekdays(weekdays: unknown): { list: number[] } | { error: string } {
if (!Array.isArray(weekdays) || weekdays.length === 0) {
return { error: "请至少选择 1 个星期" };
}
const set = new Set<number>();
for (const item of weekdays) {
if (!isIntegerInRange(item, 0, 6)) {
return { error: "星期取值非法" };
}
set.add(item);
}
const list = [...set].sort((a, b) => a - b);
return { list };
}
/** 日期集合规范化:去重 + 升序 */
function normalizeDays(days: unknown): { list: number[] } | { error: string } {
if (!Array.isArray(days) || days.length === 0) {
return { error: "请至少选择 1 个日期" };
}
const set = new Set<number>();
for (const item of days) {
if (!isIntegerInRange(item, 1, 31)) {
return { error: "日期取值需为 1-31" };
}
set.add(item);
}
const list = [...set].sort((a, b) => a - b);
return { list };
}
/** 星期显示文案:入参为显示序号(1=周一 ... 7=周日) */
function weekdayLabel(displayDay: number): string {
return WEEKDAY_LABELS[displayDay === 7 ? 0 : displayDay];
}
/** 星期显示顺序:以周一为第 1 天、周日为第 7 天,便于区间折叠与阅读 */
function weekdayDisplayOrder(day: number): number {
return day === 0 ? 7 : day;
}
/** 星期集合 → 文案:连续区间折叠为「X至Y」,其余用「、」连接 */
function formatWeekdays(weekdays: number[]): string {
const ordered = [...new Set(weekdays.map(weekdayDisplayOrder))].sort((a, b) => a - b);
if (!ordered.length) {
return "";
}
const chunks: string[] = [];
let start = ordered[0];
let end = ordered[0];
for (let index = 1; index <= ordered.length; index++) {
const current: number | undefined = ordered[index];
if (current !== undefined && current === end + 1) {
end = current;
continue;
}
chunks.push(start === end ? weekdayLabel(start) : `${weekdayLabel(start)}至${weekdayLabel(end)}`);
if (current === undefined) {
break;
}
start = current;
end = current;
}
return chunks.join("、");
}
/** 由 freqType + freqConfig 翻译为 cron 表达式与中文描述(不含 cron 合法性校验) */
export function buildSchedule(freqType: string, freqConfig?: FreqConfig | null): ScheduleResult {
const cfg: FreqConfig = freqConfig && typeof freqConfig === "object" ? freqConfig : {};
switch (freqType) {
case "minute": {
if (!isIntegerInRange(cfg.interval, 1, 59)) {
return { valid: false, error: "间隔需为 1-59 的整数(分钟)" };
}
const interval = cfg.interval;
return {
valid: true,
cronExpression: interval === 1 ? "* * * * *" : `*/${interval} * * * *`,
freqConfig: { interval },
description: interval === 1 ? "每分钟执行" : `每 ${interval} 分钟执行`
};
}
case "hour": {
if (!isIntegerInRange(cfg.interval, 1, 23)) {
return { valid: false, error: "间隔需为 1-23 的整数(小时)" };
}
if (!isIntegerInRange(cfg.minute, 0, 59)) {
return { valid: false, error: "分钟需为 0-59 的整数" };
}
const interval = cfg.interval;
const minute = cfg.minute;
const hourField = interval === 1 ? "*" : `*/${interval}`;
const minuteText = minute === 0 ? "整点" : `第 ${minute} 分`;
return {
valid: true,
cronExpression: `${minute} ${hourField} * * *`,
freqConfig: { interval, minute },
description: interval === 1 ? `每小时${minuteText}执行` : `每 ${interval} 小时${minuteText}执行`
};
}
case "day": {
const normalized = normalizeTimes(cfg.times);
if ("error" in normalized) {
return { valid: false, error: normalized.error };
}
const fields = timesToCronFields(normalized.list);
return {
valid: true,
cronExpression: `${fields.minutes} ${fields.hours} * * *`,
freqConfig: { times: normalized.list },
description: `每天 ${normalized.list.join("、")} 执行`
};
}
case "week": {
const normalizedDays = normalizeWeekdays(cfg.weekdays);
if ("error" in normalizedDays) {
return { valid: false, error: normalizedDays.error };
}
const normalizedTimes = normalizeTimes(cfg.times);
if ("error" in normalizedTimes) {
return { valid: false, error: normalizedTimes.error };
}
const fields = timesToCronFields(normalizedTimes.list);
return {
valid: true,
cronExpression: `${fields.minutes} ${fields.hours} * * ${normalizedDays.list.join(",")}`,
freqConfig: { weekdays: normalizedDays.list, times: normalizedTimes.list },
description: `每周(${formatWeekdays(normalizedDays.list)})${normalizedTimes.list.join("、")} 执行`
};
}
case "month": {
const normalizedDays = normalizeDays(cfg.days);
if ("error" in normalizedDays) {
return { valid: false, error: normalizedDays.error };
}
const normalizedTimes = normalizeTimes(cfg.times);
if ("error" in normalizedTimes) {
return { valid: false, error: normalizedTimes.error };
}
const fields = timesToCronFields(normalizedTimes.list);
return {
valid: true,
cronExpression: `${fields.minutes} ${fields.hours} ${normalizedDays.list.join(",")} * *`,
freqConfig: { days: normalizedDays.list, times: normalizedTimes.list },
description: `每月 ${normalizedDays.list.join("、")} 日 ${normalizedTimes.list.join("、")} 执行`
};
}
case "custom": {
const expression = typeof cfg.expression === "string" ? cfg.expression.trim() : "";
if (!expression) {
return { valid: false, error: "请输入 cron 表达式" };
}
if (expression.length > 64) {
return { valid: false, error: "cron 表达式长度不能超过 64" };
}
// 表达式合法性由调度器(cron 包)统一校验,这里只做描述
return {
valid: true,
cronExpression: expression,
freqConfig: { expression },
description: describeCron(expression)
};
}
default:
return { valid: false, error: "频率类型非法" };
}
}
/** 解析逗号分隔的分钟/小时段;含区间、步长等复杂写法时返回 null */
function parseSimpleNumberField(field: string, min: number, max: number): number[] | null {
if (isAllField(field)) {
return null;
}
const parts = field.split(",");
const values: number[] = [];
for (const part of parts) {
if (!isPlainNumber(part)) {
return null;
}
const value = Number(part);
if (value < min || value > max) {
return null;
}
values.push(value);
}
return values.length ? [...new Set(values)].sort((a, b) => a - b) : null;
}
/** 由分钟段与小时段还原成 HH:mm 列表(两者均为简单数字列表时) */
function parseTimeList(minuteField: string, hourField: string): string[] | null {
const minutes = parseSimpleNumberField(minuteField, 0, 59);
const hours = parseSimpleNumberField(hourField, 0, 23);
if (!minutes || !hours) {
return null;
}
const list: string[] = [];
for (const hour of hours) {
for (const minute of minutes) {
list.push(`${pad2(hour)}:${pad2(minute)}`);
}
}
return list;
}
/** 解析星期段(cron 取值,0=周日):支持数字、逗号列表、a-b 区间;无法识别返回 null */
function parseWeekdayField(field: string): number[] | null {
const list: number[] = [];
for (const part of field.split(",")) {
const rangeMatched = /^(\d)-(\d)$/.exec(part);
if (rangeMatched) {
const start = Number(rangeMatched[1]);
const end = Number(rangeMatched[2]);
// cron 中 7 等价于周日
if (start > end || start < 0 || end > 7) {
return null;
}
for (let day = start; day <= end; day++) {
list.push(day === 7 ? 0 : day);
}
continue;
}
if (!isPlainNumber(part)) {
return null;
}
const day = Number(part);
if (day < 0 || day > 7) {
return null;
}
list.push(day === 7 ? 0 : day);
}
return [...new Set(list)].sort((a, b) => a - b);
}
/**
* cron 表达式 → 中文描述。
* 只覆盖本系统会生成/常见的写法(通配、简单数字列表、步长、星期区间),
* 无法有把握描述时原样返回表达式,避免给出误导性文案。
*/
export function describeCron(expression: string): string {
if (!expression || typeof expression !== "string") {
return expression;
}
const fields = expression.trim().split(/\s+/);
let minuteField: string;
let hourField: string;
let dayOfMonthField: string;
let monthField: string;
let dayOfWeekField: string;
if (fields.length === 6) {
const [secondField, ...rest] = fields;
// 秒不为 0 的表达式不描述,原样返回
if (secondField !== "0") {
return expression;
}
[minuteField, hourField, dayOfMonthField, monthField, dayOfWeekField] = rest;
} else if (fields.length === 5) {
[minuteField, hourField, dayOfMonthField, monthField, dayOfWeekField] = fields;
} else {
return expression;
}
// 月份限定不描述
if (!isAllField(monthField)) {
return expression;
}
const dayOfMonthFixed = !isAllField(dayOfMonthField);
const dayOfWeekFixed = !isAllField(dayOfWeekField);
// 日与星期同时限定在 cron 中是「或」语义,为避免歧义不做描述
if (dayOfMonthFixed && dayOfWeekFixed) {
return expression;
}
if (dayOfMonthFixed) {
const days = parseSimpleNumberField(dayOfMonthField, 1, 31);
const times = parseTimeList(minuteField, hourField);
if (!days || !times) {
return expression;
}
return `每月 ${days.join("、")} 日 ${times.join("、")} 执行`;
}
if (dayOfWeekFixed) {
const weekdays = parseWeekdayField(dayOfWeekField);
const times = parseTimeList(minuteField, hourField);
if (!weekdays || !times) {
return expression;
}
if (weekdays.length === 7) {
return `每天 ${times.join("、")} 执行`;
}
return `每周(${formatWeekdays(weekdays)})${times.join("、")} 执行`;
}
if (isAllField(minuteField) && isAllField(hourField)) {
return "每分钟执行";
}
const minuteStep = parseStepField(minuteField);
if (minuteStep !== null && isAllField(hourField)) {
return minuteStep === 1 ? "每分钟执行" : `每 ${minuteStep} 分钟执行`;
}
if (isPlainNumber(minuteField) && isAllField(hourField)) {
const minute = Number(minuteField);
return minute === 0 ? "每小时整点执行" : `每小时第 ${minute} 分执行`;
}
const hourStep = parseStepField(hourField);
if (hourStep !== null && isPlainNumber(minuteField)) {
const minute = Number(minuteField);
const minuteText = minute === 0 ? "整点" : `第 ${minute} 分`;
return hourStep === 1 ? `每小时${minuteText}执行` : `每 ${hourStep} 小时${minuteText}执行`;
}
const times = parseTimeList(minuteField, hourField);
if (!times) {
return expression;
}
return `每天 ${times.join("、")} 执行`;
}