2025-12-06 17:04:49 +08:00
|
|
|
|
<template>
|
2025-12-08 17:36:20 +08:00
|
|
|
|
<!-- 封面模式 -->
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<Button variant="danger" size="small" @click="handleRemoveCover">
|
|
|
|
|
|
×
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</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 />
|
2025-12-08 11:40:20 +08:00
|
|
|
|
</div>
|
2025-12-08 17:36:20 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 弹窗模式 -->
|
|
|
|
|
|
<div v-else-if="mode === 'dialog'" class="file-upload dialog">
|
|
|
|
|
|
<Button @click="showDialog = true" variant="primary">
|
|
|
|
|
|
{{ buttonText || '上传文件' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="showDialog" class="overlay" @click="closeDialog">
|
|
|
|
|
|
<div class="modal" @click.stop>
|
|
|
|
|
|
<div class="header">
|
|
|
|
|
|
<h3>{{ title || '文件上传' }}</h3>
|
|
|
|
|
|
<Button variant="secondary" size="small" @click="closeDialog">×</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<Button variant="danger" size="small" @click="removeFile(index)" :disabled="uploading">
|
|
|
|
|
|
删除
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="footer">
|
|
|
|
|
|
<Button variant="secondary" @click="closeDialog" :disabled="uploading">取消</Button>
|
|
|
|
|
|
<Button variant="primary" @click="uploadFiles" :loading="uploading"
|
|
|
|
|
|
:disabled="selectedFiles.length === 0">
|
|
|
|
|
|
{{ uploading ? '上传中...' : '确定上传' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-08 11:40:20 +08:00
|
|
|
|
</div>
|
2025-12-08 17:36:20 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 内容模式 -->
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<Button variant="danger" size="small" @click="removeFile(index)" :disabled="uploading">
|
|
|
|
|
|
删除
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 上传按钮 -->
|
|
|
|
|
|
<div v-if="selectedFiles.length > 0" class="actions">
|
|
|
|
|
|
<Button variant="primary" @click="uploadFiles" :loading="uploading"
|
|
|
|
|
|
:disabled="selectedFiles.length === 0">
|
|
|
|
|
|
{{ uploading ? '上传中...' : '确定上传' }}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-06 17:04:49 +08:00
|
|
|
|
</div>
|
2025-12-08 17:36:20 +08:00
|
|
|
|
|
2025-12-06 17:04:49 +08:00
|
|
|
|
</template>
|
2025-12-08 17:36:20 +08:00
|
|
|
|
|
2025-12-06 17:04:49 +08:00
|
|
|
|
<script setup lang="ts">
|
2025-12-08 17:36:20 +08:00
|
|
|
|
import { ref, computed } from 'vue'
|
2025-12-08 11:40:20 +08:00
|
|
|
|
import { Button } from '@/components'
|
|
|
|
|
|
import { FILE_DOWNLOAD_URL } from '@/config'
|
2025-12-08 17:36:20 +08:00
|
|
|
|
import { fileAPI } from '@/api/file/file'
|
|
|
|
|
|
import type { TbSysFileDTO } from '@/types/file/file'
|
|
|
|
|
|
import {
|
|
|
|
|
|
formatFileSize,
|
|
|
|
|
|
isImageFile,
|
|
|
|
|
|
getFileTypeIcon,
|
|
|
|
|
|
isValidFileType,
|
|
|
|
|
|
getFilePreviewUrl
|
|
|
|
|
|
} from '@/utils/file'
|
|
|
|
|
|
|
|
|
|
|
|
interface Props {
|
2025-12-08 11:40:20 +08:00
|
|
|
|
mode: 'cover' | 'dialog' | 'content'
|
2025-12-08 17:36:20 +08:00
|
|
|
|
coverImg?: string
|
|
|
|
|
|
fileList?: TbSysFileDTO[]
|
|
|
|
|
|
accept?: string
|
|
|
|
|
|
maxSize?: number
|
|
|
|
|
|
maxCount?: number
|
|
|
|
|
|
title?: string
|
|
|
|
|
|
buttonText?: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
|
|
|
|
coverImg: '',
|
|
|
|
|
|
fileList: () => [],
|
|
|
|
|
|
accept: '',
|
|
|
|
|
|
maxSize: 10 * 1024 * 1024,
|
|
|
|
|
|
maxCount: 10,
|
|
|
|
|
|
title: '文件上传',
|
|
|
|
|
|
buttonText: '上传文件'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
'update:coverImg': [value: string]
|
|
|
|
|
|
'update:fileList': [value: TbSysFileDTO[]]
|
|
|
|
|
|
'upload-success': [files: TbSysFileDTO[]]
|
|
|
|
|
|
'upload-error': [error: string]
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
// 响应式数据
|
|
|
|
|
|
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 uploadFiles = async () => {
|
|
|
|
|
|
if (selectedFiles.value.length === 0) 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
|
|
|
|
|
|
}
|
2025-12-08 11:40:20 +08:00
|
|
|
|
}
|
2025-12-06 17:04:49 +08:00
|
|
|
|
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
2025-12-08 17:36:20 +08:00
|
|
|
|
@import './FileUpload.scss';
|
2025-12-06 17:04:49 +08:00
|
|
|
|
</style>
|