2025-05-11 22:33:15 +08:00
|
|
|
|
<script lang="ts" setup>
|
|
|
|
|
|
import type {
|
|
|
|
|
|
UploadFile,
|
|
|
|
|
|
UploadProgressEvent,
|
|
|
|
|
|
UploadRequestOptions,
|
|
|
|
|
|
} from 'element-plus';
|
|
|
|
|
|
|
|
|
|
|
|
import type { AxiosResponse } from '@vben/request';
|
|
|
|
|
|
|
|
|
|
|
|
import type { UploadListType } from './typing';
|
|
|
|
|
|
|
|
|
|
|
|
import type { AxiosProgressEvent } from '#/api/infra/file';
|
|
|
|
|
|
|
2025-08-05 15:32:12 +08:00
|
|
|
|
import { nextTick, ref, toRefs, watch } from 'vue';
|
2025-05-11 22:33:15 +08:00
|
|
|
|
|
|
|
|
|
|
import { CloudUpload } from '@vben/icons';
|
|
|
|
|
|
import { $t } from '@vben/locales';
|
|
|
|
|
|
import { isFunction, isObject, isString } from '@vben/utils';
|
|
|
|
|
|
|
|
|
|
|
|
import { ElMessage, ElUpload } from 'element-plus';
|
|
|
|
|
|
|
|
|
|
|
|
import { checkImgType, defaultImageAccepts } from './helper';
|
|
|
|
|
|
import { UploadResultStatus } from './typing';
|
|
|
|
|
|
import { useUpload, useUploadType } from './use-upload';
|
|
|
|
|
|
|
|
|
|
|
|
defineOptions({ name: 'ImageUpload', inheritAttrs: false });
|
|
|
|
|
|
|
|
|
|
|
|
const props = withDefaults(
|
|
|
|
|
|
defineProps<{
|
|
|
|
|
|
// 根据后缀,或者其他
|
|
|
|
|
|
accept?: string[];
|
|
|
|
|
|
api?: (
|
|
|
|
|
|
file: File,
|
|
|
|
|
|
onUploadProgress?: AxiosProgressEvent,
|
|
|
|
|
|
) => Promise<AxiosResponse<any>>;
|
2025-08-05 15:32:12 +08:00
|
|
|
|
// 组件边框圆角
|
|
|
|
|
|
borderradius?: string;
|
2025-05-11 22:33:15 +08:00
|
|
|
|
// 上传的目录
|
|
|
|
|
|
directory?: string;
|
|
|
|
|
|
disabled?: boolean;
|
2025-08-05 15:32:12 +08:00
|
|
|
|
// 上传框高度
|
|
|
|
|
|
height?: number | string;
|
2025-05-11 22:33:15 +08:00
|
|
|
|
helpText?: string;
|
|
|
|
|
|
listType?: UploadListType;
|
|
|
|
|
|
// 最大数量的文件,Infinity不限制
|
|
|
|
|
|
maxNumber?: number;
|
|
|
|
|
|
// 文件最大多少MB
|
|
|
|
|
|
maxSize?: number;
|
2025-08-05 15:32:12 +08:00
|
|
|
|
modelValue?: string | string[];
|
2025-05-11 22:33:15 +08:00
|
|
|
|
// 是否支持多选
|
|
|
|
|
|
multiple?: boolean;
|
|
|
|
|
|
// support xxx.xxx.xx
|
|
|
|
|
|
resultField?: string;
|
|
|
|
|
|
// 是否显示下面的描述
|
|
|
|
|
|
showDescription?: boolean;
|
2025-07-06 21:27:44 +08:00
|
|
|
|
// 上传框宽度
|
2025-08-05 15:32:12 +08:00
|
|
|
|
width?: number | string;
|
2025-05-11 22:33:15 +08:00
|
|
|
|
}>(),
|
|
|
|
|
|
{
|
2025-07-06 21:27:44 +08:00
|
|
|
|
modelValue: () => [],
|
2025-05-11 22:33:15 +08:00
|
|
|
|
directory: undefined,
|
|
|
|
|
|
disabled: false,
|
|
|
|
|
|
listType: 'picture-card',
|
|
|
|
|
|
helpText: '',
|
|
|
|
|
|
maxSize: 2,
|
|
|
|
|
|
maxNumber: 1,
|
|
|
|
|
|
accept: () => defaultImageAccepts,
|
|
|
|
|
|
multiple: false,
|
|
|
|
|
|
api: undefined,
|
|
|
|
|
|
resultField: '',
|
|
|
|
|
|
showDescription: true,
|
2025-07-06 21:27:44 +08:00
|
|
|
|
width: '',
|
|
|
|
|
|
height: '',
|
2025-08-05 15:32:12 +08:00
|
|
|
|
borderradius: '8px',
|
2025-05-11 22:33:15 +08:00
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-07-06 21:27:44 +08:00
|
|
|
|
const emit = defineEmits(['change', 'update:modelValue', 'delete']);
|
2025-08-05 15:32:12 +08:00
|
|
|
|
const { accept, helpText, maxNumber, maxSize, width, height, borderradius } =
|
|
|
|
|
|
toRefs(props);
|
2025-05-11 22:33:15 +08:00
|
|
|
|
const isInnerOperate = ref<boolean>(false);
|
|
|
|
|
|
const { getStringAccept } = useUploadType({
|
|
|
|
|
|
acceptRef: accept,
|
|
|
|
|
|
helpTextRef: helpText,
|
|
|
|
|
|
maxNumberRef: maxNumber,
|
|
|
|
|
|
maxSizeRef: maxSize,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const fileList = ref<UploadFile[]>([]);
|
|
|
|
|
|
const isLtMsg = ref<boolean>(true); // 文件大小错误提示
|
|
|
|
|
|
const isActMsg = ref<boolean>(true); // 文件类型错误提示
|
|
|
|
|
|
const isFirstRender = ref<boolean>(true); // 是否第一次渲染
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
2025-07-06 21:27:44 +08:00
|
|
|
|
() => props.modelValue,
|
2025-05-11 22:33:15 +08:00
|
|
|
|
async (v) => {
|
|
|
|
|
|
if (isInnerOperate.value) {
|
|
|
|
|
|
isInnerOperate.value = false;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
let value: string | string[] = [];
|
|
|
|
|
|
if (v) {
|
|
|
|
|
|
if (Array.isArray(v)) {
|
|
|
|
|
|
value = v;
|
|
|
|
|
|
} 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)),
|
2025-07-06 21:27:44 +08:00
|
|
|
|
status: UploadResultStatus.SUCCESS,
|
2025-05-11 22:33:15 +08:00
|
|
|
|
url: item,
|
|
|
|
|
|
} as UploadFile;
|
|
|
|
|
|
} else if (item && isObject(item)) {
|
|
|
|
|
|
const file = item as Record<string, any>;
|
|
|
|
|
|
return {
|
|
|
|
|
|
uid: file.uid || -i,
|
|
|
|
|
|
name: file.name || '',
|
2025-07-06 21:27:44 +08:00
|
|
|
|
status: UploadResultStatus.SUCCESS,
|
2025-05-11 22:33:15 +08:00
|
|
|
|
url: file.url,
|
|
|
|
|
|
} as UploadFile;
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter(Boolean) as UploadFile[];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!isFirstRender.value) {
|
|
|
|
|
|
emit('change', value);
|
|
|
|
|
|
isFirstRender.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
immediate: true,
|
|
|
|
|
|
deep: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
function getBase64<T extends ArrayBuffer | null | string>(file: File) {
|
|
|
|
|
|
return new Promise<T>((resolve, reject) => {
|
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
|
reader.readAsDataURL(file);
|
|
|
|
|
|
reader.addEventListener('load', () => {
|
|
|
|
|
|
resolve(reader.result as T);
|
|
|
|
|
|
});
|
|
|
|
|
|
reader.addEventListener('error', (error) => reject(error));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handlePreview = async (file: UploadFile) => {
|
|
|
|
|
|
if (!file.url) {
|
|
|
|
|
|
const preview = await getBase64<string>(file.raw!);
|
|
|
|
|
|
window.open(preview || '');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
window.open(file.url);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleRemove = async (file: UploadFile) => {
|
|
|
|
|
|
if (fileList.value) {
|
|
|
|
|
|
const index = fileList.value.findIndex((item) => item.uid === file.uid);
|
|
|
|
|
|
index !== -1 && fileList.value.splice(index, 1);
|
|
|
|
|
|
const value = getValue();
|
|
|
|
|
|
isInnerOperate.value = true;
|
2025-07-06 21:27:44 +08:00
|
|
|
|
emit('update:modelValue', value);
|
2025-05-11 22:33:15 +08:00
|
|
|
|
emit('change', value);
|
|
|
|
|
|
emit('delete', file);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const beforeUpload = async (file: File) => {
|
|
|
|
|
|
const { maxSize, accept } = props;
|
|
|
|
|
|
const isAct = checkImgType(file, accept);
|
|
|
|
|
|
if (!isAct) {
|
|
|
|
|
|
ElMessage.error($t('ui.upload.acceptUpload', [accept]));
|
|
|
|
|
|
isActMsg.value = false;
|
|
|
|
|
|
// 防止弹出多个错误提示
|
|
|
|
|
|
setTimeout(() => (isActMsg.value = true), 1000);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
const isLt = file.size / 1024 / 1024 > maxSize;
|
|
|
|
|
|
if (isLt) {
|
|
|
|
|
|
ElMessage.error($t('ui.upload.maxSizeMultiple', [maxSize]));
|
|
|
|
|
|
isLtMsg.value = false;
|
|
|
|
|
|
// 防止弹出多个错误提示
|
|
|
|
|
|
setTimeout(() => (isLtMsg.value = true), 1000);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
async function customRequest(options: UploadRequestOptions) {
|
|
|
|
|
|
let { api } = props;
|
|
|
|
|
|
if (!api || !isFunction(api)) {
|
|
|
|
|
|
api = useUpload(props.directory).httpRequest;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 上传文件
|
|
|
|
|
|
const progressEvent: AxiosProgressEvent = (e) => {
|
|
|
|
|
|
const percent = Math.trunc((e.loaded / e.total!) * 100);
|
|
|
|
|
|
options.onProgress!({
|
|
|
|
|
|
percent,
|
|
|
|
|
|
total: e.total || 0,
|
|
|
|
|
|
loaded: e.loaded || 0,
|
|
|
|
|
|
lengthComputable: true,
|
|
|
|
|
|
} as unknown as UploadProgressEvent);
|
|
|
|
|
|
};
|
|
|
|
|
|
const res = await api?.(options.file, progressEvent);
|
|
|
|
|
|
options.onSuccess!(res);
|
|
|
|
|
|
ElMessage.success($t('ui.upload.uploadSuccess'));
|
|
|
|
|
|
|
|
|
|
|
|
// 更新文件
|
|
|
|
|
|
const value = getValue();
|
|
|
|
|
|
isInnerOperate.value = true;
|
2025-07-06 21:27:44 +08:00
|
|
|
|
emit('update:modelValue', value);
|
2025-05-11 22:33:15 +08:00
|
|
|
|
emit('change', value);
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
console.error(error);
|
|
|
|
|
|
options.onError!(error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getValue() {
|
|
|
|
|
|
const list = (fileList.value || [])
|
2025-07-06 21:27:44 +08:00
|
|
|
|
.filter((item) => item?.status === UploadResultStatus.SUCCESS)
|
2025-05-11 22:33:15 +08:00
|
|
|
|
.map((item: any) => {
|
|
|
|
|
|
if (item?.response && props?.resultField) {
|
|
|
|
|
|
return item?.response;
|
|
|
|
|
|
}
|
2025-07-06 21:27:44 +08:00
|
|
|
|
return item?.response?.url || item?.response;
|
2025-05-11 22:33:15 +08:00
|
|
|
|
});
|
|
|
|
|
|
// add by 芋艿:【特殊】单个文件的情况,获取首个元素,保证返回的是 String 类型
|
|
|
|
|
|
if (props.maxNumber === 1) {
|
|
|
|
|
|
return list.length > 0 ? list[0] : '';
|
|
|
|
|
|
}
|
|
|
|
|
|
return list;
|
|
|
|
|
|
}
|
2025-08-05 15:32:12 +08:00
|
|
|
|
|
|
|
|
|
|
// 编辑按钮:触发文件选择
|
|
|
|
|
|
const triggerEdit = () => {
|
|
|
|
|
|
if (props.disabled) return;
|
|
|
|
|
|
// 只查找当前 upload-box 下的 input
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
const uploadBox = document.querySelector('.upload-box');
|
|
|
|
|
|
if (uploadBox) {
|
|
|
|
|
|
const input = uploadBox.querySelector(
|
|
|
|
|
|
'input[type="file"]',
|
|
|
|
|
|
) as HTMLInputElement | null;
|
|
|
|
|
|
if (input) input.click();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
2025-05-11 22:33:15 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2025-08-05 15:32:12 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="upload-box"
|
|
|
|
|
|
:style="{
|
|
|
|
|
|
width: width || '150px',
|
|
|
|
|
|
height: height || '150px',
|
|
|
|
|
|
borderRadius: borderradius,
|
|
|
|
|
|
}"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template
|
|
|
|
|
|
v-if="
|
|
|
|
|
|
fileList.length > 0 &&
|
|
|
|
|
|
fileList[0] &&
|
|
|
|
|
|
fileList[0].status === UploadResultStatus.SUCCESS
|
|
|
|
|
|
"
|
2025-05-11 22:33:15 +08:00
|
|
|
|
>
|
2025-08-05 15:32:12 +08:00
|
|
|
|
<div class="upload-image-wrapper">
|
|
|
|
|
|
<img :src="fileList[0].url" class="upload-image" />
|
|
|
|
|
|
<div class="upload-handle">
|
|
|
|
|
|
<div class="handle-icon" @click="handlePreview(fileList[0]!)">
|
|
|
|
|
|
<i class="el-icon el-icon-zoom-in"></i>
|
|
|
|
|
|
<span>详情</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-if="!disabled" class="handle-icon" @click="triggerEdit">
|
|
|
|
|
|
<i class="el-icon el-icon-edit"></i>
|
|
|
|
|
|
<span>编辑</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="!disabled"
|
|
|
|
|
|
class="handle-icon"
|
|
|
|
|
|
@click="handleRemove(fileList[0]!)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<i class="el-icon el-icon-delete"></i>
|
|
|
|
|
|
<span>删除</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-05-11 22:33:15 +08:00
|
|
|
|
</div>
|
2025-08-05 15:32:12 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<ElUpload
|
|
|
|
|
|
v-bind="$attrs"
|
|
|
|
|
|
v-model:file-list="fileList"
|
|
|
|
|
|
:accept="getStringAccept"
|
|
|
|
|
|
:before-upload="beforeUpload"
|
|
|
|
|
|
:http-request="customRequest"
|
|
|
|
|
|
:disabled="disabled"
|
|
|
|
|
|
:list-type="listType"
|
|
|
|
|
|
:limit="maxNumber"
|
|
|
|
|
|
:multiple="multiple"
|
|
|
|
|
|
:on-preview="handlePreview"
|
|
|
|
|
|
:on-remove="handleRemove"
|
|
|
|
|
|
class="upload"
|
|
|
|
|
|
:style="{
|
|
|
|
|
|
width: width || '150px',
|
|
|
|
|
|
height: height || '150px',
|
|
|
|
|
|
borderRadius: borderradius,
|
|
|
|
|
|
}"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="upload-content flex flex-col items-center justify-center">
|
|
|
|
|
|
<CloudUpload />
|
|
|
|
|
|
<div class="mt-2">{{ $t('ui.upload.imgUpload') }}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</ElUpload>
|
|
|
|
|
|
</template>
|
2025-05-11 22:33:15 +08:00
|
|
|
|
<div v-if="showDescription" class="mt-2 text-xs text-gray-500">
|
|
|
|
|
|
{{ getStringAccept }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
2025-08-05 15:32:12 +08:00
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
|
.upload-box {
|
2025-08-05 15:34:25 +08:00
|
|
|
|
position: relative;
|
2025-07-06 21:27:44 +08:00
|
|
|
|
display: flex;
|
2025-08-05 15:32:12 +08:00
|
|
|
|
flex-direction: column;
|
2025-07-06 21:27:44 +08:00
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
overflow: hidden;
|
2025-08-05 15:32:12 +08:00
|
|
|
|
background: #fafafa;
|
2025-08-05 15:34:25 +08:00
|
|
|
|
border: 1px dashed var(--el-border-color-darker);
|
2025-08-05 15:32:12 +08:00
|
|
|
|
transition: border-color 0.2s;
|
|
|
|
|
|
|
|
|
|
|
|
.upload {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
2025-08-05 15:34:25 +08:00
|
|
|
|
width: 100% !important;
|
|
|
|
|
|
height: 100% !important;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
border: none !important;
|
2025-08-05 15:32:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.upload-content {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
2025-08-05 15:34:25 +08:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
2025-08-05 15:32:12 +08:00
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.upload-image-wrapper {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
2025-08-05 15:34:25 +08:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
2025-08-05 15:32:12 +08:00
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
background: #fff;
|
2025-08-05 15:34:25 +08:00
|
|
|
|
border-radius: inherit;
|
2025-08-05 15:32:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.upload-image {
|
2025-08-05 15:34:25 +08:00
|
|
|
|
display: block;
|
2025-08-05 15:32:12 +08:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
|
border-radius: inherit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.upload-handle {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
right: 0;
|
2025-08-05 15:34:25 +08:00
|
|
|
|
z-index: 2;
|
2025-08-05 15:32:12 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
2025-08-05 15:34:25 +08:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
background: rgb(0 0 0 / 50%);
|
2025-08-05 15:32:12 +08:00
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transition: opacity 0.2s;
|
2025-08-05 15:34:25 +08:00
|
|
|
|
|
2025-08-05 15:32:12 +08:00
|
|
|
|
&:hover {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
}
|
2025-08-05 15:34:25 +08:00
|
|
|
|
|
2025-08-05 15:32:12 +08:00
|
|
|
|
.handle-icon {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
margin: 0 8px;
|
|
|
|
|
|
font-size: 18px;
|
2025-08-05 15:34:25 +08:00
|
|
|
|
color: #fff;
|
|
|
|
|
|
|
2025-08-05 15:32:12 +08:00
|
|
|
|
span {
|
|
|
|
|
|
margin-top: 2px;
|
2025-08-05 15:34:25 +08:00
|
|
|
|
font-size: 12px;
|
2025-08-05 15:32:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-05 15:34:25 +08:00
|
|
|
|
|
2025-08-05 15:32:12 +08:00
|
|
|
|
.upload-image-wrapper:hover .upload-handle {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
}
|
2025-07-06 21:27:44 +08:00
|
|
|
|
}
|
2025-05-11 22:33:15 +08:00
|
|
|
|
</style>
|