Perf: Optimization of cropping component result acquisition & optimization of cropping frame prompts (#7111)

* perf(cropper): enhance image cropping functionality and add output type support

* perf(cropper): enhance image cropping functionality and add output type support
This commit is contained in:
JyQAQ
2026-01-19 14:18:36 +08:00
committed by GitHub
parent 686c3f9208
commit 59aabd956d
6 changed files with 69 additions and 62 deletions

View File

@@ -312,7 +312,16 @@ const withPreviewUpload = () => {
Modal, Modal,
{ {
open: open.value, open: open.value,
title: $t('ui.crop.title'), title: h('div', {}, [
$t('ui.crop.title'),
h(
'span',
{
class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`,
},
$t('ui.crop.titleTip', [aspectRatio]),
),
]),
centered: true, centered: true,
width: 548, width: 548,
keyboard: false, keyboard: false,
@@ -357,22 +366,6 @@ const withPreviewUpload = () => {
}); });
}; };
const base64ToBlob = (base64: Base64URLString) => {
try {
const [typeStr, encodeStr] = base64.split(',');
if (!typeStr || !encodeStr) return;
const mime = typeStr.match(/:(.*?);/)?.[1];
const raw = window.atob(encodeStr);
const rawLength = raw.length;
const uInt8Array = new Uint8Array(rawLength);
for (let i = 0; i < rawLength; ++i) {
uInt8Array[i] = raw.codePointAt(i) as number;
}
return new Blob([uInt8Array], { type: mime });
} catch {
return undefined;
}
};
return defineComponent({ return defineComponent({
name: Upload.name, name: Upload.name,
emits: ['update:modelValue'], emits: ['update:modelValue'],
@@ -408,12 +401,8 @@ const withPreviewUpload = () => {
) { ) {
file.status = 'removed'; file.status = 'removed';
// antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取 // antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取
const base64 = await cropImage(originFileList[0], attrs.aspectRatio); const blob = await cropImage(originFileList[0], attrs.aspectRatio);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!base64) {
return reject(new Error($t('ui.crop.cancel')));
}
const blob = base64ToBlob(base64 as string);
if (!blob) { if (!blob) {
return reject(new Error($t('ui.crop.errorTip'))); return reject(new Error($t('ui.crop.errorTip')));
} }

View File

@@ -523,20 +523,25 @@ const handleImageLoad = () => {
* 裁剪图片 * 裁剪图片
* @param {'image/jpeg' | 'image/png'} format - 输出图片格式 * @param {'image/jpeg' | 'image/png'} format - 输出图片格式
* @param {number} quality - 压缩质量0-1 * @param {number} quality - 压缩质量0-1
* @param {'blob' | 'base64'} outputType - 输出类型
* @param {number} targetWidth - 目标宽度(可选,不传则为原始裁剪宽度) * @param {number} targetWidth - 目标宽度(可选,不传则为原始裁剪宽度)
* @param {number} targetHeight - 目标高度(可选,不传则为原始裁剪高度) * @param {number} targetHeight - 目标高度(可选,不传则为原始裁剪高度)
*/ */
const getCropImage = async ( const getCropImage = async (
format: 'image/jpeg' | 'image/png' = 'image/jpeg', format: 'image/jpeg' | 'image/png' = 'image/jpeg',
quality: number = 0.92, quality: number = 0.92,
outputType: 'base64' | 'blob' = 'blob',
targetWidth?: number, targetWidth?: number,
targetHeight?: number, targetHeight?: number,
): Promise<string | undefined> => { ): Promise<Blob | string | undefined> => {
if (!props.img || !bgImageRef.value || !containerRef.value) return; if (!props.img || !bgImageRef.value || !containerRef.value) return;
// 质量参数边界修正:强制限制在 0-1 区间,防止传入非法值报错
const validQuality = Math.max(0, Math.min(1, quality));
// 创建临时图片对象获取原始尺寸 // 创建临时图片对象获取原始尺寸
const tempImg = new Image(); const tempImg = new Image();
// Only set crossOrigin for cross-origin URLs that need CORS // 跨域图片处理:仅对非同源的网络图片设置跨域匿名
if (props.img.startsWith('http://') || props.img.startsWith('https://')) { if (props.img.startsWith('http://') || props.img.startsWith('https://')) {
try { try {
const url = new URL(props.img); const url = new URL(props.img);
@@ -544,7 +549,7 @@ const getCropImage = async (
tempImg.crossOrigin = 'anonymous'; tempImg.crossOrigin = 'anonymous';
} }
} catch { } catch {
// Invalid URL, proceed without crossOrigin // Invalid URL,跳过跨域配置,不中断执行
} }
} }
@@ -553,7 +558,7 @@ const getCropImage = async (
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
tempImg.removeEventListener('load', handleLoad); tempImg.removeEventListener('load', handleLoad);
tempImg.removeEventListener('error', handleError); tempImg.removeEventListener('error', handleError);
reject(new Error('图片加载超时')); reject(new Error('图片加载超时超时时间10秒'));
}, 10_000); }, 10_000);
const handleLoad = () => { const handleLoad = () => {
clearTimeout(timeout); clearTimeout(timeout);
@@ -571,7 +576,6 @@ const getCropImage = async (
tempImg.addEventListener('load', handleLoad); tempImg.addEventListener('load', handleLoad);
tempImg.addEventListener('error', handleError); tempImg.addEventListener('error', handleError);
tempImg.src = props.img; tempImg.src = props.img;
}); });
@@ -595,11 +599,11 @@ const getCropImage = async (
const cropOnImgX = cropLeft - imgOffsetX; const cropOnImgX = cropLeft - imgOffsetX;
const cropOnImgY = cropTop - imgOffsetY; const cropOnImgY = cropTop - imgOffsetY;
// 4. 计算渲染图片到原始图片的缩放比例(关键:保留原始像素) // 4. 计算渲染图片到原始图片的缩放比例(保留原始像素)
const scaleX = tempImg.width / renderedImgWidth; const scaleX = tempImg.width / renderedImgWidth;
const scaleY = tempImg.height / renderedImgHeight; const scaleY = tempImg.height / renderedImgHeight;
// 5. 映射到原始图片的裁剪区域(精确到原始像素) // 5. 映射到原始图片的裁剪区域(精确到原始像素,防止越界
const originalCropX = Math.max(0, Math.floor(cropOnImgX * scaleX)); const originalCropX = Math.max(0, Math.floor(cropOnImgX * scaleX));
const originalCropY = Math.max(0, Math.floor(cropOnImgY * scaleY)); const originalCropY = Math.max(0, Math.floor(cropOnImgY * scaleY));
const originalCropWidth = Math.min( const originalCropWidth = Math.min(
@@ -611,27 +615,32 @@ const getCropImage = async (
tempImg.height - originalCropY, tempImg.height - originalCropY,
); );
// 6. 处理高清屏适配关键解决Retina屏模糊 // 边界校验:裁剪尺寸非法则返回
if (originalCropWidth <= 0 || originalCropHeight <= 0) return;
// 6. 处理高清屏适配解决Retina屏模糊
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
// 最终画布尺寸(优先使用原始裁剪尺寸,或目标尺寸) // 最终画布尺寸(优先使用传入的目标尺寸,无则用原始裁剪尺寸)
const finalWidth = targetWidth || originalCropWidth; const finalWidth = targetWidth ? Math.max(1, targetWidth) : originalCropWidth;
const finalHeight = targetHeight || originalCropHeight; const finalHeight = targetHeight
? Math.max(1, targetHeight)
: originalCropHeight;
// 创建画布(乘以设备像素比,保证高清) // 创建画布并获取绘制上下文
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return; if (!ctx) return;
// 画布物理尺寸(适配高清屏 // 画布物理尺寸(乘以设备像素比,保证高清无模糊
canvas.width = finalWidth * dpr; canvas.width = finalWidth * dpr;
canvas.height = finalHeight * dpr; canvas.height = finalHeight * dpr;
// 画布显示尺寸(视觉尺寸) // 画布显示尺寸(视觉尺寸,和最终展示一致
canvas.style.width = `${finalWidth}px`; canvas.style.width = `${finalWidth}px`;
canvas.style.height = `${finalHeight}px`; canvas.style.height = `${finalHeight}px`;
// 缩放上下文适配DPR // 缩放画布上下文适配高清屏DPR
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
// 7. 绘制裁剪后的图片(使用原始像素绘制,保证清晰度) // 7. 绘制裁剪后的图片(使用原始像素绘制,保证清晰度)
@@ -647,8 +656,22 @@ const getCropImage = async (
finalHeight, // 画布绘制高度(目标尺寸) finalHeight, // 画布绘制高度(目标尺寸)
); );
// 8. 导出图片(指定质量,平衡清晰度和体积) try {
return canvas.toDataURL(format, quality); return outputType === 'base64'
? canvas.toDataURL(format, validQuality)
: new Promise<Blob>((resolve) => {
canvas.toBlob(
(blob) => {
// 兜底如果blob生成失败返回空Blob防止null
resolve(blob || new Blob([], { type: format }));
},
format,
validQuality,
);
});
} catch (error) {
console.error('图片导出失败:', error);
}
}; };
// 监听比例变化,重新调整裁剪框 // 监听比例变化,重新调整裁剪框

View File

@@ -56,6 +56,7 @@
}, },
"crop": { "crop": {
"title": "Image Cropping", "title": "Image Cropping",
"titleTip": "Cropping Ratio {0}",
"confirm": "Crop", "confirm": "Crop",
"cancel": "Cancel cropping", "cancel": "Cancel cropping",
"errorTip": "Cropping error" "errorTip": "Cropping error"

View File

@@ -56,6 +56,7 @@
}, },
"crop": { "crop": {
"title": "图片裁剪", "title": "图片裁剪",
"titleTip": "裁剪比例 {0}",
"confirm": "裁剪", "confirm": "裁剪",
"cancel": "取消裁剪", "cancel": "取消裁剪",
"errorTip": "裁剪错误" "errorTip": "裁剪错误"

View File

@@ -312,7 +312,16 @@ const withPreviewUpload = () => {
Modal, Modal,
{ {
open: open.value, open: open.value,
title: $t('ui.crop.title'), title: h('div', {}, [
$t('ui.crop.title'),
h(
'span',
{
class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`,
},
$t('ui.crop.titleTip', [aspectRatio]),
),
]),
centered: true, centered: true,
width: 548, width: 548,
keyboard: false, keyboard: false,
@@ -357,22 +366,6 @@ const withPreviewUpload = () => {
}); });
}; };
const base64ToBlob = (base64: Base64URLString) => {
try {
const [typeStr, encodeStr] = base64.split(',');
if (!typeStr || !encodeStr) return;
const mime = typeStr.match(/:(.*?);/)?.[1];
const raw = window.atob(encodeStr);
const rawLength = raw.length;
const uInt8Array = new Uint8Array(rawLength);
for (let i = 0; i < rawLength; ++i) {
uInt8Array[i] = raw.codePointAt(i) as number;
}
return new Blob([uInt8Array], { type: mime });
} catch {
return undefined;
}
};
return defineComponent({ return defineComponent({
name: Upload.name, name: Upload.name,
emits: ['update:modelValue'], emits: ['update:modelValue'],
@@ -408,12 +401,8 @@ const withPreviewUpload = () => {
) { ) {
file.status = 'removed'; file.status = 'removed';
// antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取 // antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取
const base64 = await cropImage(originFileList[0], attrs.aspectRatio); const blob = await cropImage(originFileList[0], attrs.aspectRatio);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!base64) {
return reject(new Error($t('ui.crop.cancel')));
}
const blob = base64ToBlob(base64 as string);
if (!blob) { if (!blob) {
return reject(new Error($t('ui.crop.errorTip'))); return reject(new Error($t('ui.crop.errorTip')));
} }

View File

@@ -44,7 +44,11 @@ const cropImage = async () => {
if (!cropperRef.value) return; if (!cropperRef.value) return;
cropLoading.value = true; cropLoading.value = true;
try { try {
cropperImg.value = await cropperRef.value.getCropImage(); cropperImg.value = await cropperRef.value.getCropImage(
'image/jpeg',
0.92,
'base64',
);
} catch (error) { } catch (error) {
console.error('图片裁剪失败:', error); console.error('图片裁剪失败:', error);
} finally { } finally {