From 5b9c7e781b6c0fdeee211a4848dd02c2c00b3a7d Mon Sep 17 00:00:00 2001 From: hongawen <83944980@qq.com> Date: Fri, 24 Apr 2026 16:38:43 +0800 Subject: [PATCH] =?UTF-8?q?docs(api):=20=E6=B7=BB=E5=8A=A0=E4=BA=A7?= =?UTF-8?q?=E5=93=81=E5=8A=A8=E6=80=81=E6=97=B6=E9=97=B4=E7=BA=BF=E5=89=8D?= =?UTF-8?q?=E7=AB=AFAPI=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增产品动态时间线接口文档,明确前端调用规范 - 定义接口请求参数、响应结构和字段语义说明 - 提供请求示例和错误码说明 - 添加左侧筛选项映射规则和时间格式说明 feat(product): 实现产品首页动态时间线功能 - 重构产品首页布局结构,采用档案横幅型设计 - 新增对象基础概述横幅模块 - 实现产品动态时间线面板组件 - 集成需求池管理概览和最近变化区域 - 添加扩展信息区预留模块位 chore(docs): 更新代理工作说明和前端测试策略 - 添加前端任务测试策略说明 - 更新代理工作流程规范 - 明确git操作执行边界 - 优化组件类型声明更新 --- 10-产品动态时间线_前端API文档.md | 367 +++++ AGENTS.md | 5 +- ...-04-23-product-overview-homepage-design.md | 292 ++++ .../custom/business-date-range-picker.vue | 569 ++++++++ src/service/api/product.ts | 83 ++ src/typings/api/product.d.ts | 55 + src/typings/components.d.ts | 4 + src/views/product/dashboard/homepage.ts | 390 +++++ src/views/product/dashboard/index.vue | 1280 ++++++++--------- src/views/product/dashboard/mock.ts | 60 + .../product-activity-timeline-dialog.vue | 528 +++++++ .../product-activity-timeline-panel.vue | 275 ++++ .../product/dashboard/product-activity.ts | 367 +++++ src/views/product/dashboard/shared.ts | 267 ---- 14 files changed, 3584 insertions(+), 958 deletions(-) create mode 100644 10-产品动态时间线_前端API文档.md create mode 100644 docs/superpowers/specs/2026-04-23-product-overview-homepage-design.md create mode 100644 src/components/custom/business-date-range-picker.vue create mode 100644 src/views/product/dashboard/homepage.ts create mode 100644 src/views/product/dashboard/mock.ts create mode 100644 src/views/product/dashboard/modules/product-activity-timeline-dialog.vue create mode 100644 src/views/product/dashboard/modules/product-activity-timeline-panel.vue create mode 100644 src/views/product/dashboard/product-activity.ts delete mode 100644 src/views/product/dashboard/shared.ts diff --git a/10-产品动态时间线_前端API文档.md b/10-产品动态时间线_前端API文档.md new file mode 100644 index 0000000..834094b --- /dev/null +++ b/10-产品动态时间线_前端API文档.md @@ -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` | 否 | 动作编码数组,支持多选 | +| `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>`。 + +成功响应结构: + +```json +{ + "code": 0, + "msg": "", + "data": { + "total": 0, + "list": [] + } +} +``` + +### 4.2 `data` 结构 + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `total` | `integer(int64)` | 总条数 | +| `list` | `array` | 当前页数据 | + +### 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` diff --git a/AGENTS.md b/AGENTS.md index 138abb9..a5cb44f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/*`。 diff --git a/docs/superpowers/specs/2026-04-23-product-overview-homepage-design.md b/docs/superpowers/specs/2026-04-23-product-overview-homepage-design.md new file mode 100644 index 0000000..58da0c6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-23-product-overview-homepage-design.md @@ -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 数据源中 diff --git a/src/components/custom/business-date-range-picker.vue b/src/components/custom/business-date-range-picker.vue new file mode 100644 index 0000000..4c6513f --- /dev/null +++ b/src/components/custom/business-date-range-picker.vue @@ -0,0 +1,569 @@ + + + + + diff --git a/src/service/api/product.ts b/src/service/api/product.ts index e9fa383..9376c23 100644 --- a/src/service/api/product.ts +++ b/src/service/api/product.ts @@ -18,6 +18,23 @@ type ProductResponse = Omit & { type ProductPageResponse = Api.Product.PageResult; +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, + '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({ @@ -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({ + ...safeJsonRequestConfig, + url, + method: 'get' + }); + + return mapServiceResult(result as ServiceRequestResult, data => ({ + total: normalizePageTotal(data.total), + list: data.list.map(normalizeProductActivityTimelineItem) + })); +} + export async function fetchCreateProductMember(id: string, data: Api.Product.CreateProductMemberParams) { const result = await request({ ...safeJsonRequestConfig, diff --git a/src/typings/api/product.d.ts b/src/typings/api/product.d.ts index ca68d69..1a13cb4 100644 --- a/src/typings/api/product.d.ts +++ b/src/typings/api/product.d.ts @@ -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 & Pick & { diff --git a/src/typings/components.d.ts b/src/typings/components.d.ts index d2b2cae..bb79c3e 100644 --- a/src/typings/components.d.ts +++ b/src/typings/components.d.ts @@ -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'] diff --git a/src/views/product/dashboard/homepage.ts b/src/views/product/dashboard/homepage.ts new file mode 100644 index 0000000..7e1bb7b --- /dev/null +++ b/src/views/product/dashboard/homepage.ts @@ -0,0 +1,390 @@ +import dayjs from 'dayjs'; + +const productStatusLabelMap = { + active: '启用', + paused: '暂停', + archived: '归档', + abandoned: '废弃' +} as const satisfies Record; + +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(); + + 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 & { 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 = { + 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]; +} diff --git a/src/views/product/dashboard/index.vue b/src/views/product/dashboard/index.vue index dd0d7a5..cabdd8a 100644 --- a/src/views/product/dashboard/index.vue +++ b/src/views/product/dashboard/index.vue @@ -1,51 +1,81 @@ diff --git a/src/views/product/dashboard/mock.ts b/src/views/product/dashboard/mock.ts new file mode 100644 index 0000000..5b98a07 --- /dev/null +++ b/src/views/product/dashboard/mock.ts @@ -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[]; diff --git a/src/views/product/dashboard/modules/product-activity-timeline-dialog.vue b/src/views/product/dashboard/modules/product-activity-timeline-dialog.vue new file mode 100644 index 0000000..90aeb13 --- /dev/null +++ b/src/views/product/dashboard/modules/product-activity-timeline-dialog.vue @@ -0,0 +1,528 @@ + + + + + diff --git a/src/views/product/dashboard/modules/product-activity-timeline-panel.vue b/src/views/product/dashboard/modules/product-activity-timeline-panel.vue new file mode 100644 index 0000000..c38e7ce --- /dev/null +++ b/src/views/product/dashboard/modules/product-activity-timeline-panel.vue @@ -0,0 +1,275 @@ + + + + + diff --git a/src/views/product/dashboard/product-activity.ts b/src/views/product/dashboard/product-activity.ts new file mode 100644 index 0000000..ebf4f2c --- /dev/null +++ b/src/views/product/dashboard/product-activity.ts @@ -0,0 +1,367 @@ +import dayjs from 'dayjs'; + +const productStatusLabelMap = { + active: '启用', + paused: '暂停', + archived: '归档', + abandoned: '废弃' +} as const satisfies Record; + +const activityTypeLabelMap = { + product: '产品', + status: '状态', + member: '成员' +} as const satisfies Record; + +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; + +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); +} diff --git a/src/views/product/dashboard/shared.ts b/src/views/product/dashboard/shared.ts deleted file mode 100644 index 96d1f18..0000000 --- a/src/views/product/dashboard/shared.ts +++ /dev/null @@ -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(); - - 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[]; -}