|
|
|
@ -14,6 +14,7 @@ import type { |
|
|
|
import type { Component, Ref } from 'vue'; |
|
|
|
|
|
|
|
import type { BaseFormComponentType } from '@vben/common-ui'; |
|
|
|
import type { Sortable } from '@vben/hooks'; |
|
|
|
import type { Recordable } from '@vben/types'; |
|
|
|
|
|
|
|
import { |
|
|
|
@ -21,6 +22,9 @@ import { |
|
|
|
defineAsyncComponent, |
|
|
|
defineComponent, |
|
|
|
h, |
|
|
|
nextTick, |
|
|
|
onMounted, |
|
|
|
onUnmounted, |
|
|
|
ref, |
|
|
|
render, |
|
|
|
unref, |
|
|
|
@ -33,6 +37,7 @@ import { |
|
|
|
IconPicker, |
|
|
|
VCropper, |
|
|
|
} from '@vben/common-ui'; |
|
|
|
import { useSortable } from '@vben/hooks'; |
|
|
|
import { IconifyIcon } from '@vben/icons'; |
|
|
|
import { $t } from '@vben/locales'; |
|
|
|
import { isEmpty } from '@vben/utils'; |
|
|
|
@ -126,10 +131,7 @@ const withDefaultPlaceholder = <T extends Component>( |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
const withPreviewUpload = () => { |
|
|
|
// 检查是否为图片文件的辅助函数
|
|
|
|
const isImageFile = (file: UploadFile): boolean => { |
|
|
|
const imageExtensions = new Set([ |
|
|
|
const IMAGE_EXTENSIONS = new Set([ |
|
|
|
'bmp', |
|
|
|
'gif', |
|
|
|
'jpeg', |
|
|
|
@ -137,35 +139,36 @@ const withPreviewUpload = () => { |
|
|
|
'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 ? imageExtensions.has(ext) : false; |
|
|
|
return ext ? IMAGE_EXTENSIONS.has(ext) : false; |
|
|
|
} catch { |
|
|
|
const ext = file.url?.split('.').pop()?.toLowerCase(); |
|
|
|
return ext ? imageExtensions.has(ext) : false; |
|
|
|
return ext ? IMAGE_EXTENSIONS.has(ext) : false; |
|
|
|
} |
|
|
|
} |
|
|
|
if (!file.type) { |
|
|
|
const ext = file.name?.split('.').pop()?.toLowerCase(); |
|
|
|
return ext ? imageExtensions.has(ext) : false; |
|
|
|
return ext ? IMAGE_EXTENSIONS.has(ext) : false; |
|
|
|
} |
|
|
|
return file.type.startsWith('image/'); |
|
|
|
}; |
|
|
|
// 创建默认的上传按钮插槽
|
|
|
|
const createDefaultSlotsWithUpload = ( |
|
|
|
listType: string, |
|
|
|
placeholder: string, |
|
|
|
) => { |
|
|
|
switch (listType) { |
|
|
|
case 'picture-card': { |
|
|
|
return { |
|
|
|
default: () => placeholder, |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 创建默认的上传按钮插槽 |
|
|
|
*/ |
|
|
|
function createDefaultUploadSlots(listType: string, placeholder: string) { |
|
|
|
if (listType === 'picture-card') { |
|
|
|
return { default: () => placeholder }; |
|
|
|
} |
|
|
|
default: { |
|
|
|
return { |
|
|
|
default: () => |
|
|
|
h( |
|
|
|
@ -179,58 +182,59 @@ const withPreviewUpload = () => { |
|
|
|
() => placeholder, |
|
|
|
), |
|
|
|
}; |
|
|
|
} |
|
|
|
} |
|
|
|
}; |
|
|
|
// 构建预览图片组
|
|
|
|
const previewImage = async ( |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 获取文件的 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, |
|
|
|
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'); |
|
|
|
const url = file.url || file.preview; |
|
|
|
if (url) { |
|
|
|
window.open(url, '_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), |
|
|
|
); |
|
|
|
// 过滤图片文件并生成预览
|
|
|
|
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)) as string; |
|
|
|
imgFile.preview = await getBase64(imgFile.originFileObj); |
|
|
|
} |
|
|
|
} |
|
|
|
const container: HTMLElement | null = document.createElement('div'); |
|
|
|
document.body.append(container); |
|
|
|
|
|
|
|
// 用于追踪组件是否已卸载
|
|
|
|
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 () => { |
|
|
|
@ -241,12 +245,10 @@ const withPreviewUpload = () => { |
|
|
|
class: 'hidden', |
|
|
|
preview: { |
|
|
|
visible: visible.value, |
|
|
|
// 设置初始显示的图片索引
|
|
|
|
current: imageFiles.findIndex((f) => f.uid === file.uid), |
|
|
|
current: currentIndex, |
|
|
|
onVisibleChange: (value: boolean) => { |
|
|
|
visible.value = value; |
|
|
|
if (!value) { |
|
|
|
// 延迟清理,确保动画完成
|
|
|
|
setTimeout(() => { |
|
|
|
if (!isUnmounted && container) { |
|
|
|
isUnmounted = true; |
|
|
|
@ -259,7 +261,6 @@ const withPreviewUpload = () => { |
|
|
|
}, |
|
|
|
}, |
|
|
|
() => |
|
|
|
// 渲染所有图片文件
|
|
|
|
imageFiles.map((imgFile) => |
|
|
|
h(ImageComponent, { |
|
|
|
key: imgFile.uid, |
|
|
|
@ -272,15 +273,16 @@ const withPreviewUpload = () => { |
|
|
|
}; |
|
|
|
|
|
|
|
render(h(PreviewWrapper), container); |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
// 图片裁剪操作
|
|
|
|
const cropImage = (file: File, aspectRatio: string | undefined) => { |
|
|
|
return new Promise((resolve, reject) => { |
|
|
|
const container: HTMLElement | null = document.createElement('div'); |
|
|
|
/** |
|
|
|
* 图片裁剪操作 |
|
|
|
*/ |
|
|
|
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; |
|
|
|
|
|
|
|
@ -289,7 +291,6 @@ const withPreviewUpload = () => { |
|
|
|
|
|
|
|
const closeModal = () => { |
|
|
|
open.value = false; |
|
|
|
// 延迟清理,确保动画完成
|
|
|
|
setTimeout(() => { |
|
|
|
if (!isUnmounted && container) { |
|
|
|
if (objectUrl) { |
|
|
|
@ -340,7 +341,11 @@ const withPreviewUpload = () => { |
|
|
|
} |
|
|
|
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 { |
|
|
|
@ -365,21 +370,22 @@ const withPreviewUpload = () => { |
|
|
|
|
|
|
|
render(h(CropperWrapper), container); |
|
|
|
}); |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 带预览功能的上传组件 |
|
|
|
*/ |
|
|
|
const withPreviewUpload = () => { |
|
|
|
return defineComponent({ |
|
|
|
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'] || [], |
|
|
|
); |
|
|
|
@ -393,12 +399,14 @@ const withPreviewUpload = () => { |
|
|
|
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 && |
|
|
|
@ -406,14 +414,11 @@ 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'))); |
|
|
|
throw new Error($t('ui.crop.errorTip')); |
|
|
|
} |
|
|
|
resolve(blob); |
|
|
|
}); |
|
|
|
return blob; |
|
|
|
} |
|
|
|
|
|
|
|
return attrs.beforeUpload?.(file) ?? true; |
|
|
|
@ -421,12 +426,9 @@ const withPreviewUpload = () => { |
|
|
|
|
|
|
|
const 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( |
|
|
|
@ -443,21 +445,88 @@ const withPreviewUpload = () => { |
|
|
|
await previewImage(file, previewVisible, fileList); |
|
|
|
}; |
|
|
|
|
|
|
|
const renderUploadButton = (): any => { |
|
|
|
const isDisabled = attrs.disabled; |
|
|
|
|
|
|
|
// 如果禁用,不渲染上传按钮
|
|
|
|
if (isDisabled) { |
|
|
|
return null; |
|
|
|
} |
|
|
|
|
|
|
|
// 否则渲染默认上传按钮
|
|
|
|
const renderUploadButton = () => { |
|
|
|
if (attrs.disabled) return null; |
|
|
|
return isEmpty(slots) |
|
|
|
? createDefaultSlotsWithUpload(listType, placeholder) |
|
|
|
? createDefaultUploadSlots(listType, placeholder) |
|
|
|
: slots; |
|
|
|
}; |
|
|
|
|
|
|
|
// 可以监听到表单API设置的值
|
|
|
|
// 拖拽排序
|
|
|
|
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); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
function removeDragStyle() { |
|
|
|
document.querySelector(`[id="${styleId}"]`)?.remove(); |
|
|
|
} |
|
|
|
|
|
|
|
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) => { |
|
|
|
@ -465,7 +534,16 @@ const withPreviewUpload = () => { |
|
|
|
}, |
|
|
|
); |
|
|
|
|
|
|
|
onMounted(initSortable); |
|
|
|
onUnmounted(() => { |
|
|
|
sortableInstance.value?.destroy(); |
|
|
|
removeDragStyle(); |
|
|
|
}); |
|
|
|
|
|
|
|
return () => |
|
|
|
h( |
|
|
|
'div', |
|
|
|
{ 'data-upload-id': uploadId, class: 'w-full' }, |
|
|
|
h( |
|
|
|
Upload, |
|
|
|
{ |
|
|
|
@ -476,7 +554,8 @@ const withPreviewUpload = () => { |
|
|
|
onChange: handleChange, |
|
|
|
onPreview: handlePreview, |
|
|
|
}, |
|
|
|
renderUploadButton(), |
|
|
|
renderUploadButton() as any, |
|
|
|
), |
|
|
|
); |
|
|
|
}, |
|
|
|
}); |
|
|
|
|