Merge branch 'main' of http://192.168.1.22:3000/Web/cn-rdms-web
# Conflicts: # src/views/product/requirement/index.vue # src/views/system/user-management-relation/index.vue
This commit is contained in:
13
.claude/settings.local.json
Normal file
13
.claude/settings.local.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(pnpm gen-route *)",
|
||||||
|
"Bash(pnpm typecheck *)",
|
||||||
|
"Bash(pnpm lint *)",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"Bash(Remove-Item *)",
|
||||||
|
"PowerShell(pnpm typecheck *)",
|
||||||
|
"WebFetch(domain:www.wangeditor.com)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`
|
|
||||||
24
AGENTS.md
24
AGENTS.md
@@ -58,6 +58,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 +137,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 +156,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`。
|
||||||
|
- 权限控制按钮默认采用“无权限不渲染”口径,不要把纯权限不足的入口做成禁用态再展示给用户;只有业务状态暂时不可操作、但仍需让用户感知入口存在时,才允许保留禁用态。
|
||||||
|
|
||||||
## 接口、路由与权限约束
|
## 接口、路由与权限约束
|
||||||
|
|
||||||
@@ -201,6 +207,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` 的前端兜底映射,不要直接绕开集中配置另写一份。
|
||||||
|
|
||||||
## 页面资源与菜单目录约束
|
## 页面资源与菜单目录约束
|
||||||
|
|
||||||
- 页面组件键、页面资源、菜单目录是三层不同概念,不要把它们当成同一个值。
|
- 页面组件键、页面资源、菜单目录是三层不同概念,不要把它们当成同一个值。
|
||||||
|
|||||||
370
CLAUDE.md
Normal file
370
CLAUDE.md
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
本文件是我(Claude)在 `cn-rdms-web` 项目中的个人工作笔记,沉淀团队既有规范(来源:`AGENTS.md`)与协作惯例。每次进入仓库前先读这一份,避免重复踩坑。
|
||||||
|
|
||||||
|
> 本文件仅本地保留,已加入 `.gitignore`,请勿提交。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 行为基线(最重要,先记住)
|
||||||
|
|
||||||
|
- **描述现状以代码、配置、文档可直接验证的事实为准**;不引入历史实现/过渡方案/猜测。
|
||||||
|
- **默认精简回答**:先给结论 → 改动点 → 验证方式 → 必要风险。**除非用户主动要求详细,否则不要展开**——不复述清单、不列每条改动的小理由、不堆"汇总"段。用户只让分析就停在分析层,不主动跳到实现。
|
||||||
|
- **进入实施阶段前,先说目标、涉及模块、预计改动点、验证方式**。
|
||||||
|
- **最小改动原则**:只改当前任务必需的范围,不顺手重构无关代码。
|
||||||
|
- **不主动执行 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。
|
||||||
|
|
||||||
|
### 历史代码原则
|
||||||
|
不再新增 `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(前端兜底)。
|
||||||
@@ -50,6 +50,35 @@ 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'
|
||||||
|
},
|
||||||
system: {
|
system: {
|
||||||
icon: 'carbon:cloud-service-management',
|
icon: 'carbon:cloud-service-management',
|
||||||
order: 9,
|
order: 9,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-04-20T11:27:02.190Z",
|
"generatedAt": "2026-04-29T08:18:14.397Z",
|
||||||
"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": 8,
|
||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"name": "product_list",
|
"name": "product_list",
|
||||||
@@ -41,6 +41,39 @@
|
|||||||
"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": "system_user",
|
"name": "system_user",
|
||||||
"path": "/system/user",
|
"path": "/system/user",
|
||||||
|
|||||||
@@ -54,6 +54,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 +79,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"
|
||||||
},
|
},
|
||||||
|
|||||||
433
pnpm-lock.yaml
generated
433
pnpm-lock.yaml
generated
@@ -59,6 +59,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 +134,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)
|
||||||
@@ -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'}
|
||||||
@@ -1399,6 +1398,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==}
|
||||||
|
|
||||||
@@ -1502,6 +1504,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 +1796,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 +2037,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 +2543,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 +2588,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 +2909,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 +3551,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 +3565,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 +3604,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 +3727,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 +3767,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 +3810,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 +4005,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 +4106,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 +4177,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 +4495,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 +4528,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 +4713,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 +4816,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 +4878,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 +5043,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 +5418,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 +5460,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 +5929,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':
|
||||||
@@ -6419,6 +6602,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
|
||||||
@@ -6526,6 +6711,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 +7042,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 +7500,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 +8065,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 +8104,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 +8417,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 +9223,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 +9241,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 +9269,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 +9388,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 +9417,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 +9455,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 +9623,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 +9719,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 +9790,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 +10100,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 +10122,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 +10339,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 +10464,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 +10533,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 +10705,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 +11140,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 +11226,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: {}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
169
src/components/custom/business-rich-text-editor.vue
Normal file
169
src/components/custom/business-rich-text-editor.vue
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue';
|
||||||
|
import '@wangeditor/editor/dist/css/style.css';
|
||||||
|
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
|
||||||
|
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
|
||||||
|
import { 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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = result.data;
|
||||||
|
insertFn(url, file.name, url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
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 :class="containerClass">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.business-rich-text-editor {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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>
|
||||||
87
src/components/custom/business-rich-text-view.vue
Normal file
87
src/components/custom/business-rich-text-view.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<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>
|
||||||
|
<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>
|
||||||
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>
|
||||||
314
src/components/custom/table-search-fields.vue
Normal file
314
src/components/custom/table-search-fields.vue
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } 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;
|
||||||
|
/** 占位提示文本 */
|
||||||
|
placeholder?: 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" />
|
||||||
|
</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"
|
||||||
|
@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" />
|
||||||
|
</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"
|
||||||
|
@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>
|
||||||
@@ -59,3 +59,19 @@ 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';
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
59
src/constants/status-tag.ts
Normal file
59
src/constants/status-tag.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* 业务对象状态颜色(ElTag type)集中配置
|
||||||
|
*
|
||||||
|
* 各业务域的 statusCode → ElTag type 在此统一维护,避免散落在各业务模块。
|
||||||
|
* 未来若后端状态字典返回颜色字段,可在调用方优先取后端值,缺失时回退此映射。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type StatusTagType = 'primary' | 'success' | 'warning' | 'info' | 'danger';
|
||||||
|
|
||||||
|
export type StatusDomain =
|
||||||
|
| 'projectExecution'
|
||||||
|
| 'projectTask'
|
||||||
|
| 'executionMember'
|
||||||
|
| 'project'
|
||||||
|
| 'product'
|
||||||
|
| 'requirement'
|
||||||
|
| 'workOrder';
|
||||||
|
|
||||||
|
const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>> = {
|
||||||
|
// 项目-执行
|
||||||
|
projectExecution: {
|
||||||
|
pending: 'info',
|
||||||
|
active: 'primary',
|
||||||
|
paused: 'warning',
|
||||||
|
completed: 'success',
|
||||||
|
cancelled: 'danger'
|
||||||
|
},
|
||||||
|
// 项目-任务
|
||||||
|
projectTask: {
|
||||||
|
pending: 'info',
|
||||||
|
active: 'primary',
|
||||||
|
blocked: 'warning',
|
||||||
|
completed: 'success',
|
||||||
|
cancelled: 'danger'
|
||||||
|
},
|
||||||
|
// 执行成员变更事件
|
||||||
|
executionMember: {
|
||||||
|
join: 'success',
|
||||||
|
inactive: 'danger',
|
||||||
|
owner_transfer_in: 'warning',
|
||||||
|
owner_transfer_out: 'warning'
|
||||||
|
},
|
||||||
|
// 项目(待补全)
|
||||||
|
project: {},
|
||||||
|
// 产品(待补全)
|
||||||
|
product: {},
|
||||||
|
// 需求(待补全)
|
||||||
|
requirement: {},
|
||||||
|
// 工单(待补全)
|
||||||
|
workOrder: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getStatusTagType(domain: StatusDomain, statusCode: string | null | undefined): StatusTagType {
|
||||||
|
if (!statusCode) {
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
return statusTagTypeRegistry[domain][statusCode] || 'info';
|
||||||
|
}
|
||||||
@@ -169,12 +169,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',
|
||||||
|
|||||||
@@ -174,6 +174,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': '用户详情',
|
||||||
|
|||||||
@@ -51,6 +51,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"),
|
||||||
|
|||||||
@@ -488,6 +488,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',
|
||||||
|
|||||||
@@ -211,6 +211,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",
|
||||||
|
|||||||
19
src/service/api/file.ts
Normal file
19
src/service/api/file.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||||
|
import { request } from '../request';
|
||||||
|
|
||||||
|
const FILE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/file`;
|
||||||
|
|
||||||
|
/** 上传文件(模式一:后端中转) */
|
||||||
|
export function uploadFile(file: File, directory?: string) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
if (directory) {
|
||||||
|
formData.append('directory', directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
return request<string>({
|
||||||
|
url: `${FILE_PREFIX}/upload`,
|
||||||
|
method: 'post',
|
||||||
|
data: formData
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
export * from './auth';
|
export * from './auth';
|
||||||
export * from './dict';
|
export * from './dict';
|
||||||
|
export * from './file';
|
||||||
export * from './object-context';
|
export * from './object-context';
|
||||||
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';
|
||||||
|
|||||||
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
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ 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>({
|
||||||
|
|||||||
244
src/service/api/project-shared.ts
Normal file
244
src/service/api/project-shared.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
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'
|
||||||
|
> & {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecutionMemberResponse = Omit<Api.Project.ExecutionMember, 'id' | 'executionId' | 'userId'> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
executionId: StringIdResponse;
|
||||||
|
userId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExecutionMemberLogResponse = Omit<
|
||||||
|
Api.Project.ExecutionMemberLog,
|
||||||
|
'id' | 'executionId' | 'userId' | 'operatorUserId'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
executionId: StringIdResponse;
|
||||||
|
userId: StringIdResponse;
|
||||||
|
operatorUserId: StringIdResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectTaskResponse = Omit<
|
||||||
|
Api.Project.ProjectTask,
|
||||||
|
| 'id'
|
||||||
|
| 'projectId'
|
||||||
|
| 'executionId'
|
||||||
|
| 'parentTaskId'
|
||||||
|
| 'ownerId'
|
||||||
|
| 'availableActions'
|
||||||
|
| 'plannedStartDate'
|
||||||
|
| 'plannedEndDate'
|
||||||
|
| 'actualStartDate'
|
||||||
|
| 'actualEndDate'
|
||||||
|
| 'progressRate'
|
||||||
|
> & {
|
||||||
|
id: StringIdResponse;
|
||||||
|
projectId: StringIdResponse;
|
||||||
|
executionId: StringIdResponse;
|
||||||
|
parentTaskId?: StringIdResponse | null;
|
||||||
|
ownerId: StringIdResponse;
|
||||||
|
availableActions?: LifecycleActionResponse<Api.Project.ProjectTaskActionCode>[] | null;
|
||||||
|
plannedStartDate?: ProjectLocalDateValue;
|
||||||
|
plannedEndDate?: ProjectLocalDateValue;
|
||||||
|
actualStartDate?: ProjectLocalDateValue;
|
||||||
|
actualEndDate?: ProjectLocalDateValue;
|
||||||
|
progressRate?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution {
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
id: normalizeStringId(response.id),
|
||||||
|
projectId: normalizeStringId(response.projectId),
|
||||||
|
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
|
||||||
|
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,
|
||||||
|
executionDesc: response.executionDesc ?? null,
|
||||||
|
lastStatusReason: response.lastStatusReason ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeExecutionMember(response: ExecutionMemberResponse): Api.Project.ExecutionMember {
|
||||||
|
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 normalizeExecutionMemberLog(response: ExecutionMemberLogResponse): Api.Project.ExecutionMemberLog {
|
||||||
|
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),
|
||||||
|
parentTaskId: normalizeNullableStringId(response.parentTaskId),
|
||||||
|
ownerId: normalizeStringId(response.ownerId),
|
||||||
|
ownerNickname: response.ownerNickname ?? null,
|
||||||
|
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),
|
||||||
|
taskDesc: response.taskDesc ?? null,
|
||||||
|
lastStatusReason: response.lastStatusReason ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
532
src/service/api/project.ts
Normal file
532
src/service/api/project.ts
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
import { WEB_SERVICE_PREFIX } from '@/constants/service';
|
||||||
|
import { request } from '../request';
|
||||||
|
import {
|
||||||
|
type ServiceRequestResult,
|
||||||
|
mapServiceResult,
|
||||||
|
normalizeNullableStringId,
|
||||||
|
normalizeStringId,
|
||||||
|
safeJsonRequestConfig
|
||||||
|
} from './shared';
|
||||||
|
import {
|
||||||
|
type ExecutionMemberLogResponse,
|
||||||
|
type ExecutionMemberResponse,
|
||||||
|
type ProjectExecutionResponse,
|
||||||
|
type ProjectLocalDateValue,
|
||||||
|
type ProjectMemberResponse,
|
||||||
|
type ProjectTaskResponse,
|
||||||
|
getProjectLifecycleActions,
|
||||||
|
normalizeExecutionMember,
|
||||||
|
normalizeExecutionMemberLog,
|
||||||
|
normalizeProjectExecution,
|
||||||
|
normalizeProjectLocalDate,
|
||||||
|
normalizeProjectMember,
|
||||||
|
normalizeProjectTask
|
||||||
|
} from './project-shared';
|
||||||
|
|
||||||
|
const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
|
||||||
|
|
||||||
|
type ProjectResponse = Omit<
|
||||||
|
Api.Project.Project,
|
||||||
|
'id' | 'managerUserId' | 'productId' | 'plannedStartDate' | 'plannedEndDate' | 'actualStartDate' | 'actualEndDate'
|
||||||
|
> & {
|
||||||
|
id: string | number;
|
||||||
|
managerUserId?: string | number | null;
|
||||||
|
productId?: string | number | null;
|
||||||
|
plannedStartDate?: ProjectLocalDateValue;
|
||||||
|
plannedEndDate?: ProjectLocalDateValue;
|
||||||
|
actualStartDate?: ProjectLocalDateValue;
|
||||||
|
actualEndDate?: ProjectLocalDateValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectPageResponse = Api.Project.PageResult<ProjectResponse>;
|
||||||
|
type ProjectExecutionPageResponse = Api.Project.PageResult<ProjectExecutionResponse>;
|
||||||
|
type ProjectTaskPageResponse = Api.Project.PageResult<ProjectTaskResponse>;
|
||||||
|
type StatusBoardResponse = Api.Project.StatusBoard;
|
||||||
|
|
||||||
|
type ProjectContextResponse = Omit<Api.Project.ProjectContext, 'currentProject' | 'navs'> & {
|
||||||
|
currentProject: Omit<Api.Project.ProjectContext['currentProject'], 'id'> & { id: string | number };
|
||||||
|
navs: Array<Omit<Api.Project.ProjectContext['navs'][number], 'id'> & { id: string | number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getExecutionPrefix(projectId: string) {
|
||||||
|
return `${PROJECT_PREFIX}/${projectId}/executions`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskPrefix(projectId: string, executionId: string) {
|
||||||
|
return `${getExecutionPrefix(projectId)}/${executionId}/tasks`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归一化项目数据 */
|
||||||
|
function normalizeProject(project: ProjectResponse): Api.Project.Project {
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
id: normalizeStringId(project.id),
|
||||||
|
managerUserId: normalizeNullableStringId(project.managerUserId) ?? '',
|
||||||
|
productId: normalizeNullableStringId(project.productId),
|
||||||
|
plannedStartDate: normalizeProjectLocalDate(project.plannedStartDate),
|
||||||
|
plannedEndDate: normalizeProjectLocalDate(project.plannedEndDate),
|
||||||
|
actualStartDate: normalizeProjectLocalDate(project.actualStartDate),
|
||||||
|
actualEndDate: normalizeProjectLocalDate(project.actualEndDate)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将项目详情组装为设置页数据 */
|
||||||
|
function createProjectSettings(project: Api.Project.Project): Api.Project.ProjectSettings {
|
||||||
|
return {
|
||||||
|
baseInfo: {
|
||||||
|
id: project.id,
|
||||||
|
projectCode: project.projectCode,
|
||||||
|
projectName: project.projectName,
|
||||||
|
directionCode: project.directionCode,
|
||||||
|
projectType: project.projectType,
|
||||||
|
productId: project.productId,
|
||||||
|
productName: project.productName ?? null,
|
||||||
|
managerUserId: project.managerUserId,
|
||||||
|
managerUserNickname: project.managerUserNickname ?? null,
|
||||||
|
statusCode: project.statusCode,
|
||||||
|
plannedStartDate: project.plannedStartDate,
|
||||||
|
plannedEndDate: project.plannedEndDate,
|
||||||
|
actualStartDate: project.actualStartDate,
|
||||||
|
actualEndDate: project.actualEndDate,
|
||||||
|
projectDesc: project.projectDesc,
|
||||||
|
lastStatusReason: project.lastStatusReason
|
||||||
|
},
|
||||||
|
lifecycle: {
|
||||||
|
statusCode: project.statusCode,
|
||||||
|
lastStatusReason: project.lastStatusReason,
|
||||||
|
availableActions: getProjectLifecycleActions(project.statusCode)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取项目分页 */
|
||||||
|
export async function fetchGetProjectPage(params?: Api.Project.ProjectSearchParams) {
|
||||||
|
const result = await request<ProjectPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/page`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectPageResponse>, data => ({
|
||||||
|
...data,
|
||||||
|
list: data.list.map(normalizeProject)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取项目入口页概览统计 */
|
||||||
|
export function fetchGetProjectOverviewSummary() {
|
||||||
|
return request<Api.Project.ProjectOverviewSummary>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/overview-summary`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取项目详情 */
|
||||||
|
export async function fetchGetProject(id: string) {
|
||||||
|
const result = await request<ProjectResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/get`,
|
||||||
|
method: 'get',
|
||||||
|
params: { id }
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectResponse>, normalizeProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建项目 */
|
||||||
|
export async function fetchCreateProject(data: Api.Project.SaveProjectParams) {
|
||||||
|
const result = await request<string | number>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/create`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新项目 */
|
||||||
|
export function fetchUpdateProject(data: Api.Project.UpdateProjectParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
url: `${PROJECT_PREFIX}/update`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 变更项目状态 */
|
||||||
|
export function fetchChangeProjectStatus(data: Api.Project.ChangeProjectStatusParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
url: `${PROJECT_PREFIX}/change-status`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除项目 */
|
||||||
|
export function fetchDeleteProject(data: Api.Project.DeleteProjectParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
url: `${PROJECT_PREFIX}/delete`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取项目上下文 */
|
||||||
|
export async function fetchGetProjectContext(id: string) {
|
||||||
|
const result = await request<ProjectContextResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${id}/context`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectContextResponse>, data => ({
|
||||||
|
...data,
|
||||||
|
currentProject: {
|
||||||
|
...data.currentProject,
|
||||||
|
id: normalizeStringId(data.currentProject.id)
|
||||||
|
},
|
||||||
|
navs: data.navs.map(nav => ({
|
||||||
|
...nav,
|
||||||
|
id: normalizeStringId(nav.id)
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取项目成员列表 */
|
||||||
|
export async function fetchGetProjectMembers(id: string) {
|
||||||
|
const result = await request<ProjectMemberResponse[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${id}/members`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectMemberResponse[]>, data =>
|
||||||
|
data.map(normalizeProjectMember)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建项目成员 */
|
||||||
|
export async function fetchCreateProjectMember(id: string, data: Api.Project.CreateProjectMemberParams) {
|
||||||
|
const result = await request<string | number>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${id}/members`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新项目成员 */
|
||||||
|
export function fetchUpdateProjectMember(id: string, memberId: string, data: Api.Project.UpdateProjectMemberParams) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${id}/members/${memberId}`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移出项目成员 */
|
||||||
|
export function fetchInactiveProjectMember(
|
||||||
|
id: string,
|
||||||
|
memberId: string,
|
||||||
|
data: Api.Project.InactiveProjectMemberParams
|
||||||
|
) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${PROJECT_PREFIX}/${id}/members/${memberId}/inactive`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取项目设置 */
|
||||||
|
export async function fetchGetProjectSettings(id: string) {
|
||||||
|
const result = await fetchGetProject(id);
|
||||||
|
|
||||||
|
if (result.error || !result.data) {
|
||||||
|
return result as ServiceRequestResult<Api.Project.ProjectSettings>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: createProjectSettings(result.data)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新项目设置基础信息 */
|
||||||
|
export async function fetchUpdateProjectSettingBaseInfo(
|
||||||
|
id: string,
|
||||||
|
data: Api.Project.UpdateProjectSettingBaseInfoParams
|
||||||
|
) {
|
||||||
|
const detailResult = await fetchGetProject(id);
|
||||||
|
|
||||||
|
if (detailResult.error || !detailResult.data) {
|
||||||
|
return detailResult as ServiceRequestResult<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchUpdateProject({
|
||||||
|
id,
|
||||||
|
projectCode: detailResult.data.projectCode,
|
||||||
|
projectName: data.projectName,
|
||||||
|
directionCode: data.directionCode,
|
||||||
|
projectType: data.projectType,
|
||||||
|
productId: detailResult.data.productId,
|
||||||
|
managerUserId: detailResult.data.managerUserId,
|
||||||
|
plannedStartDate: data.plannedStartDate,
|
||||||
|
plannedEndDate: data.plannedEndDate,
|
||||||
|
actualStartDate: detailResult.data.actualStartDate,
|
||||||
|
actualEndDate: detailResult.data.actualEndDate,
|
||||||
|
projectDesc: data.projectDesc
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取项目执行分页 */
|
||||||
|
export async function fetchGetProjectExecutionPage(
|
||||||
|
projectId: string,
|
||||||
|
params?: Api.Project.ProjectExecutionSearchParams
|
||||||
|
) {
|
||||||
|
const result = await request<ProjectExecutionPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${getExecutionPrefix(projectId)}/page`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectExecutionPageResponse>, data => ({
|
||||||
|
...data,
|
||||||
|
list: data.list.map(normalizeProjectExecution)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取项目执行状态看板 */
|
||||||
|
export function fetchGetProjectExecutionStatusBoard(
|
||||||
|
projectId: string,
|
||||||
|
params?: Api.Project.ProjectExecutionStatusBoardParams
|
||||||
|
) {
|
||||||
|
return request<StatusBoardResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${getExecutionPrefix(projectId)}/status-board`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取项目执行详情 */
|
||||||
|
export async function fetchGetProjectExecution(projectId: string, executionId: string) {
|
||||||
|
const result = await request<ProjectExecutionResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${getExecutionPrefix(projectId)}/${executionId}`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectExecutionResponse>, normalizeProjectExecution);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建项目执行 */
|
||||||
|
export async function fetchCreateProjectExecution(projectId: string, data: Api.Project.SaveProjectExecutionParams) {
|
||||||
|
const result = await request<string | number>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: getExecutionPrefix(projectId),
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新项目执行 */
|
||||||
|
export function fetchUpdateProjectExecution(
|
||||||
|
projectId: string,
|
||||||
|
executionId: string,
|
||||||
|
data: Api.Project.SaveProjectExecutionParams
|
||||||
|
) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${getExecutionPrefix(projectId)}/${executionId}`,
|
||||||
|
method: 'put',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 变更项目执行负责人 */
|
||||||
|
export function fetchChangeProjectExecutionOwner(
|
||||||
|
projectId: string,
|
||||||
|
executionId: string,
|
||||||
|
data: Api.Project.ChangeExecutionOwnerParams
|
||||||
|
) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${getExecutionPrefix(projectId)}/${executionId}/change-owner`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 变更项目执行状态 */
|
||||||
|
export function fetchChangeProjectExecutionStatus(
|
||||||
|
projectId: string,
|
||||||
|
executionId: string,
|
||||||
|
data: Api.Project.ChangeExecutionStatusParams
|
||||||
|
) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${getExecutionPrefix(projectId)}/${executionId}/change-status`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取项目执行成员 */
|
||||||
|
export async function fetchGetProjectExecutionMembers(projectId: string, executionId: string) {
|
||||||
|
const result = await request<ExecutionMemberResponse[]>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${getExecutionPrefix(projectId)}/${executionId}/members`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ExecutionMemberResponse[]>, data =>
|
||||||
|
data.map(normalizeExecutionMember)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建项目执行成员 */
|
||||||
|
export async function fetchCreateProjectExecutionMember(
|
||||||
|
projectId: string,
|
||||||
|
executionId: string,
|
||||||
|
data: Api.Project.CreateExecutionMemberParams
|
||||||
|
) {
|
||||||
|
const result = await request<string | number>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${getExecutionPrefix(projectId)}/${executionId}/members`,
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移除项目执行成员 */
|
||||||
|
export function fetchInactiveProjectExecutionMember(
|
||||||
|
projectId: string,
|
||||||
|
executionId: string,
|
||||||
|
payload: { memberId: string; data: Api.Project.InactiveExecutionMemberParams }
|
||||||
|
) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${getExecutionPrefix(projectId)}/${executionId}/members/${payload.memberId}/inactive`,
|
||||||
|
method: 'post',
|
||||||
|
data: payload.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取项目执行成员变更历史分页 */
|
||||||
|
export async function fetchGetProjectExecutionMemberLogPage(
|
||||||
|
projectId: string,
|
||||||
|
executionId: string,
|
||||||
|
params?: Api.Project.ExecutionMemberLogSearchParams
|
||||||
|
) {
|
||||||
|
const result = await request<Api.Project.PageResult<ExecutionMemberLogResponse>>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${getExecutionPrefix(projectId)}/${executionId}/member-logs`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<Api.Project.PageResult<ExecutionMemberLogResponse>>, data => ({
|
||||||
|
...data,
|
||||||
|
list: data.list.map(normalizeExecutionMemberLog)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取项目任务分页 */
|
||||||
|
export async function fetchGetProjectTaskPage(
|
||||||
|
projectId: string,
|
||||||
|
executionId: string,
|
||||||
|
params?: Api.Project.ProjectTaskSearchParams
|
||||||
|
) {
|
||||||
|
const result = await request<ProjectTaskPageResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${getTaskPrefix(projectId, executionId)}/page`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectTaskPageResponse>, data => ({
|
||||||
|
...data,
|
||||||
|
list: data.list.map(normalizeProjectTask)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取项目任务状态看板 */
|
||||||
|
export function fetchGetProjectTaskStatusBoard(
|
||||||
|
projectId: string,
|
||||||
|
executionId: string,
|
||||||
|
params?: Api.Project.ProjectTaskStatusBoardParams
|
||||||
|
) {
|
||||||
|
return request<StatusBoardResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${getTaskPrefix(projectId, executionId)}/status-board`,
|
||||||
|
method: 'get',
|
||||||
|
params
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取项目任务详情 */
|
||||||
|
export async function fetchGetProjectTask(projectId: string, executionId: string, taskId: string) {
|
||||||
|
const result = await request<ProjectTaskResponse>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${getTaskPrefix(projectId, executionId)}/${taskId}`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<ProjectTaskResponse>, normalizeProjectTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建项目任务 */
|
||||||
|
export async function fetchCreateProjectTask(
|
||||||
|
projectId: string,
|
||||||
|
executionId: string,
|
||||||
|
data: Api.Project.SaveProjectTaskParams
|
||||||
|
) {
|
||||||
|
const result = await request<string | number>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: getTaskPrefix(projectId, executionId),
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
});
|
||||||
|
|
||||||
|
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新项目任务 */
|
||||||
|
export function fetchUpdateProjectTask(
|
||||||
|
projectId: string,
|
||||||
|
executionId: string,
|
||||||
|
payload: { taskId: string; data: Api.Project.SaveProjectTaskParams }
|
||||||
|
) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${getTaskPrefix(projectId, executionId)}/${payload.taskId}`,
|
||||||
|
method: 'put',
|
||||||
|
data: payload.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 变更项目任务状态 */
|
||||||
|
export function fetchChangeProjectTaskStatus(
|
||||||
|
projectId: string,
|
||||||
|
executionId: string,
|
||||||
|
payload: { taskId: string; data: Api.Project.ChangeTaskStatusParams }
|
||||||
|
) {
|
||||||
|
return request<boolean>({
|
||||||
|
...safeJsonRequestConfig,
|
||||||
|
url: `${getTaskPrefix(projectId, executionId)}/${payload.taskId}/change-status`,
|
||||||
|
method: 'post',
|
||||||
|
data: payload.data
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -95,31 +95,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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -428,6 +428,18 @@ html .el-collapse {
|
|||||||
margin-left: 0 !important;
|
margin-left: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 +496,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
6
src/typings/api/product.d.ts
vendored
6
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;
|
||||||
|
|||||||
450
src/typings/api/project.d.ts
vendored
Normal file
450
src/typings/api/project.d.ts
vendored
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
declare namespace Api {
|
||||||
|
/**
|
||||||
|
* namespace Project
|
||||||
|
*
|
||||||
|
* backend api module: "project/project"
|
||||||
|
*/
|
||||||
|
namespace Project {
|
||||||
|
/** 项目状态编码 */
|
||||||
|
type ProjectStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled' | 'archived';
|
||||||
|
|
||||||
|
/** 项目状态动作编码 */
|
||||||
|
type ProjectStatusActionCode = 'auto_start' | 'pause' | 'resume' | 'complete' | 'cancel' | 'reopen' | 'archive';
|
||||||
|
|
||||||
|
/** 项目设置基础信息 */
|
||||||
|
interface ProjectSettingBaseInfo {
|
||||||
|
/** 项目 ID */
|
||||||
|
id: string;
|
||||||
|
/** 项目编码 */
|
||||||
|
projectCode: string;
|
||||||
|
/** 项目名称 */
|
||||||
|
projectName: string;
|
||||||
|
/** 项目方向字典值 */
|
||||||
|
directionCode: string;
|
||||||
|
/** 项目类型字典值 */
|
||||||
|
projectType: string;
|
||||||
|
/** 所属产品 ID */
|
||||||
|
productId: string | null;
|
||||||
|
/** 所属产品名称 */
|
||||||
|
productName: string | null;
|
||||||
|
/** 项目负责人用户昵称 */
|
||||||
|
managerUserNickname: string | null;
|
||||||
|
/** 项目负责人用户 ID */
|
||||||
|
managerUserId: string | null;
|
||||||
|
/** 项目状态编码 */
|
||||||
|
statusCode: ProjectStatusCode;
|
||||||
|
/** 计划开始日期 */
|
||||||
|
plannedStartDate: string | null;
|
||||||
|
/** 计划结束日期 */
|
||||||
|
plannedEndDate: string | null;
|
||||||
|
/** 实际开始日期 */
|
||||||
|
actualStartDate: string | null;
|
||||||
|
/** 实际结束日期 */
|
||||||
|
actualEndDate: string | null;
|
||||||
|
/** 项目说明 */
|
||||||
|
projectDesc: string | null;
|
||||||
|
/** 最近一次状态动作原因 */
|
||||||
|
lastStatusReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目生命周期动作 */
|
||||||
|
interface ProjectLifecycleAction {
|
||||||
|
actionCode: Exclude<ProjectStatusActionCode, 'auto_start'>;
|
||||||
|
actionName: string;
|
||||||
|
needReason: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目生命周期信息 */
|
||||||
|
interface ProjectLifecycleInfo {
|
||||||
|
statusCode: ProjectStatusCode;
|
||||||
|
lastStatusReason: string | null;
|
||||||
|
availableActions: ProjectLifecycleAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行状态编码 */
|
||||||
|
type ProjectExecutionStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
|
/** 执行动作编码 */
|
||||||
|
type ProjectExecutionActionCode = 'start' | 'pause' | 'resume' | 'cancel';
|
||||||
|
|
||||||
|
/** 任务状态编码 */
|
||||||
|
type ProjectTaskStatusCode = 'pending' | 'active' | 'blocked' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
|
/** 任务动作编码 */
|
||||||
|
type ProjectTaskActionCode = 'start' | 'block' | 'resume' | 'complete' | 'cancel';
|
||||||
|
|
||||||
|
interface LifecycleAction<ActionCode extends string = string> {
|
||||||
|
actionCode: ActionCode;
|
||||||
|
actionName: string;
|
||||||
|
needReason: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusBoardItem {
|
||||||
|
statusCode: string;
|
||||||
|
statusName: string;
|
||||||
|
count: number;
|
||||||
|
sort: number;
|
||||||
|
terminal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusBoard {
|
||||||
|
total: number;
|
||||||
|
items: StatusBoardItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectExecution {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
projectRequirementId: string | null;
|
||||||
|
executionName: string;
|
||||||
|
executionType: string | null;
|
||||||
|
ownerId: string;
|
||||||
|
ownerNickname?: string | null;
|
||||||
|
statusCode: ProjectExecutionStatusCode;
|
||||||
|
statusName: string | null;
|
||||||
|
terminal: boolean;
|
||||||
|
allowEdit: boolean;
|
||||||
|
availableActions: LifecycleAction<ProjectExecutionActionCode>[];
|
||||||
|
plannedStartDate: string | null;
|
||||||
|
plannedEndDate: string | null;
|
||||||
|
actualStartDate: string | null;
|
||||||
|
actualEndDate: string | null;
|
||||||
|
progressRate: number;
|
||||||
|
executionDesc: string | null;
|
||||||
|
lastStatusReason: string | null;
|
||||||
|
createTime: string;
|
||||||
|
updateTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecutionMember {
|
||||||
|
id: string;
|
||||||
|
executionId: string;
|
||||||
|
userId: string;
|
||||||
|
userNickname?: string | null;
|
||||||
|
joinedAt: string | null;
|
||||||
|
removedAt: string | null;
|
||||||
|
removedReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行成员变更事件类型 */
|
||||||
|
type ExecutionMemberActionType = 'join' | 'inactive' | 'owner_transfer_in' | 'owner_transfer_out';
|
||||||
|
|
||||||
|
/** 执行成员变更历史 */
|
||||||
|
interface ExecutionMemberLog {
|
||||||
|
id: string;
|
||||||
|
executionId: string;
|
||||||
|
actionType: ExecutionMemberActionType;
|
||||||
|
userId: string;
|
||||||
|
userNicknameSnapshot: string | null;
|
||||||
|
operatorUserId: string;
|
||||||
|
operatorNicknameSnapshot: string | null;
|
||||||
|
actionTime: string;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExecutionMemberLogSearchParams = CommonType.RecordNullable<
|
||||||
|
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||||
|
actionTypes: ExecutionMemberActionType[];
|
||||||
|
userId: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface ProjectTask {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
executionId: string;
|
||||||
|
parentTaskId: string | null;
|
||||||
|
taskTitle: string;
|
||||||
|
ownerId: string;
|
||||||
|
ownerNickname?: string | null;
|
||||||
|
statusCode: ProjectTaskStatusCode;
|
||||||
|
statusName: string | null;
|
||||||
|
terminal: boolean;
|
||||||
|
allowEdit: boolean;
|
||||||
|
availableActions: LifecycleAction<ProjectTaskActionCode>[];
|
||||||
|
progressRate: number;
|
||||||
|
plannedStartDate: string | null;
|
||||||
|
plannedEndDate: string | null;
|
||||||
|
actualStartDate: string | null;
|
||||||
|
actualEndDate: string | null;
|
||||||
|
taskDesc: string | null;
|
||||||
|
lastStatusReason: string | null;
|
||||||
|
createTime: string;
|
||||||
|
updateTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectExecutionSearchParams = CommonType.RecordNullable<
|
||||||
|
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||||
|
keyword: string;
|
||||||
|
executionType: string;
|
||||||
|
ownerId: string;
|
||||||
|
statusCode: string;
|
||||||
|
updateTime: string[];
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ProjectExecutionStatusBoardParams = CommonType.RecordNullable<{
|
||||||
|
keyword: string;
|
||||||
|
executionType: string;
|
||||||
|
ownerId: string;
|
||||||
|
updateTime: string[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
interface SaveProjectExecutionParams {
|
||||||
|
executionName: string;
|
||||||
|
executionType: string;
|
||||||
|
ownerId: string;
|
||||||
|
projectRequirementId: string | null;
|
||||||
|
plannedStartDate: string | null;
|
||||||
|
plannedEndDate: string | null;
|
||||||
|
executionDesc: string | null;
|
||||||
|
memberUserIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChangeExecutionOwnerParams {
|
||||||
|
newOwnerId: string;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChangeExecutionStatusParams {
|
||||||
|
actionCode: ProjectExecutionActionCode;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateExecutionMemberParams {
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InactiveExecutionMemberParams {
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectTaskSearchParams = CommonType.RecordNullable<
|
||||||
|
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||||
|
keyword: string;
|
||||||
|
parentTaskId: string;
|
||||||
|
ownerId: string;
|
||||||
|
statusCode: string;
|
||||||
|
updateTime: string[];
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ProjectTaskStatusBoardParams = CommonType.RecordNullable<{
|
||||||
|
keyword: string;
|
||||||
|
parentTaskId: string;
|
||||||
|
ownerId: string;
|
||||||
|
updateTime: string[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
interface SaveProjectTaskParams {
|
||||||
|
parentTaskId: string | null;
|
||||||
|
taskTitle: string;
|
||||||
|
ownerId: string | null;
|
||||||
|
progressRate?: number;
|
||||||
|
plannedStartDate: string | null;
|
||||||
|
plannedEndDate: string | null;
|
||||||
|
taskDesc: string | null;
|
||||||
|
/** 仅创建任务时生效,编辑接口静默忽略;userId 必须是当前有效执行成员且不能等于 ownerId */
|
||||||
|
assigneeUserIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChangeTaskStatusParams {
|
||||||
|
actionCode: ProjectTaskActionCode;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目设置参数 */
|
||||||
|
interface ProjectSettings {
|
||||||
|
baseInfo: ProjectSettingBaseInfo;
|
||||||
|
lifecycle: ProjectLifecycleInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目设置基础信息参数 */
|
||||||
|
interface UpdateProjectSettingBaseInfoParams {
|
||||||
|
projectName: string;
|
||||||
|
directionCode: string;
|
||||||
|
projectType: string;
|
||||||
|
plannedStartDate: string | null;
|
||||||
|
plannedEndDate: string | null;
|
||||||
|
projectDesc: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目成员状态 */
|
||||||
|
type ProjectMemberStatus = 0 | 1;
|
||||||
|
|
||||||
|
interface PageParams {
|
||||||
|
pageNo: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageResult<T = any> {
|
||||||
|
total: number;
|
||||||
|
list: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目入口页概览统计 */
|
||||||
|
interface ProjectOverviewSummary {
|
||||||
|
/** 项目状态数量映射,key 为后端状态编码 */
|
||||||
|
statusCounts: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
/** 项目 ID */
|
||||||
|
id: string;
|
||||||
|
/** 项目编码 */
|
||||||
|
projectCode: string;
|
||||||
|
/** 项目名称 */
|
||||||
|
projectName: string;
|
||||||
|
/** 项目方向字典值 */
|
||||||
|
directionCode: string;
|
||||||
|
/** 项目类型字典值 */
|
||||||
|
projectType: string;
|
||||||
|
/** 所属产品 ID */
|
||||||
|
productId: string | null;
|
||||||
|
/** 所属产品名称 */
|
||||||
|
productName?: string | null;
|
||||||
|
/** 项目负责人用户 ID */
|
||||||
|
managerUserId: string;
|
||||||
|
/** 项目负责人用户昵称 */
|
||||||
|
managerUserNickname?: string | null;
|
||||||
|
/** 项目状态编码 */
|
||||||
|
statusCode: ProjectStatusCode;
|
||||||
|
/** 计划开始日期 */
|
||||||
|
plannedStartDate: string | null;
|
||||||
|
/** 计划结束日期 */
|
||||||
|
plannedEndDate: string | null;
|
||||||
|
/** 实际开始日期 */
|
||||||
|
actualStartDate: string | null;
|
||||||
|
/** 实际结束日期 */
|
||||||
|
actualEndDate: string | null;
|
||||||
|
/** 进度百分比 */
|
||||||
|
progressRate: number;
|
||||||
|
/** 项目说明 */
|
||||||
|
projectDesc: string | null;
|
||||||
|
/** 最近一次状态动作原因 */
|
||||||
|
lastStatusReason: string | null;
|
||||||
|
/** 创建时间 */
|
||||||
|
createTime: string;
|
||||||
|
/** 更新时间 */
|
||||||
|
updateTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectContext {
|
||||||
|
currentProject: {
|
||||||
|
id: string;
|
||||||
|
projectCode: string;
|
||||||
|
projectName: string;
|
||||||
|
projectType: string;
|
||||||
|
productId: string | null;
|
||||||
|
managerUserId: string;
|
||||||
|
statusCode: ProjectStatusCode;
|
||||||
|
};
|
||||||
|
currentRole: {
|
||||||
|
roleId: string | null;
|
||||||
|
roleCode: string | null;
|
||||||
|
roleName: string | null;
|
||||||
|
guestFlag: boolean;
|
||||||
|
};
|
||||||
|
navs: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
icon: string;
|
||||||
|
sort: number;
|
||||||
|
}>;
|
||||||
|
buttons: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectMember {
|
||||||
|
/** 成员关系 ID */
|
||||||
|
id: string;
|
||||||
|
/** 用户 ID */
|
||||||
|
userId: string;
|
||||||
|
/** 用户昵称 */
|
||||||
|
userNickname: string;
|
||||||
|
/** 角色 ID */
|
||||||
|
roleId: string;
|
||||||
|
/** 角色名称 */
|
||||||
|
roleName: string;
|
||||||
|
/** 角色编码 */
|
||||||
|
roleCode: string;
|
||||||
|
/** 是否项目负责人 */
|
||||||
|
managerFlag: boolean;
|
||||||
|
/** 成员状态 */
|
||||||
|
status: ProjectMemberStatus;
|
||||||
|
/** 加入时间 */
|
||||||
|
joinedTime: string;
|
||||||
|
/** 退出时间 */
|
||||||
|
leftTime: string | null;
|
||||||
|
/** 备注 */
|
||||||
|
remark: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目搜索参数 */
|
||||||
|
type ProjectSearchParams = CommonType.RecordNullable<
|
||||||
|
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||||
|
keyword: string;
|
||||||
|
directionCode: string;
|
||||||
|
projectType: string;
|
||||||
|
productId: string;
|
||||||
|
managerUserId: string;
|
||||||
|
statusCode: ProjectStatusCode;
|
||||||
|
updateTime: string[];
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** 创建/保存项目参数 */
|
||||||
|
type SaveProjectParams = Pick<Project, 'projectName' | 'directionCode' | 'projectType' | 'projectDesc'> & {
|
||||||
|
projectCode: string | null;
|
||||||
|
productId: string | null;
|
||||||
|
managerUserId: string;
|
||||||
|
plannedStartDate: string | null;
|
||||||
|
plannedEndDate: string | null;
|
||||||
|
actualStartDate?: string | null;
|
||||||
|
actualEndDate?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 更新项目参数 */
|
||||||
|
type UpdateProjectParams = { id: string } & SaveProjectParams;
|
||||||
|
|
||||||
|
/** 变更项目状态参数 */
|
||||||
|
interface ChangeProjectStatusParams {
|
||||||
|
id: string;
|
||||||
|
actionCode: ProjectStatusActionCode;
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除项目参数 */
|
||||||
|
interface DeleteProjectParams {
|
||||||
|
id: string;
|
||||||
|
projectName: string;
|
||||||
|
confirmText: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建项目成员参数 */
|
||||||
|
interface CreateProjectMemberParams {
|
||||||
|
userId: string;
|
||||||
|
roleId: string;
|
||||||
|
remark: string | null;
|
||||||
|
previousManagerUserId?: string | null;
|
||||||
|
previousManagerRoleId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新项目成员参数 */
|
||||||
|
interface UpdateProjectMemberParams {
|
||||||
|
roleId: string;
|
||||||
|
reason: string | null;
|
||||||
|
remark: string | null;
|
||||||
|
previousManagerUserId?: string | null;
|
||||||
|
previousManagerRoleId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移出项目成员参数 */
|
||||||
|
interface InactiveProjectMemberParams {
|
||||||
|
reason: string | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/typings/api/system-manage.d.ts
vendored
6
src/typings/api/system-manage.d.ts
vendored
@@ -428,6 +428,12 @@ declare namespace Api {
|
|||||||
id: string;
|
id: string;
|
||||||
/** 用户昵称 */
|
/** 用户昵称 */
|
||||||
nickname: string;
|
nickname: string;
|
||||||
|
/** 用户账号 */
|
||||||
|
username?: string | null;
|
||||||
|
/** 部门 ID */
|
||||||
|
deptId?: string | null;
|
||||||
|
/** 部门名称 */
|
||||||
|
deptName?: string | null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/typings/components.d.ts
vendored
9
src/typings/components.d.ts
vendored
@@ -14,6 +14,10 @@ declare module 'vue' {
|
|||||||
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']
|
||||||
|
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']
|
||||||
@@ -58,6 +62,7 @@ declare module 'vue' {
|
|||||||
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 +70,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']
|
||||||
@@ -115,6 +121,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']
|
||||||
@@ -124,6 +131,7 @@ 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']
|
||||||
|
IconMdiCloseCircle: typeof import('~icons/mdi/close-circle')['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']
|
||||||
@@ -154,6 +162,7 @@ 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']
|
||||||
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
||||||
|
|||||||
13
src/typings/elegant-router.d.ts
vendored
13
src/typings/elegant-router.d.ts
vendored
@@ -65,6 +65,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";
|
||||||
@@ -117,6 +124,7 @@ declare module "@elegant-router/types" {
|
|||||||
| "login"
|
| "login"
|
||||||
| "plugin"
|
| "plugin"
|
||||||
| "product"
|
| "product"
|
||||||
|
| "project"
|
||||||
| "system"
|
| "system"
|
||||||
| "user-center"
|
| "user-center"
|
||||||
>;
|
>;
|
||||||
@@ -172,6 +180,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"
|
||||||
|
|||||||
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']
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,47 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import WangEditor from 'wangeditor';
|
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'QuillPage' });
|
defineOptions({ name: 'QuillPage' });
|
||||||
|
|
||||||
const editor = ref<WangEditor>();
|
const value = ref('<p>hello <strong>wangEditor v5</strong></p>');
|
||||||
const domRef = ref<HTMLElement>();
|
|
||||||
|
|
||||||
function renderWangEditor() {
|
|
||||||
editor.value = new WangEditor(domRef.value);
|
|
||||||
setEditorConfig();
|
|
||||||
editor.value.create();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setEditorConfig() {
|
|
||||||
if (editor.value?.config?.zIndex) {
|
|
||||||
editor.value.config.zIndex = 10;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
renderWangEditor();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full">
|
<div class="h-full">
|
||||||
<ElCard header="富文本插件" class="card-wrapper">
|
<ElCard header="富文本插件" class="card-wrapper">
|
||||||
<div ref="domRef" class="bg-white dark:bg-dark"></div>
|
<BusinessRichTextEditor v-model="value" :height="360" upload-directory="demo" />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<GithubLink link="https://github.com/wangeditor-team/wangEditor" />
|
<GithubLink link="https://github.com/wangeditor-next/wangEditor-next" />
|
||||||
</template>
|
</template>
|
||||||
</ElCard>
|
</ElCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
:deep(.w-e-toolbar) {
|
|
||||||
background: inherit !important;
|
|
||||||
border-color: var(--el-border-color) !important;
|
|
||||||
}
|
|
||||||
:deep(.w-e-text-container) {
|
|
||||||
background: inherit;
|
|
||||||
border-color: var(--el-border-color) !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -247,7 +247,12 @@ watch([() => visible.value, () => props.productId], ([currentVisible, productId]
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="product-activity-dialog__sentence">
|
<p class="product-activity-dialog__sentence">
|
||||||
<span class="product-activity-dialog__sentence-main">{{ item.compactText }}</span>
|
<span class="product-activity-dialog__sentence-main">
|
||||||
|
<template v-for="(part, index) in item.compactTextParts" :key="`${item.id}-${index}`">
|
||||||
|
<strong v-if="part.strong" class="product-activity-dialog__subject">{{ part.text }}</strong>
|
||||||
|
<span v-else>{{ part.text }}</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
<span v-if="item.statusTransition">,状态:{{ item.statusTransition }}</span>
|
<span v-if="item.statusTransition">,状态:{{ item.statusTransition }}</span>
|
||||||
<span v-if="item.reasonText">,原因:{{ item.reasonText }}</span>
|
<span v-if="item.reasonText">,原因:{{ item.reasonText }}</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -497,6 +502,11 @@ watch([() => visible.value, () => props.productId], ([currentVisible, productId]
|
|||||||
color: var(--el-text-color-primary);
|
color: var(--el-text-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-activity-dialog__subject {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.product-activity-dialog__footer-inner {
|
.product-activity-dialog__footer-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -112,7 +112,12 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="product-activity-panel__sentence">
|
<p class="product-activity-panel__sentence">
|
||||||
<span class="product-activity-panel__sentence-main">{{ item.compactText }}</span>
|
<span class="product-activity-panel__sentence-main">
|
||||||
|
<template v-for="(part, index) in item.compactTextParts" :key="`${item.id}-${index}`">
|
||||||
|
<strong v-if="part.strong" class="product-activity-panel__subject">{{ part.text }}</strong>
|
||||||
|
<span v-else>{{ part.text }}</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
<span v-if="item.statusTransition">,状态:{{ item.statusTransition }}</span>
|
<span v-if="item.statusTransition">,状态:{{ item.statusTransition }}</span>
|
||||||
<span v-if="item.reasonText">,原因:{{ item.reasonText }}</span>
|
<span v-if="item.reasonText">,原因:{{ item.reasonText }}</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -262,6 +267,11 @@ watch(
|
|||||||
color: rgb(15 23 42 / 98%);
|
color: rgb(15 23 42 / 98%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-activity-panel__subject {
|
||||||
|
color: rgb(15 23 42 / 98%);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
.product-activity-panel__body {
|
.product-activity-panel__body {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
|
|||||||
@@ -17,13 +17,20 @@ export type ProductActivityFilterType = 'all' | Api.Product.ProductActivityType;
|
|||||||
|
|
||||||
export type ProductActivityTone = 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
|
export type ProductActivityTone = 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||||
|
|
||||||
|
export interface ProductActivityTextPart {
|
||||||
|
text: string;
|
||||||
|
strong?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProductActivityDisplayItem extends Api.Product.ProductActivityTimelineItem {
|
export interface ProductActivityDisplayItem extends Api.Product.ProductActivityTimelineItem {
|
||||||
tagLabel: string;
|
tagLabel: string;
|
||||||
timeText: string;
|
timeText: string;
|
||||||
actionText: string;
|
actionText: string;
|
||||||
displaySummary: string;
|
displaySummary: string;
|
||||||
compactText: string;
|
compactText: string;
|
||||||
|
compactTextParts: ProductActivityTextPart[];
|
||||||
operatorText: string;
|
operatorText: string;
|
||||||
|
subjectText: string;
|
||||||
reasonText: string;
|
reasonText: string;
|
||||||
statusTransition: string;
|
statusTransition: string;
|
||||||
tone: ProductActivityTone;
|
tone: ProductActivityTone;
|
||||||
@@ -250,6 +257,10 @@ function isGenericActivitySummary(summaryText: string, actionText: string) {
|
|||||||
return summaryText === actionText || summaryText === actionText.replace('执行了', '执行了');
|
return summaryText === actionText || summaryText === actionText.replace('执行了', '执行了');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMemberActivityAction(actionType: Api.Product.ProductActivityActionType) {
|
||||||
|
return actionType === 'add_member' || actionType === 'remove_member' || actionType === 'update_member';
|
||||||
|
}
|
||||||
|
|
||||||
function buildMemberChangeSummary(
|
function buildMemberChangeSummary(
|
||||||
item: Api.Product.ProductActivityTimelineItem,
|
item: Api.Product.ProductActivityTimelineItem,
|
||||||
detailsRecord: ActivityDetailRecord | null,
|
detailsRecord: ActivityDetailRecord | null,
|
||||||
@@ -263,9 +274,10 @@ function buildMemberChangeSummary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const memberDetail = roleName ? `${memberName}(${roleName})` : memberName;
|
const memberDetail = roleName ? `${memberName}(${roleName})` : memberName;
|
||||||
const actionLabel = item.actionType === 'add_member' ? '将成员加入产品' : '将成员移出产品';
|
|
||||||
|
|
||||||
return operatorText === '--' ? `${actionLabel}:${memberDetail}` : `${operatorText}${actionLabel}:${memberDetail}`;
|
return operatorText === '--'
|
||||||
|
? `执行了【${item.actionName}】:${memberDetail}`
|
||||||
|
: `${operatorText}执行了【${item.actionName}】:${memberDetail}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMemberUpdateSummary(
|
function buildMemberUpdateSummary(
|
||||||
@@ -279,8 +291,8 @@ function buildMemberUpdateSummary(
|
|||||||
const roleText = roleTransitionText ? `,角色:${roleTransitionText}` : '';
|
const roleText = roleTransitionText ? `,角色:${roleTransitionText}` : '';
|
||||||
|
|
||||||
return operatorText === '--'
|
return operatorText === '--'
|
||||||
? `调整成员:${memberText}${roleText}`
|
? `执行了【${item.actionName}】:${memberText}${roleText}`
|
||||||
: `${operatorText}调整成员:${memberText}${roleText}`;
|
: `${operatorText}执行了【${item.actionName}】:${memberText}${roleText}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildManagerChangeSummary(detailsRecord: ActivityDetailRecord | null, operatorText: string) {
|
function buildManagerChangeSummary(detailsRecord: ActivityDetailRecord | null, operatorText: string) {
|
||||||
@@ -309,15 +321,11 @@ function buildManagerChangeSummary(detailsRecord: ActivityDetailRecord | null, o
|
|||||||
|
|
||||||
function resolveDetailedSummary(
|
function resolveDetailedSummary(
|
||||||
item: Api.Product.ProductActivityTimelineItem,
|
item: Api.Product.ProductActivityTimelineItem,
|
||||||
operatorText: string,
|
detailsRecord: ActivityDetailRecord | null,
|
||||||
actionText: string
|
texts: { operatorText: string; actionText: string }
|
||||||
) {
|
) {
|
||||||
|
const { operatorText, actionText } = texts;
|
||||||
const summaryText = item.summary?.trim() || '';
|
const summaryText = item.summary?.trim() || '';
|
||||||
const detailsRecord = parseActivityDetails(item.details);
|
|
||||||
|
|
||||||
if (!isGenericActivitySummary(summaryText, actionText)) {
|
|
||||||
return summaryText;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.actionType === 'add_member' || item.actionType === 'remove_member') {
|
if (item.actionType === 'add_member' || item.actionType === 'remove_member') {
|
||||||
return buildMemberChangeSummary(item, detailsRecord, operatorText) || summaryText || actionText;
|
return buildMemberChangeSummary(item, detailsRecord, operatorText) || summaryText || actionText;
|
||||||
@@ -327,6 +335,10 @@ function resolveDetailedSummary(
|
|||||||
return buildMemberUpdateSummary(item, detailsRecord, operatorText);
|
return buildMemberUpdateSummary(item, detailsRecord, operatorText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isGenericActivitySummary(summaryText, actionText)) {
|
||||||
|
return summaryText;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.actionType === 'change_manager') {
|
if (item.actionType === 'change_manager') {
|
||||||
return buildManagerChangeSummary(detailsRecord, operatorText) || summaryText || actionText;
|
return buildManagerChangeSummary(detailsRecord, operatorText) || summaryText || actionText;
|
||||||
}
|
}
|
||||||
@@ -334,13 +346,31 @@ function resolveDetailedSummary(
|
|||||||
return summaryText || actionText;
|
return summaryText || actionText;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildProductActivityTextParts(text: string, subjectText: string): ProductActivityTextPart[] {
|
||||||
|
const normalizedSubject = subjectText.trim();
|
||||||
|
const subjectIndex = normalizedSubject ? text.indexOf(normalizedSubject) : -1;
|
||||||
|
|
||||||
|
if (subjectIndex < 0) {
|
||||||
|
return [{ text }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ text: text.slice(0, subjectIndex) },
|
||||||
|
{ text: normalizedSubject, strong: true },
|
||||||
|
{ text: text.slice(subjectIndex + normalizedSubject.length) }
|
||||||
|
].filter(part => part.text);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildProductActivityDisplayItem(
|
export function buildProductActivityDisplayItem(
|
||||||
item: Api.Product.ProductActivityTimelineItem
|
item: Api.Product.ProductActivityTimelineItem
|
||||||
): ProductActivityDisplayItem {
|
): ProductActivityDisplayItem {
|
||||||
const operatorText = item.operatorName?.trim() || '--';
|
const operatorText = item.operatorName?.trim() || '--';
|
||||||
const actionText =
|
const actionText =
|
||||||
operatorText === '--' ? `执行了【${item.actionName}】` : `${operatorText}执行了【${item.actionName}】`;
|
operatorText === '--' ? `执行了【${item.actionName}】` : `${operatorText}执行了【${item.actionName}】`;
|
||||||
const displaySummary = item.type === 'status' ? actionText : resolveDetailedSummary(item, operatorText, actionText);
|
const detailsRecord = parseActivityDetails(item.details);
|
||||||
|
const subjectText = isMemberActivityAction(item.actionType) ? getActivityTargetUserName(item, detailsRecord) : '';
|
||||||
|
const displaySummary =
|
||||||
|
item.type === 'status' ? actionText : resolveDetailedSummary(item, detailsRecord, { operatorText, actionText });
|
||||||
const compactText = displaySummary;
|
const compactText = displaySummary;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -350,7 +380,9 @@ export function buildProductActivityDisplayItem(
|
|||||||
actionText,
|
actionText,
|
||||||
displaySummary,
|
displaySummary,
|
||||||
compactText,
|
compactText,
|
||||||
|
compactTextParts: buildProductActivityTextParts(compactText, subjectText),
|
||||||
operatorText,
|
operatorText,
|
||||||
|
subjectText,
|
||||||
reasonText: item.reason?.trim() || '',
|
reasonText: item.reason?.trim() || '',
|
||||||
statusTransition:
|
statusTransition:
|
||||||
item.type === 'status' && item.fromStatus && item.toStatus
|
item.type === 'status' && item.fromStatus && item.toStatus
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import dayjs from 'dayjs';
|
|||||||
import { CircleCheckFilled, DeleteFilled, FolderOpened, VideoPause } from '@element-plus/icons-vue';
|
import { CircleCheckFilled, DeleteFilled, FolderOpened, VideoPause } from '@element-plus/icons-vue';
|
||||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||||
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||||
import { fetchGetProductPage, fetchGetUserSimpleList } from '@/service/api';
|
import { fetchGetProductOverviewSummary, fetchGetProductPage, fetchGetUserSimpleList } from '@/service/api';
|
||||||
import { useDict } from '@/hooks/business/dict';
|
import { useDict } from '@/hooks/business/dict';
|
||||||
import { useRouterPush } from '@/hooks/common/router';
|
import { useRouterPush } from '@/hooks/common/router';
|
||||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||||
@@ -27,7 +27,6 @@ interface StatusNavMeta {
|
|||||||
|
|
||||||
type ProductPageResponse = Awaited<ReturnType<typeof fetchGetProductPage>>;
|
type ProductPageResponse = Awaited<ReturnType<typeof fetchGetProductPage>>;
|
||||||
|
|
||||||
const PRODUCT_OPTION_PAGE_SIZE = 200;
|
|
||||||
const PRODUCT_ENTRY_ROUTE_PATH = '/product/list';
|
const PRODUCT_ENTRY_ROUTE_PATH = '/product/list';
|
||||||
|
|
||||||
function getInitSearchParams(): Api.Product.ProductSearchParams {
|
function getInitSearchParams(): Api.Product.ProductSearchParams {
|
||||||
@@ -72,59 +71,6 @@ function formatDateTime(value?: string | null) {
|
|||||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchProductTotal(params: Api.Product.ProductSearchParams) {
|
|
||||||
const { error, data } = await fetchGetProductPage({
|
|
||||||
...params,
|
|
||||||
pageNo: 1,
|
|
||||||
pageSize: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.total;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAllProducts() {
|
|
||||||
async function collect(pageNo: number, list: Api.Product.Product[]): Promise<Api.Product.Product[] | null> {
|
|
||||||
const { error, data } = await fetchGetProductPage({
|
|
||||||
pageNo,
|
|
||||||
pageSize: PRODUCT_OPTION_PAGE_SIZE
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextList = list.concat(data.list);
|
|
||||||
|
|
||||||
if (nextList.length >= data.total || data.list.length === 0) {
|
|
||||||
return nextList;
|
|
||||||
}
|
|
||||||
|
|
||||||
return collect(pageNo + 1, nextList);
|
|
||||||
}
|
|
||||||
|
|
||||||
return collect(1, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createManagerOptions(products: Api.Product.Product[], users: Api.SystemManage.UserSimple[]) {
|
|
||||||
const managerIdSet = new Set(products.map(item => String(item.managerUserId)).filter(Boolean));
|
|
||||||
const userMap = new Map(users.map(item => [String(item.id), item]));
|
|
||||||
|
|
||||||
const options = Array.from(managerIdSet).map(managerUserId => {
|
|
||||||
return (
|
|
||||||
userMap.get(managerUserId) || {
|
|
||||||
id: managerUserId,
|
|
||||||
nickname: String(managerUserId)
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return sortManagerOptions(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusNavMetas: StatusNavMeta[] = [
|
const statusNavMetas: StatusNavMeta[] = [
|
||||||
{
|
{
|
||||||
key: 'active',
|
key: 'active',
|
||||||
@@ -166,15 +112,13 @@ const { routerPush } = useRouterPush();
|
|||||||
|
|
||||||
const { dictData: directionOptions, getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
const { dictData: directionOptions, getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||||
|
|
||||||
const statusCounts = ref<Record<Api.Product.ProductStatusCode, number>>({
|
const statusCounts = ref<Record<string, number>>({
|
||||||
active: 0,
|
active: 0,
|
||||||
archived: 0,
|
archived: 0,
|
||||||
paused: 0,
|
paused: 0,
|
||||||
abandoned: 0
|
abandoned: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
const recentUpdatedCount = ref(0);
|
|
||||||
|
|
||||||
const managerLabelMap = computed(() => {
|
const managerLabelMap = computed(() => {
|
||||||
return new Map(managerUserOptions.value.map(item => [String(item.id), item.nickname]));
|
return new Map(managerUserOptions.value.map(item => [String(item.id), item.nickname]));
|
||||||
});
|
});
|
||||||
@@ -182,7 +126,7 @@ const managerLabelMap = computed(() => {
|
|||||||
const statusItems = computed(() =>
|
const statusItems = computed(() =>
|
||||||
statusNavMetas.map(item => ({
|
statusNavMetas.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
count: statusCounts.value[item.key]
|
count: statusCounts.value[item.key] ?? 0
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -194,7 +138,7 @@ const overviewMetrics = computed(() => [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '当前启用',
|
label: '当前启用',
|
||||||
value: statusCounts.value.active,
|
value: statusCounts.value.active ?? 0,
|
||||||
hint: '正在持续服务和维护的产品'
|
hint: '正在持续服务和维护的产品'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -203,9 +147,9 @@ const overviewMetrics = computed(() => [
|
|||||||
hint: '已加载的方向字典项数量'
|
hint: '已加载的方向字典项数量'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '30天内更新',
|
label: '废弃产品',
|
||||||
value: recentUpdatedCount.value,
|
value: statusCounts.value.abandoned ?? 0,
|
||||||
hint: '最近 30 天内发生过更新的产品'
|
hint: '已明确停止建设的产品'
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -312,44 +256,33 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
immediate: false
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadManagerOptions() {
|
async function loadManagerOptions() {
|
||||||
const [allProducts, userSimpleResult] = await Promise.all([fetchAllProducts(), fetchGetUserSimpleList()]);
|
const { error, data: userList } = await fetchGetUserSimpleList();
|
||||||
|
|
||||||
const userSimpleList =
|
if (error || !userList) {
|
||||||
userSimpleResult.error || !userSimpleResult.data ? [] : sortManagerOptions(userSimpleResult.data);
|
managerUserOptions.value = [];
|
||||||
|
|
||||||
managerUserOptions.value = userSimpleList;
|
|
||||||
|
|
||||||
if (!allProducts) {
|
|
||||||
managerFilterOptions.value = [];
|
managerFilterOptions.value = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
managerFilterOptions.value = createManagerOptions(allProducts, userSimpleList);
|
const userSimpleList = sortManagerOptions(userList);
|
||||||
|
managerUserOptions.value = userSimpleList;
|
||||||
|
managerFilterOptions.value = userSimpleList;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadOverviewData() {
|
async function loadOverviewData() {
|
||||||
const end = dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss');
|
const { error, data: overviewSummary } = await fetchGetProductOverviewSummary();
|
||||||
const start = dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss');
|
|
||||||
|
|
||||||
const [activeTotal, archivedTotal, pausedTotal, abandonedTotal, recentTotal] = await Promise.all([
|
if (error || !overviewSummary) {
|
||||||
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'active' }),
|
statusCounts.value = {};
|
||||||
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'archived' }),
|
return;
|
||||||
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'paused' }),
|
}
|
||||||
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'abandoned' }),
|
|
||||||
fetchProductTotal({ pageNo: 1, pageSize: 1, updateTime: [start, end] })
|
|
||||||
]);
|
|
||||||
|
|
||||||
statusCounts.value = {
|
statusCounts.value = overviewSummary.statusCounts || {};
|
||||||
active: activeTotal,
|
|
||||||
archived: archivedTotal,
|
|
||||||
paused: pausedTotal,
|
|
||||||
abandoned: abandonedTotal
|
|
||||||
};
|
|
||||||
recentUpdatedCount.value = recentTotal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reloadProductTable(page = searchParams.pageNo ?? 1) {
|
async function reloadProductTable(page = searchParams.pageNo ?? 1) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
|||||||
import { fetchCreateProduct, fetchGetProduct, fetchUpdateProduct } from '@/service/api';
|
import { fetchCreateProduct, fetchGetProduct, fetchUpdateProduct } from '@/service/api';
|
||||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
|
||||||
import DictSelect from '@/components/custom/dict-select.vue';
|
import DictSelect from '@/components/custom/dict-select.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'ProductOperateDialog' });
|
defineOptions({ name: 'ProductOperateDialog' });
|
||||||
@@ -166,14 +167,14 @@ watch(visible, async value => {
|
|||||||
<BusinessFormDialog
|
<BusinessFormDialog
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
:title="dialogTitle"
|
:title="dialogTitle"
|
||||||
preset="lg"
|
preset="sm"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:confirm-loading="submitting"
|
:confirm-loading="submitting"
|
||||||
@confirm="handleSubmit"
|
@confirm="handleSubmit"
|
||||||
>
|
>
|
||||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||||
<ElRow :gutter="16">
|
<ElRow :gutter="16">
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem v-if="isEditMode" label="产品编码" prop="code">
|
<ElFormItem v-if="isEditMode" label="产品编码" prop="code">
|
||||||
<ElInput
|
<ElInput
|
||||||
:model-value="model.code"
|
:model-value="model.code"
|
||||||
@@ -186,12 +187,12 @@ watch(visible, async value => {
|
|||||||
<ElInput v-model="model.code" clearable placeholder="不填则由后端自动生成" />
|
<ElInput v-model="model.code" clearable placeholder="不填则由后端自动生成" />
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem label="产品名称" prop="name">
|
<ElFormItem label="产品名称" prop="name">
|
||||||
<ElInput v-model="model.name" clearable maxlength="128" placeholder="请输入产品名称" />
|
<ElInput v-model="model.name" clearable maxlength="128" placeholder="请输入产品名称" />
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem label="产品方向" prop="directionCode">
|
<ElFormItem label="产品方向" prop="directionCode">
|
||||||
<DictSelect
|
<DictSelect
|
||||||
v-model="model.directionCode"
|
v-model="model.directionCode"
|
||||||
@@ -201,7 +202,7 @@ watch(visible, async value => {
|
|||||||
/>
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem v-if="isEditMode">
|
<ElFormItem v-if="isEditMode">
|
||||||
<template #label>
|
<template #label>
|
||||||
<span class="business-form-label-with-tip">
|
<span class="business-form-label-with-tip">
|
||||||
@@ -225,9 +226,11 @@ watch(visible, async value => {
|
|||||||
/>
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem v-else label="产品经理" prop="managerUserId">
|
<ElFormItem v-else label="产品经理" prop="managerUserId">
|
||||||
<ElSelect v-model="model.managerUserId" clearable filterable placeholder="请选择产品经理">
|
<BusinessUserSelect
|
||||||
<ElOption v-for="item in managerUserOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
v-model="model.managerUserId"
|
||||||
</ElSelect>
|
:options="managerUserOptions"
|
||||||
|
placeholder="请选择产品经理"
|
||||||
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
<ElCol :span="24">
|
<ElCol :span="24">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||||
import DictSelect from '@/components/custom/dict-select.vue';
|
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
|
||||||
|
|
||||||
defineOptions({ name: 'ProductSearch' });
|
defineOptions({ name: 'ProductSearch' });
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ interface Props {
|
|||||||
managerOptions: Api.SystemManage.UserSimple[];
|
managerOptions: Api.SystemManage.UserSimple[];
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'reset'): void;
|
(e: 'reset'): void;
|
||||||
@@ -20,6 +20,32 @@ const emit = defineEmits<Emits>();
|
|||||||
|
|
||||||
const model = defineModel<Api.Product.ProductSearchParams>('model', { required: true });
|
const model = defineModel<Api.Product.ProductSearchParams>('model', { required: true });
|
||||||
|
|
||||||
|
const fields = computed<SearchField[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'keyword',
|
||||||
|
label: '关键词',
|
||||||
|
type: 'input',
|
||||||
|
placeholder: '产品名称 / 编号'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'managerUserId',
|
||||||
|
label: '产品经理',
|
||||||
|
type: 'select',
|
||||||
|
options: props.managerOptions.map(item => ({
|
||||||
|
label: item.nickname,
|
||||||
|
value: item.id
|
||||||
|
})),
|
||||||
|
placeholder: '筛选产品经理'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'directionCode',
|
||||||
|
label: '产品方向',
|
||||||
|
type: 'dict',
|
||||||
|
dictCode: RDMS_OBJECT_DIRECTION_DICT_CODE,
|
||||||
|
placeholder: '筛选产品方向'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
emit('reset');
|
emit('reset');
|
||||||
}
|
}
|
||||||
@@ -30,30 +56,7 @@ function search() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableSearchPanel :model="model" :action-col-lg="6" @reset="reset" @search="search">
|
<TableSearchFields v-model="model" :fields="fields" :columns="3" @reset="reset" @search="search" />
|
||||||
<ElCol :lg="6" :md="12" :sm="12">
|
|
||||||
<ElFormItem label="关键词">
|
|
||||||
<ElInput v-model="model.keyword" clearable placeholder="产品名称 / 编号" />
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :lg="6" :md="12" :sm="12">
|
|
||||||
<ElFormItem label="产品经理">
|
|
||||||
<ElSelect v-model="model.managerUserId" clearable filterable placeholder="筛选产品经理">
|
|
||||||
<ElOption v-for="item in managerOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :lg="6" :md="12" :sm="12">
|
|
||||||
<ElFormItem label="产品方向">
|
|
||||||
<DictSelect
|
|
||||||
v-model="model.directionCode"
|
|
||||||
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
|
|
||||||
filterable
|
|
||||||
placeholder="筛选产品方向"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
</TableSearchPanel>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -423,7 +423,9 @@ watch(
|
|||||||
:member="selectedMember"
|
:member="selectedMember"
|
||||||
:current-manager="currentManager"
|
:current-manager="currentManager"
|
||||||
:role-options="roleOptions"
|
:role-options="roleOptions"
|
||||||
:user-options="userOptions"
|
:user-options="
|
||||||
|
userOptions.filter(user => !members.some(member => member.status === 0 && member.userId === user.id))
|
||||||
|
"
|
||||||
@submit="handleSubmitMemberOperate"
|
@submit="handleSubmitMemberOperate"
|
||||||
/>
|
/>
|
||||||
<MemberRemoveDialog
|
<MemberRemoveDialog
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ watch(
|
|||||||
<BusinessFormDialog
|
<BusinessFormDialog
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
title="编辑基础信息"
|
title="编辑基础信息"
|
||||||
preset="lg"
|
preset="sm"
|
||||||
:confirm-disabled="confirmDisabled"
|
:confirm-disabled="confirmDisabled"
|
||||||
@confirm="handleConfirm"
|
@confirm="handleConfirm"
|
||||||
>
|
>
|
||||||
@@ -103,12 +103,42 @@ watch(
|
|||||||
|
|
||||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||||
<ElRow :gutter="16">
|
<ElRow :gutter="16">
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem label="产品编码">
|
<ElFormItem label="产品编码">
|
||||||
<ElInput :model-value="baseInfo?.code || ''" readonly class="base-info-dialog__readonly-input" />
|
<ElInput :model-value="baseInfo?.code || ''" readonly class="base-info-dialog__readonly-input" />
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
|
<ElFormItem label="产品名称" prop="name">
|
||||||
|
<ElInput v-if="baseInfoEditable" v-model="model.name" maxlength="128" placeholder="请输入产品名称" />
|
||||||
|
<ElInput
|
||||||
|
v-else
|
||||||
|
:model-value="model.name"
|
||||||
|
readonly
|
||||||
|
class="base-info-dialog__readonly-input"
|
||||||
|
placeholder="未获取到产品名称"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="24">
|
||||||
|
<ElFormItem label="产品方向" prop="directionCode">
|
||||||
|
<DictSelect
|
||||||
|
v-if="baseInfoEditable"
|
||||||
|
v-model="model.directionCode"
|
||||||
|
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
|
||||||
|
filterable
|
||||||
|
placeholder="请选择产品方向"
|
||||||
|
/>
|
||||||
|
<ElInput
|
||||||
|
v-else
|
||||||
|
:model-value="directionDisplayName"
|
||||||
|
readonly
|
||||||
|
class="base-info-dialog__readonly-input"
|
||||||
|
placeholder="未获取到产品方向"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="24">
|
||||||
<ElFormItem>
|
<ElFormItem>
|
||||||
<template #label>
|
<template #label>
|
||||||
<span class="business-form-label-with-tip">
|
<span class="business-form-label-with-tip">
|
||||||
@@ -131,36 +161,6 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
<ElCol :span="12">
|
|
||||||
<ElFormItem label="产品名称" prop="name">
|
|
||||||
<ElInput v-if="baseInfoEditable" v-model="model.name" maxlength="128" placeholder="请输入产品名称" />
|
|
||||||
<ElInput
|
|
||||||
v-else
|
|
||||||
:model-value="model.name"
|
|
||||||
readonly
|
|
||||||
class="base-info-dialog__readonly-input"
|
|
||||||
placeholder="未获取到产品名称"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="12">
|
|
||||||
<ElFormItem label="产品方向" prop="directionCode">
|
|
||||||
<DictSelect
|
|
||||||
v-if="baseInfoEditable"
|
|
||||||
v-model="model.directionCode"
|
|
||||||
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
|
|
||||||
filterable
|
|
||||||
placeholder="请选择产品方向"
|
|
||||||
/>
|
|
||||||
<ElInput
|
|
||||||
v-else
|
|
||||||
:model-value="directionDisplayName"
|
|
||||||
readonly
|
|
||||||
class="base-info-dialog__readonly-input"
|
|
||||||
placeholder="未获取到产品方向"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :span="24">
|
<ElCol :span="24">
|
||||||
<ElFormItem label="产品描述">
|
<ElFormItem label="产品描述">
|
||||||
<ElInput
|
<ElInput
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, nextTick, reactive, watch } from 'vue';
|
|||||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||||
|
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
|
||||||
import { getPreviousManagerRoleOptions, shouldRequireManagerHandover } from '../shared';
|
import { getPreviousManagerRoleOptions, shouldRequireManagerHandover } from '../shared';
|
||||||
|
|
||||||
defineOptions({ name: 'MemberOperateDialog' });
|
defineOptions({ name: 'MemberOperateDialog' });
|
||||||
@@ -136,21 +137,24 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="lg" @confirm="handleConfirm">
|
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="sm" @confirm="handleConfirm">
|
||||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||||
<BusinessFormSection title="成员信息">
|
<BusinessFormSection title="成员信息">
|
||||||
<ElRow :gutter="16">
|
<ElRow :gutter="16">
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
|
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
|
||||||
<ElSelect v-model="model.userId" class="w-full" filterable placeholder="请选择成员用户">
|
<BusinessUserSelect v-model="model.userId" :options="userOptions" placeholder="请选择成员用户" />
|
||||||
<ElOption v-for="item in userOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem v-else label="成员用户">
|
<ElFormItem v-else label="成员用户">
|
||||||
<ElInput :model-value="member?.userNickname || userLabelMap.get(member?.userId || '') || ''" readonly />
|
<ElInput
|
||||||
|
:model-value="member?.userNickname || userLabelMap.get(member?.userId || '') || ''"
|
||||||
|
readonly
|
||||||
|
class="member-operate-dialog__readonly-input"
|
||||||
|
placeholder="未获取到成员用户"
|
||||||
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem label="目标角色" prop="roleId">
|
<ElFormItem label="目标角色" prop="roleId">
|
||||||
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
|
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
|
||||||
<ElOption v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" />
|
<ElOption v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
@@ -201,3 +205,22 @@ watch(
|
|||||||
</ElForm>
|
</ElForm>
|
||||||
</BusinessFormDialog>
|
</BusinessFormDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.member-operate-dialog__readonly-input .el-input__wrapper) {
|
||||||
|
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
|
||||||
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.member-operate-dialog__readonly-input .el-input__wrapper:hover),
|
||||||
|
:deep(.member-operate-dialog__readonly-input.is-focus .el-input__wrapper) {
|
||||||
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.member-operate-dialog__readonly-input .el-input__inner) {
|
||||||
|
color: rgb(51 65 85 / 96%);
|
||||||
|
cursor: default;
|
||||||
|
-webkit-text-fill-color: rgb(51 65 85 / 96%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
|
|||||||
:disabled="row.status !== 0 || row.managerFlag"
|
:disabled="row.status !== 0 || row.managerFlag"
|
||||||
@click="emit('edit', row)"
|
@click="emit('edit', row)"
|
||||||
>
|
>
|
||||||
调整角色
|
编辑
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-auth="{ code: 'project:product:update', source: 'object' }"
|
v-auth="{ code: 'project:product:update', source: 'object' }"
|
||||||
|
|||||||
406
src/views/project/list/index.vue
Normal file
406
src/views/project/list/index.vue
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
<script setup lang="tsx">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
import { ElButton, ElTag } from 'element-plus';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||||
|
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||||
|
import { fetchGetProjectOverviewSummary, fetchGetProjectPage, fetchGetUserSimpleList } from '@/service/api';
|
||||||
|
import { useDict } from '@/hooks/business/dict';
|
||||||
|
import { useRouterPush } from '@/hooks/common/router';
|
||||||
|
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||||
|
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
|
||||||
|
import { getProjectStatusLabel, getProjectStatusTagType, isProjectEditable } from '../shared/project-master-data';
|
||||||
|
import ProjectOperateDialog from './modules/project-operate-dialog.vue';
|
||||||
|
import ProjectOverviewCard from './modules/project-overview-card.vue';
|
||||||
|
import ProjectSearch from './modules/project-search.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectList' });
|
||||||
|
|
||||||
|
type ProjectPageResponse = Awaited<ReturnType<typeof fetchGetProjectPage>>;
|
||||||
|
|
||||||
|
const PROJECT_ENTRY_ROUTE_PATH = '/project/list';
|
||||||
|
|
||||||
|
function getInitSearchParams(): Api.Project.ProjectSearchParams {
|
||||||
|
return {
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
keyword: '',
|
||||||
|
directionCode: undefined,
|
||||||
|
projectType: undefined,
|
||||||
|
productId: undefined,
|
||||||
|
managerUserId: undefined,
|
||||||
|
statusCode: undefined,
|
||||||
|
updateTime: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformProjectPage(response: ProjectPageResponse, pageNo: number, pageSize: number) {
|
||||||
|
if (!response.error && response.data) {
|
||||||
|
return {
|
||||||
|
data: response.data.list,
|
||||||
|
pageNum: pageNo,
|
||||||
|
pageSize,
|
||||||
|
total: response.data.total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
pageNum: pageNo,
|
||||||
|
pageSize,
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortManagerOptions(list: Api.SystemManage.UserSimple[]) {
|
||||||
|
return list.slice().sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value?: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = reactive(getInitSearchParams());
|
||||||
|
const selectedStatus = ref<Api.Project.ProjectStatusCode>('active');
|
||||||
|
const managerFilterOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||||
|
const managerUserOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||||
|
const operateVisible = ref(false);
|
||||||
|
const editingRow = ref<Api.Project.Project | null>(null);
|
||||||
|
const { routerPush } = useRouterPush();
|
||||||
|
|
||||||
|
const { dictData: directionOptions, getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||||
|
const { getLabel: getProjectTypeLabel } = useDict(RDMS_PROJECT_TYPE_DICT_CODE);
|
||||||
|
|
||||||
|
const statusCounts = ref<Record<string, number>>({
|
||||||
|
pending: 0,
|
||||||
|
active: 0,
|
||||||
|
paused: 0,
|
||||||
|
completed: 0,
|
||||||
|
cancelled: 0,
|
||||||
|
archived: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusOptions = computed(() => [
|
||||||
|
{ value: 'pending', label: '待开始' },
|
||||||
|
{ value: 'active', label: '进行中' },
|
||||||
|
{ value: 'paused', label: '已暂停' },
|
||||||
|
{ value: 'completed', label: '已完成' },
|
||||||
|
{ value: 'cancelled', label: '作废项目' },
|
||||||
|
{ value: 'archived', label: '归档项目' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const managerLabelMap = computed(() => {
|
||||||
|
return new Map(managerUserOptions.value.map(item => [String(item.id), item.nickname]));
|
||||||
|
});
|
||||||
|
|
||||||
|
function getDirectionLabel(directionCode?: string | null) {
|
||||||
|
return getDirectionDictLabel(directionCode, '--');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectTypeLabelByCode(projectType?: string | null) {
|
||||||
|
return getProjectTypeLabel(projectType, '--');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getManagerLabel(managerUserId?: string | null) {
|
||||||
|
if (!managerUserId) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRequestParams(): Api.Project.ProjectSearchParams {
|
||||||
|
return {
|
||||||
|
...searchParams,
|
||||||
|
keyword: searchParams.keyword?.trim() || undefined,
|
||||||
|
statusCode: selectedStatus.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||||
|
ProjectPageResponse,
|
||||||
|
Api.Project.Project
|
||||||
|
>({
|
||||||
|
paginationProps: {
|
||||||
|
currentPage: searchParams.pageNo,
|
||||||
|
pageSize: searchParams.pageSize
|
||||||
|
},
|
||||||
|
api: () => fetchGetProjectPage(createRequestParams()),
|
||||||
|
transform: response => transformProjectPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||||
|
onPaginationParamsChange: params => {
|
||||||
|
searchParams.pageNo = params.currentPage ?? 1;
|
||||||
|
searchParams.pageSize = params.pageSize ?? 10;
|
||||||
|
},
|
||||||
|
columns: () => [
|
||||||
|
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||||
|
{
|
||||||
|
prop: 'projectName',
|
||||||
|
label: '项目名称',
|
||||||
|
minWidth: 220,
|
||||||
|
formatter: row => (
|
||||||
|
<ElButton link type="primary" class="project-name-link" onClick={() => enterProjectContext(row)}>
|
||||||
|
{row.projectName}
|
||||||
|
</ElButton>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ prop: 'projectCode', label: '项目编码', minWidth: 140, showOverflowTooltip: true },
|
||||||
|
{
|
||||||
|
prop: 'directionCode',
|
||||||
|
label: '项目方向',
|
||||||
|
minWidth: 140,
|
||||||
|
showOverflowTooltip: true,
|
||||||
|
formatter: row => getDirectionLabel(row.directionCode)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'projectType',
|
||||||
|
label: '项目类型',
|
||||||
|
minWidth: 140,
|
||||||
|
showOverflowTooltip: true,
|
||||||
|
formatter: row => getProjectTypeLabelByCode(row.projectType)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'managerUserId',
|
||||||
|
label: '项目经理',
|
||||||
|
minWidth: 120,
|
||||||
|
formatter: row => getManagerLabel(row.managerUserId)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'progressRate',
|
||||||
|
label: '进度',
|
||||||
|
width: 100,
|
||||||
|
align: 'center',
|
||||||
|
formatter: () => '--'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'statusCode',
|
||||||
|
label: '状态',
|
||||||
|
width: 100,
|
||||||
|
align: 'center',
|
||||||
|
formatter: row => (
|
||||||
|
<ElTag type={getProjectStatusTagType(row.statusCode)}>{getProjectStatusLabel(row.statusCode)}</ElTag>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'updateTime',
|
||||||
|
label: '最近更新',
|
||||||
|
width: 170,
|
||||||
|
align: 'center',
|
||||||
|
formatter: row => formatDateTime(row.updateTime)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prop: 'operate',
|
||||||
|
label: '操作',
|
||||||
|
width: 108,
|
||||||
|
align: 'center',
|
||||||
|
fixed: 'right',
|
||||||
|
formatter: row => (
|
||||||
|
<BusinessTableActionCell
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: 'edit',
|
||||||
|
label: '编辑',
|
||||||
|
buttonType: 'primary',
|
||||||
|
disabled: !isProjectEditable(row.statusCode),
|
||||||
|
onClick: () => openEdit(row)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadManagerOptions() {
|
||||||
|
const { error, data: userList } = await fetchGetUserSimpleList();
|
||||||
|
|
||||||
|
if (error || !userList) {
|
||||||
|
managerUserOptions.value = [];
|
||||||
|
managerFilterOptions.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userSimpleList = sortManagerOptions(userList);
|
||||||
|
managerUserOptions.value = userSimpleList;
|
||||||
|
managerFilterOptions.value = userSimpleList;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOverviewData() {
|
||||||
|
const { error, data: overviewSummary } = await fetchGetProjectOverviewSummary();
|
||||||
|
|
||||||
|
if (error || !overviewSummary) {
|
||||||
|
statusCounts.value = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCounts.value = overviewSummary.statusCounts || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadProjectTable(page = searchParams.pageNo ?? 1) {
|
||||||
|
await getDataByPage(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPageData(page = searchParams.pageNo ?? 1) {
|
||||||
|
await Promise.all([loadManagerOptions(), loadOverviewData(), reloadProjectTable(page)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch() {
|
||||||
|
await reloadProjectTable(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetSearch() {
|
||||||
|
const pageSize = searchParams.pageSize ?? 10;
|
||||||
|
|
||||||
|
Object.assign(searchParams, getInitSearchParams(), {
|
||||||
|
pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
await reloadProjectTable(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusChange(status: Api.Project.ProjectStatusCode) {
|
||||||
|
selectedStatus.value = status;
|
||||||
|
await reloadProjectTable(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingRow.value = null;
|
||||||
|
operateVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: Api.Project.Project) {
|
||||||
|
editingRow.value = row;
|
||||||
|
operateVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterProjectContext(row: Api.Project.Project) {
|
||||||
|
await routerPush({
|
||||||
|
path: PROJECT_ENTRY_ROUTE_PATH,
|
||||||
|
query: {
|
||||||
|
[OBJECT_CONTEXT_QUERY_KEY]: row.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProjectSubmitted(projectId?: string) {
|
||||||
|
const isEditing = Boolean(projectId && editingRow.value?.id === projectId);
|
||||||
|
|
||||||
|
await refreshPageData(isEditing ? (searchParams.pageNo ?? 1) : 1);
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
editingRow.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await refreshPageData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[396px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||||
|
>
|
||||||
|
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||||
|
<ProjectOverviewCard
|
||||||
|
:status-counts="statusCounts"
|
||||||
|
:direction-count="directionOptions.length"
|
||||||
|
:selected-status="selectedStatus"
|
||||||
|
@status-change="handleStatusChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||||
|
<ProjectSearch
|
||||||
|
v-model:model="searchParams"
|
||||||
|
:manager-options="managerFilterOptions"
|
||||||
|
@reset="handleResetSearch"
|
||||||
|
@search="handleSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="business-table-card-body">
|
||||||
|
<template #header>
|
||||||
|
<div class="project-card-header">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-8px">
|
||||||
|
<p class="truncate text-16px font-600">项目列表</p>
|
||||||
|
<ElTag effect="plain" :type="getProjectStatusTagType(selectedStatus)">
|
||||||
|
{{
|
||||||
|
statusOptions.find(item => item.value === selectedStatus)?.label ||
|
||||||
|
getProjectStatusLabel(selectedStatus)
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TableHeaderOperation
|
||||||
|
v-model:columns="columnChecks"
|
||||||
|
:disabled-delete="true"
|
||||||
|
:loading="loading"
|
||||||
|
@refresh="refreshPageData"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<ElButton plain type="primary" @click="openCreate">
|
||||||
|
<template #icon>
|
||||||
|
<icon-ic-round-plus class="text-icon" />
|
||||||
|
</template>
|
||||||
|
新建
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</TableHeaderOperation>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
|
||||||
|
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<ElEmpty description="当前筛选条件下暂无项目" />
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProjectOperateDialog
|
||||||
|
v-model:visible="operateVisible"
|
||||||
|
:manager-user-options="managerUserOptions"
|
||||||
|
:row-data="editingRow"
|
||||||
|
@submitted="handleProjectSubmitted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.project-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-name-link {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1280px) {
|
||||||
|
.project-card-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
511
src/views/project/list/modules/project-operate-dialog.vue
Normal file
511
src/views/project/list/modules/project-operate-dialog.vue
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
<script setup lang="tsx">
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import { ElCol, ElDatePicker, ElFormItem, ElInput, ElOption, ElRow, ElSelect } from 'element-plus';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||||
|
import { fetchCreateProject, fetchGetProductPage, fetchUpdateProject } 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 BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||||
|
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
|
||||||
|
import DictSelect from '@/components/custom/dict-select.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectOperateDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
managerUserOptions: Api.SystemManage.UserSimple[];
|
||||||
|
rowData?: Api.Project.Project | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submitted', projectId?: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Model {
|
||||||
|
projectCode: string;
|
||||||
|
projectName: string;
|
||||||
|
directionCode: string;
|
||||||
|
projectType: string;
|
||||||
|
productId: string | null;
|
||||||
|
managerUserId: string | null;
|
||||||
|
plannedStartDate: string | null;
|
||||||
|
plannedEndDate: string | null;
|
||||||
|
projectDesc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { formRef, validate } = useForm();
|
||||||
|
const { createRequiredRule } = useFormRules();
|
||||||
|
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||||
|
|
||||||
|
const isEditMode = computed(() => Boolean(props.rowData?.id));
|
||||||
|
const dialogTitle = computed(() => (isEditMode.value ? '编辑项目' : '新增项目'));
|
||||||
|
const submitting = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const model = ref<Model>(createDefaultModel());
|
||||||
|
|
||||||
|
// 产品选项,包含 ID、名称、方向
|
||||||
|
interface ProductOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
directionCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productOptions = ref<ProductOption[]>([]);
|
||||||
|
|
||||||
|
const managerLabelMap = computed(() => new Map(props.managerUserOptions.map(item => [String(item.id), item.nickname])));
|
||||||
|
const managerDisplayName = computed(() => {
|
||||||
|
const managerUserId = model.value.managerUserId;
|
||||||
|
if (!managerUserId) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 当前选中产品的方向
|
||||||
|
const selectedProductDirection = computed(() => {
|
||||||
|
if (!model.value.productId) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const product = productOptions.value.find(p => p.id === model.value.productId);
|
||||||
|
return product?.directionCode || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 判断是否关联了产品(创建/编辑模式都适用)
|
||||||
|
const hasAssociatedProduct = computed(() => Boolean(model.value.productId));
|
||||||
|
|
||||||
|
// 方向字段是否只读:关联了产品时只读,未关联时可编辑
|
||||||
|
const directionReadonly = computed(() => hasAssociatedProduct.value);
|
||||||
|
|
||||||
|
// 当前生效的方向:关联产品则用产品方向,否则用用户选择的方向
|
||||||
|
const effectiveDirectionCode = computed({
|
||||||
|
get: () => {
|
||||||
|
if (hasAssociatedProduct.value) {
|
||||||
|
// 编辑/创建模式下,关联产品时使用产品方向
|
||||||
|
return selectedProductDirection.value || model.value.directionCode;
|
||||||
|
}
|
||||||
|
return model.value.directionCode;
|
||||||
|
},
|
||||||
|
set: (val: string) => {
|
||||||
|
if (!hasAssociatedProduct.value) {
|
||||||
|
model.value.directionCode = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const directionDisplayName = computed(() => {
|
||||||
|
const directionCode = effectiveDirectionCode.value;
|
||||||
|
|
||||||
|
if (!directionCode) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDirectionLabel(directionCode, directionCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
function parsePlannedDate(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlannedDateRangeValid(startDate: string | null, endDate: string | null) {
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !dayjs(endDate).isBefore(dayjs(startDate), 'day');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEndDateShortcut(text: string, mutator: (date: Date) => void) {
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
value: () => {
|
||||||
|
let startDate = parsePlannedDate(model.value.plannedStartDate);
|
||||||
|
|
||||||
|
if (!startDate) {
|
||||||
|
startDate = new Date();
|
||||||
|
model.value.plannedStartDate = dayjs(startDate).format('YYYY-MM-DD');
|
||||||
|
window.$message?.info('未选择计划开始日期,已按今日为基准计算');
|
||||||
|
nextTick(() => formRef.value?.clearValidate('plannedStartDate'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDate = new Date(startDate.getTime());
|
||||||
|
mutator(endDate);
|
||||||
|
return endDate;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const plannedEndDateShortcuts = [
|
||||||
|
buildEndDateShortcut('一星期', date => date.setDate(date.getDate() + 7)),
|
||||||
|
buildEndDateShortcut('两星期', date => date.setDate(date.getDate() + 14)),
|
||||||
|
buildEndDateShortcut('一个月', date => date.setMonth(date.getMonth() + 1)),
|
||||||
|
buildEndDateShortcut('三个月', date => date.setMonth(date.getMonth() + 3)),
|
||||||
|
buildEndDateShortcut('半年', date => date.setMonth(date.getMonth() + 6)),
|
||||||
|
buildEndDateShortcut('一年', date => date.setFullYear(date.getFullYear() + 1))
|
||||||
|
];
|
||||||
|
|
||||||
|
// 产品下拉的标签,显示产品名称 + 方向
|
||||||
|
const productOptionLabel = (item: ProductOption) => {
|
||||||
|
return `${item.name}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
projectName: [createRequiredRule('请输入项目名称')],
|
||||||
|
directionCode: [createRequiredRule('请选择项目方向')],
|
||||||
|
projectType: [createRequiredRule('请选择项目类型')],
|
||||||
|
plannedStartDate: [createRequiredRule('请选择计划开始日期')],
|
||||||
|
plannedEndDate: [
|
||||||
|
createRequiredRule('请选择计划结束日期'),
|
||||||
|
{
|
||||||
|
validator: (_rule, value: string | null, callback) => {
|
||||||
|
if (!isPlannedDateRangeValid(model.value.plannedStartDate, value)) {
|
||||||
|
callback(new Error('计划结束日期不能早于计划开始日期'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
trigger: 'change'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} satisfies Record<string, App.Global.FormRule[]>;
|
||||||
|
|
||||||
|
function createDefaultModel(): Model {
|
||||||
|
return {
|
||||||
|
projectCode: '',
|
||||||
|
projectName: '',
|
||||||
|
directionCode: '',
|
||||||
|
projectType: '',
|
||||||
|
productId: null,
|
||||||
|
managerUserId: null,
|
||||||
|
plannedStartDate: null,
|
||||||
|
plannedEndDate: null,
|
||||||
|
projectDesc: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNullableText(value?: string | null) {
|
||||||
|
return value?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProductOptions() {
|
||||||
|
const { error, data } = await fetchGetProductPage({ pageNo: 1, pageSize: 200 });
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
productOptions.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
productOptions.value = data.list.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name || item.code || item.id,
|
||||||
|
directionCode: item.directionCode || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听产品选择变化,联动方向(创建模式)
|
||||||
|
watch(
|
||||||
|
() => model.value.productId,
|
||||||
|
(newProductId, oldProductId) => {
|
||||||
|
if (isEditMode.value) {
|
||||||
|
return; // 编辑模式下不处理,产品字段只读
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newProductId && newProductId !== oldProductId) {
|
||||||
|
// 选择了产品,自动填充方向
|
||||||
|
const product = productOptions.value.find(p => p.id === newProductId);
|
||||||
|
if (product) {
|
||||||
|
model.value.directionCode = product.directionCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 取消选择产品时,directionCode 保留,用户可重新选择
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
await validate();
|
||||||
|
|
||||||
|
// 提交时,如果关联了产品,使用产品方向
|
||||||
|
const finalDirectionCode = hasAssociatedProduct.value
|
||||||
|
? selectedProductDirection.value || model.value.directionCode
|
||||||
|
: model.value.directionCode;
|
||||||
|
|
||||||
|
const payload: Api.Project.SaveProjectParams = {
|
||||||
|
projectCode: getNullableText(model.value.projectCode),
|
||||||
|
projectName: model.value.projectName.trim(),
|
||||||
|
directionCode: finalDirectionCode,
|
||||||
|
projectType: model.value.projectType,
|
||||||
|
productId: model.value.productId,
|
||||||
|
managerUserId: model.value.managerUserId || '',
|
||||||
|
plannedStartDate: model.value.plannedStartDate,
|
||||||
|
plannedEndDate: model.value.plannedEndDate,
|
||||||
|
actualStartDate: isEditMode.value ? props.rowData?.actualStartDate || null : undefined,
|
||||||
|
actualEndDate: isEditMode.value ? props.rowData?.actualEndDate || null : undefined,
|
||||||
|
projectDesc: getNullableText(model.value.projectDesc)
|
||||||
|
};
|
||||||
|
|
||||||
|
submitting.value = true;
|
||||||
|
|
||||||
|
if (isEditMode.value && props.rowData?.id) {
|
||||||
|
const updateParams: Api.Project.UpdateProjectParams = {
|
||||||
|
id: props.rowData.id,
|
||||||
|
...payload
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await fetchUpdateProject(updateParams);
|
||||||
|
|
||||||
|
submitting.value = false;
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success('项目编辑成功');
|
||||||
|
closeDialog();
|
||||||
|
emit('submitted', props.rowData.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetchCreateProject(payload);
|
||||||
|
|
||||||
|
submitting.value = false;
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success('项目新增成功');
|
||||||
|
closeDialog();
|
||||||
|
emit('submitted', result.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(visible, async value => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadProductOptions();
|
||||||
|
|
||||||
|
if (!isEditMode.value || !props.rowData?.id) {
|
||||||
|
model.value = createDefaultModel();
|
||||||
|
await nextTick();
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.value = {
|
||||||
|
projectCode: props.rowData.projectCode || '',
|
||||||
|
projectName: props.rowData.projectName || '',
|
||||||
|
directionCode: props.rowData.directionCode || '',
|
||||||
|
projectType: props.rowData.projectType || '',
|
||||||
|
productId: props.rowData.productId,
|
||||||
|
managerUserId: props.rowData.managerUserId || null,
|
||||||
|
plannedStartDate: props.rowData.plannedStartDate,
|
||||||
|
plannedEndDate: props.rowData.plannedEndDate,
|
||||||
|
projectDesc: props.rowData.projectDesc || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="dialogTitle"
|
||||||
|
preset="md"
|
||||||
|
:loading="loading"
|
||||||
|
:confirm-loading="submitting"
|
||||||
|
@confirm="handleSubmit"
|
||||||
|
>
|
||||||
|
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||||
|
<BusinessFormSection title="项目信息">
|
||||||
|
<ElRow :gutter="16">
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem v-if="isEditMode" label="项目编码" prop="projectCode">
|
||||||
|
<ElInput
|
||||||
|
:model-value="model.projectCode"
|
||||||
|
readonly
|
||||||
|
class="project-operate-dialog__readonly-input"
|
||||||
|
placeholder="未获取到项目编码"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem v-else label="项目编码" prop="projectCode">
|
||||||
|
<ElInput v-model="model.projectCode" clearable placeholder="不填则由后端自动生成" />
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="项目名称" prop="projectName">
|
||||||
|
<ElInput v-model="model.projectName" clearable maxlength="128" placeholder="请输入项目名称" />
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="项目方向" prop="directionCode">
|
||||||
|
<DictSelect
|
||||||
|
v-if="!directionReadonly"
|
||||||
|
v-model="effectiveDirectionCode"
|
||||||
|
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
|
||||||
|
filterable
|
||||||
|
placeholder="请选择项目方向"
|
||||||
|
/>
|
||||||
|
<ElInput
|
||||||
|
v-else
|
||||||
|
:model-value="directionDisplayName"
|
||||||
|
readonly
|
||||||
|
class="project-operate-dialog__readonly-input"
|
||||||
|
placeholder="未获取到项目方向"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="项目类型" prop="projectType">
|
||||||
|
<DictSelect
|
||||||
|
v-model="model.projectType"
|
||||||
|
:dict-code="RDMS_PROJECT_TYPE_DICT_CODE"
|
||||||
|
filterable
|
||||||
|
placeholder="请选择项目类型"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem v-if="isEditMode" label="所属产品">
|
||||||
|
<ElInput
|
||||||
|
:model-value="
|
||||||
|
productOptions.find(p => p.id === model.productId)?.name ||
|
||||||
|
props.rowData?.productName ||
|
||||||
|
model.productId ||
|
||||||
|
'未关联产品'
|
||||||
|
"
|
||||||
|
readonly
|
||||||
|
class="project-operate-dialog__readonly-input"
|
||||||
|
placeholder="未关联产品"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem v-else label="所属产品" prop="productId">
|
||||||
|
<ElSelect
|
||||||
|
v-model="model.productId"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="选择所属产品(可选),选择后将锁定项目方向"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="item in productOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="productOptionLabel(item)"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem v-if="isEditMode">
|
||||||
|
<template #label>
|
||||||
|
<span class="business-form-label-with-tip">
|
||||||
|
<ElTooltip
|
||||||
|
content="如需调整项目经理,请到项目内的团队管理处处理。"
|
||||||
|
popper-class="business-form-label-tooltip"
|
||||||
|
placement="top-start"
|
||||||
|
>
|
||||||
|
<span class="business-form-label-tip">
|
||||||
|
<icon-fe:question />
|
||||||
|
</span>
|
||||||
|
</ElTooltip>
|
||||||
|
<span>项目经理</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ElInput
|
||||||
|
:model-value="managerDisplayName"
|
||||||
|
readonly
|
||||||
|
class="project-operate-dialog__readonly-input"
|
||||||
|
placeholder="未获取到项目经理"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem v-else label="项目经理" prop="managerUserId">
|
||||||
|
<BusinessUserSelect
|
||||||
|
v-model="model.managerUserId"
|
||||||
|
:options="managerUserOptions"
|
||||||
|
placeholder="请选择项目经理"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="计划开始日期" prop="plannedStartDate">
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="model.plannedStartDate"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="选择开始日期"
|
||||||
|
class="project-operate-dialog__date-picker"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="计划结束日期" prop="plannedEndDate">
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="model.plannedEndDate"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="选择结束日期"
|
||||||
|
:shortcuts="plannedEndDateShortcuts"
|
||||||
|
class="project-operate-dialog__date-picker"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="24">
|
||||||
|
<ElFormItem label="项目说明" prop="projectDesc">
|
||||||
|
<ElInput
|
||||||
|
v-model="model.projectDesc"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入项目说明"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
</ElRow>
|
||||||
|
</BusinessFormSection>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.project-operate-dialog__readonly-input .el-input__wrapper) {
|
||||||
|
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
|
||||||
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.project-operate-dialog__readonly-input .el-input__wrapper:hover),
|
||||||
|
:deep(.project-operate-dialog__readonly-input.is-focus .el-input__wrapper) {
|
||||||
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.project-operate-dialog__readonly-input .el-input__inner) {
|
||||||
|
color: rgb(51 65 85 / 96%);
|
||||||
|
cursor: default;
|
||||||
|
-webkit-text-fill-color: rgb(51 65 85 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.project-operate-dialog__date-picker.el-date-editor.el-input) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
302
src/views/project/list/modules/project-overview-card.vue
Normal file
302
src/views/project/list/modules/project-overview-card.vue
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { Component } from 'vue';
|
||||||
|
import { CircleCheckFilled, DeleteFilled, DocumentAdd, FolderOpened, VideoPause } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectOverviewCard' });
|
||||||
|
|
||||||
|
interface StatusNavMeta {
|
||||||
|
key: Api.Project.ProjectStatusCode;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
tone: 'teal' | 'slate' | 'amber' | 'rose' | 'indigo';
|
||||||
|
icon: Component;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
statusCounts: Record<string, number>;
|
||||||
|
directionCount: number;
|
||||||
|
selectedStatus: Api.Project.ProjectStatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'status-change', status: Api.Project.ProjectStatusCode): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const statusNavMetas: StatusNavMeta[] = [
|
||||||
|
{
|
||||||
|
key: 'pending',
|
||||||
|
label: '待开始',
|
||||||
|
description: '项目已创建,等待启动',
|
||||||
|
tone: 'indigo',
|
||||||
|
icon: DocumentAdd
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'active',
|
||||||
|
label: '进行中',
|
||||||
|
description: '正在执行的项目',
|
||||||
|
tone: 'teal',
|
||||||
|
icon: CircleCheckFilled
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'paused',
|
||||||
|
label: '已暂停',
|
||||||
|
description: '暂时停止推进的项目',
|
||||||
|
tone: 'amber',
|
||||||
|
icon: VideoPause
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'completed',
|
||||||
|
label: '已完成',
|
||||||
|
description: '达成目标的项目',
|
||||||
|
tone: 'teal',
|
||||||
|
icon: FolderOpened
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cancelled',
|
||||||
|
label: '作废项目',
|
||||||
|
description: '已终止或取消推进的项目',
|
||||||
|
tone: 'rose',
|
||||||
|
icon: DeleteFilled
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'archived',
|
||||||
|
label: '归档项目',
|
||||||
|
description: '已收口归档的历史项目',
|
||||||
|
tone: 'slate',
|
||||||
|
icon: FolderOpened
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusItems = computed(() =>
|
||||||
|
statusNavMetas.map(item => ({
|
||||||
|
...item,
|
||||||
|
count: props.statusCounts[item.key] ?? 0
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const overviewMetrics = computed(() => [
|
||||||
|
{
|
||||||
|
label: '总项目数',
|
||||||
|
value: Object.values(props.statusCounts).reduce((sum, count) => sum + count, 0),
|
||||||
|
hint: '当前接口可查询到的项目总量'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '进行中',
|
||||||
|
value: props.statusCounts.active ?? 0,
|
||||||
|
hint: '正在执行的项目'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '待开始',
|
||||||
|
value: props.statusCounts.pending ?? 0,
|
||||||
|
hint: '等待启动的项目'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '作废项目',
|
||||||
|
value: props.statusCounts.cancelled ?? 0,
|
||||||
|
hint: '已终止或取消推进的项目'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
function handleStatusClick(status: Api.Project.ProjectStatusCode) {
|
||||||
|
emit('status-change', status);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElCard class="project-overview-card card-wrapper">
|
||||||
|
<div class="project-overview-card__stats">
|
||||||
|
<div v-for="item in overviewMetrics" :key="item.label" class="project-overview-card__stat">
|
||||||
|
<span class="project-overview-card__stat-label">{{ item.label }}</span>
|
||||||
|
<strong class="project-overview-card__stat-value">{{ item.value }}</strong>
|
||||||
|
<small class="project-overview-card__stat-hint">{{ item.hint }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-status-panel__list">
|
||||||
|
<button
|
||||||
|
v-for="item in statusItems"
|
||||||
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
|
class="project-status-item"
|
||||||
|
:class="[`project-status-item--${item.tone}`, { 'is-active': selectedStatus === item.key }]"
|
||||||
|
:aria-pressed="selectedStatus === item.key"
|
||||||
|
@click="handleStatusClick(item.key)"
|
||||||
|
>
|
||||||
|
<div class="project-status-item__icon">
|
||||||
|
<ElIcon>
|
||||||
|
<component :is="item.icon" />
|
||||||
|
</ElIcon>
|
||||||
|
</div>
|
||||||
|
<div class="project-status-item__main">
|
||||||
|
<div class="project-status-item__top">
|
||||||
|
<strong>{{ item.label }}</strong>
|
||||||
|
<em>{{ item.count }}</em>
|
||||||
|
</div>
|
||||||
|
<p class="project-status-item__desc">{{ item.description }}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.project-overview-card {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgb(226 232 240 / 92%);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgb(14 165 233 / 8%), transparent 36%),
|
||||||
|
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-overview-card__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-overview-card__stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 88%);
|
||||||
|
border-radius: 18px;
|
||||||
|
background-color: rgb(255 255 255 / 84%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-overview-card__stat-label {
|
||||||
|
color: rgb(100 116 139 / 90%);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-overview-card__stat-value {
|
||||||
|
color: rgb(15 23 42 / 94%);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-overview-card__stat-hint {
|
||||||
|
color: rgb(100 116 139 / 90%);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-status-panel__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-status-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 90%);
|
||||||
|
border-radius: 18px;
|
||||||
|
background-color: rgb(255 255 255 / 86%);
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
border-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-status-item:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgb(148 163 184 / 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-status-item.is-active {
|
||||||
|
border-color: rgb(14 165 233 / 40%);
|
||||||
|
box-shadow: 0 10px 24px rgb(14 165 233 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-status-item__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-status-item__main {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-status-item__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-status-item__top strong {
|
||||||
|
color: rgb(15 23 42 / 94%);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-status-item__top em {
|
||||||
|
color: rgb(15 23 42 / 88%);
|
||||||
|
font-size: 18px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-status-item__desc {
|
||||||
|
color: rgb(100 116 139 / 94%);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-status-item--teal .project-status-item__icon {
|
||||||
|
background-color: rgb(240 253 250 / 96%);
|
||||||
|
color: rgb(15 118 110 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-status-item--slate .project-status-item__icon {
|
||||||
|
background-color: rgb(241 245 249 / 96%);
|
||||||
|
color: rgb(51 65 85 / 92%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-status-item--amber .project-status-item__icon {
|
||||||
|
background-color: rgb(255 251 235 / 96%);
|
||||||
|
color: rgb(217 119 6 / 92%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-status-item--rose .project-status-item__icon {
|
||||||
|
background-color: rgb(255 241 242 / 96%);
|
||||||
|
color: rgb(225 29 72 / 92%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-status-item--indigo .project-status-item__icon {
|
||||||
|
background-color: rgb(238 242 255 / 96%);
|
||||||
|
color: rgb(79 70 229 / 92%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1280px) {
|
||||||
|
.project-status-item__top {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 640px) {
|
||||||
|
.project-overview-card__stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
69
src/views/project/list/modules/project-search.vue
Normal file
69
src/views/project/list/modules/project-search.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||||
|
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectSearch' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
managerOptions: Api.SystemManage.UserSimple[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'reset'): void;
|
||||||
|
(e: 'search'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const model = defineModel<Api.Project.ProjectSearchParams>('model', { required: true });
|
||||||
|
|
||||||
|
const fields = computed<SearchField[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'keyword',
|
||||||
|
label: '关键词',
|
||||||
|
type: 'input',
|
||||||
|
placeholder: '项目编码 / 名称'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'directionCode',
|
||||||
|
label: '项目方向',
|
||||||
|
type: 'dict',
|
||||||
|
dictCode: RDMS_OBJECT_DIRECTION_DICT_CODE,
|
||||||
|
placeholder: '筛选项目方向'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'projectType',
|
||||||
|
label: '项目类型',
|
||||||
|
type: 'dict',
|
||||||
|
dictCode: RDMS_PROJECT_TYPE_DICT_CODE,
|
||||||
|
placeholder: '筛选项目类型'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'managerUserId',
|
||||||
|
label: '项目经理',
|
||||||
|
type: 'select',
|
||||||
|
options: props.managerOptions.map(item => ({
|
||||||
|
label: item.nickname,
|
||||||
|
value: item.id
|
||||||
|
})),
|
||||||
|
placeholder: '筛选项目经理'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
emit('reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
emit('search');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TableSearchFields v-model="model" :fields="fields" :columns="3" @reset="reset" @search="search" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
475
src/views/project/project/execution/index.vue
Normal file
475
src/views/project/project/execution/index.vue
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
fetchChangeProjectExecutionOwner,
|
||||||
|
fetchChangeProjectExecutionStatus,
|
||||||
|
fetchCreateProjectExecution,
|
||||||
|
fetchCreateProjectExecutionMember,
|
||||||
|
fetchGetProjectExecution,
|
||||||
|
fetchGetProjectExecutionMembers,
|
||||||
|
fetchGetProjectExecutionPage,
|
||||||
|
fetchGetProjectExecutionStatusBoard,
|
||||||
|
fetchGetProjectMembers,
|
||||||
|
fetchInactiveProjectExecutionMember,
|
||||||
|
fetchUpdateProjectExecution
|
||||||
|
} from '@/service/api';
|
||||||
|
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||||
|
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||||
|
import { useCurrentProject } from '../../shared/use-current-project';
|
||||||
|
import ExecutionListPanel from './modules/execution-list-panel.vue';
|
||||||
|
import ExecutionMemberDialog from './modules/execution-member-dialog.vue';
|
||||||
|
import ExecutionOperateDialog from './modules/execution-operate-dialog.vue';
|
||||||
|
import StatusActionDialog from './modules/status-action-dialog.vue';
|
||||||
|
import TaskWorkspace from './modules/task-workspace.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectExecution' });
|
||||||
|
|
||||||
|
type ExecutionPageResponse = Awaited<ReturnType<typeof fetchGetProjectExecutionPage>>;
|
||||||
|
type ExecutionAction = Api.Project.LifecycleAction<Api.Project.ProjectExecutionActionCode>;
|
||||||
|
type ExecutionStatusFilter = string | null;
|
||||||
|
|
||||||
|
function getInitExecutionSearchParams(): Api.Project.ProjectExecutionSearchParams {
|
||||||
|
return {
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
keyword: '',
|
||||||
|
executionType: undefined,
|
||||||
|
ownerId: undefined,
|
||||||
|
statusCode: undefined,
|
||||||
|
updateTime: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformExecutionPage(response: ExecutionPageResponse, pageNo: number, pageSize: number) {
|
||||||
|
if (!response.error && response.data) {
|
||||||
|
return {
|
||||||
|
data: response.data.list,
|
||||||
|
pageNum: pageNo,
|
||||||
|
pageSize,
|
||||||
|
total: response.data.total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
pageNum: pageNo,
|
||||||
|
pageSize,
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentObjectId } = useCurrentProject();
|
||||||
|
const objectContextStore = useObjectContextStore();
|
||||||
|
|
||||||
|
const searchParams = reactive(getInitExecutionSearchParams());
|
||||||
|
const DEFAULT_EXECUTION_STATUS: ExecutionStatusFilter = 'active';
|
||||||
|
const selectedStatus = ref<ExecutionStatusFilter>(DEFAULT_EXECUTION_STATUS);
|
||||||
|
const selectedExecution = ref<Api.Project.ProjectExecution | null>(null);
|
||||||
|
const projectMembers = ref<Api.Project.ProjectMember[]>([]);
|
||||||
|
const projectMemberOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||||
|
const operateVisible = ref(false);
|
||||||
|
const operateMode = ref<'create' | 'edit' | 'view'>('create');
|
||||||
|
const memberVisible = ref(false);
|
||||||
|
const statusVisible = ref(false);
|
||||||
|
const editingExecution = ref<Api.Project.ProjectExecution | null>(null);
|
||||||
|
const editingExecutionMembers = ref<Api.Project.ExecutionMember[]>([]);
|
||||||
|
const statusExecution = ref<Api.Project.ProjectExecution | null>(null);
|
||||||
|
const statusAction = ref<ExecutionAction | null>(null);
|
||||||
|
const executionMembers = ref<Api.Project.ExecutionMember[]>([]);
|
||||||
|
const memberLoading = ref(false);
|
||||||
|
const executionStatusBoard = ref<Api.Project.StatusBoard | null>(null);
|
||||||
|
|
||||||
|
const projectId = computed(() => currentObjectId.value || '');
|
||||||
|
|
||||||
|
const statusActionTitle = computed(() =>
|
||||||
|
statusAction.value ? `执行状态变更:${statusAction.value.actionName}` : '执行状态变更'
|
||||||
|
);
|
||||||
|
const buttonCodeSet = computed(() => new Set(objectContextStore.buttonCodes));
|
||||||
|
const canCreateExecution = computed(() => buttonCodeSet.value.has('project:execution:create'));
|
||||||
|
const canUpdateExecution = computed(() => buttonCodeSet.value.has('project:execution:update'));
|
||||||
|
const canChangeExecutionOwner = computed(() => buttonCodeSet.value.has('project:execution:owner'));
|
||||||
|
const canManageExecutionMember = computed(() => buttonCodeSet.value.has('project:execution:member'));
|
||||||
|
const canChangeExecutionStatus = computed(() => buttonCodeSet.value.has('project:execution:status'));
|
||||||
|
const canDeleteExecution = computed(() => buttonCodeSet.value.has('project:execution:delete'));
|
||||||
|
const canCreateTask = computed(() => buttonCodeSet.value.has('project:task:create'));
|
||||||
|
const canUpdateTask = computed(() => buttonCodeSet.value.has('project:task:update'));
|
||||||
|
const canChangeTaskStatus = computed(() => buttonCodeSet.value.has('project:task:status'));
|
||||||
|
|
||||||
|
function createRequestParams(): Api.Project.ProjectExecutionSearchParams {
|
||||||
|
return {
|
||||||
|
...searchParams,
|
||||||
|
keyword: searchParams.keyword?.trim() || undefined,
|
||||||
|
statusCode: selectedStatus.value || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStatusBoardParams(): Api.Project.ProjectExecutionStatusBoardParams {
|
||||||
|
return {
|
||||||
|
keyword: searchParams.keyword?.trim() || undefined,
|
||||||
|
executionType: searchParams.executionType,
|
||||||
|
ownerId: searchParams.ownerId,
|
||||||
|
updateTime: searchParams.updateTime
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||||
|
ExecutionPageResponse,
|
||||||
|
Api.Project.ProjectExecution
|
||||||
|
>({
|
||||||
|
paginationProps: {
|
||||||
|
currentPage: searchParams.pageNo,
|
||||||
|
pageSize: searchParams.pageSize
|
||||||
|
},
|
||||||
|
api: () => {
|
||||||
|
if (!projectId.value) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { total: 0, list: [] },
|
||||||
|
error: null
|
||||||
|
} as unknown as ExecutionPageResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchGetProjectExecutionPage(projectId.value, createRequestParams());
|
||||||
|
},
|
||||||
|
transform: response => transformExecutionPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||||
|
onPaginationParamsChange: params => {
|
||||||
|
searchParams.pageNo = params.currentPage ?? 1;
|
||||||
|
searchParams.pageSize = params.pageSize ?? 10;
|
||||||
|
},
|
||||||
|
columns: () => [{ prop: 'executionName', label: '执行名称', minWidth: 160 }],
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
function syncSelectedExecution() {
|
||||||
|
if (!data.value.length) {
|
||||||
|
selectedExecution.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedExecution.value = data.value.find(item => item.id === selectedExecution.value?.id) || data.value[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjectMemberOptions() {
|
||||||
|
if (!projectId.value) {
|
||||||
|
projectMemberOptions.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error, data: members } = await fetchGetProjectMembers(projectId.value);
|
||||||
|
|
||||||
|
if (error || !members) {
|
||||||
|
projectMembers.value = [];
|
||||||
|
projectMemberOptions.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
projectMembers.value = members.filter(item => item.status === 0);
|
||||||
|
projectMemberOptions.value = projectMembers.value.map(item => ({
|
||||||
|
id: item.userId,
|
||||||
|
nickname: item.userNickname || item.userId,
|
||||||
|
username: null,
|
||||||
|
deptName: item.roleName || item.roleCode || null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadExecutionData(page = searchParams.pageNo ?? 1) {
|
||||||
|
await getDataByPage(page);
|
||||||
|
syncSelectedExecution();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExecutionStatusBoard() {
|
||||||
|
if (!projectId.value) {
|
||||||
|
executionStatusBoard.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error, data: board } = await fetchGetProjectExecutionStatusBoard(projectId.value, createStatusBoardParams());
|
||||||
|
|
||||||
|
executionStatusBoard.value = error || !board ? null : board;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPageData() {
|
||||||
|
await Promise.all([loadProjectMemberOptions(), reloadExecutionData(), loadExecutionStatusBoard()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch() {
|
||||||
|
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset() {
|
||||||
|
Object.assign(searchParams, getInitExecutionSearchParams());
|
||||||
|
selectedStatus.value = DEFAULT_EXECUTION_STATUS;
|
||||||
|
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusChange(status: ExecutionStatusFilter) {
|
||||||
|
selectedStatus.value = status;
|
||||||
|
await reloadExecutionData(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getExecutionDetail(row: Api.Project.ProjectExecution) {
|
||||||
|
if (!projectId.value) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetchGetProjectExecution(projectId.value, row.id);
|
||||||
|
|
||||||
|
return result.error || !result.data ? row : result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateExecution() {
|
||||||
|
editingExecution.value = null;
|
||||||
|
editingExecutionMembers.value = [];
|
||||||
|
operateMode.value = 'create';
|
||||||
|
operateVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditExecution(row: Api.Project.ProjectExecution) {
|
||||||
|
const detail = await getExecutionDetail(row);
|
||||||
|
|
||||||
|
if (!detail.allowEdit) {
|
||||||
|
window.$message?.warning('当前执行状态不允许编辑');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editingExecution.value = detail;
|
||||||
|
const memberResult = await fetchGetProjectExecutionMembers(projectId.value, detail.id);
|
||||||
|
editingExecutionMembers.value = memberResult.error || !memberResult.data ? [] : memberResult.data;
|
||||||
|
operateMode.value = 'edit';
|
||||||
|
operateVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openViewExecution(row: Api.Project.ProjectExecution) {
|
||||||
|
const detail = await getExecutionDetail(row);
|
||||||
|
|
||||||
|
editingExecution.value = detail;
|
||||||
|
const memberResult = await fetchGetProjectExecutionMembers(projectId.value, detail.id);
|
||||||
|
editingExecutionMembers.value = memberResult.error || !memberResult.data ? [] : memberResult.data;
|
||||||
|
operateMode.value = 'view';
|
||||||
|
operateVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMemberDialog(row: Api.Project.ProjectExecution) {
|
||||||
|
selectedExecution.value = await getExecutionDetail(row);
|
||||||
|
memberVisible.value = true;
|
||||||
|
await loadExecutionMembers(selectedExecution.value.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openExecutionStatus(row: Api.Project.ProjectExecution, action: ExecutionAction | null) {
|
||||||
|
const detail = await getExecutionDetail(row);
|
||||||
|
const targetAction = action || detail.availableActions[0] || null;
|
||||||
|
|
||||||
|
if (!targetAction) {
|
||||||
|
window.$message?.warning('当前执行暂无可用状态操作');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusExecution.value = detail;
|
||||||
|
statusAction.value = targetAction;
|
||||||
|
statusVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExecutionMembers(executionId: string) {
|
||||||
|
if (!projectId.value) {
|
||||||
|
executionMembers.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
memberLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error, data: members } = await fetchGetProjectExecutionMembers(projectId.value, executionId);
|
||||||
|
executionMembers.value = error || !members ? [] : members;
|
||||||
|
} finally {
|
||||||
|
memberLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExecutionSubmit(payload: Api.Project.SaveProjectExecutionParams) {
|
||||||
|
if (!projectId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = editingExecution.value
|
||||||
|
? await fetchUpdateProjectExecution(projectId.value, editingExecution.value.id, payload)
|
||||||
|
: await fetchCreateProjectExecution(projectId.value, payload);
|
||||||
|
|
||||||
|
if (!result.error) {
|
||||||
|
operateVisible.value = false;
|
||||||
|
await Promise.all([
|
||||||
|
reloadExecutionData(editingExecution.value ? (searchParams.pageNo ?? 1) : 1),
|
||||||
|
loadExecutionStatusBoard()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChangeOwner(payload: Api.Project.ChangeExecutionOwnerParams) {
|
||||||
|
if (!projectId.value || !selectedExecution.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetchChangeProjectExecutionOwner(projectId.value, selectedExecution.value.id, payload);
|
||||||
|
|
||||||
|
if (!result.error) {
|
||||||
|
selectedExecution.value = await getExecutionDetail(selectedExecution.value);
|
||||||
|
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExecutionStatusSubmit(reason: string | null) {
|
||||||
|
if (!projectId.value || !statusExecution.value || !statusAction.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetchChangeProjectExecutionStatus(projectId.value, statusExecution.value.id, {
|
||||||
|
actionCode: statusAction.value.actionCode,
|
||||||
|
reason
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.error) {
|
||||||
|
statusVisible.value = false;
|
||||||
|
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddExecutionMember(payload: Api.Project.CreateExecutionMemberParams) {
|
||||||
|
if (!projectId.value || !selectedExecution.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetchCreateProjectExecutionMember(projectId.value, selectedExecution.value.id, payload);
|
||||||
|
|
||||||
|
if (!result.error) {
|
||||||
|
await loadExecutionMembers(selectedExecution.value.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInactiveExecutionMember(
|
||||||
|
member: Api.Project.ExecutionMember,
|
||||||
|
payload: Api.Project.InactiveExecutionMemberParams
|
||||||
|
) {
|
||||||
|
if (!projectId.value || !selectedExecution.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetchInactiveProjectExecutionMember(projectId.value, selectedExecution.value.id, {
|
||||||
|
memberId: member.id,
|
||||||
|
data: payload
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.error) {
|
||||||
|
await loadExecutionMembers(selectedExecution.value.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteExecution(_row: Api.Project.ProjectExecution) {
|
||||||
|
window.$message?.warning('删除接口暂未开放,请等待后端发布');
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => projectId.value,
|
||||||
|
async () => {
|
||||||
|
selectedExecution.value = null;
|
||||||
|
Object.assign(searchParams, getInitExecutionSearchParams());
|
||||||
|
selectedStatus.value = DEFAULT_EXECUTION_STATUS;
|
||||||
|
await refreshPageData();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="projectId" class="project-execution-page">
|
||||||
|
<ExecutionListPanel
|
||||||
|
v-model:search-model="searchParams"
|
||||||
|
class="project-execution-page__aside"
|
||||||
|
:data="data"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="mobilePagination"
|
||||||
|
:selected-id="selectedExecution?.id || null"
|
||||||
|
:status-board="executionStatusBoard"
|
||||||
|
:selected-status="selectedStatus"
|
||||||
|
:owner-options="projectMemberOptions"
|
||||||
|
:can-create="canCreateExecution"
|
||||||
|
:can-update="canUpdateExecution"
|
||||||
|
:can-change-owner="canChangeExecutionOwner"
|
||||||
|
:can-manage-member="canManageExecutionMember"
|
||||||
|
:can-change-status="canChangeExecutionStatus"
|
||||||
|
:can-delete="canDeleteExecution"
|
||||||
|
@select="selectedExecution = $event"
|
||||||
|
@status-change="handleStatusChange"
|
||||||
|
@search="handleSearch"
|
||||||
|
@reset="handleReset"
|
||||||
|
@create="openCreateExecution"
|
||||||
|
@edit="openEditExecution"
|
||||||
|
@view="openViewExecution"
|
||||||
|
@members="openMemberDialog"
|
||||||
|
@status-action="openExecutionStatus"
|
||||||
|
@delete="handleDeleteExecution"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TaskWorkspace
|
||||||
|
class="project-execution-page__main"
|
||||||
|
:project-id="projectId"
|
||||||
|
:execution="selectedExecution"
|
||||||
|
:owner-options="projectMemberOptions"
|
||||||
|
:can-create="canCreateTask"
|
||||||
|
:can-update="canUpdateTask"
|
||||||
|
:can-change-status="canChangeTaskStatus"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExecutionOperateDialog
|
||||||
|
v-model:visible="operateVisible"
|
||||||
|
:mode="operateMode"
|
||||||
|
:row-data="editingExecution"
|
||||||
|
:user-options="projectMemberOptions"
|
||||||
|
:current-members="editingExecutionMembers"
|
||||||
|
@submit="handleExecutionSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExecutionMemberDialog
|
||||||
|
v-model:visible="memberVisible"
|
||||||
|
:execution="selectedExecution"
|
||||||
|
:members="executionMembers"
|
||||||
|
:user-options="projectMemberOptions"
|
||||||
|
:loading="memberLoading"
|
||||||
|
:can-manage-member="canManageExecutionMember"
|
||||||
|
:can-change-owner="canChangeExecutionOwner"
|
||||||
|
@add="handleAddExecutionMember"
|
||||||
|
@inactive="handleInactiveExecutionMember"
|
||||||
|
@change-owner="handleChangeOwner"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatusActionDialog
|
||||||
|
v-model:visible="statusVisible"
|
||||||
|
:title="statusActionTitle"
|
||||||
|
:action="statusAction"
|
||||||
|
@submit="handleExecutionStatusSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElEmpty v-else description="未获取到当前项目上下文,请返回项目列表重新选择项目" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.project-execution-page {
|
||||||
|
display: grid;
|
||||||
|
min-height: 560px;
|
||||||
|
gap: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
grid-template-columns: 396px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-execution-page__aside,
|
||||||
|
.project-execution-page__main {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1280px) {
|
||||||
|
.project-execution-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
49
src/views/project/project/execution/mock.ts
Normal file
49
src/views/project/project/execution/mock.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
EXECUTION_STATUS_ORDER,
|
||||||
|
TASK_STATUS_ORDER,
|
||||||
|
executionStatusFallbackNameMap,
|
||||||
|
taskStatusFallbackNameMap
|
||||||
|
} from './shared';
|
||||||
|
|
||||||
|
export const mockExecutionStatusCounts: Record<Api.Project.ProjectExecutionStatusCode, number> =
|
||||||
|
EXECUTION_STATUS_ORDER.reduce(
|
||||||
|
(counts, statusCode) => ({
|
||||||
|
...counts,
|
||||||
|
[statusCode]: 0
|
||||||
|
}),
|
||||||
|
{} as Record<Api.Project.ProjectExecutionStatusCode, number>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const mockTaskStatusColumns = TASK_STATUS_ORDER.map(statusCode => ({
|
||||||
|
statusCode,
|
||||||
|
statusName: taskStatusFallbackNameMap[statusCode],
|
||||||
|
tasks: [] as Api.Project.ProjectTask[]
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function createEmptyExecution(projectId: string): Api.Project.ProjectExecution {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
projectId,
|
||||||
|
projectRequirementId: null,
|
||||||
|
executionName: '',
|
||||||
|
executionType: null,
|
||||||
|
ownerId: '',
|
||||||
|
ownerNickname: null,
|
||||||
|
statusCode: 'pending',
|
||||||
|
statusName: executionStatusFallbackNameMap.pending,
|
||||||
|
terminal: false,
|
||||||
|
allowEdit: true,
|
||||||
|
availableActions: [],
|
||||||
|
plannedStartDate: null,
|
||||||
|
plannedEndDate: null,
|
||||||
|
actualStartDate: null,
|
||||||
|
actualEndDate: null,
|
||||||
|
progressRate: 0,
|
||||||
|
executionDesc: null,
|
||||||
|
lastStatusReason: null,
|
||||||
|
createTime: now,
|
||||||
|
updateTime: now
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, markRaw } from 'vue';
|
||||||
|
import type { PaginationProps } from 'element-plus';
|
||||||
|
import { Calendar, Flag, Plus, TrendCharts, User } from '@element-plus/icons-vue';
|
||||||
|
import { formatDateRange, getExecutionStatusName, getExecutionStatusTagType, getProgressText } from '../shared';
|
||||||
|
import IconMdiAccountMultipleOutline from '~icons/mdi/account-multiple-outline';
|
||||||
|
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
|
||||||
|
import IconMdiEyeOutline from '~icons/mdi/eye-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: 'ProjectExecutionListPanel' });
|
||||||
|
|
||||||
|
type ExecutionStatusFilter = string | null;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: Api.Project.ProjectExecution[];
|
||||||
|
loading: boolean;
|
||||||
|
pagination: Partial<PaginationProps & Record<string, any>>;
|
||||||
|
selectedId: string | null;
|
||||||
|
statusBoard: Api.Project.StatusBoard | null;
|
||||||
|
selectedStatus: ExecutionStatusFilter;
|
||||||
|
ownerOptions: Api.SystemManage.UserSimple[];
|
||||||
|
canCreate: boolean;
|
||||||
|
canUpdate: boolean;
|
||||||
|
canChangeOwner: boolean;
|
||||||
|
canManageMember: boolean;
|
||||||
|
canChangeStatus: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'select', row: Api.Project.ProjectExecution): void;
|
||||||
|
(e: 'status-change', status: ExecutionStatusFilter): void;
|
||||||
|
(e: 'create'): void;
|
||||||
|
(e: 'edit', row: Api.Project.ProjectExecution): void;
|
||||||
|
(e: 'view', row: Api.Project.ProjectExecution): void;
|
||||||
|
(e: 'members', row: Api.Project.ProjectExecution): void;
|
||||||
|
(e: 'delete', row: Api.Project.ProjectExecution): void;
|
||||||
|
(e: 'search'): void;
|
||||||
|
(e: 'reset'): void;
|
||||||
|
(
|
||||||
|
e: 'status-action',
|
||||||
|
row: Api.Project.ProjectExecution,
|
||||||
|
action: Api.Project.LifecycleAction<Api.Project.ProjectExecutionActionCode> | null
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const searchModel = defineModel<Api.Project.ProjectExecutionSearchParams>('searchModel', { required: true });
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
emit('search');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeywordInput(value: string) {
|
||||||
|
searchModel.value.keyword = value.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeywordClear() {
|
||||||
|
searchModel.value.keyword = undefined;
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOwnerSelect(id: string | null | undefined) {
|
||||||
|
searchModel.value.ownerId = id || undefined;
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
emit('reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
const paginationVisible = computed(() => {
|
||||||
|
const total = Number(props.pagination.total || 0);
|
||||||
|
|
||||||
|
return total > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCount = computed(() => props.statusBoard?.total ?? 0);
|
||||||
|
|
||||||
|
const statusItems = computed(() => [
|
||||||
|
{
|
||||||
|
key: null,
|
||||||
|
label: '全部',
|
||||||
|
count: totalCount.value
|
||||||
|
},
|
||||||
|
...(props.statusBoard?.items ?? []).map(item => ({
|
||||||
|
key: item.statusCode,
|
||||||
|
label: item.statusName,
|
||||||
|
count: item.count
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
|
||||||
|
function handleStatusClick(status: ExecutionStatusFilter) {
|
||||||
|
emit('status-change', status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(row: Api.Project.ProjectExecution) {
|
||||||
|
emit('select', row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(page: number) {
|
||||||
|
props.pagination['current-change']?.(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSizeChange(pageSize: number) {
|
||||||
|
props.pagination['size-change']?.(pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExecutionAction {
|
||||||
|
key: string;
|
||||||
|
tooltip: string;
|
||||||
|
icon: object;
|
||||||
|
type: 'primary' | 'success' | 'danger';
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_ACTION_ICON_MAP: Record<string, object> = {
|
||||||
|
start: markRaw(IconMdiPlay),
|
||||||
|
pause: markRaw(IconMdiPause),
|
||||||
|
resume: markRaw(IconMdiRestart),
|
||||||
|
cancel: markRaw(IconMdiCloseCircleOutline)
|
||||||
|
};
|
||||||
|
|
||||||
|
function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
|
||||||
|
const actions: ExecutionAction[] = [];
|
||||||
|
const isCancelled = row.statusCode === 'cancelled';
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
actions.push({
|
||||||
|
key: 'view',
|
||||||
|
tooltip: '查看',
|
||||||
|
icon: markRaw(IconMdiEyeOutline),
|
||||||
|
type: 'primary',
|
||||||
|
onClick: () => emit('view', row)
|
||||||
|
});
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.canUpdate && !isCancelled) {
|
||||||
|
actions.push({
|
||||||
|
key: 'edit',
|
||||||
|
tooltip: '编辑',
|
||||||
|
icon: markRaw(IconMdiPencilOutline),
|
||||||
|
type: 'primary',
|
||||||
|
onClick: () => emit('edit', row)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((props.canManageMember || props.canChangeOwner) && !isCancelled) {
|
||||||
|
actions.push({
|
||||||
|
key: 'members',
|
||||||
|
tooltip: '成员管理',
|
||||||
|
icon: markRaw(IconMdiAccountMultipleOutline),
|
||||||
|
type: 'primary',
|
||||||
|
onClick: () => emit('members', row)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.canChangeStatus) {
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.availableActions.length) {
|
||||||
|
if (row.statusCode === 'pending') {
|
||||||
|
actions.push({
|
||||||
|
key: 'cancel',
|
||||||
|
tooltip: '取消',
|
||||||
|
icon: markRaw(IconMdiCloseCircleOutline),
|
||||||
|
type: 'danger',
|
||||||
|
onClick: () =>
|
||||||
|
emit('status-action', row, {
|
||||||
|
actionCode: 'cancel',
|
||||||
|
actionName: '取消',
|
||||||
|
needReason: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.availableActions.forEach(action => {
|
||||||
|
actions.push({
|
||||||
|
key: action.actionCode,
|
||||||
|
tooltip: action.actionName,
|
||||||
|
icon: markRaw(STATUS_ACTION_ICON_MAP[action.actionCode] ?? IconMdiSync),
|
||||||
|
type: action.actionCode === 'cancel' ? 'danger' : 'success',
|
||||||
|
onClick: () => emit('status-action', row, action)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="execution-list-panel">
|
||||||
|
<header class="execution-list-panel__header">
|
||||||
|
<h3 class="execution-list-panel__title">执行池</h3>
|
||||||
|
<ElButton v-if="canCreate" type="primary" :icon="Plus" @click="emit('create')">新增</ElButton>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="execution-list-panel__search">
|
||||||
|
<ElInput
|
||||||
|
:model-value="searchModel.keyword ?? ''"
|
||||||
|
class="execution-search-input"
|
||||||
|
placeholder="搜索执行名称"
|
||||||
|
@update:model-value="handleKeywordInput"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<ElIcon v-if="searchModel.keyword" class="execution-search-input__clear" @click="handleKeywordClear">
|
||||||
|
<icon-mdi-close-circle />
|
||||||
|
</ElIcon>
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
|
|
||||||
|
<ElSelect
|
||||||
|
:model-value="searchModel.ownerId ?? null"
|
||||||
|
class="execution-owner-select"
|
||||||
|
placeholder="负责人"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
@change="handleOwnerSelect"
|
||||||
|
>
|
||||||
|
<ElOption v-for="item in ownerOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
||||||
|
</ElSelect>
|
||||||
|
|
||||||
|
<div class="execution-search__icons">
|
||||||
|
<ElTooltip content="重置" placement="top">
|
||||||
|
<ElButton link class="execution-search-input__btn" @click="handleReset">
|
||||||
|
<icon-mdi-refresh class="text-15px" />
|
||||||
|
</ElButton>
|
||||||
|
</ElTooltip>
|
||||||
|
<ElTooltip content="搜索" placement="top">
|
||||||
|
<ElButton link class="execution-search-input__btn" type="primary" @click="handleSearch">
|
||||||
|
<icon-ic-round-search class="text-15px" />
|
||||||
|
</ElButton>
|
||||||
|
</ElTooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="execution-status-grid" aria-label="执行状态筛选">
|
||||||
|
<button
|
||||||
|
v-for="item in statusItems"
|
||||||
|
:key="item.key || 'all'"
|
||||||
|
type="button"
|
||||||
|
class="execution-status-grid__item"
|
||||||
|
:class="{ 'is-active': selectedStatus === item.key }"
|
||||||
|
:aria-pressed="selectedStatus === item.key"
|
||||||
|
@click="handleStatusClick(item.key)"
|
||||||
|
>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<strong>{{ item.count }}</strong>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElScrollbar class="execution-list-panel__scrollbar">
|
||||||
|
<ElSkeleton v-if="loading" :rows="5" animated />
|
||||||
|
<ElEmpty v-else-if="data.length === 0" class="execution-list-panel__empty" description="暂无执行项" />
|
||||||
|
<div v-else class="execution-list-panel__list">
|
||||||
|
<article
|
||||||
|
v-for="row in data"
|
||||||
|
:key="row.id"
|
||||||
|
class="execution-item"
|
||||||
|
:class="{ 'is-active': selectedId === row.id }"
|
||||||
|
@click="handleSelect(row)"
|
||||||
|
>
|
||||||
|
<div class="execution-item__main">
|
||||||
|
<div class="execution-item__top">
|
||||||
|
<strong class="execution-item__name">{{ row.executionName || '未命名执行' }}</strong>
|
||||||
|
<ElTag
|
||||||
|
class="execution-item__status-tag"
|
||||||
|
:type="getExecutionStatusTagType(row.statusCode)"
|
||||||
|
effect="light"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ getExecutionStatusName(row) }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="execution-item__meta">
|
||||||
|
<span>
|
||||||
|
<ElIcon><User /></ElIcon>
|
||||||
|
{{ row.ownerNickname || '未设置负责人' }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<ElIcon><Flag /></ElIcon>
|
||||||
|
计划 {{ formatDateRange(row.plannedStartDate, row.plannedEndDate) }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<ElIcon><Calendar /></ElIcon>
|
||||||
|
实际 {{ formatDateRange(row.actualStartDate, row.actualEndDate) }}
|
||||||
|
</span>
|
||||||
|
<span class="execution-item__progress-row">
|
||||||
|
<ElIcon><TrendCharts /></ElIcon>
|
||||||
|
<ElProgress class="execution-item__progress" :percentage="row.progressRate" :stroke-width="6" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="execution-item__actions" @click.stop>
|
||||||
|
<ElTooltip v-for="action in createActions(row)" :key="action.key" :content="action.tooltip">
|
||||||
|
<ElButton link :type="action.type" class="execution-action-btn" @click="action.onClick()">
|
||||||
|
<component :is="action.icon" class="text-14px" />
|
||||||
|
</ElButton>
|
||||||
|
</ElTooltip>
|
||||||
|
<ElPopconfirm
|
||||||
|
v-if="canDelete && (row.statusCode === 'pending' || row.statusCode === 'cancelled')"
|
||||||
|
title="确认删除该执行?删除后不可恢复"
|
||||||
|
confirm-button-text="删除"
|
||||||
|
cancel-button-text="取消"
|
||||||
|
confirm-button-type="danger"
|
||||||
|
@confirm="emit('delete', row)"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<span class="inline-flex">
|
||||||
|
<ElTooltip content="删除">
|
||||||
|
<ElButton link type="danger" class="execution-action-btn">
|
||||||
|
<icon-mdi-delete-outline class="text-14px" />
|
||||||
|
</ElButton>
|
||||||
|
</ElTooltip>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ElPopconfirm>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</ElScrollbar>
|
||||||
|
|
||||||
|
<div v-if="paginationVisible" class="execution-list-panel__pagination">
|
||||||
|
<ElPagination
|
||||||
|
small
|
||||||
|
background
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
v-bind="pagination"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.execution-list-panel {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 92%);
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-list-panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-list-panel__title {
|
||||||
|
margin: 0;
|
||||||
|
color: rgb(15 23 42 / 94%);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-list-panel__search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-search-input {
|
||||||
|
width: 140px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-search-input__clear {
|
||||||
|
color: rgb(192 196 204);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-search-input__clear:hover {
|
||||||
|
color: rgb(144 147 153);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-search__icons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-search-input__btn {
|
||||||
|
width: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-owner-select {
|
||||||
|
width: 140px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-search__icons :deep(.el-button + .el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-status-grid__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 92%);
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: rgb(248 250 252 / 80%);
|
||||||
|
color: rgb(51 65 85 / 96%);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.16s ease,
|
||||||
|
background-color 0.16s ease,
|
||||||
|
color 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-status-grid__item:hover {
|
||||||
|
border-color: rgb(148 163 184 / 80%);
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-status-grid__item.is-active {
|
||||||
|
border-color: rgb(64 158 255 / 68%);
|
||||||
|
background-color: rgb(236 245 255 / 92%);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-status-grid__item span {
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-status-grid__item strong {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-list-panel__scrollbar {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-list-panel__empty {
|
||||||
|
padding: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-list-panel__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-list-panel__pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 92%);
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.16s ease,
|
||||||
|
background-color 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-item:hover {
|
||||||
|
border-color: rgb(148 163 184 / 76%);
|
||||||
|
background-color: rgb(248 250 252 / 68%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-item.is-active {
|
||||||
|
border-color: rgb(64 158 255 / 68%);
|
||||||
|
background-color: rgb(236 245 255 / 78%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-item__main {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-item__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-item__status-tag {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-item__name {
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
color: rgb(15 23 42 / 94%);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-item__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: rgb(100 116 139 / 94%);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-item__meta span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-item__progress-row {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-item__progress {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-item__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-item__actions :deep(.el-button + .el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.execution-action-btn) {
|
||||||
|
padding: 3px;
|
||||||
|
min-width: auto;
|
||||||
|
height: auto;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1280px) {
|
||||||
|
.execution-item {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-item__actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
import MemberCurrentPanel from './member-current-panel.vue';
|
||||||
|
import MemberLogPanel from './member-log-panel.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectExecutionMemberDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
execution: Api.Project.ProjectExecution | null;
|
||||||
|
members: Api.Project.ExecutionMember[];
|
||||||
|
userOptions: Api.SystemManage.UserSimple[];
|
||||||
|
loading: boolean;
|
||||||
|
canManageMember: boolean;
|
||||||
|
canChangeOwner: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'add', payload: Api.Project.CreateExecutionMemberParams): void;
|
||||||
|
(e: 'inactive', member: Api.Project.ExecutionMember, payload: Api.Project.InactiveExecutionMemberParams): void;
|
||||||
|
(e: 'change-owner', payload: Api.Project.ChangeExecutionOwnerParams): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
type TabName = 'current' | 'log';
|
||||||
|
|
||||||
|
const activeTab = ref<TabName>('current');
|
||||||
|
|
||||||
|
const currentPanelRef = ref<InstanceType<typeof MemberCurrentPanel> | null>(null);
|
||||||
|
|
||||||
|
const dialogTitle = computed(() =>
|
||||||
|
props.execution ? `执行成员管理:${props.execution.executionName}` : '执行成员管理'
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectId = computed(() => props.execution?.projectId || '');
|
||||||
|
const executionId = computed(() => props.execution?.id || '');
|
||||||
|
|
||||||
|
function handleAdd(payload: Api.Project.CreateExecutionMemberParams) {
|
||||||
|
emit('add', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInactive(member: Api.Project.ExecutionMember, payload: Api.Project.InactiveExecutionMemberParams) {
|
||||||
|
emit('inactive', member, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChangeOwner(payload: Api.Project.ChangeExecutionOwnerParams) {
|
||||||
|
emit('change-owner', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
value => {
|
||||||
|
if (value) {
|
||||||
|
activeTab.value = 'current';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPanelRef.value?.reset();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="lg" :show-footer="false" :scrollbar="false">
|
||||||
|
<ElTabs v-model="activeTab" class="execution-member-dialog__tabs">
|
||||||
|
<ElTabPane label="当前成员" name="current">
|
||||||
|
<MemberCurrentPanel
|
||||||
|
ref="currentPanelRef"
|
||||||
|
:execution="execution"
|
||||||
|
:members="members"
|
||||||
|
:user-options="userOptions"
|
||||||
|
:loading="loading"
|
||||||
|
:can-manage-member="canManageMember"
|
||||||
|
:can-change-owner="canChangeOwner"
|
||||||
|
@add="handleAdd"
|
||||||
|
@inactive="handleInactive"
|
||||||
|
@change-owner="handleChangeOwner"
|
||||||
|
/>
|
||||||
|
</ElTabPane>
|
||||||
|
<ElTabPane label="变更历史" name="log" lazy>
|
||||||
|
<MemberLogPanel
|
||||||
|
v-if="projectId && executionId"
|
||||||
|
:project-id="projectId"
|
||||||
|
:execution-id="executionId"
|
||||||
|
:user-options="userOptions"
|
||||||
|
:active="activeTab === 'log'"
|
||||||
|
/>
|
||||||
|
</ElTabPane>
|
||||||
|
</ElTabs>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.execution-member-dialog__tabs {
|
||||||
|
--el-tabs-header-height: 40px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||||
|
import { useResizeObserver } from '@vueuse/core';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { RDMS_PROJECT_EXECUTION_TYPE_DICT_CODE } from '@/constants/dict';
|
||||||
|
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||||
|
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 BusinessUserSelect from '@/components/custom/business-user-select.vue';
|
||||||
|
import DictSelect from '@/components/custom/dict-select.vue';
|
||||||
|
import { isActiveExecutionMember } from '../shared';
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectExecutionOperateDialog' });
|
||||||
|
|
||||||
|
type OperateMode = 'create' | 'edit' | 'view';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: OperateMode;
|
||||||
|
rowData: Api.Project.ProjectExecution | null;
|
||||||
|
userOptions: Api.SystemManage.UserSimple[];
|
||||||
|
currentMembers?: Api.Project.ExecutionMember[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submit', payload: Api.Project.SaveProjectExecutionParams): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const activeMembers = computed(() => (props.currentMembers ?? []).filter(member => isActiveExecutionMember(member)));
|
||||||
|
|
||||||
|
function resolveUserLabel(userId: string | null | undefined, fallbackNickname?: string | null) {
|
||||||
|
if (!userId) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackNickname || props.userOptions.find(item => item.id === userId)?.nickname || userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMemberLabel(member: Api.Project.ExecutionMember) {
|
||||||
|
return resolveUserLabel(member.userId, member.userNickname);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerDisplayName = computed(() => resolveUserLabel(props.rowData?.ownerId, props.rowData?.ownerNickname));
|
||||||
|
|
||||||
|
const activeMemberIds = computed(() => activeMembers.value.map(member => member.userId));
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const { formRef, validate } = useForm();
|
||||||
|
const { createRequiredRule } = useFormRules();
|
||||||
|
const autoOwnerMemberId = ref<string | null>(null);
|
||||||
|
|
||||||
|
/** 左栏容器 ref:用其高度动态驱动右侧富文本,让两栏视觉等高 */
|
||||||
|
const leftColRef = ref<HTMLElement>();
|
||||||
|
const editorHeight = ref<string>('45vh');
|
||||||
|
|
||||||
|
useResizeObserver(leftColRef, entries => {
|
||||||
|
const h = entries[0]?.contentRect.height;
|
||||||
|
if (h && h > 120) {
|
||||||
|
// 减去 BusinessFormSection 标题(h4)大致高度,让富文本所在 section 与左栏底对齐
|
||||||
|
editorHeight.value = `${Math.max(h - 60, 240)}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function parsePlannedDate(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlannedDateRangeValid(startDate: string | null, endDate: string | null) {
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !dayjs(endDate).isBefore(dayjs(startDate), 'day');
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = reactive<Api.Project.SaveProjectExecutionParams>({
|
||||||
|
executionName: '',
|
||||||
|
executionType: '',
|
||||||
|
ownerId: '',
|
||||||
|
projectRequirementId: null,
|
||||||
|
plannedStartDate: null,
|
||||||
|
plannedEndDate: null,
|
||||||
|
executionDesc: null,
|
||||||
|
memberUserIds: []
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildEndDateShortcut(text: string, mutator: (date: Date) => void) {
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
value: () => {
|
||||||
|
let startDate = parsePlannedDate(model.plannedStartDate);
|
||||||
|
|
||||||
|
if (!startDate) {
|
||||||
|
startDate = new Date();
|
||||||
|
model.plannedStartDate = dayjs(startDate).format('YYYY-MM-DD');
|
||||||
|
window.$message?.info('未选择计划开始日期,已按今日为基准计算');
|
||||||
|
nextTick(() => formRef.value?.clearValidate('plannedStartDate'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDate = new Date(startDate.getTime());
|
||||||
|
mutator(endDate);
|
||||||
|
return endDate;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const plannedEndDateShortcuts = [
|
||||||
|
buildEndDateShortcut('一星期', date => date.setDate(date.getDate() + 7)),
|
||||||
|
buildEndDateShortcut('两星期', date => date.setDate(date.getDate() + 14)),
|
||||||
|
buildEndDateShortcut('一个月', date => date.setMonth(date.getMonth() + 1)),
|
||||||
|
buildEndDateShortcut('三个月', date => date.setMonth(date.getMonth() + 3)),
|
||||||
|
buildEndDateShortcut('半年', date => date.setMonth(date.getMonth() + 6)),
|
||||||
|
buildEndDateShortcut('一年', date => date.setFullYear(date.getFullYear() + 1))
|
||||||
|
];
|
||||||
|
|
||||||
|
const isView = computed(() => props.mode === 'view');
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => {
|
||||||
|
if (props.mode === 'create') {
|
||||||
|
return '新建执行';
|
||||||
|
}
|
||||||
|
if (props.mode === 'view') {
|
||||||
|
return props.rowData?.executionName ? `查看执行:${props.rowData.executionName}` : '查看执行';
|
||||||
|
}
|
||||||
|
return props.rowData?.executionName ? `编辑执行:${props.rowData.executionName}` : '编辑执行';
|
||||||
|
});
|
||||||
|
const rules = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
executionName: [createRequiredRule('请输入执行名称')],
|
||||||
|
executionType: [createRequiredRule('请选择执行类型')],
|
||||||
|
ownerId: [createRequiredRule('请选择执行负责人')],
|
||||||
|
memberUserIds: props.mode === 'create' ? [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[]>
|
||||||
|
);
|
||||||
|
|
||||||
|
function normalizeMemberUserIds(memberUserIds?: string[]) {
|
||||||
|
return Array.from(new Set(memberUserIds?.filter(Boolean) ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserRoleName(item: Api.SystemManage.UserSimple) {
|
||||||
|
return item.deptName || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncOwnerMember(ownerId: string | null, previousOwnerId: string | null = autoOwnerMemberId.value) {
|
||||||
|
if (props.mode !== 'create') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMemberUserIds = normalizeMemberUserIds(model.memberUserIds);
|
||||||
|
const memberUserIds = previousOwnerId
|
||||||
|
? currentMemberUserIds.filter(userId => userId !== previousOwnerId)
|
||||||
|
: currentMemberUserIds;
|
||||||
|
|
||||||
|
model.memberUserIds = ownerId ? normalizeMemberUserIds([...memberUserIds, ownerId]) : memberUserIds;
|
||||||
|
autoOwnerMemberId.value = ownerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureOwnerInMembers() {
|
||||||
|
if (props.mode !== 'create' || !model.ownerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.memberUserIds = normalizeMemberUserIds([...(model.memberUserIds || []), model.ownerId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
ensureOwnerInMembers();
|
||||||
|
await validate();
|
||||||
|
|
||||||
|
emit('submit', {
|
||||||
|
executionName: model.executionName.trim(),
|
||||||
|
executionType: model.executionType.trim(),
|
||||||
|
ownerId: model.ownerId,
|
||||||
|
projectRequirementId: null,
|
||||||
|
plannedStartDate: model.plannedStartDate,
|
||||||
|
plannedEndDate: model.plannedEndDate,
|
||||||
|
executionDesc: isEmptyRichText(model.executionDesc) ? null : (model.executionDesc ?? null),
|
||||||
|
memberUserIds: props.mode === 'create' ? normalizeMemberUserIds(model.memberUserIds) : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMemberChange(value: string[]) {
|
||||||
|
if (props.mode !== 'create') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.memberUserIds = normalizeMemberUserIds(model.ownerId ? [...value, model.ownerId] : value);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
async value => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.executionName = props.rowData?.executionName || '';
|
||||||
|
model.executionType = props.rowData?.executionType || '';
|
||||||
|
model.ownerId = props.rowData?.ownerId || '';
|
||||||
|
model.projectRequirementId = null;
|
||||||
|
model.plannedStartDate = props.rowData?.plannedStartDate || null;
|
||||||
|
model.plannedEndDate = props.rowData?.plannedEndDate || null;
|
||||||
|
model.executionDesc = props.rowData?.executionDesc || null;
|
||||||
|
model.memberUserIds = [];
|
||||||
|
autoOwnerMemberId.value = null;
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => model.ownerId,
|
||||||
|
(ownerId, previousOwnerId) => {
|
||||||
|
syncOwnerMember(ownerId || null, previousOwnerId || null);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="dialogTitle"
|
||||||
|
width="1100px"
|
||||||
|
max-body-height="78vh"
|
||||||
|
@confirm="handleConfirm"
|
||||||
|
>
|
||||||
|
<template v-if="isView" #footer="{ close }">
|
||||||
|
<ElButton type="primary" @click="close">关闭</ElButton>
|
||||||
|
</template>
|
||||||
|
<ElForm
|
||||||
|
ref="formRef"
|
||||||
|
:model="model"
|
||||||
|
:rules="rules"
|
||||||
|
label-position="top"
|
||||||
|
:validate-on-rule-change="false"
|
||||||
|
class="execution-operate-dialog__form"
|
||||||
|
:class="{ 'is-view': isView }"
|
||||||
|
>
|
||||||
|
<div class="execution-operate-dialog__grid">
|
||||||
|
<div ref="leftColRef" class="execution-operate-dialog__col-left">
|
||||||
|
<BusinessFormSection title="执行信息">
|
||||||
|
<ElFormItem label="执行名称" prop="executionName">
|
||||||
|
<ElInput v-model="model.executionName" :readonly="isView" maxlength="200" placeholder="请输入执行名称" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="执行类型" prop="executionType">
|
||||||
|
<DictSelect
|
||||||
|
v-model="model.executionType"
|
||||||
|
:dict-code="RDMS_PROJECT_EXECUTION_TYPE_DICT_CODE"
|
||||||
|
:disabled="isView"
|
||||||
|
filterable
|
||||||
|
placeholder="请选择执行类型"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem v-if="mode === 'create'" label="负责人" prop="ownerId">
|
||||||
|
<BusinessUserSelect v-model="model.ownerId" :options="userOptions" placeholder="请选择负责人" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem v-else>
|
||||||
|
<template #label>
|
||||||
|
<template v-if="isView">负责人</template>
|
||||||
|
<span v-else class="business-form-label-with-tip">
|
||||||
|
<ElTooltip
|
||||||
|
content="如需变更负责人,请关闭此弹层后点击列表「负责人」按钮。"
|
||||||
|
popper-class="business-form-label-tooltip"
|
||||||
|
placement="top-start"
|
||||||
|
>
|
||||||
|
<span class="business-form-label-tip">
|
||||||
|
<icon-fe:question />
|
||||||
|
</span>
|
||||||
|
</ElTooltip>
|
||||||
|
<span>负责人</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ElInput
|
||||||
|
:model-value="ownerDisplayName"
|
||||||
|
readonly
|
||||||
|
class="execution-operate-dialog__readonly-input"
|
||||||
|
placeholder="未设置负责人"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem v-if="mode === 'create'" label="执行成员" prop="memberUserIds">
|
||||||
|
<ElSelect
|
||||||
|
v-model="model.memberUserIds"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
:max-collapse-tags="2"
|
||||||
|
class="w-full"
|
||||||
|
placeholder="请选择执行成员"
|
||||||
|
@change="handleMemberChange"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="item in userOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.id === model.ownerId ? `${item.nickname}(负责人)` : item.nickname"
|
||||||
|
:value="item.id"
|
||||||
|
:disabled="item.id === model.ownerId"
|
||||||
|
>
|
||||||
|
<div class="execution-member-option">
|
||||||
|
<span class="execution-member-option__name">
|
||||||
|
{{ item.nickname }}
|
||||||
|
<span v-if="item.id === model.ownerId" class="execution-member-option__owner">负责人</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="getUserRoleName(item)" class="execution-member-option__role">
|
||||||
|
{{ getUserRoleName(item) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ElOption>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem v-else>
|
||||||
|
<template #label>
|
||||||
|
<template v-if="isView">执行成员</template>
|
||||||
|
<span v-else class="business-form-label-with-tip">
|
||||||
|
<ElTooltip
|
||||||
|
content="如需调整成员,请关闭此弹层后点击列表「成员」按钮。"
|
||||||
|
popper-class="business-form-label-tooltip"
|
||||||
|
placement="top-start"
|
||||||
|
>
|
||||||
|
<span class="business-form-label-tip">
|
||||||
|
<icon-fe:question />
|
||||||
|
</span>
|
||||||
|
</ElTooltip>
|
||||||
|
<span>执行成员</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ElSelect
|
||||||
|
:model-value="activeMemberIds"
|
||||||
|
multiple
|
||||||
|
disabled
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
:max-collapse-tags="2"
|
||||||
|
class="w-full"
|
||||||
|
placeholder="暂无在岗成员"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="member in activeMembers"
|
||||||
|
:key="member.id"
|
||||||
|
:label="resolveMemberLabel(member)"
|
||||||
|
:value="member.userId"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="计划开始日期" prop="plannedStartDate">
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="model.plannedStartDate"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="选择计划开始日期"
|
||||||
|
:disabled="isView"
|
||||||
|
class="execution-operate-dialog__date-picker"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="计划结束日期" prop="plannedEndDate">
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="model.plannedEndDate"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="选择计划结束日期"
|
||||||
|
:shortcuts="isView ? undefined : plannedEndDateShortcuts"
|
||||||
|
:disabled="isView"
|
||||||
|
class="execution-operate-dialog__date-picker"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</BusinessFormSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="execution-operate-dialog__col-right">
|
||||||
|
<BusinessFormSection title="执行说明">
|
||||||
|
<ElFormItem class="execution-operate-dialog__desc-item">
|
||||||
|
<BusinessRichTextEditor
|
||||||
|
v-model="model.executionDesc"
|
||||||
|
:disabled="isView"
|
||||||
|
:height="editorHeight"
|
||||||
|
upload-directory="execution"
|
||||||
|
placeholder="请输入执行说明"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</BusinessFormSection>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.execution-operate-dialog__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 360px 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-operate-dialog__col-left,
|
||||||
|
.execution-operate-dialog__col-right {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-operate-dialog__col-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-operate-dialog__desc-item {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1024px) {
|
||||||
|
.execution-operate-dialog__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.execution-operate-dialog__date-picker.el-date-editor.el-input) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.execution-operate-dialog__readonly-input .el-input__wrapper) {
|
||||||
|
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
|
||||||
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.execution-operate-dialog__readonly-input .el-input__wrapper:hover),
|
||||||
|
:deep(.execution-operate-dialog__readonly-input.is-focus .el-input__wrapper) {
|
||||||
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.execution-operate-dialog__readonly-input .el-input__inner) {
|
||||||
|
color: rgb(51 65 85 / 96%);
|
||||||
|
cursor: default;
|
||||||
|
-webkit-text-fill-color: rgb(51 65 85 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-operate-dialog__form.is-view :deep(.el-input__wrapper),
|
||||||
|
.execution-operate-dialog__form.is-view :deep(.el-select__wrapper),
|
||||||
|
.execution-operate-dialog__form.is-view :deep(.el-textarea__inner) {
|
||||||
|
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
|
||||||
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-operate-dialog__form.is-view :deep(.el-input__wrapper:hover),
|
||||||
|
.execution-operate-dialog__form.is-view :deep(.el-input__wrapper.is-focus),
|
||||||
|
.execution-operate-dialog__form.is-view :deep(.el-select__wrapper:hover),
|
||||||
|
.execution-operate-dialog__form.is-view :deep(.el-select__wrapper.is-focused),
|
||||||
|
.execution-operate-dialog__form.is-view :deep(.el-textarea__inner:hover),
|
||||||
|
.execution-operate-dialog__form.is-view :deep(.el-textarea__inner:focus) {
|
||||||
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-operate-dialog__form.is-view :deep(.el-input__inner),
|
||||||
|
.execution-operate-dialog__form.is-view :deep(.el-select__placeholder),
|
||||||
|
.execution-operate-dialog__form.is-view :deep(.el-select__selected-item),
|
||||||
|
.execution-operate-dialog__form.is-view :deep(.el-textarea__inner) {
|
||||||
|
color: rgb(51 65 85 / 96%);
|
||||||
|
cursor: default;
|
||||||
|
-webkit-text-fill-color: rgb(51 65 85 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-member-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-member-option__name {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: rgb(15 23 42 / 94%);
|
||||||
|
font-weight: 500;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-member-option__owner {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-member-option__role {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: 48%;
|
||||||
|
overflow: hidden;
|
||||||
|
color: rgb(100 116 139 / 88%);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||||
|
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||||
|
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
|
||||||
|
import { formatDateTime, isActiveExecutionMember } from '../shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectExecutionMemberCurrentPanel' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
execution: Api.Project.ProjectExecution | null;
|
||||||
|
members: Api.Project.ExecutionMember[];
|
||||||
|
userOptions: Api.SystemManage.UserSimple[];
|
||||||
|
loading: boolean;
|
||||||
|
canManageMember: boolean;
|
||||||
|
canChangeOwner: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'add', payload: Api.Project.CreateExecutionMemberParams): void;
|
||||||
|
(e: 'inactive', member: Api.Project.ExecutionMember, payload: Api.Project.InactiveExecutionMemberParams): void;
|
||||||
|
(e: 'change-owner', payload: Api.Project.ChangeExecutionOwnerParams): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const newMemberId = ref('');
|
||||||
|
|
||||||
|
const PAGE_SIZE = 5;
|
||||||
|
const currentPage = ref(1);
|
||||||
|
|
||||||
|
const pagedMembers = computed(() => {
|
||||||
|
const start = (currentPage.value - 1) * PAGE_SIZE;
|
||||||
|
return props.members.slice(start, start + PAGE_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.members.length,
|
||||||
|
total => {
|
||||||
|
const maxPage = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
if (currentPage.value > maxPage) {
|
||||||
|
currentPage.value = maxPage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { createRequiredRule } = useFormRules();
|
||||||
|
|
||||||
|
const inactiveTarget = ref<Api.Project.ExecutionMember | null>(null);
|
||||||
|
const inactiveModel = reactive({ reason: '' });
|
||||||
|
const { formRef: inactiveFormRef, validate: validateInactive } = useForm();
|
||||||
|
const inactiveRules = {
|
||||||
|
reason: [createRequiredRule('请输入失效原因')]
|
||||||
|
} satisfies Record<string, App.Global.FormRule[]>;
|
||||||
|
const inactiveVisible = computed({
|
||||||
|
get: () => Boolean(inactiveTarget.value),
|
||||||
|
set: value => {
|
||||||
|
if (!value) {
|
||||||
|
inactiveTarget.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerTarget = ref<Api.Project.ExecutionMember | null>(null);
|
||||||
|
const ownerModel = reactive({ reason: '' });
|
||||||
|
const ownerVisible = computed({
|
||||||
|
get: () => Boolean(ownerTarget.value),
|
||||||
|
set: value => {
|
||||||
|
if (!value) {
|
||||||
|
ownerTarget.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentOwnerId = computed(() => props.execution?.ownerId || '');
|
||||||
|
|
||||||
|
function isOwner(member: Api.Project.ExecutionMember) {
|
||||||
|
return Boolean(currentOwnerId.value) && member.userId === currentOwnerId.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveMember(member: Api.Project.ExecutionMember) {
|
||||||
|
return isActiveExecutionMember(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeMemberUserIds = computed(() =>
|
||||||
|
props.members.filter(item => isActiveExecutionMember(item)).map(item => item.userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const userNicknameMap = computed(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
props.userOptions.forEach(item => {
|
||||||
|
if (item.id) {
|
||||||
|
map.set(item.id, item.nickname?.trim() || item.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getMemberIndex(index: number) {
|
||||||
|
return (currentPage.value - 1) * PAGE_SIZE + index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemberDisplayName(member: Api.Project.ExecutionMember | null) {
|
||||||
|
if (!member) return '';
|
||||||
|
return member.userNickname?.trim() || userNicknameMap.value.get(member.userId) || member.userId || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMemberActions(row: Api.Project.ExecutionMember): BusinessTableAction[] {
|
||||||
|
const actions: BusinessTableAction[] = [];
|
||||||
|
|
||||||
|
if (props.canChangeOwner) {
|
||||||
|
actions.push({
|
||||||
|
key: 'set-owner',
|
||||||
|
label: '设为负责人',
|
||||||
|
buttonType: 'primary',
|
||||||
|
onClick: () => openOwner(row)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.canManageMember) {
|
||||||
|
actions.push({
|
||||||
|
key: 'inactive',
|
||||||
|
label: '失效',
|
||||||
|
buttonType: 'danger',
|
||||||
|
onClick: () => openInactive(row)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
if (!newMemberId.value) {
|
||||||
|
window.$message?.warning('请选择成员用户');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('add', { userId: newMemberId.value });
|
||||||
|
newMemberId.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openInactive(member: Api.Project.ExecutionMember) {
|
||||||
|
inactiveTarget.value = member;
|
||||||
|
inactiveModel.reason = '';
|
||||||
|
await nextTick();
|
||||||
|
inactiveFormRef.value?.clearValidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmInactive() {
|
||||||
|
await validateInactive();
|
||||||
|
|
||||||
|
if (!inactiveTarget.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('inactive', inactiveTarget.value, { reason: inactiveModel.reason.trim() });
|
||||||
|
inactiveTarget.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOwner(member: Api.Project.ExecutionMember) {
|
||||||
|
ownerTarget.value = member;
|
||||||
|
ownerModel.reason = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmOwner() {
|
||||||
|
if (!ownerTarget.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('change-owner', {
|
||||||
|
newOwnerId: ownerTarget.value.userId,
|
||||||
|
reason: ownerModel.reason.trim() || null
|
||||||
|
});
|
||||||
|
ownerTarget.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
newMemberId.value = '';
|
||||||
|
inactiveTarget.value = null;
|
||||||
|
inactiveModel.reason = '';
|
||||||
|
ownerTarget.value = null;
|
||||||
|
ownerModel.reason = '';
|
||||||
|
currentPage.value = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ reset });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-loading="loading" class="member-current-panel">
|
||||||
|
<div v-if="canManageMember" class="member-current-panel__toolbar">
|
||||||
|
<BusinessUserSelect
|
||||||
|
v-model="newMemberId"
|
||||||
|
:options="userOptions"
|
||||||
|
:exclude-user-ids="activeMemberUserIds"
|
||||||
|
no-data-text="所有项目成员已加入执行"
|
||||||
|
placeholder="选择用户加入执行"
|
||||||
|
class="member-current-panel__user-select"
|
||||||
|
/>
|
||||||
|
<ElButton type="primary" @click="handleAdd">新增成员</ElButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElTable :data="pagedMembers" :height="247" border row-key="id" size="default">
|
||||||
|
<ElTableColumn type="index" :index="getMemberIndex" label="序号" width="64" align="center" />
|
||||||
|
<ElTableColumn label="成员" width="200" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="member-current-panel__name">{{ getMemberDisplayName(row) }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="角色" width="140" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag v-if="isOwner(row)" type="warning" effect="light">负责人</ElTag>
|
||||||
|
<ElTag v-else type="info" effect="plain">成员</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="加入时间" min-width="200" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDateTime(row.joinedAt) }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="操作" width="220" align="center" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<BusinessTableActionCell v-if="!isOwner(row) && isActiveMember(row)" :actions="buildMemberActions(row)" />
|
||||||
|
<span v-else class="member-current-panel__actions-empty">--</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<ElEmpty description="当前执行暂无成员" :image-size="80" />
|
||||||
|
</template>
|
||||||
|
</ElTable>
|
||||||
|
|
||||||
|
<div class="member-current-panel__pagination">
|
||||||
|
<ElPagination
|
||||||
|
v-if="members.length > PAGE_SIZE"
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
:page-size="PAGE_SIZE"
|
||||||
|
:total="members.length"
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
background
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="inactiveVisible"
|
||||||
|
:title="`失效成员:${getMemberDisplayName(inactiveTarget)}`"
|
||||||
|
preset="sm"
|
||||||
|
append-to-body
|
||||||
|
@confirm="confirmInactive"
|
||||||
|
>
|
||||||
|
<ElForm
|
||||||
|
ref="inactiveFormRef"
|
||||||
|
:model="inactiveModel"
|
||||||
|
:rules="inactiveRules"
|
||||||
|
label-position="top"
|
||||||
|
:validate-on-rule-change="false"
|
||||||
|
>
|
||||||
|
<ElFormItem label="失效原因" prop="reason">
|
||||||
|
<ElInput
|
||||||
|
v-model="inactiveModel.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入失效原因"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="ownerVisible"
|
||||||
|
:title="`设为负责人:${getMemberDisplayName(ownerTarget)}`"
|
||||||
|
preset="sm"
|
||||||
|
append-to-body
|
||||||
|
@confirm="confirmOwner"
|
||||||
|
>
|
||||||
|
<ElForm :model="ownerModel" label-position="top">
|
||||||
|
<ElFormItem label="变更原因">
|
||||||
|
<ElInput
|
||||||
|
v-model="ownerModel.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="可选填写变更原因"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.member-current-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-current-panel__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-current-panel__user-select {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-current-panel__name {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-current-panel__actions-empty {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-current-panel__pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
260
src/views/project/project/execution/modules/member-log-panel.vue
Normal file
260
src/views/project/project/execution/modules/member-log-panel.vue
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue';
|
||||||
|
import { fetchGetProjectExecutionMemberLogPage } from '@/service/api';
|
||||||
|
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||||
|
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
|
||||||
|
import { formatDateTime, getExecutionMemberActionName, getExecutionMemberActionTagType } from '../shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectExecutionMemberLogPanel' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
executionId: string;
|
||||||
|
userOptions: Api.SystemManage.UserSimple[];
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
type ActionType = Api.Project.ExecutionMemberActionType;
|
||||||
|
|
||||||
|
const ACTION_TYPE_OPTIONS: Array<{ label: string; value: ActionType }> = [
|
||||||
|
{ label: getExecutionMemberActionName('join'), value: 'join' },
|
||||||
|
{ label: getExecutionMemberActionName('inactive'), value: 'inactive' },
|
||||||
|
{ label: getExecutionMemberActionName('owner_transfer_in'), value: 'owner_transfer_in' },
|
||||||
|
{ label: getExecutionMemberActionName('owner_transfer_out'), value: 'owner_transfer_out' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const searchParams = reactive<{
|
||||||
|
pageNo: number;
|
||||||
|
pageSize: number;
|
||||||
|
actionTypes?: ActionType[];
|
||||||
|
userId?: string;
|
||||||
|
}>({
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 5,
|
||||||
|
actionTypes: undefined,
|
||||||
|
userId: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const canLoad = computed(() => Boolean(props.projectId && props.executionId));
|
||||||
|
|
||||||
|
type LogPageResponse = Awaited<ReturnType<typeof fetchGetProjectExecutionMemberLogPage>>;
|
||||||
|
|
||||||
|
function buildRequestParams(): Api.Project.ExecutionMemberLogSearchParams {
|
||||||
|
return {
|
||||||
|
pageNo: searchParams.pageNo,
|
||||||
|
pageSize: searchParams.pageSize,
|
||||||
|
actionTypes: searchParams.actionTypes?.length ? searchParams.actionTypes : undefined,
|
||||||
|
userId: searchParams.userId || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformLogPage(response: LogPageResponse, pageNo: number, pageSize: number) {
|
||||||
|
if (!response.error && response.data) {
|
||||||
|
return {
|
||||||
|
data: response.data.list,
|
||||||
|
pageNum: pageNo,
|
||||||
|
pageSize,
|
||||||
|
total: response.data.total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
pageNum: pageNo,
|
||||||
|
pageSize,
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||||
|
LogPageResponse,
|
||||||
|
Api.Project.ExecutionMemberLog
|
||||||
|
>({
|
||||||
|
paginationProps: {
|
||||||
|
currentPage: searchParams.pageNo,
|
||||||
|
pageSize: searchParams.pageSize
|
||||||
|
},
|
||||||
|
api: () => {
|
||||||
|
if (!canLoad.value) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { total: 0, list: [] },
|
||||||
|
error: null
|
||||||
|
} as unknown as LogPageResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchGetProjectExecutionMemberLogPage(props.projectId, props.executionId, buildRequestParams());
|
||||||
|
},
|
||||||
|
transform: response => transformLogPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 5),
|
||||||
|
onPaginationParamsChange: params => {
|
||||||
|
searchParams.pageNo = params.currentPage ?? 1;
|
||||||
|
searchParams.pageSize = params.pageSize ?? 5;
|
||||||
|
},
|
||||||
|
immediate: false,
|
||||||
|
columns: () => [{ prop: 'actionTime', label: '时间' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 每次切到该 tab 都按当前筛选条件重拉第 1 页,确保用户在"当前成员" tab 操作后回到这里能看到最新事件
|
||||||
|
watch(
|
||||||
|
() => props.active,
|
||||||
|
active => {
|
||||||
|
if (active && canLoad.value) {
|
||||||
|
getDataByPage(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 切换到不同执行项时完全清空筛选条件
|
||||||
|
watch(
|
||||||
|
() => props.executionId,
|
||||||
|
() => {
|
||||||
|
resetSearchParams();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function resetSearchParams() {
|
||||||
|
searchParams.pageNo = 1;
|
||||||
|
searchParams.actionTypes = undefined;
|
||||||
|
searchParams.userId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch() {
|
||||||
|
await getDataByPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset() {
|
||||||
|
resetSearchParams();
|
||||||
|
await getDataByPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemberDisplay(row: Api.Project.ExecutionMemberLog) {
|
||||||
|
return row.userNicknameSnapshot?.trim() || row.userId || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOperatorDisplay(row: Api.Project.ExecutionMemberLog) {
|
||||||
|
return row.operatorNicknameSnapshot?.trim() || row.operatorUserId || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
return getDataByPage(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ refresh });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="member-log-panel">
|
||||||
|
<div class="member-log-panel__toolbar">
|
||||||
|
<ElSelect
|
||||||
|
v-model="searchParams.actionTypes"
|
||||||
|
multiple
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
clearable
|
||||||
|
placeholder="全部事件"
|
||||||
|
class="member-log-panel__action-select"
|
||||||
|
>
|
||||||
|
<ElOption v-for="item in ACTION_TYPE_OPTIONS" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</ElSelect>
|
||||||
|
<BusinessUserSelect
|
||||||
|
v-model="searchParams.userId"
|
||||||
|
:options="userOptions"
|
||||||
|
placeholder="全部成员"
|
||||||
|
clearable
|
||||||
|
class="member-log-panel__user-select"
|
||||||
|
/>
|
||||||
|
<div class="member-log-panel__actions">
|
||||||
|
<ElButton @click="handleReset">重置</ElButton>
|
||||||
|
<ElButton type="primary" @click="handleSearch">查询</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElTable v-loading="loading" :data="data" :height="247" border size="default">
|
||||||
|
<ElTableColumn label="时间" width="170" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatDateTime(row.actionTime) }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="事件类型" width="130" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag :type="getExecutionMemberActionTagType(row.actionType)" effect="light">
|
||||||
|
{{ getExecutionMemberActionName(row.actionType) }}
|
||||||
|
</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="成员" min-width="120" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ getMemberDisplay(row) }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="操作人" min-width="120" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ getOperatorDisplay(row) }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="原因" min-width="200" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="row.reason">{{ row.reason }}</span>
|
||||||
|
<span v-else class="member-log-panel__empty">--</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
|
||||||
|
<template #empty>
|
||||||
|
<ElEmpty description="暂无变更记录" :image-size="80" />
|
||||||
|
</template>
|
||||||
|
</ElTable>
|
||||||
|
|
||||||
|
<div class="member-log-panel__pagination">
|
||||||
|
<ElPagination
|
||||||
|
v-if="mobilePagination.total"
|
||||||
|
background
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
small
|
||||||
|
v-bind="mobilePagination"
|
||||||
|
@current-change="mobilePagination['current-change']"
|
||||||
|
@size-change="mobilePagination['size-change']"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.member-log-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-log-panel__toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-log-panel__action-select {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-log-panel__user-select {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-log-panel__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-log-panel__empty {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-log-panel__pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<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: 'ProjectExecutionStatusActionDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
action: Api.Project.LifecycleAction<string> | 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="title" 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>
|
||||||
242
src/views/project/project/execution/modules/task-board-view.vue
Normal file
242
src/views/project/project/execution/modules/task-board-view.vue
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Edit, Flag, User } from '@element-plus/icons-vue';
|
||||||
|
import { formatDate, getProgressText, getTaskStatusName } from '../shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectExecutionTaskBoardView' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: Api.Project.ProjectTask[];
|
||||||
|
loading: boolean;
|
||||||
|
statusBoard: Api.Project.StatusBoard | null;
|
||||||
|
canUpdate: boolean;
|
||||||
|
canChangeStatus: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'detail', row: Api.Project.ProjectTask): void;
|
||||||
|
(e: 'edit', row: Api.Project.ProjectTask): void;
|
||||||
|
(
|
||||||
|
e: 'status-action',
|
||||||
|
row: Api.Project.ProjectTask,
|
||||||
|
action: Api.Project.LifecycleAction<Api.Project.ProjectTaskActionCode> | null
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const groupedTasks = computed(() => {
|
||||||
|
const map = new Map<string, Api.Project.ProjectTask[]>();
|
||||||
|
const items = props.statusBoard?.items ?? [];
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
map.set(item.statusCode, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
props.data.forEach(task => {
|
||||||
|
const list = map.get(task.statusCode);
|
||||||
|
|
||||||
|
if (list) {
|
||||||
|
list.push(task);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return items.map(item => ({
|
||||||
|
statusCode: item.statusCode,
|
||||||
|
title: item.statusName,
|
||||||
|
count: item.count,
|
||||||
|
tasks: map.get(item.statusCode) || []
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
function getFirstAction(row: Api.Project.ProjectTask) {
|
||||||
|
return row.availableActions[0] || null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElCard class="task-board-card" body-class="task-board-card__body">
|
||||||
|
<ElSkeleton v-if="loading" :rows="6" animated />
|
||||||
|
<div v-else class="task-board">
|
||||||
|
<section v-for="column in groupedTasks" :key="column.statusCode" class="task-board-column">
|
||||||
|
<header class="task-board-column__header">
|
||||||
|
<strong>{{ column.title }}</strong>
|
||||||
|
<ElTag effect="plain" size="small">{{ column.count }}</ElTag>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ElScrollbar class="task-board-column__scrollbar">
|
||||||
|
<ElEmpty v-if="column.tasks.length === 0" :description="`暂无${column.title}任务`" />
|
||||||
|
<template v-else>
|
||||||
|
<article
|
||||||
|
v-for="task in column.tasks"
|
||||||
|
:key="task.id"
|
||||||
|
class="task-board-card-item"
|
||||||
|
@click="emit('detail', task)"
|
||||||
|
>
|
||||||
|
<div class="task-board-card-item__top">
|
||||||
|
<strong class="task-board-card-item__title">{{ task.taskTitle || '未命名任务' }}</strong>
|
||||||
|
<ElTag effect="plain" size="small">{{ getTaskStatusName(task) }}</ElTag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-board-card-item__meta">
|
||||||
|
<span>
|
||||||
|
<ElIcon><User /></ElIcon>
|
||||||
|
{{ task.ownerNickname || task.ownerId || '未设置负责人' }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<ElIcon><Flag /></ElIcon>
|
||||||
|
计划结束 {{ formatDate(task.plannedEndDate) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-board-card-item__progress">
|
||||||
|
<span>进度 {{ getProgressText(task.progressRate) }}</span>
|
||||||
|
<ElProgress :percentage="task.progressRate" :stroke-width="6" :show-text="false" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-board-card-item__actions" @click.stop>
|
||||||
|
<ElButton v-if="canUpdate" size="small" plain :icon="Edit" @click="emit('edit', task)">编辑</ElButton>
|
||||||
|
<ElButton
|
||||||
|
v-if="canChangeStatus && getFirstAction(task)"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
@click="emit('status-action', task, getFirstAction(task)!)"
|
||||||
|
>
|
||||||
|
{{ getFirstAction(task)!.actionName }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
v-else-if="canChangeStatus"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
@click="emit('status-action', task, null)"
|
||||||
|
>
|
||||||
|
状态
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
</ElScrollbar>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.task-board-card {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.task-board-card__body) {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-board-column {
|
||||||
|
display: flex;
|
||||||
|
min-width: 220px;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid rgb(226 232 240 / 92%);
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: rgb(248 250 252 / 82%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-board-column__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid rgb(226 232 240 / 92%);
|
||||||
|
color: rgb(15 23 42 / 94%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-board-column__scrollbar {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-board-card-item {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 92%);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background-color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.16s ease,
|
||||||
|
box-shadow 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-board-card-item:hover {
|
||||||
|
border-color: rgb(148 163 184 / 76%);
|
||||||
|
box-shadow: 0 6px 18px rgb(15 23 42 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-board-card-item__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-board-card-item__title {
|
||||||
|
min-width: 0;
|
||||||
|
color: rgb(15 23 42 / 94%);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-board-card-item__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: rgb(100 116 139 / 94%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-board-card-item__meta span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-board-card-item__progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
color: rgb(71 85 105 / 96%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-board-card-item__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-board-card-item__actions :deep(.el-button + .el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||||
|
import { formatDate, formatDateTime, getProgressText, getTaskStatusName } from '../shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectExecutionTaskDetailDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rowData: Api.Project.ProjectTask | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const detailItems = computed(() => {
|
||||||
|
const row = props.rowData;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: '任务名称', value: row?.taskTitle || '--' },
|
||||||
|
{ label: '状态', value: row ? getTaskStatusName(row) : '--' },
|
||||||
|
{ label: '负责人', value: row?.ownerNickname || row?.ownerId || '--' },
|
||||||
|
{ label: '进度', value: getProgressText(row?.progressRate) },
|
||||||
|
{ label: '计划开始日期', value: formatDate(row?.plannedStartDate) },
|
||||||
|
{ label: '计划结束日期', value: formatDate(row?.plannedEndDate) },
|
||||||
|
{ label: '实际开始日期', value: formatDate(row?.actualStartDate) },
|
||||||
|
{ label: '实际结束日期', value: formatDate(row?.actualEndDate) },
|
||||||
|
{ label: '最近更新', value: formatDateTime(row?.updateTime) },
|
||||||
|
{ label: '状态原因', value: row?.lastStatusReason || '--', span: 2 },
|
||||||
|
{ label: '任务说明', value: row?.taskDesc || '--', span: 2 }
|
||||||
|
];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog v-model="visible" title="任务详情" preset="md" :show-footer="false">
|
||||||
|
<BusinessFormSection title="任务信息">
|
||||||
|
<ElDescriptions :column="2" border>
|
||||||
|
<ElDescriptionsItem v-for="item in detailItems" :key="item.label" :label="item.label" :span="item.span || 1">
|
||||||
|
<span class="task-detail-text">{{ item.value }}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
</BusinessFormSection>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.task-detail-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||||
|
import { useResizeObserver } from '@vueuse/core';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||||
|
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 BusinessUserSelect from '@/components/custom/business-user-select.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectExecutionTaskOperateDialog' });
|
||||||
|
|
||||||
|
type OperateMode = 'create' | 'edit';
|
||||||
|
|
||||||
|
export interface PlannedEndShortcutOffset {
|
||||||
|
text: string;
|
||||||
|
days?: number;
|
||||||
|
months?: number;
|
||||||
|
years?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: OperateMode;
|
||||||
|
rowData: Api.Project.ProjectTask | null;
|
||||||
|
userOptions: Api.SystemManage.UserSimple[];
|
||||||
|
taskOptions: Api.Project.ProjectTask[];
|
||||||
|
plannedEndShortcuts?: PlannedEndShortcutOffset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submit', payload: Api.Project.SaveProjectTaskParams): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
plannedEndShortcuts: () => [
|
||||||
|
{ text: '三天', days: 3 },
|
||||||
|
{ text: '一星期', days: 7 },
|
||||||
|
{ text: '两星期', days: 14 },
|
||||||
|
{ text: '一个月', months: 1 },
|
||||||
|
{ text: '三个月', months: 3 }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const { formRef, validate } = useForm();
|
||||||
|
const { createRequiredRule } = useFormRules();
|
||||||
|
|
||||||
|
interface FormModel {
|
||||||
|
parentTaskId: string | null;
|
||||||
|
taskTitle: string;
|
||||||
|
ownerId: string | null;
|
||||||
|
plannedStartDate: string | null;
|
||||||
|
plannedEndDate: string | null;
|
||||||
|
taskDesc: string | null;
|
||||||
|
assigneeUserIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = reactive<FormModel>({
|
||||||
|
parentTaskId: null,
|
||||||
|
taskTitle: '',
|
||||||
|
ownerId: null,
|
||||||
|
plannedStartDate: null,
|
||||||
|
plannedEndDate: null,
|
||||||
|
taskDesc: null,
|
||||||
|
assigneeUserIds: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => (props.mode === 'create' ? '新建任务' : '编辑任务'));
|
||||||
|
|
||||||
|
const selectableParentTasks = computed(() => props.taskOptions.filter(item => item.id !== props.rowData?.id));
|
||||||
|
|
||||||
|
/** 左栏容器 ref:用其高度动态驱动右侧富文本,让两栏视觉等高 */
|
||||||
|
const leftColRef = ref<HTMLElement>();
|
||||||
|
const editorHeight = ref<string>('45vh');
|
||||||
|
|
||||||
|
useResizeObserver(leftColRef, entries => {
|
||||||
|
const h = entries[0]?.contentRect.height;
|
||||||
|
if (h && h > 120) {
|
||||||
|
editorHeight.value = `${Math.max(h - 60, 240)}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
taskTitle: [createRequiredRule('请输入任务名称')],
|
||||||
|
ownerId: model.parentTaskId ? [] : [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[]>
|
||||||
|
);
|
||||||
|
|
||||||
|
function parsePlannedDate(value: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlannedDateRangeValid(startDate: string | null, endDate: string | null) {
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !dayjs(endDate).isBefore(dayjs(startDate), 'day');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOffset(date: Date, offset: PlannedEndShortcutOffset) {
|
||||||
|
if (offset.days) {
|
||||||
|
date.setDate(date.getDate() + offset.days);
|
||||||
|
}
|
||||||
|
if (offset.months) {
|
||||||
|
date.setMonth(date.getMonth() + offset.months);
|
||||||
|
}
|
||||||
|
if (offset.years) {
|
||||||
|
date.setFullYear(date.getFullYear() + offset.years);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const plannedEndDateShortcuts = computed(() =>
|
||||||
|
props.plannedEndShortcuts.map(offset => ({
|
||||||
|
text: offset.text,
|
||||||
|
value: () => {
|
||||||
|
let startDate = parsePlannedDate(model.plannedStartDate);
|
||||||
|
|
||||||
|
if (!startDate) {
|
||||||
|
startDate = new Date();
|
||||||
|
model.plannedStartDate = dayjs(startDate).format('YYYY-MM-DD');
|
||||||
|
window.$message?.info('未选择计划开始日期,已按今日为基准计算');
|
||||||
|
nextTick(() => formRef.value?.clearValidate('plannedStartDate'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDate = new Date(startDate.getTime());
|
||||||
|
applyOffset(endDate, offset);
|
||||||
|
return endDate;
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
function normalizeAssigneeIds(ids: string[]) {
|
||||||
|
return Array.from(new Set(ids.filter(id => id && id !== model.ownerId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
await validate();
|
||||||
|
|
||||||
|
const payload: Api.Project.SaveProjectTaskParams = {
|
||||||
|
parentTaskId: model.parentTaskId || null,
|
||||||
|
taskTitle: model.taskTitle.trim(),
|
||||||
|
ownerId: model.ownerId || null,
|
||||||
|
plannedStartDate: model.plannedStartDate,
|
||||||
|
plannedEndDate: model.plannedEndDate,
|
||||||
|
taskDesc: isEmptyRichText(model.taskDesc) ? null : (model.taskDesc ?? null)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (props.mode === 'create') {
|
||||||
|
payload.assigneeUserIds = normalizeAssigneeIds(model.assigneeUserIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('submit', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAssigneeChange(value: string[]) {
|
||||||
|
model.assigneeUserIds = normalizeAssigneeIds(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
async value => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.parentTaskId = props.rowData?.parentTaskId || null;
|
||||||
|
model.taskTitle = props.rowData?.taskTitle || '';
|
||||||
|
model.ownerId = props.rowData?.ownerId || null;
|
||||||
|
model.plannedStartDate = props.rowData?.plannedStartDate || null;
|
||||||
|
model.plannedEndDate = props.rowData?.plannedEndDate || null;
|
||||||
|
model.taskDesc = props.rowData?.taskDesc || null;
|
||||||
|
model.assigneeUserIds = [];
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => model.ownerId,
|
||||||
|
() => {
|
||||||
|
if (props.mode === 'create') {
|
||||||
|
model.assigneeUserIds = normalizeAssigneeIds(model.assigneeUserIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="dialogTitle"
|
||||||
|
width="1100px"
|
||||||
|
max-body-height="78vh"
|
||||||
|
@confirm="handleConfirm"
|
||||||
|
>
|
||||||
|
<ElForm
|
||||||
|
ref="formRef"
|
||||||
|
:model="model"
|
||||||
|
:rules="rules"
|
||||||
|
label-position="top"
|
||||||
|
:validate-on-rule-change="false"
|
||||||
|
class="task-operate-dialog__form"
|
||||||
|
>
|
||||||
|
<div class="task-operate-dialog__grid">
|
||||||
|
<div ref="leftColRef" class="task-operate-dialog__col-left">
|
||||||
|
<BusinessFormSection title="任务信息">
|
||||||
|
<ElFormItem label="任务名称" prop="taskTitle">
|
||||||
|
<ElInput v-model="model.taskTitle" maxlength="200" placeholder="请输入任务名称" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="父任务">
|
||||||
|
<ElSelect v-model="model.parentTaskId" clearable filterable class="w-full" placeholder="请选择父任务">
|
||||||
|
<ElOption
|
||||||
|
v-for="item in selectableParentTasks"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.taskTitle"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="负责人" prop="ownerId">
|
||||||
|
<BusinessUserSelect v-model="model.ownerId" :options="userOptions" placeholder="请选择负责人" />
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem v-if="mode === 'create'" label="协办人" prop="assigneeUserIds">
|
||||||
|
<ElSelect
|
||||||
|
v-model="model.assigneeUserIds"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
:max-collapse-tags="2"
|
||||||
|
class="w-full"
|
||||||
|
placeholder="请选择协办人"
|
||||||
|
@change="handleAssigneeChange"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="item in userOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.id === model.ownerId ? `${item.nickname}(负责人)` : item.nickname"
|
||||||
|
:value="item.id"
|
||||||
|
:disabled="item.id === model.ownerId"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="计划开始日期" prop="plannedStartDate">
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="model.plannedStartDate"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="选择计划开始日期"
|
||||||
|
class="task-operate-dialog__date-picker"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
|
<ElFormItem label="计划结束日期" prop="plannedEndDate">
|
||||||
|
<ElDatePicker
|
||||||
|
v-model="model.plannedEndDate"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="选择计划结束日期"
|
||||||
|
:shortcuts="plannedEndDateShortcuts"
|
||||||
|
class="task-operate-dialog__date-picker"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</BusinessFormSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-operate-dialog__col-right">
|
||||||
|
<BusinessFormSection title="任务说明">
|
||||||
|
<ElFormItem class="task-operate-dialog__desc-item">
|
||||||
|
<BusinessRichTextEditor
|
||||||
|
v-model="model.taskDesc"
|
||||||
|
:height="editorHeight"
|
||||||
|
upload-directory="task"
|
||||||
|
placeholder="请输入任务说明"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</BusinessFormSection>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.task-operate-dialog__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 360px 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-operate-dialog__col-left,
|
||||||
|
.task-operate-dialog__col-right {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-operate-dialog__col-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-operate-dialog__desc-item {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1024px) {
|
||||||
|
.task-operate-dialog__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.task-operate-dialog__date-picker.el-date-editor.el-input) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
75
src/views/project/project/execution/modules/task-search.vue
Normal file
75
src/views/project/project/execution/modules/task-search.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectExecutionTaskSearch' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
model: Api.Project.ProjectTaskSearchParams;
|
||||||
|
userOptions: Api.SystemManage.UserSimple[];
|
||||||
|
statusOptions: Api.Project.StatusBoardItem[];
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'search'): void;
|
||||||
|
(e: 'reset'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
disabled: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const ownerOptions = computed(() =>
|
||||||
|
props.userOptions.map(item => ({
|
||||||
|
label: item.nickname,
|
||||||
|
value: item.id
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const fields = computed<SearchField[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'keyword',
|
||||||
|
label: '关键词',
|
||||||
|
type: 'input',
|
||||||
|
placeholder: '任务名称/说明'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'statusCode',
|
||||||
|
label: '状态',
|
||||||
|
type: 'select',
|
||||||
|
options: props.statusOptions.map(item => ({
|
||||||
|
label: item.statusName,
|
||||||
|
value: item.statusCode
|
||||||
|
})),
|
||||||
|
placeholder: '全部状态'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ownerId',
|
||||||
|
label: '负责人',
|
||||||
|
type: 'select',
|
||||||
|
options: ownerOptions.value,
|
||||||
|
placeholder: '全部负责人'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'updateTime',
|
||||||
|
label: '更新时间',
|
||||||
|
type: 'dateRange'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TableSearchFields
|
||||||
|
:model-value="model"
|
||||||
|
:fields="fields"
|
||||||
|
:columns="4"
|
||||||
|
:disabled="disabled"
|
||||||
|
@search="emit('search')"
|
||||||
|
@reset="emit('reset')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
210
src/views/project/project/execution/modules/task-table-view.vue
Normal file
210
src/views/project/project/execution/modules/task-table-view.vue
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { PaginationProps } from 'element-plus';
|
||||||
|
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||||
|
import { formatDateRange, formatDateTime, getTaskStatusName, getTaskStatusTagType } from '../shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectExecutionTaskTableView' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: Api.Project.ProjectTask[];
|
||||||
|
loading: boolean;
|
||||||
|
pagination: Partial<PaginationProps & Record<string, any>>;
|
||||||
|
canUpdate: boolean;
|
||||||
|
canChangeStatus: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'detail', row: Api.Project.ProjectTask): void;
|
||||||
|
(e: 'edit', row: Api.Project.ProjectTask): void;
|
||||||
|
(
|
||||||
|
e: 'status-action',
|
||||||
|
row: Api.Project.ProjectTask,
|
||||||
|
action: Api.Project.LifecycleAction<Api.Project.ProjectTaskActionCode> | null
|
||||||
|
): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const paginationVisible = computed(() => Boolean(props.pagination.total));
|
||||||
|
|
||||||
|
const taskTitleMap = computed(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
props.data.forEach(item => {
|
||||||
|
if (item.id && item.taskTitle) {
|
||||||
|
map.set(item.id, item.taskTitle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getParentTaskLabel(parentTaskId: string | null) {
|
||||||
|
if (!parentTaskId) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
return taskTitleMap.value.get(parentTaskId) || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createActions(row: Api.Project.ProjectTask): BusinessTableAction[] {
|
||||||
|
const actions: BusinessTableAction[] = [
|
||||||
|
{
|
||||||
|
key: 'detail',
|
||||||
|
label: '详情',
|
||||||
|
onClick: () => emit('detail', row)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (props.canUpdate) {
|
||||||
|
actions.push({
|
||||||
|
key: 'edit',
|
||||||
|
label: '编辑',
|
||||||
|
onClick: () => emit('edit', row)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.canChangeStatus) {
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.availableActions.length) {
|
||||||
|
return [
|
||||||
|
...actions,
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: '状态',
|
||||||
|
buttonType: 'primary',
|
||||||
|
onClick: () => emit('status-action', row, null)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...actions,
|
||||||
|
...row.availableActions.map(action => ({
|
||||||
|
key: `status-${action.actionCode}`,
|
||||||
|
label: action.actionName,
|
||||||
|
buttonType: 'primary' as const,
|
||||||
|
onClick: () => emit('status-action', row, action)
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(page: number) {
|
||||||
|
props.pagination['current-change']?.(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSizeChange(pageSize: number) {
|
||||||
|
props.pagination['size-change']?.(pageSize);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElCard class="task-table-card" body-class="business-table-card-body">
|
||||||
|
<div class="flex-1">
|
||||||
|
<ElTable
|
||||||
|
v-loading="loading"
|
||||||
|
:data="data"
|
||||||
|
height="100%"
|
||||||
|
border
|
||||||
|
row-key="id"
|
||||||
|
highlight-current-row
|
||||||
|
@row-dblclick="row => emit('detail', row)"
|
||||||
|
>
|
||||||
|
<ElTableColumn type="index" label="序号" width="64" align="center" />
|
||||||
|
<ElTableColumn label="任务名称" min-width="220" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElButton link type="primary" class="task-title-link" @click="emit('detail', row)">
|
||||||
|
{{ row.taskTitle || '--' }}
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag effect="plain" :type="getTaskStatusTagType(row.statusCode)">{{ getTaskStatusName(row) }}</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="负责人" min-width="120" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.ownerNickname || row.ownerId || '--' }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="父任务" min-width="140" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ getParentTaskLabel(row.parentTaskId) }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="进度" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="task-table-progress">
|
||||||
|
<ElProgress :percentage="row.progressRate" :stroke-width="6" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="计划周期" min-width="190" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ formatDateRange(row.plannedStartDate, row.plannedEndDate) }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="实际周期" min-width="190" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ formatDateRange(row.actualStartDate, row.actualEndDate) }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="最近更新" width="170">
|
||||||
|
<template #default="{ row }">{{ formatDateTime(row.updateTime) }}</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="操作" width="220" fixed="right" align="center" class-name="task-operate-column">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<BusinessTableActionCell :actions="createActions(row)" />
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="paginationVisible" class="task-table-pagination">
|
||||||
|
<ElPagination
|
||||||
|
background
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
v-bind="pagination"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.task-table-card {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-title-link {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-progress {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row.current-row > td.el-table__cell) {
|
||||||
|
background-color: rgb(64 158 255 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.task-operate-column) {
|
||||||
|
background-color: var(--el-bg-color, #ffffff) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__row.current-row) {
|
||||||
|
.task-operate-column {
|
||||||
|
background-color: var(--el-bg-color, #ffffff) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.el-table__cell.is-fixed-right {
|
||||||
|
background-color: var(--el-bg-color, #ffffff) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
444
src/views/project/project/execution/modules/task-workspace.vue
Normal file
444
src/views/project/project/execution/modules/task-workspace.vue
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, markRaw, reactive, ref, watch } from 'vue';
|
||||||
|
import { Plus } from '@element-plus/icons-vue';
|
||||||
|
import {
|
||||||
|
fetchChangeProjectTaskStatus,
|
||||||
|
fetchCreateProjectTask,
|
||||||
|
fetchGetProjectTask,
|
||||||
|
fetchGetProjectTaskPage,
|
||||||
|
fetchGetProjectTaskStatusBoard,
|
||||||
|
fetchUpdateProjectTask
|
||||||
|
} from '@/service/api/project';
|
||||||
|
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||||
|
import { shouldRequireTaskProgressBeforeComplete } from '../shared';
|
||||||
|
import StatusActionDialog from './status-action-dialog.vue';
|
||||||
|
import TaskBoardView from './task-board-view.vue';
|
||||||
|
import TaskDetailDialog from './task-detail-dialog.vue';
|
||||||
|
import TaskOperateDialog from './task-operate-dialog.vue';
|
||||||
|
import TaskSearch from './task-search.vue';
|
||||||
|
import TaskTableView from './task-table-view.vue';
|
||||||
|
import IconMdiViewColumnOutline from '~icons/mdi/view-column-outline';
|
||||||
|
import IconMdiTableLarge from '~icons/mdi/table-large';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectExecutionTaskWorkspace' });
|
||||||
|
|
||||||
|
type ViewMode = 'table' | 'board';
|
||||||
|
type OperateMode = 'create' | 'edit';
|
||||||
|
type TaskPageResponse = Awaited<ReturnType<typeof fetchGetProjectTaskPage>>;
|
||||||
|
type TaskStatusAction = Api.Project.LifecycleAction<Api.Project.ProjectTaskActionCode>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
execution: Api.Project.ProjectExecution | null;
|
||||||
|
ownerOptions: Api.SystemManage.UserSimple[];
|
||||||
|
canCreate: boolean;
|
||||||
|
canUpdate: boolean;
|
||||||
|
canChangeStatus: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const viewMode = ref<ViewMode>('table');
|
||||||
|
const viewModeOptions = [
|
||||||
|
{ label: '表格', value: 'table', icon: markRaw(IconMdiTableLarge) },
|
||||||
|
{ label: '看板', value: 'board', icon: markRaw(IconMdiViewColumnOutline) }
|
||||||
|
];
|
||||||
|
const operateVisible = ref(false);
|
||||||
|
const detailVisible = ref(false);
|
||||||
|
const statusActionVisible = ref(false);
|
||||||
|
const operateMode = ref<OperateMode>('create');
|
||||||
|
const currentTask = ref<Api.Project.ProjectTask | null>(null);
|
||||||
|
const currentStatusAction = ref<TaskStatusAction | null>(null);
|
||||||
|
const taskStatusBoard = ref<Api.Project.StatusBoard | null>(null);
|
||||||
|
|
||||||
|
const searchParams = reactive<Api.Project.ProjectTaskSearchParams>({
|
||||||
|
pageNo: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
keyword: '',
|
||||||
|
parentTaskId: undefined,
|
||||||
|
ownerId: undefined,
|
||||||
|
statusCode: undefined,
|
||||||
|
updateTime: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const executionId = computed(() => props.execution?.id || '');
|
||||||
|
|
||||||
|
const canLoadTasks = computed(() => Boolean(props.projectId && executionId.value));
|
||||||
|
|
||||||
|
const statusActionTitle = computed(() =>
|
||||||
|
currentStatusAction.value ? `任务状态变更:${currentStatusAction.value.actionName}` : '任务状态变更'
|
||||||
|
);
|
||||||
|
|
||||||
|
function createRequestParams(): Api.Project.ProjectTaskSearchParams {
|
||||||
|
return {
|
||||||
|
...searchParams,
|
||||||
|
keyword: searchParams.keyword?.trim() || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStatusBoardParams(): Api.Project.ProjectTaskStatusBoardParams {
|
||||||
|
return {
|
||||||
|
keyword: searchParams.keyword?.trim() || undefined,
|
||||||
|
parentTaskId: searchParams.parentTaskId,
|
||||||
|
ownerId: searchParams.ownerId,
|
||||||
|
updateTime: searchParams.updateTime
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformTaskPage(response: TaskPageResponse, pageNo: number, pageSize: number) {
|
||||||
|
if (!response.error && response.data) {
|
||||||
|
return {
|
||||||
|
data: response.data.list,
|
||||||
|
pageNum: pageNo,
|
||||||
|
pageSize,
|
||||||
|
total: response.data.total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
pageNum: pageNo,
|
||||||
|
pageSize,
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||||
|
TaskPageResponse,
|
||||||
|
Api.Project.ProjectTask
|
||||||
|
>({
|
||||||
|
paginationProps: {
|
||||||
|
currentPage: searchParams.pageNo,
|
||||||
|
pageSize: searchParams.pageSize
|
||||||
|
},
|
||||||
|
api: () => {
|
||||||
|
if (!canLoadTasks.value) {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { total: 0, list: [] },
|
||||||
|
error: null
|
||||||
|
} as unknown as TaskPageResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchGetProjectTaskPage(props.projectId, executionId.value, createRequestParams());
|
||||||
|
},
|
||||||
|
transform: response => transformTaskPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||||
|
onPaginationParamsChange: params => {
|
||||||
|
searchParams.pageNo = params.currentPage ?? 1;
|
||||||
|
searchParams.pageSize = params.pageSize ?? 10;
|
||||||
|
},
|
||||||
|
columns: () => [{ prop: 'taskTitle', label: '任务名称', minWidth: 160 }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskOptions = computed(() => data.value);
|
||||||
|
|
||||||
|
function resetSearchParams() {
|
||||||
|
searchParams.pageNo = 1;
|
||||||
|
searchParams.keyword = '';
|
||||||
|
searchParams.parentTaskId = undefined;
|
||||||
|
searchParams.ownerId = undefined;
|
||||||
|
searchParams.statusCode = undefined;
|
||||||
|
searchParams.updateTime = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSearch() {
|
||||||
|
await Promise.all([getDataByPage(1), loadTaskStatusBoard()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReset() {
|
||||||
|
resetSearchParams();
|
||||||
|
await Promise.all([getDataByPage(1), loadTaskStatusBoard()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
if (!props.execution) {
|
||||||
|
window.$message?.warning('请先选择执行项');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
operateMode.value = 'create';
|
||||||
|
currentTask.value = null;
|
||||||
|
operateVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTaskDetail(row: Api.Project.ProjectTask) {
|
||||||
|
if (!props.execution) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetchGetProjectTask(props.projectId, props.execution.id, row.id);
|
||||||
|
|
||||||
|
return result.error || !result.data ? row : result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEdit(row: Api.Project.ProjectTask) {
|
||||||
|
const detail = await getTaskDetail(row);
|
||||||
|
|
||||||
|
if (!detail.allowEdit) {
|
||||||
|
window.$message?.warning('当前任务状态不允许编辑');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
operateMode.value = 'edit';
|
||||||
|
currentTask.value = detail;
|
||||||
|
operateVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDetail(row: Api.Project.ProjectTask) {
|
||||||
|
currentTask.value = await getTaskDetail(row);
|
||||||
|
detailVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusAction(row: Api.Project.ProjectTask, action: TaskStatusAction | null) {
|
||||||
|
const detail = await getTaskDetail(row);
|
||||||
|
const targetAction = action || detail.availableActions[0] || null;
|
||||||
|
|
||||||
|
if (!targetAction) {
|
||||||
|
window.$message?.warning('当前任务暂无可用状态操作');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRequireTaskProgressBeforeComplete(targetAction, detail)) {
|
||||||
|
window.$message?.warning('完成任务前请先将进度调整为 100%');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTask.value = detail;
|
||||||
|
currentStatusAction.value = targetAction;
|
||||||
|
statusActionVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOperateSubmit(payload: Api.Project.SaveProjectTaskParams) {
|
||||||
|
if (!props.execution) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operateMode.value !== 'create' && !currentTask.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result =
|
||||||
|
operateMode.value === 'create'
|
||||||
|
? await fetchCreateProjectTask(props.projectId, props.execution.id, payload)
|
||||||
|
: await fetchUpdateProjectTask(props.projectId, props.execution.id, {
|
||||||
|
taskId: currentTask.value!.id,
|
||||||
|
data: payload
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operateMode.value === 'create') {
|
||||||
|
window.$message?.success('任务创建成功');
|
||||||
|
} else {
|
||||||
|
window.$message?.success('任务更新成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
operateVisible.value = false;
|
||||||
|
await getData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusSubmit(reason: string | null) {
|
||||||
|
if (!props.execution || !currentTask.value || !currentStatusAction.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetchChangeProjectTaskStatus(props.projectId, props.execution.id, {
|
||||||
|
taskId: currentTask.value.id,
|
||||||
|
data: {
|
||||||
|
actionCode: currentStatusAction.value.actionCode,
|
||||||
|
reason
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success('任务状态已更新');
|
||||||
|
statusActionVisible.value = false;
|
||||||
|
await Promise.all([getData(), loadTaskStatusBoard()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTaskStatusBoard() {
|
||||||
|
if (!canLoadTasks.value) {
|
||||||
|
taskStatusBoard.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error, data: board } = await fetchGetProjectTaskStatusBoard(
|
||||||
|
props.projectId,
|
||||||
|
executionId.value,
|
||||||
|
createStatusBoardParams()
|
||||||
|
);
|
||||||
|
|
||||||
|
taskStatusBoard.value = error || !board ? null : board;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.execution?.id,
|
||||||
|
async value => {
|
||||||
|
resetSearchParams();
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
data.value = [];
|
||||||
|
taskStatusBoard.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([getDataByPage(1), loadTaskStatusBoard()]);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="task-workspace">
|
||||||
|
<header class="task-workspace__header">
|
||||||
|
<h3 class="task-workspace__title">任务</h3>
|
||||||
|
|
||||||
|
<div class="task-workspace__actions">
|
||||||
|
<ElSegmented v-model="viewMode" class="task-view-toggle" size="default" :options="viewModeOptions">
|
||||||
|
<template #default="{ item }">
|
||||||
|
<span class="task-view-toggle__item">
|
||||||
|
<component :is="item.icon" class="task-view-toggle__icon" />
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ElSegmented>
|
||||||
|
<ElButton v-if="canCreate" type="primary" :icon="Plus" :disabled="!execution" @click="handleCreate">
|
||||||
|
新增
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<TaskSearch
|
||||||
|
:model="searchParams"
|
||||||
|
:user-options="ownerOptions"
|
||||||
|
:status-options="taskStatusBoard?.items || []"
|
||||||
|
:disabled="!execution"
|
||||||
|
@search="handleSearch"
|
||||||
|
@reset="handleReset"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ElEmpty v-if="!execution" class="task-workspace__empty" description="请选择左侧执行项" />
|
||||||
|
<TaskTableView
|
||||||
|
v-else-if="viewMode === 'table'"
|
||||||
|
:data="data"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="mobilePagination"
|
||||||
|
:can-update="canUpdate"
|
||||||
|
:can-change-status="canChangeStatus"
|
||||||
|
@detail="handleDetail"
|
||||||
|
@edit="handleEdit"
|
||||||
|
@status-action="handleStatusAction"
|
||||||
|
/>
|
||||||
|
<TaskBoardView
|
||||||
|
v-else
|
||||||
|
:data="data"
|
||||||
|
:loading="loading"
|
||||||
|
:status-board="taskStatusBoard"
|
||||||
|
:can-update="canUpdate"
|
||||||
|
:can-change-status="canChangeStatus"
|
||||||
|
@detail="handleDetail"
|
||||||
|
@edit="handleEdit"
|
||||||
|
@status-action="handleStatusAction"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="execution && viewMode === 'board' && mobilePagination.total" class="task-workspace__board-pagination">
|
||||||
|
<ElPagination
|
||||||
|
background
|
||||||
|
layout="total, sizes, prev, pager, next"
|
||||||
|
v-bind="mobilePagination"
|
||||||
|
@current-change="mobilePagination['current-change']"
|
||||||
|
@size-change="mobilePagination['size-change']"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TaskOperateDialog
|
||||||
|
v-model:visible="operateVisible"
|
||||||
|
:mode="operateMode"
|
||||||
|
:row-data="currentTask"
|
||||||
|
:user-options="ownerOptions"
|
||||||
|
:task-options="taskOptions"
|
||||||
|
@submit="handleOperateSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TaskDetailDialog v-model:visible="detailVisible" :row-data="currentTask" />
|
||||||
|
|
||||||
|
<StatusActionDialog
|
||||||
|
v-model:visible="statusActionVisible"
|
||||||
|
:title="statusActionTitle"
|
||||||
|
:action="currentStatusAction"
|
||||||
|
@submit="handleStatusSubmit"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.task-workspace {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 92%);
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-workspace__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-workspace__title {
|
||||||
|
margin: 0;
|
||||||
|
color: rgb(15 23 42 / 94%);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-workspace__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-view-toggle {
|
||||||
|
--el-segmented-item-selected-bg-color: var(--el-color-primary);
|
||||||
|
--el-segmented-item-selected-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-view-toggle__item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-view-toggle__icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-workspace__board-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-workspace__empty {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px dashed rgb(203 213 225 / 92%);
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
119
src/views/project/project/execution/shared.ts
Normal file
119
src/views/project/project/execution/shared.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { getStatusTagType } from '@/constants/status-tag';
|
||||||
|
|
||||||
|
type ExecutionStatusCode = Api.Project.ProjectExecutionStatusCode;
|
||||||
|
type TaskStatusCode = Api.Project.ProjectTaskStatusCode;
|
||||||
|
type ExecutionMemberActionType = Api.Project.ExecutionMemberActionType;
|
||||||
|
|
||||||
|
export const executionMemberActionNameMap: Record<ExecutionMemberActionType, string> = {
|
||||||
|
join: '加入',
|
||||||
|
inactive: '失效',
|
||||||
|
owner_transfer_in: '转入负责人',
|
||||||
|
owner_transfer_out: '转出负责人'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EXECUTION_STATUS_ORDER = [
|
||||||
|
'pending',
|
||||||
|
'active',
|
||||||
|
'paused',
|
||||||
|
'completed',
|
||||||
|
'cancelled'
|
||||||
|
] as const satisfies readonly ExecutionStatusCode[];
|
||||||
|
|
||||||
|
export const TASK_STATUS_ORDER = [
|
||||||
|
'pending',
|
||||||
|
'active',
|
||||||
|
'blocked',
|
||||||
|
'completed',
|
||||||
|
'cancelled'
|
||||||
|
] as const satisfies readonly TaskStatusCode[];
|
||||||
|
|
||||||
|
export const executionStatusFallbackNameMap: Record<ExecutionStatusCode, string> = {
|
||||||
|
pending: '待开始',
|
||||||
|
active: '进行中',
|
||||||
|
paused: '已暂停',
|
||||||
|
completed: '已完成',
|
||||||
|
cancelled: '已取消'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const taskStatusFallbackNameMap: Record<TaskStatusCode, string> = {
|
||||||
|
pending: '待开始',
|
||||||
|
active: '进行中',
|
||||||
|
blocked: '已阻塞',
|
||||||
|
completed: '已完成',
|
||||||
|
cancelled: '已取消'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getExecutionStatusTagType(statusCode: ExecutionStatusCode | string) {
|
||||||
|
return getStatusTagType('projectExecution', statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskStatusTagType(statusCode: TaskStatusCode | string) {
|
||||||
|
return getStatusTagType('projectTask', statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExecutionMemberActionTagType(actionType: ExecutionMemberActionType | string) {
|
||||||
|
return getStatusTagType('executionMember', actionType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExecutionMemberActionName(actionType: ExecutionMemberActionType | string) {
|
||||||
|
return executionMemberActionNameMap[actionType as ExecutionMemberActionType] || actionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value: string | null | undefined) {
|
||||||
|
const parsed = dayjs(value);
|
||||||
|
|
||||||
|
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value: string | null | undefined) {
|
||||||
|
const parsed = dayjs(value);
|
||||||
|
|
||||||
|
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm:ss') : '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateRange(startDate: string | null | undefined, endDate: string | null | undefined) {
|
||||||
|
const startText = formatDate(startDate);
|
||||||
|
const endText = formatDate(endDate);
|
||||||
|
|
||||||
|
if (startText === '--' && endText === '--') {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${startText} 至 ${endText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExecutionStatusName(execution: Pick<Api.Project.ProjectExecution, 'statusCode' | 'statusName'>) {
|
||||||
|
return execution.statusName?.trim() || executionStatusFallbackNameMap[execution.statusCode] || execution.statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskStatusName(task: Pick<Api.Project.ProjectTask, 'statusCode' | 'statusName'>) {
|
||||||
|
return task.statusName?.trim() || taskStatusFallbackNameMap[task.statusCode] || task.statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProgressText(progressRate: number | null | undefined) {
|
||||||
|
if (typeof progressRate !== 'number' || !Number.isFinite(progressRate)) {
|
||||||
|
return '0%';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.min(100, Math.max(0, progressRate))}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isActiveExecutionMember(member: Pick<Api.Project.ExecutionMember, 'joinedAt' | 'removedAt'>) {
|
||||||
|
if (!member.removedAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!member.joinedAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dayjs(member.joinedAt).isAfter(dayjs(member.removedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRequireTaskProgressBeforeComplete(
|
||||||
|
action: Api.Project.LifecycleAction<Api.Project.ProjectTaskActionCode>,
|
||||||
|
task: Api.Project.ProjectTask
|
||||||
|
) {
|
||||||
|
return action.actionCode === 'complete' && task.progressRate !== 100;
|
||||||
|
}
|
||||||
401
src/views/project/project/overview/homepage.ts
Normal file
401
src/views/project/project/overview/homepage.ts
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { getProjectStatusLabel } from '../../shared/project-master-data';
|
||||||
|
|
||||||
|
export interface ProjectHomepageMetric {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectHomepageFact {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectHomepageBanner {
|
||||||
|
identity: {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
directionCode: string;
|
||||||
|
projectType: string;
|
||||||
|
statusCode: Api.Project.ProjectStatusCode | null;
|
||||||
|
statusLabel: string;
|
||||||
|
description: string;
|
||||||
|
facts: ProjectHomepageFact[];
|
||||||
|
};
|
||||||
|
metrics: ProjectHomepageMetric[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectHomepageBannerSource {
|
||||||
|
project: Api.Project.Project | null;
|
||||||
|
settings: Api.Project.ProjectSettings | null;
|
||||||
|
members: readonly Api.Project.ProjectMember[];
|
||||||
|
latestActivityTime?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectHomepageTimelineItem {
|
||||||
|
key: string;
|
||||||
|
tag: '对象' | '状态' | '团队' | '计划';
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
time: string;
|
||||||
|
tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectScheduleOverview {
|
||||||
|
metrics: ProjectHomepageMetric[];
|
||||||
|
dates: Array<{
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectTeamOverview {
|
||||||
|
metrics: ProjectHomepageMetric[];
|
||||||
|
roles: Array<{
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectHomepageExtensionModule {
|
||||||
|
key: 'milestone' | 'risk' | 'document';
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
items: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeValue(value: string | null | undefined) {
|
||||||
|
const parsed = dayjs(value);
|
||||||
|
|
||||||
|
return parsed.isValid() ? parsed.valueOf() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null | undefined) {
|
||||||
|
const parsed = dayjs(value);
|
||||||
|
|
||||||
|
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined) {
|
||||||
|
const parsed = dayjs(value);
|
||||||
|
|
||||||
|
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status: Api.Project.ProjectStatusCode | null | undefined) {
|
||||||
|
return status ? getProjectStatusLabel(status) || '--' : '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveMembers(members: readonly Api.Project.ProjectMember[]) {
|
||||||
|
return members.filter(item => item.status === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getManagerLabel(
|
||||||
|
project: Api.Project.Project | null,
|
||||||
|
settings: Api.Project.ProjectSettings | null,
|
||||||
|
members: readonly Api.Project.ProjectMember[]
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
settings?.baseInfo.managerUserNickname ||
|
||||||
|
project?.managerUserNickname ||
|
||||||
|
getActiveMembers(members).find(item => item.managerFlag)?.userNickname ||
|
||||||
|
'--'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleSummary(members: readonly Api.Project.ProjectMember[]) {
|
||||||
|
const activeMembers = getActiveMembers(members);
|
||||||
|
|
||||||
|
if (!activeMembers.length) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleCounter = new Map<string, number>();
|
||||||
|
|
||||||
|
activeMembers.forEach(member => {
|
||||||
|
const roleName = member.roleName || '未命名角色';
|
||||||
|
|
||||||
|
roleCounter.set(roleName, (roleCounter.get(roleName) || 0) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(roleCounter.entries())
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftWeight = left[0].includes('经理') || left[0].includes('负责人') ? 0 : 1;
|
||||||
|
const rightWeight = right[0].includes('经理') || right[0].includes('负责人') ? 0 : 1;
|
||||||
|
|
||||||
|
if (leftWeight !== rightWeight) {
|
||||||
|
return leftWeight - rightWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left[0].localeCompare(right[0], 'zh-CN');
|
||||||
|
})
|
||||||
|
.map(([roleName, count]) => `${roleName} ${count} 人`)
|
||||||
|
.join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlanRangeLabel(project: Api.Project.Project | null) {
|
||||||
|
const start = formatDate(project?.plannedStartDate);
|
||||||
|
const end = formatDate(project?.plannedEndDate);
|
||||||
|
|
||||||
|
if (start === '--' && end === '--') {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${start} 至 ${end}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProgressValue(project: Api.Project.Project | null) {
|
||||||
|
const progress = project?.progressRate ?? 0;
|
||||||
|
|
||||||
|
if (!Number.isFinite(progress)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(100, Math.max(0, progress));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLatestTimelineTime(
|
||||||
|
project: Api.Project.Project | null,
|
||||||
|
settings: Api.Project.ProjectSettings | null,
|
||||||
|
members: readonly Api.Project.ProjectMember[]
|
||||||
|
) {
|
||||||
|
const timeValues = [
|
||||||
|
project?.createTime,
|
||||||
|
project?.updateTime,
|
||||||
|
settings?.lifecycle.lastStatusReason ? project?.updateTime : null,
|
||||||
|
project?.actualStartDate,
|
||||||
|
project?.actualEndDate,
|
||||||
|
...members.flatMap(member => [member.joinedTime, member.leftTime || null])
|
||||||
|
];
|
||||||
|
|
||||||
|
const latestValue = timeValues.reduce((latest, current) => Math.max(latest, getTimeValue(current)), 0);
|
||||||
|
|
||||||
|
return latestValue ? dayjs(latestValue).format('YYYY-MM-DD HH:mm') : '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 待重构:拆 helper 以降低复杂度,暂以 disable 注释临时放行
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
function buildProjectHomepageBannerIdentity(source: ProjectHomepageBannerSource) {
|
||||||
|
const { project, settings, members } = source;
|
||||||
|
const baseInfo = settings?.baseInfo;
|
||||||
|
const statusCode = settings?.lifecycle.statusCode || project?.statusCode || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: project?.projectName || baseInfo?.projectName || '--',
|
||||||
|
code: project?.projectCode || baseInfo?.projectCode || '--',
|
||||||
|
directionCode: project?.directionCode || baseInfo?.directionCode || '',
|
||||||
|
projectType: project?.projectType || baseInfo?.projectType || '',
|
||||||
|
statusCode,
|
||||||
|
statusLabel: getStatusLabel(statusCode),
|
||||||
|
description: project?.projectDesc?.trim() || baseInfo?.projectDesc?.trim() || '',
|
||||||
|
facts: [
|
||||||
|
{ label: '所属产品', value: project?.productName || baseInfo?.productName || project?.productId || '--' },
|
||||||
|
{ label: '项目经理', value: getManagerLabel(project, settings, members) },
|
||||||
|
{ label: '角色摘要', value: getRoleSummary(members), fullWidth: true }
|
||||||
|
]
|
||||||
|
} satisfies ProjectHomepageBanner['identity'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProjectHomepageBannerMetrics(source: ProjectHomepageBannerSource) {
|
||||||
|
const activeMembers = getActiveMembers(source.members);
|
||||||
|
const latestTimelineTime =
|
||||||
|
source.latestActivityTime?.trim() || resolveLatestTimelineTime(source.project, source.settings, source.members);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: '团队人数',
|
||||||
|
value: String(activeMembers.length),
|
||||||
|
hint: '当前处于有效状态的项目成员数'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '项目进度',
|
||||||
|
value: `${getProgressValue(source.project)}%`,
|
||||||
|
hint: '项目详情中维护的当前进度百分比'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '计划周期',
|
||||||
|
value: getPlanRangeLabel(source.project),
|
||||||
|
hint: '项目计划开始和计划结束日期'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '最近动态时间',
|
||||||
|
value: latestTimelineTime,
|
||||||
|
hint: '对象、状态、计划或团队最近一次可确认变动时间'
|
||||||
|
}
|
||||||
|
] satisfies ProjectHomepageMetric[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProjectHomepageBanner(source: ProjectHomepageBannerSource): ProjectHomepageBanner {
|
||||||
|
return {
|
||||||
|
identity: buildProjectHomepageBannerIdentity(source),
|
||||||
|
metrics: buildProjectHomepageBannerMetrics(source)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProjectHomepageTimeline(
|
||||||
|
project: Api.Project.Project | null,
|
||||||
|
settings: Api.Project.ProjectSettings | null,
|
||||||
|
members: readonly Api.Project.ProjectMember[]
|
||||||
|
) {
|
||||||
|
const items: Array<Omit<ProjectHomepageTimelineItem, 'time'> & { time: string | null | undefined }> = [];
|
||||||
|
|
||||||
|
if (project?.createTime) {
|
||||||
|
items.push({
|
||||||
|
key: `project-create-${project.id}`,
|
||||||
|
tag: '对象',
|
||||||
|
title: '创建项目',
|
||||||
|
content: `项目 ${project.projectName || project.projectCode} 已创建并进入项目管理域。`,
|
||||||
|
time: project.createTime,
|
||||||
|
tone: 'sky'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusReason =
|
||||||
|
settings?.lifecycle.lastStatusReason || settings?.baseInfo.lastStatusReason || project?.lastStatusReason;
|
||||||
|
|
||||||
|
if (project?.updateTime && project?.statusCode && statusReason) {
|
||||||
|
const toneMap: Record<Api.Project.ProjectStatusCode, ProjectHomepageTimelineItem['tone']> = {
|
||||||
|
pending: 'slate',
|
||||||
|
active: 'emerald',
|
||||||
|
paused: 'amber',
|
||||||
|
completed: 'emerald',
|
||||||
|
cancelled: 'rose',
|
||||||
|
archived: 'slate'
|
||||||
|
};
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
key: `project-status-${project.id}-${project.updateTime}`,
|
||||||
|
tag: '状态',
|
||||||
|
title: `状态调整为${getStatusLabel(project.statusCode)}`,
|
||||||
|
content: statusReason,
|
||||||
|
time: project.updateTime,
|
||||||
|
tone: toneMap[project.statusCode]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project?.actualStartDate) {
|
||||||
|
items.push({
|
||||||
|
key: `project-actual-start-${project.id}`,
|
||||||
|
tag: '计划',
|
||||||
|
title: '实际开始',
|
||||||
|
content: '项目已记录实际开始日期,进入真实推进阶段。',
|
||||||
|
time: project.actualStartDate,
|
||||||
|
tone: 'emerald'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project?.actualEndDate) {
|
||||||
|
items.push({
|
||||||
|
key: `project-actual-end-${project.id}`,
|
||||||
|
tag: '计划',
|
||||||
|
title: '实际结束',
|
||||||
|
content: '项目已记录实际结束日期,可结合状态进入完成或归档处理。',
|
||||||
|
time: project.actualEndDate,
|
||||||
|
tone: 'slate'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
members.forEach(member => {
|
||||||
|
if (member.joinedTime) {
|
||||||
|
items.push({
|
||||||
|
key: `member-join-${member.id}`,
|
||||||
|
tag: '团队',
|
||||||
|
title: '成员加入',
|
||||||
|
content: `${member.userNickname} 以 ${member.roleName || '未命名角色'} 身份加入当前项目。`,
|
||||||
|
time: member.joinedTime,
|
||||||
|
tone: member.managerFlag ? 'emerald' : 'sky'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member.status === 1 && member.leftTime) {
|
||||||
|
items.push({
|
||||||
|
key: `member-leave-${member.id}`,
|
||||||
|
tag: '团队',
|
||||||
|
title: '成员移出',
|
||||||
|
content: `${member.userNickname} 已退出当前项目团队。`,
|
||||||
|
time: member.leftTime,
|
||||||
|
tone: 'rose'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return items
|
||||||
|
.filter(item => getTimeValue(item.time) > 0)
|
||||||
|
.sort((left, right) => getTimeValue(right.time) - getTimeValue(left.time))
|
||||||
|
.slice(0, 8)
|
||||||
|
.map(item => ({
|
||||||
|
...item,
|
||||||
|
time: formatDateTime(item.time)
|
||||||
|
})) satisfies ProjectHomepageTimelineItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProjectScheduleOverview(project: Api.Project.Project | null): ProjectScheduleOverview {
|
||||||
|
const progressValue = getProgressValue(project);
|
||||||
|
const hasActualRange = Boolean(project?.actualStartDate || project?.actualEndDate);
|
||||||
|
|
||||||
|
return {
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
label: '当前进度',
|
||||||
|
value: `${progressValue}%`,
|
||||||
|
hint: '由项目详情维护的进度字段直接呈现'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '日期完整度',
|
||||||
|
value: hasActualRange ? '已记录实际日期' : '以计划日期为主',
|
||||||
|
hint: '用于判断当前项目计划与执行日期是否闭环'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dates: [
|
||||||
|
{ label: '计划开始', value: formatDate(project?.plannedStartDate) },
|
||||||
|
{ label: '计划结束', value: formatDate(project?.plannedEndDate) },
|
||||||
|
{ label: '实际开始', value: formatDate(project?.actualStartDate) },
|
||||||
|
{ label: '实际结束', value: formatDate(project?.actualEndDate) }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProjectTeamOverview(members: readonly Api.Project.ProjectMember[]): ProjectTeamOverview {
|
||||||
|
const activeMembers = getActiveMembers(members);
|
||||||
|
const inactiveMembers = members.filter(item => item.status === 1);
|
||||||
|
const manager = activeMembers.find(item => item.managerFlag);
|
||||||
|
const roleCounter = new Map<string, number>();
|
||||||
|
|
||||||
|
activeMembers.forEach(member => {
|
||||||
|
const roleName = member.roleName || '未命名角色';
|
||||||
|
|
||||||
|
roleCounter.set(roleName, (roleCounter.get(roleName) || 0) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
label: '有效成员',
|
||||||
|
value: String(activeMembers.length),
|
||||||
|
hint: '当前仍在项目团队中的成员数'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '已退出成员',
|
||||||
|
value: String(inactiveMembers.length),
|
||||||
|
hint: '历史加入后已移出的项目成员数'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '项目经理',
|
||||||
|
value: manager?.userNickname || '--',
|
||||||
|
hint: '当前团队中标记为项目负责人的成员'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '角色类型',
|
||||||
|
value: String(roleCounter.size),
|
||||||
|
hint: '有效成员覆盖的角色种类数'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
roles: Array.from(roleCounter.entries()).map(([label, value]) => ({ label, value: String(value) }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectHomepageExtensionModules(modules: readonly ProjectHomepageExtensionModule[]) {
|
||||||
|
return [...modules];
|
||||||
|
}
|
||||||
785
src/views/project/project/overview/index.vue
Normal file
785
src/views/project/project/overview/index.vue
Normal file
@@ -0,0 +1,785 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||||
|
import { fetchGetProject, fetchGetProjectMembers, fetchGetProjectSettings } from '@/service/api';
|
||||||
|
import { useDict } from '@/hooks/business/dict';
|
||||||
|
import { useCurrentProject } from '../../shared/use-current-project';
|
||||||
|
import {
|
||||||
|
buildProjectHomepageBanner,
|
||||||
|
buildProjectHomepageTimeline,
|
||||||
|
buildProjectScheduleOverview,
|
||||||
|
buildProjectTeamOverview,
|
||||||
|
getProjectHomepageExtensionModules
|
||||||
|
} from './homepage';
|
||||||
|
import { projectHomepageExtensionMock } from './mock';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectOverview' });
|
||||||
|
|
||||||
|
const { currentObjectId, currentProject } = useCurrentProject();
|
||||||
|
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||||
|
const { getLabel: getProjectTypeLabel } = useDict(RDMS_PROJECT_TYPE_DICT_CODE);
|
||||||
|
|
||||||
|
const pageLoading = ref(false);
|
||||||
|
const projectDetail = ref<Api.Project.Project | null>(null);
|
||||||
|
const settings = ref<Api.Project.ProjectSettings | null>(null);
|
||||||
|
const members = ref<Api.Project.ProjectMember[]>([]);
|
||||||
|
const latestActivityTime = ref('');
|
||||||
|
|
||||||
|
const timelineItems = computed(() => buildProjectHomepageTimeline(projectDetail.value, settings.value, members.value));
|
||||||
|
const scheduleOverview = computed(() => buildProjectScheduleOverview(projectDetail.value));
|
||||||
|
const teamOverview = computed(() => buildProjectTeamOverview(members.value));
|
||||||
|
const homepageBanner = computed(() =>
|
||||||
|
buildProjectHomepageBanner({
|
||||||
|
project: projectDetail.value,
|
||||||
|
settings: settings.value,
|
||||||
|
members: members.value,
|
||||||
|
latestActivityTime: latestActivityTime.value
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const extensionModules = computed(() => getProjectHomepageExtensionModules(projectHomepageExtensionMock));
|
||||||
|
const directionLabel = computed(() => getDirectionLabel(homepageBanner.value.identity.directionCode, '--'));
|
||||||
|
const projectTypeLabel = computed(() => getProjectTypeLabel(homepageBanner.value.identity.projectType, '--'));
|
||||||
|
const bannerFacts = computed(() => [
|
||||||
|
{
|
||||||
|
label: '项目方向',
|
||||||
|
value: directionLabel.value,
|
||||||
|
fullWidth: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '项目类型',
|
||||||
|
value: projectTypeLabel.value,
|
||||||
|
fullWidth: false
|
||||||
|
},
|
||||||
|
...homepageBanner.value.identity.facts
|
||||||
|
]);
|
||||||
|
const progressValue = computed(() => projectDetail.value?.progressRate ?? 0);
|
||||||
|
const bannerStatusClass = computed(() => {
|
||||||
|
const statusCode = homepageBanner.value.identity.statusCode;
|
||||||
|
|
||||||
|
return statusCode ? `project-homepage-banner--${statusCode}` : 'project-homepage-banner--default';
|
||||||
|
});
|
||||||
|
const bannerStatusWordClass = computed(() => {
|
||||||
|
const statusCode = homepageBanner.value.identity.statusCode;
|
||||||
|
|
||||||
|
return statusCode
|
||||||
|
? `project-homepage-banner__status-word--${statusCode}`
|
||||||
|
: 'project-homepage-banner__status-word--default';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadOverviewData(objectId: string) {
|
||||||
|
pageLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [projectResult, settingsResult, membersResult] = await Promise.all([
|
||||||
|
fetchGetProject(objectId),
|
||||||
|
fetchGetProjectSettings(objectId),
|
||||||
|
fetchGetProjectMembers(objectId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
projectDetail.value = projectResult.error ? null : projectResult.data || null;
|
||||||
|
settings.value = settingsResult.error ? null : settingsResult.data || null;
|
||||||
|
members.value = membersResult.error ? [] : membersResult.data || [];
|
||||||
|
latestActivityTime.value = timelineItems.value[0]?.time || '';
|
||||||
|
} finally {
|
||||||
|
pageLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => currentObjectId.value,
|
||||||
|
async objectId => {
|
||||||
|
if (!objectId) {
|
||||||
|
projectDetail.value = null;
|
||||||
|
settings.value = null;
|
||||||
|
members.value = [];
|
||||||
|
latestActivityTime.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadOverviewData(objectId);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-loading="pageLoading" class="project-homepage">
|
||||||
|
<section class="project-homepage-banner" :class="bannerStatusClass">
|
||||||
|
<div class="project-homepage-banner__identity">
|
||||||
|
<div class="project-homepage-banner__title-group">
|
||||||
|
<div class="project-homepage-banner__title-main min-w-0">
|
||||||
|
<div class="project-homepage-banner__title-row">
|
||||||
|
<h1 class="project-homepage-banner__title">
|
||||||
|
{{ homepageBanner.identity.name || currentProject?.projectName || '--' }}
|
||||||
|
</h1>
|
||||||
|
<span class="project-homepage-banner__status-word" :class="bannerStatusWordClass">
|
||||||
|
{{ homepageBanner.identity.statusLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-homepage-banner__subtitle">
|
||||||
|
<span class="project-homepage-banner__code">编号 {{ homepageBanner.identity.code }}</span>
|
||||||
|
<p v-if="homepageBanner.identity.description" class="project-homepage-banner__description">
|
||||||
|
{{ homepageBanner.identity.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-homepage-banner__facts">
|
||||||
|
<div
|
||||||
|
v-for="item in bannerFacts"
|
||||||
|
:key="item.label"
|
||||||
|
class="project-homepage-banner__fact"
|
||||||
|
:class="{ 'project-homepage-banner__fact--full': item.fullWidth }"
|
||||||
|
>
|
||||||
|
<span class="project-homepage-banner__fact-label">{{ item.label }}</span>
|
||||||
|
<strong class="project-homepage-banner__fact-value">{{ item.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-homepage-banner__metrics">
|
||||||
|
<article v-for="item in homepageBanner.metrics" :key="item.label" class="project-homepage-banner__metric">
|
||||||
|
<span class="project-homepage-banner__metric-label">{{ item.label }}</span>
|
||||||
|
<strong class="project-homepage-banner__metric-value">{{ item.value }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="project-homepage-main">
|
||||||
|
<ElCard class="project-homepage-panel card-wrapper">
|
||||||
|
<template #header>
|
||||||
|
<div>
|
||||||
|
<h3 class="project-homepage-panel__title">项目动态时间线</h3>
|
||||||
|
<p class="project-homepage-panel__desc">
|
||||||
|
先展示项目创建、状态动作、实际日期和团队变化,后续可替换为专用动态接口。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="timelineItems.length" class="project-homepage-timeline">
|
||||||
|
<article v-for="item in timelineItems" :key="item.key" class="project-homepage-timeline__item">
|
||||||
|
<div class="project-homepage-timeline__rail">
|
||||||
|
<span class="project-homepage-timeline__dot" :class="`project-homepage-timeline__dot--${item.tone}`" />
|
||||||
|
<span class="project-homepage-timeline__line" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-homepage-timeline__content">
|
||||||
|
<div class="project-homepage-timeline__meta">
|
||||||
|
<ElTag effect="plain" size="small">{{ item.tag }}</ElTag>
|
||||||
|
<span class="project-homepage-timeline__time">{{ item.time }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="project-homepage-timeline__sentence">
|
||||||
|
<strong class="project-homepage-timeline__headline">{{ item.title }}</strong>
|
||||||
|
<span>{{ item.content }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElEmpty v-else description="当前暂无可展示的项目动态" :image-size="88" />
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<div class="project-homepage-main__aside">
|
||||||
|
<ElCard class="project-homepage-panel card-wrapper">
|
||||||
|
<template #header>
|
||||||
|
<div>
|
||||||
|
<h3 class="project-homepage-panel__title">计划进展概览</h3>
|
||||||
|
<p class="project-homepage-panel__desc">先看当前进度、计划周期和实际执行日期是否已经闭环。</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="project-homepage-schedule">
|
||||||
|
<div class="project-homepage-schedule__progress">
|
||||||
|
<strong>{{ progressValue }}%</strong>
|
||||||
|
<ElProgress
|
||||||
|
:percentage="progressValue"
|
||||||
|
:stroke-width="8"
|
||||||
|
:show-text="false"
|
||||||
|
:color="progressValue >= 100 ? '#10b981' : progressValue >= 50 ? '#3b82f6' : '#6366f1'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-homepage-summary-metrics">
|
||||||
|
<article
|
||||||
|
v-for="item in scheduleOverview.metrics"
|
||||||
|
:key="item.label"
|
||||||
|
class="project-homepage-summary-metrics__item"
|
||||||
|
>
|
||||||
|
<span class="project-homepage-summary-metrics__label">{{ item.label }}</span>
|
||||||
|
<strong class="project-homepage-summary-metrics__value">{{ item.value }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-homepage-schedule__dates">
|
||||||
|
<div v-for="item in scheduleOverview.dates" :key="item.label" class="project-homepage-schedule__date">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<strong>{{ item.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<ElCard class="project-homepage-panel card-wrapper">
|
||||||
|
<template #header>
|
||||||
|
<div>
|
||||||
|
<h3 class="project-homepage-panel__title">项目团队概览</h3>
|
||||||
|
<p class="project-homepage-panel__desc">承接当前成员规模、负责人和角色结构,和设置页团队维护分开表达。</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="project-homepage-team">
|
||||||
|
<div class="project-homepage-summary-metrics">
|
||||||
|
<article
|
||||||
|
v-for="item in teamOverview.metrics"
|
||||||
|
:key="item.label"
|
||||||
|
class="project-homepage-summary-metrics__item"
|
||||||
|
>
|
||||||
|
<span class="project-homepage-summary-metrics__label">{{ item.label }}</span>
|
||||||
|
<strong class="project-homepage-summary-metrics__value">{{ item.value }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="teamOverview.roles.length" class="project-homepage-team__roles">
|
||||||
|
<div v-for="item in teamOverview.roles" :key="item.label" class="project-homepage-team__role">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<strong>{{ item.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElEmpty v-else description="当前暂无有效团队成员" :image-size="72" />
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="project-homepage-extension">
|
||||||
|
<ElCard v-for="module in extensionModules" :key="module.key" class="project-homepage-panel card-wrapper">
|
||||||
|
<template #header>
|
||||||
|
<div>
|
||||||
|
<h3 class="project-homepage-panel__title">{{ module.title }}</h3>
|
||||||
|
<p class="project-homepage-panel__desc">{{ module.description }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="project-homepage-extension__list">
|
||||||
|
<div v-for="item in module.items" :key="item" class="project-homepage-extension__item">
|
||||||
|
<span class="project-homepage-extension__dot" />
|
||||||
|
<span>{{ item }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.project-homepage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 92%);
|
||||||
|
border-radius: 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgb(14 116 144 / 14%), transparent 34%),
|
||||||
|
radial-gradient(circle at bottom right, rgb(15 118 110 / 10%), transparent 28%),
|
||||||
|
linear-gradient(135deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 98%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner--default {
|
||||||
|
border-color: rgb(226 232 240 / 92%);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgb(14 116 144 / 14%), transparent 34%),
|
||||||
|
radial-gradient(circle at bottom right, rgb(15 118 110 / 10%), transparent 28%),
|
||||||
|
linear-gradient(135deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 98%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner--pending,
|
||||||
|
.project-homepage-banner--archived {
|
||||||
|
border-color: rgb(203 213 225 / 92%);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgb(100 116 139 / 14%), transparent 34%),
|
||||||
|
radial-gradient(circle at bottom right, rgb(148 163 184 / 10%), transparent 26%),
|
||||||
|
linear-gradient(135deg, rgb(248 250 252 / 99%), rgb(255 255 255 / 98%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner--active,
|
||||||
|
.project-homepage-banner--completed {
|
||||||
|
border-color: rgb(167 243 208 / 88%);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgb(5 150 105 / 16%), transparent 32%),
|
||||||
|
radial-gradient(circle at bottom right, rgb(16 185 129 / 14%), transparent 26%),
|
||||||
|
linear-gradient(135deg, rgb(236 253 245 / 99%), rgb(255 255 255 / 98%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner--paused {
|
||||||
|
border-color: rgb(253 230 138 / 90%);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgb(245 158 11 / 18%), transparent 32%),
|
||||||
|
radial-gradient(circle at bottom right, rgb(251 191 36 / 16%), transparent 24%),
|
||||||
|
linear-gradient(135deg, rgb(255 251 235 / 99%), rgb(255 255 255 / 98%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner--cancelled {
|
||||||
|
border-color: rgb(254 205 211 / 92%);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgb(244 63 94 / 16%), transparent 32%),
|
||||||
|
radial-gradient(circle at bottom right, rgb(251 113 133 / 14%), transparent 24%),
|
||||||
|
linear-gradient(135deg, rgb(255 241 242 / 99%), rgb(255 255 255 / 98%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__identity {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__title-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__title-main {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__title-row {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__code {
|
||||||
|
margin: 0;
|
||||||
|
color: rgb(14 116 144 / 92%);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__title {
|
||||||
|
margin: 0;
|
||||||
|
color: rgb(15 23 42 / 98%);
|
||||||
|
font-size: 34px;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__subtitle {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__description {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
color: rgb(71 85 105 / 94%);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__status-word {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__status-word--default {
|
||||||
|
color: rgb(148 163 184 / 48%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__status-word--pending,
|
||||||
|
.project-homepage-banner__status-word--archived {
|
||||||
|
color: transparent;
|
||||||
|
background: linear-gradient(180deg, rgb(71 85 105 / 92%), rgb(148 163 184 / 64%));
|
||||||
|
background-clip: text;
|
||||||
|
text-shadow: 0 10px 24px rgb(100 116 139 / 14%);
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__status-word--active,
|
||||||
|
.project-homepage-banner__status-word--completed {
|
||||||
|
color: transparent;
|
||||||
|
background: linear-gradient(180deg, rgb(5 150 105 / 94%), rgb(16 185 129 / 70%));
|
||||||
|
background-clip: text;
|
||||||
|
text-shadow: 0 10px 24px rgb(5 150 105 / 16%);
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__status-word--paused {
|
||||||
|
color: transparent;
|
||||||
|
background: linear-gradient(180deg, rgb(217 119 6 / 94%), rgb(245 158 11 / 70%));
|
||||||
|
background-clip: text;
|
||||||
|
text-shadow: 0 10px 24px rgb(245 158 11 / 16%);
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__status-word--cancelled {
|
||||||
|
color: transparent;
|
||||||
|
background: linear-gradient(180deg, rgb(244 63 94 / 94%), rgb(251 113 133 / 68%));
|
||||||
|
background-clip: text;
|
||||||
|
text-shadow: 0 10px 24px rgb(244 63 94 / 16%);
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__fact {
|
||||||
|
display: flex;
|
||||||
|
min-height: 58px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 88%);
|
||||||
|
border-radius: 18px;
|
||||||
|
background-color: rgb(255 255 255 / 78%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__fact--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__fact-label {
|
||||||
|
color: rgb(100 116 139 / 94%);
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__fact-value {
|
||||||
|
color: rgb(15 23 42 / 96%);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__fact--full .project-homepage-banner__fact-value {
|
||||||
|
max-width: 72%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__metric {
|
||||||
|
display: flex;
|
||||||
|
min-height: 112px;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 88%);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(241 245 249 / 98%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__metric-label {
|
||||||
|
color: rgb(100 116 139 / 92%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__metric-value {
|
||||||
|
color: rgb(15 23 42 / 98%);
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-main__aside {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-panel__title {
|
||||||
|
margin: 0;
|
||||||
|
color: rgb(15 23 42 / 98%);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-panel__desc {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: rgb(100 116 139 / 92%);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-timeline__item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-timeline__rail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-timeline__dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 0 0 4px rgb(255 255 255 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-timeline__dot--sky {
|
||||||
|
background-color: rgb(14 165 233 / 88%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-timeline__dot--emerald {
|
||||||
|
background-color: rgb(5 150 105 / 88%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-timeline__dot--amber {
|
||||||
|
background-color: rgb(217 119 6 / 88%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-timeline__dot--rose {
|
||||||
|
background-color: rgb(225 29 72 / 88%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-timeline__dot--slate {
|
||||||
|
background-color: rgb(100 116 139 / 88%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-timeline__line {
|
||||||
|
flex: 1;
|
||||||
|
width: 2px;
|
||||||
|
min-height: 30px;
|
||||||
|
margin-top: 4px;
|
||||||
|
background: linear-gradient(180deg, rgb(203 213 225 / 96%), rgb(226 232 240 / 28%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-timeline__item:last-child .project-homepage-timeline__line {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-timeline__content {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 92%);
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: rgb(255 255 255 / 98%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-timeline__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-timeline__time {
|
||||||
|
color: rgb(100 116 139 / 90%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-timeline__sentence {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: rgb(71 85 105 / 94%);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-timeline__headline {
|
||||||
|
margin-right: 6px;
|
||||||
|
color: rgb(15 23 42 / 98%);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-schedule,
|
||||||
|
.project-homepage-team {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-schedule__progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(180deg, rgb(248 250 252 / 98%), rgb(241 245 249 / 94%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-schedule__progress strong {
|
||||||
|
color: rgb(15 23 42 / 98%);
|
||||||
|
font-size: 36px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-summary-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-summary-metrics__item {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100px;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(180deg, rgb(248 250 252 / 98%), rgb(241 245 249 / 94%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-summary-metrics__label {
|
||||||
|
color: rgb(100 116 139 / 92%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-summary-metrics__value {
|
||||||
|
color: rgb(15 23 42 / 98%);
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.2;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-schedule__dates,
|
||||||
|
.project-homepage-team__roles {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-schedule__date,
|
||||||
|
.project-homepage-team__role {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 13px 14px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 88%);
|
||||||
|
border-radius: 14px;
|
||||||
|
background-color: rgb(255 255 255 / 96%);
|
||||||
|
color: rgb(51 65 85 / 95%);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-schedule__date strong,
|
||||||
|
.project-homepage-team__role strong {
|
||||||
|
color: rgb(15 23 42 / 98%);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-extension {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-extension__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-extension__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 13px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: rgb(248 250 252 / 96%);
|
||||||
|
color: rgb(51 65 85 / 95%);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-extension__dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: rgb(14 116 144 / 88%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1280px) {
|
||||||
|
.project-homepage-banner,
|
||||||
|
.project-homepage-main,
|
||||||
|
.project-homepage-extension {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.project-homepage-banner {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__title-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__title {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__status-word {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__facts,
|
||||||
|
.project-homepage-banner__metrics,
|
||||||
|
.project-homepage-summary-metrics {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-homepage-banner__fact--full .project-homepage-banner__fact-value {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
22
src/views/project/project/overview/mock.ts
Normal file
22
src/views/project/project/overview/mock.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ProjectHomepageExtensionModule } from './homepage';
|
||||||
|
|
||||||
|
export const projectHomepageExtensionMock = [
|
||||||
|
{
|
||||||
|
key: 'milestone',
|
||||||
|
title: '里程碑推进',
|
||||||
|
description: '承接项目阶段目标和关键验收点,后续接入真实里程碑接口后替换当前模块数据。',
|
||||||
|
items: ['项目概览页改版验收', '项目设置与成员维护闭环', '需求执行看板接口接入']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'risk',
|
||||||
|
title: '风险点管理',
|
||||||
|
description: '预留项目级风险摘要,避免把阻塞、延期和资源风险混在动态时间线里表达。',
|
||||||
|
items: ['项目动态暂由详情和成员记录拼装', '进度字段依赖项目详情手工维护', '里程碑和风险暂未接入专用聚合接口']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'document',
|
||||||
|
title: '项目资料',
|
||||||
|
description: '用于承接项目说明、交付资料、评审纪要和归档材料,当前先保留正式结构位。',
|
||||||
|
items: ['项目说明与目标范围', '交付物与评审记录', '项目归档材料清单']
|
||||||
|
}
|
||||||
|
] satisfies ProjectHomepageExtensionModule[];
|
||||||
9
src/views/project/project/requirement/index.vue
Normal file
9
src/views/project/project/requirement/index.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({ name: 'ProjectRequirement' });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="p-16px">
|
||||||
|
<ElEmpty description="需求池功能开发中..." />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
506
src/views/project/project/setting/index.vue
Normal file
506
src/views/project/project/setting/index.vue
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useMediaQuery } from '@vueuse/core';
|
||||||
|
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
|
||||||
|
import { objectContextDomainConfigs } from '@/constants/object-context';
|
||||||
|
import {
|
||||||
|
fetchChangeProjectStatus,
|
||||||
|
fetchCreateProjectMember,
|
||||||
|
fetchDeleteProject,
|
||||||
|
fetchGetProjectMembers,
|
||||||
|
fetchGetProjectSettings,
|
||||||
|
fetchGetRoleSimpleList,
|
||||||
|
fetchGetUserSimpleList,
|
||||||
|
fetchInactiveProjectMember,
|
||||||
|
fetchUpdateProjectMember,
|
||||||
|
fetchUpdateProjectSettingBaseInfo
|
||||||
|
} from '@/service/api';
|
||||||
|
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||||
|
import { useThemeStore } from '@/store/modules/theme';
|
||||||
|
import { useRouterPush } from '@/hooks/common/router';
|
||||||
|
import { useCurrentProject } from '../../shared/use-current-project';
|
||||||
|
import BaseInfoDialog from './modules/base-info-dialog.vue';
|
||||||
|
import MemberOperateDialog from './modules/member-operate-dialog.vue';
|
||||||
|
import MemberRemoveDialog from './modules/member-remove-dialog.vue';
|
||||||
|
import ProjectDeleteDialog from './modules/project-delete-dialog.vue';
|
||||||
|
import SettingAnchorNav from './modules/setting-anchor-nav.vue';
|
||||||
|
import SettingBaseInfoCard from './modules/setting-base-info-card.vue';
|
||||||
|
import SettingDangerZone from './modules/setting-danger-zone.vue';
|
||||||
|
import SettingLifecyclePanel from './modules/setting-lifecycle-panel.vue';
|
||||||
|
import SettingTeamPanel from './modules/setting-team-panel.vue';
|
||||||
|
import StatusActionDialog from './modules/status-action-dialog.vue';
|
||||||
|
import {
|
||||||
|
type ProjectSettingSectionKey,
|
||||||
|
canManageProjectTeam,
|
||||||
|
getProjectSettingSectionKeys,
|
||||||
|
resolveVisibleProjectSettingSectionKey,
|
||||||
|
resolveVisibleProjectSettingSections
|
||||||
|
} from './shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectSetting' });
|
||||||
|
|
||||||
|
const objectContextStore = useObjectContextStore();
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
const { routerPush } = useRouterPush();
|
||||||
|
const { currentObjectId, currentProject } = useCurrentProject();
|
||||||
|
const isCompactLayout = useMediaQuery('(max-width: 1280px)');
|
||||||
|
|
||||||
|
const projectDomainConfig = objectContextDomainConfigs.find(config => config.domainKey === 'project') || null;
|
||||||
|
|
||||||
|
const allAnchorItems = [
|
||||||
|
{ key: 'base-info', label: '基础信息' },
|
||||||
|
{ key: 'team', label: '团队管理' },
|
||||||
|
{ key: 'lifecycle', label: '生命周期管理' },
|
||||||
|
{ key: 'danger', label: '危险操作' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const anchorLabelMap = new Map(allAnchorItems.map(item => [item.key, item.label]));
|
||||||
|
|
||||||
|
const sectionIdMap: Record<ProjectSettingSectionKey, string> = {
|
||||||
|
'base-info': 'project-setting-base-info',
|
||||||
|
team: 'project-setting-team',
|
||||||
|
lifecycle: 'project-setting-lifecycle',
|
||||||
|
danger: 'project-setting-danger'
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeAnchorKey = ref<ProjectSettingSectionKey>('base-info');
|
||||||
|
const pageLoading = ref(false);
|
||||||
|
const memberLoading = ref(false);
|
||||||
|
const baseInfoVisible = ref(false);
|
||||||
|
const memberOperateVisible = ref(false);
|
||||||
|
const memberRemoveVisible = ref(false);
|
||||||
|
const statusActionVisible = ref(false);
|
||||||
|
const deleteVisible = ref(false);
|
||||||
|
const memberOperateMode = ref<'create' | 'edit'>('create');
|
||||||
|
const selectedMember = ref<Api.Project.ProjectMember | null>(null);
|
||||||
|
const selectedAction = ref<Api.Project.ProjectLifecycleAction | null>(null);
|
||||||
|
|
||||||
|
const settings = ref<Api.Project.ProjectSettings | null>(null);
|
||||||
|
const members = ref<Api.Project.ProjectMember[]>([]);
|
||||||
|
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
|
||||||
|
const userOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||||
|
const currentManager = computed(() => members.value.find(item => item.managerFlag && item.status === 0) || null);
|
||||||
|
const baseInfo = computed(() => settings.value?.baseInfo || null);
|
||||||
|
const lifecycle = computed(() => settings.value?.lifecycle || null);
|
||||||
|
const canUpdateProject = computed(() => objectContextStore.buttonCodes.includes('project:project:update'));
|
||||||
|
const canManageTeam = computed(() => canManageProjectTeam({ buttonCodes: objectContextStore.buttonCodes }));
|
||||||
|
const canChangeProjectStatus = computed(() => objectContextStore.buttonCodes.includes('project:project:status'));
|
||||||
|
const canDeleteProject = computed(() => objectContextStore.buttonCodes.includes('project:project:delete'));
|
||||||
|
const visibleSectionKeys = computed(() =>
|
||||||
|
resolveVisibleProjectSettingSections(getProjectSettingSectionKeys(), objectContextStore.buttonCodes)
|
||||||
|
);
|
||||||
|
const anchorItems = computed(() =>
|
||||||
|
visibleSectionKeys.value.map(key => ({
|
||||||
|
key,
|
||||||
|
label: anchorLabelMap.get(key) || key
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const layoutScrollTarget = `#${LAYOUT_SCROLL_EL_ID}`;
|
||||||
|
const anchorAffixOffset = computed(() => {
|
||||||
|
const fixedTopInset = themeStore.fixedHeaderAndTab
|
||||||
|
? themeStore.header.height + (themeStore.tabVisible ? themeStore.tab.height : 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return fixedTopInset + 16;
|
||||||
|
});
|
||||||
|
const anchorShellInlineStyle = computed(() => ({
|
||||||
|
maxHeight: isCompactLayout.value ? '' : `calc(100vh - ${anchorAffixOffset.value + 16}px)`
|
||||||
|
}));
|
||||||
|
const showLifecycleSection = computed(() => visibleSectionKeys.value.includes('lifecycle'));
|
||||||
|
const showDangerSection = computed(() => visibleSectionKeys.value.includes('danger'));
|
||||||
|
|
||||||
|
async function refreshContextSummary() {
|
||||||
|
if (!projectDomainConfig || !currentObjectId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await objectContextStore.enterContext(projectDomainConfig, currentObjectId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
if (!currentObjectId.value) {
|
||||||
|
settings.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error, data } = await fetchGetProjectSettings(currentObjectId.value);
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
settings.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.value = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMembers() {
|
||||||
|
if (!currentObjectId.value) {
|
||||||
|
members.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
memberLoading.value = true;
|
||||||
|
|
||||||
|
const { error, data } = await fetchGetProjectMembers(currentObjectId.value);
|
||||||
|
|
||||||
|
memberLoading.value = false;
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
members.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
members.value = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRoleOptions() {
|
||||||
|
const { error, data } = await fetchGetRoleSimpleList({
|
||||||
|
scopeType: 'object',
|
||||||
|
objectType: 'project'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
roleOptions.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
roleOptions.value = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUserOptions() {
|
||||||
|
const { error, data } = await fetchGetUserSimpleList();
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
userOptions.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
userOptions.value = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPageData() {
|
||||||
|
if (!currentObjectId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pageLoading.value = true;
|
||||||
|
|
||||||
|
await Promise.all([refreshContextSummary(), loadSettings(), loadMembers(), loadRoleOptions(), loadUserOptions()]);
|
||||||
|
|
||||||
|
pageLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToSection(key: string) {
|
||||||
|
if (!(key in sectionIdMap)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedKey = key as ProjectSettingSectionKey;
|
||||||
|
|
||||||
|
if (!visibleSectionKeys.value.includes(resolvedKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeAnchorKey.value = resolvedKey;
|
||||||
|
const target = document.getElementById(sectionIdMap[resolvedKey]);
|
||||||
|
|
||||||
|
target?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateMember() {
|
||||||
|
memberOperateMode.value = 'create';
|
||||||
|
selectedMember.value = null;
|
||||||
|
memberOperateVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditMember(member: Api.Project.ProjectMember) {
|
||||||
|
memberOperateMode.value = 'edit';
|
||||||
|
selectedMember.value = member;
|
||||||
|
memberOperateVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRemoveMember(member: Api.Project.ProjectMember) {
|
||||||
|
selectedMember.value = member;
|
||||||
|
memberRemoveVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLifecycleAction(action: Api.Project.ProjectLifecycleAction) {
|
||||||
|
selectedAction.value = action;
|
||||||
|
statusActionVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitBaseInfo(payload: Api.Project.UpdateProjectSettingBaseInfoParams) {
|
||||||
|
if (!currentObjectId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetchUpdateProjectSettingBaseInfo(currentObjectId.value, payload);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success('基础信息更新成功');
|
||||||
|
baseInfoVisible.value = false;
|
||||||
|
|
||||||
|
await Promise.all([loadSettings(), refreshContextSummary()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitMemberOperate(event: {
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
memberId?: string;
|
||||||
|
managerChanged: boolean;
|
||||||
|
payload: Api.Project.CreateProjectMemberParams | Api.Project.UpdateProjectMemberParams;
|
||||||
|
}) {
|
||||||
|
if (!currentObjectId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result =
|
||||||
|
event.mode === 'create'
|
||||||
|
? await fetchCreateProjectMember(currentObjectId.value, event.payload as Api.Project.CreateProjectMemberParams)
|
||||||
|
: await fetchUpdateProjectMember(
|
||||||
|
currentObjectId.value,
|
||||||
|
event.memberId || '',
|
||||||
|
event.payload as Api.Project.UpdateProjectMemberParams
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success(event.mode === 'create' ? '成员新增成功' : '成员角色调整成功');
|
||||||
|
memberOperateVisible.value = false;
|
||||||
|
|
||||||
|
await Promise.all([loadMembers(), loadSettings()]);
|
||||||
|
|
||||||
|
if (event.managerChanged) {
|
||||||
|
await refreshContextSummary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitRemoveMember(payload: Api.Project.InactiveProjectMemberParams) {
|
||||||
|
if (!currentObjectId.value || !selectedMember.value?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetchInactiveProjectMember(currentObjectId.value, selectedMember.value.id, payload);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success('成员移出成功');
|
||||||
|
memberRemoveVisible.value = false;
|
||||||
|
|
||||||
|
await Promise.all([loadMembers(), loadSettings()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitLifecycleAction(payload: Api.Project.ChangeProjectStatusParams) {
|
||||||
|
if (!currentObjectId.value || !selectedAction.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetchChangeProjectStatus({
|
||||||
|
...payload,
|
||||||
|
id: currentObjectId.value
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success(`${selectedAction.value.actionName}成功`);
|
||||||
|
statusActionVisible.value = false;
|
||||||
|
|
||||||
|
await Promise.all([loadSettings(), refreshContextSummary()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmitDelete(payload: Api.Project.DeleteProjectParams) {
|
||||||
|
const result = await fetchDeleteProject(payload);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.$message?.success('项目删除成功');
|
||||||
|
deleteVisible.value = false;
|
||||||
|
objectContextStore.clearContext();
|
||||||
|
|
||||||
|
await routerPush({
|
||||||
|
path: '/project/list'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
visibleSectionKeys,
|
||||||
|
sectionKeys => {
|
||||||
|
activeAnchorKey.value = resolveVisibleProjectSettingSectionKey(activeAnchorKey.value, sectionKeys);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => currentObjectId.value,
|
||||||
|
async objectId => {
|
||||||
|
if (!objectId) {
|
||||||
|
settings.value = null;
|
||||||
|
members.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadPageData();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-loading="pageLoading" class="project-setting-page">
|
||||||
|
<div class="project-setting-page__body">
|
||||||
|
<div class="project-setting-page__aside">
|
||||||
|
<div v-if="isCompactLayout" class="project-setting-page__aside-shell" :style="anchorShellInlineStyle">
|
||||||
|
<SettingAnchorNav :items="anchorItems" :active-key="activeAnchorKey" @select="scrollToSection" />
|
||||||
|
</div>
|
||||||
|
<ElAffix
|
||||||
|
v-else
|
||||||
|
class="project-setting-page__aside-affix"
|
||||||
|
:offset="anchorAffixOffset"
|
||||||
|
:target="layoutScrollTarget"
|
||||||
|
teleported
|
||||||
|
>
|
||||||
|
<div class="project-setting-page__aside-shell" :style="anchorShellInlineStyle">
|
||||||
|
<SettingAnchorNav :items="anchorItems" :active-key="activeAnchorKey" @select="scrollToSection" />
|
||||||
|
</div>
|
||||||
|
</ElAffix>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-setting-page__content">
|
||||||
|
<section :id="sectionIdMap['base-info']" class="project-setting-page__section">
|
||||||
|
<SettingBaseInfoCard :base-info="baseInfo" :readonly="!canUpdateProject" @edit="baseInfoVisible = true" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section :id="sectionIdMap.team" class="project-setting-page__section">
|
||||||
|
<SettingTeamPanel
|
||||||
|
:members="members"
|
||||||
|
:role-options="roleOptions"
|
||||||
|
:loading="memberLoading"
|
||||||
|
:readonly="!canManageTeam"
|
||||||
|
@create="openCreateMember"
|
||||||
|
@edit="openEditMember"
|
||||||
|
@remove="openRemoveMember"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="showLifecycleSection" :id="sectionIdMap.lifecycle" class="project-setting-page__section">
|
||||||
|
<SettingLifecyclePanel
|
||||||
|
:lifecycle="lifecycle"
|
||||||
|
:readonly="!canChangeProjectStatus"
|
||||||
|
@action="openLifecycleAction"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="showDangerSection" :id="sectionIdMap.danger" class="project-setting-page__section">
|
||||||
|
<SettingDangerZone
|
||||||
|
:project-name="baseInfo?.projectName || currentProject?.projectName || ''"
|
||||||
|
:disabled="!canDeleteProject"
|
||||||
|
@delete="deleteVisible = true"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BaseInfoDialog v-model:visible="baseInfoVisible" :base-info="baseInfo" @submit="handleSubmitBaseInfo" />
|
||||||
|
<MemberOperateDialog
|
||||||
|
v-model:visible="memberOperateVisible"
|
||||||
|
:mode="memberOperateMode"
|
||||||
|
:member="selectedMember"
|
||||||
|
:current-manager="currentManager"
|
||||||
|
:role-options="roleOptions"
|
||||||
|
:user-options="
|
||||||
|
userOptions.filter(user => !members.some(member => member.status === 0 && member.userId === user.id))
|
||||||
|
"
|
||||||
|
@submit="handleSubmitMemberOperate"
|
||||||
|
/>
|
||||||
|
<MemberRemoveDialog
|
||||||
|
v-model:visible="memberRemoveVisible"
|
||||||
|
:member="selectedMember"
|
||||||
|
@submit="handleSubmitRemoveMember"
|
||||||
|
/>
|
||||||
|
<StatusActionDialog
|
||||||
|
v-model:visible="statusActionVisible"
|
||||||
|
:action="selectedAction"
|
||||||
|
@submit="handleSubmitLifecycleAction"
|
||||||
|
/>
|
||||||
|
<ProjectDeleteDialog
|
||||||
|
v-model:visible="deleteVisible"
|
||||||
|
:project-id="currentObjectId"
|
||||||
|
:project-name="baseInfo?.projectName || currentProject?.projectName || ''"
|
||||||
|
@submit="handleSubmitDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.project-setting-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-setting-page__body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-setting-page__aside {
|
||||||
|
min-width: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-setting-page__aside-affix {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-setting-page__aside-shell {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 18px 16px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 92%);
|
||||||
|
border-radius: 20px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgb(15 118 110 / 7%), transparent 34%),
|
||||||
|
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-setting-page__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-setting-page__section {
|
||||||
|
scroll-margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1280px) {
|
||||||
|
.project-setting-page__body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-setting-page__aside-shell {
|
||||||
|
min-height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
313
src/views/project/project/setting/modules/base-info-dialog.vue
Normal file
313
src/views/project/project/setting/modules/base-info-dialog.vue
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, reactive, watch } from 'vue';
|
||||||
|
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||||
|
import { useDict } from '@/hooks/business/dict';
|
||||||
|
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||||
|
import DictSelect from '@/components/custom/dict-select.vue';
|
||||||
|
import { getProjectBaseInfoReadonlyMessage, isProjectBaseInfoEditable } from '../shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectBaseInfoDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
baseInfo: Api.Project.ProjectSettingBaseInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submit', payload: Api.Project.UpdateProjectSettingBaseInfoParams): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||||
|
const { getLabel: getProjectTypeLabel } = useDict(RDMS_PROJECT_TYPE_DICT_CODE);
|
||||||
|
const { formRef, validate } = useForm();
|
||||||
|
const { createRequiredRule } = useFormRules();
|
||||||
|
|
||||||
|
const model = reactive<Api.Project.UpdateProjectSettingBaseInfoParams>({
|
||||||
|
projectName: '',
|
||||||
|
directionCode: '',
|
||||||
|
projectType: '',
|
||||||
|
plannedStartDate: null,
|
||||||
|
plannedEndDate: null,
|
||||||
|
projectDesc: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseInfoEditable = computed(() => isProjectBaseInfoEditable(props.baseInfo?.statusCode));
|
||||||
|
const hasAssociatedProduct = computed(() => Boolean(props.baseInfo?.productId));
|
||||||
|
const directionReadonly = computed(() => baseInfoEditable.value && hasAssociatedProduct.value);
|
||||||
|
const readonlyMessage = computed(() => getProjectBaseInfoReadonlyMessage(props.baseInfo?.statusCode));
|
||||||
|
const confirmDisabled = computed(() => !props.baseInfo || !baseInfoEditable.value);
|
||||||
|
|
||||||
|
const directionDisplayName = computed(() => {
|
||||||
|
const directionCode = props.baseInfo?.directionCode;
|
||||||
|
|
||||||
|
if (!directionCode) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDirectionLabel(directionCode, directionCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectTypeDisplayName = computed(() => {
|
||||||
|
const projectType = props.baseInfo?.projectType;
|
||||||
|
|
||||||
|
if (!projectType) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return getProjectTypeLabel(projectType, projectType);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
projectName: [createRequiredRule('请输入项目名称')],
|
||||||
|
directionCode: [createRequiredRule('请选择项目方向')],
|
||||||
|
projectType: [createRequiredRule('请选择项目类型')]
|
||||||
|
} satisfies Record<string, App.Global.FormRule[]>;
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
if (confirmDisabled.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await validate();
|
||||||
|
|
||||||
|
emit('submit', {
|
||||||
|
projectName: model.projectName.trim(),
|
||||||
|
directionCode: directionReadonly.value ? props.baseInfo?.directionCode || model.directionCode : model.directionCode,
|
||||||
|
projectType: model.projectType,
|
||||||
|
plannedStartDate: model.plannedStartDate,
|
||||||
|
plannedEndDate: model.plannedEndDate,
|
||||||
|
projectDesc: model.projectDesc?.trim() || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
async value => {
|
||||||
|
if (!value || !props.baseInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.projectName = props.baseInfo.projectName || '';
|
||||||
|
model.directionCode = props.baseInfo.directionCode || '';
|
||||||
|
model.projectType = props.baseInfo.projectType || '';
|
||||||
|
model.plannedStartDate = props.baseInfo.plannedStartDate;
|
||||||
|
model.plannedEndDate = props.baseInfo.plannedEndDate;
|
||||||
|
model.projectDesc = props.baseInfo.projectDesc || '';
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="visible"
|
||||||
|
title="编辑基础信息"
|
||||||
|
preset="md"
|
||||||
|
:confirm-disabled="confirmDisabled"
|
||||||
|
@confirm="handleConfirm"
|
||||||
|
>
|
||||||
|
<ElAlert v-if="readonlyMessage" :title="readonlyMessage" type="warning" :closable="false" class="mb-16px" />
|
||||||
|
|
||||||
|
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||||
|
<BusinessFormSection title="项目信息">
|
||||||
|
<ElRow :gutter="16">
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="项目编码">
|
||||||
|
<ElInput :model-value="baseInfo?.projectCode || ''" readonly class="base-info-dialog__readonly-input" />
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="项目名称" prop="projectName">
|
||||||
|
<ElInput
|
||||||
|
v-if="baseInfoEditable"
|
||||||
|
v-model="model.projectName"
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="请输入项目名称"
|
||||||
|
/>
|
||||||
|
<ElInput
|
||||||
|
v-else
|
||||||
|
:model-value="model.projectName"
|
||||||
|
readonly
|
||||||
|
class="base-info-dialog__readonly-input"
|
||||||
|
placeholder="未获取到项目名称"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="项目方向" prop="directionCode">
|
||||||
|
<DictSelect
|
||||||
|
v-if="baseInfoEditable && !directionReadonly"
|
||||||
|
v-model="model.directionCode"
|
||||||
|
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
|
||||||
|
filterable
|
||||||
|
placeholder="请选择项目方向"
|
||||||
|
/>
|
||||||
|
<ElInput
|
||||||
|
v-else
|
||||||
|
:model-value="directionDisplayName"
|
||||||
|
readonly
|
||||||
|
class="base-info-dialog__readonly-input"
|
||||||
|
placeholder="未获取到项目方向"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="项目类型" prop="projectType">
|
||||||
|
<DictSelect
|
||||||
|
v-if="baseInfoEditable"
|
||||||
|
v-model="model.projectType"
|
||||||
|
:dict-code="RDMS_PROJECT_TYPE_DICT_CODE"
|
||||||
|
filterable
|
||||||
|
placeholder="请选择项目类型"
|
||||||
|
/>
|
||||||
|
<ElInput
|
||||||
|
v-else
|
||||||
|
:model-value="projectTypeDisplayName"
|
||||||
|
readonly
|
||||||
|
class="base-info-dialog__readonly-input"
|
||||||
|
placeholder="未获取到项目类型"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="所属产品">
|
||||||
|
<ElInput
|
||||||
|
:model-value="baseInfo?.productName || baseInfo?.productId || '未关联产品'"
|
||||||
|
readonly
|
||||||
|
class="base-info-dialog__readonly-input"
|
||||||
|
placeholder="未关联产品"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem>
|
||||||
|
<template #label>
|
||||||
|
<span class="business-form-label-with-tip">
|
||||||
|
<ElTooltip
|
||||||
|
content="如需调整项目经理,请到项目内的团队管理处处理。"
|
||||||
|
popper-class="business-form-label-tooltip"
|
||||||
|
placement="top-start"
|
||||||
|
>
|
||||||
|
<span class="business-form-label-tip">
|
||||||
|
<icon-fe:question />
|
||||||
|
</span>
|
||||||
|
</ElTooltip>
|
||||||
|
<span>项目经理</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<ElInput
|
||||||
|
:model-value="baseInfo?.managerUserNickname || baseInfo?.managerUserId || ''"
|
||||||
|
readonly
|
||||||
|
class="base-info-dialog__readonly-input"
|
||||||
|
placeholder="未获取到项目经理"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="计划开始日期">
|
||||||
|
<ElDatePicker
|
||||||
|
v-if="baseInfoEditable"
|
||||||
|
v-model="model.plannedStartDate"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="选择计划开始日期"
|
||||||
|
class="base-info-dialog__date-picker"
|
||||||
|
/>
|
||||||
|
<ElInput
|
||||||
|
v-else
|
||||||
|
:model-value="model.plannedStartDate || ''"
|
||||||
|
readonly
|
||||||
|
class="base-info-dialog__readonly-input"
|
||||||
|
placeholder="未填写计划开始日期"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="12">
|
||||||
|
<ElFormItem label="计划结束日期">
|
||||||
|
<ElDatePicker
|
||||||
|
v-if="baseInfoEditable"
|
||||||
|
v-model="model.plannedEndDate"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
placeholder="选择计划结束日期"
|
||||||
|
class="base-info-dialog__date-picker"
|
||||||
|
/>
|
||||||
|
<ElInput
|
||||||
|
v-else
|
||||||
|
:model-value="model.plannedEndDate || ''"
|
||||||
|
readonly
|
||||||
|
class="base-info-dialog__readonly-input"
|
||||||
|
placeholder="未填写计划结束日期"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="24">
|
||||||
|
<ElFormItem label="项目说明">
|
||||||
|
<ElInput
|
||||||
|
v-if="baseInfoEditable"
|
||||||
|
v-model="model.projectDesc"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="4000"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入项目说明"
|
||||||
|
/>
|
||||||
|
<ElInput
|
||||||
|
v-else
|
||||||
|
:model-value="model.projectDesc || ''"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
readonly
|
||||||
|
class="base-info-dialog__readonly-input"
|
||||||
|
placeholder="未填写项目说明"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
</ElRow>
|
||||||
|
</BusinessFormSection>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.base-info-dialog__readonly-input .el-input__wrapper) {
|
||||||
|
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
|
||||||
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.base-info-dialog__readonly-input .el-textarea__inner) {
|
||||||
|
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
|
||||||
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
cursor: default;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.base-info-dialog__readonly-input .el-input__wrapper:hover),
|
||||||
|
:deep(.base-info-dialog__readonly-input.is-focus .el-input__wrapper),
|
||||||
|
:deep(.base-info-dialog__readonly-input .el-textarea__inner:hover),
|
||||||
|
:deep(.base-info-dialog__readonly-input .el-textarea__inner:focus) {
|
||||||
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.base-info-dialog__readonly-input .el-input__inner),
|
||||||
|
:deep(.base-info-dialog__readonly-input .el-textarea__inner) {
|
||||||
|
color: rgb(51 65 85 / 96%);
|
||||||
|
cursor: default;
|
||||||
|
-webkit-text-fill-color: rgb(51 65 85 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.base-info-dialog__date-picker.el-date-editor.el-input) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
<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';
|
||||||
|
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||||
|
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
|
||||||
|
import { getPreviousProjectManagerRoleOptions, shouldRequireProjectManagerHandover } from '../shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectMemberOperateDialog' });
|
||||||
|
|
||||||
|
type OperateMode = 'create' | 'edit';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: OperateMode;
|
||||||
|
member: Api.Project.ProjectMember | null;
|
||||||
|
currentManager: Api.Project.ProjectMember | null;
|
||||||
|
roleOptions: Api.SystemManage.RoleSimple[];
|
||||||
|
userOptions: Api.SystemManage.UserSimple[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubmitPayload {
|
||||||
|
mode: OperateMode;
|
||||||
|
memberId?: string;
|
||||||
|
managerChanged: boolean;
|
||||||
|
payload: Api.Project.CreateProjectMemberParams | Api.Project.UpdateProjectMemberParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submit', payload: SubmitPayload): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const { formRef, validate } = useForm();
|
||||||
|
const { createRequiredRule } = useFormRules();
|
||||||
|
|
||||||
|
interface Model {
|
||||||
|
userId: string;
|
||||||
|
roleId: string;
|
||||||
|
remark: string;
|
||||||
|
reason: string;
|
||||||
|
previousManagerRoleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = reactive<Model>({
|
||||||
|
userId: '',
|
||||||
|
roleId: '',
|
||||||
|
remark: '',
|
||||||
|
reason: '',
|
||||||
|
previousManagerRoleId: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => (props.mode === 'create' ? '新增团队成员' : '调整成员角色'));
|
||||||
|
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [item.id, item.nickname])));
|
||||||
|
const selectedUserId = computed(() => (props.mode === 'create' ? model.userId : props.member?.userId || ''));
|
||||||
|
const showManagerHandover = computed(() => {
|
||||||
|
return (
|
||||||
|
shouldRequireProjectManagerHandover(model.roleId, props.currentManager) &&
|
||||||
|
Boolean(selectedUserId.value) &&
|
||||||
|
selectedUserId.value !== props.currentManager?.userId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const previousManagerRoleOptions = computed(() =>
|
||||||
|
getPreviousProjectManagerRoleOptions(props.roleOptions, props.currentManager?.roleId || '')
|
||||||
|
);
|
||||||
|
|
||||||
|
const rules = computed(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
userId: props.mode === 'create' ? [createRequiredRule('请选择成员用户')] : [],
|
||||||
|
roleId: [createRequiredRule('请选择角色')],
|
||||||
|
previousManagerRoleId: showManagerHandover.value ? [createRequiredRule('请选择原项目经理交接后角色')] : []
|
||||||
|
}) satisfies Record<string, App.Global.FormRule[]>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
await validate();
|
||||||
|
|
||||||
|
const sharedPayload = {
|
||||||
|
roleId: model.roleId,
|
||||||
|
remark: model.remark.trim() || null,
|
||||||
|
previousManagerUserId: showManagerHandover.value ? props.currentManager?.userId || null : null,
|
||||||
|
previousManagerRoleId: showManagerHandover.value ? model.previousManagerRoleId : null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (props.mode === 'create') {
|
||||||
|
emit('submit', {
|
||||||
|
mode: 'create',
|
||||||
|
managerChanged: showManagerHandover.value,
|
||||||
|
payload: {
|
||||||
|
userId: model.userId,
|
||||||
|
roleId: sharedPayload.roleId,
|
||||||
|
remark: sharedPayload.remark,
|
||||||
|
previousManagerUserId: sharedPayload.previousManagerUserId,
|
||||||
|
previousManagerRoleId: sharedPayload.previousManagerRoleId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('submit', {
|
||||||
|
mode: 'edit',
|
||||||
|
memberId: props.member?.id,
|
||||||
|
managerChanged: showManagerHandover.value,
|
||||||
|
payload: {
|
||||||
|
roleId: sharedPayload.roleId,
|
||||||
|
reason: model.reason.trim() || null,
|
||||||
|
remark: sharedPayload.remark,
|
||||||
|
previousManagerUserId: sharedPayload.previousManagerUserId,
|
||||||
|
previousManagerRoleId: sharedPayload.previousManagerRoleId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
async value => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.userId = props.mode === 'create' ? '' : props.member?.userId || '';
|
||||||
|
model.roleId = props.mode === 'create' ? '' : props.member?.roleId || '';
|
||||||
|
model.remark = props.mode === 'create' ? '' : props.member?.remark || '';
|
||||||
|
model.reason = '';
|
||||||
|
model.previousManagerRoleId = '';
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
formRef.value?.clearValidate();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="sm" @confirm="handleConfirm">
|
||||||
|
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||||
|
<BusinessFormSection title="成员信息">
|
||||||
|
<ElRow :gutter="16">
|
||||||
|
<ElCol :span="24">
|
||||||
|
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
|
||||||
|
<BusinessUserSelect v-model="model.userId" :options="userOptions" placeholder="请选择成员用户" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem v-else label="成员用户">
|
||||||
|
<ElInput
|
||||||
|
:model-value="member?.userNickname || userLabelMap.get(member?.userId || '') || ''"
|
||||||
|
readonly
|
||||||
|
class="member-operate-dialog__readonly-input"
|
||||||
|
placeholder="未获取到成员用户"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="24">
|
||||||
|
<ElFormItem label="目标角色" prop="roleId">
|
||||||
|
<ElSelect v-model="model.roleId" class="w-full" filterable placeholder="请选择角色">
|
||||||
|
<ElOption v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
<ElCol :span="24">
|
||||||
|
<ElFormItem label="备注">
|
||||||
|
<ElInput
|
||||||
|
v-model="model.remark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入备注"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElCol>
|
||||||
|
</ElRow>
|
||||||
|
</BusinessFormSection>
|
||||||
|
|
||||||
|
<BusinessFormSection v-if="mode === 'edit'" title="角色调整说明">
|
||||||
|
<ElFormItem label="变更原因">
|
||||||
|
<ElInput
|
||||||
|
v-model="model.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入变更原因"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</BusinessFormSection>
|
||||||
|
|
||||||
|
<BusinessFormSection v-if="showManagerHandover" title="项目经理交接">
|
||||||
|
<ElAlert
|
||||||
|
:title="`当前项目经理 ${currentManager?.userNickname || currentManager?.userId || ''} 将完成交接,请选择其交接后角色。`"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
class="mb-16px"
|
||||||
|
/>
|
||||||
|
<ElFormItem label="原项目经理交接后角色" prop="previousManagerRoleId">
|
||||||
|
<ElSelect v-model="model.previousManagerRoleId" class="w-full" placeholder="请选择原项目经理交接后角色">
|
||||||
|
<ElOption v-for="item in previousManagerRoleOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
</BusinessFormSection>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.member-operate-dialog__readonly-input .el-input__wrapper) {
|
||||||
|
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
|
||||||
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.member-operate-dialog__readonly-input .el-input__wrapper:hover),
|
||||||
|
:deep(.member-operate-dialog__readonly-input.is-focus .el-input__wrapper) {
|
||||||
|
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.member-operate-dialog__readonly-input .el-input__inner) {
|
||||||
|
color: rgb(51 65 85 / 96%);
|
||||||
|
cursor: default;
|
||||||
|
-webkit-text-fill-color: rgb(51 65 85 / 96%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, watch } from 'vue';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectMemberRemoveDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
member: Api.Project.ProjectMember | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submit', payload: Api.Project.InactiveProjectMemberParams): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const model = reactive({
|
||||||
|
reason: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
emit('submit', {
|
||||||
|
reason: model.reason.trim() || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
value => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.reason = '';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog v-model="visible" title="移出成员" preset="sm" @confirm="handleConfirm">
|
||||||
|
<ElAlert
|
||||||
|
:title="`确认将 ${member?.userNickname || member?.userId || '--'} 从当前项目团队中移出吗?`"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
class="mb-16px"
|
||||||
|
/>
|
||||||
|
<ElForm label-position="top">
|
||||||
|
<ElFormItem label="移出原因">
|
||||||
|
<ElInput
|
||||||
|
v-model="model.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入移出原因"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectDeleteDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submit', payload: Api.Project.DeleteProjectParams): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const model = reactive({
|
||||||
|
confirmName: '',
|
||||||
|
confirmText: '',
|
||||||
|
reason: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmDisabled = computed(() => {
|
||||||
|
return (
|
||||||
|
!model.reason.trim() || model.confirmName.trim() !== props.projectName || model.confirmText.trim() !== 'DELETE'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
emit('submit', {
|
||||||
|
id: props.projectId,
|
||||||
|
projectName: model.confirmName.trim(),
|
||||||
|
confirmText: model.confirmText.trim(),
|
||||||
|
reason: model.reason.trim()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
value => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.confirmName = '';
|
||||||
|
model.confirmText = '';
|
||||||
|
model.reason = '';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="visible"
|
||||||
|
title="删除项目"
|
||||||
|
preset="sm"
|
||||||
|
:confirm-disabled="confirmDisabled"
|
||||||
|
confirm-text="确认删除"
|
||||||
|
@confirm="handleConfirm"
|
||||||
|
>
|
||||||
|
<ElAlert
|
||||||
|
:title="`请输入当前项目名称 ${projectName || '--'} 和确认口令 DELETE,删除后将退出当前对象上下文。`"
|
||||||
|
type="error"
|
||||||
|
:closable="false"
|
||||||
|
class="mb-16px"
|
||||||
|
/>
|
||||||
|
<ElForm label-position="top">
|
||||||
|
<ElFormItem label="删除确认名称">
|
||||||
|
<ElInput v-model="model.confirmName" placeholder="请输入当前项目名称" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="确认口令">
|
||||||
|
<ElInput v-model="model.confirmText" placeholder="请输入 DELETE" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem label="删除原因">
|
||||||
|
<ElInput
|
||||||
|
v-model="model.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入删除原因"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({ name: 'ProjectSettingAnchorNav' });
|
||||||
|
|
||||||
|
interface ProjectSettingAnchorItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: readonly ProjectSettingAnchorItem[];
|
||||||
|
activeKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'select', key: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="project-setting-anchor-nav">
|
||||||
|
<div class="project-setting-anchor-nav__title">设置目录</div>
|
||||||
|
<div class="project-setting-anchor-nav__list">
|
||||||
|
<button
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
|
class="project-setting-anchor-nav__item"
|
||||||
|
:class="{ 'is-active': item.key === activeKey }"
|
||||||
|
@click="emit('select', item.key)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.project-setting-anchor-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-setting-anchor-nav__title {
|
||||||
|
color: rgb(15 23 42 / 94%);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-setting-anchor-nav__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-setting-anchor-nav__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 92%);
|
||||||
|
border-radius: 14px;
|
||||||
|
background-color: rgb(248 250 252 / 96%);
|
||||||
|
color: rgb(71 85 105 / 94%);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
background-color 0.2s ease,
|
||||||
|
color 0.2s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-setting-anchor-nav__item:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgb(148 163 184 / 56%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-setting-anchor-nav__item.is-active {
|
||||||
|
border-color: rgb(13 148 136 / 42%);
|
||||||
|
background-color: rgb(240 253 250 / 98%);
|
||||||
|
color: rgb(15 118 110 / 96%);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||||
|
import { useDict } from '@/hooks/business/dict';
|
||||||
|
import { getProjectStatusLabel, getProjectStatusTagType } from '../../../shared/project-master-data';
|
||||||
|
import { isProjectBaseInfoEditable } from '../shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectSettingBaseInfoCard' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
baseInfo: Api.Project.ProjectSettingBaseInfo | null;
|
||||||
|
readonly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'edit'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
readonly: false
|
||||||
|
});
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const editDisabled = computed(() => {
|
||||||
|
if (!props.baseInfo) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.readonly || !isProjectBaseInfoEditable(props.baseInfo.statusCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
|
||||||
|
const { getLabel: getProjectTypeLabel } = useDict(RDMS_PROJECT_TYPE_DICT_CODE);
|
||||||
|
|
||||||
|
function formatDate(date: string | null | undefined) {
|
||||||
|
if (!date) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
return dayjs(date).format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActualStartDate(date: string | null | undefined) {
|
||||||
|
return date ? dayjs(date).format('YYYY-MM-DD') : '未开始';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatActualEndDate(date: string | null | undefined) {
|
||||||
|
return date ? dayjs(date).format('YYYY-MM-DD') : '未结束';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElCard class="card-wrapper">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between gap-12px">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-16px text-[#0f172a] font-700">基础信息</h3>
|
||||||
|
</div>
|
||||||
|
<ElButton type="primary" plain :disabled="editDisabled" @click="emit('edit')">编辑基础信息</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElDescriptions v-if="baseInfo" :column="2" border>
|
||||||
|
<ElDescriptionsItem label="项目编码">{{ baseInfo.projectCode || '--' }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="项目名称">{{ baseInfo.projectName || '--' }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="项目方向">
|
||||||
|
{{ getDirectionLabel(baseInfo.directionCode, '--') }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="项目类型">
|
||||||
|
{{ getProjectTypeLabel(baseInfo.projectType, '--') }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="所属产品">{{ baseInfo.productName || baseInfo.productId || '--' }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="项目经理">
|
||||||
|
{{ baseInfo.managerUserNickname || baseInfo.managerUserId || '--' }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="计划开始日期">{{ formatDate(baseInfo.plannedStartDate) }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="计划结束日期">{{ formatDate(baseInfo.plannedEndDate) }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="实际开始日期">
|
||||||
|
{{ formatActualStartDate(baseInfo.actualStartDate) }}
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="实际结束日期">{{ formatActualEndDate(baseInfo.actualEndDate) }}</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="当前状态">
|
||||||
|
<ElTag :type="getProjectStatusTagType(baseInfo.statusCode)">
|
||||||
|
{{ getProjectStatusLabel(baseInfo.statusCode) }}
|
||||||
|
</ElTag>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem label="项目说明" :span="2">
|
||||||
|
<div class="project-setting-base-info-card__description">{{ baseInfo.projectDesc || '--' }}</div>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
</ElDescriptions>
|
||||||
|
|
||||||
|
<ElEmpty v-else description="未获取到基础信息" />
|
||||||
|
</ElCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.project-setting-base-info-card__description {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({ name: 'ProjectSettingDangerZone' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectName: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'delete'): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
withDefaults(defineProps<Props>(), {
|
||||||
|
disabled: false
|
||||||
|
});
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElCard class="project-setting-danger-zone card-wrapper">
|
||||||
|
<div class="project-setting-danger-zone__content">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="text-16px text-[#7f1d1d] font-700">危险操作</h3>
|
||||||
|
<p class="mt-8px text-14px text-[#991b1b] leading-24px">
|
||||||
|
删除后将退出当前项目对象上下文,并返回项目入口页。删除时必须输入当前项目名称
|
||||||
|
<strong>{{ projectName || '--' }}</strong>
|
||||||
|
进行二次确认。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ElButton type="danger" plain :disabled="disabled" @click="emit('delete')">删除项目</ElButton>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.project-setting-danger-zone {
|
||||||
|
border: 1px solid rgb(254 202 202 / 96%);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgb(254 226 226 / 96%), transparent 35%),
|
||||||
|
linear-gradient(180deg, rgb(255 255 255 / 98%), rgb(254 242 242 / 96%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-setting-danger-zone__content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { getProjectStatusLabel } from '../../../shared/project-master-data';
|
||||||
|
import { getProjectLifecycleActionCardMeta, getProjectLifecycleStatusSummary } from '../shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectSettingLifecyclePanel' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
lifecycle: Api.Project.ProjectLifecycleInfo | null;
|
||||||
|
readonly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'action', action: Api.Project.ProjectLifecycleAction): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
readonly: false
|
||||||
|
});
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const statusSummary = computed(() => {
|
||||||
|
if (!props.lifecycle) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getProjectLifecycleStatusSummary(props.lifecycle.statusCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionCards = computed(() =>
|
||||||
|
(props.lifecycle?.availableActions || []).map(action => ({
|
||||||
|
...action,
|
||||||
|
...getProjectLifecycleActionCardMeta(action.actionCode)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElCard class="card-wrapper">
|
||||||
|
<template #header>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-16px text-[#0f172a] font-700">生命周期管理</h3>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="lifecycle">
|
||||||
|
<div class="setting-lifecycle-panel__layout">
|
||||||
|
<section
|
||||||
|
class="setting-lifecycle-panel__hero"
|
||||||
|
:class="[`setting-lifecycle-panel__hero--${statusSummary?.tone || 'slate'}`]"
|
||||||
|
>
|
||||||
|
<div class="setting-lifecycle-panel__hero-top">
|
||||||
|
<div class="setting-lifecycle-panel__hero-main">
|
||||||
|
<div class="setting-lifecycle-panel__hero-status-row">
|
||||||
|
<span class="setting-lifecycle-panel__hero-status-label">当前状态</span>
|
||||||
|
<span class="setting-lifecycle-panel__hero-status-chip">
|
||||||
|
{{ getProjectStatusLabel(lifecycle.statusCode) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h4 class="setting-lifecycle-panel__hero-title">{{ statusSummary?.caption }}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="setting-lifecycle-panel__hero-desc">
|
||||||
|
{{ statusSummary?.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="setting-lifecycle-panel__reason-card">
|
||||||
|
<span class="setting-lifecycle-panel__reason-label">最近状态原因</span>
|
||||||
|
<strong class="setting-lifecycle-panel__reason-value">
|
||||||
|
{{ lifecycle.lastStatusReason || '当前没有记录状态原因。' }}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="setting-lifecycle-panel__action-panel">
|
||||||
|
<div class="setting-lifecycle-panel__action-head">
|
||||||
|
<h4 class="setting-lifecycle-panel__action-title">可执行动作</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="actionCards.length > 0" class="setting-lifecycle-panel__action-grid">
|
||||||
|
<button
|
||||||
|
v-for="action in actionCards"
|
||||||
|
:key="action.actionCode"
|
||||||
|
type="button"
|
||||||
|
class="setting-lifecycle-panel__action-card"
|
||||||
|
:class="[`setting-lifecycle-panel__action-card--${action.tone}`]"
|
||||||
|
:disabled="props.readonly"
|
||||||
|
@click="emit('action', action)"
|
||||||
|
>
|
||||||
|
<div class="setting-lifecycle-panel__action-card-top">
|
||||||
|
<span class="setting-lifecycle-panel__action-dot" aria-hidden="true"></span>
|
||||||
|
<strong class="setting-lifecycle-panel__action-name">{{ action.actionName }}</strong>
|
||||||
|
</div>
|
||||||
|
<p class="setting-lifecycle-panel__action-desc">{{ action.description }}</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="setting-lifecycle-panel__empty-tip">当前状态下暂无可执行生命周期动作。</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElEmpty v-else description="未获取到生命周期信息" />
|
||||||
|
</ElCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.setting-lifecycle-panel__layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__hero,
|
||||||
|
.setting-lifecycle-panel__action-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 92%);
|
||||||
|
border-radius: 20px;
|
||||||
|
background-color: rgb(248 250 252 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__hero {
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgb(15 118 110 / 10%), transparent 34%),
|
||||||
|
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__hero--emerald {
|
||||||
|
border-color: rgb(16 185 129 / 22%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__hero--amber {
|
||||||
|
border-color: rgb(245 158 11 / 22%);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgb(245 158 11 / 10%), transparent 34%),
|
||||||
|
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(255 251 235 / 97%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__hero--slate {
|
||||||
|
border-color: rgb(100 116 139 / 22%);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgb(100 116 139 / 10%), transparent 34%),
|
||||||
|
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__hero--rose {
|
||||||
|
border-color: rgb(244 63 94 / 22%);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgb(244 63 94 / 10%), transparent 34%),
|
||||||
|
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(255 241 242 / 97%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__hero-top,
|
||||||
|
.setting-lifecycle-panel__action-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__hero-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__hero-status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__hero-status-label {
|
||||||
|
color: rgb(71 85 105 / 94%);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__hero-status-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__hero-title {
|
||||||
|
color: rgb(15 23 42 / 96%);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-title {
|
||||||
|
color: rgb(15 23 42 / 96%);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__hero-desc {
|
||||||
|
max-width: 560px;
|
||||||
|
color: rgb(71 85 105 / 94%);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__reason-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 88%);
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: rgb(255 255 255 / 82%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__reason-label {
|
||||||
|
color: rgb(100 116 139 / 92%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__reason-value {
|
||||||
|
color: rgb(15 23 42 / 94%);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-panel {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgb(59 130 246 / 7%), transparent 32%),
|
||||||
|
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid rgb(226 232 240 / 92%);
|
||||||
|
border-radius: 18px;
|
||||||
|
background-color: rgb(255 255 255 / 96%);
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
border-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-card:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 10px 22px rgb(15 23 42 / 6%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-card:disabled {
|
||||||
|
opacity: 0.58;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-card:disabled:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: currentcolor;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-name {
|
||||||
|
color: rgb(15 23 42 / 96%);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-desc {
|
||||||
|
color: rgb(71 85 105 / 94%);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__empty-tip {
|
||||||
|
padding: 18px 16px;
|
||||||
|
border: 1px dashed rgb(203 213 225 / 92%);
|
||||||
|
border-radius: 16px;
|
||||||
|
color: rgb(100 116 139 / 92%);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__hero--emerald .setting-lifecycle-panel__hero-status-chip {
|
||||||
|
border-color: rgb(16 185 129 / 24%);
|
||||||
|
background-color: rgb(236 253 245 / 90%);
|
||||||
|
color: rgb(4 120 87 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__hero--amber .setting-lifecycle-panel__hero-status-chip {
|
||||||
|
border-color: rgb(245 158 11 / 24%);
|
||||||
|
background-color: rgb(255 247 237 / 94%);
|
||||||
|
color: rgb(180 83 9 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__hero--slate .setting-lifecycle-panel__hero-status-chip {
|
||||||
|
border-color: rgb(148 163 184 / 28%);
|
||||||
|
background-color: rgb(241 245 249 / 94%);
|
||||||
|
color: rgb(71 85 105 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__hero--rose .setting-lifecycle-panel__hero-status-chip {
|
||||||
|
border-color: rgb(244 63 94 / 24%);
|
||||||
|
background-color: rgb(255 241 242 / 94%);
|
||||||
|
color: rgb(190 24 93 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-card--emerald {
|
||||||
|
border-color: rgb(16 185 129 / 22%);
|
||||||
|
background: linear-gradient(90deg, rgb(236 253 245 / 90%), rgb(255 255 255 / 96%) 26%);
|
||||||
|
color: rgb(4 120 87 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-card--amber {
|
||||||
|
border-color: rgb(245 158 11 / 22%);
|
||||||
|
background: linear-gradient(90deg, rgb(255 247 237 / 92%), rgb(255 255 255 / 96%) 26%);
|
||||||
|
color: rgb(180 83 9 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-card--slate {
|
||||||
|
border-color: rgb(148 163 184 / 26%);
|
||||||
|
background: linear-gradient(90deg, rgb(241 245 249 / 92%), rgb(255 255 255 / 96%) 26%);
|
||||||
|
color: rgb(71 85 105 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-card--rose {
|
||||||
|
border-color: rgb(244 63 94 / 22%);
|
||||||
|
background: linear-gradient(90deg, rgb(255 241 242 / 92%), rgb(255 255 255 / 96%) 26%);
|
||||||
|
color: rgb(190 24 93 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-card--emerald:hover {
|
||||||
|
box-shadow: 0 10px 22px rgb(16 185 129 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-card--amber:hover {
|
||||||
|
box-shadow: 0 10px 22px rgb(245 158 11 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-card--slate:hover {
|
||||||
|
box-shadow: 0 10px 22px rgb(100 116 139 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-card--rose:hover {
|
||||||
|
box-shadow: 0 10px 22px rgb(244 63 94 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-card--emerald .setting-lifecycle-panel__action-name {
|
||||||
|
color: rgb(6 95 70 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-card--amber .setting-lifecycle-panel__action-name {
|
||||||
|
color: rgb(146 64 14 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-card--slate .setting-lifecycle-panel__action-name {
|
||||||
|
color: rgb(51 65 85 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-lifecycle-panel__action-card--rose .setting-lifecycle-panel__action-name {
|
||||||
|
color: rgb(159 18 57 / 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1280px) {
|
||||||
|
.setting-lifecycle-panel__layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 640px) {
|
||||||
|
.setting-lifecycle-panel__hero-top,
|
||||||
|
.setting-lifecycle-panel__action-head {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
191
src/views/project/project/setting/modules/setting-team-panel.vue
Normal file
191
src/views/project/project/setting/modules/setting-team-panel.vue
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { filterProjectMembers, formatProjectMemberDate, getProjectTeamTableHeight } from '../shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectSettingTeamPanel' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
members: Api.Project.ProjectMember[];
|
||||||
|
roleOptions?: Api.SystemManage.RoleSimple[];
|
||||||
|
loading?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'create'): void;
|
||||||
|
(e: 'edit', member: Api.Project.ProjectMember): void;
|
||||||
|
(e: 'remove', member: Api.Project.ProjectMember): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
loading: false,
|
||||||
|
readonly: false,
|
||||||
|
roleOptions: () => []
|
||||||
|
});
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
const selectedRoleId = ref('');
|
||||||
|
const teamTableHeight = getProjectTeamTableHeight(5);
|
||||||
|
const roleFilterOptions = computed(() => {
|
||||||
|
const roleMap = new Map<string, string>();
|
||||||
|
|
||||||
|
props.roleOptions.forEach(role => {
|
||||||
|
if (!roleMap.has(role.id)) {
|
||||||
|
roleMap.set(role.id, role.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...roleMap.entries()].map(([value, label]) => ({
|
||||||
|
value,
|
||||||
|
label
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
const filteredMembers = computed(() =>
|
||||||
|
filterProjectMembers(props.members, {
|
||||||
|
keyword: searchKeyword.value,
|
||||||
|
roleId: selectedRoleId.value
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const hasFilter = computed(() => Boolean(searchKeyword.value.trim() || selectedRoleId.value));
|
||||||
|
|
||||||
|
watch(roleFilterOptions, options => {
|
||||||
|
if (selectedRoleId.value && !options.some(item => item.value === selectedRoleId.value)) {
|
||||||
|
selectedRoleId.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function getMemberStatusLabel(status: Api.Project.ProjectMemberStatus) {
|
||||||
|
return status === 0 ? '有效' : '失效';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemberStatusTagType(status: Api.Project.ProjectMemberStatus) {
|
||||||
|
return status === 0 ? 'success' : 'info';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElCard class="card-wrapper">
|
||||||
|
<template #header>
|
||||||
|
<div class="setting-team-panel__header">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-16px text-[#0f172a] font-700">团队管理</h3>
|
||||||
|
</div>
|
||||||
|
<div class="setting-team-panel__toolbar">
|
||||||
|
<ElSelect v-model="selectedRoleId" clearable placeholder="筛选角色" class="setting-team-panel__role-filter">
|
||||||
|
<ElOption
|
||||||
|
v-for="option in roleFilterOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
<ElInput v-model="searchKeyword" clearable placeholder="搜索成员姓名" class="setting-team-panel__search" />
|
||||||
|
<ElButton type="primary" plain :disabled="props.readonly" @click="emit('create')">新增成员</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElTable
|
||||||
|
v-loading="props.loading"
|
||||||
|
:data="filteredMembers"
|
||||||
|
:height="teamTableHeight"
|
||||||
|
:empty-text="hasFilter ? '未找到匹配成员' : '暂无成员'"
|
||||||
|
border
|
||||||
|
row-key="id"
|
||||||
|
>
|
||||||
|
<ElTableColumn type="index" label="序号" width="64" align="center" />
|
||||||
|
<ElTableColumn prop="userNickname" label="成员姓名" min-width="140" />
|
||||||
|
<ElTableColumn prop="roleName" label="当前角色" min-width="140" />
|
||||||
|
<ElTableColumn label="成员状态" width="110" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag :type="getMemberStatusTagType(row.status)">{{ getMemberStatusLabel(row.status) }}</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="joinedTime" label="加入时间" min-width="132" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatProjectMemberDate(row.joinedTime) }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="leftTime" label="退出时间" min-width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatProjectMemberDate(row.leftTime) }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn prop="remark" label="备注" min-width="180" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.remark || '--' }}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn label="操作" width="180" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="setting-team-panel__actions">
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
:disabled="props.readonly || row.status !== 0 || row.managerFlag"
|
||||||
|
@click="emit('edit', row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
:disabled="props.readonly || row.status !== 0 || row.managerFlag"
|
||||||
|
@click="emit('remove', row)"
|
||||||
|
>
|
||||||
|
移出成员
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
</ElCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.setting-team-panel__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-team-panel__toolbar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-team-panel__search {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-team-panel__role-filter {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-team-panel__actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.setting-team-panel__header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-team-panel__toolbar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-team-panel__search {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-team-panel__role-filter {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue';
|
||||||
|
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectStatusActionDialog' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
action: Api.Project.ProjectLifecycleAction | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'submit', payload: Api.Project.ChangeProjectStatusParams): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>('visible', {
|
||||||
|
default: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const model = reactive({
|
||||||
|
reason: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmDisabled = computed(() => Boolean(props.action?.needReason && !model.reason.trim()));
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (!props.action) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('submit', {
|
||||||
|
id: '',
|
||||||
|
actionCode: props.action.actionCode,
|
||||||
|
reason: model.reason.trim() || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => visible.value,
|
||||||
|
value => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.reason = '';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BusinessFormDialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="action ? action.actionName : '生命周期动作'"
|
||||||
|
preset="sm"
|
||||||
|
:confirm-disabled="confirmDisabled"
|
||||||
|
@confirm="handleConfirm"
|
||||||
|
>
|
||||||
|
<ElForm label-position="top">
|
||||||
|
<ElFormItem :label="action?.needReason ? '动作原因(必填)' : '动作原因(选填)'">
|
||||||
|
<ElInput
|
||||||
|
v-model="model.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入动作原因"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
</BusinessFormDialog>
|
||||||
|
</template>
|
||||||
214
src/views/project/project/setting/shared.ts
Normal file
214
src/views/project/project/setting/shared.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export interface ProjectManagerMemberLike {
|
||||||
|
roleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectTeamManageContext {
|
||||||
|
buttonCodes: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectLifecycleStatusSummary {
|
||||||
|
tone: 'emerald' | 'amber' | 'slate' | 'rose';
|
||||||
|
caption: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectLifecycleActionCardMeta {
|
||||||
|
tone: 'emerald' | 'amber' | 'slate' | 'rose';
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const projectSettingSectionKeys = ['base-info', 'team', 'lifecycle', 'danger'] as const;
|
||||||
|
|
||||||
|
export type ProjectSettingSectionKey = (typeof projectSettingSectionKeys)[number];
|
||||||
|
|
||||||
|
const projectBaseInfoReadonlyMessageMap: Partial<Record<Api.Project.ProjectStatusCode, string>> = {
|
||||||
|
paused: '当前项目已暂停,基础信息仅支持查看,不可编辑。',
|
||||||
|
completed: '当前项目已完成,基础信息仅支持查看,不可编辑。',
|
||||||
|
cancelled: '当前项目已取消,基础信息仅支持查看,不可编辑。',
|
||||||
|
archived: '当前项目已归档,基础信息仅支持查看,不可编辑。'
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectLifecycleStatusSummaryMap: Record<Api.Project.ProjectStatusCode, ProjectLifecycleStatusSummary> = {
|
||||||
|
pending: {
|
||||||
|
tone: 'slate',
|
||||||
|
caption: '项目待开始',
|
||||||
|
description: '项目已创建,尚未进入真实推进阶段。'
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
tone: 'emerald',
|
||||||
|
caption: '项目推进中',
|
||||||
|
description: '当前可以暂停、完成或取消项目。'
|
||||||
|
},
|
||||||
|
paused: {
|
||||||
|
tone: 'amber',
|
||||||
|
caption: '项目已暂停',
|
||||||
|
description: '条件恢复后可重新推进,也可取消项目。'
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
tone: 'emerald',
|
||||||
|
caption: '项目已完成',
|
||||||
|
description: '项目已完成,可按业务需要重新开启或归档。'
|
||||||
|
},
|
||||||
|
cancelled: {
|
||||||
|
tone: 'rose',
|
||||||
|
caption: '项目已取消',
|
||||||
|
description: '项目已取消,当前暂无可执行生命周期动作。'
|
||||||
|
},
|
||||||
|
archived: {
|
||||||
|
tone: 'slate',
|
||||||
|
caption: '项目已归档',
|
||||||
|
description: '项目已收口归档,当前暂无可执行生命周期动作。'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectLifecycleActionCardMetaMap: Record<
|
||||||
|
Exclude<Api.Project.ProjectStatusActionCode, 'auto_start'>,
|
||||||
|
ProjectLifecycleActionCardMeta
|
||||||
|
> = {
|
||||||
|
pause: {
|
||||||
|
tone: 'amber',
|
||||||
|
description: '暂停当前项目,暂停期间不允许普通编辑和成员维护。'
|
||||||
|
},
|
||||||
|
resume: {
|
||||||
|
tone: 'emerald',
|
||||||
|
description: '恢复项目推进,重新进入进行中状态。'
|
||||||
|
},
|
||||||
|
complete: {
|
||||||
|
tone: 'emerald',
|
||||||
|
description: '确认项目完成,结束当前项目推进。'
|
||||||
|
},
|
||||||
|
cancel: {
|
||||||
|
tone: 'rose',
|
||||||
|
description: '取消当前项目,需要填写取消原因。'
|
||||||
|
},
|
||||||
|
reopen: {
|
||||||
|
tone: 'emerald',
|
||||||
|
description: '重新开启项目,恢复到进行中状态。'
|
||||||
|
},
|
||||||
|
archive: {
|
||||||
|
tone: 'slate',
|
||||||
|
description: '归档已完成项目,保留历史记录。'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectTeamTableHeaderHeight = 40;
|
||||||
|
const projectTeamTableRowHeight = 40;
|
||||||
|
|
||||||
|
export function shouldRequireProjectManagerHandover(
|
||||||
|
targetRoleId: string,
|
||||||
|
currentManager: ProjectManagerMemberLike | null | undefined
|
||||||
|
) {
|
||||||
|
if (!currentManager?.roleId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetRoleId === currentManager.roleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPreviousProjectManagerRoleOptions(
|
||||||
|
roleOptions: Api.SystemManage.RoleSimple[],
|
||||||
|
managerRoleId: string
|
||||||
|
) {
|
||||||
|
return roleOptions.filter(role => role.id !== managerRoleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectSettingSectionKeys() {
|
||||||
|
return [...projectSettingSectionKeys];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProjectBaseInfoEditable(status: Api.Project.ProjectStatusCode | null | undefined) {
|
||||||
|
return status === 'pending' || status === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveVisibleProjectSettingSections(
|
||||||
|
sectionKeys: readonly ProjectSettingSectionKey[],
|
||||||
|
_buttonCodes: readonly string[]
|
||||||
|
) {
|
||||||
|
return [...sectionKeys];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveVisibleProjectSettingSectionKey(
|
||||||
|
currentKey: ProjectSettingSectionKey | string | null | undefined,
|
||||||
|
visibleSectionKeys: readonly ProjectSettingSectionKey[]
|
||||||
|
) {
|
||||||
|
if (!visibleSectionKeys.length) {
|
||||||
|
return 'base-info' satisfies ProjectSettingSectionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentKey && visibleSectionKeys.includes(currentKey as ProjectSettingSectionKey)) {
|
||||||
|
return currentKey as ProjectSettingSectionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return visibleSectionKeys[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectBaseInfoReadonlyMessage(status: Api.Project.ProjectStatusCode | null | undefined) {
|
||||||
|
if (!status || isProjectBaseInfoEditable(status)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectBaseInfoReadonlyMessageMap[status] || '当前项目状态不允许编辑基础信息。';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectLifecycleStatusSummary(status: Api.Project.ProjectStatusCode) {
|
||||||
|
return projectLifecycleStatusSummaryMap[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectLifecycleActionCardMeta(
|
||||||
|
actionCode: Exclude<Api.Project.ProjectStatusActionCode, 'auto_start'>
|
||||||
|
) {
|
||||||
|
return projectLifecycleActionCardMetaMap[actionCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatProjectMemberDate(value: string | number | null | undefined) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedValue = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
|
||||||
|
const parsedDate = dayjs(normalizedValue);
|
||||||
|
|
||||||
|
if (!parsedDate.isValid()) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedDate.format('YYYY-MM-DD');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterProjectMembers(
|
||||||
|
members: readonly Api.Project.ProjectMember[],
|
||||||
|
filters: {
|
||||||
|
keyword?: string | null | undefined;
|
||||||
|
roleId?: string | null | undefined;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const normalizedKeyword = String(filters.keyword || '')
|
||||||
|
.trim()
|
||||||
|
.toLocaleLowerCase();
|
||||||
|
const normalizedRoleId = String(filters.roleId || '').trim();
|
||||||
|
|
||||||
|
if (!normalizedKeyword && !normalizedRoleId) {
|
||||||
|
return [...members];
|
||||||
|
}
|
||||||
|
|
||||||
|
return members.filter(member => {
|
||||||
|
const matchesKeyword = !normalizedKeyword || member.userNickname.toLocaleLowerCase().includes(normalizedKeyword);
|
||||||
|
const matchesRole = !normalizedRoleId || member.roleId === normalizedRoleId;
|
||||||
|
|
||||||
|
return matchesKeyword && matchesRole;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectTeamTableHeight(visibleRows: number) {
|
||||||
|
const normalizedRows = Math.max(0, visibleRows);
|
||||||
|
|
||||||
|
return projectTeamTableHeaderHeight + normalizedRows * projectTeamTableRowHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canManageProjectTeam(context: ProjectTeamManageContext) {
|
||||||
|
return (
|
||||||
|
context.buttonCodes.includes('project:project:member') || context.buttonCodes.includes('project:project:update')
|
||||||
|
);
|
||||||
|
}
|
||||||
115
src/views/project/shared/project-context-banner.vue
Normal file
115
src/views/project/shared/project-context-banner.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script setup lang="tsx">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { getProjectStatusLabel, getProjectStatusTagType } from './project-master-data';
|
||||||
|
import type { CurrentProjectSummary } from './project-context-shared';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProjectContextBanner' });
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: CurrentProjectSummary | null;
|
||||||
|
caption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
caption: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectStatusCode = computed(() => props.project?.statusCode as Api.Project.ProjectStatusCode | undefined);
|
||||||
|
|
||||||
|
const summaryItems = computed(() => {
|
||||||
|
if (!props.project) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: '项目 ID', value: props.project.id || '--' },
|
||||||
|
{ label: '项目编码', value: props.project.projectCode || '--' },
|
||||||
|
{ label: '项目类型', value: props.project.projectType || '--' },
|
||||||
|
{ label: '项目负责人', value: props.project.managerUserId || '--' }
|
||||||
|
];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElCard class="project-context-banner card-wrapper">
|
||||||
|
<template v-if="project">
|
||||||
|
<div class="flex flex-col gap-20px lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="mb-12px flex flex-wrap items-center gap-10px">
|
||||||
|
<span class="project-context-banner__code">{{ project.projectCode }}</span>
|
||||||
|
<ElTag v-if="projectStatusCode" :type="getProjectStatusTagType(projectStatusCode)" effect="light" round>
|
||||||
|
{{ getProjectStatusLabel(projectStatusCode) }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
<div class="mb-10px flex flex-wrap items-center gap-12px">
|
||||||
|
<h2 class="text-24px text-[#0f172a] font-700">{{ project.projectName }}</h2>
|
||||||
|
<span v-if="caption" class="text-14px text-[#64748b]">{{ caption }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-x-18px gap-y-8px text-13px text-[#64748b] leading-22px">
|
||||||
|
<span>对象 ID:{{ project.id || '--' }}</span>
|
||||||
|
<span>类型:{{ project.projectType || '--' }}</span>
|
||||||
|
<span>项目负责人:{{ project.managerUserId || '--' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="project-context-banner__stats">
|
||||||
|
<div v-for="item in summaryItems" :key="item.label" class="project-context-banner__stat-card">
|
||||||
|
<span class="project-context-banner__stat-label">{{ item.label }}</span>
|
||||||
|
<strong class="project-context-banner__stat-value">{{ item.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ElEmpty v-else description="未获取到当前项目上下文" :image-size="84" />
|
||||||
|
</ElCard>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.project-context-banner {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgb(226 232 240 / 88%);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgb(14 165 233 / 10%), transparent 32%),
|
||||||
|
linear-gradient(135deg, rgb(255 255 255 / 98%), rgb(248 250 252 / 96%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-context-banner__code {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: rgb(15 23 42 / 88%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-context-banner__stats {
|
||||||
|
display: grid;
|
||||||
|
flex-shrink: 0;
|
||||||
|
grid-template-columns: repeat(2, minmax(132px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
width: min(100%, 320px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-context-banner__stat-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgb(148 163 184 / 18%);
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: rgb(255 255 255 / 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-context-banner__stat-label {
|
||||||
|
color: rgb(100 116 139 / 90%);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-context-banner__stat-value {
|
||||||
|
color: rgb(15 23 42 / 94%);
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
9
src/views/project/shared/project-context-shared.ts
Normal file
9
src/views/project/shared/project-context-shared.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface CurrentProjectSummary {
|
||||||
|
id: string;
|
||||||
|
projectCode: string;
|
||||||
|
projectName: string;
|
||||||
|
projectType: string;
|
||||||
|
productId: string | null;
|
||||||
|
managerUserId: string;
|
||||||
|
statusCode: string;
|
||||||
|
}
|
||||||
87
src/views/project/shared/project-master-data.ts
Normal file
87
src/views/project/shared/project-master-data.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { transformRecordToOption } from '@/utils/common';
|
||||||
|
|
||||||
|
/** 项目状态编码与中文标签映射 */
|
||||||
|
export const projectStatusRecord: Record<Api.Project.ProjectStatusCode, string> = {
|
||||||
|
pending: '待开始',
|
||||||
|
active: '进行中',
|
||||||
|
paused: '已暂停',
|
||||||
|
completed: '已完成',
|
||||||
|
cancelled: '已取消',
|
||||||
|
archived: '已归档'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const projectStatusOptions = transformRecordToOption(projectStatusRecord);
|
||||||
|
|
||||||
|
/** 项目状态动作编码与中文标签映射 */
|
||||||
|
export const projectStatusActionRecord: Record<Api.Project.ProjectStatusActionCode, string> = {
|
||||||
|
auto_start: '自动开始',
|
||||||
|
pause: '暂停项目',
|
||||||
|
resume: '恢复项目',
|
||||||
|
complete: '完成项目',
|
||||||
|
cancel: '取消项目',
|
||||||
|
reopen: '重新开启',
|
||||||
|
archive: '归档项目'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getProjectStatusLabel(status: Api.Project.ProjectStatusCode) {
|
||||||
|
return projectStatusRecord[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据项目状态返回对应的 Tag 类型,用于 ElTag 组件的颜色映射 */
|
||||||
|
export function getProjectStatusTagType(status: Api.Project.ProjectStatusCode): UI.ThemeColor {
|
||||||
|
const statusTagTypeMap: Record<Api.Project.ProjectStatusCode, UI.ThemeColor> = {
|
||||||
|
pending: 'info',
|
||||||
|
active: 'success',
|
||||||
|
paused: 'warning',
|
||||||
|
completed: 'success',
|
||||||
|
cancelled: 'info',
|
||||||
|
archived: 'info'
|
||||||
|
};
|
||||||
|
|
||||||
|
return statusTagTypeMap[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断项目是否可编辑:pending / active / paused 状态允许编辑 */
|
||||||
|
export function isProjectEditable(status: Api.Project.ProjectStatusCode) {
|
||||||
|
return status === 'active' || status === 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断项目编辑是否受限:paused / completed 状态只能编辑部分字段 */
|
||||||
|
export function isProjectEditLimited(status: Api.Project.ProjectStatusCode) {
|
||||||
|
return status === 'paused' || status === 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据当前状态获取允许的状态动作列表 */
|
||||||
|
export function getAllowedProjectStatusActions(
|
||||||
|
status: Api.Project.ProjectStatusCode
|
||||||
|
): Array<Exclude<Api.Project.ProjectStatusActionCode, 'auto_start'>> {
|
||||||
|
const actionMap: Record<
|
||||||
|
Api.Project.ProjectStatusCode,
|
||||||
|
Array<Exclude<Api.Project.ProjectStatusActionCode, 'auto_start'>>
|
||||||
|
> = {
|
||||||
|
pending: ['cancel'],
|
||||||
|
active: ['pause', 'complete', 'cancel'],
|
||||||
|
paused: ['resume', 'cancel'],
|
||||||
|
completed: ['reopen', 'archive'],
|
||||||
|
cancelled: [],
|
||||||
|
archived: []
|
||||||
|
};
|
||||||
|
|
||||||
|
return actionMap[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectStatusActionLabel(actionCode: Api.Project.ProjectStatusActionCode) {
|
||||||
|
return projectStatusActionRecord[actionCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectStatusActionOptions(status: Api.Project.ProjectStatusCode) {
|
||||||
|
return getAllowedProjectStatusActions(status).map(actionCode => ({
|
||||||
|
value: actionCode,
|
||||||
|
label: getProjectStatusActionLabel(actionCode)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断状态动作是否必须填写原因:resume 和 auto_start 不需要原因 */
|
||||||
|
export function isProjectActionReasonRequired(actionCode: Api.Project.ProjectStatusActionCode) {
|
||||||
|
return actionCode !== 'resume' && actionCode !== 'auto_start';
|
||||||
|
}
|
||||||
42
src/views/project/shared/use-current-project.ts
Normal file
42
src/views/project/shared/use-current-project.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前项目上下文
|
||||||
|
*/
|
||||||
|
export function useCurrentProject() {
|
||||||
|
const objectContextStore = useObjectContextStore();
|
||||||
|
|
||||||
|
const currentObjectId = computed(() => objectContextStore.objectId);
|
||||||
|
|
||||||
|
const currentProject = computed(() => {
|
||||||
|
const summary = objectContextStore.objectSummary;
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((summary as unknown as Api.Project.ProjectContext).currentProject || null) as
|
||||||
|
| Api.Project.ProjectContext['currentProject']
|
||||||
|
| null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentRole = computed(() => {
|
||||||
|
const summary = objectContextStore.objectSummary;
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (summary as unknown as Api.Project.ProjectContext).currentRole || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isGuest = computed(() => currentRole.value?.guestFlag ?? true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentObjectId,
|
||||||
|
currentProject,
|
||||||
|
currentRole,
|
||||||
|
isGuest
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -298,6 +298,14 @@ async function handleSearchDictType() {
|
|||||||
await getDictTypeList();
|
await getDictTypeList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resetDictTypeSearchParams() {
|
||||||
|
const pageSize = dictTypeSearchParams.pageSize;
|
||||||
|
|
||||||
|
Object.assign(dictTypeSearchParams, getInitDictTypeSearchParams(), { pageSize });
|
||||||
|
|
||||||
|
await getDictTypeList();
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSearchDictData() {
|
async function handleSearchDictData() {
|
||||||
dictDataSearchParams.pageNo = 1;
|
dictDataSearchParams.pageNo = 1;
|
||||||
await getDictDataByPage(1);
|
await getDictDataByPage(1);
|
||||||
@@ -349,7 +357,11 @@ onMounted(() => {
|
|||||||
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[360px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[360px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||||
>
|
>
|
||||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||||
<DictTypeSearch v-model:model="dictTypeSearchParams" @search="handleSearchDictType" />
|
<DictTypeSearch
|
||||||
|
v-model:model="dictTypeSearchParams"
|
||||||
|
@reset="resetDictTypeSearchParams"
|
||||||
|
@search="handleSearchDictType"
|
||||||
|
/>
|
||||||
<ElCard v-loading="typeLoading" class="card-wrapper xl:flex-1-hidden" body-class="dict-type-card-body">
|
<ElCard v-loading="typeLoading" class="card-wrapper xl:flex-1-hidden" body-class="dict-type-card-body">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="flex items-center justify-between gap-12px py-2px">
|
<div class="flex items-center justify-between gap-12px py-2px">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
import { dictStatusOptions } from '@/constants/business';
|
import { dictStatusOptions } from '@/constants/business';
|
||||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||||
import { $t } from '@/locales';
|
import { $t } from '@/locales';
|
||||||
|
|
||||||
defineOptions({ name: 'DictDataSearch' });
|
defineOptions({ name: 'DictDataSearch' });
|
||||||
@@ -9,7 +10,7 @@ interface Props {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
disabled: false
|
disabled: false
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -22,6 +23,25 @@ const emit = defineEmits<Emits>();
|
|||||||
|
|
||||||
const model = defineModel<Api.Dict.DictDataSearchParams>('model', { required: true });
|
const model = defineModel<Api.Dict.DictDataSearchParams>('model', { required: true });
|
||||||
|
|
||||||
|
const fields = computed<SearchField[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'label',
|
||||||
|
label: $t('page.system.dict.dictLabel'),
|
||||||
|
type: 'input',
|
||||||
|
placeholder: $t('page.system.dict.form.dictLabel')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: $t('page.system.dict.dictStatus'),
|
||||||
|
type: 'select',
|
||||||
|
placeholder: $t('page.system.dict.form.dictStatus'),
|
||||||
|
options: dictStatusOptions.map(item => ({
|
||||||
|
label: $t(item.label),
|
||||||
|
value: item.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
emit('reset');
|
emit('reset');
|
||||||
}
|
}
|
||||||
@@ -32,37 +52,12 @@ function handleSearch() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableSearchPanel
|
<TableSearchFields
|
||||||
:model="model"
|
v-model="model"
|
||||||
:disabled="disabled"
|
:fields="fields"
|
||||||
:action-col-lg="12"
|
:columns="3"
|
||||||
:action-col-md="8"
|
:disabled="props.disabled"
|
||||||
@reset="handleReset"
|
@reset="handleReset"
|
||||||
@search="handleSearch"
|
@search="handleSearch"
|
||||||
>
|
/>
|
||||||
<ElCol :lg="6" :md="8" :sm="12">
|
|
||||||
<ElFormItem :label="$t('page.system.dict.dictLabel')" prop="label">
|
|
||||||
<ElInput
|
|
||||||
v-model="model.label"
|
|
||||||
clearable
|
|
||||||
:disabled="disabled"
|
|
||||||
:placeholder="$t('page.system.dict.form.dictLabel')"
|
|
||||||
/>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :lg="6" :md="8" :sm="12">
|
|
||||||
<ElFormItem :label="$t('page.system.dict.dictStatus')" prop="status">
|
|
||||||
<ElSelect
|
|
||||||
v-model="model.status"
|
|
||||||
clearable
|
|
||||||
:disabled="disabled"
|
|
||||||
:placeholder="$t('page.system.dict.form.dictStatus')"
|
|
||||||
>
|
|
||||||
<ElOption v-for="{ label, value } in dictStatusOptions" :key="value" :label="$t(label)" :value="value" />
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
</TableSearchPanel>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|||||||
@@ -106,8 +106,6 @@ const title = computed(() => {
|
|||||||
return titleMap[props.operateType];
|
return titleMap[props.operateType];
|
||||||
});
|
});
|
||||||
|
|
||||||
const dialogWidth = '780px';
|
|
||||||
|
|
||||||
const model = ref(createDefaultModel());
|
const model = ref(createDefaultModel());
|
||||||
|
|
||||||
function getCurrentScopeParams(): Api.SystemManage.ScopeQueryParams {
|
function getCurrentScopeParams(): Api.SystemManage.ScopeQueryParams {
|
||||||
@@ -1163,7 +1161,7 @@ watch(visible, value => {
|
|||||||
<BusinessFormDialog
|
<BusinessFormDialog
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
:title="title"
|
:title="title"
|
||||||
:width="dialogWidth"
|
preset="lg"
|
||||||
:loading="detailLoading"
|
:loading="detailLoading"
|
||||||
:confirm-loading="submitting"
|
:confirm-loading="submitting"
|
||||||
@confirm="handleSubmit"
|
@confirm="handleSubmit"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
import { commonStatusOptions } from '@/constants/business';
|
import { commonStatusOptions } from '@/constants/business';
|
||||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||||
import { $t } from '@/locales';
|
import { $t } from '@/locales';
|
||||||
|
|
||||||
defineOptions({ name: 'MenuSearch' });
|
defineOptions({ name: 'MenuSearch' });
|
||||||
@@ -22,6 +23,25 @@ const emit = defineEmits<Emits>();
|
|||||||
|
|
||||||
const model = defineModel<Api.SystemManage.MenuSearchParams>('model', { required: true });
|
const model = defineModel<Api.SystemManage.MenuSearchParams>('model', { required: true });
|
||||||
|
|
||||||
|
const fields = computed<SearchField[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: $t('page.system.menu.menuName'),
|
||||||
|
type: 'input',
|
||||||
|
placeholder: $t('page.system.menu.form.menuName')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: $t('page.system.menu.menuStatus'),
|
||||||
|
type: 'select',
|
||||||
|
placeholder: $t('page.system.menu.form.menuStatus'),
|
||||||
|
options: commonStatusOptions.map(item => ({
|
||||||
|
label: $t(item.label),
|
||||||
|
value: item.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
emit('reset');
|
emit('reset');
|
||||||
}
|
}
|
||||||
@@ -32,27 +52,14 @@ function search() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableSearchPanel
|
<TableSearchFields
|
||||||
:model="model"
|
v-model="model"
|
||||||
|
:fields="fields"
|
||||||
|
:columns="4"
|
||||||
:disabled="props.disabled"
|
:disabled="props.disabled"
|
||||||
:action-col-lg="8"
|
|
||||||
:action-col-md="24"
|
|
||||||
@reset="reset"
|
@reset="reset"
|
||||||
@search="search"
|
@search="search"
|
||||||
>
|
/>
|
||||||
<ElCol :lg="6" :md="8" :sm="12">
|
|
||||||
<ElFormItem :label="$t('page.system.menu.menuName')" prop="name">
|
|
||||||
<ElInput v-model="model.name" clearable :placeholder="$t('page.system.menu.form.menuName')" />
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :lg="6" :md="8" :sm="12">
|
|
||||||
<ElFormItem :label="$t('page.system.menu.menuStatus')" prop="status">
|
|
||||||
<ElSelect v-model="model.status" clearable class="w-full" :placeholder="$t('page.system.menu.form.menuStatus')">
|
|
||||||
<ElOption v-for="{ label, value } in commonStatusOptions" :key="value" :label="$t(label)" :value="value" />
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
</TableSearchPanel>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, reactive, watch } from 'vue';
|
||||||
import { postTypeOptions } from '@/constants/business';
|
import { postTypeOptions } from '@/constants/business';
|
||||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'PostSearch' });
|
defineOptions({ name: 'PostSearch' });
|
||||||
|
|
||||||
@@ -17,17 +17,69 @@ const statusOptions = [
|
|||||||
{ label: '停用', value: 1 as const }
|
{ label: '停用', value: 1 as const }
|
||||||
];
|
];
|
||||||
|
|
||||||
const keyword = computed({
|
const searchModel = reactive<{
|
||||||
get() {
|
keyword: string;
|
||||||
return model.value.name ?? model.value.code ?? '';
|
postType?: Api.SystemManage.PostType | null;
|
||||||
},
|
status?: Api.SystemManage.CommonStatus;
|
||||||
set(value: string) {
|
}>({
|
||||||
const text = value.trim() || undefined;
|
keyword: '',
|
||||||
model.value.name = text;
|
postType: undefined,
|
||||||
model.value.code = text;
|
status: undefined
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let syncingFromSource = false;
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [model.value.name, model.value.code, model.value.postType, model.value.status] as const,
|
||||||
|
([name, code, postType, status]) => {
|
||||||
|
syncingFromSource = true;
|
||||||
|
searchModel.keyword = name ?? code ?? '';
|
||||||
|
searchModel.postType = postType;
|
||||||
|
searchModel.status = status;
|
||||||
|
syncingFromSource = false;
|
||||||
|
},
|
||||||
|
{ immediate: true, flush: 'sync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [searchModel.keyword, searchModel.postType, searchModel.status] as const,
|
||||||
|
([keywordValue, postType, status]) => {
|
||||||
|
if (syncingFromSource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywordText = keywordValue.trim() || undefined;
|
||||||
|
model.value.name = keywordText;
|
||||||
|
model.value.code = keywordText;
|
||||||
|
model.value.postType = postType;
|
||||||
|
model.value.status = status;
|
||||||
|
},
|
||||||
|
{ flush: 'sync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const fields = computed<SearchField[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'keyword',
|
||||||
|
label: '岗位名称',
|
||||||
|
type: 'input',
|
||||||
|
placeholder: '请输入岗位名称或岗位编码'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'postType',
|
||||||
|
label: '岗位类型',
|
||||||
|
type: 'select',
|
||||||
|
placeholder: '请选择岗位类型',
|
||||||
|
options: postTypeOptions
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: '岗位状态',
|
||||||
|
type: 'select',
|
||||||
|
placeholder: '请选择岗位状态',
|
||||||
|
options: statusOptions
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
emit('reset');
|
emit('reset');
|
||||||
}
|
}
|
||||||
@@ -38,27 +90,7 @@ function search() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableSearchPanel :model="model" :action-col-lg="8" @reset="reset" @search="search">
|
<TableSearchFields v-model="searchModel" :fields="fields" :columns="4" @reset="reset" @search="search" />
|
||||||
<ElCol :lg="8" :md="12" :sm="12">
|
|
||||||
<ElFormItem label="岗位名称" prop="name">
|
|
||||||
<ElInput v-model="keyword" clearable placeholder="请输入岗位名称或岗位编码" />
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :lg="8" :md="12" :sm="12">
|
|
||||||
<ElFormItem label="岗位类型" prop="postType">
|
|
||||||
<ElSelect v-model="model.postType" clearable placeholder="请选择岗位类型">
|
|
||||||
<ElOption v-for="{ label, value } in postTypeOptions" :key="value" :label="label" :value="value" />
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :lg="8" :md="12" :sm="12">
|
|
||||||
<ElFormItem label="岗位状态" prop="status">
|
|
||||||
<ElSelect v-model="model.status" clearable placeholder="请选择岗位状态">
|
|
||||||
<ElOption v-for="{ label, value } in statusOptions" :key="value" :label="label" :value="value" />
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
</TableSearchPanel>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -429,10 +429,6 @@ getMenuTreeData();
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-row) {
|
|
||||||
margin: 0 0 -15px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-card-header {
|
.role-card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -167,24 +167,25 @@ watch(visible, value => {
|
|||||||
<BusinessFormDialog
|
<BusinessFormDialog
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
:title="title"
|
:title="title"
|
||||||
preset="md"
|
preset="sm"
|
||||||
:loading="detailLoading"
|
:loading="detailLoading"
|
||||||
:confirm-loading="submitting"
|
:confirm-loading="submitting"
|
||||||
|
:scrollbar="false"
|
||||||
@confirm="handleSubmit"
|
@confirm="handleSubmit"
|
||||||
>
|
>
|
||||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||||
<ElRow :gutter="16">
|
<ElRow :gutter="16">
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem :label="$t('page.system.role.roleName')" prop="name">
|
<ElFormItem :label="$t('page.system.role.roleName')" prop="name">
|
||||||
<ElInput v-model="model.name" :placeholder="$t('page.system.role.form.roleName')" />
|
<ElInput v-model="model.name" :placeholder="$t('page.system.role.form.roleName')" />
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem :label="$t('page.system.role.roleCode')" prop="code">
|
<ElFormItem :label="$t('page.system.role.roleCode')" prop="code">
|
||||||
<ElInput v-model="model.code" :placeholder="$t('page.system.role.form.roleCode')" />
|
<ElInput v-model="model.code" :placeholder="$t('page.system.role.form.roleCode')" />
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem :label="$t('page.system.role.sort')" prop="sort">
|
<ElFormItem :label="$t('page.system.role.sort')" prop="sort">
|
||||||
<ElInputNumber
|
<ElInputNumber
|
||||||
v-model="model.sort"
|
v-model="model.sort"
|
||||||
@@ -194,7 +195,7 @@ watch(visible, value => {
|
|||||||
/>
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem :label="$t('page.system.role.roleStatus')" prop="status">
|
<ElFormItem :label="$t('page.system.role.roleStatus')" prop="status">
|
||||||
<ElRadioGroup v-model="model.status" class="business-form-radio-group">
|
<ElRadioGroup v-model="model.status" class="business-form-radio-group">
|
||||||
<ElRadio v-for="{ label, value } in commonStatusOptions" :key="value" :value="value">
|
<ElRadio v-for="{ label, value } in commonStatusOptions" :key="value" :value="value">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, reactive, watch } from 'vue';
|
||||||
import { commonStatusOptions } from '@/constants/business';
|
import { commonStatusOptions } from '@/constants/business';
|
||||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||||
import { $t } from '@/locales';
|
import { $t } from '@/locales';
|
||||||
|
|
||||||
defineOptions({ name: 'RoleSearch' });
|
defineOptions({ name: 'RoleSearch' });
|
||||||
@@ -15,17 +15,61 @@ const emit = defineEmits<Emits>();
|
|||||||
|
|
||||||
const model = defineModel<Api.SystemManage.RoleSearchParams>('model', { required: true });
|
const model = defineModel<Api.SystemManage.RoleSearchParams>('model', { required: true });
|
||||||
|
|
||||||
const keyword = computed({
|
const searchModel = reactive<{
|
||||||
get() {
|
keyword: string;
|
||||||
return model.value.name ?? model.value.code ?? '';
|
status?: Api.SystemManage.CommonStatus;
|
||||||
},
|
}>({
|
||||||
set(value: string) {
|
keyword: '',
|
||||||
const text = value.trim() || undefined;
|
status: undefined
|
||||||
model.value.name = text;
|
|
||||||
model.value.code = text;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let syncingFromSource = false;
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [model.value.name, model.value.code, model.value.status] as const,
|
||||||
|
([name, code, status]) => {
|
||||||
|
syncingFromSource = true;
|
||||||
|
searchModel.keyword = name ?? code ?? '';
|
||||||
|
searchModel.status = status;
|
||||||
|
syncingFromSource = false;
|
||||||
|
},
|
||||||
|
{ immediate: true, flush: 'sync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [searchModel.keyword, searchModel.status] as const,
|
||||||
|
([keywordValue, status]) => {
|
||||||
|
if (syncingFromSource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywordText = keywordValue.trim() || undefined;
|
||||||
|
model.value.name = keywordText;
|
||||||
|
model.value.code = keywordText;
|
||||||
|
model.value.status = status;
|
||||||
|
},
|
||||||
|
{ flush: 'sync' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const fields = computed<SearchField[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'keyword',
|
||||||
|
label: $t('page.system.role.searchKeyword'),
|
||||||
|
type: 'input',
|
||||||
|
placeholder: $t('page.system.role.searchPlaceholder')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: $t('page.system.role.roleStatus'),
|
||||||
|
type: 'select',
|
||||||
|
placeholder: $t('page.system.role.form.roleStatus'),
|
||||||
|
options: commonStatusOptions.map(item => ({
|
||||||
|
label: $t(item.label),
|
||||||
|
value: item.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
emit('reset');
|
emit('reset');
|
||||||
}
|
}
|
||||||
@@ -36,20 +80,7 @@ function search() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TableSearchPanel :model="model" :action-col-lg="8" @reset="reset" @search="search">
|
<TableSearchFields v-model="searchModel" :fields="fields" :columns="3" @reset="reset" @search="search" />
|
||||||
<ElCol :lg="8" :md="12" :sm="12">
|
|
||||||
<ElFormItem :label="$t('page.system.role.searchKeyword')" prop="name">
|
|
||||||
<ElInput v-model="keyword" clearable :placeholder="$t('page.system.role.searchPlaceholder')" />
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
<ElCol :lg="8" :md="12" :sm="12">
|
|
||||||
<ElFormItem :label="$t('page.system.role.roleStatus')" prop="status">
|
|
||||||
<ElSelect v-model="model.status" clearable :placeholder="$t('page.system.role.form.roleStatus')">
|
|
||||||
<ElOption v-for="{ label, value } in commonStatusOptions" :key="value" :label="$t(label)" :value="value" />
|
|
||||||
</ElSelect>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElCol>
|
|
||||||
</TableSearchPanel>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ watch(visible, async value => {
|
|||||||
<BusinessFormDialog
|
<BusinessFormDialog
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
:title="title"
|
:title="title"
|
||||||
preset="md"
|
preset="sm"
|
||||||
:loading="detailLoading"
|
:loading="detailLoading"
|
||||||
:confirm-loading="submitting"
|
:confirm-loading="submitting"
|
||||||
:scrollbar="false"
|
:scrollbar="false"
|
||||||
@@ -339,7 +339,7 @@ watch(visible, async value => {
|
|||||||
>
|
>
|
||||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||||
<ElRow :gutter="16">
|
<ElRow :gutter="16">
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem label="上级用户" prop="managerUserId">
|
<ElFormItem label="上级用户" prop="managerUserId">
|
||||||
<ElSelect
|
<ElSelect
|
||||||
v-model="model.managerUserId"
|
v-model="model.managerUserId"
|
||||||
@@ -352,7 +352,7 @@ watch(visible, async value => {
|
|||||||
</ElSelect>
|
</ElSelect>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem label="下级用户" prop="subordinateUserId">
|
<ElFormItem label="下级用户" prop="subordinateUserId">
|
||||||
<ElSelect
|
<ElSelect
|
||||||
v-model="model.subordinateUserId"
|
v-model="model.subordinateUserId"
|
||||||
@@ -367,7 +367,7 @@ watch(visible, async value => {
|
|||||||
</ElCol>
|
</ElCol>
|
||||||
</ElRow>
|
</ElRow>
|
||||||
<ElRow :gutter="16">
|
<ElRow :gutter="16">
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem label="生效开始时间" prop="effectiveFrom" style="width: 100%">
|
<ElFormItem label="生效开始时间" prop="effectiveFrom" style="width: 100%">
|
||||||
<ElDatePicker
|
<ElDatePicker
|
||||||
v-model="model.effectiveFrom"
|
v-model="model.effectiveFrom"
|
||||||
@@ -379,7 +379,7 @@ watch(visible, async value => {
|
|||||||
/>
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem label="生效结束时间" prop="effectiveUntil" style="width: 100%">
|
<ElFormItem label="生效结束时间" prop="effectiveUntil" style="width: 100%">
|
||||||
<ElDatePicker
|
<ElDatePicker
|
||||||
v-model="model.effectiveUntil"
|
v-model="model.effectiveUntil"
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ watch(visible, async value => {
|
|||||||
<BusinessFormDialog
|
<BusinessFormDialog
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
:title="title"
|
:title="title"
|
||||||
preset="lg"
|
preset="md"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:confirm-loading="submitting"
|
:confirm-loading="submitting"
|
||||||
max-body-height="70vh"
|
max-body-height="70vh"
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ watch(visible, async value => {
|
|||||||
<BusinessFormDialog
|
<BusinessFormDialog
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
:title="title"
|
:title="title"
|
||||||
preset="md"
|
preset="sm"
|
||||||
:confirm-loading="submitting"
|
:confirm-loading="submitting"
|
||||||
:scrollbar="false"
|
:scrollbar="false"
|
||||||
@confirm="handleSubmit"
|
@confirm="handleSubmit"
|
||||||
@@ -168,12 +168,12 @@ watch(visible, async value => {
|
|||||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||||
<BusinessFormSection :title="$t('page.system.user.sections.basicInfo')">
|
<BusinessFormSection :title="$t('page.system.user.sections.basicInfo')">
|
||||||
<ElRow :gutter="16">
|
<ElRow :gutter="16">
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem :label="$t('page.system.user.deptName')">
|
<ElFormItem :label="$t('page.system.user.deptName')">
|
||||||
<ElInput :model-value="dept?.name || $t('common.noData')" disabled />
|
<ElInput :model-value="dept?.name || $t('common.noData')" disabled />
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem :label="$t('page.system.user.candidateUser')" prop="userId">
|
<ElFormItem :label="$t('page.system.user.candidateUser')" prop="userId">
|
||||||
<ElSelect
|
<ElSelect
|
||||||
v-model="model.userId"
|
v-model="model.userId"
|
||||||
@@ -185,7 +185,7 @@ watch(visible, async value => {
|
|||||||
</ElSelect>
|
</ElSelect>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem :label="$t('page.system.user.effectiveFrom')" prop="effectiveFrom" style="width: 100%">
|
<ElFormItem :label="$t('page.system.user.effectiveFrom')" prop="effectiveFrom" style="width: 100%">
|
||||||
<ElDatePicker
|
<ElDatePicker
|
||||||
v-model="model.effectiveFrom"
|
v-model="model.effectiveFrom"
|
||||||
@@ -197,7 +197,7 @@ watch(visible, async value => {
|
|||||||
/>
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElCol>
|
</ElCol>
|
||||||
<ElCol :span="12">
|
<ElCol :span="24">
|
||||||
<ElFormItem :label="$t('page.system.user.effectiveUntil')" prop="effectiveUntil" style="width: 100%">
|
<ElFormItem :label="$t('page.system.user.effectiveUntil')" prop="effectiveUntil" style="width: 100%">
|
||||||
<ElDatePicker
|
<ElDatePicker
|
||||||
v-model="model.effectiveUntil"
|
v-model="model.effectiveUntil"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user