ow
This commit is contained in:
1 parent
3194f98ac0
commit
e740ec77ec
24 files changed
+1561
-65
No files matched your search
@@ -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,
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user