feat: 同步 antdv-next 的组件
parent
8094d1ffb9
commit
ce495d67a0
|
|
@ -36,8 +36,11 @@ import type { Component, Ref } from 'vue';
|
||||||
import type {
|
import type {
|
||||||
ApiComponentSharedProps,
|
ApiComponentSharedProps,
|
||||||
BaseFormComponentType,
|
BaseFormComponentType,
|
||||||
|
CollapsibleParamsProps,
|
||||||
IconPickerProps,
|
IconPickerProps,
|
||||||
} from '@vben/common-ui';
|
} from '@vben/common-ui';
|
||||||
|
import type { Sortable } from '@vben/hooks';
|
||||||
|
import type { TipTapProps } from '@vben/plugins/tiptap';
|
||||||
import type { Recordable } from '@vben/types';
|
import type { Recordable } from '@vben/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -45,6 +48,9 @@ import {
|
||||||
defineAsyncComponent,
|
defineAsyncComponent,
|
||||||
defineComponent,
|
defineComponent,
|
||||||
h,
|
h,
|
||||||
|
nextTick,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
ref,
|
ref,
|
||||||
render,
|
render,
|
||||||
unref,
|
unref,
|
||||||
|
|
@ -55,19 +61,25 @@ import {
|
||||||
ApiComponent,
|
ApiComponent,
|
||||||
globalShareState,
|
globalShareState,
|
||||||
IconPicker,
|
IconPicker,
|
||||||
|
VbenCollapsibleParams,
|
||||||
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 { VbenTiptap } from '@vben/plugins/tiptap';
|
||||||
import { isEmpty } from '@vben/utils';
|
import { isEmpty } from '@vben/utils';
|
||||||
|
|
||||||
import { message, Modal, notification } from 'antdv-next';
|
import { message, Modal, notification } from 'antdv-next';
|
||||||
|
|
||||||
|
import { upload_file } from '#/api';
|
||||||
type AdapterUploadProps = UploadProps & {
|
type AdapterUploadProps = UploadProps & {
|
||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
crop?: boolean;
|
crop?: boolean;
|
||||||
|
draggable?: boolean;
|
||||||
handleChange?: (event: UploadChangeParam) => void;
|
handleChange?: (event: UploadChangeParam) => void;
|
||||||
maxSize?: number;
|
maxSize?: number;
|
||||||
|
onDragSort?: (oldIndex: number, newIndex: number) => void;
|
||||||
onHandleChange?: (event: UploadChangeParam) => void;
|
onHandleChange?: (event: UploadChangeParam) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -80,8 +92,8 @@ const Button = defineAsyncComponent(
|
||||||
const Checkbox = defineAsyncComponent(
|
const Checkbox = defineAsyncComponent(
|
||||||
() => import('antdv-next/dist/checkbox/index'),
|
() => import('antdv-next/dist/checkbox/index'),
|
||||||
);
|
);
|
||||||
const CheckboxGroup = defineAsyncComponent(
|
const CheckboxGroup = defineAsyncComponent(() =>
|
||||||
() => import('antdv-next/dist/checkbox/Group'),
|
import('antdv-next/dist/checkbox/index').then((res) => res.CheckboxGroup),
|
||||||
);
|
);
|
||||||
const DatePicker = defineAsyncComponent(
|
const DatePicker = defineAsyncComponent(
|
||||||
() => import('antdv-next/dist/date-picker/index'),
|
() => import('antdv-next/dist/date-picker/index'),
|
||||||
|
|
@ -170,260 +182,263 @@ const withDefaultPlaceholder = (
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const withPreviewUpload = () => {
|
const IMAGE_EXTENSIONS = new Set([
|
||||||
// 检查是否为图片文件的辅助函数
|
'bmp',
|
||||||
const isImageFile = (file: UploadFile): boolean => {
|
'gif',
|
||||||
const imageExtensions = new Set([
|
'jpeg',
|
||||||
'bmp',
|
'jpg',
|
||||||
'gif',
|
'png',
|
||||||
'jpeg',
|
'svg',
|
||||||
'jpg',
|
'webp',
|
||||||
'png',
|
]);
|
||||||
'svg',
|
|
||||||
'webp',
|
/**
|
||||||
]);
|
* 检查是否为图片文件
|
||||||
if (file.url) {
|
*/
|
||||||
try {
|
function isImageFile(file: UploadFile): boolean {
|
||||||
const pathname = new URL(file.url, 'http://localhost').pathname;
|
if (file.url) {
|
||||||
const ext = pathname.split('.').pop()?.toLowerCase();
|
try {
|
||||||
return ext ? imageExtensions.has(ext) : false;
|
const pathname = new URL(file.url, 'http://localhost').pathname;
|
||||||
} catch {
|
const ext = pathname.split('.').pop()?.toLowerCase();
|
||||||
const ext = file.url?.split('.').pop()?.toLowerCase();
|
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||||
return ext ? imageExtensions.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();
|
if (!file.type) {
|
||||||
return ext ? imageExtensions.has(ext) : false;
|
const ext = file.name?.split('.').pop()?.toLowerCase();
|
||||||
}
|
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||||
return file.type.startsWith('image/');
|
}
|
||||||
|
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,
|
* 获取文件的 Base64
|
||||||
) => {
|
*/
|
||||||
switch (listType) {
|
function getBase64(file: File): Promise<string> {
|
||||||
case 'picture-card': {
|
return new Promise((resolve, reject) => {
|
||||||
return {
|
const reader = new FileReader();
|
||||||
default: () => placeholder,
|
reader.readAsDataURL(file);
|
||||||
};
|
reader.addEventListener('load', () => resolve(reader.result as string));
|
||||||
}
|
reader.addEventListener('error', reject);
|
||||||
default: {
|
});
|
||||||
return {
|
}
|
||||||
default: () =>
|
|
||||||
h(
|
/**
|
||||||
Button,
|
* 预览图片
|
||||||
{
|
*/
|
||||||
icon: h(IconifyIcon, {
|
async function previewImage(
|
||||||
icon: 'ant-design:upload-outlined',
|
file: UploadFile,
|
||||||
class: 'mb-1 size-4',
|
open: Ref<boolean>,
|
||||||
}),
|
fileList: Ref<UploadProps['fileList']>,
|
||||||
|
) {
|
||||||
|
// 非图片文件直接打开链接
|
||||||
|
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<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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 对于图片文件,继续使用预览组
|
render(h(PreviewWrapper), container);
|
||||||
const [ImageComponent, PreviewGroupComponent] = await Promise.all([
|
}
|
||||||
Image,
|
|
||||||
PreviewGroup,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const getBase64 = (file: File) => {
|
/**
|
||||||
return new Promise((resolve, reject) => {
|
* 图片裁剪操作
|
||||||
const reader = new FileReader();
|
*/
|
||||||
reader.readAsDataURL(file);
|
function cropImage(file: File, aspectRatio: string | undefined) {
|
||||||
reader.addEventListener('load', () => resolve(reader.result));
|
return new Promise<Blob | string | undefined>((resolve, reject) => {
|
||||||
reader.addEventListener('error', (error) => reject(error));
|
const container = document.createElement('div');
|
||||||
});
|
|
||||||
};
|
|
||||||
// 从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');
|
|
||||||
document.body.append(container);
|
document.body.append(container);
|
||||||
|
|
||||||
// 用于追踪组件是否已卸载
|
|
||||||
let isUnmounted = false;
|
let isUnmounted = false;
|
||||||
|
let objectUrl: null | string = null;
|
||||||
|
|
||||||
const PreviewWrapper = {
|
const open = ref<boolean>(true);
|
||||||
|
const cropperRef = ref<InstanceType<typeof VCropper> | 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() {
|
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',
|
open: open.value,
|
||||||
preview: {
|
title: h('div', {}, [
|
||||||
open: visible.value,
|
$t('ui.crop.title'),
|
||||||
// 设置初始显示的图片索引
|
h(
|
||||||
current: imageFiles.findIndex((f) => f.uid === file.uid),
|
'span',
|
||||||
onOpenChange: (value: boolean) => {
|
{
|
||||||
visible.value = value;
|
class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`,
|
||||||
if (!value) {
|
},
|
||||||
// 延迟清理,确保动画完成
|
$t('ui.crop.titleTip', [aspectRatio]),
|
||||||
setTimeout(() => {
|
),
|
||||||
if (!isUnmounted && container) {
|
]),
|
||||||
isUnmounted = true;
|
centered: true,
|
||||||
render(null, container);
|
width: 548,
|
||||||
container.remove();
|
keyboard: false,
|
||||||
}
|
maskClosable: false,
|
||||||
}, 300);
|
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();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
() =>
|
() =>
|
||||||
// 渲染所有图片文件
|
h(VCropper, {
|
||||||
imageFiles.map((imgFile) =>
|
ref: (ref: any) => (cropperRef.value = ref),
|
||||||
h(ImageComponent, {
|
img: objectUrl as string,
|
||||||
key: imgFile.uid,
|
aspectRatio,
|
||||||
src: imgFile.url || imgFile.preview,
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
render(h(PreviewWrapper), container);
|
render(h(CropperWrapper), 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'),
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带预览功能的上传组件
|
||||||
|
*/
|
||||||
|
function withPreviewUpload() {
|
||||||
return defineComponent({
|
return defineComponent({
|
||||||
name: 'AUpload',
|
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'] || [],
|
||||||
);
|
);
|
||||||
|
|
@ -433,16 +448,18 @@ const withPreviewUpload = () => {
|
||||||
() => attrs?.aspectRatio ?? attrs?.['aspect-ratio'],
|
() => attrs?.aspectRatio ?? attrs?.['aspect-ratio'],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBeforeUpload = async (
|
async function handleBeforeUpload(
|
||||||
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 &&
|
||||||
|
|
@ -450,27 +467,21 @@ 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) {
|
||||||
if (!blob) {
|
throw new Error($t('ui.crop.errorTip'));
|
||||||
return reject(new Error($t('ui.crop.errorTip')));
|
}
|
||||||
}
|
return blob;
|
||||||
resolve(blob);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return attrs.beforeUpload?.(file) ?? true;
|
return attrs.beforeUpload?.(file) ?? true;
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleChange = (event: UploadChangeParam) => {
|
function 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(
|
||||||
|
|
@ -480,28 +491,95 @@ const withPreviewUpload = () => {
|
||||||
'update:modelValue',
|
'update:modelValue',
|
||||||
event.fileList?.length ? fileList.value : undefined,
|
event.fileList?.length ? fileList.value : undefined,
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
const handlePreview = async (file: UploadFile) => {
|
function handlePreview(file: UploadFile) {
|
||||||
previewVisible.value = true;
|
previewVisible.value = true;
|
||||||
await previewImage(file, previewVisible, fileList);
|
return previewImage(file, previewVisible, fileList);
|
||||||
};
|
}
|
||||||
|
|
||||||
const renderUploadButton = (): any => {
|
function renderUploadButton() {
|
||||||
const isDisabled = attrs.disabled;
|
if (attrs.disabled) return null;
|
||||||
|
return isEmpty(slots)
|
||||||
|
? createDefaultUploadSlots(listType, placeholder)
|
||||||
|
: slots;
|
||||||
|
}
|
||||||
|
|
||||||
// 如果禁用,不渲染上传按钮
|
// 拖拽排序
|
||||||
if (isDisabled) {
|
const draggable = computed(
|
||||||
return null;
|
() => (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, {
|
||||||
return isEmpty(slots)
|
animation: 300,
|
||||||
? createDefaultSlotsWithUpload(listType, placeholder)
|
delay: 400,
|
||||||
: slots;
|
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(
|
watch(
|
||||||
() => attrs.modelValue,
|
() => attrs.modelValue,
|
||||||
(res) => {
|
(res) => {
|
||||||
|
|
@ -509,22 +587,32 @@ const withPreviewUpload = () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onMounted(initSortable);
|
||||||
|
onUnmounted(() => {
|
||||||
|
sortableInstance.value?.destroy();
|
||||||
|
removeDragStyle();
|
||||||
|
});
|
||||||
|
|
||||||
return () =>
|
return () =>
|
||||||
h(
|
h(
|
||||||
Upload,
|
'div',
|
||||||
{
|
{ 'data-upload-id': uploadId, class: 'w-full' },
|
||||||
...props,
|
h(
|
||||||
...attrs,
|
Upload,
|
||||||
fileList: fileList.value,
|
{
|
||||||
beforeUpload: handleBeforeUpload,
|
...props,
|
||||||
onChange: handleChange,
|
...attrs,
|
||||||
onPreview: handlePreview,
|
fileList: fileList.value,
|
||||||
},
|
beforeUpload: handleBeforeUpload,
|
||||||
renderUploadButton(),
|
onChange: handleChange,
|
||||||
|
onPreview: handlePreview,
|
||||||
|
},
|
||||||
|
renderUploadButton() as any,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
|
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
|
||||||
export type ComponentType =
|
export type ComponentType =
|
||||||
|
|
@ -535,6 +623,7 @@ export type ComponentType =
|
||||||
| 'Cascader'
|
| 'Cascader'
|
||||||
| 'Checkbox'
|
| 'Checkbox'
|
||||||
| 'CheckboxGroup'
|
| 'CheckboxGroup'
|
||||||
|
| 'CollapsibleParams'
|
||||||
| 'DatePicker'
|
| 'DatePicker'
|
||||||
| 'DefaultButton'
|
| 'DefaultButton'
|
||||||
| 'Divider'
|
| 'Divider'
|
||||||
|
|
@ -548,6 +637,7 @@ export type ComponentType =
|
||||||
| 'RadioGroup'
|
| 'RadioGroup'
|
||||||
| 'RangePicker'
|
| 'RangePicker'
|
||||||
| 'Rate'
|
| 'Rate'
|
||||||
|
| 'RichEditor'
|
||||||
| 'Select'
|
| 'Select'
|
||||||
| 'Space'
|
| 'Space'
|
||||||
| 'Switch'
|
| 'Switch'
|
||||||
|
|
@ -568,6 +658,7 @@ export interface ComponentPropsMap {
|
||||||
Cascader: CascaderProps;
|
Cascader: CascaderProps;
|
||||||
Checkbox: CheckboxProps;
|
Checkbox: CheckboxProps;
|
||||||
CheckboxGroup: CheckboxGroupProps;
|
CheckboxGroup: CheckboxGroupProps;
|
||||||
|
CollapsibleParams: CollapsibleParamsProps;
|
||||||
DatePicker: DatePickerProps;
|
DatePicker: DatePickerProps;
|
||||||
DefaultButton: ButtonProps;
|
DefaultButton: ButtonProps;
|
||||||
Divider: DividerProps;
|
Divider: DividerProps;
|
||||||
|
|
@ -581,6 +672,7 @@ export interface ComponentPropsMap {
|
||||||
RadioGroup: RadioGroupProps;
|
RadioGroup: RadioGroupProps;
|
||||||
RangePicker: RangePickerProps;
|
RangePicker: RangePickerProps;
|
||||||
Rate: RateProps;
|
Rate: RateProps;
|
||||||
|
RichEditor: TipTapProps;
|
||||||
Select: SelectProps;
|
Select: SelectProps;
|
||||||
Space: SpaceProps;
|
Space: SpaceProps;
|
||||||
Switch: SwitchProps;
|
Switch: SwitchProps;
|
||||||
|
|
@ -601,13 +693,13 @@ async function initComponentAdapter() {
|
||||||
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||||
loadingSlot: 'suffixIcon',
|
loadingSlot: 'suffixIcon',
|
||||||
modelPropName: 'value',
|
modelPropName: 'value',
|
||||||
visibleEvent: 'onVisibleChange',
|
visibleEvent: 'onOpenChange',
|
||||||
}),
|
}),
|
||||||
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||||
component: Select,
|
component: Select,
|
||||||
loadingSlot: 'suffixIcon',
|
loadingSlot: 'suffixIcon',
|
||||||
modelPropName: 'value',
|
modelPropName: 'value',
|
||||||
visibleEvent: 'onVisibleChange',
|
visibleEvent: 'onOpenChange',
|
||||||
}),
|
}),
|
||||||
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||||
component: TreeSelect,
|
component: TreeSelect,
|
||||||
|
|
@ -615,7 +707,7 @@ async function initComponentAdapter() {
|
||||||
loadingSlot: 'suffixIcon',
|
loadingSlot: 'suffixIcon',
|
||||||
modelPropName: 'value',
|
modelPropName: 'value',
|
||||||
optionsPropName: 'treeData',
|
optionsPropName: 'treeData',
|
||||||
visibleEvent: 'onVisibleChange',
|
visibleEvent: 'onOpenChange',
|
||||||
}),
|
}),
|
||||||
AutoComplete,
|
AutoComplete,
|
||||||
Cascader,
|
Cascader,
|
||||||
|
|
@ -646,6 +738,27 @@ async function initComponentAdapter() {
|
||||||
RadioGroup,
|
RadioGroup,
|
||||||
RangePicker,
|
RangePicker,
|
||||||
Rate,
|
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'),
|
Select: withDefaultPlaceholder(Select, 'select'),
|
||||||
Space,
|
Space,
|
||||||
Switch,
|
Switch,
|
||||||
|
|
@ -653,6 +766,7 @@ async function initComponentAdapter() {
|
||||||
TimePicker,
|
TimePicker,
|
||||||
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
|
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
|
||||||
Upload: withPreviewUpload(),
|
Upload: withPreviewUpload(),
|
||||||
|
CollapsibleParams: VbenCollapsibleParams,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 将组件注册到全局共享状态中
|
// 将组件注册到全局共享状态中
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export * from './auth';
|
export * from './auth';
|
||||||
export * from './menu';
|
export * from './menu';
|
||||||
|
export * from './upload';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue