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;
|
/** 已下载字节 */
|
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,
|
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);
|
}
|
}
|
|
/** 下载产物:优先流式写入磁盘,避免数百 MB 的内容整块驻留内存 */
|
async function downloadProduct(task: MesProQrExportApi.ExportTask) {
|
state.phase = 'downloading';
|
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;
|
|
const controller = new AbortController();
|
let canceled = false;
|
let writable: any = null;
|
downloadAbort = () => {
|
canceled = true;
|
controller.abort();
|
};
|
|
try {
|
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();
|
downloadAbort = () => {
|
canceled = true;
|
void reader.cancel();
|
};
|
const useDisk = Boolean(saveHandle);
|
if (useDisk) {
|
writable = await saveHandle.createWritable();
|
}
|
// 边收边写:进度来自真实字节数;能写盘就只占一个分片的内存
|
const chunks: Uint8Array[] = [];
|
let loaded = 0;
|
for (;;) {
|
const { done, value } = await reader.read();
|
if (done) break;
|
if (value) {
|
if (useDisk) {
|
await writable.write(value);
|
} else {
|
chunks.push(value);
|
}
|
loaded += value.length;
|
state.downloadLoaded = loaded;
|
}
|
}
|
if (canceled) {
|
if (useDisk) await writable.abort();
|
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;
|
failWith(error);
|
} finally {
|
downloadAbort = null;
|
saveHandle = null;
|
}
|
}
|
|
/** 取消:生成中取消任务,下载中中断下载 */
|
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,
|
minimize,
|
expand,
|
close,
|
};
|
}
|