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

这个提交包含在:
2026-05-27 14:10:51 +08:00
父节点 63e214ca77
当前提交 466075c4a9
共修改 16 个文件,包含 551 行新增和 148 行删除
@@ -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();