Files
Docker/uniapp/youjiala/src/stores/uploadQueue.ts
T
2026-10-21 09:39:47 +08:00

465 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, UploadHandle>();
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<string> {
// #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<UploadItem[]>([]);
// ===== 派生视图 =====
/** 活跃态(排队/上传中):分组、角标、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<PathNode[] | null>(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,
},
);