feat: 头像上传选区限制 3840×3840 + 客户端裁剪压缩

- 后端:3840×2160 限制改为 3840×3840(方形限制适应方形裁剪),并移到裁剪结果上检查而非原图
- 前端:原图短边超过 3840 时自动抬高最小缩放,确保选区永不超过 3840×3840
- 前端:裁剪确认后用 Canvas 预裁剪 + JPEG 压缩再上传,大幅节省带宽
- 前端:裁剪框标题栏新增实时像素尺寸提示
- 新增 .crop-info 样式
This commit is contained in:
2026-05-31 02:42:27 +08:00
parent 09a9394c30
commit 4887093c3a
3 changed files with 95 additions and 34 deletions

View File

@ -26,7 +26,7 @@ const (
avatarMinWidth = 128 // 最小宽高,防止马赛克图被拉升 avatarMinWidth = 128 // 最小宽高,防止马赛克图被拉升
avatarMinHeight = 128 avatarMinHeight = 128
avatarMaxWidth = 3840 avatarMaxWidth = 3840
avatarMaxHeight = 2160 avatarMaxHeight = 3840
avatarOutputSize = 512 avatarOutputSize = 512
avatarTargetKB = 100 avatarTargetKB = 100
avatarStorageDir = "storage/uploads/avatars" avatarStorageDir = "storage/uploads/avatars"
@ -98,9 +98,7 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
} }
bounds := img.Bounds() bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy() w, h := bounds.Dx(), bounds.Dy()
if w > avatarMaxWidth || h > avatarMaxHeight { // 原图只检查最小尺寸,确保有足够像素供上采样
return "", fmt.Errorf("图片分辨率不能超过 3840x2160")
}
if w < avatarMinWidth || h < avatarMinHeight { if w < avatarMinWidth || h < avatarMinHeight {
return "", fmt.Errorf("图片分辨率不能低于 128x128") return "", fmt.Errorf("图片分辨率不能低于 128x128")
} }
@ -114,6 +112,13 @@ func (s *AvatarService) ProcessImage(userID uint, file io.Reader, contentType st
cropped = centerCrop(img) cropped = centerCrop(img)
} }
// 裁切后检查最大尺寸(用户实际选区不应超过此限制)
cb := cropped.Bounds()
cw, ch := cb.Dx(), cb.Dy()
if cw > avatarMaxWidth || ch > avatarMaxHeight {
return "", fmt.Errorf("裁切区域分辨率不能超过 3840x3840")
}
// 5. 缩放至 512x512CatmullRom 高质量) // 5. 缩放至 512x512CatmullRom 高质量)
resized := image.NewNRGBA(image.Rect(0, 0, avatarOutputSize, avatarOutputSize)) resized := image.NewNRGBA(image.Rect(0, 0, avatarOutputSize, avatarOutputSize))
draw.CatmullRom.Scale(resized, resized.Bounds(), cropped, cropped.Bounds(), draw.Over, nil) draw.CatmullRom.Scale(resized, resized.Bounds(), cropped, cropped.Bounds(), draw.Over, nil)

View File

@ -238,7 +238,7 @@
<!-- 头像裁切弹窗 --> <!-- 头像裁切弹窗 -->
<div class="crop-overlay" id="cropOverlay"> <div class="crop-overlay" id="cropOverlay">
<div class="crop-dialog"> <div class="crop-dialog">
<div class="crop-header">调整裁切范围</div> <div class="crop-header">调整裁切范围 <span class="crop-info" id="cropInfo"></span></div>
<div class="crop-stage" id="cropStage"> <div class="crop-stage" id="cropStage">
<img id="cropImageEl" draggable="false"> <img id="cropImageEl" draggable="false">
<div class="crop-mask" id="cropMask"></div> <div class="crop-mask" id="cropMask"></div>
@ -376,9 +376,11 @@
var pendingFile = null; var pendingFile = null;
var imgNW, imgNH; // 原图像素尺寸 var imgNW, imgNH; // 原图像素尺寸
var imgDW, imgDH; // 图片缩放后显示尺寸zoom=1 基准) 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 panX = 0, panY = 0; // 图片平移偏移量
var fSize, fLeft, fTop; // 裁切框的固定屏幕像素:边长、左、顶 var fSize, fLeft, fTop; // 裁切框的固定屏幕像素:边长、左、顶
var CROP_MAX_PX = 3840; // 选区最大像素限制
// 更新裁切框固定参数(图片加载时计算一次,之后不变) // 更新裁切框固定参数(图片加载时计算一次,之后不变)
function initFrame() { function initFrame() {
@ -418,6 +420,11 @@
cropMask.style.top = fTop + 'px'; cropMask.style.top = fTop + 'px';
cropMask.style.width = fSize + 'px'; cropMask.style.width = fSize + 'px';
cropMask.style.height = 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) { function openCrop(file) {
@ -431,7 +438,11 @@
var fit = Math.min(sw / imgNW, sh / imgNH); var fit = Math.min(sw / imgNW, sh / imgNH);
imgDW = Math.round(imgNW * fit); imgDW = Math.round(imgNW * fit);
imgDH = Math.round(imgNH * 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; panX = 0;
panY = 0; panY = 0;
initFrame(); initFrame();
@ -514,7 +525,7 @@
cropStage.addEventListener('wheel', function (e) { cropStage.addEventListener('wheel', function (e) {
e.preventDefault(); e.preventDefault();
if (panning) return; 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; if (newZoom === zoom) return;
zoom = newZoom; zoom = newZoom;
clampPan(); clampPan();
@ -526,19 +537,52 @@
if (e.target === cropOverlay) closeCrop(); 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 () { cropConfirmBtn.addEventListener('click', function () {
if (!pendingFile) return; if (!pendingFile) return;
var params = getCropParams(); 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.disabled = true;
cropConfirmBtn.textContent = '处理中...'; cropConfirmBtn.textContent = '处理中...';
showSettingsToast('上传中...'); showSettingsToast('上传中...');
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(); var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/settings/avatar', true); xhr.open('POST', '/api/settings/avatar', true);
xhr.onreadystatechange = function () { xhr.onreadystatechange = function () {
@ -562,6 +606,11 @@
} }
}; };
xhr.send(formData); xhr.send(formData);
}).catch(function () {
cropConfirmBtn.disabled = false;
cropConfirmBtn.textContent = '确认';
showSettingsToast('处理失败,请重试', true);
});
}); });
if (avatarWrap && avatarInput) { if (avatarWrap && avatarInput) {

View File

@ -362,6 +362,13 @@ body {
border-bottom: 1px solid var(--color-border); 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 { .crop-stage {
position: relative; position: relative;
width: min(85vw, 480px); width: min(85vw, 480px);