Files
jiang13-bbs/frontend/lib/theme.ts
freefire 24c6dea38c feat: 完成头像上传、站点主题配置与UI优化
- 新增附件模型与上传服务,拆分头像上传/管理逻辑
- 重构用户资料接口,移除直接修改头像字段,改用专用上传接口
- 添加站点公开设置API与管理员外观配置页
- 全局替换hover背景为accent-soft,优化交互一致性
- 完善头像组件,支持图片加载失败回退与src更新重置
- 新增用户状态事件总线,解决同页头像更新不及时问题
- 调整依赖包顺序与CI依赖版本
- 重构板块列表接口,新增帖子计数
- 迁移账号设置到用户主页tab,合并重复代码
2026-09-15 01:06:12 +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;
el.setAttribute("data-accent-source", "runtime");
document.head.appendChild(el);
}
el.textContent = css;
}