import type { PathNode } from "@/data/custom/fileBrowser"; import type { PickedFile } from "@/utils/chooseFiles"; import { defineStore } from "pinia"; import { computed, ref } from "vue"; import { baseUrl, bearerAuth, extractBusinessError, } from "@/common/http.interceptor"; import { copyIntoAppUploadDir } from "@/utils/appUploadFile"; /** * 上传队列 store:任务状态的唯一持有者,传输列表页只是它的视图。 * * 任务住在 store(而非页面 setup)里,所以上传中随便跳页、切 tab 都不会中断; * H5 刷新 / 杀进程仍会断(JS 运行时销毁,无解),故不做持久化—— * 重启后本地文件路径也已失效,持久化反而是脏数据。 * * 后端口径(CloudBuilder DmsFileService.UploadDmsFile,对齐旧 WebVue FileUpload): * multipart 的 form 字段名就是原始文件名(OrgFileName 取它), * 目标目录走请求头 FilePath,认证头 Authorization 由请求层令牌拼装。 */ export interface UploadItem { id: string; /** 文件名,同时是 multipart form 字段名(后端按它落 OrgFileName) */ name: string; /** 本地路径:H5 为 blob: URL,App 为 file: 路径 */ filePath: string; /** 字节数,取不到时为 0 */ size: number; /** H5 原生 File 对象:有则走 XHR+FormData 通道(可控 multipart filename) */ raw?: unknown; /** DMS 目标目录(FilePath 请求头),取 targetTrail 末段,入队时由文件页快照 */ targetPath: string; /** 从根到目标目录的面包屑链快照:传输页展示 + 点击跳回文件页对应目录 */ targetTrail: PathNode[]; /** 0-100 */ progress: number; /** 字节/秒,通道未回传进度时为 0 */ speed: number; status: "queued" | "uploading" | "success" | "failed" | "cancelled"; error: string; /** 进入终态的时刻(ms):已结束组按它倒序,刚完成的排顶部;未结束为 0 */ finishedAt: number; } /** * 多文件并发上传(并发 3,旧系统 FileUpload 默认 4,移动端蜂窝网络下保守一档)。 * 并发传同一目录曾触发后端 dms_folder_map 主键 Duplicate entry,根因是编号生成器 * SecurityGeneratorNumberService.GetNo 的非原子「读-改-写」;后端已改为数据库端原子自增, * 故此处恢复并发、pump() 也不再按 targetPath 串行。 */ const MAX_PARALLEL = 3; /** 可中止句柄:uni 的 UploadTask 与 XHR 都满足这个最小面 */ interface UploadHandle { abort: () => void; } /** 传输句柄不可序列化,放模块级 Map,不进 store state */ const taskHandles = new Map(); let seq = 0; /** * 分片文件名尾巴:后端 PackUploadDmsFile 对 multipart 的 filename 做 Split('-') 取倒数两段 * 判定分片序号/总数(原始文件名没有这个结构会直接 IndexOutOfRange,实测 401 后的第二个坑)。 * 完整格式 uid-序号-总数.ext 在 xhrUpload 里逐片拼装;单片(-1-1)后端直接落库, * 多片由最后一片请求触发服务端合并+备份+缩略图(均为旧系统 FileUpload 同款路径)。 */ function extOf(fileName: string): string { const dot = fileName.lastIndexOf("."); return dot > 0 ? fileName.slice(dot) : ".ext"; } /** * 分片协议名唯一前缀(uid-序号-总数.ext 的 uid 段)。 * pump() 是同步循环,同批并发任务几乎必在同一毫秒点着,只靠 Date.now() 会重名: * App 端重名会让 copyTo 目标冲突或互相覆盖,H5 端重名会让分片文件互相覆盖; * 重试任务也会重新准备,同样要换新名,故再拼模块级自增序号。 */ function uniqueUploadTag(): string { seq += 1; return `uni-${Date.now()}-${seq}`; } /** 分片大小:内网 3MB / 公网 1MB(旧系统 FileUpload 同口径;开发构建视为内网) */ function resolveChunkSize(): number { const publicNet = /localhost|127\.|192\.168\.|10\./.test(baseUrl); const intranet = import.meta.env.DEV || publicNet; return (intranet ? 3 : 1) * 1024 * 1024; } function prepareAppFilePath(item: UploadItem): Promise { // #ifdef APP-PLUS // App 侧 UploadTask 的 multipart filename 取 filePath 的 basename,无法直接满后端 // uid-序号-总数.ext 协议(同 H5 踩过的 IndexOutOfRange),先复制成协议名再传; // 真机验证前不引入分片循环(读不了本地字节切片) return copyIntoAppUploadDir( item.filePath, `${uniqueUploadTag()}-1-1${extOf(item.name)}`, ); // #endif // #ifndef APP-PLUS return Promise.resolve(item.filePath); // #endif } /** 解上传响应体:成功回空串/纯文本,只有异常体是 JSON 对象 */ function parseUploadBody(raw: unknown): any { if (raw && typeof raw === "object") { return raw; } const text = String(raw ?? "").trim(); if (!text.startsWith("{")) { return null; } try { return JSON.parse(text); } catch { // 注释卡在 } 与 catch 之间:阻断编辑器把 } catch { 折回同行的 Prettier 规范形(与 Stroustrup brace-style 对撞) // eslint-disable-next-line brace-style return null; } } export const useUploadQueueStore = defineStore( "uploadQueue", () => { const items = ref([]); // ===== 派生视图 ===== /** 活跃态(排队/上传中):分组、角标、beforeunload 三处共用同一判定 */ function isActive(item: UploadItem): boolean { return item.status === "queued" || item.status === "uploading"; } /** 进行中(含排队):传输页上半组 */ const activeItems = computed(() => items.value.filter(isActive)); /** 已结束(成功/失败/取消):传输页下半组,刚完成的排顶部方便找到 */ const doneItems = computed(() => { const done = items.value.filter((item: UploadItem) => !isActive(item)); return done.sort((a, b) => b.finishedAt - a.finishedAt); }); /** 进行中数量:文件页胶囊角标用 */ const uploadingCount = computed(() => activeItems.value.length); function findItem(id: string): UploadItem | undefined { return items.value.find((item: UploadItem) => item.id === id); } // ===== 队列驱动 ===== /** * 补位式调度:不满并发上限就把排队任务逐个点着。 * 后端编号生成器(GetNo)已改成数据库端原子自增,多文件并发传同一目录不再争抢 * dms_folder_map 主键,故不再按 targetPath 串行,同目录也可并行、充分提速。 */ function pump(): void { const busy = items.value.filter( (item: UploadItem) => item.status === "uploading", ).length; let free = MAX_PARALLEL - busy; while (free > 0) { const next = items.value.find( (item: UploadItem) => item.status === "queued", ); if (!next) { break; } runTask(next); free -= 1; } } function runTask(item: UploadItem): void { item.status = "uploading"; item.progress = 0; item.speed = 0; item.error = ""; taskHandles.set(item.id, startChannel(item)); } /** 通道分流:H5 拿得到原生 File 就走 XHR(可控 filename,完整复刻旧协议);其余端走 uni.uploadFile */ function startChannel(item: UploadItem): UploadHandle { // #ifdef H5 if (item.raw) { return xhrUpload(item, item.raw as Blob); } // #endif return uniUpload(item); } /** 结果统一收口:HTTP 200 不等于业务成功(后端失败回 .NET 异常体) */ function applyResult( item: UploadItem, statusCode: number, data: unknown, ): void { const httpOk = statusCode >= 200 && statusCode < 300; const problem = httpOk ? extractBusinessError(parseUploadBody(data)) : `HTTP ${statusCode}`; item.status = problem ? "failed" : "success"; item.progress = problem ? item.progress : 100; item.error = problem; finish(item); } /** H5 通道:XHR + 手写 FormData,按旧协议分片串行(字段名=原始文件名,filename=uid-序号-总数.ext) */ function xhrUpload(item: UploadItem, blob: Blob): UploadHandle { let current: XMLHttpRequest | null = null; let aborted = false; const chunkSize = resolveChunkSize(); const total = Math.max(Math.ceil(blob.size / chunkSize), 1); const uid = uniqueUploadTag(); const startedAt = Date.now(); let doneBytes = 0; const sendChunk = (index: number): void => { if (aborted) { return; } const chunk = blob.slice( (index - 1) * chunkSize, Math.min(index * chunkSize, blob.size), ); const xhr = new XMLHttpRequest(); current = xhr; const form = new FormData(); form.append( item.name, chunk, `${uid}-${index}-${total}${extOf(item.name)}`, ); xhr.open("POST", `${baseUrl}/DmsFile/UploadDmsFile`); xhr.setRequestHeader("Authorization", bearerAuth()); xhr.setRequestHeader("FilePath", item.targetPath); xhr.upload.onprogress = (e: ProgressEvent) => { if (e.total > 0 && blob.size > 0) { const loaded = doneBytes + e.loaded; item.progress = Math.round((loaded / blob.size) * 100); const seconds = Math.max((Date.now() - startedAt) / 1000, 1); item.speed = loaded / seconds; } }; xhr.onload = () => { doneBytes += chunk.size; const httpOk = xhr.status >= 200 && xhr.status < 300; const problem = httpOk ? extractBusinessError(parseUploadBody(xhr.responseText)) : `HTTP ${xhr.status}`; if (problem) { // 任意一片业务失败即中止(后端合并以物理分片文件为准,重试会整文件重发覆盖) item.status = "failed"; item.error = problem; finish(item); return; } if (index < total) { sendChunk(index + 1); return; } // 最后一片的响应才是最终结果(服务端在这片上合并入库) applyResult(item, xhr.status, xhr.responseText); }; xhr.onerror = () => { if (!aborted) { item.status = "failed"; item.error = "network error"; finish(item); } }; xhr.send(form); }; sendChunk(1); return { abort: () => { aborted = true; current?.abort(); }, }; } /** uni 通道(App):先复制成分片名再发(后端按 filename 解析分片);复制失败直接判失败,不回退原路径 */ function uniUpload(item: UploadItem): UploadHandle { let task: UniApp.UploadTask | null = null; let aborted = false; const start = (filePath: string): void => { if (aborted) { return; } task = uni.uploadFile({ url: `${baseUrl}/DmsFile/UploadDmsFile`, filePath, // 后端约定:form 字段名 = 原始文件名 name: item.name, header: { // 口径同主拦截器:归一化 Bearer 前缀(裸 token 会被后端 401),目标目录走 FilePath 头 Authorization: bearerAuth(), FilePath: item.targetPath, }, success: (res: UniApp.UploadFileSuccessCallbackResult) => applyResult(item, res.statusCode, res.data), fail: (err) => { // cancel() 主动 abort 也会走 fail:状态已被改写则不覆盖 if (item.status !== "cancelled") { item.status = "failed"; item.error = err?.errMsg || "upload fail"; finish(item); } }, }); task.onProgressUpdate((res) => { item.progress = res.progress; }); }; prepareAppFilePath(item) .then(start) .catch(() => { // 绝不能回退原路径重传:原文件名没有 uid-序号-总数 协议,后端 // PackUploadDmsFile 拆不出分片序号会静默 return null(HTTP 200 空体), // 任务反而被当成成功、库里却是 0 条——宁可显式失败让用户重试 item.status = "failed"; item.error = "文件准备失败"; finish(item); }); return { abort: () => { aborted = true; task?.abort(); }, }; } /** 任务结束统一收口:盖完成戳 + 清句柄 + 补位下一个 */ function finish(item: UploadItem): void { item.finishedAt = Date.now(); taskHandles.delete(item.id); item.speed = 0; pump(); } // ===== 对外动作 ===== /** 文件页选好文件后入队;trail 快照当前面包屑链(根→目标目录),之后用户跳走也不影响 */ function addFiles(picked: PickedFile[], trail: PathNode[]): void { const targetPath = trail[trail.length - 1]?.value ?? ""; picked.forEach((file) => { seq += 1; items.value.push({ id: `up-${Date.now().toString(36)}-${seq}`, name: file.name, filePath: file.filePath, size: file.size, raw: file.raw, targetPath, targetTrail: trail.map((node: PathNode) => ({ ...node })), progress: 0, speed: 0, status: "queued", error: "", finishedAt: 0, }); }); pump(); } // ===== 跳回目标目录的导航请求 ===== // 文件页是 reLaunch 切的 tab 页,页面实例会被销毁重建(目录栈丢失), // 跨页传不了 switchTab 参数,只能把「去哪」暂存在 store 里由文件页 onShow 消费 const pendingNav = ref(null); /** 传输页点面包屑时登记目标目录链 */ function requestNav(trail: PathNode[]): void { pendingNav.value = trail.map((node: PathNode) => ({ ...node })); } /** 文件页取走并清空导航请求(消费式,避免下次回页重复跳) */ function takeNav(): PathNode[] | null { const trail = pendingNav.value; pendingNav.value = null; return trail; } /** 取消(进行中 abort,排队中直接置为已取消) */ function cancel(id: string): void { const item = findItem(id); if (!item || item.status === "success") { return; } const task = taskHandles.get(id); item.status = "cancelled"; item.speed = 0; // 排队中取消不经过 finish,终态戳在这里一并盖(否则排序时落底) item.finishedAt = Date.now(); if (task) { taskHandles.delete(id); task.abort(); } // abort 走的 fail 回调不会替我们补位(状态已是 cancelled,不进 finish),这里自己补 pump(); } /** 失败/取消的任务重新排队 */ function retry(id: string): void { const item = findItem(id); if (!item || item.status === "queued" || item.status === "uploading") { return; } item.status = "queued"; item.progress = 0; item.error = ""; pump(); } /** 从列表移除(进行中会先取消) */ function remove(id: string): void { if (findItem(id)?.status === "uploading") { cancel(id); } const index = items.value.findIndex((item: UploadItem) => item.id === id); if (index >= 0) { items.value.splice(index, 1); } } /** 清空已结束任务(不影响进行中/排队中) */ function clearDone(): void { items.value = items.value.filter(isActive); } // #ifdef H5 // H5 刷新/关页必断进行中的上传(JS 运行时销毁):有任务时弹浏览器原生确认 window.addEventListener("beforeunload", (e: BeforeUnloadEvent) => { if (activeItems.value.length > 0) { e.preventDefault(); } }); // #endif return { items, activeItems, doneItems, uploadingCount, addFiles, requestNav, takeNav, cancel, retry, remove, clearDone, }; }, { // 任务与本地文件路径同生共死,重启后无意义,禁掉全局持久化插件 persist: false, }, );