161 lines
5.2 KiB
TypeScript
161 lines
5.2 KiB
TypeScript
// 站点背景图(前台 / 后台分轨):仅接受本站 /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<BgMode>(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;
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|