mirror of
https://github.com/tnb-labs/panel.git
synced 2026-09-21 13:20:10 +08:00
refactor: 精简上传队列与分块上传实现
后端:上传接口改走 Bind + request 结构体复用既有校验规则,分块请求公共字段内嵌复用 前端:进度按定时器批量刷新避免高频重渲染,拖拽/选择文件/节流/定时改用 vueuse 现成能力,收敛重复的状态判定与冲突处理分支 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+30
-18
@@ -1,6 +1,7 @@
|
||||
package request
|
||||
|
||||
import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
|
||||
"github.com/spf13/cast"
|
||||
@@ -90,28 +91,39 @@ type FileShareToken struct {
|
||||
Token string `json:"token" form:"token" uri:"token" validate:"required"`
|
||||
}
|
||||
|
||||
// FileUpload 上传文件请求
|
||||
type FileUpload struct {
|
||||
Path string `form:"path" validate:"required && unix_path"` // 目标路径
|
||||
Force bool `form:"force"` // 是否覆盖已存在文件
|
||||
File *multipart.FileHeader `form:"file" validate:"required"` // 文件
|
||||
}
|
||||
|
||||
// ChunkUploadFile 分块上传的文件标识,各接口共用
|
||||
type ChunkUploadFile struct {
|
||||
Path string `json:"path" form:"path" validate:"required && unix_path"` // 目标目录
|
||||
FileName string `json:"file_name" form:"file_name" validate:"required"` // 文件名
|
||||
FileHash string `json:"file_hash" form:"file_hash" validate:"required && len:64"` // 文件SHA256
|
||||
}
|
||||
|
||||
// ChunkUploadStart 分块上传开始请求
|
||||
type ChunkUploadStart struct {
|
||||
Path string `json:"path" validate:"required && unix_path"` // 目标目录
|
||||
FileName string `json:"file_name" validate:"required"` // 文件名
|
||||
FileHash string `json:"file_hash" validate:"required && len:64"` // 文件SHA256
|
||||
ChunkCount int `json:"chunk_count" validate:"required && min:1"` // 分块总数
|
||||
ChunkSize int `json:"chunk_size" validate:"required && min:1"` // 分块大小(字节)
|
||||
Force bool `json:"force"` // 是否覆盖已存在文件
|
||||
ChunkUploadFile
|
||||
ChunkCount int `json:"chunk_count" validate:"required && min:1"` // 分块总数
|
||||
ChunkSize int `json:"chunk_size" validate:"required && min:1"` // 分块大小(字节)
|
||||
Force bool `json:"force"` // 是否覆盖已存在文件
|
||||
}
|
||||
|
||||
// ChunkUpload 上传分块请求
|
||||
type ChunkUpload struct {
|
||||
ChunkUploadFile
|
||||
ChunkIndex int `form:"chunk_index" validate:"min:0"` // 分块下标
|
||||
ChunkHash string `form:"chunk_hash"` // 分块SHA256,为空则不校验
|
||||
File *multipart.FileHeader `form:"file" validate:"required"` // 分块数据
|
||||
}
|
||||
|
||||
// ChunkUploadFinish 分块上传完成请求
|
||||
type ChunkUploadFinish struct {
|
||||
Path string `json:"path" validate:"required && unix_path"` // 目标目录
|
||||
FileName string `json:"file_name" validate:"required"` // 文件名
|
||||
FileHash string `json:"file_hash" validate:"required && len:64"` // 文件SHA256
|
||||
ChunkCount int `json:"chunk_count" validate:"required && min:1"` // 分块总数
|
||||
Force bool `json:"force"` // 是否覆盖已存在文件
|
||||
}
|
||||
|
||||
// ChunkUploadCancel 取消分块上传请求
|
||||
type ChunkUploadCancel struct {
|
||||
Path string `json:"path" validate:"required && unix_path"` // 目标目录
|
||||
FileName string `json:"file_name" validate:"required"` // 文件名
|
||||
FileHash string `json:"file_hash" validate:"required && len:64"` // 文件SHA256
|
||||
ChunkUploadFile
|
||||
ChunkCount int `json:"chunk_count" validate:"required && min:1"` // 分块总数
|
||||
Force bool `json:"force"` // 是否覆盖已存在文件
|
||||
}
|
||||
|
||||
@@ -72,6 +72,6 @@ func FileRoutes(fileService *service.FileService) Endpoints {
|
||||
Document: DescribeReq[request.ChunkUploadFinish]()},
|
||||
{Method: http.MethodPost, Path: "/api/file/chunk/cancel", Handler: file.ChunkUploadCancel,
|
||||
Summary: "取消分块上传", Tags: []string{"文件"},
|
||||
Document: DescribeReq[request.ChunkUploadCancel]()},
|
||||
Document: DescribeReq[request.ChunkUploadFile]()},
|
||||
}
|
||||
}
|
||||
|
||||
+35
-53
@@ -294,50 +294,46 @@ func (s *FileService) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *FileService) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
req, err := Bind[request.FileUpload](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
path := r.FormValue("path")
|
||||
force := r.FormValue("force") == "true"
|
||||
if !filepath.IsAbs(path) {
|
||||
Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid path %s", path))
|
||||
return
|
||||
}
|
||||
_, handler, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, s.t.Get("upload file error: %v", err))
|
||||
return
|
||||
}
|
||||
if io.Exists(path) && !force {
|
||||
Error(w, http.StatusForbidden, s.t.Get("target path %s already exists", path))
|
||||
if io.Exists(req.Path) && !req.Force {
|
||||
Error(w, http.StatusForbidden, s.t.Get("target path %s already exists", req.Path))
|
||||
return
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
dir := filepath.Dir(req.Path)
|
||||
if err = stdos.MkdirAll(dir, 0755); err != nil {
|
||||
Error(w, http.StatusInternalServerError, s.t.Get("create directory error: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 先写同目录临时文件再 rename 替换,中途失败不留半截文件
|
||||
tmp, err := stdos.CreateTemp(dir, "."+filepath.Base(path)+".*.part")
|
||||
// 先写同目录临时文件再 rename 替换,中途失败不留半截文件;替换成功后临时文件已不存在,Remove 无害
|
||||
tmp, err := stdos.CreateTemp(dir, "."+filepath.Base(req.Path)+".*.part")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, s.t.Get("open file error: %v", err))
|
||||
return
|
||||
}
|
||||
src, err := handler.Open()
|
||||
if err == nil {
|
||||
_, err = stdio.Copy(tmp, src)
|
||||
_ = src.Close()
|
||||
}
|
||||
_ = tmp.Close()
|
||||
if err == nil {
|
||||
err = s.replaceFile(tmp.Name(), path)
|
||||
}
|
||||
if err != nil {
|
||||
defer func() {
|
||||
_ = tmp.Close()
|
||||
_ = stdos.Remove(tmp.Name())
|
||||
}()
|
||||
|
||||
src, err := req.File.Open()
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, s.t.Get("upload file error: %v", err))
|
||||
return
|
||||
}
|
||||
_, err = stdio.Copy(tmp, src)
|
||||
_ = src.Close()
|
||||
_ = tmp.Close()
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, s.t.Get("write file error: %v", err))
|
||||
return
|
||||
}
|
||||
if err = s.replaceFile(tmp.Name(), req.Path); err != nil {
|
||||
Error(w, http.StatusInternalServerError, s.t.Get("write file error: %v", err))
|
||||
return
|
||||
}
|
||||
@@ -787,23 +783,14 @@ func (s *FileService) ChunkUploadStart(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ChunkUploadChunk 上传单个分块,流式写入数据文件的对应偏移
|
||||
func (s *FileService) ChunkUploadChunk(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
req, err := Bind[request.ChunkUpload](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
path := r.FormValue("path")
|
||||
fileName := r.FormValue("file_name")
|
||||
fileHash := r.FormValue("file_hash")
|
||||
chunkHash := r.FormValue("chunk_hash")
|
||||
chunkIndex, err := strconv.Atoi(r.FormValue("chunk_index"))
|
||||
if !filepath.IsAbs(path) || fileName == "" || strings.Contains(fileName, "/") || len(fileHash) != 64 || err != nil || chunkIndex < 0 {
|
||||
Error(w, http.StatusBadRequest, s.t.Get("invalid chunk upload parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
// 位图由 start 创建,同时提供分块总数和分块大小
|
||||
part, mapPath := s.chunkTempPaths(path, fileName, fileHash)
|
||||
part, mapPath := s.chunkTempPaths(req.Path, req.FileName, req.FileHash)
|
||||
bitmap, err := stdos.ReadFile(mapPath)
|
||||
if err != nil || len(bitmap) <= chunkMapHeader || binary.LittleEndian.Uint64(bitmap) == 0 {
|
||||
Error(w, http.StatusBadRequest, s.t.Get("chunk upload not started"))
|
||||
@@ -811,17 +798,12 @@ func (s *FileService) ChunkUploadChunk(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
chunkCount := len(bitmap) - chunkMapHeader
|
||||
chunkSize := int64(binary.LittleEndian.Uint64(bitmap))
|
||||
if chunkIndex >= chunkCount {
|
||||
if req.ChunkIndex >= chunkCount {
|
||||
Error(w, http.StatusBadRequest, s.t.Get("chunk index out of range"))
|
||||
return
|
||||
}
|
||||
|
||||
_, handler, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, s.t.Get("get upload file error: %v", err))
|
||||
return
|
||||
}
|
||||
src, err := handler.Open()
|
||||
src, err := req.File.Open()
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, s.t.Get("open upload file error: %v", err))
|
||||
return
|
||||
@@ -837,7 +819,7 @@ func (s *FileService) ChunkUploadChunk(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 边写边算 hash;LimitReader 保证不会写到本块范围之外
|
||||
hasher := sha256.New()
|
||||
writer := stdio.MultiWriter(stdio.NewOffsetWriter(file, int64(chunkIndex)*chunkSize), hasher)
|
||||
writer := stdio.MultiWriter(stdio.NewOffsetWriter(file, int64(req.ChunkIndex)*chunkSize), hasher)
|
||||
n, err := stdio.Copy(writer, stdio.LimitReader(src, chunkSize))
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, s.t.Get("save chunk error: %v", err))
|
||||
@@ -846,11 +828,11 @@ func (s *FileService) ChunkUploadChunk(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 非末块必须是整块,末块不能超过分块大小,有多余数据说明分块大小对不上
|
||||
extra := make([]byte, 1)
|
||||
if m, _ := src.Read(extra); m > 0 || n == 0 || (chunkIndex < chunkCount-1 && n != chunkSize) {
|
||||
if m, _ := src.Read(extra); m > 0 || n == 0 || (req.ChunkIndex < chunkCount-1 && n != chunkSize) {
|
||||
Error(w, http.StatusBadRequest, s.t.Get("chunk size mismatch"))
|
||||
return
|
||||
}
|
||||
if chunkHash != "" && !strings.EqualFold(hex.EncodeToString(hasher.Sum(nil)), chunkHash) {
|
||||
if req.ChunkHash != "" && !strings.EqualFold(hex.EncodeToString(hasher.Sum(nil)), req.ChunkHash) {
|
||||
Error(w, http.StatusBadRequest, s.t.Get("chunk hash mismatch"))
|
||||
return
|
||||
}
|
||||
@@ -862,13 +844,13 @@ func (s *FileService) ChunkUploadChunk(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer func() { _ = mapFile.Close() }()
|
||||
if _, err = mapFile.WriteAt([]byte{1}, int64(chunkMapHeader+chunkIndex)); err != nil {
|
||||
if _, err = mapFile.WriteAt([]byte{1}, int64(chunkMapHeader+req.ChunkIndex)); err != nil {
|
||||
Error(w, http.StatusInternalServerError, s.t.Get("save chunk error: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, chix.M{
|
||||
"chunk_index": chunkIndex,
|
||||
"chunk_index": req.ChunkIndex,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -911,7 +893,7 @@ func (s *FileService) ChunkUploadFinish(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// ChunkUploadCancel 取消分块上传,删除临时文件
|
||||
func (s *FileService) ChunkUploadCancel(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.ChunkUploadCancel](r)
|
||||
req, err := Bind[request.ChunkUploadFile](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { promiseTimeout } from '@vueuse/core'
|
||||
import { sha256 } from 'js-sha256'
|
||||
import pLimit from 'p-limit'
|
||||
|
||||
import api from '@/api/panel/file'
|
||||
import { dirname, getFilename, joinPath, type PickedFile } from '@/utils/file'
|
||||
import { dirname, getBase, getExt, getFilename, joinPath, type PickedFile } from '@/utils/file'
|
||||
import { $gettext } from '@/utils/gettext'
|
||||
|
||||
export type UploadStatus =
|
||||
@@ -17,6 +18,10 @@ export type UploadPriority = 'high' | 'normal' | 'low'
|
||||
export type ConflictPolicy = 'ask' | 'skip' | 'rename' | 'overwrite'
|
||||
export type ConflictAction = 'skip' | 'rename' | 'overwrite'
|
||||
|
||||
// 可以(重新)开始的状态 / 已结束的状态
|
||||
export const STARTABLE = new Set<UploadStatus>(['pending', 'paused', 'error', 'skipped'])
|
||||
export const FINISHED = new Set<UploadStatus>(['done', 'skipped'])
|
||||
|
||||
export interface UploadItem {
|
||||
id: string
|
||||
file: File
|
||||
@@ -47,6 +52,8 @@ const CHUNK_RETRY = 5
|
||||
// 同时上传的文件数 / 单文件同时上传的分块数
|
||||
const FILE_CONCURRENCY = 3
|
||||
const CHUNK_CONCURRENCY = 3
|
||||
// 进度和速度的刷新间隔
|
||||
const TICK_MS = 500
|
||||
|
||||
const PRIORITY_RANK: Record<UploadPriority, number> = { high: 0, normal: 1, low: 2 }
|
||||
|
||||
@@ -62,12 +69,10 @@ const abortTask = (task?: Task) => {
|
||||
task.requests.forEach((r) => r.abort())
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
// 拆分文件名和扩展名
|
||||
const splitExt = (filename: string) => {
|
||||
const dot = filename.lastIndexOf('.')
|
||||
return dot > 0 ? [filename.slice(0, dot), filename.slice(dot)] : [filename, '']
|
||||
// 带序号的候选文件名:a.txt -> a-1.txt
|
||||
const numberedName = (filename: string, n: number) => {
|
||||
const ext = getExt(filename)
|
||||
return `${getBase(filename)}-${n}${ext ? `.${ext}` : ''}`
|
||||
}
|
||||
|
||||
// SHA-256 十六进制:安全上下文(HTTPS/localhost)下用原生 WebCrypto,否则退回纯 JS 实现
|
||||
@@ -82,20 +87,14 @@ const sha256Hex = async (data: Uint8Array): Promise<string> => {
|
||||
// 文件标识:大小 + 修改时间 + 首/中/尾各 1MB 采样,不用读整个文件;同一文件重新加入可续传
|
||||
const fileIdentifier = async (file: File): Promise<string> => {
|
||||
const sample = 1024 * 1024
|
||||
const offsets = [0, Math.floor(file.size / 2 - sample / 2), file.size - sample]
|
||||
const parts = [new TextEncoder().encode(`${file.size}|${file.lastModified}|`)]
|
||||
for (const offset of offsets) {
|
||||
const start = Math.max(0, offset)
|
||||
parts.push(new Uint8Array(await file.slice(start, start + sample).arrayBuffer()))
|
||||
}
|
||||
|
||||
const combined = new Uint8Array(parts.reduce((sum, p) => sum + p.byteLength, 0))
|
||||
let cursor = 0
|
||||
for (const part of parts) {
|
||||
combined.set(part, cursor)
|
||||
cursor += part.byteLength
|
||||
}
|
||||
return sha256Hex(combined)
|
||||
const offsets = [0, Math.floor(file.size / 2 - sample / 2), file.size - sample].map((o) =>
|
||||
Math.max(0, o),
|
||||
)
|
||||
const blob = new Blob([
|
||||
`${file.size}|${file.lastModified}|`,
|
||||
...offsets.map((o) => file.slice(o, o + sample)),
|
||||
])
|
||||
return sha256Hex(new Uint8Array(await blob.arrayBuffer()))
|
||||
}
|
||||
|
||||
// 上传队列:全局单例,关闭弹窗不影响后台上传
|
||||
@@ -143,37 +142,26 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
return items.value.filter((i) => set.has(i.id))
|
||||
}
|
||||
|
||||
// ==================== 列表刷新 & 速度统计 ====================
|
||||
// ==================== 列表刷新 & 进度统计 ====================
|
||||
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const scheduleRefresh = () => {
|
||||
if (refreshTimer) return
|
||||
refreshTimer = setTimeout(() => {
|
||||
refreshTimer = null
|
||||
window.$bus.emit('file:refresh')
|
||||
}, 1000)
|
||||
}
|
||||
const scheduleRefresh = useThrottleFn(() => window.$bus.emit('file:refresh'), 1000, true, false)
|
||||
|
||||
const lastLoaded = new Map<string, number>()
|
||||
let ticker: ReturnType<typeof setInterval> | null = null
|
||||
const tick = () => {
|
||||
for (const item of items.value) {
|
||||
if (item.status !== 'uploading') continue
|
||||
const prev = lastLoaded.get(item.id)
|
||||
if (prev !== undefined) {
|
||||
const instant = Math.max(0, item.loaded - prev)
|
||||
item.speed = item.speed ? Math.round(item.speed * 0.6 + instant * 0.4) : instant
|
||||
// 进度先记在普通 Map 里,由定时器批量刷进响应式字段并算速度,
|
||||
// 避免每个 XHR progress 事件都触发整个队列表格重渲染
|
||||
const loadedMap = new Map<string, number>()
|
||||
const ticker = useIntervalFn(
|
||||
() => {
|
||||
for (const item of items.value) {
|
||||
if (item.status !== 'uploading') continue
|
||||
const loaded = loadedMap.get(item.id) ?? item.loaded
|
||||
const instant = Math.max(0, loaded - item.loaded) * (1000 / TICK_MS)
|
||||
item.speed = Math.round(item.speed ? item.speed * 0.6 + instant * 0.4 : instant)
|
||||
item.loaded = loaded
|
||||
}
|
||||
lastLoaded.set(item.id, item.loaded)
|
||||
}
|
||||
}
|
||||
const startTicker = () => {
|
||||
if (!ticker) ticker = setInterval(tick, 1000)
|
||||
}
|
||||
const stopTicker = () => {
|
||||
if (ticker) clearInterval(ticker)
|
||||
ticker = null
|
||||
}
|
||||
},
|
||||
TICK_MS,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
// ==================== 传输 ====================
|
||||
|
||||
@@ -195,7 +183,7 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
form.append('file', item.file)
|
||||
form.append('force', String(item.force))
|
||||
const method = api.upload(form)
|
||||
method.onUpload(({ loaded }: { loaded: number }) => (item.loaded = loaded))
|
||||
method.onUpload(({ loaded }: { loaded: number }) => loadedMap.set(item.id, loaded))
|
||||
await send(method, task)
|
||||
}
|
||||
|
||||
@@ -223,27 +211,26 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
// 进度 = 已完成分块 + 在途分块已发送字节
|
||||
let doneBytes = 0
|
||||
uploaded.forEach((index) => (doneBytes += chunkLength(index)))
|
||||
item.loaded = doneBytes
|
||||
const inflight = new Map<number, number>()
|
||||
const report = () => {
|
||||
let sum = doneBytes
|
||||
inflight.forEach((v) => (sum += v))
|
||||
item.loaded = sum
|
||||
loadedMap.set(item.id, sum)
|
||||
}
|
||||
report()
|
||||
|
||||
const uploadChunk = async (index: number) => {
|
||||
const start = index * CHUNK_SIZE
|
||||
const blob = item.file.slice(start, start + chunkLength(index))
|
||||
const chunkHash = await sha256Hex(new Uint8Array(await blob.arrayBuffer()))
|
||||
const form = new FormData()
|
||||
form.append('path', dir)
|
||||
form.append('file_name', fileName)
|
||||
form.append('file_hash', item.hash)
|
||||
form.append('chunk_index', String(index))
|
||||
form.append('chunk_hash', await sha256Hex(new Uint8Array(await blob.arrayBuffer())))
|
||||
form.append('file', blob)
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
if (task.aborted) throw new Error('aborted')
|
||||
const form = new FormData()
|
||||
form.append('path', dir)
|
||||
form.append('file_name', fileName)
|
||||
form.append('file_hash', item.hash)
|
||||
form.append('chunk_index', String(index))
|
||||
form.append('chunk_hash', chunkHash)
|
||||
form.append('file', blob)
|
||||
const method = api.chunkUpload(form)
|
||||
method.onUpload(({ loaded }: { loaded: number }) => {
|
||||
inflight.set(index, loaded)
|
||||
@@ -258,16 +245,13 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
} catch (error) {
|
||||
inflight.delete(index)
|
||||
if (task.aborted || attempt >= CHUNK_RETRY) throw error
|
||||
await sleep(Math.min(1000 * 2 ** (attempt - 1), 8000))
|
||||
await promiseTimeout(Math.min(1000 * 2 ** (attempt - 1), 8000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const limit = pLimit(CHUNK_CONCURRENCY)
|
||||
const pending: number[] = []
|
||||
for (let i = 0; i < chunkCount; i++) {
|
||||
if (!uploaded.has(i)) pending.push(i)
|
||||
}
|
||||
const pending = Array.from({ length: chunkCount }, (_, i) => i).filter((i) => !uploaded.has(i))
|
||||
await Promise.all(pending.map((index) => limit(() => uploadChunk(index))))
|
||||
|
||||
if (task.aborted) throw new Error('aborted')
|
||||
@@ -281,7 +265,7 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
tasks.set(item.id, task)
|
||||
item.status = 'uploading'
|
||||
item.error = ''
|
||||
startTicker()
|
||||
ticker.resume()
|
||||
|
||||
try {
|
||||
if (item.size > CHUNK_THRESHOLD) {
|
||||
@@ -303,9 +287,9 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
}
|
||||
} finally {
|
||||
tasks.delete(item.id)
|
||||
lastLoaded.delete(item.id)
|
||||
loadedMap.delete(item.id)
|
||||
item.speed = 0
|
||||
if (tasks.size === 0) stopTicker()
|
||||
if (tasks.size === 0) ticker.pause()
|
||||
schedule()
|
||||
finishRound()
|
||||
}
|
||||
@@ -313,17 +297,16 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
|
||||
// 按优先级取排队项填满并发槽位,同优先级先进先出
|
||||
function schedule() {
|
||||
while (tasks.size < FILE_CONCURRENCY) {
|
||||
const next = items.value
|
||||
.filter((i) => i.status === 'waiting' && i.planned && !tasks.has(i.id))
|
||||
.sort((a, b) => PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority])[0]
|
||||
if (!next) break
|
||||
run(next)
|
||||
const queue = items.value
|
||||
.filter((i) => i.status === 'waiting' && i.planned && !tasks.has(i.id))
|
||||
.sort((a, b) => PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority])
|
||||
while (tasks.size < FILE_CONCURRENCY && queue.length > 0) {
|
||||
run(queue.shift()!)
|
||||
}
|
||||
}
|
||||
|
||||
function finishRound() {
|
||||
if (tasks.size > 0 || items.value.some((i) => i.status === 'waiting')) return
|
||||
if (tasks.size > 0 || stats.value.waiting > 0) return
|
||||
if (roundError > 0) {
|
||||
window.$message.warning($gettext('%{count} file(s) failed to upload', { count: roundError }))
|
||||
} else if (roundDone > 0) {
|
||||
@@ -344,9 +327,8 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
const candidates: { idx: number; name: string; path: string }[] = []
|
||||
conflicts.forEach((item, idx) => {
|
||||
if (result[idx]) return
|
||||
const [base, ext] = splitExt(getFilename(item.target))
|
||||
for (let k = 0; k < batch; k++) {
|
||||
const name = `${base}-${offset + k}${ext}`
|
||||
const name = numberedName(getFilename(item.target), offset + k)
|
||||
candidates.push({ idx, name, path: joinPath(dirname(item.target), name) })
|
||||
}
|
||||
})
|
||||
@@ -363,11 +345,20 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
return result.map((name, idx) => {
|
||||
if (name) return name
|
||||
const [base, ext] = splitExt(getFilename(conflicts[idx]!.target))
|
||||
return `${base}-${Date.now()}${ext}`
|
||||
})
|
||||
return result.map(
|
||||
(name, idx) => name || numberedName(getFilename(conflicts[idx]!.target), Date.now()),
|
||||
)
|
||||
}
|
||||
|
||||
// 对冲突项执行选定的处理方式
|
||||
const applyAction = (item: UploadItem, action: ConflictAction, newName: string) => {
|
||||
if (action === 'skip') {
|
||||
item.status = 'skipped'
|
||||
} else if (action === 'rename') {
|
||||
item.target = joinPath(dirname(item.target), newName)
|
||||
} else {
|
||||
item.force = true
|
||||
}
|
||||
}
|
||||
|
||||
// 检查目标是否已存在,按策略处理冲突;询问策略下等待用户在弹窗中选择
|
||||
@@ -375,14 +366,6 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
const unplanned = targets.filter((i) => !i.planned)
|
||||
if (unplanned.length === 0) return
|
||||
|
||||
if (conflictPolicy.value === 'overwrite') {
|
||||
unplanned.forEach((i) => {
|
||||
i.force = true
|
||||
i.planned = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let exists: boolean[] = []
|
||||
try {
|
||||
exists = await api.exist(unplanned.map((i) => i.target))
|
||||
@@ -394,14 +377,10 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
unplanned.forEach((i) => (i.planned = true))
|
||||
if (conflicts.length === 0) return
|
||||
|
||||
if (conflictPolicy.value === 'skip') {
|
||||
conflicts.forEach((i) => (i.status = 'skipped'))
|
||||
return
|
||||
}
|
||||
|
||||
const names = await uniqueNames(conflicts)
|
||||
if (conflictPolicy.value === 'rename') {
|
||||
conflicts.forEach((i, idx) => (i.target = joinPath(dirname(i.target), names[idx]!)))
|
||||
const policy = conflictPolicy.value
|
||||
const names = policy === 'rename' || policy === 'ask' ? await uniqueNames(conflicts) : []
|
||||
if (policy !== 'ask') {
|
||||
conflicts.forEach((i, idx) => applyAction(i, policy, names[idx] ?? ''))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -426,14 +405,7 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
const byId = new Map(conflicts.map((i) => [i.id, i]))
|
||||
for (const c of resolved) {
|
||||
const item = byId.get(c.id)
|
||||
if (!item) continue
|
||||
if (c.action === 'skip') {
|
||||
item.status = 'skipped'
|
||||
} else if (c.action === 'rename') {
|
||||
item.target = joinPath(dirname(item.target), c.newName)
|
||||
} else {
|
||||
item.force = true
|
||||
}
|
||||
if (item) applyAction(item, c.action, c.newName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,7 +420,7 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
// 加入队列,不自动开始;同一目标路径的未完成项不重复加入
|
||||
function add(files: PickedFile[], dir: string) {
|
||||
const occupied = new Set(
|
||||
items.value.filter((i) => i.status !== 'done' && i.status !== 'skipped').map((i) => i.target),
|
||||
items.value.filter((i) => !FINISHED.has(i.status)).map((i) => i.target),
|
||||
)
|
||||
let duplicated = 0
|
||||
for (const { file, name } of files) {
|
||||
@@ -481,9 +453,7 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
|
||||
// 开始/继续/重试,不传 ids 则作用于全部
|
||||
function start(ids?: string[]) {
|
||||
const targets = pick(ids).filter((i) =>
|
||||
['pending', 'paused', 'error', 'skipped'].includes(i.status),
|
||||
)
|
||||
const targets = pick(ids).filter((i) => STARTABLE.has(i.status))
|
||||
if (targets.length === 0) return
|
||||
targets.forEach((i) => {
|
||||
// 失败/跳过的项重试时重新检查目标,失败可能已留下半截文件
|
||||
@@ -510,9 +480,8 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
|
||||
// 取消/移除:中止传输并清理服务器上残留的临时分块
|
||||
function remove(ids?: string[]) {
|
||||
const removing = new Set(pick(ids).map((i) => i.id))
|
||||
for (const item of items.value) {
|
||||
if (!removing.has(item.id)) continue
|
||||
const removing = pick(ids)
|
||||
for (const item of removing) {
|
||||
abortTask(tasks.get(item.id))
|
||||
if (item.hash && item.status !== 'done') {
|
||||
api
|
||||
@@ -524,7 +493,8 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
items.value = items.value.filter((i) => !removing.has(i.id))
|
||||
const removed = new Set(removing.map((i) => i.id))
|
||||
items.value = items.value.filter((i) => !removed.has(i.id))
|
||||
}
|
||||
|
||||
function setPriority(ids: string[] | undefined, priority: UploadPriority) {
|
||||
@@ -532,13 +502,11 @@ export const useUploadStore = defineStore('upload', () => {
|
||||
}
|
||||
|
||||
function clearFinished() {
|
||||
remove(
|
||||
items.value.filter((i) => i.status === 'done' || i.status === 'skipped').map((i) => i.id),
|
||||
)
|
||||
remove(items.value.filter((i) => FINISHED.has(i.status)).map((i) => i.id))
|
||||
}
|
||||
|
||||
// 有上传进行中时离开页面需确认
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
useEventListener(window, 'beforeunload', (e) => {
|
||||
if (activeCount.value === 0) return
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import {
|
||||
type DataTableColumns,
|
||||
NButton,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
NTag,
|
||||
NText,
|
||||
NTooltip,
|
||||
type TagProps,
|
||||
useThemeVars,
|
||||
} from 'naive-ui'
|
||||
import type { VNode } from 'vue'
|
||||
@@ -17,6 +17,8 @@ import { useGettext } from 'vue3-gettext'
|
||||
import TheIcon from '@/components/custom/TheIcon.vue'
|
||||
import {
|
||||
type ConflictAction,
|
||||
FINISHED,
|
||||
STARTABLE,
|
||||
type UploadItem,
|
||||
type UploadPriority,
|
||||
type UploadStatus,
|
||||
@@ -38,19 +40,16 @@ const show = defineModel<boolean>('show', { type: Boolean, required: true })
|
||||
// 新添加文件的目标目录
|
||||
const props = defineProps<{ path: string }>()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const dirInput = ref<HTMLInputElement | null>(null)
|
||||
const bodyRef = ref<HTMLElement | null>(null)
|
||||
const checked = ref<string[]>([])
|
||||
const rowKey = (row: UploadItem) => row.id
|
||||
|
||||
const stats = computed(() => uploadStore.stats)
|
||||
const canStart = computed(
|
||||
() => stats.value.pending + stats.value.paused + stats.value.error + stats.value.skipped > 0,
|
||||
)
|
||||
const canPause = computed(() => stats.value.waiting + stats.value.uploading > 0)
|
||||
const hasFinished = computed(() => stats.value.done + stats.value.skipped > 0)
|
||||
const canStart = computed(() => uploadStore.items.some((i) => STARTABLE.has(i.status)))
|
||||
const canPause = computed(() => uploadStore.activeCount > 0)
|
||||
const hasFinished = computed(() => uploadStore.items.some((i) => FINISHED.has(i.status)))
|
||||
|
||||
const summary = computed(() => {
|
||||
const s = stats.value
|
||||
const s = uploadStore.stats
|
||||
const parts = [$gettext('%{n} in total', { n: s.total })]
|
||||
if (s.uploading) parts.push($gettext('%{n} uploading', { n: s.uploading }))
|
||||
if (s.waiting) parts.push($gettext('%{n} queued', { n: s.waiting }))
|
||||
@@ -68,18 +67,16 @@ const policyOptions = computed(() => [
|
||||
{ label: $gettext('Overwrite'), value: 'overwrite' },
|
||||
])
|
||||
|
||||
const priorityOptions = computed(() => [
|
||||
{ label: $gettext('High'), value: 'high' },
|
||||
{ label: $gettext('Normal'), value: 'normal' },
|
||||
{ label: $gettext('Low'), value: 'low' },
|
||||
])
|
||||
const priorityType: Record<UploadPriority, 'warning' | 'default' | 'info'> = {
|
||||
high: 'warning',
|
||||
normal: 'default',
|
||||
low: 'info',
|
||||
}
|
||||
type TagType = TagProps['type']
|
||||
const priorityMeta = computed<Record<UploadPriority, { label: string; type: TagType }>>(() => ({
|
||||
high: { label: $gettext('High'), type: 'warning' },
|
||||
normal: { label: $gettext('Normal'), type: 'default' },
|
||||
low: { label: $gettext('Low'), type: 'info' },
|
||||
}))
|
||||
const priorityOptions = computed(() =>
|
||||
Object.entries(priorityMeta.value).map(([value, meta]) => ({ label: meta.label, value })),
|
||||
)
|
||||
|
||||
type TagType = 'default' | 'info' | 'primary' | 'warning' | 'success' | 'error'
|
||||
const statusMeta = computed<Record<UploadStatus, { label: string; type: TagType }>>(() => ({
|
||||
pending: { label: $gettext('Pending'), type: 'default' },
|
||||
waiting: { label: $gettext('Queued'), type: 'info' },
|
||||
@@ -96,39 +93,24 @@ const addFiles = (files: PickedFile[]) => {
|
||||
if (files.length > 0) uploadStore.add(files, props.path)
|
||||
}
|
||||
|
||||
const onPick = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement
|
||||
addFiles(filesFromInput(input.files))
|
||||
input.value = ''
|
||||
}
|
||||
// 选文件和选文件夹共用一个对话框,文件夹通过 open({ directory: true })
|
||||
const fileDialog = useFileDialog({ multiple: true, reset: true })
|
||||
fileDialog.onChange((files) => addFiles(filesFromInput(files)))
|
||||
|
||||
// 整个弹窗内容区都可拖入,用计数器抵消子元素间的 enter/leave
|
||||
const dragDepth = ref(0)
|
||||
const onDragEnter = (e: DragEvent) => {
|
||||
if (e.dataTransfer?.types.includes('Files')) dragDepth.value++
|
||||
}
|
||||
const onDragLeave = () => {
|
||||
dragDepth.value = Math.max(0, dragDepth.value - 1)
|
||||
}
|
||||
const onDrop = async (e: DragEvent) => {
|
||||
dragDepth.value = 0
|
||||
addFiles(await readDroppedFiles(e.dataTransfer))
|
||||
}
|
||||
// 拖到弹窗外松手时 leave 事件可能不成对,兜底复位
|
||||
useEventListener(document, 'drop', () => (dragDepth.value = 0))
|
||||
// 整个弹窗内容区都可拖入
|
||||
const { isOverDropZone } = useDropZone(bodyRef, {
|
||||
checkValidity: (items) => Array.from(items).some((item) => item.kind === 'file'),
|
||||
onDrop: async (_, event) => addFiles(await readDroppedFiles(event.dataTransfer)),
|
||||
})
|
||||
|
||||
// ==================== 队列表格 ====================
|
||||
|
||||
const iconButton = (icon: string, label: string, onClick: () => void) =>
|
||||
h(NTooltip, null, {
|
||||
trigger: () =>
|
||||
h(
|
||||
NButton,
|
||||
{ quaternary: true, circle: true, size: 'small', onClick },
|
||||
{ icon: () => h(TheIcon, { icon, size: 18 }) },
|
||||
),
|
||||
default: () => label,
|
||||
})
|
||||
h(
|
||||
NButton,
|
||||
{ quaternary: true, circle: true, size: 'small', title: label, onClick },
|
||||
{ icon: () => h(TheIcon, { icon, size: 18 }) },
|
||||
)
|
||||
|
||||
const columns = computed<DataTableColumns<UploadItem>>(() => [
|
||||
{ type: 'selection' },
|
||||
@@ -139,18 +121,10 @@ const columns = computed<DataTableColumns<UploadItem>>(() => [
|
||||
render: (row) => {
|
||||
const uploadName = getFilename(row.target)
|
||||
const renamed = uploadName !== getFilename(row.name)
|
||||
return h(
|
||||
NTooltip,
|
||||
{ placement: 'top-start', delay: 500 },
|
||||
{
|
||||
trigger: () =>
|
||||
h('div', { class: 'truncate' }, [
|
||||
row.name,
|
||||
renamed ? h(NText, { depth: 3, class: 'ml-2' }, () => `→ ${uploadName}`) : null,
|
||||
]),
|
||||
default: () => row.target,
|
||||
},
|
||||
)
|
||||
return h('div', { class: 'truncate', title: row.target }, [
|
||||
row.name,
|
||||
renamed ? h(NText, { depth: 3, class: 'ml-2' }, () => `→ ${uploadName}`) : null,
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -164,11 +138,12 @@ const columns = computed<DataTableColumns<UploadItem>>(() => [
|
||||
key: 'loaded',
|
||||
width: 200,
|
||||
render: (row) => {
|
||||
const percent = row.size
|
||||
? Math.min(100, Math.floor((row.loaded / row.size) * 100))
|
||||
: row.status === 'done'
|
||||
const percent =
|
||||
row.status === 'done'
|
||||
? 100
|
||||
: 0
|
||||
: row.size
|
||||
? Math.min(100, Math.floor((row.loaded / row.size) * 100))
|
||||
: 0
|
||||
return h(NFlex, { align: 'center', size: 8, wrap: false }, () => [
|
||||
h(NProgress, {
|
||||
type: 'line',
|
||||
@@ -222,10 +197,10 @@ const columns = computed<DataTableColumns<UploadItem>>(() => [
|
||||
{
|
||||
size: 'small',
|
||||
bordered: false,
|
||||
type: priorityType[row.priority],
|
||||
type: priorityMeta.value[row.priority].type,
|
||||
class: 'cursor-pointer',
|
||||
},
|
||||
() => priorityOptions.value.find((o) => o.value === row.priority)?.label,
|
||||
() => priorityMeta.value[row.priority].label,
|
||||
),
|
||||
),
|
||||
},
|
||||
@@ -235,7 +210,7 @@ const columns = computed<DataTableColumns<UploadItem>>(() => [
|
||||
width: 90,
|
||||
render: (row) => {
|
||||
const buttons: VNode[] = []
|
||||
if (['pending', 'paused', 'error', 'skipped'].includes(row.status)) {
|
||||
if (STARTABLE.has(row.status)) {
|
||||
buttons.push(
|
||||
iconButton(
|
||||
'mdi:play',
|
||||
@@ -247,10 +222,11 @@ const columns = computed<DataTableColumns<UploadItem>>(() => [
|
||||
if (row.status === 'waiting' || row.status === 'uploading') {
|
||||
buttons.push(iconButton('mdi:pause', $gettext('Pause'), () => uploadStore.pause([row.id])))
|
||||
}
|
||||
const finished = row.status === 'done' || row.status === 'skipped'
|
||||
buttons.push(
|
||||
iconButton('mdi:close', finished ? $gettext('Remove') : $gettext('Cancel'), () =>
|
||||
uploadStore.remove([row.id]),
|
||||
iconButton(
|
||||
'mdi:close',
|
||||
FINISHED.has(row.status) ? $gettext('Remove') : $gettext('Cancel'),
|
||||
() => uploadStore.remove([row.id]),
|
||||
),
|
||||
)
|
||||
return h(NFlex, { size: 0, wrap: false }, () => buttons)
|
||||
@@ -328,10 +304,8 @@ const onConflictCancel = () => {
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
>
|
||||
<input ref="fileInput" type="file" multiple class="hidden" @change="onPick" />
|
||||
<input ref="dirInput" type="file" webkitdirectory class="hidden" @change="onPick" />
|
||||
|
||||
<div
|
||||
ref="bodyRef"
|
||||
class="upload-body"
|
||||
:style="{
|
||||
'--border-color': themeVars.borderColor,
|
||||
@@ -339,22 +313,18 @@ const onConflictCancel = () => {
|
||||
'--hover-color': themeVars.hoverColor,
|
||||
'--card-color': themeVars.modalColor,
|
||||
}"
|
||||
@dragenter.prevent="onDragEnter"
|
||||
@dragover.prevent
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<n-flex vertical :size="12">
|
||||
<!-- 添加文件 + 同名策略 -->
|
||||
<n-flex align="center" justify="space-between">
|
||||
<n-flex align="center" :size="8">
|
||||
<n-button @click="fileInput?.click()">
|
||||
<n-button @click="fileDialog.open()">
|
||||
<template #icon>
|
||||
<the-icon icon="mdi:file-plus-outline" :size="18" />
|
||||
</template>
|
||||
{{ $gettext('Add Files') }}
|
||||
</n-button>
|
||||
<n-button @click="dirInput?.click()">
|
||||
<n-button @click="fileDialog.open({ directory: true })">
|
||||
<template #icon>
|
||||
<the-icon icon="mdi:folder-plus-outline" :size="18" />
|
||||
</template>
|
||||
@@ -379,8 +349,8 @@ const onConflictCancel = () => {
|
||||
<div
|
||||
v-if="uploadStore.items.length === 0"
|
||||
class="drop-zone"
|
||||
:class="{ active: dragDepth > 0 }"
|
||||
@click="fileInput?.click()"
|
||||
:class="{ active: isOverDropZone }"
|
||||
@click="fileDialog.open()"
|
||||
>
|
||||
<the-icon :size="48" icon="mdi:cloud-upload-outline" />
|
||||
<NText>{{ $gettext('Drag files or folders here, or click to select files') }}</NText>
|
||||
@@ -401,7 +371,7 @@ const onConflictCancel = () => {
|
||||
max-height="55vh"
|
||||
:columns="columns"
|
||||
:data="uploadStore.items"
|
||||
:row-key="(row: UploadItem) => row.id"
|
||||
:row-key="rowKey"
|
||||
:checked-row-keys="checked"
|
||||
@update:checked-row-keys="(keys: (string | number)[]) => (checked = keys as string[])"
|
||||
/>
|
||||
@@ -453,7 +423,7 @@ const onConflictCancel = () => {
|
||||
</n-flex>
|
||||
|
||||
<!-- 拖入时的遮罩提示 -->
|
||||
<div v-if="dragDepth > 0 && uploadStore.items.length > 0" class="drop-overlay">
|
||||
<div v-if="isOverDropZone && uploadStore.items.length > 0" class="drop-overlay">
|
||||
<the-icon :size="40" icon="mdi:cloud-upload-outline" />
|
||||
<NText>{{ $gettext('Drop to add to the queue') }}</NText>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user