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