Procházet zdrojové kódy

Merge branch 'fork/jyqwq/feature/antd上传组件支持拖拽排序'

Jin Mao před 2 měsíci
rodič
revize
5f21bd2036

+ 333 - 254
apps/web-antd/src/adapter/component/index.ts

@@ -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,260 +131,261 @@ 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,
+  visible: Ref<boolean>,
+  fileList: Ref<UploadProps['fileList']>,
+) {
+  // 非图片文件直接打开链接
+  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);
+                }
               },
-              () => 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 closeModal = () => {
+      open.value = false;
+      setTimeout(() => {
+        if (!isUnmounted && container) {
+          if (objectUrl) {
+            URL.revokeObjectURL(objectUrl);
+          }
+          isUnmounted = true;
+          render(null, container);
+          container.remove();
+        }
+      }, 300);
+    };
 
-    const PreviewWrapper = {
+    const CropperWrapper = {
       setup() {
         return () => {
           if (isUnmounted) return null;
+          if (!objectUrl) {
+            objectUrl = URL.createObjectURL(file);
+          }
           return h(
-            PreviewGroupComponent,
+            Modal,
             {
-              class: 'hidden',
-              preview: {
-                visible: visible.value,
-                // 设置初始显示的图片索引
-                current: imageFiles.findIndex((f) => f.uid === file.uid),
-                onVisibleChange: (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'),
+              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();
               },
             },
             () =>
-              // 渲染所有图片文件
-              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'),
-                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);
-    });
-  };
+    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')));
-            }
-            resolve(blob);
-          });
+          if (!blob) {
+            throw new Error($t('ui.crop.errorTip'));
+          }
+          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,18 +534,28 @@ 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,
+          ),
         );
     },
   });

+ 333 - 254
playground/src/adapter/component/index.ts

@@ -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,260 +131,261 @@ 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,
+  visible: Ref<boolean>,
+  fileList: Ref<UploadProps['fileList']>,
+) {
+  // 非图片文件直接打开链接
+  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);
+                }
               },
-              () => 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 closeModal = () => {
+      open.value = false;
+      setTimeout(() => {
+        if (!isUnmounted && container) {
+          if (objectUrl) {
+            URL.revokeObjectURL(objectUrl);
+          }
+          isUnmounted = true;
+          render(null, container);
+          container.remove();
+        }
+      }, 300);
+    };
 
-    const PreviewWrapper = {
+    const CropperWrapper = {
       setup() {
         return () => {
           if (isUnmounted) return null;
+          if (!objectUrl) {
+            objectUrl = URL.createObjectURL(file);
+          }
           return h(
-            PreviewGroupComponent,
+            Modal,
             {
-              class: 'hidden',
-              preview: {
-                visible: visible.value,
-                // 设置初始显示的图片索引
-                current: imageFiles.findIndex((f) => f.uid === file.uid),
-                onVisibleChange: (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'),
+              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();
               },
             },
             () =>
-              // 渲染所有图片文件
-              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'),
-                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);
-    });
-  };
+    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')));
-            }
-            resolve(blob);
-          });
+          if (!blob) {
+            throw new Error($t('ui.crop.errorTip'));
+          }
+          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,18 +534,28 @@ 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,
+          ),
         );
     },
   });

+ 5 - 1
playground/src/views/examples/form/basic.vue

@@ -348,13 +348,14 @@ const [BaseForm, baseFormApi] = useVbenForm({
         // 自动携带认证信息
         customRequest: upload_file,
         disabled: false,
-        maxCount: 1,
+        maxCount: 3,
         // 单位:MB
         maxSize: 2,
         multiple: false,
         showUploadList: true,
         // 上传列表的内建样式,支持四种基本样式 text, picture, picture-card 和 picture-circle
         listType: 'picture-card',
+        draggable: true, // 启用拖拽排序
         // onChange事件已被重写,如需自定义请在此基础上扩展
         handleChange: ({ file }: { file: UploadFile }) => {
           const { name, status } = file;
@@ -364,6 +365,9 @@ const [BaseForm, baseFormApi] = useVbenForm({
             message.error(`${name} ${$t('examples.form.upload-fail')}`);
           }
         },
+        onDragSort: (oldIndex: number, newIndex: number) => {
+          console.info(`图片从 ${oldIndex} 移动到 ${newIndex}`);
+        },
       },
       fieldName: 'files',
       label: $t('examples.form.file'),