feat: 优化 banner 配置页面 添加图片裁剪
This commit is contained in:
parent
742c764508
commit
34d9a778a8
|
|
@ -18,8 +18,10 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@yp-component/root": "^0.0.48",
|
||||
"ant-design-vue": "^4.0.0",
|
||||
"china-area-data": "^5.0.1",
|
||||
"cropperjs": "^1.6.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"express": "^5.2.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
|
|
|
|||
|
|
@ -11,12 +11,18 @@ importers:
|
|||
'@ant-design/icons-vue':
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1(vue@3.5.40(typescript@5.9.3))
|
||||
'@yp-component/root':
|
||||
specifier: ^0.0.48
|
||||
version: 0.0.48(typescript@5.9.3)
|
||||
ant-design-vue:
|
||||
specifier: ^4.0.0
|
||||
version: 4.2.6(vue@3.5.40(typescript@5.9.3))
|
||||
china-area-data:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1
|
||||
cropperjs:
|
||||
specifier: ^1.6.2
|
||||
version: 1.6.2
|
||||
dayjs:
|
||||
specifier: ^1.11.21
|
||||
version: 1.11.21
|
||||
|
|
@ -839,6 +845,9 @@ packages:
|
|||
'@vue/shared@3.5.40':
|
||||
resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==}
|
||||
|
||||
'@yp-component/root@0.0.48':
|
||||
resolution: {integrity: sha512-TOp8adAob3pMSfecQjEC4uVNCGqNWcShDjSQysd4pYY34i5ldFLRGSiJdpLsFG1xKkcSudiqEc/Si/ZfYoDSqA==}
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
|
@ -1063,6 +1072,9 @@ packages:
|
|||
typescript:
|
||||
optional: true
|
||||
|
||||
cropperjs@1.6.2:
|
||||
resolution: {integrity: sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
|
@ -3025,6 +3037,13 @@ snapshots:
|
|||
|
||||
'@vue/shared@3.5.40': {}
|
||||
|
||||
'@yp-component/root@0.0.48(typescript@5.9.3)':
|
||||
dependencies:
|
||||
cropperjs: 1.6.2
|
||||
vue: 3.5.40(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
|
|
@ -3251,6 +3270,8 @@ snapshots:
|
|||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
cropperjs@1.6.2: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
|
|
|
|||
|
|
@ -0,0 +1,563 @@
|
|||
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:3,NaN 表示自由比例 */
|
||||
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;
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,3 +3,5 @@
|
|||
* 在业务中可通过 `import { HelloWorld } from '@/components'` 使用
|
||||
*/
|
||||
export { default as HelloWorld } from './HelloWorld';
|
||||
export { default as ImageCropper } from './ImageCropper';
|
||||
export type { ImageCropperRef, ImageCropperProps } from './ImageCropper';
|
||||
|
|
|
|||
|
|
@ -58,27 +58,47 @@
|
|||
|
||||
/* ===== 预览 ===== */
|
||||
.previewWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 1px solid #e8e8e8;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background-color: #fff;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
background-color: #f5f5f5;
|
||||
|
||||
:global {
|
||||
.ant-spin-nested-loading,
|
||||
.ant-spin-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.previewImg {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-height: 160px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.previewActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
.uploadAgainBtn {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 提示 ===== */
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ import {
|
|||
Spin,
|
||||
} from 'ant-design-vue';
|
||||
import { JUMP_TYPE_OPTIONS, EVENT_OPTIONS, REGION_OPTIONS } from '../model/useBannerModel';
|
||||
import { uploadFilesOneByOne, OssUploadType } from '@/utils/oss';
|
||||
import { ImageCropper } from '@/components';
|
||||
import { uploadFile, OssUploadType } from '@/utils/oss';
|
||||
import dayjs from 'dayjs';
|
||||
import styles from './BannerFormModal.module.less';
|
||||
|
||||
|
|
@ -34,6 +35,11 @@ const TITLE_MAX = 50;
|
|||
const PATH_MAX = 200;
|
||||
const URL_MAX = 500;
|
||||
|
||||
/** 图片上传限制:最大 10MB */
|
||||
const IMAGE_MAX_SIZE = 10 * 1024 * 1024;
|
||||
/** 裁剪比例:1053:351 = 3:1 */
|
||||
const CROP_ASPECT_RATIO = 1053 / 351;
|
||||
|
||||
interface BannerFormModalProps {
|
||||
visible: boolean;
|
||||
record: any;
|
||||
|
|
@ -42,6 +48,20 @@ interface BannerFormModalProps {
|
|||
onSave: (formData: any) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 base64 字符串转换为 File 对象
|
||||
*/
|
||||
function base64ToFile(dataUrl: string, fileName: string): File {
|
||||
const arr = dataUrl.split(',');
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/png';
|
||||
const bstr = atob(arr[1]);
|
||||
const u8arr = new Uint8Array(bstr.length);
|
||||
for (let i = 0; i < bstr.length; i++) {
|
||||
u8arr[i] = bstr.charCodeAt(i);
|
||||
}
|
||||
return new File([u8arr], fileName, { type: mime });
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner 新增/编辑表单弹窗
|
||||
*/
|
||||
|
|
@ -59,6 +79,11 @@ export default defineComponent({
|
|||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const formData = reactive(getDefaultForm());
|
||||
|
||||
/** 裁剪弹窗状态 */
|
||||
const cropperVisible = ref(false);
|
||||
/** 待裁剪图片的本地预览 URL */
|
||||
const uploadImageUrl = ref('');
|
||||
|
||||
/** 根据 record 初始化表单 */
|
||||
const initFormFromRecord = (record: any) => {
|
||||
const fresh = getDefaultForm();
|
||||
|
|
@ -99,6 +124,8 @@ export default defineComponent({
|
|||
(val) => {
|
||||
if (val) {
|
||||
initFormFromRecord(props.record);
|
||||
cropperVisible.value = false;
|
||||
uploadImageUrl.value = '';
|
||||
setTimeout(() => formRef.value?.clearValidate(), 0);
|
||||
}
|
||||
},
|
||||
|
|
@ -126,8 +153,8 @@ export default defineComponent({
|
|||
fileInputRef.value?.click();
|
||||
};
|
||||
|
||||
/** 处理文件选择 */
|
||||
const handleFileChange = async (e: Event) => {
|
||||
/** 处理文件选择 → 打开裁剪弹窗 */
|
||||
const handleFileChange = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
// 重置 input 以便同一文件可再次选择
|
||||
|
|
@ -138,40 +165,61 @@ export default defineComponent({
|
|||
message.error('请选择图片文件');
|
||||
return;
|
||||
}
|
||||
const MAX_SIZE = 5 * 1024 * 1024;
|
||||
if (file.size > MAX_SIZE) {
|
||||
message.error('图片大小不能超过 5MB');
|
||||
if (file.size > IMAGE_MAX_SIZE) {
|
||||
message.error(`图片大小不能超过 ${IMAGE_MAX_SIZE / 1024 / 1024}MB`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 本地预览
|
||||
const localUrl = URL.createObjectURL(file);
|
||||
// 生成本地预览 URL,打开裁剪弹窗
|
||||
uploadImageUrl.value = URL.createObjectURL(file);
|
||||
cropperVisible.value = true;
|
||||
};
|
||||
|
||||
/** 裁剪确认回调:将裁剪后的图片上传到 OSS */
|
||||
const handleCropConfirm = async (dataUrl: string) => {
|
||||
cropperVisible.value = false;
|
||||
|
||||
// 释放旧的本地预览
|
||||
if (formData.imageUrl && formData.imageUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(formData.imageUrl);
|
||||
}
|
||||
|
||||
// 先用 base64 做本地预览
|
||||
const localUrl = dataUrl;
|
||||
formData.imageUrl = localUrl;
|
||||
formData.imageStatus = 'uploading';
|
||||
|
||||
try {
|
||||
await uploadFilesOneByOne([file], OssUploadType.Banner, {
|
||||
onSuccess: (url) => {
|
||||
const file = base64ToFile(dataUrl, `banner_${Date.now()}.jpg`);
|
||||
const result = await uploadFile(file, OssUploadType.Banner as any);
|
||||
|
||||
// 释放本地预览
|
||||
URL.revokeObjectURL(localUrl);
|
||||
formData.imageUrl = url;
|
||||
formData.imageUrl = result.url;
|
||||
formData.imageStatus = 'success';
|
||||
message.success('图片上传成功');
|
||||
},
|
||||
onError: (err, idx) => {
|
||||
console.error(`[Banner] 上传失败 (${idx}):`, err);
|
||||
URL.revokeObjectURL(localUrl);
|
||||
formData.imageUrl = '';
|
||||
formData.imageStatus = 'error';
|
||||
message.error('图片上传失败');
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[Banner] 获取上传凭证失败:', err);
|
||||
console.error('[Banner] 上传失败:', err);
|
||||
URL.revokeObjectURL(localUrl);
|
||||
formData.imageUrl = '';
|
||||
formData.imageStatus = 'error';
|
||||
message.error(err?.message || '图片上传失败');
|
||||
}
|
||||
|
||||
// 清理裁剪资源
|
||||
if (uploadImageUrl.value) {
|
||||
URL.revokeObjectURL(uploadImageUrl.value);
|
||||
}
|
||||
uploadImageUrl.value = '';
|
||||
};
|
||||
|
||||
/** 裁剪弹窗取消 */
|
||||
const onCropperCancel = () => {
|
||||
cropperVisible.value = false;
|
||||
if (uploadImageUrl.value) {
|
||||
URL.revokeObjectURL(uploadImageUrl.value);
|
||||
}
|
||||
uploadImageUrl.value = '';
|
||||
};
|
||||
|
||||
/** 删除已上传图片 */
|
||||
|
|
@ -214,6 +262,7 @@ export default defineComponent({
|
|||
};
|
||||
|
||||
return () => (
|
||||
<>
|
||||
<Modal
|
||||
title={props.record ? '编辑 Banner' : '新增 Banner'}
|
||||
visible={props.visible}
|
||||
|
|
@ -229,7 +278,7 @@ export default defineComponent({
|
|||
label="Banner标题"
|
||||
name="title"
|
||||
rules={[
|
||||
{ required: true, message: '请输入 Banner 标题' },
|
||||
{ required: true, message: '请输入' },
|
||||
{ max: TITLE_MAX, message: `标题最多 ${TITLE_MAX} 个字符` },
|
||||
]}
|
||||
>
|
||||
|
|
@ -241,11 +290,12 @@ export default defineComponent({
|
|||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* ===== 上传图片 ===== */}
|
||||
{/* ===== 上传图片(必填,带裁剪) ===== */}
|
||||
<Form.Item
|
||||
label="上传图片"
|
||||
name="imageUrl"
|
||||
rules={[
|
||||
{ required: true, message: '请上传 Banner 图片' },
|
||||
{
|
||||
validator: async () => {
|
||||
if (!formData.imageUrl || formData.imageUrl.startsWith('blob:')) {
|
||||
|
|
@ -276,7 +326,7 @@ export default defineComponent({
|
|||
<span class={styles.uploadTip}>
|
||||
建议尺寸 1053*351 像素
|
||||
<br />
|
||||
最多上传 1 张
|
||||
支持 JPG/PNG,最大 10MB
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -284,13 +334,8 @@ export default defineComponent({
|
|||
<Spin spinning={formData.imageStatus === 'uploading'} tip="上传中...">
|
||||
<img class={styles.previewImg} src={formData.imageUrl} alt="banner preview" />
|
||||
</Spin>
|
||||
<div class={styles.previewActions}>
|
||||
<Button size="small" onClick={triggerFilePicker}>
|
||||
替换
|
||||
</Button>
|
||||
<Button size="small" danger onClick={handleRemoveImage}>
|
||||
删除
|
||||
</Button>
|
||||
<div class={styles.uploadAgainBtn} onClick={handleRemoveImage}>
|
||||
重新上传
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -474,6 +519,35 @@ export default defineComponent({
|
|||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ===== 图片裁剪弹窗 ===== */}
|
||||
<Modal
|
||||
visible={cropperVisible.value}
|
||||
title="裁剪图片"
|
||||
width={800}
|
||||
footer={null}
|
||||
onCancel={onCropperCancel}
|
||||
centered
|
||||
destroyOnClose
|
||||
>
|
||||
<div style={{ height: '500px' }}>
|
||||
<ImageCropper
|
||||
src={uploadImageUrl.value}
|
||||
aspectRatio={CROP_ASPECT_RATIO}
|
||||
autoCrop={true}
|
||||
autoCropArea={0.8}
|
||||
viewMode={1}
|
||||
dragMode="move"
|
||||
movable={true}
|
||||
scalable={true}
|
||||
zoomable={true}
|
||||
cropBoxMovable={true}
|
||||
cropBoxResizable={true}
|
||||
onCrop={handleCropConfirm}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ export default defineComponent({
|
|||
<Form style={{ rowGap: '10px' }} layout="inline" model={filterForm}>
|
||||
<Form.Item label="标题" name="searchTitle">
|
||||
<Input
|
||||
placeholder="请输入 Banner 标题"
|
||||
placeholder="请输入"
|
||||
style={{ width: '200px' }}
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
|
|
|
|||
Loading…
Reference in New Issue