Compare commits

..

8 Commits

Author SHA1 Message Date
dk
f634d21d2a feat(产品需求、项目需求): 开发两种需求的富文本和附件功能。 2026-05-13 23:09:35 +08:00
dk
e3a456debd Merge branch 'main' of http://192.168.1.22:3000/Web/cn-rdms-web
# Conflicts:
#	src/service/api/product.ts
#	src/service/api/project.ts
#	src/typings/api/project.d.ts
2026-05-13 21:20:59 +08:00
dk
60debcda8a feat(项目需求): 开发项目需求的功能。 2026-05-13 21:13:21 +08:00
5615399a68 feat(projects): 1、执行、任务、工作日志开发调试;2、增加富文本、附件等支撑 2026-05-12 21:41:39 +08:00
dk
28c47b14a3 fix(产品需求): 完善产品需求的诸多细节。 2026-05-09 18:15:10 +08:00
dk
5947157f89 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
2026-05-09 13:44:08 +08:00
dk
f0ea903d59 fix(产品需求): 修复产品需求在测试后存在的问题。 2026-05-09 13:42:04 +08:00
824392b564 feat(projects): 新增项目、执行、任务等功能 2026-05-09 11:30:34 +08:00
151 changed files with 25592 additions and 1934 deletions

View File

@@ -0,0 +1,19 @@
{
"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)",
"Bash(node *)",
"Bash(dir \"rdms-project-boot-*\")",
"Bash(git stash *)",
"Bash(pnpm eslint *)",
"Bash(Select-String -Pattern \"business-rich-text-editor|task-operate-dialog\")",
"Bash(powershell *)"
]
}
}

View File

@@ -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`

View File

@@ -11,6 +11,8 @@
默认回答保持精简,优先给结论、改动点、验证方式和必要风险;如果用户只要求分析、审阅或方案,就停留在分析层,不主动扩展到实现层。
分析、解释、方案类回答优先用业务和逻辑语言把结构、差异与结论说清楚,不要大段贴源码、罗列 `file:line` 或把实现细节当解释;只有用户明确要求看代码、或某行确实是讨论焦点的关键佐证时,才贴最小必要的代码片段。
## 交互与执行原则
- 进入实施阶段前,先说明目标、涉及模块、预计改动点和验证方式。
@@ -58,6 +60,7 @@
- `build/plugins/router.ts`elegant-router 配置与路由元信息生成逻辑
- `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/`:当前工作上下文的一部分,做架构级、权限级、页面规范级改动前优先查阅
@@ -136,17 +139,18 @@
- 页面组件保持“编排层薄”。页面文件主要负责搜索参数、表格 hook、列定义、弹层开关、接口调用编排不把大量表单细节和重复交互直接堆在页面根组件里。
- 列表页优先拆出同目录下的 `modules/*` 子组件,例如搜索组件、操作弹层、详情抽屉、资源面板等。
- 系统管理下现有 `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/form.ts` 中的 `useForm``useFormRules`
- 当前项目的真实业务口径是“内网中文优先”。新增业务页不必为了形式强行补全国际化键;但如果是在已有大量 `$t(...)` 的页面或模块内继续开发,优先保持该局部代码风格一致,不要半页中文直写、半页国际化混用。
## 表格、搜索区与操作列约束
- 搜索区按钮组保持在最右侧;存在折叠项时,按钮顺序保持为“展开/收起 -> 重置 -> 查询”。
- 不要在每个页面重新拼一套搜索区骨架,优先延续 `TableSearchPanel` 的结构和交互
- 搜索区按钮组必须固定在第一行最后一个位置;存在折叠项时,按钮顺序保持为“展开/收起 -> 重置 -> 查询”。这是 `TableSearchFields` 的布局契约,不允许因为查询条件不足、展开/收起或响应式样式把按钮提前到中间位置或挤到后续行。
- 不要在每个页面重新拼一套搜索区骨架;常规查询条件必须使用 `TableSearchFields`,通过 `columns` 控制每行格子数和折叠阈值。`columns` 表示首行总格数,其中最后 1 格永远留给按钮区;字段不足 `columns - 1` 时由公共组件补空占位,字段超过时剩余字段进入展开区。类似项目管理入口页这类 4 个查询条件的场景,必须使用 `:columns="4"`形成“3 个条件 + 按钮区”的首行布局
- 表格操作列优先复用 `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` 私有样式。历史页面已有私有类时不强制专项回改,当前任务触达相关页面再按公共类收敛。
- 表格、按钮、弹层、表单的尺寸和间距标准优先由 `src/styles/scss/element-plus.scss` 和公共组件承接,不在业务页面散落写新的局部尺寸作为事实标准。
## 表单与弹层约束
@@ -154,10 +158,14 @@
- 新增、编辑能力优先沿用 `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`
- 现有公共壳组件已内置尺寸预设:`dialog``sm/md/lg` 对应 `520px/640px/720px``drawer``md/lg/xl` 对应 `480px/720px/960px`优先使用预设值而不是页面内重复硬编码宽度
- 常规 CRUD 表单优先使用 `label-position="top"``ElRow + ElCol` 双列布局、`gutter=16`;普通字段优先 `span=12`,长文本或重量级字段优先 `span=24`
- `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`如果整体字段数 `<= 6`,默认按单列表单理解。
- 当纯表单 `dialog` 因字段数 `<= 6` 归入 `sm` 时,不能只改 `preset`;字段布局也要同步落到单列,常规 `ElCol` 应使用 `span=24`,除非该弹框已经被明确判定为复合内容特例。
- 左右分栏、表单 + 表格、表单 + 树、关系编辑器、时间线、大段说明区这类复合内容 `dialog`,不强行按字段数归类;可按内容复杂度单独评估使用 `md``lg` 或更宽值,但只有在无法合理归入“纯表单三档”时才允许特例。
- 禁止用页面级宽范围样式直接覆盖整页 `.business-form-dialog` 来统一放大弹框;如确实需要特殊宽度,只能精确作用于目标弹框,且不能误伤同页面其他 `dialog`
- 底部按钮顺序固定为“取消 -> 确认”,并保持右对齐。
- 单选组和开关类字段优先复用仓库既有样式钩子,例如 `business-form-radio-group``business-form-switch-field`
- 权限控制按钮默认采用“无权限不渲染”口径,不要把纯权限不足的入口做成禁用态再展示给用户;只有业务状态暂时不可操作、但仍需让用户感知入口存在时,才允许保留禁用态。
## 接口、路由与权限约束
@@ -167,6 +175,31 @@
- 涉及路由、菜单、权限的改动时,同时检查 `build/plugins/router.ts``src/router/routes/*``src/store/modules/route/*` 和相关文档。
- 对于可再生的路由产物,优先修改源配置并执行 `pnpm gen-route`,不要把手工修补生成文件当成常规方案。
## 防重复提交(两层联防)
用户快速双击、键盘连按 Enter、`ElMessageBox.confirm` 的"确定"按钮内置无 loading 等场景,都可能让同一写操作发出多次。仓库采用两层防御,新增写操作功能时按顺序检查:
### 第一层:业务按钮的 loading 锁(视觉防御)
- 新增、编辑入口优先使用 `src/components/custom/business-form-dialog.vue``src/components/custom/business-form-drawer.vue`,它们在 `submit` 流程内 await 接口期间会自动将"确认"按钮置为 `loading` + `disabled`
- 不要裸手写 `<ElButton @click="submit">` 直接调接口;若必须使用裸 `ElButton`,需要自行绑定 `:loading` 并在 await 接口期间锁住按钮。
- 删除二次确认使用 `ElMessageBox.confirm` 时,其内部"确定"按钮没有 loading 能力,必须依赖第二层兜底,不要尝试改造 confirm 的内部按钮。
### 第二层:请求层全局去重(逻辑兜底)
- 入口:`src/service/request/dedupe.ts` 提供 `withDedupe`,已在 `src/service/request/index.ts` 包住统一的 `request` 实例;`demoRequest` 未启用。
- 指纹:`method + 完整 URL + 排序后的 params + 稳定序列化的 body`body 内对象按 key 排序,数组保序。
- 行为:写操作(`POST` / `PUT` / `DELETE` / `PATCH`)在第一次请求 pending 期内,若再次发起指纹相同的请求,自动复用第一次的 Promise不发出第二次实际请求调用方两次拿到完全相同的返回对象。
- 跳过条件(即不去重,按原逻辑发出):`GET` / `HEAD` / `OPTIONS`,请求体为 `FormData``Blob`(上传场景),调用方显式传 `{ dedupe: false }`
- 业务调用方零感知:新增接口默认即享受兜底,不需要在 `src/service/api/*` 或页面层做任何改动。
- 极少数业务确实允许短时间内并发提交完全相同的写请求时,在调用处显式传 `request({ ..., dedupe: false })` 单接口关闭。
- 兜底超时 30 秒:极端情况下若某次 Promise 未 settlepending 条目过期后下一次相同请求视为新请求,避免内存泄漏。
### 设计责任划分
- 视觉层负责"按下立刻锁住按钮"的用户感知;逻辑层负责"即使锁失败也只发一次"的实际接口保护。
- 不要因为有第二层兜底就省略第一层 loading 锁:用户没有视觉反馈会再次点击;也不要试图在业务页面再造一套请求去重逻辑。
## 运行时字典使用口径
- 运行时字典统一由 `src/store/modules/dict/index.ts` 管理,登录后通过 `/system/dict-data/frontend-cache` 初始化;不要在业务页面重复直调字典接口。
@@ -201,6 +234,14 @@ const directionLabels = getLabels(row.directionCodes, { separator: '' });
- 当前系统已有页面或接口已经稳定使用某个字典,例如用户所属公司 `company -> system_user_company`
- 如果以上两种都没有,就先让后端或业务明确 `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` 的前端兜底映射,不要直接绕开集中配置另写一份。
## 页面资源与菜单目录约束
- 页面组件键、页面资源、菜单目录是三层不同概念,不要把它们当成同一个值。

408
CLAUDE.md Normal file
View File

@@ -0,0 +1,408 @@
# CLAUDE.md
本文件是我Claude`cn-rdms-web` 项目中的个人工作笔记,沉淀团队既有规范(来源:`AGENTS.md`)与协作惯例。每次进入仓库前先读这一份,避免重复踩坑。
> 本文件仅本地保留,已加入 `.gitignore`,请勿提交。
---
## 0. 行为基线(最重要,先记住)
- **描述现状以代码、配置、文档可直接验证的事实为准**;不引入历史实现/过渡方案/猜测。
- **默认精简回答**:先给结论 → 改动点 → 验证方式 → 必要风险。**除非用户主动要求详细,否则不要展开**——不复述清单、不列每条改动的小理由、不堆"汇总"段。用户只让分析就停在分析层,不主动跳到实现。
- **分析/解释类回答不要堆代码层面描述**:默认用业务/逻辑语言说清楚结构、差异与结论;不要大段贴源码、不要罗列 `file:line`、不要把"实现细节"当解释。只有用户明确要求看代码、或非贴不可的关键佐证(如某行就是争议焦点),才贴最少代码片段。
- **进入实施阶段前,先说目标、涉及模块、预计改动点、验证方式**。
- **最小改动原则**:只改当前任务必需的范围,不顺手重构无关代码。
- **不主动执行 git 操作**status/diff/add/commit/restore/reset/checkout 全部不主动跑),除非用户明确要求。识别用户改动优先用 Read 直接看文件。
- 工作树脏的时候,**不要回退与当前任务无关的变更**。
- 静态校验默认只跑 `pnpm typecheck`UI/交互/样式类任务**默认不补也不跑前端测试**,除非用户明确要求。
---
## 1. 项目骨架(认知地图)
| 维度 | 现状 |
|---|---|
| 应用 | RDMS 系统的 Vue 3 后台前端 |
| 包管理 | `pnpm`>=8.7.0Node `>=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前端兜底
---
## 19. 防重复提交(两层联防,强约束)
> 用户双击、键盘连按 Enter、`ElMessageBox.confirm` 的"确定"按钮无内置 loading 都会让同一写操作发多次。两层防御缺一不可。
### 两层各自的职责
| 层 | 谁负责 | 行为 |
|---|---|---|
| 视觉层 | `business-form-dialog.vue` / `business-form-drawer.vue` | submit 触发后立即把"确认"按钮置 loading + disabled挡住二次点击 |
| 逻辑层(兜底) | `src/service/request/dedupe.ts`(已通过 `withDedupe` 包住 `request` 实例) | 写操作 pending 期内复用同一 Promise不真正发出第二次请求 |
### 业务侧关注点
- **不要裸手写** `<ElButton @click="submit">` 调接口;用 `business-form-dialog` / `business-form-drawer` 包;非要用裸 `ElButton` 时**必须**自行绑 `:loading` 并在 await 期间锁住。
- **`ElMessageBox.confirm` 的"确定"按钮没 loading 能力**——不要尝试改它,靠第二层兜底就够。
- **新接口默认享受去重**,调用方零改动;不要在 `src/service/api/*` 或页面层再造一套去重。
### 去重生效边界
- 自动去重:`POST` / `PUT` / `DELETE` / `PATCH`
- 不去重:`GET` / `HEAD` / `OPTIONS`(避免误伤分页 / 多 widget 并发查询);请求体为 `FormData` / `Blob`(上传场景)。
- 单接口逃生口:`request({ ..., dedupe: false })`——极少用,仅当业务真允许短时间内连发完全相同的写请求。
- 兜底超时 30s保险丝防止 Promise 永不 settle 时内存泄漏。
### 指纹算法
`method 大写 | URL + 排序后的 params 序列化 | 稳定序列化的 body`。body 内对象按 key 排序、数组保序——保证调用顺序不同但参数等价的两次请求拿到同一指纹。
### 何时回到本节查
- 新建写操作页面 → 视觉层用对组件、不裸 `ElButton` 调接口
- 新建写接口 → 不用管,默认兜底;只有明确"允许短时间连发"才传 `dedupe: false`
- 新建上传 / 下载流程 → FormData / Blob 天然不在去重范围,按原方式写即可
- 用户报"双击双发"复现不出来 → 检查目标按钮是否走了 `business-form-dialog`;若用的是 `ElMessageBox.confirm`,靠第二层就该挡住

View File

@@ -50,6 +50,35 @@ export function setupElegantRouter() {
hideInMenu: true,
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: {
icon: 'carbon:cloud-service-management',
order: 9,

View File

@@ -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.",
"rules": {
"directoryComponent": "layout.base",
"pageComponentPattern": "view.<routeName>",
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
},
"total": 7,
"total": 8,
"items": [
{
"name": "product_list",
@@ -41,6 +41,39 @@
"pageType": "leaf",
"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",
"path": "/system/user",

View File

@@ -54,6 +54,8 @@
"@visactor/vue-vtable": "1.19.8",
"@vueuse/components": "13.9.0",
"@vueuse/core": "13.9.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"clipboard": "2.0.11",
"dayjs": "1.11.18",
"defu": "^6.1.4",
@@ -77,7 +79,6 @@
"vue-i18n": "11.1.11",
"vue-pdf-embed": "2.1.3",
"vue-router": "4.5.1",
"wangeditor": "4.7.15",
"xgplayer": "3.0.23",
"xlsx": "0.18.5"
},

433
pnpm-lock.yaml generated
View File

@@ -59,6 +59,12 @@ importers:
'@vueuse/core':
specifier: 13.9.0
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:
specifier: 2.0.11
version: 2.0.11
@@ -128,9 +134,6 @@ importers:
vue-router:
specifier: 4.5.1
version: 4.5.1(vue@3.5.20(typescript@5.8.3))
wangeditor:
specifier: 4.7.15
version: 4.7.15
xgplayer:
specifier: 3.0.23
version: 3.0.23(core-js@3.49.0)
@@ -560,10 +563,6 @@ packages:
peerDependencies:
'@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':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
@@ -1399,6 +1398,9 @@ packages:
'@sxzz/popperjs-es@2.11.8':
resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==}
'@transloadit/prettier-bytes@0.0.7':
resolution: {integrity: sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==}
'@turf/boolean-clockwise@6.5.0':
resolution: {integrity: sha512-45+C7LC5RMbRWrxh3Z0Eihsc8db1VGBO5d9BLTOAwU4jR6SgsunTfRWR16X7JUwIDYlCVEmnjcXJNi/kIU3VIw==}
@@ -1502,6 +1504,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/event-emitter@0.3.5':
resolution: {integrity: sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
@@ -1791,6 +1796,23 @@ packages:
cpu: [x64]
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':
resolution: {integrity: sha512-r298TUdK+CKbHGVYWgQnNSEB5uqpFvF2/aMNZ/2POQnd2CovAPJOx2nTE6hAcOn8rra2FwJ2xF8AyP1O5OhrTw==}
peerDependencies:
@@ -2015,6 +2037,93 @@ packages:
peerDependencies:
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':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@@ -2434,6 +2543,9 @@ packages:
component-emitter@1.3.1:
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
compute-scroll-into-view@1.0.20:
resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -2476,9 +2588,6 @@ packages:
core-js-compat@3.49.0:
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:
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
@@ -2800,6 +2909,9 @@ packages:
dom-serializer@1.4.1:
resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==}
dom7@3.0.0:
resolution: {integrity: sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==}
domelementtype@1.3.1:
resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==}
@@ -3439,6 +3551,9 @@ packages:
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
html-void-elements@2.0.1:
resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==}
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
@@ -3450,6 +3565,9 @@ packages:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'}
i18next@20.6.1:
resolution: {integrity: sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@@ -3486,6 +3604,9 @@ packages:
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@9.0.21:
resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
immutable@5.1.5:
resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==}
@@ -3606,6 +3727,9 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-hotkey@0.2.0:
resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==}
is-inside-container@1.0.0:
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
engines: {node: '>=14.16'}
@@ -3643,6 +3767,10 @@ packages:
resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==}
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:
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
engines: {node: '>= 0.4'}
@@ -3682,6 +3810,9 @@ packages:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
is-url@1.2.4:
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
is-weakmap@2.0.2:
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
engines: {node: '>= 0.4'}
@@ -3874,9 +4005,31 @@ packages:
lodash: '*'
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:
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:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@@ -3953,6 +4106,9 @@ packages:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-match@1.0.2:
resolution: {integrity: sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
@@ -4021,6 +4177,9 @@ packages:
muggle-string@0.4.1:
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:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -4336,6 +4495,9 @@ packages:
resolution: {integrity: sha512-spBB5sgC4cv2YcW03f/IAUN1pgDJWNWD8FzkyY4mArLUMJW+KlQhlmUdKAHQuPfb00Jl5xIfImeOsf6YL8QK7Q==}
engines: {node: '>=0.10.0'}
preact@10.29.1:
resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -4366,6 +4528,10 @@ packages:
print-js@1.6.0:
resolution: {integrity: sha512-BfnOIzSKbqGRtO4o0rnj/K3681BSd2QUrsIZy/+WdCIugjIswjmx3lDEZpXB2ruGf9d4b3YNINri81+J0FsBWg==}
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
@@ -4547,6 +4713,9 @@ packages:
resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
engines: {node: '>= 10.13.0'}
scroll-into-view-if-needed@2.2.31:
resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==}
select@1.1.2:
resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==}
@@ -4647,9 +4816,21 @@ packages:
sisteransi@1.0.5:
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:
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:
resolution: {integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==}
engines: {node: '>=0.10.0'}
@@ -4697,6 +4878,9 @@ packages:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
ssr-window@3.0.0:
resolution: {integrity: sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==}
stable-hash-x@0.2.0:
resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==}
engines: {node: '>=12.0.0'}
@@ -4859,6 +5043,9 @@ packages:
tiny-invariant@1.3.3:
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:
resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
engines: {node: '>=18'}
@@ -5231,9 +5418,6 @@ packages:
typescript:
optional: true
wangeditor@4.7.15:
resolution: {integrity: sha512-aPTdREd8BxXVyJ5MI+LU83FQ7u1EPd341iXIorRNYSOvoimNoZ4nPg+yn3FGbB93/owEa6buLw8wdhYnMCJQLg==}
watchpack@2.5.1:
resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
engines: {node: '>=10.13.0'}
@@ -5276,6 +5460,9 @@ packages:
engines: {node: '>= 8'}
hasBin: true
wildcard@1.1.2:
resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==}
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
@@ -5742,10 +5929,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/runtime-corejs3@7.29.2':
dependencies:
core-js-pure: 3.49.0
'@babel/runtime@7.29.2': {}
'@babel/template@7.28.6':
@@ -6419,6 +6602,8 @@ snapshots:
'@sxzz/popperjs-es@2.11.8': {}
'@transloadit/prettier-bytes@0.0.7': {}
'@turf/boolean-clockwise@6.5.0':
dependencies:
'@turf/helpers': 6.5.0
@@ -6526,6 +6711,8 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/event-emitter@0.3.5': {}
'@types/geojson@7946.0.16': {}
'@types/json-schema@7.0.15': {}
@@ -6855,6 +7042,35 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
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)':
dependencies:
'@visactor/vchart': 2.0.4
@@ -7284,6 +7500,114 @@ snapshots:
dependencies:
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':
dependencies:
'@webassemblyjs/helper-numbers': 1.13.2
@@ -7741,6 +8065,8 @@ snapshots:
component-emitter@1.3.1: {}
compute-scroll-into-view@1.0.20: {}
concat-map@0.0.1: {}
concat-stream@1.4.11:
@@ -7778,8 +8104,6 @@ snapshots:
dependencies:
browserslist: 4.28.1
core-js-pure@3.49.0: {}
core-js@3.49.0: {}
core-util-is@1.0.3: {}
@@ -8093,6 +8417,10 @@ snapshots:
domhandler: 4.3.1
entities: 2.2.0
dom7@3.0.0:
dependencies:
ssr-window: 3.0.0
domelementtype@1.3.1: {}
domelementtype@2.3.0: {}
@@ -8895,6 +9223,8 @@ snapshots:
hookable@5.5.3: {}
html-void-elements@2.0.1: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
@@ -8911,6 +9241,10 @@ snapshots:
human-signals@8.0.1: {}
i18next@20.6.1:
dependencies:
'@babel/runtime': 7.29.2
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@@ -8935,6 +9269,8 @@ snapshots:
immediate@3.0.6: {}
immer@9.0.21: {}
immutable@5.1.5: {}
import-fresh@3.3.1:
@@ -9052,6 +9388,8 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-hotkey@0.2.0: {}
is-inside-container@1.0.0:
dependencies:
is-docker: 3.0.0
@@ -9079,6 +9417,8 @@ snapshots:
dependencies:
isobject: 3.0.1
is-plain-object@5.0.0: {}
is-regex@1.2.1:
dependencies:
call-bound: 1.0.4
@@ -9115,6 +9455,8 @@ snapshots:
is-unicode-supported@2.1.0: {}
is-url@1.2.4: {}
is-weakmap@2.0.2: {}
is-weakref@1.1.1:
@@ -9281,8 +9623,22 @@ snapshots:
lodash: 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.throttle@4.1.1: {}
lodash.toarray@4.4.0: {}
lodash@4.17.21: {}
lodash@4.17.23: {}
@@ -9363,6 +9719,10 @@ snapshots:
mime-db@1.52.0: {}
mime-match@1.0.2:
dependencies:
wildcard: 1.1.2
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
@@ -9430,6 +9790,8 @@ snapshots:
muggle-string@0.4.1: {}
namespace-emitter@2.0.1: {}
nanoid@3.3.11: {}
nanoid@5.1.5: {}
@@ -9738,6 +10100,8 @@ snapshots:
posthtml-parser: 0.2.1
posthtml-render: 1.4.0
preact@10.29.1: {}
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.1:
@@ -9758,6 +10122,8 @@ snapshots:
print-js@1.6.0: {}
prismjs@1.30.0: {}
progress@2.0.3: {}
prompts@2.4.2:
@@ -9973,6 +10339,10 @@ snapshots:
ajv-formats: 2.1.1(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: {}
semver@6.3.1: {}
@@ -10094,8 +10464,21 @@ snapshots:
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: {}
snabbdom@3.6.3: {}
snapdragon-node@2.1.1:
dependencies:
define-property: 1.0.0
@@ -10150,6 +10533,8 @@ snapshots:
dependencies:
frac: 1.1.2
ssr-window@3.0.0: {}
stable-hash-x@0.2.0: {}
stable@0.1.8: {}
@@ -10320,6 +10705,8 @@ snapshots:
tiny-invariant@1.3.3: {}
tiny-warning@1.0.3: {}
tinyexec@1.0.4: {}
tinyglobby@0.2.15:
@@ -10753,12 +11140,6 @@ snapshots:
optionalDependencies:
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:
dependencies:
glob-to-regexp: 0.4.1
@@ -10845,6 +11226,8 @@ snapshots:
dependencies:
isexe: 2.0.0
wildcard@1.1.2: {}
wmf@1.0.2: {}
wolfy87-eventemitter@5.2.9: {}

View File

@@ -0,0 +1,718 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import { ArrowDown, Delete, Document, Loading, Picture, QuestionFilled, Upload } from '@element-plus/icons-vue';
import { deleteFile, downloadFile, uploadFile } from '@/service/api/file';
defineOptions({ name: 'BusinessAttachmentUploader' });
interface Props {
/** 上传目录,传给后端 directory 字段 */
directory?: string;
/** 数量上限,默认 20与后端 AttachmentValidator 一致) */
max?: number;
/** 单文件大小上限 MB前端兜底最终由 /system/file/upload 拦截) */
maxFileSizeMB?: number;
disabled?: boolean;
/**
* 平铺模式:所有附件直接逐项渲染,不再做"首项 + 折叠浮层"。
* 用于本身已经在 popover / 详情卡片里展示,避免嵌套浮层。
*/
flat?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
directory: undefined,
max: 20,
maxFileSizeMB: 50,
disabled: false,
flat: false
});
const model = defineModel<Api.Project.AttachmentItem[]>({ default: () => [] });
/** 给用户看的简短分类hint 行展示) */
const ALLOWED_EXTENSIONS_HINT = '支持 PDF、Word、Excel、PPT、TXT/MD/CSV、图片、ZIP/RAR/7Z、MP3/MP4';
// 与后端 AttachmentValidator 白/黑名单保持一致5.16
const ALLOWED_EXTENSIONS = new Set([
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'txt',
'md',
'csv',
'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
'zip',
'rar',
'7z',
'mp4',
'mp3'
]);
const FORBIDDEN_EXTENSIONS = new Set([
'exe',
'bat',
'cmd',
'sh',
'ps1',
'msi',
'dll',
'jar',
'war',
'php',
'jsp',
'asp',
'aspx',
'py',
'rb',
'pl',
'com',
'scr',
'vbs',
'js'
]);
interface PendingItem {
id: string;
name: string;
}
const pending = ref<PendingItem[]>([]);
const inputRef = ref<HTMLInputElement>();
const isUnmounting = ref(false);
/**
* 会话级清理账本:
* - originalIds: 弹层打开时已存在的 fileId编辑模式下来自 rowData.attachments
* 当前未在 commit/rollback 中直接读取(清理逻辑靠 addedIds 自己判定);
* 保留是为了让会话模型完整、便于后续扩展(如"撤销删除""仅删原有附件"等差异行为)。
* - addedIds: 本次会话内上传成功的 fileId
* - pendingDeleteIds: 用户在 UI 上点过"删除"的 fileId含 original 和 added 两类)
* - committed: commit() 调用后置 true阻止后续 rollback 误删
*
* UI 显示 = model已减去 pendingDelete 项)
* 真删时机commit() 删 pendingDeleterollback() 删 addedIds除非 committed
*/
interface UploadSession {
originalIds: Set<string>;
addedIds: Set<string>;
pendingDeleteIds: Set<string>;
committed: boolean;
}
const session = reactive<UploadSession>({
originalIds: new Set<string>(),
addedIds: new Set<string>(),
pendingDeleteIds: new Set<string>(),
committed: false
});
const totalCount = computed(() => model.value.length + pending.value.length);
const isFull = computed(() => totalCount.value >= props.max);
const hasUploading = computed(() => pending.value.length > 0);
const acceptExtensionsList = computed(() => Array.from(ALLOWED_EXTENSIONS).join(', '));
/**
* 列表区拆成"直接展示"和"折叠浮层"两组:
* - flat全部直接展示适合本身已在 popover 里)
* - 默认:首项直接展示,>1 时其余进入悬浮浮层
*/
const displayedAttachments = computed(() => (props.flat ? model.value : model.value.slice(0, 1)));
const popoverAttachments = computed(() => (props.flat || model.value.length <= 1 ? [] : model.value.slice(1)));
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']);
function isImage(item: Api.Project.AttachmentItem) {
if (item.contentType?.startsWith('image/')) {
return true;
}
return IMAGE_EXTENSIONS.has(getExtension(item.name));
}
interface ImagePreviewState {
visible: boolean;
urls: string[];
}
const imagePreview = reactive<ImagePreviewState>({
visible: false,
urls: []
});
function getExtension(name: string) {
const idx = name.lastIndexOf('.');
return idx > 0 ? name.slice(idx + 1).toLowerCase() : '';
}
function validateFile(file: File): string | null {
if (!file.name) {
return '文件名为空';
}
if (file.name.length > 255) {
return '文件名超过 255 字符';
}
const ext = getExtension(file.name);
if (!ext) {
return '文件缺少扩展名';
}
if (FORBIDDEN_EXTENSIONS.has(ext)) {
return `不允许上传 .${ext} 文件`;
}
if (!ALLOWED_EXTENSIONS.has(ext)) {
return `暂不支持 .${ext} 文件`;
}
if (file.size > props.maxFileSizeMB * 1024 * 1024) {
return `单文件不能超过 ${props.maxFileSizeMB}MB`;
}
return null;
}
function triggerSelect() {
if (props.disabled || isFull.value) {
return;
}
inputRef.value?.click();
}
async function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files || []);
input.value = '';
if (files.length === 0) {
return;
}
const remaining = props.max - totalCount.value;
if (files.length > remaining) {
window.$message?.warning(`最多还能上传 ${remaining} 个附件`);
return;
}
const validFiles: File[] = [];
files.forEach(file => {
const err = validateFile(file);
if (err) {
window.$message?.error(`${file.name}${err}`);
return;
}
validFiles.push(file);
});
if (validFiles.length === 0) {
return;
}
await Promise.all(validFiles.map(uploadOne));
}
async function uploadOne(file: File) {
const tempId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
pending.value = [...pending.value, { id: tempId, name: file.name }];
try {
const result = await uploadFile(file, props.directory);
if (result.error || !result.data) {
window.$message?.error(`${file.name}:上传失败`);
return;
}
const { id, url } = result.data;
// 组件已卸载用户上传过程中关弹层onBeforeUnmount 已跑过且看不到这个 id
// 这里立刻调删除,避免孤儿文件
if (isUnmounting.value) {
deleteFile(id).catch(() => {
// 已卸载场景下 console.warn 也访问不到 component scope这里静默吞掉
});
return;
}
model.value = [
...model.value,
{
fileId: id,
url,
name: file.name,
size: file.size,
contentType: file.type || undefined
}
];
session.addedIds.add(id);
} finally {
pending.value = pending.value.filter(item => item.id !== tempId);
}
}
function handleRemove(item: Api.Project.AttachmentItem) {
removeAttachmentByFileId(item.fileId);
}
async function fetchAsBlobUrl(item: Api.Project.AttachmentItem) {
const { data, error } = await downloadFile(item.fileId);
if (error || !data) {
window.$message?.error(`${item.name}:加载失败`);
return null;
}
return URL.createObjectURL(data);
}
async function handleDownload(item: Api.Project.AttachmentItem) {
const blobUrl = await fetchAsBlobUrl(item);
if (!blobUrl) {
return;
}
const link = document.createElement('a');
link.href = blobUrl;
link.download = item.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
}
async function handlePreviewImage(item: Api.Project.AttachmentItem) {
const blobUrl = await fetchAsBlobUrl(item);
if (!blobUrl) {
return;
}
imagePreview.urls = [blobUrl];
imagePreview.visible = true;
}
function handleClosePreview() {
imagePreview.urls.forEach(url => URL.revokeObjectURL(url));
imagePreview.urls = [];
imagePreview.visible = false;
}
/** 文件名点击的统一入口:图片走预览,其余走下载 */
function handleOpen(item: Api.Project.AttachmentItem) {
if (isImage(item)) {
handlePreviewImage(item);
} else {
handleDownload(item);
}
}
/** 把 model 里的某项移除(折叠浮层里也用,不依赖索引) */
function removeAttachmentByFileId(fileId: string) {
if (props.disabled) {
return;
}
const idx = model.value.findIndex(item => item.fileId === fileId);
if (idx === -1) {
return;
}
session.pendingDeleteIds.add(fileId);
model.value = model.value.filter((_, i) => i !== idx);
}
function formatSize(size?: number) {
if (!size && size !== 0) {
return '';
}
if (size < 1024) {
return `${size}B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)}KB`;
}
if (size < 1024 * 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(1)}MB`;
}
return `${(size / 1024 / 1024 / 1024).toFixed(2)}GB`;
}
/**
* 删除一批 fileId。fire-and-forget
* - 不阻塞 UI任何失败仅 console.warn
* - 后端返回 1001003001文件不存在视为成功
*/
async function deleteMany(ids: string[]) {
if (ids.length === 0) {
return;
}
await Promise.allSettled(
ids.map(async id => {
const { error } = await deleteFile(id);
if (error) {
// eslint-disable-next-line no-console
console.warn('[BusinessAttachmentUploader] 删除失败(已忽略)', id, error);
}
})
);
}
/** 等关闭弹层时先等再清理。设上限 5s避免极端网络下 commit/rollback 永久挂起。 */
async function waitForPending(maxWaitMs = 5000) {
const start = Date.now();
while (pending.value.length > 0) {
if (Date.now() - start >= maxWaitMs) {
// eslint-disable-next-line no-console
console.warn('[BusinessAttachmentUploader] 等待 pending 上传超时,继续后续清理');
return;
}
// polling: 需要在循环里 awaitsuppress 即可
// eslint-disable-next-line no-await-in-loop
await new Promise<void>(resolve => {
setTimeout(resolve, 50);
});
}
}
defineExpose({
/**
* 父组件在【打开弹层并填充 model 之后】调用。
* 把当前 model 视为 original清空 added / pendingDelete重置 committed。
*/
initSession() {
session.originalIds = new Set(model.value.map(item => item.fileId));
session.addedIds.clear();
session.pendingDeleteIds.clear();
session.committed = false;
},
/**
* 父组件在【业务保存成功后】调用。
* 真删 pendingDelete含 original 和 added 两类);置 committed 阻止后续 rollback。
*/
async commit() {
await waitForPending();
const ids = Array.from(session.pendingDeleteIds);
session.pendingDeleteIds.clear();
session.addedIds.clear();
session.committed = true;
await deleteMany(ids);
},
/**
* 父组件取消/关闭时调用onBeforeUnmount 也会兜底调一次。
* 真删 addedIds保留 originalcommitted=true 时跳过。
*/
async rollback() {
if (session.committed) {
return;
}
await waitForPending();
const ids = Array.from(session.addedIds);
session.addedIds.clear();
session.pendingDeleteIds.clear();
session.committed = true;
await deleteMany(ids);
},
/** 父组件在提交前可读此值判断是否还有 pending 上传 */
get hasUploading() {
return hasUploading.value;
}
});
onBeforeUnmount(() => {
// 标记卸载中:让正在 flight 的 uploadOne 完成时知道要立刻删除自己
isUnmounting.value = true;
// 兜底:用户没显式 rollback 就直接关弹层 / 切路由 / unmount
// deleteMany 内部已 swallow 单项失败,这里不再 awaitfire-and-forget
if (!session.committed) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
deleteMany(Array.from(session.addedIds));
}
});
</script>
<template>
<div class="business-attachment-uploader">
<div v-if="!disabled" class="business-attachment-uploader__trigger">
<ElButton :icon="Upload" :disabled="isFull" :loading="hasUploading" @click="triggerSelect">点击上传</ElButton>
<span class="business-attachment-uploader__hint">
最多 {{ max }} 已选 {{ totalCount }} 单文件 {{ maxFileSizeMB }}MB
<ElTooltip placement="top">
<template #content>
<div class="business-attachment-uploader__hint-tooltip">
<div>{{ ALLOWED_EXTENSIONS_HINT }}</div>
<div class="business-attachment-uploader__hint-tooltip-ext">允许扩展名{{ acceptExtensionsList }}</div>
</div>
</template>
<ElIcon class="business-attachment-uploader__hint-icon"><QuestionFilled /></ElIcon>
</ElTooltip>
</span>
<input
ref="inputRef"
type="file"
multiple
class="business-attachment-uploader__input"
@change="handleFileChange"
/>
</div>
<div v-else-if="totalCount === 0" class="business-attachment-uploader__empty">暂无附件</div>
<ul v-if="totalCount > 0" class="business-attachment-uploader__list">
<!-- 直接展示默认仅首项flat 模式全部 -->
<li v-for="item in displayedAttachments" :key="`done-${item.fileId}`" class="business-attachment-uploader__item">
<ElIcon class="business-attachment-uploader__icon">
<Picture v-if="isImage(item)" />
<Document v-else />
</ElIcon>
<ElLink
type="primary"
:underline="false"
class="business-attachment-uploader__name"
:title="item.name"
@click="handleOpen(item)"
>
{{ item.name }}
</ElLink>
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span>
<ElLink type="primary" :underline="false" @click="handleDownload(item)">下载</ElLink>
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
</li>
<!-- 折叠提示>1 个时显示hover 弹完整列表flat 模式下永不出现 -->
<li v-if="popoverAttachments.length > 0" class="business-attachment-uploader__more-row">
<ElPopover
trigger="hover"
placement="bottom-start"
:width="380"
:show-after="200"
popper-class="business-attachment-uploader__popover"
>
<template #reference>
<span class="business-attachment-uploader__more">
还有 {{ popoverAttachments.length }} 个附件
<ElIcon><ArrowDown /></ElIcon>
</span>
</template>
<ul class="business-attachment-uploader__popover-list">
<li
v-for="item in popoverAttachments"
:key="`popover-${item.fileId}`"
class="business-attachment-uploader__item"
>
<ElIcon class="business-attachment-uploader__icon">
<Picture v-if="isImage(item)" />
<Document v-else />
</ElIcon>
<ElLink
type="primary"
:underline="false"
class="business-attachment-uploader__name"
:title="item.name"
@click="handleOpen(item)"
>
{{ item.name }}
</ElLink>
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span>
<ElLink type="primary" :underline="false" @click="handleDownload(item)">下载</ElLink>
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
</li>
</ul>
</ElPopover>
</li>
<!-- pending 项不折叠让用户能持续看到上传进度 -->
<li
v-for="item in pending"
:key="`pending-${item.id}`"
class="business-attachment-uploader__item business-attachment-uploader__item--pending"
>
<ElIcon class="business-attachment-uploader__icon is-loading"><Loading /></ElIcon>
<span class="business-attachment-uploader__name" :title="item.name">{{ item.name }}</span>
<span class="business-attachment-uploader__status">上传中</span>
</li>
</ul>
<ElImageViewer
v-if="imagePreview.visible"
:url-list="imagePreview.urls"
hide-on-click-modal
@close="handleClosePreview"
/>
</div>
</template>
<style scoped lang="scss">
.business-attachment-uploader {
display: flex;
flex-direction: column;
gap: 8px;
}
.business-attachment-uploader__trigger {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.business-attachment-uploader__hint {
display: inline-flex;
align-items: center;
gap: 4px;
color: rgb(100 116 139 / 88%);
font-size: 12px;
}
.business-attachment-uploader__hint-icon {
color: rgb(100 116 139 / 88%);
cursor: help;
font-size: 14px;
}
.business-attachment-uploader__hint-tooltip {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 320px;
line-height: 1.5;
}
.business-attachment-uploader__hint-tooltip-ext {
word-break: break-all;
opacity: 0.85;
}
.business-attachment-uploader__empty {
color: rgb(100 116 139 / 88%);
font-size: 13px;
}
.business-attachment-uploader__input {
display: none;
}
.business-attachment-uploader__list {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.business-attachment-uploader__item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--el-fill-color-blank);
font-size: 13px;
&--pending {
background: var(--el-fill-color-light);
color: rgb(100 116 139 / 88%);
}
}
.business-attachment-uploader__icon {
flex: 0 0 auto;
color: var(--el-color-primary);
}
.business-attachment-uploader__name {
flex: 1 1 auto;
min-width: 0;
justify-content: flex-start;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.business-attachment-uploader__size {
flex: 0 0 auto;
color: rgb(100 116 139 / 88%);
font-size: 12px;
}
.business-attachment-uploader__status {
flex: 0 0 auto;
color: rgb(100 116 139 / 88%);
font-size: 12px;
}
.business-attachment-uploader__more-row {
display: flex;
align-items: center;
padding: 2px 0;
}
.business-attachment-uploader__more {
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--el-color-primary);
font-size: 13px;
cursor: pointer;
user-select: none;
&:hover {
text-decoration: underline;
}
}
</style>
<style lang="scss">
// 浮层非 scopedpopper 渲染到 body
.business-attachment-uploader__popover {
padding: 8px 4px !important;
.business-attachment-uploader__popover-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 280px;
margin: 0;
padding: 0;
list-style: none;
overflow-y: auto;
}
.business-attachment-uploader__item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
font-size: 13px;
&:hover {
background: var(--el-fill-color-light);
}
}
.business-attachment-uploader__icon {
flex: 0 0 auto;
color: var(--el-color-primary);
}
.business-attachment-uploader__name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.business-attachment-uploader__size {
flex: 0 0 auto;
color: rgb(100 116 139 / 88%);
font-size: 12px;
}
}
</style>

View File

@@ -49,8 +49,8 @@ const visible = defineModel<boolean>({
const DIALOG_WIDTH_MAP: Record<DialogPreset, string> = {
sm: '520px',
md: '640px',
lg: '720px'
md: '720px',
lg: '960px'
};
const dialogWidth = computed(() => props.width ?? DIALOG_WIDTH_MAP[props.preset]);

View File

@@ -0,0 +1,459 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue';
import '@wangeditor/editor/dist/css/style.css';
import { ElImageViewer } from 'element-plus';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
import { deleteFile, uploadFile } from '@/service/api/file';
defineOptions({ name: 'BusinessRichTextEditor' });
interface Props {
placeholder?: string;
disabled?: boolean;
height?: number | string;
/** 上传目录,传给后端 directory 字段 */
uploadDirectory?: string;
/** 单张图片大小上限MB默认 5 */
maxImageSizeMB?: number;
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '请输入内容',
disabled: false,
height: 320,
uploadDirectory: undefined,
maxImageSizeMB: 5
});
const model = defineModel<string | null | undefined>({ default: '' });
const editorRef = shallowRef<IDomEditor>();
const containerRef = ref<HTMLElement>();
/**
* 图片预览:
* - hover 富文本里的 <img> → 在图片右上角浮一个放大镜按钮
* - 点按钮 → ElImageViewer 多图模式url-list = 当前 HTML 里所有 img src按出现顺序去重
* - 编辑态与 disabled 只读态共用
*/
const zoomBtnVisible = ref(false);
const zoomBtnStyle = ref<Record<string, string>>({});
const hoveredImageSrc = ref('');
const viewerVisible = ref(false);
const viewerUrlList = ref<string[]>([]);
const viewerIndex = ref(0);
let hideZoomBtnTimer: number | undefined;
function cancelHideZoomBtn() {
if (hideZoomBtnTimer !== undefined) {
window.clearTimeout(hideZoomBtnTimer);
hideZoomBtnTimer = undefined;
}
}
function scheduleHideZoomBtn() {
cancelHideZoomBtn();
hideZoomBtnTimer = window.setTimeout(() => {
zoomBtnVisible.value = false;
}, 150);
}
function positionZoomBtn(img: HTMLImageElement) {
const container = containerRef.value;
if (!container) {
return;
}
const containerRect = container.getBoundingClientRect();
const imgRect = img.getBoundingClientRect();
const btnSize = 28;
const gap = 8;
zoomBtnStyle.value = {
top: `${imgRect.top - containerRect.top + gap}px`,
left: `${imgRect.right - containerRect.left - btnSize - gap}px`
};
hoveredImageSrc.value = img.getAttribute('src') ?? '';
zoomBtnVisible.value = true;
}
function isZoomBtn(el: EventTarget | null): boolean {
return el instanceof HTMLElement && Boolean(el.closest('.business-rich-text-editor__zoom-btn'));
}
function findImageAtPoint(e: MouseEvent): HTMLImageElement | null {
const container = containerRef.value;
if (!container) {
return null;
}
const target = e.target as HTMLElement | null;
// 1) target 本身或祖先链上是 img
const direct =
target?.tagName === 'IMG' ? (target as HTMLImageElement) : (target?.closest('img') as HTMLImageElement | null);
if (direct && container.contains(direct)) {
return direct;
}
// 2) 兜底wangeditor 可能在图片上层叠了 resize/selection 遮罩target 不是 img用坐标穿透找
if (typeof document.elementsFromPoint === 'function') {
const stack = document.elementsFromPoint(e.clientX, e.clientY);
for (const el of stack) {
if (el.tagName === 'IMG' && container.contains(el)) {
return el as HTMLImageElement;
}
}
}
return null;
}
function onContainerMouseOver(e: MouseEvent) {
if (isZoomBtn(e.target)) {
cancelHideZoomBtn();
return;
}
const img = findImageAtPoint(e);
if (img) {
cancelHideZoomBtn();
positionZoomBtn(img);
} else {
scheduleHideZoomBtn();
}
}
function onContainerMouseLeave() {
scheduleHideZoomBtn();
}
function onTextScroll() {
// wangeditor 内部滚动后按钮坐标会和图片错位,直接隐藏由下次 hover 重算
zoomBtnVisible.value = false;
}
function openImageViewer() {
if (!hoveredImageSrc.value) {
return;
}
const urls = listImageSrcs(model.value);
const idx = urls.indexOf(hoveredImageSrc.value);
viewerUrlList.value = urls.length > 0 ? urls : [hoveredImageSrc.value];
viewerIndex.value = idx >= 0 ? idx : 0;
viewerVisible.value = true;
}
function closeImageViewer() {
viewerVisible.value = false;
}
/**
* 会话级清理账本(富文本图片治标):
* - uploadedMap: 本次会话内通过 customUpload 上传成功的图片 url -> fileId
* - committed: commit() 调用后置 true阻止后续 rollback / 卸载兜底重复删
*
* 真删时机:
* - commit(): 扫当前 model HTML删 uploadedMap 里"url 已不在 HTML"的项(被用户删掉的图)
* - rollback(): 删 uploadedMap 里所有项(整个会话不要了)
* - onBeforeUnmount: 兜底走 rollback 等价逻辑
*/
interface RichTextSession {
uploadedMap: Map<string, string>;
committed: boolean;
}
const session = reactive<RichTextSession>({
uploadedMap: new Map(),
committed: false
});
const toolbarConfig: Partial<IToolbarConfig> = {
excludeKeys: [
// 视频组
'group-video',
'insertVideo',
'uploadVideo',
// 更多样式分组
'group-more-style',
// 图片:只允许本地上传,不允许插入网络图片 URL
'insertImage',
// 超链接:业务暂不需要
'insertLink',
'editLink',
'unLink',
'viewLink'
]
};
const editorConfig: Partial<IEditorConfig> = {
placeholder: props.placeholder,
readOnly: props.disabled,
MENU_CONF: {
uploadImage: {
maxFileSize: props.maxImageSizeMB * 1024 * 1024,
allowedFileTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp'],
async customUpload(file: File, insertFn: (url: string, alt?: string, href?: string) => void) {
const result = await uploadFile(file, props.uploadDirectory);
if (result.error || !result.data) {
const msg = result.error?.response?.data?.msg || '图片上传失败';
window.$message?.error(msg);
return;
}
const { id, url } = result.data;
// 记录 url -> fileId后续 commit/rollback 才知道删哪个
session.uploadedMap.set(url, id);
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;
const textContainer = containerRef.value?.querySelector('.w-e-text-container');
textContainer?.addEventListener('scroll', onTextScroll, { passive: true });
}
/**
* 从 HTML 字符串里抓所有 <img src="...">,返回 url 集合。
* 用 regex 而不是 DOMParser 是为了避免对 SSR / 测试环境的依赖。
*/
function extractImageUrls(html: string | null | undefined): Set<string> {
const urls = new Set<string>();
if (!html) {
return urls;
}
const re = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
let match: RegExpExecArray | null = re.exec(html);
while (match !== null) {
urls.add(match[1]);
match = re.exec(html);
}
return urls;
}
/** 按出现顺序去重列出当前 HTML 内所有 img src给 ElImageViewer 用。 */
function listImageSrcs(html: string | null | undefined): string[] {
const list: string[] = [];
if (!html) {
return list;
}
const re = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
let match: RegExpExecArray | null = re.exec(html);
while (match !== null) {
if (!list.includes(match[1])) {
list.push(match[1]);
}
match = re.exec(html);
}
return list;
}
/** 删除一批 fileId。fire-and-forget单项失败仅 console.warn。 */
async function deleteMany(ids: string[]) {
if (ids.length === 0) {
return;
}
await Promise.allSettled(
ids.map(async id => {
const { error } = await deleteFile(id);
if (error) {
// eslint-disable-next-line no-console
console.warn('[BusinessRichTextEditor] 删除失败(已忽略)', id, error);
}
})
);
}
defineExpose({
/**
* 父组件在【打开弹层并填充 model 之后】调用。
* 清空 uploadedMap 并重置 committedHTML 里已有的图(编辑模式回显的)不进 uploadedMap
* 因此 commit/rollback 不会动它们——只动本次会话上传的图。
*/
initSession() {
session.uploadedMap.clear();
session.committed = false;
},
/**
* 父组件在【业务保存成功后】调用。
* 扫当前 model HTMLuploadedMap 里 url 不在 HTML 的图 = 用户已删除 = 真删。
*/
async commit() {
const currentUrls = extractImageUrls(model.value);
const toDelete: string[] = [];
session.uploadedMap.forEach((fileId, url) => {
if (!currentUrls.has(url)) {
toDelete.push(fileId);
}
});
session.uploadedMap.clear();
session.committed = true;
await deleteMany(toDelete);
},
/**
* 父组件取消/关闭时调用onBeforeUnmount 也会兜底调一次。
* 删 uploadedMap 里所有项(整个会话回滚)。
*/
async rollback() {
if (session.committed) {
return;
}
const toDelete = Array.from(session.uploadedMap.values());
session.uploadedMap.clear();
session.committed = true;
await deleteMany(toDelete);
}
});
onBeforeUnmount(() => {
cancelHideZoomBtn();
const textContainer = containerRef.value?.querySelector('.w-e-text-container');
textContainer?.removeEventListener('scroll', onTextScroll);
// 兜底:用户没显式 rollback 就直接关弹层 / 切路由 / unmount
if (!session.committed) {
const toDelete = Array.from(session.uploadedMap.values());
session.uploadedMap.clear();
// eslint-disable-next-line @typescript-eslint/no-floating-promises
deleteMany(toDelete);
}
editorRef.value?.destroy();
editorRef.value = undefined;
});
/** 当 height 传 '100%' 或 'auto' 时启用「撑满父容器」模式 —— 父级必须有具体高度。 */
const isAutoFill = computed(() => props.height === '100%' || props.height === 'auto');
const containerClass = computed(() => ({
'business-rich-text-editor': true,
'business-rich-text-editor--auto-fill': isAutoFill.value
}));
const editorStyle = computed(() => {
if (isAutoFill.value) {
return { flex: 1, minHeight: 0, overflowY: 'hidden' as const };
}
return {
height: typeof props.height === 'number' ? `${props.height}px` : props.height,
overflowY: 'hidden' as const
};
});
</script>
<template>
<div ref="containerRef" :class="containerClass" @mouseover="onContainerMouseOver" @mouseleave="onContainerMouseLeave">
<Toolbar
class="business-rich-text-editor__toolbar"
:editor="editorRef"
:default-config="toolbarConfig"
mode="default"
/>
<Editor
v-model="model"
class="business-rich-text-editor__editor"
:style="editorStyle"
:default-config="editorConfig"
mode="default"
@on-created="handleCreated"
/>
<button
v-show="zoomBtnVisible"
type="button"
class="business-rich-text-editor__zoom-btn"
:style="zoomBtnStyle"
title="预览图片"
aria-label="预览图片"
@click.stop="openImageViewer"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
<path
d="M10 2a8 8 0 1 1-5.29 14.04L1.4 19.36a1 1 0 1 1-1.4-1.4l3.32-3.32A8 8 0 0 1 10 2zm0 2a6 6 0 1 0 0 12 6 6 0 0 0 0-12zm1 3v2h2v2h-2v2H9v-2H7V9h2V7h2z"
/>
</svg>
</button>
<ElImageViewer
v-if="viewerVisible"
:url-list="viewerUrlList"
:initial-index="viewerIndex"
:z-index="3100"
teleported
hide-on-click-modal
@close="closeImageViewer"
/>
</div>
</template>
<style scoped lang="scss">
.business-rich-text-editor {
position: relative;
width: 100%;
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
overflow: hidden;
background: var(--el-bg-color);
&__toolbar {
border-bottom: 1px solid var(--el-border-color);
background: var(--el-fill-color-light);
}
&__editor {
background: var(--el-bg-color);
}
&--auto-fill {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
&__zoom-btn {
position: absolute;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 4px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: pointer;
transition: background 0.15s;
z-index: 10;
&:hover {
background: rgba(0, 0, 0, 0.75);
}
}
}
/* wangeditor 弹层(链接、图片菜单等)默认 z-index 偏低,提高一档避免被 ElDialog 遮挡 */
:deep(.w-e-modal),
:deep(.w-e-drop-panel),
:deep(.w-e-bar-divider),
:deep(.w-e-hover-bar) {
z-index: 3000 !important;
}
</style>

View 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>

View 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>

View 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>

View File

@@ -89,3 +89,25 @@ export const postTypeRecord: Record<Api.SystemManage.PostType, string> = {
};
export const postTypeOptions = transformRecordToOption(postTypeRecord);
/**
* 产品对象域角色编码:产品经理
*
* 用途:
* 产品创建两步向导第 2 步初始化团队时,前端按本 code 在 fetchGetRoleSimpleList
* 返回的角色列表中反查产品经理角色 ID作为默认经理成员行的 roleId 提交。
*
* 来源口径:后端约定的产品对象域内置角色稳定 code。code 变更需同步前端常量。
*/
export const PRODUCT_MANAGER_ROLE_CODE = 'product_manager';
/**
* 项目对象域角色编码:项目经理
*
* 用途:
* 项目创建两步向导第 2 步初始化团队时,前端按本 code 在 fetchGetRoleSimpleList
* 返回的角色列表中反查项目经理角色 ID作为默认经理成员行的 roleId 提交。
*
* 来源口径:后端约定的项目对象域内置角色稳定 code。code 变更需同步前端常量。
*/
export const PROJECT_MANAGER_ROLE_CODE = 'project_manager';

View File

@@ -59,3 +59,27 @@ export const RDMS_REQ_PRIORITY_DICT_CODE = 'rdms_req_priority';
* 来源口径:产品需求文档中定义,标签包括工程需求、用户需求、安全需求、体验优化、功能需求
*/
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';
/**
* 需求允许删除的状态字典编码
*
* 对应业务字段:需求删除功能中判断 statusCode 是否允许删除
* 来源口径:用户在系统字典管理页中创建的字典 rdms_req_can_delete_status
*/
export const RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE = 'rdms_req_can_delete_status';

View File

@@ -10,11 +10,11 @@ export const objectContextDomainConfigs: App.ObjectContext.DomainConfig[] = [
routePathPrefixes: ['/project'],
entryRouteKey: 'project_list',
entryRoutePath: '/project/list',
fallbackDefaultRouteKey: 'project_dashboard',
fallbackDefaultRoutePath: '/project/dashboard',
contextApiPath: `${WEB_SERVICE_PREFIX}/project/context`,
contextApiObjectIdParamKey: 'projectId',
contextApiObjectIdPlacement: 'query',
fallbackDefaultRouteKey: 'project_project_overview',
fallbackDefaultRoutePath: '/project/project/overview',
contextApiPath: `${WEB_SERVICE_PREFIX}/project/project/{id}/context`,
contextApiObjectIdParamKey: 'id',
contextApiObjectIdPlacement: 'path',
objectIdQueryKey: OBJECT_CONTEXT_QUERY_KEY
},
{

View File

@@ -0,0 +1,65 @@
/**
* 业务对象状态颜色ElTag type集中配置
*
* 各业务域的 statusCode → ElTag type 在此统一维护,避免散落在各业务模块。
* 未来若后端状态字典返回颜色字段,可在调用方优先取后端值,缺失时回退此映射。
*/
export type StatusTagType = 'primary' | 'success' | 'warning' | 'info' | 'danger';
export type StatusDomain =
| 'projectExecution'
| 'projectTask'
| 'executionAssignee'
| 'taskAssigneeMember'
| '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',
paused: 'warning',
completed: 'success',
cancelled: 'danger'
},
// 执行协办人变更事件
executionAssignee: {
join: 'success',
inactive: 'danger',
owner_transfer_in: 'warning',
owner_transfer_out: 'warning'
},
// 任务协办人变更事件
taskAssigneeMember: {
join: 'success',
inactive: 'danger'
},
// 项目(待补全)
project: {},
// 产品(待补全)
product: {},
// 需求(待补全)
requirement: {},
// 工单(待补全)
workOrder: {}
};
export function getStatusTagType(domain: StatusDomain, statusCode: string | null | undefined): StatusTagType {
if (!statusCode) {
return 'info';
}
return statusTagTypeRegistry[domain][statusCode] || 'info';
}

View File

@@ -12,6 +12,8 @@ const authStore = useAuthStore();
const { routerPushByKey, toLogin } = useRouterPush();
const { SvgIconVNode } = useSvgIcon();
const displayName = computed(() => authStore.userInfo.nickname || authStore.userInfo.userName);
function loginOrRegister() {
toLogin();
}
@@ -84,7 +86,7 @@ function handleDropdown(key: DropdownKey) {
</template>
<div class="flex items-center">
<SvgIcon icon="ph:user-circle" class="mr-5px text-icon-large" />
<span class="text-16px font-medium">{{ authStore.userInfo.userName }}</span>
<span class="text-16px font-medium">{{ displayName }}</span>
</div>
</ElDropdown>
</template>

View File

@@ -169,12 +169,19 @@ const local: App.I18n.Schema = {
function_request: 'Request',
'function_toggle-auth': 'Toggle Auth',
'function_super-page': 'Super Admin Visible',
product: 'Product Management',
product: 'Product',
product_list: 'Product List',
product_dashboard: 'Product Dashboard',
product_requirement: 'Requirement Pool',
product_setting: 'Product Settings',
system: 'System Management',
product_dashboard: 'Dashboard',
product_requirement: 'Requirement',
product_setting: 'Settings',
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-detail': 'User Detail',
system_role: 'Role Management',

View File

@@ -174,6 +174,13 @@ const local: App.I18n.Schema = {
product_dashboard: '产品仪表盘',
product_requirement: '需求池',
product_setting: '产品设置',
project: '项目管理',
project_list: '项目列表',
project_project: '项目详情',
project_project_overview: '项目概览',
project_project_requirement: '需求池',
project_project_execution: '任务管理',
project_project_setting: '项目设置',
system: '系统管理',
system_user: '用户管理',
'system_user-detail': '用户详情',

View File

@@ -1,9 +1,11 @@
import { extend } from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
import localeData from 'dayjs/plugin/localeData';
import { setDayjsLocale } from '../locales/dayjs';
export function setupDayjs() {
extend(localeData);
extend(isoWeek);
setDayjsLocale();
}

View File

@@ -51,6 +51,11 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
product_list: () => import("@/views/product/list/index.vue"),
product_requirement: () => import("@/views/product/requirement/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_menu: () => import("@/views/system/menu/index.vue"),
system_post: () => import("@/views/system/post/index.vue"),

View File

@@ -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',
path: '/system',

View File

@@ -211,6 +211,13 @@ const routeMap: RouteMap = {
"product_list": "/product/list",
"product_requirement": "/product/requirement",
"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_dict": "/system/dict",
"system_menu": "/system/menu",

View File

@@ -14,6 +14,7 @@ interface BackendLoginToken {
interface BackendUserInfoDTO {
userId: string | number;
userName?: string | null;
nickname?: string | null;
roles?: string[] | null;
buttons?: string[] | null;
}
@@ -32,6 +33,7 @@ function mapUserInfo(data: BackendUserInfoDTO): Api.Auth.UserInfo {
return {
userId: String(data.userId ?? ''),
userName: data.userName ?? '',
nickname: data.nickname ?? '',
roles: data.roles ?? [],
buttons: data.buttons ?? []
};

55
src/service/api/file.ts Normal file
View File

@@ -0,0 +1,55 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
const FILE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/file`;
export interface UploadFileResult {
/** infra_file.id 的字符串形式(避免 Long 精度丢失) */
id: string;
/** 文件访问 URL私有桶带签名、公开桶裸 URL */
url: string;
}
/** 上传文件(模式一:后端中转) */
export function uploadFile(file: File, directory?: string) {
const formData = new FormData();
formData.append('file', file);
if (directory) {
formData.append('directory', directory);
}
return request<UploadFileResult>({
url: `${FILE_PREFIX}/upload`,
method: 'post',
data: formData
});
}
/**
* 删除文件
*
* 业务表单"取消/关闭/标记删除"场景调用本接口清理孤儿文件。
* 删除已不存在的文件(后端返回错误码 `1001003001`)应由调用方视为成功并吞掉。
*/
export function deleteFile(id: string) {
return request<boolean>({
url: `${FILE_PREFIX}/delete`,
method: 'delete',
params: { id }
});
}
/**
* 下载文件(流)
*
* 走后端代理接口 `/system/file/download?id=xxx`,由后端读取对象存储并以字节流返回。
* 私有桶下不要直接打开 `infra_file.url`,签名地址会过期。
*/
export function downloadFile(id: string) {
return request<Blob, 'blob'>({
url: `${FILE_PREFIX}/download`,
method: 'get',
params: { id },
responseType: 'blob'
});
}

View File

@@ -1,6 +1,9 @@
export * from './auth';
export * from './dict';
export * from './file';
export * from './object-context';
export * from './product';
export * from './project';
export * from './project-shared';
export * from './route';
export * from './system-manage';

View 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
};
}

View File

@@ -1,145 +1,7 @@
import type { LocationQueryValue } from 'vue-router';
import { request } from '../request';
import {
type ServiceRequestResult,
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;
}
import { type ServiceRequestResult, safeJsonRequestConfig } from './shared';
import { type BackendObjectContextDTO, normalizeObjectContext } from './object-context-normalize';
function createContextApiUrl(config: App.ObjectContext.DomainConfig, objectId: string) {
if (config.contextApiObjectIdPlacement !== 'path') {
@@ -151,30 +13,6 @@ function createContextApiUrl(config: App.ObjectContext.DomainConfig, objectId: s
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(
config: App.ObjectContext.DomainConfig,
objectId: string

View File

@@ -91,7 +91,7 @@ function createProductActivityTimelinePageQuery(params: Api.Product.ProductActiv
return query.toString();
}
/** 鑾峰彇浜у搧鍒嗛〉 */
/** 获取产品分页 */
export async function fetchGetProductPage(params?: Api.Product.ProductSearchParams) {
const result = await request<ProductPageResponse>({
...safeJsonRequestConfig,
@@ -106,7 +106,16 @@ export async function fetchGetProductPage(params?: Api.Product.ProductSearchPara
}));
}
/** 鑾峰彇浜у搧璇︽儏 */
/** 获取产品入口页概览统计 */
export function fetchGetProductOverviewSummary() {
return request<Api.Product.ProductOverviewSummary>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/overview-summary`,
method: 'get'
});
}
/** 获取产品详情 */
export async function fetchGetProduct(id: string) {
const result = await request<ProductResponse>({
...safeJsonRequestConfig,
@@ -118,7 +127,7 @@ export async function fetchGetProduct(id: string) {
return mapServiceResult(result as ServiceRequestResult<ProductResponse>, normalizeProduct);
}
/** 鍒涘缓浜у搧 */
/** 新增产品 */
export async function fetchCreateProduct(data: Api.Product.SaveProductParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
@@ -130,7 +139,19 @@ export async function fetchCreateProduct(data: Api.Product.SaveProductParams) {
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 鏇存柊浜у搧 */
/** 创建产品(含初始团队,原子接口) */
export async function fetchCreateProductWithTeam(data: Api.Product.CreateProductWithTeamParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/create-with-team`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 更新产品 */
export function fetchUpdateProduct(data: Api.Product.UpdateProductParams) {
return request<boolean>({
url: `${PRODUCT_PREFIX}/update`,
@@ -139,7 +160,7 @@ export function fetchUpdateProduct(data: Api.Product.UpdateProductParams) {
});
}
/** 鍙樻洿浜у搧鐘舵€? */
/** 改变产品状态 */
export function fetchChangeProductStatus(data: Api.Product.ChangeProductStatusParams) {
return request<boolean>({
url: `${PRODUCT_PREFIX}/change-status`,
@@ -148,7 +169,7 @@ export function fetchChangeProductStatus(data: Api.Product.ChangeProductStatusPa
});
}
/** 鍒犻櫎浜у搧 */
/** 删除产品 */
export function fetchDeleteProduct(data: Api.Product.DeleteProductParams) {
return request<boolean>({
url: `${PRODUCT_PREFIX}/delete`,
@@ -162,7 +183,14 @@ const REQUIREMENT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product/requirement`;
type RequirementResponse = Omit<
Api.Product.Requirement,
'id' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'implementProjectId' | 'sourceBizId'
| 'id'
| 'parentId'
| 'moduleId'
| 'proposerId'
| 'currentHandlerUserId'
| 'implementProjectId'
| 'sourceBizId'
| 'attachments'
> & {
id: string | number;
parentId: string | number;
@@ -170,12 +198,34 @@ type RequirementResponse = Omit<
proposerId: string | number;
currentHandlerUserId?: string | number | null;
implementProjectId?: string | number | null;
implementProjectName?: string | null;
sourceBizId?: string | number | null;
attachments?: AttachmentItemResponse[] | null;
children?: RequirementResponse[];
};
type RequirementPageResponse = Api.Product.PageResult<RequirementResponse>;
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
fileId?: string | number;
id?: string | number;
};
function normalizeAttachments(list?: AttachmentItemResponse[] | null): Api.Project.AttachmentItem[] | null {
if (!list) {
return null;
}
return list.map(item => {
const rawId = item.fileId ?? item.id;
return {
...item,
fileId: rawId === null || rawId === undefined ? '' : String(rawId)
};
});
}
function normalizeRequirement(requirement: RequirementResponse): Api.Product.Requirement {
return {
...requirement,
@@ -185,7 +235,9 @@ function normalizeRequirement(requirement: RequirementResponse): Api.Product.Req
proposerId: normalizeStringId(requirement.proposerId),
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
implementProjectId: normalizeNullableStringId(requirement.implementProjectId),
implementProjectName: requirement.implementProjectName ?? null,
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
attachments: normalizeAttachments(requirement.attachments),
children: requirement.children?.map(normalizeRequirement)
};
}
@@ -342,6 +394,26 @@ export async function fetchGetRequirementTerminalStatusDict() {
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
}
/** 判断产品需求是否已分流生成项目需求 */
export async function fetchHasDispatchedProjectRequirement(requirementId: string, productId: string) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/has-dispatched`,
method: 'get',
params: { requirementId, productId }
});
}
/** 根据当前产品需求id获取对应地所流转到项目侧的项目需求id */
export async function fetchGetDispatchedProjectLink(productRequirementId: string) {
return request<{ projectRequirementId: string; projectId: string }>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/dispatched-project-link`,
method: 'get',
params: { productRequirementId }
});
}
// ========== 模块管理 API ==========
type RequirementModuleResponse = Omit<Api.Product.RequirementModule, 'id' | 'parentId' | 'productId'> & {
id: string | number;

View File

@@ -0,0 +1,350 @@
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 ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & {
id: StringIdResponse;
executionId: StringIdResponse;
userId: StringIdResponse;
};
export type ExecutionAssigneeLogResponse = Omit<
Api.Project.ExecutionAssigneeLog,
'id' | 'executionId' | 'userId' | 'operatorUserId'
> & {
id: StringIdResponse;
executionId: StringIdResponse;
userId: StringIdResponse;
operatorUserId: StringIdResponse;
};
type TaskAssigneeRefResponse = Omit<Api.Project.TaskAssigneeRef, 'id' | 'userId'> & {
id: StringIdResponse;
userId: StringIdResponse;
};
/**
* 后端 attachments 项的兼容形态:历史/当前响应字段名是 `id`,前端类型统一用 `fileId`。
* normalizeAttachments 负责把两者归一成 `fileId`。
*/
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
fileId?: StringIdResponse;
id?: StringIdResponse;
};
function normalizeAttachments(list?: AttachmentItemResponse[] | null): Api.Project.AttachmentItem[] | null {
if (!list) {
return null;
}
return list.map(item => {
const rawId = item.fileId ?? item.id;
return {
...item,
fileId: rawId === null || rawId === undefined ? '' : String(rawId)
};
});
}
/**
* 5.6 单独接口返的协办人字段(与 5.3 嵌入字段命名口径不一致:返 userNickname 而非 nickname
* 经 normalizeTaskAssignee 归一化后对外统一为 Api.Project.TaskAssigneeRef。
*/
export type TaskAssigneeFromApiResponse = {
id: StringIdResponse;
taskId: StringIdResponse;
userId: StringIdResponse;
userNickname?: string | null;
joinedAt?: string | null;
};
export type TaskAssigneeLogResponse = Omit<
Api.Project.TaskAssigneeLog,
'id' | 'taskId' | 'userId' | 'operatorUserId'
> & {
id: StringIdResponse;
taskId: StringIdResponse;
userId: StringIdResponse;
operatorUserId: StringIdResponse;
};
export type ProjectTaskResponse = Omit<
Api.Project.ProjectTask,
| 'id'
| 'projectId'
| 'executionId'
| 'parentTaskId'
| 'ownerId'
| 'availableActions'
| 'plannedStartDate'
| 'plannedEndDate'
| 'actualStartDate'
| 'actualEndDate'
| 'progressRate'
| 'assignees'
| 'attachments'
> & {
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;
assignees?: TaskAssigneeRefResponse[] | null;
attachments?: AttachmentItemResponse[] | null;
totalSpentHours?: number | null;
};
export type TaskWorklogResponse = Omit<Api.Project.TaskWorklog, 'id' | 'taskId' | 'userId' | 'attachments'> & {
id: StringIdResponse;
taskId: StringIdResponse;
userId: StringIdResponse;
attachments?: AttachmentItemResponse[] | 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 normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee {
return {
...response,
id: normalizeStringId(response.id),
executionId: normalizeStringId(response.executionId),
userId: normalizeStringId(response.userId),
userNickname: response.userNickname ?? null,
joinedAt: response.joinedAt ?? null,
removedAt: response.removedAt ?? null,
removedReason: response.removedReason ?? null
};
}
export function normalizeExecutionAssigneeLog(
response: ExecutionAssigneeLogResponse
): Api.Project.ExecutionAssigneeLog {
return {
...response,
id: normalizeStringId(response.id),
executionId: normalizeStringId(response.executionId),
userId: normalizeStringId(response.userId),
operatorUserId: normalizeStringId(response.operatorUserId),
userNicknameSnapshot: response.userNicknameSnapshot ?? null,
operatorNicknameSnapshot: response.operatorNicknameSnapshot ?? null,
reason: response.reason ?? null
};
}
export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project.ProjectTask {
return {
...response,
id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId),
executionId: normalizeStringId(response.executionId),
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,
assignees:
response.assignees?.map(item => ({
id: normalizeStringId(item.id),
userId: normalizeStringId(item.userId),
nickname: item.nickname ?? ''
})) ?? null,
attachments: normalizeAttachments(response.attachments),
totalSpentHours: response.totalSpentHours ?? null
};
}
export function normalizeTaskWorklog(response: TaskWorklogResponse): Api.Project.TaskWorklog {
return {
...response,
id: normalizeStringId(response.id),
taskId: normalizeStringId(response.taskId),
userId: normalizeStringId(response.userId),
userNickname: response.userNickname ?? null,
workContent: response.workContent ?? null,
attachments: normalizeAttachments(response.attachments),
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0
};
}
export function normalizeTaskAssignee(response: TaskAssigneeFromApiResponse): Api.Project.TaskAssigneeRef {
return {
id: normalizeStringId(response.id),
userId: normalizeStringId(response.userId),
nickname: response.userNickname ?? '',
joinedAt: response.joinedAt ?? null
};
}
export function normalizeTaskAssigneeLog(response: TaskAssigneeLogResponse): Api.Project.TaskAssigneeLog {
return {
...response,
id: normalizeStringId(response.id),
taskId: normalizeStringId(response.taskId),
userId: normalizeStringId(response.userId),
operatorUserId: normalizeStringId(response.operatorUserId),
userNicknameSnapshot: response.userNicknameSnapshot ?? null,
operatorNicknameSnapshot: response.operatorNicknameSnapshot ?? null,
reason: response.reason ?? null
};
}

1015
src/service/api/project.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -95,31 +95,78 @@ function replaceWithStaticObjectContextDomainRoute(routes: Api.Route.MenuRoute[]
return;
}
const wrappedDomainRoute = cloneStaticRouteAsMenuRoute(staticDomainRoute, `object-context:${config.domainKey}`);
const entryRouteIndex = normalizedRoutes.findIndex(route => route.id === entryRoute.id);
const domainRouteIds = new Set(domainTopLevelRoutes.map(route => route.id));
// Create a map of backend routes by name for quick lookup
const backendRouteMap = new Map<string, Api.Route.MenuRoute>();
domainTopLevelRoutes.forEach(route => {
if (route.name) {
backendRouteMap.set(String(route.name), route);
}
});
if (entryRoute.meta) {
const nextMeta: RouteMeta = {
title: wrappedDomainRoute.meta?.title || config.domainKey,
...(wrappedDomainRoute.meta || {})
// Clone static route but preserve backend route's meta for children
// 待重构:拆 helper 以降低复杂度,暂以 disable 注释临时放行
// eslint-disable-next-line complexity
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) {
nextMeta.icon = entryRoute.meta.icon;
// If there's a backend route, preserve its meta
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) {
nextMeta.localIcon = entryRoute.meta.localIcon;
// Recursively process children
if (route.children?.length) {
baseRoute.children = route.children.map(child => cloneStaticRoutePreservingBackendMeta(child, idPrefix));
}
if (entryRoute.meta.order !== undefined) {
nextMeta.order = entryRoute.meta.order;
}
wrappedDomainRoute.meta = nextMeta;
return baseRoute;
}
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.splice(entryRouteIndex < 0 ? normalizedRoutes.length : entryRouteIndex, 0, wrappedDomainRoute);
});

View File

@@ -74,6 +74,7 @@ function createBatchDeleteQuery(ids: Array<string | number>) {
type UserSimpleResponse = Omit<Api.SystemManage.UserSimple, 'id'> & {
id: string | number;
deptId?: string | number | null;
};
type RoleResponse = Omit<Api.SystemManage.Role, 'id'> & {
@@ -120,7 +121,8 @@ type UserManagementRelationTreeResponse = Omit<
function normalizeUserSimple(user: UserSimpleResponse): Api.SystemManage.UserSimple {
return {
...user,
id: normalizeStringId(user.id)
id: normalizeStringId(user.id),
deptId: normalizeNullableStringId(user.deptId)
};
}
@@ -667,7 +669,7 @@ export function fetchAssignUserRoles(data: Api.SystemManage.AssignUserRoleParams
* - 中间节点:有上级也有下级
* - 叶子节点:基层员工,没有下级
*/
export function fetchGetUserManagementRelationTree(query: UserManagementRelationQueryReqVO) {
export async function fetchGetUserManagementRelationTree(query: UserManagementRelationQueryReqVO) {
return request<UserManagementRelationTreeResponse[]>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/tree`,
@@ -684,7 +686,7 @@ export function fetchGetUserManagementRelationTree(query: UserManagementRelation
* 通过搜索框的查询条件,获取用户管理链路树形结构
* 用于树形控件展示,包含用户的上下级层级关系
*/
export function fetchGetUserManagementRelationQuery(query: UserManagementRelationQueryReqVO) {
export async function fetchGetUserManagementRelationQuery(query: UserManagementRelationQueryReqVO) {
return request<UserManagementRelationTreeResponse[]>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/query`,
@@ -704,7 +706,7 @@ export function fetchGetUserManagementRelationQuery(query: UserManagementRelatio
*
* @param id 关系记录主键 ID
*/
export function fetchGetUserManagementRelation(id: string) {
export async function fetchGetUserManagementRelation(id: string) {
return request<UserManagementRelationResponse>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/get`,
@@ -722,7 +724,7 @@ export function fetchGetUserManagementRelation(id: string) {
*
* @param data 创建请求参数
*/
export function fetchCreateUserManagementRelation(data: Api.SystemManage.UserManagementRelationSaveReqVO) {
export async function fetchCreateUserManagementRelation(data: Api.SystemManage.UserManagementRelationSaveReqVO) {
return request<string | number>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/create`,
@@ -776,3 +778,20 @@ export function fetchBatchDeleteUserManagementRelation(ids: string[]) {
method: 'delete'
});
}
/**
* 获取未绑定直属上级的候选下级用户列表
*
* 用于获取尚未绑定直属上级的用户列表,供选择使用
*
* @returns 候选下级用户列表
*/
export async function fetchGetCandidateSubordinateUsers() {
return request<UserSimpleResponse[]>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/candidate-users`,
method: 'get'
}).then(result =>
mapServiceResult(result as ServiceRequestResult<UserSimpleResponse[]>, data => data.map(normalizeUserSimple))
);
}

View File

@@ -0,0 +1,79 @@
import type { InternalAxiosRequestConfig } from 'axios';
declare module 'axios' {
interface AxiosRequestConfig {
dedupe?: boolean;
}
}
const WRITE_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
type DedupableConfig = Pick<InternalAxiosRequestConfig, 'method' | 'url' | 'data' | 'params'> & {
dedupe?: boolean;
};
function isFormDataLike(value: unknown): boolean {
if (typeof FormData !== 'undefined' && value instanceof FormData) return true;
if (typeof Blob !== 'undefined' && value instanceof Blob) return true;
return false;
}
function stableJson(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return `{${keys.map(k => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(',')}}`;
}
export function computeDedupeKey(config: DedupableConfig): string | null {
const method = (config.method ?? 'GET').toUpperCase();
if (!WRITE_METHODS.has(method)) return null;
if (config.dedupe === false) return null;
if (isFormDataLike(config.data)) return null;
const url = config.url ?? '';
const paramsPart = stableJson(config.params);
const bodyPart = stableJson(config.data);
return `${method}|${url}?${paramsPart}|${bodyPart}`;
}
const DEFAULT_TTL_MS = 30_000;
export interface WithDedupeOptions {
ttlMs?: number;
now?: () => number;
}
type AnyRequestFn = (...args: any[]) => Promise<unknown>;
export function withDedupe<TFn extends AnyRequestFn>(request: TFn, options: WithDedupeOptions = {}): TFn {
const ttl = options.ttlMs ?? DEFAULT_TTL_MS;
const now = options.now ?? Date.now;
const pending = new Map<string, { promise: Promise<unknown>; expiresAt: number }>();
return new Proxy(request, {
apply(target, thisArg, args: Parameters<TFn>) {
const [config] = args;
const key = computeDedupeKey(config as DedupableConfig);
if (key === null) return Reflect.apply(target, thisArg, args);
const cached = pending.get(key);
if (cached && cached.expiresAt > now()) return cached.promise;
if (cached) pending.delete(key);
const promise = Promise.resolve()
.then(() => Reflect.apply(target, thisArg, args))
.finally(() => {
const current = pending.get(key);
if (current && current.promise === promise) {
pending.delete(key);
}
});
pending.set(key, { promise, expiresAt: now() + ttl });
return promise;
}
}) as TFn;
}

View File

@@ -6,125 +6,128 @@ import { getServiceBaseURL } from '@/utils/service';
import { $t } from '@/locales';
import { applyApiEncrypt } from './api-encrypt';
import { getAuthorization, handleExpiredRequest, showErrorMsg } from './shared';
import { withDedupe } from './dedupe';
import type { RequestInstanceState } from './type';
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
export const request = createFlatRequest(
{
baseURL,
headers: {
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
}
},
{
defaultState: {
errMsgStack: [],
refreshTokenPromise: null
} as RequestInstanceState,
transform(response: AxiosResponse<App.Service.Response<any>>) {
return response.data.data;
export const request = withDedupe(
createFlatRequest(
{
baseURL,
headers: {
apifoxToken: 'XL299LiMEDZ0H5h3A29PxwQXdMJqWyY2'
}
},
async onRequest(config) {
const Authorization = getAuthorization();
Object.assign(config.headers, { Authorization });
applyApiEncrypt(config);
{
defaultState: {
errMsgStack: [],
refreshTokenPromise: null
} as RequestInstanceState,
transform(response: AxiosResponse<App.Service.Response<any>>) {
return response.data.data;
},
async onRequest(config) {
const Authorization = getAuthorization();
Object.assign(config.headers, { Authorization });
applyApiEncrypt(config);
return config;
},
isBackendSuccess(response) {
// 当后端返回码为 "0"(默认)时,表示请求成功
// 如需调整该逻辑,可修改 `.env` 中的 `VITE_SERVICE_SUCCESS_CODE`
return String(response.data.code) === import.meta.env.VITE_SERVICE_SUCCESS_CODE;
},
async onBackendFail(response, instance) {
const authStore = useAuthStore();
const responseCode = String(response.data.code);
return config;
},
isBackendSuccess(response) {
// 当后端返回码为 "0"(默认)时,表示请求成功
// 如需调整该逻辑,可修改 `.env` 中的 `VITE_SERVICE_SUCCESS_CODE`
return String(response.data.code) === import.meta.env.VITE_SERVICE_SUCCESS_CODE;
},
async onBackendFail(response, instance) {
const authStore = useAuthStore();
const responseCode = String(response.data.code);
function handleLogout() {
authStore.resetStore();
}
function logoutAndCleanup() {
handleLogout();
window.removeEventListener('beforeunload', handleLogout);
request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg);
}
// 当后端返回码命中 `logoutCodes` 时,表示用户需要退出登录并跳转到登录页
const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || [];
if (logoutCodes.includes(responseCode)) {
handleLogout();
return null;
}
// 当后端返回码命中 `modalLogoutCodes` 时,表示通过弹窗提示后再退出登录
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || [];
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg];
// 防止用户刷新页面绕过退出逻辑
window.addEventListener('beforeunload', handleLogout);
window.$messageBox
?.confirm(response.data.msg, $t('common.error'), {
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'error',
closeOnClickModal: false,
closeOnPressEscape: false
})
.then(() => {
logoutAndCleanup();
});
return null;
}
// 当后端返回码命中 `expiredTokenCodes` 时,表示 token 已过期,需要刷新 token
// `refreshToken` 接口不能再返回 `expiredTokenCodes` 中的错误码,否则会形成死循环,应返回 `logoutCodes` 或 `modalLogoutCodes`
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || [];
if (expiredTokenCodes.includes(responseCode)) {
const success = await handleExpiredRequest(request.state);
if (success) {
const Authorization = getAuthorization();
Object.assign(response.config.headers, { Authorization });
return instance.request(response.config) as Promise<AxiosResponse>;
function handleLogout() {
authStore.resetStore();
}
function logoutAndCleanup() {
handleLogout();
window.removeEventListener('beforeunload', handleLogout);
request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg);
}
// 当后端返回码命中 `logoutCodes` 时,表示用户需要退出登录并跳转到登录页
const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || [];
if (logoutCodes.includes(responseCode)) {
handleLogout();
return null;
}
// 当后端返回码命中 `modalLogoutCodes` 时,表示通过弹窗提示后再退出登录
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || [];
if (modalLogoutCodes.includes(responseCode) && !request.state.errMsgStack?.includes(response.data.msg)) {
request.state.errMsgStack = [...(request.state.errMsgStack || []), response.data.msg];
// 防止用户刷新页面绕过退出逻辑
window.addEventListener('beforeunload', handleLogout);
window.$messageBox
?.confirm(response.data.msg, $t('common.error'), {
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'error',
closeOnClickModal: false,
closeOnPressEscape: false
})
.then(() => {
logoutAndCleanup();
});
return null;
}
// 当后端返回码命中 `expiredTokenCodes` 时,表示 token 已过期,需要刷新 token
// `refreshToken` 接口不能再返回 `expiredTokenCodes` 中的错误码,否则会形成死循环,应返回 `logoutCodes` 或 `modalLogoutCodes`
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || [];
if (expiredTokenCodes.includes(responseCode)) {
const success = await handleExpiredRequest(request.state);
if (success) {
const Authorization = getAuthorization();
Object.assign(response.config.headers, { Authorization });
return instance.request(response.config) as Promise<AxiosResponse>;
}
}
return null;
},
onError(error) {
// 请求失败时,在这里统一处理错误提示
let message = error.message;
let backendErrorCode = '';
// 获取后端错误信息和错误码
if (error.code === BACKEND_ERROR_CODE) {
message = error.response?.data?.msg || message;
backendErrorCode = String(error.response?.data?.code || '');
}
// 这类错误信息已经通过弹窗展示,不再重复提示
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || [];
if (modalLogoutCodes.includes(backendErrorCode)) {
return;
}
// token 过期时会自动刷新并重试请求,这里无需额外提示
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || [];
if (expiredTokenCodes.includes(backendErrorCode)) {
return;
}
showErrorMsg(request.state, message);
}
return null;
},
onError(error) {
// 请求失败时,在这里统一处理错误提示
let message = error.message;
let backendErrorCode = '';
// 获取后端错误信息和错误码
if (error.code === BACKEND_ERROR_CODE) {
message = error.response?.data?.msg || message;
backendErrorCode = String(error.response?.data?.code || '');
}
// 这类错误信息已经通过弹窗展示,不再重复提示
const modalLogoutCodes = import.meta.env.VITE_SERVICE_MODAL_LOGOUT_CODES?.split(',') || [];
if (modalLogoutCodes.includes(backendErrorCode)) {
return;
}
// token 过期时会自动刷新并重试请求,这里无需额外提示
const expiredTokenCodes = import.meta.env.VITE_SERVICE_EXPIRED_TOKEN_CODES?.split(',') || [];
if (expiredTokenCodes.includes(backendErrorCode)) {
return;
}
showErrorMsg(request.state, message);
}
}
)
);
export const demoRequest = createRequest(

View File

@@ -28,6 +28,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
const userInfo: Api.Auth.UserInfo = reactive({
userId: '',
userName: '',
nickname: '',
roles: [],
buttons: []
});

View File

@@ -153,7 +153,12 @@ export function getCacheRouteNames(routes: RouteRecordRaw[]) {
const cacheNames: LastLevelRouteKey[] = [];
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 => {
if (child.component && child.meta?.keepAlive) {
cacheNames.push(child.name as LastLevelRouteKey);

View File

@@ -428,6 +428,18 @@ html .el-collapse {
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 {
display: flex;
flex-direction: column;
@@ -484,3 +496,44 @@ html .el-collapse {
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);
}
}

View File

@@ -13,6 +13,7 @@ declare namespace Api {
interface UserInfo {
userId: string;
userName: string;
nickname: string;
roles: string[];
buttons: string[];
}

View File

@@ -21,6 +21,12 @@ declare namespace Api {
list: T[];
}
/** 产品入口页概览统计 */
interface ProductOverviewSummary {
/** 产品状态数量映射key 为后端状态编码 */
statusCounts: Record<string, number>;
}
interface Product {
/** 产品 ID */
id: string;
@@ -204,6 +210,16 @@ declare namespace Api {
previousManagerRoleId?: string | null;
}
/**
* 产品创建(含初始团队)原子接口参数
*
* 新增产品两步向导提交的载荷。经理成员也由前端聚合到 members 数组中。
*/
interface CreateProductWithTeamParams {
product: SaveProductParams;
members: CreateProductMemberParams[];
}
interface UpdateProductMemberParams {
roleId: string;
remark?: string | null;
@@ -250,17 +266,19 @@ declare namespace Api {
moduleId: string;
/** 是否需要评审0不需要1需要 */
reviewRequired: RequirementReviewRequired;
/** 需求标题 */
/** 需求名称 */
title: string;
/** 需求描述(富文本) */
/** 需求内容(富文本) */
description?: string | null;
/** 需求分类字典值 */
/** 附件列表 */
attachments?: Api.Project.AttachmentItem[] | null;
/** 需求类型字典值 */
category: string;
/** 需求类名称 */
/** 需求类名称 */
categoryName?: string | null;
/** 来源类型 */
/** 需求来源类型 */
sourceType: RequirementSourceType;
/** 来源业务ID */
/** 需求来源业务ID */
sourceBizId?: string | null;
/** 优先级0低 1中 2高 3紧急 */
priority: RequirementPriority;
@@ -282,10 +300,10 @@ declare namespace Api {
currentHandlerUserNickname?: string | null;
/** 默认实现项目编号 */
implementProjectId?: string | null;
/** 实现项目名称 */
/** 默认实现项目名称 */
implementProjectName?: string | null;
/** 预期完成时间 */
completionDate: string;
/** 所需工时(小时) */
workHours: number;
/** 排序值 */
sort: number;
/** 创建时间 */
@@ -375,12 +393,15 @@ declare namespace Api {
| 'reviewRequired'
| 'title'
| 'description'
| 'attachments'
| 'category'
| 'priority'
| 'proposerId'
| 'proposerNickname'
| 'currentHandlerUserId'
| 'currentHandlerUserNickname'
| 'implementProjectId'
| 'completionDate'
| 'workHours'
| 'sort'
>;
@@ -412,11 +433,14 @@ declare namespace Api {
| 'reviewRequired'
| 'title'
| 'description'
| 'attachments'
| 'category'
| 'priority'
| 'proposerId'
| 'proposerNickname'
| 'currentHandlerUserId'
| 'completionDate'
| 'currentHandlerUserNickname'
| 'workHours'
| 'sort'
>;

821
src/typings/api/project.d.ts vendored Normal file
View File

@@ -0,0 +1,821 @@
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' | 'paused' | 'completed' | 'cancelled';
/** 任务动作编码 */
type ProjectTaskActionCode = 'auto_start' | 'pause' | '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 ExecutionAssignee {
id: string;
executionId: string;
userId: string;
userNickname?: string | null;
joinedAt: string | null;
removedAt: string | null;
removedReason: string | null;
}
/** 执行协办人变更事件类型 */
type ExecutionAssigneeActionType = 'join' | 'inactive' | 'owner_transfer_in' | 'owner_transfer_out';
/** 执行协办人变更历史 */
interface ExecutionAssigneeLog {
id: string;
executionId: string;
actionType: ExecutionAssigneeActionType;
userId: string;
userNicknameSnapshot: string | null;
operatorUserId: string;
operatorNicknameSnapshot: string | null;
actionTime: string;
reason: string | null;
}
type ExecutionAssigneeLogSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
actionTypes: ExecutionAssigneeActionType[];
userId: string;
startTime: string;
endTime: string;
}
>;
/** 通用附件元数据(任务 / 工时等域共用,规则见 AttachmentValidator */
interface AttachmentItem {
/**
* 文件 IDinfra_file.id 字符串形式)。
* 用于会话级清理时调用 DELETE /system/file/delete?id=xxx 删除孤儿文件。
*/
fileId: string;
url: string;
name: string;
size?: number;
contentType?: string;
}
/** 任务详情 / 分页响应里嵌入的活跃协办人引用(按加入时间正序) */
interface TaskAssigneeRef {
id: string;
userId: string;
nickname: string;
/** 加入时间5.6 路径返5.3 嵌入路径不返,留 undefined */
joinedAt?: string | null;
}
/** 协办人变更事件类型5.9 actionType */
type TaskAssigneeActionType = 'join' | 'inactive';
/** 协办人变更日志 */
interface TaskAssigneeLog {
id: string;
taskId: string;
actionType: TaskAssigneeActionType;
userId: string;
userNicknameSnapshot: string | null;
operatorUserId: string;
operatorNicknameSnapshot: string | null;
actionTime: string;
reason: string | null;
}
type TaskAssigneeLogSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
actionTypes: TaskAssigneeActionType[];
userId: string;
startTime: string;
endTime: string;
}
>;
/** 5.7 加入协办人入参 */
interface CreateTaskAssigneeParams {
userId: string;
}
/** 5.8 退出协办人入参 */
interface InactiveTaskAssigneeParams {
reason: string;
}
interface ProjectTask {
id: string;
projectId: string;
executionId: string;
parentTaskId: string | null;
taskTitle: string;
ownerId: string;
ownerNickname?: string | null;
/** 所属执行的负责人 userId按钮可见度公式用 */
executionOwnerId: string;
/** 父任务负责人 userId一级任务为 null */
parentTaskOwnerId: 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;
assignees?: TaskAssigneeRef[] | null;
attachments?: AttachmentItem[] | null;
/** 已填报工时合计单位小时0.5 颗粒BigDecimal。逻辑删除的工时不计入。 */
totalSpentHours?: number | 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[];
}>;
/** 创建执行入参(含 ownerId + assigneeUserIds */
interface CreateProjectExecutionParams {
executionName: string;
executionType: string;
ownerId: string;
projectRequirementId: string | null;
plannedStartDate: string | null;
plannedEndDate: string | null;
executionDesc: string | null;
assigneeUserIds?: string[];
}
/** 执行创建/编辑弹层 emit 的统一 payload创建时含 ownerId + assigneeUserIds编辑时不含 */
type SaveProjectExecutionParams = CreateProjectExecutionParams;
/** 编辑执行入参(不含 ownerId / assigneeUserIds */
interface UpdateProjectExecutionParams {
executionName: string;
executionType: string;
projectRequirementId: string | null;
plannedStartDate: string | null;
plannedEndDate: string | null;
executionDesc: string | null;
}
interface ChangeExecutionOwnerParams {
newOwnerId: string;
reason: string | null;
}
interface ChangeExecutionStatusParams {
actionCode: ProjectExecutionActionCode;
reason: string | null;
}
interface CreateExecutionAssigneeParams {
userId: string;
}
interface InactiveExecutionAssigneeParams {
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[];
/** 编辑语义null 保留原值 / [] 清空 / [...] 整体替换 */
attachments?: AttachmentItem[] | null;
}
interface ChangeTaskStatusParams {
actionCode: ProjectTaskActionCode;
reason: string | null;
}
/** 任务工时记录 */
interface TaskWorklog {
id: string;
taskId: string;
userId: string;
userNickname: string | null;
/** 段起始日期YYYY-MM-DD单天=与 endDate 相等 */
startDate: string;
/** 段结束日期YYYY-MM-DD单天=与 startDate 相等 */
endDate: string;
/** 本次填报小时数BigDecimal0.5 颗粒,> 0 */
durationHours: number;
/** 本次填报进度0~100scale=2 */
progressRate: number;
workContent: string | null;
attachments?: AttachmentItem[] | null;
createTime: string;
updateTime: string;
}
type TaskWorklogSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
userId: string;
startDate: string;
endDate: string;
}
>;
interface SaveTaskWorklogParams {
/** 段起始日期YYYY-MM-DD */
startDate: string;
/** 段结束日期YYYY-MM-DD不得早于 startDate */
endDate: string;
/** 本次填报小时数,> 0 且 0.5 整数倍 */
durationHours: number;
/** 本次填报进度0~100scale=2必填 */
progressRate: number;
workContent?: string | null;
/** 编辑语义null 保留原值 / [] 清空 / [...] 替换 */
attachments?: AttachmentItem[] | 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 DeleteProjectExecutionParams {
/** 二次确认:必须与当前执行名称完全一致 */
executionName: string;
/** 删除确认口令:接受 "删除" 或 "DELETE" */
confirmText: string;
/** 删除原因,写入审计日志 */
reason: string;
}
/** 删除任务入参 */
interface DeleteProjectTaskParams {
/** 二次确认:必须与当前任务名称完全一致 */
taskName: string;
/** 删除确认口令:接受 "删除" 或 "DELETE" */
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;
}
/**
* 项目创建(含初始团队)原子接口参数
*
* 新增项目两步向导提交的载荷。经理成员也由前端聚合到 members 数组中。
*/
interface CreateProjectWithTeamParams {
project: SaveProjectParams;
members: CreateProjectMemberParams[];
}
// ========== 项目需求相关类型定义 ==========
/** 项目需求状态编码 */
type ProjectRequirementStatusCode =
| 'pending_confirm'
| 'pending_review'
| 'implementing'
| 'accepted'
| 'closed'
| 'rejected'
| 'cancelled';
/** 项目需求来源类型 */
type ProjectRequirementSourceType = 'manual' | 'work_order' | 'product_requirement';
/** 项目需求优先级 */
type ProjectRequirementPriority = 0 | 1 | 2 | 3;
/** 是否需要评审 */
type ProjectRequirementReviewRequired = 0 | 1;
interface ProjectRequirement {
/** 需求 ID */
id: string;
/** 所属项目 ID */
projectId: string;
/** 父需求 ID0 表示顶级需求 */
parentId: string;
/** 所属模块 ID */
moduleId: string;
/** 是否需要评审 */
reviewRequired: ProjectRequirementReviewRequired;
/** 需求标题 */
title: string;
/** 需求描述 */
description?: string | null;
/** 附件列表 */
attachments?: AttachmentItem[] | null;
/** 需求分类字典值 */
category: string;
/** 需求分类名称 */
categoryName?: string | null;
/** 需求来源类型 */
sourceType: ProjectRequirementSourceType;
/** 来源业务 ID */
sourceBizId?: string | null;
/** 优先级 */
priority: ProjectRequirementPriority;
/** 优先级名称 */
priorityName?: string | null;
/** 当前状态编码 */
statusCode: ProjectRequirementStatusCode;
/** 当前状态名称 */
statusName?: string | null;
/** 最近一次状态动作原因 */
lastStatusReason?: string | null;
/** 提出人用户 ID */
proposerId: string;
/** 提出人昵称 */
proposerNickname?: string | null;
/** 当前处理人用户 ID */
currentHandlerUserId?: string | null;
/** 当前处理人昵称 */
currentHandlerUserNickname?: string | null;
/** 所需工时 */
workHours: number;
/** 排序值 */
sort: number;
/** 创建时间 */
createTime: string;
/** 更新时间 */
updateTime: string;
/** 子需求列表 */
children?: ProjectRequirement[];
/** 是否终态 */
terminal?: boolean;
}
interface ProjectRequirementModule {
/** 模块 ID */
id: string;
/** 父模块 ID0 表示顶级 */
parentId: string;
/** 所属项目 ID */
projectId: string;
/** 模块名称 */
moduleName: string;
/** 模块说明 */
remark?: string | null;
/** 图标 */
icon?: string | null;
/** 排序值 */
sort: number;
/** 子模块列表 */
children?: ProjectRequirementModule[];
}
interface ProjectRequirementStatusDict {
/** 状态编码 */
statusCode: string;
/** 状态名称 */
statusName: string;
/** 排序值 */
sort: number;
/** 是否初始状态 */
initialFlag: boolean;
/** 是否终态 */
terminalFlag: boolean;
}
interface ProjectRequirementLifecycleAction {
actionCode: string;
actionName: string;
toStatusCode: string;
toStatusName: string;
needReason: boolean;
}
interface ProjectRequirementLifecycleInfo {
statusCode: ProjectRequirementStatusCode;
statusName?: string | null;
lastStatusReason?: string | null;
terminal: boolean;
allowEdit: boolean;
availableActions: ProjectRequirementLifecycleAction[];
}
/** 项目需求分页查询参数 */
type ProjectRequirementSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> &
Pick<
ProjectRequirement,
'moduleId' | 'parentId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceType'
> & {
projectId: string;
title: string;
}
>;
/** 创建项目需求参数 */
type SaveProjectRequirementParams = Pick<
ProjectRequirement,
| 'projectId'
| 'moduleId'
| 'reviewRequired'
| 'title'
| 'description'
| 'attachments'
| 'category'
| 'priority'
| 'proposerId'
| 'proposerNickname'
| 'currentHandlerUserId'
| 'currentHandlerUserNickname'
| 'workHours'
| 'sort'
>;
/** 更新项目需求参数 */
type UpdateProjectRequirementParams = { id: string } & SaveProjectRequirementParams;
/** 变更项目需求状态参数 */
interface ChangeProjectRequirementStatusParams {
id: string;
projectId: string;
actionCode: string;
reason?: string | null;
}
/** 关闭项目需求参数 */
interface CloseProjectRequirementParams {
id: string;
projectId: string;
reason: string;
}
/** 拆分项目需求参数 */
type SplitProjectRequirementParams = Pick<
ProjectRequirement,
| 'parentId'
| 'projectId'
| 'moduleId'
| 'reviewRequired'
| 'title'
| 'description'
| 'attachments'
| 'category'
| 'priority'
| 'proposerId'
| 'proposerNickname'
| 'currentHandlerUserId'
| 'currentHandlerUserNickname'
| 'workHours'
| 'sort'
>;
/** 删除项目需求参数 */
interface DeleteProjectRequirementParams {
id: string;
projectId: string;
}
/** 保存项目需求模块参数 */
interface SaveProjectRequirementModuleParams {
id?: string;
projectId: string;
parentId?: string | null;
moduleName: string;
remark?: string | null;
icon?: string | null;
sort?: number;
}
/** 删除项目需求模块参数 */
interface DeleteProjectRequirementModuleParams {
id?: string;
projectId: string;
}
}
}

View File

@@ -428,6 +428,12 @@ declare namespace Api {
id: string;
/** 用户昵称 */
nickname: string;
/** 用户账号 */
username?: string | null;
/** 部门 ID */
deptId?: string | null;
/** 部门名称 */
deptName?: string | null;
}
}
}

View File

@@ -10,10 +10,15 @@ declare module 'vue' {
export interface GlobalComponents {
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
BusinessAttachmentUploader: typeof import('./../components/custom/business-attachment-uploader.vue')['default']
BusinessDateRangePicker: typeof import('./../components/custom/business-date-range-picker.vue')['default']
BusinessFormDialog: typeof import('./../components/custom/business-form-dialog.vue')['default']
BusinessFormDrawer: typeof import('./../components/custom/business-form-drawer.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']
CountTo: typeof import('./../components/custom/count-to.vue')['default']
CustomIconSelect: typeof import('./../components/custom/custom-icon-select.vue')['default']
@@ -50,14 +55,17 @@ declare module 'vue' {
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
@@ -65,6 +73,7 @@ declare module 'vue' {
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSpace: typeof import('element-plus/es')['ElSpace']
ElStatistic: typeof import('element-plus/es')['ElStatistic']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
@@ -102,6 +111,7 @@ declare module 'vue' {
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
'IconIc:roundPlus': typeof import('~icons/ic/round-plus')['default']
'IconIconParkOutline:equalRatio': typeof import('~icons/icon-park-outline/equal-ratio')['default']
IconIcRoundChevronRight: typeof import('~icons/ic/round-chevron-right')['default']
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
IconIcRoundEdit: typeof import('~icons/ic/round-edit')['default']
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
@@ -114,6 +124,7 @@ declare module 'vue' {
IconLocalLogo: typeof import('~icons/local/logo')['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']
'IconMdi:paperclip': typeof import('~icons/mdi/paperclip')['default']
'IconMdi:printer': typeof import('~icons/mdi/printer')['default']
IconMdiAccountTieOutline: typeof import('~icons/mdi/account-tie-outline')['default']
IconMdiArrowDownThin: typeof import('~icons/mdi/arrow-down-thin')['default']
@@ -123,6 +134,9 @@ declare module 'vue' {
IconMdiChevronDoubleUp: typeof import('~icons/mdi/chevron-double-up')['default']
IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
IconMdiClose: typeof import('~icons/mdi/close')['default']
IconMdiCloseCircle: typeof import('~icons/mdi/close-circle')['default']
IconMdiCrown: typeof import('~icons/mdi/crown')['default']
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
@@ -153,6 +167,7 @@ declare module 'vue' {
SystemLogo: typeof import('./../components/common/system-logo.vue')['default']
TableColumnSetting: typeof import('./../components/advanced/table-column-setting.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']
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']

View File

@@ -65,6 +65,13 @@ declare module "@elegant-router/types" {
"product_list": "/product/list";
"product_requirement": "/product/requirement";
"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_dict": "/system/dict";
"system_menu": "/system/menu";
@@ -117,6 +124,7 @@ declare module "@elegant-router/types" {
| "login"
| "plugin"
| "product"
| "project"
| "system"
| "user-center"
>;
@@ -172,6 +180,11 @@ declare module "@elegant-router/types" {
| "product_list"
| "product_requirement"
| "product_setting"
| "project_list"
| "project_project_execution"
| "project_project_overview"
| "project_project_requirement"
| "project_project_setting"
| "system_dict"
| "system_menu"
| "system_post"

18
src/typings/wangeditor.d.ts vendored Normal file
View 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
View 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']
});
}

View File

@@ -1,47 +1,19 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import WangEditor from 'wangeditor';
import { ref } from 'vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
defineOptions({ name: 'QuillPage' });
const editor = ref<WangEditor>();
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();
});
const value = ref('<p>hello&nbsp;<strong>wangEditor v5</strong></p>');
</script>
<template>
<div class="h-full">
<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>
<GithubLink link="https://github.com/wangeditor-team/wangEditor" />
<GithubLink link="https://github.com/wangeditor-next/wangEditor-next" />
</template>
</ElCard>
</div>
</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>

View File

@@ -247,7 +247,12 @@ watch([() => visible.value, () => props.productId], ([currentVisible, productId]
</div>
<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.reasonText">原因{{ item.reasonText }}</span>
</p>
@@ -497,6 +502,11 @@ watch([() => visible.value, () => props.productId], ([currentVisible, productId]
color: var(--el-text-color-primary);
}
.product-activity-dialog__subject {
color: var(--el-text-color-primary);
font-weight: 700;
}
.product-activity-dialog__footer-inner {
display: flex;
align-items: center;

View File

@@ -112,7 +112,12 @@ watch(
</div>
<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.reasonText">原因{{ item.reasonText }}</span>
</p>
@@ -262,6 +267,11 @@ watch(
color: rgb(15 23 42 / 98%);
}
.product-activity-panel__subject {
color: rgb(15 23 42 / 98%);
font-weight: 700;
}
@media (width <= 768px) {
.product-activity-panel__body {
min-height: auto;

View File

@@ -17,13 +17,20 @@ export type ProductActivityFilterType = 'all' | Api.Product.ProductActivityType;
export type ProductActivityTone = 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
export interface ProductActivityTextPart {
text: string;
strong?: boolean;
}
export interface ProductActivityDisplayItem extends Api.Product.ProductActivityTimelineItem {
tagLabel: string;
timeText: string;
actionText: string;
displaySummary: string;
compactText: string;
compactTextParts: ProductActivityTextPart[];
operatorText: string;
subjectText: string;
reasonText: string;
statusTransition: string;
tone: ProductActivityTone;
@@ -250,6 +257,10 @@ function isGenericActivitySummary(summaryText: string, actionText: string) {
return summaryText === actionText || summaryText === actionText.replace('执行了', '执行了');
}
function isMemberActivityAction(actionType: Api.Product.ProductActivityActionType) {
return actionType === 'add_member' || actionType === 'remove_member' || actionType === 'update_member';
}
function buildMemberChangeSummary(
item: Api.Product.ProductActivityTimelineItem,
detailsRecord: ActivityDetailRecord | null,
@@ -263,9 +274,10 @@ function buildMemberChangeSummary(
}
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(
@@ -279,8 +291,8 @@ function buildMemberUpdateSummary(
const roleText = roleTransitionText ? `,角色:${roleTransitionText}` : '';
return operatorText === '--'
? `调整成员${memberText}${roleText}`
: `${operatorText}调整成员${memberText}${roleText}`;
? `执行了【${item.actionName}${memberText}${roleText}`
: `${operatorText}执行了【${item.actionName}${memberText}${roleText}`;
}
function buildManagerChangeSummary(detailsRecord: ActivityDetailRecord | null, operatorText: string) {
@@ -309,15 +321,11 @@ function buildManagerChangeSummary(detailsRecord: ActivityDetailRecord | null, o
function resolveDetailedSummary(
item: Api.Product.ProductActivityTimelineItem,
operatorText: string,
actionText: string
detailsRecord: ActivityDetailRecord | null,
texts: { operatorText: string; actionText: string }
) {
const { operatorText, actionText } = texts;
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') {
return buildMemberChangeSummary(item, detailsRecord, operatorText) || summaryText || actionText;
@@ -327,6 +335,10 @@ function resolveDetailedSummary(
return buildMemberUpdateSummary(item, detailsRecord, operatorText);
}
if (!isGenericActivitySummary(summaryText, actionText)) {
return summaryText;
}
if (item.actionType === 'change_manager') {
return buildManagerChangeSummary(detailsRecord, operatorText) || summaryText || actionText;
}
@@ -334,13 +346,31 @@ function resolveDetailedSummary(
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(
item: Api.Product.ProductActivityTimelineItem
): ProductActivityDisplayItem {
const operatorText = item.operatorName?.trim() || '--';
const actionText =
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;
return {
@@ -350,7 +380,9 @@ export function buildProductActivityDisplayItem(
actionText,
displaySummary,
compactText,
compactTextParts: buildProductActivityTextParts(compactText, subjectText),
operatorText,
subjectText,
reasonText: item.reason?.trim() || '',
statusTransition:
item.type === 'status' && item.fromStatus && item.toStatus

View File

@@ -6,12 +6,11 @@ import dayjs from 'dayjs';
import { CircleCheckFilled, DeleteFilled, FolderOpened, VideoPause } from '@element-plus/icons-vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
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 { useRouterPush } from '@/hooks/common/router';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
import { getProductStatusLabel, getProductStatusTagType, isProductEditable } from '../shared/product-master-data';
import { getProductStatusLabel, getProductStatusTagType } from '../shared/product-master-data';
import ProductOperateDialog from './modules/product-operate-dialog.vue';
import ProductSearch from './modules/product-search.vue';
@@ -27,7 +26,6 @@ interface StatusNavMeta {
type ProductPageResponse = Awaited<ReturnType<typeof fetchGetProductPage>>;
const PRODUCT_OPTION_PAGE_SIZE = 200;
const PRODUCT_ENTRY_ROUTE_PATH = '/product/list';
function getInitSearchParams(): Api.Product.ProductSearchParams {
@@ -72,59 +70,6 @@ function formatDateTime(value?: string | null) {
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[] = [
{
key: 'active',
@@ -166,15 +111,13 @@ const { routerPush } = useRouterPush();
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,
archived: 0,
paused: 0,
abandoned: 0
});
const recentUpdatedCount = ref(0);
const managerLabelMap = computed(() => {
return new Map(managerUserOptions.value.map(item => [String(item.id), item.nickname]));
});
@@ -182,7 +125,7 @@ const managerLabelMap = computed(() => {
const statusItems = computed(() =>
statusNavMetas.map(item => ({
...item,
count: statusCounts.value[item.key]
count: statusCounts.value[item.key] ?? 0
}))
);
@@ -194,7 +137,7 @@ const overviewMetrics = computed(() => [
},
{
label: '当前启用',
value: statusCounts.value.active,
value: statusCounts.value.active ?? 0,
hint: '正在持续服务和维护的产品'
},
{
@@ -203,9 +146,9 @@ const overviewMetrics = computed(() => [
hint: '已加载的方向字典项数量'
},
{
label: '30天内更新',
value: recentUpdatedCount.value,
hint: '最近 30 天内发生过更新的产品'
label: '废弃产品',
value: statusCounts.value.abandoned ?? 0,
hint: '已明确停止建设的产品'
}
]);
@@ -291,65 +234,34 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
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: !isProductEditable(row.statusCode),
onClick: () => openEdit(row)
}
]}
/>
)
}
]
],
immediate: false
});
async function loadManagerOptions() {
const [allProducts, userSimpleResult] = await Promise.all([fetchAllProducts(), fetchGetUserSimpleList()]);
const { error, data: userList } = await fetchGetUserSimpleList();
const userSimpleList =
userSimpleResult.error || !userSimpleResult.data ? [] : sortManagerOptions(userSimpleResult.data);
managerUserOptions.value = userSimpleList;
if (!allProducts) {
if (error || !userList) {
managerUserOptions.value = [];
managerFilterOptions.value = [];
return;
}
managerFilterOptions.value = createManagerOptions(allProducts, userSimpleList);
const userSimpleList = sortManagerOptions(userList);
managerUserOptions.value = userSimpleList;
managerFilterOptions.value = userSimpleList;
}
async function loadOverviewData() {
const end = dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss');
const start = dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss');
const { error, data: overviewSummary } = await fetchGetProductOverviewSummary();
const [activeTotal, archivedTotal, pausedTotal, abandonedTotal, recentTotal] = await Promise.all([
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'active' }),
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'archived' }),
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'paused' }),
fetchProductTotal({ pageNo: 1, pageSize: 1, statusCode: 'abandoned' }),
fetchProductTotal({ pageNo: 1, pageSize: 1, updateTime: [start, end] })
]);
if (error || !overviewSummary) {
statusCounts.value = {};
return;
}
statusCounts.value = {
active: activeTotal,
archived: archivedTotal,
paused: pausedTotal,
abandoned: abandonedTotal
};
recentUpdatedCount.value = recentTotal;
statusCounts.value = overviewSummary.statusCounts || {};
}
async function reloadProductTable(page = searchParams.pageNo ?? 1) {
@@ -384,11 +296,6 @@ function openCreate() {
operateVisible.value = true;
}
function openEdit(row: Api.Product.Product) {
editingRow.value = row;
operateVisible.value = true;
}
async function enterProductContext(row: Api.Product.Product) {
await routerPush({
path: PRODUCT_ENTRY_ROUTE_PATH,

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue';
defineOptions({ name: 'ProductCreateBaseForm' });
export interface ProductCreateBaseForm {
code: string;
name: string;
directionCode: string;
managerUserId: string | null;
description: string;
}
interface Props {
managerUserOptions: Api.SystemManage.UserSimple[];
}
defineProps<Props>();
const model = defineModel<ProductCreateBaseForm>('modelValue', { required: true });
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const rules = computed(
() =>
({
name: [createRequiredRule('请输入产品名称')],
directionCode: [createRequiredRule('请选择产品方向')],
managerUserId: [createRequiredRule('请选择产品经理')]
}) satisfies Record<string, App.Global.FormRule[]>
);
async function runValidate(): Promise<boolean> {
try {
await validate();
return true;
} catch {
return false;
}
}
defineExpose({ validate: runValidate });
</script>
<template>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="产品名称" prop="name">
<ElInput v-model="model.name" clearable maxlength="128" placeholder="请输入产品名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="产品编码" prop="code">
<ElInput v-model="model.code" clearable placeholder="不填则由后端自动生成" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="产品方向" prop="directionCode">
<DictSelect
v-model="model.directionCode"
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
filterable
placeholder="请选择产品方向"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="产品经理" prop="managerUserId">
<BusinessUserSelect
v-model="model.managerUserId"
:options="managerUserOptions"
placeholder="请选择产品经理"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="产品描述" prop="description">
<ElInput
v-model="model.description"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入产品描述"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</template>

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue';
import { PRODUCT_MANAGER_ROLE_CODE } from '@/constants/business';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
defineOptions({ name: 'ProductCreateTeamMemberDialog' });
type OperateMode = 'create' | 'edit';
interface DraftMemberInput {
userId: string;
roleId: string;
remark: string;
}
interface Props {
mode: OperateMode;
initial: DraftMemberInput | null;
userOptions: Api.SystemManage.UserSimple[];
roleOptions: Api.SystemManage.RoleSimple[];
/** 已使用且不可选的 userId编辑模式应当排除当前行自身 */
disabledUserIds?: readonly string[];
}
interface Emits {
(e: 'submit', payload: DraftMemberInput): void;
}
const props = withDefaults(defineProps<Props>(), {
disabledUserIds: () => []
});
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', { default: false });
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const model = reactive<DraftMemberInput>({
userId: '',
roleId: '',
remark: ''
});
const dialogTitle = computed(() => (props.mode === 'create' ? '新增团队成员' : '调整成员角色'));
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
const rules = computed(
() =>
({
userId: props.mode === 'create' ? [createRequiredRule('请选择成员用户')] : [],
roleId: [createRequiredRule('请选择角色')]
}) satisfies Record<string, App.Global.FormRule[]>
);
function isManagerRole(role: Api.SystemManage.RoleSimple) {
return role.code === PRODUCT_MANAGER_ROLE_CODE;
}
async function handleConfirm() {
await validate();
emit('submit', {
userId: model.userId,
roleId: model.roleId,
remark: model.remark.trim()
});
}
watch(visible, async value => {
if (!value) {
return;
}
model.userId = props.initial?.userId || '';
model.roleId = props.initial?.roleId || '';
model.remark = props.initial?.remark || '';
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">
<ElRow :gutter="16">
<ElCol :span="24">
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
<BusinessUserSelect
v-model="model.userId"
:options="userOptions"
:disabled-user-ids="disabledUserIds"
disabled-label="已添加"
placeholder="请选择成员用户"
/>
</ElFormItem>
<ElFormItem v-else label="成员用户">
<ElInput
:model-value="userLabelMap.get(String(model.userId)) || ''"
readonly
class="product-create-team-member-dialog__readonly-input"
placeholder="未获取到成员用户"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="目标角色" prop="roleId">
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
<ElOption
v-for="role in roleOptions"
:key="role.id"
:label="role.name"
:value="role.id"
:disabled="isManagerRole(role)"
>
<span>{{ role.name }}</span>
<span v-if="isManagerRole(role)" class="product-create-team-member-dialog__role-hint">
已由第 1 步指定
</span>
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="备注">
<ElInput
v-model="model.remark"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入备注"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.product-create-team-member-dialog__role-hint {
margin-left: 8px;
color: rgb(148 163 184 / 96%);
font-size: 12px;
}
:deep(.product-create-team-member-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(.product-create-team-member-dialog__readonly-input .el-input__wrapper:hover),
:deep(.product-create-team-member-dialog__readonly-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.product-create-team-member-dialog__readonly-input .el-input__inner) {
color: rgb(51 65 85 / 96%);
-webkit-text-fill-color: rgb(51 65 85 / 96%);
cursor: default;
}
</style>

View File

@@ -0,0 +1,298 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { PRODUCT_MANAGER_ROLE_CODE } from '@/constants/business';
import { fetchGetRoleSimpleList } from '@/service/api';
import { getProductTeamTableHeight } from '../../setting/shared';
import ProductCreateTeamMemberDialog from './product-create-team-member-dialog.vue';
import type { ProductCreateBaseForm } from './product-create-base-form.vue';
defineOptions({ name: 'ProductCreateTeamStep' });
interface DraftMember {
/** 客户端临时主键,仅用于 v-for 稳定 */
key: string;
userId: string;
roleId: string;
remark: string;
/** true 表示由产品经理自动派生的锁定行 */
locked: boolean;
}
interface Props {
baseInfo: ProductCreateBaseForm;
userOptions: Api.SystemManage.UserSimple[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update:members', members: Api.Product.CreateProductMemberParams[]): void;
}>();
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
const roleLoading = ref(false);
const managerRoleError = ref('');
const members = ref<DraftMember[]>([]);
const memberDialogVisible = ref(false);
const memberDialogMode = ref<'create' | 'edit'>('create');
const editingKey = ref<string | null>(null);
const teamTableHeight = getProductTeamTableHeight(5);
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
const managerRole = computed(() => roleOptions.value.find(item => item.code === PRODUCT_MANAGER_ROLE_CODE) ?? null);
// 弹框传入的禁选用户列表:新增时排除所有已选;编辑时排除自身以外的已选
const dialogDisabledUserIds = computed(() => {
return members.value
.filter(item => !editingKey.value || item.key !== editingKey.value)
.map(item => item.userId)
.filter(Boolean);
});
const dialogInitial = computed(() => {
if (memberDialogMode.value === 'create' || !editingKey.value) {
return null;
}
const target = members.value.find(item => item.key === editingKey.value);
if (!target) {
return null;
}
return { userId: target.userId, roleId: target.roleId, remark: target.remark };
});
function getUserNickname(userId: string) {
return userLabelMap.value.get(String(userId)) || userId;
}
function getRoleName(roleId: string) {
return roleOptions.value.find(item => item.id === roleId)?.name || '--';
}
function generateKey() {
return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
async function loadRoles() {
roleLoading.value = true;
managerRoleError.value = '';
const { data } = await fetchGetRoleSimpleList({ scopeType: 'object', objectType: 'product' });
roleLoading.value = false;
roleOptions.value = data ?? [];
if (!managerRole.value) {
managerRoleError.value = '未找到产品经理角色,请联系管理员';
return;
}
refreshManagerRow();
}
function refreshManagerRow() {
const managerUserId = props.baseInfo.managerUserId;
if (!managerUserId || !managerRole.value) {
members.value = members.value.filter(item => !item.locked);
emitMembers();
return;
}
const lockedIndex = members.value.findIndex(item => item.locked);
const lockedRow: DraftMember = {
key: lockedIndex >= 0 ? members.value[lockedIndex].key : generateKey(),
userId: managerUserId,
roleId: managerRole.value.id,
remark: lockedIndex >= 0 ? members.value[lockedIndex].remark : '',
locked: true
};
if (lockedIndex >= 0) {
members.value[lockedIndex] = lockedRow;
} else {
members.value = [lockedRow, ...members.value];
}
emitMembers();
}
function openCreate() {
memberDialogMode.value = 'create';
editingKey.value = null;
memberDialogVisible.value = true;
}
function openEdit(row: DraftMember) {
memberDialogMode.value = 'edit';
editingKey.value = row.key;
memberDialogVisible.value = true;
}
function removeMember(key: string) {
members.value = members.value.filter(item => item.key !== key);
emitMembers();
}
function handleMemberSubmit(payload: { userId: string; roleId: string; remark: string }) {
if (memberDialogMode.value === 'create') {
members.value.push({
key: generateKey(),
userId: payload.userId,
roleId: payload.roleId,
remark: payload.remark,
locked: false
});
} else if (editingKey.value) {
const idx = members.value.findIndex(item => item.key === editingKey.value);
if (idx >= 0) {
members.value[idx] = {
...members.value[idx],
roleId: payload.roleId,
remark: payload.remark
};
}
}
memberDialogVisible.value = false;
emitMembers();
}
function emitMembers() {
emit(
'update:members',
members.value.map(item => ({
userId: item.userId,
roleId: item.roleId,
remark: item.remark.trim() || null,
previousManagerUserId: null,
previousManagerRoleId: null
}))
);
}
async function runValidate(): Promise<boolean> {
if (managerRoleError.value) {
window.$message?.error(managerRoleError.value);
return false;
}
for (const item of members.value) {
if (!item.userId || !item.roleId) {
window.$message?.error('请补全所有成员的用户和角色');
return false;
}
}
const userIdSet = new Set<string>();
for (const item of members.value) {
if (userIdSet.has(item.userId)) {
window.$message?.error(`成员「${getUserNickname(item.userId)}」重复,请检查`);
return false;
}
userIdSet.add(item.userId);
}
return true;
}
onMounted(loadRoles);
watch(
() => props.baseInfo.managerUserId,
() => {
if (!managerRoleError.value && managerRole.value) {
refreshManagerRow();
}
}
);
defineExpose({ validate: runValidate });
</script>
<template>
<div v-loading="roleLoading" class="team-step">
<div class="team-step__toolbar">
<ElButton type="primary" plain :disabled="Boolean(managerRoleError)" @click="openCreate">新增成员</ElButton>
</div>
<ElAlert
v-if="managerRoleError"
:title="managerRoleError"
type="error"
:closable="false"
show-icon
class="team-step__alert"
/>
<ElTable :data="members" :height="teamTableHeight" border row-key="key" empty-text="点击右上角新增成员添加">
<ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn label="成员姓名" min-width="120">
<template #default="{ row }">
{{ getUserNickname(row.userId) }}
</template>
</ElTableColumn>
<ElTableColumn label="当前角色" min-width="140">
<template #default="{ row }">
{{ getRoleName(row.roleId) }}
</template>
</ElTableColumn>
<ElTableColumn label="备注" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
{{ row.remark || '--' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<div class="team-step__actions">
<ElButton link type="primary" :disabled="row.locked" @click="openEdit(row)">编辑</ElButton>
<ElButton link type="danger" :disabled="row.locked" @click="removeMember(row.key)">移除</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
<ProductCreateTeamMemberDialog
v-model:visible="memberDialogVisible"
:mode="memberDialogMode"
:initial="dialogInitial"
:user-options="userOptions"
:role-options="roleOptions"
:disabled-user-ids="dialogDisabledUserIds"
@submit="handleMemberSubmit"
/>
</div>
</template>
<style scoped>
.team-step {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.team-step__toolbar {
display: flex;
justify-content: flex-end;
}
.team-step__alert {
margin: 0;
}
.team-step__actions {
display: inline-flex;
align-items: center;
gap: 12px;
}
</style>

View File

@@ -1,10 +1,14 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { fetchCreateProduct, fetchGetProduct, fetchUpdateProduct } from '@/service/api';
import { fetchCreateProductWithTeam, fetchGetProduct, fetchUpdateProduct } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import ProductCreateBaseForm, {
type ProductCreateBaseForm as ProductCreateBaseFormModel
} from './product-create-base-form.vue';
import ProductCreateTeamStep from './product-create-team-step.vue';
defineOptions({ name: 'ProductOperateDialog' });
@@ -21,11 +25,10 @@ interface Emits {
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const visible = defineModel<boolean>('visible', { default: false });
interface Model {
// === 编辑模式(单步) ===
interface EditModel {
code: string;
directionCode: string;
name: string;
@@ -33,17 +36,26 @@ interface Model {
description: string;
}
const { formRef, validate } = useForm();
const { formRef: editFormRef, validate: editValidate } = useForm();
const { createRequiredRule } = useFormRules();
const editModel = ref<EditModel>(createEditModel());
const editLoading = ref(false);
const submitting = ref(false);
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());
const editRules = {
directionCode: [createRequiredRule('请选择产品方向')],
name: [createRequiredRule('请输入产品名称')],
managerUserId: [createRequiredRule('请选择产品经理')]
} satisfies Record<string, App.Global.FormRule[]>;
const managerLabelMap = computed(() => new Map(props.managerUserOptions.map(item => [String(item.id), item.nickname])));
const managerDisplayName = computed(() => {
const managerUserId = model.value.managerUserId;
const managerUserId = editModel.value.managerUserId;
if (!managerUserId) {
return '';
@@ -52,20 +64,8 @@ const managerDisplayName = computed(() => {
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
});
const rules = {
directionCode: [createRequiredRule('请选择产品方向')],
name: [createRequiredRule('请输入产品名称')],
managerUserId: [createRequiredRule('请选择产品经理')]
} satisfies Record<string, App.Global.FormRule[]>;
function createDefaultModel(): Model {
return {
code: '',
directionCode: '',
name: '',
managerUserId: null,
description: ''
};
function createEditModel(): EditModel {
return { code: '', directionCode: '', name: '', managerUserId: null, description: '' };
}
function getNullableText(value?: string | null) {
@@ -76,80 +76,132 @@ function closeDialog() {
visible.value = false;
}
async function handleSubmit() {
await validate();
async function handleEditSubmit() {
await editValidate();
const managerUserId = model.value.managerUserId;
const managerUserId = editModel.value.managerUserId;
if (!managerUserId) {
if (!managerUserId || !props.rowData?.id) {
return;
}
const payload: Api.Product.SaveProductParams = {
code: getNullableText(model.value.code),
directionCode: model.value.directionCode,
name: model.value.name.trim(),
// Long ID 必须以 string 提交,禁止再转成 number。
managerUserId,
description: getNullableText(model.value.description)
};
submitting.value = true;
if (isEditMode.value && props.rowData?.id) {
const result = await fetchUpdateProduct({
id: props.rowData.id,
...payload
});
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('产品编辑成功');
closeDialog();
emit('submitted', props.rowData.id);
return;
}
const result = await fetchCreateProduct(payload);
const { error } = await fetchUpdateProduct({
id: props.rowData.id,
code: getNullableText(editModel.value.code),
directionCode: editModel.value.directionCode,
name: editModel.value.name.trim(),
managerUserId,
description: getNullableText(editModel.value.description)
});
submitting.value = false;
if (result.error) {
if (error) {
return;
}
window.$message?.success('产品编辑成功');
closeDialog();
emit('submitted', props.rowData.id);
}
// === 新增模式(两步向导) ===
const baseFormRef = ref<InstanceType<typeof ProductCreateBaseForm> | null>(null);
const teamStepRef = ref<InstanceType<typeof ProductCreateTeamStep> | null>(null);
const currentStep = ref<1 | 2>(1);
const createBaseModel = ref<ProductCreateBaseFormModel>(createBaseInfo());
const draftMembers = ref<Api.Product.CreateProductMemberParams[]>([]);
function createBaseInfo(): ProductCreateBaseFormModel {
return { code: '', name: '', directionCode: '', managerUserId: null, description: '' };
}
async function goNext() {
const valid = await baseFormRef.value?.validate();
if (!valid) {
return;
}
currentStep.value = 2;
}
function goPrev() {
currentStep.value = 1;
}
async function handleCreateSubmit() {
const baseValid = await baseFormRef.value?.validate();
if (!baseValid) {
currentStep.value = 1;
return;
}
const teamValid = await teamStepRef.value?.validate();
if (!teamValid) {
return;
}
submitting.value = true;
const payload: Api.Product.CreateProductWithTeamParams = {
product: {
code: getNullableText(createBaseModel.value.code),
name: createBaseModel.value.name.trim(),
directionCode: createBaseModel.value.directionCode,
managerUserId: createBaseModel.value.managerUserId as string,
description: getNullableText(createBaseModel.value.description)
},
members: draftMembers.value
};
const { error, data } = await fetchCreateProductWithTeam(payload);
submitting.value = false;
if (error) {
return;
}
window.$message?.success('产品新增成功');
closeDialog();
emit('submitted', result.data);
emit('submitted', data);
}
// === 公共:弹框可见性变化时重置 / 加载数据 ===
watch(visible, async value => {
if (!value) {
return;
}
submitting.value = false;
currentStep.value = 1;
if (!isEditMode.value || !props.rowData?.id) {
model.value = createDefaultModel();
editModel.value = createEditModel();
createBaseModel.value = createBaseInfo();
draftMembers.value = [];
await nextTick();
formRef.value?.clearValidate();
editFormRef.value?.clearValidate();
return;
}
loading.value = true;
editLoading.value = true;
const { error, data } = await fetchGetProduct(props.rowData.id);
loading.value = false;
editLoading.value = false;
if (error || !data) {
return;
}
model.value = {
editModel.value = {
code: data.code || '',
directionCode: data.directionCode || '',
name: data.name || '',
@@ -158,51 +210,50 @@ watch(visible, async value => {
};
await nextTick();
formRef.value?.clearValidate();
editFormRef.value?.clearValidate();
});
</script>
<template>
<!-- 编辑模式单步表单与改造前一致 -->
<BusinessFormDialog
v-if="isEditMode"
v-model="visible"
:title="dialogTitle"
preset="lg"
:loading="loading"
preset="sm"
:loading="editLoading"
:confirm-loading="submitting"
@confirm="handleSubmit"
@confirm="handleEditSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElForm ref="editFormRef" :model="editModel" :rules="editRules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem v-if="isEditMode" label="产品编码" prop="code">
<ElCol :span="24">
<ElFormItem label="产品编码" prop="code">
<ElInput
:model-value="model.code"
:model-value="editModel.code"
readonly
class="product-operate-dialog__readonly-input"
placeholder="未获取到产品编码"
/>
</ElFormItem>
<ElFormItem v-else label="产品编码" prop="code">
<ElInput v-model="model.code" clearable placeholder="不填则由后端自动生成" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElCol :span="24">
<ElFormItem label="产品名称" prop="name">
<ElInput v-model="model.name" clearable maxlength="128" placeholder="请输入产品名称" />
<ElInput v-model="editModel.name" clearable maxlength="128" placeholder="请输入产品名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElCol :span="24">
<ElFormItem label="产品方向" prop="directionCode">
<DictSelect
v-model="model.directionCode"
v-model="editModel.directionCode"
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
filterable
placeholder="请选择产品方向"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem v-if="isEditMode">
<ElCol :span="24">
<ElFormItem>
<template #label>
<span class="business-form-label-with-tip">
<ElTooltip
@@ -224,16 +275,11 @@ watch(visible, async value => {
placeholder="未配置产品经理"
/>
</ElFormItem>
<ElFormItem v-else label="产品经理" prop="managerUserId">
<ElSelect v-model="model.managerUserId" clearable filterable placeholder="请选择产品经理">
<ElOption v-for="item in managerUserOptions" :key="item.id" :label="item.nickname" :value="item.id" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="产品描述" prop="description">
<ElInput
v-model="model.description"
v-model="editModel.description"
type="textarea"
:rows="4"
maxlength="500"
@@ -245,6 +291,63 @@ watch(visible, async value => {
</ElRow>
</ElForm>
</BusinessFormDialog>
<!-- 新增模式两步向导复合内容特例自定义 ElDialog 880px -->
<ElDialog
v-else
v-model="visible"
class="product-create-dialog"
:title="dialogTitle"
:close-on-click-modal="false"
destroy-on-close
align-center
width="760px"
>
<div class="product-create-dialog__stepbar">
<div class="product-create-dialog__step" :class="{ 'is-active': currentStep === 1, 'is-done': currentStep > 1 }">
<span class="product-create-dialog__step-index">1</span>
<span class="product-create-dialog__step-text">
<strong>基础资料</strong>
<small>定义产品身份和负责人</small>
</span>
</div>
<div class="product-create-dialog__step" :class="{ 'is-active': currentStep === 2 }">
<span class="product-create-dialog__step-index">2</span>
<span class="product-create-dialog__step-text">
<strong>初始化团队</strong>
<small>配置对象域成员角色</small>
</span>
</div>
</div>
<div class="product-create-dialog__body">
<div v-show="currentStep === 1" class="product-create-dialog__panel">
<ProductCreateBaseForm ref="baseFormRef" v-model="createBaseModel" :manager-user-options="managerUserOptions" />
</div>
<div v-show="currentStep === 2" class="product-create-dialog__panel">
<ProductCreateTeamStep
ref="teamStepRef"
:base-info="createBaseModel"
:user-options="managerUserOptions"
@update:members="draftMembers = $event"
/>
</div>
</div>
<template #footer>
<div class="product-create-dialog__footer">
<span class="product-create-dialog__footer-meta"> {{ currentStep }} 2 </span>
<ElSpace :size="10">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton v-if="currentStep === 2" @click="goPrev">上一步</ElButton>
<ElButton v-if="currentStep === 1" type="primary" @click="goNext">下一步</ElButton>
<ElButton v-if="currentStep === 2" type="primary" :loading="submitting" @click="handleCreateSubmit">
确定
</ElButton>
</ElSpace>
</div>
</template>
</ElDialog>
</template>
<style scoped>
@@ -264,4 +367,86 @@ watch(visible, async value => {
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
.product-create-dialog :deep(.el-dialog__body) {
display: flex;
flex-direction: column;
padding: 0;
}
.product-create-dialog__stepbar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 14px 24px;
border-bottom: 1px solid rgb(229 233 242 / 96%);
background: #fbfcfe;
}
.product-create-dialog__step {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.product-create-dialog__step-index {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 28px;
height: 28px;
border: 1px solid rgb(215 222 235 / 96%);
border-radius: 999px;
background: #fff;
color: rgb(119 129 150 / 96%);
font-size: 13px;
font-weight: 650;
}
.product-create-dialog__step.is-active .product-create-dialog__step-index,
.product-create-dialog__step.is-done .product-create-dialog__step-index {
border-color: var(--el-color-primary);
background: var(--el-color-primary);
color: #fff;
}
.product-create-dialog__step-text {
min-width: 0;
}
.product-create-dialog__step-text strong {
display: block;
font-size: 14px;
font-weight: 650;
}
.product-create-dialog__step-text small {
display: block;
margin-top: 2px;
color: rgb(119 129 150 / 96%);
font-size: 12px;
}
.product-create-dialog__body {
min-height: 0;
max-height: min(560px, calc(100vh - 240px));
overflow: auto;
}
.product-create-dialog__panel {
padding: 24px;
}
.product-create-dialog__footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.product-create-dialog__footer-meta {
color: rgb(119 129 150 / 96%);
font-size: 13px;
}
</style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import DictSelect from '@/components/custom/dict-select.vue';
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
defineOptions({ name: 'ProductSearch' });
@@ -9,7 +9,7 @@ interface Props {
managerOptions: Api.SystemManage.UserSimple[];
}
defineProps<Props>();
const props = defineProps<Props>();
interface Emits {
(e: 'reset'): void;
@@ -20,6 +20,32 @@ const emit = defineEmits<Emits>();
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() {
emit('reset');
}
@@ -30,30 +56,7 @@ function search() {
</script>
<template>
<TableSearchPanel :model="model" :action-col-lg="6" @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>
<TableSearchFields v-model="model" :fields="fields" :columns="3" @reset="reset" @search="search" />
</template>
<style scoped></style>

View File

@@ -1,29 +1,36 @@
<script setup lang="tsx">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { computed, markRaw, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { TableInstance } from 'element-plus';
import { ElButton, ElTag } from 'element-plus';
import { ElButton, ElTag, ElTooltip } from 'element-plus';
import dayjs from 'dayjs';
import {
RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE,
RDMS_REQ_CATEGORY_DICT_CODE,
RDMS_REQ_PRIORITY_DICT_CODE
RDMS_REQ_PRIORITY_DICT_CODE,
RDMS_REQ_SOURCE_TYPE_DICT_CODE
} from '@/constants/dict';
import {
fetchChangeRequirementStatus,
fetchDeleteRequirement,
fetchGetProductMembers,
fetchGetProjectListByProductId,
fetchGetRequirementAllowedTransitions,
fetchGetRequirementStatusDict,
fetchGetRequirementTerminalStatusDict,
fetchGetRequirementTree
fetchGetRequirementTree,
fetchHasDispatchedProjectRequirement
} from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
import { useDict } from '@/hooks/business/dict';
import DictTag from '@/components/custom/dict-tag.vue';
import DictText from '@/components/custom/dict-text.vue';
import { useCurrentProduct } from '../shared/use-current-product';
import {
ACTION_ICON_MAP,
ACTION_TYPE_MAP,
type RequirementStatusActionCode,
getRequirementActionDisplayName,
getRequirementActionTagType,
getRequirementStatusTagType,
isRequirementActionNeedProject,
isRequirementActionNeedReviewChoice,
@@ -35,14 +42,23 @@ import RequirementCreateDialog from './modules/requirement-create-dialog.vue';
import RequirementDetailDialog from './modules/requirement-detail-dialog.vue';
import RequirementSplitDialog from './modules/requirement-split-dialog.vue';
import RequirementActionDialog from './modules/requirement-action-dialog.vue';
import IconMdiSync from '~icons/mdi/sync';
defineOptions({ name: 'ProductRequirement' });
const router = useRouter();
const { currentObjectId } = useCurrentProduct();
const { hasObjectAuth } = useAuth();
const statusOptions = ref<Array<{ label: string; value: string }>>([]);
const terminalStatusOptions = ref<string[]>([]);
const projectOptions = ref<Api.Project.Project[]>([]);
const projectNameMap = computed(() => {
return new Map(projectOptions.value.map(item => [item.id, item.projectName]));
});
const { hasValue: canDeleteStatusHasValue } = useDict(RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE);
async function loadStatusOptions() {
const { error, data } = await fetchGetRequirementStatusDict();
@@ -69,6 +85,22 @@ async function loadTerminalStatusOptions() {
terminalStatusOptions.value = data.map(item => item.statusCode);
}
async function loadProjectOptions() {
if (!currentObjectId.value) {
projectOptions.value = [];
return;
}
const { error, data } = await fetchGetProjectListByProductId(currentObjectId.value);
if (error || !data) {
projectOptions.value = [];
return;
}
projectOptions.value = data;
}
function getStatusLabel(statusCode: string) {
const item = statusOptions.value.find(opt => opt.value === statusCode);
return item ? item.label : statusCode;
@@ -80,6 +112,7 @@ const priorityTagTypeMap: Record<number, UI.ThemeColor> = {
2: 'warning',
3: 'danger'
};
const hasDispatchedMap = ref<Record<string, boolean>>({});
function formatDateTime(value?: string | null) {
if (!value) {
@@ -90,20 +123,22 @@ function formatDateTime(value?: string | null) {
}
function isTerminalStatus(statusCode: string) {
return terminalStatusOptions.value.some(option => option === statusCode);
return terminalStatusOptions.value.includes(statusCode);
}
function canSplitRequirement(row: Api.Product.Requirement) {
if (row.implementProjectId) {
return false;
}
const hasDispatched = hasDispatchedMap.value[row.id];
if (hasDispatched) {
return false;
}
return row.statusCode === 'pending_dispatch' || row.statusCode === 'implementing';
}
function canDeleteRequirement(row: Api.Product.Requirement) {
const allowedStatusCodes: Api.Product.RequirementStatusCode[] = [
'pending_confirm',
'pending_review',
'pending_dispatch'
];
const isStatusAllowed = allowedStatusCodes.includes(row.statusCode);
const isStatusAllowed = canDeleteStatusHasValue(row.statusCode);
const hasNoChildren = !row.children || row.children.length === 0;
return isStatusAllowed && hasNoChildren;
}
@@ -199,17 +234,50 @@ function collectAllRequirementIds(nodes: Api.Product.Requirement[]): string[] {
return ids;
}
function collectRequirementIdsForActions(nodes: Api.Product.Requirement[]): string[] {
const ids: string[] = [];
for (const node of nodes) {
const isTerminal = isTerminalStatus(node.statusCode);
const hasDispatched = Boolean(node.implementProjectId);
if (!isTerminal && !hasDispatched) {
ids.push(node.id);
}
if (node.children?.length) {
ids.push(...collectRequirementIdsForActions(node.children));
}
}
return ids;
}
function collectRequirementIdsForSplitCheck(nodes: Api.Product.Requirement[]): string[] {
const ids: string[] = [];
for (const node of nodes) {
if (!node.implementProjectId) {
ids.push(node.id);
}
if (node.children?.length) {
ids.push(...collectRequirementIdsForSplitCheck(node.children));
}
}
return ids;
}
async function loadAllowedTransitionsForAll() {
if (!currentObjectId.value) {
allowedTransitionsMap.value = new Map();
return;
}
const allIds = collectAllRequirementIds(treeData.value);
const idsToQuery = collectRequirementIdsForActions(treeData.value);
const newMap = new Map<string, Api.Product.RequirementLifecycleAction[]>();
if (idsToQuery.length === 0) {
allowedTransitionsMap.value = newMap;
return;
}
const results = await Promise.all(
allIds.map(async id => {
idsToQuery.map(async id => {
const { error, data } = await fetchGetRequirementAllowedTransitions(id, currentObjectId.value!);
return { id, actions: error ? [] : data || [] };
})
@@ -222,6 +290,30 @@ async function loadAllowedTransitionsForAll() {
allowedTransitionsMap.value = newMap;
}
async function loadHasDispatchedForAll() {
if (!currentObjectId.value) {
hasDispatchedMap.value = {};
return;
}
const idsToQuery = collectRequirementIdsForSplitCheck(treeData.value);
const newMap: Record<string, boolean> = {};
if (idsToQuery.length === 0) {
hasDispatchedMap.value = newMap;
return;
}
await Promise.all(
idsToQuery.map(async id => {
const { data } = await fetchHasDispatchedProjectRequirement(id, currentObjectId.value!);
newMap[id] = Boolean(data);
})
);
hasDispatchedMap.value = newMap;
}
function getRowActions(row: Api.Product.Requirement): Api.Product.RequirementLifecycleAction[] {
return allowedTransitionsMap.value.get(row.id) || [];
}
@@ -244,38 +336,22 @@ const columns = computed(() => [
},
{
prop: 'title',
label: '标题',
label: '需求名称',
minWidth: 200,
formatter: (row: Api.Product.Requirement) => {
const isTerminal = isTerminalStatus(row.statusCode);
const className = 'requirement-title';
return (
<ElButton link type={isTerminal ? 'info' : 'primary'} class={className} onClick={() => openView(row)}>
<ElButton link type="primary" class={className} onClick={() => openView(row)}>
{row.title}
</ElButton>
);
}
},
{
prop: 'category',
label: '分类',
minWidth: 120,
formatter: (row: Api.Product.Requirement) => row.category
},
// {
// prop: 'description',
// label: '描述',
// minWidth: 200,
// showOverflowTooltip: true,
// formatter: (row: Api.Product.Requirement) => {
// return row.description?.replace(/<[^>]+>/g, '').trim() || '--';
// }
// },
{
prop: 'priority',
label: '优先级',
width: 100,
width: 75,
align: 'center',
formatter: (row: Api.Product.Requirement) => (
<DictTag dictCode={RDMS_REQ_PRIORITY_DICT_CODE} value={row.priority} type={getPriorityTagType(row.priority)} />
@@ -287,11 +363,46 @@ const columns = computed(() => [
width: 100,
align: 'center',
formatter: (row: Api.Product.Requirement) => (
<ElTag type={getRequirementStatusTagType(row.statusCode)}>
{getStatusLabel(row.statusCode)}
</ElTag>
<ElTag type={getRequirementStatusTagType(row.statusCode)}>{getStatusLabel(row.statusCode)}</ElTag>
)
},
{
prop: 'workHours',
label: '所需工时',
width: 75,
align: 'center',
formatter: (row: Api.Product.Requirement) => (row.workHours !== null ? `${row.workHours}h` : '--')
},
{
prop: 'category',
label: '需求类型',
minWidth: 100,
formatter: (row: Api.Product.Requirement) => row.category
},
{
prop: 'sourceType',
label: '需求来源',
minWidth: 100,
align: 'center',
formatter: (row: Api.Product.Requirement) => (
<DictText dictCode={RDMS_REQ_SOURCE_TYPE_DICT_CODE} value={row.sourceType} />
)
},
// {
// prop: 'description',
// label: '内容',
// minWidth: 200,
// showOverflowTooltip: true,
// formatter: (row: Api.Product.Requirement) => {
// return row.description?.replace(/<[^>]+>/g, '').trim() || '--';
// }
// },
{
prop: 'proposerNickname',
label: '提出人',
minWidth: 70,
formatter: (row: Api.Product.Requirement) => row.proposerNickname || '--'
},
{
prop: 'currentHandlerUserId',
label: '负责人',
@@ -315,15 +426,23 @@ const columns = computed(() => [
}
},
{
prop: 'implementProjectName',
prop: 'implementProjectId',
label: '实现项目',
minWidth: 140,
formatter: (row: Api.Product.Requirement) => row.implementProjectName || '--'
formatter: (row: Api.Product.Requirement) => {
if (!row.implementProjectId) return '--';
const projectName = projectNameMap.value.get(row.implementProjectId) || row.implementProjectName || '--';
return (
<ElButton link type="primary" class="implement-project-link" onClick={() => handleImplementProjectClick(row)}>
{projectName}
</ElButton>
);
}
},
{
prop: 'createTime',
label: '创建时间',
width: 170,
minWidth: 180,
formatter: (row: Api.Product.Requirement) => formatDateTime(row.createTime)
},
{
@@ -336,26 +455,33 @@ const columns = computed(() => [
const actions: {
key: string;
label: string;
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
icon: object;
type: 'primary' | 'success' | 'danger';
disabled?: boolean;
onClick: () => void;
}[] = [];
if (canSplitRequirement(row) && hasObjectAuth('project:product:status')) {
if (canSplitRequirement(row) && hasObjectAuth('project:product:split')) {
actions.push({
key: 'split',
label: '拆分',
buttonType: 'primary',
icon: ACTION_ICON_MAP.split,
type: ACTION_TYPE_MAP.split,
onClick: () => openSplit(row)
});
}
if (hasObjectAuth('project:product:update')) {
if (
hasObjectAuth('project:product:update') &&
!isTerminalStatus(row.statusCode) &&
row.statusCode !== 'accepted' &&
!row.implementProjectId
) {
actions.push({
key: 'edit',
label: '编辑',
buttonType: 'info',
disabled: isTerminalStatus(row.statusCode),
icon: ACTION_ICON_MAP.edit,
type: ACTION_TYPE_MAP.edit,
onClick: () => openEdit(row)
});
}
@@ -380,26 +506,76 @@ const columns = computed(() => [
actions.push({
key: `action-${action.actionCode}`,
label: getRequirementActionDisplayName(action),
buttonType: getRequirementActionTagType(action.actionCode as RequirementStatusActionCode),
icon: ACTION_ICON_MAP[action.actionCode] ?? markRaw(IconMdiSync),
type: ACTION_TYPE_MAP[action.actionCode] ?? 'primary',
onClick: () => handleActionClick(row, action)
});
}
}
if (hasStatusAuth && canDeleteRequirement(row)) {
if (canDeleteRequirement(row) && hasObjectAuth('project:product:delete')) {
actions.push({
key: 'delete',
label: '删除',
buttonType: 'danger',
icon: ACTION_ICON_MAP.delete,
type: ACTION_TYPE_MAP.delete,
onClick: () => handleDelete(row)
});
}
return <BusinessTableActionCell actions={actions} />;
return (
<div class="requirement-action-cell" onClick={event => event.stopPropagation()}>
{actions.map(action => {
const IconComponent = action.icon as any;
return (
<ElTooltip key={action.key} content={action.label} placement="top">
<ElButton
link
size="small"
class="requirement-action-icon-btn"
type={action.type}
onClick={() => action.onClick()}
>
<IconComponent class="text-18px" />
</ElButton>
</ElTooltip>
);
})}
</div>
);
}
}
]);
const columnChecks = ref<UI.TableColumnCheck[]>([]);
watch(
() => columns.value,
cols => {
const existingMap = new Map(columnChecks.value.map(c => [c.prop, c.checked]));
columnChecks.value = cols
.filter(col => col.prop && col.prop !== 'operate')
.map(col => ({
prop: String(col.prop),
label: String(col.label || ''),
checked: existingMap.has(String(col.prop)) ? existingMap.get(String(col.prop))! : true,
visible: true
}));
},
{ immediate: true }
);
const visibleColumns = computed(() => {
if (columnChecks.value.length === 0) return columns.value;
const visibleSet = new Set(columnChecks.value.filter(c => c.checked).map(c => c.prop));
return columns.value.filter(col => {
const prop = String(col.prop || '');
if (!prop) return true;
if (prop === 'operate') return true;
return visibleSet.has(prop);
});
});
async function loadMembers() {
if (!currentObjectId.value) {
memberOptions.value = [];
@@ -423,8 +599,6 @@ async function loadTreeData() {
return;
}
loading.value = true;
const { error, data } = await fetchGetRequirementTree({
productId: currentObjectId.value,
moduleId: selectedModuleId.value,
@@ -438,8 +612,6 @@ async function loadTreeData() {
sourceType: searchParams.sourceType
});
loading.value = false;
if (error || !data) {
treeData.value = [];
pagination.total = 0;
@@ -451,8 +623,14 @@ async function loadTreeData() {
}
async function reloadTable() {
await loadTreeData();
await loadAllowedTransitionsForAll();
loading.value = true;
try {
await loadTreeData();
await loadAllowedTransitionsForAll();
await loadHasDispatchedForAll();
} finally {
loading.value = false;
}
}
function handleModuleSelect(moduleId: string | undefined) {
@@ -510,6 +688,17 @@ function openSplit(row: Api.Product.Requirement) {
splitVisible.value = true;
}
async function handleImplementProjectClick(row: Api.Product.Requirement) {
if (!row.implementProjectId) return;
router.push({
path: '/project/project/requirement',
query: {
objectId: row.implementProjectId
}
});
}
function handleActionClick(row: Api.Product.Requirement, action: Api.Product.RequirementLifecycleAction) {
const actionCode = action.actionCode as RequirementStatusActionCode;
@@ -619,11 +808,13 @@ watch(
() => currentObjectId.value,
async id => {
if (id) {
await Promise.all([loadMembers(), loadTreeData()]);
await Promise.all([loadMembers(), loadTreeData(), loadProjectOptions()]);
await loadAllowedTransitionsForAll();
await loadHasDispatchedForAll();
} else {
memberOptions.value = [];
treeData.value = [];
projectOptions.value = [];
allowedTransitionsMap.value = new Map();
}
},
@@ -660,7 +851,7 @@ onMounted(async () => {
<p>需求列表</p>
<ElTag effect="plain">{{ pagination.total }} </ElTag>
</div>
<TableHeaderOperation :loading="loading" @refresh="reloadTable">
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
<template #default>
<ElButton
v-auth="{ code: 'project:product:create', source: 'object' }"
@@ -690,7 +881,7 @@ onMounted(async () => {
:data="treeData"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
>
<ElTableColumn v-for="col in columns" :key="String(col.prop || 'index')" v-bind="col" />
<ElTableColumn v-for="col in visibleColumns" :key="String(col.prop || 'index')" v-bind="col" />
<template #empty>
<ElEmpty description="当前模块下暂无需求" />
@@ -747,6 +938,7 @@ onMounted(async () => {
v-model:visible="actionVisible"
:action="currentAction"
:requirement-title="actionRequirement?.title || ''"
:project-options="projectOptions"
@submitted="handleActionSubmitted"
/>
</div>
@@ -774,7 +966,28 @@ onMounted(async () => {
padding: 0;
}
:deep(.implement-project-link) {
padding: 0;
font-weight: 500;
}
:deep(.el-table__row[class*='el-table__row--level-']:not(.el-table__row--level-0) td:first-child .cell) {
color: transparent;
}
:deep(.requirement-action-cell) {
display: inline-flex;
align-items: center;
gap: 1px;
}
:deep(.requirement-action-icon-btn) {
padding: 1px;
height: auto;
min-width: auto;
}
:deep(.requirement-action-icon-btn:hover) {
background-color: var(--el-fill-color-light);
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { type Ref, computed, inject, ref } from 'vue';
defineOptions({ name: 'ModuleTreeNode' });
@@ -32,15 +32,17 @@ const emit = defineEmits([
'updateNewChildModuleName'
]);
const collapsedModuleIds = inject<Ref<Set<string>>>('collapsedModuleIds', ref(new Set()));
const toggleCollapse = inject<(id: string) => void>('toggleCollapse', () => {});
const isRootModule = computed(() => props.module.id === props.rootModuleId);
const isSelected = computed(() => props.selectedModuleId === props.module.id);
const isEditing = computed(() => props.editingNodeId === props.module.id);
const isAddingChild = computed(() => props.addingChildParentId === props.module.id);
const hasChildren = computed(() => props.module.children && props.module.children.length > 0);
const isCollapsed = computed(() =>
hasChildren.value && props.module.id ? collapsedModuleIds.value.has(props.module.id) : false
);
const hasRequirements = computed(() => {
const moduleId = props.module.id;
@@ -91,6 +93,12 @@ function handleAddChildConfirm() {
function handleAddChildCancel() {
emit('addChildCancel');
}
function handleToggle() {
if (props.module.id) {
toggleCollapse(props.module.id);
}
}
</script>
<template>
@@ -105,12 +113,21 @@ function handleAddChildCancel() {
:style="indentStyle"
@click="handleClick"
>
<div
class="module-tree-item__toggle"
:class="{ 'is-expanded': hasChildren && !isCollapsed }"
@click.stop="handleToggle"
>
<icon-ic-round-chevron-right v-if="hasChildren" class="text-14px" />
</div>
<div class="module-tree-item__icon">
<icon-mdi-folder-open v-if="isRootModule" class="text-16px" />
<icon-mdi-folder-outline v-else class="text-16px" />
</div>
<div class="module-tree-item__content">
<span v-if="!isEditing" class="module-tree-item__label">{{ module.moduleName }}</span>
<ElTooltip v-if="!isEditing" :content="module.moduleName" placement="top" :show-after="500">
<span class="module-tree-item__label">{{ module.moduleName }}</span>
</ElTooltip>
<ElInput
v-else
:model-value="editingName"
@@ -124,7 +141,7 @@ function handleAddChildCancel() {
/>
</div>
<div v-if="!isRootModule && !isEditing" class="module-tree-item__actions">
<div v-if="!isEditing" class="module-tree-item__actions" @click.stop>
<ElDropdown trigger="click">
<ElButton text size="small" class="module-tree-item__more-btn">
<icon-mdi-dots-horizontal class="text-14px" />
@@ -140,14 +157,18 @@ function handleAddChildCancel() {
<span>新增子模块</span>
</div>
</ElDropdownItem>
<ElDropdownItem v-auth="{ code: 'project:product:update', source: 'object' }" @click="handleStartEdit">
<ElDropdownItem
v-if="!isRootModule"
v-auth="{ code: 'project:product:update', source: 'object' }"
@click="handleStartEdit"
>
<div class="flex items-center gap-6px">
<icon-mdi-pencil-outline class="text-14px" />
<span>编辑</span>
</div>
</ElDropdownItem>
<ElDropdownItem
v-if="canDeleteModule"
v-if="!isRootModule && canDeleteModule"
v-auth="{ code: 'project:product:delete', source: 'object' }"
divided
@click="handleDelete"
@@ -163,7 +184,7 @@ function handleAddChildCancel() {
</div>
</div>
<template v-if="hasChildren">
<template v-if="hasChildren && !isCollapsed">
<ModuleTreeNode
v-for="child in module.children"
:key="child.id"
@@ -270,6 +291,23 @@ function handleAddChildCancel() {
color: rgb(100 116 139 / 80%);
}
.module-tree-item__toggle {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
cursor: pointer;
user-select: none;
transition: transform 0.2s ease;
color: rgb(148 163 184);
}
.module-tree-item__toggle.is-expanded svg {
transform: rotate(90deg);
}
.module-tree-item__content {
flex: 1;
min-width: 0;

View File

@@ -14,6 +14,7 @@ defineOptions({ name: 'RequirementActionDialog' });
interface Props {
action: Api.Product.RequirementLifecycleAction | null;
requirementTitle: string;
projectOptions: Api.Project.Project[];
}
const props = defineProps<Props>();
@@ -57,8 +58,6 @@ const reviewChoiceOptions = [
{ label: '不需要评审', value: 'claim_to_dispatch', description: '认领后直接进入分流' }
];
const projectOptions = [{ label: 'NPQS-10086', value: '202642910086' }];
const rules = computed(() => {
const baseRules: Record<string, App.Global.FormRule[]> = {};
@@ -117,11 +116,15 @@ async function handleSubmit() {
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElFormItem label="需求标题">
<span class="text-14px">{{ requirementTitle }}</span>
</ElFormItem>
<ElAlert
v-if="requirementTitle"
:title="`需求名称:${requirementTitle}`"
type="info"
:closable="false"
class="mb-16px"
/>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElFormItem v-if="isClaimAction" label="是否需要评审" prop="reviewChoice">
<ElRadioGroup v-model="model.reviewChoice" class="business-form-radio-group">
<ElRadio v-for="option in reviewChoiceOptions" :key="option.value" :value="option.value">
@@ -135,7 +138,7 @@ async function handleSubmit() {
<ElFormItem v-if="isDispatchAction" label="实现项目" prop="implementProjectId">
<ElSelect v-model="model.implementProjectId" class="w-full" filterable placeholder="请选择实现项目(必选)">
<ElOption v-for="item in projectOptions" :key="item.value" :label="item.label" :value="item.value" />
<ElOption v-for="item in projectOptions" :key="item.id" :label="item.projectName" :value="item.id" />
</ElSelect>
</ElFormItem>

View File

@@ -1,9 +1,13 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import { fetchCreateRequirement, fetchGetRequirementModuleTree } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import MemberSelectOption from './member-select-option.vue';
@@ -31,9 +35,11 @@ const visible = defineModel<boolean>('visible', {
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
const priorityOptions = computed(() => {
return priorityDictData.value.map(item => ({
label: item.label,
@@ -43,32 +49,49 @@ const priorityOptions = computed(() => {
interface Model {
title: string;
description: string;
description: string | null;
attachments: Api.Project.AttachmentItem[];
reviewRequired: number;
completionDate: string;
moduleId: string;
category: string;
priority: number | null;
proposerId: string;
currentHandlerUserId: string;
workHours: number | null;
sort: number;
}
const submitting = ref(false);
const loading = ref(false);
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
const model = ref<Model>(createDefaultModel());
const memberUserOptions = computed(() => {
return props.memberOptions.filter(m => m.status === 0);
});
const moduleTreeProps = {
label: 'moduleName',
value: 'id',
children: 'children'
};
const flatModuleOptions = computed<Array<{ label: string; value: string }>>(() => {
const options: Array<{ label: string; value: string }> = [];
function walk(modules: Api.Product.RequirementModule[], parentPath: string) {
for (const module of modules) {
const currentPath = `${parentPath}/${module.moduleName}`;
options.push({
label: currentPath,
value: module.id || ''
});
if (module.children?.length) {
walk(module.children, currentPath);
}
}
}
if (moduleTree.value.length > 0) {
walk(moduleTree.value, '');
}
return options;
});
const reviewRequiredOptions = [
{ label: '不需要', value: 0 },
@@ -76,33 +99,60 @@ const reviewRequiredOptions = [
];
const rules = {
title: [createRequiredRule('请输入需求标题')],
category: [createRequiredRule('请选择分类')],
title: [createRequiredRule('请输入需求名称')],
category: [createRequiredRule('请选择需求类型')],
priority: [createRequiredRule('请选择优先级')],
proposerId: [createRequiredRule('请选择提出人')],
currentHandlerUserId: [createRequiredRule('请选择负责人')],
completionDate: [createRequiredRule('请选择预期完成时间')]
workHours: [createRequiredRule('请输入所需工时')]
} satisfies Record<string, App.Global.FormRule[]>;
const leftColRef = ref<HTMLElement>();
const editorHeight = ref<string>('45vh');
const ATTACHMENT_SECTION_RESERVE_PX = 140;
useResizeObserver(leftColRef, entries => {
const height = entries[0]?.contentRect.height;
if (height && height > 120) {
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
}
});
function isEmptyRichText(html: string | null | undefined) {
if (!html) {
return true;
}
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, '')
.trim();
if (text) {
return false;
}
return !/<img\b/i.test(html);
}
function createDefaultModel(): Model {
return {
title: '',
description: '',
description: null,
attachments: [],
reviewRequired: 0,
completionDate: '',
moduleId: props.defaultModuleId || '0',
category: '功能需求',
priority: 1,
proposerId: '',
currentHandlerUserId: '',
workHours: null,
sort: 0
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function closeDialog() {
visible.value = false;
}
@@ -114,18 +164,31 @@ async function handleSubmit() {
return;
}
if (attachmentUploaderRef.value?.hasUploading) {
window.$message?.warning('附件正在上传中,请稍候');
return;
}
const proposer = memberUserOptions.value.find(m => m.userId === model.value.proposerId);
const proposerNickname = proposer?.userNickname || '';
const handler = memberUserOptions.value.find(m => m.userId === model.value.currentHandlerUserId);
const currentHandlerUserNickname = handler?.userNickname || '';
const payload: Api.Product.SaveRequirementParams = {
productId: props.productId,
moduleId: model.value.moduleId || '0',
reviewRequired: model.value.reviewRequired as Api.Product.RequirementReviewRequired,
title: model.value.title.trim(),
description: getNullableText(model.value.description),
description: isEmptyRichText(model.value.description) ? null : (model.value.description ?? null),
attachments: [...model.value.attachments],
category: model.value.category,
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
proposerId: model.value.proposerId,
proposerNickname,
currentHandlerUserId: model.value.currentHandlerUserId,
currentHandlerUserNickname,
implementProjectId: null,
completionDate: model.value.completionDate,
workHours: model.value.workHours || 0,
sort: model.value.sort
};
@@ -139,6 +202,8 @@ async function handleSubmit() {
return;
}
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
window.$message?.success('需求新增成功');
closeDialog();
emit('submitted');
@@ -150,8 +215,12 @@ async function loadModuleTree() {
return;
}
loading.value = true;
const { error, data } = await fetchGetRequirementModuleTree(props.productId);
loading.value = false;
if (error || !data) {
moduleTree.value = [];
return;
@@ -171,6 +240,8 @@ watch(
await loadModuleTree();
await nextTick();
attachmentUploaderRef.value?.initSession();
richTextEditorRef.value?.initSession();
formRef.value?.clearValidate();
}
);
@@ -180,115 +251,150 @@ watch(
<BusinessFormDialog
v-model="visible"
title="新增需求"
preset="lg"
width="1100px"
max-body-height="78vh"
:loading="loading"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="标题" prop="title">
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入需求标题" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="预期完成时间" prop="completionDate" style="width: 100%">
<ElDatePicker
v-model="model.completionDate"
type="datetime"
class="w-full"
placeholder="选择预期完成时间"
value-format="x"
style="width: 100%"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="描述">
<ElInput
v-model="model.description"
type="textarea"
:rows="6"
maxlength="2000"
show-word-limit
placeholder="请输入需求描述"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="是否需要评审">
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
<ElOption
v-for="item in reviewRequiredOptions"
:key="item.value"
:label="item.label"
:value="item.value"
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<div class="requirement-operate-dialog__grid">
<div ref="leftColRef" class="requirement-operate-dialog__col-left">
<BusinessFormSection title="需求信息">
<ElFormItem label="需求名称" prop="title">
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入需求名称" />
</ElFormItem>
<ElFormItem label="模块">
<ElSelect v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
<ElFormItem label="是否需要评审">
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
<ElOption
v-for="item in reviewRequiredOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="优先级" prop="priority">
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
<ElFormItem label="所需工时" prop="workHours">
<ElInputNumber
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="模块">
<ElTreeSelect
v-model="model.moduleId"
:data="moduleTree"
:props="moduleTreeProps"
class="w-full"
check-strictly
:render-after-expand="false"
placeholder="请选择所属模块"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="分类" prop="category">
<DictSelect v-model="model.category" :dict-code="categoryDictCode" filterable placeholder="请选择分类" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="优先级" prop="priority">
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="提出人" prop="proposerId">
<ElSelect v-model="model.proposerId" class="w-full" filterable placeholder="请选择提出人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ElSelect v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</ElCol>
</ElRow>
</ElFormItem>
<ElFormItem label="需求类型" prop="category">
<DictSelect
v-model="model.category"
:dict-code="categoryDictCode"
filterable
placeholder="请选择需求类型"
/>
</ElFormItem>
<ElFormItem label="提出人" prop="proposerId">
<ElSelect v-model="model.proposerId" class="w-full" filterable placeholder="请选择提出人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ElSelect v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</BusinessFormSection>
</div>
<div class="requirement-operate-dialog__col-right">
<BusinessFormSection title="需求内容">
<ElFormItem class="requirement-operate-dialog__desc-item">
<BusinessRichTextEditor
ref="richTextEditorRef"
v-model="model.description"
:height="editorHeight"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection title="附件">
<ElFormItem class="requirement-operate-dialog__attachment-item">
<BusinessAttachmentUploader
ref="attachmentUploaderRef"
v-model="model.attachments"
directory="requirement"
/>
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>
<style scoped>
.requirement-operate-dialog__grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
align-items: start;
}
.requirement-operate-dialog__col-left,
.requirement-operate-dialog__col-right {
min-width: 0;
}
.requirement-operate-dialog__col-right {
display: flex;
flex-direction: column;
gap: 16px;
}
.requirement-operate-dialog__desc-item,
.requirement-operate-dialog__attachment-item {
margin-bottom: 0;
}
@media (width <= 1024px) {
.requirement-operate-dialog__grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,11 +1,18 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { fetchGetRequirement, fetchGetRequirementModuleTree, fetchUpdateRequirement } from '@/service/api';
import { useResizeObserver } from '@vueuse/core';
import {
fetchGetProjectListByProductId,
fetchGetRequirement,
fetchGetRequirementModuleTree,
fetchUpdateRequirement
} from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import ReadonlyField from '@/components/custom/readonly-field.vue';
import MemberSelectOption from './member-select-option.vue';
@@ -40,6 +47,9 @@ const { createRequiredRule } = useFormRules();
const { getLabel: getCategoryLabel } = useDict(() => props.categoryDictCode);
const { getLabel: getPriorityLabel, enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
const priorityOptions = computed(() => {
return priorityDictData.value.map(item => ({
label: item.label,
@@ -49,15 +59,18 @@ const priorityOptions = computed(() => {
interface Model {
title: string;
description: string;
description: string | null;
attachments: Api.Project.AttachmentItem[];
reviewRequired: number;
completionDate: string;
moduleId: string;
category: string;
priority: number | null;
proposerId: string;
proposerNickname: string;
currentHandlerUserId: string;
currentHandlerUserNickname: string;
implementProjectId: string | null;
workHours: number | null;
sort: number;
lastStatusReason: string;
}
@@ -65,7 +78,7 @@ interface Model {
const loading = ref(false);
const submitting = ref(false);
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
const projectOptions = ref<Api.Project.Project[]>([]);
const model = ref<Model>(createDefaultModel());
const isViewMode = computed(() => props.mode === 'view');
@@ -74,6 +87,7 @@ const dialogTitle = computed(() => {
if (isViewMode.value) {
return '查看需求';
}
return '编辑需求';
});
@@ -87,6 +101,7 @@ const memberLabelMap = computed(() => {
const moduleLabelMap = computed(() => {
const map = new Map<string | undefined, string>();
function traverse(modules: Api.Product.RequirementModule[]) {
for (const module of modules) {
map.set(module.id, module.moduleName);
@@ -95,15 +110,37 @@ const moduleLabelMap = computed(() => {
}
}
}
traverse(moduleTree.value);
return map;
});
const moduleTreeProps = {
label: 'moduleName',
value: 'id',
children: 'children'
};
const projectOptionsMap = computed(() => {
return new Map(projectOptions.value.map(item => [String(item.id), item.projectName]));
});
const flatModuleOptions = computed<Array<{ label: string; value: string }>>(() => {
const options: Array<{ label: string; value: string }> = [];
function walk(modules: Api.Product.RequirementModule[], parentPath: string) {
for (const module of modules) {
const currentPath = `${parentPath}/${module.moduleName}`;
options.push({
label: currentPath,
value: module.id || ''
});
if (module.children?.length) {
walk(module.children, currentPath);
}
}
}
if (moduleTree.value.length > 0) {
walk(moduleTree.value, '');
}
return options;
});
const reviewRequiredOptions = [
{ label: '不需要', value: 0 },
@@ -112,38 +149,66 @@ const reviewRequiredOptions = [
const rules = computed(() => {
const baseRules: Record<string, App.Global.FormRule[]> = {
title: isEditMode.value ? [createRequiredRule('请输入需求标题')] : [],
category: isEditMode.value ? [createRequiredRule('请选择分类')] : [],
title: isEditMode.value ? [createRequiredRule('请输入需求名称')] : [],
category: isEditMode.value ? [createRequiredRule('请选择需求类型')] : [],
priority: isEditMode.value ? [createRequiredRule('请选择优先级')] : [],
proposerId: isEditMode.value ? [createRequiredRule('请选择提出人')] : [],
currentHandlerUserId: isEditMode.value ? [createRequiredRule('请选择负责人')] : [],
completionDate: isEditMode.value ? [createRequiredRule('请选择预期完成时间')] : []
currentHandlerUserId: isEditMode.value ? [createRequiredRule('请选择负责人')] : []
};
return baseRules;
});
const leftColRef = ref<HTMLElement>();
const editorHeight = ref<string>('45vh');
const ATTACHMENT_SECTION_RESERVE_PX = 140;
useResizeObserver(leftColRef, entries => {
const height = entries[0]?.contentRect.height;
if (height && height > 120) {
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
}
});
function isEmptyRichText(html: string | null | undefined) {
if (!html) {
return true;
}
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, '')
.trim();
if (text) {
return false;
}
return !/<img\b/i.test(html);
}
function createDefaultModel(): Model {
return {
title: '',
description: '',
description: null,
attachments: [],
reviewRequired: 0,
completionDate: '',
moduleId: '0',
category: '',
priority: 1,
proposerId: '',
proposerNickname: '',
currentHandlerUserId: '',
currentHandlerUserNickname: '',
implementProjectId: null,
workHours: null,
sort: 0,
lastStatusReason: ''
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function closeDialog() {
visible.value = false;
}
@@ -155,6 +220,13 @@ async function handleSubmit() {
return;
}
if (attachmentUploaderRef.value?.hasUploading) {
window.$message?.warning('附件正在上传中,请稍候');
return;
}
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
submitting.value = true;
const updatePayload: Api.Product.UpdateRequirementParams = {
@@ -163,13 +235,16 @@ async function handleSubmit() {
moduleId: model.value.moduleId || '0',
reviewRequired: model.value.reviewRequired as Api.Product.RequirementReviewRequired,
title: model.value.title.trim(),
description: getNullableText(model.value.description),
description: isEmptyRichText(model.value.description) ? null : (model.value.description ?? null),
attachments: [...model.value.attachments],
category: model.value.category,
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
proposerId: model.value.proposerId,
proposerNickname: model.value.proposerNickname,
currentHandlerUserId: model.value.currentHandlerUserId,
currentHandlerUserNickname: handler?.userNickname || model.value.currentHandlerUserNickname,
implementProjectId: model.value.implementProjectId,
completionDate: model.value.completionDate,
workHours: model.value.workHours || 0,
sort: model.value.sort
};
@@ -181,6 +256,8 @@ async function handleSubmit() {
return;
}
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
window.$message?.success('需求更新成功');
closeDialog();
emit('submitted', props.requirement.id);
@@ -202,6 +279,42 @@ async function loadModuleTree() {
moduleTree.value = data;
}
async function loadProjectOptions() {
if (!props.productId) {
projectOptions.value = [];
return;
}
const { error, data } = await fetchGetProjectListByProductId(props.productId);
if (error || !data) {
projectOptions.value = [];
return;
}
projectOptions.value = data;
}
function transformRequirementData(data: Api.Product.Requirement): typeof model.value {
return {
title: data.title || '',
description: data.description || null,
attachments: data.attachments ? [...data.attachments] : [],
reviewRequired: data.reviewRequired ?? 0,
moduleId: data.moduleId || '0',
category: data.category || '',
priority: data.priority ?? null,
proposerId: data.proposerId || '',
proposerNickname: data.proposerNickname || '',
currentHandlerUserId: data.currentHandlerUserId || '',
currentHandlerUserNickname: data.currentHandlerUserNickname || '',
implementProjectId: data.implementProjectId || null,
workHours: data.workHours ?? null,
sort: data.sort ?? 0,
lastStatusReason: data.lastStatusReason || ''
};
}
async function loadRequirementDetail() {
if (!props.productId || !props.requirement?.id) {
return;
@@ -217,20 +330,7 @@ async function loadRequirementDetail() {
return;
}
model.value = {
title: data.title || '',
description: data.description || '',
reviewRequired: data.reviewRequired ?? 0,
completionDate: data.completionDate || '',
moduleId: data.moduleId || '0',
category: data.category || '',
priority: data.priority ?? null,
proposerId: data.proposerId || '',
currentHandlerUserId: data.currentHandlerUserId || '',
implementProjectId: data.implementProjectId || null,
sort: data.sort ?? 0,
lastStatusReason: data.lastStatusReason || ''
};
model.value = transformRequirementData(data);
}
watch(
@@ -240,13 +340,17 @@ watch(
return;
}
await loadModuleTree();
await Promise.all([loadModuleTree(), loadProjectOptions()]);
if (props.requirement?.id) {
await loadRequirementDetail();
} else {
model.value = createDefaultModel();
}
await nextTick();
attachmentUploaderRef.value?.initSession();
richTextEditorRef.value?.initSession();
formRef.value?.clearValidate();
}
);
@@ -256,167 +360,172 @@ watch(
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="lg"
width="1100px"
max-body-height="78vh"
:loading="loading"
:confirm-loading="submitting"
:show-footer="isEditMode"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="标题" prop="title">
<template v-if="isViewMode">
<template v-if="isViewMode" #footer="{ close }">
<ElButton type="primary" @click="close">关闭</ElButton>
</template>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<div class="requirement-operate-dialog__grid">
<div ref="leftColRef" class="requirement-operate-dialog__col-left">
<BusinessFormSection title="需求信息">
<ElFormItem label="需求名称" prop="title">
<ReadonlyField :value="model.title" />
</template>
<ElInput v-else v-model="model.title" clearable maxlength="256" placeholder="请输入需求标题" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="预期完成时间" prop="completionDate" style="width: 100%">
<template v-if="isViewMode">
<ReadonlyField
:value="model.completionDate ? dayjs(Number(model.completionDate)).format('YYYY-MM-DD HH:mm:ss') : '--'"
</ElFormItem>
<ElFormItem label="模块">
<template v-if="isViewMode">
<ReadonlyField :value="moduleLabelMap.get(model.moduleId) || '--'" />
</template>
<ElSelect v-else v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
<ElFormItem label="是否需要评审">
<ReadonlyField :value="reviewRequiredOptions.find(opt => opt.value === model.reviewRequired)?.label" />
</ElFormItem>
<ElFormItem label="优先级" prop="priority">
<template v-if="isViewMode">
<ReadonlyField
:value="model.priority !== null ? getPriorityLabel(String(model.priority)) || '--' : '--'"
/>
</template>
<ElSelect v-else v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
<ElFormItem label="所需工时">
<template v-if="isViewMode">
<ReadonlyField :value="model.workHours != null ? `${model.workHours}小时` : '--'" />
</template>
<ElInputNumber
v-else
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</template>
<ElDatePicker
v-else
v-model="model.completionDate"
type="datetime"
class="w-full"
placeholder="选择预期完成时间"
value-format="x"
style="width: 100%"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="描述">
<template v-if="isViewMode">
<div class="readonly-textarea">
{{ model.description || '--' }}
</div>
</template>
<ElInput
v-else
v-model="model.description"
type="textarea"
:rows="6"
maxlength="2000"
show-word-limit
placeholder="请输入需求描述"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="是否需要评审">
<ReadonlyField :value="reviewRequiredOptions.find(opt => opt.value === model.reviewRequired)?.label" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="模块">
<template v-if="isViewMode">
<ReadonlyField :value="moduleLabelMap.get(model.moduleId) || '--'" />
</template>
<ElTreeSelect
v-else
v-model="model.moduleId"
:data="moduleTree"
:props="moduleTreeProps"
class="w-full"
check-strictly
:render-after-expand="false"
placeholder="请选择所属模块"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="分类" prop="category">
<template v-if="isViewMode">
</ElFormItem>
<ElFormItem label="需求类型" prop="category">
<ReadonlyField :value="getCategoryLabel(model.category) || '--'" />
</template>
<DictSelect
v-else
v-model="model.category"
:dict-code="categoryDictCode"
filterable
placeholder="请选择分类"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="优先级" prop="priority">
<template v-if="isViewMode">
<ReadonlyField
:value="model.priority !== null ? getPriorityLabel(String(model.priority)) || '--' : '--'"
/>
</template>
<ElSelect v-else v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="提出人" prop="proposerId">
<template v-if="isViewMode">
</ElFormItem>
<ElFormItem label="提出人" prop="proposerId">
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
</template>
<ElSelect v-else v-model="model.proposerId" class="w-full" filterable placeholder="请选择提出人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
</ElFormItem>
<ElFormItem label="负责人" prop="currentHandlerUserId">
<template v-if="isViewMode">
<ReadonlyField :value="memberLabelMap.get(model.currentHandlerUserId) || '--'" />
</template>
<ElSelect
v-else
v-model="model.currentHandlerUserId"
class="w-full"
filterable
placeholder="请选择负责人"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="负责人" prop="currentHandlerUserId">
<template v-if="isViewMode">
<ReadonlyField :value="memberLabelMap.get(model.currentHandlerUserId) || '--'" />
</template>
<ElSelect v-else v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="实现项目">
<ReadonlyField :value="model.implementProjectId || '--'" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="排序值">
<template v-if="isViewMode">
<ReadonlyField :value="model.sort" />
</template>
<ElInputNumber v-else v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</ElCol>
<ElCol v-if="isViewMode && model.lastStatusReason" :span="24">
<ElFormItem label="状态变更原因">
<div class="readonly-textarea">
{{ model.lastStatusReason }}
</div>
</ElFormItem>
</ElCol>
</ElRow>
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem v-if="isViewMode" label="实现项目">
<ReadonlyField :value="projectOptionsMap.get(model.implementProjectId || '') || '--'" />
</ElFormItem>
<ElFormItem label="排序值">
<template v-if="isViewMode">
<ReadonlyField :value="model.sort" />
</template>
<ElInputNumber
v-else
v-model="model.sort"
class="w-full"
:min="0"
:max="9999"
placeholder="请输入排序值"
/>
</ElFormItem>
<ElFormItem v-if="isViewMode && model.lastStatusReason" label="状态变更原因">
<div class="requirement-operate-dialog__readonly-textarea">{{ model.lastStatusReason }}</div>
</ElFormItem>
</BusinessFormSection>
</div>
<div class="requirement-operate-dialog__col-right">
<BusinessFormSection title="需求内容">
<ElFormItem class="requirement-operate-dialog__desc-item">
<BusinessRichTextEditor
ref="richTextEditorRef"
v-model="model.description"
:disabled="isViewMode"
:height="editorHeight"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection title="附件">
<ElFormItem class="requirement-operate-dialog__attachment-item">
<BusinessAttachmentUploader
ref="attachmentUploaderRef"
v-model="model.attachments"
directory="requirement"
:disabled="isViewMode"
/>
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.readonly-textarea {
.requirement-operate-dialog__grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
align-items: start;
}
.requirement-operate-dialog__col-left,
.requirement-operate-dialog__col-right {
min-width: 0;
}
.requirement-operate-dialog__col-right {
display: flex;
flex-direction: column;
gap: 16px;
}
.requirement-operate-dialog__desc-item,
.requirement-operate-dialog__attachment-item {
margin-bottom: 0;
}
.requirement-operate-dialog__readonly-textarea {
box-sizing: border-box;
width: 100%;
min-height: 100px;
@@ -430,4 +539,10 @@ watch(
white-space: pre-wrap;
word-break: break-all;
}
@media (width <= 1024px) {
.requirement-operate-dialog__grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { computed, nextTick, provide, ref, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import {
fetchCreateRequirementModule,
@@ -41,12 +41,24 @@ const rootModule = computed<Api.Product.RequirementModule | null>(() => {
const editingNodeId = ref<string | undefined>(undefined);
const editingName = ref('');
const addingTopModule = ref(false);
const newModuleName = ref('');
const addingChildParentId = ref<string | undefined>(undefined);
const newChildModuleName = ref('');
const collapsedModuleIds = ref(new Set<string>());
function handleToggleCollapse(moduleId: string) {
const set = collapsedModuleIds.value;
if (set.has(moduleId)) {
set.delete(moduleId);
} else {
set.add(moduleId);
}
collapsedModuleIds.value = new Set(set);
}
provide('collapsedModuleIds', collapsedModuleIds);
provide('toggleCollapse', handleToggleCollapse);
const moduleRequirementCountMap = computed(() => {
const countMap = new Map<string, number>();
@@ -98,59 +110,6 @@ function handleNodeSelect(moduleId: string) {
emit('select', moduleId);
}
function startAddTopModule() {
if (addingTopModule.value || addingChildParentId.value) return;
addingTopModule.value = true;
newModuleName.value = '';
nextTick(() => {
const input = document.querySelector('.new-module-input input') as HTMLInputElement;
input?.focus();
});
}
async function handleAddTopModuleConfirm() {
const name = newModuleName.value.trim();
if (!name) {
addingTopModule.value = false;
newModuleName.value = '';
return;
}
if (!currentObjectId.value || !rootModule.value?.id) {
addingTopModule.value = false;
return;
}
const { error } = await fetchCreateRequirementModule({
id: undefined,
productId: currentObjectId.value,
parentId: rootModule.value.id,
moduleName: name,
remark: null,
icon: null,
sort: 0
});
if (error) {
addingTopModule.value = false;
return;
}
window.$message?.success('模块新增成功');
addingTopModule.value = false;
newModuleName.value = '';
await loadModuleTree();
emit('refresh');
}
function handleAddTopModuleCancel() {
addingTopModule.value = false;
newModuleName.value = '';
}
function handleStartEdit(module: Api.Product.RequirementModule) {
editingNodeId.value = module.id;
editingName.value = module.moduleName;
@@ -199,7 +158,7 @@ async function handleUpdateModuleName(module: Api.Product.RequirementModule, nam
}
function handleStartAddChild(module: Api.Product.RequirementModule) {
if (addingTopModule.value || addingChildParentId.value) return;
if (addingChildParentId.value) return;
addingChildParentId.value = module.id;
newChildModuleName.value = '';
@@ -309,23 +268,12 @@ defineExpose({
</script>
<template>
<div class="requirement-module-tree-wrapper">
<div class="module-tree-header">
<span class="module-tree-header__title">模块</span>
<ElSpace>
<ElButton
v-auth="{ code: 'project:product:create', source: 'object' }"
circle
text
size="small"
@click="startAddTopModule"
>
<template #icon>
<icon-ic-round-plus class="text-16px" />
</template>
</ElButton>
</ElSpace>
</div>
<ElCard class="requirement-module-tree-card card-wrapper">
<template #header>
<div class="module-tree-header">
<span class="module-tree-header__title">模块</span>
</div>
</template>
<div class="module-tree-list">
<template v-for="data in moduleTree" :key="data.id">
@@ -351,32 +299,24 @@ defineExpose({
@update-new-child-module-name="newChildModuleName = $event"
/>
</template>
<div v-if="addingTopModule" class="module-tree-item module-tree-item--new">
<div class="module-tree-item__icon">
<icon-mdi-folder-plus-outline class="text-16px" />
</div>
<div class="module-tree-item__content">
<ElInput
v-model="newModuleName"
size="small"
class="new-module-input module-tree-item__input"
placeholder="请输入模块名"
@blur="handleAddTopModuleConfirm"
@keyup.enter="handleAddTopModuleConfirm"
@keyup.esc="handleAddTopModuleCancel"
/>
</div>
</div>
</div>
</div>
</ElCard>
</template>
<style scoped>
.requirement-module-tree-wrapper {
display: flex;
flex-direction: column;
gap: 14px;
.requirement-module-tree-card {
height: 100%;
}
.requirement-module-tree-card :deep(.el-card__header) {
padding: 12px 16px;
border-bottom: none;
}
.requirement-module-tree-card :deep(.el-card__body) {
padding: 0 16px 16px;
height: calc(100% - 48px);
overflow: hidden;
}
.module-tree-header {
@@ -387,7 +327,7 @@ defineExpose({
.module-tree-header__title {
color: rgb(15 23 42 / 94%);
font-size: 15px;
font-size: 16px;
font-weight: 700;
}
@@ -396,6 +336,8 @@ defineExpose({
flex-direction: column;
gap: 10px;
min-height: 0;
height: 100%;
overflow-y: auto;
}
.module-tree-item {

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { RDMS_REQ_SOURCE_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchGetRequirementStatusDict } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import DictSelect from '@/components/custom/dict-select.vue';
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
import MemberSelectOption from './member-select-option.vue';
@@ -33,6 +34,17 @@ const model = defineModel<Api.Product.RequirementSearchParams>('model', { requir
const requirementStatusOptions = ref<Array<{ label: string; value: string }>>([]);
const { enabledDictData: sourceTypeDictData } = useDict(RDMS_REQ_SOURCE_TYPE_DICT_CODE);
const sourceTypeOptions = computed(() => {
return sourceTypeDictData.value
.filter(item => item.value !== 'product_requirement')
.map(item => ({
label: item.label,
value: item.value
}));
});
async function loadStatusOptions() {
const { error, data } = await fetchGetRequirementStatusDict();
@@ -63,18 +75,18 @@ onMounted(async () => {
<template>
<TableSearchPanel :model="model" :action-col-lg="6" @reset="reset" @search="search">
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="标题">
<ElInput v-model="model.title" clearable placeholder="输入需求标题" />
<ElFormItem label="需求名称">
<ElInput v-model="model.title" clearable placeholder="输入需求名称" />
</ElFormItem>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="分类">
<ElFormItem label="需求类型">
<DictSelect
v-model="model.category"
:dict-code="categoryDictCode"
clearable
filterable
placeholder="筛选分类"
placeholder="筛选需求类型"
/>
</ElFormItem>
</ElCol>
@@ -111,13 +123,10 @@ onMounted(async () => {
</ElFormItem>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="来源类型">
<DictSelect
v-model="model.sourceType"
:dict-code="RDMS_REQ_SOURCE_TYPE_DICT_CODE"
clearable
placeholder="筛选来源类型"
/>
<ElFormItem label="需求来源">
<ElSelect v-model="model.sourceType" clearable placeholder="筛选需求来源">
<ElOption v-for="item in sourceTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
</TableSearchPanel>

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import { fetchSplitRequirement } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import MemberSelectOption from './member-select-option.vue';
defineOptions({ name: 'RequirementSplitDialog' });
@@ -31,9 +34,11 @@ const visible = defineModel<boolean>('visible', {
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
const priorityOptions = computed(() => {
return priorityDictData.value.map(item => ({
label: item.label,
@@ -43,18 +48,18 @@ const priorityOptions = computed(() => {
interface Model {
title: string;
description: string;
description: string | null;
attachments: Api.Project.AttachmentItem[];
reviewRequired: number;
category: string;
priority: number | null;
currentHandlerUserId: string;
completionDate: string;
workHours: number | null;
sort: number;
}
const submitting = ref(false);
const loading = ref(false);
const model = ref<Model>(createDefaultModel());
const memberUserOptions = computed(() => {
@@ -67,30 +72,56 @@ const reviewRequiredOptions = [
];
const rules = {
title: [createRequiredRule('请输入子需求标题')],
category: [createRequiredRule('请选择分类')],
title: [createRequiredRule('请输入子需求名称')],
priority: [createRequiredRule('请选择优先级')],
currentHandlerUserId: [createRequiredRule('请选择负责人')],
completionDate: [createRequiredRule('请选择预期完成时间')]
workHours: [createRequiredRule('请输入所需工时')]
} satisfies Record<string, App.Global.FormRule[]>;
const leftColRef = ref<HTMLElement>();
const editorHeight = ref<string>('45vh');
const ATTACHMENT_SECTION_RESERVE_PX = 140;
useResizeObserver(leftColRef, entries => {
const height = entries[0]?.contentRect.height;
if (height && height > 120) {
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
}
});
function isEmptyRichText(html: string | null | undefined) {
if (!html) {
return true;
}
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, '')
.trim();
if (text) {
return false;
}
return !/<img\b/i.test(html);
}
function createDefaultModel(): Model {
return {
title: '',
description: '',
description: null,
attachments: [],
reviewRequired: 0,
category: '',
priority: 1,
currentHandlerUserId: '',
completionDate: '',
workHours: null,
sort: 0
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function closeDialog() {
visible.value = false;
}
@@ -102,23 +133,31 @@ async function handleSubmit() {
return;
}
if (attachmentUploaderRef.value?.hasUploading) {
window.$message?.warning('附件正在上传中,请稍候');
return;
}
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
const payload: Api.Product.SplitRequirementParams = {
parentId: props.parentRequirement.id,
productId: props.productId,
moduleId: props.parentRequirement.moduleId,
proposerId: props.parentRequirement.proposerId,
proposerNickname: props.parentRequirement.proposerNickname || '',
currentHandlerUserNickname: handler?.userNickname || '',
title: model.value.title.trim(),
description: getNullableText(model.value.description),
description: isEmptyRichText(model.value.description) ? null : (model.value.description ?? null),
attachments: [...model.value.attachments],
reviewRequired: model.value.reviewRequired as Api.Product.RequirementReviewRequired,
category: model.value.category,
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
currentHandlerUserId: model.value.currentHandlerUserId,
completionDate: model.value.completionDate,
workHours: model.value.workHours || 0,
sort: model.value.sort
};
console.log('payload', payload);
submitting.value = true;
const result = await fetchSplitRequirement(payload);
@@ -129,6 +168,8 @@ async function handleSubmit() {
return;
}
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
window.$message?.success('需求拆分成功');
closeDialog();
emit('submitted');
@@ -143,7 +184,17 @@ watch(
model.value = createDefaultModel();
if (props.parentRequirement?.category) {
model.value.category = props.parentRequirement.category;
}
if (props.parentRequirement?.currentHandlerUserId) {
model.value.currentHandlerUserId = props.parentRequirement.currentHandlerUserId;
}
await nextTick();
attachmentUploaderRef.value?.initSession();
richTextEditorRef.value?.initSession();
formRef.value?.clearValidate();
}
);
@@ -153,7 +204,8 @@ watch(
<BusinessFormDialog
v-model="visible"
title="拆分需求"
preset="lg"
width="1100px"
max-body-height="78vh"
:loading="loading"
:confirm-loading="submitting"
@confirm="handleSubmit"
@@ -166,83 +218,116 @@ watch(
class="mb-16px"
/>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="子需求标题" prop="title">
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入子需求标题" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="预期完成时间" prop="completionDate" style="width: 100%">
<ElDatePicker
v-model="model.completionDate"
type="datetime"
class="w-full"
placeholder="选择预期完成时间"
value-format="x"
style="width: 100%"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="描述">
<ElInput
v-model="model.description"
type="textarea"
:rows="6"
maxlength="2000"
show-word-limit
placeholder="请输入需求描述"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="是否需要评审">
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
<ElOption
v-for="item in reviewRequiredOptions"
:key="item.value"
:label="item.label"
:value="item.value"
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<div class="requirement-operate-dialog__grid">
<div ref="leftColRef" class="requirement-operate-dialog__col-left">
<BusinessFormSection title="子需求信息">
<ElFormItem label="子需求名称" prop="title">
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入子需求名称" />
</ElFormItem>
<ElFormItem label="是否需要评审">
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
<ElOption
v-for="item in reviewRequiredOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="优先级" prop="priority">
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
<ElFormItem label="所需工时" prop="workHours">
<ElInputNumber
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="分类" prop="category">
<DictSelect v-model="model.category" :dict-code="categoryDictCode" filterable placeholder="请选择分类" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="优先级" prop="priority">
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ElSelect v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</ElCol>
</ElRow>
</ElFormItem>
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ElSelect v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</BusinessFormSection>
</div>
<div class="requirement-operate-dialog__col-right">
<BusinessFormSection title="需求内容">
<ElFormItem class="requirement-operate-dialog__desc-item">
<BusinessRichTextEditor
ref="richTextEditorRef"
v-model="model.description"
:height="editorHeight"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection title="附件">
<ElFormItem class="requirement-operate-dialog__attachment-item">
<BusinessAttachmentUploader
ref="attachmentUploaderRef"
v-model="model.attachments"
directory="requirement"
/>
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>
<style scoped>
.requirement-operate-dialog__grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
align-items: start;
}
.requirement-operate-dialog__col-left,
.requirement-operate-dialog__col-right {
min-width: 0;
}
.requirement-operate-dialog__col-right {
display: flex;
flex-direction: column;
gap: 16px;
}
.requirement-operate-dialog__desc-item,
.requirement-operate-dialog__attachment-item {
margin-bottom: 0;
}
@media (width <= 1024px) {
.requirement-operate-dialog__grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,4 +1,17 @@
import { markRaw } from 'vue';
import { transformRecordToOption } from '@/utils/common';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiCheckOutline from '~icons/mdi/check-outline';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiSync from '~icons/mdi/sync';
import IconMdiPowerSettingsNew from '~icons/mdi/power-settings-new';
import IconMdiShareVariant from '~icons/mdi/share-variant';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiGlasses from '~icons/mdi/glasses';
import IconMdiClose from '~icons/mdi/close';
import IconTablerSitemap from '~icons/tabler/sitemap';
import IconTablerCircleX from '~icons/tabler/circle-x';
import IconMaterialSymbolsDescriptionOutline from '~icons/material-symbols/description-outline';
export type RequirementStatusActionCode =
| 'claim_to_review'
@@ -34,6 +47,44 @@ export const requirementStatusActionRecord: Record<RequirementStatusActionCode,
close: '关闭'
};
/**
* 操作按钮图标映射
*
* 将操作类型映射到对应的 Iconify 图标组件
*/
export const ACTION_ICON_MAP: Record<string, object> = {
split: markRaw(IconTablerSitemap),
edit: markRaw(IconMdiPencilOutline),
claim_to_review: markRaw(IconMaterialSymbolsDescriptionOutline),
claim_to_dispatch: markRaw(IconMdiCheckOutline),
to_dispatch: markRaw(IconMdiGlasses),
dispatch: markRaw(IconMdiShareVariant),
accept: markRaw(IconMdiCheckCircleOutline),
reject: markRaw(IconMdiClose),
cancel: markRaw(IconTablerCircleX),
close: markRaw(IconMdiPowerSettingsNew),
delete: markRaw(IconMdiDeleteOutline)
};
/**
* 操作按钮颜色类型映射
*
* 审批/成功类操作 → success危险操作 → danger其他 → primary
*/
export const ACTION_TYPE_MAP: Record<string, 'primary' | 'success' | 'danger'> = {
split: 'primary',
edit: 'primary',
claim_to_review: 'primary',
claim_to_dispatch: 'primary',
to_dispatch: 'primary',
dispatch: 'primary',
accept: 'primary',
reject: 'danger',
cancel: 'danger',
close: 'danger',
delete: 'danger'
};
export function getRequirementStatusLabel(status: Api.Product.RequirementStatusCode) {
return requirementStatusRecord[status];
}

View File

@@ -424,6 +424,7 @@ watch(
:current-manager="currentManager"
:role-options="roleOptions"
:user-options="userOptions"
:disabled-user-ids="members.filter(member => member.status === 0).map(member => member.userId)"
@submit="handleSubmitMemberOperate"
/>
<MemberRemoveDialog

View File

@@ -95,7 +95,7 @@ watch(
<BusinessFormDialog
v-model="visible"
title="编辑基础信息"
preset="lg"
preset="sm"
:confirm-disabled="confirmDisabled"
@confirm="handleConfirm"
>
@@ -103,12 +103,42 @@ watch(
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElCol :span="24">
<ElFormItem label="产品编码">
<ElInput :model-value="baseInfo?.code || ''" readonly class="base-info-dialog__readonly-input" />
</ElFormItem>
</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>
<template #label>
<span class="business-form-label-with-tip">
@@ -131,36 +161,6 @@ watch(
/>
</ElFormItem>
</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">
<ElFormItem label="产品描述">
<ElInput

View File

@@ -3,6 +3,7 @@ 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 { getPreviousManagerRoleOptions, shouldRequireManagerHandover } from '../shared';
defineOptions({ name: 'MemberOperateDialog' });
@@ -15,6 +16,8 @@ interface Props {
currentManager: Api.Product.ProductMember | null;
roleOptions: Api.SystemManage.RoleSimple[];
userOptions: Api.SystemManage.UserSimple[];
/** 已是有效成员、需在下拉中禁选并标记"已添加"的 userId 集合 */
disabledUserIds?: readonly string[];
}
interface SubmitPayload {
@@ -28,7 +31,9 @@ interface Emits {
(e: 'submit', payload: SubmitPayload): void;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
disabledUserIds: () => []
});
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
@@ -136,21 +141,30 @@ watch(
</script>
<template>
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="lg" @confirm="handleConfirm">
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<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="12">
<ElCol :span="24">
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
<ElSelect v-model="model.userId" class="w-full" filterable placeholder="请选择成员用户">
<ElOption v-for="item in userOptions" :key="item.id" :label="item.nickname" :value="item.id" />
</ElSelect>
<BusinessUserSelect
v-model="model.userId"
:options="userOptions"
:disabled-user-ids="props.disabledUserIds"
disabled-label="已添加"
placeholder="请选择成员用户"
/>
</ElFormItem>
<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>
</ElCol>
<ElCol :span="12">
<ElCol :span="24">
<ElFormItem label="目标角色" prop="roleId">
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
<ElOption v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" />
@@ -201,3 +215,22 @@ watch(
</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>

View File

@@ -134,7 +134,7 @@ function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
:disabled="row.status !== 0 || row.managerFlag"
@click="emit('edit', row)"
>
调整角色
编辑
</ElButton>
<ElButton
v-auth="{ code: 'project:product:update', source: 'object' }"

View File

@@ -78,18 +78,6 @@ const productLifecycleActionCardMetaMap: Record<Api.Product.ProductStatusActionC
}
};
const productSettingErrorMessageMap: Record<string, string> = {
'1008001002': '产品名称已存在,请更换名称',
'1008001007': '当前产品状态不允许编辑基础信息',
'1008001008': '当前产品已暂停,基础信息仅支持查看,不可编辑。',
'1008001013': '请选择原产品经理交接后的角色',
'1008001014': '当前产品经理不能直接移出,请先完成经理交接',
'1008001015': '当前产品经理不能直接调整为非经理角色,请先完成经理转交',
'1008001004': '当前状态不支持该动作',
'1008001005': '当前动作必须填写原因',
'1008001006': '删除确认名称与当前产品名称不一致'
};
const productTeamTableHeaderHeight = 40;
const productTeamTableRowHeight = 40;
@@ -225,9 +213,3 @@ export function canManageProductTeam(context: ProductTeamManageContext) {
return loginUserId === currentManagerUserId;
}
export function getProductSettingErrorMessage(code: string | number | null | undefined, backendMessage: string) {
const normalizedCode = String(code || '');
return productSettingErrorMessageMap[normalizedCode] || backendMessage;
}

View File

@@ -0,0 +1,380 @@
<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 { getProjectStatusLabel, getProjectStatusTagType } 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)
}
],
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;
}
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>

View File

@@ -0,0 +1,317 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } 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 { fetchGetProductPage } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue';
defineOptions({ name: 'ProjectCreateBaseForm' });
export interface ProjectCreateBaseForm {
projectCode: string;
projectName: string;
directionCode: string;
projectType: string;
productId: string | null;
managerUserId: string | null;
plannedStartDate: string | null;
plannedEndDate: string | null;
projectDesc: string;
}
interface ProductOption {
id: string;
name: string;
directionCode: string;
}
interface Props {
managerUserOptions: Api.SystemManage.UserSimple[];
}
defineProps<Props>();
const model = defineModel<ProjectCreateBaseForm>('modelValue', { required: true });
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const productOptions = ref<ProductOption[]>([]);
const hasAssociatedProduct = computed(() => Boolean(model.value.productId));
const directionReadonly = computed(() => hasAssociatedProduct.value);
const selectedProductDirection = computed(() => {
if (!model.value.productId) {
return '';
}
return productOptions.value.find(p => p.id === model.value.productId)?.directionCode || '';
});
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 rules = computed(
() =>
({
projectName: [createRequiredRule('请输入项目名称')],
directionCode: [createRequiredRule('请选择项目方向')],
projectType: [createRequiredRule('请选择项目类型')],
managerUserId: [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[]>
);
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 || ''
}));
}
function onProductChange(newProductId: string | null) {
if (!newProductId) {
return;
}
const product = productOptions.value.find(p => p.id === newProductId);
if (product) {
model.value.directionCode = product.directionCode;
}
}
async function runValidate(): Promise<boolean> {
try {
await validate();
return true;
} catch {
return false;
}
}
onMounted(loadProductOptions);
defineExpose({ validate: runValidate });
</script>
<template>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="项目名称" prop="projectName">
<ElInput v-model="model.projectName" clearable maxlength="128" placeholder="请输入项目名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目编码" prop="projectCode">
<ElInput v-model="model.projectCode" clearable 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-create-base-form__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 label="所属产品" prop="productId">
<ElSelect
v-model="model.productId"
clearable
filterable
placeholder="选择所属产品(可选),选择后将锁定项目方向"
@change="onProductChange"
>
<ElOption v-for="item in productOptions" :key="item.id" :label="item.name" :value="item.id" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem 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-create-base-form__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-create-base-form__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>
</ElForm>
</template>
<style scoped>
:deep(.project-create-base-form__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-create-base-form__readonly-input .el-input__wrapper:hover),
:deep(.project-create-base-form__readonly-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.project-create-base-form__readonly-input .el-input__inner) {
color: rgb(51 65 85 / 96%);
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
:deep(.project-create-base-form__date-picker.el-date-editor.el-input) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue';
import { PROJECT_MANAGER_ROLE_CODE } from '@/constants/business';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
defineOptions({ name: 'ProjectCreateTeamMemberDialog' });
type OperateMode = 'create' | 'edit';
interface DraftMemberInput {
userId: string;
roleId: string;
remark: string;
}
interface Props {
mode: OperateMode;
initial: DraftMemberInput | null;
userOptions: Api.SystemManage.UserSimple[];
roleOptions: Api.SystemManage.RoleSimple[];
/** 已使用且不可选的 userId编辑模式应当排除当前行自身 */
disabledUserIds?: readonly string[];
}
interface Emits {
(e: 'submit', payload: DraftMemberInput): void;
}
const props = withDefaults(defineProps<Props>(), {
disabledUserIds: () => []
});
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', { default: false });
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const model = reactive<DraftMemberInput>({
userId: '',
roleId: '',
remark: ''
});
const dialogTitle = computed(() => (props.mode === 'create' ? '新增团队成员' : '调整成员角色'));
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
const rules = computed(
() =>
({
userId: props.mode === 'create' ? [createRequiredRule('请选择成员用户')] : [],
roleId: [createRequiredRule('请选择角色')]
}) satisfies Record<string, App.Global.FormRule[]>
);
function isManagerRole(role: Api.SystemManage.RoleSimple) {
return role.code === PROJECT_MANAGER_ROLE_CODE;
}
async function handleConfirm() {
await validate();
emit('submit', {
userId: model.userId,
roleId: model.roleId,
remark: model.remark.trim()
});
}
watch(visible, async value => {
if (!value) {
return;
}
model.userId = props.initial?.userId || '';
model.roleId = props.initial?.roleId || '';
model.remark = props.initial?.remark || '';
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">
<ElRow :gutter="16">
<ElCol :span="24">
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
<BusinessUserSelect
v-model="model.userId"
:options="userOptions"
:disabled-user-ids="disabledUserIds"
disabled-label="已添加"
placeholder="请选择成员用户"
/>
</ElFormItem>
<ElFormItem v-else label="成员用户">
<ElInput
:model-value="userLabelMap.get(String(model.userId)) || ''"
readonly
class="project-create-team-member-dialog__readonly-input"
placeholder="未获取到成员用户"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="目标角色" prop="roleId">
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
<ElOption
v-for="role in roleOptions"
:key="role.id"
:label="role.name"
:value="role.id"
:disabled="isManagerRole(role)"
>
<span>{{ role.name }}</span>
<span v-if="isManagerRole(role)" class="project-create-team-member-dialog__role-hint">
已由第 1 步指定
</span>
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="备注">
<ElInput
v-model="model.remark"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入备注"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.project-create-team-member-dialog__role-hint {
margin-left: 8px;
color: rgb(148 163 184 / 96%);
font-size: 12px;
}
:deep(.project-create-team-member-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-create-team-member-dialog__readonly-input .el-input__wrapper:hover),
:deep(.project-create-team-member-dialog__readonly-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.project-create-team-member-dialog__readonly-input .el-input__inner) {
color: rgb(51 65 85 / 96%);
-webkit-text-fill-color: rgb(51 65 85 / 96%);
cursor: default;
}
</style>

View File

@@ -0,0 +1,298 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { PROJECT_MANAGER_ROLE_CODE } from '@/constants/business';
import { fetchGetRoleSimpleList } from '@/service/api';
import { getProjectTeamTableHeight } from '@/views/project/project/setting/shared';
import ProjectCreateTeamMemberDialog from './project-create-team-member-dialog.vue';
import type { ProjectCreateBaseForm } from './project-create-base-form.vue';
defineOptions({ name: 'ProjectCreateTeamStep' });
interface DraftMember {
/** 客户端临时主键,仅用于 v-for 稳定 */
key: string;
userId: string;
roleId: string;
remark: string;
/** true 表示由项目经理自动派生的锁定行 */
locked: boolean;
}
interface Props {
baseInfo: ProjectCreateBaseForm;
userOptions: Api.SystemManage.UserSimple[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update:members', members: Api.Project.CreateProjectMemberParams[]): void;
}>();
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
const roleLoading = ref(false);
const managerRoleError = ref('');
const members = ref<DraftMember[]>([]);
const memberDialogVisible = ref(false);
const memberDialogMode = ref<'create' | 'edit'>('create');
const editingKey = ref<string | null>(null);
const teamTableHeight = getProjectTeamTableHeight(5);
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
const managerRole = computed(() => roleOptions.value.find(item => item.code === PROJECT_MANAGER_ROLE_CODE) ?? null);
// 弹框传入的禁选用户列表:新增时排除所有已选;编辑时排除自身以外的已选
const dialogDisabledUserIds = computed(() => {
return members.value
.filter(item => !editingKey.value || item.key !== editingKey.value)
.map(item => item.userId)
.filter(Boolean);
});
const dialogInitial = computed(() => {
if (memberDialogMode.value === 'create' || !editingKey.value) {
return null;
}
const target = members.value.find(item => item.key === editingKey.value);
if (!target) {
return null;
}
return { userId: target.userId, roleId: target.roleId, remark: target.remark };
});
function getUserNickname(userId: string) {
return userLabelMap.value.get(String(userId)) || userId;
}
function getRoleName(roleId: string) {
return roleOptions.value.find(item => item.id === roleId)?.name || '--';
}
function generateKey() {
return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
async function loadRoles() {
roleLoading.value = true;
managerRoleError.value = '';
const { data } = await fetchGetRoleSimpleList({ scopeType: 'object', objectType: 'project' });
roleLoading.value = false;
roleOptions.value = data ?? [];
if (!managerRole.value) {
managerRoleError.value = '未找到项目经理角色,请联系管理员';
return;
}
refreshManagerRow();
}
function refreshManagerRow() {
const managerUserId = props.baseInfo.managerUserId;
if (!managerUserId || !managerRole.value) {
members.value = members.value.filter(item => !item.locked);
emitMembers();
return;
}
const lockedIndex = members.value.findIndex(item => item.locked);
const lockedRow: DraftMember = {
key: lockedIndex >= 0 ? members.value[lockedIndex].key : generateKey(),
userId: managerUserId,
roleId: managerRole.value.id,
remark: lockedIndex >= 0 ? members.value[lockedIndex].remark : '',
locked: true
};
if (lockedIndex >= 0) {
members.value[lockedIndex] = lockedRow;
} else {
members.value = [lockedRow, ...members.value];
}
emitMembers();
}
function openCreate() {
memberDialogMode.value = 'create';
editingKey.value = null;
memberDialogVisible.value = true;
}
function openEdit(row: DraftMember) {
memberDialogMode.value = 'edit';
editingKey.value = row.key;
memberDialogVisible.value = true;
}
function removeMember(key: string) {
members.value = members.value.filter(item => item.key !== key);
emitMembers();
}
function handleMemberSubmit(payload: { userId: string; roleId: string; remark: string }) {
if (memberDialogMode.value === 'create') {
members.value.push({
key: generateKey(),
userId: payload.userId,
roleId: payload.roleId,
remark: payload.remark,
locked: false
});
} else if (editingKey.value) {
const idx = members.value.findIndex(item => item.key === editingKey.value);
if (idx >= 0) {
members.value[idx] = {
...members.value[idx],
roleId: payload.roleId,
remark: payload.remark
};
}
}
memberDialogVisible.value = false;
emitMembers();
}
function emitMembers() {
emit(
'update:members',
members.value.map(item => ({
userId: item.userId,
roleId: item.roleId,
remark: item.remark.trim() || null,
previousManagerUserId: null,
previousManagerRoleId: null
}))
);
}
async function runValidate(): Promise<boolean> {
if (managerRoleError.value) {
window.$message?.error(managerRoleError.value);
return false;
}
for (const item of members.value) {
if (!item.userId || !item.roleId) {
window.$message?.error('请补全所有成员的用户和角色');
return false;
}
}
const userIdSet = new Set<string>();
for (const item of members.value) {
if (userIdSet.has(item.userId)) {
window.$message?.error(`成员「${getUserNickname(item.userId)}」重复,请检查`);
return false;
}
userIdSet.add(item.userId);
}
return true;
}
onMounted(loadRoles);
watch(
() => props.baseInfo.managerUserId,
() => {
if (!managerRoleError.value && managerRole.value) {
refreshManagerRow();
}
}
);
defineExpose({ validate: runValidate });
</script>
<template>
<div v-loading="roleLoading" class="team-step">
<div class="team-step__toolbar">
<ElButton type="primary" plain :disabled="Boolean(managerRoleError)" @click="openCreate">新增成员</ElButton>
</div>
<ElAlert
v-if="managerRoleError"
:title="managerRoleError"
type="error"
:closable="false"
show-icon
class="team-step__alert"
/>
<ElTable :data="members" :height="teamTableHeight" border row-key="key" empty-text="点击右上角新增成员添加">
<ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn label="成员姓名" min-width="120">
<template #default="{ row }">
{{ getUserNickname(row.userId) }}
</template>
</ElTableColumn>
<ElTableColumn label="当前角色" min-width="140">
<template #default="{ row }">
{{ getRoleName(row.roleId) }}
</template>
</ElTableColumn>
<ElTableColumn label="备注" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
{{ row.remark || '--' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<div class="team-step__actions">
<ElButton link type="primary" :disabled="row.locked" @click="openEdit(row)">编辑</ElButton>
<ElButton link type="danger" :disabled="row.locked" @click="removeMember(row.key)">移除</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
<ProjectCreateTeamMemberDialog
v-model:visible="memberDialogVisible"
:mode="memberDialogMode"
:initial="dialogInitial"
:user-options="userOptions"
:role-options="roleOptions"
:disabled-user-ids="dialogDisabledUserIds"
@submit="handleMemberSubmit"
/>
</div>
</template>
<style scoped>
.team-step {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.team-step__toolbar {
display: flex;
justify-content: flex-end;
}
.team-step__alert {
margin: 0;
}
.team-step__actions {
display: inline-flex;
align-items: center;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,682 @@
<script setup lang="tsx">
import { computed, nextTick, ref, watch } from 'vue';
import { ElCol, ElDatePicker, ElFormItem, ElInput, ElRow } from 'element-plus';
import dayjs from 'dayjs';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchCreateProjectWithTeam, 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 DictSelect from '@/components/custom/dict-select.vue';
import ProjectCreateBaseForm, {
type ProjectCreateBaseForm as ProjectCreateBaseFormModel
} from './project-create-base-form.vue';
import ProjectCreateTeamStep from './project-create-team-step.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 EditModel {
projectCode: string;
projectName: string;
directionCode: string;
projectType: string;
productId: string | null;
managerUserId: string | null;
plannedStartDate: string | null;
plannedEndDate: string | null;
projectDesc: string;
}
const { formRef: editFormRef, validate: editValidate } = useForm();
const { createRequiredRule } = useFormRules();
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const editModel = ref<EditModel>(createEditModel());
const editLoading = ref(false);
const submitting = ref(false);
const isEditMode = computed(() => Boolean(props.rowData?.id));
const dialogTitle = computed(() => (isEditMode.value ? '编辑项目' : '新增项目'));
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 = editModel.value.managerUserId;
if (!managerUserId) {
return '';
}
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
});
const hasAssociatedProduct = computed(() => Boolean(editModel.value.productId));
const directionReadonly = computed(() => hasAssociatedProduct.value);
const selectedProductDirection = computed(() => {
if (!editModel.value.productId) {
return '';
}
return productOptions.value.find(p => p.id === editModel.value.productId)?.directionCode || '';
});
const effectiveDirectionCode = computed({
get: () => {
if (hasAssociatedProduct.value) {
return selectedProductDirection.value || editModel.value.directionCode;
}
return editModel.value.directionCode;
},
set: (val: string) => {
if (!hasAssociatedProduct.value) {
editModel.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(editModel.value.plannedStartDate);
if (!startDate) {
startDate = new Date();
editModel.value.plannedStartDate = dayjs(startDate).format('YYYY-MM-DD');
window.$message?.info('未选择计划开始日期,已按今日为基准计算');
nextTick(() => editFormRef.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 editRules = {
projectName: [createRequiredRule('请输入项目名称')],
directionCode: [createRequiredRule('请选择项目方向')],
projectType: [createRequiredRule('请选择项目类型')],
plannedStartDate: [createRequiredRule('请选择计划开始日期')],
plannedEndDate: [
createRequiredRule('请选择计划结束日期'),
{
validator: (_rule, value: string | null, callback) => {
if (!isPlannedDateRangeValid(editModel.value.plannedStartDate, value)) {
callback(new Error('计划结束日期不能早于计划开始日期'));
return;
}
callback();
},
trigger: 'change'
}
]
} satisfies Record<string, App.Global.FormRule[]>;
function createEditModel(): EditModel {
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 || ''
}));
}
async function handleEditSubmit() {
await editValidate();
if (!props.rowData?.id) {
return;
}
const managerUserId = editModel.value.managerUserId;
if (!managerUserId) {
return;
}
const finalDirectionCode = hasAssociatedProduct.value
? selectedProductDirection.value || editModel.value.directionCode
: editModel.value.directionCode;
submitting.value = true;
const { error } = await fetchUpdateProject({
id: props.rowData.id,
projectCode: getNullableText(editModel.value.projectCode),
projectName: editModel.value.projectName.trim(),
directionCode: finalDirectionCode,
projectType: editModel.value.projectType,
productId: editModel.value.productId,
managerUserId,
plannedStartDate: editModel.value.plannedStartDate,
plannedEndDate: editModel.value.plannedEndDate,
actualStartDate: props.rowData.actualStartDate || null,
actualEndDate: props.rowData.actualEndDate || null,
projectDesc: getNullableText(editModel.value.projectDesc)
});
submitting.value = false;
if (error) {
return;
}
window.$message?.success('项目编辑成功');
closeDialog();
emit('submitted', props.rowData.id);
}
// === 新增模式(两步向导) ===
const baseFormRef = ref<InstanceType<typeof ProjectCreateBaseForm> | null>(null);
const teamStepRef = ref<InstanceType<typeof ProjectCreateTeamStep> | null>(null);
const currentStep = ref<1 | 2>(1);
const createBaseModel = ref<ProjectCreateBaseFormModel>(createBaseInfo());
const draftMembers = ref<Api.Project.CreateProjectMemberParams[]>([]);
function createBaseInfo(): ProjectCreateBaseFormModel {
return {
projectCode: '',
projectName: '',
directionCode: '',
projectType: '',
productId: null,
managerUserId: null,
plannedStartDate: null,
plannedEndDate: null,
projectDesc: ''
};
}
async function goNext() {
const valid = await baseFormRef.value?.validate();
if (!valid) {
return;
}
currentStep.value = 2;
}
function goPrev() {
currentStep.value = 1;
}
async function handleCreateSubmit() {
const baseValid = await baseFormRef.value?.validate();
if (!baseValid) {
currentStep.value = 1;
return;
}
const teamValid = await teamStepRef.value?.validate();
if (!teamValid) {
return;
}
submitting.value = true;
const payload: Api.Project.CreateProjectWithTeamParams = {
project: {
projectCode: getNullableText(createBaseModel.value.projectCode),
projectName: createBaseModel.value.projectName.trim(),
directionCode: createBaseModel.value.directionCode,
projectType: createBaseModel.value.projectType,
productId: createBaseModel.value.productId,
managerUserId: createBaseModel.value.managerUserId as string,
plannedStartDate: createBaseModel.value.plannedStartDate,
plannedEndDate: createBaseModel.value.plannedEndDate,
projectDesc: getNullableText(createBaseModel.value.projectDesc)
},
members: draftMembers.value
};
const { error, data } = await fetchCreateProjectWithTeam(payload);
submitting.value = false;
if (error) {
return;
}
window.$message?.success('项目新增成功');
closeDialog();
emit('submitted', data);
}
// === 公共:弹框可见性变化时重置 / 加载数据 ===
watch(visible, async value => {
if (!value) {
return;
}
submitting.value = false;
currentStep.value = 1;
if (!isEditMode.value || !props.rowData?.id) {
editModel.value = createEditModel();
createBaseModel.value = createBaseInfo();
draftMembers.value = [];
await nextTick();
editFormRef.value?.clearValidate();
return;
}
editLoading.value = true;
// 编辑模式继续在主弹框拉产品选项(用于回显所属产品名称)
await loadProductOptions();
editLoading.value = false;
editModel.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();
editFormRef.value?.clearValidate();
});
</script>
<template>
<!-- 编辑模式单步表单与改造前一致 -->
<BusinessFormDialog
v-if="isEditMode"
v-model="visible"
:title="dialogTitle"
preset="md"
:loading="editLoading"
:confirm-loading="submitting"
@confirm="handleEditSubmit"
>
<ElForm ref="editFormRef" :model="editModel" :rules="editRules" label-position="top">
<BusinessFormSection title="项目信息">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="项目编码" prop="projectCode">
<ElInput
:model-value="editModel.projectCode"
readonly
class="project-operate-dialog__readonly-input"
placeholder="未获取到项目编码"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目名称" prop="projectName">
<ElInput v-model="editModel.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="editModel.projectType"
:dict-code="RDMS_PROJECT_TYPE_DICT_CODE"
filterable
placeholder="请选择项目类型"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="所属产品">
<ElInput
:model-value="
productOptions.find(p => p.id === editModel.productId)?.name ||
props.rowData?.productName ||
editModel.productId ||
'未关联产品'
"
readonly
class="project-operate-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="managerDisplayName"
readonly
class="project-operate-dialog__readonly-input"
placeholder="未获取到项目经理"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="计划开始日期" prop="plannedStartDate">
<ElDatePicker
v-model="editModel.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="editModel.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="editModel.projectDesc"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入项目说明"
/>
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
</ElForm>
</BusinessFormDialog>
<!-- 新增模式:两步向导(复合内容特例,自定义 ElDialog 880px -->
<ElDialog
v-else
v-model="visible"
class="project-create-dialog"
:title="dialogTitle"
:close-on-click-modal="false"
destroy-on-close
align-center
width="760px"
>
<div class="project-create-dialog__stepbar">
<div class="project-create-dialog__step" :class="{ 'is-active': currentStep === 1, 'is-done': currentStep > 1 }">
<span class="project-create-dialog__step-index">1</span>
<span class="project-create-dialog__step-text">
<strong>基础资料</strong>
<small>定义项目身份和负责人</small>
</span>
</div>
<div class="project-create-dialog__step" :class="{ 'is-active': currentStep === 2 }">
<span class="project-create-dialog__step-index">2</span>
<span class="project-create-dialog__step-text">
<strong>初始化团队</strong>
<small>配置对象域成员角色</small>
</span>
</div>
</div>
<div class="project-create-dialog__body">
<div v-show="currentStep === 1" class="project-create-dialog__panel">
<ProjectCreateBaseForm ref="baseFormRef" v-model="createBaseModel" :manager-user-options="managerUserOptions" />
</div>
<div v-show="currentStep === 2" class="project-create-dialog__panel">
<ProjectCreateTeamStep
ref="teamStepRef"
:base-info="createBaseModel"
:user-options="managerUserOptions"
@update:members="draftMembers = $event"
/>
</div>
</div>
<template #footer>
<div class="project-create-dialog__footer">
<span class="project-create-dialog__footer-meta">第 {{ currentStep }} 步,共 2 步</span>
<ElSpace :size="10">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton v-if="currentStep === 2" @click="goPrev">上一步</ElButton>
<ElButton v-if="currentStep === 1" type="primary" @click="goNext">下一步</ElButton>
<ElButton v-if="currentStep === 2" type="primary" :loading="submitting" @click="handleCreateSubmit">
确定
</ElButton>
</ElSpace>
</div>
</template>
</ElDialog>
</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%;
}
.project-create-dialog :deep(.el-dialog__body) {
display: flex;
flex-direction: column;
padding: 0;
}
.project-create-dialog__stepbar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 14px 24px;
border-bottom: 1px solid rgb(229 233 242 / 96%);
background: #fbfcfe;
}
.project-create-dialog__step {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.project-create-dialog__step-index {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 28px;
height: 28px;
border: 1px solid rgb(215 222 235 / 96%);
border-radius: 999px;
background: #fff;
color: rgb(119 129 150 / 96%);
font-size: 13px;
font-weight: 650;
}
.project-create-dialog__step.is-active .project-create-dialog__step-index,
.project-create-dialog__step.is-done .project-create-dialog__step-index {
border-color: var(--el-color-primary);
background: var(--el-color-primary);
color: #fff;
}
.project-create-dialog__step-text {
min-width: 0;
}
.project-create-dialog__step-text strong {
display: block;
font-size: 14px;
font-weight: 650;
}
.project-create-dialog__step-text small {
display: block;
margin-top: 2px;
color: rgb(119 129 150 / 96%);
font-size: 12px;
}
.project-create-dialog__body {
min-height: 0;
max-height: min(560px, calc(100vh - 240px));
overflow: auto;
}
.project-create-dialog__panel {
padding: 24px;
}
.project-create-dialog__footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.project-create-dialog__footer-meta {
color: rgb(119 129 150 / 96%);
font-size: 13px;
}
</style>

View 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>

View 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>

View File

@@ -0,0 +1,190 @@
import type { ComputedRef } from 'vue';
import { ElMessageBox } from 'element-plus';
import { fetchGetProjectTask, fetchGetProjectTaskPage, fetchGetProjectTaskWorklogPage } from '@/service/api/project';
type ProjectTask = Api.Project.ProjectTask;
type TaskAction = Api.Project.LifecycleAction<Api.Project.ProjectTaskActionCode>;
export interface CascadeTriggerPayload {
task: ProjectTask;
submittedProgress: number;
}
export interface UseTaskCompletionCascadeOptions {
projectId: ComputedRef<string>;
executionId: ComputedRef<string>;
/** 由调用方提供:打开 StatusActionDialog 的钩子composable 不持有 dialog 实例 */
openStatusActionDialog: (task: ProjectTask, action: TaskAction, fromCascade: boolean) => void;
/** 从 task 的 availableActions 里找出"完成"动作;找不到返回 null */
resolveCompleteAction: (task: ProjectTask) => TaskAction | null;
}
interface AssigneeProgress {
userId: string;
nickname: string;
/** null = 该协办人从未填过 worklog */
latestProgress: number | null;
}
const TASK_COMPLETED_STATUS_CODE: Api.Project.ProjectTaskStatusCode = 'completed';
const NO_WARNING_CONFIRM_MESSAGE = '任务进度已达 100%,是否完成当前任务?';
const PARENT_CONFIRM_MESSAGE = '所有子任务已完成,是否完成父任务?';
function buildAssigneeWarningMessage(under100: AssigneeProgress[]): string {
const names = under100.map(item => item.nickname).join('、');
return `存在协办人进度未达 100%${names}),是否仍要完成当前任务?`;
}
export function useTaskCompletionCascade(options: UseTaskCompletionCascadeOptions) {
/**
* worklog 提交后命中 owner + progress=100 + 非删除时由 workspace 调用。
* 内部:拉协办人进度 → 构造 confirm 文案 → 用户确认 → 打开完成弹层
*/
async function triggerAfterWorklog(payload: CascadeTriggerPayload): Promise<void> {
const { task } = payload;
const completeAction = options.resolveCompleteAction(task);
if (!completeAction) {
window.$message?.warning('当前任务暂无可用完成动作');
return;
}
const under100 = await loadAssigneesUnder100(task);
const message = under100.length > 0 ? buildAssigneeWarningMessage(under100) : NO_WARNING_CONFIRM_MESSAGE;
const messageBoxType = under100.length > 0 ? 'warning' : 'info';
try {
await ElMessageBox.confirm(message, '完成确认', {
confirmButtonText: '完成任务',
cancelButtonText: '仅保留工时',
type: messageBoxType
});
} catch {
return;
}
options.openStatusActionDialog(task, completeAction, true);
}
/**
* 由 workspace 在 handleStatusSubmit 完成成功 + pendingCascade=true 时调用。
* 内部:判断当前任务有无父任务 → 拉同级子任务 → 全 completed → 拉父任务详情 → owner 一致 → 弹完成提示。
*/
async function onTaskCompleted(completedTask: ProjectTask): Promise<void> {
if (!completedTask.parentTaskId) {
return;
}
const siblings = await loadSiblings(completedTask.parentTaskId);
if (siblings === null) {
window.$message?.warning('父任务级联检查失败');
return;
}
const allCompleted = siblings.every(item => item.statusCode === TASK_COMPLETED_STATUS_CODE);
if (!allCompleted) {
return;
}
const parent = await fetchTaskDetail(completedTask.parentTaskId);
if (!parent) {
window.$message?.warning('父任务级联检查失败');
return;
}
if (parent.ownerId !== completedTask.ownerId) {
// owner 不一致:本期不做主动通知,留待通知功能上线后由父任务负责人决策
return;
}
const completeAction = options.resolveCompleteAction(parent);
if (!completeAction) {
window.$message?.warning('父任务暂无可用完成动作');
return;
}
try {
await ElMessageBox.confirm(PARENT_CONFIRM_MESSAGE, '完成父任务确认', {
confirmButtonText: '完成父任务',
cancelButtonText: '暂不完成',
type: 'info'
});
} catch {
return;
}
options.openStatusActionDialog(parent, completeAction, false);
}
/** 拉协办人进度并筛出 < 100% 的项;接口失败时返回空数组(降级到普通文案) */
async function loadAssigneesUnder100(task: ProjectTask): Promise<AssigneeProgress[]> {
const assignees = task.assignees ?? [];
if (assignees.length === 0) {
return [];
}
try {
const result = await fetchGetProjectTaskWorklogPage(options.projectId.value, options.executionId.value, task.id, {
pageNo: 1,
pageSize: -1
});
if (result.error || !result.data) {
return [];
}
// worklog 接口默认按 endDate DESC 排序,相同 userId 第一次出现的即为该用户最新一条
const latestByUser = new Map<string, number>();
for (const log of result.data.list) {
if (!latestByUser.has(log.userId)) {
latestByUser.set(log.userId, log.progressRate);
}
}
const under100: AssigneeProgress[] = [];
for (const assignee of assignees) {
const progress = latestByUser.get(assignee.userId);
if (progress === undefined) {
under100.push({ userId: assignee.userId, nickname: assignee.nickname, latestProgress: null });
} else if (progress < 100) {
under100.push({ userId: assignee.userId, nickname: assignee.nickname, latestProgress: progress });
}
}
return under100;
} catch {
return [];
}
}
/** 拉父级下所有子任务;接口失败返回 null与"空列表"区分,由调用方降级处理) */
async function loadSiblings(parentTaskId: string): Promise<ProjectTask[] | null> {
try {
const result = await fetchGetProjectTaskPage(options.projectId.value, options.executionId.value, {
pageNo: 1,
pageSize: -1,
parentTaskId
});
if (result.error || !result.data) {
return null;
}
return result.data.list;
} catch {
return null;
}
}
/** 拉父任务详情;失败返回 null */
async function fetchTaskDetail(taskId: string): Promise<ProjectTask | null> {
try {
const result = await fetchGetProjectTask(options.projectId.value, options.executionId.value, taskId);
if (result.error || !result.data) {
return null;
}
return result.data;
} catch {
return null;
}
}
return {
triggerAfterWorklog,
onTaskCompleted
};
}

View File

@@ -0,0 +1,132 @@
import { computed } from 'vue';
import { useAuthStore } from '@/store/modules/auth';
import { useObjectContextStore } from '@/store/modules/object-context';
/**
* 任务 / 执行按钮可见度集中判定
*
* 关键领域规则:
* - 任务负责人本人不能编辑 / 删除自己负责的任务(增删改归上级 / 项目负责人裁决)
* - 本人能做的:状态推进(含 cancel "退出"任务)、加协办人、在自己任务下新增子任务
* - 执行负责人对子任务无编辑 / 删除权(子任务归父任务 owner 管)
* - 父任务负责人能改 / 删子任务,但不能给子任务加协办人 / 建孙任务 / 推进状态
*
* 权限码来源:`project:*` / `project:execution:*` / `project:task:*` 是**对象域权限码**
* 挂在项目对象上下文里(项目负责人 / 项目协作者等角色),从 objectContextStore.buttonCodes 取,
* **不在** authStore.userInfo.buttons那是全局站点级权限
*/
export function useTaskPermissions() {
const authStore = useAuthStore();
const objectContextStore = useObjectContextStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const buttonCodeSet = computed(() => new Set(objectContextStore.buttonCodes));
function hasPermission(code: string): boolean {
return buttonCodeSet.value.has(code);
}
/**
* 判定对象是否处于可编辑/可操作状态。
*
* 按按钮可见度矩阵spec §4.2 / §4.3 注释 `allowEdit === true 即 pending / active 状态`
* 可编辑状态严格 = `pending` OR `active`**不含 paused / completed / cancelled**。
*
* 不用 `record.allowEdit === true`:列表 VO 后端不一定下发该字段,
* 经 `normalizeProjectExecution` 的 `Boolean(undefined) === false` 会让列表所有行误判为不可编辑。
* 直接读 `statusCode` 字段(列表 / 详情 VO 都必下发,状态机核心字段)。
*/
function isMutable(record: { statusCode: string }): boolean {
return record.statusCode === 'pending' || record.statusCode === 'active';
}
// —— 执行侧 ——
function canEditExecution(execution: Api.Project.ProjectExecution): boolean {
return (
isMutable(execution) && (hasPermission('project:execution:update') || currentUserId.value === execution.ownerId)
);
}
function canDeleteExecution(execution: Api.Project.ProjectExecution): boolean {
return execution.statusCode === 'pending' && hasPermission('project:execution:delete');
}
function canChangeExecutionOwner(execution: Api.Project.ProjectExecution): boolean {
return isMutable(execution) && hasPermission('project:execution:owner');
}
function canManageExecutionAssignee(execution: Api.Project.ProjectExecution): boolean {
return (
isMutable(execution) && (hasPermission('project:execution:assignee') || currentUserId.value === execution.ownerId)
);
}
/**
* 协办人入口按钮(列表面板)是否显示。
*
* 仅"项目负责人 / 项目创建人 / 执行负责人"可见,普通登录用户隐藏(去"查看"对话框里看团队)。
* 不含状态前置——任何状态下身份匹配都给入口dialog 内的"加 / 移 / 换 owner"写按钮自己再判 isMutable。
*/
function canSeeExecutionAssigneeEntry(execution: Api.Project.ProjectExecution): boolean {
return (
hasPermission('project:execution:assignee') ||
hasPermission('project:execution:owner') ||
currentUserId.value === execution.ownerId
);
}
// —— 任务侧(按一级 / 子任务分流) ——
function isTopLevelTask(task: Api.Project.ProjectTask): boolean {
return task.parentTaskId === null || task.parentTaskId === undefined;
}
function canEditTask(task: Api.Project.ProjectTask): boolean {
if (!isMutable(task)) return false;
if (hasPermission('project:task:update')) return true;
return isTopLevelTask(task)
? currentUserId.value === task.executionOwnerId
: currentUserId.value === task.parentTaskOwnerId;
}
function canDeleteTask(task: Api.Project.ProjectTask): boolean {
if (task.statusCode !== 'pending') return false;
if (hasPermission('project:task:delete')) return true;
return isTopLevelTask(task)
? currentUserId.value === task.executionOwnerId
: currentUserId.value === task.parentTaskOwnerId;
}
function canCreateTopLevelTask(execution: Api.Project.ProjectExecution): boolean {
return isMutable(execution) && (hasPermission('project:task:create') || currentUserId.value === execution.ownerId);
}
function canCreateSubTask(task: Api.Project.ProjectTask): boolean {
return isMutable(task) && (hasPermission('project:task:create') || currentUserId.value === task.ownerId);
}
function canManageTaskAssignee(task: Api.Project.ProjectTask): boolean {
return isMutable(task) && (hasPermission('project:task:assignee') || currentUserId.value === task.ownerId);
}
function canReportTaskWorklog(): boolean {
return hasPermission('project:task:worklog');
}
return {
// execution
canEditExecution,
canDeleteExecution,
canChangeExecutionOwner,
canManageExecutionAssignee,
canSeeExecutionAssigneeEntry,
// task
canEditTask,
canDeleteTask,
canCreateTopLevelTask,
canCreateSubTask,
canManageTaskAssignee,
canReportTaskWorklog
};
}

View File

@@ -0,0 +1,515 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import {
fetchChangeProjectExecutionOwner,
fetchChangeProjectExecutionStatus,
fetchCreateProjectExecution,
fetchCreateProjectExecutionAssignee,
fetchDeleteProjectExecution,
fetchGetProjectExecution,
fetchGetProjectExecutionAssignees,
fetchGetProjectExecutionPage,
fetchGetProjectExecutionStatusBoard,
fetchGetProjectMembers,
fetchInactiveProjectExecutionAssignee,
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 { useTaskPermissions } from './composables/use-task-permissions';
import ExecutionListPanel from './modules/execution-list-panel.vue';
import ExecutionAssigneeDialog from './modules/execution-assignee-dialog.vue';
import ExecutionOperateDialog from './modules/execution-operate-dialog.vue';
import ObjectDeleteDialog from './modules/object-delete-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 assigneeDialogVisible = ref(false);
const statusVisible = ref(false);
const editingExecution = ref<Api.Project.ProjectExecution | null>(null);
const editingExecutionAssignees = ref<Api.Project.ExecutionAssignee[]>([]);
const statusExecution = ref<Api.Project.ProjectExecution | null>(null);
const statusAction = ref<ExecutionAction | null>(null);
const executionAssignees = ref<Api.Project.ExecutionAssignee[]>([]);
const assigneeLoading = 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 deleteDialogVisible = ref(false);
const { canCreateTopLevelTask } = useTaskPermissions();
// 第 2 类:项目内 RBAC 权限码 OR 执行 owner 字段身份;含 isMutable 状态前置
// 选中的执行 = null 时按钮隐藏(无对象上下文可判)
const canCreateTask = computed(() =>
selectedExecution.value ? canCreateTopLevelTask(selectedExecution.value) : false
);
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;
editingExecutionAssignees.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 assigneeResult = await fetchGetProjectExecutionAssignees(projectId.value, detail.id);
editingExecutionAssignees.value = assigneeResult.error || !assigneeResult.data ? [] : assigneeResult.data;
operateMode.value = 'edit';
operateVisible.value = true;
}
async function openViewExecution(row: Api.Project.ProjectExecution) {
const detail = await getExecutionDetail(row);
editingExecution.value = detail;
const assigneeResult = await fetchGetProjectExecutionAssignees(projectId.value, detail.id);
editingExecutionAssignees.value = assigneeResult.error || !assigneeResult.data ? [] : assigneeResult.data;
operateMode.value = 'view';
operateVisible.value = true;
}
async function openMemberDialog(row: Api.Project.ProjectExecution) {
selectedExecution.value = await getExecutionDetail(row);
assigneeDialogVisible.value = true;
await loadExecutionAssignees(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 loadExecutionAssignees(executionId: string) {
if (!projectId.value) {
executionAssignees.value = [];
return;
}
assigneeLoading.value = true;
try {
const { error, data: assignees } = await fetchGetProjectExecutionAssignees(projectId.value, executionId);
executionAssignees.value = error || !assignees ? [] : assignees;
} finally {
assigneeLoading.value = false;
}
}
async function handleExecutionSubmit(payload: Api.Project.SaveProjectExecutionParams) {
if (!projectId.value) {
return;
}
const result = editingExecution.value
? await fetchUpdateProjectExecution(projectId.value, editingExecution.value.id, {
executionName: payload.executionName,
executionType: payload.executionType,
projectRequirementId: payload.projectRequirementId,
plannedStartDate: payload.plannedStartDate,
plannedEndDate: payload.plannedEndDate,
executionDesc: payload.executionDesc
})
: 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 handleAddExecutionAssignee(payload: Api.Project.CreateExecutionAssigneeParams) {
if (!projectId.value || !selectedExecution.value) {
return;
}
const result = await fetchCreateProjectExecutionAssignee(projectId.value, selectedExecution.value.id, payload);
if (!result.error) {
await loadExecutionAssignees(selectedExecution.value.id);
}
}
async function handleInactiveExecutionAssignee(
assignee: Api.Project.ExecutionAssignee,
payload: Api.Project.InactiveExecutionAssigneeParams
) {
if (!projectId.value || !selectedExecution.value) {
return;
}
const result = await fetchInactiveProjectExecutionAssignee(projectId.value, selectedExecution.value.id, {
assigneeId: assignee.id,
data: payload
});
if (!result.error) {
await loadExecutionAssignees(selectedExecution.value.id);
}
}
function handleDeleteExecution(row: Api.Project.ProjectExecution) {
selectedExecution.value = row;
deleteDialogVisible.value = true;
}
async function confirmDeleteExecution(payload: { name: string; confirmText: string; reason: string }) {
if (!projectId.value || !selectedExecution.value) return;
const { error } = await fetchDeleteProjectExecution(projectId.value, selectedExecution.value.id, {
executionName: payload.name,
confirmText: payload.confirmText,
reason: payload.reason
});
if (error) return;
window.$message?.success('删除成功');
deleteDialogVisible.value = false;
selectedExecution.value = null;
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
}
async function handleExecutionChangedByTask() {
if (!selectedExecution.value) {
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
return;
}
const latestExecution = await getExecutionDetail(selectedExecution.value);
selectedExecution.value = latestExecution;
if (selectedStatus.value && latestExecution.statusCode !== selectedStatus.value) {
selectedStatus.value = latestExecution.statusCode;
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
return;
}
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
}
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"
@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"
:can-create="canCreateTask"
@execution-changed="handleExecutionChangedByTask"
/>
<ExecutionOperateDialog
v-model:visible="operateVisible"
:mode="operateMode"
:row-data="editingExecution"
:user-options="projectMemberOptions"
:current-assignees="editingExecutionAssignees"
@submit="handleExecutionSubmit"
/>
<ExecutionAssigneeDialog
v-model:visible="assigneeDialogVisible"
:execution="selectedExecution"
:assignees="executionAssignees"
:user-options="projectMemberOptions"
:loading="assigneeLoading"
@add="handleAddExecutionAssignee"
@inactive="handleInactiveExecutionAssignee"
@change-owner="handleChangeOwner"
/>
<StatusActionDialog
v-model:visible="statusVisible"
:title="statusActionTitle"
:action="statusAction"
@submit="handleExecutionStatusSubmit"
/>
<ObjectDeleteDialog
v-model:visible="deleteDialogVisible"
object-type="execution"
:object-name="selectedExecution?.executionName ?? ''"
:on-confirm="confirmDeleteExecution"
/>
</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>

View 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
};
}

View File

@@ -0,0 +1,354 @@
<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, isActiveExecutionAssignee, withVirtualOwnerAssignee } from '../shared';
defineOptions({ name: 'ProjectExecutionAssigneeCurrentPanel' });
interface Props {
execution: Api.Project.ProjectExecution | null;
assignees: Api.Project.ExecutionAssignee[];
userOptions: Api.SystemManage.UserSimple[];
loading: boolean;
canManageAssignee: boolean;
canChangeOwner: boolean;
}
interface Emits {
(e: 'add', payload: Api.Project.CreateExecutionAssigneeParams): void;
(e: 'inactive', assignee: Api.Project.ExecutionAssignee, payload: Api.Project.InactiveExecutionAssigneeParams): void;
(e: 'change-owner', payload: Api.Project.ChangeExecutionOwnerParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const newAssigneeId = ref('');
const PAGE_SIZE = 5;
const currentPage = ref(1);
const displayAssignees = computed<Api.Project.ExecutionAssignee[]>(() => {
const ownerId = props.execution?.ownerId;
if (!ownerId) {
return props.assignees;
}
const ownerNickname =
props.execution?.ownerNickname?.trim() ||
props.userOptions.find(item => item.id === ownerId)?.nickname?.trim() ||
ownerId;
return withVirtualOwnerAssignee(props.assignees, ownerId, ownerNickname, props.execution?.id ?? '');
});
const pagedAssignees = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE;
return displayAssignees.value.slice(start, start + PAGE_SIZE);
});
watch(
() => displayAssignees.value.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.ExecutionAssignee | 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.ExecutionAssignee | 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.ExecutionAssignee) {
return Boolean(currentOwnerId.value) && member.userId === currentOwnerId.value;
}
function isActiveAssignee(assignee: Api.Project.ExecutionAssignee) {
return isActiveExecutionAssignee(assignee);
}
const activeAssigneeUserIds = computed(() => {
const list = props.assignees.filter(item => isActiveExecutionAssignee(item)).map(item => item.userId);
const ownerId = props.execution?.ownerId;
// 负责人无论是否实际入库为协办人,都不允许再被加为协办人
if (ownerId && !list.includes(ownerId)) {
list.push(ownerId);
}
return list;
});
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 getAssigneeIndex(index: number) {
return (currentPage.value - 1) * PAGE_SIZE + index + 1;
}
function getAssigneeDisplayName(assignee: Api.Project.ExecutionAssignee | null) {
if (!assignee) return '';
return assignee.userNickname?.trim() || userNicknameMap.value.get(assignee.userId) || assignee.userId || '--';
}
function buildAssigneeActions(row: Api.Project.ExecutionAssignee): BusinessTableAction[] {
const actions: BusinessTableAction[] = [];
if (props.canChangeOwner) {
actions.push({
key: 'set-owner',
label: '设为负责人',
buttonType: 'primary',
onClick: () => openOwner(row)
});
}
if (props.canManageAssignee) {
actions.push({
key: 'inactive',
label: '失效',
buttonType: 'danger',
onClick: () => openInactive(row)
});
}
return actions;
}
function handleAdd() {
if (!newAssigneeId.value) {
window.$message?.warning('请选择协办人');
return;
}
emit('add', { userId: newAssigneeId.value });
newAssigneeId.value = '';
}
async function openInactive(assignee: Api.Project.ExecutionAssignee) {
inactiveTarget.value = assignee;
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(assignee: Api.Project.ExecutionAssignee) {
ownerTarget.value = assignee;
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() {
newAssigneeId.value = '';
inactiveTarget.value = null;
inactiveModel.reason = '';
ownerTarget.value = null;
ownerModel.reason = '';
currentPage.value = 1;
}
defineExpose({ reset });
</script>
<template>
<div v-loading="loading" class="assignee-current-panel">
<div v-if="canManageAssignee" class="assignee-current-panel__toolbar">
<BusinessUserSelect
v-model="newAssigneeId"
:options="userOptions"
:exclude-user-ids="activeAssigneeUserIds"
no-data-text="所有项目成员已加入执行"
placeholder="选择用户加入执行"
class="assignee-current-panel__user-select"
/>
<ElButton type="primary" @click="handleAdd">新增协办人</ElButton>
</div>
<ElTable :data="pagedAssignees" :height="247" border row-key="id" size="default">
<ElTableColumn type="index" :index="getAssigneeIndex" label="序号" width="64" align="center" />
<ElTableColumn label="协办人" width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="assignee-current-panel__name">{{ getAssigneeDisplayName(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) && isActiveAssignee(row)" :actions="buildAssigneeActions(row)" />
<span v-else class="assignee-current-panel__actions-empty">--</span>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty description="当前执行暂无协办人" :image-size="80" />
</template>
</ElTable>
<div class="assignee-current-panel__pagination">
<ElPagination
v-if="displayAssignees.length > PAGE_SIZE"
v-model:current-page="currentPage"
:page-size="PAGE_SIZE"
:total="displayAssignees.length"
layout="total, prev, pager, next"
background
small
/>
</div>
<BusinessFormDialog
v-model="inactiveVisible"
:title="`失效协办人:${getAssigneeDisplayName(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="`设为负责人:${getAssigneeDisplayName(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">
.assignee-current-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.assignee-current-panel__toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.assignee-current-panel__user-select {
width: 280px;
}
.assignee-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;
}
.assignee-current-panel__actions-empty {
color: var(--el-text-color-placeholder);
}
.assignee-current-panel__pagination {
display: flex;
justify-content: flex-end;
min-height: 32px;
}
</style>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import { useTaskPermissions } from '../composables/use-task-permissions';
import AssigneeCurrentPanel from './execution-assignee-current-panel.vue';
import AssigneeLogPanel from './execution-assignee-log-panel.vue';
defineOptions({ name: 'ProjectExecutionAssigneeDialog' });
interface Props {
execution: Api.Project.ProjectExecution | null;
assignees: Api.Project.ExecutionAssignee[];
userOptions: Api.SystemManage.UserSimple[];
loading: boolean;
}
interface Emits {
(e: 'add', payload: Api.Project.CreateExecutionAssigneeParams): void;
(e: 'inactive', assignee: Api.Project.ExecutionAssignee, payload: Api.Project.InactiveExecutionAssigneeParams): void;
(e: 'change-owner', payload: Api.Project.ChangeExecutionOwnerParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { canManageExecutionAssignee, canChangeExecutionOwner } = useTaskPermissions();
const resolvedCanManageAssignee = computed(() =>
props.execution ? canManageExecutionAssignee(props.execution) : false
);
const resolvedCanChangeOwner = computed(() => (props.execution ? canChangeExecutionOwner(props.execution) : false));
type TabName = 'current' | 'log';
const activeTab = ref<TabName>('current');
const currentPanelRef = ref<InstanceType<typeof AssigneeCurrentPanel> | 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.CreateExecutionAssigneeParams) {
emit('add', payload);
}
function handleInactive(assignee: Api.Project.ExecutionAssignee, payload: Api.Project.InactiveExecutionAssigneeParams) {
emit('inactive', assignee, 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-assignee-dialog__tabs">
<ElTabPane label="当前协办人" name="current">
<AssigneeCurrentPanel
ref="currentPanelRef"
:execution="execution"
:assignees="assignees"
:user-options="userOptions"
:loading="loading"
:can-manage-assignee="resolvedCanManageAssignee"
:can-change-owner="resolvedCanChangeOwner"
@add="handleAdd"
@inactive="handleInactive"
@change-owner="handleChangeOwner"
/>
</ElTabPane>
<ElTabPane label="变更历史" name="log" lazy>
<AssigneeLogPanel
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-assignee-dialog__tabs {
--el-tabs-header-height: 40px;
}
</style>

View File

@@ -0,0 +1,260 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import { fetchGetProjectExecutionAssigneeLogPage } from '@/service/api';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import { formatDateTime, getExecutionAssigneeActionName, getExecutionAssigneeActionTagType } from '../shared';
defineOptions({ name: 'ProjectExecutionAssigneeLogPanel' });
interface Props {
projectId: string;
executionId: string;
userOptions: Api.SystemManage.UserSimple[];
active: boolean;
}
const props = defineProps<Props>();
type ActionType = Api.Project.ExecutionAssigneeActionType;
const ACTION_TYPE_OPTIONS: Array<{ label: string; value: ActionType }> = [
{ label: getExecutionAssigneeActionName('join'), value: 'join' },
{ label: getExecutionAssigneeActionName('inactive'), value: 'inactive' },
{ label: getExecutionAssigneeActionName('owner_transfer_in'), value: 'owner_transfer_in' },
{ label: getExecutionAssigneeActionName('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 fetchGetProjectExecutionAssigneeLogPage>>;
function buildRequestParams(): Api.Project.ExecutionAssigneeLogSearchParams {
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.ExecutionAssigneeLog
>({
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 fetchGetProjectExecutionAssigneeLogPage(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 getAssigneeDisplay(row: Api.Project.ExecutionAssigneeLog) {
return row.userNicknameSnapshot?.trim() || row.userId || '--';
}
function getOperatorDisplay(row: Api.Project.ExecutionAssigneeLog) {
return row.operatorNicknameSnapshot?.trim() || row.operatorUserId || '--';
}
function refresh() {
return getDataByPage(1);
}
defineExpose({ refresh });
</script>
<template>
<div class="assignee-log-panel">
<div class="assignee-log-panel__toolbar">
<ElSelect
v-model="searchParams.actionTypes"
multiple
collapse-tags
collapse-tags-tooltip
clearable
placeholder="全部事件"
class="assignee-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="assignee-log-panel__user-select"
/>
<div class="assignee-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="getExecutionAssigneeActionTagType(row.actionType)" effect="light">
{{ getExecutionAssigneeActionName(row.actionType) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="协办人" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ getAssigneeDisplay(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="assignee-log-panel__empty">--</span>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty description="暂无变更记录" :image-size="80" />
</template>
</ElTable>
<div class="assignee-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">
.assignee-log-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.assignee-log-panel__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.assignee-log-panel__action-select {
width: 200px;
}
.assignee-log-panel__user-select {
width: 200px;
}
.assignee-log-panel__actions {
display: flex;
gap: 12px;
margin-left: auto;
}
.assignee-log-panel__empty {
color: var(--el-text-color-placeholder);
}
.assignee-log-panel__pagination {
display: flex;
justify-content: flex-end;
min-height: 32px;
}
</style>

View File

@@ -0,0 +1,599 @@
<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 } from '../shared';
import { useTaskPermissions } from '../composables/use-task-permissions';
import IconMdiAccountMultipleOutline from '~icons/mdi/account-multiple-outline';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-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;
}
const { canEditExecution, canDeleteExecution, canSeeExecutionAssigneeEntry } = useTaskPermissions();
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' | 'warning';
onClick: () => void;
}
const STATUS_ACTION_ICON_MAP: Record<string, object> = {
start: markRaw(IconMdiPlay),
pause: markRaw(IconMdiPause),
resume: markRaw(IconMdiRestart),
cancel: markRaw(IconMdiCloseCircleOutline),
complete: markRaw(IconMdiCheckCircleOutline)
};
// 状态推进按钮 type 映射cancel 破坏性=红pause 中断=橙complete 完结=绿resume/start 主动作=蓝
const STATUS_ACTION_TYPE_MAP: Record<string, ExecutionAction['type']> = {
cancel: 'danger',
pause: 'warning',
complete: 'success',
resume: 'primary',
start: 'primary'
};
// 同一状态下多个推进按钮的展示顺序:暂停 → 取消 → 完成 → 恢复 → 开始
const STATUS_ACTION_ORDER: Record<string, number> = {
pause: 1,
cancel: 2,
complete: 3,
resume: 4,
start: 5
};
function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
const actions: ExecutionAction[] = [];
// 查看入口已收到执行名称(点击名称触发 view操作区不再放眼睛按钮。
// 编辑执行pending/active + (权限码 OR 字段身份)
if (canEditExecution(row)) {
actions.push({
key: 'edit',
tooltip: '编辑',
icon: markRaw(IconMdiPencilOutline),
type: 'primary',
onClick: () => emit('edit', row)
});
}
// 协办人入口:仅项目负责人 / 项目创建人 / 执行负责人可见,无状态前置
// 普通登录用户通过"查看"对话框看团队信息dialog 内"加 / 移 / 换 owner"再自判 isMutable
if (canSeeExecutionAssigneeEntry(row)) {
actions.push({
key: 'members',
tooltip: '协办人',
icon: markRaw(IconMdiAccountMultipleOutline),
type: 'primary',
onClick: () => emit('members', row)
});
}
// 状态推进按钮完全依赖 availableActionsowner-only 字段硬卡spec §3.4.1
// 前端只控制展示顺序与 type/icon不参与判定哪些动作可见
const sortedActions = [...row.availableActions].sort(
(a, b) => (STATUS_ACTION_ORDER[a.actionCode] ?? 99) - (STATUS_ACTION_ORDER[b.actionCode] ?? 99)
);
sortedActions.forEach(action => {
actions.push({
key: action.actionCode,
tooltip: action.actionName,
icon: markRaw(STATUS_ACTION_ICON_MAP[action.actionCode] ?? IconMdiSync),
type: STATUS_ACTION_TYPE_MAP[action.actionCode] ?? 'primary',
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"
role="button"
tabindex="0"
@click.stop="emit('view', row)"
@keydown.enter.stop.prevent="emit('view', row)"
>
{{ row.executionName || '未命名执行' }}
</strong>
<ElTag
class="execution-item__status-tag"
:type="getExecutionStatusTagType(row.statusCode)"
effect="light"
size="small"
>
{{ getExecutionStatusName(row) }}
</ElTag>
<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>
<ElTooltip v-if="canDeleteExecution(row)" content="删除">
<ElButton link type="danger" class="execution-action-btn" @click="emit('delete', row)">
<icon-mdi-delete-outline class="text-14px" />
</ElButton>
</ElTooltip>
</div>
</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>
</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 {
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;
cursor: pointer;
transition: color 0.16s ease;
}
.execution-item__name:hover,
.execution-item__name:focus-visible {
color: var(--el-color-primary);
outline: none;
}
.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;
margin-left: 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__top {
align-items: flex-start;
flex-wrap: wrap;
}
.execution-item__actions {
justify-content: flex-end;
}
}
</style>

View File

@@ -0,0 +1,553 @@
<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 { isActiveExecutionAssignee, withVirtualOwnerAssignee } from '../shared';
function isEmptyRichText(html: string | null | undefined) {
if (!html) {
return true;
}
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/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[];
currentAssignees?: Api.Project.ExecutionAssignee[];
}
interface Emits {
(e: 'submit', payload: Api.Project.SaveProjectExecutionParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
function resolveUserLabel(userId: string | null | undefined, fallbackNickname?: string | null) {
if (!userId) {
return '';
}
return fallbackNickname || props.userOptions.find(item => item.id === userId)?.nickname || userId;
}
function resolveAssigneeLabel(assignee: Api.Project.ExecutionAssignee) {
return resolveUserLabel(assignee.userId, assignee.userNickname);
}
const ownerDisplayName = computed(() => resolveUserLabel(props.rowData?.ownerId, props.rowData?.ownerNickname));
// view / edit 模式下协办人 select 的展示数据:先过滤掉失效项,再兜底前置一行虚拟负责人
// 让用户视觉上感知"负责人也在团队里";虚拟行不会发到后端(这里 select 是 disabled 仅展示)
const activeAssignees = computed(() => {
const filtered = (props.currentAssignees ?? []).filter(assignee => isActiveExecutionAssignee(assignee));
return withVirtualOwnerAssignee(filtered, props.rowData?.ownerId, ownerDisplayName.value, props.rowData?.id ?? '');
});
const activeAssigneeIds = computed(() => activeAssignees.value.map(assignee => assignee.userId));
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const autoOwnerAssigneeId = 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,
assigneeUserIds: []
});
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: props.mode === 'create' ? [createRequiredRule('请选择执行负责人')] : [],
assigneeUserIds: 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 normalizeAssigneeUserIds(assigneeUserIds?: string[]) {
return Array.from(new Set(assigneeUserIds?.filter(Boolean) ?? []));
}
function getUserRoleName(item: Api.SystemManage.UserSimple) {
return item.deptName || '';
}
function syncOwnerAssignee(ownerId: string | null, previousOwnerId: string | null = autoOwnerAssigneeId.value) {
if (props.mode !== 'create') {
return;
}
const currentAssigneeUserIds = normalizeAssigneeUserIds(model.assigneeUserIds);
const assigneeUserIds = previousOwnerId
? currentAssigneeUserIds.filter(userId => userId !== previousOwnerId)
: currentAssigneeUserIds;
model.assigneeUserIds = ownerId ? normalizeAssigneeUserIds([...assigneeUserIds, ownerId]) : assigneeUserIds;
autoOwnerAssigneeId.value = ownerId;
}
function ensureOwnerInAssignees() {
if (props.mode !== 'create' || !model.ownerId) {
return;
}
model.assigneeUserIds = normalizeAssigneeUserIds([...(model.assigneeUserIds || []), model.ownerId]);
}
async function handleConfirm() {
ensureOwnerInAssignees();
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),
assigneeUserIds: props.mode === 'create' ? normalizeAssigneeUserIds(model.assigneeUserIds) : undefined
});
}
function handleAssigneeChange(value: string[]) {
if (props.mode !== 'create') {
return;
}
model.assigneeUserIds = normalizeAssigneeUserIds(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.assigneeUserIds = [];
autoOwnerAssigneeId.value = null;
await nextTick();
formRef.value?.clearValidate();
}
);
watch(
() => model.ownerId,
(ownerId, previousOwnerId) => {
syncOwnerAssignee(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="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"
>
<div class="execution-assignee-option">
<span class="execution-assignee-option__name">
{{ item.nickname }}
<span v-if="item.id === model.ownerId" class="execution-assignee-option__owner">负责人</span>
</span>
<span v-if="getUserRoleName(item)" class="execution-assignee-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="activeAssigneeIds"
multiple
disabled
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="2"
class="w-full"
placeholder="暂无在岗协办人"
>
<ElOption
v-for="assignee in activeAssignees"
:key="assignee.id"
:label="resolveAssigneeLabel(assignee)"
:value="assignee.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-assignee-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.execution-assignee-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-assignee-option__owner {
flex: 0 0 auto;
color: var(--el-color-primary);
font-size: 12px;
font-weight: 500;
}
.execution-assignee-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>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'ObjectDeleteDialog' });
interface Props {
/** 是否显示v-model:visible */
visible: boolean;
/** 对象类型:影响标题、字段标签、警示文案 */
objectType: 'execution' | 'task';
/** 当前对象的名称,用作输入框 placeholder 参照;提交时校验完全一致 */
objectName: string;
/** 删除确认回调async接收三个字段resolve 后由调用方决定刷新/关闭 */
onConfirm: (payload: { name: string; confirmText: string; reason: string }) => Promise<void>;
}
interface Emits {
(e: 'update:visible', value: boolean): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const VALID_CONFIRM_TEXTS = new Set(['删除', 'DELETE']);
const dialogVisible = computed({
get: () => props.visible,
set: val => emit('update:visible', val)
});
const objectTypeLabel = computed(() => (props.objectType === 'execution' ? '执行' : '任务'));
const form = reactive({
name: '',
confirmText: '',
reason: ''
});
const submitting = ref(false);
watch(
() => props.visible,
val => {
if (val) {
form.name = '';
form.confirmText = '';
form.reason = '';
submitting.value = false;
}
}
);
const canSubmit = computed(
() => form.name === props.objectName && VALID_CONFIRM_TEXTS.has(form.confirmText) && form.reason.trim().length > 0
);
async function handleConfirm() {
submitting.value = true;
try {
await props.onConfirm({
name: form.name,
confirmText: form.confirmText,
reason: form.reason
});
} finally {
submitting.value = false;
}
}
</script>
<template>
<BusinessFormDialog
v-model="dialogVisible"
:title="`删除${objectTypeLabel}`"
preset="sm"
:confirm-loading="submitting"
:confirm-disabled="!canSubmit"
@confirm="handleConfirm"
>
<ElAlert type="error" :closable="false" show-icon>
此操作不可撤销删除后{{ objectTypeLabel }}下挂数据将不可见
</ElAlert>
<ElForm label-position="top" class="mt-3">
<ElFormItem :label="`请再次输入${objectTypeLabel}名称(与当前名称完全一致)`" required>
<ElInput v-model="form.name" :placeholder="objectName" />
</ElFormItem>
<ElFormItem label="删除确认口令" required>
<ElInput v-model="form.confirmText" placeholder='请输入"删除"以确认' />
</ElFormItem>
<ElFormItem label="删除原因" required>
<ElInput v-model="form.reason" type="textarea" :rows="3" maxlength="500" show-word-limit />
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -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>

View File

@@ -0,0 +1,261 @@
<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 } from '../shared';
defineOptions({ name: 'ProjectExecutionTaskAssigneeCurrentPanel' });
interface Props {
task: Api.Project.ProjectTask | null;
assignees: Api.Project.TaskAssigneeRef[];
userOptions: Api.SystemManage.UserSimple[];
loading: boolean;
canManage: boolean;
}
interface Emits {
(e: 'add', payload: Api.Project.CreateTaskAssigneeParams): void;
(e: 'inactive', assignee: Api.Project.TaskAssigneeRef, payload: Api.Project.InactiveTaskAssigneeParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const newUserId = ref('');
const PAGE_SIZE = 5;
const currentPage = ref(1);
const pagedAssignees = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE;
return props.assignees.slice(start, start + PAGE_SIZE);
});
watch(
() => props.assignees.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.TaskAssigneeRef | 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 ownerId = computed(() => props.task?.ownerId || '');
const activeAssigneeUserIds = computed(() => props.assignees.map(item => item.userId));
const excludeUserIds = computed(() => {
const ids = [...activeAssigneeUserIds.value];
if (ownerId.value) {
ids.push(ownerId.value);
}
return ids;
});
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 getAssigneeIndex(index: number) {
return (currentPage.value - 1) * PAGE_SIZE + index + 1;
}
function getAssigneeDisplayName(assignee: Api.Project.TaskAssigneeRef | null) {
if (!assignee) return '';
return assignee.nickname?.trim() || userNicknameMap.value.get(assignee.userId) || assignee.userId || '--';
}
function buildAssigneeActions(row: Api.Project.TaskAssigneeRef): BusinessTableAction[] {
if (!props.canManage) {
return [];
}
return [
{
key: 'inactive',
label: '失效',
buttonType: 'danger',
onClick: () => openInactive(row)
}
];
}
function handleAdd() {
if (!newUserId.value) {
window.$message?.warning('请选择协办人用户');
return;
}
emit('add', { userId: newUserId.value });
newUserId.value = '';
}
async function openInactive(assignee: Api.Project.TaskAssigneeRef) {
inactiveTarget.value = assignee;
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 reset() {
newUserId.value = '';
inactiveTarget.value = null;
inactiveModel.reason = '';
currentPage.value = 1;
}
defineExpose({ reset });
</script>
<template>
<div v-loading="loading" class="task-assignee-current-panel">
<div v-if="canManage" class="task-assignee-current-panel__toolbar">
<BusinessUserSelect
v-model="newUserId"
:options="userOptions"
:exclude-user-ids="excludeUserIds"
no-data-text="暂无可选成员"
placeholder="选择协办人"
class="task-assignee-current-panel__user-select"
/>
<ElButton type="primary" @click="handleAdd">新增协办人</ElButton>
</div>
<ElTable :data="pagedAssignees" :height="247" border row-key="id" size="default">
<ElTableColumn type="index" :index="getAssigneeIndex" label="序号" width="64" align="center" />
<ElTableColumn label="协办人" width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="task-assignee-current-panel__name">{{ getAssigneeDisplayName(row) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="加入时间" min-width="200" align="center">
<template #default="{ row }">{{ formatDateTime(row.joinedAt) }}</template>
</ElTableColumn>
<ElTableColumn label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<BusinessTableActionCell v-if="canManage" :actions="buildAssigneeActions(row)" />
<span v-else class="task-assignee-current-panel__actions-empty">--</span>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty description="当前任务暂无活跃协办人" :image-size="80" />
</template>
</ElTable>
<div class="task-assignee-current-panel__pagination">
<ElPagination
v-if="assignees.length > PAGE_SIZE"
v-model:current-page="currentPage"
:page-size="PAGE_SIZE"
:total="assignees.length"
layout="total, prev, pager, next"
background
small
/>
</div>
<BusinessFormDialog
v-model="inactiveVisible"
:title="`失效协办人:${getAssigneeDisplayName(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>
</div>
</template>
<style scoped lang="scss">
.task-assignee-current-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.task-assignee-current-panel__toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.task-assignee-current-panel__user-select {
width: 280px;
}
.task-assignee-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;
}
.task-assignee-current-panel__actions-empty {
color: var(--el-text-color-placeholder);
}
.task-assignee-current-panel__pagination {
display: flex;
justify-content: flex-end;
min-height: 32px;
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import TaskAssigneeCurrentPanel from './task-assignee-current-panel.vue';
import TaskAssigneeLogPanel from './task-assignee-log-panel.vue';
defineOptions({ name: 'ProjectExecutionTaskAssigneeDialog' });
interface Props {
task: Api.Project.ProjectTask | null;
assignees: Api.Project.TaskAssigneeRef[];
userOptions: Api.SystemManage.UserSimple[];
loading: boolean;
canManage: boolean;
}
interface Emits {
(e: 'add', payload: Api.Project.CreateTaskAssigneeParams): void;
(e: 'inactive', assignee: Api.Project.TaskAssigneeRef, payload: Api.Project.InactiveTaskAssigneeParams): 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 TaskAssigneeCurrentPanel> | null>(null);
const dialogTitle = computed(() => (props.task ? `协办人管理:${props.task.taskTitle}` : '协办人管理'));
const projectId = computed(() => props.task?.projectId || '');
const executionId = computed(() => props.task?.executionId || '');
const taskId = computed(() => props.task?.id || '');
function handleAdd(payload: Api.Project.CreateTaskAssigneeParams) {
emit('add', payload);
}
function handleInactive(assignee: Api.Project.TaskAssigneeRef, payload: Api.Project.InactiveTaskAssigneeParams) {
emit('inactive', assignee, 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="task-assignee-dialog__tabs">
<ElTabPane label="当前协办人" name="current">
<TaskAssigneeCurrentPanel
ref="currentPanelRef"
:task="task"
:assignees="assignees"
:user-options="userOptions"
:loading="loading"
:can-manage="canManage"
@add="handleAdd"
@inactive="handleInactive"
/>
</ElTabPane>
<ElTabPane label="变更历史" name="log" lazy>
<TaskAssigneeLogPanel
v-if="projectId && executionId && taskId"
:project-id="projectId"
:execution-id="executionId"
:task-id="taskId"
:user-options="userOptions"
:active="activeTab === 'log'"
/>
</ElTabPane>
</ElTabs>
</BusinessFormDialog>
</template>
<style scoped lang="scss">
.task-assignee-dialog__tabs {
--el-tabs-header-height: 40px;
}
</style>

View File

@@ -0,0 +1,245 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import { fetchGetProjectTaskAssigneeLogPage } from '@/service/api/project';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import { formatDateTime, getTaskAssigneeActionName, getTaskAssigneeActionTagType } from '../shared';
defineOptions({ name: 'ProjectExecutionTaskAssigneeLogPanel' });
interface Props {
projectId: string;
executionId: string;
taskId: string;
userOptions: Api.SystemManage.UserSimple[];
active: boolean;
}
const props = defineProps<Props>();
type ActionType = Api.Project.TaskAssigneeActionType;
const ACTION_TYPE_OPTIONS: Array<{ label: string; value: ActionType }> = [
{ label: getTaskAssigneeActionName('join'), value: 'join' },
{ label: getTaskAssigneeActionName('inactive'), value: 'inactive' }
];
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 && props.taskId));
type LogPageResponse = Awaited<ReturnType<typeof fetchGetProjectTaskAssigneeLogPage>>;
function buildRequestParams(): Api.Project.TaskAssigneeLogSearchParams {
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.TaskAssigneeLog
>({
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 fetchGetProjectTaskAssigneeLogPage(props.projectId, props.executionId, props.taskId, 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: '时间' }]
});
watch(
() => props.active,
active => {
if (active && canLoad.value) {
getDataByPage(1);
}
},
{ immediate: true }
);
watch(
() => props.taskId,
() => {
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 getAssigneeDisplay(row: Api.Project.TaskAssigneeLog) {
return row.userNicknameSnapshot?.trim() || row.userId || '--';
}
function getOperatorDisplay(row: Api.Project.TaskAssigneeLog) {
return row.operatorNicknameSnapshot?.trim() || row.operatorUserId || '--';
}
</script>
<template>
<div class="task-assignee-log-panel">
<div class="task-assignee-log-panel__toolbar">
<ElSelect
v-model="searchParams.actionTypes"
multiple
collapse-tags
collapse-tags-tooltip
clearable
placeholder="全部事件"
class="task-assignee-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="task-assignee-log-panel__user-select"
/>
<div class="task-assignee-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="getTaskAssigneeActionTagType(row.actionType)" effect="light">
{{ getTaskAssigneeActionName(row.actionType) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="协办人" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ getAssigneeDisplay(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="task-assignee-log-panel__empty">--</span>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty description="暂无变更记录" :image-size="80" />
</template>
</ElTable>
<div class="task-assignee-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">
.task-assignee-log-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.task-assignee-log-panel__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.task-assignee-log-panel__action-select {
width: 200px;
}
.task-assignee-log-panel__user-select {
width: 200px;
}
.task-assignee-log-panel__actions {
display: flex;
gap: 12px;
margin-left: auto;
}
.task-assignee-log-panel__empty {
color: var(--el-text-color-placeholder);
}
.task-assignee-log-panel__pagination {
display: flex;
justify-content: flex-end;
min-height: 32px;
}
</style>

View File

@@ -0,0 +1,246 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Edit, Flag, User } from '@element-plus/icons-vue';
import { formatDate, getProgressText, getTaskStatusName } from '../shared';
import { useTaskPermissions } from '../composables/use-task-permissions';
defineOptions({ name: 'ProjectExecutionTaskBoardView' });
interface Props {
data: Api.Project.ProjectTask[];
loading: boolean;
statusBoard: Api.Project.StatusBoard | null;
}
const { canEditTask } = useTaskPermissions();
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) {
// auto_start 由后端在填工时时自动触发,无手工开始通道;防御后端误下发,前端不渲染按钮
return row.availableActions.find(item => item.actionCode !== 'auto_start') || 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="canEditTask(task)" size="small" plain :icon="Edit" @click="emit('edit', task)">
编辑
</ElButton>
<ElButton
v-if="getFirstAction(task)"
size="small"
type="primary"
plain
@click="emit('status-action', task, getFirstAction(task)!)"
>
{{ getFirstAction(task)!.actionName }}
</ElButton>
<ElButton
v-else-if="task.availableActions.length === 0 && task.statusCode !== 'cancelled'"
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>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import type { WorklogChangedPayload } from '../shared';
import TaskInfoReadonly from './task-info-readonly.vue';
import TaskWorklogContent from './task-worklog-content.vue';
defineOptions({ name: 'ProjectExecutionTaskDetailDialog' });
interface Props {
task: Api.Project.ProjectTask | null;
userOptions?: Api.SystemManage.UserSimple[];
taskOptions?: Api.Project.ProjectTask[];
/** 弹层打开时默认激活的 tab'info' = 任务信息,'worklog' = 工作日志 */
defaultTab?: 'info' | 'worklog';
}
interface Emits {
(e: 'worklog-changed', payload: WorklogChangedPayload): void;
}
const props = withDefaults(defineProps<Props>(), {
userOptions: () => [],
taskOptions: () => [],
defaultTab: 'info'
});
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
type TabName = 'info' | 'worklog';
const activeTab = ref<TabName>('info');
const dialogTitle = computed(() => '任务详情');
watch(visible, val => {
if (val) {
activeTab.value = props.defaultTab;
}
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
width="1100px"
max-body-height="78vh"
:show-footer="false"
:scrollbar="false"
>
<ElTabs v-model="activeTab" class="task-detail-dialog__tabs">
<ElTabPane label="任务信息" name="info">
<TaskInfoReadonly :task="task" :user-options="userOptions" :task-options="taskOptions" />
</ElTabPane>
<ElTabPane label="工作日志" name="worklog" lazy>
<TaskWorklogContent
:task="task"
:active="activeTab === 'worklog' && visible"
@changed="payload => emit('worklog-changed', payload)"
/>
</ElTabPane>
</ElTabs>
</BusinessFormDialog>
</template>
<style scoped lang="scss">
.task-detail-dialog__tabs {
--el-tabs-header-height: 40px;
}
// 任务信息 tab 自然高度较大;给 tab 内容一个最小高度(超过两个 tab 的自然高度),切换时弹层不缩水
.task-detail-dialog__tabs :deep(.el-tabs__content),
.task-detail-dialog__tabs :deep(.el-tab-pane) {
min-height: 640px;
}
</style>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed } from 'vue';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.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: 'ProjectExecutionTaskInfoReadonly' });
interface Props {
task: Api.Project.ProjectTask | null;
userOptions?: Api.SystemManage.UserSimple[];
taskOptions?: Api.Project.ProjectTask[];
}
const props = withDefaults(defineProps<Props>(), {
userOptions: () => [],
taskOptions: () => []
});
const taskTitle = computed(() => props.task?.taskTitle ?? '');
const taskDesc = computed(() => props.task?.taskDesc ?? '');
const ownerId = computed(() => props.task?.ownerId ?? null);
const parentTaskId = computed(() => props.task?.parentTaskId ?? null);
const plannedStartDate = computed(() => props.task?.plannedStartDate ?? null);
const plannedEndDate = computed(() => props.task?.plannedEndDate ?? null);
const attachments = computed(() => props.task?.attachments ?? []);
const assigneeIds = computed(() => props.task?.assignees?.map(a => a.userId) ?? []);
const assigneeOptions = computed(() => props.task?.assignees ?? []);
// 父任务在当前页 taskOptions 中找;找不到(跨页)回退用 ID 当 label避免显示空
const parentTaskOptions = computed(() => {
if (!parentTaskId.value) return [];
const found = props.taskOptions.find(t => t.id === parentTaskId.value);
if (found) return [{ id: found.id, taskTitle: found.taskTitle }];
return [{ id: parentTaskId.value, taskTitle: parentTaskId.value }];
});
</script>
<template>
<ElForm label-position="top" class="task-info-readonly">
<div class="task-info-readonly__grid">
<div class="task-info-readonly__col-left">
<BusinessFormSection title="任务信息">
<ElFormItem label="任务名称">
<ElInput :model-value="taskTitle" readonly placeholder="--" />
</ElFormItem>
<ElFormItem label="父任务">
<ElSelect :model-value="parentTaskId" disabled clearable filterable class="w-full" placeholder="无">
<ElOption v-for="item in parentTaskOptions" :key="item.id" :label="item.taskTitle" :value="item.id" />
</ElSelect>
</ElFormItem>
<ElFormItem label="负责人">
<BusinessUserSelect :model-value="ownerId" :options="userOptions" disabled placeholder="--" />
</ElFormItem>
<ElFormItem label="协办人">
<ElSelect
:model-value="assigneeIds"
multiple
disabled
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="2"
class="w-full"
placeholder="暂无协办人"
>
<ElOption
v-for="item in assigneeOptions"
:key="item.userId"
:label="item.nickname"
:value="item.userId"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="计划开始日期">
<ElDatePicker
:model-value="plannedStartDate"
type="date"
value-format="YYYY-MM-DD"
disabled
placeholder="--"
class="task-info-readonly__date-picker"
/>
</ElFormItem>
<ElFormItem label="计划结束日期">
<ElDatePicker
:model-value="plannedEndDate"
type="date"
value-format="YYYY-MM-DD"
disabled
placeholder="--"
class="task-info-readonly__date-picker"
/>
</ElFormItem>
</BusinessFormSection>
</div>
<div class="task-info-readonly__col-right">
<BusinessFormSection title="任务说明">
<ElFormItem class="task-info-readonly__desc-item">
<BusinessRichTextEditor
:model-value="taskDesc"
disabled
:height="320"
upload-directory="task"
placeholder="--"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection title="附件">
<ElFormItem class="task-info-readonly__attachment-item">
<BusinessAttachmentUploader :model-value="attachments" disabled directory="task" />
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
</template>
<style scoped>
.task-info-readonly__grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
align-items: start;
}
.task-info-readonly__col-left,
.task-info-readonly__col-right {
min-width: 0;
}
.task-info-readonly__col-right {
display: flex;
flex-direction: column;
gap: 16px;
}
.task-info-readonly__desc-item,
.task-info-readonly__attachment-item {
margin-bottom: 0;
}
:deep(.task-info-readonly__date-picker.el-date-editor.el-input) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,464 @@
<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 BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import 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;
/** 创建模式下的父任务预填;编辑/查看模式忽略 */
defaultParentTaskId?: string | 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>(), {
defaultParentTaskId: null,
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();
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
interface FormModel {
parentTaskId: string | null;
taskTitle: string;
ownerId: string | null;
plannedStartDate: string | null;
plannedEndDate: string | null;
taskDesc: string | null;
assigneeUserIds: string[];
attachments: Api.Project.AttachmentItem[];
}
const model = reactive<FormModel>({
parentTaskId: null,
taskTitle: '',
ownerId: null,
plannedStartDate: null,
plannedEndDate: null,
taskDesc: null,
assigneeUserIds: [],
attachments: []
});
const dialogTitle = computed(() => {
if (props.mode === 'create') {
return '新建任务';
}
return props.rowData?.taskTitle ? `编辑任务:${props.rowData.taskTitle}` : '编辑任务';
});
const selectableParentTasks = computed(() => props.taskOptions.filter(item => item.id !== props.rowData?.id));
/** 左栏容器 ref用其高度动态驱动右侧富文本让两栏视觉等高 */
const leftColRef = ref<HTMLElement>();
const editorHeight = ref<string>('45vh');
// 右栏:富文本 + 附件区。预留附件区粗略高度(标题 + 按钮行 + 列表起始空间)
const ATTACHMENT_SECTION_RESERVE_PX = 140;
useResizeObserver(leftColRef, entries => {
const h = entries[0]?.contentRect.height;
if (h && h > 120) {
editorHeight.value = `${Math.max(h - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
}
});
function isEmptyRichText(html: string | null | undefined) {
if (!html) {
return true;
}
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/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;
}
}))
);
/**
* 提交用:过滤掉 ownerId后端契约任务协办人不能等于 owner+ 去重
*/
function normalizeAssigneeIds(ids: string[]) {
return Array.from(new Set(ids.filter(id => id && id !== model.ownerId)));
}
/**
* 自动加进 model.assigneeUserIds 的 owner跟踪它以便 owner 切换时正确移除旧值。
* 防止用户先选了某 A 作为 owner自动加入再换成 B 作为 owner 时A 仍残留在协办人里。
*/
const autoOwnerAssigneeId = ref<string | null>(null);
/**
* UI 层把 owner 也加进 model.assigneeUserIds让协办人 select 视觉上显示 owner
* (体验上让用户感知"负责人也在团队里")。提交时由 normalizeAssigneeIds 过滤掉 owner。
*/
function syncOwnerAssignee(ownerId: string | null, previousOwnerId: string | null = autoOwnerAssigneeId.value) {
if (props.mode !== 'create') {
return;
}
const current = Array.from(new Set((model.assigneeUserIds ?? []).filter(Boolean)));
const withoutPrevious = previousOwnerId ? current.filter(userId => userId !== previousOwnerId) : current;
model.assigneeUserIds = ownerId ? Array.from(new Set([...withoutPrevious, ownerId])) : withoutPrevious;
autoOwnerAssigneeId.value = ownerId;
}
async function handleConfirm() {
await validate();
if (attachmentUploaderRef.value?.hasUploading) {
window.$message?.warning('附件正在上传中,请稍候');
return;
}
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),
attachments: [...model.attachments]
};
if (props.mode === 'create') {
payload.assigneeUserIds = normalizeAssigneeIds(model.assigneeUserIds);
}
emit('submit', payload);
}
function handleAssigneeChange(value: string[]) {
// UI 层保持 owner 不掉队;提交时再由 normalizeAssigneeIds 过滤
const cleaned = Array.from(new Set(value.filter(Boolean)));
if (props.mode === 'create' && model.ownerId && !cleaned.includes(model.ownerId)) {
cleaned.push(model.ownerId);
}
model.assigneeUserIds = cleaned;
}
function applyRowDataToModel() {
model.parentTaskId =
props.mode === 'create' ? (props.defaultParentTaskId ?? null) : 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 = [];
model.attachments = props.rowData?.attachments ? [...props.rowData.attachments] : [];
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
applyRowDataToModel();
autoOwnerAssigneeId.value = null;
await nextTick();
// 让附件组件把当前 model 视作 original必须在 model 填充之后
attachmentUploaderRef.value?.initSession();
richTextEditorRef.value?.initSession();
formRef.value?.clearValidate();
}
);
watch(
() => model.ownerId,
(ownerId, previousOwnerId) => {
syncOwnerAssignee(ownerId || null, previousOwnerId || null);
}
);
defineExpose({
/** 父组件在业务保存成功后调用,触发删除被标记的附件 + 已被删的富文本图片 */
async commit() {
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
}
});
</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 v-else>
<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>
<ElSelect
:model-value="model.assigneeUserIds"
multiple
disabled
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="2"
class="w-full"
placeholder="暂无协办人"
/>
</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
ref="richTextEditorRef"
v-model="model.taskDesc"
:height="editorHeight"
upload-directory="task"
placeholder="请输入任务说明"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection title="附件">
<ElFormItem class="task-operate-dialog__attachment-item">
<BusinessAttachmentUploader ref="attachmentUploaderRef" v-model="model.attachments" directory="task" />
</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,
.task-operate-dialog__attachment-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>

View 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>

View File

@@ -0,0 +1,284 @@
<script setup lang="ts">
import { computed, markRaw } from 'vue';
import type { PaginationProps } from 'element-plus';
import { useAuthStore } from '@/store/modules/auth';
import {
canReportTaskWorklog,
formatDateRange,
formatDateTime,
getTaskStatusName,
getTaskStatusTagType
} from '../shared';
import { useTaskPermissions } from '../composables/use-task-permissions';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiPause from '~icons/mdi/pause';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiRestart from '~icons/mdi/restart';
import IconMdiSync from '~icons/mdi/sync';
defineOptions({ name: 'ProjectExecutionTaskTableView' });
interface Props {
data: Api.Project.ProjectTask[];
loading: boolean;
pagination: Partial<PaginationProps & Record<string, any>>;
}
interface Emits {
(e: 'detail', row: Api.Project.ProjectTask): void;
(e: 'edit', row: Api.Project.ProjectTask): void;
(e: 'report', row: Api.Project.ProjectTask): void;
(e: 'delete', 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 authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const { canEditTask, canDeleteTask, canReportTaskWorklog: hasReportWorklogPermission } = useTaskPermissions();
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) || '--';
}
interface TaskAction {
key: string;
tooltip: string;
icon: object;
type: 'primary' | 'success' | 'danger';
onClick: () => void;
}
const STATUS_ACTION_ICON_MAP: Record<string, object> = {
pause: markRaw(IconMdiPause),
complete: markRaw(IconMdiCheckCircleOutline),
resume: markRaw(IconMdiRestart),
cancel: markRaw(IconMdiCloseCircleOutline)
};
function createActions(row: Api.Project.ProjectTask): TaskAction[] {
const actions: TaskAction[] = [];
// 填报:权限码门槛 AND 业务规则(叶子/身份/状态)双重判定
if (hasReportWorklogPermission() && canReportTaskWorklog(row, props.data, currentUserId.value)) {
actions.push({
key: 'report',
tooltip: '填报',
icon: markRaw(IconMdiClipboardEditOutline),
type: 'primary',
onClick: () => emit('report', row)
});
}
if (canEditTask(row)) {
actions.push({
key: 'edit',
tooltip: '编辑',
icon: markRaw(IconMdiPencilOutline),
type: 'primary',
onClick: () => emit('edit', row)
});
}
if (canDeleteTask(row)) {
actions.push({
key: 'delete',
tooltip: '删除',
icon: markRaw(IconMdiDeleteOutline),
type: 'danger',
onClick: () => emit('delete', row)
});
}
if (!row.availableActions.length) {
return actions;
}
row.availableActions.forEach(action => {
// auto_start 由后端在填工时时自动触发,无手工开始通道;防御后端误下发,前端不渲染
if (action.actionCode === 'auto_start') {
return;
}
// 完成任务至少要求任务进度达到 100%;父级提交入口仍保留同样兜底校验。
if (action.actionCode === 'complete' && row.progressRate < 100) {
return;
}
actions.push({
key: `status-${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;
}
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>
<ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn label="任务名称" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<span
v-if="row.taskTitle"
class="task-table-title"
role="button"
tabindex="0"
@click.stop="emit('detail', row)"
@keydown.enter.prevent="emit('detail', row)"
>
{{ row.taskTitle }}
</span>
<span v-else>--</span>
</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="160">
<template #default="{ row }">
<div class="task-table-progress">
<ElProgress :percentage="row.progressRate" :stroke-width="18" text-inside />
</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="210" fixed="right" align="center" class-name="task-operate-column">
<template #default="{ row }">
<div class="task-action-cell" @click.stop>
<ElTooltip v-for="action in createActions(row)" :key="action.key" :content="action.tooltip">
<ElButton link :type="action.type" class="task-action-btn" @click="action.onClick()">
<component :is="action.icon" class="text-15px" />
</ElButton>
</ElTooltip>
</div>
</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-table-title {
color: var(--el-color-primary);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.task-table-progress {
padding: 0 8px;
}
.task-action-cell {
display: inline-flex;
align-items: center;
gap: 6px;
}
.task-action-cell :deep(.el-button + .el-button) {
margin-left: 0;
}
:deep(.task-action-btn) {
padding: 3px;
min-width: auto;
height: auto;
line-height: 1;
}
.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>

View File

@@ -0,0 +1,322 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { fetchGetProjectTaskWorklogPage } from '@/service/api/project';
import { useAuthStore } from '@/store/modules/auth';
import { formatDate, getProgressText, getTaskStatusName, getTaskStatusTagType } from '../shared';
import type { WorklogChangedPayload } from '../shared';
import TaskWorklogPanel from './task-worklog-panel.vue';
defineOptions({ name: 'ProjectExecutionTaskWorklogContent' });
interface Props {
task: Api.Project.ProjectTask | null;
/** 是否激活;放进 tab 时由父级控制按需加载 */
active?: boolean;
}
interface Emits {
(e: 'changed', payload: WorklogChangedPayload): void;
}
const props = withDefaults(defineProps<Props>(), {
active: true
});
const emit = defineEmits<Emits>();
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const isOwner = computed(() => Boolean(props.task?.ownerId && props.task.ownerId === currentUserId.value));
const records = ref<Api.Project.TaskWorklog[]>([]);
const recordsLoading = ref(false);
const ownerName = computed(() => props.task?.ownerNickname?.trim() || props.task?.ownerId || '--');
const statusName = computed(() => (props.task ? getTaskStatusName(props.task) : ''));
const statusTagType = computed(() => (props.task ? getTaskStatusTagType(props.task.statusCode) : 'info'));
const progressText = computed(() => getProgressText(props.task?.progressRate));
const plannedStartText = computed(() =>
props.task?.plannedStartDate ? formatDate(props.task.plannedStartDate) : '--'
);
const plannedEndText = computed(() => (props.task?.plannedEndDate ? formatDate(props.task.plannedEndDate) : '--'));
const actualStartText = computed(() => (props.task?.actualStartDate ? formatDate(props.task.actualStartDate) : '--'));
const actualEndText = computed(() => (props.task?.actualEndDate ? formatDate(props.task.actualEndDate) : '--'));
// 协办人视角 records 只含自身;责任人视角 records 含全员
const totalHours = computed(() => records.value.reduce((sum, item) => sum + (item.durationHours ?? 0), 0));
const totalHoursText = computed(() => {
if (recordsLoading.value) return '...';
return `${totalHours.value.toFixed(1)} h`;
});
// 责任人视角下"总工时" hover 展示按用户分组的明细;协办人视角不计算
// 候选范围:责任人 + 所有协办人 + records 中出现过的用户(兜底已退出协办人);
// 没填过工时的显示 0h
const hoursByUserDetail = computed(() => {
if (!isOwner.value) return [];
const sumMap = new Map<string, number>();
for (const item of records.value) {
sumMap.set(item.userId, (sumMap.get(item.userId) ?? 0) + (item.durationHours ?? 0));
}
const nicknameMap = new Map<string, string>();
const userIds: string[] = [];
const pushUser = (userId: string | null | undefined, name: string | null | undefined) => {
if (!userId || nicknameMap.has(userId)) return;
nicknameMap.set(userId, name?.trim() || userId);
userIds.push(userId);
};
pushUser(props.task?.ownerId, props.task?.ownerNickname);
for (const assignee of props.task?.assignees ?? []) {
pushUser(assignee.userId, assignee.nickname);
}
// records 中可能存在已退出协办人,按 worklog 自身昵称回填
for (const item of records.value) {
pushUser(item.userId, item.userNickname);
}
const arr = userIds.map(userId => ({
userId,
name: nicknameMap.get(userId) || userId,
hours: sumMap.get(userId) ?? 0
}));
// 责任人置顶其余按工时降序0h 自然落在最后)
arr.sort((a, b) => {
if (a.userId === props.task?.ownerId) return -1;
if (b.userId === props.task?.ownerId) return 1;
return b.hours - a.hours;
});
return arr;
});
async function loadRecords() {
if (!props.task) {
records.value = [];
return;
}
if (!currentUserId.value) {
records.value = [];
return;
}
recordsLoading.value = true;
const params: Api.Project.TaskWorklogSearchParams = {
pageNo: 1,
pageSize: -1
};
// 协办人视角:只看自己的 worklogowner 视角:全量加载
if (!isOwner.value) {
params.userId = currentUserId.value;
}
const { error, data } = await fetchGetProjectTaskWorklogPage(
props.task.projectId,
props.task.executionId,
props.task.id,
params
);
recordsLoading.value = false;
records.value = error || !data ? [] : data.list;
}
function handleWorklogChanged(payload: WorklogChangedPayload) {
loadRecords();
emit('changed', payload);
}
watch(
() => [props.active, props.task?.id] as const,
([isActive]) => {
if (isActive) {
loadRecords();
}
},
{ immediate: true }
);
</script>
<template>
<div class="task-worklog-content">
<div v-if="task" class="task-worklog-content__cards">
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">负责人</span>
<span class="task-worklog-content__card-value" :title="ownerName">{{ ownerName }}</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">任务状态</span>
<ElTag :type="statusTagType" size="small" effect="light" class="task-worklog-content__card-tag">
{{ statusName || '--' }}
</ElTag>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">计划开始</span>
<span class="task-worklog-content__card-value">{{ plannedStartText }}</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">计划结束</span>
<span class="task-worklog-content__card-value">{{ plannedEndText }}</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">当前进度</span>
<span class="task-worklog-content__card-value">{{ progressText }}</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">总工时</span>
<ElTooltip
v-if="isOwner && hoursByUserDetail.length > 0"
placement="top"
effect="light"
popper-class="task-worklog-content__hours-popper"
>
<span
class="task-worklog-content__card-value task-worklog-content__card-value--accent task-worklog-content__card-value--hoverable"
>
{{ totalHoursText }}
</span>
<template #content>
<div class="task-worklog-content__hours-detail">
<div v-for="item in hoursByUserDetail" :key="item.userId" class="task-worklog-content__hours-detail-row">
<span
class="task-worklog-content__hours-detail-name"
:class="{ 'is-owner': item.userId === task?.ownerId }"
:title="item.name"
>
{{ item.name }}
</span>
<span class="task-worklog-content__hours-detail-hours">{{ item.hours.toFixed(1) }}h</span>
</div>
</div>
</template>
</ElTooltip>
<span v-else class="task-worklog-content__card-value task-worklog-content__card-value--accent">
{{ totalHoursText }}
</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">实际开始</span>
<span class="task-worklog-content__card-value">{{ actualStartText }}</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">实际结束</span>
<span class="task-worklog-content__card-value">{{ actualEndText }}</span>
</div>
</div>
<TaskWorklogPanel
v-if="task"
:project-id="task.projectId"
:execution-id="task.executionId"
:task-id="task.id"
:task-owner-id="task.ownerId"
:owner-nickname="task.ownerNickname"
:assignees="task.assignees"
:task-progress-rate="task.progressRate"
:can-submit="true"
:external-list="records"
:show-assignee-column="isOwner"
@changed="handleWorklogChanged"
/>
</div>
</template>
<style scoped lang="scss">
.task-worklog-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.task-worklog-content__cards {
display: grid;
grid-template-columns: repeat(4, 1fr); // 统一 8 卡 4×2 布局
gap: 12px;
}
.task-worklog-content__card {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 12px 14px;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
.task-worklog-content__card-label {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.2;
}
.task-worklog-content__card-value {
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 600;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-worklog-content__card-value--accent {
color: var(--el-color-primary);
}
.task-worklog-content__card-tag {
align-self: flex-start;
}
.task-worklog-content__card-value--hoverable {
cursor: default;
border-bottom: 1px dashed currentColor;
align-self: flex-start;
}
</style>
<style lang="scss">
// tooltip popper 走 teleport必须用全局样式
.task-worklog-content__hours-popper.el-popper {
max-width: 280px;
padding: 8px 10px;
}
.task-worklog-content__hours-detail {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 160px;
}
.task-worklog-content__hours-detail-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12px;
line-height: 1.4;
font-variant-numeric: tabular-nums;
}
.task-worklog-content__hours-detail-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
color: var(--el-text-color-primary);
&.is-owner {
font-weight: 700;
}
}
.task-worklog-content__hours-detail-hours {
flex: 0 0 auto;
color: var(--el-color-primary);
font-weight: 500;
}
</style>

Some files were not shown because too many files have changed in this diff Show More