diff --git a/src/components/ImageCropper/index.tsx b/src/components/ImageCropper/index.tsx deleted file mode 100644 index 0d0e127..0000000 --- a/src/components/ImageCropper/index.tsx +++ /dev/null @@ -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; - /** 获取裁剪后的图片 Blob */ - getCroppedBlob: (options?: Cropper.GetCroppedCanvasOptions) => Promise; - /** 确认裁剪,返回 base64 并触发 onCrop 回调 */ - confirmCrop: () => Promise; - /** 设置裁剪框数据 */ - setCropBoxData: (data: Partial) => 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 = { - 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(null); - const cropperRef = ref(null); - const isLoading = ref(false); - const error = ref(null); - const cropData = ref(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) => { - 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 => { - 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 => { - 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 => { - 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 () => ( -
-
- {isLoading.value && ( -
-
- 加载中... -
- )} - {error.value && ( -
- 加载失败: {error.value.message} -
- )} - {props.src ? ( - crop - ) : ( -
- 请上传图片 -
- )} - - {props.showCropInfo && cropInfo.value && props.src && !error.value && ( -
- - 尺寸: {cropInfo.value.width} × {cropInfo.value.height} - - 比例: {cropInfo.value.ratio} -
- )} -
- {props.src && !error.value && ( -
- - - - - - -
- )} -
- ); - }, -}); - -export { ImageCropper }; -export default ImageCropper; diff --git a/src/components/ImageCropper/style.less b/src/components/ImageCropper/style.less deleted file mode 100644 index 8af3c3f..0000000 --- a/src/components/ImageCropper/style.less +++ /dev/null @@ -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); - } - } - } -} diff --git a/src/components/index.ts b/src/components/index.ts index 30b0699..fdb9b81 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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'; diff --git a/src/main.ts b/src/main.ts index d723be9..2c16dae 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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'); diff --git a/src/pages/events/banner/components/BannerFormModal.tsx b/src/pages/events/banner/components/BannerFormModal.tsx index eb8cf2f..2327240 100644 --- a/src/pages/events/banner/components/BannerFormModal.tsx +++ b/src/pages/events/banner/components/BannerFormModal.tsx @@ -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';