feat(admin): 站点外观 - 支持 Tab 分类与主题渐变按钮配置
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { App, Button, Form, Input, Skeleton } from "antd";
|
||||
import { App, Button, Form, Input, Skeleton, Tabs } from "antd";
|
||||
import { Switch } from "@/pages/admin/ui/controls";
|
||||
import { Copyright, Globe2, Image as ImageIcon, MonitorPlay, Moon, Palette, RefreshCw, RotateCcw, Save, Search, Sun, Type, Undo2, Upload } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
@@ -7,7 +7,7 @@ import { useBlocker } from "react-router";
|
||||
import { AdminPageFrame } from "@/pages/admin/components/admin-shell";
|
||||
import { AdminStatusBadge, SettingsSectionCard } from "@/pages/admin/components/admin-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cloneSkinDefinition, DEFAULT_CLASSIC_SKIN, duplicateSkinDefinition, normalizeSkinDefinition, type SkinDefinition } from "@/lib/skin-themes";
|
||||
import { cloneSkinDefinition, DEFAULT_CLASSIC_SKIN, duplicateSkinDefinition, isSkinButtonFill, normalizeSkinDefinition, type SkinDefinition } from "@/lib/skin-themes";
|
||||
import { SkinThemeEditor } from "@/pages/admin/settings/components/skin-theme-editor";
|
||||
import { WelcomeSetting } from "@/pages/admin/settings/components/welcome-setting";
|
||||
import { deleteAdminResources } from "@/services/api/admin-storage";
|
||||
@@ -28,6 +28,7 @@ const FILE_RULES: Record<AppearanceAssetSlot, { accept: string; maxBytes: number
|
||||
|
||||
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 [brandSlug, setBrandSlug] = useState("");
|
||||
@@ -79,16 +80,17 @@ export default function AppearanceSettingsPage() {
|
||||
const blocker = useBlocker(dirty && !saving && !restoring);
|
||||
|
||||
const applySetting = useCallback((value: AdminAppearance) => {
|
||||
setSetting(value);
|
||||
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);
|
||||
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)]);
|
||||
setSkinId(themes.some((theme) => theme.id === value.skinId) ? value.skinId : "classic");
|
||||
setSkinThemes(themes);
|
||||
setSkinId(selectedID);
|
||||
setSeoTitle(value.seoTitle);
|
||||
setSeoDescription(value.seoDescription);
|
||||
setSeoKeywords(value.seoKeywords);
|
||||
@@ -268,39 +270,46 @@ export default function AppearanceSettingsPage() {
|
||||
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] of [
|
||||
[nextSeoTitle, "SEO 标题", 70],
|
||||
[nextSeoDescription, "SEO 描述", 200],
|
||||
[nextSeoKeywords, "SEO 关键词", 300],
|
||||
[nextFooterCopyright, "版权信息", 160],
|
||||
[nextIcpFilingNumber, "备案号", 64],
|
||||
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;
|
||||
}
|
||||
@@ -429,7 +438,7 @@ export default function AppearanceSettingsPage() {
|
||||
<strong>{dirty ? "站点配置有调整待保存" : "站点及外观已与服务端同步"}</strong>
|
||||
<AdminStatusBadge label={dirty ? "尚未生效" : "服务端当前值"} tone={dirty ? "warning" : "neutral"} />
|
||||
</div>
|
||||
<p>{dirty ? "品牌、SEO、备案、皮肤和媒体只在本页预览;保存后才会应用。" : "公开页面会在应用渲染前读取品牌与皮肤,不会先闪现旧站点身份。"}</p>
|
||||
<p>{dirty ? "切换分类保留草稿;保存修改会一次应用品牌、登录页、SEO、备案和皮肤调整。" : "按分类管理站点配置;欢迎页开关独立即时保存,其余修改统一保存。"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-appearance-command-actions">
|
||||
@@ -450,254 +459,303 @@ export default function AppearanceSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSectionCard
|
||||
className="admin-appearance-section admin-appearance-brand-section"
|
||||
icon={<Palette className="size-4" aria-hidden="true" />}
|
||||
title="1. 设置品牌识别"
|
||||
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>
|
||||
<WelcomeSetting />
|
||||
</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}
|
||||
<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}
|
||||
aria-label="禁用 Logo 后面的圆角矩形外框"
|
||||
aria-describedby="appearance-logo-frame-help"
|
||||
onChange={(checked) => setLogoFrameEnabled(!checked)}
|
||||
onSelect={setSkinId}
|
||||
onCreate={() => duplicateSkin("classic")}
|
||||
onDuplicate={duplicateSkin}
|
||||
onDelete={deleteSkin}
|
||||
onChange={changeSkin}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<SettingsSectionCard
|
||||
className="admin-appearance-section admin-appearance-auth-section"
|
||||
icon={<MonitorPlay className="size-4" aria-hidden="true" />}
|
||||
title="2. 设置登录页内容与媒体"
|
||||
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>
|
||||
|
||||
<SettingsSectionCard
|
||||
className="admin-appearance-section"
|
||||
icon={<Search className="size-4" aria-hidden="true" />}
|
||||
title="3. 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>
|
||||
|
||||
<SettingsSectionCard
|
||||
className="admin-appearance-section"
|
||||
icon={<Globe2 className="size-4" aria-hidden="true" />}
|
||||
title="4. 首页页尾与备案"
|
||||
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>
|
||||
|
||||
<SettingsSectionCard
|
||||
className="admin-appearance-section"
|
||||
icon={<Type className="size-4" aria-hidden="true" />}
|
||||
title="5. 皮肤主题"
|
||||
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>
|
||||
</SettingsSectionCard>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AdminPageFrame>
|
||||
@@ -831,6 +889,7 @@ function validateSkinDrafts(themes: SkinDefinition[], selectedID: string) {
|
||||
if (Array.from(theme.description.trim()).length > 100) return "皮肤主题说明不能超过 100 个字符";
|
||||
const invalidColor = [...Object.values(theme.tokens.light), ...Object.values(theme.tokens.dark)].some((color) => !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(color));
|
||||
if (invalidColor) return `主题“${theme.name}”存在无效颜色,请使用 6 或 8 位十六进制色值`;
|
||||
if (!isSkinButtonFill(theme.tokens.buttons.light) || !isSkinButtonFill(theme.tokens.buttons.dark)) return `主题“${theme.name}”的主按钮参数无效,请检查颜色和渐变角度(0–360°)`;
|
||||
if (theme.tokens.components.controlHeightSmall > theme.tokens.components.controlHeight || theme.tokens.components.controlHeight > theme.tokens.components.controlHeightLarge) return `主题“${theme.name}”的控件高度顺序无效`;
|
||||
if (theme.tokens.components.motionFast > theme.tokens.components.motionNormal) return `主题“${theme.name}”的快速动效不能慢于常规动效`;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { IconButton, SegmentedControl, Select, Tooltip } from "@/pages/admin/ui/
|
||||
import { Check, Copy, LockKeyhole, Moon, Plus, Sparkles, Sun, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { SKIN_COLOR_GROUPS, SKIN_COMPONENT_NUMBER_FIELDS, skinSwatches, type SkinComponentTokens, type SkinDefinition, type SkinModeTokens, type SkinThemeMode } from "@/lib/skin-themes";
|
||||
import { SKIN_COLOR_GROUPS, SKIN_COMPONENT_NUMBER_FIELDS, SKIN_BUTTON_COLOR_FIELDS, getSkinButtonAppearance, skinSwatches, type SkinButtonFill, type SkinComponentTokens, type SkinDefinition, type SkinModeTokens, type SkinThemeMode } from "@/lib/skin-themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AdminStatusBadge } from "@/pages/admin/components/admin-ui";
|
||||
|
||||
@@ -30,6 +30,10 @@ export function SkinThemeEditor({
|
||||
const selected = useMemo(() => themes.find((theme) => theme.id === selectedID) || themes[0], [selectedID, themes]);
|
||||
if (!selected) return null;
|
||||
const editorDisabled = disabled || selected.locked;
|
||||
const buttonFill = selected.tokens.buttons[mode];
|
||||
const updateButton = (patch: Partial<SkinButtonFill>) => onChange({ ...selected, tokens: { ...selected.tokens,
|
||||
buttons: { ...selected.tokens.buttons, [mode]: { ...buttonFill, ...patch } },
|
||||
} });
|
||||
|
||||
const updateIdentity = (patch: Partial<Pick<SkinDefinition, "name" | "description">>) => onChange({ ...selected, ...patch });
|
||||
const updateColor = (key: keyof SkinModeTokens, value: string) =>
|
||||
@@ -115,7 +119,7 @@ export function SkinThemeEditor({
|
||||
{selected.locked ? (
|
||||
<div className="admin-skin-locked-note">
|
||||
<LockKeyhole className="size-4" aria-hidden="true" />
|
||||
<span>经典黑白始终保留项目原始视觉,不能改名、修改或删除。点击“从默认新建”即可复制全部参数后自由调整。</span>
|
||||
<span>经典黑白采用黑白基础配色与紫蓝渐变主按钮,不能改名、修改或删除。点击“从默认新建”即可复制全部参数后自由调整。</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -164,6 +168,27 @@ export function SkinThemeEditor({
|
||||
|
||||
<SkinThemePreview theme={selected} mode={mode} />
|
||||
|
||||
<details className="admin-skin-token-group" open>
|
||||
<summary><span>主按钮填充</span><small>{mode === "light" ? "浅色" : "深色"}模式</small></summary>
|
||||
<div className="admin-skin-component-grid">
|
||||
<label className="admin-skin-number-field">
|
||||
<span>填充方式</span>
|
||||
<Select value={buttonFill.mode} disabled={editorDisabled}
|
||||
options={[{ label: "纯色(使用主操作颜色)", value: "solid" }, { label: "渐变", value: "gradient" }]}
|
||||
onChange={(value) => updateButton({ mode: value })} />
|
||||
</label>
|
||||
{buttonFill.mode === "gradient" ? <label className="admin-skin-number-field">
|
||||
<span>渐变角度</span>
|
||||
<InputNumber aria-label="渐变角度" value={buttonFill.angle} min={0} max={360} precision={0} addonAfter="°" disabled={editorDisabled}
|
||||
onChange={(value) => { if (typeof value === "number") updateButton({ angle: value }); }} />
|
||||
</label> : null}
|
||||
</div>
|
||||
{buttonFill.mode === "gradient" ? <div className="admin-skin-color-grid">
|
||||
{SKIN_BUTTON_COLOR_FIELDS.map(({ key, label }) => <ColorTokenField key={key} label={label} help="仅影响主按钮,不改变选中态、开关和危险操作" value={buttonFill[key]} disabled={editorDisabled}
|
||||
onChange={(value) => updateButton({ [key]: value.trim().toLowerCase() })} />)}
|
||||
</div> : null}
|
||||
</details>
|
||||
|
||||
<div className="admin-skin-color-groups">
|
||||
{SKIN_COLOR_GROUPS.map((group, index) => (
|
||||
<details key={group.key} className="admin-skin-token-group" open={index < 2}>
|
||||
@@ -240,6 +265,7 @@ function ColorTokenField({ label, help, value, disabled, onChange }: { label: st
|
||||
|
||||
function SkinThemePreview({ theme, mode }: { theme: SkinDefinition; mode: SkinThemeMode }) {
|
||||
const color = theme.tokens[mode];
|
||||
const button = getSkinButtonAppearance(theme, mode);
|
||||
const component = theme.tokens.components;
|
||||
const shadow = component.shadowStyle === "none" ? "none" : component.shadowStyle === "strong" ? "0 18px 44px #0000003d" : "0 10px 28px #00000024";
|
||||
return (
|
||||
@@ -264,7 +290,7 @@ function SkinThemePreview({ theme, mode }: { theme: SkinDefinition; mode: SkinTh
|
||||
>
|
||||
输入框状态
|
||||
</span>
|
||||
<span className="admin-skin-live-preview-button" style={{ height: component.controlHeight, background: color.primary, color: color.primaryForeground, borderRadius: component.buttonRadius, fontWeight: component.buttonFontWeight }}>
|
||||
<span className="admin-skin-live-preview-button" style={{ height: component.controlHeight, background: button.background, color: button.foreground, borderRadius: component.buttonRadius, fontWeight: component.buttonFontWeight }}>
|
||||
主要操作
|
||||
</span>
|
||||
<span className="admin-skin-live-preview-switches" aria-label="开关开启与关闭状态预览">
|
||||
|
||||
Reference in New Issue
Block a user