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