重构 PC 首页与报销列表降噪(frontend-simplification-refactor)

这个提交包含在:
2026-05-29 11:25:45 +08:00
父节点 a5549bdce6
当前提交 03f31958fa
共修改 18 个文件,包含 689 行新增和 296 行删除
@@ -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);
+1 -1
查看文件
@@ -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) {
+16 -11
查看文件
@@ -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("导出文件可能包含业务或个人信息");
+11 -13
查看文件
@@ -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);
+5 -3
查看文件
@@ -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");