import type { Area } from 'react-easy-crop'; export const AVATAR_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp'; export const AVATAR_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; /** 头像输出尺寸 */ export const AVATAR_OUTPUT_SIZE = 512; /** * 原图体积软上限:仅防止浏览器加载过大文件卡死。 * 实际上传限额看裁剪后的文件(见 validateAvatarOutput)。 */ export const AVATAR_SOURCE_MAX_MB = 20; function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error('图片加载失败')); img.src = src; }); } /** 选择/拖入原图时:只校验格式与可读性上限,不按上传限额卡死 */ export function validateAvatarFile(file: File): string | null { if (!AVATAR_MIME_TYPES.includes(file.type)) { return '仅支持 JPG、PNG、GIF、WebP 格式'; } if (file.size > AVATAR_SOURCE_MAX_MB * 1024 * 1024) { return `原图过大(超过 ${AVATAR_SOURCE_MAX_MB}MB),请换一张较小的图片`; } return null; } /** 裁剪完成后:按实际上传体积校验 */ export function validateAvatarOutput(file: File, maxMb: number): string | null { if (file.size > maxMb * 1024 * 1024) { return `裁剪后头像仍超过 ${maxMb}MB,请缩小裁剪区域或换图`; } return null; } /** 将裁剪区域渲染为 JPEG 文件 */ export async function getCroppedAvatarFile( imageSrc: string, pixelCrop: Area, originalName = 'avatar.jpg', ): Promise { const image = await loadImage(imageSrc); const canvas = document.createElement('canvas'); const size = AVATAR_OUTPUT_SIZE; canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('裁剪失败'); ctx.drawImage( image, pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height, 0, 0, size, size, ); const blob = await new Promise((resolve, reject) => { canvas.toBlob( b => (b ? resolve(b) : reject(new Error('裁剪失败'))), 'image/jpeg', 0.92, ); }); const baseName = originalName.replace(/\.[^.]+$/, '') || 'avatar'; return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' }); }