|
|
|
@ -26,12 +26,17 @@ import { |
|
|
|
watch, |
|
|
|
} from 'vue'; |
|
|
|
|
|
|
|
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui'; |
|
|
|
import { |
|
|
|
ApiComponent, |
|
|
|
globalShareState, |
|
|
|
IconPicker, |
|
|
|
VCropper, |
|
|
|
} from '@vben/common-ui'; |
|
|
|
import { IconifyIcon } from '@vben/icons'; |
|
|
|
import { $t } from '@vben/locales'; |
|
|
|
import { isEmpty } from '@vben/utils'; |
|
|
|
|
|
|
|
import { message, notification } from 'ant-design-vue'; |
|
|
|
import { message, Modal, notification } from 'ant-design-vue'; |
|
|
|
|
|
|
|
const AutoComplete = defineAsyncComponent( |
|
|
|
() => import('ant-design-vue/es/auto-complete'), |
|
|
|
@ -121,6 +126,33 @@ 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; |
|
|
|
} |
|
|
|
return file.type.startsWith('image/'); |
|
|
|
}; |
|
|
|
// 创建默认的上传按钮插槽
|
|
|
|
const createDefaultSlotsWithUpload = ( |
|
|
|
listType: string, |
|
|
|
@ -155,27 +187,6 @@ const withPreviewUpload = () => { |
|
|
|
visible: Ref<boolean>, |
|
|
|
fileList: Ref<UploadProps['fileList']>, |
|
|
|
) => { |
|
|
|
// 检查是否为图片文件的辅助函数
|
|
|
|
const isImageFile = (file: UploadFile): boolean => { |
|
|
|
const imageExtensions = new Set([ |
|
|
|
'bmp', |
|
|
|
'gif', |
|
|
|
'jpeg', |
|
|
|
'jpg', |
|
|
|
'png', |
|
|
|
'webp', |
|
|
|
]); |
|
|
|
if (file.url) { |
|
|
|
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; |
|
|
|
} |
|
|
|
return file.type.startsWith('image/'); |
|
|
|
}; |
|
|
|
|
|
|
|
// 如果当前文件不是图片,直接打开
|
|
|
|
if (!isImageFile(file)) { |
|
|
|
if (file.url) { |
|
|
|
@ -261,6 +272,107 @@ 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'); |
|
|
|
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: $t('ui.crop.title'), |
|
|
|
centered: true, |
|
|
|
width: 548, |
|
|
|
keyboard: false, |
|
|
|
maskClosable: false, |
|
|
|
closable: false, |
|
|
|
cancelText: $t('common.cancel'), |
|
|
|
okText: $t('ui.crop.confirm'), |
|
|
|
destroyOnClose: 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); |
|
|
|
}); |
|
|
|
}; |
|
|
|
|
|
|
|
const base64ToBlob = (base64: Base64URLString) => { |
|
|
|
try { |
|
|
|
const [typeStr, encodeStr] = base64.split(','); |
|
|
|
if (!typeStr || !encodeStr) return; |
|
|
|
const mime = typeStr.match(/:(.*?);/)?.[1]; |
|
|
|
const raw = window.atob(encodeStr); |
|
|
|
const rawLength = raw.length; |
|
|
|
const uInt8Array = new Uint8Array(rawLength); |
|
|
|
for (let i = 0; i < rawLength; ++i) { |
|
|
|
uInt8Array[i] = raw.codePointAt(i) as number; |
|
|
|
} |
|
|
|
return new Blob([uInt8Array], { type: mime }); |
|
|
|
} catch { |
|
|
|
return undefined; |
|
|
|
} |
|
|
|
}; |
|
|
|
return defineComponent({ |
|
|
|
name: Upload.name, |
|
|
|
emits: ['update:modelValue'], |
|
|
|
@ -278,12 +390,37 @@ const withPreviewUpload = () => { |
|
|
|
attrs?.fileList || attrs?.['file-list'] || [], |
|
|
|
); |
|
|
|
|
|
|
|
const handleBeforeUpload = (file: UploadFile) => { |
|
|
|
const handleBeforeUpload = async ( |
|
|
|
file: UploadFile, |
|
|
|
originFileList: Array<File>, |
|
|
|
) => { |
|
|
|
if (attrs.maxSize && (file.size || 0) / 1024 / 1024 > attrs.maxSize) { |
|
|
|
message.error($t('ui.formRules.sizeLimit', [attrs.maxSize])); |
|
|
|
file.status = 'removed'; |
|
|
|
return false; |
|
|
|
} |
|
|
|
// 多选或者非图片不唤起裁剪框
|
|
|
|
if ( |
|
|
|
attrs.crop && |
|
|
|
!attrs.multiple && |
|
|
|
originFileList[0] && |
|
|
|
isImageFile(file) |
|
|
|
) { |
|
|
|
file.status = 'removed'; |
|
|
|
// antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取
|
|
|
|
const base64 = await cropImage(originFileList[0], attrs.aspectRatio); |
|
|
|
return new Promise((resolve, reject) => { |
|
|
|
if (!base64) { |
|
|
|
return reject(new Error($t('ui.crop.cancel'))); |
|
|
|
} |
|
|
|
const blob = base64ToBlob(base64 as string); |
|
|
|
if (!blob) { |
|
|
|
return reject(new Error($t('ui.crop.errorTip'))); |
|
|
|
} |
|
|
|
resolve(blob); |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
return attrs.beforeUpload?.(file) ?? true; |
|
|
|
}; |
|
|
|
|
|
|
|
@ -377,6 +514,7 @@ async function initComponentAdapter() { |
|
|
|
// 如果你的组件体积比较大,可以使用异步加载
|
|
|
|
// Button: () =>
|
|
|
|
// import('xxx').then((res) => res.Button),
|
|
|
|
|
|
|
|
ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', { |
|
|
|
component: Cascader, |
|
|
|
fieldNames: { label: 'label', value: 'value', children: 'children' }, |
|
|
|
@ -384,34 +522,20 @@ async function initComponentAdapter() { |
|
|
|
modelPropName: 'value', |
|
|
|
visibleEvent: 'onVisibleChange', |
|
|
|
}), |
|
|
|
ApiSelect: withDefaultPlaceholder( |
|
|
|
{ |
|
|
|
...ApiComponent, |
|
|
|
name: 'ApiSelect', |
|
|
|
}, |
|
|
|
'select', |
|
|
|
{ |
|
|
|
component: Select, |
|
|
|
loadingSlot: 'suffixIcon', |
|
|
|
visibleEvent: 'onDropdownVisibleChange', |
|
|
|
modelPropName: 'value', |
|
|
|
}, |
|
|
|
), |
|
|
|
ApiTreeSelect: withDefaultPlaceholder( |
|
|
|
{ |
|
|
|
...ApiComponent, |
|
|
|
name: 'ApiTreeSelect', |
|
|
|
}, |
|
|
|
'select', |
|
|
|
{ |
|
|
|
component: TreeSelect, |
|
|
|
fieldNames: { label: 'label', value: 'value', children: 'children' }, |
|
|
|
loadingSlot: 'suffixIcon', |
|
|
|
modelPropName: 'value', |
|
|
|
optionsPropName: 'treeData', |
|
|
|
visibleEvent: 'onVisibleChange', |
|
|
|
}, |
|
|
|
), |
|
|
|
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', { |
|
|
|
component: Select, |
|
|
|
loadingSlot: 'suffixIcon', |
|
|
|
modelPropName: 'value', |
|
|
|
visibleEvent: 'onVisibleChange', |
|
|
|
}), |
|
|
|
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', { |
|
|
|
component: TreeSelect, |
|
|
|
fieldNames: { label: 'label', value: 'value', children: 'children' }, |
|
|
|
loadingSlot: 'suffixIcon', |
|
|
|
modelPropName: 'value', |
|
|
|
optionsPropName: 'treeData', |
|
|
|
visibleEvent: 'onVisibleChange', |
|
|
|
}), |
|
|
|
AutoComplete, |
|
|
|
Cascader, |
|
|
|
Checkbox, |
|
|
|
|