|
@@ -14,6 +14,7 @@ import type {
|
|
|
import type { Component, Ref } from 'vue';
|
|
import type { Component, Ref } from 'vue';
|
|
|
|
|
|
|
|
import type { BaseFormComponentType } from '@vben/common-ui';
|
|
import type { BaseFormComponentType } from '@vben/common-ui';
|
|
|
|
|
+import type { Sortable } from '@vben/hooks';
|
|
|
import type { Recordable } from '@vben/types';
|
|
import type { Recordable } from '@vben/types';
|
|
|
|
|
|
|
|
import {
|
|
import {
|
|
@@ -21,6 +22,9 @@ import {
|
|
|
defineAsyncComponent,
|
|
defineAsyncComponent,
|
|
|
defineComponent,
|
|
defineComponent,
|
|
|
h,
|
|
h,
|
|
|
|
|
+ nextTick,
|
|
|
|
|
+ onMounted,
|
|
|
|
|
+ onUnmounted,
|
|
|
ref,
|
|
ref,
|
|
|
render,
|
|
render,
|
|
|
unref,
|
|
unref,
|
|
@@ -33,6 +37,7 @@ import {
|
|
|
IconPicker,
|
|
IconPicker,
|
|
|
VCropper,
|
|
VCropper,
|
|
|
} from '@vben/common-ui';
|
|
} from '@vben/common-ui';
|
|
|
|
|
+import { useSortable } from '@vben/hooks';
|
|
|
import { IconifyIcon } from '@vben/icons';
|
|
import { IconifyIcon } from '@vben/icons';
|
|
|
import { $t } from '@vben/locales';
|
|
import { $t } from '@vben/locales';
|
|
|
import { isEmpty } from '@vben/utils';
|
|
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);
|
|
document.body.append(container);
|
|
|
|
|
|
|
|
- // 用于追踪组件是否已卸载
|
|
|
|
|
let isUnmounted = false;
|
|
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() {
|
|
setup() {
|
|
|
return () => {
|
|
return () => {
|
|
|
if (isUnmounted) return null;
|
|
if (isUnmounted) return null;
|
|
|
|
|
+ if (!objectUrl) {
|
|
|
|
|
+ objectUrl = URL.createObjectURL(file);
|
|
|
|
|
+ }
|
|
|
return h(
|
|
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({
|
|
return defineComponent({
|
|
|
name: Upload.name,
|
|
name: Upload.name,
|
|
|
emits: ['update:modelValue'],
|
|
emits: ['update:modelValue'],
|
|
|
- setup: (
|
|
|
|
|
|
|
+ setup(
|
|
|
props: any,
|
|
props: any,
|
|
|
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
|
|
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
|
|
|
- ) => {
|
|
|
|
|
|
|
+ ) {
|
|
|
const previewVisible = ref<boolean>(false);
|
|
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 listType = attrs?.listType || attrs?.['list-type'] || 'text';
|
|
|
-
|
|
|
|
|
const fileList = ref<UploadProps['fileList']>(
|
|
const fileList = ref<UploadProps['fileList']>(
|
|
|
attrs?.fileList || attrs?.['file-list'] || [],
|
|
attrs?.fileList || attrs?.['file-list'] || [],
|
|
|
);
|
|
);
|
|
@@ -393,12 +399,14 @@ const withPreviewUpload = () => {
|
|
|
file: UploadFile,
|
|
file: UploadFile,
|
|
|
originFileList: Array<File>,
|
|
originFileList: Array<File>,
|
|
|
) => {
|
|
) => {
|
|
|
|
|
+ // 文件大小限制
|
|
|
if (maxSize.value && (file.size || 0) / 1024 / 1024 > maxSize.value) {
|
|
if (maxSize.value && (file.size || 0) / 1024 / 1024 > maxSize.value) {
|
|
|
message.error($t('ui.formRules.sizeLimit', [maxSize.value]));
|
|
message.error($t('ui.formRules.sizeLimit', [maxSize.value]));
|
|
|
file.status = 'removed';
|
|
file.status = 'removed';
|
|
|
return false;
|
|
return false;
|
|
|
}
|
|
}
|
|
|
- // 多选或者非图片不唤起裁剪框
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // 图片裁剪处理
|
|
|
if (
|
|
if (
|
|
|
attrs.crop &&
|
|
attrs.crop &&
|
|
|
!attrs.multiple &&
|
|
!attrs.multiple &&
|
|
@@ -406,14 +414,11 @@ const withPreviewUpload = () => {
|
|
|
isImageFile(file)
|
|
isImageFile(file)
|
|
|
) {
|
|
) {
|
|
|
file.status = 'removed';
|
|
file.status = 'removed';
|
|
|
- // antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取
|
|
|
|
|
const blob = await cropImage(originFileList[0], aspectRatio.value);
|
|
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;
|
|
return attrs.beforeUpload?.(file) ?? true;
|
|
@@ -421,12 +426,9 @@ const withPreviewUpload = () => {
|
|
|
|
|
|
|
|
const handleChange = (event: UploadChangeParam) => {
|
|
const handleChange = (event: UploadChangeParam) => {
|
|
|
try {
|
|
try {
|
|
|
- // 行内写法 handleChange: (event) => {}
|
|
|
|
|
attrs.handleChange?.(event);
|
|
attrs.handleChange?.(event);
|
|
|
- // template写法 @handle-change="(event) => {}"
|
|
|
|
|
attrs.onHandleChange?.(event);
|
|
attrs.onHandleChange?.(event);
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- // Avoid breaking internal v-model sync on user handler errors
|
|
|
|
|
console.error(error);
|
|
console.error(error);
|
|
|
}
|
|
}
|
|
|
fileList.value = event.fileList.filter(
|
|
fileList.value = event.fileList.filter(
|
|
@@ -443,21 +445,88 @@ const withPreviewUpload = () => {
|
|
|
await previewImage(file, previewVisible, fileList);
|
|
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)
|
|
return isEmpty(slots)
|
|
|
- ? createDefaultSlotsWithUpload(listType, placeholder)
|
|
|
|
|
|
|
+ ? createDefaultUploadSlots(listType, placeholder)
|
|
|
: slots;
|
|
: 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(
|
|
watch(
|
|
|
() => attrs.modelValue,
|
|
() => attrs.modelValue,
|
|
|
(res) => {
|
|
(res) => {
|
|
@@ -465,18 +534,28 @@ const withPreviewUpload = () => {
|
|
|
},
|
|
},
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
|
|
+ onMounted(initSortable);
|
|
|
|
|
+ onUnmounted(() => {
|
|
|
|
|
+ sortableInstance.value?.destroy();
|
|
|
|
|
+ removeDragStyle();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
return () =>
|
|
return () =>
|
|
|
h(
|
|
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,
|
|
|
|
|
+ ),
|
|
|
);
|
|
);
|
|
|
},
|
|
},
|
|
|
});
|
|
});
|