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

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,请缩小图片后再试");
}