feat: 完成头像上传、站点主题配置与UI优化
- 新增附件模型与上传服务,拆分头像上传/管理逻辑 - 重构用户资料接口,移除直接修改头像字段,改用专用上传接口 - 添加站点公开设置API与管理员外观配置页 - 全局替换hover背景为accent-soft,优化交互一致性 - 完善头像组件,支持图片加载失败回退与src更新重置 - 新增用户状态事件总线,解决同页头像更新不及时问题 - 调整依赖包顺序与CI依赖版本 - 重构板块列表接口,新增帖子计数 - 迁移账号设置到用户主页tab,合并重复代码
This commit is contained in:
243
frontend/lib/theme.ts
Normal file
243
frontend/lib/theme.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
// 站点主题色工具库(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;
|
||||
}
|
||||
Reference in New Issue
Block a user