feat: 优化 banner 配置页面 添加图片裁剪

This commit is contained in:
ZhuRui 2026-07-25 14:55:56 +08:00
parent 742c764508
commit 34d9a778a8
8 changed files with 1086 additions and 275 deletions

View File

@ -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",

View File

@ -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

View File

@ -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:14/3 表示 4:3NaN 表示自由比例 */
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;

View File

@ -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);
}
}
}
}

View File

@ -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';

View File

@ -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);
}
}
/* ===== 提示 ===== */

View File

@ -11,7 +11,8 @@ import {
Spin,
} from 'ant-design-vue';
import { JUMP_TYPE_OPTIONS, EVENT_OPTIONS, REGION_OPTIONS } from '../model/useBannerModel';
import { uploadFilesOneByOne, OssUploadType } from '@/utils/oss';
import { ImageCropper } from '@/components';
import { uploadFile, OssUploadType } from '@/utils/oss';
import dayjs from 'dayjs';
import styles from './BannerFormModal.module.less';
@ -34,6 +35,11 @@ const TITLE_MAX = 50;
const PATH_MAX = 200;
const URL_MAX = 500;
/** 图片上传限制:最大 10MB */
const IMAGE_MAX_SIZE = 10 * 1024 * 1024;
/** 裁剪比例1053:351 = 3:1 */
const CROP_ASPECT_RATIO = 1053 / 351;
interface BannerFormModalProps {
visible: boolean;
record: any;
@ -42,6 +48,20 @@ interface BannerFormModalProps {
onSave: (formData: any) => void;
}
/**
* base64 File
*/
function base64ToFile(dataUrl: string, fileName: string): File {
const arr = dataUrl.split(',');
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/png';
const bstr = atob(arr[1]);
const u8arr = new Uint8Array(bstr.length);
for (let i = 0; i < bstr.length; i++) {
u8arr[i] = bstr.charCodeAt(i);
}
return new File([u8arr], fileName, { type: mime });
}
/**
* Banner /
*/
@ -59,6 +79,11 @@ export default defineComponent({
const fileInputRef = ref<HTMLInputElement | null>(null);
const formData = reactive(getDefaultForm());
/** 裁剪弹窗状态 */
const cropperVisible = ref(false);
/** 待裁剪图片的本地预览 URL */
const uploadImageUrl = ref('');
/** 根据 record 初始化表单 */
const initFormFromRecord = (record: any) => {
const fresh = getDefaultForm();
@ -99,6 +124,8 @@ export default defineComponent({
(val) => {
if (val) {
initFormFromRecord(props.record);
cropperVisible.value = false;
uploadImageUrl.value = '';
setTimeout(() => formRef.value?.clearValidate(), 0);
}
},
@ -126,8 +153,8 @@ export default defineComponent({
fileInputRef.value?.click();
};
/** 处理文件选择 */
const handleFileChange = async (e: Event) => {
/** 处理文件选择 → 打开裁剪弹窗 */
const handleFileChange = (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
// 重置 input 以便同一文件可再次选择
@ -138,40 +165,61 @@ export default defineComponent({
message.error('请选择图片文件');
return;
}
const MAX_SIZE = 5 * 1024 * 1024;
if (file.size > MAX_SIZE) {
message.error('图片大小不能超过 5MB');
if (file.size > IMAGE_MAX_SIZE) {
message.error(`图片大小不能超过 ${IMAGE_MAX_SIZE / 1024 / 1024}MB`);
return;
}
// 本地预览
const localUrl = URL.createObjectURL(file);
// 生成本地预览 URL打开裁剪弹窗
uploadImageUrl.value = URL.createObjectURL(file);
cropperVisible.value = true;
};
/** 裁剪确认回调:将裁剪后的图片上传到 OSS */
const handleCropConfirm = async (dataUrl: string) => {
cropperVisible.value = false;
// 释放旧的本地预览
if (formData.imageUrl && formData.imageUrl.startsWith('blob:')) {
URL.revokeObjectURL(formData.imageUrl);
}
// 先用 base64 做本地预览
const localUrl = dataUrl;
formData.imageUrl = localUrl;
formData.imageStatus = 'uploading';
try {
await uploadFilesOneByOne([file], OssUploadType.Banner, {
onSuccess: (url) => {
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 () => (
<Modal
title={props.record ? '编辑 Banner' : '新增 Banner'}
visible={props.visible}
onCancel={props.onClose}
width={640}
destroyOnClose
footer={null}
centered
>
<Form ref={formRef} layout="vertical" model={formData} class={styles.form} requiredMark>
{/* ===== Banner 标题 ===== */}
<Form.Item
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' && (
<>
<Modal
title={props.record ? '编辑 Banner' : '新增 Banner'}
visible={props.visible}
onCancel={props.onClose}
width={640}
destroyOnClose
footer={null}
centered
>
<Form ref={formRef} layout="vertical" model={formData} class={styles.form} requiredMark>
{/* ===== Banner 标题 ===== */}
<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"
label="Banner标题"
name="title"
rules={[
{ required: true, message: '请输入页面路径' },
{
pattern: /^\//,
message: '页面路径需以 / 开头',
},
{ max: PATH_MAX, message: `路径最多 ${PATH_MAX} 个字符` },
{ required: true, message: '请输入' },
{ max: TITLE_MAX, message: `标题最多 ${TITLE_MAX} 个字符` },
]}
>
<Input
placeholder="/pages/index/index"
maxlength={PATH_MAX}
placeholder="请输入标题"
maxlength={TITLE_MAX}
allowClear
v-model:value={formData.pagePath}
v-model:value={formData.title}
/>
</Form.Item>
)}
{formData.jumpType === 'custom_link' && (
{/* ===== 上传图片(必填,带裁剪) ===== */}
<Form.Item
label="自定义链接"
name="customUrl"
label="上传图片"
name="imageUrl"
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: '请选择结束时间' },
{ required: true, message: '请上传 Banner 图片' },
{
validator: async () => {
const [s, e] = formData.displayTimeRange || [];
if (s && e && new Date(e) < new Date(s)) {
return Promise.reject(new Error('结束时间不能早于开始时间'));
if (!formData.imageUrl || formData.imageUrl.startsWith('blob:')) {
return Promise.reject(new Error('请上传 Banner 图片'));
}
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%' }}
<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 />
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>
{/* ===== 联动字段:根据跳转类型动态展示 ===== */}
{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>
</Modal>
{/* ===== 排序 ===== */}
<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}
{/* ===== 图片裁剪弹窗 ===== */}
<Modal
visible={cropperVisible.value}
title="裁剪图片"
width={800}
footer={null}
onCancel={onCropperCancel}
centered
destroyOnClose
>
<div style={{ height: '500px' }}>
<ImageCropper
src={uploadImageUrl.value}
aspectRatio={CROP_ASPECT_RATIO}
autoCrop={true}
autoCropArea={0.8}
viewMode={1}
dragMode="move"
movable={true}
scalable={true}
zoomable={true}
cropBoxMovable={true}
cropBoxResizable={true}
onCrop={handleCropConfirm}
/>
</Form.Item>
</Form>
{/* ===== 底部按钮 ===== */}
<div class={styles.footer}>
<Button onClick={props.onClose}></Button>
<Button type="primary" loading={props.submitting} onClick={handleSubmit}>
</Button>
</div>
</Modal>
</div>
</Modal>
</>
);
},
});

View File

@ -199,7 +199,7 @@ export default defineComponent({
<Form style={{ rowGap: '10px' }} layout="inline" model={filterForm}>
<Form.Item label="标题" name="searchTitle">
<Input
placeholder="请输入 Banner 标题"
placeholder="请输入"
style={{ width: '200px' }}
allowClear
onPressEnter={handleSearch}