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 // 最小宽高,防止马赛克图被拉升
|
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. 缩放至 512x512(CatmullRom 高质量)
|
// 5. 缩放至 512x512(CatmullRom 高质量)
|
||||||
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)
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user