From 03f31958fac540cd0e9ece0a53a4b34b65875789 Mon Sep 17 00:00:00 2001 From: shunli Date: Fri, 29 May 2026 11:25:45 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=20PC=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E4=B8=8E=E6=8A=A5=E9=94=80=E5=88=97=E8=A1=A8=E9=99=8D=E5=99=AA?= =?UTF-8?q?=EF=BC=88frontend-simplification-refactor=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/hyb-oa/frontend-react-vite-design.md | 30 ++ .../check.jsonl | 4 + .../design.md | 75 ++++ .../implement.jsonl | 4 + .../prd.md | 140 +++++++ .../task.json | 30 ++ client/src/pages/PortalHomePage.tsx | 239 ++++++----- client/src/pages/ReimbursementsPage.tsx | 383 ++++++++++-------- .../tests/e2e/announcements-presence.spec.ts | 2 + client/tests/e2e/helpers/smokeAssertions.ts | 2 +- client/tests/e2e/login-portal-ux.spec.ts | 27 +- .../manual/r70-trialrun-deep-assist.spec.ts | 2 + .../e2e/pc-core-workflow-visual-wave2.spec.ts | 3 +- .../e2e/r102-reimbursement-main-flow.spec.ts | 3 +- client/tests/e2e/r105-pretrial-visual.spec.ts | 2 +- .../e2e/system-action-confirmation.spec.ts | 7 +- client/tests/e2e/visual-portal.spec.ts | 24 +- client/tests/e2e/visual-shell.spec.ts | 8 +- 18 files changed, 689 insertions(+), 296 deletions(-) create mode 100644 .trellis/tasks/05-29-frontend-simplification-refactor/check.jsonl create mode 100644 .trellis/tasks/05-29-frontend-simplification-refactor/design.md create mode 100644 .trellis/tasks/05-29-frontend-simplification-refactor/implement.jsonl create mode 100644 .trellis/tasks/05-29-frontend-simplification-refactor/prd.md create mode 100644 .trellis/tasks/05-29-frontend-simplification-refactor/task.json diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index 58b18028..4a703166 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -195,6 +195,36 @@ point and prevents each route from inventing a different title/action layout. `Topbar`, `NavLinks`, `PageHeader`, `WorkspaceHeader`, login, Portal or other A-class pages. +## Convention: A-Class PC Task-First Pages + +**What**: A-class PC pages such as Portal, approvals, reimbursement list and +reimbursement detail should make the current user task obvious before exposing +secondary operations: + +- First visible task surface should answer: what needs attention, what can be + started, and where recent progress lives. +- Keep one primary action in the first screen. Allow at most two secondary + entries; move export, batch, delete, templates, complex filters, statistics + and admin/finance tools into `更多`, `高级 / 台账`, detail actions, role-only + panels or confirmation dialogs. +- Explanation text should only be default-visible for risk, exception or empty + states. Do not use repeated subtitles or notice cards to explain normal + workflow steps that the layout can express. +- Low-frequency capabilities are hidden or moved, not removed, unless the + current Trellis task explicitly deletes the capability. + +**Why**: hyb-oa serves small teams with overlapping roles. Core pages must make +the next click clear for ordinary employees and approvers, while keeping finance +and admin tools available without dominating the default path. + +**Tests Required**: + +- `cd client && npm run typecheck` +- `cd client && npm run build` +- Focused Playwright or screenshot checks for changed A-class PC pages, + asserting the task surface, primary action and secondary/advanced entry + behavior. + ## Convention: Detail And Mobile Command Surfaces **What**: PC detail pages and notification-like flows should reuse the Wave3 diff --git a/.trellis/tasks/05-29-frontend-simplification-refactor/check.jsonl b/.trellis/tasks/05-29-frontend-simplification-refactor/check.jsonl new file mode 100644 index 00000000..7104a872 --- /dev/null +++ b/.trellis/tasks/05-29-frontend-simplification-refactor/check.jsonl @@ -0,0 +1,4 @@ +{"file": ".trellis/spec/frontend/index.md", "reason": "前端规约入口,用于核对 React/Vite 页面实现是否走正确规则来源。"} +{"file": ".trellis/spec/hyb-oa/index.md", "reason": "hyb-oa 规约入口,用于核对项目级检查范围。"} +{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "核对 Trellis 任务合同、验证和提交治理要求。"} +{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "核对 PC 页面降噪规则、A 类页面体验和前端验证要求。"} diff --git a/.trellis/tasks/05-29-frontend-simplification-refactor/design.md b/.trellis/tasks/05-29-frontend-simplification-refactor/design.md new file mode 100644 index 00000000..961dea3b --- /dev/null +++ b/.trellis/tasks/05-29-frontend-simplification-refactor/design.md @@ -0,0 +1,75 @@ +# 前端页面降噪第一轮实现方案 + +## Scope + +第一轮只修改 PC Web 的两个样板页面: + +- `client/src/pages/PortalHomePage.tsx` +- `client/src/pages/ReimbursementsPage.tsx` + +不修改后端 API、数据库、权限、审批状态机、报销业务规则、移动端/企业微信页面。审批中心和报销详情作为第二轮扩展对象,本轮不改。 + +## Design Direction + +按 `frontend-design` 口径,本轮采用“安静、任务优先、工作台式极简”的方向:不做营销感英雄区,不新增装饰性视觉;重点是信息层级、主动作、低频能力后置和文案压缩。页面应保持 OA 场景所需的密度,但第一屏只能让用户看到下一步。 + +硬性规则: + +- 每个页面首屏只允许 1 个主按钮。 +- 每个页面首屏最多 2 个次级入口。 +- 说明文案只服务风险、异常、空态。 +- 低频能力隐藏/后置,不删除。 + +## Portal Home + +目标:从“综合仪表盘”改为“今日任务入口”。 + +实现要点: + +- 保留 `WorkbenchHero` 和现有数据源,压缩 subtitle,不再解释所有角色能力。 +- 新增或重组首屏任务区,优先展示: + - 当前最重要待办。 + - 可发起的主动作。 + - 最近申请/报销状态入口。 +- 首屏主按钮策略: + - 有审批待办时,主按钮为“处理待办”,指向 `/workflow/tasks`。 + - 无审批待办且可创建报销时,主按钮为“新建报销”,指向 `/reimbursements`。 + - 其他场景使用当前可用的最高优先级 quick action。 +- 首屏次级入口最多两个,例如“查看报销进度”“全部待办”。 +- `团队状态`、`通知/动态`、`角色重点`、`项目与组织`、`采用率` 等保留为首屏之后的次级区,不与任务入口并排抢注意力。 +- `GettingStartedCard` 只在真正的新用户引导时显示;避免长期占据核心任务区。 + +## Reimbursements + +目标:从“全功能台账”改为“员工进度视图”,台账能力后置。 + +实现要点: + +- `WorkspaceHeader` 的主按钮固定为“新建报销”。 +- 新建下拉仍保留模板创建、差旅关联等能力,但默认按钮语义保持单一;复杂来源放入下拉或“更多创建方式”。 +- 默认视图突出草稿待补、审批中/被驳回需处理、最近已通过/已支付,不把财务台账能力作为第一层。 +- 将 `导出`、`批量操作`、`仅自己/全范围`、`统计分析`、复杂筛选收纳到“高级/台账”区域或更多菜单,仅对有权限用户显示。 +- 删除现有普通状态说明卡: + - “草稿可以继续编辑或删除” + - “审批中单据以详情页为主” + 仅在空态、错误、批量模式、删除确认等风险/异常场景保留说明。 +- 列表行主动作保留“编辑/查看”,删除草稿后置到更多或仅在草稿状态的次级动作里出现。 + +## Shared Implementation Notes + +- 优先复用现有共享组件和 CSS 类:`PageScaffold`、`ListWorkspace`、`WorkspaceHeader`、`WorkbenchHero`、`FilterBar`、`PrimaryTable`、`MetricCard`、`StatusBadge`。 +- 不引入新的 UI 框架或大范围样式系统。 +- 若需要补少量页面级样式,优先扩展现有 `portal-*`、`ops-*` 类,避免在页面 JSX 中堆大量一次性 Tailwind。 +- 不改变 API 查询参数语义;如需“高级/台账”开关,优先用前端状态和现有 URL 参数表达。 +- 保留现有权限判断,不能让无权限用户看到不可执行入口。 + +## Verification Plan + +- `cd client && npm run typecheck` +- `cd client && npm run build` +- 对 PC 视口做前后截图或 Playwright 视觉检查: + - 首页员工路径:打开首页 → 新建报销/查看报销进度。 + - 首页审批人路径:打开首页 → 处理待办/进入审批中心。 + - 报销列表路径:打开报销列表 → 新建报销/查看需处理单据/进入高级台账。 +- 检查首屏动作预算:1 个主按钮、最多 2 个次级入口。 +- 检查说明文案预算:默认可见文案只服务风险、异常、空态。 diff --git a/.trellis/tasks/05-29-frontend-simplification-refactor/implement.jsonl b/.trellis/tasks/05-29-frontend-simplification-refactor/implement.jsonl new file mode 100644 index 00000000..4e8c7c93 --- /dev/null +++ b/.trellis/tasks/05-29-frontend-simplification-refactor/implement.jsonl @@ -0,0 +1,4 @@ +{"file": ".trellis/spec/frontend/index.md", "reason": "前端规约入口,确认 React/Vite 页面实现规则来源。"} +{"file": ".trellis/spec/hyb-oa/index.md", "reason": "hyb-oa 规约入口,确认任务应读取的项目级规则。"} +{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Trellis 任务、批准、验证和提交治理规则。"} +{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "PC 页面降噪、A 类页面体验和前端验证规则。"} diff --git a/.trellis/tasks/05-29-frontend-simplification-refactor/prd.md b/.trellis/tasks/05-29-frontend-simplification-refactor/prd.md new file mode 100644 index 00000000..600e055b --- /dev/null +++ b/.trellis/tasks/05-29-frontend-simplification-refactor/prd.md @@ -0,0 +1,140 @@ +# 前端页面降噪重构需求讨论 + +## Goal + +围绕 hyb-oa 当前前端页面“信息啰嗦、操作入口过多、用户理解和完成任务成本高”的问题,先明确重构目标、优先页面、保留能力和验收口径,再决定是否进入实现。 + +> Status: 第一轮 PC Web 样板实现已完成并通过本地验证。 + +## Scope + +- 第一轮只重构 PC Web 的工作台首页和报销列表:`PortalHomePage.tsx` 任务入口化,`ReimbursementsPage.tsx` 员工进度视图化。 +- 同步更新受影响的 Playwright/E2E 断言,保证现有 PC 核心路径仍按新信息架构验证。 +- 将稳定的页面降噪规则沉淀到 `.trellis/spec/hyb-oa/frontend-react-vite-design.md`。 +- 不修改后端 API、数据库、权限模型、审批/报销状态机、移动端/企业微信页面或生产配置。 + +## What I already know + +- 用户反馈的核心问题是:页面感觉啰嗦,需要操作的内容太多,对用户而言体验复杂。 +- 用户已确认首批治理路径为:工作台/首页 → 待办/审批 → 报销列表/报销详情。 +- 用户已确认本次核心目标是先把“我现在该点什么”变清楚。 +- 用户已确认首批角色视角优先服务普通员工和审批人:员工提交/查状态,审批人处理待办;财务/管理员先只做“不挡路”。 +- 项目是面向 10 人以下国内科技公司的 OA,角色重叠、审批链短、使用频次高。 +- PC 端主要承担批量查看、对账、审批、财务复核、导出和管理配置。 +- 手机/企业微信端主要承担提交、补材料、查状态、处理待办和从消息直达业务详情。 +- 产品事实源将页面分为 A/B/C 类:A 类核心流程包括首页、审批中心、报销列表/详情、移动报销详情、通知中心等。 +- 前端已有共享模式:`PageScaffold`、`ListWorkspace`、`WorkspaceHeader`、`WorkbenchHero`、`EntityOverviewHero`、`PrimaryTable`、`StatStrip` 等。 +- 前端规约要求 PC 页面保持“密集但有序”的 OA 操作布局,不做营销式页面;移动页面复用统一 mobile shell、卡片/列表行、安全区和粘性操作模式。 + +## Assumptions + +- 本轮讨论先做需求收敛,不直接修改代码。 +- “降噪”不等于砍掉业务能力,而是重新分层:高频主动作前置,低频/高风险/排障动作后置或折叠。 +- 优先重构 A 类核心流程比全面铺开更可能产生可感知收益。 +- 需要区分普通员工、审批人、财务/管理员,不同角色看到的入口密度应不同。 +- 本次优先治理同屏操作入口过多、层级不清,而不是优先删除业务数据字段。 +- 本轮先做前端层降噪,不改后端流程、接口、权限模型、状态机或审批/报销业务规则。 +- 首批实现限定为 PC Web;移动端/企业微信页面本轮不做重构,只做必要回归确认。 +- 实现节奏采用“先样板、再扩展”:第一轮先做工作台和报销列表,第二轮再扩展到审批中心和报销详情。 +- 第一轮样板采用硬性首屏动作预算:每个页面首屏只允许 1 个主按钮、最多 2 个次级入口,其余动作进入“更多/高级/详情”。 +- 页面说明文案采用预算规则:只服务风险、异常、空态;常规流程说明不作为默认可见内容。 +- 低频能力本轮采用“隐藏/后置,不删除”原则,不破坏现有功能能力。 + +## Open Questions + +- 当前无阻塞问题;后续若实现发现现有接口无法支撑目标视图,先回到 PRD 讨论,不直接改后端。 + +## Requirements (evolving) + +- 首批治理路径锁定为:工作台/首页 → 待办/审批 → 报销列表/报销详情。 +- 首批主任务按“员工提交/查状态、审批人处理待办”定义。 +- 工作台首页从“综合仪表盘”调整为“今日任务入口”,首屏只回答:我现在要处理什么、我能发起什么、我最近的单据到哪了。 +- 审批中心默认视图调整为“待我处理优先”,首屏只显示审批人当前可处理的任务。 +- 审批中心的“我发起的”“已处理”“全部/历史”放到次级 Tab 或筛选;批量审批、统计摘要、流程解释、历史记录不压在第一层。 +- 审批中心行内主动作保留“处理/查看”,避免一行出现多个同级操作。 +- 报销列表默认从“全功能台账”调整为“员工进度视图”,首屏突出新建报销、草稿待补、审批中/被驳回需处理、最近已通过/已支付状态。 +- 报销列表中的模板创建、关联差旅、导出 CSV/Excel、批量审批、复杂筛选、删除说明后置;财务/管理员可通过角色入口或高级视图进入台账模式。 +- 报销详情从“所有信息平铺页面”调整为“状态驱动页面”,顶部只回答当前状态、下一步、谁要处理。 +- 报销详情按状态展示主动作:草稿为补明细/补发票/提交;审批中为看进度/催办;被驳回为看原因/修改后重提;已通过/已支付为查看结果/下载或留档。 +- 报销详情中的明细、发票、附件、预算、审批记录、财务导出继续保留,但按当前状态相关性排序或折叠,不在首屏全部平铺。 +- 实现范围限定为前端信息架构和交互层重排:默认视图、操作显隐、排序、折叠、文案和布局。 +- 不在本轮修改后端 API、数据库、权限、状态机、审批动作、报销数据结构或业务规则。 +- 首批实现限定 PC Web 的工作台、审批中心、报销列表和报销详情;移动端保持现状,不纳入本轮重构。 +- 第一轮实现样板限定为 PC Web 的工作台和报销列表,用于验证入口降噪、默认视图重排和主动作体系。 +- 审批中心和报销详情暂作为第二轮扩展对象,第一轮不直接修改。 +- 工作台首屏主按钮根据当前用户上下文确定:有审批待办时优先“处理待办”,否则优先“新建报销”或当前最相关发起动作。 +- 报销列表首屏主按钮固定为“新建报销”,次级入口限定为“查看需要我处理的单据”和“高级筛选/台账”。 +- 删除、提交、驳回、预算超限、OCR 失败、权限不足、空列表等场景可以保留解释文案;普通状态下删除“先看状态再处理”“进入详情查看附件审批历史”等重复操作说明。 +- 模板创建、差旅关联、导出、批量处理、高级筛选、删除草稿等能力不删除,后置到“更多”“高级筛选/台账”“详情页动作区”或特定状态下再出现。 +- 每个目标页面需要明确 1 个主任务和 1-2 个主动作,让用户能立即判断下一步。 +- 导出、批量、删除、催办、模板、差旅关联、调试/错误详情等低频或高风险动作应后置到更多菜单、详情页、权限场景或二次确认中。 +- 财务导出、批量审批、系统管理、排障信息可以存在,但不应抢占普通员工和审批人的第一屏注意力。 +- 首页中的团队状态、近期动态、公告、项目、组织、系统摘要、采用率等内容保留为次级区或折叠区,不与待办、报销、审批抢首屏注意力。 +- 降低目标页面首屏信息和操作负担。 +- 保留真实业务流程必须动作,不隐藏用户完成任务所必需的入口。 +- 不把低频配置入口放到高频员工路径里。 +- 普通用户界面不暴露 UUID、内部状态码、调试字段或服务端实现术语。 +- A 类核心流程优先保证试运行路径顺畅。 + +## Acceptance Criteria (evolving) + +- [x] 明确本次重构的首批目标页面或用户路径。 +- [x] 明确第一轮样板页面:工作台和报销列表。 +- [x] 明确第一轮样板首屏动作预算。 +- [x] 明确说明文案预算。 +- [x] 明确低频能力隐藏/后置、不删除。 +- [x] 明确每个目标页面的主任务、主动作、次级动作和隐藏/延后动作。 +- [x] 明确不做范围,避免把降噪扩大成全站重写。 +- [x] 明确前端实现前需要遵守的设计与验证口径。 +- [x] 第一轮实现后,员工路径“打开首页 → 新建报销/查看报销进度”可通过可视化检查确认更清楚。 +- [x] 第一轮实现后,审批人路径“打开首页 → 处理待办/进入审批中心”可通过可视化检查确认更清楚。 +- [x] 第一轮实现后,保留前后截图对比,并检查首屏满足 1 个主按钮、最多 2 个次级入口。 +- [x] 第一轮实现后,说明文案只服务风险、异常、空态,普通流程解释不作为默认可见内容。 + +## Definition of Done + +- PRD 记录用户确认后的范围和验收标准。 +- 若进入实现,先按 Trellis workflow 激活任务并读取前端规约。 +- 前端改动需执行 `cd client && npm run typecheck && npm run build`。 +- 涉及核心页面时补充必要 Playwright/视觉验证。 +- 面向用户可测试的页面改动,部署和公网检查通过后再通知用户测试。 + +## Out of Scope + +- 本轮讨论阶段不直接改代码。 +- 不重构移动端/企业微信页面;仅在 PC 改动可能影响共享逻辑时做必要回归。 +- 不处理差旅能力恢复。 +- 不调整权限模型、真实数据、生产配置或企业微信写入。 +- 不修改后端 API、数据库、审批流程、报销状态机或权限判定。 +- 不做全站一次性重写,除非后续明确批准。 + +## Technical Notes + +- 当前任务:`.trellis/tasks/05-29-frontend-simplification-refactor/` +- 体验事实源:`docs/产品设计规则.md` +- 前端规约入口:`.trellis/spec/frontend/index.md` +- hyb-oa 前端规则:`.trellis/spec/hyb-oa/frontend-react-vite-design.md` +- 路由入口:`client/src/router/index.tsx` +- 首批目标页面当前体量:`PortalHomePage.tsx` 1260 行、`WorkflowTasksPage.tsx` 460 行、`ReimbursementsPage.tsx` 992 行、`ReimbursementDetailPage.tsx` 1923 行。 +- 初步代码观察:工作台、报销列表和报销详情都同时承载状态摘要、说明文本、筛选、批量、导出、附件/票据、审批记录等内容,复杂感更像“同层级内容过多”而不是单纯视觉样式问题。 +- 视觉和交互设计裁决后续应使用 `frontend-design` skill;本轮先按 `grill-me` 做需求拷问。 + +## Verification + +- `cd client && npm run typecheck`:通过。 +- `cd client && npm run build`:通过。 +- `cd client && npx playwright test tests/e2e/visual-shell.spec.ts --grep "PC 首页与报销列表" --workers=1 --reporter=list`:1 passed。 +- `cd client && npx playwright test tests/e2e/login-portal-ux.spec.ts --grep "Portal (员工视角|管理视角|bootstrap|域管理)" --workers=1 --reporter=list`:4 passed。 +- `python3 ./.trellis/scripts/task.py validate .trellis/tasks/05-29-frontend-simplification-refactor`:通过。 +- `node scripts/qa/check-trellis-governance.mjs`:通过。 +- `git diff --check`:通过。 + +## Test Plan + +- `cd client && npm run typecheck` +- `cd client && npm run build` +- `cd client && npx playwright test tests/e2e/visual-shell.spec.ts --grep "PC 首页与报销列表" --workers=1 --reporter=list` +- `cd client && npx playwright test tests/e2e/login-portal-ux.spec.ts --grep "Portal (员工视角|管理视角|bootstrap|域管理)" --workers=1 --reporter=list` +- `python3 ./.trellis/scripts/task.py validate .trellis/tasks/05-29-frontend-simplification-refactor` +- `node scripts/qa/check-trellis-governance.mjs` +- `git diff --check` diff --git a/.trellis/tasks/05-29-frontend-simplification-refactor/task.json b/.trellis/tasks/05-29-frontend-simplification-refactor/task.json new file mode 100644 index 00000000..71c5f5a0 --- /dev/null +++ b/.trellis/tasks/05-29-frontend-simplification-refactor/task.json @@ -0,0 +1,30 @@ +{ + "id": "frontend-simplification-refactor", + "name": "frontend-simplification-refactor", + "title": "前端页面降噪重构需求讨论", + "description": "第一轮 PC Web 前端页面降噪重构:工作台任务入口化,报销列表员工进度视图化。", + "status": "in_progress", + "dev_type": null, + "scope": "client", + "package": null, + "priority": "P2", + "creator": "shunli", + "assignee": "shunli", + "createdAt": "2026-05-29", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + "client/src/pages/PortalHomePage.tsx", + "client/src/pages/ReimbursementsPage.tsx", + ".trellis/spec/hyb-oa/frontend-react-vite-design.md" + ], + "notes": "", + "meta": {} +} diff --git a/client/src/pages/PortalHomePage.tsx b/client/src/pages/PortalHomePage.tsx index 980f99f1..e08e6035 100644 --- a/client/src/pages/PortalHomePage.tsx +++ b/client/src/pages/PortalHomePage.tsx @@ -67,6 +67,16 @@ type QuickActionConfig = { disabled?: boolean; }; +type PortalFocusAction = { + label: string; + to: string; +}; + +type PortalFocusLink = { + label: string; + to: string; +}; + type GettingStartedChecklistItem = { key: string; title: string; @@ -129,11 +139,6 @@ function compactCards(cards: PortalHomePayload["cards"], excludedKeys: string[]) return cards.filter((card) => !excludedKeys.includes(card.key)); } -function formatWeeklyStatus(payload: PortalHomePayload["weeklyReport"]) { - if (!payload) return "未启用"; - return payload.status === "submitted" ? "已提交" : "待填写"; -} - function formatRelativeTime(value: string | null) { if (!value) { return "-"; @@ -411,6 +416,7 @@ function SecondaryCardsPanel({ title, cards }: { title: string; cards: PortalHom {cards.map((card) => ( {cards.length ? cards.slice(0, 6).map((card) => ( - + {card.title} {card.value} @@ -713,47 +719,77 @@ function QuickActionsPanel({ ); } -function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) { - const visibleItems = items.slice(0, 5); - const hiddenCount = Math.max(items.length - visibleItems.length, 0); - +function TodayTaskPanel({ + primaryAction, + secondaryLinks, + focusItem, + hiddenFocusCount, +}: { + primaryAction: PortalFocusAction | null; + secondaryLinks: PortalFocusLink[]; + focusItem: TodoAggregateItem | null; + hiddenFocusCount: number; +}) { return ( - -
- {visibleItems.length ? visibleItems.map((item) => ( - -
-
- {item.icon} -
-
-
-
{item.title}
- {item.sourceLabel} - {item.severity === "high" ? 高优先 : null} - {item.severity === "warning" ? 关注 : null} + +
+
+
+
当前焦点
+ {hiddenFocusCount > 0 ?
另有 {hiddenFocusCount} 项
: null} +
+ + {focusItem ? ( + +
+
+ {focusItem.icon}
-
进入
+
+
{focusItem.title}
+
{focusItem.sourceLabel}
+
+ arrow_forward
+ + ) : ( +
+ 暂无必须立即处理的事项。
- - )) : } -
- {hiddenCount > 0 ? ( -
- 另有 {hiddenCount} 项。 - 查看全部待办 + )}
- ) : null} + +
+
下一步
+ {primaryAction ? ( + + ) : ( + + )} + + {secondaryLinks.length ? ( +
+ {secondaryLinks.map((item) => ( + + ))} +
+ ) : null} +
+
); } @@ -1112,6 +1148,33 @@ export function PortalHomePage() { }); } + const approvalAction = quickActions.find((item) => item.key === "approval"); + const expenseAction = quickActions.find((item) => item.key === "expense"); + const fallbackAction = quickActions.find((item): item is QuickActionConfig & { to: string } => Boolean(item.to)); + const primaryAction: PortalFocusAction | null = + payload.pendingTasks.count > 0 && approvalAction?.to + ? { label: "处理待办", to: approvalAction.to } + : expenseAction?.to + ? { label: "新建报销", to: expenseAction.to } + : fallbackAction + ? { label: fallbackAction.label, to: fallbackAction.to } + : null; + const secondaryLinkCandidates: Array = [ + hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS) + ? { label: "报销进度", to: "/reimbursements" } + : null, + payload.pendingTasks.count > 0 && approvalAction?.to + ? { label: "审批中心", to: approvalAction.to } + : null, + todoAggregateItems.length > 1 + ? { label: "全部待办", to: "/todos" } + : null, + ]; + const secondaryLinks = secondaryLinkCandidates + .filter((item): item is PortalFocusLink => item !== null) + .filter((item) => item.to !== primaryAction?.to) + .slice(0, 2); + return ( @@ -1123,12 +1186,12 @@ export function PortalHomePage() { title={`你好,${payload.greeting.name}`} className="workbench-hero--compact" subtitle={roleCapabilities.canUseFinance - ? "付款、风险、审批。" + ? "先处理今天的付款、风险和审批。" : roleCapabilities.canManageSystem - ? "先看待处理,再进入配置、审计和通知。" + ? "先处理待办,再进入管理入口。" : isManagerView - ? "审批待办、团队状态和周报缺口集中查看。" - : "先看待办,再提交申请、补材料或查看状态。"} + ? "先处理待办,再看团队状态。" + : "先处理待办,再发起申请。"} actions={ !payload.workspace.persisted && canSetupWorkspace ? ( - - - startCreateReimbursement()}> - 空白报销 - - - -
- 从模板创建 -
- {templateOptionsQuery.isLoading ? ( - 正在加载模板... - ) : templateOptions.length ? ( - templateOptions.map((template) => ( - startCreateReimbursement({ templateId: template.id })} - > - {template.name} - - )) - ) : ( - 暂无可用模板 - )} - - -
- 关联差旅申请 -
- {linkableTravelRequestsQuery.isLoading ? ( - 正在加载差旅申请... - ) : linkableTravelRequests.length ? ( - linkableTravelRequests.map((travelRequest) => ( - startCreateReimbursement({ linkedTravelRequestId: travelRequest.id })} - > - {travelRequest.requestNo ?? travelRequest.id} · {travelRequest.purpose} - - )) - ) : ( - 暂无可关联的差旅申请 - )} -
- - ) : null} -
+ canCreateExpense ? ( + + ) : null } /> @@ -462,13 +423,6 @@ export function ReimbursementsPage() {

) : null} - - - - 0 ? "warning" : "neutral"} /> - - -
setSearchParams({ status: value, page: "1", scope })} > - {[ - { value: "draft", label: "草稿" }, - { value: "reviewing", label: "审批中" }, - { value: "approved", label: "已通过" }, - { value: "paid", label: "已支付" }, - { value: "rejected", label: "已驳回" }, - { value: "cancelled", label: "已取消" }, - ].map((item) => ( + {REIMBURSEMENT_STATUS_OPTIONS.map((item) => (
- {showProxyOwnerSelector ? ( - + 待我处理 + ) : null} + +
+
- {canViewAll ? ( - <> - - - - - ) : null} + {showAdvancedTools ? ( +
+
+
+
当前列表
+
{stats.currentPageCount} 条 / 共 {listQuery.data?.total ?? 0} 条
+
+
+
当前金额
+
{formatCurrency(stats.currentPageAmount)}
+
+
+
审批中
+
0 ? "mt-1 font-semibold text-amber-700" : "mt-1 font-semibold text-zinc-900"}>{stats.reviewing} 条
+
+
+
已支付
+
{stats.paid} 条
+
+
- - +
+ {showProxyOwnerSelector ? ( + + ) : null} + + {canCreateExpense ? ( + + + + + +
+ 从模板创建 +
+ {templateOptionsQuery.isLoading ? ( + 正在加载模板... + ) : templateOptions.length ? ( + templateOptions.map((template) => ( + startCreateReimbursement({ templateId: template.id })} + > + {template.name} + + )) + ) : ( + 暂无可用模板 + )} + + +
+ 关联差旅申请 +
+ {linkableTravelRequestsQuery.isLoading ? ( + 正在加载差旅申请... + ) : linkableTravelRequests.length ? ( + linkableTravelRequests.map((travelRequest) => ( + startCreateReimbursement({ linkedTravelRequestId: travelRequest.id })} + > + {travelRequest.requestNo ?? travelRequest.id} · {travelRequest.purpose} + + )) + ) : ( + 暂无可关联的差旅申请 + )} +
+
+ ) : null} + + {canViewAll ? ( + <> + + + + + ) : null} + + + - - - setExportConfirmFormat("csv")} - > - 导出 CSV - - setExportConfirmFormat("xlsx")} - > - 导出 Excel - - - + + + setExportConfirmFormat("csv")} + > + 导出 CSV + + setExportConfirmFormat("xlsx")} + > + 导出 Excel + + + - {canShowBatchEntry ? ( - - ) : null} + {canShowBatchEntry ? ( + + ) : null} +
-
+ ) : null} {batchMode ? (
@@ -595,16 +635,6 @@ export function ReimbursementsPage() { ) : null} - {status === "draft" ? ( - - 提交后的单据进入审批链;已通过、已支付单据不再提供删除入口。 - - ) : status === "reviewing" ? ( - - 查看当前节点、票据、附件和审批历史后再处理;有批量权限时只会选择你可处理的待办。 - - ) : null} - {listQuery.isLoading ? : null} {listQuery.isError ? ( @@ -810,15 +840,26 @@ export function ReimbursementsPage() { {canDeleteDraft ? ( - + + + + + + handleDeleteDraft(item)} + > + 删除草稿 + + + ) : null}
diff --git a/client/tests/e2e/announcements-presence.spec.ts b/client/tests/e2e/announcements-presence.spec.ts index c4632a52..7ac801b8 100644 --- a/client/tests/e2e/announcements-presence.spec.ts +++ b/client/tests/e2e/announcements-presence.spec.ts @@ -36,8 +36,10 @@ test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇 await page.getByRole("button", { name: "保存状态" }).click(); await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 }); await page.goto("/portal"); + await page.locator("summary").filter({ hasText: "团队与通知" }).click(); await page.getByRole("tab", { name: "公告" }).click(); await expect(page.getByText(announcementTitle)).toBeVisible(); + await page.locator("summary").filter({ hasText: "更多入口" }).click(); await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible(); await logout(page, EMPLOYEE.name); diff --git a/client/tests/e2e/helpers/smokeAssertions.ts b/client/tests/e2e/helpers/smokeAssertions.ts index 402cdf02..6bed0d6c 100644 --- a/client/tests/e2e/helpers/smokeAssertions.ts +++ b/client/tests/e2e/helpers/smokeAssertions.ts @@ -24,7 +24,7 @@ type Envelope = { export async function assertPortalLoaded(page: Page) { await expect(page).toHaveURL(/\/portal/); await expect(page.getByRole("link", { name: "Portal" }).first()).toBeVisible(); - await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); } export async function assertForbiddenPage(page: Page) { diff --git a/client/tests/e2e/login-portal-ux.spec.ts b/client/tests/e2e/login-portal-ux.spec.ts index 2ebabe6c..104e430d 100644 --- a/client/tests/e2e/login-portal-ux.spec.ts +++ b/client/tests/e2e/login-portal-ux.spec.ts @@ -512,25 +512,26 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => { await login(page, EMPLOYEE); - await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible(); - - await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); + await expect(page.getByText("当前焦点")).toBeVisible(); + await expect(page.getByText("下一步")).toBeVisible(); + await expect(page.getByRole("link", { name: "新建报销" })).toBeVisible(); + await page.locator("summary").filter({ hasText: "更多入口" }).click(); const labels = await page.locator('[aria-label^="portal-card-"]').evaluateAll((nodes) => nodes.map((node) => node.getAttribute("aria-label")), ); - expect(labels).toEqual([ + expect(labels.slice(0, 4)).toEqual([ "portal-card-my_todos", "portal-card-today_presence", "portal-card-my_weekly_report", "portal-card-my_projects", ]); - await expect(page.locator("summary").filter({ hasText: "更多入口" })).toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible(); + await page.locator("summary").filter({ hasText: "团队与通知" }).click(); await expect(page.getByRole("tab", { name: "公告" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "常用" })).toBeVisible(); await expect(page.getByRole("heading", { name: "团队状态" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "今天先处理" })).toBeVisible(); await expect(page.getByRole("heading", { name: "通知" })).toBeVisible(); }); @@ -544,19 +545,21 @@ test("Portal 管理视角重排正确,更多入口默认折叠", async ({ page await login(page, MANAGER); - await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); + await expect(page.getByRole("link", { name: "处理待办" })).toBeVisible(); + await page.locator("summary").filter({ hasText: "角色重点" }).click(); const labels = await page.locator('[aria-label^="portal-card-"]').evaluateAll((nodes) => nodes.map((node) => node.getAttribute("aria-label")), ); - expect(labels).toEqual([ + expect(labels.slice(0, 4)).toEqual([ "portal-card-pending_approvals", "portal-card-team_presence", "portal-card-weekly_missing", "portal-card-weekly_help", ]); - await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible(); await expect(page.getByText("试点观测摘要")).toBeVisible(); await expect(page.getByRole("link", { name: "采购申请" })).toBeVisible(); await expect(page.getByRole("link", { name: "差旅申请" })).not.toBeVisible(); @@ -636,7 +639,7 @@ test("Portal bootstrap refresh 首次瞬时失败后重试成功仍保留登录 await page.goto("/portal"); await expect(page).toHaveURL(/\/portal/); - await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); expect(refreshAttempts).toBeGreaterThanOrEqual(2); }); @@ -650,7 +653,9 @@ test("Portal 域管理角色也进入经理视图", async ({ page }) => { await login(page, EMPLOYEE); + await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible(); + await page.locator("summary").filter({ hasText: "角色重点" }).click(); await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible(); await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible(); }); diff --git a/client/tests/e2e/manual/r70-trialrun-deep-assist.spec.ts b/client/tests/e2e/manual/r70-trialrun-deep-assist.spec.ts index 31a3d411..f75938ca 100644 --- a/client/tests/e2e/manual/r70-trialrun-deep-assist.spec.ts +++ b/client/tests/e2e/manual/r70-trialrun-deep-assist.spec.ts @@ -194,8 +194,10 @@ test("R70 深层自动辅助试运行:周报待办、公告团队状态、真 await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 }); await page.goto("/portal"); + await page.locator("summary").filter({ hasText: "团队与通知" }).click(); await page.getByRole("tab", { name: "公告" }).click(); await expect(page.getByText(announcementTitle)).toBeVisible({ timeout: 30_000 }); + await page.locator("summary").filter({ hasText: "更多入口" }).click(); await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible(); return announcementTitle; }); diff --git a/client/tests/e2e/pc-core-workflow-visual-wave2.spec.ts b/client/tests/e2e/pc-core-workflow-visual-wave2.spec.ts index b10bfa34..f0bdf2fb 100644 --- a/client/tests/e2e/pc-core-workflow-visual-wave2.spec.ts +++ b/client/tests/e2e/pc-core-workflow-visual-wave2.spec.ts @@ -316,7 +316,8 @@ test("PC 核心工作流 Wave2 视觉截图", async ({ page }) => { await page.getByTestId("login-password").fill(visualUser.password); await page.getByTestId("login-submit").click(); await expect(page).toHaveURL(/\/portal/); - await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); + await expect(page.getByRole("link", { name: "处理待办" })).toBeVisible(); await capture(page, "02-portal"); await page.goto("/reimbursements?status=reviewing&scope=all"); diff --git a/client/tests/e2e/r102-reimbursement-main-flow.spec.ts b/client/tests/e2e/r102-reimbursement-main-flow.spec.ts index 21348b89..bc34d84d 100644 --- a/client/tests/e2e/r102-reimbursement-main-flow.spec.ts +++ b/client/tests/e2e/r102-reimbursement-main-flow.spec.ts @@ -323,7 +323,8 @@ test.describe("R102 reimbursement main flow", () => { await expect(page.getByTestId("reimbursement-row").getByText(formNumber)).toBeVisible(); await expect(page.getByText(reimbursementId)).toHaveCount(0); - await page.getByRole("button", { name: "删除" }).first().click(); + await page.getByRole("button", { name: "更多" }).first().click(); + await page.getByRole("menuitem", { name: "删除草稿" }).click(); await expect(page.getByTestId("action-confirm-dialog")).toContainText(formNumber); await page.getByTestId("action-confirm-submit").click(); await expect(page.getByTestId("reimbursement-row")).toHaveCount(0); diff --git a/client/tests/e2e/r105-pretrial-visual.spec.ts b/client/tests/e2e/r105-pretrial-visual.spec.ts index 9ed9353e..87062669 100644 --- a/client/tests/e2e/r105-pretrial-visual.spec.ts +++ b/client/tests/e2e/r105-pretrial-visual.spec.ts @@ -120,7 +120,7 @@ test.describe("R105 pretrial visual acceptance", () => { await loginLiveUser(page, "employee"); await page.goto("/portal"); - await expect(page.locator('[aria-label^="portal-card-"]').first()).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); await expectNoLegacyDesignCopy(page); await page.screenshot({ path: screenshotPath("desktop-portal.png"), fullPage: true }); diff --git a/client/tests/e2e/system-action-confirmation.spec.ts b/client/tests/e2e/system-action-confirmation.spec.ts index a4a509f2..845df9f1 100644 --- a/client/tests/e2e/system-action-confirmation.spec.ts +++ b/client/tests/e2e/system-action-confirmation.spec.ts @@ -277,17 +277,20 @@ test.describe("R7E 全站高风险操作二次确认", () => { await page.goto("/reimbursements"); await expect(page.getByTestId("reimbursements-table")).toBeVisible(); - await page.getByTestId("reimbursement-row").getByRole("button", { name: "删除" }).click(); + await page.getByTestId("reimbursement-row").getByRole("button", { name: "更多" }).click(); + await page.getByRole("menuitem", { name: "删除草稿" }).click(); await expect(page.getByTestId("action-confirm-dialog")).toContainText("删除后无法恢复"); expect(calls.deleteReimbursement).toBe(0); await page.getByTestId("action-confirm-cancel").click(); expect(calls.deleteReimbursement).toBe(0); - await page.getByTestId("reimbursement-row").getByRole("button", { name: "删除" }).click(); + await page.getByTestId("reimbursement-row").getByRole("button", { name: "更多" }).click(); + await page.getByRole("menuitem", { name: "删除草稿" }).click(); await page.getByTestId("action-confirm-submit").click(); await expect.poll(() => calls.deleteReimbursement).toBe(1); + await page.getByRole("button", { name: "高级 / 台账" }).click(); await page.getByTestId("reimbursements-export").click(); await page.getByTestId("reimbursements-export-csv").click(); await expect(page.getByTestId("action-confirm-dialog")).toContainText("导出文件可能包含业务或个人信息"); diff --git a/client/tests/e2e/visual-portal.spec.ts b/client/tests/e2e/visual-portal.spec.ts index 0185fa61..ee6b382d 100644 --- a/client/tests/e2e/visual-portal.spec.ts +++ b/client/tests/e2e/visual-portal.spec.ts @@ -20,12 +20,11 @@ test.describe("visual portal", () => { await page.setViewportSize({ width: 1440, height: 1200 }); await login(page, EMPLOYEE); - await expect(page.locator('[aria-label="portal-card-my_todos"]')).toBeVisible(); - await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible(); - await expect(page.locator('[aria-label="portal-card-my_weekly_report"]')).toBeVisible(); - await expect(page.locator('[aria-label="portal-card-my_projects"]')).toBeVisible(); - await expect(page.locator("summary").filter({ hasText: "我的申请与资产 / 继续工作" })).toBeVisible(); - await expect(page.getByRole("tab", { name: "公告" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); + await expect(page.getByText("当前焦点")).toBeVisible(); + await expect(page.getByText("下一步")).toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "更多入口" })).toBeVisible(); await attachScreenshot(page, testInfo, "portal-employee.png"); }); @@ -35,12 +34,11 @@ test.describe("visual portal", () => { await login(page, ADMIN); await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toHaveCount(0); - await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible(); - await expect(page.locator('[aria-label="portal-card-weekly_missing"]')).toBeVisible(); - await expect(page.locator('[aria-label="portal-card-weekly_help"]')).toBeVisible(); - await expect(page.locator("summary").filter({ hasText: "角色重点 / 常用入口" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); + await expect(page.getByText("下一步")).toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible(); await expect(page.getByText("试点观测摘要")).toBeVisible(); - await expect(page.getByRole("tab", { name: "公告" })).toBeVisible(); await attachScreenshot(page, testInfo, "portal-admin.png"); await logout(page, ADMIN.name); @@ -51,8 +49,8 @@ test.describe("visual portal", () => { await login(page, FINANCE); await expect(page.getByText("财务工作台")).toBeVisible(); - await expect(page.getByText("财务处理")).toBeVisible(); - await expect(page.locator("summary").filter({ hasText: "角色重点 / 常用入口" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible(); await attachScreenshot(page, testInfo, "portal-finance.png"); await logout(page, FINANCE.name); diff --git a/client/tests/e2e/visual-shell.spec.ts b/client/tests/e2e/visual-shell.spec.ts index ad2f36a0..e29002a2 100644 --- a/client/tests/e2e/visual-shell.spec.ts +++ b/client/tests/e2e/visual-shell.spec.ts @@ -334,15 +334,17 @@ test.describe("visual shell", () => { await page.setViewportSize({ width: 1440, height: 1024 }); await loginVisualUser(page); - await expect(page.getByRole("heading", { name: "今天先处理" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "常用动作" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); + await expect(page.getByText("下一步")).toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible(); await expect(page.locator("summary").filter({ hasText: /角色重点|更多入口/ })).toBeVisible(); await attachScreenshot(page, testInfo, "pc-portal-wave1.png"); await page.goto("/reimbursements"); await expect(page.getByRole("heading", { name: "我的报销" })).toBeVisible(); await expect(page.getByTestId("reimbursements-create")).toBeVisible(); - await expect(page.getByTestId("reimbursements-export")).toBeVisible(); + await expect(page.getByRole("button", { name: "高级 / 台账" })).toBeVisible(); + await expect(page.getByTestId("reimbursements-export")).not.toBeVisible(); await expect(page.getByTestId("reimbursements-status-tabs")).toBeVisible(); await expect(page.getByTestId("reimbursements-table")).toBeVisible(); await attachScreenshot(page, testInfo, "pc-reimbursements-wave1.png");