From 34d9a778a819bf30d92b3d6ae9c7e8322304ab19 Mon Sep 17 00:00:00 2001 From: ZhuRui Date: Sat, 25 Jul 2026 14:55:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20banner=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E9=9D=A2=20=E6=B7=BB=E5=8A=A0=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E8=A3=81=E5=89=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + pnpm-lock.yaml | 21 + src/components/ImageCropper/index.tsx | 563 +++++++++++++++++ src/components/ImageCropper/style.less | 129 ++++ src/components/index.ts | 2 + .../components/BannerFormModal.module.less | 48 +- .../banner/components/BannerFormModal.tsx | 594 ++++++++++-------- src/pages/events/banner/index.tsx | 2 +- 8 files changed, 1086 insertions(+), 275 deletions(-) create mode 100644 src/components/ImageCropper/index.tsx create mode 100644 src/components/ImageCropper/style.less diff --git a/package.json b/package.json index 7cd8ef3..d8ca145 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d7894f..d937aa4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/components/ImageCropper/index.tsx b/src/components/ImageCropper/index.tsx new file mode 100644 index 0000000..0d0e127 --- /dev/null +++ b/src/components/ImageCropper/index.tsx @@ -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; + /** 获取裁剪后的图片 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 new file mode 100644 index 0000000..8af3c3f --- /dev/null +++ b/src/components/ImageCropper/style.less @@ -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); + } + } + } +} diff --git a/src/components/index.ts b/src/components/index.ts index 17c5079..0c7e7d8 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -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'; diff --git a/src/pages/events/banner/components/BannerFormModal.module.less b/src/pages/events/banner/components/BannerFormModal.module.less index 8dd12e8..8d3a844 100644 --- a/src/pages/events/banner/components/BannerFormModal.module.less +++ b/src/pages/events/banner/components/BannerFormModal.module.less @@ -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); + } } /* ===== 提示 ===== */ diff --git a/src/pages/events/banner/components/BannerFormModal.tsx b/src/pages/events/banner/components/BannerFormModal.tsx index 4d2536a..bde7652 100644 --- a/src/pages/events/banner/components/BannerFormModal.tsx +++ b/src/pages/events/banner/components/BannerFormModal.tsx @@ -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(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) => { - URL.revokeObjectURL(localUrl); - formData.imageUrl = url; - formData.imageStatus = 'success'; - message.success('图片上传成功'); - }, - onError: (err, idx) => { - console.error(`[Banner] 上传失败 (${idx}):`, err); - URL.revokeObjectURL(localUrl); - formData.imageUrl = ''; - formData.imageStatus = 'error'; - message.error('图片上传失败'); - }, - }); + const file = base64ToFile(dataUrl, `banner_${Date.now()}.jpg`); + const result = await uploadFile(file, OssUploadType.Banner as any); + + // 释放本地预览 + URL.revokeObjectURL(localUrl); + formData.imageUrl = result.url; + formData.imageStatus = 'success'; + message.success('图片上传成功'); } 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,266 +262,292 @@ export default defineComponent({ }; return () => ( - -
- {/* ===== Banner 标题 ===== */} - - - - - {/* ===== 上传图片 ===== */} - { - if (!formData.imageUrl || formData.imageUrl.startsWith('blob:')) { - return Promise.reject(new Error('请上传 Banner 图片')); - } - return Promise.resolve(); - }, - }, - ]} - > -
- - {formData.imageStatus === 'empty' || formData.imageStatus === 'error' ? ( -
- + - 点击上传 - - 建议尺寸 1053*351 像素 -
- 最多上传 1 张 -
-
- ) : ( -
- - banner preview - -
- - -
-
- )} -
-
- - {/* ===== 跳转类型 ===== */} - - - - )} - - {formData.jumpType === 'miniprogram_page' && ( - - )} - {formData.jumpType === 'custom_link' && ( + {/* ===== 上传图片(必填,带裁剪) ===== */} - - - )} - - {formData.jumpType === 'none' && ( -
选择"无链接"时,点击 Banner 不会跳转。
- )} - - {/* ===== 所属区域 ===== */} - - + {formData.imageStatus === 'empty' || formData.imageStatus === 'error' ? ( +
+ + + 点击上传 + + 建议尺寸 1053*351 像素 +
+ 支持 JPG/PNG,最大 10MB +
+
+ ) : ( +
+ + banner preview + +
+ 重新上传 +
+
+ )} + +
+ + {/* ===== 跳转类型 ===== */} + + + + )} + + {formData.jumpType === 'miniprogram_page' && ( + + + + )} + + {formData.jumpType === 'custom_link' && ( + + + + )} + + {formData.jumpType === 'none' && ( +
选择"无链接"时,点击 Banner 不会跳转。
+ )} + + {/* ===== 所属区域 ===== */} + +