diff --git a/apps/web-antd/src/adapter/component/index.ts b/apps/web-antd/src/adapter/component/index.ts index 34c821cb5..0f002700d 100644 --- a/apps/web-antd/src/adapter/component/index.ts +++ b/apps/web-antd/src/adapter/component/index.ts @@ -26,12 +26,17 @@ import { watch, } from 'vue'; -import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui'; +import { + ApiComponent, + globalShareState, + IconPicker, + VCropper, +} from '@vben/common-ui'; import { IconifyIcon } from '@vben/icons'; import { $t } from '@vben/locales'; import { isEmpty } from '@vben/utils'; -import { message, notification } from 'ant-design-vue'; +import { message, Modal, notification } from 'ant-design-vue'; const AutoComplete = defineAsyncComponent( () => import('ant-design-vue/es/auto-complete'), @@ -121,6 +126,33 @@ const withDefaultPlaceholder = ( }; 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; + } + return file.type.startsWith('image/'); + }; // 创建默认的上传按钮插槽 const createDefaultSlotsWithUpload = ( listType: string, @@ -155,27 +187,6 @@ const withPreviewUpload = () => { 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) { @@ -261,6 +272,107 @@ const withPreviewUpload = () => { 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(true); + const cropperRef = ref | 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: $t('ui.crop.title'), + 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); + }); + }; + + const base64ToBlob = (base64: Base64URLString) => { + try { + const [typeStr, encodeStr] = base64.split(','); + if (!typeStr || !encodeStr) return; + const mime = typeStr.match(/:(.*?);/)?.[1]; + const raw = window.atob(encodeStr); + const rawLength = raw.length; + const uInt8Array = new Uint8Array(rawLength); + for (let i = 0; i < rawLength; ++i) { + uInt8Array[i] = raw.codePointAt(i) as number; + } + return new Blob([uInt8Array], { type: mime }); + } catch { + return undefined; + } + }; return defineComponent({ name: Upload.name, emits: ['update:modelValue'], @@ -278,12 +390,37 @@ const withPreviewUpload = () => { attrs?.fileList || attrs?.['file-list'] || [], ); - const handleBeforeUpload = (file: UploadFile) => { + const handleBeforeUpload = async ( + file: UploadFile, + originFileList: Array, + ) => { if (attrs.maxSize && (file.size || 0) / 1024 / 1024 > attrs.maxSize) { message.error($t('ui.formRules.sizeLimit', [attrs.maxSize])); file.status = 'removed'; return false; } + // 多选或者非图片不唤起裁剪框 + if ( + attrs.crop && + !attrs.multiple && + originFileList[0] && + isImageFile(file) + ) { + file.status = 'removed'; + // antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取 + const base64 = await cropImage(originFileList[0], attrs.aspectRatio); + return new Promise((resolve, reject) => { + if (!base64) { + return reject(new Error($t('ui.crop.cancel'))); + } + const blob = base64ToBlob(base64 as string); + if (!blob) { + return reject(new Error($t('ui.crop.errorTip'))); + } + resolve(blob); + }); + } + return attrs.beforeUpload?.(file) ?? true; }; @@ -377,6 +514,7 @@ async function initComponentAdapter() { // 如果你的组件体积比较大,可以使用异步加载 // Button: () => // import('xxx').then((res) => res.Button), + ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', { component: Cascader, fieldNames: { label: 'label', value: 'value', children: 'children' }, @@ -384,34 +522,20 @@ async function initComponentAdapter() { modelPropName: 'value', visibleEvent: 'onVisibleChange', }), - ApiSelect: withDefaultPlaceholder( - { - ...ApiComponent, - name: 'ApiSelect', - }, - 'select', - { - component: Select, - loadingSlot: 'suffixIcon', - visibleEvent: 'onDropdownVisibleChange', - modelPropName: 'value', - }, - ), - ApiTreeSelect: withDefaultPlaceholder( - { - ...ApiComponent, - name: 'ApiTreeSelect', - }, - 'select', - { - component: TreeSelect, - fieldNames: { label: 'label', value: 'value', children: 'children' }, - loadingSlot: 'suffixIcon', - modelPropName: 'value', - optionsPropName: 'treeData', - visibleEvent: 'onVisibleChange', - }, - ), + ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', { + component: Select, + loadingSlot: 'suffixIcon', + modelPropName: 'value', + visibleEvent: 'onVisibleChange', + }), + ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', { + component: TreeSelect, + fieldNames: { label: 'label', value: 'value', children: 'children' }, + loadingSlot: 'suffixIcon', + modelPropName: 'value', + optionsPropName: 'treeData', + visibleEvent: 'onVisibleChange', + }), AutoComplete, Cascader, Checkbox, diff --git a/packages/locales/src/langs/en-US/ui.json b/packages/locales/src/langs/en-US/ui.json index f5166cdfa..fbba093a6 100644 --- a/packages/locales/src/langs/en-US/ui.json +++ b/packages/locales/src/langs/en-US/ui.json @@ -54,6 +54,12 @@ "copy": "Copy", "copied": "Copied" }, + "crop": { + "title": "Image Cropping", + "confirm": "Crop", + "cancel": "Cancel cropping", + "errorTip": "Cropping error" + }, "fallback": { "pageNotFound": "Oops! Page Not Found", "pageNotFoundDesc": "Sorry, we couldn't find the page you were looking for.", diff --git a/packages/locales/src/langs/zh-CN/ui.json b/packages/locales/src/langs/zh-CN/ui.json index 460931898..173fa516f 100644 --- a/packages/locales/src/langs/zh-CN/ui.json +++ b/packages/locales/src/langs/zh-CN/ui.json @@ -54,6 +54,12 @@ "copy": "复制", "copied": "已复制" }, + "crop": { + "title": "图片裁剪", + "confirm": "裁剪", + "cancel": "取消裁剪", + "errorTip": "裁剪错误" + }, "fallback": { "pageNotFound": "哎呀!未找到页面", "pageNotFoundDesc": "抱歉,我们无法找到您要找的页面。", diff --git a/playground/src/adapter/component/index.ts b/playground/src/adapter/component/index.ts index 48ce2fcc8..0f002700d 100644 --- a/playground/src/adapter/component/index.ts +++ b/playground/src/adapter/component/index.ts @@ -26,12 +26,17 @@ import { watch, } from 'vue'; -import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui'; +import { + ApiComponent, + globalShareState, + IconPicker, + VCropper, +} from '@vben/common-ui'; import { IconifyIcon } from '@vben/icons'; import { $t } from '@vben/locales'; import { isEmpty } from '@vben/utils'; -import { message, notification } from 'ant-design-vue'; +import { message, Modal, notification } from 'ant-design-vue'; const AutoComplete = defineAsyncComponent( () => import('ant-design-vue/es/auto-complete'), @@ -101,7 +106,6 @@ const withDefaultPlaceholder = ( $t(`ui.placeholder.${type}`); // 透传组件暴露的方法 const innerRef = ref(); - // const publicApi: Recordable = {}; expose( new Proxy( {}, @@ -111,14 +115,6 @@ const withDefaultPlaceholder = ( }, ), ); - // const instance = getCurrentInstance(); - // instance?.proxy?.$nextTick(() => { - // for (const key in innerRef.value) { - // if (typeof innerRef.value[key] === 'function') { - // publicApi[key] = innerRef.value[key]; - // } - // } - // }); return () => h( component, @@ -130,6 +126,33 @@ const withDefaultPlaceholder = ( }; 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; + } + return file.type.startsWith('image/'); + }; // 创建默认的上传按钮插槽 const createDefaultSlotsWithUpload = ( listType: string, @@ -164,27 +187,6 @@ const withPreviewUpload = () => { 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) { @@ -270,6 +272,107 @@ const withPreviewUpload = () => { 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(true); + const cropperRef = ref | 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: $t('ui.crop.title'), + 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); + }); + }; + + const base64ToBlob = (base64: Base64URLString) => { + try { + const [typeStr, encodeStr] = base64.split(','); + if (!typeStr || !encodeStr) return; + const mime = typeStr.match(/:(.*?);/)?.[1]; + const raw = window.atob(encodeStr); + const rawLength = raw.length; + const uInt8Array = new Uint8Array(rawLength); + for (let i = 0; i < rawLength; ++i) { + uInt8Array[i] = raw.codePointAt(i) as number; + } + return new Blob([uInt8Array], { type: mime }); + } catch { + return undefined; + } + }; return defineComponent({ name: Upload.name, emits: ['update:modelValue'], @@ -287,12 +390,37 @@ const withPreviewUpload = () => { attrs?.fileList || attrs?.['file-list'] || [], ); - const handleBeforeUpload = (file: UploadFile) => { + const handleBeforeUpload = async ( + file: UploadFile, + originFileList: Array, + ) => { if (attrs.maxSize && (file.size || 0) / 1024 / 1024 > attrs.maxSize) { message.error($t('ui.formRules.sizeLimit', [attrs.maxSize])); file.status = 'removed'; return false; } + // 多选或者非图片不唤起裁剪框 + if ( + attrs.crop && + !attrs.multiple && + originFileList[0] && + isImageFile(file) + ) { + file.status = 'removed'; + // antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取 + const base64 = await cropImage(originFileList[0], attrs.aspectRatio); + return new Promise((resolve, reject) => { + if (!base64) { + return reject(new Error($t('ui.crop.cancel'))); + } + const blob = base64ToBlob(base64 as string); + if (!blob) { + return reject(new Error($t('ui.crop.errorTip'))); + } + resolve(blob); + }); + } + return attrs.beforeUpload?.(file) ?? true; }; diff --git a/playground/src/locales/langs/en-US/examples.json b/playground/src/locales/langs/en-US/examples.json index 958b0c83f..05035487f 100644 --- a/playground/src/locales/langs/en-US/examples.json +++ b/playground/src/locales/langs/en-US/examples.json @@ -23,6 +23,7 @@ "upload-error": "Partial file upload failed", "upload-urls": "Urls after file upload", "file": "file", + "crop-image": "Crop image", "upload-image": "Click to upload image" }, "vxeTable": { diff --git a/playground/src/locales/langs/zh-CN/examples.json b/playground/src/locales/langs/zh-CN/examples.json index 3de48e214..3b0d934cf 100644 --- a/playground/src/locales/langs/zh-CN/examples.json +++ b/playground/src/locales/langs/zh-CN/examples.json @@ -26,6 +26,7 @@ "upload-error": "部分文件上传失败", "upload-urls": "文件上传后的网址", "file": "文件", + "crop-image": "裁剪图片", "upload-image": "点击上传图片" }, "vxeTable": { diff --git a/playground/src/views/examples/form/basic.vue b/playground/src/views/examples/form/basic.vue index 7cb085227..40f5930d5 100644 --- a/playground/src/views/examples/form/basic.vue +++ b/playground/src/views/examples/form/basic.vue @@ -358,6 +358,28 @@ const [BaseForm, baseFormApi] = useVbenForm({ }, rules: 'selectRequired', }, + { + component: 'Upload', + componentProps: { + accept: '.png,.jpg,.jpeg', + customRequest: upload_file, + maxCount: 1, + maxSize: 2, + listType: 'picture-card', + // 是否启用图片裁剪(多选或者非图片不唤起裁剪框) + crop: true, + // 裁剪比例 + aspectRatio: '1:1', + }, + fieldName: 'cropImage', + label: $t('examples.form.crop-image'), + renderComponentContent: () => { + return { + default: () => $t('examples.form.upload-image'), + }; + }, + rules: 'selectRequired', + }, ], // 大屏一行显示3个,中屏一行显示2个,小屏一行显示1个 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', @@ -365,13 +387,20 @@ const [BaseForm, baseFormApi] = useVbenForm({ function onSubmit(values: Record) { const files = toRaw(values.files) as UploadFile[]; + const cropImage = (toRaw(values.cropImage) ?? []) as UploadFile[]; const doneFiles = files.filter((file) => file.status === 'done'); const failedFiles = files.filter((file) => file.status !== 'done'); + const doneCrop = cropImage.filter((file) => file.status === 'done'); + const failedCrop = cropImage.filter((file) => file.status !== 'done'); const msg = [ ...doneFiles.map((file) => file.response?.url || file.url), ...failedFiles.map((file) => file.name), ].join(', '); + const msgCrop = [ + ...doneCrop.map((file) => file.response?.url || file.url), + ...failedCrop.map((file) => file.name), + ].join(', '); if (failedFiles.length === 0) { message.success({ @@ -383,8 +412,19 @@ function onSubmit(values: Record) { }); return; } + if (doneCrop.length > 0 && failedCrop.length === 0) { + message.success({ + content: `${$t('examples.form.upload-urls')}: ${msgCrop}`, + }); + } else if (failedCrop.length > 0) { + message.error({ + content: `${$t('examples.form.upload-error')}: ${msgCrop}`, + }); + return; + } // 如果需要可提交前替换为需要的urls values.files = doneFiles.map((file) => file.response?.url || file.url); + values.cropImage = doneCrop.map((file) => file.response?.url || file.url); message.success({ content: `form values: ${JSON.stringify(values)}`, });