فهرست منبع

feat(upload prop:crop,aspectRatio): from Upload component accept prop… (#7095)

* feat(upload prop:crop,aspectRatio): from Upload component accept prop crop,aspectRatio

* feat(upload prop:crop,aspectRatio): from Upload component accept prop crop,aspectRatio

* feat(upload prop:crop,aspectRatio): from Upload component accept prop crop,aspectRatio

* feat(upload prop:crop,aspectRatio): from Upload component accept prop crop,aspectRatio
JyQAQ 5 ماه پیش
والد
کامیت
67da9417a8

+ 176 - 52
apps/web-antd/src/adapter/component/index.ts

@@ -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,

+ 6 - 0
packages/locales/src/langs/en-US/ui.json

@@ -54,6 +54,12 @@
     "copy": "Copy",
     "copied": "Copied"
   },
+  "crop": {
+    "title": "Image Cropping",
+    "confirm": "Crop",
+    "cancel": "Cancel cropping",
+    "errorTip": "Cropping error"
+  },
   "fallback": {
     "pageNotFound": "Oops! Page Not Found",
     "pageNotFoundDesc": "Sorry, we couldn't find the page you were looking for.",

+ 6 - 0
packages/locales/src/langs/zh-CN/ui.json

@@ -54,6 +54,12 @@
     "copy": "复制",
     "copied": "已复制"
   },
+  "crop": {
+    "title": "图片裁剪",
+    "confirm": "裁剪",
+    "cancel": "取消裁剪",
+    "errorTip": "裁剪错误"
+  },
   "fallback": {
     "pageNotFound": "哎呀!未找到页面",
     "pageNotFoundDesc": "抱歉,我们无法找到您要找的页面。",

+ 161 - 33
playground/src/adapter/component/index.ts

@@ -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'),
@@ -101,7 +106,6 @@ const withDefaultPlaceholder = <T extends Component>(
         $t(`ui.placeholder.${type}`);
       // 透传组件暴露的方法
       const innerRef = ref();
-      // const publicApi: Recordable<any> = {};
       expose(
         new Proxy(
           {},
@@ -111,14 +115,6 @@ const withDefaultPlaceholder = <T extends Component>(
           },
         ),
       );
-      // const instance = getCurrentInstance();
-      // instance?.proxy?.$nextTick(() => {
-      //   for (const key in innerRef.value) {
-      //     if (typeof innerRef.value[key] === 'function') {
-      //       publicApi[key] = innerRef.value[key];
-      //     }
-      //   }
-      // });
       return () =>
         h(
           component,
@@ -130,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,
@@ -164,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) {
@@ -270,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'],
@@ -287,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;
       };
 

+ 1 - 0
playground/src/locales/langs/en-US/examples.json

@@ -23,6 +23,7 @@
     "upload-error": "Partial file upload failed",
     "upload-urls": "Urls after file upload",
     "file": "file",
+    "crop-image": "Crop image",
     "upload-image": "Click to upload image"
   },
   "vxeTable": {

+ 1 - 0
playground/src/locales/langs/zh-CN/examples.json

@@ -26,6 +26,7 @@
     "upload-error": "部分文件上传失败",
     "upload-urls": "文件上传后的网址",
     "file": "文件",
+    "crop-image": "裁剪图片",
     "upload-image": "点击上传图片"
   },
   "vxeTable": {

+ 40 - 0
playground/src/views/examples/form/basic.vue

@@ -358,6 +358,28 @@ const [BaseForm, baseFormApi] = useVbenForm({
       },
       rules: 'selectRequired',
     },
+    {
+      component: 'Upload',
+      componentProps: {
+        accept: '.png,.jpg,.jpeg',
+        customRequest: upload_file,
+        maxCount: 1,
+        maxSize: 2,
+        listType: 'picture-card',
+        // 是否启用图片裁剪(多选或者非图片不唤起裁剪框)
+        crop: true,
+        // 裁剪比例
+        aspectRatio: '1:1',
+      },
+      fieldName: 'cropImage',
+      label: $t('examples.form.crop-image'),
+      renderComponentContent: () => {
+        return {
+          default: () => $t('examples.form.upload-image'),
+        };
+      },
+      rules: 'selectRequired',
+    },
   ],
   // 大屏一行显示3个,中屏一行显示2个,小屏一行显示1个
   wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
@@ -365,13 +387,20 @@ const [BaseForm, baseFormApi] = useVbenForm({
 
 function onSubmit(values: Record<string, any>) {
   const files = toRaw(values.files) as UploadFile[];
+  const cropImage = (toRaw(values.cropImage) ?? []) as UploadFile[];
   const doneFiles = files.filter((file) => file.status === 'done');
   const failedFiles = files.filter((file) => file.status !== 'done');
+  const doneCrop = cropImage.filter((file) => file.status === 'done');
+  const failedCrop = cropImage.filter((file) => file.status !== 'done');
 
   const msg = [
     ...doneFiles.map((file) => file.response?.url || file.url),
     ...failedFiles.map((file) => file.name),
   ].join(', ');
+  const msgCrop = [
+    ...doneCrop.map((file) => file.response?.url || file.url),
+    ...failedCrop.map((file) => file.name),
+  ].join(', ');
 
   if (failedFiles.length === 0) {
     message.success({
@@ -383,8 +412,19 @@ function onSubmit(values: Record<string, any>) {
     });
     return;
   }
+  if (doneCrop.length > 0 && failedCrop.length === 0) {
+    message.success({
+      content: `${$t('examples.form.upload-urls')}: ${msgCrop}`,
+    });
+  } else if (failedCrop.length > 0) {
+    message.error({
+      content: `${$t('examples.form.upload-error')}: ${msgCrop}`,
+    });
+    return;
+  }
   // 如果需要可提交前替换为需要的urls
   values.files = doneFiles.map((file) => file.response?.url || file.url);
+  values.cropImage = doneCrop.map((file) => file.response?.url || file.url);
   message.success({
     content: `form values: ${JSON.stringify(values)}`,
   });