diff --git a/README.md b/README.md index 26ef03fca..ba7654879 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ## 🐶 新手必读 -- nodejs > 20.12.0 && pnpm > 10.14.0 (强制使用pnpm) +- nodejs > 20.12.0 && pnpm > 10.22.0 (强制使用pnpm) - 演示地址【Vue3 + element-plus】: - 演示地址【Vue3 + vben5(ant-design-vue)】: - 演示地址【Vue2 + element-ui】: @@ -41,22 +41,22 @@ | 框架 | 说明 | 版本 | | --- | --- | --- | -| [Vue](https://staging-cn.vuejs.org/) | vue框架 | 3.5.17 | -| [Vite](https://cn.vitejs.dev//) | 开发与构建工具 | 7.1.2 | +| [Vue](https://staging-cn.vuejs.org/) | vue框架 | 3.5.24 | +| [Vite](https://cn.vitejs.dev//) | 开发与构建工具 | 7.2.2 | | [Ant Design Vue](https://www.antdv.com/) | Ant Design Vue | 4.2.6 | | [Element Plus](https://element-plus.org/zh-CN/) | Element Plus | 2.10.2 | | [Naive UI](https://www.naiveui.com/) | Naive UI | 2.42.0 | | [TDesign](https://tdesign.tencent.com/) | TDesign | 1.17.1 | -| [TypeScript](https://www.typescriptlang.org/docs/) | JavaScript 超集 | 5.8.3 | +| [TypeScript](https://www.typescriptlang.org/docs/) | JavaScript 超集 | 5.9.3 | | [pinia](https://pinia.vuejs.org/) | Vue 存储库替代 vuex5 | 3.0.3 | | [vueuse](https://vueuse.org/) | 常用工具集 | 13.4.0 | | [vue-i18n](https://kazupon.github.io/vue-i18n/zh/introduction.html/) | 国际化 | 11.1.7 | | [vue-router](https://router.vuejs.org/) | Vue 路由 | 4.5.1 | -| [Tailwind CSS](https://tailwindcss.com/) | 原子 CSS | 3.4.17 | +| [Tailwind CSS](https://tailwindcss.com/) | 原子 CSS | 3.4.18 | | [Iconify](https://iconify.design/) | 图标组件 | 5.0.0 | -| [Iconify](https://icon-sets.iconify.design/) | 在线图标库 | 2.2.354 | +| [Iconify](https://icon-sets.iconify.design/) | 在线图标库 | 2.2.406 | | [TinyMCE](https://www.tiny.cloud/) | 富文本编辑器 | 6.1.0 | -| [Echarts](https://echarts.apache.org/) | 图表库 | 5.6.0 | +| [Echarts](https://echarts.apache.org/) | 图表库 | 6.0.0 | | [axios](https://axios-http.com/) | http客户端 | 1.10.0 | | [dayjs](https://day.js.org/) | 日期处理库 | 1.11.13 | | [vee-validate](https://vee-validate.logaretm.com/) | 表单验证 | 4.15.1 | diff --git a/apps/web-antd/src/adapter/component/index.ts b/apps/web-antd/src/adapter/component/index.ts index 68e7e88b3..a62b88065 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'; @@ -22,9 +38,6 @@ const AutoComplete = defineAsyncComponent( () => import('ant-design-vue/es/auto-complete'), ); const Button = defineAsyncComponent(() => import('ant-design-vue/es/button')); -const Cascader = defineAsyncComponent( - () => import('ant-design-vue/es/cascader'), -); const Checkbox = defineAsyncComponent( () => import('ant-design-vue/es/checkbox'), ); @@ -68,7 +81,14 @@ const TimeRangePicker = defineAsyncComponent(() => const TreeSelect = defineAsyncComponent( () => import('ant-design-vue/es/tree-select'), ); +const Cascader = defineAsyncComponent( + () => import('ant-design-vue/es/cascader'), +); 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,12 +124,223 @@ 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 = | 'ApiCascader' | 'ApiSelect' | 'ApiTreeSelect' | 'AutoComplete' + | 'Cascader' | 'Checkbox' | 'CheckboxGroup' | 'DatePicker' @@ -143,21 +374,13 @@ async function initComponentAdapter() { // 如果你的组件体积比较大,可以使用异步加载 // Button: () => // import('xxx').then((res) => res.Button), - ApiCascader: withDefaultPlaceholder( - { - ...ApiComponent, - name: 'ApiCascader', - }, - 'select', - { - component: Cascader, - fieldNames: { label: 'label', value: 'value', children: 'children' }, - loadingSlot: 'suffixIcon', - modelPropName: 'value', - optionsPropName: 'treeData', - visibleEvent: 'onVisibleChange', - }, - ), + ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', { + component: Cascader, + fieldNames: { label: 'label', value: 'value', children: 'children' }, + loadingSlot: 'suffixIcon', + modelPropName: 'value', + visibleEvent: 'onVisibleChange', + }), ApiSelect: withDefaultPlaceholder( { ...ApiComponent, @@ -187,6 +410,7 @@ async function initComponentAdapter() { }, ), AutoComplete, + Cascader, Checkbox, CheckboxGroup, DatePicker, @@ -221,6 +445,7 @@ async function initComponentAdapter() { TimeRangePicker, TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'), Upload, + PreviewUpload: withPreviewUpload(), FileUpload, ImageUpload, }; diff --git a/apps/web-antd/src/adapter/vxe-table.ts b/apps/web-antd/src/adapter/vxe-table.ts index d41f39113..15ab9212a 100644 --- a/apps/web-antd/src/adapter/vxe-table.ts +++ b/apps/web-antd/src/adapter/vxe-table.ts @@ -84,9 +84,10 @@ setupVbenVxeTable({ // 表格配置项可以用 cellRender: { name: 'CellImage' }, vxeUI.renderer.add('CellImage', { - renderTableDefault(_renderOpts, params) { + renderTableDefault(renderOpts, params) { + const { props } = renderOpts; const { column, row } = params; - return h(Image, { src: row[column.field] }); + return h(Image, { src: row[column.field], ...props }); }, }); diff --git a/apps/web-antd/src/api/bpm/model/index.ts b/apps/web-antd/src/api/bpm/model/index.ts index 545a73c03..e0c033a44 100644 --- a/apps/web-antd/src/api/bpm/model/index.ts +++ b/apps/web-antd/src/api/bpm/model/index.ts @@ -30,6 +30,7 @@ export namespace BpmModelApi { deploymentTime: number; suspensionState: number; formType?: number; + formCustomCreatePath?: string; formCustomViewPath?: string; formFields?: string[]; } diff --git a/apps/web-antd/src/components/upload/file-upload.vue b/apps/web-antd/src/components/upload/file-upload.vue index b2a800785..3838642ea 100644 --- a/apps/web-antd/src/components/upload/file-upload.vue +++ b/apps/web-antd/src/components/upload/file-upload.vue @@ -51,12 +51,12 @@ const { getStringAccept } = useUploadType({ maxSizeRef: maxSize, }); -// 计算当前绑定的值,优先使用 modelValue +/** 计算当前绑定的值,优先使用 modelValue */ const currentValue = computed(() => { return props.modelValue === undefined ? props.value : props.modelValue; }); -// 判断是否使用 modelValue +/** 判断是否使用 modelValue */ const isUsingModelValue = computed(() => { return props.modelValue !== undefined; }); @@ -82,19 +82,21 @@ watch( } else { value.push(v); } - fileList.value = value.map((item, i) => { - if (item && isString(item)) { - return { - uid: `${-i}`, - name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)), - status: UploadResultStatus.DONE, - url: item, - }; - } else if (item && isObject(item)) { - return item; - } - return null; - }) as UploadProps['fileList']; + fileList.value = value + .map((item, i) => { + if (item && isString(item)) { + return { + uid: `${-i}`, + name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)), + status: UploadResultStatus.DONE, + url: item, + }; + } else if (item && isObject(item)) { + return item; + } + return null; + }) + .filter(Boolean) as UploadProps['fileList']; } if (!isFirstRender.value) { emit('change', value); @@ -107,6 +109,7 @@ watch( }, ); +/** 处理文件删除 */ async function handleRemove(file: UploadFile) { if (fileList.value) { const index = fileList.value.findIndex((item) => item.uid === file.uid); @@ -120,17 +123,17 @@ async function handleRemove(file: UploadFile) { } } -// 处理文件预览 +/** 处理文件预览 */ function handlePreview(file: UploadFile) { emit('preview', file); } -// 处理文件数量超限 +/** 处理文件数量超限 */ function handleExceed() { message.error($t('ui.upload.maxNumber', [maxNumber.value])); } -// 处理上传错误 +/** 处理上传错误 */ function handleUploadError(error: any) { console.error('上传错误:', error); message.error($t('ui.upload.uploadError')); @@ -138,6 +141,11 @@ function handleUploadError(error: any) { uploadNumber.value = Math.max(0, uploadNumber.value - 1); } +/** + * 上传前校验 + * @param file 待上传的文件 + * @returns 是否允许上传 + */ async function beforeUpload(file: File) { const fileContent = await file.text(); emit('returnText', fileContent); @@ -171,7 +179,8 @@ async function beforeUpload(file: File) { return true; } -async function customRequest(info: UploadRequestOption) { +/** 自定义上传请求 */ +async function customRequest(info: UploadRequestOption) { let { api } = props; if (!api || !isFunction(api)) { api = useUpload(props.directory).httpRequest; @@ -196,7 +205,11 @@ async function customRequest(info: UploadRequestOption) { } } -// 处理上传成功 +/** + * 处理上传成功 + * @param res 上传响应结果 + * @param file 上传的文件 + */ function handleUploadSuccess(res: any, file: File) { // 删除临时文件 const index = fileList.value?.findIndex((item) => item.name === file.name); @@ -228,6 +241,10 @@ function handleUploadSuccess(res: any, file: File) { } } +/** + * 获取当前文件列表的值 + * @returns 文件 URL 列表或字符串 + */ function getValue() { const list = (fileList.value || []) .filter((item) => item?.status === UploadResultStatus.DONE) diff --git a/apps/web-antd/src/components/upload/image-upload.vue b/apps/web-antd/src/components/upload/image-upload.vue index fe962f538..b78d8f15d 100644 --- a/apps/web-antd/src/components/upload/image-upload.vue +++ b/apps/web-antd/src/components/upload/image-upload.vue @@ -55,12 +55,12 @@ const { getStringAccept } = useUploadType({ maxSizeRef: maxSize, }); -// 计算当前绑定的值,优先使用 modelValue +/** 计算当前绑定的值,优先使用 modelValue */ const currentValue = computed(() => { return props.modelValue === undefined ? props.value : props.modelValue; }); -// 判断是否使用 modelValue +/** 判断是否使用 modelValue */ const isUsingModelValue = computed(() => { return props.modelValue !== undefined; }); @@ -89,19 +89,21 @@ watch( } else { value.push(v); } - fileList.value = value.map((item, i) => { - if (item && isString(item)) { - return { - uid: `${-i}`, - name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)), - status: UploadResultStatus.DONE, - url: item, - }; - } else if (item && isObject(item)) { - return item; - } - return null; - }) as UploadProps['fileList']; + fileList.value = value + .map((item, i) => { + if (item && isString(item)) { + return { + uid: `${-i}`, + name: item.slice(Math.max(0, item.lastIndexOf('/') + 1)), + status: UploadResultStatus.DONE, + url: item, + }; + } else if (item && isObject(item)) { + return item; + } + return null; + }) + .filter(Boolean) as UploadProps['fileList']; } if (!isFirstRender.value) { emit('change', value); @@ -114,6 +116,7 @@ watch( }, ); +/** 将文件转换为 Base64 格式 */ function getBase64(file: File) { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -125,6 +128,7 @@ function getBase64(file: File) { }); } +/** 处理图片预览 */ async function handlePreview(file: UploadFile) { if (!file.url && !file.preview) { file.preview = await getBase64(file.originFileObj!); @@ -138,6 +142,7 @@ async function handlePreview(file: UploadFile) { ); } +/** 处理文件删除 */ async function handleRemove(file: UploadFile) { if (fileList.value) { const index = fileList.value.findIndex((item) => item.uid === file.uid); @@ -151,11 +156,17 @@ async function handleRemove(file: UploadFile) { } } +/** 关闭预览弹窗 */ function handleCancel() { previewOpen.value = false; previewTitle.value = ''; } +/** + * 上传前校验 + * @param file 待上传的文件 + * @returns 是否允许上传 + */ async function beforeUpload(file: File) { // 检查文件数量限制 if (fileList.value!.length >= props.maxNumber) { @@ -186,7 +197,8 @@ async function beforeUpload(file: File) { return true; } -async function customRequest(info: UploadRequestOption) { +/** 自定义上传请求 */ +async function customRequest(info: UploadRequestOption) { let { api } = props; if (!api || !isFunction(api)) { api = useUpload(props.directory).httpRequest; @@ -211,7 +223,11 @@ async function customRequest(info: UploadRequestOption) { } } -// 处理上传成功 +/** + * 处理上传成功 + * @param res 上传响应结果 + * @param file 上传的文件 + */ function handleUploadSuccess(res: any, file: File) { // 删除临时文件 const index = fileList.value?.findIndex((item) => item.name === file.name); @@ -243,14 +259,18 @@ function handleUploadSuccess(res: any, file: File) { } } -// 处理上传错误 +/** 处理上传错误 */ function handleUploadError(error: any) { console.error('上传错误:', error); - message.error('上传错误!!!'); + message.error($t('ui.upload.uploadError')); // 上传失败时减少计数器 uploadNumber.value = Math.max(0, uploadNumber.value - 1); } +/** + * 获取当前文件列表的值 + * @returns 文件 URL 列表或字符串 + */ function getValue() { const list = (fileList.value || []) .filter((item) => item?.status === UploadResultStatus.DONE) diff --git a/apps/web-antd/src/components/upload/input-upload.vue b/apps/web-antd/src/components/upload/input-upload.vue index 66495467a..90b2f4f7c 100644 --- a/apps/web-antd/src/components/upload/input-upload.vue +++ b/apps/web-antd/src/components/upload/input-upload.vue @@ -6,7 +6,7 @@ import type { FileUploadProps } from './typing'; import { computed } from 'vue'; import { useVModel } from '@vueuse/core'; -import { Col, Input, Row, Textarea } from 'ant-design-vue'; +import { Input, Textarea } from 'ant-design-vue'; import FileUpload from './file-upload.vue'; @@ -30,6 +30,7 @@ const modelValue = useVModel(props, 'modelValue', emits, { passive: true, }); +/** 处理文件内容返回 */ function handleReturnText(text: string) { modelValue.value = text; emits('change', modelValue.value); @@ -37,6 +38,7 @@ function handleReturnText(text: string) { emits('update:modelValue', modelValue.value); } +/** 计算输入框属性 */ const inputProps = computed(() => { return { ...props.inputProps, @@ -44,6 +46,7 @@ const inputProps = computed(() => { }; }); +/** 计算文本域属性 */ const textareaProps = computed(() => { return { ...props.textareaProps, @@ -51,6 +54,7 @@ const textareaProps = computed(() => { }; }); +/** 计算文件上传属性 */ const fileUploadProps = computed(() => { return { ...props.fileUploadProps, @@ -58,17 +62,17 @@ const fileUploadProps = computed(() => { });