feat: 头像上传选区限制 3840×3840 + 客户端裁剪压缩
- 后端:3840×2160 限制改为 3840×3840(方形限制适应方形裁剪),并移到裁剪结果上检查而非原图 - 前端:原图短边超过 3840 时自动抬高最小缩放,确保选区永不超过 3840×3840 - 前端:裁剪确认后用 Canvas 预裁剪 + JPEG 压缩再上传,大幅节省带宽 - 前端:裁剪框标题栏新增实时像素尺寸提示 - 新增 .crop-info 样式
This commit is contained in:
@ -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)
|
||||
|
||||
@ -238,7 +238,7 @@
|
||||
<!-- 头像裁切弹窗 -->
|
||||
<div class="crop-overlay" id="cropOverlay">
|
||||
<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">
|
||||
<img id="cropImageEl" draggable="false">
|
||||
<div class="crop-mask" id="cropMask"></div>
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user