收敛 PC 工作台视觉 05-27-pc-frontend-visual-consistency-wave1
这个提交包含在:
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
在新工单中引用
屏蔽一个用户