From e1de082d17ee2929c1ee061112630d6a79009d2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Mon, 14 Sep 2026 15:38:07 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=B2=BE=E7=AE=80=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E9=98=9F=E5=88=97=E4=B8=8E=E5=88=86=E5=9D=97=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端:上传接口改走 Bind + request 结构体复用既有校验规则,分块请求公共字段内嵌复用 前端:进度按定时器批量刷新避免高频重渲染,拖拽/选择文件/节流/定时改用 vueuse 现成能力,收敛重复的状态判定与冲突处理分支 Co-Authored-By: Claude Fable 5.1 --- internal/request/file.go | 48 +++--- internal/route/file.go | 2 +- internal/service/file.go | 88 +++++------ web/src/stores/modules/upload/index.ts | 204 +++++++++++-------------- web/src/views/file/UploadModal.vue | 138 +++++++---------- 5 files changed, 206 insertions(+), 274 deletions(-) diff --git a/internal/request/file.go b/internal/request/file.go index 465be7d84..6174dc575 100644 --- a/internal/request/file.go +++ b/internal/request/file.go @@ -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"` // 是否覆盖已存在文件 } diff --git a/internal/route/file.go b/internal/route/file.go index 8b57584a6..abb6e7b8d 100644 --- a/internal/route/file.go +++ b/internal/route/file.go @@ -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]()}, } } diff --git a/internal/service/file.go b/internal/service/file.go index 0947b2aa4..e76e9d2dd 100644 --- a/internal/service/file.go +++ b/internal/service/file.go @@ -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 diff --git a/web/src/stores/modules/upload/index.ts b/web/src/stores/modules/upload/index.ts index b0f8e1588..105f29f5e 100644 --- a/web/src/stores/modules/upload/index.ts +++ b/web/src/stores/modules/upload/index.ts @@ -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(['pending', 'paused', 'error', 'skipped']) +export const FINISHED = new Set(['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 = { 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 => { // 文件标识:大小 + 修改时间 + 首/中/尾各 1MB 采样,不用读整个文件;同一文件重新加入可续传 const fileIdentifier = async (file: File): Promise => { 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 | 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() - let ticker: ReturnType | 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() + 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() 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 = '' diff --git a/web/src/views/file/UploadModal.vue b/web/src/views/file/UploadModal.vue index 8a70d51e9..b6a71514e 100644 --- a/web/src/views/file/UploadModal.vue +++ b/web/src/views/file/UploadModal.vue @@ -1,5 +1,4 @@