feature/0723/ZR #1
@@ -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: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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -6,10 +6,13 @@ import App from './App';
|
||||
import router from './router';
|
||||
import './router/guard'; // 路由守卫:登录鉴权 + 动态菜单加载
|
||||
import './assets/styles/index.less';
|
||||
import ypComponent from '@yp-component/root';
|
||||
import '@yp-component/root/style.css';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(router);
|
||||
app.use(Antd);
|
||||
app.use(ypComponent);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Spin,
|
||||
} from 'ant-design-vue';
|
||||
import { JUMP_TYPE_OPTIONS, EVENT_OPTIONS, REGION_OPTIONS } from '../model/useBannerModel';
|
||||
import { ImageCropper } from '@/components';
|
||||
import { ImageCropper } from '@yp-component/root';
|
||||
import { uploadFile, OssUploadType } from '@/utils/oss';
|
||||
import dayjs from 'dayjs';
|
||||
import styles from './BannerFormModal.module.less';
|
||||
|
||||
Reference in New Issue
Block a user