import type { MesProQrExportApi } from '#/api/mes/pro/qrExport';
|
|
import { reactive } from 'vue';
|
|
import { useAppConfig } from '@vben/hooks';
|
import { useAccessStore } from '@vben/stores';
|
import { downloadFileFromBlobPart } from '@vben/utils';
|
|
import { message } from 'ant-design-vue';
|
|
import {
|
cancelQrExportTask,
|
getQrExportCount,
|
getQrExportDownloadPath,
|
getQrExportTask,
|
submitQrExportTask,
|
} from '#/api/mes/pro/qrExport';
|
|
/**
|
* 导出阶段
|
* <p>
|
* counting 统计张数 / sync 小批量同步下载中 / generating 后台生成中 /
|
* downloading 下载产物中 / done 完成 / canceled 已取消 / failed 失败
|
*/
|
export type QrExportPhase =
|
| 'canceled'
|
| 'counting'
|
| 'done'
|
| 'downloading'
|
| 'failed'
|
| 'generating'
|
| 'idle'
|
| 'sync';
|
|
export interface QrExportState {
|
bizType?: MesProQrExportApi.BizType;
|
/** 下载失败后,服务端文件仍可用时允许直接重试 */
|
downloadRetryable: boolean;
|
/** 已下载字节 */
|
downloadLoaded: number;
|
/** 产物总字节(取自 Content-Length,取不到时为 0 表示未知) */
|
downloadTotal: number;
|
errorMsg: string;
|
/** 已生成张数 */
|
finished: number;
|
message: string;
|
/** 收起为浮标(任务继续在后台跑) */
|
minimized: boolean;
|
phase: QrExportPhase;
|
/** 进度百分比(0~100) */
|
progress: number;
|
taskId?: number;
|
taskName: string;
|
/** 标签总张数 */
|
total: number;
|
}
|
|
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
|
/**
|
* 导出状态提到模块级单例:进度面板被收起、或页面组件被销毁(用户切走再切回)时,
|
* 轮询与下载都必须继续,不能因为组件卸载而中断。
|
*/
|
const state = reactive<QrExportState>(createInitialState());
|
|
let pollTimer: null | ReturnType<typeof setInterval> = null;
|
let downloadAbort: null | (() => void) = null;
|
|
/**
|
* 目标文件句柄
|
* <p>
|
* 大批量导出的产物可达数百 MB,若整包读进内存再落盘,标签页极易被撑爆。因此优先在点击导出
|
* (用户手势仍在有效期内)时取到保存句柄,产物边下载边写盘,内存只占一个分片。
|
* 浏览器不支持或用户放弃选择时退回内存下载。
|
*/
|
let saveHandle: any = null;
|
|
/** 尝试取得保存句柄;不支持、被取消时返回 null(不视为错误) */
|
async function tryPickSaveFile(suggestedName: string): Promise<any> {
|
const picker = (window as any).showSaveFilePicker;
|
if (typeof picker !== 'function') {
|
return null;
|
}
|
try {
|
return await picker({
|
suggestedName,
|
types: [
|
{ description: 'ZIP 压缩包', accept: { 'application/zip': ['.zip'] } },
|
],
|
});
|
} catch {
|
return null;
|
}
|
}
|
|
function createInitialState(): QrExportState {
|
return {
|
downloadLoaded: 0,
|
downloadRetryable: false,
|
downloadTotal: 0,
|
errorMsg: '',
|
finished: 0,
|
message: '',
|
minimized: false,
|
phase: 'idle',
|
progress: 0,
|
taskName: '',
|
total: 0,
|
};
|
}
|
|
function resetState() {
|
Object.assign(state, createInitialState());
|
}
|
|
function stopPolling() {
|
if (pollTimer !== null) {
|
clearInterval(pollTimer);
|
pollTimer = null;
|
}
|
}
|
|
/** 是否处于进行中(面板需要展示) */
|
function isActive() {
|
return [
|
'counting',
|
'generating',
|
'downloading',
|
'sync',
|
].includes(state.phase);
|
}
|
|
/** 由外部页面注入的「小批量同步下载」实现,避免本模块依赖具体业务页 */
|
export interface StartOptions {
|
fileName?: string;
|
/** 小批量(不超过分界线)时如何同步下载,由调用页提供 */
|
syncDownload?: () => Promise<void>;
|
}
|
|
/**
|
* 发起导出
|
* <p>
|
* 先问后端要张数:不超过分界线直接同步下载(秒出、不排队);超过则转后台任务并展示进度。
|
*/
|
async function start(
|
params: MesProQrExportApi.ExportParams,
|
options: StartOptions = {},
|
) {
|
if (isActive()) {
|
message.warning('已有导出任务正在进行,请等待完成或取消后再试');
|
return;
|
}
|
resetState();
|
state.bizType = params.bizType;
|
state.taskName = options.fileName ?? '二维码';
|
state.phase = 'counting';
|
state.message = '正在统计待导出数量…';
|
|
try {
|
const count = await getQrExportCount(params);
|
state.total = count.totalCount;
|
if (count.totalCount === 0) {
|
state.phase = 'failed';
|
state.errorMsg = '没有可导出的二维码';
|
state.message = '没有可导出的二维码';
|
return;
|
}
|
if (count.mode === 'SYNC') {
|
await startSync(params, count, options);
|
return;
|
}
|
// 大批量:趁用户手势仍有效先取保存句柄,产物可直接流式落盘
|
saveHandle = await tryPickSaveFile(`${state.taskName}.zip`);
|
await startAsync(params, count);
|
} catch (error: any) {
|
failWith(error);
|
}
|
}
|
|
/** 小批量:沿用原有同步下载接口 */
|
async function startSync(
|
_params: MesProQrExportApi.ExportParams,
|
count: MesProQrExportApi.ExportCount,
|
options: StartOptions,
|
) {
|
state.phase = 'sync';
|
state.message = `共 ${count.totalCount} 张,正在生成…`;
|
if (!options.syncDownload) {
|
state.phase = 'failed';
|
state.errorMsg = '缺少同步下载实现';
|
return;
|
}
|
await options.syncDownload();
|
state.phase = 'done';
|
state.progress = 100;
|
state.finished = count.totalCount;
|
state.message = `已导出 ${count.totalCount} 张`;
|
}
|
|
/** 大批量:提交后台任务并轮询进度 */
|
async function startAsync(
|
params: MesProQrExportApi.ExportParams,
|
count: MesProQrExportApi.ExportCount,
|
) {
|
state.phase = 'generating';
|
state.message = `共 ${count.totalCount} 张,正在后台生成…`;
|
const task = await submitQrExportTask(params);
|
state.taskId = task.id;
|
state.taskName = task.taskName ?? state.taskName;
|
state.total = task.totalCount ?? count.totalCount;
|
state.finished = task.finishedCount ?? 0;
|
state.progress = task.progress ?? 0;
|
pollTask();
|
}
|
|
function pollTask() {
|
stopPolling();
|
pollTimer = setInterval(() => {
|
void refreshTask();
|
}, 1000);
|
}
|
|
async function refreshTask() {
|
if (!state.taskId) return;
|
try {
|
const task = await getQrExportTask(state.taskId);
|
state.total = task.totalCount ?? 0;
|
state.finished = task.finishedCount ?? 0;
|
state.progress = task.progress ?? 0;
|
state.taskName = task.taskName ?? state.taskName;
|
if (task.status === 30) {
|
// 待下载
|
stopPolling();
|
state.progress = 100;
|
await downloadProduct(task);
|
return;
|
}
|
if (task.status === 40) {
|
stopPolling();
|
state.phase = 'canceled';
|
state.message = '导出已取消';
|
return;
|
}
|
if (task.status === 50) {
|
stopPolling();
|
state.phase = 'failed';
|
state.errorMsg = task.errorMsg ?? '导出失败';
|
state.message = '导出失败';
|
return;
|
}
|
state.phase = 'generating';
|
state.message = `正在生成(${state.finished} / ${state.total})…`;
|
} catch (error: any) {
|
stopPolling();
|
failWith(error);
|
}
|
}
|
|
/** 每次只请求 2 MB,避免代理对单个大文件响应超时。 */
|
const DOWNLOAD_CHUNK_SIZE = 2 * 1024 * 1024;
|
|
async function fetchDownloadChunk(
|
url: string,
|
token: string | undefined,
|
start: number,
|
end: number,
|
total: number,
|
signal: AbortSignal,
|
): Promise<Uint8Array> {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
try {
|
const response = await fetch(url, {
|
headers: {
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
Range: `bytes=${start}-${end}`,
|
},
|
signal,
|
});
|
if (response.status !== 206) {
|
throw new Error(`分段下载失败(HTTP ${response.status})`);
|
}
|
if (response.headers.get('content-range') !== `bytes ${start}-${end}/${total}`) {
|
throw new Error('下载响应的字节范围不正确');
|
}
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
if (bytes.byteLength !== end - start + 1) {
|
throw new Error('下载内容不完整');
|
}
|
return bytes;
|
} catch (error) {
|
if (signal.aborted || attempt === 2) throw error;
|
await new Promise((resolve) => setTimeout(resolve, 750 * (attempt + 1)));
|
}
|
}
|
throw new Error('分段下载失败');
|
}
|
|
/** 下载产物:优先写入磁盘,每段独立请求并可重试。 */
|
async function downloadProduct(task: MesProQrExportApi.ExportTask) {
|
state.phase = 'downloading';
|
state.downloadRetryable = false;
|
state.message = '正在下载…';
|
state.downloadLoaded = 0;
|
state.downloadTotal = task.fileSize ?? 0;
|
const fileName = `${task.taskName ?? '二维码导出'}.zip`;
|
const url = `${apiURL}${getQrExportDownloadPath(task.id)}`;
|
const token = useAccessStore().accessToken ?? undefined;
|
|
const controller = new AbortController();
|
let canceled = false;
|
let writable: any = null;
|
downloadAbort = () => {
|
canceled = true;
|
controller.abort();
|
};
|
|
try {
|
const useDisk = Boolean(saveHandle);
|
if (useDisk) {
|
writable = await saveHandle.createWritable();
|
}
|
const chunks: Uint8Array[] = [];
|
let loaded = 0;
|
if (Number.isSafeInteger(task.fileSize) && (task.fileSize ?? 0) > 0) {
|
const total = task.fileSize!;
|
for (let start = 0; start < total; start += DOWNLOAD_CHUNK_SIZE) {
|
const end = Math.min(start + DOWNLOAD_CHUNK_SIZE, total) - 1;
|
const value = await fetchDownloadChunk(url, token, start, end, total, controller.signal);
|
if (useDisk) {
|
await writable.write(value);
|
} else {
|
chunks.push(value);
|
}
|
loaded += value.length;
|
state.downloadLoaded = loaded;
|
}
|
} else {
|
// 兼容未记录文件大小的旧任务。
|
const response = await fetch(url, {
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
signal: controller.signal,
|
});
|
if (!response.ok || !response.body) {
|
throw new Error(`下载失败(HTTP ${response.status})`);
|
}
|
const contentLength = Number(response.headers.get('content-length') ?? 0);
|
if (contentLength > 0) state.downloadTotal = contentLength;
|
const reader = response.body.getReader();
|
for (;;) {
|
const { done, value } = await reader.read();
|
if (done) break;
|
if (!value) continue;
|
if (useDisk) {
|
await writable.write(value);
|
} else {
|
chunks.push(value);
|
}
|
loaded += value.length;
|
state.downloadLoaded = loaded;
|
}
|
}
|
if (canceled) {
|
return;
|
}
|
if (useDisk) {
|
await writable.close();
|
} else {
|
const blob = new Blob(chunks as BlobPart[], { type: 'application/zip' });
|
downloadFileFromBlobPart({ fileName, source: blob });
|
}
|
state.phase = 'done';
|
state.message = `已导出 ${state.total} 张`;
|
} catch (error: any) {
|
if (canceled) return;
|
state.downloadRetryable = true;
|
failWith(error);
|
} finally {
|
if (writable && state.phase !== 'done') {
|
await writable.abort().catch(() => {});
|
}
|
downloadAbort = null;
|
saveHandle = null;
|
}
|
}
|
|
/** 重新下载已生成的 ZIP,无需再次生成二维码。 */
|
async function retryDownload() {
|
if (!state.downloadRetryable || !state.taskId || state.phase !== 'failed') return;
|
try {
|
// 保存文件选择器需要用户点击触发;先取得句柄,再查询任务状态。
|
saveHandle = await tryPickSaveFile(`${state.taskName}.zip`);
|
const task = await getQrExportTask(state.taskId);
|
if (!task.downloadable) {
|
throw new Error('导出文件已过期,请重新生成');
|
}
|
await downloadProduct(task);
|
} catch (error: any) {
|
failWith(error);
|
}
|
}
|
|
/** 取消:生成中取消任务,下载中中断下载 */
|
async function cancel() {
|
if (state.phase === 'downloading') {
|
downloadAbort?.();
|
state.phase = 'canceled';
|
state.message = '已取消下载(服务端文件会被自动清理)';
|
return;
|
}
|
if (state.taskId && ['counting', 'generating'].includes(state.phase)) {
|
try {
|
await cancelQrExportTask(state.taskId);
|
} catch {
|
// 取消失败不阻断界面收尾:服务端任务的超时回收机制会兜底
|
}
|
stopPolling();
|
state.phase = 'canceled';
|
state.message = '已取消导出';
|
}
|
}
|
|
/** 收起为浮标,任务继续在后台跑 */
|
function minimize() {
|
state.minimized = true;
|
}
|
|
/** 从浮标展开面板 */
|
function expand() {
|
state.minimized = false;
|
}
|
|
/** 关闭面板并清空状态(进行中需先取消) */
|
function close() {
|
stopPolling();
|
downloadAbort?.();
|
downloadAbort = null;
|
saveHandle = null;
|
resetState();
|
}
|
|
function failWith(error: any) {
|
state.phase = 'failed';
|
state.errorMsg = error?.message ?? error?.msg ?? '导出失败';
|
state.message = '导出失败';
|
}
|
|
export function useQrExport() {
|
return {
|
state,
|
isActive,
|
start,
|
cancel,
|
retryDownload,
|
minimize,
|
expand,
|
close,
|
};
|
}
|