From 1d77b018bb038a34c3bf92b5e3ffbf5be969dac0 Mon Sep 17 00:00:00 2001 From: "yuan.ji" <961999367@qq.com> Date: Fri, 21 Nov 2025 17:33:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(function):=20add=20antd=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E6=94=AF=E6=8C=81=E8=B0=83=E7=94=A8Image?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E6=9F=A5=E7=9C=8B=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antd/src/adapter/component/index.ts | 236 ++++++++++++++++++- playground/src/adapter/component/index.ts | 236 ++++++++++++++++++- 2 files changed, 466 insertions(+), 6 deletions(-) diff --git a/apps/web-antd/src/adapter/component/index.ts b/apps/web-antd/src/adapter/component/index.ts index 786a93da..8455ef3a 100644 --- a/apps/web-antd/src/adapter/component/index.ts +++ b/apps/web-antd/src/adapter/component/index.ts @@ -3,15 +3,31 @@ * 可用于 vben-form、vben-modal、vben-drawer 等组件使用, */ -import type { Component } from 'vue'; +import type { + UploadChangeParam, + UploadFile, + UploadProps, +} from 'ant-design-vue'; + +import type { Component, Ref } from 'vue'; import type { BaseFormComponentType } from '@vben/common-ui'; import type { Recordable } from '@vben/types'; -import { defineAsyncComponent, defineComponent, h, ref } from 'vue'; +import { + defineAsyncComponent, + defineComponent, + h, + ref, + render, + unref, + watch, +} from 'vue'; import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui'; +import { IconifyIcon } from '@vben/icons'; import { $t } from '@vben/locales'; +import { isEmpty } from '@vben/utils'; import { notification } from 'ant-design-vue'; @@ -60,6 +76,10 @@ const TreeSelect = defineAsyncComponent( () => import('ant-design-vue/es/tree-select'), ); const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload')); +const Image = defineAsyncComponent(() => import('ant-design-vue/es/image')); +const PreviewGroup = defineAsyncComponent(() => + import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup), +); const withDefaultPlaceholder = ( component: T, @@ -95,6 +115,216 @@ const withDefaultPlaceholder = ( }); }; +const withPreviewUpload = () => { + return defineComponent({ + name: Upload.name, + emits: ['change', 'update:modelValue'], + setup: ( + props: any, + { attrs, slots, emit }: { attrs: any; emit: any; slots: any }, + ) => { + const previewVisible = ref(false); + + const placeholder = attrs?.placeholder || $t(`ui.placeholder.upload`); + + const listType = attrs?.listType || attrs?.['list-type'] || 'text'; + + const fileList = ref( + attrs?.fileList || attrs?.['file-list'] || [], + ); + + const handleChange = async (event: UploadChangeParam) => { + fileList.value = event.fileList; + emit('change', event); + emit( + 'update:modelValue', + 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; + } + + // 否则渲染默认上传按钮 + return isEmpty(slots) + ? createDefaultSlotsWithUpload(listType, placeholder) + : slots; + }; + + // 可以监听到表单API设置的值 + watch( + () => attrs.modelValue, + (res) => { + fileList.value = res; + }, + ); + + return () => + h( + Upload, + { + ...props, + ...attrs, + fileList: fileList.value, + onChange: handleChange, + onPreview: handlePreview, + }, + renderUploadButton(), + ); + }, + }); +}; + +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', + }), + }, + () => placeholder, + ), + }; + } + } +}; + +const previewImage = async ( + file: UploadFile, + visible: Ref, + fileList: Ref, +) => { + // 检查是否为图片文件的辅助函数 + 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) { + window.open(file.url, '_blank'); + } else if (file.preview) { + window.open(file.preview, '_blank'); + } else { + console.warn('无法打开文件,没有可用的URL或预览地址'); + } + 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'); + document.body.append(container); + + // 用于追踪组件是否已卸载 + let isUnmounted = false; + + const PreviewWrapper = { + setup() { + return () => { + if (isUnmounted) return null; + return h( + PreviewGroupComponent, + { + 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); + } + }, + }, + }, + () => + // 渲染所有图片文件 + imageFiles.map((imgFile) => + h(ImageComponent, { + key: imgFile.uid, + src: imgFile.url || imgFile.preview, + }), + ), + ); + }; + }, + }; + + render(h(PreviewWrapper), container); +}; + // 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明 export type ComponentType = | 'ApiSelect' @@ -189,7 +419,7 @@ async function initComponentAdapter() { Textarea: withDefaultPlaceholder(Textarea, 'input'), TimePicker, TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'), - Upload, + Upload: withPreviewUpload(), }; // 将组件注册到全局共享状态中 diff --git a/playground/src/adapter/component/index.ts b/playground/src/adapter/component/index.ts index 094d4fc4..2e50f6da 100644 --- a/playground/src/adapter/component/index.ts +++ b/playground/src/adapter/component/index.ts @@ -3,15 +3,31 @@ * 可用于 vben-form、vben-modal、vben-drawer 等组件使用, */ -import type { Component } from 'vue'; +import type { + UploadChangeParam, + UploadFile, + UploadProps, +} from 'ant-design-vue'; + +import type { Component, Ref } from 'vue'; import type { BaseFormComponentType } from '@vben/common-ui'; import type { Recordable } from '@vben/types'; -import { defineAsyncComponent, defineComponent, h, ref } from 'vue'; +import { + defineAsyncComponent, + defineComponent, + h, + ref, + render, + unref, + watch, +} from 'vue'; import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui'; +import { IconifyIcon } from '@vben/icons'; import { $t } from '@vben/locales'; +import { isEmpty } from '@vben/utils'; import { notification } from 'ant-design-vue'; @@ -60,6 +76,10 @@ const TreeSelect = defineAsyncComponent( () => import('ant-design-vue/es/tree-select'), ); const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload')); +const Image = defineAsyncComponent(() => import('ant-design-vue/es/image')); +const PreviewGroup = defineAsyncComponent(() => + import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup), +); const withDefaultPlaceholder = ( component: T, @@ -104,6 +124,216 @@ const withDefaultPlaceholder = ( }); }; +const withPreviewUpload = () => { + return defineComponent({ + name: Upload.name, + emits: ['change', 'update:modelValue'], + setup: ( + props: any, + { attrs, slots, emit }: { attrs: any; emit: any; slots: any }, + ) => { + const previewVisible = ref(false); + + const placeholder = attrs?.placeholder || $t(`ui.placeholder.upload`); + + const listType = attrs?.listType || attrs?.['list-type'] || 'text'; + + const fileList = ref( + attrs?.fileList || attrs?.['file-list'] || [], + ); + + const handleChange = async (event: UploadChangeParam) => { + fileList.value = event.fileList; + emit('change', event); + emit( + 'update:modelValue', + 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; + } + + // 否则渲染默认上传按钮 + return isEmpty(slots) + ? createDefaultSlotsWithUpload(listType, placeholder) + : slots; + }; + + // 可以监听到表单API设置的值 + watch( + () => attrs.modelValue, + (res) => { + fileList.value = res; + }, + ); + + return () => + h( + Upload, + { + ...props, + ...attrs, + fileList: fileList.value, + onChange: handleChange, + onPreview: handlePreview, + }, + renderUploadButton(), + ); + }, + }); +}; + +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', + }), + }, + () => placeholder, + ), + }; + } + } +}; + +const previewImage = async ( + file: UploadFile, + visible: Ref, + fileList: Ref, +) => { + // 检查是否为图片文件的辅助函数 + 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) { + window.open(file.url, '_blank'); + } else if (file.preview) { + window.open(file.preview, '_blank'); + } else { + console.warn('无法打开文件,没有可用的URL或预览地址'); + } + 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'); + document.body.append(container); + + // 用于追踪组件是否已卸载 + let isUnmounted = false; + + const PreviewWrapper = { + setup() { + return () => { + if (isUnmounted) return null; + return h( + PreviewGroupComponent, + { + 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); + } + }, + }, + }, + () => + // 渲染所有图片文件 + imageFiles.map((imgFile) => + h(ImageComponent, { + key: imgFile.uid, + src: imgFile.url || imgFile.preview, + }), + ), + ); + }; + }, + }; + + render(h(PreviewWrapper), container); +}; + // 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明 export type ComponentType = | 'ApiSelect' @@ -185,7 +415,7 @@ async function initComponentAdapter() { Textarea: withDefaultPlaceholder(Textarea, 'input'), TimePicker, TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'), - Upload, + Upload: withPreviewUpload(), }; // 将组件注册到全局共享状态中