|
|
@@ -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 = <T extends Component>(
|
|
|
};
|
|
|
|
|
|
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<boolean>,
|
|
|
fileList: Ref<UploadProps['fileList']>,
|
|
|
) => {
|
|
|
- // 检查是否为图片文件的辅助函数
|
|
|
- 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<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: $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<File>,
|
|
|
+ ) => {
|
|
|
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,
|