优化PC核心工作流视觉 Wave2(05-27-pc-core-workflow-visual-wave2)
这个提交包含在:
@@ -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,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}>
|
||||
现在刷新
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
在新工单中引用
屏蔽一个用户