docs(api): 添加产品动态时间线前端API文档

- 新增产品动态时间线接口文档,明确前端调用规范
- 定义接口请求参数、响应结构和字段语义说明
- 提供请求示例和错误码说明
- 添加左侧筛选项映射规则和时间格式说明

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

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

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

- 添加前端任务测试策略说明
- 更新代理工作流程规范
- 明确git操作执行边界
- 优化组件类型声明更新
This commit is contained in:
2026-04-24 16:38:43 +08:00
parent 4122dfa50d
commit 5b9c7e781b
14 changed files with 3584 additions and 958 deletions

View File

@@ -0,0 +1,367 @@
# 10-产品动态时间线 前端 API 文档
## 0. 文档定位
本文档是给前端产品首页“产品动态展示区域”单独使用的接口文档。
目标:
- 明确前端当前应该调用哪个接口
- 明确左侧筛选项如何映射到后端参数
- 明确接口返回字段、时间格式、边界规则
- 避免继续混用设置页最近动态和首页正式时间线
说明:
- 设置页原最近动态接口 `GET /project/product/{id}/activities` 继续保留
- 产品首页正式动态时间线请统一使用本文档中的新接口
- 当前首页动态时间线不包含需求池变动,需求池由独立区域承载
---
## 1. 接口概览
### 1.1 接口信息
- 接口名称:获取产品动态时间线分页
- 请求方法:`GET`
- 请求路径:`/project/product/{id}/activities/page`
- 权限码:`project:product:query`
- 适用页面:产品首页动态时间线区域
### 1.2 接口用途
该接口用于返回产品首页动态展示区域的正式时间线数据,支持:
- 默认最近 30 天
- 左侧类型筛选
- 动作多选筛选
- 分页查询
- 创建初始化噪音去除
### 1.3 当前纳入首页时间线的事件范围
当前只包含以下 5 类:
- 产品创建
- 产品状态变更
- 产品经理变更
- 成员加入
- 成员移出
当前明确不包含:
- 需求池变动
- `update_member`
- 普通产品主数据编辑 `update`
- 删除产品动态
---
## 2. 请求定义
### 2.1 路径参数
| 参数名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `id` | `integer(int64)` | 是 | 产品 ID |
### 2.2 查询参数
| 参数名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `pageNo` | `integer` | 是 | 页码,从 `1` 开始 |
| `pageSize` | `integer` | 是 | 每页条数 |
| `activityType` | `string` | 否 | 分类,可选 `status` / `product` / `member` |
| `actionTypes` | `array<string>` | 否 | 动作编码数组,支持多选 |
| `startTime` | `string` | 否 | 开始时间,格式 `yyyy-MM-dd HH:mm:ss` |
| `endTime` | `string` | 否 | 结束时间,格式 `yyyy-MM-dd HH:mm:ss` |
### 2.3 参数规则
#### 2.3.1 时间参数规则
- `startTime``endTime` 必须同时传,或者同时不传
- 都不传时,后端默认查询最近 `30`
- 只传一个时,后端返回参数错误
- `startTime > endTime` 时,后端返回参数错误
#### 2.3.2 筛选参数规则
- `activityType` 是分类筛选
- `actionTypes` 是动作细筛选
- 两者同时传时,按交集处理
- 如果前端未来需要做跨类型多选,可以不传 `activityType`,只传 `actionTypes`
#### 2.3.3 `actionTypes` 传参方式
GET 场景请按重复参数方式传递,例如:
```text
/project/product/1024/activities/page?pageNo=1&pageSize=10&activityType=status&actionTypes=pause&actionTypes=resume&actionTypes=archive&actionTypes=abandon
```
---
## 3. 左侧筛选映射
首页左侧当前 5 个筛选项,前端请按下表映射到请求参数:
| 前端筛选项 | `activityType` | `actionTypes` |
| --- | --- | --- |
| 产品创建 | `product` | `create` |
| 产品状态变更 | `status` | `pause` / `resume` / `archive` / `abandon` |
| 产品经理变更 | `product` | `change_manager` |
| 成员加入 | `member` | `add_member` |
| 成员移出 | `member` | `remove_member` |
补充说明:
- 首页时间线当前不展示需求池变动
- 需求池的展示由独立模块负责,不要通过本接口混查
---
## 4. 响应定义
### 4.1 响应包装
接口统一返回 `CommonResult<PageResult<ProductActivityTimelineRespVO>>`
成功响应结构:
```json
{
"code": 0,
"msg": "",
"data": {
"total": 0,
"list": []
}
}
```
### 4.2 `data` 结构
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `total` | `integer(int64)` | 总条数 |
| `list` | `array<object>` | 当前页数据 |
### 4.3 单条动态结构
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | `string` | 动态唯一标识,格式 `type:sourceId`,例如 `status:11` |
| `type` | `string` | 动态类型,取值 `status` / `product` / `member` |
| `actionType` | `string` | 动作编码 |
| `actionName` | `string` | 动作中文名称 |
| `operatorUserId` | `integer(int64)` | 操作人用户 ID可为 `null` |
| `operatorName` | `string` | 操作人名称,可为空字符串 |
| `occurredAt` | `integer(int64)` | 动态发生时间,毫秒时间戳 |
| `summary` | `string` | 可直接展示的摘要文案 |
| `reason` | `string` | 原因说明,可为 `null` |
| `fromStatus` | `string` | 原状态编码,可为 `null` |
| `toStatus` | `string` | 目标状态编码,可为 `null` |
| `details` | `string` | 补充明细,当前为 JSON 字符串 |
### 4.4 时间格式说明
这个接口当前有两个时间口径:
- 请求里的 `startTime``endTime`:字符串,格式 `yyyy-MM-dd HH:mm:ss`
- 响应里的 `occurredAt`:毫秒时间戳 `number`
前端需要按这个真实口径处理,不要把 `occurredAt` 当成格式化字符串读取。
---
## 5. 字段语义说明
### 5.1 `type` 取值说明
| 取值 | 说明 | 数据来源 |
| --- | --- | --- |
| `status` | 产品状态变更 | `rdms_product_status_log` |
| `product` | 产品对象动态 | `rdms_biz_audit_log``bizType=product` |
| `member` | 产品团队动态 | `rdms_biz_audit_log``bizType=rdms_user_object_role` |
### 5.2 `actionType` 取值范围
当前首页时间线只会出现以下动作:
| `type` | `actionType` | 说明 |
| --- | --- | --- |
| `product` | `create` | 产品创建 |
| `product` | `change_manager` | 产品经理变更 |
| `status` | `pause` | 暂停 |
| `status` | `resume` | 恢复 |
| `status` | `archive` | 归档 |
| `status` | `abandon` | 废弃 |
| `member` | `add_member` | 成员加入 |
| `member` | `remove_member` | 成员移出 |
### 5.3 `details` 当前口径
`details` 当前不做统一结构化建模,按来源原样返回字符串:
- `type=status`
- 返回状态日志补充信息
- 当前包含 `productCodeSnapshot``productNameSnapshot`
- `type=product`
- 返回产品审计 `fieldChanges`
- `type=member`
- 返回成员审计 `fieldChanges`
前端建议:
- 首版先不依赖 `details` 做复杂渲染
- 先以 `actionName + summary + operatorName + occurredAt` 跑通展示
---
## 6. 后端聚合规则
为了让前端看到的是“可直接展示的正式时间线”,后端已固定以下规则:
### 6.1 创建去噪
产品创建时通常会伴随初始化动作:
- 初始化 `change_manager`
- 初始化 `add_member`
这两类初始化动作不会单独出现在首页时间线里,最终只保留一条 `create`
### 6.2 状态日志优先
如果同一状态动作同时存在:
- 产品审计日志
- 状态日志
则首页时间线只取状态日志,不重复展示产品审计里的同类状态动作。
### 6.3 成员调整排除
`update_member` 当前不进入首页正式时间线。
原因:
- 首页当前只需要展示“加入”和“移出”
- 角色调整、备注调整等细节先不进入首页主时间线
---
## 7. 请求示例
### 7.1 默认查询首页动态
```http
GET /project/product/1024/activities/page?pageNo=1&pageSize=6
```
### 7.2 查询“产品状态变更”
```http
GET /project/product/1024/activities/page?pageNo=1&pageSize=10&activityType=status&actionTypes=pause&actionTypes=resume&actionTypes=archive&actionTypes=abandon
```
### 7.3 查询“成员移出”并限制时间范围
```http
GET /project/product/1024/activities/page?pageNo=1&pageSize=10&activityType=member&actionTypes=remove_member&startTime=2026-03-24 00:00:00&endTime=2026-04-23 23:59:59
```
---
## 8. 响应示例
```json
{
"code": 0,
"msg": "",
"data": {
"total": 2,
"list": [
{
"id": "product:22",
"type": "product",
"actionType": "change_manager",
"actionName": "切换产品经理",
"operatorUserId": 10002,
"operatorName": "李四",
"occurredAt": 1776812345000,
"summary": "李四执行了【切换产品经理】",
"reason": null,
"fromStatus": null,
"toStatus": null,
"details": "{\"managerUserId\":{\"before\":10001,\"after\":10002}}"
},
{
"id": "status:11",
"type": "status",
"actionType": "resume",
"actionName": "恢复",
"operatorUserId": 10001,
"operatorName": "张三",
"occurredAt": 1776812984000,
"summary": "张三执行了【恢复】:可以继续开展",
"reason": "可以继续开展",
"fromStatus": "paused",
"toStatus": "active",
"details": "{\"productCodeSnapshot\":\"CNPD2026001\",\"productNameSnapshot\":\"统一交付平台\"}"
}
]
}
}
```
---
## 9. 错误码
| `code` | 说明 |
| --- | --- |
| `0` | 成功 |
| `400` | 请求参数错误,例如只传了一侧时间,或开始时间晚于结束时间 |
| `401` | 未登录 |
| `403` | 没有该产品查询权限 |
| `1008001000` | 产品不存在 |
参数错误示例:
```json
{
"code": 400,
"msg": "开始时间和结束时间必须同时传入",
"data": null
}
```
---
## 10. 前端接入建议
首页动态区域首版建议直接消费以下字段:
- `actionName`
- `summary`
- `operatorName`
- `occurredAt`
左侧筛选建议直接使用:
- `activityType`
- `actionTypes`
当前不建议首版依赖:
- `details` 的深度结构化解析
- 需求池事件混入本接口
- 自行从 `actionType` 反推新的派生事件类型
一句话结论:
- 设置页最近动态继续调 `/activities`
- 产品首页正式动态时间线统一调 `/activities/page`

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'> & {

View File

@@ -10,6 +10,7 @@ declare module 'vue' {
export interface GlobalComponents {
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
BusinessDateRangePicker: typeof import('./../components/custom/business-date-range-picker.vue')['default']
BusinessFormDialog: typeof import('./../components/custom/business-form-dialog.vue')['default']
BusinessFormDrawer: typeof import('./../components/custom/business-form-drawer.vue')['default']
BusinessFormSection: typeof import('./../components/custom/business-form-section.vue')['default']
@@ -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']

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[];
}