Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a5845708d | |||
| cd64cf42cc | |||
| 31344f1d58 | |||
| 1543bf76a9 | |||
| 622d8d5a4d | |||
| 3c1cf6c7fa | |||
| 17690283f6 | |||
| 030dc737fc | |||
| 609a01dc8a | |||
| 80f028bcb9 | |||
| 5061eced32 | |||
| 6896a86130 | |||
| 0652a24c5e | |||
| d53a8dfae5 | |||
| 2e369b23a9 | |||
| b72ad00912 | |||
| 7cc29e0a35 | |||
| 39458386ae | |||
| acef4418d8 | |||
| 9d84b1aae0 | |||
| d3d0830820 | |||
| b2da882b31 | |||
| 4ed4b537ad | |||
| 3988eaf910 | |||
| e9214137c1 | |||
| 13b74cfe97 | |||
|
|
ab882e085b | ||
| 62859bfc38 | |||
| ba328e02bb | |||
|
|
28d597d91e | ||
|
|
fe29fde564 | ||
|
|
7d578ab271 | ||
|
|
71da2d507e | ||
| acd41555f9 | |||
| 2367e03146 | |||
|
|
023490c012 | ||
|
|
29ef03c40f | ||
| 387eb41412 | |||
|
|
480714172e | ||
|
|
0c6ed249ee | ||
| 543d1a59a9 | |||
|
|
3ad30b4f39 | ||
|
|
14e0502d16 | ||
|
|
d43f999b96 | ||
|
|
8b34147868 | ||
| 7a4d831c10 | |||
|
|
3a064eb09f | ||
| 960fe805ec | |||
| 59b73f3dae | |||
| ddd05f8c02 | |||
| f634d21d2a | |||
| e3a456debd | |||
| 60debcda8a | |||
| 5615399a68 | |||
| 28c47b14a3 | |||
| 5947157f89 | |||
| f0ea903d59 | |||
| 824392b564 |
8
.env
8
.env
@@ -2,9 +2,9 @@
|
|||||||
# 如果部署在子目录下,结尾必须带 "/",例如 "/admin/",不能写成 "/admin"
|
# 如果部署在子目录下,结尾必须带 "/",例如 "/admin/",不能写成 "/admin"
|
||||||
VITE_BASE_URL=/
|
VITE_BASE_URL=/
|
||||||
|
|
||||||
VITE_APP_TITLE=研发内部管理系统
|
VITE_APP_TITLE=研发管理系统
|
||||||
|
|
||||||
VITE_APP_DESC=Frontend application for 灿能研发内部管理系统
|
VITE_APP_DESC=Frontend application for 灿能研发管理系统
|
||||||
|
|
||||||
# 图标名称前缀
|
# 图标名称前缀
|
||||||
VITE_ICON_PREFIX=icon
|
VITE_ICON_PREFIX=icon
|
||||||
@@ -33,7 +33,7 @@ VITE_SERVICE_SUCCESS_CODE=0
|
|||||||
|
|
||||||
# 后端登出状态码;当返回这些 code 时,前端会登出并跳回登录页
|
# 后端登出状态码;当返回这些 code 时,前端会登出并跳回登录页
|
||||||
# 典型场景:token 无效、登录状态失效、账号被踢下线、后端要求强制重新登录
|
# 典型场景:token 无效、登录状态失效、账号被踢下线、后端要求强制重新登录
|
||||||
VITE_SERVICE_LOGOUT_CODES=401,1002023000
|
VITE_SERVICE_LOGOUT_CODES=401
|
||||||
|
|
||||||
# 后端弹窗登出状态码;当返回这些 code 时,前端会先弹窗再登出
|
# 后端弹窗登出状态码;当返回这些 code 时,前端会先弹窗再登出
|
||||||
# 典型场景:账号被禁用、密码已重置、登录安全策略触发、需要用户先确认后再重新登录
|
# 典型场景:账号被禁用、密码已重置、登录安全策略触发、需要用户先确认后再重新登录
|
||||||
@@ -41,7 +41,7 @@ VITE_SERVICE_MODAL_LOGOUT_CODES=7777,7778
|
|||||||
|
|
||||||
# token 过期状态码;当返回这些 code 时,前端会尝试刷新 token 并重发请求
|
# token 过期状态码;当返回这些 code 时,前端会尝试刷新 token 并重发请求
|
||||||
# 典型场景:accessToken 过期但 refreshToken 仍有效、短期登录凭证失效但允许无感续期
|
# 典型场景:accessToken 过期但 refreshToken 仍有效、短期登录凭证失效但允许无感续期
|
||||||
VITE_SERVICE_EXPIRED_TOKEN_CODES=1002023001
|
VITE_SERVICE_EXPIRED_TOKEN_CODES=1002023000
|
||||||
|
|
||||||
# 静态路由模式下定义的超级管理员角色
|
# 静态路由模式下定义的超级管理员角色
|
||||||
VITE_STATIC_SUPER_ROLE=R_SUPER
|
VITE_STATIC_SUPER_ROLE=R_SUPER
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -38,5 +38,9 @@ yarn.lock
|
|||||||
/docs/*
|
/docs/*
|
||||||
!/docs/frontend-page-resource-manifest.json
|
!/docs/frontend-page-resource-manifest.json
|
||||||
|
|
||||||
|
# Claude
|
||||||
|
/.claude/*
|
||||||
|
|
||||||
# Temp
|
# Temp
|
||||||
/codeTemp/*
|
/codeTemp/*
|
||||||
|
SKILL.md
|
||||||
|
|||||||
2
.trae/rules/vue-need.md
Normal file
2
.trae/rules/vue-need.md
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
1. 每次开发新功能、编写代码时都添加好相应的注释。
|
||||||
|
2. 所有的vue文件编码必须是UTF-8的。
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
# 10-产品动态时间线 前端 API 文档
|
|
||||||
|
|
||||||
## 0. 文档定位
|
|
||||||
|
|
||||||
本文档是给前端产品首页“产品动态展示区域”单独使用的接口文档。
|
|
||||||
|
|
||||||
目标:
|
|
||||||
|
|
||||||
- 明确前端当前应该调用哪个接口
|
|
||||||
- 明确左侧筛选项如何映射到后端参数
|
|
||||||
- 明确接口返回字段、时间格式、边界规则
|
|
||||||
- 避免继续混用设置页最近动态和首页正式时间线
|
|
||||||
|
|
||||||
说明:
|
|
||||||
|
|
||||||
- 设置页原最近动态接口 `GET /project/product/{id}/activities` 继续保留
|
|
||||||
- 产品首页正式动态时间线请统一使用本文档中的新接口
|
|
||||||
- 当前首页动态时间线不包含需求池变动,需求池由独立区域承载
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 接口概览
|
|
||||||
|
|
||||||
### 1.1 接口信息
|
|
||||||
|
|
||||||
- 接口名称:获取产品动态时间线分页
|
|
||||||
- 请求方法:`GET`
|
|
||||||
- 请求路径:`/project/product/{id}/activities/page`
|
|
||||||
- 权限码:`project:product:query`
|
|
||||||
- 适用页面:产品首页动态时间线区域
|
|
||||||
|
|
||||||
### 1.2 接口用途
|
|
||||||
|
|
||||||
该接口用于返回产品首页动态展示区域的正式时间线数据,支持:
|
|
||||||
|
|
||||||
- 默认最近 30 天
|
|
||||||
- 左侧类型筛选
|
|
||||||
- 动作多选筛选
|
|
||||||
- 分页查询
|
|
||||||
- 创建初始化噪音去除
|
|
||||||
|
|
||||||
### 1.3 当前纳入首页时间线的事件范围
|
|
||||||
|
|
||||||
当前只包含以下 5 类:
|
|
||||||
|
|
||||||
- 产品创建
|
|
||||||
- 产品状态变更
|
|
||||||
- 产品经理变更
|
|
||||||
- 成员加入
|
|
||||||
- 成员移出
|
|
||||||
|
|
||||||
当前明确不包含:
|
|
||||||
|
|
||||||
- 需求池变动
|
|
||||||
- `update_member`
|
|
||||||
- 普通产品主数据编辑 `update`
|
|
||||||
- 删除产品动态
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 请求定义
|
|
||||||
|
|
||||||
### 2.1 路径参数
|
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `id` | `integer(int64)` | 是 | 产品 ID |
|
|
||||||
|
|
||||||
### 2.2 查询参数
|
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `pageNo` | `integer` | 是 | 页码,从 `1` 开始 |
|
|
||||||
| `pageSize` | `integer` | 是 | 每页条数 |
|
|
||||||
| `activityType` | `string` | 否 | 分类,可选 `status` / `product` / `member` |
|
|
||||||
| `actionTypes` | `array<string>` | 否 | 动作编码数组,支持多选 |
|
|
||||||
| `startTime` | `string` | 否 | 开始时间,格式 `yyyy-MM-dd HH:mm:ss` |
|
|
||||||
| `endTime` | `string` | 否 | 结束时间,格式 `yyyy-MM-dd HH:mm:ss` |
|
|
||||||
|
|
||||||
### 2.3 参数规则
|
|
||||||
|
|
||||||
#### 2.3.1 时间参数规则
|
|
||||||
|
|
||||||
- `startTime` 和 `endTime` 必须同时传,或者同时不传
|
|
||||||
- 都不传时,后端默认查询最近 `30` 天
|
|
||||||
- 只传一个时,后端返回参数错误
|
|
||||||
- `startTime > endTime` 时,后端返回参数错误
|
|
||||||
|
|
||||||
#### 2.3.2 筛选参数规则
|
|
||||||
|
|
||||||
- `activityType` 是分类筛选
|
|
||||||
- `actionTypes` 是动作细筛选
|
|
||||||
- 两者同时传时,按交集处理
|
|
||||||
- 如果前端未来需要做跨类型多选,可以不传 `activityType`,只传 `actionTypes`
|
|
||||||
|
|
||||||
#### 2.3.3 `actionTypes` 传参方式
|
|
||||||
|
|
||||||
GET 场景请按重复参数方式传递,例如:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/project/product/1024/activities/page?pageNo=1&pageSize=10&activityType=status&actionTypes=pause&actionTypes=resume&actionTypes=archive&actionTypes=abandon
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 左侧筛选映射
|
|
||||||
|
|
||||||
首页左侧当前 5 个筛选项,前端请按下表映射到请求参数:
|
|
||||||
|
|
||||||
| 前端筛选项 | `activityType` | `actionTypes` |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 产品创建 | `product` | `create` |
|
|
||||||
| 产品状态变更 | `status` | `pause` / `resume` / `archive` / `abandon` |
|
|
||||||
| 产品经理变更 | `product` | `change_manager` |
|
|
||||||
| 成员加入 | `member` | `add_member` |
|
|
||||||
| 成员移出 | `member` | `remove_member` |
|
|
||||||
|
|
||||||
补充说明:
|
|
||||||
|
|
||||||
- 首页时间线当前不展示需求池变动
|
|
||||||
- 需求池的展示由独立模块负责,不要通过本接口混查
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 响应定义
|
|
||||||
|
|
||||||
### 4.1 响应包装
|
|
||||||
|
|
||||||
接口统一返回 `CommonResult<PageResult<ProductActivityTimelineRespVO>>`。
|
|
||||||
|
|
||||||
成功响应结构:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "",
|
|
||||||
"data": {
|
|
||||||
"total": 0,
|
|
||||||
"list": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 `data` 结构
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `total` | `integer(int64)` | 总条数 |
|
|
||||||
| `list` | `array<object>` | 当前页数据 |
|
|
||||||
|
|
||||||
### 4.3 单条动态结构
|
|
||||||
|
|
||||||
| 字段 | 类型 | 说明 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `id` | `string` | 动态唯一标识,格式 `type:sourceId`,例如 `status:11` |
|
|
||||||
| `type` | `string` | 动态类型,取值 `status` / `product` / `member` |
|
|
||||||
| `actionType` | `string` | 动作编码 |
|
|
||||||
| `actionName` | `string` | 动作中文名称 |
|
|
||||||
| `operatorUserId` | `integer(int64)` | 操作人用户 ID,可为 `null` |
|
|
||||||
| `operatorName` | `string` | 操作人名称,可为空字符串 |
|
|
||||||
| `occurredAt` | `integer(int64)` | 动态发生时间,毫秒时间戳 |
|
|
||||||
| `summary` | `string` | 可直接展示的摘要文案 |
|
|
||||||
| `reason` | `string` | 原因说明,可为 `null` |
|
|
||||||
| `fromStatus` | `string` | 原状态编码,可为 `null` |
|
|
||||||
| `toStatus` | `string` | 目标状态编码,可为 `null` |
|
|
||||||
| `details` | `string` | 补充明细,当前为 JSON 字符串 |
|
|
||||||
|
|
||||||
### 4.4 时间格式说明
|
|
||||||
|
|
||||||
这个接口当前有两个时间口径:
|
|
||||||
|
|
||||||
- 请求里的 `startTime`、`endTime`:字符串,格式 `yyyy-MM-dd HH:mm:ss`
|
|
||||||
- 响应里的 `occurredAt`:毫秒时间戳 `number`
|
|
||||||
|
|
||||||
前端需要按这个真实口径处理,不要把 `occurredAt` 当成格式化字符串读取。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 字段语义说明
|
|
||||||
|
|
||||||
### 5.1 `type` 取值说明
|
|
||||||
|
|
||||||
| 取值 | 说明 | 数据来源 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `status` | 产品状态变更 | `rdms_product_status_log` |
|
|
||||||
| `product` | 产品对象动态 | `rdms_biz_audit_log` 中 `bizType=product` |
|
|
||||||
| `member` | 产品团队动态 | `rdms_biz_audit_log` 中 `bizType=rdms_user_object_role` |
|
|
||||||
|
|
||||||
### 5.2 `actionType` 取值范围
|
|
||||||
|
|
||||||
当前首页时间线只会出现以下动作:
|
|
||||||
|
|
||||||
| `type` | `actionType` | 说明 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `product` | `create` | 产品创建 |
|
|
||||||
| `product` | `change_manager` | 产品经理变更 |
|
|
||||||
| `status` | `pause` | 暂停 |
|
|
||||||
| `status` | `resume` | 恢复 |
|
|
||||||
| `status` | `archive` | 归档 |
|
|
||||||
| `status` | `abandon` | 废弃 |
|
|
||||||
| `member` | `add_member` | 成员加入 |
|
|
||||||
| `member` | `remove_member` | 成员移出 |
|
|
||||||
|
|
||||||
### 5.3 `details` 当前口径
|
|
||||||
|
|
||||||
`details` 当前不做统一结构化建模,按来源原样返回字符串:
|
|
||||||
|
|
||||||
- `type=status`
|
|
||||||
- 返回状态日志补充信息
|
|
||||||
- 当前包含 `productCodeSnapshot`、`productNameSnapshot`
|
|
||||||
- `type=product`
|
|
||||||
- 返回产品审计 `fieldChanges`
|
|
||||||
- `type=member`
|
|
||||||
- 返回成员审计 `fieldChanges`
|
|
||||||
|
|
||||||
前端建议:
|
|
||||||
|
|
||||||
- 首版先不依赖 `details` 做复杂渲染
|
|
||||||
- 先以 `actionName + summary + operatorName + occurredAt` 跑通展示
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 后端聚合规则
|
|
||||||
|
|
||||||
为了让前端看到的是“可直接展示的正式时间线”,后端已固定以下规则:
|
|
||||||
|
|
||||||
### 6.1 创建去噪
|
|
||||||
|
|
||||||
产品创建时通常会伴随初始化动作:
|
|
||||||
|
|
||||||
- 初始化 `change_manager`
|
|
||||||
- 初始化 `add_member`
|
|
||||||
|
|
||||||
这两类初始化动作不会单独出现在首页时间线里,最终只保留一条 `create`。
|
|
||||||
|
|
||||||
### 6.2 状态日志优先
|
|
||||||
|
|
||||||
如果同一状态动作同时存在:
|
|
||||||
|
|
||||||
- 产品审计日志
|
|
||||||
- 状态日志
|
|
||||||
|
|
||||||
则首页时间线只取状态日志,不重复展示产品审计里的同类状态动作。
|
|
||||||
|
|
||||||
### 6.3 成员调整排除
|
|
||||||
|
|
||||||
`update_member` 当前不进入首页正式时间线。
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- 首页当前只需要展示“加入”和“移出”
|
|
||||||
- 角色调整、备注调整等细节先不进入首页主时间线
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 请求示例
|
|
||||||
|
|
||||||
### 7.1 默认查询首页动态
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /project/product/1024/activities/page?pageNo=1&pageSize=6
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 查询“产品状态变更”
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /project/product/1024/activities/page?pageNo=1&pageSize=10&activityType=status&actionTypes=pause&actionTypes=resume&actionTypes=archive&actionTypes=abandon
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 查询“成员移出”并限制时间范围
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /project/product/1024/activities/page?pageNo=1&pageSize=10&activityType=member&actionTypes=remove_member&startTime=2026-03-24 00:00:00&endTime=2026-04-23 23:59:59
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 响应示例
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "",
|
|
||||||
"data": {
|
|
||||||
"total": 2,
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"id": "product:22",
|
|
||||||
"type": "product",
|
|
||||||
"actionType": "change_manager",
|
|
||||||
"actionName": "切换产品经理",
|
|
||||||
"operatorUserId": 10002,
|
|
||||||
"operatorName": "李四",
|
|
||||||
"occurredAt": 1776812345000,
|
|
||||||
"summary": "李四执行了【切换产品经理】",
|
|
||||||
"reason": null,
|
|
||||||
"fromStatus": null,
|
|
||||||
"toStatus": null,
|
|
||||||
"details": "{\"managerUserId\":{\"before\":10001,\"after\":10002}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "status:11",
|
|
||||||
"type": "status",
|
|
||||||
"actionType": "resume",
|
|
||||||
"actionName": "恢复",
|
|
||||||
"operatorUserId": 10001,
|
|
||||||
"operatorName": "张三",
|
|
||||||
"occurredAt": 1776812984000,
|
|
||||||
"summary": "张三执行了【恢复】:可以继续开展",
|
|
||||||
"reason": "可以继续开展",
|
|
||||||
"fromStatus": "paused",
|
|
||||||
"toStatus": "active",
|
|
||||||
"details": "{\"productCodeSnapshot\":\"CNPD2026001\",\"productNameSnapshot\":\"统一交付平台\"}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 错误码
|
|
||||||
|
|
||||||
| `code` | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `0` | 成功 |
|
|
||||||
| `400` | 请求参数错误,例如只传了一侧时间,或开始时间晚于结束时间 |
|
|
||||||
| `401` | 未登录 |
|
|
||||||
| `403` | 没有该产品查询权限 |
|
|
||||||
| `1008001000` | 产品不存在 |
|
|
||||||
|
|
||||||
参数错误示例:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 400,
|
|
||||||
"msg": "开始时间和结束时间必须同时传入",
|
|
||||||
"data": null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 前端接入建议
|
|
||||||
|
|
||||||
首页动态区域首版建议直接消费以下字段:
|
|
||||||
|
|
||||||
- `actionName`
|
|
||||||
- `summary`
|
|
||||||
- `operatorName`
|
|
||||||
- `occurredAt`
|
|
||||||
|
|
||||||
左侧筛选建议直接使用:
|
|
||||||
|
|
||||||
- `activityType`
|
|
||||||
- `actionTypes`
|
|
||||||
|
|
||||||
当前不建议首版依赖:
|
|
||||||
|
|
||||||
- `details` 的深度结构化解析
|
|
||||||
- 需求池事件混入本接口
|
|
||||||
- 自行从 `actionType` 反推新的派生事件类型
|
|
||||||
|
|
||||||
一句话结论:
|
|
||||||
|
|
||||||
- 设置页最近动态继续调 `/activities`
|
|
||||||
- 产品首页正式动态时间线统一调 `/activities/page`
|
|
||||||
52
AGENTS.md
52
AGENTS.md
@@ -11,6 +11,8 @@
|
|||||||
|
|
||||||
默认回答保持精简,优先给结论、改动点、验证方式和必要风险;如果用户只要求分析、审阅或方案,就停留在分析层,不主动扩展到实现层。
|
默认回答保持精简,优先给结论、改动点、验证方式和必要风险;如果用户只要求分析、审阅或方案,就停留在分析层,不主动扩展到实现层。
|
||||||
|
|
||||||
|
分析、解释、方案类回答优先用业务和逻辑语言把结构、差异与结论说清楚,不要大段贴源码、罗列 `file:line` 或把实现细节当解释;只有用户明确要求看代码、或某行确实是讨论焦点的关键佐证时,才贴最小必要的代码片段。
|
||||||
|
|
||||||
## 交互与执行原则
|
## 交互与执行原则
|
||||||
|
|
||||||
- 进入实施阶段前,先说明目标、涉及模块、预计改动点和验证方式。
|
- 进入实施阶段前,先说明目标、涉及模块、预计改动点和验证方式。
|
||||||
@@ -58,6 +60,7 @@
|
|||||||
- `build/plugins/router.ts`:elegant-router 配置与路由元信息生成逻辑
|
- `build/plugins/router.ts`:elegant-router 配置与路由元信息生成逻辑
|
||||||
- `src/hooks/common/table.ts`:列表页表格 hook 主入口
|
- `src/hooks/common/table.ts`:列表页表格 hook 主入口
|
||||||
- `src/hooks/common/form.ts`:表单校验与表单实例 hook
|
- `src/hooks/common/form.ts`:表单校验与表单实例 hook
|
||||||
|
- `src/constants/status-tag.ts`:业务对象状态颜色(ElTag type)集中配置
|
||||||
- `src/styles/scss/element-plus.scss`:当前项目表格、弹层、按钮、表单密度与公共壳样式标准
|
- `src/styles/scss/element-plus.scss`:当前项目表格、弹层、按钮、表单密度与公共壳样式标准
|
||||||
- `packages/*`:项目内本地共享库
|
- `packages/*`:项目内本地共享库
|
||||||
- `docs/`:当前工作上下文的一部分,做架构级、权限级、页面规范级改动前优先查阅
|
- `docs/`:当前工作上下文的一部分,做架构级、权限级、页面规范级改动前优先查阅
|
||||||
@@ -136,17 +139,18 @@
|
|||||||
- 页面组件保持“编排层薄”。页面文件主要负责搜索参数、表格 hook、列定义、弹层开关、接口调用编排,不把大量表单细节和重复交互直接堆在页面根组件里。
|
- 页面组件保持“编排层薄”。页面文件主要负责搜索参数、表格 hook、列定义、弹层开关、接口调用编排,不把大量表单细节和重复交互直接堆在页面根组件里。
|
||||||
- 列表页优先拆出同目录下的 `modules/*` 子组件,例如搜索组件、操作弹层、详情抽屉、资源面板等。
|
- 列表页优先拆出同目录下的 `modules/*` 子组件,例如搜索组件、操作弹层、详情抽屉、资源面板等。
|
||||||
- 系统管理下现有 `user`、`role`、`menu`、`dict` 页面可以作为参考实现,新增同类页面优先沿用它们的拆分方式。
|
- 系统管理下现有 `user`、`role`、`menu`、`dict` 页面可以作为参考实现,新增同类页面优先沿用它们的拆分方式。
|
||||||
- 搜索组件优先复用 `src/components/custom/table-search-panel.vue` 作为外壳。搜索模块本身应尽量只接收 `model`,只向外发出 `reset` / `search`,不直接承载列表请求逻辑。
|
- 新增或触达列表页搜索组件时,必须按 `docs/table-search-fields-usage.md` 使用 `src/components/custom/table-search-fields.vue` 的 `fields` 声明式配置,不得手写 `ElRow / ElCol / ElFormItem` 搜索区骨架;只有字段存在复杂联动、自定义插槽或 `TableSearchFields` 明确无法承载时,才允许退回 `src/components/custom/table-search-panel.vue`,并需要在实施说明中写明原因。搜索模块本身应尽量只接收 `model` 和必要选项,只向外发出 `reset` / `search`,不直接承载列表请求逻辑。
|
||||||
- 列表能力优先复用 `src/hooks/common/table.ts` 中的 `useUIPaginatedTable`、`useTableOperate`、`defaultTransform`。
|
- 列表能力优先复用 `src/hooks/common/table.ts` 中的 `useUIPaginatedTable`、`useTableOperate`、`defaultTransform`。
|
||||||
- 表单能力优先复用 `src/hooks/common/form.ts` 中的 `useForm`、`useFormRules`。
|
- 表单能力优先复用 `src/hooks/common/form.ts` 中的 `useForm`、`useFormRules`。
|
||||||
- 当前项目的真实业务口径是“内网中文优先”。新增业务页不必为了形式强行补全国际化键;但如果是在已有大量 `$t(...)` 的页面或模块内继续开发,优先保持该局部代码风格一致,不要半页中文直写、半页国际化混用。
|
- 当前项目的真实业务口径是“内网中文优先”。新增业务页不必为了形式强行补全国际化键;但如果是在已有大量 `$t(...)` 的页面或模块内继续开发,优先保持该局部代码风格一致,不要半页中文直写、半页国际化混用。
|
||||||
|
|
||||||
## 表格、搜索区与操作列约束
|
## 表格、搜索区与操作列约束
|
||||||
|
|
||||||
- 搜索区按钮组保持在最右侧;存在折叠项时,按钮顺序保持为“展开/收起 -> 重置 -> 查询”。
|
- 搜索区按钮组必须固定在第一行最后一个位置;存在折叠项时,按钮顺序保持为“展开/收起 -> 重置 -> 查询”。这是 `TableSearchFields` 的布局契约,不允许因为查询条件不足、展开/收起或响应式样式把按钮提前到中间位置或挤到后续行。
|
||||||
- 不要在每个页面重新拼一套搜索区骨架,优先延续 `TableSearchPanel` 的结构和交互。
|
- 不要在每个页面重新拼一套搜索区骨架;常规查询条件必须使用 `TableSearchFields`,通过 `columns` 控制每行格子数和折叠阈值。`columns` 表示首行总格数,其中最后 1 格永远留给按钮区;字段不足 `columns - 1` 时由公共组件补空占位,字段超过时剩余字段进入展开区。类似项目管理入口页这类 4 个查询条件的场景,必须使用 `:columns="4"`,形成“3 个条件 + 按钮区”的首行布局。
|
||||||
- 表格操作列优先复用 `src/components/custom/business-table-action-cell.tsx`。
|
- 表格操作列优先复用 `src/components/custom/business-table-action-cell.tsx`。
|
||||||
- 操作数 `<= 2` 时默认直出;操作数 `> 2` 时优先收敛为 `1 个直出主按钮 + 1 个更多按钮`。
|
- 操作数 `<= 2` 时默认直出;操作数 `> 2` 时优先收敛为 `1 个直出主按钮 + 1 个更多按钮`。
|
||||||
|
- 新增列表页如果使用 `ElCard` 承载需要撑满剩余高度的 `ElTable height="100%"`,`body-class` 优先使用公共类 `business-table-card-body`,该类由 `src/styles/scss/element-plus.scss` 统一维护;不要再为每个页面新增 `xxx-table-card-body` 私有样式。历史页面已有私有类时不强制专项回改,当前任务触达相关页面再按公共类收敛。
|
||||||
- 表格、按钮、弹层、表单的尺寸和间距标准优先由 `src/styles/scss/element-plus.scss` 和公共组件承接,不在业务页面散落写新的局部尺寸作为事实标准。
|
- 表格、按钮、弹层、表单的尺寸和间距标准优先由 `src/styles/scss/element-plus.scss` 和公共组件承接,不在业务页面散落写新的局部尺寸作为事实标准。
|
||||||
|
|
||||||
## 表单与弹层约束
|
## 表单与弹层约束
|
||||||
@@ -154,10 +158,14 @@
|
|||||||
- 新增、编辑能力优先沿用 `ElDialog / ElDrawer / ElForm / ElScrollbar / #footer` 这一套标准组合,不额外创造新的弹层交互模型。
|
- 新增、编辑能力优先沿用 `ElDialog / ElDrawer / ElForm / ElScrollbar / #footer` 这一套标准组合,不额外创造新的弹层交互模型。
|
||||||
- 轻中量表单优先复用 `src/components/custom/business-form-dialog.vue`;字段较多、需要保留列表上下文或承载重型控件时,再考虑 `src/components/custom/business-form-drawer.vue`。
|
- 轻中量表单优先复用 `src/components/custom/business-form-dialog.vue`;字段较多、需要保留列表上下文或承载重型控件时,再考虑 `src/components/custom/business-form-drawer.vue`。
|
||||||
- 表单分组优先复用 `src/components/custom/business-form-section.vue`。
|
- 表单分组优先复用 `src/components/custom/business-form-section.vue`。
|
||||||
- 现有公共壳组件已内置尺寸预设:`dialog` 的 `sm/md/lg` 对应 `520px/640px/720px`,`drawer` 的 `md/lg/xl` 对应 `480px/720px/960px`;优先使用预设值而不是页面内重复硬编码宽度。
|
- `dialog` 宽度优先按纯表单字段数分三档:`<= 6` 个字段用 `sm`,默认单列,目标宽度 `520px`;`7 ~ 14` 个字段用 `md`,默认双列,目标宽度 `720px`;`> 14` 个字段用 `lg`,仍以双列为主,目标宽度 `960px`。宽度只做响应式收缩,实际宽度不超过 `calc(100vw - 32px)`;不因为单个 `textarea` 自动升档,也不做列数响应式折叠。
|
||||||
- 常规 CRUD 表单优先使用 `label-position="top"`、`ElRow + ElCol` 双列布局、`gutter=16`;普通字段优先 `span=12`,长文本或重量级字段优先 `span=24`。
|
- 常规 CRUD 表单优先使用 `label-position="top"`、`ElRow + ElCol` 双列布局、`gutter=16`;普通字段优先 `span=12`,长文本或重量级字段优先 `span=24`。如果整体字段数 `<= 6`,默认按单列表单理解。
|
||||||
|
- 当纯表单 `dialog` 因字段数 `<= 6` 归入 `sm` 时,不能只改 `preset`;字段布局也要同步落到单列,常规 `ElCol` 应使用 `span=24`,除非该弹框已经被明确判定为复合内容特例。
|
||||||
|
- 左右分栏、表单 + 表格、表单 + 树、关系编辑器、时间线、大段说明区这类复合内容 `dialog`,不强行按字段数归类;可按内容复杂度单独评估使用 `md`、`lg` 或更宽值,但只有在无法合理归入“纯表单三档”时才允许特例。
|
||||||
|
- 禁止用页面级宽范围样式直接覆盖整页 `.business-form-dialog` 来统一放大弹框;如确实需要特殊宽度,只能精确作用于目标弹框,且不能误伤同页面其他 `dialog`。
|
||||||
- 底部按钮顺序固定为“取消 -> 确认”,并保持右对齐。
|
- 底部按钮顺序固定为“取消 -> 确认”,并保持右对齐。
|
||||||
- 单选组和开关类字段优先复用仓库既有样式钩子,例如 `business-form-radio-group`、`business-form-switch-field`。
|
- 单选组和开关类字段优先复用仓库既有样式钩子,例如 `business-form-radio-group`、`business-form-switch-field`。
|
||||||
|
- 权限控制按钮默认采用“无权限不渲染”口径,不要把纯权限不足的入口做成禁用态再展示给用户;只有业务状态暂时不可操作、但仍需让用户感知入口存在时,才允许保留禁用态。
|
||||||
|
|
||||||
## 接口、路由与权限约束
|
## 接口、路由与权限约束
|
||||||
|
|
||||||
@@ -167,6 +175,31 @@
|
|||||||
- 涉及路由、菜单、权限的改动时,同时检查 `build/plugins/router.ts`、`src/router/routes/*`、`src/store/modules/route/*` 和相关文档。
|
- 涉及路由、菜单、权限的改动时,同时检查 `build/plugins/router.ts`、`src/router/routes/*`、`src/store/modules/route/*` 和相关文档。
|
||||||
- 对于可再生的路由产物,优先修改源配置并执行 `pnpm gen-route`,不要把手工修补生成文件当成常规方案。
|
- 对于可再生的路由产物,优先修改源配置并执行 `pnpm gen-route`,不要把手工修补生成文件当成常规方案。
|
||||||
|
|
||||||
|
## 防重复提交(两层联防)
|
||||||
|
|
||||||
|
用户快速双击、键盘连按 Enter、`ElMessageBox.confirm` 的"确定"按钮内置无 loading 等场景,都可能让同一写操作发出多次。仓库采用两层防御,新增写操作功能时按顺序检查:
|
||||||
|
|
||||||
|
### 第一层:业务按钮的 loading 锁(视觉防御)
|
||||||
|
|
||||||
|
- 新增、编辑入口优先使用 `src/components/custom/business-form-dialog.vue` 或 `src/components/custom/business-form-drawer.vue`,它们在 `submit` 流程内 await 接口期间会自动将"确认"按钮置为 `loading` + `disabled`。
|
||||||
|
- 不要裸手写 `<ElButton @click="submit">` 直接调接口;若必须使用裸 `ElButton`,需要自行绑定 `:loading` 并在 await 接口期间锁住按钮。
|
||||||
|
- 删除二次确认使用 `ElMessageBox.confirm` 时,其内部"确定"按钮没有 loading 能力,必须依赖第二层兜底,不要尝试改造 confirm 的内部按钮。
|
||||||
|
|
||||||
|
### 第二层:请求层全局去重(逻辑兜底)
|
||||||
|
|
||||||
|
- 入口:`src/service/request/dedupe.ts` 提供 `withDedupe`,已在 `src/service/request/index.ts` 包住统一的 `request` 实例;`demoRequest` 未启用。
|
||||||
|
- 指纹:`method + 完整 URL + 排序后的 params + 稳定序列化的 body`;body 内对象按 key 排序,数组保序。
|
||||||
|
- 行为:写操作(`POST` / `PUT` / `DELETE` / `PATCH`)在第一次请求 pending 期内,若再次发起指纹相同的请求,自动复用第一次的 Promise,不发出第二次实际请求;调用方两次拿到完全相同的返回对象。
|
||||||
|
- 跳过条件(即不去重,按原逻辑发出):`GET` / `HEAD` / `OPTIONS`,请求体为 `FormData` 或 `Blob`(上传场景),调用方显式传 `{ dedupe: false }`。
|
||||||
|
- 业务调用方零感知:新增接口默认即享受兜底,不需要在 `src/service/api/*` 或页面层做任何改动。
|
||||||
|
- 极少数业务确实允许短时间内并发提交完全相同的写请求时,在调用处显式传 `request({ ..., dedupe: false })` 单接口关闭。
|
||||||
|
- 兜底超时 30 秒:极端情况下若某次 Promise 未 settle,pending 条目过期后下一次相同请求视为新请求,避免内存泄漏。
|
||||||
|
|
||||||
|
### 设计责任划分
|
||||||
|
|
||||||
|
- 视觉层负责"按下立刻锁住按钮"的用户感知;逻辑层负责"即使锁失败也只发一次"的实际接口保护。
|
||||||
|
- 不要因为有第二层兜底就省略第一层 loading 锁:用户没有视觉反馈会再次点击;也不要试图在业务页面再造一套请求去重逻辑。
|
||||||
|
|
||||||
## 运行时字典使用口径
|
## 运行时字典使用口径
|
||||||
|
|
||||||
- 运行时字典统一由 `src/store/modules/dict/index.ts` 管理,登录后通过 `/system/dict-data/frontend-cache` 初始化;不要在业务页面重复直调字典接口。
|
- 运行时字典统一由 `src/store/modules/dict/index.ts` 管理,登录后通过 `/system/dict-data/frontend-cache` 初始化;不要在业务页面重复直调字典接口。
|
||||||
@@ -201,6 +234,14 @@ const directionLabels = getLabels(row.directionCodes, { separator: ',' });
|
|||||||
- 当前系统已有页面或接口已经稳定使用某个字典,例如用户所属公司 `company -> system_user_company`。
|
- 当前系统已有页面或接口已经稳定使用某个字典,例如用户所属公司 `company -> system_user_company`。
|
||||||
- 如果以上两种都没有,就先让后端或业务明确 `dictType`,不要前端自己命名。
|
- 如果以上两种都没有,就先让后端或业务明确 `dictType`,不要前端自己命名。
|
||||||
|
|
||||||
|
## 业务对象状态颜色集中口径
|
||||||
|
|
||||||
|
- 各业务域(产品、项目、需求、任务、执行、工单等)的 `statusCode -> ElTag type` 集中维护在 `src/constants/status-tag.ts`,不要在各业务页面或模块内散落硬编码同一份映射。
|
||||||
|
- 通用入口是 `getStatusTagType(domain, statusCode)`,未匹配的 `statusCode` 默认回退到 `'info'`。
|
||||||
|
- 业务模块按域写薄包装暴露给页面调用,例如 `getExecutionStatusTagType(code)` 内部调用 `getStatusTagType('projectExecution', code)`,避免页面直接耦合到 domain 字符串。
|
||||||
|
- 新增对象域时同步两处:`StatusDomain` 增加枚举值;`statusTagTypeRegistry` 添加对应 `statusCode -> StatusTagType` 映射。
|
||||||
|
- 后端契约:未来若状态字典开始返回颜色字段,调用方应优先使用后端值,缺失时再回退到 `getStatusTagType` 的前端兜底映射,不要直接绕开集中配置另写一份。
|
||||||
|
|
||||||
## 页面资源与菜单目录约束
|
## 页面资源与菜单目录约束
|
||||||
|
|
||||||
- 页面组件键、页面资源、菜单目录是三层不同概念,不要把它们当成同一个值。
|
- 页面组件键、页面资源、菜单目录是三层不同概念,不要把它们当成同一个值。
|
||||||
@@ -221,6 +262,7 @@ const directionLabels = getLabels(row.directionCodes, { separator: ',' });
|
|||||||
- 如果后端当前接口暂时还返回数值型 ID,前端也必须尽可能在 `typings`、API 适配层或进入业务层前转成 `string`,不要把 ID 按 `number` 扩散到页面、store 和组件里。
|
- 如果后端当前接口暂时还返回数值型 ID,前端也必须尽可能在 `typings`、API 适配层或进入业务层前转成 `string`,不要把 ID 按 `number` 扩散到页面、store 和组件里。
|
||||||
- 但要注意:如果后端把超出 JS 安全整数范围的 Long 直接作为 JSON 数字返回,前端在业务层再 `String(number)` 只能得到“已经丢精度后的错误字符串”。这种情况必须明确记为接口契约风险,不要误判为“前端已安全处理”。
|
- 但要注意:如果后端把超出 JS 安全整数范围的 Long 直接作为 JSON 数字返回,前端在业务层再 `String(number)` 只能得到“已经丢精度后的错误字符串”。这种情况必须明确记为接口契约风险,不要误判为“前端已安全处理”。
|
||||||
- 因此,新增或改造接口时,最稳妥的契约仍然是:后端长整型 ID 直接按字符串返回;前端全链路按字符串接收和传递。若后端暂未改,前端侧也不得新增 `number` 口径 ID 用法。
|
- 因此,新增或改造接口时,最稳妥的契约仍然是:后端长整型 ID 直接按字符串返回;前端全链路按字符串接收和传递。若后端暂未改,前端侧也不得新增 `number` 口径 ID 用法。
|
||||||
|
- API 适配层兜底(实操约束):所有从后端接收的数值型 ID 字段(不论后端实际返回 `string`、`number` 或两者混合),都必须在 `src/service/api/*` 的 normalize 或 map 函数中显式调用 `String(rawId)` 归一一次;前端业务层(`views`、`store`、组件、`Map` 键、路由参数)只接收 `string` 形态,永远不需要自己 `String()`。这条与后端是否做了 Long → String 全局序列化无关——后端做了是双保险,没做且字段取值始终在 JS 安全整数内(例如 `infra_file_config.id` 永远是两位数)也是合理选择,前端 normalize 已经把口径收死,业务层无感。但这条不开按字段取值范围豁免的口子:前端 normalize 是无差别的,任何 ID 都要 `String()`,不要按某个字段当前取值大小决定要不要走 normalize,避免后续逐步污染仓库的 ID 纪律。
|
||||||
- 对仓库中的历史代码,原则是“不再新增 number 口径 ID,当前任务触达相关链路时优先顺手矫正”;不要继续复制历史写法。
|
- 对仓库中的历史代码,原则是“不再新增 number 口径 ID,当前任务触达相关链路时优先顺手矫正”;不要继续复制历史写法。
|
||||||
- 遵循仓库现有的 Vue SFC 风格:`script setup`、类型化 store、职责单一的小型 composable/helper。
|
- 遵循仓库现有的 Vue SFC 风格:`script setup`、类型化 store、职责单一的小型 composable/helper。
|
||||||
- 修改界面时优先延续 `src/layouts` 和 `src/theme` 中已有的 UI 模式,不要平行引入另一套设计体系。
|
- 修改界面时优先延续 `src/layouts` 和 `src/theme` 中已有的 UI 模式,不要平行引入另一套设计体系。
|
||||||
|
|||||||
431
CLAUDE.md
Normal file
431
CLAUDE.md
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
本文件是我(Claude)在 `cn-rdms-web` 项目中的个人工作笔记,沉淀团队既有规范(来源:`AGENTS.md`)与协作惯例。每次进入仓库前先读这一份,避免重复踩坑。
|
||||||
|
|
||||||
|
> 本文件仅本地保留,已加入 `.gitignore`,请勿提交。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 行为基线(最重要,先记住)
|
||||||
|
|
||||||
|
- **描述现状以代码、配置、文档可直接验证的事实为准**;不引入历史实现/过渡方案/猜测。
|
||||||
|
- **默认精简回答**:先给结论 → 改动点 → 验证方式 → 必要风险。**除非用户主动要求详细,否则不要展开**——不复述清单、不列每条改动的小理由、不堆"汇总"段。用户只让分析就停在分析层,不主动跳到实现。
|
||||||
|
- **分析/解释类回答不要堆代码层面描述**:默认用业务/逻辑语言说清楚结构、差异与结论;不要大段贴源码、不要罗列 `file:line`、不要把"实现细节"当解释。只有用户明确要求看代码、或非贴不可的关键佐证(如某行就是争议焦点),才贴最少代码片段。
|
||||||
|
- **进入实施阶段前,先说目标、涉及模块、预计改动点、验证方式**。
|
||||||
|
- **最小改动原则**:只改当前任务必需的范围,不顺手重构无关代码。
|
||||||
|
- **不主动执行 git 操作**(status/diff/add/commit/restore/reset/checkout 全部不主动跑),除非用户明确要求。识别用户改动优先用 Read 直接看文件。
|
||||||
|
- 工作树脏的时候,**不要回退与当前任务无关的变更**。
|
||||||
|
- 静态校验默认只跑 `pnpm typecheck`;UI/交互/样式类任务**默认不补也不跑前端测试**,除非用户明确要求。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目骨架(认知地图)
|
||||||
|
|
||||||
|
| 维度 | 现状 |
|
||||||
|
|---|---|
|
||||||
|
| 应用 | RDMS 系统的 Vue 3 后台前端 |
|
||||||
|
| 包管理 | `pnpm`(>=8.7.0),Node `>=20.19.0` |
|
||||||
|
| 工具链 | Vite 7、TypeScript、Pinia、Element Plus、UnoCSS |
|
||||||
|
| 工作区 | `packages/*`,通过 `@sa/*` 引用 |
|
||||||
|
| 别名 | `@` → `src`;`~` → 仓库根 |
|
||||||
|
| 端口 | dev 9527 / preview 9725 |
|
||||||
|
| 环境文件 | `.env`、`.env.dev`、`.env.prod` |
|
||||||
|
|
||||||
|
**已经形成闭环的五条主线,后续改动顺着做,不平行起新的:**
|
||||||
|
|
||||||
|
1. **路由来源统一**:页面文件 + 自定义路由 → `elegant-router` 生成 → `build/plugins/router.ts` 集中补 `meta`。
|
||||||
|
2. **权限入口统一**:常量路由 / 权限路由分流;`route store` 负责初始化、菜单生成、缓存路由、面包屑。
|
||||||
|
3. **请求入口统一**:所有业务请求走 `src/service/request/index.ts`。
|
||||||
|
4. **页面套路统一**:列表页 = 搜索区 + 表格区 + 操作弹层/抽屉 + `modules/*` 子组件。
|
||||||
|
5. **衍生资产统一**:页面资源白名单从路由结构生成,不手工维护第二份。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 关键目录速查
|
||||||
|
|
||||||
|
| 路径 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| `src/views` | 业务页面(编排层薄) |
|
||||||
|
| `src/components` | 共享组件 |
|
||||||
|
| `src/layouts` | 应用壳、头部、侧栏、菜单、标签页、主题抽屉 |
|
||||||
|
| `src/store/modules` | Pinia 模块:app / auth / route / tab / theme / dict |
|
||||||
|
| `src/service/api` | 接口封装、参数归一化、查询字符串拼装、返回类型对齐 |
|
||||||
|
| `src/service/request` | 统一请求实例、鉴权、加密、错误处理、token 刷新 |
|
||||||
|
| `src/router/routes` | 自定义路由 |
|
||||||
|
| `src/router/elegant` | **生成产物,不要手改** |
|
||||||
|
| `src/theme/settings.ts` | 默认主题与布局设置 |
|
||||||
|
| `build/plugins/router.ts` | elegant-router 配置 + 路由 meta 生成 |
|
||||||
|
| `src/hooks/common/table.ts` | 列表页表格 hook 主入口 |
|
||||||
|
| `src/hooks/common/form.ts` | 表单校验与表单实例 hook |
|
||||||
|
| `src/constants/status-tag.ts` | 业务对象状态颜色(ElTag type)集中配置 |
|
||||||
|
| `src/styles/scss/element-plus.scss` | 表格/弹层/按钮/表单 密度与公共壳样式 |
|
||||||
|
| `packages/*` | 项目内本地共享库 |
|
||||||
|
| `docs/` | 架构/权限/页面规范文档,做相关改动前先查 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 生成文件(不要手改)
|
||||||
|
|
||||||
|
- `src/router/elegant/imports.ts`
|
||||||
|
- `src/router/elegant/routes.ts`
|
||||||
|
- `src/router/elegant/transform.ts`
|
||||||
|
- `src/typings/elegant-router.d.ts`
|
||||||
|
- `src/typings/components.d.ts`
|
||||||
|
- `docs/frontend-page-resource-manifest.json`
|
||||||
|
|
||||||
|
**再生命令:**
|
||||||
|
- 路由产物过期 → `pnpm gen-route`
|
||||||
|
- 页面资源清单需同步 → `pnpm gen:page-resource-manifest`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 路由与导航
|
||||||
|
|
||||||
|
- 新增业务页:通过页面文件 + `build/plugins/router.ts` 补齐,**不要在多个位置重复注册**。
|
||||||
|
- `meta.icon` = Iconify 图标;`meta.localIcon` = 本地 SVG。**不要混用字段语义。**
|
||||||
|
- `meta` 中心落点是 `build/plugins/router.ts`,新页的 `icon`/`order`/`roles`/`keepAlive` 在那里集中维护。
|
||||||
|
- `meta.constant = true` → 常量路由;其他默认权限路由。常量路由维护入口是 `build/plugins/router.ts` 和 `src/router/routes/custom-routes.ts`。
|
||||||
|
- `i18nKey` 是兼容字段,不是新页必须补齐项。
|
||||||
|
|
||||||
|
### 4.1 对象上下文业务域(重要陷阱)
|
||||||
|
|
||||||
|
- `product`、`project` 这类业务域,**入口页是设计如此**:先进业务域入口页 → 再选对象建上下文。**不要把"入口页是可点击菜单"误判成 bug。**
|
||||||
|
- 入口页(如 `product_list -> /product/list -> view.product_list`)可作为左侧一级菜单实际命中页。这 ≠ 已进入对象上下文态。
|
||||||
|
- **遇到"点入口页后布局壳消失、只剩内容页"**:先查是否动态权限路由模式 + 后端 `get-user-routes` 是否缺业务域根路由。**不要直接把入口菜单从"菜单"改成"目录"**。
|
||||||
|
- 在 `VITE_AUTH_ROUTE_MODE=dynamic` 下,若后端只返回叶子页(如缺 `product -> layout.base`,只返 `product_list`),前端必须在动态路由归一化阶段**补回本地业务域骨架**,不能让入口裸挂为顶层 `view.*`。
|
||||||
|
- 对象上下文稳定来源仍是本地路由骨架;动态路由兼容只能"补骨架 + 对齐入口",不能反推。
|
||||||
|
- 新增业务域时同步检查:本地静态骨架、`src/constants/object-context.ts` 中的 `domainKey/entryRouteKey/entryRoutePath/fallbackDefaultRouteKey`、动态路由归一化、对象上下文 store、头部菜单切换。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 分层职责
|
||||||
|
|
||||||
|
| 层 | 该做 | 不该做 |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/views` | 编排状态、表单行为、组合 store/service | 散落 URL 拼接、token 注入、错误提示、权限路由推导 |
|
||||||
|
| `src/components` | 可复用 UI / 局部业务部件 | 长期堆只服务单页面的复杂流程 |
|
||||||
|
| `src/service/api` | 接口封装、参数归一化、查询拼装、类型对齐 | 在 views/store/components 重复手写接口地址和序列化 |
|
||||||
|
| `src/service/request` | 统一鉴权/加密/成功码/token 刷新/错误处理 | 平行引入新的 axios/fetch 链绕开封装 |
|
||||||
|
| `src/store/modules` | 跨页面共享状态 | 把临时局部状态堆进全局 store |
|
||||||
|
| `src/router` & `build/plugins/router.ts` | 路由/菜单/权限标识/首页/路由 meta | 在页面里临时写条件分支替代正式配置 |
|
||||||
|
| `src/layouts` & `src/theme` | 全局布局壳与主题 | 在业务页面复制平行布局/主题状态 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 业务页面开发风格
|
||||||
|
|
||||||
|
- **页面组件保持"编排层薄"**:页面文件主管搜索参数、表格 hook、列定义、弹层开关、接口编排。
|
||||||
|
- 列表页拆同目录 `modules/*`:搜索组件、操作弹层、详情抽屉、资源面板等。
|
||||||
|
- **参考实现**:系统管理下 `user`/`role`/`menu`/`dict`。
|
||||||
|
- 列表 hook 优先复用:`src/hooks/common/table.ts` 的 `useUIPaginatedTable`、`useTableOperate`、`defaultTransform`。
|
||||||
|
- 表单 hook 优先复用:`src/hooks/common/form.ts` 的 `useForm`、`useFormRules`。
|
||||||
|
- **业务口径是"内网中文优先"**:新页不必强行国际化;但已有大量 `$t(...)` 的页面继续开发时,保持局部一致,不要中文/i18n 混用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 表格、搜索区、操作列
|
||||||
|
|
||||||
|
### 7.1 搜索区(强约束)
|
||||||
|
|
||||||
|
- **必须用** `src/components/custom/table-search-fields.vue` 的 `fields` 声明式配置,不得手写 `ElRow/ElCol/ElFormItem` 骨架。
|
||||||
|
- 仅当字段存在复杂联动、自定义插槽或 `TableSearchFields` 明确无法承载时,才退回 `src/components/custom/table-search-panel.vue`,并在实施说明中写明原因。
|
||||||
|
- **搜索区按钮组固定在第一行最后一格**;存在折叠时按钮顺序固定为 **展开/收起 → 重置 → 查询**。**不允许**因查询条件不足、展开收起或响应式样式把按钮提前或挤到下一行。
|
||||||
|
- `columns` 表示首行总格数,**最后 1 格永远留给按钮**;字段不足 `columns - 1` 由组件补空占位;超过则进入展开区。
|
||||||
|
- 4 个查询条件的场景必须 `:columns="4"`(3 条件 + 按钮)。
|
||||||
|
- 搜索模块只接 `model` 和必要选项,只发 `reset`/`search`,**不直接承载列表请求**。
|
||||||
|
- 详细规范见 `docs/table-search-fields-usage.md`。
|
||||||
|
|
||||||
|
### 7.2 表格
|
||||||
|
|
||||||
|
- 操作列优先复用 `src/components/custom/business-table-action-cell.tsx`。
|
||||||
|
- 操作数 ≤ 2:直出;操作数 > 2:**1 个直出主按钮 + 1 个更多按钮**。
|
||||||
|
- `ElCard` 承载 `ElTable height="100%"` 时,`body-class` 优先用公共类 **`business-table-card-body`**(由 `src/styles/scss/element-plus.scss` 维护)。**不要为每页新建 `xxx-table-card-body` 私有样式**。历史私有类不强制专项回改,触达再收敛。
|
||||||
|
- 表格/按钮/弹层/表单的尺寸与间距标准走 `element-plus.scss` 和公共组件,**不要在业务页散落写局部尺寸作为事实标准**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 表单与弹层(强约束)
|
||||||
|
|
||||||
|
### 8.1 组件选择
|
||||||
|
|
||||||
|
- 标准组合:`ElDialog / ElDrawer / ElForm / ElScrollbar / #footer`。
|
||||||
|
- 轻中量表单:`src/components/custom/business-form-dialog.vue`。
|
||||||
|
- 字段较多 / 需保留列表上下文 / 重型控件:`src/components/custom/business-form-drawer.vue`。
|
||||||
|
- 表单分组:`src/components/custom/business-form-section.vue`。
|
||||||
|
|
||||||
|
### 8.2 Dialog 宽度三档(按纯表单字段数)
|
||||||
|
|
||||||
|
| 字段数 | preset | 默认列数 | 目标宽度 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ≤ 6 | `sm` | 单列 | 520px |
|
||||||
|
| 7 ~ 14 | `md` | 双列 | 720px |
|
||||||
|
| > 14 | `lg` | 双列为主 | 960px |
|
||||||
|
|
||||||
|
- 实际宽度上限:`calc(100vw - 32px)`。
|
||||||
|
- **不因为单个 textarea 自动升档**,不做列数响应式折叠。
|
||||||
|
- 归到 `sm` 时不能只改 preset,**字段布局也要落到单列**:常规 `ElCol` 用 `span=24`,除非已判定为复合内容特例。
|
||||||
|
|
||||||
|
### 8.3 复合内容特例
|
||||||
|
|
||||||
|
左右分栏 / 表单+表格 / 表单+树 / 关系编辑器 / 时间线 / 大段说明区 → 不强按字段数归类,按内容复杂度评估 `md`/`lg` 或更宽。**只有无法合理归入"纯表单三档"时才允许特例。**
|
||||||
|
|
||||||
|
### 8.4 表单布局
|
||||||
|
|
||||||
|
- 常规 CRUD:`label-position="top"` + `ElRow + ElCol` 双列 + `gutter=16`。
|
||||||
|
- 普通字段 `span=12`;长文本/重量级字段 `span=24`。
|
||||||
|
- 字段 ≤ 6 默认按单列理解。
|
||||||
|
|
||||||
|
### 8.5 其他
|
||||||
|
|
||||||
|
- **禁止**用页面级宽范围样式覆盖整页 `.business-form-dialog` 来统一放大;如需特殊宽度,必须精确作用于目标弹框,不误伤同页其他 dialog。
|
||||||
|
- 底部按钮固定 **取消 → 确认**,右对齐。
|
||||||
|
- 单选组/开关字段优先复用既有钩子:`business-form-radio-group`、`business-form-switch-field`。
|
||||||
|
- **权限按钮默认"无权限不渲染"**;只有业务状态暂时不可操作但仍需让用户感知入口存在时,才允许保留禁用态。
|
||||||
|
|
||||||
|
### 8.6 全局反馈(Toast / Message)
|
||||||
|
|
||||||
|
- **全局反馈通道只有一个**:`window.$message`(`src/components/common/app-provider.vue` 注入的 `ElMessage`),全仓 30+ 处都用它。**不要平行引入 `ElNotification` / 自定义 toast**;要求"全局风格切换"则单独立项,不要在小改动里悄悄启动。
|
||||||
|
- **type 语义**(4 种 type → 3 类视觉语义):
|
||||||
|
- `error` → 错误(红):操作失败、明确异常
|
||||||
|
- `warning` → 告警(橙):用户即将出错、风险确认
|
||||||
|
- `success` → 通知-成功(绿):操作成功
|
||||||
|
- `info` → 通知-信息(蓝):信息告知、默认兜底说明
|
||||||
|
- **type 选错就丑**:`warning` 是"出错警告",不要拿来表达普通信息(用 `info`);`info` 是"信息告知",不要拿来报错(用 `error`)。
|
||||||
|
- **"先做 A 再做 B" 的引导性提示**:用 `ElFormItem :error="msg"` 红字内联(跟校验同款),**不要用 toast**——toast 适合事后反馈、不阻断流程,对引导性提示体验差。
|
||||||
|
- **全局视觉**(实色背景 + 白字 + 阴影 + `$radius` 圆角)由 `src/styles/scss/element-plus.scss` 末尾的 `.el-message` 块统一维护,**业务页面禁止覆盖** `.el-message-*` 样式。要调颜色就改 `element-plus.scss`,不要在业务页 scoped 散落。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
window.$message?.success('保存成功');
|
||||||
|
window.$message?.error('保存失败:xxx');
|
||||||
|
window.$message?.warning('当前修改未保存,确认离开?');
|
||||||
|
window.$message?.info('未选择计划开始日期,已按今日为基准计算');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 接口、路由、权限
|
||||||
|
|
||||||
|
- 默认走 `src/service/request/index.ts`,不另造鉴权/加密/错误处理/token 刷新。
|
||||||
|
- 接口前缀、服务常量优先复用 `src/constants/service.ts`。
|
||||||
|
- 后端契约变化时同步检查 `src/service/api/*`、`src/typings/api/*`、相关页面、说明文档。
|
||||||
|
- 路由/菜单/权限改动时同步检查 `build/plugins/router.ts`、`src/router/routes/*`、`src/store/modules/route/*`、相关文档。
|
||||||
|
- 路由产物过期:改源配置 + `pnpm gen-route`,**不要把手工修补生成文件当常规方案**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 运行时字典
|
||||||
|
|
||||||
|
- 由 `src/store/modules/dict/index.ts` 管理,登录后通过 `/system/dict-data/frontend-cache` 初始化。**不要在页面重复直调字典接口。**
|
||||||
|
- 字典编码常量收敛在 `src/constants/dict.ts`。**不要散落硬编码 `dictType`。**
|
||||||
|
- **不要猜字典编码**:先从后端接口文档/字段契约/系统字典管理页确认真实 `dictType`,再写入常量。
|
||||||
|
- 常量加中文注释:对应业务字段 + 编码确认来源。
|
||||||
|
- 后端编码带历史命名痕迹(如 `rdms_product_direction`)时,前端常量名按真实业务语义命名,**不扩散历史误导**。
|
||||||
|
|
||||||
|
### 字典使用方式
|
||||||
|
|
||||||
|
| 场景 | 组件/Hook |
|
||||||
|
|---|---|
|
||||||
|
| 表单下拉 | `src/components/custom/dict-select.vue` |
|
||||||
|
| 普通文案回显 | `src/components/custom/dict-text.vue` |
|
||||||
|
| 标签态回显 | `src/components/custom/dict-tag.vue`(标签颜色业务页自决) |
|
||||||
|
| script setup / TSX 列格式化 / 复杂判断 | `src/hooks/business/dict.ts` 的 `useDict(dictCode)` |
|
||||||
|
|
||||||
|
`useDict` 常用能力:`dictOptions`、`getItem`、`getLabel`、`getLabels`、`hasValue`。
|
||||||
|
|
||||||
|
`DictSelect` 默认只展示启用项;需包含禁用项显式 `:only-enabled="false"`。
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<DictSelect v-model="form.directionCode" :dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE" />
|
||||||
|
<DictText :dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE" :value="row.directionCode" />
|
||||||
|
<DictTag :dict-code="SYSTEM_USER_COMPANY_DICT_CODE" :value="row.companyCode" type="info" />
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { getLabel, getLabels } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||||
|
const directionLabel = getLabel(row.directionCode);
|
||||||
|
const directionLabels = getLabels(row.directionCodes, { separator: ',' });
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 页面资源 & 菜单目录(三层不同概念)
|
||||||
|
|
||||||
|
- `component` = 渲染哪个页面组件
|
||||||
|
- 菜单目录 = 挂在哪个业务目录、最终 URL
|
||||||
|
- 页面资源 = 白名单中选择并回填组件信息
|
||||||
|
|
||||||
|
**不要混淆**:组件键 `view.system_dict` ≠ 必须挂 `/system/dict`,同一个组件允许在新业务目录下复用。
|
||||||
|
|
||||||
|
页面资源白名单的标准路径是**参考路径,不应反向覆盖菜单树已确定的最终 URL**。
|
||||||
|
|
||||||
|
菜单编辑器/页面资源选择逻辑改动时,保证"组件可解析、资源合法、最终 URL 由菜单树决定",**不要强绑标准路径与父目录前缀**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. ID 类型铁律(强约束,必须严格执行)
|
||||||
|
|
||||||
|
> 后端主键 ID / 用户 ID / 对象 ID / 雪花 ID / Long ID **一律按 `string` 接收和传递**。
|
||||||
|
|
||||||
|
**原因**:JS `number` 无法稳定承载 Long 精度;序列化精度丢失;`number/string` 键不一致 → 回显/筛选/映射/路由参数/对象上下文异常。
|
||||||
|
|
||||||
|
### 落实范围(全部)
|
||||||
|
`typings`、API 返回类型、表单 model、组件 props/emits、`ElSelect` 的 value、路由参数、查询参数、`Map` 键、筛选条件、store 状态 → **全部 `string` / `string[]`**。
|
||||||
|
|
||||||
|
### 禁止写法
|
||||||
|
- ❌ `Number(id)` / `+id` / `parseInt(id)` / `parseFloat(id)` / `Math.floor(id)`
|
||||||
|
- ❌ 任何"为了比较/传参/回填/提交而把 ID 转 number"
|
||||||
|
|
||||||
|
### 比较与映射
|
||||||
|
- ✅ `id === targetId`
|
||||||
|
- ✅ `Map<string, T>` / `Set<string>`
|
||||||
|
- ❌ 不混用 `number/string` 双口径
|
||||||
|
|
||||||
|
### 后端契约风险(关键)
|
||||||
|
- 后端暂返数值型 ID 时,**前端在 `typings` / API 适配层 / 进业务层前转 `string`**,不要按 `number` 扩散。
|
||||||
|
- **但如果后端把超 JS 安全整数的 Long 直接作为 JSON 数字返回,前端再 `String(number)` 只能得到"已经丢精度后的错误字符串"**。这种情况必须明确记为接口契约风险,不能误判为"已安全处理"。
|
||||||
|
- 最稳妥契约:**后端 Long ID 直接按字符串返回**;前端全链路按字符串。后端未改,前端也不得新增 `number` 口径 ID。
|
||||||
|
|
||||||
|
### API 适配层兜底(操作约束)
|
||||||
|
- 所有从后端接收的数值型 ID 字段,**必须**在 `src/service/api/*` 的 normalize/map 函数里显式 `String(rawId)` 一次——**不管后端返回 string、number、还是混合**。
|
||||||
|
- 业务层(views / store / 组件 / `Map` key / 路由参数)**只接收 string**,从不需要自己 `String()`。
|
||||||
|
- 与"后端是否已经全局 Long → String"**无关**:
|
||||||
|
- 后端做了 → 双保险
|
||||||
|
- 后端没做但取值在 JS 安全整数内 → 单层防御也对(实际值不丢精度)
|
||||||
|
- 后端没做且取值超安全整数 → 不安全,必须推后端改
|
||||||
|
- **不开"按取值范围豁免"的口子**:哪怕后端说"这个字段永远是两位数"(如 `infra_file_config.id`),前端照样 `String()`。否则后续会冒出"projectStatus 是 Long 但只有 0-99,也可以保留 number"等连锁例外,铁律字面被掏空。
|
||||||
|
|
||||||
|
### 历史代码原则
|
||||||
|
不再新增 `number` 口径;当前任务触达相关链路时**顺手矫正**;不要继续复制历史写法。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 代码约定
|
||||||
|
|
||||||
|
- 优先用别名导入(`@/...`、`~/...`),避免长相对路径。
|
||||||
|
- 与 TypeScript 严格模式兼容。
|
||||||
|
- 沿用 Vue SFC 风格:`script setup`、类型化 store、职责单一的小型 composable/helper。
|
||||||
|
- UI 沿用 `src/layouts` 和 `src/theme` 现有模式,不平行引入新设计体系。
|
||||||
|
- **注释克制**:只在代码本身不直观时补必要中文说明;不删原有有效注释;不写没信息量的注释。
|
||||||
|
- 中文内容用 UTF-8,自检显示;**不要用改成英文规避编码问题**。
|
||||||
|
- Node ESM 脚本:避免 `__filename`/`__dirname` 这类下划线悬挂命名。
|
||||||
|
- 批量异步并发优先 `Promise.all(...)`,不在循环里默认 `await`。
|
||||||
|
- 手写 `new Promise(...)` 用 block 写法,不要写成隐式返回的单表达式箭头函数。
|
||||||
|
- 函数若同时承担"判断 + 转换 + 组装 + 递归",拆 helper。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 校验
|
||||||
|
|
||||||
|
### 14.1 校验口径
|
||||||
|
|
||||||
|
| 任务类型 | 默认校验 |
|
||||||
|
|---|---|
|
||||||
|
| 前端页面/交互/样式 | `pnpm typecheck`,不主动跑测试 |
|
||||||
|
| 需更严格静态检查 | 加 `pnpm lint` |
|
||||||
|
| 涉及路由 | 加 `pnpm gen-route` |
|
||||||
|
| 影响页面资源清单/菜单资源选择/页面白名单 | 加 `pnpm gen:page-resource-manifest` |
|
||||||
|
|
||||||
|
### 14.2 静态校验自查清单
|
||||||
|
- 调用链是否闭环?改动是否在正确分层?
|
||||||
|
- 路由/菜单/权限标识/主题状态/资源注册 是否前后一致?
|
||||||
|
- 改动范围是否控制在最小集合?
|
||||||
|
- 文档/类型/接口封装/生成产物 是否需要同步更新?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 提交规范
|
||||||
|
|
||||||
|
- **`pre-commit` 执行 `pnpm typecheck && pnpm lint && git diff --exit-code`**:能跑 ≠ 能提交。
|
||||||
|
- `pnpm lint` 会跑 `eslint . --fix`:提交失败后检查是否有被自动修复但未重新暂存的文件。
|
||||||
|
- 推荐提交方式:`pnpm commit:zh`(交互选 type/scope/description)。
|
||||||
|
- 手动提交:`git commit -m "type(scope): 描述"`,参考 `docs/前端提交规范与示例.md`。
|
||||||
|
- `commit-msg` 钩子校验 Conventional Commits。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 协作记忆(与本仓库用户共事)
|
||||||
|
|
||||||
|
- 用户语言:**中文**(始终用中文回复)。
|
||||||
|
- **不主动跑 git 命令**(用户已强调)。
|
||||||
|
- 默认精简、结论先行。
|
||||||
|
- 工作树脏时不要回退无关变更。
|
||||||
|
- 改架构/权限/页面规范前先翻 `docs/`,避免与现有约定冲突。
|
||||||
|
- 改布局/主题时同时检查 `src/layouts/*` 与 `src/store/modules/theme/*`。
|
||||||
|
- 改路由/菜单时同时检查 `build/plugins/router.ts` 与 `src/router/routes/*`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 常用命令速查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm typecheck # 最小静态校验
|
||||||
|
pnpm lint # eslint . --fix
|
||||||
|
pnpm gen-route # 重新生成路由产物
|
||||||
|
pnpm gen:page-resource-manifest # 同步页面资源清单
|
||||||
|
pnpm commit:zh # 交互式提交(推荐)
|
||||||
|
pnpm dev # dev server (9527)
|
||||||
|
pnpm preview # preview server (9725)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. 业务对象状态颜色
|
||||||
|
|
||||||
|
- 集中文件:`src/constants/status-tag.ts`
|
||||||
|
- 各业务域 `statusCode → ElTag type` 在此统一维护,**不要在各页面散落硬编码**。
|
||||||
|
- 已支持域:`projectExecution`、`projectTask`;预留:`project`、`product`、`requirement`、`workOrder`。
|
||||||
|
- helper:`getStatusTagType(domain, statusCode)`,未匹配回退 `'info'`。
|
||||||
|
- 业务模块写薄包装,例如 `getExecutionStatusTagType(code) = getStatusTagType('projectExecution', code)`。
|
||||||
|
- 新增对象域:在 `StatusDomain` 加枚举 + `statusTagTypeRegistry` 加对应 map;调用方写一个 wrapper 即可。
|
||||||
|
- 后端契约:未来若状态字典返颜色字段,调用方优先取后端值,缺失时回退 helper(前端兜底)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. 防重复提交(两层联防,强约束)
|
||||||
|
|
||||||
|
> 用户双击、键盘连按 Enter、`ElMessageBox.confirm` 的"确定"按钮无内置 loading 都会让同一写操作发多次。两层防御缺一不可。
|
||||||
|
|
||||||
|
### 两层各自的职责
|
||||||
|
|
||||||
|
| 层 | 谁负责 | 行为 |
|
||||||
|
|---|---|---|
|
||||||
|
| 视觉层 | `business-form-dialog.vue` / `business-form-drawer.vue` | submit 触发后立即把"确认"按钮置 loading + disabled,挡住二次点击 |
|
||||||
|
| 逻辑层(兜底) | `src/service/request/dedupe.ts`(已通过 `withDedupe` 包住 `request` 实例) | 写操作 pending 期内复用同一 Promise,不真正发出第二次请求 |
|
||||||
|
|
||||||
|
### 业务侧关注点
|
||||||
|
|
||||||
|
- **不要裸手写** `<ElButton @click="submit">` 调接口;用 `business-form-dialog` / `business-form-drawer` 包;非要用裸 `ElButton` 时**必须**自行绑 `:loading` 并在 await 期间锁住。
|
||||||
|
- **`ElMessageBox.confirm` 的"确定"按钮没 loading 能力**——不要尝试改它,靠第二层兜底就够。
|
||||||
|
- **新接口默认享受去重**,调用方零改动;不要在 `src/service/api/*` 或页面层再造一套去重。
|
||||||
|
|
||||||
|
### 去重生效边界
|
||||||
|
|
||||||
|
- 自动去重:`POST` / `PUT` / `DELETE` / `PATCH`。
|
||||||
|
- 不去重:`GET` / `HEAD` / `OPTIONS`(避免误伤分页 / 多 widget 并发查询);请求体为 `FormData` / `Blob`(上传场景)。
|
||||||
|
- 单接口逃生口:`request({ ..., dedupe: false })`——极少用,仅当业务真允许短时间内连发完全相同的写请求。
|
||||||
|
- 兜底超时 30s:保险丝,防止 Promise 永不 settle 时内存泄漏。
|
||||||
|
|
||||||
|
### 指纹算法
|
||||||
|
|
||||||
|
`method 大写 | URL + 排序后的 params 序列化 | 稳定序列化的 body`。body 内对象按 key 排序、数组保序——保证调用顺序不同但参数等价的两次请求拿到同一指纹。
|
||||||
|
|
||||||
|
### 何时回到本节查
|
||||||
|
|
||||||
|
- 新建写操作页面 → 视觉层用对组件、不裸 `ElButton` 调接口
|
||||||
|
- 新建写接口 → 不用管,默认兜底;只有明确"允许短时间连发"才传 `dedupe: false`
|
||||||
|
- 新建上传 / 下载流程 → FormData / Blob 天然不在去重范围,按原方式写即可
|
||||||
|
- 用户报"双击双发"复现不出来 → 检查目标按钮是否走了 `business-form-dialog`;若用的是 `ElMessageBox.confirm`,靠第二层就该挡住
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. 我生成文档的输出格式(强约束)
|
||||||
|
|
||||||
|
- **superpowers 工作流(`docs/superpowers/plans/`、`docs/superpowers/specs/`)下输出的文档继续用 `.md`**——工作流以 markdown 为前提。
|
||||||
|
- **其他**我生成的文档(设计方案、复盘、规约、技术经验沉淀等)**默认用 `.html`**,沿用 `docs/debt/` 现有 HTML 文档(参考 `token-刷新机制对齐分析.html`、`技术负债台账.html`)的样式骨架:
|
||||||
|
- 单文件、内联 CSS
|
||||||
|
- `max-width: 980px` 居中容器、`padding: 32px 28px 80px`
|
||||||
|
- 14px / `line-height: 1.7`、`PingFang SC` / `Microsoft YaHei` 中文字体优先
|
||||||
|
- 模块化区块:`section` + 编号 h2、`card`、`table.cmp`、`pre`、`tag-ok/warn/bad/crit`
|
||||||
|
- 配色用 `--bg / --panel / --border / --text / --primary` 一套 CSS 变量
|
||||||
|
- **`README.md`** 是目录索引约定文件,**保持 `.md`**(不强行 `.html`)。
|
||||||
|
- **已有 `.md` 文档不主动改写**,等用户明确要求再转。
|
||||||
35
README.md
35
README.md
@@ -1,35 +0,0 @@
|
|||||||
# cn-rdms-web
|
|
||||||
|
|
||||||
这是当前项目的前端工程仓库。
|
|
||||||
|
|
||||||
原开源模板项目的介绍内容已移除,这个 README 现在只保留当前项目自身所需的信息。
|
|
||||||
|
|
||||||
## 项目说明
|
|
||||||
|
|
||||||
待补充。
|
|
||||||
|
|
||||||
建议后续在这里补充:
|
|
||||||
|
|
||||||
- 项目背景
|
|
||||||
- 技术栈
|
|
||||||
- 目录结构
|
|
||||||
- 本地启动方式
|
|
||||||
- 环境变量说明
|
|
||||||
- 构建与发布流程
|
|
||||||
|
|
||||||
## 本地开发
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## 常用命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm dev
|
|
||||||
pnpm build
|
|
||||||
pnpm build:dev
|
|
||||||
pnpm typecheck
|
|
||||||
pnpm lint
|
|
||||||
```
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ProxyOptions } from 'vite';
|
import type { ProxyOptions } from 'vite';
|
||||||
import { bgRed, bgYellow, green, lightBlue } from 'kolorist';
|
import { bgRed, bgYellow, green, lightBlue } from 'kolorist';
|
||||||
import { consola } from 'consola';
|
import { consola } from 'consola';
|
||||||
|
import { WEB_SERVICE_PREFIX } from '../../src/constants/service';
|
||||||
import { createServiceConfig } from '../../src/utils/service';
|
import { createServiceConfig } from '../../src/utils/service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +25,14 @@ export function createViteProxy(env: Env.ImportMeta, enable: boolean) {
|
|||||||
Object.assign(proxy, createProxyItem(item, isEnableProxyLog));
|
Object.assign(proxy, createProxyItem(item, isEnableProxyLog));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 富文本图片 <img src="/admin-api/system/file/{configId}/get/{path}"> 由浏览器直接发起,
|
||||||
|
// 不经过 axios,没有 baseURL 前缀。这里加一条原样透传,避免被 Vite SPA fallback 兜底成 index.html。
|
||||||
|
// 不带 rewrite —— 原样把 /admin-api/* 转发到后端;不影响现有 /proxy-default 链路。
|
||||||
|
proxy[WEB_SERVICE_PREFIX] = {
|
||||||
|
target: baseURL,
|
||||||
|
changeOrigin: true
|
||||||
|
};
|
||||||
|
|
||||||
return proxy;
|
return proxy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,13 @@ export function setupElegantRouter() {
|
|||||||
onRouteMetaGen(routeName) {
|
onRouteMetaGen(routeName) {
|
||||||
const key = routeName as RouteKey;
|
const key = routeName as RouteKey;
|
||||||
|
|
||||||
const constantRoutes: RouteKey[] = ['login', '403', '404', '500'];
|
const constantRoutes: RouteKey[] = ['login', '403', '404', '500', 'workbench'];
|
||||||
const routeMetaMap: Partial<Record<RouteKey, Partial<RouteMeta>>> = {
|
const routeMetaMap: Partial<Record<RouteKey, Partial<RouteMeta>>> = {
|
||||||
|
workbench: {
|
||||||
|
icon: 'mdi:view-dashboard-outline',
|
||||||
|
order: 1,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
product: {
|
product: {
|
||||||
icon: 'carbon:product',
|
icon: 'carbon:product',
|
||||||
order: 4
|
order: 4
|
||||||
@@ -50,6 +55,119 @@ export function setupElegantRouter() {
|
|||||||
hideInMenu: true,
|
hideInMenu: true,
|
||||||
activeMenu: 'product_list'
|
activeMenu: 'product_list'
|
||||||
},
|
},
|
||||||
|
project: {
|
||||||
|
icon: 'mdi:briefcase-outline',
|
||||||
|
order: 5
|
||||||
|
},
|
||||||
|
project_list: {
|
||||||
|
icon: 'material-symbols:view-list-outline-rounded',
|
||||||
|
order: 1,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
|
project_project: {
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenu: 'project_list'
|
||||||
|
},
|
||||||
|
project_project_overview: {
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenu: 'project_list'
|
||||||
|
},
|
||||||
|
project_project_requirement: {
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenu: 'project_list'
|
||||||
|
},
|
||||||
|
project_project_execution: {
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenu: 'project_list'
|
||||||
|
},
|
||||||
|
project_project_setting: {
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenu: 'project_list'
|
||||||
|
},
|
||||||
|
ticket: {
|
||||||
|
icon: 'mdi:ticket-confirmation-outline',
|
||||||
|
order: 6
|
||||||
|
},
|
||||||
|
'ticket_my-submitted': {
|
||||||
|
icon: 'mdi:upload-outline',
|
||||||
|
order: 1,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
|
'ticket_my-pending': {
|
||||||
|
icon: 'mdi:inbox-arrow-down-outline',
|
||||||
|
order: 2,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
icon: 'mdi:chart-line',
|
||||||
|
order: 7
|
||||||
|
},
|
||||||
|
'metrics_project-progress': {
|
||||||
|
icon: 'mdi:progress-clock',
|
||||||
|
order: 1,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
|
'metrics_member-efficiency': {
|
||||||
|
icon: 'mdi:account-multiple-check-outline',
|
||||||
|
order: 2,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
|
metrics_worktime: {
|
||||||
|
icon: 'mdi:clock-time-five-outline',
|
||||||
|
order: 3,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
|
'personal-center': {
|
||||||
|
icon: 'mdi:account-circle-outline',
|
||||||
|
order: 8
|
||||||
|
},
|
||||||
|
'personal-center_my-profile': {
|
||||||
|
icon: 'mdi:account-box-outline',
|
||||||
|
order: 0,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
|
'personal-center_my-item': {
|
||||||
|
icon: 'mdi:checkbox-multiple-blank-circle-outline',
|
||||||
|
order: 1,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
|
'personal-center_work-report': {
|
||||||
|
icon: 'mdi:file-chart-outline',
|
||||||
|
order: 3,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
|
'personal-center_work-report_weekly': {
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenu: 'personal-center_work-report'
|
||||||
|
},
|
||||||
|
'personal-center_work-report_monthly': {
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenu: 'personal-center_work-report'
|
||||||
|
},
|
||||||
|
'personal-center_work-report_project': {
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenu: 'personal-center_work-report'
|
||||||
|
},
|
||||||
|
'personal-center_my-performance': {
|
||||||
|
icon: 'mdi:trophy-outline',
|
||||||
|
order: 4,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
|
'personal-center_my-application': {
|
||||||
|
icon: 'mdi:file-document-outline',
|
||||||
|
order: 5,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
|
'personal-center_overtime-application': {
|
||||||
|
icon: 'mdi:clock-plus-outline',
|
||||||
|
order: 6,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
|
'personal-center_pending-approval': {
|
||||||
|
icon: 'mdi:check-decagram-outline',
|
||||||
|
order: 7,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
system: {
|
system: {
|
||||||
icon: 'carbon:cloud-service-management',
|
icon: 'carbon:cloud-service-management',
|
||||||
order: 9,
|
order: 9,
|
||||||
@@ -81,6 +199,20 @@ export function setupElegantRouter() {
|
|||||||
hideInMenu: true,
|
hideInMenu: true,
|
||||||
roles: ['R_ADMIN'],
|
roles: ['R_ADMIN'],
|
||||||
activeMenu: 'system_user'
|
activeMenu: 'system_user'
|
||||||
|
},
|
||||||
|
infra: {
|
||||||
|
icon: 'ep:monitor',
|
||||||
|
order: 20
|
||||||
|
},
|
||||||
|
'infra_state-machine': {
|
||||||
|
icon: 'mdi:state-machine',
|
||||||
|
order: 1,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
|
'infra_rd-code': {
|
||||||
|
icon: 'mdi:identifier',
|
||||||
|
order: 2,
|
||||||
|
keepAlive: true
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-04-20T11:27:02.190Z",
|
"generatedAt": "2026-06-05T03:08:01.803Z",
|
||||||
"description": "Frontend visible page resource whitelist for backend route/menu configuration.",
|
"description": "Frontend visible page resource whitelist for backend route/menu configuration.",
|
||||||
"rules": {
|
"rules": {
|
||||||
"directoryComponent": "layout.base",
|
"directoryComponent": "layout.base",
|
||||||
"pageComponentPattern": "view.<routeName>",
|
"pageComponentPattern": "view.<routeName>",
|
||||||
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
|
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
|
||||||
},
|
},
|
||||||
"total": 7,
|
"total": 22,
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"name": "product_list",
|
"name": "product_list",
|
||||||
@@ -41,6 +41,435 @@
|
|||||||
"pageType": "leaf",
|
"pageType": "leaf",
|
||||||
"source": "generated"
|
"source": "generated"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "project_list",
|
||||||
|
"path": "/project/list",
|
||||||
|
"component": "view.project_list",
|
||||||
|
"title": "项目列表",
|
||||||
|
"routeTitle": "project_list",
|
||||||
|
"i18nKey": "route.project_list",
|
||||||
|
"icon": "material-symbols:view-list-outline-rounded",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 1,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"keepAlive": true,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null,
|
||||||
|
"redirect": null,
|
||||||
|
"props": null,
|
||||||
|
"meta": {
|
||||||
|
"title": "项目列表",
|
||||||
|
"i18nKey": "route.project_list",
|
||||||
|
"icon": "material-symbols:view-list-outline-rounded",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 1,
|
||||||
|
"keepAlive": true,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null
|
||||||
|
},
|
||||||
|
"parentName": "project",
|
||||||
|
"pageType": "leaf",
|
||||||
|
"source": "generated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ticket_my-submitted",
|
||||||
|
"path": "/ticket/my-submitted",
|
||||||
|
"component": "view.ticket_my-submitted",
|
||||||
|
"title": "我提交的工单",
|
||||||
|
"routeTitle": "ticket_my-submitted",
|
||||||
|
"i18nKey": "route.ticket_my-submitted",
|
||||||
|
"icon": "mdi:upload-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 1,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"keepAlive": true,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null,
|
||||||
|
"redirect": null,
|
||||||
|
"props": null,
|
||||||
|
"meta": {
|
||||||
|
"title": "我提交的工单",
|
||||||
|
"i18nKey": "route.ticket_my-submitted",
|
||||||
|
"icon": "mdi:upload-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 1,
|
||||||
|
"keepAlive": true,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null
|
||||||
|
},
|
||||||
|
"parentName": "ticket",
|
||||||
|
"pageType": "leaf",
|
||||||
|
"source": "generated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ticket_my-pending",
|
||||||
|
"path": "/ticket/my-pending",
|
||||||
|
"component": "view.ticket_my-pending",
|
||||||
|
"title": "待我处理的工单",
|
||||||
|
"routeTitle": "ticket_my-pending",
|
||||||
|
"i18nKey": "route.ticket_my-pending",
|
||||||
|
"icon": "mdi:inbox-arrow-down-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 2,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"keepAlive": true,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null,
|
||||||
|
"redirect": null,
|
||||||
|
"props": null,
|
||||||
|
"meta": {
|
||||||
|
"title": "待我处理的工单",
|
||||||
|
"i18nKey": "route.ticket_my-pending",
|
||||||
|
"icon": "mdi:inbox-arrow-down-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 2,
|
||||||
|
"keepAlive": true,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null
|
||||||
|
},
|
||||||
|
"parentName": "ticket",
|
||||||
|
"pageType": "leaf",
|
||||||
|
"source": "generated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "metrics_project-progress",
|
||||||
|
"path": "/metrics/project-progress",
|
||||||
|
"component": "view.metrics_project-progress",
|
||||||
|
"title": "项目进度",
|
||||||
|
"routeTitle": "metrics_project-progress",
|
||||||
|
"i18nKey": "route.metrics_project-progress",
|
||||||
|
"icon": "mdi:progress-clock",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 1,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"keepAlive": true,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null,
|
||||||
|
"redirect": null,
|
||||||
|
"props": null,
|
||||||
|
"meta": {
|
||||||
|
"title": "项目进度",
|
||||||
|
"i18nKey": "route.metrics_project-progress",
|
||||||
|
"icon": "mdi:progress-clock",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 1,
|
||||||
|
"keepAlive": true,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null
|
||||||
|
},
|
||||||
|
"parentName": "metrics",
|
||||||
|
"pageType": "leaf",
|
||||||
|
"source": "generated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "metrics_member-efficiency",
|
||||||
|
"path": "/metrics/member-efficiency",
|
||||||
|
"component": "view.metrics_member-efficiency",
|
||||||
|
"title": "员工能效",
|
||||||
|
"routeTitle": "metrics_member-efficiency",
|
||||||
|
"i18nKey": "route.metrics_member-efficiency",
|
||||||
|
"icon": "mdi:account-multiple-check-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 2,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"keepAlive": true,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null,
|
||||||
|
"redirect": null,
|
||||||
|
"props": null,
|
||||||
|
"meta": {
|
||||||
|
"title": "员工能效",
|
||||||
|
"i18nKey": "route.metrics_member-efficiency",
|
||||||
|
"icon": "mdi:account-multiple-check-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 2,
|
||||||
|
"keepAlive": true,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null
|
||||||
|
},
|
||||||
|
"parentName": "metrics",
|
||||||
|
"pageType": "leaf",
|
||||||
|
"source": "generated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "metrics_worktime",
|
||||||
|
"path": "/metrics/worktime",
|
||||||
|
"component": "view.metrics_worktime",
|
||||||
|
"title": "工时统计",
|
||||||
|
"routeTitle": "metrics_worktime",
|
||||||
|
"i18nKey": "route.metrics_worktime",
|
||||||
|
"icon": "mdi:clock-time-five-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 3,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"keepAlive": true,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null,
|
||||||
|
"redirect": null,
|
||||||
|
"props": null,
|
||||||
|
"meta": {
|
||||||
|
"title": "工时统计",
|
||||||
|
"i18nKey": "route.metrics_worktime",
|
||||||
|
"icon": "mdi:clock-time-five-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 3,
|
||||||
|
"keepAlive": true,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null
|
||||||
|
},
|
||||||
|
"parentName": "metrics",
|
||||||
|
"pageType": "leaf",
|
||||||
|
"source": "generated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "personal-center_my-profile",
|
||||||
|
"path": "/personal-center/my-profile",
|
||||||
|
"component": "view.personal-center_my-profile",
|
||||||
|
"title": "个人信息",
|
||||||
|
"routeTitle": "personal-center_my-profile",
|
||||||
|
"i18nKey": "route.personal-center_my-profile",
|
||||||
|
"icon": "mdi:account-box-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 0,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"keepAlive": true,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null,
|
||||||
|
"redirect": null,
|
||||||
|
"props": null,
|
||||||
|
"meta": {
|
||||||
|
"title": "个人信息",
|
||||||
|
"i18nKey": "route.personal-center_my-profile",
|
||||||
|
"icon": "mdi:account-box-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 0,
|
||||||
|
"keepAlive": true,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null
|
||||||
|
},
|
||||||
|
"parentName": "personal-center",
|
||||||
|
"pageType": "leaf",
|
||||||
|
"source": "generated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "personal-center_my-item",
|
||||||
|
"path": "/personal-center/my-item",
|
||||||
|
"component": "view.personal-center_my-item",
|
||||||
|
"title": "我的事项",
|
||||||
|
"routeTitle": "personal-center_my-item",
|
||||||
|
"i18nKey": "route.personal-center_my-item",
|
||||||
|
"icon": "mdi:checkbox-multiple-blank-circle-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 1,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"keepAlive": true,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null,
|
||||||
|
"redirect": null,
|
||||||
|
"props": null,
|
||||||
|
"meta": {
|
||||||
|
"title": "我的事项",
|
||||||
|
"i18nKey": "route.personal-center_my-item",
|
||||||
|
"icon": "mdi:checkbox-multiple-blank-circle-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 1,
|
||||||
|
"keepAlive": true,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null
|
||||||
|
},
|
||||||
|
"parentName": "personal-center",
|
||||||
|
"pageType": "leaf",
|
||||||
|
"source": "generated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "personal-center_work-report",
|
||||||
|
"path": "/personal-center/work-report",
|
||||||
|
"component": "view.personal-center_work-report",
|
||||||
|
"title": "工作报告",
|
||||||
|
"routeTitle": "personal-center_work-report",
|
||||||
|
"i18nKey": "route.personal-center_work-report",
|
||||||
|
"icon": "mdi:file-chart-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 3,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"keepAlive": true,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null,
|
||||||
|
"redirect": null,
|
||||||
|
"props": null,
|
||||||
|
"meta": {
|
||||||
|
"title": "工作报告",
|
||||||
|
"i18nKey": "route.personal-center_work-report",
|
||||||
|
"icon": "mdi:file-chart-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 3,
|
||||||
|
"keepAlive": true,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null
|
||||||
|
},
|
||||||
|
"parentName": "personal-center",
|
||||||
|
"pageType": "leaf",
|
||||||
|
"source": "generated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "personal-center_my-performance",
|
||||||
|
"path": "/personal-center/my-performance",
|
||||||
|
"component": "view.personal-center_my-performance",
|
||||||
|
"title": "我的绩效",
|
||||||
|
"routeTitle": "personal-center_my-performance",
|
||||||
|
"i18nKey": "route.personal-center_my-performance",
|
||||||
|
"icon": "mdi:trophy-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 4,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"keepAlive": true,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null,
|
||||||
|
"redirect": null,
|
||||||
|
"props": null,
|
||||||
|
"meta": {
|
||||||
|
"title": "我的绩效",
|
||||||
|
"i18nKey": "route.personal-center_my-performance",
|
||||||
|
"icon": "mdi:trophy-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 4,
|
||||||
|
"keepAlive": true,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null
|
||||||
|
},
|
||||||
|
"parentName": "personal-center",
|
||||||
|
"pageType": "leaf",
|
||||||
|
"source": "generated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "personal-center_my-application",
|
||||||
|
"path": "/personal-center/my-application",
|
||||||
|
"component": "view.personal-center_my-application",
|
||||||
|
"title": "我的申请",
|
||||||
|
"routeTitle": "personal-center_my-application",
|
||||||
|
"i18nKey": "route.personal-center_my-application",
|
||||||
|
"icon": "mdi:file-document-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 5,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"keepAlive": true,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null,
|
||||||
|
"redirect": null,
|
||||||
|
"props": null,
|
||||||
|
"meta": {
|
||||||
|
"title": "我的申请",
|
||||||
|
"i18nKey": "route.personal-center_my-application",
|
||||||
|
"icon": "mdi:file-document-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 5,
|
||||||
|
"keepAlive": true,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null
|
||||||
|
},
|
||||||
|
"parentName": "personal-center",
|
||||||
|
"pageType": "leaf",
|
||||||
|
"source": "generated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "personal-center_pending-approval",
|
||||||
|
"path": "/personal-center/pending-approval",
|
||||||
|
"component": "view.personal-center_pending-approval",
|
||||||
|
"title": "待我审批",
|
||||||
|
"routeTitle": "personal-center_pending-approval",
|
||||||
|
"i18nKey": "route.personal-center_pending-approval",
|
||||||
|
"icon": "mdi:check-decagram-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 7,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"keepAlive": true,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null,
|
||||||
|
"redirect": null,
|
||||||
|
"props": null,
|
||||||
|
"meta": {
|
||||||
|
"title": "待我审批",
|
||||||
|
"i18nKey": "route.personal-center_pending-approval",
|
||||||
|
"icon": "mdi:check-decagram-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 7,
|
||||||
|
"keepAlive": true,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null
|
||||||
|
},
|
||||||
|
"parentName": "personal-center",
|
||||||
|
"pageType": "leaf",
|
||||||
|
"source": "generated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "personal-center_overtime-application",
|
||||||
|
"path": "/personal-center/overtime-application",
|
||||||
|
"component": "view.personal-center_overtime-application",
|
||||||
|
"title": "加班申请",
|
||||||
|
"routeTitle": "personal-center_overtime-application",
|
||||||
|
"i18nKey": "route.personal-center_overtime-application",
|
||||||
|
"icon": "mdi:clock-plus-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 6,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"keepAlive": true,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null,
|
||||||
|
"redirect": null,
|
||||||
|
"props": null,
|
||||||
|
"meta": {
|
||||||
|
"title": "加班申请",
|
||||||
|
"i18nKey": "route.personal-center_overtime-application",
|
||||||
|
"icon": "mdi:clock-plus-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 6,
|
||||||
|
"keepAlive": true,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null
|
||||||
|
},
|
||||||
|
"parentName": "personal-center",
|
||||||
|
"pageType": "leaf",
|
||||||
|
"source": "generated"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "system_user",
|
"name": "system_user",
|
||||||
"path": "/system/user",
|
"path": "/system/user",
|
||||||
@@ -238,6 +667,72 @@
|
|||||||
"parentName": "system",
|
"parentName": "system",
|
||||||
"pageType": "leaf",
|
"pageType": "leaf",
|
||||||
"source": "generated"
|
"source": "generated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "infra_state-machine",
|
||||||
|
"path": "/infra/state-machine",
|
||||||
|
"component": "view.infra_state-machine",
|
||||||
|
"title": "状态机管理",
|
||||||
|
"routeTitle": "infra_state-machine",
|
||||||
|
"i18nKey": "route.infra_state-machine",
|
||||||
|
"icon": "mdi:state-machine",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 1,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"keepAlive": true,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null,
|
||||||
|
"redirect": null,
|
||||||
|
"props": null,
|
||||||
|
"meta": {
|
||||||
|
"title": "状态机管理",
|
||||||
|
"i18nKey": "route.infra_state-machine",
|
||||||
|
"icon": "mdi:state-machine",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 1,
|
||||||
|
"keepAlive": true,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null
|
||||||
|
},
|
||||||
|
"parentName": "infra",
|
||||||
|
"pageType": "leaf",
|
||||||
|
"source": "generated"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "infra_rd-code",
|
||||||
|
"path": "/infra/rd-code",
|
||||||
|
"component": "view.infra_rd-code",
|
||||||
|
"title": "研发令号",
|
||||||
|
"routeTitle": "infra_rd-code",
|
||||||
|
"i18nKey": "route.infra_rd-code",
|
||||||
|
"icon": "mdi:identifier",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 2,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"keepAlive": true,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null,
|
||||||
|
"redirect": null,
|
||||||
|
"props": null,
|
||||||
|
"meta": {
|
||||||
|
"title": "研发令号",
|
||||||
|
"i18nKey": "route.infra_rd-code",
|
||||||
|
"icon": "mdi:identifier",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 2,
|
||||||
|
"keepAlive": true,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null
|
||||||
|
},
|
||||||
|
"parentName": "infra",
|
||||||
|
"pageType": "leaf",
|
||||||
|
"source": "generated"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,292 +0,0 @@
|
|||||||
# 产品对象首页改版设计说明
|
|
||||||
|
|
||||||
日期:2026-04-23
|
|
||||||
|
|
||||||
## 1. 目标
|
|
||||||
|
|
||||||
本设计用于收敛 RDMS 产品对象上下文默认首页的改版方向。
|
|
||||||
|
|
||||||
本轮目标不是继续做“说明型占位页”,而是明确把当前 `/product/dashboard?objectId=...` 改成一个真正可用的产品对象首页:
|
|
||||||
|
|
||||||
- 第一眼先让用户知道当前看的是什么产品
|
|
||||||
- 第二眼能快速判断对象最近发生了什么
|
|
||||||
- 第三眼能看出需求池现在的经营状态和最近变化
|
|
||||||
- 底部为后续业务模块保留正式挂载位,而不是临时拼接入口
|
|
||||||
|
|
||||||
## 2. 已确认诉求
|
|
||||||
|
|
||||||
基于本轮对话,已确认以下用户诉求:
|
|
||||||
|
|
||||||
1. 首页顶部必须先展示产品基础概述,而不是先铺统计卡片
|
|
||||||
2. 基础概述至少包含:名称、编号、团队、产品经理等对象基础信息
|
|
||||||
3. 页面需要一块明显的时间线,用于承接产品对象与团队变更动态
|
|
||||||
4. 页面需要承接需求池管理情况,重点看总量、状态、待处理等统计信息
|
|
||||||
5. 需求相关事件不要混入对象时间线,应单独作为需求池最近变化区域
|
|
||||||
6. 快捷入口不要保留
|
|
||||||
7. 底部允许保留后续扩展区,重点预留给里程碑、风险点管理、产品资料等模块
|
|
||||||
8. 能接真实接口就接真实接口,当前没有稳定接口的区域允许先用假数据,但结构必须按正式首页来设计
|
|
||||||
|
|
||||||
## 3. 首页定位结论
|
|
||||||
|
|
||||||
本页定位不是:
|
|
||||||
|
|
||||||
- 纯报表看板
|
|
||||||
- 纯审计日志页
|
|
||||||
- 设置页搬运版
|
|
||||||
- 导航入口集合页
|
|
||||||
|
|
||||||
本页定位应当是:
|
|
||||||
|
|
||||||
- 产品对象首页
|
|
||||||
- 偏统计,也带审计
|
|
||||||
- 但页面主语始终是“当前产品对象”
|
|
||||||
|
|
||||||
换句话说,这个页面要同时回答三个问题:
|
|
||||||
|
|
||||||
1. 我现在看的是什么产品?
|
|
||||||
2. 这个产品对象最近发生了什么?
|
|
||||||
3. 这个产品的需求池现在处于什么状态?
|
|
||||||
|
|
||||||
## 4. 页面结构
|
|
||||||
|
|
||||||
### 4.1 桌面端结构
|
|
||||||
|
|
||||||
桌面端建议采用三层结构:
|
|
||||||
|
|
||||||
1. 顶部 `对象基础概述横幅`
|
|
||||||
2. 中部 `左时间线 + 右需求池双模块`
|
|
||||||
3. 底部 `扩展信息区`
|
|
||||||
|
|
||||||
推荐布局比例:
|
|
||||||
|
|
||||||
- 顶部横幅:`24 / 24`
|
|
||||||
- 中部主区:左 `16 / 24`,右 `8 / 24`
|
|
||||||
- 底部扩展区:`24 / 24`
|
|
||||||
|
|
||||||
中部左侧时间线高度应明显高于右侧任一单模块,形成首页主阅读区。
|
|
||||||
|
|
||||||
### 4.2 移动端结构
|
|
||||||
|
|
||||||
移动端统一退化为单列纵向布局,顺序为:
|
|
||||||
|
|
||||||
1. 对象基础概述横幅
|
|
||||||
2. 对象 / 团队动态时间线
|
|
||||||
3. 需求池管理概览
|
|
||||||
4. 需求池最近变化
|
|
||||||
5. 扩展信息区
|
|
||||||
|
|
||||||
移动端不强撑左右栏并排,不做卡片墙式压缩。
|
|
||||||
|
|
||||||
## 5. 模块设计
|
|
||||||
|
|
||||||
### 5.1 对象基础概述横幅
|
|
||||||
|
|
||||||
顶部采用“档案横幅型”,不采用纯指标卡片型。
|
|
||||||
|
|
||||||
横幅左侧承接对象身份信息:
|
|
||||||
|
|
||||||
- 产品名称
|
|
||||||
- 产品编号
|
|
||||||
- 当前状态标签
|
|
||||||
- 产品经理
|
|
||||||
- 团队规模
|
|
||||||
- 团队角色摘要
|
|
||||||
- 简短描述或备注
|
|
||||||
|
|
||||||
横幅右侧承接 4 个摘要指标:
|
|
||||||
|
|
||||||
- 团队人数
|
|
||||||
- 需求总量
|
|
||||||
- 待处理需求
|
|
||||||
- 最近动态时间
|
|
||||||
|
|
||||||
设计原则:
|
|
||||||
|
|
||||||
- 左侧负责建立对象识别
|
|
||||||
- 右侧负责快速判断当前概况
|
|
||||||
- 右侧指标只保留 4 项,不堆成报表卡片墙
|
|
||||||
|
|
||||||
### 5.2 对象 / 团队动态时间线
|
|
||||||
|
|
||||||
该区域位于中部左侧,是首页的主阅读区。
|
|
||||||
|
|
||||||
这条时间线只承接对象与团队变化,不承接需求事件。
|
|
||||||
|
|
||||||
第一版事件范围收敛为:
|
|
||||||
|
|
||||||
- 产品创建
|
|
||||||
- 产品状态变更
|
|
||||||
- 产品经理变更
|
|
||||||
- 成员加入
|
|
||||||
- 成员移出
|
|
||||||
- 成员角色调整
|
|
||||||
|
|
||||||
每条时间线建议展示:
|
|
||||||
|
|
||||||
- 事件标题
|
|
||||||
- 事件类型标签
|
|
||||||
- 发生时间
|
|
||||||
- 操作摘要
|
|
||||||
- 必要时展示原因或备注
|
|
||||||
|
|
||||||
表达目标是“业务时间线”,不是后台审计表格。
|
|
||||||
|
|
||||||
### 5.3 需求池管理概览
|
|
||||||
|
|
||||||
该区域位于中部右侧上半块,用于表达需求池的经营状态。
|
|
||||||
|
|
||||||
第一版首页需要优先看到的内容:
|
|
||||||
|
|
||||||
- 需求总量
|
|
||||||
- 各状态数量
|
|
||||||
- 待处理数量
|
|
||||||
- 高优先级待处理数量
|
|
||||||
|
|
||||||
展示方式建议为“摘要指标 + 状态分布列表”,不直接在首页展开完整需求表格。
|
|
||||||
|
|
||||||
这一块回答的是:
|
|
||||||
|
|
||||||
- 需求池是否健康
|
|
||||||
- 当前待处理压力大不大
|
|
||||||
- 是否存在需要优先关注的积压
|
|
||||||
|
|
||||||
### 5.4 需求池最近变化
|
|
||||||
|
|
||||||
该区域位于中部右侧下半块,与需求池管理概览上下分层,但属于同一侧栏语义。
|
|
||||||
|
|
||||||
该区域不重复展示总量,而是展示需求池最近发生的变化。
|
|
||||||
|
|
||||||
第一版建议承接:
|
|
||||||
|
|
||||||
- 最近新增需求
|
|
||||||
- 最近状态流转
|
|
||||||
- 最近关闭或完成
|
|
||||||
|
|
||||||
每条记录建议至少展示:
|
|
||||||
|
|
||||||
- 需求标题
|
|
||||||
- 动作类型
|
|
||||||
- 时间
|
|
||||||
- 当前状态或状态变更摘要
|
|
||||||
|
|
||||||
若当前没有真实数据,仍保留正式模块壳,不退化成“待开发”一句话。
|
|
||||||
|
|
||||||
### 5.5 扩展信息区
|
|
||||||
|
|
||||||
底部不再保留快捷入口,改为正式扩展信息区。
|
|
||||||
|
|
||||||
当前优先预留 3 类模块位:
|
|
||||||
|
|
||||||
- 里程碑
|
|
||||||
- 风险点管理
|
|
||||||
- 产品资料
|
|
||||||
|
|
||||||
这一层的作用是:
|
|
||||||
|
|
||||||
- 为后续对象级信息继续扩展留下稳定挂载位
|
|
||||||
- 不把中部主结构挤成信息大杂烩
|
|
||||||
- 避免为了未来模块提前做假导航入口
|
|
||||||
|
|
||||||
如果当前没有稳定接口,可先保留正式卡片结构与空态说明。
|
|
||||||
|
|
||||||
## 6. 数据策略
|
|
||||||
|
|
||||||
### 6.1 真实接口优先
|
|
||||||
|
|
||||||
当前首页优先消费现有真实接口:
|
|
||||||
|
|
||||||
- `fetchGetProduct`
|
|
||||||
- `fetchGetProductSettings`
|
|
||||||
- `fetchGetProductMembers`
|
|
||||||
|
|
||||||
这些接口足以支撑:
|
|
||||||
|
|
||||||
- 对象基础概述中的名称、编号、状态、产品经理、描述
|
|
||||||
- 团队人数与角色摘要
|
|
||||||
- 最近动态中的产品创建、状态变化、成员加入/移出
|
|
||||||
|
|
||||||
### 6.2 假数据使用边界
|
|
||||||
|
|
||||||
当前没有稳定真实接口的区域,允许先用假数据,但边界必须明确:
|
|
||||||
|
|
||||||
- 需求池管理概览
|
|
||||||
- 需求池最近变化
|
|
||||||
- 扩展信息区中的里程碑、风险点管理、产品资料摘要
|
|
||||||
|
|
||||||
假数据的使用原则:
|
|
||||||
|
|
||||||
1. 只补“当前没有稳定接口”的区域
|
|
||||||
2. 不反向污染对象基础信息
|
|
||||||
3. 不把假数据混入对象上下文 store
|
|
||||||
4. 数据源要集中放在概览页自己的 mock 模块中,方便后续替换
|
|
||||||
|
|
||||||
### 6.3 不推荐的做法
|
|
||||||
|
|
||||||
以下做法应避免:
|
|
||||||
|
|
||||||
- 把需求假数据散落写进页面组件
|
|
||||||
- 用对象 demo 数据冒充真实产品详情
|
|
||||||
- 把对象时间线和需求时间线混成一条
|
|
||||||
- 用快捷入口伪装成首页内容
|
|
||||||
|
|
||||||
## 7. 空态规则
|
|
||||||
|
|
||||||
首页至少要区分三种状态:
|
|
||||||
|
|
||||||
1. 能力未接入,只能先显示正式占位信息
|
|
||||||
2. 能力已接入,但当前该产品暂无业务数据
|
|
||||||
3. 当前用户无权限查看该模块
|
|
||||||
|
|
||||||
这三种状态不能共用一套模糊文案。
|
|
||||||
|
|
||||||
对需求池和扩展信息区,当前阶段更推荐“正式空态”而不是“待开发”。
|
|
||||||
|
|
||||||
## 8. 页面边界
|
|
||||||
|
|
||||||
首页明确不承接以下内容:
|
|
||||||
|
|
||||||
- 快捷入口导航区
|
|
||||||
- 完整团队成员表格
|
|
||||||
- 完整需求列表表格
|
|
||||||
- 设置页重表单
|
|
||||||
- 完整审计日志明细页
|
|
||||||
|
|
||||||
首页要做的是概述、判断与阅读,不是重操作页。
|
|
||||||
|
|
||||||
## 9. 实施建议
|
|
||||||
|
|
||||||
第一阶段建议先完成结构性改造:
|
|
||||||
|
|
||||||
1. 重做顶部横幅,建立对象档案感
|
|
||||||
2. 保留中部左高右双块结构
|
|
||||||
3. 用真实接口接通对象概述与对象 / 团队时间线
|
|
||||||
4. 用局部 mock 数据先接通需求池两块和底部扩展区
|
|
||||||
|
|
||||||
第二阶段再逐步替换需求池与扩展区数据源:
|
|
||||||
|
|
||||||
- 接真实需求池统计接口
|
|
||||||
- 接真实需求动态接口
|
|
||||||
- 接里程碑、风险点、产品资料摘要接口
|
|
||||||
|
|
||||||
## 10. 验证标准
|
|
||||||
|
|
||||||
本设计是否成立,可按以下标准判断:
|
|
||||||
|
|
||||||
1. 进入首页后,第一眼能认出当前产品对象
|
|
||||||
2. 用户能自然读到对象 / 团队最近发生了什么
|
|
||||||
3. 右侧能快速判断需求池当前压力与最近变化
|
|
||||||
4. 页面看起来像“对象首页”,而不是“普通后台卡片堆叠页”
|
|
||||||
5. 当前没有真实接口的区域也保留正式结构,不显得像临时占位
|
|
||||||
6. 后续新增里程碑、风险点管理、产品资料等能力时,不需要推翻整页结构
|
|
||||||
|
|
||||||
## 11. 本轮设计结论
|
|
||||||
|
|
||||||
本轮最终设计结论如下:
|
|
||||||
|
|
||||||
- 首页定位为“产品对象首页”,偏统计,也带审计,但不做纯报表页
|
|
||||||
- 顶部采用档案横幅型,先立住对象身份信息
|
|
||||||
- 中部左侧是高权重的对象 / 团队动态时间线
|
|
||||||
- 中部右侧拆为“需求池管理概览 + 需求池最近变化”上下两块
|
|
||||||
- 底部去掉快捷入口,改为正式扩展信息区
|
|
||||||
- 当前有真实接口的模块优先接真实接口
|
|
||||||
- 当前没有稳定接口的区域允许先用假数据,但必须隔离在概览页局部 mock 数据源中
|
|
||||||
31
package.json
31
package.json
@@ -37,9 +37,6 @@
|
|||||||
"update-pkg": "sa update-pkg"
|
"update-pkg": "sa update-pkg"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@antv/data-set": "0.11.8",
|
|
||||||
"@antv/g2": "5.4.0",
|
|
||||||
"@antv/g6": "5.0.49",
|
|
||||||
"@better-scroll/core": "2.5.1",
|
"@better-scroll/core": "2.5.1",
|
||||||
"@iconify/vue": "5.0.0",
|
"@iconify/vue": "5.0.0",
|
||||||
"@sa/axios": "workspace:*",
|
"@sa/axios": "workspace:*",
|
||||||
@@ -47,49 +44,35 @@
|
|||||||
"@sa/hooks": "workspace:*",
|
"@sa/hooks": "workspace:*",
|
||||||
"@sa/materials": "workspace:*",
|
"@sa/materials": "workspace:*",
|
||||||
"@sa/utils": "workspace:*",
|
"@sa/utils": "workspace:*",
|
||||||
"@visactor/vchart": "2.0.4",
|
"@univerjs/preset-sheets-core": "^0.25.0",
|
||||||
"@visactor/vchart-theme": "1.12.2",
|
"@univerjs/presets": "^0.25.0",
|
||||||
"@visactor/vtable-editors": "1.19.8",
|
|
||||||
"@visactor/vtable-gantt": "1.19.8",
|
|
||||||
"@visactor/vue-vtable": "1.19.8",
|
|
||||||
"@vueuse/components": "13.9.0",
|
|
||||||
"@vueuse/core": "13.9.0",
|
"@vueuse/core": "13.9.0",
|
||||||
|
"@wangeditor/editor": "^5.1.23",
|
||||||
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
|
"@zwight/luckyexcel": "^1.1.6",
|
||||||
"clipboard": "2.0.11",
|
"clipboard": "2.0.11",
|
||||||
"dayjs": "1.11.18",
|
"dayjs": "1.11.18",
|
||||||
"defu": "^6.1.4",
|
"defu": "^6.1.4",
|
||||||
"dhtmlx-gantt": "9.0.14",
|
|
||||||
"dompurify": "3.2.6",
|
"dompurify": "3.2.6",
|
||||||
"echarts": "6.0.0",
|
"echarts": "6.0.0",
|
||||||
"element-plus": "^2.11.1",
|
"element-plus": "^2.11.1",
|
||||||
"jsbarcode": "3.12.1",
|
"grid-layout-plus": "^1.1.1",
|
||||||
"jsencrypt": "^3.5.4",
|
"jsencrypt": "^3.5.4",
|
||||||
"json5": "2.2.3",
|
"json5": "2.2.3",
|
||||||
"nprogress": "0.2.0",
|
"nprogress": "0.2.0",
|
||||||
"pinia": "3.0.3",
|
"pinia": "3.0.3",
|
||||||
"pinyin-pro": "3.27.0",
|
|
||||||
"print-js": "1.6.0",
|
|
||||||
"swiper": "11.2.10",
|
|
||||||
"tailwind-merge": "3.3.1",
|
"tailwind-merge": "3.3.1",
|
||||||
"typeit": "8.8.7",
|
|
||||||
"vditor": "3.11.2",
|
|
||||||
"vue": "3.5.20",
|
"vue": "3.5.20",
|
||||||
"vue-draggable-plus": "0.6.0",
|
"vue-draggable-plus": "0.6.0",
|
||||||
"vue-i18n": "11.1.11",
|
"vue-i18n": "11.1.11",
|
||||||
"vue-pdf-embed": "2.1.3",
|
"vue-router": "4.5.1"
|
||||||
"vue-router": "4.5.1",
|
|
||||||
"wangeditor": "4.7.15",
|
|
||||||
"xgplayer": "3.0.23",
|
|
||||||
"xlsx": "0.18.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@amap/amap-jsapi-types": "0.0.15",
|
|
||||||
"@elegant-router/vue": "0.3.8",
|
"@elegant-router/vue": "0.3.8",
|
||||||
"@iconify/json": "2.2.380",
|
"@iconify/json": "2.2.380",
|
||||||
"@sa/scripts": "workspace:*",
|
"@sa/scripts": "workspace:*",
|
||||||
"@sa/uno-preset": "workspace:*",
|
"@sa/uno-preset": "workspace:*",
|
||||||
"@soybeanjs/eslint-config": "1.7.1",
|
"@soybeanjs/eslint-config": "1.7.1",
|
||||||
"@types/bmapgl": "0.0.7",
|
|
||||||
"@types/dompurify": "3.2.0",
|
|
||||||
"@types/node": "24.3.0",
|
"@types/node": "24.3.0",
|
||||||
"@types/nprogress": "0.2.3",
|
"@types/nprogress": "0.2.3",
|
||||||
"@unocss/eslint-config": "66.5.0",
|
"@unocss/eslint-config": "66.5.0",
|
||||||
|
|||||||
6305
pnpm-lock.yaml
generated
6305
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
1018
src/components/custom/attendee-user-picker.vue
Normal file
1018
src/components/custom/attendee-user-picker.vue
Normal file
File diff suppressed because it is too large
Load Diff
718
src/components/custom/business-attachment-uploader.vue
Normal file
718
src/components/custom/business-attachment-uploader.vue
Normal file
@@ -0,0 +1,718 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
|
||||||
|
import { ArrowDown, Delete, Document, Loading, Picture, QuestionFilled, Upload } from '@element-plus/icons-vue';
|
||||||
|
import { deleteFile, downloadFile, uploadFile } from '@/service/api/file';
|
||||||
|
|
||||||
|
defineOptions({ name: 'BusinessAttachmentUploader' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 上传目录,传给后端 directory 字段 */
|
||||||
|
directory?: string;
|
||||||
|
/** 数量上限,默认 20(与后端 AttachmentValidator 一致) */
|
||||||
|
max?: number;
|
||||||
|
/** 单文件大小上限 MB(前端兜底;最终由 /system/file/upload 拦截) */
|
||||||
|
maxFileSizeMB?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* 平铺模式:所有附件直接逐项渲染,不再做"首项 + 折叠浮层"。
|
||||||
|
* 用于本身已经在 popover / 详情卡片里展示,避免嵌套浮层。
|
||||||
|
*/
|
||||||
|
flat?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
directory: undefined,
|
||||||
|
max: 20,
|
||||||
|
maxFileSizeMB: 50,
|
||||||
|
disabled: false,
|
||||||
|
flat: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const model = defineModel<Api.Project.AttachmentItem[]>({ default: () => [] });
|
||||||
|
|
||||||
|
/** 给用户看的简短分类(hint 行展示) */
|
||||||
|
const ALLOWED_EXTENSIONS_HINT = '支持 PDF、Word、Excel、PPT、TXT/MD/CSV、图片、ZIP/RAR/7Z、MP3/MP4';
|
||||||
|
|
||||||
|
// 与后端 AttachmentValidator 白/黑名单保持一致(5.16)
|
||||||
|
const ALLOWED_EXTENSIONS = new Set([
|
||||||
|
'pdf',
|
||||||
|
'doc',
|
||||||
|
'docx',
|
||||||
|
'xls',
|
||||||
|
'xlsx',
|
||||||
|
'ppt',
|
||||||
|
'pptx',
|
||||||
|
'txt',
|
||||||
|
'md',
|
||||||
|
'csv',
|
||||||
|
'jpg',
|
||||||
|
'jpeg',
|
||||||
|
'png',
|
||||||
|
'gif',
|
||||||
|
'webp',
|
||||||
|
'bmp',
|
||||||
|
'zip',
|
||||||
|
'rar',
|
||||||
|
'7z',
|
||||||
|
'mp4',
|
||||||
|
'mp3'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const FORBIDDEN_EXTENSIONS = new Set([
|
||||||
|
'exe',
|
||||||
|
'bat',
|
||||||
|
'cmd',
|
||||||
|
'sh',
|
||||||
|
'ps1',
|
||||||
|
'msi',
|
||||||
|
'dll',
|
||||||
|
'jar',
|
||||||
|
'war',
|
||||||
|
'php',
|
||||||
|
'jsp',
|
||||||
|
'asp',
|
||||||
|
'aspx',
|
||||||
|
'py',
|
||||||
|
'rb',
|
||||||
|
'pl',
|
||||||
|
'com',
|
||||||
|
'scr',
|
||||||
|
'vbs',
|
||||||
|
'js'
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface PendingItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = ref<PendingItem[]>([]);
|
||||||
|
const inputRef = ref<HTMLInputElement>();
|
||||||
|
const isUnmounting = ref(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话级清理账本:
|
||||||
|
* - originalIds: 弹层打开时已存在的 fileId(编辑模式下来自 rowData.attachments)。
|
||||||
|
* 当前未在 commit/rollback 中直接读取(清理逻辑靠 addedIds 自己判定);
|
||||||
|
* 保留是为了让会话模型完整、便于后续扩展(如"撤销删除""仅删原有附件"等差异行为)。
|
||||||
|
* - addedIds: 本次会话内上传成功的 fileId
|
||||||
|
* - pendingDeleteIds: 用户在 UI 上点过"删除"的 fileId(含 original 和 added 两类)
|
||||||
|
* - committed: commit() 调用后置 true,阻止后续 rollback 误删
|
||||||
|
*
|
||||||
|
* UI 显示 = model(已减去 pendingDelete 项)
|
||||||
|
* 真删时机:commit() 删 pendingDelete;rollback() 删 addedIds(除非 committed)
|
||||||
|
*/
|
||||||
|
interface UploadSession {
|
||||||
|
originalIds: Set<string>;
|
||||||
|
addedIds: Set<string>;
|
||||||
|
pendingDeleteIds: Set<string>;
|
||||||
|
committed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = reactive<UploadSession>({
|
||||||
|
originalIds: new Set<string>(),
|
||||||
|
addedIds: new Set<string>(),
|
||||||
|
pendingDeleteIds: new Set<string>(),
|
||||||
|
committed: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCount = computed(() => model.value.length + pending.value.length);
|
||||||
|
const isFull = computed(() => totalCount.value >= props.max);
|
||||||
|
const hasUploading = computed(() => pending.value.length > 0);
|
||||||
|
|
||||||
|
const acceptExtensionsList = computed(() => Array.from(ALLOWED_EXTENSIONS).join(', '));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表区拆成"直接展示"和"折叠浮层"两组:
|
||||||
|
* - flat:全部直接展示(适合本身已在 popover 里)
|
||||||
|
* - 默认:首项直接展示,>1 时其余进入悬浮浮层
|
||||||
|
*/
|
||||||
|
const displayedAttachments = computed(() => (props.flat ? model.value : model.value.slice(0, 1)));
|
||||||
|
const popoverAttachments = computed(() => (props.flat || model.value.length <= 1 ? [] : model.value.slice(1)));
|
||||||
|
|
||||||
|
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']);
|
||||||
|
|
||||||
|
function isImage(item: Api.Project.AttachmentItem) {
|
||||||
|
if (item.contentType?.startsWith('image/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return IMAGE_EXTENSIONS.has(getExtension(item.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImagePreviewState {
|
||||||
|
visible: boolean;
|
||||||
|
urls: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagePreview = reactive<ImagePreviewState>({
|
||||||
|
visible: false,
|
||||||
|
urls: []
|
||||||
|
});
|
||||||
|
|
||||||
|
function getExtension(name: string) {
|
||||||
|
const idx = name.lastIndexOf('.');
|
||||||
|
return idx > 0 ? name.slice(idx + 1).toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFile(file: File): string | null {
|
||||||
|
if (!file.name) {
|
||||||
|
return '文件名为空';
|
||||||
|
}
|
||||||
|
if (file.name.length > 255) {
|
||||||
|
return '文件名超过 255 字符';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = getExtension(file.name);
|
||||||
|
if (!ext) {
|
||||||
|
return '文件缺少扩展名';
|
||||||
|
}
|
||||||
|
if (FORBIDDEN_EXTENSIONS.has(ext)) {
|
||||||
|
return `不允许上传 .${ext} 文件`;
|
||||||
|
}
|
||||||
|
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
||||||
|
return `暂不支持 .${ext} 文件`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > props.maxFileSizeMB * 1024 * 1024) {
|
||||||
|
return `单文件不能超过 ${props.maxFileSizeMB}MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerSelect() {
|
||||||
|
if (props.disabled || isFull.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileChange(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const files = Array.from(input.files || []);
|
||||||
|
input.value = '';
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = props.max - totalCount.value;
|
||||||
|
if (files.length > remaining) {
|
||||||
|
window.$message?.warning(`最多还能上传 ${remaining} 个附件`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
files.forEach(file => {
|
||||||
|
const err = validateFile(file);
|
||||||
|
if (err) {
|
||||||
|
window.$message?.error(`${file.name}:${err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
validFiles.push(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validFiles.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(validFiles.map(uploadOne));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadOne(file: File) {
|
||||||
|
const tempId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
pending.value = [...pending.value, { id: tempId, name: file.name }];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadFile(file, props.directory);
|
||||||
|
if (result.error || !result.data) {
|
||||||
|
window.$message?.error(`${file.name}:上传失败`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, url } = result.data;
|
||||||
|
|
||||||
|
// 组件已卸载(用户上传过程中关弹层):onBeforeUnmount 已跑过且看不到这个 id,
|
||||||
|
// 这里立刻调删除,避免孤儿文件
|
||||||
|
if (isUnmounting.value) {
|
||||||
|
deleteFile(id).catch(() => {
|
||||||
|
// 已卸载场景下 console.warn 也访问不到 component scope,这里静默吞掉
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.value = [
|
||||||
|
...model.value,
|
||||||
|
{
|
||||||
|
fileId: id,
|
||||||
|
url,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
contentType: file.type || undefined
|
||||||
|
}
|
||||||
|
];
|
||||||
|
session.addedIds.add(id);
|
||||||
|
} finally {
|
||||||
|
pending.value = pending.value.filter(item => item.id !== tempId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(item: Api.Project.AttachmentItem) {
|
||||||
|
removeAttachmentByFileId(item.fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAsBlobUrl(item: Api.Project.AttachmentItem) {
|
||||||
|
const { data, error } = await downloadFile(item.fileId);
|
||||||
|
if (error || !data) {
|
||||||
|
window.$message?.error(`${item.name}:加载失败`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return URL.createObjectURL(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload(item: Api.Project.AttachmentItem) {
|
||||||
|
const blobUrl = await fetchAsBlobUrl(item);
|
||||||
|
if (!blobUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = item.name;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePreviewImage(item: Api.Project.AttachmentItem) {
|
||||||
|
const blobUrl = await fetchAsBlobUrl(item);
|
||||||
|
if (!blobUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
imagePreview.urls = [blobUrl];
|
||||||
|
imagePreview.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClosePreview() {
|
||||||
|
imagePreview.urls.forEach(url => URL.revokeObjectURL(url));
|
||||||
|
imagePreview.urls = [];
|
||||||
|
imagePreview.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件名点击的统一入口:图片走预览,其余走下载 */
|
||||||
|
function handleOpen(item: Api.Project.AttachmentItem) {
|
||||||
|
if (isImage(item)) {
|
||||||
|
handlePreviewImage(item);
|
||||||
|
} else {
|
||||||
|
handleDownload(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 model 里的某项移除(折叠浮层里也用,不依赖索引) */
|
||||||
|
function removeAttachmentByFileId(fileId: string) {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idx = model.value.findIndex(item => item.fileId === fileId);
|
||||||
|
if (idx === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.pendingDeleteIds.add(fileId);
|
||||||
|
model.value = model.value.filter((_, i) => i !== idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(size?: number) {
|
||||||
|
if (!size && size !== 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (size < 1024) {
|
||||||
|
return `${size}B`;
|
||||||
|
}
|
||||||
|
if (size < 1024 * 1024) {
|
||||||
|
return `${(size / 1024).toFixed(1)}KB`;
|
||||||
|
}
|
||||||
|
if (size < 1024 * 1024 * 1024) {
|
||||||
|
return `${(size / 1024 / 1024).toFixed(1)}MB`;
|
||||||
|
}
|
||||||
|
return `${(size / 1024 / 1024 / 1024).toFixed(2)}GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除一批 fileId。fire-and-forget:
|
||||||
|
* - 不阻塞 UI;任何失败仅 console.warn
|
||||||
|
* - 后端返回 1001003001(文件不存在)视为成功
|
||||||
|
*/
|
||||||
|
async function deleteMany(ids: string[]) {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.allSettled(
|
||||||
|
ids.map(async id => {
|
||||||
|
const { error } = await deleteFile(id);
|
||||||
|
if (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[BusinessAttachmentUploader] 删除失败(已忽略)', id, error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 等关闭弹层时先等再清理。设上限 5s,避免极端网络下 commit/rollback 永久挂起。 */
|
||||||
|
async function waitForPending(maxWaitMs = 5000) {
|
||||||
|
const start = Date.now();
|
||||||
|
while (pending.value.length > 0) {
|
||||||
|
if (Date.now() - start >= maxWaitMs) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[BusinessAttachmentUploader] 等待 pending 上传超时,继续后续清理');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// polling: 需要在循环里 await,suppress 即可
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
setTimeout(resolve, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
/**
|
||||||
|
* 父组件在【打开弹层并填充 model 之后】调用。
|
||||||
|
* 把当前 model 视为 original,清空 added / pendingDelete,重置 committed。
|
||||||
|
*/
|
||||||
|
initSession() {
|
||||||
|
session.originalIds = new Set(model.value.map(item => item.fileId));
|
||||||
|
session.addedIds.clear();
|
||||||
|
session.pendingDeleteIds.clear();
|
||||||
|
session.committed = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 父组件在【业务保存成功后】调用。
|
||||||
|
* 真删 pendingDelete(含 original 和 added 两类);置 committed 阻止后续 rollback。
|
||||||
|
*/
|
||||||
|
async commit() {
|
||||||
|
await waitForPending();
|
||||||
|
const ids = Array.from(session.pendingDeleteIds);
|
||||||
|
session.pendingDeleteIds.clear();
|
||||||
|
session.addedIds.clear();
|
||||||
|
session.committed = true;
|
||||||
|
await deleteMany(ids);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 父组件取消/关闭时调用;onBeforeUnmount 也会兜底调一次。
|
||||||
|
* 真删 addedIds(保留 original);committed=true 时跳过。
|
||||||
|
*/
|
||||||
|
async rollback() {
|
||||||
|
if (session.committed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await waitForPending();
|
||||||
|
const ids = Array.from(session.addedIds);
|
||||||
|
session.addedIds.clear();
|
||||||
|
session.pendingDeleteIds.clear();
|
||||||
|
session.committed = true;
|
||||||
|
await deleteMany(ids);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 父组件在提交前可读此值判断是否还有 pending 上传 */
|
||||||
|
get hasUploading() {
|
||||||
|
return hasUploading.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
// 标记卸载中:让正在 flight 的 uploadOne 完成时知道要立刻删除自己
|
||||||
|
isUnmounting.value = true;
|
||||||
|
// 兜底:用户没显式 rollback 就直接关弹层 / 切路由 / unmount
|
||||||
|
// deleteMany 内部已 swallow 单项失败,这里不再 await,fire-and-forget
|
||||||
|
if (!session.committed) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
deleteMany(Array.from(session.addedIds));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="business-attachment-uploader">
|
||||||
|
<div v-if="!disabled" class="business-attachment-uploader__trigger">
|
||||||
|
<ElButton :icon="Upload" :disabled="isFull" :loading="hasUploading" @click="triggerSelect">点击上传</ElButton>
|
||||||
|
<span class="business-attachment-uploader__hint">
|
||||||
|
最多 {{ max }} 个,已选 {{ totalCount }} 个;单文件 ≤ {{ maxFileSizeMB }}MB
|
||||||
|
<ElTooltip placement="top">
|
||||||
|
<template #content>
|
||||||
|
<div class="business-attachment-uploader__hint-tooltip">
|
||||||
|
<div>{{ ALLOWED_EXTENSIONS_HINT }}</div>
|
||||||
|
<div class="business-attachment-uploader__hint-tooltip-ext">允许扩展名:{{ acceptExtensionsList }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ElIcon class="business-attachment-uploader__hint-icon"><QuestionFilled /></ElIcon>
|
||||||
|
</ElTooltip>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
class="business-attachment-uploader__input"
|
||||||
|
@change="handleFileChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="totalCount === 0" class="business-attachment-uploader__empty">暂无附件</div>
|
||||||
|
|
||||||
|
<ul v-if="totalCount > 0" class="business-attachment-uploader__list">
|
||||||
|
<!-- 直接展示:默认仅首项;flat 模式全部 -->
|
||||||
|
<li v-for="item in displayedAttachments" :key="`done-${item.fileId}`" class="business-attachment-uploader__item">
|
||||||
|
<ElIcon class="business-attachment-uploader__icon">
|
||||||
|
<Picture v-if="isImage(item)" />
|
||||||
|
<Document v-else />
|
||||||
|
</ElIcon>
|
||||||
|
<ElLink
|
||||||
|
type="primary"
|
||||||
|
underline="never"
|
||||||
|
class="business-attachment-uploader__name"
|
||||||
|
:title="item.name"
|
||||||
|
@click="handleOpen(item)"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</ElLink>
|
||||||
|
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span>
|
||||||
|
<ElLink type="primary" underline="never" @click="handleDownload(item)">下载</ElLink>
|
||||||
|
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- 折叠提示:>1 个时显示,hover 弹完整列表(flat 模式下永不出现) -->
|
||||||
|
<li v-if="popoverAttachments.length > 0" class="business-attachment-uploader__more-row">
|
||||||
|
<ElPopover
|
||||||
|
trigger="hover"
|
||||||
|
placement="bottom-start"
|
||||||
|
:width="380"
|
||||||
|
:show-after="200"
|
||||||
|
popper-class="business-attachment-uploader__popover"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<span class="business-attachment-uploader__more">
|
||||||
|
还有 {{ popoverAttachments.length }} 个附件
|
||||||
|
<ElIcon><ArrowDown /></ElIcon>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ul class="business-attachment-uploader__popover-list">
|
||||||
|
<li
|
||||||
|
v-for="item in popoverAttachments"
|
||||||
|
:key="`popover-${item.fileId}`"
|
||||||
|
class="business-attachment-uploader__item"
|
||||||
|
>
|
||||||
|
<ElIcon class="business-attachment-uploader__icon">
|
||||||
|
<Picture v-if="isImage(item)" />
|
||||||
|
<Document v-else />
|
||||||
|
</ElIcon>
|
||||||
|
<ElLink
|
||||||
|
type="primary"
|
||||||
|
underline="never"
|
||||||
|
class="business-attachment-uploader__name"
|
||||||
|
:title="item.name"
|
||||||
|
@click="handleOpen(item)"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</ElLink>
|
||||||
|
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span>
|
||||||
|
<ElLink type="primary" underline="never" @click="handleDownload(item)">下载</ElLink>
|
||||||
|
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</ElPopover>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- pending 项不折叠:让用户能持续看到上传进度 -->
|
||||||
|
<li
|
||||||
|
v-for="item in pending"
|
||||||
|
:key="`pending-${item.id}`"
|
||||||
|
class="business-attachment-uploader__item business-attachment-uploader__item--pending"
|
||||||
|
>
|
||||||
|
<ElIcon class="business-attachment-uploader__icon is-loading"><Loading /></ElIcon>
|
||||||
|
<span class="business-attachment-uploader__name" :title="item.name">{{ item.name }}</span>
|
||||||
|
<span class="business-attachment-uploader__status">上传中…</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ElImageViewer
|
||||||
|
v-if="imagePreview.visible"
|
||||||
|
:url-list="imagePreview.urls"
|
||||||
|
hide-on-click-modal
|
||||||
|
@close="handleClosePreview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.business-attachment-uploader {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__hint {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: rgb(100 116 139 / 88%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__hint-icon {
|
||||||
|
color: rgb(100 116 139 / 88%);
|
||||||
|
cursor: help;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__hint-tooltip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 320px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__hint-tooltip-ext {
|
||||||
|
word-break: break-all;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__empty {
|
||||||
|
color: rgb(100 116 139 / 88%);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&--pending {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
color: rgb(100 116 139 / 88%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__size {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: rgb(100 116 139 / 88%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__status {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: rgb(100 116 139 / 88%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__more-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__more {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
// 浮层非 scoped:popper 渲染到 body
|
||||||
|
.business-attachment-uploader__popover {
|
||||||
|
padding: 8px 4px !important;
|
||||||
|
|
||||||
|
.business-attachment-uploader__popover-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 280px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-attachment-uploader__size {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: rgb(100 116 139 / 88%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -49,8 +49,8 @@ const visible = defineModel<boolean>({
|
|||||||
|
|
||||||
const DIALOG_WIDTH_MAP: Record<DialogPreset, string> = {
|
const DIALOG_WIDTH_MAP: Record<DialogPreset, string> = {
|
||||||
sm: '520px',
|
sm: '520px',
|
||||||
md: '640px',
|
md: '720px',
|
||||||
lg: '720px'
|
lg: '960px'
|
||||||
};
|
};
|
||||||
|
|
||||||
const dialogWidth = computed(() => props.width ?? DIALOG_WIDTH_MAP[props.preset]);
|
const dialogWidth = computed(() => props.width ?? DIALOG_WIDTH_MAP[props.preset]);
|
||||||
|
|||||||
461
src/components/custom/business-rich-text-editor.vue
Normal file
461
src/components/custom/business-rich-text-editor.vue
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue';
|
||||||
|
import '@wangeditor/editor/dist/css/style.css';
|
||||||
|
import { ElImageViewer } from 'element-plus';
|
||||||
|
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
|
||||||
|
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
|
||||||
|
import { buildFileProxyUrl, deleteFile, uploadFile } from '@/service/api/file';
|
||||||
|
|
||||||
|
defineOptions({ name: 'BusinessRichTextEditor' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
height?: number | string;
|
||||||
|
/** 上传目录,传给后端 directory 字段 */
|
||||||
|
uploadDirectory?: string;
|
||||||
|
/** 单张图片大小上限(MB),默认 5 */
|
||||||
|
maxImageSizeMB?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
placeholder: '请输入内容',
|
||||||
|
disabled: false,
|
||||||
|
height: 320,
|
||||||
|
uploadDirectory: undefined,
|
||||||
|
maxImageSizeMB: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
const model = defineModel<string | null | undefined>({ default: '' });
|
||||||
|
|
||||||
|
const editorRef = shallowRef<IDomEditor>();
|
||||||
|
const containerRef = ref<HTMLElement>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片预览:
|
||||||
|
* - hover 富文本里的 <img> → 在图片右上角浮一个放大镜按钮
|
||||||
|
* - 点按钮 → ElImageViewer 多图模式,url-list = 当前 HTML 里所有 img src(按出现顺序去重)
|
||||||
|
* - 编辑态与 disabled 只读态共用
|
||||||
|
*/
|
||||||
|
const zoomBtnVisible = ref(false);
|
||||||
|
const zoomBtnStyle = ref<Record<string, string>>({});
|
||||||
|
const hoveredImageSrc = ref('');
|
||||||
|
|
||||||
|
const viewerVisible = ref(false);
|
||||||
|
const viewerUrlList = ref<string[]>([]);
|
||||||
|
const viewerIndex = ref(0);
|
||||||
|
|
||||||
|
let hideZoomBtnTimer: number | undefined;
|
||||||
|
|
||||||
|
function cancelHideZoomBtn() {
|
||||||
|
if (hideZoomBtnTimer !== undefined) {
|
||||||
|
window.clearTimeout(hideZoomBtnTimer);
|
||||||
|
hideZoomBtnTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleHideZoomBtn() {
|
||||||
|
cancelHideZoomBtn();
|
||||||
|
hideZoomBtnTimer = window.setTimeout(() => {
|
||||||
|
zoomBtnVisible.value = false;
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionZoomBtn(img: HTMLImageElement) {
|
||||||
|
const container = containerRef.value;
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const imgRect = img.getBoundingClientRect();
|
||||||
|
const btnSize = 28;
|
||||||
|
const gap = 8;
|
||||||
|
zoomBtnStyle.value = {
|
||||||
|
top: `${imgRect.top - containerRect.top + gap}px`,
|
||||||
|
left: `${imgRect.right - containerRect.left - btnSize - gap}px`
|
||||||
|
};
|
||||||
|
hoveredImageSrc.value = img.getAttribute('src') ?? '';
|
||||||
|
zoomBtnVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZoomBtn(el: EventTarget | null): boolean {
|
||||||
|
return el instanceof HTMLElement && Boolean(el.closest('.business-rich-text-editor__zoom-btn'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findImageAtPoint(e: MouseEvent): HTMLImageElement | null {
|
||||||
|
const container = containerRef.value;
|
||||||
|
if (!container) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
// 1) target 本身或祖先链上是 img
|
||||||
|
const direct =
|
||||||
|
target?.tagName === 'IMG' ? (target as HTMLImageElement) : (target?.closest('img') as HTMLImageElement | null);
|
||||||
|
if (direct && container.contains(direct)) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
// 2) 兜底:wangeditor 可能在图片上层叠了 resize/selection 遮罩,target 不是 img;用坐标穿透找
|
||||||
|
if (typeof document.elementsFromPoint === 'function') {
|
||||||
|
const stack = document.elementsFromPoint(e.clientX, e.clientY);
|
||||||
|
for (const el of stack) {
|
||||||
|
if (el.tagName === 'IMG' && container.contains(el)) {
|
||||||
|
return el as HTMLImageElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContainerMouseOver(e: MouseEvent) {
|
||||||
|
if (isZoomBtn(e.target)) {
|
||||||
|
cancelHideZoomBtn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const img = findImageAtPoint(e);
|
||||||
|
if (img) {
|
||||||
|
cancelHideZoomBtn();
|
||||||
|
positionZoomBtn(img);
|
||||||
|
} else {
|
||||||
|
scheduleHideZoomBtn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContainerMouseLeave() {
|
||||||
|
scheduleHideZoomBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTextScroll() {
|
||||||
|
// wangeditor 内部滚动后按钮坐标会和图片错位,直接隐藏由下次 hover 重算
|
||||||
|
zoomBtnVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImageViewer() {
|
||||||
|
if (!hoveredImageSrc.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const urls = listImageSrcs(model.value);
|
||||||
|
const idx = urls.indexOf(hoveredImageSrc.value);
|
||||||
|
viewerUrlList.value = urls.length > 0 ? urls : [hoveredImageSrc.value];
|
||||||
|
viewerIndex.value = idx >= 0 ? idx : 0;
|
||||||
|
viewerVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeImageViewer() {
|
||||||
|
viewerVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话级清理账本(富文本图片治标):
|
||||||
|
* - uploadedMap: 本次会话内通过 customUpload 上传成功的图片 url -> fileId
|
||||||
|
* - committed: commit() 调用后置 true,阻止后续 rollback / 卸载兜底重复删
|
||||||
|
*
|
||||||
|
* 真删时机:
|
||||||
|
* - commit(): 扫当前 model HTML,删 uploadedMap 里"url 已不在 HTML"的项(被用户删掉的图)
|
||||||
|
* - rollback(): 删 uploadedMap 里所有项(整个会话不要了)
|
||||||
|
* - onBeforeUnmount: 兜底走 rollback 等价逻辑
|
||||||
|
*/
|
||||||
|
interface RichTextSession {
|
||||||
|
uploadedMap: Map<string, string>;
|
||||||
|
committed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = reactive<RichTextSession>({
|
||||||
|
uploadedMap: new Map(),
|
||||||
|
committed: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolbarConfig: Partial<IToolbarConfig> = {
|
||||||
|
excludeKeys: [
|
||||||
|
// 视频组
|
||||||
|
'group-video',
|
||||||
|
'insertVideo',
|
||||||
|
'uploadVideo',
|
||||||
|
// 更多样式分组
|
||||||
|
'group-more-style',
|
||||||
|
// 图片:只允许本地上传,不允许插入网络图片 URL
|
||||||
|
'insertImage',
|
||||||
|
// 超链接:业务暂不需要
|
||||||
|
'insertLink',
|
||||||
|
'editLink',
|
||||||
|
'unLink',
|
||||||
|
'viewLink'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const editorConfig: Partial<IEditorConfig> = {
|
||||||
|
placeholder: props.placeholder,
|
||||||
|
readOnly: props.disabled,
|
||||||
|
MENU_CONF: {
|
||||||
|
uploadImage: {
|
||||||
|
maxFileSize: props.maxImageSizeMB * 1024 * 1024,
|
||||||
|
allowedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp'],
|
||||||
|
async customUpload(file: File, insertFn: (url: string, alt?: string, href?: string) => void) {
|
||||||
|
const result = await uploadFile(file, props.uploadDirectory);
|
||||||
|
|
||||||
|
if (result.error || !result.data) {
|
||||||
|
const msg = result.error?.response?.data?.msg || '图片上传失败';
|
||||||
|
window.$message?.error(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用永久代理路径塞 <img src>,不要用 result.data.url(24h 签名会过期)
|
||||||
|
const { id, configId, path } = result.data;
|
||||||
|
const proxyUrl = buildFileProxyUrl(configId, path);
|
||||||
|
// 记录 url -> fileId,后续 commit/rollback 才知道删哪个
|
||||||
|
session.uploadedMap.set(proxyUrl, id);
|
||||||
|
insertFn(proxyUrl, file.name, proxyUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.disabled,
|
||||||
|
value => {
|
||||||
|
const editor = editorRef.value;
|
||||||
|
if (!editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
editor.disable();
|
||||||
|
} else {
|
||||||
|
editor.enable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleCreated(editor: IDomEditor) {
|
||||||
|
editorRef.value = editor;
|
||||||
|
const textContainer = containerRef.value?.querySelector('.w-e-text-container');
|
||||||
|
textContainer?.addEventListener('scroll', onTextScroll, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 HTML 字符串里抓所有 <img src="...">,返回 url 集合。
|
||||||
|
* 用 regex 而不是 DOMParser 是为了避免对 SSR / 测试环境的依赖。
|
||||||
|
*/
|
||||||
|
function extractImageUrls(html: string | null | undefined): Set<string> {
|
||||||
|
const urls = new Set<string>();
|
||||||
|
if (!html) {
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
const re = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
|
||||||
|
let match: RegExpExecArray | null = re.exec(html);
|
||||||
|
while (match !== null) {
|
||||||
|
urls.add(match[1]);
|
||||||
|
match = re.exec(html);
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按出现顺序去重列出当前 HTML 内所有 img src,给 ElImageViewer 用。 */
|
||||||
|
function listImageSrcs(html: string | null | undefined): string[] {
|
||||||
|
const list: string[] = [];
|
||||||
|
if (!html) {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
const re = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
|
||||||
|
let match: RegExpExecArray | null = re.exec(html);
|
||||||
|
while (match !== null) {
|
||||||
|
if (!list.includes(match[1])) {
|
||||||
|
list.push(match[1]);
|
||||||
|
}
|
||||||
|
match = re.exec(html);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除一批 fileId。fire-and-forget;单项失败仅 console.warn。 */
|
||||||
|
async function deleteMany(ids: string[]) {
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.allSettled(
|
||||||
|
ids.map(async id => {
|
||||||
|
const { error } = await deleteFile(id);
|
||||||
|
if (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[BusinessRichTextEditor] 删除失败(已忽略)', id, error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
/**
|
||||||
|
* 父组件在【打开弹层并填充 model 之后】调用。
|
||||||
|
* 清空 uploadedMap 并重置 committed;HTML 里已有的图(编辑模式回显的)不进 uploadedMap,
|
||||||
|
* 因此 commit/rollback 不会动它们——只动本次会话上传的图。
|
||||||
|
*/
|
||||||
|
initSession() {
|
||||||
|
session.uploadedMap.clear();
|
||||||
|
session.committed = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 父组件在【业务保存成功后】调用。
|
||||||
|
* 扫当前 model HTML:uploadedMap 里 url 不在 HTML 的图 = 用户已删除 = 真删。
|
||||||
|
*/
|
||||||
|
async commit() {
|
||||||
|
const currentUrls = extractImageUrls(model.value);
|
||||||
|
const toDelete: string[] = [];
|
||||||
|
session.uploadedMap.forEach((fileId, url) => {
|
||||||
|
if (!currentUrls.has(url)) {
|
||||||
|
toDelete.push(fileId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
session.uploadedMap.clear();
|
||||||
|
session.committed = true;
|
||||||
|
await deleteMany(toDelete);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 父组件取消/关闭时调用;onBeforeUnmount 也会兜底调一次。
|
||||||
|
* 删 uploadedMap 里所有项(整个会话回滚)。
|
||||||
|
*/
|
||||||
|
async rollback() {
|
||||||
|
if (session.committed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toDelete = Array.from(session.uploadedMap.values());
|
||||||
|
session.uploadedMap.clear();
|
||||||
|
session.committed = true;
|
||||||
|
await deleteMany(toDelete);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cancelHideZoomBtn();
|
||||||
|
const textContainer = containerRef.value?.querySelector('.w-e-text-container');
|
||||||
|
textContainer?.removeEventListener('scroll', onTextScroll);
|
||||||
|
|
||||||
|
// 兜底:用户没显式 rollback 就直接关弹层 / 切路由 / unmount
|
||||||
|
if (!session.committed) {
|
||||||
|
const toDelete = Array.from(session.uploadedMap.values());
|
||||||
|
session.uploadedMap.clear();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
|
deleteMany(toDelete);
|
||||||
|
}
|
||||||
|
editorRef.value?.destroy();
|
||||||
|
editorRef.value = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 当 height 传 '100%' 或 'auto' 时启用「撑满父容器」模式 —— 父级必须有具体高度。 */
|
||||||
|
const isAutoFill = computed(() => props.height === '100%' || props.height === 'auto');
|
||||||
|
|
||||||
|
const containerClass = computed(() => ({
|
||||||
|
'business-rich-text-editor': true,
|
||||||
|
'business-rich-text-editor--auto-fill': isAutoFill.value
|
||||||
|
}));
|
||||||
|
|
||||||
|
const editorStyle = computed(() => {
|
||||||
|
if (isAutoFill.value) {
|
||||||
|
return { flex: 1, minHeight: 0, overflowY: 'hidden' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
height: typeof props.height === 'number' ? `${props.height}px` : props.height,
|
||||||
|
overflowY: 'hidden' as const
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="containerRef" :class="containerClass" @mouseover="onContainerMouseOver" @mouseleave="onContainerMouseLeave">
|
||||||
|
<Toolbar
|
||||||
|
class="business-rich-text-editor__toolbar"
|
||||||
|
:editor="editorRef"
|
||||||
|
:default-config="toolbarConfig"
|
||||||
|
mode="default"
|
||||||
|
/>
|
||||||
|
<Editor
|
||||||
|
v-model="model"
|
||||||
|
class="business-rich-text-editor__editor"
|
||||||
|
:style="editorStyle"
|
||||||
|
:default-config="editorConfig"
|
||||||
|
mode="default"
|
||||||
|
@on-created="handleCreated"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-show="zoomBtnVisible"
|
||||||
|
type="button"
|
||||||
|
class="business-rich-text-editor__zoom-btn"
|
||||||
|
:style="zoomBtnStyle"
|
||||||
|
title="预览图片"
|
||||||
|
aria-label="预览图片"
|
||||||
|
@click.stop="openImageViewer"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M10 2a8 8 0 1 1-5.29 14.04L1.4 19.36a1 1 0 1 1-1.4-1.4l3.32-3.32A8 8 0 0 1 10 2zm0 2a6 6 0 1 0 0 12 6 6 0 0 0 0-12zm1 3v2h2v2h-2v2H9v-2H7V9h2V7h2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<ElImageViewer
|
||||||
|
v-if="viewerVisible"
|
||||||
|
:url-list="viewerUrlList"
|
||||||
|
:initial-index="viewerIndex"
|
||||||
|
:z-index="3100"
|
||||||
|
teleported
|
||||||
|
hide-on-click-modal
|
||||||
|
@close="closeImageViewer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.business-rich-text-editor {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: var(--el-border-radius-base);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
|
||||||
|
&__toolbar {
|
||||||
|
border-bottom: 1px solid var(--el-border-color);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__editor {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--auto-fill {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__zoom-btn {
|
||||||
|
position: absolute;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* wangeditor 弹层(链接、图片菜单等)默认 z-index 偏低,提高一档避免被 ElDialog 遮挡 */
|
||||||
|
:deep(.w-e-modal),
|
||||||
|
:deep(.w-e-drop-panel),
|
||||||
|
:deep(.w-e-bar-divider),
|
||||||
|
:deep(.w-e-hover-bar) {
|
||||||
|
z-index: 3000 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
88
src/components/custom/business-rich-text-view.vue
Normal file
88
src/components/custom/business-rich-text-view.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { sanitizeHtml } from '@/utils/sanitize';
|
||||||
|
|
||||||
|
defineOptions({ name: 'BusinessRichTextView' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string | null;
|
||||||
|
emptyText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
value: '',
|
||||||
|
emptyText: '—'
|
||||||
|
});
|
||||||
|
|
||||||
|
const safeHtml = computed(() => sanitizeHtml(props.value));
|
||||||
|
const isEmpty = computed(() => !safeHtml.value || safeHtml.value.replace(/<[^>]+>/g, '').trim() === '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="business-rich-text-view">
|
||||||
|
<span v-if="isEmpty" class="business-rich-text-view__empty">{{ props.emptyText }}</span>
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div v-else class="business-rich-text-view__content" v-html="safeHtml" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.business-rich-text-view {
|
||||||
|
width: 100%;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
word-break: break-word;
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
:deep(p) {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(ul),
|
||||||
|
:deep(ol) {
|
||||||
|
padding-left: 24px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(blockquote) {
|
||||||
|
padding: 6px 12px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
border-left: 3px solid var(--el-border-color);
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(table) {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(table td),
|
||||||
|
:deep(table th) {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(a) {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { computed, defineComponent, ref } from 'vue';
|
import { computed, defineComponent, h, ref } from 'vue';
|
||||||
import type { PropType } from 'vue';
|
import type { Component, PropType } from 'vue';
|
||||||
import { ElButton, ElPopover } from 'element-plus';
|
import { ElButton, ElPopover, ElTooltip } from 'element-plus';
|
||||||
import { $t } from '@/locales';
|
import { $t } from '@/locales';
|
||||||
|
import IconMdiDotsHorizontal from '~icons/mdi/dots-horizontal';
|
||||||
|
import IconMdiChevronDown from '~icons/mdi/chevron-down';
|
||||||
|
|
||||||
export type BusinessTableAction = {
|
export type BusinessTableAction = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||||
|
icon?: Component;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onClick: () => void | Promise<void>;
|
onClick: () => void | Promise<void>;
|
||||||
};
|
};
|
||||||
@@ -17,12 +20,20 @@ export default defineComponent({
|
|||||||
actions: {
|
actions: {
|
||||||
type: Array as PropType<BusinessTableAction[]>,
|
type: Array as PropType<BusinessTableAction[]>,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
type: String as PropType<'button' | 'icon'>,
|
||||||
|
default: 'button'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const popoverVisible = ref(false);
|
const popoverVisible = ref(false);
|
||||||
|
|
||||||
const directActions = computed(() => {
|
const directActions = computed(() => {
|
||||||
|
if (props.variant === 'icon') {
|
||||||
|
return props.actions;
|
||||||
|
}
|
||||||
|
|
||||||
if (props.actions.length <= 2) {
|
if (props.actions.length <= 2) {
|
||||||
return props.actions;
|
return props.actions;
|
||||||
}
|
}
|
||||||
@@ -31,6 +42,10 @@ export default defineComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const moreActions = computed(() => {
|
const moreActions = computed(() => {
|
||||||
|
if (props.variant === 'icon') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
if (props.actions.length <= 2) {
|
if (props.actions.length <= 2) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -47,21 +62,86 @@ export default defineComponent({
|
|||||||
await action.onClick();
|
await action.onClick();
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => (
|
function renderIcon(action: BusinessTableAction) {
|
||||||
<div class="business-table-action-cell" onClick={event => event.stopPropagation()}>
|
if (!action.icon) return null;
|
||||||
{directActions.value.map(action => (
|
|
||||||
|
return h(action.icon, { class: 'business-table-action-icon' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderButtonAction(action: BusinessTableAction) {
|
||||||
|
return (
|
||||||
|
<ElButton
|
||||||
|
key={action.key}
|
||||||
|
plain
|
||||||
|
size="small"
|
||||||
|
type={action.buttonType}
|
||||||
|
disabled={action.disabled}
|
||||||
|
class="business-table-action-button"
|
||||||
|
onClick={() => handleAction(action)}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</ElButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIconAction(action: BusinessTableAction) {
|
||||||
|
return (
|
||||||
|
<ElTooltip key={action.key} content={action.label} placement="top">
|
||||||
<ElButton
|
<ElButton
|
||||||
key={action.key}
|
link
|
||||||
plain
|
|
||||||
size="small"
|
size="small"
|
||||||
type={action.buttonType}
|
type={action.buttonType}
|
||||||
disabled={action.disabled}
|
disabled={action.disabled}
|
||||||
class="business-table-action-button"
|
class="business-table-action-icon-button"
|
||||||
|
aria-label={action.label}
|
||||||
onClick={() => handleAction(action)}
|
onClick={() => handleAction(action)}
|
||||||
>
|
>
|
||||||
{action.label}
|
{renderIcon(action)}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
))}
|
</ElTooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMenuButton(action: BusinessTableAction) {
|
||||||
|
if (props.variant === 'icon') {
|
||||||
|
return (
|
||||||
|
<ElButton
|
||||||
|
key={action.key}
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
type={action.buttonType}
|
||||||
|
disabled={action.disabled}
|
||||||
|
class="business-table-action-menu__link"
|
||||||
|
onClick={() => handleAction(action)}
|
||||||
|
>
|
||||||
|
<span class="business-table-action-menu__item">
|
||||||
|
{renderIcon(action)}
|
||||||
|
<span>{action.label}</span>
|
||||||
|
</span>
|
||||||
|
</ElButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ElButton
|
||||||
|
key={action.key}
|
||||||
|
plain
|
||||||
|
size="small"
|
||||||
|
type={action.buttonType}
|
||||||
|
disabled={action.disabled}
|
||||||
|
class="business-table-action-menu__button"
|
||||||
|
onClick={() => handleAction(action)}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</ElButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => (
|
||||||
|
<div class="business-table-action-cell" onClick={event => event.stopPropagation()}>
|
||||||
|
{directActions.value.map(action =>
|
||||||
|
props.variant === 'icon' ? renderIconAction(action) : renderButtonAction(action)
|
||||||
|
)}
|
||||||
|
|
||||||
{moreActions.value.length > 0 && (
|
{moreActions.value.length > 0 && (
|
||||||
<ElPopover
|
<ElPopover
|
||||||
@@ -74,32 +154,28 @@ export default defineComponent({
|
|||||||
{{
|
{{
|
||||||
reference: () => (
|
reference: () => (
|
||||||
<ElButton
|
<ElButton
|
||||||
plain
|
link={props.variant === 'icon'}
|
||||||
|
plain={props.variant !== 'icon'}
|
||||||
size="small"
|
size="small"
|
||||||
class="business-table-action-button"
|
class={
|
||||||
|
props.variant === 'icon' ? 'business-table-action-icon-button' : 'business-table-action-button'
|
||||||
|
}
|
||||||
|
aria-label={$t('common.more')}
|
||||||
onClick={event => event.stopPropagation()}
|
onClick={event => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<span class="inline-flex items-center gap-4px">
|
{props.variant === 'icon' ? (
|
||||||
{$t('common.more')}
|
<IconMdiDotsHorizontal class="business-table-action-icon" />
|
||||||
<icon-mdi-chevron-down class="text-14px" />
|
) : (
|
||||||
</span>
|
<span class="inline-flex items-center gap-4px">
|
||||||
|
{$t('common.more')}
|
||||||
|
<IconMdiChevronDown class="text-14px" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
),
|
),
|
||||||
default: () => (
|
default: () => (
|
||||||
<div class="business-table-action-menu">
|
<div class="business-table-action-menu">
|
||||||
{moreActions.value.map(action => (
|
{moreActions.value.map(action => renderMenuButton(action))}
|
||||||
<ElButton
|
|
||||||
key={action.key}
|
|
||||||
plain
|
|
||||||
size="small"
|
|
||||||
type={action.buttonType}
|
|
||||||
disabled={action.disabled}
|
|
||||||
class="business-table-action-menu__button"
|
|
||||||
onClick={() => handleAction(action)}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</ElButton>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
938
src/components/custom/business-user-picker.vue
Normal file
938
src/components/custom/business-user-picker.vue
Normal file
@@ -0,0 +1,938 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useFormItem } from 'element-plus';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
import { usePickerSelection } from './business-user-picker/composables/use-picker-selection';
|
||||||
|
import { useDeptSource } from './business-user-picker/composables/use-dept-source';
|
||||||
|
import { useChainSource } from './business-user-picker/composables/use-chain-source';
|
||||||
|
import UserPickerTrigger from './business-user-picker/components/user-picker-trigger.vue';
|
||||||
|
import IconEpOfficeBuilding from '~icons/ep/office-building';
|
||||||
|
import IconEpUser from '~icons/ep/user';
|
||||||
|
|
||||||
|
defineOptions({ name: 'BusinessUserPicker' });
|
||||||
|
|
||||||
|
type Source = 'dept' | 'chain' | 'all';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userOptions: Api.SystemManage.UserSimple[];
|
||||||
|
sources?: Source[];
|
||||||
|
multiple?: boolean;
|
||||||
|
disabledUserIds?: readonly string[];
|
||||||
|
excludeUserIds?: readonly string[];
|
||||||
|
disabledLabel?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
title?: string;
|
||||||
|
dialogWidth?: string;
|
||||||
|
confirmText?: string;
|
||||||
|
triggerSize?: 'default' | 'small' | 'large';
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
sources: () => ['dept', 'chain', 'all'],
|
||||||
|
multiple: false,
|
||||||
|
disabledUserIds: () => [],
|
||||||
|
excludeUserIds: () => [],
|
||||||
|
disabledLabel: '',
|
||||||
|
placeholder: '请选择用户',
|
||||||
|
title: '选择用户',
|
||||||
|
dialogWidth: '820px',
|
||||||
|
confirmText: '',
|
||||||
|
triggerSize: 'default',
|
||||||
|
disabled: false
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'change', value: string | string[] | null): void;
|
||||||
|
(e: 'confirm', payload: { userIds: string[] }): void;
|
||||||
|
(e: 'cancel'): void;
|
||||||
|
}
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const model = defineModel<string | string[] | null>({ default: null });
|
||||||
|
const visible = defineModel<boolean>('visible', { default: false });
|
||||||
|
|
||||||
|
const { formItem } = useFormItem();
|
||||||
|
|
||||||
|
const source = ref<Source>(props.sources[0] ?? 'all');
|
||||||
|
const currentNodeId = ref<string | null>(null);
|
||||||
|
const treeSearch = ref('');
|
||||||
|
const userSearch = ref('');
|
||||||
|
const hideAdded = ref(false);
|
||||||
|
|
||||||
|
const disabledUserIdSet = computed(() => new Set(props.disabledUserIds.map(String)));
|
||||||
|
const excludeUserIdSet = computed(() => new Set(props.excludeUserIds.map(String)));
|
||||||
|
|
||||||
|
const selection = usePickerSelection(() => ({ multiple: props.multiple }));
|
||||||
|
const deptSource = useDeptSource(
|
||||||
|
() => props.userOptions,
|
||||||
|
() => new Set(selection.selectedIds.value),
|
||||||
|
() => disabledUserIdSet.value
|
||||||
|
);
|
||||||
|
const chainSource = useChainSource(
|
||||||
|
() => new Set(selection.selectedIds.value),
|
||||||
|
() => disabledUserIdSet.value
|
||||||
|
);
|
||||||
|
|
||||||
|
const showTabs = computed(() => props.sources.length > 1);
|
||||||
|
|
||||||
|
const userByIdMap = computed(() => new Map(props.userOptions.map(u => [String(u.id), u])));
|
||||||
|
|
||||||
|
const committedIds = computed<string[]>(() => {
|
||||||
|
const value = model.value;
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(String);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string' && value) {
|
||||||
|
return [value];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedUsers = computed(() =>
|
||||||
|
committedIds.value.map(id => userByIdMap.value.get(id)).filter((u): u is Api.SystemManage.UserSimple => Boolean(u))
|
||||||
|
);
|
||||||
|
|
||||||
|
const lockedSelectedIds = computed(() => selection.selectedIds.value.filter(id => disabledUserIdSet.value.has(id)));
|
||||||
|
|
||||||
|
const visibleSelectedIds = computed(() => selection.selectedIds.value.slice(0, 4));
|
||||||
|
const overflowSelectedCount = computed(() => Math.max(0, selection.size.value - 4));
|
||||||
|
const overflowSelectedIds = computed(() => selection.selectedIds.value.slice(4));
|
||||||
|
const overflowPopoverVisible = ref(false);
|
||||||
|
const overflowReferenceEl = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
function handleOverflowOutsideClick(e: MouseEvent) {
|
||||||
|
if (!overflowPopoverVisible.value) return;
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (!target) return;
|
||||||
|
if (target.closest('.user-picker__overflow-popper')) return;
|
||||||
|
if (target.closest('.el-popper')) return;
|
||||||
|
if (overflowReferenceEl.value?.contains(target)) return;
|
||||||
|
overflowPopoverVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('mousedown', handleOverflowOutsideClick, true));
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('mousedown', handleOverflowOutsideClick, true));
|
||||||
|
|
||||||
|
function getUserById(uid: string) {
|
||||||
|
return userByIdMap.value.get(uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleUserIds(): string[] {
|
||||||
|
let pool: string[];
|
||||||
|
if (source.value === 'all' || !currentNodeId.value) {
|
||||||
|
pool = props.userOptions.map(u => String(u.id));
|
||||||
|
} else if (source.value === 'dept') {
|
||||||
|
const node = deptSource.findNode(deptSource.tree.value, currentNodeId.value);
|
||||||
|
pool = node ? deptSource.getNodeUserIds(node) : props.userOptions.map(u => String(u.id));
|
||||||
|
} else {
|
||||||
|
const node = chainSource.findNode(chainSource.tree.value, currentNodeId.value);
|
||||||
|
pool = node ? chainSource.getNodeUserIds(node) : props.userOptions.map(u => String(u.id));
|
||||||
|
}
|
||||||
|
return pool.filter(id => !excludeUserIdSet.value.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredUserIds = computed(() => {
|
||||||
|
let ids = visibleUserIds();
|
||||||
|
if (hideAdded.value) ids = ids.filter(id => !disabledUserIdSet.value.has(id));
|
||||||
|
const kw = userSearch.value.trim().toLowerCase();
|
||||||
|
if (kw) {
|
||||||
|
ids = ids.filter(id => {
|
||||||
|
const u = getUserById(id);
|
||||||
|
if (!u) return false;
|
||||||
|
return (
|
||||||
|
u.nickname.toLowerCase().includes(kw) ||
|
||||||
|
(u.username ?? '').toLowerCase().includes(kw) ||
|
||||||
|
(u.deptName ?? '').toLowerCase().includes(kw)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function switchSource(next: Source) {
|
||||||
|
if (source.value === next) return;
|
||||||
|
source.value = next;
|
||||||
|
currentNodeId.value = null;
|
||||||
|
treeSearch.value = '';
|
||||||
|
if (next === 'dept') await deptSource.ensureLoaded();
|
||||||
|
else if (next === 'chain') await chainSource.ensureLoaded();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeptNodeClick(data: Api.SystemManage.DeptSimple) {
|
||||||
|
currentNodeId.value = deptSource.nodeKey(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChainNodeClick(data: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||||
|
currentNodeId.value = chainSource.nodeKey(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDeptCheck(node: Api.SystemManage.DeptSimple) {
|
||||||
|
if (!props.multiple) return;
|
||||||
|
const ids = deptSource.getNodeUserIds(node).filter(id => !disabledUserIdSet.value.has(id));
|
||||||
|
const state = deptSource.getNodeCheckState(node);
|
||||||
|
if (state === 'all') selection.removeMany(ids);
|
||||||
|
else selection.addMany(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleChainCheck(node: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||||
|
if (!props.multiple) return;
|
||||||
|
const ids = chainSource.getNodeUserIds(node).filter(id => !disabledUserIdSet.value.has(id));
|
||||||
|
const state = chainSource.getNodeCheckState(node);
|
||||||
|
if (state === 'all') selection.removeMany(ids);
|
||||||
|
else selection.addMany(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUser(uid: string) {
|
||||||
|
if (disabledUserIdSet.value.has(uid)) return;
|
||||||
|
selection.toggle(uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
selection.clear(lockedSelectedIds.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearUserFilter() {
|
||||||
|
userSearch.value = '';
|
||||||
|
hideAdded.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDisabled = computed(() => {
|
||||||
|
if (!props.multiple) return !selection.selectedIds.value.length;
|
||||||
|
return selection.size.value === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolvedConfirmText = computed(() => {
|
||||||
|
if (props.confirmText) return props.confirmText;
|
||||||
|
if (!props.multiple) return '确定';
|
||||||
|
return `确定(${selection.size.value})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (confirmDisabled.value) return;
|
||||||
|
const value = selection.commit();
|
||||||
|
model.value = value;
|
||||||
|
emit('change', value);
|
||||||
|
emit('confirm', { userIds: selection.selectedIds.value });
|
||||||
|
visible.value = false;
|
||||||
|
nextTick(() => {
|
||||||
|
formItem?.validate?.('change').catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
emit('cancel');
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDialog() {
|
||||||
|
visible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(visible, async value => {
|
||||||
|
if (value) {
|
||||||
|
treeSearch.value = '';
|
||||||
|
userSearch.value = '';
|
||||||
|
hideAdded.value = false;
|
||||||
|
currentNodeId.value = null;
|
||||||
|
source.value = props.sources[0] ?? 'all';
|
||||||
|
selection.reset(model.value);
|
||||||
|
if (source.value === 'dept') await deptSource.ensureLoaded();
|
||||||
|
else if (source.value === 'chain') await chainSource.ensureLoaded();
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="business-user-picker">
|
||||||
|
<slot name="trigger" :open="openDialog" :selected-users="selectedUsers" :disabled="disabled">
|
||||||
|
<UserPickerTrigger
|
||||||
|
:selected-users="selectedUsers"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:multiple="multiple"
|
||||||
|
:disabled="disabled"
|
||||||
|
:size="triggerSize"
|
||||||
|
@open="openDialog"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
|
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="title"
|
||||||
|
preset="lg"
|
||||||
|
:width="dialogWidth"
|
||||||
|
max-body-height="540px"
|
||||||
|
:confirm-disabled="confirmDisabled"
|
||||||
|
:confirm-text="resolvedConfirmText"
|
||||||
|
@confirm="handleConfirm"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
>
|
||||||
|
<div class="user-picker">
|
||||||
|
<div v-if="showTabs" class="user-picker__tabs">
|
||||||
|
<button
|
||||||
|
v-for="tab in sources"
|
||||||
|
:key="tab"
|
||||||
|
class="user-picker__tab"
|
||||||
|
:class="{ 'is-active': source === tab }"
|
||||||
|
type="button"
|
||||||
|
@click="switchSource(tab)"
|
||||||
|
>
|
||||||
|
{{ tab === 'dept' ? '部门' : tab === 'chain' ? '团队' : '全部用户' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="user-picker__picker" :class="{ 'is-single': source === 'all' }">
|
||||||
|
<div v-if="source !== 'all'" class="user-picker__col user-picker__col--tree">
|
||||||
|
<div class="user-picker__col-head">{{ source === 'dept' ? '部门' : '团队' }}</div>
|
||||||
|
<div class="user-picker__search">
|
||||||
|
<ElInput
|
||||||
|
v-model="treeSearch"
|
||||||
|
size="small"
|
||||||
|
clearable
|
||||||
|
:placeholder="source === 'dept' ? '搜索部门…' : '搜索成员…'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-loading="source === 'dept' ? deptSource.loading.value : chainSource.loading.value"
|
||||||
|
class="user-picker__col-body"
|
||||||
|
>
|
||||||
|
<ElTree
|
||||||
|
v-if="source === 'dept'"
|
||||||
|
:data="deptSource.filterByKeyword(treeSearch)"
|
||||||
|
:props="deptSource.treeProps.value"
|
||||||
|
node-key="id"
|
||||||
|
:expand-on-click-node="false"
|
||||||
|
:default-expand-all="true"
|
||||||
|
:indent="14"
|
||||||
|
class="user-picker__tree"
|
||||||
|
@node-click="handleDeptNodeClick"
|
||||||
|
>
|
||||||
|
<template #default="{ data }">
|
||||||
|
<div class="user-picker__node" :class="{ 'is-active': currentNodeId === String(data.id) }">
|
||||||
|
<span
|
||||||
|
v-if="multiple"
|
||||||
|
class="user-picker__node-check"
|
||||||
|
:class="{
|
||||||
|
'is-checked': deptSource.getNodeCheckState(data) === 'all',
|
||||||
|
'is-partial': deptSource.getNodeCheckState(data) === 'partial'
|
||||||
|
}"
|
||||||
|
@click.stop="toggleDeptCheck(data)"
|
||||||
|
/>
|
||||||
|
<IconEpOfficeBuilding class="user-picker__node-icon" />
|
||||||
|
<span class="user-picker__node-label">{{ data.name }}</span>
|
||||||
|
<span v-if="deptSource.getMetaText(data)" class="user-picker__node-meta">
|
||||||
|
{{ deptSource.getMetaText(data) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTree>
|
||||||
|
<ElTree
|
||||||
|
v-else
|
||||||
|
:data="chainSource.filterByKeyword(treeSearch)"
|
||||||
|
:props="chainSource.treeProps.value"
|
||||||
|
node-key="userId"
|
||||||
|
:expand-on-click-node="false"
|
||||||
|
:default-expand-all="true"
|
||||||
|
:indent="14"
|
||||||
|
class="user-picker__tree"
|
||||||
|
@node-click="handleChainNodeClick"
|
||||||
|
>
|
||||||
|
<template #default="{ data }">
|
||||||
|
<div class="user-picker__node" :class="{ 'is-active': currentNodeId === chainSource.nodeKey(data) }">
|
||||||
|
<span
|
||||||
|
v-if="multiple"
|
||||||
|
class="user-picker__node-check"
|
||||||
|
:class="{
|
||||||
|
'is-checked': chainSource.getNodeCheckState(data) === 'all',
|
||||||
|
'is-partial': chainSource.getNodeCheckState(data) === 'partial'
|
||||||
|
}"
|
||||||
|
@click.stop="toggleChainCheck(data)"
|
||||||
|
/>
|
||||||
|
<IconEpUser class="user-picker__node-icon" />
|
||||||
|
<span class="user-picker__node-label">{{ data.userNickname }}</span>
|
||||||
|
<span v-if="chainSource.getMetaText(data)" class="user-picker__node-meta">
|
||||||
|
{{ chainSource.getMetaText(data) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTree>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="user-picker__col user-picker__col--users">
|
||||||
|
<div class="user-picker__col-head user-picker__col-head--user">
|
||||||
|
<span>
|
||||||
|
候选用户(
|
||||||
|
<span>{{ filteredUserIds.length }}</span>
|
||||||
|
人)
|
||||||
|
</span>
|
||||||
|
<label v-if="multiple" class="user-picker__hide-added">
|
||||||
|
<ElCheckbox v-model="hideAdded">隐藏已添加</ElCheckbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="user-picker__search">
|
||||||
|
<ElInput
|
||||||
|
v-model="userSearch"
|
||||||
|
size="small"
|
||||||
|
clearable
|
||||||
|
:placeholder="source === 'all' ? '搜索用户名 / 部门…' : '搜索用户名…'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="user-picker__col-body">
|
||||||
|
<div v-if="!filteredUserIds.length" class="user-picker__empty">
|
||||||
|
该节点下没有匹配用户
|
||||||
|
<button
|
||||||
|
v-if="userSearch || hideAdded"
|
||||||
|
type="button"
|
||||||
|
class="user-picker__link user-picker__empty-action"
|
||||||
|
@click="clearUserFilter"
|
||||||
|
>
|
||||||
|
清除筛选条件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="uid in filteredUserIds"
|
||||||
|
:key="uid"
|
||||||
|
class="user-picker__user-row"
|
||||||
|
:class="{
|
||||||
|
'is-disabled': disabledUserIdSet.has(uid),
|
||||||
|
'is-selected': !multiple && selection.has(uid)
|
||||||
|
}"
|
||||||
|
@click="toggleUser(uid)"
|
||||||
|
>
|
||||||
|
<span v-if="multiple" class="user-picker__node-check" :class="{ 'is-checked': selection.has(uid) }" />
|
||||||
|
<span class="user-picker__user-avatar">{{ (getUserById(uid)?.nickname ?? '?').slice(0, 1) }}</span>
|
||||||
|
<div class="user-picker__user-main">
|
||||||
|
<div class="user-picker__user-name">{{ getUserById(uid)?.nickname }}</div>
|
||||||
|
</div>
|
||||||
|
<span v-if="disabledUserIdSet.has(uid) && disabledLabel" class="user-picker__user-tag">
|
||||||
|
{{ disabledLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="multiple" class="user-picker__selected">
|
||||||
|
<div class="user-picker__selected-head">
|
||||||
|
<span>
|
||||||
|
已选
|
||||||
|
<strong>{{ selection.size.value }}</strong>
|
||||||
|
人
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="selection.size.value > lockedSelectedIds.length"
|
||||||
|
type="button"
|
||||||
|
class="user-picker__link user-picker__link--danger"
|
||||||
|
@click="clearAll"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="selection.size.value === 0" class="user-picker__selected-empty">从左侧勾选用户后会出现在这里</div>
|
||||||
|
<div v-else class="user-picker__chips">
|
||||||
|
<span v-for="uid in visibleSelectedIds" :key="uid" class="user-picker__chip">
|
||||||
|
<span class="user-picker__chip-name">
|
||||||
|
{{ getUserById(uid)?.nickname }}
|
||||||
|
<ElTooltip v-if="disabledUserIdSet.has(uid) && disabledLabel" :content="disabledLabel" placement="top">
|
||||||
|
<span class="user-picker__chip-lock">·{{ disabledLabel }}</span>
|
||||||
|
</ElTooltip>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="!disabledUserIdSet.has(uid)"
|
||||||
|
type="button"
|
||||||
|
class="user-picker__chip-x"
|
||||||
|
@click="toggleUser(uid)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<ElPopover
|
||||||
|
v-if="overflowSelectedCount > 0"
|
||||||
|
:visible="overflowPopoverVisible"
|
||||||
|
placement="top-end"
|
||||||
|
:width="360"
|
||||||
|
popper-class="user-picker__overflow-popper"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<button
|
||||||
|
ref="overflowReferenceEl"
|
||||||
|
type="button"
|
||||||
|
class="user-picker__chip-more"
|
||||||
|
@click="overflowPopoverVisible = !overflowPopoverVisible"
|
||||||
|
>
|
||||||
|
+{{ overflowSelectedCount }} 更多
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<div class="user-picker__overflow-head">
|
||||||
|
<span>
|
||||||
|
另外
|
||||||
|
<strong>{{ overflowSelectedCount }}</strong>
|
||||||
|
人
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-picker__overflow-chips">
|
||||||
|
<span v-for="uid in overflowSelectedIds" :key="uid" class="user-picker__chip">
|
||||||
|
<span class="user-picker__chip-name">
|
||||||
|
{{ getUserById(uid)?.nickname }}
|
||||||
|
<ElTooltip
|
||||||
|
v-if="disabledUserIdSet.has(uid) && disabledLabel"
|
||||||
|
:content="disabledLabel"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<span class="user-picker__chip-lock">·{{ disabledLabel }}</span>
|
||||||
|
</ElTooltip>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="!disabledUserIdSet.has(uid)"
|
||||||
|
type="button"
|
||||||
|
class="user-picker__chip-x"
|
||||||
|
@click="toggleUser(uid)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ElPopover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.business-user-picker {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* picker 内容上下贴满,标准 body padding 显得空——仅在含本组件的 dialog 上收紧 */
|
||||||
|
:deep(.business-form-dialog__body:has(.user-picker)) {
|
||||||
|
padding-top: 8px !important;
|
||||||
|
padding-bottom: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__tab {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__tab.is-active {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
border-bottom-color: var(--el-color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
height: min(280px, 44vh);
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__picker.is-single {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__col-head {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color);
|
||||||
|
background: #fafbfc;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__col-head--user {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__col-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__search {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__tree {
|
||||||
|
padding: 4px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__tree :deep(.el-tree-node__content) {
|
||||||
|
height: 32px;
|
||||||
|
padding-right: 8px !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__tree :deep(.el-tree-node__content:hover) {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__tree :deep(.el-tree-node__expand-icon) {
|
||||||
|
padding: 4px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__tree :deep(.el-tree-node__expand-icon.is-leaf) {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__node {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__node.is-active {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__node-check {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__node-check:hover {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__node-check.is-checked {
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__node-check.is-checked::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
left: 4px;
|
||||||
|
width: 3px;
|
||||||
|
height: 7px;
|
||||||
|
border: solid #fff;
|
||||||
|
border-width: 0 1px 1px 0;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__node-check.is-partial {
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__node-check.is-partial::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 8px;
|
||||||
|
height: 2px;
|
||||||
|
margin: -1px 0 0 -4px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__node-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__node.is-active .user-picker__node-icon {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__node-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__node-meta {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-left: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__node.is-active .user-picker__node-meta {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__user-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 10px;
|
||||||
|
height: 36px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__user-row:hover {
|
||||||
|
background: var(--el-fill-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__user-row.is-disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__user-row.is-disabled:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__user-row.is-selected {
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__user-row.is-selected .user-picker__user-name {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__user-avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #c7d2fe, #93c5fd);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__user-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__user-name {
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__user-tag {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--el-color-warning-light-7);
|
||||||
|
color: var(--el-color-warning-dark-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__empty {
|
||||||
|
padding: 40px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__hide-added {
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__empty-action {
|
||||||
|
display: block;
|
||||||
|
margin: 6px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__selected {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__selected-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__selected-head strong {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__selected-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 26px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 4px 2px 8px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--el-border-color-darker);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__chip-name {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__chip-lock {
|
||||||
|
color: var(--el-color-warning-dark-2);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__chip-x {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--el-fill-color);
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__chip-x:hover {
|
||||||
|
background: var(--el-color-danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__chip-more {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px dashed var(--el-border-color-darker);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-size: 11.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__chip-more:hover {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__overflow-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__overflow-head strong {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__overflow-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__link {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11.5px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__link--danger {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker__link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'UserPickerTrigger' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedUsers: Api.SystemManage.UserSimple[];
|
||||||
|
placeholder: string;
|
||||||
|
multiple: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
size: 'default' | 'small' | 'large';
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<{ (e: 'open'): void }>();
|
||||||
|
|
||||||
|
const displayText = computed(() => {
|
||||||
|
if (!props.selectedUsers.length) return '';
|
||||||
|
if (!props.multiple) return props.selectedUsers[0]?.nickname ?? '';
|
||||||
|
const head = props.selectedUsers
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(u => u.nickname)
|
||||||
|
.join('、');
|
||||||
|
const rest = props.selectedUsers.length - 2;
|
||||||
|
return rest > 0 ? `${head} +${rest}` : head;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sizeClass = computed(() => `is-${props.size}`);
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (props.disabled) return;
|
||||||
|
emit('open');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="user-picker-trigger"
|
||||||
|
:class="[sizeClass, { 'is-disabled': disabled }]"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="handleClick"
|
||||||
|
@keydown.enter.prevent="handleClick"
|
||||||
|
@keydown.space.prevent="handleClick"
|
||||||
|
>
|
||||||
|
<span v-if="displayText" class="user-picker-trigger__text">{{ displayText }}</span>
|
||||||
|
<span v-else class="user-picker-trigger__placeholder">{{ placeholder }}</span>
|
||||||
|
<span class="user-picker-trigger__suffix">
|
||||||
|
<icon-ep:arrow-down />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-picker-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 30px 0 11px;
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: var(--el-border-radius-base);
|
||||||
|
font-size: var(--el-font-size-base);
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker-trigger.is-small {
|
||||||
|
min-height: 24px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker-trigger.is-large {
|
||||||
|
min-height: 40px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker-trigger:hover:not(.is-disabled) {
|
||||||
|
border-color: var(--el-border-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker-trigger:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker-trigger.is-disabled {
|
||||||
|
background: var(--el-disabled-bg-color);
|
||||||
|
color: var(--el-disabled-text-color);
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: var(--el-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker-trigger__text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker-trigger__placeholder {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-picker-trigger__suffix {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 14px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { fetchGetUserManagementRelationTree } from '@/service/api';
|
||||||
|
import type { TreeCheckState } from './use-dept-source';
|
||||||
|
|
||||||
|
type ChainNode = Api.SystemManage.UserManagementRelationTreeRespVO;
|
||||||
|
|
||||||
|
export function useChainSource(selectedIds: () => Set<string>, disabledUserIdSet: () => Set<string>) {
|
||||||
|
const tree = ref<ChainNode[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
async function ensureLoaded() {
|
||||||
|
if (loaded) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchGetUserManagementRelationTree({ fromUserIndex: false });
|
||||||
|
tree.value = data ?? [];
|
||||||
|
loaded = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeKey(node: ChainNode): string {
|
||||||
|
return node.id ?? `chain_${node.userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeUserIds(node: ChainNode): string[] {
|
||||||
|
const ids = new Set<string>([String(node.userId)]);
|
||||||
|
if (node.children) {
|
||||||
|
for (const c of node.children) {
|
||||||
|
for (const id of getNodeUserIds(c)) ids.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...ids];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeCheckState(node: ChainNode): TreeCheckState {
|
||||||
|
const ids = getNodeUserIds(node).filter(id => !disabledUserIdSet().has(id));
|
||||||
|
if (!ids.length) return 'none';
|
||||||
|
const sel = ids.filter(id => selectedIds().has(id)).length;
|
||||||
|
if (sel === 0) return 'none';
|
||||||
|
if (sel === ids.length) return 'all';
|
||||||
|
return 'partial';
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNode(list: ChainNode[], key: string): ChainNode | null {
|
||||||
|
for (const n of list) {
|
||||||
|
if (nodeKey(n) === key) return n;
|
||||||
|
if (n.children) {
|
||||||
|
const r = findNode(n.children, key);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchKeyword(node: ChainNode, kw: string): boolean {
|
||||||
|
if (!kw) return true;
|
||||||
|
if (node.userNickname.toLowerCase().includes(kw)) return true;
|
||||||
|
if (node.children) return node.children.some(c => matchKeyword(c, kw));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByKeyword(kw: string) {
|
||||||
|
const lower = kw.trim().toLowerCase();
|
||||||
|
if (!lower) return tree.value;
|
||||||
|
return tree.value.filter(n => matchKeyword(n, lower));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetaText(node: ChainNode): string {
|
||||||
|
const total = getNodeUserIds(node).length;
|
||||||
|
return total > 1 ? `${total} 人` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeProps = computed(() => ({ children: 'children', label: 'userNickname' }) as const);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tree,
|
||||||
|
loading,
|
||||||
|
treeProps,
|
||||||
|
ensureLoaded,
|
||||||
|
getNodeUserIds,
|
||||||
|
getNodeCheckState,
|
||||||
|
findNode,
|
||||||
|
filterByKeyword,
|
||||||
|
getMetaText,
|
||||||
|
nodeKey
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { fetchGetDeptSimpleList } from '@/service/api';
|
||||||
|
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||||
|
|
||||||
|
export type TreeCheckState = 'none' | 'partial' | 'all';
|
||||||
|
|
||||||
|
export function useDeptSource(
|
||||||
|
userOptions: () => Api.SystemManage.UserSimple[],
|
||||||
|
selectedIds: () => Set<string>,
|
||||||
|
disabledUserIdSet: () => Set<string>
|
||||||
|
) {
|
||||||
|
const tree = ref<Api.SystemManage.DeptSimple[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
async function ensureLoaded() {
|
||||||
|
if (loaded) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchGetDeptSimpleList();
|
||||||
|
tree.value = data ? buildMenuTree(data) : [];
|
||||||
|
loaded = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDeptIds(node: Api.SystemManage.DeptSimple): string[] {
|
||||||
|
const ids: string[] = [String(node.id)];
|
||||||
|
if (node.children) {
|
||||||
|
for (const c of node.children) ids.push(...collectDeptIds(c));
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeUserIds(node: Api.SystemManage.DeptSimple): string[] {
|
||||||
|
const deptIds = new Set(collectDeptIds(node));
|
||||||
|
return userOptions()
|
||||||
|
.filter(u => u.deptId !== null && u.deptId !== undefined && deptIds.has(String(u.deptId)))
|
||||||
|
.map(u => String(u.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeCheckState(node: Api.SystemManage.DeptSimple): TreeCheckState {
|
||||||
|
const ids = getNodeUserIds(node).filter(id => !disabledUserIdSet().has(id));
|
||||||
|
if (!ids.length) return 'none';
|
||||||
|
const sel = ids.filter(id => selectedIds().has(id)).length;
|
||||||
|
if (sel === 0) return 'none';
|
||||||
|
if (sel === ids.length) return 'all';
|
||||||
|
return 'partial';
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNode(list: Api.SystemManage.DeptSimple[], key: string): Api.SystemManage.DeptSimple | null {
|
||||||
|
for (const n of list) {
|
||||||
|
if (String(n.id) === key) return n;
|
||||||
|
if (n.children) {
|
||||||
|
const r = findNode(n.children, key);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchKeyword(node: Api.SystemManage.DeptSimple, kw: string): boolean {
|
||||||
|
if (!kw) return true;
|
||||||
|
if (node.name.toLowerCase().includes(kw)) return true;
|
||||||
|
if (node.children) return node.children.some(c => matchKeyword(c, kw));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByKeyword(kw: string) {
|
||||||
|
const lower = kw.trim().toLowerCase();
|
||||||
|
if (!lower) return tree.value;
|
||||||
|
return tree.value.filter(n => matchKeyword(n, lower));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetaText(node: Api.SystemManage.DeptSimple): string {
|
||||||
|
const total = getNodeUserIds(node).length;
|
||||||
|
return total > 0 ? `${total} 人` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeKey(node: Api.SystemManage.DeptSimple): string {
|
||||||
|
return String(node.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeProps = computed(() => ({ children: 'children', label: 'name' }) as const);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tree,
|
||||||
|
loading,
|
||||||
|
treeProps,
|
||||||
|
ensureLoaded,
|
||||||
|
getNodeUserIds,
|
||||||
|
getNodeCheckState,
|
||||||
|
findNode,
|
||||||
|
filterByKeyword,
|
||||||
|
getMetaText,
|
||||||
|
nodeKey
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
export interface PickerSelectionOptions {
|
||||||
|
multiple: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePickerSelection(options: () => PickerSelectionOptions) {
|
||||||
|
const multiSet = ref<Set<string>>(new Set());
|
||||||
|
const singleId = ref<string | null>(null);
|
||||||
|
|
||||||
|
const multiple = computed(() => options().multiple);
|
||||||
|
|
||||||
|
function has(userId: string): boolean {
|
||||||
|
if (multiple.value) return multiSet.value.has(userId);
|
||||||
|
return singleId.value === userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(userId: string) {
|
||||||
|
if (multiple.value) {
|
||||||
|
if (multiSet.value.has(userId)) multiSet.value.delete(userId);
|
||||||
|
else multiSet.value.add(userId);
|
||||||
|
multiSet.value = new Set(multiSet.value);
|
||||||
|
} else {
|
||||||
|
singleId.value = singleId.value === userId ? null : userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMany(userIds: readonly string[]) {
|
||||||
|
if (!multiple.value) {
|
||||||
|
singleId.value = userIds[0] ?? singleId.value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const id of userIds) multiSet.value.add(id);
|
||||||
|
multiSet.value = new Set(multiSet.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMany(userIds: readonly string[]) {
|
||||||
|
if (!multiple.value) {
|
||||||
|
if (singleId.value && userIds.includes(singleId.value)) singleId.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const id of userIds) multiSet.value.delete(id);
|
||||||
|
multiSet.value = new Set(multiSet.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(preserveIds?: readonly string[]) {
|
||||||
|
const keep = new Set((preserveIds ?? []).map(String));
|
||||||
|
if (multiple.value) {
|
||||||
|
const next = new Set<string>();
|
||||||
|
for (const id of multiSet.value) {
|
||||||
|
if (keep.has(id)) next.add(id);
|
||||||
|
}
|
||||||
|
multiSet.value = next;
|
||||||
|
} else if (singleId.value && !keep.has(singleId.value)) singleId.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(initial: string | string[] | null | undefined) {
|
||||||
|
if (multiple.value) {
|
||||||
|
const ids = Array.isArray(initial) ? initial.map(String) : [];
|
||||||
|
multiSet.value = new Set(ids);
|
||||||
|
} else {
|
||||||
|
singleId.value = typeof initial === 'string' ? initial : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIds = computed<string[]>(() => {
|
||||||
|
if (multiple.value) return [...multiSet.value];
|
||||||
|
return singleId.value ? [singleId.value] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const size = computed(() => selectedIds.value.length);
|
||||||
|
|
||||||
|
function commit(): string | string[] | null {
|
||||||
|
if (multiple.value) return [...multiSet.value];
|
||||||
|
return singleId.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedIds,
|
||||||
|
size,
|
||||||
|
has,
|
||||||
|
toggle,
|
||||||
|
addMany,
|
||||||
|
removeMany,
|
||||||
|
clear,
|
||||||
|
reset,
|
||||||
|
commit
|
||||||
|
};
|
||||||
|
}
|
||||||
131
src/components/custom/business-user-select.vue
Normal file
131
src/components/custom/business-user-select.vue
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'BusinessUserSelect' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
options: Api.SystemManage.UserSimple[];
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
clearable?: boolean;
|
||||||
|
disabledUserIds?: readonly string[];
|
||||||
|
excludeUserIds?: readonly string[];
|
||||||
|
disabledLabel?: string;
|
||||||
|
noDataText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
placeholder: '请选择用户',
|
||||||
|
disabled: false,
|
||||||
|
clearable: true,
|
||||||
|
disabledUserIds: () => [],
|
||||||
|
excludeUserIds: () => [],
|
||||||
|
disabledLabel: '',
|
||||||
|
noDataText: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const model = defineModel<string | null>('modelValue', {
|
||||||
|
default: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
const disabledUserIdSet = computed(() => new Set(props.disabledUserIds.map(id => String(id))));
|
||||||
|
const excludeUserIdSet = computed(() => new Set(props.excludeUserIds.map(id => String(id))));
|
||||||
|
|
||||||
|
const visibleOptions = computed(() => {
|
||||||
|
const keyword = searchKeyword.value.trim().toLocaleLowerCase();
|
||||||
|
const options = props.options.filter(item => !excludeUserIdSet.value.has(String(item.id)));
|
||||||
|
|
||||||
|
if (!keyword) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
return options.filter(item => {
|
||||||
|
const searchText = [item.nickname, item.username, item.deptName, item.id]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLocaleLowerCase();
|
||||||
|
|
||||||
|
return searchText.includes(keyword);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFilter(value: string) {
|
||||||
|
searchKeyword.value = value;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElSelect
|
||||||
|
v-model="model"
|
||||||
|
class="w-full"
|
||||||
|
filterable
|
||||||
|
:filter-method="handleFilter"
|
||||||
|
:clearable="clearable"
|
||||||
|
:disabled="disabled"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:no-data-text="noDataText || undefined"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="item in visibleOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.nickname"
|
||||||
|
:value="item.id"
|
||||||
|
:disabled="disabledUserIdSet.has(String(item.id))"
|
||||||
|
>
|
||||||
|
<div class="business-user-select__option">
|
||||||
|
<span class="business-user-select__name">{{ item.nickname }}</span>
|
||||||
|
<span class="business-user-select__suffix">
|
||||||
|
<ElTag
|
||||||
|
v-if="disabledLabel && disabledUserIdSet.has(String(item.id))"
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
effect="light"
|
||||||
|
disable-transitions
|
||||||
|
>
|
||||||
|
{{ disabledLabel }}
|
||||||
|
</ElTag>
|
||||||
|
<span v-if="item.deptName || item.username" class="business-user-select__meta">
|
||||||
|
{{ [item.username, item.deptName].filter(Boolean).join(' · ') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ElOption>
|
||||||
|
</ElSelect>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.business-user-select__option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-user-select__name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: rgb(15 23 42 / 94%);
|
||||||
|
font-weight: 500;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-user-select__suffix {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: 58%;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-user-select__meta {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: rgb(100 116 139 / 88%);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
84
src/components/custom/current-user-role-tags.vue
Normal file
84
src/components/custom/current-user-role-tags.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { ElTag } from 'element-plus';
|
||||||
|
|
||||||
|
defineOptions({ name: 'CurrentUserRoleTags' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 当前登录用户在该对象(产品/项目)的角色;无角色为 []。后端只读计算字段,随登录身份变化 */
|
||||||
|
roles?: Api.Common.CurrentUserRole[] | null;
|
||||||
|
/** 无业务角色时的占位文案 */
|
||||||
|
emptyText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), { roles: () => [], emptyText: '--' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色族按 roleKey 后缀匹配。
|
||||||
|
*
|
||||||
|
* 后端 roleKey 为域前缀风格:product_manager / project_manager / product_creator /
|
||||||
|
* project_creator / *_observer 等(见 src/constants/business.ts 的经理 code);
|
||||||
|
* 文档示例里的裸 creator / implicit_observer 不是真实 key,故不能按字面量精确匹配。
|
||||||
|
*/
|
||||||
|
function isManagerRole(roleKey: string) {
|
||||||
|
return /manager$/i.test(roleKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCreatorRole(roleKey: string) {
|
||||||
|
return /creator$/i.test(roleKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 系统隐式角色:创建者 / 隐式观察者,弱化为淡灰标签 */
|
||||||
|
function isMuted(roleKey: string) {
|
||||||
|
return isCreatorRole(roleKey) || /observer$/i.test(roleKey) || roleKey.includes('implicit');
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = computed(() => {
|
||||||
|
const roles = props.roles ?? [];
|
||||||
|
// 当前用户是产品经理 / 项目经理时,隐藏创建者标签(隐式观察者不受影响)
|
||||||
|
const hasManager = roles.some(role => isManagerRole(role.roleKey));
|
||||||
|
const visibleRoles = hasManager ? roles.filter(role => !isCreatorRole(role.roleKey)) : roles;
|
||||||
|
|
||||||
|
return visibleRoles.map((role, index) => ({
|
||||||
|
key: `${role.roleKey}-${index}`,
|
||||||
|
roleName: role.roleName,
|
||||||
|
muted: isMuted(role.roleKey)
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="items.length" class="current-user-role-tags">
|
||||||
|
<ElTag
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.key"
|
||||||
|
size="small"
|
||||||
|
effect="plain"
|
||||||
|
round
|
||||||
|
:type="item.muted ? 'info' : 'primary'"
|
||||||
|
:class="{ 'current-user-role-tags__muted': item.muted }"
|
||||||
|
>
|
||||||
|
{{ item.roleName }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
<span v-else class="current-user-role-tags__empty">{{ emptyText }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.current-user-role-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
// 列设置 align="center" 只影响文本流;flex 容器需显式居中标签
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 隐式角色(创建者 / 隐式观察者)弱化:在灰色 info 标签基础上再降透明度
|
||||||
|
.current-user-role-tags__muted {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-user-role-tags__empty {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
|
import { useDictStore } from '@/store/modules/dict';
|
||||||
import { useDict } from '@/hooks/business/dict';
|
import { useDict } from '@/hooks/business/dict';
|
||||||
|
|
||||||
defineOptions({ name: 'DictSelect' });
|
defineOptions({ name: 'DictSelect' });
|
||||||
|
|
||||||
|
const ensuredEmptyDictCodes = new Set<string>();
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dictCode: string;
|
dictCode: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@@ -14,6 +17,8 @@ interface Props {
|
|||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
collapseTags?: boolean;
|
collapseTags?: boolean;
|
||||||
collapseTagsTooltip?: boolean;
|
collapseTagsTooltip?: boolean;
|
||||||
|
/** 下拉项右侧追加字典 remark 中文释义(优先级等需要"P0 → 紧急"对照的场景) */
|
||||||
|
showRemark?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -24,29 +29,53 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
onlyEnabled: true,
|
onlyEnabled: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
collapseTags: false,
|
collapseTags: false,
|
||||||
collapseTagsTooltip: false
|
collapseTagsTooltip: false,
|
||||||
|
showRemark: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const model = defineModel<string | number | Array<string | number> | null | undefined>({
|
const model = defineModel<string | number | Array<string | number> | null | undefined>({
|
||||||
default: undefined
|
default: undefined
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dictStore = useDictStore();
|
||||||
const { enabledDictData, dictData } = useDict(() => props.dictCode);
|
const { enabledDictData, dictData } = useDict(() => props.dictCode);
|
||||||
|
|
||||||
const dictOptions = computed(() => {
|
const dictOptions = computed(() => {
|
||||||
const source = props.onlyEnabled ? enabledDictData.value : dictData.value;
|
const source = props.onlyEnabled ? enabledDictData.value : dictData.value;
|
||||||
|
|
||||||
return source.map(item => ({
|
return source.map(item => ({
|
||||||
label: item.label,
|
label: item.label,
|
||||||
value: item.value
|
value: item.value,
|
||||||
|
colorType: item.colorType ?? null,
|
||||||
|
remark: item.remark ?? null
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 单选时取当前选中项的 colorType,用于触发器 prefix 色块
|
||||||
|
const selectedColorType = computed<string | null>(() => {
|
||||||
|
if (props.multiple) return null;
|
||||||
|
const value = model.value;
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
return dictOptions.value.find(opt => opt.value === value)?.colorType ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.dictCode, dictOptions.value.length, dictStore.initialized, dictStore.loading] as const,
|
||||||
|
async ([dictCode, optionCount, initialized, loading]) => {
|
||||||
|
if (!dictCode || optionCount > 0 || !initialized || loading || ensuredEmptyDictCodes.has(dictCode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensuredEmptyDictCodes.add(dictCode);
|
||||||
|
await dictStore.ensureDictData(dictCode, true);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ElSelect
|
<ElSelect
|
||||||
v-model="model"
|
v-model="model"
|
||||||
class="w-full"
|
class="dict-select w-full"
|
||||||
:placeholder="props.placeholder"
|
:placeholder="props.placeholder"
|
||||||
:disabled="props.disabled"
|
:disabled="props.disabled"
|
||||||
:clearable="props.clearable"
|
:clearable="props.clearable"
|
||||||
@@ -55,8 +84,51 @@ const dictOptions = computed(() => {
|
|||||||
:collapse-tags="props.collapseTags"
|
:collapse-tags="props.collapseTags"
|
||||||
:collapse-tags-tooltip="props.collapseTagsTooltip"
|
:collapse-tags-tooltip="props.collapseTagsTooltip"
|
||||||
>
|
>
|
||||||
<ElOption v-for="item in dictOptions" :key="item.value" :label="item.label" :value="item.value" />
|
<template v-if="selectedColorType" #prefix>
|
||||||
|
<span class="dict-select__color-dot" :style="{ background: selectedColorType }" />
|
||||||
|
</template>
|
||||||
|
<ElOption v-for="item in dictOptions" :key="item.value" :label="item.label" :value="item.value">
|
||||||
|
<span class="dict-select__option">
|
||||||
|
<span
|
||||||
|
v-if="item.colorType"
|
||||||
|
class="dict-select__color-dot dict-select__color-dot--option"
|
||||||
|
:style="{ background: item.colorType }"
|
||||||
|
/>
|
||||||
|
<span class="dict-select__option-label">{{ item.label }}</span>
|
||||||
|
<span v-if="props.showRemark && item.remark" class="dict-select__option-remark">{{ item.remark }}</span>
|
||||||
|
</span>
|
||||||
|
</ElOption>
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
.dict-select__color-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
vertical-align: middle;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-select__color-dot--option {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-select__option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-select__option-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-select__option-remark {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useDict } from '@/hooks/business/dict';
|
||||||
import DictText from './dict-text.vue';
|
import DictText from './dict-text.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'DictTag' });
|
defineOptions({ name: 'DictTag' });
|
||||||
@@ -14,6 +16,7 @@ interface Props {
|
|||||||
fallback?: string;
|
fallback?: string;
|
||||||
separator?: string;
|
separator?: string;
|
||||||
onlyEnabled?: boolean;
|
onlyEnabled?: boolean;
|
||||||
|
/** 显式传入时优先;不传则按字典 item.colorType 自动取色 */
|
||||||
type?: DictTagType;
|
type?: DictTagType;
|
||||||
effect?: DictTagEffect;
|
effect?: DictTagEffect;
|
||||||
size?: DictTagSize;
|
size?: DictTagSize;
|
||||||
@@ -30,10 +33,54 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
size: 'default',
|
size: 'default',
|
||||||
round: false
|
round: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { getItem } = useDict(() => props.dictCode);
|
||||||
|
|
||||||
|
// 单值才支持自动取色;多值(数组)走默认渲染避免歧义
|
||||||
|
const autoColorType = computed<string | null>(() => {
|
||||||
|
if (Array.isArray(props.value)) return null;
|
||||||
|
if (props.value === null || props.value === undefined || props.value === '') return null;
|
||||||
|
return getItem(props.value, { onlyEnabled: props.onlyEnabled })?.colorType ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// props.type 优先(向后兼容);其次字典 colorType(hex);都没有时回落到原生 ElTag 默认
|
||||||
|
const hexColor = computed(() => (props.type ? null : autoColorType.value));
|
||||||
|
|
||||||
|
const tagStyle = computed<Record<string, string> | null>(() => {
|
||||||
|
if (!hexColor.value) return null;
|
||||||
|
// light 效果:浅底 + 主色字 + 中浅边;plain/dark 同样的色调思路,仅明度差异
|
||||||
|
const fg = hexColor.value;
|
||||||
|
if (props.effect === 'dark') {
|
||||||
|
return {
|
||||||
|
color: '#fff',
|
||||||
|
background: fg,
|
||||||
|
borderColor: fg
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (props.effect === 'plain') {
|
||||||
|
return {
|
||||||
|
color: fg,
|
||||||
|
background: 'transparent',
|
||||||
|
borderColor: `color-mix(in srgb, ${fg} 50%, white)`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// light(默认)
|
||||||
|
return {
|
||||||
|
color: fg,
|
||||||
|
background: `color-mix(in srgb, ${fg} 12%, white)`,
|
||||||
|
borderColor: `color-mix(in srgb, ${fg} 30%, white)`
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ElTag :type="props.type" :effect="props.effect" :size="props.size" :round="props.round">
|
<ElTag
|
||||||
|
:type="props.type"
|
||||||
|
:effect="props.effect"
|
||||||
|
:size="props.size"
|
||||||
|
:round="props.round"
|
||||||
|
:style="tagStyle ?? undefined"
|
||||||
|
>
|
||||||
<DictText
|
<DictText
|
||||||
:dict-code="props.dictCode"
|
:dict-code="props.dictCode"
|
||||||
:value="props.value"
|
:value="props.value"
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
import { $t } from '@/locales';
|
import { $t } from '@/locales';
|
||||||
|
|
||||||
defineOptions({ name: 'LookForward' });
|
defineOptions({ name: 'LookForward' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -10,7 +17,10 @@ defineOptions({ name: 'LookForward' });
|
|||||||
<SvgIcon local-icon="expectation" />
|
<SvgIcon local-icon="expectation" />
|
||||||
</div>
|
</div>
|
||||||
<slot>
|
<slot>
|
||||||
<h3 class="text-28px text-primary font-500">{{ $t('common.lookForward') }}</h3>
|
<h3 class="text-28px text-primary font-500">{{ title ?? $t('common.lookForward') }}</h3>
|
||||||
|
</slot>
|
||||||
|
<slot name="subtitle">
|
||||||
|
<p v-if="subtitle" class="text-14px text-base-text op-65">{{ subtitle }}</p>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
108
src/components/custom/subordinate-selector.vue
Normal file
108
src/components/custom/subordinate-selector.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({ name: 'SubordinateSelector' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
loading?: boolean;
|
||||||
|
data?: Api.SystemManage.MySubordinateTreeNode | null;
|
||||||
|
emptyText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
loading: false,
|
||||||
|
data: null,
|
||||||
|
emptyText: '暂无下属数据'
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedUserId = defineModel<string | null>('selectedUserId', {
|
||||||
|
default: null
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleNodeClick(node: Api.SystemManage.MySubordinateTreeNode) {
|
||||||
|
selectedUserId.value = node.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodeLabel(node: Api.SystemManage.MySubordinateTreeNode) {
|
||||||
|
const label = node.isRoot ? '全部下属' : node.userNickname;
|
||||||
|
return `${label}${node.subordinateCount ? `(${node.subordinateCount})` : ''}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElCard class="subordinate-selector" body-class="subordinate-selector__body">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between gap-12px">
|
||||||
|
<span class="text-14px font-600">团队成员</span>
|
||||||
|
<ElTag v-if="props.data" effect="plain">{{ props.data.subordinateCount }}</ElTag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-loading="props.loading" class="subordinate-selector__content">
|
||||||
|
<ElEmpty v-if="!props.data" :image-size="88" :description="props.emptyText" />
|
||||||
|
<ElTree
|
||||||
|
v-else
|
||||||
|
:data="[props.data]"
|
||||||
|
node-key="userId"
|
||||||
|
:current-node-key="selectedUserId || undefined"
|
||||||
|
:props="{ label: 'userNickname', children: 'children' }"
|
||||||
|
highlight-current
|
||||||
|
:default-expanded-keys="[props.data.userId]"
|
||||||
|
expand-on-click-node
|
||||||
|
class="subordinate-selector__tree"
|
||||||
|
@node-click="handleNodeClick"
|
||||||
|
>
|
||||||
|
<template #default="{ data: node }">
|
||||||
|
<span class="subordinate-selector__node-label">{{ renderNodeLabel(node) }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTree>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.subordinate-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.subordinate-selector__body) {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subordinate-selector__content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 240px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subordinate-selector__tree {
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subordinate-selector__node-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.subordinate-selector__tree .el-tree-node__content) {
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.subordinate-selector__tree .el-tree-node__content:hover) {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.subordinate-selector__tree .el-tree-node.is-current > .el-tree-node__content) {
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
335
src/components/custom/table-search-fields.vue
Normal file
335
src/components/custom/table-search-fields.vue
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import type { VNode } from 'vue';
|
||||||
|
import { ElButton, ElCol, ElDatePicker, ElFormItem, ElInput, ElOption, ElRow, ElSelect } from 'element-plus';
|
||||||
|
import DictSelect from './dict-select.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'TableSearchFields' });
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchField {
|
||||||
|
/** 字段键名 */
|
||||||
|
key: string;
|
||||||
|
/** 字段标签 */
|
||||||
|
label: string;
|
||||||
|
/** 字段类型 */
|
||||||
|
type: 'input' | 'select' | 'date' | 'dateRange' | 'dict';
|
||||||
|
/** date 字段的日期粒度 */
|
||||||
|
dateType?: 'date' | 'month';
|
||||||
|
/** dateRange 字段的日期范围粒度 */
|
||||||
|
dateRangeType?: 'daterange' | 'monthrange';
|
||||||
|
/** 日期字段提交格式 */
|
||||||
|
valueFormat?: string;
|
||||||
|
/** 占位列数,默认 1 */
|
||||||
|
span?: number;
|
||||||
|
/** select 类型的选项 */
|
||||||
|
options?: Option[];
|
||||||
|
/** dict 类型的字典编码 */
|
||||||
|
dictCode?: string;
|
||||||
|
/** dict 类型下拉项右侧追加字典 remark 释义(如优先级 "P0 → 紧急") */
|
||||||
|
showRemark?: boolean;
|
||||||
|
/** 占位提示文本 */
|
||||||
|
placeholder?: string;
|
||||||
|
/** select 类型的自定义选项渲染函数 */
|
||||||
|
renderOption?: (option: Option) => VNode | VNode[] | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** 绑定表单数据对象 */
|
||||||
|
modelValue: Record<string, any>;
|
||||||
|
/** 查询字段定义数组 */
|
||||||
|
fields: SearchField[];
|
||||||
|
/** 每行格子数(按钮占 1 格) */
|
||||||
|
columns: number;
|
||||||
|
/** 表单标签宽度 */
|
||||||
|
labelWidth?: string | number;
|
||||||
|
/** 格子间距 */
|
||||||
|
gutter?: number;
|
||||||
|
/** 是否禁用 */
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
labelWidth: 80,
|
||||||
|
gutter: 16,
|
||||||
|
disabled: false
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'search'): void;
|
||||||
|
(e: 'reset'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 折叠/展开状态
|
||||||
|
const expanded = ref(false);
|
||||||
|
|
||||||
|
// 是否需要折叠(字段数 > columns - 1)
|
||||||
|
const needsCollapse = computed(() => props.fields.length > props.columns - 1);
|
||||||
|
|
||||||
|
// 第一行字段数(留一个位置给按钮)
|
||||||
|
const firstRowFieldCount = computed(() => props.columns - 1);
|
||||||
|
|
||||||
|
// 计算第一行字段
|
||||||
|
const firstRowFields = computed(() => {
|
||||||
|
if (expanded.value || !needsCollapse.value) {
|
||||||
|
return props.fields.slice(0, firstRowFieldCount.value);
|
||||||
|
}
|
||||||
|
return props.fields.slice(0, firstRowFieldCount.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算后续行字段(用于展开后显示)
|
||||||
|
const remainingFields = computed(() => {
|
||||||
|
if (expanded.value || !needsCollapse.value) {
|
||||||
|
return props.fields.slice(firstRowFieldCount.value);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstRowButtonSpan = computed(() => {
|
||||||
|
return Math.floor(24 / props.columns);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算第一行字段的 span(字段和按钮区保持同一列宽)
|
||||||
|
const firstRowFieldSpan = computed(() => {
|
||||||
|
return firstRowButtonSpan.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算每个字段的 span(用于后续行)
|
||||||
|
const fieldSpan = computed(() => {
|
||||||
|
return Math.floor(24 / props.columns);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 字段不足时补足首行空列,确保按钮区始终落在 columns 定义的最后一格。
|
||||||
|
const firstRowPlaceholderSpan = computed(() => {
|
||||||
|
const emptySlotCount = Math.max(props.columns - 1 - firstRowFields.value.length, 0);
|
||||||
|
return emptySlotCount * fieldSpan.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleToggle() {
|
||||||
|
expanded.value = !expanded.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
emit('reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
emit('search');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- eslint-disable vue/no-mutating-props -->
|
||||||
|
<template>
|
||||||
|
<ElCard class="card-wrapper">
|
||||||
|
<ElForm :model="props.modelValue" :label-width="props.labelWidth" @submit.prevent @keyup.enter="handleSearch">
|
||||||
|
<!-- 第一行:fields + 按钮 -->
|
||||||
|
<ElRow :gutter="props.gutter">
|
||||||
|
<ElCol
|
||||||
|
v-for="field in firstRowFields"
|
||||||
|
:key="field.key"
|
||||||
|
class="table-search-fields__col"
|
||||||
|
:span="firstRowFieldSpan"
|
||||||
|
>
|
||||||
|
<ElFormItem :label="field.label">
|
||||||
|
<ElInput
|
||||||
|
v-if="field.type === 'input'"
|
||||||
|
:model-value="props.modelValue[field.key]"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
clearable
|
||||||
|
:disabled="props.disabled"
|
||||||
|
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||||
|
/>
|
||||||
|
<ElSelect
|
||||||
|
v-else-if="field.type === 'select'"
|
||||||
|
:model-value="props.modelValue[field.key]"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
clearable
|
||||||
|
:disabled="props.disabled"
|
||||||
|
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||||
|
>
|
||||||
|
<ElOption v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value">
|
||||||
|
<template v-if="field.renderOption" #default>
|
||||||
|
<component :is="field.renderOption(opt)" />
|
||||||
|
</template>
|
||||||
|
</ElOption>
|
||||||
|
</ElSelect>
|
||||||
|
<ElDatePicker
|
||||||
|
v-else-if="field.type === 'date'"
|
||||||
|
:model-value="props.modelValue[field.key]"
|
||||||
|
:type="field.dateType || 'date'"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
clearable
|
||||||
|
:disabled="props.disabled"
|
||||||
|
:value-format="field.valueFormat || 'YYYY-MM-DD'"
|
||||||
|
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||||
|
/>
|
||||||
|
<ElDatePicker
|
||||||
|
v-else-if="field.type === 'dateRange'"
|
||||||
|
:model-value="props.modelValue[field.key]"
|
||||||
|
:type="field.dateRangeType || 'daterange'"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
clearable
|
||||||
|
:disabled="props.disabled"
|
||||||
|
:value-format="field.valueFormat || 'YYYY-MM-DD'"
|
||||||
|
:start-placeholder="field.dateRangeType === 'monthrange' ? '开始月份' : '开始日期'"
|
||||||
|
:end-placeholder="field.dateRangeType === 'monthrange' ? '结束月份' : '结束日期'"
|
||||||
|
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||||
|
/>
|
||||||
|
<DictSelect
|
||||||
|
v-else-if="field.type === 'dict'"
|
||||||
|
:model-value="props.modelValue[field.key]"
|
||||||
|
:dict-code="field.dictCode!"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
:disabled="props.disabled"
|
||||||
|
:show-remark="field.showRemark"
|
||||||
|
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
|
||||||
|
<ElCol
|
||||||
|
v-if="firstRowPlaceholderSpan > 0"
|
||||||
|
class="table-search-fields__col table-search-fields__placeholder-col"
|
||||||
|
:span="firstRowPlaceholderSpan"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 按钮区域 -->
|
||||||
|
<ElCol class="table-search-fields__col table-search-fields__action-col" :span="firstRowButtonSpan">
|
||||||
|
<ElFormItem class="table-search-fields__actions" label-width="0">
|
||||||
|
<ElButton
|
||||||
|
v-if="needsCollapse"
|
||||||
|
circle
|
||||||
|
:title="expanded ? '收起' : '展开'"
|
||||||
|
:aria-label="expanded ? '收起查询条件' : '展开查询条件'"
|
||||||
|
:disabled="props.disabled"
|
||||||
|
@click="handleToggle"
|
||||||
|
>
|
||||||
|
<icon-mdi-chevron-double-up v-if="expanded" />
|
||||||
|
<icon-mdi-chevron-double-down v-else />
|
||||||
|
</ElButton>
|
||||||
|
<ElButton :disabled="props.disabled" @click="handleReset">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-refresh class="text-icon" />
|
||||||
|
</template>
|
||||||
|
重置
|
||||||
|
</ElButton>
|
||||||
|
<ElButton type="primary" :disabled="props.disabled" @click="handleSearch">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-search class="text-icon" />
|
||||||
|
</template>
|
||||||
|
查询
|
||||||
|
</ElButton>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
</ElRow>
|
||||||
|
|
||||||
|
<!-- 展开后的后续行 -->
|
||||||
|
<ElRow v-if="expanded && remainingFields.length > 0" :gutter="props.gutter">
|
||||||
|
<ElCol v-for="field in remainingFields" :key="field.key" class="table-search-fields__col" :span="fieldSpan">
|
||||||
|
<ElFormItem :label="field.label">
|
||||||
|
<ElInput
|
||||||
|
v-if="field.type === 'input'"
|
||||||
|
:model-value="props.modelValue[field.key]"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
clearable
|
||||||
|
:disabled="props.disabled"
|
||||||
|
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||||
|
/>
|
||||||
|
<ElSelect
|
||||||
|
v-else-if="field.type === 'select'"
|
||||||
|
:model-value="props.modelValue[field.key]"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
clearable
|
||||||
|
:disabled="props.disabled"
|
||||||
|
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||||
|
>
|
||||||
|
<ElOption v-for="opt in field.options" :key="opt.value" :label="opt.label" :value="opt.value">
|
||||||
|
<template v-if="field.renderOption" #default>
|
||||||
|
<component :is="field.renderOption(opt)" />
|
||||||
|
</template>
|
||||||
|
</ElOption>
|
||||||
|
</ElSelect>
|
||||||
|
<ElDatePicker
|
||||||
|
v-else-if="field.type === 'date'"
|
||||||
|
:model-value="props.modelValue[field.key]"
|
||||||
|
:type="field.dateType || 'date'"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
clearable
|
||||||
|
:disabled="props.disabled"
|
||||||
|
:value-format="field.valueFormat || 'YYYY-MM-DD'"
|
||||||
|
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||||
|
/>
|
||||||
|
<ElDatePicker
|
||||||
|
v-else-if="field.type === 'dateRange'"
|
||||||
|
:model-value="props.modelValue[field.key]"
|
||||||
|
:type="field.dateRangeType || 'daterange'"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
clearable
|
||||||
|
:disabled="props.disabled"
|
||||||
|
:value-format="field.valueFormat || 'YYYY-MM-DD'"
|
||||||
|
:start-placeholder="field.dateRangeType === 'monthrange' ? '开始月份' : '开始日期'"
|
||||||
|
:end-placeholder="field.dateRangeType === 'monthrange' ? '结束月份' : '结束日期'"
|
||||||
|
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||||
|
/>
|
||||||
|
<DictSelect
|
||||||
|
v-else-if="field.type === 'dict'"
|
||||||
|
:model-value="props.modelValue[field.key]"
|
||||||
|
:dict-code="field.dictCode!"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
:disabled="props.disabled"
|
||||||
|
:show-remark="field.showRemark"
|
||||||
|
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
</ElRow>
|
||||||
|
</ElForm>
|
||||||
|
</ElCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-search-fields__col {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-search-fields__placeholder-col {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-search-fields__actions {
|
||||||
|
:deep(.el-form-item__content) {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button + .el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-form-item__content) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input),
|
||||||
|
:deep(.el-select),
|
||||||
|
:deep(.el-date-editor) {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
163
src/components/custom/team-context-panel.vue
Normal file
163
src/components/custom/team-context-panel.vue
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { TeamViewMode } from '@/views/personal-center/shared/team-dashboard';
|
||||||
|
|
||||||
|
defineOptions({ name: 'TeamContextPanel' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
loading?: boolean;
|
||||||
|
selectedLabel?: string;
|
||||||
|
subordinateCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
loading: false,
|
||||||
|
selectedLabel: '',
|
||||||
|
subordinateCount: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const mode = defineModel<TeamViewMode>('mode', {
|
||||||
|
required: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const scopeOptions = computed(() => [
|
||||||
|
{ label: '个人视角', value: 'self' satisfies TeamViewMode },
|
||||||
|
{ label: '团队视角', value: 'team' satisfies TeamViewMode }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const contextText = computed(() => {
|
||||||
|
if (mode.value === 'self') {
|
||||||
|
return '当前查看我自己的数据。';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.selectedLabel) {
|
||||||
|
return `当前范围:${props.selectedLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '当前查看团队数据。';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElCard class="team-context-panel" body-class="team-context-panel__body">
|
||||||
|
<div v-loading="props.loading" class="team-context-panel__layout">
|
||||||
|
<div class="team-context-panel__controls">
|
||||||
|
<ElSegmented v-model="mode" :options="scopeOptions" class="team-context-panel__segmented" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="team-context-panel__info">
|
||||||
|
<div class="team-context-panel__info-main">
|
||||||
|
<div class="team-context-panel__info-item">
|
||||||
|
<span class="team-context-panel__info-label">当前范围</span>
|
||||||
|
<strong class="team-context-panel__info-value">
|
||||||
|
{{ props.selectedLabel || (mode === 'self' ? '我自己' : '--') }}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="mode === 'team'" class="team-context-panel__info-item">
|
||||||
|
<span class="team-context-panel__info-label">下属人数</span>
|
||||||
|
<strong class="team-context-panel__info-value">{{ props.subordinateCount }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="team-context-panel__info-desc">{{ contextText }}</p>
|
||||||
|
<div v-if="$slots.default" class="team-context-panel__summary">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.team-context-panel {
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.team-context-panel__body) {
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-context-panel__layout {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-context-panel__controls {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.team-context-panel__segmented) {
|
||||||
|
padding: 6px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.team-context-panel__segmented .el-segmented__item) {
|
||||||
|
min-width: 96px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 22px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-context-panel__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
border-left: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-context-panel__info-main {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-context-panel__info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-context-panel__info-label {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-context-panel__info-value {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-context-panel__info-desc {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-context-panel__summary {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1200px) {
|
||||||
|
.team-context-panel__layout {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-context-panel__info {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { getPaletteColorByNumber } from '@sa/color';
|
|
||||||
|
|
||||||
defineOptions({ name: 'WaveBg' });
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Theme color */
|
|
||||||
themeColor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
|
||||||
|
|
||||||
const lightColor = computed(() => getPaletteColorByNumber(props.themeColor, 200));
|
|
||||||
const darkColor = computed(() => getPaletteColorByNumber(props.themeColor, 500));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="absolute-lt z-1 size-full overflow-hidden">
|
|
||||||
<div class="absolute -right-300px -top-900px lt-sm:(-right-100px -top-1170px)">
|
|
||||||
<svg height="1337" width="1337">
|
|
||||||
<defs>
|
|
||||||
<path
|
|
||||||
id="path-1"
|
|
||||||
opacity="1"
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M1337,668.5 C1337,1037.455193874239 1037.455193874239,1337 668.5,1337 C523.6725684305388,1337 337,1236 370.50000000000006,1094 C434.03835568300906,824.6732385973953 6.906089672974592e-14,892.6277623047779 0,668.5000000000001 C0,299.5448061257611 299.5448061257609,1.1368683772161603e-13 668.4999999999999,0 C1037.455193874239,0 1337,299.544806125761 1337,668.5Z"
|
|
||||||
/>
|
|
||||||
<linearGradient id="linearGradient-2" x1="0.79" y1="0.62" x2="0.21" y2="0.86">
|
|
||||||
<stop offset="0" :stop-color="lightColor" stop-opacity="1" />
|
|
||||||
<stop offset="1" :stop-color="darkColor" stop-opacity="1" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g opacity="1">
|
|
||||||
<use xlink:href="#path-1" fill="url(#linearGradient-2)" fill-opacity="1" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="absolute -bottom-400px -left-200px lt-sm:(-bottom-760px -left-100px)">
|
|
||||||
<svg height="896" width="967.8852157128662">
|
|
||||||
<defs>
|
|
||||||
<path
|
|
||||||
id="path-2"
|
|
||||||
opacity="1"
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M896,448 C1142.6325445712241,465.5747656464056 695.2579309733121,896 448,896 C200.74206902668806,896 5.684341886080802e-14,695.2579309733121 0,448.0000000000001 C0,200.74206902668806 200.74206902668791,5.684341886080802e-14 447.99999999999994,0 C695.2579309733121,0 475,418 896,448Z"
|
|
||||||
/>
|
|
||||||
<linearGradient id="linearGradient-3" x1="0.5" y1="0" x2="0.5" y2="1">
|
|
||||||
<stop offset="0" :stop-color="darkColor" stop-opacity="1" />
|
|
||||||
<stop offset="1" :stop-color="lightColor" stop-opacity="1" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g opacity="1">
|
|
||||||
<use xlink:href="#path-2" fill="url(#linearGradient-3)" fill-opacity="1" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -89,3 +89,25 @@ export const postTypeRecord: Record<Api.SystemManage.PostType, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const postTypeOptions = transformRecordToOption(postTypeRecord);
|
export const postTypeOptions = transformRecordToOption(postTypeRecord);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 产品对象域角色编码:产品经理
|
||||||
|
*
|
||||||
|
* 用途:
|
||||||
|
* 产品创建两步向导第 2 步初始化团队时,前端按本 code 在 fetchGetRoleSimpleList
|
||||||
|
* 返回的角色列表中反查产品经理角色 ID,作为默认经理成员行的 roleId 提交。
|
||||||
|
*
|
||||||
|
* 来源口径:后端约定的产品对象域内置角色稳定 code。code 变更需同步前端常量。
|
||||||
|
*/
|
||||||
|
export const PRODUCT_MANAGER_ROLE_CODE = 'product_manager';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目对象域角色编码:项目经理
|
||||||
|
*
|
||||||
|
* 用途:
|
||||||
|
* 项目创建两步向导第 2 步初始化团队时,前端按本 code 在 fetchGetRoleSimpleList
|
||||||
|
* 返回的角色列表中反查项目经理角色 ID,作为默认经理成员行的 roleId 提交。
|
||||||
|
*
|
||||||
|
* 来源口径:后端约定的项目对象域内置角色稳定 code。code 变更需同步前端常量。
|
||||||
|
*/
|
||||||
|
export const PROJECT_MANAGER_ROLE_CODE = 'project_manager';
|
||||||
|
|||||||
@@ -45,10 +45,14 @@ export const SYSTEM_USER_COMPANY_DICT_CODE = 'system_user_company';
|
|||||||
export const RDMS_REQ_SOURCE_TYPE_DICT_CODE = 'rdms_req_source_type';
|
export const RDMS_REQ_SOURCE_TYPE_DICT_CODE = 'rdms_req_source_type';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 需求优先级字典编码
|
* 优先级字典编码
|
||||||
*
|
*
|
||||||
* 对应业务字段:需求相关接口和页面中的 priority
|
* 对应业务字段:
|
||||||
* 来源口径:产品需求文档中定义,标签包括紧急、高、中、低
|
* - 需求(产品需求 / 项目需求)的 priority(旧口径:Integer,数字大=高,0=低 / 3=紧急)
|
||||||
|
* - 任务 / 执行的 priority(新口径:String "0"~"3",数字越小优先级越高,"1"=默认 P1)
|
||||||
|
*
|
||||||
|
* 来源口径:后端统一字典 rdms_req_priority,4 档标签 P0/P1/P2/P3。
|
||||||
|
* 数值取值口径不同是已知遗留——前端用本字典的 label / colorType 渲染即可,不要硬编码 P0~P3。
|
||||||
*/
|
*/
|
||||||
export const RDMS_REQ_PRIORITY_DICT_CODE = 'rdms_req_priority';
|
export const RDMS_REQ_PRIORITY_DICT_CODE = 'rdms_req_priority';
|
||||||
|
|
||||||
@@ -59,3 +63,68 @@ export const RDMS_REQ_PRIORITY_DICT_CODE = 'rdms_req_priority';
|
|||||||
* 来源口径:产品需求文档中定义,标签包括工程需求、用户需求、安全需求、体验优化、功能需求
|
* 来源口径:产品需求文档中定义,标签包括工程需求、用户需求、安全需求、体验优化、功能需求
|
||||||
*/
|
*/
|
||||||
export const RDMS_REQ_CATEGORY_DICT_CODE = 'rdms_req_category';
|
export const RDMS_REQ_CATEGORY_DICT_CODE = 'rdms_req_category';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目类型字典编码
|
||||||
|
*
|
||||||
|
* 对应业务字段:项目相关接口和页面中的 projectType
|
||||||
|
* 来源口径:后端字典 rdms_project_type
|
||||||
|
*/
|
||||||
|
export const RDMS_PROJECT_TYPE_DICT_CODE = 'rdms_project_type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目执行类型字典编码
|
||||||
|
*
|
||||||
|
* 对应业务字段:项目任务管理中执行的 executionType
|
||||||
|
* 来源口径:`rdms-project-boot-执行任务接口API文档.md` 明确 executionType 来自字典 rdms_project_execution_type
|
||||||
|
*/
|
||||||
|
export const RDMS_PROJECT_EXECUTION_TYPE_DICT_CODE = 'rdms_project_execution_type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态机对象类型字典编码
|
||||||
|
*
|
||||||
|
* 对应业务字段:状态机管理中的 objectType / 对象类型
|
||||||
|
* 来源口径:用户明确指定对象类型下拉来自运行时字典 object_status_model_object_type
|
||||||
|
*/
|
||||||
|
export const OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE = 'object_status_model_object_type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务/个人事项类型字典编码
|
||||||
|
*
|
||||||
|
* 对应业务字段:任务、个人事项中的 type
|
||||||
|
* 来源口径:用户明确指定任务/个人事项类型下拉来自运行时字典 rdms_task_item_type
|
||||||
|
*/
|
||||||
|
export const RDMS_TASK_ITEM_TYPE_DICT_CODE = 'rdms_task_item_type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 需求允许删除的状态字典编码
|
||||||
|
*
|
||||||
|
* 对应业务字段:需求删除功能中判断 statusCode 是否允许删除
|
||||||
|
* 来源口径:用户在系统字典管理页中创建的字典 rdms_req_can_delete_status
|
||||||
|
*/
|
||||||
|
export const RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE = 'rdms_req_can_delete_status';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作日志难度字典编码
|
||||||
|
*
|
||||||
|
* 对应业务字段:任务/个人事项工作日志中的 difficulty
|
||||||
|
* 来源口径:用户明确指定任务/个人事项工作日志难度下拉来自运行时字典 rdms_task_item_worklog_difficulty
|
||||||
|
*/
|
||||||
|
export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task_item_worklog_difficulty';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加班时长快捷选项字典编码
|
||||||
|
*
|
||||||
|
* 对应业务字段:加班申请中的 overtimeDuration
|
||||||
|
* 来源口径:`overtime-application-design.md` 明确时长下拉字典为 rdms_overtime_duration
|
||||||
|
*/
|
||||||
|
export const RDMS_OVERTIME_DURATION_DICT_CODE = 'rdms_overtime_duration';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站内信消息等级字典编码
|
||||||
|
*
|
||||||
|
* 对应业务字段:站内信 NotifyMessage.level(1=普通 2=提醒 3=警告 4=严重,数字越大越紧急)
|
||||||
|
* 来源口径:`2026-06-13-站内信消息等级-前端对接.html` 明确等级字典为 notify_message_level,
|
||||||
|
* 显示名与颜色(hex)均走字典,前端按 level 取色不硬编码。
|
||||||
|
*/
|
||||||
|
export const NOTIFY_MESSAGE_LEVEL_DICT_CODE = 'notify_message_level';
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
/** baidu map sdk url */
|
|
||||||
export const BAIDU_MAP_SDK_URL = `https://api.map.baidu.com/getscript?v=3.0&ak=KSezYymXPth1DIGILRX3oYN9PxbOQQmU&services=&t=20210201100830&s=1`;
|
|
||||||
|
|
||||||
/** Amap sdk url */
|
|
||||||
export const AMAP_SDK_URL = 'https://webapi.amap.com/maps?v=2.0&key=e7bd02bd504062087e6563daf4d6721d';
|
|
||||||
|
|
||||||
/** tencent sdk url */
|
|
||||||
export const TENCENT_MAP_SDK_URL = 'https://map.qq.com/api/gljs?v=1.exp&key=A6DBZ-KXPLW-JKSRY-ONZF4-CPHY3-K6BL7';
|
|
||||||
@@ -10,11 +10,11 @@ export const objectContextDomainConfigs: App.ObjectContext.DomainConfig[] = [
|
|||||||
routePathPrefixes: ['/project'],
|
routePathPrefixes: ['/project'],
|
||||||
entryRouteKey: 'project_list',
|
entryRouteKey: 'project_list',
|
||||||
entryRoutePath: '/project/list',
|
entryRoutePath: '/project/list',
|
||||||
fallbackDefaultRouteKey: 'project_dashboard',
|
fallbackDefaultRouteKey: 'project_project_overview',
|
||||||
fallbackDefaultRoutePath: '/project/dashboard',
|
fallbackDefaultRoutePath: '/project/project/overview',
|
||||||
contextApiPath: `${WEB_SERVICE_PREFIX}/project/context`,
|
contextApiPath: `${WEB_SERVICE_PREFIX}/project/project/{id}/context`,
|
||||||
contextApiObjectIdParamKey: 'projectId',
|
contextApiObjectIdParamKey: 'id',
|
||||||
contextApiObjectIdPlacement: 'query',
|
contextApiObjectIdPlacement: 'path',
|
||||||
objectIdQueryKey: OBJECT_CONTEXT_QUERY_KEY
|
objectIdQueryKey: OBJECT_CONTEXT_QUERY_KEY
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
124
src/constants/status-tag.ts
Normal file
124
src/constants/status-tag.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* 业务对象状态颜色(ElTag type)集中配置
|
||||||
|
*
|
||||||
|
* 各业务域的 statusCode → ElTag type 在此统一维护,避免散落在各业务模块。
|
||||||
|
* 未来若后端状态字典返回颜色字段,可在调用方优先取后端值,缺失时回退此映射。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type StatusTagType = 'primary' | 'success' | 'warning' | 'info' | 'danger';
|
||||||
|
|
||||||
|
export type StatusDomain =
|
||||||
|
| 'projectExecution'
|
||||||
|
| 'projectTask'
|
||||||
|
| 'executionAssignee'
|
||||||
|
| 'taskAssigneeMember'
|
||||||
|
| 'project'
|
||||||
|
| 'product'
|
||||||
|
| 'productRequirement'
|
||||||
|
| 'projectRequirement'
|
||||||
|
| 'workOrder'
|
||||||
|
| 'workReport'
|
||||||
|
| 'performanceSheet'
|
||||||
|
| 'personalItem'
|
||||||
|
| 'overtimeApplication';
|
||||||
|
|
||||||
|
const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>> = {
|
||||||
|
// 项目-执行
|
||||||
|
projectExecution: {
|
||||||
|
pending: 'info',
|
||||||
|
active: 'primary',
|
||||||
|
paused: 'warning',
|
||||||
|
completed: 'success',
|
||||||
|
cancelled: 'danger'
|
||||||
|
},
|
||||||
|
// 项目-任务
|
||||||
|
projectTask: {
|
||||||
|
pending: 'info',
|
||||||
|
active: 'primary',
|
||||||
|
paused: 'warning',
|
||||||
|
completed: 'success',
|
||||||
|
cancelled: 'danger'
|
||||||
|
},
|
||||||
|
// 执行协办人变更事件
|
||||||
|
executionAssignee: {
|
||||||
|
join: 'success',
|
||||||
|
inactive: 'danger',
|
||||||
|
owner_transfer_in: 'warning',
|
||||||
|
owner_transfer_out: 'warning'
|
||||||
|
},
|
||||||
|
// 任务协办人变更事件
|
||||||
|
taskAssigneeMember: {
|
||||||
|
join: 'success',
|
||||||
|
inactive: 'danger'
|
||||||
|
},
|
||||||
|
// 项目(待补全)
|
||||||
|
project: {},
|
||||||
|
// 产品(待补全)
|
||||||
|
product: {},
|
||||||
|
// 产品需求
|
||||||
|
productRequirement: {
|
||||||
|
pending_claim: 'info',
|
||||||
|
pending_review: 'info',
|
||||||
|
pending_dispatch: 'primary',
|
||||||
|
reviewed: 'success',
|
||||||
|
review_rejected: 'danger',
|
||||||
|
implementing: 'primary',
|
||||||
|
accepted: 'success',
|
||||||
|
closed: 'danger',
|
||||||
|
rejected: 'danger',
|
||||||
|
cancelled: 'danger'
|
||||||
|
},
|
||||||
|
// 项目需求
|
||||||
|
projectRequirement: {
|
||||||
|
pending_claim: 'info',
|
||||||
|
pending_review: 'info',
|
||||||
|
reviewed: 'success',
|
||||||
|
review_rejected: 'danger',
|
||||||
|
implementing: 'primary',
|
||||||
|
accepted: 'success',
|
||||||
|
closed: 'danger',
|
||||||
|
rejected: 'danger',
|
||||||
|
cancelled: 'danger'
|
||||||
|
},
|
||||||
|
// 工单(待补全)
|
||||||
|
workOrder: {},
|
||||||
|
// 工作报告
|
||||||
|
workReport: {
|
||||||
|
draft: 'info',
|
||||||
|
pending_approval: 'warning',
|
||||||
|
approved: 'success',
|
||||||
|
rejected: 'danger'
|
||||||
|
},
|
||||||
|
// 绩效表
|
||||||
|
performanceSheet: {
|
||||||
|
draft: 'info',
|
||||||
|
sent: 'warning',
|
||||||
|
confirmed: 'success',
|
||||||
|
rejected: 'danger'
|
||||||
|
},
|
||||||
|
// 个人事项
|
||||||
|
personalItem: {
|
||||||
|
pending: 'info',
|
||||||
|
active: 'primary',
|
||||||
|
completed: 'success',
|
||||||
|
cancelled: 'danger'
|
||||||
|
},
|
||||||
|
// 加班申请
|
||||||
|
overtimeApplication: {
|
||||||
|
pending: 'warning',
|
||||||
|
approved: 'success',
|
||||||
|
rejected: 'danger'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getStatusTagType(domain: StatusDomain, statusCode: string | null | undefined): StatusTagType {
|
||||||
|
if (!statusCode) {
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusTagTypeRegistry[domain]?.[statusCode] || 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPersonalItemStatusTagType(statusCode: string | null | undefined) {
|
||||||
|
return getStatusTagType('personalItem', statusCode);
|
||||||
|
}
|
||||||
@@ -5,5 +5,6 @@ export enum SetupStoreId {
|
|||||||
Dict = 'dict-store',
|
Dict = 'dict-store',
|
||||||
Route = 'route-store',
|
Route = 'route-store',
|
||||||
Tab = 'tab-store',
|
Tab = 'tab-store',
|
||||||
ObjectContext = 'object-context-store'
|
ObjectContext = 'object-context-store',
|
||||||
|
Workbench = 'workbench-store'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,12 +131,14 @@ export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: C
|
|||||||
* @param callback callback function
|
* @param callback callback function
|
||||||
*/
|
*/
|
||||||
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
|
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
|
||||||
if (!isRendered()) return;
|
|
||||||
|
|
||||||
const updatedOpts = callback(chartOptions, optionsFactory);
|
const updatedOpts = callback(chartOptions, optionsFactory);
|
||||||
|
|
||||||
Object.assign(chartOptions, updatedOpts);
|
Object.assign(chartOptions, updatedOpts);
|
||||||
|
|
||||||
|
// 图表未初始化(容器尺寸未就绪)时只缓存最新 options,待 render() 初始化时一并应用;
|
||||||
|
// 否则数据先于初始化到达会被静默丢弃,首屏永远停留在空数据
|
||||||
|
if (!isRendered()) return;
|
||||||
|
|
||||||
if (isRendered()) {
|
if (isRendered()) {
|
||||||
chart?.clear();
|
chart?.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
import { computed, effectScope, onScopeDispose, ref, watch } from 'vue';
|
|
||||||
import { useElementSize } from '@vueuse/core';
|
|
||||||
import VChart, { registerLiquidChart } from '@visactor/vchart';
|
|
||||||
import type { ISpec, ITheme } from '@visactor/vchart';
|
|
||||||
import light from '@visactor/vchart-theme/public/light.json';
|
|
||||||
import dark from '@visactor/vchart-theme/public/dark.json';
|
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
|
||||||
|
|
||||||
registerLiquidChart();
|
|
||||||
|
|
||||||
// register the theme
|
|
||||||
VChart.ThemeManager.registerTheme('light', light as ITheme);
|
|
||||||
VChart.ThemeManager.registerTheme('dark', dark as ITheme);
|
|
||||||
|
|
||||||
interface ChartHooks {
|
|
||||||
onRender?: (chart: VChart) => void | Promise<void>;
|
|
||||||
onUpdated?: (chart: VChart) => void | Promise<void>;
|
|
||||||
onDestroy?: (chart: VChart) => void | Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVChart<T extends ISpec>(specFactory: () => T, hooks: ChartHooks = {}) {
|
|
||||||
const scope = effectScope();
|
|
||||||
const themeStore = useThemeStore();
|
|
||||||
const darkMode = computed(() => themeStore.darkMode);
|
|
||||||
|
|
||||||
const domRef = ref<HTMLElement | null>(null);
|
|
||||||
const initialSize = { width: 0, height: 0 };
|
|
||||||
const { width, height } = useElementSize(domRef, initialSize);
|
|
||||||
|
|
||||||
let chart: VChart | null = null;
|
|
||||||
const spec: T = specFactory();
|
|
||||||
|
|
||||||
const { onRender, onUpdated, onDestroy } = hooks;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* whether can render chart
|
|
||||||
*
|
|
||||||
* when domRef is ready and initialSize is valid
|
|
||||||
*/
|
|
||||||
function canRender() {
|
|
||||||
return domRef.value && initialSize.width > 0 && initialSize.height > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** is chart rendered */
|
|
||||||
function isRendered() {
|
|
||||||
return Boolean(domRef.value && chart);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* update chart spec
|
|
||||||
*
|
|
||||||
* @param callback callback function
|
|
||||||
*/
|
|
||||||
async function updateSpec(callback: (opts: T, optsFactory: () => T) => ISpec = () => spec) {
|
|
||||||
if (!isRendered()) return;
|
|
||||||
|
|
||||||
const updatedOpts = callback(spec, specFactory);
|
|
||||||
|
|
||||||
Object.assign(spec, updatedOpts);
|
|
||||||
|
|
||||||
// if (isRendered()) {
|
|
||||||
// chart?.release();
|
|
||||||
// }
|
|
||||||
|
|
||||||
chart?.updateSpec({ ...updatedOpts }, true);
|
|
||||||
|
|
||||||
await onUpdated?.(chart!);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSpec(newSpec: T) {
|
|
||||||
chart?.updateSpec(newSpec);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** render chart */
|
|
||||||
async function render() {
|
|
||||||
if (!isRendered()) {
|
|
||||||
// apply the theme
|
|
||||||
if (darkMode.value) {
|
|
||||||
VChart.ThemeManager.setCurrentTheme('dark');
|
|
||||||
} else {
|
|
||||||
VChart.ThemeManager.setCurrentTheme('light');
|
|
||||||
}
|
|
||||||
|
|
||||||
chart = new VChart(spec, { dom: domRef.value as HTMLElement });
|
|
||||||
chart.renderSync();
|
|
||||||
|
|
||||||
await onRender?.(chart);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** resize chart */
|
|
||||||
function resize() {
|
|
||||||
// chart?.resize();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** destroy chart */
|
|
||||||
async function destroy() {
|
|
||||||
if (!chart) return;
|
|
||||||
|
|
||||||
await onDestroy?.(chart);
|
|
||||||
chart?.release();
|
|
||||||
chart = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** change chart theme */
|
|
||||||
async function changeTheme() {
|
|
||||||
await destroy();
|
|
||||||
await render();
|
|
||||||
await onUpdated?.(chart!);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* render chart by size
|
|
||||||
*
|
|
||||||
* @param w width
|
|
||||||
* @param h height
|
|
||||||
*/
|
|
||||||
async function renderChartBySize(w: number, h: number) {
|
|
||||||
initialSize.width = w;
|
|
||||||
initialSize.height = h;
|
|
||||||
|
|
||||||
// size is abnormal, destroy chart
|
|
||||||
if (!canRender()) {
|
|
||||||
await destroy();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// resize chart
|
|
||||||
if (isRendered()) {
|
|
||||||
resize();
|
|
||||||
}
|
|
||||||
|
|
||||||
// render chart
|
|
||||||
await render();
|
|
||||||
}
|
|
||||||
|
|
||||||
scope.run(() => {
|
|
||||||
watch([width, height], ([newWidth, newHeight]) => {
|
|
||||||
renderChartBySize(newWidth, newHeight);
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(darkMode, () => {
|
|
||||||
changeTheme();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onScopeDispose(() => {
|
|
||||||
destroy();
|
|
||||||
scope.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
domRef,
|
|
||||||
updateSpec,
|
|
||||||
setSpec
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
import { useDebounceFn, useInfiniteScroll } from '@vueuse/core';
|
||||||
|
import { NOTIFY_MESSAGE_LEVEL_DICT_CODE } from '@/constants/dict';
|
||||||
|
import {
|
||||||
|
fetchGetMyNotifyMessagePage,
|
||||||
|
fetchGetUnreadNotifyCount,
|
||||||
|
fetchUpdateAllNotifyMessageRead,
|
||||||
|
fetchUpdateNotifyMessageRead
|
||||||
|
} from '@/service/api';
|
||||||
|
import { useDictStore } from '@/store/modules/dict';
|
||||||
|
import { formatDateTime, formatRelativeTime } from '@/utils/datetime';
|
||||||
|
|
||||||
|
defineOptions({ name: 'NotificationBell' });
|
||||||
|
|
||||||
|
const dictStore = useDictStore();
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
const UNREAD_COUNT_POLL_INTERVAL = 15 * 1000;
|
||||||
|
|
||||||
|
type TabKey = 'unread' | 'read';
|
||||||
|
|
||||||
|
interface MessageListState {
|
||||||
|
items: Api.NotifyMessage.NotifyMessage[];
|
||||||
|
pageNo: number;
|
||||||
|
total: number;
|
||||||
|
loading: boolean;
|
||||||
|
/** 是否已按当前关键字拉过第一页(tab 懒加载 / 失效重拉用) */
|
||||||
|
loaded: boolean;
|
||||||
|
/** 竞态令牌:重置后递增,过期响应直接丢弃 */
|
||||||
|
token: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createListState(): MessageListState {
|
||||||
|
return { items: [], pageNo: 1, total: 0, loading: false, loaded: false, token: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const listStates = reactive<Record<TabKey, MessageListState>>({
|
||||||
|
unread: createListState(),
|
||||||
|
read: createListState()
|
||||||
|
});
|
||||||
|
|
||||||
|
const unreadCount = ref(0);
|
||||||
|
const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)));
|
||||||
|
|
||||||
|
const drawerOpen = ref(false);
|
||||||
|
const activeTab = ref<TabKey>('unread');
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
|
||||||
|
const detailVisible = ref(false);
|
||||||
|
const detailMessage = ref<Api.NotifyMessage.NotifyMessage | null>(null);
|
||||||
|
|
||||||
|
function keywordParam() {
|
||||||
|
return searchKeyword.value.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表圆点颜色:跟随消息等级(与等级徽标同一字典色源);取不到时回 undefined,由 CSS 兜底 */
|
||||||
|
function levelDotColor(level: number) {
|
||||||
|
return dictStore.getDictItem(NOTIFY_MESSAGE_LEVEL_DICT_CODE, level)?.colorType ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshUnreadCount() {
|
||||||
|
const { data, error } = await fetchGetUnreadNotifyCount();
|
||||||
|
if (!error && typeof data === 'number') {
|
||||||
|
unreadCount.value = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetList(tab: TabKey) {
|
||||||
|
const state = listStates[tab];
|
||||||
|
state.token += 1;
|
||||||
|
state.items = [];
|
||||||
|
state.pageNo = 1;
|
||||||
|
state.total = 0;
|
||||||
|
state.loading = false;
|
||||||
|
state.loaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPage(tab: TabKey) {
|
||||||
|
const state = listStates[tab];
|
||||||
|
if (state.loading) return;
|
||||||
|
|
||||||
|
const token = state.token;
|
||||||
|
state.loading = true;
|
||||||
|
|
||||||
|
const { data, error } = await fetchGetMyNotifyMessagePage({
|
||||||
|
pageNo: state.pageNo,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
readStatus: tab === 'read',
|
||||||
|
keyword: keywordParam()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (token !== state.token) return;
|
||||||
|
|
||||||
|
state.loading = false;
|
||||||
|
state.loaded = true;
|
||||||
|
|
||||||
|
if (error || !data) return;
|
||||||
|
|
||||||
|
state.items.push(...data.list);
|
||||||
|
state.total = data.total;
|
||||||
|
state.pageNo += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMore(tab: TabKey) {
|
||||||
|
const state = listStates[tab];
|
||||||
|
return state.loaded && state.items.length < state.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureLoaded(tab: TabKey) {
|
||||||
|
const state = listStates[tab];
|
||||||
|
if (!state.loaded && !state.loading) {
|
||||||
|
loadPage(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyKeywordSearch = useDebounceFn(() => {
|
||||||
|
if (!drawerOpen.value) return;
|
||||||
|
resetList('unread');
|
||||||
|
resetList('read');
|
||||||
|
loadPage(activeTab.value);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
watch(searchKeyword, () => {
|
||||||
|
applyKeywordSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(activeTab, tab => {
|
||||||
|
ensureLoaded(tab);
|
||||||
|
});
|
||||||
|
|
||||||
|
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null;
|
||||||
|
const unreadScrollbar = ref<ScrollbarRefValue>(null);
|
||||||
|
const readScrollbar = ref<ScrollbarRefValue>(null);
|
||||||
|
|
||||||
|
useInfiniteScroll(
|
||||||
|
() => unreadScrollbar.value?.wrapRef,
|
||||||
|
() => {
|
||||||
|
if (drawerOpen.value && hasMore('unread') && !listStates.unread.loading) {
|
||||||
|
loadPage('unread');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ distance: 48 }
|
||||||
|
);
|
||||||
|
|
||||||
|
useInfiniteScroll(
|
||||||
|
() => readScrollbar.value?.wrapRef,
|
||||||
|
() => {
|
||||||
|
if (drawerOpen.value && hasMore('read') && !listStates.read.loading) {
|
||||||
|
loadPage('read');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ distance: 48 }
|
||||||
|
);
|
||||||
|
|
||||||
|
function openDrawer() {
|
||||||
|
drawerOpen.value = true;
|
||||||
|
// 每次打开面板都从第 1 页重拉(与后端对齐的消费口径)
|
||||||
|
resetList('unread');
|
||||||
|
resetList('read');
|
||||||
|
loadPage(activeTab.value);
|
||||||
|
refreshUnreadCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
drawerOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrawerClosed() {
|
||||||
|
searchKeyword.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markRead(item: Api.NotifyMessage.NotifyMessage) {
|
||||||
|
const { error } = await fetchUpdateNotifyMessageRead([item.id]);
|
||||||
|
if (error) return;
|
||||||
|
|
||||||
|
// 本地移除、不按原页号回拉,避免未读集合收缩导致的分页漂移
|
||||||
|
const state = listStates.unread;
|
||||||
|
const index = state.items.findIndex(row => row.id === item.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
state.items.splice(index, 1);
|
||||||
|
state.total = Math.max(0, state.total - 1);
|
||||||
|
}
|
||||||
|
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
||||||
|
|
||||||
|
// 已读列表失效,下次进入已读 tab 时从第 1 页重拉
|
||||||
|
resetList('read');
|
||||||
|
|
||||||
|
// 移除后剩余条目不足一页且还有更多时补拉,防止列表不再触发滚动加载
|
||||||
|
if (state.items.length < PAGE_SIZE && hasMore('unread')) {
|
||||||
|
loadPage('unread');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(row: Api.NotifyMessage.NotifyMessage) {
|
||||||
|
// 弹框持有该行引用,正文不随未读列表移除而消失
|
||||||
|
detailMessage.value = row;
|
||||||
|
detailVisible.value = true;
|
||||||
|
// 未读消息「打开即已读」:后台静默标记,避免"看一半就跑到已读"
|
||||||
|
if (!row.readStatus) {
|
||||||
|
markRead(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllRead() {
|
||||||
|
const { error } = await fetchUpdateAllNotifyMessageRead();
|
||||||
|
if (error) return;
|
||||||
|
|
||||||
|
unreadCount.value = 0;
|
||||||
|
resetList('unread');
|
||||||
|
resetList('read');
|
||||||
|
loadPage(activeTab.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 等级徽标颜色/文案走字典:若未在登录缓存内则按编码补拉一次(已缓存时不发请求)
|
||||||
|
dictStore.ensureDictData(NOTIFY_MESSAGE_LEVEL_DICT_CODE);
|
||||||
|
refreshUnreadCount();
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
if (document.hidden) return;
|
||||||
|
refreshUnreadCount();
|
||||||
|
}, UNREAD_COUNT_POLL_INTERVAL);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="notification-bell__trigger"
|
||||||
|
type="button"
|
||||||
|
:aria-label="unreadCount > 0 ? `通知,${unreadCount} 条未读` : '通知'"
|
||||||
|
@click="openDrawer"
|
||||||
|
>
|
||||||
|
<SvgIcon icon="mdi:bell-outline" class="notification-bell__icon" />
|
||||||
|
<span v-if="unreadCount > 0" class="notification-bell__badge">{{ badgeLabel }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ElDrawer v-model="drawerOpen" size="480px" @closed="onDrawerClosed">
|
||||||
|
<template #header>
|
||||||
|
<div class="notification-bell__header-main">
|
||||||
|
<span class="notification-bell__title">
|
||||||
|
通知
|
||||||
|
<span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span>
|
||||||
|
</span>
|
||||||
|
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="notification-bell__panel">
|
||||||
|
<div class="notification-bell__search">
|
||||||
|
<ElInput v-model="searchKeyword" placeholder="搜索通知" clearable>
|
||||||
|
<template #prefix>
|
||||||
|
<SvgIcon icon="mdi:magnify" />
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElTabs v-model="activeTab" class="notification-bell__tabs">
|
||||||
|
<ElTabPane name="unread">
|
||||||
|
<template #label>
|
||||||
|
<span class="notification-bell__tab-label">
|
||||||
|
未读
|
||||||
|
<span class="notification-bell__tab-count">{{ listStates.unread.total }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
|
||||||
|
<ul v-if="listStates.unread.items.length > 0" class="notification-bell__list">
|
||||||
|
<li
|
||||||
|
v-for="row in listStates.unread.items"
|
||||||
|
:key="row.id"
|
||||||
|
class="notification-bell__row is-unread"
|
||||||
|
@click="openDetail(row)"
|
||||||
|
>
|
||||||
|
<span class="notification-bell__row-dot" :style="{ backgroundColor: levelDotColor(row.level) }" />
|
||||||
|
<div class="notification-bell__row-body">
|
||||||
|
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
|
||||||
|
<div class="notification-bell__row-meta">
|
||||||
|
<DictTag :dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE" :value="row.level" size="small" round />
|
||||||
|
<span class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="notification-bell__empty">
|
||||||
|
{{ listStates.unread.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="listStates.unread.items.length > 0" class="notification-bell__footer-hint">
|
||||||
|
{{ listStates.unread.loading ? '加载中…' : hasMore('unread') ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</ElTabPane>
|
||||||
|
|
||||||
|
<ElTabPane name="read">
|
||||||
|
<template #label>
|
||||||
|
<span class="notification-bell__tab-label">已读</span>
|
||||||
|
</template>
|
||||||
|
<ElScrollbar ref="readScrollbar" class="notification-bell__scroll">
|
||||||
|
<ul v-if="listStates.read.items.length > 0" class="notification-bell__list">
|
||||||
|
<li
|
||||||
|
v-for="row in listStates.read.items"
|
||||||
|
:key="row.id"
|
||||||
|
class="notification-bell__row"
|
||||||
|
@click="openDetail(row)"
|
||||||
|
>
|
||||||
|
<span class="notification-bell__row-dot" :style="{ backgroundColor: levelDotColor(row.level) }" />
|
||||||
|
<div class="notification-bell__row-body">
|
||||||
|
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
|
||||||
|
<div class="notification-bell__row-meta">
|
||||||
|
<DictTag :dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE" :value="row.level" size="small" round />
|
||||||
|
<span class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="notification-bell__empty">
|
||||||
|
{{ listStates.read.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="listStates.read.items.length > 0" class="notification-bell__footer-hint">
|
||||||
|
{{ listStates.read.loading ? '加载中…' : hasMore('read') ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</ElTabPane>
|
||||||
|
</ElTabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<ElButton @click="closeDrawer">关闭</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElDrawer>
|
||||||
|
|
||||||
|
<ElDialog v-model="detailVisible" width="520px" align-center class="notification-bell__detail">
|
||||||
|
<template #header>
|
||||||
|
<div class="notification-bell__detail-head">
|
||||||
|
<span class="notification-bell__detail-sender">{{ detailMessage?.templateNickname || '系统通知' }}</span>
|
||||||
|
<DictTag
|
||||||
|
v-if="detailMessage"
|
||||||
|
:dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE"
|
||||||
|
:value="detailMessage.level"
|
||||||
|
size="small"
|
||||||
|
round
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="detailMessage" class="notification-bell__detail-body">
|
||||||
|
<div class="notification-bell__detail-content">{{ detailMessage.templateContent }}</div>
|
||||||
|
<div class="notification-bell__detail-time">收到于 {{ formatDateTime(detailMessage.createTime) }}</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<ElButton @click="detailVisible = false">关闭</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notification-bell__trigger {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 4px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__trigger:hover {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--el-color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: var(--el-color-danger);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
animation: notification-badge-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 扩散波纹:跟随心跳节奏向外晕开,增强未读提醒的醒目度 */
|
||||||
|
.notification-bell__badge::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: var(--el-color-danger);
|
||||||
|
animation: notification-badge-ping 1.6s ease-out infinite;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes notification-badge-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes notification-badge-ping {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
70%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1.9);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__header-main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__title-count {
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: var(--el-color-danger);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__search {
|
||||||
|
padding: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__tabs {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__tabs :deep(.el-tabs__content) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__tabs :deep(.el-tab-pane) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__tab-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__tab-count {
|
||||||
|
padding: 0 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: var(--el-fill-color);
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__tabs :deep(.el-tabs__item.is-active) .notification-bell__tab-count {
|
||||||
|
background-color: var(--el-color-primary-light-9);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__scroll {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 14px minmax(0, 1fr);
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row + .notification-bell__row {
|
||||||
|
border-top: 1px dashed var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row:hover {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: transparent;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row.is-unread .notification-bell__row-dot {
|
||||||
|
background-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row-body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row-title {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row.is-unread .notification-bell__row-title {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__row-time {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__empty {
|
||||||
|
padding: 48px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__footer-hint {
|
||||||
|
padding: 12px 0 4px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__detail-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__detail-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-right: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__detail-sender {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__detail-content {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__detail-time {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,11 +12,13 @@ const authStore = useAuthStore();
|
|||||||
const { routerPushByKey, toLogin } = useRouterPush();
|
const { routerPushByKey, toLogin } = useRouterPush();
|
||||||
const { SvgIconVNode } = useSvgIcon();
|
const { SvgIconVNode } = useSvgIcon();
|
||||||
|
|
||||||
|
const displayName = computed(() => authStore.userInfo.nickname || authStore.userInfo.userName);
|
||||||
|
|
||||||
function loginOrRegister() {
|
function loginOrRegister() {
|
||||||
toLogin();
|
toLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
type DropdownKey = 'user-center' | 'logout';
|
type DropdownKey = 'personal-center_my-profile' | 'logout';
|
||||||
|
|
||||||
type DropdownOption = {
|
type DropdownOption = {
|
||||||
key: DropdownKey;
|
key: DropdownKey;
|
||||||
@@ -27,8 +29,8 @@ type DropdownOption = {
|
|||||||
const options = computed(() => {
|
const options = computed(() => {
|
||||||
const opts: DropdownOption[] = [
|
const opts: DropdownOption[] = [
|
||||||
{
|
{
|
||||||
label: $t('common.userCenter'),
|
label: $t('common.myProfile'),
|
||||||
key: 'user-center',
|
key: 'personal-center_my-profile',
|
||||||
icon: SvgIconVNode({ icon: 'ph:user-circle', fontSize: 18 })
|
icon: SvgIconVNode({ icon: 'ph:user-circle', fontSize: 18 })
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -84,7 +86,7 @@ function handleDropdown(key: DropdownKey) {
|
|||||||
</template>
|
</template>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<SvgIcon icon="ph:user-circle" class="mr-5px text-icon-large" />
|
<SvgIcon icon="ph:user-circle" class="mr-5px text-icon-large" />
|
||||||
<span class="text-16px font-medium">{{ authStore.userInfo.userName }}</span>
|
<span class="text-16px font-medium">{{ displayName }}</span>
|
||||||
</div>
|
</div>
|
||||||
</ElDropdown>
|
</ElDropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import GlobalLogo from '../global-logo/index.vue';
|
|||||||
import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
|
import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
|
||||||
import GlobalSearch from '../global-search/index.vue';
|
import GlobalSearch from '../global-search/index.vue';
|
||||||
import ThemeButton from './components/theme-button.vue';
|
import ThemeButton from './components/theme-button.vue';
|
||||||
|
import NotificationBell from './components/notification-bell.vue';
|
||||||
import UserAvatar from './components/user-avatar.vue';
|
import UserAvatar from './components/user-avatar.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'GlobalHeader' });
|
defineOptions({ name: 'GlobalHeader' });
|
||||||
@@ -40,14 +41,10 @@ const { isFullscreen, toggle } = useFullscreen();
|
|||||||
<div>
|
<div>
|
||||||
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
|
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
|
||||||
</div>
|
</div>
|
||||||
<ThemeSchemaSwitch
|
|
||||||
:theme-schema="themeStore.themeScheme"
|
|
||||||
:is-dark="themeStore.darkMode"
|
|
||||||
@switch="themeStore.toggleThemeScheme"
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<ThemeButton />
|
<ThemeButton />
|
||||||
</div>
|
</div>
|
||||||
|
<NotificationBell />
|
||||||
<UserAvatar />
|
<UserAvatar />
|
||||||
</div>
|
</div>
|
||||||
</DarkModeContainer>
|
</DarkModeContainer>
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { Search } from '@element-plus/icons-vue';
|
||||||
|
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||||
|
import { fetchGetProductPage, fetchGetProjectPage } from '@/service/api';
|
||||||
|
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ObjectContextSwitcher' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
domainConfig: App.ObjectContext.DomainConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ObjectOption = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string | null;
|
||||||
|
createTime?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const objectContextStore = useObjectContextStore();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const keyword = ref('');
|
||||||
|
const expanded = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const switchingId = ref('');
|
||||||
|
const options = ref<ObjectOption[]>([]);
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const OBJECT_SWITCHER_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
const isProductDomain = computed(() => props.domainConfig.domainKey === 'product');
|
||||||
|
const domainLabel = computed(() => (isProductDomain.value ? '产品' : '项目'));
|
||||||
|
const allLabel = computed(() => `全部${domainLabel.value}`);
|
||||||
|
const placeholder = computed(() => `搜索${domainLabel.value}`);
|
||||||
|
const previewOptions = computed(() => options.value.slice(0, 3));
|
||||||
|
const displayOptions = computed(() => {
|
||||||
|
if (keyword.value.trim() || expanded.value) {
|
||||||
|
return options.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return previewOptions.value;
|
||||||
|
});
|
||||||
|
const hiddenCount = computed(() => Math.max(options.value.length - previewOptions.value.length, 0));
|
||||||
|
const showAllEntry = computed(() => !keyword.value.trim() && !expanded.value && hiddenCount.value > 0);
|
||||||
|
|
||||||
|
function sortByCreateTimeDesc(list: ObjectOption[]) {
|
||||||
|
return list.slice().sort((left, right) => {
|
||||||
|
const leftTime = left.createTime ? new Date(left.createTime).getTime() : 0;
|
||||||
|
const rightTime = right.createTime ? new Date(right.createTime).getTime() : 0;
|
||||||
|
|
||||||
|
return rightTime - leftTime;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchObjectOptionsPage(pageNo: number, keywordValue?: string) {
|
||||||
|
const result =
|
||||||
|
props.domainConfig.domainKey === 'product'
|
||||||
|
? await fetchGetProductPage({ pageNo, pageSize: OBJECT_SWITCHER_PAGE_SIZE, keyword: keywordValue })
|
||||||
|
: await fetchGetProjectPage({ pageNo, pageSize: OBJECT_SWITCHER_PAGE_SIZE, keyword: keywordValue });
|
||||||
|
|
||||||
|
if (result.error || !result.data) {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
list: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = result.data.list.map(item => {
|
||||||
|
if (props.domainConfig.domainKey === 'product') {
|
||||||
|
const product = item as Api.Product.Product;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
code: product.code,
|
||||||
|
createTime: product.createTime
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = item as Api.Project.Project;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: project.id,
|
||||||
|
name: project.projectName,
|
||||||
|
code: project.projectCode,
|
||||||
|
createTime: project.createTime
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: result.data.total,
|
||||||
|
list
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOptions() {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
const keywordValue = keyword.value.trim() || undefined;
|
||||||
|
const firstPage = await fetchObjectOptionsPage(1, keywordValue);
|
||||||
|
const pageCount = Math.ceil(firstPage.total / OBJECT_SWITCHER_PAGE_SIZE);
|
||||||
|
const restPages =
|
||||||
|
pageCount > 1
|
||||||
|
? await Promise.all(
|
||||||
|
Array.from({ length: pageCount - 1 }, (_, index) => fetchObjectOptionsPage(index + 2, keywordValue))
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const list = [firstPage, ...restPages].flatMap(page => page.list);
|
||||||
|
|
||||||
|
loading.value = false;
|
||||||
|
options.value = sortByCreateTimeDesc(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibleChange(value: boolean) {
|
||||||
|
visible.value = value;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
expanded.value = false;
|
||||||
|
loadOptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelect(option: ObjectOption) {
|
||||||
|
if (option.id === objectContextStore.objectId) {
|
||||||
|
visible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switchingId.value = option.id;
|
||||||
|
const result = await objectContextStore.switchContext(props.domainConfig, option.id);
|
||||||
|
switchingId.value = '';
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
visible.value = false;
|
||||||
|
const query = {
|
||||||
|
...route.query,
|
||||||
|
[OBJECT_CONTEXT_QUERY_KEY]: option.id
|
||||||
|
};
|
||||||
|
const targetLocation = route.name ? { name: route.name, query } : { path: route.path, query };
|
||||||
|
|
||||||
|
await router.push(targetLocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => keyword.value,
|
||||||
|
() => {
|
||||||
|
if (!visible.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded.value = Boolean(keyword.value.trim());
|
||||||
|
|
||||||
|
if (searchTimer) {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
loadOptions();
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElPopover
|
||||||
|
:visible="visible"
|
||||||
|
trigger="click"
|
||||||
|
placement="bottom-start"
|
||||||
|
:width="300"
|
||||||
|
popper-class="object-context-switcher__popper"
|
||||||
|
@update:visible="handleVisibleChange"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<button type="button" class="object-context-switcher__trigger" :class="{ 'is-open': visible }">
|
||||||
|
<span class="object-context-switcher__trigger-label">{{ objectContextStore.objectName }}</span>
|
||||||
|
<icon-ep:sort class="object-context-switcher__trigger-icon" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="object-context-switcher__panel">
|
||||||
|
<ElInput v-model="keyword" clearable :placeholder="placeholder" class="object-context-switcher__search">
|
||||||
|
<template #suffix>
|
||||||
|
<ElIcon>
|
||||||
|
<Search />
|
||||||
|
</ElIcon>
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
|
|
||||||
|
<div v-loading="loading" class="object-context-switcher__list">
|
||||||
|
<button
|
||||||
|
v-for="item in displayOptions"
|
||||||
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
class="object-context-switcher__item"
|
||||||
|
:class="{ 'is-active': item.id === objectContextStore.objectId }"
|
||||||
|
:disabled="switchingId === item.id"
|
||||||
|
@click="handleSelect(item)"
|
||||||
|
>
|
||||||
|
<span class="object-context-switcher__item-icon">
|
||||||
|
<icon-ep:box v-if="isProductDomain" />
|
||||||
|
<icon-ep:folder v-else />
|
||||||
|
</span>
|
||||||
|
<span class="object-context-switcher__item-main">
|
||||||
|
<span class="object-context-switcher__item-name">{{ item.name }}</span>
|
||||||
|
<span v-if="item.code" class="object-context-switcher__item-code">{{ item.code }}</span>
|
||||||
|
</span>
|
||||||
|
<icon-ep:check v-if="item.id === objectContextStore.objectId" class="object-context-switcher__check" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ElEmpty v-if="!loading && !displayOptions.length" :description="`暂无可选${domainLabel}`" :image-size="54" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="showAllEntry" type="button" class="object-context-switcher__all" @click="expanded = true">
|
||||||
|
<span>{{ allLabel }}</span>
|
||||||
|
<span class="object-context-switcher__all-meta">{{ hiddenCount }} 个更多</span>
|
||||||
|
<icon-ep:arrow-right class="object-context-switcher__all-arrow" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ElPopover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.object-context-switcher__trigger {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 16rem;
|
||||||
|
height: 32px;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 10px 0 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__trigger:hover,
|
||||||
|
.object-context-switcher__trigger.is-open {
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__trigger-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__trigger-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__search {
|
||||||
|
padding: 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__list {
|
||||||
|
min-height: 84px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__item {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__item:hover,
|
||||||
|
.object-context-switcher__item.is-active {
|
||||||
|
background: rgb(59 130 246 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__item:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__item-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--el-color-primary-light-8);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__item-main {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__item-name,
|
||||||
|
.object-context-switcher__item-code {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__item-name {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__item-code {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__check {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__all {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: calc(100% + 24px);
|
||||||
|
height: 38px;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 -12px -12px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__all:hover {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__all-meta {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-context-switcher__all-arrow {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.object-context-switcher__popper.el-popover) {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 90%);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow:
|
||||||
|
0 12px 28px rgb(15 23 42 / 10%),
|
||||||
|
0 2px 8px rgb(15 23 42 / 6%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,7 +12,7 @@ const { selectedKeyDummy, handleSelect } = useMenu();
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
<Teleport defer :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||||
<ElMenu
|
<ElMenu
|
||||||
ellipsis
|
ellipsis
|
||||||
class="w-full"
|
class="w-full"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useObjectContextStore } from '@/store/modules/object-context';
|
|||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import { useRouterPush } from '@/hooks/common/router';
|
import { useRouterPush } from '@/hooks/common/router';
|
||||||
import FirstLevelMenu from '../components/first-level-menu.vue';
|
import FirstLevelMenu from '../components/first-level-menu.vue';
|
||||||
|
import ObjectContextSwitcher from '../components/object-context-switcher.vue';
|
||||||
import { useMenu, useMixMenuContext } from '../../../context';
|
import { useMenu, useMixMenuContext } from '../../../context';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@@ -92,7 +93,8 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
<!-- defer:BaseLayout 二次挂载时 GlobalMenu 已缓存为同步挂载,目标 div 还未插入 document,不延迟解析会静默失败且不重试 -->
|
||||||
|
<Teleport defer :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||||
<div class="mix-header-nav size-full min-w-0 flex-y-center">
|
<div class="mix-header-nav size-full min-w-0 flex-y-center">
|
||||||
<button
|
<button
|
||||||
v-if="activeFirstLevelMenu"
|
v-if="activeFirstLevelMenu"
|
||||||
@@ -108,7 +110,7 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
|
|||||||
class="mx-12px h-20px w-1px shrink-0 bg-[var(--el-border-color)]"
|
class="mx-12px h-20px w-1px shrink-0 bg-[var(--el-border-color)]"
|
||||||
></div>
|
></div>
|
||||||
<div v-if="showObjectContextInfo" class="context-object-tag h-full flex-y-center">
|
<div v-if="showObjectContextInfo" class="context-object-tag h-full flex-y-center">
|
||||||
<span class="context-object-tag__label">{{ objectContextStore.objectName }}</span>
|
<ObjectContextSwitcher v-if="currentObjectContextDomain" :domain-config="currentObjectContextDomain" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="showObjectContextInfo && headerMenus.length"
|
v-if="showObjectContextInfo && headerMenus.length"
|
||||||
@@ -160,7 +162,7 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
<Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||||
<FirstLevelMenu
|
<FirstLevelMenu
|
||||||
:menus="allMenus"
|
:menus="allMenus"
|
||||||
:active-menu-key="activeFirstLevelMenuKey"
|
:active-menu-key="activeFirstLevelMenuKey"
|
||||||
@@ -208,28 +210,6 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-object-tag {
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-object-tag__label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
max-width: 14rem;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid rgb(148 163 184 / 26%);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: linear-gradient(180deg, rgb(248 250 252 / 95%), rgb(241 245 249 / 92%));
|
|
||||||
color: rgb(15 23 42 / 88%);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-nav-list {
|
.header-nav-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
<Teleport defer :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||||
<ElMenu
|
<ElMenu
|
||||||
ellipsis
|
ellipsis
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@@ -66,7 +66,7 @@ watch(
|
|||||||
<MenuItem v-for="item in firstLevelMenus" :key="item.key" :item="item" :index="item.key" />
|
<MenuItem v-for="item in firstLevelMenus" :key="item.key" :item="item" :index="item.key" />
|
||||||
</ElMenu>
|
</ElMenu>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
<Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||||
<SimpleScrollbar>
|
<SimpleScrollbar>
|
||||||
<ElMenu
|
<ElMenu
|
||||||
mode="vertical"
|
mode="vertical"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
<Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||||
<SimpleScrollbar>
|
<SimpleScrollbar>
|
||||||
<ElMenu
|
<ElMenu
|
||||||
mode="vertical"
|
mode="vertical"
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
<Teleport defer :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||||
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
|
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
|
||||||
<FirstLevelMenu
|
<FirstLevelMenu
|
||||||
:menus="allMenus"
|
:menus="allMenus"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { themeSchemaRecord } from '@/constants/app';
|
|
||||||
import { useThemeStore } from '@/store/modules/theme';
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
import { $t } from '@/locales';
|
import { $t } from '@/locales';
|
||||||
import SettingItem from '../components/setting-item.vue';
|
import SettingItem from '../components/setting-item.vue';
|
||||||
@@ -9,16 +8,6 @@ defineOptions({ name: 'DarkMode' });
|
|||||||
|
|
||||||
const themeStore = useThemeStore();
|
const themeStore = useThemeStore();
|
||||||
|
|
||||||
const icons: Record<UnionKey.ThemeScheme, string> = {
|
|
||||||
light: 'material-symbols:sunny',
|
|
||||||
dark: 'material-symbols:nightlight-rounded',
|
|
||||||
auto: 'material-symbols:hdr-auto'
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleSegmentChange(value: string | number) {
|
|
||||||
themeStore.setThemeScheme(value as UnionKey.ThemeScheme);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGrayscaleChange(value: boolean) {
|
function handleGrayscaleChange(value: boolean) {
|
||||||
themeStore.setGrayscale(value);
|
themeStore.setGrayscale(value);
|
||||||
}
|
}
|
||||||
@@ -33,15 +22,6 @@ const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layo
|
|||||||
<template>
|
<template>
|
||||||
<ElDivider>{{ $t('theme.themeSchema.title') }}</ElDivider>
|
<ElDivider>{{ $t('theme.themeSchema.title') }}</ElDivider>
|
||||||
<div class="flex-col-stretch gap-16px">
|
<div class="flex-col-stretch gap-16px">
|
||||||
<div class="i-flex-center">
|
|
||||||
<ElTabs v-model="themeStore.themeScheme" type="border-card" class="segment" @tab-change="handleSegmentChange">
|
|
||||||
<ElTabPane v-for="(_, key) in themeSchemaRecord" :key="key" :name="key">
|
|
||||||
<template #label>
|
|
||||||
<SvgIcon :icon="icons[key]" class="h-23px text-icon-small" />
|
|
||||||
</template>
|
|
||||||
</ElTabPane>
|
|
||||||
</ElTabs>
|
|
||||||
</div>
|
|
||||||
<Transition name="sider-inverted">
|
<Transition name="sider-inverted">
|
||||||
<SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')">
|
<SettingItem v-if="showSiderInverted" :label="$t('theme.sider.inverted')">
|
||||||
<ElSwitch v-model="themeStore.sider.inverted" />
|
<ElSwitch v-model="themeStore.sider.inverted" />
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const local: App.I18n.Schema = {
|
|||||||
trigger: 'Trigger',
|
trigger: 'Trigger',
|
||||||
update: 'Update',
|
update: 'Update',
|
||||||
updateSuccess: 'Update Success',
|
updateSuccess: 'Update Success',
|
||||||
userCenter: 'User Center',
|
myProfile: 'My Profile',
|
||||||
yesOrNo: {
|
yesOrNo: {
|
||||||
yes: 'Yes',
|
yes: 'Yes',
|
||||||
no: 'No'
|
no: 'No'
|
||||||
@@ -158,23 +158,41 @@ const local: App.I18n.Schema = {
|
|||||||
404: 'Page Not Found',
|
404: 'Page Not Found',
|
||||||
500: 'Server Error',
|
500: 'Server Error',
|
||||||
'iframe-page': 'Iframe',
|
'iframe-page': 'Iframe',
|
||||||
'user-center': 'User Center',
|
workbench: 'Workbench',
|
||||||
function: 'System Function',
|
ticket: 'Ticket',
|
||||||
function_tab: 'Tab',
|
'ticket_my-submitted': 'My Submitted',
|
||||||
'function_multi-tab': 'Multi Tab',
|
'ticket_my-pending': 'My Pending',
|
||||||
'function_hide-child': 'Hide Child',
|
metrics: 'Metrics',
|
||||||
'function_hide-child_one': 'Hide Child',
|
'metrics_project-progress': 'Project Progress',
|
||||||
'function_hide-child_two': 'Two',
|
'metrics_member-efficiency': 'Member Efficiency',
|
||||||
'function_hide-child_three': 'Three',
|
metrics_worktime: 'Worktime',
|
||||||
function_request: 'Request',
|
'personal-center': 'Personal Center',
|
||||||
'function_toggle-auth': 'Toggle Auth',
|
'personal-center_my-profile': 'My Profile',
|
||||||
'function_super-page': 'Super Admin Visible',
|
'personal-center_my-item': 'My Items',
|
||||||
product: 'Product Management',
|
'personal-center_work-report': 'Work Report',
|
||||||
|
'personal-center_work-report_weekly': 'Weekly Report',
|
||||||
|
'personal-center_work-report_monthly': 'Monthly Report',
|
||||||
|
'personal-center_work-report_project': 'Project Fortnightly Report',
|
||||||
|
'personal-center_my-performance': 'My Performance',
|
||||||
|
'personal-center_my-application': 'My Application',
|
||||||
|
'personal-center_overtime-application': 'Overtime Application',
|
||||||
|
'personal-center_pending-approval': 'Pending Approval',
|
||||||
|
infra: 'Infra',
|
||||||
|
'infra_state-machine': 'State Machine',
|
||||||
|
'infra_rd-code': 'R&D Code',
|
||||||
|
product: 'Product',
|
||||||
product_list: 'Product List',
|
product_list: 'Product List',
|
||||||
product_dashboard: 'Product Dashboard',
|
product_dashboard: 'Dashboard',
|
||||||
product_requirement: 'Requirement Pool',
|
product_requirement: 'Requirement',
|
||||||
product_setting: 'Product Settings',
|
product_setting: 'Settings',
|
||||||
system: 'System Management',
|
project: 'Project',
|
||||||
|
project_list: 'Project List',
|
||||||
|
project_project: 'Project',
|
||||||
|
project_project_overview: 'Overview',
|
||||||
|
project_project_requirement: 'Requirement',
|
||||||
|
project_project_execution: 'Task Management',
|
||||||
|
project_project_setting: 'Settings',
|
||||||
|
system: 'System',
|
||||||
system_user: 'User Management',
|
system_user: 'User Management',
|
||||||
'system_user-detail': 'User Detail',
|
'system_user-detail': 'User Detail',
|
||||||
system_role: 'Role Management',
|
system_role: 'Role Management',
|
||||||
@@ -185,31 +203,7 @@ const local: App.I18n.Schema = {
|
|||||||
exception: 'Exception',
|
exception: 'Exception',
|
||||||
exception_403: '403',
|
exception_403: '403',
|
||||||
exception_404: '404',
|
exception_404: '404',
|
||||||
exception_500: '500',
|
exception_500: '500'
|
||||||
plugin: 'Plugin',
|
|
||||||
plugin_copy: 'Copy',
|
|
||||||
plugin_charts: 'Charts',
|
|
||||||
plugin_charts_echarts: 'ECharts',
|
|
||||||
plugin_charts_antv: 'AntV',
|
|
||||||
plugin_charts_vchart: 'VChart',
|
|
||||||
plugin_editor: 'Editor',
|
|
||||||
plugin_editor_quill: 'Quill',
|
|
||||||
plugin_editor_markdown: 'Markdown',
|
|
||||||
plugin_icon: 'Icon',
|
|
||||||
plugin_map: 'Map',
|
|
||||||
plugin_print: 'Print',
|
|
||||||
plugin_swiper: 'Swiper',
|
|
||||||
plugin_video: 'Video',
|
|
||||||
plugin_barcode: 'Barcode',
|
|
||||||
plugin_pinyin: 'pinyin',
|
|
||||||
plugin_excel: 'Excel',
|
|
||||||
plugin_pdf: 'PDF preview',
|
|
||||||
plugin_gantt: 'Gantt Chart',
|
|
||||||
plugin_gantt_dhtmlx: 'dhtmlxGantt',
|
|
||||||
plugin_gantt_vtable: 'VTableGantt',
|
|
||||||
plugin_typeit: 'Typeit',
|
|
||||||
plugin_tables: 'Tables',
|
|
||||||
plugin_tables_vtable: 'VTable'
|
|
||||||
},
|
},
|
||||||
page: {
|
page: {
|
||||||
login: {
|
login: {
|
||||||
@@ -305,45 +299,6 @@ const local: App.I18n.Schema = {
|
|||||||
},
|
},
|
||||||
creativity: 'Creativity'
|
creativity: 'Creativity'
|
||||||
},
|
},
|
||||||
function: {
|
|
||||||
tab: {
|
|
||||||
tabOperate: {
|
|
||||||
title: 'Tab Operation',
|
|
||||||
addTab: 'Add Tab',
|
|
||||||
addTabDesc: 'To user management page',
|
|
||||||
closeTab: 'Close Tab',
|
|
||||||
closeCurrentTab: 'Close Current Tab',
|
|
||||||
closeAboutTab: 'Close "User Management" Tab',
|
|
||||||
addMultiTab: 'Add Multi Tab',
|
|
||||||
addMultiTabDesc1: 'To MultiTab page',
|
|
||||||
addMultiTabDesc2: 'To MultiTab page(with query params)'
|
|
||||||
},
|
|
||||||
tabTitle: {
|
|
||||||
title: 'Tab Title',
|
|
||||||
changeTitle: 'Change Title',
|
|
||||||
change: 'Change',
|
|
||||||
resetTitle: 'Reset Title',
|
|
||||||
reset: 'Reset'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
multiTab: {
|
|
||||||
routeParam: 'Route Param',
|
|
||||||
backTab: 'Back function_tab'
|
|
||||||
},
|
|
||||||
toggleAuth: {
|
|
||||||
toggleAccount: 'Toggle Account',
|
|
||||||
authHook: 'Auth Hook Function `hasAuth`',
|
|
||||||
superAdminVisible: 'Super Admin Visible',
|
|
||||||
adminVisible: 'Admin Visible',
|
|
||||||
adminOrUserVisible: 'Admin and User Visible'
|
|
||||||
},
|
|
||||||
request: {
|
|
||||||
repeatedErrorOccurOnce: 'Repeated Request Error Occurs Once',
|
|
||||||
repeatedError: 'Repeated Request Error',
|
|
||||||
repeatedErrorMsg1: 'Custom Request Error 1',
|
|
||||||
repeatedErrorMsg2: 'Custom Request Error 2'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
system: {
|
system: {
|
||||||
common: {
|
common: {
|
||||||
status: {
|
status: {
|
||||||
@@ -488,6 +443,7 @@ const local: App.I18n.Schema = {
|
|||||||
orgType: {
|
orgType: {
|
||||||
company: 'Company',
|
company: 'Company',
|
||||||
dept: 'Department',
|
dept: 'Department',
|
||||||
|
function: 'Functional Department',
|
||||||
direction: 'Direction',
|
direction: 'Direction',
|
||||||
team: 'Team'
|
team: 'Team'
|
||||||
},
|
},
|
||||||
@@ -685,6 +641,7 @@ const local: App.I18n.Schema = {
|
|||||||
dictStatus: 'Dictionary Status',
|
dictStatus: 'Dictionary Status',
|
||||||
dictLabel: 'Dictionary Label',
|
dictLabel: 'Dictionary Label',
|
||||||
dictValue: 'Dictionary Value',
|
dictValue: 'Dictionary Value',
|
||||||
|
colorType: 'Color Type',
|
||||||
sort: 'Sort',
|
sort: 'Sort',
|
||||||
remark: 'Remark',
|
remark: 'Remark',
|
||||||
form: {
|
form: {
|
||||||
@@ -693,6 +650,7 @@ const local: App.I18n.Schema = {
|
|||||||
dictStatus: 'Please select dictionary status',
|
dictStatus: 'Please select dictionary status',
|
||||||
dictLabel: 'Please enter dictionary label',
|
dictLabel: 'Please enter dictionary label',
|
||||||
dictValue: 'Please enter dictionary value',
|
dictValue: 'Please enter dictionary value',
|
||||||
|
colorType: 'Please enter color type',
|
||||||
sort: 'Please enter sort',
|
sort: 'Please enter sort',
|
||||||
remark: 'Please enter remark'
|
remark: 'Please enter remark'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const local: App.I18n.Schema = {
|
const local: App.I18n.Schema = {
|
||||||
system: {
|
system: {
|
||||||
title: '研发内部管理系统'
|
title: '研发管理系统'
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
action: '操作',
|
action: '操作',
|
||||||
@@ -40,7 +40,7 @@ const local: App.I18n.Schema = {
|
|||||||
trigger: '触发',
|
trigger: '触发',
|
||||||
update: '更新',
|
update: '更新',
|
||||||
updateSuccess: '更新成功',
|
updateSuccess: '更新成功',
|
||||||
userCenter: '个人中心',
|
myProfile: '个人信息',
|
||||||
yesOrNo: {
|
yesOrNo: {
|
||||||
yes: '是',
|
yes: '是',
|
||||||
no: '否'
|
no: '否'
|
||||||
@@ -158,22 +158,40 @@ const local: App.I18n.Schema = {
|
|||||||
404: '页面不存在',
|
404: '页面不存在',
|
||||||
500: '服务器错误',
|
500: '服务器错误',
|
||||||
'iframe-page': '外链页面',
|
'iframe-page': '外链页面',
|
||||||
'user-center': '个人中心',
|
workbench: '工作台',
|
||||||
function: '系统功能',
|
ticket: '工单',
|
||||||
function_tab: '标签页',
|
'ticket_my-submitted': '我提交的工单',
|
||||||
'function_multi-tab': '多标签页',
|
'ticket_my-pending': '待我处理的工单',
|
||||||
'function_hide-child': '隐藏子菜单',
|
metrics: '效能度量',
|
||||||
'function_hide-child_one': '隐藏子菜单',
|
'metrics_project-progress': '项目进度',
|
||||||
'function_hide-child_two': '菜单二',
|
'metrics_member-efficiency': '员工能效',
|
||||||
'function_hide-child_three': '菜单三',
|
metrics_worktime: '工时统计',
|
||||||
function_request: '请求',
|
'personal-center': '个人中心',
|
||||||
'function_toggle-auth': '切换权限',
|
'personal-center_my-profile': '个人信息',
|
||||||
'function_super-page': '超级管理员可见',
|
'personal-center_my-item': '我的事项',
|
||||||
|
'personal-center_work-report': '工作报告',
|
||||||
|
'personal-center_work-report_weekly': '个人周报',
|
||||||
|
'personal-center_work-report_monthly': '个人月报',
|
||||||
|
'personal-center_work-report_project': '项目半月报',
|
||||||
|
'personal-center_my-performance': '我的绩效',
|
||||||
|
'personal-center_my-application': '我的申请',
|
||||||
|
'personal-center_overtime-application': '加班申请',
|
||||||
|
'personal-center_pending-approval': '待我审批',
|
||||||
|
infra: '基础设施',
|
||||||
|
'infra_state-machine': '状态机管理',
|
||||||
|
'infra_rd-code': '研发令号',
|
||||||
product: '产品管理',
|
product: '产品管理',
|
||||||
product_list: '产品列表',
|
product_list: '产品列表',
|
||||||
product_dashboard: '产品仪表盘',
|
product_dashboard: '产品仪表盘',
|
||||||
product_requirement: '需求池',
|
product_requirement: '需求池',
|
||||||
product_setting: '产品设置',
|
product_setting: '产品设置',
|
||||||
|
project: '项目管理',
|
||||||
|
project_list: '项目列表',
|
||||||
|
project_project: '项目详情',
|
||||||
|
project_project_overview: '项目概览',
|
||||||
|
project_project_requirement: '需求池',
|
||||||
|
project_project_execution: '任务管理',
|
||||||
|
project_project_setting: '项目设置',
|
||||||
system: '系统管理',
|
system: '系统管理',
|
||||||
system_user: '用户管理',
|
system_user: '用户管理',
|
||||||
'system_user-detail': '用户详情',
|
'system_user-detail': '用户详情',
|
||||||
@@ -185,31 +203,7 @@ const local: App.I18n.Schema = {
|
|||||||
exception: '异常页',
|
exception: '异常页',
|
||||||
exception_403: '403',
|
exception_403: '403',
|
||||||
exception_404: '404',
|
exception_404: '404',
|
||||||
exception_500: '500',
|
exception_500: '500'
|
||||||
plugin: '插件示例',
|
|
||||||
plugin_copy: '剪贴板',
|
|
||||||
plugin_charts: '图表',
|
|
||||||
plugin_charts_echarts: 'ECharts',
|
|
||||||
plugin_charts_antv: 'AntV',
|
|
||||||
plugin_charts_vchart: 'VChart',
|
|
||||||
plugin_editor: '编辑器',
|
|
||||||
plugin_editor_quill: '富文本编辑器',
|
|
||||||
plugin_editor_markdown: 'MD 编辑器',
|
|
||||||
plugin_icon: '图标',
|
|
||||||
plugin_map: '地图',
|
|
||||||
plugin_print: '打印',
|
|
||||||
plugin_swiper: 'Swiper',
|
|
||||||
plugin_video: '视频',
|
|
||||||
plugin_barcode: '条形码',
|
|
||||||
plugin_pinyin: '拼音',
|
|
||||||
plugin_excel: 'Excel',
|
|
||||||
plugin_pdf: 'PDF 预览',
|
|
||||||
plugin_gantt: '甘特图',
|
|
||||||
plugin_gantt_dhtmlx: 'dhtmlxGantt',
|
|
||||||
plugin_gantt_vtable: 'VTableGantt',
|
|
||||||
plugin_typeit: '打字机',
|
|
||||||
plugin_tables: '表格',
|
|
||||||
plugin_tables_vtable: 'VTable'
|
|
||||||
},
|
},
|
||||||
page: {
|
page: {
|
||||||
login: {
|
login: {
|
||||||
@@ -261,7 +255,7 @@ const local: App.I18n.Schema = {
|
|||||||
about: {
|
about: {
|
||||||
title: '关于',
|
title: '关于',
|
||||||
introduction:
|
introduction:
|
||||||
'灿能研发内部管理系统是灿能电力内部使用的研发管理前端系统,用于承载内部业务模块、工程协作流程和日常管理能力。',
|
'灿能研发管理系统是灿能电力内部使用的研发管理前端系统,用于承载内部业务模块、工程协作流程和日常管理能力。',
|
||||||
projectInfo: {
|
projectInfo: {
|
||||||
title: '项目信息',
|
title: '项目信息',
|
||||||
version: '版本',
|
version: '版本',
|
||||||
@@ -304,45 +298,6 @@ const local: App.I18n.Schema = {
|
|||||||
},
|
},
|
||||||
creativity: '创意'
|
creativity: '创意'
|
||||||
},
|
},
|
||||||
function: {
|
|
||||||
tab: {
|
|
||||||
tabOperate: {
|
|
||||||
title: '标签页操作',
|
|
||||||
addTab: '添加标签页',
|
|
||||||
addTabDesc: '跳转到用户管理页面',
|
|
||||||
closeTab: '关闭标签页',
|
|
||||||
closeCurrentTab: '关闭当前标签页',
|
|
||||||
closeAboutTab: '关闭"用户管理"标签页',
|
|
||||||
addMultiTab: '添加多标签页',
|
|
||||||
addMultiTabDesc1: '跳转到多标签页页面',
|
|
||||||
addMultiTabDesc2: '跳转到多标签页页面(带有查询参数)'
|
|
||||||
},
|
|
||||||
tabTitle: {
|
|
||||||
title: '标签页标题',
|
|
||||||
changeTitle: '修改标题',
|
|
||||||
change: '修改',
|
|
||||||
resetTitle: '重置标题',
|
|
||||||
reset: '重置'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
multiTab: {
|
|
||||||
routeParam: '路由参数',
|
|
||||||
backTab: '返回 function_tab'
|
|
||||||
},
|
|
||||||
toggleAuth: {
|
|
||||||
toggleAccount: '切换账号',
|
|
||||||
authHook: '权限钩子函数 `hasAuth`',
|
|
||||||
superAdminVisible: '超级管理员可见',
|
|
||||||
adminVisible: '管理员可见',
|
|
||||||
adminOrUserVisible: '管理员和用户可见'
|
|
||||||
},
|
|
||||||
request: {
|
|
||||||
repeatedErrorOccurOnce: '重复请求错误只出现一次',
|
|
||||||
repeatedError: '重复请求错误',
|
|
||||||
repeatedErrorMsg1: '自定义请求错误 1',
|
|
||||||
repeatedErrorMsg2: '自定义请求错误 2'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
system: {
|
system: {
|
||||||
common: {
|
common: {
|
||||||
status: {
|
status: {
|
||||||
@@ -484,6 +439,7 @@ const local: App.I18n.Schema = {
|
|||||||
orgType: {
|
orgType: {
|
||||||
company: '公司',
|
company: '公司',
|
||||||
dept: '部门',
|
dept: '部门',
|
||||||
|
function: '职能部门',
|
||||||
direction: '方向',
|
direction: '方向',
|
||||||
team: '团队'
|
team: '团队'
|
||||||
},
|
},
|
||||||
@@ -673,6 +629,7 @@ const local: App.I18n.Schema = {
|
|||||||
dictStatus: '字典状态',
|
dictStatus: '字典状态',
|
||||||
dictLabel: '字典标签',
|
dictLabel: '字典标签',
|
||||||
dictValue: '字典键值',
|
dictValue: '字典键值',
|
||||||
|
colorType: '颜色类型',
|
||||||
sort: '排序',
|
sort: '排序',
|
||||||
remark: '备注',
|
remark: '备注',
|
||||||
form: {
|
form: {
|
||||||
@@ -681,6 +638,7 @@ const local: App.I18n.Schema = {
|
|||||||
dictStatus: '请选择字典状态',
|
dictStatus: '请选择字典状态',
|
||||||
dictLabel: '请输入字典标签',
|
dictLabel: '请输入字典标签',
|
||||||
dictValue: '请输入字典键值',
|
dictValue: '请输入字典键值',
|
||||||
|
colorType: '请输入颜色类型',
|
||||||
sort: '请输入排序',
|
sort: '请输入排序',
|
||||||
remark: '请输入备注'
|
remark: '请输入备注'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,3 @@ import 'element-plus/dist/index.css';
|
|||||||
import 'element-plus/theme-chalk/dark/css-vars.css';
|
import 'element-plus/theme-chalk/dark/css-vars.css';
|
||||||
import 'uno.css';
|
import 'uno.css';
|
||||||
import '../styles/css/global.css';
|
import '../styles/css/global.css';
|
||||||
import 'swiper/css';
|
|
||||||
import 'swiper/css/navigation';
|
|
||||||
import 'swiper/css/pagination';
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { extend } from 'dayjs';
|
import { extend } from 'dayjs';
|
||||||
|
import isoWeek from 'dayjs/plugin/isoWeek';
|
||||||
import localeData from 'dayjs/plugin/localeData';
|
import localeData from 'dayjs/plugin/localeData';
|
||||||
import { setDayjsLocale } from '../locales/dayjs';
|
import { setDayjsLocale } from '../locales/dayjs';
|
||||||
|
|
||||||
export function setupDayjs() {
|
export function setupDayjs() {
|
||||||
extend(localeData);
|
extend(localeData);
|
||||||
|
extend(isoWeek);
|
||||||
|
|
||||||
setDayjsLocale();
|
setDayjsLocale();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,37 +20,30 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
|||||||
500: () => import("@/views/_builtin/500/index.vue"),
|
500: () => import("@/views/_builtin/500/index.vue"),
|
||||||
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
|
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
|
||||||
login: () => import("@/views/_builtin/login/index.vue"),
|
login: () => import("@/views/_builtin/login/index.vue"),
|
||||||
"function_hide-child_one": () => import("@/views/function/hide-child/one/index.vue"),
|
"infra_rd-code": () => import("@/views/infra/rd-code/index.vue"),
|
||||||
"function_hide-child_three": () => import("@/views/function/hide-child/three/index.vue"),
|
"infra_state-machine": () => import("@/views/infra/state-machine/index.vue"),
|
||||||
"function_hide-child_two": () => import("@/views/function/hide-child/two/index.vue"),
|
"metrics_member-efficiency": () => import("@/views/metrics/member-efficiency/index.vue"),
|
||||||
"function_multi-tab": () => import("@/views/function/multi-tab/index.vue"),
|
"metrics_project-progress": () => import("@/views/metrics/project-progress/index.vue"),
|
||||||
function_request: () => import("@/views/function/request/index.vue"),
|
metrics_worktime: () => import("@/views/metrics/worktime/index.vue"),
|
||||||
"function_super-page": () => import("@/views/function/super-page/index.vue"),
|
"personal-center_my-application": () => import("@/views/personal-center/my-application/index.vue"),
|
||||||
function_tab: () => import("@/views/function/tab/index.vue"),
|
"personal-center_my-item": () => import("@/views/personal-center/my-item/index.vue"),
|
||||||
"function_toggle-auth": () => import("@/views/function/toggle-auth/index.vue"),
|
"personal-center_my-performance": () => import("@/views/personal-center/my-performance/index.vue"),
|
||||||
plugin_barcode: () => import("@/views/plugin/barcode/index.vue"),
|
"personal-center_my-profile": () => import("@/views/personal-center/my-profile/index.vue"),
|
||||||
plugin_charts_antv: () => import("@/views/plugin/charts/antv/index.vue"),
|
"personal-center_overtime-application": () => import("@/views/personal-center/overtime-application/index.vue"),
|
||||||
plugin_charts_echarts: () => import("@/views/plugin/charts/echarts/index.vue"),
|
"personal-center_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"),
|
||||||
plugin_charts_vchart: () => import("@/views/plugin/charts/vchart/index.vue"),
|
"personal-center_work-report": () => import("@/views/personal-center/work-report/index.vue"),
|
||||||
plugin_copy: () => import("@/views/plugin/copy/index.vue"),
|
"personal-center_work-report_monthly": () => import("@/views/personal-center/work-report/monthly/index.vue"),
|
||||||
plugin_editor_markdown: () => import("@/views/plugin/editor/markdown/index.vue"),
|
"personal-center_work-report_project": () => import("@/views/personal-center/work-report/project/index.vue"),
|
||||||
plugin_editor_quill: () => import("@/views/plugin/editor/quill/index.vue"),
|
"personal-center_work-report_weekly": () => import("@/views/personal-center/work-report/weekly/index.vue"),
|
||||||
plugin_excel: () => import("@/views/plugin/excel/index.vue"),
|
|
||||||
plugin_gantt_dhtmlx: () => import("@/views/plugin/gantt/dhtmlx/index.vue"),
|
|
||||||
plugin_gantt_vtable: () => import("@/views/plugin/gantt/vtable/index.vue"),
|
|
||||||
plugin_icon: () => import("@/views/plugin/icon/index.vue"),
|
|
||||||
plugin_map: () => import("@/views/plugin/map/index.vue"),
|
|
||||||
plugin_pdf: () => import("@/views/plugin/pdf/index.vue"),
|
|
||||||
plugin_pinyin: () => import("@/views/plugin/pinyin/index.vue"),
|
|
||||||
plugin_print: () => import("@/views/plugin/print/index.vue"),
|
|
||||||
plugin_swiper: () => import("@/views/plugin/swiper/index.vue"),
|
|
||||||
plugin_tables_vtable: () => import("@/views/plugin/tables/vtable/index.vue"),
|
|
||||||
plugin_typeit: () => import("@/views/plugin/typeit/index.vue"),
|
|
||||||
plugin_video: () => import("@/views/plugin/video/index.vue"),
|
|
||||||
product_dashboard: () => import("@/views/product/dashboard/index.vue"),
|
product_dashboard: () => import("@/views/product/dashboard/index.vue"),
|
||||||
product_list: () => import("@/views/product/list/index.vue"),
|
product_list: () => import("@/views/product/list/index.vue"),
|
||||||
product_requirement: () => import("@/views/product/requirement/index.vue"),
|
product_requirement: () => import("@/views/product/requirement/index.vue"),
|
||||||
product_setting: () => import("@/views/product/setting/index.vue"),
|
product_setting: () => import("@/views/product/setting/index.vue"),
|
||||||
|
project_list: () => import("@/views/project/list/index.vue"),
|
||||||
|
project_project_execution: () => import("@/views/project/project/execution/index.vue"),
|
||||||
|
project_project_overview: () => import("@/views/project/project/overview/index.vue"),
|
||||||
|
project_project_requirement: () => import("@/views/project/project/requirement/index.vue"),
|
||||||
|
project_project_setting: () => import("@/views/project/project/setting/index.vue"),
|
||||||
system_dict: () => import("@/views/system/dict/index.vue"),
|
system_dict: () => import("@/views/system/dict/index.vue"),
|
||||||
system_menu: () => import("@/views/system/menu/index.vue"),
|
system_menu: () => import("@/views/system/menu/index.vue"),
|
||||||
system_post: () => import("@/views/system/post/index.vue"),
|
system_post: () => import("@/views/system/post/index.vue"),
|
||||||
@@ -58,5 +51,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
|||||||
"system_user-detail": () => import("@/views/system/user-detail/[id].vue"),
|
"system_user-detail": () => import("@/views/system/user-detail/[id].vue"),
|
||||||
"system_user-management-relation": () => import("@/views/system/user-management-relation/index.vue"),
|
"system_user-management-relation": () => import("@/views/system/user-management-relation/index.vue"),
|
||||||
system_user: () => import("@/views/system/user/index.vue"),
|
system_user: () => import("@/views/system/user/index.vue"),
|
||||||
"user-center": () => import("@/views/user-center/index.vue"),
|
"ticket_my-pending": () => import("@/views/ticket/my-pending/index.vue"),
|
||||||
|
"ticket_my-submitted": () => import("@/views/ticket/my-submitted/index.vue"),
|
||||||
|
workbench: () => import("@/views/workbench/index.vue"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,124 +39,6 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
hideInMenu: true
|
hideInMenu: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'function',
|
|
||||||
path: '/function',
|
|
||||||
component: 'layout.base',
|
|
||||||
meta: {
|
|
||||||
title: 'function',
|
|
||||||
i18nKey: 'route.function',
|
|
||||||
icon: 'icon-park-outline:all-application',
|
|
||||||
order: 6
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'function_hide-child',
|
|
||||||
path: '/function/hide-child',
|
|
||||||
meta: {
|
|
||||||
title: 'function_hide-child',
|
|
||||||
i18nKey: 'route.function_hide-child',
|
|
||||||
icon: 'material-symbols:filter-list-off',
|
|
||||||
order: 2
|
|
||||||
},
|
|
||||||
redirect: '/function/hide-child/one',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'function_hide-child_one',
|
|
||||||
path: '/function/hide-child/one',
|
|
||||||
component: 'view.function_hide-child_one',
|
|
||||||
meta: {
|
|
||||||
title: 'function_hide-child_one',
|
|
||||||
i18nKey: 'route.function_hide-child_one',
|
|
||||||
icon: 'material-symbols:filter-list-off',
|
|
||||||
hideInMenu: true,
|
|
||||||
activeMenu: 'function_hide-child'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'function_hide-child_three',
|
|
||||||
path: '/function/hide-child/three',
|
|
||||||
component: 'view.function_hide-child_three',
|
|
||||||
meta: {
|
|
||||||
title: 'function_hide-child_three',
|
|
||||||
i18nKey: 'route.function_hide-child_three',
|
|
||||||
hideInMenu: true,
|
|
||||||
activeMenu: 'function_hide-child'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'function_hide-child_two',
|
|
||||||
path: '/function/hide-child/two',
|
|
||||||
component: 'view.function_hide-child_two',
|
|
||||||
meta: {
|
|
||||||
title: 'function_hide-child_two',
|
|
||||||
i18nKey: 'route.function_hide-child_two',
|
|
||||||
hideInMenu: true,
|
|
||||||
activeMenu: 'function_hide-child'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'function_multi-tab',
|
|
||||||
path: '/function/multi-tab',
|
|
||||||
component: 'view.function_multi-tab',
|
|
||||||
meta: {
|
|
||||||
title: 'function_multi-tab',
|
|
||||||
i18nKey: 'route.function_multi-tab',
|
|
||||||
icon: 'ic:round-tab',
|
|
||||||
multiTab: true,
|
|
||||||
hideInMenu: true,
|
|
||||||
activeMenu: 'function_tab'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'function_request',
|
|
||||||
path: '/function/request',
|
|
||||||
component: 'view.function_request',
|
|
||||||
meta: {
|
|
||||||
title: 'function_request',
|
|
||||||
i18nKey: 'route.function_request',
|
|
||||||
icon: 'carbon:network-overlay',
|
|
||||||
order: 3
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'function_super-page',
|
|
||||||
path: '/function/super-page',
|
|
||||||
component: 'view.function_super-page',
|
|
||||||
meta: {
|
|
||||||
title: 'function_super-page',
|
|
||||||
i18nKey: 'route.function_super-page',
|
|
||||||
icon: 'ic:round-supervisor-account',
|
|
||||||
order: 5,
|
|
||||||
roles: ['R_SUPER']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'function_tab',
|
|
||||||
path: '/function/tab',
|
|
||||||
component: 'view.function_tab',
|
|
||||||
meta: {
|
|
||||||
title: 'function_tab',
|
|
||||||
i18nKey: 'route.function_tab',
|
|
||||||
icon: 'ic:round-tab',
|
|
||||||
order: 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'function_toggle-auth',
|
|
||||||
path: '/function/toggle-auth',
|
|
||||||
component: 'view.function_toggle-auth',
|
|
||||||
meta: {
|
|
||||||
title: 'function_toggle-auth',
|
|
||||||
i18nKey: 'route.function_toggle-auth',
|
|
||||||
icon: 'ic:round-construction',
|
|
||||||
order: 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'iframe-page',
|
name: 'iframe-page',
|
||||||
path: '/iframe-page/:url',
|
path: '/iframe-page/:url',
|
||||||
@@ -170,6 +52,43 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
keepAlive: true
|
keepAlive: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'infra',
|
||||||
|
path: '/infra',
|
||||||
|
component: 'layout.base',
|
||||||
|
meta: {
|
||||||
|
title: 'infra',
|
||||||
|
i18nKey: 'route.infra',
|
||||||
|
icon: 'ep:monitor',
|
||||||
|
order: 20
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'infra_rd-code',
|
||||||
|
path: '/infra/rd-code',
|
||||||
|
component: 'view.infra_rd-code',
|
||||||
|
meta: {
|
||||||
|
title: 'infra_rd-code',
|
||||||
|
i18nKey: 'route.infra_rd-code',
|
||||||
|
icon: 'mdi:identifier',
|
||||||
|
order: 2,
|
||||||
|
keepAlive: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'infra_state-machine',
|
||||||
|
path: '/infra/state-machine',
|
||||||
|
component: 'view.infra_state-machine',
|
||||||
|
meta: {
|
||||||
|
title: 'infra_state-machine',
|
||||||
|
i18nKey: 'route.infra_state-machine',
|
||||||
|
icon: 'mdi:state-machine',
|
||||||
|
order: 1,
|
||||||
|
keepAlive: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'login',
|
name: 'login',
|
||||||
path: '/login/:module(pwd-login|reset-pwd)?',
|
path: '/login/:module(pwd-login|reset-pwd)?',
|
||||||
@@ -183,250 +102,183 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'plugin',
|
name: 'metrics',
|
||||||
path: '/plugin',
|
path: '/metrics',
|
||||||
component: 'layout.base',
|
component: 'layout.base',
|
||||||
meta: {
|
meta: {
|
||||||
title: '插件示例',
|
title: 'metrics',
|
||||||
i18nKey: 'route.plugin',
|
i18nKey: 'route.metrics',
|
||||||
order: 7,
|
icon: 'mdi:chart-line',
|
||||||
icon: 'clarity:plugin-line'
|
order: 7
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
name: 'plugin_barcode',
|
name: 'metrics_member-efficiency',
|
||||||
path: '/plugin/barcode',
|
path: '/metrics/member-efficiency',
|
||||||
component: 'view.plugin_barcode',
|
component: 'view.metrics_member-efficiency',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'plugin_barcode',
|
title: 'metrics_member-efficiency',
|
||||||
i18nKey: 'route.plugin_barcode',
|
i18nKey: 'route.metrics_member-efficiency',
|
||||||
icon: 'ic:round-barcode'
|
icon: 'mdi:account-multiple-check-outline',
|
||||||
}
|
order: 2,
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_charts',
|
|
||||||
path: '/plugin/charts',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_charts',
|
|
||||||
i18nKey: 'route.plugin_charts',
|
|
||||||
icon: 'mdi:chart-areaspline'
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'plugin_charts_antv',
|
|
||||||
path: '/plugin/charts/antv',
|
|
||||||
component: 'view.plugin_charts_antv',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_charts_antv',
|
|
||||||
i18nKey: 'route.plugin_charts_antv',
|
|
||||||
icon: 'hugeicons:flow-square'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_charts_echarts',
|
|
||||||
path: '/plugin/charts/echarts',
|
|
||||||
component: 'view.plugin_charts_echarts',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_charts_echarts',
|
|
||||||
i18nKey: 'route.plugin_charts_echarts',
|
|
||||||
icon: 'simple-icons:apacheecharts'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_charts_vchart',
|
|
||||||
path: '/plugin/charts/vchart',
|
|
||||||
component: 'view.plugin_charts_vchart',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_charts_vchart',
|
|
||||||
i18nKey: 'route.plugin_charts_vchart',
|
|
||||||
localIcon: 'visactor'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_copy',
|
|
||||||
path: '/plugin/copy',
|
|
||||||
component: 'view.plugin_copy',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_copy',
|
|
||||||
i18nKey: 'route.plugin_copy',
|
|
||||||
icon: 'mdi:clipboard-outline'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_editor',
|
|
||||||
path: '/plugin/editor',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_editor',
|
|
||||||
i18nKey: 'route.plugin_editor',
|
|
||||||
icon: 'icon-park-outline:editor'
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: 'plugin_editor_markdown',
|
|
||||||
path: '/plugin/editor/markdown',
|
|
||||||
component: 'view.plugin_editor_markdown',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_editor_markdown',
|
|
||||||
i18nKey: 'route.plugin_editor_markdown',
|
|
||||||
icon: 'ri:markdown-line'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_editor_quill',
|
|
||||||
path: '/plugin/editor/quill',
|
|
||||||
component: 'view.plugin_editor_quill',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_editor_quill',
|
|
||||||
i18nKey: 'route.plugin_editor_quill',
|
|
||||||
icon: 'mdi:file-document-edit-outline'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_excel',
|
|
||||||
path: '/plugin/excel',
|
|
||||||
component: 'view.plugin_excel',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_excel',
|
|
||||||
i18nKey: 'route.plugin_excel',
|
|
||||||
icon: 'ri:file-excel-2-line',
|
|
||||||
keepAlive: true
|
keepAlive: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'plugin_gantt',
|
name: 'metrics_project-progress',
|
||||||
path: '/plugin/gantt',
|
path: '/metrics/project-progress',
|
||||||
|
component: 'view.metrics_project-progress',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'plugin_gantt',
|
title: 'metrics_project-progress',
|
||||||
i18nKey: 'route.plugin_gantt',
|
i18nKey: 'route.metrics_project-progress',
|
||||||
icon: 'ant-design:bar-chart-outlined'
|
icon: 'mdi:progress-clock',
|
||||||
|
order: 1,
|
||||||
|
keepAlive: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'metrics_worktime',
|
||||||
|
path: '/metrics/worktime',
|
||||||
|
component: 'view.metrics_worktime',
|
||||||
|
meta: {
|
||||||
|
title: 'metrics_worktime',
|
||||||
|
i18nKey: 'route.metrics_worktime',
|
||||||
|
icon: 'mdi:clock-time-five-outline',
|
||||||
|
order: 3,
|
||||||
|
keepAlive: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'personal-center',
|
||||||
|
path: '/personal-center',
|
||||||
|
component: 'layout.base',
|
||||||
|
meta: {
|
||||||
|
title: 'personal-center',
|
||||||
|
i18nKey: 'route.personal-center',
|
||||||
|
icon: 'mdi:account-circle-outline',
|
||||||
|
order: 8
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'personal-center_my-application',
|
||||||
|
path: '/personal-center/my-application',
|
||||||
|
component: 'view.personal-center_my-application',
|
||||||
|
meta: {
|
||||||
|
title: 'personal-center_my-application',
|
||||||
|
i18nKey: 'route.personal-center_my-application',
|
||||||
|
icon: 'mdi:file-document-outline',
|
||||||
|
order: 4,
|
||||||
|
keepAlive: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'personal-center_my-item',
|
||||||
|
path: '/personal-center/my-item',
|
||||||
|
component: 'view.personal-center_my-item',
|
||||||
|
meta: {
|
||||||
|
title: 'personal-center_my-item',
|
||||||
|
i18nKey: 'route.personal-center_my-item',
|
||||||
|
icon: 'mdi:checkbox-multiple-blank-circle-outline',
|
||||||
|
order: 1,
|
||||||
|
keepAlive: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'personal-center_my-performance',
|
||||||
|
path: '/personal-center/my-performance',
|
||||||
|
component: 'view.personal-center_my-performance',
|
||||||
|
meta: {
|
||||||
|
title: 'personal-center_my-performance',
|
||||||
|
i18nKey: 'route.personal-center_my-performance',
|
||||||
|
icon: 'mdi:trophy-outline',
|
||||||
|
order: 4,
|
||||||
|
keepAlive: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'personal-center_my-profile',
|
||||||
|
path: '/personal-center/my-profile',
|
||||||
|
component: 'view.personal-center_my-profile',
|
||||||
|
meta: {
|
||||||
|
title: 'personal-center_my-profile',
|
||||||
|
i18nKey: 'route.personal-center_my-profile',
|
||||||
|
icon: 'mdi:account-box-outline',
|
||||||
|
order: 0,
|
||||||
|
keepAlive: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'personal-center_overtime-application',
|
||||||
|
path: '/personal-center/overtime-application',
|
||||||
|
component: 'view.personal-center_overtime-application',
|
||||||
|
meta: {
|
||||||
|
title: 'personal-center_overtime-application',
|
||||||
|
i18nKey: 'route.personal-center_overtime-application',
|
||||||
|
icon: 'mdi:clock-plus-outline',
|
||||||
|
order: 6,
|
||||||
|
keepAlive: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'personal-center_pending-approval',
|
||||||
|
path: '/personal-center/pending-approval',
|
||||||
|
component: 'view.personal-center_pending-approval',
|
||||||
|
meta: {
|
||||||
|
title: 'personal-center_pending-approval',
|
||||||
|
i18nKey: 'route.personal-center_pending-approval',
|
||||||
|
icon: 'mdi:check-decagram-outline',
|
||||||
|
order: 7,
|
||||||
|
keepAlive: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'personal-center_work-report',
|
||||||
|
path: '/personal-center/work-report',
|
||||||
|
component: 'view.personal-center_work-report',
|
||||||
|
meta: {
|
||||||
|
title: 'personal-center_work-report',
|
||||||
|
i18nKey: 'route.personal-center_work-report',
|
||||||
|
icon: 'mdi:file-chart-outline',
|
||||||
|
order: 3,
|
||||||
|
keepAlive: true
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
name: 'plugin_gantt_dhtmlx',
|
name: 'personal-center_work-report_monthly',
|
||||||
path: '/plugin/gantt/dhtmlx',
|
path: '/personal-center/work-report/monthly',
|
||||||
component: 'view.plugin_gantt_dhtmlx',
|
component: 'view.personal-center_work-report_monthly',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'plugin_gantt_dhtmlx',
|
title: 'personal-center_work-report_monthly',
|
||||||
i18nKey: 'route.plugin_gantt_dhtmlx',
|
i18nKey: 'route.personal-center_work-report_monthly',
|
||||||
icon: 'gridicons:posts'
|
hideInMenu: true,
|
||||||
|
activeMenu: 'personal-center_work-report'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'plugin_gantt_vtable',
|
name: 'personal-center_work-report_project',
|
||||||
path: '/plugin/gantt/vtable',
|
path: '/personal-center/work-report/project',
|
||||||
component: 'view.plugin_gantt_vtable',
|
component: 'view.personal-center_work-report_project',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'plugin_gantt_vtable',
|
title: 'personal-center_work-report_project',
|
||||||
i18nKey: 'route.plugin_gantt_vtable',
|
i18nKey: 'route.personal-center_work-report_project',
|
||||||
localIcon: 'visactor'
|
hideInMenu: true,
|
||||||
|
activeMenu: 'personal-center_work-report'
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_icon',
|
|
||||||
path: '/plugin/icon',
|
|
||||||
component: 'view.plugin_icon',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_icon',
|
|
||||||
i18nKey: 'route.plugin_icon',
|
|
||||||
localIcon: 'custom-icon'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_map',
|
|
||||||
path: '/plugin/map',
|
|
||||||
component: 'view.plugin_map',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_map',
|
|
||||||
i18nKey: 'route.plugin_map',
|
|
||||||
icon: 'mdi:map'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_pdf',
|
|
||||||
path: '/plugin/pdf',
|
|
||||||
component: 'view.plugin_pdf',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_pdf',
|
|
||||||
i18nKey: 'route.plugin_pdf',
|
|
||||||
icon: 'uiw:file-pdf'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_pinyin',
|
|
||||||
path: '/plugin/pinyin',
|
|
||||||
component: 'view.plugin_pinyin',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_pinyin',
|
|
||||||
i18nKey: 'route.plugin_pinyin',
|
|
||||||
icon: 'entypo-social:google-hangouts'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_print',
|
|
||||||
path: '/plugin/print',
|
|
||||||
component: 'view.plugin_print',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_print',
|
|
||||||
i18nKey: 'route.plugin_print',
|
|
||||||
icon: 'mdi:printer'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_swiper',
|
|
||||||
path: '/plugin/swiper',
|
|
||||||
component: 'view.plugin_swiper',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_swiper',
|
|
||||||
i18nKey: 'route.plugin_swiper',
|
|
||||||
icon: 'simple-icons:swiper'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_tables',
|
|
||||||
path: '/plugin/tables',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_tables',
|
|
||||||
i18nKey: 'route.plugin_tables',
|
|
||||||
icon: 'icon-park-outline:table'
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
{
|
{
|
||||||
name: 'plugin_tables_vtable',
|
name: 'personal-center_work-report_weekly',
|
||||||
path: '/plugin/tables/vtable',
|
path: '/personal-center/work-report/weekly',
|
||||||
component: 'view.plugin_tables_vtable',
|
component: 'view.personal-center_work-report_weekly',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'plugin_tables_vtable',
|
title: 'personal-center_work-report_weekly',
|
||||||
i18nKey: 'route.plugin_tables_vtable',
|
i18nKey: 'route.personal-center_work-report_weekly',
|
||||||
localIcon: 'visactor'
|
hideInMenu: true,
|
||||||
|
activeMenu: 'personal-center_work-report'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_typeit',
|
|
||||||
path: '/plugin/typeit',
|
|
||||||
component: 'view.plugin_typeit',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_typeit',
|
|
||||||
i18nKey: 'route.plugin_typeit',
|
|
||||||
icon: 'mdi:typewriter'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'plugin_video',
|
|
||||||
path: '/plugin/video',
|
|
||||||
component: 'view.plugin_video',
|
|
||||||
meta: {
|
|
||||||
title: 'plugin_video',
|
|
||||||
i18nKey: 'route.plugin_video',
|
|
||||||
icon: 'mdi:video'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -488,6 +340,87 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'project',
|
||||||
|
path: '/project',
|
||||||
|
component: 'layout.base',
|
||||||
|
meta: {
|
||||||
|
title: 'project',
|
||||||
|
i18nKey: 'route.project',
|
||||||
|
icon: 'mdi:briefcase-outline',
|
||||||
|
order: 5
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'project_list',
|
||||||
|
path: '/project/list',
|
||||||
|
component: 'view.project_list',
|
||||||
|
meta: {
|
||||||
|
title: 'project_list',
|
||||||
|
i18nKey: 'route.project_list',
|
||||||
|
icon: 'material-symbols:view-list-outline-rounded',
|
||||||
|
order: 1,
|
||||||
|
keepAlive: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'project_project',
|
||||||
|
path: '/project/project',
|
||||||
|
meta: {
|
||||||
|
title: 'project_project',
|
||||||
|
i18nKey: 'route.project_project',
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenu: 'project_list'
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'project_project_execution',
|
||||||
|
path: '/project/project/execution',
|
||||||
|
component: 'view.project_project_execution',
|
||||||
|
meta: {
|
||||||
|
title: 'project_project_execution',
|
||||||
|
i18nKey: 'route.project_project_execution',
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenu: 'project_list'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'project_project_overview',
|
||||||
|
path: '/project/project/overview',
|
||||||
|
component: 'view.project_project_overview',
|
||||||
|
meta: {
|
||||||
|
title: 'project_project_overview',
|
||||||
|
i18nKey: 'route.project_project_overview',
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenu: 'project_list'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'project_project_requirement',
|
||||||
|
path: '/project/project/requirement',
|
||||||
|
component: 'view.project_project_requirement',
|
||||||
|
meta: {
|
||||||
|
title: 'project_project_requirement',
|
||||||
|
i18nKey: 'route.project_project_requirement',
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenu: 'project_list'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'project_project_setting',
|
||||||
|
path: '/project/project/setting',
|
||||||
|
component: 'view.project_project_setting',
|
||||||
|
meta: {
|
||||||
|
title: 'project_project_setting',
|
||||||
|
i18nKey: 'route.project_project_setting',
|
||||||
|
hideInMenu: true,
|
||||||
|
activeMenu: 'project_list'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'system',
|
name: 'system',
|
||||||
path: '/system',
|
path: '/system',
|
||||||
@@ -583,13 +516,53 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'user-center',
|
name: 'ticket',
|
||||||
path: '/user-center',
|
path: '/ticket',
|
||||||
component: 'layout.base$view.user-center',
|
component: 'layout.base',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'user-center',
|
title: 'ticket',
|
||||||
i18nKey: 'route.user-center',
|
i18nKey: 'route.ticket',
|
||||||
hideInMenu: true
|
icon: 'mdi:ticket-confirmation-outline',
|
||||||
|
order: 6
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'ticket_my-pending',
|
||||||
|
path: '/ticket/my-pending',
|
||||||
|
component: 'view.ticket_my-pending',
|
||||||
|
meta: {
|
||||||
|
title: 'ticket_my-pending',
|
||||||
|
i18nKey: 'route.ticket_my-pending',
|
||||||
|
icon: 'mdi:inbox-arrow-down-outline',
|
||||||
|
order: 2,
|
||||||
|
keepAlive: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ticket_my-submitted',
|
||||||
|
path: '/ticket/my-submitted',
|
||||||
|
component: 'view.ticket_my-submitted',
|
||||||
|
meta: {
|
||||||
|
title: 'ticket_my-submitted',
|
||||||
|
i18nKey: 'route.ticket_my-submitted',
|
||||||
|
icon: 'mdi:upload-outline',
|
||||||
|
order: 1,
|
||||||
|
keepAlive: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'workbench',
|
||||||
|
path: '/workbench',
|
||||||
|
component: 'layout.base$view.workbench',
|
||||||
|
meta: {
|
||||||
|
title: 'workbench',
|
||||||
|
i18nKey: 'route.workbench',
|
||||||
|
icon: 'mdi:view-dashboard-outline',
|
||||||
|
order: 1,
|
||||||
|
keepAlive: true,
|
||||||
|
constant: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -170,47 +170,38 @@ const routeMap: RouteMap = {
|
|||||||
"403": "/403",
|
"403": "/403",
|
||||||
"404": "/404",
|
"404": "/404",
|
||||||
"500": "/500",
|
"500": "/500",
|
||||||
"function": "/function",
|
|
||||||
"function_hide-child": "/function/hide-child",
|
|
||||||
"function_hide-child_one": "/function/hide-child/one",
|
|
||||||
"function_hide-child_three": "/function/hide-child/three",
|
|
||||||
"function_hide-child_two": "/function/hide-child/two",
|
|
||||||
"function_multi-tab": "/function/multi-tab",
|
|
||||||
"function_request": "/function/request",
|
|
||||||
"function_super-page": "/function/super-page",
|
|
||||||
"function_tab": "/function/tab",
|
|
||||||
"function_toggle-auth": "/function/toggle-auth",
|
|
||||||
"iframe-page": "/iframe-page/:url",
|
"iframe-page": "/iframe-page/:url",
|
||||||
|
"infra": "/infra",
|
||||||
|
"infra_rd-code": "/infra/rd-code",
|
||||||
|
"infra_state-machine": "/infra/state-machine",
|
||||||
"login": "/login/:module(pwd-login|reset-pwd)?",
|
"login": "/login/:module(pwd-login|reset-pwd)?",
|
||||||
"plugin": "/plugin",
|
"metrics": "/metrics",
|
||||||
"plugin_barcode": "/plugin/barcode",
|
"metrics_member-efficiency": "/metrics/member-efficiency",
|
||||||
"plugin_charts": "/plugin/charts",
|
"metrics_project-progress": "/metrics/project-progress",
|
||||||
"plugin_charts_antv": "/plugin/charts/antv",
|
"metrics_worktime": "/metrics/worktime",
|
||||||
"plugin_charts_echarts": "/plugin/charts/echarts",
|
"personal-center": "/personal-center",
|
||||||
"plugin_charts_vchart": "/plugin/charts/vchart",
|
"personal-center_my-application": "/personal-center/my-application",
|
||||||
"plugin_copy": "/plugin/copy",
|
"personal-center_my-item": "/personal-center/my-item",
|
||||||
"plugin_editor": "/plugin/editor",
|
"personal-center_my-performance": "/personal-center/my-performance",
|
||||||
"plugin_editor_markdown": "/plugin/editor/markdown",
|
"personal-center_my-profile": "/personal-center/my-profile",
|
||||||
"plugin_editor_quill": "/plugin/editor/quill",
|
"personal-center_overtime-application": "/personal-center/overtime-application",
|
||||||
"plugin_excel": "/plugin/excel",
|
"personal-center_pending-approval": "/personal-center/pending-approval",
|
||||||
"plugin_gantt": "/plugin/gantt",
|
"personal-center_work-report": "/personal-center/work-report",
|
||||||
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx",
|
"personal-center_work-report_monthly": "/personal-center/work-report/monthly",
|
||||||
"plugin_gantt_vtable": "/plugin/gantt/vtable",
|
"personal-center_work-report_project": "/personal-center/work-report/project",
|
||||||
"plugin_icon": "/plugin/icon",
|
"personal-center_work-report_weekly": "/personal-center/work-report/weekly",
|
||||||
"plugin_map": "/plugin/map",
|
|
||||||
"plugin_pdf": "/plugin/pdf",
|
|
||||||
"plugin_pinyin": "/plugin/pinyin",
|
|
||||||
"plugin_print": "/plugin/print",
|
|
||||||
"plugin_swiper": "/plugin/swiper",
|
|
||||||
"plugin_tables": "/plugin/tables",
|
|
||||||
"plugin_tables_vtable": "/plugin/tables/vtable",
|
|
||||||
"plugin_typeit": "/plugin/typeit",
|
|
||||||
"plugin_video": "/plugin/video",
|
|
||||||
"product": "/product",
|
"product": "/product",
|
||||||
"product_dashboard": "/product/dashboard",
|
"product_dashboard": "/product/dashboard",
|
||||||
"product_list": "/product/list",
|
"product_list": "/product/list",
|
||||||
"product_requirement": "/product/requirement",
|
"product_requirement": "/product/requirement",
|
||||||
"product_setting": "/product/setting",
|
"product_setting": "/product/setting",
|
||||||
|
"project": "/project",
|
||||||
|
"project_list": "/project/list",
|
||||||
|
"project_project": "/project/project",
|
||||||
|
"project_project_execution": "/project/project/execution",
|
||||||
|
"project_project_overview": "/project/project/overview",
|
||||||
|
"project_project_requirement": "/project/project/requirement",
|
||||||
|
"project_project_setting": "/project/project/setting",
|
||||||
"system": "/system",
|
"system": "/system",
|
||||||
"system_dict": "/system/dict",
|
"system_dict": "/system/dict",
|
||||||
"system_menu": "/system/menu",
|
"system_menu": "/system/menu",
|
||||||
@@ -219,7 +210,10 @@ const routeMap: RouteMap = {
|
|||||||
"system_user": "/system/user",
|
"system_user": "/system/user",
|
||||||
"system_user-detail": "/system/user-detail/:id",
|
"system_user-detail": "/system/user-detail/:id",
|
||||||
"system_user-management-relation": "/system/user-management-relation",
|
"system_user-management-relation": "/system/user-management-relation",
|
||||||
"user-center": "/user-center"
|
"ticket": "/ticket",
|
||||||
|
"ticket_my-pending": "/ticket/my-pending",
|
||||||
|
"ticket_my-submitted": "/ticket/my-submitted",
|
||||||
|
"workbench": "/workbench"
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||||
import { request } from '../request';
|
import { request } from '../request';
|
||||||
import { clearUserRouteCache } from './route';
|
import { clearUserRouteCache } from './route';
|
||||||
import type { ServiceRequestResult } from './shared';
|
import { type ServiceRequestResult, mapServiceResult, normalizeStringId } from './shared';
|
||||||
|
|
||||||
/** 后端登录返回 */
|
/** 后端登录返回 */
|
||||||
interface BackendLoginToken {
|
interface BackendLoginToken {
|
||||||
@@ -14,10 +14,38 @@ interface BackendLoginToken {
|
|||||||
interface BackendUserInfoDTO {
|
interface BackendUserInfoDTO {
|
||||||
userId: string | number;
|
userId: string | number;
|
||||||
userName?: string | null;
|
userName?: string | null;
|
||||||
|
nickname?: string | null;
|
||||||
roles?: string[] | null;
|
roles?: string[] | null;
|
||||||
buttons?: string[] | null;
|
buttons?: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BackendMyProfileDetailDTO {
|
||||||
|
id?: string | number | null;
|
||||||
|
userId?: string | number | null;
|
||||||
|
username?: string | null;
|
||||||
|
userName?: string | null;
|
||||||
|
nickname?: string | null;
|
||||||
|
company?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
mobile?: string | null;
|
||||||
|
sex?: Api.SystemManage.UserGender | null;
|
||||||
|
avatar?: string | null;
|
||||||
|
loginIp?: string | null;
|
||||||
|
loginDate?: string | null;
|
||||||
|
createTime?: string | null;
|
||||||
|
roles?: Api.SystemManage.RoleSimple[] | null;
|
||||||
|
dept?: Api.SystemManage.DeptSimple | null;
|
||||||
|
position?: Api.SystemManage.PostSimple | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackendFileDTO {
|
||||||
|
id: string | number;
|
||||||
|
configId: string | number;
|
||||||
|
name?: string | null;
|
||||||
|
path: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
let userInfoPromise: Promise<ServiceRequestResult<BackendUserInfoDTO>> | null = null;
|
let userInfoPromise: Promise<ServiceRequestResult<BackendUserInfoDTO>> | null = null;
|
||||||
|
|
||||||
/** 将后端 token 结构转换成前端现有结构 */
|
/** 将后端 token 结构转换成前端现有结构 */
|
||||||
@@ -32,11 +60,48 @@ function mapUserInfo(data: BackendUserInfoDTO): Api.Auth.UserInfo {
|
|||||||
return {
|
return {
|
||||||
userId: String(data.userId ?? ''),
|
userId: String(data.userId ?? ''),
|
||||||
userName: data.userName ?? '',
|
userName: data.userName ?? '',
|
||||||
|
nickname: data.nickname ?? '',
|
||||||
roles: data.roles ?? [],
|
roles: data.roles ?? [],
|
||||||
buttons: data.buttons ?? []
|
buttons: data.buttons ?? []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeStringId(value: string | number | null | undefined): string | null {
|
||||||
|
return value === null || value === undefined ? null : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
function mapMyProfileDetail(data: BackendMyProfileDetailDTO, fallbackUserId = ''): Api.Auth.MyProfileDetail {
|
||||||
|
const baseInfo = {
|
||||||
|
userId: String(data.id ?? data.userId ?? fallbackUserId ?? ''),
|
||||||
|
username: data.username ?? data.userName ?? '',
|
||||||
|
nickname: data.nickname ?? '',
|
||||||
|
deptId: safeStringId(data.dept?.id),
|
||||||
|
deptName: data.dept?.name ?? '',
|
||||||
|
positionId: safeStringId(data.position?.id),
|
||||||
|
positionName: data.position?.name ?? ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const contactInfo = {
|
||||||
|
company: data.company ?? null,
|
||||||
|
email: data.email ?? '',
|
||||||
|
mobile: data.mobile ?? '',
|
||||||
|
sex: data.sex ?? 0,
|
||||||
|
avatar: data.avatar ?? ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const extraInfo = {
|
||||||
|
roles: data.roles ?? [],
|
||||||
|
dept: data.dept ?? null,
|
||||||
|
position: data.position ?? null,
|
||||||
|
loginIp: data.loginIp ?? '',
|
||||||
|
loginDate: data.loginDate ?? null,
|
||||||
|
createTime: data.createTime ?? null
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...baseInfo, ...contactInfo, ...extraInfo };
|
||||||
|
}
|
||||||
|
|
||||||
export function clearUserInfoCache() {
|
export function clearUserInfoCache() {
|
||||||
userInfoPromise = null;
|
userInfoPromise = null;
|
||||||
}
|
}
|
||||||
@@ -99,19 +164,88 @@ export async function fetchGetUserInfo(force = false): Promise<ServiceRequestRes
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取当前登录人资料详情 */
|
||||||
|
export async function fetchGetMyProfileDetail(
|
||||||
|
options: {
|
||||||
|
userId?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<ServiceRequestResult<Api.Auth.MyProfileDetail>> {
|
||||||
|
const result = await request<BackendMyProfileDetailDTO>({
|
||||||
|
url: `${SYSTEM_SERVICE_PREFIX}/user/profile/get`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error || !result.data) {
|
||||||
|
return result as ServiceRequestResult<Api.Auth.MyProfileDetail>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: mapMyProfileDetail(result.data, options.userId ?? '')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新当前登录人基础资料 */
|
||||||
|
export function fetchUpdateMyProfile(data: Api.Auth.UpdateMyProfileParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
url: `${SYSTEM_SERVICE_PREFIX}/user/profile/update`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改当前登录人密码 */
|
||||||
|
export async function fetchUpdateMyAvatar(file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const result = await request<BackendFileDTO>({
|
||||||
|
url: `${SYSTEM_SERVICE_PREFIX}/user/profile/update-avatar`,
|
||||||
|
method: 'put',
|
||||||
|
data: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<BackendFileDTO>, data => ({
|
||||||
|
...data,
|
||||||
|
id: normalizeStringId(data.id),
|
||||||
|
configId: normalizeStringId(data.configId)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUpdateMyPassword(data: Api.Auth.UpdateMyPasswordParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
url: `${SYSTEM_SERVICE_PREFIX}/user/profile/update-password`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 刷新 token
|
* 刷新 token
|
||||||
*
|
*
|
||||||
* @param refreshToken 刷新 token
|
* @param refreshToken 刷新 token
|
||||||
*/
|
*/
|
||||||
export function fetchRefreshToken(refreshToken: string) {
|
export async function fetchRefreshToken(refreshToken: string): Promise<ServiceRequestResult<Api.Auth.LoginToken>> {
|
||||||
return request<Api.Auth.LoginToken>({
|
// 后端要求 refreshToken 通过 query 参数传递,且 Content-Type 为 form-urlencoded
|
||||||
|
// skipAuth: 不注入过期 access 头,否则会被网关拦下死循环(网关一律校验 Authorization,不看 PermitAll)
|
||||||
|
const result = await request<BackendLoginToken>({
|
||||||
url: `${SYSTEM_SERVICE_PREFIX}/auth/refresh-token`,
|
url: `${SYSTEM_SERVICE_PREFIX}/auth/refresh-token`,
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data: {
|
params: { refreshToken },
|
||||||
refreshToken
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
}
|
skipAuth: true,
|
||||||
|
suppressErrorMessage: true,
|
||||||
|
skipTokenRefresh: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.error || !result.data) {
|
||||||
|
return result as ServiceRequestResult<Api.Auth.LoginToken>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: mapLoginToken(result.data)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||||
import { request } from '../request';
|
import { request } from '../request';
|
||||||
|
import { type ServiceRequestResult, mapServiceResult } from './shared';
|
||||||
|
|
||||||
const DICT_TYPE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-type`;
|
const DICT_TYPE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-type`;
|
||||||
const DICT_DATA_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-data`;
|
const DICT_DATA_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-data`;
|
||||||
@@ -15,6 +16,56 @@ function createBatchDeleteQuery(ids: number[]) {
|
|||||||
return query.toString();
|
return query.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DictDataResponse = Omit<Api.Dict.DictData, 'colorType'> & {
|
||||||
|
colorType?: string | null;
|
||||||
|
color_type?: string | null;
|
||||||
|
css_class?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DictDataPageResponse = Omit<Api.Dict.PageResult<Api.Dict.DictData>, 'list'> & {
|
||||||
|
list: DictDataResponse[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type FrontendDictDataResponse = Omit<Api.Dict.FrontendDictData, 'colorType'> & {
|
||||||
|
colorType?: string | null;
|
||||||
|
color_type?: string | null;
|
||||||
|
css_class?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FrontendDictCacheResponse = Record<string, FrontendDictDataResponse[]>;
|
||||||
|
|
||||||
|
function normalizeColorType(value?: string | null) {
|
||||||
|
return value?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDictData(data: DictDataResponse): Api.Dict.DictData {
|
||||||
|
const { color_type: colorTypeFromSnakeCase, css_class: cssClassFromSnakeCase, ...rest } = data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase),
|
||||||
|
cssClass: normalizeColorType(data.cssClass ?? cssClassFromSnakeCase)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFrontendDictData(data: FrontendDictDataResponse): Api.Dict.FrontendDictData {
|
||||||
|
const { color_type: colorTypeFromSnakeCase, css_class: cssClassFromSnakeCase, ...rest } = data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase),
|
||||||
|
cssClass: normalizeColorType(data.cssClass ?? cssClassFromSnakeCase)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSaveDictDataRequest(data: Api.Dict.SaveDictDataParams) {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
colorType: normalizeColorType(data.colorType),
|
||||||
|
remark: data.remark?.trim() || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取字典类型分页 */
|
/** 获取字典类型分页 */
|
||||||
export function fetchGetDictTypePage(params?: Api.Dict.DictTypeSearchParams) {
|
export function fetchGetDictTypePage(params?: Api.Dict.DictTypeSearchParams) {
|
||||||
return request<Api.Dict.PageResult<Api.Dict.DictType>>({
|
return request<Api.Dict.PageResult<Api.Dict.DictType>>({
|
||||||
@@ -60,20 +111,40 @@ export function fetchBatchDeleteDictType(ids: number[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 获取字典数据分页 */
|
/** 获取字典数据分页 */
|
||||||
export function fetchGetDictDataPage(params: Api.Dict.DictDataSearchParams) {
|
export async function fetchGetDictDataPage(params: Api.Dict.DictDataSearchParams) {
|
||||||
return request<Api.Dict.PageResult<Api.Dict.DictData>>({
|
const result = await request<DictDataPageResponse>({
|
||||||
url: `${DICT_DATA_PREFIX}/page`,
|
url: `${DICT_DATA_PREFIX}/page`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params
|
params
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.error || !result.data) {
|
||||||
|
return result as unknown as Awaited<ReturnType<typeof request<Api.Dict.PageResult<Api.Dict.DictData>>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: {
|
||||||
|
...result.data,
|
||||||
|
list: result.data.list.map(normalizeDictData)
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取前端运行时字典缓存 */
|
/** 获取前端运行时字典缓存 */
|
||||||
export function fetchGetFrontendDictCache() {
|
export async function fetchGetFrontendDictCache() {
|
||||||
return request<Api.Dict.FrontendDictCache>({
|
const result = await request<FrontendDictCacheResponse>({
|
||||||
url: `${DICT_DATA_PREFIX}/frontend-cache`,
|
url: `${DICT_DATA_PREFIX}/frontend-cache`,
|
||||||
method: 'get'
|
method: 'get'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(
|
||||||
|
result as ServiceRequestResult<FrontendDictCacheResponse>,
|
||||||
|
data =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(data).map(([dictType, list]) => [dictType, list.map(normalizeFrontendDictData)])
|
||||||
|
) as Api.Dict.FrontendDictCache
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建字典数据 */
|
/** 创建字典数据 */
|
||||||
@@ -81,7 +152,7 @@ export function fetchCreateDictData(data: Api.Dict.SaveDictDataParams) {
|
|||||||
return request<number>({
|
return request<number>({
|
||||||
url: `${DICT_DATA_PREFIX}/create`,
|
url: `${DICT_DATA_PREFIX}/create`,
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data
|
data: toSaveDictDataRequest(data)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +161,7 @@ export function fetchUpdateDictData(data: { id: number } & Api.Dict.SaveDictData
|
|||||||
return request<boolean>({
|
return request<boolean>({
|
||||||
url: `${DICT_DATA_PREFIX}/update`,
|
url: `${DICT_DATA_PREFIX}/update`,
|
||||||
method: 'put',
|
method: 'put',
|
||||||
data
|
data: toSaveDictDataRequest(data)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,9 +183,14 @@ export function fetchBatchDeleteDictData(ids: number[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 通过岗位编码获取该字典的所有字典数据 */
|
/** 通过岗位编码获取该字典的所有字典数据 */
|
||||||
export function fetchGetDictDataByCode(code: string) {
|
export async function fetchGetDictDataByCode(code: string) {
|
||||||
return request<Api.Dict.PageResult<Api.Dict.DictData>>({
|
const result = await request<DictDataPageResponse>({
|
||||||
url: `${DICT_DATA_PREFIX}/code?code=${code}`,
|
url: `${DICT_DATA_PREFIX}/code?code=${code}`,
|
||||||
method: 'get'
|
method: 'get'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<DictDataPageResponse>, data => ({
|
||||||
|
...data,
|
||||||
|
list: (data.list ?? []).map(normalizeDictData)
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
88
src/service/api/file.ts
Normal file
88
src/service/api/file.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||||
|
import { request } from '../request';
|
||||||
|
import { type ServiceRequestResult, mapServiceResult } from './shared';
|
||||||
|
|
||||||
|
const FILE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/file`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼接文件永久代理路径,用于富文本 <img src>。
|
||||||
|
*
|
||||||
|
* 后端 GET 接口匿名访问、Content-Disposition: inline,私有桶下也不会过期。
|
||||||
|
* 调用方拿到上传响应里的 configId + path 后直接调用本函数得到可写入 HTML 的 url。
|
||||||
|
*/
|
||||||
|
export function buildFileProxyUrl(configId: string, path: string) {
|
||||||
|
return `${FILE_PREFIX}/${configId}/get/${encodeURI(path)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadFileResult {
|
||||||
|
/** infra_file.id 的字符串形式(避免 Long 精度丢失) */
|
||||||
|
id: string;
|
||||||
|
/** 对象存储配置编号(字符串形式),与 path 一起拼接永久代理路径 */
|
||||||
|
configId: string;
|
||||||
|
/** 文件相对路径(含日期目录、文件名),与 configId 一起拼接永久代理路径 */
|
||||||
|
path: string;
|
||||||
|
/**
|
||||||
|
* 文件访问 URL:私有桶带签名(24h 过期)、公开桶裸 URL。
|
||||||
|
* ⚠️ 仅供后端调试 / 历史兼容,禁止写进富文本 <img src> —— 会随签名过期导致回显失效。
|
||||||
|
* 富文本图片请用 buildFileProxyUrl(configId, path) 的返回值。
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadFileResponse = {
|
||||||
|
id: string | number;
|
||||||
|
configId: string | number;
|
||||||
|
path: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 上传文件(模式一:后端中转) */
|
||||||
|
export async function uploadFile(file: File, directory?: string) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
if (directory) {
|
||||||
|
formData.append('directory', directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await request<UploadFileResponse>({
|
||||||
|
url: `${FILE_PREFIX}/upload`,
|
||||||
|
method: 'post',
|
||||||
|
data: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<UploadFileResponse>, data => ({
|
||||||
|
id: String(data.id),
|
||||||
|
configId: String(data.configId),
|
||||||
|
path: data.path,
|
||||||
|
url: data.url
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除文件
|
||||||
|
*
|
||||||
|
* 业务表单"取消/关闭/标记删除"场景调用本接口清理孤儿文件。
|
||||||
|
* 删除已不存在的文件(后端返回错误码 `1001003001`)应由调用方视为成功并吞掉。
|
||||||
|
*/
|
||||||
|
export function deleteFile(id: string) {
|
||||||
|
return request<boolean>({
|
||||||
|
url: `${FILE_PREFIX}/delete`,
|
||||||
|
method: 'delete',
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文件(流)
|
||||||
|
*
|
||||||
|
* 走后端代理接口 `/system/file/download?id=xxx`,由后端读取对象存储并以字节流返回。
|
||||||
|
* 私有桶下不要直接打开 `infra_file.url`,签名地址会过期。
|
||||||
|
*/
|
||||||
|
export function downloadFile(id: string) {
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
url: `${FILE_PREFIX}/download`,
|
||||||
|
method: 'get',
|
||||||
|
params: { id },
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,17 @@
|
|||||||
export * from './auth';
|
export * from './auth';
|
||||||
export * from './dict';
|
export * from './dict';
|
||||||
|
export * from './file';
|
||||||
|
export * from './infra';
|
||||||
|
export * from './notice';
|
||||||
|
export * from './notify-message';
|
||||||
export * from './object-context';
|
export * from './object-context';
|
||||||
|
export * from './overtime-application';
|
||||||
|
export * from './performance';
|
||||||
|
export * from './personal-item';
|
||||||
export * from './product';
|
export * from './product';
|
||||||
|
export * from './project';
|
||||||
|
export * from './project-group';
|
||||||
|
export * from './project-shared';
|
||||||
export * from './route';
|
export * from './route';
|
||||||
export * from './system-manage';
|
export * from './system-manage';
|
||||||
|
export * from './work-report';
|
||||||
|
|||||||
208
src/service/api/infra.ts
Normal file
208
src/service/api/infra.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { WEB_SERVICE_PREFIX } from '@/constants/service';
|
||||||
|
import { request } from '../request';
|
||||||
|
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
|
||||||
|
|
||||||
|
const OBJECT_STATUS_MODEL_PREFIX = `${WEB_SERVICE_PREFIX}/project/status/model`;
|
||||||
|
const OBJECT_STATUS_TRANSITION_PREFIX = `${WEB_SERVICE_PREFIX}/project/status/transition`;
|
||||||
|
|
||||||
|
type ObjectStatusModelResponse = Omit<
|
||||||
|
Api.Infra.ObjectStatusModel,
|
||||||
|
| 'id'
|
||||||
|
| 'initialFlag'
|
||||||
|
| 'terminalFlag'
|
||||||
|
| 'allowEdit'
|
||||||
|
| 'progressExcludedFlag'
|
||||||
|
| 'allowCreateProject'
|
||||||
|
| 'allowCreateRequirement'
|
||||||
|
> & {
|
||||||
|
id: string | number;
|
||||||
|
initialFlag: boolean | number | string | null | undefined;
|
||||||
|
terminalFlag: boolean | number | string | null | undefined;
|
||||||
|
allowEdit: boolean | number | string | null | undefined;
|
||||||
|
progressExcludedFlag: boolean | number | string | null | undefined;
|
||||||
|
allowCreateProject: boolean | number | string | null | undefined;
|
||||||
|
allowCreateRequirement: boolean | number | string | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ObjectStatusTransitionResponse = Omit<Api.Infra.ObjectStatusTransition, 'id' | 'needReason'> & {
|
||||||
|
id: string | number;
|
||||||
|
needReason: boolean | number | string | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ObjectStatusModelPageResponse = Api.Infra.PageResult<ObjectStatusModelResponse>;
|
||||||
|
|
||||||
|
type ObjectStatusTransitionPageResponse = Api.Infra.PageResult<ObjectStatusTransitionResponse>;
|
||||||
|
|
||||||
|
function createBatchDeleteQuery(ids: string[]) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
|
||||||
|
ids.forEach(id => {
|
||||||
|
query.append('ids', id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!normalized || normalized === '0' || normalized === 'false' || normalized === 'n') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeObjectStatusModel(model: ObjectStatusModelResponse): Api.Infra.ObjectStatusModel {
|
||||||
|
return {
|
||||||
|
...model,
|
||||||
|
id: normalizeStringId(model.id),
|
||||||
|
initialFlag: normalizeBooleanFlag(model.initialFlag),
|
||||||
|
terminalFlag: normalizeBooleanFlag(model.terminalFlag),
|
||||||
|
allowEdit: normalizeBooleanFlag(model.allowEdit),
|
||||||
|
progressExcludedFlag: normalizeBooleanFlag(model.progressExcludedFlag),
|
||||||
|
allowCreateProject: normalizeBooleanFlag(model.allowCreateProject),
|
||||||
|
allowCreateRequirement: normalizeBooleanFlag(model.allowCreateRequirement)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeObjectStatusTransition(transition: ObjectStatusTransitionResponse): Api.Infra.ObjectStatusTransition {
|
||||||
|
return {
|
||||||
|
...transition,
|
||||||
|
id: normalizeStringId(transition.id),
|
||||||
|
needReason: normalizeBooleanFlag(transition.needReason)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetObjectStatusModelPage(params?: Api.Infra.ObjectStatusModelSearchParams) {
|
||||||
|
const result = await request<ObjectStatusModelPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OBJECT_STATUS_MODEL_PREFIX}/page`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ObjectStatusModelPageResponse>, data => ({
|
||||||
|
...data,
|
||||||
|
list: data.list.map(normalizeObjectStatusModel)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetObjectStatusModel(id: string) {
|
||||||
|
const result = await request<ObjectStatusModelResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OBJECT_STATUS_MODEL_PREFIX}/get`,
|
||||||
|
method: 'get',
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ObjectStatusModelResponse>, normalizeObjectStatusModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCreateObjectStatusModel(data: Api.Infra.SaveObjectStatusModelParams) {
|
||||||
|
const result = await request<string | number>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OBJECT_STATUS_MODEL_PREFIX}/create`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUpdateObjectStatusModel(data: { id: string } & Api.Infra.SaveObjectStatusModelParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
url: `${OBJECT_STATUS_MODEL_PREFIX}/update`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDeleteObjectStatusModel(id: string) {
|
||||||
|
return request<boolean>({
|
||||||
|
url: `${OBJECT_STATUS_MODEL_PREFIX}/delete`,
|
||||||
|
method: 'delete',
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchBatchDeleteObjectStatusModel(ids: string[]) {
|
||||||
|
return request<boolean>({
|
||||||
|
url: `${OBJECT_STATUS_MODEL_PREFIX}/delete-list?${createBatchDeleteQuery(ids)}`,
|
||||||
|
method: 'delete'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetObjectStatusTransitionPage(params?: Api.Infra.ObjectStatusTransitionSearchParams) {
|
||||||
|
const result = await request<ObjectStatusTransitionPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/page`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ObjectStatusTransitionPageResponse>, data => ({
|
||||||
|
...data,
|
||||||
|
list: data.list.map(normalizeObjectStatusTransition)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetObjectStatusTransition(id: string) {
|
||||||
|
const result = await request<ObjectStatusTransitionResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/get`,
|
||||||
|
method: 'get',
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(
|
||||||
|
result as ServiceRequestResult<ObjectStatusTransitionResponse>,
|
||||||
|
normalizeObjectStatusTransition
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCreateObjectStatusTransition(data: Api.Infra.SaveObjectStatusTransitionParams) {
|
||||||
|
const result = await request<string | number>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/create`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUpdateObjectStatusTransition(data: { id: string } & Api.Infra.SaveObjectStatusTransitionParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/update`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDeleteObjectStatusTransition(id: string) {
|
||||||
|
return request<boolean>({
|
||||||
|
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/delete`,
|
||||||
|
method: 'delete',
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchBatchDeleteObjectStatusTransition(ids: string[]) {
|
||||||
|
return request<boolean>({
|
||||||
|
url: `${OBJECT_STATUS_TRANSITION_PREFIX}/delete-list?${createBatchDeleteQuery(ids)}`,
|
||||||
|
method: 'delete'
|
||||||
|
});
|
||||||
|
}
|
||||||
28
src/service/api/notice.ts
Normal file
28
src/service/api/notice.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||||
|
import { request } from '../request';
|
||||||
|
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
|
||||||
|
|
||||||
|
const NOTICE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/notice`;
|
||||||
|
|
||||||
|
type NoticeResponse = Omit<Api.Notice.Notice, 'id'> & {
|
||||||
|
id: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeNotice(data: NoticeResponse): Api.Notice.Notice {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
id: normalizeStringId(data.id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取最近公告(status=0,按 id 倒序;登录即可,工作台公告卡片用) */
|
||||||
|
export async function fetchGetRecentNotices(size?: number) {
|
||||||
|
const result = await request<NoticeResponse[]>({
|
||||||
|
url: `${NOTICE_PREFIX}/recent`,
|
||||||
|
method: 'get',
|
||||||
|
params: { size },
|
||||||
|
...safeJsonRequestConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<NoticeResponse[]>, data => data.map(normalizeNotice));
|
||||||
|
}
|
||||||
63
src/service/api/notify-message.ts
Normal file
63
src/service/api/notify-message.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||||
|
import { request } from '../request';
|
||||||
|
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
|
||||||
|
|
||||||
|
const NOTIFY_MESSAGE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/notify-message`;
|
||||||
|
|
||||||
|
type NotifyMessageResponse = Omit<Api.NotifyMessage.NotifyMessage, 'id' | 'level'> & {
|
||||||
|
id: string | number;
|
||||||
|
/** 后端老消息可能不带 level,按可空接收,normalize 时回落普通(1) */
|
||||||
|
level?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MyNotifyMessagePageResponse = Omit<Api.NotifyMessage.PageResult<Api.NotifyMessage.NotifyMessage>, 'list'> & {
|
||||||
|
list: NotifyMessageResponse[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeNotifyMessage(data: NotifyMessageResponse): Api.NotifyMessage.NotifyMessage {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
id: normalizeStringId(data.id),
|
||||||
|
level: data.level ?? 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前用户未读站内信数量(铃铛红点轮询用) */
|
||||||
|
export function fetchGetUnreadNotifyCount() {
|
||||||
|
return request<number>({
|
||||||
|
url: `${NOTIFY_MESSAGE_PREFIX}/get-unread-count`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页获取我的站内信(消息列表唯一数据源;未读传 readStatus=false、已读传 true) */
|
||||||
|
export async function fetchGetMyNotifyMessagePage(params: Api.NotifyMessage.MyPageParams) {
|
||||||
|
const result = await request<MyNotifyMessagePageResponse>({
|
||||||
|
url: `${NOTIFY_MESSAGE_PREFIX}/my-page`,
|
||||||
|
method: 'get',
|
||||||
|
params,
|
||||||
|
...safeJsonRequestConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MyNotifyMessagePageResponse>, data => ({
|
||||||
|
...data,
|
||||||
|
list: data.list.map(normalizeNotifyMessage)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量标记站内信已读(后端幂等:重复提交、非本人条目均安全) */
|
||||||
|
export function fetchUpdateNotifyMessageRead(ids: string[]) {
|
||||||
|
// 后端约定 ids 逗号分隔
|
||||||
|
return request<boolean>({
|
||||||
|
url: `${NOTIFY_MESSAGE_PREFIX}/update-read?ids=${ids.join(',')}`,
|
||||||
|
method: 'put'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前用户全部站内信标记已读 */
|
||||||
|
export function fetchUpdateAllNotifyMessageRead() {
|
||||||
|
return request<boolean>({
|
||||||
|
url: `${NOTIFY_MESSAGE_PREFIX}/update-all-read`,
|
||||||
|
method: 'put'
|
||||||
|
});
|
||||||
|
}
|
||||||
199
src/service/api/object-context-normalize.ts
Normal file
199
src/service/api/object-context-normalize.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { normalizeNullableStringId, normalizeStringId } from './shared';
|
||||||
|
|
||||||
|
export interface BackendObjectContextMenuDTO {
|
||||||
|
key?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
routeKey?: string | null;
|
||||||
|
routePath?: string | null;
|
||||||
|
id?: string | number | null;
|
||||||
|
name?: string | null;
|
||||||
|
path?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
sort?: number | null;
|
||||||
|
children?: BackendObjectContextMenuDTO[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackendProductContextProductDTO {
|
||||||
|
id?: string | number | null;
|
||||||
|
code?: string | null;
|
||||||
|
directionCode?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
managerUserId?: string | number | null;
|
||||||
|
statusCode?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackendProjectContextProjectDTO {
|
||||||
|
id?: string | number | null;
|
||||||
|
projectCode?: string | null;
|
||||||
|
projectName?: string | null;
|
||||||
|
projectType?: string | null;
|
||||||
|
productId?: string | number | null;
|
||||||
|
managerUserId?: string | number | null;
|
||||||
|
statusCode?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackendObjectContextRoleDTO {
|
||||||
|
roleId?: string | number | null;
|
||||||
|
roleCode?: string | null;
|
||||||
|
roleName?: string | null;
|
||||||
|
guestFlag?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendObjectContextDTO {
|
||||||
|
domainKey?: string | null;
|
||||||
|
objectType?: string | null;
|
||||||
|
objectId?: string | number | null;
|
||||||
|
objectName?: string | null;
|
||||||
|
objectSummary?: Record<string, unknown> | null;
|
||||||
|
menus?: BackendObjectContextMenuDTO[] | null;
|
||||||
|
contextScopedMenus?: BackendObjectContextMenuDTO[] | null;
|
||||||
|
buttonCodes?: string[] | null;
|
||||||
|
currentProduct?: BackendProductContextProductDTO | null;
|
||||||
|
currentProject?: BackendProjectContextProjectDTO | null;
|
||||||
|
currentRole?: BackendObjectContextRoleDTO | null;
|
||||||
|
navs?: BackendObjectContextMenuDTO[] | null;
|
||||||
|
buttons?: string[] | null;
|
||||||
|
defaultRouteKey?: string | null;
|
||||||
|
defaultRoutePath?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeString(value: string | number | null | undefined) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoutePath(path: string | null | undefined) {
|
||||||
|
const normalizedPath = normalizeString(path).trim();
|
||||||
|
|
||||||
|
if (!normalizedPath) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedPath.startsWith('/')) {
|
||||||
|
return normalizedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/${normalizedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCurrentProduct(
|
||||||
|
product: BackendProductContextProductDTO
|
||||||
|
): Record<'id' | 'code' | 'directionCode' | 'name' | 'managerUserId' | 'statusCode', string> {
|
||||||
|
return {
|
||||||
|
id: normalizeStringId(product.id || ''),
|
||||||
|
code: normalizeString(product.code),
|
||||||
|
directionCode: normalizeString(product.directionCode),
|
||||||
|
name: normalizeString(product.name),
|
||||||
|
managerUserId: normalizeNullableStringId(product.managerUserId) ?? '',
|
||||||
|
statusCode: normalizeString(product.statusCode)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCurrentProject(project: BackendProjectContextProjectDTO) {
|
||||||
|
return {
|
||||||
|
id: normalizeStringId(project.id || ''),
|
||||||
|
projectCode: normalizeString(project.projectCode),
|
||||||
|
projectName: normalizeString(project.projectName),
|
||||||
|
projectType: normalizeString(project.projectType),
|
||||||
|
productId: normalizeNullableStringId(project.productId),
|
||||||
|
managerUserId: normalizeNullableStringId(project.managerUserId) ?? '',
|
||||||
|
statusCode: normalizeString(project.statusCode)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCurrentRole(role: BackendObjectContextRoleDTO) {
|
||||||
|
return {
|
||||||
|
roleId: normalizeStringId(role.roleId || ''),
|
||||||
|
roleCode: normalizeString(role.roleCode),
|
||||||
|
roleName: normalizeString(role.roleName),
|
||||||
|
guestFlag: Boolean(role.guestFlag)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMenu(menu: BackendObjectContextMenuDTO): App.ObjectContext.Menu {
|
||||||
|
const routeKey = normalizeString(menu.routeKey);
|
||||||
|
const routePath = normalizeRoutePath(menu.routePath || menu.path);
|
||||||
|
const key = normalizeString(menu.key || routeKey || routePath || menu.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label: normalizeString(menu.label || menu.name),
|
||||||
|
routeKey: routeKey || null,
|
||||||
|
routePath: routePath || null,
|
||||||
|
children: menu.children?.map(child => normalizeMenu(child)) || []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFirstNonEmptyMenuSource(data: BackendObjectContextDTO) {
|
||||||
|
const menuSources = [data.contextScopedMenus, data.menus, data.navs];
|
||||||
|
|
||||||
|
return menuSources.find(source => Array.isArray(source) && source.length > 0) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFirstRoutableMenu(menus: App.ObjectContext.Menu[]): App.ObjectContext.Menu | null {
|
||||||
|
for (const menu of menus) {
|
||||||
|
if (menu.routeKey || menu.routePath) {
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstChildMenu = menu.children?.length ? getFirstRoutableMenu(menu.children) : null;
|
||||||
|
|
||||||
|
if (firstChildMenu) {
|
||||||
|
return firstChildMenu;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeObjectSummary(data: BackendObjectContextDTO): App.ObjectContext.Summary | null {
|
||||||
|
if (data.objectSummary) {
|
||||||
|
return data.objectSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: App.ObjectContext.Summary = {};
|
||||||
|
|
||||||
|
if (data.currentProduct) {
|
||||||
|
summary.currentProduct = normalizeCurrentProduct(data.currentProduct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.currentProject) {
|
||||||
|
summary.currentProject = normalizeCurrentProject(data.currentProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.currentRole !== undefined) {
|
||||||
|
summary.currentRole = data.currentRole ? normalizeCurrentRole(data.currentRole) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(summary).length ? summary : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 待重构:拆 helper 以降低复杂度,暂以 disable 注释临时放行
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
export function normalizeObjectContext(
|
||||||
|
config: App.ObjectContext.DomainConfig,
|
||||||
|
objectId: string,
|
||||||
|
data: BackendObjectContextDTO
|
||||||
|
): Api.ObjectContext.ContextInfo {
|
||||||
|
const rawMenus = getFirstNonEmptyMenuSource(data);
|
||||||
|
const contextScopedMenus = rawMenus.map(menu => normalizeMenu(menu));
|
||||||
|
const firstRoutableMenu = getFirstRoutableMenu(contextScopedMenus);
|
||||||
|
const currentProduct = data.currentProduct ? normalizeCurrentProduct(data.currentProduct) : null;
|
||||||
|
const currentProject = data.currentProject ? normalizeCurrentProject(data.currentProject) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
domainKey: (data.domainKey || config.domainKey) as App.ObjectContext.DomainKey,
|
||||||
|
objectType: (data.objectType || config.objectType) as App.ObjectContext.ObjectType,
|
||||||
|
objectId: normalizeString(data.objectId) || currentProduct?.id || currentProject?.id || objectId,
|
||||||
|
objectName: normalizeString(data.objectName || currentProduct?.name || currentProject?.projectName),
|
||||||
|
objectSummary: normalizeObjectSummary(data),
|
||||||
|
contextScopedMenus,
|
||||||
|
buttonCodes: data.buttonCodes ?? data.buttons ?? [],
|
||||||
|
defaultRouteKey: data.defaultRouteKey || firstRoutableMenu?.routeKey || '',
|
||||||
|
defaultRoutePath:
|
||||||
|
normalizeRoutePath(data.defaultRoutePath) || firstRoutableMenu?.routePath || config.fallbackDefaultRoutePath
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,145 +1,7 @@
|
|||||||
import type { LocationQueryValue } from 'vue-router';
|
import type { LocationQueryValue } from 'vue-router';
|
||||||
import { request } from '../request';
|
import { request } from '../request';
|
||||||
import {
|
import { type ServiceRequestResult, safeJsonRequestConfig } from './shared';
|
||||||
type ServiceRequestResult,
|
import { type BackendObjectContextDTO, normalizeObjectContext } from './object-context-normalize';
|
||||||
normalizeNullableStringId,
|
|
||||||
normalizeStringId,
|
|
||||||
safeJsonRequestConfig
|
|
||||||
} from './shared';
|
|
||||||
|
|
||||||
interface BackendObjectContextMenuDTO {
|
|
||||||
key?: string | null;
|
|
||||||
label?: string | null;
|
|
||||||
routeKey?: string | null;
|
|
||||||
routePath?: string | null;
|
|
||||||
id?: string | number | null;
|
|
||||||
name?: string | null;
|
|
||||||
path?: string | null;
|
|
||||||
children?: BackendObjectContextMenuDTO[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BackendProductContextProductDTO {
|
|
||||||
id?: string | number | null;
|
|
||||||
code?: string | null;
|
|
||||||
directionCode?: string | null;
|
|
||||||
name?: string | null;
|
|
||||||
managerUserId?: string | number | null;
|
|
||||||
statusCode?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BackendProductContextRoleDTO {
|
|
||||||
roleId?: string | number | null;
|
|
||||||
roleCode?: string | null;
|
|
||||||
roleName?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BackendObjectContextDTO {
|
|
||||||
domainKey?: string | null;
|
|
||||||
objectType?: string | null;
|
|
||||||
objectId?: string | number | null;
|
|
||||||
objectName?: string | null;
|
|
||||||
objectSummary?: Record<string, unknown> | null;
|
|
||||||
menus?: BackendObjectContextMenuDTO[] | null;
|
|
||||||
contextScopedMenus?: BackendObjectContextMenuDTO[] | null;
|
|
||||||
buttonCodes?: string[] | null;
|
|
||||||
currentProduct?: BackendProductContextProductDTO | null;
|
|
||||||
currentRole?: BackendProductContextRoleDTO | null;
|
|
||||||
navs?: BackendObjectContextMenuDTO[] | null;
|
|
||||||
buttons?: string[] | null;
|
|
||||||
defaultRouteKey?: string | null;
|
|
||||||
defaultRoutePath?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeString(value: string | number | null | undefined) {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRoutePath(path: string | null | undefined) {
|
|
||||||
const normalizedPath = normalizeString(path).trim();
|
|
||||||
|
|
||||||
if (!normalizedPath) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedPath.startsWith('/')) {
|
|
||||||
return normalizedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/${normalizedPath}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCurrentProduct(
|
|
||||||
product: BackendProductContextProductDTO
|
|
||||||
): Record<'id' | 'code' | 'directionCode' | 'name' | 'managerUserId' | 'statusCode', string> {
|
|
||||||
return {
|
|
||||||
id: normalizeStringId(product.id || ''),
|
|
||||||
code: normalizeString(product.code),
|
|
||||||
directionCode: normalizeString(product.directionCode),
|
|
||||||
name: normalizeString(product.name),
|
|
||||||
managerUserId: normalizeNullableStringId(product.managerUserId) ?? '',
|
|
||||||
statusCode: normalizeString(product.statusCode)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCurrentRole(role: BackendProductContextRoleDTO) {
|
|
||||||
return {
|
|
||||||
roleId: normalizeStringId(role.roleId || ''),
|
|
||||||
roleCode: normalizeString(role.roleCode),
|
|
||||||
roleName: normalizeString(role.roleName)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeMenu(menu: BackendObjectContextMenuDTO): App.ObjectContext.Menu {
|
|
||||||
const routeKey = normalizeString(menu.routeKey);
|
|
||||||
const routePath = normalizeRoutePath(menu.routePath || menu.path);
|
|
||||||
const key = normalizeString(menu.key || routeKey || routePath || menu.id);
|
|
||||||
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
label: normalizeString(menu.label || menu.name),
|
|
||||||
routeKey: routeKey || null,
|
|
||||||
routePath: routePath || null,
|
|
||||||
children: menu.children?.map(child => normalizeMenu(child)) || []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFirstRoutableMenu(menus: App.ObjectContext.Menu[]): App.ObjectContext.Menu | null {
|
|
||||||
for (const menu of menus) {
|
|
||||||
if (menu.routeKey || menu.routePath) {
|
|
||||||
return menu;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstChildMenu = menu.children?.length ? getFirstRoutableMenu(menu.children) : null;
|
|
||||||
|
|
||||||
if (firstChildMenu) {
|
|
||||||
return firstChildMenu;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeObjectSummary(data: BackendObjectContextDTO): App.ObjectContext.Summary | null {
|
|
||||||
if (data.objectSummary) {
|
|
||||||
return data.objectSummary;
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary: App.ObjectContext.Summary = {};
|
|
||||||
|
|
||||||
if (data.currentProduct) {
|
|
||||||
summary.currentProduct = normalizeCurrentProduct(data.currentProduct);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.currentRole !== undefined) {
|
|
||||||
summary.currentRole = data.currentRole ? normalizeCurrentRole(data.currentRole) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.keys(summary).length ? summary : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createContextApiUrl(config: App.ObjectContext.DomainConfig, objectId: string) {
|
function createContextApiUrl(config: App.ObjectContext.DomainConfig, objectId: string) {
|
||||||
if (config.contextApiObjectIdPlacement !== 'path') {
|
if (config.contextApiObjectIdPlacement !== 'path') {
|
||||||
@@ -151,30 +13,6 @@ function createContextApiUrl(config: App.ObjectContext.DomainConfig, objectId: s
|
|||||||
return config.contextApiPath.replace(placeholder, encodeURIComponent(objectId));
|
return config.contextApiPath.replace(placeholder, encodeURIComponent(objectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeObjectContext(
|
|
||||||
config: App.ObjectContext.DomainConfig,
|
|
||||||
objectId: string,
|
|
||||||
data: BackendObjectContextDTO
|
|
||||||
): Api.ObjectContext.ContextInfo {
|
|
||||||
const rawMenus = data.contextScopedMenus ?? data.menus ?? data.navs ?? [];
|
|
||||||
const contextScopedMenus = rawMenus.map(menu => normalizeMenu(menu));
|
|
||||||
const firstRoutableMenu = getFirstRoutableMenu(contextScopedMenus);
|
|
||||||
const currentProduct = data.currentProduct ? normalizeCurrentProduct(data.currentProduct) : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
domainKey: (data.domainKey || config.domainKey) as App.ObjectContext.DomainKey,
|
|
||||||
objectType: (data.objectType || config.objectType) as App.ObjectContext.ObjectType,
|
|
||||||
objectId: normalizeString(data.objectId) || currentProduct?.id || objectId,
|
|
||||||
objectName: normalizeString(data.objectName || currentProduct?.name),
|
|
||||||
objectSummary: normalizeObjectSummary(data),
|
|
||||||
contextScopedMenus,
|
|
||||||
buttonCodes: data.buttonCodes ?? data.buttons ?? [],
|
|
||||||
defaultRouteKey: data.defaultRouteKey || firstRoutableMenu?.routeKey || '',
|
|
||||||
defaultRoutePath:
|
|
||||||
normalizeRoutePath(data.defaultRoutePath) || firstRoutableMenu?.routePath || config.fallbackDefaultRoutePath
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchGetObjectContext(
|
export async function fetchGetObjectContext(
|
||||||
config: App.ObjectContext.DomainConfig,
|
config: App.ObjectContext.DomainConfig,
|
||||||
objectId: string
|
objectId: string
|
||||||
|
|||||||
323
src/service/api/overtime-application.ts
Normal file
323
src/service/api/overtime-application.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { WEB_SERVICE_PREFIX } from '@/constants/service';
|
||||||
|
import { request } from '../request';
|
||||||
|
import { type ProjectLocalDateValue, normalizeProjectLocalDate } from './project-shared';
|
||||||
|
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
|
||||||
|
|
||||||
|
const OVERTIME_APPLICATION_PREFIX = `${WEB_SERVICE_PREFIX}/project/overtime-applications`;
|
||||||
|
|
||||||
|
type StringIdResponse = string | number;
|
||||||
|
|
||||||
|
type OvertimeApplicationResponse = Omit<
|
||||||
|
Api.OvertimeApplication.OvertimeApplication,
|
||||||
|
'id' | 'applicantId' | 'approverId' | 'overtimeDate' | 'allowEdit' | 'terminal'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
applicantId: StringIdResponse;
|
||||||
|
approverId: StringIdResponse;
|
||||||
|
overtimeDate: ProjectLocalDateValue;
|
||||||
|
allowEdit?: boolean | number | string | null;
|
||||||
|
terminal?: boolean | number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OvertimeApplicationPageResponse = Omit<Api.OvertimeApplication.OvertimeApplicationPageResult, 'total' | 'list'> & {
|
||||||
|
total: number | string;
|
||||||
|
list: OvertimeApplicationResponse[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type OvertimeApplicationApprovalRecordResponse = Omit<
|
||||||
|
Api.OvertimeApplication.OvertimeApplicationApprovalRecord,
|
||||||
|
'id' | 'overtimeApplicationId' | 'statusLogId' | 'auditorUserId'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
overtimeApplicationId: StringIdResponse;
|
||||||
|
statusLogId: StringIdResponse;
|
||||||
|
auditorUserId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TeamOvertimeSummaryResponse = Api.OvertimeApplication.TeamOvertimeSummary;
|
||||||
|
|
||||||
|
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
|
||||||
|
return !['', '0', 'false', 'n', 'no'].includes(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTotal(total: number | string) {
|
||||||
|
const value = Number(total);
|
||||||
|
|
||||||
|
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOvertimeApplication(
|
||||||
|
response: OvertimeApplicationResponse
|
||||||
|
): Api.OvertimeApplication.OvertimeApplication {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
applicantId: normalizeStringId(response.applicantId),
|
||||||
|
approverId: normalizeStringId(response.approverId),
|
||||||
|
overtimeDate: normalizeProjectLocalDate(response.overtimeDate) ?? '',
|
||||||
|
statusName: response.statusName || response.statusCode,
|
||||||
|
allowEdit: normalizeBooleanFlag(response.allowEdit),
|
||||||
|
terminal: normalizeBooleanFlag(response.terminal),
|
||||||
|
approvalComment: response.approvalComment ?? null,
|
||||||
|
approvalTime: response.approvalTime ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApprovalRecord(
|
||||||
|
response: OvertimeApplicationApprovalRecordResponse
|
||||||
|
): Api.OvertimeApplication.OvertimeApplicationApprovalRecord {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
overtimeApplicationId: normalizeStringId(response.overtimeApplicationId),
|
||||||
|
statusLogId: normalizeStringId(response.statusLogId),
|
||||||
|
auditorUserId: normalizeStringId(response.auditorUserId),
|
||||||
|
opinion: response.opinion ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPageQuery(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
|
||||||
|
query.append('pageNo', String(params.pageNo ?? 1));
|
||||||
|
query.append('pageSize', String(params.pageSize ?? 10));
|
||||||
|
|
||||||
|
if (params.applicantIds !== null && params.applicantIds !== undefined) {
|
||||||
|
if (params.applicantIds.length) {
|
||||||
|
params.applicantIds.forEach(item => {
|
||||||
|
if (item) {
|
||||||
|
query.append('applicantIds', item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
query.append('applicantIds', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.keyword) {
|
||||||
|
query.append('keyword', params.keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.applicantName) {
|
||||||
|
query.append('applicantName', params.applicantName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.approverId) {
|
||||||
|
query.append('approverId', params.approverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.approverName) {
|
||||||
|
query.append('approverName', params.approverName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.statusCode) {
|
||||||
|
query.append('statusCode', params.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.overtimeDate?.forEach(item => {
|
||||||
|
if (item) {
|
||||||
|
query.append('overtimeDate', item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
params.createTime?.forEach(item => {
|
||||||
|
if (item) {
|
||||||
|
query.append('createTime', item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSaveRequest(data: Api.OvertimeApplication.SaveOvertimeApplicationParams) {
|
||||||
|
return {
|
||||||
|
overtimeDate: data.overtimeDate,
|
||||||
|
overtimeDuration: data.overtimeDuration,
|
||||||
|
overtimeReason: data.overtimeReason.trim(),
|
||||||
|
overtimeContent: data.overtimeContent.trim(),
|
||||||
|
approverId: data.approverId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStatusActionRequest(data: Api.OvertimeApplication.StatusActionParams = {}) {
|
||||||
|
return {
|
||||||
|
reason: data.reason?.trim() || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetOvertimeApplicationPage(
|
||||||
|
params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}
|
||||||
|
) {
|
||||||
|
const query = createPageQuery(params);
|
||||||
|
|
||||||
|
const result = await request<OvertimeApplicationPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${OVERTIME_APPLICATION_PREFIX}/page?${query}` : `${OVERTIME_APPLICATION_PREFIX}/page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationPageResponse>, data => ({
|
||||||
|
total: normalizeTotal(data.total),
|
||||||
|
list: data.list.map(normalizeOvertimeApplication)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetOvertimeApplicationApprovalPage(
|
||||||
|
params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}
|
||||||
|
) {
|
||||||
|
const query = createPageQuery(params);
|
||||||
|
|
||||||
|
const result = await request<OvertimeApplicationPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query
|
||||||
|
? `${OVERTIME_APPLICATION_PREFIX}/approval-page?${query}`
|
||||||
|
: `${OVERTIME_APPLICATION_PREFIX}/approval-page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationPageResponse>, data => ({
|
||||||
|
total: normalizeTotal(data.total),
|
||||||
|
list: data.list.map(normalizeOvertimeApplication)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetOvertimeApplicationDetail(id: string) {
|
||||||
|
const result = await request<OvertimeApplicationResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationResponse>, normalizeOvertimeApplication);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCreateOvertimeApplication(data: Api.OvertimeApplication.SaveOvertimeApplicationParams) {
|
||||||
|
const result = await request<string | number>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: OVERTIME_APPLICATION_PREFIX,
|
||||||
|
method: 'post',
|
||||||
|
data: toSaveRequest(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUpdateRejectedOvertimeApplication(
|
||||||
|
id: string,
|
||||||
|
data: Api.OvertimeApplication.SaveOvertimeApplicationParams
|
||||||
|
) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/${id}`,
|
||||||
|
method: 'put',
|
||||||
|
data: toSaveRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchApproveOvertimeApplication(id: string, data: Api.OvertimeApplication.StatusActionParams = {}) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/approve`,
|
||||||
|
method: 'post',
|
||||||
|
data: toStatusActionRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRejectOvertimeApplication(id: string, data: Api.OvertimeApplication.StatusActionParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/reject`,
|
||||||
|
method: 'post',
|
||||||
|
data: toStatusActionRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchBatchApproveOvertimeApplication(
|
||||||
|
data: Api.OvertimeApplication.OvertimeApplicationBatchActionParams
|
||||||
|
) {
|
||||||
|
return request<Api.OvertimeApplication.OvertimeApplicationBatchActionResult>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/batch-approve`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchBatchRejectOvertimeApplication(
|
||||||
|
data: Api.OvertimeApplication.OvertimeApplicationBatchActionParams
|
||||||
|
) {
|
||||||
|
return request<Api.OvertimeApplication.OvertimeApplicationBatchActionResult>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/batch-reject`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDeleteOvertimeApplication(id: string) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/${id}`,
|
||||||
|
method: 'delete'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetOvertimeApplicationApprovalRecords(id: string) {
|
||||||
|
const result = await request<OvertimeApplicationApprovalRecordResponse[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/approval-records`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationApprovalRecordResponse[]>, data =>
|
||||||
|
data.map(normalizeApprovalRecord)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetOvertimeApplicationStatusDict() {
|
||||||
|
const result = await request<Api.OvertimeApplication.OvertimeApplicationStatusDict[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/status/dict`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(
|
||||||
|
result as ServiceRequestResult<Api.OvertimeApplication.OvertimeApplicationStatusDict[]>,
|
||||||
|
data => data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetTeamOvertimeSummary(params: Api.OvertimeApplication.TeamOvertimeSummaryParams = {}) {
|
||||||
|
const result = await request<TeamOvertimeSummaryResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${OVERTIME_APPLICATION_PREFIX}/team/summary`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<TeamOvertimeSummaryResponse>, data => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchExportOvertimeApplications(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) {
|
||||||
|
const query = createPageQuery(params);
|
||||||
|
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
url: query ? `${OVERTIME_APPLICATION_PREFIX}/export?${query}` : `${OVERTIME_APPLICATION_PREFIX}/export`,
|
||||||
|
method: 'get',
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
501
src/service/api/performance.ts
Normal file
501
src/service/api/performance.ts
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { WEB_SERVICE_PREFIX } from '@/constants/service';
|
||||||
|
import { request } from '../request';
|
||||||
|
import {
|
||||||
|
type ServiceRequestResult,
|
||||||
|
mapServiceResult,
|
||||||
|
normalizeNullableStringId,
|
||||||
|
normalizeStringId,
|
||||||
|
safeJsonRequestConfig
|
||||||
|
} from './shared';
|
||||||
|
|
||||||
|
const TEMPLATE_PREFIX = `${WEB_SERVICE_PREFIX}/project/performance-templates`;
|
||||||
|
const SHEET_PREFIX = `${WEB_SERVICE_PREFIX}/project/performance-sheets`;
|
||||||
|
const TEAM_PREFIX = `${SHEET_PREFIX}/team`;
|
||||||
|
|
||||||
|
type StringIdResponse = string | number;
|
||||||
|
|
||||||
|
type TemplateResponse = Omit<Api.Performance.Template.Template, 'id' | 'fileId' | 'uploadUserId' | 'activeFlag'> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
fileId: StringIdResponse;
|
||||||
|
uploadUserId: StringIdResponse;
|
||||||
|
activeFlag?: boolean | number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TemplatePageResponse = {
|
||||||
|
total: number | string;
|
||||||
|
list: TemplateResponse[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SheetResponse = Omit<
|
||||||
|
Api.Performance.Sheet.Sheet,
|
||||||
|
'id' | 'employeeId' | 'employeeDeptId' | 'managerId' | 'templateId' | 'fileId'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
employeeId: StringIdResponse;
|
||||||
|
employeeDeptId: StringIdResponse;
|
||||||
|
managerId: StringIdResponse;
|
||||||
|
templateId: StringIdResponse;
|
||||||
|
fileId?: StringIdResponse | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SheetPageResponse = {
|
||||||
|
total: number | string;
|
||||||
|
list: SheetResponse[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatusLogResponse = Omit<Api.Performance.Sheet.StatusLog, 'id' | 'sheetId' | 'operatorUserId'> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
sheetId: StringIdResponse;
|
||||||
|
operatorUserId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResponseRecordResponse = Omit<
|
||||||
|
Api.Performance.Sheet.ResponseRecord,
|
||||||
|
'id' | 'sheetId' | 'statusLogId' | 'responderUserId'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
sheetId: StringIdResponse;
|
||||||
|
statusLogId: StringIdResponse;
|
||||||
|
responderUserId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MonthlyResultResponse = Omit<Api.Performance.Sheet.MonthlyResult, 'sheetId' | 'employeeId'> & {
|
||||||
|
sheetId?: StringIdResponse | null;
|
||||||
|
employeeId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TeamSummaryResponse = Omit<
|
||||||
|
Api.Performance.Team.Summary,
|
||||||
|
| 'totalSheetCount'
|
||||||
|
| 'pendingSendCount'
|
||||||
|
| 'pendingConfirmCount'
|
||||||
|
| 'pendingSendUsers'
|
||||||
|
| 'pendingConfirmUsers'
|
||||||
|
| 'deptOrgAverages'
|
||||||
|
> & {
|
||||||
|
totalSheetCount?: number | string | null;
|
||||||
|
pendingSendCount?: number | string | null;
|
||||||
|
pendingConfirmCount?: number | string | null;
|
||||||
|
pendingSendUsers?: Array<
|
||||||
|
Omit<Api.Performance.Team.PendingSendUser, 'userId' | 'managerUserId' | 'sheetId'> & {
|
||||||
|
userId: StringIdResponse;
|
||||||
|
managerUserId: StringIdResponse;
|
||||||
|
sheetId?: StringIdResponse | null;
|
||||||
|
}
|
||||||
|
> | null;
|
||||||
|
pendingConfirmUsers?: Array<
|
||||||
|
Omit<Api.Performance.Team.PendingConfirmUser, 'userId' | 'sheetId'> & {
|
||||||
|
userId: StringIdResponse;
|
||||||
|
sheetId: StringIdResponse;
|
||||||
|
}
|
||||||
|
> | null;
|
||||||
|
deptOrgAverages?: Array<
|
||||||
|
Omit<Api.Performance.Team.DeptOrgAverage, 'deptId' | 'confirmedCount'> & {
|
||||||
|
deptId: StringIdResponse;
|
||||||
|
confirmedCount?: number | string | null;
|
||||||
|
}
|
||||||
|
> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (typeof value === 'number') return value === 1;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
|
||||||
|
return !['', '0', 'false', 'n', 'no'].includes(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTotal(value: number | string | null | undefined) {
|
||||||
|
const total = Number(value ?? 0);
|
||||||
|
|
||||||
|
return Number.isFinite(total) ? Math.max(0, total) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTemplate(response: TemplateResponse): Api.Performance.Template.Template {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
fileId: normalizeStringId(response.fileId),
|
||||||
|
uploadUserId: normalizeStringId(response.uploadUserId),
|
||||||
|
activeFlag: normalizeBooleanFlag(response.activeFlag),
|
||||||
|
remark: response.remark ?? null,
|
||||||
|
scoreCellMapping: response.scoreCellMapping ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSheet(response: SheetResponse): Api.Performance.Sheet.Sheet {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
employeeId: normalizeStringId(response.employeeId),
|
||||||
|
employeeDeptId: normalizeStringId(response.employeeDeptId),
|
||||||
|
managerId: normalizeStringId(response.managerId),
|
||||||
|
templateId: normalizeStringId(response.templateId),
|
||||||
|
fileId: normalizeNullableStringId(response.fileId),
|
||||||
|
fileName: response.fileName ?? null,
|
||||||
|
statusName: response.statusName || response.statusCode,
|
||||||
|
actualScoreTotal: response.actualScoreTotal ?? null,
|
||||||
|
baseScoreTotal: response.baseScoreTotal ?? null,
|
||||||
|
extraScoreTotal: response.extraScoreTotal ?? null,
|
||||||
|
sentTime: response.sentTime ?? null,
|
||||||
|
confirmedTime: response.confirmedTime ?? null,
|
||||||
|
rejectedTime: response.rejectedTime ?? null,
|
||||||
|
lastStatusReason: response.lastStatusReason ?? null,
|
||||||
|
createTime: response.createTime ?? null,
|
||||||
|
updateTime: response.updateTime ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatusLog(response: StatusLogResponse): Api.Performance.Sheet.StatusLog {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
sheetId: normalizeStringId(response.sheetId),
|
||||||
|
operatorUserId: normalizeStringId(response.operatorUserId),
|
||||||
|
reason: response.reason ?? null,
|
||||||
|
remark: response.remark ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResponseRecord(response: ResponseRecordResponse): Api.Performance.Sheet.ResponseRecord {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
sheetId: normalizeStringId(response.sheetId),
|
||||||
|
statusLogId: normalizeStringId(response.statusLogId),
|
||||||
|
responderUserId: normalizeStringId(response.responderUserId),
|
||||||
|
opinion: response.opinion ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMonthlyResult(response: MonthlyResultResponse): Api.Performance.Sheet.MonthlyResult {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
sheetId: normalizeNullableStringId(response.sheetId),
|
||||||
|
employeeId: normalizeStringId(response.employeeId),
|
||||||
|
actualScoreTotal: response.actualScoreTotal ?? null,
|
||||||
|
baseScoreTotal: response.baseScoreTotal ?? null,
|
||||||
|
extraScoreTotal: response.extraScoreTotal ?? null,
|
||||||
|
statusCode: response.statusCode ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTeamSummary(response: TeamSummaryResponse): Api.Performance.Team.Summary {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
totalSheetCount: normalizeTotal(response.totalSheetCount),
|
||||||
|
pendingSendCount: normalizeTotal(response.pendingSendCount),
|
||||||
|
pendingConfirmCount: normalizeTotal(response.pendingConfirmCount),
|
||||||
|
pendingSendUsers: (response.pendingSendUsers || []).map(item => ({
|
||||||
|
...item,
|
||||||
|
userId: normalizeStringId(item.userId),
|
||||||
|
managerUserId: normalizeStringId(item.managerUserId),
|
||||||
|
sheetId: normalizeNullableStringId(item.sheetId),
|
||||||
|
statusCode: item.statusCode ?? null
|
||||||
|
})),
|
||||||
|
pendingConfirmUsers: (response.pendingConfirmUsers || []).map(item => ({
|
||||||
|
...item,
|
||||||
|
userId: normalizeStringId(item.userId),
|
||||||
|
sheetId: normalizeStringId(item.sheetId),
|
||||||
|
sentTime: item.sentTime ?? null
|
||||||
|
})),
|
||||||
|
deptOrgAverages: (response.deptOrgAverages || []).map(item => ({
|
||||||
|
...item,
|
||||||
|
deptId: normalizeStringId(item.deptId),
|
||||||
|
averageScore: item.averageScore ?? null,
|
||||||
|
confirmedCount: normalizeTotal(item.confirmedCount)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendValue(query: URLSearchParams, key: string, value: unknown) {
|
||||||
|
if (value === null || value === undefined || value === '') return;
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (!value.length) {
|
||||||
|
query.append(key, '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
value.forEach(item => appendValue(query, key, item));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
query.append(key, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatToYYYYMM(value?: string | null) {
|
||||||
|
if (!value) return '';
|
||||||
|
|
||||||
|
const d = dayjs(value);
|
||||||
|
|
||||||
|
return d.isValid() ? d.format('YYYY-MM') : value.slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSheetQuery(params: Api.Performance.Sheet.SearchParams = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
|
||||||
|
query.append('pageNo', String(params.pageNo ?? 1));
|
||||||
|
query.append('pageSize', String(params.pageSize ?? 10));
|
||||||
|
appendValue(query, 'employeeIds', params.employeeIds);
|
||||||
|
// 将 periodMonthRange 拆为 periodMonthStart / periodMonthEnd
|
||||||
|
if (params.periodMonthRange?.length === 2) {
|
||||||
|
appendValue(query, 'periodMonthStart', formatToYYYYMM(params.periodMonthRange[0]));
|
||||||
|
appendValue(query, 'periodMonthEnd', formatToYYYYMM(params.periodMonthRange[1]));
|
||||||
|
}
|
||||||
|
// employeeId 单选追加到 employeeIds
|
||||||
|
if (params.employeeId) {
|
||||||
|
query.append('employeeIds', params.employeeId);
|
||||||
|
}
|
||||||
|
appendValue(query, 'employeeDeptId', params.employeeDeptId);
|
||||||
|
appendValue(query, 'managerName', params.managerName);
|
||||||
|
appendValue(query, 'statusCode', params.statusCode);
|
||||||
|
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTemplateQuery(params: Api.Performance.Template.SearchParams = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
|
||||||
|
query.append('pageNo', String(params.pageNo ?? 1));
|
||||||
|
query.append('pageSize', String(params.pageSize ?? 10));
|
||||||
|
appendValue(query, 'templateName', params.templateName);
|
||||||
|
appendValue(query, 'activeFlag', params.activeFlag);
|
||||||
|
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPerformanceTemplateCurrent() {
|
||||||
|
const result = await request<TemplateResponse | null>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${TEMPLATE_PREFIX}/current`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<TemplateResponse | null>, data =>
|
||||||
|
data ? normalizeTemplate(data) : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPerformanceTemplatePage(params: Api.Performance.Template.SearchParams = {}) {
|
||||||
|
const query = createTemplateQuery(params);
|
||||||
|
const result = await request<TemplatePageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${TEMPLATE_PREFIX}/page?${query}` : `${TEMPLATE_PREFIX}/page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<TemplatePageResponse>, data => ({
|
||||||
|
total: normalizeTotal(data.total),
|
||||||
|
list: data.list.map(normalizeTemplate)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadPerformanceTemplate(data: Api.Performance.Template.UploadParams) {
|
||||||
|
const result = await request<StringIdResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${TEMPLATE_PREFIX}/upload`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activatePerformanceTemplate(id: string) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${TEMPLATE_PREFIX}/${id}/activate`,
|
||||||
|
method: 'post'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPerformanceSheetPage(params: Api.Performance.Sheet.SearchParams = {}) {
|
||||||
|
const query = createSheetQuery(params);
|
||||||
|
const result = await request<SheetPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${SHEET_PREFIX}/page?${query}` : `${SHEET_PREFIX}/page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<SheetPageResponse>, data => ({
|
||||||
|
total: normalizeTotal(data.total),
|
||||||
|
list: data.list.map(normalizeSheet)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPerformanceSheet(id: string) {
|
||||||
|
const result = await request<SheetResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${SHEET_PREFIX}/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<SheetResponse>, normalizeSheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPerformanceSheet(data: Api.Performance.Sheet.CreateParams) {
|
||||||
|
const result = await request<StringIdResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: SHEET_PREFIX,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePerformanceSheetExcel(id: string, data: Api.Performance.Sheet.ExcelUpdateParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${SHEET_PREFIX}/${id}/excel`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePerformanceSheet(id: string) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${SHEET_PREFIX}/${id}`,
|
||||||
|
method: 'delete'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendPerformanceSheet(id: string) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${SHEET_PREFIX}/${id}/send`,
|
||||||
|
method: 'post'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resendPerformanceSheet(id: string) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${SHEET_PREFIX}/${id}/resend`,
|
||||||
|
method: 'post'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmPerformanceSheet(id: string, data: Api.Performance.Sheet.StatusActionParams = {}) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${SHEET_PREFIX}/${id}/confirm`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectPerformanceSheet(id: string, data: Api.Performance.Sheet.StatusActionParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${SHEET_PREFIX}/${id}/reject`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadPerformanceSheet(id: string) {
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
url: `${SHEET_PREFIX}/${id}/download`,
|
||||||
|
method: 'get',
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function batchDownloadPerformanceSheets(data: Api.Performance.Sheet.BatchDownloadParams) {
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${SHEET_PREFIX}/batch-download`,
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportPerformanceSheets(params: Api.Performance.Sheet.SearchParams = {}) {
|
||||||
|
const query = createSheetQuery(params);
|
||||||
|
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
url: query ? `${SHEET_PREFIX}/export?${query}` : `${SHEET_PREFIX}/export`,
|
||||||
|
method: 'get',
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPerformanceSheetStatusLogs(id: string) {
|
||||||
|
const result = await request<StatusLogResponse[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${SHEET_PREFIX}/${id}/status-logs`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<StatusLogResponse[]>, data => data.map(normalizeStatusLog));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPerformanceSheetResponseRecords(id: string) {
|
||||||
|
const result = await request<ResponseRecordResponse[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${SHEET_PREFIX}/${id}/response-records`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ResponseRecordResponse[]>, data =>
|
||||||
|
data.map(normalizeResponseRecord)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPerformanceMonthlyResult(employeeId: string, periodMonth: string) {
|
||||||
|
const result = await request<MonthlyResultResponse | null>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${SHEET_PREFIX}/monthly-result`,
|
||||||
|
method: 'get',
|
||||||
|
params: { employeeId, periodMonth }
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MonthlyResultResponse | null>, data =>
|
||||||
|
data ? normalizeMonthlyResult(data) : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchPerformanceSheetStatusDict() {
|
||||||
|
return request<Api.Performance.Sheet.StatusDict[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${SHEET_PREFIX}/status-dict`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchPerformanceSheetStatusTransitions() {
|
||||||
|
return request<Api.Performance.Sheet.StatusTransition[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${SHEET_PREFIX}/status-transitions`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTeamPerformanceSummary(params: Api.Performance.Team.SummaryParams = {}) {
|
||||||
|
const result = await request<TeamSummaryResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${TEAM_PREFIX}/summary`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<TeamSummaryResponse>, normalizeTeamSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remindTeamPerformance(data: Api.Performance.Team.RemindParams) {
|
||||||
|
return request<Api.Performance.Team.RemindResult>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${TEAM_PREFIX}/remind`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
880
src/service/api/personal-item.ts
Normal file
880
src/service/api/personal-item.ts
Normal file
@@ -0,0 +1,880 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type { ConfigType } from 'dayjs';
|
||||||
|
import type { FlatResponseData } from '@sa/axios';
|
||||||
|
import { WEB_SERVICE_PREFIX } from '@/constants/service';
|
||||||
|
import { request } from '../request';
|
||||||
|
import {
|
||||||
|
type ProjectExecutionResponse,
|
||||||
|
type TaskWorklogResponse,
|
||||||
|
normalizeProjectLocalDate,
|
||||||
|
normalizeTaskWorklog
|
||||||
|
} from './project-shared';
|
||||||
|
import { type ServiceRequestResult, mapServiceResult, normalizeStringId, safeJsonRequestConfig } from './shared';
|
||||||
|
|
||||||
|
type PersonalItemRecord = Api.PersonalItem.PersonalItem;
|
||||||
|
type PersonalItemWorklogRecord = Api.Project.TaskWorklog;
|
||||||
|
type PersonalItemResult<T> = Promise<FlatResponseData<any, T>>;
|
||||||
|
type StringIdResponse = string | number;
|
||||||
|
type PersonalItemLocalDateValue = string | number[] | null;
|
||||||
|
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
|
||||||
|
fileId?: StringIdResponse;
|
||||||
|
id?: StringIdResponse;
|
||||||
|
};
|
||||||
|
type PersonalItemLifecycleActionResponse = Omit<Api.PersonalItem.PersonalItemLifecycleAction, 'needReason'> & {
|
||||||
|
needReason?: boolean | number | string | null;
|
||||||
|
};
|
||||||
|
type PersonalItemResponse = Omit<
|
||||||
|
Api.PersonalItem.PersonalItem,
|
||||||
|
| 'id'
|
||||||
|
| 'ownerId'
|
||||||
|
| 'terminal'
|
||||||
|
| 'allowEdit'
|
||||||
|
| 'availableActions'
|
||||||
|
| 'plannedStartDate'
|
||||||
|
| 'plannedEndDate'
|
||||||
|
| 'actualStartDate'
|
||||||
|
| 'actualEndDate'
|
||||||
|
| 'attachments'
|
||||||
|
| 'totalSpentHours'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
ownerId: StringIdResponse;
|
||||||
|
terminal?: boolean | number | string | null;
|
||||||
|
allowEdit?: boolean | number | string | null;
|
||||||
|
availableActions?: PersonalItemLifecycleActionResponse[] | null;
|
||||||
|
plannedStartDate?: PersonalItemLocalDateValue;
|
||||||
|
plannedEndDate?: PersonalItemLocalDateValue;
|
||||||
|
actualStartDate?: PersonalItemLocalDateValue;
|
||||||
|
actualEndDate?: PersonalItemLocalDateValue;
|
||||||
|
attachments?: AttachmentItemResponse[] | null;
|
||||||
|
progressRate?: number | null;
|
||||||
|
totalSpentHours?: number | string | null;
|
||||||
|
};
|
||||||
|
type PersonalItemPageResponse = Omit<Api.PersonalItem.PersonalItemPageResult, 'total' | 'list'> & {
|
||||||
|
total: number | string;
|
||||||
|
list: PersonalItemResponse[];
|
||||||
|
};
|
||||||
|
type PersonalItemWorklogPageResponse = Api.Project.PageResult<TaskWorklogResponse>;
|
||||||
|
type PersonalItemExecutionOptionResponse = ProjectExecutionResponse & {
|
||||||
|
projectName?: string | null;
|
||||||
|
};
|
||||||
|
type PersonalItemSaveRequest = {
|
||||||
|
executionId?: string;
|
||||||
|
taskTitle: string;
|
||||||
|
type: string;
|
||||||
|
progressRate?: number;
|
||||||
|
plannedStartDate?: string;
|
||||||
|
plannedEndDate?: string;
|
||||||
|
taskDesc?: string;
|
||||||
|
attachments?: Array<{
|
||||||
|
id?: string;
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
contentType?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
type PersonalItemWorklogSaveRequest = {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
durationHours: number;
|
||||||
|
progressRate: number;
|
||||||
|
workContent?: string;
|
||||||
|
attachments?: Array<{
|
||||||
|
id?: string;
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
contentType?: string;
|
||||||
|
}>;
|
||||||
|
difficulty: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERSONAL_ITEM_PREFIX = `${WEB_SERVICE_PREFIX}/project/personal-items`;
|
||||||
|
|
||||||
|
const CURRENT_USER_ID = 'current-user';
|
||||||
|
const CURRENT_USER_NAME = '当前用户';
|
||||||
|
|
||||||
|
const personalItems: PersonalItemRecord[] = createSeedItems();
|
||||||
|
const personalItemWorklogs: PersonalItemWorklogRecord[] = createSeedWorklogs();
|
||||||
|
const executionOptions: Api.PersonalItem.PersonalItemExecutionOption[] = createExecutionOptions();
|
||||||
|
|
||||||
|
function createSuccessResult<T>(data: T): PersonalItemResult<T> {
|
||||||
|
return Promise.resolve({
|
||||||
|
data,
|
||||||
|
error: null,
|
||||||
|
response: undefined
|
||||||
|
} as unknown as FlatResponseData<any, T>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePageTotal(total: number | string) {
|
||||||
|
const value = Number(total);
|
||||||
|
|
||||||
|
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAttachments(list?: AttachmentItemResponse[] | null): Api.Project.AttachmentItem[] | null {
|
||||||
|
if (!list) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.map(item => {
|
||||||
|
const rawId = item.fileId ?? item.id;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
fileId: rawId === null || rawId === undefined ? '' : String(rawId)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!normalized || normalized === '0' || normalized === 'false' || normalized === 'n') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLifecycleActions(
|
||||||
|
actions?: PersonalItemLifecycleActionResponse[] | null
|
||||||
|
): Api.PersonalItem.PersonalItemLifecycleAction[] {
|
||||||
|
return (actions ?? []).map(action => ({
|
||||||
|
actionCode: action.actionCode,
|
||||||
|
actionName: action.actionName ?? '',
|
||||||
|
needReason: normalizeBooleanFlag(action.needReason)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePersonalItem(response: PersonalItemResponse): Api.PersonalItem.PersonalItem {
|
||||||
|
return {
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
taskTitle: response.taskTitle ?? '',
|
||||||
|
type: response.type ?? '',
|
||||||
|
ownerId: normalizeStringId(response.ownerId),
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
terminal: normalizeBooleanFlag(response.terminal),
|
||||||
|
allowEdit: normalizeBooleanFlag(response.allowEdit),
|
||||||
|
availableActions: normalizeLifecycleActions(response.availableActions),
|
||||||
|
progressRate:
|
||||||
|
typeof response.progressRate === 'number' ? response.progressRate : Number(response.progressRate ?? 0),
|
||||||
|
totalSpentHours: (() => {
|
||||||
|
if (typeof response.totalSpentHours === 'number') {
|
||||||
|
return response.totalSpentHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.totalSpentHours === null || response.totalSpentHours === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(response.totalSpentHours);
|
||||||
|
})(),
|
||||||
|
plannedStartDate: normalizeProjectLocalDate(response.plannedStartDate),
|
||||||
|
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
|
||||||
|
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
|
||||||
|
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
|
||||||
|
taskDesc: response.taskDesc ?? null,
|
||||||
|
lastStatusReason: response.lastStatusReason ?? null,
|
||||||
|
attachments: normalizeAttachments(response.attachments),
|
||||||
|
creator: response.creator ?? '',
|
||||||
|
createTime: response.createTime ?? '',
|
||||||
|
updater: response.updater ?? '',
|
||||||
|
updateTime: response.updateTime ?? '',
|
||||||
|
deleted: Boolean(response.deleted),
|
||||||
|
ownerName: response.ownerName ?? null,
|
||||||
|
ownerNickname: response.ownerNickname ?? null,
|
||||||
|
statusName: response.statusName ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePersonalItemExecutionOption(
|
||||||
|
response: PersonalItemExecutionOptionResponse
|
||||||
|
): Api.PersonalItem.PersonalItemExecutionOption {
|
||||||
|
return {
|
||||||
|
executionId: normalizeStringId(response.id),
|
||||||
|
executionName: response.executionName ?? '',
|
||||||
|
projectId: normalizeStringId(response.projectId),
|
||||||
|
projectName: response.projectName ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPersonalItemSaveRequest(data: Api.PersonalItem.SavePersonalItemParams): PersonalItemSaveRequest {
|
||||||
|
return {
|
||||||
|
executionId: data.executionId ?? undefined,
|
||||||
|
taskTitle: data.taskTitle.trim(),
|
||||||
|
type: data.type,
|
||||||
|
progressRate: typeof data.progressRate === 'number' ? data.progressRate : undefined,
|
||||||
|
plannedStartDate: data.plannedStartDate ?? undefined,
|
||||||
|
plannedEndDate: data.plannedEndDate ?? undefined,
|
||||||
|
taskDesc: data.taskDesc ?? undefined,
|
||||||
|
attachments:
|
||||||
|
data.attachments?.map(item => ({
|
||||||
|
id: item.fileId || undefined,
|
||||||
|
url: item.url,
|
||||||
|
name: item.name,
|
||||||
|
size: item.size,
|
||||||
|
contentType: item.contentType
|
||||||
|
})) ?? undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPersonalItemWorklogSaveRequest(
|
||||||
|
data: Api.PersonalItem.SavePersonalItemWorklogParams
|
||||||
|
): PersonalItemWorklogSaveRequest {
|
||||||
|
return {
|
||||||
|
startDate: data.startDate,
|
||||||
|
endDate: data.endDate,
|
||||||
|
durationHours: Number(data.durationHours.toFixed(1)),
|
||||||
|
progressRate: Number(data.progressRate.toFixed(2)),
|
||||||
|
workContent: data.workContent ?? undefined,
|
||||||
|
attachments:
|
||||||
|
data.attachments?.map(item => ({
|
||||||
|
id: item.fileId || undefined,
|
||||||
|
url: item.url,
|
||||||
|
name: item.name,
|
||||||
|
size: item.size,
|
||||||
|
contentType: item.contentType
|
||||||
|
})) ?? undefined,
|
||||||
|
difficulty: data.difficulty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPersonalItemPageQuery(params: Api.PersonalItem.PersonalItemSearchParams = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
|
||||||
|
query.append('pageNo', String(params.pageNo ?? 1));
|
||||||
|
query.append('pageSize', String(params.pageSize ?? 10));
|
||||||
|
|
||||||
|
if (params.keyword) {
|
||||||
|
query.append('keyword', params.keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.ownerId) {
|
||||||
|
query.append('ownerId', params.ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.statusCode) {
|
||||||
|
query.append('statusCode', params.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.updateTime?.forEach(item => {
|
||||||
|
if (item) {
|
||||||
|
query.append('updateTime', item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIdsQuery(ids: string[]) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
|
||||||
|
ids.forEach(id => {
|
||||||
|
if (id) {
|
||||||
|
query.append('ids', id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBindExecutionQuery(payload: Api.PersonalItem.BindPersonalItemExecutionParams) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
|
||||||
|
payload.ids.forEach(id => {
|
||||||
|
if (id) {
|
||||||
|
query.append('itemIds', id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
query.append('executionId', payload.executionId);
|
||||||
|
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneAttachment(item: Api.Project.AttachmentItem): Api.Project.AttachmentItem {
|
||||||
|
return { ...item };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneItem(item: PersonalItemRecord): PersonalItemRecord {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
attachments: item.attachments?.map(cloneAttachment) ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneWorklog(item: PersonalItemWorklogRecord): PersonalItemWorklogRecord {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
attachments: item.attachments?.map(cloneAttachment) ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDateTime(value?: ConfigType | null) {
|
||||||
|
const target = value ? dayjs(value) : dayjs();
|
||||||
|
return target.isValid() ? target.format('YYYY-MM-DD HH:mm:ss') : dayjs().format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDate(value?: ConfigType | null) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = dayjs(value);
|
||||||
|
return target.isValid() ? target.format('YYYY-MM-DD') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSeedItems(): PersonalItemRecord[] {
|
||||||
|
const now = dayjs();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'personal-item-1',
|
||||||
|
taskTitle: '整理供应商沟通纪要',
|
||||||
|
type: 'daily',
|
||||||
|
ownerId: CURRENT_USER_ID,
|
||||||
|
statusCode: 'active',
|
||||||
|
progressRate: 45,
|
||||||
|
plannedStartDate: normalizeDate(now.subtract(3, 'day')),
|
||||||
|
plannedEndDate: normalizeDate(now.add(2, 'day')),
|
||||||
|
actualStartDate: normalizeDate(now.subtract(2, 'day')),
|
||||||
|
actualEndDate: null,
|
||||||
|
taskDesc: '<p>补齐今天会议纪要,沉淀成一页内部记录,便于后续同步。</p>',
|
||||||
|
lastStatusReason: null,
|
||||||
|
attachments: null,
|
||||||
|
creator: CURRENT_USER_NAME,
|
||||||
|
createTime: normalizeDateTime(now.subtract(3, 'day').hour(9).minute(20).second(0)),
|
||||||
|
updater: CURRENT_USER_NAME,
|
||||||
|
updateTime: normalizeDateTime(now.subtract(2, 'hour')),
|
||||||
|
deleted: false,
|
||||||
|
ownerName: CURRENT_USER_NAME,
|
||||||
|
statusName: '进行中'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'personal-item-2',
|
||||||
|
taskTitle: '清理浏览器收藏夹里的项目入口',
|
||||||
|
type: 'daily',
|
||||||
|
ownerId: CURRENT_USER_ID,
|
||||||
|
statusCode: 'pending',
|
||||||
|
progressRate: 0,
|
||||||
|
plannedStartDate: normalizeDate(now.add(1, 'day')),
|
||||||
|
plannedEndDate: normalizeDate(now.add(4, 'day')),
|
||||||
|
actualStartDate: null,
|
||||||
|
actualEndDate: null,
|
||||||
|
taskDesc: '<p>把已经废弃的测试环境、旧文档入口统一清理。</p>',
|
||||||
|
lastStatusReason: null,
|
||||||
|
attachments: null,
|
||||||
|
creator: CURRENT_USER_NAME,
|
||||||
|
createTime: normalizeDateTime(now.subtract(2, 'day').hour(14).minute(10).second(0)),
|
||||||
|
updater: CURRENT_USER_NAME,
|
||||||
|
updateTime: normalizeDateTime(now.subtract(5, 'hour')),
|
||||||
|
deleted: false,
|
||||||
|
ownerName: CURRENT_USER_NAME,
|
||||||
|
statusName: '待处理'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'personal-item-3',
|
||||||
|
taskTitle: '补充账号开通说明截图',
|
||||||
|
type: 'support',
|
||||||
|
ownerId: CURRENT_USER_ID,
|
||||||
|
statusCode: 'completed',
|
||||||
|
progressRate: 100,
|
||||||
|
plannedStartDate: normalizeDate(now.subtract(5, 'day')),
|
||||||
|
plannedEndDate: normalizeDate(now.subtract(2, 'day')),
|
||||||
|
actualStartDate: normalizeDate(now.subtract(5, 'day')),
|
||||||
|
actualEndDate: normalizeDate(now.subtract(1, 'day')),
|
||||||
|
taskDesc: '<p>为新同事入职说明补一版截图,后续发在群公告。</p>',
|
||||||
|
lastStatusReason: '已完成并同步团队',
|
||||||
|
attachments: null,
|
||||||
|
creator: CURRENT_USER_NAME,
|
||||||
|
createTime: normalizeDateTime(now.subtract(5, 'day').hour(11).minute(0).second(0)),
|
||||||
|
updater: CURRENT_USER_NAME,
|
||||||
|
updateTime: normalizeDateTime(now.subtract(1, 'day').hour(18).minute(30).second(0)),
|
||||||
|
deleted: false,
|
||||||
|
ownerName: CURRENT_USER_NAME,
|
||||||
|
statusName: '已完成'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSeedWorklogs(): PersonalItemWorklogRecord[] {
|
||||||
|
const now = dayjs();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'worklog-1',
|
||||||
|
taskId: 'personal-item-1',
|
||||||
|
userId: CURRENT_USER_ID,
|
||||||
|
userNickname: CURRENT_USER_NAME,
|
||||||
|
startDate: normalizeDate(now.subtract(2, 'day'))!,
|
||||||
|
endDate: normalizeDate(now.subtract(2, 'day'))!,
|
||||||
|
durationHours: 2.5,
|
||||||
|
progressRate: 30,
|
||||||
|
difficulty: '2',
|
||||||
|
workContent: '整理会议录音和重点结论,先输出初版纪要。',
|
||||||
|
attachments: null,
|
||||||
|
createTime: normalizeDateTime(now.subtract(2, 'day').hour(19)),
|
||||||
|
updateTime: normalizeDateTime(now.subtract(2, 'day').hour(19))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'worklog-2',
|
||||||
|
taskId: 'personal-item-1',
|
||||||
|
userId: CURRENT_USER_ID,
|
||||||
|
userNickname: CURRENT_USER_NAME,
|
||||||
|
startDate: normalizeDate(now.subtract(1, 'day'))!,
|
||||||
|
endDate: normalizeDate(now.subtract(1, 'day'))!,
|
||||||
|
durationHours: 1.5,
|
||||||
|
progressRate: 45,
|
||||||
|
difficulty: '2',
|
||||||
|
workContent: '补全供应商待确认项并整理后续跟进人。',
|
||||||
|
attachments: null,
|
||||||
|
createTime: normalizeDateTime(now.subtract(1, 'day').hour(18)),
|
||||||
|
updateTime: normalizeDateTime(now.subtract(1, 'day').hour(18))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'worklog-3',
|
||||||
|
taskId: 'personal-item-3',
|
||||||
|
userId: CURRENT_USER_ID,
|
||||||
|
userNickname: CURRENT_USER_NAME,
|
||||||
|
startDate: normalizeDate(now.subtract(5, 'day'))!,
|
||||||
|
endDate: normalizeDate(now.subtract(5, 'day'))!,
|
||||||
|
durationHours: 1,
|
||||||
|
progressRate: 60,
|
||||||
|
difficulty: '1',
|
||||||
|
workContent: '补拍账号开通流程截图。',
|
||||||
|
attachments: null,
|
||||||
|
createTime: normalizeDateTime(now.subtract(5, 'day').hour(15)),
|
||||||
|
updateTime: normalizeDateTime(now.subtract(5, 'day').hour(15))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'worklog-4',
|
||||||
|
taskId: 'personal-item-3',
|
||||||
|
userId: CURRENT_USER_ID,
|
||||||
|
userNickname: CURRENT_USER_NAME,
|
||||||
|
startDate: normalizeDate(now.subtract(1, 'day'))!,
|
||||||
|
endDate: normalizeDate(now.subtract(1, 'day'))!,
|
||||||
|
durationHours: 0.5,
|
||||||
|
progressRate: 100,
|
||||||
|
difficulty: '1',
|
||||||
|
workContent: '校对文案并发到群公告。',
|
||||||
|
attachments: null,
|
||||||
|
createTime: normalizeDateTime(now.subtract(1, 'day').hour(18).minute(20)),
|
||||||
|
updateTime: normalizeDateTime(now.subtract(1, 'day').hour(18).minute(20))
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExecutionOptions(): Api.PersonalItem.PersonalItemExecutionOption[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
executionId: 'execution-1001',
|
||||||
|
executionName: '2026Q2 运营提效',
|
||||||
|
projectId: 'project-1001',
|
||||||
|
projectName: '运营中台优化'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
executionId: 'execution-1002',
|
||||||
|
executionName: '2026Q2 用户支持专项',
|
||||||
|
projectId: 'project-1002',
|
||||||
|
projectName: '基础平台升级'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
executionId: 'execution-1003',
|
||||||
|
executionName: '2026Q3 数据治理',
|
||||||
|
projectId: 'project-1003',
|
||||||
|
projectName: '数据资产规范化'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findItemIndex(id: string) {
|
||||||
|
return personalItems.findIndex(item => item.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemOrThrow(id: string) {
|
||||||
|
const item = personalItems.find(current => current.id === id && !current.deleted);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw new Error(`personal item not found: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortItems(list: PersonalItemRecord[]) {
|
||||||
|
return [...list].sort((left, right) => dayjs(right.updateTime).valueOf() - dayjs(left.updateTime).valueOf());
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortWorklogs(list: PersonalItemWorklogRecord[]) {
|
||||||
|
return [...list].sort((left, right) => {
|
||||||
|
const endDiff = dayjs(right.endDate).valueOf() - dayjs(left.endDate).valueOf();
|
||||||
|
if (endDiff !== 0) {
|
||||||
|
return endDiff;
|
||||||
|
}
|
||||||
|
return dayjs(right.updateTime).valueOf() - dayjs(left.updateTime).valueOf();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPersonalItemStatusName(statusCode: Api.PersonalItem.PersonalItemStatusCode) {
|
||||||
|
const statusNameMap: Partial<Record<Api.PersonalItem.PersonalItemStatusCode, string>> = {
|
||||||
|
pending: '待处理',
|
||||||
|
active: '进行中',
|
||||||
|
completed: '已完成'
|
||||||
|
};
|
||||||
|
|
||||||
|
return statusNameMap[statusCode] || statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItemsByIds(ids: string[]) {
|
||||||
|
const idSet = new Set(ids);
|
||||||
|
|
||||||
|
for (let i = personalItems.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (idSet.has(personalItems[i].id)) {
|
||||||
|
personalItems.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = personalItemWorklogs.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (idSet.has(personalItemWorklogs[i].taskId)) {
|
||||||
|
personalItemWorklogs.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumWorklogHours(logs: PersonalItemWorklogRecord[]) {
|
||||||
|
return logs.reduce((sum, log) => sum + (log.durationHours ?? 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncItemFromWorklogs(itemId: string) {
|
||||||
|
const item = getItemOrThrow(itemId);
|
||||||
|
const logs = sortWorklogs(personalItemWorklogs.filter(log => log.taskId === itemId));
|
||||||
|
|
||||||
|
item.statusName = getPersonalItemStatusName(item.statusCode);
|
||||||
|
item.totalSpentHours = sumWorklogHours(logs);
|
||||||
|
|
||||||
|
if (logs.length === 0) {
|
||||||
|
if (item.statusCode !== 'completed') {
|
||||||
|
item.progressRate = 0;
|
||||||
|
item.actualStartDate = null;
|
||||||
|
item.actualEndDate = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestLog = logs[0];
|
||||||
|
const chronologicalLogs = [...logs].sort(
|
||||||
|
(left, right) => dayjs(left.startDate).valueOf() - dayjs(right.startDate).valueOf()
|
||||||
|
);
|
||||||
|
|
||||||
|
item.progressRate = latestLog.progressRate ?? item.progressRate;
|
||||||
|
item.actualStartDate = chronologicalLogs[0]?.startDate ?? item.actualStartDate;
|
||||||
|
item.actualEndDate = latestLog.endDate ?? item.actualEndDate;
|
||||||
|
item.updateTime = latestLog.updateTime;
|
||||||
|
item.updater = CURRENT_USER_NAME;
|
||||||
|
|
||||||
|
if (item.statusCode === 'pending') {
|
||||||
|
item.statusCode = 'active';
|
||||||
|
item.statusName = getPersonalItemStatusName(item.statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySaveFields(target: PersonalItemRecord, payload: Api.PersonalItem.SavePersonalItemParams) {
|
||||||
|
target.taskTitle = payload.taskTitle.trim();
|
||||||
|
target.type = payload.type;
|
||||||
|
target.ownerId = payload.ownerId || target.ownerId;
|
||||||
|
target.ownerName = CURRENT_USER_NAME;
|
||||||
|
target.plannedStartDate = payload.plannedStartDate;
|
||||||
|
target.plannedEndDate = payload.plannedEndDate;
|
||||||
|
target.taskDesc = payload.taskDesc ?? null;
|
||||||
|
target.attachments = payload.attachments?.map(cloneAttachment) ?? null;
|
||||||
|
target.updater = CURRENT_USER_NAME;
|
||||||
|
target.updateTime = normalizeDateTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterWorklogs(taskId: string, params?: Api.PersonalItem.PersonalItemWorklogSearchParams) {
|
||||||
|
return sortWorklogs(
|
||||||
|
personalItemWorklogs.filter(item => {
|
||||||
|
if (item.taskId !== taskId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.userId && item.userId !== params.userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.startDate && dayjs(item.endDate).isBefore(dayjs(params.startDate), 'day')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.endDate && dayjs(item.startDate).isAfter(dayjs(params.endDate), 'day')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetPersonalItemPage(params: Api.PersonalItem.PersonalItemSearchParams = {}) {
|
||||||
|
const query = createPersonalItemPageQuery(params);
|
||||||
|
|
||||||
|
const result = await request<PersonalItemPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${PERSONAL_ITEM_PREFIX}/page?${query}` : `${PERSONAL_ITEM_PREFIX}/page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<PersonalItemPageResponse>, data => ({
|
||||||
|
total: normalizePageTotal(data.total),
|
||||||
|
list: data.list.map(normalizePersonalItem)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetPersonalItemDetail(id: string) {
|
||||||
|
const result = await request<PersonalItemResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PERSONAL_ITEM_PREFIX}/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<PersonalItemResponse>, normalizePersonalItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCreatePersonalItem(data: Api.PersonalItem.SavePersonalItemParams) {
|
||||||
|
const result = await request<string | number>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: PERSONAL_ITEM_PREFIX,
|
||||||
|
method: 'post',
|
||||||
|
data: toPersonalItemSaveRequest(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapped = mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
|
|
||||||
|
if (!mapped.error && mapped.data) {
|
||||||
|
const now = normalizeDateTime();
|
||||||
|
const createdItem: PersonalItemRecord = {
|
||||||
|
id: mapped.data,
|
||||||
|
taskTitle: data.taskTitle.trim(),
|
||||||
|
type: data.type,
|
||||||
|
ownerId: data.ownerId || CURRENT_USER_ID,
|
||||||
|
statusCode: 'pending',
|
||||||
|
progressRate: typeof data.progressRate === 'number' ? data.progressRate : 0,
|
||||||
|
plannedStartDate: data.plannedStartDate,
|
||||||
|
plannedEndDate: data.plannedEndDate,
|
||||||
|
actualStartDate: null,
|
||||||
|
actualEndDate: null,
|
||||||
|
taskDesc: data.taskDesc ?? null,
|
||||||
|
lastStatusReason: null,
|
||||||
|
attachments: data.attachments?.map(cloneAttachment) ?? null,
|
||||||
|
creator: CURRENT_USER_NAME,
|
||||||
|
createTime: now,
|
||||||
|
updater: CURRENT_USER_NAME,
|
||||||
|
updateTime: now,
|
||||||
|
deleted: false,
|
||||||
|
ownerName: CURRENT_USER_NAME,
|
||||||
|
statusName: getPersonalItemStatusName('pending')
|
||||||
|
};
|
||||||
|
|
||||||
|
personalItems.unshift(createdItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUpdatePersonalItem(data: Api.PersonalItem.UpdatePersonalItemParams) {
|
||||||
|
const result = await request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PERSONAL_ITEM_PREFIX}/${data.id}`,
|
||||||
|
method: 'put',
|
||||||
|
data: toPersonalItemSaveRequest(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapped = mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
|
||||||
|
|
||||||
|
if (!mapped.error && mapped.data) {
|
||||||
|
const targetIndex = findItemIndex(data.id);
|
||||||
|
|
||||||
|
if (targetIndex >= 0) {
|
||||||
|
applySaveFields(personalItems[targetIndex], data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchChangePersonalItemStatus(id: string, data: Api.PersonalItem.ChangePersonalItemStatusParams) {
|
||||||
|
const result = await request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PERSONAL_ITEM_PREFIX}/${id}/change-status`,
|
||||||
|
method: 'post',
|
||||||
|
data: {
|
||||||
|
actionCode: data.actionCode,
|
||||||
|
reason: data.reason ?? undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapped = mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
|
||||||
|
|
||||||
|
if (!mapped.error && mapped.data) {
|
||||||
|
const target = personalItems.find(item => item.id === id);
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
target.lastStatusReason = data.reason ?? null;
|
||||||
|
target.updater = CURRENT_USER_NAME;
|
||||||
|
target.updateTime = normalizeDateTime();
|
||||||
|
|
||||||
|
if (data.actionCode === 'start') {
|
||||||
|
target.statusCode = 'active';
|
||||||
|
target.statusName = getPersonalItemStatusName('active');
|
||||||
|
target.actualStartDate ??= normalizeDate(dayjs());
|
||||||
|
target.actualEndDate = null;
|
||||||
|
} else if (data.actionCode === 'complete') {
|
||||||
|
target.statusCode = 'completed';
|
||||||
|
target.statusName = getPersonalItemStatusName('completed');
|
||||||
|
target.progressRate = 100;
|
||||||
|
target.actualStartDate ??= normalizeDate(dayjs());
|
||||||
|
target.actualEndDate = normalizeDate(dayjs());
|
||||||
|
} else if (data.actionCode === 'reopen') {
|
||||||
|
target.statusCode = 'active';
|
||||||
|
target.statusName = getPersonalItemStatusName('active');
|
||||||
|
target.actualStartDate ??= normalizeDate(dayjs());
|
||||||
|
target.actualEndDate = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDeletePersonalItem(id: string) {
|
||||||
|
const result = await request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PERSONAL_ITEM_PREFIX}/delete`,
|
||||||
|
method: 'delete',
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapped = mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
|
||||||
|
|
||||||
|
if (!mapped.error && mapped.data) {
|
||||||
|
removeItemsByIds([id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBatchDeletePersonalItems(payload: Api.PersonalItem.BatchDeletePersonalItemParams) {
|
||||||
|
const query = createIdsQuery(payload.ids);
|
||||||
|
const result = await request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${PERSONAL_ITEM_PREFIX}/delete-list?${query}` : `${PERSONAL_ITEM_PREFIX}/delete-list`,
|
||||||
|
method: 'delete'
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapped = mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
|
||||||
|
|
||||||
|
if (!mapped.error && mapped.data) {
|
||||||
|
removeItemsByIds(payload.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetPersonalItemExecutionOptions() {
|
||||||
|
const result = await request<PersonalItemExecutionOptionResponse[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PERSONAL_ITEM_PREFIX}/owner/all-execution`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<PersonalItemExecutionOptionResponse[]>, data =>
|
||||||
|
data.map(normalizePersonalItemExecutionOption)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBindPersonalItemsToExecution(payload: Api.PersonalItem.BindPersonalItemExecutionParams) {
|
||||||
|
const query = createBindExecutionQuery(payload);
|
||||||
|
const result = await request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${PERSONAL_ITEM_PREFIX}/relate-execution?${query}` : `${PERSONAL_ITEM_PREFIX}/relate-execution`,
|
||||||
|
method: 'post'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<boolean>, value => Boolean(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchStartPersonalItem(id: string): PersonalItemResult<boolean> {
|
||||||
|
return fetchChangePersonalItemStatus(id, { actionCode: 'start' }) as PersonalItemResult<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCompletePersonalItem(id: string): PersonalItemResult<boolean> {
|
||||||
|
return fetchChangePersonalItemStatus(id, { actionCode: 'complete' }) as PersonalItemResult<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchReopenPersonalItem(id: string): PersonalItemResult<boolean> {
|
||||||
|
return fetchChangePersonalItemStatus(id, { actionCode: 'reopen' }) as PersonalItemResult<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetPersonalItemWorklogPage(
|
||||||
|
taskId: string,
|
||||||
|
params: Api.PersonalItem.PersonalItemWorklogSearchParams = {}
|
||||||
|
) {
|
||||||
|
const result = await request<PersonalItemWorklogPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PERSONAL_ITEM_PREFIX}/${taskId}/worklogs`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<PersonalItemWorklogPageResponse>, data => ({
|
||||||
|
...data,
|
||||||
|
list: data.list.map(normalizeTaskWorklog)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCreatePersonalItemWorklog(
|
||||||
|
taskId: string,
|
||||||
|
data: Api.PersonalItem.SavePersonalItemWorklogParams
|
||||||
|
) {
|
||||||
|
const result = await request<string | number>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PERSONAL_ITEM_PREFIX}/${taskId}/worklogs`,
|
||||||
|
method: 'post',
|
||||||
|
data: toPersonalItemWorklogSaveRequest(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUpdatePersonalItemWorklog(
|
||||||
|
taskId: string,
|
||||||
|
payload: { worklogId: string; data: Api.PersonalItem.SavePersonalItemWorklogParams }
|
||||||
|
): PersonalItemResult<boolean> {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PERSONAL_ITEM_PREFIX}/${taskId}/worklogs/${payload.worklogId}`,
|
||||||
|
method: 'put',
|
||||||
|
data: toPersonalItemWorklogSaveRequest(payload.data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDeletePersonalItemWorklog(taskId: string, worklogId: string): PersonalItemResult<boolean> {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PERSONAL_ITEM_PREFIX}/${taskId}/worklogs/${worklogId}`,
|
||||||
|
method: 'delete'
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,9 +11,11 @@ import { normalizeProductMember, normalizeProductSettings } from './product-shar
|
|||||||
|
|
||||||
const PRODUCT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product`;
|
const PRODUCT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product`;
|
||||||
|
|
||||||
type ProductResponse = Omit<Api.Product.Product, 'id' | 'managerUserId'> & {
|
type ProductResponse = Omit<Api.Product.Product, 'id' | 'managerUserId' | 'currentUserRoles'> & {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
managerUserId?: string | number | null;
|
managerUserId?: string | number | null;
|
||||||
|
/** 灰度/兼容期后端可能缺省,适配层兜底为 [] */
|
||||||
|
currentUserRoles?: Api.Common.CurrentUserRole[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProductPageResponse = Api.Product.PageResult<ProductResponse>;
|
type ProductPageResponse = Api.Product.PageResult<ProductResponse>;
|
||||||
@@ -39,7 +41,8 @@ function normalizeProduct(product: ProductResponse): Api.Product.Product {
|
|||||||
return {
|
return {
|
||||||
...product,
|
...product,
|
||||||
id: normalizeStringId(product.id),
|
id: normalizeStringId(product.id),
|
||||||
managerUserId: normalizeNullableStringId(product.managerUserId) ?? ''
|
managerUserId: normalizeNullableStringId(product.managerUserId) ?? '',
|
||||||
|
currentUserRoles: product.currentUserRoles ?? []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +94,7 @@ function createProductActivityTimelinePageQuery(params: Api.Product.ProductActiv
|
|||||||
return query.toString();
|
return query.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 鑾峰彇浜у搧鍒嗛〉 */
|
/** 获取产品分页 */
|
||||||
export async function fetchGetProductPage(params?: Api.Product.ProductSearchParams) {
|
export async function fetchGetProductPage(params?: Api.Product.ProductSearchParams) {
|
||||||
const result = await request<ProductPageResponse>({
|
const result = await request<ProductPageResponse>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
@@ -106,7 +109,37 @@ export async function fetchGetProductPage(params?: Api.Product.ProductSearchPara
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 鑾峰彇浜у搧璇︽儏 */
|
type ProductOverviewSummaryResponse = Omit<Api.Product.ProductOverviewSummary, 'total' | 'items'> & {
|
||||||
|
/** 后端 overview-summary 升级(total/items)灰度期间可能缺省,适配层兜底 */
|
||||||
|
total?: number | null;
|
||||||
|
items?: Api.Product.OverviewStatusItem[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 归一化产品概览统计:total/items 兜底,保证业务层拿到完整结构 */
|
||||||
|
function normalizeProductOverviewSummary(data: ProductOverviewSummaryResponse): Api.Product.ProductOverviewSummary {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
statusCounts: data.statusCounts ?? {},
|
||||||
|
total: data.total ?? 0,
|
||||||
|
items: data.items ?? []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取产品入口页概览统计 */
|
||||||
|
export async function fetchGetProductOverviewSummary() {
|
||||||
|
const result = await request<ProductOverviewSummaryResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PRODUCT_PREFIX}/overview-summary`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(
|
||||||
|
result as ServiceRequestResult<ProductOverviewSummaryResponse>,
|
||||||
|
normalizeProductOverviewSummary
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取产品详情 */
|
||||||
export async function fetchGetProduct(id: string) {
|
export async function fetchGetProduct(id: string) {
|
||||||
const result = await request<ProductResponse>({
|
const result = await request<ProductResponse>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
@@ -118,7 +151,7 @@ export async function fetchGetProduct(id: string) {
|
|||||||
return mapServiceResult(result as ServiceRequestResult<ProductResponse>, normalizeProduct);
|
return mapServiceResult(result as ServiceRequestResult<ProductResponse>, normalizeProduct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 鍒涘缓浜у搧 */
|
/** 新增产品 */
|
||||||
export async function fetchCreateProduct(data: Api.Product.SaveProductParams) {
|
export async function fetchCreateProduct(data: Api.Product.SaveProductParams) {
|
||||||
const result = await request<string | number>({
|
const result = await request<string | number>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
@@ -130,7 +163,19 @@ export async function fetchCreateProduct(data: Api.Product.SaveProductParams) {
|
|||||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 鏇存柊浜у搧 */
|
/** 创建产品(含初始团队,原子接口) */
|
||||||
|
export async function fetchCreateProductWithTeam(data: Api.Product.CreateProductWithTeamParams) {
|
||||||
|
const result = await request<string | number>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PRODUCT_PREFIX}/create-with-team`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新产品 */
|
||||||
export function fetchUpdateProduct(data: Api.Product.UpdateProductParams) {
|
export function fetchUpdateProduct(data: Api.Product.UpdateProductParams) {
|
||||||
return request<boolean>({
|
return request<boolean>({
|
||||||
url: `${PRODUCT_PREFIX}/update`,
|
url: `${PRODUCT_PREFIX}/update`,
|
||||||
@@ -139,7 +184,7 @@ export function fetchUpdateProduct(data: Api.Product.UpdateProductParams) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 鍙樻洿浜у搧鐘舵€? */
|
/** 改变产品状态 */
|
||||||
export function fetchChangeProductStatus(data: Api.Product.ChangeProductStatusParams) {
|
export function fetchChangeProductStatus(data: Api.Product.ChangeProductStatusParams) {
|
||||||
return request<boolean>({
|
return request<boolean>({
|
||||||
url: `${PRODUCT_PREFIX}/change-status`,
|
url: `${PRODUCT_PREFIX}/change-status`,
|
||||||
@@ -148,7 +193,7 @@ export function fetchChangeProductStatus(data: Api.Product.ChangeProductStatusPa
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 鍒犻櫎浜у搧 */
|
/** 删除产品 */
|
||||||
export function fetchDeleteProduct(data: Api.Product.DeleteProductParams) {
|
export function fetchDeleteProduct(data: Api.Product.DeleteProductParams) {
|
||||||
return request<boolean>({
|
return request<boolean>({
|
||||||
url: `${PRODUCT_PREFIX}/delete`,
|
url: `${PRODUCT_PREFIX}/delete`,
|
||||||
@@ -162,7 +207,14 @@ const REQUIREMENT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product/requirement`;
|
|||||||
|
|
||||||
type RequirementResponse = Omit<
|
type RequirementResponse = Omit<
|
||||||
Api.Product.Requirement,
|
Api.Product.Requirement,
|
||||||
'id' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'implementProjectId' | 'sourceBizId'
|
| 'id'
|
||||||
|
| 'parentId'
|
||||||
|
| 'moduleId'
|
||||||
|
| 'proposerId'
|
||||||
|
| 'currentHandlerUserId'
|
||||||
|
| 'implementProjectId'
|
||||||
|
| 'sourceBizCode'
|
||||||
|
| 'attachments'
|
||||||
> & {
|
> & {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
parentId: string | number;
|
parentId: string | number;
|
||||||
@@ -170,11 +222,68 @@ type RequirementResponse = Omit<
|
|||||||
proposerId: string | number;
|
proposerId: string | number;
|
||||||
currentHandlerUserId?: string | number | null;
|
currentHandlerUserId?: string | number | null;
|
||||||
implementProjectId?: string | number | null;
|
implementProjectId?: string | number | null;
|
||||||
sourceBizId?: string | number | null;
|
implementProjectName?: string | null;
|
||||||
|
sourceBizCode?: string | null;
|
||||||
|
attachments?: AttachmentItemResponse[] | null;
|
||||||
children?: RequirementResponse[];
|
children?: RequirementResponse[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type RequirementPageResponse = Api.Product.PageResult<RequirementResponse>;
|
type RequirementPageResponse = Api.Product.PageResult<RequirementResponse>;
|
||||||
|
type RequirementReviewResponse = Omit<
|
||||||
|
Api.Product.RequirementReview,
|
||||||
|
'id' | 'requirementId' | 'operatorId' | 'attendees' | 'attachments'
|
||||||
|
> & {
|
||||||
|
id: string | number;
|
||||||
|
requirementId: string | number;
|
||||||
|
operatorId: string | number;
|
||||||
|
attendees?: Array<{
|
||||||
|
userId: string | number;
|
||||||
|
nickname: string;
|
||||||
|
}>;
|
||||||
|
attachments?: AttachmentItemResponse[] | null;
|
||||||
|
};
|
||||||
|
type ProductRequirementDashboardSummaryResponse = {
|
||||||
|
total?: number | string | null;
|
||||||
|
todo?: number | string | null;
|
||||||
|
pendingClaim?: number | string | null;
|
||||||
|
pendingReview?: number | string | null;
|
||||||
|
pendingDispatch?: number | string | null;
|
||||||
|
completed?: number | string | null;
|
||||||
|
completionRate?: number | string | null;
|
||||||
|
highPriorityTodo?: number | string | null;
|
||||||
|
};
|
||||||
|
type ProductRequirementDashboardRecentChangeResponse = Omit<
|
||||||
|
Api.Product.ProductRequirementDashboardRecentChange,
|
||||||
|
'id' | 'requirementId' | 'operatorUserId'
|
||||||
|
> & {
|
||||||
|
id: string | number;
|
||||||
|
requirementId?: string | number | null;
|
||||||
|
operatorUserId?: string | number | null;
|
||||||
|
};
|
||||||
|
type ProductRequirementDashboardResponse = {
|
||||||
|
summary?: ProductRequirementDashboardSummaryResponse | null;
|
||||||
|
recentChanges?: ProductRequirementDashboardRecentChangeResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
|
||||||
|
fileId?: string | number;
|
||||||
|
id?: string | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeAttachments(list?: AttachmentItemResponse[] | null): Api.Project.AttachmentItem[] | null {
|
||||||
|
if (!list) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.map(item => {
|
||||||
|
const rawId = item.fileId ?? item.id;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
fileId: rawId === null || rawId === undefined ? '' : String(rawId)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRequirement(requirement: RequirementResponse): Api.Product.Requirement {
|
function normalizeRequirement(requirement: RequirementResponse): Api.Product.Requirement {
|
||||||
return {
|
return {
|
||||||
@@ -185,11 +294,58 @@ function normalizeRequirement(requirement: RequirementResponse): Api.Product.Req
|
|||||||
proposerId: normalizeStringId(requirement.proposerId),
|
proposerId: normalizeStringId(requirement.proposerId),
|
||||||
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
|
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
|
||||||
implementProjectId: normalizeNullableStringId(requirement.implementProjectId),
|
implementProjectId: normalizeNullableStringId(requirement.implementProjectId),
|
||||||
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
|
implementProjectName: requirement.implementProjectName ?? null,
|
||||||
|
sourceBizCode: requirement.sourceBizCode ?? null,
|
||||||
|
attachments: normalizeAttachments(requirement.attachments),
|
||||||
children: requirement.children?.map(normalizeRequirement)
|
children: requirement.children?.map(normalizeRequirement)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRequirementReview(review: RequirementReviewResponse): Api.Product.RequirementReview {
|
||||||
|
return {
|
||||||
|
...review,
|
||||||
|
id: normalizeStringId(review.id),
|
||||||
|
requirementId: normalizeStringId(review.requirementId),
|
||||||
|
operatorId: normalizeStringId(review.operatorId),
|
||||||
|
attendees: review.attendees?.map(item => ({
|
||||||
|
...item,
|
||||||
|
userId: normalizeStringId(item.userId)
|
||||||
|
})),
|
||||||
|
attachments: normalizeAttachments(review.attachments)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDashboardCount(value: number | string | null | undefined) {
|
||||||
|
const count = Number(value ?? 0);
|
||||||
|
|
||||||
|
return Number.isFinite(count) ? Math.max(0, count) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProductRequirementDashboard(
|
||||||
|
data: ProductRequirementDashboardResponse
|
||||||
|
): Api.Product.ProductRequirementDashboard {
|
||||||
|
const summary = data.summary ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
total: normalizeDashboardCount(summary.total),
|
||||||
|
todo: normalizeDashboardCount(summary.todo),
|
||||||
|
pendingClaim: normalizeDashboardCount(summary.pendingClaim),
|
||||||
|
pendingReview: normalizeDashboardCount(summary.pendingReview),
|
||||||
|
pendingDispatch: normalizeDashboardCount(summary.pendingDispatch),
|
||||||
|
completed: normalizeDashboardCount(summary.completed),
|
||||||
|
completionRate: Math.min(100, normalizeDashboardCount(summary.completionRate)),
|
||||||
|
highPriorityTodo: normalizeDashboardCount(summary.highPriorityTodo)
|
||||||
|
},
|
||||||
|
recentChanges: (data.recentChanges ?? []).map(item => ({
|
||||||
|
...item,
|
||||||
|
id: normalizeStringId(item.id),
|
||||||
|
requirementId: normalizeNullableStringId(item.requirementId),
|
||||||
|
operatorUserId: normalizeNullableStringId(item.operatorUserId)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取需求分页列表 */
|
/** 获取需求分页列表 */
|
||||||
export async function fetchGetRequirementPage(params?: Api.Product.RequirementSearchParams) {
|
export async function fetchGetRequirementPage(params?: Api.Product.RequirementSearchParams) {
|
||||||
const result = await request<RequirementPageResponse>({
|
const result = await request<RequirementPageResponse>({
|
||||||
@@ -285,17 +441,6 @@ export async function fetchSplitRequirement(data: Api.Product.SplitRequirementPa
|
|||||||
|
|
||||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 关闭需求 */
|
|
||||||
export function fetchCloseRequirement(data: Api.Product.CloseRequirementParams) {
|
|
||||||
return request<boolean>({
|
|
||||||
...safeJsonRequestConfig,
|
|
||||||
url: `${REQUIREMENT_PREFIX}/close`,
|
|
||||||
method: 'post',
|
|
||||||
data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取需求可执行的状态动作列表 */
|
/** 获取需求可执行的状态动作列表 */
|
||||||
export async function fetchGetRequirementAllowedTransitions(requirementId: string, productId: string) {
|
export async function fetchGetRequirementAllowedTransitions(requirementId: string, productId: string) {
|
||||||
const result = await request<Api.Product.RequirementLifecycleAction[]>({
|
const result = await request<Api.Product.RequirementLifecycleAction[]>({
|
||||||
@@ -308,16 +453,62 @@ export async function fetchGetRequirementAllowedTransitions(requirementId: strin
|
|||||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementLifecycleAction[]>, data => data);
|
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementLifecycleAction[]>, data => data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取需求生命周期信息 */
|
/** 批量获取需求可执行的状态动作列表 */
|
||||||
export async function fetchGetRequirementLifecycle(requirementId: string, productId: string) {
|
export async function fetchGetRequirementAllowedTransitionsBatch(data: Api.Product.RequirementBatchReqVO) {
|
||||||
const result = await request<Api.Product.RequirementLifecycleInfo>({
|
const result = await request<Api.Product.RequirementAllowedTransitionBatchRespVO[]>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
url: `${REQUIREMENT_PREFIX}/lifecycle`,
|
url: `${REQUIREMENT_PREFIX}/allowed-transitions/batch`,
|
||||||
method: 'get',
|
method: 'post',
|
||||||
params: { requirementId, productId }
|
data
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementLifecycleInfo>, data => data);
|
return mapServiceResult(
|
||||||
|
result as ServiceRequestResult<Api.Product.RequirementAllowedTransitionBatchRespVO[]>,
|
||||||
|
data1 =>
|
||||||
|
data1.map(item => ({
|
||||||
|
requirementId: normalizeStringId(item.requirementId),
|
||||||
|
transitions: item.transitions
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交产品需求评审 */
|
||||||
|
export async function fetchSubmitProductRequirementReview(data: Api.Product.RequirementReviewSubmitParams) {
|
||||||
|
const result = await request<string | number>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${REQUIREMENT_PREFIX}/review/submit`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取产品需求评审记录 */
|
||||||
|
export async function fetchGetProductRequirementReview(productId: string, requirementId: string) {
|
||||||
|
const result = await request<RequirementReviewResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${REQUIREMENT_PREFIX}/review/get`,
|
||||||
|
method: 'get',
|
||||||
|
params: { productId, requirementId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<RequirementReviewResponse>, normalizeRequirementReview);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取产品概览需求池实时看板 */
|
||||||
|
export async function fetchGetProductRequirementDashboard(productId: string) {
|
||||||
|
const result = await request<ProductRequirementDashboardResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${REQUIREMENT_PREFIX}/dashboard`,
|
||||||
|
method: 'get',
|
||||||
|
params: { productId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(
|
||||||
|
result as ServiceRequestResult<ProductRequirementDashboardResponse>,
|
||||||
|
normalizeProductRequirementDashboard
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取需求所有状态字典 */
|
/** 获取需求所有状态字典 */
|
||||||
@@ -331,15 +522,41 @@ export async function fetchGetRequirementStatusDict() {
|
|||||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
|
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取需求终止态状态字典 */
|
/** 判断产品需求是否已指派并生成项目需求 */
|
||||||
export async function fetchGetRequirementTerminalStatusDict() {
|
export async function fetchHasDispatchedProjectRequirement(requirementId: string, productId: string) {
|
||||||
const result = await request<Api.Product.RequirementStatusDict[]>({
|
return request<boolean>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
url: `${REQUIREMENT_PREFIX}/status/dict/terminal`,
|
url: `${REQUIREMENT_PREFIX}/has-dispatched`,
|
||||||
method: 'get'
|
method: 'get',
|
||||||
|
params: { requirementId, productId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量判断产品需求是否已指派并生成项目需求 */
|
||||||
|
export async function fetchHasDispatchedProjectRequirementBatch(data: Api.Product.RequirementBatchReqVO) {
|
||||||
|
const result = await request<Api.Product.RequirementHasDispatchedBatchRespVO[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${REQUIREMENT_PREFIX}/has-dispatched/batch`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
|
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementHasDispatchedBatchRespVO[]>, data1 =>
|
||||||
|
data1.map(item => ({
|
||||||
|
requirementId: normalizeStringId(item.requirementId),
|
||||||
|
hasDispatched: Boolean(item.hasDispatched)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据当前产品需求id获取对应地,所流转到项目侧的项目需求id */
|
||||||
|
export async function fetchGetDispatchedProjectLink(productRequirementId: string) {
|
||||||
|
return request<{ projectRequirementId: string; projectId: string }>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${REQUIREMENT_PREFIX}/dispatched-project-link`,
|
||||||
|
method: 'get',
|
||||||
|
params: { productRequirementId }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 模块管理 API ==========
|
// ========== 模块管理 API ==========
|
||||||
@@ -466,6 +683,19 @@ export async function fetchCreateProductMember(id: string, data: Api.Product.Cre
|
|||||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchBatchCreateProductMembers(id: string, data: Api.Product.BatchCreateProductMembersParams) {
|
||||||
|
const result = await request<Array<string | number>>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PRODUCT_PREFIX}/${id}/members/batch`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<Array<string | number>>, list =>
|
||||||
|
Array.isArray(list) ? list.map(normalizeStringId) : []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchUpdateProductMember(id: string, memberId: string, data: Api.Product.UpdateProductMemberParams) {
|
export function fetchUpdateProductMember(id: string, memberId: string, data: Api.Product.UpdateProductMemberParams) {
|
||||||
return request<boolean>({
|
return request<boolean>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
@@ -475,6 +705,15 @@ export function fetchUpdateProductMember(id: string, memberId: string, data: Api
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchBatchInactiveProductMembers(id: string, data: Api.Product.BatchInactiveProductMembersParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PRODUCT_PREFIX}/${id}/members/batch/inactive`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchInactiveProductMember(
|
export function fetchInactiveProductMember(
|
||||||
id: string,
|
id: string,
|
||||||
memberId: string,
|
memberId: string,
|
||||||
|
|||||||
62
src/service/api/project-group.ts
Normal file
62
src/service/api/project-group.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { WEB_SERVICE_PREFIX } from '@/constants/service';
|
||||||
|
import { request } from '../request';
|
||||||
|
import {
|
||||||
|
type ServiceRequestResult,
|
||||||
|
mapServiceResult,
|
||||||
|
normalizeNullableStringId,
|
||||||
|
safeJsonRequestConfig
|
||||||
|
} from './shared';
|
||||||
|
import { type ProjectResponse, normalizeProject } from './project';
|
||||||
|
|
||||||
|
const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* group-page 原始响应。
|
||||||
|
* 组级 managerUserId、productId:后端对小数值 Long(如 1001)仍按数字返回,需 String() 归一;
|
||||||
|
* projects 字段与 page 接口项目行完全一致,复用 ProjectResponse / normalizeProject。
|
||||||
|
*/
|
||||||
|
type ProjectGroupResponse = Omit<Api.Project.ProjectGroup, 'productId' | 'managerUserId' | 'projects'> & {
|
||||||
|
productId?: string | number | null;
|
||||||
|
managerUserId?: string | number | null;
|
||||||
|
projects: ProjectResponse[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectGroupPageResponse = Omit<Api.Project.ProjectGroupPageResult, 'list'> & {
|
||||||
|
list: ProjectGroupResponse[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 归一化分组:组级 ID String 化,组内项目复用 normalizeProject(id/managerUserId/productId/日期统一口径) */
|
||||||
|
function normalizeProjectGroup(group: ProjectGroupResponse): Api.Project.ProjectGroup {
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
productId: normalizeNullableStringId(group.productId),
|
||||||
|
managerUserId: normalizeNullableStringId(group.managerUserId),
|
||||||
|
projects: Array.isArray(group.projects) ? group.projects.map(normalizeProject) : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 项目列表「按产品分组」分页。
|
||||||
|
*
|
||||||
|
* 后端契约见《项目列表产品分组-前端API-2026-06-10》:
|
||||||
|
* - pageNo/pageSize 为产品组维度分页;statusCode 不传 = 「全部」口径(后端从状态机推导,
|
||||||
|
* 当前等价 pending/active/paused/completed,不含 cancelled/archived)。
|
||||||
|
* - 组内 projects 仅返前 topN 条(默认 5),projectTotal 为该口径组内全量计数;
|
||||||
|
* 剩余项目由页面按 productId / orphanOnly + statusCodes 走 page 接口展开拉取。
|
||||||
|
* - typeCounts / hasBaseline 现状恒按「全部」口径统计,不随 statusCode 变化;其中 typeCounts 已提需求
|
||||||
|
* 改为与 projectTotal 同口径(见《2026-06-11-项目分组接口typeCounts口径-后端接口需求》),后端落地后更新本注释;
|
||||||
|
* hasBaseline = 存在非已取消的主线项目(已归档/完成也算占坑),前端直接消费、不自行推导。
|
||||||
|
*/
|
||||||
|
export async function fetchGetProjectGroupPage(params?: Api.Project.ProjectGroupSearchParams) {
|
||||||
|
const result = await request<ProjectGroupPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/group-page`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectGroupPageResponse>, data => ({
|
||||||
|
...data,
|
||||||
|
list: Array.isArray(data.list) ? data.list.map(normalizeProjectGroup) : []
|
||||||
|
}));
|
||||||
|
}
|
||||||
632
src/service/api/project-shared.ts
Normal file
632
src/service/api/project-shared.ts
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { normalizeNullableStringId, normalizeStringId } from './shared';
|
||||||
|
|
||||||
|
type ProjectStatusCode = Api.Project.ProjectStatusCode;
|
||||||
|
type ProjectStatusActionCode = Exclude<Api.Project.ProjectStatusActionCode, 'auto_start'>;
|
||||||
|
|
||||||
|
type StringIdResponse = string | number;
|
||||||
|
|
||||||
|
export type ProjectLocalDateValue = string | number[] | null;
|
||||||
|
|
||||||
|
export type LifecycleActionResponse<ActionCode extends string> = Partial<Api.Project.LifecycleAction<ActionCode>> & {
|
||||||
|
actionCode: ActionCode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectExecutionResponse = Omit<
|
||||||
|
Api.Project.ProjectExecution,
|
||||||
|
| 'id'
|
||||||
|
| 'projectId'
|
||||||
|
| 'projectRequirementId'
|
||||||
|
| 'ownerId'
|
||||||
|
| 'availableActions'
|
||||||
|
| 'plannedStartDate'
|
||||||
|
| 'plannedEndDate'
|
||||||
|
| 'actualStartDate'
|
||||||
|
| 'actualEndDate'
|
||||||
|
| 'progressRate'
|
||||||
|
| 'priority'
|
||||||
|
| 'priorityName'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
projectId: StringIdResponse;
|
||||||
|
projectRequirementId?: StringIdResponse | null;
|
||||||
|
ownerId: StringIdResponse;
|
||||||
|
availableActions?: LifecycleActionResponse<Api.Project.ProjectExecutionActionCode>[] | null;
|
||||||
|
plannedStartDate?: ProjectLocalDateValue;
|
||||||
|
plannedEndDate?: ProjectLocalDateValue;
|
||||||
|
actualStartDate?: ProjectLocalDateValue;
|
||||||
|
actualEndDate?: ProjectLocalDateValue;
|
||||||
|
progressRate?: number | null;
|
||||||
|
priority?: string | number | null;
|
||||||
|
priorityName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MyExecutionResponse = Omit<
|
||||||
|
Api.Project.MyExecutionItem,
|
||||||
|
| 'id'
|
||||||
|
| 'projectId'
|
||||||
|
| 'projectRequirementId'
|
||||||
|
| 'priority'
|
||||||
|
| 'progressRate'
|
||||||
|
| 'plannedStartDate'
|
||||||
|
| 'plannedEndDate'
|
||||||
|
| 'actualStartDate'
|
||||||
|
| 'actualEndDate'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
projectId: StringIdResponse;
|
||||||
|
projectRequirementId?: StringIdResponse | null;
|
||||||
|
priority?: string | number | null;
|
||||||
|
progressRate?: number | null;
|
||||||
|
plannedStartDate?: ProjectLocalDateValue;
|
||||||
|
plannedEndDate?: ProjectLocalDateValue;
|
||||||
|
actualStartDate?: ProjectLocalDateValue;
|
||||||
|
actualEndDate?: ProjectLocalDateValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MyParticipatedProjectResponse = Omit<Api.Project.MyParticipatedProjectItem, 'id'> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MyOwnedProjectMemberResponse = Omit<Api.Project.MyOwnedProjectMember, 'userId'> & {
|
||||||
|
userId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MyOwnedProjectResponse = Omit<Api.Project.MyOwnedProjectItem, 'id' | 'members'> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
members?: MyOwnedProjectMemberResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MyTaskResponse = Omit<
|
||||||
|
Api.Project.MyTaskItem,
|
||||||
|
| 'id'
|
||||||
|
| 'projectId'
|
||||||
|
| 'executionId'
|
||||||
|
| 'priority'
|
||||||
|
| 'plannedEndDate'
|
||||||
|
| 'progressRate'
|
||||||
|
| 'createTime'
|
||||||
|
| 'parentTaskId'
|
||||||
|
| 'availableActions'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
projectId: StringIdResponse;
|
||||||
|
executionId?: StringIdResponse | null;
|
||||||
|
priority?: string | number | null;
|
||||||
|
plannedEndDate?: ProjectLocalDateValue;
|
||||||
|
progressRate?: number | string | null;
|
||||||
|
createTime?: string | number | null;
|
||||||
|
parentTaskId?: StringIdResponse | null;
|
||||||
|
availableActions?: LifecycleActionResponse<Api.Project.ProjectTaskActionCode>[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamLoadDistributionItemResponse = Omit<Api.Project.TeamLoadDistributionItem, 'projectId'> & {
|
||||||
|
projectId?: StringIdResponse | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamLoadMemberResponse = Omit<Api.Project.TeamLoadMember, 'userId' | 'items'> & {
|
||||||
|
userId: StringIdResponse;
|
||||||
|
items?: TeamLoadDistributionItemResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamLoadResponse = {
|
||||||
|
members?: TeamLoadMemberResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorklogDistributionItemResponse = Omit<Api.Project.WorklogDistributionItem, 'projectId'> & {
|
||||||
|
projectId?: StringIdResponse | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MyWorklogWeekResponse = Omit<Api.Project.MyWorklogWeekResult, 'dailyHours' | 'distribution'> & {
|
||||||
|
dailyHours?: number[] | null;
|
||||||
|
distribution?: WorklogDistributionItemResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamWorklogWeekMemberResponse = Omit<Api.Project.TeamWorklogWeekMember, 'userId' | 'items'> & {
|
||||||
|
userId: StringIdResponse;
|
||||||
|
items?: WorklogDistributionItemResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamWorklogWeekResponse = Omit<Api.Project.TeamWorklogWeekResult, 'members'> & {
|
||||||
|
members?: TeamWorklogWeekMemberResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
executionId: StringIdResponse;
|
||||||
|
userId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecutionAssigneeLogResponse = Omit<
|
||||||
|
Api.Project.ExecutionAssigneeLog,
|
||||||
|
'id' | 'executionId' | 'userId' | 'operatorUserId'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
executionId: StringIdResponse;
|
||||||
|
userId: StringIdResponse;
|
||||||
|
operatorUserId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskAssigneeRefResponse = Omit<Api.Project.TaskAssigneeRef, 'id' | 'userId'> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
userId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端 attachments 项的兼容形态:历史/当前响应字段名是 `id`,前端类型统一用 `fileId`。
|
||||||
|
* normalizeAttachments 负责把两者归一成 `fileId`。
|
||||||
|
*/
|
||||||
|
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
|
||||||
|
fileId?: StringIdResponse;
|
||||||
|
id?: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeAttachments(list?: AttachmentItemResponse[] | null): Api.Project.AttachmentItem[] | null {
|
||||||
|
if (!list) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return list.map(item => {
|
||||||
|
const rawId = item.fileId ?? item.id;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
fileId: rawId === null || rawId === undefined ? '' : String(rawId)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 5.6 单独接口返的协办人字段(与 5.3 嵌入字段命名口径不一致:返 userNickname 而非 nickname)。
|
||||||
|
* 经 normalizeTaskAssignee 归一化后对外统一为 Api.Project.TaskAssigneeRef。
|
||||||
|
*/
|
||||||
|
export type TaskAssigneeFromApiResponse = {
|
||||||
|
id: StringIdResponse;
|
||||||
|
taskId: StringIdResponse;
|
||||||
|
userId: StringIdResponse;
|
||||||
|
userNickname?: string | null;
|
||||||
|
joinedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskAssigneeLogResponse = Omit<
|
||||||
|
Api.Project.TaskAssigneeLog,
|
||||||
|
'id' | 'taskId' | 'userId' | 'operatorUserId'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
taskId: StringIdResponse;
|
||||||
|
userId: StringIdResponse;
|
||||||
|
operatorUserId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectTaskResponse = Omit<
|
||||||
|
Api.Project.ProjectTask,
|
||||||
|
| 'id'
|
||||||
|
| 'projectId'
|
||||||
|
| 'executionId'
|
||||||
|
| 'parentTaskId'
|
||||||
|
| 'ownerId'
|
||||||
|
| 'executionOwnerId'
|
||||||
|
| 'parentTaskOwnerId'
|
||||||
|
| 'availableActions'
|
||||||
|
| 'plannedStartDate'
|
||||||
|
| 'plannedEndDate'
|
||||||
|
| 'actualStartDate'
|
||||||
|
| 'actualEndDate'
|
||||||
|
| 'progressRate'
|
||||||
|
| 'assignees'
|
||||||
|
| 'attachments'
|
||||||
|
| 'priority'
|
||||||
|
| 'priorityName'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
projectId: StringIdResponse;
|
||||||
|
executionId: StringIdResponse;
|
||||||
|
executionName?: string | null;
|
||||||
|
executionStatusCode?: Api.Project.ProjectExecutionStatusCode | null;
|
||||||
|
parentTaskId?: StringIdResponse | null;
|
||||||
|
ownerId: StringIdResponse;
|
||||||
|
executionOwnerId?: StringIdResponse | null;
|
||||||
|
parentTaskOwnerId?: StringIdResponse | null;
|
||||||
|
availableActions?: LifecycleActionResponse<Api.Project.ProjectTaskActionCode>[] | null;
|
||||||
|
plannedStartDate?: ProjectLocalDateValue;
|
||||||
|
plannedEndDate?: ProjectLocalDateValue;
|
||||||
|
actualStartDate?: ProjectLocalDateValue;
|
||||||
|
actualEndDate?: ProjectLocalDateValue;
|
||||||
|
progressRate?: number | null;
|
||||||
|
assignees?: TaskAssigneeRefResponse[] | null;
|
||||||
|
attachments?: AttachmentItemResponse[] | null;
|
||||||
|
totalSpentHours?: number | null;
|
||||||
|
priority?: string | number | null;
|
||||||
|
priorityName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TaskWorklogResponse = Omit<
|
||||||
|
Api.Project.TaskWorklog,
|
||||||
|
'id' | 'taskId' | 'userId' | 'difficulty' | 'attachments' | 'startDate' | 'endDate'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
taskId: StringIdResponse;
|
||||||
|
userId: StringIdResponse;
|
||||||
|
difficulty?: string | null;
|
||||||
|
attachments?: AttachmentItemResponse[] | null;
|
||||||
|
startDate?: ProjectLocalDateValue;
|
||||||
|
endDate?: ProjectLocalDateValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ProjectMemberResponse {
|
||||||
|
id: string | number;
|
||||||
|
userId: string | number;
|
||||||
|
userNickname: string;
|
||||||
|
roleId: string | number;
|
||||||
|
roleName: string;
|
||||||
|
roleCode: string;
|
||||||
|
managerFlag: boolean;
|
||||||
|
status: 0 | 1;
|
||||||
|
joinedTime: string;
|
||||||
|
leftTime?: string | null;
|
||||||
|
remark?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectLifecycleActionNameMap: Record<ProjectStatusActionCode, string> = {
|
||||||
|
pause: '暂停项目',
|
||||||
|
resume: '恢复项目',
|
||||||
|
complete: '完成项目',
|
||||||
|
cancel: '取消项目',
|
||||||
|
reopen: '重新开启',
|
||||||
|
archive: '归档项目'
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectLifecycleActionReasonRequiredMap: Record<ProjectStatusActionCode, boolean> = {
|
||||||
|
pause: true,
|
||||||
|
resume: false,
|
||||||
|
complete: true,
|
||||||
|
cancel: true,
|
||||||
|
reopen: true,
|
||||||
|
archive: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectLifecycleActionMap: Record<ProjectStatusCode, ProjectStatusActionCode[]> = {
|
||||||
|
pending: ['cancel'],
|
||||||
|
active: ['pause', 'complete', 'cancel'],
|
||||||
|
paused: ['resume', 'cancel'],
|
||||||
|
completed: ['reopen', 'archive'],
|
||||||
|
cancelled: [],
|
||||||
|
archived: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getProjectLifecycleActions(statusCode: ProjectStatusCode): Api.Project.ProjectLifecycleAction[] {
|
||||||
|
return projectLifecycleActionMap[statusCode].map(actionCode => ({
|
||||||
|
actionCode,
|
||||||
|
actionName: projectLifecycleActionNameMap[actionCode],
|
||||||
|
needReason: projectLifecycleActionReasonRequiredMap[actionCode]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProjectLocalDate(value: ProjectLocalDateValue | undefined) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const [year, month, day] = value;
|
||||||
|
|
||||||
|
if (!year || !month || !day) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [year, month, day].map(item => String(item).padStart(2, '0')).join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端 LocalDateTime 统一序列化为毫秒时间戳(也可能是数字字符串/格式化字符串),
|
||||||
|
* 归一为 'YYYY-MM-DD HH:mm:ss' 供展示与 dayjs 解析。
|
||||||
|
*/
|
||||||
|
export function normalizeProjectDateTime(value: string | number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: dayjs.Dayjs;
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
parsed = dayjs(value);
|
||||||
|
} else if (/^\d+$/.test(value)) {
|
||||||
|
// 字符串形态的毫秒时间戳:dayjs 无法直接解析,先转数值(时间值非 ID,安全整数范围内)
|
||||||
|
parsed = dayjs(Number(value));
|
||||||
|
} else {
|
||||||
|
parsed = dayjs(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm:ss') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLifecycleActions<ActionCode extends string>(
|
||||||
|
actions: LifecycleActionResponse<ActionCode>[] | null | undefined
|
||||||
|
): Api.Project.LifecycleAction<ActionCode>[] {
|
||||||
|
return (actions ?? []).map(action => ({
|
||||||
|
actionCode: action.actionCode,
|
||||||
|
actionName: action.actionName ?? '',
|
||||||
|
needReason: Boolean(action.needReason)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProjectMember(response: ProjectMemberResponse): Api.Project.ProjectMember {
|
||||||
|
return {
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
userId: normalizeStringId(response.userId),
|
||||||
|
userNickname: response.userNickname || '',
|
||||||
|
roleId: normalizeStringId(response.roleId),
|
||||||
|
roleName: response.roleName || '',
|
||||||
|
roleCode: response.roleCode || '',
|
||||||
|
managerFlag: Boolean(response.managerFlag),
|
||||||
|
status: response.status,
|
||||||
|
joinedTime: response.joinedTime,
|
||||||
|
leftTime: response.leftTime ?? null,
|
||||||
|
remark: response.remark ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePriority(value: string | number | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '1';
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProgressRate(value: number | string | null | undefined) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = typeof value === 'number' ? value : Number(value ?? 0);
|
||||||
|
return Number.isFinite(numeric) ? numeric : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
projectId: normalizeStringId(response.projectId),
|
||||||
|
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
|
||||||
|
projectRequirementName: response.projectRequirementName ?? null,
|
||||||
|
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
|
||||||
|
ownerId: normalizeStringId(response.ownerId),
|
||||||
|
ownerNickname: response.ownerNickname ?? null,
|
||||||
|
statusName: response.statusName ?? null,
|
||||||
|
terminal: Boolean(response.terminal),
|
||||||
|
allowEdit: Boolean(response.allowEdit),
|
||||||
|
availableActions: normalizeLifecycleActions(response.availableActions),
|
||||||
|
plannedStartDate: normalizeProjectLocalDate(response.plannedStartDate),
|
||||||
|
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
|
||||||
|
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
|
||||||
|
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
|
||||||
|
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
|
||||||
|
priority: normalizePriority(response.priority),
|
||||||
|
priorityName: response.priorityName ?? null,
|
||||||
|
executionDesc: response.executionDesc ?? null,
|
||||||
|
lastStatusReason: response.lastStatusReason ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMyExecution(response: MyExecutionResponse): Api.Project.MyExecutionItem {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
projectId: normalizeStringId(response.projectId),
|
||||||
|
statusName: response.statusName ?? null,
|
||||||
|
priority: normalizePriority(response.priority),
|
||||||
|
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
|
||||||
|
plannedStartDate: normalizeProjectLocalDate(response.plannedStartDate),
|
||||||
|
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
|
||||||
|
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
|
||||||
|
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
|
||||||
|
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
|
||||||
|
projectRequirementName: response.projectRequirementName ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMyParticipatedProject(
|
||||||
|
response: MyParticipatedProjectResponse
|
||||||
|
): Api.Project.MyParticipatedProjectItem {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
code: response.code ?? null,
|
||||||
|
statusName: response.statusName ?? null,
|
||||||
|
myRole: response.myRole ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMyOwnedProject(response: MyOwnedProjectResponse): Api.Project.MyOwnedProjectItem {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
code: response.code ?? null,
|
||||||
|
myRole: response.myRole ?? null,
|
||||||
|
plannedEndDate: response.plannedEndDate ?? null,
|
||||||
|
members: (response.members ?? []).map(member => ({
|
||||||
|
...member,
|
||||||
|
userId: normalizeStringId(member.userId),
|
||||||
|
userName: member.userName ?? null
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMyTask(response: MyTaskResponse): Api.Project.MyTaskItem {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
projectId: normalizeStringId(response.projectId),
|
||||||
|
executionId: normalizeNullableStringId(response.executionId),
|
||||||
|
executionName: response.executionName ?? null,
|
||||||
|
statusName: response.statusName ?? null,
|
||||||
|
priority: normalizePriority(response.priority),
|
||||||
|
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
|
||||||
|
progressRate: normalizeProgressRate(response.progressRate) ?? 0,
|
||||||
|
createTime: normalizeProjectDateTime(response.createTime),
|
||||||
|
parentTaskId: normalizeNullableStringId(response.parentTaskId),
|
||||||
|
terminal: Boolean(response.terminal),
|
||||||
|
allowEdit: Boolean(response.allowEdit),
|
||||||
|
availableActions: normalizeLifecycleActions(response.availableActions)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWorklogDistributionItem(
|
||||||
|
response: WorklogDistributionItemResponse | TeamLoadDistributionItemResponse
|
||||||
|
): { projectId: string | null; projectName: string | null; kind: 'project' | 'personal' | 'other' } {
|
||||||
|
return {
|
||||||
|
projectId: normalizeNullableStringId(response.projectId),
|
||||||
|
projectName: response.projectName ?? null,
|
||||||
|
kind: response.kind
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTeamLoad(response: TeamLoadResponse): Api.Project.TeamLoadResult {
|
||||||
|
return {
|
||||||
|
members: (response.members ?? []).map(member => ({
|
||||||
|
userId: normalizeStringId(member.userId),
|
||||||
|
userNickname: member.userNickname ?? '',
|
||||||
|
items: (member.items ?? []).map(item => ({
|
||||||
|
...normalizeWorklogDistributionItem(item),
|
||||||
|
count: typeof item.count === 'number' ? item.count : 0
|
||||||
|
})),
|
||||||
|
dueSoonCount: typeof member.dueSoonCount === 'number' ? member.dueSoonCount : 0,
|
||||||
|
overdueCount: typeof member.overdueCount === 'number' ? member.overdueCount : 0
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMyWorklogWeek(response: MyWorklogWeekResponse): Api.Project.MyWorklogWeekResult {
|
||||||
|
return {
|
||||||
|
weekStart: response.weekStart ?? '',
|
||||||
|
dailyHours: response.dailyHours ?? [0, 0, 0, 0, 0],
|
||||||
|
distribution: (response.distribution ?? []).map(item => ({
|
||||||
|
...normalizeWorklogDistributionItem(item),
|
||||||
|
hours: typeof item.hours === 'number' ? item.hours : 0
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTeamWorklogWeek(response: TeamWorklogWeekResponse): Api.Project.TeamWorklogWeekResult {
|
||||||
|
return {
|
||||||
|
weekStart: response.weekStart ?? '',
|
||||||
|
members: (response.members ?? []).map(member => ({
|
||||||
|
userId: normalizeStringId(member.userId),
|
||||||
|
userNickname: member.userNickname ?? '',
|
||||||
|
items: (member.items ?? []).map(item => ({
|
||||||
|
...normalizeWorklogDistributionItem(item),
|
||||||
|
hours: typeof item.hours === 'number' ? item.hours : 0
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
executionId: normalizeStringId(response.executionId),
|
||||||
|
userId: normalizeStringId(response.userId),
|
||||||
|
userNickname: response.userNickname ?? null,
|
||||||
|
joinedAt: response.joinedAt ?? null,
|
||||||
|
removedAt: response.removedAt ?? null,
|
||||||
|
removedReason: response.removedReason ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeExecutionAssigneeLog(
|
||||||
|
response: ExecutionAssigneeLogResponse
|
||||||
|
): Api.Project.ExecutionAssigneeLog {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
executionId: normalizeStringId(response.executionId),
|
||||||
|
userId: normalizeStringId(response.userId),
|
||||||
|
operatorUserId: normalizeStringId(response.operatorUserId),
|
||||||
|
userNicknameSnapshot: response.userNicknameSnapshot ?? null,
|
||||||
|
operatorNicknameSnapshot: response.operatorNicknameSnapshot ?? null,
|
||||||
|
reason: response.reason ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project.ProjectTask {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
projectId: normalizeStringId(response.projectId),
|
||||||
|
executionId: normalizeStringId(response.executionId),
|
||||||
|
executionName: response.executionName ?? null,
|
||||||
|
executionStatusCode: response.executionStatusCode ?? null,
|
||||||
|
parentTaskId: normalizeNullableStringId(response.parentTaskId),
|
||||||
|
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
|
||||||
|
projectRequirementName: response.projectRequirementName ?? null,
|
||||||
|
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
|
||||||
|
type: response.type ?? '',
|
||||||
|
ownerId: normalizeStringId(response.ownerId),
|
||||||
|
ownerNickname: response.ownerNickname ?? null,
|
||||||
|
executionOwnerId: normalizeNullableStringId(response.executionOwnerId),
|
||||||
|
parentTaskOwnerId: normalizeNullableStringId(response.parentTaskOwnerId),
|
||||||
|
statusName: response.statusName ?? null,
|
||||||
|
terminal: Boolean(response.terminal),
|
||||||
|
allowEdit: Boolean(response.allowEdit),
|
||||||
|
availableActions: normalizeLifecycleActions(response.availableActions),
|
||||||
|
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
|
||||||
|
plannedStartDate: normalizeProjectLocalDate(response.plannedStartDate),
|
||||||
|
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
|
||||||
|
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
|
||||||
|
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
|
||||||
|
priority: normalizePriority(response.priority),
|
||||||
|
priorityName: response.priorityName ?? null,
|
||||||
|
taskDesc: response.taskDesc ?? null,
|
||||||
|
lastStatusReason: response.lastStatusReason ?? null,
|
||||||
|
assignees:
|
||||||
|
response.assignees?.map(item => ({
|
||||||
|
id: normalizeStringId(item.id),
|
||||||
|
userId: normalizeStringId(item.userId),
|
||||||
|
nickname: item.nickname ?? ''
|
||||||
|
})) ?? null,
|
||||||
|
attachments: normalizeAttachments(response.attachments),
|
||||||
|
totalSpentHours: response.totalSpentHours ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTaskWorklog(response: TaskWorklogResponse): Api.Project.TaskWorklog {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
taskId: normalizeStringId(response.taskId),
|
||||||
|
userId: normalizeStringId(response.userId),
|
||||||
|
userNickname: response.userNickname ?? null,
|
||||||
|
workContent: response.workContent ?? null,
|
||||||
|
attachments: normalizeAttachments(response.attachments),
|
||||||
|
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
|
||||||
|
// 后端 LocalDate 默认序列化为 [year, month, day] 数组,必须归一为 'YYYY-MM-DD' 字符串供 ElDatePicker 使用
|
||||||
|
startDate: normalizeProjectLocalDate(response.startDate) ?? '',
|
||||||
|
endDate: normalizeProjectLocalDate(response.endDate) ?? '',
|
||||||
|
// 历史记录或异常缺失时兜底为字典默认档位 "2"
|
||||||
|
difficulty: response.difficulty ?? '2',
|
||||||
|
difficultyName: response.difficultyName ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTaskAssignee(response: TaskAssigneeFromApiResponse): Api.Project.TaskAssigneeRef {
|
||||||
|
return {
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
userId: normalizeStringId(response.userId),
|
||||||
|
nickname: response.userNickname ?? '',
|
||||||
|
joinedAt: response.joinedAt ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTaskAssigneeLog(response: TaskAssigneeLogResponse): Api.Project.TaskAssigneeLog {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
taskId: normalizeStringId(response.taskId),
|
||||||
|
userId: normalizeStringId(response.userId),
|
||||||
|
operatorUserId: normalizeStringId(response.operatorUserId),
|
||||||
|
userNicknameSnapshot: response.userNicknameSnapshot ?? null,
|
||||||
|
operatorNicknameSnapshot: response.operatorNicknameSnapshot ?? null,
|
||||||
|
reason: response.reason ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
1362
src/service/api/project.ts
Normal file
1362
src/service/api/project.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
|||||||
import type { RouteMeta } from 'vue-router';
|
|
||||||
import type { ElegantConstRoute, LastLevelRouteKey } from '@elegant-router/types';
|
import type { ElegantConstRoute, LastLevelRouteKey } from '@elegant-router/types';
|
||||||
import { objectContextDomainConfigs } from '@/constants/object-context';
|
import { objectContextDomainConfigs } from '@/constants/object-context';
|
||||||
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||||
@@ -95,31 +94,78 @@ function replaceWithStaticObjectContextDomainRoute(routes: Api.Route.MenuRoute[]
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wrappedDomainRoute = cloneStaticRouteAsMenuRoute(staticDomainRoute, `object-context:${config.domainKey}`);
|
// Create a map of backend routes by name for quick lookup
|
||||||
const entryRouteIndex = normalizedRoutes.findIndex(route => route.id === entryRoute.id);
|
const backendRouteMap = new Map<string, Api.Route.MenuRoute>();
|
||||||
const domainRouteIds = new Set(domainTopLevelRoutes.map(route => route.id));
|
domainTopLevelRoutes.forEach(route => {
|
||||||
|
if (route.name) {
|
||||||
|
backendRouteMap.set(String(route.name), route);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (entryRoute.meta) {
|
// Clone static route but preserve backend route's meta for children
|
||||||
const nextMeta: RouteMeta = {
|
// 待重构:拆 helper 以降低复杂度,暂以 disable 注释临时放行
|
||||||
title: wrappedDomainRoute.meta?.title || config.domainKey,
|
// eslint-disable-next-line complexity
|
||||||
...(wrappedDomainRoute.meta || {})
|
function cloneStaticRoutePreservingBackendMeta(route: ElegantConstRoute, idPrefix: string): Api.Route.MenuRoute {
|
||||||
|
const backendRoute = route.name ? backendRouteMap.get(String(route.name)) : undefined;
|
||||||
|
const { children: _children, ...routeWithoutChildren } = route;
|
||||||
|
const baseRoute: Api.Route.MenuRoute = {
|
||||||
|
...routeWithoutChildren,
|
||||||
|
id: `${idPrefix}:${String(route.name || route.path)}`
|
||||||
};
|
};
|
||||||
|
|
||||||
if (entryRoute.meta.icon) {
|
// If there's a backend route, preserve its meta
|
||||||
nextMeta.icon = entryRoute.meta.icon;
|
if (backendRoute?.meta) {
|
||||||
|
baseRoute.meta = {
|
||||||
|
...baseRoute.meta,
|
||||||
|
title: backendRoute.meta.title || baseRoute.meta?.title || String(route.name || route.path),
|
||||||
|
icon: backendRoute.meta.icon || baseRoute.meta?.icon,
|
||||||
|
localIcon: backendRoute.meta.localIcon || baseRoute.meta?.localIcon,
|
||||||
|
order:
|
||||||
|
backendRoute.meta.order !== undefined && backendRoute.meta.order !== null
|
||||||
|
? backendRoute.meta.order
|
||||||
|
: baseRoute.meta?.order,
|
||||||
|
keepAlive:
|
||||||
|
backendRoute.meta.keepAlive !== undefined && backendRoute.meta.keepAlive !== null
|
||||||
|
? backendRoute.meta.keepAlive
|
||||||
|
: baseRoute.meta?.keepAlive,
|
||||||
|
i18nKey: backendRoute.meta.i18nKey || baseRoute.meta?.i18nKey
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entryRoute.meta.localIcon) {
|
// Recursively process children
|
||||||
nextMeta.localIcon = entryRoute.meta.localIcon;
|
if (route.children?.length) {
|
||||||
|
baseRoute.children = route.children.map(child => cloneStaticRoutePreservingBackendMeta(child, idPrefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entryRoute.meta.order !== undefined) {
|
return baseRoute;
|
||||||
nextMeta.order = entryRoute.meta.order;
|
|
||||||
}
|
|
||||||
|
|
||||||
wrappedDomainRoute.meta = nextMeta;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wrappedDomainRoute = cloneStaticRoutePreservingBackendMeta(
|
||||||
|
staticDomainRoute,
|
||||||
|
`object-context:${config.domainKey}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Merge entry route's meta to domain route
|
||||||
|
if (entryRoute.meta) {
|
||||||
|
wrappedDomainRoute.meta = {
|
||||||
|
...wrappedDomainRoute.meta,
|
||||||
|
title: entryRoute.meta.title || wrappedDomainRoute.meta?.title || config.domainKey,
|
||||||
|
icon: entryRoute.meta.icon || wrappedDomainRoute.meta?.icon,
|
||||||
|
localIcon: entryRoute.meta.localIcon || wrappedDomainRoute.meta?.localIcon,
|
||||||
|
order:
|
||||||
|
entryRoute.meta.order !== undefined && entryRoute.meta.order !== null
|
||||||
|
? entryRoute.meta.order
|
||||||
|
: wrappedDomainRoute.meta?.order,
|
||||||
|
keepAlive:
|
||||||
|
entryRoute.meta.keepAlive !== undefined && entryRoute.meta.keepAlive !== null
|
||||||
|
? entryRoute.meta.keepAlive
|
||||||
|
: wrappedDomainRoute.meta?.keepAlive
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryRouteIndex = normalizedRoutes.findIndex(route => route.id === entryRoute.id);
|
||||||
|
const domainRouteIds = new Set(domainTopLevelRoutes.map(route => route.id));
|
||||||
|
|
||||||
normalizedRoutes = normalizedRoutes.filter(route => !domainRouteIds.has(route.id));
|
normalizedRoutes = normalizedRoutes.filter(route => !domainRouteIds.has(route.id));
|
||||||
normalizedRoutes.splice(entryRouteIndex < 0 ? normalizedRoutes.length : entryRouteIndex, 0, wrappedDomainRoute);
|
normalizedRoutes.splice(entryRouteIndex < 0 ? normalizedRoutes.length : entryRouteIndex, 0, wrappedDomainRoute);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ function createBatchDeleteQuery(ids: Array<string | number>) {
|
|||||||
|
|
||||||
type UserSimpleResponse = Omit<Api.SystemManage.UserSimple, 'id'> & {
|
type UserSimpleResponse = Omit<Api.SystemManage.UserSimple, 'id'> & {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
|
deptId?: string | number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type RoleResponse = Omit<Api.SystemManage.Role, 'id'> & {
|
type RoleResponse = Omit<Api.SystemManage.Role, 'id'> & {
|
||||||
@@ -117,10 +118,16 @@ type UserManagementRelationTreeResponse = Omit<
|
|||||||
children?: UserManagementRelationTreeResponse[] | null;
|
children?: UserManagementRelationTreeResponse[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MySubordinateTreeNodeResponse = Omit<Api.SystemManage.MySubordinateTreeNode, 'userId' | 'children'> & {
|
||||||
|
userId: string | number;
|
||||||
|
children?: MySubordinateTreeNodeResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeUserSimple(user: UserSimpleResponse): Api.SystemManage.UserSimple {
|
function normalizeUserSimple(user: UserSimpleResponse): Api.SystemManage.UserSimple {
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
id: normalizeStringId(user.id)
|
id: normalizeStringId(user.id),
|
||||||
|
deptId: normalizeNullableStringId(user.deptId)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +186,14 @@ function normalizeUserManagementRelationTree(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMySubordinateTreeNode(node: MySubordinateTreeNodeResponse): Api.SystemManage.MySubordinateTreeNode {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
userId: normalizeStringId(node.userId),
|
||||||
|
children: node.children?.map(normalizeMySubordinateTreeNode) ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取角色分页 */
|
/** 获取角色分页 */
|
||||||
export async function fetchGetRolePage(params?: Api.SystemManage.RoleSearchParams) {
|
export async function fetchGetRolePage(params?: Api.SystemManage.RoleSearchParams) {
|
||||||
const query = createRolePageQuery(params);
|
const query = createRolePageQuery(params);
|
||||||
@@ -443,7 +458,7 @@ export function fetchBatchDeletePost(ids: number[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 获取用户简单列表(用于用户选择下拉框) */
|
/** 获取用户简单列表(用于用户选择下拉框) */
|
||||||
export function fetchGetUserSimpleList() {
|
export async function fetchGetUserSimpleList() {
|
||||||
return request<UserSimpleResponse[]>({
|
return request<UserSimpleResponse[]>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
url: `${USER_PREFIX}/simple-list`,
|
url: `${USER_PREFIX}/simple-list`,
|
||||||
@@ -453,6 +468,19 @@ export function fetchGetUserSimpleList() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取当前登录人的直属上级 */
|
||||||
|
export async function fetchGetLoginUserDirectManager() {
|
||||||
|
return request<UserSimpleResponse | null>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${USER_PREFIX}/profile/direct-manager`,
|
||||||
|
method: 'get'
|
||||||
|
}).then(result =>
|
||||||
|
mapServiceResult(result as ServiceRequestResult<UserSimpleResponse | null>, data =>
|
||||||
|
data ? normalizeUserSimple(data) : null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取用户分页 */
|
/** 获取用户分页 */
|
||||||
export function fetchGetUserPage(params?: Api.SystemManage.UserSearchParams) {
|
export function fetchGetUserPage(params?: Api.SystemManage.UserSearchParams) {
|
||||||
return request<Api.SystemManage.UserList>({
|
return request<Api.SystemManage.UserList>({
|
||||||
@@ -667,7 +695,7 @@ export function fetchAssignUserRoles(data: Api.SystemManage.AssignUserRoleParams
|
|||||||
* - 中间节点:有上级也有下级
|
* - 中间节点:有上级也有下级
|
||||||
* - 叶子节点:基层员工,没有下级
|
* - 叶子节点:基层员工,没有下级
|
||||||
*/
|
*/
|
||||||
export function fetchGetUserManagementRelationTree(query: UserManagementRelationQueryReqVO) {
|
export async function fetchGetUserManagementRelationTree(query: UserManagementRelationQueryReqVO) {
|
||||||
return request<UserManagementRelationTreeResponse[]>({
|
return request<UserManagementRelationTreeResponse[]>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/tree`,
|
url: `${USER_MANAGEMENT_RELATION_PREFIX}/tree`,
|
||||||
@@ -684,7 +712,7 @@ export function fetchGetUserManagementRelationTree(query: UserManagementRelation
|
|||||||
* 通过搜索框的查询条件,获取用户管理链路树形结构
|
* 通过搜索框的查询条件,获取用户管理链路树形结构
|
||||||
* 用于树形控件展示,包含用户的上下级层级关系
|
* 用于树形控件展示,包含用户的上下级层级关系
|
||||||
*/
|
*/
|
||||||
export function fetchGetUserManagementRelationQuery(query: UserManagementRelationQueryReqVO) {
|
export async function fetchGetUserManagementRelationQuery(query: UserManagementRelationQueryReqVO) {
|
||||||
return request<UserManagementRelationTreeResponse[]>({
|
return request<UserManagementRelationTreeResponse[]>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/query`,
|
url: `${USER_MANAGEMENT_RELATION_PREFIX}/query`,
|
||||||
@@ -697,6 +725,17 @@ export function fetchGetUserManagementRelationQuery(query: UserManagementRelatio
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取当前登录用户下属树 */
|
||||||
|
export async function fetchGetMySubordinateTree() {
|
||||||
|
return request<MySubordinateTreeNodeResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${USER_MANAGEMENT_RELATION_PREFIX}/my-subordinate-tree`,
|
||||||
|
method: 'get'
|
||||||
|
}).then(result =>
|
||||||
|
mapServiceResult(result as ServiceRequestResult<MySubordinateTreeNodeResponse>, normalizeMySubordinateTreeNode)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户管理链路详情
|
* 获取用户管理链路详情
|
||||||
*
|
*
|
||||||
@@ -704,7 +743,7 @@ export function fetchGetUserManagementRelationQuery(query: UserManagementRelatio
|
|||||||
*
|
*
|
||||||
* @param id 关系记录主键 ID
|
* @param id 关系记录主键 ID
|
||||||
*/
|
*/
|
||||||
export function fetchGetUserManagementRelation(id: string) {
|
export async function fetchGetUserManagementRelation(id: string) {
|
||||||
return request<UserManagementRelationResponse>({
|
return request<UserManagementRelationResponse>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/get`,
|
url: `${USER_MANAGEMENT_RELATION_PREFIX}/get`,
|
||||||
@@ -722,7 +761,7 @@ export function fetchGetUserManagementRelation(id: string) {
|
|||||||
*
|
*
|
||||||
* @param data 创建请求参数
|
* @param data 创建请求参数
|
||||||
*/
|
*/
|
||||||
export function fetchCreateUserManagementRelation(data: Api.SystemManage.UserManagementRelationSaveReqVO) {
|
export async function fetchCreateUserManagementRelation(data: Api.SystemManage.UserManagementRelationSaveReqVO) {
|
||||||
return request<string | number>({
|
return request<string | number>({
|
||||||
...safeJsonRequestConfig,
|
...safeJsonRequestConfig,
|
||||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/create`,
|
url: `${USER_MANAGEMENT_RELATION_PREFIX}/create`,
|
||||||
@@ -776,3 +815,20 @@ export function fetchBatchDeleteUserManagementRelation(ids: string[]) {
|
|||||||
method: 'delete'
|
method: 'delete'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取未绑定直属上级的候选下级用户列表
|
||||||
|
*
|
||||||
|
* 用于获取尚未绑定直属上级的用户列表,供选择使用
|
||||||
|
*
|
||||||
|
* @returns 候选下级用户列表
|
||||||
|
*/
|
||||||
|
export async function fetchGetCandidateSubordinateUsers() {
|
||||||
|
return request<UserSimpleResponse[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${USER_MANAGEMENT_RELATION_PREFIX}/candidate-users`,
|
||||||
|
method: 'get'
|
||||||
|
}).then(result =>
|
||||||
|
mapServiceResult(result as ServiceRequestResult<UserSimpleResponse[]>, data => data.map(normalizeUserSimple))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
980
src/service/api/work-report.ts
Normal file
980
src/service/api/work-report.ts
Normal file
@@ -0,0 +1,980 @@
|
|||||||
|
import { WEB_SERVICE_PREFIX } from '@/constants/service';
|
||||||
|
import { request } from '../request';
|
||||||
|
import {
|
||||||
|
type ServiceRequestResult,
|
||||||
|
mapServiceResult,
|
||||||
|
normalizeNullableStringId,
|
||||||
|
normalizeStringId,
|
||||||
|
safeJsonRequestConfig
|
||||||
|
} from './shared';
|
||||||
|
|
||||||
|
const WORK_REPORT_PREFIX = `${WEB_SERVICE_PREFIX}/project/work-reports`;
|
||||||
|
const WEEKLY_PREFIX = `${WORK_REPORT_PREFIX}/weekly`;
|
||||||
|
const MONTHLY_PREFIX = `${WORK_REPORT_PREFIX}/monthly`;
|
||||||
|
const PROJECT_PREFIX = `${WORK_REPORT_PREFIX}/project`;
|
||||||
|
|
||||||
|
type StringIdResponse = string | number;
|
||||||
|
type MaybeStringIdResponse = string | number | null | undefined;
|
||||||
|
|
||||||
|
type PageResponse<T> = {
|
||||||
|
total: number | string;
|
||||||
|
list: T[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReviewItemResponse = Omit<Api.WorkReport.Common.PersonalReportReviewItem, 'id'> & {
|
||||||
|
id?: MaybeStringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlanItemResponse = Omit<Api.WorkReport.Common.PersonalReportPlanItem, 'id'> & {
|
||||||
|
id?: MaybeStringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WeeklyTravelSegmentResponse = Omit<Api.WorkReport.Weekly.WeeklyReportTravelSegment, 'id'> & {
|
||||||
|
id?: MaybeStringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WeeklyReportResponse = Omit<
|
||||||
|
Api.WorkReport.Weekly.WeeklyReport,
|
||||||
|
'id' | 'reporterId' | 'supervisorUserId' | 'reviewItems' | 'planItems' | 'travelSegments'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
reporterId: StringIdResponse;
|
||||||
|
supervisorUserId: StringIdResponse;
|
||||||
|
reviewItems?: ReviewItemResponse[] | null;
|
||||||
|
planItems?: PlanItemResponse[] | null;
|
||||||
|
travelSegments?: WeeklyTravelSegmentResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MonthlyReportResponse = Omit<
|
||||||
|
Api.WorkReport.Monthly.MonthlyReport,
|
||||||
|
'id' | 'reporterId' | 'supervisorUserId' | 'reviewItems' | 'planItems'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
reporterId: StringIdResponse;
|
||||||
|
supervisorUserId: StringIdResponse;
|
||||||
|
reviewItems?: ReviewItemResponse[] | null;
|
||||||
|
planItems?: PlanItemResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MemberSnapshotResponse = Omit<Api.WorkReport.Project.WorkReportMemberSnapshot, 'userId'> & {
|
||||||
|
userId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectReportItemResponse = Omit<Api.WorkReport.Project.ProjectReportItem, 'id'> & {
|
||||||
|
id?: MaybeStringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectReportResponse = Omit<
|
||||||
|
Api.WorkReport.Project.ProjectReport,
|
||||||
|
'id' | 'projectId' | 'projectOwnerId' | 'projectMemberSnapshot' | 'supervisorUserId' | 'currentItems' | 'nextItems'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
projectId: StringIdResponse;
|
||||||
|
projectOwnerId: StringIdResponse;
|
||||||
|
projectMemberSnapshot?: MemberSnapshotResponse[] | null;
|
||||||
|
supervisorUserId: StringIdResponse;
|
||||||
|
currentItems?: ProjectReportItemResponse[] | null;
|
||||||
|
nextItems?: ProjectReportItemResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApprovalRecordResponse = Omit<
|
||||||
|
Api.WorkReport.Common.WorkReportApprovalRecord,
|
||||||
|
'id' | 'statusLogId' | 'auditorUserId'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
statusLogId: StringIdResponse;
|
||||||
|
auditorUserId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MonthlyApprovalRecordResponse = Omit<
|
||||||
|
Api.WorkReport.Monthly.MonthlyReportApprovalRecord,
|
||||||
|
'id' | 'statusLogId' | 'auditorUserId'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
statusLogId: StringIdResponse;
|
||||||
|
auditorUserId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectOptionResponse = Omit<Api.WorkReport.Project.ProjectReportOwnerProjectOption, 'id'> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TeamReportPendingUserResponse = Omit<Api.WorkReport.Common.TeamReportPendingUser, 'userId'> & {
|
||||||
|
userId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TeamReportSummaryResponse = Omit<Api.WorkReport.Common.TeamReportSummary, 'unsubmittedUsers'> & {
|
||||||
|
unsubmittedUsers?: TeamReportPendingUserResponse[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (typeof value === 'number') return value === 1;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return !['', '0', 'false', 'n', 'no'].includes(normalized);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApprovalConclusion(value: unknown) {
|
||||||
|
const conclusion = String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
if (conclusion === 'approve') return 'approved';
|
||||||
|
if (conclusion === 'reject') return 'rejected';
|
||||||
|
|
||||||
|
return conclusion;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDateText(value: unknown) {
|
||||||
|
if (value === null || value === undefined) return undefined;
|
||||||
|
const text = String(value).trim();
|
||||||
|
const commaDateMatch = text.match(/^(\d{4}),(\d{1,2}),(\d{1,2})$/);
|
||||||
|
|
||||||
|
if (commaDateMatch) {
|
||||||
|
const [, year, month, day] = commaDateMatch;
|
||||||
|
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTotal(total: number | string) {
|
||||||
|
const value = Number(total);
|
||||||
|
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumWorkHours(items: Array<{ workHours?: number | string | null }> = []) {
|
||||||
|
return items.reduce((sum, item) => {
|
||||||
|
const value = Number(item.workHours ?? 0);
|
||||||
|
return Number.isFinite(value) ? sum + value : sum;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReportTotalWorkHours(
|
||||||
|
totalWorkHours: number | string | null | undefined,
|
||||||
|
fallbackTotalWorkHours: number
|
||||||
|
) {
|
||||||
|
const normalizedTotal = Number(totalWorkHours ?? 0);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(totalWorkHours === null ||
|
||||||
|
totalWorkHours === undefined ||
|
||||||
|
totalWorkHours === '' ||
|
||||||
|
(Number.isFinite(normalizedTotal) && normalizedTotal === 0)) &&
|
||||||
|
fallbackTotalWorkHours > 0
|
||||||
|
) {
|
||||||
|
return fallbackTotalWorkHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalWorkHours ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendValue(query: URLSearchParams, key: string, value: unknown) {
|
||||||
|
if (value === null || value === undefined || value === '') return;
|
||||||
|
query.append(key, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendArray(query: URLSearchParams, key: string, values?: Array<string | null | undefined> | null) {
|
||||||
|
values?.forEach(value => appendValue(query, key, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendNullableArrayFlag(
|
||||||
|
query: URLSearchParams,
|
||||||
|
key: string,
|
||||||
|
values?: Array<string | null | undefined> | null
|
||||||
|
) {
|
||||||
|
if (values === null || values === undefined) return;
|
||||||
|
|
||||||
|
if (!values.length) {
|
||||||
|
query.append(key, '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendArray(query, key, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBasePageQuery(params: Api.WorkReport.Common.WorkReportBaseSearchParams = {}) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
|
||||||
|
appendValue(query, 'pageNo', params.pageNo ?? 1);
|
||||||
|
appendValue(query, 'pageSize', params.pageSize ?? 10);
|
||||||
|
appendValue(query, 'keyword', params.keyword);
|
||||||
|
appendValue(query, 'statusCode', params.statusCode);
|
||||||
|
appendValue(query, 'supervisorName', params.supervisorName);
|
||||||
|
appendArray(query, 'periodStartDate', params.periodStartDate);
|
||||||
|
appendArray(query, 'submitTime', params.submitTime);
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWeeklyPageQuery(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
|
||||||
|
const query = createBasePageQuery(params);
|
||||||
|
appendNullableArrayFlag(query, 'reporterIds', params.reporterIds);
|
||||||
|
appendValue(query, 'isBusinessTrip', params.isBusinessTrip);
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMonthlyPageQuery(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
|
||||||
|
const query = createBasePageQuery(params);
|
||||||
|
appendNullableArrayFlag(query, 'reporterIds', params.reporterIds);
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProjectPageQuery(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
|
||||||
|
const query = createBasePageQuery(params);
|
||||||
|
appendNullableArrayFlag(query, 'projectOwnerIds', params.projectOwnerIds);
|
||||||
|
appendValue(query, 'projectId', params.projectId);
|
||||||
|
appendValue(query, 'flag', params.flag);
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReviewItem(item: ReviewItemResponse): Api.WorkReport.Common.PersonalReportReviewItem {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
id: normalizeNullableStringId(item.id) ?? undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlanItem(item: PlanItemResponse): Api.WorkReport.Common.PersonalReportPlanItem {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
id: normalizeNullableStringId(item.id) ?? undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWeeklyTravelSegment(
|
||||||
|
item: WeeklyTravelSegmentResponse
|
||||||
|
): Api.WorkReport.Weekly.WeeklyReportTravelSegment {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
id: normalizeNullableStringId(item.id) ?? undefined,
|
||||||
|
startDate: normalizeDateText(item.startDate),
|
||||||
|
endDate: normalizeDateText(item.endDate)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWeeklyReport(response: WeeklyReportResponse): Api.WorkReport.Weekly.WeeklyReport {
|
||||||
|
const fallbackTotalWorkHours = sumWorkHours(response.reviewItems ?? []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
reporterId: normalizeStringId(response.reporterId),
|
||||||
|
supervisorUserId: normalizeStringId(response.supervisorUserId),
|
||||||
|
reporterDeptName: response.reporterDeptName ?? null,
|
||||||
|
reporterPostName: response.reporterPostName ?? null,
|
||||||
|
statusName: response.statusName || response.statusCode,
|
||||||
|
allowEdit: normalizeBooleanFlag(response.allowEdit),
|
||||||
|
terminal: normalizeBooleanFlag(response.terminal),
|
||||||
|
isBusinessTrip: normalizeBooleanFlag(response.isBusinessTrip),
|
||||||
|
totalWorkHours: normalizeReportTotalWorkHours(response.totalWorkHours, fallbackTotalWorkHours),
|
||||||
|
submitTime: response.submitTime ?? null,
|
||||||
|
reviewItems: response.reviewItems?.map(normalizeReviewItem) ?? [],
|
||||||
|
planItems: response.planItems?.map(normalizePlanItem) ?? [],
|
||||||
|
travelSegments: response.travelSegments?.map(normalizeWeeklyTravelSegment) ?? []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMonthlyReport(response: MonthlyReportResponse): Api.WorkReport.Monthly.MonthlyReport {
|
||||||
|
const fallbackTotalWorkHours = sumWorkHours(response.reviewItems ?? []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
reporterId: normalizeStringId(response.reporterId),
|
||||||
|
supervisorUserId: normalizeStringId(response.supervisorUserId),
|
||||||
|
reporterDeptName: response.reporterDeptName ?? null,
|
||||||
|
reporterPostName: response.reporterPostName ?? null,
|
||||||
|
statusName: response.statusName || response.statusCode,
|
||||||
|
allowEdit: normalizeBooleanFlag(response.allowEdit),
|
||||||
|
terminal: normalizeBooleanFlag(response.terminal),
|
||||||
|
totalWorkHours: normalizeReportTotalWorkHours(response.totalWorkHours, fallbackTotalWorkHours),
|
||||||
|
submitTime: response.submitTime ?? null,
|
||||||
|
reviewItems: response.reviewItems?.map(normalizeReviewItem) ?? [],
|
||||||
|
planItems: response.planItems?.map(normalizePlanItem) ?? []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMemberSnapshot(item: MemberSnapshotResponse): Api.WorkReport.Project.WorkReportMemberSnapshot {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
userId: normalizeStringId(item.userId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProjectReportItem(item: ProjectReportItemResponse): Api.WorkReport.Project.ProjectReportItem {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
id: normalizeNullableStringId(item.id) ?? undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProjectReport(response: ProjectReportResponse): Api.WorkReport.Project.ProjectReport {
|
||||||
|
const fallbackTotalWorkHours = sumWorkHours(response.currentItems ?? []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
projectId: normalizeStringId(response.projectId),
|
||||||
|
projectOwnerId: normalizeStringId(response.projectOwnerId),
|
||||||
|
projectMemberSnapshot: response.projectMemberSnapshot?.map(normalizeMemberSnapshot) ?? [],
|
||||||
|
supervisorUserId: normalizeStringId(response.supervisorUserId),
|
||||||
|
statusName: response.statusName || response.statusCode,
|
||||||
|
allowEdit: normalizeBooleanFlag(response.allowEdit),
|
||||||
|
terminal: normalizeBooleanFlag(response.terminal),
|
||||||
|
totalWorkHours: normalizeReportTotalWorkHours(response.totalWorkHours, fallbackTotalWorkHours),
|
||||||
|
submitTime: response.submitTime ?? null,
|
||||||
|
currentItems: response.currentItems?.map(normalizeProjectReportItem) ?? [],
|
||||||
|
nextItems: response.nextItems?.map(normalizeProjectReportItem) ?? []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApprovalRecord(response: ApprovalRecordResponse): Api.WorkReport.Common.WorkReportApprovalRecord {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
statusLogId: normalizeStringId(response.statusLogId),
|
||||||
|
auditorUserId: normalizeStringId(response.auditorUserId),
|
||||||
|
conclusion: normalizeApprovalConclusion(response.conclusion),
|
||||||
|
opinion: response.opinion ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMonthlyApprovalRecord(
|
||||||
|
response: MonthlyApprovalRecordResponse
|
||||||
|
): Api.WorkReport.Monthly.MonthlyReportApprovalRecord {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
statusLogId: normalizeStringId(response.statusLogId),
|
||||||
|
auditorUserId: normalizeStringId(response.auditorUserId),
|
||||||
|
conclusion: normalizeApprovalConclusion(response.conclusion),
|
||||||
|
opinion: response.opinion ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProjectOption(
|
||||||
|
response: ProjectOptionResponse
|
||||||
|
): Api.WorkReport.Project.ProjectReportOwnerProjectOption {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTeamReportSummary(response: TeamReportSummaryResponse): Api.WorkReport.Common.TeamReportSummary {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
unsubmittedUsers:
|
||||||
|
response.unsubmittedUsers?.map(item => ({
|
||||||
|
...item,
|
||||||
|
userId: normalizeStringId(item.userId)
|
||||||
|
})) ?? []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPage<TInput, TOutput>(data: PageResponse<TInput>, mapper: (item: TInput) => TOutput) {
|
||||||
|
return {
|
||||||
|
total: normalizeTotal(data.total),
|
||||||
|
list: data.list.map(mapper)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStatusActionRequest(data: Api.WorkReport.Common.StatusActionParams = {}) {
|
||||||
|
return {
|
||||||
|
reason: data.reason?.trim() || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPersonalReviewItems(items: Api.WorkReport.Common.PersonalReportReviewItem[] = []) {
|
||||||
|
return items.map((item, index) => ({
|
||||||
|
itemNumber: item.itemNumber ?? index + 1,
|
||||||
|
itemTitle: item.itemTitle?.trim() || '',
|
||||||
|
workHours: item.workHours ?? 0,
|
||||||
|
contentText: item.contentText?.trim() || '',
|
||||||
|
contentJson: item.contentJson ?? null,
|
||||||
|
reflectionText: item.reflectionText?.trim() || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPersonalPlanItems(items: Api.WorkReport.Common.PersonalReportPlanItem[] = []) {
|
||||||
|
return items.map((item, index) => ({
|
||||||
|
itemNumber: item.itemNumber ?? index + 1,
|
||||||
|
itemTitle: item.itemTitle?.trim() || '',
|
||||||
|
targetText: item.targetText?.trim() || '',
|
||||||
|
targetJson: item.targetJson ?? null,
|
||||||
|
supportNeed: item.supportNeed?.trim() || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWeeklySaveRequest(data: Api.WorkReport.Weekly.WeeklyReportSaveParams) {
|
||||||
|
return {
|
||||||
|
periodKey: data.periodKey,
|
||||||
|
periodLabel: data.periodLabel,
|
||||||
|
periodStartDate: data.periodStartDate,
|
||||||
|
periodEndDate: data.periodEndDate,
|
||||||
|
isBusinessTrip: data.isBusinessTrip,
|
||||||
|
reviewItems: toPersonalReviewItems(data.reviewItems),
|
||||||
|
planItems: toPersonalPlanItems(data.planItems),
|
||||||
|
travelSegments: data.isBusinessTrip
|
||||||
|
? data.travelSegments.map((item, index) => ({
|
||||||
|
sort: item.sort ?? index + 1,
|
||||||
|
startDate: item.startDate || undefined,
|
||||||
|
endDate: item.endDate || undefined,
|
||||||
|
travelDays: item.travelDays ?? 0,
|
||||||
|
location: item.location?.trim() || ''
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMonthlySaveRequest(data: Api.WorkReport.Monthly.MonthlyReportSaveParams) {
|
||||||
|
return {
|
||||||
|
periodKey: data.periodKey,
|
||||||
|
periodLabel: data.periodLabel,
|
||||||
|
periodStartDate: data.periodStartDate,
|
||||||
|
periodEndDate: data.periodEndDate,
|
||||||
|
reviewItems: toPersonalReviewItems(data.reviewItems),
|
||||||
|
planItems: toPersonalPlanItems(data.planItems)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProjectItems(items: Api.WorkReport.Project.ProjectReportItem[] = []) {
|
||||||
|
return items.map(item => ({
|
||||||
|
itemTitle: item.itemTitle?.trim() || '',
|
||||||
|
workHours: item.workHours ?? 0,
|
||||||
|
priorityCode: item.priorityCode || undefined,
|
||||||
|
progressRate: item.progressRate ?? 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProjectSaveRequest(data: Api.WorkReport.Project.ProjectReportSaveParams) {
|
||||||
|
return {
|
||||||
|
projectId: data.projectId,
|
||||||
|
periodKey: data.periodKey,
|
||||||
|
periodLabel: data.periodLabel,
|
||||||
|
periodStartDate: data.periodStartDate,
|
||||||
|
periodEndDate: data.periodEndDate,
|
||||||
|
flag: data.flag,
|
||||||
|
projectStatusDesc: data.projectStatusDesc?.trim() || '',
|
||||||
|
projectProgressPlan: data.projectProgressPlan?.trim() || '',
|
||||||
|
projectKeyPoints: data.projectKeyPoints?.trim() || '',
|
||||||
|
projectProblems: data.projectProblems?.trim() || '',
|
||||||
|
currentItems: toProjectItems(data.currentItems),
|
||||||
|
nextItems: toProjectItems(data.nextItems)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetWorkReportStatusDict() {
|
||||||
|
const result = await request<Api.WorkReport.Common.WorkReportStatusDict[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WORK_REPORT_PREFIX}/status/dict`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<Api.WorkReport.Common.WorkReportStatusDict[]>, data => data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetTeamReportSummary(params: Api.WorkReport.Common.TeamReportSummaryParams) {
|
||||||
|
const result = await request<TeamReportSummaryResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WORK_REPORT_PREFIX}/team/summary`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<TeamReportSummaryResponse>, normalizeTeamReportSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRemindTeamReport(data: Api.WorkReport.Common.TeamReportRemindParams) {
|
||||||
|
const result = await request<Api.WorkReport.Common.TeamReportRemindResult>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WORK_REPORT_PREFIX}/team/remind`,
|
||||||
|
method: 'post',
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
userIds: data.userIds && data.userIds.length ? data.userIds : undefined
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(
|
||||||
|
result as ServiceRequestResult<Api.WorkReport.Common.TeamReportRemindResult>,
|
||||||
|
payload => payload
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetWeeklyReportPage(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
|
||||||
|
const query = createWeeklyPageQuery(params);
|
||||||
|
const result = await request<PageResponse<WeeklyReportResponse>>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${WEEKLY_PREFIX}/page?${query}` : `${WEEKLY_PREFIX}/page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<PageResponse<WeeklyReportResponse>>, data =>
|
||||||
|
mapPage(data, normalizeWeeklyReport)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetWeeklyReportApprovalPage(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
|
||||||
|
const query = createWeeklyPageQuery(params);
|
||||||
|
const result = await request<PageResponse<WeeklyReportResponse>>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${WEEKLY_PREFIX}/approval-page?${query}` : `${WEEKLY_PREFIX}/approval-page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<PageResponse<WeeklyReportResponse>>, data =>
|
||||||
|
mapPage(data, normalizeWeeklyReport)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetWeeklyReportDetail(id: string) {
|
||||||
|
const result = await request<WeeklyReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WEEKLY_PREFIX}/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInitWeeklyReport() {
|
||||||
|
const result = await request<WeeklyReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WEEKLY_PREFIX}/init`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPreviewWeeklyReportDefaultDraft(
|
||||||
|
params: Api.WorkReport.Weekly.WeeklyReportDefaultDraftParams
|
||||||
|
) {
|
||||||
|
const result = await request<WeeklyReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WEEKLY_PREFIX}/default-draft`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRefreshWeeklyReportDraft(data: Api.WorkReport.Weekly.WeeklyReportRefreshDraftParams) {
|
||||||
|
const result = await request<WeeklyReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WEEKLY_PREFIX}/refresh-draft`,
|
||||||
|
method: 'post',
|
||||||
|
data: toWeeklySaveRequest(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<WeeklyReportResponse>, normalizeWeeklyReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCreateWeeklyReport(data: Api.WorkReport.Weekly.WeeklyReportSaveParams) {
|
||||||
|
const result = await request<StringIdResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: WEEKLY_PREFIX,
|
||||||
|
method: 'post',
|
||||||
|
data: toWeeklySaveRequest(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUpdateWeeklyReport(id: string, data: Api.WorkReport.Weekly.WeeklyReportSaveParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WEEKLY_PREFIX}/${id}`,
|
||||||
|
method: 'put',
|
||||||
|
data: toWeeklySaveRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSubmitWeeklyReport(id: string) {
|
||||||
|
return request<boolean>({ ...safeJsonRequestConfig, url: `${WEEKLY_PREFIX}/${id}/submit`, method: 'post' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchApproveWeeklyReport(id: string, data: Api.WorkReport.Common.StatusActionParams = {}) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WEEKLY_PREFIX}/${id}/approve`,
|
||||||
|
method: 'post',
|
||||||
|
data: toStatusActionRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRejectWeeklyReport(id: string, data: Api.WorkReport.Common.StatusActionParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WEEKLY_PREFIX}/${id}/reject`,
|
||||||
|
method: 'post',
|
||||||
|
data: toStatusActionRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDeleteWeeklyReport(id: string) {
|
||||||
|
return request<boolean>({ ...safeJsonRequestConfig, url: `${WEEKLY_PREFIX}/${id}`, method: 'delete' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetWeeklyReportApprovalRecords(id: string) {
|
||||||
|
const result = await request<ApprovalRecordResponse[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${WEEKLY_PREFIX}/${id}/approval-records`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ApprovalRecordResponse[]>, data =>
|
||||||
|
data.map(normalizeApprovalRecord)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchExportWeeklyReports(params: Api.WorkReport.Weekly.WeeklyReportSearchParams = {}) {
|
||||||
|
const query = createWeeklyPageQuery(params);
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
url: query ? `${WEEKLY_PREFIX}/export?${query}` : `${WEEKLY_PREFIX}/export`,
|
||||||
|
method: 'get',
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchExportWeeklyReportContent(
|
||||||
|
data: Api.WorkReport.Common.ContentExportParams<Api.WorkReport.Weekly.WeeklyReportSearchParams>
|
||||||
|
) {
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
url: `${WEEKLY_PREFIX}/content-export`,
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetMonthlyReportPage(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
|
||||||
|
const query = createMonthlyPageQuery(params);
|
||||||
|
const result = await request<PageResponse<MonthlyReportResponse>>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${MONTHLY_PREFIX}/page?${query}` : `${MONTHLY_PREFIX}/page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<PageResponse<MonthlyReportResponse>>, data =>
|
||||||
|
mapPage(data, normalizeMonthlyReport)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetMonthlyReportApprovalPage(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
|
||||||
|
const query = createMonthlyPageQuery(params);
|
||||||
|
const result = await request<PageResponse<MonthlyReportResponse>>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${MONTHLY_PREFIX}/approval-page?${query}` : `${MONTHLY_PREFIX}/approval-page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<PageResponse<MonthlyReportResponse>>, data =>
|
||||||
|
mapPage(data, normalizeMonthlyReport)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetMonthlyReportDetail(id: string) {
|
||||||
|
const result = await request<MonthlyReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${MONTHLY_PREFIX}/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MonthlyReportResponse>, normalizeMonthlyReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInitMonthlyReport() {
|
||||||
|
const result = await request<MonthlyReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${MONTHLY_PREFIX}/init`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MonthlyReportResponse>, normalizeMonthlyReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPreviewMonthlyReportDefaultDraft(
|
||||||
|
params: Api.WorkReport.Monthly.MonthlyReportDefaultDraftParams
|
||||||
|
) {
|
||||||
|
const result = await request<MonthlyReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${MONTHLY_PREFIX}/default-draft`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MonthlyReportResponse>, normalizeMonthlyReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRefreshMonthlyReportDraft(data: Api.WorkReport.Monthly.MonthlyReportRefreshDraftParams) {
|
||||||
|
const result = await request<MonthlyReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${MONTHLY_PREFIX}/refresh-draft`,
|
||||||
|
method: 'post',
|
||||||
|
data: toMonthlySaveRequest(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MonthlyReportResponse>, normalizeMonthlyReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCreateMonthlyReport(data: Api.WorkReport.Monthly.MonthlyReportSaveParams) {
|
||||||
|
const result = await request<StringIdResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: MONTHLY_PREFIX,
|
||||||
|
method: 'post',
|
||||||
|
data: toMonthlySaveRequest(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUpdateMonthlyReport(id: string, data: Api.WorkReport.Monthly.MonthlyReportSaveParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${MONTHLY_PREFIX}/${id}`,
|
||||||
|
method: 'put',
|
||||||
|
data: toMonthlySaveRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSubmitMonthlyReport(id: string) {
|
||||||
|
return request<boolean>({ ...safeJsonRequestConfig, url: `${MONTHLY_PREFIX}/${id}/submit`, method: 'post' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchApproveMonthlyReport(id: string, data: Api.WorkReport.Monthly.MonthlyReportApproveParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${MONTHLY_PREFIX}/${id}/approve`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRejectMonthlyReport(id: string, data: Api.WorkReport.Common.StatusActionParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${MONTHLY_PREFIX}/${id}/reject`,
|
||||||
|
method: 'post',
|
||||||
|
data: toStatusActionRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDeleteMonthlyReport(id: string) {
|
||||||
|
return request<boolean>({ ...safeJsonRequestConfig, url: `${MONTHLY_PREFIX}/${id}`, method: 'delete' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetMonthlyReportApprovalRecords(id: string) {
|
||||||
|
const result = await request<MonthlyApprovalRecordResponse[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${MONTHLY_PREFIX}/${id}/approval-records`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<MonthlyApprovalRecordResponse[]>, data =>
|
||||||
|
data.map(normalizeMonthlyApprovalRecord)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchExportMonthlyReports(params: Api.WorkReport.Monthly.MonthlyReportSearchParams = {}) {
|
||||||
|
const query = createMonthlyPageQuery(params);
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
url: query ? `${MONTHLY_PREFIX}/export?${query}` : `${MONTHLY_PREFIX}/export`,
|
||||||
|
method: 'get',
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchExportMonthlyReportContent(
|
||||||
|
data: Api.WorkReport.Common.ContentExportParams<Api.WorkReport.Monthly.MonthlyReportSearchParams>
|
||||||
|
) {
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
url: `${MONTHLY_PREFIX}/content-export`,
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetProjectReportOwnerProjectOptions() {
|
||||||
|
const result = await request<ProjectOptionResponse[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/owner-project-options`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectOptionResponse[]>, data =>
|
||||||
|
data.map(normalizeProjectOption)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetProjectReportPage(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
|
||||||
|
const query = createProjectPageQuery(params);
|
||||||
|
const result = await request<PageResponse<ProjectReportResponse>>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${PROJECT_PREFIX}/page?${query}` : `${PROJECT_PREFIX}/page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<PageResponse<ProjectReportResponse>>, data =>
|
||||||
|
mapPage(data, normalizeProjectReport)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetProjectReportApprovalPage(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
|
||||||
|
const query = createProjectPageQuery(params);
|
||||||
|
const result = await request<PageResponse<ProjectReportResponse>>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: query ? `${PROJECT_PREFIX}/approval-page?${query}` : `${PROJECT_PREFIX}/approval-page`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<PageResponse<ProjectReportResponse>>, data =>
|
||||||
|
mapPage(data, normalizeProjectReport)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetProjectReportDetail(id: string) {
|
||||||
|
const result = await request<ProjectReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${id}`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectReportResponse>, normalizeProjectReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInitProjectReport(projectId: string) {
|
||||||
|
const result = await request<ProjectReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/init`,
|
||||||
|
method: 'get',
|
||||||
|
params: { projectId }
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectReportResponse>, normalizeProjectReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPreviewProjectReportDefaultDraft(
|
||||||
|
projectId: string,
|
||||||
|
params: Api.WorkReport.Project.ProjectReportDefaultDraftParams
|
||||||
|
) {
|
||||||
|
const result = await request<ProjectReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${projectId}/default-draft`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectReportResponse>, normalizeProjectReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRefreshProjectReportDraft(
|
||||||
|
projectId: string,
|
||||||
|
data: Api.WorkReport.Project.ProjectReportRefreshDraftParams
|
||||||
|
) {
|
||||||
|
const result = await request<ProjectReportResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${projectId}/refresh-draft`,
|
||||||
|
method: 'post',
|
||||||
|
data: {
|
||||||
|
periodKey: data.periodKey,
|
||||||
|
periodLabel: data.periodLabel,
|
||||||
|
periodStartDate: data.periodStartDate,
|
||||||
|
periodEndDate: data.periodEndDate,
|
||||||
|
flag: data.flag,
|
||||||
|
projectStatusDesc: data.projectStatusDesc?.trim() || '',
|
||||||
|
projectProgressPlan: data.projectProgressPlan?.trim() || '',
|
||||||
|
projectKeyPoints: data.projectKeyPoints?.trim() || '',
|
||||||
|
projectProblems: data.projectProblems?.trim() || '',
|
||||||
|
currentItems: toProjectItems(data.currentItems),
|
||||||
|
nextItems: toProjectItems(data.nextItems)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectReportResponse>, normalizeProjectReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCreateProjectReport(data: Api.WorkReport.Project.ProjectReportSaveParams) {
|
||||||
|
const result = await request<StringIdResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: PROJECT_PREFIX,
|
||||||
|
method: 'post',
|
||||||
|
data: toProjectSaveRequest(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<StringIdResponse>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUpdateProjectReport(id: string, data: Api.WorkReport.Project.ProjectReportSaveParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${id}`,
|
||||||
|
method: 'put',
|
||||||
|
data: toProjectSaveRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchSubmitProjectReport(id: string) {
|
||||||
|
return request<boolean>({ ...safeJsonRequestConfig, url: `${PROJECT_PREFIX}/${id}/submit`, method: 'post' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchApproveProjectReport(id: string, data: Api.WorkReport.Common.StatusActionParams = {}) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${id}/approve`,
|
||||||
|
method: 'post',
|
||||||
|
data: toStatusActionRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRejectProjectReport(id: string, data: Api.WorkReport.Common.StatusActionParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${id}/reject`,
|
||||||
|
method: 'post',
|
||||||
|
data: toStatusActionRequest(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDeleteProjectReport(id: string) {
|
||||||
|
return request<boolean>({ ...safeJsonRequestConfig, url: `${PROJECT_PREFIX}/${id}`, method: 'delete' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchGetProjectReportApprovalRecords(id: string) {
|
||||||
|
const result = await request<ApprovalRecordResponse[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${id}/approval-records`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ApprovalRecordResponse[]>, data =>
|
||||||
|
data.map(normalizeApprovalRecord)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchExportProjectReports(params: Api.WorkReport.Project.ProjectReportSearchParams = {}) {
|
||||||
|
const query = createProjectPageQuery(params);
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
url: query ? `${PROJECT_PREFIX}/export?${query}` : `${PROJECT_PREFIX}/export`,
|
||||||
|
method: 'get',
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchExportProjectReportContent(
|
||||||
|
data: Api.WorkReport.Common.ContentExportParams<Api.WorkReport.Project.ProjectReportSearchParams>
|
||||||
|
) {
|
||||||
|
return request<Blob, 'blob'>({
|
||||||
|
url: `${PROJECT_PREFIX}/content-export`,
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
90
src/service/request/dedupe.ts
Normal file
90
src/service/request/dedupe.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import type { InternalAxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
|
declare module 'axios' {
|
||||||
|
interface AxiosRequestConfig {
|
||||||
|
dedupe?: boolean;
|
||||||
|
/**
|
||||||
|
* 跳过 Authorization 注入。
|
||||||
|
*
|
||||||
|
* 用于公开接口(refresh-token / login / register 等 PermitAll 路径),
|
||||||
|
* 避免给它们带上过期 access 头被网关拦截。
|
||||||
|
*/
|
||||||
|
skipAuth?: boolean;
|
||||||
|
/** 请求失败时不走通用错误 toast,由调用方自行收敛提示。 */
|
||||||
|
suppressErrorMessage?: boolean;
|
||||||
|
/** 请求失败命中过期 access code 时,不再触发 refresh-token 流程。 */
|
||||||
|
skipTokenRefresh?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WRITE_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
|
||||||
|
|
||||||
|
type DedupableConfig = Pick<InternalAxiosRequestConfig, 'method' | 'url' | 'data' | 'params'> & {
|
||||||
|
dedupe?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isFormDataLike(value: unknown): boolean {
|
||||||
|
if (typeof FormData !== 'undefined' && value instanceof FormData) return true;
|
||||||
|
if (typeof Blob !== 'undefined' && value instanceof Blob) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableJson(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (typeof value !== 'object') return JSON.stringify(value);
|
||||||
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const keys = Object.keys(obj).sort();
|
||||||
|
return `{${keys.map(k => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(',')}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeDedupeKey(config: DedupableConfig): string | null {
|
||||||
|
const method = (config.method ?? 'GET').toUpperCase();
|
||||||
|
if (!WRITE_METHODS.has(method)) return null;
|
||||||
|
if (config.dedupe === false) return null;
|
||||||
|
if (isFormDataLike(config.data)) return null;
|
||||||
|
|
||||||
|
const url = config.url ?? '';
|
||||||
|
const paramsPart = stableJson(config.params);
|
||||||
|
const bodyPart = stableJson(config.data);
|
||||||
|
return `${method}|${url}?${paramsPart}|${bodyPart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TTL_MS = 30_000;
|
||||||
|
|
||||||
|
export interface WithDedupeOptions {
|
||||||
|
ttlMs?: number;
|
||||||
|
now?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnyRequestFn = (...args: any[]) => Promise<unknown>;
|
||||||
|
|
||||||
|
export function withDedupe<TFn extends AnyRequestFn>(request: TFn, options: WithDedupeOptions = {}): TFn {
|
||||||
|
const ttl = options.ttlMs ?? DEFAULT_TTL_MS;
|
||||||
|
const now = options.now ?? Date.now;
|
||||||
|
const pending = new Map<string, { promise: Promise<unknown>; expiresAt: number }>();
|
||||||
|
|
||||||
|
return new Proxy(request, {
|
||||||
|
apply(target, thisArg, args: Parameters<TFn>) {
|
||||||
|
const [config] = args;
|
||||||
|
const key = computeDedupeKey(config as DedupableConfig);
|
||||||
|
if (key === null) return Reflect.apply(target, thisArg, args);
|
||||||
|
|
||||||
|
const cached = pending.get(key);
|
||||||
|
if (cached && cached.expiresAt > now()) return cached.promise;
|
||||||
|
if (cached) pending.delete(key);
|
||||||
|
|
||||||
|
const promise = Promise.resolve()
|
||||||
|
.then(() => Reflect.apply(target, thisArg, args))
|
||||||
|
.finally(() => {
|
||||||
|
const current = pending.get(key);
|
||||||
|
if (current && current.promise === promise) {
|
||||||
|
pending.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pending.set(key, { promise, expiresAt: now() + ttl });
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
}) as TFn;
|
||||||
|
}
|
||||||
32
src/service/request/error-message.ts
Normal file
32
src/service/request/error-message.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export const SESSION_EXPIRED_MESSAGE = '登录已失效,请重新登录';
|
||||||
|
|
||||||
|
export interface ErrorMessageSuppressOptions {
|
||||||
|
backendErrorCode: string;
|
||||||
|
suppressErrorMessage?: boolean;
|
||||||
|
logoutCodes: string[];
|
||||||
|
modalLogoutCodes: string[];
|
||||||
|
expiredTokenCodes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackendFailDeferOptions {
|
||||||
|
suppressErrorMessage?: boolean;
|
||||||
|
skipTokenRefresh?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseServiceCodes(codes?: string) {
|
||||||
|
return codes?.split(',').filter(Boolean) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldDeferBackendFailToCaller(options: BackendFailDeferOptions) {
|
||||||
|
return Boolean(options.suppressErrorMessage && options.skipTokenRefresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldSuppressErrorMessage(options: ErrorMessageSuppressOptions) {
|
||||||
|
if (options.suppressErrorMessage) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handledCodes = [...options.logoutCodes, ...options.modalLogoutCodes, ...options.expiredTokenCodes];
|
||||||
|
|
||||||
|
return handledCodes.includes(options.backendErrorCode);
|
||||||
|
}
|
||||||
@@ -5,126 +5,159 @@ import { localStg } from '@/utils/storage';
|
|||||||
import { getServiceBaseURL } from '@/utils/service';
|
import { getServiceBaseURL } from '@/utils/service';
|
||||||
import { $t } from '@/locales';
|
import { $t } from '@/locales';
|
||||||
import { applyApiEncrypt } from './api-encrypt';
|
import { applyApiEncrypt } from './api-encrypt';
|
||||||
import { getAuthorization, handleExpiredRequest, showErrorMsg } from './shared';
|
import { parseServiceCodes, shouldDeferBackendFailToCaller, shouldSuppressErrorMessage } from './error-message';
|
||||||
|
import { getAuthorization, handleExpiredRequest, notifySessionExpired, showErrorMsg } from './shared';
|
||||||
|
import { withDedupe } from './dedupe';
|
||||||
import type { RequestInstanceState } from './type';
|
import type { RequestInstanceState } from './type';
|
||||||
|
|
||||||
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
|
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
|
||||||
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
|
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
|
||||||
|
const REQUEST_TIMEOUT = 15 * 1000;
|
||||||
|
|
||||||
export const request = createFlatRequest(
|
export const request = withDedupe(
|
||||||
{
|
createFlatRequest(
|
||||||
baseURL,
|
{
|
||||||
headers: {
|
baseURL,
|
||||||
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
|
timeout: REQUEST_TIMEOUT,
|
||||||
}
|
headers: {
|
||||||
},
|
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
|
||||||
{
|
}
|
||||||
defaultState: {
|
|
||||||
errMsgStack: [],
|
|
||||||
refreshTokenPromise: null
|
|
||||||
} as RequestInstanceState,
|
|
||||||
transform(response: AxiosResponse<App.Service.Response<any>>) {
|
|
||||||
return response.data.data;
|
|
||||||
},
|
},
|
||||||
async onRequest(config) {
|
{
|
||||||
const Authorization = getAuthorization();
|
defaultState: {
|
||||||
Object.assign(config.headers, { Authorization });
|
errMsgStack: [],
|
||||||
applyApiEncrypt(config);
|
refreshTokenPromise: null
|
||||||
|
} as RequestInstanceState,
|
||||||
return config;
|
transform(response: AxiosResponse<App.Service.Response<any>>) {
|
||||||
},
|
return response.data.data;
|
||||||
isBackendSuccess(response) {
|
},
|
||||||
// 当后端返回码为 "0"(默认)时,表示请求成功
|
async onRequest(config) {
|
||||||
// 如需调整该逻辑,可修改 `.env` 中的 `VITE_SERVICE_SUCCESS_CODE`
|
// skipAuth 为 true 的请求不注入 Authorization——避免给公开接口(如 refresh-token)
|
||||||
return String(response.data.code) === import.meta.env.VITE_SERVICE_SUCCESS_CODE;
|
// 带上过期 access 头被网关拦截(网关只看 Authorization,不区分路由是否 PermitAll)
|
||||||
},
|
if (!config.skipAuth) {
|
||||||
async onBackendFail(response, instance) {
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
const responseCode = String(response.data.code);
|
|
||||||
|
|
||||||
function handleLogout() {
|
|
||||||
authStore.resetStore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function logoutAndCleanup() {
|
|
||||||
handleLogout();
|
|
||||||
window.removeEventListener('beforeunload', handleLogout);
|
|
||||||
|
|
||||||
request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 当后端返回码命中 `logoutCodes` 时,表示用户需要退出登录并跳转到登录页
|
|
||||||
const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || [];
|
|
||||||
if (logoutCodes.includes(responseCode)) {
|
|
||||||
handleLogout();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 当后端返回码命中 `modalLogoutCodes` 时,表示通过弹窗提示后再退出登录
|
|
||||||
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || [];
|
|
||||||
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
|
|
||||||
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg];
|
|
||||||
|
|
||||||
// 防止用户刷新页面绕过退出逻辑
|
|
||||||
window.addEventListener('beforeunload', handleLogout);
|
|
||||||
|
|
||||||
window.$messageBox
|
|
||||||
?.confirm(response.data.msg, $t('common.error'), {
|
|
||||||
confirmButtonText: $t('common.confirm'),
|
|
||||||
cancelButtonText: $t('common.cancel'),
|
|
||||||
type: 'error',
|
|
||||||
closeOnClickModal: false,
|
|
||||||
closeOnPressEscape: false
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
logoutAndCleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 当后端返回码命中 `expiredTokenCodes` 时,表示 token 已过期,需要刷新 token
|
|
||||||
// `refreshToken` 接口不能再返回 `expiredTokenCodes` 中的错误码,否则会形成死循环,应返回 `logoutCodes` 或 `modalLogoutCodes`
|
|
||||||
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || [];
|
|
||||||
if (expiredTokenCodes.includes(responseCode)) {
|
|
||||||
const success = await handleExpiredRequest(request.state);
|
|
||||||
if (success) {
|
|
||||||
const Authorization = getAuthorization();
|
const Authorization = getAuthorization();
|
||||||
Object.assign(response.config.headers, { Authorization });
|
Object.assign(config.headers, { Authorization });
|
||||||
|
|
||||||
return instance.request(response.config) as Promise<AxiosResponse>;
|
|
||||||
}
|
}
|
||||||
|
applyApiEncrypt(config);
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
isBackendSuccess(response) {
|
||||||
|
// 当后端返回码为 "0"(默认)时,表示请求成功
|
||||||
|
// 如需调整该逻辑,可修改 `.env` 中的 `VITE_SERVICE_SUCCESS_CODE`
|
||||||
|
return String(response.data.code) === import.meta.env.VITE_SERVICE_SUCCESS_CODE;
|
||||||
|
},
|
||||||
|
async onBackendFail(response, instance) {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const responseCode = String(response.data.code);
|
||||||
|
|
||||||
|
if (
|
||||||
|
shouldDeferBackendFailToCaller({
|
||||||
|
suppressErrorMessage: response.config.suppressErrorMessage,
|
||||||
|
skipTokenRefresh: response.config.skipTokenRefresh
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
authStore.resetStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function logoutAndCleanup() {
|
||||||
|
handleLogout();
|
||||||
|
window.removeEventListener('beforeunload', handleLogout);
|
||||||
|
|
||||||
|
request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当后端返回码命中 `logoutCodes` 时,表示登录态已失效,需要提示后退出登录
|
||||||
|
// 走 notifySessionExpired 而不是裸 resetStore:保证并发请求只弹一次 toast、只清一次状态
|
||||||
|
const logoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_LOGOUT_CODES);
|
||||||
|
if (logoutCodes.includes(responseCode)) {
|
||||||
|
notifySessionExpired();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当后端返回码命中 `modalLogoutCodes` 时,表示通过弹窗提示后再退出登录
|
||||||
|
const modalLogoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES);
|
||||||
|
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
|
||||||
|
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg];
|
||||||
|
|
||||||
|
// 防止用户刷新页面绕过退出逻辑
|
||||||
|
window.addEventListener('beforeunload', handleLogout);
|
||||||
|
|
||||||
|
window.$messageBox
|
||||||
|
?.confirm(response.data.msg, $t('common.error'), {
|
||||||
|
confirmButtonText: $t('common.confirm'),
|
||||||
|
cancelButtonText: $t('common.cancel'),
|
||||||
|
type: 'error',
|
||||||
|
closeOnClickModal: false,
|
||||||
|
closeOnPressEscape: false
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
logoutAndCleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当后端返回码命中 `expiredTokenCodes` 时,表示 token 已过期,需要刷新 token
|
||||||
|
// `refreshToken` 接口不能再返回 `expiredTokenCodes` 中的错误码,否则会形成死循环,应返回 `logoutCodes` 或 `modalLogoutCodes`
|
||||||
|
const expiredTokenCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES);
|
||||||
|
if (expiredTokenCodes.includes(responseCode)) {
|
||||||
|
if (response.config.skipTokenRefresh) {
|
||||||
|
notifySessionExpired();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await handleExpiredRequest(request.state);
|
||||||
|
if (success) {
|
||||||
|
const Authorization = getAuthorization();
|
||||||
|
Object.assign(response.config.headers, { Authorization });
|
||||||
|
|
||||||
|
return instance.request(response.config) as Promise<AxiosResponse>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
// 请求失败时,在这里统一处理错误提示
|
||||||
|
|
||||||
|
let message = error.message;
|
||||||
|
let backendErrorCode = '';
|
||||||
|
|
||||||
|
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
|
||||||
|
message = '请求超时,请稍后重试';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取后端错误信息和错误码
|
||||||
|
if (error.code === BACKEND_ERROR_CODE) {
|
||||||
|
message = error.response?.data?.msg || message;
|
||||||
|
backendErrorCode = String(error.response?.data?.code || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const suppressErrorMessage = Boolean(error.config?.suppressErrorMessage);
|
||||||
|
const logoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_LOGOUT_CODES);
|
||||||
|
const modalLogoutCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES);
|
||||||
|
const expiredTokenCodes = parseServiceCodes(import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES);
|
||||||
|
if (
|
||||||
|
shouldSuppressErrorMessage({
|
||||||
|
backendErrorCode,
|
||||||
|
suppressErrorMessage,
|
||||||
|
logoutCodes,
|
||||||
|
modalLogoutCodes,
|
||||||
|
expiredTokenCodes
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showErrorMsg(request.state, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
// 请求失败时,在这里统一处理错误提示
|
|
||||||
|
|
||||||
let message = error.message;
|
|
||||||
let backendErrorCode = '';
|
|
||||||
|
|
||||||
// 获取后端错误信息和错误码
|
|
||||||
if (error.code === BACKEND_ERROR_CODE) {
|
|
||||||
message = error.response?.data?.msg || message;
|
|
||||||
backendErrorCode = String(error.response?.data?.code || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 这类错误信息已经通过弹窗展示,不再重复提示
|
|
||||||
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || [];
|
|
||||||
if (modalLogoutCodes.includes(backendErrorCode)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// token 过期时会自动刷新并重试请求,这里无需额外提示
|
|
||||||
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || [];
|
|
||||||
if (expiredTokenCodes.includes(backendErrorCode)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showErrorMsg(request.state, message);
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
export const demoRequest = createRequest(
|
export const demoRequest = createRequest(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useAuthStore } from '@/store/modules/auth';
|
import { useAuthStore } from '@/store/modules/auth';
|
||||||
import { localStg } from '@/utils/storage';
|
import { localStg } from '@/utils/storage';
|
||||||
import { fetchRefreshToken } from '../api';
|
import { fetchRefreshToken } from '../api';
|
||||||
|
import { SESSION_EXPIRED_MESSAGE } from './error-message';
|
||||||
import type { RequestInstanceState } from './type';
|
import type { RequestInstanceState } from './type';
|
||||||
|
|
||||||
export function getAuthorization() {
|
export function getAuthorization() {
|
||||||
@@ -12,8 +13,6 @@ export function getAuthorization() {
|
|||||||
|
|
||||||
/** 刷新 token */
|
/** 刷新 token */
|
||||||
async function handleRefreshToken() {
|
async function handleRefreshToken() {
|
||||||
const { resetStore } = useAuthStore();
|
|
||||||
|
|
||||||
const rToken = localStg.get('refreshToken') || '';
|
const rToken = localStg.get('refreshToken') || '';
|
||||||
const { error, data } = await fetchRefreshToken(rToken);
|
const { error, data } = await fetchRefreshToken(rToken);
|
||||||
if (!error) {
|
if (!error) {
|
||||||
@@ -22,25 +21,48 @@ async function handleRefreshToken() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetStore();
|
notifySessionExpired();
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleExpiredRequest(state: RequestInstanceState) {
|
export async function handleExpiredRequest(state: RequestInstanceState) {
|
||||||
if (!state.refreshTokenFn) {
|
if (!state.refreshTokenPromise) {
|
||||||
state.refreshTokenFn = handleRefreshToken();
|
state.refreshTokenPromise = handleRefreshToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = await state.refreshTokenFn;
|
const success = await state.refreshTokenPromise;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
state.refreshTokenFn = null;
|
state.refreshTokenPromise = null;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 会话失效一次性锁:保证 N 个并发请求只弹一次 toast、只 resetStore 一次
|
||||||
|
let sessionExpiredNotified = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知用户会话已失效,弹一次 toast 后清状态、跳登录。
|
||||||
|
*
|
||||||
|
* 多个并发请求触发时只会真正执行一次;登录成功后由 resetSessionExpiredFlag() 复位。
|
||||||
|
*/
|
||||||
|
export function notifySessionExpired() {
|
||||||
|
if (sessionExpiredNotified) return;
|
||||||
|
sessionExpiredNotified = true;
|
||||||
|
|
||||||
|
window.$message?.error(SESSION_EXPIRED_MESSAGE);
|
||||||
|
|
||||||
|
const { resetStore } = useAuthStore();
|
||||||
|
resetStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录成功后复位一次性锁,让下一次会话失效仍能正常提示 */
|
||||||
|
export function resetSessionExpiredFlag() {
|
||||||
|
sessionExpiredNotified = false;
|
||||||
|
}
|
||||||
|
|
||||||
export function showErrorMsg(state: RequestInstanceState, message: string) {
|
export function showErrorMsg(state: RequestInstanceState, message: string) {
|
||||||
if (!state.errMsgStack?.length) {
|
if (!state.errMsgStack?.length) {
|
||||||
state.errMsgStack = [];
|
state.errMsgStack = [];
|
||||||
|
|||||||
@@ -3,5 +3,7 @@ export interface RequestInstanceState {
|
|||||||
refreshTokenPromise: Promise<boolean> | null;
|
refreshTokenPromise: Promise<boolean> | null;
|
||||||
/** 请求错误信息栈 */
|
/** 请求错误信息栈 */
|
||||||
errMsgStack: string[];
|
errMsgStack: string[];
|
||||||
|
// 索引签名是 @sa/axios 的 defaultState 类型约束(要求 Record<string, unknown>)的硬要求,不能删
|
||||||
|
// 字段名对齐已通过把 shared.ts 里的 refreshTokenFn 全部改成 refreshTokenPromise 来消除隐患
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useRoute } from 'vue-router';
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { useLoading } from '@sa/hooks';
|
import { useLoading } from '@sa/hooks';
|
||||||
import { clearAuthApiCache, fetchGetUserInfo, fetchLogin } from '@/service/api';
|
import { clearAuthApiCache, fetchGetUserInfo, fetchLogin } from '@/service/api';
|
||||||
|
import { resetSessionExpiredFlag } from '@/service/request/shared';
|
||||||
import { useRouterPush } from '@/hooks/common/router';
|
import { useRouterPush } from '@/hooks/common/router';
|
||||||
import { localStg } from '@/utils/storage';
|
import { localStg } from '@/utils/storage';
|
||||||
import { SetupStoreId } from '@/enum';
|
import { SetupStoreId } from '@/enum';
|
||||||
@@ -28,6 +29,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
|||||||
const userInfo: Api.Auth.UserInfo = reactive({
|
const userInfo: Api.Auth.UserInfo = reactive({
|
||||||
userId: '',
|
userId: '',
|
||||||
userName: '',
|
userName: '',
|
||||||
|
nickname: '',
|
||||||
roles: [],
|
roles: [],
|
||||||
buttons: []
|
buttons: []
|
||||||
});
|
});
|
||||||
@@ -49,16 +51,27 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
|||||||
|
|
||||||
clearAuthStorage();
|
clearAuthStorage();
|
||||||
|
|
||||||
authStore.$reset();
|
// setup store 没有内置 $reset,需要显式重置内部状态,避免 token / userInfo 残留导致 isLogin 误判。
|
||||||
dictStore.resetDictCache();
|
token.value = '';
|
||||||
objectContextStore.$reset();
|
Object.assign(userInfo, {
|
||||||
|
userId: '',
|
||||||
|
userName: '',
|
||||||
|
nickname: '',
|
||||||
|
roles: [],
|
||||||
|
buttons: []
|
||||||
|
});
|
||||||
|
|
||||||
if (!route.meta.constant) {
|
dictStore.resetDictCache();
|
||||||
|
objectContextStore.clearContext();
|
||||||
|
|
||||||
|
// 用路由名判断当前是否已在登录页,避免依赖 route.meta.constant ——
|
||||||
|
// workbench 等首页也是常量路由,原写法会让常量路由上的登出请求不跳转。
|
||||||
|
if (route.name !== 'login') {
|
||||||
await toLogin();
|
await toLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
tabStore.cacheTabs();
|
tabStore.cacheTabs();
|
||||||
routeStore.resetStore();
|
await routeStore.resetStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Record the user ID of the previous login session Used to compare with the current user ID on next login */
|
/** Record the user ID of the previous login session Used to compare with the current user ID on next login */
|
||||||
@@ -118,11 +131,17 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
|||||||
// If the tab needs to be cleared,it means we don't need to redirect.
|
// If the tab needs to be cleared,it means we don't need to redirect.
|
||||||
needRedirect = false;
|
needRedirect = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 跳首页前先把权限路由建好:菜单/路由/首页 redirect 全部就绪后再导航,
|
||||||
|
// 否则依赖守卫在"跳首页"那次导航里懒加载,会出现首页先以空 menus 渲染、
|
||||||
|
// 之后无新导航补灌、菜单一直空到手动刷新才恢复的竞态。
|
||||||
|
await routeStore.initAuthRoute();
|
||||||
|
|
||||||
await redirectFromLogin(needRedirect);
|
await redirectFromLogin(needRedirect);
|
||||||
|
|
||||||
window.$notification?.success({
|
window.$notification?.success({
|
||||||
title: $t('page.login.common.loginSuccess'),
|
title: $t('page.login.common.loginSuccess'),
|
||||||
message: $t('page.login.common.welcomeBack', { userName: userInfo.userName }),
|
message: $t('page.login.common.welcomeBack', { userName: userInfo.nickname || userInfo.userName }),
|
||||||
duration: 4500
|
duration: 4500
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -148,6 +167,9 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
|||||||
|
|
||||||
token.value = loginToken.token;
|
token.value = loginToken.token;
|
||||||
|
|
||||||
|
// 复位会话失效一次性锁,让下一次会话失效仍能正常提示
|
||||||
|
resetSessionExpiredFlag();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +189,18 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshUserInfo() {
|
||||||
|
const { data: info, error } = await fetchGetUserInfo(true);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
Object.assign(userInfo, info);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function initUserInfo() {
|
async function initUserInfo() {
|
||||||
const hasToken = getToken();
|
const hasToken = getToken();
|
||||||
|
|
||||||
@@ -189,6 +223,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
|||||||
loginLoading,
|
loginLoading,
|
||||||
resetStore,
|
resetStore,
|
||||||
login,
|
login,
|
||||||
initUserInfo
|
initUserInfo,
|
||||||
|
refreshUserInfo
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE } from '@/constants/dict';
|
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE } from '@/constants/dict';
|
||||||
import { fetchGetFrontendDictCache } from '@/service/api';
|
import { fetchGetDictDataByCode, fetchGetFrontendDictCache } from '@/service/api';
|
||||||
import { SetupStoreId } from '@/enum';
|
import { SetupStoreId } from '@/enum';
|
||||||
|
|
||||||
type DictValue = string | number | null | undefined;
|
type DictValue = string | number | null | undefined;
|
||||||
@@ -19,6 +19,24 @@ function sortDictData(list: Api.Dict.DictData[]) {
|
|||||||
return list.slice().sort((left, right) => left.sort - right.sort || left.label.localeCompare(right.label, 'zh-CN'));
|
return list.slice().sort((left, right) => left.sort - right.sort || left.label.localeCompare(right.label, 'zh-CN'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hex 色值兜底校验:仅接受 #RRGGBB(6 位);其他格式(含 #RGB 简写 / rgb())一律视为无效回落到默认渲染
|
||||||
|
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
|
||||||
|
|
||||||
|
function normalizeColorType(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
const trimmed = raw.trim().toLowerCase();
|
||||||
|
return HEX_COLOR_PATTERN.test(trimmed) ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析字典项最终展示色(hex)。
|
||||||
|
* 精确色 cssClass 优先(覆盖 colorType 落到语义色无法区分黄/橙等场景),其次 colorType;
|
||||||
|
* 两者都不是合法 hex 时回落 null(默认渲染)。
|
||||||
|
*/
|
||||||
|
function resolveDisplayColor(colorType: unknown, cssClass: unknown): string | null {
|
||||||
|
return normalizeColorType(cssClass) ?? normalizeColorType(colorType);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeFrontendDictData(
|
function normalizeFrontendDictData(
|
||||||
dictType: string,
|
dictType: string,
|
||||||
list: Api.Dict.FrontendDictData[],
|
list: Api.Dict.FrontendDictData[],
|
||||||
@@ -31,13 +49,25 @@ function normalizeFrontendDictData(
|
|||||||
dictType: item.dictType || dictType,
|
dictType: item.dictType || dictType,
|
||||||
sort: item.sort,
|
sort: item.sort,
|
||||||
status: item.status ?? 0,
|
status: item.status ?? 0,
|
||||||
remark: null,
|
colorType: resolveDisplayColor(item.colorType, item.cssClass),
|
||||||
|
remark: item.remark ?? null,
|
||||||
createTime: 0
|
createTime: 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return sortDictData(normalizedList);
|
return sortDictData(normalizedList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDictDataItem(item: Api.Dict.DictData, dictType: string): Api.Dict.DictData {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
value: String(item.value),
|
||||||
|
dictType: item.dictType || dictType,
|
||||||
|
status: item.status ?? 0,
|
||||||
|
colorType: resolveDisplayColor(item.colorType, item.cssClass),
|
||||||
|
remark: item.remark ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeFrontendDictCache(cache: Api.Dict.FrontendDictCache) {
|
function normalizeFrontendDictCache(cache: Api.Dict.FrontendDictCache) {
|
||||||
const entries = Object.entries(cache);
|
const entries = Object.entries(cache);
|
||||||
|
|
||||||
@@ -89,6 +119,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
|||||||
const loadedAt = ref<number | null>(null);
|
const loadedAt = ref<number | null>(null);
|
||||||
|
|
||||||
let initPromise: Promise<boolean> | null = null;
|
let initPromise: Promise<boolean> | null = null;
|
||||||
|
const dictDataLoadPromises = new Map<string, Promise<boolean>>();
|
||||||
|
|
||||||
function resetDictCache() {
|
function resetDictCache() {
|
||||||
dictTypes.value = [];
|
dictTypes.value = [];
|
||||||
@@ -96,6 +127,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
|||||||
loadedAt.value = null;
|
loadedAt.value = null;
|
||||||
initialized.value = false;
|
initialized.value = false;
|
||||||
initPromise = null;
|
initPromise = null;
|
||||||
|
dictDataLoadPromises.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initDictCache(force = false) {
|
async function initDictCache(force = false) {
|
||||||
@@ -137,6 +169,51 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
|||||||
return initPromise;
|
return initPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureDictData(dictType: string, force = false) {
|
||||||
|
if (!dictType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initialized.value) {
|
||||||
|
await initDictCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && getDictData(dictType).length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = dictDataLoadPromises.get(dictType);
|
||||||
|
if (pending && !force) {
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
const result = await fetchGetDictDataByCode(dictType);
|
||||||
|
|
||||||
|
if (result.error || !result.data?.list?.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
dictDataMap.value = {
|
||||||
|
...dictDataMap.value,
|
||||||
|
[dictType]: sortDictData(result.data.list.map(item => normalizeDictDataItem(item, dictType)))
|
||||||
|
};
|
||||||
|
dictTypes.value = createRuntimeDictTypes(dictDataMap.value);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})();
|
||||||
|
|
||||||
|
dictDataLoadPromises.set(dictType, promise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await promise;
|
||||||
|
} finally {
|
||||||
|
if (dictDataLoadPromises.get(dictType) === promise) {
|
||||||
|
dictDataLoadPromises.delete(dictType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getDictData(dictType: string, onlyEnabled = false) {
|
function getDictData(dictType: string, onlyEnabled = false) {
|
||||||
if (!dictType) {
|
if (!dictType) {
|
||||||
return [];
|
return [];
|
||||||
@@ -199,6 +276,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
|||||||
dictDataMap,
|
dictDataMap,
|
||||||
loadedAt,
|
loadedAt,
|
||||||
initDictCache,
|
initDictCache,
|
||||||
|
ensureDictData,
|
||||||
resetDictCache,
|
resetDictCache,
|
||||||
getDictData,
|
getDictData,
|
||||||
getDictOptions,
|
getDictOptions,
|
||||||
|
|||||||
@@ -149,9 +149,16 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
|||||||
|
|
||||||
/** 重置 store */
|
/** 重置 store */
|
||||||
async function resetStore() {
|
async function resetStore() {
|
||||||
const routeStore = useRouteStore();
|
// setup store 没有内置 $reset,需要显式重置内部状态。
|
||||||
|
// 否则 isInitConstantRoute / isInitAuthRoute 一直停在 true,导致下面 initConstantRoute 早返,
|
||||||
routeStore.$reset();
|
// 路由被 resetVueRoutes 摘掉后无法重新注册,菜单和导航都会失效。
|
||||||
|
setIsInitConstantRoute(false);
|
||||||
|
setIsInitAuthRoute(false);
|
||||||
|
constantRoutes.value = [];
|
||||||
|
authRoutes.value = [];
|
||||||
|
menus.value = [];
|
||||||
|
cacheRoutes.value = [];
|
||||||
|
excludeCacheRoutes.value = [];
|
||||||
|
|
||||||
resetVueRoutes();
|
resetVueRoutes();
|
||||||
|
|
||||||
@@ -242,7 +249,10 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
|
|||||||
/** 统一处理常量路由和权限路由 */
|
/** 统一处理常量路由和权限路由 */
|
||||||
async function handleConstantAndAuthRoutes() {
|
async function handleConstantAndAuthRoutes() {
|
||||||
const { getAuthVueRoutes } = await loadRouteModule();
|
const { getAuthVueRoutes } = await loadRouteModule();
|
||||||
const allRoutes = [...constantRoutes.value, ...authRoutes.value];
|
// 常量路由优先:动态权限路由中与常量路由 name 重复的项剔除,避免菜单出现重复入口(如 workbench)
|
||||||
|
const constantRouteNames = new Set(constantRoutes.value.map(route => route.name));
|
||||||
|
const dedupedAuthRoutes = authRoutes.value.filter(route => !constantRouteNames.has(route.name));
|
||||||
|
const allRoutes = [...constantRoutes.value, ...dedupedAuthRoutes];
|
||||||
|
|
||||||
const sortRoutes = sortRoutesByOrder(allRoutes);
|
const sortRoutes = sortRoutesByOrder(allRoutes);
|
||||||
|
|
||||||
|
|||||||
@@ -153,7 +153,12 @@ export function getCacheRouteNames(routes: RouteRecordRaw[]) {
|
|||||||
const cacheNames: LastLevelRouteKey[] = [];
|
const cacheNames: LastLevelRouteKey[] = [];
|
||||||
|
|
||||||
routes.forEach(route => {
|
routes.forEach(route => {
|
||||||
// only get last two level route, which has component
|
// Check first-level routes (routes with component but no children)
|
||||||
|
if (route.component && route.meta?.keepAlive && !route.children?.length) {
|
||||||
|
cacheNames.push(route.name as LastLevelRouteKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check second-level routes
|
||||||
route.children?.forEach(child => {
|
route.children?.forEach(child => {
|
||||||
if (child.component && child.meta?.keepAlive) {
|
if (child.component && child.meta?.keepAlive) {
|
||||||
cacheNames.push(child.name as LastLevelRouteKey);
|
cacheNames.push(child.name as LastLevelRouteKey);
|
||||||
|
|||||||
11
src/store/modules/workbench/index.ts
Normal file
11
src/store/modules/workbench/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { useWorkbenchLayout } from '@/views/workbench/composables/use-workbench-layout';
|
||||||
|
import { SetupStoreId } from '@/enum';
|
||||||
|
import { useAuthStore } from '../auth';
|
||||||
|
|
||||||
|
export const useWorkbenchStore = defineStore(SetupStoreId.Workbench, () => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const userId = computed(() => String(authStore.userInfo?.userId ?? 'anonymous'));
|
||||||
|
return useWorkbenchLayout({ userId: userId.value });
|
||||||
|
});
|
||||||
@@ -406,6 +406,7 @@ html .el-collapse {
|
|||||||
.business-table-action-cell {
|
.business-table-action-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
@@ -416,6 +417,20 @@ html .el-collapse {
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.business-table-action-icon-button {
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&.el-button + .el-button {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-table-action-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
.business-table-action-menu {
|
.business-table-action-menu {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -428,6 +443,31 @@ html .el-collapse {
|
|||||||
margin-left: 0 !important;
|
margin-left: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.business-table-action-menu__link {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-table-action-menu__item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.business-table-card-body {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100% - 56px);
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
> .flex-1 {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.el-card {
|
.el-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -484,3 +524,44 @@ html .el-collapse {
|
|||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.el-message {
|
||||||
|
min-width: 280px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: $radius;
|
||||||
|
box-shadow: 0 6px 16px rgb(0 0 0 / 15%);
|
||||||
|
|
||||||
|
.el-message__content {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message__closeBtn {
|
||||||
|
color: rgb(255 255 255 / 80%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message__closeBtn:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--success {
|
||||||
|
background-color: var(--el-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--info {
|
||||||
|
background-color: var(--el-color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warning {
|
||||||
|
background-color: var(--el-color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--error {
|
||||||
|
background-color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,4 +89,7 @@ export const themeSettings: App.Theme.ThemeSetting = {
|
|||||||
*
|
*
|
||||||
* If publish new version, use `overrideThemeSettings` to override certain theme settings
|
* If publish new version, use `overrideThemeSettings` to override certain theme settings
|
||||||
*/
|
*/
|
||||||
export const overrideThemeSettings: Partial<App.Theme.ThemeSetting> = {};
|
// 系统固定亮色主题:切换入口已全部移除,发新版时把老用户缓存的暗色设置刷回亮色
|
||||||
|
export const overrideThemeSettings: Partial<App.Theme.ThemeSetting> = {
|
||||||
|
themeScheme: 'light'
|
||||||
|
};
|
||||||
|
|||||||
35
src/typings/api/auth.d.ts
vendored
35
src/typings/api/auth.d.ts
vendored
@@ -13,8 +13,43 @@ declare namespace Api {
|
|||||||
interface UserInfo {
|
interface UserInfo {
|
||||||
userId: string;
|
userId: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
|
nickname: string;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
buttons: string[];
|
buttons: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MyProfileDetail {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
deptId?: string | null;
|
||||||
|
deptName?: string | null;
|
||||||
|
positionId?: string | null;
|
||||||
|
positionName?: string | null;
|
||||||
|
company?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
mobile?: string | null;
|
||||||
|
sex?: Api.SystemManage.UserGender | null;
|
||||||
|
avatar?: string | null;
|
||||||
|
roles: Api.SystemManage.RoleSimple[];
|
||||||
|
dept?: Api.SystemManage.DeptSimple | null;
|
||||||
|
position?: Api.SystemManage.PostSimple | null;
|
||||||
|
loginIp?: string | null;
|
||||||
|
loginDate?: string | null;
|
||||||
|
createTime?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateMyProfileParams {
|
||||||
|
nickname?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
mobile?: string | null;
|
||||||
|
sex?: Api.SystemManage.UserGender | null;
|
||||||
|
avatar?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateMyPasswordParams {
|
||||||
|
oldPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/typings/api/common.d.ts
vendored
16
src/typings/api/common.d.ts
vendored
@@ -31,6 +31,22 @@ declare namespace Api {
|
|||||||
*/
|
*/
|
||||||
type EnableStatus = '1' | '2';
|
type EnableStatus = '1' | '2';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表项「当前登录用户在该对象的角色」(产品 / 项目列表共用)。
|
||||||
|
*
|
||||||
|
* 后端只读计算字段,随登录身份变化:同一份列表不同账号看到的内容不同;无角色为 []。
|
||||||
|
* 提交 / 更新接口不需要回传它。
|
||||||
|
*/
|
||||||
|
interface CurrentUserRole {
|
||||||
|
/**
|
||||||
|
* 角色稳定标识(程序判断用,不随中文名变化)。
|
||||||
|
* 例:product_manager / project_manager / developer / tester / watcher / creator / implicit_observer。
|
||||||
|
*/
|
||||||
|
roleKey: string;
|
||||||
|
/** 角色中文名(直接展示) */
|
||||||
|
roleName: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** common record */
|
/** common record */
|
||||||
type CommonRecord<T = any> = {
|
type CommonRecord<T = any> = {
|
||||||
/** record id */
|
/** record id */
|
||||||
|
|||||||
12
src/typings/api/dict.d.ts
vendored
12
src/typings/api/dict.d.ts
vendored
@@ -55,6 +55,10 @@ declare namespace Api {
|
|||||||
sort: number;
|
sort: number;
|
||||||
/** status: 0 enabled, 1 disabled */
|
/** status: 0 enabled, 1 disabled */
|
||||||
status: DictStatus;
|
status: DictStatus;
|
||||||
|
/** 颜色(hex,#xxxxxx);nullable,无值时前端按默认渲染 */
|
||||||
|
colorType?: string | null;
|
||||||
|
/** 精确颜色(hex,#xxxxxx);存在时优先于 colorType,用于 colorType 落到语义色无法区分的场景 */
|
||||||
|
cssClass?: string | null;
|
||||||
/** remark */
|
/** remark */
|
||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
/** create time */
|
/** create time */
|
||||||
@@ -73,6 +77,12 @@ declare namespace Api {
|
|||||||
dictType?: string;
|
dictType?: string;
|
||||||
/** status: 0 enabled, 1 disabled */
|
/** status: 0 enabled, 1 disabled */
|
||||||
status?: DictStatus;
|
status?: DictStatus;
|
||||||
|
/** 颜色(hex,#xxxxxx);nullable,无值时前端按默认渲染 */
|
||||||
|
colorType?: string | null;
|
||||||
|
/** 精确颜色(hex,#xxxxxx);存在时优先于 colorType */
|
||||||
|
cssClass?: string | null;
|
||||||
|
/** 备注,可用于下拉中文释义展示 */
|
||||||
|
remark?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** frontend runtime dict cache map */
|
/** frontend runtime dict cache map */
|
||||||
@@ -82,7 +92,7 @@ declare namespace Api {
|
|||||||
type DictDataSearchParams = CommonType.RecordNullable<Pick<DictData, 'label' | 'dictType' | 'status'>> & PageParams;
|
type DictDataSearchParams = CommonType.RecordNullable<Pick<DictData, 'label' | 'dictType' | 'status'>> & PageParams;
|
||||||
|
|
||||||
/** dict data save params */
|
/** dict data save params */
|
||||||
type SaveDictDataParams = Pick<DictData, 'label' | 'value' | 'dictType' | 'sort' | 'status'> & {
|
type SaveDictDataParams = Pick<DictData, 'label' | 'value' | 'dictType' | 'sort' | 'status' | 'colorType'> & {
|
||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
101
src/typings/api/infra.d.ts
vendored
Normal file
101
src/typings/api/infra.d.ts
vendored
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
declare namespace Api {
|
||||||
|
/**
|
||||||
|
* namespace Infra
|
||||||
|
*
|
||||||
|
* backend api module: "project/status/*"
|
||||||
|
*/
|
||||||
|
namespace Infra {
|
||||||
|
type CommonStatus = 0 | 1;
|
||||||
|
|
||||||
|
interface PageParams {
|
||||||
|
pageNo: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageResult<T = any> {
|
||||||
|
total: number;
|
||||||
|
list: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ObjectStatusModel {
|
||||||
|
id: string;
|
||||||
|
objectType: string;
|
||||||
|
statusCode: string;
|
||||||
|
statusName: string;
|
||||||
|
sort: number;
|
||||||
|
status: CommonStatus;
|
||||||
|
initialFlag: boolean;
|
||||||
|
terminalFlag: boolean;
|
||||||
|
allowEdit: boolean;
|
||||||
|
progressExcludedFlag: boolean;
|
||||||
|
allowCreateProject: boolean;
|
||||||
|
allowCreateRequirement: boolean;
|
||||||
|
remark?: string | null;
|
||||||
|
creator?: string | null;
|
||||||
|
createTime: string;
|
||||||
|
updater?: string | null;
|
||||||
|
updateTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ObjectStatusModelSearchParams = CommonType.RecordNullable<
|
||||||
|
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
||||||
|
Pick<ObjectStatusModel, 'objectType' | 'status' | 'initialFlag' | 'terminalFlag'> & {
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
type SaveObjectStatusModelParams = Pick<
|
||||||
|
ObjectStatusModel,
|
||||||
|
| 'objectType'
|
||||||
|
| 'statusCode'
|
||||||
|
| 'statusName'
|
||||||
|
| 'sort'
|
||||||
|
| 'status'
|
||||||
|
| 'initialFlag'
|
||||||
|
| 'terminalFlag'
|
||||||
|
| 'allowEdit'
|
||||||
|
| 'progressExcludedFlag'
|
||||||
|
| 'allowCreateProject'
|
||||||
|
| 'allowCreateRequirement'
|
||||||
|
> & {
|
||||||
|
remark?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ObjectStatusModelList = PageResult<ObjectStatusModel>;
|
||||||
|
|
||||||
|
interface ObjectStatusTransition {
|
||||||
|
id: string;
|
||||||
|
objectType: string;
|
||||||
|
actionCode: string;
|
||||||
|
actionName: string;
|
||||||
|
fromStatusCode: string;
|
||||||
|
fromStatusName?: string | null;
|
||||||
|
toStatusCode: string;
|
||||||
|
toStatusName?: string | null;
|
||||||
|
needReason: boolean;
|
||||||
|
status: CommonStatus;
|
||||||
|
remark?: string | null;
|
||||||
|
creator?: string | null;
|
||||||
|
createTime: string;
|
||||||
|
updater?: string | null;
|
||||||
|
updateTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ObjectStatusTransitionSearchParams = CommonType.RecordNullable<
|
||||||
|
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
||||||
|
Pick<
|
||||||
|
ObjectStatusTransition,
|
||||||
|
'objectType' | 'fromStatusCode' | 'toStatusCode' | 'status' | 'actionCode' | 'actionName'
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
type SaveObjectStatusTransitionParams = Pick<
|
||||||
|
ObjectStatusTransition,
|
||||||
|
'objectType' | 'actionCode' | 'actionName' | 'fromStatusCode' | 'toStatusCode' | 'needReason' | 'status'
|
||||||
|
> & {
|
||||||
|
remark?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ObjectStatusTransitionList = PageResult<ObjectStatusTransition>;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/typings/api/notice.d.ts
vendored
Normal file
24
src/typings/api/notice.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
declare namespace Api {
|
||||||
|
/**
|
||||||
|
* namespace Notice
|
||||||
|
*
|
||||||
|
* backend api module: "notice"(通知公告)
|
||||||
|
*/
|
||||||
|
namespace Notice {
|
||||||
|
/** 公告(ID 在 API 适配层已统一为 string) */
|
||||||
|
interface Notice {
|
||||||
|
/** 公告编号 */
|
||||||
|
id: string;
|
||||||
|
/** 公告标题 */
|
||||||
|
title: string;
|
||||||
|
/** 公告类型,字典 system_notice_type */
|
||||||
|
type: number;
|
||||||
|
/** 公告内容(富文本 / 纯文本,由录入决定) */
|
||||||
|
content: string;
|
||||||
|
/** 状态:0 开启 / 1 关闭 */
|
||||||
|
status: number;
|
||||||
|
/** 创建时间 */
|
||||||
|
createTime: string | number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/typings/api/notify-message.d.ts
vendored
Normal file
46
src/typings/api/notify-message.d.ts
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
declare namespace Api {
|
||||||
|
/**
|
||||||
|
* namespace NotifyMessage
|
||||||
|
*
|
||||||
|
* backend api module: "notify-message"(站内信 · 我的收件箱)
|
||||||
|
*/
|
||||||
|
namespace NotifyMessage {
|
||||||
|
interface PageParams {
|
||||||
|
pageNo: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageResult<T = any> {
|
||||||
|
total: number;
|
||||||
|
list: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 站内信(铃铛 / 收件箱展示用;ID 在 API 适配层已统一为 string) */
|
||||||
|
interface NotifyMessage {
|
||||||
|
/** 站内信编号(雪花 Long,按 string 接收) */
|
||||||
|
id: string;
|
||||||
|
/** 发送人名称(模板配置的发件人显示名) */
|
||||||
|
templateNickname: string;
|
||||||
|
/** 最终消息正文(占位符已渲染,直接展示) */
|
||||||
|
templateContent: string;
|
||||||
|
/** 消息类型,字典 system_notify_template_type */
|
||||||
|
templateType: number;
|
||||||
|
/** 消息等级(字典 notify_message_level,1=普通 2=提醒 3=警告 4=严重,数字越大越紧急);老消息缺省为普通(1) */
|
||||||
|
level: number;
|
||||||
|
/** 是否已读 */
|
||||||
|
readStatus: boolean;
|
||||||
|
/** 阅读时间;未读为 null */
|
||||||
|
readTime: string | number | null;
|
||||||
|
/** 收到时间 */
|
||||||
|
createTime: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 我的站内信分页查询参数 */
|
||||||
|
interface MyPageParams extends PageParams {
|
||||||
|
/** true 只看已读 / false 只看未读 / 不传 = 全部 */
|
||||||
|
readStatus?: boolean;
|
||||||
|
/** 关键字,后端对消息正文模糊匹配;不传或空串 = 不过滤 */
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/typings/api/overtime-application.d.ts
vendored
Normal file
112
src/typings/api/overtime-application.d.ts
vendored
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
declare namespace Api {
|
||||||
|
namespace OvertimeApplication {
|
||||||
|
interface PageParams {
|
||||||
|
pageNo: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OvertimeApplicationStatusCode = 'pending' | 'approved' | 'rejected';
|
||||||
|
|
||||||
|
type OvertimeApplicationActionType = 'submit' | 'resubmit' | 'approve' | 'reject';
|
||||||
|
|
||||||
|
interface OvertimeApplication {
|
||||||
|
id: string;
|
||||||
|
applicantId: string;
|
||||||
|
applicantName: string;
|
||||||
|
overtimeDate: string;
|
||||||
|
overtimeDuration: string;
|
||||||
|
overtimeReason: string;
|
||||||
|
overtimeContent: string;
|
||||||
|
approverId: string;
|
||||||
|
approverName: string;
|
||||||
|
statusCode: OvertimeApplicationStatusCode;
|
||||||
|
statusName: string;
|
||||||
|
allowEdit: boolean;
|
||||||
|
terminal: boolean;
|
||||||
|
approvalComment?: string | null;
|
||||||
|
submitTime: string;
|
||||||
|
approvalTime?: string | null;
|
||||||
|
createTime: string;
|
||||||
|
updateTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type OvertimeApplicationSearchParams = CommonType.RecordNullable<
|
||||||
|
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||||
|
applicantIds: string[] | null;
|
||||||
|
keyword: string;
|
||||||
|
applicantName: string;
|
||||||
|
approverId: string;
|
||||||
|
approverName: string;
|
||||||
|
statusCode: OvertimeApplicationStatusCode;
|
||||||
|
overtimeDate: string[];
|
||||||
|
createTime: string[];
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface OvertimeApplicationPageResult {
|
||||||
|
total: number;
|
||||||
|
list: OvertimeApplication[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaveOvertimeApplicationParams {
|
||||||
|
overtimeDate: string;
|
||||||
|
overtimeDuration: string;
|
||||||
|
overtimeReason: string;
|
||||||
|
overtimeContent: string;
|
||||||
|
approverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusActionParams {
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OvertimeApplicationBatchActionParams {
|
||||||
|
ids: string[];
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OvertimeApplicationBatchFailItem {
|
||||||
|
id: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OvertimeApplicationBatchActionResult {
|
||||||
|
successCount: number;
|
||||||
|
failCount: number;
|
||||||
|
failItems: OvertimeApplicationBatchFailItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OvertimeApplicationApprovalRecord {
|
||||||
|
id: string;
|
||||||
|
overtimeApplicationId: string;
|
||||||
|
statusLogId: string;
|
||||||
|
approvalRound: number;
|
||||||
|
conclusion: string;
|
||||||
|
opinion?: string | null;
|
||||||
|
auditorUserId: string;
|
||||||
|
auditorName: string;
|
||||||
|
createTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OvertimeApplicationStatusDict {
|
||||||
|
statusCode: string;
|
||||||
|
statusName: string;
|
||||||
|
sort: number;
|
||||||
|
initialFlag: boolean;
|
||||||
|
terminalFlag: boolean;
|
||||||
|
allowEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamOvertimeSummaryParams {
|
||||||
|
month?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamOvertimeSummary {
|
||||||
|
month: string;
|
||||||
|
totalApplicationCount: number;
|
||||||
|
pendingCount: number;
|
||||||
|
approvedCount: number;
|
||||||
|
rejectedCount: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
231
src/typings/api/performance.d.ts
vendored
Normal file
231
src/typings/api/performance.d.ts
vendored
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
declare namespace Api {
|
||||||
|
namespace Performance {
|
||||||
|
namespace Common {
|
||||||
|
interface PageParams {
|
||||||
|
pageNo: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageResult<T> {
|
||||||
|
total: number;
|
||||||
|
list: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type SheetStatusCode = 'draft' | 'sent' | 'confirmed' | 'rejected' | string;
|
||||||
|
type SheetActionCode = 'send' | 'resend' | 'confirm' | 'reject' | string;
|
||||||
|
type RemindType = 'pending_confirm' | 'pending_send';
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Template {
|
||||||
|
interface ScoreCellMapping {
|
||||||
|
actualScoreTotalCell?: string | null;
|
||||||
|
baseScoreTotalCell?: string | null;
|
||||||
|
extraScoreTotalCell?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Template {
|
||||||
|
id: string;
|
||||||
|
templateName: string;
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
versionNo: number;
|
||||||
|
activeFlag: boolean;
|
||||||
|
uploadUserId: string;
|
||||||
|
uploadUserName: string;
|
||||||
|
uploadTime: string;
|
||||||
|
remark?: string | null;
|
||||||
|
scoreCellMapping?: ScoreCellMapping | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchParams = CommonType.RecordNullable<
|
||||||
|
Common.PageParams & {
|
||||||
|
templateName: string;
|
||||||
|
activeFlag: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface UploadParams {
|
||||||
|
templateName: string;
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
activeFlag?: boolean | null;
|
||||||
|
remark?: string | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Sheet {
|
||||||
|
interface Sheet {
|
||||||
|
id: string;
|
||||||
|
periodMonth: string;
|
||||||
|
employeeId: string;
|
||||||
|
employeeName: string;
|
||||||
|
employeeDeptId: string;
|
||||||
|
employeeDeptName: string;
|
||||||
|
deptOrgType: string;
|
||||||
|
managerId: string;
|
||||||
|
managerName: string;
|
||||||
|
templateId: string;
|
||||||
|
fileId?: string | null;
|
||||||
|
fileName?: string | null;
|
||||||
|
fileVersion: number;
|
||||||
|
statusCode: Common.SheetStatusCode;
|
||||||
|
statusName: string;
|
||||||
|
actualScoreTotal?: string | number | null;
|
||||||
|
baseScoreTotal?: string | number | null;
|
||||||
|
extraScoreTotal?: string | number | null;
|
||||||
|
sentTime?: string | null;
|
||||||
|
confirmedTime?: string | null;
|
||||||
|
rejectedTime?: string | null;
|
||||||
|
lastStatusReason?: string | null;
|
||||||
|
createTime?: string | null;
|
||||||
|
updateTime?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchParams = CommonType.RecordNullable<
|
||||||
|
Common.PageParams & {
|
||||||
|
employeeIds: string[];
|
||||||
|
periodMonthRange: string[];
|
||||||
|
employeeId: string;
|
||||||
|
employeeName: string;
|
||||||
|
employeeDeptId: string;
|
||||||
|
employeeDeptName: string;
|
||||||
|
managerId: string;
|
||||||
|
managerName: string;
|
||||||
|
statusCode: Common.SheetStatusCode;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface CreateParams {
|
||||||
|
periodMonth: string;
|
||||||
|
employeeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExcelUpdateParams {
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
fileVersion: number;
|
||||||
|
actualScoreTotal: string | number;
|
||||||
|
baseScoreTotal: string | number;
|
||||||
|
extraScoreTotal: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusActionParams {
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchDownloadParams {
|
||||||
|
ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusDict {
|
||||||
|
statusCode: Common.SheetStatusCode;
|
||||||
|
statusName: string;
|
||||||
|
sort: number;
|
||||||
|
initialFlag: boolean;
|
||||||
|
terminalFlag: boolean;
|
||||||
|
allowEdit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusTransition {
|
||||||
|
actionCode: Common.SheetActionCode;
|
||||||
|
actionName: string;
|
||||||
|
fromStatusCode: Common.SheetStatusCode;
|
||||||
|
toStatusCode: Common.SheetStatusCode;
|
||||||
|
needReason: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusLog {
|
||||||
|
id: string;
|
||||||
|
sheetId: string;
|
||||||
|
actionType: Common.SheetActionCode;
|
||||||
|
fromStatus?: Common.SheetStatusCode | null;
|
||||||
|
toStatus?: Common.SheetStatusCode | null;
|
||||||
|
reason?: string | null;
|
||||||
|
operatorUserId: string;
|
||||||
|
operatorName: string;
|
||||||
|
periodMonthSnapshot: string;
|
||||||
|
employeeNameSnapshot: string;
|
||||||
|
remark?: string | null;
|
||||||
|
createTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResponseRecord {
|
||||||
|
id: string;
|
||||||
|
sheetId: string;
|
||||||
|
statusLogId: string;
|
||||||
|
roundNo: number;
|
||||||
|
actionType: Common.SheetActionCode;
|
||||||
|
fromStatus: Common.SheetStatusCode;
|
||||||
|
toStatus: Common.SheetStatusCode;
|
||||||
|
opinion?: string | null;
|
||||||
|
responderUserId: string;
|
||||||
|
responderName: string;
|
||||||
|
createTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthlyResult {
|
||||||
|
sheetId?: string | null;
|
||||||
|
periodMonth: string;
|
||||||
|
employeeId: string;
|
||||||
|
actualScoreTotal?: string | number | null;
|
||||||
|
baseScoreTotal?: string | number | null;
|
||||||
|
extraScoreTotal?: string | number | null;
|
||||||
|
statusCode?: Common.SheetStatusCode | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Team {
|
||||||
|
interface SummaryParams {
|
||||||
|
periodMonthStart?: string | null;
|
||||||
|
periodMonthEnd?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingSendUser {
|
||||||
|
userId: string;
|
||||||
|
userNickname: string;
|
||||||
|
managerUserId: string;
|
||||||
|
managerName: string;
|
||||||
|
sheetId?: string | null;
|
||||||
|
statusCode?: Common.SheetStatusCode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingConfirmUser {
|
||||||
|
userId: string;
|
||||||
|
userNickname: string;
|
||||||
|
sheetId: string;
|
||||||
|
sentTime?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeptOrgAverage {
|
||||||
|
deptId: string;
|
||||||
|
deptName: string;
|
||||||
|
deptOrgType: string;
|
||||||
|
averageScore?: string | number | null;
|
||||||
|
confirmedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Summary {
|
||||||
|
periodMonthStart: string;
|
||||||
|
periodMonthEnd: string;
|
||||||
|
totalSheetCount: number;
|
||||||
|
pendingSendCount: number;
|
||||||
|
pendingConfirmCount: number;
|
||||||
|
confirmedRate: string | number;
|
||||||
|
pendingSendUsers: PendingSendUser[];
|
||||||
|
pendingConfirmUsers: PendingConfirmUser[];
|
||||||
|
deptOrgAverages: DeptOrgAverage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemindParams {
|
||||||
|
periodMonthStart?: string | null;
|
||||||
|
periodMonthEnd?: string | null;
|
||||||
|
remindType: Common.RemindType;
|
||||||
|
userIds?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemindResult {
|
||||||
|
remindedCount: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/typings/api/personal-item.d.ts
vendored
Normal file
99
src/typings/api/personal-item.d.ts
vendored
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
declare namespace Api {
|
||||||
|
namespace PersonalItem {
|
||||||
|
interface PageParams {
|
||||||
|
pageNo: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PersonalItemStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
|
interface PersonalItemLifecycleAction {
|
||||||
|
actionCode: string;
|
||||||
|
actionName: string;
|
||||||
|
needReason: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersonalItem {
|
||||||
|
id: string;
|
||||||
|
taskTitle: string;
|
||||||
|
type: string;
|
||||||
|
ownerId: string;
|
||||||
|
statusCode: PersonalItemStatusCode;
|
||||||
|
terminal?: boolean;
|
||||||
|
allowEdit?: boolean;
|
||||||
|
availableActions?: PersonalItemLifecycleAction[] | null;
|
||||||
|
progressRate: number;
|
||||||
|
totalSpentHours?: number | null;
|
||||||
|
plannedStartDate: string | null;
|
||||||
|
plannedEndDate: string | null;
|
||||||
|
actualStartDate: string | null;
|
||||||
|
actualEndDate: string | null;
|
||||||
|
taskDesc: string | null;
|
||||||
|
lastStatusReason: string | null;
|
||||||
|
attachments: Api.Project.AttachmentItem[] | null;
|
||||||
|
creator: string;
|
||||||
|
createTime: string;
|
||||||
|
updater: string;
|
||||||
|
updateTime: string;
|
||||||
|
deleted: boolean;
|
||||||
|
ownerName?: string | null;
|
||||||
|
ownerNickname?: string | null;
|
||||||
|
statusName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PersonalItemSearchParams = CommonType.RecordNullable<
|
||||||
|
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||||
|
keyword: string;
|
||||||
|
ownerId: string;
|
||||||
|
statusCode: PersonalItemStatusCode;
|
||||||
|
updateTime: string[];
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface PersonalItemPageResult {
|
||||||
|
total: number;
|
||||||
|
list: PersonalItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SavePersonalItemParams {
|
||||||
|
taskTitle: string;
|
||||||
|
type: string;
|
||||||
|
ownerId?: string;
|
||||||
|
executionId?: string | null;
|
||||||
|
progressRate?: number | null;
|
||||||
|
plannedStartDate: string | null;
|
||||||
|
plannedEndDate: string | null;
|
||||||
|
taskDesc: string | null;
|
||||||
|
attachments: Api.Project.AttachmentItem[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdatePersonalItemParams extends SavePersonalItemParams {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChangePersonalItemStatusParams {
|
||||||
|
actionCode: string;
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersonalItemExecutionOption {
|
||||||
|
executionId: string;
|
||||||
|
executionName: string;
|
||||||
|
projectId?: string | null;
|
||||||
|
projectName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchDeletePersonalItemParams {
|
||||||
|
ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BindPersonalItemExecutionParams {
|
||||||
|
ids: string[];
|
||||||
|
executionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PersonalItemWorklog = Api.Project.TaskWorklog;
|
||||||
|
type PersonalItemWorklogSearchParams = Api.Project.TaskWorklogSearchParams;
|
||||||
|
type SavePersonalItemWorklogParams = Api.Project.SaveTaskWorklogParams;
|
||||||
|
}
|
||||||
|
}
|
||||||
213
src/typings/api/product.d.ts
vendored
213
src/typings/api/product.d.ts
vendored
@@ -21,6 +21,29 @@ declare namespace Api {
|
|||||||
list: T[];
|
list: T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 入口页概览统计状态看板项(状态机全部启用状态,按 sort 升序,计数为 0 也返回;与项目域契约同构) */
|
||||||
|
interface OverviewStatusItem {
|
||||||
|
statusCode: string;
|
||||||
|
/** 状态展示名(状态机配置中文名,前端直接渲染,不做本地名称映射) */
|
||||||
|
statusName: string;
|
||||||
|
count: number;
|
||||||
|
sort: number;
|
||||||
|
/** 是否终态(状态机 terminal_flag) */
|
||||||
|
terminal: boolean;
|
||||||
|
/** 是否计入"全部";当前口径无排除项恒为 true(产品列表暂无"全部"视图,按同构契约返回) */
|
||||||
|
includeInAll: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 产品入口页概览统计 */
|
||||||
|
interface ProductOverviewSummary {
|
||||||
|
/** 产品状态数量映射,key 为后端状态编码(过渡兼容字段,前端迁移完成后由后端删除) */
|
||||||
|
statusCounts: Record<string, number>;
|
||||||
|
/** "全部"口径总数 = items 各状态 count 之和 */
|
||||||
|
total: number;
|
||||||
|
/** 状态看板项,覆盖状态机全部启用状态,按 sort 升序 */
|
||||||
|
items: OverviewStatusItem[];
|
||||||
|
}
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
/** 产品 ID */
|
/** 产品 ID */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -44,6 +67,8 @@ declare namespace Api {
|
|||||||
createTime: string;
|
createTime: string;
|
||||||
/** 更新时间 */
|
/** 更新时间 */
|
||||||
updateTime: string;
|
updateTime: string;
|
||||||
|
/** 当前登录用户在该产品的角色(后端只读计算字段,随登录身份变化;无角色为 []) */
|
||||||
|
currentUserRoles: Api.Common.CurrentUserRole[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductSettingBaseInfo {
|
interface ProductSettingBaseInfo {
|
||||||
@@ -166,8 +191,10 @@ declare namespace Api {
|
|||||||
|
|
||||||
type ProductSearchParams = CommonType.RecordNullable<
|
type ProductSearchParams = CommonType.RecordNullable<
|
||||||
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
||||||
Pick<Product, 'directionCode' | 'managerUserId' | 'statusCode'> & {
|
Pick<Product, 'directionCode' | 'managerUserId'> & {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
|
/** 状态编码来自状态机(overview-summary items 动态下发),不再用前端字面量联合约束 */
|
||||||
|
statusCode: string;
|
||||||
updateTime: string[];
|
updateTime: string[];
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
@@ -204,6 +231,32 @@ declare namespace Api {
|
|||||||
previousManagerRoleId?: string | null;
|
previousManagerRoleId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量新增产品成员参数
|
||||||
|
*
|
||||||
|
* 刻意不复用 CreateProductMemberParams:批量接口不承担「产品经理交接」语义,
|
||||||
|
* 后端兜底拒绝 roleId 为产品经理角色的项。
|
||||||
|
*/
|
||||||
|
interface BatchCreateProductMembersParams {
|
||||||
|
members: Array<{
|
||||||
|
userId: string;
|
||||||
|
roleId: string;
|
||||||
|
remark?: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 产品创建(含初始团队)原子接口参数
|
||||||
|
*
|
||||||
|
* 新增产品两步向导提交的载荷。经理成员也由前端聚合到 members 数组中。
|
||||||
|
*/
|
||||||
|
interface CreateProductWithTeamParams {
|
||||||
|
product: SaveProductParams;
|
||||||
|
members: CreateProductMemberParams[];
|
||||||
|
/** 关注人 user_id 数组(选填);后端按 (user, object, role) 三元组幂等写入 product_watcher 角色 */
|
||||||
|
watcherUserIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface UpdateProductMemberParams {
|
interface UpdateProductMemberParams {
|
||||||
roleId: string;
|
roleId: string;
|
||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
@@ -216,18 +269,37 @@ declare namespace Api {
|
|||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BatchInactiveProductMembersParams {
|
||||||
|
memberIds: string[];
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 产品需求相关类型定义 ==========
|
// ========== 产品需求相关类型定义 ==========
|
||||||
/** 需求状态编码 */
|
/** 需求状态编码 */
|
||||||
type RequirementStatusCode =
|
type RequirementStatusCode =
|
||||||
| 'pending_confirm'
|
| 'pending_claim'
|
||||||
| 'pending_review'
|
| 'pending_review'
|
||||||
| 'pending_dispatch'
|
| 'pending_dispatch'
|
||||||
|
| 'reviewed'
|
||||||
|
| 'review_rejected'
|
||||||
| 'implementing'
|
| 'implementing'
|
||||||
| 'accepted'
|
| 'accepted'
|
||||||
| 'closed'
|
| 'closed'
|
||||||
| 'rejected'
|
| 'rejected'
|
||||||
| 'cancelled';
|
| 'cancelled';
|
||||||
|
|
||||||
|
/** 需求状态动作编码 */
|
||||||
|
type RequirementStatusActionCode =
|
||||||
|
| 'claim_to_review'
|
||||||
|
| 'claim_to_dispatch'
|
||||||
|
| 'pass_review'
|
||||||
|
| 'reject_review'
|
||||||
|
| 'dispatch'
|
||||||
|
| 'cancel'
|
||||||
|
| 'accept'
|
||||||
|
| 'close'
|
||||||
|
| 'reject';
|
||||||
|
|
||||||
/** 需求来源类型 */
|
/** 需求来源类型 */
|
||||||
type RequirementSourceType = 'manual' | 'work_order';
|
type RequirementSourceType = 'manual' | 'work_order';
|
||||||
|
|
||||||
@@ -250,18 +322,20 @@ declare namespace Api {
|
|||||||
moduleId: string;
|
moduleId: string;
|
||||||
/** 是否需要评审(0不需要;1需要) */
|
/** 是否需要评审(0不需要;1需要) */
|
||||||
reviewRequired: RequirementReviewRequired;
|
reviewRequired: RequirementReviewRequired;
|
||||||
/** 需求标题 */
|
/** 需求名称 */
|
||||||
title: string;
|
title: string;
|
||||||
/** 需求描述(富文本) */
|
/** 需求内容(富文本) */
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
/** 需求分类字典值 */
|
/** 附件列表 */
|
||||||
|
attachments?: Api.Project.AttachmentItem[] | null;
|
||||||
|
/** 需求类型字典值 */
|
||||||
category: string;
|
category: string;
|
||||||
/** 需求分类名称 */
|
/** 需求类型名称 */
|
||||||
categoryName?: string | null;
|
categoryName?: string | null;
|
||||||
/** 来源类型 */
|
/** 需求来源类型 */
|
||||||
sourceType: RequirementSourceType;
|
sourceType: RequirementSourceType;
|
||||||
/** 来源业务ID */
|
/** 来源业务编号 */
|
||||||
sourceBizId?: string | null;
|
sourceBizCode?: string | null;
|
||||||
/** 优先级(0低 1中 2高 3紧急) */
|
/** 优先级(0低 1中 2高 3紧急) */
|
||||||
priority: RequirementPriority;
|
priority: RequirementPriority;
|
||||||
/** 优先级名称 */
|
/** 优先级名称 */
|
||||||
@@ -280,12 +354,12 @@ declare namespace Api {
|
|||||||
currentHandlerUserId?: string | null;
|
currentHandlerUserId?: string | null;
|
||||||
/** 当前处理人姓名 */
|
/** 当前处理人姓名 */
|
||||||
currentHandlerUserNickname?: string | null;
|
currentHandlerUserNickname?: string | null;
|
||||||
/** 默认实现项目编号 */
|
/** 默认关联项目编号 */
|
||||||
implementProjectId?: string | null;
|
implementProjectId?: string | null;
|
||||||
/** 实现项目名称 */
|
/** 默认关联项目名称 */
|
||||||
implementProjectName?: string | null;
|
implementProjectName?: string | null;
|
||||||
/** 预期完成时间 */
|
/** 预期完成日期 */
|
||||||
completionDate: string;
|
expectedTime?: string | null;
|
||||||
/** 排序值 */
|
/** 排序值 */
|
||||||
sort: number;
|
sort: number;
|
||||||
/** 创建时间 */
|
/** 创建时间 */
|
||||||
@@ -294,8 +368,6 @@ declare namespace Api {
|
|||||||
updateTime: string;
|
updateTime: string;
|
||||||
/** 子需求列表(树形结构) */
|
/** 子需求列表(树形结构) */
|
||||||
children?: Requirement[];
|
children?: Requirement[];
|
||||||
/** 是否为终态 */
|
|
||||||
terminal?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 需求模块实体 ==========
|
// ========== 需求模块实体 ==========
|
||||||
@@ -332,25 +404,103 @@ declare namespace Api {
|
|||||||
initialFlag: boolean;
|
initialFlag: boolean;
|
||||||
/** 是否终态 */
|
/** 是否终态 */
|
||||||
terminalFlag: boolean;
|
terminalFlag: boolean;
|
||||||
|
/** 是否允许编辑 */
|
||||||
|
allowEdit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 需求生命周期 ==========
|
|
||||||
|
|
||||||
interface RequirementLifecycleAction {
|
interface RequirementLifecycleAction {
|
||||||
actionCode: string;
|
actionCode: RequirementStatusActionCode;
|
||||||
actionName: string;
|
actionName: string;
|
||||||
toStatusCode: string;
|
toStatusCode: string;
|
||||||
toStatusName: string;
|
toStatusName: string;
|
||||||
needReason: boolean;
|
needReason: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RequirementLifecycleInfo {
|
interface RequirementBatchReqVO {
|
||||||
statusCode: RequirementStatusCode;
|
productId: string;
|
||||||
statusName?: string | null;
|
requirementIds: string[];
|
||||||
lastStatusReason?: string | null;
|
}
|
||||||
terminal: boolean;
|
|
||||||
allowEdit: boolean;
|
interface RequirementAllowedTransitionBatchRespVO {
|
||||||
availableActions: RequirementLifecycleAction[];
|
requirementId: string;
|
||||||
|
transitions: RequirementLifecycleAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequirementHasDispatchedBatchRespVO {
|
||||||
|
requirementId: string;
|
||||||
|
hasDispatched: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProductRequirementDashboardRecentChangeActionType = 'create' | 'delete' | 'status_terminal';
|
||||||
|
|
||||||
|
interface ProductRequirementDashboardSummary {
|
||||||
|
/** 当前产品下所有未删除需求数,包括根需求和子需求 */
|
||||||
|
total: number;
|
||||||
|
/** 待认领、待评审、待指派的需求数 */
|
||||||
|
todo: number;
|
||||||
|
/** 待认领需求数 */
|
||||||
|
pendingClaim: number;
|
||||||
|
/** 待评审需求数 */
|
||||||
|
pendingReview: number;
|
||||||
|
/** 待指派需求数 */
|
||||||
|
pendingDispatch: number;
|
||||||
|
/** 已验收或已关闭需求数 */
|
||||||
|
completed: number;
|
||||||
|
/** 完成率,0-100 */
|
||||||
|
completionRate: number;
|
||||||
|
/** P0/P1 且待处理的需求数 */
|
||||||
|
highPriorityTodo: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductRequirementDashboardRecentChange {
|
||||||
|
id: string;
|
||||||
|
requirementId?: string | null;
|
||||||
|
title: string;
|
||||||
|
actionType: ProductRequirementDashboardRecentChangeActionType;
|
||||||
|
actionLabel: string;
|
||||||
|
content: string;
|
||||||
|
occurredAt: string;
|
||||||
|
operatorUserId?: string | null;
|
||||||
|
operatorName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductRequirementDashboard {
|
||||||
|
summary: ProductRequirementDashboardSummary;
|
||||||
|
recentChanges: ProductRequirementDashboardRecentChange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequirementReviewConclusion = 0 | 1;
|
||||||
|
|
||||||
|
interface RequirementReviewAttendeeItem {
|
||||||
|
userId: string;
|
||||||
|
nickname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequirementReview {
|
||||||
|
id: string;
|
||||||
|
objectType: 'product_requirement';
|
||||||
|
requirementId: string;
|
||||||
|
operatorId: string;
|
||||||
|
conclusion: RequirementReviewConclusion;
|
||||||
|
reviewContent?: string | null;
|
||||||
|
requirementEstimatedHours?: number | string | null;
|
||||||
|
attendees?: RequirementReviewAttendeeItem[];
|
||||||
|
attachments?: Api.Project.AttachmentItem[] | null;
|
||||||
|
reviewTime?: string | null;
|
||||||
|
createTime?: string;
|
||||||
|
updateTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequirementReviewSubmitParams {
|
||||||
|
productId: string;
|
||||||
|
requirementId: string;
|
||||||
|
operatorId: string;
|
||||||
|
conclusion: RequirementReviewConclusion;
|
||||||
|
reviewContent?: string | null;
|
||||||
|
requirementEstimatedHours?: number | string | null;
|
||||||
|
attendees?: RequirementReviewAttendeeItem[];
|
||||||
|
attachments?: Api.Project.AttachmentItem[] | null;
|
||||||
|
reviewTime?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 请求参数类型 ==========
|
// ========== 请求参数类型 ==========
|
||||||
@@ -360,7 +510,7 @@ declare namespace Api {
|
|||||||
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
||||||
Pick<
|
Pick<
|
||||||
Requirement,
|
Requirement,
|
||||||
'moduleId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceType'
|
'moduleId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceBizCode'
|
||||||
> & {
|
> & {
|
||||||
productId: string;
|
productId: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -375,12 +525,16 @@ declare namespace Api {
|
|||||||
| 'reviewRequired'
|
| 'reviewRequired'
|
||||||
| 'title'
|
| 'title'
|
||||||
| 'description'
|
| 'description'
|
||||||
|
| 'attachments'
|
||||||
| 'category'
|
| 'category'
|
||||||
| 'priority'
|
| 'priority'
|
||||||
|
| 'sourceBizCode'
|
||||||
| 'proposerId'
|
| 'proposerId'
|
||||||
|
| 'proposerNickname'
|
||||||
| 'currentHandlerUserId'
|
| 'currentHandlerUserId'
|
||||||
|
| 'currentHandlerUserNickname'
|
||||||
| 'implementProjectId'
|
| 'implementProjectId'
|
||||||
| 'completionDate'
|
| 'expectedTime'
|
||||||
| 'sort'
|
| 'sort'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@@ -412,11 +566,14 @@ declare namespace Api {
|
|||||||
| 'reviewRequired'
|
| 'reviewRequired'
|
||||||
| 'title'
|
| 'title'
|
||||||
| 'description'
|
| 'description'
|
||||||
|
| 'attachments'
|
||||||
| 'category'
|
| 'category'
|
||||||
| 'priority'
|
| 'priority'
|
||||||
| 'proposerId'
|
| 'proposerId'
|
||||||
|
| 'proposerNickname'
|
||||||
| 'currentHandlerUserId'
|
| 'currentHandlerUserId'
|
||||||
| 'completionDate'
|
| 'currentHandlerUserNickname'
|
||||||
|
| 'expectedTime'
|
||||||
| 'sort'
|
| 'sort'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|||||||
1378
src/typings/api/project.d.ts
vendored
Normal file
1378
src/typings/api/project.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user