feat: 同步 antdv-next 的组件

pull/360/head^2^2
xingyu4j 2026-06-06 16:20:36 +08:00
parent 8094d1ffb9
commit ce495d67a0
3 changed files with 408 additions and 268 deletions

View File

@ -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,10 +182,7 @@ const withDefaultPlaceholder = (
}); });
}; };
const withPreviewUpload = () => { const IMAGE_EXTENSIONS = new Set([
// 检查是否为图片文件的辅助函数
const isImageFile = (file: UploadFile): boolean => {
const imageExtensions = new Set([
'bmp', 'bmp',
'gif', 'gif',
'jpeg', 'jpeg',
@ -181,35 +190,36 @@ const withPreviewUpload = () => {
'png', 'png',
'svg', 'svg',
'webp', 'webp',
]); ]);
/**
*
*/
function isImageFile(file: UploadFile): boolean {
if (file.url) { if (file.url) {
try { try {
const pathname = new URL(file.url, 'http://localhost').pathname; const pathname = new URL(file.url, 'http://localhost').pathname;
const ext = pathname.split('.').pop()?.toLowerCase(); const ext = pathname.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false; return ext ? IMAGE_EXTENSIONS.has(ext) : false;
} catch { } catch {
const ext = file.url?.split('.').pop()?.toLowerCase(); const ext = file.url?.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false; return ext ? IMAGE_EXTENSIONS.has(ext) : false;
} }
} }
if (!file.type) { if (!file.type) {
const ext = file.name?.split('.').pop()?.toLowerCase(); const ext = file.name?.split('.').pop()?.toLowerCase();
return ext ? imageExtensions.has(ext) : false; return ext ? IMAGE_EXTENSIONS.has(ext) : false;
} }
return file.type.startsWith('image/'); return file.type.startsWith('image/');
}; }
// 创建默认的上传按钮插槽
const createDefaultSlotsWithUpload = ( /**
listType: string, *
placeholder: string, */
) => { function createDefaultUploadSlots(listType: string, placeholder: string) {
switch (listType) { if (listType === 'picture-card') {
case 'picture-card': { return { default: () => placeholder };
return {
default: () => placeholder,
};
} }
default: {
return { return {
default: () => default: () =>
h( h(
@ -223,19 +233,33 @@ const withPreviewUpload = () => {
() => placeholder, () => placeholder,
), ),
}; };
} }
}
}; /**
// 构建预览图片组 * Base64
const previewImage = async ( */
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, file: UploadFile,
visible: Ref<boolean>, open: Ref<boolean>,
fileList: Ref<UploadProps['fileList']>, fileList: Ref<UploadProps['fileList']>,
) => { ) {
// 如果当前文件不是图片,直接打开 // 非图片文件直接打开链接
if (!isImageFile(file)) { if (!isImageFile(file)) {
if (file.url) { const url = file.url || file.preview;
window.open(file.url, '_blank'); if (url) {
window.open(url, '_blank');
} else if (file.preview) { } else if (file.preview) {
window.open(file.preview, '_blank'); window.open(file.preview, '_blank');
} else { } else {
@ -244,37 +268,26 @@ const withPreviewUpload = () => {
return; return;
} }
// 对于图片文件,继续使用预览组
const [ImageComponent, PreviewGroupComponent] = await Promise.all([ const [ImageComponent, PreviewGroupComponent] = await Promise.all([
Image, Image,
PreviewGroup, PreviewGroup,
]); ]);
const getBase64 = (file: File) => { // 过滤图片文件并生成预览
return new Promise((resolve, reject) => { const imageFiles = (unref(fileList) || []).filter((f) => isImageFile(f));
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) { for (const imgFile of imageFiles) {
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) { if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) {
imgFile.preview = (await getBase64(imgFile.originFileObj)) as string; imgFile.preview = await getBase64(imgFile.originFileObj);
} }
} }
const container: HTMLElement | null = document.createElement('div');
document.body.append(container);
// 用于追踪组件是否已卸载 const container = document.createElement('div');
document.body.append(container);
let isUnmounted = false; let isUnmounted = false;
const currentIndex = imageFiles.findIndex((f) => f.uid === file.uid);
const PreviewWrapper = { const PreviewWrapper = {
setup() { setup() {
return () => { return () => {
@ -284,13 +297,11 @@ const withPreviewUpload = () => {
{ {
class: 'hidden', class: 'hidden',
preview: { preview: {
open: visible.value, open: open.value,
// 设置初始显示的图片索引 current: currentIndex,
current: imageFiles.findIndex((f) => f.uid === file.uid),
onOpenChange: (value: boolean) => { onOpenChange: (value: boolean) => {
visible.value = value; open.value = value;
if (!value) { if (!value) {
// 延迟清理,确保动画完成
setTimeout(() => { setTimeout(() => {
if (!isUnmounted && container) { if (!isUnmounted && container) {
isUnmounted = true; isUnmounted = true;
@ -303,7 +314,6 @@ const withPreviewUpload = () => {
}, },
}, },
() => () =>
// 渲染所有图片文件
imageFiles.map((imgFile) => imageFiles.map((imgFile) =>
h(ImageComponent, { h(ImageComponent, {
key: imgFile.uid, key: imgFile.uid,
@ -316,24 +326,24 @@ const withPreviewUpload = () => {
}; };
render(h(PreviewWrapper), container); render(h(PreviewWrapper), container);
}; }
// 图片裁剪操作 /**
const cropImage = (file: File, aspectRatio: string | undefined) => { *
return new Promise((resolve, reject) => { */
const container: HTMLElement | null = document.createElement('div'); 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; let objectUrl: null | string = null;
const open = ref<boolean>(true); const open = ref<boolean>(true);
const cropperRef = ref<InstanceType<typeof VCropper> | null>(null); const cropperRef = ref<InstanceType<typeof VCropper> | null>(null);
const closeModal = () => { function closeModal() {
open.value = false; open.value = false;
// 延迟清理,确保动画完成
setTimeout(() => { setTimeout(() => {
if (!isUnmounted && container) { if (!isUnmounted && container) {
if (objectUrl) { if (objectUrl) {
@ -344,7 +354,7 @@ const withPreviewUpload = () => {
container.remove(); container.remove();
} }
}, 300); }, 300);
}; }
const CropperWrapper = { const CropperWrapper = {
setup() { setup() {
@ -384,7 +394,11 @@ const withPreviewUpload = () => {
} }
try { try {
const dataUrl = await cropper.getCropImage(); const dataUrl = await cropper.getCropImage();
if (dataUrl) {
resolve(dataUrl); resolve(dataUrl);
} else {
reject(new Error($t('ui.crop.errorTip')));
}
} catch { } catch {
reject(new Error($t('ui.crop.errorTip'))); reject(new Error($t('ui.crop.errorTip')));
} finally { } finally {
@ -409,21 +423,22 @@ const withPreviewUpload = () => {
render(h(CropperWrapper), container); 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) {
return reject(new Error($t('ui.crop.errorTip'))); throw new Error($t('ui.crop.errorTip'));
} }
resolve(blob); return 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) => {
previewVisible.value = true;
await previewImage(file, previewVisible, fileList);
};
const renderUploadButton = (): any => {
const isDisabled = attrs.disabled;
// 如果禁用,不渲染上传按钮
if (isDisabled) {
return null;
} }
// 否则渲染默认上传按钮 function handlePreview(file: UploadFile) {
return isEmpty(slots) previewVisible.value = true;
? createDefaultSlotsWithUpload(listType, placeholder) return previewImage(file, previewVisible, fileList);
: slots; }
};
// 可以监听到表单API设置的值 function renderUploadButton() {
if (attrs.disabled) return null;
return isEmpty(slots)
? createDefaultUploadSlots(listType, placeholder)
: slots;
}
// 拖拽排序
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) => {
@ -509,7 +587,16 @@ const withPreviewUpload = () => {
}, },
); );
onMounted(initSortable);
onUnmounted(() => {
sortableInstance.value?.destroy();
removeDragStyle();
});
return () => return () =>
h(
'div',
{ 'data-upload-id': uploadId, class: 'w-full' },
h( h(
Upload, Upload,
{ {
@ -520,11 +607,12 @@ const withPreviewUpload = () => {
onChange: handleChange, onChange: handleChange,
onPreview: handlePreview, onPreview: handlePreview,
}, },
renderUploadButton(), 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,
}; };
// 将组件注册到全局共享状态中 // 将组件注册到全局共享状态中

View File

@ -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';

View File

@ -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)));
}
}