新增: - blob_store 二进制去重存储 + blob_dedup 测试 其它: - 后端 settings/auth/media_library/upload/operations 服务与 handler 调整 - 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
146 lines
4.5 KiB
TypeScript
146 lines
4.5 KiB
TypeScript
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;
|
||
|
||
// 品牌 Logo 输出高度上限(与后端 brandMaxDim 对齐)
|
||
export const BRAND_MAX_DIM = 2048;
|
||
// 裁剪后品牌图大小上限(与后端 BrandLogoMaxBytes 对齐)
|
||
export const BRAND_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 降质量,直到满足大小上限;仍超限则抛出错误。
|
||
*/
|
||
async function exportWebp(
|
||
canvas: HTMLCanvasElement,
|
||
maxBytes: number
|
||
): Promise<Blob> {
|
||
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 <= maxBytes) return blob;
|
||
}
|
||
if (last && last.size <= maxBytes) return last;
|
||
throw new Error("裁剪后的图片仍超过大小限制,请缩小图片后再试");
|
||
}
|
||
|
||
/**
|
||
* 按裁剪区域导出正方形 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
|
||
);
|
||
|
||
return exportWebp(canvas, AVATAR_MAX_BYTES);
|
||
}
|
||
|
||
/**
|
||
* 按裁剪区域导出任意比例 WebP(品牌 Logo 用)。
|
||
* 输出高度限制在 BRAND_MAX_DIM 以内,宽度按比例自适应。
|
||
*/
|
||
export async function getCroppedBrandWebp(
|
||
imageSrc: string,
|
||
pixelCrop: Area,
|
||
aspect: number
|
||
): Promise<Blob> {
|
||
const image = await loadImage(imageSrc);
|
||
const canvas = document.createElement("canvas");
|
||
|
||
// 以高度为基准,宽度按纵横比计算;若超宽则按宽度反推高度
|
||
let outH = pixelCrop.height;
|
||
let outW = Math.round(outH * aspect);
|
||
if (outW > BRAND_MAX_DIM) {
|
||
outW = BRAND_MAX_DIM;
|
||
outH = Math.round(outW / aspect);
|
||
}
|
||
if (outH > BRAND_MAX_DIM) {
|
||
outH = BRAND_MAX_DIM;
|
||
outW = Math.round(outH * aspect);
|
||
}
|
||
|
||
canvas.width = outW;
|
||
canvas.height = outH;
|
||
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,
|
||
outW,
|
||
outH
|
||
);
|
||
|
||
return exportWebp(canvas, BRAND_MAX_BYTES);
|
||
}
|
||
|
||
/**
|
||
* 整图直传(「原图自适应」用):不做任何裁剪,仅按比例缩放到 BRAND_MAX_DIM 内并导出 WebP。
|
||
* 页眉图框高度固定、宽度按图片原始比例自适应。
|
||
*/
|
||
export async function getFullBrandWebp(imageSrc: string): Promise<Blob> {
|
||
const image = await loadImage(imageSrc);
|
||
const canvas = document.createElement("canvas");
|
||
|
||
const scale = Math.min(1, BRAND_MAX_DIM / Math.max(image.naturalWidth, image.naturalHeight));
|
||
const outW = Math.max(1, Math.round(image.naturalWidth * scale));
|
||
const outH = Math.max(1, Math.round(image.naturalHeight * scale));
|
||
|
||
canvas.width = outW;
|
||
canvas.height = outH;
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) throw new Error("当前浏览器不支持图片处理");
|
||
|
||
ctx.imageSmoothingEnabled = true;
|
||
ctx.imageSmoothingQuality = "high";
|
||
ctx.drawImage(image, 0, 0, outW, outH);
|
||
|
||
return exportWebp(canvas, BRAND_MAX_BYTES);
|
||
}
|