Files
jiang13-bbs/frontend/lib/theme.ts
freefire 29db9ae9b5 feat(frontend): add theme real-time sync feature
1. 新增ThemeSync组件实现站点主题色无刷新同步
2. 给获取站点设置的接口添加no-store缓存策略确保拿到最新配置
3. 调整主题应用代码的属性设置顺序避免逻辑问题
2026-09-15 05:04:25 +08:00

244 lines
8.2 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", "#0f1115", "#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 令牌);
// 未配置或等于默认色时返回空串,完全使用内置样式
export function accentStyleCSS(input: string): string {
if (!input || input.toLowerCase() === DEFAULT_ACCENT) return "";
const { light, dark } = deriveAccentPair(input);
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:${rgba(light, 0.1)};`,
`--nav-active-ink:${light};`,
`--nav-active-line:${rgba(light, 0.22)};`,
`}`,
`.dark{`,
`--accent:${dark};`,
`--accent-hi:${darkHi};`,
`--accent-soft:${rgba(dark, 0.16)};`,
`--accent-on:#10162b;`,
`--nav-active-bg:${rgba(dark, 0.14)};`,
`--nav-active-ink:${darkHi};`,
`--nav-active-line:${rgba(dark, 0.3)};`,
`}`,
].join("");
}
// ---------------------------------------------------------------
// 客户端:实时换肤(后台预览 / 保存后即时生效)
// ---------------------------------------------------------------
const STYLE_ELEMENT_ID = "j13-accent-vars";
// 将主题色应用到当前文档;传 null 移除覆盖、恢复内置默认
export function applyAccentTheme(input: string | null): void {
if (typeof document === "undefined") return;
let el = document.getElementById(STYLE_ELEMENT_ID) as HTMLStyleElement | null;
const css = input === null ? "" : accentStyleCSS(input);
if (!css) {
el?.remove();
return;
}
if (!el) {
el = document.createElement("style");
el.id = STYLE_ELEMENT_ID;
document.head.appendChild(el);
}
el.setAttribute("data-accent-source", "runtime");
el.textContent = css;
}