mirror of
https://github.com/ctwj/urldb.git
synced 2025-11-25 19:37:33 +08:00
367 lines
9.7 KiB
Vue
367 lines
9.7 KiB
Vue
<template>
|
||
<div class="file-upload-container">
|
||
<n-upload
|
||
multiple
|
||
directory-dnd
|
||
:custom-request="customRequest"
|
||
:on-before-upload="handleBeforeUpload"
|
||
:on-finish="handleUploadFinish"
|
||
:on-error="handleUploadError"
|
||
:on-remove="handleFileRemove"
|
||
:max="5"
|
||
ref="uploadRef"
|
||
>
|
||
<n-upload-dragger>
|
||
<div style="margin-bottom: 12px">
|
||
<i class="fas fa-cloud-upload-alt text-4xl text-gray-400"></i>
|
||
</div>
|
||
<n-text style="font-size: 16px">
|
||
点击或者拖动文件到该区域来上传
|
||
</n-text>
|
||
<n-p depth="3" style="margin: 8px 0 0 0">
|
||
支持极速上传,相同文件将直接返回已上传的文件信息
|
||
</n-p>
|
||
</n-upload-dragger>
|
||
</n-upload>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { useMessage } from 'naive-ui'
|
||
import { useFileApi } from '~/composables/useFileApi'
|
||
|
||
interface FileItem {
|
||
id: number
|
||
original_name: string
|
||
file_name: string
|
||
file_path: string
|
||
file_size: number
|
||
file_type: string
|
||
mime_type: string
|
||
file_hash: string
|
||
access_url: string
|
||
user_id: number
|
||
user: string
|
||
status: string
|
||
is_public: boolean
|
||
is_deleted: boolean
|
||
created_at: string
|
||
updated_at: string
|
||
}
|
||
|
||
interface UploadFileInfo {
|
||
id: string
|
||
name: string
|
||
status: 'pending' | 'uploading' | 'finished' | 'error' | 'removed'
|
||
url?: string
|
||
file?: File
|
||
}
|
||
|
||
const message = useMessage()
|
||
const fileApi = useFileApi()
|
||
|
||
// 响应式数据
|
||
const uploadRef = ref()
|
||
const fileList = ref<FileItem[]>([])
|
||
const isPublic = ref(true) // 默认公开
|
||
const maxFiles = ref(10)
|
||
const maxFileSize = ref(5 * 1024 * 1024) // 5MB
|
||
const acceptTypes = ref('image/*')
|
||
|
||
// 添加状态标记:用于跟踪已上传的文件
|
||
const uploadedFiles = ref<Map<string, boolean>>(new Map()) // 文件哈希 -> 是否已上传
|
||
const uploadingFiles = ref<Set<string>>(new Set()) // 正在上传的文件哈希
|
||
|
||
// 计算文件SHA256哈希值
|
||
const calculateFileHash = async (file: File): Promise<string> => {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader()
|
||
reader.onload = (e) => {
|
||
try {
|
||
const arrayBuffer = e.target?.result as ArrayBuffer
|
||
crypto.subtle.digest('SHA-256', arrayBuffer).then(hashBuffer => {
|
||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||
resolve(hashHex)
|
||
}).catch(reject)
|
||
} catch (error) {
|
||
reject(error)
|
||
}
|
||
}
|
||
reader.onerror = reject
|
||
reader.readAsArrayBuffer(file)
|
||
})
|
||
}
|
||
|
||
// 生成文件哈希值(基于文件名、大小和修改时间,用于前端去重)
|
||
const generateFileHash = (file: File): string => {
|
||
return `${file.name}_${file.size}_${file.lastModified}`
|
||
}
|
||
|
||
// 检查文件是否已经上传过
|
||
const isFileAlreadyUploaded = (file: File): boolean => {
|
||
const fileHash = generateFileHash(file)
|
||
return uploadedFiles.value.has(fileHash)
|
||
}
|
||
|
||
// 检查文件是否正在上传
|
||
const isFileUploading = (file: File): boolean => {
|
||
const fileHash = generateFileHash(file)
|
||
return uploadingFiles.value.has(fileHash)
|
||
}
|
||
|
||
// 标记文件为已上传
|
||
const markFileAsUploaded = (file: File) => {
|
||
const fileHash = generateFileHash(file)
|
||
uploadedFiles.value.set(fileHash, true)
|
||
uploadingFiles.value.delete(fileHash)
|
||
}
|
||
|
||
// 标记文件为正在上传
|
||
const markFileAsUploading = (file: File) => {
|
||
const fileHash = generateFileHash(file)
|
||
uploadingFiles.value.add(fileHash)
|
||
}
|
||
|
||
// 标记文件上传失败
|
||
const markFileAsFailed = (file: File) => {
|
||
const fileHash = generateFileHash(file)
|
||
uploadingFiles.value.delete(fileHash)
|
||
}
|
||
|
||
// 自定义上传请求
|
||
const customRequest = async (options: any) => {
|
||
const { file, onProgress, onSuccess, onError } = options
|
||
|
||
// 检查文件是否已经上传过
|
||
if (isFileAlreadyUploaded(file.file)) {
|
||
message.warning(`${file.name} 已经上传过了,跳过重复上传`)
|
||
if (onSuccess) {
|
||
onSuccess({ message: '文件已存在,跳过上传' })
|
||
}
|
||
return
|
||
}
|
||
|
||
// 检查文件是否正在上传
|
||
if (isFileUploading(file.file)) {
|
||
message.warning(`${file.name} 正在上传中,请稍候`)
|
||
return
|
||
}
|
||
|
||
// 标记文件为正在上传
|
||
markFileAsUploading(file.file)
|
||
|
||
console.log('开始上传文件:', file.name, file.file)
|
||
|
||
try {
|
||
// 计算文件哈希值
|
||
const fileHash = await calculateFileHash(file.file)
|
||
console.log('文件哈希值:', fileHash)
|
||
|
||
// 创建FormData
|
||
const formData = new FormData()
|
||
formData.append('file', file.file)
|
||
formData.append('is_public', isPublic.value.toString())
|
||
formData.append('file_hash', fileHash)
|
||
|
||
// 调用统一的API接口
|
||
const response = await fileApi.uploadFile(formData)
|
||
|
||
console.log('文件上传成功:', file.name, response)
|
||
|
||
// 标记文件为已上传
|
||
markFileAsUploaded(file.file)
|
||
|
||
// 检查是否为重复文件
|
||
if (response.data && response.data.is_duplicate) {
|
||
message.success(`${file.name} 极速上传成功(文件已存在)`)
|
||
} else {
|
||
message.success(`${file.name} 上传成功`)
|
||
}
|
||
|
||
if (onSuccess) {
|
||
onSuccess(response)
|
||
}
|
||
} catch (error) {
|
||
console.error('文件上传失败:', file.name, error)
|
||
// 标记文件上传失败
|
||
markFileAsFailed(file.file)
|
||
if (onError) {
|
||
onError(error)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 默认文件列表(从props传入)
|
||
const defaultFileList = ref<UploadFileInfo[]>([])
|
||
|
||
// 方法
|
||
const handleBeforeUpload = (data: { file: Required<UploadFileInfo> }) => {
|
||
const { file } = data
|
||
|
||
// 检查文件是否已经上传过
|
||
if (file.file && isFileAlreadyUploaded(file.file)) {
|
||
//message.warning(`${file.name} 已经上传过了,请勿重复上传`)
|
||
return false
|
||
}
|
||
|
||
// 检查文件是否正在上传
|
||
if (file.file && isFileUploading(file.file)) {
|
||
message.warning(`${file.name} 正在上传中,请稍候`)
|
||
return false
|
||
}
|
||
|
||
// 检查文件大小
|
||
if (file.file && file.file.size > maxFileSize.value) {
|
||
message.error(`文件大小不能超过 ${formatFileSize(maxFileSize.value)}`)
|
||
return false
|
||
}
|
||
|
||
// 检查文件类型
|
||
if (file.file) {
|
||
const fileName = file.file.name.toLowerCase()
|
||
const acceptedTypes = acceptTypes.value.split(',')
|
||
const isAccepted = acceptedTypes.some(type => {
|
||
if (type === 'image/*') {
|
||
return file.file!.type.startsWith('image/')
|
||
}
|
||
if (type.startsWith('.')) {
|
||
return fileName.endsWith(type)
|
||
}
|
||
return file.file!.type === type
|
||
})
|
||
|
||
if (!isAccepted) {
|
||
message.error('只支持图片格式文件')
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
const handleUploadFinish = (data: { file: Required<UploadFileInfo> }) => {
|
||
const { file } = data
|
||
|
||
if (file.status === 'finished') {
|
||
// 确保文件被标记为已上传
|
||
if (file.file) {
|
||
markFileAsUploaded(file.file)
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleUploadError = (data: { file: Required<UploadFileInfo> }) => {
|
||
const { file } = data
|
||
message.error(`${file.name} 上传失败`)
|
||
// 标记文件上传失败
|
||
if (file.file) {
|
||
markFileAsFailed(file.file)
|
||
}
|
||
}
|
||
|
||
const handleFileRemove = (data: { file: Required<UploadFileInfo> }) => {
|
||
const { file } = data
|
||
message.info(`已移除 ${file.name}`)
|
||
// 从上传状态中移除文件
|
||
if (file.file) {
|
||
const fileHash = generateFileHash(file.file)
|
||
uploadingFiles.value.delete(fileHash)
|
||
}
|
||
}
|
||
|
||
const loadFileList = async () => {
|
||
try {
|
||
const response = await fileApi.getFileList({
|
||
page: 1,
|
||
page_size: 50
|
||
})
|
||
fileList.value = response.data.files || []
|
||
} catch (error) {
|
||
console.error('加载文件列表失败:', error)
|
||
message.error('加载文件列表失败')
|
||
}
|
||
}
|
||
|
||
const copyFileUrl = async (file: FileItem) => {
|
||
try {
|
||
await navigator.clipboard.writeText(file.access_url)
|
||
message.success('文件链接已复制到剪贴板')
|
||
} catch (error) {
|
||
console.error('复制失败:', error)
|
||
message.error('复制失败')
|
||
}
|
||
}
|
||
|
||
const openFile = (file: FileItem) => {
|
||
window.open(file.access_url, '_blank')
|
||
}
|
||
|
||
const deleteFile = async (file: FileItem) => {
|
||
try {
|
||
await fileApi.deleteFiles([file.id])
|
||
message.success('文件删除成功')
|
||
loadFileList()
|
||
} catch (error) {
|
||
console.error('删除文件失败:', error)
|
||
message.error('删除文件失败')
|
||
}
|
||
}
|
||
|
||
const getFileIconClass = (fileType: string) => {
|
||
const iconMap: Record<string, string> = {
|
||
'image': 'fas fa-image text-blue-500',
|
||
'document': 'fas fa-file-alt text-green-500',
|
||
'video': 'fas fa-video text-red-500',
|
||
'audio': 'fas fa-music text-purple-500',
|
||
'archive': 'fas fa-archive text-orange-500',
|
||
'other': 'fas fa-file text-gray-500'
|
||
}
|
||
return iconMap[fileType] || iconMap.other
|
||
}
|
||
|
||
const getFileDescription = (file: FileItem) => {
|
||
return `${formatFileSize(file.file_size)} | ${file.file_type} | ${file.created_at}`
|
||
}
|
||
|
||
const formatFileSize = (bytes: number) => {
|
||
if (bytes === 0) return '0 B'
|
||
const k = 1024
|
||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||
}
|
||
|
||
// 生命周期
|
||
// 不在组件挂载时加载文件列表,由父组件管理
|
||
|
||
// 重置上传组件状态
|
||
const resetUpload = () => {
|
||
if (uploadRef.value) {
|
||
uploadRef.value.clear()
|
||
}
|
||
// 清空上传状态
|
||
uploadedFiles.value.clear()
|
||
uploadingFiles.value.clear()
|
||
}
|
||
|
||
// 清空已上传文件状态(用于重新开始上传)
|
||
const clearUploadedFiles = () => {
|
||
uploadedFiles.value.clear()
|
||
uploadingFiles.value.clear()
|
||
}
|
||
|
||
// 暴露方法给父组件
|
||
defineExpose({
|
||
loadFileList,
|
||
fileList,
|
||
resetUpload,
|
||
clearUploadedFiles,
|
||
uploadedFiles,
|
||
uploadingFiles
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
</style> |