fix(auth): 登录主题 - 跟随应用外观并补齐回归

This commit is contained in:
ddCat
2026-09-24 00:05:13 +08:00
parent e8c2c40715
commit d89b55d182
9 changed files with 276 additions and 100 deletions

View File

@@ -143,7 +143,7 @@ func defaultAppearanceSkinThemes() []AppearanceSkinTheme {
primary: "#087f76", primaryHover: "#076d66", primaryActive: "#095c57", primaryForeground: "#ffffff", selected: "#dff4f0", selectedHover: "#ccebe6", selectedActive: "#b9e3dd", selectedForeground: "#075f59", info: "#dd7a38",
switchChecked: "#087f76", switchCheckedHover: "#076d66", switchCheckedHandle: "#ffffff", switchUnchecked: "#a8b7b9", switchUncheckedHover: "#899b9e", switchUncheckedHandle: "#ffffff",
success: "#16866f", warning: "#c46722", danger: "#c83f3a", dangerHover: "#ad3532", dangerActive: "#922e2c", dangerForeground: "#ffffff",
workspace: "#ffffff", grid: "#e8f2f0", adminBackground: "#eef3f4", adminSurface: "#ffffff", adminSubtle: "#f5f9f9", adminStrong: "#dce9e8", authBackground: "#071a1d", authPanel: "#0b2226", authCard: "#0d272b", authAccent: "#72eadc", authMuted: "#8db9b4",
workspace: "#ffffff", grid: "#e8f2f0", adminBackground: "#eef3f4", adminSurface: "#ffffff", adminSubtle: "#f5f9f9", adminStrong: "#dce9e8", authBackground: "#f6f8f9", authPanel: "#edf4f3", authCard: "#ffffff", authAccent: "#087f76", authMuted: "#607079",
})
studio.Tokens.Dark = tintAppearanceSkinMode(studio.Tokens.Dark, appearanceSkinPalette{
canvas: "#0b1215", surface: "#121d21", subtle: "#142428", raised: "#203a3b", overlay: "#172328", text: "#e7f4f2", muted: "#8ca6a7", border: "#294044",
@@ -160,7 +160,7 @@ func defaultAppearanceSkinThemes() []AppearanceSkinTheme {
primary: "#b94f2f", primaryHover: "#9e4128", primaryActive: "#843621", primaryForeground: "#fffaf6", selected: "#f8e0cf", selectedHover: "#f1d1bb", selectedActive: "#e9c1a6", selectedForeground: "#8c3d25", info: "#c58a3b",
switchChecked: "#a84a2f", switchCheckedHover: "#bd5b3c", switchCheckedHandle: "#fffaf6", switchUnchecked: "#c7b3a7", switchUncheckedHover: "#aa9182", switchUncheckedHandle: "#fffdf9",
success: "#4d7f50", warning: "#bd6819", danger: "#bd3d32", dangerHover: "#a33229", dangerActive: "#892a23", dangerForeground: "#fffaf6",
workspace: "#fffdf9", grid: "#f3e9e1", adminBackground: "#f6eee7", adminSurface: "#fffdf9", adminSubtle: "#faf3ed", adminStrong: "#ead8ca", authBackground: "#1f1410", authPanel: "#291813", authCard: "#361f17", authAccent: "#ffc08b", authMuted: "#b99d89",
workspace: "#fffdf9", grid: "#f3e9e1", adminBackground: "#f6eee7", adminSurface: "#fffdf9", adminSubtle: "#faf3ed", adminStrong: "#ead8ca", authBackground: "#fbf7f2", authPanel: "#f6ebe3", authCard: "#fffdf9", authAccent: "#b94f2f", authMuted: "#806b61",
})
warm.Tokens.Dark = tintAppearanceSkinMode(warm.Tokens.Dark, appearanceSkinPalette{
canvas: "#1b1210", surface: "#291a16", subtle: "#33211b", raised: "#493027", overlay: "#31201a", text: "#f8e9df", muted: "#b89c8c", border: "#50372e",
@@ -177,7 +177,7 @@ func defaultAppearanceSkinThemes() []AppearanceSkinTheme {
primary: "#6656d9", primaryHover: "#5847c7", primaryActive: "#4939b2", primaryForeground: "#ffffff", selected: "#ebe8ff", selectedHover: "#ded9ff", selectedActive: "#d0c9ff", selectedForeground: "#4f3fb5", info: "#8f61e8",
switchChecked: "#6656d9", switchCheckedHover: "#5847c7", switchCheckedHandle: "#ffffff", switchUnchecked: "#b4afc5", switchUncheckedHover: "#9891ae", switchUncheckedHandle: "#ffffff",
success: "#2f966e", warning: "#b96f16", danger: "#c73559", dangerHover: "#ac2c4b", dangerActive: "#912640", dangerForeground: "#ffffff",
workspace: "#ffffff", grid: "#f0eef8", adminBackground: "#f1f0f7", adminSurface: "#ffffff", adminSubtle: "#f7f6fb", adminStrong: "#e4e0f1", authBackground: "#110d20", authPanel: "#17112b", authCard: "#211936", authAccent: "#b4a8ff", authMuted: "#958dad",
workspace: "#ffffff", grid: "#f0eef8", adminBackground: "#f1f0f7", adminSurface: "#ffffff", adminSubtle: "#f7f6fb", adminStrong: "#e4e0f1", authBackground: "#f8f7fc", authPanel: "#f0eefb", authCard: "#ffffff", authAccent: "#6656d9", authMuted: "#716a86",
})
violet.Tokens.Dark = tintAppearanceSkinMode(violet.Tokens.Dark, appearanceSkinPalette{
canvas: "#0f0c19", surface: "#171321", subtle: "#201a2f", raised: "#302746", overlay: "#1c1734", text: "#f2efff", muted: "#a9a2bd", border: "#39304d",
@@ -203,7 +203,7 @@ func defaultClassicAppearanceSkin() AppearanceSkinTheme {
Control: "#ffffff", ControlHover: "#f5f5f5", ControlActive: "#ececec", ControlBorder: "#d1d1d1", ControlFocus: "#171717", ControlDisabledBackground: "#f2f2f2", ControlDisabledForeground: "#a3a3a3", SwitchChecked: "#16a34a", SwitchCheckedHover: "#15803d", SwitchCheckedHandle: "#ffffff", SwitchUnchecked: "#b8b8b8", SwitchUncheckedHover: "#9f9f9f", SwitchUncheckedHandle: "#ffffff",
Primary: "#171717", PrimaryHover: "#303030", PrimaryActive: "#404040", PrimaryForeground: "#ffffff", Selected: "#e8e8e8", SelectedHover: "#dedede", SelectedActive: "#d5d5d5", SelectedForeground: "#171717",
Icon: "#3f3f46", IconMuted: "#a1a1aa", IconActive: "#171717", Success: "#16a34a", Warning: "#d97706", Danger: "#dc2626", DangerHover: "#b91c1c", DangerActive: "#991b1b", DangerForeground: "#ffffff", Info: "#2563eb", Workspace: "#ffffff", WorkspaceGrid: "#f3f3f3",
AdminBackground: "#f3f4f6", AdminSurface: "#ffffff", AdminSubtle: "#f7f8fa", AdminStrong: "#eceff3", AuthBackground: "#08090c", AuthPanel: "#0b0c10", AuthCard: "#121318", AuthAccent: "#93c5fd", AuthMuted: "#8a8b91",
AdminBackground: "#f3f4f6", AdminSurface: "#ffffff", AdminSubtle: "#f7f8fa", AdminStrong: "#eceff3", AuthBackground: "#ffffff", AuthPanel: "#f7f7f7", AuthCard: "#ffffff", AuthAccent: "#2563eb", AuthMuted: "#737373",
},
Dark: AppearanceSkinModeTokens{
Canvas: "#0a0a0a", Surface: "#181818", SurfaceSubtle: "#202020", SurfaceRaised: "#2a2a2a", Overlay: "#1f1f20", Text: "#f5f5f5", TextMuted: "#a3a3a3", Border: "#2d2d2d",

View File

@@ -50,6 +50,27 @@ func TestAppearanceDefaultsPreserveBuiltInBrand(t *testing.T) {
}
}
func TestBuiltInAppearanceSkinAuthPalettesFollowMode(t *testing.T) {
for _, skin := range defaultAppearanceSkinThemes() {
if skin.Tokens.Light.AuthBackground == skin.Tokens.Dark.AuthBackground || skin.Tokens.Light.AuthCard == skin.Tokens.Dark.AuthCard {
t.Errorf("skin %s reuses dark auth surfaces in light mode", skin.ID)
}
for _, mode := range []struct {
name string
tokens AppearanceSkinModeTokens
}{
{name: "light", tokens: skin.Tokens.Light},
{name: "dark", tokens: skin.Tokens.Dark},
} {
for label, foreground := range map[string]string{"text": mode.tokens.Text, "muted": mode.tokens.AuthMuted, "accent": mode.tokens.AuthAccent} {
if ratio := appearanceColorContrastRatio(t, foreground, mode.tokens.AuthCard); ratio < 4.5 {
t.Errorf("skin %s %s auth %s contrast = %.2f, want at least 4.5", skin.ID, mode.name, label, ratio)
}
}
}
}
}
func TestBuiltInAppearanceSkinTooltipPairsMeetContrast(t *testing.T) {
for _, skin := range defaultAppearanceSkinThemes() {
modes := []struct {

View File

@@ -340,11 +340,11 @@ export const DEFAULT_CLASSIC_SKIN: SkinDefinition = {
adminSurface: "#ffffff",
adminSubtle: "#f6f6f6",
adminStrong: "#ececec",
authBackground: "#08090c",
authPanel: "#0b0c10",
authCard: "#121318",
authAccent: "#93c5fd",
authMuted: "#8a8b91",
authBackground: "#ffffff",
authPanel: "#f7f7f7",
authCard: "#ffffff",
authAccent: "#2563eb",
authMuted: "#737373",
},
dark: {
canvas: "#0a0a0a",

View File

@@ -1,5 +1,5 @@
import { motion, useReducedMotion } from "motion/react";
import { ConfigProvider, Tabs } from "antd";
import { Tabs } from "antd";
import { ArrowLeft, Play } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router";
@@ -7,8 +7,8 @@ import { Link, Outlet, useLocation, useNavigate } from "react-router";
import { BrandLogo } from "@/components/brand/brand-logo";
import { SiteComplianceFooter } from "@/components/layout/site-compliance-footer";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { getAntThemeConfig } from "@/lib/app-theme";
import { brandStudioLabel, useAppearanceStore } from "@/stores/use-appearance-store";
import { useThemeStore } from "@/stores/use-theme-store";
const AUTH_TABS = [
{ key: "login", label: "登录" },
@@ -48,6 +48,7 @@ export function LinuxDOIcon() {
export function AuthScene() {
const appearance = useAppearanceStore((state) => state.appearance);
const theme = useThemeStore((state) => state.theme);
const location = useLocation();
const navigate = useNavigate();
const reducedMotion = useReducedMotion();
@@ -74,18 +75,18 @@ export function AuthScene() {
};
return (
<main className="auth-scene h-dvh min-h-0 overflow-y-auto text-white lg:overflow-hidden">
<main className="auth-scene h-dvh min-h-0 overflow-y-auto lg:overflow-hidden">
<div className="grid min-h-full lg:h-full lg:grid-cols-[minmax(0,1.32fr)_minmax(520px,1fr)]">
<section className="relative min-h-[250px] overflow-hidden sm:min-h-[320px] lg:min-h-0" aria-label={`${appearance.brandName}品牌影片`}>
<section className="auth-scene-hero relative min-h-[250px] overflow-hidden sm:min-h-[320px] lg:min-h-0" aria-label={`${appearance.brandName}品牌影片`}>
{videoActive && appearance.authVideoUrl ? <video ref={videoRef} className="absolute inset-0 size-full object-cover" src={appearance.authVideoUrl} poster={appearance.authVideoPosterUrl || undefined} autoPlay muted loop playsInline preload="metadata" onPlay={() => setVideoPlaying(true)} onPause={() => setVideoPlaying(false)} /> : appearance.authVideoPosterUrl && failedPosterURL !== appearance.authVideoPosterUrl ? <img className="absolute inset-0 size-full object-cover" src={appearance.authVideoPosterUrl} alt="" decoding="async" onError={() => setFailedPosterURL(appearance.authVideoPosterUrl)} /> : null}
<div aria-hidden className="absolute inset-0 bg-[linear-gradient(180deg,rgba(4,5,8,.58),transparent_42%,rgba(4,5,8,.74))]" />
<div aria-hidden className="auth-scene-hero-overlay absolute inset-0" />
<div aria-hidden className="auth-scene-video-blend absolute inset-y-0 right-0 hidden w-[clamp(120px,14vw,240px)] lg:block" />
<div className="absolute inset-x-0 top-0 flex items-center justify-between gap-4 p-5 sm:p-7 lg:p-9">
<Link to="/" className="inline-flex items-center gap-2.5 text-sm font-semibold text-white drop-shadow-sm transition-opacity hover:opacity-80">
<BrandLogo theme="dark" className="size-7" alt="" fallback={<span className="size-7 bg-current" style={{ mask: "url(/logo.svg) center / contain no-repeat", WebkitMask: "url(/logo.svg) center / contain no-repeat" }} />} />
<Link to="/" className="auth-scene-brand inline-flex items-center gap-2.5 text-sm font-semibold drop-shadow-sm transition-opacity hover:opacity-80">
<BrandLogo theme={theme} className="size-7" alt="" fallback={<span className="size-7 bg-current" style={{ mask: "url(/logo.svg) center / contain no-repeat", WebkitMask: "url(/logo.svg) center / contain no-repeat" }} />} />
{appearance.brandName}
</Link>
<button type="button" className="inline-flex items-center gap-2 rounded-full border border-white/16 bg-black/20 px-3 py-1.5 text-[var(--fs-label)] text-white/76 backdrop-blur-xl transition hover:bg-black/35 disabled:cursor-default" onClick={playVideo} disabled={videoPlaying || !appearance.authVideoUrl} aria-pressed={videoPlaying}>
<button type="button" className="auth-scene-hero-control inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-[var(--fs-label)] backdrop-blur-xl transition disabled:cursor-default" onClick={playVideo} disabled={videoPlaying || !appearance.authVideoUrl} aria-pressed={videoPlaying}>
<Play className="size-3 fill-current" />
{videoPlaying ? "创作正在发生" : "播放品牌影片"}
</button>
@@ -96,9 +97,9 @@ export function AuthScene() {
transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}
className="absolute inset-x-0 bottom-0 max-w-2xl p-5 sm:p-7 lg:p-10"
>
<p className="text-xs font-semibold tracking-[0.18em] text-white/58">{brandStudioLabel(appearance)}</p>
<h1 className="mt-3 max-w-xl whitespace-pre-line text-3xl font-semibold leading-tight sm:text-4xl lg:text-5xl">{appearance.authHeroTitle}</h1>
{appearance.authHeroDescription ? <p className="mt-4 max-w-xl whitespace-pre-line text-sm leading-6 text-white/62 sm:text-base sm:leading-7">{appearance.authHeroDescription}</p> : null}
<p className="auth-scene-hero-muted text-xs font-semibold tracking-[0.18em]">{brandStudioLabel(appearance)}</p>
<h1 className="auth-scene-hero-title mt-3 max-w-xl whitespace-pre-line text-3xl font-semibold leading-tight sm:text-4xl lg:text-5xl">{appearance.authHeroTitle}</h1>
{appearance.authHeroDescription ? <p className="auth-scene-hero-muted mt-4 max-w-xl whitespace-pre-line text-sm leading-6 sm:text-base sm:leading-7">{appearance.authHeroDescription}</p> : null}
</motion.div>
</section>
@@ -115,25 +116,23 @@ export function AuthScene() {
transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}
className="my-auto w-full max-w-[460px]"
>
<ConfigProvider theme={getAntThemeConfig(true, appearance.activeSkin)}>
<div className="auth-card-dark auth-scene-card h-auto overflow-hidden rounded-lg backdrop-blur-2xl">
<section aria-label={copy.title} className={`flex flex-col ${recovery ? "min-h-[600px]" : activeTab === "login" ? "min-h-[500px]" : "min-h-[620px] sm:min-h-[640px]"}`}>
<header className="px-6 pb-5 pt-6 sm:px-8 sm:pt-7">
<p className="auth-scene-eyebrow text-xs font-semibold tracking-[0.18em]">{copy.eyebrow}</p>
<h2 className="mt-2 text-3xl font-semibold">{copy.title}</h2>
<p className="auth-scene-muted mt-2 text-sm leading-6">{copy.description}</p>
</header>
{!recovery ? (
<div className="px-6 sm:px-8">
<Tabs className="auth-card-tabs" activeKey={activeTab} items={AUTH_TABS} onChange={(key) => navigate({ pathname: key === "register" ? "/register" : "/login", search: location.search })} />
</div>
) : null}
<div key={location.pathname} className="flex-1 px-6 py-6 sm:px-8 sm:py-7">
<Outlet />
<div className="auth-scene-card h-auto overflow-hidden rounded-lg backdrop-blur-2xl">
<section aria-label={copy.title} className={`flex flex-col ${recovery ? "min-h-[600px]" : activeTab === "login" ? "" : "min-h-[620px] sm:min-h-[640px]"}`}>
<header className="px-6 pb-5 pt-6 sm:px-8 sm:pt-7">
<p className="auth-scene-eyebrow text-xs font-semibold tracking-[0.18em]">{copy.eyebrow}</p>
<h2 className="mt-2 text-3xl font-semibold">{copy.title}</h2>
<p className="auth-scene-muted mt-2 text-sm leading-6">{copy.description}</p>
</header>
{!recovery ? (
<div className="px-6 sm:px-8">
<Tabs className="auth-card-tabs" activeKey={activeTab} items={AUTH_TABS} onChange={(key) => navigate({ pathname: key === "register" ? "/register" : "/login", search: location.search })} />
</div>
</section>
</div>
</ConfigProvider>
) : null}
<div key={location.pathname} className={`${!recovery && activeTab === "login" ? "" : "flex-1"} px-6 py-6 sm:px-8 sm:py-7`}>
<Outlet />
</div>
</section>
</div>
</motion.div>
<SiteComplianceFooter variant="auth" className="absolute inset-x-0 bottom-0" />
</section>

View File

@@ -98,7 +98,7 @@ export default function ForgotPasswordPage() {
<Input
id="recovery-email"
size="large"
prefix={<Mail className="size-4 text-white/35" />}
prefix={<Mail className="auth-scene-icon size-4" />}
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="请输入绑定邮箱"
@@ -120,7 +120,7 @@ export default function ForgotPasswordPage() {
<form onSubmit={submitReset} className="space-y-4">
<AuthField label="账号邮箱" htmlFor="recovery-email-confirm">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<Input id="recovery-email-confirm" size="large" prefix={<Mail className="size-4 text-white/35" />} value={email} readOnly autoComplete="email" />
<Input id="recovery-email-confirm" size="large" prefix={<Mail className="auth-scene-icon size-4" />} value={email} readOnly autoComplete="email" />
<Button htmlType="button" size="large" onClick={editEmail}>
修改邮箱
</Button>
@@ -131,7 +131,7 @@ export default function ForgotPasswordPage() {
<Input
id="recovery-code"
size="large"
prefix={<ShieldCheck className="size-4 text-white/35" />}
prefix={<ShieldCheck className="auth-scene-icon size-4" />}
value={emailCode}
onChange={(event) => setEmailCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="6 位验证码"
@@ -149,7 +149,7 @@ export default function ForgotPasswordPage() {
<Input.Password
id="recovery-password"
size="large"
prefix={<LockKeyhole className="size-4 text-white/35" />}
prefix={<LockKeyhole className="auth-scene-icon size-4" />}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少 8 位"
@@ -162,7 +162,7 @@ export default function ForgotPasswordPage() {
<Input.Password
id="recovery-confirm-password"
size="large"
prefix={<LockKeyhole className="size-4 text-white/35" />}
prefix={<LockKeyhole className="auth-scene-icon size-4" />}
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
placeholder="再次输入密码"
@@ -183,7 +183,7 @@ export default function ForgotPasswordPage() {
function AuthField({ label, htmlFor, children }: { label: string; htmlFor: string; children: ReactNode }) {
return (
<div className="space-y-2">
<label htmlFor={htmlFor} className="block text-xs font-medium text-white/62">
<label htmlFor={htmlFor} className="auth-scene-label block text-xs font-medium">
{label}
</label>
{children}
@@ -194,7 +194,7 @@ function AuthField({ label, htmlFor, children }: { label: string; htmlFor: strin
function BackToLogin({ to }: { to: string }) {
return (
<div className="text-center">
<Link to={to} className="inline-flex min-h-8 items-center gap-1.5 rounded-sm text-xs text-white/50 transition-colors hover:text-white/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-300/45">
<Link to={to} className="auth-scene-link inline-flex min-h-8 items-center gap-1.5 rounded-sm text-xs transition-colors">
<ArrowLeft className="size-3.5" />
返回登录
</Link>
@@ -204,7 +204,7 @@ function BackToLogin({ to }: { to: string }) {
function Notice({ icon, children }: { icon: ReactNode; children: ReactNode }) {
return (
<div className="flex items-start gap-2 rounded-lg border border-amber-300/15 bg-amber-300/[0.06] px-3 py-2.5 text-xs leading-5 text-amber-100/78">
<div className="auth-scene-notice flex items-start gap-2 rounded-lg border px-3 py-2.5 text-xs leading-5">
<span className="mt-0.5 shrink-0">{icon}</span>
{children}
</div>

View File

@@ -69,7 +69,7 @@ export default function LoginPage() {
{methods.length > 0 && <Segmented block aria-label="登录方式" value={method} disabled={submitting} options={[{ value: "password", label: "密码登录" }, ...methods.map((value) => ({ value, label: methodLabels[value] }))]} onChange={(value) => { setMethod(value as typeof method); setVerification({ ...emptyVerification }); }} />}
{method !== "password" ? <VerificationFields key={method} purpose="login" method={method} value={verification} onChange={setVerification} disabled={submitting} /> : <>
<AuthField label="用户名 / 邮箱" htmlFor="login-account">
<Input id="login-account" size="large" prefix={<UserRound className="size-4 text-white/35" />} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="用户名或邮箱" autoComplete="username" required />
<Input id="login-account" size="large" prefix={<UserRound className="auth-scene-icon size-4" />} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="用户名或邮箱" autoComplete="username" required />
</AuthField>
<AuthField
label="密码"
@@ -77,7 +77,7 @@ export default function LoginPage() {
action={
<Link
to={forgotPasswordURL}
className="-my-2 inline-flex min-h-8 items-center rounded-sm text-xs font-medium text-blue-300/80 transition-colors hover:text-blue-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-300/45"
className="auth-scene-link -my-2 inline-flex min-h-8 items-center rounded-sm text-xs font-medium transition-colors"
>
忘记密码?
</Link>
@@ -86,7 +86,7 @@ export default function LoginPage() {
<Input.Password
id="login-password"
size="large"
prefix={<LockKeyhole className="size-4 text-white/35" />}
prefix={<LockKeyhole className="auth-scene-icon size-4" />}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
@@ -100,7 +100,7 @@ export default function LoginPage() {
</Button>
{linuxdoEnabled ? (
<>
<Divider plain className="!border-white/10 !text-white/30">
<Divider plain className="auth-scene-divider">
或
</Divider>
<Button size="large" block icon={<LinuxDOIcon />} href={linuxDOLoginURL(next)}>
@@ -116,7 +116,7 @@ function AuthField({ label, htmlFor, action, children }: { label: string; htmlFo
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<label htmlFor={htmlFor} className="text-xs font-medium text-white/62">
<label htmlFor={htmlFor} className="auth-scene-label text-xs font-medium">
{label}
</label>
{action}

View File

@@ -104,7 +104,7 @@ export default function RegisterPage() {
<div className="grid gap-4 sm:grid-cols-2">
<AuthField label="用户名">
<Input size="large" prefix={<UserRound className="size-4 text-white/35" />} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="3-32 位字符" autoComplete="username" required disabled={disabled} />
<Input size="large" prefix={<UserRound className="auth-scene-icon size-4" />} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="3-32 位字符" autoComplete="username" required disabled={disabled} />
</AuthField>
<AuthField label="显示名称">
<Input size="large" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="不填则使用用户名" disabled={disabled} />
@@ -114,7 +114,7 @@ export default function RegisterPage() {
{settings?.firstUser ? <AuthField label="邮箱(可选)">
<Input
size="large"
prefix={<Mail className="size-4 text-white/35" />}
prefix={<Mail className="auth-scene-icon size-4" />}
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="用于登录与安全验证"
@@ -131,7 +131,7 @@ export default function RegisterPage() {
<AuthField label="密码">
<Input.Password
size="large"
prefix={<LockKeyhole className="size-4 text-white/35" />}
prefix={<LockKeyhole className="auth-scene-icon size-4" />}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少 8 位"
@@ -143,7 +143,7 @@ export default function RegisterPage() {
<AuthField label="确认密码">
<Input.Password
size="large"
prefix={<LockKeyhole className="size-4 text-white/35" />}
prefix={<LockKeyhole className="auth-scene-icon size-4" />}
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
placeholder="再次输入密码"
@@ -154,11 +154,11 @@ export default function RegisterPage() {
</AuthField>
</div>
<div className="flex items-start gap-2 text-xs leading-5 text-white/55">
<div className="auth-scene-muted flex items-start gap-2 text-xs leading-5">
<Checkbox checked={agreementAccepted} onChange={(event) => setAgreementAccepted(event.target.checked)}>
<span className="text-xs leading-5 text-white/55">我已阅读并同意</span>
<span className="auth-scene-muted text-xs leading-5">我已阅读并同意</span>
</Checkbox>
<button type="button" className="-ml-1 text-xs leading-5 text-blue-300/85 transition hover:text-blue-200" onClick={() => setAgreementOpen(true)}>
<button type="button" className="auth-scene-link -ml-1 text-xs leading-5 transition" onClick={() => setAgreementOpen(true)}>
《影策服务协议》
</button>
</div>
@@ -168,7 +168,7 @@ export default function RegisterPage() {
</Button>
{settings?.linuxdoEnabled && !settings.smsAndEmailRegistration ? (
<>
<Divider plain className="!border-white/10 !text-white/30">
<Divider plain className="auth-scene-divider">
或
</Divider>
<Button size="large" block disabled={!agreementAccepted} icon={<LinuxDOIcon />} href={agreementAccepted ? linuxDOLoginURL(next, true) : undefined}>
@@ -194,7 +194,7 @@ export default function RegisterPage() {
function AuthField({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block space-y-2">
<span className="text-xs font-medium text-white/62">{label}</span>
<span className="auth-scene-label text-xs font-medium">{label}</span>
{children}
</label>
);
@@ -202,7 +202,7 @@ function AuthField({ label, children }: { label: string; children: ReactNode })
function Notice({ icon, tone, children }: { icon: ReactNode; tone: "blue" | "amber"; children: ReactNode }) {
return (
<div className={`flex items-start gap-2 rounded-lg border px-3 py-2.5 text-xs leading-5 ${tone === "blue" ? "border-blue-300/15 bg-blue-300/[0.06] text-blue-100/78" : "border-amber-300/15 bg-amber-300/[0.06] text-amber-100/78"}`}>
<div data-tone={tone} className="auth-scene-notice flex items-start gap-2 rounded-lg border px-3 py-2.5 text-xs leading-5">
<span className="mt-0.5 shrink-0">{icon}</span>
{children}
</div>

View File

@@ -295,12 +295,12 @@
--glass-alpha-strong: 0.97;
--glass-border-alpha: 0.78;
/* 站点皮肤对登录页的组件级入口;经典皮肤保持原始值。 */
--auth-page-bg: #08090c;
--auth-panel-bg: #0b0c10;
--auth-card-bg: rgba(18, 19, 24, 0.94);
--auth-accent: rgba(147, 197, 253, 0.8);
--auth-muted: rgba(255, 255, 255, 0.45);
/* 登录表单跟随全局明暗主题;品牌影片保留独立深色舞台。 */
--auth-page-bg: #ffffff;
--auth-panel-bg: #f7f7f7;
--auth-card-bg: #ffffff;
--auth-accent: #2563eb;
--auth-muted: #737373;
/* 可配置皮肤的组件入口。经典主题保持这些项目原始值;自定义主题在
React 启动前由公开外观配置覆盖,页面和组件只消费这些语义变量。 */
@@ -359,6 +359,45 @@
.auth-scene {
background: var(--auth-page-bg);
color: var(--foreground);
}
.auth-scene-hero {
background: var(--auth-panel-bg);
color: var(--foreground);
}
.auth-scene-hero-overlay {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--auth-panel-bg) 40%, transparent),
transparent 42%,
color-mix(in srgb, var(--auth-panel-bg) 76%, transparent)
);
pointer-events: none;
}
.auth-scene-brand,
.auth-scene-brand:hover {
color: inherit;
}
.auth-scene-hero-control {
border: 1px solid color-mix(in srgb, var(--foreground) 16%, transparent);
background: color-mix(in srgb, var(--auth-panel-bg) 78%, transparent);
color: color-mix(in srgb, var(--foreground) 82%, transparent);
}
.auth-scene-hero-control:hover {
background: color-mix(in srgb, var(--auth-panel-bg) 92%, transparent);
}
.auth-scene-hero-muted {
color: color-mix(in srgb, var(--foreground) 64%, transparent);
}
.auth-scene-hero-title {
color: var(--foreground);
}
.auth-scene-form-pane {
@@ -371,7 +410,7 @@
.auth-scene-card {
background: var(--auth-card-bg);
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.34);
box-shadow: var(--elevation-card-hover);
}
.auth-scene-eyebrow {
@@ -382,16 +421,57 @@
color: var(--auth-muted);
}
.auth-scene-label {
color: color-mix(in srgb, var(--foreground) 72%, transparent);
}
.auth-scene-icon {
color: var(--auth-muted);
}
.auth-scene-link {
color: var(--auth-accent);
}
.auth-scene-link:hover {
color: var(--foreground);
}
.auth-scene-link:focus-visible {
outline: var(--focus-ring-width) solid var(--ring);
outline-offset: 3px;
}
.auth-scene-divider.ant-divider {
border-color: var(--border);
color: var(--auth-muted);
}
.auth-scene-notice {
--auth-notice-color: var(--palette-status-warning);
border-color: color-mix(in srgb, var(--auth-notice-color) 24%, transparent);
background: color-mix(in srgb, var(--auth-notice-color) 6%, transparent);
color: var(--foreground);
}
.auth-scene-notice[data-tone="blue"] {
--auth-notice-color: var(--palette-status-loading);
}
.auth-scene-notice > span:first-child {
color: var(--auth-notice-color);
}
.auth-scene-return {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.58);
border: var(--control-border-width) solid var(--border);
background: color-mix(in srgb, var(--foreground) 4%, transparent);
color: var(--auth-muted);
}
.auth-scene-return:hover {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.08);
color: #fff;
border-color: var(--input);
background: color-mix(in srgb, var(--foreground) 8%, transparent);
color: var(--foreground);
}
.site-compliance-footer {
@@ -433,15 +513,15 @@
.site-compliance-footer.is-auth {
min-height: 54px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-top: var(--control-border-width) solid var(--border);
background: color-mix(in srgb, var(--auth-panel-bg) 88%, transparent);
color: rgba(255, 255, 255, 0.48);
color: var(--auth-muted);
backdrop-filter: blur(14px);
}
.site-compliance-footer.is-auth a:hover,
.site-compliance-footer.is-auth a:focus-visible {
color: rgba(255, 255, 255, 0.86);
color: var(--foreground);
}
@media (max-width: 640px) {
@@ -714,6 +794,11 @@
.dark {
--background: #0f0f0f;
--auth-page-bg: #08090c;
--auth-panel-bg: #0b0c10;
--auth-card-bg: #121318;
--auth-accent: #93c5fd;
--auth-muted: #8a8b91;
--foreground: #ffffff;
--border: #222222;
--ring: oklch(0.556 0 0);
@@ -6841,13 +6926,13 @@ html:not(.dark) .creation-studio-control[aria-expanded="true"] { background: #e7
justify-content: center;
margin: 0 !important;
padding: 8px 0 12px !important;
color: rgba(255, 255, 255, 0.38);
color: var(--auth-muted);
font-weight: 500;
transition: color var(--motion-instant) ease-out;
}
.auth-card-tabs .ant-tabs-tab:hover {
color: rgba(255, 255, 255, 0.68);
color: var(--foreground);
}
.auth-card-tabs .ant-tabs-tab-active::after {
@@ -6857,19 +6942,19 @@ html:not(.dark) .creation-studio-control[aria-expanded="true"] { background: #e7
width: 20px;
height: 2px;
border-radius: 1px;
background: rgba(255, 255, 255, 0.82);
background: var(--foreground);
content: "";
transform: translateX(-50%);
}
.auth-card-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
color: rgba(255, 255, 255, 0.94) !important;
color: var(--foreground) !important;
text-shadow: none;
}
.auth-card-tabs .ant-tabs-tab-btn:focus-visible {
border-radius: 3px;
outline: 1px solid rgba(255, 255, 255, 0.48);
outline: var(--focus-ring-width) solid var(--ring);
outline-offset: 3px;
}
@@ -6877,39 +6962,39 @@ html:not(.dark) .creation-studio-control[aria-expanded="true"] { background: #e7
display: none;
}
.auth-card-dark :where(.ant-input, .ant-input-affix-wrapper) {
.auth-scene-card :where(.ant-input, .ant-input-affix-wrapper) {
border-color: transparent !important;
background: rgba(255, 255, 255, 0.055) !important;
background: color-mix(in srgb, var(--foreground) 5.5%, transparent) !important;
box-shadow: none !important;
transition: border-color var(--motion-instant) ease-out, background-color var(--motion-instant) ease-out !important;
}
.auth-card-dark .ant-input-affix-wrapper > .ant-input {
.auth-scene-card .ant-input-affix-wrapper > .ant-input {
background: transparent !important;
}
.auth-card-dark :where(.ant-input:hover, .ant-input-affix-wrapper:hover) {
border-color: rgba(255, 255, 255, 0.08) !important;
background: rgba(255, 255, 255, 0.07) !important;
.auth-scene-card :where(.ant-input:hover, .ant-input-affix-wrapper:hover) {
border-color: var(--border) !important;
background: color-mix(in srgb, var(--foreground) 7%, transparent) !important;
}
.auth-card-dark :where(.ant-input:focus, .ant-input-focused, .ant-input-affix-wrapper-focused) {
border-color: transparent !important;
background: rgba(255, 255, 255, 0.085) !important;
.auth-scene-card :where(.ant-input:focus, .ant-input-focused, .ant-input-affix-wrapper-focused) {
border-color: var(--ring) !important;
background: color-mix(in srgb, var(--foreground) 8.5%, transparent) !important;
outline: none !important;
box-shadow: none !important;
box-shadow: 0 0 0 var(--focus-ring-width) color-mix(in srgb, var(--ring) 18%, transparent) !important;
}
.auth-card-dark .ant-input-affix-wrapper-focused .ant-input-prefix svg {
color: rgba(255, 255, 255, 0.62) !important;
.auth-scene-card .ant-input-affix-wrapper-focused .ant-input-prefix svg {
color: var(--foreground) !important;
}
.auth-card-dark input:-webkit-autofill,
.auth-card-dark input:-webkit-autofill:hover,
.auth-card-dark input:-webkit-autofill:focus {
box-shadow: 0 0 0 1000px #17181c inset !important;
-webkit-text-fill-color: #f5f5f5 !important;
caret-color: #f5f5f5;
.auth-scene-card input:-webkit-autofill,
.auth-scene-card input:-webkit-autofill:hover,
.auth-scene-card input:-webkit-autofill:focus {
box-shadow: 0 0 0 1000px var(--auth-card-bg) inset !important;
-webkit-text-fill-color: var(--foreground) !important;
caret-color: var(--foreground);
}
:where(.ant-modal, .ant-drawer, .ant-select-dropdown, .ant-dropdown, .ant-popover) {

View File

@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { DEFAULT_CLASSIC_SKIN, duplicateSkinDefinition, skinCSSVariables } from "../src/lib/skin-themes";
const source = (path: string) => readFileSync(new URL(`../src/${path}`, import.meta.url), "utf8");
describe("authentication follows the application theme", () => {
test("classic login surfaces have distinct light and dark palettes", () => {
const css = source("styles/globals.css");
const dark = css.slice(css.indexOf(".dark {\n --background:"));
for (const [mode, background, panel, card] of [
["light", "#ffffff", "#f7f7f7", "#ffffff"],
["dark", "#08090c", "#0b0c10", "#121318"],
] as const) {
const variables = skinCSSVariables(DEFAULT_CLASSIC_SKIN, mode);
const modeCSS = mode === "dark" ? dark : css.slice(0, css.indexOf(".auth-scene {"));
expect(variables["--auth-page-bg"]).toBe(background);
expect(variables["--auth-panel-bg"]).toBe(panel);
expect(variables["--auth-card-bg"]).toBe(card);
for (const key of ["--auth-page-bg", "--auth-panel-bg", "--auth-card-bg", "--auth-accent", "--auth-muted"]) {
expect(modeCSS).toContain(`${key}: ${variables[key]};`);
}
}
});
test("auth inherits the global Ant Design provider instead of forcing dark controls", () => {
const scene = source("pages/auth/auth-scene.tsx");
expect(scene).not.toContain("ConfigProvider");
expect(scene).not.toContain("getAntThemeConfig");
expect(scene).not.toContain("auth-card-dark");
expect(scene.match(/<main className="([^"]+)"/)?.[1]).not.toContain("text-white");
expect(scene).toContain("auth-scene-hero");
expect(scene).toContain("<BrandLogo theme={theme}");
expect(source("components/layout/app-providers.tsx")).toContain("getAntThemeConfig(dark, appearance.activeSkin)");
});
test("form labels, links and notices do not assume white text", () => {
for (const page of ["login", "register", "forgot-password"]) {
const form = source(`pages/auth/${page}.tsx`);
expect(form).not.toMatch(/(?:text|border|bg)-white/);
expect(form).not.toMatch(/text-(?:blue|amber)-[123]00/);
expect(form).toContain("auth-scene-label");
expect(form).toContain("auth-scene-icon");
}
expect(source("pages/auth/register.tsx")).toContain("auth-scene-notice");
expect(source("pages/auth/forgot-password.tsx")).toContain("auth-scene-notice");
});
test("focus and autofill keep theme-aware foregrounds and visible focus", () => {
const css = source("styles/globals.css");
expect(css).not.toContain(".auth-card-dark");
expect(css).toContain(".auth-scene-card input:-webkit-autofill");
expect(css).toContain("-webkit-text-fill-color: var(--foreground) !important");
expect(css).toContain("box-shadow: 0 0 0 1000px var(--auth-card-bg) inset !important");
expect(css).toContain(".auth-scene-link:focus-visible");
expect(css).toContain("box-shadow: 0 0 0 var(--focus-ring-width) color-mix(in srgb, var(--ring) 18%, transparent)");
});
test("custom auth palettes are preserved per mode", () => {
const skin = duplicateSkinDefinition(DEFAULT_CLASSIC_SKIN, ["classic"]);
skin.tokens.light.authPanel = "#f6ebe3";
skin.tokens.light.authCard = "#fffdf9";
skin.tokens.dark.authPanel = "#21120e";
skin.tokens.dark.authCard = "#361f17";
expect(skinCSSVariables(skin, "light")["--auth-panel-bg"]).toBe("#f6ebe3");
expect(skinCSSVariables(skin, "light")["--auth-card-bg"]).toBe("#fffdf9");
expect(skinCSSVariables(skin, "dark")["--auth-panel-bg"]).toBe("#21120e");
expect(skinCSSVariables(skin, "dark")["--auth-card-bg"]).toBe("#361f17");
});
});