395 lines
9.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div
class="relative inline-block"
@mouseenter="showTooltipHandler"
@mouseleave="hideTooltipHandler"
>
<!-- 文件上传按钮 -->
<el-button
v-if="!disabled"
circle
size="small"
class="upload-btn relative transition-all-200ms"
:class="{ 'has-files': fileList.length > 0 }"
@click="triggerFileInput"
:disabled="fileList.length >= limit"
>
<Icon icon="ep:paperclip" :size="16" />
<!-- 文件数量徽章 -->
<span
v-if="fileList.length > 0"
class="absolute -top-1 -right-1 bg-red-500 text-white text-10px px-1 rounded-8px min-w-4 h-4 flex items-center justify-center leading-none font-medium"
>
{{ fileList.length }}
</span>
</el-button>
<!-- 隐藏的文件输入框 -->
<input
ref="fileInputRef"
type="file"
multiple
style="display: none"
:accept="acceptTypes"
@change="handleFileSelect"
/>
<!-- Hover 显示的文件列表 -->
<div
v-if="fileList.length > 0 && showTooltip"
class="file-tooltip"
@mouseenter="showTooltipHandler"
@mouseleave="hideTooltipHandler"
>
<div class="tooltip-arrow"></div>
<div class="max-h-200px overflow-y-auto file-list">
<div
v-for="(file, index) in fileList"
:key="index"
class="flex items-center justify-between p-2 mb-1 bg-gray-50 rounded-6px text-12px transition-all-200ms last:mb-0 hover:bg-gray-100"
:class="{ 'opacity-70': file.uploading }"
>
<div class="flex items-center flex-1 min-w-0">
<Icon :icon="getFileIcon(file.name)" class="text-blue-500 mr-2 flex-shrink-0" />
<span
class="font-medium text-gray-900 mr-1 overflow-hidden text-ellipsis whitespace-nowrap flex-1"
>{{ file.name }}</span
>
<span class="text-gray-500 flex-shrink-0 text-11px"
>({{ formatFileSize(file.size) }})</span
>
</div>
<div class="flex items-center gap-1 flex-shrink-0 ml-2">
<el-progress
v-if="file.uploading"
:percentage="file.progress || 0"
:show-text="false"
size="small"
class="w-60px"
/>
<el-button
v-else-if="!disabled"
link
type="danger"
size="small"
@click="removeFile(index)"
>
<Icon icon="ep:close" :size="12" />
</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useUpload } from '@/components/UploadFile/src/useUpload'
import { formatFileSize, getFileIcon } from '@/utils/file'
export interface FileItem {
name: string
size: number
url?: string
uploading?: boolean
progress?: number
raw?: File
}
defineOptions({ name: 'MessageFileUpload' })
const props = defineProps({
modelValue: {
type: Array as PropType<string[]>,
default: () => []
},
limit: {
type: Number,
default: 5
},
maxSize: {
type: Number,
default: 10 // MB
},
acceptTypes: {
type: String,
default: '.jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.txt,.xls,.xlsx,.ppt,.pptx,.csv,.md'
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'upload-success', 'upload-error'])
const fileInputRef = ref<HTMLInputElement>()
const fileList = ref<FileItem[]>([]) // 内部管理文件列表
const uploadedUrls = ref<string[]>([]) // 已上传的 URL 列表
const showTooltip = ref(false) // 控制 tooltip 显示
const hideTimer = ref<NodeJS.Timeout | null>(null) // 隐藏延迟定时器
const message = useMessage()
const { httpRequest } = useUpload()
/** 监听 v-model 变化 */
watch(
() => props.modelValue,
(newVal) => {
uploadedUrls.value = [...newVal]
// 如果外部清空了 URLs也清空内部文件列表
if (newVal.length === 0) {
fileList.value = []
}
},
{ immediate: true, deep: true }
)
/** 触发文件选择 */
const triggerFileInput = () => {
fileInputRef.value?.click()
}
/** 显示 tooltip */
const showTooltipHandler = () => {
if (hideTimer.value) {
clearTimeout(hideTimer.value)
hideTimer.value = null
}
showTooltip.value = true
}
/** 隐藏 tooltip */
const hideTooltipHandler = () => {
hideTimer.value = setTimeout(() => {
showTooltip.value = false
hideTimer.value = null
}, 300) // 300ms 延迟隐藏
}
/** 处理文件选择 */
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
const files = Array.from(target.files || [])
if (files.length === 0) {
return
}
// 检查总文件数是否超过限制
if (files.length + fileList.value.length > props.limit) {
message.error(`最多只能上传 ${props.limit} 个文件`)
target.value = '' // 清空输入
return
}
// 处理每个文件
files.forEach((file) => {
if (file.size > props.maxSize * 1024 * 1024) {
message.error(`文件 ${file.name} 大小超过 ${props.maxSize}MB`)
return
}
const fileItem: FileItem = {
name: file.name,
size: file.size,
uploading: true,
progress: 0,
raw: file
}
fileList.value.push(fileItem)
// 立即开始上传
uploadFile(fileItem)
})
// 清空 input 值,允许重复选择相同文件
target.value = ''
}
/** 上传文件 */
const uploadFile = async (fileItem: FileItem) => {
try {
// 模拟上传进度
const progressInterval = setInterval(() => {
if (fileItem.progress! < 90) {
fileItem.progress = (fileItem.progress || 0) + Math.random() * 10
}
}, 100)
// 调用上传接口
// const formData = new FormData()
// formData.append('file', fileItem.raw!)
const response = await httpRequest({
file: fileItem.raw!,
filename: fileItem.name
} as any)
fileItem.uploading = false
fileItem.progress = 100
fileItem.url = (response as any).data
// 添加到 URL 列表
uploadedUrls.value.push(fileItem.url!)
clearInterval(progressInterval)
emit('upload-success', fileItem)
updateModelValue()
} catch (error) {
fileItem.uploading = false
message.error(`文件 ${fileItem.name} 上传失败`)
emit('upload-error', error)
// 移除上传失败的文件
const index = fileList.value.indexOf(fileItem)
if (index > -1) {
removeFile(index)
}
}
}
/** 删除文件 */
const removeFile = (index: number) => {
// 从 URL 列表中移除
const removedFile = fileList.value[index]
fileList.value.splice(index, 1)
if (removedFile.url) {
const urlIndex = uploadedUrls.value.indexOf(removedFile.url)
if (urlIndex > -1) {
uploadedUrls.value.splice(urlIndex, 1)
}
}
updateModelValue()
}
/** 更新 v-model */
const updateModelValue = () => {
emit('update:modelValue', [...uploadedUrls.value])
}
// 暴露方法
defineExpose({
triggerFileInput,
clearFiles: () => {
fileList.value = []
uploadedUrls.value = []
updateModelValue()
}
})
// 组件销毁时清理定时器
onUnmounted(() => {
if (hideTimer.value) {
clearTimeout(hideTimer.value)
}
})
</script>
<style scoped>
/* 上传按钮样式 */
.upload-btn {
--el-button-bg-color: transparent;
--el-button-border-color: transparent;
--el-button-hover-bg-color: var(--el-fill-color-light);
--el-button-hover-border-color: transparent;
color: var(--el-text-color-regular);
}
.upload-btn.has-files {
color: var(--el-color-primary);
--el-button-hover-bg-color: var(--el-color-primary-light-9);
}
.file-tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: white;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
min-width: 240px;
max-width: 320px;
padding: 8px;
animation: fadeInDown 0.2s ease;
}
.tooltip-arrow {
position: absolute;
bottom: -5px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid var(--el-border-color-light);
}
/* Tooltip 箭头伪元素 */
.tooltip-arrow::after {
content: '';
position: absolute;
bottom: 1px;
left: -4px;
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid white;
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(4px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(4px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* 滚动条样式 */
.file-list::-webkit-scrollbar {
width: 4px;
}
.file-list::-webkit-scrollbar-track {
background: transparent;
}
.file-list::-webkit-scrollbar-thumb {
background: var(--el-border-color-light);
border-radius: 2px;
}
.file-list::-webkit-scrollbar-thumb:hover {
background: var(--el-border-color);
}
/* 滚动条样式 */
.file-list::-webkit-scrollbar {
width: 4px;
}
.file-list::-webkit-scrollbar-track {
background: transparent;
}
.file-list::-webkit-scrollbar-thumb {
background: var(--el-border-color-light);
border-radius: 2px;
}
.file-list::-webkit-scrollbar-thumb:hover {
background: var(--el-border-color);
}
</style>