feat: 图片裁剪功能更新

This commit is contained in:
ZhuRui
2026-07-27 09:03:46 +08:00
parent b86c22e7d2
commit b852754ca6
5 changed files with 4 additions and 695 deletions
-563
View File
@@ -1,563 +0,0 @@
import { defineComponent, ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue';
import { Button } from 'ant-design-vue';
import Cropper from 'cropperjs';
import 'cropperjs/dist/cropper.css';
import './style.less';
export interface ImageCropperRef {
/** 重置裁剪器到初始状态 */
reset: () => void;
/** 清空裁剪框 */
clear: () => void;
/** 旋转图片,正数顺时针,负数逆时针 */
rotate: (degree: number) => void;
/** 缩放图片,正数放大,负数缩小 */
zoom: (ratio: number) => void;
/** 移动图片 */
move: (offsetX: number, offsetY: number) => void;
/** 获取 Cropper.js 原始实例 */
getCropper: () => Cropper | null;
/** 获取裁剪后的 canvas */
getCroppedCanvas: (options?: Cropper.GetCroppedCanvasOptions) => HTMLCanvasElement | null;
/** 获取裁剪后的图片 base64 */
getCroppedImage: (options?: Cropper.GetCroppedCanvasOptions) => Promise<string>;
/** 获取裁剪后的图片 Blob */
getCroppedBlob: (options?: Cropper.GetCroppedCanvasOptions) => Promise<Blob>;
/** 确认裁剪,返回 base64 并触发 onCrop 回调 */
confirmCrop: () => Promise<string>;
/** 设置裁剪框数据 */
setCropBoxData: (data: Partial<Cropper.SetCropBoxDataOptions>) => void;
/** 获取当前裁剪框数据 */
getCropBoxData: () => Cropper.CropBoxData | null;
}
export interface ImageCropperProps {
/** 图片源地址,支持 URL 或 base64 */
src?: string;
/** 裁剪框的宽高比,例如 1 表示 1:1,4/3 表示 4:3NaN 表示自由比例 */
aspectRatio?: number;
/** 是否在初始化时自动生成裁剪框 */
autoCrop?: boolean;
/** 自动生成裁剪框时,裁剪框占图片的比例 (0-1) */
autoCropArea?: number;
/** 视图模式
* 0: 无限制
* 1: 裁剪框不能超出画布
* 2: 画布最小化以适应容器
* 3: 画布最小化以填充容器
*/
viewMode?: 0 | 1 | 2 | 3;
/** 拖拽模式
* 'crop': 生成新的裁剪框
* 'move': 仅移动画布
* 'none': 无操作
*/
dragMode?: 'crop' | 'move' | 'none';
/** 裁剪框的初始宽高比 */
initialAspectRatio?: number;
/** 裁剪框的最小宽度(像素) */
minCropBoxWidth?: number;
/** 裁剪框的最小高度(像素) */
minCropBoxHeight?: number;
/** 容器的最小宽度(像素) */
minContainerWidth?: number;
/** 容器的最小高度(像素) */
minContainerHeight?: number;
/** 画布的最小宽度(像素) */
minCanvasWidth?: number;
/** 画布的最小高度(像素) */
minCanvasHeight?: number;
/** 是否在容器上显示网格背景 */
background?: boolean;
/** 是否在裁剪框上方显示黑色模态层 */
modal?: boolean;
/** 是否在裁剪框上方显示中心指示器 */
center?: boolean;
/** 是否高亮显示裁剪区域 */
highlight?: boolean;
/** 是否在裁剪框上显示虚线 */
guides?: boolean;
/** 是否允许移动图片 */
movable?: boolean;
/** 是否允许旋转图片 */
rotatable?: boolean;
/** 是否允许缩放图片(翻转) */
scalable?: boolean;
/** 是否允许缩放图片 */
zoomable?: boolean;
/** 是否允许通过触摸缩放图片 */
zoomOnTouch?: boolean;
/** 是否允许通过鼠标滚轮缩放图片 */
zoomOnWheel?: boolean;
/** 鼠标滚轮缩放比例 */
wheelZoomRatio?: number;
/** 是否允许移动裁剪框 */
cropBoxMovable?: boolean;
/** 是否允许调整裁剪框大小 */
cropBoxResizable?: boolean;
/** 是否允许双击切换拖拽模式 */
toggleDragModeOnDblclick?: boolean;
/** 是否在窗口大小变化时重新渲染裁剪器 */
responsive?: boolean;
/** 是否在窗口大小变化时恢复裁剪区域 */
restore?: boolean;
/** 是否检查图片的跨域属性 */
checkCrossOrigin?: boolean;
/** 是否检查图片的 EXIF 方向信息 */
checkOrientation?: boolean;
/** 输出图片类型,如 'image/png', 'image/jpeg' */
outputType?: string;
/** 输出图片质量 (0-1) */
quality?: number;
/** 填充颜色,用于裁剪结果透明区域 */
fillColor?: string;
/** 初始裁剪宽度(像素) */
initialCropWidth?: number;
/** 初始裁剪高度(像素) */
initialCropHeight?: number;
/** 是否显示裁剪信息(尺寸和比例) */
showCropInfo?: boolean;
/** 裁剪器准备就绪时的回调 */
onReady?: () => void;
/** 开始裁剪时的回调 */
onCropStart?: (event: Cropper.CropStartEvent) => void;
/** 裁剪移动时的回调 */
onCropMove?: (event: Cropper.CropMoveEvent) => void;
/** 裁剪结束时的回调 */
onCropEnd?: (event: Cropper.CropEndEvent) => void;
/** 确认裁剪时的回调,返回 base64 数据和 canvas */
onCrop?: (data: string, canvas: HTMLCanvasElement) => void;
/** 裁剪数据变化时的回调 */
onCropDataChange?: (data: Cropper.Data) => void;
/** 缩放时的回调 */
onZoom?: (event: Cropper.ZoomEvent) => void;
/** 发生错误时的回调 */
onError?: (error: Error) => void;
}
const DEFAULT_OPTIONS: Partial<Cropper.Options> = {
viewMode: 1,
dragMode: 'move',
aspectRatio: NaN,
autoCrop: true,
autoCropArea: 0.8,
restore: true,
guides: true,
center: true,
highlight: true,
background: true,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: true,
movable: true,
scalable: true,
zoomable: true,
zoomOnTouch: true,
zoomOnWheel: true,
rotatable: true,
};
const ImageCropper = defineComponent({
name: 'ImageCropper',
props: {
src: String,
aspectRatio: Number,
autoCrop: { type: Boolean, default: true },
autoCropArea: { type: Number, default: 0.8 },
viewMode: { type: Number as () => 0 | 1 | 2 | 3, default: 1 },
dragMode: { type: String as () => 'crop' | 'move' | 'none', default: 'move' },
initialAspectRatio: Number,
minCropBoxWidth: Number,
minCropBoxHeight: Number,
minContainerWidth: Number,
minContainerHeight: Number,
minCanvasWidth: Number,
minCanvasHeight: Number,
background: { type: Boolean, default: true },
modal: { type: Boolean, default: true },
center: { type: Boolean, default: true },
highlight: { type: Boolean, default: true },
guides: { type: Boolean, default: true },
movable: { type: Boolean, default: true },
rotatable: { type: Boolean, default: true },
scalable: { type: Boolean, default: true },
zoomable: { type: Boolean, default: true },
zoomOnTouch: { type: Boolean, default: true },
zoomOnWheel: { type: Boolean, default: true },
wheelZoomRatio: { type: Number, default: 0.1 },
cropBoxMovable: { type: Boolean, default: true },
cropBoxResizable: { type: Boolean, default: true },
toggleDragModeOnDblclick: { type: Boolean, default: true },
responsive: { type: Boolean, default: true },
restore: { type: Boolean, default: true },
checkCrossOrigin: { type: Boolean, default: true },
checkOrientation: { type: Boolean, default: true },
outputType: { type: String, default: 'image/png' },
quality: { type: Number, default: 0.92 },
fillColor: String,
initialCropWidth: Number,
initialCropHeight: Number,
showCropInfo: { type: Boolean, default: true },
onReady: { type: Function as unknown as () => (() => void) | undefined, default: undefined },
onCropStart: {
type: Function as unknown as () => ((event: any) => void) | undefined,
default: undefined,
},
onCropMove: {
type: Function as unknown as () => ((event: any) => void) | undefined,
default: undefined,
},
onCropEnd: {
type: Function as unknown as () => ((event: any) => void) | undefined,
default: undefined,
},
onCrop: {
type: Function as unknown as () =>
((data: string, canvas: HTMLCanvasElement) => void) | undefined,
default: undefined,
},
onCropDataChange: {
type: Function as unknown as () => ((data: any) => void) | undefined,
default: undefined,
},
onZoom: {
type: Function as unknown as () => ((event: any) => void) | undefined,
default: undefined,
},
onError: {
type: Function as unknown as () => ((error: Error) => void) | undefined,
default: undefined,
},
},
setup(props: ImageCropperProps, { expose }) {
const imageRef = ref<HTMLImageElement | null>(null);
const cropperRef = ref<Cropper | null>(null);
const isLoading = ref(false);
const error = ref<Error | null>(null);
const cropData = ref<Cropper.Data | null>(null);
const cropperOptions = computed((): Cropper.Options => {
return {
...DEFAULT_OPTIONS,
aspectRatio: props.aspectRatio ?? NaN,
autoCrop: props.autoCrop,
autoCropArea: props.autoCropArea,
viewMode: props.viewMode,
dragMode: props.dragMode,
initialAspectRatio: props.initialAspectRatio,
minCropBoxWidth: props.minCropBoxWidth,
minCropBoxHeight: props.minCropBoxHeight,
minContainerWidth: props.minContainerWidth,
minContainerHeight: props.minContainerHeight,
minCanvasWidth: props.minCanvasWidth,
minCanvasHeight: props.minCanvasHeight,
background: props.background,
modal: props.modal,
center: props.center,
highlight: props.highlight,
guides: props.guides,
movable: props.movable,
rotatable: props.rotatable,
scalable: props.scalable,
zoomable: props.zoomable,
zoomOnTouch: props.zoomOnTouch,
zoomOnWheel: props.zoomOnWheel,
wheelZoomRatio: props.wheelZoomRatio,
cropBoxMovable: props.cropBoxMovable,
cropBoxResizable: props.cropBoxResizable,
toggleDragModeOnDblclick: props.toggleDragModeOnDblclick,
responsive: props.responsive,
restore: props.restore,
checkCrossOrigin: props.checkCrossOrigin,
checkOrientation: props.checkOrientation,
ready: () => {
isLoading.value = false;
if (props.initialCropWidth && props.initialCropHeight && cropperRef.value) {
const containerData = cropperRef.value.getContainerData();
cropperRef.value.setCropBoxData({
width: Math.min(props.initialCropWidth, containerData.width * 0.8),
height: Math.min(props.initialCropHeight, containerData.height * 0.8),
});
}
props.onReady?.();
},
cropstart: (event) => props.onCropStart?.(event),
cropmove: (event) => props.onCropMove?.(event),
cropend: (event) => props.onCropEnd?.(event),
zoom: (event) => props.onZoom?.(event),
crop: (event) => {
cropData.value = event.detail;
props.onCropDataChange?.(event.detail);
},
};
});
const cropInfo = computed(() => {
if (!cropData.value) return null;
const { width, height } = cropData.value;
const ratio = props.aspectRatio
? (Math.round(props.aspectRatio * 100) / 100).toString()
: width && height
? Math.round((width / height) * 100) / 100
: '-';
return {
width: Math.round(width),
height: Math.round(height),
ratio: ratio,
};
});
const initCropper = () => {
if (!imageRef.value || !props.src) return;
isLoading.value = true;
error.value = null;
cropData.value = null;
if (cropperRef.value) {
try {
cropperRef.value.destroy();
} catch (e) {
console.warn('Destroy cropper failed:', e);
}
cropperRef.value = null;
}
try {
cropperRef.value = new Cropper(imageRef.value, {
...cropperOptions.value,
ready: () => {
isLoading.value = false;
if (props.initialCropWidth && props.initialCropHeight && cropperRef.value) {
const containerData = cropperRef.value.getContainerData();
cropperRef.value.setCropBoxData({
width: Math.min(props.initialCropWidth, containerData.width * 0.8),
height: Math.min(props.initialCropHeight, containerData.height * 0.8),
});
}
props.onReady?.();
},
});
} catch (err) {
const errorInstance =
err instanceof Error ? err : new Error('Failed to initialize cropper');
error.value = errorInstance;
isLoading.value = false;
props.onError?.(errorInstance);
}
};
const getCropper = (): Cropper | null => cropperRef.value;
const reset = () => cropperRef.value?.reset();
const clear = () => cropperRef.value?.clear();
const rotate = (degree: number) => cropperRef.value?.rotate(degree);
const zoom = (ratio: number) => cropperRef.value?.zoom(ratio);
const move = (offsetX: number, offsetY: number) => cropperRef.value?.move(offsetX, offsetY);
const setCropBoxData = (data: Partial<Cropper.SetCropBoxDataOptions>) => {
cropperRef.value?.setCropBoxData(data);
};
const getCropBoxData = (): Cropper.CropBoxData | null => {
return cropperRef.value?.getCropBoxData() || null;
};
const getCroppedCanvas = (
options?: Cropper.GetCroppedCanvasOptions,
): HTMLCanvasElement | null => {
if (!cropperRef.value) return null;
const canvas = cropperRef.value.getCroppedCanvas({
fillColor: props.fillColor,
...options,
});
return canvas;
};
const getCroppedBlob = (options?: Cropper.GetCroppedCanvasOptions): Promise<Blob> => {
return new Promise((resolve, reject) => {
const canvas = getCroppedCanvas(options);
if (!canvas) {
reject(new Error('Failed to get cropped canvas'));
return;
}
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Failed to get blob from canvas'));
return;
}
resolve(blob);
},
props.outputType,
props.quality,
);
});
};
const getCroppedImage = (options?: Cropper.GetCroppedCanvasOptions): Promise<string> => {
return new Promise((resolve, reject) => {
const canvas = getCroppedCanvas(options);
if (!canvas) {
reject(new Error('Failed to get cropped canvas'));
return;
}
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Failed to get blob from canvas'));
return;
}
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
},
props.outputType,
props.quality,
);
});
};
const confirmCrop = async (): Promise<string> => {
const canvas = getCroppedCanvas();
if (!canvas) {
throw new Error('No cropper instance or canvas');
}
const dataUrl = await getCroppedImage();
props.onCrop?.(dataUrl, canvas);
return dataUrl;
};
watch(
() => props.src,
(newSrc) => {
if (newSrc) {
nextTick(() => {
if (imageRef.value) {
if (imageRef.value.complete) {
initCropper();
} else {
imageRef.value.onload = () => initCropper();
imageRef.value.onerror = (err) => {
const errorInstance = new Error('Image load failed');
error.value = errorInstance;
isLoading.value = false;
props.onError?.(errorInstance);
};
}
}
});
} else {
if (cropperRef.value) {
cropperRef.value.destroy();
cropperRef.value = null;
}
isLoading.value = false;
error.value = null;
cropData.value = null;
}
},
);
watch(
() => props.aspectRatio,
(ratio) => {
cropperRef.value?.setAspectRatio(ratio ?? NaN);
},
);
watch([() => props.cropBoxResizable, () => props.cropBoxMovable], () => {
if (props.src && cropperRef.value) {
nextTick(() => {
initCropper();
});
}
});
onMounted(() => {
if (props.src) {
initCropper();
}
});
onUnmounted(() => {
if (cropperRef.value) {
try {
cropperRef.value.destroy();
} catch (e) {
// 静默处理销毁错误
}
}
});
expose({
getCropper,
reset,
clear,
rotate,
zoom,
move,
getCroppedCanvas,
getCroppedImage,
getCroppedBlob,
confirmCrop,
setCropBoxData,
getCropBoxData,
});
return () => (
<div class="image-cropper">
<div class="image-cropper_container">
{isLoading.value && (
<div class="image-cropper_loading">
<div class="image-cropper_loading-spinner"></div>
<span>...</span>
</div>
)}
{error.value && (
<div class="image-cropper_error">
<span>: {error.value.message}</span>
</div>
)}
{props.src ? (
<img
ref={imageRef}
src={props.src}
alt="crop"
class="image-cropper_image"
style={{ display: isLoading.value || error.value ? 'none' : 'block' }}
/>
) : (
<div class="image-cropper_placeholder">
<span></span>
</div>
)}
{props.showCropInfo && cropInfo.value && props.src && !error.value && (
<div class="image-cropper_info">
<span>
: {cropInfo.value.width} × {cropInfo.value.height}
</span>
<span>: {cropInfo.value.ratio}</span>
</div>
)}
</div>
{props.src && !error.value && (
<div class="image-cropper_actions">
<Button onClick={reset}></Button>
<Button onClick={() => rotate(-90)}>90°</Button>
<Button onClick={() => rotate(90)}>90°</Button>
<Button onClick={() => zoom(-0.1)}></Button>
<Button onClick={() => zoom(0.1)}></Button>
<Button type="primary" onClick={confirmCrop}>
</Button>
</div>
)}
</div>
);
},
});
export { ImageCropper };
export default ImageCropper;
-129
View File
@@ -1,129 +0,0 @@
.image-cropper {
width: 100%;
max-width: 800px;
margin: 0 auto;
border-radius: 12px;
overflow: hidden;
background: #fafafa;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.3s ease;
&:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
&_container {
position: relative;
height: 420px;
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
&_image {
max-width: 100%;
max-height: 100%;
display: block;
}
&_placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #999;
font-size: 16px;
font-weight: 500;
}
&_info {
position: absolute;
bottom: 12px;
left: 12px;
right: 12px;
display: flex;
justify-content: space-between;
padding: 8px 16px;
background: rgba(0, 0, 0, 0.6);
border-radius: 6px;
color: #fff;
font-size: 13px;
z-index: 5;
backdrop-filter: blur(4px);
span {
font-weight: 500;
}
}
&_loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.9);
z-index: 10;
color: #666;
font-size: 14px;
gap: 12px;
}
&_loading-spinner {
width: 36px;
height: 36px;
border: 3px solid #f0f0f0;
border-top-color: #1890ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
&_error {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 245, 245, 0.95);
z-index: 10;
color: #ff4d4f;
font-size: 14px;
padding: 20px;
text-align: center;
}
&_actions {
padding: 16px 20px;
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
background: #fff;
border-top: 1px solid #f0f0f0;
.ant-btn {
border-radius: 6px;
transition: all 0.2s ease;
&:hover {
transform: translateY(-1px);
}
}
}
}
-2
View File
@@ -3,6 +3,4 @@
* 在业务中可通过 `import { HelloWorld } from '@/components'` 使用
*/
export { default as HelloWorld } from './HelloWorld';
export { default as ImageCropper } from './ImageCropper';
export type { ImageCropperRef, ImageCropperProps } from './ImageCropper';
export { default as ChangePasswordModal } from './ChangePasswordModal';