docs(api): 添加产品动态时间线前端API文档
- 新增产品动态时间线接口文档,明确前端调用规范 - 定义接口请求参数、响应结构和字段语义说明 - 提供请求示例和错误码说明 - 添加左侧筛选项映射规则和时间格式说明 feat(product): 实现产品首页动态时间线功能 - 重构产品首页布局结构,采用档案横幅型设计 - 新增对象基础概述横幅模块 - 实现产品动态时间线面板组件 - 集成需求池管理概览和最近变化区域 - 添加扩展信息区预留模块位 chore(docs): 更新代理工作说明和前端测试策略 - 添加前端任务测试策略说明 - 更新代理工作流程规范 - 明确git操作执行边界 - 优化组件类型声明更新
This commit is contained in:
367
10-产品动态时间线_前端API文档.md
Normal file
367
10-产品动态时间线_前端API文档.md
Normal 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`
|
||||
@@ -236,6 +236,8 @@ const directionLabels = getLabels(row.directionCodes, { separator: ',' });
|
||||
|
||||
对有实际影响的代码改动,优先执行:
|
||||
|
||||
- 对前端页面、交互、样式类任务,除非用户明确要求新增测试、运行测试,或当前任务本身就是修测试/补测试,否则默认不补前端测试,也不主动跑前端测试命令。
|
||||
- 上述前端任务默认只做静态校验;最小校验口径是 `pnpm typecheck`。如果需要更严格的静态检查,再补 `pnpm lint`。
|
||||
- `pnpm typecheck`
|
||||
- `pnpm lint`
|
||||
|
||||
@@ -269,7 +271,8 @@ const directionLabels = getLabels(row.directionCodes, { separator: ',' });
|
||||
|
||||
## 代理工作说明
|
||||
|
||||
- 编辑前先检查当前 `git diff`,仓库中可能已经存在用户进行中的修改。
|
||||
- 除非用户明确要求,否则不要主动执行任何 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/*`。
|
||||
|
||||
@@ -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 数据源中
|
||||
569
src/components/custom/business-date-range-picker.vue
Normal file
569
src/components/custom/business-date-range-picker.vue
Normal 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>
|
||||
@@ -18,6 +18,23 @@ type ProductResponse = Omit<Api.Product.Product, 'id' | 'managerUserId'> & {
|
||||
|
||||
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,
|
||||
@@ -26,6 +43,54 @@ function normalizeProduct(product: ProductResponse): Api.Product.Product {
|
||||
};
|
||||
}
|
||||
|
||||
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>({
|
||||
@@ -123,6 +188,24 @@ export async function fetchGetProductMembers(id: string) {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
55
src/typings/api/product.d.ts
vendored
55
src/typings/api/product.d.ts
vendored
@@ -109,6 +109,61 @@ declare namespace Api {
|
||||
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'> & {
|
||||
|
||||
4
src/typings/components.d.ts
vendored
4
src/typings/components.d.ts
vendored
@@ -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']
|
||||
@@ -29,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']
|
||||
@@ -56,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']
|
||||
|
||||
390
src/views/product/dashboard/homepage.ts
Normal file
390
src/views/product/dashboard/homepage.ts
Normal 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];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
60
src/views/product/dashboard/mock.ts
Normal file
60
src/views/product/dashboard/mock.ts
Normal 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[];
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
367
src/views/product/dashboard/product-activity.ts
Normal file
367
src/views/product/dashboard/product-activity.ts
Normal 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);
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { getProductStatusLabel } from '../shared/product-master-data';
|
||||
|
||||
export interface ProductDashboardMetricCard {
|
||||
key: 'status' | 'team' | 'manager' | 'action';
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProductDashboardTeamSummary {
|
||||
managerDisplayName: string;
|
||||
activeMemberCount: number;
|
||||
latestJoinedMemberLabel: string;
|
||||
roleSummaries: string[];
|
||||
}
|
||||
|
||||
export interface ProductDashboardQuickLink {
|
||||
key: 'requirement' | 'setting' | 'list';
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ProductDashboardActivityItem {
|
||||
key: string;
|
||||
title: string;
|
||||
content: string;
|
||||
time: string;
|
||||
tag: string;
|
||||
tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||
}
|
||||
|
||||
export interface ProductDashboardPlaceholderPanel {
|
||||
title: string;
|
||||
description: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export interface ProductDashboardGrowthModule {
|
||||
key: 'requirement-analysis' | 'project-progress' | 'rd-milestone';
|
||||
title: string;
|
||||
description: string;
|
||||
indicators: string[];
|
||||
}
|
||||
|
||||
function getActiveMembers(members: readonly Api.Product.ProductMember[]) {
|
||||
return members.filter(item => item.status === 0);
|
||||
}
|
||||
|
||||
function getTimeValue(value: string | null | undefined) {
|
||||
const parsed = dayjs(value);
|
||||
|
||||
return parsed.isValid() ? parsed.valueOf() : 0;
|
||||
}
|
||||
|
||||
function formatActivityTime(value: string | null | undefined) {
|
||||
const parsed = dayjs(value);
|
||||
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : '--';
|
||||
}
|
||||
|
||||
export function getProductDashboardMetricCards(
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
) {
|
||||
const activeMembers = getActiveMembers(members);
|
||||
const managerDisplayName =
|
||||
settings?.baseInfo.managerUserNickname || activeMembers.find(item => item.managerFlag)?.userNickname || '--';
|
||||
const actionCount = settings?.lifecycle.availableActions.length || 0;
|
||||
const statusLabel = settings ? getProductStatusLabel(settings.lifecycle.statusCode) : '--';
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'status',
|
||||
label: '当前状态',
|
||||
value: statusLabel
|
||||
},
|
||||
{
|
||||
key: 'team',
|
||||
label: '团队成员',
|
||||
value: `${activeMembers.length} 人`
|
||||
},
|
||||
{
|
||||
key: 'manager',
|
||||
label: '当前经理',
|
||||
value: managerDisplayName
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: '可执行动作',
|
||||
value: `${actionCount} 项`
|
||||
}
|
||||
] satisfies ProductDashboardMetricCard[];
|
||||
}
|
||||
|
||||
export function getProductDashboardTeamSummary(
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
): ProductDashboardTeamSummary {
|
||||
const activeMembers = getActiveMembers(members);
|
||||
const latestJoinedMember = activeMembers
|
||||
.slice()
|
||||
.sort((left, right) => getTimeValue(right.joinedTime) - getTimeValue(left.joinedTime))[0];
|
||||
const latestJoinedDate = latestJoinedMember ? dayjs(latestJoinedMember.joinedTime) : null;
|
||||
|
||||
const roleCounter = new Map<string, number>();
|
||||
|
||||
activeMembers.forEach(member => {
|
||||
const roleName = member.roleName || '未命名角色';
|
||||
|
||||
roleCounter.set(roleName, (roleCounter.get(roleName) || 0) + 1);
|
||||
});
|
||||
|
||||
const roleSummaries = Array.from(roleCounter.entries())
|
||||
.sort((left, right) => {
|
||||
const leftManagerWeight = left[0].includes('经理') ? 0 : 1;
|
||||
const rightManagerWeight = right[0].includes('经理') ? 0 : 1;
|
||||
|
||||
if (leftManagerWeight !== rightManagerWeight) {
|
||||
return leftManagerWeight - rightManagerWeight;
|
||||
}
|
||||
|
||||
return left[0].localeCompare(right[0], 'zh-CN');
|
||||
})
|
||||
.map(([roleName, count]) => `${roleName} ${count} 人`);
|
||||
|
||||
return {
|
||||
managerDisplayName:
|
||||
settings?.baseInfo.managerUserNickname || activeMembers.find(item => item.managerFlag)?.userNickname || '--',
|
||||
activeMemberCount: activeMembers.length,
|
||||
latestJoinedMemberLabel:
|
||||
latestJoinedMember && latestJoinedDate?.isValid()
|
||||
? `${latestJoinedMember.userNickname} · ${latestJoinedDate.format('YYYY-MM-DD')}`
|
||||
: '--',
|
||||
roleSummaries
|
||||
};
|
||||
}
|
||||
|
||||
export function getProductDashboardQuickLinks() {
|
||||
return [
|
||||
{
|
||||
key: 'requirement',
|
||||
label: '进入需求页',
|
||||
description: '查看当前产品下的需求承接位'
|
||||
},
|
||||
{
|
||||
key: 'setting',
|
||||
label: '查看设置',
|
||||
description: '进入产品基础信息、团队和生命周期管理'
|
||||
},
|
||||
{
|
||||
key: 'list',
|
||||
label: '返回列表',
|
||||
description: '退出当前对象视角,回到产品入口页'
|
||||
}
|
||||
] satisfies ProductDashboardQuickLink[];
|
||||
}
|
||||
|
||||
export function getProductDashboardActivityItems(
|
||||
product: Api.Product.Product | null,
|
||||
settings: Api.Product.ProductSettings | null,
|
||||
members: readonly Api.Product.ProductMember[]
|
||||
) {
|
||||
const items: ProductDashboardActivityItem[] = [];
|
||||
|
||||
if (product?.createTime) {
|
||||
items.push({
|
||||
key: `product-create-${product.id}`,
|
||||
title: '创建产品',
|
||||
content: `产品 ${product.name || product.code} 已建立并纳入对象上下文。`,
|
||||
time: product.createTime,
|
||||
tag: '创建',
|
||||
tone: 'sky'
|
||||
});
|
||||
}
|
||||
|
||||
if (settings && settings.baseInfo.lastStatusReason && product?.updateTime) {
|
||||
const statusCode = settings.lifecycle.statusCode;
|
||||
let tone: ProductDashboardActivityItem['tone'] = 'slate';
|
||||
|
||||
if (statusCode === 'active') {
|
||||
tone = 'emerald';
|
||||
} else if (statusCode === 'paused') {
|
||||
tone = 'amber';
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: `product-status-${product.id}-${product.updateTime}`,
|
||||
title: `状态调整为${getProductStatusLabel(settings.lifecycle.statusCode)}`,
|
||||
content: settings.baseInfo.lastStatusReason,
|
||||
time: product.updateTime,
|
||||
tag: '状态',
|
||||
tone
|
||||
});
|
||||
}
|
||||
|
||||
members.forEach(member => {
|
||||
if (member.joinedTime) {
|
||||
items.push({
|
||||
key: `member-join-${member.id}`,
|
||||
title: '成员加入',
|
||||
content: `${member.userNickname} 以${member.roleName}身份加入当前产品。`,
|
||||
time: member.joinedTime,
|
||||
tag: '团队',
|
||||
tone: member.managerFlag ? 'emerald' : 'sky'
|
||||
});
|
||||
}
|
||||
|
||||
if (member.status === 1 && member.leftTime) {
|
||||
items.push({
|
||||
key: `member-leave-${member.id}`,
|
||||
title: '成员退出',
|
||||
content: `${member.userNickname} 已退出当前产品团队。`,
|
||||
time: member.leftTime,
|
||||
tag: '团队',
|
||||
tone: 'rose'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items
|
||||
.filter(item => getTimeValue(item.time) > 0)
|
||||
.sort((left, right) => getTimeValue(right.time) - getTimeValue(left.time))
|
||||
.slice(0, 6)
|
||||
.map(item => ({
|
||||
...item,
|
||||
time: formatActivityTime(item.time)
|
||||
}));
|
||||
}
|
||||
|
||||
export function getProductDashboardRecentActivityPlaceholder() {
|
||||
return {
|
||||
title: '最近动态',
|
||||
description: '当前先基于产品详情、生命周期与团队关系展示已知动态;后续接入审计日志后可继续扩充为完整时间线。',
|
||||
items: ['产品创建记录', '状态调整记录', '成员加入记录', '成员退出记录']
|
||||
} satisfies ProductDashboardPlaceholderPanel;
|
||||
}
|
||||
|
||||
export function getProductDashboardRdMilestonePlaceholder() {
|
||||
return {
|
||||
title: '研发令 / 里程碑摘要',
|
||||
description: '当前未接入研发令与里程碑聚合能力,后续将在这里展示年度研发令、关键节点和版本里程碑。',
|
||||
items: ['当前年度研发令', '历史研发令', '关键节点计划']
|
||||
} satisfies ProductDashboardPlaceholderPanel;
|
||||
}
|
||||
|
||||
export function getProductDashboardGrowthModules() {
|
||||
return [
|
||||
{
|
||||
key: 'requirement-analysis',
|
||||
title: '需求分析',
|
||||
description: '暂未接入需求统计接口,后续将展示需求总量、状态分布与优先级分布。',
|
||||
indicators: ['需求总数', '待处理数量', '高优先级数量']
|
||||
},
|
||||
{
|
||||
key: 'project-progress',
|
||||
title: '项目推进',
|
||||
description: '当前未汇总项目推进数据,后续将展示关联项目、里程碑与风险摘要。',
|
||||
indicators: ['关联项目数', '进行中项目', '近期里程碑']
|
||||
},
|
||||
{
|
||||
key: 'rd-milestone',
|
||||
title: '研发令与里程碑',
|
||||
description: '当前未接入研发令与里程碑聚合能力,后续将在此展示研发令编号与关键节点信息。',
|
||||
indicators: ['当前年度研发令', '历史研发令', '关键节点']
|
||||
}
|
||||
] satisfies ProductDashboardGrowthModule[];
|
||||
}
|
||||
Reference in New Issue
Block a user