462 lines
11 KiB
Vue
462 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import type { UploadProps, UploadRequestHandler } from 'element-plus';
|
|
|
|
import { computed, ref, watch } from 'vue';
|
|
import { VueCropper } from 'vue-cropper';
|
|
import 'vue-cropper/dist/index.css';
|
|
|
|
import { useAccessStore } from '@easyflow/stores';
|
|
|
|
import { Delete, Plus, Refresh } from '@element-plus/icons-vue';
|
|
import {
|
|
ElButton,
|
|
ElDialog,
|
|
ElIcon,
|
|
ElImage,
|
|
ElMessage,
|
|
ElUpload,
|
|
} from 'element-plus';
|
|
|
|
import { api } from '#/api/request';
|
|
import { $t } from '#/locales';
|
|
|
|
// 定义组件props
|
|
interface Props {
|
|
modelValue?: string; // 双向绑定的图片URL
|
|
crop?: boolean; // 是否启用裁剪
|
|
action?: string; // 上传地址
|
|
headers?: Record<string, string>; // 上传请求头
|
|
data?: Record<string, any>; // 上传额外数据
|
|
cropConfig?: Partial<CropConfig>; // 裁剪配置
|
|
limit?: number; // 文件大小限制(MB)
|
|
accept?: string; // 文件类型
|
|
}
|
|
|
|
interface CropConfig {
|
|
title: string;
|
|
outputSize: number;
|
|
outputType: string;
|
|
info: boolean;
|
|
full: boolean;
|
|
fixed: boolean;
|
|
fixedNumber: [number, number];
|
|
canMove: boolean;
|
|
canMoveBox: boolean;
|
|
fixedBox: boolean;
|
|
original: boolean;
|
|
autoCrop: boolean;
|
|
autoCropWidth: number;
|
|
autoCropHeight: number;
|
|
centerBox: boolean;
|
|
high: boolean;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
modelValue: '',
|
|
action: '/api/v1/commons/upload',
|
|
crop: false,
|
|
headers: () => ({}),
|
|
data: () => ({}),
|
|
cropConfig: () => ({}),
|
|
limit: 5,
|
|
accept: 'image/*',
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: string];
|
|
uploadError: [error: Error];
|
|
uploadSuccess: [url: string];
|
|
}>();
|
|
|
|
const accessStore = useAccessStore();
|
|
const headers = computed(() => ({
|
|
'easyflow-token': accessStore.accessToken,
|
|
'Content-Type': 'multipart/form-data',
|
|
...props.headers,
|
|
}));
|
|
|
|
// 默认裁剪配置
|
|
const defaultCropConfig: CropConfig = {
|
|
title: $t('cropper.ImageCropping'),
|
|
outputSize: 1,
|
|
outputType: 'png',
|
|
info: true,
|
|
full: false,
|
|
fixed: false,
|
|
fixedNumber: [1, 1],
|
|
canMove: false,
|
|
canMoveBox: true,
|
|
fixedBox: false,
|
|
original: false,
|
|
autoCrop: true,
|
|
autoCropWidth: 200,
|
|
autoCropHeight: 200,
|
|
centerBox: true,
|
|
high: true,
|
|
};
|
|
|
|
// refs
|
|
const uploadRef = ref<InstanceType<typeof ElUpload>>();
|
|
const cropperRef = ref<InstanceType<typeof VueCropper>>();
|
|
const showCropDialog = ref(false);
|
|
const cropImageUrl = ref('');
|
|
const uploading = ref(false);
|
|
const currentFile = ref<File | null>(null);
|
|
|
|
// 合并裁剪配置
|
|
const mergedCropConfig = computed(() => ({
|
|
...defaultCropConfig,
|
|
...props.cropConfig,
|
|
}));
|
|
|
|
// 触发上传 - 修复:直接触发上传组件的点击事件
|
|
const triggerUpload = () => {
|
|
// 创建隐藏的input元素来触发文件选择
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = props.accept;
|
|
input.style.display = 'none';
|
|
|
|
input.addEventListener('change', (e) => {
|
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
if (file) {
|
|
handleFileSelect(file);
|
|
}
|
|
input.remove();
|
|
});
|
|
|
|
document.body.append(input);
|
|
input.click();
|
|
};
|
|
|
|
// 处理文件选择
|
|
const handleFileSelect = (file: File) => {
|
|
// 验证文件
|
|
const isImage = file.type.startsWith('image/');
|
|
if (!isImage) {
|
|
ElMessage.error($t('cropper.message.onlyImage'));
|
|
return;
|
|
}
|
|
|
|
const isLtLimit = file.size / 1024 / 1024 < props.limit;
|
|
if (!isLtLimit) {
|
|
ElMessage.error($t('cropper.message.imgSize', { limit: props.limit }));
|
|
return;
|
|
}
|
|
|
|
currentFile.value = file;
|
|
|
|
// 如果需要裁剪,显示裁剪对话框
|
|
if (props.crop) {
|
|
cropImageUrl.value = URL.createObjectURL(file);
|
|
showCropDialog.value = true;
|
|
} else {
|
|
// 直接上传
|
|
uploadFile(file);
|
|
}
|
|
};
|
|
|
|
// 上传前验证
|
|
const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
|
|
const isImage = rawFile.type.startsWith('image/');
|
|
if (!isImage) {
|
|
ElMessage.error($t('cropper.message.onlyImage'));
|
|
return false;
|
|
}
|
|
|
|
const isLtLimit = rawFile.size / 1024 / 1024 < props.limit;
|
|
if (!isLtLimit) {
|
|
ElMessage.error($t('cropper.message.imgSize', { limit: props.limit }));
|
|
return false;
|
|
}
|
|
|
|
// 如果需要裁剪,显示裁剪对话框
|
|
if (props.crop) {
|
|
currentFile.value = rawFile;
|
|
cropImageUrl.value = URL.createObjectURL(rawFile);
|
|
showCropDialog.value = true;
|
|
return false; // 阻止自动上传
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
// 统一上传方法
|
|
const uploadFile = async (file: File) => {
|
|
try {
|
|
uploading.value = true;
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
Object.entries(props.data).forEach(([key, value]) => {
|
|
formData.append(key, value);
|
|
});
|
|
|
|
const response = await api.post(props.action, formData, {
|
|
headers: headers.value,
|
|
});
|
|
|
|
if (response.errorCode !== 0) {
|
|
throw new Error(
|
|
`${$t('cropper.message.uploadFailed')}: ${response.message}`,
|
|
);
|
|
}
|
|
|
|
const imageUrl = response.data.path;
|
|
|
|
if (!imageUrl) {
|
|
throw new Error($t('cropper.message.notUrl'));
|
|
}
|
|
|
|
emit('update:modelValue', imageUrl);
|
|
emit('uploadSuccess', imageUrl);
|
|
|
|
ElMessage.success($t('cropper.message.uploadSuccessful'));
|
|
} catch (error) {
|
|
const err =
|
|
error instanceof Error
|
|
? error
|
|
: new Error($t('cropper.message.uploadFailed'));
|
|
emit('uploadError', err);
|
|
ElMessage.error(err.message);
|
|
} finally {
|
|
uploading.value = false;
|
|
currentFile.value = null;
|
|
}
|
|
};
|
|
|
|
// 处理上传
|
|
const handleUpload: UploadRequestHandler = async (options) => {
|
|
const { file, onSuccess } = options;
|
|
await uploadFile(file);
|
|
onSuccess({}); // 调用成功回调
|
|
};
|
|
|
|
// 处理裁剪 - 修复:使用正确的裁剪逻辑
|
|
const handleCrop = () => {
|
|
if (!cropperRef.value) {
|
|
ElMessage.error($t('cropper.message.notInitialized'));
|
|
return;
|
|
}
|
|
|
|
cropperRef.value.getCropBlob(async (blob: Blob | null) => {
|
|
if (!blob) {
|
|
ElMessage.error($t('cropper.message.cropFailed'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
uploading.value = true;
|
|
|
|
// 创建文件对象,保留原始文件名但使用裁剪后的内容
|
|
const originalName = currentFile.value?.name || 'cropped-image';
|
|
const fileExtension = originalName.split('.').pop() || 'png';
|
|
const fileName = `cropped-${Date.now()}.${fileExtension}`;
|
|
|
|
const file = new File([blob], fileName, { type: blob.type });
|
|
|
|
await uploadFile(file);
|
|
showCropDialog.value = false;
|
|
} catch (error) {
|
|
const err =
|
|
error instanceof Error
|
|
? error
|
|
: new Error($t('cropper.message.uploadFailed'));
|
|
emit('uploadError', err);
|
|
ElMessage.error(err.message);
|
|
} finally {
|
|
uploading.value = false;
|
|
}
|
|
});
|
|
};
|
|
|
|
// 删除图片
|
|
const handleRemove = () => {
|
|
emit('update:modelValue', '');
|
|
ElMessage.success($t('message.deleteOkMessage'));
|
|
};
|
|
|
|
// 清理URL对象
|
|
watch(showCropDialog, (newVal) => {
|
|
if (!newVal && cropImageUrl.value) {
|
|
URL.revokeObjectURL(cropImageUrl.value);
|
|
cropImageUrl.value = '';
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="image-upload-container">
|
|
<!-- 上传按钮 -->
|
|
<div v-if="!modelValue" class="upload-area">
|
|
<ElUpload
|
|
ref="uploadRef"
|
|
class="avatar-uploader"
|
|
action="#"
|
|
:show-file-list="false"
|
|
:before-upload="beforeUpload"
|
|
:http-request="handleUpload"
|
|
:accept="accept"
|
|
>
|
|
<ElIcon class="avatar-uploader-icon"><Plus /></ElIcon>
|
|
</ElUpload>
|
|
</div>
|
|
|
|
<!-- 图片预览 -->
|
|
<div v-else class="preview-area">
|
|
<div class="preview-container">
|
|
<ElImage
|
|
:src="modelValue"
|
|
:preview-src-list="[modelValue]"
|
|
fit="cover"
|
|
class="preview-image"
|
|
:zoom-rate="1.2"
|
|
:max-scale="7"
|
|
:min-scale="0.2"
|
|
hide-on-click-modal
|
|
/>
|
|
<div class="preview-actions">
|
|
<ElButton @click="triggerUpload">
|
|
<ElIcon><Refresh /></ElIcon>
|
|
</ElButton>
|
|
<ElButton @click="handleRemove">
|
|
<ElIcon><Delete /></ElIcon>
|
|
</ElButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 裁剪对话框 -->
|
|
<ElDialog
|
|
v-model="showCropDialog"
|
|
:title="mergedCropConfig.title"
|
|
width="800px"
|
|
:close-on-click-modal="false"
|
|
destroy-on-close
|
|
>
|
|
<div class="cropper-container">
|
|
<VueCropper
|
|
ref="cropperRef"
|
|
:img="cropImageUrl"
|
|
:output-size="mergedCropConfig.outputSize"
|
|
:output-type="mergedCropConfig.outputType"
|
|
:info="mergedCropConfig.info"
|
|
:full="mergedCropConfig.full"
|
|
:fixed="mergedCropConfig.fixed"
|
|
:fixed-number="mergedCropConfig.fixedNumber"
|
|
:can-move="mergedCropConfig.canMove"
|
|
:can-move-box="mergedCropConfig.canMoveBox"
|
|
:fixed-box="mergedCropConfig.fixedBox"
|
|
:original="mergedCropConfig.original"
|
|
:auto-crop="mergedCropConfig.autoCrop"
|
|
:auto-crop-width="mergedCropConfig.autoCropWidth"
|
|
:auto-crop-height="mergedCropConfig.autoCropHeight"
|
|
:center-box="mergedCropConfig.centerBox"
|
|
:high="mergedCropConfig.high"
|
|
mode="cover"
|
|
/>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<span class="dialog-footer">
|
|
<ElButton @click="showCropDialog = false" :disabled="uploading">
|
|
{{ $t('button.cancel') }}
|
|
</ElButton>
|
|
<ElButton type="primary" @click="handleCrop" :loading="uploading">
|
|
{{
|
|
uploading ? $t('cropper.Uploading') : $t('cropper.ConfirmCrop')
|
|
}}
|
|
</ElButton>
|
|
</span>
|
|
</template>
|
|
</ElDialog>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* 样式保持不变 */
|
|
.image-upload-container {
|
|
display: inline-block;
|
|
}
|
|
|
|
.upload-area {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.avatar-uploader {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 100px;
|
|
height: 100px;
|
|
overflow: hidden;
|
|
cursor: pointer;
|
|
border: 1px dashed #d9d9d9;
|
|
border-radius: 6px;
|
|
transition: border-color 0.3s;
|
|
}
|
|
|
|
.avatar-uploader:hover {
|
|
border-color: #409eff;
|
|
}
|
|
|
|
.avatar-uploader-icon {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 100%;
|
|
height: 100%;
|
|
font-size: 28px;
|
|
color: #8c939d;
|
|
}
|
|
|
|
.preview-area {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
}
|
|
|
|
.preview-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
align-items: center;
|
|
}
|
|
|
|
.preview-image {
|
|
width: 100px;
|
|
height: 100px;
|
|
overflow: hidden;
|
|
border-radius: 6px;
|
|
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
|
|
}
|
|
|
|
.preview-actions {
|
|
display: flex;
|
|
gap: 5px;
|
|
}
|
|
|
|
.cropper-container {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 400px;
|
|
background: #f5f7fa;
|
|
}
|
|
|
|
.edit-actions {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
padding: 20px 0;
|
|
}
|
|
|
|
.edit-actions .el-button {
|
|
justify-content: flex-start;
|
|
}
|
|
</style>
|