From ce495d67a045c1d88edafaeed81d25cc26069049 Mon Sep 17 00:00:00 2001 From: xingyu4j Date: Sat, 6 Jun 2026 16:20:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5=20antdv-next=20?= =?UTF-8?q?=E7=9A=84=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/adapter/component/index.ts | 650 ++++++++++-------- apps/web-antdv-next/src/api/core/index.ts | 1 + apps/web-antdv-next/src/api/core/upload.ts | 25 + 3 files changed, 408 insertions(+), 268 deletions(-) create mode 100644 apps/web-antdv-next/src/api/core/upload.ts diff --git a/apps/web-antdv-next/src/adapter/component/index.ts b/apps/web-antdv-next/src/adapter/component/index.ts index e87411d16..ebd6909b1 100644 --- a/apps/web-antdv-next/src/adapter/component/index.ts +++ b/apps/web-antdv-next/src/adapter/component/index.ts @@ -36,8 +36,11 @@ import type { Component, Ref } from 'vue'; 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 { @@ -45,6 +48,9 @@ import { defineAsyncComponent, defineComponent, h, + nextTick, + onMounted, + onUnmounted, ref, render, unref, @@ -55,19 +61,25 @@ 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; }; @@ -80,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'), @@ -170,260 +182,263 @@ const withDefaultPlaceholder = ( }); }; -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; - } +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 ? imageExtensions.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 { + 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, + fileList: Ref, +) { + // 非图片文件直接打开链接 + 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, - fileList: Ref, - ) => { - // 如果当前文件不是图片,直接打开 - 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, - ]); + render(h(PreviewWrapper), container); +} - 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'); +/** + * 图片裁剪操作 + */ +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 PreviewWrapper = { + const open = ref(true); + const cropperRef = ref | null>(null); + + 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(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'), - 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(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( attrs?.fileList || attrs?.['file-list'] || [], ); @@ -433,16 +448,18 @@ const withPreviewUpload = () => { () => attrs?.aspectRatio ?? attrs?.['aspect-ratio'], ); - const handleBeforeUpload = async ( + async function handleBeforeUpload( 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 && @@ -450,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( @@ -480,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); + + 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; } - // 否则渲染默认上传按钮 - return isEmpty(slots) - ? createDefaultSlotsWithUpload(listType, placeholder) - : slots; - }; + 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; + } - // 可以监听到表单API设置的值 + 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) => { @@ -509,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 = @@ -535,6 +623,7 @@ export type ComponentType = | 'Cascader' | 'Checkbox' | 'CheckboxGroup' + | 'CollapsibleParams' | 'DatePicker' | 'DefaultButton' | 'Divider' @@ -548,6 +637,7 @@ export type ComponentType = | 'RadioGroup' | 'RangePicker' | 'Rate' + | 'RichEditor' | 'Select' | 'Space' | 'Switch' @@ -568,6 +658,7 @@ export interface ComponentPropsMap { Cascader: CascaderProps; Checkbox: CheckboxProps; CheckboxGroup: CheckboxGroupProps; + CollapsibleParams: CollapsibleParamsProps; DatePicker: DatePickerProps; DefaultButton: ButtonProps; Divider: DividerProps; @@ -581,6 +672,7 @@ export interface ComponentPropsMap { RadioGroup: RadioGroupProps; RangePicker: RangePickerProps; Rate: RateProps; + RichEditor: TipTapProps; Select: SelectProps; Space: SpaceProps; Switch: SwitchProps; @@ -601,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, @@ -615,7 +707,7 @@ async function initComponentAdapter() { loadingSlot: 'suffixIcon', modelPropName: 'value', optionsPropName: 'treeData', - visibleEvent: 'onVisibleChange', + visibleEvent: 'onOpenChange', }), AutoComplete, Cascader, @@ -646,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, @@ -653,6 +766,7 @@ async function initComponentAdapter() { TimePicker, TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'), Upload: withPreviewUpload(), + CollapsibleParams: VbenCollapsibleParams, }; // 将组件注册到全局共享状态中 diff --git a/apps/web-antdv-next/src/api/core/index.ts b/apps/web-antdv-next/src/api/core/index.ts index 28a5aef47..04256867c 100644 --- a/apps/web-antdv-next/src/api/core/index.ts +++ b/apps/web-antdv-next/src/api/core/index.ts @@ -1,3 +1,4 @@ export * from './auth'; export * from './menu'; +export * from './upload'; export * from './user'; diff --git a/apps/web-antdv-next/src/api/core/upload.ts b/apps/web-antdv-next/src/api/core/upload.ts new file mode 100644 index 000000000..246d4f267 --- /dev/null +++ b/apps/web-antdv-next/src/api/core/upload.ts @@ -0,0 +1,25 @@ +import { requestClient } from '#/api/request'; + +interface UploadFileParams { + file: File; + onError?: (error: Error) => void; + onProgress?: (progress: { percent: number }) => void; + onSuccess?: (data: any, file: File) => void; +} +export async function upload_file({ + file, + onError, + onProgress, + onSuccess, +}: UploadFileParams) { + try { + onProgress?.({ percent: 0 }); + + const data = await requestClient.upload('/upload', { file }); + + onProgress?.({ percent: 100 }); + onSuccess?.(data, file); + } catch (error) { + onError?.(error instanceof Error ? error : new Error(String(error))); + } +}