Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
4
.env
4
.env
@@ -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
|
||||||
|
|||||||
@@ -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,107 @@ 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_my-weekly': {
|
||||||
|
icon: 'mdi:calendar-week-outline',
|
||||||
|
order: 2,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
|
'personal-center_my-monthly': {
|
||||||
|
icon: 'mdi:calendar-month-outline',
|
||||||
|
order: 3,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
|
'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_pending-approval': {
|
||||||
|
icon: 'mdi:check-decagram-outline',
|
||||||
|
order: 6,
|
||||||
|
keepAlive: true
|
||||||
|
},
|
||||||
system: {
|
system: {
|
||||||
icon: 'carbon:cloud-service-management',
|
icon: 'carbon:cloud-service-management',
|
||||||
order: 9,
|
order: 9,
|
||||||
@@ -81,6 +187,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-05-19T07:08:28.081Z",
|
||||||
"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_my-weekly",
|
||||||
|
"path": "/personal-center/my-weekly",
|
||||||
|
"component": "view.personal-center_my-weekly",
|
||||||
|
"title": "我的周报",
|
||||||
|
"routeTitle": "personal-center_my-weekly",
|
||||||
|
"i18nKey": "route.personal-center_my-weekly",
|
||||||
|
"icon": "mdi:calendar-week-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-weekly",
|
||||||
|
"icon": "mdi:calendar-week-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_my-monthly",
|
||||||
|
"path": "/personal-center/my-monthly",
|
||||||
|
"component": "view.personal-center_my-monthly",
|
||||||
|
"title": "我的月报",
|
||||||
|
"routeTitle": "personal-center_my-monthly",
|
||||||
|
"i18nKey": "route.personal-center_my-monthly",
|
||||||
|
"icon": "mdi:calendar-month-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 2,
|
||||||
|
"hideInMenu": false,
|
||||||
|
"keepAlive": true,
|
||||||
|
"activeMenu": null,
|
||||||
|
"multiTab": false,
|
||||||
|
"fixedIndexInTab": null,
|
||||||
|
"redirect": null,
|
||||||
|
"props": null,
|
||||||
|
"meta": {
|
||||||
|
"title": "我的月报",
|
||||||
|
"i18nKey": "route.personal-center_my-monthly",
|
||||||
|
"icon": "mdi:calendar-month-outline",
|
||||||
|
"localIcon": null,
|
||||||
|
"order": 2,
|
||||||
|
"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": 3,
|
||||||
|
"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": 3,
|
||||||
|
"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": 4,
|
||||||
|
"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": 4,
|
||||||
|
"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": 5,
|
||||||
|
"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": 5,
|
||||||
|
"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 数据源中
|
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
"@antv/g2": "5.4.0",
|
"@antv/g2": "5.4.0",
|
||||||
"@antv/g6": "5.0.49",
|
"@antv/g6": "5.0.49",
|
||||||
"@better-scroll/core": "2.5.1",
|
"@better-scroll/core": "2.5.1",
|
||||||
|
"@iconify-vue/mingcute": "^1.0.5",
|
||||||
"@iconify/vue": "5.0.0",
|
"@iconify/vue": "5.0.0",
|
||||||
"@sa/axios": "workspace:*",
|
"@sa/axios": "workspace:*",
|
||||||
"@sa/color": "workspace:*",
|
"@sa/color": "workspace:*",
|
||||||
@@ -54,6 +55,8 @@
|
|||||||
"@visactor/vue-vtable": "1.19.8",
|
"@visactor/vue-vtable": "1.19.8",
|
||||||
"@vueuse/components": "13.9.0",
|
"@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",
|
||||||
"clipboard": "2.0.11",
|
"clipboard": "2.0.11",
|
||||||
"dayjs": "1.11.18",
|
"dayjs": "1.11.18",
|
||||||
"defu": "^6.1.4",
|
"defu": "^6.1.4",
|
||||||
@@ -77,7 +80,6 @@
|
|||||||
"vue-i18n": "11.1.11",
|
"vue-i18n": "11.1.11",
|
||||||
"vue-pdf-embed": "2.1.3",
|
"vue-pdf-embed": "2.1.3",
|
||||||
"vue-router": "4.5.1",
|
"vue-router": "4.5.1",
|
||||||
"wangeditor": "4.7.15",
|
|
||||||
"xgplayer": "3.0.23",
|
"xgplayer": "3.0.23",
|
||||||
"xlsx": "0.18.5"
|
"xlsx": "0.18.5"
|
||||||
},
|
},
|
||||||
@@ -89,7 +91,6 @@
|
|||||||
"@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/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",
|
||||||
|
|||||||
466
pnpm-lock.yaml
generated
466
pnpm-lock.yaml
generated
@@ -20,6 +20,9 @@ importers:
|
|||||||
'@better-scroll/core':
|
'@better-scroll/core':
|
||||||
specifier: 2.5.1
|
specifier: 2.5.1
|
||||||
version: 2.5.1
|
version: 2.5.1
|
||||||
|
'@iconify-vue/mingcute':
|
||||||
|
specifier: ^1.0.5
|
||||||
|
version: 1.0.5(vue@3.5.20(typescript@5.8.3))
|
||||||
'@iconify/vue':
|
'@iconify/vue':
|
||||||
specifier: 5.0.0
|
specifier: 5.0.0
|
||||||
version: 5.0.0(vue@3.5.20(typescript@5.8.3))
|
version: 5.0.0(vue@3.5.20(typescript@5.8.3))
|
||||||
@@ -59,6 +62,12 @@ importers:
|
|||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: 13.9.0
|
specifier: 13.9.0
|
||||||
version: 13.9.0(vue@3.5.20(typescript@5.8.3))
|
version: 13.9.0(vue@3.5.20(typescript@5.8.3))
|
||||||
|
'@wangeditor/editor':
|
||||||
|
specifier: ^5.1.23
|
||||||
|
version: 5.1.23
|
||||||
|
'@wangeditor/editor-for-vue':
|
||||||
|
specifier: ^5.1.12
|
||||||
|
version: 5.1.12(@wangeditor/editor@5.1.23)(vue@3.5.20(typescript@5.8.3))
|
||||||
clipboard:
|
clipboard:
|
||||||
specifier: 2.0.11
|
specifier: 2.0.11
|
||||||
version: 2.0.11
|
version: 2.0.11
|
||||||
@@ -128,9 +137,6 @@ importers:
|
|||||||
vue-router:
|
vue-router:
|
||||||
specifier: 4.5.1
|
specifier: 4.5.1
|
||||||
version: 4.5.1(vue@3.5.20(typescript@5.8.3))
|
version: 4.5.1(vue@3.5.20(typescript@5.8.3))
|
||||||
wangeditor:
|
|
||||||
specifier: 4.7.15
|
|
||||||
version: 4.7.15
|
|
||||||
xgplayer:
|
xgplayer:
|
||||||
specifier: 3.0.23
|
specifier: 3.0.23
|
||||||
version: 3.0.23(core-js@3.49.0)
|
version: 3.0.23(core-js@3.49.0)
|
||||||
@@ -159,9 +165,6 @@ importers:
|
|||||||
'@types/bmapgl':
|
'@types/bmapgl':
|
||||||
specifier: 0.0.7
|
specifier: 0.0.7
|
||||||
version: 0.0.7
|
version: 0.0.7
|
||||||
'@types/dompurify':
|
|
||||||
specifier: 3.2.0
|
|
||||||
version: 3.2.0
|
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 24.3.0
|
specifier: 24.3.0
|
||||||
version: 24.3.0
|
version: 24.3.0
|
||||||
@@ -560,10 +563,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/core': ^7.0.0-0
|
'@babel/core': ^7.0.0-0
|
||||||
|
|
||||||
'@babel/runtime-corejs3@7.29.2':
|
|
||||||
resolution: {integrity: sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw==}
|
|
||||||
engines: {node: '>=6.9.0'}
|
|
||||||
|
|
||||||
'@babel/runtime@7.29.2':
|
'@babel/runtime@7.29.2':
|
||||||
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -858,6 +857,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
|
|
||||||
|
'@iconify-vue/mingcute@1.0.5':
|
||||||
|
resolution: {integrity: sha512-9g/iEU2XdobbfS6vKp01btfBlPiMqlqa+GujwYOc5WVJierhKt3dF0+tamomdk9vYcIsJiGcqOaKvrJF0g6prA==}
|
||||||
|
|
||||||
|
'@iconify/css-vue@1.0.2':
|
||||||
|
resolution: {integrity: sha512-KXG9zXTMmJLi1AF2ket+YWUGdSqFvIMSnCO789uOVpba6SZhqeUttu0JIaEcq2dNlt4oonwdtMyerkpRkAFYhw==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: '>=3.0.0'
|
||||||
|
|
||||||
'@iconify/json@2.2.380':
|
'@iconify/json@2.2.380':
|
||||||
resolution: {integrity: sha512-+Al/Q+mMB/nLz/tawmJEOkCs6+RKKVUS/Yg9I80h2yRpu0kIzxVLQRfF0NifXz/fH92vDVXbS399wio4lMVF4Q==}
|
resolution: {integrity: sha512-+Al/Q+mMB/nLz/tawmJEOkCs6+RKKVUS/Yg9I80h2yRpu0kIzxVLQRfF0NifXz/fH92vDVXbS399wio4lMVF4Q==}
|
||||||
|
|
||||||
@@ -1399,6 +1406,9 @@ packages:
|
|||||||
'@sxzz/popperjs-es@2.11.8':
|
'@sxzz/popperjs-es@2.11.8':
|
||||||
resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==}
|
resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==}
|
||||||
|
|
||||||
|
'@transloadit/prettier-bytes@0.0.7':
|
||||||
|
resolution: {integrity: sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==}
|
||||||
|
|
||||||
'@turf/boolean-clockwise@6.5.0':
|
'@turf/boolean-clockwise@6.5.0':
|
||||||
resolution: {integrity: sha512-45+C7LC5RMbRWrxh3Z0Eihsc8db1VGBO5d9BLTOAwU4jR6SgsunTfRWR16X7JUwIDYlCVEmnjcXJNi/kIU3VIw==}
|
resolution: {integrity: sha512-45+C7LC5RMbRWrxh3Z0Eihsc8db1VGBO5d9BLTOAwU4jR6SgsunTfRWR16X7JUwIDYlCVEmnjcXJNi/kIU3VIw==}
|
||||||
|
|
||||||
@@ -1489,10 +1499,6 @@ packages:
|
|||||||
'@types/d3-timer@3.0.2':
|
'@types/d3-timer@3.0.2':
|
||||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||||
|
|
||||||
'@types/dompurify@3.2.0':
|
|
||||||
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
|
|
||||||
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
|
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
'@types/eslint-scope@3.7.7':
|
||||||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
||||||
|
|
||||||
@@ -1502,6 +1508,9 @@ packages:
|
|||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@types/event-emitter@0.3.5':
|
||||||
|
resolution: {integrity: sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==}
|
||||||
|
|
||||||
'@types/geojson@7946.0.16':
|
'@types/geojson@7946.0.16':
|
||||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||||
|
|
||||||
@@ -1791,6 +1800,23 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@uppy/companion-client@2.2.2':
|
||||||
|
resolution: {integrity: sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==}
|
||||||
|
|
||||||
|
'@uppy/core@2.3.4':
|
||||||
|
resolution: {integrity: sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==}
|
||||||
|
|
||||||
|
'@uppy/store-default@2.1.1':
|
||||||
|
resolution: {integrity: sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==}
|
||||||
|
|
||||||
|
'@uppy/utils@4.1.3':
|
||||||
|
resolution: {integrity: sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==}
|
||||||
|
|
||||||
|
'@uppy/xhr-upload@2.1.3':
|
||||||
|
resolution: {integrity: sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@uppy/core': ^2.3.3
|
||||||
|
|
||||||
'@visactor/vchart-theme@1.12.2':
|
'@visactor/vchart-theme@1.12.2':
|
||||||
resolution: {integrity: sha512-r298TUdK+CKbHGVYWgQnNSEB5uqpFvF2/aMNZ/2POQnd2CovAPJOx2nTE6hAcOn8rra2FwJ2xF8AyP1O5OhrTw==}
|
resolution: {integrity: sha512-r298TUdK+CKbHGVYWgQnNSEB5uqpFvF2/aMNZ/2POQnd2CovAPJOx2nTE6hAcOn8rra2FwJ2xF8AyP1O5OhrTw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2015,6 +2041,93 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.5.0
|
vue: ^3.5.0
|
||||||
|
|
||||||
|
'@wangeditor/basic-modules@1.1.7':
|
||||||
|
resolution: {integrity: sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@wangeditor/core': 1.x
|
||||||
|
dom7: ^3.0.0
|
||||||
|
lodash.throttle: ^4.1.1
|
||||||
|
nanoid: ^3.2.0
|
||||||
|
slate: ^0.72.0
|
||||||
|
snabbdom: ^3.1.0
|
||||||
|
|
||||||
|
'@wangeditor/code-highlight@1.0.3':
|
||||||
|
resolution: {integrity: sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@wangeditor/core': 1.x
|
||||||
|
dom7: ^3.0.0
|
||||||
|
slate: ^0.72.0
|
||||||
|
snabbdom: ^3.1.0
|
||||||
|
|
||||||
|
'@wangeditor/core@1.1.19':
|
||||||
|
resolution: {integrity: sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@uppy/core': ^2.1.1
|
||||||
|
'@uppy/xhr-upload': ^2.0.3
|
||||||
|
dom7: ^3.0.0
|
||||||
|
is-hotkey: ^0.2.0
|
||||||
|
lodash.camelcase: ^4.3.0
|
||||||
|
lodash.clonedeep: ^4.5.0
|
||||||
|
lodash.debounce: ^4.0.8
|
||||||
|
lodash.foreach: ^4.5.0
|
||||||
|
lodash.isequal: ^4.5.0
|
||||||
|
lodash.throttle: ^4.1.1
|
||||||
|
lodash.toarray: ^4.4.0
|
||||||
|
nanoid: ^3.2.0
|
||||||
|
slate: ^0.72.0
|
||||||
|
snabbdom: ^3.1.0
|
||||||
|
|
||||||
|
'@wangeditor/editor-for-vue@5.1.12':
|
||||||
|
resolution: {integrity: sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@wangeditor/editor': '>=5.1.0'
|
||||||
|
vue: ^3.0.5
|
||||||
|
|
||||||
|
'@wangeditor/editor@5.1.23':
|
||||||
|
resolution: {integrity: sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==}
|
||||||
|
|
||||||
|
'@wangeditor/list-module@1.0.5':
|
||||||
|
resolution: {integrity: sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@wangeditor/core': 1.x
|
||||||
|
dom7: ^3.0.0
|
||||||
|
slate: ^0.72.0
|
||||||
|
snabbdom: ^3.1.0
|
||||||
|
|
||||||
|
'@wangeditor/table-module@1.1.4':
|
||||||
|
resolution: {integrity: sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==}
|
||||||
|
peerDependencies:
|
||||||
|
'@wangeditor/core': 1.x
|
||||||
|
dom7: ^3.0.0
|
||||||
|
lodash.isequal: ^4.5.0
|
||||||
|
lodash.throttle: ^4.1.1
|
||||||
|
nanoid: ^3.2.0
|
||||||
|
slate: ^0.72.0
|
||||||
|
snabbdom: ^3.1.0
|
||||||
|
|
||||||
|
'@wangeditor/upload-image-module@1.0.2':
|
||||||
|
resolution: {integrity: sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@uppy/core': ^2.0.3
|
||||||
|
'@uppy/xhr-upload': ^2.0.3
|
||||||
|
'@wangeditor/basic-modules': 1.x
|
||||||
|
'@wangeditor/core': 1.x
|
||||||
|
dom7: ^3.0.0
|
||||||
|
lodash.foreach: ^4.5.0
|
||||||
|
slate: ^0.72.0
|
||||||
|
snabbdom: ^3.1.0
|
||||||
|
|
||||||
|
'@wangeditor/video-module@1.1.4':
|
||||||
|
resolution: {integrity: sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@uppy/core': ^2.1.4
|
||||||
|
'@uppy/xhr-upload': ^2.0.7
|
||||||
|
'@wangeditor/core': 1.x
|
||||||
|
dom7: ^3.0.0
|
||||||
|
nanoid: ^3.2.0
|
||||||
|
slate: ^0.72.0
|
||||||
|
snabbdom: ^3.1.0
|
||||||
|
|
||||||
'@webassemblyjs/ast@1.14.1':
|
'@webassemblyjs/ast@1.14.1':
|
||||||
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
||||||
|
|
||||||
@@ -2434,6 +2547,9 @@ packages:
|
|||||||
component-emitter@1.3.1:
|
component-emitter@1.3.1:
|
||||||
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
||||||
|
|
||||||
|
compute-scroll-into-view@1.0.20:
|
||||||
|
resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
|
||||||
|
|
||||||
concat-map@0.0.1:
|
concat-map@0.0.1:
|
||||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||||
|
|
||||||
@@ -2476,9 +2592,6 @@ packages:
|
|||||||
core-js-compat@3.49.0:
|
core-js-compat@3.49.0:
|
||||||
resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==}
|
resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==}
|
||||||
|
|
||||||
core-js-pure@3.49.0:
|
|
||||||
resolution: {integrity: sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==}
|
|
||||||
|
|
||||||
core-js@3.49.0:
|
core-js@3.49.0:
|
||||||
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
|
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
|
||||||
|
|
||||||
@@ -2800,6 +2913,9 @@ packages:
|
|||||||
dom-serializer@1.4.1:
|
dom-serializer@1.4.1:
|
||||||
resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==}
|
resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==}
|
||||||
|
|
||||||
|
dom7@3.0.0:
|
||||||
|
resolution: {integrity: sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==}
|
||||||
|
|
||||||
domelementtype@1.3.1:
|
domelementtype@1.3.1:
|
||||||
resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==}
|
resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==}
|
||||||
|
|
||||||
@@ -3439,6 +3555,9 @@ packages:
|
|||||||
hookable@5.5.3:
|
hookable@5.5.3:
|
||||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||||
|
|
||||||
|
html-void-elements@2.0.1:
|
||||||
|
resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==}
|
||||||
|
|
||||||
html2canvas@1.4.1:
|
html2canvas@1.4.1:
|
||||||
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
@@ -3450,6 +3569,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
|
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
|
|
||||||
|
i18next@20.6.1:
|
||||||
|
resolution: {integrity: sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==}
|
||||||
|
|
||||||
iconv-lite@0.4.24:
|
iconv-lite@0.4.24:
|
||||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3486,6 +3608,9 @@ packages:
|
|||||||
immediate@3.0.6:
|
immediate@3.0.6:
|
||||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||||
|
|
||||||
|
immer@9.0.21:
|
||||||
|
resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
|
||||||
|
|
||||||
immutable@5.1.5:
|
immutable@5.1.5:
|
||||||
resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==}
|
resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==}
|
||||||
|
|
||||||
@@ -3606,6 +3731,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
is-hotkey@0.2.0:
|
||||||
|
resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==}
|
||||||
|
|
||||||
is-inside-container@1.0.0:
|
is-inside-container@1.0.0:
|
||||||
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
|
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
|
||||||
engines: {node: '>=14.16'}
|
engines: {node: '>=14.16'}
|
||||||
@@ -3643,6 +3771,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==}
|
resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
is-plain-object@5.0.0:
|
||||||
|
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
is-regex@1.2.1:
|
is-regex@1.2.1:
|
||||||
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
|
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3682,6 +3814,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
|
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
is-url@1.2.4:
|
||||||
|
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||||
|
|
||||||
is-weakmap@2.0.2:
|
is-weakmap@2.0.2:
|
||||||
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
|
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3874,9 +4009,31 @@ packages:
|
|||||||
lodash: '*'
|
lodash: '*'
|
||||||
lodash-es: '*'
|
lodash-es: '*'
|
||||||
|
|
||||||
|
lodash.camelcase@4.3.0:
|
||||||
|
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
||||||
|
|
||||||
|
lodash.clonedeep@4.5.0:
|
||||||
|
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
|
||||||
|
|
||||||
|
lodash.debounce@4.0.8:
|
||||||
|
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||||
|
|
||||||
|
lodash.foreach@4.5.0:
|
||||||
|
resolution: {integrity: sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==}
|
||||||
|
|
||||||
|
lodash.isequal@4.5.0:
|
||||||
|
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||||
|
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||||
|
|
||||||
lodash.merge@4.6.2:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
|
lodash.throttle@4.1.1:
|
||||||
|
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
|
||||||
|
|
||||||
|
lodash.toarray@4.4.0:
|
||||||
|
resolution: {integrity: sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==}
|
||||||
|
|
||||||
lodash@4.17.21:
|
lodash@4.17.21:
|
||||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||||
|
|
||||||
@@ -3953,6 +4110,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
mime-match@1.0.2:
|
||||||
|
resolution: {integrity: sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==}
|
||||||
|
|
||||||
mime-types@2.1.35:
|
mime-types@2.1.35:
|
||||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -4021,6 +4181,9 @@ packages:
|
|||||||
muggle-string@0.4.1:
|
muggle-string@0.4.1:
|
||||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||||
|
|
||||||
|
namespace-emitter@2.0.1:
|
||||||
|
resolution: {integrity: sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==}
|
||||||
|
|
||||||
nanoid@3.3.11:
|
nanoid@3.3.11:
|
||||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
@@ -4336,6 +4499,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-spBB5sgC4cv2YcW03f/IAUN1pgDJWNWD8FzkyY4mArLUMJW+KlQhlmUdKAHQuPfb00Jl5xIfImeOsf6YL8QK7Q==}
|
resolution: {integrity: sha512-spBB5sgC4cv2YcW03f/IAUN1pgDJWNWD8FzkyY4mArLUMJW+KlQhlmUdKAHQuPfb00Jl5xIfImeOsf6YL8QK7Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
preact@10.29.1:
|
||||||
|
resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==}
|
||||||
|
|
||||||
prelude-ls@1.2.1:
|
prelude-ls@1.2.1:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -4366,6 +4532,10 @@ packages:
|
|||||||
print-js@1.6.0:
|
print-js@1.6.0:
|
||||||
resolution: {integrity: sha512-BfnOIzSKbqGRtO4o0rnj/K3681BSd2QUrsIZy/+WdCIugjIswjmx3lDEZpXB2ruGf9d4b3YNINri81+J0FsBWg==}
|
resolution: {integrity: sha512-BfnOIzSKbqGRtO4o0rnj/K3681BSd2QUrsIZy/+WdCIugjIswjmx3lDEZpXB2ruGf9d4b3YNINri81+J0FsBWg==}
|
||||||
|
|
||||||
|
prismjs@1.30.0:
|
||||||
|
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
progress@2.0.3:
|
progress@2.0.3:
|
||||||
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
@@ -4547,6 +4717,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
|
resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
|
||||||
engines: {node: '>= 10.13.0'}
|
engines: {node: '>= 10.13.0'}
|
||||||
|
|
||||||
|
scroll-into-view-if-needed@2.2.31:
|
||||||
|
resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==}
|
||||||
|
|
||||||
select@1.1.2:
|
select@1.1.2:
|
||||||
resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==}
|
resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==}
|
||||||
|
|
||||||
@@ -4647,9 +4820,21 @@ packages:
|
|||||||
sisteransi@1.0.5:
|
sisteransi@1.0.5:
|
||||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||||
|
|
||||||
|
slate-history@0.66.0:
|
||||||
|
resolution: {integrity: sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==}
|
||||||
|
peerDependencies:
|
||||||
|
slate: '>=0.65.3'
|
||||||
|
|
||||||
|
slate@0.72.8:
|
||||||
|
resolution: {integrity: sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==}
|
||||||
|
|
||||||
slice-source@0.4.1:
|
slice-source@0.4.1:
|
||||||
resolution: {integrity: sha512-YiuPbxpCj4hD9Qs06hGAz/OZhQ0eDuALN0lRWJez0eD/RevzKqGdUx1IOMUnXgpr+sXZLq3g8ERwbAH0bCb8vg==}
|
resolution: {integrity: sha512-YiuPbxpCj4hD9Qs06hGAz/OZhQ0eDuALN0lRWJez0eD/RevzKqGdUx1IOMUnXgpr+sXZLq3g8ERwbAH0bCb8vg==}
|
||||||
|
|
||||||
|
snabbdom@3.6.3:
|
||||||
|
resolution: {integrity: sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==}
|
||||||
|
engines: {node: '>=12.17.0'}
|
||||||
|
|
||||||
snapdragon-node@2.1.1:
|
snapdragon-node@2.1.1:
|
||||||
resolution: {integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==}
|
resolution: {integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -4697,6 +4882,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
|
ssr-window@3.0.0:
|
||||||
|
resolution: {integrity: sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==}
|
||||||
|
|
||||||
stable-hash-x@0.2.0:
|
stable-hash-x@0.2.0:
|
||||||
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
|
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -4859,6 +5047,9 @@ packages:
|
|||||||
tiny-invariant@1.3.3:
|
tiny-invariant@1.3.3:
|
||||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
|
tiny-warning@1.0.3:
|
||||||
|
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
|
||||||
|
|
||||||
tinyexec@1.0.4:
|
tinyexec@1.0.4:
|
||||||
resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
|
resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -5231,9 +5422,6 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
wangeditor@4.7.15:
|
|
||||||
resolution: {integrity: sha512-aPTdREd8BxXVyJ5MI+LU83FQ7u1EPd341iXIorRNYSOvoimNoZ4nPg+yn3FGbB93/owEa6buLw8wdhYnMCJQLg==}
|
|
||||||
|
|
||||||
watchpack@2.5.1:
|
watchpack@2.5.1:
|
||||||
resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
|
resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -5276,6 +5464,9 @@ packages:
|
|||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
wildcard@1.1.2:
|
||||||
|
resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==}
|
||||||
|
|
||||||
wmf@1.0.2:
|
wmf@1.0.2:
|
||||||
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
@@ -5742,10 +5933,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@babel/runtime-corejs3@7.29.2':
|
|
||||||
dependencies:
|
|
||||||
core-js-pure: 3.49.0
|
|
||||||
|
|
||||||
'@babel/runtime@7.29.2': {}
|
'@babel/runtime@7.29.2': {}
|
||||||
|
|
||||||
'@babel/template@7.28.6':
|
'@babel/template@7.28.6':
|
||||||
@@ -5997,6 +6184,17 @@ snapshots:
|
|||||||
|
|
||||||
'@humanwhocodes/retry@0.4.3': {}
|
'@humanwhocodes/retry@0.4.3': {}
|
||||||
|
|
||||||
|
'@iconify-vue/mingcute@1.0.5(vue@3.5.20(typescript@5.8.3))':
|
||||||
|
dependencies:
|
||||||
|
'@iconify/css-vue': 1.0.2(vue@3.5.20(typescript@5.8.3))
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- vue
|
||||||
|
|
||||||
|
'@iconify/css-vue@1.0.2(vue@3.5.20(typescript@5.8.3))':
|
||||||
|
dependencies:
|
||||||
|
'@iconify/types': 2.0.0
|
||||||
|
vue: 3.5.20(typescript@5.8.3)
|
||||||
|
|
||||||
'@iconify/json@2.2.380':
|
'@iconify/json@2.2.380':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
@@ -6419,6 +6617,8 @@ snapshots:
|
|||||||
|
|
||||||
'@sxzz/popperjs-es@2.11.8': {}
|
'@sxzz/popperjs-es@2.11.8': {}
|
||||||
|
|
||||||
|
'@transloadit/prettier-bytes@0.0.7': {}
|
||||||
|
|
||||||
'@turf/boolean-clockwise@6.5.0':
|
'@turf/boolean-clockwise@6.5.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@turf/helpers': 6.5.0
|
'@turf/helpers': 6.5.0
|
||||||
@@ -6510,10 +6710,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/d3-timer@3.0.2': {}
|
'@types/d3-timer@3.0.2': {}
|
||||||
|
|
||||||
'@types/dompurify@3.2.0':
|
|
||||||
dependencies:
|
|
||||||
dompurify: 3.2.6
|
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
'@types/eslint-scope@3.7.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/eslint': 9.6.1
|
'@types/eslint': 9.6.1
|
||||||
@@ -6526,6 +6722,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@types/event-emitter@0.3.5': {}
|
||||||
|
|
||||||
'@types/geojson@7946.0.16': {}
|
'@types/geojson@7946.0.16': {}
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
@@ -6855,6 +7053,35 @@ snapshots:
|
|||||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@uppy/companion-client@2.2.2':
|
||||||
|
dependencies:
|
||||||
|
'@uppy/utils': 4.1.3
|
||||||
|
namespace-emitter: 2.0.1
|
||||||
|
|
||||||
|
'@uppy/core@2.3.4':
|
||||||
|
dependencies:
|
||||||
|
'@transloadit/prettier-bytes': 0.0.7
|
||||||
|
'@uppy/store-default': 2.1.1
|
||||||
|
'@uppy/utils': 4.1.3
|
||||||
|
lodash.throttle: 4.1.1
|
||||||
|
mime-match: 1.0.2
|
||||||
|
namespace-emitter: 2.0.1
|
||||||
|
nanoid: 3.3.11
|
||||||
|
preact: 10.29.1
|
||||||
|
|
||||||
|
'@uppy/store-default@2.1.1': {}
|
||||||
|
|
||||||
|
'@uppy/utils@4.1.3':
|
||||||
|
dependencies:
|
||||||
|
lodash.throttle: 4.1.1
|
||||||
|
|
||||||
|
'@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4)':
|
||||||
|
dependencies:
|
||||||
|
'@uppy/companion-client': 2.2.2
|
||||||
|
'@uppy/core': 2.3.4
|
||||||
|
'@uppy/utils': 4.1.3
|
||||||
|
nanoid: 3.3.11
|
||||||
|
|
||||||
'@visactor/vchart-theme@1.12.2(@visactor/vchart@2.0.4)':
|
'@visactor/vchart-theme@1.12.2(@visactor/vchart@2.0.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@visactor/vchart': 2.0.4
|
'@visactor/vchart': 2.0.4
|
||||||
@@ -7284,6 +7511,114 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.20(typescript@5.8.3)
|
vue: 3.5.20(typescript@5.8.3)
|
||||||
|
|
||||||
|
'@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||||
|
dependencies:
|
||||||
|
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
dom7: 3.0.0
|
||||||
|
is-url: 1.2.4
|
||||||
|
lodash.throttle: 4.1.1
|
||||||
|
nanoid: 3.3.11
|
||||||
|
slate: 0.72.8
|
||||||
|
snabbdom: 3.6.3
|
||||||
|
|
||||||
|
'@wangeditor/code-highlight@1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||||
|
dependencies:
|
||||||
|
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
dom7: 3.0.0
|
||||||
|
prismjs: 1.30.0
|
||||||
|
slate: 0.72.8
|
||||||
|
snabbdom: 3.6.3
|
||||||
|
|
||||||
|
'@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||||
|
dependencies:
|
||||||
|
'@types/event-emitter': 0.3.5
|
||||||
|
'@uppy/core': 2.3.4
|
||||||
|
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
|
||||||
|
dom7: 3.0.0
|
||||||
|
event-emitter: 0.3.5
|
||||||
|
html-void-elements: 2.0.1
|
||||||
|
i18next: 20.6.1
|
||||||
|
is-hotkey: 0.2.0
|
||||||
|
lodash.camelcase: 4.3.0
|
||||||
|
lodash.clonedeep: 4.5.0
|
||||||
|
lodash.debounce: 4.0.8
|
||||||
|
lodash.foreach: 4.5.0
|
||||||
|
lodash.isequal: 4.5.0
|
||||||
|
lodash.throttle: 4.1.1
|
||||||
|
lodash.toarray: 4.4.0
|
||||||
|
nanoid: 3.3.11
|
||||||
|
scroll-into-view-if-needed: 2.2.31
|
||||||
|
slate: 0.72.8
|
||||||
|
slate-history: 0.66.0(slate@0.72.8)
|
||||||
|
snabbdom: 3.6.3
|
||||||
|
|
||||||
|
'@wangeditor/editor-for-vue@5.1.12(@wangeditor/editor@5.1.23)(vue@3.5.20(typescript@5.8.3))':
|
||||||
|
dependencies:
|
||||||
|
'@wangeditor/editor': 5.1.23
|
||||||
|
vue: 3.5.20(typescript@5.8.3)
|
||||||
|
|
||||||
|
'@wangeditor/editor@5.1.23':
|
||||||
|
dependencies:
|
||||||
|
'@uppy/core': 2.3.4
|
||||||
|
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
|
||||||
|
'@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
'@wangeditor/code-highlight': 1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
'@wangeditor/list-module': 1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
'@wangeditor/table-module': 1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
'@wangeditor/upload-image-module': 1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
'@wangeditor/video-module': 1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
dom7: 3.0.0
|
||||||
|
is-hotkey: 0.2.0
|
||||||
|
lodash.camelcase: 4.3.0
|
||||||
|
lodash.clonedeep: 4.5.0
|
||||||
|
lodash.debounce: 4.0.8
|
||||||
|
lodash.foreach: 4.5.0
|
||||||
|
lodash.isequal: 4.5.0
|
||||||
|
lodash.throttle: 4.1.1
|
||||||
|
lodash.toarray: 4.4.0
|
||||||
|
nanoid: 3.3.11
|
||||||
|
slate: 0.72.8
|
||||||
|
snabbdom: 3.6.3
|
||||||
|
|
||||||
|
'@wangeditor/list-module@1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||||
|
dependencies:
|
||||||
|
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
dom7: 3.0.0
|
||||||
|
slate: 0.72.8
|
||||||
|
snabbdom: 3.6.3
|
||||||
|
|
||||||
|
'@wangeditor/table-module@1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||||
|
dependencies:
|
||||||
|
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
dom7: 3.0.0
|
||||||
|
lodash.isequal: 4.5.0
|
||||||
|
lodash.throttle: 4.1.1
|
||||||
|
nanoid: 3.3.11
|
||||||
|
slate: 0.72.8
|
||||||
|
snabbdom: 3.6.3
|
||||||
|
|
||||||
|
'@wangeditor/upload-image-module@1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||||
|
dependencies:
|
||||||
|
'@uppy/core': 2.3.4
|
||||||
|
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
|
||||||
|
'@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
dom7: 3.0.0
|
||||||
|
lodash.foreach: 4.5.0
|
||||||
|
slate: 0.72.8
|
||||||
|
snabbdom: 3.6.3
|
||||||
|
|
||||||
|
'@wangeditor/video-module@1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3))(dom7@3.0.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)':
|
||||||
|
dependencies:
|
||||||
|
'@uppy/core': 2.3.4
|
||||||
|
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
|
||||||
|
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.3)
|
||||||
|
dom7: 3.0.0
|
||||||
|
nanoid: 3.3.11
|
||||||
|
slate: 0.72.8
|
||||||
|
snabbdom: 3.6.3
|
||||||
|
|
||||||
'@webassemblyjs/ast@1.14.1':
|
'@webassemblyjs/ast@1.14.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@webassemblyjs/helper-numbers': 1.13.2
|
'@webassemblyjs/helper-numbers': 1.13.2
|
||||||
@@ -7741,6 +8076,8 @@ snapshots:
|
|||||||
|
|
||||||
component-emitter@1.3.1: {}
|
component-emitter@1.3.1: {}
|
||||||
|
|
||||||
|
compute-scroll-into-view@1.0.20: {}
|
||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
|
|
||||||
concat-stream@1.4.11:
|
concat-stream@1.4.11:
|
||||||
@@ -7778,8 +8115,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.28.1
|
browserslist: 4.28.1
|
||||||
|
|
||||||
core-js-pure@3.49.0: {}
|
|
||||||
|
|
||||||
core-js@3.49.0: {}
|
core-js@3.49.0: {}
|
||||||
|
|
||||||
core-util-is@1.0.3: {}
|
core-util-is@1.0.3: {}
|
||||||
@@ -8093,6 +8428,10 @@ snapshots:
|
|||||||
domhandler: 4.3.1
|
domhandler: 4.3.1
|
||||||
entities: 2.2.0
|
entities: 2.2.0
|
||||||
|
|
||||||
|
dom7@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
ssr-window: 3.0.0
|
||||||
|
|
||||||
domelementtype@1.3.1: {}
|
domelementtype@1.3.1: {}
|
||||||
|
|
||||||
domelementtype@2.3.0: {}
|
domelementtype@2.3.0: {}
|
||||||
@@ -8895,6 +9234,8 @@ snapshots:
|
|||||||
|
|
||||||
hookable@5.5.3: {}
|
hookable@5.5.3: {}
|
||||||
|
|
||||||
|
html-void-elements@2.0.1: {}
|
||||||
|
|
||||||
html2canvas@1.4.1:
|
html2canvas@1.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
css-line-break: 2.1.0
|
css-line-break: 2.1.0
|
||||||
@@ -8911,6 +9252,10 @@ snapshots:
|
|||||||
|
|
||||||
human-signals@8.0.1: {}
|
human-signals@8.0.1: {}
|
||||||
|
|
||||||
|
i18next@20.6.1:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.2
|
||||||
|
|
||||||
iconv-lite@0.4.24:
|
iconv-lite@0.4.24:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@@ -8935,6 +9280,8 @@ snapshots:
|
|||||||
|
|
||||||
immediate@3.0.6: {}
|
immediate@3.0.6: {}
|
||||||
|
|
||||||
|
immer@9.0.21: {}
|
||||||
|
|
||||||
immutable@5.1.5: {}
|
immutable@5.1.5: {}
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
@@ -9052,6 +9399,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-extglob: 2.1.1
|
is-extglob: 2.1.1
|
||||||
|
|
||||||
|
is-hotkey@0.2.0: {}
|
||||||
|
|
||||||
is-inside-container@1.0.0:
|
is-inside-container@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-docker: 3.0.0
|
is-docker: 3.0.0
|
||||||
@@ -9079,6 +9428,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isobject: 3.0.1
|
isobject: 3.0.1
|
||||||
|
|
||||||
|
is-plain-object@5.0.0: {}
|
||||||
|
|
||||||
is-regex@1.2.1:
|
is-regex@1.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
@@ -9115,6 +9466,8 @@ snapshots:
|
|||||||
|
|
||||||
is-unicode-supported@2.1.0: {}
|
is-unicode-supported@2.1.0: {}
|
||||||
|
|
||||||
|
is-url@1.2.4: {}
|
||||||
|
|
||||||
is-weakmap@2.0.2: {}
|
is-weakmap@2.0.2: {}
|
||||||
|
|
||||||
is-weakref@1.1.1:
|
is-weakref@1.1.1:
|
||||||
@@ -9281,8 +9634,22 @@ snapshots:
|
|||||||
lodash: 4.17.23
|
lodash: 4.17.23
|
||||||
lodash-es: 4.17.23
|
lodash-es: 4.17.23
|
||||||
|
|
||||||
|
lodash.camelcase@4.3.0: {}
|
||||||
|
|
||||||
|
lodash.clonedeep@4.5.0: {}
|
||||||
|
|
||||||
|
lodash.debounce@4.0.8: {}
|
||||||
|
|
||||||
|
lodash.foreach@4.5.0: {}
|
||||||
|
|
||||||
|
lodash.isequal@4.5.0: {}
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
|
lodash.throttle@4.1.1: {}
|
||||||
|
|
||||||
|
lodash.toarray@4.4.0: {}
|
||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.17.21: {}
|
||||||
|
|
||||||
lodash@4.17.23: {}
|
lodash@4.17.23: {}
|
||||||
@@ -9363,6 +9730,10 @@ snapshots:
|
|||||||
|
|
||||||
mime-db@1.52.0: {}
|
mime-db@1.52.0: {}
|
||||||
|
|
||||||
|
mime-match@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
wildcard: 1.1.2
|
||||||
|
|
||||||
mime-types@2.1.35:
|
mime-types@2.1.35:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-db: 1.52.0
|
mime-db: 1.52.0
|
||||||
@@ -9430,6 +9801,8 @@ snapshots:
|
|||||||
|
|
||||||
muggle-string@0.4.1: {}
|
muggle-string@0.4.1: {}
|
||||||
|
|
||||||
|
namespace-emitter@2.0.1: {}
|
||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
nanoid@5.1.5: {}
|
nanoid@5.1.5: {}
|
||||||
@@ -9738,6 +10111,8 @@ snapshots:
|
|||||||
posthtml-parser: 0.2.1
|
posthtml-parser: 0.2.1
|
||||||
posthtml-render: 1.4.0
|
posthtml-render: 1.4.0
|
||||||
|
|
||||||
|
preact@10.29.1: {}
|
||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
prettier-linter-helpers@1.0.1:
|
prettier-linter-helpers@1.0.1:
|
||||||
@@ -9758,6 +10133,8 @@ snapshots:
|
|||||||
|
|
||||||
print-js@1.6.0: {}
|
print-js@1.6.0: {}
|
||||||
|
|
||||||
|
prismjs@1.30.0: {}
|
||||||
|
|
||||||
progress@2.0.3: {}
|
progress@2.0.3: {}
|
||||||
|
|
||||||
prompts@2.4.2:
|
prompts@2.4.2:
|
||||||
@@ -9973,6 +10350,10 @@ snapshots:
|
|||||||
ajv-formats: 2.1.1(ajv@8.18.0)
|
ajv-formats: 2.1.1(ajv@8.18.0)
|
||||||
ajv-keywords: 5.1.0(ajv@8.18.0)
|
ajv-keywords: 5.1.0(ajv@8.18.0)
|
||||||
|
|
||||||
|
scroll-into-view-if-needed@2.2.31:
|
||||||
|
dependencies:
|
||||||
|
compute-scroll-into-view: 1.0.20
|
||||||
|
|
||||||
select@1.1.2: {}
|
select@1.1.2: {}
|
||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
@@ -10094,8 +10475,21 @@ snapshots:
|
|||||||
|
|
||||||
sisteransi@1.0.5: {}
|
sisteransi@1.0.5: {}
|
||||||
|
|
||||||
|
slate-history@0.66.0(slate@0.72.8):
|
||||||
|
dependencies:
|
||||||
|
is-plain-object: 5.0.0
|
||||||
|
slate: 0.72.8
|
||||||
|
|
||||||
|
slate@0.72.8:
|
||||||
|
dependencies:
|
||||||
|
immer: 9.0.21
|
||||||
|
is-plain-object: 5.0.0
|
||||||
|
tiny-warning: 1.0.3
|
||||||
|
|
||||||
slice-source@0.4.1: {}
|
slice-source@0.4.1: {}
|
||||||
|
|
||||||
|
snabbdom@3.6.3: {}
|
||||||
|
|
||||||
snapdragon-node@2.1.1:
|
snapdragon-node@2.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
define-property: 1.0.0
|
define-property: 1.0.0
|
||||||
@@ -10150,6 +10544,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
frac: 1.1.2
|
frac: 1.1.2
|
||||||
|
|
||||||
|
ssr-window@3.0.0: {}
|
||||||
|
|
||||||
stable-hash-x@0.2.0: {}
|
stable-hash-x@0.2.0: {}
|
||||||
|
|
||||||
stable@0.1.8: {}
|
stable@0.1.8: {}
|
||||||
@@ -10320,6 +10716,8 @@ snapshots:
|
|||||||
|
|
||||||
tiny-invariant@1.3.3: {}
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
|
tiny-warning@1.0.3: {}
|
||||||
|
|
||||||
tinyexec@1.0.4: {}
|
tinyexec@1.0.4: {}
|
||||||
|
|
||||||
tinyglobby@0.2.15:
|
tinyglobby@0.2.15:
|
||||||
@@ -10753,12 +11151,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.8.3
|
typescript: 5.8.3
|
||||||
|
|
||||||
wangeditor@4.7.15:
|
|
||||||
dependencies:
|
|
||||||
'@babel/runtime': 7.29.2
|
|
||||||
'@babel/runtime-corejs3': 7.29.2
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
watchpack@2.5.1:
|
watchpack@2.5.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
glob-to-regexp: 0.4.1
|
glob-to-regexp: 0.4.1
|
||||||
@@ -10845,6 +11237,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
||||||
|
wildcard@1.1.2: {}
|
||||||
|
|
||||||
wmf@1.0.2: {}
|
wmf@1.0.2: {}
|
||||||
|
|
||||||
wolfy87-eventemitter@5.2.9: {}
|
wolfy87-eventemitter@5.2.9: {}
|
||||||
|
|||||||
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,13 @@
|
|||||||
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';
|
||||||
|
|
||||||
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 +18,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 +40,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 +60,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 +152,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')}
|
<icon-mdi-dots-horizontal class="business-table-action-icon" />
|
||||||
<icon-mdi-chevron-down class="text-14px" />
|
) : (
|
||||||
</span>
|
<span class="inline-flex items-center gap-4px">
|
||||||
|
{$t('common.more')}
|
||||||
|
<icon-mdi-chevron-down 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
329
src/components/custom/table-search-fields.vue
Normal file
329
src/components/custom/table-search-fields.vue
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
<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';
|
||||||
|
/** 占位列数,默认 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="date"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
clearable
|
||||||
|
:disabled="props.disabled"
|
||||||
|
value-format="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="daterange"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
clearable
|
||||||
|
:disabled="props.disabled"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
@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="date"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
clearable
|
||||||
|
:disabled="props.disabled"
|
||||||
|
value-format="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="daterange"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
clearable
|
||||||
|
:disabled="props.disabled"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
@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>
|
||||||
@@ -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,51 @@ 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';
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
77
src/constants/status-tag.ts
Normal file
77
src/constants/status-tag.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* 业务对象状态颜色(ElTag type)集中配置
|
||||||
|
*
|
||||||
|
* 各业务域的 statusCode → ElTag type 在此统一维护,避免散落在各业务模块。
|
||||||
|
* 未来若后端状态字典返回颜色字段,可在调用方优先取后端值,缺失时回退此映射。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type StatusTagType = 'primary' | 'success' | 'warning' | 'info' | 'danger';
|
||||||
|
|
||||||
|
export type StatusDomain =
|
||||||
|
| 'projectExecution'
|
||||||
|
| 'projectTask'
|
||||||
|
| 'executionAssignee'
|
||||||
|
| 'taskAssigneeMember'
|
||||||
|
| 'project'
|
||||||
|
| 'product'
|
||||||
|
| 'requirement'
|
||||||
|
| 'workOrder'
|
||||||
|
| 'personalItem';
|
||||||
|
|
||||||
|
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: {},
|
||||||
|
// 需求(待补全)
|
||||||
|
requirement: {},
|
||||||
|
// 工单(待补全)
|
||||||
|
workOrder: {},
|
||||||
|
// 个人事项
|
||||||
|
personalItem: {
|
||||||
|
pending: 'info',
|
||||||
|
active: 'primary',
|
||||||
|
completed: 'success',
|
||||||
|
cancelled: '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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,457 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useInfiniteScroll } from '@vueuse/core';
|
||||||
|
|
||||||
|
defineOptions({ name: 'NotificationBell' });
|
||||||
|
|
||||||
|
interface NotificationItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
timeLabel: string;
|
||||||
|
unread: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
// 通知 mock:扩到 60 条以演示分页 / 搜索;等真接口落地后整体迁移
|
||||||
|
function buildMockNotifications(): NotificationItem[] {
|
||||||
|
const titles = [
|
||||||
|
'你被指派为执行「迭代 24.06」负责人',
|
||||||
|
'任务「SSO 改造」状态变更:开发中 → 待验收',
|
||||||
|
'需求「多币种支持」评审通过',
|
||||||
|
'工单 #1042 已分派给你',
|
||||||
|
'需求「订单导出」被退回,请补充材料',
|
||||||
|
'@ 你的评论已被回复',
|
||||||
|
'项目「客户中心 2.0」周报已生成',
|
||||||
|
'工单 #1098 客户回复待处理',
|
||||||
|
'执行「迭代 24.05」已结束',
|
||||||
|
'需求「批量审批」分配给你'
|
||||||
|
];
|
||||||
|
const times = ['10min 前', '30min 前', '1h 前', '2h 前', '4h 前', '昨日', '前天', '3 天前', '1 周前', '2 周前'];
|
||||||
|
return Array.from({ length: 60 }, (_, i) => ({
|
||||||
|
id: `m${i + 1}`,
|
||||||
|
title: `${titles[i % titles.length]}(#${i + 1})`,
|
||||||
|
timeLabel: times[Math.floor(i / 6) % times.length],
|
||||||
|
unread: i < 14
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifications = ref<NotificationItem[]>(buildMockNotifications());
|
||||||
|
|
||||||
|
const unreadAll = computed(() => notifications.value.filter(n => n.unread));
|
||||||
|
const readAll = computed(() => notifications.value.filter(n => !n.unread));
|
||||||
|
const unreadCount = computed(() => unreadAll.value.length);
|
||||||
|
const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)));
|
||||||
|
|
||||||
|
const drawerOpen = ref(false);
|
||||||
|
const activeTab = ref<'unread' | 'read'>('unread');
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
|
||||||
|
function matchesKeyword(item: NotificationItem) {
|
||||||
|
const kw = searchKeyword.value.trim();
|
||||||
|
if (!kw) return true;
|
||||||
|
return item.title.toLowerCase().includes(kw.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredUnread = computed(() => unreadAll.value.filter(matchesKeyword));
|
||||||
|
const filteredRead = computed(() => readAll.value.filter(matchesKeyword));
|
||||||
|
|
||||||
|
const unreadPageSize = ref(PAGE_SIZE);
|
||||||
|
const readPageSize = ref(PAGE_SIZE);
|
||||||
|
|
||||||
|
const visibleUnread = computed(() => filteredUnread.value.slice(0, unreadPageSize.value));
|
||||||
|
const visibleRead = computed(() => filteredRead.value.slice(0, readPageSize.value));
|
||||||
|
|
||||||
|
const hasMoreUnread = computed(() => unreadPageSize.value < filteredUnread.value.length);
|
||||||
|
const hasMoreRead = computed(() => readPageSize.value < filteredRead.value.length);
|
||||||
|
|
||||||
|
watch(searchKeyword, () => {
|
||||||
|
unreadPageSize.value = PAGE_SIZE;
|
||||||
|
readPageSize.value = PAGE_SIZE;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 已读列表数量会因"标已读"动态增长 / 未读会缩小;切换 tab 不重置已展示页数,体感更自然
|
||||||
|
|
||||||
|
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null;
|
||||||
|
const unreadScrollbar = ref<ScrollbarRefValue>(null);
|
||||||
|
const readScrollbar = ref<ScrollbarRefValue>(null);
|
||||||
|
|
||||||
|
useInfiniteScroll(
|
||||||
|
() => unreadScrollbar.value?.wrapRef,
|
||||||
|
() => {
|
||||||
|
if (hasMoreUnread.value) unreadPageSize.value += PAGE_SIZE;
|
||||||
|
},
|
||||||
|
{ distance: 48 }
|
||||||
|
);
|
||||||
|
|
||||||
|
useInfiniteScroll(
|
||||||
|
() => readScrollbar.value?.wrapRef,
|
||||||
|
() => {
|
||||||
|
if (hasMoreRead.value) readPageSize.value += PAGE_SIZE;
|
||||||
|
},
|
||||||
|
{ distance: 48 }
|
||||||
|
);
|
||||||
|
|
||||||
|
function openDrawer() {
|
||||||
|
drawerOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
drawerOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markRead(item: NotificationItem) {
|
||||||
|
if (!item.unread) return;
|
||||||
|
item.unread = false;
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[notification] mark-read', item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAllRead() {
|
||||||
|
notifications.value.forEach(item => {
|
||||||
|
item.unread = false;
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[notification] mark-all-read');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openItem(item: NotificationItem) {
|
||||||
|
markRead(item);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn('[notification] open', item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrawerClosed() {
|
||||||
|
searchKeyword.value = '';
|
||||||
|
}
|
||||||
|
</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" :with-header="false" @closed="onDrawerClosed">
|
||||||
|
<div class="notification-bell__panel">
|
||||||
|
<header class="notification-bell__header">
|
||||||
|
<span class="notification-bell__title">
|
||||||
|
通知
|
||||||
|
<span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="notification-bell__header-actions">
|
||||||
|
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
|
||||||
|
<button class="notification-bell__close" type="button" aria-label="关闭" @click="closeDrawer">
|
||||||
|
<SvgIcon icon="mdi:close" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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">{{ filteredUnread.length }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
|
||||||
|
<ul v-if="visibleUnread.length > 0" class="notification-bell__list">
|
||||||
|
<li
|
||||||
|
v-for="row in visibleUnread"
|
||||||
|
:key="row.id"
|
||||||
|
class="notification-bell__row is-unread"
|
||||||
|
@click="openItem(row)"
|
||||||
|
>
|
||||||
|
<span class="notification-bell__row-dot" />
|
||||||
|
<div class="notification-bell__row-body">
|
||||||
|
<div class="notification-bell__row-title">{{ row.title }}</div>
|
||||||
|
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="notification-bell__empty">
|
||||||
|
{{ searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="visibleUnread.length > 0" class="notification-bell__footer-hint">
|
||||||
|
{{ hasMoreUnread ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</ElTabPane>
|
||||||
|
|
||||||
|
<ElTabPane name="read">
|
||||||
|
<template #label>
|
||||||
|
<span class="notification-bell__tab-label">
|
||||||
|
已读
|
||||||
|
<span class="notification-bell__tab-count">{{ filteredRead.length }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ElScrollbar ref="readScrollbar" class="notification-bell__scroll">
|
||||||
|
<ul v-if="visibleRead.length > 0" class="notification-bell__list">
|
||||||
|
<li v-for="row in visibleRead" :key="row.id" class="notification-bell__row" @click="openItem(row)">
|
||||||
|
<span class="notification-bell__row-dot" />
|
||||||
|
<div class="notification-bell__row-body">
|
||||||
|
<div class="notification-bell__row-title">{{ row.title }}</div>
|
||||||
|
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="notification-bell__empty">
|
||||||
|
{{ searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="visibleRead.length > 0" class="notification-bell__footer-hint">
|
||||||
|
{{ hasMoreRead ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
</ElTabPane>
|
||||||
|
</ElTabs>
|
||||||
|
</div>
|
||||||
|
</ElDrawer>
|
||||||
|
</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: 4px;
|
||||||
|
right: 4px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: var(--el-color-danger);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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__header-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__close {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
transition:
|
||||||
|
background-color 120ms ease,
|
||||||
|
color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__close:hover {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell__search {
|
||||||
|
padding: 12px 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;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
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-time {
|
||||||
|
margin-top: 4px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
</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' });
|
||||||
@@ -48,6 +49,7 @@ const { isFullscreen, toggle } = useFullscreen();
|
|||||||
<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>
|
||||||
@@ -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({
|
||||||
@@ -108,7 +109,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"
|
||||||
@@ -208,28 +209,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;
|
||||||
|
|||||||
@@ -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,7 +158,25 @@ 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',
|
||||||
|
ticket: 'Ticket',
|
||||||
|
'ticket_my-submitted': 'My Submitted',
|
||||||
|
'ticket_my-pending': 'My Pending',
|
||||||
|
metrics: 'Metrics',
|
||||||
|
'metrics_project-progress': 'Project Progress',
|
||||||
|
'metrics_member-efficiency': 'Member Efficiency',
|
||||||
|
metrics_worktime: 'Worktime',
|
||||||
|
'personal-center': 'Personal Center',
|
||||||
|
'personal-center_my-profile': 'My Profile',
|
||||||
|
'personal-center_my-item': 'My Items',
|
||||||
|
'personal-center_my-weekly': 'My Weekly Report',
|
||||||
|
'personal-center_my-monthly': 'My Monthly Report',
|
||||||
|
'personal-center_my-performance': 'My Performance',
|
||||||
|
'personal-center_my-application': 'My Application',
|
||||||
|
'personal-center_pending-approval': 'Pending Approval',
|
||||||
|
infra: 'Infra',
|
||||||
|
'infra_state-machine': 'State Machine',
|
||||||
|
'infra_rd-code': 'R&D Code',
|
||||||
function: 'System Function',
|
function: 'System Function',
|
||||||
function_tab: 'Tab',
|
function_tab: 'Tab',
|
||||||
'function_multi-tab': 'Multi Tab',
|
'function_multi-tab': 'Multi Tab',
|
||||||
@@ -169,12 +187,19 @@ const local: App.I18n.Schema = {
|
|||||||
function_request: 'Request',
|
function_request: 'Request',
|
||||||
'function_toggle-auth': 'Toggle Auth',
|
'function_toggle-auth': 'Toggle Auth',
|
||||||
'function_super-page': 'Super Admin Visible',
|
'function_super-page': 'Super Admin Visible',
|
||||||
product: 'Product Management',
|
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',
|
||||||
@@ -192,9 +217,6 @@ const local: App.I18n.Schema = {
|
|||||||
plugin_charts_echarts: 'ECharts',
|
plugin_charts_echarts: 'ECharts',
|
||||||
plugin_charts_antv: 'AntV',
|
plugin_charts_antv: 'AntV',
|
||||||
plugin_charts_vchart: 'VChart',
|
plugin_charts_vchart: 'VChart',
|
||||||
plugin_editor: 'Editor',
|
|
||||||
plugin_editor_quill: 'Quill',
|
|
||||||
plugin_editor_markdown: 'Markdown',
|
|
||||||
plugin_icon: 'Icon',
|
plugin_icon: 'Icon',
|
||||||
plugin_map: 'Map',
|
plugin_map: 'Map',
|
||||||
plugin_print: 'Print',
|
plugin_print: 'Print',
|
||||||
@@ -488,6 +510,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'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const local: App.I18n.Schema = {
|
|||||||
trigger: '触发',
|
trigger: '触发',
|
||||||
update: '更新',
|
update: '更新',
|
||||||
updateSuccess: '更新成功',
|
updateSuccess: '更新成功',
|
||||||
userCenter: '个人中心',
|
myProfile: '个人信息',
|
||||||
yesOrNo: {
|
yesOrNo: {
|
||||||
yes: '是',
|
yes: '是',
|
||||||
no: '否'
|
no: '否'
|
||||||
@@ -158,7 +158,25 @@ const local: App.I18n.Schema = {
|
|||||||
404: '页面不存在',
|
404: '页面不存在',
|
||||||
500: '服务器错误',
|
500: '服务器错误',
|
||||||
'iframe-page': '外链页面',
|
'iframe-page': '外链页面',
|
||||||
'user-center': '个人中心',
|
workbench: '工作台',
|
||||||
|
ticket: '工单',
|
||||||
|
'ticket_my-submitted': '我提交的工单',
|
||||||
|
'ticket_my-pending': '待我处理的工单',
|
||||||
|
metrics: '效能度量',
|
||||||
|
'metrics_project-progress': '项目进度',
|
||||||
|
'metrics_member-efficiency': '员工能效',
|
||||||
|
metrics_worktime: '工时统计',
|
||||||
|
'personal-center': '个人中心',
|
||||||
|
'personal-center_my-profile': '个人信息',
|
||||||
|
'personal-center_my-item': '我的事项',
|
||||||
|
'personal-center_my-weekly': '我的周报',
|
||||||
|
'personal-center_my-monthly': '我的月报',
|
||||||
|
'personal-center_my-performance': '我的绩效',
|
||||||
|
'personal-center_my-application': '我的申请',
|
||||||
|
'personal-center_pending-approval': '待我审批',
|
||||||
|
infra: '基础设施',
|
||||||
|
'infra_state-machine': '状态机管理',
|
||||||
|
'infra_rd-code': '研发令号',
|
||||||
function: '系统功能',
|
function: '系统功能',
|
||||||
function_tab: '标签页',
|
function_tab: '标签页',
|
||||||
'function_multi-tab': '多标签页',
|
'function_multi-tab': '多标签页',
|
||||||
@@ -174,6 +192,13 @@ const local: App.I18n.Schema = {
|
|||||||
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': '用户详情',
|
||||||
@@ -192,9 +217,6 @@ const local: App.I18n.Schema = {
|
|||||||
plugin_charts_echarts: 'ECharts',
|
plugin_charts_echarts: 'ECharts',
|
||||||
plugin_charts_antv: 'AntV',
|
plugin_charts_antv: 'AntV',
|
||||||
plugin_charts_vchart: 'VChart',
|
plugin_charts_vchart: 'VChart',
|
||||||
plugin_editor: '编辑器',
|
|
||||||
plugin_editor_quill: '富文本编辑器',
|
|
||||||
plugin_editor_markdown: 'MD 编辑器',
|
|
||||||
plugin_icon: '图标',
|
plugin_icon: '图标',
|
||||||
plugin_map: '地图',
|
plugin_map: '地图',
|
||||||
plugin_print: '打印',
|
plugin_print: '打印',
|
||||||
@@ -484,6 +506,7 @@ const local: App.I18n.Schema = {
|
|||||||
orgType: {
|
orgType: {
|
||||||
company: '公司',
|
company: '公司',
|
||||||
dept: '部门',
|
dept: '部门',
|
||||||
|
function: '职能部门',
|
||||||
direction: '方向',
|
direction: '方向',
|
||||||
team: '团队'
|
team: '团队'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,23 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
|||||||
"function_super-page": () => import("@/views/function/super-page/index.vue"),
|
"function_super-page": () => import("@/views/function/super-page/index.vue"),
|
||||||
function_tab: () => import("@/views/function/tab/index.vue"),
|
function_tab: () => import("@/views/function/tab/index.vue"),
|
||||||
"function_toggle-auth": () => import("@/views/function/toggle-auth/index.vue"),
|
"function_toggle-auth": () => import("@/views/function/toggle-auth/index.vue"),
|
||||||
|
"infra_rd-code": () => import("@/views/infra/rd-code/index.vue"),
|
||||||
|
"infra_state-machine": () => import("@/views/infra/state-machine/index.vue"),
|
||||||
|
"metrics_member-efficiency": () => import("@/views/metrics/member-efficiency/index.vue"),
|
||||||
|
"metrics_project-progress": () => import("@/views/metrics/project-progress/index.vue"),
|
||||||
|
metrics_worktime: () => import("@/views/metrics/worktime/index.vue"),
|
||||||
|
"personal-center_my-application": () => import("@/views/personal-center/my-application/index.vue"),
|
||||||
|
"personal-center_my-item": () => import("@/views/personal-center/my-item/index.vue"),
|
||||||
|
"personal-center_my-monthly": () => import("@/views/personal-center/my-monthly/index.vue"),
|
||||||
|
"personal-center_my-performance": () => import("@/views/personal-center/my-performance/index.vue"),
|
||||||
|
"personal-center_my-profile": () => import("@/views/personal-center/my-profile/index.vue"),
|
||||||
|
"personal-center_my-weekly": () => import("@/views/personal-center/my-weekly/index.vue"),
|
||||||
|
"personal-center_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"),
|
||||||
plugin_barcode: () => import("@/views/plugin/barcode/index.vue"),
|
plugin_barcode: () => import("@/views/plugin/barcode/index.vue"),
|
||||||
plugin_charts_antv: () => import("@/views/plugin/charts/antv/index.vue"),
|
plugin_charts_antv: () => import("@/views/plugin/charts/antv/index.vue"),
|
||||||
plugin_charts_echarts: () => import("@/views/plugin/charts/echarts/index.vue"),
|
plugin_charts_echarts: () => import("@/views/plugin/charts/echarts/index.vue"),
|
||||||
plugin_charts_vchart: () => import("@/views/plugin/charts/vchart/index.vue"),
|
plugin_charts_vchart: () => import("@/views/plugin/charts/vchart/index.vue"),
|
||||||
plugin_copy: () => import("@/views/plugin/copy/index.vue"),
|
plugin_copy: () => import("@/views/plugin/copy/index.vue"),
|
||||||
plugin_editor_markdown: () => import("@/views/plugin/editor/markdown/index.vue"),
|
|
||||||
plugin_editor_quill: () => import("@/views/plugin/editor/quill/index.vue"),
|
|
||||||
plugin_excel: () => import("@/views/plugin/excel/index.vue"),
|
plugin_excel: () => import("@/views/plugin/excel/index.vue"),
|
||||||
plugin_gantt_dhtmlx: () => import("@/views/plugin/gantt/dhtmlx/index.vue"),
|
plugin_gantt_dhtmlx: () => import("@/views/plugin/gantt/dhtmlx/index.vue"),
|
||||||
plugin_gantt_vtable: () => import("@/views/plugin/gantt/vtable/index.vue"),
|
plugin_gantt_vtable: () => import("@/views/plugin/gantt/vtable/index.vue"),
|
||||||
@@ -51,6 +61,11 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
|||||||
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 +73,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"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -170,6 +170,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)?',
|
||||||
@@ -182,6 +219,152 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
hideInMenu: true
|
hideInMenu: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'metrics',
|
||||||
|
path: '/metrics',
|
||||||
|
component: 'layout.base',
|
||||||
|
meta: {
|
||||||
|
title: 'metrics',
|
||||||
|
i18nKey: 'route.metrics',
|
||||||
|
icon: 'mdi:chart-line',
|
||||||
|
order: 7
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'metrics_member-efficiency',
|
||||||
|
path: '/metrics/member-efficiency',
|
||||||
|
component: 'view.metrics_member-efficiency',
|
||||||
|
meta: {
|
||||||
|
title: 'metrics_member-efficiency',
|
||||||
|
i18nKey: 'route.metrics_member-efficiency',
|
||||||
|
icon: 'mdi:account-multiple-check-outline',
|
||||||
|
order: 2,
|
||||||
|
keepAlive: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'metrics_project-progress',
|
||||||
|
path: '/metrics/project-progress',
|
||||||
|
component: 'view.metrics_project-progress',
|
||||||
|
meta: {
|
||||||
|
title: 'metrics_project-progress',
|
||||||
|
i18nKey: 'route.metrics_project-progress',
|
||||||
|
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-monthly',
|
||||||
|
path: '/personal-center/my-monthly',
|
||||||
|
component: 'view.personal-center_my-monthly',
|
||||||
|
meta: {
|
||||||
|
title: 'personal-center_my-monthly',
|
||||||
|
i18nKey: 'route.personal-center_my-monthly',
|
||||||
|
icon: 'mdi:calendar-month-outline',
|
||||||
|
order: 2,
|
||||||
|
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: 3,
|
||||||
|
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_my-weekly',
|
||||||
|
path: '/personal-center/my-weekly',
|
||||||
|
component: 'view.personal-center_my-weekly',
|
||||||
|
meta: {
|
||||||
|
title: 'personal-center_my-weekly',
|
||||||
|
i18nKey: 'route.personal-center_my-weekly',
|
||||||
|
icon: 'mdi:calendar-week-outline',
|
||||||
|
order: 1,
|
||||||
|
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: 5,
|
||||||
|
keepAlive: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'plugin',
|
name: 'plugin',
|
||||||
path: '/plugin',
|
path: '/plugin',
|
||||||
@@ -254,37 +437,6 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
icon: 'mdi:clipboard-outline'
|
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',
|
name: 'plugin_excel',
|
||||||
path: '/plugin/excel',
|
path: '/plugin/excel',
|
||||||
@@ -488,6 +640,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 +816,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -181,7 +181,22 @@ const routeMap: RouteMap = {
|
|||||||
"function_tab": "/function/tab",
|
"function_tab": "/function/tab",
|
||||||
"function_toggle-auth": "/function/toggle-auth",
|
"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)?",
|
||||||
|
"metrics": "/metrics",
|
||||||
|
"metrics_member-efficiency": "/metrics/member-efficiency",
|
||||||
|
"metrics_project-progress": "/metrics/project-progress",
|
||||||
|
"metrics_worktime": "/metrics/worktime",
|
||||||
|
"personal-center": "/personal-center",
|
||||||
|
"personal-center_my-application": "/personal-center/my-application",
|
||||||
|
"personal-center_my-item": "/personal-center/my-item",
|
||||||
|
"personal-center_my-monthly": "/personal-center/my-monthly",
|
||||||
|
"personal-center_my-performance": "/personal-center/my-performance",
|
||||||
|
"personal-center_my-profile": "/personal-center/my-profile",
|
||||||
|
"personal-center_my-weekly": "/personal-center/my-weekly",
|
||||||
|
"personal-center_pending-approval": "/personal-center/pending-approval",
|
||||||
"plugin": "/plugin",
|
"plugin": "/plugin",
|
||||||
"plugin_barcode": "/plugin/barcode",
|
"plugin_barcode": "/plugin/barcode",
|
||||||
"plugin_charts": "/plugin/charts",
|
"plugin_charts": "/plugin/charts",
|
||||||
@@ -189,9 +204,6 @@ const routeMap: RouteMap = {
|
|||||||
"plugin_charts_echarts": "/plugin/charts/echarts",
|
"plugin_charts_echarts": "/plugin/charts/echarts",
|
||||||
"plugin_charts_vchart": "/plugin/charts/vchart",
|
"plugin_charts_vchart": "/plugin/charts/vchart",
|
||||||
"plugin_copy": "/plugin/copy",
|
"plugin_copy": "/plugin/copy",
|
||||||
"plugin_editor": "/plugin/editor",
|
|
||||||
"plugin_editor_markdown": "/plugin/editor/markdown",
|
|
||||||
"plugin_editor_quill": "/plugin/editor/quill",
|
|
||||||
"plugin_excel": "/plugin/excel",
|
"plugin_excel": "/plugin/excel",
|
||||||
"plugin_gantt": "/plugin/gantt",
|
"plugin_gantt": "/plugin/gantt",
|
||||||
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx",
|
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx",
|
||||||
@@ -211,6 +223,13 @@ const routeMap: RouteMap = {
|
|||||||
"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 +238,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)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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,11 @@
|
|||||||
export * from './auth';
|
export * from './auth';
|
||||||
export * from './dict';
|
export * from './dict';
|
||||||
|
export * from './file';
|
||||||
|
export * from './infra';
|
||||||
export * from './object-context';
|
export * from './object-context';
|
||||||
|
export * from './personal-item';
|
||||||
export * from './product';
|
export * from './product';
|
||||||
|
export * from './project';
|
||||||
|
export * from './project-shared';
|
||||||
export * from './route';
|
export * from './route';
|
||||||
export * from './system-manage';
|
export * from './system-manage';
|
||||||
|
|||||||
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'
|
||||||
|
});
|
||||||
|
}
|
||||||
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
|
||||||
|
|||||||
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'
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -91,7 +91,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 +106,16 @@ export async function fetchGetProductPage(params?: Api.Product.ProductSearchPara
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 鑾峰彇浜у搧璇︽儏 */
|
/** 获取产品入口页概览统计 */
|
||||||
|
export function fetchGetProductOverviewSummary() {
|
||||||
|
return request<Api.Product.ProductOverviewSummary>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PRODUCT_PREFIX}/overview-summary`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取产品详情 */
|
||||||
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 +127,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 +139,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 +160,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 +169,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 +183,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'
|
||||||
|
| 'sourceBizId'
|
||||||
|
| 'attachments'
|
||||||
> & {
|
> & {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
parentId: string | number;
|
parentId: string | number;
|
||||||
@@ -170,11 +198,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;
|
||||||
|
implementProjectName?: string | null;
|
||||||
sourceBizId?: string | number | null;
|
sourceBizId?: string | number | 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 +270,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),
|
||||||
|
implementProjectName: requirement.implementProjectName ?? null,
|
||||||
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
|
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
|
||||||
|
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 +417,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 +429,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 +498,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 +659,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 +681,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,
|
||||||
|
|||||||
397
src/service/api/project-shared.ts
Normal file
397
src/service/api/project-shared.ts
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
};
|
||||||
|
}
|
||||||
1218
src/service/api/project.ts
Normal file
1218
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'> & {
|
||||||
@@ -120,7 +121,8 @@ type UserManagementRelationTreeResponse = Omit<
|
|||||||
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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,7 +445,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`,
|
||||||
@@ -667,7 +669,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 +686,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`,
|
||||||
@@ -704,7 +706,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 +724,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 +778,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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
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,153 @@ 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);
|
||||||
|
|
||||||
export const request = createFlatRequest(
|
export const request = withDedupe(
|
||||||
{
|
createFlatRequest(
|
||||||
baseURL,
|
{
|
||||||
headers: {
|
baseURL,
|
||||||
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
|
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 === 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 */
|
||||||
@@ -148,6 +161,9 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
|||||||
|
|
||||||
token.value = loginToken.token;
|
token.value = loginToken.token;
|
||||||
|
|
||||||
|
// 复位会话失效一次性锁,让下一次会话失效仍能正常提示
|
||||||
|
resetSessionExpiredFlag();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +183,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 +217,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,15 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeFrontendDictData(
|
function normalizeFrontendDictData(
|
||||||
dictType: string,
|
dictType: string,
|
||||||
list: Api.Dict.FrontendDictData[],
|
list: Api.Dict.FrontendDictData[],
|
||||||
@@ -31,13 +40,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: normalizeColorType(item.colorType),
|
||||||
|
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: normalizeColorType(item.colorType),
|
||||||
|
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 +110,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 +118,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 +160,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 +267,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 });
|
||||||
|
});
|
||||||
@@ -416,6 +416,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 +442,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 +523,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
src/typings/api/dict.d.ts
vendored
6
src/typings/api/dict.d.ts
vendored
@@ -55,6 +55,8 @@ 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;
|
||||||
/** remark */
|
/** remark */
|
||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
/** create time */
|
/** create time */
|
||||||
@@ -73,6 +75,10 @@ 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;
|
||||||
|
/** 备注,可用于下拉中文释义展示 */
|
||||||
|
remark?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** frontend runtime dict cache map */
|
/** frontend runtime dict cache map */
|
||||||
|
|||||||
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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
185
src/typings/api/product.d.ts
vendored
185
src/typings/api/product.d.ts
vendored
@@ -21,6 +21,12 @@ declare namespace Api {
|
|||||||
list: T[];
|
list: T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 产品入口页概览统计 */
|
||||||
|
interface ProductOverviewSummary {
|
||||||
|
/** 产品状态数量映射,key 为后端状态编码 */
|
||||||
|
statusCounts: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
/** 产品 ID */
|
/** 产品 ID */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -204,6 +210,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 +248,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,17 +301,19 @@ 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 */
|
/** 需求来源业务ID */
|
||||||
sourceBizId?: string | null;
|
sourceBizId?: string | null;
|
||||||
/** 优先级(0低 1中 2高 3紧急) */
|
/** 优先级(0低 1中 2高 3紧急) */
|
||||||
priority: RequirementPriority;
|
priority: RequirementPriority;
|
||||||
@@ -280,12 +333,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 +347,6 @@ declare namespace Api {
|
|||||||
updateTime: string;
|
updateTime: string;
|
||||||
/** 子需求列表(树形结构) */
|
/** 子需求列表(树形结构) */
|
||||||
children?: Requirement[];
|
children?: Requirement[];
|
||||||
/** 是否为终态 */
|
|
||||||
terminal?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 需求模块实体 ==========
|
// ========== 需求模块实体 ==========
|
||||||
@@ -332,25 +383,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 请求参数类型 ==========
|
// ========== 请求参数类型 ==========
|
||||||
@@ -375,12 +504,15 @@ declare namespace Api {
|
|||||||
| 'reviewRequired'
|
| 'reviewRequired'
|
||||||
| 'title'
|
| 'title'
|
||||||
| 'description'
|
| 'description'
|
||||||
|
| 'attachments'
|
||||||
| 'category'
|
| 'category'
|
||||||
| 'priority'
|
| 'priority'
|
||||||
| 'proposerId'
|
| 'proposerId'
|
||||||
|
| 'proposerNickname'
|
||||||
| 'currentHandlerUserId'
|
| 'currentHandlerUserId'
|
||||||
|
| 'currentHandlerUserNickname'
|
||||||
| 'implementProjectId'
|
| 'implementProjectId'
|
||||||
| 'completionDate'
|
| 'expectedTime'
|
||||||
| 'sort'
|
| 'sort'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@@ -412,11 +544,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'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|||||||
1085
src/typings/api/project.d.ts
vendored
Normal file
1085
src/typings/api/project.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
14
src/typings/api/system-manage.d.ts
vendored
14
src/typings/api/system-manage.d.ts
vendored
@@ -47,6 +47,8 @@ declare namespace Api {
|
|||||||
type: RoleType;
|
type: RoleType;
|
||||||
/** remark */
|
/** remark */
|
||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
|
/** 是否在前端选择面板可见:0 不可见 / 1 可见,缺省视作可见 */
|
||||||
|
visible?: 0 | 1 | null;
|
||||||
/** create time */
|
/** create time */
|
||||||
createTime: number;
|
createTime: number;
|
||||||
}
|
}
|
||||||
@@ -69,7 +71,7 @@ declare namespace Api {
|
|||||||
roleCode: string;
|
roleCode: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DeptOrgType = 'company' | 'dept' | 'direction' | 'team';
|
type DeptOrgType = 'company' | 'dept' | 'function' | 'direction' | 'team';
|
||||||
|
|
||||||
interface Dept {
|
interface Dept {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -148,6 +150,7 @@ declare namespace Api {
|
|||||||
sex?: UserGender | null;
|
sex?: UserGender | null;
|
||||||
avatar?: string | null;
|
avatar?: string | null;
|
||||||
status: CommonStatus;
|
status: CommonStatus;
|
||||||
|
sort?: number;
|
||||||
loginIp?: string | null;
|
loginIp?: string | null;
|
||||||
resignedAt?: number | null;
|
resignedAt?: number | null;
|
||||||
loginDate?: number | null;
|
loginDate?: number | null;
|
||||||
@@ -178,6 +181,7 @@ declare namespace Api {
|
|||||||
mobile?: string | null;
|
mobile?: string | null;
|
||||||
sex?: UserGender | null;
|
sex?: UserGender | null;
|
||||||
avatar?: string | null;
|
avatar?: string | null;
|
||||||
|
sort?: number;
|
||||||
password?: string;
|
password?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -224,7 +228,7 @@ declare namespace Api {
|
|||||||
|
|
||||||
type PostList = PageResult<Post>;
|
type PostList = PageResult<Post>;
|
||||||
|
|
||||||
type RoleSimple = Pick<Role, 'id' | 'name' | 'code' | 'status' | 'sort'>;
|
type RoleSimple = Pick<Role, 'id' | 'name' | 'code' | 'status' | 'sort' | 'remark' | 'visible'>;
|
||||||
|
|
||||||
type RoleSimpleList = RoleSimple[];
|
type RoleSimpleList = RoleSimple[];
|
||||||
|
|
||||||
@@ -428,6 +432,12 @@ declare namespace Api {
|
|||||||
id: string;
|
id: string;
|
||||||
/** 用户昵称 */
|
/** 用户昵称 */
|
||||||
nickname: string;
|
nickname: string;
|
||||||
|
/** 用户账号 */
|
||||||
|
username?: string | null;
|
||||||
|
/** 部门 ID */
|
||||||
|
deptId?: string | null;
|
||||||
|
/** 部门名称 */
|
||||||
|
deptName?: string | null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
src/typings/app.d.ts
vendored
3
src/typings/app.d.ts
vendored
@@ -333,7 +333,7 @@ declare namespace App {
|
|||||||
trigger: string;
|
trigger: string;
|
||||||
update: string;
|
update: string;
|
||||||
updateSuccess: string;
|
updateSuccess: string;
|
||||||
userCenter: string;
|
myProfile: string;
|
||||||
yesOrNo: {
|
yesOrNo: {
|
||||||
yes: string;
|
yes: string;
|
||||||
no: string;
|
no: string;
|
||||||
@@ -684,6 +684,7 @@ declare namespace App {
|
|||||||
orgType: {
|
orgType: {
|
||||||
company: string;
|
company: string;
|
||||||
dept: string;
|
dept: string;
|
||||||
|
function: string;
|
||||||
direction: string;
|
direction: string;
|
||||||
team: string;
|
team: string;
|
||||||
};
|
};
|
||||||
|
|||||||
28
src/typings/components.d.ts
vendored
28
src/typings/components.d.ts
vendored
@@ -9,11 +9,18 @@ export {}
|
|||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
|
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
|
||||||
|
AttendeeUserPicker: typeof import('./../components/custom/attendee-user-picker.vue')['default']
|
||||||
BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
|
BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
|
||||||
|
BusinessAttachmentUploader: typeof import('./../components/custom/business-attachment-uploader.vue')['default']
|
||||||
BusinessDateRangePicker: typeof import('./../components/custom/business-date-range-picker.vue')['default']
|
BusinessDateRangePicker: typeof import('./../components/custom/business-date-range-picker.vue')['default']
|
||||||
BusinessFormDialog: typeof import('./../components/custom/business-form-dialog.vue')['default']
|
BusinessFormDialog: typeof import('./../components/custom/business-form-dialog.vue')['default']
|
||||||
BusinessFormDrawer: typeof import('./../components/custom/business-form-drawer.vue')['default']
|
BusinessFormDrawer: typeof import('./../components/custom/business-form-drawer.vue')['default']
|
||||||
BusinessFormSection: typeof import('./../components/custom/business-form-section.vue')['default']
|
BusinessFormSection: typeof import('./../components/custom/business-form-section.vue')['default']
|
||||||
|
BusinessFormSimpleDialog: typeof import('./../components/custom/business-form-simple-dialog.vue')['default']
|
||||||
|
BusinessRichTextEditor: typeof import('./../components/custom/business-rich-text-editor.vue')['default']
|
||||||
|
BusinessRichTextView: typeof import('./../components/custom/business-rich-text-view.vue')['default']
|
||||||
|
BusinessUserPicker: typeof import('./../components/custom/business-user-picker.vue')['default']
|
||||||
|
BusinessUserSelect: typeof import('./../components/custom/business-user-select.vue')['default']
|
||||||
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
|
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
|
||||||
CountTo: typeof import('./../components/custom/count-to.vue')['default']
|
CountTo: typeof import('./../components/custom/count-to.vue')['default']
|
||||||
CustomIconSelect: typeof import('./../components/custom/custom-icon-select.vue')['default']
|
CustomIconSelect: typeof import('./../components/custom/custom-icon-select.vue')['default']
|
||||||
@@ -50,14 +57,17 @@ declare module 'vue' {
|
|||||||
ElForm: typeof import('element-plus/es')['ElForm']
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
|
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||||
|
ElLink: typeof import('element-plus/es')['ElLink']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
|
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
|
||||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
|
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
@@ -65,6 +75,7 @@ declare module 'vue' {
|
|||||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||||
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||||
ElSpace: typeof import('element-plus/es')['ElSpace']
|
ElSpace: typeof import('element-plus/es')['ElSpace']
|
||||||
ElStatistic: typeof import('element-plus/es')['ElStatistic']
|
ElStatistic: typeof import('element-plus/es')['ElStatistic']
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
@@ -91,10 +102,19 @@ declare module 'vue' {
|
|||||||
IconCarbonStop: typeof import('~icons/carbon/stop')['default']
|
IconCarbonStop: typeof import('~icons/carbon/stop')['default']
|
||||||
'IconCharm:download': typeof import('~icons/charm/download')['default']
|
'IconCharm:download': typeof import('~icons/charm/download')['default']
|
||||||
'IconEp:arrowDown': typeof import('~icons/ep/arrow-down')['default']
|
'IconEp:arrowDown': typeof import('~icons/ep/arrow-down')['default']
|
||||||
|
'IconEp:arrowRight': typeof import('~icons/ep/arrow-right')['default']
|
||||||
|
'IconEp:box': typeof import('~icons/ep/box')['default']
|
||||||
|
'IconEp:check': typeof import('~icons/ep/check')['default']
|
||||||
|
'IconEp:files': typeof import('~icons/ep/files')['default']
|
||||||
|
'IconEp:folder': typeof import('~icons/ep/folder')['default']
|
||||||
|
'IconEp:infoFilled': typeof import('~icons/ep/info-filled')['default']
|
||||||
|
'IconEp:plus': typeof import('~icons/ep/plus')['default']
|
||||||
|
'IconEp:sort': typeof import('~icons/ep/sort')['default']
|
||||||
IconEpRemoveFilled: typeof import('~icons/ep/remove-filled')['default']
|
IconEpRemoveFilled: typeof import('~icons/ep/remove-filled')['default']
|
||||||
IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default']
|
IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default']
|
||||||
'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default']
|
'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default']
|
||||||
'IconF7:flagCircleFill': typeof import('~icons/f7/flag-circle-fill')['default']
|
'IconF7:flagCircleFill': typeof import('~icons/f7/flag-circle-fill')['default']
|
||||||
|
'IconFe:eye': typeof import('~icons/fe/eye')['default']
|
||||||
'IconFe:question': typeof import('~icons/fe/question')['default']
|
'IconFe:question': typeof import('~icons/fe/question')['default']
|
||||||
'IconFileIcons:microsoftExcel': typeof import('~icons/file-icons/microsoft-excel')['default']
|
'IconFileIcons:microsoftExcel': typeof import('~icons/file-icons/microsoft-excel')['default']
|
||||||
'IconGg:ratio': typeof import('~icons/gg/ratio')['default']
|
'IconGg:ratio': typeof import('~icons/gg/ratio')['default']
|
||||||
@@ -102,6 +122,7 @@ declare module 'vue' {
|
|||||||
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||||
'IconIc:roundPlus': typeof import('~icons/ic/round-plus')['default']
|
'IconIc:roundPlus': typeof import('~icons/ic/round-plus')['default']
|
||||||
'IconIconParkOutline:equalRatio': typeof import('~icons/icon-park-outline/equal-ratio')['default']
|
'IconIconParkOutline:equalRatio': typeof import('~icons/icon-park-outline/equal-ratio')['default']
|
||||||
|
IconIcRoundChevronRight: typeof import('~icons/ic/round-chevron-right')['default']
|
||||||
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
|
||||||
IconIcRoundEdit: typeof import('~icons/ic/round-edit')['default']
|
IconIcRoundEdit: typeof import('~icons/ic/round-edit')['default']
|
||||||
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
||||||
@@ -114,6 +135,7 @@ declare module 'vue' {
|
|||||||
IconLocalLogo: typeof import('~icons/local/logo')['default']
|
IconLocalLogo: typeof import('~icons/local/logo')['default']
|
||||||
'IconMaterialSymbolsLight:rotate90DegreesCcwOutlineRounded': typeof import('~icons/material-symbols-light/rotate90-degrees-ccw-outline-rounded')['default']
|
'IconMaterialSymbolsLight:rotate90DegreesCcwOutlineRounded': typeof import('~icons/material-symbols-light/rotate90-degrees-ccw-outline-rounded')['default']
|
||||||
IconMaterialSymbolsLightCheckCircleRounded: typeof import('~icons/material-symbols-light/check-circle-rounded')['default']
|
IconMaterialSymbolsLightCheckCircleRounded: typeof import('~icons/material-symbols-light/check-circle-rounded')['default']
|
||||||
|
'IconMdi:paperclip': typeof import('~icons/mdi/paperclip')['default']
|
||||||
'IconMdi:printer': typeof import('~icons/mdi/printer')['default']
|
'IconMdi:printer': typeof import('~icons/mdi/printer')['default']
|
||||||
IconMdiAccountTieOutline: typeof import('~icons/mdi/account-tie-outline')['default']
|
IconMdiAccountTieOutline: typeof import('~icons/mdi/account-tie-outline')['default']
|
||||||
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
|
||||||
@@ -123,6 +145,9 @@ declare module 'vue' {
|
|||||||
IconMdiChevronDoubleUp: typeof import('~icons/mdi/chevron-double-up')['default']
|
IconMdiChevronDoubleUp: typeof import('~icons/mdi/chevron-double-up')['default']
|
||||||
IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
|
IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
|
||||||
IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
|
IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
|
||||||
|
IconMdiClose: typeof import('~icons/mdi/close')['default']
|
||||||
|
IconMdiCloseCircle: typeof import('~icons/mdi/close-circle')['default']
|
||||||
|
IconMdiCrown: typeof import('~icons/mdi/crown')['default']
|
||||||
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
|
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
|
||||||
IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
|
IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
|
||||||
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
|
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
|
||||||
@@ -132,6 +157,7 @@ declare module 'vue' {
|
|||||||
IconMdiFolderPlusOutline: typeof import('~icons/mdi/folder-plus-outline')['default']
|
IconMdiFolderPlusOutline: typeof import('~icons/mdi/folder-plus-outline')['default']
|
||||||
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
|
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
|
||||||
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
|
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
|
||||||
|
IconMdiLinkVariant: typeof import('~icons/mdi/link-variant')['default']
|
||||||
IconMdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
|
IconMdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
|
||||||
IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline')['default']
|
IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline')['default']
|
||||||
IconMdiPlus: typeof import('~icons/mdi/plus')['default']
|
IconMdiPlus: typeof import('~icons/mdi/plus')['default']
|
||||||
@@ -153,8 +179,10 @@ declare module 'vue' {
|
|||||||
SystemLogo: typeof import('./../components/common/system-logo.vue')['default']
|
SystemLogo: typeof import('./../components/common/system-logo.vue')['default']
|
||||||
TableColumnSetting: typeof import('./../components/advanced/table-column-setting.vue')['default']
|
TableColumnSetting: typeof import('./../components/advanced/table-column-setting.vue')['default']
|
||||||
TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
|
TableHeaderOperation: typeof import('./../components/advanced/table-header-operation.vue')['default']
|
||||||
|
TableSearchFields: typeof import('./../components/custom/table-search-fields.vue')['default']
|
||||||
TableSearchPanel: typeof import('./../components/custom/table-search-panel.vue')['default']
|
TableSearchPanel: typeof import('./../components/custom/table-search-panel.vue')['default']
|
||||||
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
|
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
|
||||||
|
UserPickerTrigger: typeof import('./../components/custom/business-user-picker/components/user-picker-trigger.vue')['default']
|
||||||
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
||||||
WebSiteLink: typeof import('./../components/custom/web-site-link.vue')['default']
|
WebSiteLink: typeof import('./../components/custom/web-site-link.vue')['default']
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/typings/elegant-router.d.ts
vendored
60
src/typings/elegant-router.d.ts
vendored
@@ -35,7 +35,22 @@ declare module "@elegant-router/types" {
|
|||||||
"function_tab": "/function/tab";
|
"function_tab": "/function/tab";
|
||||||
"function_toggle-auth": "/function/toggle-auth";
|
"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)?";
|
||||||
|
"metrics": "/metrics";
|
||||||
|
"metrics_member-efficiency": "/metrics/member-efficiency";
|
||||||
|
"metrics_project-progress": "/metrics/project-progress";
|
||||||
|
"metrics_worktime": "/metrics/worktime";
|
||||||
|
"personal-center": "/personal-center";
|
||||||
|
"personal-center_my-application": "/personal-center/my-application";
|
||||||
|
"personal-center_my-item": "/personal-center/my-item";
|
||||||
|
"personal-center_my-monthly": "/personal-center/my-monthly";
|
||||||
|
"personal-center_my-performance": "/personal-center/my-performance";
|
||||||
|
"personal-center_my-profile": "/personal-center/my-profile";
|
||||||
|
"personal-center_my-weekly": "/personal-center/my-weekly";
|
||||||
|
"personal-center_pending-approval": "/personal-center/pending-approval";
|
||||||
"plugin": "/plugin";
|
"plugin": "/plugin";
|
||||||
"plugin_barcode": "/plugin/barcode";
|
"plugin_barcode": "/plugin/barcode";
|
||||||
"plugin_charts": "/plugin/charts";
|
"plugin_charts": "/plugin/charts";
|
||||||
@@ -43,9 +58,6 @@ declare module "@elegant-router/types" {
|
|||||||
"plugin_charts_echarts": "/plugin/charts/echarts";
|
"plugin_charts_echarts": "/plugin/charts/echarts";
|
||||||
"plugin_charts_vchart": "/plugin/charts/vchart";
|
"plugin_charts_vchart": "/plugin/charts/vchart";
|
||||||
"plugin_copy": "/plugin/copy";
|
"plugin_copy": "/plugin/copy";
|
||||||
"plugin_editor": "/plugin/editor";
|
|
||||||
"plugin_editor_markdown": "/plugin/editor/markdown";
|
|
||||||
"plugin_editor_quill": "/plugin/editor/quill";
|
|
||||||
"plugin_excel": "/plugin/excel";
|
"plugin_excel": "/plugin/excel";
|
||||||
"plugin_gantt": "/plugin/gantt";
|
"plugin_gantt": "/plugin/gantt";
|
||||||
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx";
|
"plugin_gantt_dhtmlx": "/plugin/gantt/dhtmlx";
|
||||||
@@ -65,6 +77,13 @@ declare module "@elegant-router/types" {
|
|||||||
"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";
|
||||||
@@ -73,7 +92,10 @@ declare module "@elegant-router/types" {
|
|||||||
"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";
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,11 +136,16 @@ declare module "@elegant-router/types" {
|
|||||||
| "500"
|
| "500"
|
||||||
| "function"
|
| "function"
|
||||||
| "iframe-page"
|
| "iframe-page"
|
||||||
|
| "infra"
|
||||||
| "login"
|
| "login"
|
||||||
|
| "metrics"
|
||||||
|
| "personal-center"
|
||||||
| "plugin"
|
| "plugin"
|
||||||
| "product"
|
| "product"
|
||||||
|
| "project"
|
||||||
| "system"
|
| "system"
|
||||||
| "user-center"
|
| "ticket"
|
||||||
|
| "workbench"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,13 +176,23 @@ declare module "@elegant-router/types" {
|
|||||||
| "function_super-page"
|
| "function_super-page"
|
||||||
| "function_tab"
|
| "function_tab"
|
||||||
| "function_toggle-auth"
|
| "function_toggle-auth"
|
||||||
|
| "infra_rd-code"
|
||||||
|
| "infra_state-machine"
|
||||||
|
| "metrics_member-efficiency"
|
||||||
|
| "metrics_project-progress"
|
||||||
|
| "metrics_worktime"
|
||||||
|
| "personal-center_my-application"
|
||||||
|
| "personal-center_my-item"
|
||||||
|
| "personal-center_my-monthly"
|
||||||
|
| "personal-center_my-performance"
|
||||||
|
| "personal-center_my-profile"
|
||||||
|
| "personal-center_my-weekly"
|
||||||
|
| "personal-center_pending-approval"
|
||||||
| "plugin_barcode"
|
| "plugin_barcode"
|
||||||
| "plugin_charts_antv"
|
| "plugin_charts_antv"
|
||||||
| "plugin_charts_echarts"
|
| "plugin_charts_echarts"
|
||||||
| "plugin_charts_vchart"
|
| "plugin_charts_vchart"
|
||||||
| "plugin_copy"
|
| "plugin_copy"
|
||||||
| "plugin_editor_markdown"
|
|
||||||
| "plugin_editor_quill"
|
|
||||||
| "plugin_excel"
|
| "plugin_excel"
|
||||||
| "plugin_gantt_dhtmlx"
|
| "plugin_gantt_dhtmlx"
|
||||||
| "plugin_gantt_vtable"
|
| "plugin_gantt_vtable"
|
||||||
@@ -172,6 +209,11 @@ declare module "@elegant-router/types" {
|
|||||||
| "product_list"
|
| "product_list"
|
||||||
| "product_requirement"
|
| "product_requirement"
|
||||||
| "product_setting"
|
| "product_setting"
|
||||||
|
| "project_list"
|
||||||
|
| "project_project_execution"
|
||||||
|
| "project_project_overview"
|
||||||
|
| "project_project_requirement"
|
||||||
|
| "project_project_setting"
|
||||||
| "system_dict"
|
| "system_dict"
|
||||||
| "system_menu"
|
| "system_menu"
|
||||||
| "system_post"
|
| "system_post"
|
||||||
@@ -179,7 +221,9 @@ declare module "@elegant-router/types" {
|
|||||||
| "system_user-detail"
|
| "system_user-detail"
|
||||||
| "system_user-management-relation"
|
| "system_user-management-relation"
|
||||||
| "system_user"
|
| "system_user"
|
||||||
| "user-center"
|
| "ticket_my-pending"
|
||||||
|
| "ticket_my-submitted"
|
||||||
|
| "workbench"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
18
src/typings/wangeditor.d.ts
vendored
Normal file
18
src/typings/wangeditor.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
declare module '@wangeditor/editor-for-vue' {
|
||||||
|
import type { DefineComponent } from 'vue';
|
||||||
|
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
|
||||||
|
|
||||||
|
export const Editor: DefineComponent<{
|
||||||
|
modelValue?: string | null;
|
||||||
|
defaultConfig?: Partial<IEditorConfig>;
|
||||||
|
defaultContent?: unknown[];
|
||||||
|
defaultHtml?: string;
|
||||||
|
mode?: 'default' | 'simple';
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const Toolbar: DefineComponent<{
|
||||||
|
editor: IDomEditor | null | undefined;
|
||||||
|
defaultConfig?: Partial<IToolbarConfig>;
|
||||||
|
mode?: 'default' | 'simple';
|
||||||
|
}>;
|
||||||
|
}
|
||||||
64
src/utils/sanitize.ts
Normal file
64
src/utils/sanitize.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
const ALLOWED_TAGS = [
|
||||||
|
'a',
|
||||||
|
'p',
|
||||||
|
'br',
|
||||||
|
'span',
|
||||||
|
'div',
|
||||||
|
'b',
|
||||||
|
'i',
|
||||||
|
'u',
|
||||||
|
's',
|
||||||
|
'sub',
|
||||||
|
'sup',
|
||||||
|
'strong',
|
||||||
|
'em',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
'ul',
|
||||||
|
'ol',
|
||||||
|
'li',
|
||||||
|
'blockquote',
|
||||||
|
'pre',
|
||||||
|
'code',
|
||||||
|
'hr',
|
||||||
|
'img',
|
||||||
|
'table',
|
||||||
|
'thead',
|
||||||
|
'tbody',
|
||||||
|
'tr',
|
||||||
|
'th',
|
||||||
|
'td'
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALLOWED_ATTR = [
|
||||||
|
'href',
|
||||||
|
'target',
|
||||||
|
'rel',
|
||||||
|
'src',
|
||||||
|
'alt',
|
||||||
|
'title',
|
||||||
|
'class',
|
||||||
|
'style',
|
||||||
|
'colspan',
|
||||||
|
'rowspan',
|
||||||
|
'width',
|
||||||
|
'height'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function sanitizeHtml(html: string | null | undefined): string {
|
||||||
|
if (!html) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return DOMPurify.sanitize(html, {
|
||||||
|
ALLOWED_TAGS,
|
||||||
|
ALLOWED_ATTR,
|
||||||
|
ADD_ATTR: ['target']
|
||||||
|
});
|
||||||
|
}
|
||||||
3
src/views/infra/rd-code/index.vue
Normal file
3
src/views/infra/rd-code/index.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<LookForward title="研发令号" subtitle="功能建设中,敬请期待" />
|
||||||
|
</template>
|
||||||
395
src/views/infra/state-machine/index.vue
Normal file
395
src/views/infra/state-machine/index.vue
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
<script setup lang="tsx">
|
||||||
|
import { computed, nextTick, onActivated, reactive, ref } from 'vue';
|
||||||
|
import type { TableInstance } from 'element-plus';
|
||||||
|
import { ElTag } from 'element-plus';
|
||||||
|
import { useBoolean } from '@sa/hooks';
|
||||||
|
import { OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||||
|
import {
|
||||||
|
fetchBatchDeleteObjectStatusModel,
|
||||||
|
fetchDeleteObjectStatusModel,
|
||||||
|
fetchGetObjectStatusModelPage
|
||||||
|
} from '@/service/api';
|
||||||
|
import { useAuth } from '@/hooks/business/auth';
|
||||||
|
import { useDict } from '@/hooks/business/dict';
|
||||||
|
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||||
|
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||||
|
import StateMachineOperateDialog from './modules/state-machine-operate-dialog.vue';
|
||||||
|
import StateMachineSearch from './modules/state-machine-search.vue';
|
||||||
|
import StateTransitionDialog from './modules/state-transition-dialog.vue';
|
||||||
|
import { formatDateTime, getBooleanLabel, getBooleanTagType, getStatusLabel, getStatusTagType } from './shared';
|
||||||
|
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||||
|
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||||
|
import IconMdiSourceBranch from '~icons/mdi/source-branch';
|
||||||
|
|
||||||
|
defineOptions({ name: 'StateMachineManage' });
|
||||||
|
|
||||||
|
function getInitSearchParams(): Api.Infra.ObjectStatusModelSearchParams {
|
||||||
|
return {
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
keyword: undefined,
|
||||||
|
objectType: undefined,
|
||||||
|
status: undefined,
|
||||||
|
initialFlag: undefined,
|
||||||
|
terminalFlag: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformPageResult(
|
||||||
|
response: Awaited<ReturnType<typeof fetchGetObjectStatusModelPage>>,
|
||||||
|
pageNo: number,
|
||||||
|
pageSize: number
|
||||||
|
) {
|
||||||
|
if (!response.error) {
|
||||||
|
return {
|
||||||
|
data: response.data.list,
|
||||||
|
pageNum: pageNo,
|
||||||
|
pageSize,
|
||||||
|
total: response.data.total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
pageNum: pageNo,
|
||||||
|
pageSize,
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = reactive(getInitSearchParams());
|
||||||
|
const stateTableRef = ref<TableInstance>();
|
||||||
|
const checkedRowKeys = ref<string[]>([]);
|
||||||
|
const { getLabel: getObjectTypeLabel } = useDict(OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE);
|
||||||
|
const { hasAuth } = useAuth();
|
||||||
|
|
||||||
|
const canDeleteStateMachine = computed(() => hasAuth('infra:state-machine:delete'));
|
||||||
|
const canUpdateStateMachine = computed(() => hasAuth('infra:state-machine:update'));
|
||||||
|
const canManageStateTransition = computed(() => hasAuth('infra:state-transition:manage'));
|
||||||
|
|
||||||
|
function getStatusModelActions(row: Api.Infra.ObjectStatusModel): BusinessTableAction[] {
|
||||||
|
const actions: BusinessTableAction[] = [];
|
||||||
|
|
||||||
|
if (canManageStateTransition.value) {
|
||||||
|
actions.push({
|
||||||
|
key: 'transition',
|
||||||
|
label: '状态流转',
|
||||||
|
icon: IconMdiSourceBranch,
|
||||||
|
buttonType: 'primary',
|
||||||
|
onClick: () => openTransitionDialog(row)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canUpdateStateMachine.value) {
|
||||||
|
actions.push({
|
||||||
|
key: 'edit',
|
||||||
|
label: '编辑',
|
||||||
|
icon: IconMdiPencilOutline,
|
||||||
|
buttonType: 'primary',
|
||||||
|
onClick: () => openEdit(row)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canDeleteStateMachine.value) {
|
||||||
|
actions.push({
|
||||||
|
key: 'delete',
|
||||||
|
label: '删除',
|
||||||
|
icon: IconMdiDeleteOutline,
|
||||||
|
buttonType: 'danger',
|
||||||
|
onClick: () => handleDeleteAction(row)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable({
|
||||||
|
paginationProps: {
|
||||||
|
currentPage: searchParams.pageNo,
|
||||||
|
pageSize: searchParams.pageSize
|
||||||
|
},
|
||||||
|
api: () => fetchGetObjectStatusModelPage(searchParams),
|
||||||
|
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||||
|
onPaginationParamsChange: params => {
|
||||||
|
searchParams.pageNo = params.currentPage ?? 1;
|
||||||
|
searchParams.pageSize = params.pageSize ?? 10;
|
||||||
|
},
|
||||||
|
columns: () => [
|
||||||
|
{ prop: 'selection', type: 'selection', width: 48 },
|
||||||
|
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||||
|
{
|
||||||
|
prop: 'objectType',
|
||||||
|
label: '对象类型',
|
||||||
|
minWidth: 130,
|
||||||
|
formatter: row => getObjectTypeLabel(row.objectType)
|
||||||
|
},
|
||||||
|
{ prop: 'statusName', label: '状态名称', minWidth: 140, showOverflowTooltip: true },
|
||||||
|
{ prop: 'statusCode', label: '状态编码', minWidth: 160, showOverflowTooltip: true },
|
||||||
|
{
|
||||||
|
prop: 'status',
|
||||||
|
label: '配置状态',
|
||||||
|
width: 110,
|
||||||
|
align: 'center',
|
||||||
|
formatter: row => <ElTag type={getStatusTagType(row.status)}>{getStatusLabel(row.status)}</ElTag>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'initialFlag',
|
||||||
|
label: '初始状态',
|
||||||
|
width: 110,
|
||||||
|
align: 'center',
|
||||||
|
formatter: row => <ElTag type={getBooleanTagType(row.initialFlag)}>{getBooleanLabel(row.initialFlag)}</ElTag>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'terminalFlag',
|
||||||
|
label: '终态',
|
||||||
|
width: 90,
|
||||||
|
align: 'center',
|
||||||
|
formatter: row => <ElTag type={getBooleanTagType(row.terminalFlag)}>{getBooleanLabel(row.terminalFlag)}</ElTag>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'allowEdit',
|
||||||
|
label: '允许编辑主数据',
|
||||||
|
width: 140,
|
||||||
|
align: 'center',
|
||||||
|
formatter: row => <ElTag type={getBooleanTagType(row.allowEdit)}>{getBooleanLabel(row.allowEdit)}</ElTag>
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// prop: 'progressExcludedFlag',
|
||||||
|
// label: '不参与上层进度统计',
|
||||||
|
// width: 160,
|
||||||
|
// align: 'center',
|
||||||
|
// formatter: row => (
|
||||||
|
// <ElTag type={getBooleanTagType(row.progressExcludedFlag)}>{getBooleanLabel(row.progressExcludedFlag)}</ElTag>
|
||||||
|
// )
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// prop: 'allowCreateProject',
|
||||||
|
// label: '允许新建项目',
|
||||||
|
// width: 130,
|
||||||
|
// align: 'center',
|
||||||
|
// formatter: row => (
|
||||||
|
// <ElTag type={getBooleanTagType(row.allowCreateProject)}>{getBooleanLabel(row.allowCreateProject)}</ElTag>
|
||||||
|
// )
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// prop: 'allowCreateRequirement',
|
||||||
|
// label: '允许新增需求',
|
||||||
|
// width: 130,
|
||||||
|
// align: 'center',
|
||||||
|
// formatter: row => (
|
||||||
|
// <ElTag type={getBooleanTagType(row.allowCreateRequirement)}>
|
||||||
|
// {getBooleanLabel(row.allowCreateRequirement)}
|
||||||
|
// </ElTag>
|
||||||
|
// )
|
||||||
|
// },
|
||||||
|
{ prop: 'sort', label: '排序', width: 90, align: 'center' },
|
||||||
|
{
|
||||||
|
prop: 'remark',
|
||||||
|
label: '备注',
|
||||||
|
minWidth: 180,
|
||||||
|
showOverflowTooltip: true,
|
||||||
|
formatter: row => row.remark || '--'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'createTime',
|
||||||
|
label: '创建时间',
|
||||||
|
minWidth: 170,
|
||||||
|
formatter: row => formatDateTime(row.createTime)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'operate',
|
||||||
|
label: '操作',
|
||||||
|
width: 220,
|
||||||
|
align: 'center',
|
||||||
|
fixed: 'right',
|
||||||
|
formatter: row => {
|
||||||
|
const actions = getStatusModelActions(row);
|
||||||
|
|
||||||
|
if (!actions.length) {
|
||||||
|
return <span>--</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BusinessTableActionCell actions={actions} variant="icon" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const { bool: operateVisible, setTrue: openOperateModal, setFalse: closeOperateModal } = useBoolean();
|
||||||
|
const operateType = ref<UI.TableOperateType>('add');
|
||||||
|
const editingData = ref<Api.Infra.ObjectStatusModel | null>(null);
|
||||||
|
|
||||||
|
const { bool: transitionVisible, setTrue: openTransitionModal, setFalse: closeTransitionModal } = useBoolean();
|
||||||
|
const transitionRow = ref<Api.Infra.ObjectStatusModel | null>(null);
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
operateType.value = 'add';
|
||||||
|
editingData.value = null;
|
||||||
|
openOperateModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: Api.Infra.ObjectStatusModel) {
|
||||||
|
operateType.value = 'edit';
|
||||||
|
editingData.value = item;
|
||||||
|
openOperateModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTransitionDialog(item: Api.Infra.ObjectStatusModel) {
|
||||||
|
transitionRow.value = item;
|
||||||
|
openTransitionModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(item: Api.Infra.ObjectStatusModel) {
|
||||||
|
const { error } = await fetchDeleteObjectStatusModel(item.id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success('删除成功');
|
||||||
|
await reloadStatusTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAction(row: Api.Infra.ObjectStatusModel) {
|
||||||
|
try {
|
||||||
|
await window.$messageBox?.confirm('确认删除当前状态模型吗?', '警告', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleDelete(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchDelete() {
|
||||||
|
if (!checkedRowKeys.value.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await fetchBatchDeleteObjectStatusModel(checkedRowKeys.value);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success('删除成功');
|
||||||
|
await reloadStatusTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectionChange(rows: Api.Infra.ObjectStatusModel[]) {
|
||||||
|
checkedRowKeys.value = rows.map(item => item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadStatusTable(page = searchParams.pageNo) {
|
||||||
|
checkedRowKeys.value = [];
|
||||||
|
await getDataByPage(page);
|
||||||
|
await nextTick();
|
||||||
|
stateTableRef.value?.clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSearchParams() {
|
||||||
|
Object.assign(searchParams, getInitSearchParams());
|
||||||
|
reloadStatusTable(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
reloadStatusTable(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmitted() {
|
||||||
|
closeOperateModal();
|
||||||
|
reloadStatusTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
resetSearchParams();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex-col-stretch gap-16px overflow-hidden">
|
||||||
|
<StateMachineSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||||
|
|
||||||
|
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between gap-12px">
|
||||||
|
<div class="flex items-center gap-10px">
|
||||||
|
<p>状态模型列表</p>
|
||||||
|
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||||
|
</div>
|
||||||
|
<TableHeaderOperation
|
||||||
|
v-model:columns="columnChecks"
|
||||||
|
:disabled-delete="checkedRowKeys.length === 0"
|
||||||
|
:loading="loading"
|
||||||
|
@refresh="getData"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<ElButton v-auth="'infra:state-machine:create'" plain type="primary" @click="openAdd">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-plus class="text-icon" />
|
||||||
|
</template>
|
||||||
|
新增
|
||||||
|
</ElButton>
|
||||||
|
<ElPopconfirm
|
||||||
|
v-if="canDeleteStateMachine"
|
||||||
|
title="确认删除选中的状态模型吗?"
|
||||||
|
@confirm="handleBatchDelete"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<ElButton type="danger" plain :disabled="checkedRowKeys.length === 0">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-delete class="text-icon" />
|
||||||
|
</template>
|
||||||
|
批量删除
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElPopconfirm>
|
||||||
|
</template>
|
||||||
|
</TableHeaderOperation>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<ElTable
|
||||||
|
ref="stateTableRef"
|
||||||
|
v-loading="loading"
|
||||||
|
height="100%"
|
||||||
|
border
|
||||||
|
row-key="id"
|
||||||
|
:data="data"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
|
>
|
||||||
|
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||||
|
</ElTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-20px flex justify-end">
|
||||||
|
<ElPagination
|
||||||
|
v-if="mobilePagination.total"
|
||||||
|
layout="total,prev,pager,next,sizes"
|
||||||
|
v-bind="mobilePagination"
|
||||||
|
@current-change="mobilePagination['current-change']"
|
||||||
|
@size-change="mobilePagination['size-change']"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<StateMachineOperateDialog
|
||||||
|
v-model:visible="operateVisible"
|
||||||
|
:operate-type="operateType"
|
||||||
|
:row-data="editingData"
|
||||||
|
@submitted="handleSubmitted"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StateTransitionDialog
|
||||||
|
v-model:visible="transitionVisible"
|
||||||
|
:current-status="transitionRow"
|
||||||
|
@update:visible="value => !value && closeTransitionModal()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import { OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||||
|
import { fetchCreateObjectStatusModel, fetchGetObjectStatusModel, fetchUpdateObjectStatusModel } from '@/service/api';
|
||||||
|
import { useDict } from '@/hooks/business/dict';
|
||||||
|
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
import { statusOptions } from '../shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'StateMachineOperateDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
operateType: UI.TableOperateType;
|
||||||
|
rowData?: Api.Infra.ObjectStatusModel | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submitted: [statusModelId: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const { formRef, validate } = useForm();
|
||||||
|
const { createRequiredRule } = useFormRules();
|
||||||
|
const { dictOptions: objectTypeOptions } = useDict(OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE);
|
||||||
|
|
||||||
|
const detailLoading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const isEdit = computed(() => props.operateType === 'edit');
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
const titleMap: Record<UI.TableOperateType, string> = {
|
||||||
|
add: '新增状态模型',
|
||||||
|
edit: '编辑状态模型'
|
||||||
|
};
|
||||||
|
|
||||||
|
return titleMap[props.operateType];
|
||||||
|
});
|
||||||
|
|
||||||
|
type Model = Api.Infra.SaveObjectStatusModelParams;
|
||||||
|
|
||||||
|
const model = ref(createDefaultModel());
|
||||||
|
|
||||||
|
function createDefaultModel(): Model {
|
||||||
|
return {
|
||||||
|
objectType: 'product',
|
||||||
|
statusCode: '',
|
||||||
|
statusName: '',
|
||||||
|
sort: 0,
|
||||||
|
status: 0,
|
||||||
|
initialFlag: false,
|
||||||
|
terminalFlag: false,
|
||||||
|
allowEdit: false,
|
||||||
|
progressExcludedFlag: false,
|
||||||
|
allowCreateProject: false,
|
||||||
|
allowCreateRequirement: false,
|
||||||
|
remark: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
objectType: createRequiredRule('请选择对象类型'),
|
||||||
|
statusCode: createRequiredRule('请输入状态编码'),
|
||||||
|
statusName: createRequiredRule('请输入状态名称'),
|
||||||
|
sort: createRequiredRule('请输入排序值'),
|
||||||
|
status: createRequiredRule('请选择配置状态')
|
||||||
|
} satisfies Record<string, App.Global.FormRule>;
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initModel() {
|
||||||
|
model.value = createDefaultModel();
|
||||||
|
|
||||||
|
if (!isEdit.value || !props.rowData) {
|
||||||
|
await nextTick();
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailLoading.value = true;
|
||||||
|
|
||||||
|
const { error, data } = await fetchGetObjectStatusModel(props.rowData.id);
|
||||||
|
|
||||||
|
detailLoading.value = false;
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
model.value = {
|
||||||
|
objectType: data.objectType,
|
||||||
|
statusCode: data.statusCode,
|
||||||
|
statusName: data.statusName,
|
||||||
|
sort: data.sort ?? 0,
|
||||||
|
status: data.status,
|
||||||
|
initialFlag: data.initialFlag,
|
||||||
|
terminalFlag: data.terminalFlag,
|
||||||
|
allowEdit: data.allowEdit,
|
||||||
|
progressExcludedFlag: data.progressExcludedFlag,
|
||||||
|
allowCreateProject: data.allowCreateProject,
|
||||||
|
allowCreateRequirement: data.allowCreateRequirement,
|
||||||
|
remark: data.remark ?? ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
await validate();
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
|
||||||
|
const submitData: Api.Infra.SaveObjectStatusModelParams = {
|
||||||
|
...model.value,
|
||||||
|
statusCode: model.value.statusCode.trim(),
|
||||||
|
statusName: model.value.statusName.trim(),
|
||||||
|
remark: model.value.remark?.trim() || null
|
||||||
|
};
|
||||||
|
|
||||||
|
let statusModelId = props.rowData?.id ?? '';
|
||||||
|
|
||||||
|
if (isEdit.value && props.rowData) {
|
||||||
|
const { error } = await fetchUpdateObjectStatusModel({ id: props.rowData.id, ...submitData });
|
||||||
|
|
||||||
|
submitting.value = false;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { error, data } = await fetchCreateObjectStatusModel(submitData);
|
||||||
|
|
||||||
|
submitting.value = false;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusModelId = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success(isEdit.value ? '修改成功' : '新增成功');
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
emit('submitted', statusModelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(visible, value => {
|
||||||
|
if (value) {
|
||||||
|
initModel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="title"
|
||||||
|
preset="lg"
|
||||||
|
:loading="detailLoading"
|
||||||
|
:confirm-loading="submitting"
|
||||||
|
@confirm="handleSubmit"
|
||||||
|
>
|
||||||
|
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||||
|
<ElRow :gutter="16">
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="对象类型" prop="objectType">
|
||||||
|
<ElSelect
|
||||||
|
v-model="model.objectType"
|
||||||
|
class="w-full"
|
||||||
|
placeholder="请选择或输入对象类型"
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
clearable
|
||||||
|
:reserve-keyword="false"
|
||||||
|
>
|
||||||
|
<ElOption v-for="item in objectTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="状态编码" prop="statusCode">
|
||||||
|
<ElInput v-model="model.statusCode" placeholder="请输入状态编码" />
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="状态名称" prop="statusName">
|
||||||
|
<ElInput v-model="model.statusName" placeholder="请输入状态名称" />
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="排序值" prop="sort">
|
||||||
|
<ElInputNumber v-model="model.sort" class="w-full" :min="0" placeholder="请输入排序值" />
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="24">
|
||||||
|
<ElFormItem label="配置状态" prop="status">
|
||||||
|
<ElRadioGroup v-model="model.status" class="business-form-radio-group">
|
||||||
|
<ElRadio v-for="{ label, value } in statusOptions" :key="value" :value="value">
|
||||||
|
{{ label }}
|
||||||
|
</ElRadio>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="是否初始状态" prop="initialFlag">
|
||||||
|
<div class="business-form-switch-field">
|
||||||
|
<ElSwitch v-model="model.initialFlag" />
|
||||||
|
<span class="ml-8px text-12px text-[#606266]">{{ model.initialFlag ? '是' : '否' }}</span>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="是否终态" prop="terminalFlag">
|
||||||
|
<div class="business-form-switch-field">
|
||||||
|
<ElSwitch v-model="model.terminalFlag" />
|
||||||
|
<span class="ml-8px text-12px text-[#606266]">{{ model.terminalFlag ? '是' : '否' }}</span>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="允许编辑主数据" prop="allowEdit">
|
||||||
|
<div class="business-form-switch-field">
|
||||||
|
<ElSwitch v-model="model.allowEdit" />
|
||||||
|
<span class="ml-8px text-12px text-[#606266]">{{ model.allowEdit ? '是' : '否' }}</span>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="不参与上层进度统计" prop="progressExcludedFlag">
|
||||||
|
<div class="business-form-switch-field">
|
||||||
|
<ElSwitch v-model="model.progressExcludedFlag" />
|
||||||
|
<span class="ml-8px text-12px text-[#606266]">{{ model.progressExcludedFlag ? '是' : '否' }}</span>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="允许新建项目" prop="allowCreateProject">
|
||||||
|
<div class="business-form-switch-field">
|
||||||
|
<ElSwitch v-model="model.allowCreateProject" />
|
||||||
|
<span class="ml-8px text-12px text-[#606266]">{{ model.allowCreateProject ? '是' : '否' }}</span>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="允许新增需求" prop="allowCreateRequirement">
|
||||||
|
<div class="business-form-switch-field">
|
||||||
|
<ElSwitch v-model="model.allowCreateRequirement" />
|
||||||
|
<span class="ml-8px text-12px text-[#606266]">{{ model.allowCreateRequirement ? '是' : '否' }}</span>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="24">
|
||||||
|
<ElFormItem label="备注" prop="remark">
|
||||||
|
<ElInput v-model="model.remark" type="textarea" :rows="4" placeholder="请输入备注" />
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
</ElRow>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
142
src/views/infra/state-machine/modules/state-machine-search.vue
Normal file
142
src/views/infra/state-machine/modules/state-machine-search.vue
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue';
|
||||||
|
import { OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||||
|
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||||
|
import { statusOptions } from '../shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'StateMachineSearch' });
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
reset: [];
|
||||||
|
search: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const model = defineModel<Api.Infra.ObjectStatusModelSearchParams>('model', { required: true });
|
||||||
|
|
||||||
|
const booleanOptions = [
|
||||||
|
{ label: '是', value: 1 },
|
||||||
|
{ label: '否', value: 0 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const searchModel = reactive<{
|
||||||
|
keyword: string;
|
||||||
|
objectType?: string;
|
||||||
|
status?: Api.Infra.CommonStatus;
|
||||||
|
initialFlag?: number;
|
||||||
|
terminalFlag?: number;
|
||||||
|
}>({
|
||||||
|
keyword: '',
|
||||||
|
objectType: undefined,
|
||||||
|
status: undefined,
|
||||||
|
initialFlag: undefined,
|
||||||
|
terminalFlag: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
let syncingFromSource = false;
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
model.value.keyword,
|
||||||
|
model.value.objectType,
|
||||||
|
model.value.status,
|
||||||
|
model.value.initialFlag,
|
||||||
|
model.value.terminalFlag
|
||||||
|
] as const,
|
||||||
|
([keyword, objectType, status, initialFlag, terminalFlag]) => {
|
||||||
|
syncingFromSource = true;
|
||||||
|
searchModel.keyword = keyword ?? '';
|
||||||
|
searchModel.objectType = objectType;
|
||||||
|
searchModel.status = status;
|
||||||
|
|
||||||
|
if (initialFlag === undefined) {
|
||||||
|
searchModel.initialFlag = undefined;
|
||||||
|
} else {
|
||||||
|
searchModel.initialFlag = initialFlag ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (terminalFlag === undefined) {
|
||||||
|
searchModel.terminalFlag = undefined;
|
||||||
|
} else {
|
||||||
|
searchModel.terminalFlag = terminalFlag ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncingFromSource = false;
|
||||||
|
},
|
||||||
|
{ immediate: true, flush: 'sync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
searchModel.keyword,
|
||||||
|
searchModel.objectType,
|
||||||
|
searchModel.status,
|
||||||
|
searchModel.initialFlag,
|
||||||
|
searchModel.terminalFlag
|
||||||
|
] as const,
|
||||||
|
([keywordValue, objectType, status, initialFlag, terminalFlag]) => {
|
||||||
|
if (syncingFromSource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.value.keyword = keywordValue.trim() || undefined;
|
||||||
|
model.value.objectType = objectType;
|
||||||
|
model.value.status = status;
|
||||||
|
model.value.initialFlag = initialFlag === undefined ? undefined : initialFlag === 1;
|
||||||
|
model.value.terminalFlag = terminalFlag === undefined ? undefined : terminalFlag === 1;
|
||||||
|
},
|
||||||
|
{ flush: 'sync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const fields = computed<SearchField[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'objectType',
|
||||||
|
label: '对象类型',
|
||||||
|
type: 'dict',
|
||||||
|
placeholder: '请选择对象类型',
|
||||||
|
dictCode: OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'keyword',
|
||||||
|
label: '关键字',
|
||||||
|
type: 'input',
|
||||||
|
placeholder: '请输入状态名称或状态编码'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: '配置状态',
|
||||||
|
type: 'select',
|
||||||
|
placeholder: '请选择配置状态',
|
||||||
|
options: statusOptions
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'initialFlag',
|
||||||
|
label: '初始状态',
|
||||||
|
type: 'select',
|
||||||
|
placeholder: '请选择是否初始状态',
|
||||||
|
options: booleanOptions
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'terminalFlag',
|
||||||
|
label: '终态',
|
||||||
|
type: 'select',
|
||||||
|
placeholder: '请选择是否终态',
|
||||||
|
options: booleanOptions
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
emit('reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
emit('search');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TableSearchFields v-model="searchModel" :fields="fields" :columns="4" @reset="reset" @search="search" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
<script setup lang="tsx">
|
||||||
|
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||||
|
import type { TableInstance } from 'element-plus';
|
||||||
|
import { ElButton, ElTag } from 'element-plus';
|
||||||
|
import { useBoolean } from '@sa/hooks';
|
||||||
|
import { OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||||
|
import {
|
||||||
|
fetchBatchDeleteObjectStatusTransition,
|
||||||
|
fetchDeleteObjectStatusTransition,
|
||||||
|
fetchGetObjectStatusModelPage,
|
||||||
|
fetchGetObjectStatusTransitionPage
|
||||||
|
} from '@/service/api';
|
||||||
|
import { useDict } from '@/hooks/business/dict';
|
||||||
|
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
|
||||||
|
import { formatDateTime, getBooleanLabel, getBooleanTagType, getStatusLabel, getStatusTagType } from '../shared';
|
||||||
|
import StateTransitionOperateDialog from './state-transition-operate-dialog.vue';
|
||||||
|
import StateTransitionSearch from './state-transition-search.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'StateTransitionDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentStatus?: Api.Infra.ObjectStatusModel | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
function getInitSearchParams(): Api.Infra.ObjectStatusTransitionSearchParams {
|
||||||
|
return {
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
objectType: props.currentStatus?.objectType,
|
||||||
|
fromStatusCode: props.currentStatus?.statusCode,
|
||||||
|
actionCode: undefined,
|
||||||
|
actionName: undefined,
|
||||||
|
toStatusCode: undefined,
|
||||||
|
status: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformPageResult(
|
||||||
|
response: Awaited<ReturnType<typeof fetchGetObjectStatusTransitionPage>>,
|
||||||
|
pageNo: number,
|
||||||
|
pageSize: number
|
||||||
|
) {
|
||||||
|
if (!response.error) {
|
||||||
|
return {
|
||||||
|
data: response.data.list,
|
||||||
|
pageNum: pageNo,
|
||||||
|
pageSize,
|
||||||
|
total: response.data.total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
pageNum: pageNo,
|
||||||
|
pageSize,
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = reactive(getInitSearchParams());
|
||||||
|
const transitionTableRef = ref<TableInstance>();
|
||||||
|
const checkedRowKeys = ref<string[]>([]);
|
||||||
|
const statusModelOptions = ref<Api.Infra.ObjectStatusModel[]>([]);
|
||||||
|
const loadingOptions = ref(false);
|
||||||
|
const { getLabel: getObjectTypeLabel } = useDict(OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE);
|
||||||
|
|
||||||
|
const targetStatusOptions = computed(() =>
|
||||||
|
statusModelOptions.value.map(item => ({
|
||||||
|
label: `${item.statusName} (${item.statusCode})`,
|
||||||
|
value: item.statusCode
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentStatusLabel = computed(() => {
|
||||||
|
if (!props.currentStatus) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${props.currentStatus.statusName} (${props.currentStatus.statusCode})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentObjectTypeLabel = computed(() => getObjectTypeLabel(props.currentStatus?.objectType));
|
||||||
|
|
||||||
|
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable({
|
||||||
|
paginationProps: {
|
||||||
|
currentPage: searchParams.pageNo,
|
||||||
|
pageSize: searchParams.pageSize
|
||||||
|
},
|
||||||
|
api: () => fetchGetObjectStatusTransitionPage(searchParams),
|
||||||
|
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||||
|
onPaginationParamsChange: params => {
|
||||||
|
searchParams.pageNo = params.currentPage ?? 1;
|
||||||
|
searchParams.pageSize = params.pageSize ?? 10;
|
||||||
|
},
|
||||||
|
columns: () => [
|
||||||
|
{ prop: 'selection', type: 'selection', width: 48 },
|
||||||
|
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||||
|
{ prop: 'actionName', label: '动作名称', minWidth: 150, showOverflowTooltip: true },
|
||||||
|
{ prop: 'actionCode', label: '动作编码', minWidth: 150, showOverflowTooltip: true },
|
||||||
|
{
|
||||||
|
prop: 'toStatusCode',
|
||||||
|
label: '目标状态',
|
||||||
|
minWidth: 180,
|
||||||
|
formatter: row => row.toStatusName?.trim() || row.toStatusCode
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'needReason',
|
||||||
|
label: '必须填写原因',
|
||||||
|
width: 120,
|
||||||
|
align: 'center',
|
||||||
|
formatter: row => <ElTag type={getBooleanTagType(row.needReason)}>{getBooleanLabel(row.needReason)}</ElTag>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'status',
|
||||||
|
label: '配置状态',
|
||||||
|
width: 110,
|
||||||
|
align: 'center',
|
||||||
|
formatter: row => <ElTag type={getStatusTagType(row.status)}>{getStatusLabel(row.status)}</ElTag>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'remark',
|
||||||
|
label: '备注',
|
||||||
|
minWidth: 180,
|
||||||
|
showOverflowTooltip: true,
|
||||||
|
formatter: row => row.remark || '--'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'createTime',
|
||||||
|
label: '创建时间',
|
||||||
|
minWidth: 170,
|
||||||
|
formatter: row => formatDateTime(row.createTime)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'operate',
|
||||||
|
label: '操作',
|
||||||
|
width: 180,
|
||||||
|
align: 'center',
|
||||||
|
fixed: 'right',
|
||||||
|
formatter: row => (
|
||||||
|
<BusinessTableActionCell
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: 'edit',
|
||||||
|
label: '编辑',
|
||||||
|
buttonType: 'primary',
|
||||||
|
onClick: () => openEdit(row)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
label: '删除',
|
||||||
|
buttonType: 'danger',
|
||||||
|
onClick: () => handleDeleteAction(row)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const { bool: operateVisible, setTrue: openOperateModal, setFalse: closeOperateModal } = useBoolean();
|
||||||
|
const operateType = ref<UI.TableOperateType>('add');
|
||||||
|
const editingData = ref<Api.Infra.ObjectStatusTransition | null>(null);
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
operateType.value = 'add';
|
||||||
|
editingData.value = null;
|
||||||
|
openOperateModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: Api.Infra.ObjectStatusTransition) {
|
||||||
|
operateType.value = 'edit';
|
||||||
|
editingData.value = item;
|
||||||
|
openOperateModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(item: Api.Infra.ObjectStatusTransition) {
|
||||||
|
const { error } = await fetchDeleteObjectStatusTransition(item.id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success('删除成功');
|
||||||
|
await reloadTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAction(row: Api.Infra.ObjectStatusTransition) {
|
||||||
|
try {
|
||||||
|
await window.$messageBox?.confirm('确认删除当前状态流转吗?', '警告', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleDelete(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchDelete() {
|
||||||
|
if (!checkedRowKeys.value.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await fetchBatchDeleteObjectStatusTransition(checkedRowKeys.value);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success('删除成功');
|
||||||
|
await reloadTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectionChange(rows: Api.Infra.ObjectStatusTransition[]) {
|
||||||
|
checkedRowKeys.value = rows.map(item => item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadTable(page = searchParams.pageNo) {
|
||||||
|
checkedRowKeys.value = [];
|
||||||
|
await getDataByPage(page);
|
||||||
|
await nextTick();
|
||||||
|
transitionTableRef.value?.clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSearchParams() {
|
||||||
|
Object.assign(searchParams, getInitSearchParams());
|
||||||
|
reloadTable(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
reloadTable(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmitted() {
|
||||||
|
closeOperateModal();
|
||||||
|
reloadTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatusModelOptions() {
|
||||||
|
if (!props.currentStatus?.objectType) {
|
||||||
|
statusModelOptions.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingOptions.value = true;
|
||||||
|
|
||||||
|
const { error, data: page } = await fetchGetObjectStatusModelPage({
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 200,
|
||||||
|
keyword: undefined,
|
||||||
|
objectType: props.currentStatus.objectType,
|
||||||
|
status: undefined,
|
||||||
|
initialFlag: undefined,
|
||||||
|
terminalFlag: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
loadingOptions.value = false;
|
||||||
|
|
||||||
|
statusModelOptions.value = error ? [] : page.list;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initDialog() {
|
||||||
|
if (!props.currentStatus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(searchParams, getInitSearchParams(), {
|
||||||
|
objectType: props.currentStatus.objectType,
|
||||||
|
fromStatusCode: props.currentStatus.statusCode
|
||||||
|
});
|
||||||
|
|
||||||
|
checkedRowKeys.value = [];
|
||||||
|
|
||||||
|
await Promise.all([loadStatusModelOptions(), reloadTable(1)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [visible.value, props.currentStatus?.id] as const,
|
||||||
|
([opened]) => {
|
||||||
|
if (opened) {
|
||||||
|
initDialog();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="visible"
|
||||||
|
title="状态流转配置"
|
||||||
|
width="1200px"
|
||||||
|
:loading="loadingOptions"
|
||||||
|
:show-footer="false"
|
||||||
|
:scrollbar="false"
|
||||||
|
>
|
||||||
|
<div v-if="currentStatus" class="state-transition-dialog">
|
||||||
|
<StateTransitionSearch
|
||||||
|
v-model:model="searchParams"
|
||||||
|
:target-status-options="targetStatusOptions"
|
||||||
|
@reset="resetSearchParams"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between gap-12px">
|
||||||
|
<div class="min-w-0 flex flex-wrap items-center gap-8px">
|
||||||
|
<p>状态流转列表</p>
|
||||||
|
<ElTag type="primary" effect="light">
|
||||||
|
{{ currentObjectTypeLabel }}
|
||||||
|
</ElTag>
|
||||||
|
<ElTag type="success" effect="light">
|
||||||
|
{{ currentStatusLabel }}
|
||||||
|
</ElTag>
|
||||||
|
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||||
|
</div>
|
||||||
|
<TableHeaderOperation
|
||||||
|
v-model:columns="columnChecks"
|
||||||
|
:disabled-delete="checkedRowKeys.length === 0"
|
||||||
|
:loading="loading"
|
||||||
|
@refresh="getData"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<ElButton plain type="primary" @click="openAdd">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-plus class="text-icon" />
|
||||||
|
</template>
|
||||||
|
新增
|
||||||
|
</ElButton>
|
||||||
|
<ElPopconfirm title="确认删除选中的状态流转吗?" @confirm="handleBatchDelete">
|
||||||
|
<template #reference>
|
||||||
|
<ElButton type="danger" plain :disabled="checkedRowKeys.length === 0">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-delete class="text-icon" />
|
||||||
|
</template>
|
||||||
|
批量删除
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElPopconfirm>
|
||||||
|
</template>
|
||||||
|
</TableHeaderOperation>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<ElTable
|
||||||
|
ref="transitionTableRef"
|
||||||
|
v-loading="loading"
|
||||||
|
height="100%"
|
||||||
|
border
|
||||||
|
row-key="id"
|
||||||
|
:data="data"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
|
>
|
||||||
|
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||||
|
</ElTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-20px flex justify-end">
|
||||||
|
<ElPagination
|
||||||
|
v-if="mobilePagination.total"
|
||||||
|
layout="total,prev,pager,next,sizes"
|
||||||
|
v-bind="mobilePagination"
|
||||||
|
@current-change="mobilePagination['current-change']"
|
||||||
|
@size-change="mobilePagination['size-change']"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="h-full flex items-center justify-center">
|
||||||
|
<ElEmpty description="请选择状态模型" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StateTransitionOperateDialog
|
||||||
|
v-model:visible="operateVisible"
|
||||||
|
:operate-type="operateType"
|
||||||
|
:row-data="editingData"
|
||||||
|
:current-status="currentStatus"
|
||||||
|
:target-status-options="targetStatusOptions"
|
||||||
|
append-to-body
|
||||||
|
@submitted="handleSubmitted"
|
||||||
|
/>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.state-transition-dialog {
|
||||||
|
display: flex;
|
||||||
|
min-height: 560px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import { OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||||
|
import {
|
||||||
|
fetchCreateObjectStatusTransition,
|
||||||
|
fetchGetObjectStatusTransition,
|
||||||
|
fetchUpdateObjectStatusTransition
|
||||||
|
} from '@/service/api';
|
||||||
|
import { useDict } from '@/hooks/business/dict';
|
||||||
|
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
import { statusOptions } from '../shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'StateTransitionOperateDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
operateType: UI.TableOperateType;
|
||||||
|
rowData?: Api.Infra.ObjectStatusTransition | null;
|
||||||
|
currentStatus?: Api.Infra.ObjectStatusModel | null;
|
||||||
|
targetStatusOptions: Array<{ label: string; value: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submitted: [transitionId: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const { formRef, validate } = useForm();
|
||||||
|
const { createRequiredRule } = useFormRules();
|
||||||
|
const { getLabel: getObjectTypeLabel } = useDict(OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE);
|
||||||
|
|
||||||
|
const detailLoading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const isEdit = computed(() => props.operateType === 'edit');
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
const titleMap: Record<UI.TableOperateType, string> = {
|
||||||
|
add: '新增状态流转',
|
||||||
|
edit: '编辑状态流转'
|
||||||
|
};
|
||||||
|
|
||||||
|
return titleMap[props.operateType];
|
||||||
|
});
|
||||||
|
|
||||||
|
type Model = Api.Infra.SaveObjectStatusTransitionParams;
|
||||||
|
|
||||||
|
const model = ref(createDefaultModel());
|
||||||
|
|
||||||
|
const currentObjectTypeLabel = computed(() => getObjectTypeLabel(model.value.objectType));
|
||||||
|
|
||||||
|
const currentFromStatusLabel = computed(() => {
|
||||||
|
if (!props.currentStatus) {
|
||||||
|
return model.value.fromStatusCode || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${props.currentStatus.statusName} (${props.currentStatus.statusCode})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function createDefaultModel(): Model {
|
||||||
|
return {
|
||||||
|
objectType: props.currentStatus?.objectType ?? 'product',
|
||||||
|
actionCode: '',
|
||||||
|
actionName: '',
|
||||||
|
fromStatusCode: props.currentStatus?.statusCode ?? '',
|
||||||
|
toStatusCode: '',
|
||||||
|
needReason: false,
|
||||||
|
status: 0,
|
||||||
|
remark: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
actionCode: createRequiredRule('请输入动作编码'),
|
||||||
|
actionName: createRequiredRule('请输入动作名称'),
|
||||||
|
toStatusCode: createRequiredRule('请选择目标状态'),
|
||||||
|
status: createRequiredRule('请选择配置状态')
|
||||||
|
} satisfies Record<string, App.Global.FormRule>;
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initModel() {
|
||||||
|
model.value = createDefaultModel();
|
||||||
|
|
||||||
|
if (!isEdit.value || !props.rowData) {
|
||||||
|
await nextTick();
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
detailLoading.value = true;
|
||||||
|
|
||||||
|
const { error, data } = await fetchGetObjectStatusTransition(props.rowData.id);
|
||||||
|
|
||||||
|
detailLoading.value = false;
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
model.value = {
|
||||||
|
objectType: data.objectType,
|
||||||
|
actionCode: data.actionCode,
|
||||||
|
actionName: data.actionName,
|
||||||
|
fromStatusCode: data.fromStatusCode,
|
||||||
|
toStatusCode: data.toStatusCode,
|
||||||
|
needReason: data.needReason,
|
||||||
|
status: data.status,
|
||||||
|
remark: data.remark ?? ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
await validate();
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
|
||||||
|
const submitData: Api.Infra.SaveObjectStatusTransitionParams = {
|
||||||
|
...model.value,
|
||||||
|
objectType: props.currentStatus?.objectType ?? model.value.objectType,
|
||||||
|
fromStatusCode: props.currentStatus?.statusCode ?? model.value.fromStatusCode,
|
||||||
|
actionCode: model.value.actionCode.trim(),
|
||||||
|
actionName: model.value.actionName.trim(),
|
||||||
|
remark: model.value.remark?.trim() || null
|
||||||
|
};
|
||||||
|
|
||||||
|
let transitionId = props.rowData?.id ?? '';
|
||||||
|
|
||||||
|
if (isEdit.value && props.rowData) {
|
||||||
|
const { error } = await fetchUpdateObjectStatusTransition({ id: props.rowData.id, ...submitData });
|
||||||
|
|
||||||
|
submitting.value = false;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { error, data } = await fetchCreateObjectStatusTransition(submitData);
|
||||||
|
|
||||||
|
submitting.value = false;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
transitionId = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success(isEdit.value ? '修改成功' : '新增成功');
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
emit('submitted', transitionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(visible, value => {
|
||||||
|
if (value) {
|
||||||
|
initModel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="title"
|
||||||
|
preset="md"
|
||||||
|
:loading="detailLoading"
|
||||||
|
:confirm-loading="submitting"
|
||||||
|
@confirm="handleSubmit"
|
||||||
|
>
|
||||||
|
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||||
|
<ElRow :gutter="16">
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="对象类型">
|
||||||
|
<ElInput :model-value="currentObjectTypeLabel" readonly />
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="起始状态">
|
||||||
|
<ElInput :model-value="currentFromStatusLabel" readonly />
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="动作编码" prop="actionCode">
|
||||||
|
<ElInput v-model="model.actionCode" placeholder="请输入动作编码" />
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="动作名称" prop="actionName">
|
||||||
|
<ElInput v-model="model.actionName" placeholder="请输入动作名称" />
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="目标状态" prop="toStatusCode">
|
||||||
|
<ElSelect v-model="model.toStatusCode" class="w-full" placeholder="请选择目标状态">
|
||||||
|
<ElOption v-for="{ label, value } in targetStatusOptions" :key="value" :label="label" :value="value" />
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="配置状态" prop="status">
|
||||||
|
<ElRadioGroup v-model="model.status" class="business-form-radio-group">
|
||||||
|
<ElRadio v-for="{ label, value } in statusOptions" :key="value" :value="value">
|
||||||
|
{{ label }}
|
||||||
|
</ElRadio>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="必须填写原因" prop="needReason">
|
||||||
|
<div class="business-form-switch-field">
|
||||||
|
<ElSwitch v-model="model.needReason" />
|
||||||
|
<span class="ml-8px text-12px text-[#606266]">{{ model.needReason ? '是' : '否' }}</span>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="24">
|
||||||
|
<ElFormItem label="备注" prop="remark">
|
||||||
|
<ElInput v-model="model.remark" type="textarea" :rows="4" placeholder="请输入备注" />
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
</ElRow>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue';
|
||||||
|
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||||
|
import { statusOptions } from '../shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'StateTransitionSearch' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
targetStatusOptions: Array<{ label: string; value: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
reset: [];
|
||||||
|
search: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const model = defineModel<Api.Infra.ObjectStatusTransitionSearchParams>('model', { required: true });
|
||||||
|
|
||||||
|
const searchModel = reactive<{
|
||||||
|
keyword: string;
|
||||||
|
toStatusCode?: string;
|
||||||
|
status?: Api.Infra.CommonStatus;
|
||||||
|
}>({
|
||||||
|
keyword: '',
|
||||||
|
toStatusCode: undefined,
|
||||||
|
status: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
let syncingFromSource = false;
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [model.value.actionName, model.value.actionCode, model.value.toStatusCode, model.value.status] as const,
|
||||||
|
([actionName, actionCode, toStatusCode, status]) => {
|
||||||
|
syncingFromSource = true;
|
||||||
|
searchModel.keyword = actionName ?? actionCode ?? '';
|
||||||
|
searchModel.toStatusCode = toStatusCode;
|
||||||
|
searchModel.status = status;
|
||||||
|
syncingFromSource = false;
|
||||||
|
},
|
||||||
|
{ immediate: true, flush: 'sync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [searchModel.keyword, searchModel.toStatusCode, searchModel.status] as const,
|
||||||
|
([keywordValue, toStatusCode, status]) => {
|
||||||
|
if (syncingFromSource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywordText = keywordValue.trim() || undefined;
|
||||||
|
model.value.actionName = keywordText;
|
||||||
|
model.value.actionCode = keywordText;
|
||||||
|
model.value.toStatusCode = toStatusCode;
|
||||||
|
model.value.status = status;
|
||||||
|
},
|
||||||
|
{ flush: 'sync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const fields = computed<SearchField[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'keyword',
|
||||||
|
label: '动作名称',
|
||||||
|
type: 'input',
|
||||||
|
placeholder: '请输入动作名称或动作编码'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'toStatusCode',
|
||||||
|
label: '目标状态',
|
||||||
|
type: 'select',
|
||||||
|
placeholder: '请选择目标状态',
|
||||||
|
options: props.targetStatusOptions
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: '配置状态',
|
||||||
|
type: 'select',
|
||||||
|
placeholder: '请选择配置状态',
|
||||||
|
options: statusOptions
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
emit('reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
emit('search');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TableSearchFields v-model="searchModel" :fields="fields" :columns="4" @reset="reset" @search="search" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
38
src/views/infra/state-machine/shared.ts
Normal file
38
src/views/infra/state-machine/shared.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export const statusOptions: Array<{ label: string; value: Api.Infra.CommonStatus }> = [
|
||||||
|
{ label: '启用', value: 0 },
|
||||||
|
{ label: '停用', value: 1 }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getStatusLabel(value?: Api.Infra.CommonStatus | null) {
|
||||||
|
if (value === 0) {
|
||||||
|
return '启用';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 1) {
|
||||||
|
return '停用';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatusTagType(value?: Api.Infra.CommonStatus | null): UI.ThemeColor {
|
||||||
|
return value === 0 ? 'success' : 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBooleanLabel(value?: boolean | null) {
|
||||||
|
return value ? '是' : '否';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBooleanTagType(value?: boolean | null): UI.ThemeColor {
|
||||||
|
return value ? 'success' : 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value?: string | number | null) {
|
||||||
|
if (!value) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
}
|
||||||
3
src/views/metrics/member-efficiency/index.vue
Normal file
3
src/views/metrics/member-efficiency/index.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<LookForward title="员工能效" subtitle="功能建设中,敬请期待" />
|
||||||
|
</template>
|
||||||
3
src/views/metrics/project-progress/index.vue
Normal file
3
src/views/metrics/project-progress/index.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<LookForward title="项目进度" subtitle="功能建设中,敬请期待" />
|
||||||
|
</template>
|
||||||
3
src/views/metrics/worktime/index.vue
Normal file
3
src/views/metrics/worktime/index.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<LookForward title="工时统计" subtitle="功能建设中,敬请期待" />
|
||||||
|
</template>
|
||||||
3
src/views/personal-center/my-application/index.vue
Normal file
3
src/views/personal-center/my-application/index.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<LookForward title="我的申请" subtitle="功能建设中,敬请期待" />
|
||||||
|
</template>
|
||||||
667
src/views/personal-center/my-item/index.vue
Normal file
667
src/views/personal-center/my-item/index.vue
Normal file
@@ -0,0 +1,667 @@
|
|||||||
|
<script setup lang="tsx">
|
||||||
|
import { computed, markRaw, nextTick, onActivated, reactive, ref } from 'vue';
|
||||||
|
import type { TableInstance } from 'element-plus';
|
||||||
|
import { ElButton, ElMessageBox, ElTag, ElTooltip } from 'element-plus';
|
||||||
|
import { useBoolean } from '@sa/hooks';
|
||||||
|
import {
|
||||||
|
fetchBatchDeletePersonalItems,
|
||||||
|
fetchBindPersonalItemsToExecution,
|
||||||
|
fetchChangePersonalItemStatus,
|
||||||
|
fetchDeletePersonalItem,
|
||||||
|
fetchGetPersonalItemDetail,
|
||||||
|
fetchGetPersonalItemPage
|
||||||
|
} from '@/service/api';
|
||||||
|
import { useAuthStore } from '@/store/modules/auth';
|
||||||
|
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||||
|
import PersonalItemBindExecutionDialog from './modules/personal-item-bind-execution-dialog.vue';
|
||||||
|
import PersonalItemDetailDialog from './modules/personal-item-detail-dialog.vue';
|
||||||
|
import PersonalItemOperateDialog from './modules/personal-item-operate-dialog.vue';
|
||||||
|
import PersonalItemSearch from './modules/personal-item-search.vue';
|
||||||
|
import PersonalItemStatusActionDialog from './modules/personal-item-status-action-dialog.vue';
|
||||||
|
import {
|
||||||
|
formatPersonalItemDateRange,
|
||||||
|
formatPersonalItemDateTime,
|
||||||
|
formatPersonalItemOwnerName,
|
||||||
|
formatPersonalItemProgress,
|
||||||
|
getPersonalItemStatusLabel,
|
||||||
|
resolvePersonalItemStatusTagType
|
||||||
|
} from './modules/personal-item-shared';
|
||||||
|
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
|
||||||
|
import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline';
|
||||||
|
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
|
||||||
|
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||||
|
import IconMdiPause from '~icons/mdi/pause';
|
||||||
|
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||||
|
import IconMdiPlay from '~icons/mdi/play';
|
||||||
|
import IconMdiRestart from '~icons/mdi/restart';
|
||||||
|
import IconMdiSync from '~icons/mdi/sync';
|
||||||
|
|
||||||
|
defineOptions({ name: 'MyItem' });
|
||||||
|
|
||||||
|
type DetailTab = 'worklog';
|
||||||
|
type PersonalItemOperateType = UI.TableOperateType | 'view';
|
||||||
|
|
||||||
|
interface PersonalItemRowAction {
|
||||||
|
key: string;
|
||||||
|
tooltip: string;
|
||||||
|
icon: object;
|
||||||
|
type: 'primary' | 'success' | 'warning' | 'danger';
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lifecycleActionIconMap: Record<string, object> = {
|
||||||
|
start: markRaw(IconMdiPlay),
|
||||||
|
pause: markRaw(IconMdiPause),
|
||||||
|
resume: markRaw(IconMdiRestart),
|
||||||
|
reopen: markRaw(IconMdiRestart),
|
||||||
|
cancel: markRaw(IconMdiCloseCircleOutline),
|
||||||
|
complete: markRaw(IconMdiCheckCircleOutline)
|
||||||
|
};
|
||||||
|
|
||||||
|
const lifecycleActionTypeMap: Record<string, PersonalItemRowAction['type']> = {
|
||||||
|
cancel: 'danger',
|
||||||
|
pause: 'warning',
|
||||||
|
complete: 'success',
|
||||||
|
resume: 'primary',
|
||||||
|
reopen: 'primary',
|
||||||
|
start: 'primary'
|
||||||
|
};
|
||||||
|
|
||||||
|
const lifecycleActionOrder: Record<string, number> = {
|
||||||
|
pause: 1,
|
||||||
|
cancel: 2,
|
||||||
|
complete: 3,
|
||||||
|
resume: 4,
|
||||||
|
reopen: 5,
|
||||||
|
start: 6
|
||||||
|
};
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const currentUserId = computed(() => {
|
||||||
|
const rawUserId = authStore.userInfo.userId;
|
||||||
|
|
||||||
|
return rawUserId ? String(rawUserId) : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
function getInitSearchParams(): Api.PersonalItem.PersonalItemSearchParams {
|
||||||
|
return {
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
keyword: undefined,
|
||||||
|
ownerId: currentUserId.value || undefined,
|
||||||
|
statusCode: undefined,
|
||||||
|
updateTime: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformPageResult(
|
||||||
|
response: Awaited<ReturnType<typeof fetchGetPersonalItemPage>>,
|
||||||
|
pageNo: number,
|
||||||
|
pageSize: number
|
||||||
|
) {
|
||||||
|
if (!response.error) {
|
||||||
|
return {
|
||||||
|
data: response.data.list,
|
||||||
|
pageNum: pageNo,
|
||||||
|
pageSize,
|
||||||
|
total: response.data.total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
pageNum: pageNo,
|
||||||
|
pageSize,
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = reactive(getInitSearchParams());
|
||||||
|
const tableRef = ref<TableInstance>();
|
||||||
|
const checkedRowIds = ref<string[]>([]);
|
||||||
|
const bindExecutionSubmitting = ref(false);
|
||||||
|
|
||||||
|
const selectedCount = computed(() => checkedRowIds.value.length);
|
||||||
|
|
||||||
|
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable({
|
||||||
|
paginationProps: {
|
||||||
|
currentPage: searchParams.pageNo,
|
||||||
|
pageSize: searchParams.pageSize
|
||||||
|
},
|
||||||
|
api: () => fetchGetPersonalItemPage(searchParams),
|
||||||
|
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||||
|
onPaginationParamsChange: params => {
|
||||||
|
searchParams.pageNo = params.currentPage ?? 1;
|
||||||
|
searchParams.pageSize = params.pageSize ?? 10;
|
||||||
|
},
|
||||||
|
columns: () => [
|
||||||
|
{ prop: 'selection', type: 'selection', width: 48 },
|
||||||
|
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||||
|
{
|
||||||
|
prop: 'taskTitle',
|
||||||
|
label: '事项标题',
|
||||||
|
minWidth: 260,
|
||||||
|
showOverflowTooltip: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'ownerName',
|
||||||
|
label: '负责人',
|
||||||
|
minWidth: 140,
|
||||||
|
formatter: row => formatPersonalItemOwnerName(row)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'statusCode',
|
||||||
|
label: '状态',
|
||||||
|
width: 120,
|
||||||
|
align: 'center',
|
||||||
|
formatter: row => (
|
||||||
|
<ElTag type={resolvePersonalItemStatusTagType(row.statusCode)}>
|
||||||
|
{getPersonalItemStatusLabel(row.statusCode)}
|
||||||
|
</ElTag>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'progressRate',
|
||||||
|
label: '进度',
|
||||||
|
width: 100,
|
||||||
|
align: 'center',
|
||||||
|
formatter: row => formatPersonalItemProgress(row.progressRate)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'plannedDateRange',
|
||||||
|
label: '计划日期',
|
||||||
|
minWidth: 220,
|
||||||
|
formatter: row => formatPersonalItemDateRange(row.plannedStartDate, row.plannedEndDate)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'actualDateRange',
|
||||||
|
label: '实际日期',
|
||||||
|
minWidth: 220,
|
||||||
|
formatter: row => formatPersonalItemDateRange(row.actualStartDate, row.actualEndDate)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'updateTime',
|
||||||
|
label: '最近更新',
|
||||||
|
minWidth: 180,
|
||||||
|
formatter: row => formatPersonalItemDateTime(row.updateTime)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'operate',
|
||||||
|
label: '操作',
|
||||||
|
width: 240,
|
||||||
|
align: 'center',
|
||||||
|
fixed: 'right',
|
||||||
|
formatter: row => renderRowActions(row)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const { bool: operateVisible, setTrue: openOperateDialog, setFalse: closeOperateDialog } = useBoolean();
|
||||||
|
const { bool: detailVisible, setTrue: openDetailDialog } = useBoolean();
|
||||||
|
const {
|
||||||
|
bool: bindExecutionVisible,
|
||||||
|
setTrue: openBindExecutionDialog,
|
||||||
|
setFalse: closeBindExecutionDialog
|
||||||
|
} = useBoolean();
|
||||||
|
const { bool: statusActionVisible, setTrue: openStatusActionDialog, setFalse: closeStatusActionDialog } = useBoolean();
|
||||||
|
|
||||||
|
const operateType = ref<PersonalItemOperateType>('add');
|
||||||
|
const editingData = ref<Api.PersonalItem.PersonalItem | null>(null);
|
||||||
|
const detailData = ref<Api.PersonalItem.PersonalItem | null>(null);
|
||||||
|
const detailDefaultTab = ref<DetailTab>('worklog');
|
||||||
|
const currentStatusAction = ref<Api.PersonalItem.PersonalItemLifecycleAction | null>(null);
|
||||||
|
const currentStatusItem = ref<Api.PersonalItem.PersonalItem | null>(null);
|
||||||
|
|
||||||
|
async function openDetail(row: Api.PersonalItem.PersonalItem, defaultTab: DetailTab = 'worklog') {
|
||||||
|
const { error, data: latestDetail } = await fetchGetPersonalItemDetail(row.id);
|
||||||
|
|
||||||
|
detailData.value = error || !latestDetail ? row : latestDetail;
|
||||||
|
detailDefaultTab.value = defaultTab;
|
||||||
|
openDetailDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openView(row: Api.PersonalItem.PersonalItem) {
|
||||||
|
operateType.value = 'view';
|
||||||
|
editingData.value = row;
|
||||||
|
openOperateDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
// function createLifecycleAction(
|
||||||
|
// fallback: {
|
||||||
|
// key: string;
|
||||||
|
// tooltip: string;
|
||||||
|
// icon: object;
|
||||||
|
// type: PersonalItemRowAction['type'];
|
||||||
|
// actionCode: string;
|
||||||
|
// },
|
||||||
|
// action: Api.PersonalItem.PersonalItemLifecycleAction | null
|
||||||
|
// ): PersonalItemRowAction {
|
||||||
|
// return {
|
||||||
|
// key: fallback.key,
|
||||||
|
// tooltip: action?.actionName ?? fallback.tooltip,
|
||||||
|
// icon: fallback.icon,
|
||||||
|
// type: fallback.type,
|
||||||
|
// disabled: !action,
|
||||||
|
// onClick: async () =>
|
||||||
|
// handleStatusAction(currentStatusItem.value!, {
|
||||||
|
// actionCode: action?.actionCode ?? fallback.actionCode,
|
||||||
|
// actionName: action?.actionName ?? fallback.tooltip,
|
||||||
|
// needReason: action?.needReason ?? false
|
||||||
|
// })
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAction[] {
|
||||||
|
currentStatusItem.value = row;
|
||||||
|
|
||||||
|
const rawLifecycleActions = [...(row.availableActions ?? [])];
|
||||||
|
const pauseAction = rawLifecycleActions.find(action => action.actionCode === 'pause') ?? null;
|
||||||
|
const cancelAction = rawLifecycleActions.find(action => action.actionCode === 'cancel') ?? null;
|
||||||
|
const completeAction = rawLifecycleActions.find(action => action.actionCode === 'complete') ?? null;
|
||||||
|
|
||||||
|
const lifecycleActions = rawLifecycleActions
|
||||||
|
.filter(action => !['pause', 'cancel', 'complete'].includes(action.actionCode))
|
||||||
|
.sort(
|
||||||
|
(left, right) => (lifecycleActionOrder[left.actionCode] ?? 99) - (lifecycleActionOrder[right.actionCode] ?? 99)
|
||||||
|
)
|
||||||
|
.map(action => ({
|
||||||
|
key: `status-${action.actionCode}`,
|
||||||
|
tooltip: action.actionName,
|
||||||
|
icon: markRaw(lifecycleActionIconMap[action.actionCode] ?? IconMdiSync),
|
||||||
|
type: lifecycleActionTypeMap[action.actionCode] ?? 'primary',
|
||||||
|
onClick: async () => handleStatusAction(row, action)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'worklog',
|
||||||
|
tooltip: '填报',
|
||||||
|
icon: markRaw(IconMdiClipboardEditOutline),
|
||||||
|
type: 'primary',
|
||||||
|
onClick: async () => openDetail(row, 'worklog')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'edit',
|
||||||
|
tooltip: '编辑',
|
||||||
|
icon: markRaw(IconMdiPencilOutline),
|
||||||
|
type: 'primary',
|
||||||
|
onClick: async () => {
|
||||||
|
operateType.value = 'edit';
|
||||||
|
editingData.value = row;
|
||||||
|
openOperateDialog();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delete',
|
||||||
|
tooltip: '删除',
|
||||||
|
icon: markRaw(IconMdiDeleteOutline),
|
||||||
|
type: 'danger',
|
||||||
|
onClick: async () => handleDelete(row)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status-pause',
|
||||||
|
tooltip: pauseAction?.actionName ?? '暂停',
|
||||||
|
icon: markRaw(IconMdiPause),
|
||||||
|
type: 'warning',
|
||||||
|
disabled: !pauseAction,
|
||||||
|
onClick: async () =>
|
||||||
|
handleStatusAction(row, {
|
||||||
|
actionCode: pauseAction?.actionCode ?? 'pause',
|
||||||
|
actionName: pauseAction?.actionName ?? '暂停',
|
||||||
|
needReason: pauseAction?.needReason ?? false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// key: 'status-cancel',
|
||||||
|
// tooltip: cancelAction?.actionName ?? '取消',
|
||||||
|
// icon: markRaw(IconMdiCloseCircleOutline),
|
||||||
|
// type: 'danger',
|
||||||
|
// disabled: !cancelAction,
|
||||||
|
// onClick: async () =>
|
||||||
|
// handleStatusAction(row, {
|
||||||
|
// actionCode: cancelAction?.actionCode ?? 'cancel',
|
||||||
|
// actionName: cancelAction?.actionName ?? '取消',
|
||||||
|
// needReason: cancelAction?.needReason ?? false
|
||||||
|
// })
|
||||||
|
// },
|
||||||
|
...lifecycleActions,
|
||||||
|
{
|
||||||
|
key: 'status-complete',
|
||||||
|
tooltip: completeAction?.actionName ?? '完成',
|
||||||
|
icon: markRaw(IconMdiCheckCircleOutline),
|
||||||
|
type: 'success',
|
||||||
|
disabled: !completeAction,
|
||||||
|
onClick: async () =>
|
||||||
|
handleStatusAction(row, {
|
||||||
|
actionCode: completeAction?.actionCode ?? 'complete',
|
||||||
|
actionName: completeAction?.actionName ?? '完成',
|
||||||
|
needReason: completeAction?.needReason ?? false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRowActions(row: Api.PersonalItem.PersonalItem) {
|
||||||
|
return (
|
||||||
|
<div class="personal-item-row-actions" onClick={event => event.stopPropagation()}>
|
||||||
|
{buildRowActions(row).map(action => {
|
||||||
|
const Icon = action.icon as any;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ElTooltip key={action.key} content={action.tooltip}>
|
||||||
|
<span class="inline-flex">
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
type={action.type}
|
||||||
|
class="personal-item-row-action-btn"
|
||||||
|
disabled={action.disabled}
|
||||||
|
onClick={event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (action.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
action.onClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon class="text-15px" />
|
||||||
|
</ElButton>
|
||||||
|
</span>
|
||||||
|
</ElTooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
operateType.value = 'add';
|
||||||
|
editingData.value = null;
|
||||||
|
openOperateDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectionChange(rows: Api.PersonalItem.PersonalItem[]) {
|
||||||
|
checkedRowIds.value = rows.map(item => item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReloadPageAfterRemove() {
|
||||||
|
const currentPage = searchParams.pageNo ?? 1;
|
||||||
|
|
||||||
|
if (currentPage > 1 && data.value.length > 0 && checkedRowIds.value.length >= data.value.length) {
|
||||||
|
return currentPage - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadTable(page = searchParams.pageNo ?? 1) {
|
||||||
|
checkedRowIds.value = [];
|
||||||
|
await getDataByPage(page);
|
||||||
|
await nextTick();
|
||||||
|
tableRef.value?.clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSearchParams() {
|
||||||
|
Object.assign(searchParams, getInitSearchParams());
|
||||||
|
reloadTable(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
reloadTable(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmitted() {
|
||||||
|
closeOperateDialog();
|
||||||
|
reloadTable(searchParams.pageNo ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDetailChanged(latestItem: Api.PersonalItem.PersonalItem) {
|
||||||
|
detailData.value = latestItem;
|
||||||
|
|
||||||
|
const targetIndex = data.value.findIndex(item => item.id === latestItem.id);
|
||||||
|
|
||||||
|
if (targetIndex >= 0) {
|
||||||
|
data.value.splice(targetIndex, 1, latestItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStatusAction(row: Api.PersonalItem.PersonalItem, action: Api.PersonalItem.PersonalItemLifecycleAction) {
|
||||||
|
currentStatusItem.value = row;
|
||||||
|
currentStatusAction.value = action;
|
||||||
|
openStatusActionDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusActionSubmit(reason: string | null) {
|
||||||
|
if (!currentStatusItem.value || !currentStatusAction.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await fetchChangePersonalItemStatus(currentStatusItem.value.id, {
|
||||||
|
actionCode: currentStatusAction.value.actionCode,
|
||||||
|
reason
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeStatusActionDialog();
|
||||||
|
window.$message?.success(`${currentStatusAction.value.actionName}成功`);
|
||||||
|
await reloadTable(searchParams.pageNo ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(row: Api.PersonalItem.PersonalItem) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除个人事项“${row.taskTitle}”吗?`, '删除确认', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await fetchDeletePersonalItem(row.id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success('删除成功');
|
||||||
|
await reloadTable(searchParams.pageNo ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBatchDelete() {
|
||||||
|
if (!checkedRowIds.value.length) {
|
||||||
|
window.$message?.warning('请先选择个人事项');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除选中的 ${selectedCount.value} 条个人事项吗?`, '删除确认', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPage = resolveReloadPageAfterRemove();
|
||||||
|
const { error } = await fetchBatchDeletePersonalItems({ ids: [...checkedRowIds.value] });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success('批量删除成功');
|
||||||
|
await reloadTable(targetPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenBindExecution() {
|
||||||
|
if (!checkedRowIds.value.length) {
|
||||||
|
window.$message?.warning('请先选择个人事项');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openBindExecutionDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBindExecutionSubmit(payload: { executionId: string }) {
|
||||||
|
bindExecutionSubmitting.value = true;
|
||||||
|
|
||||||
|
const targetPage = resolveReloadPageAfterRemove();
|
||||||
|
const { error } = await fetchBindPersonalItemsToExecution({
|
||||||
|
ids: [...checkedRowIds.value],
|
||||||
|
executionId: payload.executionId
|
||||||
|
});
|
||||||
|
|
||||||
|
bindExecutionSubmitting.value = false;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeBindExecutionDialog();
|
||||||
|
window.$message?.success('批量关联执行成功');
|
||||||
|
await reloadTable(targetPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
searchParams.ownerId = currentUserId.value || undefined;
|
||||||
|
reloadTable(searchParams.pageNo ?? 1);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex-col-stretch gap-16px overflow-hidden">
|
||||||
|
<PersonalItemSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||||
|
|
||||||
|
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between gap-12px">
|
||||||
|
<div class="flex items-center gap-10px">
|
||||||
|
<p>个人事项</p>
|
||||||
|
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||||
|
</div>
|
||||||
|
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
|
||||||
|
<template #default>
|
||||||
|
<ElButton plain type="danger" :disabled="selectedCount === 0" @click="handleBatchDelete">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-delete class="text-icon" />
|
||||||
|
</template>
|
||||||
|
批量删除
|
||||||
|
</ElButton>
|
||||||
|
<ElButton plain :disabled="selectedCount === 0" @click="handleOpenBindExecution">
|
||||||
|
<template #icon>
|
||||||
|
<icon-mdi-link-variant class="text-icon" />
|
||||||
|
</template>
|
||||||
|
批量关联执行
|
||||||
|
</ElButton>
|
||||||
|
<ElButton plain type="primary" @click="openAdd">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-plus class="text-icon" />
|
||||||
|
</template>
|
||||||
|
新增
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</TableHeaderOperation>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<ElTable
|
||||||
|
ref="tableRef"
|
||||||
|
v-loading="loading"
|
||||||
|
height="100%"
|
||||||
|
border
|
||||||
|
row-key="id"
|
||||||
|
:data="data"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
|
>
|
||||||
|
<template v-for="col in columns" :key="String(col.prop)">
|
||||||
|
<ElTableColumn v-if="col.prop === 'taskTitle'" v-bind="col">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElButton link type="primary" class="personal-item-title-link" @click.stop="openView(row)">
|
||||||
|
{{ row.taskTitle || '--' }}
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn v-else v-bind="col" />
|
||||||
|
</template>
|
||||||
|
</ElTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-20px flex justify-end">
|
||||||
|
<ElPagination
|
||||||
|
v-if="mobilePagination.total"
|
||||||
|
layout="total,prev,pager,next,sizes"
|
||||||
|
v-bind="mobilePagination"
|
||||||
|
@current-change="mobilePagination['current-change']"
|
||||||
|
@size-change="mobilePagination['size-change']"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<PersonalItemOperateDialog
|
||||||
|
v-model:visible="operateVisible"
|
||||||
|
:operate-type="operateType"
|
||||||
|
:row-data="editingData"
|
||||||
|
@submitted="handleSubmitted"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PersonalItemDetailDialog
|
||||||
|
v-model:visible="detailVisible"
|
||||||
|
:row-data="detailData"
|
||||||
|
:default-tab="detailDefaultTab"
|
||||||
|
@changed="handleDetailChanged"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PersonalItemBindExecutionDialog
|
||||||
|
v-model:visible="bindExecutionVisible"
|
||||||
|
:selected-count="selectedCount"
|
||||||
|
:submit-loading="bindExecutionSubmitting"
|
||||||
|
@submit="handleBindExecutionSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PersonalItemStatusActionDialog
|
||||||
|
v-model:visible="statusActionVisible"
|
||||||
|
:action="currentStatusAction"
|
||||||
|
@submit="handleStatusActionSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.personal-item-row-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-item-row-actions :deep(.el-button + .el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.personal-item-row-action-btn) {
|
||||||
|
padding: 3px;
|
||||||
|
min-width: auto;
|
||||||
|
height: auto;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.personal-item-title-link) {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.personal-item-title-link > span) {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { fetchGetPersonalItemExecutionOptions } from '@/service/api';
|
||||||
|
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'PersonalItemBindExecutionDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedCount: number;
|
||||||
|
submitLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submit', payload: { executionId: string }): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
submitLoading: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const { formRef, validate } = useForm();
|
||||||
|
const { createRequiredRule } = useFormRules();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const executionOptions = ref<Api.PersonalItem.PersonalItemExecutionOption[]>([]);
|
||||||
|
|
||||||
|
const model = reactive({
|
||||||
|
executionId: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
executionId: [createRequiredRule('请选择执行')]
|
||||||
|
}) satisfies Record<string, App.Global.FormRule[]>
|
||||||
|
);
|
||||||
|
|
||||||
|
function getExecutionOptionLabel(option: Api.PersonalItem.PersonalItemExecutionOption) {
|
||||||
|
if (option.projectName?.trim()) {
|
||||||
|
return `${option.projectName} / ${option.executionName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return option.executionName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExecutionOptions() {
|
||||||
|
loading.value = true;
|
||||||
|
const { error, data } = await fetchGetPersonalItemExecutionOptions();
|
||||||
|
loading.value = false;
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
executionOptions.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
executionOptions.value = data.map(item => ({ ...item }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initDialog() {
|
||||||
|
model.executionId = '';
|
||||||
|
await loadExecutionOptions();
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
await validate();
|
||||||
|
|
||||||
|
emit('submit', {
|
||||||
|
executionId: model.executionId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
value => {
|
||||||
|
if (value) {
|
||||||
|
initDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="visible"
|
||||||
|
title="批量关联执行"
|
||||||
|
preset="sm"
|
||||||
|
:loading="loading"
|
||||||
|
:confirm-loading="props.submitLoading"
|
||||||
|
@confirm="handleConfirm"
|
||||||
|
>
|
||||||
|
<ElAlert
|
||||||
|
:title="`已选中 ${props.selectedCount} 条个人事项,关联成功后这些事项会从当前列表移除。`"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
class="mb-16px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||||
|
<ElFormItem label="执行" prop="executionId">
|
||||||
|
<ElSelect
|
||||||
|
v-model="model.executionId"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="请选择执行"
|
||||||
|
class="w-full"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="option in executionOptions"
|
||||||
|
:key="option.executionId"
|
||||||
|
:label="getExecutionOptionLabel(option)"
|
||||||
|
:value="option.executionId"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { ElMessageBox } from 'element-plus';
|
||||||
|
import {
|
||||||
|
fetchCompletePersonalItem,
|
||||||
|
fetchCreatePersonalItemWorklog,
|
||||||
|
fetchDeletePersonalItemWorklog,
|
||||||
|
fetchGetPersonalItemDetail,
|
||||||
|
fetchGetPersonalItemWorklogPage,
|
||||||
|
fetchUpdatePersonalItemWorklog
|
||||||
|
} from '@/service/api';
|
||||||
|
import { useAuthStore } from '@/store/modules/auth';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
import TaskWorklogPanel from '@/views/project/project/execution/modules/task-worklog-panel.vue';
|
||||||
|
import {
|
||||||
|
formatPersonalItemDate,
|
||||||
|
formatPersonalItemOwnerName,
|
||||||
|
formatPersonalItemProgress,
|
||||||
|
getPersonalItemStatusLabel,
|
||||||
|
resolvePersonalItemStatusTagType
|
||||||
|
} from './personal-item-shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'PersonalItemDetailDialog' });
|
||||||
|
|
||||||
|
type TabName = 'worklog';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rowData?: Api.PersonalItem.PersonalItem | null;
|
||||||
|
defaultTab?: TabName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
rowData: null,
|
||||||
|
defaultTab: 'worklog'
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
changed: [item: Api.PersonalItem.PersonalItem];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeTab = ref<TabName>('worklog');
|
||||||
|
const detailData = ref<Api.PersonalItem.PersonalItem | null>(null);
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const currentUserId = computed(() => authStore.userInfo.userId || '');
|
||||||
|
const currentUserName = computed(
|
||||||
|
() => authStore.userInfo.nickname?.trim() || authStore.userInfo.userName?.trim() || ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const COMPLETED_STATUS_CODE: Api.PersonalItem.PersonalItemStatusCode = 'completed';
|
||||||
|
const COMPLETE_ACTION_CODE = 'complete';
|
||||||
|
|
||||||
|
const ownerName = computed(() => {
|
||||||
|
if (!detailData.value) return '--';
|
||||||
|
|
||||||
|
const displayName = formatPersonalItemOwnerName(detailData.value);
|
||||||
|
|
||||||
|
if (displayName !== detailData.value.ownerId) {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return detailData.value.ownerId === currentUserId.value && currentUserName.value
|
||||||
|
? currentUserName.value
|
||||||
|
: displayName;
|
||||||
|
});
|
||||||
|
const statusName = computed(() => (detailData.value ? getPersonalItemStatusLabel(detailData.value.statusCode) : '--'));
|
||||||
|
const statusTagType = computed(() =>
|
||||||
|
detailData.value ? resolvePersonalItemStatusTagType(detailData.value.statusCode) : 'info'
|
||||||
|
);
|
||||||
|
const progressText = computed(() => formatPersonalItemProgress(detailData.value?.progressRate));
|
||||||
|
const plannedStartText = computed(() => formatPersonalItemDate(detailData.value?.plannedStartDate));
|
||||||
|
const plannedEndText = computed(() => formatPersonalItemDate(detailData.value?.plannedEndDate));
|
||||||
|
const actualStartText = computed(() => formatPersonalItemDate(detailData.value?.actualStartDate));
|
||||||
|
const actualEndText = computed(() => formatPersonalItemDate(detailData.value?.actualEndDate));
|
||||||
|
const totalHoursText = computed(() => {
|
||||||
|
const total = detailData.value?.totalSpentHours;
|
||||||
|
return `${typeof total === 'number' && Number.isFinite(total) ? total.toFixed(1) : '0.0'}h`;
|
||||||
|
});
|
||||||
|
const canSubmitWorklog = computed(() =>
|
||||||
|
Boolean(
|
||||||
|
detailData.value?.id &&
|
||||||
|
(detailData.value.statusCode === 'pending' ||
|
||||||
|
detailData.value.statusCode === 'active' ||
|
||||||
|
detailData.value.statusCode === 'completed')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
function syncDetailFromPageRow() {
|
||||||
|
detailData.value = props.rowData ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDetail() {
|
||||||
|
if (!detailData.value?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error, data } = await fetchGetPersonalItemDetail(detailData.value.id);
|
||||||
|
|
||||||
|
if (!error && data) {
|
||||||
|
detailData.value = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canPromptCompleteItem(item: Api.PersonalItem.PersonalItem) {
|
||||||
|
if (item.statusCode === COMPLETED_STATUS_CODE || item.terminal) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
item.progressRate >= 100 && (item.availableActions ?? []).some(action => action.actionCode === COMPLETE_ACTION_CODE)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptCompleteItemIfNeeded() {
|
||||||
|
if (!detailData.value || !canPromptCompleteItem(detailData.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('事项进度已达 100%,是否完成当前事项?', '完成确认', {
|
||||||
|
confirmButtonText: '完成事项',
|
||||||
|
cancelButtonText: '仅保留工时',
|
||||||
|
type: 'info'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await fetchCompletePersonalItem(detailData.value.id);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
window.$message?.success('个人事项已完成');
|
||||||
|
await refreshDetail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWorklogChanged() {
|
||||||
|
await refreshDetail();
|
||||||
|
await promptCompleteItemIfNeeded();
|
||||||
|
|
||||||
|
if (detailData.value) {
|
||||||
|
emit('changed', detailData.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchPersonalWorklogPage(params: Api.Project.TaskWorklogSearchParams) {
|
||||||
|
return fetchGetPersonalItemWorklogPage(detailData.value!.id, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPersonalWorklog(data: Api.Project.SaveTaskWorklogParams) {
|
||||||
|
return fetchCreatePersonalItemWorklog(detailData.value!.id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePersonalWorklog(payload: { worklogId: string; data: Api.Project.SaveTaskWorklogParams }) {
|
||||||
|
return fetchUpdatePersonalItemWorklog(detailData.value!.id, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePersonalWorklog(worklogId: string) {
|
||||||
|
return fetchDeletePersonalItemWorklog(detailData.value!.id, worklogId);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
value => {
|
||||||
|
if (value) {
|
||||||
|
activeTab.value = props.defaultTab;
|
||||||
|
syncDetailFromPageRow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.rowData,
|
||||||
|
() => {
|
||||||
|
if (visible.value) {
|
||||||
|
syncDetailFromPageRow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.defaultTab,
|
||||||
|
value => {
|
||||||
|
if (visible.value) {
|
||||||
|
activeTab.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="visible"
|
||||||
|
title="工作日志"
|
||||||
|
width="1100px"
|
||||||
|
max-body-height="78vh"
|
||||||
|
:show-footer="false"
|
||||||
|
:scrollbar="false"
|
||||||
|
>
|
||||||
|
<ElTabs v-model="activeTab" class="personal-item-detail-dialog__tabs">
|
||||||
|
<ElTabPane label="工作日志" name="worklog" lazy>
|
||||||
|
<div v-if="detailData" class="personal-item-worklog-content">
|
||||||
|
<div class="personal-item-worklog-content__cards">
|
||||||
|
<div class="personal-item-worklog-content__card">
|
||||||
|
<span class="personal-item-worklog-content__card-label">负责人</span>
|
||||||
|
<span class="personal-item-worklog-content__card-value" :title="ownerName">{{ ownerName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="personal-item-worklog-content__card">
|
||||||
|
<span class="personal-item-worklog-content__card-label">当前状态</span>
|
||||||
|
<ElTag :type="statusTagType" size="small" effect="light" class="personal-item-worklog-content__card-tag">
|
||||||
|
{{ statusName }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
<div class="personal-item-worklog-content__card">
|
||||||
|
<span class="personal-item-worklog-content__card-label">计划开始</span>
|
||||||
|
<span class="personal-item-worklog-content__card-value">{{ plannedStartText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="personal-item-worklog-content__card">
|
||||||
|
<span class="personal-item-worklog-content__card-label">计划结束</span>
|
||||||
|
<span class="personal-item-worklog-content__card-value">{{ plannedEndText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="personal-item-worklog-content__card">
|
||||||
|
<span class="personal-item-worklog-content__card-label">当前进度</span>
|
||||||
|
<span class="personal-item-worklog-content__card-value">{{ progressText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="personal-item-worklog-content__card">
|
||||||
|
<span class="personal-item-worklog-content__card-label">累计工时</span>
|
||||||
|
<span class="personal-item-worklog-content__card-value personal-item-worklog-content__card-value--accent">
|
||||||
|
{{ totalHoursText }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="personal-item-worklog-content__card">
|
||||||
|
<span class="personal-item-worklog-content__card-label">实际开始</span>
|
||||||
|
<span class="personal-item-worklog-content__card-value">{{ actualStartText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="personal-item-worklog-content__card">
|
||||||
|
<span class="personal-item-worklog-content__card-label">实际结束</span>
|
||||||
|
<span class="personal-item-worklog-content__card-value">{{ actualEndText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TaskWorklogPanel
|
||||||
|
project-id=""
|
||||||
|
execution-id=""
|
||||||
|
:task-id="detailData.id"
|
||||||
|
:task-owner-id="currentUserId"
|
||||||
|
:task-status-code="detailData.statusCode"
|
||||||
|
:task-progress-rate="detailData.progressRate"
|
||||||
|
:can-submit="canSubmitWorklog"
|
||||||
|
:active="activeTab === 'worklog' && visible"
|
||||||
|
:fetch-worklog-page="fetchPersonalWorklogPage"
|
||||||
|
:create-worklog="createPersonalWorklog"
|
||||||
|
:update-worklog="updatePersonalWorklog"
|
||||||
|
:delete-worklog="deletePersonalWorklog"
|
||||||
|
attachment-directory="personal-item-worklog"
|
||||||
|
create-success-message="工作日志新增成功"
|
||||||
|
update-success-message="工作日志修改成功"
|
||||||
|
delete-success-message="工作日志删除成功"
|
||||||
|
@changed="handleWorklogChanged"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElTabPane>
|
||||||
|
</ElTabs>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.personal-item-detail-dialog__tabs {
|
||||||
|
--el-tabs-header-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-item-detail-dialog__tabs :deep(.el-tabs__content),
|
||||||
|
.personal-item-detail-dialog__tabs :deep(.el-tab-pane) {
|
||||||
|
min-height: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-item-worklog-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-item-worklog-content__cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-item-worklog-content__card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-item-worklog-content__card-label {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-item-worklog-content__card-value {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-item-worklog-content__card-value--accent {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-item-worklog-content__card-tag {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||||
|
import { useResizeObserver } from '@vueuse/core';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { RDMS_TASK_ITEM_TYPE_DICT_CODE } from '@/constants/dict';
|
||||||
|
import { fetchCreatePersonalItem, fetchGetPersonalItemDetail, fetchUpdatePersonalItem } from '@/service/api';
|
||||||
|
import { useAuthStore } from '@/store/modules/auth';
|
||||||
|
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||||
|
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||||
|
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||||||
|
import DictSelect from '@/components/custom/dict-select.vue';
|
||||||
|
import { isEmptyRichText } from './personal-item-shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'PersonalItemOperateDialog' });
|
||||||
|
|
||||||
|
type PersonalItemOperateType = UI.TableOperateType | 'view';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
operateType: PersonalItemOperateType;
|
||||||
|
rowData?: Api.PersonalItem.PersonalItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submitted: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const currentUserId = computed(() => authStore.userInfo.userId || 'current-user');
|
||||||
|
|
||||||
|
const { formRef, validate } = useForm();
|
||||||
|
const { createRequiredRule } = useFormRules();
|
||||||
|
|
||||||
|
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||||
|
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||||||
|
const leftColRef = ref<HTMLElement>();
|
||||||
|
const editorHeight = ref<string>('45vh');
|
||||||
|
|
||||||
|
const ATTACHMENT_SECTION_RESERVE_PX = 140;
|
||||||
|
|
||||||
|
useResizeObserver(leftColRef, entries => {
|
||||||
|
const h = entries[0]?.contentRect.height;
|
||||||
|
if (h && h > 120) {
|
||||||
|
editorHeight.value = `${Math.max(h - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEdit = computed(() => props.operateType === 'edit');
|
||||||
|
const isView = computed(() => props.operateType === 'view');
|
||||||
|
const detailLoading = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
|
||||||
|
interface Model {
|
||||||
|
taskTitle: string;
|
||||||
|
type: string;
|
||||||
|
plannedStartDate: string | null;
|
||||||
|
plannedEndDate: string | null;
|
||||||
|
taskDesc: string | null;
|
||||||
|
attachments: Api.Project.AttachmentItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = reactive<Model>(createDefaultModel());
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
if (isView.value) {
|
||||||
|
return '个人事项详情';
|
||||||
|
}
|
||||||
|
|
||||||
|
return isEdit.value ? '编辑个人事项' : '新增个人事项';
|
||||||
|
});
|
||||||
|
|
||||||
|
function createDefaultModel(): Model {
|
||||||
|
return {
|
||||||
|
taskTitle: '',
|
||||||
|
type: '',
|
||||||
|
plannedStartDate: null,
|
||||||
|
plannedEndDate: null,
|
||||||
|
taskDesc: null,
|
||||||
|
attachments: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlannedDateRangeValid(startDate: string | null, endDate: string | null) {
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !dayjs(endDate).isBefore(dayjs(startDate), 'day');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
taskTitle: [
|
||||||
|
createRequiredRule('请输入事项标题'),
|
||||||
|
{
|
||||||
|
validator: (_rule, value: string, callback) => {
|
||||||
|
if (!value?.trim()) {
|
||||||
|
callback(new Error('请输入事项标题'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
type: [createRequiredRule('请选择事项类型')],
|
||||||
|
plannedStartDate: [createRequiredRule('请选择计划开始日期')],
|
||||||
|
plannedEndDate: [
|
||||||
|
createRequiredRule('请选择计划结束日期'),
|
||||||
|
{
|
||||||
|
validator: (_rule, value: string | null, callback) => {
|
||||||
|
if (!isPlannedDateRangeValid(model.plannedStartDate, value)) {
|
||||||
|
callback(new Error('计划结束日期不能早于计划开始日期'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
trigger: 'change'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}) satisfies Record<string, App.Global.FormRule[]>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function initModel() {
|
||||||
|
detailLoading.value = true;
|
||||||
|
|
||||||
|
Object.assign(model, createDefaultModel());
|
||||||
|
|
||||||
|
if ((isEdit.value || isView.value) && props.rowData) {
|
||||||
|
const { error, data } = await fetchGetPersonalItemDetail(props.rowData.id);
|
||||||
|
|
||||||
|
if (!error && data) {
|
||||||
|
model.taskTitle = data.taskTitle;
|
||||||
|
model.type = data.type;
|
||||||
|
model.plannedStartDate = data.plannedStartDate;
|
||||||
|
model.plannedEndDate = data.plannedEndDate;
|
||||||
|
model.taskDesc = data.taskDesc;
|
||||||
|
model.attachments = data.attachments ? [...data.attachments] : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
detailLoading.value = false;
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
attachmentUploaderRef.value?.initSession();
|
||||||
|
richTextEditorRef.value?.initSession();
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (isView.value) {
|
||||||
|
visible.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await validate();
|
||||||
|
|
||||||
|
if (attachmentUploaderRef.value?.hasUploading) {
|
||||||
|
window.$message?.warning('附件正在上传中,请稍候');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Api.PersonalItem.SavePersonalItemParams = {
|
||||||
|
taskTitle: model.taskTitle.trim(),
|
||||||
|
type: model.type,
|
||||||
|
ownerId: currentUserId.value,
|
||||||
|
plannedStartDate: model.plannedStartDate,
|
||||||
|
plannedEndDate: model.plannedEndDate,
|
||||||
|
taskDesc: isEmptyRichText(model.taskDesc) ? null : (model.taskDesc ?? null),
|
||||||
|
attachments: [...model.attachments]
|
||||||
|
};
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
|
||||||
|
const result =
|
||||||
|
isEdit.value && props.rowData
|
||||||
|
? await fetchUpdatePersonalItem({ id: props.rowData.id, ...payload })
|
||||||
|
: await fetchCreatePersonalItem(payload);
|
||||||
|
|
||||||
|
submitting.value = false;
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
|
||||||
|
|
||||||
|
window.$message?.success(isEdit.value ? '个人事项修改成功' : '个人事项创建成功');
|
||||||
|
visible.value = false;
|
||||||
|
emit('submitted');
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
value => {
|
||||||
|
if (value) {
|
||||||
|
initModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="title"
|
||||||
|
width="1100px"
|
||||||
|
:loading="detailLoading"
|
||||||
|
:confirm-loading="submitting"
|
||||||
|
:show-footer="!isView"
|
||||||
|
max-body-height="78vh"
|
||||||
|
@confirm="handleSubmit"
|
||||||
|
>
|
||||||
|
<ElForm
|
||||||
|
ref="formRef"
|
||||||
|
:model="model"
|
||||||
|
:rules="rules"
|
||||||
|
label-position="top"
|
||||||
|
:validate-on-rule-change="false"
|
||||||
|
class="personal-item-operate-dialog__form"
|
||||||
|
>
|
||||||
|
<div class="personal-item-operate-dialog__grid">
|
||||||
|
<div ref="leftColRef" class="personal-item-operate-dialog__col-left">
|
||||||
|
<BusinessFormSection title="事项信息">
|
||||||
|
<ElFormItem label="事项标题" prop="taskTitle">
|
||||||
|
<ElInput
|
||||||
|
v-model="model.taskTitle"
|
||||||
|
:clearable="!isView"
|
||||||
|
:disabled="isView"
|
||||||
|
maxlength="300"
|
||||||
|
placeholder="请输入事项标题"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="事项类型" prop="type">
|
||||||
|
<DictSelect
|
||||||
|
v-model="model.type"
|
||||||
|
:dict-code="RDMS_TASK_ITEM_TYPE_DICT_CODE"
|
||||||
|
:clearable="!isView"
|
||||||
|
:disabled="isView"
|
||||||
|
placeholder="请选择事项类型"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="计划开始日期" prop="plannedStartDate">
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="model.plannedStartDate"
|
||||||
|
:disabled="isView"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="请选择计划开始日期"
|
||||||
|
class="personal-item-operate-dialog__date-picker"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="计划结束日期" prop="plannedEndDate">
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="model.plannedEndDate"
|
||||||
|
:disabled="isView"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="请选择计划结束日期"
|
||||||
|
class="personal-item-operate-dialog__date-picker"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</BusinessFormSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="personal-item-operate-dialog__col-right">
|
||||||
|
<BusinessFormSection title="事项说明">
|
||||||
|
<ElFormItem class="personal-item-operate-dialog__desc-item" prop="taskDesc">
|
||||||
|
<BusinessRichTextEditor
|
||||||
|
ref="richTextEditorRef"
|
||||||
|
v-model="model.taskDesc"
|
||||||
|
:height="editorHeight"
|
||||||
|
:disabled="isView"
|
||||||
|
upload-directory="personal-item"
|
||||||
|
placeholder="请输入事项说明"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</BusinessFormSection>
|
||||||
|
|
||||||
|
<BusinessFormSection title="附件">
|
||||||
|
<ElFormItem class="personal-item-operate-dialog__attachment-item">
|
||||||
|
<BusinessAttachmentUploader
|
||||||
|
ref="attachmentUploaderRef"
|
||||||
|
v-model="model.attachments"
|
||||||
|
directory="personal-item"
|
||||||
|
:disabled="isView"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</BusinessFormSection>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.personal-item-operate-dialog__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 360px 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-item-operate-dialog__col-left,
|
||||||
|
.personal-item-operate-dialog__col-right {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-item-operate-dialog__col-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.personal-item-operate-dialog__desc-item,
|
||||||
|
.personal-item-operate-dialog__attachment-item {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1024px) {
|
||||||
|
.personal-item-operate-dialog__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.personal-item-operate-dialog__date-picker.el-date-editor.el-input) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
import { fetchGetObjectStatusModelPage } from '@/service/api';
|
||||||
|
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||||
|
import { personalItemStatusOptions } from './personal-item-shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'PersonalItemSearch' });
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
reset: [];
|
||||||
|
search: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const model = defineModel<Api.PersonalItem.PersonalItemSearchParams>('model', {
|
||||||
|
required: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchModel = reactive<{
|
||||||
|
keyword: string;
|
||||||
|
statusCode?: Api.PersonalItem.PersonalItemStatusCode;
|
||||||
|
}>({
|
||||||
|
keyword: '',
|
||||||
|
statusCode: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
let syncingFromSource = false;
|
||||||
|
const statusOptions = ref<Array<{ label: string; value: string }>>([...personalItemStatusOptions]);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [model.value.keyword, model.value.statusCode] as const,
|
||||||
|
([keyword, statusCode]) => {
|
||||||
|
syncingFromSource = true;
|
||||||
|
searchModel.keyword = keyword ?? '';
|
||||||
|
searchModel.statusCode = statusCode;
|
||||||
|
syncingFromSource = false;
|
||||||
|
},
|
||||||
|
{ immediate: true, flush: 'sync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [searchModel.keyword, searchModel.statusCode] as const,
|
||||||
|
([keyword, statusCode]) => {
|
||||||
|
if (syncingFromSource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.value.keyword = keyword.trim() || undefined;
|
||||||
|
model.value.statusCode = statusCode;
|
||||||
|
},
|
||||||
|
{ flush: 'sync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const fields = computed<SearchField[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'keyword',
|
||||||
|
label: '关键字',
|
||||||
|
type: 'input',
|
||||||
|
placeholder: '请输入标题或说明'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'statusCode',
|
||||||
|
label: '状态',
|
||||||
|
type: 'select',
|
||||||
|
placeholder: '请选择状态',
|
||||||
|
options: statusOptions.value
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function loadStatusOptions() {
|
||||||
|
const { error, data } = await fetchGetObjectStatusModelPage({
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 100,
|
||||||
|
objectType: 'task',
|
||||||
|
status: 0,
|
||||||
|
initialFlag: undefined,
|
||||||
|
terminalFlag: undefined,
|
||||||
|
keyword: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !data?.list?.length) {
|
||||||
|
statusOptions.value = [...personalItemStatusOptions];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusOptions.value = data.list
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => left.sort - right.sort)
|
||||||
|
.map(item => ({
|
||||||
|
label: item.statusName,
|
||||||
|
value: item.statusCode
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
emit('reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
emit('search');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadStatusOptions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TableSearchFields v-model="searchModel" :fields="fields" :columns="3" @reset="handleReset" @search="handleSearch" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { getPersonalItemStatusTagType } from '@/constants/status-tag';
|
||||||
|
|
||||||
|
export const personalItemStatusOptions = [
|
||||||
|
{ label: '待处理', value: 'pending' as const },
|
||||||
|
{ label: '进行中', value: 'active' as const },
|
||||||
|
{ label: '已完成', value: 'completed' as const }
|
||||||
|
];
|
||||||
|
|
||||||
|
const personalItemStatusLabelMap: Record<Api.PersonalItem.PersonalItemStatusCode, string> = {
|
||||||
|
pending: '待开始',
|
||||||
|
active: '进行中',
|
||||||
|
paused: '已暂停',
|
||||||
|
completed: '已完成',
|
||||||
|
cancelled: '已取消'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPersonalItemStatusLabel(statusCode: Api.PersonalItem.PersonalItemStatusCode | null | undefined) {
|
||||||
|
if (!statusCode) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
return personalItemStatusLabelMap[statusCode] || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePersonalItemStatusTagType(
|
||||||
|
statusCode: Api.PersonalItem.PersonalItemStatusCode | null | undefined
|
||||||
|
) {
|
||||||
|
return getPersonalItemStatusTagType(statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPersonalItemDate(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = dayjs(value);
|
||||||
|
|
||||||
|
if (!target.isValid()) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
return target.format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPersonalItemDateTime(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = dayjs(value);
|
||||||
|
|
||||||
|
if (!target.isValid()) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
return target.format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPersonalItemProgress(value: number | null | undefined) {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
return '0%';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = Math.round(Math.min(100, Math.max(0, value)) * 100) / 100;
|
||||||
|
return `${normalized}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPersonalItemName(value: string | null | undefined) {
|
||||||
|
return value?.trim() || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPersonalItemOwnerName(
|
||||||
|
item: Pick<Api.PersonalItem.PersonalItem, 'ownerNickname' | 'ownerName' | 'ownerId'>
|
||||||
|
) {
|
||||||
|
return item.ownerNickname?.trim() || item.ownerName?.trim() || item.ownerId || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPersonalItemDateRange(start: string | null | undefined, end: string | null | undefined) {
|
||||||
|
const startText = formatPersonalItemDate(start);
|
||||||
|
const endText = formatPersonalItemDate(end);
|
||||||
|
|
||||||
|
if (startText === '--' && endText === '--') {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${startText} ~ ${endText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEmptyRichText(html: string | null | undefined) {
|
||||||
|
if (!html) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = html
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/ /g, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !/<img\b/i.test(html);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, reactive, watch } from 'vue';
|
||||||
|
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'PersonalItemStatusActionDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
action: Api.PersonalItem.PersonalItemLifecycleAction | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submit', reason: string | null): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const { formRef, validate } = useForm();
|
||||||
|
const { createRequiredRule } = useFormRules();
|
||||||
|
|
||||||
|
const model = reactive({
|
||||||
|
reason: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
reason: props.action?.needReason ? [createRequiredRule('请输入动作原因')] : []
|
||||||
|
}) satisfies Record<string, App.Global.FormRule[]>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
await validate();
|
||||||
|
emit('submit', model.reason.trim() || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
async value => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.reason = '';
|
||||||
|
await nextTick();
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog v-model="visible" :title="action?.actionName || '状态变更'" preset="sm" @confirm="handleConfirm">
|
||||||
|
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||||
|
<ElFormItem label="动作原因" prop="reason">
|
||||||
|
<ElInput
|
||||||
|
v-model="model.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
:placeholder="action?.needReason ? '请输入动作原因' : '可选填写动作原因'"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
3
src/views/personal-center/my-monthly/index.vue
Normal file
3
src/views/personal-center/my-monthly/index.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<LookForward title="我的月报" subtitle="功能建设中,敬请期待" />
|
||||||
|
</template>
|
||||||
3
src/views/personal-center/my-performance/index.vue
Normal file
3
src/views/personal-center/my-performance/index.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<LookForward title="我的绩效" subtitle="功能建设中,敬请期待" />
|
||||||
|
</template>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user