fix(appearance): 本地保存站点 Logo 并支持登录视频自动播放 (#435)
This commit is contained in:
@@ -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 }) {
|
||||
|
||||
@@ -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="" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user