feat: 完成头像上传、站点主题配置与UI优化

- 新增附件模型与上传服务,拆分头像上传/管理逻辑
- 重构用户资料接口,移除直接修改头像字段,改用专用上传接口
- 添加站点公开设置API与管理员外观配置页
- 全局替换hover背景为accent-soft,优化交互一致性
- 完善头像组件,支持图片加载失败回退与src更新重置
- 新增用户状态事件总线,解决同页头像更新不及时问题
- 调整依赖包顺序与CI依赖版本
- 重构板块列表接口,新增帖子计数
- 迁移账号设置到用户主页tab,合并重复代码
This commit is contained in:
2026-09-15 01:06:12 +08:00
parent f58b2af993
commit 24c6dea38c
48 changed files with 2513 additions and 432 deletions

View File

@@ -349,12 +349,28 @@ export async function fetchOverview(cookieHeader?: string): Promise<OverviewResp
return res.json();
}
export async function fetchBoards(): Promise<{ boards: Board[] }> {
export async function fetchBoards(): Promise<{ boards: BoardWithCount[] }> {
const res = await fetch(`${API_BASE}/api/boards`, ssrInit());
if (!res.ok) throw new Error("获取板块失败");
return res.json();
}
// 公开站点设置:accent 为站点主题色 hex(空串=内置默认)
export interface PublicSettings {
accent: string;
}
// SSR 读取公开站点设置(主题色注入用);后端不可用时降级为默认色,不阻塞渲染
export async function fetchPublicSettings(): Promise<PublicSettings> {
try {
const res = await fetch(`${API_BASE}/api/settings`, ssrInit());
if (!res.ok) return { accent: "" };
return (await res.json()) as PublicSettings;
} catch {
return { accent: "" };
}
}
export async function fetchPostDetail(id: string, cookieHeader?: string): Promise<{ post: Post }> {
const res = await fetch(`${API_BASE}/api/posts/${id}`, ssrInit(cookieHeaders(cookieHeader)));
if (!res.ok) throw new Error("获取帖子失败");
@@ -517,7 +533,6 @@ export async function apiChangePassword(oldPassword: string, newPassword: string
// 更新当前用户资料(昵称、头像、邮箱、签名)
export async function apiUpdateProfile(data: {
nickname: string;
avatar?: string;
email?: string;
signature?: string;
}): Promise<{ user?: User; error?: string }> {
@@ -529,6 +544,66 @@ export async function apiUpdateProfile(data: {
return res.json();
}
// 媒体库附件(avatar = 头像,image = 帖子插图等)
export interface MediaAttachment {
id: number;
url: string;
kind: "avatar" | "image";
size: number;
width: number;
height: number;
created_at: string;
}
// 上传裁剪后的头像(Blob 必须是 WebP;不要手动设置 Content-Type,交给浏览器加 multipart 边界)
export async function apiUploadAvatar(
file: Blob
): Promise<{ url?: string; error?: string }> {
const form = new FormData();
form.append("file", file, "avatar.webp");
const res = await fetchWithRefresh("/api/upload/avatar", {
method: "POST",
headers: clientHeaders(),
body: form,
});
return res.json();
}
// 选用一张本人历史上传的头像
export async function apiUseAvatar(
url: string
): Promise<{ url?: string; error?: string }> {
const res = await fetchWithRefresh("/api/avatar/use", {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ url }),
});
return res.json();
}
// 媒体库:本人上传的全部图片
export async function apiMyMedia(): Promise<{
attachments?: MediaAttachment[];
error?: string;
}> {
const res = await fetchWithRefresh("/api/my/media", {
method: "GET",
headers: clientHeaders(),
});
return res.json();
}
// 彻底删除本人附件
export async function apiDeleteAttachment(
id: number
): Promise<{ ok?: boolean; error?: string }> {
const res = await fetchWithRefresh(`/api/my/attachments/${id}`, {
method: "DELETE",
headers: clientHeaders(),
});
return res.json();
}
export async function apiToggleLike(postId: string) {
const res = await fetchWithRefresh(`/api/posts/${postId}/like`, {
method: "POST",
@@ -663,3 +738,23 @@ export async function apiAdminDeleteAnnouncement(id: number): Promise<void> {
throw new Error(data.error || "删除公告失败");
}
}
// ===== 站点外观设置 =====
export async function apiGetSettings(): Promise<PublicSettings> {
const res = await fetchWithRefresh("/api/settings", { method: "GET" });
if (!res.ok) throw new Error("获取站点设置失败");
return res.json();
}
// 更新站点主题色;accent 传空串恢复默认
export async function apiUpdateSiteSettings(accent: string): Promise<PublicSettings> {
const res = await fetchWithRefresh("/api/admin/settings", {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ accent }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "保存设置失败");
return { accent: data.accent ?? "" };
}

59
frontend/lib/cropImage.ts Normal file
View File

@@ -0,0 +1,59 @@
import type { Area } from "react-easy-crop";
// 头像裁剪输出尺寸(正方形)。头像最大展示 88px,320px 足够覆盖高分屏
export const AVATAR_OUTPUT_SIZE = 320;
// 裁剪后 WebP 文件大小上限(与后端 service.AvatarMaxBytes 保持一致)
export const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.addEventListener("load", () => resolve(img));
img.addEventListener("error", () => reject(new Error("图片加载失败,请换一张试试")));
img.src = src;
});
}
function canvasToWebp(canvas: HTMLCanvasElement, quality: number): Promise<Blob | null> {
return new Promise((resolve) => canvas.toBlob(resolve, "image/webp", quality));
}
/**
* 按裁剪区域导出正方形 WebP。
* 自动按 0.92 → 0.65 降质量,直到满足大小上限;仍超限则抛出错误。
*/
export async function getCroppedAvatarWebp(
imageSrc: string,
pixelCrop: Area
): Promise<Blob> {
const image = await loadImage(imageSrc);
const canvas = document.createElement("canvas");
canvas.width = AVATAR_OUTPUT_SIZE;
canvas.height = AVATAR_OUTPUT_SIZE;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("当前浏览器不支持图片裁剪");
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(
image,
pixelCrop.x,
pixelCrop.y,
pixelCrop.width,
pixelCrop.height,
0,
0,
AVATAR_OUTPUT_SIZE,
AVATAR_OUTPUT_SIZE
);
let last: Blob | null = null;
for (const quality of [0.92, 0.85, 0.75, 0.65]) {
const blob = await canvasToWebp(canvas, quality);
if (!blob) throw new Error("当前浏览器不支持 WebP 导出,请使用最新版 Chrome/Edge/Safari");
last = blob;
if (blob.size <= AVATAR_MAX_BYTES) return blob;
}
if (last && last.size <= AVATAR_MAX_BYTES) return last;
throw new Error("裁剪后的图片仍超过 2MB,请缩小图片后再试");
}

View File

@@ -1,6 +1,9 @@
import { cache } from "react";
import { fetchMe } from "./api";
import { fetchMe, fetchPublicSettings } from "./api";
// 同一 SSR 请求内去重:layout 与页面可能同时需要当前用户,
// 相同 Cookie 头参数下只产生一次 /api/me 调用。
export const getMeCached = cache((cookieHeader?: string) => fetchMe(cookieHeader));
// 同请求内去重:layout 注入主题 CSS 变量、后台页读取当前配置共用
export const getPublicSettingsCached = cache(() => fetchPublicSettings());

243
frontend/lib/theme.ts Normal file
View 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;
}

View File

@@ -0,0 +1,29 @@
import type { User } from "./api";
/**
* 全局用户状态事件总线。
*
* 背景:Header 等客户端组件挂载在持久化的 root layout 上,仅靠
* router.refresh() 刷新 props 在上传/删除头像等场景下存在时序与
* Router Cache 的不确定性。同页内的数据更新必须走显式的状态链路
* (React 状态/事件),storage 事件、轮询等方案对本标签页无效。
*
* 任意位置修改了当前用户资料后 dispatch 一个补丁,Header 等订阅方
* 立即合并更新;router.refresh() 仍照常调用,负责校正服务端渲染区域。
*/
export const USER_UPDATED_EVENT = "j13:user-updated";
export function emitUserUpdate(patch: Partial<User>): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(USER_UPDATED_EVENT, { detail: patch }));
}
export function onUserUpdate(handler: (patch: Partial<User>) => void): () => void {
if (typeof window === "undefined") return () => {};
const listener = (e: Event) => {
const detail = (e as CustomEvent<Partial<User>>).detail;
if (detail) handler(detail);
};
window.addEventListener(USER_UPDATED_EVENT, listener);
return () => window.removeEventListener(USER_UPDATED_EVENT, listener);
}