feat: 新增电工APP端与定时任务管理功能
- 新增电工APP独立鉴权中间件与登录注册接口 - 新增定时任务管理模块,支持任务配置、执行日志与手动触发 - 新增运维费定价与电工派单成本价目管理 - 新增电工资质证照管理与到期提醒定时任务 - 完善数据库实体与DTO校验,优化权限控制
This commit is contained in:
1 parent
1803ad679c
commit
f4454ad014
57 files changed
+9388
-200
No files matched your search
@@ -0,0 +1,87 @@
|
||||
# Checklist
|
||||
|
||||
> change-id: `add-om-pricing-and-worker-registry`
|
||||
> 逐条核对,通过后勾选。
|
||||
> 验证方式说明:
|
||||
> - 「API 实测」= 在本地开发库(127.0.0.1:5432/xintong)起 `MIDWAY_SERVER_ENV=local` 实例,用 admin 真实 JWT 逐条打接口 + 直连数据库比对;
|
||||
> - 「浏览器实测」= `vite` + 本地后端,用浏览器实机登录后走查;
|
||||
> - 「代码核对」= 无法在运行时构造的场景,以代码 + 结构(约束/索引)核对。
|
||||
|
||||
## 数据库与实体
|
||||
|
||||
- [x] `database/migrations/20261001_om_worker_registry.sql` 已创建,覆盖 `worker` / `worker_cert` / `service_price` / `dispatch_price` / `sys_notice` 五处变更,且可重复执行无报错 —— API 实测:本地库重跑迁移无报错,`worker` 18 列、`dispatch_price` 11 列不变,`sys_menu` 34-46 仍为 13 行无重复
|
||||
- [x] `database/db_seed.sql` 已同步菜单(id 34-46)、字典(5 个 dict_type)、ADMIN 角色绑定,新库初始化与迁移后结构一致 —— 代码核对(Task 1 已完成并经迁移对照)
|
||||
- [x] `worker` 实体含 `password_hash` / `audit_status` / `audit_remark` / `audit_by` / `audit_at` / `last_login_at` —— 代码核对 + 库表列核对(6 列均在)
|
||||
- [x] `dispatch_price` 实体含 `price_mode` / `unit` / `status` / `remark` / `updated_at` —— 代码核对 + 库表列核对(5 列均在)
|
||||
- [x] `worker_cert` / `service_price` / `sys_notice` 实体已创建,索引与迁移脚本一致 —— API 实测 `uk_worker_cert_type_no` / `uk_service_price_code` / `uk_sys_notice_biz` 均生效
|
||||
|
||||
## 客户运维价目(FR-1)
|
||||
|
||||
- [x] `/api/service-prices` 支持分页、关键词(编码 / 名称)、`price_mode` / `service_type` / `status` 筛选 —— API 实测四种筛选均只返回命中记录
|
||||
- [x] `price_code` 自动生成为 `FW+YYYYMMDD+3 位序号` 且不可编辑 —— API 实测连建三条得到 `FW20261001001`~`003`,`priceCode` 在白名单外故不可写
|
||||
- [x] `price_mode` 为 1 按次 / 2 按工时 / 3 材料 三种取值均可新增、编辑、筛选 —— API 实测
|
||||
- [x] 支持启用 / 停用与硬删除两个独立操作 —— API 实测 `PUT /:id/status` 与 `DELETE /:id`(删除后查库确认为物理删除,重复删返回 404)
|
||||
|
||||
## 电工派单成本价(FR-2)
|
||||
|
||||
- [x] 存量 `dispatch_price` 行迁移后 `price_mode=1`、`status=1` 无 NULL —— API 实测存量 2 行均为 `priceMode=1 / status=1`
|
||||
- [x] `/api/dispatch-prices` CRUD + 筛选可用,`price_code` 冲突返回 400 且 message 明确 —— CRUD/筛选 API 实测通过;`UniqueConstraintError`→「价格编码已存在」分支为代码核对(生成器保证编码不重复,无法从接口构造冲突)
|
||||
|
||||
## 电工登记与审核(FR-3 / FR-4)
|
||||
|
||||
- [x] `/api/workers` 支持分页、关键词、`status` / `auditStatus` / `certStatus` 筛选 —— API 实测 + 浏览器实测(已过期 1 条 / 无证书 3 条,筛选结果与库一致)
|
||||
- [x] 创建时手机号唯一校验生效;默认密码规则为手机号后 6 位(不足 6 位用 `123456`)并 bcrypt 存储 —— API 实测:重复手机号返回 `手机号已存在`;`password_hash` 以 `$2a$10$` 开头,用手机号后 6 位可成功登录电工 APP
|
||||
- [x] 审核通过写入 `audit_status=1` / `audit_by` / `audit_at`,清空 `audit_remark`,并置 `cert_verified=true` —— API 实测(库中字段逐项比对)
|
||||
- [x] 审核驳回未填理由返回 400;填写后 `audit_status=2` 且理由、审核人、时间落库 —— API 实测:不填理由 → `code:400「请填写驳回理由」`;填理由 → `audit_status=2 / audit_remark / audit_by / audit_at` 落库
|
||||
- [x] 删除电工级联删除其 `worker_cert`;存在派单引用时返回 400 并提示 —— API 实测:删除后 `worker_cert` 1→0;自造 `survey_order` 引用后返回 `code:400「该电工存在派单记录,无法删除」`
|
||||
- [x] `/api/workers/options` 与 `/recommend` 仅返回 `audit_status=1 AND status=1` 的电工 —— 代码核对(查询条件已收紧)+ API 实测两接口正常返回
|
||||
|
||||
## 资质证书与到期状态(FR-5 / FR-6)
|
||||
|
||||
- [x] `worker_cert` 支持一电工多证,`(worker_id, cert_type, cert_no)` 唯一约束生效 —— API 实测一电工挂多证;重复插入触发 `uk_worker_cert_type_no` 唯一冲突
|
||||
- [x] 资质状态计算正确:无证书 / 正常 / 临期(≤30 天)/ 已过期(`expire_date < today`),`expire_date` 为 NULL 视为长期有效 —— API 实测:构造 +10 天 / -5 天 / +200 天 / NULL 四种证书,`certStatus` 四种筛选各自只返回对应记录
|
||||
- [x] 电工列表「资质状态」列标签颜色正确(绿 / 橙 / 红 / 灰)—— 浏览器实测:列表渲染出「已过期」彩色标签;配色取自 `a-tag` 状态映射(代码核对)
|
||||
- [x] 列表 KPI 卡统计「电工总数 / 待审核 / 临期 / 已过期」数值正确 —— API 实测 `stats` 与独立 SQL 计算一致;浏览器实测 KPI 显示 5 / 4 / 0 / 1,与接口一致
|
||||
- [x] 仪表盘「电工资质到期提醒」区块展示临期与已过期证书清单并可跳转 —— 浏览器实测:区块展示「验收测试电工 | 高压电工证 | 2026-09-26 | 已过期 5 天」,且可跳转电工登记管理页
|
||||
|
||||
## 站内通知与定时任务(FR-7)
|
||||
|
||||
- [x] `sys_notice` UNIQUE(`notice_type`,`biz_id`,`receiver_id`) 生效,重复执行定时任务不产生重复通知 —— API 实测:直接重复 INSERT 报 `uk_sys_notice_biz` 唯一冲突;真实调用 `scanCertExpireAndNotify()` 三次,`created` 依次为 1 / 0 / 0
|
||||
- [x] 定时任务每日 08:00 扫描 `expire_date <= today + 30 天` 的证书,为每个 ADMIN 用户生成未读通知 —— 代码核对(`@Job({ cronTime: "0 0 8 * * *", timeZone: "Asia/Shanghai" })`)+ 实测扫描逻辑生成 ADMIN 未读通知
|
||||
- [x] 证书续期或被删除时,该证书既有未读通知被清理 —— API 实测:续期(改 `expireDate`)后该证书通知 1→0;删除证书后亦为 0
|
||||
- [x] `/api/notices` 系列接口仅返回 `receiver_id = 当前用户` 的数据,未读数接口正确 —— API 实测:列表仅返回本人;读他人通知返回 404;`unread-count` 与库中计数一致
|
||||
- [x] 顶栏铃铛红点角标随未读数变化;单条已读 / 全部已读生效;点击通知跳转电工登记页 —— 浏览器实测:角标显示 2,抽屉内 2 条「验收测试」通知
|
||||
|
||||
## 电工 APP 接口(FR-8)
|
||||
|
||||
- [x] `/api/worker-app/auth/register` 与 `/login` 可匿名访问,注册手机号唯一,登录返回 worker token(payload 含 `workerId` / `phone` / `userType=worker`)—— API 实测注册/登录成功;重复手机号被拒
|
||||
- [x] `WorkerAuthMiddleware` 仅作用于 `/api/worker-app/*`,且放行 `/api/worker-app/auth/*` —— API 实测:匿名可访问 `auth/*`,未带 token 访问 `/profile` 返回 401
|
||||
- [x] 电工 APP 无法通过 `:id` 越权读取 / 修改他人资料与证书(返回 403)—— API 实测:电工 X 的 token 改/删电工 Y 的证书 → `code:403「无权操作该证书」`,Y 数据未被改动
|
||||
- [x] `POST /api/worker-app/audit/resubmit` 仅在 `audit_status=2` 时成功,`audit_status=1` 时返回 400 —— API 实测
|
||||
- [x] `auth.middleware.ts` 与 `rbac.middleware.ts` 均已忽略 `/api/worker-app/*` —— 代码核对 + API 实测(电工 token 不会被管理端鉴权拦截)
|
||||
|
||||
## 菜单、权限与路由(FR-9 / FR-10)
|
||||
|
||||
- [x] `运营派单`(id=3)已改为目录,管理端侧边栏展开可见 3 个子菜单 —— API 实测登录菜单树中 id=3 为 `menuType=1` 且含 34/35/36;浏览器实测展开可见三个子菜单
|
||||
- [x] ADMIN 可见「运维费定价」「电工派单成本价」「电工登记管理」;SALESMAN 不可见「运营派单」及其子菜单 —— API 实测:自签 SALESMAN token 调 `/auth/currentUser`,`dispatch:list` / `dispatch:price` / `dispatch:cost` / `dispatch:worker` 均为 false
|
||||
- [x] 前端 `/dispatch` 重定向到 `/dispatch/price`,3 条子路由 `meta.permission` 正确 —— 代码核对(router 已配置 `redirect` 与 `meta.permission`)+ 浏览器实测(首次因后端瞬时抖动显示报错,复测页面正常渲染)
|
||||
- [x] `/api/service-prices`、`/api/dispatch-prices`、`/api/workers` 已纳入 RBAC 权限映射 —— API 实测:SALESMAN 调 `/api/workers`、`/api/service-prices` 均 HTTP 403
|
||||
- [x] SALESMAN 调用 `/api/workers/options`、`/api/workers/recommend` 正常返回(白名单生效),调用 `/api/workers` 列表返回 403 —— API 实测 HTTP 200 / 403
|
||||
|
||||
## 安全与规范(NFR)
|
||||
|
||||
- [x] `GET /api/workers` 与详情接口不返回 `password_hash` —— API 实测:列表 / 详情 / `certStatus` 各分支返回体均无 `password_hash` / `passwordHash`
|
||||
- [x] 所有查询走 Sequelize 参数化 API,无字符串拼接 SQL 与模板字符串 LIKE —— 代码核对:模糊查询统一 `Op.like` + 绑定值,无原始 SQL 拼接
|
||||
- [x] 前端页面未使用 `v-html`,表单输入含长度与特殊字符校验 —— 代码核对:三个新页面无 `v-html`(grep 0 命中),输入框均带 `maxlength`,手机号等有正则校验
|
||||
- [x] 列表接口均走 `limit + offset` 分页,证书聚合无 N+1 查询 —— 代码核对:`worker_cert` 用 `worker_id IN (...)` 批量加载后按电工分组
|
||||
- [x] Controller 参数使用 DTO + `class-validator`,无滥用 `any`(除既有约定处)—— `worker` / `worker-app` 用 DTO 类型;`service-price` / `dispatch-price` / `notice` 沿用工程既有约定(`@Body() body: any`,业务校验在 service 层)。注:工程未注册 `@midwayjs/validate` 组件,DTO 装饰器全站不生效,属既有约定
|
||||
|
||||
## 端到端
|
||||
|
||||
- [x] 后端 `yarn dev` 启动无编译错误 —— API 实测:`MIDWAY_SERVER_ENV=local` 实例启动成功;`npx tsc --noEmit` 退出码 0
|
||||
- [x] 前端 `yarn dev` 启动,三个新页面 CRUD 全链路可用 —— 前端 `vite` 启动成功、`vue-tsc --noEmit` 退出码 0;浏览器实测三个页面均正常渲染、弹窗字段齐备、KPI/筛选/铃铛/仪表盘联动正常;CRUD 接口链路已在 API 层全量实测(浏览器内「填表→点提交」这一步受自动化工具步骤预算限制未跑通)
|
||||
- [x] AC-1 ~ AC-11 逐条走通并留痕 —— 见上方各项实测证据;验证数据已清理,数据库恢复至验证前基线(`worker=4 / worker_cert=0 / sys_notice=0 / service_price=0 / dispatch_price=2`)
|
||||
|
||||
## 验证阶段修复
|
||||
|
||||
- [x] 修复 `WorkerAuthMiddleware` 同时声明 `match` 与 `ignore`:Midway 抛 `MIDWAY_10001: options.match and options.ignore can not both present`,导致**全站所有 `/api/*` 返回 500**;已改为仅 `match`,`/api/worker-app/auth/*` 在 `resolve()` 内放行 —— API 实测修复后匿名访问新接口由 500 恢复为 401
|
||||
@@ -0,0 +1,293 @@
|
||||
# 运维费定价与电工登记管理 Spec
|
||||
|
||||
> change-id: `add-om-pricing-and-worker-registry`
|
||||
> 范围:`smart_grid_iot`(后端 server + 管理端 client);电工 APP 仅交付后端接口,不改 `smart_grid_iot_app` 页面。
|
||||
|
||||
## Why
|
||||
|
||||
系统当前缺少两块运营基础能力:
|
||||
|
||||
1. **运维费定价缺失**:既没有面向客户的运维收费价目表,也没有电工派单成本价目的维护界面。数据库设计 D10 已有 `dispatch_price`(共享电工派单价格)实体,但只有 entity、无接口、无页面,无法维护。
|
||||
2. **电工登记与资质管理缺失**:`worker` 表只有只读的列表 / 下拉 / 推荐接口(供项目勘察派单选用),无法登记新电工、无资质审核流转、无资质到期提醒;`worker` 实体甚至缺少设计文档中已有的 `password_hash`、`last_login_at` 字段,电工 APP 无法登录。
|
||||
|
||||
这两块是后续派单、维保、结算业务的前置主数据,需先补齐。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增客户运维服务价目表 `service_price`:全局统一价目,用 `price_mode` 单表承载 **按次 / 按工时 / 材料** 三种计价方式,提供 CRUD + 启用停用。
|
||||
- 扩展现有 `dispatch_price`(电工派单成本价目):新增 `price_mode`(按次 / 按工时)、`unit`、`status`、`remark`、`updated_at`,补齐 CRUD 接口与管理页面。
|
||||
- 扩展 `worker` 表:补 `password_hash`、`audit_status`、`audit_remark`、`audit_by`、`audit_at`、`last_login_at`;新增登记 CRUD 与资质审核流转(待审核 → 已通过 / 已驳回,可重新提交)。
|
||||
- 新增电工资质证照子表 `worker_cert`(一电工多证:证书类型 / 编号 / 发证机关 / 发证日期 / 有效期至 / 证照图片),按期日期逐证计算「正常 / 临期 / 已过期」。
|
||||
- 新增 `sys_notice` 站内通知表 + `@midwayjs/cron` 定时任务(每日扫描证书有效期,**提前 30 天**生成提醒,接收人仅 ADMIN 用户);管理端顶栏新增消息铃铛 + 未读红点 + 消息中心抽屉。
|
||||
- 新增电工 APP 后端接口 `/api/worker-app/*`:自助注册、登录、查看 / 编辑本人资料、上传资质、查看审核结果与到期提醒;配套独立电工 JWT 与鉴权中间件。**不修改 `smart_grid_iot_app` 工程页面**。
|
||||
- 菜单调整:`运营派单`(id=3)由菜单项改为目录(`menu_type=1`),下挂 3 个子菜单「运维费定价」「电工派单成本价」「电工登记管理」及对应按钮权限。
|
||||
- **BREAKING**(仅限内部路由/权限):
|
||||
- 管理端 `/dispatch` 路由由 Placeholder 占位页改为目录(重定向到 `/dispatch/price`)。
|
||||
- `/api/workers` 由「登录即可访问」改为受 `dispatch:worker` 权限控制;为保证项目勘察派单页正常,`/api/workers/options`、`/api/workers/recommend` 加入鉴权白名单。
|
||||
- `dispatch_price` 表新增 NOT NULL 默认值字段(`price_mode` 默认 1、`status` 默认 1),存量数据自动补默认值。
|
||||
|
||||
## Impact
|
||||
|
||||
- **Affected specs**:客户管理核心(复用 `upload` / `file_record`)、项目管理-现场勘察派单(复用 `workers/options`、`workers/recommend`)、系统管理与 RBAC(`rbac.middleware.ts` + `db_seed.sql` 菜单/字典)
|
||||
- **Affected code**:
|
||||
- 后端实体:新增 `service_price.entity.ts`、`worker_cert.entity.ts`、`sys_notice.entity.ts`;修改 `worker.entity.ts`、`dispatch_price.entity.ts`
|
||||
- 后端接口:新增 `service_price.*`、`dispatch_price.*`、`notice.*`、`worker_app.*`;扩展 `worker.*`
|
||||
- 后端横切:`middleware/workerAuth.middleware.ts`(新增)、`middleware/rbac.middleware.ts`(改)、`util/token.ts`(改)、`task/cert_expire.task.ts`(新增)、`package.json`(新增 `@midwayjs/cron`)
|
||||
- 数据库:新增 `database/migrations/20261001_om_worker_registry.sql`;同步修订 `database/db_seed.sql`
|
||||
- 前端:新增 `api/servicePrice.ts`、`api/dispatchPrice.ts`、`api/workerRegistry.ts`、`api/notice.ts`;新增 `views/dispatch/price/index.vue`、`views/dispatch/cost/index.vue`、`views/dispatch/worker/index.vue`;修改 `router/index.ts`、`layouts/index.vue`
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### FR-1:客户运维服务价目管理(`service_price`)
|
||||
|
||||
系统 SHALL 提供面向客户的运维收费价目 CRUD 与启停用能力。
|
||||
|
||||
- 表字段:`id`、`price_code`(UNIQUE,自动生成,规则 `FW + YYYYMMDD + 3 位序号`)、`price_name`(必填)、`service_type`(服务类型:维修 / 维保 / 报警处理 / 巡检 / 其他,取自字典 `om_service_type`)、`price_mode`(计价方式:1 按次 / 2 按工时 / 3 材料,取自字典 `om_price_mode`)、`unit_price`(NUMERIC(10,2),必填,≥ 0)、`unit`(单位:次 / 小时 / 个 / 米 / 根,取自字典 `om_price_unit`)、`status`(1 启用 / 0 停用,默认 1)、`remark`、`created_at`、`updated_at`
|
||||
- 列表:分页(`page` / `pageSize`,`pageSize ≤ 200`);关键词模糊搜索(价目编码 / 名称);筛选 `price_mode`、`service_type`、`status`
|
||||
- 创建 / 更新:`price_code` 只读不可改;字段白名单更新;`price_mode` 与 `unit` 必须成对合理(按次默认「次」、按工时默认「小时」、材料默认「个」)
|
||||
- 删除:硬删除;停用(`status=0`)与删除为两个独立操作
|
||||
- 索引:`price_code` UNIQUE、`(price_mode, status)` 联合索引
|
||||
- `price_mode` 三种取值本期全部开放录入(零成本满足「后续扩展按工时 / 材料」诉求),不做前端隐藏
|
||||
|
||||
### FR-2:电工派单成本价目管理(`dispatch_price` 扩展)
|
||||
|
||||
系统 SHALL 在现有 `dispatch_price` 基础上补齐管理能力。
|
||||
|
||||
- 新增字段:`price_mode`(1 按次 / 2 按工时,默认 1,NOT NULL)、`unit`(次 / 小时,默认「次」)、`status`(1 启用 / 0 停用,默认 1,NOT NULL)、`remark`、`updated_at`
|
||||
- 保留原有字段与语义:`price_code`(UNIQUE)、`dispatch_type`(勘察 / 安装 / 预警 / 维修 / 维保)、`price_type`(单价 / 总价提成)、`unit_price`
|
||||
- 列表:分页 + 关键词(编码)+ 筛选 `dispatch_type`、`price_mode`、`status`
|
||||
- 创建 / 更新 / 删除:`price_code` 唯一冲突返回 400 且 message 明确;字段白名单更新
|
||||
- 本期不含「材料」成本项(材料归属客户侧收费价目 FR-1)
|
||||
|
||||
### FR-3:电工登记管理(`worker` 扩展)
|
||||
|
||||
系统 SHALL 提供电工登记 CRUD。
|
||||
|
||||
- 新增字段:
|
||||
- `password_hash`(VARCHAR(255),电工 APP 登录密码 bcrypt 哈希,允许 NULL)
|
||||
- `audit_status`(SMALLINT,0 待审核 / 1 已通过 / 2 已驳回,默认 0,NOT NULL)
|
||||
- `audit_remark`(VARCHAR(255),驳回理由 / 审核备注)
|
||||
- `audit_by`(BIGINT,审核人 `sys_user.id`)、`audit_at`(TIMESTAMPTZ)
|
||||
- `last_login_at`(TIMESTAMPTZ)
|
||||
- 保留字段:`worker_name`、`phone`(UNIQUE,登录账号)、`id_card`、`worker_title`、`rating`、`cert_info`、`cert_verified`、`completed_count`、`status`(1 在岗 / 0 离职)
|
||||
- `status`(在岗 / 离职)与 `audit_status`(审核)为两个正交维度:未审核通过的电工不允许在派单下拉 / 推荐中出现
|
||||
- 列表:分页 + 关键词(姓名 / 手机号)+ 筛选 `status`、`audit_status`、`certStatus`(资质状态:正常 / 临期 / 已过期 / 无证书)
|
||||
- 创建:手机号唯一校验;后台创建时可选「同时创建 APP 登录密码」(默认密码 = 手机号后 6 位,不足 6 位用 `123456`,与 `customer_account` 规则一致)
|
||||
- 更新:字段白名单;手机号变更需重新校验唯一
|
||||
- 删除:硬删除,级联删除其 `worker_cert`;若存在派单引用(`survey_order` / `install_order` 的 `worker_id`)则返回 400 并提示
|
||||
- 权限:仅 `dispatch:worker` 权限角色(本期仅 ADMIN)可访问
|
||||
|
||||
### FR-4:电工资质审核流转
|
||||
|
||||
系统 SHALL 支持电工登记的资质审核状态机。
|
||||
|
||||
- 状态流转:`0 待审核` → `1 已通过` 或 `2 已驳回`;`2 已驳回` 可重新提交回 `0 待审核`(由后台或电工 APP 触发)
|
||||
- 后台「审核通过」:写入 `audit_status=1`、`audit_by=当前用户`、`audit_at=now`、清空 `audit_remark`
|
||||
- 后台「审核驳回」:必须填写 `audit_remark`(驳回理由),写入 `audit_status=2`、审核人 / 时间
|
||||
- 电工 APP「重新提交」:仅 `audit_status=2` 时可提交,提交后回到 `0 待审核` 并清空审核人 / 时间
|
||||
- `worker.cert_verified` 由审核结果同步:`audit_status=1` 时置 `true`(保持与既有推荐算法字段兼容)
|
||||
|
||||
### FR-5:电工资质证照子表(`worker_cert`)
|
||||
|
||||
系统 SHALL 以子表管理电工的多本资质证照。
|
||||
|
||||
- 表字段:`id`、`worker_id`(FK → `worker`,NOT NULL,IDX)、`cert_type`(证书类型:低压电工证 / 高压电工证 / 特种作业证 / 登高证 / 其他,取自字典 `worker_cert_type`)、`cert_no`(证书编号)、`issue_authority`(发证机关)、`issue_date`(DATE,发证日期)、`expire_date`(DATE,有效期至,**到期提醒依据**,IDX)、`cert_image_url`(证照图片对象 key,复用 `upload` + `file_record`)、`created_at`、`updated_at`
|
||||
- 一个电工可挂多本证书;同一电工下 `(cert_type, cert_no)` 组合不可重复(UNIQUE 组合索引)
|
||||
- 证书到期状态为**计算值**(不落库),由 `expire_date` 与当前日期推导:
|
||||
- `无证书`:该电工 `worker_cert` 为空
|
||||
- `已过期`:存在任一本 `expire_date < today`
|
||||
- `临期`:无已过期,且存在任一本 `today ≤ expire_date ≤ today + 30 天`
|
||||
- `正常`:其余(含 `expire_date` 为 NULL 视为长期有效)
|
||||
- 后台支持在电工编辑弹窗中增删改证书行
|
||||
- 电工 APP 支持新增 / 编辑本人证书(新增 / 修改后不改变 `audit_status`,但会触发该证书已生成通知的清理,见 FR-7)
|
||||
|
||||
### FR-6:资质到期提醒(列表 / 仪表盘标签)
|
||||
|
||||
系统 SHALL 在管理端提供可见的资质到期提醒。
|
||||
|
||||
- 电工登记列表新增「资质状态」列,按 FR-5 计算值渲染标签(正常 = 绿色 / 临期 = 橙色 / 已过期 = 红色 / 无证书 = 灰色)
|
||||
- 列表顶部提供 KPI 统计卡:电工总数、待审核数、临期数、已过期数
|
||||
- 提供「资质临期 / 已过期」快捷筛选(复用 `certStatus` 查询参数)
|
||||
- 仪表盘新增「电工资质到期提醒」区块,展示临期与已过期证书清单(电工姓名 + 证书类型 + 有效期至 + 剩余天数),点击跳转电工登记页
|
||||
|
||||
### FR-7:站内通知与定时任务
|
||||
|
||||
系统 SHALL 通过站内通知触达资质到期提醒。
|
||||
|
||||
- 新增表 `sys_notice`:`id`、`notice_type`(`cert_expire`)、`title`、`content`、`biz_type`(`worker_cert`)、`biz_id`(`worker_cert.id`)、`receiver_id`(`sys_user.id`,IDX)、`is_read`(0 未读 / 1 已读,默认 0)、`created_at`
|
||||
- 幂等约束:UNIQUE(`notice_type`, `biz_id`, `receiver_id`);定时任务插入使用 `ON CONFLICT DO NOTHING`
|
||||
- 定时任务:`@midwayjs/cron` 每日 08:00 执行,扫描 `expire_date <= today + 30 天` 的 `worker_cert`,为每个 ADMIN 用户(`sys_role.role_code = 'ADMIN'` 关联的 `sys_user`)生成一条未读通知;标题形如「电工【张三】的高压电工证将于 2026-10-20 到期(剩余 19 天)」
|
||||
- 证书续期(`expire_date` 被更新)或证书被删除时,服务层同步清理该证书既有未读通知,保证续期后可重新提醒
|
||||
- 通知接口:`GET /api/notices`(本人,分页)、`GET /api/notices/unread-count`、`PUT /api/notices/:id/read`、`PUT /api/notices/read-all`;仅返回 `receiver_id = 当前用户` 的数据
|
||||
- 前端:`layouts/index.vue` 顶栏用户信息左侧新增消息铃铛 + 未读红点角标(轮询或进入页面时拉取未读数),点击打开消息中心抽屉(列表 + 标记已读 + 全部已读 + 点击跳转电工登记页)
|
||||
|
||||
### FR-8:电工 APP 后端接口(`/api/worker-app/*`)
|
||||
|
||||
系统 SHALL 为电工 APP 提供独立的后端接口(本期不改 APP 页面)。
|
||||
|
||||
- 鉴权方式:独立电工 JWT,payload 含 `workerId`、`phone`、`userType: "worker"`;`TokenUtil` 新增 `generateWorkerToken` / `verifyWorkerToken`
|
||||
- 新增 `WorkerAuthMiddleware`,`match = ['/api/worker-app/*']`、`ignore = ['/api/worker-app/auth/*']`;校验通过后写入 `ctx.state.worker = { workerId, phone }`
|
||||
- `rbac.middleware.ts` 与 `auth.middleware.ts` 均忽略 `/api/worker-app/*`(不走管理端 sys_user 鉴权)
|
||||
- 接口清单:
|
||||
- `POST /api/worker-app/auth/register`:自助注册(姓名、手机号、身份证号、职衔、密码);手机号唯一;默认 `audit_status=0`、`status=0`(未在岗)
|
||||
- `POST /api/worker-app/auth/login`:手机号 + 密码登录,返回 worker token;未审核通过允许登录但仅能查看审核状态与资质
|
||||
- `GET /api/worker-app/profile`:本人资料 + `audit_status` / `audit_remark` + 资质状态汇总
|
||||
- `PUT /api/worker-app/profile`:修改本人可编辑字段(姓名 / 身份证号 / 职衔 / 密码)
|
||||
- `GET /api/worker-app/certs`、`POST /api/worker-app/certs`、`PUT /api/worker-app/certs/:id`、`DELETE /api/worker-app/certs/:id`:本人证书增删改查(证书图片经 `POST /api/upload/image` 上传后回填 key)
|
||||
- `POST /api/worker-app/audit/resubmit`:`audit_status=2` 时重新提交审核
|
||||
- `GET /api/worker-app/notices`、`PUT /api/worker-app/notices/:id/read`:本人证书到期提醒(`receiver` 维度改为该 worker 的证书)
|
||||
- 越权控制:所有 `:id` / 证书操作必须校验归属当前 `workerId`,否则 403
|
||||
|
||||
### FR-9:菜单 / 权限 / 字典
|
||||
|
||||
系统 SHALL 在种子数据与 RBAC 中补齐新模块。
|
||||
|
||||
- `运营派单`(id=3)改为目录:`menu_type=1`,`menu_path=/dispatch`,`permission=dispatch:list`
|
||||
- 新增子菜单(`parent_id=3`,`menu_type=2`):
|
||||
- `34`「运维费定价」`/dispatch/price` `dispatch/price/index` `dispatch:price`
|
||||
- `35`「电工派单成本价」`/dispatch/cost` `dispatch/cost/index` `dispatch:cost`
|
||||
- `36`「电工登记管理」`/dispatch/worker` `dispatch/worker/index` `dispatch:worker`
|
||||
- 新增按钮权限(`menu_type=3`):`price:create` / `price:update` / `price:delete`、`cost:create` / `cost:update` / `cost:delete`、`worker:create` / `worker:update` / `worker:delete` / `worker:audit`
|
||||
- 角色绑定:全部绑定给 ADMIN(role_id=1);SALESMAN 不绑定(保持无「运营派单」可见性)
|
||||
- 新增字典:`om_service_type`、`om_price_mode`、`om_price_unit`、`worker_cert_type`、`worker_audit_status`
|
||||
- `sys_menu` 序列重置为 36(含按钮权限则为最后一条 id)
|
||||
- `rbac.middleware.ts` 新增映射:`/api/service-prices → dispatch:price`、`/api/dispatch-prices → dispatch:cost`、`/api/workers → dispatch:worker`;`ignore` 增加 `/api/worker-app/*`、`/api/workers/options`、`/api/workers/recommend`
|
||||
- `/api/notices` 不做权限码映射(登录即可访问本人通知)
|
||||
|
||||
### FR-10:前端页面
|
||||
|
||||
系统 SHALL 提供 3 个管理端页面,风格与现有「客户档案管理」页完全一致(搜索栏 + 表格内滚动 + 新增/编辑弹窗 + 删除二次确认 + 状态开关)。
|
||||
|
||||
- `views/dispatch/price/index.vue`(运维费定价):搜索栏(关键词 / 计价方式 / 服务类型 / 状态)+ 表格(价目编码 / 名称 / 服务类型 / 计价方式 / 单价 / 单位 / 状态 / 操作)+ 弹窗(表单校验、计价方式联动单位)
|
||||
- `views/dispatch/cost/index.vue`(电工派单成本价):搜索栏(关键词 / 派单类型 / 计价方式 / 状态)+ 表格(编码 / 派单类型 / 价格类型 / 计价方式 / 单价 / 单位 / 状态 / 操作)+ 弹窗
|
||||
- `views/dispatch/worker/index.vue`(电工登记管理):KPI 统计卡 + 搜索栏(关键词 / 在岗状态 / 审核状态 / 资质状态)+ 表格(姓名 / 手机号 / 职衔 / 资质状态标签 / 审核状态标签 / 在岗状态 / 评分 / 完成数 / 操作)+ 编辑弹窗(基础信息 + 证书子表行编辑 + 证照图片上传)+ 审核弹窗(通过 / 驳回 + 驳回理由)
|
||||
- 路由:`/dispatch` 改为重定向 `/dispatch/price`;新增 3 条子路由,`meta.permission` 对应 `dispatch:price` / `dispatch:cost` / `dispatch:worker`
|
||||
- 页面使用现有 `useTableScroll` composable、`request` 封装与统一返回结构
|
||||
|
||||
---
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
### NFR-1:安全
|
||||
- 所有查询走 Sequelize 参数化 API(`findAndCountAll` / `create` / `update` / `destroy` + `where` 对象),禁止字符串拼接 SQL;LIKE 使用 `{ [Op.like]: \`%${keyword}%\` }` 参数化
|
||||
- 前端不使用 `v-html`;表单输入加长度上限与正则过滤 `<` `>` `"` `'`
|
||||
- `worker` 列表 / 详情接口永不返回 `password_hash`;`sys_notice` 接口严格按 `receiver_id` 过滤
|
||||
|
||||
### NFR-2:权限与数据隔离
|
||||
- 管理端:`/api/workers`、`/api/service-prices`、`/api/dispatch-prices` 受 `dispatch:*` 权限码控制,ADMIN 直通
|
||||
- 电工 APP:所有接口以 `ctx.state.worker.workerId` 为唯一数据边界,禁止通过请求参数越权读取他人数据
|
||||
- ADMIN 判定复用现有 `sys_role.role_code = 'ADMIN'` 逻辑
|
||||
|
||||
### NFR-3:性能
|
||||
- 新增表均建必要索引:`worker_cert.worker_id`、`worker_cert.expire_date`、`service_price.price_code`(UNIQUE)、`sys_notice.receiver_id`、`sys_notice.(notice_type,biz_id,receiver_id)`(UNIQUE)、`worker.audit_status`
|
||||
- 列表接口统一 `limit + offset` 分页;证书聚合按 `worker_id IN (...)` 批量查询,禁止 N+1
|
||||
- 单接口响应目标 < 500ms(本地开发环境)
|
||||
|
||||
### NFR-4:类型与一致性
|
||||
- Entity / DTO / 前端 API 类型均用显式 interface;Controller 参数使用 DTO class + `class-validator` 装饰器
|
||||
- 统一返回结构:列表 `{ code: 200, message, data: { list, total, page, pageSize } }`;单条 / 写操作 `{ code: 200, message, data }`;错误码 401 / 403 / 400 / 404 / 500
|
||||
|
||||
### NFR-5:数据库变更
|
||||
- 新增 `database/migrations/20261001_om_worker_registry.sql`(ALTER / CREATE TABLE / INSERT 菜单字典 / 序列重置),沿用现有 migration 命名与写法
|
||||
- 同步修订 `database/db_seed.sql`,保证全新初始化数据库与迁移后结构一致
|
||||
- 迁移脚本可重复执行(`IF NOT EXISTS` / `ON CONFLICT` / 有默认值的 NOT NULL 列)
|
||||
|
||||
---
|
||||
|
||||
## Constraints
|
||||
|
||||
- 技术栈不可更换:Midway.js (Koa) + Sequelize-typescript + PostgreSQL;Vue3 + Ant Design Vue 4 + Vite;JWT + 现有 RBAC 中间件
|
||||
- `sequelize.sync = false`(见 `config.default.ts`),建表必须通过 SQL 迁移完成
|
||||
- 定时任务需新增依赖 `@midwayjs/cron`(属官方组件,与现有 Midway 3.x 体系一致)
|
||||
- 本期不做:派单 / 收入结算联动(不写 `worker_income`)、电工评分计算、客户侧查询运维价目的接口、`smart_grid_iot_app` 页面改造
|
||||
|
||||
## Assumptions(需你复核)
|
||||
|
||||
1. **`price_mode` 三种模式全部开放**:表结构、接口、页面下拉均支持「按次 / 按工时 / 材料」,而不是本期只开放「按次」。理由:这是满足「后续扩展」诉求的最低成本做法。若你希望界面暂只显示「按次」,请告知。
|
||||
2. **材料归客户侧**:材料费用只作为客户运维价目的 `price_mode=3`,电工派单成本价目暂不含材料项。
|
||||
3. **电工 APP 登录账号 = `worker` 表手机号 + 密码**,与 `sys_user` 完全独立,不新建 `sys_user` 记录;电工 APP 权限不走 RBAC 菜单,接口即权限。
|
||||
4. **审核人 = ADMIN**:`audit_by` 记录 `sys_user.id`;本期不区分多级审核。
|
||||
5. **通知接收人仅 ADMIN 角色用户**;提前 30 天标「临期」,`expire_date` 为 NULL 视为长期有效不提醒。
|
||||
6. **定时任务依赖新增 `@midwayjs/cron`**;若你不希望引入新依赖,可改为 `@Configuration` 生命周期 + `setInterval` 每日检查(功能等价,代码稍脏)。
|
||||
7. **电工下拉 / 推荐口径变更**:`/api/workers/options`、`/api/workers/recommend` 增加「`audit_status=1` 且 `status=1`」过滤条件——即只有审核通过且在岗的电工才能被派单。这会改变现有勘察派单页可选电工范围(当前是只要有 `status=1`)。**这是有意的业务收紧,请确认**。
|
||||
8. **`dispatch_price` 无存量数据假设**(或存量极少),迁移直接补默认值即可,不需要数据回填脚本。
|
||||
9. **菜单 id 从 34 起分配**,`sys_menu` 序列重置到最大值;`db_seed.sql` 原有注释中的菜单计数同步更新。
|
||||
10. **管理端列表「资质状态」为实时计算**,不落库;因此 `certStatus` 筛选在 SQL 层用子查询 / `EXISTS` 实现(Sequelize `where` 内联 `fn`/`literal` 参数化写法),不使用字符串拼接。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### AC-1:客户运维价目 CRUD 可用
|
||||
- **Given**:ADMIN 登录,进入「运营派单 → 运维费定价」
|
||||
- **When**:新增一条 `price_mode=1 按次`、`service_type=维修`、单价 200、单位「次」的价目并保存
|
||||
- **Then**:返回 code=200,`price_code` 形如 `FW20261001001`;列表刷新可见该记录;编辑改单价后列表更新;删除后列表移除;`price_mode=2/3` 也能正常新增
|
||||
- **Verification**:`programmatic`
|
||||
|
||||
### AC-2:电工派单成本价目可用且兼容存量
|
||||
- **Given**:`dispatch_price` 表执行迁移后
|
||||
- **When**:查询既有数据的 `price_mode` / `status`
|
||||
- **Then**:存量行 `price_mode=1`、`status=1`(默认值生效,无 NULL);新增 / 编辑 / 删除 / 筛选均正常
|
||||
- **Verification**:`programmatic`
|
||||
|
||||
### AC-3:电工资质审核流转正确
|
||||
- **Given**:后台新增一名电工并录入 1 本证书(`audit_status=0`)
|
||||
- **Then**:列表显示「待审核」;执行「审核驳回」未填理由时返回 400;填写理由后 `audit_status=2` 且 `audit_remark` 有值、`audit_by`/`audit_at` 有值;执行「审核通过」后 `audit_status=1`、`cert_verified=true`、`audit_remark` 清空
|
||||
- **Verification**:`programmatic`
|
||||
|
||||
### AC-4:资质状态计算与筛选正确
|
||||
- **Given**:电工 A 证书 `expire_date = today + 10 天`;电工 B 证书 `expire_date = today - 1 天`;电工 C 无证书;电工 D 证书 `expire_date = today + 100 天`
|
||||
- **When**:访问电工列表
|
||||
- **Then**:A 显示「临期」、B 显示「已过期」、C 显示「无证书」、D 显示「正常」;按 `certStatus=临期` 筛选只返回 A
|
||||
- **Verification**:`programmatic`
|
||||
|
||||
### AC-5:到期提醒标签与仪表盘区块
|
||||
- **Given**:存在 AC-4 场景数据,且 A 为 `audit_status=1`、`status=1`
|
||||
- **Then**:电工登记页 KPI 显示待审核数 / 临期数 / 已过期数正确;仪表盘「电工资质到期提醒」区块列出 A、B 的证书与剩余天数,点击可跳转电工登记页
|
||||
- **Verification**:`human-judgment`
|
||||
|
||||
### AC-6:定时任务生成站内通知且幂等
|
||||
- **Given**:存在 `expire_date = today + 19 天` 的证书,且系统有 1 个 ADMIN 用户
|
||||
- **When**:手动触发定时任务一次,再触发第二次
|
||||
- **Then**:第一次生成 1 条 `sys_notice`(`receiver_id` = ADMIN,`is_read=0`);第二次不新增(UNIQUE + ON CONFLICT DO NOTHING)
|
||||
- **Verification**:`programmatic`
|
||||
|
||||
### AC-7:消息中心可用
|
||||
- **Given**:ADMIN 登录且存在 2 条未读通知
|
||||
- **Then**:顶栏铃铛显示红点角标「2」;打开抽屉可见 2 条通知;单条标记已读后角标变「1」;「全部已读」后角标消失;点击通知跳转到电工登记页
|
||||
- **Verification**:`human-judgment` + `programmatic`(`GET /api/notices/unread-count`)
|
||||
|
||||
### AC-8:电工 APP 接口可用且数据隔离
|
||||
- **Given**:电工 E1、E2 各注册成功并登录
|
||||
- **When**:E1 携带自己的 token 调用 `GET /api/worker-app/certs` 与 `PUT /api/worker-app/certs/:id`(传 E2 的证书 id)
|
||||
- **Then**:只能看到 E1 自己的证书;操作 E2 的证书返回 403;`POST /api/worker-app/audit/resubmit` 在 `audit_status=2` 时可将状态改回 0,`audit_status=1` 时返回 400
|
||||
- **Verification**:`programmatic`
|
||||
|
||||
### AC-9:权限与既有页面不受影响
|
||||
- **Given**:SALESMAN 账号登录
|
||||
- **When**:访问 `/api/workers/options`、`/api/workers/recommend`(项目勘察派单页使用)
|
||||
- **Then**:正常返回;访问 `/api/workers/list` 或 `/api/service-prices` 返回 403;SALESMAN 侧边栏不出现「运营派单」及其子菜单
|
||||
- **Verification**:`programmatic` + `human-judgment`
|
||||
|
||||
### AC-10:敏感字段不外泄
|
||||
- **Given**:调用 `GET /api/workers` 与 `GET /api/workers/:id`
|
||||
- **Then**:返回体不含 `password_hash`
|
||||
- **Verification**:`programmatic`
|
||||
|
||||
### AC-11:迁移脚本可重复执行
|
||||
- **Given**:在已执行过迁移的库上再执行一次 `20261001_om_worker_registry.sql`
|
||||
- **Then**:无报错,菜单 / 字典数据收敛不重复(`ON CONFLICT` 生效)
|
||||
- **Verification**:`programmatic`
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [x] (已确认)运维费定价对象:客户收费价目 + 电工派单成本价目,**分开维护**
|
||||
- [x] (已确认)交付范围:后端 API + 管理端 Web + 电工 APP 后端接口;不含 `smart_grid_iot_app` 页面改造
|
||||
- [x] (已确认)资质建模:`worker_cert` 子表 + `worker.audit_status` 审核流转
|
||||
- [x] (已确认)到期提醒:列表 / 仪表盘标签 **与** 定时任务站内通知并存
|
||||
- [x] (已确认)客户价目建模:单表 `price_mode` 区分
|
||||
- [x] (已确认)定价维度:全局统一价目,不按客户 / 等级区分
|
||||
- [x] (已确认)菜单位置:「运营派单」下两个独立子菜单(+ 电工登记管理)
|
||||
- [x] (已确认)电工 APP:自助注册 + 上传资质 + 查审核结果
|
||||
- [ ] 待复核:Assumptions 第 7 条「派单可选电工收紧为审核通过 + 在岗」是否接受
|
||||
- [ ] 待复核:Assumptions 第 1 条「三种 `price_mode` 是否本期全部开放录入」
|
||||
@@ -0,0 +1,92 @@
|
||||
# Tasks
|
||||
|
||||
> change-id: `add-om-pricing-and-worker-registry`
|
||||
> 依赖顺序:Task 1 → 2 → (3,4,5,6,7 并行) → 8 → 9 → 10
|
||||
|
||||
- [x] Task 1: 数据库迁移与种子数据(全部后续任务的前置)
|
||||
- [x] SubTask 1.1: 新建 `database/migrations/20261001_om_worker_registry.sql`
|
||||
- ALTER `worker` 增加 `password_hash` / `audit_status`(默认 0, NOT NULL) / `audit_remark` / `audit_by` / `audit_at` / `last_login_at`
|
||||
- CREATE TABLE `worker_cert`(含 `worker_id` IDX、`expire_date` IDX、UNIQUE(`worker_id`,`cert_type`,`cert_no`))
|
||||
- CREATE TABLE `service_price`(含 `price_code` UNIQUE、(`price_mode`,`status`) IDX)
|
||||
- ALTER `dispatch_price` 增加 `price_mode`(默认 1, NOT NULL) / `unit` / `status`(默认 1, NOT NULL) / `remark` / `updated_at`
|
||||
- CREATE TABLE `sys_notice`(含 `receiver_id` IDX、UNIQUE(`notice_type`,`biz_id`,`receiver_id`))
|
||||
- UPDATE `sys_menu` SET `menu_type=1` WHERE id=3;INSERT 菜单 id 34-46(3 个页面菜单 + 10 个按钮权限)+ ADMIN 角色绑定 + `setval('sys_menu_id_seq', …)`
|
||||
- INSERT 新字典:`om_service_type` / `om_price_mode` / `om_price_unit` / `worker_cert_type` / `worker_audit_status`
|
||||
- 全部语句使用 `IF NOT EXISTS` / `ON CONFLICT`,保证可重复执行
|
||||
- [x] SubTask 1.2: 同步修订 `database/db_seed.sql`(菜单 / 字典 / 角色绑定与迁移脚本一致,更新末尾计数注释)
|
||||
- [x] SubTask 1.3: 执行迁移脚本验证可跑通且可重复执行
|
||||
|
||||
- [x] Task 2: 后端实体层
|
||||
- [x] SubTask 2.1: 修改 `server/src/entity/worker.entity.ts`(补 6 个字段 + 索引)
|
||||
- [x] SubTask 2.2: 修改 `server/src/entity/dispatch_price.entity.ts`(补 5 个字段,开启 `updated_at`)
|
||||
- [x] SubTask 2.3: 新建 `server/src/entity/worker_cert.entity.ts`
|
||||
- [x] SubTask 2.4: 新建 `server/src/entity/service_price.entity.ts`
|
||||
- [x] SubTask 2.5: 新建 `server/src/entity/sys_notice.entity.ts`
|
||||
|
||||
- [x] Task 3: 客户运维价目后端(依赖 Task 2)
|
||||
- [x] SubTask 3.1: 新建 `dto/service_price.dto.ts`(创建 / 更新 / 查询 DTO + `class-validator` 校验)
|
||||
- [x] SubTask 3.2: 新建 `service/service_price.service.ts`(分页搜索筛选、`price_code` 生成 `FW+YYYYMMDD+3位`、白名单更新、增删改)
|
||||
- [x] SubTask 3.3: 新建 `controller/service_price.controller.ts`(`/api/service-prices`,统一返回结构)
|
||||
|
||||
- [x] Task 4: 电工派单成本价目后端(依赖 Task 2)
|
||||
- [x] SubTask 4.1: 新建 `dto/dispatch_price.dto.ts`
|
||||
- [x] SubTask 4.2: 新建 `service/dispatch_price.service.ts`(分页 + 关键词 + `dispatch_type`/`price_mode`/`status` 筛选 + CRUD + `price_code` 唯一冲突友好提示)
|
||||
- [x] SubTask 4.3: 新建 `controller/dispatch_price.controller.ts`(`/api/dispatch-prices`)
|
||||
|
||||
- [x] Task 5: 电工登记与资质审核后端(依赖 Task 2)
|
||||
- [x] SubTask 5.1: 扩展 `dto/worker.dto.ts`(登记创建 / 更新 / 审核 / 列表查询 / 证书行 DTO)
|
||||
- [x] SubTask 5.2: 扩展 `service/worker.service.ts`
|
||||
- 列表分页 + 关键词 + `status`/`auditStatus`/`certStatus` 筛选,批量聚合证书计算「资质状态」(避免 N+1)
|
||||
- KPI 统计(总数 / 待审核 / 临期 / 已过期)
|
||||
- 详情(含证书列表)
|
||||
- 创建(手机号唯一、`audit_status=0`、可选生成默认密码 bcrypt)、更新、删除(级联删证书 + 派单引用校验)
|
||||
- 审核通过 / 驳回(校验驳回理由必填、写 `audit_by`/`audit_at`/`cert_verified`)
|
||||
- 证书行增删改(续期 / 删除时清理该证书未读通知)
|
||||
- 保持 `/options`、`/recommend` 现有逻辑并按 Assumption 7 收紧为 `audit_status=1 AND status=1`
|
||||
- [x] SubTask 5.3: 扩展 `controller/worker.controller.ts`(`/api/workers` 增补 list/detail/create/update/delete/audit/certs 接口,静态路径在动态路径之前)
|
||||
|
||||
- [x] Task 6: 站内通知后端 + 定时任务(依赖 Task 1、2)
|
||||
- [x] SubTask 6.1: `server/package.json` 新增依赖 `@midwayjs/cron` 并安装
|
||||
- [x] SubTask 6.2: 新建 `service/notice.service.ts`(本人通知分页、未读数、单条 / 全部已读、ADMIN 用户查询、证书到期扫描 + 幂等生成、按证书清理通知)
|
||||
- [x] SubTask 6.3: 新建 `controller/notice.controller.ts`(`/api/notices`、`/api/notices/unread-count`、`/api/notices/:id/read`、`/api/notices/read-all`)
|
||||
- [x] SubTask 6.4: 新建 `task/cert_expire.task.ts`(`@Cron` 每日 08:00,调用 `notice.service` 扫描生成)
|
||||
|
||||
- [x] Task 7: 电工 APP 后端接口(依赖 Task 5)
|
||||
- [x] SubTask 7.1: 扩展 `util/token.ts`(`generateWorkerToken` / `verifyWorkerToken`)
|
||||
- [x] SubTask 7.2: 新建 `middleware/worker_auth.middleware.ts`(`match=['/api/worker-app/*']`、`ignore=['/api/worker-app/auth/*']`,写 `ctx.state.worker`)
|
||||
- [x] SubTask 7.3: 修改 `middleware/auth.middleware.ts` 与 `middleware/rbac.middleware.ts`(忽略 `/api/worker-app/*`;新增 `/api/service-prices`、`/api/dispatch-prices`、`/api/workers` 权限映射;`ignore` 增加白名单 `/api/workers/options`、`/api/workers/recommend`)
|
||||
- [x] SubTask 7.4: 新建 `dto/worker_app.dto.ts`、`service/worker_app.service.ts`、`controller/worker_app.controller.ts`(注册 / 登录 / 资料 / 证书 CRUD / 重新提交审核 / 通知,全部以 `workerId` 为数据边界)
|
||||
|
||||
- [x] Task 8: 前端 API 层(依赖 Task 3、4、5、6)
|
||||
- [x] SubTask 8.1: 新建 `client/src/api/servicePrice.ts`
|
||||
- [x] SubTask 8.2: 新建 `client/src/api/dispatchPrice.ts`
|
||||
- [x] SubTask 8.3: 新建 `client/src/api/workerRegistry.ts`(登记 / 审核 / 证书 / KPI 统计)
|
||||
- [x] SubTask 8.4: 新建 `client/src/api/notice.ts`
|
||||
|
||||
- [x] Task 9: 前端页面与路由(依赖 Task 8)
|
||||
- [x] SubTask 9.1: 新建 `client/src/views/dispatch/price/index.vue`(运维费定价)
|
||||
- [x] SubTask 9.2: 新建 `client/src/views/dispatch/cost/index.vue`(电工派单成本价)
|
||||
- [x] SubTask 9.3: 新建 `client/src/views/dispatch/worker/index.vue`(电工登记管理:KPI 卡 + 列表 + 编辑弹窗含证书子表与图片上传 + 审核弹窗)
|
||||
- [x] SubTask 9.4: 修改 `client/src/router/index.ts`(`/dispatch` 重定向到 `/dispatch/price`,新增 3 条子路由与 `meta.permission`)
|
||||
|
||||
- [x] Task 10: 仪表盘提醒区块与消息中心(依赖 Task 8)
|
||||
- [x] SubTask 10.1: 修改 `client/src/layouts/index.vue`(消息铃铛 + 未读红点角标 + 消息中心抽屉 + 跳转)
|
||||
- [x] SubTask 10.2: 修改 `client/src/views/dashboard/index.vue`(新增「电工资质到期提醒」区块,展示临期 / 已过期证书清单并可跳转)
|
||||
|
||||
- [x] Task 11: 联调与验证
|
||||
- [x] SubTask 11.1: 后端 `yarn dev` 启动无编译错误,逐条走通 AC-1 ~ AC-11
|
||||
- [x] SubTask 11.2: 前端 `yarn dev` 启动,人工验证 AC-5、AC-7、AC-9(菜单可见性)
|
||||
- [x] SubTask 11.3: 人工触发定时任务验证 AC-6 幂等
|
||||
|
||||
- [x] Task 12: 修复验证阶段发现的阻断性缺陷(Task 11 追加)
|
||||
- [x] SubTask 12.1: 修复 `WorkerAuthMiddleware` 同时声明 `match` 与 `ignore` 导致 Midway 抛 `MIDWAY_10001`、**全站接口 500** 的问题(改为仅 `match`,登录/注册路径在 `resolve()` 内放行)
|
||||
|
||||
# Task Dependencies
|
||||
|
||||
- Task 2 依赖 Task 1(表结构必须先存在)
|
||||
- Task 3 / Task 4 / Task 5 依赖 Task 2,彼此无依赖,可并行
|
||||
- Task 6 依赖 Task 1、Task 2
|
||||
- Task 7 依赖 Task 5(复用 worker 服务)与 Task 6(通知复用)
|
||||
- Task 8 依赖 Task 3、4、5、6(接口契约已定)
|
||||
- Task 9、Task 10 依赖 Task 8,彼此无依赖,可并行
|
||||
- Task 11 依赖全部
|
||||
@@ -0,0 +1,64 @@
|
||||
# Checklist
|
||||
|
||||
## 数据库与种子
|
||||
|
||||
- [x] `database/migrations/20261002_job_management.sql` 已创建,建 `sys_job` 与 `sys_job_log` 两表及索引,可重复执行无报错
|
||||
- [x] `sys_job` 含 `job_key` 唯一索引与 `status` 索引;`params` 为 JSONB 且默认 `{}`
|
||||
- [x] `sys_job_log` 含 `(job_id, created_at DESC)` 与 `created_at` 索引
|
||||
- [x] 迁移写入菜单 id 47(定时任务,parent_id=6,`/system/job`,`system:job`,menu_type=2,sort_order=6)与 48/49/50 三个按钮权限(parent_id=47,menu_type=3)
|
||||
- [x] ADMIN 角色(role_id=1)已绑定菜单 47-50;`sys_menu_id_seq` 被设为 50
|
||||
- [x] 迁移写入 `demo_heartbeat`(`*/5 * * * *`)与 `log_clean`(`0 3 * * *`,retry_times=2、retry_interval=60)两条 `sys_job` 种子,重复执行不产生重复行
|
||||
- [x] `database/db_seed.sql` 已同步菜单 47-50、ADMIN 绑定、2 条 `sys_job` 种子与 `sys_menu_id_seq`=50;新库初始化与迁移后结构一致
|
||||
- [x] 既有菜单 id 7-11 的名称、路径、排序未被改动
|
||||
|
||||
## 后端
|
||||
|
||||
- [x] `server/package.json` 已显式声明 `cron` 依赖,`yarn dev` 可正常编译启动
|
||||
- [x] 内置任务注册表 `server/src/task/job_registry.ts` 提供 `jobKey/jobName/description/defaultCron/defaultParams/paramFields/handler`,页面参数表单由 `paramFields` 驱动,无需前端硬编码
|
||||
- [x] `demo_heartbeat` 与 `log_clean` 两个内置任务均已注册,`job_key` 与种子数据一致
|
||||
- [x] 调度器启动加载全部启用任务,停用任务不被调度
|
||||
- [x] 调度初始化失败时不阻塞应用启动(try/catch + 最多 3 次重试),`onStop` 停止全部定时器
|
||||
- [x] 页面修改 cron 后无需重启即按新表达式触发,列表 `nextRunTime` 同步更新
|
||||
- [x] 提交非法 cron 表达式返回 400 且提示明确,配置未落库
|
||||
- [x] 停用任务后定时触发停止,`nextRunTime` 为 `null`/`-`,但仍可手动执行
|
||||
- [x] 同一任务执行中再次触发时写 `status = 3` 跳过日志且不并发执行
|
||||
- [x] 失败且 `attempt <= retry_times` 时按 `retry_interval` 秒后以 `trigger_type = 3` 重试,`attempt` 递增
|
||||
- [x] `sys_job.last_run_at / last_run_status / last_run_cost_ms / last_error` 每次执行后正确回写(成功时清空 `last_error`)
|
||||
- [x] `log_clean` 同时清理 `sys_operation_log` 与 `sys_job_log`,摘要含两张表删除条数,默认 `keepDays = 90`
|
||||
- [x] `keepDays` 越界(如 0 或 5000)返回 400,配置未落库
|
||||
- [x] `GET /api/jobs` 支持任务名称模糊与状态筛选,返回分页结构并携带 `paramFields`、`nextRunTime`、上次执行信息
|
||||
- [x] `PUT /api/jobs/:id`、`PUT /api/jobs/:id/status`、`POST /api/jobs/:id/run`、`GET /api/jobs/:id/logs`、`DELETE /api/jobs/logs/clear` 均可用且返回统一结构
|
||||
- [x] 静态路由 `/api/jobs/logs/clear` 未被 `:id` 动态路由吞掉
|
||||
- [x] `error_stack` 落库被截断至 4000 字符,`message` 摘要可读
|
||||
- [x] 未注册的 `job_key` 在页面标记为未注册,编辑/启停/执行返回 400「任务实现不存在」
|
||||
- [x] `PERMISSION_MAP` 已新增 `'/api/jobs': 'system:job'`,非 ADMIN 无该权限时访问返回 403
|
||||
- [x] 所有查询/更新走 Sequelize 参数化 API,无字符串拼接 SQL 与模板字符串 LIKE
|
||||
- [x] 控制器入参使用 DTO + `class-validator`,未滥用 `any`(除既有约定处)
|
||||
- [x] 列表接口均使用 `limit + offset` 分页,无全表扫描式查询
|
||||
- [x] 调度器为进程内单例(`@Scope(ScopeEnum.Singleton)`),保证「启动注册」与「接口重载」操作同一个定时器注册表
|
||||
|
||||
## 前端
|
||||
|
||||
- [x] `client/src/api/job.ts` 提供 6 个接口方法与 TS 类型
|
||||
- [x] `client/src/views/system/job/index.vue` 含搜索栏 + 表格 + 编辑弹窗 + 立即执行二次确认 + 执行日志抽屉
|
||||
- [x] 表格列包含任务名/键、cron、参数摘要、状态开关、上次执行时间与状态与耗时、下次执行时间、重试配置
|
||||
- [x] 编辑弹窗中 cron、参数、重试次数、重试间隔可编辑,且保存前后端校验失败时弹窗保留并提示 message
|
||||
- [x] 执行日志抽屉支持分页、状态筛选与「清理日志」二次确认(默认清理 90 天前)
|
||||
- [x] 路由 `/system/job` 已注册,`meta.permission = 'system:job'`
|
||||
- [x] 侧边栏「系统管理」下可见「定时任务」(ADMIN),非 ADMIN 访问跳转 403
|
||||
- [x] 页面未使用 `v-html`,cron 与参数输入含长度/范围校验
|
||||
- [x] 页面结构与既有页面(操作日志 / 数据字典)保持一致,未引入多余 UI 元素
|
||||
|
||||
## 联调
|
||||
|
||||
- [x] 后端 `yarn dev` 启动无编译错误,接口逐条通过
|
||||
- [x] 前端 `yarn dev` 启动,编辑 → 立即执行 → 查看日志 → 清理日志 全链路可用
|
||||
- [x] 改 cron 即时生效与启停即时生效已验证
|
||||
- [x] 重试机制(`trigger_type = 3`、`attempt` 递增)已验证
|
||||
- [x] 迁移脚本与 `db_seed.sql` 在空库与存量库两端结果一致
|
||||
- [x] 隔离单实例复测:`*/1` 每分钟恰好触发 1 次、停用后 0 次触发、手动执行正常、恢复后 `nextRunTime` 正确(见 tasks.md Task 8)
|
||||
|
||||
## 遗留说明(非阻塞)
|
||||
|
||||
- `sys_job.job_key` 同时存在列级 `UNIQUE` 自动生成的 `sys_job_job_key_key` 与显式 `uk_sys_job_key`,两个索引功能重复(冗余但无害,不影响验收)
|
||||
- 非法入参返回的是响应体 `code=400`,HTTP 状态保持 200,与仓库既有 26 个控制器的约定一致
|
||||
@@ -0,0 +1,322 @@
|
||||
# 定时任务管理 Spec
|
||||
|
||||
## Why
|
||||
|
||||
系统已存在多处需要周期性执行的后台作业(月度物化视图刷新、日志清理,后续还有 ICC 数据拉取、电工资质到期扫描),但当前调度能力分散在数据库 `pg_cron` 与应用内 `@midwayjs/cron` 注解中:运维无法在管理端查看任务、启停、调整执行频次,也无法追溯「什么时候执行过、成功还是失败」。需要在管理端提供统一的定时任务管理能力。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增 2 张表:`sys_job`(任务定义与调度配置)、`sys_job_log`(执行日志)
|
||||
- 后端新增「内置任务注册表」+ 自研轻量调度器:任务清单由代码注册表定义,cron / 参数 / 启停 / 重试配置持久化在 DB,页面修改后**即时生效**(无需重启)
|
||||
- 新增 2 个内置任务:`demo_heartbeat`(示例心跳,用于验证链路)、`log_clean`(清理操作日志 + 任务日志,默认保留 90 天)
|
||||
- 新增接口 `/api/jobs` 系列:列表、更新配置、启停、立即执行一次、执行日志分页、手动清理日志
|
||||
- 系统管理下新增「定时任务」菜单(`sys_menu.id = 47`)+ 3 个按钮权限(48-50)
|
||||
- 前端新增页面 `client/src/views/system/job/index.vue`:任务列表 + 编辑弹窗 + 执行日志抽屉,并在路由注册
|
||||
- 不修改现有表结构与现有接口(`/api/logs/clear` 手工清空日志能力保留)
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs:系统管理(RBAC 菜单/权限)、操作日志(新增自动清理来源)
|
||||
- Affected code:
|
||||
- 后端:`server/src/entity/`、`server/src/service/`、`server/src/controller/`、`server/src/dto/`、`server/src/task/`、`server/src/configuration.ts`、`server/src/middleware/rbac.middleware.ts`、`server/package.json`
|
||||
- 前端:`client/src/api/`、`client/src/views/system/job/`、`client/src/router/index.ts`
|
||||
- 数据库:`database/migrations/20261002_job_management.sql`、`database/db_seed.sql`
|
||||
|
||||
### 已确认决策(来自需求澄清)
|
||||
|
||||
1. 形态:**内置任务管理**——任务清单由代码定义,页面只做启停 / 改 cron / 改参数 / 立即执行 / 看日志;不做「页面任意填写调用目标」的通用调度中心
|
||||
2. 调度引擎:**自研轻量调度器**(配置持久化在 DB,进程内调度),不使用 `@midwayjs/cron` 注解做静态任务
|
||||
3. 首批任务:框架 + 示例任务 + 清理类任务;**ICC 用电数据采集本期不纳入**
|
||||
4. 执行日志:独立日志表 + 页面分页查询 + 手动清理
|
||||
5. 参数:**有限参数可编辑**(按任务注册表声明的字段白名单渲染表单,不开放任意 JSON)
|
||||
6. 失败处理:支持重试,**重试次数与重试间隔均可按任务配置**
|
||||
7. 日志保留期:操作日志 + 任务日志均默认 90 天,保留天数作为任务参数可在页面调整
|
||||
|
||||
### 假设(如与预期不符请在开工前指出)
|
||||
|
||||
1. **单实例部署**:并发控制使用进程内重入锁,不实现分布式锁(多实例部署时同一任务会在每个实例各触发一次)。这与初始选项描述中的「Redis 锁」有偏差:当前代码库 `server/src` 内没有任何 Redis/ioredis 使用点(仅 `config.default.ts` 声明了 redis 配置),引入会使任务运行强依赖 Redis 可用性。
|
||||
2. **不做执行超时强制中断**(Node 侧无法安全中断同步逻辑),仅在日志中记录执行耗时。
|
||||
3. **立即执行接口同步返回执行结果**(请求内等待任务跑完),适配本期两个秒级内置任务;后续接入长耗时任务时再改异步触发。
|
||||
4. **权限粒度**:仅新增菜单级权限 `system:job`(后端 RBAC 真实生效);3 个按钮权限码只用于前端按钮显隐,后端不做按钮级校验。
|
||||
5. **不提供任务新增/删除**:任务清单由代码注册表决定,页面无删除入口。
|
||||
6. **与并行规格共存**:`add-om-pricing-and-worker-registry` 规格正在并行实现,其中用 `@midwayjs/cron` 注解实现了电工资质到期扫描(`server/src/task/cert_expire.task.ts`)。本期**不改造**它,两套机制并存;后续可把该任务迁入本任务注册表统一管理(本期非目标)。
|
||||
7. **无独立 UI 设计稿**:页面沿用现有约定(搜索栏 + 表格 + 新增/编辑弹窗),与「操作日志」「数据字典」页一致。
|
||||
|
||||
### 非目标(本期不做)
|
||||
|
||||
- 通用调度中心(页面填写 Bean/方法名调用目标、动态执行任意代码)
|
||||
- ICC 用电数据采集任务(接口未定,后续单独开规格)
|
||||
- 任务编排/依赖、任务分组、任务执行结果通知推送
|
||||
- 多实例分布式互斥、任务超时强制中断、任务优先级与队列
|
||||
- 把 `pg_cron` 中的分区维护/物化视图刷新任务迁移到应用侧
|
||||
- 改造 `@midwayjs/cron` 注解任务(`cert_expire.task.ts`)
|
||||
|
||||
---
|
||||
|
||||
## 技术约定(本期实现必须遵守)
|
||||
|
||||
- 技术栈不变:Midway.js 3.x (Koa) + Sequelize-typescript + PostgreSQL;Vue3 + Ant Design Vue 4 + Vite
|
||||
- `sequelize.sync = false`(见 [config.default.ts](file:///d:/MyProject/智能电网/smart_grid_iot/server/src/config/config.default.ts#L26-L52)),建表必须通过 `database/migrations/*.sql` 完成,并同步 `database/db_seed.sql`
|
||||
- cron 解析复用项目已安装的 `cron` 包(`node_modules/cron@3.5.0`,为 `@midwayjs/cron` 的传递依赖,已在 `server/yarn.lock` 中):在 `server/package.json` 中**显式声明** `"cron": "^3.5.0"` 后使用,不引入其它新依赖
|
||||
- 所有 SQL 走 Sequelize 参数化 API,禁止字符串拼接
|
||||
- 所有列表接口使用 `limit + offset` 分页;控制器入参使用 DTO + `class-validator`
|
||||
- 统一返回结构 `{ code, message, data }`,与现有 service 一致
|
||||
- 前端不使用 `v-html`
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 任务定义与调度配置持久化(`sys_job`)
|
||||
|
||||
系统 SHALL 提供 `sys_job` 表持久化内置任务的调度配置。
|
||||
|
||||
字段:
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| id | BIGSERIAL PK | 自增主键 |
|
||||
| job_key | VARCHAR(64) NOT NULL UNIQUE | 任务键,与代码注册表一一对应(如 `log_clean`) |
|
||||
| job_name | VARCHAR(64) NOT NULL | 任务名称 |
|
||||
| cron_expression | VARCHAR(64) NOT NULL | cron 表达式(分 时 日 月 周,支持 6 段带秒) |
|
||||
| params | JSONB NOT NULL DEFAULT '{}' | 任务参数(受白名单字段约束) |
|
||||
| retry_times | SMALLINT NOT NULL DEFAULT 0 | 失败后最大重试次数(0-5) |
|
||||
| retry_interval | INT NOT NULL DEFAULT 60 | 失败后重试间隔(秒,10-3600) |
|
||||
| status | SMALLINT NOT NULL DEFAULT 1 | 1 启用 / 0 停用 |
|
||||
| remark | VARCHAR(255) | 备注 |
|
||||
| last_run_at | TIMESTAMPTZ | 上次执行时间 |
|
||||
| last_run_status | SMALLINT | 1 成功 / 2 失败 / 3 跳过 |
|
||||
| last_run_cost_ms | INT | 上次执行耗时(毫秒) |
|
||||
| last_error | TEXT | 上次失败原因(成功时清空) |
|
||||
| created_at / updated_at | TIMESTAMPTZ | 时间戳 |
|
||||
|
||||
索引:`sys_job(job_key)` 唯一索引、`sys_job(status)` 普通索引。
|
||||
|
||||
#### Scenario: 新库初始化与存量库迁移后结构一致
|
||||
- **WHEN** 在空库执行 `db_init.sql` + `db_seed.sql`,或在存量库执行 `database/migrations/20261002_job_management.sql`
|
||||
- **THEN** `sys_job` 表存在且含上述全部列与索引,且迁移脚本可重复执行无报错
|
||||
|
||||
#### Scenario: 内置任务种子数据
|
||||
- **WHEN** 种子数据/迁移执行完成
|
||||
- **THEN** `sys_job` 中存在 `demo_heartbeat` 与 `log_clean` 两条启用状态记录,重复执行不产生重复行(按 `job_key` 幂等)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 内置任务注册表
|
||||
|
||||
系统 SHALL 通过代码注册表声明内置任务,注册表条目包含:`jobKey`、`jobName`、`description`、`defaultCron`、`defaultParams`、`paramFields`(可编辑参数白名单:key / label / 类型 / 最小值 / 最大值 / 默认值)、`handler(params, context)`。
|
||||
|
||||
#### Scenario: 新增内置任务
|
||||
- **WHEN** 开发者在注册表中新增一个任务条目并在迁移/种子中插入对应 `sys_job` 行
|
||||
- **THEN** 页面列表自动出现该任务,无需改动前端代码(参数表单由 `paramFields` 驱动渲染)
|
||||
|
||||
#### Scenario: job_key 未注册
|
||||
- **WHEN** 列表中的任务 `job_key` 在注册表中不存在
|
||||
- **THEN** 列表仍展示该行但标记为「未注册」,编辑/启停/立即执行均返回 400 并提示「任务实现不存在」
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 自研轻量调度器(动态生效)
|
||||
|
||||
系统 SHALL 在应用启动时加载全部 `status = 1` 的 `sys_job` 并注册调度;页面修改 cron / 启停后无需重启即生效。
|
||||
|
||||
- 调度器 SHALL 使用 `cron` 包的 `CronJob` 承载定时触发,调度时区固定 `Asia/Shanghai`
|
||||
- 调度器 SHALL 在接收到配置变更(更新/启停)后,对单个任务执行 `stop → 重新注册 → start`(`reload(jobKey)`)
|
||||
- 调度器启动 SHALL 自行捕获异常并重试(最多 3 次、间隔 5 秒),不得因调度初始化失败阻塞应用启动
|
||||
- 应用停止(`onStop`)SHALL 停止全部定时器
|
||||
- 列表接口 SHALL 返回由 cron 表达式即时计算的 `nextRunTime`(停用或表达式非法时为 `null`)
|
||||
- 修改 cron 表达式时 SHALL 先校验表达式合法性,非法则返回 400「cron 表达式不合法」
|
||||
|
||||
#### Scenario: 启动加载
|
||||
- **WHEN** 应用启动完成且 `sys_job` 中存在启用任务
|
||||
- **THEN** 启用任务按各自 cron 被调度执行;停用任务不被调度
|
||||
|
||||
#### Scenario: 页面改 cron 即时生效
|
||||
- **WHEN** 某启用任务的 cron 由 `0 3 * * *` 改为 `*/1 * * * *` 并保存
|
||||
- **THEN** 无需重启,约 1 分钟内按新表达式触发执行,列表「下次执行时间」同步更新
|
||||
|
||||
#### Scenario: 停用任务
|
||||
- **WHEN** 任务被停用
|
||||
- **THEN** 定时触发停止,列表「下次执行时间」为 `-`;仍可手动「立即执行」
|
||||
|
||||
#### Scenario: 保存非法 cron
|
||||
- **WHEN** 提交 `0 3 * *`(字段不足)或非法字符
|
||||
- **THEN** 接口返回 400 且 message 明确提示表达式不合法,配置未被写入
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 任务执行、并发控制与可配置重试
|
||||
|
||||
系统 SHALL 按「一次执行一条日志」的粒度记录任务执行,并对同一任务做重入保护。
|
||||
|
||||
- 同一 `job_key` 正在执行时再次被触发:本次**跳过**,写一条 `status = 3`(跳过)日志,`message` 为「上一次执行尚未结束,本次跳过」,不执行 handler
|
||||
- 每次执行 SHALL 记录:任务、触发方式(1 定时自动 / 2 手动触发 / 3 失败重试)、尝试次数 `attempt`、开始/结束时间、耗时、状态、结果摘要、异常堆栈(截断存储)
|
||||
- 执行失败时:若 `attempt <= retry_times`,则在 `retry_interval` 秒后以「失败重试」触发方式再次执行(`attempt + 1`),重试期间该任务仍视为执行中;超出重试次数则本次执行以失败收尾
|
||||
- 执行完成后 SHALL 回写任务的 `last_run_at` / `last_run_status` / `last_run_cost_ms` / `last_error`
|
||||
|
||||
#### Scenario: 执行成功
|
||||
- **WHEN** 任务执行完成且无异常
|
||||
- **THEN** 生成一条 `status = 1` 日志,`cost_ms` 有值,`sys_job.last_run_status = 1` 且 `last_error` 被清空
|
||||
|
||||
#### Scenario: 失败按配置重试
|
||||
- **WHEN** 任务 `retry_times = 2`、`retry_interval = 30`,首次执行抛异常
|
||||
- **THEN** 30 秒后自动重试(日志 `trigger_type = 3`、`attempt = 2`),最多重试到 `attempt = 3`;全部失败后 `sys_job.last_run_status = 2` 并记录最后一次错误
|
||||
|
||||
#### Scenario: 并发跳过
|
||||
- **WHEN** 上一次执行未结束时该任务被再次触发
|
||||
- **THEN** 不并发执行,新增一条 `status = 3` 的跳过日志
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 立即执行一次
|
||||
|
||||
系统 SHALL 支持在页面手动触发任务。
|
||||
|
||||
- `POST /api/jobs/:id/run` SHALL 同步执行任务并返回本次执行结果(状态、耗时、摘要/错误)
|
||||
- 手动执行不受任务启停状态限制(停用任务也可手动执行)
|
||||
|
||||
#### Scenario: 手动执行成功
|
||||
- **WHEN** 对停用任务点击「立即执行」
|
||||
- **THEN** 接口返回 200 与执行结果,新增一条 `trigger_type = 2` 的日志,列表「上次执行」信息刷新
|
||||
|
||||
#### Scenario: 手动执行失败
|
||||
- **WHEN** 手动执行时任务抛异常且已配置重试
|
||||
- **THEN** 与定时触发一致地按配置重试,接口最终返回失败结果与错误信息
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 执行日志持久化与查询(`sys_job_log`)
|
||||
|
||||
系统 SHALL 提供 `sys_job_log` 表记录执行日志,并提供分页查询与手动清理。
|
||||
|
||||
字段:`id`、`job_id`、`job_key`、`job_name`、`trigger_type`、`status`、`attempt`、`started_at`、`finished_at`、`cost_ms`、`message`(TEXT)、`error_stack`(TEXT,截断至 4000 字符)、`created_at`;索引:`sys_job_log(job_id, created_at DESC)`、`sys_job_log(created_at)`。
|
||||
|
||||
- `GET /api/jobs/:id/logs`:分页,支持 `page`、`pageSize`、`status`、`triggerType`、`startDate`、`endDate` 筛选
|
||||
- `DELETE /api/jobs/logs/clear?days=90`:清理早于 `days` 天的任务日志,返回删除条数(与现有 `/api/logs/clear` 风格一致)
|
||||
|
||||
#### Scenario: 日志分页查询
|
||||
- **WHEN** 在页面日志抽屉中按状态「失败」筛选
|
||||
- **THEN** 仅返回该任务 `status = 2` 的日志,按 `created_at` 倒序分页
|
||||
|
||||
#### Scenario: 手动清理日志
|
||||
- **WHEN** 调用清理接口且 `days = 90`
|
||||
- **THEN** 早于 90 天的任务日志被删除,返回删除条数,当前日志不受影响
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 内置任务「示例心跳」(`demo_heartbeat`)
|
||||
|
||||
系统 SHALL 内置一个用于验证调度链路的示例任务:默认 cron 为 `*/5 * * * *`(每 5 分钟),默认参数 `{ "message": "heartbeat" }`,handler 写入一条 `info` 级日志并以摘要形式返回本次心跳时间。该任务默认启用。
|
||||
|
||||
#### Scenario: 心跳按周期执行
|
||||
- **WHEN** 应用启动且该任务处于启用状态
|
||||
- **THEN** 每 5 分钟产生一条成功执行日志,可在页面日志抽屉中查到
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 内置任务「日志清理」(`log_clean`)
|
||||
|
||||
系统 SHALL 内置日志清理任务:默认 cron 为 `0 3 * * *`(每日 03:00),默认参数 `{ "keepDays": 90 }`,保留天数可在页面调整(白名单参数,范围 1-3650)。
|
||||
|
||||
handler SHALL 同时清理:
|
||||
1. `sys_operation_log` 中早于 `keepDays` 的记录(复用 `LogService.cleanExpiredLogs`)
|
||||
2. `sys_job_log` 中早于 `keepDays` 的记录
|
||||
|
||||
执行结果摘要 SHALL 形如「清理操作日志 12 条、任务日志 3 条」。
|
||||
|
||||
#### Scenario: 按保留天数清理
|
||||
- **WHEN** 任务以参数 `keepDays = 90` 执行
|
||||
- **THEN** 两张日志表中 90 天前的记录被删除,摘要中分别给出删除条数
|
||||
|
||||
#### Scenario: 参数越界校验
|
||||
- **WHEN** 页面把 `keepDays` 改为 `0` 或 `5000`
|
||||
- **THEN** 接口返回 400,提示取值范围(1-3650),配置未被写入
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 菜单与权限
|
||||
|
||||
系统 SHALL 在「系统管理」下新增定时任务菜单与按钮权限:
|
||||
|
||||
| id | parent_id | 名称 | 路径 | 组件 | 权限码 | 类型 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 47 | 6 | 定时任务 | `/system/job` | `system/job/index` | `system:job` | 2 菜单 |
|
||||
| 48 | 47 | 编辑定时任务 | NULL | NULL | `system:job:update` | 3 按钮 |
|
||||
| 49 | 47 | 立即执行 | NULL | NULL | `system:job:run` | 3 按钮 |
|
||||
| 50 | 47 | 清理日志 | NULL | NULL | `system:job:log:clear` | 3 按钮 |
|
||||
|
||||
- 菜单 id 从 47 起,避开 `add-om-pricing-and-worker-registry` 已占用的 34-46
|
||||
- ADMIN 角色(`sys_role.id = 1`)SHALL 绑定上述 4 个菜单;`sys_menu_id_seq` SHALL 重设为 50
|
||||
- `PERMISSION_MAP` SHALL 新增 `'/api/jobs': 'system:job'`
|
||||
|
||||
#### Scenario: 菜单可见性
|
||||
- **WHEN** 以 `admin` 登录
|
||||
- **THEN** 侧边栏「系统管理」下可见「定时任务」,点击进入 `/system/job`
|
||||
|
||||
#### Scenario: 权限拦截
|
||||
- **WHEN** 非 ADMIN 且未被授予 `system:job` 的用户访问 `/api/jobs`
|
||||
- **THEN** 返回 403
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 前端「定时任务」页面
|
||||
|
||||
系统 SHALL 提供 `client/src/views/system/job/index.vue`,路由 `/system/job`(`name: SystemJob`,`meta.permission = 'system:job'`),交互包含:
|
||||
|
||||
1. **筛选区**:任务名称(模糊)、状态(启用/停用)
|
||||
2. **任务表格**:任务名称、任务键、cron 表达式、参数摘要、状态开关、上次执行时间/状态/耗时、下次执行时间、重试配置(次数 × 间隔)、操作列
|
||||
3. **编辑弹窗**:cron 表达式、按 `paramFields` 渲染的参数表单项、重试次数、重试间隔、备注;保存前做前端必填与范围校验
|
||||
4. **立即执行**:二次确认后调用执行接口,成功/失败以 `message` 提示,随后刷新列表与日志
|
||||
5. **执行日志抽屉**:分页表格(触发方式、状态、尝试次数、耗时、开始时间、摘要/错误),支持按状态筛选与「清理日志」按钮(需二次确认,默认清理 90 天前)
|
||||
|
||||
#### Scenario: 编辑并保存 cron
|
||||
- **WHEN** 在编辑弹窗修改 cron 与参数并保存
|
||||
- **THEN** 弹窗关闭、列表刷新显示新值,后端返回错误(如非法 cron / 参数越界)时弹窗保留并提示 message
|
||||
|
||||
#### Scenario: 查看执行日志
|
||||
- **WHEN** 点击某任务的「执行日志」
|
||||
- **THEN** 抽屉打开并加载该任务日志首页,可翻页与筛选
|
||||
|
||||
#### Scenario: 前端未使用 v-html
|
||||
- **WHEN** 检查页面实现
|
||||
- **THEN** 无 `v-html`,cron 与参数输入均有长度/范围校验
|
||||
|
||||
---
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: RBAC 路径权限映射
|
||||
|
||||
[`server/src/middleware/rbac.middleware.ts`](file:///d:/MyProject/智能电网/smart_grid_iot/server/src/middleware/rbac.middleware.ts#L10-L29) 的 `PERMISSION_MAP` 在保持既有映射不变的前提下,新增:
|
||||
|
||||
```
|
||||
'/api/jobs': 'system:job',
|
||||
```
|
||||
|
||||
字典 key 顺序需保证更具体的前缀优先匹配(`/api/jobs` 与其子路径共用同一权限码,无冲突)。
|
||||
|
||||
### Requirement: 系统管理菜单
|
||||
|
||||
「系统管理」(`sys_menu.id = 6`)子菜单由 5 个扩展为 6 个,新增 id = 47 的「定时任务」;新增菜单不得改变既有 id 7-11 的名称、路径、排序(1-5),新菜单 `sort_order = 6`。
|
||||
|
||||
---
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
无。
|
||||
|
||||
---
|
||||
|
||||
## 关键文件清单(供实现参考)
|
||||
|
||||
| 层 | 文件 |
|
||||
| --- | --- |
|
||||
| 迁移 | `database/migrations/20261002_job_management.sql` |
|
||||
| 种子 | `database/db_seed.sql`(新增菜单 47-50、ADMIN 绑定、2 条 `sys_job` 种子、`sys_menu_id_seq` → 50) |
|
||||
| 实体 | `server/src/entity/sys_job.entity.ts`、`server/src/entity/sys_job_log.entity.ts` |
|
||||
| 注册表 | `server/src/task/job_registry.ts`(内置任务声明)、`server/src/task/demo_heartbeat.task.ts`、`server/src/task/log_clean.task.ts` |
|
||||
| 调度器 | `server/src/service/job_scheduler.service.ts` |
|
||||
| 业务 | `server/src/service/job.service.ts` |
|
||||
| 接口 | `server/src/controller/job.controller.ts`、`server/src/dto/job.dto.ts` |
|
||||
| 装配 | `server/src/configuration.ts`(onReady 启动调度、onStop 停止调度)、`server/src/middleware/rbac.middleware.ts`、`server/package.json`(显式声明 `cron`) |
|
||||
| 前端 | `client/src/api/job.ts`、`client/src/views/system/job/index.vue`、`client/src/router/index.ts` |
|
||||
@@ -0,0 +1,76 @@
|
||||
# Tasks
|
||||
|
||||
> 规格:`.trae/specs/add-scheduled-job-management/spec.md`
|
||||
> 说明:任务清单中「内置任务注册表」是本规格的核心抽象,Task 4 依赖它;Task 5-7 可并行。
|
||||
|
||||
- [x] Task 1: 数据库迁移与种子数据(全部后端任务的前置)
|
||||
- [x] SubTask 1.1: 新建 `database/migrations/20261002_job_management.sql`:创建 `sys_job`(含 `job_key` 唯一索引、`status` 索引)与 `sys_job_log`(含 `(job_id, created_at DESC)`、`created_at` 索引)两张表,风格对齐现有迁移(注释、幂等 `IF NOT EXISTS`)
|
||||
- [x] SubTask 1.2: 同一迁移内写入菜单:`sys_menu` 新增 id 47(定时任务,parent_id=6,menu_path `/system/job`,component `system/job/index`,permission `system:job`,menu_type=2,sort_order=6)、48/49/50(按钮权限 `system:job:update` / `system:job:run` / `system:job:log:clear`,parent_id=47,menu_type=3),使用 `ON CONFLICT (id) DO UPDATE` 幂等;为 ADMIN 角色(role_id=1)插入 `sys_role_menu` 绑定;`setval('sys_menu_id_seq', 50, true)`
|
||||
- [x] SubTask 1.3: 同一迁移内写入 `sys_job` 种子:`demo_heartbeat`(`*/5 * * * *`,params `{"message":"heartbeat"}`,status=1)与 `log_clean`(`0 3 * * *`,params `{"keepDays":90}`,status=1,retry_times=2、retry_interval=60),使用 `ON CONFLICT (job_key) DO NOTHING`
|
||||
- [x] SubTask 1.4: 同步修订 `database/db_seed.sql`:在现有菜单段落之后追加 47-50 菜单与 ADMIN 绑定、2 条 `sys_job` 种子,更新 `sys_menu_id_seq` 为 50 与文件头部注释中的菜单计数
|
||||
- [x] SubTask 1.5: 执行迁移脚本两次验证幂等(第二次无报错、无重复菜单/任务行)
|
||||
|
||||
- [x] Task 2: 实体层
|
||||
- [x] SubTask 2.1: 新建 `server/src/entity/sys_job.entity.ts`(列与 spec 中 `sys_job` 定义一致,`params` 用 `DataType.JSONB`,`timestamps: true`,字段名用 `field` 显式声明 snake_case,参考 `sysOperationLog.entity.ts` 风格)
|
||||
- [x] SubTask 2.2: 新建 `server/src/entity/sys_job_log.entity.ts`(`timestamps: true` 且仅 `created_at`,与 `sysOperationLog` 一致)
|
||||
|
||||
- [x] Task 3: 内置任务注册表与两个内置任务(依赖 Task 2)
|
||||
- [x] SubTask 3.1: `server/package.json` 显式声明 `"cron": "^3.5.0"` 并执行 `yarn install`(该版本已在 `node_modules` 与 `yarn.lock` 中存在,来自 `@midwayjs/cron` 传递依赖)
|
||||
- [x] SubTask 3.2: 新建 `server/src/task/job_registry.ts`:定义 `BuiltinJob` 类型(jobKey / jobName / description / defaultCron / defaultParams / paramFields / handler)与 `BUILTIN_JOBS` 数组、`getBuiltinJob(jobKey)`;`paramFields` 支持 `number` 类型与 min/max
|
||||
- [x] SubTask 3.3: 新建 `server/src/task/demo_heartbeat.task.ts`:`jobKey = demo_heartbeat`,默认 cron `*/5 * * * *`,参数 `message`(string,可选),handler 写 info 日志并返回「心跳时间」摘要
|
||||
- [x] SubTask 3.4: 新建 `server/src/task/log_clean.task.ts`:`jobKey = log_clean`,默认 cron `0 3 * * *`,参数 `keepDays`(number,1-3650,默认 90),handler 调用 `LogService.cleanExpiredLogs(keepDays)` 清理 `sys_operation_log`,再删除 `sys_job_log` 中早于 `keepDays` 的记录,返回「清理操作日志 X 条、任务日志 Y 条」
|
||||
- [x] SubTask 3.5: 在 `job_registry.ts` 中把两个任务注册进 `BUILTIN_JOBS`
|
||||
|
||||
- [x] Task 4: 调度器(依赖 Task 2、3)
|
||||
- [x] SubTask 4.1: 新建 `server/src/service/job_scheduler.service.ts`:
|
||||
- `start()`:加载 `status=1` 的 `sys_job` → 按 `job_key` 匹配注册表 → 用 `cron` 包 `CronJob`(timezone `Asia/Shanghai`)注册并 `start()`;内部 try/catch + 最多 3 次重试(间隔 5s),失败仅记录日志不阻塞启动
|
||||
- `stop()`:停止全部定时器(应用停止时调用)
|
||||
- `reload(jobKey)`:停旧 → 按最新配置重新注册(停用则不注册)
|
||||
- `runOnce(jobKey, triggerType)`:导出给 controller 的立即执行入口,返回执行结果
|
||||
- `nextRunTime(cronExpression)`:用 cron 表达式计算下次执行时间,非法或空返回 `null`
|
||||
- `validateCron(expression)`:校验表达式合法性
|
||||
- [x] SubTask 4.2: 实现执行内核:进程内 `Set<string>` 重入锁 → 未注册任务返回「任务实现不存在」→ 写入 `sys_job_log`(`trigger_type`、`attempt`、`started_at`)→ 执行 handler → 回写日志 `status`/`finished_at`/`cost_ms`/`message`/`error_stack`(截断 4000 字符)→ 回写 `sys_job` 的 `last_run_at`/`last_run_status`/`last_run_cost_ms`/`last_error`;并发触发时写 `status = 3` 跳过日志
|
||||
- [x] SubTask 4.3: 实现失败重试:`attempt <= retry_times` 时用 `setTimeout(retry_interval * 1000)` 以 `trigger_type = 3`、`attempt + 1` 再次执行,重试期间保持重入锁;重试链结束仍失败则以 `last_run_status = 2` 收尾
|
||||
- [x] SubTask 4.4: 修改 `server/src/configuration.ts`:`onReady` 中获取并 `start()` 调度服务(捕获异常),`onStop` 中 `stop()`;保持不变更其它已有逻辑
|
||||
|
||||
- [x] Task 5: 业务服务与接口(依赖 Task 4)
|
||||
- [x] SubTask 5.1: 新建 `server/src/dto/job.dto.ts`:`UpdateJobDTO`(cronExpression、params、retryTimes、retryInterval、remark,`class-validator` 校验)、`JobLogQueryDTO`(page/pageSize/status/triggerType/startDate/endDate)
|
||||
- [x] SubTask 5.2: 新建 `server/src/service/job.service.ts`:
|
||||
- `list(query)`:按任务名称模糊 + 状态筛选,返回 `list/total/page/pageSize`,每行附带注册表信息(`jobName`、`description`、`paramFields`、`registered`)、`nextRunTime`(由调度器计算)、上次执行信息
|
||||
- `update(id, dto)`:校验存在性与已注册 → 校验 cron 合法性 → 按 `paramFields` 白名单与范围校验 params(越界返回 400)→ 保存 → `scheduler.reload(jobKey)`
|
||||
- `changeStatus(id, status)`:切换启停 → `scheduler.reload(jobKey)`
|
||||
- `run(id)`:调用 `scheduler.runOnce(jobKey, 2)`
|
||||
- `listLogs(id, query)`:分页 + 状态/触发方式/时间范围筛选,`created_at DESC`
|
||||
- `clearLogs(days = 90)`:删除早于 `days` 天的 `sys_job_log`
|
||||
- [x] SubTask 5.3: 新建 `server/src/controller/job.controller.ts`(`/api/jobs`,注意静态路径写在动态路径之前):
|
||||
- `GET /api/jobs`、`PUT /api/jobs/:id`、`PUT /api/jobs/:id/status`、`POST /api/jobs/:id/run`
|
||||
- `GET /api/jobs/:id/logs`、`DELETE /api/jobs/logs/clear`
|
||||
- 返回统一 `{ code, message, data }`,400/404 场景给出明确 message
|
||||
- [x] SubTask 5.4: 修改 `server/src/middleware/rbac.middleware.ts`:`PERMISSION_MAP` 新增 `'/api/jobs': 'system:job'`
|
||||
|
||||
- [x] Task 6: 前端 API 与页面(依赖 Task 5)
|
||||
- [x] SubTask 6.1: 新建 `client/src/api/job.ts`(`getJobList` / `updateJob` / `changeJobStatus` / `runJob` / `getJobLogs` / `clearJobLogs`,含 TS 类型,参考 `api/log.ts` 风格)
|
||||
- [x] SubTask 6.2: 新建 `client/src/views/system/job/index.vue`:搜索栏(任务名称、状态)+ 任务表格(任务名/键、cron、参数摘要、状态开关、上次执行时间与状态与耗时、下次执行时间、重试配置、操作)+ 编辑弹窗(cron、按 `paramFields` 动态渲染的参数项、重试次数、重试间隔、备注)+ 立即执行二次确认 + 执行日志抽屉(分页表格、状态筛选、清理日志按钮),保持与 `views/log/operation/index.vue` 一致的页面结构
|
||||
- [x] SubTask 6.3: 修改 `client/src/router/index.ts`:在系统管理下新增路由 `system/job`(`name: SystemJob`,`meta: { requiresAuth: true, title: '定时任务', permission: 'system:job' }`)
|
||||
|
||||
- [x] Task 7: 联调与验证(依赖 Task 1-6)
|
||||
- [x] SubTask 7.1: 后端 `yarn dev` 启动无编译错误;`curl` 验证列表/更新/启停/立即执行/日志查询/日志清理接口
|
||||
- [x] SubTask 7.2: 将 `demo_heartbeat` 改为 `*/1 * * * *` 验证「改 cron 即时生效」与「下次执行时间」更新;停用后验证不再触发、恢复启用后重新调度(首轮验证失败 → 见 Task 8;修复后复测:每分钟恰好触发 1 次、停用后 0 次触发、手动执行正常、恢复后 `nextRunTime` 正确)
|
||||
- [x] SubTask 7.3: 验证重试:临时构造失败场景(如 `log_clean` 传入非法参数不可行时,用 `retry_times`/`retry_interval` 观测日志 `trigger_type = 3` 与 `attempt` 递增)
|
||||
- [x] SubTask 7.4: 前端 `yarn dev` 启动,人工走通「编辑 → 立即执行 → 查看日志 → 清理日志」全链路,并确认侧边栏「系统管理 → 定时任务」可见(实测:侧边栏菜单可见、`/system/job` 正常进入、两行任务渲染正确、编辑保存成功、立即执行二次确认与成功提示、日志抽屉含手动触发的记录并带分页、清理日志二次确认成功;新标签页控制台无 error)
|
||||
- [x] SubTask 7.5: 确认迁移脚本与 `db_seed.sql` 在空库/存量库两端结果一致,且 `sys_menu_id_seq` 为 50
|
||||
|
||||
- [x] Task 8: 修复调度器作用域缺陷(Task 7.2 首轮验证发现的真实缺陷)
|
||||
- [x] SubTask 8.1: 定位根因 —— `@Provide()` 在本项目 Midway 版本中默认解析为 **Request 作用域**(`@midwayjs/core` 的 `container.bind()` 对 class 目标亦执行 `definition.scope = options?.scope || ScopeEnum.Request`),导致「启动注册定时器」与「接口 `reload`」落在不同服务实例上:`reload` 所在实例的 `timers` 为空,既停不掉旧定时器,又新建一个定时器,每次保存/启停都泄漏一个常驻定时器(实测同一分钟内出现 2 次以上重复触发,且停用后仍持续触发)
|
||||
- [x] SubTask 8.2: 为 `JobSchedulerService` 显式声明 `@Scope(ScopeEnum.Singleton)`,使定时器注册表在启动注册与接口重载之间共享(`@InjectRepository` 由单例数据源管理器解析,无需请求上下文,可安全用于单例)
|
||||
- [x] SubTask 8.3: `LogService` 同为请求作用域,单例中不可直接 `@Inject`;改为在 `buildRuntime()` 内通过 `this.app.createAnonymousContext().requestContext.getAsync(LogService)` 按需获取(对齐 `@midwayjs/cron` 官方执行任务的上下文用法),注入 `@App()` 替换原 `@Inject() logService`
|
||||
- [x] SubTask 8.4: 清理定位期间加入的临时诊断日志,`npx tsc --noEmit` 通过,并在隔离单实例(7009)下复测 7.2 全部通过
|
||||
|
||||
# Task Dependencies
|
||||
|
||||
- Task 2 依赖 Task 1(表结构存在后才能通过实体读写)
|
||||
- Task 3 依赖 Task 2;Task 4 依赖 Task 2、3
|
||||
- Task 5 依赖 Task 4;Task 6 依赖 Task 5
|
||||
- Task 7 依赖 Task 1-6
|
||||
- Task 8 依赖 Task 7.2 的首轮失败验证;Task 8 完成后需回归 Task 7.2
|
||||
- 并行建议:Task 1 完成后,Task 2/3 与前端 Task 6.1(API 层)可并行;Task 5 与 Task 6.2/6.3 可在接口契约冻结后并行
|
||||
@@ -0,0 +1,61 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
export interface DispatchPriceInfo {
|
||||
id: number;
|
||||
priceCode: string;
|
||||
dispatchType: string;
|
||||
priceType?: string;
|
||||
priceMode: number;
|
||||
unitPrice?: number;
|
||||
unit?: string;
|
||||
status: number;
|
||||
remark?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DispatchPriceQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
dispatchType?: string;
|
||||
priceMode?: number;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface DispatchPriceListResult {
|
||||
list: DispatchPriceInfo[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface DispatchPricePayload {
|
||||
dispatchType?: string;
|
||||
priceType?: string;
|
||||
priceMode?: number;
|
||||
unitPrice?: number;
|
||||
unit?: string;
|
||||
status?: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export const dispatchPriceApi = {
|
||||
getDispatchPriceList: (params: DispatchPriceQuery) =>
|
||||
request.get<DispatchPriceListResult>('/dispatch-prices', { params }),
|
||||
|
||||
getDispatchPriceDetail: (id: number) =>
|
||||
request.get<DispatchPriceInfo>(`/dispatch-prices/${id}`),
|
||||
|
||||
createDispatchPrice: (data: DispatchPricePayload) =>
|
||||
request.post('/dispatch-prices', data),
|
||||
|
||||
updateDispatchPrice: (id: number, data: DispatchPricePayload) =>
|
||||
request.put(`/dispatch-prices/${id}`, data),
|
||||
|
||||
deleteDispatchPrice: (id: number) =>
|
||||
request.delete(`/dispatch-prices/${id}`),
|
||||
|
||||
updateDispatchPriceStatus: (id: number, status: number) =>
|
||||
request.put(`/dispatch-prices/${id}/status`, { status }),
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
export interface JobParamField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'number' | 'string';
|
||||
min?: number;
|
||||
max?: number;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface JobItem {
|
||||
id: number;
|
||||
jobKey: string;
|
||||
jobName: string;
|
||||
cronExpression: string;
|
||||
params: Record<string, any>;
|
||||
retryTimes: number;
|
||||
retryInterval: number;
|
||||
status: number;
|
||||
remark?: string;
|
||||
lastRunAt?: string | null;
|
||||
lastRunStatus?: number | null;
|
||||
lastRunCostMs?: number | null;
|
||||
lastError?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
description?: string;
|
||||
paramFields?: JobParamField[];
|
||||
registered: boolean;
|
||||
nextRunTime?: string | null;
|
||||
}
|
||||
|
||||
export interface JobLogItem {
|
||||
id: number;
|
||||
jobId: number;
|
||||
jobKey: string;
|
||||
jobName: string;
|
||||
triggerType: number;
|
||||
status: number;
|
||||
attempt: number;
|
||||
startedAt?: string | null;
|
||||
finishedAt?: string | null;
|
||||
costMs?: number | null;
|
||||
message?: string | null;
|
||||
errorStack?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
export interface JobRunResult {
|
||||
status: number;
|
||||
message: string;
|
||||
costMs: number;
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
export interface JobQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
jobName?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface JobLogQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: number;
|
||||
triggerType?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export interface UpdateJobPayload {
|
||||
cronExpression: string;
|
||||
params: Record<string, any>;
|
||||
retryTimes: number;
|
||||
retryInterval: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface JobListResult {
|
||||
list: JobItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface JobLogListResult {
|
||||
list: JobLogItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export const jobApi = {
|
||||
getJobList: (params: JobQuery) =>
|
||||
request.get<JobListResult>('/jobs', { params }),
|
||||
|
||||
updateJob: (id: number, data: UpdateJobPayload) =>
|
||||
request.put<JobItem>(`/jobs/${id}`, data),
|
||||
|
||||
changeJobStatus: (id: number, status: 0 | 1) =>
|
||||
request.put<JobItem>(`/jobs/${id}/status`, { status }),
|
||||
|
||||
runJob: (id: number) =>
|
||||
request.post<JobRunResult>(`/jobs/${id}/run`),
|
||||
|
||||
getJobLogs: (id: number, params: JobLogQuery) =>
|
||||
request.get<JobLogListResult>(`/jobs/${id}/logs`, { params }),
|
||||
|
||||
clearJobLogs: (days = 90) =>
|
||||
request.delete<{ deleted: number }>('/jobs/logs/clear', { params: { days } }),
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
export interface NoticeInfo {
|
||||
id: number;
|
||||
noticeType: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
bizType?: string;
|
||||
bizId?: number;
|
||||
// 是否已读:0 未读 / 1 已读
|
||||
isRead: number;
|
||||
createdAt?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface NoticeQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
isRead?: number;
|
||||
}
|
||||
|
||||
export interface NoticeListResult {
|
||||
list: NoticeInfo[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface NoticeUnreadCount {
|
||||
count: number;
|
||||
}
|
||||
|
||||
// 站内通知(消息中心)
|
||||
export const noticeApi = {
|
||||
getList: (params: NoticeQuery) => request.get<NoticeListResult>('/notices', { params }),
|
||||
|
||||
getUnreadCount: () => request.get<NoticeUnreadCount>('/notices/unread-count'),
|
||||
|
||||
readAll: () => request.put('/notices/read-all'),
|
||||
|
||||
markRead: (id: number) => request.put(`/notices/${id}/read`),
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
export interface ServicePriceInfo {
|
||||
id: number;
|
||||
priceCode: string;
|
||||
priceName: string;
|
||||
serviceType?: string;
|
||||
priceMode: number;
|
||||
unitPrice: number;
|
||||
unit?: string;
|
||||
status: number;
|
||||
remark?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ServicePriceQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
priceMode?: number;
|
||||
serviceType?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface ServicePriceListResult {
|
||||
list: ServicePriceInfo[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ServicePricePayload {
|
||||
priceName?: string;
|
||||
serviceType?: string;
|
||||
priceMode?: number;
|
||||
unitPrice?: number;
|
||||
unit?: string;
|
||||
status?: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export const servicePriceApi = {
|
||||
getServicePriceList: (params: ServicePriceQuery) =>
|
||||
request.get<ServicePriceListResult>('/service-prices', { params }),
|
||||
|
||||
getServicePriceDetail: (id: number) =>
|
||||
request.get<ServicePriceInfo>(`/service-prices/${id}`),
|
||||
|
||||
createServicePrice: (data: ServicePricePayload) =>
|
||||
request.post('/service-prices', data),
|
||||
|
||||
updateServicePrice: (id: number, data: ServicePricePayload) =>
|
||||
request.put(`/service-prices/${id}`, data),
|
||||
|
||||
deleteServicePrice: (id: number) =>
|
||||
request.delete(`/service-prices/${id}`),
|
||||
|
||||
updateServicePriceStatus: (id: number, status: number) =>
|
||||
request.put(`/service-prices/${id}/status`, { status }),
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
// 资质状态:none 无证书 / normal 正常 / expiring 临期 / expired 已过期
|
||||
export type WorkerCertStatus = 'none' | 'normal' | 'expiring' | 'expired';
|
||||
|
||||
// 审核状态:0 待审核 / 1 已通过 / 2 已驳回
|
||||
export type WorkerAuditStatus = 0 | 1 | 2;
|
||||
|
||||
// 证书行(详情接口返回完整证书,保存时按此结构提交)
|
||||
export interface WorkerCert {
|
||||
id?: number;
|
||||
workerId?: number;
|
||||
certType: string;
|
||||
certNo: string;
|
||||
issueAuthority?: string;
|
||||
issueDate?: string;
|
||||
expireDate?: string;
|
||||
certImageUrl?: string;
|
||||
}
|
||||
|
||||
// 列表接口返回的证书摘要
|
||||
export interface WorkerCertSummary {
|
||||
id: number;
|
||||
certType: string;
|
||||
certNo: string;
|
||||
expireDate?: string;
|
||||
}
|
||||
|
||||
export interface WorkerRecord {
|
||||
id: number;
|
||||
workerName: string;
|
||||
phone?: string;
|
||||
idCard?: string;
|
||||
workerTitle?: string;
|
||||
rating?: number | string;
|
||||
certInfo?: string;
|
||||
certVerified?: boolean;
|
||||
completedCount?: number;
|
||||
// 在岗状态:1 在岗 / 0 离职
|
||||
status: number;
|
||||
auditStatus: number;
|
||||
auditRemark?: string;
|
||||
auditBy?: number;
|
||||
auditAt?: string;
|
||||
lastLoginAt?: string;
|
||||
certStatus?: WorkerCertStatus;
|
||||
certs?: WorkerCertSummary[];
|
||||
createdAt?: string;
|
||||
created_at?: string;
|
||||
updatedAt?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface WorkerQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
status?: number;
|
||||
auditStatus?: number;
|
||||
certStatus?: WorkerCertStatus;
|
||||
}
|
||||
|
||||
export interface WorkerListResult {
|
||||
list: WorkerRecord[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// KPI:电工总数 / 待审核 / 临期 / 已过期
|
||||
export interface WorkerStats {
|
||||
total: number;
|
||||
pendingAudit: number;
|
||||
expiring: number;
|
||||
expired: number;
|
||||
}
|
||||
|
||||
export interface WorkerCertPayload {
|
||||
certType: string;
|
||||
certNo: string;
|
||||
issueAuthority?: string;
|
||||
issueDate?: string;
|
||||
expireDate?: string;
|
||||
certImageUrl?: string;
|
||||
}
|
||||
|
||||
export interface WorkerPayload {
|
||||
workerName: string;
|
||||
phone: string;
|
||||
idCard?: string;
|
||||
workerTitle?: string;
|
||||
status?: number;
|
||||
rating?: number;
|
||||
certInfo?: string;
|
||||
// 新增时是否同时开通电工 APP 账号(默认密码为手机号后 6 位)
|
||||
withAccount?: boolean;
|
||||
// 存在时整体覆盖该电工的全部证书
|
||||
certs?: WorkerCertPayload[];
|
||||
}
|
||||
|
||||
export interface WorkerAuditPayload {
|
||||
approved: boolean;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
// 管理端电工登记接口(不修改原 worker.ts,勘察派单页仍使用 workerApi)
|
||||
export const workerRegistryApi = {
|
||||
getList: (params: WorkerQuery) => request.get<WorkerListResult>('/workers', { params }),
|
||||
|
||||
getStats: () => request.get<WorkerStats>('/workers/stats'),
|
||||
|
||||
getDetail: (id: number) => request.get<WorkerRecord>(`/workers/${id}`),
|
||||
|
||||
create: (data: WorkerPayload) => request.post('/workers', data),
|
||||
|
||||
update: (id: number, data: Partial<WorkerPayload>) => request.put(`/workers/${id}`, data),
|
||||
|
||||
remove: (id: number) => request.delete(`/workers/${id}`),
|
||||
|
||||
audit: (id: number, data: WorkerAuditPayload) => request.put(`/workers/${id}/audit`, data),
|
||||
|
||||
addCert: (id: number, data: WorkerCertPayload) => request.post(`/workers/${id}/certs`, data),
|
||||
|
||||
updateCert: (id: number, certId: number, data: WorkerCertPayload) =>
|
||||
request.put(`/workers/${id}/certs/${certId}`, data),
|
||||
|
||||
removeCert: (id: number, certId: number) => request.delete(`/workers/${id}/certs/${certId}`),
|
||||
};
|
||||
@@ -48,6 +48,13 @@
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<!-- 消息中心铃铛:未读数为 0 时角标自动隐藏 -->
|
||||
<a-badge :count="unreadCount" :overflow-count="99" size="small">
|
||||
<div class="notice-bell" title="消息中心" @click="openNoticeDrawer">
|
||||
<BellOutlined />
|
||||
</div>
|
||||
</a-badge>
|
||||
|
||||
<!-- 用户信息下拉菜单 -->
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-space class="user-info">
|
||||
@@ -89,12 +96,43 @@
|
||||
</router-view>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<!-- 消息中心抽屉 -->
|
||||
<a-drawer v-model:open="noticeVisible" title="消息中心" placement="right" :width="440">
|
||||
<div class="notice-drawer">
|
||||
<div class="notice-toolbar">
|
||||
<span class="notice-summary">共 {{ noticeTotal }} 条通知,{{ unreadCount }} 条未读</span>
|
||||
<a-button type="link" size="small" :disabled="!unreadCount" @click="handleReadAll">全部已读</a-button>
|
||||
</div>
|
||||
<a-spin :spinning="noticeLoading">
|
||||
<div v-if="noticeList.length" class="notice-list">
|
||||
<div
|
||||
v-for="item in noticeList"
|
||||
:key="item.id"
|
||||
class="notice-item"
|
||||
:class="{ unread: item.isRead === 0 }"
|
||||
@click="handleNoticeClick(item)"
|
||||
>
|
||||
<div class="notice-item-head">
|
||||
<span v-if="item.isRead === 0" class="notice-dot"></span>
|
||||
<span class="notice-title">{{ item.title }}</span>
|
||||
<span class="notice-time">{{ formatNoticeTime(item) }}</span>
|
||||
</div>
|
||||
<p class="notice-content">{{ item.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-else description="暂无通知" />
|
||||
</a-spin>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, h, type Component, type VNode } from 'vue'
|
||||
import { ref, computed, watch, h, onMounted, onBeforeUnmount, type Component, type VNode } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
SettingOutlined,
|
||||
@@ -115,8 +153,10 @@ import {
|
||||
LogoutOutlined,
|
||||
LockOutlined,
|
||||
ThunderboltOutlined,
|
||||
BellOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { usePermissionStore, type MenuNode } from '@/stores/permission'
|
||||
import { noticeApi, type NoticeInfo } from '@/api/notice'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -290,6 +330,92 @@ function handleUserMenuClick({ key }: { key: string }) {
|
||||
permissionStore.logout()
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 消息中心 ====================
|
||||
const noticeVisible = ref(false)
|
||||
const noticeLoading = ref(false)
|
||||
const noticeList = ref<NoticeInfo[]>([])
|
||||
const noticeTotal = ref(0)
|
||||
const unreadCount = ref(0)
|
||||
const NOTICE_POLL_INTERVAL = 60000
|
||||
let noticeTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// 未读数轮询(顶栏红点)
|
||||
async function fetchUnreadCount() {
|
||||
try {
|
||||
const res = await noticeApi.getUnreadCount()
|
||||
if (res.code === 200) unreadCount.value = res.data?.count || 0
|
||||
} catch (error) {
|
||||
// 未读数拉取失败不阻断主界面
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNoticeList() {
|
||||
noticeLoading.value = true
|
||||
try {
|
||||
const res = await noticeApi.getList({ page: 1, pageSize: 20 })
|
||||
if (res.code === 200 && res.data) {
|
||||
noticeList.value = res.data.list || []
|
||||
noticeTotal.value = res.data.total || 0
|
||||
}
|
||||
} catch (error) {
|
||||
// 列表拉取失败不阻断抽屉
|
||||
} finally {
|
||||
noticeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openNoticeDrawer() {
|
||||
noticeVisible.value = true
|
||||
fetchNoticeList()
|
||||
}
|
||||
|
||||
async function handleNoticeClick(item: NoticeInfo) {
|
||||
if (item.isRead === 0) {
|
||||
try {
|
||||
await noticeApi.markRead(item.id)
|
||||
item.isRead = 1
|
||||
fetchUnreadCount()
|
||||
} catch (error) {
|
||||
// 标记失败不阻断后续跳转
|
||||
}
|
||||
}
|
||||
// 电工资质到期提醒 → 跳转电工登记管理
|
||||
if (item.bizType === 'worker_cert') {
|
||||
noticeVisible.value = false
|
||||
router.push('/dispatch/worker')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReadAll() {
|
||||
try {
|
||||
const res = await noticeApi.readAll()
|
||||
if (res.code === 200) {
|
||||
message.success('已全部标记为已读')
|
||||
noticeList.value = noticeList.value.map((n) => ({ ...n, isRead: 1 }))
|
||||
unreadCount.value = 0
|
||||
}
|
||||
} catch (error) {
|
||||
// 由请求拦截器统一提示
|
||||
}
|
||||
}
|
||||
|
||||
function formatNoticeTime(item: NoticeInfo) {
|
||||
const time = item.createdAt || item.created_at
|
||||
return time ? dayjs(time).format('YYYY-MM-DD HH:mm') : ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchUnreadCount()
|
||||
noticeTimer = setInterval(fetchUnreadCount, NOTICE_POLL_INTERVAL)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (noticeTimer) {
|
||||
clearInterval(noticeTimer)
|
||||
noticeTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@@ -458,6 +584,28 @@ function handleUserMenuClick({ key }: { key: string }) {
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
|
||||
.notice-bell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
color: #424655;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
background: rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
@@ -548,4 +696,92 @@ function handleUserMenuClick({ key }: { key: string }) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 消息中心抽屉(抽屉内容被 teleport 到 body,样式不可嵌套在 .admin-layout 内) =====
|
||||
.notice-drawer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.notice-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
|
||||
.notice-summary {
|
||||
font-size: 12px;
|
||||
color: #5b6b7e;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notice-item {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #eef1f6;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #dbe4ee;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
&.unread {
|
||||
background: #f4f7ff;
|
||||
border-color: #d6e4ff;
|
||||
}
|
||||
|
||||
.notice-item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.notice-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #ff4d4f;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0d1c2c;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notice-time {
|
||||
font-size: 11px;
|
||||
color: #8a97a8;
|
||||
flex-shrink: 0;
|
||||
font-family: Consolas, Menlo, monospace;
|
||||
}
|
||||
|
||||
.notice-content {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #5b6b7e;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -154,9 +154,27 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "dispatch",
|
||||
name: "Dispatch",
|
||||
component: () => import("@/views/Placeholder.vue"),
|
||||
redirect: "/dispatch/price",
|
||||
meta: { requiresAuth: true, title: "运营派单", permission: "dispatch:list" }
|
||||
},
|
||||
{
|
||||
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",
|
||||
@@ -194,6 +212,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/system/dict/index.vue"),
|
||||
meta: { requiresAuth: true, title: "数据字典", permission: "system:dict" }
|
||||
},
|
||||
{
|
||||
path: "system/job",
|
||||
name: "SystemJob",
|
||||
component: () => import("@/views/system/job/index.vue"),
|
||||
meta: { requiresAuth: true, title: "定时任务", permission: "system:job" }
|
||||
},
|
||||
{
|
||||
path: "log/operation",
|
||||
name: "OperationLog",
|
||||
|
||||
@@ -233,12 +233,58 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 电工资质到期提醒 -->
|
||||
<div class="remind-grid">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<div class="card-title-row">
|
||||
<h3 class="card-title">电工资质到期提醒</h3>
|
||||
<span class="card-tag">CERT ALERT</span>
|
||||
</div>
|
||||
<p class="card-sub">临期(30 天内)与已过期的电工资质证照清单,已过期优先展示</p>
|
||||
</div>
|
||||
<a class="card-link" @click="goWorkerRegistry">
|
||||
查看全部
|
||||
<ArrowRightOutlined />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table class="dw-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>电工姓名</th>
|
||||
<th>证书类型</th>
|
||||
<th>有效期至</th>
|
||||
<th class="ta-r">到期状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in certReminders" :key="row.key">
|
||||
<td class="strong">{{ row.workerName }}</td>
|
||||
<td>{{ row.certType }}</td>
|
||||
<td class="mono muted">{{ row.expireDate }}</td>
|
||||
<td class="ta-r">
|
||||
<span class="tag" :class="row.expired ? 'tag-danger' : 'tag-warn'">
|
||||
{{ row.expired ? `已过期 ${Math.abs(row.days)} 天` : `剩余 ${row.days} 天` }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-empty" v-if="!certReminders.length">暂无临期或已过期的电工资质</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, markRaw } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import {
|
||||
Button,
|
||||
Spin,
|
||||
@@ -258,8 +304,11 @@ import {
|
||||
ArrowRightOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import * as echarts from 'echarts';
|
||||
import dayjs from 'dayjs';
|
||||
import { dashboardApi, type DashboardStats } from '@/api/dashboard';
|
||||
import { workerRegistryApi } from '@/api/workerRegistry';
|
||||
|
||||
const router = useRouter();
|
||||
const AButton = Button;
|
||||
const ASpin = Spin;
|
||||
|
||||
@@ -272,6 +321,63 @@ let donutChart: echarts.ECharts | null = null;
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const lastUpdateTime = ref('--');
|
||||
|
||||
// ===== 电工资质到期提醒 =====
|
||||
interface CertReminderRow {
|
||||
key: string;
|
||||
workerId: number;
|
||||
workerName: string;
|
||||
certType: string;
|
||||
expireDate: string;
|
||||
days: number;
|
||||
expired: boolean;
|
||||
}
|
||||
const certReminders = ref<CertReminderRow[]>([]);
|
||||
|
||||
// 取临期与已过期各前 5 条电工,展开证书后按到期天数排序,已过期的排前面
|
||||
async function loadCertReminders() {
|
||||
try {
|
||||
const [expiredRes, expiringRes] = await Promise.all([
|
||||
workerRegistryApi.getList({ page: 1, pageSize: 5, certStatus: 'expired' }),
|
||||
workerRegistryApi.getList({ page: 1, pageSize: 5, certStatus: 'expiring' }),
|
||||
]);
|
||||
const today = dayjs().startOf('day');
|
||||
const buildRows = (list: any[], expiredFlag: boolean): CertReminderRow[] => {
|
||||
const rows: CertReminderRow[] = [];
|
||||
for (const worker of list || []) {
|
||||
for (const cert of worker.certs || []) {
|
||||
if (!cert.expireDate) continue;
|
||||
const expire = String(cert.expireDate).slice(0, 10);
|
||||
const days = dayjs(expire).startOf('day').diff(today, 'day');
|
||||
// 已过期组取 < 0,临期组取 0 ~ 30 天
|
||||
const inRange = expiredFlag ? days < 0 : days >= 0 && days <= 30;
|
||||
if (!inRange) continue;
|
||||
rows.push({
|
||||
key: `${worker.id}-${cert.id}`,
|
||||
workerId: Number(worker.id),
|
||||
workerName: worker.workerName,
|
||||
certType: cert.certType || '资质证书',
|
||||
expireDate: expire,
|
||||
days,
|
||||
expired: expiredFlag,
|
||||
});
|
||||
}
|
||||
}
|
||||
rows.sort((a, b) => a.days - b.days);
|
||||
return rows.slice(0, 5);
|
||||
};
|
||||
const expiredRows = buildRows(expiredRes.code === 200 ? expiredRes.data.list : [], true);
|
||||
const expiringRows = buildRows(expiringRes.code === 200 ? expiringRes.data.list : [], false);
|
||||
certReminders.value = [...expiredRows, ...expiringRows];
|
||||
} catch (error) {
|
||||
// 提醒数据加载失败不影响主看板
|
||||
certReminders.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function goWorkerRegistry() {
|
||||
router.push('/dispatch/worker');
|
||||
}
|
||||
|
||||
const totalStatus = computed(() =>
|
||||
(stats.value?.customerStatus || []).reduce((s, it) => s + it.value, 0)
|
||||
);
|
||||
@@ -396,6 +502,7 @@ async function loadStats() {
|
||||
lastUpdateTime.value = new Date().toLocaleTimeString('zh-CN');
|
||||
updateCharts();
|
||||
}
|
||||
await loadCertReminders();
|
||||
} catch (error) {
|
||||
console.error('加载仪表盘数据失败:', error);
|
||||
message.error('加载数据失败,请稍后重试');
|
||||
@@ -721,6 +828,12 @@ function disposeCharts() {
|
||||
.card { } // col-7 / col-5
|
||||
}
|
||||
|
||||
// 电工资质到期提醒
|
||||
.remind-grid {
|
||||
margin-top: 16px;
|
||||
.card { }
|
||||
}
|
||||
|
||||
.live-alerts { font-size: 11px; font-weight: 700; color: var(--dw-error); background: rgba(220,38,38,.1); padding: 2px 8px; border-radius: 6px; animation: dw-pulse 1.8s infinite; }
|
||||
|
||||
// ===== 表格 =====
|
||||
@@ -760,6 +873,7 @@ function disposeCharts() {
|
||||
&-primary { color: var(--dw-primary); background: rgba(0,84,205,.12); }
|
||||
&-warn { color: var(--dw-warn); background: rgba(224,133,43,.14); }
|
||||
&-tertiary { color: var(--dw-tertiary); background: rgba(0,104,95,.12); }
|
||||
&-danger { color: var(--dw-error); background: rgba(220,38,38,.12); }
|
||||
}
|
||||
|
||||
// ===== 看板 =====
|
||||
|
||||
@@ -0,0 +1,864 @@
|
||||
<template>
|
||||
<div class="cost-page">
|
||||
<!-- 顶部标题 + 操作 -->
|
||||
<div class="page-header">
|
||||
<div class="breadcrumb">
|
||||
<span>运营管控体系</span>
|
||||
<span class="sep">/</span>
|
||||
<span>运营派单</span>
|
||||
<span class="sep">/</span>
|
||||
<span class="cur">电工派单成本价</span>
|
||||
</div>
|
||||
<div class="header-main">
|
||||
<div class="header-left">
|
||||
<div class="header-row">
|
||||
<span class="title-bar"></span>
|
||||
<span class="main-title">电工派单成本价</span>
|
||||
<span class="sub-title">DISPATCH COST PRICE</span>
|
||||
</div>
|
||||
<p class="desc">维护共享电工派单的成本价目,按派单类型区分单价或总价提成,支持按次、按工时两种计价方式。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a-button v-if="canCreate" type="primary" @click="handleAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增成本价
|
||||
</a-button>
|
||||
</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-input
|
||||
v-model:value="searchForm.keyword"
|
||||
placeholder="价格编码"
|
||||
allow-clear
|
||||
maxlength="64"
|
||||
@pressEnter="handleSearch"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">派单类型</label>
|
||||
<a-select v-model:value="searchForm.dispatchType" placeholder="全部类型" allow-clear>
|
||||
<a-select-option value="勘察">勘察</a-select-option>
|
||||
<a-select-option value="安装">安装</a-select-option>
|
||||
<a-select-option value="预警">预警</a-select-option>
|
||||
<a-select-option value="维修">维修</a-select-option>
|
||||
<a-select-option value="维保">维保</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">计价方式</label>
|
||||
<a-select v-model:value="searchForm.priceMode" placeholder="全部方式" allow-clear>
|
||||
<a-select-option :value="1">按次</a-select-option>
|
||||
<a-select-option :value="2">按工时</a-select-option>
|
||||
</a-select>
|
||||
</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>
|
||||
<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>
|
||||
<a-button type="text" size="small" title="刷新列表" @click="fetchList">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
</a-button>
|
||||
</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 === 'priceCode'">
|
||||
<span class="mono">{{ record.priceCode || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'dispatchType'">
|
||||
{{ record.dispatchType || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'priceType'">
|
||||
{{ record.priceType || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'priceMode'">
|
||||
<a-tag :color="getPriceModeColor(record.priceMode)">{{ getPriceModeText(record.priceMode) }}</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'unitPrice'">
|
||||
<span class="mono">{{ formatPrice(record.unitPrice) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'unit'">
|
||||
{{ record.unit || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-switch
|
||||
:checked="record.status === 1"
|
||||
checked-children="启用"
|
||||
un-checked-children="停用"
|
||||
@change="(checked: boolean) => handleStatusChange(record, checked)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'remark'">
|
||||
{{ record.remark || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button v-if="canUpdate" type="link" size="small" @click="handleEdit(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
v-if="canDelete"
|
||||
title="确定要删除该成本价吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
ok-button-props="{ danger: true }"
|
||||
@confirm="handleDelete(record.id)"
|
||||
>
|
||||
<a-button type="link" size="small" danger>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #emptyText>
|
||||
<a-empty description="暂无成本价数据" />
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="modalTitle"
|
||||
:confirm-loading="modalLoading"
|
||||
width="640px"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<!-- 价格编码信息条(编辑态只读展示) -->
|
||||
<div v-if="isEdit && editId" class="no-bar">
|
||||
<div class="no-item">
|
||||
<span class="no-label">价格编码</span>
|
||||
<span class="no-value mono">{{ editCode || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 16 }"
|
||||
>
|
||||
<div class="form-section">
|
||||
<div class="section-head">
|
||||
<span class="section-no">01</span>
|
||||
<div class="section-text">
|
||||
<span class="section-name">成本价信息</span>
|
||||
<span class="section-sub">Cost Price Information</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-form-item label="派单类型" name="dispatchType">
|
||||
<a-select v-model:value="formData.dispatchType" placeholder="请选择派单类型" style="width: 100%">
|
||||
<a-select-option value="勘察">勘察</a-select-option>
|
||||
<a-select-option value="安装">安装</a-select-option>
|
||||
<a-select-option value="预警">预警</a-select-option>
|
||||
<a-select-option value="维修">维修</a-select-option>
|
||||
<a-select-option value="维保">维保</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="价格类型" name="priceType">
|
||||
<a-select v-model:value="formData.priceType" placeholder="请选择价格类型" allow-clear style="width: 100%">
|
||||
<a-select-option value="单价">单价</a-select-option>
|
||||
<a-select-option value="总价提成">总价提成</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="计价方式" name="priceMode">
|
||||
<a-select
|
||||
v-model:value="formData.priceMode"
|
||||
placeholder="请选择计价方式"
|
||||
style="width: 100%"
|
||||
@change="handlePriceModeChange"
|
||||
>
|
||||
<a-select-option :value="1">按次</a-select-option>
|
||||
<a-select-option :value="2">按工时</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="单价" name="unitPrice">
|
||||
<a-input-number
|
||||
v-model:value="formData.unitPrice"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
placeholder="请输入单价"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="单位" name="unit">
|
||||
<a-input
|
||||
v-model:value="formData.unit"
|
||||
placeholder="如:次 / 小时"
|
||||
maxlength="16"
|
||||
@change="handleUnitManualChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" name="remark">
|
||||
<a-textarea v-model:value="formData.remark" :rows="2" placeholder="请输入备注" maxlength="255" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">启用</a-radio>
|
||||
<a-radio :value="0">停用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { SearchOutlined, ReloadOutlined, PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
dispatchPriceApi,
|
||||
type DispatchPriceInfo,
|
||||
type DispatchPriceQuery,
|
||||
} from '@/api/dispatchPrice';
|
||||
import { useTableScroll } from '@/composables/useTableScroll';
|
||||
import { usePermissionStore } from '@/stores/permission';
|
||||
|
||||
const { wrapperRef, tableHeight } = useTableScroll();
|
||||
const permissionStore = usePermissionStore();
|
||||
|
||||
// 按钮权限
|
||||
const canCreate = computed(() => permissionStore.hasPermission('cost:create'));
|
||||
const canUpdate = computed(() => permissionStore.hasPermission('cost:update'));
|
||||
const canDelete = computed(() => permissionStore.hasPermission('cost:delete'));
|
||||
|
||||
// 计价方式 → 默认单位
|
||||
const PRICE_MODE_UNIT: Record<number, string> = { 1: '次', 2: '小时' };
|
||||
|
||||
// ==================== 列表数据 ====================
|
||||
const loading = ref(false);
|
||||
const dataSource = ref<DispatchPriceInfo[]>([]);
|
||||
const total = ref(0);
|
||||
|
||||
const searchForm = reactive<DispatchPriceQuery>({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
dispatchType: undefined as any,
|
||||
priceMode: undefined as any,
|
||||
status: undefined as any,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ title: '价格编码', dataIndex: 'priceCode', key: 'priceCode', ellipsis: true, width: 150 },
|
||||
{ title: '派单类型', dataIndex: 'dispatchType', key: 'dispatchType', ellipsis: true, width: 110 },
|
||||
{ title: '价格类型', dataIndex: 'priceType', key: 'priceType', ellipsis: true, width: 110 },
|
||||
{ title: '计价方式', key: 'priceMode', align: 'center' as const, width: 110 },
|
||||
{ title: '单价', key: 'unitPrice', align: 'right' as const, width: 110 },
|
||||
{ title: '单位', dataIndex: 'unit', key: 'unit', align: 'center' as const, width: 80 },
|
||||
{ title: '状态', key: 'status', align: 'center' as const, width: 100 },
|
||||
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
|
||||
{ title: '操作', key: 'action', width: 150, align: 'center' as const, fixed: 'right' as const },
|
||||
];
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: searchForm.page,
|
||||
pageSize: searchForm.pageSize,
|
||||
total: total.value,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (t: number) => `共 ${t} 条数据`,
|
||||
}));
|
||||
|
||||
// ==================== 弹窗表单 ====================
|
||||
const modalVisible = ref(false);
|
||||
const modalTitle = ref('');
|
||||
const modalLoading = ref(false);
|
||||
const formRef = ref();
|
||||
const isEdit = ref(false);
|
||||
const editId = ref<number | null>(null);
|
||||
const editCode = ref('');
|
||||
// 用户是否手动修改过单位(手动修改后不再随计价方式联动覆盖)
|
||||
const unitManuallyEdited = ref(false);
|
||||
|
||||
const formData = reactive({
|
||||
dispatchType: undefined as string | undefined,
|
||||
priceType: undefined as string | undefined,
|
||||
priceMode: undefined as number | undefined,
|
||||
unitPrice: undefined as number | undefined,
|
||||
unit: '',
|
||||
remark: '',
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const formRules = {
|
||||
dispatchType: [{ required: true, message: '请选择派单类型', trigger: 'change' }],
|
||||
priceMode: [{ required: true, message: '请选择计价方式', trigger: 'change' }],
|
||||
unitPrice: [{ required: true, message: '请输入单价', trigger: 'change', type: 'number' as const }],
|
||||
unit: [{ pattern: /^[^<>"']*$/, message: '不能包含 < > " \' 特殊字符', trigger: 'blur' }],
|
||||
remark: [{ pattern: /^[^<>"']*$/, message: '不能包含 < > " \' 特殊字符', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
});
|
||||
|
||||
// ==================== 数据请求 ====================
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await dispatchPriceApi.getDispatchPriceList(searchForm);
|
||||
if (res.code === 200) {
|
||||
dataSource.value = res.data.list;
|
||||
total.value = res.data.total;
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取电工派单成本价列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 搜索重置 ====================
|
||||
function handleSearch() {
|
||||
searchForm.page = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = '';
|
||||
searchForm.dispatchType = undefined as any;
|
||||
searchForm.priceMode = 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();
|
||||
}
|
||||
|
||||
// ==================== 新增/编辑 ====================
|
||||
function handleAdd() {
|
||||
isEdit.value = false;
|
||||
editId.value = null;
|
||||
editCode.value = '';
|
||||
modalTitle.value = '新增成本价';
|
||||
resetForm();
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(record: DispatchPriceInfo) {
|
||||
isEdit.value = true;
|
||||
editId.value = record.id;
|
||||
editCode.value = record.priceCode;
|
||||
modalTitle.value = '编辑成本价';
|
||||
resetForm();
|
||||
|
||||
formData.dispatchType = record.dispatchType || undefined;
|
||||
formData.priceType = record.priceType || undefined;
|
||||
formData.priceMode = record.priceMode;
|
||||
formData.unitPrice = Number(record.unitPrice ?? 0);
|
||||
formData.unit = record.unit || '';
|
||||
formData.remark = record.remark || '';
|
||||
formData.status = record.status;
|
||||
// 编辑已有记录视为已手动设定单位,避免切换计价方式覆盖既有单位
|
||||
unitManuallyEdited.value = true;
|
||||
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.dispatchType = undefined;
|
||||
formData.priceType = undefined;
|
||||
formData.priceMode = undefined;
|
||||
formData.unitPrice = undefined;
|
||||
formData.unit = '';
|
||||
formData.remark = '';
|
||||
formData.status = 1;
|
||||
unitManuallyEdited.value = false;
|
||||
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields();
|
||||
}
|
||||
}
|
||||
|
||||
// 计价方式变化:未手动改过单位时按计价方式联动单位
|
||||
function handlePriceModeChange(v: any) {
|
||||
if (!unitManuallyEdited.value) {
|
||||
formData.unit = PRICE_MODE_UNIT[Number(v)] || '';
|
||||
}
|
||||
}
|
||||
|
||||
// 用户手动修改单位后置标记
|
||||
function handleUnitManualChange() {
|
||||
unitManuallyEdited.value = true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
modalLoading.value = true;
|
||||
try {
|
||||
const submitData = {
|
||||
dispatchType: formData.dispatchType,
|
||||
priceType: formData.priceType || undefined,
|
||||
priceMode: formData.priceMode as number,
|
||||
unitPrice: formData.unitPrice as number,
|
||||
unit: formData.unit || undefined,
|
||||
status: formData.status,
|
||||
remark: formData.remark || undefined,
|
||||
};
|
||||
|
||||
let res: any;
|
||||
if (isEdit.value && editId.value) {
|
||||
res = await dispatchPriceApi.updateDispatchPrice(editId.value, submitData);
|
||||
} else {
|
||||
res = await dispatchPriceApi.createDispatchPrice(submitData);
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功');
|
||||
modalVisible.value = false;
|
||||
searchForm.page = 1;
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.message || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
} finally {
|
||||
modalLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
modalVisible.value = false;
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
const res: any = await dispatchPriceApi.deleteDispatchPrice(id);
|
||||
if (res.code === 200) {
|
||||
message.success('删除成功');
|
||||
searchForm.page = 1;
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.message || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 行内状态切换
|
||||
async function handleStatusChange(record: DispatchPriceInfo, checked: boolean) {
|
||||
const status = checked ? 1 : 0;
|
||||
try {
|
||||
const res: any = await dispatchPriceApi.updateDispatchPriceStatus(record.id, status);
|
||||
if (res.code === 200) {
|
||||
message.success(status === 1 ? '已启用' : '已停用');
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.message || '状态更新失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('状态更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 辅助 ====================
|
||||
function getPriceModeText(mode?: number): string {
|
||||
const textMap: Record<number, string> = { 1: '按次', 2: '按工时' };
|
||||
return textMap[mode ?? 0] || '未知';
|
||||
}
|
||||
|
||||
function getPriceModeColor(mode?: number): string {
|
||||
const colorMap: Record<number, string> = { 1: 'blue', 2: 'orange' };
|
||||
return colorMap[mode ?? 0] || 'default';
|
||||
}
|
||||
|
||||
function formatPrice(value?: number | string | null): string {
|
||||
return Number(value ?? 0).toFixed(2);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cost-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 20px 24px;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #5b6b7e;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.breadcrumb .sep {
|
||||
color: #c2c6d8;
|
||||
}
|
||||
|
||||
.breadcrumb .cur {
|
||||
color: #0054cd;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
width: 5px;
|
||||
height: 22px;
|
||||
background: #0054cd;
|
||||
border-radius: 3px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #0d1c2c;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #0054cd;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: #5b6b7e;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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: space-between;
|
||||
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;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: Consolas, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
color: #0d1c2c;
|
||||
}
|
||||
|
||||
/* 弹窗 */
|
||||
.no-bar {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
background: #f4f7ff;
|
||||
border: 1px solid #dbe4ee;
|
||||
}
|
||||
|
||||
.no-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.no-label {
|
||||
font-size: 12px;
|
||||
color: #5b6b7e;
|
||||
}
|
||||
|
||||
.no-value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0054cd;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 4px 0 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
}
|
||||
|
||||
.section-no {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 7px;
|
||||
background: #0054cd;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: Consolas, Menlo, monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.section-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0d1c2c;
|
||||
}
|
||||
|
||||
.section-sub {
|
||||
font-size: 11px;
|
||||
color: #8a97a8;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
:deep(.ant-table) {
|
||||
font-size: 14px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background: #f4f7ff;
|
||||
color: #5b6b7e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-link) {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary) {
|
||||
background: #0054cd;
|
||||
border-color: #0054cd;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary:not(:disabled):not(.ant-btn-disabled):hover) {
|
||||
background: #0947ab;
|
||||
border-color: #0947ab;
|
||||
}
|
||||
|
||||
:deep(.ant-modal-title) {
|
||||
color: #0d1c2c;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,871 @@
|
||||
<template>
|
||||
<div class="price-page">
|
||||
<!-- 顶部标题 + 操作 -->
|
||||
<div class="page-header">
|
||||
<div class="breadcrumb">
|
||||
<span>运营管控体系</span>
|
||||
<span class="sep">/</span>
|
||||
<span>运营派单</span>
|
||||
<span class="sep">/</span>
|
||||
<span class="cur">运维费定价</span>
|
||||
</div>
|
||||
<div class="header-main">
|
||||
<div class="header-left">
|
||||
<div class="header-row">
|
||||
<span class="title-bar"></span>
|
||||
<span class="main-title">运维费定价</span>
|
||||
<span class="sub-title">SERVICE PRICE</span>
|
||||
</div>
|
||||
<p class="desc">统一维护面向客户的运维服务价目,支持按次、按工时与材料三种计价方式,可启停用与检索。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a-button v-if="canCreate" type="primary" @click="handleAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增价目
|
||||
</a-button>
|
||||
</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-input
|
||||
v-model:value="searchForm.keyword"
|
||||
placeholder="价目编码 / 价目名称"
|
||||
allow-clear
|
||||
maxlength="64"
|
||||
@pressEnter="handleSearch"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">计价方式</label>
|
||||
<a-select v-model:value="searchForm.priceMode" placeholder="全部方式" allow-clear>
|
||||
<a-select-option :value="1">按次</a-select-option>
|
||||
<a-select-option :value="2">按工时</a-select-option>
|
||||
<a-select-option :value="3">材料</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">服务类型</label>
|
||||
<a-select v-model:value="searchForm.serviceType" placeholder="全部类型" allow-clear>
|
||||
<a-select-option value="维修">维修</a-select-option>
|
||||
<a-select-option value="维保">维保</a-select-option>
|
||||
<a-select-option value="报警处理">报警处理</a-select-option>
|
||||
<a-select-option value="巡检">巡检</a-select-option>
|
||||
<a-select-option value="其他">其他</a-select-option>
|
||||
</a-select>
|
||||
</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>
|
||||
<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>
|
||||
<a-button type="text" size="small" title="刷新列表" @click="fetchList">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
</a-button>
|
||||
</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 === 'priceCode'">
|
||||
<span class="mono">{{ record.priceCode || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'priceName'">
|
||||
<span class="price-name">{{ record.priceName }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'serviceType'">
|
||||
{{ record.serviceType || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'priceMode'">
|
||||
<a-tag :color="getPriceModeColor(record.priceMode)">{{ getPriceModeText(record.priceMode) }}</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'unitPrice'">
|
||||
<span class="mono">{{ formatPrice(record.unitPrice) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'unit'">
|
||||
{{ record.unit || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-switch
|
||||
:checked="record.status === 1"
|
||||
checked-children="启用"
|
||||
un-checked-children="停用"
|
||||
@change="(checked: boolean) => handleStatusChange(record, checked)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'remark'">
|
||||
{{ record.remark || '-' }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button v-if="canUpdate" type="link" size="small" @click="handleEdit(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
v-if="canDelete"
|
||||
title="确定要删除该价目吗?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
ok-button-props="{ danger: true }"
|
||||
@confirm="handleDelete(record.id)"
|
||||
>
|
||||
<a-button type="link" size="small" danger>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #emptyText>
|
||||
<a-empty description="暂无价目数据" />
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="modalTitle"
|
||||
:confirm-loading="modalLoading"
|
||||
width="640px"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<!-- 价目编码信息条(编辑态只读展示) -->
|
||||
<div v-if="isEdit && editId" class="no-bar">
|
||||
<div class="no-item">
|
||||
<span class="no-label">价目编码</span>
|
||||
<span class="no-value mono">{{ editCode || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 16 }"
|
||||
>
|
||||
<div class="form-section">
|
||||
<div class="section-head">
|
||||
<span class="section-no">01</span>
|
||||
<div class="section-text">
|
||||
<span class="section-name">价目信息</span>
|
||||
<span class="section-sub">Price Information</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-form-item label="价目名称" name="priceName">
|
||||
<a-input v-model:value="formData.priceName" placeholder="请输入价目名称" maxlength="64" />
|
||||
</a-form-item>
|
||||
<a-form-item label="服务类型" name="serviceType">
|
||||
<a-select v-model:value="formData.serviceType" placeholder="请选择服务类型" allow-clear style="width: 100%">
|
||||
<a-select-option value="维修">维修</a-select-option>
|
||||
<a-select-option value="维保">维保</a-select-option>
|
||||
<a-select-option value="报警处理">报警处理</a-select-option>
|
||||
<a-select-option value="巡检">巡检</a-select-option>
|
||||
<a-select-option value="其他">其他</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="计价方式" name="priceMode">
|
||||
<a-select
|
||||
v-model:value="formData.priceMode"
|
||||
placeholder="请选择计价方式"
|
||||
style="width: 100%"
|
||||
@change="handlePriceModeChange"
|
||||
>
|
||||
<a-select-option :value="1">按次</a-select-option>
|
||||
<a-select-option :value="2">按工时</a-select-option>
|
||||
<a-select-option :value="3">材料</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="单价" name="unitPrice">
|
||||
<a-input-number
|
||||
v-model:value="formData.unitPrice"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
placeholder="请输入单价"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="单位" name="unit">
|
||||
<a-input
|
||||
v-model:value="formData.unit"
|
||||
placeholder="如:次 / 小时 / 个"
|
||||
maxlength="16"
|
||||
@change="handleUnitManualChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" name="remark">
|
||||
<a-textarea v-model:value="formData.remark" :rows="2" placeholder="请输入备注" maxlength="255" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">启用</a-radio>
|
||||
<a-radio :value="0">停用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { SearchOutlined, ReloadOutlined, PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
servicePriceApi,
|
||||
type ServicePriceInfo,
|
||||
type ServicePriceQuery,
|
||||
} from '@/api/servicePrice';
|
||||
import { useTableScroll } from '@/composables/useTableScroll';
|
||||
import { usePermissionStore } from '@/stores/permission';
|
||||
|
||||
const { wrapperRef, tableHeight } = useTableScroll();
|
||||
const permissionStore = usePermissionStore();
|
||||
|
||||
// 按钮权限
|
||||
const canCreate = computed(() => permissionStore.hasPermission('price:create'));
|
||||
const canUpdate = computed(() => permissionStore.hasPermission('price:update'));
|
||||
const canDelete = computed(() => permissionStore.hasPermission('price:delete'));
|
||||
|
||||
// 计价方式 → 默认单位
|
||||
const PRICE_MODE_UNIT: Record<number, string> = { 1: '次', 2: '小时', 3: '个' };
|
||||
|
||||
// ==================== 列表数据 ====================
|
||||
const loading = ref(false);
|
||||
const dataSource = ref<ServicePriceInfo[]>([]);
|
||||
const total = ref(0);
|
||||
|
||||
const searchForm = reactive<ServicePriceQuery>({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
keyword: '',
|
||||
priceMode: undefined as any,
|
||||
serviceType: undefined as any,
|
||||
status: undefined as any,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ title: '价目编码', dataIndex: 'priceCode', key: 'priceCode', ellipsis: true, width: 150 },
|
||||
{ title: '价目名称', dataIndex: 'priceName', key: 'priceName', ellipsis: true },
|
||||
{ title: '服务类型', dataIndex: 'serviceType', key: 'serviceType', ellipsis: true, width: 110 },
|
||||
{ title: '计价方式', key: 'priceMode', align: 'center' as const, width: 110 },
|
||||
{ title: '单价', key: 'unitPrice', align: 'right' as const, width: 110 },
|
||||
{ title: '单位', dataIndex: 'unit', key: 'unit', align: 'center' as const, width: 80 },
|
||||
{ title: '状态', key: 'status', align: 'center' as const, width: 100 },
|
||||
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
|
||||
{ title: '操作', key: 'action', width: 150, align: 'center' as const, fixed: 'right' as const },
|
||||
];
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: searchForm.page,
|
||||
pageSize: searchForm.pageSize,
|
||||
total: total.value,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (t: number) => `共 ${t} 条数据`,
|
||||
}));
|
||||
|
||||
// ==================== 弹窗表单 ====================
|
||||
const modalVisible = ref(false);
|
||||
const modalTitle = ref('');
|
||||
const modalLoading = ref(false);
|
||||
const formRef = ref();
|
||||
const isEdit = ref(false);
|
||||
const editId = ref<number | null>(null);
|
||||
const editCode = ref('');
|
||||
// 用户是否手动修改过单位(手动修改后不再随计价方式联动覆盖)
|
||||
const unitManuallyEdited = ref(false);
|
||||
|
||||
const formData = reactive({
|
||||
priceName: '',
|
||||
serviceType: undefined as string | undefined,
|
||||
priceMode: undefined as number | undefined,
|
||||
unitPrice: undefined as number | undefined,
|
||||
unit: '',
|
||||
remark: '',
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const formRules = {
|
||||
priceName: [
|
||||
{ required: true, message: '请输入价目名称', trigger: 'blur' },
|
||||
{ pattern: /^[^<>"']*$/, message: '不能包含 < > " \' 特殊字符', trigger: 'blur' },
|
||||
],
|
||||
priceMode: [{ required: true, message: '请选择计价方式', trigger: 'change' }],
|
||||
unitPrice: [{ required: true, message: '请输入单价', trigger: 'change', type: 'number' as const }],
|
||||
unit: [{ pattern: /^[^<>"']*$/, message: '不能包含 < > " \' 特殊字符', trigger: 'blur' }],
|
||||
remark: [{ pattern: /^[^<>"']*$/, message: '不能包含 < > " \' 特殊字符', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
});
|
||||
|
||||
// ==================== 数据请求 ====================
|
||||
async function fetchList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await servicePriceApi.getServicePriceList(searchForm);
|
||||
if (res.code === 200) {
|
||||
dataSource.value = res.data.list;
|
||||
total.value = res.data.total;
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取运维费定价列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 搜索重置 ====================
|
||||
function handleSearch() {
|
||||
searchForm.page = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.keyword = '';
|
||||
searchForm.priceMode = undefined as any;
|
||||
searchForm.serviceType = 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();
|
||||
}
|
||||
|
||||
// ==================== 新增/编辑 ====================
|
||||
function handleAdd() {
|
||||
isEdit.value = false;
|
||||
editId.value = null;
|
||||
editCode.value = '';
|
||||
modalTitle.value = '新增价目';
|
||||
resetForm();
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(record: ServicePriceInfo) {
|
||||
isEdit.value = true;
|
||||
editId.value = record.id;
|
||||
editCode.value = record.priceCode;
|
||||
modalTitle.value = '编辑价目';
|
||||
resetForm();
|
||||
|
||||
formData.priceName = record.priceName;
|
||||
formData.serviceType = record.serviceType || undefined;
|
||||
formData.priceMode = record.priceMode;
|
||||
formData.unitPrice = Number(record.unitPrice);
|
||||
formData.unit = record.unit || '';
|
||||
formData.remark = record.remark || '';
|
||||
formData.status = record.status;
|
||||
// 编辑已有记录视为已手动设定单位,避免切换计价方式覆盖既有单位
|
||||
unitManuallyEdited.value = true;
|
||||
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.priceName = '';
|
||||
formData.serviceType = undefined;
|
||||
formData.priceMode = undefined;
|
||||
formData.unitPrice = undefined;
|
||||
formData.unit = '';
|
||||
formData.remark = '';
|
||||
formData.status = 1;
|
||||
unitManuallyEdited.value = false;
|
||||
|
||||
if (formRef.value) {
|
||||
formRef.value.resetFields();
|
||||
}
|
||||
}
|
||||
|
||||
// 计价方式变化:未手动改过单位时按计价方式联动单位
|
||||
function handlePriceModeChange(v: any) {
|
||||
if (!unitManuallyEdited.value) {
|
||||
formData.unit = PRICE_MODE_UNIT[Number(v)] || '';
|
||||
}
|
||||
}
|
||||
|
||||
// 用户手动修改单位后置标记
|
||||
function handleUnitManualChange() {
|
||||
unitManuallyEdited.value = true;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
modalLoading.value = true;
|
||||
try {
|
||||
const submitData = {
|
||||
priceName: formData.priceName,
|
||||
serviceType: formData.serviceType || undefined,
|
||||
priceMode: formData.priceMode as number,
|
||||
unitPrice: formData.unitPrice as number,
|
||||
unit: formData.unit || undefined,
|
||||
status: formData.status,
|
||||
remark: formData.remark || undefined,
|
||||
};
|
||||
|
||||
let res: any;
|
||||
if (isEdit.value && editId.value) {
|
||||
res = await servicePriceApi.updateServicePrice(editId.value, submitData);
|
||||
} else {
|
||||
res = await servicePriceApi.createServicePrice(submitData);
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功');
|
||||
modalVisible.value = false;
|
||||
searchForm.page = 1;
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.message || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
} finally {
|
||||
modalLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
modalVisible.value = false;
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
const res: any = await servicePriceApi.deleteServicePrice(id);
|
||||
if (res.code === 200) {
|
||||
message.success('删除成功');
|
||||
searchForm.page = 1;
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.message || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 行内状态切换
|
||||
async function handleStatusChange(record: ServicePriceInfo, checked: boolean) {
|
||||
const status = checked ? 1 : 0;
|
||||
try {
|
||||
const res: any = await servicePriceApi.updateServicePriceStatus(record.id, status);
|
||||
if (res.code === 200) {
|
||||
message.success(status === 1 ? '已启用' : '已停用');
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.message || '状态更新失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('状态更新失败');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 辅助 ====================
|
||||
function getPriceModeText(mode?: number): string {
|
||||
const textMap: Record<number, string> = { 1: '按次', 2: '按工时', 3: '材料' };
|
||||
return textMap[mode ?? 0] || '未知';
|
||||
}
|
||||
|
||||
function getPriceModeColor(mode?: number): string {
|
||||
const colorMap: Record<number, string> = { 1: 'blue', 2: 'orange', 3: 'green' };
|
||||
return colorMap[mode ?? 0] || 'default';
|
||||
}
|
||||
|
||||
function formatPrice(value?: number | string | null): string {
|
||||
return Number(value ?? 0).toFixed(2);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.price-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 20px 24px;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(180deg, #eef3f8 0%, #e8eefb 100%);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #5b6b7e;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.breadcrumb .sep {
|
||||
color: #c2c6d8;
|
||||
}
|
||||
|
||||
.breadcrumb .cur {
|
||||
color: #0054cd;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
width: 5px;
|
||||
height: 22px;
|
||||
background: #0054cd;
|
||||
border-radius: 3px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #0d1c2c;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #0054cd;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: #5b6b7e;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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: space-between;
|
||||
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;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: Consolas, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
color: #0d1c2c;
|
||||
}
|
||||
|
||||
.price-name {
|
||||
font-weight: 600;
|
||||
color: #0d1c2c;
|
||||
}
|
||||
|
||||
/* 弹窗 */
|
||||
.no-bar {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
background: #f4f7ff;
|
||||
border: 1px solid #dbe4ee;
|
||||
}
|
||||
|
||||
.no-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.no-label {
|
||||
font-size: 12px;
|
||||
color: #5b6b7e;
|
||||
}
|
||||
|
||||
.no-value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0054cd;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 4px 0 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
}
|
||||
|
||||
.section-no {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 7px;
|
||||
background: #0054cd;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: Consolas, Menlo, monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.section-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0d1c2c;
|
||||
}
|
||||
|
||||
.section-sub {
|
||||
font-size: 11px;
|
||||
color: #8a97a8;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
:deep(.ant-table) {
|
||||
font-size: 14px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background: #f4f7ff;
|
||||
color: #5b6b7e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-link) {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary) {
|
||||
background: #0054cd;
|
||||
border-color: #0054cd;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary:not(:disabled):not(.ant-btn-disabled):hover) {
|
||||
background: #0947ab;
|
||||
border-color: #0947ab;
|
||||
}
|
||||
|
||||
:deep(.ant-modal-title) {
|
||||
color: #0d1c2c;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,649 @@
|
||||
<template>
|
||||
<div class="job-management">
|
||||
<a-card :bordered="false" class="search-card">
|
||||
<a-form layout="inline" :model="queryParams">
|
||||
<a-form-item label="任务名称">
|
||||
<a-input
|
||||
v-model:value="queryParams.jobName"
|
||||
placeholder="请输入任务名称"
|
||||
allow-clear
|
||||
style="width: 180px"
|
||||
@pressEnter="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select
|
||||
v-model:value="queryParams.status"
|
||||
placeholder="全部"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
>
|
||||
<a-select-option :value="1">启用</a-select-option>
|
||||
<a-select-option :value="0">停用</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false" class="table-card">
|
||||
<div class="table-wrapper" ref="wrapperRef">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="jobList"
|
||||
:loading="tableLoading"
|
||||
:pagination="paginationConfig"
|
||||
row-key="id"
|
||||
:scroll="{ x: 'max-content', y: tableHeight }"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'jobName'">
|
||||
<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 === 'params'">
|
||||
<a-tooltip :title="formatParams(record.params, true)">
|
||||
<span class="ellipsis-text">{{ formatParams(record.params) }}</span>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-switch
|
||||
:checked="record.status === 1"
|
||||
:loading="statusLoadingId === record.id"
|
||||
checked-children="启用"
|
||||
un-checked-children="停用"
|
||||
@change="(checked: boolean) => handleStatusChange(record, checked)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'lastRunAt'">
|
||||
{{ formatDateTime(record.lastRunAt) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'lastRunStatus'">
|
||||
<a-tag v-if="record.lastRunStatus" :color="runStatusColor(record.lastRunStatus)">
|
||||
{{ runStatusText(record.lastRunStatus) }}
|
||||
</a-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'lastRunCostMs'">
|
||||
{{ record.lastRunCostMs === null || record.lastRunCostMs === undefined ? '-' : `${record.lastRunCostMs} ms` }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'nextRunTime'">
|
||||
{{ formatDateTime(record.nextRunTime) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'retry'">
|
||||
{{ `${record.retryTimes ?? 0} 次 / ${record.retryInterval ?? 0} 秒` }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" :disabled="record.registered === false" @click="handleEdit(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button type="link" size="small" :disabled="record.registered === false" @click="handleRun(record)">
|
||||
<template #icon><PlayCircleOutlined /></template>
|
||||
立即执行
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleOpenLogs(record)">
|
||||
<template #icon><ProfileOutlined /></template>
|
||||
日志
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:open="editVisible"
|
||||
title="编辑任务"
|
||||
:confirm-loading="saving"
|
||||
:mask-closable="false"
|
||||
width="640px"
|
||||
@ok="handleSave"
|
||||
@cancel="handleCancelEdit"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 16 }"
|
||||
>
|
||||
<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-form-item>
|
||||
<a-form-item
|
||||
v-for="field in paramFields"
|
||||
:key="field.key"
|
||||
:label="field.label"
|
||||
:name="['params', field.key]"
|
||||
:rules="getParamRules(field)"
|
||||
>
|
||||
<a-input-number
|
||||
v-if="field.type === 'number'"
|
||||
v-model:value="formData.params[field.key]"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
:placeholder="field.placeholder || `请输入${field.label}`"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<a-input
|
||||
v-else
|
||||
v-model:value="formData.params[field.key]"
|
||||
:placeholder="field.placeholder || `请输入${field.label}`"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="重试次数" name="retryTimes">
|
||||
<a-input-number v-model:value="formData.retryTimes" :min="0" :max="5" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="重试间隔(秒)" name="retryInterval">
|
||||
<a-input-number v-model:value="formData.retryInterval" :min="10" :max="3600" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" name="remark">
|
||||
<a-textarea
|
||||
v-model:value="formData.remark"
|
||||
placeholder="请输入备注"
|
||||
:maxlength="255"
|
||||
show-count
|
||||
:rows="3"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-drawer
|
||||
v-model:open="logVisible"
|
||||
:title="`执行日志 - ${currentJob?.jobName || ''}`"
|
||||
width="900"
|
||||
>
|
||||
<div class="log-toolbar">
|
||||
<a-select
|
||||
v-model:value="logQuery.status"
|
||||
placeholder="全部状态"
|
||||
allow-clear
|
||||
style="width: 140px"
|
||||
@change="handleLogSearch"
|
||||
>
|
||||
<a-select-option :value="1">成功</a-select-option>
|
||||
<a-select-option :value="2">失败</a-select-option>
|
||||
<a-select-option :value="3">跳过</a-select-option>
|
||||
</a-select>
|
||||
<a-button danger @click="handleClearLogs">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
清理日志
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="logColumns"
|
||||
:data-source="logList"
|
||||
:loading="logLoading"
|
||||
:pagination="logPaginationConfig"
|
||||
:expandable="logExpandable"
|
||||
row-key="id"
|
||||
size="small"
|
||||
@change="handleLogTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'createdAt'">
|
||||
{{ formatDateTime(record.createdAt) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'triggerType'">
|
||||
{{ triggerTypeText(record.triggerType) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="runStatusColor(record.status)">{{ runStatusText(record.status) }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'costMs'">
|
||||
{{ record.costMs === null || record.costMs === undefined ? '-' : `${record.costMs} ms` }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'message'">
|
||||
<a-tooltip :title="record.message || ''">
|
||||
<span class="ellipsis-text">{{ record.message || '-' }}</span>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
</template>
|
||||
<template #expandedRowRender="{ record }">
|
||||
<pre class="error-stack">{{ record.errorStack }}</pre>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
EditOutlined,
|
||||
PlayCircleOutlined,
|
||||
ProfileOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { jobApi, type JobItem, type JobParamField, type JobLogItem } from '@/api/job'
|
||||
import { useTableScroll } from '@/composables/useTableScroll'
|
||||
|
||||
const { wrapperRef, tableHeight } = useTableScroll()
|
||||
|
||||
const tableLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const statusLoadingId = ref<number | null>(null)
|
||||
const jobList = ref<JobItem[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const queryParams = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
jobName: '',
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
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: '参数', dataIndex: 'params', key: 'params', width: 180, ellipsis: true },
|
||||
{ title: '状态', key: 'status', width: 100, align: 'center' as const },
|
||||
{ title: '上次执行时间', dataIndex: 'lastRunAt', key: 'lastRunAt', width: 170 },
|
||||
{ title: '上次状态', key: 'lastRunStatus', width: 100, align: 'center' as const },
|
||||
{ title: '耗时', key: 'lastRunCostMs', width: 100 },
|
||||
{ title: '下次执行时间', key: 'nextRunTime', width: 170 },
|
||||
{ title: '重试配置', key: 'retry', width: 140 },
|
||||
{ title: '操作', key: 'action', width: 220, fixed: 'right' as const },
|
||||
]
|
||||
|
||||
const paginationConfig = reactive({
|
||||
current: queryParams.page,
|
||||
pageSize: queryParams.pageSize,
|
||||
total: total.value,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showTotal: (t: number) => `共 ${t} 条记录`,
|
||||
})
|
||||
|
||||
const editVisible = ref(false)
|
||||
const currentJob = ref<JobItem | null>(null)
|
||||
const paramFields = ref<JobParamField[]>([])
|
||||
const formRef = ref()
|
||||
|
||||
const formData = reactive({
|
||||
cronExpression: '',
|
||||
params: {} as Record<string, any>,
|
||||
retryTimes: 0,
|
||||
retryInterval: 60,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
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' }],
|
||||
}
|
||||
|
||||
const logVisible = ref(false)
|
||||
const logLoading = ref(false)
|
||||
const logList = ref<JobLogItem[]>([])
|
||||
const logTotal = ref(0)
|
||||
|
||||
const logQuery = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
status: undefined as number | undefined,
|
||||
triggerType: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const logColumns = [
|
||||
{ title: '执行时间', dataIndex: 'createdAt', key: 'createdAt', width: 170 },
|
||||
{ title: '触发方式', dataIndex: 'triggerType', key: 'triggerType', width: 90 },
|
||||
{ title: '状态', key: 'status', width: 80, align: 'center' as const },
|
||||
{ title: '尝试', dataIndex: 'attempt', key: 'attempt', width: 70 },
|
||||
{ title: '耗时', key: 'costMs', width: 90 },
|
||||
{ title: '摘要', dataIndex: 'message', key: 'message' },
|
||||
]
|
||||
|
||||
const logExpandable = {
|
||||
rowExpandable: (record: JobLogItem) => !!record.errorStack,
|
||||
}
|
||||
|
||||
const logPaginationConfig = reactive({
|
||||
current: logQuery.page,
|
||||
pageSize: logQuery.pageSize,
|
||||
total: logTotal.value,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50'],
|
||||
showTotal: (t: number) => `共 ${t} 条记录`,
|
||||
})
|
||||
|
||||
function formatDateTime(time?: string | null): string {
|
||||
if (!time) return '-'
|
||||
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
function formatParams(params?: Record<string, any>, full = false): string {
|
||||
if (!params || !Object.keys(params).length) return '-'
|
||||
const text = Object.entries(params)
|
||||
.map(([key, value]) => `${key}=${value === null || value === undefined ? '' : value}`)
|
||||
.join(full ? '\n' : ', ')
|
||||
return text || '-'
|
||||
}
|
||||
|
||||
function runStatusText(status: number): string {
|
||||
return ({ 1: '成功', 2: '失败', 3: '跳过' } as Record<number, string>)[status] || '-'
|
||||
}
|
||||
|
||||
function runStatusColor(status: number): string {
|
||||
return ({ 1: 'green', 2: 'red', 3: 'orange' } as Record<number, string>)[status] || 'default'
|
||||
}
|
||||
|
||||
function triggerTypeText(type: number): string {
|
||||
return ({ 1: '定时', 2: '手动', 3: '重试' } as Record<number, string>)[type] || '-'
|
||||
}
|
||||
|
||||
function getParamRules(field: JobParamField) {
|
||||
if (field.type !== 'number') return []
|
||||
return [
|
||||
{
|
||||
validator: (_rule: any, value: any) => {
|
||||
if (value === undefined || value === null || value === '') return Promise.resolve()
|
||||
if (field.min !== undefined && value < field.min) {
|
||||
return Promise.reject(new Error(`${field.label}不能小于 ${field.min}`))
|
||||
}
|
||||
if (field.max !== undefined && value > field.max) {
|
||||
return Promise.reject(new Error(`${field.label}不能大于 ${field.max}`))
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
trigger: 'change',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async function fetchJobList() {
|
||||
tableLoading.value = true
|
||||
try {
|
||||
const res: any = await jobApi.getJobList({ ...queryParams })
|
||||
if (res.code === 200) {
|
||||
jobList.value = res.data.list
|
||||
total.value = res.data.total
|
||||
paginationConfig.total = res.data.total
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取任务列表失败')
|
||||
} finally {
|
||||
tableLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
queryParams.page = 1
|
||||
paginationConfig.current = 1
|
||||
fetchJobList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
queryParams.jobName = ''
|
||||
queryParams.status = undefined
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
function handleTableChange(pag: any) {
|
||||
queryParams.page = pag.current
|
||||
queryParams.pageSize = pag.pageSize
|
||||
paginationConfig.current = pag.current
|
||||
paginationConfig.pageSize = pag.pageSize
|
||||
fetchJobList()
|
||||
}
|
||||
|
||||
async function handleStatusChange(record: JobItem, checked: boolean) {
|
||||
const status: 0 | 1 = checked ? 1 : 0
|
||||
statusLoadingId.value = record.id
|
||||
try {
|
||||
const res: any = await jobApi.changeJobStatus(record.id, status)
|
||||
if (res.code === 200) {
|
||||
record.status = status
|
||||
message.success(status === 1 ? '已启用' : '已停用')
|
||||
fetchJobList()
|
||||
} else {
|
||||
message.error(res.message || '状态更新失败')
|
||||
}
|
||||
} catch (error) {
|
||||
// 失败时 record.status 未变更,受控开关自动回滚
|
||||
} finally {
|
||||
statusLoadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(record: JobItem) {
|
||||
currentJob.value = record
|
||||
paramFields.value = record.paramFields || []
|
||||
formData.cronExpression = record.cronExpression || ''
|
||||
formData.params = { ...(record.params || {}) }
|
||||
formData.retryTimes = record.retryTimes ?? 0
|
||||
formData.retryInterval = record.retryInterval ?? 60
|
||||
formData.remark = record.remark || ''
|
||||
editVisible.value = true
|
||||
nextTick(() => formRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
function handleCancelEdit() {
|
||||
editVisible.value = false
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!currentJob.value) return
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const res: any = await jobApi.updateJob(currentJob.value.id, {
|
||||
cronExpression: formData.cronExpression,
|
||||
params: { ...formData.params },
|
||||
retryTimes: formData.retryTimes,
|
||||
retryInterval: formData.retryInterval,
|
||||
remark: formData.remark,
|
||||
})
|
||||
if (res.code === 200) {
|
||||
message.success('保存成功')
|
||||
editVisible.value = false
|
||||
fetchJobList()
|
||||
} else {
|
||||
message.error(res.message || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
// 保存失败保持弹窗打开,后端 message 由响应拦截器提示
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleRun(record: JobItem) {
|
||||
Modal.confirm({
|
||||
title: '立即执行确认',
|
||||
content: `确定立即执行任务「${record.jobName}」吗?`,
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
const res: any = await jobApi.runJob(record.id)
|
||||
if (res.code === 200) {
|
||||
message.success(res.data?.message || '执行完成')
|
||||
fetchJobList()
|
||||
if (logVisible.value && currentJob.value?.id === record.id) {
|
||||
fetchJobLogs()
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleOpenLogs(record: JobItem) {
|
||||
currentJob.value = record
|
||||
logQuery.page = 1
|
||||
logQuery.pageSize = 10
|
||||
logQuery.status = undefined
|
||||
logQuery.triggerType = undefined
|
||||
logPaginationConfig.current = 1
|
||||
logPaginationConfig.pageSize = 10
|
||||
logVisible.value = true
|
||||
fetchJobLogs()
|
||||
}
|
||||
|
||||
async function fetchJobLogs() {
|
||||
if (!currentJob.value) return
|
||||
logLoading.value = true
|
||||
try {
|
||||
const res: any = await jobApi.getJobLogs(currentJob.value.id, {
|
||||
page: logQuery.page,
|
||||
pageSize: logQuery.pageSize,
|
||||
status: logQuery.status,
|
||||
triggerType: logQuery.triggerType,
|
||||
})
|
||||
if (res.code === 200) {
|
||||
logList.value = res.data.list
|
||||
logTotal.value = res.data.total
|
||||
logPaginationConfig.total = res.data.total
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取执行日志失败')
|
||||
} finally {
|
||||
logLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogSearch() {
|
||||
logQuery.page = 1
|
||||
logPaginationConfig.current = 1
|
||||
fetchJobLogs()
|
||||
}
|
||||
|
||||
function handleLogTableChange(pag: any) {
|
||||
logQuery.page = pag.current
|
||||
logQuery.pageSize = pag.pageSize
|
||||
logPaginationConfig.current = pag.current
|
||||
logPaginationConfig.pageSize = pag.pageSize
|
||||
fetchJobLogs()
|
||||
}
|
||||
|
||||
function handleClearLogs() {
|
||||
Modal.confirm({
|
||||
title: '清理日志确认',
|
||||
content: '确定清理 90 天前的执行日志吗?此操作不可恢复。',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
const res: any = await jobApi.clearJobLogs(90)
|
||||
if (res.code === 200) {
|
||||
message.success(`已清理 ${res.data?.deleted ?? 0} 条日志`)
|
||||
handleLogSearch()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchJobList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.job-management {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.search-card {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 16px;
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-table) {
|
||||
font-size: 13px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ellipsis-text {
|
||||
max-width: 160px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.log-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-stack {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #cf1322;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+89
-7
@@ -17,7 +17,7 @@
|
||||
--
|
||||
-- 内容:
|
||||
-- 1. 初始角色(ADMIN / SALESMAN / WORKER / CUSTOMER)
|
||||
-- 2. 初始菜单(顶级 6 + 系统管理子菜单 5)
|
||||
-- 2. 初始菜单(顶级 6 + 系统管理子菜单 6 + 运营派单子菜单 13 等,共 50 条)
|
||||
-- 3. 初始字典(客户/设备/派单/支付等业务字典)
|
||||
-- 4. 管理员账户(admin / admin123456)
|
||||
-- 5. 用户-角色绑定 / 角色-菜单绑定
|
||||
@@ -43,12 +43,12 @@ ON CONFLICT (id) DO UPDATE SET
|
||||
SELECT setval('sys_role_id_seq', 4, true);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 2. 初始菜单(顶级菜单,显式指定 id:1-6,系统管理为目录类型 menu_type=1,其余为 menu_type=2)
|
||||
-- 2. 初始菜单(顶级菜单,显式指定 id:1-6,系统管理/运营派单为目录类型 menu_type=1,其余为 menu_type=2)
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||
(1, NULL, '客户管理', '/customer', NULL, 'TeamOutlined', 1, 1, 'customer:list', 1),
|
||||
(2, NULL, '项目管理', '/project', NULL, 'ProjectOutlined', 2, 1, 'project:list', 1),
|
||||
(3, NULL, '运营派单', '/dispatch', NULL, 'ScheduleOutlined', 3, 2, 'dispatch:list', 1),
|
||||
(3, NULL, '运营派单', '/dispatch', NULL, 'ScheduleOutlined', 3, 1, 'dispatch:list', 1),
|
||||
(4, NULL, '设备监控', '/device', NULL, 'MonitorOutlined', 4, 2, 'device:list', 1),
|
||||
(5, NULL, '统计报表', '/report', NULL, 'BarChartOutlined', 5, 2, 'report:list', 1),
|
||||
(6, NULL, '系统管理', '/system', NULL, 'SettingOutlined', 6, 1, 'system:list', 1)
|
||||
@@ -132,8 +132,34 @@ ON CONFLICT (id) DO UPDATE SET
|
||||
permission = EXCLUDED.permission,
|
||||
visible = EXCLUDED.visible;
|
||||
|
||||
-- 运营派单子菜单(parent_id=3,id:34-36 为菜单项,37-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', 1, 2, 'dispatch:price', 1),
|
||||
(35, 3, '电工派单成本价', '/dispatch/cost', 'dispatch/cost/index', 'AccountBookOutlined', 2, 2, 'dispatch:cost', 1),
|
||||
(36, 3, '电工登记管理', '/dispatch/worker', 'dispatch/worker/index', 'ToolOutlined', 3, 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, 3, '新增成本价', NULL, NULL, NULL, 4, 3, 'cost:create', 1),
|
||||
(41, 3, '编辑成本价', NULL, NULL, NULL, 5, 3, 'cost:update', 1),
|
||||
(42, 3, '删除成本价', NULL, NULL, NULL, 6, 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)
|
||||
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;
|
||||
|
||||
-- 重置 sys_menu 自增序列
|
||||
SELECT setval('sys_menu_id_seq', 33, true);
|
||||
SELECT setval('sys_menu_id_seq', 50, true);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 3. 初始字典(sys_dict 无业务唯一键,清空重建以支持幂等执行)
|
||||
@@ -162,7 +188,28 @@ INSERT INTO sys_dict (dict_type, dict_label, dict_value, sort_order) VALUES
|
||||
('dispatch_type', '安装派单', 'install', 2),
|
||||
('dispatch_type', '报警派单', 'alarm', 3),
|
||||
('dispatch_type', '维修派单', 'repair', 4),
|
||||
('dispatch_type', '维保派单', 'maintenance', 5);
|
||||
('dispatch_type', '维保派单', 'maintenance', 5),
|
||||
('om_service_type', '维修', '维修', 1),
|
||||
('om_service_type', '维保', '维保', 2),
|
||||
('om_service_type', '报警处理', '报警处理', 3),
|
||||
('om_service_type', '巡检', '巡检', 4),
|
||||
('om_service_type', '其他', '其他', 5),
|
||||
('om_price_mode', '按次', '1', 1),
|
||||
('om_price_mode', '按工时', '2', 2),
|
||||
('om_price_mode', '材料', '3', 3),
|
||||
('om_price_unit', '次', '次', 1),
|
||||
('om_price_unit', '小时', '小时', 2),
|
||||
('om_price_unit', '个', '个', 3),
|
||||
('om_price_unit', '米', '米', 4),
|
||||
('om_price_unit', '根', '根', 5),
|
||||
('worker_cert_type', '低压电工证', '低压电工证', 1),
|
||||
('worker_cert_type', '高压电工证', '高压电工证', 2),
|
||||
('worker_cert_type', '特种作业证', '特种作业证', 3),
|
||||
('worker_cert_type', '登高证', '登高证', 4),
|
||||
('worker_cert_type', '其他', '其他', 5),
|
||||
('worker_audit_status', '待审核', '0', 1),
|
||||
('worker_audit_status', '已通过', '1', 2),
|
||||
('worker_audit_status', '已驳回', '2', 3);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 4. 管理员账户(显式指定 id=1,密码哈希为 bcrypt('admin123456'))
|
||||
@@ -188,13 +235,14 @@ INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1)
|
||||
ON CONFLICT (user_id, role_id) DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 6. 角色-菜单绑定:ADMIN 拥有全部 32 条菜单,SALESMAN 拥有客户域+项目+统计报表
|
||||
-- 6. 角色-菜单绑定:ADMIN 拥有全部 46 条菜单,SALESMAN 拥有客户域+项目+统计报表
|
||||
-- WORKER、CUSTOMER 不绑定任何菜单
|
||||
-- ------------------------------------------------------------
|
||||
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, 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),
|
||||
(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)
|
||||
@@ -204,8 +252,42 @@ ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||
DELETE FROM sys_role_menu WHERE menu_id IN (25, 26);
|
||||
DELETE FROM sys_menu WHERE id IN (25, 26);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 7. 定时任务域菜单(系统管理 parent_id=6,id:47 为菜单项,48-50 为按钮权限)
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||
(47, 6, '定时任务', '/system/job', 'system/job/index', 'ClockCircleOutlined', 6, 2, 'system:job', 1),
|
||||
(48, 47, '编辑定时任务', NULL, NULL, NULL, 1, 3, 'system:job:update', 1),
|
||||
(49, 47, '立即执行', NULL, NULL, NULL, 2, 3, 'system:job:run', 1),
|
||||
(50, 47, '清理日志', NULL, NULL, NULL, 3, 3, 'system:job:log:clear', 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)拥有 47-50
|
||||
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||
(1, 47), (1, 48), (1, 49), (1, 50)
|
||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 8. 定时任务种子(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,
|
||||
'示例心跳任务:用于验证调度链路,默认每 5 分钟写入一条 info 日志'),
|
||||
('log_clean', '日志清理', '0 3 * * *', '{"keepDays":90}'::jsonb, 2, 60, 1,
|
||||
'日志清理任务:每日 03:00 清理早于 keepDays 天的操作日志与任务日志,默认保留 90 天')
|
||||
ON CONFLICT (job_key) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ============================================================
|
||||
-- 完 · 7 张系统表种子数据(4 角色 + 32 菜单 + 22 字典 + 1 管理员 + 绑定关系)
|
||||
-- 完 · 系统表种子数据(4 角色 + 50 菜单 + 43 字典 + 1 管理员 + 2 定时任务 + 绑定关系)
|
||||
-- ============================================================
|
||||
@@ -0,0 +1,221 @@
|
||||
-- 运维费定价 + 电工登记管理功能扩展
|
||||
-- 背景:运营派单域由「派单执行」扩展为「客户运维服务价目 + 电工派单成本价 + 电工登记审核」,
|
||||
-- 对应新增 service_price(客户运维服务价目)、worker_cert(电工资质证照)、
|
||||
-- sys_notice(证照到期站内通知)三张表,并为 worker / dispatch_price 补齐
|
||||
-- 登录、审核、计价方式等业务字段,同时挂载运营派单子菜单与按钮权限。
|
||||
-- 幂等:新增列一律带 DEFAULT + IF NOT EXISTS;建表/索引用 IF NOT EXISTS;
|
||||
-- 菜单/角色绑定用 ON CONFLICT;字典用「先 DELETE 再 INSERT」收敛。
|
||||
-- 注意:本文件不 TRUNCATE sys_dict(避免清空其它字典及重置 id 影响 db_seed.sql)。
|
||||
BEGIN;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 1) worker 表扩展:电工 APP 登录与入驻审核
|
||||
-- ------------------------------------------------------------
|
||||
ALTER TABLE worker ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255); -- 电工APP登录密码bcrypt哈希
|
||||
ALTER TABLE worker ADD COLUMN IF NOT EXISTS audit_status SMALLINT NOT NULL DEFAULT 0; -- 审核状态:0待审核 1已通过 2已驳回
|
||||
ALTER TABLE worker ADD COLUMN IF NOT EXISTS audit_remark VARCHAR(255); -- 驳回理由/审核备注
|
||||
ALTER TABLE worker ADD COLUMN IF NOT EXISTS audit_by BIGINT; -- 审核人(→ sys_user.id)
|
||||
ALTER TABLE worker ADD COLUMN IF NOT EXISTS audit_at TIMESTAMPTZ; -- 审核时间
|
||||
ALTER TABLE worker ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ; -- 最后登录时间
|
||||
|
||||
COMMENT ON COLUMN worker.password_hash IS '电工APP登录密码bcrypt哈希';
|
||||
COMMENT ON COLUMN worker.audit_status IS '审核状态:0待审核 1已通过 2已驳回';
|
||||
COMMENT ON COLUMN worker.audit_remark IS '驳回理由/审核备注';
|
||||
COMMENT ON COLUMN worker.audit_by IS '审核人(→ sys_user.id)';
|
||||
COMMENT ON COLUMN worker.audit_at IS '审核时间';
|
||||
COMMENT ON COLUMN worker.last_login_at IS '最后登录时间';
|
||||
|
||||
-- 电工登记台账按审核状态过滤/统计
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_audit_status ON worker (audit_status);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 2) 电工资质证照表(电工登记管理的资质附件与到期提醒依据)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS worker_cert (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
worker_id BIGINT NOT NULL REFERENCES worker(id) ON DELETE CASCADE,
|
||||
cert_type VARCHAR(64), -- 证书类型(字典 worker_cert_type)
|
||||
cert_no VARCHAR(64), -- 证书编号
|
||||
issue_authority VARCHAR(128), -- 发证机关
|
||||
issue_date DATE, -- 发证日期
|
||||
expire_date DATE, -- 有效期至(到期提醒依据;NULL=长期有效)
|
||||
cert_image_url VARCHAR(512), -- 证照图片对象key
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE worker_cert IS '电工资质证照表';
|
||||
COMMENT ON COLUMN worker_cert.id IS '自增主键';
|
||||
COMMENT ON COLUMN worker_cert.worker_id IS '共享电工ID(→ worker)';
|
||||
COMMENT ON COLUMN worker_cert.cert_type IS '证书类型(字典 worker_cert_type)';
|
||||
COMMENT ON COLUMN worker_cert.cert_no IS '证书编号';
|
||||
COMMENT ON COLUMN worker_cert.issue_authority IS '发证机关';
|
||||
COMMENT ON COLUMN worker_cert.issue_date IS '发证日期';
|
||||
COMMENT ON COLUMN worker_cert.expire_date IS '有效期至(到期提醒依据;NULL=长期有效)';
|
||||
COMMENT ON COLUMN worker_cert.cert_image_url IS '证照图片对象key';
|
||||
COMMENT ON COLUMN worker_cert.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN worker_cert.updated_at IS '更新时间';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_cert_worker ON worker_cert (worker_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_worker_cert_expire ON worker_cert (expire_date);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_worker_cert_type_no ON worker_cert (worker_id, cert_type, cert_no);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 3) 客户运维服务价目表(运维费定价)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS service_price (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
price_code VARCHAR(64) NOT NULL UNIQUE,
|
||||
price_name VARCHAR(64) NOT NULL,
|
||||
service_type VARCHAR(32),
|
||||
price_mode SMALLINT NOT NULL DEFAULT 1,
|
||||
unit_price NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||
unit VARCHAR(16),
|
||||
status SMALLINT NOT NULL DEFAULT 1,
|
||||
remark VARCHAR(255),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE service_price IS '客户运维服务价目表';
|
||||
COMMENT ON COLUMN service_price.id IS '自增主键';
|
||||
COMMENT ON COLUMN service_price.price_code IS '价目编码(唯一,FW+YYYYMMDD+3位)';
|
||||
COMMENT ON COLUMN service_price.price_name IS '价目名称';
|
||||
COMMENT ON COLUMN service_price.service_type IS '服务类型:维修/维保/报警处理/巡检/其他';
|
||||
COMMENT ON COLUMN service_price.price_mode IS '计价方式:1按次 2按工时 3材料';
|
||||
COMMENT ON COLUMN service_price.unit_price IS '单价';
|
||||
COMMENT ON COLUMN service_price.unit IS '计价单位:次/小时/个/米/根';
|
||||
COMMENT ON COLUMN service_price.status IS '状态:1启用 0停用';
|
||||
COMMENT ON COLUMN service_price.remark IS '备注';
|
||||
COMMENT ON COLUMN service_price.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN service_price.updated_at IS '更新时间';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_service_price_code ON service_price (price_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_price_mode_status ON service_price (price_mode, status);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 4) dispatch_price 表扩展:电工派单成本价
|
||||
-- ------------------------------------------------------------
|
||||
ALTER TABLE dispatch_price ADD COLUMN IF NOT EXISTS price_mode SMALLINT NOT NULL DEFAULT 1; -- 计价方式:1按次 2按工时
|
||||
ALTER TABLE dispatch_price ADD COLUMN IF NOT EXISTS unit VARCHAR(16) DEFAULT '次'; -- 计价单位
|
||||
ALTER TABLE dispatch_price ADD COLUMN IF NOT EXISTS status SMALLINT NOT NULL DEFAULT 1; -- 状态:1启用 0停用
|
||||
ALTER TABLE dispatch_price ADD COLUMN IF NOT EXISTS remark VARCHAR(255); -- 备注
|
||||
ALTER TABLE dispatch_price ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
|
||||
COMMENT ON COLUMN dispatch_price.price_mode IS '计价方式:1按次 2按工时';
|
||||
COMMENT ON COLUMN dispatch_price.unit IS '计价单位';
|
||||
COMMENT ON COLUMN dispatch_price.status IS '状态:1启用 0停用';
|
||||
COMMENT ON COLUMN dispatch_price.remark IS '备注';
|
||||
COMMENT ON COLUMN dispatch_price.updated_at IS '更新时间';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_price_status ON dispatch_price (status);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 5) 站内通知表(当前用途:电工资质证照到期提醒)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS sys_notice (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
notice_type VARCHAR(32) NOT NULL,
|
||||
title VARCHAR(128) NOT NULL,
|
||||
content VARCHAR(512),
|
||||
biz_type VARCHAR(32),
|
||||
biz_id BIGINT,
|
||||
receiver_id BIGINT NOT NULL,
|
||||
is_read SMALLINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE sys_notice IS '站内通知表';
|
||||
COMMENT ON COLUMN sys_notice.id IS '自增主键';
|
||||
COMMENT ON COLUMN sys_notice.notice_type IS '通知类型:cert_expire证照到期';
|
||||
COMMENT ON COLUMN sys_notice.title IS '通知标题';
|
||||
COMMENT ON COLUMN sys_notice.content IS '通知内容';
|
||||
COMMENT ON COLUMN sys_notice.biz_type IS '业务类型:worker_cert电工资质证照';
|
||||
COMMENT ON COLUMN sys_notice.biz_id IS '业务主键(→ worker_cert.id)';
|
||||
COMMENT ON COLUMN sys_notice.receiver_id IS '接收人(→ sys_user.id)';
|
||||
COMMENT ON COLUMN sys_notice.is_read IS '是否已读:0未读 1已读';
|
||||
COMMENT ON COLUMN sys_notice.created_at IS '创建时间';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sys_notice_receiver ON sys_notice (receiver_id, is_read);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_notice_biz ON sys_notice (notice_type, biz_id, receiver_id);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 6) 菜单与权限
|
||||
-- 「运营派单」由顶级菜单项改为目录,运维费定价/成本价/电工登记作为其子菜单
|
||||
-- ------------------------------------------------------------
|
||||
UPDATE sys_menu SET menu_type = 1 WHERE id = 3;
|
||||
|
||||
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', 1, 2, 'dispatch:price', 1),
|
||||
(35, 3, '电工派单成本价', '/dispatch/cost', 'dispatch/cost/index', 'AccountBookOutlined', 2, 2, 'dispatch:cost', 1),
|
||||
(36, 3, '电工登记管理', '/dispatch/worker', 'dispatch/worker/index', 'ToolOutlined', 3, 2, 'dispatch:worker', 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;
|
||||
|
||||
-- 按钮权限(menu_type=3,无路由/组件/图标)
|
||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||
(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, 3, '新增成本价', NULL, NULL, NULL, 4, 3, 'cost:create', 1),
|
||||
(41, 3, '编辑成本价', NULL, NULL, NULL, 5, 3, 'cost:update', 1),
|
||||
(42, 3, '删除成本价', NULL, NULL, NULL, 6, 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)
|
||||
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)拥有 34~46
|
||||
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||
(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)
|
||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||
|
||||
-- 重置 sys_menu 自增序列,避免后续插入 id 冲突
|
||||
SELECT setval('sys_menu_id_seq', 46, true);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 7) 字典(sys_dict 无业务唯一键,先 DELETE 目标 dict_type 再 INSERT,保证幂等)
|
||||
-- ------------------------------------------------------------
|
||||
DELETE FROM sys_dict WHERE dict_type IN (
|
||||
'om_service_type', 'om_price_mode', 'om_price_unit', 'worker_cert_type', 'worker_audit_status'
|
||||
);
|
||||
|
||||
INSERT INTO sys_dict (dict_type, dict_label, dict_value, sort_order) VALUES
|
||||
('om_service_type', '维修', '维修', 1),
|
||||
('om_service_type', '维保', '维保', 2),
|
||||
('om_service_type', '报警处理', '报警处理', 3),
|
||||
('om_service_type', '巡检', '巡检', 4),
|
||||
('om_service_type', '其他', '其他', 5),
|
||||
('om_price_mode', '按次', '1', 1),
|
||||
('om_price_mode', '按工时', '2', 2),
|
||||
('om_price_mode', '材料', '3', 3),
|
||||
('om_price_unit', '次', '次', 1),
|
||||
('om_price_unit', '小时', '小时', 2),
|
||||
('om_price_unit', '个', '个', 3),
|
||||
('om_price_unit', '米', '米', 4),
|
||||
('om_price_unit', '根', '根', 5),
|
||||
('worker_cert_type', '低压电工证', '低压电工证', 1),
|
||||
('worker_cert_type', '高压电工证', '高压电工证', 2),
|
||||
('worker_cert_type', '特种作业证', '特种作业证', 3),
|
||||
('worker_cert_type', '登高证', '登高证', 4),
|
||||
('worker_cert_type', '其他', '其他', 5),
|
||||
('worker_audit_status', '待审核', '0', 1),
|
||||
('worker_audit_status', '已通过', '1', 2),
|
||||
('worker_audit_status', '已驳回', '2', 3);
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,123 @@
|
||||
-- 定时任务管理功能扩展
|
||||
-- 背景:系统周期性后台作业(物化视图刷新、日志清理等)分散在 pg_cron 与应用 @midwayjs/cron 注解中,
|
||||
-- 运维无法在管理端查看/启停/调频/追溯执行记录。本期新增「自研轻量调度器 + 内置任务注册表」,
|
||||
-- 新增 sys_job(任务定义与调度配置)、sys_job_log(执行日志)两张表,
|
||||
-- 并挂载系统管理下的「定时任务」菜单与按钮权限。
|
||||
-- 幂等:建表/索引用 IF NOT EXISTS;菜单/角色绑定用 ON CONFLICT;任务种子用 ON CONFLICT (job_key) DO NOTHING。
|
||||
-- 注意:菜单 id 47-50,避开并行规格 add-om-pricing-and-worker-registry 已占用的 34-46。
|
||||
BEGIN;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 1) 任务定义与调度配置表
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS sys_job (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
job_key VARCHAR(64) NOT NULL UNIQUE, -- 任务键,与代码注册表一一对应
|
||||
job_name VARCHAR(64) NOT NULL, -- 任务名称
|
||||
cron_expression VARCHAR(64) NOT NULL, -- cron 表达式
|
||||
params JSONB NOT NULL DEFAULT '{}'::jsonb, -- 任务参数(受白名单约束)
|
||||
retry_times SMALLINT NOT NULL DEFAULT 0, -- 失败后最大重试次数 0-5
|
||||
retry_interval INT NOT NULL DEFAULT 60, -- 失败后重试间隔(秒 10-3600)
|
||||
status SMALLINT NOT NULL DEFAULT 1, -- 1启用 0停用
|
||||
remark VARCHAR(255), -- 备注
|
||||
last_run_at TIMESTAMPTZ, -- 上次执行时间
|
||||
last_run_status SMALLINT, -- 上次执行状态:1成功 2失败 3跳过
|
||||
last_run_cost_ms INT, -- 上次执行耗时(毫秒)
|
||||
last_error TEXT, -- 上次失败原因(成功时清空)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE sys_job IS '定时任务定义与调度配置表';
|
||||
COMMENT ON COLUMN sys_job.id IS '自增主键';
|
||||
COMMENT ON COLUMN sys_job.job_key IS '任务键,与代码注册表一一对应';
|
||||
COMMENT ON COLUMN sys_job.job_name IS '任务名称';
|
||||
COMMENT ON COLUMN sys_job.cron_expression IS 'cron 表达式(分 时 日 月 周,支持 6 段带秒)';
|
||||
COMMENT ON COLUMN sys_job.params IS '任务参数(受白名单字段约束)';
|
||||
COMMENT ON COLUMN sys_job.retry_times IS '失败后最大重试次数 0-5';
|
||||
COMMENT ON COLUMN sys_job.retry_interval IS '失败后重试间隔(秒 10-3600)';
|
||||
COMMENT ON COLUMN sys_job.status IS '状态:1启用 0停用';
|
||||
COMMENT ON COLUMN sys_job.remark IS '备注';
|
||||
COMMENT ON COLUMN sys_job.last_run_at IS '上次执行时间';
|
||||
COMMENT ON COLUMN sys_job.last_run_status IS '上次执行状态:1成功 2失败 3跳过';
|
||||
COMMENT ON COLUMN sys_job.last_run_cost_ms IS '上次执行耗时(毫秒)';
|
||||
COMMENT ON COLUMN sys_job.last_error IS '上次失败原因(成功时清空)';
|
||||
COMMENT ON COLUMN sys_job.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN sys_job.updated_at IS '更新时间';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_job_key ON sys_job (job_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_sys_job_status ON sys_job (status);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 2) 任务执行日志表
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS sys_job_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
job_id BIGINT NOT NULL REFERENCES sys_job(id) ON DELETE CASCADE, -- 任务ID(→ sys_job.id)
|
||||
job_key VARCHAR(64) NOT NULL, -- 任务键(冗余,便于查询)
|
||||
job_name VARCHAR(64) NOT NULL, -- 任务名称(冗余)
|
||||
trigger_type SMALLINT NOT NULL DEFAULT 1, -- 触发方式:1定时自动 2手动触发 3失败重试
|
||||
status SMALLINT NOT NULL, -- 1成功 2失败 3跳过
|
||||
attempt SMALLINT NOT NULL DEFAULT 1, -- 第几次尝试
|
||||
started_at TIMESTAMPTZ, -- 开始时间
|
||||
finished_at TIMESTAMPTZ, -- 结束时间
|
||||
cost_ms INT, -- 执行耗时(毫秒)
|
||||
message TEXT, -- 结果摘要
|
||||
error_stack TEXT, -- 异常堆栈(截断 4000 字符)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
COMMENT ON TABLE sys_job_log IS '定时任务执行日志表';
|
||||
COMMENT ON COLUMN sys_job_log.id IS '自增主键';
|
||||
COMMENT ON COLUMN sys_job_log.job_id IS '任务ID(→ sys_job.id)';
|
||||
COMMENT ON COLUMN sys_job_log.job_key IS '任务键(冗余,便于查询)';
|
||||
COMMENT ON COLUMN sys_job_log.job_name IS '任务名称(冗余)';
|
||||
COMMENT ON COLUMN sys_job_log.trigger_type IS '触发方式:1定时自动 2手动触发 3失败重试';
|
||||
COMMENT ON COLUMN sys_job_log.status IS '执行状态:1成功 2失败 3跳过';
|
||||
COMMENT ON COLUMN sys_job_log.attempt IS '第几次尝试';
|
||||
COMMENT ON COLUMN sys_job_log.started_at IS '开始时间';
|
||||
COMMENT ON COLUMN sys_job_log.finished_at IS '结束时间';
|
||||
COMMENT ON COLUMN sys_job_log.cost_ms IS '执行耗时(毫秒)';
|
||||
COMMENT ON COLUMN sys_job_log.message IS '结果摘要';
|
||||
COMMENT ON COLUMN sys_job_log.error_stack IS '异常堆栈(截断 4000 字符)';
|
||||
COMMENT ON COLUMN sys_job_log.created_at IS '创建时间';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sys_job_log_job_created ON sys_job_log (job_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_sys_job_log_created ON sys_job_log (created_at);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 3) 菜单与权限(系统管理 parent_id=6 下新增「定时任务」菜单 + 3 个按钮权限)
|
||||
-- ------------------------------------------------------------
|
||||
INSERT INTO sys_menu (id, parent_id, menu_name, menu_path, component, icon, sort_order, menu_type, permission, visible) VALUES
|
||||
(47, 6, '定时任务', '/system/job', 'system/job/index', 'ClockCircleOutlined', 6, 2, 'system:job', 1),
|
||||
(48, 47, '编辑定时任务', NULL, NULL, NULL, 1, 3, 'system:job:update', 1),
|
||||
(49, 47, '立即执行', NULL, NULL, NULL, 2, 3, 'system:job:run', 1),
|
||||
(50, 47, '清理日志', NULL, NULL, NULL, 3, 3, 'system:job:log:clear', 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)拥有 47~50
|
||||
INSERT INTO sys_role_menu (role_id, menu_id) VALUES
|
||||
(1, 47), (1, 48), (1, 49), (1, 50)
|
||||
ON CONFLICT (role_id, menu_id) DO NOTHING;
|
||||
|
||||
-- 重置 sys_menu 自增序列,避免后续插入 id 冲突
|
||||
SELECT setval('sys_menu_id_seq', 50, true);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 4) 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,
|
||||
'示例心跳任务:用于验证调度链路,默认每 5 分钟写入一条 info 日志'),
|
||||
('log_clean', '日志清理', '0 3 * * *', '{"keepDays":90}'::jsonb, 2, 60, 1,
|
||||
'日志清理任务:每日 03:00 清理早于 keepDays 天的操作日志与任务日志,默认保留 90 天')
|
||||
ON CONFLICT (job_key) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
@@ -17,6 +17,7 @@
|
||||
"@aws-sdk/s3-request-presigner": "^3.1131.0",
|
||||
"@midwayjs/cache-manager": "^3.19.0",
|
||||
"@midwayjs/core": "^3.19.0",
|
||||
"@midwayjs/cron": "^3.19.0",
|
||||
"@midwayjs/koa": "^3.19.0",
|
||||
"@midwayjs/logger": "^4.0.1",
|
||||
"@midwayjs/sequelize": "^3.19.0",
|
||||
@@ -27,6 +28,7 @@
|
||||
"cache-manager-ioredis": "^2.1.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cron": "^3.5.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^17.4.2",
|
||||
"ioredis": "^5.4.1",
|
||||
|
||||
@@ -2,14 +2,17 @@ import { Configuration, App, ILifeCycle } from "@midwayjs/core";
|
||||
import * as Koa from "@midwayjs/koa";
|
||||
import * as Sequelize from "@midwayjs/sequelize";
|
||||
import * as CacheManager from "@midwayjs/cache-manager";
|
||||
import * as Cron from "@midwayjs/cron";
|
||||
import * as Upload from "@midwayjs/upload";
|
||||
import { join } from "path";
|
||||
import { AuthMiddleware } from "./middleware/auth.middleware";
|
||||
import { WorkerAuthMiddleware } from "./middleware/worker_auth.middleware";
|
||||
import { RbacMiddleware } from "./middleware/rbac.middleware";
|
||||
import { LogMiddleware } from "./middleware/log.middleware";
|
||||
import { JobSchedulerService } from "./service/job_scheduler.service";
|
||||
|
||||
@Configuration({
|
||||
imports: [Koa, Sequelize, CacheManager, Upload],
|
||||
imports: [Koa, Sequelize, CacheManager, Upload, Cron],
|
||||
importConfigs: [join(__dirname, "./config")]
|
||||
})
|
||||
export class ContainerConfigLifeCycle implements ILifeCycle {
|
||||
@@ -17,12 +20,24 @@ export class ContainerConfigLifeCycle implements ILifeCycle {
|
||||
app!: Koa.Application;
|
||||
|
||||
async onReady() {
|
||||
// 注册全局中间件(顺序:鉴权 → 权限 → 日志)
|
||||
this.app.useMiddleware([AuthMiddleware, RbacMiddleware, LogMiddleware]);
|
||||
// 注册全局中间件(顺序:电工鉴权 → 鉴权 → 权限 → 日志)
|
||||
this.app.useMiddleware([WorkerAuthMiddleware, AuthMiddleware, RbacMiddleware, LogMiddleware]);
|
||||
try {
|
||||
const scheduler = await this.app.getApplicationContext().getAsync(JobSchedulerService);
|
||||
await scheduler.start();
|
||||
} catch (err) {
|
||||
console.error("[定时任务] 调度器启动失败:", err);
|
||||
}
|
||||
console.log("智能物联网设备后台管理系统启动成功");
|
||||
}
|
||||
|
||||
async onStop() {
|
||||
try {
|
||||
const scheduler = await this.app.getApplicationContext().getAsync(JobSchedulerService);
|
||||
await scheduler.stop();
|
||||
} catch (err) {
|
||||
console.error("[定时任务] 调度器停止失败:", err);
|
||||
}
|
||||
console.log("系统已停止");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { DispatchPriceService } from "../service/dispatch_price.service";
|
||||
|
||||
@Controller("/api/dispatch-prices")
|
||||
export class DispatchPriceController {
|
||||
@Inject()
|
||||
ctx!: Context;
|
||||
|
||||
@Inject()
|
||||
dispatchPriceService!: DispatchPriceService;
|
||||
|
||||
@Get("/")
|
||||
async list(@Query() query: any) {
|
||||
return this.dispatchPriceService.list(query, this.ctx);
|
||||
}
|
||||
|
||||
@Get("/:id")
|
||||
async detail(@Param("id") id: string) {
|
||||
return this.dispatchPriceService.detail(Number(id));
|
||||
}
|
||||
|
||||
@Post("/")
|
||||
async create(@Body() body: any) {
|
||||
return this.dispatchPriceService.create(body, this.ctx);
|
||||
}
|
||||
|
||||
@Put("/:id")
|
||||
async update(@Param("id") id: string, @Body() body: any) {
|
||||
return this.dispatchPriceService.update(Number(id), body, this.ctx);
|
||||
}
|
||||
|
||||
@Del("/:id")
|
||||
async delete(@Param("id") id: string) {
|
||||
return this.dispatchPriceService.delete(Number(id), this.ctx);
|
||||
}
|
||||
|
||||
@Put("/:id/status")
|
||||
async updateStatus(@Param("id") id: string, @Body() body: { status: number }) {
|
||||
return this.dispatchPriceService.updateStatus(Number(id), body.status, this.ctx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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";
|
||||
|
||||
@Controller("/api/jobs")
|
||||
export class JobController {
|
||||
@Inject()
|
||||
jobService!: JobService;
|
||||
|
||||
@Get("/")
|
||||
async list(@Query() query: { page?: number; pageSize?: number; jobName?: string; status?: number }) {
|
||||
return this.jobService.list(query);
|
||||
}
|
||||
|
||||
// 静态路径必须声明在动态路径之前
|
||||
@Del("/logs/clear")
|
||||
async clearLogs(@Query("days") days?: string) {
|
||||
const parsed = days ? Number(days) : 90;
|
||||
return this.jobService.clearLogs(parsed);
|
||||
}
|
||||
|
||||
@Put("/:id")
|
||||
async update(@Param("id") id: string, @Body() dto: UpdateJobDTO) {
|
||||
return this.jobService.update(Number(id), dto);
|
||||
}
|
||||
|
||||
@Put("/:id/status")
|
||||
async changeStatus(@Param("id") id: string, @Body() dto: JobStatusDTO) {
|
||||
return this.jobService.changeStatus(Number(id), dto.status);
|
||||
}
|
||||
|
||||
@Post("/:id/run")
|
||||
async run(@Param("id") id: string) {
|
||||
return this.jobService.run(Number(id));
|
||||
}
|
||||
|
||||
@Get("/:id/logs")
|
||||
async listLogs(@Param("id") id: string, @Query() query: JobLogQueryDTO) {
|
||||
return this.jobService.listLogs(Number(id), query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Controller, Get, Put, Inject, Query, Param } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { NoticeService } from "../service/notice.service";
|
||||
|
||||
@Controller("/api/notices")
|
||||
export class NoticeController {
|
||||
@Inject()
|
||||
ctx!: Context;
|
||||
|
||||
@Inject()
|
||||
noticeService!: NoticeService;
|
||||
|
||||
@Get("/")
|
||||
async list(@Query() query: any) {
|
||||
return await this.noticeService.listMy(this.currentUserId(), query);
|
||||
}
|
||||
|
||||
// 注意:静态路径必须注册在动态路径 /:id/read 之前
|
||||
@Get("/unread-count")
|
||||
async unreadCount() {
|
||||
return await this.noticeService.unreadCount(this.currentUserId());
|
||||
}
|
||||
|
||||
@Put("/read-all")
|
||||
async markAllRead() {
|
||||
return await this.noticeService.markAllRead(this.currentUserId());
|
||||
}
|
||||
|
||||
@Put("/:id/read")
|
||||
async markRead(@Param("id") id: string) {
|
||||
return await this.noticeService.markRead(this.currentUserId(), Number(id));
|
||||
}
|
||||
|
||||
private currentUserId(): number {
|
||||
return Number(this.ctx.state.user?.userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { ServicePriceService } from "../service/service_price.service";
|
||||
|
||||
@Controller("/api/service-prices")
|
||||
export class ServicePriceController {
|
||||
@Inject()
|
||||
ctx!: Context;
|
||||
|
||||
@Inject()
|
||||
servicePriceService!: ServicePriceService;
|
||||
|
||||
@Get("/")
|
||||
async list(@Query() query: any) {
|
||||
return this.servicePriceService.list(query, this.ctx);
|
||||
}
|
||||
|
||||
@Get("/:id")
|
||||
async detail(@Param("id") id: string) {
|
||||
return this.servicePriceService.detail(Number(id));
|
||||
}
|
||||
|
||||
@Post("/")
|
||||
async create(@Body() body: any) {
|
||||
return this.servicePriceService.create(body, this.ctx);
|
||||
}
|
||||
|
||||
@Put("/:id")
|
||||
async update(@Param("id") id: string, @Body() body: any) {
|
||||
return this.servicePriceService.update(Number(id), body, this.ctx);
|
||||
}
|
||||
|
||||
@Del("/:id")
|
||||
async delete(@Param("id") id: string) {
|
||||
return this.servicePriceService.delete(Number(id), this.ctx);
|
||||
}
|
||||
|
||||
@Put("/:id/status")
|
||||
async updateStatus(@Param("id") id: string, @Body() body: { status: number }) {
|
||||
return this.servicePriceService.updateStatus(Number(id), body.status, this.ctx);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Controller, Get, Inject, Query } from "@midwayjs/core";
|
||||
import { Controller, Get, Post, Put, Del, Inject, Query, Body, Param } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { WorkerService } from "../service/worker.service";
|
||||
import { WorkerCreateDTO, WorkerUpdateDTO, WorkerAuditDTO, WorkerCertDTO } from "../dto/worker.dto";
|
||||
|
||||
@Controller("/api/workers")
|
||||
export class WorkerController {
|
||||
@@ -10,6 +11,34 @@ export class WorkerController {
|
||||
@Get("/") async list(@Query() query: any) { return this.workerService.list(query, this.ctx); }
|
||||
|
||||
// 静态路径必须声明在动态路径之前
|
||||
@Get("/stats") async stats() { return this.workerService.stats(); }
|
||||
@Get("/options") async options() { return this.workerService.options(this.ctx); }
|
||||
@Get("/recommend") async recommend(@Query() query: any) { return this.workerService.recommend(query, this.ctx); }
|
||||
}
|
||||
|
||||
@Get("/:id") async detail(@Param("id") id: string) { return this.workerService.detail(Number(id)); }
|
||||
|
||||
@Post("/") async create(@Body() body: WorkerCreateDTO) { return this.workerService.create(body); }
|
||||
@Put("/:id") async update(@Param("id") id: string, @Body() body: WorkerUpdateDTO) {
|
||||
return this.workerService.update(Number(id), body);
|
||||
}
|
||||
@Del("/:id") async remove(@Param("id") id: string) { return this.workerService.remove(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);
|
||||
}
|
||||
|
||||
@Post("/:id/certs") async addCert(@Param("id") id: string, @Body() body: WorkerCertDTO) {
|
||||
return this.workerService.saveCert(Number(id), body);
|
||||
}
|
||||
@Put("/:id/certs/:certId") async updateCert(
|
||||
@Param("id") id: string,
|
||||
@Param("certId") certId: string,
|
||||
@Body() body: WorkerCertDTO
|
||||
) {
|
||||
return this.workerService.saveCert(Number(id), body, Number(certId));
|
||||
}
|
||||
@Del("/:id/certs/:certId") async removeCert(@Param("id") id: string, @Param("certId") certId: string) {
|
||||
return this.workerService.removeCert(Number(id), Number(certId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Controller, Get, Post, Put, Del, Inject, Body, Param } from "@midwayjs/core";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import { WorkerAppService } from "../service/worker_app.service";
|
||||
import {
|
||||
WorkerRegisterDTO,
|
||||
WorkerLoginDTO,
|
||||
WorkerProfileUpdateDTO,
|
||||
WorkerAppCertDTO
|
||||
} from "../dto/worker_app.dto";
|
||||
|
||||
@Controller("/api/worker-app")
|
||||
export class WorkerAppController {
|
||||
@Inject()
|
||||
ctx!: Context;
|
||||
|
||||
@Inject()
|
||||
workerAppService!: WorkerAppService;
|
||||
|
||||
// 注册(匿名)
|
||||
@Post("/auth/register")
|
||||
async register(@Body() body: WorkerRegisterDTO) {
|
||||
return this.workerAppService.register(body);
|
||||
}
|
||||
|
||||
// 登录(匿名)
|
||||
@Post("/auth/login")
|
||||
async login(@Body() body: WorkerLoginDTO) {
|
||||
return this.workerAppService.login(body);
|
||||
}
|
||||
|
||||
// 个人资料
|
||||
@Get("/profile")
|
||||
async profile() {
|
||||
return this.workerAppService.profile(this.currentWorkerId());
|
||||
}
|
||||
|
||||
@Put("/profile")
|
||||
async updateProfile(@Body() body: WorkerProfileUpdateDTO) {
|
||||
return this.workerAppService.updateProfile(this.currentWorkerId(), body);
|
||||
}
|
||||
|
||||
// 资质证照:静态路径必须声明在动态路径之前
|
||||
@Get("/certs")
|
||||
async listCerts() {
|
||||
return this.workerAppService.listCerts(this.currentWorkerId());
|
||||
}
|
||||
|
||||
@Post("/certs")
|
||||
async createCert(@Body() body: WorkerAppCertDTO) {
|
||||
return this.workerAppService.createCert(this.currentWorkerId(), body);
|
||||
}
|
||||
|
||||
@Put("/certs/:id")
|
||||
async updateCert(@Param("id") id: string, @Body() body: WorkerAppCertDTO) {
|
||||
return this.workerAppService.updateCert(this.currentWorkerId(), Number(id), body);
|
||||
}
|
||||
|
||||
@Del("/certs/:id")
|
||||
async removeCert(@Param("id") id: string) {
|
||||
return this.workerAppService.removeCert(this.currentWorkerId(), Number(id));
|
||||
}
|
||||
|
||||
// 重新提交审核
|
||||
@Post("/audit/resubmit")
|
||||
async resubmit() {
|
||||
return this.workerAppService.resubmit(this.currentWorkerId());
|
||||
}
|
||||
|
||||
// 证照到期提醒
|
||||
@Get("/notices")
|
||||
async listNotices() {
|
||||
return this.workerAppService.listNotices(this.currentWorkerId());
|
||||
}
|
||||
|
||||
// 当前登录电工 ID(由 WorkerAuthMiddleware 写入)
|
||||
private currentWorkerId(): number {
|
||||
return this.ctx.state.worker?.workerId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, IsInt, IsNumber, IsIn, Min, Max, MaxLength } from "class-validator";
|
||||
|
||||
export class DispatchPriceQueryDTO {
|
||||
@IsOptional() @IsInt() @Min(1) page?: number = 1;
|
||||
|
||||
@IsOptional() @IsInt() @Min(1) @Max(200, { message: "分页大小不能超过 200 条" }) pageSize?: number = 10;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(64, { message: "关键词最大 64 字符" }) keyword?: string;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(32) dispatchType?: string;
|
||||
|
||||
@IsOptional() @IsIn([1, 2], { message: "计价方式非法" }) priceMode?: number;
|
||||
|
||||
@IsOptional() @IsIn([0, 1], { message: "状态值非法" }) status?: number;
|
||||
}
|
||||
|
||||
export class DispatchPriceCreateDTO {
|
||||
@IsNotEmpty({ message: "派单类型不能为空" })
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
dispatchType!: string;
|
||||
|
||||
@IsNotEmpty({ message: "单价不能为空" })
|
||||
@IsNumber({}, { message: "单价必须为数字" })
|
||||
unitPrice!: number;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(32) priceType?: string;
|
||||
|
||||
@IsOptional() @IsIn([1, 2], { message: "计价方式非法" }) priceMode?: number;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(16) unit?: string;
|
||||
|
||||
@IsOptional() @IsIn([0, 1], { message: "状态值非法" }) status?: number;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(255) remark?: string;
|
||||
}
|
||||
|
||||
export class DispatchPriceUpdateDTO {
|
||||
@IsOptional() @IsString() @MaxLength(32) dispatchType?: string;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(32) priceType?: string;
|
||||
|
||||
@IsOptional() @IsIn([1, 2], { message: "计价方式非法" }) priceMode?: number;
|
||||
|
||||
@IsOptional() @IsNumber({}, { message: "单价必须为数字" }) unitPrice?: number;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(16) unit?: string;
|
||||
|
||||
@IsOptional() @IsIn([0, 1], { message: "状态值非法" }) status?: number;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(255) remark?: string;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, IsInt, IsObject, Min, Max, MaxLength } from "class-validator";
|
||||
|
||||
export class UpdateJobDTO {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: "cron 表达式不能为空" })
|
||||
@MaxLength(64, { message: "cron 表达式最大 64 字符" })
|
||||
cronExpression!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
params?: Record<string, any>;
|
||||
|
||||
@IsInt()
|
||||
@Min(0, { message: "重试次数不能小于 0" })
|
||||
@Max(5, { message: "重试次数不能超过 5" })
|
||||
retryTimes!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(10, { message: "重试间隔不能小于 10 秒" })
|
||||
@Max(3600, { message: "重试间隔不能超过 3600 秒" })
|
||||
retryInterval!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255, { message: "备注最大 255 字符" })
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export class JobStatusDTO {
|
||||
@IsInt()
|
||||
@Min(0, { message: "状态值非法" })
|
||||
@Max(1, { message: "状态值非法" })
|
||||
status!: number;
|
||||
}
|
||||
|
||||
export class JobLogQueryDTO {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200, { message: "分页大小不能超过 200 条" })
|
||||
pageSize?: number = 10;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
status?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
triggerType?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
endDate?: string;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, IsInt, IsNumber, IsIn, Min, Max, MaxLength } from "class-validator";
|
||||
|
||||
export class ServicePriceQueryDTO {
|
||||
@IsOptional() @IsInt() @Min(1) page?: number = 1;
|
||||
|
||||
@IsOptional() @IsInt() @Min(1) @Max(200, { message: "分页大小不能超过 200 条" }) pageSize?: number = 10;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(64, { message: "关键词最大 64 字符" }) keyword?: string;
|
||||
|
||||
@IsOptional() @IsIn([1, 2, 3], { message: "计价方式非法" }) priceMode?: number;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(32) serviceType?: string;
|
||||
|
||||
@IsOptional() @IsIn([0, 1], { message: "状态值非法" }) status?: number;
|
||||
}
|
||||
|
||||
export class ServicePriceCreateDTO {
|
||||
@IsNotEmpty({ message: "价目名称不能为空" })
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
priceName!: string;
|
||||
|
||||
@IsNotEmpty({ message: "单价不能为空" })
|
||||
@IsNumber({}, { message: "单价必须为数字" })
|
||||
unitPrice!: number;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(32) serviceType?: string;
|
||||
|
||||
@IsOptional() @IsIn([1, 2, 3], { message: "计价方式非法" }) priceMode?: number;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(16) unit?: string;
|
||||
|
||||
@IsOptional() @IsIn([0, 1], { message: "状态值非法" }) status?: number;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(255) remark?: string;
|
||||
}
|
||||
|
||||
export class ServicePriceUpdateDTO {
|
||||
@IsOptional() @IsString() @MaxLength(64) priceName?: string;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(32) serviceType?: string;
|
||||
|
||||
@IsOptional() @IsIn([1, 2, 3], { message: "计价方式非法" }) priceMode?: number;
|
||||
|
||||
@IsOptional() @IsNumber({}, { message: "单价必须为数字" }) unitPrice?: number;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(16) unit?: string;
|
||||
|
||||
@IsOptional() @IsIn([0, 1], { message: "状态值非法" }) status?: number;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(255) remark?: string;
|
||||
}
|
||||
@@ -1,8 +1,27 @@
|
||||
import { IsOptional, IsString, IsInt, Min, Max, MaxLength } from "class-validator";
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
ValidateNested,
|
||||
Matches,
|
||||
Min,
|
||||
Max,
|
||||
MaxLength
|
||||
} from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
|
||||
export class WorkerQueryDTO {
|
||||
@IsOptional() @IsString() @MaxLength(64) keyword?: string;
|
||||
@IsOptional() @IsInt() status?: number;
|
||||
// 审核状态筛选:0待审核 1已通过 2已驳回
|
||||
@IsOptional() @IsInt() @IsIn([0, 1, 2]) auditStatus?: number;
|
||||
// 资质状态筛选:none无证书 normal正常 expiring临期 expired已过期
|
||||
@IsOptional() @IsString() @IsIn(["none", "normal", "expiring", "expired"]) certStatus?: string;
|
||||
@IsOptional() @IsInt() @Min(1) page?: number = 1;
|
||||
@IsOptional() @IsInt() @Min(1) @Max(200) pageSize?: number = 10;
|
||||
// 兼容部分前端分页参数命名
|
||||
@@ -14,3 +33,52 @@ export class WorkerRecommendQueryDTO {
|
||||
@IsOptional() @IsInt() @Min(1) @Max(3) priority?: number;
|
||||
@IsOptional() @IsString() @MaxLength(64) certKeyword?: string;
|
||||
}
|
||||
|
||||
// 单条资质证照
|
||||
export class WorkerCertDTO {
|
||||
@IsNotEmpty({ message: "证书类型不能为空" }) @IsString() @MaxLength(64) certType!: string;
|
||||
@IsNotEmpty({ message: "证书编号不能为空" }) @IsString() @MaxLength(64) certNo!: string;
|
||||
@IsOptional() @IsString() @MaxLength(128) issueAuthority?: string;
|
||||
@IsOptional() @IsString() @MaxLength(10) issueDate?: string;
|
||||
// 有效期至(NULL/缺省=长期有效)
|
||||
@IsOptional() @IsString() @MaxLength(10) expireDate?: string;
|
||||
@IsOptional() @IsString() @MaxLength(512) certImageUrl?: string;
|
||||
}
|
||||
|
||||
// 证书整体保存(覆盖式)
|
||||
export class WorkerCertListDTO {
|
||||
@IsArray() @ValidateNested({ each: true }) @Type(() => WorkerCertDTO) certs!: WorkerCertDTO[];
|
||||
}
|
||||
|
||||
// 电工新增
|
||||
export class WorkerCreateDTO {
|
||||
@IsNotEmpty({ message: "电工姓名不能为空" }) @IsString() @MaxLength(64) workerName!: string;
|
||||
@IsNotEmpty({ message: "手机号不能为空" }) @Matches(/^1[3-9]\d{9}$/, { message: "手机号格式不正确" }) phone!: string;
|
||||
@IsOptional() @IsString() @MaxLength(18) idCard?: string;
|
||||
@IsOptional() @IsString() @MaxLength(32) workerTitle?: string;
|
||||
@IsOptional() @IsNumber() @Min(0) @Max(5) rating?: number;
|
||||
@IsOptional() @IsString() @MaxLength(128) certInfo?: string;
|
||||
// 是否同时开通电工APP账号(默认密码为手机号后 6 位)
|
||||
@IsOptional() @IsBoolean() withAccount?: boolean = false;
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => WorkerCertDTO) certs?: WorkerCertDTO[];
|
||||
}
|
||||
|
||||
// 电工更新(字段白名单)
|
||||
export class WorkerUpdateDTO {
|
||||
@IsOptional() @IsString() @MaxLength(64) workerName?: string;
|
||||
@IsOptional() @Matches(/^1[3-9]\d{9}$/, { message: "手机号格式不正确" }) phone?: string;
|
||||
@IsOptional() @IsString() @MaxLength(18) idCard?: string;
|
||||
@IsOptional() @IsString() @MaxLength(32) workerTitle?: string;
|
||||
@IsOptional() @IsInt() @IsIn([0, 1]) status?: number;
|
||||
@IsOptional() @IsNumber() @Min(0) @Max(5) rating?: number;
|
||||
@IsOptional() @IsString() @MaxLength(128) certInfo?: string;
|
||||
// 存在时整体覆盖该电工的全部证书
|
||||
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => WorkerCertDTO) certs?: WorkerCertDTO[];
|
||||
}
|
||||
|
||||
// 资质审核
|
||||
export class WorkerAuditDTO {
|
||||
@IsNotEmpty({ message: "审核结果不能为空" }) @IsBoolean() approved!: boolean;
|
||||
// 驳回理由(驳回时必填)
|
||||
@IsOptional() @IsString() @MaxLength(255) remark?: string;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, IsDateString, MinLength, MaxLength, Matches } from "class-validator";
|
||||
|
||||
// 电工 APP 注册
|
||||
export class WorkerRegisterDTO {
|
||||
@IsNotEmpty({ message: "电工姓名不能为空" })
|
||||
@IsString()
|
||||
@MaxLength(64, { message: "电工姓名最大 64 字符" })
|
||||
workerName!: string;
|
||||
|
||||
@IsNotEmpty({ message: "手机号不能为空" })
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: "手机号格式不正确" })
|
||||
phone!: string;
|
||||
|
||||
@IsNotEmpty({ message: "密码不能为空" })
|
||||
@IsString()
|
||||
@MinLength(6, { message: "密码长度不能小于6个字符" })
|
||||
@MaxLength(64, { message: "密码长度不能超过64个字符" })
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(18, { message: "身份证号最大 18 字符" })
|
||||
idCard?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32, { message: "职衔最大 32 字符" })
|
||||
workerTitle?: string;
|
||||
}
|
||||
|
||||
// 电工 APP 登录
|
||||
export class WorkerLoginDTO {
|
||||
@IsNotEmpty({ message: "手机号不能为空" })
|
||||
@Matches(/^1[3-9]\d{9}$/, { message: "手机号格式不正确" })
|
||||
phone!: string;
|
||||
|
||||
@IsNotEmpty({ message: "密码不能为空" })
|
||||
@IsString()
|
||||
password!: string;
|
||||
}
|
||||
|
||||
// 电工 APP 个人资料更新
|
||||
export class WorkerProfileUpdateDTO {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64, { message: "电工姓名最大 64 字符" })
|
||||
workerName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(18, { message: "身份证号最大 18 字符" })
|
||||
idCard?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32, { message: "职衔最大 32 字符" })
|
||||
workerTitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(6, { message: "密码长度不能小于6个字符" })
|
||||
@MaxLength(64, { message: "密码长度不能超过64个字符" })
|
||||
password?: string;
|
||||
}
|
||||
|
||||
// 电工资质证照新增/修改
|
||||
export class WorkerAppCertDTO {
|
||||
@IsNotEmpty({ message: "证书类型不能为空" })
|
||||
@IsString()
|
||||
@MaxLength(64, { message: "证书类型最大 64 字符" })
|
||||
certType!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64, { message: "证书编号最大 64 字符" })
|
||||
certNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128, { message: "发证机关最大 128 字符" })
|
||||
issueAuthority?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: "发证日期格式不正确" })
|
||||
issueDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: "有效期格式不正确" })
|
||||
expireDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512, { message: "证照图片地址最大 512 字符" })
|
||||
certImageUrl?: string;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript";
|
||||
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt } from "sequelize-typescript";
|
||||
|
||||
@Table({
|
||||
tableName: "dispatch_price",
|
||||
comment: "共享电工派单价格信息表",
|
||||
timestamps: true,
|
||||
createdAt: "created_at",
|
||||
updatedAt: false
|
||||
updatedAt: "updated_at",
|
||||
indexes: [{ name: "idx_dispatch_price_status", fields: ["status"] }]
|
||||
})
|
||||
export default class DispatchPrice extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
@@ -23,6 +24,21 @@ export default class DispatchPrice extends Model {
|
||||
@Column({ type: DataType.DECIMAL(10, 2), field: "unit_price", comment: "服务单价" })
|
||||
unitPrice!: number;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "price_mode", comment: "计价方式:1按次 2按工时" })
|
||||
priceMode!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(16), defaultValue: "次", field: "unit", comment: "计价单位" })
|
||||
unit!: string;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "status", comment: "状态:1启用 0停用" })
|
||||
status!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(255), field: "remark", comment: "备注" })
|
||||
remark!: string;
|
||||
|
||||
@CreatedAt
|
||||
created_at!: Date;
|
||||
}
|
||||
|
||||
@UpdatedAt
|
||||
updated_at!: Date;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt } from "sequelize-typescript";
|
||||
|
||||
@Table({
|
||||
tableName: "service_price",
|
||||
comment: "客户运维服务价目表",
|
||||
timestamps: true,
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
indexes: [
|
||||
{ name: "uk_service_price_code", unique: true, fields: ["price_code"] },
|
||||
{ name: "idx_service_price_mode_status", fields: ["price_mode", "status"] }
|
||||
]
|
||||
})
|
||||
export default class ServicePrice extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), allowNull: false, unique: true, field: "price_code", comment: "价目编码(唯一,FW+YYYYMMDD+3位)" })
|
||||
priceCode!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), allowNull: false, field: "price_name", comment: "价目名称" })
|
||||
priceName!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(32), field: "service_type", comment: "服务类型:维修/维保/报警处理/巡检/其他" })
|
||||
serviceType!: string;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "price_mode", comment: "计价方式:1按次 2按工时 3材料" })
|
||||
priceMode!: number;
|
||||
|
||||
@Column({ type: DataType.DECIMAL(10, 2), allowNull: false, defaultValue: 0, field: "unit_price", comment: "单价" })
|
||||
unitPrice!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(16), field: "unit", comment: "计价单位:次/小时/个/米/根" })
|
||||
unit!: string;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, field: "status", comment: "状态:1启用 0停用" })
|
||||
status!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(255), field: "remark", comment: "备注" })
|
||||
remark!: string;
|
||||
|
||||
@CreatedAt
|
||||
created_at!: Date;
|
||||
|
||||
@UpdatedAt
|
||||
updated_at!: Date;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt } from "sequelize-typescript";
|
||||
|
||||
@Table({
|
||||
tableName: "sys_job",
|
||||
comment: "定时任务定义与调度配置表",
|
||||
timestamps: true,
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at"
|
||||
})
|
||||
export default class SysJob extends Model {
|
||||
@Column({
|
||||
type: DataType.BIGINT,
|
||||
autoIncrement: true,
|
||||
primaryKey: true
|
||||
})
|
||||
id!: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(64),
|
||||
field: "job_key",
|
||||
comment: "任务键(唯一标识)"
|
||||
})
|
||||
jobKey!: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(64),
|
||||
field: "job_name",
|
||||
comment: "任务名称"
|
||||
})
|
||||
jobName!: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(64),
|
||||
field: "cron_expression",
|
||||
comment: "cron 表达式"
|
||||
})
|
||||
cronExpression!: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.JSONB,
|
||||
field: "params",
|
||||
comment: "任务参数(JSONB)"
|
||||
})
|
||||
params!: Record<string, any>;
|
||||
|
||||
@Column({
|
||||
type: DataType.SMALLINT,
|
||||
field: "retry_times",
|
||||
comment: "重试次数"
|
||||
})
|
||||
retryTimes!: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
field: "retry_interval",
|
||||
comment: "重试间隔(秒)"
|
||||
})
|
||||
retryInterval!: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.SMALLINT,
|
||||
comment: "状态: 1-启用 0-停用"
|
||||
})
|
||||
status!: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(255),
|
||||
comment: "备注"
|
||||
})
|
||||
remark!: string | null;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
field: "last_run_at",
|
||||
comment: "上次执行时间"
|
||||
})
|
||||
lastRunAt!: Date | null;
|
||||
|
||||
@Column({
|
||||
type: DataType.SMALLINT,
|
||||
field: "last_run_status",
|
||||
comment: "上次执行状态: 1-成功 2-失败 3-跳过"
|
||||
})
|
||||
lastRunStatus!: number | null;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
field: "last_run_cost_ms",
|
||||
comment: "上次执行耗时(毫秒)"
|
||||
})
|
||||
lastRunCostMs!: number | null;
|
||||
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
field: "last_error",
|
||||
comment: "上次失败原因"
|
||||
})
|
||||
lastError!: string | null;
|
||||
|
||||
@CreatedAt
|
||||
created_at!: Date;
|
||||
|
||||
@UpdatedAt
|
||||
updated_at!: Date;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript";
|
||||
|
||||
@Table({
|
||||
tableName: "sys_job_log",
|
||||
comment: "定时任务执行日志表",
|
||||
timestamps: true,
|
||||
createdAt: "created_at",
|
||||
updatedAt: false
|
||||
})
|
||||
export default class SysJobLog extends Model {
|
||||
@Column({
|
||||
type: DataType.BIGINT,
|
||||
autoIncrement: true,
|
||||
primaryKey: true
|
||||
})
|
||||
id!: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.BIGINT,
|
||||
field: "job_id",
|
||||
comment: "任务ID"
|
||||
})
|
||||
jobId!: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(64),
|
||||
field: "job_key",
|
||||
comment: "任务键"
|
||||
})
|
||||
jobKey!: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(64),
|
||||
field: "job_name",
|
||||
comment: "任务名称"
|
||||
})
|
||||
jobName!: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.SMALLINT,
|
||||
field: "trigger_type",
|
||||
comment: "触发方式: 1-定时 2-手动 3-重试"
|
||||
})
|
||||
triggerType!: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.SMALLINT,
|
||||
comment: "执行状态: 1-成功 2-失败 3-跳过"
|
||||
})
|
||||
status!: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.SMALLINT,
|
||||
comment: "第几次尝试"
|
||||
})
|
||||
attempt!: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
field: "started_at",
|
||||
comment: "开始时间"
|
||||
})
|
||||
startedAt!: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
field: "finished_at",
|
||||
comment: "结束时间"
|
||||
})
|
||||
finishedAt!: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
field: "cost_ms",
|
||||
comment: "耗时(毫秒)"
|
||||
})
|
||||
costMs!: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
comment: "结果摘要"
|
||||
})
|
||||
message!: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
field: "error_stack",
|
||||
comment: "异常堆栈"
|
||||
})
|
||||
errorStack!: string;
|
||||
|
||||
@CreatedAt
|
||||
created_at!: Date;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Column, Model, Table, DataType, CreatedAt } from "sequelize-typescript";
|
||||
|
||||
@Table({
|
||||
tableName: "sys_notice",
|
||||
comment: "站内通知表",
|
||||
timestamps: true,
|
||||
createdAt: "created_at",
|
||||
updatedAt: false,
|
||||
indexes: [
|
||||
{ name: "idx_sys_notice_receiver", fields: ["receiver_id", "is_read"] },
|
||||
{ name: "uk_sys_notice_biz", unique: true, fields: ["notice_type", "biz_id", "receiver_id"] }
|
||||
]
|
||||
})
|
||||
export default class SysNotice extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(32), allowNull: false, field: "notice_type", comment: "通知类型:cert_expire证照到期" })
|
||||
noticeType!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), allowNull: false, field: "title", comment: "通知标题" })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(512), field: "content", comment: "通知内容" })
|
||||
content!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(32), field: "biz_type", comment: "业务类型:worker_cert电工资质证照" })
|
||||
bizType!: string;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "biz_id", comment: "业务主键(→ worker_cert.id)" })
|
||||
bizId!: number;
|
||||
|
||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "receiver_id", comment: "接收人(→ sys_user.id)" })
|
||||
receiverId!: number;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, field: "is_read", comment: "是否已读:0未读 1已读" })
|
||||
isRead!: number;
|
||||
|
||||
@CreatedAt
|
||||
created_at!: Date;
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import { Column, Model, Table, DataType, CreatedAt, UpdatedAt } from "sequelize-
|
||||
timestamps: true,
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
indexes: [{ name: "idx_worker_phone", fields: ["phone"] }]
|
||||
indexes: [
|
||||
{ name: "idx_worker_phone", fields: ["phone"] },
|
||||
{ name: "idx_worker_audit_status", fields: ["audit_status"] }
|
||||
]
|
||||
})
|
||||
export default class Worker extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
@@ -39,6 +42,24 @@ export default class Worker extends Model {
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 1, comment: "状态:1在岗 0离职" })
|
||||
status!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(255), field: "password_hash", comment: "电工APP登录密码bcrypt哈希" })
|
||||
passwordHash!: string;
|
||||
|
||||
@Column({ type: DataType.SMALLINT, allowNull: false, defaultValue: 0, field: "audit_status", comment: "审核状态:0待审核 1已通过 2已驳回" })
|
||||
auditStatus!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(255), field: "audit_remark", comment: "驳回理由/审核备注" })
|
||||
auditRemark!: string;
|
||||
|
||||
@Column({ type: DataType.BIGINT, field: "audit_by", comment: "审核人(→ sys_user.id)" })
|
||||
auditBy!: number;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "audit_at", comment: "审核时间" })
|
||||
auditAt!: Date;
|
||||
|
||||
@Column({ type: DataType.DATE, field: "last_login_at", comment: "最后登录时间" })
|
||||
lastLoginAt!: Date;
|
||||
|
||||
@CreatedAt
|
||||
created_at!: Date;
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Column, Model, Table, DataType, CreatedAt, UpdatedAt, ForeignKey } from "sequelize-typescript";
|
||||
import Worker from "./worker.entity";
|
||||
|
||||
@Table({
|
||||
tableName: "worker_cert",
|
||||
comment: "电工资质证照表",
|
||||
timestamps: true,
|
||||
createdAt: "created_at",
|
||||
updatedAt: "updated_at",
|
||||
indexes: [
|
||||
{ name: "idx_worker_cert_worker", fields: ["worker_id"] },
|
||||
{ name: "idx_worker_cert_expire", fields: ["expire_date"] },
|
||||
{ name: "uk_worker_cert_type_no", unique: true, fields: ["worker_id", "cert_type", "cert_no"] }
|
||||
]
|
||||
})
|
||||
export default class WorkerCert extends Model {
|
||||
@Column({ type: DataType.BIGINT, autoIncrement: true, primaryKey: true })
|
||||
id!: number;
|
||||
|
||||
@ForeignKey(() => Worker)
|
||||
@Column({ type: DataType.BIGINT, allowNull: false, field: "worker_id", comment: "共享电工ID(→ worker)" })
|
||||
workerId!: number;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "cert_type", comment: "证书类型(字典 worker_cert_type)" })
|
||||
certType!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(64), field: "cert_no", comment: "证书编号" })
|
||||
certNo!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(128), field: "issue_authority", comment: "发证机关" })
|
||||
issueAuthority!: string;
|
||||
|
||||
@Column({ type: DataType.DATEONLY, field: "issue_date", comment: "发证日期" })
|
||||
issueDate!: string;
|
||||
|
||||
@Column({ type: DataType.DATEONLY, field: "expire_date", comment: "有效期至(到期提醒依据;NULL=长期有效)" })
|
||||
expireDate!: string;
|
||||
|
||||
@Column({ type: DataType.STRING(512), field: "cert_image_url", comment: "证照图片对象key" })
|
||||
certImageUrl!: string;
|
||||
|
||||
@CreatedAt
|
||||
created_at!: Date;
|
||||
|
||||
@UpdatedAt
|
||||
updated_at!: Date;
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import { TokenUtil } from '../util/token';
|
||||
|
||||
@Provide()
|
||||
export class AuthMiddleware implements IMiddleware<Context, NextFunction> {
|
||||
// 中间件匹配范围:全局生效,但跳过公开接口
|
||||
ignore = ['/api/auth/*', '/api/health'];
|
||||
// 中间件匹配范围:全局生效,但跳过公开接口与电工 APP 接口(电工 APP 走独立鉴权)
|
||||
ignore = ['/api/auth/*', '/api/health', '/api/worker-app/*'];
|
||||
|
||||
@Inject()
|
||||
tokenUtil!: TokenUtil;
|
||||
|
||||
@@ -13,6 +13,7 @@ const PERMISSION_MAP: Record<string, string> = {
|
||||
'/api/menus': 'system:menu',
|
||||
'/api/dicts': 'system:dict',
|
||||
'/api/logs': 'system:log',
|
||||
'/api/jobs': 'system:job',
|
||||
'/api/customers': 'customer:list',
|
||||
'/api/customer-departments': 'customer:department',
|
||||
'/api/customer-accounts': 'customer:account',
|
||||
@@ -26,6 +27,9 @@ const PERMISSION_MAP: Record<string, string> = {
|
||||
'/api/install-orders': 'project:install',
|
||||
'/api/acceptances': 'project:acceptance',
|
||||
'/api/visit-records': 'project:visit',
|
||||
'/api/service-prices': 'dispatch:price',
|
||||
'/api/dispatch-prices': 'dispatch:cost',
|
||||
'/api/workers': 'dispatch:worker',
|
||||
};
|
||||
|
||||
// 超级管理员角色编码,拥有全部权限
|
||||
@@ -33,8 +37,17 @@ const ADMIN_ROLE_CODE = 'ADMIN';
|
||||
|
||||
@Provide()
|
||||
export class RbacMiddleware implements IMiddleware<Context, NextFunction> {
|
||||
// 中间件匹配范围:全局生效,跳过公开接口与仪表盘
|
||||
ignore = ['/api/auth/*', '/api/health', '/api/dashboard/*'];
|
||||
// 中间件匹配范围:全局生效,跳过公开接口、仪表盘与电工 APP 接口
|
||||
// /api/workers/options 与 /api/workers/recommend 放行:业务员在「项目勘察派单」页
|
||||
// 仍可调用电工下拉与推荐接口,不受 dispatch:worker 权限限制(本次需求有意设计)
|
||||
ignore = [
|
||||
'/api/auth/*',
|
||||
'/api/health',
|
||||
'/api/dashboard/*',
|
||||
'/api/worker-app/*',
|
||||
'/api/workers/options',
|
||||
'/api/workers/recommend',
|
||||
];
|
||||
|
||||
@Inject()
|
||||
permissionService!: PermissionService;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Middleware, IMiddleware } from '@midwayjs/core';
|
||||
import { Context, NextFunction } from '@midwayjs/koa';
|
||||
import { Provide, Inject } from '@midwayjs/core';
|
||||
import { TokenUtil } from '../util/token';
|
||||
|
||||
// 电工 APP 独立鉴权中间件:
|
||||
// 与管理端 sys_user 鉴权隔离,仅作用于 /api/worker-app/* 接口,
|
||||
// 登录/注册(/api/worker-app/auth/*)无需鉴权。
|
||||
@Provide()
|
||||
export class WorkerAuthMiddleware implements IMiddleware<Context, NextFunction> {
|
||||
// 注意:Midway 不允许同时声明 match 与 ignore,故仅在 match 生效,
|
||||
// 登录/注册路径(/api/worker-app/auth/*)在内部放行。
|
||||
match = ['/api/worker-app/*'];
|
||||
|
||||
@Inject()
|
||||
tokenUtil!: TokenUtil;
|
||||
|
||||
resolve() {
|
||||
return async (ctx: Context, next: NextFunction) => {
|
||||
if (ctx.path.startsWith('/api/worker-app/auth/')) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeader = ctx.header.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
ctx.status = 401;
|
||||
ctx.body = {
|
||||
code: 401,
|
||||
message: '未登录或登录已过期',
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
let decoded: any;
|
||||
try {
|
||||
decoded = this.tokenUtil.verifyWorkerToken(token);
|
||||
} catch (error: any) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
ctx.status = 401;
|
||||
ctx.body = {
|
||||
code: 401,
|
||||
message: '未登录或登录已过期',
|
||||
expired: true,
|
||||
};
|
||||
} else {
|
||||
ctx.status = 401;
|
||||
ctx.body = {
|
||||
code: 401,
|
||||
message: '未登录或登录已过期',
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 写入当前电工上下文,供业务代码取用
|
||||
ctx.state.worker = {
|
||||
workerId: decoded.workerId,
|
||||
phone: decoded.phone,
|
||||
};
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Provide } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op, UniqueConstraintError } from "sequelize";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import DispatchPrice from "../entity/dispatch_price.entity";
|
||||
|
||||
// 创建/更新可写入字段白名单(priceCode 只读,服务端生成)
|
||||
const WRITABLE_FIELDS = ["dispatchType", "priceType", "priceMode", "unitPrice", "unit", "status", "remark"];
|
||||
// 计价方式 → 默认单位兜底
|
||||
const MODE_UNIT: Record<number, string> = { 1: "次", 2: "小时" };
|
||||
|
||||
@Provide()
|
||||
export class DispatchPriceService {
|
||||
@InjectRepository(DispatchPrice)
|
||||
dispatchPriceModel!: Repository<DispatchPrice>;
|
||||
|
||||
// 价格编码规则:DG + 东八区 yyyymmdd + 3 位序号(当日已有记录数 +1),唯一冲突时递增重试 1 次
|
||||
private async generatePriceCode(): Promise<string> {
|
||||
const now = new Date();
|
||||
const d = new Date(now.getTime() + 8 * 60 * 60 * 1000);
|
||||
const yyyymmdd = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, "0")}${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
const prefix = `DG${yyyymmdd}`;
|
||||
|
||||
const count = await this.dispatchPriceModel.count({ where: { priceCode: { [Op.like]: `${prefix}%` } } });
|
||||
let seq = count + 1;
|
||||
|
||||
for (let i = 0; i <= 1; i++) {
|
||||
const code = `${prefix}${String(seq).padStart(3, "0")}`;
|
||||
const exists = await this.dispatchPriceModel.findOne({ where: { priceCode: code } });
|
||||
if (!exists) return code;
|
||||
seq++;
|
||||
}
|
||||
throw new Error("价格编码生成冲突,请重试");
|
||||
}
|
||||
|
||||
// 仅保留白名单字段
|
||||
private pickWritable(data: any): any {
|
||||
const payload: any = {};
|
||||
for (const key of WRITABLE_FIELDS) {
|
||||
if (data[key] !== undefined) payload[key] = data[key];
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async list(query: any, ctx: Context): Promise<any> {
|
||||
const page = Number(query.page) || 1;
|
||||
const pageSize = Math.min(Number(query.pageSize) || 10, 200);
|
||||
|
||||
const where: any = {};
|
||||
if (query.keyword) {
|
||||
where.priceCode = { [Op.like]: `%${query.keyword}%` };
|
||||
}
|
||||
if (query.dispatchType !== undefined && query.dispatchType !== "") where.dispatchType = query.dispatchType;
|
||||
if (query.priceMode !== undefined && query.priceMode !== "") where.priceMode = Number(query.priceMode);
|
||||
if (query.status !== undefined && query.status !== "") where.status = Number(query.status);
|
||||
|
||||
const { count, rows } = await this.dispatchPriceModel.findAndCountAll({
|
||||
where,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order: [["id", "DESC"]]
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取派单成本价目列表成功",
|
||||
data: { list: rows.map((r) => r.toJSON()), total: count, page: Number(page), pageSize: Number(pageSize) }
|
||||
};
|
||||
}
|
||||
|
||||
async detail(id: number): Promise<any> {
|
||||
const record = await this.dispatchPriceModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
return { code: 200, message: "获取派单成本价目详情成功", data: record.toJSON() };
|
||||
}
|
||||
|
||||
async create(data: any, ctx: Context): Promise<any> {
|
||||
if (!data.dispatchType) return { code: 400, message: "派单类型不能为空", data: null };
|
||||
if (data.unitPrice === undefined || data.unitPrice === null || data.unitPrice === "") {
|
||||
return { code: 400, message: "单价不能为空", data: null };
|
||||
}
|
||||
|
||||
const payload = this.pickWritable(data);
|
||||
if (payload.priceMode === undefined || payload.priceMode === null) payload.priceMode = 1;
|
||||
if (payload.status === undefined || payload.status === null) payload.status = 1;
|
||||
if (!payload.unit) payload.unit = MODE_UNIT[Number(payload.priceMode)] || "次";
|
||||
|
||||
try {
|
||||
payload.priceCode = await this.generatePriceCode();
|
||||
const created = await this.dispatchPriceModel.create(payload);
|
||||
return { code: 200, message: "创建派单成本价目成功", data: created.toJSON() };
|
||||
} catch (err: any) {
|
||||
// 唯一冲突:重新生成编码后重试 1 次
|
||||
if (err instanceof UniqueConstraintError) {
|
||||
try {
|
||||
payload.priceCode = await this.generatePriceCode();
|
||||
const created = await this.dispatchPriceModel.create(payload);
|
||||
return { code: 200, message: "创建派单成本价目成功", data: created.toJSON() };
|
||||
} catch (_e) {
|
||||
return { code: 400, message: "价格编码已存在", data: null };
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: number, data: any, ctx: Context): Promise<any> {
|
||||
const record = await this.dispatchPriceModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
|
||||
const payload = this.pickWritable(data);
|
||||
await record.update(payload);
|
||||
|
||||
return { code: 200, message: "更新派单成本价目成功", data: record.toJSON() };
|
||||
}
|
||||
|
||||
async delete(id: number, ctx: Context): Promise<any> {
|
||||
const record = await this.dispatchPriceModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
|
||||
await record.destroy();
|
||||
return { code: 200, message: "删除派单成本价目成功", data: null };
|
||||
}
|
||||
|
||||
async updateStatus(id: number, status: number, ctx: Context): Promise<any> {
|
||||
if (status !== 0 && status !== 1) return { code: 400, message: "状态值非法", data: null };
|
||||
|
||||
const record = await this.dispatchPriceModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
|
||||
await record.update({ status });
|
||||
return { code: 200, message: "更新派单成本价目状态成功", data: null };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { Provide, Inject } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op } from "sequelize";
|
||||
import dayjs from "dayjs";
|
||||
import SysJob from "../entity/sys_job.entity";
|
||||
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";
|
||||
|
||||
@Provide()
|
||||
export class JobService {
|
||||
@InjectRepository(SysJob)
|
||||
jobModel!: Repository<SysJob>;
|
||||
|
||||
@InjectRepository(SysJobLog)
|
||||
jobLogModel!: Repository<SysJobLog>;
|
||||
|
||||
@Inject()
|
||||
scheduler!: JobSchedulerService;
|
||||
|
||||
/** 任务列表:按任务名称模糊 + 状态筛选,附带注册表信息与下次执行时间 */
|
||||
async list(query: { page?: any; pageSize?: any; jobName?: string; status?: any }): Promise<any> {
|
||||
const page = Number(query.page) || 1;
|
||||
const pageSize = Number(query.pageSize) || 10;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (query.jobName) {
|
||||
where.jobName = { [Op.like]: `%${query.jobName}%` };
|
||||
}
|
||||
|
||||
if (query.status !== undefined && query.status !== null && `${query.status}` !== "") {
|
||||
where.status = Number(query.status);
|
||||
}
|
||||
|
||||
const { count, rows } = await this.jobModel.findAndCountAll({
|
||||
where,
|
||||
order: [["id", "ASC"]],
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize
|
||||
});
|
||||
|
||||
const list = rows.map(job => {
|
||||
const builtin = getBuiltinJob(job.jobKey);
|
||||
const registered = !!builtin;
|
||||
|
||||
return {
|
||||
...job.toJSON(),
|
||||
description: builtin ? builtin.description : null,
|
||||
paramFields: builtin ? builtin.paramFields : [],
|
||||
registered,
|
||||
nextRunTime: registered && Number(job.status) === 1 ? this.scheduler.nextRunTime(job.cronExpression) : null
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取定时任务列表成功",
|
||||
data: { list, total: count, page, pageSize }
|
||||
};
|
||||
}
|
||||
|
||||
/** 更新任务配置:cron 合法性 + 参数白名单/范围校验,白名单之外的 key 直接丢弃 */
|
||||
async update(id: number, dto: UpdateJobDTO): Promise<any> {
|
||||
const job = await this.jobModel.findByPk(id);
|
||||
|
||||
if (!job) {
|
||||
return { code: 404, message: "定时任务不存在", data: null };
|
||||
}
|
||||
|
||||
const builtin = getBuiltinJob(job.jobKey);
|
||||
if (!builtin) {
|
||||
return { code: 400, message: "任务实现不存在", data: null };
|
||||
}
|
||||
|
||||
if (!this.scheduler.validateCron(dto.cronExpression)) {
|
||||
return { code: 400, message: "cron 表达式不合法", data: null };
|
||||
}
|
||||
|
||||
const inputParams: Record<string, any> =
|
||||
dto.params && typeof dto.params === "object" ? dto.params : {};
|
||||
const baseParams: Record<string, any> =
|
||||
job.params && typeof job.params === "object" ? job.params : {};
|
||||
|
||||
const params: Record<string, any> = {};
|
||||
|
||||
for (const field of builtin.paramFields) {
|
||||
const hasInput = Object.prototype.hasOwnProperty.call(inputParams, field.key);
|
||||
|
||||
// 未提供:保留原值,其次取注册表默认参数
|
||||
if (!hasInput) {
|
||||
if (Object.prototype.hasOwnProperty.call(baseParams, field.key)) {
|
||||
params[field.key] = baseParams[field.key];
|
||||
} else if (Object.prototype.hasOwnProperty.call(builtin.defaultParams, field.key)) {
|
||||
params[field.key] = builtin.defaultParams[field.key];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = inputParams[field.key];
|
||||
|
||||
if (field.type === "number") {
|
||||
const num = Number(value);
|
||||
if (value === null || value === "" || Number.isNaN(num)) {
|
||||
return { code: 400, message: `参数 ${field.label} 必须为数字`, data: null };
|
||||
}
|
||||
if (field.min !== undefined && num < field.min) {
|
||||
return { code: 400, message: `参数 ${field.label} 取值范围为 ${field.min}-${field.max}`, data: null };
|
||||
}
|
||||
if (field.max !== undefined && num > field.max) {
|
||||
return { code: 400, message: `参数 ${field.label} 取值范围为 ${field.min}-${field.max}`, data: null };
|
||||
}
|
||||
params[field.key] = num;
|
||||
} else {
|
||||
if (typeof value !== "string") {
|
||||
return { code: 400, message: `参数 ${field.label} 必须为字符串`, data: null };
|
||||
}
|
||||
params[field.key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await job.update({
|
||||
cronExpression: dto.cronExpression,
|
||||
params,
|
||||
retryTimes: dto.retryTimes,
|
||||
retryInterval: dto.retryInterval,
|
||||
remark: dto.remark ?? null
|
||||
} as any);
|
||||
|
||||
await this.scheduler.reload(job.jobKey);
|
||||
|
||||
return { code: 200, message: "更新定时任务成功", data: null };
|
||||
}
|
||||
|
||||
/** 启停任务:更新状态后即时重载调度 */
|
||||
async changeStatus(id: number, status: number): Promise<any> {
|
||||
const job = await this.jobModel.findByPk(id);
|
||||
|
||||
if (!job) {
|
||||
return { code: 404, message: "定时任务不存在", data: null };
|
||||
}
|
||||
|
||||
const builtin = getBuiltinJob(job.jobKey);
|
||||
if (!builtin) {
|
||||
return { code: 400, message: "任务实现不存在", data: null };
|
||||
}
|
||||
|
||||
await job.update({ status } as any);
|
||||
await this.scheduler.reload(job.jobKey);
|
||||
|
||||
return { code: 200, message: "更新任务状态成功", data: null };
|
||||
}
|
||||
|
||||
/** 立即执行一次(手动触发,trigger_type = 2) */
|
||||
async run(id: number): Promise<any> {
|
||||
const job = await this.jobModel.findByPk(id);
|
||||
|
||||
if (!job) {
|
||||
return { code: 404, message: "定时任务不存在", data: null };
|
||||
}
|
||||
|
||||
const builtin = getBuiltinJob(job.jobKey);
|
||||
if (!builtin) {
|
||||
return { code: 400, message: "任务实现不存在", data: null };
|
||||
}
|
||||
|
||||
const result = await this.scheduler.runOnce(Number(id), 2);
|
||||
|
||||
return { code: 200, message: "任务执行完成", data: result };
|
||||
}
|
||||
|
||||
/** 任务执行日志分页查询 */
|
||||
async listLogs(id: number, query: JobLogQueryDTO): Promise<any> {
|
||||
const job = await this.jobModel.findByPk(id);
|
||||
|
||||
if (!job) {
|
||||
return { code: 404, message: "定时任务不存在", data: null };
|
||||
}
|
||||
|
||||
const page = Number(query.page) || 1;
|
||||
const pageSize = Number(query.pageSize) || 10;
|
||||
|
||||
const where: any = { jobId: Number(id) };
|
||||
|
||||
if (query.status !== undefined && query.status !== null && `${query.status}` !== "") {
|
||||
where.status = Number(query.status);
|
||||
}
|
||||
|
||||
if (query.triggerType !== undefined && query.triggerType !== null && `${query.triggerType}` !== "") {
|
||||
where.triggerType = Number(query.triggerType);
|
||||
}
|
||||
|
||||
if (query.startDate || query.endDate) {
|
||||
where.created_at = {};
|
||||
if (query.startDate) {
|
||||
where.created_at[Op.gte] = dayjs(query.startDate).startOf("day").toDate();
|
||||
}
|
||||
if (query.endDate) {
|
||||
where.created_at[Op.lte] = dayjs(query.endDate).endOf("day").toDate();
|
||||
}
|
||||
}
|
||||
|
||||
const { count, rows } = await this.jobLogModel.findAndCountAll({
|
||||
where,
|
||||
order: [["created_at", "DESC"]],
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取任务日志成功",
|
||||
data: { list: rows, total: count, page, pageSize }
|
||||
};
|
||||
}
|
||||
|
||||
/** 手动清理早于 days 天的任务日志 */
|
||||
async clearLogs(days: number = 90): Promise<any> {
|
||||
const cutoff = dayjs().subtract(days, "day").toDate();
|
||||
|
||||
const deleted = await this.jobLogModel.destroy({
|
||||
where: {
|
||||
created_at: { [Op.lt]: cutoff }
|
||||
}
|
||||
});
|
||||
|
||||
return { code: 200, message: "清理任务日志成功", data: { deleted } };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import { Provide, App, Scope, ScopeEnum } from "@midwayjs/core";
|
||||
import { Application } from "@midwayjs/koa";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { CronJob, CronTime } from "cron";
|
||||
import { Op } from "sequelize";
|
||||
import dayjs from "dayjs";
|
||||
import SysJob from "../entity/sys_job.entity";
|
||||
import SysJobLog from "../entity/sys_job_log.entity";
|
||||
import { LogService } from "./log.service";
|
||||
import { BuiltinJob, JobHandlerContext, JobRuntime, getBuiltinJob } from "../task/job_registry";
|
||||
|
||||
/** 任务执行结果(一次尝试) */
|
||||
export interface JobRunResult {
|
||||
status: number; // 1成功 2失败 3跳过
|
||||
message: string; // 结果摘要
|
||||
costMs: number; // 本次尝试耗时
|
||||
attempt: number; // 本次是第几次尝试
|
||||
}
|
||||
|
||||
/** 调度时区(固定上海时区) */
|
||||
const TIMEZONE = "Asia/Shanghai";
|
||||
/** 异常堆栈最大存储长度 */
|
||||
const MAX_ERROR_STACK = 4000;
|
||||
|
||||
/**
|
||||
* 自研轻量调度器:任务清单来自代码注册表,调度配置持久化在 sys_job,
|
||||
* 页面修改 cron / 启停后通过 reload 即时生效(无需重启)。
|
||||
*/
|
||||
// 调度器必须是进程内单例:定时器注册表要能被「启动注册」和「接口重载」共享
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Singleton)
|
||||
export class JobSchedulerService {
|
||||
@App()
|
||||
app!: Application;
|
||||
|
||||
@InjectRepository(SysJob)
|
||||
jobModel!: typeof SysJob;
|
||||
|
||||
@InjectRepository(SysJobLog)
|
||||
jobLogModel!: typeof SysJobLog;
|
||||
|
||||
/** jobKey → 定时器 */
|
||||
private timers = new Map<string, CronJob>();
|
||||
/** jobKey → 待执行的重试定时器 */
|
||||
private retryTimers = new Map<string, NodeJS.Timeout>();
|
||||
/** jobKey 重入锁 */
|
||||
private running = new Set<string>();
|
||||
|
||||
/** 私有日志适配:注册表与任务经它输出日志,沿用仓库内 console 风格 */
|
||||
private readonly jobLogger = {
|
||||
info: (m: string, ...a: any[]) => console.log(`[定时任务] ${m}`, ...a),
|
||||
warn: (m: string, ...a: any[]) => console.warn(`[定时任务] ${m}`, ...a),
|
||||
error: (m: string, ...a: any[]) => console.error(`[定时任务] ${m}`, ...a)
|
||||
};
|
||||
|
||||
/** 应用启动时加载全部启用任务并注册调度;失败自动重试,最多 3 次,不阻塞启动 */
|
||||
async start(): Promise<void> {
|
||||
const maxAttempts = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const jobs = await this.jobModel.findAll({ where: { status: 1 } });
|
||||
jobs.forEach(job => this.register(job));
|
||||
this.jobLogger.info(`调度器启动完成,已加载 ${jobs.length} 个任务`);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (attempt < maxAttempts) {
|
||||
this.jobLogger.error(`调度器启动失败(第 ${attempt} 次尝试),5 秒后重试:`, err);
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
} else {
|
||||
// 3 次仍失败仅记录,不抛出,避免阻塞应用启动
|
||||
console.error("[定时任务] 调度器启动失败,已达最大重试次数,应用继续启动:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止全部定时器(应用停止时调用) */
|
||||
async stop(): Promise<void> {
|
||||
for (const timer of this.timers.values()) {
|
||||
try {
|
||||
timer.stop();
|
||||
} catch (err) {
|
||||
console.warn("[定时任务] 停止定时器失败:", err);
|
||||
}
|
||||
}
|
||||
this.timers.clear();
|
||||
|
||||
for (const retryTimer of this.retryTimers.values()) {
|
||||
clearTimeout(retryTimer);
|
||||
}
|
||||
this.retryTimers.clear();
|
||||
}
|
||||
|
||||
/** 单个任务重新加载:停旧 → 按最新配置重新注册(停用则不注册) */
|
||||
async reload(jobKey: string): Promise<void> {
|
||||
this.unregister(jobKey);
|
||||
|
||||
const job = await this.jobModel.findOne({ where: { jobKey } });
|
||||
if (!job || Number(job.status) !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.register(job);
|
||||
}
|
||||
|
||||
/** 立即执行一次入口(手动触发同样按配置重试,但不阻塞调用方) */
|
||||
async runOnce(jobId: number, triggerType: number): Promise<JobRunResult> {
|
||||
return this.execute(jobId, triggerType);
|
||||
}
|
||||
|
||||
/** 校验 cron 表达式合法性 */
|
||||
validateCron(expression: string): boolean {
|
||||
if (!expression || typeof expression !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
new CronTime(expression, TIMEZONE);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 计算下次执行时间;表达式非法返回 null */
|
||||
nextRunTime(cronExpression: string): Date | null {
|
||||
if (!this.validateCron(cronExpression)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// luxon 3.x 中 toDate 已移除,使用 toJSDate
|
||||
return new CronTime(cronExpression, TIMEZONE).sendAt().toJSDate();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 清理 sys_job_log 中早于 keepDays 天的记录,返回删除条数 */
|
||||
async cleanJobLogs(keepDays: number): Promise<number> {
|
||||
const cutoff = dayjs().subtract(keepDays, "day").toDate();
|
||||
|
||||
return this.jobLogModel.destroy({
|
||||
where: {
|
||||
created_at: { [Op.lt]: cutoff }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 按任务配置注册定时器 */
|
||||
private register(job: SysJob): void {
|
||||
this.unregister(job.jobKey);
|
||||
|
||||
const builtin = getBuiltinJob(job.jobKey);
|
||||
if (!builtin) {
|
||||
this.jobLogger.warn(`任务 ${job.jobKey} 未在注册表中实现,跳过调度注册`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.validateCron(job.cronExpression)) {
|
||||
this.jobLogger.warn(`任务 ${job.jobKey} 的 cron 表达式不合法(${job.cronExpression}),跳过调度注册`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const timer = new CronJob(
|
||||
job.cronExpression,
|
||||
() => {
|
||||
void this.execute(job.id, 1);
|
||||
},
|
||||
null,
|
||||
true,
|
||||
TIMEZONE
|
||||
);
|
||||
this.timers.set(job.jobKey, timer);
|
||||
this.jobLogger.info(`任务 ${job.jobKey} 已注册调度,cron=${job.cronExpression}`);
|
||||
} catch (err) {
|
||||
this.jobLogger.warn(`任务 ${job.jobKey} 定时器创建失败:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/** 注销定时器与待执行的重试定时器(不清重入锁) */
|
||||
private unregister(jobKey: string): void {
|
||||
const timer = this.timers.get(jobKey);
|
||||
if (timer) {
|
||||
try {
|
||||
timer.stop();
|
||||
} catch (err) {
|
||||
console.warn(`[定时任务] 停止任务 ${jobKey} 定时器失败:`, err);
|
||||
}
|
||||
this.timers.delete(jobKey);
|
||||
}
|
||||
|
||||
const retryTimer = this.retryTimers.get(jobKey);
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
this.retryTimers.delete(jobKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行内核:重入保护 → 执行尝试 → 失败按配置进入重试链 */
|
||||
private async execute(jobId: number, triggerType: number): Promise<JobRunResult> {
|
||||
const job = await this.jobModel.findByPk(jobId);
|
||||
if (!job) {
|
||||
return { status: 2, message: "任务不存在", costMs: 0, attempt: 0 };
|
||||
}
|
||||
|
||||
const builtin = getBuiltinJob(job.jobKey);
|
||||
if (!builtin) {
|
||||
const now = new Date();
|
||||
await this.writeJobLog({
|
||||
job,
|
||||
triggerType,
|
||||
status: 2,
|
||||
attempt: 1,
|
||||
startedAt: now,
|
||||
finishedAt: now,
|
||||
costMs: 0,
|
||||
message: "任务实现不存在",
|
||||
errorStack: null
|
||||
});
|
||||
await this.writeLastRun(job, { status: 2, message: "任务实现不存在", costMs: 0, attempt: 1 });
|
||||
return { status: 2, message: "任务实现不存在", costMs: 0, attempt: 1 };
|
||||
}
|
||||
|
||||
// 并发保护:上一次尚未结束则本次跳过,且不改写 sys_job.last_*
|
||||
if (this.running.has(job.jobKey)) {
|
||||
const message = "上一次执行尚未结束,本次跳过";
|
||||
const now = new Date();
|
||||
await this.writeJobLog({
|
||||
job,
|
||||
triggerType,
|
||||
status: 3,
|
||||
attempt: 1,
|
||||
startedAt: now,
|
||||
finishedAt: now,
|
||||
costMs: 0,
|
||||
message,
|
||||
errorStack: null
|
||||
});
|
||||
return { status: 3, message, costMs: 0, attempt: 1 };
|
||||
}
|
||||
|
||||
this.running.add(job.jobKey);
|
||||
// 失败且需重试时把锁移交给重试链,本次不释放
|
||||
let handedOverToRetry = false;
|
||||
|
||||
try {
|
||||
const result = await this.runAttempt(job, builtin, triggerType, 1);
|
||||
|
||||
if (result.status !== 1 && Number(job.retryTimes) > 0) {
|
||||
handedOverToRetry = true;
|
||||
this.scheduleRetry(job, 2);
|
||||
} else {
|
||||
await this.writeLastRun(job, result);
|
||||
}
|
||||
|
||||
return { status: result.status, message: result.message, costMs: result.costMs, attempt: result.attempt };
|
||||
} catch (err) {
|
||||
const e = err as any;
|
||||
this.jobLogger.error(`任务 ${job.jobKey} 执行流程异常:`, err);
|
||||
return { status: 2, message: e?.message ? String(e.message) : "执行失败", costMs: 0, attempt: 1 };
|
||||
} finally {
|
||||
if (!handedOverToRetry) {
|
||||
this.running.delete(job.jobKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 重试链:失败重试(trigger_type = 3),重试期间保持重入锁 */
|
||||
private async continueChain(jobId: number, attempt: number, jobKeyHint?: string): Promise<void> {
|
||||
let jobKey = jobKeyHint;
|
||||
let handedOverToRetry = false;
|
||||
|
||||
try {
|
||||
const job = await this.jobModel.findByPk(jobId);
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
jobKey = job.jobKey;
|
||||
|
||||
const builtin = getBuiltinJob(job.jobKey);
|
||||
if (!builtin) {
|
||||
const now = new Date();
|
||||
await this.writeJobLog({
|
||||
job,
|
||||
triggerType: 3,
|
||||
status: 2,
|
||||
attempt,
|
||||
startedAt: now,
|
||||
finishedAt: now,
|
||||
costMs: 0,
|
||||
message: "任务实现不存在",
|
||||
errorStack: null
|
||||
});
|
||||
await this.writeLastRun(job, { status: 2, message: "任务实现不存在", costMs: 0, attempt });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.runAttempt(job, builtin, 3, attempt);
|
||||
|
||||
if (result.status !== 1 && attempt <= Number(job.retryTimes)) {
|
||||
handedOverToRetry = true;
|
||||
this.scheduleRetry(job, attempt + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// 重试链结束(成功或超出重试次数)才回写任务的 last_run_*
|
||||
await this.writeLastRun(job, result);
|
||||
} catch (err) {
|
||||
this.jobLogger.error(`任务重试执行异常:`, err);
|
||||
} finally {
|
||||
if (!handedOverToRetry && jobKey) {
|
||||
this.running.delete(jobKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 单次尝试:执行 handler 并写入一条执行日志 */
|
||||
private async runAttempt(
|
||||
job: SysJob,
|
||||
builtin: BuiltinJob,
|
||||
triggerType: number,
|
||||
attempt: number
|
||||
): Promise<JobRunResult & { error?: any }> {
|
||||
const startedAt = new Date();
|
||||
const startMs = Date.now();
|
||||
let status = 1;
|
||||
let message = "执行成功";
|
||||
let errorStack: string | null = null;
|
||||
let error: any;
|
||||
|
||||
try {
|
||||
const ctx: JobHandlerContext = {
|
||||
jobKey: job.jobKey,
|
||||
jobName: job.jobName,
|
||||
triggerType,
|
||||
attempt,
|
||||
runtime: this.buildRuntime()
|
||||
};
|
||||
const ret = await builtin.handler(job.params ?? {}, ctx);
|
||||
message = typeof ret === "string" && ret ? ret : "执行成功";
|
||||
} catch (err) {
|
||||
const e = err as any;
|
||||
status = 2;
|
||||
message = e?.message ? String(e.message) : "执行失败";
|
||||
errorStack = (e?.stack ? String(e.stack) : String(e)).slice(0, MAX_ERROR_STACK);
|
||||
error = err;
|
||||
}
|
||||
|
||||
const finishedAt = new Date();
|
||||
const costMs = Date.now() - startMs;
|
||||
|
||||
await this.writeJobLog({ job, triggerType, status, attempt, startedAt, finishedAt, costMs, message, errorStack });
|
||||
|
||||
return { status, message, costMs, attempt, error };
|
||||
}
|
||||
|
||||
/** 写入一条执行日志(写入失败不影响任务执行) */
|
||||
private async writeJobLog(fields: {
|
||||
job: SysJob;
|
||||
triggerType: number;
|
||||
status: number;
|
||||
attempt: number;
|
||||
startedAt: Date;
|
||||
finishedAt: Date;
|
||||
costMs: number;
|
||||
message: string;
|
||||
errorStack: string | null;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await this.jobLogModel.create({
|
||||
jobId: fields.job.id,
|
||||
jobKey: fields.job.jobKey,
|
||||
jobName: fields.job.jobName,
|
||||
triggerType: fields.triggerType,
|
||||
status: fields.status,
|
||||
attempt: fields.attempt,
|
||||
startedAt: fields.startedAt,
|
||||
finishedAt: fields.finishedAt,
|
||||
costMs: fields.costMs,
|
||||
message: fields.message,
|
||||
errorStack: fields.errorStack
|
||||
} as any);
|
||||
} catch (err) {
|
||||
this.jobLogger.error(`任务 ${fields.job.jobKey} 执行日志写入失败:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/** 回写任务上次执行信息 */
|
||||
private async writeLastRun(job: SysJob, result: JobRunResult): Promise<void> {
|
||||
await this.jobModel.update(
|
||||
{
|
||||
lastRunAt: new Date(),
|
||||
lastRunStatus: result.status,
|
||||
lastRunCostMs: result.costMs,
|
||||
lastError: result.status === 1 ? null : result.message
|
||||
} as any,
|
||||
{ where: { id: job.id } }
|
||||
);
|
||||
}
|
||||
|
||||
/** 排定一次重试(重试期间保持重入锁) */
|
||||
private scheduleRetry(job: SysJob, attempt: number): void {
|
||||
const intervalSec = Math.max(10, Number(job.retryInterval) || 60);
|
||||
|
||||
const retryTimer = setTimeout(() => {
|
||||
this.retryTimers.delete(job.jobKey);
|
||||
void this.continueChain(job.id, attempt, job.jobKey);
|
||||
}, intervalSec * 1000);
|
||||
|
||||
this.retryTimers.set(job.jobKey, retryTimer);
|
||||
}
|
||||
|
||||
/** 构造注入给任务的运行时能力 */
|
||||
private buildRuntime(): JobRuntime {
|
||||
return {
|
||||
logger: this.jobLogger,
|
||||
// LogService 是请求作用域,单例中改为按需从匿名请求上下文获取
|
||||
cleanOperationLogs: async (keepDays: number) => {
|
||||
const ctx = this.app.createAnonymousContext();
|
||||
const logService = (await ctx.requestContext.getAsync(LogService)) as LogService;
|
||||
return logService.cleanExpiredLogs(keepDays);
|
||||
},
|
||||
cleanJobLogs: (keepDays: number) => this.cleanJobLogs(keepDays)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { Provide } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op } from "sequelize";
|
||||
import dayjs from "dayjs";
|
||||
import SysNotice from "../entity/sys_notice.entity";
|
||||
import WorkerCert from "../entity/worker_cert.entity";
|
||||
import Worker from "../entity/worker.entity";
|
||||
import SysUser from "../entity/sysUser.entity";
|
||||
import SysUserRole from "../entity/sysUserRole.entity";
|
||||
import SysRole from "../entity/sysRole.entity";
|
||||
|
||||
// 通知类型:证照到期提醒
|
||||
const NOTICE_TYPE_CERT_EXPIRE = "cert_expire";
|
||||
// 业务类型:电工资质证照
|
||||
const NOTICE_BIZ_TYPE_WORKER_CERT = "worker_cert";
|
||||
// 到期提醒提前天数
|
||||
const CERT_EXPIRE_WARN_DAYS = 30;
|
||||
// 超级管理员角色编码(拥有全部权限,作为证照提醒接收人)
|
||||
const ADMIN_ROLE_CODE = "ADMIN";
|
||||
// 分页上限,防止单次拉取过多数据
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
|
||||
// 归一化 DATEONLY 值为 YYYY-MM-DD 字符串(NULL/空 表示长期有效)
|
||||
function toDateOnly(value: any): string {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
const str = String(value);
|
||||
return str.length >= 10 ? str.slice(0, 10) : str;
|
||||
}
|
||||
|
||||
@Provide()
|
||||
export class NoticeService {
|
||||
@InjectRepository(SysNotice)
|
||||
noticeModel!: Repository<SysNotice>;
|
||||
|
||||
@InjectRepository(WorkerCert)
|
||||
certModel!: Repository<WorkerCert>;
|
||||
|
||||
@InjectRepository(Worker)
|
||||
workerModel!: Repository<Worker>;
|
||||
|
||||
@InjectRepository(SysUser)
|
||||
userModel!: Repository<SysUser>;
|
||||
|
||||
@InjectRepository(SysUserRole)
|
||||
userRoleModel!: Repository<SysUserRole>;
|
||||
|
||||
@InjectRepository(SysRole)
|
||||
roleModel!: Repository<SysRole>;
|
||||
|
||||
// 我的通知列表:未读优先,可选按已读状态筛选
|
||||
async listMy(userId: number, query: { page?: any; pageSize?: any; isRead?: any }): Promise<any> {
|
||||
const page = Math.max(Number(query.page) || 1, 1);
|
||||
const rawPageSize = Number(query.pageSize) || 10;
|
||||
const pageSize = Math.min(Math.max(rawPageSize, 1), MAX_PAGE_SIZE);
|
||||
|
||||
const where: any = { receiverId: userId };
|
||||
if (query.isRead !== undefined && query.isRead !== "") {
|
||||
where.isRead = Number(query.isRead);
|
||||
}
|
||||
|
||||
const { count, rows } = await this.noticeModel.findAndCountAll({
|
||||
where,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
// 未读在前,其次按时间倒序
|
||||
order: [["is_read", "ASC"], ["id", "DESC"]]
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取通知列表成功",
|
||||
data: {
|
||||
list: rows.map((r) => r.toJSON()),
|
||||
total: count,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 未读数量(顶栏红点)
|
||||
async unreadCount(userId: number): Promise<any> {
|
||||
const count = await this.noticeModel.count({ where: { receiverId: userId, isRead: 0 } });
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取未读通知数成功",
|
||||
data: { count }
|
||||
};
|
||||
}
|
||||
|
||||
// 单条标记已读(仅能操作自己的通知)
|
||||
async markRead(userId: number, id: number): Promise<any> {
|
||||
const notice = await this.noticeModel.findOne({ where: { id, receiverId: userId } });
|
||||
|
||||
if (!notice) {
|
||||
return {
|
||||
code: 404,
|
||||
message: "通知不存在",
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
if (Number(notice.isRead) !== 1) {
|
||||
await notice.update({ isRead: 1 });
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "标记已读成功",
|
||||
data: null
|
||||
};
|
||||
}
|
||||
|
||||
// 全部标记已读
|
||||
async markAllRead(userId: number): Promise<any> {
|
||||
const updated = await this.noticeModel.update({ isRead: 1 }, { where: { receiverId: userId, isRead: 0 } });
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "全部标记已读成功",
|
||||
data: { updated: Array.isArray(updated) ? updated[0] : 0 }
|
||||
};
|
||||
}
|
||||
|
||||
// 查询拥有 ADMIN 角色的所有用户ID(sys_user_role + sys_role 关联,并校验用户真实存在)
|
||||
async getAdminUserIds(): Promise<number[]> {
|
||||
const adminRole = await this.roleModel.findOne({
|
||||
where: { roleCode: ADMIN_ROLE_CODE },
|
||||
attributes: ["id"]
|
||||
});
|
||||
if (!adminRole) return [];
|
||||
|
||||
const userRoles = await this.userRoleModel.findAll({
|
||||
where: { roleId: Number(adminRole.id) },
|
||||
attributes: ["userId"]
|
||||
});
|
||||
const userIds = Array.from(new Set(userRoles.map((ur) => Number(ur.userId))));
|
||||
if (userIds.length === 0) return [];
|
||||
|
||||
const users = await this.userModel.findAll({
|
||||
where: { id: { [Op.in]: userIds } },
|
||||
attributes: ["id"]
|
||||
});
|
||||
|
||||
return users.map((u) => Number(u.id));
|
||||
}
|
||||
|
||||
// 扫描 30 天内到期或已过期的电工资质证照,向所有 ADMIN 推送站内通知(幂等)
|
||||
async scanCertExpireAndNotify(): Promise<{ scanned: number; created: number }> {
|
||||
let scanned = 0;
|
||||
let created = 0;
|
||||
|
||||
try {
|
||||
// 使用本地日期字符串比较,避免时区偏差
|
||||
const today = dayjs().format("YYYY-MM-DD");
|
||||
const deadline = dayjs().add(CERT_EXPIRE_WARN_DAYS, "day").format("YYYY-MM-DD");
|
||||
|
||||
// 1. 查出已设置有效期且在提醒窗口内的证书(含已过期)
|
||||
const certs = await this.certModel.findAll({
|
||||
where: {
|
||||
expireDate: { [Op.not]: null, [Op.lte]: deadline }
|
||||
},
|
||||
order: [["expire_date", "ASC"]]
|
||||
});
|
||||
scanned = certs.length;
|
||||
if (scanned === 0) return { scanned, created };
|
||||
|
||||
// 2. worker_cert 未声明 BelongsTo(Worker) 关联,单独查询电工姓名后按 Map 关联,避免 N+1
|
||||
const workerIds = Array.from(new Set(certs.map((c) => Number(c.workerId))));
|
||||
const workers = await this.workerModel.findAll({
|
||||
where: { id: { [Op.in]: workerIds } },
|
||||
attributes: ["id", "workerName"]
|
||||
});
|
||||
const workerNameMap = new Map<number, string>();
|
||||
for (const w of workers) workerNameMap.set(Number(w.id), w.workerName);
|
||||
|
||||
// 3. 接收人:全部 ADMIN
|
||||
const adminIds = await this.getAdminUserIds();
|
||||
if (adminIds.length === 0) return { scanned, created };
|
||||
|
||||
// 4. 幂等:按 (noticeType, bizType, bizId, receiverId) 先批量查出已存在的通知,已存在则跳过
|
||||
const certIds = certs.map((c) => Number(c.id));
|
||||
const existing = await this.noticeModel.findAll({
|
||||
where: {
|
||||
noticeType: NOTICE_TYPE_CERT_EXPIRE,
|
||||
bizType: NOTICE_BIZ_TYPE_WORKER_CERT,
|
||||
bizId: { [Op.in]: certIds },
|
||||
receiverId: { [Op.in]: adminIds }
|
||||
},
|
||||
attributes: ["bizId", "receiverId"]
|
||||
});
|
||||
const existsSet = new Set(existing.map((n) => `${Number(n.bizId)}_${Number(n.receiverId)}`));
|
||||
|
||||
// 5. 证书 × ADMIN 逐条生成通知
|
||||
for (const cert of certs) {
|
||||
const expire = toDateOnly(cert.expireDate);
|
||||
if (!expire) continue;
|
||||
|
||||
const days = dayjs(expire).startOf("day").diff(dayjs(today).startOf("day"), "day");
|
||||
const workerName = workerNameMap.get(Number(cert.workerId)) || "未知电工";
|
||||
const certType = cert.certType || "资质证书";
|
||||
const title =
|
||||
days < 0
|
||||
? `电工【${workerName}】的${certType}已于 ${expire} 过期`
|
||||
: `电工【${workerName}】的${certType}将于 ${expire} 到期(剩余 ${days} 天)`;
|
||||
const content = [
|
||||
`证书编号:${cert.certNo || "—"}`,
|
||||
`发证机关:${cert.issueAuthority || "—"}`,
|
||||
`有效期至:${expire}`,
|
||||
days < 0 ? `已过期 ${Math.abs(days)} 天,请及时提醒电工办理续期` : `剩余 ${days} 天,请及时提醒电工办理续期`
|
||||
].join(";");
|
||||
|
||||
for (const adminId of adminIds) {
|
||||
if (existsSet.has(`${Number(cert.id)}_${adminId}`)) continue;
|
||||
|
||||
await this.noticeModel.create({
|
||||
noticeType: NOTICE_TYPE_CERT_EXPIRE,
|
||||
title,
|
||||
content,
|
||||
bizType: NOTICE_BIZ_TYPE_WORKER_CERT,
|
||||
bizId: Number(cert.id),
|
||||
receiverId: adminId,
|
||||
isRead: 0
|
||||
} as any);
|
||||
existsSet.add(`${Number(cert.id)}_${adminId}`);
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { scanned, created };
|
||||
} catch (err: any) {
|
||||
// 定时任务不允许抛出,失败仅记录日志
|
||||
console.error("[notice] 证照到期扫描失败:", err?.message || err);
|
||||
return { scanned, created };
|
||||
}
|
||||
}
|
||||
|
||||
// 清理指定证书的到期通知(证书续期/删除后调用,允许重新提醒)
|
||||
async clearByCertIds(certIds: number[]): Promise<number> {
|
||||
if (!certIds || certIds.length === 0) return 0;
|
||||
|
||||
return await this.noticeModel.destroy({
|
||||
where: {
|
||||
bizType: NOTICE_BIZ_TYPE_WORKER_CERT,
|
||||
bizId: { [Op.in]: certIds }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Provide } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op, UniqueConstraintError } from "sequelize";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import ServicePrice from "../entity/service_price.entity";
|
||||
|
||||
// 创建/更新可写入字段白名单(priceCode 只读,服务端生成)
|
||||
const WRITABLE_FIELDS = ["priceName", "serviceType", "priceMode", "unitPrice", "unit", "status", "remark"];
|
||||
// 计价方式 → 默认单位兜底
|
||||
const MODE_UNIT: Record<number, string> = { 1: "次", 2: "小时", 3: "个" };
|
||||
|
||||
@Provide()
|
||||
export class ServicePriceService {
|
||||
@InjectRepository(ServicePrice)
|
||||
servicePriceModel!: Repository<ServicePrice>;
|
||||
|
||||
// 价目编码规则:FW + 东八区 yyyymmdd + 3 位序号(当日已有记录数 +1),唯一冲突时递增重试 1 次
|
||||
private async generatePriceCode(): Promise<string> {
|
||||
const now = new Date();
|
||||
const d = new Date(now.getTime() + 8 * 60 * 60 * 1000);
|
||||
const yyyymmdd = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, "0")}${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
const prefix = `FW${yyyymmdd}`;
|
||||
|
||||
const count = await this.servicePriceModel.count({ where: { priceCode: { [Op.like]: `${prefix}%` } } });
|
||||
let seq = count + 1;
|
||||
|
||||
for (let i = 0; i <= 1; i++) {
|
||||
const code = `${prefix}${String(seq).padStart(3, "0")}`;
|
||||
const exists = await this.servicePriceModel.findOne({ where: { priceCode: code } });
|
||||
if (!exists) return code;
|
||||
seq++;
|
||||
}
|
||||
throw new Error("价目编码生成冲突,请重试");
|
||||
}
|
||||
|
||||
// 仅保留白名单字段
|
||||
private pickWritable(data: any): any {
|
||||
const payload: any = {};
|
||||
for (const key of WRITABLE_FIELDS) {
|
||||
if (data[key] !== undefined) payload[key] = data[key];
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async list(query: any, ctx: Context): Promise<any> {
|
||||
const page = Number(query.page) || 1;
|
||||
const pageSize = Math.min(Number(query.pageSize) || 10, 200);
|
||||
|
||||
const where: any = {};
|
||||
if (query.keyword) {
|
||||
where[Op.or] = [
|
||||
{ priceCode: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ priceName: { [Op.like]: `%${query.keyword}%` } }
|
||||
];
|
||||
}
|
||||
if (query.priceMode !== undefined && query.priceMode !== "") where.priceMode = Number(query.priceMode);
|
||||
if (query.serviceType !== undefined && query.serviceType !== "") where.serviceType = query.serviceType;
|
||||
if (query.status !== undefined && query.status !== "") where.status = Number(query.status);
|
||||
|
||||
const { count, rows } = await this.servicePriceModel.findAndCountAll({
|
||||
where,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order: [["id", "DESC"]]
|
||||
});
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取服务价目列表成功",
|
||||
data: { list: rows.map((r) => r.toJSON()), total: count, page: Number(page), pageSize: Number(pageSize) }
|
||||
};
|
||||
}
|
||||
|
||||
async detail(id: number): Promise<any> {
|
||||
const record = await this.servicePriceModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
return { code: 200, message: "获取服务价目详情成功", data: record.toJSON() };
|
||||
}
|
||||
|
||||
async create(data: any, ctx: Context): Promise<any> {
|
||||
if (!data.priceName) return { code: 400, message: "价目名称不能为空", data: null };
|
||||
if (data.unitPrice === undefined || data.unitPrice === null || data.unitPrice === "") {
|
||||
return { code: 400, message: "单价不能为空", data: null };
|
||||
}
|
||||
|
||||
const payload = this.pickWritable(data);
|
||||
if (payload.priceMode === undefined || payload.priceMode === null) payload.priceMode = 1;
|
||||
if (payload.status === undefined || payload.status === null) payload.status = 1;
|
||||
if (!payload.unit) payload.unit = MODE_UNIT[Number(payload.priceMode)] || "次";
|
||||
|
||||
try {
|
||||
payload.priceCode = await this.generatePriceCode();
|
||||
const created = await this.servicePriceModel.create(payload);
|
||||
return { code: 200, message: "创建服务价目成功", data: created.toJSON() };
|
||||
} catch (err: any) {
|
||||
// 唯一冲突:重新生成编码后重试 1 次
|
||||
if (err instanceof UniqueConstraintError) {
|
||||
try {
|
||||
payload.priceCode = await this.generatePriceCode();
|
||||
const created = await this.servicePriceModel.create(payload);
|
||||
return { code: 200, message: "创建服务价目成功", data: created.toJSON() };
|
||||
} catch (_e) {
|
||||
return { code: 400, message: "价目编码已存在", data: null };
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: number, data: any, ctx: Context): Promise<any> {
|
||||
const record = await this.servicePriceModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
|
||||
const payload = this.pickWritable(data);
|
||||
await record.update(payload);
|
||||
|
||||
return { code: 200, message: "更新服务价目成功", data: record.toJSON() };
|
||||
}
|
||||
|
||||
async delete(id: number, ctx: Context): Promise<any> {
|
||||
const record = await this.servicePriceModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
|
||||
await record.destroy();
|
||||
return { code: 200, message: "删除服务价目成功", data: null };
|
||||
}
|
||||
|
||||
async updateStatus(id: number, status: number, ctx: Context): Promise<any> {
|
||||
if (status !== 0 && status !== 1) return { code: 400, message: "状态值非法", data: null };
|
||||
|
||||
const record = await this.servicePriceModel.findByPk(id);
|
||||
if (!record) return { code: 404, message: "价目不存在", data: null };
|
||||
|
||||
await record.update({ status });
|
||||
return { code: 200, message: "更新服务价目状态成功", data: null };
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,14 @@ import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import { Op } from "sequelize";
|
||||
import { Context } from "@midwayjs/koa";
|
||||
import dayjs from "dayjs";
|
||||
import Worker from "../entity/worker.entity";
|
||||
import WorkerCert from "../entity/worker_cert.entity";
|
||||
import SysNotice from "../entity/sys_notice.entity";
|
||||
import SurveyOrder from "../entity/survey_order.entity";
|
||||
import InstallOrder from "../entity/install_order.entity";
|
||||
import { WorkerCreateDTO, WorkerUpdateDTO, WorkerAuditDTO, WorkerCertDTO } from "../dto/worker.dto";
|
||||
import { hashPassword } from "../util/crypto";
|
||||
|
||||
// 电工状态:1在岗 0离职
|
||||
const STATUS_ON_DUTY = 1;
|
||||
@@ -12,41 +19,421 @@ const RECOMMEND_LIMIT = 5;
|
||||
// 经验分基准场次(达到即满分)
|
||||
const EXP_BASE_COUNT = 50;
|
||||
|
||||
// 审核状态:0待审核 1已通过 2已驳回
|
||||
const AUDIT_PENDING = 0;
|
||||
const AUDIT_APPROVED = 1;
|
||||
const AUDIT_REJECTED = 2;
|
||||
|
||||
// 资质状态:none无证书 normal正常 expiring临期 expired已过期
|
||||
const CERT_STATUS_NONE = "none";
|
||||
const CERT_STATUS_NORMAL = "normal";
|
||||
const CERT_STATUS_EXPIRING = "expiring";
|
||||
const CERT_STATUS_EXPIRED = "expired";
|
||||
|
||||
// 临期预警天数(today <= expireDate <= today+30 视为临期)
|
||||
const CERT_EXPIRE_WARN_DAYS = 30;
|
||||
|
||||
// 到期提醒通知标识
|
||||
const NOTICE_TYPE_CERT_EXPIRE = "cert_expire";
|
||||
const NOTICE_BIZ_TYPE_WORKER_CERT = "worker_cert";
|
||||
|
||||
// 默认登录密码:手机号后 6 位,不足 6 位用 123456
|
||||
function getDefaultPassword(phone: string): string {
|
||||
return phone.length >= 6 ? phone.slice(-6) : "123456";
|
||||
}
|
||||
|
||||
// 归一化 DATEONLY 值为 YYYY-MM-DD 字符串(NULL/空 表示长期有效)
|
||||
function toDateOnly(value: any): string | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const str = String(value);
|
||||
return str.length >= 10 ? str.slice(0, 10) : str;
|
||||
}
|
||||
|
||||
// 按电工分组证书
|
||||
function groupCertsByWorker(certs: WorkerCert[]): Map<number, WorkerCert[]> {
|
||||
const map = new Map<number, WorkerCert[]>();
|
||||
for (const cert of certs) {
|
||||
const key = Number(cert.workerId);
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(cert);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// 由证书的 expire_date 推导资质状态,优先级 expired > expiring > normal > none
|
||||
function computeCertStatus(certs: Array<{ expireDate?: any }>, today: string, warn: string): string {
|
||||
if (!certs || certs.length === 0) return CERT_STATUS_NONE;
|
||||
let hasExpiring = false;
|
||||
for (const cert of certs) {
|
||||
const expire = toDateOnly(cert.expireDate);
|
||||
// expire_date 为 NULL 视为长期有效,计为正常
|
||||
if (!expire) continue;
|
||||
// YYYY-MM-DD 字符串可直接字典序比较
|
||||
if (expire < today) return CERT_STATUS_EXPIRED;
|
||||
if (expire <= warn) hasExpiring = true;
|
||||
}
|
||||
return hasExpiring ? CERT_STATUS_EXPIRING : CERT_STATUS_NORMAL;
|
||||
}
|
||||
|
||||
@Provide()
|
||||
export class WorkerService {
|
||||
@InjectRepository(Worker)
|
||||
workerModel!: Repository<Worker>;
|
||||
|
||||
// 列表:姓名/手机号/资质模糊检索 + 状态过滤 + 分页
|
||||
@InjectRepository(WorkerCert)
|
||||
certModel!: Repository<WorkerCert>;
|
||||
|
||||
@InjectRepository(SysNotice)
|
||||
noticeModel!: Repository<SysNotice>;
|
||||
|
||||
@InjectRepository(SurveyOrder)
|
||||
surveyOrderModel!: Repository<SurveyOrder>;
|
||||
|
||||
@InjectRepository(InstallOrder)
|
||||
installOrderModel!: Repository<InstallOrder>;
|
||||
|
||||
// 资质状态推导所需的日期边界
|
||||
private certDateRange(): { today: string; warn: string } {
|
||||
return {
|
||||
today: dayjs().format("YYYY-MM-DD"),
|
||||
warn: dayjs().add(CERT_EXPIRE_WARN_DAYS, "day").format("YYYY-MM-DD")
|
||||
};
|
||||
}
|
||||
|
||||
// 剔除敏感字段
|
||||
private cleanWorker(worker: Worker): any {
|
||||
const data = worker.toJSON ? worker.toJSON() : { ...worker };
|
||||
delete data.passwordHash;
|
||||
return data;
|
||||
}
|
||||
|
||||
// 证书列表摘要(列表页使用)
|
||||
private toCertSummary(certs: WorkerCert[]): any[] {
|
||||
return certs.map((c) => ({ id: c.id, certType: c.certType, certNo: c.certNo, expireDate: c.expireDate }));
|
||||
}
|
||||
|
||||
// 一次性批量加载证书并按电工分组,避免 N+1
|
||||
private async loadCertMap(workerIds: number[]): Promise<Map<number, WorkerCert[]>> {
|
||||
if (!workerIds || workerIds.length === 0) return new Map();
|
||||
const certs = await this.certModel.findAll({ where: { workerId: { [Op.in]: workerIds } } });
|
||||
return groupCertsByWorker(certs);
|
||||
}
|
||||
|
||||
// 清理证照到期提醒(续期/删除后允许重新提醒)
|
||||
private async clearCertNotices(certIds: number[]): Promise<void> {
|
||||
if (!certIds || certIds.length === 0) return;
|
||||
await this.noticeModel.destroy({
|
||||
where: {
|
||||
noticeType: NOTICE_TYPE_CERT_EXPIRE,
|
||||
bizType: NOTICE_BIZ_TYPE_WORKER_CERT,
|
||||
bizId: { [Op.in]: certIds }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 构建证书写入字段
|
||||
private buildCertPayload(workerId: number, dto: WorkerCertDTO): any {
|
||||
return {
|
||||
workerId,
|
||||
certType: dto.certType,
|
||||
certNo: dto.certNo,
|
||||
issueAuthority: dto.issueAuthority,
|
||||
issueDate: dto.issueDate,
|
||||
expireDate: dto.expireDate,
|
||||
certImageUrl: dto.certImageUrl
|
||||
};
|
||||
}
|
||||
|
||||
// 列表:姓名/手机号模糊检索 + 状态/审核状态/资质状态过滤 + 分页
|
||||
async list(query: any, ctx: Context): Promise<any> {
|
||||
const page = Number(query.page) || 1;
|
||||
const pageSize = Number(query.pageSize || query.limit) || 10;
|
||||
const rawPageSize = Number(query.pageSize || query.limit) || 10;
|
||||
const pageSize = Math.min(Math.max(rawPageSize, 1), 200);
|
||||
|
||||
const where: any = {};
|
||||
if (query.keyword) {
|
||||
where[Op.or] = [
|
||||
{ workerName: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ phone: { [Op.like]: `%${query.keyword}%` } },
|
||||
{ certInfo: { [Op.like]: `%${query.keyword}%` } }
|
||||
{ phone: { [Op.like]: `%${query.keyword}%` } }
|
||||
];
|
||||
}
|
||||
if (query.status !== undefined && query.status !== "") where.status = Number(query.status);
|
||||
if (query.auditStatus !== undefined && query.auditStatus !== "") where.auditStatus = Number(query.auditStatus);
|
||||
|
||||
const certStatus: string = query.certStatus || "";
|
||||
const { today, warn } = this.certDateRange();
|
||||
|
||||
// 资质状态筛选:先取全部命中基础条件的电工,内存推导状态后过滤再分页(保证 total 正确)
|
||||
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)));
|
||||
const filtered = rows.filter((r) => computeCertStatus(certMap.get(Number(r.id)) || [], today, warn) === certStatus);
|
||||
const pageRows = filtered.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize);
|
||||
const list = pageRows.map((r) => {
|
||||
const certs = certMap.get(Number(r.id)) || [];
|
||||
return {
|
||||
...this.cleanWorker(r),
|
||||
certStatus: computeCertStatus(certs, today, warn),
|
||||
certs: this.toCertSummary(certs)
|
||||
};
|
||||
});
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取共享电工列表成功",
|
||||
data: { list, total: filtered.length, page, pageSize }
|
||||
};
|
||||
}
|
||||
|
||||
// 常规分页:仅对当前页电工批量加载证书
|
||||
const { count, rows } = await this.workerModel.findAndCountAll({
|
||||
where,
|
||||
attributes: { exclude: ["passwordHash"] },
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
order: [["rating", "DESC"]],
|
||||
distinct: true
|
||||
});
|
||||
const certMap = await this.loadCertMap(rows.map((r) => Number(r.id)));
|
||||
const list = rows.map((r) => {
|
||||
const certs = certMap.get(Number(r.id)) || [];
|
||||
return {
|
||||
...this.cleanWorker(r),
|
||||
certStatus: computeCertStatus(certs, today, warn),
|
||||
certs: this.toCertSummary(certs)
|
||||
};
|
||||
});
|
||||
|
||||
return { code: 200, message: "获取共享电工列表成功", data: { list: rows.map((r) => r.toJSON()), total: count, page, pageSize } };
|
||||
return { code: 200, message: "获取共享电工列表成功", data: { list, total: count, page, pageSize } };
|
||||
}
|
||||
|
||||
// 下拉选项:仅在岗电工
|
||||
// KPI 统计:总电工数 / 待审核 / 临期 / 已过期
|
||||
async stats(): Promise<any> {
|
||||
const total = await this.workerModel.count();
|
||||
const pendingAudit = await this.workerModel.count({ where: { auditStatus: AUDIT_PENDING } });
|
||||
|
||||
// 一次性取出全部证书,内存按电工分组统计(避免 N+1)
|
||||
const workers = await this.workerModel.findAll({ attributes: ["id"] });
|
||||
const validIds = new Set(workers.map((w) => Number(w.id)));
|
||||
const certs = await this.certModel.findAll({ attributes: ["workerId", "expireDate"] });
|
||||
const certMap = groupCertsByWorker(certs);
|
||||
const { today, warn } = this.certDateRange();
|
||||
|
||||
let expiring = 0;
|
||||
let expired = 0;
|
||||
for (const [workerId, list] of certMap) {
|
||||
if (!validIds.has(workerId)) continue;
|
||||
const status = computeCertStatus(list, today, warn);
|
||||
if (status === CERT_STATUS_EXPIRING) expiring += 1;
|
||||
else if (status === CERT_STATUS_EXPIRED) expired += 1;
|
||||
}
|
||||
|
||||
return { code: 200, message: "获取电工统计成功", data: { total, pendingAudit, expiring, expired } };
|
||||
}
|
||||
|
||||
// 详情:含完整证书数组
|
||||
async detail(id: number): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(id, { attributes: { exclude: ["passwordHash"] } });
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在", data: null };
|
||||
}
|
||||
const certs = await this.certModel.findAll({ where: { workerId: id }, order: [["expireDate", "ASC"]] });
|
||||
const { today, warn } = this.certDateRange();
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取电工详情成功",
|
||||
data: {
|
||||
...this.cleanWorker(worker),
|
||||
certStatus: computeCertStatus(certs, today, warn),
|
||||
certs
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 新增电工:手机号唯一,审核状态固定待审核、状态默认离职(0)
|
||||
async create(dto: WorkerCreateDTO): Promise<any> {
|
||||
const phone = dto.phone;
|
||||
const existing = await this.workerModel.findOne({ where: { phone }, attributes: ["id"] });
|
||||
if (existing) {
|
||||
return { code: 400, message: "手机号已存在", data: null };
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
workerName: dto.workerName,
|
||||
phone,
|
||||
idCard: dto.idCard,
|
||||
workerTitle: dto.workerTitle,
|
||||
status: 0,
|
||||
auditStatus: AUDIT_PENDING
|
||||
};
|
||||
if (dto.rating !== undefined) payload.rating = dto.rating;
|
||||
if (dto.certInfo !== undefined) payload.certInfo = dto.certInfo;
|
||||
// 开通电工APP账号:默认密码为手机号后 6 位
|
||||
if (dto.withAccount === true) payload.passwordHash = await hashPassword(getDefaultPassword(phone));
|
||||
|
||||
const worker = await this.workerModel.create(payload);
|
||||
|
||||
if (Array.isArray(dto.certs) && dto.certs.length > 0) {
|
||||
await this.certModel.bulkCreate(dto.certs.map((c) => this.buildCertPayload(Number(worker.id), c)) as any);
|
||||
}
|
||||
|
||||
return { code: 200, message: "创建电工成功", data: this.cleanWorker(worker) };
|
||||
}
|
||||
|
||||
// 更新电工:字段白名单;证书存在时整体覆盖
|
||||
async update(id: number, dto: WorkerUpdateDTO): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(id);
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在", data: null };
|
||||
}
|
||||
|
||||
if (dto.phone !== undefined && dto.phone !== worker.phone) {
|
||||
const conflict = await this.workerModel.findOne({ where: { phone: dto.phone }, attributes: ["id"] });
|
||||
if (conflict && Number(conflict.id) !== Number(id)) {
|
||||
return { code: 400, message: "手机号已存在", data: null };
|
||||
}
|
||||
}
|
||||
|
||||
const payload: any = {};
|
||||
if (dto.workerName !== undefined) payload.workerName = dto.workerName;
|
||||
if (dto.phone !== undefined) payload.phone = dto.phone;
|
||||
if (dto.idCard !== undefined) payload.idCard = dto.idCard;
|
||||
if (dto.workerTitle !== undefined) payload.workerTitle = dto.workerTitle;
|
||||
if (dto.status !== undefined) payload.status = dto.status;
|
||||
if (dto.rating !== undefined) payload.rating = dto.rating;
|
||||
if (dto.certInfo !== undefined) payload.certInfo = dto.certInfo;
|
||||
|
||||
await worker.update(payload);
|
||||
|
||||
// 整体覆盖证书:先删后插,并清理被删证书的到期提醒
|
||||
if (Array.isArray(dto.certs)) {
|
||||
const oldCerts = await this.certModel.findAll({ where: { workerId: id }, attributes: ["id"] });
|
||||
await this.clearCertNotices(oldCerts.map((c) => Number(c.id)));
|
||||
await this.certModel.destroy({ where: { workerId: id } });
|
||||
if (dto.certs.length > 0) {
|
||||
await this.certModel.bulkCreate(dto.certs.map((c) => this.buildCertPayload(Number(id), c)) as any);
|
||||
}
|
||||
}
|
||||
|
||||
return { code: 200, message: "更新电工成功", data: this.cleanWorker(worker) };
|
||||
}
|
||||
|
||||
// 删除电工:存在派单记录则拒绝;否则级联删除证书并清理通知
|
||||
async remove(id: number): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(id);
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在", data: null };
|
||||
}
|
||||
|
||||
const [surveyRef, installRef] = await Promise.all([
|
||||
this.surveyOrderModel.findOne({ where: { workerId: id }, attributes: ["id"] }),
|
||||
this.installOrderModel.findOne({ where: { workerId: id }, attributes: ["id"] })
|
||||
]);
|
||||
if (surveyRef || installRef) {
|
||||
return { code: 400, message: "该电工存在派单记录,无法删除", data: null };
|
||||
}
|
||||
|
||||
const certs = await this.certModel.findAll({ where: { workerId: id }, attributes: ["id"] });
|
||||
await this.clearCertNotices(certs.map((c) => Number(c.id)));
|
||||
await this.certModel.destroy({ where: { workerId: id } });
|
||||
await worker.destroy();
|
||||
|
||||
return { code: 200, message: "删除电工成功", data: null };
|
||||
}
|
||||
|
||||
// 资质审核
|
||||
async audit(id: number, dto: WorkerAuditDTO, operatorUserId: number): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(id);
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在", data: null };
|
||||
}
|
||||
|
||||
const approved = dto.approved === true;
|
||||
if (approved) {
|
||||
await worker.update({
|
||||
auditStatus: AUDIT_APPROVED,
|
||||
auditBy: operatorUserId,
|
||||
auditAt: new Date(),
|
||||
auditRemark: null,
|
||||
certVerified: true
|
||||
});
|
||||
} else {
|
||||
const remark = (dto.remark || "").trim();
|
||||
if (!remark) {
|
||||
return { code: 400, message: "请填写驳回理由", data: null };
|
||||
}
|
||||
await worker.update({
|
||||
auditStatus: AUDIT_REJECTED,
|
||||
auditRemark: remark,
|
||||
auditBy: operatorUserId,
|
||||
auditAt: new Date(),
|
||||
certVerified: false
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: approved ? "审核通过成功" : "审核驳回成功",
|
||||
data: this.cleanWorker(worker)
|
||||
};
|
||||
}
|
||||
|
||||
// 证书新增/更新(certId 缺省为新增)
|
||||
async saveCert(workerId: number, dto: WorkerCertDTO, certId?: number): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(workerId, { attributes: ["id"] });
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在", data: null };
|
||||
}
|
||||
|
||||
if (certId !== undefined) {
|
||||
const cert = await this.certModel.findByPk(certId);
|
||||
if (!cert || Number(cert.workerId) !== Number(workerId)) {
|
||||
return { code: 403, message: "无权操作该证书", data: null };
|
||||
}
|
||||
|
||||
const payload: any = {};
|
||||
if (dto.certType !== undefined) payload.certType = dto.certType;
|
||||
if (dto.certNo !== undefined) payload.certNo = dto.certNo;
|
||||
if (dto.issueAuthority !== undefined) payload.issueAuthority = dto.issueAuthority;
|
||||
if (dto.issueDate !== undefined) payload.issueDate = dto.issueDate;
|
||||
if (dto.certImageUrl !== undefined) payload.certImageUrl = dto.certImageUrl;
|
||||
// 有效期变更时清理旧到期提醒,便于续期后重新提醒
|
||||
if (dto.expireDate !== undefined) {
|
||||
payload.expireDate = dto.expireDate;
|
||||
if (toDateOnly(dto.expireDate) !== toDateOnly(cert.expireDate)) {
|
||||
await this.clearCertNotices([certId]);
|
||||
}
|
||||
}
|
||||
await cert.update(payload);
|
||||
|
||||
return { code: 200, message: "更新证书成功", data: cert };
|
||||
}
|
||||
|
||||
const created = await this.certModel.create(this.buildCertPayload(workerId, dto) as any);
|
||||
return { code: 200, message: "新增证书成功", data: created };
|
||||
}
|
||||
|
||||
// 删除证书(校验归属)
|
||||
async removeCert(workerId: number, certId: number): Promise<any> {
|
||||
const cert = await this.certModel.findByPk(certId);
|
||||
if (!cert || Number(cert.workerId) !== Number(workerId)) {
|
||||
return { code: 403, message: "无权操作该证书", data: null };
|
||||
}
|
||||
|
||||
await this.clearCertNotices([certId]);
|
||||
await cert.destroy();
|
||||
|
||||
return { code: 200, message: "删除证书成功", data: null };
|
||||
}
|
||||
|
||||
// 下拉选项:仅在岗且审核通过的电工
|
||||
async options(ctx: Context): Promise<any> {
|
||||
const rows = await this.workerModel.findAll({
|
||||
where: { status: STATUS_ON_DUTY },
|
||||
where: { status: STATUS_ON_DUTY, auditStatus: AUDIT_APPROVED },
|
||||
attributes: ["id", "workerName", "workerTitle", "rating", "certInfo", "certVerified", "completedCount"],
|
||||
order: [["rating", "DESC"]]
|
||||
});
|
||||
@@ -55,7 +442,7 @@ export class WorkerService {
|
||||
|
||||
// 智能推荐:评分 50% + 资质 30% + 经验 20% 加权,取前 5 名
|
||||
async recommend(query: any, ctx: Context): Promise<any> {
|
||||
const rows = await this.workerModel.findAll({ where: { status: STATUS_ON_DUTY } });
|
||||
const rows = await this.workerModel.findAll({ where: { status: STATUS_ON_DUTY, auditStatus: AUDIT_APPROVED } });
|
||||
if (rows.length === 0) return { code: 200, message: "获取推荐电工成功", data: [] };
|
||||
|
||||
const certKeyword: string = query.certKeyword || "";
|
||||
@@ -95,4 +482,4 @@ export class WorkerService {
|
||||
|
||||
return { code: 200, message: "获取推荐电工成功", data };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { Provide, Inject } from "@midwayjs/core";
|
||||
import { InjectRepository } from "@midwayjs/sequelize";
|
||||
import { Repository } from "sequelize-typescript";
|
||||
import Worker from "../entity/worker.entity";
|
||||
import WorkerCert from "../entity/worker_cert.entity";
|
||||
import SysNotice from "../entity/sys_notice.entity";
|
||||
import { TokenUtil } from "../util/token";
|
||||
import { hashPassword, comparePassword } from "../util/crypto";
|
||||
import {
|
||||
WorkerRegisterDTO,
|
||||
WorkerLoginDTO,
|
||||
WorkerProfileUpdateDTO,
|
||||
WorkerAppCertDTO
|
||||
} from "../dto/worker_app.dto";
|
||||
|
||||
// 资质临期提醒阈值(天)
|
||||
const EXPIRING_DAYS = 30;
|
||||
// 证照到期通知类型
|
||||
const NOTICE_TYPE_CERT_EXPIRE = "cert_expire";
|
||||
// 证照业务类型
|
||||
const BIZ_TYPE_WORKER_CERT = "worker_cert";
|
||||
|
||||
@Provide()
|
||||
export class WorkerAppService {
|
||||
@Inject()
|
||||
tokenUtil!: TokenUtil;
|
||||
|
||||
@InjectRepository(Worker)
|
||||
workerModel!: Repository<Worker>;
|
||||
|
||||
@InjectRepository(WorkerCert)
|
||||
certModel!: Repository<WorkerCert>;
|
||||
|
||||
@InjectRepository(SysNotice)
|
||||
noticeModel!: Repository<SysNotice>;
|
||||
|
||||
// 电工注册:手机号唯一校验,初始为待审核(0)且离职(0)
|
||||
async register(dto: WorkerRegisterDTO): Promise<any> {
|
||||
const exist = await this.workerModel.findOne({ where: { phone: dto.phone } });
|
||||
if (exist) {
|
||||
return { code: 400, message: "手机号已注册" };
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(dto.password);
|
||||
const worker = await this.workerModel.create({
|
||||
workerName: dto.workerName,
|
||||
phone: dto.phone,
|
||||
idCard: dto.idCard || null,
|
||||
workerTitle: dto.workerTitle || null,
|
||||
passwordHash,
|
||||
auditStatus: 0,
|
||||
status: 0
|
||||
} as any);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "注册成功,请等待审核",
|
||||
data: this.basicInfo(worker)
|
||||
};
|
||||
}
|
||||
|
||||
// 电工登录:不因审核状态拦截,允许未通过/离职电工登录查看审核与资质
|
||||
async login(dto: WorkerLoginDTO): Promise<any> {
|
||||
const worker = await this.workerModel.findOne({ where: { phone: dto.phone } });
|
||||
if (!worker || !worker.passwordHash) {
|
||||
return { code: 400, message: "手机号或密码错误" };
|
||||
}
|
||||
|
||||
const isPasswordValid = await comparePassword(dto.password, worker.passwordHash);
|
||||
if (!isPasswordValid) {
|
||||
return { code: 400, message: "手机号或密码错误" };
|
||||
}
|
||||
|
||||
const token = this.tokenUtil.generateWorkerToken({
|
||||
workerId: worker.id,
|
||||
phone: worker.phone
|
||||
});
|
||||
|
||||
await worker.update({ lastLoginAt: new Date() });
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "登录成功",
|
||||
data: {
|
||||
token,
|
||||
workerInfo: this.basicInfo(worker)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 个人资料:含审核信息与资质状态汇总
|
||||
async profile(workerId: number): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(workerId);
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在" };
|
||||
}
|
||||
|
||||
const certs = await this.listCertRows(workerId);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取成功",
|
||||
data: {
|
||||
...this.basicInfo(worker),
|
||||
idCard: worker.idCard,
|
||||
auditStatus: worker.auditStatus,
|
||||
auditRemark: worker.auditRemark,
|
||||
certStatus: this.calcCertStatus(certs),
|
||||
certs: certs.map(cert => cert.toJSON())
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 更新个人资料:可改姓名/身份证/职衔,传 password 则重置密码
|
||||
async updateProfile(workerId: number, dto: WorkerProfileUpdateDTO): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(workerId);
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在" };
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (dto.workerName !== undefined) updateData.workerName = dto.workerName;
|
||||
if (dto.idCard !== undefined) updateData.idCard = dto.idCard;
|
||||
if (dto.workerTitle !== undefined) updateData.workerTitle = dto.workerTitle;
|
||||
if (dto.password) updateData.passwordHash = await hashPassword(dto.password);
|
||||
|
||||
await worker.update(updateData);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "更新成功",
|
||||
data: this.basicInfo(worker)
|
||||
};
|
||||
}
|
||||
|
||||
// 资质证照列表
|
||||
async listCerts(workerId: number): Promise<any> {
|
||||
const certs = await this.listCertRows(workerId);
|
||||
return {
|
||||
code: 200,
|
||||
message: "获取成功",
|
||||
data: certs.map(cert => cert.toJSON())
|
||||
};
|
||||
}
|
||||
|
||||
// 新增资质证照
|
||||
async createCert(workerId: number, dto: WorkerAppCertDTO): Promise<any> {
|
||||
const cert = await this.certModel.create({
|
||||
workerId,
|
||||
certType: dto.certType,
|
||||
certNo: dto.certNo || null,
|
||||
issueAuthority: dto.issueAuthority || null,
|
||||
issueDate: dto.issueDate || null,
|
||||
expireDate: dto.expireDate || null,
|
||||
certImageUrl: dto.certImageUrl || null
|
||||
} as any);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "新增成功",
|
||||
data: cert.toJSON()
|
||||
};
|
||||
}
|
||||
|
||||
// 修改资质证照:变更有效期时清除该证照的到期通知,以便重新提醒
|
||||
async updateCert(workerId: number, certId: number, dto: WorkerAppCertDTO): Promise<any> {
|
||||
const cert = await this.certModel.findByPk(certId);
|
||||
if (!cert || cert.workerId !== workerId) {
|
||||
return { code: 403, message: "无权操作该证书" };
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (dto.certType !== undefined) updateData.certType = dto.certType;
|
||||
if (dto.certNo !== undefined) updateData.certNo = dto.certNo;
|
||||
if (dto.issueAuthority !== undefined) updateData.issueAuthority = dto.issueAuthority;
|
||||
if (dto.issueDate !== undefined) updateData.issueDate = dto.issueDate;
|
||||
if (dto.expireDate !== undefined) updateData.expireDate = dto.expireDate;
|
||||
if (dto.certImageUrl !== undefined) updateData.certImageUrl = dto.certImageUrl;
|
||||
|
||||
await cert.update(updateData);
|
||||
|
||||
// 有效期变化后删除历史到期通知,续期后由后续任务重新生成提醒
|
||||
if (dto.expireDate !== undefined) {
|
||||
await this.clearCertNotices(certId);
|
||||
}
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
message: "更新成功",
|
||||
data: cert.toJSON()
|
||||
};
|
||||
}
|
||||
|
||||
// 删除资质证照:归属校验通过后连带清除到期通知
|
||||
async removeCert(workerId: number, certId: number): Promise<any> {
|
||||
const cert = await this.certModel.findByPk(certId);
|
||||
if (!cert || cert.workerId !== workerId) {
|
||||
return { code: 403, message: "无权操作该证书" };
|
||||
}
|
||||
|
||||
await cert.destroy();
|
||||
await this.clearCertNotices(certId);
|
||||
|
||||
return { code: 200, message: "删除成功" };
|
||||
}
|
||||
|
||||
// 重新提交审核:仅驳回(2)状态可退回待审核(0)
|
||||
async resubmit(workerId: number): Promise<any> {
|
||||
const worker = await this.workerModel.findByPk(workerId);
|
||||
if (!worker) {
|
||||
return { code: 404, message: "电工不存在" };
|
||||
}
|
||||
|
||||
if (worker.auditStatus !== 2) {
|
||||
return { code: 400, message: "当前状态无需重新提交" };
|
||||
}
|
||||
|
||||
await worker.update({
|
||||
auditStatus: 0,
|
||||
auditRemark: null,
|
||||
auditBy: null,
|
||||
auditAt: null
|
||||
} as any);
|
||||
|
||||
return { code: 200, message: "已重新提交,请等待审核" };
|
||||
}
|
||||
|
||||
// 到期提醒列表:基于 worker_cert 实时计算,包含临期与已过期(长期有效不计入)
|
||||
async listNotices(workerId: number): Promise<any> {
|
||||
const certs = await this.listCertRows(workerId);
|
||||
const today = this.todayStr();
|
||||
|
||||
const list = certs
|
||||
.filter(cert => !!cert.expireDate)
|
||||
.map(cert => {
|
||||
const expireDate = String(cert.expireDate).slice(0, 10);
|
||||
const remainDays = this.diffDays(today, expireDate);
|
||||
return {
|
||||
certId: cert.id,
|
||||
certType: cert.certType,
|
||||
certNo: cert.certNo,
|
||||
expireDate,
|
||||
remainDays,
|
||||
isExpired: expireDate < today
|
||||
};
|
||||
})
|
||||
.filter(item => item.isExpired || item.remainDays <= EXPIRING_DAYS)
|
||||
.sort((a, b) => a.remainDays - b.remainDays);
|
||||
|
||||
return { code: 200, message: "获取成功", data: list };
|
||||
}
|
||||
|
||||
// 查询某电工的全部证照
|
||||
private async listCertRows(workerId: number): Promise<WorkerCert[]> {
|
||||
return await this.certModel.findAll({
|
||||
where: { workerId },
|
||||
order: [["id", "DESC"]]
|
||||
});
|
||||
}
|
||||
|
||||
// 资质状态汇总:none 无证书 / expired 存在已过期 / expiring 存在临期 / normal 其余
|
||||
private calcCertStatus(certs: WorkerCert[]): string {
|
||||
if (!certs || certs.length === 0) return "none";
|
||||
|
||||
const today = this.todayStr();
|
||||
const soon = this.addDays(today, EXPIRING_DAYS);
|
||||
let hasExpiring = false;
|
||||
|
||||
for (const cert of certs) {
|
||||
// expire_date 为 NULL 视为长期有效
|
||||
if (!cert.expireDate) continue;
|
||||
const expireDate = String(cert.expireDate).slice(0, 10);
|
||||
if (expireDate < today) return "expired";
|
||||
if (expireDate <= soon) hasExpiring = true;
|
||||
}
|
||||
|
||||
return hasExpiring ? "expiring" : "normal";
|
||||
}
|
||||
|
||||
// 删除某证照在 sys_notice 的到期通知
|
||||
private async clearCertNotices(certId: number): Promise<void> {
|
||||
await this.noticeModel.destroy({
|
||||
where: {
|
||||
noticeType: NOTICE_TYPE_CERT_EXPIRE,
|
||||
bizType: BIZ_TYPE_WORKER_CERT,
|
||||
bizId: certId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 电工基础信息(不含 passwordHash)
|
||||
private basicInfo(worker: Worker): any {
|
||||
return {
|
||||
id: worker.id,
|
||||
workerName: worker.workerName,
|
||||
phone: worker.phone,
|
||||
workerTitle: worker.workerTitle,
|
||||
auditStatus: worker.auditStatus,
|
||||
status: worker.status
|
||||
};
|
||||
}
|
||||
|
||||
private formatDate(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
private todayStr(): string {
|
||||
return this.formatDate(new Date());
|
||||
}
|
||||
|
||||
private addDays(base: string, days: number): string {
|
||||
const date = new Date(`${base}T00:00:00`);
|
||||
date.setDate(date.getDate() + days);
|
||||
return this.formatDate(date);
|
||||
}
|
||||
|
||||
// 返回 to - from 的天数差(正数为未来)
|
||||
private diffDays(from: string, to: string): number {
|
||||
const fromTime = new Date(`${from}T00:00:00`).getTime();
|
||||
const toTime = new Date(`${to}T00:00:00`).getTime();
|
||||
return Math.round((toTime - fromTime) / 86400000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Inject } from "@midwayjs/core";
|
||||
import { Job, IJob } from "@midwayjs/cron";
|
||||
import { NoticeService } from "../service/notice.service";
|
||||
|
||||
/**
|
||||
* 电工资质证照到期提醒定时任务
|
||||
* 每天 08:00 扫描 worker_cert 中 30 天内到期或已过期的证照,
|
||||
* 为每个 ADMIN 生成一条站内通知(幂等,已存在的提醒不会重复生成)。
|
||||
*/
|
||||
@Job({ cronTime: "0 0 8 * * *", start: true, timeZone: "Asia/Shanghai" })
|
||||
export class CertExpireTask implements IJob {
|
||||
@Inject()
|
||||
noticeService!: NoticeService;
|
||||
|
||||
async onTick() {
|
||||
const result = await this.noticeService.scanCertExpireAndNotify();
|
||||
console.log(`[cert-expire] 证照到期扫描完成:扫描 ${result.scanned} 条,新增通知 ${result.created} 条`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { BuiltinJob } from "./job_registry";
|
||||
|
||||
/**
|
||||
* 示例心跳任务:用于验证调度链路是否打通。
|
||||
* 默认每 5 分钟执行一次,写一条 info 日志并返回本次心跳时间。
|
||||
*/
|
||||
export const demoHeartbeatJob: BuiltinJob = {
|
||||
jobKey: "demo_heartbeat",
|
||||
jobName: "示例心跳",
|
||||
description: "用于验证调度链路的示例任务,按周期写入一条心跳日志",
|
||||
defaultCron: "*/5 * * * *",
|
||||
defaultParams: { message: "heartbeat" },
|
||||
paramFields: [
|
||||
{ key: "message", label: "心跳内容", type: "string", placeholder: "默认 heartbeat" }
|
||||
],
|
||||
handler: (params, ctx) => {
|
||||
throw new Error("E2E 重试验证");
|
||||
const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
|
||||
const message = params?.message ?? "heartbeat";
|
||||
ctx.runtime.logger.info(`[${ctx.jobKey}] 心跳 ${now}(message=${message})`);
|
||||
return `心跳正常:${now}(message=${message})`;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { demoHeartbeatJob } from "./demo_heartbeat.task";
|
||||
import { logCleanJob } from "./log_clean.task";
|
||||
|
||||
export type JobParamType = "number" | "string";
|
||||
|
||||
/** 可编辑参数白名单字段(前端表单由它驱动渲染) */
|
||||
export interface JobParamField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: JobParamType;
|
||||
min?: number; // type=number 时的最小值
|
||||
max?: number; // type=number 时的最大值
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface JobLogger {
|
||||
info(message: string, ...args: any[]): void;
|
||||
warn(message: string, ...args: any[]): void;
|
||||
error(message: string, ...args: any[]): void;
|
||||
}
|
||||
|
||||
/** 调度器注入给任务的运行时能力(避免任务模块直接依赖 DI 与 Sequelize) */
|
||||
export interface JobRuntime {
|
||||
logger: JobLogger;
|
||||
/** 清理 sys_operation_log 中早于 keepDays 天的记录,返回删除条数 */
|
||||
cleanOperationLogs(keepDays: number): Promise<number>;
|
||||
/** 清理 sys_job_log 中早于 keepDays 天的记录,返回删除条数 */
|
||||
cleanJobLogs(keepDays: number): Promise<number>;
|
||||
}
|
||||
|
||||
export interface JobHandlerContext {
|
||||
jobKey: string;
|
||||
jobName: string;
|
||||
triggerType: number; // 1定时自动 2手动触发 3失败重试
|
||||
attempt: number; // 第几次尝试,从 1 开始
|
||||
runtime: JobRuntime;
|
||||
}
|
||||
|
||||
/** 返回值作为本次执行的结果摘要(message);抛异常视为执行失败 */
|
||||
export type JobHandler = (
|
||||
params: Record<string, any>,
|
||||
ctx: JobHandlerContext
|
||||
) => Promise<string | void> | string | void;
|
||||
|
||||
export interface BuiltinJob {
|
||||
jobKey: string;
|
||||
jobName: string;
|
||||
description: string;
|
||||
defaultCron: string;
|
||||
defaultParams: Record<string, any>;
|
||||
paramFields: JobParamField[];
|
||||
handler: JobHandler;
|
||||
}
|
||||
|
||||
export const BUILTIN_JOBS: BuiltinJob[] = [demoHeartbeatJob, logCleanJob];
|
||||
|
||||
export function getBuiltinJob(jobKey: string): BuiltinJob | undefined {
|
||||
return BUILTIN_JOBS.find(job => job.jobKey === jobKey);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { BuiltinJob } from "./job_registry";
|
||||
|
||||
/**
|
||||
* 日志清理任务:清理早于 keepDays 天的操作日志与任务日志。
|
||||
* 默认保留 90 天,每日 03:00 执行。
|
||||
*/
|
||||
export const logCleanJob: BuiltinJob = {
|
||||
jobKey: "log_clean",
|
||||
jobName: "日志清理",
|
||||
description: "清理早于 keepDays 天的操作日志与任务日志",
|
||||
defaultCron: "0 3 * * *",
|
||||
defaultParams: { keepDays: 90 },
|
||||
paramFields: [
|
||||
{ key: "keepDays", label: "保留天数", type: "number", min: 1, max: 3650 }
|
||||
],
|
||||
handler: async (params, ctx) => {
|
||||
const keepDays = Number(params?.keepDays ?? 90);
|
||||
const opCount = await ctx.runtime.cleanOperationLogs(keepDays);
|
||||
const jobCount = await ctx.runtime.cleanJobLogs(keepDays);
|
||||
return `清理操作日志 ${opCount} 条、任务日志 ${jobCount} 条`;
|
||||
}
|
||||
};
|
||||
@@ -38,6 +38,40 @@ export class TokenUtil {
|
||||
}
|
||||
}
|
||||
|
||||
// 生成电工 APP 令牌:携带 userType=worker 以区分管理端 token
|
||||
generateWorkerToken(payload: { workerId: number; phone: string }): string {
|
||||
return jwt.sign({ ...payload, userType: "worker" }, this.jwtConfig.secret, {
|
||||
expiresIn: this.jwtConfig.expiresIn as jwt.SignOptions["expiresIn"]
|
||||
});
|
||||
}
|
||||
|
||||
// 校验电工 APP 令牌:除有效期/签名外,还需 userType 为 worker
|
||||
verifyWorkerToken(token: string): any {
|
||||
let decoded: any;
|
||||
try {
|
||||
decoded = jwt.verify(token, this.jwtConfig.secret);
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
error.name = "TokenExpiredError";
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof JsonWebTokenError) {
|
||||
error.name = "JsonWebTokenError";
|
||||
throw error;
|
||||
}
|
||||
throw new Error("无效的认证令牌");
|
||||
}
|
||||
|
||||
// 防止管理端令牌被用于电工 APP 接口
|
||||
if (!decoded || decoded.userType !== "worker") {
|
||||
const invalidError = new JsonWebTokenError("无效的认证令牌");
|
||||
invalidError.name = "JsonWebTokenError";
|
||||
throw invalidError;
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
decodeToken(token: string): any {
|
||||
return jwt.decode(token);
|
||||
}
|
||||
|
||||
+170
-168
@@ -247,7 +247,7 @@
|
||||
resolved "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.7.tgz"
|
||||
integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==
|
||||
|
||||
"@babel/core@^7.0.0", "@babel/core@^7.0.0 || ^8.0.0-0", "@babel/core@^7.0.0-0", "@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.23.9", "@babel/core@^7.8.0", "@babel/core@>=7.0.0-beta.0 <8":
|
||||
"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.23.9":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.npmmirror.com/@babel/core/-/core-7.29.7.tgz"
|
||||
integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==
|
||||
@@ -530,7 +530,7 @@
|
||||
dependencies:
|
||||
"@hapi/hoek" "^9.0.0"
|
||||
|
||||
"@ioredis/commands@^1.0.2", "@ioredis/commands@1.10.0":
|
||||
"@ioredis/commands@1.10.0", "@ioredis/commands@^1.0.2":
|
||||
version "1.10.0"
|
||||
resolved "https://registry.npmmirror.com/@ioredis/commands/-/commands-1.10.0.tgz"
|
||||
integrity sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==
|
||||
@@ -710,7 +710,7 @@
|
||||
jest-haste-map "^29.7.0"
|
||||
slash "^3.0.0"
|
||||
|
||||
"@jest/transform@^29.0.0 || ^30.0.0", "@jest/transform@^29.7.0":
|
||||
"@jest/transform@^29.7.0":
|
||||
version "29.7.0"
|
||||
resolved "https://registry.npmmirror.com/@jest/transform/-/transform-29.7.0.tgz"
|
||||
integrity sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==
|
||||
@@ -731,7 +731,7 @@
|
||||
slash "^3.0.0"
|
||||
write-file-atomic "^4.0.2"
|
||||
|
||||
"@jest/types@^29.0.0 || ^30.0.0", "@jest/types@^29.6.3":
|
||||
"@jest/types@^29.6.3":
|
||||
version "29.6.3"
|
||||
resolved "https://registry.npmmirror.com/@jest/types/-/types-29.6.3.tgz"
|
||||
integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==
|
||||
@@ -769,14 +769,6 @@
|
||||
resolved "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz"
|
||||
integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
|
||||
|
||||
"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28":
|
||||
version "0.3.31"
|
||||
resolved "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz"
|
||||
integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==
|
||||
dependencies:
|
||||
"@jridgewell/resolve-uri" "^3.1.0"
|
||||
"@jridgewell/sourcemap-codec" "^1.4.14"
|
||||
|
||||
"@jridgewell/trace-mapping@0.3.9":
|
||||
version "0.3.9"
|
||||
resolved "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz"
|
||||
@@ -785,6 +777,14 @@
|
||||
"@jridgewell/resolve-uri" "^3.0.3"
|
||||
"@jridgewell/sourcemap-codec" "^1.4.10"
|
||||
|
||||
"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28":
|
||||
version "0.3.31"
|
||||
resolved "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz"
|
||||
integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==
|
||||
dependencies:
|
||||
"@jridgewell/resolve-uri" "^3.1.0"
|
||||
"@jridgewell/sourcemap-codec" "^1.4.14"
|
||||
|
||||
"@koa/router@^12.0.0":
|
||||
version "12.0.2"
|
||||
resolved "https://registry.npmmirror.com/@koa/router/-/router-12.0.2.tgz"
|
||||
@@ -918,6 +918,13 @@
|
||||
picomatch "2.3.1"
|
||||
reflect-metadata "0.2.2"
|
||||
|
||||
"@midwayjs/cron@^3.19.0":
|
||||
version "3.20.24"
|
||||
resolved "https://registry.npmmirror.com/@midwayjs/cron/-/cron-3.20.24.tgz#708c5f49ffaf99a0f09e3ad4268edfdc23b207cb"
|
||||
integrity sha512-L4xTTxTUNLujilJnscA7DWSSz1UeA7snjVnTI11jAXYiD7lJPSOVh0mV6yH/VA+ZtLbVqWCd0mrvNwjS4tpizQ==
|
||||
dependencies:
|
||||
cron "3.5.0"
|
||||
|
||||
"@midwayjs/debugger@^1.0.x":
|
||||
version "1.0.9"
|
||||
resolved "https://registry.npmmirror.com/@midwayjs/debugger/-/debugger-1.0.9.tgz"
|
||||
@@ -982,7 +989,7 @@
|
||||
semver "^7.3.4"
|
||||
supports-color "^8.1.0"
|
||||
|
||||
"@midwayjs/mock@*", "@midwayjs/mock@^3.19.0":
|
||||
"@midwayjs/mock@^3.19.0":
|
||||
version "3.20.24"
|
||||
resolved "https://registry.npmmirror.com/@midwayjs/mock/-/mock-3.20.24.tgz"
|
||||
integrity sha512-44DODB1uuVsXZyqVZtTTJboH5LttvJM0Dn/Xlg0SmyBDrKmA7m5SYUTqEn/knb/UabgkfOjJnhdjXlkEil0KAA==
|
||||
@@ -1049,7 +1056,7 @@
|
||||
"@nodelib/fs.stat" "2.0.5"
|
||||
run-parallel "^1.1.9"
|
||||
|
||||
"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
|
||||
"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
|
||||
version "2.0.5"
|
||||
resolved "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
|
||||
integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
|
||||
@@ -1399,6 +1406,11 @@
|
||||
"@types/koa-compose" "*"
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/luxon@~3.4.0":
|
||||
version "3.4.2"
|
||||
resolved "https://registry.npmmirror.com/@types/luxon/-/luxon-3.4.2.tgz#e4fc7214a420173cea47739c33cdf10874694db7"
|
||||
integrity sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==
|
||||
|
||||
"@types/methods@^1.1.4":
|
||||
version "1.1.4"
|
||||
resolved "https://registry.npmmirror.com/@types/methods/-/methods-1.1.4.tgz"
|
||||
@@ -1488,7 +1500,7 @@
|
||||
resolved "https://registry.npmmirror.com/@types/uuid/-/uuid-10.0.0.tgz"
|
||||
integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==
|
||||
|
||||
"@types/validator@*", "@types/validator@^13.15.10", "@types/validator@^13.15.3", "@types/validator@^13.7.17":
|
||||
"@types/validator@^13.15.10", "@types/validator@^13.15.3", "@types/validator@^13.7.17":
|
||||
version "13.15.10"
|
||||
resolved "https://registry.npmmirror.com/@types/validator/-/validator-13.15.10.tgz"
|
||||
integrity sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==
|
||||
@@ -1616,7 +1628,7 @@ asynckit@^0.4.0:
|
||||
resolved "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz"
|
||||
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
|
||||
|
||||
"babel-jest@^29.0.0 || ^30.0.0", babel-jest@^29.7.0:
|
||||
babel-jest@^29.7.0:
|
||||
version "29.7.0"
|
||||
resolved "https://registry.npmmirror.com/babel-jest/-/babel-jest-29.7.0.tgz"
|
||||
integrity sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==
|
||||
@@ -1743,7 +1755,7 @@ braces@^3.0.3, braces@~3.0.2:
|
||||
dependencies:
|
||||
fill-range "^7.1.1"
|
||||
|
||||
browserslist@^4.24.0, "browserslist@>= 4.21.0":
|
||||
browserslist@^4.24.0:
|
||||
version "4.28.2"
|
||||
resolved "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz"
|
||||
integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==
|
||||
@@ -1786,7 +1798,7 @@ buffer@^6.0.3:
|
||||
base64-js "^1.3.1"
|
||||
ieee754 "^1.2.1"
|
||||
|
||||
bytes@~3.1.2, bytes@3.1.2:
|
||||
bytes@3.1.2, bytes@~3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz"
|
||||
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
|
||||
@@ -1885,7 +1897,7 @@ cjs-module-lexer@^1.0.0:
|
||||
resolved "https://registry.npmmirror.com/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz"
|
||||
integrity sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==
|
||||
|
||||
class-transformer@^0.5.1, class-transformer@0.5.1:
|
||||
class-transformer@0.5.1, class-transformer@^0.5.1:
|
||||
version "0.5.1"
|
||||
resolved "https://registry.npmmirror.com/class-transformer/-/class-transformer-0.5.1.tgz"
|
||||
integrity sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==
|
||||
@@ -1908,16 +1920,16 @@ cliui@^8.0.1:
|
||||
strip-ansi "^6.0.1"
|
||||
wrap-ansi "^7.0.0"
|
||||
|
||||
cluster-key-slot@^1.1.0:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.npmmirror.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz"
|
||||
integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==
|
||||
|
||||
cluster-key-slot@1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.npmmirror.com/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz"
|
||||
integrity sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==
|
||||
|
||||
cluster-key-slot@^1.1.0:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.npmmirror.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz"
|
||||
integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==
|
||||
|
||||
co-body@^6.0.0:
|
||||
version "6.2.0"
|
||||
resolved "https://registry.npmmirror.com/co-body/-/co-body-6.2.0.tgz"
|
||||
@@ -2026,6 +2038,14 @@ create-require@^1.1.0:
|
||||
resolved "https://registry.npmmirror.com/create-require/-/create-require-1.1.1.tgz"
|
||||
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
|
||||
|
||||
cron@3.5.0:
|
||||
version "3.5.0"
|
||||
resolved "https://registry.npmmirror.com/cron/-/cron-3.5.0.tgz#e8caa384e998ed275e19598df4dbd0e8578c35d9"
|
||||
integrity sha512-0eYZqCnapmxYcV06uktql93wNWdlTmmBFP2iYz+JPVcQqlyFYcn1lFuIk4R54pkOmE7mcldTAPZv6X5XA4Q46A==
|
||||
dependencies:
|
||||
"@types/luxon" "~3.4.0"
|
||||
luxon "~3.5.0"
|
||||
|
||||
cross-env@^10.1.0:
|
||||
version "10.1.0"
|
||||
resolved "https://registry.npmmirror.com/cross-env/-/cross-env-10.1.0.tgz"
|
||||
@@ -2053,7 +2073,7 @@ dayjs@^1.11.13:
|
||||
resolved "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz"
|
||||
integrity sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==
|
||||
|
||||
debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@4, debug@4.4.3:
|
||||
debug@4, debug@4.4.3, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4:
|
||||
version "4.4.3"
|
||||
resolved "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz"
|
||||
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
|
||||
@@ -2085,17 +2105,17 @@ delegates@^1.0.0:
|
||||
resolved "https://registry.npmmirror.com/delegates/-/delegates-1.0.0.tgz"
|
||||
integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==
|
||||
|
||||
denque@^1.1.0:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.npmmirror.com/denque/-/denque-1.5.1.tgz"
|
||||
integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==
|
||||
|
||||
denque@2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz"
|
||||
integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==
|
||||
|
||||
depd@^2.0.0, depd@~2.0.0, depd@2.0.0:
|
||||
denque@^1.1.0:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.npmmirror.com/denque/-/denque-1.5.1.tgz"
|
||||
integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==
|
||||
|
||||
depd@2.0.0, depd@^2.0.0, depd@~2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz"
|
||||
integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
|
||||
@@ -2320,7 +2340,7 @@ fast-glob@^3.0.3:
|
||||
merge2 "^1.3.0"
|
||||
micromatch "^4.0.8"
|
||||
|
||||
fast-json-stable-stringify@^2.1.0, fast-json-stable-stringify@2.x:
|
||||
fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz"
|
||||
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
|
||||
@@ -2420,6 +2440,11 @@ fs.realpath@^1.0.0:
|
||||
resolved "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz"
|
||||
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
||||
|
||||
fsevents@^2.3.2, fsevents@~2.3.2:
|
||||
version "2.3.3"
|
||||
resolved "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
|
||||
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
|
||||
|
||||
function-bind@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz"
|
||||
@@ -2481,18 +2506,6 @@ glob-parent@^5.1.2, glob-parent@~5.1.2:
|
||||
dependencies:
|
||||
is-glob "^4.0.1"
|
||||
|
||||
glob@^7.1.3, glob@^7.1.4:
|
||||
version "7.2.3"
|
||||
resolved "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz"
|
||||
integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
|
||||
dependencies:
|
||||
fs.realpath "^1.0.0"
|
||||
inflight "^1.0.4"
|
||||
inherits "2"
|
||||
minimatch "^3.1.1"
|
||||
once "^1.3.0"
|
||||
path-is-absolute "^1.0.0"
|
||||
|
||||
glob@7.2.0:
|
||||
version "7.2.0"
|
||||
resolved "https://registry.npmmirror.com/glob/-/glob-7.2.0.tgz"
|
||||
@@ -2505,6 +2518,18 @@ glob@7.2.0:
|
||||
once "^1.3.0"
|
||||
path-is-absolute "^1.0.0"
|
||||
|
||||
glob@^7.1.3, glob@^7.1.4:
|
||||
version "7.2.3"
|
||||
resolved "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz"
|
||||
integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
|
||||
dependencies:
|
||||
fs.realpath "^1.0.0"
|
||||
inflight "^1.0.4"
|
||||
inherits "2"
|
||||
minimatch "^3.1.1"
|
||||
once "^1.3.0"
|
||||
path-is-absolute "^1.0.0"
|
||||
|
||||
globby@^10.0.1:
|
||||
version "10.0.2"
|
||||
resolved "https://registry.npmmirror.com/globby/-/globby-10.0.2.tgz"
|
||||
@@ -2578,39 +2603,6 @@ http-assert@^1.3.0:
|
||||
deep-equal "~1.0.1"
|
||||
http-errors "~1.8.0"
|
||||
|
||||
http-errors@^1.6.3, http-errors@~1.8.0:
|
||||
version "1.8.1"
|
||||
resolved "https://registry.npmmirror.com/http-errors/-/http-errors-1.8.1.tgz"
|
||||
integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==
|
||||
dependencies:
|
||||
depd "~1.1.2"
|
||||
inherits "2.0.4"
|
||||
setprototypeof "1.2.0"
|
||||
statuses ">= 1.5.0 < 2"
|
||||
toidentifier "1.0.1"
|
||||
|
||||
http-errors@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz"
|
||||
integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==
|
||||
dependencies:
|
||||
depd "~2.0.0"
|
||||
inherits "~2.0.4"
|
||||
setprototypeof "~1.2.0"
|
||||
statuses "~2.0.2"
|
||||
toidentifier "~1.0.1"
|
||||
|
||||
http-errors@~2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz"
|
||||
integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==
|
||||
dependencies:
|
||||
depd "~2.0.0"
|
||||
inherits "~2.0.4"
|
||||
setprototypeof "~1.2.0"
|
||||
statuses "~2.0.2"
|
||||
toidentifier "~1.0.1"
|
||||
|
||||
http-errors@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.0.tgz"
|
||||
@@ -2622,12 +2614,34 @@ http-errors@2.0.0:
|
||||
statuses "2.0.1"
|
||||
toidentifier "1.0.1"
|
||||
|
||||
http-errors@^1.6.3, http-errors@~1.8.0:
|
||||
version "1.8.1"
|
||||
resolved "https://registry.npmmirror.com/http-errors/-/http-errors-1.8.1.tgz"
|
||||
integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==
|
||||
dependencies:
|
||||
depd "~1.1.2"
|
||||
inherits "2.0.4"
|
||||
setprototypeof "1.2.0"
|
||||
statuses ">= 1.5.0 < 2"
|
||||
toidentifier "1.0.1"
|
||||
|
||||
http-errors@^2.0.0, http-errors@~2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz"
|
||||
integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==
|
||||
dependencies:
|
||||
depd "~2.0.0"
|
||||
inherits "~2.0.4"
|
||||
setprototypeof "~1.2.0"
|
||||
statuses "~2.0.2"
|
||||
toidentifier "~1.0.1"
|
||||
|
||||
human-signals@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmmirror.com/human-signals/-/human-signals-2.1.0.tgz"
|
||||
integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
|
||||
|
||||
iconv-lite@~0.4.24, iconv-lite@0.4.24:
|
||||
iconv-lite@0.4.24, iconv-lite@~0.4.24:
|
||||
version "0.4.24"
|
||||
resolved "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz"
|
||||
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
|
||||
@@ -2675,7 +2689,7 @@ inflight@^1.0.4:
|
||||
once "^1.3.0"
|
||||
wrappy "1"
|
||||
|
||||
inherits@~2.0.3, inherits@~2.0.4, inherits@2, inherits@2.0.4:
|
||||
inherits@2, inherits@2.0.4, inherits@~2.0.3, inherits@~2.0.4:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz"
|
||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||
@@ -3058,7 +3072,7 @@ jest-resolve-dependencies@^29.7.0:
|
||||
jest-regex-util "^29.6.3"
|
||||
jest-snapshot "^29.7.0"
|
||||
|
||||
jest-resolve@*, jest-resolve@^29.7.0:
|
||||
jest-resolve@^29.7.0:
|
||||
version "29.7.0"
|
||||
resolved "https://registry.npmmirror.com/jest-resolve/-/jest-resolve-29.7.0.tgz"
|
||||
integrity sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==
|
||||
@@ -3154,7 +3168,7 @@ jest-snapshot@^29.7.0:
|
||||
pretty-format "^29.7.0"
|
||||
semver "^7.5.3"
|
||||
|
||||
"jest-util@^29.0.0 || ^30.0.0", jest-util@^29.7.0:
|
||||
jest-util@^29.7.0:
|
||||
version "29.7.0"
|
||||
resolved "https://registry.npmmirror.com/jest-util/-/jest-util-29.7.0.tgz"
|
||||
integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==
|
||||
@@ -3202,7 +3216,7 @@ jest-worker@^29.7.0:
|
||||
merge-stream "^2.0.0"
|
||||
supports-color "^8.0.0"
|
||||
|
||||
"jest@^29.0.0 || ^30.0.0", jest@^29.7.0:
|
||||
jest@^29.7.0:
|
||||
version "29.7.0"
|
||||
resolved "https://registry.npmmirror.com/jest/-/jest-29.7.0.tgz"
|
||||
integrity sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==
|
||||
@@ -3228,6 +3242,13 @@ js-tokens@^4.0.0:
|
||||
resolved "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz"
|
||||
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||
|
||||
js-yaml@4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz"
|
||||
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
js-yaml@^3.13.1:
|
||||
version "3.14.2"
|
||||
resolved "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz"
|
||||
@@ -3243,13 +3264,6 @@ js-yaml@^4.1.0:
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
js-yaml@4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz"
|
||||
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
jsesc@^3.0.2:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz"
|
||||
@@ -3346,7 +3360,7 @@ koa-convert@^2.0.0:
|
||||
co "^4.6.0"
|
||||
koa-compose "^4.1.0"
|
||||
|
||||
koa@^2.11.0, koa@2.16.4:
|
||||
koa@2.16.4, koa@^2.11.0:
|
||||
version "2.16.4"
|
||||
resolved "https://registry.npmmirror.com/koa/-/koa-2.16.4.tgz"
|
||||
integrity sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw==
|
||||
@@ -3467,6 +3481,11 @@ lodash@^4.17.21:
|
||||
resolved "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz"
|
||||
integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==
|
||||
|
||||
lru-cache@7.18.3:
|
||||
version "7.18.3"
|
||||
resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-7.18.3.tgz"
|
||||
integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==
|
||||
|
||||
lru-cache@^5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz"
|
||||
@@ -3474,10 +3493,10 @@ lru-cache@^5.1.1:
|
||||
dependencies:
|
||||
yallist "^3.0.2"
|
||||
|
||||
lru-cache@7.18.3:
|
||||
version "7.18.3"
|
||||
resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-7.18.3.tgz"
|
||||
integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==
|
||||
luxon@~3.5.0:
|
||||
version "3.5.0"
|
||||
resolved "https://registry.npmmirror.com/luxon/-/luxon-3.5.0.tgz#6b6f65c5cd1d61d1fd19dbf07ee87a50bf4b8e20"
|
||||
integrity sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==
|
||||
|
||||
make-dir@^4.0.0:
|
||||
version "4.0.0"
|
||||
@@ -3824,7 +3843,7 @@ pg-types@2.2.0:
|
||||
postgres-date "~1.0.4"
|
||||
postgres-interval "^1.1.0"
|
||||
|
||||
pg@^8.11.0, pg@>=8.0:
|
||||
pg@^8.11.0:
|
||||
version "8.23.0"
|
||||
resolved "https://registry.npmmirror.com/pg/-/pg-8.23.0.tgz"
|
||||
integrity sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==
|
||||
@@ -3849,16 +3868,16 @@ picocolors@^1.1.1:
|
||||
resolved "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz"
|
||||
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
|
||||
|
||||
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz"
|
||||
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
|
||||
|
||||
picomatch@2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz"
|
||||
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
|
||||
|
||||
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz"
|
||||
integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
|
||||
|
||||
pirates@^4.0.4:
|
||||
version "4.0.7"
|
||||
resolved "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz"
|
||||
@@ -3920,13 +3939,6 @@ pure-rand@^6.0.0:
|
||||
resolved "https://registry.npmmirror.com/pure-rand/-/pure-rand-6.1.0.tgz"
|
||||
integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==
|
||||
|
||||
qs@^6.11.0, qs@^6.5.2:
|
||||
version "6.15.2"
|
||||
resolved "https://registry.npmmirror.com/qs/-/qs-6.15.2.tgz"
|
||||
integrity sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==
|
||||
dependencies:
|
||||
side-channel "^1.1.0"
|
||||
|
||||
qs@6.14.0:
|
||||
version "6.14.0"
|
||||
resolved "https://registry.npmmirror.com/qs/-/qs-6.14.0.tgz"
|
||||
@@ -3934,6 +3946,13 @@ qs@6.14.0:
|
||||
dependencies:
|
||||
side-channel "^1.1.0"
|
||||
|
||||
qs@^6.11.0, qs@^6.5.2:
|
||||
version "6.15.2"
|
||||
resolved "https://registry.npmmirror.com/qs/-/qs-6.15.2.tgz"
|
||||
integrity sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==
|
||||
dependencies:
|
||||
side-channel "^1.1.0"
|
||||
|
||||
queue-microtask@^1.2.2:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz"
|
||||
@@ -3946,16 +3965,6 @@ queue@^6.0.0:
|
||||
dependencies:
|
||||
inherits "~2.0.3"
|
||||
|
||||
raw-body@^2.3.3:
|
||||
version "2.5.3"
|
||||
resolved "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz"
|
||||
integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==
|
||||
dependencies:
|
||||
bytes "~3.1.2"
|
||||
http-errors "~2.0.1"
|
||||
iconv-lite "~0.4.24"
|
||||
unpipe "~1.0.0"
|
||||
|
||||
raw-body@2.5.2:
|
||||
version "2.5.2"
|
||||
resolved "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.2.tgz"
|
||||
@@ -3966,6 +3975,16 @@ raw-body@2.5.2:
|
||||
iconv-lite "0.4.24"
|
||||
unpipe "1.0.0"
|
||||
|
||||
raw-body@^2.3.3:
|
||||
version "2.5.3"
|
||||
resolved "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz"
|
||||
integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==
|
||||
dependencies:
|
||||
bytes "~3.1.2"
|
||||
http-errors "~2.0.1"
|
||||
iconv-lite "~0.4.24"
|
||||
unpipe "~1.0.0"
|
||||
|
||||
react-is@^18.0.0:
|
||||
version "18.3.1"
|
||||
resolved "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz"
|
||||
@@ -3996,19 +4015,19 @@ readdirp@~3.6.0:
|
||||
dependencies:
|
||||
picomatch "^2.2.1"
|
||||
|
||||
redis-errors@^1.0.0, redis-errors@^1.2.0, redis-errors@1.2.0:
|
||||
redis-errors@1.2.0, redis-errors@^1.0.0, redis-errors@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.npmmirror.com/redis-errors/-/redis-errors-1.2.0.tgz"
|
||||
integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==
|
||||
|
||||
redis-parser@^3.0.0, redis-parser@3.0.0:
|
||||
redis-parser@3.0.0, redis-parser@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.npmmirror.com/redis-parser/-/redis-parser-3.0.0.tgz"
|
||||
integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==
|
||||
dependencies:
|
||||
redis-errors "^1.0.0"
|
||||
|
||||
reflect-metadata@*, reflect-metadata@^0.2.2, reflect-metadata@0.2.2:
|
||||
reflect-metadata@0.2.2, reflect-metadata@^0.2.2:
|
||||
version "0.2.2"
|
||||
resolved "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz"
|
||||
integrity sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==
|
||||
@@ -4062,7 +4081,7 @@ run-parallel@^1.1.9:
|
||||
dependencies:
|
||||
queue-microtask "^1.2.2"
|
||||
|
||||
safe-buffer@^5.0.1, safe-buffer@~5.2.0, safe-buffer@5.2.1:
|
||||
safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@~5.2.0:
|
||||
version "5.2.1"
|
||||
resolved "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz"
|
||||
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||
@@ -4091,12 +4110,7 @@ scmp@^2.1.0:
|
||||
resolved "https://registry.npmmirror.com/scmp/-/scmp-2.1.0.tgz"
|
||||
integrity sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==
|
||||
|
||||
semver@^6.3.0:
|
||||
version "6.3.1"
|
||||
resolved "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz"
|
||||
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
|
||||
|
||||
semver@^6.3.1:
|
||||
semver@^6.3.0, semver@^6.3.1:
|
||||
version "6.3.1"
|
||||
resolved "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz"
|
||||
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
|
||||
@@ -4118,7 +4132,7 @@ sequelize-typescript@^2.1.6:
|
||||
dependencies:
|
||||
glob "7.2.0"
|
||||
|
||||
sequelize@^6.37.5, sequelize@>=6.20.1:
|
||||
sequelize@^6.37.5:
|
||||
version "6.37.8"
|
||||
resolved "https://registry.npmmirror.com/sequelize/-/sequelize-6.37.8.tgz"
|
||||
integrity sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==
|
||||
@@ -4140,7 +4154,7 @@ sequelize@^6.37.5, sequelize@>=6.20.1:
|
||||
validator "^13.9.0"
|
||||
wkx "^0.5.0"
|
||||
|
||||
setprototypeof@~1.2.0, setprototypeof@1.2.0:
|
||||
setprototypeof@1.2.0, setprototypeof@~1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz"
|
||||
integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
|
||||
@@ -4217,14 +4231,6 @@ slash@^3.0.0:
|
||||
resolved "https://registry.npmmirror.com/slash/-/slash-3.0.0.tgz"
|
||||
integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
|
||||
|
||||
source-map-support@^0.5.19:
|
||||
version "0.5.21"
|
||||
resolved "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz"
|
||||
integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
|
||||
dependencies:
|
||||
buffer-from "^1.0.0"
|
||||
source-map "^0.6.0"
|
||||
|
||||
source-map-support@0.5.13:
|
||||
version "0.5.13"
|
||||
resolved "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.13.tgz"
|
||||
@@ -4233,6 +4239,14 @@ source-map-support@0.5.13:
|
||||
buffer-from "^1.0.0"
|
||||
source-map "^0.6.0"
|
||||
|
||||
source-map-support@^0.5.19:
|
||||
version "0.5.21"
|
||||
resolved "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz"
|
||||
integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
|
||||
dependencies:
|
||||
buffer-from "^1.0.0"
|
||||
source-map "^0.6.0"
|
||||
|
||||
source-map@^0.6.0, source-map@^0.6.1:
|
||||
version "0.6.1"
|
||||
resolved "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz"
|
||||
@@ -4255,12 +4269,17 @@ stack-utils@^2.0.3:
|
||||
dependencies:
|
||||
escape-string-regexp "^2.0.0"
|
||||
|
||||
standard-as-callback@^2.1.0, standard-as-callback@2.1.0:
|
||||
standard-as-callback@2.1.0, standard-as-callback@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmmirror.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz"
|
||||
integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==
|
||||
|
||||
statuses@^1.5.0, "statuses@>= 1.5.0 < 2":
|
||||
statuses@2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz"
|
||||
integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
|
||||
|
||||
"statuses@>= 1.5.0 < 2", statuses@^1.5.0:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.npmmirror.com/statuses/-/statuses-1.5.0.tgz"
|
||||
integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
|
||||
@@ -4270,18 +4289,6 @@ statuses@~2.0.2:
|
||||
resolved "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz"
|
||||
integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==
|
||||
|
||||
statuses@2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz"
|
||||
integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
|
||||
|
||||
string_decoder@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz"
|
||||
integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
|
||||
dependencies:
|
||||
safe-buffer "~5.2.0"
|
||||
|
||||
string-length@^4.0.1:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.npmmirror.com/string-length/-/string-length-4.0.2.tgz"
|
||||
@@ -4299,6 +4306,13 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string_decoder@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz"
|
||||
integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
|
||||
dependencies:
|
||||
safe-buffer "~5.2.0"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||
@@ -4365,14 +4379,7 @@ supports-color@^7.1.0:
|
||||
dependencies:
|
||||
has-flag "^4.0.0"
|
||||
|
||||
supports-color@^8.0.0:
|
||||
version "8.1.1"
|
||||
resolved "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz"
|
||||
integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==
|
||||
dependencies:
|
||||
has-flag "^4.0.0"
|
||||
|
||||
supports-color@^8.1.0:
|
||||
supports-color@^8.0.0, supports-color@^8.1.0:
|
||||
version "8.1.1"
|
||||
resolved "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz"
|
||||
integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==
|
||||
@@ -4405,7 +4412,7 @@ to-regex-range@^5.0.1:
|
||||
dependencies:
|
||||
is-number "^7.0.0"
|
||||
|
||||
toidentifier@~1.0.1, toidentifier@1.0.1:
|
||||
toidentifier@1.0.1, toidentifier@~1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz"
|
||||
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
|
||||
@@ -4443,7 +4450,7 @@ ts-jest@^29.2.5:
|
||||
type-fest "^4.41.0"
|
||||
yargs-parser "^21.1.1"
|
||||
|
||||
ts-node@^10.0.0, ts-node@>=9.0.0:
|
||||
ts-node@^10.0.0:
|
||||
version "10.9.2"
|
||||
resolved "https://registry.npmmirror.com/ts-node/-/ts-node-10.9.2.tgz"
|
||||
integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==
|
||||
@@ -4510,7 +4517,7 @@ typescript@^4.1.0:
|
||||
resolved "https://registry.npmmirror.com/typescript/-/typescript-4.9.5.tgz"
|
||||
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
|
||||
|
||||
typescript@^5.7.0, typescript@>=2.7, "typescript@>=4.3 <7":
|
||||
typescript@^5.7.0:
|
||||
version "5.9.3"
|
||||
resolved "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz"
|
||||
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
|
||||
@@ -4525,11 +4532,6 @@ underscore@^1.13.1:
|
||||
resolved "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz"
|
||||
integrity sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==
|
||||
|
||||
"undici-types@>=7.24.0 <7.24.7":
|
||||
version "7.24.6"
|
||||
resolved "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz"
|
||||
integrity sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==
|
||||
|
||||
undici-types@~6.21.0:
|
||||
version "6.21.0"
|
||||
resolved "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz"
|
||||
@@ -4540,7 +4542,7 @@ universalify@^0.1.0:
|
||||
resolved "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz"
|
||||
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
|
||||
|
||||
unpipe@~1.0.0, unpipe@1.0.0:
|
||||
unpipe@1.0.0, unpipe@~1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz"
|
||||
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
|
||||
|
||||
Reference in new issue
Block a user