优化PC核心工作流视觉 Wave2(05-27-pc-core-workflow-visual-wave2)

这个提交包含在:
2026-05-27 18:12:23 +08:00
父节点 8f17d6454d
当前提交 9ac73a3872
共修改 16 个文件,包含 771 行新增和 96 行删除
+3 -3
查看文件
@@ -15,13 +15,13 @@ export function AuthPanel({ title, subtitle, children, footer, className, "data-
return (
<section
className={cn(
"relative w-full overflow-hidden rounded-2xl border border-slate-200/80 bg-white/95 p-6 shadow-[0_24px_80px_rgba(15,23,42,0.10)] ring-1 ring-white/70 backdrop-blur sm:p-8",
"auth-panel relative w-full overflow-hidden p-6 sm:p-7",
className,
)}
data-testid={testId}
>
<div aria-hidden="true" className="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#00398a,#0f766e)]" />
<div className="space-y-7">
<div aria-hidden="true" className="auth-panel__accent" />
<div className="space-y-6">
<AppBrand compact />
<div>
<h1 className="text-xl font-semibold leading-7 text-slate-950">{title}</h1>
@@ -9,6 +9,7 @@ export function MetricCard({
href,
tone = "neutral",
ariaLabel,
className,
}: {
title: string;
value: ReactNode;
@@ -16,6 +17,7 @@ export function MetricCard({
href?: string;
tone?: "neutral" | "warning" | "danger";
ariaLabel?: string;
className?: string;
}) {
const content = (
<div className="relative z-[1]">
@@ -28,22 +30,23 @@ export function MetricCard({
</div>
);
const className = cn(
const rootClassName = cn(
"metric-card",
tone === "warning" && "metric-card--warning",
tone === "danger" && "metric-card--danger",
className,
);
if (href) {
return (
<Link to={href} aria-label={ariaLabel} className={className}>
<Link to={href} aria-label={ariaLabel} className={rootClassName}>
{content}
</Link>
);
}
return (
<div aria-label={ariaLabel} className={className}>
<div aria-label={ariaLabel} className={rootClassName}>
{content}
</div>
);
+173 -19
查看文件
@@ -49,6 +49,69 @@
@apply space-y-5;
}
.auth-shell {
background:
radial-gradient(circle at 20% 12%, rgba(0, 57, 138, 0.10), transparent 26rem),
radial-gradient(circle at 78% 78%, rgba(19, 168, 184, 0.10), transparent 24rem),
linear-gradient(180deg, #f9fbfd 0%, #eff4f8 58%, #e6edf3 100%);
}
.auth-shell__grid {
opacity: 0.68;
background-image:
linear-gradient(rgba(15, 23, 42, 0.052) 1px, transparent 1px),
linear-gradient(90deg, rgba(15, 23, 42, 0.052) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: linear-gradient(180deg, black, transparent 88%);
}
.auth-shell__rail {
background: linear-gradient(180deg, transparent, rgba(0, 57, 138, 0.34), rgba(19, 168, 184, 0.28), transparent);
}
.auth-panel {
border: 1px solid rgba(203, 213, 225, 0.92);
border-radius: var(--radius-lg);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.96)),
#ffffff;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.12), 0 1px 0 rgba(255, 255, 255, 0.82) inset;
}
.auth-panel__accent {
position: absolute;
inset: 0 auto 0 0;
width: 4px;
background: linear-gradient(180deg, var(--color-accent-primary), var(--color-accent-secondary));
}
.auth-mode-switch {
border: 1px solid rgba(203, 213, 225, 0.88);
background: rgba(241, 245, 249, 0.74);
@apply grid grid-cols-2 gap-1 rounded-lg p-1;
}
.auth-mode-switch__item {
@apply h-9 rounded-md text-sm font-medium transition;
}
.auth-mode-switch__item--active {
color: var(--color-accent-primary);
background: #ffffff;
box-shadow: var(--shadow-xs);
}
.auth-mode-switch__item--idle {
color: var(--color-text-secondary);
@apply hover:text-slate-950;
}
.auth-side-entry {
border: 1px solid rgba(203, 213, 225, 0.88);
background: rgba(248, 250, 252, 0.82);
@apply rounded-lg px-3.5 py-3 text-sm leading-6;
}
.workbench-hero {
position: relative;
overflow: hidden;
@@ -59,6 +122,97 @@
@apply flex flex-col gap-4 px-5 py-5 md:flex-row md:items-end md:justify-between md:px-6;
}
.ops-status-strip {
@apply grid gap-3;
}
.ops-status-strip .metric-card {
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xs);
@apply px-3.5 py-3.5;
}
.ops-status-strip .metric-card::before {
width: 2px;
}
.ops-status-strip .metric-card__title {
@apply text-xs font-semibold;
}
.ops-status-strip .metric-card__value {
@apply mt-1 text-lg;
}
.ops-status-strip .metric-card__note {
@apply mt-1 text-xs leading-5;
}
.ops-command-bar {
border-color: rgba(203, 213, 225, 0.92);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.94);
box-shadow: var(--shadow-xs);
@apply p-3 md:p-3.5;
}
.ops-command-bar__row {
@apply flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between;
}
.ops-command-bar__tabs {
@apply flex w-full flex-wrap justify-start gap-1 rounded-lg border border-slate-200 bg-slate-50 p-1 xl:w-auto;
}
.ops-command-bar__actions {
@apply flex w-full flex-wrap items-center gap-2 xl:w-auto xl:justify-end;
}
.ops-filter-label {
color: var(--color-text-secondary);
@apply text-xs font-semibold;
}
.ops-inline-alert {
border-radius: var(--radius-lg);
@apply border px-3.5 py-2.5 text-sm leading-6;
}
.ops-table-focus {
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xs);
}
.ops-table-focus table {
min-width: 760px;
}
.ops-table-focus thead,
.ops-table-focus .table-header {
background: rgba(248, 250, 252, 0.96);
}
.ops-table-focus th,
.ops-table-focus .table-th {
color: var(--color-text-tertiary);
@apply px-4 py-3 text-xs font-semibold;
}
.ops-table-focus td,
.ops-table-focus .table-td,
.ops-table-focus .table-td-strong {
@apply px-4 py-3 text-sm;
}
.ops-table-focus tbody tr:hover {
background: rgba(248, 250, 252, 0.92);
}
.ops-table-focus .table-td-strong {
color: var(--color-text-primary);
@apply font-semibold;
}
.workbench-hero::after {
display: none;
}
@@ -312,9 +466,9 @@
.portal-list-card {
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(226, 232, 240, 0.94);
border-radius: var(--radius-lg);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-xs);
@apply block px-3.5 py-3.5 transition duration-150;
@apply block px-3.5 py-3 transition duration-150;
}
.portal-list-card:hover {
@@ -328,21 +482,21 @@
}
.portal-list-card--compact {
border-radius: var(--radius-lg);
@apply px-3.5 py-3.5;
border-radius: var(--radius-sm);
@apply px-3.5 py-3;
}
.portal-action-stack {
@apply grid gap-3 sm:grid-cols-2 xl:grid-cols-1;
@apply grid gap-2 sm:grid-cols-2 xl:grid-cols-1;
}
.portal-action-tile {
min-height: 100%;
background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(226, 232, 240, 0.94);
border-radius: var(--radius-lg);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-xs);
@apply px-3.5 py-3.5 text-left transition duration-150;
@apply px-3 py-3 text-left transition duration-150;
}
.portal-action-tile:hover {
@@ -358,13 +512,13 @@
}
.portal-action-tile--compact {
@apply px-3.5 py-3.5;
@apply px-3 py-3;
}
.portal-action-icon {
background: rgba(0, 57, 138, 0.08);
color: var(--color-accent-primary);
@apply flex size-10 items-center justify-center rounded-xl;
@apply flex size-9 items-center justify-center rounded-lg;
}
.portal-action-icon--muted {
@@ -375,19 +529,19 @@
.portal-status-card {
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(226, 232, 240, 0.94);
border-radius: var(--radius-panel);
@apply px-4 py-4;
border-radius: var(--radius-sm);
@apply px-3.5 py-3;
}
.portal-team-list {
border: 1px solid rgba(226, 232, 240, 0.94);
background: rgba(255, 255, 255, 0.64);
@apply overflow-hidden rounded-2xl divide-y divide-slate-200/80;
@apply overflow-hidden rounded-lg divide-y divide-slate-200/80;
}
.portal-team-row {
background: rgba(255, 255, 255, 0.84);
@apply flex min-h-[64px] items-center gap-3 px-4 py-3;
@apply flex min-h-[56px] items-center gap-3 px-3.5 py-2.5;
}
.portal-panel-footer {
@@ -396,15 +550,15 @@
}
.portal-insight-tabs__list {
@apply h-10 rounded-full border-slate-200 bg-slate-50 p-1;
@apply h-9 rounded-lg border-slate-200 bg-slate-50 p-1;
}
.portal-insight-tabs__content {
@apply mt-4;
@apply mt-3;
}
.portal-secondary-drawer {
@apply p-4 md:p-5;
@apply p-3.5 md:p-4;
}
.portal-secondary-summary {
@@ -417,15 +571,15 @@
}
.portal-secondary-drawer__body {
@apply mt-4 space-y-5;
@apply mt-4 space-y-4;
}
.portal-secondary-compact-grid {
@apply grid gap-4 xl:grid-cols-2;
@apply grid gap-3 xl:grid-cols-2;
}
.portal-compact-group {
@apply rounded-xl border border-slate-200 bg-white/80 px-3.5 py-3.5;
@apply rounded-lg border border-slate-200 bg-white/80 px-3.5 py-3;
}
.portal-compact-group__title {
+6 -10
查看文件
@@ -6,21 +6,17 @@ export function AuthLayout() {
const { updateReady, applyUpdate, dismissUpdate } = usePwaUpdatePrompt();
return (
<div className="relative min-h-screen overflow-hidden bg-[#f4f7fa] px-4 py-8 sm:px-6 lg:px-10">
<div className="auth-shell relative min-h-screen overflow-hidden px-4 py-8 sm:px-6 lg:px-10">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-70"
style={{
backgroundImage: "linear-gradient(rgba(15, 23, 42, 0.045) 1px, transparent 1px), linear-gradient(90deg, rgba(15, 23, 42, 0.045) 1px, transparent 1px)",
backgroundSize: "44px 44px",
}}
className="auth-shell__grid pointer-events-none absolute inset-0"
/>
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#00398a,#0f766e)]" />
<div className="relative mx-auto flex min-h-[calc(100vh-4rem)] max-w-[448px] flex-col justify-center">
<div aria-hidden="true" className="auth-shell__rail pointer-events-none absolute inset-y-8 left-[max(1rem,calc((100vw-520px)/2-88px))] hidden w-px lg:block" />
<div className="relative mx-auto flex min-h-[calc(100vh-4rem)] max-w-[460px] flex-col justify-center">
{updateReady ? (
<div className="mb-4 rounded-xl border border-primary/20 bg-primary/5 px-4 py-3 text-sm text-slate-700" data-testid="auth-pwa-update-notice">
<div className="mb-4 rounded-lg border border-primary/20 bg-white/95 px-4 py-3 text-sm text-slate-700 shadow-sm" data-testid="auth-pwa-update-notice">
<div className="font-medium text-slate-900">系统有新版本</div>
<div className="mt-1 leading-6">完成当前输入后刷新页面即可更新,当前表单不会被自动打断。</div>
<div className="mt-1 leading-6">刷新由你确认,当前输入不会被自动清空。</div>
<div className="mt-3 flex gap-2">
<Button size="sm" type="button" onClick={applyUpdate}>
现在刷新
+4 -4
查看文件
@@ -61,7 +61,7 @@ export function ForgotPasswordPage() {
try {
await forgotPassword({ email: normalizedEmail });
setCodeRequested(true);
setMessage("如账号可用,验证码将发送到企业邮箱;请在有效期内完成重置,并查看免提醒邮件。");
setMessage("如账号可用,验证码将发送到企业邮箱;10 分钟内有效,请同时查看免提醒邮件。");
} catch (err) {
if (isApiError(err)) {
setError(mapApiErrorMessage(err.code, err.details, err.message));
@@ -140,7 +140,7 @@ export function ForgotPasswordPage() {
{!emailDeliveryReady ? (
<div
aria-live="polite"
className="flex gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-800"
className="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 px-3.5 py-2.5 text-sm leading-6 text-amber-800"
data-testid="forgot-password-email-delivery-status"
>
<MailCheck aria-hidden="true" className="mt-0.5 h-4 w-4 flex-none" />
@@ -214,13 +214,13 @@ export function ForgotPasswordPage() {
) : null}
{message ? (
<div className="mt-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700" data-testid="forgot-password-message">
<div className="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3.5 py-2.5 text-sm text-emerald-700" data-testid="forgot-password-message">
{message}
</div>
) : null}
{error ? (
<div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600" data-testid="forgot-password-error">
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-3.5 py-2.5 text-sm text-red-600" data-testid="forgot-password-error">
{error}
</div>
) : null}
+13 -13
查看文件
@@ -166,7 +166,7 @@ export function LoginPage() {
await requestEmailLoginCode({ email });
setEmailCodeRequested(true);
setEmailCodeCooldown(60);
setEmailCodeMessage("如账号可用,验证码将发送到企业邮箱;请同时查看免提醒邮件。");
setEmailCodeMessage("如账号可用,验证码将发送到企业邮箱;10 分钟内有效,请同时查看免提醒邮件。");
} catch (err) {
if (isApiError(err)) {
setEmailCodeError(mapApiErrorMessage(err.code, err.details, err.message));
@@ -260,7 +260,7 @@ export function LoginPage() {
</div>
</div>
{error ? <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">{error}</div> : null}
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3.5 py-2.5 text-sm text-red-600">{error}</div> : null}
<Button data-testid="login-submit" className="w-full" disabled={submitting} type="submit">
{submitting ? "登录中..." : "登录"}
@@ -309,7 +309,7 @@ export function LoginPage() {
{!emailDeliveryReady ? (
<div
aria-live="polite"
className="flex gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-800"
className="flex gap-3 rounded-lg border border-amber-200 bg-amber-50 px-3.5 py-2.5 text-sm leading-6 text-amber-800"
data-testid="login-email-delivery-status"
>
<MailCheck aria-hidden="true" className="mt-0.5 h-4 w-4 flex-none" />
@@ -341,13 +341,13 @@ export function LoginPage() {
</div>
{emailCodeMessage ? (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700" data-testid="login-email-code-message">
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3.5 py-2.5 text-sm text-emerald-700" data-testid="login-email-code-message">
{emailCodeMessage}
</div>
) : null}
{emailCodeError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600" data-testid="login-email-code-error">
<div className="rounded-lg border border-red-200 bg-red-50 px-3.5 py-2.5 text-sm text-red-600" data-testid="login-email-code-error">
{emailCodeError}
</div>
) : null}
@@ -366,10 +366,10 @@ export function LoginPage() {
}
return (
<AuthPanel title="登录工作台" subtitle={showWecomPrimary ? "企业微信进入优先" : loginMode === "email_code" ? "邮箱验证码登录" : "账号密码登录"}>
<div className="space-y-5">
<AuthPanel title="登录工作台" subtitle={showWecomPrimary ? "企业微信优先入口" : "账号、邮箱或企业微信"}>
<div className="space-y-4">
{wecomNotice ? (
<div className={`rounded-xl border px-4 py-3 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
<div className={`rounded-lg border px-3.5 py-2.5 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
<div className="font-medium">{wecomNotice.title}</div>
<div className="mt-1 leading-6">{wecomNotice.description}</div>
</div>
@@ -381,7 +381,7 @@ export function LoginPage() {
{wecomConfigQuery.isLoading ? "准备中..." : "企业微信一键进入"}
</Button>
{wecomConfig && !wecomEntry ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-700">
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-700">
企业微信入口不可用,请用账号密码。
</div>
) : null}
@@ -398,10 +398,10 @@ export function LoginPage() {
) : null}
{!showWecomPrimary ? (
<div className="grid grid-cols-2 gap-1 rounded-xl border border-slate-200 bg-slate-100/80 p-1" data-testid="login-mode-switch">
<div className="auth-mode-switch" data-testid="login-mode-switch">
<button
aria-pressed={loginMode === "password"}
className={`h-10 rounded-lg text-sm font-medium transition ${loginMode === "password" ? "bg-white text-primary shadow-sm" : "text-slate-500 hover:text-slate-900"}`}
className={`auth-mode-switch__item ${loginMode === "password" ? "auth-mode-switch__item--active" : "auth-mode-switch__item--idle"}`}
data-testid="login-mode-password"
type="button"
onClick={() => selectLoginMode("password")}
@@ -410,7 +410,7 @@ export function LoginPage() {
</button>
<button
aria-pressed={loginMode === "email_code"}
className={`h-10 rounded-lg text-sm font-medium transition ${loginMode === "email_code" ? "bg-white text-primary shadow-sm" : "text-slate-500 hover:text-slate-900"}`}
className={`auth-mode-switch__item ${loginMode === "email_code" ? "auth-mode-switch__item--active" : "auth-mode-switch__item--idle"}`}
data-testid="login-mode-email-code"
type="button"
onClick={() => selectLoginMode("email_code")}
@@ -436,7 +436,7 @@ export function LoginPage() {
{showBrowserWecomNotice ? (
<div
className="flex gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm leading-6 text-slate-600"
className="auth-side-entry flex gap-3 text-slate-600"
data-testid="wecom-entry-browser-notice"
>
<Building2 aria-hidden="true" className="mt-0.5 h-4 w-4 flex-none text-primary" />
+16 -17
查看文件
@@ -388,7 +388,7 @@ function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] })
const safeItems = Array.isArray(items) ? items : [];
return (
<SectionPanel title="常用入口">
<SectionPanel title="入口">
<div className="grid gap-3 sm:grid-cols-2">
{safeItems.length ? safeItems.map((item) => (
<Link key={item.key} to={item.href} className="portal-list-card text-sm font-medium text-slate-700">
@@ -400,13 +400,13 @@ function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] })
);
}
function SecondaryCardsPanel({ title, description, cards }: { title: string; description: string; cards: PortalHomePayload["cards"] }) {
function SecondaryCardsPanel({ title, cards }: { title: string; cards: PortalHomePayload["cards"] }) {
if (!cards.length) {
return <EmptyStateCard title={title} description="暂无统计项。" />;
}
return (
<SectionPanel title={title} description={description}>
<SectionPanel title={title}>
<div className="grid gap-3 sm:grid-cols-2">
{cards.map((card) => (
<MetricCard
@@ -589,7 +589,7 @@ function GettingStartedCard({
return (
<SectionPanel
title="快速上手"
description="完成基础资料。"
description="基础资料"
actions={(
<button
type="button"
@@ -740,7 +740,7 @@ function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
{item.severity === "high" ? <span className="badge-error">高优先</span> : null}
{item.severity === "warning" ? <span className="badge-warning">关注</span> : null}
</div>
<div className="mt-2 text-sm text-primary">进入处理</div>
<div className="mt-1 text-sm text-primary">进入</div>
</div>
</div>
</Link>
@@ -748,7 +748,7 @@ function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
</div>
{hiddenCount > 0 ? (
<div className="portal-panel-footer">
<span>还有 {hiddenCount} 项已收起。</span>
<span>另有 {hiddenCount} 项。</span>
<Link to="/todos" className="font-medium text-primary hover:underline">查看全部待办</Link>
</div>
) : null}
@@ -784,7 +784,7 @@ function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) {
</div>
{hiddenCount > 0 ? (
<div className="portal-panel-footer">
<span>已收起 {hiddenCount} 位成员。</span>
<span>另有 {hiddenCount} 位。</span>
<Link to="/team" className="font-medium text-primary hover:underline">查看全部</Link>
</div>
) : null}
@@ -1053,7 +1053,7 @@ export function PortalHomePage() {
key: "approval",
icon: "fact_check",
label: "处理审批",
description: "进入审批中心查看待办",
description: "待办处理",
to: "/workflow/tasks",
});
}
@@ -1062,7 +1062,7 @@ export function PortalHomePage() {
key: "finance",
icon: "payments",
label: "财务处理",
description: "查看付款和风险待处理",
description: "付款 / 风险",
to: payload.financeSummary.href,
});
}
@@ -1071,7 +1071,7 @@ export function PortalHomePage() {
key: "system",
icon: "admin_panel_settings",
label: "系统管理",
description: "账号、配置和审计入口",
description: "账号 / 配置",
to: "/people",
});
}
@@ -1080,7 +1080,7 @@ export function PortalHomePage() {
key: "leave",
icon: "event_available",
label: "提交请假",
description: "直接进入请假申请页",
description: "请假申请",
to: "/leave-requests?action=create",
});
}
@@ -1089,7 +1089,7 @@ export function PortalHomePage() {
key: "expense",
icon: "request_quote",
label: "提交报销",
description: "进入报销工作台创建单据",
description: "报销单据",
to: "/reimbursements",
});
}
@@ -1097,7 +1097,7 @@ export function PortalHomePage() {
key: "attendance",
icon: "schedule",
label: "考勤汇总",
description: "查看企微打卡摘要和 OA 修正",
description: "企微摘要 / OA 修正",
to: "/attendance",
});
if (payload.weeklyReport || payload.weeklySummary) {
@@ -1105,7 +1105,7 @@ export function PortalHomePage() {
key: "weekly",
icon: "article",
label: "提交周报",
description: "进入周报页继续填写或查看团队缺口",
description: "填写 / 查看缺口",
to: "/weekly-reports",
});
}
@@ -1136,7 +1136,7 @@ export function PortalHomePage() {
}
/>
<div className="portal-metric-strip">
<div className="portal-metric-strip ops-status-strip">
{primaryCards.map((card) => (
<MetricCard
key={card.key}
@@ -1161,7 +1161,7 @@ export function PortalHomePage() {
fallback={(
<PortalSectionFallback
title="快速上手"
description="完成基础资料。"
description="基础资料"
/>
)}
>
@@ -1234,7 +1234,6 @@ export function PortalHomePage() {
<SecondaryCardsPanel
title="项目与组织"
description="项目、组织、风险"
cards={secondaryCards}
/>
</SecondaryWorkspacePanel>
+25 -16
查看文件
@@ -363,7 +363,7 @@ export function ReimbursementsPage() {
<ListWorkspace>
<WorkspaceHeader
title="我的报销"
subtitle="草稿、审批和付款状态。"
subtitle="单据、金额、状态。"
actions={
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
{canCreateExpense ? (
@@ -461,21 +461,21 @@ export function ReimbursementsPage() {
</p>
) : null}
<StatStrip columns={4}>
<StatStrip columns={4} className="ops-status-strip">
<MetricCard title="当前列表" value={stats.currentPageCount} note={`共 ${listQuery.data?.total ?? 0} 条`} />
<MetricCard title="当前金额" value={formatCurrency(stats.currentPageAmount)} note="当前页" />
<MetricCard title="审批中" value={stats.reviewing} note="待推进" tone={stats.reviewing > 0 ? "warning" : "neutral"} />
<MetricCard title="已支付" value={stats.paid} note={scope === "all" ? "全范围" : "仅自己"} />
</StatStrip>
<FilterBar className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5">
<div className="ops-command-bar__row">
<Tabs
data-testid="reimbursements-status-tabs"
value={status}
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
>
<TabsList className="flex w-full flex-wrap justify-start gap-1 md:w-auto">
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
{[
{ value: "draft", label: "草稿" },
{ value: "reviewing", label: "审批中" },
@@ -495,10 +495,10 @@ export function ReimbursementsPage() {
</TabsList>
</Tabs>
<div className="flex flex-wrap items-center gap-2">
<div className="ops-command-bar__actions">
{showProxyOwnerSelector ? (
<select
className="select-sm h-10"
className="select-sm"
data-testid="reimbursements-owner-select"
onChange={(event) => setProxyOwnerEmployeeId(event.target.value)}
value={proxyOwnerEmployeeId}
@@ -518,6 +518,7 @@ export function ReimbursementsPage() {
<>
<Button
data-testid="reimbursements-scope-own"
size="sm"
variant={scope === "own" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
type="button"
@@ -526,13 +527,14 @@ export function ReimbursementsPage() {
</Button>
<Button
data-testid="reimbursements-scope-all"
size="sm"
variant={scope === "all" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
type="button"
>
全范围
</Button>
<Button asChild variant="secondary" type="button">
<Button asChild size="sm" variant="secondary" type="button">
<Link to="/reimbursements/dashboard">统计分析</Link>
</Button>
</>
@@ -542,6 +544,7 @@ export function ReimbursementsPage() {
<DropdownMenuTrigger asChild>
<Button
data-testid="reimbursements-export"
size="sm"
variant="secondary"
type="button"
disabled={exportMutation.isPending}
@@ -567,6 +570,7 @@ export function ReimbursementsPage() {
{canShowBatchEntry ? (
<Button
size="sm"
variant={batchMode ? "default" : "secondary"}
onClick={() => {
setBatchMode((current) => !current);
@@ -584,7 +588,7 @@ export function ReimbursementsPage() {
</div>
{batchMode ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<div className="ops-inline-alert border-amber-200 bg-amber-50 text-amber-800">
仅勾选你可处理的审批中报销;驳回需填写理由。
</div>
) : null}
@@ -710,7 +714,7 @@ export function ReimbursementsPage() {
)}
</div>
<PrimaryTable className="hidden md:block">
<PrimaryTable className="hidden md:block ops-table-focus">
<table className="w-full border-collapse text-left" data-testid="reimbursements-table">
<thead className="table-header">
<tr>
@@ -782,13 +786,18 @@ export function ReimbursementsPage() {
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
<td className="table-td">
<div className="flex flex-wrap gap-2">
<Link
to={`/reimbursements/${item.id}`}
className={["draft", "rejected"].includes(item.status) ? "btn-primary" : "btn-secondary"}
data-testid="reimbursement-detail-link"
<Button
asChild
size="sm"
variant={["draft", "rejected"].includes(item.status) ? "default" : "secondary"}
>
{["draft", "rejected"].includes(item.status) ? "编辑" : "详情"}
</Link>
<Link
to={`/reimbursements/${item.id}`}
data-testid="reimbursement-detail-link"
>
{["draft", "rejected"].includes(item.status) ? "编辑" : "详情"}
</Link>
</Button>
{canDeleteDraft ? (
<Button
disabled={deleteMutation.isPending}
+10 -11
查看文件
@@ -15,7 +15,6 @@ import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import {
ActionToolbar,
FilterBar,
ListWorkspace,
PrimaryTable,
@@ -210,11 +209,11 @@ export function WorkflowTasksPage() {
<WorkspaceHeader
eyebrow="审批"
title="审批中心"
subtitle="待处理、已处理和业务类型。"
subtitle="任务、节点、业务状态。"
/>
<div data-testid="workflow-task-summary">
<StatStrip columns={4}>
<StatStrip columns={4} className="ops-status-strip">
<MetricCard title="待办总数" value={summaryQuery.data?.pendingTotal ?? "-"} />
<MetricCard title="超时待办" value={summaryQuery.data?.overdueTotal ?? "-"} tone="warning" />
<MetricCard title="最早待处理" value={formatSummaryTime(summaryQuery.data?.earliestPendingAt)} />
@@ -232,14 +231,14 @@ export function WorkflowTasksPage() {
</StatStrip>
</div>
<FilterBar>
<ActionToolbar className="items-start">
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
<div className="ops-command-bar__row">
<Tabs
data-testid="workflow-tasks-status-tabs"
value={status}
onValueChange={(value) => updateSearchParams({ status: value as WorkflowStatusFilter, page: 1 })}
>
<TabsList className="flex w-full flex-wrap justify-start gap-1 xl:w-auto">
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
{WORKFLOW_STATUS_OPTIONS.map((item) => (
<TabsTrigger
data-testid={`workflow-tasks-tab-${item.value}`}
@@ -252,13 +251,13 @@ export function WorkflowTasksPage() {
</TabsList>
</Tabs>
<div className="flex w-full items-center gap-2 sm:w-auto">
<label className="text-sm font-medium text-zinc-700" htmlFor="workflow-entity-type-filter">
<div className="ops-command-bar__actions">
<label className="ops-filter-label" htmlFor="workflow-entity-type-filter">
业务类型
</label>
<select
id="workflow-entity-type-filter"
className="select-std min-w-36 flex-1 sm:flex-none"
className="select-sm min-w-32"
data-testid="workflow-tasks-entity-filter"
value={entityType}
onChange={(event) => updateSearchParams({ entityType: event.target.value as WorkflowEntityFilter, page: 1 })}
@@ -270,7 +269,7 @@ export function WorkflowTasksPage() {
))}
</select>
</div>
</ActionToolbar>
</div>
</FilterBar>
{query.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
@@ -352,7 +351,7 @@ export function WorkflowTasksPage() {
)}
</div>
<PrimaryTable className="hidden md:block">
<PrimaryTable className="hidden md:block ops-table-focus">
<Table data-testid="workflow-tasks-table">
<TableHeader>
<tr>
@@ -0,0 +1,331 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import type { Page, Route } from "@playwright/test";
import { test, expect } from "./helpers/fixtures";
test.use({ serviceWorkers: "block" });
const visualUser = {
identifier: "[email protected]",
password: "VisualPassword1234",
name: "Visual Manager",
};
const visualMe = {
id: "employee-visual-wave2",
employeeNo: "E-WAVE2",
name: visualUser.name,
email: visualUser.identifier,
phone: null,
roleCodes: ["manager"],
permissions: [
"expense.create",
"expense.view_own",
"expense.view_all",
"expense.delete_own",
"expense.approve",
"leave.approve",
"report.dashboard.view",
],
profileCompleteness: {
hasEmergencyContact: true,
hasBankAccount: true,
hasAvatar: false,
hasIdNumber: false,
score: 80,
},
};
function envelope<T>(data: T) {
return { requestId: "mock-pc-wave2", data };
}
async function fulfillJson<T>(route: Route, data: T) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(envelope(data)),
});
}
function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") {
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
}
function reimbursementItem(id: string, status: string, formNumber: string, amount: string) {
return {
id,
formNumber,
status,
reimbursementDate: "2026-05-27",
totalAmount: amount,
description: "Wave2 PC 视觉验证",
payment: {
paymentVoucherNo: null,
paymentDate: null,
paymentChannel: null,
paidAt: null,
paidBy: null,
},
owner: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
submitter: { id: visualMe.id, employeeNo: visualMe.employeeNo, name: visualMe.name },
project: { id: "project-wave2", projectNo: "HYB-WAVE2", name: "OA 试运行" },
pendingWorkflow: status === "reviewing"
? {
task: { id: `task-${id}`, stepName: "主管审批" },
allowedActions: ["approve", "reject"],
}
: null,
createdAt: "2026-05-27T09:00:00.000Z",
updatedAt: "2026-05-27T09:30:00.000Z",
};
}
const pendingWorkflowTask = {
id: "workflow-task-wave2",
instanceId: "workflow-instance-wave2",
stepId: "manager-review",
stepName: "主管审批",
assigneeId: visualMe.id,
assigneeRole: "manager",
status: "pending",
outcome: null,
createdAt: "2026-05-27T09:05:00.000Z",
completedAt: null,
workflowInstance: {
entityType: "reimbursement",
entityId: "reimbursement-wave2-reviewing",
currentStatus: "reviewing",
},
businessSummary: {
reference: "BX20260527001",
title: "研发采购报销",
applicantName: "王经理",
amount: "¥1,260.00",
duration: null,
businessStatus: "reviewing",
},
allowedActions: ["approve", "reject", "return", "transfer"],
};
async function mockWave2Apis(page: Page) {
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
const pathName = url.pathname;
if (pathName === "/api/auth/capabilities") {
await fulfillJson(route, {
emailDelivery: { ready: true, statusReasons: [] },
emailLoginCode: { enabled: true },
passwordReset: { enabled: true },
});
return;
}
if (pathName === "/api/auth/sso/providers") {
await fulfillJson(route, []);
return;
}
if (pathName === "/api/auth/wecom/config") {
await fulfillJson(route, {
enabled: true,
entryEnabled: true,
oauthEnabled: true,
corpId: "wx-wave2",
agentId: "1000002",
loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal",
bindEnabled: true,
defaultReturnTo: "/portal",
status: "ready",
statusReasons: [],
});
return;
}
if (pathName === "/api/auth/login" || pathName === "/api/auth/refresh") {
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
body: JSON.stringify(envelope({ accessToken: "mock-pc-wave2-token", expiresIn: 900, mustChangePassword: false })),
});
return;
}
if (pathName === "/api/me") {
await fulfillJson(route, visualMe);
return;
}
if (pathName === "/api/system/navigation") {
await fulfillJson(route, {
workspace: {
id: "workspace-wave2",
persisted: true,
profile: "small_team_internal",
orgMode: "single_company",
primaryEntryMode: "password",
notificationStrategy: "in_app",
devicePriority: "desktop",
moduleVisibility: {},
createdAt: null,
updatedAt: null,
updatedBy: null,
},
sections: {
main: [
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
navigationItem("workflow", "/workflow/tasks", "审批待办", "fact_check"),
navigationItem("expense", "/reimbursements", "报销", "request_quote"),
],
admin: [],
backup: [],
},
recommendedRolePresets: [],
});
return;
}
if (pathName === "/api/notifications") {
await fulfillJson(route, {
page: 1,
pageSize: Number(url.searchParams.get("pageSize") ?? 5),
total: 0,
unreadOnly: url.searchParams.get("unreadOnly") === "1",
unreadCount: 0,
items: [],
});
return;
}
if (pathName === "/api/portal/home") {
await fulfillJson(route, {
workspace: { id: "workspace-wave2", persisted: true, profile: "small_team_internal" },
greeting: { name: visualMe.name },
cards: [
{ key: "pending_approvals", title: "审批待办", value: 3, href: "/workflow/tasks", severity: "warning" },
{ key: "team_presence", title: "团队状态", value: "7/9 在岗", href: "/team", severity: "normal" },
{ key: "weekly_missing", title: "周报缺口", value: 1, href: "/weekly-reports", severity: "warning" },
{ key: "weekly_help", title: "需协助", value: 1, href: "/weekly-reports", severity: "normal" },
{ key: "my_projects", title: "项目跟进", value: 4, href: "/projects", severity: "normal" },
],
pendingTasks: { count: 3, href: "/workflow/tasks" },
myApplications: [
{ domain: "reimbursement", count: 2, href: "/reimbursements" },
{ domain: "leave", count: 1, href: "/leave-requests" },
],
riskSummary: null,
announcements: [{ id: "ann-wave2", title: "试运行提醒", summary: "今日优先处理审批和报销。", href: null }],
quickLinks: [{ key: "expense", title: "报销列表", href: "/reimbursements" }],
todayPresence: { date: "2026-05-27", status: "office", label: "在岗", note: "已同步企微", href: "/team" },
teamPresence: { date: "2026-05-27", office: 7, remote: 1, out: 0, travel: 0, leave: 1, unspecified: 0, total: 9, href: "/team" },
weeklyReport: { weekStart: "2026-05-25", status: "draft", needsHelp: false, href: "/weekly-reports" },
weeklySummary: { weekStart: "2026-05-25", submittedCount: 8, missingCount: 1, needsHelpCount: 1, href: "/weekly-reports" },
todoSummary: { openCount: 2, highPriorityCount: 1, href: "/todos" },
projectSummary: null,
peopleLifecycle: null,
financeSummary: null,
adoptionSummary: null,
roleCapabilities: {
canSubmitApplications: true,
canApproveTasks: true,
canUseFinance: false,
canManageSystem: false,
canViewTeam: true,
isAdminOnly: false,
},
recentActivities: [
{ id: "activity-wave2-1", type: "reimbursement.submit", actorName: "王经理", summary: "王经理提交了研发采购报销", createdAt: "2026-05-27T08:50:00.000Z", href: "/reimbursements/reimbursement-wave2-reviewing" },
],
teamStatusBoard: [
{ employeeId: "employee-wave2-1", name: "王经理", avatarUrl: null, presenceStatus: "office", attendanceClockIn: "2026-05-27T09:00:00.000Z", attendanceClockOut: null, onLeave: false, leaveType: null },
{ employeeId: "employee-wave2-2", name: "李工", avatarUrl: null, presenceStatus: "remote", attendanceClockIn: null, attendanceClockOut: null, onLeave: false, leaveType: null },
],
});
return;
}
if (pathName === "/api/workflow/tasks/summary") {
await fulfillJson(route, {
scope: "mine",
pendingTotal: 3,
overdueTotal: 1,
earliestPendingAt: "2026-05-27T09:05:00.000Z",
averagePendingAgeHours: 2,
byEntityType: [{ key: "reimbursement", pendingTotal: 3 }],
byStep: [],
byAssigneeRole: [],
});
return;
}
if (pathName === "/api/workflow/tasks") {
const status = url.searchParams.get("status") ?? "pending";
await fulfillJson(route, {
page: 1,
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
total: status === "pending" ? 1 : 0,
items: status === "pending" ? [pendingWorkflowTask] : [],
});
return;
}
if (pathName === "/api/reimbursements" && request.method() === "GET") {
const status = url.searchParams.get("status") ?? "draft";
const items = [
reimbursementItem("reimbursement-wave2-draft", "draft", "BX20260527000", "320.00"),
reimbursementItem("reimbursement-wave2-reviewing", "reviewing", "BX20260527001", "1260.00"),
].filter((item) => item.status === status);
await fulfillJson(route, { page: 1, pageSize: 20, total: items.length, scope: url.searchParams.get("scope") ?? "own", items });
return;
}
if (pathName === "/api/reimbursement-templates") {
await fulfillJson(route, { page: 1, pageSize: 20, total: 0, items: [] });
return;
}
if (pathName === "/api/reimbursements/linkable-travel-requests") {
await fulfillJson(route, { ownerEmployeeId: visualMe.id, items: [] });
return;
}
await fulfillJson(route, {});
});
}
async function capture(page: Page, name: string) {
const outputDir = path.resolve(process.cwd(), "../output/playwright/pc-core-workflow-visual-wave2");
mkdirSync(outputDir, { recursive: true });
await page.screenshot({ path: path.join(outputDir, `${name}.png`), fullPage: true });
}
test("PC 核心工作流 Wave2 视觉截图", async ({ page }) => {
await mockWave2Apis(page);
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/login");
await expect(page.getByTestId("login-mode-switch")).toBeVisible();
await expect(page.getByTestId("wecom-entry-browser-notice")).toBeVisible();
await capture(page, "01-login");
await page.getByTestId("login-identifier").fill(visualUser.identifier);
await page.getByTestId("login-password").fill(visualUser.password);
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(/\/portal/);
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible();
await capture(page, "02-portal");
await page.goto("/reimbursements?status=reviewing&scope=all");
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
await expect(page.locator(".ops-command-bar").first()).toBeVisible();
await capture(page, "03-reimbursements");
await page.goto("/workflow/tasks");
await expect(page.getByTestId("workflow-tasks-table")).toBeVisible();
await expect(page.locator(".ops-command-bar").first()).toBeVisible();
await capture(page, "04-workflow-tasks");
});