liu
3 天以前 ee7ac8df8bce38add1f75baf876b49de94b9e00a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
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,
  };
}