feat(themes): 添加可编辑的站点外观和皮肤主题(#408)

This commit is contained in:
Ray authored and GitHub committed 2026-09-03 21:13:41 +08:00
1 parent c768fccbc2
commit 5e66cb7c45
70 files changed
+3654 -327

No files matched your search

+63 -7
View File
@@ -1,9 +1,10 @@
import { create } from "zustand";
import type { PublicAppearance } from "@/services/api/appearance";
import { applySkinTheme, DEFAULT_CLASSIC_SKIN, normalizeSkinDefinition } from "@/lib/skin-themes";
export const DEFAULT_PUBLIC_APPEARANCE: PublicAppearance = {
schemaVersion: 3,
schemaVersion: 6,
brandName: "影策",
brandSlug: "open-ai-canvas",
authHeroTitle: "让一个故事,\n从文字走向银幕。",
@@ -14,6 +15,13 @@ export const DEFAULT_PUBLIC_APPEARANCE: PublicAppearance = {
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",
skinId: "classic",
activeSkin: DEFAULT_CLASSIC_SKIN,
seoTitle: "影策",
seoDescription: "影策,面向 AI 影视与短剧创作的工作台。",
seoKeywords: "",
footerCopyright: `© ${new Date().getFullYear()} 影策. All rights reserved.`,
icpFilingEnabled: false,
icpFilingNumber: "",
logoConfigured: false,
darkLogoConfigured: false,
authVideoConfigured: false,
@@ -42,11 +50,17 @@ export function normalizePublicAppearance(value?: Partial<PublicAppearance> | nu
const customVideo = Boolean(value?.authVideoConfigured);
const logoUrl = safeAppearanceURL(value?.logoUrl, DEFAULT_PUBLIC_APPEARANCE.logoUrl);
const darkLogoUrl = safeAppearanceURL(value?.darkLogoUrl, logoUrl);
const resolvedBrandName = brandName || DEFAULT_PUBLIC_APPEARANCE.brandName;
const seoTitle = normalizeAppearanceCopy(value?.seoTitle, resolvedBrandName);
const seoDescription = normalizeAppearanceCopy(value?.seoDescription, `${resolvedBrandName},面向 AI 影视与短剧创作的工作台。`, true);
const seoKeywords = normalizeAppearanceCopy(value?.seoKeywords, "", true);
const footerCopyright = normalizeAppearanceCopy(value?.footerCopyright, `© ${new Date().getFullYear()} ${resolvedBrandName}. All rights reserved.`);
const icpFilingNumber = normalizeAppearanceCopy(value?.icpFilingNumber, "", true);
return {
...DEFAULT_PUBLIC_APPEARANCE,
...value,
schemaVersion: 3,
brandName: brandName || DEFAULT_PUBLIC_APPEARANCE.brandName,
schemaVersion: 6,
brandName: resolvedBrandName,
brandSlug,
authHeroTitle,
authHeroDescription,
@@ -55,7 +69,14 @@ 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),
skinId: value?.skinId === "classic" ? value.skinId : DEFAULT_PUBLIC_APPEARANCE.skinId,
skinId: normalizeSkinDefinition(value?.activeSkin).id,
activeSkin: normalizeSkinDefinition(value?.activeSkin),
seoTitle,
seoDescription,
seoKeywords,
footerCopyright,
icpFilingEnabled: Boolean(value?.icpFilingEnabled && icpFilingNumber),
icpFilingNumber,
logoConfigured: Boolean(value?.logoConfigured),
darkLogoConfigured: Boolean(value?.darkLogoConfigured),
authVideoConfigured: customVideo,
@@ -74,15 +95,25 @@ function normalizeAppearanceCopy(value: unknown, fallback: string, allowEmpty =
export function commitPublicAppearance(value?: Partial<PublicAppearance> | null) {
const appearance = normalizePublicAppearance(value);
useAppearanceStore.getState().setAppearance(appearance);
applySkinTheme(appearance.activeSkin, typeof document !== "undefined" && document.documentElement.classList.contains("dark") ? "dark" : "light");
applyAppearanceMetadata(appearance);
return appearance;
}
export function applyAppearanceMetadata(appearance: PublicAppearance, targetDocument: Document | undefined = typeof document === "undefined" ? undefined : document) {
if (!targetDocument) return;
targetDocument.title = appearance.brandName;
const description = targetDocument.querySelector<HTMLMetaElement>('meta[name="description"]');
if (description) description.content = `${appearance.brandName},面向 AI 影视与短剧创作的工作台。`;
targetDocument.title = appearance.seoTitle || appearance.brandName;
setMeta(targetDocument, "name", "description", appearance.seoDescription);
setMeta(targetDocument, "name", "keywords", appearance.seoKeywords);
setMeta(targetDocument, "property", "og:title", appearance.seoTitle || appearance.brandName);
setMeta(targetDocument, "property", "og:description", appearance.seoDescription);
setMeta(targetDocument, "property", "og:site_name", appearance.brandName);
setMeta(targetDocument, "property", "og:type", "website");
setMeta(targetDocument, "name", "twitter:card", "summary");
setMeta(targetDocument, "name", "twitter:title", appearance.seoTitle || appearance.brandName);
setMeta(targetDocument, "name", "twitter:description", appearance.seoDescription);
const mode = targetDocument.documentElement.classList.contains("dark") ? "dark" : "light";
setMeta(targetDocument, "name", "theme-color", appearance.activeSkin.tokens[mode].canvas);
let favicon = targetDocument.querySelector<HTMLLinkElement>('link[rel~="icon"]');
if (!favicon) {
favicon = targetDocument.createElement("link");
@@ -90,6 +121,31 @@ export function applyAppearanceMetadata(appearance: PublicAppearance, targetDocu
targetDocument.head.appendChild(favicon);
}
favicon.href = appearanceLogoURL(appearance, targetDocument.documentElement.classList.contains("dark") ? "dark" : "light");
const location = targetDocument.defaultView?.location;
if (location && (location.protocol === "http:" || location.protocol === "https:")) {
let canonical = targetDocument.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!canonical) {
canonical = targetDocument.createElement("link");
canonical.rel = "canonical";
targetDocument.head.appendChild(canonical);
}
canonical.href = `${location.origin}${location.pathname}`;
}
}
function setMeta(targetDocument: Document, attribute: "name" | "property", key: string, content: string) {
let element = targetDocument.querySelector<HTMLMetaElement>(`meta[${attribute}="${key}"]`);
if (!content) {
element?.remove();
return;
}
if (!element) {
element = targetDocument.createElement("meta");
element.setAttribute(attribute, key);
targetDocument.head.appendChild(element);
}
element.content = content;
}
export function appearanceLogoURL(appearance: PublicAppearance, theme: "light" | "dark") {