|
|
|
@ -5,11 +5,42 @@ |
|
|
|
|
|
|
|
/* eslint-disable vue/one-component-per-file */ |
|
|
|
|
|
|
|
import type { UploadChangeParam, UploadFile, UploadProps } from 'antdv-next'; |
|
|
|
import type { |
|
|
|
AutoCompleteProps, |
|
|
|
ButtonProps, |
|
|
|
CascaderProps, |
|
|
|
CheckboxGroupProps, |
|
|
|
CheckboxProps, |
|
|
|
DatePickerProps, |
|
|
|
DividerProps, |
|
|
|
InputNumberProps, |
|
|
|
InputProps, |
|
|
|
MentionsProps, |
|
|
|
RadioGroupProps, |
|
|
|
RadioProps, |
|
|
|
RangePickerProps, |
|
|
|
RateProps, |
|
|
|
SelectProps, |
|
|
|
SpaceProps, |
|
|
|
SwitchProps, |
|
|
|
TextAreaProps, |
|
|
|
TimePickerProps, |
|
|
|
TreeSelectProps, |
|
|
|
UploadChangeParam, |
|
|
|
UploadFile, |
|
|
|
UploadProps, |
|
|
|
} from 'antdv-next'; |
|
|
|
|
|
|
|
import type { Component, Ref } from 'vue'; |
|
|
|
|
|
|
|
import type { BaseFormComponentType } from '@vben/common-ui'; |
|
|
|
import type { |
|
|
|
ApiComponentSharedProps, |
|
|
|
BaseFormComponentType, |
|
|
|
CollapsibleParamsProps, |
|
|
|
IconPickerProps, |
|
|
|
} from '@vben/common-ui'; |
|
|
|
import type { Sortable } from '@vben/hooks'; |
|
|
|
import type { TipTapProps } from '@vben/plugins/tiptap'; |
|
|
|
import type { Recordable } from '@vben/types'; |
|
|
|
|
|
|
|
import { |
|
|
|
@ -17,6 +48,9 @@ import { |
|
|
|
defineAsyncComponent, |
|
|
|
defineComponent, |
|
|
|
h, |
|
|
|
nextTick, |
|
|
|
onMounted, |
|
|
|
onUnmounted, |
|
|
|
ref, |
|
|
|
render, |
|
|
|
unref, |
|
|
|
@ -27,14 +61,28 @@ import { |
|
|
|
ApiComponent, |
|
|
|
globalShareState, |
|
|
|
IconPicker, |
|
|
|
VbenCollapsibleParams, |
|
|
|
VCropper, |
|
|
|
} from '@vben/common-ui'; |
|
|
|
import { useSortable } from '@vben/hooks'; |
|
|
|
import { IconifyIcon } from '@vben/icons'; |
|
|
|
import { $t } from '@vben/locales'; |
|
|
|
import { VbenTiptap } from '@vben/plugins/tiptap'; |
|
|
|
import { isEmpty } from '@vben/utils'; |
|
|
|
|
|
|
|
import { message, Modal, notification } from 'antdv-next'; |
|
|
|
|
|
|
|
import { upload_file } from '#/api'; |
|
|
|
type AdapterUploadProps = UploadProps & { |
|
|
|
aspectRatio?: string; |
|
|
|
crop?: boolean; |
|
|
|
draggable?: boolean; |
|
|
|
handleChange?: (event: UploadChangeParam) => void; |
|
|
|
maxSize?: number; |
|
|
|
onDragSort?: (oldIndex: number, newIndex: number) => void; |
|
|
|
onHandleChange?: (event: UploadChangeParam) => void; |
|
|
|
}; |
|
|
|
|
|
|
|
const AutoComplete = defineAsyncComponent( |
|
|
|
() => import('antdv-next/dist/auto-complete/index'), |
|
|
|
); |
|
|
|
@ -44,8 +92,8 @@ const Button = defineAsyncComponent( |
|
|
|
const Checkbox = defineAsyncComponent( |
|
|
|
() => import('antdv-next/dist/checkbox/index'), |
|
|
|
); |
|
|
|
const CheckboxGroup = defineAsyncComponent( |
|
|
|
() => import('antdv-next/dist/checkbox/Group'), |
|
|
|
const CheckboxGroup = defineAsyncComponent(() => |
|
|
|
import('antdv-next/dist/checkbox/index').then((res) => res.CheckboxGroup), |
|
|
|
); |
|
|
|
const DatePicker = defineAsyncComponent( |
|
|
|
() => import('antdv-next/dist/date-picker/index'), |
|
|
|
@ -100,8 +148,8 @@ const PreviewGroup = defineAsyncComponent(() => |
|
|
|
import('antdv-next/dist/image/index').then((res) => res.ImagePreviewGroup), |
|
|
|
); |
|
|
|
|
|
|
|
const withDefaultPlaceholder = <T extends Component>( |
|
|
|
component: T, |
|
|
|
const withDefaultPlaceholder = ( |
|
|
|
component: Component, |
|
|
|
type: 'input' | 'select', |
|
|
|
componentProps: Recordable<any> = {}, |
|
|
|
) => { |
|
|
|
@ -134,260 +182,263 @@ const withDefaultPlaceholder = <T extends Component>( |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
const withPreviewUpload = () => { |
|
|
|
// 检查是否为图片文件的辅助函数
|
|
|
|
const isImageFile = (file: UploadFile): boolean => { |
|
|
|
const imageExtensions = new Set([ |
|
|
|
'bmp', |
|
|
|
'gif', |
|
|
|
'jpeg', |
|
|
|
'jpg', |
|
|
|
'png', |
|
|
|
'svg', |
|
|
|
'webp', |
|
|
|
]); |
|
|
|
if (file.url) { |
|
|
|
try { |
|
|
|
const pathname = new URL(file.url, 'http://localhost').pathname; |
|
|
|
const ext = pathname.split('.').pop()?.toLowerCase(); |
|
|
|
return ext ? imageExtensions.has(ext) : false; |
|
|
|
} catch { |
|
|
|
const ext = file.url?.split('.').pop()?.toLowerCase(); |
|
|
|
return ext ? imageExtensions.has(ext) : false; |
|
|
|
} |
|
|
|
} |
|
|
|
if (!file.type) { |
|
|
|
const ext = file.name?.split('.').pop()?.toLowerCase(); |
|
|
|
return ext ? imageExtensions.has(ext) : false; |
|
|
|
const IMAGE_EXTENSIONS = new Set([ |
|
|
|
'bmp', |
|
|
|
'gif', |
|
|
|
'jpeg', |
|
|
|
'jpg', |
|
|
|
'png', |
|
|
|
'svg', |
|
|
|
'webp', |
|
|
|
]); |
|
|
|
|
|
|
|
/** |
|
|
|
* 检查是否为图片文件 |
|
|
|
*/ |
|
|
|
function isImageFile(file: UploadFile): boolean { |
|
|
|
if (file.url) { |
|
|
|
try { |
|
|
|
const pathname = new URL(file.url, 'http://localhost').pathname; |
|
|
|
const ext = pathname.split('.').pop()?.toLowerCase(); |
|
|
|
return ext ? IMAGE_EXTENSIONS.has(ext) : false; |
|
|
|
} catch { |
|
|
|
const ext = file.url?.split('.').pop()?.toLowerCase(); |
|
|
|
return ext ? IMAGE_EXTENSIONS.has(ext) : false; |
|
|
|
} |
|
|
|
return file.type.startsWith('image/'); |
|
|
|
} |
|
|
|
if (!file.type) { |
|
|
|
const ext = file.name?.split('.').pop()?.toLowerCase(); |
|
|
|
return ext ? IMAGE_EXTENSIONS.has(ext) : false; |
|
|
|
} |
|
|
|
return file.type.startsWith('image/'); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 创建默认的上传按钮插槽 |
|
|
|
*/ |
|
|
|
function createDefaultUploadSlots(listType: string, placeholder: string) { |
|
|
|
if (listType === 'picture-card') { |
|
|
|
return { default: () => placeholder }; |
|
|
|
} |
|
|
|
return { |
|
|
|
default: () => |
|
|
|
h( |
|
|
|
Button, |
|
|
|
{ |
|
|
|
icon: h(IconifyIcon, { |
|
|
|
icon: 'ant-design:upload-outlined', |
|
|
|
class: 'mb-1 size-4', |
|
|
|
}), |
|
|
|
}, |
|
|
|
() => placeholder, |
|
|
|
), |
|
|
|
}; |
|
|
|
// 创建默认的上传按钮插槽
|
|
|
|
const createDefaultSlotsWithUpload = ( |
|
|
|
listType: string, |
|
|
|
placeholder: string, |
|
|
|
) => { |
|
|
|
switch (listType) { |
|
|
|
case 'picture-card': { |
|
|
|
return { |
|
|
|
default: () => placeholder, |
|
|
|
}; |
|
|
|
} |
|
|
|
default: { |
|
|
|
return { |
|
|
|
default: () => |
|
|
|
h( |
|
|
|
Button, |
|
|
|
{ |
|
|
|
icon: h(IconifyIcon, { |
|
|
|
icon: 'ant-design:upload-outlined', |
|
|
|
class: 'mb-1 size-4', |
|
|
|
}), |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 获取文件的 Base64 |
|
|
|
*/ |
|
|
|
function getBase64(file: File): Promise<string> { |
|
|
|
return new Promise((resolve, reject) => { |
|
|
|
const reader = new FileReader(); |
|
|
|
reader.readAsDataURL(file); |
|
|
|
reader.addEventListener('load', () => resolve(reader.result as string)); |
|
|
|
reader.addEventListener('error', reject); |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 预览图片 |
|
|
|
*/ |
|
|
|
async function previewImage( |
|
|
|
file: UploadFile, |
|
|
|
open: Ref<boolean>, |
|
|
|
fileList: Ref<UploadProps['fileList']>, |
|
|
|
) { |
|
|
|
// 非图片文件直接打开链接
|
|
|
|
if (!isImageFile(file)) { |
|
|
|
const url = file.url || file.preview; |
|
|
|
if (url) { |
|
|
|
window.open(url, '_blank'); |
|
|
|
} else if (file.preview) { |
|
|
|
window.open(file.preview, '_blank'); |
|
|
|
} else { |
|
|
|
message.error($t('ui.formRules.previewWarning')); |
|
|
|
} |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
const [ImageComponent, PreviewGroupComponent] = await Promise.all([ |
|
|
|
Image, |
|
|
|
PreviewGroup, |
|
|
|
]); |
|
|
|
|
|
|
|
// 过滤图片文件并生成预览
|
|
|
|
const imageFiles = (unref(fileList) || []).filter((f) => isImageFile(f)); |
|
|
|
|
|
|
|
for (const imgFile of imageFiles) { |
|
|
|
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) { |
|
|
|
imgFile.preview = await getBase64(imgFile.originFileObj); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
const container = document.createElement('div'); |
|
|
|
document.body.append(container); |
|
|
|
let isUnmounted = false; |
|
|
|
|
|
|
|
const currentIndex = imageFiles.findIndex((f) => f.uid === file.uid); |
|
|
|
|
|
|
|
const PreviewWrapper = { |
|
|
|
setup() { |
|
|
|
return () => { |
|
|
|
if (isUnmounted) return null; |
|
|
|
return h( |
|
|
|
PreviewGroupComponent, |
|
|
|
{ |
|
|
|
class: 'hidden', |
|
|
|
preview: { |
|
|
|
open: open.value, |
|
|
|
current: currentIndex, |
|
|
|
onOpenChange: (value: boolean) => { |
|
|
|
open.value = value; |
|
|
|
if (!value) { |
|
|
|
setTimeout(() => { |
|
|
|
if (!isUnmounted && container) { |
|
|
|
isUnmounted = true; |
|
|
|
render(null, container); |
|
|
|
container.remove(); |
|
|
|
} |
|
|
|
}, 300); |
|
|
|
} |
|
|
|
}, |
|
|
|
() => placeholder, |
|
|
|
}, |
|
|
|
}, |
|
|
|
() => |
|
|
|
imageFiles.map((imgFile) => |
|
|
|
h(ImageComponent, { |
|
|
|
key: imgFile.uid, |
|
|
|
src: imgFile.url || imgFile.preview, |
|
|
|
}), |
|
|
|
), |
|
|
|
}; |
|
|
|
} |
|
|
|
} |
|
|
|
); |
|
|
|
}; |
|
|
|
}, |
|
|
|
}; |
|
|
|
// 构建预览图片组
|
|
|
|
const previewImage = async ( |
|
|
|
file: UploadFile, |
|
|
|
visible: Ref<boolean>, |
|
|
|
fileList: Ref<UploadProps['fileList']>, |
|
|
|
) => { |
|
|
|
// 如果当前文件不是图片,直接打开
|
|
|
|
if (!isImageFile(file)) { |
|
|
|
if (file.url) { |
|
|
|
window.open(file.url, '_blank'); |
|
|
|
} else if (file.preview) { |
|
|
|
window.open(file.preview, '_blank'); |
|
|
|
} else { |
|
|
|
message.error($t('ui.formRules.previewWarning')); |
|
|
|
} |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
// 对于图片文件,继续使用预览组
|
|
|
|
const [ImageComponent, PreviewGroupComponent] = await Promise.all([ |
|
|
|
Image, |
|
|
|
PreviewGroup, |
|
|
|
]); |
|
|
|
|
|
|
|
const getBase64 = (file: File) => { |
|
|
|
return new Promise((resolve, reject) => { |
|
|
|
const reader = new FileReader(); |
|
|
|
reader.readAsDataURL(file); |
|
|
|
reader.addEventListener('load', () => resolve(reader.result)); |
|
|
|
reader.addEventListener('error', (error) => reject(error)); |
|
|
|
}); |
|
|
|
}; |
|
|
|
// 从fileList中过滤出所有图片文件
|
|
|
|
const imageFiles = (unref(fileList) || []).filter((element) => |
|
|
|
isImageFile(element), |
|
|
|
); |
|
|
|
|
|
|
|
// 为所有没有预览地址的图片生成预览
|
|
|
|
for (const imgFile of imageFiles) { |
|
|
|
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) { |
|
|
|
imgFile.preview = (await getBase64(imgFile.originFileObj)) as string; |
|
|
|
} |
|
|
|
} |
|
|
|
const container: HTMLElement | null = document.createElement('div'); |
|
|
|
render(h(PreviewWrapper), container); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 图片裁剪操作 |
|
|
|
*/ |
|
|
|
function cropImage(file: File, aspectRatio: string | undefined) { |
|
|
|
return new Promise<Blob | string | undefined>((resolve, reject) => { |
|
|
|
const container = document.createElement('div'); |
|
|
|
document.body.append(container); |
|
|
|
|
|
|
|
// 用于追踪组件是否已卸载
|
|
|
|
let isUnmounted = false; |
|
|
|
let objectUrl: null | string = null; |
|
|
|
|
|
|
|
const open = ref<boolean>(true); |
|
|
|
const cropperRef = ref<InstanceType<typeof VCropper> | null>(null); |
|
|
|
|
|
|
|
const PreviewWrapper = { |
|
|
|
function closeModal() { |
|
|
|
open.value = false; |
|
|
|
setTimeout(() => { |
|
|
|
if (!isUnmounted && container) { |
|
|
|
if (objectUrl) { |
|
|
|
URL.revokeObjectURL(objectUrl); |
|
|
|
} |
|
|
|
isUnmounted = true; |
|
|
|
render(null, container); |
|
|
|
container.remove(); |
|
|
|
} |
|
|
|
}, 300); |
|
|
|
} |
|
|
|
|
|
|
|
const CropperWrapper = { |
|
|
|
setup() { |
|
|
|
return () => { |
|
|
|
if (isUnmounted) return null; |
|
|
|
if (!objectUrl) { |
|
|
|
objectUrl = URL.createObjectURL(file); |
|
|
|
} |
|
|
|
return h( |
|
|
|
PreviewGroupComponent, |
|
|
|
Modal, |
|
|
|
{ |
|
|
|
class: 'hidden', |
|
|
|
preview: { |
|
|
|
open: visible.value, |
|
|
|
// 设置初始显示的图片索引
|
|
|
|
current: imageFiles.findIndex((f) => f.uid === file.uid), |
|
|
|
onOpenChange: (value: boolean) => { |
|
|
|
visible.value = value; |
|
|
|
if (!value) { |
|
|
|
// 延迟清理,确保动画完成
|
|
|
|
setTimeout(() => { |
|
|
|
if (!isUnmounted && container) { |
|
|
|
isUnmounted = true; |
|
|
|
render(null, container); |
|
|
|
container.remove(); |
|
|
|
} |
|
|
|
}, 300); |
|
|
|
open: open.value, |
|
|
|
title: h('div', {}, [ |
|
|
|
$t('ui.crop.title'), |
|
|
|
h( |
|
|
|
'span', |
|
|
|
{ |
|
|
|
class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`, |
|
|
|
}, |
|
|
|
$t('ui.crop.titleTip', [aspectRatio]), |
|
|
|
), |
|
|
|
]), |
|
|
|
centered: true, |
|
|
|
width: 548, |
|
|
|
keyboard: false, |
|
|
|
maskClosable: false, |
|
|
|
closable: false, |
|
|
|
cancelText: $t('common.cancel'), |
|
|
|
okText: $t('ui.crop.confirm'), |
|
|
|
destroyOnHidden: true, |
|
|
|
onOk: async () => { |
|
|
|
const cropper = cropperRef.value; |
|
|
|
if (!cropper) { |
|
|
|
reject(new Error('Cropper not found')); |
|
|
|
closeModal(); |
|
|
|
return; |
|
|
|
} |
|
|
|
try { |
|
|
|
const dataUrl = await cropper.getCropImage(); |
|
|
|
if (dataUrl) { |
|
|
|
resolve(dataUrl); |
|
|
|
} else { |
|
|
|
reject(new Error($t('ui.crop.errorTip'))); |
|
|
|
} |
|
|
|
}, |
|
|
|
} catch { |
|
|
|
reject(new Error($t('ui.crop.errorTip'))); |
|
|
|
} finally { |
|
|
|
closeModal(); |
|
|
|
} |
|
|
|
}, |
|
|
|
onCancel() { |
|
|
|
resolve(''); |
|
|
|
closeModal(); |
|
|
|
}, |
|
|
|
}, |
|
|
|
() => |
|
|
|
// 渲染所有图片文件
|
|
|
|
imageFiles.map((imgFile) => |
|
|
|
h(ImageComponent, { |
|
|
|
key: imgFile.uid, |
|
|
|
src: imgFile.url || imgFile.preview, |
|
|
|
}), |
|
|
|
), |
|
|
|
h(VCropper, { |
|
|
|
ref: (ref: any) => (cropperRef.value = ref), |
|
|
|
img: objectUrl as string, |
|
|
|
aspectRatio, |
|
|
|
}), |
|
|
|
); |
|
|
|
}; |
|
|
|
}, |
|
|
|
}; |
|
|
|
|
|
|
|
render(h(PreviewWrapper), container); |
|
|
|
}; |
|
|
|
|
|
|
|
// 图片裁剪操作
|
|
|
|
const cropImage = (file: File, aspectRatio: string | undefined) => { |
|
|
|
return new Promise((resolve, reject) => { |
|
|
|
const container: HTMLElement | null = document.createElement('div'); |
|
|
|
document.body.append(container); |
|
|
|
|
|
|
|
// 用于追踪组件是否已卸载
|
|
|
|
let isUnmounted = false; |
|
|
|
let objectUrl: null | string = null; |
|
|
|
|
|
|
|
const open = ref<boolean>(true); |
|
|
|
const cropperRef = ref<InstanceType<typeof VCropper> | null>(null); |
|
|
|
|
|
|
|
const closeModal = () => { |
|
|
|
open.value = false; |
|
|
|
// 延迟清理,确保动画完成
|
|
|
|
setTimeout(() => { |
|
|
|
if (!isUnmounted && container) { |
|
|
|
if (objectUrl) { |
|
|
|
URL.revokeObjectURL(objectUrl); |
|
|
|
} |
|
|
|
isUnmounted = true; |
|
|
|
render(null, container); |
|
|
|
container.remove(); |
|
|
|
} |
|
|
|
}, 300); |
|
|
|
}; |
|
|
|
|
|
|
|
const CropperWrapper = { |
|
|
|
setup() { |
|
|
|
return () => { |
|
|
|
if (isUnmounted) return null; |
|
|
|
if (!objectUrl) { |
|
|
|
objectUrl = URL.createObjectURL(file); |
|
|
|
} |
|
|
|
return h( |
|
|
|
Modal, |
|
|
|
{ |
|
|
|
open: open.value, |
|
|
|
title: h('div', {}, [ |
|
|
|
$t('ui.crop.title'), |
|
|
|
h( |
|
|
|
'span', |
|
|
|
{ |
|
|
|
class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`, |
|
|
|
}, |
|
|
|
$t('ui.crop.titleTip', [aspectRatio]), |
|
|
|
), |
|
|
|
]), |
|
|
|
centered: true, |
|
|
|
width: 548, |
|
|
|
keyboard: false, |
|
|
|
maskClosable: false, |
|
|
|
closable: false, |
|
|
|
cancelText: $t('common.cancel'), |
|
|
|
okText: $t('ui.crop.confirm'), |
|
|
|
destroyOnHidden: true, |
|
|
|
onOk: async () => { |
|
|
|
const cropper = cropperRef.value; |
|
|
|
if (!cropper) { |
|
|
|
reject(new Error('Cropper not found')); |
|
|
|
closeModal(); |
|
|
|
return; |
|
|
|
} |
|
|
|
try { |
|
|
|
const dataUrl = await cropper.getCropImage(); |
|
|
|
resolve(dataUrl); |
|
|
|
} catch { |
|
|
|
reject(new Error($t('ui.crop.errorTip'))); |
|
|
|
} finally { |
|
|
|
closeModal(); |
|
|
|
} |
|
|
|
}, |
|
|
|
onCancel() { |
|
|
|
resolve(''); |
|
|
|
closeModal(); |
|
|
|
}, |
|
|
|
}, |
|
|
|
() => |
|
|
|
h(VCropper, { |
|
|
|
ref: (ref: any) => (cropperRef.value = ref), |
|
|
|
img: objectUrl as string, |
|
|
|
aspectRatio, |
|
|
|
}), |
|
|
|
); |
|
|
|
}; |
|
|
|
}, |
|
|
|
}; |
|
|
|
|
|
|
|
render(h(CropperWrapper), container); |
|
|
|
}); |
|
|
|
}; |
|
|
|
render(h(CropperWrapper), container); |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 带预览功能的上传组件 |
|
|
|
*/ |
|
|
|
function withPreviewUpload() { |
|
|
|
return defineComponent({ |
|
|
|
name: 'AUpload', |
|
|
|
name: Upload.name, |
|
|
|
emits: ['update:modelValue'], |
|
|
|
setup: ( |
|
|
|
setup( |
|
|
|
props: any, |
|
|
|
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any }, |
|
|
|
) => { |
|
|
|
) { |
|
|
|
const previewVisible = ref<boolean>(false); |
|
|
|
|
|
|
|
const placeholder = attrs?.placeholder || $t(`ui.placeholder.upload`); |
|
|
|
|
|
|
|
const placeholder = attrs?.placeholder || $t('ui.placeholder.upload'); |
|
|
|
const listType = attrs?.listType || attrs?.['list-type'] || 'text'; |
|
|
|
|
|
|
|
const fileList = ref<UploadProps['fileList']>( |
|
|
|
attrs?.fileList || attrs?.['file-list'] || [], |
|
|
|
); |
|
|
|
@ -397,16 +448,18 @@ const withPreviewUpload = () => { |
|
|
|
() => attrs?.aspectRatio ?? attrs?.['aspect-ratio'], |
|
|
|
); |
|
|
|
|
|
|
|
const handleBeforeUpload = async ( |
|
|
|
async function handleBeforeUpload( |
|
|
|
file: UploadFile, |
|
|
|
originFileList: Array<File>, |
|
|
|
) => { |
|
|
|
) { |
|
|
|
// 文件大小限制
|
|
|
|
if (maxSize.value && (file.size || 0) / 1024 / 1024 > maxSize.value) { |
|
|
|
message.error($t('ui.formRules.sizeLimit', [maxSize.value])); |
|
|
|
file.status = 'removed'; |
|
|
|
return false; |
|
|
|
} |
|
|
|
// 多选或者非图片不唤起裁剪框
|
|
|
|
|
|
|
|
// 图片裁剪处理
|
|
|
|
if ( |
|
|
|
attrs.crop && |
|
|
|
!attrs.multiple && |
|
|
|
@ -414,27 +467,21 @@ const withPreviewUpload = () => { |
|
|
|
isImageFile(file) |
|
|
|
) { |
|
|
|
file.status = 'removed'; |
|
|
|
// antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取
|
|
|
|
const blob = await cropImage(originFileList[0], aspectRatio.value); |
|
|
|
return new Promise((resolve, reject) => { |
|
|
|
if (!blob) { |
|
|
|
return reject(new Error($t('ui.crop.errorTip'))); |
|
|
|
} |
|
|
|
resolve(blob); |
|
|
|
}); |
|
|
|
if (!blob) { |
|
|
|
throw new Error($t('ui.crop.errorTip')); |
|
|
|
} |
|
|
|
return blob; |
|
|
|
} |
|
|
|
|
|
|
|
return attrs.beforeUpload?.(file) ?? true; |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
const handleChange = (event: UploadChangeParam) => { |
|
|
|
function handleChange(event: UploadChangeParam) { |
|
|
|
try { |
|
|
|
// 行内写法 handleChange: (event) => {}
|
|
|
|
attrs.handleChange?.(event); |
|
|
|
// template写法 @handle-change="(event) => {}"
|
|
|
|
attrs.onHandleChange?.(event); |
|
|
|
} catch (error) { |
|
|
|
// Avoid breaking internal v-model sync on user handler errors
|
|
|
|
console.error(error); |
|
|
|
} |
|
|
|
fileList.value = event.fileList.filter( |
|
|
|
@ -444,28 +491,95 @@ const withPreviewUpload = () => { |
|
|
|
'update:modelValue', |
|
|
|
event.fileList?.length ? fileList.value : undefined, |
|
|
|
); |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
const handlePreview = async (file: UploadFile) => { |
|
|
|
function handlePreview(file: UploadFile) { |
|
|
|
previewVisible.value = true; |
|
|
|
await previewImage(file, previewVisible, fileList); |
|
|
|
}; |
|
|
|
return previewImage(file, previewVisible, fileList); |
|
|
|
} |
|
|
|
|
|
|
|
const renderUploadButton = (): any => { |
|
|
|
const isDisabled = attrs.disabled; |
|
|
|
function renderUploadButton() { |
|
|
|
if (attrs.disabled) return null; |
|
|
|
return isEmpty(slots) |
|
|
|
? createDefaultUploadSlots(listType, placeholder) |
|
|
|
: slots; |
|
|
|
} |
|
|
|
|
|
|
|
// 如果禁用,不渲染上传按钮
|
|
|
|
if (isDisabled) { |
|
|
|
return null; |
|
|
|
// 拖拽排序
|
|
|
|
const draggable = computed( |
|
|
|
() => (attrs.draggable ?? false) && !attrs.disabled, |
|
|
|
); |
|
|
|
const uploadId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; |
|
|
|
const sortableInstance = ref<null | Sortable>(null); |
|
|
|
|
|
|
|
const styleId = `upload-drag-style-${uploadId}`; |
|
|
|
|
|
|
|
function injectDragStyle() { |
|
|
|
if (!document.querySelector(`[id="${styleId}"]`)) { |
|
|
|
const style = document.createElement('style'); |
|
|
|
style.id = styleId; |
|
|
|
style.textContent = ` |
|
|
|
[data-upload-id="${uploadId}"] .ant-upload-list-item { cursor: move; } |
|
|
|
[data-upload-id="${uploadId}"] .ant-upload-list-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.15); } |
|
|
|
`;
|
|
|
|
document.head.append(style); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// 否则渲染默认上传按钮
|
|
|
|
return isEmpty(slots) |
|
|
|
? createDefaultSlotsWithUpload(listType, placeholder) |
|
|
|
: slots; |
|
|
|
}; |
|
|
|
function removeDragStyle() { |
|
|
|
document.querySelector(`[id="${styleId}"]`)?.remove(); |
|
|
|
} |
|
|
|
|
|
|
|
// 可以监听到表单API设置的值
|
|
|
|
async function initSortable(retryCount = 0) { |
|
|
|
if (!draggable.value) return; |
|
|
|
|
|
|
|
injectDragStyle(); |
|
|
|
await nextTick(); |
|
|
|
await new Promise((resolve) => setTimeout(resolve, 100)); |
|
|
|
|
|
|
|
const container = document.querySelector( |
|
|
|
`[data-upload-id="${uploadId}"] .ant-upload-list`, |
|
|
|
) as HTMLElement; |
|
|
|
|
|
|
|
if (!container) { |
|
|
|
if (retryCount < 5) { |
|
|
|
setTimeout(() => initSortable(retryCount + 1), 200); |
|
|
|
} |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
const { initializeSortable } = useSortable(container, { |
|
|
|
animation: 300, |
|
|
|
delay: 400, |
|
|
|
delayOnTouchOnly: true, |
|
|
|
filter: |
|
|
|
'.ant-upload-select, .ant-upload-list-item-error, .ant-upload-list-item-uploading', |
|
|
|
onEnd: (evt) => { |
|
|
|
const { oldIndex, newIndex } = evt; |
|
|
|
if ( |
|
|
|
oldIndex === undefined || |
|
|
|
newIndex === undefined || |
|
|
|
oldIndex === newIndex |
|
|
|
) { |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
const list = [...(fileList.value || [])]; |
|
|
|
const [movedItem] = list.splice(oldIndex, 1); |
|
|
|
if (movedItem) { |
|
|
|
list.splice(newIndex, 0, movedItem); |
|
|
|
fileList.value = list; |
|
|
|
} |
|
|
|
|
|
|
|
attrs.onDragSort?.(oldIndex, newIndex); |
|
|
|
emit('update:modelValue', fileList.value); |
|
|
|
}, |
|
|
|
}); |
|
|
|
|
|
|
|
sortableInstance.value = await initializeSortable(); |
|
|
|
} |
|
|
|
|
|
|
|
// 监听表单值变化
|
|
|
|
watch( |
|
|
|
() => attrs.modelValue, |
|
|
|
(res) => { |
|
|
|
@ -473,22 +587,32 @@ const withPreviewUpload = () => { |
|
|
|
}, |
|
|
|
); |
|
|
|
|
|
|
|
onMounted(initSortable); |
|
|
|
onUnmounted(() => { |
|
|
|
sortableInstance.value?.destroy(); |
|
|
|
removeDragStyle(); |
|
|
|
}); |
|
|
|
|
|
|
|
return () => |
|
|
|
h( |
|
|
|
Upload, |
|
|
|
{ |
|
|
|
...props, |
|
|
|
...attrs, |
|
|
|
fileList: fileList.value, |
|
|
|
beforeUpload: handleBeforeUpload, |
|
|
|
onChange: handleChange, |
|
|
|
onPreview: handlePreview, |
|
|
|
}, |
|
|
|
renderUploadButton(), |
|
|
|
'div', |
|
|
|
{ 'data-upload-id': uploadId, class: 'w-full' }, |
|
|
|
h( |
|
|
|
Upload, |
|
|
|
{ |
|
|
|
...props, |
|
|
|
...attrs, |
|
|
|
fileList: fileList.value, |
|
|
|
beforeUpload: handleBeforeUpload, |
|
|
|
onChange: handleChange, |
|
|
|
onPreview: handlePreview, |
|
|
|
}, |
|
|
|
renderUploadButton() as any, |
|
|
|
), |
|
|
|
); |
|
|
|
}, |
|
|
|
}); |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
|
|
|
|
export type ComponentType = |
|
|
|
@ -499,6 +623,7 @@ export type ComponentType = |
|
|
|
| 'Cascader' |
|
|
|
| 'Checkbox' |
|
|
|
| 'CheckboxGroup' |
|
|
|
| 'CollapsibleParams' |
|
|
|
| 'DatePicker' |
|
|
|
| 'DefaultButton' |
|
|
|
| 'Divider' |
|
|
|
@ -512,6 +637,7 @@ export type ComponentType = |
|
|
|
| 'RadioGroup' |
|
|
|
| 'RangePicker' |
|
|
|
| 'Rate' |
|
|
|
| 'RichEditor' |
|
|
|
| 'Select' |
|
|
|
| 'Space' |
|
|
|
| 'Switch' |
|
|
|
@ -521,6 +647,41 @@ export type ComponentType = |
|
|
|
| 'Upload' |
|
|
|
| BaseFormComponentType; |
|
|
|
|
|
|
|
/** |
|
|
|
* 与 {@link ComponentType} 中注册的组件名一一对应,便于 Schema 上 `component` + `componentProps` 联动提示 |
|
|
|
*/ |
|
|
|
export interface ComponentPropsMap { |
|
|
|
ApiCascader: ApiComponentSharedProps & CascaderProps; |
|
|
|
ApiSelect: ApiComponentSharedProps & SelectProps; |
|
|
|
ApiTreeSelect: ApiComponentSharedProps & TreeSelectProps; |
|
|
|
AutoComplete: AutoCompleteProps; |
|
|
|
Cascader: CascaderProps; |
|
|
|
Checkbox: CheckboxProps; |
|
|
|
CheckboxGroup: CheckboxGroupProps; |
|
|
|
CollapsibleParams: CollapsibleParamsProps; |
|
|
|
DatePicker: DatePickerProps; |
|
|
|
DefaultButton: ButtonProps; |
|
|
|
Divider: DividerProps; |
|
|
|
IconPicker: IconPickerProps; |
|
|
|
Input: InputProps; |
|
|
|
InputNumber: InputNumberProps; |
|
|
|
InputPassword: InputProps; |
|
|
|
Mentions: MentionsProps; |
|
|
|
PrimaryButton: ButtonProps; |
|
|
|
Radio: RadioProps; |
|
|
|
RadioGroup: RadioGroupProps; |
|
|
|
RangePicker: RangePickerProps; |
|
|
|
Rate: RateProps; |
|
|
|
RichEditor: TipTapProps; |
|
|
|
Select: SelectProps; |
|
|
|
Space: SpaceProps; |
|
|
|
Switch: SwitchProps; |
|
|
|
Textarea: TextAreaProps; |
|
|
|
TimePicker: TimePickerProps; |
|
|
|
TreeSelect: TreeSelectProps; |
|
|
|
Upload: AdapterUploadProps; |
|
|
|
} |
|
|
|
|
|
|
|
async function initComponentAdapter() { |
|
|
|
const components: Partial<Record<ComponentType, Component>> = { |
|
|
|
// 如果你的组件体积比较大,可以使用异步加载
|
|
|
|
@ -532,13 +693,13 @@ async function initComponentAdapter() { |
|
|
|
fieldNames: { label: 'label', value: 'value', children: 'children' }, |
|
|
|
loadingSlot: 'suffixIcon', |
|
|
|
modelPropName: 'value', |
|
|
|
visibleEvent: 'onVisibleChange', |
|
|
|
visibleEvent: 'onOpenChange', |
|
|
|
}), |
|
|
|
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', { |
|
|
|
component: Select, |
|
|
|
loadingSlot: 'suffixIcon', |
|
|
|
modelPropName: 'value', |
|
|
|
visibleEvent: 'onVisibleChange', |
|
|
|
visibleEvent: 'onOpenChange', |
|
|
|
}), |
|
|
|
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', { |
|
|
|
component: TreeSelect, |
|
|
|
@ -546,7 +707,7 @@ async function initComponentAdapter() { |
|
|
|
loadingSlot: 'suffixIcon', |
|
|
|
modelPropName: 'value', |
|
|
|
optionsPropName: 'treeData', |
|
|
|
visibleEvent: 'onVisibleChange', |
|
|
|
visibleEvent: 'onOpenChange', |
|
|
|
}), |
|
|
|
AutoComplete, |
|
|
|
Cascader, |
|
|
|
@ -564,7 +725,9 @@ async function initComponentAdapter() { |
|
|
|
modelValueProp: 'value', |
|
|
|
}), |
|
|
|
Input: withDefaultPlaceholder(Input, 'input'), |
|
|
|
InputNumber: withDefaultPlaceholder(InputNumber, 'input'), |
|
|
|
InputNumber: withDefaultPlaceholder(InputNumber, 'input', { |
|
|
|
style: { width: '100%' }, |
|
|
|
}), |
|
|
|
InputPassword: withDefaultPlaceholder(InputPassword, 'input'), |
|
|
|
Mentions: withDefaultPlaceholder(Mentions, 'input'), |
|
|
|
// 自定义主要按钮
|
|
|
|
@ -575,6 +738,27 @@ async function initComponentAdapter() { |
|
|
|
RadioGroup, |
|
|
|
RangePicker, |
|
|
|
Rate, |
|
|
|
RichEditor: withDefaultPlaceholder(VbenTiptap, 'input', { |
|
|
|
imageUpload: { |
|
|
|
upload: (file: any, onProgress: any) => { |
|
|
|
return new Promise((resolve, reject) => { |
|
|
|
upload_file({ |
|
|
|
file, |
|
|
|
onProgress({ percent }) { |
|
|
|
onProgress?.(percent); |
|
|
|
}, |
|
|
|
onSuccess(response) { |
|
|
|
// 从响应中提取图片URL
|
|
|
|
resolve(response?.data?.url ?? response?.url ?? ''); |
|
|
|
}, |
|
|
|
onError() { |
|
|
|
reject(new Error($t('ui.tiptap.upload.uploadFailed'))); |
|
|
|
}, |
|
|
|
}); |
|
|
|
}); |
|
|
|
}, |
|
|
|
}, |
|
|
|
}), |
|
|
|
Select: withDefaultPlaceholder(Select, 'select'), |
|
|
|
Space, |
|
|
|
Switch, |
|
|
|
@ -582,6 +766,7 @@ async function initComponentAdapter() { |
|
|
|
TimePicker, |
|
|
|
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'), |
|
|
|
Upload: withPreviewUpload(), |
|
|
|
CollapsibleParams: VbenCollapsibleParams, |
|
|
|
}; |
|
|
|
|
|
|
|
// 将组件注册到全局共享状态中
|
|
|
|
|