fix(appearance): 本地保存站点 Logo 并支持登录视频自动播放 (#435)

This commit is contained in:
Ray
2026-09-07 22:28:32 +08:00
committed by GitHub
parent 6a7605ec33
commit bdfccf51d1
13 changed files with 328 additions and 38 deletions

View File

@@ -1,4 +1,4 @@
import type { CSSProperties, ReactNode } from "react";
import { useState, type CSSProperties, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { appearanceLogoURL, useAppearanceStore } from "@/stores/use-appearance-store";
@@ -14,8 +14,24 @@ type BrandLogoProps = {
export function BrandLogo({ className, fallback, alt = "", theme = "auto" }: BrandLogoProps) {
const appearance = useAppearanceStore((state) => state.appearance);
const currentTheme = useThemeStore((state) => state.theme);
const source = appearanceLogoURL(appearance, theme === "auto" ? currentTheme : theme);
const [failedSource, setFailedSource] = useState<string | null>(null);
if (!appearance.logoConfigured) return <>{fallback}</>;
return <img src={appearanceLogoURL(appearance, theme === "auto" ? currentTheme : theme)} alt={alt} className={cn("block object-contain", className)} draggable={false} />;
// A configured custom logo must never fall through to the built-in brand
// when its file becomes unavailable. Keep its footprint neutral instead.
if (failedSource === source) return <span className={cn("block", className)} aria-hidden="true" />;
return (
<img
src={source}
alt={alt}
className={cn("block object-contain", className)}
draggable={false}
onError={(event) => {
event.currentTarget.style.visibility = "hidden";
setFailedSource(source);
}}
/>
);
}
export function BrandLogoFrame({ className, logoClassName, fallback, alt = "", theme = "auto" }: BrandLogoProps & { logoClassName?: string }) {

View File

@@ -31,6 +31,7 @@ export default function AppearanceSettingsPage() {
const [brandSlug, setBrandSlug] = useState("");
const [authHeroTitle, setAuthHeroTitle] = useState("");
const [authHeroDescription, setAuthHeroDescription] = useState("");
const [authVideoAutoplay, setAuthVideoAutoplay] = useState(true);
const [logoFrameEnabled, setLogoFrameEnabled] = useState(true);
const [skinId, setSkinId] = useState("classic");
const [skinThemes, setSkinThemes] = useState<SkinDefinition[]>([DEFAULT_CLASSIC_SKIN]);
@@ -61,6 +62,7 @@ export default function AppearanceSettingsPage() {
brandSlug.trim().toLocaleLowerCase() !== setting?.brandSlug ||
normalizeDraftCopy(authHeroTitle) !== setting?.authHeroTitle ||
normalizeDraftCopy(authHeroDescription) !== setting?.authHeroDescription ||
authVideoAutoplay !== setting?.authVideoAutoplay ||
logoFrameEnabled !== setting?.logoFrameEnabled ||
skinId !== setting?.skinId ||
JSON.stringify(skinThemes) !== JSON.stringify(setting?.skinThemes) ||
@@ -80,6 +82,7 @@ export default function AppearanceSettingsPage() {
setBrandSlug(value.brandSlug);
setAuthHeroTitle(value.authHeroTitle);
setAuthHeroDescription(value.authHeroDescription);
setAuthVideoAutoplay(value.authVideoAutoplay);
setLogoFrameEnabled(value.logoFrameEnabled);
const themes = value.skinThemes.map((theme) => normalizeSkinDefinition(theme));
setSkinThemes(themes.length ? themes : [cloneSkinDefinition(DEFAULT_CLASSIC_SKIN)]);
@@ -189,6 +192,7 @@ export default function AppearanceSettingsPage() {
setBrandSlug(setting.brandSlug);
setAuthHeroTitle(setting.authHeroTitle);
setAuthHeroDescription(setting.authHeroDescription);
setAuthVideoAutoplay(setting.authVideoAutoplay);
setLogoFrameEnabled(setting.logoFrameEnabled);
setSkinId(setting.skinId);
setSkinThemes(setting.skinThemes.map((theme) => cloneSkinDefinition(theme)));
@@ -324,6 +328,7 @@ export default function AppearanceSettingsPage() {
logoFrameEnabled,
authVideoResourceId: ids.video,
authVideoPosterResourceId: ids.poster,
authVideoAutoplay,
skinId,
skinThemes,
seoTitle: nextSeoTitle,
@@ -547,6 +552,16 @@ export default function AppearanceSettingsPage() {
</Form.Item>
</Form>
<div className="admin-appearance-media-list">
<div className="admin-appearance-video-autoplay-option">
<span className="admin-appearance-video-autoplay-copy">
<strong>登录页视频自动播放</strong>
<small>默认开启并静音循环播放;访客启用“减少动态效果”时仍尊重其系统偏好。</small>
</span>
<span className="admin-appearance-video-autoplay-control">
<span>{authVideoAutoplay ? "已开启" : "已关闭"}</span>
<Switch checked={authVideoAutoplay} disabled={saving || refreshing || restoring} aria-label="登录页视频自动播放" onChange={setAuthVideoAutoplay} />
</span>
</div>
<AssetPicker
slot="video"
title="品牌视频"
@@ -580,7 +595,7 @@ export default function AppearanceSettingsPage() {
<AdminStatusBadge label={dirty ? "未保存" : "线上版本"} tone={dirty ? "warning" : "success"} />
</div>
<div className="admin-appearance-preview-stage">
<video key={previews.video} src={previews.video} poster={previews.poster || undefined} muted loop playsInline autoPlay preload="metadata" />
<video key={`${previews.video}-${authVideoAutoplay}`} src={previews.video} poster={previews.poster || undefined} muted loop playsInline autoPlay={authVideoAutoplay} preload="metadata" />
<span className="admin-appearance-preview-shade" />
<span className="admin-appearance-preview-brand">
<img src={previews.logoDark} alt="" />

View File

@@ -1,7 +1,7 @@
import { motion, useReducedMotion } from "motion/react";
import { ConfigProvider, Tabs } from "antd";
import { ArrowLeft, Play } from "lucide-react";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router";
import { BrandLogo } from "@/components/brand/brand-logo";
@@ -51,17 +51,33 @@ export function AuthScene() {
const location = useLocation();
const navigate = useNavigate();
const reducedMotion = useReducedMotion();
const [videoActive, setVideoActive] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const [manualVideoActive, setManualVideoActive] = useState(false);
const [videoPlaying, setVideoPlaying] = useState(false);
const [failedPosterURL, setFailedPosterURL] = useState("");
const recovery = location.pathname === "/forgot-password";
const activeTab = location.pathname === "/register" ? "register" : "login";
const copy = recovery ? authCopy.recovery : activeTab === "register" ? authCopy.register : authCopy.login;
const automaticVideoActive = appearance.authVideoAutoplay && !reducedMotion;
const videoActive = Boolean(appearance.authVideoUrl && (automaticVideoActive || manualVideoActive));
useEffect(() => {
setManualVideoActive(false);
setVideoPlaying(false);
}, [appearance.authVideoUrl, appearance.authVideoAutoplay]);
const playVideo = () => {
setManualVideoActive(true);
requestAnimationFrame(() => {
void videoRef.current?.play().catch(() => setVideoPlaying(false));
});
};
return (
<main className="auth-scene h-dvh min-h-0 overflow-y-auto text-white 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}品牌影片`}>
{videoActive && appearance.authVideoUrl ? <video className="absolute inset-0 size-full object-cover" src={appearance.authVideoUrl} poster={appearance.authVideoPosterUrl || undefined} autoPlay={!reducedMotion} muted loop playsInline preload="metadata" /> : 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}
{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-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">
@@ -69,9 +85,9 @@ export function AuthScene() {
<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" }} />} />
{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={() => setVideoActive(true)} disabled={videoActive || !appearance.authVideoUrl} aria-pressed={videoActive}>
<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}>
<Play className="size-3 fill-current" />
{videoActive ? "创作正在发生" : "播放品牌影片"}
{videoPlaying ? "创作正在发生" : "播放品牌影片"}
</button>
</div>
<motion.div

View File

@@ -12,6 +12,7 @@ export type PublicAppearance = {
logoFrameEnabled: boolean;
authVideoUrl: string;
authVideoPosterUrl: string;
authVideoAutoplay: boolean;
skinId: string;
activeSkin: SkinDefinition;
seoTitle: string;
@@ -40,6 +41,7 @@ export type AdminAppearance = {
logoFrameEnabled: boolean;
authVideoResourceId: string;
authVideoPosterResourceId: string;
authVideoAutoplay: boolean;
skinId: string;
skinThemes: SkinDefinition[];
seoTitle: string;
@@ -87,6 +89,7 @@ export async function updateAdminAppearance(
| "logoFrameEnabled"
| "authVideoResourceId"
| "authVideoPosterResourceId"
| "authVideoAutoplay"
| "skinId"
| "skinThemes"
| "seoTitle"

View File

@@ -4,7 +4,7 @@ import type { PublicAppearance } from "@/services/api/appearance";
import { applySkinTheme, DEFAULT_CLASSIC_SKIN, normalizeSkinDefinition } from "@/lib/skin-themes";
export const DEFAULT_PUBLIC_APPEARANCE: PublicAppearance = {
schemaVersion: 6,
schemaVersion: 7,
brandName: "影策",
brandSlug: "open-ai-canvas",
authHeroTitle: "让一个故事,\n从文字走向银幕。",
@@ -14,6 +14,7 @@ export const DEFAULT_PUBLIC_APPEARANCE: PublicAppearance = {
logoFrameEnabled: true,
authVideoUrl: "https://boss-shjd.biliapi.net/updream/aniforge/video/video_bbcb00bd-650d-4249-9346-5cd21fd2484c_m1hc-u0-1pu13x-3v1s.mp4",
authVideoPosterUrl: "https://i0.hdslb.com/bfs/aitool/aniforge/image/02933f26-5f1b-49ff-a811-b7f95ee5e5b8_m1hc-u0-sau.jpg",
authVideoAutoplay: true,
skinId: "classic",
activeSkin: DEFAULT_CLASSIC_SKIN,
seoTitle: "影策",
@@ -59,7 +60,7 @@ export function normalizePublicAppearance(value?: Partial<PublicAppearance> | nu
return {
...DEFAULT_PUBLIC_APPEARANCE,
...value,
schemaVersion: 6,
schemaVersion: 7,
brandName: resolvedBrandName,
brandSlug,
authHeroTitle,
@@ -69,6 +70,7 @@ export function normalizePublicAppearance(value?: Partial<PublicAppearance> | nu
logoFrameEnabled: value?.logoFrameEnabled !== false,
authVideoUrl: safeAppearanceURL(value?.authVideoUrl, DEFAULT_PUBLIC_APPEARANCE.authVideoUrl),
authVideoPosterUrl: safeAppearanceURL(value?.authVideoPosterUrl, customVideo ? "" : DEFAULT_PUBLIC_APPEARANCE.authVideoPosterUrl),
authVideoAutoplay: value?.authVideoAutoplay !== false,
skinId: normalizeSkinDefinition(value?.activeSkin).id,
activeSkin: normalizeSkinDefinition(value?.activeSkin),
seoTitle,

View File

@@ -248,6 +248,50 @@
margin-top: 4px;
}
.admin-appearance-video-autoplay-option {
display: flex;
min-height: 72px;
align-items: center;
justify-content: space-between;
gap: 16px;
border: 1px solid var(--admin-card-border);
border-radius: var(--r-md);
background: var(--admin-layer-2);
padding: 12px 14px;
}
.admin-appearance-video-autoplay-copy {
display: grid;
gap: 4px;
min-width: 0;
}
.admin-appearance-video-autoplay-copy strong {
color: var(--admin-text-primary);
font-size: 12px;
font-weight: 650;
}
.admin-appearance-video-autoplay-copy small,
.admin-appearance-video-autoplay-control > span {
color: var(--admin-text-muted);
font-size: 11px;
line-height: 1.5;
}
.admin-appearance-video-autoplay-control {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.admin-appearance-video-autoplay-control > span {
font-size: 10px;
font-weight: 600;
white-space: nowrap;
}
.admin-appearance-asset-row {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
@@ -1222,6 +1266,16 @@
justify-content: space-between;
}
.admin-appearance-video-autoplay-option {
align-items: flex-start;
flex-direction: column;
}
.admin-appearance-video-autoplay-control {
width: 100%;
justify-content: space-between;
}
.admin-appearance-asset-actions {
grid-column: 1 / -1;
justify-content: flex-start;