mirror of
https://gitee.com/dapppp/ruoyi-plus-vben5.git
synced 2026-03-07 23:31:08 +08:00
Merge branch 'main' into fix
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
|
||||
*/
|
||||
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
|
||||
import type {
|
||||
UploadChangeParam,
|
||||
UploadFile,
|
||||
@@ -24,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'),
|
||||
@@ -99,7 +106,6 @@ const withDefaultPlaceholder = <T extends Component>(
|
||||
$t(`ui.placeholder.${type}`);
|
||||
// 透传组件暴露的方法
|
||||
const innerRef = ref();
|
||||
// const publicApi: Recordable<any> = {};
|
||||
expose(
|
||||
new Proxy(
|
||||
{},
|
||||
@@ -109,14 +115,6 @@ const withDefaultPlaceholder = <T extends Component>(
|
||||
},
|
||||
),
|
||||
);
|
||||
// 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,
|
||||
@@ -128,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,
|
||||
@@ -162,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) {
|
||||
@@ -268,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'],
|
||||
@@ -285,16 +390,50 @@ 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;
|
||||
};
|
||||
|
||||
const handleChange = async (event: UploadChangeParam) => {
|
||||
const handleChange = (event: UploadChangeParam) => {
|
||||
try {
|
||||
// 行内写法 handleChange: (event) => {}
|
||||
attrs.handleChange?.(event);
|
||||
// template写法 @handle-change="(event) => {}"
|
||||
attrs.onHandleChange?.(event);
|
||||
} catch (error) {
|
||||
// Avoid breaking internal v-model sync on user handler errors
|
||||
console.error(error);
|
||||
}
|
||||
fileList.value = event.fileList.filter(
|
||||
(file) => file.status !== 'removed',
|
||||
);
|
||||
|
||||
@@ -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": {
|
||||
@@ -75,5 +76,8 @@
|
||||
},
|
||||
"function": {
|
||||
"contentMenu": "Content Menu"
|
||||
},
|
||||
"cropper": {
|
||||
"title": "Cropper"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"upload-error": "部分文件上传失败",
|
||||
"upload-urls": "文件上传后的网址",
|
||||
"file": "文件",
|
||||
"crop-image": "裁剪图片",
|
||||
"upload-image": "点击上传图片"
|
||||
},
|
||||
"vxeTable": {
|
||||
@@ -75,5 +76,8 @@
|
||||
},
|
||||
"function": {
|
||||
"contentMenu": "上下文菜单"
|
||||
},
|
||||
"cropper": {
|
||||
"title": "图片裁剪"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,9 +108,9 @@ function setupAccessGuard(router: Router) {
|
||||
let redirectPath: string;
|
||||
if (from.query.redirect) {
|
||||
redirectPath = from.query.redirect as string;
|
||||
} else if (to.path === preferences.app.defaultHomePath) {
|
||||
} else if (to.fullPath === preferences.app.defaultHomePath) {
|
||||
redirectPath = preferences.app.defaultHomePath;
|
||||
} else if (userInfo.homePath && to.path === userInfo.homePath) {
|
||||
} else if (userInfo.homePath && to.fullPath === userInfo.homePath) {
|
||||
redirectPath = userInfo.homePath;
|
||||
} else {
|
||||
redirectPath = to.fullPath;
|
||||
|
||||
@@ -337,6 +337,15 @@ const routes: RouteRecordRaw[] = [
|
||||
title: $t('examples.function.contentMenu'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'CropperDemo',
|
||||
path: '/examples/cropper',
|
||||
component: () => import('#/views/examples/cropper/index.vue'),
|
||||
meta: {
|
||||
icon: 'mdi:crop',
|
||||
title: $t('examples.cropper.title'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
138
playground/src/views/examples/cropper/index.vue
Normal file
138
playground/src/views/examples/cropper/index.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadChangeParam } from 'ant-design-vue';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page, VCropper } from '@vben/common-ui';
|
||||
|
||||
import { Button, Card, Select, Upload } from 'ant-design-vue';
|
||||
|
||||
const options = [
|
||||
{ label: '1:1', value: '1:1' },
|
||||
{ label: '16:9', value: '16:9' },
|
||||
{ label: '不限制', value: '' },
|
||||
];
|
||||
|
||||
const cropperRef = ref<InstanceType<typeof VCropper>>();
|
||||
|
||||
const cropLoading = ref(false);
|
||||
const validAspectRatio = ref<string | undefined>('1:1');
|
||||
const imgUrl = ref('');
|
||||
const cropperImg = ref();
|
||||
|
||||
const selectImgFile = (event: UploadChangeParam) => {
|
||||
const file = event.fileList[0]?.originFileObj;
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
console.error('请上传图片文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', (e) => {
|
||||
imgUrl.value = e.target?.result as string;
|
||||
});
|
||||
reader.addEventListener('error', () => {
|
||||
console.error('Failed to read file');
|
||||
});
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const cropImage = async () => {
|
||||
if (!cropperRef.value) return;
|
||||
cropLoading.value = true;
|
||||
try {
|
||||
cropperImg.value = await cropperRef.value.getCropImage();
|
||||
} catch (error) {
|
||||
console.error('图片裁剪失败:', error);
|
||||
} finally {
|
||||
cropLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 下载图片
|
||||
*/
|
||||
const downloadImage = () => {
|
||||
if (!cropperImg.value) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = `cropped-image-${Date.now()}.png`;
|
||||
link.href = cropperImg.value;
|
||||
link.click();
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<Page
|
||||
title="VCropper 图片裁剪"
|
||||
description="VCropper是一个图片裁剪组件,提供基础的图片裁剪功能。"
|
||||
>
|
||||
<Card>
|
||||
<div class="image-cropper-container">
|
||||
<div class="cropper-ratio-display">
|
||||
<label class="ratio-label">当前裁剪比例:</label>
|
||||
<Select
|
||||
class="w-24"
|
||||
v-model:value="validAspectRatio"
|
||||
:options="options"
|
||||
/>
|
||||
<Upload
|
||||
:max-count="1"
|
||||
:show-upload-list="false"
|
||||
:before-upload="() => false"
|
||||
@change="selectImgFile"
|
||||
>
|
||||
<Button>上传图片</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
|
||||
<div v-if="imgUrl" class="cropper-main-wrapper">
|
||||
<VCropper
|
||||
ref="cropperRef"
|
||||
:img="imgUrl"
|
||||
:aspect-ratio="validAspectRatio"
|
||||
:width="600"
|
||||
:height="600"
|
||||
/>
|
||||
|
||||
<!-- 操作按钮组 -->
|
||||
<div class="cropper-btn-group">
|
||||
<Button :loading="cropLoading" @click="cropImage" type="primary">
|
||||
裁剪
|
||||
</Button>
|
||||
<Button v-if="cropperImg" @click="downloadImage" danger>
|
||||
下载图片
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 裁剪预览 -->
|
||||
<img
|
||||
v-if="cropperImg"
|
||||
class="h-full w-80"
|
||||
:src="cropperImg"
|
||||
alt="裁剪预览"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Page>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* 比例展示区域 */
|
||||
.cropper-ratio-display {
|
||||
@apply my-2.5 flex items-center justify-start gap-4;
|
||||
}
|
||||
|
||||
.ratio-label {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
/* 主裁剪区域 */
|
||||
.cropper-main-wrapper {
|
||||
@apply flex items-center gap-4;
|
||||
}
|
||||
.cropper-btn-group {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
</style>
|
||||
@@ -348,6 +348,15 @@ const [BaseForm, baseFormApi] = useVbenForm({
|
||||
showUploadList: true,
|
||||
// 上传列表的内建样式,支持四种基本样式 text, picture, picture-card 和 picture-circle
|
||||
listType: 'picture-card',
|
||||
// onChange事件已被重写,如需自定义请在此基础上扩展
|
||||
handleChange: ({ file }: { file: UploadFile }) => {
|
||||
const { name, status } = file;
|
||||
if (status === 'done') {
|
||||
message.success(`${name} ${$t('examples.form.upload-success')}`);
|
||||
} else if (status === 'error') {
|
||||
message.error(`${name} ${$t('examples.form.upload-fail')}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
fieldName: 'files',
|
||||
label: $t('examples.form.file'),
|
||||
@@ -358,6 +367,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 +396,20 @@ const [BaseForm, baseFormApi] = useVbenForm({
|
||||
|
||||
function onSubmit(values: Record<string, any>) {
|
||||
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 +421,19 @@ function onSubmit(values: Record<string, any>) {
|
||||
});
|
||||
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)}`,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user