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