重构前端 Wave0-2 体验基线(05-28-frontend-ux-redesign)

这个提交包含在:
2026-05-28 23:03:40 +08:00
父节点 605dddf633
当前提交 8f38687186
共修改 23 个文件,包含 711 行新增和 146 行删除
@@ -152,6 +152,45 @@ change the visual treatment once while preserving component contracts.
- Focused Playwright screenshot coverage when changing login, Portal, primary
PC list pages or command-bar/table styling.
## Convention: Global Shell And Page Header Baseline
**What**: PC page redesigns should preserve the shared Wave0-2 shell and
header baseline instead of recreating page-local wrappers:
- `AppShellLayout` owns the PC shell background and content offset through
`app-shell` and `app-shell-main`.
- `Sidebar` and `NavLinks` own primary navigation grouping. The mental model is
`工作` -> `业务` -> `财务` -> `管理` -> collapsed `更多`.
- `Topbar` is a compact command surface: page title, group badge, user context,
global search and notifications. Do not expand it back into a large hero.
- `PageHeader` and `WorkspaceHeader` should use `page-header-shell`,
`page-header-shell__eyebrow`, `page-header-shell__title`,
`page-header-shell__subtitle` and `page-header-shell__actions`.
- Auth guidance and step hints may use `ux-command-note`, `ux-step-list` and
`ux-step-item` when they explain the next action without adding page-local
warning cards.
**Why**: The shell/header baseline gives Wave3-5 pages a stable visual entry
point and prevents each route from inventing a different title/action layout.
**Example**:
```tsx
<WorkspaceHeader
eyebrow="审批"
title="审批中心"
subtitle="先看待处理,再进入详情核对依据。"
/>
```
**Tests Required**:
- `cd client && npm run typecheck`
- `cd client && npm run build`
- Focused Playwright coverage when changing `AppShellLayout`, `Sidebar`,
`Topbar`, `NavLinks`, `PageHeader`, `WorkspaceHeader`, login, Portal or other
A-class pages.
## Convention: Detail And Mobile Command Surfaces
**What**: PC detail pages and notification-like flows should reuse the Wave3
@@ -0,0 +1,3 @@
{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend quality checklist and linked project rules."}
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Required frontend checks, auth/page visual rules and Playwright expectations."}
{"file": ".trellis/tasks/05-28-frontend-ux-redesign/research/ux-audit-matrix.md", "reason": "Scope and acceptance basis for Wave0-Wave2 UX review."}
@@ -0,0 +1,3 @@
{"file": ".trellis/spec/frontend/index.md", "reason": "Frontend task entrypoint and project-specific spec routing."}
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Frontend React/Vite design and validation rules for OA pages."}
{"file": ".trellis/tasks/05-28-frontend-ux-redesign/research/ux-audit-matrix.md", "reason": "Wave0 audit findings and Wave1/Wave2 implementation direction."}
@@ -0,0 +1,32 @@
# Implementation Notes
## Scope Implemented
- Wave 0: wrote `research/ux-audit-matrix.md` with global findings, Wave 2 page audit and Wave 3-5 follow-up grouping.
- Wave 1: updated global shell, sidebar, topbar, page headers and shared layout classes.
- Wave 2: refined login, forgot password, portal, workflow tasks, reimbursements, reimbursement detail and notifications with clearer hierarchy and next-step language.
## UX Decisions
- Visual direction: precise, restrained OA operations console.
- Navigation groups now reflect user tasks: 工作、业务、财务、管理、更多.
- Search is presented as a command entry in the Topbar.
- Notifications now behave as an inbox and return to the workbench, not the legacy dashboard.
- Login and password reset retain the generic “如账号可用” copy to avoid account enumeration.
- Wave 3-5 stay as follow-up tasks because they require different validation surfaces.
## Validation
- `cd client && npm run typecheck` — PASS.
- `cd client && npm run build` — PASS.
- `cd client && npx playwright test tests/e2e/pc-core-workflow-visual-wave2.spec.ts tests/e2e/login-portal-ux.spec.ts --workers=1 --reporter=list` — PASS, 11 tests.
- `cd client && npx playwright test tests/e2e/r89-mobile-login-navigation.spec.ts --workers=1 --reporter=list` — PASS, 3 tests.
- `python3 ./.trellis/scripts/task.py validate 05-28-frontend-ux-redesign` — PASS.
- `node scripts/qa/check-trellis-governance.mjs` — PASS.
- `git diff --check` — PASS.
## Not Done In This Task
- No backend permission, workflow, status-machine or data model changes.
- No deployment or public smoke yet.
- Wave 3-5 pages are recorded for follow-up tasks, not completed here.
@@ -0,0 +1,194 @@
# 全站前端体验重构
## Goal
从用户角度系统重构 hyb-oa 前端体验,让 PC 与移动端页面形成统一、克制、专业、可扫描的 OA 工作界面;降低用户理解成本、减少无效操作、统一页面布局和交互模式,并优先保障真实试运行中的核心业务路径。
## What I already know
- 用户已明确希望“重新优化改进全部页面”,从用户角度、用户交互、用户体验思考,也可参考优秀系统设计。
- 已确认前端视觉、布局、组件观感、字体、颜色、动效、间距和交互细节的最终裁决以 `frontend-design` skill 为准。
- `docs/产品设计规则.md` 已瘦身为产品体验事实源,不再作为视觉规则手册。
- 当前前端为 React + TypeScript + Vite + Tailwind,页面集中在 `client/src/pages/`,全局框架包括 `AppShellLayout`、`Sidebar`、`Topbar`、`NavLinks` 和 `components/patterns/*`。
- 现有页面数量较多,覆盖登录、工作台、审批、报销、通知、项目、合同、采购、票据池、付款、AP、资产、考勤、人员和系统管理。
- 真实试运行仍在进行,面向用户可测试的页面改动必须部署与公网检查通过后才能告知用户可测试。
## Product Direction
采用“克制、专业、可扫描的运营后台”方向:
- 不做营销站式大图、口号或装饰性卡片。
- PC 端以高效扫描、批量查看、筛选、审批、复核和导出为核心。
- 移动/企业微信端以快速提交、补材料、查状态、处理待办和消息直达为核心。
- 视觉要有品牌感和统一性,但不牺牲信息密度、可读性和业务判断效率。
## Scope
本任务实施 Wave 0 + Wave 1 + Wave 2:
- Wave 0:完成全路由 UX 审计矩阵,记录页面优先级、问题类型和后续分批依据。
- Wave 1:统一 PC 全局框架、侧边栏、Topbar、页面标题区、列表/详情基础视觉基线。
- Wave 2:优化登录、找回密码、工作台、审批中心、报销列表、报销详情和通知中心。
本任务只做前端体验重构与自动化验证;不修改后端权限模型、审批状态机、业务数据结构、生产配置或真实业务数据。
## User Roles
- 普通员工:提交报销/采购/材料、查看进度、补充附件、阅读公告、处理个人待办。
- 审批人/负责人:快速判断待办、查看关键信息、批准/驳回、追踪项目和合同状态。
- 财务/AP:复核票据、报销、付款批次、银行回单、应付台账、导出与审计。
- 管理员:维护组织、用户、审批规则、系统设置、功能开关和审计日志。
## Requirements
### R1. 全局信息架构
- 重新梳理 PC 侧导航分组:工作台、待办/审批、报销、项目/合同、财务、管理。
- 高频入口稳定可见,低频和管理员入口收纳,不让普通员工被管理功能干扰。
- 保留全局搜索、最近访问、通知/待办入口,但减少重复入口和解释文案。
### R2. 统一页面布局
- 建立统一页面骨架:标题区、关键状态区、主操作区、筛选/搜索区、主体区、辅助信息区。
- 列表页、详情页、工作台页、配置页分别形成稳定模式。
- 详情页优先呈现“当前状态、下一步动作、关键明细、附件/审计”,避免把操作埋在页面底部。
### R3. 文案和操作降噪
- 页面文案从功能说明改为用户下一步动作提示。
- 长说明收进帮助、提示、空状态或详情抽屉。
- 当前状态不可用的按钮不强行展示;必须展示时要解释原因。
- 表单字段、按钮、状态标签和错误信息使用一致的业务语言。
### R4. 核心业务流程优化
- 登录/找回密码、工作台、审批中心、报销列表、报销详情、通知中心优先优化。
- 审批页突出“我需要判断什么、依据是什么、点按钮的后果是什么”。
- 报销/票据/附件页面突出状态、重复、缺失、可补材料和审计边界。
- 财务/AP/付款页面突出批量复核、风险提示、下载用途和导出边界。
### R5. 视觉和组件系统
- 统一页面宽度、间距、标题层级、按钮层级、状态色、表格密度、卡片/面板样式。
- 复用或升级 `PageScaffold`、`ListWorkspace`、`SectionPanel`、`StatusBadge`、`MetricCard` 等公共组件。
- 避免一页一个风格,不再新增局部随意样式。
- 交互动效只服务状态变化、加载、确认和导航,不做干扰业务判断的装饰动效。
### R6. 移动和企业微信入口
- 移动端优先保证从企业微信消息或移动首页进入目标业务后,少步骤完成提交、补材料、审批或查看状态。
- 移动详情页不照搬 PC 表格;用分组信息、底部主操作和清晰状态解释。
- 企业微信只做脱敏消息和 OA deeplink,附件和审批动作仍按 OA 权限执行。
### R7. 验证和发布
- 每一批前端改动必须通过 `cd client && npm run typecheck` 和 `cd client && npm run build`。
- 高频页面需要 Playwright 视觉/交互检查,至少覆盖 PC 宽屏、常规桌面和移动视口。
- 面向用户测试前必须完成部署和公网检查。
## Proposed Delivery Waves
### Wave 0:UX 审计与页面矩阵
- 盘点所有 PC/移动路由。
- 按 A/B/C 页面等级记录布局、文案、操作、状态、权限、移动适配问题。
- 产出本任务内的整改矩阵,不新增独立噪音文档。
### Wave 1:全局框架与设计基线
- 重构 AppShell、侧边栏、Topbar、页面骨架、列表/详情/面板公共模式。
- 统一按钮、状态、空状态、加载态、错误态、表格密度和基础间距。
- 这一步是后续全站一致性的前提。
### Wave 2:高频核心页
- 登录/找回密码、工作台、审批中心、报销列表、报销详情、通知中心。
- 目标是显著减少啰嗦、减少点击、清晰展示下一步动作。
### Wave 3:业务详情和移动关键页
- 审批详情、项目详情、合同台账/详情、移动报销、移动审批、移动通知、企微入口页。
### Wave 4:财务和运营页
- 票据池、AP、付款批次、采购/费用、资产、合同归档。
- 重点是表格、筛选、批量操作、附件下载审计和状态解释。
### Wave 5:管理配置页
- 用户、组织、审批规则、流程定义、系统设置、功能开关、审计日志。
- 允许信息密度较高,但统一排版、权限提示和排障语言。
## Suggested MVP for This Task
建议本任务第一轮实施 Wave 0 + Wave 1 + Wave 2,原因:
- 先统一全局框架和高频核心页,能最快改善真实试运行体验。
- Wave 3-5 页面数量多、业务差异大,应在统一组件模式建立后批量套用。
- 一次性改完全部页面风险过高,容易产生回归、遗漏权限状态或视觉不一致。
## Test Plan
- `cd client && npm run typecheck`
- `cd client && npm run build`
- `cd client && npx playwright test tests/e2e/pc-core-workflow-visual-wave2.spec.ts tests/e2e/login-portal-ux.spec.ts --workers=1 --reporter=list`
- `cd client && npx playwright test tests/e2e/r89-mobile-login-navigation.spec.ts --workers=1 --reporter=list`
- `python3 ./.trellis/scripts/task.py validate 05-28-frontend-ux-redesign`
- `node scripts/qa/check-trellis-governance.mjs`
- `git diff --check`
- 若要交给用户公网试用,必须另行部署并完成公网 smoke/视觉检查后再勾选部署验收项。
## Follow-up Plan for Wave 3-5
Wave 3-5 必须记录在本任务中,避免 Wave 0-2 完成后遗忘;但不建议在本任务内同时实施。
推荐排期:
1. Wave 0-2 完成、通过验证并部署公网检查后,立即新建 `frontend-ux-wave3-business-mobile` 任务。
2. Wave 3 完成后,再新建 `frontend-ux-wave4-finance-ops` 任务。
3. Wave 4 完成后,再新建 `frontend-ux-wave5-admin-config` 任务。
拆开的理由:
- Wave 3 涉及业务详情页和移动/企微入口,验证重点是真实业务链路和移动交互。
- Wave 4 涉及财务、付款、AP、票据和资产,验证重点是表格、批量操作、附件下载审计和权限边界。
- Wave 5 涉及管理配置和审计,验证重点是高信息密度、排障语言、权限提示和误操作防护。
- 三类页面的用户角色、交互风险和测试路径不同,强行放在一个大任务里会降低质量。
Wave 3-5 不作为本任务验收阻断项;本任务结束时必须在交付总结中明确下一任务建议和页面清单。
## Acceptance Criteria
- [x] 已完成全路由页面 UX 审计矩阵,并按 A/B/C 级标记优先级。
- [x] 已确立统一页面骨架和公共组件使用方式。
- [x] PC 全局导航、Topbar 和内容容器明显统一,低频入口不干扰高频用户路径。
- [x] Wave 2 高频核心页完成第一轮体验重构。
- [x] Wave 3-5 后续任务计划已记录,且不被误判为本任务已完成范围。
- [x] 页面文案明显降噪,主要页面能直接说明当前状态和下一步动作。
- [x] 桌面与移动关键视口无明显文本溢出、遮挡、错位和不可点击问题。
- [x] `cd client && npm run typecheck` 通过。
- [x] `cd client && npm run build` 通过。
- [x] 高频页面完成 Playwright 截图/交互验证。
- [ ] 若部署给用户测试,已完成公网检查并记录证据。
## Out of Scope
- 不改变后端权限模型、审批状态机或业务数据结构。
- 不新增真实业务写入、企业微信写入、生产配置变更或数据库迁移。
- 不把移动端改造成完整 PC 管理后台。
- 不在本任务恢复差旅主线能力。
- 不为了视觉统一删除已有业务能力;如发现业务流程不合理,记录为后续任务。
## Technical Notes
- 重点代码区域:
- `client/src/layouts/AppShellLayout.tsx`
- `client/src/components/navigation/*`
- `client/src/components/patterns/*`
- `client/src/components/common/*`
- `client/src/pages/*`
- 设计依据:
- `frontend-design` skill 决定视觉和交互方向。
- `docs/产品设计规则.md` 只提供 hyb-oa 产品体验事实、用户角色和页面职责。
- `.trellis/spec/hyb-oa/frontend-react-vite-design.md` 和 `.trellis/spec/frontend/index.md` 约束工程实现。
- 已按用户确认实施 Wave 0 + Wave 1 + Wave 2;Wave 3-5 作为后续任务拆分,不计入本任务完成范围。
@@ -0,0 +1,45 @@
# UX Audit Matrix
## Audit Method
- 依据 `client/src/router/index.tsx`、`client/src/features/navigation/schema.ts` 和页面源码盘点。
- 页面分级沿用 `docs/产品设计规则.md`:A 类核心流程、B 类业务运营、C 类管理配置。
- 本轮实施范围为 Wave 0-2;Wave 3-5 只记录后续任务,不作为当前验收完成范围。
## Global Findings
| Area | Finding | Impact | Current Task Action |
| --- | --- | --- | --- |
| PC navigation | 主导航、管理入口、备用模块的分类偏业务历史,财务/项目/合同等用户心智未成组。 | 用户找入口需要读说明,普通员工容易看到过多低频项。 | Wave 1 重排导航分组和侧栏信息。 |
| Topbar | 顶部展示页面组、标题和问候,信息占高较多;搜索按钮不够像命令入口。 | PC 页面有效内容首屏被压缩。 | Wave 1 做紧凑命令栏。 |
| Page headers | `PageHeader`、`WorkspaceHeader`、`WorkbenchHero`、`EntityOverviewHero` 并存,标题/说明/action 的密度不完全一致。 | 页面风格统一度不足。 | Wave 1 统一 header 视觉和行动区。 |
| Cards/panels | 大量页面已复用 `SectionPanel`、`ListWorkspace`,但局部还存在 page-local 卡片和提示块。 | 视觉统一改动需要多点修复。 | Wave 1 先统一 shared pattern class。 |
| Lists/tables | PC 操作列表已有 `ops-*` 规范,但部分空态、错误态和筛选控件仍不一致。 | 审批、报销、通知的扫描效率不稳定。 | Wave 2 聚焦核心列表。 |
| Copy | 部分页面说明偏“模块解释”,不是“下一步动作”。 | 用户需要理解系统设计而非直接完成任务。 | Wave 2 降噪。 |
| Mobile | 移动壳和卡片模式已存在;关键详情页属于 Wave 3。 | 本轮只避免 Wave 2 改动破坏移动入口。 | Wave 3 继续。 |
## Wave 2 Page Audit
| Page | Class | Current Issues | Target Change |
| --- | --- | --- | --- |
| Login | A | 功能完整,但登录方式、企微入口、邮箱验证码和密码入口信息层级仍可更清楚。 | 保留成熟功能,改为更明确的登录方式分区、辅助状态和焦点顺序。 |
| Forgot password | A | 流程可用,但验证码、有效期、密码要求和返回登录的提示仍偏散。 | 强化步骤感和状态提示。 |
| Portal home | A | 信息丰富但首屏模块多,角色摘要、待处理和常用动作可更聚焦。 | 工作台突出“待处理 + 常用动作 + 关键指标”,次要信息收纳。 |
| Workflow tasks | A | 已使用 `ListWorkspace` 和 `ops-*`,但 subtitle 太短,缺少“下一步如何处理”的上下文。 | 提升标题说明、状态指标和空态语义。 |
| Reimbursements | A | 已支持批量、导出、范围、模板,但筛选/范围/导出动作密度偏高。 | 保持功能,优化 header、command bar 和列表扫描字段。 |
| Reimbursement detail | A | 信息完整,分区较多;状态、下一步动作、票据和附件需要更强层级。 | 保留功能,优化 hero 说明、摘要和证据区语义。 |
| Notifications | A | 已改为 inbox 卡片,但返回仪表盘不符合当前主路径,筛选区和未读统计可以更统一。 | 改为通知收件箱工作区,直达业务、全部已读、类型筛选稳定。 |
## Follow-up Page Groups
| Wave | Pages | Reason To Split |
| --- | --- | --- |
| Wave 3 | 审批详情、项目详情、合同台账/详情、移动报销、移动审批、移动通知、企微入口页 | 详情和移动链路要验证业务直达和移动操作,不适合和全局框架同批扩大。 |
| Wave 4 | 票据池、AP、付款批次、采购/费用、资产、合同归档 | 财务运营页涉及批量操作、附件审计、导出和权限边界。 |
| Wave 5 | 用户、组织、审批规则、流程定义、系统设置、功能开关、审计日志 | 管理页信息密度高,重在排障语言和误操作防护。 |
## Design Direction Locked For Implementation
- Tone: 精密、克制、运营后台。
- Differentiation: 所有页面都能先回答“现在是什么状态、我下一步做什么、动作有什么后果”。
- Visual rule: 通过 shared shell、header、command bar、status strip 和 detail surface 统一,而不是每页单独美化。
@@ -0,0 +1,26 @@
{
"id": "frontend-ux-redesign",
"name": "frontend-ux-redesign",
"title": "全站前端体验重构",
"description": "统一 hyb-oa 前端 Wave0-2 体验基线:UX 审计、全局框架和高频核心页。",
"status": "in_progress",
"dev_type": null,
"scope": "client/frontend-ux-wave0-2",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-05-28",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+5 -5
查看文件
@@ -13,16 +13,16 @@ type PageHeaderProps = {
export function PageHeader({ title, subtitle, actions, eyebrow, meta, className }: PageHeaderProps) {
return (
<header className={cn("flex flex-col gap-4 md:flex-row md:items-end md:justify-between", className)}>
<header className={cn("page-header-shell", className)}>
<div className="min-w-0 max-w-3xl">
{eyebrow ? (
<div className="text-xs font-bold uppercase text-primary">{eyebrow}</div>
<div className="page-header-shell__eyebrow">{eyebrow}</div>
) : null}
<h1 className="mt-1 text-2xl font-semibold text-[var(--color-text-primary)] md:text-3xl">{title}</h1>
{subtitle ? <p className="mt-2 text-sm leading-6 text-[var(--color-text-secondary)]">{subtitle}</p> : null}
<h1 className="page-header-shell__title">{title}</h1>
{subtitle ? <p className="page-header-shell__subtitle">{subtitle}</p> : null}
{meta ? <div className="mt-3 flex flex-wrap gap-2">{meta}</div> : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
{actions ? <div className="page-header-shell__actions">{actions}</div> : null}
</header>
);
}
+76 -20
查看文件
@@ -56,6 +56,31 @@ const LEGACY_ACCESSIBLE_LABELS: Record<string, string> = {
"/assets": "资产台账",
};
const WORK_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/todos"];
const BUSINESS_NAV_ORDER = [
"/projects",
"/contracts-registry",
"/purchase-requests",
"/assets",
"/weekly-reports",
"/team",
"/announcements",
"/leave-requests",
"/attendance",
"/wecom-approval-archive",
];
const FINANCE_NAV_ORDER = ["/payment-batches", "/ap-operations", "/invoice-pool", "/budgets", "/inventory-checks"];
const MANAGEMENT_NAV_ORDER = [
"/people",
"/organization",
"/system/workspace",
"/system/workflow-definitions",
"/system/settings",
"/system/feature-flags",
"/audit-logs",
];
const MORE_NAV_ORDER = ["/travel-requests", "/dashboard", "/settings/profile", "/system/cutover", "/system/integrations/erp"];
type ResolvedNavItem = {
key?: string;
href: string;
@@ -75,10 +100,8 @@ function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
onClick={() => onNavigate?.()}
className={({ isActive }) =>
cn(
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition duration-150",
isActive
? "bg-primary/10 text-primary ring-1 ring-primary/10"
: "text-[var(--color-text-secondary)] hover:bg-slate-50 hover:text-[var(--color-text-primary)]",
"group nav-link-item",
isActive ? "nav-link-item--active" : "nav-link-item--idle",
)
}
>
@@ -86,10 +109,8 @@ function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
<>
<span
className={cn(
"flex size-9 items-center justify-center rounded-lg border text-[20px] transition-colors",
isActive
? "border-primary/15 bg-white text-primary"
: "border-slate-200 bg-white text-[var(--color-text-tertiary)] group-hover:border-slate-300 group-hover:text-primary",
"nav-link-item__icon",
isActive ? "nav-link-item__icon--active" : "nav-link-item__icon--idle",
)}
>
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
@@ -101,17 +122,14 @@ function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
);
}
function renderSection(title: string, description: string, items: ResolvedNavItem[], onNavigate?: () => void) {
function renderSection(title: string, items: ResolvedNavItem[], onNavigate?: () => void) {
if (!items.length) {
return null;
}
return (
<section className="space-y-2">
<div className="px-3">
<div className="text-[11px] font-bold uppercase tracking-[0.2em] text-primary/70">{title}</div>
<div className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">{description}</div>
</div>
<section className="nav-section">
<div className="nav-section__label">{title}</div>
<div className="space-y-1.5">{items.map((item) => renderNavItem(item, onNavigate))}</div>
</section>
);
@@ -126,10 +144,10 @@ function renderBackupSection(items: ResolvedNavItem[], onNavigate?: () => void)
<details className="rounded-2xl border border-slate-200 bg-slate-50/80 px-3 py-3">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 rounded-xl px-2 py-1.5 text-sm font-semibold text-[var(--color-text-primary)]">
<div>
<div>备用模块</div>
<div className="mt-0.5 text-xs font-normal text-[var(--color-text-secondary)]">默认收起,不干扰主路径</div>
<div>更多</div>
<div className="mt-0.5 text-xs font-normal text-[var(--color-text-secondary)]">低频入口和历史模块</div>
</div>
<span className="text-xs font-medium text-[var(--color-text-tertiary)]">按需展开</span>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">expand_more</span>
</summary>
<div className="mt-3 space-y-1.5 pb-1">{items.map((item) => renderNavItem(item, onNavigate))}</div>
</details>
@@ -186,6 +204,41 @@ function normalizeSections(sections: { main: NavigationItem[]; admin: Navigation
};
}
function regroupSections(sections: { main: ResolvedNavItem[]; admin: ResolvedNavItem[]; backup: ResolvedNavItem[] }) {
const allItems = [...sections.main, ...sections.admin, ...sections.backup];
const byHref = new Map<string, ResolvedNavItem>();
for (const item of allItems) {
if (!byHref.has(item.href)) {
byHref.set(item.href, item);
}
}
const used = new Set<string>();
const take = (hrefs: string[]) =>
hrefs
.map((href) => {
const item = byHref.get(href);
if (item) {
used.add(href);
}
return item;
})
.filter((item): item is ResolvedNavItem => Boolean(item));
return {
work: take(WORK_NAV_ORDER),
business: take(BUSINESS_NAV_ORDER),
finance: take(FINANCE_NAV_ORDER),
management: take(MANAGEMENT_NAV_ORDER),
more: [
...take(MORE_NAV_ORDER),
...allItems
.filter((item) => !used.has(item.href))
.map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon })),
],
};
}
export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const { me, permissions } = useAuth();
const navigationQuery = useQuery({
@@ -197,12 +250,15 @@ export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const sections = navigationQuery.data?.sections
? normalizeSections(navigationQuery.data.sections)
: buildFallbackItems(permissions, me?.roleCodes ?? []);
const groupedSections = regroupSections(sections);
return (
<nav className="space-y-5">
{renderSection("主导航", "今日工作与高频协同。", sections.main, onNavigate)}
{renderSection("管理入口", "仅管理员与负责人使用。", sections.admin, onNavigate)}
{renderBackupSection(sections.backup, onNavigate)}
{renderSection("工作", groupedSections.work, onNavigate)}
{renderSection("业务", groupedSections.business, onNavigate)}
{renderSection("财务", groupedSections.finance, onNavigate)}
{renderSection("管理", groupedSections.management, onNavigate)}
{renderBackupSection(groupedSections.more, onNavigate)}
</nav>
);
}
+4 -4
查看文件
@@ -3,15 +3,15 @@ import { NavLinks } from "./NavLinks";
export function Sidebar() {
return (
<aside className="fixed inset-y-0 z-20 hidden w-72 overflow-hidden border-r border-slate-200 bg-white/96 shadow-[8px_0_24px_rgba(15,23,42,0.045)] lg:flex lg:flex-col">
<aside className="app-shell-sidebar fixed inset-y-0 z-20 hidden w-72 overflow-hidden border-r border-slate-200 lg:flex lg:flex-col">
<div className="border-b border-slate-200 px-6 py-5">
<AppBrand />
<div className="mt-4 overflow-hidden rounded-xl border border-slate-200 bg-slate-50 px-3.5 py-3 text-sm text-[var(--color-text-secondary)]">
<div className="sidebar-focus-card mt-4 text-[var(--color-text-secondary)]">
<div className="flex items-center gap-2 font-semibold text-[var(--color-text-primary)]">
<span className="size-2 rounded-full bg-primary" />
今日工作台
今日优先
</div>
<div className="mt-1.5 leading-6">待处理、团队状态和常用入口。</div>
<div className="mt-1.5 leading-6">先处理待办,再进入报销、项目和财务流程。</div>
</div>
</div>
+12 -9
查看文件
@@ -135,8 +135,8 @@ export function Topbar() {
return (
<>
<header className="sticky top-0 z-10 border-b border-slate-200/80 bg-white/94 px-4 py-3 shadow-[0_4px_14px_rgba(15,23,42,0.035)] sm:px-8">
<div className="flex items-center justify-between gap-4">
<header className="app-topbar sticky top-0 z-10 px-4 py-2.5 sm:px-8">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<Dialog open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
<DialogTrigger asChild>
@@ -164,9 +164,11 @@ export function Topbar() {
</Dialog>
<div className="min-w-0">
<div className="text-xs font-bold uppercase tracking-[0.2em] text-primary/70">{pageMeta.group}</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<p className="truncate text-lg font-semibold text-[var(--color-text-primary)]">{pageMeta.title}</p>
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-base font-semibold text-[var(--color-text-primary)] md:text-lg">{pageMeta.title}</p>
<span className="hidden rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-[var(--color-text-secondary)] sm:inline-flex">
{pageMeta.group}
</span>
{wecomEntry ? (
<span
className="badge-neutral border-primary-200 bg-primary-50 text-primary"
@@ -176,7 +178,7 @@ export function Topbar() {
</span>
) : null}
</div>
<div className="mt-1 text-sm text-[var(--color-text-secondary)]">{me ? `你好,${me.name}` : "欢迎进入海音达工作台"}</div>
<div className="mt-0.5 text-xs text-[var(--color-text-secondary)]">{me ? `${me.name} · 当前工作区` : "海音达 OA"}</div>
</div>
</div>
@@ -187,10 +189,11 @@ export function Topbar() {
type="button"
onClick={() => setSearchOpen(true)}
data-testid="global-search-trigger"
className="hidden md:inline-flex"
className="app-command-button hidden md:inline-flex"
>
<span className="material-symbols-outlined mr-1 text-[18px]">search</span>
搜索
<span className="ml-2 rounded bg-white px-1.5 py-0.5 text-[11px] text-slate-500">Ctrl K</span>
</Button>
<Button
@@ -199,7 +202,7 @@ export function Topbar() {
type="button"
onClick={() => setSearchOpen(true)}
data-testid="global-search-trigger-mobile"
className="md:hidden"
className="app-command-button md:hidden"
aria-label="打开搜索"
>
<span className="material-symbols-outlined text-[18px]">search</span>
@@ -213,7 +216,7 @@ export function Topbar() {
aria-label="通知"
type="button"
data-testid="notifications-bell"
className="relative"
className="app-command-button relative"
>
<BellIcon />
{unreadCount > 0 ? (
@@ -21,14 +21,14 @@ export function ListWorkspace({ className, children }: WorkspaceChildrenProps) {
export function WorkspaceHeader({ eyebrow, title, subtitle, meta, actions, className }: WorkspaceHeaderProps) {
return (
<header className={cn("flex flex-col gap-3 md:flex-row md:items-end md:justify-between", className)}>
<header className={cn("page-header-shell", className)}>
<div className="min-w-0 max-w-3xl">
{eyebrow ? <div className="text-xs font-bold uppercase text-primary">{eyebrow}</div> : null}
<h1 className="mt-1 text-2xl font-semibold text-[var(--color-text-primary)] md:text-[1.65rem]">{title}</h1>
{subtitle ? <div className="mt-1.5 text-sm leading-6 text-[var(--color-text-secondary)]">{subtitle}</div> : null}
{eyebrow ? <div className="page-header-shell__eyebrow">{eyebrow}</div> : null}
<h1 className="page-header-shell__title">{title}</h1>
{subtitle ? <div className="page-header-shell__subtitle">{subtitle}</div> : null}
{meta ? <div className="mt-2.5 flex flex-wrap gap-2">{meta}</div> : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2 md:justify-end">{actions}</div> : null}
{actions ? <div className="page-header-shell__actions">{actions}</div> : null}
</header>
);
}
+32 -32
查看文件
@@ -4,27 +4,27 @@ export const BACKUP_NAV_ORDER = ["/attendance", "/wecom-approval-archive", "/con
const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/portal", title: "工作台", group: "工作台" },
{ path: "/announcements", title: "公告", group: "主导航" },
{ path: "/team", title: "团队", group: "主导航" },
{ path: "/attendance", title: "考勤", group: "备用模块" },
{ path: "/wecom-approval-archive", title: "企微审批历史", group: "备用模块" },
{ path: "/projects", title: "项目", group: "主导航" },
{ path: "/contracts-registry", title: "合同台账", group: "备用模块" },
{ path: "/purchase-requests", title: "采购申请", group: "备用模块" },
{ path: "/travel-requests", title: "差旅申请", group: "备用模块" },
{ path: "/budgets", title: "预算管理", group: "备用模块" },
{ path: "/payment-batches", title: "付款批次", group: "备用模块" },
{ path: "/ap-operations", title: "应付作业", group: "备用模块" },
{ path: "/invoice-pool", title: "票据中心", group: "备用模块" },
{ path: "/inventory-checks", title: "盘点任务", group: "备用模块" },
{ path: "/weekly-reports", title: "周报", group: "主导航" },
{ path: "/todos", title: "待办", group: "主导航" },
{ path: "/reimbursements/", title: "报销详情", group: "主导航" },
{ path: "/reimbursements", title: "报销", group: "主导航" },
{ path: "/leave-requests", title: "请假", group: "主导航" },
{ path: "/assets", title: "资产", group: "主导航" },
{ path: "/workflow/tasks/", title: "审批详情", group: "主导航" },
{ path: "/workflow/tasks", title: "审批待办", group: "主导航" },
{ path: "/announcements", title: "公告", group: "业务" },
{ path: "/team", title: "团队", group: "业务" },
{ path: "/attendance", title: "考勤", group: "业务" },
{ path: "/wecom-approval-archive", title: "企微审批历史", group: "业务" },
{ path: "/projects", title: "项目", group: "业务" },
{ path: "/contracts-registry", title: "合同台账", group: "业务" },
{ path: "/purchase-requests", title: "采购申请", group: "业务" },
{ path: "/travel-requests", title: "差旅申请", group: "更多" },
{ path: "/budgets", title: "预算管理", group: "财务" },
{ path: "/payment-batches", title: "付款批次", group: "财务" },
{ path: "/ap-operations", title: "应付作业", group: "财务" },
{ path: "/invoice-pool", title: "票据中心", group: "财务" },
{ path: "/inventory-checks", title: "盘点任务", group: "财务" },
{ path: "/weekly-reports", title: "周报", group: "业务" },
{ path: "/todos", title: "待办", group: "工作" },
{ path: "/reimbursements/", title: "报销详情", group: "工作" },
{ path: "/reimbursements", title: "报销", group: "工作" },
{ path: "/leave-requests", title: "请假", group: "业务" },
{ path: "/assets", title: "资产", group: "业务" },
{ path: "/workflow/tasks/", title: "审批详情", group: "工作" },
{ path: "/workflow/tasks", title: "审批待办", group: "工作" },
{ path: "/mobile/assets/scan", title: "移动资产扫描", group: "移动工作台" },
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
{ path: "/mobile/wecom-approval-archive", title: "移动企微审批历史", group: "移动工作台" },
@@ -36,17 +36,17 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/mobile/reimbursements", title: "移动报销", group: "移动工作台" },
{ path: "/mobile", title: "移动首页", group: "移动工作台" },
{ path: "/settings/profile", title: "个人设置", group: "账号设置" },
{ path: "/system/workspace", title: "工作区设置", group: "管理入口" },
{ path: "/people", title: "人员管理", group: "管理入口" },
{ path: "/organization", title: "组织架构", group: "管理入口" },
{ path: "/system/approval-rules", title: "审批规则", group: "管理入口" },
{ path: "/system/workflow-definitions", title: "流程定义", group: "管理入口" },
{ path: "/system/settings", title: "系统设置", group: "管理入口" },
{ path: "/system/feature-flags", title: "功能开关", group: "管理入口" },
{ path: "/system/cutover", title: "切换控制台", group: "管理入口" },
{ path: "/system/integrations/erp", title: "ERP 集成", group: "管理入口" },
{ path: "/dashboard", title: "数据看板", group: "备用模块" },
{ path: "/audit-logs", title: "审计日志", group: "管理入口" },
{ path: "/system/workspace", title: "工作区设置", group: "管理" },
{ path: "/people", title: "人员管理", group: "管理" },
{ path: "/organization", title: "组织架构", group: "管理" },
{ path: "/system/approval-rules", title: "审批规则", group: "管理" },
{ path: "/system/workflow-definitions", title: "流程定义", group: "管理" },
{ path: "/system/settings", title: "系统设置", group: "管理" },
{ path: "/system/feature-flags", title: "功能开关", group: "管理" },
{ path: "/system/cutover", title: "切换控制台", group: "更多" },
{ path: "/system/integrations/erp", title: "ERP 集成", group: "更多" },
{ path: "/dashboard", title: "数据看板", group: "更多" },
{ path: "/audit-logs", title: "审计日志", group: "管理" },
];
export function resolvePageMeta(pathname: string) {
+127 -2
查看文件
@@ -23,7 +23,8 @@
body {
min-height: 100vh;
min-height: -webkit-fill-available;
background: linear-gradient(180deg, #ffffff 0%, var(--color-bg-canvas) 16rem, #eef2f6 100%);
background:
linear-gradient(180deg, #f8fafc 0%, #f3f6f8 22rem, #eef2f6 100%);
background-attachment: fixed;
color: var(--color-text-primary);
@apply font-sans antialiased selection:bg-primary selection:text-white;
@@ -40,8 +41,24 @@
}
@layer components {
.app-shell {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 252, 0.76)),
var(--color-bg-canvas);
}
.app-shell-sidebar {
background: rgba(255, 255, 255, 0.98);
box-shadow: 10px 0 26px rgba(15, 23, 42, 0.052);
}
.app-shell-main {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(241, 245, 249, 0.36));
}
.layout-container {
@apply mx-auto w-full px-0 sm:px-1 lg:px-2;
@apply mx-auto w-full max-w-[1680px] px-0 sm:px-1 lg:px-2;
}
.page-scaffold {
@@ -49,6 +66,92 @@
@apply space-y-4 md:space-y-5;
}
.page-header-shell {
border-bottom: 1px solid rgba(226, 232, 240, 0.92);
@apply flex flex-col gap-3 pb-3 md:flex-row md:items-end md:justify-between;
}
.page-header-shell__eyebrow {
color: var(--color-accent-primary);
@apply text-xs font-bold uppercase;
}
.page-header-shell__title {
color: var(--color-text-primary);
@apply mt-1 text-2xl font-semibold md:text-[1.7rem];
}
.page-header-shell__subtitle {
color: var(--color-text-secondary);
@apply mt-1.5 max-w-3xl text-sm leading-6;
}
.page-header-shell__actions {
@apply flex shrink-0 flex-wrap items-center gap-2 md:justify-end;
}
.app-topbar {
border-bottom: 1px solid rgba(226, 232, 240, 0.92);
background: rgba(255, 255, 255, 0.93);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.035);
backdrop-filter: blur(16px);
}
.app-command-button {
border-color: rgba(203, 213, 225, 0.96);
background: rgba(248, 250, 252, 0.92);
@apply border;
}
.sidebar-focus-card {
border: 1px solid rgba(203, 213, 225, 0.88);
background:
linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.88));
@apply overflow-hidden rounded-lg px-3.5 py-3 text-sm;
}
.nav-section {
@apply space-y-1.5;
}
.nav-section__label {
color: var(--color-text-tertiary);
@apply px-3 text-[11px] font-bold uppercase;
}
.nav-link-item {
@apply flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm transition duration-150;
}
.nav-link-item--active {
background: rgba(0, 57, 138, 0.08);
color: var(--color-accent-primary);
box-shadow: inset 3px 0 0 var(--color-accent-primary);
}
.nav-link-item--idle {
color: var(--color-text-secondary);
@apply hover:bg-slate-50 hover:text-slate-950;
}
.nav-link-item__icon {
@apply flex size-8 shrink-0 items-center justify-center rounded-md text-[19px] transition-colors;
}
.nav-link-item--active .nav-link-item__icon {
background: rgba(255, 255, 255, 0.9);
color: var(--color-accent-primary);
}
.nav-link-item--idle .nav-link-item__icon {
background: rgba(241, 245, 249, 0.82);
color: var(--color-text-tertiary);
}
.nav-link-item--idle:hover .nav-link-item__icon {
color: var(--color-accent-primary);
}
.auth-shell {
background:
radial-gradient(circle at 20% 12%, rgba(0, 57, 138, 0.10), transparent 26rem),
@@ -321,6 +424,28 @@
@apply block px-3.5 py-3.5 transition duration-150 hover:border-slate-300 hover:bg-white;
}
.ux-step-list {
@apply grid gap-2;
}
.ux-step-item {
border-color: rgba(226, 232, 240, 0.94);
background: rgba(248, 250, 252, 0.84);
@apply flex items-start gap-3 rounded-lg border px-3 py-2.5 text-sm;
}
.ux-step-item__index {
background: rgba(0, 57, 138, 0.08);
color: var(--color-accent-primary);
@apply flex size-6 shrink-0 items-center justify-center rounded-md text-xs font-semibold;
}
.ux-command-note {
border-color: rgba(203, 213, 225, 0.9);
background: rgba(248, 250, 252, 0.82);
@apply rounded-lg border px-3 py-2 text-xs leading-5 text-slate-600;
}
.metric-card::before {
position: absolute;
inset: 0 auto 0 0;
+2 -2
查看文件
@@ -59,11 +59,11 @@ export function AppShellLayout() {
}
return (
<div className="relative flex min-h-screen overflow-hidden bg-[var(--color-bg-canvas)]">
<div className="app-shell relative flex min-h-screen overflow-hidden">
{isMobileRoute ? null : <Sidebar />}
<main
className={`flex min-w-0 flex-1 flex-col ${isMobileRoute ? "hyb-mobile-shell overflow-hidden" : "lg:ml-72"}`}
className={`app-shell-main flex min-w-0 flex-1 flex-col ${isMobileRoute ? "hyb-mobile-shell overflow-hidden" : "lg:ml-72"}`}
style={isMobileRoute ? { height: "var(--mobile-viewport-height, 100vh)", maxHeight: "var(--mobile-viewport-height, 100vh)", minHeight: "var(--mobile-viewport-height, 100vh)" } : undefined}
>
{isMobileRoute ? null : <Topbar />}
+12 -1
查看文件
@@ -115,7 +115,7 @@ export function ForgotPasswordPage() {
return (
<AuthPanel
title="找回密码"
subtitle="使用账号邮箱接收验证码"
subtitle="用企业邮箱验证码重置"
data-testid="forgot-password-page"
footer={(
<Link className="font-medium text-primary hover:underline" to="/login">
@@ -123,6 +123,17 @@ export function ForgotPasswordPage() {
</Link>
)}
>
<div className="ux-step-list mb-5">
<div className="ux-step-item">
<span className="ux-step-item__index">1</span>
<span>输入账号绑定的企业邮箱,系统发送 6 位验证码。</span>
</div>
<div className="ux-step-item">
<span className="ux-step-item__index">2</span>
<span>填写验证码和新密码;验证码 10 分钟内有效。</span>
</div>
</div>
<form className="space-y-4" onSubmit={handleRequestCode}>
<div className="space-y-2">
<Label htmlFor="forgot-password-email">邮箱</Label>
+9 -3
查看文件
@@ -366,8 +366,14 @@ export function LoginPage() {
}
return (
<AuthPanel title="登录工作台" subtitle={showWecomPrimary ? "企业微信优先入口" : "账号、邮箱或企业微信"}>
<AuthPanel title="海音达 OA" subtitle={showWecomPrimary ? "企业微信环境优先免登" : "选择一种方式进入工作台"}>
<div className="space-y-4">
{!showWecomPrimary ? (
<div className="ux-command-note">
员工日常建议用企业邮箱验证码;固定办公电脑可使用账号密码。企业微信免登需要从企业微信工作台打开。
</div>
) : null}
{wecomNotice ? (
<div className={`rounded-lg border px-3.5 py-2.5 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
<div className="font-medium">{wecomNotice.title}</div>
@@ -406,7 +412,7 @@ export function LoginPage() {
type="button"
onClick={() => selectLoginMode("password")}
>
密码登录
账号密码
</button>
<button
aria-pressed={loginMode === "email_code"}
@@ -430,7 +436,7 @@ export function LoginPage() {
<div className="space-y-3">
<div className="flex items-center gap-3 text-xs text-slate-400">
<span className="h-px flex-1 bg-slate-100" />
<span>其他登录方式</span>
<span>企业入口</span>
<span className="h-px flex-1 bg-slate-100" />
</div>
+23 -23
查看文件
@@ -4,11 +4,10 @@ import { toast } from "sonner";
import { fetchNotifications, markAllNotificationsRead, markNotificationRead } from "../api/notifications";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { PageHeader } from "../components/common/PageHeader";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { Pagination } from "../components/common/Pagination";
import { Button } from "../components/ui/button";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { FilterBar, ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { resolveNotificationTargetLink } from "../lib/notificationLinks";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
@@ -79,10 +78,11 @@ export function NotificationsPage() {
const notificationItems = listQuery.data?.items ?? [];
return (
<PageScaffold>
<PageHeader
title="通知"
subtitle="事件、待办和业务直达入口。"
<ListWorkspace>
<WorkspaceHeader
eyebrow="通知"
title="通知中心"
subtitle="这里是事件收件箱;需要处理的事项会直达业务详情或审批任务。"
actions={
<>
<Button
@@ -95,13 +95,13 @@ export function NotificationsPage() {
全部标为已读
</Button>
<Button asChild variant="secondary" data-testid="notifications-back">
<Link to="/dashboard">返回仪表盘</Link>
<Link to="/portal">返回工作台</Link>
</Button>
</>
}
/>
<div className="card-std space-y-4 p-4 md:p-5">
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<Tabs
@@ -128,7 +128,7 @@ export function NotificationsPage() {
<span>类型</span>
<select
data-testid="notifications-type-filter"
className="h-10 min-w-40 rounded-lg border border-zinc-200 bg-white px-3 text-sm text-zinc-700 shadow-sm outline-none transition focus:border-zinc-300 focus:ring-2 focus:ring-zinc-100"
className="select-sm min-w-40"
value={selectedType}
onChange={(event) =>
updateFilters({
@@ -151,20 +151,21 @@ export function NotificationsPage() {
<span className="ml-2 text-zinc-500">/ 共 {listQuery.data?.total ?? 0}</span>
</div>
</div>
</FilterBar>
{listQuery.isLoading ? <TableSkeleton columns={4} rows={5} /> : null}
{listQuery.isLoading ? <TableSkeleton columns={4} rows={5} /> : null}
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(listQuery.error)
? `${listQuery.error.code}: ${mapApiErrorMessage(
listQuery.error.code,
listQuery.error.details,
listQuery.error.message,
)}`
: "加载通知失败"}
</p>
) : null}
{listQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(listQuery.error)
? `${listQuery.error.code}: ${mapApiErrorMessage(
listQuery.error.code,
listQuery.error.details,
listQuery.error.message,
)}`
: "加载通知失败"}
</p>
) : null}
{!listQuery.isLoading && !listQuery.isError ? (
<>
@@ -275,7 +276,6 @@ export function NotificationsPage() {
}
testIdPrefix="notifications"
/>
</div>
</PageScaffold>
</ListWorkspace>
);
}
+9 -7
查看文件
@@ -691,7 +691,8 @@ function QuickActionsPanel({
}) {
return (
<SectionPanel
title="常用"
title="常用动作"
description={layout === "rail" ? "从这里开始当天的主要操作。" : undefined}
className={layout === "rail" ? "portal-panel-fill" : undefined}
>
<div className={layout === "rail" ? "portal-action-stack" : "grid gap-3 sm:grid-cols-2 xl:grid-cols-4"}>
@@ -718,7 +719,8 @@ function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
return (
<SectionPanel
title="待处理"
title="今天先处理"
description="审批、TODO 和异常提醒集中在这里。"
className="portal-panel-fill"
>
<div className="space-y-3">
@@ -1121,12 +1123,12 @@ export function PortalHomePage() {
title={`你好,${payload.greeting.name}`}
className="workbench-hero--compact"
subtitle={roleCapabilities.canUseFinance
? "付款、风险、审批。"
: roleCapabilities.canManageSystem
? "配置、审计、通知。"
? "付款、风险、审批。"
: roleCapabilities.canManageSystem
? "先看待处理,再进入配置、审计和通知。"
: isManagerView
? "审批、团队、周报。"
: "待办、申请、考勤。"}
? "先处理审批,再看团队状态和周报缺口。"
: "先看待办,再提交申请、补材料或查看状态。"}
actions={
!payload.workspace.persisted && canSetupWorkspace ? (
<Button asChild variant="secondary" type="button">
+12 -11
查看文件
@@ -976,7 +976,7 @@ export function ReimbursementDetailPage() {
{downloadReasonDialog}
<EntityOverviewHero
title={`报销详情 ${displayCode}`}
subtitle="状态、票据、审批和付款集中处理。"
subtitle="先确认当前状态和下一步,再处理明细、发票、附件和审批记录。"
meta={(
<>
<StatusBadge status={detail.status} />
@@ -992,7 +992,7 @@ export function ReimbursementDetailPage() {
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
<Button asChild variant="secondary" data-testid="reimbursement-back">
<Link to="/reimbursements">返回列表</Link>
<Link to="/reimbursements">报销列表</Link>
</Button>
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
@@ -1022,7 +1022,7 @@ export function ReimbursementDetailPage() {
<div className="detail-workspace-grid">
<div className="space-y-5">
<SectionPanel title="办理摘要">
<SectionPanel title="当前状态与下一步" description="提交、付款、附件和审批依据集中在本页处理。">
<div className="space-y-4" data-testid="reimbursement-overview">
<StatStrip columns={4} className="ops-status-strip">
<MetricCard
@@ -1168,8 +1168,8 @@ export function ReimbursementDetailPage() {
<div className="space-y-4 text-sm">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">附件材料</h3>
<p className="text-xs text-zinc-500">支付截图、合同、订单或情况说明,不参与发票识别。</p>
<h3 className="text-base font-semibold text-zinc-900">补充附件</h3>
<p className="text-xs text-zinc-500">合同、订单、支付截图或情况说明;敏感附件下载会要求填写用途。</p>
</div>
{canEditItems ? (
<div className="flex flex-col gap-2 md:flex-row md:items-center">
@@ -1245,14 +1245,14 @@ export function ReimbursementDetailPage() {
</div>
</SectionPanel>
<SectionPanel title="明细与票据">
<SectionPanel title="明细与发票" description="明细决定金额,发票决定提交完整性。">
<div className="space-y-4">
<div className="space-y-4" data-testid="reimbursement-items">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">报销明细</h3>
<p className="text-xs text-zinc-500">明细变更会自动重算总金额。</p>
</div>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">报销明细</h3>
<p className="text-xs text-zinc-500">明细变更会自动重算总金额。</p>
</div>
{canEditItems ? (
<Button
@@ -1399,6 +1399,7 @@ export function ReimbursementDetailPage() {
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-zinc-900">发票</h3>
<p className="mt-1 text-xs text-zinc-500">提交前至少添加 1 张发票;重复或不可用发票会给出原因。</p>
</div>
{canOpenInvoiceBindDialog ? (
<Button
+14 -3
查看文件
@@ -29,7 +29,7 @@ import { Pagination } from "../components/common/Pagination";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { FilterBar, ListWorkspace, PrimaryTable, StatStrip, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { FilterBar, ListWorkspace, PrimaryTable, StatStrip, WorkspaceHeader, WorkspaceNotice } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { Button } from "../components/ui/button";
import {
@@ -362,8 +362,9 @@ export function ReimbursementsPage() {
return (
<ListWorkspace>
<WorkspaceHeader
title="我的报销"
subtitle="单据、金额、状态。"
eyebrow="报销"
title={scope === "all" ? "报销列表" : "我的报销"}
subtitle="先看状态和金额,再继续编辑、查看详情、导出或批量处理。"
actions={
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
{canCreateExpense ? (
@@ -594,6 +595,16 @@ export function ReimbursementsPage() {
) : null}
</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.isError ? (
+22 -15
查看文件
@@ -21,6 +21,7 @@ import {
StatStrip,
WorkspaceEmptyState,
WorkspaceHeader,
WorkspaceNotice,
} from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { Button } from "../components/ui/button";
@@ -209,7 +210,7 @@ export function WorkflowTasksPage() {
<WorkspaceHeader
eyebrow="审批"
title="审批中心"
subtitle="任务、节点、业务状态。"
subtitle="先看待处理,再进入详情核对依据;快速通过前仍会弹出确认。"
/>
<div data-testid="workflow-task-summary">
@@ -272,19 +273,25 @@ export function WorkflowTasksPage() {
</div>
</FilterBar>
{query.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
{status === "pending" ? (
<WorkspaceNotice title="处理原则" tone="neutral">
先看业务编号、申请人、金额/天数和当前节点;需要查看附件、审批历史或驳回原因时进入详情页。
</WorkspaceNotice>
) : null}
{query.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(query.error)
? `${query.error.code}: ${mapApiErrorMessage(
query.error.code,
query.error.details,
query.error.message,
)}`
: "加载待办失败,请稍后重试"}
</p>
) : null}
{query.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
{query.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{isApiError(query.error)
? `${query.error.code}: ${mapApiErrorMessage(
query.error.code,
query.error.details,
query.error.message,
)}`
: "加载待办失败,请稍后重试"}
</p>
) : null}
{!query.isLoading && !query.isError ? (
<>
@@ -347,7 +354,7 @@ export function WorkflowTasksPage() {
</article>
))
) : (
<WorkspaceEmptyState />
<WorkspaceEmptyState>当前筛选下没有审批任务。</WorkspaceEmptyState>
)}
</div>
@@ -410,7 +417,7 @@ export function WorkflowTasksPage() {
) : (
<TableRow>
<TableCell className="px-6 py-8 text-center text-zinc-500" colSpan={8}>
暂无数据
当前筛选下没有审批任务
</TableCell>
</TableRow>
)}
+5 -4
查看文件
@@ -530,11 +530,11 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
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();
});
test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page }) => {
test("Portal 管理视角重排正确,更多入口默认折叠", async ({ page }) => {
await mockPasswordLogin(page);
await mockNavigation(page, "manager");
await mockPortalHome(page, "manager");
@@ -558,9 +558,10 @@ test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page
await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible();
await expect(page.getByText("试点观测摘要")).toBeVisible();
await expect(page.getByRole("link", { name: "采购申请" })).not.toBeVisible();
await page.locator("summary").filter({ hasText: "备用模块" }).click();
await expect(page.getByRole("link", { name: "采购申请" })).toBeVisible();
await expect(page.getByRole("link", { name: "差旅申请" })).not.toBeVisible();
await page.locator("summary").filter({ hasText: "更多" }).click();
await expect(page.getByRole("link", { name: "差旅申请" })).toBeVisible();
});
test("Portal 新员工引导先显示欢迎弹窗,再显示快速上手清单", async ({ page }) => {