Files
jiang13-bbs/frontend/lib/theme.ts
freefire 5f7193042f feat: 外观支持自定义 CSS/JS,并升级发帖工作台与积分存量看板
后台可注入全站 CSS/JS(SSR 生效,CSS 可热换);发帖/编辑改为锁一屏工作室;经济看板增加存量健康度;主题令牌与弹层误关一并收紧。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 04:16:32 +08:00

271 lines
9.3 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.
// 站点主题色工具库(SSR / 客户端同构)
//
// 设计:
// - 后端只存一个浅色基准 hex(site_settings.theme_accent,空=默认靛蓝)。
// - 所有 accent 相关 CSS 变量由本模块从该 hex 派生:浅色一套、暗色一套。
// - 自定义色经 WCAG AA 自校正(相对白色 / 暗面板对比度 ≥4.5),保证选中底、
// 按钮白字、暗色文字在任何主题色下都可读,不需要管理员具备配色知识。
// - 派生变量通过 SSR 内联 <style> 注入,覆盖 globals.css 中的默认值,无 FOUC。
// ---------------------------------------------------------------
// 预设色板(成对调校:浅色已验证对白色 ≥4.5,暗色对 #181b21/#20242c ≥4.5)
// ---------------------------------------------------------------
export interface AccentPreset {
key: string;
name: string;
light: string;
dark: string;
}
// 内置默认(与 globals.css 默认值一致,后端未配置时不注入覆盖样式)
export const DEFAULT_ACCENT = "#3b5bdb";
export const ACCENT_PRESETS: AccentPreset[] = [
{ key: "indigo", name: "靛蓝", light: "#3b5bdb", dark: "#8ea2ff" },
{ key: "emerald", name: "翡翠绿", light: "#05875f", dark: "#34d399" },
{ key: "violet", name: "紫罗兰", light: "#6d28d9", dark: "#a78bfa" },
{ key: "orange", name: "赤陶橙", light: "#c2410c", dark: "#fb923c" },
{ key: "rose", name: "玫红", light: "#be123c", dark: "#fb7185" },
{ key: "sky", name: "天青蓝", light: "#0369a1", dark: "#38bdf8" },
];
// 按浅色 hex 找对应预设(自定义色返回 null)
export function findPreset(hex: string): AccentPreset | null {
const h = hex.toLowerCase();
return ACCENT_PRESETS.find((p) => p.light === h) ?? null;
}
// ---------------------------------------------------------------
// 颜色数学
// ---------------------------------------------------------------
type RGB = [number, number, number];
type HSL = [number, number, number];
function hexToRgb(hex: string): RGB {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgbToHex([r, g, b]: RGB): string {
return (
"#" +
[r, g, b]
.map((v) => Math.round(v).toString(16).padStart(2, "0"))
.join("")
);
}
// 校验并归一化为 #rrggbb;非法返回 null(支持省略 # 与 3 位简写)
export function normalizeHex(input: string): string | null {
let s = input.trim().toLowerCase();
if (!s) return null;
if (!s.startsWith("#")) s = "#" + s;
if (/^#[0-9a-f]{3}$/.test(s)) {
s = "#" + s[1].repeat(2) + s[2].repeat(2) + s[3].repeat(2);
}
return /^#[0-9a-f]{6}$/.test(s) ? s : null;
}
function srgbLin(c: number): number {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
function luminance([r, g, b]: RGB): number {
return 0.2126 * srgbLin(r) + 0.7152 * srgbLin(g) + 0.0722 * srgbLin(b);
}
// WCAG 对比度(hex 对 hex)
export function contrastRatio(a: string, b: string): number {
const la = luminance(hexToRgb(a));
const lb = luminance(hexToRgb(b));
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
function rgbToHsl([r, g, b]: RGB): HSL {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
}
return [h, s, l];
}
function hslToRgb([h, s, l]: HSL): RGB {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r = 0;
let g = 0;
let b = 0;
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
}
// 与指定颜色混合:weight=1 返回目标色,weight=0 返回基色
function mix(from: string, toward: string, weight: number): string {
const a = hexToRgb(from);
const b = hexToRgb(toward);
return rgbToHex([
a[0] + (b[0] - a[0]) * weight,
a[1] + (b[1] - a[1]) * weight,
a[2] + (b[2] - a[2]) * weight,
]);
}
function rgba(hex: string, alpha: number): string {
const [r, g, b] = hexToRgb(hex);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
// ---------------------------------------------------------------
// AA 自校正
// ---------------------------------------------------------------
// 浅色模式基准色:相对白色对比度不足(多为粉彩/荧光)时逐步降亮度至 ≥4.5
function clampForLight(hex: string): string {
const [h, s, lRaw] = rgbToHsl(hexToRgb(hex));
let l = lRaw;
let cur = hex;
for (let i = 0; i < 120; i++) {
cur = rgbToHex(hslToRgb([h, s, l]));
if (contrastRatio(cur, "#ffffff") >= 4.5) return cur;
l -= 0.01;
}
return cur;
}
// 暗色模式伴随色:同色相提亮,至对暗面板与深色压字全部 ≥4.5
function deriveDark(hex: string): string {
const [h, s, lRaw] = rgbToHsl(hexToRgb(hex));
let l = Math.max(lRaw, 0.62);
const surfaces = ["#181b21", "#101211", "#10162b"];
let cur = hex;
for (let i = 0; i < 120; i++) {
cur = rgbToHex(hslToRgb([h, s, l]));
if (surfaces.every((sf) => contrastRatio(cur, sf) >= 4.5)) return cur;
l = Math.min(0.86, l + 0.01);
}
return cur;
}
// ---------------------------------------------------------------
// 派生 CSS 变量
// ---------------------------------------------------------------
export interface AccentDerivation {
light: string; // 校正后的浅色基准
dark: string; // 暗色模式伴随色
adjusted: boolean; // 自定义色是否经过自动校正(UI 可提示)
}
// 从任意输入 hex 派生浅/暗两套主题色;预设色直接采用调校好的成对值
export function deriveAccentPair(input: string): AccentDerivation {
const hex = normalizeHex(input);
if (!hex) {
return { light: DEFAULT_ACCENT, dark: ACCENT_PRESETS[0].dark, adjusted: false };
}
const preset = findPreset(hex);
if (preset) return { light: preset.light, dark: preset.dark, adjusted: false };
const light = clampForLight(hex);
return { light, dark: deriveDark(hex), adjusted: light !== hex };
}
// 生成可直接注入的 CSS 文本(覆盖 globals.css 默认 accent 令牌);
// 未配置或等于默认色时返回空串,完全使用内置样式(SSR 场景)
export function accentStyleCSS(input: string): string {
if (!input || input.toLowerCase() === DEFAULT_ACCENT) return "";
return buildAccentCSS(input);
}
/** 始终输出一套 accent 覆盖(含默认色),供客户端 runtime 盖住 SSR 自定义 style */
function buildAccentCSS(input: string): string {
const { light, dark } = deriveAccentPair(input || DEFAULT_ACCENT);
const hi = mix(light, "#000000", 0.1);
const darkHi = mix(dark, "#ffffff", 0.14);
return [
`:root{`,
`--accent:${light};`,
`--accent-hi:${hi};`,
`--accent-soft:${rgba(light, 0.1)};`,
`--accent-on:#ffffff;`,
`--nav-active-bg:var(--panel);`,
`--nav-active-ink:${light};`,
`--nav-active-line:${rgba(light, 0.38)};`,
`}`,
`.dark{`,
`--accent:${dark};`,
`--accent-hi:${darkHi};`,
`--accent-soft:${rgba(dark, 0.16)};`,
`--accent-on:#10162b;`,
`--nav-active-bg:var(--panel);`,
`--nav-active-ink:${darkHi};`,
`--nav-active-line:${rgba(dark, 0.38)};`,
`}`,
].join("");
}
// ---------------------------------------------------------------
// 客户端:实时换肤(后台预览 / 保存后即时生效)
// ---------------------------------------------------------------
/** SSR 注入的 style(React 托管,客户端禁止 remove/改写) */
const SSR_STYLE_ID = "j13-accent-vars";
/** 客户端专用覆盖节点,挂在 body 末尾以压过 SSR */
const RUNTIME_STYLE_ID = "j13-accent-runtime";
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-accent-source", "runtime");
document.body.appendChild(el);
return el;
}
function removeRuntimeStyle(): void {
const el = document.getElementById(RUNTIME_STYLE_ID);
// 只移除我们自己创建的节点,绝不碰 React 的 SSR <style>
el?.remove();
}
// 将主题色应用到当前文档;传 null/默认色时恢复内置令牌(不拆 SSR 节点)
export function applyAccentTheme(input: string | null): void {
if (typeof document === "undefined") return;
const raw = (input || "").trim().toLowerCase();
const isDefault = !raw || raw === DEFAULT_ACCENT.toLowerCase();
if (isDefault) {
const ssr = document.getElementById(SSR_STYLE_ID);
const ssrHasCustom = !!(ssr && ssr.textContent && ssr.textContent.trim());
if (ssrHasCustom) {
// SSR 仍挂着旧自定义色:用 runtime 写回默认覆盖,等 refresh 后再卸
ensureRuntimeStyle().textContent = buildAccentCSS(DEFAULT_ACCENT);
} else {
removeRuntimeStyle();
}
return;
}
ensureRuntimeStyle().textContent = buildAccentCSS(raw);
}