Files
urbanLifeline/urbanLifelineWeb/packages/shared/src/components/fileupload/FileUpload.vue
2025-12-19 17:34:30 +08:00

476 lines
13 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 v-if="mode === 'cover'" class="file-upload cover">
<!-- 已上传的封面 -->
<div v-if="currentCoverImg" class="image-wrapper">
<img :src="currentCoverImg" class="image" :class="coverImageClass" @load="handleCoverImageLoad"
alt="封面图片" />
<div class="actions">
<ElButton type="danger" size="small" @click="handleRemoveCover">
×
</ElButton>
</div>
</div>
<!-- 上传区域 -->
<div v-else class="area cover" :class="{ dragover: isDragover, disabled: uploading }" @click="triggerFileInput"
@drop.prevent="handleDrop" @dragover.prevent="handleDragOver" @dragleave.prevent="handleDragLeave">
<div v-if="!uploading" class="content">
<div class="icon">
<div class="plus"></div>
</div>
<div class="text">点击上传封面</div>
<div class="tip">
{{ `支持 ${getAcceptText()} 格式,最大 ${(maxSize / 1024 / 1024).toFixed(0)}MB` }}
</div>
</div>
<div v-if="uploading" class="loading">
<div class="spinner"></div>
<div>上传中...</div>
</div>
</div>
<input ref="fileInputRef" type="file" :accept="accept" @change="handleFileSelect" hidden />
</div>
<!-- 弹窗模式 -->
<div v-else-if="mode === 'dialog'" class="file-upload dialog">
<ElButton @click="showDialog = true" type="primary">
{{ buttonText || '上传文件' }}
</ElButton>
<ElDialog
v-model="showDialog"
:title="title || '文件上传'"
width="500px"
:before-close="handleDialogClose"
>
<div class="dialog-content">
<div class="area" :class="{ dragover: isDragover, disabled: uploading }" @click="triggerFileInput"
@drop.prevent="handleDrop" @dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave">
<div class="icon">
<div class="plus"></div>
</div>
<div class="text">
将文件拖到此处<span class="link">点击上传</span>
</div>
<div class="tip">{{ getUploadTip() }}</div>
</div>
<input ref="fileInputRef" type="file" :accept="accept" :multiple="maxCount > 1"
@change="handleFileSelect" hidden />
<!-- 文件列表 -->
<div v-if="selectedFiles.length > 0" class="files">
<div v-for="(file, index) in selectedFiles" :key="index" class="file">
<div class="preview">
<img v-if="isImageFile(file)" :src="getFilePreviewUrl(file)" class="thumb" />
<span v-else class="type-icon">{{ getFileTypeIcon(file) }}</span>
</div>
<div class="info">
<div class="name">{{ file.name }}</div>
<div class="size">{{ formatFileSize(file.size) }}</div>
</div>
<div class="actions">
<ElButton type="danger" size="small" @click="removeFile(index)" :disabled="uploading">
删除
</ElButton>
</div>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<ElButton @click="closeDialog" :disabled="uploading">取消</ElButton>
<ElButton type="primary" @click="uploadFiles" :loading="uploading"
:disabled="selectedFiles.length === 0">
{{ uploading ? '上传中...' : '确定上传' }}
</ElButton>
</div>
</template>
</ElDialog>
</div>
<!-- 内容模式 -->
<div v-else class="file-upload content">
<div class="container">
<div class="area" :class="{ dragover: isDragover, disabled: uploading }" @click="triggerFileInput"
@drop.prevent="handleDrop" @dragover.prevent="handleDragOver" @dragleave.prevent="handleDragLeave">
<div class="icon">
<div class="plus"></div>
</div>
<div class="text">
将文件拖到此处<span class="link">点击上传</span>
</div>
<div class="tip">{{ getUploadTip() }}</div>
</div>
<input ref="fileInputRef" type="file" :accept="accept" :multiple="maxCount > 1" @change="handleFileSelect"
hidden />
<!-- 文件列表 -->
<div v-if="selectedFiles.length > 0" class="files">
<h4>待上传文件</h4>
<div v-for="(file, index) in selectedFiles" :key="index" class="file">
<div class="preview">
<img v-if="isImageFile(file)" :src="getFilePreviewUrl(file)" class="thumb" />
<span v-else class="type-icon">{{ getFileTypeIcon(file) }}</span>
</div>
<div class="info">
<div class="name">{{ file.name }}</div>
<div class="size">{{ formatFileSize(file.size) }}</div>
</div>
<div class="actions">
<ElButton type="danger" size="small" @click="removeFile(index)" :disabled="uploading">
删除
</ElButton>
</div>
</div>
</div>
<!-- 上传按钮 -->
<div v-if="selectedFiles.length > 0" class="actions">
<ElButton type="primary" @click="uploadFiles" :loading="uploading"
:disabled="selectedFiles.length === 0">
{{ uploading ? '上传中...' : '确定上传' }}
</ElButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { FILE_DOWNLOAD_URL } from '@/config'
import { fileAPI } from '@/api/file/file'
import type { TbSysFileDTO } from '@/types/file/file'
import { ElButton, ElDialog } from 'element-plus'
import {
formatFileSize,
isImageFile,
getFileTypeIcon,
isValidFileType,
getFilePreviewUrl
} from '@/utils/file'
interface Props {
mode: 'cover' | 'dialog' | 'content'
coverImg?: string
fileList?: TbSysFileDTO[]
accept?: string
maxSize?: number
maxCount?: number
title?: string
buttonText?: string
// 自定义上传函数,如果提供则使用外部实现,否则使用默认上传逻辑
customUpload?: (files: File[]) => Promise<TbSysFileDTO[] | void>
}
const props = withDefaults(defineProps<Props>(), {
coverImg: '',
fileList: () => [],
accept: '',
maxSize: 10 * 1024 * 1024,
maxCount: 10,
title: '文件上传',
buttonText: '上传文件',
customUpload: undefined
})
const emit = defineEmits<{
'update:coverImg': [value: string]
'update:fileList': [value: TbSysFileDTO[]]
'upload-success': [files: TbSysFileDTO[]]
'upload-error': [error: string]
// 文件准备就绪事件,当使用自定义上传时触发,传递文件列表供外部处理
'files-ready': [files: File[]]
}>()
// 响应式数据
const showDialog = ref(false)
const uploading = ref(false)
const selectedFiles = ref<File[]>([])
const isDragover = ref(false)
const coverImageClass = ref('')
// 文件输入引用
const fileInputRef = ref<HTMLInputElement>()
// 计算属性
const currentCoverImg = computed({
get: () => props.coverImg,
set: (value: string) => emit('update:coverImg', value)
})
// 触发文件输入
const triggerFileInput = () => {
fileInputRef.value?.click()
}
// 拖拽相关
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
isDragover.value = true
}
const handleDragLeave = () => {
isDragover.value = false
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
isDragover.value = false
if (uploading.value) return
const files = event.dataTransfer?.files
if (files && files.length > 0) {
addFiles(Array.from(files))
// cover模式和content模式下拖拽文件后立即上传
if (props.mode === 'cover' || props.mode === 'content') {
uploadFiles()
}
}
}
// 处理封面图片加载
const handleCoverImageLoad = (event: Event) => {
const img = event.target as HTMLImageElement
const width = img.naturalWidth
const height = img.naturalHeight
if (width > height) {
coverImageClass.value = 'horizontal'
} else {
coverImageClass.value = 'vertical'
}
}
// 删除封面
const handleRemoveCover = () => {
emit('update:coverImg', '')
coverImageClass.value = ''
selectedFiles.value = []
}
// 获取接受文件类型文本
const getAcceptText = (): string => {
if (!props.accept || props.accept === '*/*') return '所有'
if (props.accept.includes('image')) return '图片'
return props.accept
}
// 获取上传提示文本
const getUploadTip = (): string => {
const acceptText = getAcceptText()
const sizeText = (props.maxSize / 1024 / 1024).toFixed(0)
return `支持 ${acceptText} 格式,单个文件不超过 ${sizeText}MB`
}
// 添加文件
const addFiles = (files: File[]) => {
files.forEach(file => {
// 验证文件大小
if (file.size > props.maxSize) {
emit('upload-error', `文件 ${file.name} 大小超过 ${(props.maxSize / 1024 / 1024).toFixed(0)}MB`)
return
}
// 验证文件类型
if (props.accept && !isValidFileType(file, props.accept)) {
emit('upload-error', `文件 ${file.name} 类型不符合要求`)
return
}
// 检查是否重复
if (selectedFiles.value.some(f => f.name === file.name && f.size === file.size)) {
emit('upload-error', `文件 ${file.name} 已添加`)
return
}
// 如果不允许多选或封面模式,清空之前的文件
if (props.maxCount === 1 || props.mode === 'cover') {
selectedFiles.value = [file]
} else {
// 检查数量限制
if (selectedFiles.value.length >= props.maxCount) {
emit('upload-error', `最多只能上传 ${props.maxCount} 个文件`)
return
}
selectedFiles.value.push(file)
}
})
}
// 处理文件选择
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
const files = Array.from(target.files || [])
if (files.length === 0) return
addFiles(files)
// 清空 input允许重复选择同一文件
target.value = ''
// cover模式和content模式下选择文件后立即上传
if (props.mode === 'cover' || props.mode === 'content') {
uploadFiles()
}
}
// 移除文件
const removeFile = (index: number) => {
selectedFiles.value.splice(index, 1)
}
// 关闭弹窗
const closeDialog = () => {
showDialog.value = false
selectedFiles.value = []
isDragover.value = false
}
// 处理弹窗关闭前的回调
const handleDialogClose = (done: () => void) => {
if (uploading.value) {
return
}
closeDialog()
done()
}
// 上传文件
const uploadFiles = async () => {
if (selectedFiles.value.length === 0) return
// 如果提供了自定义上传函数,则使用外部实现
if (props.customUpload) {
uploading.value = true
try {
// 触发 files-ready 事件,传递文件列表
emit('files-ready', [...selectedFiles.value])
// 调用自定义上传函数
const result = await props.customUpload([...selectedFiles.value])
// 如果自定义函数返回了文件列表,触发上传成功事件
if (result && result.length > 0) {
emit('upload-success', result)
emit('update:fileList', [...props.fileList, ...result])
if (props.mode === 'cover' && result[0]?.url) {
emit('update:coverImg', result[0].url)
}
}
// 清空文件列表
selectedFiles.value = []
if (props.mode === 'dialog') {
closeDialog()
}
} catch (error) {
console.error('自定义上传失败:', error)
emit('upload-error', error instanceof Error ? error.message : '上传失败,请重试')
} finally {
uploading.value = false
}
return
}
// 默认上传逻辑
uploading.value = true
const uploadedFilesList: TbSysFileDTO[] = []
try {
if (selectedFiles.value.length === 1) {
const file = selectedFiles.value[0]
const result = await fileAPI.uploadFile({
file: file,
module: props.mode,
optsn: ''
})
if (result.success && result.data) {
uploadedFilesList.push(result.data)
if (props.mode === 'cover' && result.data.url) {
emit('update:coverImg', result.data.url)
}
} else {
emit('upload-error', result.message || '上传失败,请重试')
return
}
} else {
const result = await fileAPI.batchUploadFiles({
files: selectedFiles.value,
module: props.mode,
optsn: ''
})
if (result.success) {
const files = result.dataList || []
uploadedFilesList.push(...files)
} else {
emit('upload-error', result.message || '上传失败,请重试')
return
}
}
// 上传成功
if (uploadedFilesList.length > 0) {
emit('upload-success', uploadedFilesList)
emit('update:fileList', [...props.fileList, ...uploadedFilesList])
}
// 清空文件列表
selectedFiles.value = []
if (props.mode === 'dialog') {
closeDialog()
}
} catch (error) {
console.error('上传失败:', error)
emit('upload-error', '上传失败,请重试')
} finally {
uploading.value = false
}
}
// 暴露方法和状态供外部使用
defineExpose({
// 当前选中的文件列表
selectedFiles,
// 上传状态
uploading,
// 弹窗显示状态
showDialog,
// 手动触发文件选择
triggerFileInput,
// 手动添加文件
addFiles,
// 移除指定文件
removeFile,
// 清空所有文件
clearFiles: () => { selectedFiles.value = [] },
// 手动触发上传
uploadFiles,
// 关闭弹窗
closeDialog,
// 打开弹窗
openDialog: () => { showDialog.value = true }
})
</script>
<style lang="scss" scoped>
@import url('./FileUpload.scss');
</style>