465 lines
16 KiB
TypeScript
465 lines
16 KiB
TypeScript
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,
|
||
},
|
||
);
|