收敛 PC 工作台视觉 05-27-pc-frontend-visual-consistency-wave1

这个提交包含在:
2026-05-27 14:10:51 +08:00
父节点 63e214ca77
当前提交 466075c4a9
共修改 16 个文件,包含 551 行新增和 148 行删除
@@ -0,0 +1,5 @@
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Quality gate, commit and deployment governance"}
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Frontend design, auth/PWA and PC visual consistency contracts"}
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "Evidence and test output privacy boundaries"}
{"file": ".trellis/tasks/05-27-pc-frontend-visual-consistency-wave1/research/pc-visual-audit.md", "reason": "Design rule validation baseline summarized for this task"}
{"file": ".trellis/tasks/05-27-pc-frontend-visual-consistency-wave1/research/pc-visual-audit.md", "reason": "Acceptance-oriented visual audit baseline"}
@@ -0,0 +1,6 @@
{"file": ".trellis/spec/hyb-oa/index.md", "reason": "hyb-oa Trellis spec entry and read order for implementation"}
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Trellis task, approval, commit and deployment boundaries"}
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Frontend React/Vite design contracts and frontend-design authority"}
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "Prevent exposing secrets, full accounts, raw payloads or sensitive business data in evidence"}
{"file": ".trellis/tasks/05-27-pc-frontend-visual-consistency-wave1/research/pc-visual-audit.md", "reason": "Hyinda product design and PC workbench rules summarized for this task"}
{"file": ".trellis/tasks/05-27-pc-frontend-visual-consistency-wave1/research/pc-visual-audit.md", "reason": "Public page sampling and design convergence notes"}
@@ -0,0 +1,38 @@
# 实施记录
## 设计方向
- 视觉基调按 `frontend-design` 收敛为“克制、精密、低噪音的 Hyinda 工作台”。
- `docs/产品设计规则.md` 作为业务、品牌和验收参考;不使用营销式大卡、装饰光斑或多套页面局部风格。
- 第一批只覆盖共享 PC 视觉底座、`/portal` 和 `/reimbursements`,不扩展到全站。
## 实施范围
- 降低全局背景、卡片、侧边栏、顶栏、按钮、指标卡和动作块的装饰强度。
- `/portal` 员工视角保留核心指标、待处理、常用、团队状态和通知,把申请/资产/继续工作等低频内容收进“更多入口”。
- `/reimbursements` 只突出“新建报销”,把统计、导出、批量和代提交范围切换降到工具行。
- 补充视觉壳层 E2E mock,覆盖 `/portal` 与 `/reimbursements` 的 PC 收敛基线。
## 约束
- 不修改后端 API、权限模型、路由语义、数据库、真实业务数据或部署配置。
- 不改变移动端业务结构;移动用例只作为回归保护。
- 面向用户可测试前仍需部署和公网检查通过。
## Spec 更新判断
- 本轮没有新增 API、数据结构、权限、部署、环境变量或跨层契约。
- `frontend-design` 作为最终视觉裁决、PC 工作台低噪音方向、截图证据路径等规则已由 `.trellis/spec/hyb-oa/frontend-react-vite-design.md`、`docs/产品设计规则.md` 和本任务 PRD 覆盖。
- 因此不更新 `.trellis/spec/`,仅在当前任务实施记录中保留本轮样板页决策和验证结果。
## 验证记录
- `cd client && E2E_BASE_URL=http://127.0.0.1:3000 npx playwright test tests/e2e/visual-shell.spec.ts --workers=1 --reporter=list`:通过,5 passed。
- `cd client && npm run typecheck`:通过。
- `cd client && npm run build`:通过。
- `cd client && E2E_BASE_URL=http://127.0.0.1:3000 npx playwright test tests/e2e/login-portal-ux.spec.ts --workers=1 --reporter=list`:首次 9 passed / 1 failed,旧断言仍查找“我的申请与资产 / 继续工作”;已更新为“更多入口”后重跑通过,10 passed。
- `node scripts/qa/check-ui-design-rules.mjs --strict`:通过,0 findings。
- `python3 ./.trellis/scripts/task.py validate 05-27-pc-frontend-visual-consistency-wave1`:通过。
- `node scripts/qa/check-trellis-governance.mjs`:通过。
- `git diff --check`:通过。
- 截图证据:`output/playwright/pc-frontend-visual-consistency-wave1/` 已生成 PC 首页、PC 报销列表、壳层和移动回归截图。
@@ -0,0 +1,100 @@
# PC 前端视觉一致性第一批
## Goal
以 `frontend-design` 为视觉决策来源,把 PC 端从“多套页面局部样式并存”收敛为克制、精密、低噪音的 Hyinda 工作台。第一批只做设计底座和两个样板页:`/portal` 首页、`/reimbursements` 报销列表,形成后续页面迁移的可复用骨架。
## What I Already Know
- 用户已确认执行“PC 前端视觉一致性第一批”建议。
- 前端视觉最终裁决以 `frontend-design` skill 为准,`docs/产品设计规则.md` 作为业务、品牌和验收参考。
- 公网页面抽样显示:
- `/portal` 当前首屏层级偏多:标题 9 个、链接 42 个、`SectionPanel` 8 个,信息像模块拼接。
- `/reimbursements` 当前可见按钮约 19 个,主操作、状态切换、范围切换、导出和批量操作竞争注意力。
- 代码中并存 `PageHeader`、`WorkspaceHeader`、`WorkbenchHero`、`SectionPanel`、`WorkspaceSection`、`card-std` 和页面局部卡片写法。
- PC OA 应该像工作台:浅色、清晰边界、主任务突出、批量操作可达但不抢首屏。
## Research References
- `research/pc-visual-audit.md`:公网只读抽样与代码样板审视结论。
## Requirements
- 统一 PC 工作台基础样式:
- 降低全局背景、侧边栏、顶栏、卡片和指标卡的装饰噪音。
- 保留 Hyinda 品牌蓝作为主行动色,不新增营销式视觉。
- 收敛卡片圆角、阴影和渐变强度,让页面更像办公系统。
- 首页 `/portal`:
- 首屏优先展示:欢迎/角色、核心指标、待处理、常用动作。
- 员工视角下把“我的申请与资产、继续工作、常用入口、项目与协同”等低频或重复内容下沉到更轻的二级区域。
- 保留经理/管理员视角的业务信息,但减少标题和大卡片堆叠感。
- 报销列表 `/reimbursements`:
- 主操作只突出“新建报销”。
- 导出、批量操作、统计分析等降为次级工具区,不和主按钮同权。
- 状态切换保留,但视觉上应更像筛选控件而不是一排主按钮。
- 不改变报销创建、导出、批量、删除草稿、筛选、分页和权限逻辑。
- 自动化与视觉验证:
- 前端 typecheck/build 通过。
- Focused Playwright 覆盖 `/portal`、`/reimbursements` 可见性和主要操作入口。
- 补 PC 截图证据。
## Scope
- 共享 PC 视觉底座:`client/src/design/theme.css`、`client/src/index.css`、PC 壳层导航和通用按钮样式。
- 第一批样板页:`/portal` 首页、`/reimbursements` 报销列表。
- 回归验证:登录/Portal 交互、PC 首页、PC 报销列表、移动首页和移动审批列表的既有视觉壳层基线。
- 任务记录:当前 Trellis task 下的 PRD、研究记录、实施记录和验证记录。
## Acceptance Criteria
- [x] PC 侧边栏/顶栏/卡片视觉更克制,阴影和渐变显著减少。
- [x] `/portal` 员工视角首屏信息减少,不再出现多个同级大区块抢注意力。
- [x] `/portal` 保留待处理、常用动作、通知/动态、最近访问等入口,但低频内容下沉。
- [x] `/reimbursements` 只把“新建报销”作为主 CTA,导出/批量/统计分析成为次级工具。
- [x] `/reimbursements` 原有列表、状态 tab、权限、删除草稿、批量模式入口仍可用。
- [x] 不修改后端接口、权限模型、路由语义、数据库或真实业务数据。
- [x] 前端 typecheck/build、UI 设计规则检查、focused E2E、Trellis governance、`git diff --check` 通过。
## Definition Of Done
- 第一批 PC 视觉收敛代码、测试和任务记录完成。
- 本地验证、视觉壳层回归、登录/Portal 回归、Trellis 治理校验和截图证据完成。
- 下一步进入提交与部署批准;未经部署和公网检查,不告知用户可以公网测试。
## Test Plan
- 本地验证通过:
- `cd client && npm run typecheck`
- `cd client && npm run build`
- `cd client && E2E_BASE_URL=http://127.0.0.1:<port> npx playwright test tests/e2e/login-portal-ux.spec.ts --workers=1 --reporter=list`
- `cd client && E2E_BASE_URL=http://127.0.0.1:<port> npx playwright test tests/e2e/visual-shell.spec.ts --workers=1 --reporter=list` 或新增 focused 用例。
- `node scripts/qa/check-ui-design-rules.mjs --strict`
- `python3 ./.trellis/scripts/task.py validate 05-27-pc-frontend-visual-consistency-wave1`
- `node scripts/qa/check-trellis-governance.mjs`
- `git diff --check`
- 生成桌面截图证据,保存到 `output/playwright/pc-frontend-visual-consistency-wave1/`。
- 提交代码后再请求部署批准;未经部署和公网检查,不告知用户可以公网测试。
## Out Of Scope
- 不做全站页面一次性重构。
- 不改移动核心业务流程。
- 不新增第三方 UI 库。
- 不改后端 API、权限、审批、报销状态机、数据库或部署环境变量。
- 不删除业务功能,只调整信息层级、视觉密度和入口权重。
## Technical Notes
- 设计源:`frontend-design` skill。
- 产品规则:`docs/产品设计规则.md`。
- 前端规范:`.trellis/spec/hyb-oa/frontend-react-vite-design.md`。
- 主要文件:
- `client/src/index.css`
- `client/src/components/navigation/Sidebar.tsx`
- `client/src/components/navigation/Topbar.tsx`
- `client/src/components/patterns/ListWorkspace.tsx`
- `client/src/components/patterns/SectionPanel.tsx`
- `client/src/components/patterns/MetricCard.tsx`
- `client/src/components/patterns/WorkbenchHero.tsx`
- `client/src/pages/PortalHomePage.tsx`
- `client/src/pages/ReimbursementsPage.tsx`
@@ -0,0 +1,45 @@
# PC 视觉一致性审视
## 方法
- 使用 `frontend-design` 的方向重新审视 PC 页面:目的、语气、约束、差异化。
- 结合项目事实源 `docs/产品设计规则.md` 和前端规范。
- 对公网 `https://oa.hyinda.com` 做只读抽样,页面包括 `/login`、`/portal`、`/reimbursements`、`/projects`、移动首页和移动报销。
- 只记录结构性指标,不记录账号、密码、完整邮箱、完整手机号或原始业务 payload。
## 抽样结论
- `/portal`:
- 标题层级偏多,抽样可见 9 个标题。
- 链接入口偏多,抽样可见 42 个链接。
- `card-std` 9 个、`section-panel` 8 个,主次区块不像同一张工作台。
- `rounded-2xl`、阴影和渐变使用偏多,形成“模块拼接”观感。
- `/reimbursements`:
- 抽样可见按钮约 19 个,主按钮、导出、状态、范围、批量、删除等同屏竞争。
- 当前业务功能可用,但首屏操作权重没有收敛。
- 状态切换应保持高可用,但视觉上应降为筛选控件。
- 代码样板:
- `PageHeader` 与 `WorkspaceHeader` 基本重复。
- `SectionPanel` 与 `WorkspaceSection` 都承担“卡片分区”角色。
- `WorkbenchHero`、`MetricCard`、`card-std` 当前仍保留较多装饰性背景、阴影或渐变。
## 推荐方向
设计语气:克制、精密、低噪音的 Hyinda 工作台。
- 保留品牌蓝用于主 CTA、选中态和状态强调。
- 降低全局背景、卡片、侧边栏和顶栏装饰强度。
- 首屏只保留一个明确工作流:需要处理什么、常用动作是什么、最新变化是什么。
- 低频功能保留入口,但下沉到次级区块或工具菜单。
## 第一批范围
- 统一 PC 基础视觉 token 和共享组件,不新增 UI 库。
- 首页 `/portal` 作为工作台样板。
- 报销列表 `/reimbursements` 作为列表/筛选/表格样板。
## 风险
- 首页角色差异多,改动要保留管理员、经理、员工、财务视角的可用入口。
- 报销列表涉及导出、批量、代提交、模板创建、差旅关联,不能只做视觉隐藏导致操作不可达。
- 不应把移动端专项样式带回 PC,也不应让 PC 端变成营销页。
@@ -0,0 +1,36 @@
{
"id": "pc-frontend-visual-consistency-wave1",
"name": "pc-frontend-visual-consistency-wave1",
"title": "PC 前端视觉一致性第一批",
"description": "第一批收敛 PC 前端视觉体系,降低装饰噪音并以 /portal 与 /reimbursements 作为工作台和列表页样板。",
"status": "in_progress",
"dev_type": null,
"scope": "client/pc-visual",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-05-27",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
"client/src/index.css",
"client/src/components/navigation/Sidebar.tsx",
"client/src/components/navigation/Topbar.tsx",
"client/src/components/patterns/ListWorkspace.tsx",
"client/src/components/patterns/SectionPanel.tsx",
"client/src/components/patterns/MetricCard.tsx",
"client/src/components/patterns/WorkbenchHero.tsx",
"client/src/pages/PortalHomePage.tsx",
"client/src/pages/ReimbursementsPage.tsx"
],
"notes": "",
"meta": {}
}
@@ -75,10 +75,10 @@ function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
onClick={() => onNavigate?.()}
className={({ isActive }) =>
cn(
"group flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm transition duration-200",
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition duration-150",
isActive
? "bg-primary text-white shadow-sm"
: "text-[var(--color-text-secondary)] hover:-translate-y-0.5 hover:bg-white/70 hover:text-[var(--color-text-primary)] hover:shadow-sm",
? "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)]",
)
}
>
@@ -86,9 +86,9 @@ function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
<>
<span
className={cn(
"flex size-9 items-center justify-center rounded-xl border text-[20px] transition-colors",
"flex size-9 items-center justify-center rounded-lg border text-[20px] transition-colors",
isActive
? "border-white/15 bg-white/15 text-white"
? "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",
)}
>
+5 -5
查看文件
@@ -3,19 +3,19 @@ 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/92 shadow-[18px_0_50px_rgba(15,23,42,0.08)] backdrop-blur-xl lg:flex lg:flex-col">
<div className="border-b border-slate-200 px-6 py-6">
<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">
<div className="border-b border-slate-200 px-6 py-5">
<AppBrand />
<div className="mt-5 overflow-hidden rounded-3xl border border-slate-200 bg-[linear-gradient(135deg,rgba(0,57,138,0.08),rgba(19,168,184,0.10))] px-4 py-4 text-sm text-[var(--color-text-secondary)]">
<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="flex items-center gap-2 font-semibold text-[var(--color-text-primary)]">
<span className="size-2 rounded-full bg-primary" />
今日工作台
</div>
<div className="mt-2 leading-6">待处理、团队状态和常用入口。</div>
<div className="mt-1.5 leading-6">待处理、团队状态和常用入口。</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5 [scrollbar-width:thin]">
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 [scrollbar-width:thin]">
<NavLinks />
</div>
</aside>
+1 -1
查看文件
@@ -135,7 +135,7 @@ export function Topbar() {
return (
<>
<header className="sticky top-0 z-10 border-b border-slate-200/80 bg-white/88 px-4 py-3 shadow-[0_10px_30px_rgba(15,23,42,0.06)] backdrop-blur-xl sm:px-8">
<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">
<div className="flex min-w-0 items-center gap-3">
<Dialog open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
+2 -2
查看文件
@@ -8,8 +8,8 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: "bg-[var(--color-accent-primary)] text-white shadow-sm hover:-translate-y-0.5 hover:bg-[var(--color-accent-primary-hover)] hover:shadow-md",
secondary: "border border-slate-200 bg-white/90 text-slate-700 shadow-sm hover:-translate-y-0.5 hover:bg-white hover:shadow-md",
default: "bg-[var(--color-accent-primary)] text-white shadow-sm hover:bg-[var(--color-accent-primary-hover)]",
secondary: "border border-slate-200 bg-white/90 text-slate-700 shadow-sm hover:border-slate-300 hover:bg-white",
outline: "border border-slate-200 bg-transparent text-slate-900 hover:bg-white/80",
destructive: "bg-red-600 text-white hover:bg-red-700 shadow-sm",
ghost: "bg-transparent text-slate-700 hover:bg-slate-100",
+6 -6
查看文件
@@ -3,7 +3,7 @@
--color-bg-canvas-deep: #e8eef5;
--color-bg-surface: #ffffff;
--color-bg-surface-subtle: #f8fafc;
--color-bg-surface-raised: rgba(255, 255, 255, 0.92);
--color-bg-surface-raised: rgba(255, 255, 255, 0.96);
--color-bg-primary-soft: #e6ecf4;
--color-text-primary: #0f172a;
--color-text-secondary: #475569;
@@ -21,11 +21,11 @@
--radius-sm: 0.5rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--radius-xl: 1.25rem;
--radius-2xl: 1.5rem;
--radius-panel: 1.25rem;
--radius-xl: 1rem;
--radius-2xl: 1.25rem;
--radius-panel: 1rem;
--shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.05);
--shadow-sm: 0 8px 24px rgba(15, 23, 42, 0.08), 0 1px 2px rgba(15, 23, 42, 0.05);
--shadow-md: 0 22px 55px rgba(0, 57, 138, 0.14);
--shadow-sm: 0 4px 14px rgba(15, 23, 42, 0.055), 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow-md: 0 14px 34px rgba(0, 57, 138, 0.10);
--shadow-focus: 0 0 0 4px rgba(0, 57, 138, 0.14);
}
+47 -31
查看文件
@@ -23,10 +23,7 @@
body {
min-height: 100vh;
min-height: -webkit-fill-available;
background:
radial-gradient(circle at 10% -8%, rgba(0, 57, 138, 0.10), transparent 30rem),
radial-gradient(circle at 92% 0%, rgba(19, 168, 184, 0.12), transparent 32rem),
linear-gradient(180deg, #ffffff 0%, var(--color-bg-canvas) 18rem, #eef2f6 100%);
background: linear-gradient(180deg, #ffffff 0%, var(--color-bg-canvas) 16rem, #eef2f6 100%);
background-attachment: fixed;
color: var(--color-text-primary);
@apply font-sans antialiased selection:bg-primary selection:text-white;
@@ -48,8 +45,8 @@
}
.page-scaffold {
animation: page-reveal 420ms ease-out both;
@apply space-y-6;
animation: page-reveal 320ms ease-out both;
@apply space-y-5;
}
.workbench-hero {
@@ -58,7 +55,7 @@
background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(226, 232, 240, 0.92);
border-radius: var(--radius-panel);
box-shadow: var(--shadow-sm);
box-shadow: var(--shadow-xs);
@apply flex flex-col gap-4 px-5 py-5 md:flex-row md:items-end md:justify-between md:px-6;
}
@@ -102,8 +99,8 @@
background: var(--color-bg-surface-raised);
border: 1px solid rgba(226, 232, 240, 0.92);
border-radius: var(--radius-panel);
box-shadow: var(--shadow-sm);
backdrop-filter: blur(18px);
box-shadow: var(--shadow-xs);
backdrop-filter: none;
}
.card-title {
@@ -156,24 +153,24 @@
.metric-card {
position: relative;
overflow: hidden;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.86));
background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(226, 232, 240, 0.96);
border-radius: var(--radius-panel);
box-shadow: var(--shadow-xs);
@apply block px-5 py-5 transition duration-200 hover:-translate-y-0.5 hover:shadow-sm;
@apply block px-4 py-4 transition duration-150 hover:border-slate-300 hover:bg-white;
}
.metric-card::before {
position: absolute;
inset: 0 auto 0 0;
width: 4px;
width: 3px;
content: "";
background: linear-gradient(180deg, var(--color-accent-primary), rgba(19, 168, 184, 0.72));
opacity: 0.82;
}
.metric-card--warning {
background: linear-gradient(180deg, rgba(255, 251, 235, 0.98), rgba(254, 243, 199, 0.78));
background: rgba(255, 251, 235, 0.92);
@apply border-amber-200;
}
@@ -182,7 +179,7 @@
}
.metric-card--danger {
background: linear-gradient(180deg, rgba(254, 242, 242, 0.98), rgba(254, 226, 226, 0.76));
background: rgba(254, 242, 242, 0.94);
@apply border-red-200;
}
@@ -197,7 +194,7 @@
.metric-card__value {
color: var(--color-text-primary);
@apply mt-2 text-2xl font-semibold tracking-tight;
@apply mt-2 text-xl font-semibold tracking-tight;
}
.metric-card__note {
@@ -297,11 +294,11 @@
}
.portal-metric-strip {
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-4;
@apply grid gap-3 md:grid-cols-2 xl:grid-cols-4;
}
.portal-home-grid {
@apply grid gap-6 xl:grid-cols-[minmax(0,2fr),minmax(320px,0.95fr)] xl:items-stretch;
@apply grid gap-5 xl:grid-cols-[minmax(0,2fr),minmax(320px,0.92fr)] xl:items-stretch;
}
.portal-home-grid--collaboration {
@@ -315,15 +312,14 @@
.portal-list-card {
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(226, 232, 240, 0.94);
border-radius: var(--radius-panel);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xs);
@apply block px-4 py-4 transition duration-200;
@apply block px-3.5 py-3.5 transition duration-150;
}
.portal-list-card:hover {
background: #ffffff;
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
border-color: rgba(203, 213, 225, 0.96);
}
.portal-list-card--warning {
@@ -342,17 +338,16 @@
.portal-action-tile {
min-height: 100%;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.86));
background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(226, 232, 240, 0.94);
border-radius: var(--radius-panel);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xs);
@apply px-4 py-4 text-left transition duration-200;
@apply px-3.5 py-3.5 text-left transition duration-150;
}
.portal-action-tile:hover {
box-shadow: var(--shadow-sm);
transform: translateY(-2px);
border-color: rgba(203, 213, 225, 0.96);
background: #ffffff;
}
.portal-action-tile--disabled {
@@ -367,9 +362,9 @@
}
.portal-action-icon {
background: linear-gradient(135deg, rgba(0, 57, 138, 0.12), rgba(19, 168, 184, 0.12));
background: rgba(0, 57, 138, 0.08);
color: var(--color-accent-primary);
@apply flex size-10 items-center justify-center rounded-2xl;
@apply flex size-10 items-center justify-center rounded-xl;
}
.portal-action-icon--muted {
@@ -409,7 +404,7 @@
}
.portal-secondary-drawer {
@apply p-5 md:p-6;
@apply p-4 md:p-5;
}
.portal-secondary-summary {
@@ -422,7 +417,28 @@
}
.portal-secondary-drawer__body {
@apply mt-5 space-y-6;
@apply mt-4 space-y-5;
}
.portal-secondary-compact-grid {
@apply grid gap-4 xl:grid-cols-2;
}
.portal-compact-group {
@apply rounded-xl border border-slate-200 bg-white/80 px-3.5 py-3.5;
}
.portal-compact-group__title {
color: var(--color-text-primary);
@apply text-sm font-semibold;
}
.portal-compact-group__body {
@apply mt-3 space-y-2;
}
.portal-compact-row {
@apply flex min-h-9 items-center justify-between gap-3 rounded-lg px-2 py-1.5 text-sm text-slate-700 transition-colors hover:bg-slate-50 hover:text-slate-950;
}
}
+77 -32
查看文件
@@ -384,23 +384,6 @@ function AnnouncementsList({ announcements }: { announcements: PortalHomePayload
);
}
function RecentVisitsPanel() {
const recentVisits = readRecentVisits();
return (
<SectionPanel title="继续工作">
<div className="space-y-3">
{recentVisits.length ? recentVisits.map((item) => (
<Link key={item.path} to={item.path} className="portal-list-card">
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="mt-1 text-xs text-zinc-500">{item.path}</div>
</Link>
)) : <EmptyStateCard title="暂无最近访问" description="最近页面会显示在这里。" />}
</div>
</SectionPanel>
);
}
function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] }) {
const safeItems = Array.isArray(items) ? items : [];
@@ -431,7 +414,7 @@ function SecondaryCardsPanel({ title, description, cards }: { title: string; des
title={card.title}
value={card.value}
href={card.href}
note="查看"
note="查看"
tone={severityTone(card.severity)}
/>
))}
@@ -440,6 +423,75 @@ function SecondaryCardsPanel({ title, description, cards }: { title: string; des
);
}
function CompactGroup({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="portal-compact-group">
<h3 className="portal-compact-group__title">{title}</h3>
<div className="portal-compact-group__body">{children}</div>
</section>
);
}
function CompactSummaryLinks({ title, items }: { title: string; items: SummaryItem[] }) {
return (
<CompactGroup title={title}>
{items.length ? items.map((item) => (
<Link key={item.key} to={item.href} className="portal-compact-row">
<span className="min-w-0 truncate">{item.label}</span>
<span className={item.tone === "warning" ? "shrink-0 font-semibold text-amber-700" : "shrink-0 font-semibold text-slate-900"}>
{item.value}
</span>
</Link>
)) : <div className="px-2 py-1.5 text-sm text-slate-500">暂无摘要。</div>}
</CompactGroup>
);
}
function CompactRecentVisits() {
const recentVisits = readRecentVisits();
return (
<CompactGroup title="继续工作">
{recentVisits.length ? recentVisits.map((item) => (
<Link key={item.path} to={item.path} className="portal-compact-row">
<span className="min-w-0 truncate">{item.title}</span>
<span className="shrink-0 text-xs text-slate-400">打开</span>
</Link>
)) : <div className="px-2 py-1.5 text-sm text-slate-500">暂无最近访问。</div>}
</CompactGroup>
);
}
function CompactQuickLinks({ items }: { items: PortalHomePayload["quickLinks"] }) {
const safeItems = Array.isArray(items) ? items : [];
return (
<CompactGroup title="常用入口">
{safeItems.length ? safeItems.slice(0, 6).map((item) => (
<Link key={item.key} to={item.href} className="portal-compact-row">
<span className="min-w-0 truncate">{item.title}</span>
<span className="shrink-0 text-xs text-slate-400">进入</span>
</Link>
)) : <div className="px-2 py-1.5 text-sm text-slate-500">暂无常用入口。</div>}
</CompactGroup>
);
}
function CompactMetricLinks({ title, cards }: { title: string; cards: PortalHomePayload["cards"] }) {
return (
<CompactGroup title={title}>
{cards.length ? cards.slice(0, 6).map((card) => (
<Link key={card.key} to={card.href} className="portal-compact-row">
<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"}>
{card.value}
</span>
</Link>
)) : <div className="px-2 py-1.5 text-sm text-slate-500">暂无统计项。</div>}
</CompactGroup>
);
}
function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullable<PortalHomePayload["adoptionSummary"]> | null }) {
if (!adoptionSummary) {
return null;
@@ -1191,21 +1243,14 @@ export function PortalHomePage() {
</>
) : (
<SecondaryWorkspacePanel
title="我的申请与资产 / 继续工作"
description="申请、资产、最近访问"
title="更多入口"
description="申请、资产、最近访问和项目指标"
>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
<SummaryPanel title="我的申请与资产" items={employeeSummaryItems} />
<RecentVisitsPanel />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
<QuickLinksPanel items={quickLinkItems} />
<SecondaryCardsPanel
title="项目与协同"
description="项目、参考指标"
cards={secondaryCards}
/>
<div className="portal-secondary-compact-grid">
<CompactSummaryLinks title="我的申请与资产" items={employeeSummaryItems} />
<CompactRecentVisits />
<CompactQuickLinks items={quickLinkItems} />
<CompactMetricLinks title="项目与协同" cards={secondaryCards} />
</div>
</SecondaryWorkspacePanel>
)}
+48 -51
查看文件
@@ -366,30 +366,6 @@ export function ReimbursementsPage() {
subtitle="草稿、审批和付款状态。"
actions={
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
{showProxyOwnerSelector ? (
<select
className="select-std"
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}
{canViewAll ? (
<Button asChild variant="secondary" type="button">
<Link to="/reimbursements/dashboard">统计分析</Link>
</Button>
) : null}
{canCreateExpense ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -446,33 +422,6 @@ export function ReimbursementsPage() {
</DropdownMenuContent>
</DropdownMenu>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="reimbursements-export"
variant="secondary"
type="button"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-testid="reimbursements-export-csv"
onSelect={() => setExportConfirmFormat("csv")}
>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem
data-testid="reimbursements-export-xlsx"
onSelect={() => setExportConfirmFormat("xlsx")}
>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
}
/>
@@ -547,6 +496,24 @@ export function ReimbursementsPage() {
</Tabs>
<div className="flex flex-wrap items-center gap-2">
{showProxyOwnerSelector ? (
<select
className="select-sm h-10"
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}
{canViewAll ? (
<>
<Button
@@ -565,9 +532,39 @@ export function ReimbursementsPage() {
>
全范围
</Button>
<Button asChild variant="secondary" type="button">
<Link to="/reimbursements/dashboard">统计分析</Link>
</Button>
</>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="reimbursements-export"
variant="secondary"
type="button"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-testid="reimbursements-export-csv"
onSelect={() => setExportConfirmFormat("csv")}
>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem
data-testid="reimbursements-export-xlsx"
onSelect={() => setExportConfirmFormat("xlsx")}
>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{canShowBatchEntry ? (
<Button
variant={batchMode ? "default" : "secondary"}
+1 -1
查看文件
@@ -526,7 +526,7 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
"portal-card-my_projects",
]);
await expect(page.locator("summary").filter({ hasText: "我的申请与资产 / 继续工作" })).toBeVisible();
await expect(page.locator("summary").filter({ hasText: "更多入口" })).toBeVisible();
await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
await expect(page.getByRole("heading", { name: "常用" })).toBeVisible();
await expect(page.getByRole("heading", { name: "团队状态" })).toBeVisible();
+129 -14
查看文件
@@ -1,5 +1,9 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page, type Route, type TestInfo } from "@playwright/test";
const SCREENSHOT_DIR = path.resolve(process.cwd(), "../output/playwright/pc-frontend-visual-consistency-wave1");
function envelope(data: unknown) {
return { requestId: "mock-r96-ui", data };
}
@@ -48,6 +52,22 @@ async function mockVisualApis(page: Page) {
return;
}
if (path === "/api/auth/capabilities") {
await fulfillJson(route, {
emailDelivery: {
ready: true,
statusReasons: [],
},
emailLoginCode: {
enabled: true,
},
passwordReset: {
enabled: true,
},
});
return;
}
if (path === "/api/auth/wecom/config") {
await fulfillJson(route, {
enabled: true,
@@ -166,11 +186,32 @@ async function mockVisualApis(page: Page) {
return;
}
if (path === "/api/workflow/tasks/summary") {
await fulfillJson(route, {
scope: url.searchParams.get("scope") === "system" ? "system" : "mine",
pendingTotal: 1,
overdueTotal: 0,
earliestPendingAt: "2026-05-06T09:00:00.000Z",
averagePendingAgeHours: 2,
byEntityType: [
{
key: "reimbursement",
pendingTotal: 1,
overdueTotal: 0,
},
],
byStep: [],
byAssigneeRole: [],
});
return;
}
if (path === "/api/notifications") {
await fulfillJson(route, {
items: [
{
id: "notice-r96",
type: "workflow.task_assigned",
title: "审批提醒",
body: "有 1 条待办。",
targetType: "workflow_task",
@@ -182,6 +223,59 @@ async function mockVisualApis(page: Page) {
total: 1,
page: 1,
pageSize: 3,
unreadOnly: false,
unreadCount: 1,
});
return;
}
if (path === "/api/reimbursement-templates") {
await fulfillJson(route, { enabled: true, items: [] });
return;
}
if (path === "/api/reimbursements/linkable-travel-requests") {
await fulfillJson(route, { items: [] });
return;
}
if (path === "/api/reimbursements") {
await fulfillJson(route, {
page: 1,
pageSize: 10,
total: 1,
scope: "own",
items: [
{
id: "rb-r96",
formNumber: "BX20260506001",
status: "draft",
reimbursementDate: "2026-05-06",
totalAmount: "168.30",
description: "视觉基线报销",
payment: {
paymentVoucherNo: null,
paymentDate: null,
paymentChannel: null,
paidAt: null,
paidBy: null,
},
owner: {
id: "employee-r96",
employeeNo: "R96001",
name: "Employee",
},
submitter: {
id: "employee-r96",
employeeNo: "R96001",
name: "Employee",
},
project: null,
pendingWorkflow: null,
createdAt: "2026-05-06T09:00:00.000Z",
updatedAt: "2026-05-06T09:00:00.000Z",
},
],
});
return;
}
@@ -191,10 +285,22 @@ async function mockVisualApis(page: Page) {
}
async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) {
const body = await page.screenshot({ fullPage: true });
await testInfo.attach(name, {
body: await page.screenshot({ fullPage: true }),
body,
contentType: "image/png",
});
await mkdir(SCREENSHOT_DIR, { recursive: true });
await writeFile(path.join(SCREENSHOT_DIR, name), body);
}
async function loginVisualUser(page: Page) {
await page.goto("/login");
await expect(page.getByTestId("login-password-primary-panel")).toBeVisible();
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("EmployeePassword1234");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/portal/);
}
test.describe("visual shell", () => {
@@ -213,11 +319,7 @@ test.describe("visual shell", () => {
test("登录后壳层布局基线", async ({ page }, testInfo) => {
await mockVisualApis(page);
await page.setViewportSize({ width: 1440, height: 1024 });
await page.goto("/login");
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("EmployeePassword1234");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/portal/);
await loginVisualUser(page);
await expect(page.locator("aside")).toBeVisible();
await expect(page.getByTestId("topbar-profile-trigger")).toBeVisible();
@@ -227,13 +329,29 @@ test.describe("visual shell", () => {
await attachScreenshot(page, testInfo, "app-shell.png");
});
test("PC 首页与报销列表收敛布局基线", async ({ page }, testInfo) => {
await mockVisualApis(page);
await page.setViewportSize({ width: 1440, height: 1024 });
await loginVisualUser(page);
await expect(page.getByRole("heading", { name: "待处理" })).toBeVisible();
await expect(page.getByRole("heading", { name: "常用" })).toBeVisible();
await expect(page.locator("summary").filter({ hasText: /角色重点|更多入口/ })).toBeVisible();
await attachScreenshot(page, testInfo, "pc-portal-wave1.png");
await page.goto("/reimbursements");
await expect(page.getByRole("heading", { name: "我的报销" })).toBeVisible();
await expect(page.getByTestId("reimbursements-create")).toBeVisible();
await expect(page.getByTestId("reimbursements-export")).toBeVisible();
await expect(page.getByTestId("reimbursements-status-tabs")).toBeVisible();
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
await attachScreenshot(page, testInfo, "pc-reimbursements-wave1.png");
});
test("移动首页任务化布局基线", async ({ page }, testInfo) => {
await mockVisualApis(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/login");
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("EmployeePassword1234");
await page.getByTestId("login-submit").click();
await loginVisualUser(page);
await page.goto("/mobile");
await expect(page.getByRole("heading", { name: "今天" })).toBeVisible();
@@ -247,10 +365,7 @@ test.describe("visual shell", () => {
test("审批待办手机卡片布局基线", async ({ page }, testInfo) => {
await mockVisualApis(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/login");
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("EmployeePassword1234");
await page.getByTestId("login-submit").click();
await loginVisualUser(page);
await page.goto("/workflow/tasks");
await expect(page.getByTestId("workflow-tasks-mobile-list")).toBeVisible();