feat: 优化 banner 配置页面 添加图片裁剪
This commit is contained in:
parent
742c764508
commit
34d9a778a8
|
|
@ -18,8 +18,10 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons-vue": "^7.0.1",
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
|
"@yp-component/root": "^0.0.48",
|
||||||
"ant-design-vue": "^4.0.0",
|
"ant-design-vue": "^4.0.0",
|
||||||
"china-area-data": "^5.0.1",
|
"china-area-data": "^5.0.1",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"dayjs": "^1.11.21",
|
"dayjs": "^1.11.21",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,18 @@ importers:
|
||||||
'@ant-design/icons-vue':
|
'@ant-design/icons-vue':
|
||||||
specifier: ^7.0.1
|
specifier: ^7.0.1
|
||||||
version: 7.0.1(vue@3.5.40(typescript@5.9.3))
|
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:
|
ant-design-vue:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.2.6(vue@3.5.40(typescript@5.9.3))
|
version: 4.2.6(vue@3.5.40(typescript@5.9.3))
|
||||||
china-area-data:
|
china-area-data:
|
||||||
specifier: ^5.0.1
|
specifier: ^5.0.1
|
||||||
version: 5.0.1
|
version: 5.0.1
|
||||||
|
cropperjs:
|
||||||
|
specifier: ^1.6.2
|
||||||
|
version: 1.6.2
|
||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.21
|
specifier: ^1.11.21
|
||||||
version: 1.11.21
|
version: 1.11.21
|
||||||
|
|
@ -839,6 +845,9 @@ packages:
|
||||||
'@vue/shared@3.5.40':
|
'@vue/shared@3.5.40':
|
||||||
resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==}
|
resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==}
|
||||||
|
|
||||||
|
'@yp-component/root@0.0.48':
|
||||||
|
resolution: {integrity: sha512-TOp8adAob3pMSfecQjEC4uVNCGqNWcShDjSQysd4pYY34i5ldFLRGSiJdpLsFG1xKkcSudiqEc/Si/ZfYoDSqA==}
|
||||||
|
|
||||||
accepts@2.0.0:
|
accepts@2.0.0:
|
||||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
@ -1063,6 +1072,9 @@ packages:
|
||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
cropperjs@1.6.2:
|
||||||
|
resolution: {integrity: sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -3025,6 +3037,13 @@ snapshots:
|
||||||
|
|
||||||
'@vue/shared@3.5.40': {}
|
'@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:
|
accepts@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-types: 3.0.2
|
mime-types: 3.0.2
|
||||||
|
|
@ -3251,6 +3270,8 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
cropperjs@1.6.2: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
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'` 使用
|
* 在业务中可通过 `import { HelloWorld } from '@/components'` 使用
|
||||||
*/
|
*/
|
||||||
export { default as HelloWorld } from './HelloWorld';
|
export { default as HelloWorld } from './HelloWorld';
|
||||||
|
export { default as ImageCropper } from './ImageCropper';
|
||||||
|
export type { ImageCropperRef, ImageCropperProps } from './ImageCropper';
|
||||||
|
|
|
||||||
|
|
@ -58,27 +58,47 @@
|
||||||
|
|
||||||
/* ===== 预览 ===== */
|
/* ===== 预览 ===== */
|
||||||
.previewWrapper {
|
.previewWrapper {
|
||||||
display: flex;
|
position: relative;
|
||||||
flex-direction: column;
|
width: 100%;
|
||||||
align-items: center;
|
height: 140px;
|
||||||
gap: 12px;
|
border: 1px solid #d9d9d9;
|
||||||
border: 1px solid #e8e8e8;
|
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background-color: #fff;
|
overflow: hidden;
|
||||||
padding: 12px;
|
background-color: #f5f5f5;
|
||||||
|
|
||||||
|
:global {
|
||||||
|
.ant-spin-nested-loading,
|
||||||
|
.ant-spin-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewImg {
|
.previewImg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
height: 100%;
|
||||||
max-height: 160px;
|
object-fit: cover;
|
||||||
object-fit: contain;
|
display: block;
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewActions {
|
.uploadAgainBtn {
|
||||||
display: flex;
|
position: absolute;
|
||||||
gap: 8px;
|
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,
|
Spin,
|
||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
import { JUMP_TYPE_OPTIONS, EVENT_OPTIONS, REGION_OPTIONS } from '../model/useBannerModel';
|
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 dayjs from 'dayjs';
|
||||||
import styles from './BannerFormModal.module.less';
|
import styles from './BannerFormModal.module.less';
|
||||||
|
|
||||||
|
|
@ -34,6 +35,11 @@ const TITLE_MAX = 50;
|
||||||
const PATH_MAX = 200;
|
const PATH_MAX = 200;
|
||||||
const URL_MAX = 500;
|
const URL_MAX = 500;
|
||||||
|
|
||||||
|
/** 图片上传限制:最大 10MB */
|
||||||
|
const IMAGE_MAX_SIZE = 10 * 1024 * 1024;
|
||||||
|
/** 裁剪比例:1053:351 = 3:1 */
|
||||||
|
const CROP_ASPECT_RATIO = 1053 / 351;
|
||||||
|
|
||||||
interface BannerFormModalProps {
|
interface BannerFormModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
record: any;
|
record: any;
|
||||||
|
|
@ -42,6 +48,20 @@ interface BannerFormModalProps {
|
||||||
onSave: (formData: any) => void;
|
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 新增/编辑表单弹窗
|
* Banner 新增/编辑表单弹窗
|
||||||
*/
|
*/
|
||||||
|
|
@ -59,6 +79,11 @@ export default defineComponent({
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||||
const formData = reactive(getDefaultForm());
|
const formData = reactive(getDefaultForm());
|
||||||
|
|
||||||
|
/** 裁剪弹窗状态 */
|
||||||
|
const cropperVisible = ref(false);
|
||||||
|
/** 待裁剪图片的本地预览 URL */
|
||||||
|
const uploadImageUrl = ref('');
|
||||||
|
|
||||||
/** 根据 record 初始化表单 */
|
/** 根据 record 初始化表单 */
|
||||||
const initFormFromRecord = (record: any) => {
|
const initFormFromRecord = (record: any) => {
|
||||||
const fresh = getDefaultForm();
|
const fresh = getDefaultForm();
|
||||||
|
|
@ -99,6 +124,8 @@ export default defineComponent({
|
||||||
(val) => {
|
(val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
initFormFromRecord(props.record);
|
initFormFromRecord(props.record);
|
||||||
|
cropperVisible.value = false;
|
||||||
|
uploadImageUrl.value = '';
|
||||||
setTimeout(() => formRef.value?.clearValidate(), 0);
|
setTimeout(() => formRef.value?.clearValidate(), 0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -126,8 +153,8 @@ export default defineComponent({
|
||||||
fileInputRef.value?.click();
|
fileInputRef.value?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 处理文件选择 */
|
/** 处理文件选择 → 打开裁剪弹窗 */
|
||||||
const handleFileChange = async (e: Event) => {
|
const handleFileChange = (e: Event) => {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
const file = target.files?.[0];
|
const file = target.files?.[0];
|
||||||
// 重置 input 以便同一文件可再次选择
|
// 重置 input 以便同一文件可再次选择
|
||||||
|
|
@ -138,40 +165,61 @@ export default defineComponent({
|
||||||
message.error('请选择图片文件');
|
message.error('请选择图片文件');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const MAX_SIZE = 5 * 1024 * 1024;
|
if (file.size > IMAGE_MAX_SIZE) {
|
||||||
if (file.size > MAX_SIZE) {
|
message.error(`图片大小不能超过 ${IMAGE_MAX_SIZE / 1024 / 1024}MB`);
|
||||||
message.error('图片大小不能超过 5MB');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 本地预览
|
// 生成本地预览 URL,打开裁剪弹窗
|
||||||
const localUrl = URL.createObjectURL(file);
|
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.imageUrl = localUrl;
|
||||||
formData.imageStatus = 'uploading';
|
formData.imageStatus = 'uploading';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await uploadFilesOneByOne([file], OssUploadType.Banner, {
|
const file = base64ToFile(dataUrl, `banner_${Date.now()}.jpg`);
|
||||||
onSuccess: (url) => {
|
const result = await uploadFile(file, OssUploadType.Banner as any);
|
||||||
URL.revokeObjectURL(localUrl);
|
|
||||||
formData.imageUrl = url;
|
// 释放本地预览
|
||||||
formData.imageStatus = 'success';
|
URL.revokeObjectURL(localUrl);
|
||||||
message.success('图片上传成功');
|
formData.imageUrl = result.url;
|
||||||
},
|
formData.imageStatus = 'success';
|
||||||
onError: (err, idx) => {
|
message.success('图片上传成功');
|
||||||
console.error(`[Banner] 上传失败 (${idx}):`, err);
|
|
||||||
URL.revokeObjectURL(localUrl);
|
|
||||||
formData.imageUrl = '';
|
|
||||||
formData.imageStatus = 'error';
|
|
||||||
message.error('图片上传失败');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[Banner] 获取上传凭证失败:', err);
|
console.error('[Banner] 上传失败:', err);
|
||||||
URL.revokeObjectURL(localUrl);
|
URL.revokeObjectURL(localUrl);
|
||||||
formData.imageUrl = '';
|
formData.imageUrl = '';
|
||||||
formData.imageStatus = 'error';
|
formData.imageStatus = 'error';
|
||||||
message.error(err?.message || '图片上传失败');
|
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 () => (
|
return () => (
|
||||||
<Modal
|
<>
|
||||||
title={props.record ? '编辑 Banner' : '新增 Banner'}
|
<Modal
|
||||||
visible={props.visible}
|
title={props.record ? '编辑 Banner' : '新增 Banner'}
|
||||||
onCancel={props.onClose}
|
visible={props.visible}
|
||||||
width={640}
|
onCancel={props.onClose}
|
||||||
destroyOnClose
|
width={640}
|
||||||
footer={null}
|
destroyOnClose
|
||||||
centered
|
footer={null}
|
||||||
>
|
centered
|
||||||
<Form ref={formRef} layout="vertical" model={formData} class={styles.form} requiredMark>
|
>
|
||||||
{/* ===== Banner 标题 ===== */}
|
<Form ref={formRef} layout="vertical" model={formData} class={styles.form} requiredMark>
|
||||||
<Form.Item
|
{/* ===== Banner 标题 ===== */}
|
||||||
label="Banner标题"
|
|
||||||
name="title"
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: '请输入 Banner 标题' },
|
|
||||||
{ max: TITLE_MAX, message: `标题最多 ${TITLE_MAX} 个字符` },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="请输入标题"
|
|
||||||
maxlength={TITLE_MAX}
|
|
||||||
allowClear
|
|
||||||
v-model:value={formData.title}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{/* ===== 上传图片 ===== */}
|
|
||||||
<Form.Item
|
|
||||||
label="上传图片"
|
|
||||||
name="imageUrl"
|
|
||||||
rules={[
|
|
||||||
{
|
|
||||||
validator: async () => {
|
|
||||||
if (!formData.imageUrl || formData.imageUrl.startsWith('blob:')) {
|
|
||||||
return Promise.reject(new Error('请上传 Banner 图片'));
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<div class={styles.uploader}>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
{formData.imageStatus === 'empty' || formData.imageStatus === 'error' ? (
|
|
||||||
<div
|
|
||||||
class={`${styles.uploadCard} ${
|
|
||||||
formData.imageStatus === 'error' ? styles.uploadCardError : ''
|
|
||||||
}`}
|
|
||||||
onClick={triggerFilePicker}
|
|
||||||
>
|
|
||||||
<span class={styles.uploadIcon}>+</span>
|
|
||||||
<span class={styles.uploadHint}>点击上传</span>
|
|
||||||
<span class={styles.uploadTip}>
|
|
||||||
建议尺寸 1053*351 像素
|
|
||||||
<br />
|
|
||||||
最多上传 1 张
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div class={styles.previewWrapper}>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{/* ===== 跳转类型 ===== */}
|
|
||||||
<Form.Item
|
|
||||||
label="跳转类型"
|
|
||||||
name="jumpType"
|
|
||||||
rules={[{ required: true, message: '请选择跳转类型' }]}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
options={JUMP_TYPE_OPTIONS as any}
|
|
||||||
placeholder="请选择"
|
|
||||||
v-model:value={formData.jumpType}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{/* ===== 联动字段:根据跳转类型动态展示 ===== */}
|
|
||||||
{formData.jumpType === 'event_detail' && (
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="关联赛事"
|
label="Banner标题"
|
||||||
name="relatedEventId"
|
name="title"
|
||||||
rules={[{ required: true, message: '请选择关联赛事' }]}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
options={EVENT_OPTIONS as any}
|
|
||||||
placeholder="不关联"
|
|
||||||
allowClear
|
|
||||||
v-model:value={formData.relatedEventId}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{formData.jumpType === 'miniprogram_page' && (
|
|
||||||
<Form.Item
|
|
||||||
label="页面路径"
|
|
||||||
name="pagePath"
|
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: '请输入页面路径' },
|
{ required: true, message: '请输入' },
|
||||||
{
|
{ max: TITLE_MAX, message: `标题最多 ${TITLE_MAX} 个字符` },
|
||||||
pattern: /^\//,
|
|
||||||
message: '页面路径需以 / 开头',
|
|
||||||
},
|
|
||||||
{ max: PATH_MAX, message: `路径最多 ${PATH_MAX} 个字符` },
|
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
placeholder="/pages/index/index"
|
placeholder="请输入标题"
|
||||||
maxlength={PATH_MAX}
|
maxlength={TITLE_MAX}
|
||||||
allowClear
|
allowClear
|
||||||
v-model:value={formData.pagePath}
|
v-model:value={formData.title}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
|
||||||
|
|
||||||
{formData.jumpType === 'custom_link' && (
|
{/* ===== 上传图片(必填,带裁剪) ===== */}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="自定义链接"
|
label="上传图片"
|
||||||
name="customUrl"
|
name="imageUrl"
|
||||||
rules={[
|
rules={[
|
||||||
{ required: true, message: '请输入自定义链接' },
|
{ required: true, message: '请上传 Banner 图片' },
|
||||||
{
|
|
||||||
pattern: /^https?:\/\//i,
|
|
||||||
message: '请输入以 http/https 开头的链接',
|
|
||||||
},
|
|
||||||
{ max: URL_MAX, message: `链接最多 ${URL_MAX} 个字符` },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="https://xxx"
|
|
||||||
maxlength={URL_MAX}
|
|
||||||
allowClear
|
|
||||||
v-model:value={formData.customUrl}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{formData.jumpType === 'none' && (
|
|
||||||
<div class={styles.tip}>选择"无链接"时,点击 Banner 不会跳转。</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ===== 所属区域 ===== */}
|
|
||||||
<Form.Item
|
|
||||||
label="所属区域"
|
|
||||||
name="region"
|
|
||||||
rules={[{ required: true, message: '请选择所属区域' }]}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
options={REGION_OPTIONS as any}
|
|
||||||
placeholder="首页"
|
|
||||||
v-model:value={formData.region}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{/* ===== 展示时间(开始/结束) ===== */}
|
|
||||||
<div class={styles.timeRow}>
|
|
||||||
<Form.Item
|
|
||||||
label="开始时间"
|
|
||||||
name="displayStartTime"
|
|
||||||
class={styles.timeItem}
|
|
||||||
rules={[{ required: true, message: '请选择开始时间' }]}
|
|
||||||
>
|
|
||||||
<DatePicker
|
|
||||||
placeholder="年-月-日"
|
|
||||||
format="YYYY-MM-DD"
|
|
||||||
value={
|
|
||||||
formData.displayTimeRange?.[0] ? dayjs(formData.displayTimeRange[0]) : undefined
|
|
||||||
}
|
|
||||||
onUpdate:value={(v: any) => {
|
|
||||||
formData.displayTimeRange = [
|
|
||||||
v ? v.format('YYYY-MM-DD') : '',
|
|
||||||
formData.displayTimeRange?.[1] || '',
|
|
||||||
];
|
|
||||||
}}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
|
||||||
label="结束时间"
|
|
||||||
name="displayEndTime"
|
|
||||||
class={styles.timeItem}
|
|
||||||
rules={[
|
|
||||||
{ required: true, message: '请选择结束时间' },
|
|
||||||
{
|
{
|
||||||
validator: async () => {
|
validator: async () => {
|
||||||
const [s, e] = formData.displayTimeRange || [];
|
if (!formData.imageUrl || formData.imageUrl.startsWith('blob:')) {
|
||||||
if (s && e && new Date(e) < new Date(s)) {
|
return Promise.reject(new Error('请上传 Banner 图片'));
|
||||||
return Promise.reject(new Error('结束时间不能早于开始时间'));
|
|
||||||
}
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<DatePicker
|
<div class={styles.uploader}>
|
||||||
placeholder="年-月-日"
|
<input
|
||||||
format="YYYY-MM-DD"
|
ref={fileInputRef}
|
||||||
value={
|
type="file"
|
||||||
formData.displayTimeRange?.[1] ? dayjs(formData.displayTimeRange[1]) : undefined
|
accept="image/*"
|
||||||
}
|
style={{ display: 'none' }}
|
||||||
onUpdate:value={(v: any) => {
|
onChange={handleFileChange}
|
||||||
formData.displayTimeRange = [
|
/>
|
||||||
formData.displayTimeRange?.[0] || '',
|
{formData.imageStatus === 'empty' || formData.imageStatus === 'error' ? (
|
||||||
v ? v.format('YYYY-MM-DD') : '',
|
<div
|
||||||
];
|
class={`${styles.uploadCard} ${
|
||||||
}}
|
formData.imageStatus === 'error' ? styles.uploadCardError : ''
|
||||||
style={{ width: '100%' }}
|
}`}
|
||||||
|
onClick={triggerFilePicker}
|
||||||
|
>
|
||||||
|
<span class={styles.uploadIcon}>+</span>
|
||||||
|
<span class={styles.uploadHint}>点击上传</span>
|
||||||
|
<span class={styles.uploadTip}>
|
||||||
|
建议尺寸 1053*351 像素
|
||||||
|
<br />
|
||||||
|
支持 JPG/PNG,最大 10MB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class={styles.previewWrapper}>
|
||||||
|
<Spin spinning={formData.imageStatus === 'uploading'} tip="上传中...">
|
||||||
|
<img class={styles.previewImg} src={formData.imageUrl} alt="banner preview" />
|
||||||
|
</Spin>
|
||||||
|
<div class={styles.uploadAgainBtn} onClick={handleRemoveImage}>
|
||||||
|
重新上传
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* ===== 跳转类型 ===== */}
|
||||||
|
<Form.Item
|
||||||
|
label="跳转类型"
|
||||||
|
name="jumpType"
|
||||||
|
rules={[{ required: true, message: '请选择跳转类型' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={JUMP_TYPE_OPTIONS as any}
|
||||||
|
placeholder="请选择"
|
||||||
|
v-model:value={formData.jumpType}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* ===== 联动字段:根据跳转类型动态展示 ===== */}
|
||||||
|
{formData.jumpType === 'event_detail' && (
|
||||||
|
<Form.Item
|
||||||
|
label="关联赛事"
|
||||||
|
name="relatedEventId"
|
||||||
|
rules={[{ required: true, message: '请选择关联赛事' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={EVENT_OPTIONS as any}
|
||||||
|
placeholder="不关联"
|
||||||
|
allowClear
|
||||||
|
v-model:value={formData.relatedEventId}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.jumpType === 'miniprogram_page' && (
|
||||||
|
<Form.Item
|
||||||
|
label="页面路径"
|
||||||
|
name="pagePath"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入页面路径' },
|
||||||
|
{
|
||||||
|
pattern: /^\//,
|
||||||
|
message: '页面路径需以 / 开头',
|
||||||
|
},
|
||||||
|
{ max: PATH_MAX, message: `路径最多 ${PATH_MAX} 个字符` },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="/pages/index/index"
|
||||||
|
maxlength={PATH_MAX}
|
||||||
|
allowClear
|
||||||
|
v-model:value={formData.pagePath}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.jumpType === 'custom_link' && (
|
||||||
|
<Form.Item
|
||||||
|
label="自定义链接"
|
||||||
|
name="customUrl"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入自定义链接' },
|
||||||
|
{
|
||||||
|
pattern: /^https?:\/\//i,
|
||||||
|
message: '请输入以 http/https 开头的链接',
|
||||||
|
},
|
||||||
|
{ max: URL_MAX, message: `链接最多 ${URL_MAX} 个字符` },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="https://xxx"
|
||||||
|
maxlength={URL_MAX}
|
||||||
|
allowClear
|
||||||
|
v-model:value={formData.customUrl}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.jumpType === 'none' && (
|
||||||
|
<div class={styles.tip}>选择"无链接"时,点击 Banner 不会跳转。</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== 所属区域 ===== */}
|
||||||
|
<Form.Item
|
||||||
|
label="所属区域"
|
||||||
|
name="region"
|
||||||
|
rules={[{ required: true, message: '请选择所属区域' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={REGION_OPTIONS as any}
|
||||||
|
placeholder="首页"
|
||||||
|
v-model:value={formData.region}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* ===== 展示时间(开始/结束) ===== */}
|
||||||
|
<div class={styles.timeRow}>
|
||||||
|
<Form.Item
|
||||||
|
label="开始时间"
|
||||||
|
name="displayStartTime"
|
||||||
|
class={styles.timeItem}
|
||||||
|
rules={[{ required: true, message: '请选择开始时间' }]}
|
||||||
|
>
|
||||||
|
<DatePicker
|
||||||
|
placeholder="年-月-日"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
value={
|
||||||
|
formData.displayTimeRange?.[0] ? dayjs(formData.displayTimeRange[0]) : undefined
|
||||||
|
}
|
||||||
|
onUpdate:value={(v: any) => {
|
||||||
|
formData.displayTimeRange = [
|
||||||
|
v ? v.format('YYYY-MM-DD') : '',
|
||||||
|
formData.displayTimeRange?.[1] || '',
|
||||||
|
];
|
||||||
|
}}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="结束时间"
|
||||||
|
name="displayEndTime"
|
||||||
|
class={styles.timeItem}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请选择结束时间' },
|
||||||
|
{
|
||||||
|
validator: async () => {
|
||||||
|
const [s, e] = formData.displayTimeRange || [];
|
||||||
|
if (s && e && new Date(e) < new Date(s)) {
|
||||||
|
return Promise.reject(new Error('结束时间不能早于开始时间'));
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<DatePicker
|
||||||
|
placeholder="年-月-日"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
value={
|
||||||
|
formData.displayTimeRange?.[1] ? dayjs(formData.displayTimeRange[1]) : undefined
|
||||||
|
}
|
||||||
|
onUpdate:value={(v: any) => {
|
||||||
|
formData.displayTimeRange = [
|
||||||
|
formData.displayTimeRange?.[0] || '',
|
||||||
|
v ? v.format('YYYY-MM-DD') : '',
|
||||||
|
];
|
||||||
|
}}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 排序 ===== */}
|
||||||
|
<Form.Item
|
||||||
|
label="排序"
|
||||||
|
name="sort"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入排序' },
|
||||||
|
{ type: 'number', min: 1, max: 999, message: '排序范围 1-999' },
|
||||||
|
]}
|
||||||
|
extra="数字越小越靠前"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
placeholder="数字越小越靠前"
|
||||||
|
min={1}
|
||||||
|
max={999}
|
||||||
|
precision={0}
|
||||||
|
style={{ width: '200px' }}
|
||||||
|
v-model:value={formData.sort}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{/* ===== 底部按钮 ===== */}
|
||||||
|
<div class={styles.footer}>
|
||||||
|
<Button onClick={props.onClose}>取消</Button>
|
||||||
|
<Button type="primary" loading={props.submitting} onClick={handleSubmit}>
|
||||||
|
确认保存
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* ===== 排序 ===== */}
|
{/* ===== 图片裁剪弹窗 ===== */}
|
||||||
<Form.Item
|
<Modal
|
||||||
label="排序"
|
visible={cropperVisible.value}
|
||||||
name="sort"
|
title="裁剪图片"
|
||||||
rules={[
|
width={800}
|
||||||
{ required: true, message: '请输入排序' },
|
footer={null}
|
||||||
{ type: 'number', min: 1, max: 999, message: '排序范围 1-999' },
|
onCancel={onCropperCancel}
|
||||||
]}
|
centered
|
||||||
extra="数字越小越靠前"
|
destroyOnClose
|
||||||
>
|
>
|
||||||
<InputNumber
|
<div style={{ height: '500px' }}>
|
||||||
placeholder="数字越小越靠前"
|
<ImageCropper
|
||||||
min={1}
|
src={uploadImageUrl.value}
|
||||||
max={999}
|
aspectRatio={CROP_ASPECT_RATIO}
|
||||||
precision={0}
|
autoCrop={true}
|
||||||
style={{ width: '200px' }}
|
autoCropArea={0.8}
|
||||||
v-model:value={formData.sort}
|
viewMode={1}
|
||||||
|
dragMode="move"
|
||||||
|
movable={true}
|
||||||
|
scalable={true}
|
||||||
|
zoomable={true}
|
||||||
|
cropBoxMovable={true}
|
||||||
|
cropBoxResizable={true}
|
||||||
|
onCrop={handleCropConfirm}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</div>
|
||||||
</Form>
|
</Modal>
|
||||||
|
</>
|
||||||
{/* ===== 底部按钮 ===== */}
|
|
||||||
<div class={styles.footer}>
|
|
||||||
<Button onClick={props.onClose}>取消</Button>
|
|
||||||
<Button type="primary" loading={props.submitting} onClick={handleSubmit}>
|
|
||||||
确认保存
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ export default defineComponent({
|
||||||
<Form style={{ rowGap: '10px' }} layout="inline" model={filterForm}>
|
<Form style={{ rowGap: '10px' }} layout="inline" model={filterForm}>
|
||||||
<Form.Item label="标题" name="searchTitle">
|
<Form.Item label="标题" name="searchTitle">
|
||||||
<Input
|
<Input
|
||||||
placeholder="请输入 Banner 标题"
|
placeholder="请输入"
|
||||||
style={{ width: '200px' }}
|
style={{ width: '200px' }}
|
||||||
allowClear
|
allowClear
|
||||||
onPressEnter={handleSearch}
|
onPressEnter={handleSearch}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue