diff --git a/src/utils/imageCompress.ts b/src/utils/imageCompress.ts new file mode 100644 index 0000000..58477d8 --- /dev/null +++ b/src/utils/imageCompress.ts @@ -0,0 +1,61 @@ +/** + * 图片压缩工具 —— 头像上传前的前端预压缩,降低网络延迟 + * + * 头像展示尺寸很小(会话列表 38px、成员/消息 30px、编辑预览 96px), + * 上传前压成 AVATAR_UPLOAD_SIZE 见方的 JPEG,可显著减少请求体体积。 + */ + +/** 头像上传目标边长(正方形,px) */ +export const AVATAR_UPLOAD_SIZE = 50 + +/** 载入图片为可绘制源(优先 ImageBitmap,回退 HTMLImageElement) */ +async function LoadImageSource(file: File): Promise { + if (typeof createImageBitmap === 'function') { + return await createImageBitmap(file) + } + const url = URL.createObjectURL(file) + try { + return await new Promise((resolve, reject) => { + const el = new Image() + el.onload = () => resolve(el) + el.onerror = () => reject(new Error('图片解码失败')) + el.src = url + }) + } finally { + URL.revokeObjectURL(url) + } +} + +/** + * 将图片居中裁剪为正方形并缩放为 Size×Size 的 JPEG File。 + * + * - 透明区域以白色填充(JPEG 不支持透明,避免出现黑底) + * - 非图片、canvas 不可用或解码失败时原样返回,保证上传不中断 + */ +export async function compressImageSquare(file: File, Size = AVATAR_UPLOAD_SIZE): Promise { + if (!file.type.startsWith('image/')) return file + try { + const source = await LoadImageSource(file) + const canvas = document.createElement('canvas') + canvas.width = Size + canvas.height = Size + const ctx = canvas.getContext('2d') + if (!ctx) return file + // 居中裁剪正方形后缩放 + const side = Math.min(source.width, source.height) + const sx = (source.width - side) / 2 + const sy = (source.height - side) / 2 + ctx.fillStyle = '#ffffff' + ctx.fillRect(0, 0, Size, Size) + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = 'high' + ctx.drawImage(source, sx, sy, side, side, 0, 0, Size, Size) + if (typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap) source.close() + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.9)) + if (!blob) return file + const base = file.name.replace(/\.[^.]+$/, '') + return new File([blob], `${base}.jpg`, { type: 'image/jpeg', lastModified: Date.now() }) + } catch { + return file + } +} diff --git a/src/views/Profile.vue b/src/views/Profile.vue index bee15c7..71b64ae 100644 --- a/src/views/Profile.vue +++ b/src/views/Profile.vue @@ -16,6 +16,7 @@ import { computed, ref } from 'vue' import { ElMessage } from 'element-plus' import { useUserStore } from '@/stores/user' import { getAvatarUrl, uploadAvatar, modifyPersonalConfig, modifyPassword } from '@/api/auth' +import { compressImageSquare } from '@/utils/imageCompress' const userStore = useUserStore() @@ -40,7 +41,8 @@ async function handleAvatarChange(e: Event): Promise { if (avatarUploading.value) return avatarUploading.value = true try { - await uploadAvatar(file) + // 前端预压缩为 50×50 JPEG,降低上传体积与延迟(后端仍会再规范化) + await uploadAvatar(await compressImageSquare(file)) avatarVersion.value++ ElMessage.success('头像修改成功') } catch { diff --git a/src/views/talk/Talk.vue b/src/views/talk/Talk.vue index 03255fb..b310ccf 100644 --- a/src/views/talk/Talk.vue +++ b/src/views/talk/Talk.vue @@ -50,6 +50,7 @@ import { subscribeTalkEvent, emitTalkEvent, } from '@/utils/talkSocket' +import { compressImageSquare } from '@/utils/imageCompress' import GroupCreateDialog from './GroupCreateDialog.vue' import TalkAvatar from './TalkAvatar.vue' @@ -765,7 +766,8 @@ async function OnAvatarPicked(event: Event): Promise { } AvatarUploading.value = true try { - const result = await uploadTalkAttachment(file) + // 前端预压缩为 50×50 JPEG 后上传,降低体积与延迟 + const result = await uploadTalkAttachment(await compressImageSquare(file)) AvatarSHA1.value = result.SHA1 AvatarPreview.value = buildTalkAssetUrl(result.SHA1) } catch {