19 Commits

Author SHA1 Message Date
dk
f4f43814b3 fix(产品需求): 修复产品需求使用状态和终止态字典的问题。
fix(组织): 修复组织编码下拉框的数据显示问题、修复组织编码负责人无法新增的问题。
fix(管理链路): 修复管理链路高度没固定,节点全部收缩等问题。
2026-05-07 17:09:53 +08:00
dk
991cbb5278 fix(产品需求): 修复需求树的序号展示问题。 2026-05-07 11:02:10 +08:00
dk
67ef8af3fa feat(产品需求): 实现产品需求相关代码。 2026-05-06 17:50:29 +08:00
dk
89cdc62eaa fix(user): 对话框宽度被固定为800px了。 2026-04-28 15:44:15 +08:00
dk
3cd91b01e1 Merge remote-tracking branch 'origin/main' 2026-04-28 15:43:41 +08:00
dk
174d352280 fix(user): 对话框宽度被固定为800px了。 2026-04-28 15:43:19 +08:00
dk
0fca2f8c0d fix(user): 对话框宽度被固定为800px了。 2026-04-28 15:39:10 +08:00
5b9c7e781b docs(api): 添加产品动态时间线前端API文档
- 新增产品动态时间线接口文档,明确前端调用规范
- 定义接口请求参数、响应结构和字段语义说明
- 提供请求示例和错误码说明
- 添加左侧筛选项映射规则和时间格式说明

feat(product): 实现产品首页动态时间线功能

- 重构产品首页布局结构,采用档案横幅型设计
- 新增对象基础概述横幅模块
- 实现产品动态时间线面板组件
- 集成需求池管理概览和最近变化区域
- 添加扩展信息区预留模块位

chore(docs): 更新代理工作说明和前端测试策略

- 添加前端任务测试策略说明
- 更新代理工作流程规范
- 明确git操作执行边界
- 优化组件类型声明更新
2026-04-24 16:38:43 +08:00
4122dfa50d feat(product): 新增产品管理模块与字典组件功能
- 新增产品管理相关路由和页面(dashboard、list、requirement、setting)
- 实现产品基础信息编辑弹窗组件(base-info-dialog.vue)
- 添加运行时字典功能(dict-select、dict-text、dict-tag组件)
- 集成字典管理store和API调用
- 规范ID类型定义为string避免精度丢失问题
- 完善国际化资源文件支持中英文对照
- 新增对象上下文业务域入口页导航实现说明
- 添加Vue DevTools浮动入口注释说明
- 统一权限控制支持全局和对象作用域区分
- 规范分页查询参数类型定义与使用方式
2026-04-23 09:05:55 +08:00
dk
c5911ea34b feat(user): 用户页面搜索区新增用户昵称搜索框
- 在用户组件中将用户名搜索框替换为用户昵称搜索框。
- 给岗位设置最小宽度,防止内容缩起来。
fix(user-management-relation): 管理链路树显示问题
- 当组织类型不为公司时(为部门、方向、团队),为防止根节点变更导致下面的子节点出现紊乱,此时隐藏编辑按钮。
- 把用margin扩充宽度改为用min-width。
fix(role): 选中某角色后的操作列的透明显示问题
- 选中某角色后,当页面分辨率不够时(出现滚动条),创建时间等字段的内容会透过操作列
fix(post): 岗位的名称、编码字段的显示问题
- 给这两个字段设置最小宽度,防止它们缩起来。
2026-04-22 14:34:26 +08:00
dk
ca1756344c feat(system): 用户模块新增组织编码功能
- 在用户组件中新增 orgCodeOptions 数据选项
- 调用 fetchGetDictDataByCode 获取 'rdms_object_direction' 字典数据
- 将组织编码选项传递给用户组织操作对话框组件
- 在用户操作对话框中新增所属公司选择字段
- 修改组织操作对话框支持组织编码下拉选择功能
- 仅当组织类型为 'direction'(方向) 时显示编码下拉选项
- 更新国际化配置中组织类型 'direction' 的显示文本从 '条线' 改为 '方向'
2026-04-21 11:49:42 +08:00
dk
b4878845da fix(UserOperateDialog): 用户新增和编辑时的对话框里,昵称应该是必填项。
feat(User):搜索框组件新增按“所属公司”搜索;index组件新增“所属公司”字段;新增、编辑的对话框应该可以新增或修改“所属公司”。
2026-04-16 20:27:08 +08:00
dk
b265d0d4f1 fix(user-management-relation): 优化一些细节,主要是汇报关系 -> 管理链路。 2026-04-15 20:58:00 +08:00
dk
497a0906cf fix(user-management-relation): 优化一些细节,主要是代码格式、带人关系 -> 汇报关系。 2026-04-15 20:46:07 +08:00
e22f6550ae refactor(projects): 页面布局调整为rdms风格 2026-04-15 09:35:54 +08:00
dk
a6fc7b48dc feat(user-management-relation): 在用户管理页面集成用户带人关系组件,并修复相关的诸多BUG和样式问题 2026-04-14 16:33:47 +08:00
dk
9b6f5955c3 feat(user-management-relation): 创建带人关系页面、编写各组件代码,完善诸多细节 2026-04-10 16:30:42 +08:00
dk
b6a50563bc feat(user-management-relation): 创建带人关系页面(即直接管理) 2026-04-08 16:03:26 +08:00
dk
fb48977867 fix(post): 修复一些小语法问题(测试能否正常提交,绕过eslint检查) 2026-04-07 13:59:59 +08:00
125 changed files with 17507 additions and 387 deletions

View File

@@ -6,5 +6,5 @@ VITE_OTHER_SERVICE_BASE_URL= `{
"demo": "http://localhost:9528"
}`
# 鏄惁鍦ㄥ紑鍙戠幆澧冨惎鐢?Vue DevTools 娴姩鍏ュ彛
# 是否在开发环境启用 Vue DevTools 浮动入口
VITE_DEVTOOLS_ENABLED=N

1
.gitattributes vendored
View File

@@ -11,3 +11,4 @@
"*.md" eol=lf
"*.yaml" eol=lf
"*.yml" eol=lf
".*" text eol=lf

3
.gitignore vendored
View File

@@ -37,3 +37,6 @@ yarn.lock
# Docs
/docs/*
!/docs/frontend-page-resource-manifest.json
# Temp
/codeTemp/*

View File

@@ -0,0 +1,367 @@
# 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`

279
AGENTS.md Normal file
View File

@@ -0,0 +1,279 @@
# AGENTS.md
本文件为后续编码代理提供 `cn-rdms-web` 的稳定仓库上下文。
在修改代码前请先阅读。
## 适用范围
本说明适用于以 `C:\code\gitea\rdms\cn-rdms-web` 为根目录的整个仓库。
描述仓库现状时,以当前代码、当前配置、当前文档中可直接验证的事实为准;除非用户明确要求,不引入历史实现、过渡方案或猜测来解释当前行为。
默认回答保持精简,优先给结论、改动点、验证方式和必要风险;如果用户只要求分析、审阅或方案,就停留在分析层,不主动扩展到实现层。
## 交互与执行原则
- 进入实施阶段前,先说明目标、涉及模块、预计改动点和验证方式。
- 先定义验证方式,再执行修改和校验;如果没有实际运行命令,需要明确说明只做了静态检查。
- 只在当前任务需要的最小范围内改动,避免把无关重构混入同一次修改。
## 项目概览
- 应用类型RDMS 系统的 Vue 3 后台前端
- 包管理器:`pnpm`
- 运行时与工具链Vite 7、TypeScript、Pinia、Element Plus、UnoCSS
- 工作区包位于 `packages/*`,通过 `@sa/*` 引用
- Node 版本要求:`>=20.19.0`
- pnpm 版本要求:`>=8.7.0`
## 项目骨架主线
当前项目不是边写边拼的页面集合,而是已经形成闭环的后台前端骨架。后续改动优先顺着这几条主线做,而不是平行再起一套:
- 路由来源统一:页面文件与自定义路由作为源头,经 `elegant-router` 生成路由产物,再由 `build/plugins/router.ts` 集中补齐 `meta`
- 权限入口统一:常量路由和权限路由明确分流,`route store` 负责初始化、菜单生成、缓存路由和面包屑
- 请求入口统一:所有业务请求默认走 `src/service/request/index.ts`
- 页面套路统一:列表页通常拆为搜索区、表格区、操作弹层/抽屉和 `modules/*` 子组件
- 衍生资产统一:页面资源白名单从路由结构生成,不手工维护第二份页面清单
## 环境与构建说明
- Vite 路径别名:`@` -> `src`
- Vite 路径别名:`~` -> 仓库根目录
- 开发服务器默认端口:`9527`
- 预览服务器默认端口:`9725`
- 环境文件包括 `.env``.env.dev``.env.prod`
## 关键目录与文件
- `src/views`:业务页面
- `src/components`:共享组件
- `src/layouts`:应用壳、头部、侧栏、菜单、标签页、主题抽屉
- `src/store/modules`Pinia 模块,包含 app、auth、route、tab、theme
- `src/service/api`:接口封装与请求参数拼装
- `src/service/request`统一请求实例、鉴权头、加密、错误处理、token 刷新
- `src/router/routes`:自定义路由定义
- `src/router/elegant`:自动生成的路由产物
- `src/theme/settings.ts`:默认主题与布局设置
- `build/plugins/router.ts`elegant-router 配置与路由元信息生成逻辑
- `src/hooks/common/table.ts`:列表页表格 hook 主入口
- `src/hooks/common/form.ts`:表单校验与表单实例 hook
- `src/styles/scss/element-plus.scss`:当前项目表格、弹层、按钮、表单密度与公共壳样式标准
- `packages/*`:项目内本地共享库
- `docs/`:当前工作上下文的一部分,做架构级、权限级、页面规范级改动前优先查阅
## 生成文件
除非有非常明确的理由并且同步维护生成流程,否则不要手工修改生成文件。
- `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`
## 路由与导航开发口径
- 新增业务页面时,优先通过页面文件与 `build/plugins/router.ts` 补齐路由,不要手工在多个位置重复注册同一页面。
- 路由 `meta` 的中心落点是 `build/plugins/router.ts`;新增业务页的 `icon``order``roles``keepAlive` 优先在那里集中维护。
- 当前代码链路仍保留 `i18nKey` 兼容字段,但它是兼容保留项,不是新增业务页面必须补齐的默认要求。
- `meta.constant = true` 的路由属于常量路由;其余默认属于权限路由。
- 常量路由维护入口优先是 `build/plugins/router.ts``src/router/routes/custom-routes.ts`,不要把常量路由散落到业务页面逻辑里。
- 菜单图标约定属于路由契约的一部分:`meta.icon` 表示 Iconify 图标,`meta.localIcon` 表示本地 SVG 图标;不要混用字段语义。
### 对象上下文业务域入口页口径
- `product``project` 这类对象上下文业务域的入口页,按 `docs/rdms/rdms-object-context-navigation-implementation-notes.md` 口径,本来就是“先进入业务域入口页,再选择对象建立上下文”;不要把“入口页是可点击菜单”误判成配置错误。
- 对象上下文业务域的“入口态”页面,例如 `product_list -> /product/list -> view.product_list`,可以作为左侧一级入口菜单实际命中的页面;这不等于已经进入对象上下文态。
- 不要为了修复“点击入口页后只剩内容页、布局壳消失”的问题,直接要求把对象域入口菜单从“菜单”改成“目录”。先检查当前是不是动态权限路由模式,以及后端 `get-user-routes` 返回是否缺少业务域根路由。
-`VITE_AUTH_ROUTE_MODE=dynamic` 下,如果后端只返回对象域入口叶子页,而没有返回本地静态骨架中的业务域根路由,例如缺少 `product -> layout.base`、只返回 `product_list -> view.product_list`,前端必须在动态路由归一化阶段补回本地业务域骨架,而不是让入口页直接裸挂成顶层 `view.*` 路由。
- 对象上下文业务域的稳定来源仍应是本地路由骨架:业务域根路由负责 `layout.base`,入口页负责对象列表或对象选择,真正的对象功能页继续挂在该业务域下。动态路由兼容逻辑只能做“补骨架”和“对齐入口”,不要反过来推翻这层结构。
- 后续新增新的对象上下文业务域时,至少同步检查这几处是否闭环:本地静态路由骨架、`src/constants/object-context.ts` 中的 `domainKey / entryRouteKey / entryRoutePath / fallbackDefaultRouteKey`、动态路由归一化逻辑、对象上下文 store 与头部菜单切换逻辑。
## 分层职责约束
### `src/views`
- 页面层负责页面编排、交互状态、表单行为和对 store/service 的组合调用。
- 不要在页面组件里散落 URL 拼接、token 注入、统一错误提示或权限路由推导逻辑。
### `src/components`
- 共享组件负责可复用 UI 或局部业务部件。
- 不要把只服务于单个页面的复杂流程长期堆在公共组件目录中。
### `src/service/api`
- API 层负责接口封装、请求参数归一化、查询字符串拼装和返回类型对齐。
- 不要在 `views``store``components` 中重复手写同一接口地址和参数序列化逻辑。
### `src/service/request`
- 请求层负责统一请求实例、鉴权头、接口加密、成功码判定、token 刷新和通用错误处理。
- 除非任务明确需要,不要平行引入新的 `axios`/`fetch` 调用链绕开现有封装。
### `src/store/modules`
- Store 负责跨页面共享状态,例如认证、路由、标签页、主题、布局和全局 UI 状态。
- 临时性的页面局部状态优先留在页面组件或 composable 中,不要无边界堆进全局 store。
### `src/router` 与 `build/plugins/router.ts`
- 路由、菜单、权限标识、首页配置和路由元信息优先沿用当前 elegant-router 与 route store 链路。
- 不要只在页面里临时写条件分支来替代正式的路由、菜单或权限配置。
### `src/layouts` 与 `src/theme`
- 布局壳和主题设置是全局行为源头相关改动要同时检查布局组件、theme store 和默认设置。
- 不要在业务页面里复制一套平行的布局状态或主题状态。
## 业务页面开发风格
- 页面组件保持“编排层薄”。页面文件主要负责搜索参数、表格 hook、列定义、弹层开关、接口调用编排不把大量表单细节和重复交互直接堆在页面根组件里。
- 列表页优先拆出同目录下的 `modules/*` 子组件,例如搜索组件、操作弹层、详情抽屉、资源面板等。
- 系统管理下现有 `user``role``menu``dict` 页面可以作为参考实现,新增同类页面优先沿用它们的拆分方式。
- 搜索组件优先复用 `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` 的结构和交互。
- 表格操作列优先复用 `src/components/custom/business-table-action-cell.tsx`
- 操作数 `<= 2` 时默认直出;操作数 `> 2` 时优先收敛为 `1 个直出主按钮 + 1 个更多按钮`
- 表格、按钮、弹层、表单的尺寸和间距标准优先由 `src/styles/scss/element-plus.scss` 和公共组件承接,不在业务页面散落写新的局部尺寸作为事实标准。
## 表单与弹层约束
- 新增、编辑能力优先沿用 `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`
- 底部按钮顺序固定为“取消 -> 确认”,并保持右对齐。
- 单选组和开关类字段优先复用仓库既有样式钩子,例如 `business-form-radio-group``business-form-switch-field`
## 接口、路由与权限约束
- 默认沿用 `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`,不要把手工修补生成文件当成常规方案。
## 运行时字典使用口径
- 运行时字典统一由 `src/store/modules/dict/index.ts` 管理,登录后通过 `/system/dict-data/frontend-cache` 初始化;不要在业务页面重复直调字典接口。
- 字典编码常量优先收敛在 `src/constants/dict.ts`,不要在页面里散落硬编码 `dictType`
- 不要猜测字典编码。新增某个业务字段对应的字典前,先从“后端接口文档、后端字段契约、系统字典管理页”确认真实 `dictType`,再写入 `src/constants/dict.ts`
- `src/constants/dict.ts` 中每个导出的字典常量,尽量补中文注释,至少说明两件事:对应哪个业务字段、这个编码是从哪里确认出来的。
- 如果后端实际 `dictType` 带有历史命名痕迹,例如当前对象方向仍叫 `rdms_product_direction`,前端常量名优先按真实业务语义命名,不要继续把历史误导扩散到页面代码里。
- 表单下拉优先使用 `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)`,常用能力包括 `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: '' });
```
确认字典编码的典型方式:
- 后端接口文档直接写明字段使用哪个字典,例如产品 `directionCode -> rdms_product_direction`;如果该编码只是历史命名,前端常量名仍按通用业务语义命名。
- 当前系统已有页面或接口已经稳定使用某个字典,例如用户所属公司 `company -> system_user_company`
- 如果以上两种都没有,就先让后端或业务明确 `dictType`,不要前端自己命名。
## 页面资源与菜单目录约束
- 页面组件键、页面资源、菜单目录是三层不同概念,不要把它们当成同一个值。
- `component` 决定“渲染哪个页面组件”;菜单目录决定“挂在哪个业务目录下”和最终 URL页面资源主要用于从白名单中选择并回填组件信息。
- 不要因为组件键是 `view.system_dict`,就推导它只能挂在 `/system/dict`;同一个页面组件允许挂在新的业务目录下复用。
- 页面资源白名单中的标准路径是参考路径,不应反向覆盖当前菜单树已经确定的最终 URL。
- 涉及菜单编辑器或页面资源选择逻辑时,优先保证“组件可解析、资源合法、最终 URL 由菜单树决定”,不要强绑页面资源标准路径和父级目录前缀。
## 代码约定
- 优先使用现有别名导入(`@/...``~/...`),避免过长的相对路径。
- 保持与 TypeScript 严格模式兼容。
- 后端返回的主键 ID、用户 ID、对象 ID、雪花 ID、Long ID 等,一律优先按 `string` 在前端接收和传递,不要默认写成 `number`
- 这是强约束不是建议项。原因包括JavaScript `number` 无法稳定承载长整型精度、接口序列化后可能出现精度丢失、运行时还容易出现 `number/string` 键不一致,最终导致回显、筛选、映射、路由参数、对象上下文等逻辑异常。
- 这条约束要落实到所有层:`typings`、API 返回类型、页面表单 `model`、组件 `props` / `emits``ElSelect``value`、路由参数、查询参数、`Map` 键、筛选条件、store 状态,一律优先使用 `string` / `string[]`
- 明确禁止把 ID 当成普通数值处理。禁止写法包括但不限于:`Number(id)``+id``parseInt(id)``parseFloat(id)``Math.floor(id)`,以及任何“为了比较、传参、回填、提交而把 ID 转成 number”的做法。
- 比较、映射、筛选 ID 时,默认按字符串语义处理,例如 `id === targetId``Map<string, T>``Set<string>`,不要混用 `number/string` 双口径。
- 如果后端当前接口暂时还返回数值型 ID前端也必须尽可能在 `typings`、API 适配层或进入业务层前转成 `string`,不要把 ID 按 `number` 扩散到页面、store 和组件里。
- 但要注意:如果后端把超出 JS 安全整数范围的 Long 直接作为 JSON 数字返回,前端在业务层再 `String(number)` 只能得到“已经丢精度后的错误字符串”。这种情况必须明确记为接口契约风险,不要误判为“前端已安全处理”。
- 因此,新增或改造接口时,最稳妥的契约仍然是:后端长整型 ID 直接按字符串返回;前端全链路按字符串接收和传递。若后端暂未改,前端侧也不得新增 `number` 口径 ID 用法。
- 对仓库中的历史代码,原则是“不再新增 number 口径 ID当前任务触达相关链路时优先顺手矫正”不要继续复制历史写法。
- 遵循仓库现有的 Vue SFC 风格:`script setup`、类型化 store、职责单一的小型 composable/helper。
- 修改界面时优先延续 `src/layouts``src/theme` 中已有的 UI 模式,不要平行引入另一套设计体系。
- 注释保持克制,只在代码本身不够直观时补充必要说明。
## 注释与编码
- 新增或修改代码时,关键分支、关键约束和非直观实现可以补充简洁中文注释。
- 不要为了省事删除原有有效注释,也不要添加没有信息量的注释。
- 写入中文内容时保持 UTF-8 编码,并自行确认显示正常;不要用改成英文来规避编码问题。
## 校验建议
对有实际影响的代码改动,优先执行:
- 对前端页面、交互、样式类任务,除非用户明确要求新增测试、运行测试,或当前任务本身就是修测试/补测试,否则默认不补前端测试,也不主动跑前端测试命令。
- 上述前端任务默认只做静态校验;最小校验口径是 `pnpm typecheck`。如果需要更严格的静态检查,再补 `pnpm lint`
- `pnpm typecheck`
- `pnpm lint`
如果改动涉及路由,额外执行:
- `pnpm gen-route`
如果改动影响页面资源清单、菜单资源选择或页面白名单,额外执行:
- `pnpm gen:page-resource-manifest`
静态校验时,至少自查以下几点:
- 调用链是否闭环,改动是否落在正确的分层位置
- 路由、菜单、权限标识、主题状态或资源注册是否前后一致
- 改动范围是否控制在当前任务所需的最小集合内
- 文档、类型定义、接口封装或生成产物是否需要同步更新
## 提交与脚本约束
- `pre-commit` 会执行 `pnpm typecheck && pnpm lint && git diff --exit-code`,因此“代码能跑”不等于“可以提交”。
- `pnpm lint` 实际会执行 `eslint . --fix`;提交失败后要检查是否有被自动修复但尚未重新暂存的文件。
- 提交规范说明以 `docs/前端提交规范与示例.md` 为准;最稳妥的提交方式是执行 `pnpm commit:zh`,按交互选择 `type``scope``description`
- `commit-msg` 钩子会校验 Conventional Commits推荐使用 `pnpm commit:zh` 生成提交信息。
- 如果手动提交,执行 `git commit -m "type(scope): 描述"`,并确保 `type``scope`、描述写法与 `docs/前端提交规范与示例.md` 保持一致。
- 提交信息基础格式遵循 `type(scope): 描述`
- 写 Node ESM 脚本时,避免沿用 `__filename``__dirname` 这类下划线悬挂命名。
- 能并发的批量异步任务优先 `Promise.all(...)`,不要默认在循环体里直接 `await`
- 手写 `new Promise(...)` 时优先使用 block 写法,不要把 executor 写成隐式返回值的单表达式箭头函数。
- 一个函数如果开始同时承担“判断 + 转换 + 组装 + 递归”,优先拆 helper避免把复杂度堆到单个函数里。
## 代理工作说明
- 除非用户明确要求,否则不要主动执行任何 git 操作,包括但不限于 `git status``git diff``git add``git commit``git restore``git reset``git checkout`
- 如果任务需要识别用户已有改动,优先通过当前文件内容和直接读取文件来判断;只有用户明确要求查看 git 状态时,才执行对应 git 命令。
- 在工作树不干净时,不要回退与当前任务无关的变更。
- 修改布局或主题行为时,同时检查 `src/layouts/*``src/store/modules/theme/*`,因为相关逻辑分散在界面层和状态层。
- 修改路由或菜单时,同时检查 `build/plugins/router.ts``src/router/routes/*`
- 做架构级、权限级或页面规范级修改前,优先查阅 `docs/` 中现有说明,避免与当前文档约定冲突。

View File

@@ -29,6 +29,27 @@ export function setupElegantRouter() {
const constantRoutes: RouteKey[] = ['login', '403', '404', '500'];
const routeMetaMap: Partial<Record<RouteKey, Partial<RouteMeta>>> = {
product: {
icon: 'carbon:product',
order: 4
},
product_list: {
icon: 'material-symbols:view-list-outline-rounded',
order: 1,
keepAlive: true
},
product_dashboard: {
hideInMenu: true,
activeMenu: 'product_list'
},
product_requirement: {
hideInMenu: true,
activeMenu: 'product_list'
},
product_setting: {
hideInMenu: true,
activeMenu: 'product_list'
},
system: {
icon: 'carbon:cloud-service-management',
order: 9,

View File

@@ -1,13 +1,46 @@
{
"generatedAt": "2026-03-27T05:39:32.467Z",
"generatedAt": "2026-04-20T11:27:02.190Z",
"description": "Frontend visible page resource whitelist for backend route/menu configuration.",
"rules": {
"directoryComponent": "layout.base",
"pageComponentPattern": "view.<routeName>",
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
},
"total": 5,
"total": 7,
"items": [
{
"name": "product_list",
"path": "/product/list",
"component": "view.product_list",
"title": "产品列表",
"routeTitle": "product_list",
"i18nKey": "route.product_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.product_list",
"icon": "material-symbols:view-list-outline-rounded",
"localIcon": null,
"order": 1,
"keepAlive": true,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "product",
"pageType": "leaf",
"source": "generated"
},
{
"name": "system_user",
"path": "/system/user",
@@ -172,6 +205,39 @@
"parentName": "system",
"pageType": "leaf",
"source": "generated"
},
{
"name": "system_user-management-relation",
"path": "/system/user-management-relation",
"component": "view.system_user-management-relation",
"title": "管理链路",
"routeTitle": "system_user-management-relation",
"i18nKey": "route.system_user-management-relation",
"icon": null,
"localIcon": null,
"order": null,
"hideInMenu": false,
"keepAlive": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null,
"redirect": null,
"props": null,
"meta": {
"title": "管理链路",
"i18nKey": "route.system_user-management-relation",
"icon": null,
"localIcon": null,
"order": null,
"keepAlive": false,
"hideInMenu": false,
"activeMenu": null,
"multiTab": false,
"fixedIndexInTab": null
},
"parentName": "system",
"pageType": "leaf",
"source": "generated"
}
]
}

View File

@@ -0,0 +1,292 @@
# 产品对象首页改版设计说明
日期2026-04-23
## 1. 目标
本设计用于收敛 RDMS 产品对象上下文默认首页的改版方向。
本轮目标不是继续做“说明型占位页”,而是明确把当前 `/product/dashboard?objectId=...` 改成一个真正可用的产品对象首页:
- 第一眼先让用户知道当前看的是什么产品
- 第二眼能快速判断对象最近发生了什么
- 第三眼能看出需求池现在的经营状态和最近变化
- 底部为后续业务模块保留正式挂载位,而不是临时拼接入口
## 2. 已确认诉求
基于本轮对话,已确认以下用户诉求:
1. 首页顶部必须先展示产品基础概述,而不是先铺统计卡片
2. 基础概述至少包含:名称、编号、团队、产品经理等对象基础信息
3. 页面需要一块明显的时间线,用于承接产品对象与团队变更动态
4. 页面需要承接需求池管理情况,重点看总量、状态、待处理等统计信息
5. 需求相关事件不要混入对象时间线,应单独作为需求池最近变化区域
6. 快捷入口不要保留
7. 底部允许保留后续扩展区,重点预留给里程碑、风险点管理、产品资料等模块
8. 能接真实接口就接真实接口,当前没有稳定接口的区域允许先用假数据,但结构必须按正式首页来设计
## 3. 首页定位结论
本页定位不是:
- 纯报表看板
- 纯审计日志页
- 设置页搬运版
- 导航入口集合页
本页定位应当是:
- 产品对象首页
- 偏统计,也带审计
- 但页面主语始终是“当前产品对象”
换句话说,这个页面要同时回答三个问题:
1. 我现在看的是什么产品?
2. 这个产品对象最近发生了什么?
3. 这个产品的需求池现在处于什么状态?
## 4. 页面结构
### 4.1 桌面端结构
桌面端建议采用三层结构:
1. 顶部 `对象基础概述横幅`
2. 中部 `左时间线 + 右需求池双模块`
3. 底部 `扩展信息区`
推荐布局比例:
- 顶部横幅:`24 / 24`
- 中部主区:左 `16 / 24`,右 `8 / 24`
- 底部扩展区:`24 / 24`
中部左侧时间线高度应明显高于右侧任一单模块,形成首页主阅读区。
### 4.2 移动端结构
移动端统一退化为单列纵向布局,顺序为:
1. 对象基础概述横幅
2. 对象 / 团队动态时间线
3. 需求池管理概览
4. 需求池最近变化
5. 扩展信息区
移动端不强撑左右栏并排,不做卡片墙式压缩。
## 5. 模块设计
### 5.1 对象基础概述横幅
顶部采用“档案横幅型”,不采用纯指标卡片型。
横幅左侧承接对象身份信息:
- 产品名称
- 产品编号
- 当前状态标签
- 产品经理
- 团队规模
- 团队角色摘要
- 简短描述或备注
横幅右侧承接 4 个摘要指标:
- 团队人数
- 需求总量
- 待处理需求
- 最近动态时间
设计原则:
- 左侧负责建立对象识别
- 右侧负责快速判断当前概况
- 右侧指标只保留 4 项,不堆成报表卡片墙
### 5.2 对象 / 团队动态时间线
该区域位于中部左侧,是首页的主阅读区。
这条时间线只承接对象与团队变化,不承接需求事件。
第一版事件范围收敛为:
- 产品创建
- 产品状态变更
- 产品经理变更
- 成员加入
- 成员移出
- 成员角色调整
每条时间线建议展示:
- 事件标题
- 事件类型标签
- 发生时间
- 操作摘要
- 必要时展示原因或备注
表达目标是“业务时间线”,不是后台审计表格。
### 5.3 需求池管理概览
该区域位于中部右侧上半块,用于表达需求池的经营状态。
第一版首页需要优先看到的内容:
- 需求总量
- 各状态数量
- 待处理数量
- 高优先级待处理数量
展示方式建议为“摘要指标 + 状态分布列表”,不直接在首页展开完整需求表格。
这一块回答的是:
- 需求池是否健康
- 当前待处理压力大不大
- 是否存在需要优先关注的积压
### 5.4 需求池最近变化
该区域位于中部右侧下半块,与需求池管理概览上下分层,但属于同一侧栏语义。
该区域不重复展示总量,而是展示需求池最近发生的变化。
第一版建议承接:
- 最近新增需求
- 最近状态流转
- 最近关闭或完成
每条记录建议至少展示:
- 需求标题
- 动作类型
- 时间
- 当前状态或状态变更摘要
若当前没有真实数据,仍保留正式模块壳,不退化成“待开发”一句话。
### 5.5 扩展信息区
底部不再保留快捷入口,改为正式扩展信息区。
当前优先预留 3 类模块位:
- 里程碑
- 风险点管理
- 产品资料
这一层的作用是:
- 为后续对象级信息继续扩展留下稳定挂载位
- 不把中部主结构挤成信息大杂烩
- 避免为了未来模块提前做假导航入口
如果当前没有稳定接口,可先保留正式卡片结构与空态说明。
## 6. 数据策略
### 6.1 真实接口优先
当前首页优先消费现有真实接口:
- `fetchGetProduct`
- `fetchGetProductSettings`
- `fetchGetProductMembers`
这些接口足以支撑:
- 对象基础概述中的名称、编号、状态、产品经理、描述
- 团队人数与角色摘要
- 最近动态中的产品创建、状态变化、成员加入/移出
### 6.2 假数据使用边界
当前没有稳定真实接口的区域,允许先用假数据,但边界必须明确:
- 需求池管理概览
- 需求池最近变化
- 扩展信息区中的里程碑、风险点管理、产品资料摘要
假数据的使用原则:
1. 只补“当前没有稳定接口”的区域
2. 不反向污染对象基础信息
3. 不把假数据混入对象上下文 store
4. 数据源要集中放在概览页自己的 mock 模块中,方便后续替换
### 6.3 不推荐的做法
以下做法应避免:
- 把需求假数据散落写进页面组件
- 用对象 demo 数据冒充真实产品详情
- 把对象时间线和需求时间线混成一条
- 用快捷入口伪装成首页内容
## 7. 空态规则
首页至少要区分三种状态:
1. 能力未接入,只能先显示正式占位信息
2. 能力已接入,但当前该产品暂无业务数据
3. 当前用户无权限查看该模块
这三种状态不能共用一套模糊文案。
对需求池和扩展信息区,当前阶段更推荐“正式空态”而不是“待开发”。
## 8. 页面边界
首页明确不承接以下内容:
- 快捷入口导航区
- 完整团队成员表格
- 完整需求列表表格
- 设置页重表单
- 完整审计日志明细页
首页要做的是概述、判断与阅读,不是重操作页。
## 9. 实施建议
第一阶段建议先完成结构性改造:
1. 重做顶部横幅,建立对象档案感
2. 保留中部左高右双块结构
3. 用真实接口接通对象概述与对象 / 团队时间线
4. 用局部 mock 数据先接通需求池两块和底部扩展区
第二阶段再逐步替换需求池与扩展区数据源:
- 接真实需求池统计接口
- 接真实需求动态接口
- 接里程碑、风险点、产品资料摘要接口
## 10. 验证标准
本设计是否成立,可按以下标准判断:
1. 进入首页后,第一眼能认出当前产品对象
2. 用户能自然读到对象 / 团队最近发生了什么
3. 右侧能快速判断需求池当前压力与最近变化
4. 页面看起来像“对象首页”,而不是“普通后台卡片堆叠页”
5. 当前没有真实接口的区域也保留正式结构,不显得像临时占位
6. 后续新增里程碑、风险点管理、产品资料等能力时,不需要推翻整页结构
## 11. 本轮设计结论
本轮最终设计结论如下:
- 首页定位为“产品对象首页”,偏统计,也带审计,但不做纯报表页
- 顶部采用档案横幅型,先立住对象身份信息
- 中部左侧是高权重的对象 / 团队动态时间线
- 中部右侧拆为“需求池管理概览 + 需求池最近变化”上下两块
- 底部去掉快捷入口,改为正式扩展信息区
- 当前有真实接口的模块优先接真实接口
- 当前没有稳定接口的区域允许先用假数据,但必须隔离在概览页局部 mock 数据源中

View File

@@ -0,0 +1,569 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { Calendar } from '@element-plus/icons-vue';
defineOptions({ name: 'BusinessDateRangePicker' });
type DateRangeValue = [string, string];
interface DateRangeShortcut {
label: string;
value: DateRangeValue | (() => DateRangeValue);
}
interface Props {
placeholder?: string;
disabled?: boolean;
shortcuts?: DateRangeShortcut[];
popoverWidth?: number;
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '请选择日期范围',
disabled: false,
shortcuts: () => [],
popoverWidth: 458
});
const model = defineModel<DateRangeValue>({
default: () => ['', '']
});
const popoverVisible = ref(false);
const activeTab = ref<'advanced' | 'custom'>('custom');
const draftRange = ref<DateRangeValue>(normalizeDateRange(model.value));
const panelMonth = ref(dayjs().startOf('month'));
const displayText = computed(() => {
const normalizedRange = normalizeDateRange(model.value);
return normalizedRange.every(Boolean) ? normalizedRange.join(' ~ ') : '';
});
const confirmDisabled = computed(() => {
return !isCompleteDateRange(draftRange.value);
});
const defaultShortcuts = computed<DateRangeShortcut[]>(() => [
{
label: '最近 7 天',
value: () => buildRecentDateRange(7)
},
{
label: '最近 30 天',
value: () => buildRecentDateRange(30)
},
{
label: '本周',
value: () => [dayjs().startOf('week').format('YYYY-MM-DD'), dayjs().endOf('week').format('YYYY-MM-DD')]
},
{
label: '本月',
value: () => [dayjs().startOf('month').format('YYYY-MM-DD'), dayjs().endOf('month').format('YYYY-MM-DD')]
}
]);
const resolvedShortcuts = computed(() => (props.shortcuts?.length ? props.shortcuts : defaultShortcuts.value));
const panelTitle = computed(() => panelMonth.value.format('YYYY 年 M 月'));
const calendarCells = computed(() => {
const startDate = panelMonth.value.startOf('month').startOf('week');
return Array.from({ length: 42 }, (_, index) => {
const date = startDate.add(index, 'day');
const dateText = date.format('YYYY-MM-DD');
return {
date,
dateText,
dayText: date.format('D'),
isCurrentMonth: date.month() === panelMonth.value.month(),
isSelected: isSelectedDate(dateText),
isInRange: isInSelectedRange(dateText),
isStart: draftRange.value[0] === dateText,
isEnd: draftRange.value[1] === dateText
};
});
});
const activeShortcutLabel = computed(() => {
const matchedShortcut = resolvedShortcuts.value.find(shortcut => {
const shortcutRange = resolveShortcutValue(shortcut);
return shortcutRange[0] === draftRange.value[0] && shortcutRange[1] === draftRange.value[1];
});
return matchedShortcut?.label || '';
});
function buildRecentDateRange(days: number): DateRangeValue {
const end = dayjs();
const start = dayjs().subtract(Math.max(days - 1, 0), 'day');
return [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')];
}
function normalizeDateRange(value: readonly string[] | null | undefined): DateRangeValue {
const [startDate = '', endDate = ''] = value || [];
return [formatDate(startDate), formatDate(endDate)];
}
function formatDate(value: string) {
if (!value) {
return '';
}
const parsed = dayjs(value);
return parsed.isValid() ? parsed.format('YYYY-MM-DD') : '';
}
function isCompleteDateRange(value: readonly string[]) {
return value.length === 2 && value.every(item => dayjs(item).isValid());
}
function syncPanelMonth(value: readonly string[]) {
const [startDate, endDate] = value;
const candidateDate = startDate || endDate;
const parsed = dayjs(candidateDate);
panelMonth.value = parsed.isValid() ? parsed.startOf('month') : dayjs().startOf('month');
}
function isSelectedDate(dateText: string) {
return draftRange.value.includes(dateText);
}
function isInSelectedRange(dateText: string) {
if (!isCompleteDateRange(draftRange.value)) {
return false;
}
const current = dayjs(dateText);
const startDate = dayjs(draftRange.value[0]);
const endDate = dayjs(draftRange.value[1]);
return current.isAfter(startDate, 'day') && current.isBefore(endDate, 'day');
}
function resolveShortcutValue(shortcut: DateRangeShortcut) {
return normalizeDateRange(typeof shortcut.value === 'function' ? shortcut.value() : shortcut.value);
}
function updateModel(value: DateRangeValue) {
const normalizedRange = normalizeDateRange(value);
if (!isCompleteDateRange(normalizedRange)) {
return;
}
model.value = normalizedRange;
}
function handleVisibleChange(currentVisible: boolean) {
popoverVisible.value = currentVisible;
if (currentVisible) {
draftRange.value = normalizeDateRange(model.value);
syncPanelMonth(draftRange.value);
return;
}
draftRange.value = normalizeDateRange(model.value);
}
function handleShortcutClick(shortcut: DateRangeShortcut) {
const shortcutRange = resolveShortcutValue(shortcut);
draftRange.value = shortcutRange;
syncPanelMonth(shortcutRange);
}
function handleDateClick(dateText: string) {
const [startDate, endDate] = draftRange.value;
if (!startDate || (startDate && endDate)) {
draftRange.value = [dateText, ''];
return;
}
if (dayjs(dateText).isBefore(dayjs(startDate), 'day')) {
draftRange.value = [dateText, startDate];
return;
}
draftRange.value = [startDate, dateText];
}
function switchPanelMonth(step: number) {
panelMonth.value = panelMonth.value.add(step, 'month');
}
function switchPanelYear(step: number) {
panelMonth.value = panelMonth.value.add(step, 'year');
}
function handleConfirm() {
if (confirmDisabled.value) {
return;
}
updateModel(draftRange.value);
popoverVisible.value = false;
}
function handleCancel() {
draftRange.value = normalizeDateRange(model.value);
popoverVisible.value = false;
}
watch(
() => model.value,
value => {
if (!popoverVisible.value) {
draftRange.value = normalizeDateRange(value);
}
}
);
</script>
<template>
<ElPopover
:visible="popoverVisible"
trigger="click"
placement="bottom-start"
:width="popoverWidth"
popper-class="business-date-range-picker__popper"
:disabled="disabled"
@update:visible="handleVisibleChange"
>
<template #reference>
<ElInput
:model-value="displayText"
readonly
:disabled="disabled"
:placeholder="placeholder"
class="business-date-range-picker__input"
>
<template #suffix>
<ElIcon><Calendar /></ElIcon>
</template>
</ElInput>
</template>
<div class="business-date-range-picker__panel">
<aside class="business-date-range-picker__shortcuts">
<ElButton
v-for="shortcut in resolvedShortcuts"
:key="shortcut.label"
:type="activeShortcutLabel === shortcut.label ? 'primary' : 'default'"
class="business-date-range-picker__shortcut"
@click="handleShortcutClick(shortcut)"
>
{{ shortcut.label }}
</ElButton>
</aside>
<section class="business-date-range-picker__main">
<div class="business-date-range-picker__tabs">
<button
class="business-date-range-picker__tab"
:class="{ 'business-date-range-picker__tab--active': activeTab === 'advanced' }"
type="button"
@click="activeTab = 'advanced'"
>
高级选项
</button>
<button
class="business-date-range-picker__tab"
:class="{ 'business-date-range-picker__tab--active': activeTab === 'custom' }"
type="button"
@click="activeTab = 'custom'"
>
自定义
</button>
</div>
<div v-if="activeTab === 'advanced'" class="business-date-range-picker__advanced">
<ElButton
v-for="shortcut in resolvedShortcuts"
:key="shortcut.label"
plain
:type="activeShortcutLabel === shortcut.label ? 'primary' : 'default'"
class="business-date-range-picker__advanced-button"
@click="handleShortcutClick(shortcut)"
>
{{ shortcut.label }}
</ElButton>
</div>
<div v-else class="business-date-range-picker__custom">
<div class="business-date-range-picker__fields">
<ElInput v-model="draftRange[0]" class="business-date-range-picker__field" />
<span class="business-date-range-picker__separator"></span>
<ElInput v-model="draftRange[1]" class="business-date-range-picker__field" />
</div>
<div class="business-date-range-picker__calendar">
<div class="business-date-range-picker__calendar-header">
<button type="button" class="business-date-range-picker__icon-button" @click="switchPanelYear(-1)">
«
</button>
<button type="button" class="business-date-range-picker__icon-button" @click="switchPanelMonth(-1)">
</button>
<span class="business-date-range-picker__calendar-title">{{ panelTitle }}</span>
<button type="button" class="business-date-range-picker__icon-button" @click="switchPanelMonth(1)">
</button>
<button type="button" class="business-date-range-picker__icon-button" @click="switchPanelYear(1)">
»
</button>
</div>
<div class="business-date-range-picker__weekdays">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<div class="business-date-range-picker__days">
<button
v-for="cell in calendarCells"
:key="cell.dateText"
type="button"
class="business-date-range-picker__day"
:class="{
'business-date-range-picker__day--muted': !cell.isCurrentMonth,
'business-date-range-picker__day--selected': cell.isSelected,
'business-date-range-picker__day--in-range': cell.isInRange,
'business-date-range-picker__day--start': cell.isStart,
'business-date-range-picker__day--end': cell.isEnd
}"
@click="handleDateClick(cell.dateText)"
>
<span>{{ cell.dayText }}</span>
</button>
</div>
</div>
</div>
<div class="business-date-range-picker__footer">
<ElButton @click="handleCancel">取消</ElButton>
<ElButton type="primary" :disabled="confirmDisabled" @click="handleConfirm">确定</ElButton>
</div>
</section>
</div>
</ElPopover>
</template>
<style scoped>
.business-date-range-picker__input {
width: 100%;
}
.business-date-range-picker__panel {
display: grid;
grid-template-columns: 102px minmax(0, 1fr);
overflow: hidden;
border-radius: 8px;
background-color: var(--el-bg-color);
}
.business-date-range-picker__shortcuts {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 10px;
border-right: 1px solid var(--el-border-color-lighter);
}
.business-date-range-picker__shortcut {
width: 78px;
margin-left: 0;
}
.business-date-range-picker__main {
min-width: 0;
}
.business-date-range-picker__tabs {
display: flex;
align-items: center;
height: 40px;
padding: 0 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.business-date-range-picker__tab {
position: relative;
height: 40px;
padding: 0 12px;
border: none;
background: transparent;
color: var(--el-text-color-regular);
cursor: pointer;
font-size: 14px;
}
.business-date-range-picker__tab--active {
color: var(--el-color-primary);
}
.business-date-range-picker__tab--active::after {
position: absolute;
right: 12px;
bottom: 0;
left: 12px;
height: 2px;
background-color: var(--el-color-primary);
content: '';
}
.business-date-range-picker__advanced {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
min-height: 230px;
padding: 16px;
}
.business-date-range-picker__advanced-button {
margin-left: 0;
}
.business-date-range-picker__custom {
padding: 10px 8px 0;
}
.business-date-range-picker__fields {
display: grid;
grid-template-columns: minmax(0, 1fr) 20px minmax(0, 1fr);
align-items: center;
gap: 6px;
padding: 0 8px 6px;
}
.business-date-range-picker__separator {
color: var(--el-text-color-placeholder);
text-align: center;
}
.business-date-range-picker__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 12px 12px;
border-top: 1px solid var(--el-border-color-light);
}
:global(.business-date-range-picker__popper.el-popover.el-popper) {
padding: 0;
border: 1px solid var(--el-border-color-light);
box-shadow: var(--el-box-shadow-light);
}
.business-date-range-picker__calendar {
padding: 0 10px 8px;
}
.business-date-range-picker__calendar-header {
display: grid;
grid-template-columns: 28px 28px minmax(0, 1fr) 28px 28px;
align-items: center;
height: 36px;
color: var(--el-text-color-primary);
}
.business-date-range-picker__calendar-title {
text-align: center;
font-size: 16px;
}
.business-date-range-picker__icon-button {
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--el-text-color-placeholder);
cursor: pointer;
font-size: 18px;
line-height: 28px;
}
.business-date-range-picker__icon-button:hover {
color: var(--el-color-primary);
background-color: var(--el-fill-color-light);
}
.business-date-range-picker__weekdays,
.business-date-range-picker__days {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.business-date-range-picker__weekdays {
height: 30px;
align-items: center;
border-bottom: 1px solid var(--el-border-color-lighter);
color: var(--el-text-color-regular);
font-size: 13px;
text-align: center;
}
.business-date-range-picker__days {
padding-top: 4px;
}
.business-date-range-picker__day {
height: 30px;
padding: 0;
border: none;
background: transparent;
color: var(--el-text-color-regular);
cursor: pointer;
font-size: 13px;
}
.business-date-range-picker__day span {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
}
.business-date-range-picker__day:hover span {
color: var(--el-color-primary);
}
.business-date-range-picker__day--muted {
color: var(--el-text-color-placeholder);
}
.business-date-range-picker__day--in-range {
background-color: var(--el-color-primary-light-9);
}
.business-date-range-picker__day--selected span {
background-color: var(--el-color-primary);
color: #fff;
}
.business-date-range-picker__day--start {
border-radius: 6px 0 0 6px;
}
.business-date-range-picker__day--end {
border-radius: 0 6px 6px 0;
}
</style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useDict } from '@/hooks/business/dict';
defineOptions({ name: 'DictSelect' });
interface Props {
dictCode: string;
placeholder?: string;
disabled?: boolean;
clearable?: boolean;
filterable?: boolean;
onlyEnabled?: boolean;
multiple?: boolean;
collapseTags?: boolean;
collapseTagsTooltip?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '请选择',
disabled: false,
clearable: true,
filterable: false,
onlyEnabled: true,
multiple: false,
collapseTags: false,
collapseTagsTooltip: false
});
const model = defineModel<string | number | Array<string | number> | null | undefined>({
default: undefined
});
const { enabledDictData, dictData } = useDict(() => props.dictCode);
const dictOptions = computed(() => {
const source = props.onlyEnabled ? enabledDictData.value : dictData.value;
return source.map(item => ({
label: item.label,
value: item.value
}));
});
</script>
<template>
<ElSelect
v-model="model"
class="w-full"
:placeholder="props.placeholder"
:disabled="props.disabled"
:clearable="props.clearable"
:filterable="props.filterable"
:multiple="props.multiple"
:collapse-tags="props.collapseTags"
:collapse-tags-tooltip="props.collapseTagsTooltip"
>
<ElOption v-for="item in dictOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</template>
<style scoped></style>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import DictText from './dict-text.vue';
defineOptions({ name: 'DictTag' });
type DictValue = string | number;
type DictTagType = 'primary' | 'success' | 'info' | 'warning' | 'danger';
type DictTagEffect = 'dark' | 'light' | 'plain';
type DictTagSize = 'large' | 'default' | 'small';
interface Props {
dictCode: string;
value?: DictValue | DictValue[] | null;
fallback?: string;
separator?: string;
onlyEnabled?: boolean;
type?: DictTagType;
effect?: DictTagEffect;
size?: DictTagSize;
round?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
value: null,
fallback: '--',
separator: ' / ',
onlyEnabled: false,
type: undefined,
effect: 'light',
size: 'default',
round: false
});
</script>
<template>
<ElTag :type="props.type" :effect="props.effect" :size="props.size" :round="props.round">
<DictText
:dict-code="props.dictCode"
:value="props.value"
:fallback="props.fallback"
:separator="props.separator"
:only-enabled="props.onlyEnabled"
tag="span"
/>
</ElTag>
</template>
<style scoped></style>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useDict } from '@/hooks/business/dict';
defineOptions({
name: 'DictText',
inheritAttrs: false
});
type DictValue = string | number;
interface Props {
dictCode: string;
value?: DictValue | DictValue[] | null;
fallback?: string;
separator?: string;
onlyEnabled?: boolean;
tag?: string;
}
const props = withDefaults(defineProps<Props>(), {
value: null,
fallback: '--',
separator: ' / ',
onlyEnabled: false,
tag: 'span'
});
const { getLabel, getLabels } = useDict(() => props.dictCode);
const text = computed(() => {
if (Array.isArray(props.value)) {
return getLabels(props.value, {
fallback: props.fallback,
separator: props.separator,
onlyEnabled: props.onlyEnabled
});
}
return getLabel(props.value, {
fallback: props.fallback,
onlyEnabled: props.onlyEnabled
});
});
</script>
<template>
<component :is="props.tag" v-bind="$attrs">
{{ text }}
</component>
</template>
<style scoped></style>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue';
defineOptions({ name: 'ReadonlyField' });
interface Props {
value?: string | number | null;
placeholder?: string;
}
const props = withDefaults(defineProps<Props>(), {
value: '',
placeholder: '--'
});
const displayValue = computed(() => {
if (props.value === null || props.value === undefined || props.value === '') {
return props.placeholder;
}
return String(props.value);
});
</script>
<template>
<div class="readonly-field">
{{ displayValue }}
</div>
</template>
<style scoped>
.readonly-field {
display: flex;
align-items: center;
width: 100%;
height: 32px;
padding: 0 12px;
border-radius: 4px;
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;
color: rgb(51 65 85 / 96%);
font-size: 14px;
cursor: default;
}
</style>

View File

@@ -17,6 +17,20 @@ export const commonStatusOptions = [
{ value: 1, label: commonStatusRecord[1] }
] satisfies CommonType.Option<Api.SystemManage.CommonStatus, App.I18n.I18nKey>[];
export const scopeTypeRecord: Record<Api.SystemManage.ScopeType, App.I18n.I18nKey> = {
global: 'page.system.common.scopeType.global',
object: 'page.system.common.scopeType.object'
};
export const scopeTypeOptions = transformRecordToOption(scopeTypeRecord);
export const objectTypeRecord: Record<Api.SystemManage.ObjectType, App.I18n.I18nKey> = {
product: 'page.system.common.objectType.product',
project: 'page.system.common.objectType.project'
};
export const objectTypeOptions = transformRecordToOption(objectTypeRecord);
export const dictStatusRecord: Record<'0' | '1', App.I18n.I18nKey> = {
'0': 'page.system.common.status.enable',
'1': 'page.system.common.status.disable'

61
src/constants/dict.ts Normal file
View File

@@ -0,0 +1,61 @@
/**
* 运行时字典编码常量
*
* 约定:
* 1. 不要在业务页面硬编码 dictType。
* 2. 新增字典编码前,先从“后端接口文档 / 后端字段契约 / 系统字典管理页”确认真实 dictType。
* 3. 确认后再收敛到本文件,并补上中文注释说明“这个编码对应哪个业务字段”。
*/
/**
* 对象方向字典编码
*
* 对应业务字段:产品、项目及后续其他对象中的 directionCode / direction
* 来源口径:
* 1. 方向类业务语义已经纠正为“对象通用方向”
* 2. 后端字典编码已准备切到更准确的 rdms_object_direction
*
* 说明:
* 前端页面统一使用本常量,不再继续使用带 product 痕迹的旧命名。
*/
export const RDMS_OBJECT_DIRECTION_DICT_CODE = 'rdms_object_direction';
/**
* 对象方向历史字典编码
*
* 用途:
* 仅用于前后端切换期间兼容旧数据,不允许新页面直接使用。
*/
export const RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE = 'rdms_product_direction';
/**
* 用户所属公司字典编码
*
* 对应业务字段:用户相关接口和页面中的 company
* 来源口径:当前系统"用户管理"页面按系统字典 system_user_company 做下拉和文案回显
*/
export const SYSTEM_USER_COMPANY_DICT_CODE = 'system_user_company';
/**
* 需求来源类型字典编码
*
* 对应业务字段:需求相关接口和页面中的 sourceType
* 来源口径:产品需求文档中定义,标签包括工单流转、手动新增
*/
export const RDMS_REQ_SOURCE_TYPE_DICT_CODE = 'rdms_req_source_type';
/**
* 需求优先级字典编码
*
* 对应业务字段:需求相关接口和页面中的 priority
* 来源口径:产品需求文档中定义,标签包括紧急、高、中、低
*/
export const RDMS_REQ_PRIORITY_DICT_CODE = 'rdms_req_priority';
/**
* 需求分类字典编码
*
* 对应业务字段:需求相关接口和页面中的 category
* 来源口径:产品需求文档中定义,标签包括工程需求、用户需求、安全需求、体验优化、功能需求
*/
export const RDMS_REQ_CATEGORY_DICT_CODE = 'rdms_req_category';

View File

@@ -0,0 +1,59 @@
import { WEB_SERVICE_PREFIX } from './service';
export const OBJECT_CONTEXT_QUERY_KEY = 'objectId' as const;
export const objectContextDomainConfigs: App.ObjectContext.DomainConfig[] = [
{
domainKey: 'project',
mode: 'object-context',
objectType: 'project',
routePathPrefixes: ['/project'],
entryRouteKey: 'project_list',
entryRoutePath: '/project/list',
fallbackDefaultRouteKey: 'project_dashboard',
fallbackDefaultRoutePath: '/project/dashboard',
contextApiPath: `${WEB_SERVICE_PREFIX}/project/context`,
contextApiObjectIdParamKey: 'projectId',
contextApiObjectIdPlacement: 'query',
objectIdQueryKey: OBJECT_CONTEXT_QUERY_KEY
},
{
domainKey: 'product',
mode: 'object-context',
objectType: 'product',
routePathPrefixes: ['/product'],
entryRouteKey: 'product_list',
entryRoutePath: '/product/list',
fallbackDefaultRouteKey: 'product_dashboard',
fallbackDefaultRoutePath: '/product/dashboard',
contextApiPath: `${WEB_SERVICE_PREFIX}/project/product/{id}/context`,
contextApiObjectIdParamKey: 'id',
contextApiObjectIdPlacement: 'path',
objectIdQueryKey: OBJECT_CONTEXT_QUERY_KEY
}
];
function normalizePath(path: string) {
if (!path) {
return '/';
}
return path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
}
function isPathMatchedByPrefix(path: string, prefix: string) {
const normalizedPath = normalizePath(path);
const normalizedPrefix = normalizePath(prefix);
return normalizedPath === normalizedPrefix || normalizedPath.startsWith(`${normalizedPrefix}/`);
}
export function getObjectContextDomainConfigByPath(path: string) {
return objectContextDomainConfigs.find(config =>
config.routePathPrefixes.some(prefix => isPathMatchedByPrefix(path, prefix))
);
}
export function isObjectContextEntryPath(path: string, config: App.ObjectContext.DomainConfig) {
return normalizePath(path) === normalizePath(config.entryRoutePath);
}

View File

@@ -0,0 +1,455 @@
export interface DemoProductRequirement {
id: string;
title: string;
status: '待评审' | '设计中' | '开发中' | '验证中' | '已完成';
priority: 'P0' | 'P1' | 'P2';
owner: string;
module: string;
updatedAt: string;
}
export interface DemoProductRoadmapItem {
id: string;
title: string;
window: string;
status: '已排期' | '推进中' | '风险关注';
summary: string;
}
export type DemoProductManageStatus = '启用产品' | '归档产品' | '暂停产品' | '废弃产品';
export interface DemoProduct {
id: string;
name: string;
code: string;
owner: string;
department: string;
status: '规划中' | '研发中' | '稳定运营';
manageStatus: DemoProductManageStatus;
stage: '探索' | '增长' | '平台化';
version: string;
releaseTarget: string;
updatedAt: string;
health: '健康' | '关注' | '加速';
summary: string;
tags: string[];
teamCount: number;
requirementCount: number;
bugCount: number;
focus: string[];
requirements: DemoProductRequirement[];
roadmap: DemoProductRoadmapItem[];
}
export const demoProducts: DemoProduct[] = [
{
id: 'product-alpha',
name: '产品中台 Alpha',
code: 'ALPHA',
owner: '林语辰',
department: '平台产品部',
status: '研发中',
manageStatus: '启用产品',
stage: '平台化',
version: 'v2.8.0',
releaseTarget: '2026-05-10',
updatedAt: '2026-04-16',
health: '健康',
summary: '面向多业务线复用的产品主数据与流程配置中台,当前重点在规则编排和版本发布节奏收口。',
tags: ['平台能力', '规则编排', '统一发布'],
teamCount: 14,
requirementCount: 26,
bugCount: 5,
focus: ['统一配置台账', '版本灰度策略', '对象权限接入'],
requirements: [
{
id: 'REQ-101',
title: '支持产品对象上下文的头部导航切换',
status: '开发中',
priority: 'P0',
owner: '赵明远',
module: '工作台',
updatedAt: '2026-04-15'
},
{
id: 'REQ-108',
title: '接入对象成员角色模板的快捷查看',
status: '设计中',
priority: 'P1',
owner: '姜知夏',
module: '权限',
updatedAt: '2026-04-13'
},
{
id: 'REQ-112',
title: '支持发布包差异对比摘要',
status: '待评审',
priority: 'P1',
owner: '周承安',
module: '发布',
updatedAt: '2026-04-11'
}
],
roadmap: [
{
id: 'RM-1',
title: '对象上下文导航试点',
window: '2026 Q2',
status: '推进中',
summary: '先在产品域打通对象入口、头部导航、按钮权限隔离。'
},
{
id: 'RM-2',
title: '规则编排配置台账',
window: '2026 Q2',
status: '已排期',
summary: '把历史分散配置统一归档到产品规则台账。'
},
{
id: 'RM-3',
title: '发布治理看板',
window: '2026 Q3',
status: '风险关注',
summary: '依赖后端事件流与测试数据沉淀,排期受联调进度影响。'
}
]
},
{
id: 'product-orbit',
name: 'Orbit 客户协同端',
code: 'ORBIT',
owner: '程清和',
department: '客户体验部',
status: '稳定运营',
manageStatus: '归档产品',
stage: '增长',
version: 'v1.9.3',
releaseTarget: '2026-04-28',
updatedAt: '2026-04-14',
health: '关注',
summary: '围绕客户协同与交付反馈的门户产品,近期重点是降低工单回流和优化首屏转化链路。',
tags: ['客户协同', '交付门户', '反馈闭环'],
teamCount: 10,
requirementCount: 18,
bugCount: 9,
focus: ['首屏引导改版', '交付看板合并', '通知触达回收'],
requirements: [
{
id: 'REQ-203',
title: '重构客户交付看板首页信息密度',
status: '验证中',
priority: 'P0',
owner: '顾思远',
module: '门户',
updatedAt: '2026-04-15'
},
{
id: 'REQ-217',
title: '补充客户联系人生命周期标签',
status: '开发中',
priority: 'P1',
owner: '何嘉宁',
module: '客户画像',
updatedAt: '2026-04-12'
}
],
roadmap: [
{
id: 'RM-4',
title: '客户首页分群策略升级',
window: '2026 Q2',
status: '推进中',
summary: '把静态首页切分为按客户阶段动态呈现的版本。'
},
{
id: 'RM-5',
title: '交付反馈闭环自动催办',
window: '2026 Q3',
status: '已排期',
summary: '通过规则任务减少人工跟进成本。'
}
]
},
{
id: 'product-lighthouse',
name: 'Lighthouse 经营驾驶舱',
code: 'LIGHT',
owner: '宋知序',
department: '商业产品部',
status: '稳定运营',
manageStatus: '启用产品',
stage: '增长',
version: 'v3.2.1',
releaseTarget: '2026-05-22',
updatedAt: '2026-04-15',
health: '健康',
summary: '承接经营看板、指标订阅和异常播报的统一产品驾驶舱,当前聚焦跨部门指标口径收敛与高频场景提效。',
tags: ['经营分析', '指标订阅', '统一驾驶舱'],
teamCount: 12,
requirementCount: 21,
bugCount: 3,
focus: ['指标口径治理', '异常订阅编排', '高层驾驶舱视图'],
requirements: [
{
id: 'REQ-221',
title: '支持核心经营指标的口径版本管理',
status: '开发中',
priority: 'P0',
owner: '孟之遥',
module: '指标中心',
updatedAt: '2026-04-14'
},
{
id: 'REQ-228',
title: '补齐驾驶舱异常波动播报模板',
status: '待评审',
priority: 'P1',
owner: '韩屿川',
module: '播报',
updatedAt: '2026-04-11'
}
],
roadmap: [
{
id: 'RM-8',
title: '经营指标主题化看板升级',
window: '2026 Q2',
status: '推进中',
summary: '将现有指标页按经营主题重组,减少跨页面跳转成本。'
}
]
},
{
id: 'product-pulse',
name: 'Pulse 消息协同台',
code: 'PULSE',
owner: '许闻洲',
department: '协同平台部',
status: '规划中',
manageStatus: '启用产品',
stage: '探索',
version: 'v0.6.4',
releaseTarget: '2026-05-30',
updatedAt: '2026-04-13',
health: '关注',
summary: '统一承接站内消息、流程通知与消息编排的试点产品,当前重点是通知模板复用与多渠道触达一致性。',
tags: ['消息编排', '流程通知', '多渠道触达'],
teamCount: 7,
requirementCount: 13,
bugCount: 2,
focus: ['模板复用', '渠道一致性', '消息审计留痕'],
requirements: [
{
id: 'REQ-331',
title: '梳理流程类通知的统一模板规范',
status: '设计中',
priority: 'P1',
owner: '丁和畅',
module: '模板中心',
updatedAt: '2026-04-12'
}
],
roadmap: [
{
id: 'RM-9',
title: '流程消息中心试点',
window: '2026 Q2',
status: '已排期',
summary: '先打通审批、告警两条主链路,验证模板与渠道编排能力。'
}
]
},
{
id: 'product-nova',
name: 'Nova 数据服务台',
code: 'NOVA',
owner: '陆闻笙',
department: '数据中台部',
status: '研发中',
manageStatus: '暂停产品',
stage: '探索',
version: 'v0.9.0',
releaseTarget: '2026-06-18',
updatedAt: '2026-04-12',
health: '加速',
summary: '承接跨系统数据接入、数据模型装配与查询服务的试点产品,当前仍在能力边界探索阶段。',
tags: ['数据服务', '模型装配', '试点产品'],
teamCount: 8,
requirementCount: 11,
bugCount: 4,
focus: ['接入链路模板化', '查询 SLA 监控', '多租户样例沉淀'],
requirements: [
{
id: 'REQ-301',
title: '沉淀数据接入模板库',
status: '开发中',
priority: 'P0',
owner: '沈南舟',
module: '接入',
updatedAt: '2026-04-16'
},
{
id: 'REQ-306',
title: '接入失败告警卡片化展示',
status: '设计中',
priority: 'P2',
owner: '夏安宁',
module: '监控',
updatedAt: '2026-04-10'
}
],
roadmap: [
{
id: 'RM-6',
title: '试点租户接入扩容',
window: '2026 Q2',
status: '推进中',
summary: '把当前 2 个试点租户扩到 6 个,验证模型复用率。'
}
]
},
{
id: 'product-atlas',
name: 'Atlas 组织配置台',
code: 'ATLAS',
owner: '冯见山',
department: '企业应用部',
status: '稳定运营',
manageStatus: '归档产品',
stage: '平台化',
version: 'v2.6.8',
releaseTarget: '2026-02-28',
updatedAt: '2026-04-09',
health: '健康',
summary: '曾用于统一组织架构、岗位映射和通讯录同步的配置平台,现已完成能力迁移,仅作为历史归档保留。',
tags: ['组织配置', '历史归档', '同步映射'],
teamCount: 6,
requirementCount: 8,
bugCount: 0,
focus: ['历史配置追溯', '迁移审计', '只读查询'],
requirements: [
{
id: 'REQ-510',
title: '补充组织配置迁移后的审计说明',
status: '已完成',
priority: 'P2',
owner: '罗听雪',
module: '审计',
updatedAt: '2026-04-06'
}
],
roadmap: [
{
id: 'RM-10',
title: '归档访问范围收口',
window: '2026 Q2',
status: '已排期',
summary: '控制仅审计角色可访问历史配置详情,普通角色只看摘要。'
}
]
},
{
id: 'product-sprint',
name: 'Sprint 交付排期台',
code: 'SPRINT',
owner: '魏书言',
department: '交付效能部',
status: '研发中',
manageStatus: '暂停产品',
stage: '增长',
version: 'v1.3.0',
releaseTarget: '2026-06-08',
updatedAt: '2026-04-08',
health: '关注',
summary: '面向交付里程碑排期、风险跟踪和协作节奏对齐的产品,当前因上游流程调整进入阶段性暂停。',
tags: ['交付排期', '风险跟踪', '协作节奏'],
teamCount: 9,
requirementCount: 15,
bugCount: 6,
focus: ['排期模板统一', '跨团队风险同步', '里程碑预警'],
requirements: [
{
id: 'REQ-612',
title: '梳理暂停期间保留的风险同步能力范围',
status: '待评审',
priority: 'P1',
owner: '徐青禾',
module: '风险中心',
updatedAt: '2026-04-07'
}
],
roadmap: [
{
id: 'RM-11',
title: '暂停期能力边界梳理',
window: '2026 Q2',
status: '风险关注',
summary: '待交付流程新方案确定后,再决定是否恢复后续迭代。'
}
]
},
{
id: 'product-legacy',
name: 'Legacy 营销活动台',
code: 'LEGACY',
owner: '陈念初',
department: '增长运营部',
status: '稳定运营',
manageStatus: '废弃产品',
stage: '增长',
version: 'v3.4.1',
releaseTarget: '2026-03-18',
updatedAt: '2026-03-25',
health: '关注',
summary: '面向历史营销活动配置与投放归档的旧产品,目前仅保留数据查询和审计访问能力,不再纳入持续建设计划。',
tags: ['历史归档', '活动投放', '审计留痕'],
teamCount: 5,
requirementCount: 6,
bugCount: 1,
focus: ['历史活动追溯', '旧投放数据迁移', '权限范围收敛'],
requirements: [
{
id: 'REQ-401',
title: '补充历史活动包的只读访问说明',
status: '已完成',
priority: 'P2',
owner: '白昭宁',
module: '审计',
updatedAt: '2026-03-20'
}
],
roadmap: [
{
id: 'RM-7',
title: '历史活动数据归档收尾',
window: '2026 Q1',
status: '已排期',
summary: '只保留审计查询链路,后续不再承接新的活动能力建设。'
}
]
}
];
export function getDemoProductById(productId: string) {
return demoProducts.find(item => item.id === productId) || null;
}
export function getProductStatusType(status: DemoProduct['status']) {
const statusTypeMap: Record<DemoProduct['status'], 'success' | 'warning' | 'info'> = {
: 'info',
: 'warning',
: 'success'
};
return statusTypeMap[status];
}
export function getProductHealthType(health: DemoProduct['health']) {
const healthTypeMap: Record<DemoProduct['health'], 'success' | 'warning' | 'danger'> = {
: 'success',
: 'warning',
: 'danger'
};
return healthTypeMap[health];
}

View File

@@ -0,0 +1,46 @@
export type AuthSource = 'global' | 'object' | 'both';
export interface AuthDirectiveBindingValue {
code: string | string[];
source?: AuthSource;
}
export interface AuthDirectiveCodeSource {
globalButtonCodes: string[];
objectButtonCodes: string[];
}
function normalizeCodes(codes: string | string[]) {
return Array.isArray(codes) ? codes : [codes];
}
function includesAny(sourceCodes: string[], targetCodes: string[]) {
return targetCodes.some(code => sourceCodes.includes(code));
}
export function resolveAuthVisible(
bindingValue: string | AuthDirectiveBindingValue,
codeSource: AuthDirectiveCodeSource
) {
const resolvedBinding =
typeof bindingValue === 'string'
? {
code: bindingValue,
source: 'global' as const
}
: bindingValue;
const targetCodes = normalizeCodes(resolvedBinding.code);
const hasGlobal = includesAny(codeSource.globalButtonCodes, targetCodes);
const hasObject = includesAny(codeSource.objectButtonCodes, targetCodes);
if (resolvedBinding.source === 'object') {
return hasObject;
}
if (resolvedBinding.source === 'both') {
return hasGlobal || hasObject;
}
return hasGlobal;
}

44
src/directives/auth.ts Normal file
View File

@@ -0,0 +1,44 @@
import { watchEffect } from 'vue';
import type { Directive } from 'vue';
import { useAuthStore } from '@/store/modules/auth';
import { useObjectContextStore } from '@/store/modules/object-context';
import { type AuthDirectiveBindingValue, resolveAuthVisible } from './auth-shared';
type AuthDirectiveElement = HTMLElement & {
authStopHandle?: (() => void) | null;
};
function toggleElementVisible(el: HTMLElement, visible: boolean) {
el.style.display = visible ? '' : 'none';
}
function getVisible(bindingValue: string | AuthDirectiveBindingValue) {
const authStore = useAuthStore();
const objectContextStore = useObjectContextStore();
return resolveAuthVisible(bindingValue, {
globalButtonCodes: authStore.userInfo.buttons,
objectButtonCodes: objectContextStore.buttonCodes
});
}
function bindAuthEffect(el: AuthDirectiveElement, bindingValue: string | AuthDirectiveBindingValue) {
el.authStopHandle?.();
el.authStopHandle = watchEffect(() => {
toggleElementVisible(el, getVisible(bindingValue));
});
}
export const authDirective: Directive<AuthDirectiveElement, string | AuthDirectiveBindingValue> = {
mounted(el, binding) {
bindAuthEffect(el, binding.value);
},
updated(el, binding) {
bindAuthEffect(el, binding.value);
},
unmounted(el) {
el.authStopHandle?.();
el.authStopHandle = null;
}
};

6
src/directives/index.ts Normal file
View File

@@ -0,0 +1,6 @@
import type { App } from 'vue';
import { authDirective } from './auth';
export function setupDirectives(app: App) {
app.directive('auth', authDirective);
}

View File

@@ -2,6 +2,8 @@ export enum SetupStoreId {
App = 'app-store',
Theme = 'theme-store',
Auth = 'auth-store',
Dict = 'dict-store',
Route = 'route-store',
Tab = 'tab-store'
Tab = 'tab-store',
ObjectContext = 'object-context-store'
}

View File

@@ -1,7 +1,9 @@
import { useAuthStore } from '@/store/modules/auth';
import { useObjectContextStore } from '@/store/modules/object-context';
export function useAuth() {
const authStore = useAuthStore();
const objectContextStore = useObjectContextStore();
function hasAuth(codes: string | string[]) {
if (!authStore.isLogin) {
@@ -15,7 +17,14 @@ export function useAuth() {
return codes.some(code => authStore.userInfo.buttons.includes(code));
}
function hasObjectAuth(codes: string | string[]) {
const targetCodes = typeof codes === 'string' ? [codes] : codes;
return targetCodes.some(code => objectContextStore.buttonCodes.includes(code));
}
return {
hasAuth
hasAuth,
hasObjectAuth
};
}

View File

@@ -0,0 +1,86 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue';
import { useDictStore } from '@/store/modules/dict';
type DictCode = string | Ref<string> | ComputedRef<string>;
type DictValue = string | number | null | undefined;
type DictValueList = Array<DictValue> | null | undefined;
type DictFilterOptions = {
onlyEnabled?: boolean;
};
type DictLabelOptions = string | (DictFilterOptions & { fallback?: string });
type DictLabelsOptions = string | (DictFilterOptions & { fallback?: string; separator?: string });
function normalizeLabelOptions(options?: DictLabelOptions) {
if (typeof options === 'string') {
return {
fallback: options,
onlyEnabled: false
};
}
return {
fallback: options?.fallback ?? '--',
onlyEnabled: options?.onlyEnabled ?? false
};
}
function normalizeLabelsOptions(options?: DictLabelsOptions) {
if (typeof options === 'string') {
return {
fallback: options,
separator: ' / ',
onlyEnabled: false
};
}
return {
fallback: options?.fallback ?? '--',
separator: options?.separator ?? ' / ',
onlyEnabled: options?.onlyEnabled ?? false
};
}
export function useDict(dictCode: DictCode | MaybeRefOrGetter<string>) {
const dictStore = useDictStore();
const currentDictCode = computed(() => toValue(dictCode));
const dictData = computed(() => dictStore.getDictData(currentDictCode.value));
const enabledDictData = computed(() => dictStore.getDictData(currentDictCode.value, true));
const dictOptions = computed(() => dictStore.getDictOptions(currentDictCode.value));
const dictMap = computed(() => new Map(dictData.value.map(item => [item.value, item])));
const enabledDictMap = computed(() => new Map(enabledDictData.value.map(item => [item.value, item])));
function getItem(value?: DictValue, options: DictFilterOptions = {}) {
return dictStore.getDictItem(currentDictCode.value, value, options);
}
function getLabel(value?: DictValue, options?: DictLabelOptions) {
const normalizedOptions = normalizeLabelOptions(options);
return dictStore.getDictLabel(currentDictCode.value, value, normalizedOptions);
}
function getLabels(values?: DictValueList, options?: DictLabelsOptions) {
const normalizedOptions = normalizeLabelsOptions(options);
return dictStore.getDictLabels(currentDictCode.value, values, normalizedOptions);
}
function hasValue(value?: DictValue, options: DictFilterOptions = {}) {
return dictStore.hasDictValue(currentDictCode.value, value, options);
}
return {
dictData,
enabledDictData,
dictOptions,
dictMap,
enabledDictMap,
getItem,
getLabel,
getLabels,
hasValue
};
}

View File

@@ -1,7 +1,7 @@
import { useRouter } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router';
import type { RouteKey } from '@elegant-router/types';
import { router as globalRouter } from '@/router';
import { getGlobalRouter } from '@/router/instance';
/**
* Router push
@@ -11,6 +11,7 @@ import { router as globalRouter } from '@/router';
* @param inSetup Whether is in vue script setup
*/
export function useRouterPush(inSetup = true) {
const globalRouter = getGlobalRouter();
const router = inSetup ? useRouter() : globalRouter;
const route = globalRouter.currentRoute;

View File

@@ -16,18 +16,18 @@ defineOptions({ name: 'BaseLayout' });
const appStore = useAppStore();
const themeStore = useThemeStore();
const { childLevelMenus, isActiveFirstLevelMenuHasChildren } = setupMixMenuContext();
const { childLevelMenus } = setupMixMenuContext();
const GlobalMenu = defineAsyncComponent(() => import('../modules/global-menu/index.vue'));
const layoutMode = computed(() => {
const vertical: LayoutMode = 'vertical';
const horizontal: LayoutMode = 'horizontal';
return themeStore.layout.mode.includes(vertical) ? vertical : horizontal;
return themeStore.layoutMode.includes(vertical) ? vertical : horizontal;
});
const headerProps = computed(() => {
const { mode, reverseHorizontalMix } = themeStore.layout;
const mode = themeStore.layoutMode;
const headerPropsConfig: Record<UnionKey.ThemeLayoutMode, App.Global.HeaderProps> = {
vertical: {
@@ -48,31 +48,26 @@ const headerProps = computed(() => {
'horizontal-mix': {
showLogo: true,
showMenu: true,
showMenuToggler: reverseHorizontalMix && isActiveFirstLevelMenuHasChildren.value
showMenuToggler: false
}
};
return headerPropsConfig[mode];
});
const siderVisible = computed(() => themeStore.layout.mode !== 'horizontal');
const siderVisible = computed(() => themeStore.layoutMode !== 'horizontal');
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
const isVerticalMix = computed(() => themeStore.layoutMode === 'vertical-mix');
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix');
const isHorizontalMix = computed(() => themeStore.layoutMode === 'horizontal-mix');
const siderWidth = computed(() => getSiderWidth());
const siderCollapsedWidth = computed(() => getSiderCollapsedWidth());
function getSiderWidth() {
const { reverseHorizontalMix } = themeStore.layout;
const { width, mixWidth, mixChildMenuWidth } = themeStore.sider;
if (isHorizontalMix.value && reverseHorizontalMix) {
return isActiveFirstLevelMenuHasChildren.value ? width : 0;
}
let w = isVerticalMix.value || isHorizontalMix.value ? mixWidth : width;
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
@@ -83,13 +78,8 @@ function getSiderWidth() {
}
function getSiderCollapsedWidth() {
const { reverseHorizontalMix } = themeStore.layout;
const { collapsedWidth, mixCollapsedWidth, mixChildMenuWidth } = themeStore.sider;
if (isHorizontalMix.value && reverseHorizontalMix) {
return isActiveFirstLevelMenuHasChildren.value ? collapsedWidth : 0;
}
let w = isVerticalMix.value || isHorizontalMix.value ? mixCollapsedWidth : collapsedWidth;
if (isVerticalMix.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
@@ -110,7 +100,7 @@ function getSiderCollapsedWidth() {
:full-content="appStore.fullContent"
:fixed-top="themeStore.fixedHeaderAndTab"
:header-height="themeStore.header.height"
:tab-visible="themeStore.tab.visible"
:tab-visible="themeStore.tabVisible"
:tab-height="themeStore.tab.height"
:content-class="appStore.contentXScrollable ? 'overflow-x-hidden' : ''"
:sider-visible="siderVisible"

View File

@@ -2,30 +2,49 @@ import { computed, nextTick, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useContext } from '@sa/hooks';
import type { RouteKey } from '@elegant-router/types';
import { getObjectContextDomainConfigByPath } from '@/constants/object-context';
import { useRouteStore } from '@/store/modules/route';
import { useObjectContextStore } from '@/store/modules/object-context';
import { useRouterPush } from '@/hooks/common/router';
export const { setupStore: setupMixMenuContext, useStore: useMixMenuContext } = useContext('mix-menu', useMixMenu);
function isMenuMatchedByPath(path: string, menuPath: string) {
if (!menuPath) {
return false;
}
return path === menuPath || path.startsWith(`${menuPath}/`);
}
function useMixMenu() {
const route = useRoute();
const routeStore = useRouteStore();
const objectContextStore = useObjectContextStore();
const { selectedKey } = useMenu();
const activeFirstLevelMenuKey = ref('');
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
function setActiveFirstLevelMenuKey(key: string) {
activeFirstLevelMenuKey.value = key;
}
function getActiveFirstLevelMenuKey() {
const [firstLevelRouteName] = selectedKey.value.split('_');
const [firstLevelMenuKey = ''] = routeStore.getSelectedMenuKeyPath(selectedKey.value);
setActiveFirstLevelMenuKey(firstLevelRouteName);
if (firstLevelMenuKey) {
setActiveFirstLevelMenuKey(firstLevelMenuKey);
return;
}
const fallbackFirstLevelMenuKey =
allMenus.value.find(menu => isMenuMatchedByPath(route.path, menu.routePath))?.key || '';
setActiveFirstLevelMenuKey(fallbackFirstLevelMenuKey);
}
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
routeStore.menus.map(menu => {
const { children: _, ...rest } = menu;
@@ -38,6 +57,25 @@ function useMixMenu() {
() => routeStore.menus.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
);
const currentObjectContextDomain = computed(() => getObjectContextDomainConfigByPath(route.path) || null);
const headerMenuMode = computed<'global' | 'object-context'>(() =>
currentObjectContextDomain.value && objectContextStore.hasContext ? 'object-context' : 'global'
);
const headerMenus = computed<(App.Global.Menu | App.ObjectContext.Menu)[]>(() => {
if (headerMenuMode.value === 'object-context') {
return objectContextStore.contextScopedMenus;
}
// 对象型业务域处于入口态时,头部只保留业务域锚点,不继续投影全局子菜单。
if (currentObjectContextDomain.value) {
return [];
}
return childLevelMenus.value;
});
const isActiveFirstLevelMenuHasChildren = computed(() => {
if (!activeFirstLevelMenuKey.value) {
return false;
@@ -49,7 +87,7 @@ function useMixMenu() {
});
watch(
() => route.name,
[selectedKey, allMenus, () => route.path],
() => {
getActiveFirstLevelMenuKey();
},
@@ -60,6 +98,9 @@ function useMixMenu() {
allMenus,
firstLevelMenus,
childLevelMenus,
headerMenuMode,
headerMenus,
currentObjectContextDomain,
isActiveFirstLevelMenuHasChildren,
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,

View File

@@ -14,9 +14,12 @@ withDefaults(defineProps<Props>(), {
</script>
<template>
<RouterLink to="/" class="w-full flex-center nowrap-hidden">
<SystemLogo class="text-32px text-primary" />
<h2 v-show="showTitle" class="pl-8px text-16px text-primary font-bold transition duration-300 ease-in-out">
<RouterLink to="/" class="h-full w-full flex-y-center justify-start gap-8px nowrap-hidden px-12px">
<SystemLogo class="shrink-0 text-32px text-primary" />
<h2
v-show="showTitle"
class="min-w-0 flex-1-hidden ellipsis-text text-16px text-primary font-bold transition duration-300 ease-in-out"
>
{{ $t('system.title') }}
</h2>
</RouterLink>

View File

@@ -7,7 +7,6 @@ import VerticalMenu from './modules/vertical-menu.vue';
import VerticalMixMenu from './modules/vertical-mix-menu.vue';
import HorizontalMenu from './modules/horizontal-menu.vue';
import HorizontalMixMenu from './modules/horizontal-mix-menu.vue';
import ReversedHorizontalMixMenu from './modules/reversed-horizontal-mix-menu.vue';
defineOptions({
name: 'GlobalMenu'
@@ -21,13 +20,13 @@ const activeMenu = computed(() => {
vertical: VerticalMenu,
'vertical-mix': VerticalMixMenu,
horizontal: HorizontalMenu,
'horizontal-mix': themeStore.layout.reverseHorizontalMix ? ReversedHorizontalMixMenu : HorizontalMixMenu
'horizontal-mix': HorizontalMixMenu
};
return menuMap[themeStore.layout.mode];
return menuMap[themeStore.layoutMode];
});
const reRenderVertical = computed(() => themeStore.layout.mode === 'vertical' && appStore.isMobile);
const reRenderVertical = computed(() => themeStore.layoutMode === 'vertical' && appStore.isMobile);
</script>
<template>

View File

@@ -1,43 +1,164 @@
<script setup lang="ts">
import type { RouteKey } from '@elegant-router/types';
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { getObjectContextDomainConfigByPath } from '@/constants/object-context';
import { useAppStore } from '@/store/modules/app';
import { useObjectContextStore } from '@/store/modules/object-context';
import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router';
import FirstLevelMenu from '../components/first-level-menu.vue';
import { useMenu, useMixMenuContext } from '../../../context';
import MenuItem from '../components/menu-item.vue';
defineOptions({
name: 'HorizontalMixMenu'
});
const appStore = useAppStore();
const route = useRoute();
const objectContextStore = useObjectContextStore();
const themeStore = useThemeStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { allMenus, childLevelMenus, activeFirstLevelMenuKey, setActiveFirstLevelMenuKey } = useMixMenuContext();
const { selectedKeyDummy, handleSelect } = useMenu();
const { routerPush, routerPushByKeyWithMetaQuery } = useRouterPush();
const {
allMenus,
headerMenuMode,
headerMenus,
currentObjectContextDomain,
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey
} = useMixMenuContext();
const { selectedKey } = useMenu();
const activeFirstLevelMenu = computed(
() => allMenus.value.find(menu => menu.key === activeFirstLevelMenuKey.value) || null
);
const headerMenuHeight = computed(() => `${themeStore.header.height}px`);
const showObjectContextInfo = computed(
() => headerMenuMode.value === 'object-context' && objectContextStore.hasContext
);
const activeHeaderMenuKey = computed(() =>
headerMenuMode.value === 'object-context' ? String(route.name || '') : selectedKey.value
);
function handleSelectMixMenu(menu: App.Global.Menu) {
setActiveFirstLevelMenuKey(menu.key);
if (!menu.children?.length) {
routerPushByKeyWithMetaQuery(menu.routeKey);
const domainConfig = getObjectContextDomainConfigByPath(menu.routePath);
if (domainConfig) {
objectContextStore.clearContext();
routerPush({ path: domainConfig.entryRoutePath });
return;
}
routerPushByKeyWithMetaQuery(menu.routeKey);
}
function handleClickNavItem(menu: App.Global.Menu | App.ObjectContext.Menu) {
if (headerMenuMode.value === 'object-context') {
const location = objectContextStore.getMenuRouteLocation(menu as App.ObjectContext.Menu);
if (location) {
routerPush(location);
}
return;
}
routerPushByKeyWithMetaQuery((menu as App.Global.Menu).routeKey);
}
function handleClickDomainAnchor() {
if (currentObjectContextDomain.value) {
objectContextStore.clearContext();
routerPush({ path: currentObjectContextDomain.value.entryRoutePath });
return;
}
if (!activeFirstLevelMenu.value) {
return;
}
routerPushByKeyWithMetaQuery(activeFirstLevelMenu.value.routeKey);
}
function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
if (menu.key === activeHeaderMenuKey.value) {
return true;
}
return menu.children?.some(child => isMenuActive(child)) || false;
}
</script>
<template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
<ElMenu
ellipsis
class="w-full"
mode="horizontal"
:default-active="selectedKeyDummy"
@select="val => handleSelect(val as RouteKey)"
>
<MenuItem v-for="item in childLevelMenus" :key="item.key" :item="item" :index="item.key" />
</ElMenu>
<div class="mix-header-nav size-full min-w-0 flex-y-center">
<button
v-if="activeFirstLevelMenu"
type="button"
class="domain-anchor h-full flex-y-center gap-8px px-8px text-left"
@click="handleClickDomainAnchor"
>
<component :is="activeFirstLevelMenu.icon" v-if="activeFirstLevelMenu.icon" class="text-icon" />
<span class="domain-anchor__label">{{ activeFirstLevelMenu.label }}</span>
</button>
<div
v-if="showObjectContextInfo || headerMenus.length"
class="mx-12px h-20px w-1px shrink-0 bg-[var(--el-border-color)]"
></div>
<div v-if="showObjectContextInfo" class="context-object-tag h-full flex-y-center">
<span class="context-object-tag__label">{{ objectContextStore.objectName }}</span>
</div>
<div
v-if="showObjectContextInfo && headerMenus.length"
class="mx-12px h-20px w-1px shrink-0 bg-[var(--el-border-color)]"
></div>
<div v-if="headerMenus.length" class="header-nav-list h-full min-w-0 flex-1">
<template v-for="item in headerMenus" :key="item.key">
<button
v-if="!item.children?.length"
type="button"
class="header-nav-item"
:class="{ 'is-active': isMenuActive(item) }"
@click="handleClickNavItem(item)"
>
<span class="header-nav-item__label">{{ item.label }}</span>
</button>
<ElDropdown
v-else
trigger="hover"
placement="bottom"
popper-class="header-nav-dropdown"
:show-timeout="120"
:hide-timeout="120"
:teleported="true"
>
<button
type="button"
class="header-nav-item header-nav-item--dropdown"
:class="{ 'is-active': isMenuActive(item) }"
>
<span class="header-nav-item__label">{{ item.label }}</span>
<icon-ep:arrow-down class="header-nav-item__arrow" />
</button>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="child in item.children"
:key="child.key"
class="header-nav-dropdown__item"
:class="{ 'is-active-route': isMenuActive(child) }"
@click="handleClickNavItem(child)"
>
{{ child.label }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</template>
</div>
</div>
</Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<FirstLevelMenu
@@ -52,4 +173,160 @@ function handleSelectMixMenu(menu: App.Global.Menu) {
</Teleport>
</template>
<style scoped></style>
<style scoped>
.mix-header-nav {
height: v-bind(headerMenuHeight);
overflow: hidden;
}
.domain-anchor {
appearance: none;
-webkit-appearance: none;
border: none;
background: transparent;
margin: 0;
padding-top: 0;
padding-bottom: 0;
font: inherit;
flex-shrink: 0;
min-width: 0;
line-height: 1;
color: var(--el-text-color-primary);
}
.domain-anchor:hover {
color: var(--el-color-primary);
}
.domain-anchor__label {
display: inline-flex;
align-items: center;
max-width: 12rem;
line-height: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.context-object-tag {
flex-shrink: 0;
min-width: 0;
}
.context-object-tag__label {
display: inline-flex;
align-items: center;
max-width: 14rem;
height: 32px;
padding: 0 12px;
border: 1px solid rgb(148 163 184 / 26%);
border-radius: 999px;
background: linear-gradient(180deg, rgb(248 250 252 / 95%), rgb(241 245 249 / 92%));
color: rgb(15 23 42 / 88%);
font-size: 13px;
line-height: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-nav-list {
display: flex;
align-items: center;
gap: 4px;
height: 100%;
overflow: hidden;
}
.header-nav-item {
appearance: none;
-webkit-appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
position: relative;
margin: 0;
height: 100%;
flex-shrink: 0;
padding: 0 14px;
border: none;
background: transparent;
font: inherit;
line-height: 1;
color: var(--el-text-color-primary);
white-space: nowrap;
cursor: pointer;
}
.header-nav-item:hover {
color: var(--el-color-primary);
}
.header-nav-item__label {
display: inline-flex;
align-items: center;
line-height: 1;
}
.header-nav-item__arrow {
font-size: 12px;
line-height: 1;
}
.header-nav-item.is-active {
color: var(--el-color-primary);
}
.header-nav-item.is-active::after {
content: '';
position: absolute;
left: 12px;
right: 12px;
bottom: 0;
height: 2px;
border-radius: 999px;
background-color: var(--el-color-primary);
}
:global(.header-nav-dropdown.el-popper) {
padding: 0;
border: none;
border-radius: 14px;
background-color: rgb(255 255 255 / 98%);
box-shadow:
0 12px 28px rgb(15 23 42 / 10%),
0 2px 8px rgb(15 23 42 / 6%);
backdrop-filter: blur(8px);
}
:global(.header-nav-dropdown .el-popper__arrow) {
display: none;
}
:global(.header-nav-dropdown .el-dropdown-menu) {
padding: 8px;
border: 1px solid rgb(226 232 240 / 90%);
border-radius: 14px;
box-shadow: none;
}
:global(.header-nav-dropdown .el-dropdown-menu__item) {
height: 40px;
margin: 2px 0;
padding: 0 12px;
border-radius: 10px;
font-size: 14px;
line-height: 40px;
color: rgb(15 23 42 / 88%);
}
:global(.header-nav-dropdown .el-dropdown-menu__item:hover) {
background-color: rgb(99 102 241 / 8%);
}
:global(.header-nav-dropdown .el-dropdown-menu__item.is-active-route) {
color: var(--el-color-primary);
background-color: rgb(99 102 241 / 10%);
}
</style>

View File

@@ -5,7 +5,9 @@ import { SimpleScrollbar } from '@sa/materials';
import { useBoolean } from '@sa/hooks';
import type { RouteKey } from '@elegant-router/types';
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { getObjectContextDomainConfigByPath } from '@/constants/object-context';
import { useAppStore } from '@/store/modules/app';
import { useObjectContextStore } from '@/store/modules/object-context';
import { useThemeStore } from '@/store/modules/theme';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
@@ -21,9 +23,10 @@ defineOptions({
const route = useRoute();
const appStore = useAppStore();
const objectContextStore = useObjectContextStore();
const themeStore = useThemeStore();
const routeStore = useRouteStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { routerPush, routerPushByKeyWithMetaQuery } = useRouterPush();
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
const {
allMenus,
@@ -44,6 +47,14 @@ const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value ||
function handleSelectMixMenu(menu: App.Global.Menu) {
setActiveFirstLevelMenuKey(menu.key);
const domainConfig = getObjectContextDomainConfigByPath(menu.routePath);
if (domainConfig) {
objectContextStore.clearContext();
routerPush({ path: domainConfig.entryRoutePath });
return;
}
if (menu.children?.length) {
setDrawerVisible(true);
} else {

View File

@@ -10,8 +10,8 @@ defineOptions({ name: 'GlobalSider' });
const appStore = useAppStore();
const themeStore = useThemeStore();
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
const isHorizontalMix = computed(() => themeStore.layout.mode === 'horizontal-mix');
const isVerticalMix = computed(() => themeStore.layoutMode === 'vertical-mix');
const isHorizontalMix = computed(() => themeStore.layoutMode === 'horizontal-mix');
const darkMenu = computed(() => !themeStore.darkMode && !isHorizontalMix.value && themeStore.sider.inverted);
const showLogo = computed(() => !isVerticalMix.value && !isHorizontalMix.value);
const menuWrapperClass = computed(() => (showLogo.value ? 'flex-1-hidden' : 'h-full'));

View File

@@ -2,7 +2,6 @@
import { useAppStore } from '@/store/modules/app';
import { $t } from '@/locales';
import DarkMode from './modules/dark-mode.vue';
import LayoutMode from './modules/layout-mode.vue';
import ThemeColor from './modules/theme-color.vue';
import PageFun from './modules/page-fun.vue';
import ConfigOperation from './modules/config-operation.vue';
@@ -15,7 +14,6 @@ const appStore = useAppStore();
<template>
<ElDrawer v-model="appStore.themeDrawerVisible" :title="$t('theme.themeDrawerTitle')" :size="360">
<DarkMode />
<LayoutMode />
<ThemeColor />
<PageFun />
<template #footer>

View File

@@ -27,7 +27,7 @@ function handleColourWeaknessChange(value: boolean) {
themeStore.setColourWeakness(value);
}
const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layout.mode.includes('vertical'));
const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layoutMode.includes('vertical'));
</script>
<template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { themePageAnimationModeOptions, themeScrollModeOptions, themeTabModeOptions } from '@/constants/app';
import { themePageAnimationModeOptions, themeScrollModeOptions } from '@/constants/app';
import { useThemeStore } from '@/store/modules/theme';
import { translateOptions } from '@/utils/common';
import { $t } from '@/locales';
@@ -10,7 +10,7 @@ defineOptions({ name: 'PageFun' });
const themeStore = useThemeStore();
const layoutMode = computed(() => themeStore.layout.mode);
const layoutMode = computed(() => themeStore.layoutMode);
const isMixLayoutMode = computed(() => layoutMode.value.includes('mix'));
@@ -55,25 +55,6 @@ const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wra
<SettingItem v-if="themeStore.header.breadcrumb.visible" key="4-1" :label="$t('theme.header.breadcrumb.showIcon')">
<ElSwitch v-model="themeStore.header.breadcrumb.showIcon" />
</SettingItem>
<SettingItem key="5" :label="$t('theme.tab.visible')">
<ElSwitch v-model="themeStore.tab.visible" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-1" :label="$t('theme.tab.cache')">
<ElSwitch v-model="themeStore.tab.cache" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-2" :label="$t('theme.tab.height')">
<ElInputNumber v-model="themeStore.tab.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5-3" :label="$t('theme.tab.mode.title')">
<ElSelect v-model="themeStore.tab.mode" size="small" class="w-120px">
<ElOption
v-for="{ label, value } in translateOptions(themeTabModeOptions)"
:key="value"
:label="label"
:value="value"
/>
</ElSelect>
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="6-1" :label="$t('theme.sider.width')">
<ElInputNumber v-model="themeStore.sider.width" size="small" :step="1" class="w-120px" />
</SettingItem>

View File

@@ -169,6 +169,11 @@ const local: App.I18n.Schema = {
function_request: 'Request',
'function_toggle-auth': 'Toggle Auth',
'function_super-page': 'Super Admin Visible',
product: 'Product Management',
product_list: 'Product List',
product_dashboard: 'Product Dashboard',
product_requirement: 'Requirement Pool',
product_setting: 'Product Settings',
system: 'System Management',
system_user: 'User Management',
'system_user-detail': 'User Detail',
@@ -176,6 +181,7 @@ const local: App.I18n.Schema = {
system_menu: 'Menu Management',
system_post: 'Post Management',
system_dict: 'Dictionary Management',
'system_user-management-relation': 'User Management Relation',
exception: 'Exception',
exception_403: '403',
exception_404: '404',
@@ -343,6 +349,14 @@ const local: App.I18n.Schema = {
status: {
enable: 'Enable',
disable: 'Disable'
},
scopeType: {
global: 'Global',
object: 'Object Scope'
},
objectType: {
product: 'Product',
project: 'Project'
}
},
role: {
@@ -366,6 +380,16 @@ const local: App.I18n.Schema = {
selectedCount: 'Selected Resources',
disabledTip: 'Disabled roles cannot be assigned menu permissions',
emptyRole: 'Select a role first',
currentRoleCount: 'Role Count',
globalRoleTitle: 'Global Roles',
objectRoleTitle: 'Object-Scope Role Templates',
globalRoleSummary: 'Manage global login-state roles and their resource authorization relations.',
objectRoleSummary:
'Manage object-scope role templates and their authorized resources without participating in the global login-state navigation.',
objectRoleSummaryProduct:
'Manage product-scope role templates and their authorized resources without participating in the global login-state navigation.',
objectRoleSummaryProject:
'Manage project-scope role templates and their authorized resources without participating in the global login-state navigation.',
lastAuthSave: 'Last auth save',
unsavedTip: 'Remember to save after changing permissions',
form: {
@@ -491,6 +515,7 @@ const local: App.I18n.Schema = {
routeKind: 'Route Kind',
routePropsJson: 'Route Props JSON',
pageResource: 'Page Resource',
boundRoute: 'Bound Route',
component: 'Component Path',
componentName: 'Component Name',
iframeUrl: 'Iframe URL',
@@ -520,6 +545,40 @@ const local: App.I18n.Schema = {
alwaysShow: 'Always Show',
createTime: 'Create Time',
topLevel: 'Top Level Menu',
scopeType: 'Scope',
objectType: 'Object Type',
resourceCode: 'Resource Code',
contextEyebrow: 'Menu Configuration Context',
contextTitle: 'Unified Scope Resource Configuration',
contextDescription:
'Use one menu page to manage both global route resources and object-scope permission resources, instead of duplicating product and project pages.',
currentContext: 'Current Context',
currentResourceCount: 'Resource Count',
editorMode: 'Editor Mode',
editorModeGlobal: 'Route Resource Editor',
editorModeObject: 'Object Navigation Editor',
globalResourceTitle: 'Global Menu Resources',
objectResourceTitle: 'Object-Scope Resources',
globalResourceSummary: 'Configure login-state menus, route mappings, and global button permission resources.',
objectResourceSummary:
'Configure object-scope navigation items and action buttons. Navigation items bind real page routes, and action buttons provide in-object permission points.',
objectResourceSummaryProduct:
'Configure product-scope navigation items and action buttons. Navigation items bind real page routes, and action buttons provide in-product permission points.',
objectResourceSummaryProject:
'Configure project-scope navigation items and action buttons. Navigation items bind real page routes, and action buttons provide in-project permission points.',
scopeHintGlobal:
'Global mode keeps the current route-oriented editor and continues to serve login-state menus and global button permissions.',
scopeHintObject:
'Object mode manages navigation items and action buttons. Navigation items bind real page resources, and action buttons only maintain permission codes.',
objectTypePlaceholder: 'Please select an object type',
contextReady: 'Context Selected',
contextPending: 'Waiting For Object Type',
objectTypeRequiredTitle: 'Select an object type first',
objectTypeRequiredDescription:
'Object-scope resources must first define the configuration range, such as product or project. Then load the resource tree and editor.',
objectModeTipTitle: 'Object scope currently manages navigation items and action buttons',
objectModeTipDescription:
'In the first phase, object-scope menus only expose navigation items and action buttons. Navigation items bind real page routes for the object header navigation, and action buttons only maintain permission codes. Directory creation is intentionally hidden for now.',
sections: {
basic: 'Basic Information',
route: 'Route Information',
@@ -531,6 +590,7 @@ const local: App.I18n.Schema = {
parentId: 'Please select parent menu',
menuName: 'Please enter menu name',
permission: 'Please enter permission',
resourceCode: 'Please enter the resource code',
routeName: 'Please enter route name',
routePath: 'Please enter route path',
path: 'Please enter route path',
@@ -538,6 +598,7 @@ const local: App.I18n.Schema = {
componentName: 'Please enter component name',
routeKind: 'Please select route kind',
pageResource: 'Please select page resource',
boundRoute: 'Please select a bound route',
pageResourceParentMismatch: 'The selected page resource does not match the current parent menu path',
routePropsJson: 'Please enter a valid JSON string',
routePropsJsonHint: 'For example {"url":"https://example.com"}',
@@ -585,6 +646,8 @@ const local: App.I18n.Schema = {
'Fill in the last segment of the access path. For Role Management, the full path is /system/role, so this field is usually role.',
pageResource:
'Page routes should select a page resource from the frontend whitelist. For example, Role Management maps to /system/role and view.system_role.',
boundRoute:
'Object-scope navigation items should bind real object page routes. For example, the product scope can bind /product/dashboard or /product/requirement.',
component:
'The component field should use the frontend page-resource whitelist key, not a src file path. For Role Management, use or select view.system_role.'
},
@@ -594,7 +657,9 @@ const local: App.I18n.Schema = {
type: {
directory: 'Directory',
menu: 'Menu',
button: 'Button'
button: 'Button',
navigation: 'Navigation Item',
actionButton: 'Action Button'
},
iconType: {
iconify: 'Iconify Icon',

View File

@@ -169,6 +169,11 @@ const local: App.I18n.Schema = {
function_request: '请求',
'function_toggle-auth': '切换权限',
'function_super-page': '超级管理员可见',
product: '产品管理',
product_list: '产品列表',
product_dashboard: '产品仪表盘',
product_requirement: '需求池',
product_setting: '产品设置',
system: '系统管理',
system_user: '用户管理',
'system_user-detail': '用户详情',
@@ -176,6 +181,7 @@ const local: App.I18n.Schema = {
system_menu: '菜单管理',
system_post: '岗位管理',
system_dict: '字典管理',
'system_user-management-relation': '管理链路',
exception: '异常页',
exception_403: '403',
exception_404: '404',
@@ -342,6 +348,14 @@ const local: App.I18n.Schema = {
status: {
enable: '启用',
disable: '禁用'
},
scopeType: {
global: '全域',
object: '对象域'
},
objectType: {
product: '产品',
project: '项目'
}
},
role: {
@@ -357,7 +371,7 @@ const local: App.I18n.Schema = {
roleStatus: '角色状态',
roleDesc: '角色描述',
remark: '备注',
sort: '显示顺序',
sort: '序',
createTime: '创建时间',
menuAuth: '菜单权限',
buttonAuth: '按钮权限',
@@ -365,6 +379,13 @@ const local: App.I18n.Schema = {
selectedCount: '已选资源',
disabledTip: '禁用角色不允许分配菜单权限',
emptyRole: '请先选择角色',
currentRoleCount: '当前角色数',
globalRoleTitle: '全域角色',
objectRoleTitle: '对象域角色模板',
globalRoleSummary: '当前维护登录态全域角色及其资源授权关系。',
objectRoleSummary: '当前维护对象域角色模板及其可授权资源关系,不参与登录态全局菜单导航。',
objectRoleSummaryProduct: '当前维护产品域角色模板及其可授权资源关系,不参与登录态全局菜单导航。',
objectRoleSummaryProject: '当前维护项目域角色模板及其可授权资源关系,不参与登录态全局菜单导航。',
lastAuthSave: '最近一次授权保存',
unsavedTip: '授权变更后请记得保存',
form: {
@@ -373,7 +394,7 @@ const local: App.I18n.Schema = {
roleStatus: '请选择角色状态',
roleDesc: '请输入角色描述',
remark: '请输入备注',
sort: '请输入显示顺序',
sort: '请输入序',
resourceKeyword: '输入资源名称过滤权限树',
startTime: '开始时间',
endTime: '结束时间'
@@ -403,7 +424,7 @@ const local: App.I18n.Schema = {
emptyLeader: '暂无负责人',
userName: '用户名',
userGender: '性别',
nickName: '昵称',
nickName: '用户昵称',
deptName: '所属组织',
positionName: '岗位',
userPhone: '手机号',
@@ -421,7 +442,7 @@ const local: App.I18n.Schema = {
form: {
userName: '请输入用户名',
userGender: '请选择性别',
nickName: '请输入昵称',
nickName: '请输入用户昵称',
orgName: '请输入组织名称',
orgCode: '请输入组织编码',
orgTypeLabel: '请选择组织类型',
@@ -463,7 +484,7 @@ const local: App.I18n.Schema = {
orgType: {
company: '公司',
dept: '部门',
direction: '条线',
direction: '方向',
team: '团队'
},
gender: {
@@ -490,6 +511,7 @@ const local: App.I18n.Schema = {
routeKind: '路由类型',
routePropsJson: '路由参数 JSON',
pageResource: '页面资源',
boundRoute: '绑定路由',
component: '组件路径',
componentName: '组件名称',
iframeUrl: 'iframe 地址',
@@ -519,6 +541,36 @@ const local: App.I18n.Schema = {
alwaysShow: '总是显示子菜单',
createTime: '创建时间',
topLevel: '顶级菜单',
scopeType: '作用域',
objectType: '对象类型',
resourceCode: '资源编码',
contextEyebrow: '菜单配置上下文',
contextTitle: '统一作用域资源配置',
contextDescription: '用同一套菜单页同时承接全域路由资源与对象域权限资源,避免为产品和项目再拆多套重复页面。',
currentContext: '当前上下文',
currentResourceCount: '当前资源数',
editorMode: '编辑模式',
editorModeGlobal: '路由型资源编辑器',
editorModeObject: '对象导航编辑器',
globalResourceTitle: '全域菜单资源',
objectResourceTitle: '对象域资源',
globalResourceSummary: '当前维护登录态菜单、路由映射与全局按钮权限资源。',
objectResourceSummary: '当前维护对象域导航项与操作按钮。导航项绑定真实页面路由,操作按钮用于对象内权限点。',
objectResourceSummaryProduct:
'当前维护产品域导航项与操作按钮。导航项绑定真实页面路由,操作按钮用于产品对象内权限点。',
objectResourceSummaryProject:
'当前维护项目域导航项与操作按钮。导航项绑定真实页面路由,操作按钮用于项目对象内权限点。',
scopeHintGlobal: '全域模式下保留现有路由型资源编辑方式,用于登录态菜单与全局按钮权限链路。',
scopeHintObject: '对象域模式下维护导航项和操作按钮。导航项绑定真实页面资源,操作按钮只维护权限标识。',
objectTypePlaceholder: '请选择对象类型',
contextReady: '已选定配置范围',
contextPending: '等待选择对象类型',
objectTypeRequiredTitle: '请先选择对象类型',
objectTypeRequiredDescription:
'对象域资源必须先明确配置范围,例如产品或项目。选定后再加载树形资源列表和编辑弹层。',
objectModeTipTitle: '对象域当前配置的是对象导航项和操作按钮',
objectModeTipDescription:
'第一版对象域菜单页只开放导航项和操作按钮。导航项通过绑定真实页面路由建立对象内头部导航,操作按钮只维护权限标识;暂不开放目录配置。',
sections: {
basic: '基础信息',
route: '路由信息',
@@ -530,6 +582,7 @@ const local: App.I18n.Schema = {
parentId: '请选择父级菜单',
menuName: '请输入菜单名称',
permission: '请输入权限标识',
resourceCode: '请输入资源编码',
routeName: '请输入路由名称',
routePath: '请输入路由路径',
path: '请输入路由地址',
@@ -537,6 +590,7 @@ const local: App.I18n.Schema = {
componentName: '请输入组件名称',
routeKind: '请选择路由类型',
pageResource: '请选择页面资源',
boundRoute: '请选择绑定路由',
pageResourceParentMismatch: '所选页面资源与当前父级菜单层级不匹配',
routePropsJson: '请输入合法的 JSON 字符串',
routePropsJsonHint: '例如 {"url":"https://example.com"}',
@@ -580,6 +634,8 @@ const local: App.I18n.Schema = {
routePath: '路由地址填写访问路径中的末级段。以“角色管理”为例,完整地址是 /system/role这里通常填写 role。',
pageResource:
'普通页面请从前端页面资源白名单中选择。例如角色管理对应 /system/role与组件键 view.system_role。',
boundRoute:
'对象域导航项请绑定真实对象页面路由。例如产品域可绑定 /product/dashboard、/product/requirement 等页面。',
component:
'组件路径填写前端页面资源白名单中的组件键,不是 src 下的文件路径。以“角色管理”为例,可填写或选择 view.system_role。'
},
@@ -589,7 +645,9 @@ const local: App.I18n.Schema = {
type: {
directory: '目录',
menu: '菜单',
button: '按钮'
button: '按钮',
navigation: '导航项',
actionButton: '操作按钮'
},
iconType: {
iconify: 'iconify图标',

View File

@@ -1,6 +1,7 @@
import { createApp } from 'vue';
import './plugins/assets';
import { setupDayjs, setupIconifyOffline, setupLoading, setupNProgress, setupUI } from './plugins';
import { setupDirectives } from './directives';
import { setupStore } from './store';
import { setupRouter } from './router';
import { setupI18n } from './locales';
@@ -17,6 +18,8 @@ async function setupApp() {
const app = createApp(App);
setupDirectives(app);
setupUI(app);
setupStore(app);

View File

@@ -47,11 +47,16 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
plugin_tables_vtable: () => import("@/views/plugin/tables/vtable/index.vue"),
plugin_typeit: () => import("@/views/plugin/typeit/index.vue"),
plugin_video: () => import("@/views/plugin/video/index.vue"),
product_dashboard: () => import("@/views/product/dashboard/index.vue"),
product_list: () => import("@/views/product/list/index.vue"),
product_requirement: () => import("@/views/product/requirement/index.vue"),
product_setting: () => import("@/views/product/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"),
system_role: () => import("@/views/system/role/index.vue"),
"system_user-detail": () => import("@/views/system/user-detail/[id].vue"),
"system_user-management-relation": () => import("@/views/system/user-management-relation/index.vue"),
system_user: () => import("@/views/system/user/index.vue"),
"user-center": () => import("@/views/user-center/index.vue"),
};

View File

@@ -430,6 +430,64 @@ export const generatedRoutes: GeneratedRoute[] = [
}
]
},
{
name: 'product',
path: '/product',
component: 'layout.base',
meta: {
title: 'product',
i18nKey: 'route.product',
icon: 'carbon:product',
order: 4
},
children: [
{
name: 'product_dashboard',
path: '/product/dashboard',
component: 'view.product_dashboard',
meta: {
title: 'product_dashboard',
i18nKey: 'route.product_dashboard',
hideInMenu: true,
activeMenu: 'product_list'
}
},
{
name: 'product_list',
path: '/product/list',
component: 'view.product_list',
meta: {
title: 'product_list',
i18nKey: 'route.product_list',
icon: 'material-symbols:view-list-outline-rounded',
order: 1,
keepAlive: true
}
},
{
name: 'product_requirement',
path: '/product/requirement',
component: 'view.product_requirement',
meta: {
title: 'product_requirement',
i18nKey: 'route.product_requirement',
hideInMenu: true,
activeMenu: 'product_list'
}
},
{
name: 'product_setting',
path: '/product/setting',
component: 'view.product_setting',
meta: {
title: 'product_setting',
i18nKey: 'route.product_setting',
hideInMenu: true,
activeMenu: 'product_list'
}
}
]
},
{
name: 'system',
path: '/system',
@@ -512,6 +570,15 @@ export const generatedRoutes: GeneratedRoute[] = [
roles: ['R_ADMIN'],
activeMenu: 'system_user'
}
},
{
name: 'system_user-management-relation',
path: '/system/user-management-relation',
component: 'view.system_user-management-relation',
meta: {
title: 'system_user-management-relation',
i18nKey: 'route.system_user-management-relation'
}
}
]
},

View File

@@ -206,6 +206,11 @@ const routeMap: RouteMap = {
"plugin_tables_vtable": "/plugin/tables/vtable",
"plugin_typeit": "/plugin/typeit",
"plugin_video": "/plugin/video",
"product": "/product",
"product_dashboard": "/product/dashboard",
"product_list": "/product/list",
"product_requirement": "/product/requirement",
"product_setting": "/product/setting",
"system": "/system",
"system_dict": "/system/dict",
"system_menu": "/system/menu",
@@ -213,6 +218,7 @@ const routeMap: RouteMap = {
"system_role": "/system/role",
"system_user": "/system/user",
"system_user-detail": "/system/user-detail/:id",
"system_user-management-relation": "/system/user-management-relation",
"user-center": "/user-center"
};

View File

@@ -7,6 +7,7 @@ import type {
} from 'vue-router';
import type { RouteKey, RoutePath } from '@elegant-router/types';
import { useAuthStore } from '@/store/modules/auth';
import { useObjectContextStore } from '@/store/modules/object-context';
import { useRouteStore } from '@/store/modules/route';
import { localStg } from '@/utils/storage';
import { getRouteName } from '@/router/elegant/transform';
@@ -51,6 +52,13 @@ export function createRouteGuard(router: Router) {
// 不需要登录的路由允许直接访问
if (!needLogin) {
const objectContextLocation = await handleObjectContextSwitch(to);
if (objectContextLocation) {
next(objectContextLocation);
return;
}
handleRouteSwitch(to, from, next);
return;
}
@@ -68,6 +76,13 @@ export function createRouteGuard(router: Router) {
}
// 正常放行
const objectContextLocation = await handleObjectContextSwitch(to);
if (objectContextLocation) {
next(objectContextLocation);
return;
}
handleRouteSwitch(to, from, next);
});
}
@@ -176,6 +191,12 @@ function handleRouteSwitch(to: RouteLocationNormalized, from: RouteLocationNorma
next();
}
async function handleObjectContextSwitch(to: RouteLocationNormalized) {
const objectContextStore = useObjectContextStore();
return objectContextStore.ensureContextByRoute(to);
}
function getRouteQueryOfLoginRoute(to: RouteLocationNormalized, routeHome: RouteKey) {
const loginRoute: RouteKey = 'login';
const redirect = to.fullPath;

View File

@@ -8,6 +8,7 @@ import {
} from 'vue-router';
import { createBuiltinVueRoutes } from './routes/builtin';
import { createRouterGuard } from './guard';
import { setGlobalRouter } from './instance';
const { VITE_ROUTER_HISTORY_MODE = 'history', VITE_BASE_URL } = import.meta.env;
@@ -22,6 +23,8 @@ export const router = createRouter({
routes: createBuiltinVueRoutes()
});
setGlobalRouter(router);
/** 挂载并初始化 Vue Router */
export async function setupRouter(app: App) {
app.use(router);

15
src/router/instance.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { Router } from 'vue-router';
let globalRouter: Router | null = null;
export function setGlobalRouter(router: Router) {
globalRouter = router;
}
export function getGlobalRouter() {
if (!globalRouter) {
throw new Error('Global router is not initialized');
}
return globalRouter;
}

View File

@@ -68,6 +68,14 @@ export function fetchGetDictDataPage(params: Api.Dict.DictDataSearchParams) {
});
}
/** 获取前端运行时字典缓存 */
export function fetchGetFrontendDictCache() {
return request<Api.Dict.FrontendDictCache>({
url: `${DICT_DATA_PREFIX}/frontend-cache`,
method: 'get'
});
}
/** 创建字典数据 */
export function fetchCreateDictData(data: Api.Dict.SaveDictDataParams) {
return request<number>({
@@ -102,3 +110,11 @@ export function fetchBatchDeleteDictData(ids: number[]) {
method: 'delete'
});
}
/** 通过岗位编码获取该字典的所有字典数据 */
export function fetchGetDictDataByCode(code: string) {
return request<Api.Dict.PageResult<Api.Dict.DictData>>({
url: `${DICT_DATA_PREFIX}/code?code=${code}`,
method: 'get'
});
}

View File

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

View File

@@ -0,0 +1,202 @@
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;
}
function createContextApiUrl(config: App.ObjectContext.DomainConfig, objectId: string) {
if (config.contextApiObjectIdPlacement !== 'path') {
return config.contextApiPath;
}
const placeholder = `{${config.contextApiObjectIdParamKey}}`;
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
): Promise<ServiceRequestResult<Api.ObjectContext.ContextInfo>> {
const result = await request<BackendObjectContextDTO>({
...safeJsonRequestConfig,
url: createContextApiUrl(config, objectId),
method: 'get',
params:
config.contextApiObjectIdPlacement === 'path'
? undefined
: ({
[config.contextApiObjectIdParamKey]: objectId
} satisfies Record<string, LocationQueryValue | LocationQueryValue[]>)
});
if (result.error || !result.data) {
return result as ServiceRequestResult<Api.ObjectContext.ContextInfo>;
}
return {
...result,
data: normalizeObjectContext(config, objectId, result.data)
};
}

View File

@@ -0,0 +1,83 @@
import { normalizeNullableStringId, normalizeStringId } from './shared';
type ProductStatusCode = Api.Product.ProductStatusCode;
type ProductStatusActionCode = Api.Product.ProductStatusActionCode;
interface ProductSettingsResponse {
baseInfo: {
id: string | number;
code: string;
directionCode: string;
name: string;
managerUserId?: string | number | null;
managerUserNickname?: string | null;
description?: string | null;
statusCode: ProductStatusCode;
lastStatusReason?: string | null;
};
lifecycle: {
statusCode: ProductStatusCode;
lastStatusReason?: string | null;
availableActions?: Array<{
actionCode: ProductStatusActionCode;
actionName: string;
needReason: boolean;
}> | null;
};
}
interface ProductMemberResponse {
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;
}
export function normalizeProductSettings(response: ProductSettingsResponse): Api.Product.ProductSettings {
return {
baseInfo: {
id: normalizeStringId(response.baseInfo.id),
code: response.baseInfo.code || '',
directionCode: response.baseInfo.directionCode || '',
name: response.baseInfo.name || '',
managerUserId: normalizeNullableStringId(response.baseInfo.managerUserId) ?? '',
managerUserNickname: response.baseInfo.managerUserNickname || '',
description: response.baseInfo.description ?? null,
statusCode: response.baseInfo.statusCode,
lastStatusReason: response.baseInfo.lastStatusReason ?? null
},
lifecycle: {
statusCode: response.lifecycle.statusCode,
lastStatusReason: response.lifecycle.lastStatusReason ?? null,
availableActions:
response.lifecycle.availableActions?.map(item => ({
actionCode: item.actionCode,
actionName: item.actionName,
needReason: item.needReason
})) ?? []
}
};
}
export function normalizeProductMember(response: ProductMemberResponse): Api.Product.ProductMember {
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
};
}

489
src/service/api/product.ts Normal file
View File

@@ -0,0 +1,489 @@
import { WEB_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import {
type ServiceRequestResult,
mapServiceResult,
normalizeNullableStringId,
normalizeStringId,
safeJsonRequestConfig
} from './shared';
import { normalizeProductMember, normalizeProductSettings } from './product-shared';
const PRODUCT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product`;
type ProductResponse = Omit<Api.Product.Product, 'id' | 'managerUserId'> & {
id: string | number;
managerUserId?: string | number | null;
};
type ProductPageResponse = Api.Product.PageResult<ProductResponse>;
type ProductActivityTimelineItemResponse = Omit<
Api.Product.ProductActivityTimelineItem,
'id' | 'operatorUserId' | 'targetUserId' | 'occurredAt'
> & {
id: string | number;
operatorUserId?: string | number | null;
targetUserId?: string | number | null;
occurredAt: number | string;
};
type ProductActivityTimelinePageResponse = Omit<
Api.Product.PageResult<ProductActivityTimelineItemResponse>,
'total'
> & {
total: number | string;
};
function normalizeProduct(product: ProductResponse): Api.Product.Product {
return {
...product,
id: normalizeStringId(product.id),
managerUserId: normalizeNullableStringId(product.managerUserId) ?? ''
};
}
function normalizeOccurredAt(occurredAt: number | string) {
const value = Number(occurredAt);
return Number.isFinite(value) ? value : 0;
}
function normalizePageTotal(total: number | string) {
const value = Number(total);
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
function normalizeProductActivityTimelineItem(
item: ProductActivityTimelineItemResponse
): Api.Product.ProductActivityTimelineItem {
return {
...item,
id: normalizeStringId(item.id),
operatorUserId: normalizeNullableStringId(item.operatorUserId),
targetUserId: normalizeNullableStringId(item.targetUserId),
occurredAt: normalizeOccurredAt(item.occurredAt)
};
}
function createProductActivityTimelinePageQuery(params: Api.Product.ProductActivityTimelinePageParams) {
const query = new URLSearchParams();
query.append('pageNo', String(params.pageNo));
query.append('pageSize', String(params.pageSize));
if (params.activityType) {
query.append('activityType', params.activityType);
}
params.actionTypes?.forEach(actionType => {
if (actionType) {
query.append('actionTypes', actionType);
}
});
if (params.startTime && params.endTime) {
query.append('startTime', params.startTime);
query.append('endTime', params.endTime);
}
return query.toString();
}
/** 鑾峰彇浜у搧鍒嗛〉 */
export async function fetchGetProductPage(params?: Api.Product.ProductSearchParams) {
const result = await request<ProductPageResponse>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<ProductPageResponse>, data => ({
...data,
list: data.list.map(normalizeProduct)
}));
}
/** 鑾峰彇浜у搧璇︽儏 */
export async function fetchGetProduct(id: string) {
const result = await request<ProductResponse>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/get`,
method: 'get',
params: { id }
});
return mapServiceResult(result as ServiceRequestResult<ProductResponse>, normalizeProduct);
}
/** 鍒涘缓浜у搧 */
export async function fetchCreateProduct(data: Api.Product.SaveProductParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/create`,
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`,
method: 'put',
data
});
}
/** 鍙樻洿浜у搧鐘舵€? */
export function fetchChangeProductStatus(data: Api.Product.ChangeProductStatusParams) {
return request<boolean>({
url: `${PRODUCT_PREFIX}/change-status`,
method: 'post',
data
});
}
/** 鍒犻櫎浜у搧 */
export function fetchDeleteProduct(data: Api.Product.DeleteProductParams) {
return request<boolean>({
url: `${PRODUCT_PREFIX}/delete`,
method: 'post',
data
});
}
// ========== 产品需求 API ==========
const REQUIREMENT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product/requirement`;
type RequirementResponse = Omit<
Api.Product.Requirement,
'id' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'implementProjectId' | 'sourceBizId'
> & {
id: string | number;
parentId: string | number;
moduleId: string | number;
proposerId: string | number;
currentHandlerUserId?: string | number | null;
implementProjectId?: string | number | null;
sourceBizId?: string | number | null;
children?: RequirementResponse[];
};
type RequirementPageResponse = Api.Product.PageResult<RequirementResponse>;
function normalizeRequirement(requirement: RequirementResponse): Api.Product.Requirement {
return {
...requirement,
id: normalizeStringId(requirement.id),
parentId: normalizeStringId(requirement.parentId),
moduleId: normalizeStringId(requirement.moduleId),
proposerId: normalizeStringId(requirement.proposerId),
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
implementProjectId: normalizeNullableStringId(requirement.implementProjectId),
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
children: requirement.children?.map(normalizeRequirement)
};
}
/** 获取需求分页列表 */
export async function fetchGetRequirementPage(params?: Api.Product.RequirementSearchParams) {
const result = await request<RequirementPageResponse>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/page`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<RequirementPageResponse>, data => ({
...data,
list: data.list.map(normalizeRequirement)
}));
}
/** 获取需求树形列表支持分页pageSize只算父需求 */
export async function fetchGetRequirementTree(params?: Api.Product.RequirementSearchParams) {
const result = await request<Api.Product.PageResult<RequirementResponse>>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/tree`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<Api.Product.PageResult<RequirementResponse>>, data => ({
...data,
list: data.list.map(normalizeRequirement)
}));
}
/** 获取需求详情 */
export async function fetchGetRequirement(id: string, productId: string) {
const result = await request<RequirementResponse>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/get`,
method: 'get',
params: { id, productId }
});
return mapServiceResult(result as ServiceRequestResult<RequirementResponse>, normalizeRequirement);
}
/** 创建需求 */
export async function fetchCreateRequirement(data: Api.Product.SaveRequirementParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/create`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 更新需求 */
export function fetchUpdateRequirement(data: Api.Product.UpdateRequirementParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/update`,
method: 'put',
data
});
}
/** 变更需求状态 */
export function fetchChangeRequirementStatus(data: Api.Product.ChangeRequirementStatusParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/change-status`,
method: 'post',
data
});
}
/** 删除需求 */
export function fetchDeleteRequirement(data: Api.Product.DeleteRequirementParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/delete`,
method: 'post',
data
});
}
/** 拆分需求 */
export async function fetchSplitRequirement(data: Api.Product.SplitRequirementParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/split`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 关闭需求 */
export function fetchCloseRequirement(data: Api.Product.CloseRequirementParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/close`,
method: 'post',
data
});
}
/** 获取需求可执行的状态动作列表 */
export async function fetchGetRequirementAllowedTransitions(requirementId: string, productId: string) {
const result = await request<Api.Product.RequirementLifecycleAction[]>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/allowed-transitions`,
method: 'get',
params: { requirementId, productId }
});
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementLifecycleAction[]>, data => data);
}
/** 获取需求生命周期信息 */
export async function fetchGetRequirementLifecycle(requirementId: string, productId: string) {
const result = await request<Api.Product.RequirementLifecycleInfo>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/lifecycle`,
method: 'get',
params: { requirementId, productId }
});
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementLifecycleInfo>, data => data);
}
/** 获取需求所有状态字典 */
export async function fetchGetRequirementStatusDict() {
const result = await request<Api.Product.RequirementStatusDict[]>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/status/dict`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
}
/** 获取需求终止态状态字典 */
export async function fetchGetRequirementTerminalStatusDict() {
const result = await request<Api.Product.RequirementStatusDict[]>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/status/dict/terminal`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
}
// ========== 模块管理 API ==========
type RequirementModuleResponse = Omit<Api.Product.RequirementModule, 'id' | 'parentId' | 'productId'> & {
id: string | number;
parentId: string | number;
productId: string | number;
children?: RequirementModuleResponse[];
};
function normalizeRequirementModule(module: RequirementModuleResponse): Api.Product.RequirementModule {
return {
...module,
id: normalizeStringId(module.id),
parentId: normalizeStringId(module.parentId),
productId: normalizeStringId(module.productId),
children: module.children?.map(normalizeRequirementModule)
};
}
/** 获取需求模块树 */
export async function fetchGetRequirementModuleTree(productId: string) {
const result = await request<RequirementModuleResponse[]>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/module/tree`,
method: 'get',
params: { productId }
});
return mapServiceResult(result as ServiceRequestResult<RequirementModuleResponse[]>, data =>
data.map(normalizeRequirementModule)
);
}
/** 创建需求模块 */
export async function fetchCreateRequirementModule(data: Api.Product.SaveRequirementModuleParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/module/create`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 更新需求模块 */
export function fetchUpdateRequirementModule(data: Api.Product.SaveRequirementModuleParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/module/update`,
method: 'put',
data
});
}
/** 删除需求模块 */
export function fetchDeleteRequirementModule(data: Api.Product.DeleteRequirementModuleParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${REQUIREMENT_PREFIX}/module/delete`,
method: 'post',
data
});
}
export async function fetchGetProductSettings(id: string) {
const result = await request<Api.Product.ProductSettings>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/${id}/settings`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<Api.Product.ProductSettings>, normalizeProductSettings);
}
export function fetchUpdateProductSettingBaseInfo(id: string, data: Api.Product.UpdateProductSettingBaseInfoParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/${id}/settings/base-info`,
method: 'put',
data
});
}
export async function fetchGetProductMembers(id: string) {
const result = await request<Api.Product.ProductMember[]>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/${id}/members`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<Api.Product.ProductMember[]>, data =>
data.map(normalizeProductMember)
);
}
export async function fetchGetProductActivityTimelinePage(
id: string,
params: Api.Product.ProductActivityTimelinePageParams
) {
const query = createProductActivityTimelinePageQuery(params);
const url = query ? `${PRODUCT_PREFIX}/${id}/activities/page?${query}` : `${PRODUCT_PREFIX}/${id}/activities/page`;
const result = await request<ProductActivityTimelinePageResponse>({
...safeJsonRequestConfig,
url,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ProductActivityTimelinePageResponse>, data => ({
total: normalizePageTotal(data.total),
list: data.list.map(normalizeProductActivityTimelineItem)
}));
}
export async function fetchCreateProductMember(id: string, data: Api.Product.CreateProductMemberParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/${id}/members`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
export function fetchUpdateProductMember(id: string, memberId: string, data: Api.Product.UpdateProductMemberParams) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/${id}/members/${memberId}`,
method: 'put',
data
});
}
export function fetchInactiveProductMember(
id: string,
memberId: string,
data: Api.Product.InactiveProductMemberParams
) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/${id}/members/${memberId}/inactive`,
method: 'post',
data
});
}

View File

@@ -1,7 +1,10 @@
import type { LastLevelRouteKey } from '@elegant-router/types';
import type { RouteMeta } from 'vue-router';
import type { ElegantConstRoute, LastLevelRouteKey } from '@elegant-router/types';
import { objectContextDomainConfigs } from '@/constants/object-context';
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { createStaticRoutes } from '@/router/routes';
import { request } from '../request';
import type { ServiceRequestResult } from './shared';
import { type ServiceRequestResult, safeJsonRequestConfig } from './shared';
type BackendMenuRoute = Omit<Api.Route.MenuRoute, 'id' | 'children'> & {
id: string | number;
@@ -15,6 +18,12 @@ interface BackendUserRouteDTO {
let userRoutePromise: Promise<ServiceRequestResult<BackendUserRouteDTO>> | null = null;
const staticObjectContextRouteMap = new Map<App.ObjectContext.DomainKey, ElegantConstRoute>(
createStaticRoutes()
.authRoutes.filter(route => objectContextDomainConfigs.some(config => config.domainKey === route.name))
.map(route => [route.name as App.ObjectContext.DomainKey, route])
);
export function clearUserRouteCache() {
userRoutePromise = null;
}
@@ -27,22 +36,117 @@ function normalizeMenuRoute(route: BackendMenuRoute): Api.Route.MenuRoute {
};
}
function normalizePath(path?: string | null) {
if (!path) {
return '/';
}
return path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
}
function isPathMatchedByPrefix(path: string, prefix: string) {
const normalizedPath = normalizePath(path);
const normalizedPrefix = normalizePath(prefix);
return normalizedPath === normalizedPrefix || normalizedPath.startsWith(`${normalizedPrefix}/`);
}
function isTopLevelObjectContextEntryRoute(route: Api.Route.MenuRoute, config: App.ObjectContext.DomainConfig) {
const routePath = normalizePath(route.path);
return (
route.component?.startsWith('view.') &&
!route.children?.length &&
(route.name === config.entryRouteKey || routePath === normalizePath(config.entryRoutePath))
);
}
function cloneStaticRouteAsMenuRoute(route: ElegantConstRoute, idPrefix: string): Api.Route.MenuRoute {
return {
...route,
id: `${idPrefix}:${String(route.name || route.path)}`,
children: route.children?.map(child => cloneStaticRouteAsMenuRoute(child, idPrefix))
};
}
function replaceWithStaticObjectContextDomainRoute(routes: Api.Route.MenuRoute[]) {
let normalizedRoutes = [...routes];
objectContextDomainConfigs.forEach(config => {
const hasDomainRootRoute = normalizedRoutes.some(route => route.name === config.domainKey);
if (hasDomainRootRoute) {
return;
}
const domainTopLevelRoutes = normalizedRoutes.filter(route =>
config.routePathPrefixes.some(prefix => isPathMatchedByPrefix(route.path, prefix))
);
const entryRoute = domainTopLevelRoutes.find(route => isTopLevelObjectContextEntryRoute(route, config));
if (!entryRoute) {
return;
}
const staticDomainRoute = staticObjectContextRouteMap.get(config.domainKey);
if (!staticDomainRoute) {
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));
if (entryRoute.meta) {
const nextMeta: RouteMeta = {
title: wrappedDomainRoute.meta?.title || config.domainKey,
...(wrappedDomainRoute.meta || {})
};
if (entryRoute.meta.icon) {
nextMeta.icon = entryRoute.meta.icon;
}
if (entryRoute.meta.localIcon) {
nextMeta.localIcon = entryRoute.meta.localIcon;
}
if (entryRoute.meta.order !== undefined) {
nextMeta.order = entryRoute.meta.order;
}
wrappedDomainRoute.meta = nextMeta;
}
normalizedRoutes = normalizedRoutes.filter(route => !domainRouteIds.has(route.id));
normalizedRoutes.splice(entryRouteIndex < 0 ? normalizedRoutes.length : entryRouteIndex, 0, wrappedDomainRoute);
});
return normalizedRoutes;
}
function normalizeUserRoute(data: BackendUserRouteDTO): Api.Route.UserRoute {
return {
routes: (data.routes ?? []).map(route => normalizeMenuRoute(route)),
routes: replaceWithStaticObjectContextDomainRoute((data.routes ?? []).map(route => normalizeMenuRoute(route))),
home: (data.home || 'system_user') as LastLevelRouteKey
};
}
/** 获取常量路由 */
export function fetchGetConstantRoutes() {
return request<Api.Route.MenuRoute[]>({ url: '/route/getConstantRoutes' });
return request<Api.Route.MenuRoute[]>({
...safeJsonRequestConfig,
url: '/route/getConstantRoutes'
});
}
/** 获取用户路由 */
export async function fetchGetUserRoutes(force = false): Promise<ServiceRequestResult<Api.Route.UserRoute>> {
if (!userRoutePromise || force) {
userRoutePromise = request<BackendUserRouteDTO>({
...safeJsonRequestConfig,
url: `${SYSTEM_SERVICE_PREFIX}/auth/get-user-routes`
}).then(result => result as ServiceRequestResult<BackendUserRouteDTO>);
}

View File

@@ -1,4 +1,5 @@
import type { AxiosError, AxiosResponse } from 'axios';
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
import { safeJsonTransformResponse } from '../request/json';
export type ServiceRequestResult<T> =
| {
@@ -11,3 +12,33 @@ export type ServiceRequestResult<T> =
error: AxiosError<App.Service.Response<unknown>>;
response: AxiosResponse<App.Service.Response<unknown>> | undefined;
};
export const safeJsonRequestConfig: Pick<AxiosRequestConfig, 'transformResponse'> = {
transformResponse: [safeJsonTransformResponse]
};
export function normalizeStringId(id: string | number) {
return String(id);
}
export function normalizeNullableStringId(id: string | number | null | undefined) {
if (id === null || id === undefined || id === '') {
return null;
}
return String(id);
}
export function mapServiceResult<TInput, TOutput>(
result: ServiceRequestResult<TInput>,
mapper: (data: TInput) => TOutput
): ServiceRequestResult<TOutput> {
if (result.error || result.data === null) {
return result as ServiceRequestResult<TOutput>;
}
return {
...result,
data: mapper(result.data)
};
}

View File

@@ -1,6 +1,13 @@
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
import { request } from '../request';
import type { ServiceRequestResult } from './shared';
import {
type ServiceRequestResult,
mapServiceResult,
normalizeNullableStringId,
normalizeStringId,
safeJsonRequestConfig
} from './shared';
import UserManagementRelationQueryReqVO = Api.SystemManage.UserManagementRelationQueryReqVO;
const ROLE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/role`;
const MENU_PREFIX = `${SYSTEM_SERVICE_PREFIX}/menu`;
@@ -9,6 +16,7 @@ const USER_PREFIX = `${SYSTEM_SERVICE_PREFIX}/user`;
const DEPT_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dept`;
const POST_PREFIX = `${SYSTEM_SERVICE_PREFIX}/post`;
const ORG_LEADER_PREFIX = `${SYSTEM_SERVICE_PREFIX}/org-leader`;
const USER_MANAGEMENT_RELATION_PREFIX = `${SYSTEM_SERVICE_PREFIX}/user-management-relation`;
function createRolePageQuery(params?: Api.SystemManage.RoleSearchParams) {
const query = new URLSearchParams();
@@ -43,10 +51,18 @@ function createRolePageQuery(params?: Api.SystemManage.RoleSearchParams) {
}
});
if (params.scopeType) {
query.append('scopeType', params.scopeType);
}
if (params.objectType) {
query.append('objectType', params.objectType);
}
return query.toString();
}
function createBatchDeleteQuery(ids: number[]) {
function createBatchDeleteQuery(ids: Array<string | number>) {
const query = new URLSearchParams();
ids.forEach(id => {
@@ -56,39 +72,160 @@ function createBatchDeleteQuery(ids: number[]) {
return query.toString();
}
type UserSimpleResponse = Omit<Api.SystemManage.UserSimple, 'id'> & {
id: string | number;
};
type RoleResponse = Omit<Api.SystemManage.Role, 'id'> & {
id: string | number;
};
type RolePageResponse = Api.SystemManage.PageResult<RoleResponse>;
type RoleSimpleResponse = Omit<Api.SystemManage.RoleSimple, 'id'> & {
id: string | number;
};
type MenuResponse = Omit<Api.SystemManage.Menu, 'id' | 'parentId' | 'children'> & {
id: string | number;
parentId: string | number;
children?: MenuResponse[] | null;
};
type MenuSimpleResponse = Omit<Api.SystemManage.MenuSimple, 'id' | 'parentId' | 'children'> & {
id: string | number;
parentId: string | number;
children?: MenuSimpleResponse[] | null;
};
type UserManagementRelationResponse = Omit<
Api.SystemManage.UserManagementRelation,
'id' | 'managerUserId' | 'subordinateUserId'
> & {
id: string | number | null;
managerUserId: string | number | null;
subordinateUserId: string | number | null;
};
type UserManagementRelationTreeResponse = Omit<
Api.SystemManage.UserManagementRelationTreeRespVO,
'id' | 'userId' | 'managerUserId' | 'children'
> & {
id: string | number | null;
userId: string | number;
managerUserId: string | number | null;
children?: UserManagementRelationTreeResponse[] | null;
};
function normalizeUserSimple(user: UserSimpleResponse): Api.SystemManage.UserSimple {
return {
...user,
id: normalizeStringId(user.id)
};
}
function normalizeRole(role: RoleResponse): Api.SystemManage.Role {
return {
...role,
id: normalizeStringId(role.id)
};
}
function normalizeRoleSimple(role: RoleSimpleResponse): Api.SystemManage.RoleSimple {
return {
...role,
id: normalizeStringId(role.id)
};
}
function normalizeMenu(menu: MenuResponse): Api.SystemManage.Menu {
return {
...menu,
id: normalizeStringId(menu.id),
parentId: normalizeStringId(menu.parentId),
children: menu.children?.map(normalizeMenu) ?? null
};
}
function normalizeMenuSimple(menu: MenuSimpleResponse): Api.SystemManage.MenuSimple {
return {
...menu,
id: normalizeStringId(menu.id),
parentId: normalizeStringId(menu.parentId),
children: menu.children?.map(normalizeMenuSimple) ?? null
};
}
function normalizeUserManagementRelation(
relation: UserManagementRelationResponse
): Api.SystemManage.UserManagementRelation {
return {
...relation,
id: normalizeNullableStringId(relation.id),
managerUserId: normalizeNullableStringId(relation.managerUserId),
subordinateUserId: normalizeNullableStringId(relation.subordinateUserId)
};
}
function normalizeUserManagementRelationTree(
relation: UserManagementRelationTreeResponse
): Api.SystemManage.UserManagementRelationTreeRespVO {
return {
...relation,
id: normalizeNullableStringId(relation.id),
userId: normalizeStringId(relation.userId),
managerUserId: normalizeNullableStringId(relation.managerUserId),
children: relation.children?.map(normalizeUserManagementRelationTree) ?? null
};
}
/** 获取角色分页 */
export function fetchGetRolePage(params?: Api.SystemManage.RoleSearchParams) {
export async function fetchGetRolePage(params?: Api.SystemManage.RoleSearchParams) {
const query = createRolePageQuery(params);
return request<Api.SystemManage.RoleList>({
const result = await request<RolePageResponse>({
...safeJsonRequestConfig,
url: query ? `${ROLE_PREFIX}/page?${query}` : `${ROLE_PREFIX}/page`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<RolePageResponse>, data => ({
...data,
list: data.list.map(normalizeRole)
}));
}
/** 为兼容旧代码保留原函数名 */
export const fetchGetRoleList = fetchGetRolePage;
/** 获取角色详情 */
export function fetchGetRole(id: number) {
return request<Api.SystemManage.Role>({
export async function fetchGetRole(id: string) {
const result = await request<RoleResponse>({
...safeJsonRequestConfig,
url: `${ROLE_PREFIX}/get`,
method: 'get',
params: { id }
});
return mapServiceResult(result as ServiceRequestResult<RoleResponse>, normalizeRole);
}
/** 创建角色 */
export function fetchCreateRole(data: Api.SystemManage.SaveRoleParams) {
return request<number>({
export async function fetchCreateRole(data: Api.SystemManage.SaveRoleParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${ROLE_PREFIX}/create`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 更新角色 */
export function fetchUpdateRole(data: { id: number } & Api.SystemManage.SaveRoleParams) {
export function fetchUpdateRole(
data: { id: string } & Omit<Api.SystemManage.SaveRoleParams, 'scopeType' | 'objectType'>
) {
return request<boolean>({
url: `${ROLE_PREFIX}/update`,
method: 'put',
@@ -97,7 +234,7 @@ export function fetchUpdateRole(data: { id: number } & Api.SystemManage.SaveRole
}
/** 删除角色 */
export function fetchDeleteRole(id: number) {
export function fetchDeleteRole(id: string) {
return request<boolean>({
url: `${ROLE_PREFIX}/delete`,
method: 'delete',
@@ -106,7 +243,7 @@ export function fetchDeleteRole(id: number) {
}
/** 批量删除角色 */
export function fetchBatchDeleteRole(ids: number[]) {
export function fetchBatchDeleteRole(ids: string[]) {
return request<boolean>({
url: `${ROLE_PREFIX}/delete-list?${createBatchDeleteQuery(ids)}`,
method: 'delete'
@@ -119,7 +256,8 @@ export function fetchBatchDeleteRole(ids: number[]) {
* 为当前用户页面保留 `roleName / roleCode` 字段,直到该页面完成重构
*/
export async function fetchGetAllRoles(): Promise<ServiceRequestResult<Api.SystemManage.AllRole[]>> {
const result = await request<Api.SystemManage.RoleSimpleList>({
const result = await request<RoleSimpleResponse[]>({
...safeJsonRequestConfig,
url: `${ROLE_PREFIX}/simple-list`,
method: 'get'
});
@@ -130,20 +268,28 @@ export async function fetchGetAllRoles(): Promise<ServiceRequestResult<Api.Syste
return {
...result,
data: result.data.map(item => ({
...item,
roleName: item.name,
roleCode: item.code
}))
data: result.data.map(item => {
const role = normalizeRoleSimple(item);
return {
...role,
roleName: role.name,
roleCode: role.code
};
})
};
}
/** 获取启用状态的角色简表 */
export function fetchGetRoleSimpleList() {
return request<Api.SystemManage.RoleSimpleList>({
export async function fetchGetRoleSimpleList(params?: Api.SystemManage.ScopeQueryParams) {
const result = await request<RoleSimpleResponse[]>({
...safeJsonRequestConfig,
url: `${ROLE_PREFIX}/simple-list`,
method: 'get'
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<RoleSimpleResponse[]>, data => data.map(normalizeRoleSimple));
}
/** 获取部门列表 */
@@ -296,6 +442,17 @@ export function fetchBatchDeletePost(ids: number[]) {
});
}
/** 获取用户简单列表(用于用户选择下拉框) */
export function fetchGetUserSimpleList() {
return request<UserSimpleResponse[]>({
...safeJsonRequestConfig,
url: `${USER_PREFIX}/simple-list`,
method: 'get'
}).then(result =>
mapServiceResult(result as ServiceRequestResult<UserSimpleResponse[]>, data => data.map(normalizeUserSimple))
);
}
/** 获取用户分页 */
export function fetchGetUserPage(params?: Api.SystemManage.UserSearchParams) {
return request<Api.SystemManage.UserList>({
@@ -308,6 +465,18 @@ export function fetchGetUserPage(params?: Api.SystemManage.UserSearchParams) {
/** 为兼容旧代码保留原函数名 */
export const fetchGetUserList = fetchGetUserPage;
/** 通过部门id获取用户详情 */
export function fetchGetUserListByDeptId(deptId: any) {
return request<UserSimpleResponse[]>({
...safeJsonRequestConfig,
url: `${USER_PREFIX}/list-by-dept-id`,
method: 'get',
params: { deptId }
}).then(result =>
mapServiceResult(result as ServiceRequestResult<UserSimpleResponse[]>, data => data.map(normalizeUserSimple))
);
}
/** 获取用户详情 */
export function fetchGetUser(id: number) {
return request<Api.SystemManage.User>({
@@ -371,34 +540,45 @@ export function fetchBatchDeleteUser(ids: number[]) {
}
/** 获取菜单列表 */
export function fetchGetMenuList(params?: Api.SystemManage.MenuSearchParams) {
return request<Api.SystemManage.MenuList>({
export async function fetchGetMenuList(params?: Api.SystemManage.MenuSearchParams) {
const result = await request<MenuResponse[]>({
...safeJsonRequestConfig,
url: `${MENU_PREFIX}/list`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MenuResponse[]>, data => data.map(normalizeMenu));
}
/** 获取菜单详情 */
export function fetchGetMenu(id: number) {
return request<Api.SystemManage.Menu>({
export async function fetchGetMenu(id: string) {
const result = await request<MenuResponse>({
...safeJsonRequestConfig,
url: `${MENU_PREFIX}/get`,
method: 'get',
params: { id }
});
return mapServiceResult(result as ServiceRequestResult<MenuResponse>, normalizeMenu);
}
/** 创建菜单 */
export function fetchCreateMenu(data: Api.SystemManage.SaveMenuParams) {
return request<number>({
export async function fetchCreateMenu(data: Api.SystemManage.SaveMenuParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${MENU_PREFIX}/create`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 更新菜单 */
export function fetchUpdateMenu(data: { id: number } & Api.SystemManage.SaveMenuParams) {
export function fetchUpdateMenu(
data: { id: string } & Omit<Api.SystemManage.SaveMenuParams, 'scopeType' | 'objectType'>
) {
return request<boolean>({
url: `${MENU_PREFIX}/update`,
method: 'put',
@@ -407,7 +587,7 @@ export function fetchUpdateMenu(data: { id: number } & Api.SystemManage.SaveMenu
}
/** 删除菜单 */
export function fetchDeleteMenu(id: number) {
export function fetchDeleteMenu(id: string) {
return request<boolean>({
url: `${MENU_PREFIX}/delete`,
method: 'delete',
@@ -416,7 +596,7 @@ export function fetchDeleteMenu(id: number) {
}
/** 批量删除菜单 */
export function fetchBatchDeleteMenu(ids: number[]) {
export function fetchBatchDeleteMenu(ids: string[]) {
return request<boolean>({
url: `${MENU_PREFIX}/delete-list?${createBatchDeleteQuery(ids)}`,
method: 'delete'
@@ -424,20 +604,27 @@ export function fetchBatchDeleteMenu(ids: number[]) {
}
/** 获取启用状态的菜单简表 */
export function fetchGetMenuSimpleList() {
return request<Api.SystemManage.MenuSimpleList>({
export async function fetchGetMenuSimpleList(params?: Api.SystemManage.ScopeQueryParams) {
const result = await request<MenuSimpleResponse[]>({
...safeJsonRequestConfig,
url: `${MENU_PREFIX}/simple-list`,
method: 'get'
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<MenuSimpleResponse[]>, data => data.map(normalizeMenuSimple));
}
/** 获取角色关联的菜单 ID 列表 */
export function fetchGetRoleMenuIds(roleId: number) {
return request<number[]>({
export async function fetchGetRoleMenuIds(roleId: string) {
const result = await request<Array<string | number>>({
...safeJsonRequestConfig,
url: `${PERMISSION_PREFIX}/list-role-menus`,
method: 'get',
params: { roleId }
});
return mapServiceResult(result as ServiceRequestResult<Array<string | number>>, data => data.map(normalizeStringId));
}
/** 分配角色菜单 */
@@ -450,12 +637,15 @@ export function fetchAssignRoleMenus(data: Api.SystemManage.AssignRoleMenuParams
}
/** 获取用户关联的角色 ID 列表 */
export function fetchGetUserRoleIds(userId: number) {
return request<number[]>({
export async function fetchGetUserRoleIds(userId: number) {
const result = await request<Array<string | number>>({
...safeJsonRequestConfig,
url: `${PERMISSION_PREFIX}/list-user-roles`,
method: 'get',
params: { userId }
});
return mapServiceResult(result as ServiceRequestResult<Array<string | number>>, data => data.map(normalizeStringId));
}
/** 分配用户角色 */
@@ -466,3 +656,123 @@ export function fetchAssignUserRoles(data: Api.SystemManage.AssignUserRoleParams
data
});
}
// ==================== 用户管理链路相关 API ====================
/**
* 获取用户管理链路树形结构
*
* 用于树形控件展示,包含用户的上下级层级关系
* 树形结构特点:
* - 根节点:最高领导,没有上级
* - 中间节点:有上级也有下级
* - 叶子节点:基层员工,没有下级
*/
export function fetchGetUserManagementRelationTree(query: UserManagementRelationQueryReqVO) {
return request<UserManagementRelationTreeResponse[]>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/tree`,
method: 'get',
params: query
}).then(result =>
mapServiceResult(result as ServiceRequestResult<UserManagementRelationTreeResponse[]>, data =>
data.map(normalizeUserManagementRelationTree)
)
);
}
/**
* 通过搜索框的查询条件,获取用户管理链路树形结构
* 用于树形控件展示,包含用户的上下级层级关系
*/
export function fetchGetUserManagementRelationQuery(query: UserManagementRelationQueryReqVO) {
return request<UserManagementRelationTreeResponse[]>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/query`,
method: 'get',
params: query
}).then(result =>
mapServiceResult(result as ServiceRequestResult<UserManagementRelationTreeResponse[]>, data =>
data.map(normalizeUserManagementRelationTree)
)
);
}
/**
* 获取用户管理链路详情
*
* 根据主键 ID 查询单条用户管理链路记录
*
* @param id 关系记录主键 ID
*/
export function fetchGetUserManagementRelation(id: string) {
return request<UserManagementRelationResponse>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/get`,
method: 'get',
params: { id }
}).then(result =>
mapServiceResult(result as ServiceRequestResult<UserManagementRelationResponse>, normalizeUserManagementRelation)
);
}
/**
* 创建用户管理链路
*
* 创建新的用户管理链路记录
*
* @param data 创建请求参数
*/
export function fetchCreateUserManagementRelation(data: Api.SystemManage.UserManagementRelationSaveReqVO) {
return request<string | number>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/create`,
method: 'post',
data
}).then(result => mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId));
}
/**
* 更新用户管理链路
*
* 更新已有的用户管理链路记录
*
* @param data 更新请求参数(包含 id
*/
export function fetchUpdateUserManagementRelation(
data: { id: string } & Api.SystemManage.UserManagementRelationSaveReqVO
) {
return request<boolean>({
url: `${USER_MANAGEMENT_RELATION_PREFIX}/update`,
method: 'put',
data
});
}
/**
* 删除用户管理链路
*
* 根据主键 ID 删除单条用户管理链路记录
*
* @param id 关系记录主键 ID
*/
export function fetchDeleteUserManagementRelation(id: string | null) {
return request<boolean>({
url: `${USER_MANAGEMENT_RELATION_PREFIX}/delete`,
method: 'delete',
params: { id }
});
}
/**
* 批量删除用户管理链路
*
* 根据主键 ID 列表批量删除用户管理链路记录
*
* @param ids 关系记录主键 ID 列表
*/
export function fetchBatchDeleteUserManagementRelation(ids: string[]) {
return request<boolean>({
url: `${USER_MANAGEMENT_RELATION_PREFIX}/delete-list?${createBatchDeleteQuery(ids)}`,
method: 'delete'
});
}

View File

@@ -0,0 +1,84 @@
const MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
function shouldStringifyUnsafeInteger(token: string) {
if (token.includes('.') || token.includes('e') || token.includes('E')) {
return false;
}
try {
const value = BigInt(token);
return value > MAX_SAFE_INTEGER_BIGINT || value < -MAX_SAFE_INTEGER_BIGINT;
} catch {
return false;
}
}
function replaceUnsafeIntegerTokens(raw: string) {
let result = '';
let index = 0;
let inString = false;
let isEscaping = false;
while (index < raw.length) {
const char = raw[index];
if (inString) {
result += char;
if (isEscaping) {
isEscaping = false;
} else if (char === '\\') {
isEscaping = true;
} else if (char === '"') {
inString = false;
}
index += 1;
} else if (char === '"') {
inString = true;
result += char;
index += 1;
} else {
const nextChar = raw[index + 1] ?? '';
const isNumberStart = char === '-' ? /\d/.test(nextChar) : /\d/.test(char);
if (!isNumberStart) {
result += char;
index += 1;
} else {
let end = index + 1;
while (end < raw.length && /[\d.+\-Ee]/.test(raw[end])) {
end += 1;
}
const token = raw.slice(index, end);
result += shouldStringifyUnsafeInteger(token) ? `"${token}"` : token;
index = end;
}
}
}
return result;
}
/**
* 保留超出 JS 安全整数范围的 Long 原始值,避免在 JSON.parse 阶段丢精度。
*/
export function safeJsonTransformResponse(data: unknown) {
if (typeof data !== 'string') {
return data;
}
const raw = data.trim();
if (!raw) {
return data;
}
try {
return JSON.parse(replaceUnsafeIntegerTokens(raw));
} catch {
return data;
}
}

View File

@@ -2,8 +2,8 @@ import { effectScope, nextTick, onScopeDispose, ref, watch } from 'vue';
import { breakpointsTailwind, useBreakpoints, useEventListener, useTitle } from '@vueuse/core';
import { defineStore } from 'pinia';
import { useBoolean } from '@sa/hooks';
import { router } from '@/router';
import { localStg } from '@/utils/storage';
import { getGlobalRouter } from '@/router/instance';
import { SetupStoreId } from '@/enum';
import { $t, setLocale } from '@/locales';
import { setDayjsLocale } from '@/locales/dayjs';
@@ -63,7 +63,7 @@ export const useAppStore = defineStore(SetupStoreId.App, () => {
/** Update document title by locale */
function updateDocumentTitleByLocale() {
const { i18nKey, title } = router.currentRoute.value.meta;
const { i18nKey, title } = getGlobalRouter().currentRoute.value.meta;
const documentTitle = i18nKey ? $t(i18nKey) : title;
@@ -83,11 +83,10 @@ export const useAppStore = defineStore(SetupStoreId.App, () => {
if (newValue) {
// backup theme setting before is mobile
localStg.set('backupThemeSettingBeforeIsMobile', {
layout: themeStore.layout.mode,
layout: themeStore.layoutMode,
siderCollapse: siderCollapse.value
});
themeStore.setThemeLayout('vertical');
setSiderCollapse(true);
} else {
// when is not mobile, recover the backup theme setting
@@ -95,7 +94,6 @@ export const useAppStore = defineStore(SetupStoreId.App, () => {
if (backup) {
nextTick(() => {
themeStore.setThemeLayout(backup.layout);
setSiderCollapse(backup.siderCollapse);
localStg.remove('backupThemeSettingBeforeIsMobile');

View File

@@ -7,8 +7,10 @@ import { useRouterPush } from '@/hooks/common/router';
import { localStg } from '@/utils/storage';
import { SetupStoreId } from '@/enum';
import { $t } from '@/locales';
import { useDictStore } from '../dict';
import { useRouteStore } from '../route';
import { useTabStore } from '../tab';
import { useObjectContextStore } from '../object-context';
import { clearAuthStorage, getToken } from './shared';
export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
@@ -16,6 +18,8 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
const authStore = useAuthStore();
const routeStore = useRouteStore();
const tabStore = useTabStore();
const dictStore = useDictStore();
const objectContextStore = useObjectContextStore();
const { toLogin, redirectFromLogin } = useRouterPush(false);
const { loading: loginLoading, startLoading, endLoading } = useLoading();
@@ -46,6 +50,8 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
clearAuthStorage();
authStore.$reset();
dictStore.resetDictCache();
objectContextStore.$reset();
if (!route.meta.constant) {
await toLogin();
@@ -138,6 +144,8 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
const pass = await getUserInfo();
if (pass) {
await dictStore.initDictCache(true);
token.value = loginToken.token;
return true;

View File

@@ -0,0 +1,210 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE } from '@/constants/dict';
import { fetchGetFrontendDictCache } from '@/service/api';
import { SetupStoreId } from '@/enum';
type DictValue = string | number | null | undefined;
type DictFilterOptions = {
onlyEnabled?: boolean;
};
type DictLabelOptions = DictFilterOptions & {
fallback?: string;
};
type DictLabelsOptions = DictLabelOptions & {
separator?: string;
};
function sortDictData(list: Api.Dict.DictData[]) {
return list.slice().sort((left, right) => left.sort - right.sort || left.label.localeCompare(right.label, 'zh-CN'));
}
function normalizeFrontendDictData(
dictType: string,
list: Api.Dict.FrontendDictData[],
dictIndex: number
): Api.Dict.DictData[] {
const normalizedList = list.map((item, itemIndex) => ({
id: -((dictIndex + 1) * 100000 + itemIndex + 1),
label: item.label,
value: item.value,
dictType: item.dictType || dictType,
sort: item.sort,
status: item.status ?? 0,
remark: null,
createTime: 0
}));
return sortDictData(normalizedList);
}
function normalizeFrontendDictCache(cache: Api.Dict.FrontendDictCache) {
const entries = Object.entries(cache);
return Object.fromEntries(
entries.map(([dictType, list], index) => [dictType, normalizeFrontendDictData(dictType, list, index)])
);
}
function applyDictTypeAliases(dictDataMap: Record<string, Api.Dict.DictData[]>) {
const nextDictDataMap = { ...dictDataMap };
// 兼容后端尚未切换完成的过渡期:旧编码仍返回时,前端统一映射到新编码。
if (!nextDictDataMap[RDMS_OBJECT_DIRECTION_DICT_CODE] && nextDictDataMap[RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE]) {
nextDictDataMap[RDMS_OBJECT_DIRECTION_DICT_CODE] = nextDictDataMap[RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE].map(
item => ({
...item,
dictType: RDMS_OBJECT_DIRECTION_DICT_CODE
})
);
}
return nextDictDataMap;
}
function createRuntimeDictTypes(dictDataMap: Record<string, Api.Dict.DictData[]>) {
return Object.keys(dictDataMap).map((dictType, index) => ({
id: -(index + 1),
name: dictType,
type: dictType,
status: 0 as const,
remark: null,
createTime: 0
}));
}
function findDictItem(list: Api.Dict.DictData[], value?: DictValue) {
if (value === null || value === undefined || value === '') {
return undefined;
}
return list.find(item => item.value === String(value));
}
export const useDictStore = defineStore(SetupStoreId.Dict, () => {
const loading = ref(false);
const initialized = ref(false);
const dictTypes = ref<Api.Dict.DictType[]>([]);
const dictDataMap = ref<Record<string, Api.Dict.DictData[]>>({});
const loadedAt = ref<number | null>(null);
let initPromise: Promise<boolean> | null = null;
function resetDictCache() {
dictTypes.value = [];
dictDataMap.value = {};
loadedAt.value = null;
initialized.value = false;
initPromise = null;
}
async function initDictCache(force = false) {
if (initialized.value && !force) {
return true;
}
if (initPromise && !force) {
return initPromise;
}
if (force) {
resetDictCache();
}
initPromise = (async () => {
loading.value = true;
const result = await fetchGetFrontendDictCache();
loading.value = false;
if (result.error) {
initPromise = null;
return false;
}
const normalizedDictDataMap = applyDictTypeAliases(normalizeFrontendDictCache(result.data || {}));
dictTypes.value = createRuntimeDictTypes(normalizedDictDataMap);
dictDataMap.value = normalizedDictDataMap;
loadedAt.value = Date.now();
initialized.value = true;
initPromise = null;
return true;
})();
return initPromise;
}
function getDictData(dictType: string, onlyEnabled = false) {
if (!dictType) {
return [];
}
const list = dictDataMap.value[dictType] || [];
if (!onlyEnabled) {
return list;
}
return list.filter(item => item.status === 0);
}
function getDictOptions(dictType: string, onlyEnabled = true) {
return getDictData(dictType, onlyEnabled).map(item => ({
label: item.label,
value: item.value
}));
}
function getDictItem(dictType: string, value?: DictValue, options: DictFilterOptions = {}) {
return findDictItem(getDictData(dictType, options.onlyEnabled), value);
}
function getDictLabel(dictType: string, value?: DictValue, options: DictLabelOptions = {}) {
const { fallback = '--', onlyEnabled = false } = options;
if (value === null || value === undefined || value === '') {
return fallback;
}
const matched = getDictItem(dictType, value, { onlyEnabled });
return matched?.label || String(value);
}
function getDictLabels(dictType: string, values?: Array<DictValue> | null, options: DictLabelsOptions = {}) {
const { fallback = '--', separator = ' / ', onlyEnabled = false } = options;
if (!values?.length) {
return fallback;
}
const labels = values
.filter(value => value !== null && value !== undefined && value !== '')
.map(value => getDictLabel(dictType, value, { fallback: String(value), onlyEnabled }));
return labels.length ? labels.join(separator) : fallback;
}
function hasDictValue(dictType: string, value?: DictValue, options: DictFilterOptions = {}) {
return Boolean(getDictItem(dictType, value, options));
}
return {
loading,
initialized,
dictTypes,
dictDataMap,
loadedAt,
initDictCache,
resetDictCache,
getDictData,
getDictOptions,
getDictItem,
getDictLabel,
getDictLabels,
hasDictValue
};
});

View File

@@ -0,0 +1,403 @@
import { computed, ref } from 'vue';
import type { LocationQueryRaw, RouteLocationNormalized, RouteLocationRaw } from 'vue-router';
import { defineStore } from 'pinia';
import type { ElegantConstRoute } from '@elegant-router/types';
import {
OBJECT_CONTEXT_QUERY_KEY,
getObjectContextDomainConfigByPath,
isObjectContextEntryPath
} from '@/constants/object-context';
import { fetchGetObjectContext } from '@/service/api/object-context';
import { $t } from '@/locales';
import { SetupStoreId } from '@/enum';
import { useRouteStore } from '../route';
function createEmptyState(): App.ObjectContext.State {
return {
domainKey: '',
objectType: '',
objectId: '',
objectName: '',
objectSummary: null,
contextScopedMenus: [],
buttonCodes: [],
defaultRouteKey: '',
defaultRoutePath: '',
isReady: false
};
}
function normalizePath(path: string) {
if (!path) {
return '/';
}
return path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
}
function isRouteMatchedByPrefix(path: string, prefix: string) {
const normalizedPath = normalizePath(path);
const normalizedPrefix = normalizePath(prefix);
return normalizedPath === normalizedPrefix || normalizedPath.startsWith(`${normalizedPrefix}/`);
}
function findDomainRootRoute(
routes: ElegantConstRoute[],
config: App.ObjectContext.DomainConfig
): ElegantConstRoute | null {
for (const route of routes) {
if (config.routePathPrefixes.some(prefix => isRouteMatchedByPrefix(route.path, prefix))) {
return route;
}
if (route.children?.length) {
const matchedChild = findDomainRootRoute(route.children, config);
if (matchedChild) {
return matchedChild;
}
}
}
return null;
}
function isEntryRoute(route: ElegantConstRoute, config: App.ObjectContext.DomainConfig) {
return route.name === config.entryRouteKey || normalizePath(route.path) === normalizePath(config.entryRoutePath);
}
function getContextMenuLabel(route: ElegantConstRoute) {
const routeName = String(route.name || route.path);
return route.meta?.i18nKey ? $t(route.meta.i18nKey) : String(route.meta?.title || routeName);
}
type ContextRouteLookupItem = {
key: string;
label: string;
routeKey: string | null;
routePath: string | null;
};
function createContextRouteLookup(
routes: ElegantConstRoute[],
lookup = new Map<string, ContextRouteLookupItem>()
): Map<string, ContextRouteLookupItem> {
routes.forEach(route => {
const routeName = route.name ? String(route.name) : '';
const routePath = route.path ? String(route.path) : '';
const item: ContextRouteLookupItem = {
key: routeName || routePath,
label: getContextMenuLabel(route),
routeKey: routeName || null,
routePath: routePath || null
};
if (routeName) {
lookup.set(routeName, item);
}
if (routePath) {
lookup.set(routePath, item);
}
route.children?.forEach(child => {
createContextRouteLookup([child], lookup);
});
});
return lookup;
}
function enrichContextMenu(
menu: App.ObjectContext.Menu,
routeLookup: Map<string, ContextRouteLookupItem>
): App.ObjectContext.Menu {
const matchedRoute =
routeLookup.get(String(menu.routeKey || '')) ||
routeLookup.get(String(menu.routePath || '')) ||
routeLookup.get(menu.key);
return {
key: matchedRoute?.key || menu.key,
label: menu.label || matchedRoute?.label || menu.key,
routeKey: menu.routeKey || matchedRoute?.routeKey || null,
routePath: menu.routePath || matchedRoute?.routePath || null,
children: menu.children?.map(child => enrichContextMenu(child, routeLookup)) || []
};
}
function getLeafRoutes(routes: ElegantConstRoute[]): ElegantConstRoute[] {
return routes.flatMap(route => {
if (route.children?.length) {
return getLeafRoutes(route.children);
}
return [route];
});
}
export const useObjectContextStore = defineStore(SetupStoreId.ObjectContext, () => {
const routeStore = useRouteStore();
const domainKey = ref<App.ObjectContext.DomainKey>('');
const objectType = ref<App.ObjectContext.ObjectType>('');
const objectId = ref('');
const objectName = ref('');
const objectSummary = ref<App.ObjectContext.Summary | null>(null);
const contextScopedMenus = ref<App.ObjectContext.Menu[]>([]);
const buttonCodes = ref<string[]>([]);
const defaultRouteKey = ref('');
const defaultRoutePath = ref('');
const isReady = ref(false);
const hasContext = computed(() => isReady.value && Boolean(domainKey.value) && Boolean(objectId.value));
function patchState(state: App.ObjectContext.State) {
domainKey.value = state.domainKey;
objectType.value = state.objectType;
objectId.value = state.objectId;
objectName.value = state.objectName;
objectSummary.value = state.objectSummary;
contextScopedMenus.value = state.contextScopedMenus;
buttonCodes.value = state.buttonCodes;
defaultRouteKey.value = state.defaultRouteKey;
defaultRoutePath.value = state.defaultRoutePath;
isReady.value = state.isReady;
}
function clearContext() {
patchState(createEmptyState());
}
function resolveDefaultRoute(
config: App.ObjectContext.DomainConfig,
domainRoutes: ElegantConstRoute[],
context: Api.ObjectContext.ContextInfo
) {
const leafRoutes = getLeafRoutes(domainRoutes);
const defaultRouteByKey = (routeKey?: string | null) => leafRoutes.find(route => route.name === routeKey);
const defaultRouteByPath = (routePath?: string | null) =>
leafRoutes.find(route => normalizePath(route.path) === normalizePath(routePath || ''));
const matchedContextByKey = defaultRouteByKey(context.defaultRouteKey);
if (matchedContextByKey?.name && matchedContextByKey.path) {
return {
defaultRouteKey: String(matchedContextByKey.name),
defaultRoutePath: matchedContextByKey.path
};
}
const matchedContextByPath = defaultRouteByPath(context.defaultRoutePath);
if (matchedContextByPath?.name && matchedContextByPath.path) {
return {
defaultRouteKey: String(matchedContextByPath.name),
defaultRoutePath: matchedContextByPath.path
};
}
const matchedFallbackByKey = defaultRouteByKey(config.fallbackDefaultRouteKey);
if (matchedFallbackByKey?.name && matchedFallbackByKey.path) {
return {
defaultRouteKey: String(matchedFallbackByKey.name),
defaultRoutePath: matchedFallbackByKey.path
};
}
const matchedFallbackByPath = defaultRouteByPath(config.fallbackDefaultRoutePath);
if (matchedFallbackByPath?.name && matchedFallbackByPath.path) {
return {
defaultRouteKey: String(matchedFallbackByPath.name),
defaultRoutePath: matchedFallbackByPath.path
};
}
const [firstLeafRoute] = leafRoutes;
if (firstLeafRoute?.name && firstLeafRoute.path) {
return {
defaultRouteKey: String(firstLeafRoute.name),
defaultRoutePath: firstLeafRoute.path
};
}
return {
defaultRouteKey: context.defaultRouteKey || config.fallbackDefaultRouteKey,
defaultRoutePath: context.defaultRoutePath || config.fallbackDefaultRoutePath
};
}
function applyContext(config: App.ObjectContext.DomainConfig, context: Api.ObjectContext.ContextInfo) {
const domainRootRoute = findDomainRootRoute(routeStore.authRoutes, config);
const domainRoutes: ElegantConstRoute[] =
domainRootRoute?.children?.filter((route: ElegantConstRoute) => !isEntryRoute(route, config)) || [];
const routeLookup = createContextRouteLookup(domainRoutes);
// 对象上下文菜单以接口返回为准,前端只补全跳转所需的本地路由信息。
const contextMenus = context.contextScopedMenus.map(menu => enrichContextMenu(menu, routeLookup));
const resolvedDefaultRoute = resolveDefaultRoute(config, domainRoutes, context);
patchState({
...context,
contextScopedMenus: contextMenus,
defaultRouteKey: resolvedDefaultRoute.defaultRouteKey,
defaultRoutePath: resolvedDefaultRoute.defaultRoutePath,
isReady: true
});
}
function getObjectIdFromRoute(route: Pick<RouteLocationNormalized, 'query'>) {
const routeObjectId = route.query?.[OBJECT_CONTEXT_QUERY_KEY];
if (Array.isArray(routeObjectId)) {
return String(routeObjectId[0] || '');
}
if (routeObjectId === null || routeObjectId === undefined) {
return '';
}
return String(routeObjectId);
}
function getContextQuery(targetObjectId = objectId.value): LocationQueryRaw {
if (!targetObjectId) {
return {};
}
return {
[OBJECT_CONTEXT_QUERY_KEY]: targetObjectId
};
}
function createEntryLocation(config: App.ObjectContext.DomainConfig): RouteLocationRaw {
return {
path: config.entryRoutePath
};
}
function createDefaultLocation(config: App.ObjectContext.DomainConfig, targetObjectId: string): RouteLocationRaw {
const query = getContextQuery(targetObjectId);
if (defaultRouteKey.value) {
return {
name: defaultRouteKey.value,
query
};
}
if (defaultRoutePath.value) {
return {
path: defaultRoutePath.value,
query
};
}
return {
path: config.fallbackDefaultRoutePath,
query
};
}
async function enterContext(config: App.ObjectContext.DomainConfig, targetObjectId: string) {
const result = await fetchGetObjectContext(config, targetObjectId);
if (!result.error && result.data) {
applyContext(config, result.data);
}
return result;
}
async function switchContext(config: App.ObjectContext.DomainConfig, targetObjectId: string) {
return enterContext(config, targetObjectId);
}
async function ensureContextByRoute(to: RouteLocationNormalized): Promise<RouteLocationRaw | null> {
const domainConfig = getObjectContextDomainConfigByPath(to.path);
if (!domainConfig) {
if (hasContext.value) {
clearContext();
}
return null;
}
const routeObjectId = getObjectIdFromRoute(to);
if (!routeObjectId) {
clearContext();
if (isObjectContextEntryPath(to.path, domainConfig)) {
return null;
}
return createEntryLocation(domainConfig);
}
const isSameContext =
hasContext.value && domainKey.value === domainConfig.domainKey && objectId.value === routeObjectId;
if (!isSameContext) {
const { error } = await enterContext(domainConfig, routeObjectId);
if (error) {
clearContext();
return createEntryLocation(domainConfig);
}
}
if (isObjectContextEntryPath(to.path, domainConfig)) {
return createDefaultLocation(domainConfig, routeObjectId);
}
return null;
}
function getMenuRouteLocation(
menu: App.ObjectContext.Menu,
targetObjectId = objectId.value
): RouteLocationRaw | null {
const query = getContextQuery(targetObjectId);
if (menu.routeKey) {
return {
name: menu.routeKey,
query
};
}
if (menu.routePath) {
return {
path: menu.routePath,
query
};
}
return null;
}
return {
domainKey,
objectType,
objectId,
objectName,
objectSummary,
contextScopedMenus,
buttonCodes,
defaultRouteKey,
defaultRoutePath,
isReady,
hasContext,
clearContext,
enterContext,
switchContext,
ensureContextByRoute,
getContextQuery,
getMenuRouteLocation
};
});

View File

@@ -3,13 +3,12 @@ import type { RouteRecordRaw } from 'vue-router';
import { defineStore } from 'pinia';
import { useBoolean } from '@sa/hooks';
import type { CustomRoute, ElegantConstRoute, LastLevelRouteKey, RouteKey, RouteMap } from '@elegant-router/types';
import { router } from '@/router';
import { fetchGetUserRoutes, fetchIsRouteExist } from '@/service/api';
import { SetupStoreId } from '@/enum';
import { createStaticRoutes, getAuthVueRoutes } from '@/router/routes';
import { ROOT_ROUTE } from '@/router/routes/builtin';
import { getRouteName, getRoutePath } from '@/router/elegant/transform';
import { getGlobalRouter } from '@/router/instance';
import { useAuthStore } from '../auth';
import { useDictStore } from '../dict';
import { useTabStore } from '../tab';
import {
filterAuthRoutesByRoles,
@@ -23,8 +22,27 @@ import {
updateLocaleOfGlobalMenus
} from './shared';
type RouteModule = typeof import('@/router/routes');
async function loadRouteModule(): Promise<RouteModule> {
return import('@/router/routes');
}
function createRootRoute(redirect: string): CustomRoute {
return {
name: 'root',
path: '/',
redirect,
meta: {
title: 'root',
constant: true
}
};
}
export const useRouteStore = defineStore(SetupStoreId.Route, () => {
const authStore = useAuthStore();
const dictStore = useDictStore();
const tabStore = useTabStore();
const { bool: isInitConstantRoute, setBool: setIsInitConstantRoute } = useBoolean();
const { bool: isInitAuthRoute, setBool: setIsInitAuthRoute } = useBoolean();
@@ -117,7 +135,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
* @param routeKey
*/
async function resetRouteCache(routeKey?: RouteKey) {
const routeName = routeKey || (router.currentRoute.value.name as RouteKey);
const routeName = routeKey || (getGlobalRouter().currentRoute.value.name as RouteKey);
excludeCacheRoutes.value.push(routeName);
@@ -127,7 +145,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
}
/** 全局面包屑 */
const breadcrumbs = computed(() => getBreadcrumbsByRoute(router.currentRoute.value, menus.value));
const breadcrumbs = computed(() => getBreadcrumbsByRoute(getGlobalRouter().currentRoute.value, menus.value));
/** 重置 store */
async function resetStore() {
@@ -151,11 +169,12 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
async function initConstantRoute() {
if (isInitConstantRoute.value) return;
const { createStaticRoutes } = await loadRouteModule();
const staticRoute = createStaticRoutes();
addConstantRoutes(staticRoute.constantRoutes);
handleConstantAndAuthRoutes();
await handleConstantAndAuthRoutes();
setIsInitConstantRoute(true);
@@ -169,8 +188,10 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
await authStore.initUserInfo();
}
await dictStore.initDictCache();
if (authRouteMode.value === 'static') {
initStaticAuthRoute();
await initStaticAuthRoute();
} else {
await initDynamicAuthRoute();
}
@@ -179,7 +200,8 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
}
/** 初始化静态权限路由 */
function initStaticAuthRoute() {
async function initStaticAuthRoute() {
const { createStaticRoutes } = await loadRouteModule();
const { authRoutes: staticAuthRoutes } = createStaticRoutes();
if (authStore.isStaticSuper) {
@@ -190,7 +212,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
addAuthRoutes(filteredAuthRoutes);
}
handleConstantAndAuthRoutes();
await handleConstantAndAuthRoutes();
setIsInitAuthRoute(true);
}
@@ -204,11 +226,11 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
addAuthRoutes(routes);
handleConstantAndAuthRoutes();
await handleConstantAndAuthRoutes();
setRouteHome(home);
handleUpdateRootRouteRedirect(home);
await handleUpdateRootRouteRedirect(home);
setIsInitAuthRoute(true);
} else {
@@ -218,7 +240,8 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
}
/** 统一处理常量路由和权限路由 */
function handleConstantAndAuthRoutes() {
async function handleConstantAndAuthRoutes() {
const { getAuthVueRoutes } = await loadRouteModule();
const allRoutes = [...constantRoutes.value, ...authRoutes.value];
const sortRoutes = sortRoutesByOrder(allRoutes);
@@ -241,7 +264,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
*/
function addRoutesToVueRouter(routes: RouteRecordRaw[]) {
routes.forEach(route => {
const removeFn = router.addRoute(route);
const removeFn = getGlobalRouter().addRoute(route);
addRemoveRouteFn(removeFn);
});
}
@@ -260,11 +283,13 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
*
* @param redirectKey 重定向目标路由 key
*/
function handleUpdateRootRouteRedirect(redirectKey: LastLevelRouteKey) {
async function handleUpdateRootRouteRedirect(redirectKey: LastLevelRouteKey) {
const redirect = getRoutePath(redirectKey);
if (redirect) {
const rootRoute: CustomRoute = { ...ROOT_ROUTE, redirect };
const { getAuthVueRoutes } = await loadRouteModule();
const rootRoute = createRootRoute(redirect);
const router = getGlobalRouter();
router.removeRoute(rootRoute.name);
@@ -287,6 +312,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
}
if (authRouteMode.value === 'static') {
const { createStaticRoutes } = await loadRouteModule();
const { authRoutes: staticAuthRoutes } = createStaticRoutes();
return isRouteExistByRouteName(routeName, staticAuthRoutes);
}
@@ -316,6 +342,7 @@ export const useRouteStore = defineStore(SetupStoreId.Route, () => {
return {
resetStore,
routeHome,
authRoutes,
menus,
searchMenus,
updateGlobalMenusByLocale,

View File

@@ -2,10 +2,10 @@ import { computed, ref } from 'vue';
import { useEventListener } from '@vueuse/core';
import { defineStore } from 'pinia';
import type { RouteKey } from '@elegant-router/types';
import { router } from '@/router';
import { useRouteStore } from '@/store/modules/route';
import { useRouterPush } from '@/hooks/common/router';
import { localStg } from '@/utils/storage';
import { getGlobalRouter } from '@/router/instance';
import { SetupStoreId } from '@/enum';
import { useThemeStore } from '../theme';
import {
@@ -35,7 +35,7 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
/** Init home tab */
function initHomeTab() {
homeTab.value = getDefaultHomeTab(router, routeStore.routeHome);
homeTab.value = getDefaultHomeTab(getGlobalRouter(), routeStore.routeHome);
}
/** Get all tabs */
@@ -62,7 +62,7 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
const storageTabs = localStg.get('globalTabs');
if (themeStore.tab.cache && storageTabs) {
const extractedTabs = extractTabsByAllRoutes(router, storageTabs);
const extractedTabs = extractTabsByAllRoutes(getGlobalRouter(), storageTabs);
tabs.value = updateTabsByI18nKey(extractedTabs);
}

View File

@@ -1,6 +1,6 @@
import { computed, effectScope, onScopeDispose, ref, toRefs, watch } from 'vue';
import type { Ref } from 'vue';
import { usePreferredColorScheme } from '@vueuse/core';
import { breakpointsTailwind, useBreakpoints, usePreferredColorScheme } from '@vueuse/core';
import { defineStore } from 'pinia';
import { getPaletteColorByNumber } from '@sa/color';
import { localStg } from '@/utils/storage';
@@ -19,6 +19,8 @@ import {
export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
const scope = effectScope();
const osTheme = usePreferredColorScheme();
const breakpoints = useBreakpoints(breakpointsTailwind);
const isMobile = breakpoints.smaller('sm');
/** Theme settings */
const settings: Ref<App.Theme.ThemeSetting> = ref(initThemeSettings());
@@ -51,6 +53,12 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
/** UI theme */
const uiTheme = computed(() => getNaiveTheme(themeColors.value, settings.value.recommendColor));
/** Product layout mode */
const layoutMode = computed<UnionKey.ThemeLayoutMode>(() => (isMobile.value ? 'vertical' : 'horizontal-mix'));
/** Product tab visible */
const tabVisible = computed(() => (isMobile.value ? settings.value.tab.visible : false));
/**
* Settings json
*
@@ -216,6 +224,8 @@ export const useThemeStore = defineStore(SetupStoreId.Theme, () => {
darkMode,
themeColors,
uiTheme,
layoutMode,
tabVisible,
settingsJson,
setGrayscale,
setColourWeakness,

View File

@@ -13,7 +13,7 @@ export const themeSettings: App.Theme.ThemeSetting = {
},
isInfoFollowPrimary: true,
layout: {
mode: 'vertical',
mode: 'horizontal-mix',
scrollMode: 'content',
reverseHorizontalMix: false
},

View File

@@ -61,6 +61,23 @@ declare namespace Api {
createTime: number;
}
/** frontend runtime dict item */
interface FrontendDictData {
/** dict label */
label: string;
/** dict value */
value: string;
/** display order */
sort: number;
/** dict type code */
dictType?: string;
/** status: 0 enabled, 1 disabled */
status?: DictStatus;
}
/** frontend runtime dict cache map */
type FrontendDictCache = Record<string, FrontendDictData[]>;
/** dict data search params */
type DictDataSearchParams = CommonType.RecordNullable<Pick<DictData, 'label' | 'dictType' | 'status'>> & PageParams;

15
src/typings/api/object-context.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
declare namespace Api {
namespace ObjectContext {
interface ContextInfo {
domainKey: App.ObjectContext.DomainKey;
objectType: App.ObjectContext.ObjectType;
objectId: string;
objectName: string;
objectSummary: App.ObjectContext.Summary | null;
contextScopedMenus: App.ObjectContext.Menu[];
buttonCodes: string[];
defaultRouteKey: string;
defaultRoutePath: string;
}
}
}

443
src/typings/api/product.d.ts vendored Normal file
View File

@@ -0,0 +1,443 @@
declare namespace Api {
/**
* namespace Product
*
* backend api module: "project/product"
*/
namespace Product {
type ProductStatusCode = 'active' | 'paused' | 'archived' | 'abandoned';
type ProductStatusActionCode = 'pause' | 'resume' | 'archive' | 'abandon';
type ProductMemberStatus = 0 | 1;
interface PageParams {
pageNo: number;
pageSize: number;
}
interface PageResult<T = any> {
total: number;
list: T[];
}
interface Product {
/** 产品 ID */
id: string;
/** 产品编码 */
code: string;
/** 产品方向字典值 */
directionCode: string;
/** 产品名称 */
name: string;
/** 产品经理用户 ID */
managerUserId: string;
/** 产品描述 */
description?: string | null;
/** 产品状态编码 */
statusCode: ProductStatusCode;
/** 最近一次状态动作原因 */
lastStatusReason?: string | null;
/** 备注 */
remark?: string | null;
/** 创建时间 */
createTime: string;
/** 更新时间 */
updateTime: string;
}
interface ProductSettingBaseInfo {
/** 产品 ID */
id: string;
/** 产品编码 */
code: string;
/** 产品方向字典值 */
directionCode: string;
/** 产品名称 */
name: string;
/** 产品经理用户 ID */
managerUserId: string;
/** 产品经理昵称 */
managerUserNickname: string;
/** 产品描述 */
description?: string | null;
/** 当前产品状态 */
statusCode: ProductStatusCode;
/** 最近一次状态动作原因 */
lastStatusReason?: string | null;
}
interface ProductLifecycleAction {
actionCode: ProductStatusActionCode;
actionName: string;
needReason: boolean;
}
interface ProductLifecycleInfo {
statusCode: ProductStatusCode;
lastStatusReason?: string | null;
availableActions: ProductLifecycleAction[];
}
interface ProductSettings {
baseInfo: ProductSettingBaseInfo;
lifecycle: ProductLifecycleInfo;
}
interface ProductMember {
/** 团队关系 ID */
id: string;
/** 用户 ID */
userId: string;
/** 用户昵称 */
userNickname: string;
/** 角色 ID */
roleId: string;
/** 角色名称 */
roleName: string;
/** 角色编码 */
roleCode: string;
/** 是否当前产品经理 */
managerFlag: boolean;
/** 成员状态 */
status: ProductMemberStatus;
/** 加入时间 */
joinedTime: string;
/** 退出时间 */
leftTime?: string | null;
/** 备注 */
remark?: string | null;
}
type ProductActivityType = 'status' | 'product' | 'member';
type ProductActivityActionType =
| 'create'
| 'change_manager'
| 'pause'
| 'resume'
| 'archive'
| 'abandon'
| 'add_member'
| 'update_member'
| 'remove_member';
interface ProductActivityTimelinePageParams extends PageParams {
/** 分类 */
activityType?: ProductActivityType | null;
/** 动作编码数组,多选时按重复 query 参数传递 */
actionTypes?: ProductActivityActionType[] | null;
/** 开始时间,格式 yyyy-MM-dd HH:mm:ss */
startTime?: string | null;
/** 结束时间,格式 yyyy-MM-dd HH:mm:ss */
endTime?: string | null;
}
interface ProductActivityTimelineItem {
/** 动态唯一标识 */
id: string;
/** 动态类型 */
type: ProductActivityType;
/** 动作编码 */
actionType: ProductActivityActionType;
/** 动作中文名称 */
actionName: string;
/** 操作人用户 ID */
operatorUserId?: string | null;
/** 操作人名称 */
operatorName: string;
/** 目标用户 ID成员类动态使用 */
targetUserId?: string | null;
/** 目标用户名称,成员类动态使用 */
targetUserName?: string | null;
/** 动态发生时间,毫秒时间戳 */
occurredAt: number;
/** 可直接展示的摘要文案 */
summary: string;
/** 原因说明 */
reason?: string | null;
/** 原状态编码 */
fromStatus?: ProductStatusCode | null;
/** 目标状态编码 */
toStatus?: ProductStatusCode | null;
/** 补充明细,当前为 JSON 字符串 */
details?: string | null;
}
type ProductSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> &
Pick<Product, 'directionCode' | 'managerUserId' | 'statusCode'> & {
keyword: string;
updateTime: string[];
}
>;
type SaveProductParams = Pick<Product, 'directionCode' | 'name' | 'managerUserId'> & {
code?: string | null;
description?: string | null;
remark?: string | null;
};
type UpdateProductParams = { id: string } & SaveProductParams;
interface ChangeProductStatusParams {
id: string;
actionCode: ProductStatusActionCode;
reason?: string | null;
}
interface DeleteProductParams {
id: string;
productName: string;
reason: string;
}
type UpdateProductSettingBaseInfoParams = Pick<ProductSettingBaseInfo, 'directionCode' | 'name'> & {
description?: string | null;
};
interface CreateProductMemberParams {
userId: string;
roleId: string;
remark?: string | null;
previousManagerUserId?: string | null;
previousManagerRoleId?: string | null;
}
interface UpdateProductMemberParams {
roleId: string;
remark?: string | null;
reason?: string | null;
previousManagerUserId?: string | null;
previousManagerRoleId?: string | null;
}
interface InactiveProductMemberParams {
reason?: string | null;
}
// ========== 产品需求相关类型定义 ==========
/** 需求状态编码 */
type RequirementStatusCode =
| 'pending_confirm'
| 'pending_review'
| 'pending_dispatch'
| 'implementing'
| 'accepted'
| 'closed'
| 'rejected'
| 'cancelled';
/** 需求来源类型 */
type RequirementSourceType = 'manual' | 'work_order';
/** 需求优先级 */
type RequirementPriority = 0 | 1 | 2 | 3;
/** 是否需要评审 */
type RequirementReviewRequired = 0 | 1;
// ========== 需求实体 ==========
interface Requirement {
/** 需求编号 */
id: string;
/** 产品 ID */
productId: string;
/** 父需求编号0表示顶级需求 */
parentId: string;
/** 所属模块编号 */
moduleId: string;
/** 是否需要评审0不需要1需要 */
reviewRequired: RequirementReviewRequired;
/** 需求标题 */
title: string;
/** 需求描述(富文本) */
description?: string | null;
/** 需求分类字典值 */
category: string;
/** 需求分类名称 */
categoryName?: string | null;
/** 来源类型 */
sourceType: RequirementSourceType;
/** 来源业务ID */
sourceBizId?: string | null;
/** 优先级0低 1中 2高 3紧急 */
priority: RequirementPriority;
/** 优先级名称 */
priorityName?: string | null;
/** 当前状态编码 */
statusCode: RequirementStatusCode;
/** 当前状态名称 */
statusName?: string | null;
/** 最近一次状态动作原因 */
lastStatusReason?: string | null;
/** 提出人用户编号 */
proposerId: string;
/** 提出人用户姓名 */
proposerNickname?: string | null;
/** 当前处理人用户编号 */
currentHandlerUserId?: string | null;
/** 当前处理人姓名 */
currentHandlerUserNickname?: string | null;
/** 默认实现项目编号 */
implementProjectId?: string | null;
/** 实现项目名称 */
implementProjectName?: string | null;
/** 预期完成时间 */
completionDate: string;
/** 排序值 */
sort: number;
/** 创建时间 */
createTime: string;
/** 更新时间 */
updateTime: string;
/** 子需求列表(树形结构) */
children?: Requirement[];
/** 是否为终态 */
terminal?: boolean;
}
// ========== 需求模块实体 ==========
interface RequirementModule {
/** 模块编号 */
id: string | undefined;
/** 父模块编号0表示顶级 */
parentId: string | undefined;
/** 所属产品编号 */
productId: string;
/** 模块名称 */
moduleName: string;
/** 模块说明 */
remark?: string | null;
/** 图标 */
icon?: string | null;
/** 排序值 */
sort: number;
/** 子模块列表 */
children?: RequirementModule[];
}
// ========== 需求状态字典 ==========
interface RequirementStatusDict {
/** 状态编码 */
statusCode: string;
/** 状态名称 */
statusName: string;
/** 排序值 */
sort: number;
/** 是否初始状态 */
initialFlag: boolean;
/** 是否终态 */
terminalFlag: boolean;
}
// ========== 需求生命周期 ==========
interface RequirementLifecycleAction {
actionCode: string;
actionName: string;
toStatusCode: string;
toStatusName: string;
needReason: boolean;
}
interface RequirementLifecycleInfo {
statusCode: RequirementStatusCode;
statusName?: string | null;
lastStatusReason?: string | null;
terminal: boolean;
allowEdit: boolean;
availableActions: RequirementLifecycleAction[];
}
// ========== 请求参数类型 ==========
/** 需求分页查询参数 */
type RequirementSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> &
Pick<
Requirement,
'moduleId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceType'
> & {
productId: string;
title?: string;
}
>;
/** 创建需求参数 */
type SaveRequirementParams = Pick<
Requirement,
| 'productId'
| 'moduleId'
| 'reviewRequired'
| 'title'
| 'description'
| 'category'
| 'priority'
| 'proposerId'
| 'currentHandlerUserId'
| 'implementProjectId'
| 'completionDate'
| 'sort'
>;
/** 更新需求参数 */
type UpdateRequirementParams = { id: string } & SaveRequirementParams;
/** 变更需求状态参数 */
interface ChangeRequirementStatusParams {
id: string;
productId: string;
actionCode: string;
reason?: string | null;
implementProjectId?: string | null;
}
/** 关闭需求参数 */
interface CloseRequirementParams {
id: string;
productId: string;
reason: string;
}
/** 拆分需求参数 */
type SplitRequirementParams = Pick<
Requirement,
| 'parentId'
| 'productId'
| 'moduleId'
| 'reviewRequired'
| 'title'
| 'description'
| 'category'
| 'priority'
| 'proposerId'
| 'currentHandlerUserId'
| 'completionDate'
| 'sort'
>;
/** 删除需求参数 */
interface DeleteRequirementParams {
id: string;
productId: string;
}
// ========== 模块请求参数 ==========
/** 保存模块参数 */
type SaveRequirementModuleParams = Pick<
RequirementModule,
'id' | 'productId' | 'parentId' | 'moduleName' | 'remark' | 'icon' | 'sort'
>;
/** 删除模块参数 */
interface DeleteRequirementModuleParams {
id: string | undefined;
productId: string;
}
}
}

View File

@@ -19,13 +19,26 @@ declare namespace Api {
type RoleType = 1 | 2;
type ScopeType = 'global' | 'object';
type ObjectType = 'product' | 'project';
interface ScopeQueryParams {
scopeType?: ScopeType;
objectType?: ObjectType;
}
interface Role {
/** role id */
id: number;
id: string;
/** role name */
name: string;
/** role code */
code: string;
/** scope type */
scopeType?: ScopeType;
/** object type */
objectType?: ObjectType | '' | null;
/** display sort */
sort: number;
/** status: 0 enabled, 1 disabled */
@@ -39,13 +52,12 @@ declare namespace Api {
}
type RoleSearchParams = CommonType.RecordNullable<Pick<Role, 'name' | 'code' | 'status'>> &
PageParams & {
createTime?: string[];
};
PageParams & { createTime?: string[] } & ScopeQueryParams;
type SaveRoleParams = Pick<Role, 'name' | 'code' | 'sort' | 'status'> & {
type SaveRoleParams = (Pick<Role, 'name' | 'code' | 'sort' | 'status'> & {
remark?: string | null;
};
}) &
ScopeQueryParams;
type RoleList = PageResult<Role>;
@@ -91,7 +103,7 @@ declare namespace Api {
interface OrgLeaderRelation {
id: number;
deptId: number;
userId: number;
userId: string;
userNickname: string;
effectiveFrom?: number | null;
effectiveUntil?: number | null;
@@ -103,7 +115,7 @@ declare namespace Api {
type OrgLeaderRelationList = OrgLeaderRelation[];
interface OrgLeaderCandidateUser {
id: number;
id: string;
nickname: string;
deptId: number;
deptName?: string | null;
@@ -113,7 +125,7 @@ declare namespace Api {
type SaveOrgLeaderRelationParams = {
deptId: number;
userId: number;
userId: string | null;
effectiveFrom?: number | null;
effectiveUntil?: number | null;
remark?: string | null;
@@ -130,6 +142,7 @@ declare namespace Api {
deptName?: string | null;
positionId?: number | null;
positionName?: string | null;
company?: string | null;
email?: string | null;
mobile?: string | null;
sex?: UserGender | null;
@@ -145,9 +158,11 @@ declare namespace Api {
Pick<User, 'status'> &
Pick<PageParams, 'pageNo' | 'pageSize'> & {
username?: string;
nickname?: string;
mobile?: string;
deptId?: number;
roleId?: number;
roleId?: string;
company?: string;
}
>;
@@ -158,6 +173,7 @@ declare namespace Api {
remark?: string | null;
positionId?: number | null;
resignedAt?: number | null;
company?: string | null;
email?: string | null;
mobile?: string | null;
sex?: UserGender | null;
@@ -214,7 +230,7 @@ declare namespace Api {
interface AssignUserRoleParams {
userId: number;
roleIds: number[];
roleIds: string[];
}
/**
@@ -240,17 +256,21 @@ declare namespace Api {
interface Menu {
/** menu id */
id: number;
id: string;
/** menu name */
name: string;
/** permission code */
permission?: string | null;
/** scope type */
scopeType?: ScopeType;
/** object type */
objectType?: ObjectType | '' | null;
/** menu type */
type: MenuType;
/** display sort */
sort: number;
/** parent menu id */
parentId: number;
parentId: string;
/** route path */
path?: string | null;
/** menu icon */
@@ -277,7 +297,7 @@ declare namespace Api {
children?: Menu[] | null;
}
type MenuSearchParams = CommonType.RecordNullable<Pick<Menu, 'name' | 'status'>>;
type MenuSearchParams = CommonType.RecordNullable<Pick<Menu, 'name' | 'status'>> & ScopeQueryParams;
type SaveMenuParams = Pick<
Menu,
@@ -296,12 +316,15 @@ declare namespace Api {
| 'visible'
| 'keepAlive'
| 'alwaysShow'
>;
> &
ScopeQueryParams;
interface MenuSimple {
id: number;
id: string;
name: string;
parentId: number;
parentId: string;
scopeType?: ScopeType;
objectType?: ObjectType | '' | null;
type: MenuType;
children?: MenuSimple[] | null;
}
@@ -311,8 +334,100 @@ declare namespace Api {
type MenuSimpleList = MenuSimple[];
interface AssignRoleMenuParams {
roleId: number;
menuIds: number[];
roleId: string;
menuIds: string[];
}
/**
* 用户管理链路记录
*
* 用于管理用户之间的管理链路,支持多层级上下级关系
* 对应后端 UserManagementRelationRespVO
*/
interface UserManagementRelation {
/** 主键 ID */
id: string | null;
/** 管理者用户 ID */
managerUserId: string | null;
/** 被管理用户 ID */
subordinateUserId: string | null;
/** 生效开始时间 */
effectiveFrom?: number | null;
/** 生效结束时间 */
effectiveUntil?: number | null;
/** 备注 */
remark?: string | null;
/** 创建时间 */
createTime: number;
}
/**
* 用户管理链路树形响应 VO
*
* 专门用于树形结构展示的响应对象
* 对应后端 UserManagementRelationTreeRespVO
*/
interface UserManagementRelationTreeRespVO {
/** 关系记录主键 ID最高领导为 null */
id: string | null;
/** 用户 ID */
userId: string;
/** 用户昵称 */
userNickname: string;
/** 上级用户 ID最高领导为 null */
managerUserId: string | null;
/** 上级用户昵称(最高领导为 null */
managerNickname: string | null;
/** 下级用户列表(基层员工为空列表) */
children?: UserManagementRelationTreeRespVO[] | null;
}
/**
* 用户管理链路保存参数
*
* 用于创建和更新操作
* 对应后端 UserManagementRelationSaveReqVO
*/
interface UserManagementRelationSaveReqVO {
/** 主键 ID更新时需要 */
id?: string;
/** 管理者用户 ID */
managerUserId: string | null;
/** 被管理用户 ID */
subordinateUserId: string | null;
/** 生效开始时间 */
effectiveFrom?: number | null;
/** 生效结束时间 */
effectiveUntil?: number | null;
/** 备注 */
remark?: string | null;
}
/**
* 用户管理链路查询参数(列表)
*
* 用于搜索框的查询和导出
* 对应后端 UserManagementRelationQueryReqVO
*/
type UserManagementRelationQueryReqVO = CommonType.RecordNullable<
Pick<UserManagementRelation, 'managerUserId' | 'subordinateUserId'> & {
/** 是否来自管理链路的index组件 */
fromUserIndex: boolean;
/** 部门ID */
deptId?: number | null;
}
>;
/**
* 用户简单信息
*
* 用于用户选择下拉框
*/
interface UserSimple {
/** 用户 ID */
id: string;
/** 用户昵称 */
nickname: string;
}
}
}

47
src/typings/app.d.ts vendored
View File

@@ -549,6 +549,14 @@ declare namespace App {
enable: string;
disable: string;
};
scopeType: {
global: string;
object: string;
};
objectType: {
product: string;
project: string;
};
};
role: {
title: string;
@@ -569,6 +577,13 @@ declare namespace App {
selectedCount: string;
disabledTip: string;
emptyRole: string;
currentRoleCount: string;
globalRoleTitle: string;
objectRoleTitle: string;
globalRoleSummary: string;
objectRoleSummary: string;
objectRoleSummaryProduct: string;
objectRoleSummaryProject: string;
lastAuthSave: string;
unsavedTip: string;
form: {
@@ -691,11 +706,15 @@ declare namespace App {
menuType: string;
menuName: string;
permission: string;
scopeType: string;
objectType: string;
resourceCode: string;
routeName: string;
routePath: string;
routeKind: string;
routePropsJson: string;
pageResource: string;
boundRoute: string;
component: string;
componentName: string;
iframeUrl: string;
@@ -725,6 +744,29 @@ declare namespace App {
alwaysShow: string;
createTime: string;
topLevel: string;
contextEyebrow: string;
contextTitle: string;
contextDescription: string;
currentContext: string;
currentResourceCount: string;
editorMode: string;
editorModeGlobal: string;
editorModeObject: string;
globalResourceTitle: string;
objectResourceTitle: string;
globalResourceSummary: string;
objectResourceSummary: string;
objectResourceSummaryProduct: string;
objectResourceSummaryProject: string;
scopeHintGlobal: string;
scopeHintObject: string;
objectTypePlaceholder: string;
contextReady: string;
contextPending: string;
objectTypeRequiredTitle: string;
objectTypeRequiredDescription: string;
objectModeTipTitle: string;
objectModeTipDescription: string;
sections: {
basic: string;
route: string;
@@ -736,6 +778,7 @@ declare namespace App {
parentId: string;
menuName: string;
permission: string;
resourceCode: string;
routeName: string;
routePath: string;
path: string;
@@ -743,6 +786,7 @@ declare namespace App {
componentName: string;
routeKind: string;
pageResource: string;
boundRoute: string;
pageResourceParentMismatch: string;
routePropsJson: string;
routePropsJsonHint: string;
@@ -784,6 +828,7 @@ declare namespace App {
};
routePath: string;
pageResource: string;
boundRoute: string;
component: string;
};
addMenu: string;
@@ -793,6 +838,8 @@ declare namespace App {
directory: string;
menu: string;
button: string;
navigation: string;
actionButton: string;
};
iconType: {
iconify: string;

View File

@@ -10,6 +10,7 @@ declare module 'vue' {
export interface GlobalComponents {
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
BetterScroll: typeof import('./../components/custom/better-scroll.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']
@@ -17,6 +18,10 @@ declare module 'vue' {
CountTo: typeof import('./../components/custom/count-to.vue')['default']
CustomIconSelect: typeof import('./../components/custom/custom-icon-select.vue')['default']
DarkModeContainer: typeof import('./../components/common/dark-mode-container.vue')['default']
DictSelect: typeof import('./../components/custom/dict-select.vue')['default']
DictTag: typeof import('./../components/custom/dict-tag.vue')['default']
DictText: typeof import('./../components/custom/dict-text.vue')['default']
ElAffix: typeof import('element-plus/es')['ElAffix']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
@@ -25,12 +30,14 @@ declare module 'vue' {
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDatePickerPanel: typeof import('element-plus/es')['ElDatePickerPanel']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
@@ -52,6 +59,7 @@ declare module 'vue' {
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
@@ -82,6 +90,7 @@ declare module 'vue' {
IconCarbonPlay: typeof import('~icons/carbon/play')['default']
IconCarbonStop: typeof import('~icons/carbon/stop')['default']
'IconCharm:download': typeof import('~icons/charm/download')['default']
'IconEp:arrowDown': typeof import('~icons/ep/arrow-down')['default']
IconEpRemoveFilled: typeof import('~icons/ep/remove-filled')['default']
IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default']
'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default']
@@ -94,6 +103,7 @@ declare module 'vue' {
'IconIc:roundPlus': typeof import('~icons/ic/round-plus')['default']
'IconIconParkOutline:equalRatio': typeof import('~icons/icon-park-outline/equal-ratio')['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']
IconIcRoundRefresh: typeof import('~icons/ic/round-refresh')['default']
IconIcRoundRemove: typeof import('~icons/ic/round-remove')['default']
@@ -103,6 +113,7 @@ declare module 'vue' {
IconLocalCast: typeof import('~icons/local/cast')['default']
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: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']
@@ -111,11 +122,17 @@ declare module 'vue' {
IconMdiChevronDoubleDown: typeof import('~icons/mdi/chevron-double-down')['default']
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']
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
IconMdiFilterVariant: typeof import('~icons/mdi/filter-variant')['default']
IconMdiFolderOpen: typeof import('~icons/mdi/folder-open')['default']
IconMdiFolderOutline: typeof import('~icons/mdi/folder-outline')['default']
IconMdiFolderPlusOutline: typeof import('~icons/mdi/folder-plus-outline')['default']
IconMdiKeyboardEsc: typeof import('~icons/mdi/keyboard-esc')['default']
IconMdiKeyboardReturn: typeof import('~icons/mdi/keyboard-return')['default']
IconMdiMenuDown: typeof import('~icons/mdi/menu-down')['default']
IconMdiPencilOutline: typeof import('~icons/mdi/pencil-outline')['default']
IconMdiPlus: typeof import('~icons/mdi/plus')['default']
IconMdiRefresh: typeof import('~icons/mdi/refresh')['default']
@@ -126,6 +143,8 @@ declare module 'vue' {
LookForward: typeof import('./../components/custom/look-forward.vue')['default']
MenuToggler: typeof import('./../components/common/menu-toggler.vue')['default']
PinToggler: typeof import('./../components/common/pin-toggler.vue')['default']
PrioritySelect: typeof import('../views/product/requirement/modules/priority-select.vue')['default']
ReadonlyField: typeof import('./../components/custom/readonly-field.vue')['default']
ReloadButton: typeof import('./../components/common/reload-button.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']

View File

@@ -60,6 +60,11 @@ declare module "@elegant-router/types" {
"plugin_tables_vtable": "/plugin/tables/vtable";
"plugin_typeit": "/plugin/typeit";
"plugin_video": "/plugin/video";
"product": "/product";
"product_dashboard": "/product/dashboard";
"product_list": "/product/list";
"product_requirement": "/product/requirement";
"product_setting": "/product/setting";
"system": "/system";
"system_dict": "/system/dict";
"system_menu": "/system/menu";
@@ -67,6 +72,7 @@ declare module "@elegant-router/types" {
"system_role": "/system/role";
"system_user": "/system/user";
"system_user-detail": "/system/user-detail/:id";
"system_user-management-relation": "/system/user-management-relation";
"user-center": "/user-center";
};
@@ -110,6 +116,7 @@ declare module "@elegant-router/types" {
| "iframe-page"
| "login"
| "plugin"
| "product"
| "system"
| "user-center"
>;
@@ -161,11 +168,16 @@ declare module "@elegant-router/types" {
| "plugin_tables_vtable"
| "plugin_typeit"
| "plugin_video"
| "product_dashboard"
| "product_list"
| "product_requirement"
| "product_setting"
| "system_dict"
| "system_menu"
| "system_post"
| "system_role"
| "system_user-detail"
| "system_user-management-relation"
| "system_user"
| "user-center"
>;

57
src/typings/object-context.d.ts vendored Normal file
View File

@@ -0,0 +1,57 @@
declare namespace App {
namespace ObjectContext {
type DomainKey = 'project' | 'product' | string;
type ObjectType = 'project' | 'product' | string;
type Menu = {
/** 对象上下文菜单 key优先约定为目标路由 name */
key: string;
/** 菜单文案 */
label: string;
/** 路由 name可为空 */
routeKey?: string | null;
/** 路由 path可为空 */
routePath?: string | null;
/** 子菜单 */
children?: Menu[];
};
interface DomainConfig {
domainKey: DomainKey;
mode: 'object-context';
objectType: ObjectType;
/** 用于识别当前路由是否属于该业务域 */
routePathPrefixes: string[];
/** 业务域入口页 */
entryRouteKey: string;
entryRoutePath: string;
/** 对象默认首页兜底值 */
fallbackDefaultRouteKey: string;
fallbackDefaultRoutePath: string;
/** 上下文接口 */
contextApiPath: string;
contextApiObjectIdParamKey: string;
contextApiObjectIdPlacement?: 'query' | 'path';
/** 第一版固定为 objectId */
objectIdQueryKey: 'objectId';
}
interface Summary {
[key: string]: unknown;
}
interface State {
domainKey: DomainKey;
objectType: ObjectType;
objectId: string;
objectName: string;
objectSummary: Summary | null;
contextScopedMenus: Menu[];
buttonCodes: string[];
defaultRouteKey: string;
defaultRoutePath: string;
isReady: boolean;
}
}
}

View File

@@ -0,0 +1,390 @@
import dayjs from 'dayjs';
const productStatusLabelMap = {
active: '启用',
paused: '暂停',
archived: '归档',
abandoned: '废弃'
} as const satisfies Record<Api.Product.ProductStatusCode, string>;
export interface ProductHomepageMetric {
label: string;
value: string;
hint: string;
}
export interface ProductHomepageFact {
label: string;
value: string;
}
export interface ProductHomepageBanner {
identity: {
name: string;
code: string;
directionCode: string;
statusCode: Api.Product.ProductStatusCode | null;
statusLabel: string;
managerLabel: string;
description: string;
facts: ProductHomepageFact[];
};
metrics: ProductHomepageMetric[];
}
export interface ProductHomepageBannerSource {
product: Api.Product.Product | null;
settings: Api.Product.ProductSettings | null;
members: readonly Api.Product.ProductMember[];
requirementSummary: ProductRequirementPoolSummary;
latestActivityTime?: string | null;
}
export interface ProductHomepageTimelineItem {
key: string;
tag: '对象' | '状态' | '团队';
title: string;
content: string;
time: string;
tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
}
export interface ProductRequirementPoolSummarySource {
total: number;
todo: number;
analyzing: number;
planned: number;
done: number;
highPriorityTodo: number;
}
export interface ProductRequirementPoolSummary {
metrics: ProductHomepageMetric[];
distribution: Array<{
label: string;
value: string;
}>;
total: number;
todo: number;
highPriorityTodo: number;
}
export interface ProductRequirementPoolRecentChangeSource {
id: string;
title: string;
actionLabel: string;
time: string;
statusLabel: string;
}
export interface ProductRequirementPoolRecentChange {
id: string;
title: string;
actionLabel: string;
time: string;
statusLabel: string;
}
export interface ProductHomepageExtensionModule {
key: 'milestone' | 'risk' | 'document';
title: string;
description: string;
items: string[];
}
function normalizeCount(value: number | null | undefined) {
if (!Number.isFinite(value)) {
return 0;
}
return Math.max(0, Number(value));
}
function getTimeValue(value: string | null | undefined) {
const parsed = dayjs(value);
return parsed.isValid() ? parsed.valueOf() : 0;
}
function formatDateTime(value: string | null | undefined) {
const parsed = dayjs(value);
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : '--';
}
function getStatusLabel(status: Api.Product.ProductStatusCode | null | undefined) {
if (!status) {
return '--';
}
return productStatusLabelMap[status] || '--';
}
function getActiveMembers(members: readonly Api.Product.ProductMember[]) {
return members.filter(item => item.status === 0);
}
function getManagerLabel(settings: Api.Product.ProductSettings | null, members: readonly Api.Product.ProductMember[]) {
return (
settings?.baseInfo.managerUserNickname ||
getActiveMembers(members).find(item => item.managerFlag)?.userNickname ||
'--'
);
}
function getRoleSummary(members: readonly Api.Product.ProductMember[]) {
const activeMembers = getActiveMembers(members);
if (!activeMembers.length) {
return '--';
}
const roleCounter = new Map<string, number>();
activeMembers.forEach(member => {
const roleName = member.roleName || '未命名角色';
roleCounter.set(roleName, (roleCounter.get(roleName) || 0) + 1);
});
return Array.from(roleCounter.entries())
.sort((left, right) => {
const leftWeight = left[0].includes('经理') ? 0 : 1;
const rightWeight = right[0].includes('经理') ? 0 : 1;
if (leftWeight !== rightWeight) {
return leftWeight - rightWeight;
}
return left[0].localeCompare(right[0], 'zh-CN');
})
.map(([roleName, count]) => `${roleName} ${count}`)
.join(' / ');
}
function resolveLatestTimelineTime(
product: Api.Product.Product | null,
settings: Api.Product.ProductSettings | null,
members: readonly Api.Product.ProductMember[]
) {
const timeValues = [
product?.createTime,
product?.updateTime,
settings?.lifecycle.lastStatusReason ? product?.updateTime : null,
...members.flatMap(member => [member.joinedTime, member.leftTime || null])
];
const latestValue = timeValues.reduce((latest, current) => {
return Math.max(latest, getTimeValue(current));
}, 0);
return latestValue ? dayjs(latestValue).format('YYYY-MM-DD HH:mm') : '--';
}
export function buildRequirementPoolSummary(
source: ProductRequirementPoolSummarySource | null | undefined
): ProductRequirementPoolSummary {
const total = normalizeCount(source?.total);
const todo = normalizeCount(source?.todo);
const analyzing = normalizeCount(source?.analyzing);
const planned = normalizeCount(source?.planned);
const done = normalizeCount(source?.done);
const highPriorityTodo = normalizeCount(source?.highPriorityTodo);
const distribution = [
{ label: '待处理', value: String(todo) },
{ label: '分析中', value: String(analyzing) },
{ label: '已规划', value: String(planned) },
{ label: '已完成', value: String(done) }
];
return {
metrics: [
{
label: '需求总量',
value: String(total),
hint: '当前需求池累计收录的需求数量'
},
{
label: '状态类型',
value: String(distribution.length),
hint: '首页当前重点展示的需求状态分层'
},
{
label: '待处理',
value: String(todo),
hint: '等待进入分析或分派的需求数量'
},
{
label: '高优先级待处理',
value: String(highPriorityTodo),
hint: '需要优先推进的待处理需求数量'
}
],
distribution,
total,
todo,
highPriorityTodo
};
}
export function buildRequirementPoolRecentChanges(
source: readonly ProductRequirementPoolRecentChangeSource[] | null | undefined
) {
return [...(source || [])]
.filter(item => getTimeValue(item.time) > 0)
.sort((left, right) => getTimeValue(right.time) - getTimeValue(left.time))
.map(item => ({
...item,
time: formatDateTime(item.time)
})) satisfies ProductRequirementPoolRecentChange[];
}
export function buildProductHomepageTimeline(
product: Api.Product.Product | null,
settings: Api.Product.ProductSettings | null,
members: readonly Api.Product.ProductMember[]
) {
const items: Array<Omit<ProductHomepageTimelineItem, 'time'> & { time: string | null | undefined }> = [];
if (product?.createTime) {
items.push({
key: `product-create-${product.id}`,
tag: '对象',
title: '创建产品',
content: `产品 ${product.name || product.code} 已创建并进入产品管理域。`,
time: product.createTime,
tone: 'sky'
});
}
const statusReason =
settings?.lifecycle.lastStatusReason || settings?.baseInfo.lastStatusReason || product?.lastStatusReason;
if (product?.updateTime && settings?.lifecycle.statusCode && statusReason) {
const statusCode = settings.lifecycle.statusCode;
const toneMap: Record<Api.Product.ProductStatusCode, ProductHomepageTimelineItem['tone']> = {
active: 'emerald',
paused: 'amber',
archived: 'slate',
abandoned: 'rose'
};
items.push({
key: `product-status-${product.id}-${product.updateTime}`,
tag: '状态',
title: `状态调整为${getStatusLabel(statusCode)}`,
content: statusReason,
time: product.updateTime,
tone: toneMap[statusCode]
});
}
members.forEach(member => {
if (member.joinedTime) {
items.push({
key: `member-join-${member.id}`,
tag: '团队',
title: '成员加入',
content: `${member.userNickname}${member.roleName}身份加入当前产品。`,
time: member.joinedTime,
tone: member.managerFlag ? 'emerald' : 'sky'
});
}
if (member.status === 1 && member.leftTime) {
items.push({
key: `member-leave-${member.id}`,
tag: '团队',
title: '成员移出',
content: `${member.userNickname} 已退出当前产品团队。`,
time: member.leftTime,
tone: 'rose'
});
}
});
return items
.filter(item => getTimeValue(item.time) > 0)
.sort((left, right) => getTimeValue(right.time) - getTimeValue(left.time))
.slice(0, 8)
.map(item => ({
...item,
time: formatDateTime(item.time)
})) satisfies ProductHomepageTimelineItem[];
}
function buildProductHomepageBannerIdentity(source: ProductHomepageBannerSource) {
const { product, settings, members } = source;
const managerLabel = getManagerLabel(settings, members);
const baseInfo = settings?.baseInfo;
const statusCode = resolveProductHomepageStatusCode(product, settings);
return {
name: product?.name || baseInfo?.name || '--',
code: product?.code || baseInfo?.code || '--',
directionCode: product?.directionCode || baseInfo?.directionCode || '',
statusCode,
statusLabel: getStatusLabel(statusCode),
managerLabel,
description: resolveProductHomepageDescription(product, settings),
facts: [
{ label: '产品经理', value: managerLabel },
{ label: '角色摘要', value: getRoleSummary(members) }
]
} satisfies ProductHomepageBanner['identity'];
}
function resolveProductHomepageStatusCode(
product: Api.Product.Product | null,
settings: Api.Product.ProductSettings | null
) {
return settings?.lifecycle.statusCode || product?.statusCode || null;
}
function resolveProductHomepageDescription(
product: Api.Product.Product | null,
settings: Api.Product.ProductSettings | null
) {
return product?.description?.trim() || settings?.baseInfo.description?.trim() || '';
}
function buildProductHomepageBannerMetrics(source: ProductHomepageBannerSource) {
const activeMembers = getActiveMembers(source.members);
const fallbackLatestTimelineTime = resolveLatestTimelineTime(source.product, source.settings, source.members);
const latestTimelineTime = source.latestActivityTime?.trim() || fallbackLatestTimelineTime || '--';
const { requirementSummary } = source;
return [
{
label: '团队人数',
value: String(activeMembers.length),
hint: '当前处于有效状态的团队成员数'
},
{
label: '需求总量',
value: String(requirementSummary.total),
hint: '需求池累计收录的需求数量'
},
{
label: '待处理需求',
value: String(requirementSummary.todo),
hint: '等待进入分析或分派的需求数量'
},
{
label: '最近动态时间',
value: latestTimelineTime,
hint: '对象或团队最近一次可确认的变动时间'
}
] satisfies ProductHomepageMetric[];
}
export function buildProductHomepageBanner(source: ProductHomepageBannerSource): ProductHomepageBanner {
return {
identity: buildProductHomepageBannerIdentity(source),
metrics: buildProductHomepageBannerMetrics(source)
};
}
export function getProductHomepageExtensionModules(modules: readonly ProductHomepageExtensionModule[]) {
return [...modules];
}

View File

@@ -0,0 +1,767 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { fetchGetProduct, fetchGetProductMembers, fetchGetProductSettings } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useCurrentProduct } from '../shared/use-current-product';
import ProductActivityTimelinePanel from './modules/product-activity-timeline-panel.vue';
import {
buildProductHomepageBanner,
buildRequirementPoolRecentChanges,
buildRequirementPoolSummary,
getProductHomepageExtensionModules
} from './homepage';
import { productHomepageExtensionMock, productRequirementPoolMock } from './mock';
defineOptions({ name: 'ProductDashboard' });
const { currentObjectId } = useCurrentProduct();
const { getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const pageLoading = ref(false);
const productDetail = ref<Api.Product.Product | null>(null);
const settings = ref<Api.Product.ProductSettings | null>(null);
const members = ref<Api.Product.ProductMember[]>([]);
const latestActivityTime = ref('');
const requirementPoolSummary = computed(() => buildRequirementPoolSummary(productRequirementPoolMock.summary));
const requirementPoolRecentChanges = computed(() =>
buildRequirementPoolRecentChanges(productRequirementPoolMock.recentChanges)
);
const homepageBanner = computed(() =>
buildProductHomepageBanner({
product: productDetail.value,
settings: settings.value,
members: members.value,
requirementSummary: requirementPoolSummary.value,
latestActivityTime: latestActivityTime.value
})
);
const extensionModules = computed(() => getProductHomepageExtensionModules(productHomepageExtensionMock));
const directionLabel = computed(() => getDirectionDictLabel(homepageBanner.value.identity.directionCode, '--'));
const bannerFacts = computed(() => {
const [managerFact, roleFact] = homepageBanner.value.identity.facts;
return [
{
label: '产品方向',
value: directionLabel.value,
fullWidth: false
},
{
label: managerFact?.label || '产品经理',
value: managerFact?.value || '--',
fullWidth: false
},
{
label: roleFact?.label || '角色摘要',
value: roleFact?.value || '--',
fullWidth: true
}
];
});
const bannerStatusClass = computed(() => {
const statusCode = homepageBanner.value.identity.statusCode;
return statusCode ? `product-homepage-banner--${statusCode}` : 'product-homepage-banner--default';
});
const bannerStatusWordClass = computed(() => {
const statusCode = homepageBanner.value.identity.statusCode;
return statusCode
? `product-homepage-banner__status-word--${statusCode}`
: 'product-homepage-banner__status-word--default';
});
function handleLatestActivityTimeChange(value: string) {
latestActivityTime.value = value;
}
async function loadDashboardData(objectId: string) {
pageLoading.value = true;
try {
const [productResult, settingsResult, membersResult] = await Promise.all([
fetchGetProduct(objectId),
fetchGetProductSettings(objectId),
fetchGetProductMembers(objectId)
]);
productDetail.value = productResult.error ? null : productResult.data || null;
settings.value = settingsResult.error ? null : settingsResult.data || null;
members.value = membersResult.error ? [] : membersResult.data || [];
} finally {
pageLoading.value = false;
}
}
watch(
() => currentObjectId.value,
async objectId => {
if (!objectId) {
productDetail.value = null;
settings.value = null;
members.value = [];
latestActivityTime.value = '';
return;
}
await loadDashboardData(objectId);
},
{ immediate: true }
);
</script>
<template>
<div v-loading="pageLoading" class="product-homepage">
<section class="product-homepage-banner" :class="bannerStatusClass">
<div class="product-homepage-banner__identity">
<div class="product-homepage-banner__title-group">
<div class="product-homepage-banner__title-main min-w-0">
<div class="product-homepage-banner__title-row">
<h1 class="product-homepage-banner__title">{{ homepageBanner.identity.name }}</h1>
<span class="product-homepage-banner__status-word" :class="bannerStatusWordClass">
{{ homepageBanner.identity.statusLabel }}
</span>
</div>
<div class="product-homepage-banner__subtitle">
<span class="product-homepage-banner__code">编号 {{ homepageBanner.identity.code }}</span>
<p v-if="homepageBanner.identity.description" class="product-homepage-banner__description">
{{ homepageBanner.identity.description }}
</p>
</div>
</div>
</div>
<div class="product-homepage-banner__facts">
<div
v-for="item in bannerFacts"
:key="item.label"
class="product-homepage-banner__fact"
:class="{ 'product-homepage-banner__fact--full': item.fullWidth }"
>
<span class="product-homepage-banner__fact-label">{{ item.label }}</span>
<strong class="product-homepage-banner__fact-value">{{ item.value }}</strong>
</div>
</div>
</div>
<div class="product-homepage-banner__metrics">
<article v-for="item in homepageBanner.metrics" :key="item.label" class="product-homepage-banner__metric">
<span class="product-homepage-banner__metric-label">{{ item.label }}</span>
<strong class="product-homepage-banner__metric-value">{{ item.value }}</strong>
</article>
</div>
</section>
<section class="product-homepage-main">
<ProductActivityTimelinePanel
:product-id="currentObjectId || ''"
@latest-time-change="handleLatestActivityTimeChange"
/>
<div class="product-homepage-main__aside">
<ElCard class="product-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="product-homepage-panel__title">需求池管理概览</h3>
<p class="product-homepage-panel__desc">先看需求池现在的总体规模状态结构和待处理压力</p>
</div>
</template>
<div class="product-homepage-requirement-summary">
<div class="product-homepage-requirement-summary__metrics">
<article
v-for="item in requirementPoolSummary.metrics"
:key="item.label"
class="product-homepage-requirement-summary__metric"
>
<span class="product-homepage-requirement-summary__metric-label">{{ item.label }}</span>
<strong class="product-homepage-requirement-summary__metric-value">{{ item.value }}</strong>
</article>
</div>
<div class="product-homepage-requirement-summary__distribution">
<div
v-for="item in requirementPoolSummary.distribution"
:key="item.label"
class="product-homepage-requirement-summary__distribution-item"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
</div>
</ElCard>
<ElCard class="product-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="product-homepage-panel__title">需求池最近变化</h3>
<p class="product-homepage-panel__desc">承接需求新增状态流转和关闭情况和产品动态时间线分开表达</p>
</div>
</template>
<div v-if="requirementPoolRecentChanges.length" class="product-homepage-requirement-changes">
<article
v-for="item in requirementPoolRecentChanges"
:key="item.id"
class="product-homepage-requirement-changes__item"
>
<div class="product-homepage-requirement-changes__meta">
<ElTag type="info" effect="plain" size="small">{{ item.actionLabel }}</ElTag>
<span class="product-homepage-requirement-changes__time">{{ item.time }}</span>
</div>
<strong class="product-homepage-requirement-changes__title">{{ item.title }}</strong>
<p class="product-homepage-requirement-changes__status">当前状态{{ item.statusLabel }}</p>
</article>
</div>
<ElEmpty v-else description="当前暂无需求池最近变化" :image-size="72" />
</ElCard>
</div>
</section>
<section class="product-homepage-extension">
<ElCard v-for="module in extensionModules" :key="module.key" class="product-homepage-panel card-wrapper">
<template #header>
<div>
<h3 class="product-homepage-panel__title">{{ module.title }}</h3>
<p class="product-homepage-panel__desc">{{ module.description }}</p>
</div>
</template>
<div class="product-homepage-extension__list">
<div v-for="item in module.items" :key="item" class="product-homepage-extension__item">
<span class="product-homepage-extension__dot" />
<span>{{ item }}</span>
</div>
</div>
</ElCard>
</section>
</div>
</template>
<style scoped>
.product-homepage {
display: flex;
flex-direction: column;
gap: 16px;
}
.product-homepage-banner {
display: grid;
grid-template-columns: minmax(0, 1.55fr) minmax(320px, 1fr);
gap: 16px;
padding: 24px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 24px;
background:
radial-gradient(circle at top left, rgb(14 116 144 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(15 118 110 / 10%), transparent 28%),
linear-gradient(135deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 98%));
}
.product-homepage-banner--default {
border-color: rgb(226 232 240 / 92%);
background:
radial-gradient(circle at top left, rgb(14 116 144 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(15 118 110 / 10%), transparent 28%),
linear-gradient(135deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 98%));
}
.product-homepage-banner--active {
border-color: rgb(167 243 208 / 88%);
background:
radial-gradient(circle at top left, rgb(5 150 105 / 16%), transparent 32%),
radial-gradient(circle at bottom right, rgb(16 185 129 / 14%), transparent 26%),
linear-gradient(135deg, rgb(236 253 245 / 99%), rgb(255 255 255 / 98%));
}
.product-homepage-banner--paused {
border-color: rgb(253 230 138 / 90%);
background:
radial-gradient(circle at top left, rgb(245 158 11 / 18%), transparent 32%),
radial-gradient(circle at bottom right, rgb(251 191 36 / 16%), transparent 24%),
linear-gradient(135deg, rgb(255 251 235 / 99%), rgb(255 255 255 / 98%));
}
.product-homepage-banner--archived {
border-color: rgb(203 213 225 / 92%);
background:
radial-gradient(circle at top left, rgb(100 116 139 / 14%), transparent 34%),
radial-gradient(circle at bottom right, rgb(148 163 184 / 10%), transparent 26%),
linear-gradient(135deg, rgb(248 250 252 / 99%), rgb(255 255 255 / 98%));
}
.product-homepage-banner--abandoned {
border-color: rgb(254 205 211 / 92%);
background:
radial-gradient(circle at top left, rgb(244 63 94 / 16%), transparent 32%),
radial-gradient(circle at bottom right, rgb(251 113 133 / 14%), transparent 24%),
linear-gradient(135deg, rgb(255 241 242 / 99%), rgb(255 255 255 / 98%));
}
.product-homepage-banner__identity {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.product-homepage-banner__title-group {
display: flex;
align-items: flex-start;
gap: 12px;
}
.product-homepage-banner__title-main {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 12px;
}
.product-homepage-banner__title-row {
display: flex;
min-width: 0;
align-items: baseline;
gap: 14px;
}
.product-homepage-banner__code {
margin: 0;
color: rgb(14 116 144 / 92%);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.product-homepage-banner__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 34px;
line-height: 1.15;
letter-spacing: -0.03em;
}
.product-homepage-banner__subtitle {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: baseline;
gap: 10px 14px;
}
.product-homepage-banner__description {
margin: 0;
min-width: 0;
color: rgb(71 85 105 / 94%);
font-size: 14px;
line-height: 1.8;
}
.product-homepage-banner__status-word {
flex-shrink: 0;
font-size: 26px;
font-weight: 800;
line-height: 1;
letter-spacing: 0.18em;
text-transform: uppercase;
user-select: none;
}
.product-homepage-banner__status-word--default {
color: rgb(148 163 184 / 48%);
}
.product-homepage-banner__status-word--active {
color: transparent;
background: linear-gradient(180deg, rgb(5 150 105 / 94%), rgb(16 185 129 / 70%));
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 10px 24px rgb(5 150 105 / 16%);
}
.product-homepage-banner__status-word--paused {
color: transparent;
background: linear-gradient(180deg, rgb(217 119 6 / 94%), rgb(245 158 11 / 70%));
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 10px 24px rgb(245 158 11 / 16%);
}
.product-homepage-banner__status-word--archived {
color: transparent;
background: linear-gradient(180deg, rgb(71 85 105 / 92%), rgb(148 163 184 / 64%));
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 10px 24px rgb(100 116 139 / 14%);
}
.product-homepage-banner__status-word--abandoned {
color: transparent;
background: linear-gradient(180deg, rgb(225 29 72 / 94%), rgb(251 113 133 / 68%));
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 10px 24px rgb(225 29 72 / 16%);
}
.product-homepage-banner__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.product-homepage-banner__fact {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 58px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 18px;
background-color: rgb(255 255 255 / 78%);
}
.product-homepage-banner__fact--full {
grid-column: 1 / -1;
align-items: flex-start;
}
.product-homepage-banner__fact-label {
color: rgb(100 116 139 / 94%);
font-size: 13px;
white-space: nowrap;
}
.product-homepage-banner__fact-value {
color: rgb(15 23 42 / 96%);
font-size: 15px;
line-height: 1.6;
text-align: right;
}
.product-homepage-banner__fact--full .product-homepage-banner__fact-value {
max-width: 72%;
text-align: left;
}
.product-homepage-banner__metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.product-homepage-banner__metric {
display: flex;
min-height: 112px;
flex-direction: column;
justify-content: center;
gap: 16px;
padding: 18px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 20px;
background: linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(241 245 249 / 98%));
}
.product-homepage-banner__metric-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.product-homepage-banner__metric-value {
color: rgb(15 23 42 / 98%);
font-size: 28px;
line-height: 1.1;
letter-spacing: -0.02em;
word-break: break-word;
}
.product-homepage-main {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.product-homepage-main__aside {
display: flex;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.product-homepage-panel {
overflow: hidden;
}
.product-homepage-panel__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
}
.product-homepage-panel__desc {
margin: 4px 0 0;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.7;
}
.product-homepage-timeline {
display: flex;
flex-direction: column;
gap: 10px;
}
.product-homepage-timeline__item {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 12px;
}
.product-homepage-timeline__rail {
display: flex;
flex-direction: column;
align-items: center;
}
.product-homepage-timeline__dot {
width: 12px;
height: 12px;
border-radius: 999px;
margin-top: 6px;
box-shadow: 0 0 0 4px rgb(255 255 255 / 96%);
}
.product-homepage-timeline__dot--sky {
background-color: rgb(14 165 233 / 88%);
}
.product-homepage-timeline__dot--emerald {
background-color: rgb(5 150 105 / 88%);
}
.product-homepage-timeline__dot--amber {
background-color: rgb(217 119 6 / 88%);
}
.product-homepage-timeline__dot--rose {
background-color: rgb(225 29 72 / 88%);
}
.product-homepage-timeline__dot--slate {
background-color: rgb(100 116 139 / 88%);
}
.product-homepage-timeline__line {
flex: 1;
width: 2px;
min-height: 30px;
margin-top: 4px;
background: linear-gradient(180deg, rgb(203 213 225 / 96%), rgb(226 232 240 / 28%));
}
.product-homepage-timeline__item:last-child .product-homepage-timeline__line {
opacity: 0;
}
.product-homepage-timeline__content {
padding: 12px 14px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 16px;
background-color: rgb(255 255 255 / 98%);
}
.product-homepage-timeline__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.product-homepage-timeline__time {
color: rgb(100 116 139 / 90%);
font-size: 12px;
}
.product-homepage-timeline__sentence {
margin: 6px 0 0;
color: rgb(71 85 105 / 94%);
font-size: 13px;
line-height: 1.65;
}
.product-homepage-timeline__headline {
margin-right: 6px;
color: rgb(15 23 42 / 98%);
font-weight: 600;
}
.product-homepage-requirement-summary {
display: flex;
flex-direction: column;
gap: 16px;
}
.product-homepage-requirement-summary__metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.product-homepage-requirement-summary__metric {
display: flex;
min-height: 100px;
flex-direction: column;
justify-content: center;
gap: 14px;
padding: 14px 16px;
border-radius: 18px;
background: linear-gradient(180deg, rgb(248 250 252 / 98%), rgb(241 245 249 / 94%));
}
.product-homepage-requirement-summary__metric-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.product-homepage-requirement-summary__metric-value {
color: rgb(15 23 42 / 98%);
font-size: 24px;
line-height: 1.1;
}
.product-homepage-requirement-summary__distribution {
display: flex;
flex-direction: column;
gap: 10px;
}
.product-homepage-requirement-summary__distribution-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 14px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 14px;
background-color: rgb(255 255 255 / 96%);
color: rgb(51 65 85 / 95%);
font-size: 14px;
}
.product-homepage-requirement-summary__distribution-item strong {
color: rgb(15 23 42 / 98%);
font-size: 18px;
}
.product-homepage-requirement-changes {
display: flex;
flex-direction: column;
gap: 12px;
}
.product-homepage-requirement-changes__item {
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 90%);
border-radius: 18px;
background-color: rgb(255 255 255 / 98%);
}
.product-homepage-requirement-changes__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.product-homepage-requirement-changes__time {
color: rgb(100 116 139 / 90%);
font-size: 12px;
}
.product-homepage-requirement-changes__title {
display: block;
margin-top: 10px;
color: rgb(15 23 42 / 98%);
font-size: 15px;
line-height: 1.65;
}
.product-homepage-requirement-changes__status {
margin: 8px 0 0;
color: rgb(71 85 105 / 94%);
font-size: 13px;
line-height: 1.7;
}
.product-homepage-extension {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.product-homepage-extension__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.product-homepage-extension__item {
display: flex;
align-items: center;
gap: 10px;
padding: 13px 14px;
border-radius: 16px;
background-color: rgb(248 250 252 / 96%);
color: rgb(51 65 85 / 95%);
font-size: 14px;
line-height: 1.7;
}
.product-homepage-extension__dot {
width: 8px;
height: 8px;
border-radius: 999px;
background-color: rgb(14 116 144 / 88%);
flex-shrink: 0;
}
@media (width <= 1280px) {
.product-homepage-banner,
.product-homepage-main,
.product-homepage-extension {
grid-template-columns: 1fr;
}
}
@media (width <= 768px) {
.product-homepage-banner {
padding: 18px;
}
.product-homepage-banner__title-row {
flex-wrap: wrap;
}
.product-homepage-banner__title {
font-size: 28px;
}
.product-homepage-banner__status-word {
font-size: 22px;
}
.product-homepage-banner__facts,
.product-homepage-banner__metrics,
.product-homepage-requirement-summary__metrics {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,60 @@
import type {
ProductHomepageExtensionModule,
ProductRequirementPoolRecentChangeSource,
ProductRequirementPoolSummarySource
} from './homepage';
export const productRequirementPoolMock = {
summary: {
total: 18,
todo: 3,
analyzing: 5,
planned: 6,
done: 4,
highPriorityTodo: 2
} satisfies ProductRequirementPoolSummarySource,
recentChanges: [
{
id: 'req-1001',
title: '支持产品资料标签归档',
actionLabel: '新增需求',
time: '2026-04-22 16:20:00',
statusLabel: '待处理'
},
{
id: 'req-1002',
title: '统一需求池状态颜色',
actionLabel: '状态流转',
time: '2026-04-23 11:00:00',
statusLabel: '分析中'
},
{
id: 'req-1003',
title: '补充对象首页需求池统计接口',
actionLabel: '关闭需求',
time: '2026-04-23 14:30:00',
statusLabel: '已完成'
}
] satisfies ProductRequirementPoolRecentChangeSource[]
};
export const productHomepageExtensionMock = [
{
key: 'milestone',
title: '里程碑',
description: '当前先承接产品对象下的版本节点与阶段目标,后续接真实里程碑聚合接口。',
items: ['对象首页改版验收', '需求池统计接口接入', '产品资料结构梳理']
},
{
key: 'risk',
title: '风险点管理',
description: '预留给跨需求、跨团队的产品级风险摘要,避免把风险信息挤进时间线。',
items: ['需求池真实接口尚未接入', '对象首页长期指标来源待统一', '团队调整记录缺少专用日志接口']
},
{
key: 'document',
title: '产品资料',
description: '用于承接产品说明、制度文档、对外资料等对象档案信息,当前先保留正式结构位。',
items: ['产品定位说明', '对象上下文使用说明', '需求池维护约定']
}
] satisfies ProductHomepageExtensionModule[];

View File

@@ -0,0 +1,528 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { fetchGetProductActivityTimelinePage } from '@/service/api';
import BusinessDateRangePicker from '@/components/custom/business-date-range-picker.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import {
DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE,
PRODUCT_ACTIVITY_TIME_SHORTCUTS,
PRODUCT_ACTIVITY_TYPE_OPTIONS,
type ProductActivityFilterType,
buildProductActivityDisplayItems,
buildProductActivityRange
} from '../product-activity';
defineOptions({ name: 'ProductActivityTimelineDialog' });
interface Props {
productId: string;
}
const props = defineProps<Props>();
const visible = defineModel<boolean>('visible', {
default: false
});
const loading = ref(false);
const loadError = ref(false);
const items = ref<ReturnType<typeof buildProductActivityDisplayItems>>([]);
const pagination = reactive({
pageNo: 1,
pageSize: DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE,
total: 0
});
const filters = reactive<{
activityType: ProductActivityFilterType;
timeRange: [string, string];
}>({
activityType: 'all',
timeRange: buildProductActivityRange(30)
});
const timeRangeShortcuts = PRODUCT_ACTIVITY_TIME_SHORTCUTS.map(shortcut => ({
label: shortcut.label,
value: () => {
const end = dayjs();
const start = dayjs()
.subtract(Math.max(shortcut.days - 1, 0), 'day')
.startOf('day');
return [start.format('YYYY-MM-DD'), end.format('YYYY-MM-DD')] as [string, string];
}
}));
function buildDraftDateRange(timeRange: [string, string]) {
const [startTime, endTime] = timeRange;
return [dayjs(startTime).format('YYYY-MM-DD'), dayjs(endTime).format('YYYY-MM-DD')] as [string, string];
}
function buildApiTimeRange(dateRange: [string, string]): [string, string] {
const [startDate, endDate] = dateRange;
return [
dayjs(startDate).startOf('day').format('YYYY-MM-DD HH:mm:ss'),
dayjs(endDate).endOf('day').format('YYYY-MM-DD HH:mm:ss')
];
}
function buildQueryParams(): Api.Product.ProductActivityTimelinePageParams {
const [startTime, endTime] = filters.timeRange;
return {
pageNo: pagination.pageNo,
pageSize: pagination.pageSize,
activityType: filters.activityType === 'all' ? null : filters.activityType,
startTime,
endTime
};
}
function resetFilters() {
filters.activityType = 'all';
filters.timeRange = buildProductActivityRange(30);
pagination.pageNo = 1;
pagination.pageSize = DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE;
}
async function loadActivities() {
if (!visible.value || !props.productId) {
items.value = [];
pagination.total = 0;
loadError.value = false;
return;
}
loading.value = true;
loadError.value = false;
try {
const result = await fetchGetProductActivityTimelinePage(props.productId, buildQueryParams());
if (result.error || !result.data) {
items.value = [];
pagination.total = 0;
loadError.value = true;
return;
}
items.value = buildProductActivityDisplayItems(result.data.list);
pagination.total = result.data.total;
} finally {
loading.value = false;
}
}
function reloadActivities() {
loadActivities().catch(() => {
loadError.value = true;
});
}
function handleQuery() {
pagination.pageNo = 1;
reloadActivities();
}
function handleReset() {
resetFilters();
reloadActivities();
}
function handlePageChange(pageNo: number) {
pagination.pageNo = pageNo;
reloadActivities();
}
function handleDateRangeChange(dateRange: [string, string]) {
filters.timeRange = buildApiTimeRange(dateRange);
pagination.pageNo = 1;
reloadActivities();
}
watch(
() => filters.activityType,
() => {
if (!visible.value) {
return;
}
pagination.pageNo = 1;
reloadActivities();
}
);
watch([() => visible.value, () => props.productId], ([currentVisible, productId]) => {
if (!currentVisible) {
return;
}
if (!productId) {
items.value = [];
pagination.total = 0;
loadError.value = false;
return;
}
reloadActivities();
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
class="product-activity-dialog"
title="产品动态时间线"
width="1100px"
:scrollbar="false"
>
<div class="product-activity-dialog__layout">
<aside class="product-activity-dialog__filters">
<section class="product-activity-dialog__section">
<div class="product-activity-dialog__section-header">
<h4>分类</h4>
</div>
<ElRadioGroup v-model="filters.activityType" class="product-activity-dialog__radio-group">
<ElRadioButton
v-for="option in PRODUCT_ACTIVITY_TYPE_OPTIONS"
:key="option.value"
:label="option.value"
:value="option.value"
>
{{ option.label }}
</ElRadioButton>
</ElRadioGroup>
</section>
<section class="product-activity-dialog__section">
<div class="product-activity-dialog__section-header">
<h4>时间范围</h4>
</div>
<BusinessDateRangePicker
:model-value="buildDraftDateRange(filters.timeRange)"
:shortcuts="timeRangeShortcuts"
placeholder="请选择时间范围"
@update:model-value="handleDateRangeChange"
/>
</section>
<div class="product-activity-dialog__actions">
<ElButton @click="handleReset">重置</ElButton>
<ElButton type="primary" @click="handleQuery">查询</ElButton>
</div>
</aside>
<section class="product-activity-dialog__result">
<div class="product-activity-dialog__result-header">
<h4>查询结果</h4>
<span v-if="pagination.total" class="product-activity-dialog__result-total">
{{ pagination.total }}
</span>
</div>
<div v-loading="loading" class="product-activity-dialog__result-body">
<div v-if="loadError" class="product-activity-dialog__state">
<ElEmpty description="产品动态加载失败" :image-size="88" />
<ElButton type="primary" plain @click="loadActivities">重新加载</ElButton>
</div>
<ElScrollbar v-else-if="items.length" class="product-activity-dialog__scrollbar">
<div class="product-activity-dialog__timeline">
<article v-for="item in items" :key="item.id" class="product-activity-dialog__item">
<div class="product-activity-dialog__rail">
<span class="product-activity-dialog__dot" :class="`product-activity-dialog__dot--${item.tone}`" />
<span class="product-activity-dialog__line" />
</div>
<div class="product-activity-dialog__content">
<div class="product-activity-dialog__meta">
<div class="product-activity-dialog__meta-main">
<ElTag effect="plain" size="small">{{ item.tagLabel }}</ElTag>
</div>
<span class="product-activity-dialog__time">{{ item.timeText }}</span>
</div>
<p class="product-activity-dialog__sentence">
<span class="product-activity-dialog__sentence-main">{{ item.compactText }}</span>
<span v-if="item.statusTransition">状态{{ item.statusTransition }}</span>
<span v-if="item.reasonText">原因{{ item.reasonText }}</span>
</p>
</div>
</article>
</div>
</ElScrollbar>
<ElEmpty v-else description="当前筛选条件下暂无产品动态" :image-size="88" />
</div>
</section>
</div>
<template #footer="{ close }">
<div class="product-activity-dialog__footer-inner">
<div class="product-activity-dialog__footer-pagination">
<ElPagination
v-if="pagination.total"
layout="total,prev,pager,next"
:current-page="pagination.pageNo"
:page-size="pagination.pageSize"
:total="pagination.total"
@current-change="handlePageChange"
/>
</div>
<ElButton @click="close">关闭</ElButton>
</div>
</template>
</BusinessFormDialog>
</template>
<style scoped>
.product-activity-dialog {
:deep(.el-dialog) {
max-width: calc(100vw - 32px);
}
}
.product-activity-dialog__layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 16px;
height: 640px;
}
.product-activity-dialog__filters,
.product-activity-dialog__result {
min-width: 0;
border: 1px solid var(--el-border-color-light);
border-radius: 18px;
background-color: var(--el-fill-color-lighter);
}
.product-activity-dialog__filters {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
padding: 16px;
overflow-y: auto;
}
.product-activity-dialog__section {
display: flex;
flex-direction: column;
gap: 12px;
}
.product-activity-dialog__section-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.product-activity-dialog__section-header h4,
.product-activity-dialog__result-header h4 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 14px;
font-weight: 700;
}
.product-activity-dialog__section-header span,
.product-activity-dialog__result-total {
margin: 0;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.6;
}
.product-activity-dialog__radio-group {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.product-activity-dialog__radio-group :deep(.el-radio-button__inner) {
width: 100%;
}
.product-activity-dialog__actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: auto;
}
.product-activity-dialog__time-range-input {
width: 100%;
}
.product-activity-dialog__result {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
padding: 16px;
overflow: hidden;
background: linear-gradient(180deg, var(--el-bg-color), var(--el-fill-color-lighter));
}
.product-activity-dialog__result-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.product-activity-dialog__result-body {
flex: 1;
min-height: 0;
overflow: hidden;
}
.product-activity-dialog__state {
display: flex;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
}
.product-activity-dialog__scrollbar {
height: 100%;
}
.product-activity-dialog__scrollbar :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.product-activity-dialog__timeline {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 100%;
}
.product-activity-dialog__item {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 12px;
}
.product-activity-dialog__rail {
display: flex;
flex-direction: column;
align-items: center;
}
.product-activity-dialog__dot {
width: 12px;
height: 12px;
margin-top: 6px;
border-radius: 999px;
box-shadow: 0 0 0 4px var(--el-bg-color);
}
.product-activity-dialog__dot--sky {
background-color: rgb(14 165 233 / 88%);
}
.product-activity-dialog__dot--emerald {
background-color: rgb(5 150 105 / 88%);
}
.product-activity-dialog__dot--amber {
background-color: rgb(217 119 6 / 88%);
}
.product-activity-dialog__dot--rose {
background-color: rgb(225 29 72 / 88%);
}
.product-activity-dialog__dot--slate {
background-color: rgb(100 116 139 / 88%);
}
.product-activity-dialog__line {
flex: 1;
width: 2px;
min-height: 24px;
margin-top: 4px;
background: linear-gradient(180deg, var(--el-border-color), transparent);
}
.product-activity-dialog__item:last-child .product-activity-dialog__line {
opacity: 0;
}
.product-activity-dialog__content {
padding: 10px 12px;
border: 1px solid var(--el-border-color-light);
border-radius: 16px;
background-color: var(--el-bg-color);
}
.product-activity-dialog__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.product-activity-dialog__meta-main {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.product-activity-dialog__time {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.product-activity-dialog__sentence {
margin: 6px 0 0;
color: var(--el-text-color-regular);
font-size: 14px;
line-height: 1.6;
}
.product-activity-dialog__sentence-main {
color: var(--el-text-color-primary);
}
.product-activity-dialog__footer-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.product-activity-dialog__footer-pagination {
min-width: 0;
}
@media (width <= 960px) {
.product-activity-dialog__layout {
grid-template-columns: 1fr;
height: 640px;
}
.product-activity-dialog__result-header {
flex-direction: column;
align-items: stretch;
}
.product-activity-dialog__footer-inner {
flex-direction: column;
align-items: stretch;
}
}
</style>

View File

@@ -0,0 +1,275 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { fetchGetProductActivityTimelinePage } from '@/service/api';
import {
DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE,
buildProductActivityDisplayItems,
buildProductActivityRange,
formatProductActivityTime
} from '../product-activity';
import ProductActivityTimelineDialog from './product-activity-timeline-dialog.vue';
defineOptions({ name: 'ProductActivityTimelinePanel' });
interface Props {
productId: string;
}
interface Emits {
(e: 'latest-time-change', value: string): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const dialogVisible = ref(false);
const loading = ref(false);
const loadError = ref(false);
const items = ref<ReturnType<typeof buildProductActivityDisplayItems>>([]);
async function loadRecentActivities() {
if (!props.productId) {
items.value = [];
loadError.value = false;
emit('latest-time-change', '');
return;
}
loading.value = true;
loadError.value = false;
try {
const [startTime, endTime] = buildProductActivityRange(30);
const result = await fetchGetProductActivityTimelinePage(props.productId, {
pageNo: 1,
pageSize: DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE,
startTime,
endTime
});
if (result.error || !result.data) {
loadError.value = true;
items.value = [];
emit('latest-time-change', '');
return;
}
items.value = buildProductActivityDisplayItems(result.data.list);
emit('latest-time-change', formatProductActivityTime(result.data.list[0]?.occurredAt) || '');
} finally {
loading.value = false;
}
}
function openDialog() {
if (!props.productId) {
return;
}
dialogVisible.value = true;
}
watch(
() => props.productId,
async () => {
await loadRecentActivities();
},
{ immediate: true }
);
</script>
<template>
<ElCard class="product-activity-panel card-wrapper">
<template #header>
<div class="product-activity-panel__header">
<div>
<h3 class="product-activity-panel__title">产品动态时间线</h3>
</div>
<ElButton text type="primary" :disabled="!productId" @click="openDialog">更多</ElButton>
</div>
</template>
<div v-loading="loading" class="product-activity-panel__body">
<div v-if="loadError" class="product-activity-panel__state">
<ElEmpty description="产品动态加载失败" :image-size="88" />
<ElButton type="primary" plain @click="loadRecentActivities">重新加载</ElButton>
</div>
<div v-else-if="items.length" class="product-activity-panel__timeline">
<article v-for="item in items" :key="item.id" class="product-activity-panel__item">
<div class="product-activity-panel__rail">
<span class="product-activity-panel__dot" :class="`product-activity-panel__dot--${item.tone}`" />
<span class="product-activity-panel__line" />
</div>
<div class="product-activity-panel__content">
<div class="product-activity-panel__meta">
<div class="product-activity-panel__meta-main">
<ElTag effect="plain" size="small">{{ item.tagLabel }}</ElTag>
</div>
<span class="product-activity-panel__time">{{ item.timeText }}</span>
</div>
<p class="product-activity-panel__sentence">
<span class="product-activity-panel__sentence-main">{{ item.compactText }}</span>
<span v-if="item.statusTransition">状态{{ item.statusTransition }}</span>
<span v-if="item.reasonText">原因{{ item.reasonText }}</span>
</p>
</div>
</article>
</div>
<ElEmpty v-else description="当前最近30天暂无可展示的产品动态" :image-size="88" />
</div>
</ElCard>
<ProductActivityTimelineDialog v-model:visible="dialogVisible" :product-id="productId" />
</template>
<style scoped>
.product-activity-panel {
overflow: hidden;
}
.product-activity-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.product-activity-panel__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
}
.product-activity-panel__desc {
margin: 4px 0 0;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.7;
}
.product-activity-panel__body {
min-height: 520px;
}
.product-activity-panel__state {
display: flex;
min-height: 420px;
flex-direction: column;
align-items: center;
justify-content: center;
}
.product-activity-panel__timeline {
display: flex;
flex-direction: column;
gap: 10px;
}
.product-activity-panel__item {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 12px;
}
.product-activity-panel__rail {
display: flex;
flex-direction: column;
align-items: center;
}
.product-activity-panel__dot {
width: 12px;
height: 12px;
margin-top: 6px;
border-radius: 999px;
box-shadow: 0 0 0 4px rgb(255 255 255 / 96%);
}
.product-activity-panel__dot--sky {
background-color: rgb(14 165 233 / 88%);
}
.product-activity-panel__dot--emerald {
background-color: rgb(5 150 105 / 88%);
}
.product-activity-panel__dot--amber {
background-color: rgb(217 119 6 / 88%);
}
.product-activity-panel__dot--rose {
background-color: rgb(225 29 72 / 88%);
}
.product-activity-panel__dot--slate {
background-color: rgb(100 116 139 / 88%);
}
.product-activity-panel__line {
flex: 1;
width: 2px;
min-height: 24px;
margin-top: 4px;
background: linear-gradient(180deg, rgb(203 213 225 / 96%), rgb(226 232 240 / 28%));
}
.product-activity-panel__item:last-child .product-activity-panel__line {
opacity: 0;
}
.product-activity-panel__content {
padding: 10px 12px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 16px;
background-color: rgb(255 255 255 / 98%);
}
.product-activity-panel__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.product-activity-panel__meta-main {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.product-activity-panel__time {
color: rgb(100 116 139 / 90%);
font-size: 12px;
}
.product-activity-panel__sentence {
margin: 6px 0 0;
color: rgb(71 85 105 / 94%);
font-size: 14px;
line-height: 1.6;
}
.product-activity-panel__sentence-main {
color: rgb(15 23 42 / 98%);
}
@media (width <= 768px) {
.product-activity-panel__body {
min-height: auto;
}
.product-activity-panel__header {
flex-direction: column;
align-items: stretch;
}
}
</style>

View File

@@ -0,0 +1,367 @@
import dayjs from 'dayjs';
const productStatusLabelMap = {
active: '启用',
paused: '暂停',
archived: '归档',
abandoned: '废弃'
} as const satisfies Record<Api.Product.ProductStatusCode, string>;
const activityTypeLabelMap = {
product: '产品',
status: '状态',
member: '成员'
} as const satisfies Record<Api.Product.ProductActivityType, string>;
export type ProductActivityFilterType = 'all' | Api.Product.ProductActivityType;
export type ProductActivityTone = 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
export interface ProductActivityDisplayItem extends Api.Product.ProductActivityTimelineItem {
tagLabel: string;
timeText: string;
actionText: string;
displaySummary: string;
compactText: string;
operatorText: string;
reasonText: string;
statusTransition: string;
tone: ProductActivityTone;
}
export const PRODUCT_ACTIVITY_TYPE_OPTIONS: Array<{ label: string; value: ProductActivityFilterType }> = [
{ label: '全部', value: 'all' },
{ label: '产品', value: 'product' },
{ label: '状态', value: 'status' },
{ label: '成员', value: 'member' }
];
export const PRODUCT_ACTIVITY_ACTION_OPTIONS: Array<{
label: string;
value: Api.Product.ProductActivityActionType;
type: Api.Product.ProductActivityType;
}> = [
{ label: '产品创建', value: 'create', type: 'product' },
{ label: '产品经理变更', value: 'change_manager', type: 'product' },
{ label: '暂停', value: 'pause', type: 'status' },
{ label: '恢复', value: 'resume', type: 'status' },
{ label: '归档', value: 'archive', type: 'status' },
{ label: '废弃', value: 'abandon', type: 'status' },
{ label: '成员加入', value: 'add_member', type: 'member' },
{ label: '成员调整', value: 'update_member', type: 'member' },
{ label: '成员移出', value: 'remove_member', type: 'member' }
];
export const PRODUCT_ACTIVITY_TIME_SHORTCUTS = [
{ label: '最近7天', days: 7 },
{ label: '最近30天', days: 30 },
{ label: '最近90天', days: 90 }
] as const;
export const DEFAULT_PRODUCT_ACTIVITY_PAGE_SIZE = 10;
type ActivityDetailRecord = Record<string, unknown>;
function getStatusLabel(status: Api.Product.ProductStatusCode | null | undefined) {
if (!status) {
return '--';
}
return productStatusLabelMap[status] || '--';
}
function getActivityTone(item: Api.Product.ProductActivityTimelineItem): ProductActivityTone {
if (item.type === 'status') {
if (item.actionType === 'resume') {
return 'emerald';
}
if (item.actionType === 'pause') {
return 'amber';
}
if (item.actionType === 'abandon') {
return 'rose';
}
return 'slate';
}
if (item.type === 'product') {
return item.actionType === 'change_manager' ? 'emerald' : 'sky';
}
return item.actionType === 'remove_member' ? 'rose' : 'sky';
}
export function formatProductActivityTime(occurredAt: number | null | undefined) {
if (!Number.isFinite(occurredAt)) {
return '';
}
const parsed = dayjs(Number(occurredAt));
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : '';
}
export function buildProductActivityRange(days: number): [string, string] {
const end = dayjs().endOf('day');
const start = dayjs()
.subtract(Math.max(days - 1, 0), 'day')
.startOf('day');
return [start.format('YYYY-MM-DD HH:mm:ss'), end.format('YYYY-MM-DD HH:mm:ss')];
}
export function getProductActivityActionOptions(activityType: ProductActivityFilterType) {
if (activityType === 'all') {
return PRODUCT_ACTIVITY_ACTION_OPTIONS;
}
return PRODUCT_ACTIVITY_ACTION_OPTIONS.filter(item => item.type === activityType);
}
export function normalizeProductActivityActionTypes(
activityType: ProductActivityFilterType,
actionTypes: readonly Api.Product.ProductActivityActionType[]
) {
const allowed = new Set(getProductActivityActionOptions(activityType).map(item => item.value));
return actionTypes.filter(actionType => allowed.has(actionType));
}
function parseActivityDetails(details: string | null | undefined): ActivityDetailRecord | null {
if (!details?.trim()) {
return null;
}
try {
const parsed = JSON.parse(details);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const normalized = parsed as ActivityDetailRecord;
const fieldChanges = normalized.fieldChanges;
if (fieldChanges && typeof fieldChanges === 'object' && !Array.isArray(fieldChanges)) {
return {
...normalized,
...(fieldChanges as ActivityDetailRecord)
};
}
return normalized;
}
} catch {}
return null;
}
function getRecordValue(record: ActivityDetailRecord | null, keys: readonly string[]) {
if (!record) {
return undefined;
}
const matchedKey = keys.find(key => key in record);
return matchedKey ? record[matchedKey] : undefined;
}
function getFieldChangeText(
record: ActivityDetailRecord | null,
keys: readonly string[],
preferredSide: 'before' | 'after'
) {
const rawValue = getRecordValue(record, keys);
if (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) {
const fieldChange = rawValue as { before?: unknown; after?: unknown };
const preferredValue = fieldChange[preferredSide];
if (preferredValue !== null && preferredValue !== undefined && String(preferredValue).trim()) {
return String(preferredValue).trim();
}
const fallbackSide = preferredSide === 'after' ? fieldChange.before : fieldChange.after;
if (fallbackSide !== null && fallbackSide !== undefined && String(fallbackSide).trim()) {
return String(fallbackSide).trim();
}
}
if (rawValue !== null && rawValue !== undefined && String(rawValue).trim()) {
return String(rawValue).trim();
}
return '';
}
function getActivityTargetUserName(
item: Api.Product.ProductActivityTimelineItem,
detailsRecord: ActivityDetailRecord | null
) {
const targetUserName = item.targetUserName?.trim() || '';
if (targetUserName) {
return targetUserName;
}
const preferredSide = item.actionType === 'remove_member' ? 'before' : 'after';
return getFieldChangeText(
detailsRecord,
[
'memberUserName',
'memberUserNickname',
'memberName',
'userNickname',
'userName',
'targetUserName',
'targetUserNickname'
],
preferredSide
);
}
function getActivityTargetRoleName(
item: Api.Product.ProductActivityTimelineItem,
detailsRecord: ActivityDetailRecord | null
) {
const preferredSide = item.actionType === 'remove_member' ? 'before' : 'after';
return getFieldChangeText(detailsRecord, ['roleName', 'memberRoleName', 'targetRoleName'], preferredSide);
}
function getRoleTransitionText(detailsRecord: ActivityDetailRecord | null) {
const beforeRoleName = getFieldChangeText(detailsRecord, ['roleName', 'memberRoleName', 'targetRoleName'], 'before');
const afterRoleName = getFieldChangeText(detailsRecord, ['roleName', 'memberRoleName', 'targetRoleName'], 'after');
if (beforeRoleName && afterRoleName && beforeRoleName !== afterRoleName) {
return `${beforeRoleName} -> ${afterRoleName}`;
}
return afterRoleName || beforeRoleName;
}
function isGenericActivitySummary(summaryText: string, actionText: string) {
if (!summaryText) {
return true;
}
return summaryText === actionText || summaryText === actionText.replace('执行了', '执行了');
}
function buildMemberChangeSummary(
item: Api.Product.ProductActivityTimelineItem,
detailsRecord: ActivityDetailRecord | null,
operatorText: string
) {
const memberName = getActivityTargetUserName(item, detailsRecord);
const roleName = getActivityTargetRoleName(item, detailsRecord);
if (!memberName) {
return '';
}
const memberDetail = roleName ? `${memberName}${roleName}` : memberName;
const actionLabel = item.actionType === 'add_member' ? '将成员加入产品' : '将成员移出产品';
return operatorText === '--' ? `${actionLabel}${memberDetail}` : `${operatorText}${actionLabel}${memberDetail}`;
}
function buildMemberUpdateSummary(
item: Api.Product.ProductActivityTimelineItem,
detailsRecord: ActivityDetailRecord | null,
operatorText: string
) {
const memberName = getActivityTargetUserName(item, detailsRecord);
const roleTransitionText = getRoleTransitionText(detailsRecord);
const memberText = memberName || '成员';
const roleText = roleTransitionText ? `,角色:${roleTransitionText}` : '';
return operatorText === '--'
? `调整成员:${memberText}${roleText}`
: `${operatorText}调整成员:${memberText}${roleText}`;
}
function buildManagerChangeSummary(detailsRecord: ActivityDetailRecord | null, operatorText: string) {
const beforeManagerName = getFieldChangeText(
detailsRecord,
['beforeManagerUserName', 'beforeManagerUserNickname', 'managerUserName', 'managerUserNickname', 'managerName'],
'before'
);
const afterManagerName = getFieldChangeText(
detailsRecord,
['afterManagerUserName', 'afterManagerUserNickname', 'managerUserName', 'managerUserNickname', 'managerName'],
'after'
);
if (!beforeManagerName && !afterManagerName) {
return '';
}
const transitionText =
beforeManagerName && afterManagerName
? `${beforeManagerName} -> ${afterManagerName}`
: afterManagerName || beforeManagerName;
return operatorText === '--' ? `变更产品经理:${transitionText}` : `${operatorText}变更产品经理:${transitionText}`;
}
function resolveDetailedSummary(
item: Api.Product.ProductActivityTimelineItem,
operatorText: string,
actionText: string
) {
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;
}
if (item.actionType === 'update_member') {
return buildMemberUpdateSummary(item, detailsRecord, operatorText);
}
if (item.actionType === 'change_manager') {
return buildManagerChangeSummary(detailsRecord, operatorText) || summaryText || actionText;
}
return summaryText || actionText;
}
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 compactText = displaySummary;
return {
...item,
tagLabel: activityTypeLabelMap[item.type],
timeText: formatProductActivityTime(item.occurredAt) || '--',
actionText,
displaySummary,
compactText,
operatorText,
reasonText: item.reason?.trim() || '',
statusTransition:
item.type === 'status' && item.fromStatus && item.toStatus
? `${getStatusLabel(item.fromStatus)} -> ${getStatusLabel(item.toStatus)}`
: '',
tone: getActivityTone(item)
};
}
export function buildProductActivityDisplayItems(
items: readonly Api.Product.ProductActivityTimelineItem[] | null | undefined
) {
return [...(items || [])].map(buildProductActivityDisplayItem);
}

View File

@@ -0,0 +1,696 @@
<script setup lang="tsx">
import { computed, onMounted, reactive, ref } from 'vue';
import type { Component } from 'vue';
import { ElButton, ElTag } from 'element-plus';
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 { 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 ProductOperateDialog from './modules/product-operate-dialog.vue';
import ProductSearch from './modules/product-search.vue';
defineOptions({ name: 'ProductList' });
interface StatusNavMeta {
key: Api.Product.ProductStatusCode;
label: string;
description: string;
tone: 'teal' | 'slate' | 'amber' | 'rose';
icon: Component;
}
type ProductPageResponse = Awaited<ReturnType<typeof fetchGetProductPage>>;
const PRODUCT_OPTION_PAGE_SIZE = 200;
const PRODUCT_ENTRY_ROUTE_PATH = '/product/list';
function getInitSearchParams(): Api.Product.ProductSearchParams {
return {
pageNo: 1,
pageSize: 10,
keyword: '',
directionCode: undefined,
managerUserId: undefined,
statusCode: undefined,
updateTime: undefined
};
}
function transformProductPage(response: ProductPageResponse, 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');
}
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',
label: '启用产品',
description: '当前正常服务中的产品',
tone: 'teal',
icon: CircleCheckFilled
},
{
key: 'archived',
label: '归档产品',
description: '已完成阶段目标的产品',
tone: 'slate',
icon: FolderOpened
},
{
key: 'paused',
label: '暂停产品',
description: '阶段性暂停投入的产品',
tone: 'amber',
icon: VideoPause
},
{
key: 'abandoned',
label: '废弃产品',
description: '已明确停止建设的产品',
tone: 'rose',
icon: DeleteFilled
}
];
const searchParams = reactive(getInitSearchParams());
const selectedStatus = ref<Api.Product.ProductStatusCode>('active');
const managerFilterOptions = ref<Api.SystemManage.UserSimple[]>([]);
const managerUserOptions = ref<Api.SystemManage.UserSimple[]>([]);
const operateVisible = ref(false);
const editingRow = ref<Api.Product.Product | null>(null);
const { routerPush } = useRouterPush();
const { dictData: directionOptions, getLabel: getDirectionDictLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const statusCounts = ref<Record<Api.Product.ProductStatusCode, 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]));
});
const statusItems = computed(() =>
statusNavMetas.map(item => ({
...item,
count: statusCounts.value[item.key]
}))
);
const overviewMetrics = computed(() => [
{
label: '可见产品',
value: Object.values(statusCounts.value).reduce((sum, count) => sum + count, 0),
hint: '当前接口可查询到的产品总量'
},
{
label: '当前启用',
value: statusCounts.value.active,
hint: '正在持续服务和维护的产品'
},
{
label: '产品方向',
value: directionOptions.value.length,
hint: '已加载的方向字典项数量'
},
{
label: '30天内更新',
value: recentUpdatedCount.value,
hint: '最近 30 天内发生过更新的产品'
}
]);
function getDirectionLabel(directionCode?: string | null) {
return getDirectionDictLabel(directionCode, '--');
}
function getManagerLabel(managerUserId?: string | null) {
if (!managerUserId) {
return '--';
}
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
}
function createRequestParams(): Api.Product.ProductSearchParams {
return {
...searchParams,
keyword: searchParams.keyword?.trim() || undefined,
statusCode: selectedStatus.value
};
}
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
ProductPageResponse,
Api.Product.Product
>({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => fetchGetProductPage(createRequestParams()),
transform: response => transformProductPage(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: 'name',
label: '产品名称',
minWidth: 220,
formatter: row => (
<ElButton link type="primary" class="product-name-link" onClick={() => enterProductContext(row)}>
{row.name}
</ElButton>
)
},
{ prop: 'code', label: '产品编码', minWidth: 140, showOverflowTooltip: true },
{
prop: 'managerUserId',
label: '产品经理',
minWidth: 120,
formatter: row => getManagerLabel(row.managerUserId)
},
{
prop: 'directionCode',
label: '产品方向',
minWidth: 140,
showOverflowTooltip: true,
formatter: row => getDirectionLabel(row.directionCode)
},
{
prop: 'statusCode',
label: '管理状态',
width: 120,
align: 'center',
formatter: row => (
<ElTag type={getProductStatusTagType(row.statusCode)}>{getProductStatusLabel(row.statusCode)}</ElTag>
)
},
{
prop: 'lastStatusReason',
label: '状态原因',
minWidth: 180,
showOverflowTooltip: true,
formatter: row => row.lastStatusReason?.trim() || '--'
},
{
prop: 'updateTime',
label: '最近更新',
width: 170,
align: 'center',
formatter: row => formatDateTime(row.updateTime)
},
{
prop: 'operate',
label: '操作',
width: 108,
align: 'center',
fixed: 'right',
formatter: row => (
<BusinessTableActionCell
actions={[
{
key: 'edit',
label: '编辑',
buttonType: 'primary',
disabled: !isProductEditable(row.statusCode),
onClick: () => openEdit(row)
}
]}
/>
)
}
]
});
async function loadManagerOptions() {
const [allProducts, userSimpleResult] = await Promise.all([fetchAllProducts(), fetchGetUserSimpleList()]);
const userSimpleList =
userSimpleResult.error || !userSimpleResult.data ? [] : sortManagerOptions(userSimpleResult.data);
managerUserOptions.value = userSimpleList;
if (!allProducts) {
managerFilterOptions.value = [];
return;
}
managerFilterOptions.value = createManagerOptions(allProducts, 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 [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] })
]);
statusCounts.value = {
active: activeTotal,
archived: archivedTotal,
paused: pausedTotal,
abandoned: abandonedTotal
};
recentUpdatedCount.value = recentTotal;
}
async function reloadProductTable(page = searchParams.pageNo ?? 1) {
await getDataByPage(page);
}
async function refreshPageData(page = searchParams.pageNo ?? 1) {
await Promise.all([loadManagerOptions(), loadOverviewData(), reloadProductTable(page)]);
}
async function handleSearch() {
await reloadProductTable(1);
}
async function handleResetSearch() {
const pageSize = searchParams.pageSize ?? 10;
Object.assign(searchParams, getInitSearchParams(), {
pageSize
});
await reloadProductTable(1);
}
async function handleStatusChange(status: Api.Product.ProductStatusCode) {
selectedStatus.value = status;
await reloadProductTable(1);
}
function openCreate() {
editingRow.value = null;
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,
query: {
[OBJECT_CONTEXT_QUERY_KEY]: row.id
}
});
}
async function handleProductSubmitted(productId?: string) {
const isEditing = Boolean(productId && editingRow.value?.id === productId);
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">
<ElCard class="product-overview-card card-wrapper">
<div class="product-overview-card__stats">
<div v-for="item in overviewMetrics" :key="item.label" class="product-overview-card__stat">
<span class="product-overview-card__stat-label">{{ item.label }}</span>
<strong class="product-overview-card__stat-value">{{ item.value }}</strong>
<small class="product-overview-card__stat-hint">{{ item.hint }}</small>
</div>
</div>
<div class="product-status-panel__list">
<button
v-for="item in statusItems"
:key="item.key"
type="button"
class="product-status-item"
:class="[`product-status-item--${item.tone}`, { 'is-active': selectedStatus === item.key }]"
:aria-pressed="selectedStatus === item.key"
@click="handleStatusChange(item.key)"
>
<div class="product-status-item__icon">
<ElIcon>
<component :is="item.icon" />
</ElIcon>
</div>
<div class="product-status-item__main">
<div class="product-status-item__top">
<strong>{{ item.label }}</strong>
<em>{{ item.count }}</em>
</div>
<p class="product-status-item__desc">{{ item.description }}</p>
</div>
</button>
</div>
</ElCard>
</div>
<div class="flex-col-stretch gap-16px xl:min-h-0">
<ProductSearch
v-model:model="searchParams"
:manager-options="managerFilterOptions"
@reset="handleResetSearch"
@search="handleSearch"
/>
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="product-table-card-body">
<template #header>
<div class="product-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="getProductStatusTagType(selectedStatus)">
{{
statusItems.find(item => item.key === selectedStatus)?.label ||
getProductStatusLabel(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>
<ProductOperateDialog
v-model:visible="operateVisible"
:manager-user-options="managerUserOptions"
:row-data="editingRow"
@submitted="handleProductSubmitted"
/>
</div>
</template>
<style lang="scss" scoped>
.product-overview-card {
overflow: hidden;
border: 1px solid rgb(226 232 240 / 92%);
background:
radial-gradient(circle at top left, rgb(15 118 110 / 8%), transparent 36%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
}
.product-overview-card__stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.product-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%);
}
.product-overview-card__stat-label {
color: rgb(100 116 139 / 90%);
font-size: 13px;
}
.product-overview-card__stat-value {
color: rgb(15 23 42 / 94%);
font-size: 24px;
font-weight: 700;
line-height: 1.1;
}
.product-overview-card__stat-hint {
color: rgb(100 116 139 / 90%);
font-size: 12px;
line-height: 1.5;
}
.product-status-panel__list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.product-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;
}
.product-status-item:hover {
transform: translateY(-1px);
border-color: rgb(148 163 184 / 60%);
}
.product-status-item.is-active {
border-color: rgb(15 118 110 / 40%);
box-shadow: 0 10px 24px rgb(15 118 110 / 8%);
}
.product-status-item__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 14px;
font-size: 20px;
}
.product-status-item__main {
min-width: 0;
flex: 1;
}
.product-status-item__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.product-status-item__top strong {
color: rgb(15 23 42 / 94%);
font-size: 15px;
font-weight: 700;
}
.product-status-item__top em {
color: rgb(15 23 42 / 88%);
font-size: 18px;
font-style: normal;
font-weight: 700;
}
.product-status-item__desc {
color: rgb(100 116 139 / 94%);
font-size: 13px;
line-height: 1.6;
}
.product-status-item--teal .product-status-item__icon {
background-color: rgb(240 253 250 / 96%);
color: rgb(15 118 110 / 96%);
}
.product-status-item--slate .product-status-item__icon {
background-color: rgb(241 245 249 / 96%);
color: rgb(51 65 85 / 92%);
}
.product-status-item--amber .product-status-item__icon {
background-color: rgb(255 251 235 / 96%);
color: rgb(217 119 6 / 92%);
}
.product-status-item--rose .product-status-item__icon {
background-color: rgb(255 241 242 / 96%);
color: rgb(225 29 72 / 92%);
}
:deep(.product-table-card-body) {
display: flex;
flex: 1;
flex-direction: column;
}
.product-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.product-name-link {
padding: 0;
}
@media (width <= 1280px) {
.product-card-header,
.product-status-item__top {
align-items: flex-start;
flex-direction: column;
}
}
@media (width <= 640px) {
.product-overview-card__stats {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { fetchGetProduct } from '@/service/api';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictText from '@/components/custom/dict-text.vue';
import { getProductStatusLabel, getProductStatusTagType } from '../../shared/product-master-data';
defineOptions({ name: 'ProductDetailDialog' });
interface Props {
rowData?: Api.Product.Product | null;
managerOptions: Api.SystemManage.UserSimple[];
}
const props = defineProps<Props>();
const visible = defineModel<boolean>('visible', {
default: false
});
const detailLoading = ref(false);
const detailData = ref<Api.Product.Product | null>(null);
const title = computed(() => {
return detailData.value?.name ? `产品详情 - ${detailData.value.name}` : '产品详情';
});
const managerLabelMap = computed(() => {
return new Map(props.managerOptions.map(item => [String(item.id), item.nickname]));
});
function getManagerLabel(managerUserId?: string | null) {
if (!managerUserId) {
return '--';
}
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
}
function formatTime(value?: string | null) {
if (!value) {
return '--';
}
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
}
async function initDetail() {
detailData.value = props.rowData ? { ...props.rowData } : null;
if (!props.rowData) {
return;
}
detailLoading.value = true;
const { error, data } = await fetchGetProduct(props.rowData.id);
detailLoading.value = false;
if (!error) {
detailData.value = data;
}
}
watch(visible, value => {
if (value) {
initDetail();
}
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="title"
preset="lg"
:loading="detailLoading"
:show-footer="false"
:scrollbar="false"
>
<template v-if="detailData">
<div class="mb-16px flex flex-wrap items-center gap-8px">
<ElTag>{{ detailData.code }}</ElTag>
<ElTag :type="getProductStatusTagType(detailData.statusCode)">
{{ getProductStatusLabel(detailData.statusCode) }}
</ElTag>
</div>
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="产品名称">{{ detailData.name }}</ElDescriptionsItem>
<ElDescriptionsItem label="产品方向">
<DictText :dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE" :value="detailData.directionCode" />
</ElDescriptionsItem>
<ElDescriptionsItem label="产品经理">{{ getManagerLabel(detailData.managerUserId) }}</ElDescriptionsItem>
<ElDescriptionsItem label="创建时间">{{ formatTime(detailData.createTime) }}</ElDescriptionsItem>
<ElDescriptionsItem label="更新时间">{{ formatTime(detailData.updateTime) }}</ElDescriptionsItem>
<ElDescriptionsItem label="最近状态原因">{{ detailData.lastStatusReason || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="产品描述" :span="2">
<span class="whitespace-pre-wrap">{{ detailData.description || '--' }}</span>
</ElDescriptionsItem>
</ElDescriptions>
</template>
<ElEmpty v-else description="未获取到产品详情" />
</BusinessFormDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,140 @@
<script setup lang="ts">
import { computed } from 'vue';
import { type DemoProduct, getProductHealthType, getProductStatusType } from '@/constants/product-demo';
defineOptions({ name: 'ProductEntryCard' });
interface Props {
product: DemoProduct;
entering?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
entering: false
});
interface Emits {
(e: 'enter', product: DemoProduct): void;
}
const emit = defineEmits<Emits>();
const quickFacts = computed(() => [
{ label: '版本', value: props.product.version },
{ label: '目标发版', value: props.product.releaseTarget },
{ label: '团队规模', value: `${props.product.teamCount}` }
]);
function handleEnter() {
emit('enter', props.product);
}
</script>
<template>
<ElCard class="product-entry-card h-full">
<div class="mb-14px flex items-start justify-between gap-12px">
<div class="min-w-0">
<div class="mb-8px flex flex-wrap items-center gap-8px">
<span class="product-entry-card__code">{{ product.code }}</span>
<ElTag :type="getProductStatusType(product.status)" round>{{ product.status }}</ElTag>
<ElTag :type="getProductHealthType(product.health)" effect="dark" round>{{ product.health }}</ElTag>
</div>
<h3 class="mb-6px text-18px text-[#0f172a] font-700">{{ product.name }}</h3>
<p class="text-13px text-[#64748b]">负责人{{ product.owner }} / 阶段{{ product.stage }}</p>
</div>
<div class="product-entry-card__pulse"></div>
</div>
<p class="mb-14px min-h-[66px] text-14px text-[#475569] leading-22px">{{ product.summary }}</p>
<div class="mb-14px flex flex-wrap gap-8px">
<ElTag v-for="tag in product.tags" :key="tag" effect="plain" round>{{ tag }}</ElTag>
</div>
<div class="grid mb-14px gap-10px sm:grid-cols-3">
<div v-for="item in quickFacts" :key="item.label" class="product-entry-card__fact">
<span class="product-entry-card__fact-label">{{ item.label }}</span>
<strong class="product-entry-card__fact-value">{{ item.value }}</strong>
</div>
</div>
<div class="mb-16px rounded-16px bg-[#f8fafc] p-12px">
<p class="mb-8px text-12px text-[#94a3b8] tracking-[0.08em] uppercase">当前聚焦</p>
<div class="flex flex-wrap gap-8px">
<span v-for="item in product.focus" :key="item" class="product-entry-card__focus-chip">
{{ item }}
</span>
</div>
</div>
<div class="flex items-center justify-between gap-12px">
<div class="text-13px text-[#64748b]">
<span>需求 {{ product.requirementCount }}</span>
<span class="mx-8px text-[#cbd5e1]">|</span>
<span>缺陷 {{ product.bugCount }}</span>
</div>
<ElButton type="primary" :loading="entering" @click="handleEnter">进入产品</ElButton>
</div>
</ElCard>
</template>
<style scoped>
.product-entry-card {
border: 1px solid rgb(226 232 240 / 92%);
background:
radial-gradient(circle at top right, rgb(16 185 129 / 7%), transparent 28%),
linear-gradient(180deg, rgb(255 255 255 / 98%), rgb(248 250 252 / 96%));
}
.product-entry-card__code {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
border-radius: 999px;
background-color: rgb(15 23 42 / 92%);
color: #fff;
font-size: 11px;
letter-spacing: 0.08em;
}
.product-entry-card__pulse {
width: 12px;
height: 12px;
margin-top: 6px;
border-radius: 999px;
background: radial-gradient(circle, rgb(14 165 233 / 82%), rgb(14 165 233 / 16%));
box-shadow: 0 0 0 6px rgb(14 165 233 / 8%);
}
.product-entry-card__fact {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-radius: 14px;
background-color: rgb(241 245 249 / 88%);
}
.product-entry-card__fact-label {
color: rgb(100 116 139 / 88%);
font-size: 12px;
}
.product-entry-card__fact-value {
color: rgb(15 23 42 / 90%);
font-size: 15px;
}
.product-entry-card__focus-chip {
display: inline-flex;
align-items: center;
height: 30px;
padding: 0 12px;
border: 1px dashed rgb(125 211 252 / 80%);
border-radius: 999px;
color: rgb(14 116 144 / 92%);
font-size: 12px;
background-color: rgb(236 254 255 / 88%);
}
</style>

View File

@@ -0,0 +1,267 @@
<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 { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue';
defineOptions({ name: 'ProductOperateDialog' });
interface Props {
managerUserOptions: Api.SystemManage.UserSimple[];
rowData?: Api.Product.Product | null;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted', productId?: string): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
interface Model {
code: string;
directionCode: string;
name: string;
managerUserId: string | null;
description: string;
}
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
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 managerLabelMap = computed(() => new Map(props.managerUserOptions.map(item => [String(item.id), item.nickname])));
const managerDisplayName = computed(() => {
const managerUserId = model.value.managerUserId;
if (!managerUserId) {
return '';
}
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
});
const rules = {
directionCode: [createRequiredRule('请选择产品方向')],
name: [createRequiredRule('请输入产品名称')],
managerUserId: [createRequiredRule('请选择产品经理')]
} satisfies Record<string, App.Global.FormRule[]>;
function createDefaultModel(): Model {
return {
code: '',
directionCode: '',
name: '',
managerUserId: null,
description: ''
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function closeDialog() {
visible.value = false;
}
async function handleSubmit() {
await validate();
const managerUserId = model.value.managerUserId;
if (!managerUserId) {
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);
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('产品新增成功');
closeDialog();
emit('submitted', result.data);
}
watch(visible, async value => {
if (!value) {
return;
}
if (!isEditMode.value || !props.rowData?.id) {
model.value = createDefaultModel();
await nextTick();
formRef.value?.clearValidate();
return;
}
loading.value = true;
const { error, data } = await fetchGetProduct(props.rowData.id);
loading.value = false;
if (error || !data) {
return;
}
model.value = {
code: data.code || '',
directionCode: data.directionCode || '',
name: data.name || '',
managerUserId: data.managerUserId || null,
description: data.description || ''
};
await nextTick();
formRef.value?.clearValidate();
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="lg"
:loading="loading"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem v-if="isEditMode" label="产品编码" prop="code">
<ElInput
:model-value="model.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">
<ElFormItem label="产品名称" prop="name">
<ElInput v-model="model.name" clearable maxlength="128" 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 v-if="isEditMode">
<template #label>
<span class="business-form-label-with-tip">
<ElTooltip
content="如需调整产品经理,请到产品内的团队管理处处理。"
popper-class="business-form-label-tooltip"
placement="top-start"
>
<span class="business-form-label-tip">
<icon-fe:question />
</span>
</ElTooltip>
<span>产品经理</span>
</span>
</template>
<ElInput
:model-value="managerDisplayName"
readonly
class="product-operate-dialog__readonly-input"
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"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入产品描述"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
:deep(.product-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(.product-operate-dialog__readonly-input .el-input__wrapper:hover),
:deep(.product-operate-dialog__readonly-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.product-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

@@ -0,0 +1,59 @@
<script setup lang="ts">
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';
defineOptions({ name: 'ProductSearch' });
interface Props {
managerOptions: Api.SystemManage.UserSimple[];
}
defineProps<Props>();
interface Emits {
(e: 'reset'): void;
(e: 'search'): void;
}
const emit = defineEmits<Emits>();
const model = defineModel<Api.Product.ProductSearchParams>('model', { required: true });
function reset() {
emit('reset');
}
function search() {
emit('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>
</template>
<style scoped></style>

View File

@@ -0,0 +1,780 @@
<script setup lang="tsx">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import type { TableInstance } from 'element-plus';
import { ElButton, ElTag } from 'element-plus';
import dayjs from 'dayjs';
import {
RDMS_REQ_CATEGORY_DICT_CODE,
RDMS_REQ_PRIORITY_DICT_CODE
} from '@/constants/dict';
import {
fetchChangeRequirementStatus,
fetchDeleteRequirement,
fetchGetProductMembers,
fetchGetRequirementAllowedTransitions,
fetchGetRequirementStatusDict,
fetchGetRequirementTerminalStatusDict,
fetchGetRequirementTree
} from '@/service/api';
import { useAuth } from '@/hooks/business/auth';
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
import DictTag from '@/components/custom/dict-tag.vue';
import { useCurrentProduct } from '../shared/use-current-product';
import {
type RequirementStatusActionCode,
getRequirementActionDisplayName,
getRequirementActionTagType,
getRequirementStatusTagType,
isRequirementActionNeedProject,
isRequirementActionNeedReviewChoice,
isRequirementActionTerminal
} from './shared/requirement-master-data';
import RequirementModuleTree from './modules/requirement-module-tree.vue';
import RequirementSearch from './modules/requirement-search.vue';
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';
defineOptions({ name: 'ProductRequirement' });
const { currentObjectId } = useCurrentProduct();
const { hasObjectAuth } = useAuth();
const statusOptions = ref<Array<{ label: string; value: string }>>([]);
const terminalStatusOptions = ref<string[]>([]);
async function loadStatusOptions() {
const { error, data } = await fetchGetRequirementStatusDict();
if (error || !data) {
statusOptions.value = [];
return;
}
statusOptions.value = data.map(item => ({
label: item.statusName,
value: item.statusCode
}));
}
async function loadTerminalStatusOptions() {
const { error, data } = await fetchGetRequirementTerminalStatusDict();
if (error || !data) {
terminalStatusOptions.value = [];
return;
}
terminalStatusOptions.value = data.map(item => item.statusCode);
}
function getStatusLabel(statusCode: string) {
const item = statusOptions.value.find(opt => opt.value === statusCode);
return item ? item.label : statusCode;
}
const priorityTagTypeMap: Record<number, UI.ThemeColor> = {
0: 'info',
1: 'primary',
2: 'warning',
3: 'danger'
};
function formatDateTime(value?: string | null) {
if (!value) {
return '--';
}
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
}
function isTerminalStatus(statusCode: string) {
return terminalStatusOptions.value.some(option => option === statusCode);
}
function canSplitRequirement(row: Api.Product.Requirement) {
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 hasNoChildren = !row.children || row.children.length === 0;
return isStatusAllowed && hasNoChildren;
}
const selectedModuleId = ref<string | undefined>('');
const memberOptions = ref<Api.Product.ProductMember[]>([]);
const requirementTableRef = ref<TableInstance>();
const loading = ref(false);
const treeData = ref<Api.Product.Requirement[]>([]);
const pagination = reactive({
pageNo: 1,
pageSize: 10,
total: 0
});
const allowedTransitionsMap = ref<Map<string, Api.Product.RequirementLifecycleAction[]>>(new Map());
const searchParams = reactive({
title: undefined as string | undefined,
category: undefined as string | undefined,
priority: undefined as Api.Product.RequirementPriority | undefined,
statusCode: undefined as Api.Product.RequirementStatusCode | undefined,
currentHandlerUserId: undefined as string | undefined,
sourceType: undefined as Api.Product.RequirementSourceType | undefined
});
const createVisible = ref(false);
const detailVisible = ref(false);
const detailMode = ref<'view' | 'edit'>('view');
const selectedRequirement = ref<Api.Product.Requirement | null>(null);
const splitVisible = ref(false);
const splitParentRequirement = ref<Api.Product.Requirement | null>(null);
const actionVisible = ref(false);
const actionRequirement = ref<Api.Product.Requirement | null>(null);
const currentAction = ref<Api.Product.RequirementLifecycleAction | null>(null);
interface MemberUserOption {
id: string;
nickname: string;
roleName: string;
}
const memberUserOptions = computed<MemberUserOption[]>(() => {
return memberOptions.value
.filter(m => m.status === 0)
.map(m => ({
id: m.userId,
nickname: m.userNickname,
roleName: m.roleName
}));
});
const memberLabelMap = computed(() => {
return new Map(memberUserOptions.value.map(item => [String(item.id), item.nickname]));
});
function getMemberLabel(userId?: string | null) {
if (!userId) {
return '--';
}
return memberLabelMap.value.get(String(userId)) || String(userId);
}
function getPriorityTagType(priority?: number | null): UI.ThemeColor {
if (priority === null || priority === undefined) {
return 'info';
}
return priorityTagTypeMap[priority] || 'info';
}
function flattenTree(nodes: Api.Product.Requirement[]): Api.Product.Requirement[] {
const result: Api.Product.Requirement[] = [];
for (const node of nodes) {
result.push(node);
if (node.children?.length) {
result.push(...flattenTree(node.children));
}
}
return result;
}
function collectAllRequirementIds(nodes: Api.Product.Requirement[]): string[] {
const ids: string[] = [];
for (const node of nodes) {
ids.push(node.id);
if (node.children?.length) {
ids.push(...collectAllRequirementIds(node.children));
}
}
return ids;
}
async function loadAllowedTransitionsForAll() {
if (!currentObjectId.value) {
allowedTransitionsMap.value = new Map();
return;
}
const allIds = collectAllRequirementIds(treeData.value);
const newMap = new Map<string, Api.Product.RequirementLifecycleAction[]>();
const results = await Promise.all(
allIds.map(async id => {
const { error, data } = await fetchGetRequirementAllowedTransitions(id, currentObjectId.value!);
return { id, actions: error ? [] : data || [] };
})
);
for (const { id, actions } of results) {
newMap.set(id, actions);
}
allowedTransitionsMap.value = newMap;
}
function getRowActions(row: Api.Product.Requirement): Api.Product.RequirementLifecycleAction[] {
return allowedTransitionsMap.value.get(row.id) || [];
}
const columns = computed(() => [
{
type: 'index',
label: '序号',
width: 64,
align: 'center',
index: (index: number): number => {
const flatList = flattenTree(treeData.value);
const row = flatList[index];
if (!row || row.parentId !== '0') {
return 0;
}
const parentIndex = treeData.value.findIndex(item => item.id === row.id);
return parentIndex >= 0 ? (pagination.pageNo - 1) * pagination.pageSize + parentIndex + 1 : 0;
}
},
{
prop: 'title',
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)}>
{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,
align: 'center',
formatter: (row: Api.Product.Requirement) => (
<DictTag dictCode={RDMS_REQ_PRIORITY_DICT_CODE} value={row.priority} type={getPriorityTagType(row.priority)} />
)
},
{
prop: 'statusCode',
label: '状态',
width: 100,
align: 'center',
formatter: (row: Api.Product.Requirement) => (
<ElTag type={getRequirementStatusTagType(row.statusCode)}>
{getStatusLabel(row.statusCode)}
</ElTag>
)
},
{
prop: 'currentHandlerUserId',
label: '负责人',
minWidth: 70,
formatter: (row: Api.Product.Requirement) => getMemberLabel(row.currentHandlerUserId)
},
{
prop: 'sourceBizId',
label: '来源业务编号',
minWidth: 140,
formatter: (row: Api.Product.Requirement) => {
if (!row.sourceBizId || row.sourceType === 'manual') {
return '--';
}
return (
<ElButton link type="primary" class="requirement-source-link">
{row.sourceBizId}
</ElButton>
);
}
},
{
prop: 'implementProjectName',
label: '实现项目',
minWidth: 140,
formatter: (row: Api.Product.Requirement) => row.implementProjectName || '--'
},
{
prop: 'createTime',
label: '创建时间',
width: 170,
formatter: (row: Api.Product.Requirement) => formatDateTime(row.createTime)
},
{
prop: 'operate',
label: '操作',
width: 200,
align: 'center',
fixed: 'right',
formatter: (row: Api.Product.Requirement) => {
const actions: {
key: string;
label: string;
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
disabled?: boolean;
onClick: () => void;
}[] = [];
if (canSplitRequirement(row) && hasObjectAuth('project:product:status')) {
actions.push({
key: 'split',
label: '拆分',
buttonType: 'primary',
onClick: () => openSplit(row)
});
}
if (hasObjectAuth('project:product:update')) {
actions.push({
key: 'edit',
label: '编辑',
buttonType: 'info',
disabled: isTerminalStatus(row.statusCode),
onClick: () => openEdit(row)
});
}
const lifecycleActions = getRowActions(row);
const hasStatusAuth = hasObjectAuth('project:product:status');
if (hasStatusAuth) {
const nonTerminalActions: Api.Product.RequirementLifecycleAction[] = [];
const terminalActions: Api.Product.RequirementLifecycleAction[] = [];
for (const action of lifecycleActions) {
const code = action.actionCode as RequirementStatusActionCode;
if (isRequirementActionTerminal(code)) {
terminalActions.push(action);
} else {
nonTerminalActions.push(action);
}
}
for (const action of [...nonTerminalActions, ...terminalActions]) {
actions.push({
key: `action-${action.actionCode}`,
label: getRequirementActionDisplayName(action),
buttonType: getRequirementActionTagType(action.actionCode as RequirementStatusActionCode),
onClick: () => handleActionClick(row, action)
});
}
}
if (hasStatusAuth && canDeleteRequirement(row)) {
actions.push({
key: 'delete',
label: '删除',
buttonType: 'danger',
onClick: () => handleDelete(row)
});
}
return <BusinessTableActionCell actions={actions} />;
}
}
]);
async function loadMembers() {
if (!currentObjectId.value) {
memberOptions.value = [];
return;
}
const { error, data: members } = await fetchGetProductMembers(currentObjectId.value);
if (error || !members) {
memberOptions.value = [];
return;
}
memberOptions.value = members;
}
async function loadTreeData() {
if (!currentObjectId.value) {
treeData.value = [];
pagination.total = 0;
return;
}
loading.value = true;
const { error, data } = await fetchGetRequirementTree({
productId: currentObjectId.value,
moduleId: selectedModuleId.value,
pageNo: pagination.pageNo,
pageSize: pagination.pageSize,
title: searchParams.title,
category: searchParams.category,
priority: searchParams.priority,
statusCode: searchParams.statusCode,
currentHandlerUserId: searchParams.currentHandlerUserId,
sourceType: searchParams.sourceType
});
loading.value = false;
if (error || !data) {
treeData.value = [];
pagination.total = 0;
return;
}
treeData.value = data.list;
pagination.total = data.total;
}
async function reloadTable() {
await loadTreeData();
await loadAllowedTransitionsForAll();
}
function handleModuleSelect(moduleId: string | undefined) {
selectedModuleId.value = moduleId;
pagination.pageNo = 1;
reloadTable();
}
function handleSearch() {
pagination.pageNo = 1;
reloadTable();
}
function handleResetSearch() {
searchParams.title = undefined;
searchParams.category = undefined;
searchParams.priority = undefined;
searchParams.statusCode = undefined;
searchParams.currentHandlerUserId = undefined;
searchParams.sourceType = undefined;
pagination.pageNo = 1;
reloadTable();
}
function handlePageChange(page: number) {
pagination.pageNo = page;
reloadTable();
}
function handleSizeChange(size: number) {
pagination.pageNo = 1;
pagination.pageSize = size;
reloadTable();
}
function openCreate() {
selectedRequirement.value = null;
createVisible.value = true;
}
function openView(row: Api.Product.Requirement) {
selectedRequirement.value = row;
detailMode.value = 'view';
detailVisible.value = true;
}
function openEdit(row: Api.Product.Requirement) {
selectedRequirement.value = row;
detailMode.value = 'edit';
detailVisible.value = true;
}
function openSplit(row: Api.Product.Requirement) {
splitParentRequirement.value = row;
splitVisible.value = true;
}
function handleActionClick(row: Api.Product.Requirement, action: Api.Product.RequirementLifecycleAction) {
const actionCode = action.actionCode as RequirementStatusActionCode;
if (
!isRequirementActionNeedReviewChoice(actionCode) &&
!isRequirementActionNeedProject(actionCode) &&
!isRequirementActionTerminal(actionCode)
) {
handleDirectAction(row, action);
return;
}
actionRequirement.value = row;
currentAction.value = action;
actionVisible.value = true;
}
async function handleDirectAction(row: Api.Product.Requirement, action: Api.Product.RequirementLifecycleAction) {
if (!currentObjectId.value) return;
try {
await window.$messageBox?.confirm(`确定要执行"${action.actionName}"操作吗?`, '确认操作', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'info'
});
} catch {
return;
}
const { error } = await fetchChangeRequirementStatus({
id: row.id,
productId: currentObjectId.value,
actionCode: action.actionCode
});
if (error) {
return;
}
window.$message?.success(`${action.actionName}成功`);
await reloadTable();
}
async function handleActionSubmitted(payload: { actionCode: string; reason?: string; implementProjectId?: string }) {
if (!currentObjectId.value || !actionRequirement.value) return;
const { error } = await fetchChangeRequirementStatus({
id: actionRequirement.value.id,
productId: currentObjectId.value,
actionCode: payload.actionCode,
reason: payload.reason,
implementProjectId: payload.implementProjectId
});
if (error) {
return;
}
window.$message?.success('操作成功');
actionVisible.value = false;
await reloadTable();
}
async function handleDelete(row: Api.Product.Requirement) {
if (!currentObjectId.value) return;
try {
await window.$messageBox?.confirm('确定要删除该需求吗?删除后不可恢复。', '删除确认', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning'
});
} catch {
return;
}
const { error } = await fetchDeleteRequirement({
id: row.id,
productId: currentObjectId.value
});
if (error) {
return;
}
window.$message?.success('需求删除成功');
await reloadTable();
}
async function handleCreateSubmitted() {
createVisible.value = false;
await reloadTable();
}
async function handleDetailSubmitted() {
detailVisible.value = false;
await reloadTable();
}
async function handleSplitSubmitted() {
splitVisible.value = false;
await reloadTable();
}
watch(
() => currentObjectId.value,
async id => {
if (id) {
await Promise.all([loadMembers(), loadTreeData()]);
await loadAllowedTransitionsForAll();
} else {
memberOptions.value = [];
treeData.value = [];
allowedTransitionsMap.value = new Map();
}
},
{ immediate: true }
);
onMounted(async () => {
await Promise.all([loadStatusOptions(), loadTerminalStatusOptions()]);
});
</script>
<template>
<div
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[280px_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">
<RequirementModuleTree :requirement-tree="treeData" @select="handleModuleSelect" @refresh="reloadTable" />
</div>
<div class="flex-col-stretch gap-16px xl:min-h-0">
<RequirementSearch
v-model:model="searchParams"
:member-options="memberUserOptions"
:category-dict-code="RDMS_REQ_CATEGORY_DICT_CODE"
:priority-dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
@reset="handleResetSearch"
@search="handleSearch"
/>
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="requirement-table-card-body">
<template #header>
<div class="flex items-center justify-between gap-12px">
<div class="flex flex-wrap items-center gap-8px">
<p>需求列表</p>
<ElTag effect="plain">{{ pagination.total }} </ElTag>
</div>
<TableHeaderOperation :loading="loading" @refresh="reloadTable">
<template #default>
<ElButton
v-auth="{ code: 'project:product:create', source: 'object' }"
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
ref="requirementTableRef"
v-loading="loading"
border
lazy
row-key="id"
:indent="32"
height="100%"
:data="treeData"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
>
<ElTableColumn v-for="col in columns" :key="String(col.prop || 'index')" v-bind="col" />
<template #empty>
<ElEmpty description="当前模块下暂无需求" />
</template>
</ElTable>
</div>
<div class="mt-16px flex justify-end">
<ElPagination
v-model:current-page="pagination.pageNo"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 15, 20, 25, 30]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</ElCard>
</div>
<RequirementCreateDialog
v-model:visible="createVisible"
:product-id="currentObjectId || ''"
:default-module-id="selectedModuleId"
:member-options="memberOptions"
:category-dict-code="RDMS_REQ_CATEGORY_DICT_CODE"
:priority-dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
@submitted="handleCreateSubmitted"
/>
<RequirementDetailDialog
v-model:visible="detailVisible"
:mode="detailMode"
:requirement="selectedRequirement"
:product-id="currentObjectId || ''"
:member-options="memberOptions"
:category-dict-code="RDMS_REQ_CATEGORY_DICT_CODE"
:priority-dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
@submitted="handleDetailSubmitted"
/>
<RequirementSplitDialog
v-model:visible="splitVisible"
:parent-requirement="splitParentRequirement"
:product-id="currentObjectId || ''"
:member-options="memberOptions"
:category-dict-code="RDMS_REQ_CATEGORY_DICT_CODE"
:priority-dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
@submitted="handleSplitSubmitted"
/>
<RequirementActionDialog
v-model:visible="actionVisible"
:action="currentAction"
:requirement-title="actionRequirement?.title || ''"
@submitted="handleActionSubmitted"
/>
</div>
</template>
<style lang="scss" scoped>
:deep(.requirement-table-card-body) {
height: calc(100% - 56px);
display: flex;
flex-direction: column;
}
:deep(.requirement-title) {
padding: 0;
font-weight: 500;
}
:deep(.requirement-title--terminal) {
padding: 0;
text-decoration: line-through;
opacity: 0.6;
}
:deep(.requirement-source-link) {
padding: 0;
}
:deep(.el-table__row[class*='el-table__row--level-']:not(.el-table__row--level-0) td:first-child .cell) {
color: transparent;
}
</style>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { ElTag } from 'element-plus';
defineOptions({ name: 'MemberSelectOption' });
interface Props {
nickname: string;
roleName: string;
}
defineProps<Props>();
</script>
<template>
<div class="member-select-option">
<span class="member-select-option__name">{{ nickname }}</span>
<ElTag type="info" size="small" class="member-select-option__role">{{ roleName }}</ElTag>
</div>
</template>
<style scoped>
.member-select-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.member-select-option__name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-select-option__role {
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,313 @@
<script setup lang="ts">
import { computed } from 'vue';
defineOptions({ name: 'ModuleTreeNode' });
interface Props {
module: Api.Product.RequirementModule;
level?: number;
selectedModuleId?: string;
editingNodeId?: string | undefined;
editingName?: string;
addingChildParentId?: string | undefined;
newChildModuleName?: string;
rootModuleId?: string;
moduleRequirementCountMap?: Map<string, number>;
}
const props = withDefaults(defineProps<Props>(), {
level: 0
});
const emit = defineEmits([
'select',
'edit',
'editConfirm',
'editCancel',
'delete',
'addChild',
'addChildConfirm',
'addChildCancel',
'updateEditingName',
'updateNewChildModuleName'
]);
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 hasRequirements = computed(() => {
const moduleId = props.module.id;
if (!moduleId || !props.moduleRequirementCountMap) return false;
return (props.moduleRequirementCountMap.get(moduleId) || 0) > 0;
});
const canDeleteModule = computed(() => !hasChildren.value && !hasRequirements.value);
const indentStyle = computed(() => {
if (props.level === 0) return {};
const indent = 24 + (props.level - 1) * 24;
return {
width: `calc(100% - ${indent}px)`,
marginLeft: `${indent}px`
};
});
function handleClick() {
if (props.editingNodeId || props.addingChildParentId) return;
emit('select', props.module.id);
}
function handleStartEdit() {
emit('edit', props.module);
}
function handleEditConfirm() {
emit('editConfirm', props.module);
}
function handleEditCancel() {
emit('editCancel');
}
function handleStartAddChild() {
emit('addChild', props.module);
}
function handleDelete() {
emit('delete', props.module);
}
function handleAddChildConfirm() {
emit('addChildConfirm');
}
function handleAddChildCancel() {
emit('addChildCancel');
}
</script>
<template>
<div class="module-tree-node">
<div
class="module-tree-item"
:class="{
'is-root': isRootModule,
'is-active': isSelected,
'is-editing': isEditing
}"
:style="indentStyle"
@click="handleClick"
>
<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>
<ElInput
v-else
:model-value="editingName"
size="small"
class="module-tree-item__input"
placeholder="请输入模块名"
@update:model-value="emit('updateEditingName', $event)"
@blur="handleEditConfirm"
@keyup.enter="handleEditConfirm"
@keyup.esc="handleEditCancel"
/>
</div>
<div v-if="!isRootModule && !isEditing" class="module-tree-item__actions">
<ElDropdown trigger="click">
<ElButton text size="small" class="module-tree-item__more-btn">
<icon-mdi-dots-horizontal class="text-14px" />
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-auth="{ code: 'project:product:create', source: 'object' }"
@click="handleStartAddChild"
>
<div class="flex items-center gap-6px">
<icon-ic-round-plus class="text-14px" />
<span>新增子模块</span>
</div>
</ElDropdownItem>
<ElDropdownItem 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-auth="{ code: 'project:product:delete', source: 'object' }"
divided
@click="handleDelete"
>
<div class="flex items-center gap-6px text-error">
<icon-mdi-delete-outline class="text-14px" />
<span>删除</span>
</div>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</div>
<template v-if="hasChildren">
<ModuleTreeNode
v-for="child in module.children"
:key="child.id"
:module="child"
:level="level + 1"
:selected-module-id="selectedModuleId"
:editing-node-id="editingNodeId"
:editing-name="editingName"
:adding-child-parent-id="addingChildParentId"
:new-child-module-name="newChildModuleName"
:root-module-id="rootModuleId"
:module-requirement-count-map="moduleRequirementCountMap"
@select="emit('select', $event)"
@edit="emit('edit', $event)"
@edit-confirm="emit('editConfirm', $event)"
@edit-cancel="emit('editCancel')"
@delete="emit('delete', $event)"
@add-child="emit('addChild', $event)"
@add-child-confirm="emit('addChildConfirm')"
@add-child-cancel="emit('addChildCancel')"
@update-editing-name="emit('updateEditingName', $event)"
@update-new-child-module-name="emit('updateNewChildModuleName', $event)"
/>
</template>
<div
v-if="isAddingChild"
class="module-tree-item module-tree-item--new"
:style="{
width: indentStyle.width,
marginLeft: level === 0 ? '24px' : `calc(24px + ${level * 24}px)`
}"
>
<div class="module-tree-item__icon">
<icon-mdi-folder-plus-outline class="text-16px" />
</div>
<div class="module-tree-item__content">
<ElInput
:model-value="newChildModuleName"
size="small"
class="new-child-module-input module-tree-item__input"
placeholder="请输入模块名"
@update:model-value="emit('updateNewChildModuleName', $event)"
@blur="handleAddChildConfirm"
@keyup.enter="handleAddChildConfirm"
@keyup.esc="handleAddChildCancel"
/>
</div>
</div>
</div>
</template>
<style scoped>
.module-tree-node {
display: flex;
flex-direction: column;
gap: 10px;
}
.module-tree-item {
display: flex;
align-items: center;
gap: 10px;
min-height: 42px;
padding: 0 14px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 14px;
background-color: rgb(248 250 252 / 96%);
color: rgb(71 85 105 / 94%);
font-size: 14px;
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
}
.module-tree-item:hover {
transform: translateY(-1px);
border-color: rgb(148 163 184 / 56%);
}
.module-tree-item.is-active {
border-color: rgb(13 148 136 / 42%);
background-color: rgb(240 253 250 / 98%);
color: rgb(15 118 110 / 96%);
font-weight: 600;
}
.module-tree-item.is-root:not(.is-active) .module-tree-item__icon {
color: rgb(13 148 136 / 80%);
}
.module-tree-item--new {
border-style: dashed;
border-color: rgb(148 163 184 / 56%);
}
.module-tree-item__icon {
display: flex;
align-items: center;
flex-shrink: 0;
color: rgb(100 116 139 / 80%);
}
.module-tree-item__content {
flex: 1;
min-width: 0;
overflow: hidden;
}
.module-tree-item__label {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.module-tree-item__input {
width: 100%;
}
.module-tree-item__input :deep(.el-input__inner) {
height: 28px;
}
.module-tree-item__actions {
display: flex;
align-items: center;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.2s ease;
}
.module-tree-item:hover .module-tree-item__actions {
opacity: 1;
}
.module-tree-item.is-editing .module-tree-item__actions {
opacity: 0;
}
.module-tree-item__more-btn {
padding: 4px;
}
</style>

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import {
type RequirementStatusActionCode,
isRequirementActionNeedProject,
isRequirementActionNeedReviewChoice,
isRequirementActionTerminal
} from '../shared/requirement-master-data';
defineOptions({ name: 'RequirementActionDialog' });
interface Props {
action: Api.Product.RequirementLifecycleAction | null;
requirementTitle: string;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted', payload: { actionCode: string; reason?: string; implementProjectId?: string }): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', { default: false });
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
interface Model {
reviewChoice: string;
implementProjectId: string;
reason: string;
}
const model = ref<Model>({ reviewChoice: '', implementProjectId: '', reason: '' });
const submitting = ref(false);
const actionCode = computed(() => props.action?.actionCode as RequirementStatusActionCode | undefined);
const isClaimAction = computed(() =>
actionCode.value ? isRequirementActionNeedReviewChoice(actionCode.value) : false
);
const isDispatchAction = computed(() => (actionCode.value ? isRequirementActionNeedProject(actionCode.value) : false));
const isTerminalAction = computed(() => (actionCode.value ? isRequirementActionTerminal(actionCode.value) : false));
const dialogTitle = computed(() => {
if (!props.action) return '';
if (isClaimAction.value) return '认领需求';
return props.action.actionName;
});
const reviewChoiceOptions = [
{ label: '需要评审', value: 'claim_to_review', description: '认领后进入评审流程' },
{ label: '不需要评审', value: 'claim_to_dispatch', description: '认领后直接进入分流' }
];
const projectOptions = [{ label: 'NPQS-10086', value: '202642910086' }];
const rules = computed(() => {
const baseRules: Record<string, App.Global.FormRule[]> = {};
if (isClaimAction.value) {
baseRules.reviewChoice = [createRequiredRule('请选择是否需要评审')];
}
if (isDispatchAction.value) {
baseRules.implementProjectId = [createRequiredRule('请选择实现项目')];
}
if (isTerminalAction.value) {
baseRules.reason = [createRequiredRule('请输入状态变更原因')];
}
return baseRules;
});
watch(
() => visible.value,
val => {
if (val) {
model.value = { reviewChoice: '', implementProjectId: '', reason: '' };
}
}
);
async function handleSubmit() {
await validate();
submitting.value = true;
const payload: { actionCode: string; reason?: string; implementProjectId?: string } = {
actionCode: isClaimAction.value ? model.value.reviewChoice : props.action!.actionCode
};
if (isDispatchAction.value) {
payload.implementProjectId = model.value.implementProjectId;
}
if (isTerminalAction.value) {
payload.reason = model.value.reason.trim();
}
emit('submitted', payload);
submitting.value = false;
visible.value = false;
}
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="sm"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElFormItem label="需求标题">
<span class="text-14px">{{ requirementTitle }}</span>
</ElFormItem>
<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">
<div class="flex flex-col gap-2px">
<span>{{ option.label }}</span>
<span class="text-12px text-gray-400">{{ option.description }}</span>
</div>
</ElRadio>
</ElRadioGroup>
</ElFormItem>
<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" />
</ElSelect>
</ElFormItem>
<ElFormItem v-if="isTerminalAction" label="变更原因" prop="reason">
<ElInput
v-model="model.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入状态变更原因(必填)"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,294 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { fetchCreateRequirement, fetchGetRequirementModuleTree } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import MemberSelectOption from './member-select-option.vue';
defineOptions({ name: 'RequirementCreateDialog' });
interface Props {
productId: string;
defaultModuleId?: string;
memberOptions: Api.Product.ProductMember[];
categoryDictCode: string;
priorityDictCode: string;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
const priorityOptions = computed(() => {
return priorityDictData.value.map(item => ({
label: item.label,
value: Number(item.value)
}));
});
interface Model {
title: string;
description: string;
reviewRequired: number;
completionDate: string;
moduleId: string;
category: string;
priority: number | null;
proposerId: string;
currentHandlerUserId: string;
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 reviewRequiredOptions = [
{ label: '不需要', value: 0 },
{ label: '需要', value: 1 }
];
const rules = {
title: [createRequiredRule('请输入需求标题')],
category: [createRequiredRule('请选择分类')],
priority: [createRequiredRule('请选择优先级')],
proposerId: [createRequiredRule('请选择提出人')],
currentHandlerUserId: [createRequiredRule('请选择负责人')],
completionDate: [createRequiredRule('请选择预期完成时间')]
} satisfies Record<string, App.Global.FormRule[]>;
function createDefaultModel(): Model {
return {
title: '',
description: '',
reviewRequired: 0,
completionDate: '',
moduleId: props.defaultModuleId || '0',
category: '功能需求',
priority: 1,
proposerId: '',
currentHandlerUserId: '',
sort: 0
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function closeDialog() {
visible.value = false;
}
async function handleSubmit() {
await validate();
if (!props.productId) {
return;
}
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),
category: model.value.category,
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
proposerId: model.value.proposerId,
currentHandlerUserId: model.value.currentHandlerUserId,
implementProjectId: null,
completionDate: model.value.completionDate,
sort: model.value.sort
};
submitting.value = true;
const result = await fetchCreateRequirement(payload);
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('需求新增成功');
closeDialog();
emit('submitted');
}
async function loadModuleTree() {
if (!props.productId) {
moduleTree.value = [];
return;
}
const { error, data } = await fetchGetRequirementModuleTree(props.productId);
if (error || !data) {
moduleTree.value = [];
return;
}
moduleTree.value = data;
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
model.value = createDefaultModel();
await loadModuleTree();
await nextTick();
formRef.value?.clearValidate();
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="新增需求"
preset="lg"
: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"
/>
</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>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,433 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { fetchGetRequirement, fetchGetRequirementModuleTree, fetchUpdateRequirement } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import ReadonlyField from '@/components/custom/readonly-field.vue';
import MemberSelectOption from './member-select-option.vue';
defineOptions({ name: 'RequirementDetailDialog' });
type DialogMode = 'view' | 'edit';
interface Props {
mode: DialogMode;
requirement: Api.Product.Requirement | null;
productId: string;
memberOptions: Api.Product.ProductMember[];
categoryDictCode: string;
priorityDictCode: string;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted', requirementId?: string): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { getLabel: getCategoryLabel } = useDict(() => props.categoryDictCode);
const { getLabel: getPriorityLabel, enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
const priorityOptions = computed(() => {
return priorityDictData.value.map(item => ({
label: item.label,
value: Number(item.value)
}));
});
interface Model {
title: string;
description: string;
reviewRequired: number;
completionDate: string;
moduleId: string;
category: string;
priority: number | null;
proposerId: string;
currentHandlerUserId: string;
implementProjectId: string | null;
sort: number;
lastStatusReason: string;
}
const loading = ref(false);
const submitting = ref(false);
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
const model = ref<Model>(createDefaultModel());
const isViewMode = computed(() => props.mode === 'view');
const isEditMode = computed(() => props.mode === 'edit');
const dialogTitle = computed(() => {
if (isViewMode.value) {
return '查看需求';
}
return '编辑需求';
});
const memberUserOptions = computed(() => {
return props.memberOptions.filter(m => m.status === 0);
});
const memberLabelMap = computed(() => {
return new Map(memberUserOptions.value.map(item => [String(item.userId), item.userNickname]));
});
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);
if (module.children?.length) {
traverse(module.children);
}
}
}
traverse(moduleTree.value);
return map;
});
const moduleTreeProps = {
label: 'moduleName',
value: 'id',
children: 'children'
};
const reviewRequiredOptions = [
{ label: '不需要', value: 0 },
{ label: '需要', value: 1 }
];
const rules = computed(() => {
const baseRules: Record<string, App.Global.FormRule[]> = {
title: isEditMode.value ? [createRequiredRule('请输入需求标题')] : [],
category: isEditMode.value ? [createRequiredRule('请选择分类')] : [],
priority: isEditMode.value ? [createRequiredRule('请选择优先级')] : [],
proposerId: isEditMode.value ? [createRequiredRule('请选择提出人')] : [],
currentHandlerUserId: isEditMode.value ? [createRequiredRule('请选择负责人')] : [],
completionDate: isEditMode.value ? [createRequiredRule('请选择预期完成时间')] : []
};
return baseRules;
});
function createDefaultModel(): Model {
return {
title: '',
description: '',
reviewRequired: 0,
completionDate: '',
moduleId: '0',
category: '',
priority: 1,
proposerId: '',
currentHandlerUserId: '',
implementProjectId: null,
sort: 0,
lastStatusReason: ''
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function closeDialog() {
visible.value = false;
}
async function handleSubmit() {
await validate();
if (!props.productId || !props.requirement?.id) {
return;
}
submitting.value = true;
const updatePayload: Api.Product.UpdateRequirementParams = {
id: props.requirement.id,
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),
category: model.value.category,
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
proposerId: model.value.proposerId,
currentHandlerUserId: model.value.currentHandlerUserId,
implementProjectId: model.value.implementProjectId,
completionDate: model.value.completionDate,
sort: model.value.sort
};
const { error } = await fetchUpdateRequirement(updatePayload);
submitting.value = false;
if (error) {
return;
}
window.$message?.success('需求更新成功');
closeDialog();
emit('submitted', props.requirement.id);
}
async function loadModuleTree() {
if (!props.productId) {
moduleTree.value = [];
return;
}
const { error, data } = await fetchGetRequirementModuleTree(props.productId);
if (error || !data) {
moduleTree.value = [];
return;
}
moduleTree.value = data;
}
async function loadRequirementDetail() {
if (!props.productId || !props.requirement?.id) {
return;
}
loading.value = true;
const { error, data } = await fetchGetRequirement(props.requirement.id, props.productId);
loading.value = false;
if (error || !data) {
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 || ''
};
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
await loadModuleTree();
if (props.requirement?.id) {
await loadRequirementDetail();
}
await nextTick();
formRef.value?.clearValidate();
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="lg"
: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">
<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') : '--'"
/>
</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">
<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">
<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"
>
<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>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.readonly-textarea {
box-sizing: border-box;
width: 100%;
min-height: 100px;
padding: 8px 12px;
border-radius: 4px;
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;
color: rgb(51 65 85 / 96%);
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@@ -0,0 +1,451 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import {
fetchCreateRequirementModule,
fetchDeleteRequirementModule,
fetchGetRequirementModuleTree,
fetchUpdateRequirementModule
} from '@/service/api';
import { useCurrentProduct } from '../../shared/use-current-product';
import ModuleTreeNode from './module-tree-node.vue';
defineOptions({ name: 'RequirementModuleTree' });
interface Props {
requirementTree?: Api.Product.Requirement[];
}
const props = withDefaults(defineProps<Props>(), {
requirementTree: () => []
});
interface Emits {
(e: 'select', moduleId: string | undefined): void;
(e: 'refresh'): void;
}
const emit = defineEmits<Emits>();
const { currentObjectId } = useCurrentProduct();
const loading = ref(false);
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
const selectedModuleId = ref<string | undefined>(undefined);
const rootModule = computed<Api.Product.RequirementModule | null>(() => {
if (moduleTree.value.length === 0) return null;
return moduleTree.value[0];
});
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 moduleRequirementCountMap = computed(() => {
const countMap = new Map<string, number>();
function countRequirementsByModule(nodes: Api.Product.Requirement[]): void {
for (const node of nodes) {
const currentCount = countMap.get(node.moduleId) || 0;
countMap.set(node.moduleId, currentCount + 1);
if (node.children?.length) {
countRequirementsByModule(node.children);
}
}
}
if (props.requirementTree?.length) {
countRequirementsByModule(props.requirementTree);
}
return countMap;
});
async function loadModuleTree() {
if (!currentObjectId.value) {
moduleTree.value = [];
return;
}
loading.value = true;
const { error, data } = await fetchGetRequirementModuleTree(currentObjectId.value);
loading.value = false;
if (error || !data) {
moduleTree.value = [];
return;
}
moduleTree.value = data;
if (data.length > 0 && !selectedModuleId.value) {
selectedModuleId.value = data[0].id;
emit('select', data[0].id);
}
}
function handleNodeSelect(moduleId: string) {
if (editingNodeId.value || addingChildParentId.value) return;
selectedModuleId.value = moduleId;
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;
nextTick(() => {
const input = document.querySelector('.module-tree-item.is-editing .el-input__inner') as HTMLInputElement;
input?.focus();
input?.select();
});
}
async function handleEditConfirm(module: Api.Product.RequirementModule) {
const name = editingName.value.trim();
editingNodeId.value = undefined;
if (!name || name === module.moduleName) {
return;
}
await handleUpdateModuleName(module, name);
}
function handleEditCancel() {
editingNodeId.value = undefined;
}
async function handleUpdateModuleName(module: Api.Product.RequirementModule, name: string) {
if (!currentObjectId.value) return;
const { error } = await fetchUpdateRequirementModule({
id: module.id,
productId: currentObjectId.value,
parentId: module.parentId,
moduleName: name,
remark: module.remark,
icon: module.icon,
sort: module.sort
});
if (error) return;
window.$message?.success('模块名称更新成功');
await loadModuleTree();
emit('refresh');
}
function handleStartAddChild(module: Api.Product.RequirementModule) {
if (addingTopModule.value || addingChildParentId.value) return;
addingChildParentId.value = module.id;
newChildModuleName.value = '';
nextTick(() => {
const input = document.querySelector('.new-child-module-input input') as HTMLInputElement;
input?.focus();
});
}
async function handleAddChildConfirm() {
const name = newChildModuleName.value.trim();
const parentId = addingChildParentId.value;
addingChildParentId.value = undefined;
newChildModuleName.value = '';
if (!name) {
return;
}
if (!currentObjectId.value) {
return;
}
if (!parentId) {
return;
}
const { error } = await fetchCreateRequirementModule({
id: undefined,
productId: currentObjectId.value,
parentId,
moduleName: name,
remark: null,
icon: null,
sort: 0
});
if (error) {
return;
}
window.$message?.success('子模块新增成功');
await loadModuleTree();
emit('refresh');
}
function handleAddChildCancel() {
addingChildParentId.value = undefined;
newChildModuleName.value = '';
}
async function handleDeleteModule(module: Api.Product.RequirementModule) {
if (!currentObjectId.value) return;
try {
await ElMessageBox.confirm(
`确定要删除模块 "${module.moduleName}" 吗?该模块下的所有需求将被一并删除。`,
'删除确认',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning'
}
);
} catch {
return;
}
const { error } = await fetchDeleteRequirementModule({
id: module.id,
productId: currentObjectId.value
});
if (error) return;
window.$message?.success('模块删除成功');
if (selectedModuleId.value === module.id) {
const rootId = rootModule.value?.id || '';
selectedModuleId.value = rootId;
emit('select', rootId);
}
await loadModuleTree();
emit('refresh');
}
watch(
() => currentObjectId.value,
async id => {
if (id) {
selectedModuleId.value = '';
await loadModuleTree();
} else {
moduleTree.value = [];
}
},
{ immediate: true }
);
defineExpose({
loadModuleTree,
selectedModuleId
});
</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>
<div class="module-tree-list">
<template v-for="data in moduleTree" :key="data.id">
<ModuleTreeNode
:module="data"
:level="0"
:selected-module-id="selectedModuleId"
:editing-node-id="editingNodeId"
:editing-name="editingName"
:adding-child-parent-id="addingChildParentId"
:new-child-module-name="newChildModuleName"
:root-module-id="rootModule?.id"
:module-requirement-count-map="moduleRequirementCountMap"
@select="handleNodeSelect"
@edit="handleStartEdit"
@edit-confirm="handleEditConfirm"
@edit-cancel="handleEditCancel"
@delete="handleDeleteModule"
@add-child="handleStartAddChild"
@add-child-confirm="handleAddChildConfirm"
@add-child-cancel="handleAddChildCancel"
@update-editing-name="editingName = $event"
@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>
</template>
<style scoped>
.requirement-module-tree-wrapper {
display: flex;
flex-direction: column;
gap: 14px;
}
.module-tree-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.module-tree-header__title {
color: rgb(15 23 42 / 94%);
font-size: 15px;
font-weight: 700;
}
.module-tree-list {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.module-tree-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 42px;
padding: 0 14px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 14px;
background-color: rgb(248 250 252 / 96%);
color: rgb(71 85 105 / 94%);
font-size: 14px;
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
}
.module-tree-item:hover {
transform: translateY(-1px);
border-color: rgb(148 163 184 / 56%);
}
.module-tree-item--new {
border-style: dashed;
border-color: rgb(148 163 184 / 56%);
}
.module-tree-item__icon {
display: flex;
align-items: center;
flex-shrink: 0;
color: rgb(100 116 139 / 80%);
}
.module-tree-item__content {
flex: 1;
min-width: 0;
overflow: hidden;
}
.module-tree-item__input {
width: 100%;
}
.module-tree-item__input :deep(.el-input__inner) {
height: 28px;
}
</style>

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { RDMS_REQ_SOURCE_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchGetRequirementStatusDict } from '@/service/api';
import DictSelect from '@/components/custom/dict-select.vue';
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
import MemberSelectOption from './member-select-option.vue';
defineOptions({ name: 'RequirementSearch' });
interface MemberUserOption {
id: string;
nickname: string;
roleName?: string;
}
interface Props {
memberOptions: MemberUserOption[];
categoryDictCode: string;
priorityDictCode: string;
}
defineProps<Props>();
interface Emits {
(e: 'reset'): void;
(e: 'search'): void;
}
const emit = defineEmits<Emits>();
const model = defineModel<Api.Product.RequirementSearchParams>('model', { required: true });
const requirementStatusOptions = ref<Array<{ label: string; value: string }>>([]);
async function loadStatusOptions() {
const { error, data } = await fetchGetRequirementStatusDict();
if (error || !data) {
requirementStatusOptions.value = [];
return;
}
requirementStatusOptions.value = data.map(item => ({
label: item.statusName,
value: item.statusCode
}));
}
function reset() {
emit('reset');
}
function search() {
emit('search');
}
onMounted(async () => {
await loadStatusOptions();
});
</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.title" clearable placeholder="输入需求标题" />
</ElFormItem>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="分类">
<DictSelect
v-model="model.category"
:dict-code="categoryDictCode"
clearable
filterable
placeholder="筛选分类"
/>
</ElFormItem>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="优先级">
<DictSelect v-model="model.priority" :dict-code="priorityDictCode" clearable placeholder="筛选优先级" />
</ElFormItem>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="状态">
<ElSelect v-model="model.statusCode" clearable placeholder="筛选状态">
<ElOption
v-for="item in requirementStatusOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="负责人">
<ElSelect
v-model="model.currentHandlerUserId"
clearable
filterable
placeholder="筛选负责人"
:filter-method="(val: string) => val"
>
<ElOption v-for="item in memberOptions" :key="item.id" :label="item.nickname" :value="item.id">
<MemberSelectOption :nickname="item.nickname" :role-name="item.roleName || ''" />
</ElOption>
</ElSelect>
</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>
</ElCol>
</TableSearchPanel>
</template>
<style scoped></style>

View File

@@ -0,0 +1,248 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { fetchSplitRequirement } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import MemberSelectOption from './member-select-option.vue';
defineOptions({ name: 'RequirementSplitDialog' });
interface Props {
parentRequirement: Api.Product.Requirement | null;
productId: string;
memberOptions: Api.Product.ProductMember[];
categoryDictCode: string;
priorityDictCode: string;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
const priorityOptions = computed(() => {
return priorityDictData.value.map(item => ({
label: item.label,
value: Number(item.value)
}));
});
interface Model {
title: string;
description: string;
reviewRequired: number;
category: string;
priority: number | null;
currentHandlerUserId: string;
completionDate: string;
sort: number;
}
const submitting = ref(false);
const loading = ref(false);
const model = ref<Model>(createDefaultModel());
const memberUserOptions = computed(() => {
return props.memberOptions.filter(m => m.status === 0);
});
const reviewRequiredOptions = [
{ label: '不需要', value: 0 },
{ label: '需要', value: 1 }
];
const rules = {
title: [createRequiredRule('请输入子需求标题')],
category: [createRequiredRule('请选择分类')],
priority: [createRequiredRule('请选择优先级')],
currentHandlerUserId: [createRequiredRule('请选择负责人')],
completionDate: [createRequiredRule('请选择预期完成时间')]
} satisfies Record<string, App.Global.FormRule[]>;
function createDefaultModel(): Model {
return {
title: '',
description: '',
reviewRequired: 0,
category: '',
priority: 1,
currentHandlerUserId: '',
completionDate: '',
sort: 0
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function closeDialog() {
visible.value = false;
}
async function handleSubmit() {
await validate();
if (!props.productId || !props.parentRequirement?.id) {
return;
}
const payload: Api.Product.SplitRequirementParams = {
parentId: props.parentRequirement.id,
productId: props.productId,
moduleId: props.parentRequirement.moduleId,
proposerId: props.parentRequirement.proposerId,
title: model.value.title.trim(),
description: getNullableText(model.value.description),
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,
sort: model.value.sort
};
console.log('payload', payload);
submitting.value = true;
const result = await fetchSplitRequirement(payload);
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('需求拆分成功');
closeDialog();
emit('submitted');
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
model.value = createDefaultModel();
await nextTick();
formRef.value?.clearValidate();
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="拆分需求"
preset="lg"
:loading="loading"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElAlert
v-if="parentRequirement"
:title="`正在拆分需求:${parentRequirement.title}`"
type="info"
:closable="false"
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"
/>
</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>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>

View File

@@ -0,0 +1,98 @@
import { transformRecordToOption } from '@/utils/common';
export type RequirementStatusActionCode =
| 'claim_to_review'
| 'claim_to_dispatch'
| 'reject'
| 'to_dispatch'
| 'dispatch'
| 'cancel'
| 'accept'
| 'close';
export const requirementStatusRecord: Record<Api.Product.RequirementStatusCode, string> = {
pending_confirm: '待确认',
pending_review: '待评审',
pending_dispatch: '待分流',
implementing: '实施中',
accepted: '已验收',
closed: '已关闭',
rejected: '已拒绝',
cancelled: '已取消'
};
export const requirementStatusOptions = transformRecordToOption(requirementStatusRecord);
export const requirementStatusActionRecord: Record<RequirementStatusActionCode, string> = {
claim_to_review: '认领',
claim_to_dispatch: '认领',
reject: '拒绝',
to_dispatch: '评审通过',
dispatch: '分流',
cancel: '取消',
accept: '验收通过',
close: '关闭'
};
export function getRequirementStatusLabel(status: Api.Product.RequirementStatusCode) {
return requirementStatusRecord[status];
}
export function getRequirementStatusTagType(status: Api.Product.RequirementStatusCode): UI.ThemeColor {
const statusTagTypeMap: Record<Api.Product.RequirementStatusCode, UI.ThemeColor> = {
pending_confirm: 'info',
pending_review: 'warning',
pending_dispatch: 'primary',
implementing: 'primary',
accepted: 'success',
closed: 'info',
rejected: 'danger',
cancelled: 'danger'
};
return statusTagTypeMap[status];
}
export function getRequirementActionLabel(actionCode: RequirementStatusActionCode) {
return requirementStatusActionRecord[actionCode];
}
export function getRequirementActionTagType(
actionCode: RequirementStatusActionCode
): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
const actionTagTypeMap: Record<RequirementStatusActionCode, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
claim_to_review: 'primary',
claim_to_dispatch: 'primary',
reject: 'danger',
to_dispatch: 'success',
dispatch: 'primary',
cancel: 'danger',
accept: 'success',
close: 'info'
};
return actionTagTypeMap[actionCode];
}
export function isRequirementActionTerminal(actionCode: RequirementStatusActionCode) {
const terminalActions: RequirementStatusActionCode[] = ['reject', 'cancel', 'close'];
return terminalActions.includes(actionCode);
}
export function isRequirementActionNeedProject(actionCode: RequirementStatusActionCode) {
return actionCode === 'dispatch';
}
export function isRequirementActionNeedReviewChoice(actionCode: RequirementStatusActionCode) {
return actionCode === 'claim_to_review' || actionCode === 'claim_to_dispatch';
}
export function getRequirementActionDisplayName(action: Api.Product.RequirementLifecycleAction): string {
const code = action.actionCode as RequirementStatusActionCode;
if (code === 'claim_to_review' || code === 'claim_to_dispatch') {
return '认领';
}
return action.actionName;
}

View File

@@ -0,0 +1,505 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useMediaQuery } from '@vueuse/core';
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
import { objectContextDomainConfigs } from '@/constants/object-context';
import {
fetchChangeProductStatus,
fetchCreateProductMember,
fetchDeleteProduct,
fetchGetProductMembers,
fetchGetProductSettings,
fetchGetRoleSimpleList,
fetchGetUserSimpleList,
fetchInactiveProductMember,
fetchUpdateProductMember,
fetchUpdateProductSettingBaseInfo
} from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useObjectContextStore } from '@/store/modules/object-context';
import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router';
import { useCurrentProduct } from '../shared/use-current-product';
import BaseInfoDialog from './modules/base-info-dialog.vue';
import MemberOperateDialog from './modules/member-operate-dialog.vue';
import MemberRemoveDialog from './modules/member-remove-dialog.vue';
import ProductDeleteDialog from './modules/product-delete-dialog.vue';
import SettingAnchorNav from './modules/setting-anchor-nav.vue';
import SettingBaseInfoCard from './modules/setting-base-info-card.vue';
import SettingDangerZone from './modules/setting-danger-zone.vue';
import SettingLifecyclePanel from './modules/setting-lifecycle-panel.vue';
import SettingTeamPanel from './modules/setting-team-panel.vue';
import StatusActionDialog from './modules/status-action-dialog.vue';
import {
type ProductSettingSectionKey,
canManageProductTeam,
getProductSettingSectionKeys,
resolveVisibleProductSettingSectionKey,
resolveVisibleProductSettingSections
} from './shared';
defineOptions({ name: 'ProductSetting' });
const authStore = useAuthStore();
const objectContextStore = useObjectContextStore();
const themeStore = useThemeStore();
const { routerPush } = useRouterPush();
const { currentObjectId, currentProduct } = useCurrentProduct();
const isCompactLayout = useMediaQuery('(max-width: 1280px)');
const productDomainConfig = objectContextDomainConfigs.find(config => config.domainKey === 'product') || null;
const allAnchorItems = [
{ key: 'base-info', label: '基础信息' },
{ key: 'team', label: '团队管理' },
{ key: 'lifecycle', label: '生命周期管理' },
{ key: 'danger', label: '危险操作' }
] as const;
const anchorLabelMap = new Map(allAnchorItems.map(item => [item.key, item.label]));
const sectionIdMap: Record<ProductSettingSectionKey, string> = {
'base-info': 'product-setting-base-info',
team: 'product-setting-team',
lifecycle: 'product-setting-lifecycle',
danger: 'product-setting-danger'
};
const activeAnchorKey = ref<ProductSettingSectionKey>('base-info');
const pageLoading = ref(false);
const memberLoading = ref(false);
const baseInfoVisible = ref(false);
const memberOperateVisible = ref(false);
const memberRemoveVisible = ref(false);
const statusActionVisible = ref(false);
const deleteVisible = ref(false);
const memberOperateMode = ref<'create' | 'edit'>('create');
const selectedMember = ref<Api.Product.ProductMember | null>(null);
const selectedAction = ref<Api.Product.ProductLifecycleAction | null>(null);
const settings = ref<Api.Product.ProductSettings | null>(null);
const members = ref<Api.Product.ProductMember[]>([]);
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
const userOptions = ref<Api.SystemManage.UserSimple[]>([]);
const currentManager = computed(() => members.value.find(item => item.managerFlag && item.status === 0) || null);
const baseInfo = computed(() => settings.value?.baseInfo || null);
const lifecycle = computed(() => settings.value?.lifecycle || null);
const canManageTeam = computed(() =>
canManageProductTeam({
buttonCodes: objectContextStore.buttonCodes,
loginUserId: authStore.userInfo.userId,
currentManagerUserId: currentManager.value?.userId
})
);
const visibleSectionKeys = computed(() =>
resolveVisibleProductSettingSections(getProductSettingSectionKeys(), objectContextStore.buttonCodes)
);
const anchorItems = computed(() =>
visibleSectionKeys.value.map(key => ({
key,
label: anchorLabelMap.get(key) || key
}))
);
const layoutScrollTarget = `#${LAYOUT_SCROLL_EL_ID}`;
const anchorAffixOffset = computed(() => {
const fixedTopInset = themeStore.fixedHeaderAndTab
? themeStore.header.height + (themeStore.tabVisible ? themeStore.tab.height : 0)
: 0;
return fixedTopInset + 16;
});
const anchorShellInlineStyle = computed(() => ({
maxHeight: isCompactLayout.value ? '' : `calc(100vh - ${anchorAffixOffset.value + 16}px)`
}));
const showLifecycleSection = computed(() => visibleSectionKeys.value.includes('lifecycle'));
const showDangerSection = computed(() => visibleSectionKeys.value.includes('danger'));
async function loadSettings() {
if (!currentObjectId.value) {
settings.value = null;
return;
}
const { error, data } = await fetchGetProductSettings(currentObjectId.value);
if (error || !data) {
settings.value = null;
return;
}
settings.value = data;
}
async function loadMembers() {
if (!currentObjectId.value) {
members.value = [];
return;
}
memberLoading.value = true;
const { error, data } = await fetchGetProductMembers(currentObjectId.value);
memberLoading.value = false;
if (error || !data) {
members.value = [];
return;
}
members.value = data;
}
async function loadRoleOptions() {
const { error, data } = await fetchGetRoleSimpleList({
scopeType: 'object',
objectType: 'product'
});
if (error || !data) {
roleOptions.value = [];
return;
}
roleOptions.value = data;
}
async function loadUserOptions() {
const { error, data } = await fetchGetUserSimpleList();
if (error || !data) {
userOptions.value = [];
return;
}
userOptions.value = data;
}
async function refreshContextSummary() {
if (!productDomainConfig || !currentObjectId.value) {
return;
}
await objectContextStore.enterContext(productDomainConfig, currentObjectId.value);
}
async function loadPageData() {
if (!currentObjectId.value) {
return;
}
pageLoading.value = true;
await Promise.all([loadSettings(), loadMembers(), loadRoleOptions(), loadUserOptions()]);
pageLoading.value = false;
}
function scrollToSection(key: string) {
if (!(key in sectionIdMap)) {
return;
}
const resolvedKey = key as ProductSettingSectionKey;
if (!visibleSectionKeys.value.includes(resolvedKey)) {
return;
}
activeAnchorKey.value = resolvedKey;
const target = document.getElementById(sectionIdMap[resolvedKey]);
target?.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
function openCreateMember() {
memberOperateMode.value = 'create';
selectedMember.value = null;
memberOperateVisible.value = true;
}
function openEditMember(member: Api.Product.ProductMember) {
memberOperateMode.value = 'edit';
selectedMember.value = member;
memberOperateVisible.value = true;
}
function openRemoveMember(member: Api.Product.ProductMember) {
selectedMember.value = member;
memberRemoveVisible.value = true;
}
function openLifecycleAction(action: Api.Product.ProductLifecycleAction) {
selectedAction.value = action;
statusActionVisible.value = true;
}
async function handleSubmitBaseInfo(payload: Api.Product.UpdateProductSettingBaseInfoParams) {
if (!currentObjectId.value) {
return;
}
const result = await fetchUpdateProductSettingBaseInfo(currentObjectId.value, payload);
if (result.error) {
return;
}
window.$message?.success('基础信息更新成功');
baseInfoVisible.value = false;
await Promise.all([loadSettings(), refreshContextSummary()]);
}
async function handleSubmitMemberOperate(event: {
mode: 'create' | 'edit';
memberId?: string;
managerChanged: boolean;
payload: Api.Product.CreateProductMemberParams | Api.Product.UpdateProductMemberParams;
}) {
if (!currentObjectId.value) {
return;
}
const result =
event.mode === 'create'
? await fetchCreateProductMember(currentObjectId.value, event.payload as Api.Product.CreateProductMemberParams)
: await fetchUpdateProductMember(
currentObjectId.value,
event.memberId || '',
event.payload as Api.Product.UpdateProductMemberParams
);
if (result.error) {
return;
}
window.$message?.success(event.mode === 'create' ? '成员新增成功' : '成员角色调整成功');
memberOperateVisible.value = false;
await Promise.all([loadMembers(), loadSettings()]);
if (event.managerChanged) {
await refreshContextSummary();
}
}
async function handleSubmitRemoveMember(payload: Api.Product.InactiveProductMemberParams) {
if (!currentObjectId.value || !selectedMember.value?.id) {
return;
}
const result = await fetchInactiveProductMember(currentObjectId.value, selectedMember.value.id, payload);
if (result.error) {
return;
}
window.$message?.success('成员移出成功');
memberRemoveVisible.value = false;
await Promise.all([loadMembers(), loadSettings()]);
}
async function handleSubmitLifecycleAction(payload: Api.Product.ChangeProductStatusParams) {
if (!currentObjectId.value || !selectedAction.value) {
return;
}
const result = await fetchChangeProductStatus({
...payload,
id: currentObjectId.value
});
if (result.error) {
return;
}
window.$message?.success(`${selectedAction.value.actionName}成功`);
statusActionVisible.value = false;
await Promise.all([loadSettings(), refreshContextSummary()]);
}
async function handleSubmitDelete(payload: Api.Product.DeleteProductParams) {
const result = await fetchDeleteProduct(payload);
if (result.error) {
return;
}
window.$message?.success('产品删除成功');
deleteVisible.value = false;
objectContextStore.clearContext();
await routerPush({
path: '/product/list'
});
}
watch(
visibleSectionKeys,
sectionKeys => {
activeAnchorKey.value = resolveVisibleProductSettingSectionKey(activeAnchorKey.value, sectionKeys);
},
{
immediate: true
}
);
watch(
() => currentObjectId.value,
async objectId => {
if (!objectId) {
settings.value = null;
members.value = [];
return;
}
await loadPageData();
},
{ immediate: true }
);
</script>
<template>
<div v-loading="pageLoading" class="product-setting-page">
<div class="product-setting-page__body">
<div class="product-setting-page__aside">
<div v-if="isCompactLayout" class="product-setting-page__aside-shell" :style="anchorShellInlineStyle">
<SettingAnchorNav :items="anchorItems" :active-key="activeAnchorKey" @select="scrollToSection" />
</div>
<ElAffix
v-else
class="product-setting-page__aside-affix"
:offset="anchorAffixOffset"
:target="layoutScrollTarget"
teleported
>
<div class="product-setting-page__aside-shell" :style="anchorShellInlineStyle">
<SettingAnchorNav :items="anchorItems" :active-key="activeAnchorKey" @select="scrollToSection" />
</div>
</ElAffix>
</div>
<div class="product-setting-page__content">
<section :id="sectionIdMap['base-info']" class="product-setting-page__section">
<SettingBaseInfoCard :base-info="baseInfo" @edit="baseInfoVisible = true" />
</section>
<section :id="sectionIdMap.team" class="product-setting-page__section">
<SettingTeamPanel
:members="members"
:role-options="roleOptions"
:loading="memberLoading"
:readonly="!canManageTeam"
@create="openCreateMember"
@edit="openEditMember"
@remove="openRemoveMember"
/>
</section>
<section v-if="showLifecycleSection" :id="sectionIdMap.lifecycle" class="product-setting-page__section">
<SettingLifecyclePanel :lifecycle="lifecycle" @action="openLifecycleAction" />
</section>
<section v-if="showDangerSection" :id="sectionIdMap.danger" class="product-setting-page__section">
<SettingDangerZone
:product-name="baseInfo?.name || currentProduct?.name || ''"
@delete="deleteVisible = true"
/>
</section>
</div>
</div>
<BaseInfoDialog v-model:visible="baseInfoVisible" :base-info="baseInfo" @submit="handleSubmitBaseInfo" />
<MemberOperateDialog
v-model:visible="memberOperateVisible"
:mode="memberOperateMode"
:member="selectedMember"
:current-manager="currentManager"
:role-options="roleOptions"
:user-options="userOptions"
@submit="handleSubmitMemberOperate"
/>
<MemberRemoveDialog
v-model:visible="memberRemoveVisible"
:member="selectedMember"
@submit="handleSubmitRemoveMember"
/>
<StatusActionDialog
v-model:visible="statusActionVisible"
:action="selectedAction"
@submit="handleSubmitLifecycleAction"
/>
<ProductDeleteDialog
v-model:visible="deleteVisible"
:product-id="currentObjectId"
:product-name="baseInfo?.name || currentProduct?.name || ''"
@submit="handleSubmitDelete"
/>
</div>
</template>
<style scoped>
.product-setting-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.product-setting-page__body {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.product-setting-page__aside {
min-width: 0;
align-self: stretch;
}
.product-setting-page__aside-affix {
display: block;
width: 100%;
}
.product-setting-page__aside-shell {
min-height: 100%;
padding: 18px 16px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 20px;
background:
radial-gradient(circle at top left, rgb(15 118 110 / 7%), transparent 34%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
box-sizing: border-box;
overflow-y: auto;
scrollbar-gutter: stable;
}
.product-setting-page__content {
display: flex;
flex-direction: column;
gap: 16px;
}
.product-setting-page__section {
scroll-margin-top: 16px;
}
@media (width <= 1280px) {
.product-setting-page__body {
grid-template-columns: 1fr;
}
.product-setting-page__aside-shell {
min-height: auto;
overflow: visible;
}
}
</style>

View File

@@ -0,0 +1,218 @@
<script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { useDict } from '@/hooks/business/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import { getProductBaseInfoReadonlyMessage, isProductBaseInfoEditable } from '../shared';
defineOptions({ name: 'BaseInfoDialog' });
interface Props {
baseInfo: Api.Product.ProductSettingBaseInfo | null;
}
interface Emits {
(e: 'submit', payload: Api.Product.UpdateProductSettingBaseInfoParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const model = reactive<Api.Product.UpdateProductSettingBaseInfoParams>({
directionCode: '',
name: '',
description: ''
});
const baseInfoEditable = computed(() => isProductBaseInfoEditable(props.baseInfo?.statusCode));
const readonlyMessage = computed(() => getProductBaseInfoReadonlyMessage(props.baseInfo?.statusCode));
const confirmDisabled = computed(() => {
if (!props.baseInfo) {
return true;
}
return !baseInfoEditable.value;
});
const directionDisplayName = computed(() => {
const directionCode = props.baseInfo?.directionCode;
if (!directionCode) {
return '';
}
return getDirectionLabel(directionCode, directionCode);
});
const rules = {
directionCode: [createRequiredRule('请选择产品方向')],
name: [createRequiredRule('请输入产品名称')]
} satisfies Record<string, App.Global.FormRule[]>;
async function handleConfirm() {
if (confirmDisabled.value) {
return;
}
await validate();
emit('submit', {
directionCode: model.directionCode,
name: model.name.trim(),
description: model.description?.trim() || null
});
}
watch(
() => visible.value,
async value => {
if (!value || !props.baseInfo) {
return;
}
model.directionCode = props.baseInfo.directionCode || '';
model.name = props.baseInfo.name || '';
model.description = props.baseInfo.description || '';
await nextTick();
formRef.value?.clearValidate();
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="编辑基础信息"
preset="lg"
:confirm-disabled="confirmDisabled"
@confirm="handleConfirm"
>
<ElAlert v-if="readonlyMessage" :title="readonlyMessage" type="warning" :closable="false" class="mb-16px" />
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="产品编码">
<ElInput :model-value="baseInfo?.code || ''" readonly class="base-info-dialog__readonly-input" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem>
<template #label>
<span class="business-form-label-with-tip">
<ElTooltip
content="如需调整产品经理,请到产品内的团队管理处处理。"
popper-class="business-form-label-tooltip"
placement="top-start"
>
<span class="business-form-label-tip">
<icon-fe:question />
</span>
</ElTooltip>
<span>产品经理</span>
</span>
</template>
<ElInput
:model-value="baseInfo?.managerUserNickname || baseInfo?.managerUserId || ''"
readonly
class="base-info-dialog__readonly-input"
/>
</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
v-if="baseInfoEditable"
v-model="model.description"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入产品描述"
/>
<ElInput
v-else
:model-value="model.description"
type="textarea"
:rows="4"
readonly
class="base-info-dialog__readonly-input"
placeholder="未填写产品描述"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
:deep(.base-info-dialog__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
}
:deep(.base-info-dialog__readonly-input .el-textarea__inner) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
resize: none;
}
:deep(.base-info-dialog__readonly-input .el-input__wrapper:hover),
:deep(.base-info-dialog__readonly-input.is-focus .el-input__wrapper),
:deep(.base-info-dialog__readonly-input .el-textarea__inner:hover),
:deep(.base-info-dialog__readonly-input .el-textarea__inner:focus) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.base-info-dialog__readonly-input .el-input__inner),
:deep(.base-info-dialog__readonly-input .el-textarea__inner) {
color: rgb(51 65 85 / 96%);
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
</style>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import { getPreviousManagerRoleOptions, shouldRequireManagerHandover } from '../shared';
defineOptions({ name: 'MemberOperateDialog' });
type OperateMode = 'create' | 'edit';
interface Props {
mode: OperateMode;
member: Api.Product.ProductMember | null;
currentManager: Api.Product.ProductMember | null;
roleOptions: Api.SystemManage.RoleSimple[];
userOptions: Api.SystemManage.UserSimple[];
}
interface SubmitPayload {
mode: OperateMode;
memberId?: string;
managerChanged: boolean;
payload: Api.Product.CreateProductMemberParams | Api.Product.UpdateProductMemberParams;
}
interface Emits {
(e: 'submit', payload: SubmitPayload): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
interface Model {
userId: string;
roleId: string;
remark: string;
reason: string;
previousManagerRoleId: string;
}
const model = reactive<Model>({
userId: '',
roleId: '',
remark: '',
reason: '',
previousManagerRoleId: ''
});
const dialogTitle = computed(() => (props.mode === 'create' ? '新增团队成员' : '调整成员角色'));
const selectedUserId = computed(() => (props.mode === 'create' ? model.userId : props.member?.userId || ''));
const showManagerHandover = computed(() => {
return (
shouldRequireManagerHandover(model.roleId, props.currentManager) &&
Boolean(selectedUserId.value) &&
selectedUserId.value !== props.currentManager?.userId
);
});
const previousManagerRoleOptions = computed(() =>
getPreviousManagerRoleOptions(props.roleOptions, props.currentManager?.roleId || '')
);
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [item.id, item.nickname])));
const rules = computed(
() =>
({
userId: props.mode === 'create' ? [createRequiredRule('请选择成员用户')] : [],
roleId: [createRequiredRule('请选择角色')],
previousManagerRoleId: showManagerHandover.value ? [createRequiredRule('请选择原产品经理交接后角色')] : []
}) satisfies Record<string, App.Global.FormRule[]>
);
async function handleConfirm() {
await validate();
const sharedPayload = {
roleId: model.roleId,
remark: model.remark.trim() || null,
previousManagerUserId: showManagerHandover.value ? props.currentManager?.userId || null : null,
previousManagerRoleId: showManagerHandover.value ? model.previousManagerRoleId : null
};
if (props.mode === 'create') {
emit('submit', {
mode: 'create',
managerChanged: showManagerHandover.value,
payload: {
userId: model.userId,
roleId: sharedPayload.roleId,
remark: sharedPayload.remark,
previousManagerUserId: sharedPayload.previousManagerUserId,
previousManagerRoleId: sharedPayload.previousManagerRoleId
}
});
return;
}
emit('submit', {
mode: 'edit',
memberId: props.member?.id,
managerChanged: showManagerHandover.value,
payload: {
roleId: sharedPayload.roleId,
remark: sharedPayload.remark,
reason: model.reason.trim() || null,
previousManagerUserId: sharedPayload.previousManagerUserId,
previousManagerRoleId: sharedPayload.previousManagerRoleId
}
});
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
model.userId = props.mode === 'create' ? '' : props.member?.userId || '';
model.roleId = props.mode === 'create' ? '' : props.member?.roleId || '';
model.remark = props.mode === 'create' ? '' : props.member?.remark || '';
model.reason = '';
model.previousManagerRoleId = '';
await nextTick();
formRef.value?.clearValidate();
}
);
</script>
<template>
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="lg" @confirm="handleConfirm">
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<BusinessFormSection title="成员信息">
<ElRow :gutter="16">
<ElCol :span="12">
<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>
</ElFormItem>
<ElFormItem v-else label="成员用户">
<ElInput :model-value="member?.userNickname || userLabelMap.get(member?.userId || '') || ''" readonly />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<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" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="备注">
<ElInput
v-model="model.remark"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入备注"
/>
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
<BusinessFormSection v-if="mode === 'edit'" title="角色调整说明">
<ElFormItem label="变更原因">
<ElInput
v-model="model.reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请输入变更原因"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection v-if="showManagerHandover" title="产品经理交接">
<ElAlert
:title="`当前产品经理 ${currentManager?.userNickname || currentManager?.userId || ''} 将完成交接,请选择其交接后角色。`"
type="warning"
:closable="false"
class="mb-16px"
/>
<ElFormItem label="原产品经理交接后角色" prop="previousManagerRoleId">
<ElSelect v-model="model.previousManagerRoleId" class="w-full" placeholder="请选择原产品经理交接后角色">
<ElOption v-for="item in previousManagerRoleOptions" :key="item.id" :label="item.name" :value="item.id" />
</ElSelect>
</ElFormItem>
</BusinessFormSection>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { reactive, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'MemberRemoveDialog' });
interface Props {
member: Api.Product.ProductMember | null;
}
interface Emits {
(e: 'submit', payload: Api.Product.InactiveProductMemberParams): void;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const model = reactive({
reason: ''
});
function handleConfirm() {
emit('submit', {
reason: model.reason.trim() || null
});
}
watch(
() => visible.value,
value => {
if (!value) {
return;
}
model.reason = '';
}
);
</script>
<template>
<BusinessFormDialog v-model="visible" title="移出成员" preset="sm" @confirm="handleConfirm">
<ElAlert
:title="`确认将 ${member?.userNickname || member?.userId || '--'} 从当前产品团队中移出吗?`"
type="warning"
:closable="false"
class="mb-16px"
/>
<ElForm label-position="top">
<ElFormItem label="移出原因">
<ElInput
v-model="model.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入移出原因"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'ProductDeleteDialog' });
interface Props {
productId: string;
productName: string;
}
interface Emits {
(e: 'submit', payload: Api.Product.DeleteProductParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const model = reactive({
confirmName: '',
reason: ''
});
const confirmDisabled = computed(() => {
return !model.reason.trim() || model.confirmName.trim() !== props.productName;
});
function handleConfirm() {
emit('submit', {
id: props.productId,
productName: model.confirmName.trim(),
reason: model.reason.trim()
});
}
watch(
() => visible.value,
value => {
if (!value) {
return;
}
model.confirmName = '';
model.reason = '';
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
title="删除产品"
preset="sm"
:confirm-disabled="confirmDisabled"
confirm-text="确认删除"
@confirm="handleConfirm"
>
<ElAlert
:title="`请输入当前产品名称 ${productName || '--'} 完成二次确认,删除后将退出当前对象上下文。`"
type="error"
:closable="false"
class="mb-16px"
/>
<ElForm label-position="top">
<ElFormItem label="删除确认名称">
<ElInput v-model="model.confirmName" placeholder="请输入当前产品名称" />
</ElFormItem>
<ElFormItem label="删除原因">
<ElInput
v-model="model.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入删除原因"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
defineOptions({ name: 'SettingAnchorNav' });
interface SettingAnchorItem {
key: string;
label: string;
}
interface Props {
items: readonly SettingAnchorItem[];
activeKey: string;
}
interface Emits {
(e: 'select', key: string): void;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
</script>
<template>
<div class="setting-anchor-nav">
<div class="setting-anchor-nav__title">设置目录</div>
<div class="setting-anchor-nav__list">
<button
v-for="item in items"
:key="item.key"
type="button"
class="setting-anchor-nav__item"
:class="{ 'is-active': item.key === activeKey }"
@click="emit('select', item.key)"
>
{{ item.label }}
</button>
</div>
</div>
</template>
<style scoped>
.setting-anchor-nav {
display: flex;
flex-direction: column;
gap: 14px;
}
.setting-anchor-nav__title {
color: rgb(15 23 42 / 94%);
font-size: 15px;
font-weight: 700;
}
.setting-anchor-nav__list {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.setting-anchor-nav__item {
display: flex;
align-items: center;
width: 100%;
min-height: 42px;
padding: 0 14px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 14px;
background-color: rgb(248 250 252 / 96%);
color: rgb(71 85 105 / 94%);
font-size: 14px;
text-align: left;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
}
.setting-anchor-nav__item:hover {
transform: translateY(-1px);
border-color: rgb(148 163 184 / 56%);
}
.setting-anchor-nav__item.is-active {
border-color: rgb(13 148 136 / 42%);
background-color: rgb(240 253 250 / 98%);
color: rgb(15 118 110 / 96%);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import DictText from '@/components/custom/dict-text.vue';
import { getProductStatusLabel, getProductStatusTagType } from '../../shared/product-master-data';
import { isProductBaseInfoEditable } from '../shared';
defineOptions({ name: 'SettingBaseInfoCard' });
interface Props {
baseInfo: Api.Product.ProductSettingBaseInfo | null;
}
interface Emits {
(e: 'edit'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const editDisabled = computed(() => {
if (!props.baseInfo) {
return true;
}
return !isProductBaseInfoEditable(props.baseInfo.statusCode);
});
</script>
<template>
<ElCard class="card-wrapper">
<template #header>
<div class="flex items-center justify-between gap-12px">
<div>
<h3 class="text-16px text-[#0f172a] font-700">基础信息</h3>
</div>
<ElButton
v-auth="{ code: 'project:product:update', source: 'object' }"
type="primary"
plain
:disabled="editDisabled"
@click="emit('edit')"
>
编辑基础信息
</ElButton>
</div>
</template>
<ElDescriptions v-if="baseInfo" :column="2" border>
<ElDescriptionsItem label="产品编码">{{ baseInfo.code || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="产品名称">{{ baseInfo.name || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="产品方向">
<DictText :dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE" :value="baseInfo.directionCode" />
</ElDescriptionsItem>
<ElDescriptionsItem label="产品经理">
{{ baseInfo.managerUserNickname || baseInfo.managerUserId || '--' }}
</ElDescriptionsItem>
<ElDescriptionsItem label="当前状态">
<ElTag :type="getProductStatusTagType(baseInfo.statusCode)">
{{ getProductStatusLabel(baseInfo.statusCode) }}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem label="最近状态原因">{{ baseInfo.lastStatusReason || '--' }}</ElDescriptionsItem>
<ElDescriptionsItem label="产品描述" :span="2">
<div class="setting-base-info-card__description">{{ baseInfo.description || '--' }}</div>
</ElDescriptionsItem>
</ElDescriptions>
<ElEmpty v-else description="未获取到基础信息" />
</ElCard>
</template>
<style scoped>
.setting-base-info-card__description {
white-space: pre-wrap;
line-height: 1.7;
}
</style>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
defineOptions({ name: 'SettingDangerZone' });
interface Props {
productName: string;
}
interface Emits {
(e: 'delete'): void;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
</script>
<template>
<ElCard class="setting-danger-zone card-wrapper">
<div class="setting-danger-zone__content">
<div class="min-w-0 flex-1">
<h3 class="text-16px text-[#7f1d1d] font-700">危险操作</h3>
<p class="mt-8px text-14px text-[#991b1b] leading-24px">
删除后将退出当前产品对象上下文并返回产品入口页删除时必须输入当前产品名称
<strong>{{ productName || '--' }}</strong>
进行二次确认
</p>
</div>
<ElButton
v-auth="{ code: 'project:product:delete', source: 'object' }"
type="danger"
plain
@click="emit('delete')"
>
删除产品
</ElButton>
</div>
</ElCard>
</template>
<style scoped>
.setting-danger-zone {
border: 1px solid rgb(254 202 202 / 96%);
background:
radial-gradient(circle at top right, rgb(254 226 226 / 96%), transparent 35%),
linear-gradient(180deg, rgb(255 255 255 / 98%), rgb(254 242 242 / 96%));
}
.setting-danger-zone__content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
</style>

View File

@@ -0,0 +1,393 @@
<script setup lang="ts">
import { computed } from 'vue';
import { getProductStatusLabel } from '../../shared/product-master-data';
import { getProductLifecycleActionCardMeta, getProductLifecycleStatusSummary } from '../shared';
defineOptions({ name: 'SettingLifecyclePanel' });
interface Props {
lifecycle: Api.Product.ProductLifecycleInfo | null;
}
interface Emits {
(e: 'action', action: Api.Product.ProductLifecycleAction): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const statusSummary = computed(() => {
if (!props.lifecycle) {
return null;
}
return getProductLifecycleStatusSummary(props.lifecycle.statusCode);
});
const actionCards = computed(() =>
(props.lifecycle?.availableActions || []).map(action => ({
...action,
...getProductLifecycleActionCardMeta(action.actionCode)
}))
);
</script>
<template>
<ElCard class="card-wrapper">
<template #header>
<div>
<h3 class="text-16px text-[#0f172a] font-700">生命周期管理</h3>
</div>
</template>
<template v-if="lifecycle">
<div class="setting-lifecycle-panel__layout">
<section
class="setting-lifecycle-panel__hero"
:class="[`setting-lifecycle-panel__hero--${statusSummary?.tone || 'slate'}`]"
>
<div class="setting-lifecycle-panel__hero-top">
<div class="setting-lifecycle-panel__hero-main">
<div class="setting-lifecycle-panel__hero-status-row">
<span class="setting-lifecycle-panel__hero-status-label">当前状态</span>
<span class="setting-lifecycle-panel__hero-status-chip">
{{ getProductStatusLabel(lifecycle.statusCode) }}
</span>
</div>
<h4 class="setting-lifecycle-panel__hero-title">{{ statusSummary?.caption }}</h4>
</div>
</div>
<p class="setting-lifecycle-panel__hero-desc">
{{ statusSummary?.description }}
</p>
<div class="setting-lifecycle-panel__reason-card">
<span class="setting-lifecycle-panel__reason-label">最近状态原因</span>
<strong class="setting-lifecycle-panel__reason-value">
{{ lifecycle.lastStatusReason || '当前没有记录状态原因。' }}
</strong>
</div>
</section>
<section class="setting-lifecycle-panel__action-panel">
<div class="setting-lifecycle-panel__action-head">
<h4 class="setting-lifecycle-panel__action-title">可执行动作</h4>
</div>
<div v-if="actionCards.length > 0" class="setting-lifecycle-panel__action-grid">
<button
v-for="action in actionCards"
:key="action.actionCode"
type="button"
class="setting-lifecycle-panel__action-card"
:class="[`setting-lifecycle-panel__action-card--${action.tone}`]"
@click="emit('action', action)"
>
<div class="setting-lifecycle-panel__action-card-top">
<span class="setting-lifecycle-panel__action-dot" aria-hidden="true"></span>
<strong class="setting-lifecycle-panel__action-name">{{ action.actionName }}</strong>
</div>
<p class="setting-lifecycle-panel__action-desc">{{ action.description }}</p>
</button>
</div>
<div v-else class="setting-lifecycle-panel__empty-tip">当前状态下暂无可执行生命周期动作</div>
</section>
</div>
</template>
<ElEmpty v-else description="未获取到生命周期信息" />
</ElCard>
</template>
<style scoped>
.setting-lifecycle-panel__layout {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
gap: 16px;
align-items: start;
}
.setting-lifecycle-panel__hero,
.setting-lifecycle-panel__action-panel {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 100%;
padding: 18px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 20px;
background-color: rgb(248 250 252 / 96%);
}
.setting-lifecycle-panel__hero {
overflow: hidden;
background:
radial-gradient(circle at top left, rgb(15 118 110 / 10%), transparent 34%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
}
.setting-lifecycle-panel__hero--emerald {
border-color: rgb(16 185 129 / 22%);
}
.setting-lifecycle-panel__hero--amber {
border-color: rgb(245 158 11 / 22%);
background:
radial-gradient(circle at top left, rgb(245 158 11 / 10%), transparent 34%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(255 251 235 / 97%));
}
.setting-lifecycle-panel__hero--slate {
border-color: rgb(100 116 139 / 22%);
background:
radial-gradient(circle at top left, rgb(100 116 139 / 10%), transparent 34%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
}
.setting-lifecycle-panel__hero--rose {
border-color: rgb(244 63 94 / 22%);
background:
radial-gradient(circle at top left, rgb(244 63 94 / 10%), transparent 34%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(255 241 242 / 97%));
}
.setting-lifecycle-panel__hero-top,
.setting-lifecycle-panel__action-head {
display: flex;
align-items: center;
gap: 16px;
}
.setting-lifecycle-panel__hero-main {
display: flex;
flex-direction: column;
gap: 10px;
}
.setting-lifecycle-panel__hero-status-row {
display: flex;
align-items: center;
gap: 10px;
}
.setting-lifecycle-panel__hero-status-label {
color: rgb(71 85 105 / 94%);
font-size: 12px;
font-weight: 600;
}
.setting-lifecycle-panel__hero-status-chip {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 13px;
font-weight: 700;
line-height: 1.2;
}
.setting-lifecycle-panel__hero-title {
color: rgb(15 23 42 / 96%);
font-size: 22px;
font-weight: 700;
line-height: 1.25;
}
.setting-lifecycle-panel__action-title {
color: rgb(15 23 42 / 96%);
font-size: 18px;
font-weight: 700;
line-height: 1.25;
}
.setting-lifecycle-panel__hero-desc {
max-width: 560px;
color: rgb(71 85 105 / 94%);
font-size: 14px;
line-height: 1.7;
}
.setting-lifecycle-panel__reason-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 88%);
border-radius: 16px;
background-color: rgb(255 255 255 / 82%);
}
.setting-lifecycle-panel__reason-label {
color: rgb(100 116 139 / 92%);
font-size: 12px;
}
.setting-lifecycle-panel__reason-value {
color: rgb(15 23 42 / 94%);
font-size: 15px;
line-height: 1.7;
}
.setting-lifecycle-panel__action-panel {
background:
radial-gradient(circle at top right, rgb(59 130 246 / 7%), transparent 32%),
linear-gradient(180deg, rgb(255 255 255 / 99%), rgb(248 250 252 / 97%));
}
.setting-lifecycle-panel__action-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 10px;
}
.setting-lifecycle-panel__action-card {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
padding: 14px 16px;
border: 1px solid rgb(226 232 240 / 92%);
border-radius: 18px;
background-color: rgb(255 255 255 / 96%);
text-align: left;
transition:
transform 0.2s ease,
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.setting-lifecycle-panel__action-card:hover {
transform: translateY(-1px);
box-shadow: 0 10px 22px rgb(15 23 42 / 6%);
}
.setting-lifecycle-panel__action-card-top {
display: flex;
align-items: center;
gap: 10px;
}
.setting-lifecycle-panel__action-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background-color: currentcolor;
flex: 0 0 auto;
}
.setting-lifecycle-panel__action-name {
color: rgb(15 23 42 / 96%);
font-size: 16px;
font-weight: 700;
}
.setting-lifecycle-panel__action-desc {
color: rgb(71 85 105 / 94%);
font-size: 13px;
line-height: 1.6;
}
.setting-lifecycle-panel__empty-tip {
padding: 18px 16px;
border: 1px dashed rgb(203 213 225 / 92%);
border-radius: 16px;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.7;
}
.setting-lifecycle-panel__hero--emerald .setting-lifecycle-panel__hero-status-chip {
border-color: rgb(16 185 129 / 24%);
background-color: rgb(236 253 245 / 90%);
color: rgb(4 120 87 / 96%);
}
.setting-lifecycle-panel__hero--amber .setting-lifecycle-panel__hero-status-chip {
border-color: rgb(245 158 11 / 24%);
background-color: rgb(255 247 237 / 94%);
color: rgb(180 83 9 / 96%);
}
.setting-lifecycle-panel__hero--slate .setting-lifecycle-panel__hero-status-chip {
border-color: rgb(148 163 184 / 28%);
background-color: rgb(241 245 249 / 94%);
color: rgb(71 85 105 / 96%);
}
.setting-lifecycle-panel__hero--rose .setting-lifecycle-panel__hero-status-chip {
border-color: rgb(244 63 94 / 24%);
background-color: rgb(255 241 242 / 94%);
color: rgb(190 24 93 / 96%);
}
.setting-lifecycle-panel__action-card--emerald {
border-color: rgb(16 185 129 / 22%);
background: linear-gradient(90deg, rgb(236 253 245 / 90%), rgb(255 255 255 / 96%) 26%);
color: rgb(4 120 87 / 96%);
}
.setting-lifecycle-panel__action-card--amber {
border-color: rgb(245 158 11 / 22%);
background: linear-gradient(90deg, rgb(255 247 237 / 92%), rgb(255 255 255 / 96%) 26%);
color: rgb(180 83 9 / 96%);
}
.setting-lifecycle-panel__action-card--slate {
border-color: rgb(148 163 184 / 26%);
background: linear-gradient(90deg, rgb(241 245 249 / 92%), rgb(255 255 255 / 96%) 26%);
color: rgb(71 85 105 / 96%);
}
.setting-lifecycle-panel__action-card--rose {
border-color: rgb(244 63 94 / 22%);
background: linear-gradient(90deg, rgb(255 241 242 / 92%), rgb(255 255 255 / 96%) 26%);
color: rgb(190 24 93 / 96%);
}
.setting-lifecycle-panel__action-card--emerald:hover {
box-shadow: 0 10px 22px rgb(16 185 129 / 12%);
}
.setting-lifecycle-panel__action-card--amber:hover {
box-shadow: 0 10px 22px rgb(245 158 11 / 12%);
}
.setting-lifecycle-panel__action-card--slate:hover {
box-shadow: 0 10px 22px rgb(100 116 139 / 10%);
}
.setting-lifecycle-panel__action-card--rose:hover {
box-shadow: 0 10px 22px rgb(244 63 94 / 12%);
}
.setting-lifecycle-panel__action-card--emerald .setting-lifecycle-panel__action-name {
color: rgb(6 95 70 / 96%);
}
.setting-lifecycle-panel__action-card--amber .setting-lifecycle-panel__action-name {
color: rgb(146 64 14 / 96%);
}
.setting-lifecycle-panel__action-card--slate .setting-lifecycle-panel__action-name {
color: rgb(51 65 85 / 96%);
}
.setting-lifecycle-panel__action-card--rose .setting-lifecycle-panel__action-name {
color: rgb(159 18 57 / 96%);
}
@media (width <= 1280px) {
.setting-lifecycle-panel__layout {
grid-template-columns: 1fr;
}
}
@media (width <= 640px) {
.setting-lifecycle-panel__hero-top,
.setting-lifecycle-panel__action-head {
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,201 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { filterProductMembers, formatProductMemberDate, getProductTeamTableHeight } from '../shared';
defineOptions({ name: 'SettingTeamPanel' });
interface Props {
members: Api.Product.ProductMember[];
roleOptions?: Api.SystemManage.RoleSimple[];
loading?: boolean;
readonly?: boolean;
}
interface Emits {
(e: 'create'): void;
(e: 'edit', member: Api.Product.ProductMember): void;
(e: 'remove', member: Api.Product.ProductMember): void;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
readonly: false,
roleOptions: () => []
});
const emit = defineEmits<Emits>();
const searchKeyword = ref('');
const selectedRoleId = ref('');
const teamTableHeight = getProductTeamTableHeight(5);
const roleFilterOptions = computed(() => {
const roleMap = new Map<string, string>();
props.roleOptions.forEach(role => {
if (!roleMap.has(role.id)) {
roleMap.set(role.id, role.name);
}
});
return [...roleMap.entries()].map(([value, label]) => ({
value,
label
}));
});
const filteredMembers = computed(() =>
filterProductMembers(props.members, {
keyword: searchKeyword.value,
roleId: selectedRoleId.value
})
);
const hasFilter = computed(() => Boolean(searchKeyword.value.trim() || selectedRoleId.value));
watch(roleFilterOptions, options => {
if (selectedRoleId.value && !options.some(item => item.value === selectedRoleId.value)) {
selectedRoleId.value = '';
}
});
function getMemberStatusLabel(status: Api.Product.ProductMemberStatus) {
return status === 0 ? '有效' : '失效';
}
function getMemberStatusTagType(status: Api.Product.ProductMemberStatus) {
return status === 0 ? 'success' : 'info';
}
</script>
<template>
<ElCard class="card-wrapper">
<template #header>
<div class="setting-team-panel__header">
<div>
<h3 class="text-16px text-[#0f172a] font-700">团队管理</h3>
</div>
<div class="setting-team-panel__toolbar">
<ElSelect v-model="selectedRoleId" clearable placeholder="筛选角色" class="setting-team-panel__role-filter">
<ElOption
v-for="option in roleFilterOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
<ElInput v-model="searchKeyword" clearable placeholder="搜索成员姓名" class="setting-team-panel__search" />
<ElButton
v-if="!props.readonly"
v-auth="{ code: 'project:product:update', source: 'object' }"
type="primary"
plain
@click="emit('create')"
>
新增成员
</ElButton>
</div>
</div>
</template>
<ElTable
v-loading="props.loading"
:data="filteredMembers"
:height="teamTableHeight"
:empty-text="hasFilter ? '未找到匹配成员' : '暂无成员'"
border
row-key="id"
>
<ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn prop="userNickname" label="成员姓名" min-width="140" />
<ElTableColumn prop="roleName" label="当前角色" min-width="140" />
<ElTableColumn label="成员状态" width="110" align="center">
<template #default="{ row }">
<ElTag :type="getMemberStatusTagType(row.status)">{{ getMemberStatusLabel(row.status) }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="joinedTime" label="加入时间" min-width="132" align="center">
<template #default="{ row }">
{{ formatProductMemberDate(row.joinedTime) }}
</template>
</ElTableColumn>
<ElTableColumn prop="leftTime" label="退出时间" min-width="170">
<template #default="{ row }">
{{ formatProductMemberDate(row.leftTime) }}
</template>
</ElTableColumn>
<ElTableColumn prop="remark" label="备注" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ row.remark || '--' }}
</template>
</ElTableColumn>
<ElTableColumn v-if="!props.readonly" label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<div class="setting-team-panel__actions">
<ElButton
v-auth="{ code: 'project:product:update', source: 'object' }"
link
type="primary"
:disabled="row.status !== 0 || row.managerFlag"
@click="emit('edit', row)"
>
调整角色
</ElButton>
<ElButton
v-auth="{ code: 'project:product:update', source: 'object' }"
link
type="danger"
:disabled="row.status !== 0 || row.managerFlag"
@click="emit('remove', row)"
>
移出成员
</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
</ElCard>
</template>
<style scoped>
.setting-team-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.setting-team-panel__toolbar {
display: inline-flex;
align-items: center;
gap: 12px;
}
.setting-team-panel__search {
width: 220px;
}
.setting-team-panel__role-filter {
width: 180px;
}
.setting-team-panel__actions {
display: inline-flex;
align-items: center;
gap: 12px;
}
@media (width <= 768px) {
.setting-team-panel__header {
align-items: flex-start;
flex-direction: column;
}
.setting-team-panel__toolbar {
width: 100%;
}
.setting-team-panel__search {
width: 100%;
}
.setting-team-panel__role-filter {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'StatusActionDialog' });
interface Props {
action: Api.Product.ProductLifecycleAction | null;
}
interface Emits {
(e: 'submit', payload: Api.Product.ChangeProductStatusParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const model = reactive({
reason: ''
});
const confirmDisabled = computed(() => Boolean(props.action?.needReason && !model.reason.trim()));
function handleConfirm() {
if (!props.action) {
return;
}
emit('submit', {
id: '',
actionCode: props.action.actionCode,
reason: model.reason.trim() || null
});
}
watch(
() => visible.value,
value => {
if (!value) {
return;
}
model.reason = '';
}
);
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="action ? `${action.actionName}产品` : '生命周期动作'"
preset="sm"
:confirm-disabled="confirmDisabled"
@confirm="handleConfirm"
>
<ElForm label-position="top">
<ElFormItem :label="action?.needReason ? '动作原因(必填)' : '动作原因(选填)'">
<ElInput
v-model="model.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入动作原因"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

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