重构 PC 首页与报销列表降噪(frontend-simplification-refactor)
这个提交包含在:
@@ -195,6 +195,36 @@ point and prevents each route from inventing a different title/action layout.
|
|||||||
`Topbar`, `NavLinks`, `PageHeader`, `WorkspaceHeader`, login, Portal or other
|
`Topbar`, `NavLinks`, `PageHeader`, `WorkspaceHeader`, login, Portal or other
|
||||||
A-class pages.
|
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
|
## Convention: Detail And Mobile Command Surfaces
|
||||||
|
|
||||||
**What**: PC detail pages and notification-like flows should reuse the Wave3
|
**What**: PC detail pages and notification-like flows should reuse the Wave3
|
||||||
|
|||||||
@@ -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 类页面体验和前端验证要求。"}
|
||||||
@@ -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 个次级入口。
|
||||||
|
- 检查说明文案预算:默认可见文案只服务风险、异常、空态。
|
||||||
@@ -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 类页面体验和前端验证规则。"}
|
||||||
@@ -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`
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
+147
-92
@@ -67,6 +67,16 @@ type QuickActionConfig = {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PortalFocusAction = {
|
||||||
|
label: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PortalFocusLink = {
|
||||||
|
label: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
|
||||||
type GettingStartedChecklistItem = {
|
type GettingStartedChecklistItem = {
|
||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -129,11 +139,6 @@ function compactCards(cards: PortalHomePayload["cards"], excludedKeys: string[])
|
|||||||
return cards.filter((card) => !excludedKeys.includes(card.key));
|
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) {
|
function formatRelativeTime(value: string | null) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return "-";
|
return "-";
|
||||||
@@ -411,6 +416,7 @@ function SecondaryCardsPanel({ title, cards }: { title: string; cards: PortalHom
|
|||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<MetricCard
|
<MetricCard
|
||||||
key={card.key}
|
key={card.key}
|
||||||
|
ariaLabel={`portal-card-${card.key}`}
|
||||||
title={card.title}
|
title={card.title}
|
||||||
value={card.value}
|
value={card.value}
|
||||||
href={card.href}
|
href={card.href}
|
||||||
@@ -481,7 +487,7 @@ function CompactMetricLinks({ title, cards }: { title: string; cards: PortalHome
|
|||||||
return (
|
return (
|
||||||
<CompactGroup title={title}>
|
<CompactGroup title={title}>
|
||||||
{cards.length ? cards.slice(0, 6).map((card) => (
|
{cards.length ? cards.slice(0, 6).map((card) => (
|
||||||
<Link key={card.key} to={card.href} className="portal-compact-row">
|
<Link key={card.key} to={card.href} className="portal-compact-row" aria-label={`portal-card-${card.key}`}>
|
||||||
<span className="min-w-0 truncate">{card.title}</span>
|
<span className="min-w-0 truncate">{card.title}</span>
|
||||||
<span className={card.severity === "high" || card.severity === "warning" ? "shrink-0 font-semibold text-amber-700" : "shrink-0 font-semibold text-slate-900"}>
|
<span className={card.severity === "high" || card.severity === "warning" ? "shrink-0 font-semibold text-amber-700" : "shrink-0 font-semibold text-slate-900"}>
|
||||||
{card.value}
|
{card.value}
|
||||||
@@ -713,47 +719,77 @@ function QuickActionsPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
|
function TodayTaskPanel({
|
||||||
const visibleItems = items.slice(0, 5);
|
primaryAction,
|
||||||
const hiddenCount = Math.max(items.length - visibleItems.length, 0);
|
secondaryLinks,
|
||||||
|
focusItem,
|
||||||
|
hiddenFocusCount,
|
||||||
|
}: {
|
||||||
|
primaryAction: PortalFocusAction | null;
|
||||||
|
secondaryLinks: PortalFocusLink[];
|
||||||
|
focusItem: TodoAggregateItem | null;
|
||||||
|
hiddenFocusCount: number;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<SectionPanel
|
<SectionPanel title="今日任务" className="portal-panel-fill">
|
||||||
title="今日待办焦点"
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.45fr),minmax(280px,0.72fr)] xl:items-stretch">
|
||||||
description="审批、TODO 和异常提醒集中在这里。"
|
<div className="rounded-lg border border-slate-200 bg-white/80 px-3.5 py-3">
|
||||||
className="portal-panel-fill"
|
<div className="flex items-center justify-between gap-3">
|
||||||
>
|
<div className="text-sm font-semibold text-zinc-900">当前焦点</div>
|
||||||
<div className="space-y-3">
|
{hiddenFocusCount > 0 ? <div className="text-xs text-zinc-500">另有 {hiddenFocusCount} 项</div> : null}
|
||||||
{visibleItems.length ? visibleItems.map((item) => (
|
</div>
|
||||||
<Link key={`${item.title}-${item.href}-${item.sourceLabel}`} to={item.href} className="portal-list-card">
|
|
||||||
<div className="flex items-start gap-3">
|
{focusItem ? (
|
||||||
<div className={item.severity === "high"
|
<Link
|
||||||
? "mt-0.5 flex size-9 items-center justify-center rounded-lg bg-red-50 text-red-600"
|
to={focusItem.href}
|
||||||
: item.severity === "warning"
|
className="mt-3 block rounded-lg border border-slate-200 bg-slate-50/70 px-3 py-2.5 transition-colors hover:border-slate-300 hover:bg-white"
|
||||||
? "mt-0.5 flex size-9 items-center justify-center rounded-lg bg-amber-50 text-amber-600"
|
>
|
||||||
: "mt-0.5 flex size-9 items-center justify-center rounded-lg bg-zinc-100 text-zinc-600"}
|
<div className="flex items-center gap-3">
|
||||||
>
|
<div className={focusItem.severity === "high"
|
||||||
<span className="material-symbols-outlined text-[18px]">{item.icon}</span>
|
? "flex size-9 shrink-0 items-center justify-center rounded-lg bg-red-50 text-red-600"
|
||||||
</div>
|
: focusItem.severity === "warning"
|
||||||
<div className="min-w-0 flex-1">
|
? "flex size-9 shrink-0 items-center justify-center rounded-lg bg-amber-50 text-amber-600"
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
: "flex size-9 shrink-0 items-center justify-center rounded-lg bg-zinc-100 text-zinc-600"}
|
||||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
>
|
||||||
<span className="badge-neutral">{item.sourceLabel}</span>
|
<span className="material-symbols-outlined text-[18px]">{focusItem.icon}</span>
|
||||||
{item.severity === "high" ? <span className="badge-error">高优先</span> : null}
|
|
||||||
{item.severity === "warning" ? <span className="badge-warning">关注</span> : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-sm text-primary">进入</div>
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-medium text-zinc-900">{focusItem.title}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">{focusItem.sourceLabel}</div>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-zinc-400">arrow_forward</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 rounded-lg border border-dashed border-slate-300 px-3 py-4 text-sm text-zinc-500">
|
||||||
|
暂无必须立即处理的事项。
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
)}
|
||||||
)) : <EmptyStateCard title="暂无待办" description="没有需要立刻处理的事。" />}
|
|
||||||
</div>
|
|
||||||
{hiddenCount > 0 ? (
|
|
||||||
<div className="portal-panel-footer">
|
|
||||||
<span>另有 {hiddenCount} 项。</span>
|
|
||||||
<Link to="/todos" className="font-medium text-primary hover:underline">查看全部待办</Link>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white/90 px-3.5 py-3">
|
||||||
|
<div className="text-sm font-semibold text-zinc-900">下一步</div>
|
||||||
|
{primaryAction ? (
|
||||||
|
<Button asChild className="mt-3 w-full" type="button">
|
||||||
|
<Link to={primaryAction.to}>{primaryAction.label}</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button className="mt-3 w-full" disabled type="button">
|
||||||
|
暂无主动作
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{secondaryLinks.length ? (
|
||||||
|
<div className="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-1">
|
||||||
|
{secondaryLinks.map((item) => (
|
||||||
|
<Button key={`${item.label}-${item.to}`} asChild variant="secondary" type="button">
|
||||||
|
<Link to={item.to}>{item.label}</Link>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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<PortalFocusLink | null> = [
|
||||||
|
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 (
|
return (
|
||||||
<PageScaffold>
|
<PageScaffold>
|
||||||
<ErrorBoundary fallback={null}>
|
<ErrorBoundary fallback={null}>
|
||||||
@@ -1123,12 +1186,12 @@ export function PortalHomePage() {
|
|||||||
title={`你好,${payload.greeting.name}`}
|
title={`你好,${payload.greeting.name}`}
|
||||||
className="workbench-hero--compact"
|
className="workbench-hero--compact"
|
||||||
subtitle={roleCapabilities.canUseFinance
|
subtitle={roleCapabilities.canUseFinance
|
||||||
? "付款、风险、审批。"
|
? "先处理今天的付款、风险和审批。"
|
||||||
: roleCapabilities.canManageSystem
|
: roleCapabilities.canManageSystem
|
||||||
? "先看待处理,再进入配置、审计和通知。"
|
? "先处理待办,再进入管理入口。"
|
||||||
: isManagerView
|
: isManagerView
|
||||||
? "审批待办、团队状态和周报缺口集中查看。"
|
? "先处理待办,再看团队状态。"
|
||||||
: "先看待办,再提交申请、补材料或查看状态。"}
|
: "先处理待办,再发起申请。"}
|
||||||
actions={
|
actions={
|
||||||
!payload.workspace.persisted && canSetupWorkspace ? (
|
!payload.workspace.persisted && canSetupWorkspace ? (
|
||||||
<Button asChild variant="secondary" type="button">
|
<Button asChild variant="secondary" type="button">
|
||||||
@@ -1138,25 +1201,12 @@ export function PortalHomePage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="portal-metric-strip ops-status-strip">
|
<TodayTaskPanel
|
||||||
{primaryCards.map((card) => (
|
primaryAction={primaryAction}
|
||||||
<MetricCard
|
secondaryLinks={secondaryLinks}
|
||||||
key={card.key}
|
focusItem={todoAggregateItems[0] ?? null}
|
||||||
ariaLabel={`portal-card-${card.key}`}
|
hiddenFocusCount={Math.max(todoAggregateItems.length - 1, 0)}
|
||||||
href={card.href}
|
/>
|
||||||
note={card.key === "my_weekly_report"
|
|
||||||
? formatWeeklyStatus(payload.weeklyReport)
|
|
||||||
: card.key === "today_presence"
|
|
||||||
? payload.todayPresence?.note ?? "更新今日状态"
|
|
||||||
: card.key === "team_presence"
|
|
||||||
? payload.teamPresence ? `请假 ${payload.teamPresence.leave},未更新 ${payload.teamPresence.unspecified}` : "团队状态板待启用"
|
|
||||||
: undefined}
|
|
||||||
title={card.title}
|
|
||||||
tone={severityTone(card.severity)}
|
|
||||||
value={card.value}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showGettingStarted ? (
|
{showGettingStarted ? (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
@@ -1175,33 +1225,33 @@ export function PortalHomePage() {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="portal-home-grid">
|
<SecondaryWorkspacePanel
|
||||||
<TodoAggregatePanel items={todoAggregateItems} />
|
title="团队与通知"
|
||||||
<QuickActionsPanel items={quickActions} layout="rail" />
|
description="团队状态、动态和公告"
|
||||||
</div>
|
>
|
||||||
|
<div className="portal-home-grid portal-home-grid--collaboration">
|
||||||
<div className="portal-home-grid portal-home-grid--collaboration">
|
<ErrorBoundary
|
||||||
<ErrorBoundary
|
fallback={(
|
||||||
fallback={(
|
<PortalSectionFallback
|
||||||
<PortalSectionFallback
|
title="团队全员状态"
|
||||||
title="团队全员状态"
|
description="今日团队状态。"
|
||||||
description="今日团队状态。"
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
>
|
||||||
>
|
<TeamStatusBoardPanel items={teamStatusBoard} />
|
||||||
<TeamStatusBoardPanel items={teamStatusBoard} />
|
</ErrorBoundary>
|
||||||
</ErrorBoundary>
|
<ErrorBoundary
|
||||||
<ErrorBoundary
|
fallback={(
|
||||||
fallback={(
|
<PortalSectionFallback
|
||||||
<PortalSectionFallback
|
title="近期动态流"
|
||||||
title="近期动态流"
|
description="最近业务动作。"
|
||||||
description="最近业务动作。"
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
>
|
||||||
>
|
<ActivityAnnouncementsPanel activities={recentActivities} announcements={announcementItems} />
|
||||||
<ActivityAnnouncementsPanel activities={recentActivities} announcements={announcementItems} />
|
</ErrorBoundary>
|
||||||
</ErrorBoundary>
|
</div>
|
||||||
</div>
|
</SecondaryWorkspacePanel>
|
||||||
|
|
||||||
{isManagerView ? (
|
{isManagerView ? (
|
||||||
<>
|
<>
|
||||||
@@ -1210,6 +1260,10 @@ export function PortalHomePage() {
|
|||||||
description="入口"
|
description="入口"
|
||||||
>
|
>
|
||||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)] 2xl:grid-cols-[minmax(0,1fr),minmax(0,1fr),340px]">
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)] 2xl:grid-cols-[minmax(0,1fr),minmax(0,1fr),340px]">
|
||||||
|
{primaryCards.length ? (
|
||||||
|
<SecondaryCardsPanel title="状态摘要" cards={primaryCards} />
|
||||||
|
) : null}
|
||||||
|
<QuickActionsPanel items={quickActions} />
|
||||||
{roleCapabilities.canApproveTasks ? (
|
{roleCapabilities.canApproveTasks ? (
|
||||||
<SummaryPanel title="审批处理" items={approvalSummaryItems} />
|
<SummaryPanel title="审批处理" items={approvalSummaryItems} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1248,6 +1302,7 @@ export function PortalHomePage() {
|
|||||||
description="申请、资产、最近访问"
|
description="申请、资产、最近访问"
|
||||||
>
|
>
|
||||||
<div className="portal-secondary-compact-grid">
|
<div className="portal-secondary-compact-grid">
|
||||||
|
<CompactMetricLinks title="状态摘要" cards={primaryCards} />
|
||||||
<CompactSummaryLinks title="我的申请与资产" items={employeeSummaryItems} />
|
<CompactSummaryLinks title="我的申请与资产" items={employeeSummaryItems} />
|
||||||
<CompactRecentVisits />
|
<CompactRecentVisits />
|
||||||
<CompactQuickLinks items={quickLinkItems} />
|
<CompactQuickLinks items={quickLinkItems} />
|
||||||
|
|||||||
+212
-171
@@ -29,8 +29,7 @@ import { Pagination } from "../components/common/Pagination";
|
|||||||
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
||||||
import { StatusBadge } from "../components/common/StatusBadge";
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||||
import { FilterBar, ListWorkspace, PrimaryTable, StatStrip, WorkspaceHeader, WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||||
import { MetricCard } from "../components/patterns/MetricCard";
|
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -66,6 +65,15 @@ type BatchResultState = {
|
|||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const REIMBURSEMENT_STATUS_OPTIONS: Array<{ value: ReimbursementStatus; label: string }> = [
|
||||||
|
{ value: "draft", label: "草稿" },
|
||||||
|
{ value: "rejected", label: "被驳回" },
|
||||||
|
{ value: "reviewing", label: "审批中" },
|
||||||
|
{ value: "approved", label: "已通过" },
|
||||||
|
{ value: "paid", label: "已支付" },
|
||||||
|
{ value: "cancelled", label: "已取消" },
|
||||||
|
];
|
||||||
|
|
||||||
function formatCurrency(value: number) {
|
function formatCurrency(value: number) {
|
||||||
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value);
|
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value);
|
||||||
}
|
}
|
||||||
@@ -108,6 +116,7 @@ export function ReimbursementsPage() {
|
|||||||
const [batchResult, setBatchResult] = useState<BatchResultState | null>(null);
|
const [batchResult, setBatchResult] = useState<BatchResultState | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
|
||||||
const [exportConfirmFormat, setExportConfirmFormat] = useState<ExportFormat | null>(null);
|
const [exportConfirmFormat, setExportConfirmFormat] = useState<ExportFormat | null>(null);
|
||||||
|
const [showLedgerTools, setShowLedgerTools] = useState(false);
|
||||||
|
|
||||||
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
|
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
|
||||||
const page = Number(searchParams.get("page") ?? "1") || 1;
|
const page = Number(searchParams.get("page") ?? "1") || 1;
|
||||||
@@ -178,6 +187,7 @@ export function ReimbursementsPage() {
|
|||||||
);
|
);
|
||||||
const canShowBatchEntry =
|
const canShowBatchEntry =
|
||||||
status === "reviewing" && scope === "all" && canApproveExpense && actionableItems.length > 0;
|
status === "reviewing" && scope === "all" && canApproveExpense && actionableItems.length > 0;
|
||||||
|
const showAdvancedTools = showLedgerTools || scope === "all" || batchMode || showProxyOwnerSelector;
|
||||||
const selectedItems = actionableItems.filter((item) => selectedReimbursementIds.includes(item.id));
|
const selectedItems = actionableItems.filter((item) => selectedReimbursementIds.includes(item.id));
|
||||||
const allActionableSelected =
|
const allActionableSelected =
|
||||||
actionableItems.length > 0 && actionableItems.every((item) => selectedReimbursementIds.includes(item.id));
|
actionableItems.length > 0 && actionableItems.every((item) => selectedReimbursementIds.includes(item.id));
|
||||||
@@ -363,67 +373,18 @@ export function ReimbursementsPage() {
|
|||||||
<ListWorkspace>
|
<ListWorkspace>
|
||||||
<WorkspaceHeader
|
<WorkspaceHeader
|
||||||
eyebrow="报销"
|
eyebrow="报销"
|
||||||
title={scope === "all" ? "报销列表" : "我的报销"}
|
title={scope === "all" ? "报销台账" : "我的报销"}
|
||||||
subtitle="先看状态和金额,再继续编辑、查看详情、导出或批量处理。"
|
|
||||||
actions={
|
actions={
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
canCreateExpense ? (
|
||||||
{canCreateExpense ? (
|
<Button
|
||||||
<DropdownMenu>
|
data-testid="reimbursements-create"
|
||||||
<DropdownMenuTrigger asChild>
|
disabled={createMutation.isPending}
|
||||||
<Button
|
onClick={() => startCreateReimbursement()}
|
||||||
data-testid="reimbursements-create"
|
type="button"
|
||||||
disabled={createMutation.isPending}
|
>
|
||||||
type="button"
|
{createMutation.isPending ? "创建中..." : "新建报销"}
|
||||||
>
|
</Button>
|
||||||
{createMutation.isPending ? "创建中..." : "新建报销"}
|
) : null
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-80">
|
|
||||||
<DropdownMenuItem onSelect={() => startCreateReimbursement()}>
|
|
||||||
空白报销
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
|
|
||||||
从模板创建
|
|
||||||
</div>
|
|
||||||
{templateOptionsQuery.isLoading ? (
|
|
||||||
<DropdownMenuItem disabled>正在加载模板...</DropdownMenuItem>
|
|
||||||
) : templateOptions.length ? (
|
|
||||||
templateOptions.map((template) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={template.id}
|
|
||||||
onSelect={() => startCreateReimbursement({ templateId: template.id })}
|
|
||||||
>
|
|
||||||
{template.name}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<DropdownMenuItem disabled>暂无可用模板</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
|
|
||||||
关联差旅申请
|
|
||||||
</div>
|
|
||||||
{linkableTravelRequestsQuery.isLoading ? (
|
|
||||||
<DropdownMenuItem disabled>正在加载差旅申请...</DropdownMenuItem>
|
|
||||||
) : linkableTravelRequests.length ? (
|
|
||||||
linkableTravelRequests.map((travelRequest) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={travelRequest.id}
|
|
||||||
onSelect={() => startCreateReimbursement({ linkedTravelRequestId: travelRequest.id })}
|
|
||||||
>
|
|
||||||
{travelRequest.requestNo ?? travelRequest.id} · {travelRequest.purpose}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<DropdownMenuItem disabled>暂无可关联的差旅申请</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -462,13 +423,6 @@ export function ReimbursementsPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<StatStrip columns={4} className="ops-status-strip">
|
|
||||||
<MetricCard title="当前列表" value={stats.currentPageCount} note={`共 ${listQuery.data?.total ?? 0} 条`} />
|
|
||||||
<MetricCard title="当前金额" value={formatCurrency(stats.currentPageAmount)} note="当前页" />
|
|
||||||
<MetricCard title="审批中" value={stats.reviewing} note="待推进" tone={stats.reviewing > 0 ? "warning" : "neutral"} />
|
|
||||||
<MetricCard title="已支付" value={stats.paid} note={scope === "all" ? "全范围" : "仅自己"} />
|
|
||||||
</StatStrip>
|
|
||||||
|
|
||||||
<FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5">
|
<FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5">
|
||||||
<div className="ops-command-bar__row">
|
<div className="ops-command-bar__row">
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -477,14 +431,7 @@ export function ReimbursementsPage() {
|
|||||||
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
|
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
|
||||||
>
|
>
|
||||||
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
||||||
{[
|
{REIMBURSEMENT_STATUS_OPTIONS.map((item) => (
|
||||||
{ value: "draft", label: "草稿" },
|
|
||||||
{ value: "reviewing", label: "审批中" },
|
|
||||||
{ value: "approved", label: "已通过" },
|
|
||||||
{ value: "paid", label: "已支付" },
|
|
||||||
{ value: "rejected", label: "已驳回" },
|
|
||||||
{ value: "cancelled", label: "已取消" },
|
|
||||||
].map((item) => (
|
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
data-testid={`reimbursements-tab-${item.value}`}
|
data-testid={`reimbursements-tab-${item.value}`}
|
||||||
key={item.value}
|
key={item.value}
|
||||||
@@ -497,52 +444,144 @@ export function ReimbursementsPage() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div className="ops-command-bar__actions">
|
<div className="ops-command-bar__actions">
|
||||||
{showProxyOwnerSelector ? (
|
{canApproveExpense ? (
|
||||||
<select
|
<Button
|
||||||
className="select-sm"
|
data-testid="reimbursements-actionable"
|
||||||
data-testid="reimbursements-owner-select"
|
size="sm"
|
||||||
onChange={(event) => setProxyOwnerEmployeeId(event.target.value)}
|
variant={status === "reviewing" && scope === "all" ? "default" : "secondary"}
|
||||||
value={proxyOwnerEmployeeId}
|
onClick={() => setSearchParams({ status: "reviewing", page: "1", scope: "all" })}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<option value="">自己</option>
|
待我处理
|
||||||
{(proxyDelegationsQuery.data?.received ?? [])
|
</Button>
|
||||||
.filter((item) => item.status === "active")
|
|
||||||
.map((item) => (
|
|
||||||
<option key={item.id} value={item.principalEmployeeId}>
|
|
||||||
{item.principal?.name ?? item.principalEmployeeId}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={showAdvancedTools ? "default" : "secondary"}
|
||||||
|
onClick={() => setShowLedgerTools((current) => !current)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
高级 / 台账
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{canViewAll ? (
|
{showAdvancedTools ? (
|
||||||
<>
|
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3">
|
||||||
<Button
|
<div className="grid gap-3 text-sm md:grid-cols-4">
|
||||||
data-testid="reimbursements-scope-own"
|
<div>
|
||||||
size="sm"
|
<div className="text-xs text-zinc-500">当前列表</div>
|
||||||
variant={scope === "own" ? "default" : "secondary"}
|
<div className="mt-1 font-semibold text-zinc-900">{stats.currentPageCount} 条 / 共 {listQuery.data?.total ?? 0} 条</div>
|
||||||
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
|
</div>
|
||||||
type="button"
|
<div>
|
||||||
>
|
<div className="text-xs text-zinc-500">当前金额</div>
|
||||||
仅自己
|
<div className="mt-1 font-semibold text-zinc-900">{formatCurrency(stats.currentPageAmount)}</div>
|
||||||
</Button>
|
</div>
|
||||||
<Button
|
<div>
|
||||||
data-testid="reimbursements-scope-all"
|
<div className="text-xs text-zinc-500">审批中</div>
|
||||||
size="sm"
|
<div className={stats.reviewing > 0 ? "mt-1 font-semibold text-amber-700" : "mt-1 font-semibold text-zinc-900"}>{stats.reviewing} 条</div>
|
||||||
variant={scope === "all" ? "default" : "secondary"}
|
</div>
|
||||||
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
|
<div>
|
||||||
type="button"
|
<div className="text-xs text-zinc-500">已支付</div>
|
||||||
>
|
<div className="mt-1 font-semibold text-zinc-900">{stats.paid} 条</div>
|
||||||
全范围
|
</div>
|
||||||
</Button>
|
</div>
|
||||||
<Button asChild size="sm" variant="secondary" type="button">
|
|
||||||
<Link to="/reimbursements/dashboard">统计分析</Link>
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<DropdownMenu>
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
<DropdownMenuTrigger asChild>
|
{showProxyOwnerSelector ? (
|
||||||
|
<select
|
||||||
|
className="select-sm"
|
||||||
|
data-testid="reimbursements-owner-select"
|
||||||
|
onChange={(event) => setProxyOwnerEmployeeId(event.target.value)}
|
||||||
|
value={proxyOwnerEmployeeId}
|
||||||
|
>
|
||||||
|
<option value="">自己</option>
|
||||||
|
{(proxyDelegationsQuery.data?.received ?? [])
|
||||||
|
.filter((item) => item.status === "active")
|
||||||
|
.map((item) => (
|
||||||
|
<option key={item.id} value={item.principalEmployeeId}>
|
||||||
|
{item.principal?.name ?? item.principalEmployeeId}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canCreateExpense ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="sm" variant="secondary" type="button">
|
||||||
|
更多创建方式
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-80">
|
||||||
|
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
|
||||||
|
从模板创建
|
||||||
|
</div>
|
||||||
|
{templateOptionsQuery.isLoading ? (
|
||||||
|
<DropdownMenuItem disabled>正在加载模板...</DropdownMenuItem>
|
||||||
|
) : templateOptions.length ? (
|
||||||
|
templateOptions.map((template) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={template.id}
|
||||||
|
onSelect={() => startCreateReimbursement({ templateId: template.id })}
|
||||||
|
>
|
||||||
|
{template.name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem disabled>暂无可用模板</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
|
||||||
|
关联差旅申请
|
||||||
|
</div>
|
||||||
|
{linkableTravelRequestsQuery.isLoading ? (
|
||||||
|
<DropdownMenuItem disabled>正在加载差旅申请...</DropdownMenuItem>
|
||||||
|
) : linkableTravelRequests.length ? (
|
||||||
|
linkableTravelRequests.map((travelRequest) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={travelRequest.id}
|
||||||
|
onSelect={() => startCreateReimbursement({ linkedTravelRequestId: travelRequest.id })}
|
||||||
|
>
|
||||||
|
{travelRequest.requestNo ?? travelRequest.id} · {travelRequest.purpose}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem disabled>暂无可关联的差旅申请</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canViewAll ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
data-testid="reimbursements-scope-own"
|
||||||
|
size="sm"
|
||||||
|
variant={scope === "own" ? "default" : "secondary"}
|
||||||
|
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
仅自己
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-testid="reimbursements-scope-all"
|
||||||
|
size="sm"
|
||||||
|
variant={scope === "all" ? "default" : "secondary"}
|
||||||
|
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
全范围
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="sm" variant="secondary" type="button">
|
||||||
|
<Link to="/reimbursements/dashboard">统计分析</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
data-testid="reimbursements-export"
|
data-testid="reimbursements-export"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -552,41 +591,42 @@ export function ReimbursementsPage() {
|
|||||||
>
|
>
|
||||||
{exportMutation.isPending ? "导出中..." : "导出"}
|
{exportMutation.isPending ? "导出中..." : "导出"}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
data-testid="reimbursements-export-csv"
|
data-testid="reimbursements-export-csv"
|
||||||
onSelect={() => setExportConfirmFormat("csv")}
|
onSelect={() => setExportConfirmFormat("csv")}
|
||||||
>
|
>
|
||||||
导出 CSV
|
导出 CSV
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
data-testid="reimbursements-export-xlsx"
|
data-testid="reimbursements-export-xlsx"
|
||||||
onSelect={() => setExportConfirmFormat("xlsx")}
|
onSelect={() => setExportConfirmFormat("xlsx")}
|
||||||
>
|
>
|
||||||
导出 Excel
|
导出 Excel
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{canShowBatchEntry ? (
|
{canShowBatchEntry ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={batchMode ? "default" : "secondary"}
|
variant={batchMode ? "default" : "secondary"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setBatchMode((current) => !current);
|
setBatchMode((current) => !current);
|
||||||
setSelectedReimbursementIds([]);
|
setSelectedReimbursementIds([]);
|
||||||
setBatchDialogAction(null);
|
setBatchDialogAction(null);
|
||||||
setBatchComment("");
|
setBatchComment("");
|
||||||
setBatchError("");
|
setBatchError("");
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{batchMode ? "退出批量模式" : "批量操作"}
|
{batchMode ? "退出批量模式" : "批量操作"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
{batchMode ? (
|
{batchMode ? (
|
||||||
<div className="ops-inline-alert border-amber-200 bg-amber-50 text-amber-800">
|
<div className="ops-inline-alert border-amber-200 bg-amber-50 text-amber-800">
|
||||||
@@ -595,16 +635,6 @@ export function ReimbursementsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
{status === "draft" ? (
|
|
||||||
<WorkspaceNotice title="草稿可以继续编辑或删除" tone="neutral">
|
|
||||||
提交后的单据进入审批链;已通过、已支付单据不再提供删除入口。
|
|
||||||
</WorkspaceNotice>
|
|
||||||
) : status === "reviewing" ? (
|
|
||||||
<WorkspaceNotice title="审批中单据以详情页为主" tone="neutral">
|
|
||||||
查看当前节点、票据、附件和审批历史后再处理;有批量权限时只会选择你可处理的待办。
|
|
||||||
</WorkspaceNotice>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{listQuery.isLoading ? <TableSkeleton columns={batchMode ? 7 : 6} rows={5} /> : null}
|
{listQuery.isLoading ? <TableSkeleton columns={batchMode ? 7 : 6} rows={5} /> : null}
|
||||||
|
|
||||||
{listQuery.isError ? (
|
{listQuery.isError ? (
|
||||||
@@ -810,15 +840,26 @@ export function ReimbursementsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
{canDeleteDraft ? (
|
{canDeleteDraft ? (
|
||||||
<Button
|
<DropdownMenu>
|
||||||
disabled={deleteMutation.isPending}
|
<DropdownMenuTrigger asChild>
|
||||||
onClick={() => handleDeleteDraft(item)}
|
<Button
|
||||||
size="sm"
|
disabled={deleteMutation.isPending}
|
||||||
type="button"
|
size="sm"
|
||||||
variant="destructive"
|
type="button"
|
||||||
>
|
variant="secondary"
|
||||||
删除
|
>
|
||||||
</Button>
|
更多
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-red-600"
|
||||||
|
onSelect={() => handleDeleteDraft(item)}
|
||||||
|
>
|
||||||
|
删除草稿
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -36,8 +36,10 @@ test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇
|
|||||||
await page.getByRole("button", { name: "保存状态" }).click();
|
await page.getByRole("button", { name: "保存状态" }).click();
|
||||||
await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 });
|
||||||
await page.goto("/portal");
|
await page.goto("/portal");
|
||||||
|
await page.locator("summary").filter({ hasText: "团队与通知" }).click();
|
||||||
await page.getByRole("tab", { name: "公告" }).click();
|
await page.getByRole("tab", { name: "公告" }).click();
|
||||||
await expect(page.getByText(announcementTitle)).toBeVisible();
|
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 expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
|
||||||
|
|
||||||
await logout(page, EMPLOYEE.name);
|
await logout(page, EMPLOYEE.name);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type Envelope<T> = {
|
|||||||
export async function assertPortalLoaded(page: Page) {
|
export async function assertPortalLoaded(page: Page) {
|
||||||
await expect(page).toHaveURL(/\/portal/);
|
await expect(page).toHaveURL(/\/portal/);
|
||||||
await expect(page.getByRole("link", { name: "Portal" }).first()).toBeVisible();
|
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) {
|
export async function assertForbiddenPage(page: Page) {
|
||||||
|
|||||||
@@ -512,25 +512,26 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
|
|||||||
|
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
|
|
||||||
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.locator('[aria-label^="portal-card-"]').first()).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) =>
|
const labels = await page.locator('[aria-label^="portal-card-"]').evaluateAll((nodes) =>
|
||||||
nodes.map((node) => node.getAttribute("aria-label")),
|
nodes.map((node) => node.getAttribute("aria-label")),
|
||||||
);
|
);
|
||||||
expect(labels).toEqual([
|
expect(labels.slice(0, 4)).toEqual([
|
||||||
"portal-card-my_todos",
|
"portal-card-my_todos",
|
||||||
"portal-card-today_presence",
|
"portal-card-today_presence",
|
||||||
"portal-card-my_weekly_report",
|
"portal-card-my_weekly_report",
|
||||||
"portal-card-my_projects",
|
"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("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();
|
|
||||||
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 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) =>
|
const labels = await page.locator('[aria-label^="portal-card-"]').evaluateAll((nodes) =>
|
||||||
nodes.map((node) => node.getAttribute("aria-label")),
|
nodes.map((node) => node.getAttribute("aria-label")),
|
||||||
);
|
);
|
||||||
expect(labels).toEqual([
|
expect(labels.slice(0, 4)).toEqual([
|
||||||
"portal-card-pending_approvals",
|
"portal-card-pending_approvals",
|
||||||
"portal-card-team_presence",
|
"portal-card-team_presence",
|
||||||
"portal-card-weekly_missing",
|
"portal-card-weekly_missing",
|
||||||
"portal-card-weekly_help",
|
"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.getByText("试点观测摘要")).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "采购申请" })).toBeVisible();
|
await expect(page.getByRole("link", { name: "采购申请" })).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "差旅申请" })).not.toBeVisible();
|
await expect(page.getByRole("link", { name: "差旅申请" })).not.toBeVisible();
|
||||||
@@ -636,7 +639,7 @@ test("Portal bootstrap refresh 首次瞬时失败后重试成功仍保留登录
|
|||||||
await page.goto("/portal");
|
await page.goto("/portal");
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/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);
|
expect(refreshAttempts).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -650,7 +653,9 @@ test("Portal 域管理角色也进入经理视图", async ({ page }) => {
|
|||||||
|
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
|
||||||
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.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible();
|
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible();
|
||||||
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
|
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -194,8 +194,10 @@ test("R70 深层自动辅助试运行:周报待办、公告团队状态、真
|
|||||||
await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await page.goto("/portal");
|
await page.goto("/portal");
|
||||||
|
await page.locator("summary").filter({ hasText: "团队与通知" }).click();
|
||||||
await page.getByRole("tab", { name: "公告" }).click();
|
await page.getByRole("tab", { name: "公告" }).click();
|
||||||
await expect(page.getByText(announcementTitle)).toBeVisible({ timeout: 30_000 });
|
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();
|
await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
|
||||||
return announcementTitle;
|
return announcementTitle;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -316,7 +316,8 @@ test("PC 核心工作流 Wave2 视觉截图", async ({ page }) => {
|
|||||||
await page.getByTestId("login-password").fill(visualUser.password);
|
await page.getByTestId("login-password").fill(visualUser.password);
|
||||||
await page.getByTestId("login-submit").click();
|
await page.getByTestId("login-submit").click();
|
||||||
await expect(page).toHaveURL(/\/portal/);
|
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 capture(page, "02-portal");
|
||||||
|
|
||||||
await page.goto("/reimbursements?status=reviewing&scope=all");
|
await page.goto("/reimbursements?status=reviewing&scope=all");
|
||||||
|
|||||||
@@ -323,7 +323,8 @@ test.describe("R102 reimbursement main flow", () => {
|
|||||||
await expect(page.getByTestId("reimbursement-row").getByText(formNumber)).toBeVisible();
|
await expect(page.getByTestId("reimbursement-row").getByText(formNumber)).toBeVisible();
|
||||||
await expect(page.getByText(reimbursementId)).toHaveCount(0);
|
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 expect(page.getByTestId("action-confirm-dialog")).toContainText(formNumber);
|
||||||
await page.getByTestId("action-confirm-submit").click();
|
await page.getByTestId("action-confirm-submit").click();
|
||||||
await expect(page.getByTestId("reimbursement-row")).toHaveCount(0);
|
await expect(page.getByTestId("reimbursement-row")).toHaveCount(0);
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ test.describe("R105 pretrial visual acceptance", () => {
|
|||||||
await loginLiveUser(page, "employee");
|
await loginLiveUser(page, "employee");
|
||||||
|
|
||||||
await page.goto("/portal");
|
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 expectNoLegacyDesignCopy(page);
|
||||||
await page.screenshot({ path: screenshotPath("desktop-portal.png"), fullPage: true });
|
await page.screenshot({ path: screenshotPath("desktop-portal.png"), fullPage: true });
|
||||||
|
|
||||||
|
|||||||
@@ -277,17 +277,20 @@ test.describe("R7E 全站高风险操作二次确认", () => {
|
|||||||
await page.goto("/reimbursements");
|
await page.goto("/reimbursements");
|
||||||
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
|
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("删除后无法恢复");
|
await expect(page.getByTestId("action-confirm-dialog")).toContainText("删除后无法恢复");
|
||||||
expect(calls.deleteReimbursement).toBe(0);
|
expect(calls.deleteReimbursement).toBe(0);
|
||||||
|
|
||||||
await page.getByTestId("action-confirm-cancel").click();
|
await page.getByTestId("action-confirm-cancel").click();
|
||||||
expect(calls.deleteReimbursement).toBe(0);
|
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 page.getByTestId("action-confirm-submit").click();
|
||||||
await expect.poll(() => calls.deleteReimbursement).toBe(1);
|
await expect.poll(() => calls.deleteReimbursement).toBe(1);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "高级 / 台账" }).click();
|
||||||
await page.getByTestId("reimbursements-export").click();
|
await page.getByTestId("reimbursements-export").click();
|
||||||
await page.getByTestId("reimbursements-export-csv").click();
|
await page.getByTestId("reimbursements-export-csv").click();
|
||||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText("导出文件可能包含业务或个人信息");
|
await expect(page.getByTestId("action-confirm-dialog")).toContainText("导出文件可能包含业务或个人信息");
|
||||||
|
|||||||
@@ -20,12 +20,11 @@ test.describe("visual portal", () => {
|
|||||||
await page.setViewportSize({ width: 1440, height: 1200 });
|
await page.setViewportSize({ width: 1440, height: 1200 });
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
|
|
||||||
await expect(page.locator('[aria-label="portal-card-my_todos"]')).toBeVisible();
|
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
|
||||||
await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
|
await expect(page.getByText("当前焦点")).toBeVisible();
|
||||||
await expect(page.locator('[aria-label="portal-card-my_weekly_report"]')).toBeVisible();
|
await expect(page.getByText("下一步")).toBeVisible();
|
||||||
await expect(page.locator('[aria-label="portal-card-my_projects"]')).toBeVisible();
|
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible();
|
||||||
await expect(page.locator("summary").filter({ hasText: "我的申请与资产 / 继续工作" })).toBeVisible();
|
await expect(page.locator("summary").filter({ hasText: "更多入口" })).toBeVisible();
|
||||||
await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
|
|
||||||
|
|
||||||
await attachScreenshot(page, testInfo, "portal-employee.png");
|
await attachScreenshot(page, testInfo, "portal-employee.png");
|
||||||
});
|
});
|
||||||
@@ -35,12 +34,11 @@ test.describe("visual portal", () => {
|
|||||||
await login(page, ADMIN);
|
await login(page, ADMIN);
|
||||||
|
|
||||||
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toHaveCount(0);
|
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.getByRole("heading", { name: "今日任务" })).toBeVisible();
|
||||||
await expect(page.locator('[aria-label="portal-card-weekly_missing"]')).toBeVisible();
|
await expect(page.getByText("下一步")).toBeVisible();
|
||||||
await expect(page.locator('[aria-label="portal-card-weekly_help"]')).toBeVisible();
|
await expect(page.locator("summary").filter({ hasText: "团队与通知" })).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.getByText("试点观测摘要")).toBeVisible();
|
||||||
await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
|
|
||||||
|
|
||||||
await attachScreenshot(page, testInfo, "portal-admin.png");
|
await attachScreenshot(page, testInfo, "portal-admin.png");
|
||||||
await logout(page, ADMIN.name);
|
await logout(page, ADMIN.name);
|
||||||
@@ -51,8 +49,8 @@ test.describe("visual portal", () => {
|
|||||||
await login(page, FINANCE);
|
await login(page, FINANCE);
|
||||||
|
|
||||||
await expect(page.getByText("财务工作台")).toBeVisible();
|
await expect(page.getByText("财务工作台")).toBeVisible();
|
||||||
await expect(page.getByText("财务处理")).toBeVisible();
|
await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible();
|
||||||
await expect(page.locator("summary").filter({ hasText: "角色重点 / 常用入口" })).toBeVisible();
|
await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible();
|
||||||
|
|
||||||
await attachScreenshot(page, testInfo, "portal-finance.png");
|
await attachScreenshot(page, testInfo, "portal-finance.png");
|
||||||
await logout(page, FINANCE.name);
|
await logout(page, FINANCE.name);
|
||||||
|
|||||||
@@ -334,15 +334,17 @@ test.describe("visual shell", () => {
|
|||||||
await page.setViewportSize({ width: 1440, height: 1024 });
|
await page.setViewportSize({ width: 1440, height: 1024 });
|
||||||
await loginVisualUser(page);
|
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 expect(page.locator("summary").filter({ hasText: /角色重点|更多入口/ })).toBeVisible();
|
||||||
await attachScreenshot(page, testInfo, "pc-portal-wave1.png");
|
await attachScreenshot(page, testInfo, "pc-portal-wave1.png");
|
||||||
|
|
||||||
await page.goto("/reimbursements");
|
await page.goto("/reimbursements");
|
||||||
await expect(page.getByRole("heading", { name: "我的报销" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "我的报销" })).toBeVisible();
|
||||||
await expect(page.getByTestId("reimbursements-create")).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-status-tabs")).toBeVisible();
|
||||||
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
|
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
|
||||||
await attachScreenshot(page, testInfo, "pc-reimbursements-wave1.png");
|
await attachScreenshot(page, testInfo, "pc-reimbursements-wave1.png");
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户