mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 13:47:15 +08:00
feat: 文件分块上传
This commit is contained in:
@@ -10,7 +10,8 @@ export default {
|
||||
// 删除文件
|
||||
delete: (path: string): any => http.Post('/file/delete', { path }),
|
||||
// 上传文件
|
||||
upload: (formData: FormData): any => http.Post('/file/upload', formData),
|
||||
upload: (formData: FormData): any =>
|
||||
http.Post('/file/upload', formData, { meta: { noAlert: true } }),
|
||||
// 检查文件是否存在
|
||||
exist: (paths: string[]): any => http.Post('/file/exist', paths),
|
||||
// 移动文件
|
||||
@@ -40,5 +41,22 @@ export default {
|
||||
sort: string,
|
||||
page: number,
|
||||
limit: number
|
||||
): any => http.Get('/file/list', { params: { path, keyword, sub, sort, page, limit } })
|
||||
): any => http.Get('/file/list', { params: { path, keyword, sub, sort, page, limit } }),
|
||||
// 分块上传开始
|
||||
chunkStart: (data: {
|
||||
path: string
|
||||
file_name: string
|
||||
file_hash: string
|
||||
chunk_count: number
|
||||
}): any => http.Post('/file/chunk/start', data),
|
||||
// 上传分块
|
||||
chunkUpload: (formData: FormData): any =>
|
||||
http.Post('/file/chunk/upload', formData, { meta: { noAlert: true } }),
|
||||
// 完成分块上传
|
||||
chunkFinish: (data: {
|
||||
path: string
|
||||
file_name: string
|
||||
file_hash: string
|
||||
chunk_count: number
|
||||
}): any => http.Post('/file/chunk/finish', data)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { sha256 } from 'js-sha256'
|
||||
import type { UploadCustomRequestOptions, UploadFileInfo, UploadInst } from 'naive-ui'
|
||||
import pLimit from 'p-limit'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
import api from '@/api/panel/file'
|
||||
@@ -17,6 +19,304 @@ const fileList = ref<UploadFileInfo[]>([])
|
||||
|
||||
// 文件数量阈值,超过此数量需要二次确认
|
||||
const FILE_COUNT_THRESHOLD = 100
|
||||
// 大文件阈值,超过此大小使用分块上传 (100MB)
|
||||
const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024
|
||||
// 分块大小 (5MB)
|
||||
const CHUNK_SIZE = 5 * 1024 * 1024
|
||||
// 分块上传重试次数
|
||||
const CHUNK_RETRY_COUNT = 10
|
||||
// 并发上传数
|
||||
const CONCURRENT_UPLOADS = 3
|
||||
|
||||
// 上传速度状态(每个文件独立)
|
||||
interface UploadProgress {
|
||||
fileName: string
|
||||
speed: string
|
||||
}
|
||||
const uploadProgressMap = ref<Map<string, UploadProgress>>(new Map())
|
||||
|
||||
// 每个上传任务的状态
|
||||
interface UploadTask {
|
||||
isCancelled: boolean
|
||||
activeRequests: { abort: () => void }[]
|
||||
}
|
||||
|
||||
// 以文件唯一标识为 key 存储每个上传任务的状态
|
||||
const uploadTasks = new Map<string, UploadTask>()
|
||||
|
||||
// 获取文件唯一标识
|
||||
const getFileKey = (file: File) => `${file.name}-${file.size}-${file.lastModified}`
|
||||
|
||||
// 取消单个文件的上传
|
||||
const cancelUpload = (file: File) => {
|
||||
const fileKey = getFileKey(file)
|
||||
const task = uploadTasks.get(fileKey)
|
||||
if (task) {
|
||||
task.isCancelled = true
|
||||
task.activeRequests.forEach((req) => req.abort())
|
||||
uploadTasks.delete(fileKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 取消所有上传
|
||||
const cancelAllUploads = () => {
|
||||
uploadTasks.forEach((task) => {
|
||||
task.isCancelled = true
|
||||
task.activeRequests.forEach((req) => req.abort())
|
||||
})
|
||||
uploadTasks.clear()
|
||||
}
|
||||
|
||||
// 计算文件标识符(快速,用于断点续传识别)
|
||||
// 使用文件元数据 + 首尾采样计算,避免读取整个大文件
|
||||
const calculateFileIdentifier = async (file: File): Promise<string> => {
|
||||
const sampleSize = 1024 * 1024 // 1MB samples
|
||||
|
||||
// 读取首部
|
||||
const headChunk = file.slice(0, Math.min(sampleSize, file.size))
|
||||
const headBuffer = await headChunk.arrayBuffer()
|
||||
|
||||
// 读取尾部(如果文件足够大)
|
||||
let tailBuffer: ArrayBuffer
|
||||
if (file.size > sampleSize * 2) {
|
||||
const tailChunk = file.slice(file.size - sampleSize, file.size)
|
||||
tailBuffer = await tailChunk.arrayBuffer()
|
||||
} else {
|
||||
tailBuffer = headBuffer
|
||||
}
|
||||
|
||||
// 组合元数据
|
||||
const metadata = `${file.name}|${file.size}|${file.lastModified}`
|
||||
const metaBuffer = new TextEncoder().encode(metadata)
|
||||
|
||||
// 合并所有数据计算hash
|
||||
const combined = new Uint8Array(
|
||||
metaBuffer.byteLength + headBuffer.byteLength + tailBuffer.byteLength
|
||||
)
|
||||
combined.set(new Uint8Array(metaBuffer), 0)
|
||||
combined.set(new Uint8Array(headBuffer), metaBuffer.byteLength)
|
||||
combined.set(new Uint8Array(tailBuffer), metaBuffer.byteLength + headBuffer.byteLength)
|
||||
|
||||
return sha256(combined)
|
||||
}
|
||||
|
||||
// 计算分块SHA256
|
||||
const calculateChunkHash = async (chunk: Blob): Promise<string> => {
|
||||
const buffer = await chunk.arrayBuffer()
|
||||
return sha256(new Uint8Array(buffer))
|
||||
}
|
||||
|
||||
// 格式化速度显示
|
||||
const formatSpeed = (bytesPerSecond: number): string => {
|
||||
if (bytesPerSecond < 1024) {
|
||||
return `${bytesPerSecond.toFixed(0)} B/s`
|
||||
} else if (bytesPerSecond < 1024 * 1024) {
|
||||
return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`
|
||||
} else if (bytesPerSecond < 1024 * 1024 * 1024) {
|
||||
return `${(bytesPerSecond / 1024 / 1024).toFixed(1)} MB/s`
|
||||
} else {
|
||||
return `${(bytesPerSecond / 1024 / 1024 / 1024).toFixed(2)} GB/s`
|
||||
}
|
||||
}
|
||||
|
||||
// 带重试的分块上传
|
||||
const uploadChunkWithRetry = async (
|
||||
formData: FormData,
|
||||
chunkIndex: number,
|
||||
chunkSize: number,
|
||||
onChunkComplete: (size: number) => void,
|
||||
task: UploadTask
|
||||
): Promise<void> => {
|
||||
let lastError: Error | null = null
|
||||
for (let attempt = 1; attempt <= CHUNK_RETRY_COUNT; attempt++) {
|
||||
// 检查是否已取消
|
||||
if (task.isCancelled) {
|
||||
throw new DOMException('Upload cancelled', 'AbortError')
|
||||
}
|
||||
try {
|
||||
const method = api.chunkUpload(formData)
|
||||
task.activeRequests.push(method)
|
||||
try {
|
||||
await method
|
||||
onChunkComplete(chunkSize)
|
||||
return
|
||||
} finally {
|
||||
// 从活跃请求列表中移除
|
||||
const index = task.activeRequests.indexOf(method)
|
||||
if (index > -1) {
|
||||
task.activeRequests.splice(index, 1)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果是取消错误,直接抛出
|
||||
if (task.isCancelled || (error as Error).message?.includes('abort')) {
|
||||
throw new DOMException('Upload cancelled', 'AbortError')
|
||||
}
|
||||
lastError = error as Error
|
||||
console.warn(
|
||||
`Chunk ${chunkIndex} upload failed (attempt ${attempt}/${CHUNK_RETRY_COUNT}):`,
|
||||
error
|
||||
)
|
||||
if (attempt < CHUNK_RETRY_COUNT) {
|
||||
// 等待一段时间后重试,指数退避
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, Math.min(1000 * Math.pow(2, attempt - 1), 10000))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Chunk ${chunkIndex} upload failed after ${CHUNK_RETRY_COUNT} attempts: ${lastError?.message}`
|
||||
)
|
||||
}
|
||||
|
||||
// 分块上传
|
||||
const chunkedUpload = async (
|
||||
file: File,
|
||||
onProgress: (e: { percent: number }) => void,
|
||||
onFinish: () => void,
|
||||
onError: () => void
|
||||
) => {
|
||||
// 创建此文件的上传任务
|
||||
const fileKey = getFileKey(file)
|
||||
const task: UploadTask = { isCancelled: false, activeRequests: [] }
|
||||
uploadTasks.set(fileKey, task)
|
||||
|
||||
// 初始化进度显示
|
||||
uploadProgressMap.value.set(fileKey, { fileName: file.name, speed: '' })
|
||||
|
||||
try {
|
||||
// 计算文件标识符(快速)
|
||||
onProgress({ percent: 0 })
|
||||
const fileHash = await calculateFileIdentifier(file)
|
||||
|
||||
// 检查是否已取消
|
||||
if (task.isCancelled) {
|
||||
throw new DOMException('Upload cancelled', 'AbortError')
|
||||
}
|
||||
|
||||
// 计算分块数量
|
||||
const chunkCount = Math.ceil(file.size / CHUNK_SIZE)
|
||||
|
||||
// 开始分块上传(查询已上传的分块)
|
||||
const startMethod = api.chunkStart({
|
||||
path: path.value,
|
||||
file_name: file.name,
|
||||
file_hash: fileHash,
|
||||
chunk_count: chunkCount
|
||||
})
|
||||
task.activeRequests.push(startMethod)
|
||||
const startRes = await startMethod
|
||||
task.activeRequests = task.activeRequests.filter((r) => r !== startMethod)
|
||||
|
||||
const uploadedChunks: Set<number> = new Set(startRes.uploaded_chunks)
|
||||
|
||||
// 速度计算相关
|
||||
let uploadedBytes = uploadedChunks.size * CHUNK_SIZE
|
||||
let lastTime = Date.now()
|
||||
let lastBytes = uploadedBytes
|
||||
|
||||
// 更新进度和速度
|
||||
const updateProgress = () => {
|
||||
const now = Date.now()
|
||||
const timeDiff = (now - lastTime) / 1000 // 秒
|
||||
if (timeDiff >= 0.5) {
|
||||
// 每0.5秒更新一次速度
|
||||
const bytesDiff = uploadedBytes - lastBytes
|
||||
const speed = bytesDiff / timeDiff
|
||||
uploadProgressMap.value.set(fileKey, { fileName: file.name, speed: formatSpeed(speed) })
|
||||
lastTime = now
|
||||
lastBytes = uploadedBytes
|
||||
}
|
||||
const percent = Math.ceil((uploadedBytes / file.size) * 100)
|
||||
onProgress({ percent: Math.min(percent, 99) }) // 最多99%,留1%给finish
|
||||
}
|
||||
|
||||
// 分块完成回调
|
||||
const onChunkComplete = (size: number) => {
|
||||
uploadedBytes += size
|
||||
updateProgress()
|
||||
}
|
||||
|
||||
// 构建待上传分块列表
|
||||
const pendingChunks: number[] = []
|
||||
for (let i = 0; i < chunkCount; i++) {
|
||||
if (!uploadedChunks.has(i)) {
|
||||
pendingChunks.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
// 并发上传单个分块
|
||||
const uploadChunk = async (chunkIndex: number) => {
|
||||
// 检查是否已取消
|
||||
if (task.isCancelled) {
|
||||
throw new DOMException('Upload cancelled', 'AbortError')
|
||||
}
|
||||
|
||||
const start = chunkIndex * CHUNK_SIZE
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size)
|
||||
const chunk = file.slice(start, end)
|
||||
const chunkSize = end - start
|
||||
|
||||
// 计算分块hash
|
||||
const chunkHash = await calculateChunkHash(chunk)
|
||||
|
||||
// 上传分块
|
||||
const formData = new FormData()
|
||||
formData.append('path', path.value)
|
||||
formData.append('file_name', file.name)
|
||||
formData.append('file_hash', fileHash)
|
||||
formData.append('chunk_index', chunkIndex.toString())
|
||||
formData.append('chunk_hash', chunkHash)
|
||||
formData.append('file', chunk)
|
||||
|
||||
await uploadChunkWithRetry(formData, chunkIndex, chunkSize, onChunkComplete, task)
|
||||
}
|
||||
|
||||
// 控制并发
|
||||
const limit = pLimit(CONCURRENT_UPLOADS)
|
||||
await Promise.all(pendingChunks.map((chunkIndex) => limit(() => uploadChunk(chunkIndex))))
|
||||
|
||||
// 检查是否已取消
|
||||
if (task.isCancelled) {
|
||||
throw new DOMException('Upload cancelled', 'AbortError')
|
||||
}
|
||||
|
||||
// 完成分块上传(合并)
|
||||
const finishMethod = api.chunkFinish({
|
||||
path: path.value,
|
||||
file_name: file.name,
|
||||
file_hash: fileHash,
|
||||
chunk_count: chunkCount
|
||||
})
|
||||
task.activeRequests.push(finishMethod)
|
||||
await finishMethod
|
||||
task.activeRequests = task.activeRequests.filter((r) => r !== finishMethod)
|
||||
|
||||
onProgress({ percent: 100 })
|
||||
uploadProgressMap.value.delete(fileKey)
|
||||
uploadTasks.delete(fileKey)
|
||||
if (!task.isCancelled) {
|
||||
onFinish()
|
||||
window.$message.success($gettext('Upload %{ fileName } successful', { fileName: file.name }))
|
||||
}
|
||||
window.$bus.emit('file:refresh')
|
||||
} catch (error) {
|
||||
uploadProgressMap.value.delete(fileKey)
|
||||
uploadTasks.delete(fileKey)
|
||||
|
||||
// 如果是取消错误,静默处理
|
||||
if ((error as Error).name === 'AbortError' || task.isCancelled) {
|
||||
console.log('Upload cancelled by user')
|
||||
return
|
||||
}
|
||||
|
||||
console.error('Chunked upload error:', error)
|
||||
if (!task.isCancelled) {
|
||||
onError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听预拖入的文件
|
||||
watch(
|
||||
@@ -50,13 +350,12 @@ watch(
|
||||
|
||||
// 将文件添加到上传列表
|
||||
const addFilesToList = (files: File[], autoUpload: boolean = false) => {
|
||||
const newFiles: UploadFileInfo[] = files.map((file, index) => ({
|
||||
fileList.value = files.map((file, index) => ({
|
||||
id: `dropped-${Date.now()}-${index}`,
|
||||
name: file.name,
|
||||
status: 'pending' as const,
|
||||
file: file
|
||||
}))
|
||||
fileList.value = newFiles
|
||||
|
||||
// 自动开始上传
|
||||
if (autoUpload) {
|
||||
@@ -66,34 +365,67 @@ const addFilesToList = (files: File[], autoUpload: boolean = false) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 监听弹窗关闭,清空文件列表
|
||||
// 监听弹窗关闭,清空文件列表并取消上传
|
||||
watch(show, (val) => {
|
||||
if (!val) {
|
||||
cancelAllUploads()
|
||||
fileList.value = []
|
||||
}
|
||||
})
|
||||
|
||||
const uploadRequest = ({ file, onFinish, onError, onProgress }: UploadCustomRequestOptions) => {
|
||||
const fileObj = file.file as File
|
||||
|
||||
// 大文件使用分块上传
|
||||
if (fileObj.size > LARGE_FILE_THRESHOLD) {
|
||||
chunkedUpload(fileObj, onProgress, onFinish, onError)
|
||||
return
|
||||
}
|
||||
|
||||
// 小文件使用普通上传
|
||||
const fileKey = getFileKey(fileObj)
|
||||
const task: UploadTask = { isCancelled: false, activeRequests: [] }
|
||||
uploadTasks.set(fileKey, task)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('path', `${path.value}/${file.name}`)
|
||||
formData.append('file', file.file as File)
|
||||
const { uploading } = useRequest(api.upload(formData))
|
||||
formData.append('file', fileObj)
|
||||
|
||||
const method = api.upload(formData)
|
||||
task.activeRequests.push(method)
|
||||
|
||||
const { uploading } = useRequest(method)
|
||||
.onSuccess(() => {
|
||||
onFinish()
|
||||
window.$bus.emit('file:refresh')
|
||||
window.$message.success($gettext('Upload %{ fileName } successful', { fileName: file.name }))
|
||||
uploadTasks.delete(fileKey)
|
||||
if (!task.isCancelled) {
|
||||
onFinish()
|
||||
window.$bus.emit('file:refresh')
|
||||
window.$message.success($gettext('Upload %{ fileName } successful', { fileName: file.name }))
|
||||
}
|
||||
})
|
||||
.onError(() => {
|
||||
onError()
|
||||
uploadTasks.delete(fileKey)
|
||||
if (!task.isCancelled) {
|
||||
onError()
|
||||
}
|
||||
})
|
||||
.onComplete(() => {
|
||||
stopWatch()
|
||||
})
|
||||
const stopWatch = watch(uploading, (progress) => {
|
||||
onProgress({ percent: Math.ceil((progress.loaded / progress.total) * 100) })
|
||||
if (!task.isCancelled) {
|
||||
onProgress({ percent: Math.ceil((progress.loaded / progress.total) * 100) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 处理文件移除(取消上传)
|
||||
const handleRemove = ({ file }: { file: UploadFileInfo }) => {
|
||||
if (file.file) {
|
||||
cancelUpload(file.file)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件选择变化(用于文件数量确认)
|
||||
const handleChange = (data: { fileList: UploadFileInfo[] }) => {
|
||||
const newFiles = data.fileList.filter(
|
||||
@@ -131,6 +463,17 @@ const handleChange = (data: { fileList: UploadFileInfo[] }) => {
|
||||
:segmented="false"
|
||||
>
|
||||
<n-flex vertical>
|
||||
<!-- 上传速度显示 -->
|
||||
<n-flex
|
||||
v-for="[key, progress] in uploadProgressMap"
|
||||
:key="key"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
class="upload-speed-bar"
|
||||
>
|
||||
<NText>{{ progress.fileName }}</NText>
|
||||
<NText type="success">{{ progress.speed || $gettext('Preparing...') }}</NText>
|
||||
</n-flex>
|
||||
<n-upload
|
||||
ref="upload"
|
||||
v-model:file-list="fileList"
|
||||
@@ -138,6 +481,7 @@ const handleChange = (data: { fileList: UploadFileInfo[] }) => {
|
||||
directory-dnd
|
||||
:custom-request="uploadRequest"
|
||||
@change="handleChange"
|
||||
@remove="handleRemove"
|
||||
>
|
||||
<n-upload-dragger>
|
||||
<div style="margin-bottom: 12px">
|
||||
@@ -155,4 +499,11 @@ const handleChange = (data: { fileList: UploadFileInfo[] }) => {
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
<style scoped lang="scss">
|
||||
.upload-speed-bar {
|
||||
padding: 8px 12px;
|
||||
background: var(--n-color-embedded);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user