diff --git a/internal/service/avatar_service.go b/internal/service/avatar_service.go index 34c63a1..6093231 100644 --- a/internal/service/avatar_service.go +++ b/internal/service/avatar_service.go @@ -26,7 +26,7 @@ const ( avatarMinWidth = 128 // 最小宽高,防止马赛克图被拉升 avatarMinHeight = 128 avatarMaxWidth = 3840 - avatarMaxHeight = 2160 + avatarMaxHeight = 3840 avatarOutputSize = 512 avatarTargetKB = 100 avatarStorageDir = "storage/uploads/avatars" @@ -98,9 +98,7 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st } bounds := img.Bounds() w, h := bounds.Dx(), bounds.Dy() - if w > avatarMaxWidth || h > avatarMaxHeight { - return "", fmt.Errorf("图片分辨率不能超过 3840x2160") - } + // 原图只检查最小尺寸,确保有足够像素供上采样 if w < avatarMinWidth || h < avatarMinHeight { return "", fmt.Errorf("图片分辨率不能低于 128x128") } @@ -114,6 +112,13 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st cropped = centerCrop(img) } + // 裁切后检查最大尺寸(用户实际选区不应超过此限制) + cb := cropped.Bounds() + cw, ch := cb.Dx(), cb.Dy() + if cw > avatarMaxWidth || ch > avatarMaxHeight { + return "", fmt.Errorf("裁切区域分辨率不能超过 3840x3840") + } + // 5. 缩放至 512x512(CatmullRom 高质量) resized := image.NewNRGBA(image.Rect(0, 0, avatarOutputSize, avatarOutputSize)) draw.CatmullRom.Scale(resized, resized.Bounds(), cropped, cropped.Bounds(), draw.Over, nil) diff --git a/templates/MetaLab-2026/html/settings/index.html b/templates/MetaLab-2026/html/settings/index.html index d5a0e0c..b22b480 100644 --- a/templates/MetaLab-2026/html/settings/index.html +++ b/templates/MetaLab-2026/html/settings/index.html @@ -238,7 +238,7 @@
-
调整裁切范围
+
调整裁切范围
@@ -376,9 +376,11 @@ var pendingFile = null; var imgNW, imgNH; // 原图像素尺寸 var imgDW, imgDH; // 图片缩放后显示尺寸(zoom=1 基准) - var zoom = 1; // 图片缩放系数 1.0 ~ 3.0 + var zoom = 1; // 图片缩放系数 + var minZoom = 1; // 最小缩放系数(超大图限制选区不超过 3840px) var panX = 0, panY = 0; // 图片平移偏移量 var fSize, fLeft, fTop; // 裁切框的固定屏幕像素:边长、左、顶 + var CROP_MAX_PX = 3840; // 选区最大像素限制 // 更新裁切框固定参数(图片加载时计算一次,之后不变) function initFrame() { @@ -418,6 +420,11 @@ cropMask.style.top = fTop + 'px'; cropMask.style.width = fSize + 'px'; cropMask.style.height = fSize + 'px'; + + // 实时显示选区像素尺寸 + var p = getCropParams(); + var infoEl = document.getElementById('cropInfo'); + if (infoEl) infoEl.textContent = p.size + ' × ' + p.size + ' px'; } function openCrop(file) { @@ -431,7 +438,11 @@ var fit = Math.min(sw / imgNW, sh / imgNH); imgDW = Math.round(imgNW * fit); imgDH = Math.round(imgNH * fit); - zoom = 1; + // 限制选框最大像素:原图短边超过 3840 时,抬高最小缩放 + minZoom = Math.min(imgNW, imgNH) > CROP_MAX_PX + ? Math.min(imgNW, imgNH) / CROP_MAX_PX + : 1; + zoom = minZoom; panX = 0; panY = 0; initFrame(); @@ -514,7 +525,7 @@ cropStage.addEventListener('wheel', function (e) { e.preventDefault(); if (panning) return; - var newZoom = Math.max(1, Math.min(zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1), 3)); + var newZoom = Math.max(minZoom, Math.min(zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1), 3)); if (newZoom === zoom) return; zoom = newZoom; clampPan(); @@ -526,42 +537,80 @@ if (e.target === cropOverlay) closeCrop(); }); + // 客户端裁剪 + 压缩:用 Canvas 裁出选区,转 JPEG 后上传,大幅节省带宽 + function cropOnClient(file, x, y, size) { + return new Promise(function (resolve) { + var url = URL.createObjectURL(file); + var img = new Image(); + img.onload = function () { + URL.revokeObjectURL(url); + var out = Math.min(size, 2048); // 不超过 2048px,避免 Canvas 内存过大 + var canvas = document.createElement('canvas'); + canvas.width = out; + canvas.height = out; + var ctx = canvas.getContext('2d'); + ctx.drawImage(img, x, y, size, size, 0, 0, out, out); + canvas.toBlob(function (blob) { + resolve({ + file: new File([blob], 'avatar.jpg', { type: blob.type || 'image/jpeg' }), + cropX: 0, + cropY: 0, + cropSize: out + }); + }, 'image/jpeg', 0.88); + }; + img.onerror = function () { + URL.revokeObjectURL(url); + // Canvas 处理失败时回退:直接上传原文件 + 原坐标 + resolve({ file: file, cropX: x, cropY: y, cropSize: size }); + }; + img.src = url; + }); + } + cropConfirmBtn.addEventListener('click', function () { if (!pendingFile) return; var params = getCropParams(); - var formData = new FormData(); - formData.append('avatar', pendingFile); - formData.append('crop_x', params.x); - formData.append('crop_y', params.y); - formData.append('crop_size', params.size); cropConfirmBtn.disabled = true; cropConfirmBtn.textContent = '处理中...'; showSettingsToast('上传中...'); - var xhr = new XMLHttpRequest(); - xhr.open('POST', '/api/settings/avatar', true); - xhr.onreadystatechange = function () { - if (xhr.readyState !== 4) return; + cropOnClient(pendingFile, params.x, params.y, params.size).then(function (result) { + var formData = new FormData(); + formData.append('avatar', result.file, result.file.name); + formData.append('crop_x', result.cropX); + formData.append('crop_y', result.cropY); + formData.append('crop_size', result.cropSize); + + var xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/settings/avatar', true); + xhr.onreadystatechange = function () { + if (xhr.readyState !== 4) return; + cropConfirmBtn.disabled = false; + cropConfirmBtn.textContent = '确认'; + closeCrop(); + + try { + var res = JSON.parse(xhr.responseText); + if (res.success) { + showSettingsToast(res.message || '头像已更新'); + if (res.data && res.data.url && avatarPreview.tagName === 'IMG') { + avatarPreview.src = res.data.url + '?t=' + Date.now(); + } + } else { + showSettingsToast(res.message || '上传失败', true); + } + } catch (err) { + showSettingsToast('上传失败', true); + } + }; + xhr.send(formData); + }).catch(function () { cropConfirmBtn.disabled = false; cropConfirmBtn.textContent = '确认'; - closeCrop(); - - try { - var res = JSON.parse(xhr.responseText); - if (res.success) { - showSettingsToast(res.message || '头像已更新'); - if (res.data && res.data.url && avatarPreview.tagName === 'IMG') { - avatarPreview.src = res.data.url + '?t=' + Date.now(); - } - } else { - showSettingsToast(res.message || '上传失败', true); - } - } catch (err) { - showSettingsToast('上传失败', true); - } - }; - xhr.send(formData); + showSettingsToast('处理失败,请重试', true); + }); }); if (avatarWrap && avatarInput) { diff --git a/templates/MetaLab-2026/static/css/settings.css b/templates/MetaLab-2026/static/css/settings.css index 54376e0..cd021f3 100644 --- a/templates/MetaLab-2026/static/css/settings.css +++ b/templates/MetaLab-2026/static/css/settings.css @@ -362,6 +362,13 @@ body { border-bottom: 1px solid var(--color-border); } +.crop-info { + font-size: .75rem; + color: var(--color-text-secondary); + font-weight: 400; + margin-left: .5rem; +} + .crop-stage { position: relative; width: min(85vw, 480px);