Files
ttstd-infinite-canvas/web/src/pages/admin/settings/appearance-settings-page.tsx

783 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { commitPublicAppearance, DEFAULT_PUBLIC_APPEARANCE } from "@/stores/use-appearance-store";
import { deleteAdminResources } from "@/services/api/admin-storage";
import { AdminStatusBadge, SettingsSectionCard } from "@/pages/admin/components/admin-ui";
import { AdminPageFrame } from "@/pages/admin/components/admin-shell";
import { Palette, RefreshCw, Undo2, RotateCcw, Save, Sun, Moon, MonitorPlay, Globe2, Search, Copyright, Type } from "lucide-react";
import { cn } from "@/lib/utils";
import { Switch } from "@/pages/admin/ui/controls";
import { WelcomeSetting } from "@/pages/admin/settings/components/welcome-setting";
import { SkinThemeEditor } from "@/pages/admin/settings/components/skin-theme-editor";
import { CanvasAppearanceEditor } from "./components/canvas-appearance-editor";
import { UpdateAnnouncementEditor } from "./components/update-announcement-editor";
import { DEFAULT_UPDATE_ANNOUNCEMENT, normalizeUpdateAnnouncement, validateUpdateAnnouncement, type UpdateAnnouncement } from "@/lib/update-announcement";
import { App, Button, Tabs, Form, Input } from "antd";
import { useCallback, useEffect, useRef, useState } from "react";
import { useBlocker } from "react-router";
import { cloneSkinDefinition, DEFAULT_CLASSIC_SKIN, normalizeSkinDefinition, type SkinDefinition, duplicateSkinDefinition } from "@/lib/skin-themes";
import { DEFAULT_CANVAS_APPEARANCE, type CanvasAppearance } from "@/lib/canvas/agent-appearance";
import { getAdminAppearance, type AdminAppearance, type AppearanceAssetSlot, resetAdminAppearance, uploadAppearanceAsset, updateAdminAppearance } from "@/services/api/appearance";
import { normalizeDraftCopy, normalizeSingleLine, hasUnsupportedControlCharacter, validateSkinDrafts, useAppearancePreviews, AppearanceSkeleton, AssetPicker, LogoThemePreview } from "./appearance-settings-parts";
import { type DraftFiles, FILE_RULES, type ResetState } from "./appearance-settings-parts";
export { type DraftFiles, FILE_RULES, type ResetState } from "./appearance-settings-parts";
const EMPTY_FILES: DraftFiles = { logo: null, "logo-dark": null, video: null, poster: null };
const EMPTY_RESETS: ResetState = { logo: false, "logo-dark": false, video: false, poster: false };
export default function AppearanceSettingsPage() {
const { message, modal } = App.useApp();
const [activeTab, setActiveTab] = useState("brand");
const [setting, setSetting] = useState<AdminAppearance | null>(null);
const [brandName, setBrandName] = useState("");
const [canvas, setCanvas] = useState<CanvasAppearance>(DEFAULT_CANVAS_APPEARANCE);
const [updates, setUpdates] = useState<UpdateAnnouncement>(DEFAULT_UPDATE_ANNOUNCEMENT);
const [canvasUploading, setCanvasUploading] = useState(false);
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]);
const [seoTitle, setSeoTitle] = useState("");
const [seoDescription, setSeoDescription] = useState("");
const [seoKeywords, setSeoKeywords] = useState("");
const [footerCopyright, setFooterCopyright] = useState("");
const [icpFilingEnabled, setIcpFilingEnabled] = useState(false);
const [icpFilingNumber, setIcpFilingNumber] = useState("");
const [files, setFiles] = useState<DraftFiles>(EMPTY_FILES);
const [resets, setResets] = useState<ResetState>(EMPTY_RESETS);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [restoring, setRestoring] = useState(false);
const [loadError, setLoadError] = useState("");
const requestVersionRef = useRef(0);
const inputRefs = {
logo: useRef<HTMLInputElement>(null),
"logo-dark": useRef<HTMLInputElement>(null),
video: useRef<HTMLInputElement>(null),
poster: useRef<HTMLInputElement>(null),
};
const dirty =
Boolean(setting) &&
(JSON.stringify(updates) !== JSON.stringify(normalizeUpdateAnnouncement(setting?.updates)) ||
JSON.stringify(canvas) !== JSON.stringify(setting?.canvas || DEFAULT_CANVAS_APPEARANCE) ||
canvasUploading ||
brandName.trim() !== setting?.brandName ||
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) ||
normalizeSingleLine(seoTitle) !== setting?.seoTitle ||
normalizeDraftCopy(seoDescription) !== setting?.seoDescription ||
normalizeSingleLine(seoKeywords) !== setting?.seoKeywords ||
normalizeSingleLine(footerCopyright) !== setting?.footerCopyright ||
icpFilingEnabled !== setting?.icpFilingEnabled ||
normalizeSingleLine(icpFilingNumber) !== setting?.icpFilingNumber ||
Object.values(files).some(Boolean) ||
Object.values(resets).some(Boolean));
const blocker = useBlocker(dirty && !saving && !restoring);
const applySetting = useCallback((value: AdminAppearance) => {
const themes = value.skinThemes.length ? value.skinThemes.map((theme) => normalizeSkinDefinition(theme)) : [cloneSkinDefinition(DEFAULT_CLASSIC_SKIN)];
const selectedID = themes.some((theme) => theme.id === value.skinId) ? value.skinId : "classic";
setSetting({ ...value, skinThemes: themes, skinId: selectedID });
setBrandName(value.brandName);
setCanvas(value.canvas || DEFAULT_CANVAS_APPEARANCE);
setUpdates(normalizeUpdateAnnouncement(value.updates));
setBrandSlug(value.brandSlug);
setAuthHeroTitle(value.authHeroTitle);
setAuthHeroDescription(value.authHeroDescription);
setAuthVideoAutoplay(value.authVideoAutoplay);
setLogoFrameEnabled(value.logoFrameEnabled);
setSkinThemes(themes);
setSkinId(selectedID);
setSeoTitle(value.seoTitle);
setSeoDescription(value.seoDescription);
setSeoKeywords(value.seoKeywords);
setFooterCopyright(value.footerCopyright);
setIcpFilingEnabled(value.icpFilingEnabled);
setIcpFilingNumber(value.icpFilingNumber);
setFiles(EMPTY_FILES);
setResets(EMPTY_RESETS);
setLoadError("");
}, []);
const load = useCallback(
async (initial = false) => {
const requestVersion = ++requestVersionRef.current;
if (initial) setLoading(true);
else setRefreshing(true);
try {
const value = await getAdminAppearance();
if (requestVersion === requestVersionRef.current) applySetting(value);
} catch (error) {
if (requestVersion !== requestVersionRef.current) return;
const detail = error instanceof Error ? error.message : "读取外观配置失败";
setLoadError(detail);
if (!initial) message.error(detail);
} finally {
if (requestVersion === requestVersionRef.current) {
setLoading(false);
setRefreshing(false);
}
}
},
[applySetting, message],
);
useEffect(() => {
void load(true);
return () => {
requestVersionRef.current += 1;
};
}, [load]);
useEffect(() => {
if (blocker.state !== "blocked") return;
modal.confirm({
title: "放弃站点及外观调整?",
content: "当前品牌、画布 Agent、更新公告、SEO、备案、皮肤或媒体配置尚未保存,离开后草稿会丢失。线上站点不会改变。",
okText: "放弃并离开",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () => blocker.proceed(),
onCancel: () => blocker.reset(),
});
}, [blocker, modal]);
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirty || saving || restoring) return;
event.preventDefault();
};
window.addEventListener("beforeunload", beforeUnload);
return () => window.removeEventListener("beforeunload", beforeUnload);
}, [dirty, restoring, saving]);
const selectFile = (slot: AppearanceAssetSlot, file?: File) => {
if (!file) return;
const rule = FILE_RULES[slot];
if (!rule.accept.split(",").includes(file.type) || file.size <= 0 || file.size > rule.maxBytes) {
message.error(`${rule.label}须使用支持的格式,且不超过 ${rule.maxBytes >> 20}MB`);
if (inputRefs[slot].current) inputRefs[slot].current.value = "";
return;
}
setFiles((current) => ({
...current,
[slot]: file,
...(slot === "video" ? { poster: null } : {}),
}));
setResets((current) => ({
...current,
[slot]: false,
...(slot === "video" ? { poster: true } : {}),
}));
if (slot === "video" && inputRefs.poster.current) inputRefs.poster.current.value = "";
};
const resetAsset = (slot: AppearanceAssetSlot) => {
setFiles((current) => ({
...current,
[slot]: null,
...(slot === "video" ? { poster: null } : {}),
}));
setResets((current) => ({
...current,
[slot]: true,
...(slot === "video" ? { poster: true } : {}),
}));
if (inputRefs[slot].current) inputRefs[slot].current.value = "";
if (slot === "video" && inputRefs.poster.current) inputRefs.poster.current.value = "";
};
const discardDraft = () => {
if (!setting || saving || restoring || canvasUploading) return;
setCanvas(setting.canvas || DEFAULT_CANVAS_APPEARANCE);
setUpdates(normalizeUpdateAnnouncement(setting.updates));
setBrandName(setting.brandName);
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)));
setSeoTitle(setting.seoTitle);
setSeoDescription(setting.seoDescription);
setSeoKeywords(setting.seoKeywords);
setFooterCopyright(setting.footerCopyright);
setIcpFilingEnabled(setting.icpFilingEnabled);
setIcpFilingNumber(setting.icpFilingNumber);
setFiles(EMPTY_FILES);
setResets(EMPTY_RESETS);
Object.values(inputRefs).forEach((ref) => {
if (ref.current) ref.current.value = "";
});
message.info("已撤销未保存的外观调整");
};
const requestRefresh = () => {
if (canvasUploading) return;
if (!dirty) {
void load(false);
return;
}
modal.confirm({
title: "放弃调整并重新读取?",
content: "重新读取会丢弃当前品牌、画布 Agent、更新公告、SEO、备案、皮肤和待上传文件。",
okText: "放弃并刷新",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () => load(false),
});
};
const restoreBuiltInAppearance = () => {
if (!setting?.configured || saving || refreshing || restoring || canvasUploading) return;
modal.confirm({
title: "恢复影策默认品牌标识?",
content: "品牌名称、英文标识、Logo、画布 Agent 名称/文案/形象、更新公告、登录页文案、视频、封面、SEO、备案和皮肤主题会立即恢复为项目内置值;自定义公告会清空并关闭,展示原有更新日志。已上传文件仍保留在存储资源中,不会被删除。",
okText: "恢复默认",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: async () => {
setRestoring(true);
requestVersionRef.current += 1;
try {
const restored = await resetAdminAppearance();
applySetting(restored);
commitPublicAppearance(restored.public);
Object.values(inputRefs).forEach((ref) => {
if (ref.current) ref.current.value = "";
});
message.success("已恢复影策默认品牌标识");
} catch (error) {
message.error(error instanceof Error ? error.message : "恢复默认外观失败");
throw error;
} finally {
setRestoring(false);
}
},
});
};
const save = async () => {
if (!setting || saving || restoring || canvasUploading) return;
const updatesError = validateUpdateAnnouncement(updates);
if (updatesError) {
setActiveTab("updates");
message.error(updatesError);
return;
}
if (![canvas.agentName, canvas.panelTitle, canvas.welcomeTitle, canvas.inputPlaceholder].every((text) => text.trim())) {
setActiveTab("canvas");
message.error("请填写助手名称、面板标题、欢迎标题和输入框提示");
return;
}
const nextBrandName = brandName.trim();
const nextBrandSlug = brandSlug.trim().toLocaleLowerCase();
const nextAuthHeroTitle = normalizeDraftCopy(authHeroTitle);
const nextAuthHeroDescription = normalizeDraftCopy(authHeroDescription);
const nextSeoTitle = normalizeSingleLine(seoTitle);
const nextSeoDescription = normalizeDraftCopy(seoDescription);
const nextSeoKeywords = normalizeSingleLine(seoKeywords);
const nextFooterCopyright = normalizeSingleLine(footerCopyright);
const nextIcpFilingNumber = normalizeSingleLine(icpFilingNumber);
if (!nextBrandName || Array.from(nextBrandName).length > 40) {
setActiveTab("brand");
message.error("品牌名称必须为 1 到 40 个字符");
return;
}
if (!/^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/.test(nextBrandSlug)) {
setActiveTab("brand");
message.error("英文品牌标识须为 1 到 48 位小写字母、数字或连字符");
return;
}
if (!nextAuthHeroTitle || Array.from(nextAuthHeroTitle).length > 80 || hasUnsupportedControlCharacter(nextAuthHeroTitle)) {
setActiveTab("auth");
message.error("登录页主标题必须为 1 到 80 个字符,可使用换行");
return;
}
if (Array.from(nextAuthHeroDescription).length > 160 || hasUnsupportedControlCharacter(nextAuthHeroDescription)) {
setActiveTab("auth");
message.error("登录页说明文案不能超过 160 个字符,可使用换行");
return;
}
for (const [value, label, max, tab] of [
[nextSeoTitle, "SEO 标题", 70, "seo"],
[nextSeoDescription, "SEO 描述", 200, "seo"],
[nextSeoKeywords, "SEO 关键词", 300, "seo"],
[nextFooterCopyright, "版权信息", 160, "footer"],
[nextIcpFilingNumber, "备案号", 64, "footer"],
] as const) {
if (Array.from(value).length > max || hasUnsupportedControlCharacter(value)) {
setActiveTab(tab);
message.error(`${label}不能超过 ${max} 个字符,且不能包含控制字符`);
return;
}
}
if (icpFilingEnabled && !nextIcpFilingNumber) {
setActiveTab("footer");
message.error("显示备案号前请先填写备案号");
return;
}
const skinError = validateSkinDrafts(skinThemes, skinId);
if (skinError) {
setActiveTab("skins");
message.error(skinError);
return;
}
setSaving(true);
const uploadedIDs: string[] = [];
try {
const ids: Record<AppearanceAssetSlot, string> = {
logo: resets.logo ? "" : setting.logoResourceId,
"logo-dark": resets["logo-dark"] ? "" : setting.darkLogoResourceId,
video: resets.video ? "" : setting.authVideoResourceId,
poster: resets.poster ? "" : setting.authVideoPosterResourceId,
};
for (const slot of ["logo", "logo-dark", "video", "poster"] as AppearanceAssetSlot[]) {
const file = files[slot];
if (!file) continue;
const resource = await uploadAppearanceAsset(slot, file);
uploadedIDs.push(resource.id);
ids[slot] = resource.id;
}
const updated = await updateAdminAppearance({
canvas,
updates,
brandName: nextBrandName,
brandSlug: nextBrandSlug,
authHeroTitle: nextAuthHeroTitle,
authHeroDescription: nextAuthHeroDescription,
logoResourceId: ids.logo,
darkLogoResourceId: ids["logo-dark"],
logoFrameEnabled,
authVideoResourceId: ids.video,
authVideoPosterResourceId: ids.poster,
authVideoAutoplay,
skinId,
skinThemes,
seoTitle: nextSeoTitle,
seoDescription: nextSeoDescription,
seoKeywords: nextSeoKeywords,
footerCopyright: nextFooterCopyright,
icpFilingEnabled,
icpFilingNumber: nextIcpFilingNumber,
});
applySetting(updated);
commitPublicAppearance(updated.public);
Object.values(inputRefs).forEach((ref) => {
if (ref.current) ref.current.value = "";
});
message.success("站点及外观配置已保存并立即生效");
} catch (error) {
const detail = error instanceof Error ? error.message : "保存外观配置失败";
if (uploadedIDs.length) {
try {
await deleteAdminResources(uploadedIDs);
} catch {
message.warning("保存失败,且临时上传文件未能自动清理;可在存储资源中检查");
}
}
message.error(detail);
} finally {
setSaving(false);
}
};
const previews = useAppearancePreviews(setting, files, resets);
const lightLogoSelected = Boolean(files.logo || (!resets.logo && setting?.logoResourceId));
const darkLogoSelected = Boolean(files["logo-dark"] || (!resets["logo-dark"] && setting?.darkLogoResourceId));
const status = setting?.configured ? <AdminStatusBadge label="已自定义" tone="success" /> : <AdminStatusBadge label="使用原始外观" tone="neutral" />;
const copyCustomized = normalizeDraftCopy(authHeroTitle) !== DEFAULT_PUBLIC_APPEARANCE.authHeroTitle || normalizeDraftCopy(authHeroDescription) !== DEFAULT_PUBLIC_APPEARANCE.authHeroDescription;
const draftBrandName = brandName.trim() || "站点名称";
const selectedSkin = skinThemes.find((skin) => skin.id === skinId) || skinThemes[0] || DEFAULT_CLASSIC_SKIN;
const changeSkin = (next: SkinDefinition) => {
if (next.locked) return;
setSkinThemes((current) => current.map((theme) => (theme.id === next.id ? next : theme)));
};
const duplicateSkin = (sourceID: string) => {
if (skinThemes.length >= 16) return;
const source = skinThemes.find((theme) => theme.id === sourceID) || DEFAULT_CLASSIC_SKIN;
const copy = duplicateSkinDefinition(
source,
skinThemes.map((theme) => theme.id),
);
setSkinThemes((current) => [...current, copy]);
setSkinId(copy.id);
};
const deleteSkin = (targetID: string) => {
const target = skinThemes.find((theme) => theme.id === targetID);
if (!target || target.locked) return;
modal.confirm({
title: `删除主题“${target.name}”?`,
content: "删除会随本页其他调整一起保存;保存前仍可点击“撤销调整”恢复。若它当前启用,将自动切回经典黑白。",
okText: "删除主题",
cancelText: "取消",
okButtonProps: { danger: true },
onOk: () => {
setSkinThemes((current) => current.filter((theme) => theme.id !== targetID));
if (skinId === targetID) setSkinId("classic");
},
});
};
return (
<AdminPageFrame title="站点及外观" description="统一管理品牌身份、登录页、搜索信息、备案展示与全站皮肤主题" scroll>
{loading ? (
<AppearanceSkeleton />
) : loadError || !setting ? (
<div className="admin-settings-stack admin-appearance-settings">
<div className="admin-appearance-load-error" role="alert">
<span className="admin-appearance-load-error-icon">
<Palette className="size-5" aria-hidden="true" />
</span>
<div>
<h2>无法读取外观配置</h2>
<p>{loadError || "当前没有可显示的配置,请稍后重试。"}</p>
</div>
<Button icon={<RefreshCw className="size-4" />} onClick={() => void load(true)}>
重新读取
</Button>
</div>
</div>
) : (
<div className="admin-settings-stack admin-appearance-settings">
<div className={cn("admin-appearance-command-bar", dirty && "is-dirty")}>
<div className="admin-appearance-command-copy" aria-live="polite">
<div>
<div className="flex flex-wrap items-center gap-2">
<strong>{dirty ? "站点配置有调整待保存" : "站点及外观已与服务端同步"}</strong>
<AdminStatusBadge label={dirty ? "尚未生效" : "服务端当前值"} tone={dirty ? "warning" : "neutral"} />
</div>
<p>{dirty ? "切换分类保留草稿;保存修改会一次应用品牌、登录页、更新公告、SEO、备案和皮肤调整。" : "按分类管理站点配置;欢迎页开关独立即时保存,其余修改统一保存。"}</p>
</div>
</div>
<div className="admin-appearance-command-actions">
{dirty ? (
<Button icon={<Undo2 className="size-4" />} disabled={saving || restoring || canvasUploading} onClick={discardDraft}>
撤销调整
</Button>
) : null}
<Button icon={<RotateCcw className="size-4" />} loading={restoring} disabled={!setting.configured || saving || refreshing || canvasUploading} onClick={restoreBuiltInAppearance}>
恢复影策默认
</Button>
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} disabled={saving || restoring || canvasUploading} onClick={requestRefresh}>
刷新状态
</Button>
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || refreshing || restoring || canvasUploading} onClick={() => void save()}>
保存修改
</Button>
</div>
</div>
<Tabs
className="admin-appearance-tabs"
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: "brand",
label: "品牌识别",
children: (
<SettingsSectionCard
className="admin-appearance-section admin-appearance-brand-section"
icon={<Palette className="size-4" aria-hidden="true" />}
title="品牌识别"
description="中文品牌名用于主要界面,英文品牌标识用于英文角标和可安全品牌化的路径建议;不会改动代码包、数据库或部署标识。"
status={status}
>
<div className="admin-appearance-brand-layout">
<Form className="admin-appearance-form" layout="vertical" requiredMark={false} disabled={saving || refreshing || restoring}>
<Form.Item label="品牌名称" extra="1–40 个字符。保存后同步到登录页、工作台、管理后台与浏览器标题。">
<Input value={brandName} maxLength={40} showCount placeholder="输入品牌名称" onChange={(event) => setBrandName(event.target.value)} />
</Form.Item>
<Form.Item label="英文品牌标识" extra="1–48 位小写字母、数字或连字符,例如 hima-studio。对象存储可一键采用该值作为路径前缀。">
<Input
value={brandSlug}
maxLength={48}
showCount
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="例如:hima-studio"
onChange={(event) => setBrandSlug(event.target.value.toLocaleLowerCase().replace(/[^a-z0-9-]/g, ""))}
/>
</Form.Item>
</Form>
<div className="admin-appearance-brand-logo admin-appearance-logo-stack">
<AssetPicker
slot="logo"
title="浅色模式 Logo"
description="用于浅色界面;建议透明背景 PNG 或 WebP。"
configured={Boolean(setting.logoResourceId) && !resets.logo}
file={files.logo}
inputRef={inputRefs.logo}
onSelect={selectFile}
onReset={resetAsset}
disabled={saving || refreshing || restoring}
emptyLabel={darkLogoSelected ? "将自动复用深色模式 Logo" : "未上传时使用项目原始 Logo"}
/>
<AssetPicker
slot="logo-dark"
title="深色模式 Logo(可选)"
description="用于深色界面;不上传时自动复用浅色模式 Logo。"
configured={Boolean(setting.darkLogoResourceId) && !resets["logo-dark"]}
file={files["logo-dark"]}
inputRef={inputRefs["logo-dark"]}
onSelect={selectFile}
onReset={resetAsset}
disabled={saving || refreshing || restoring}
emptyLabel={lightLogoSelected ? "将自动复用浅色模式 Logo" : "未上传时使用项目原始 Logo"}
/>
<div className="admin-appearance-logo-frame-option">
<div className="admin-appearance-logo-frame-copy">
<strong>禁用 Logo 后面的圆角矩形外框</strong>
<p id="appearance-logo-frame-help">开启后,工作台和管理后台导航只显示 Logo 本身;Logo 会等比放大到原外框的可用区域,导航占位保持不变。</p>
</div>
<div className="admin-appearance-logo-frame-control">
<span>{logoFrameEnabled ? "保留外框" : "已禁用外框"}</span>
<Switch
checked={!logoFrameEnabled}
disabled={saving || refreshing || restoring}
aria-label="禁用 Logo 后面的圆角矩形外框"
aria-describedby="appearance-logo-frame-help"
onChange={(checked) => setLogoFrameEnabled(!checked)}
/>
</div>
</div>
<div className="admin-appearance-logo-preview-grid" aria-label="深浅模式 Logo 预览">
<LogoThemePreview label="浅色界面" icon={<Sun />} src={previews.logoLight} dark={false} frameEnabled={logoFrameEnabled} />
<LogoThemePreview label="深色界面" icon={<Moon />} src={previews.logoDark} dark frameEnabled={logoFrameEnabled} />
</div>
</div>
</div>
</SettingsSectionCard>
),
},
{
key: "auth",
label: "登录页",
destroyOnHidden: true,
children: (
<SettingsSectionCard
className="admin-appearance-section admin-appearance-auth-section"
icon={<MonitorPlay className="size-4" aria-hidden="true" />}
title="登录页内容与媒体"
description="登录、注册与找回密码共享左侧品牌文案和影片;更换视频会同时取消旧封面,避免品牌串帧。"
status={
<AdminStatusBadge
label={copyCustomized || setting.authVideoResourceId || setting.authVideoPosterResourceId ? "已配置" : "使用原始内容"}
tone={copyCustomized || setting.authVideoResourceId || setting.authVideoPosterResourceId ? "success" : "neutral"}
/>
}
>
<div className="admin-appearance-auth-layout">
<div className="admin-appearance-auth-controls">
<Form className="admin-appearance-form" layout="vertical" requiredMark={false} disabled={saving || refreshing || restoring}>
<Form.Item label="登录页主标题" extra="1–80 个字符,可换行;登录、注册和找回密码页面共用。">
<Input.TextArea value={authHeroTitle} maxLength={80} showCount autoSize={{ minRows: 2, maxRows: 4 }} placeholder="输入登录页主标题" onChange={(event) => setAuthHeroTitle(event.target.value)} />
</Form.Item>
<Form.Item label="登录页说明文案(可选)" extra="最多 160 个字符,可换行;留空时不显示说明。">
<Input.TextArea
value={authHeroDescription}
maxLength={160}
showCount
autoSize={{ minRows: 2, maxRows: 5 }}
placeholder="补充一句品牌介绍或产品定位"
onChange={(event) => setAuthHeroDescription(event.target.value)}
/>
</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="品牌视频"
description="支持 MP4 或 WebM,最多 256MB;服务器运行时上传配额仍会同时生效。"
configured={Boolean(setting.authVideoResourceId) && !resets.video}
file={files.video}
inputRef={inputRefs.video}
onSelect={selectFile}
onReset={resetAsset}
disabled={saving || refreshing || restoring}
/>
<AssetPicker
slot="poster"
title="视频封面(可选)"
description="自定义视频未设置封面时保持中性背景,不显示原品牌封面。"
configured={Boolean(setting.authVideoPosterResourceId) && !resets.poster}
file={files.poster}
inputRef={inputRefs.poster}
onSelect={selectFile}
onReset={resetAsset}
disabled={saving || refreshing || restoring}
/>
</div>
</div>
<div className="admin-appearance-preview" aria-label="登录页实时预览">
<div className="admin-appearance-preview-heading">
<div>
<span>登录页实时预览</span>
<strong>{brandName.trim() || "未命名品牌"}</strong>
</div>
<AdminStatusBadge label={dirty ? "未保存" : "线上版本"} tone={dirty ? "warning" : "success"} />
</div>
<div className="admin-appearance-preview-stage">
<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="" />
<strong>{brandName.trim() || "未命名品牌"}</strong>
</span>
<span className="admin-appearance-preview-copy">
<small>{(brandSlug.trim() || "brand-studio").replace(/-+/g, " ").toLocaleUpperCase()}</small>
<strong>{normalizeDraftCopy(authHeroTitle) || "请输入登录页主标题"}</strong>
{normalizeDraftCopy(authHeroDescription) ? <span>{normalizeDraftCopy(authHeroDescription)}</span> : null}
</span>
</div>
<p>预览与正式登录页使用同一文案和媒体。正式页面会先解析公开配置,再渲染品牌内容。</p>
</div>
</div>
</SettingsSectionCard>
),
},
{
key: "welcome",
label: "欢迎页",
children: (
<SettingsSectionCard className="admin-appearance-section" icon={<Globe2 className="size-4" aria-hidden="true" />} title="欢迎页" description="控制访客是否可以访问欢迎页,开关修改后立即保存。">
<div className="admin-appearance-section-form">
<WelcomeSetting />
</div>
</SettingsSectionCard>
),
},
{
key: "seo",
label: "SEO 信息",
children: (
<SettingsSectionCard
className="admin-appearance-section"
icon={<Search className="size-4" aria-hidden="true" />}
title="SEO 信息"
description="配置浏览器标题、搜索摘要和关键词;留空时标题与描述会自动跟随当前站点名称。"
status={<AdminStatusBadge label={seoTitle || seoDescription || seoKeywords ? "已自定义" : "自动跟随品牌"} tone={seoTitle || seoDescription || seoKeywords ? "success" : "neutral"} />}
>
<Form className="admin-appearance-form admin-appearance-section-form" layout="vertical" requiredMark={false} disabled={saving || refreshing || restoring}>
<div className="admin-appearance-form-grid">
<Form.Item label="SEO 标题" extra="建议简洁描述站点用途;留空时使用站点名称。">
<Input value={seoTitle} maxLength={70} showCount placeholder={draftBrandName} onChange={(event) => setSeoTitle(event.target.value)} />
</Form.Item>
<Form.Item label="SEO 关键词" extra="以逗号分隔;Google 不使用该标签排名,仍可供其他工具读取。">
<Input value={seoKeywords} maxLength={300} showCount placeholder="AI 影视, AI 短剧, 创作工作台" onChange={(event) => setSeoKeywords(event.target.value)} />
</Form.Item>
</div>
<Form.Item label="SEO 描述" extra="建议用一到两句话准确概括站点,避免重复堆砌关键词;留空时自动生成品牌描述。">
<Input.TextArea
value={seoDescription}
maxLength={200}
showCount
autoSize={{ minRows: 3, maxRows: 5 }}
placeholder={`${draftBrandName},面向 AI 影视与短剧创作的工作台。`}
onChange={(event) => setSeoDescription(event.target.value)}
/>
</Form.Item>
</Form>
</SettingsSectionCard>
),
},
{
key: "footer",
label: "页尾与备案",
children: (
<SettingsSectionCard
className="admin-appearance-section"
icon={<Globe2 className="size-4" aria-hidden="true" />}
title="首页页尾与备案"
description="配置公开登录首页底部的版权和备案信息;备案号启用后固定链接工信部备案管理系统。"
status={<AdminStatusBadge label={icpFilingEnabled ? "展示备案号" : "未展示备案号"} tone={icpFilingEnabled ? "success" : "neutral"} />}
>
<div className="admin-appearance-section-form admin-appearance-footer-settings">
<Form className="admin-appearance-form" layout="vertical" requiredMark={false} disabled={saving || refreshing || restoring}>
<Form.Item label="版权信息" extra="留空时自动使用当前年份和站点名称。">
<Input
prefix={<Copyright className="size-3.5" aria-hidden="true" />}
value={footerCopyright}
maxLength={160}
showCount
placeholder={`© ${new Date().getFullYear()} ${draftBrandName}. All rights reserved.`}
onChange={(event) => setFooterCopyright(event.target.value)}
/>
</Form.Item>
<Form.Item label="备案号" extra="请填写真实备案号,例如“蜀ICP备XXXXXXXX号”;系统不会替你申请或核验备案。">
<Input value={icpFilingNumber} maxLength={64} showCount placeholder="例如:蜀ICP备XXXXXXXX号" onChange={(event) => setIcpFilingNumber(event.target.value)} />
</Form.Item>
</Form>
<div className="admin-appearance-logo-frame-option">
<div className="admin-appearance-logo-frame-copy">
<strong>在首页底部显示备案号</strong>
<p id="appearance-icp-help">启用后,备案号会显示在登录、注册和找回密码页底部,并链接至 https://beian.miit.gov.cn/ 供公众查询。</p>
</div>
<div className="admin-appearance-logo-frame-control">
<span>{icpFilingEnabled ? "已显示" : "未显示"}</span>
<Switch checked={icpFilingEnabled} disabled={saving || refreshing || restoring} aria-label="在首页底部显示备案号" aria-describedby="appearance-icp-help" onChange={setIcpFilingEnabled} />
</div>
</div>
</div>
</SettingsSectionCard>
),
},
{
key: "skins",
label: "皮肤主题",
children: (
<SettingsSectionCard
className="admin-appearance-section"
icon={<Type className="size-4" aria-hidden="true" />}
title="皮肤主题"
description="经典黑白采用黑白底色与紫蓝渐变主按钮;复制后可调整浅色、深色、按钮填充及控件参数,所有修改统一保存。"
status={<AdminStatusBadge label={selectedSkin.name} tone="info" />}
>
<SkinThemeEditor
themes={skinThemes}
selectedID={skinId}
disabled={saving || refreshing || restoring}
onSelect={setSkinId}
onCreate={() => duplicateSkin("classic")}
onDuplicate={duplicateSkin}
onDelete={deleteSkin}
onChange={changeSkin}
/>
</SettingsSectionCard>
),
},
{ key: "canvas", label: "画布配置", children: <CanvasAppearanceEditor value={canvas} onChange={setCanvas} disabled={saving || refreshing || restoring || canvasUploading} onUploading={setCanvasUploading} /> },
{ key: "updates", label: "更新公告", children: <UpdateAnnouncementEditor value={updates} onChange={setUpdates} disabled={saving || refreshing || restoring || canvasUploading} onUploading={setCanvasUploading} /> },
]}
/>
</div>
)}
</AdminPageFrame>
);
}