This commit is contained in:
owenchen committed 2026-10-14 18:50:17 +08:00
1 parent 3194f98ac0
commit e740ec77ec
24 files changed
+1561 -65

No files matched your search

+467
View File
@@ -0,0 +1,467 @@
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";
/**
* 上传队列 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,移动端蜂窝网络下保守一档) */
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";
}
/** 分片大小:内网 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;
}
// #ifdef APP-PLUS
/**
* App 侧 UploadTask 的 multipart filename 取 filePath 的 basename,无法直接满后端
* uid-序号-总数.ext 协议(同 H5 踩过的 IndexOutOfRange),先把文件复制成
* _doc/uploads/uid-1-1.ext 再传;真机验证前不引入分片循环(读不了本地字节切片)。
* 项目 types 未含 plus 声明,本地宽松兑底(仅 App 运行时真实存在)。
*/
// eslint-disable-next-line ts/no-explicit-any
declare const plus: any;
// #endif
function prepareAppFilePath(item: UploadItem): Promise<string> {
// #ifdef APP-PLUS
const chunkName = `uni-${Date.now()}-1-1${extOf(item.name)}`;
return new Promise((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
"_doc/",
(root: any) => {
root.getDirectory(
"uploads",
{ create: true },
(dir: any) => {
plus.io.resolveLocalFileSystemURL(
item.filePath,
(entry: any) => {
entry.copyTo(
dir,
chunkName,
(moved: any) => resolve(moved.toLocalURL()),
reject,
);
},
reject,
);
},
reject,
);
},
reject,
);
});
// #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);
}
// ===== 队列驱动 =====
/** 补位式调度:不满并发上限就把排队任务逐个点着 */
function pump(): void {
let busy = items.value.filter(
(item: UploadItem) => item.status === "uploading",
).length;
while (busy < MAX_PARALLEL) {
const next = items.value.find(
(item: UploadItem) => item.status === "queued",
);
if (!next) {
break;
}
runTask(next);
busy += 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 = `uni-${Date.now()}`;
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(() => start(item.filePath));
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,
},
);