mirror of
https://gitee.com/dapppp/ruoyi-plus-vben5.git
synced 2026-03-08 07:31:09 +08:00
Merge branch 'main' into fix
This commit is contained in:
@@ -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')));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 监听比例变化,重新调整裁剪框
|
// 监听比例变化,重新调整裁剪框
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
},
|
},
|
||||||
"crop": {
|
"crop": {
|
||||||
"title": "图片裁剪",
|
"title": "图片裁剪",
|
||||||
|
"titleTip": "裁剪比例 {0}",
|
||||||
"confirm": "裁剪",
|
"confirm": "裁剪",
|
||||||
"cancel": "取消裁剪",
|
"cancel": "取消裁剪",
|
||||||
"errorTip": "裁剪错误"
|
"errorTip": "裁剪错误"
|
||||||
|
|||||||
@@ -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')));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user