/** * 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用 * 可用于 vben-form、vben-modal、vben-drawer 等组件使用, */ /* eslint-disable vue/one-component-per-file */ import type { AutoCompleteProps, ButtonProps, CascaderProps, CheckboxGroupProps, CheckboxProps, DatePickerProps, DividerProps, InputNumberProps, InputProps, MentionsProps, RadioGroupProps, RadioProps, RateProps, SelectProps, SpaceProps, SwitchProps, TextAreaProps, TimePickerProps, TreeSelectProps, UploadChangeParam, UploadFile, UploadProps, } from 'ant-design-vue'; import type { RangePickerProps } from 'ant-design-vue/es/date-picker'; import type { Component, Ref } from 'vue'; import type { ApiComponentSharedProps, BaseFormComponentType, 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 { computed, defineAsyncComponent, defineComponent, h, nextTick, onMounted, onUnmounted, ref, render, unref, watch, } from 'vue'; import { ApiComponent, globalShareState, IconPicker, 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 'ant-design-vue'; 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('ant-design-vue/es/auto-complete'), ); const Button = defineAsyncComponent(() => import('ant-design-vue/es/button')); const Checkbox = defineAsyncComponent( () => import('ant-design-vue/es/checkbox'), ); const CheckboxGroup = defineAsyncComponent(() => import('ant-design-vue/es/checkbox').then((res) => res.CheckboxGroup), ); const DatePicker = defineAsyncComponent( () => import('ant-design-vue/es/date-picker'), ); const Divider = defineAsyncComponent(() => import('ant-design-vue/es/divider')); const Input = defineAsyncComponent(() => import('ant-design-vue/es/input')); const InputNumber = defineAsyncComponent( () => import('ant-design-vue/es/input-number'), ); const InputPassword = defineAsyncComponent(() => import('ant-design-vue/es/input').then((res) => res.InputPassword), ); const Mentions = defineAsyncComponent( () => import('ant-design-vue/es/mentions'), ); const Radio = defineAsyncComponent(() => import('ant-design-vue/es/radio')); const RadioGroup = defineAsyncComponent(() => import('ant-design-vue/es/radio').then((res) => res.RadioGroup), ); const RangePicker = defineAsyncComponent(() => import('ant-design-vue/es/date-picker').then((res) => res.RangePicker), ); const Rate = defineAsyncComponent(() => import('ant-design-vue/es/rate')); const Select = defineAsyncComponent(() => import('ant-design-vue/es/select')); const Space = defineAsyncComponent(() => import('ant-design-vue/es/space')); const Switch = defineAsyncComponent(() => import('ant-design-vue/es/switch')); const Textarea = defineAsyncComponent(() => import('ant-design-vue/es/input').then((res) => res.Textarea), ); const TimePicker = defineAsyncComponent( () => import('ant-design-vue/es/time-picker'), ); const TreeSelect = defineAsyncComponent( () => import('ant-design-vue/es/tree-select'), ); const Cascader = defineAsyncComponent( () => import('ant-design-vue/es/cascader'), ); const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload')); const Image = defineAsyncComponent(() => import('ant-design-vue/es/image')); const PreviewGroup = defineAsyncComponent(() => import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup), ); const withDefaultPlaceholder = ( component: T, type: 'input' | 'select', componentProps: Recordable = {}, ) => { return defineComponent({ name: component.name, inheritAttrs: false, setup: (props: any, { attrs, expose, slots }) => { const placeholder = props?.placeholder || attrs?.placeholder || $t(`ui.placeholder.${type}`); // 透传组件暴露的方法 const innerRef = ref(); expose( new Proxy( {}, { get: (_target, key) => innerRef.value?.[key], has: (_target, key) => key in (innerRef.value || {}), }, ), ); return () => h( component, { ...componentProps, placeholder, ...props, ...attrs, ref: innerRef }, slots, ); }, }); }; 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; } } 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, ), }; } /** * 获取文件的 Base64 */ function getBase64(file: File): Promise { 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, fileList: Ref, ) { // 非图片文件直接打开链接 if (!isImageFile(file)) { 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 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: { visible: visible.value, current: currentIndex, onVisibleChange: (value: boolean) => { visible.value = value; if (!value) { setTimeout(() => { if (!isUnmounted && container) { isUnmounted = true; render(null, container); container.remove(); } }, 300); } }, }, }, () => imageFiles.map((imgFile) => h(ImageComponent, { key: imgFile.uid, src: imgFile.url || imgFile.preview, }), ), ); }; }, }; render(h(PreviewWrapper), container); } /** * 图片裁剪操作 */ function cropImage(file: File, aspectRatio: string | undefined) { return new Promise((resolve, reject) => { const container = document.createElement('div'); document.body.append(container); let isUnmounted = false; let objectUrl: null | string = null; const open = ref(true); const cropperRef = ref | 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'), destroyOnClose: 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(); }, }, () => h(VCropper, { ref: (ref: any) => (cropperRef.value = ref), img: objectUrl as string, aspectRatio, }), ); }; }, }; render(h(CropperWrapper), container); }); } /** * 带预览功能的上传组件 */ const withPreviewUpload = () => { return defineComponent({ name: Upload.name, emits: ['update:modelValue'], setup( props: any, { attrs, slots, emit }: { attrs: any; emit: any; slots: any }, ) { const previewVisible = ref(false); const placeholder = attrs?.placeholder || $t('ui.placeholder.upload'); const listType = attrs?.listType || attrs?.['list-type'] || 'text'; const fileList = ref( attrs?.fileList || attrs?.['file-list'] || [], ); const maxSize = computed(() => attrs?.maxSize ?? attrs?.['max-size']); const aspectRatio = computed( () => attrs?.aspectRatio ?? attrs?.['aspect-ratio'], ); const handleBeforeUpload = async ( file: UploadFile, originFileList: Array, ) => { // 文件大小限制 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 && originFileList[0] && isImageFile(file) ) { file.status = 'removed'; const blob = await cropImage(originFileList[0], aspectRatio.value); if (!blob) { throw new Error($t('ui.crop.errorTip')); } return blob; } return attrs.beforeUpload?.(file) ?? true; }; const handleChange = (event: UploadChangeParam) => { try { attrs.handleChange?.(event); attrs.onHandleChange?.(event); } catch (error) { console.error(error); } fileList.value = event.fileList.filter( (file) => file.status !== 'removed', ); emit( 'update:modelValue', event.fileList?.length ? fileList.value : undefined, ); }; const handlePreview = async (file: UploadFile) => { previewVisible.value = true; await previewImage(file, previewVisible, fileList); }; const renderUploadButton = () => { if (attrs.disabled) return null; return isEmpty(slots) ? createDefaultUploadSlots(listType, placeholder) : slots; }; // 拖拽排序 const draggable = computed( () => (attrs.draggable ?? false) && !attrs.disabled, ); const uploadId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; const sortableInstance = ref(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) => { fileList.value = res; }, ); onMounted(initSortable); onUnmounted(() => { sortableInstance.value?.destroy(); removeDragStyle(); }); return () => h( '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 = | 'ApiCascader' | 'ApiSelect' | 'ApiTreeSelect' | 'AutoComplete' | 'Cascader' | 'Checkbox' | 'CheckboxGroup' | 'DatePicker' | 'DefaultButton' | 'Divider' | 'IconPicker' | 'Input' | 'InputNumber' | 'InputPassword' | 'Mentions' | 'PrimaryButton' | 'Radio' | 'RadioGroup' | 'RangePicker' | 'Rate' | 'RichEditor' | 'Select' | 'Space' | 'Switch' | 'Textarea' | 'TimePicker' | 'TreeSelect' | '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; 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> = { // 如果你的组件体积比较大,可以使用异步加载 // Button: () => // import('xxx').then((res) => res.Button), ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', { component: Cascader, fieldNames: { label: 'label', value: 'value', children: 'children' }, loadingSlot: 'suffixIcon', modelPropName: 'value', 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, CheckboxGroup, DatePicker, // 自定义默认按钮 DefaultButton: (props, { attrs, slots }) => { return h(Button, { ...props, attrs, type: 'default' }, slots); }, Divider, IconPicker: withDefaultPlaceholder(IconPicker, 'select', { iconSlot: 'addonAfter', inputComponent: Input, modelValueProp: 'value', }), Input: withDefaultPlaceholder(Input, 'input'), InputNumber: withDefaultPlaceholder(InputNumber, 'input'), InputPassword: withDefaultPlaceholder(InputPassword, 'input'), Mentions: withDefaultPlaceholder(Mentions, 'input'), // 自定义主要按钮 PrimaryButton: (props, { attrs, slots }) => { return h(Button, { ...props, attrs, type: 'primary' }, slots); }, Radio, RadioGroup, RangePicker, Rate, RichEditor: withDefaultPlaceholder(VbenTiptap, 'input'), Select: withDefaultPlaceholder(Select, 'select'), Space, Switch, Textarea: withDefaultPlaceholder(Textarea, 'input'), TimePicker, TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'), Upload: withPreviewUpload(), }; // 将组件注册到全局共享状态中 globalShareState.setComponents(components); // 定义全局共享状态中的消息提示 globalShareState.defineMessage({ // 复制成功消息提示 copyPreferencesSuccess: (title, content) => { notification.success({ description: content, message: title, placement: 'bottomRight', }); }, }); } export { initComponentAdapter };