refactor: 精简上传队列与分块上传实现

后端:上传接口改走 Bind + request 结构体复用既有校验规则,分块请求公共字段内嵌复用
前端:进度按定时器批量刷新避免高频重渲染,拖拽/选择文件/节流/定时改用 vueuse 现成能力,收敛重复的状态判定与冲突处理分支

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
耗子
2026-09-14 15:38:07 +08:00
co-authored by Claude Fable 5.1
parent 1c2843e3c8
commit e1de082d17
5 changed files with 206 additions and 274 deletions
+30 -18
View File
@@ -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"` // 是否覆盖已存在文件
}
+1 -1
View File
@@ -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
View File
@@ -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) {
// 边写边算 hashLimitReader 保证不会写到本块范围之外
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
+86 -118
View File
@@ -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 = ''
+54 -84
View File
@@ -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>