// 站点背景图(前台 / 后台分轨):仅接受本站 /uploads/backgrounds/ 下的文件。 // 铺放模式映射到 CSS background-*,真正绘制在 body::before 固定层上, // 避免 background-attachment:fixed 在移动端掉帧。 import type { CSSProperties } from "react"; export type BgSurface = "site" | "admin"; export type BgMode = "cover" | "contain" | "repeat" | "stretch"; export const DEFAULT_BG_MODE: BgMode = "cover"; /** 预览框按常见 PC 显示器 16:9(1920×1080)等比例缩放 */ export const BG_PREVIEW_ASPECT = "16 / 9"; export const BG_MODE_OPTIONS: { key: BgMode; label: string; hint: string }[] = [ { key: "cover", label: "填充", hint: "裁切铺满,适合照片" }, { key: "contain", label: "适应", hint: "整图可见,可能留边" }, { key: "repeat", label: "平铺", hint: "原尺寸重复,适合纹理" }, { key: "stretch", label: "拉伸", hint: "拉满屏幕,可能变形" }, ]; export interface SiteBgConfig { bg_site_url: string; bg_site_mode: BgMode; bg_admin_url: string; bg_admin_mode: BgMode; } export type SiteBgInput = { bg_site_url?: string | null; bg_site_mode?: string | null; bg_admin_url?: string | null; bg_admin_mode?: string | null; }; const BG_URL_RE = /^\/uploads\/backgrounds\/[0-9a-f]{32}\.(jpg|jpeg|png|webp)$/; const MODE_SET = new Set(BG_MODE_OPTIONS.map((o) => o.key)); const SSR_STYLE_ID = "j13-bg-vars"; const RUNTIME_STYLE_ID = "j13-bg-runtime"; export function normalizeBgURL(raw: string | null | undefined): string { const u = (raw || "").trim().toLowerCase(); return BG_URL_RE.test(u) ? u : ""; } export function normalizeBgMode(raw: string | null | undefined): BgMode { const m = (raw || "").trim().toLowerCase() as BgMode; return MODE_SET.has(m) ? m : DEFAULT_BG_MODE; } /** 媒体库条目是否可作为背景图(JPEG / PNG / WebP) */ export function mediaURLFitsBackground(url: string): boolean { return /\.(jpe?g|png|webp)(\?|$)/i.test(url); } export function bgConfigFromSettings(s: SiteBgInput | null | undefined): SiteBgConfig { return { bg_site_url: normalizeBgURL(s?.bg_site_url), bg_site_mode: normalizeBgMode(s?.bg_site_mode), bg_admin_url: normalizeBgURL(s?.bg_admin_url), bg_admin_mode: normalizeBgMode(s?.bg_admin_mode), }; } function modeCSS(mode: BgMode): { size: string; repeat: string; position: string } { switch (mode) { case "contain": return { size: "contain", repeat: "no-repeat", position: "center" }; case "repeat": return { size: "auto", repeat: "repeat", position: "0 0" }; case "stretch": return { size: "100% 100%", repeat: "no-repeat", position: "center" }; default: return { size: "cover", repeat: "no-repeat", position: "center" }; } } function cssUrl(url: string): string { // 已由 regex 锁死路径,仍去掉引号,避免意外打断 url("…") return `url("${url.replace(/["'\\]/g, "")}")`; } function surfaceVars(prefix: "site" | "admin", url: string, mode: BgMode): string { const m = modeCSS(mode); const image = url ? cssUrl(url) : "none"; return [ `--j13-bg-${prefix}-image:${image};`, `--j13-bg-${prefix}-size:${m.size};`, `--j13-bg-${prefix}-repeat:${m.repeat};`, `--j13-bg-${prefix}-position:${m.position};`, ].join(""); } /** SSR 内联:仅在至少一侧配置了图时输出 */ export function bgStyleCSS(cfg: SiteBgConfig): string { if (!cfg.bg_site_url && !cfg.bg_admin_url) return ""; return ( "html{" + surfaceVars("site", cfg.bg_site_url, cfg.bg_site_mode) + surfaceVars("admin", cfg.bg_admin_url, cfg.bg_admin_mode) + "}" ); } function ensureRuntimeStyle(): HTMLStyleElement { let el = document.getElementById(RUNTIME_STYLE_ID) as HTMLStyleElement | null; if (el) return el; el = document.createElement("style"); el.id = RUNTIME_STYLE_ID; el.setAttribute("data-bg-source", "runtime"); document.body.appendChild(el); return el; } function sameConfig(a: SiteBgConfig, b: SiteBgConfig): boolean { return ( a.bg_site_url === b.bg_site_url && a.bg_site_mode === b.bg_site_mode && a.bg_admin_url === b.bg_admin_url && a.bg_admin_mode === b.bg_admin_mode ); } let lastApplied: SiteBgConfig | null = null; /** 客户端热替换背景;禁止改写 SSR 节点 */ export function applySiteBackground(input: SiteBgInput | null): void { if (typeof document === "undefined") return; const cfg = bgConfigFromSettings(input); if (lastApplied && sameConfig(lastApplied, cfg)) return; lastApplied = cfg; const html = document.documentElement; html.classList.toggle("j13-has-site-bg", !!cfg.bg_site_url); html.classList.toggle("j13-has-admin-bg", !!cfg.bg_admin_url); const css = bgStyleCSS(cfg); if (!css) { const ssr = document.getElementById(SSR_STYLE_ID); if (ssr && ssr.textContent && ssr.textContent.trim()) { ensureRuntimeStyle().textContent = "html{" + surfaceVars("site", "", DEFAULT_BG_MODE) + surfaceVars("admin", "", DEFAULT_BG_MODE) + "}"; } else { document.getElementById(RUNTIME_STYLE_ID)?.remove(); } return; } ensureRuntimeStyle().textContent = css; } export function previewBoxStyle(url: string, mode: BgMode): CSSProperties { const m = modeCSS(mode); return { backgroundColor: "var(--bg)", backgroundImage: url ? cssUrl(url) : "none", backgroundSize: m.size, backgroundRepeat: m.repeat, backgroundPosition: m.position, }; }