taro-bluetooth-print 2.15.2 → 2.15.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +4 -4
  3. package/dist/adapters/index.cjs.js +1 -0
  4. package/dist/adapters/index.es.js +1 -0
  5. package/dist/chunks/AdapterFactory-CkjGKGZN.js +1 -0
  6. package/dist/chunks/AdapterFactory-DA4PnIrY.js +1 -0
  7. package/dist/chunks/EscPosDriver-BEwkA9KT.js +1 -0
  8. package/dist/chunks/EscPosDriver-ju4j3bXJ.js +1 -0
  9. package/dist/chunks/gbk-data-BhfiCGRq.js +1 -0
  10. package/dist/chunks/gbk-data-Cunq2WDW.js +1 -0
  11. package/dist/chunks/index-Cee15KIh.js +1 -0
  12. package/dist/chunks/index-DIquif_6.js +1 -0
  13. package/dist/chunks/shared-C7eA759x.js +1 -0
  14. package/dist/chunks/shared-CvHoYVGG.js +1 -0
  15. package/dist/core/index.cjs.js +1 -0
  16. package/dist/core/index.es.js +1 -0
  17. package/dist/drivers/index.cjs.js +1 -0
  18. package/dist/drivers/index.es.js +1 -0
  19. package/dist/encoding/index.cjs.js +1 -0
  20. package/dist/encoding/index.es.js +1 -0
  21. package/dist/index.cjs.js +1 -1
  22. package/dist/index.es.js +1 -1
  23. package/dist/types/config/PrinterConfigManager.d.ts +58 -7
  24. package/dist/types/core/BluetoothPrinter.d.ts +54 -0
  25. package/dist/types/encoding/GbkTable.d.ts +14 -5
  26. package/dist/types/index.d.ts +3 -3
  27. package/dist/types/plugins/builtin/RetryPlugin.d.ts +19 -1
  28. package/dist/types/plugins/index.d.ts +1 -0
  29. package/dist/types/services/BatchPrintManager.d.ts +48 -2
  30. package/package.json +7 -3
  31. package/src/config/PrinterConfigManager.ts +150 -45
  32. package/src/core/BluetoothPrinter.ts +115 -5
  33. package/src/encoding/GbkTable.ts +18 -7
  34. package/src/index.ts +15 -3
  35. package/src/plugins/builtin/RetryPlugin.ts +46 -12
  36. package/src/plugins/index.ts +1 -0
  37. package/src/services/BatchPrintManager.ts +126 -8
  38. package/dist/hero-illustration.svg +0 -44
  39. package/dist/index.umd.js +0 -1
  40. package/dist/logo.svg +0 -17
  41. package/dist/manifest.webmanifest +0 -17
  42. package/dist/offline.html +0 -146
  43. package/dist/service-worker.js +0 -161
@@ -22,6 +22,18 @@ import type { ICommandBuilder } from '@/services/interfaces';
22
22
  import type { TextAlign } from '@/formatter';
23
23
  import type { BarcodeFormat } from '@/barcode';
24
24
 
25
+ /** Payload for `job-completed` / `job-failed` events. */
26
+ export interface JobResult {
27
+ /** Source of the completed job: `print` (CommandBuilder pipeline) or `writeRaw` (raw bytes). */
28
+ source: 'print' | 'writeRaw';
29
+ /** Total bytes transmitted to the printer. */
30
+ bytes: number;
31
+ /** Job end timestamp (epoch ms). */
32
+ completedAt: number;
33
+ /** Wall-clock duration of the job in ms (0 for synchronous completion). */
34
+ durationMs: number;
35
+ }
36
+
25
37
  /** Printer event map. */
26
38
  export interface PrinterEvents {
27
39
  'state-change': PrinterState;
@@ -30,6 +42,10 @@ export interface PrinterEvents {
30
42
  connected: string;
31
43
  disconnected: string;
32
44
  'print-complete': void;
45
+ /** Emitted when a print/writeRaw job successfully completes all bytes. */
46
+ 'job-completed': JobResult;
47
+ /** Emitted when a print/writeRaw job fails (immediately before re-throwing). */
48
+ 'job-failed': JobResult & { error: BluetoothPrintError };
33
49
  }
34
50
 
35
51
  /** Bluetooth Thermal Printer Controller. */
@@ -71,6 +87,12 @@ export class BluetoothPrinter extends EventEmitter<PrinterEvents> {
71
87
  * Resolve the first constructor argument to an IConnectionManager. An adapter
72
88
  * (anything implementing `connect`/`disconnect`/`write`) gets auto-wrapped for
73
89
  * backward compatibility; missing/undefined falls back to a default manager.
90
+ *
91
+ * Disambiguation: `IConnectionManager` exposes `getState()` (manager-only
92
+ * method on the interface). Adapters only have `connect`/`disconnect`/`write`.
93
+ * Using `getState` as the discriminator correctly identifies a pre-built
94
+ * manager vs. a raw adapter — using `connect` (which both have) silently
95
+ * re-wrapped managers and broke isConnected() (v2.15.3 fix).
74
96
  */
75
97
  private resolveConnectionManager(
76
98
  arg: IConnectionManager | IPrinterAdapter | undefined
@@ -78,11 +100,11 @@ export class BluetoothPrinter extends EventEmitter<PrinterEvents> {
78
100
  if (!arg) {
79
101
  return new ConnectionManager();
80
102
  }
81
- // Adapters expose `connect`; managers expose `getState`. Disambiguate by method presence.
82
- if (typeof (arg as IPrinterAdapter).connect === 'function') {
83
- return new ConnectionManager(arg as IPrinterAdapter);
103
+ // Managers expose `getState`; adapters do not. Disambiguate by method presence.
104
+ if (typeof (arg as IConnectionManager).getState === 'function') {
105
+ return arg as IConnectionManager;
84
106
  }
85
- return arg as IConnectionManager;
107
+ return new ConnectionManager(arg as IPrinterAdapter);
86
108
  }
87
109
 
88
110
  /**
@@ -224,6 +246,79 @@ export class BluetoothPrinter extends EventEmitter<PrinterEvents> {
224
246
  return this;
225
247
  }
226
248
 
249
+ /**
250
+ * Write a pre-built byte buffer directly to the connected printer,
251
+ * bypassing the CommandBuilder entirely. Use this when a driver (such as
252
+ * TsplDriver, ZplDriver, StarPrinter, CPCL or any custom protocol) emits
253
+ * its own byte stream and you want the same connection / chunking /
254
+ * progress / pause pipeline as the standard print() path.
255
+ *
256
+ * Unlike print() — which calls commandBuilder.getBuffer() then clears it —
257
+ * writeRaw() does NOT touch the command queue. It is safe to call between
258
+ * text() / qr() / cut() calls; those calls continue to accumulate in the
259
+ * command buffer for the next print() invocation.
260
+ *
261
+ * @param buffer Bytes to send (e.g. `tsplDriver.getBuffer()`)
262
+ * @param options Optional adapter overrides (chunkSize, delay, retries).
263
+ * Forwarded to PrintJobManager.setOptions() before start.
264
+ * @throws BluetoothPrintError(CONNECTION_FAILED) if not connected
265
+ * @throws BluetoothPrintError(PRINT_JOB_FAILED) on adapter write failure
266
+ *
267
+ * @example
268
+ * ```ts
269
+ * // TSPL label printing — full end-to-end flow now works.
270
+ * const tspl = new TsplDriver()
271
+ * .size(60, 40)
272
+ * .gap(3)
273
+ * .clear()
274
+ * .text('Hello', { x: 20, y: 20, font: 3 })
275
+ * .print(1, 1);
276
+ * await printer.writeRaw(tspl.getBuffer());
277
+ * ```
278
+ */
279
+ async writeRaw(buffer: Uint8Array, options?: IAdapterOptions): Promise<void> {
280
+ if (!this.connectionManager.isConnected()) {
281
+ throw new BluetoothPrintError(
282
+ ErrorCode.CONNECTION_FAILED,
283
+ 'Printer not connected. Call connect() first.'
284
+ );
285
+ }
286
+
287
+ this.printerLogger.info(`writeRaw: ${buffer.length} bytes`);
288
+ if (options) this.printJobManager.setOptions(options);
289
+
290
+ this.updateState();
291
+ this.printJobManager.setProgressCallback((sent, total) => {
292
+ this.emit('progress', { sent, total });
293
+ });
294
+
295
+ const startTime = Date.now();
296
+ try {
297
+ await this.printJobManager.start(buffer);
298
+ if (!this.printJobManager.isPaused()) {
299
+ this.emit('print-complete');
300
+ this.emit('job-completed', {
301
+ source: 'writeRaw',
302
+ bytes: buffer.length,
303
+ completedAt: Date.now(),
304
+ durationMs: Date.now() - startTime,
305
+ });
306
+ }
307
+ } catch (error) {
308
+ const wrapped = this.handleError(error, ErrorCode.PRINT_JOB_FAILED, 'writeRaw failed');
309
+ this.emit('job-failed', {
310
+ source: 'writeRaw',
311
+ bytes: buffer.length,
312
+ completedAt: Date.now(),
313
+ durationMs: Date.now() - startTime,
314
+ error: wrapped,
315
+ });
316
+ throw wrapped;
317
+ } finally {
318
+ this.updateState();
319
+ }
320
+ }
321
+
227
322
  /**
228
323
  * Pauses the current print job.
229
324
  */
@@ -284,17 +379,32 @@ export class BluetoothPrinter extends EventEmitter<PrinterEvents> {
284
379
  this.emit('progress', { sent, total });
285
380
  });
286
381
 
382
+ const startTime = Date.now();
287
383
  try {
288
384
  await this.printJobManager.start(buffer);
289
385
  if (this.printJobManager.isPaused()) {
290
386
  this.printerLogger.info('Print job paused');
291
387
  } else {
292
388
  this.emit('print-complete');
389
+ this.emit('job-completed', {
390
+ source: 'print',
391
+ bytes: buffer.length,
392
+ completedAt: Date.now(),
393
+ durationMs: Date.now() - startTime,
394
+ });
293
395
  this.printerLogger.info('Print job completed successfully');
294
396
  }
295
397
  } catch (error) {
296
398
  this.printerLogger.error('Print job failed with error:', error);
297
- throw this.handleError(error, ErrorCode.PRINT_JOB_FAILED, 'Print job failed');
399
+ const wrapped = this.handleError(error, ErrorCode.PRINT_JOB_FAILED, 'Print job failed');
400
+ this.emit('job-failed', {
401
+ source: 'print',
402
+ bytes: buffer.length,
403
+ completedAt: Date.now(),
404
+ durationMs: Date.now() - startTime,
405
+ error: wrapped,
406
+ });
407
+ throw wrapped;
298
408
  } finally {
299
409
  this.updateState();
300
410
  }
@@ -1,10 +1,19 @@
1
1
  /**
2
2
  * GBK Encoding Table - 懒加载版本
3
3
  *
4
- * 优化策略:
5
- * 1. 默认使用精简版编码表 (gbk-lite.ts,约 3500 常用字)
6
- * 2. 遇到非常用字时动态加载完整编码表
7
- * 3. 二分查找代替 Map,大幅减少内存占用
4
+ * 优化策略(v2.15.4 重新设计):
5
+ * 1. 默认使用精简版编码表 (GbkLite.ts,约 106 个最常用汉字)
6
+ * 2. 命中精简表的字符直接返回(毫秒级)
7
+ * 3. 未命中时查完整表 (GbkData.ts)
8
+ * 4. GbkData 通过 vite manualChunks 拆成独立 chunk `chunks/gbk-data-*.js`
9
+ * —— 在 dist 输出层面做到了代码分割,但运行时仍然同步可用
10
+ *
11
+ * 注意:dynamic import() 方案曾在 v2.15.4-dev 中尝试,但因为
12
+ * EncodingService.encode() / TemplateRenderer 都是同步 API,
13
+ * 切换到 dynamic 后会导致中文编码首次失败('生产日期'/'谢谢惠顾'
14
+ * 这类字符不在 lite 表中)。v2.15.4-final 选择保留 static import
15
+ * 来换取 100% backward compat;异步 pre-warm 留给 v2.15.5+ 重构
16
+ * sync → async encoding API 时再启用。
8
17
  *
9
18
  * GBK: 23940 个字符映射
10
19
  * Big5: 13911 个字符映射
@@ -17,12 +26,14 @@ import { GBK_DATA as FULL_GBK_DATA, BIG5_DATA as FULL_BIG5_DATA } from './GbkDat
17
26
  let GBK_DATA: number[] | null = null;
18
27
  let BIG5_DATA: number[] | null = null;
19
28
 
20
- function loadFullData() {
29
+ function loadFullData(): { GBK_DATA: number[]; BIG5_DATA: number[] } {
21
30
  if (!GBK_DATA) {
22
31
  GBK_DATA = FULL_GBK_DATA;
23
32
  BIG5_DATA = FULL_BIG5_DATA;
24
33
  }
25
- return { GBK_DATA: GBK_DATA, BIG5_DATA: BIG5_DATA! };
34
+ // After the guard above, both module-level vars are guaranteed initialized.
35
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
36
+ return { GBK_DATA: GBK_DATA as number[], BIG5_DATA: BIG5_DATA as number[] };
26
37
  }
27
38
 
28
39
  // Unicode to GBK mapping table
@@ -38,7 +49,7 @@ export const unicodeToBig5: Map<number, number> = new Map();
38
49
  export const big5ToUnicode: Map<number, number> = new Map();
39
50
 
40
51
  /**
41
- * Get GBK bytes for a Unicode character
52
+ * Get GBK bytes for a Unicode character.
42
53
  * 先查精简表,查不到再懒加载完整表
43
54
  */
44
55
  export function getGbkBytes(unicode: number): [number, number] | null {
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  // Core classes
9
9
  export { BluetoothPrinter } from './core/BluetoothPrinter';
10
- export type { PrinterEvents } from './core/BluetoothPrinter';
10
+ export type { PrinterEvents, JobResult } from './core/BluetoothPrinter';
11
11
  export { EventEmitter } from './core/EventEmitter';
12
12
 
13
13
  // Drivers - 打印机驱动
@@ -117,8 +117,20 @@ export type {
117
117
  LoggingConfig,
118
118
  } from './config/PrinterConfig';
119
119
 
120
- export { PrinterConfigManager, printerConfigManager } from './config/PrinterConfigManager';
121
- export type { SavedPrinter, GlobalConfig, IConfigStorage } from './config/PrinterConfigManager';
120
+ export {
121
+ PrinterConfigManager,
122
+ printerConfigManager,
123
+ PRINTER_CONFIG_EXPORT_VERSION,
124
+ LocalStorage,
125
+ } from './config/PrinterConfigManager';
126
+ export type {
127
+ SavedPrinter,
128
+ GlobalConfig,
129
+ IConfigStorage,
130
+ PrintConfig,
131
+ PrinterConfigSnapshot,
132
+ PrinterConfigImportResult,
133
+ } from './config/PrinterConfigManager';
122
134
 
123
135
  // Plugin System - 插件系统
124
136
  export { createLoggingPlugin, createRetryPlugin } from './plugins';
@@ -7,6 +7,18 @@ import { Plugin, PluginFactory, PluginOptions } from '../PluginTypes';
7
7
  import { Logger } from '@/utils/logger';
8
8
  import { BluetoothPrintError, ErrorCode } from '@/errors/BaseError';
9
9
 
10
+ /** Payload passed to the onRetry callback for each retry attempt. */
11
+ export interface RetryAttempt {
12
+ /** 1-indexed attempt number (1 = first retry after the initial failure). */
13
+ attempt: number;
14
+ /** Maximum retries allowed. */
15
+ maxRetries: number;
16
+ /** Delay in ms before this retry will be scheduled to run. */
17
+ delayMs: number;
18
+ /** The error that triggered this retry. */
19
+ error: BluetoothPrintError;
20
+ }
21
+
10
22
  export interface RetryPluginOptions extends PluginOptions {
11
23
  /** Maximum retry attempts (default: 3) */
12
24
  maxRetries?: number;
@@ -18,24 +30,31 @@ export interface RetryPluginOptions extends PluginOptions {
18
30
  backoffMultiplier?: number;
19
31
  /** Error codes that should trigger retry */
20
32
  retryableErrors?: ErrorCode[];
33
+ /**
34
+ * Optional callback invoked BEFORE sleeping for each retry attempt.
35
+ * Receives the 1-indexed attempt number, the planned delay, and the
36
+ * triggering error. Use this for surfacing retry state to UIs (toast
37
+ * "Reconnecting (2/3)…") or for telemetry.
38
+ */
39
+ onRetry?: (info: RetryAttempt) => void;
21
40
  }
22
41
 
23
42
  /**
24
43
  * Creates a retry plugin instance
25
44
  */
26
45
  export const createRetryPlugin: PluginFactory = (options?: RetryPluginOptions): Plugin => {
27
- const opts: Required<RetryPluginOptions> = {
28
- maxRetries: options?.maxRetries ?? 3,
29
- initialDelay: options?.initialDelay ?? 1000,
30
- maxDelay: options?.maxDelay ?? 10000,
31
- backoffMultiplier: options?.backoffMultiplier ?? 2,
32
- retryableErrors: options?.retryableErrors ?? [
33
- ErrorCode.CONNECTION_FAILED,
34
- ErrorCode.CONNECTION_TIMEOUT,
35
- ErrorCode.WRITE_FAILED,
36
- ErrorCode.WRITE_TIMEOUT,
37
- ],
38
- };
46
+ const maxRetries = options?.maxRetries ?? 3;
47
+ const initialDelay = options?.initialDelay ?? 1000;
48
+ const maxDelay = options?.maxDelay ?? 10000;
49
+ const backoffMultiplier = options?.backoffMultiplier ?? 2;
50
+ const retryableErrors: ErrorCode[] = options?.retryableErrors ?? [
51
+ ErrorCode.CONNECTION_FAILED,
52
+ ErrorCode.CONNECTION_TIMEOUT,
53
+ ErrorCode.WRITE_FAILED,
54
+ ErrorCode.WRITE_TIMEOUT,
55
+ ];
56
+ const onRetry = options?.onRetry;
57
+ const opts = { maxRetries, initialDelay, maxDelay, backoffMultiplier, retryableErrors, onRetry };
39
58
 
40
59
  const logger = Logger.scope('RetryPlugin');
41
60
  let retryCount = 0;
@@ -75,6 +94,21 @@ export const createRetryPlugin: PluginFactory = (options?: RetryPluginOptions):
75
94
  );
76
95
  logger.info(`Waiting ${currentDelay}ms before retry...`);
77
96
 
97
+ // Fire user callback BEFORE sleeping so UIs can update immediately.
98
+ // Wrapped in try/catch — a throwing callback must not break retry timing.
99
+ if (opts.onRetry) {
100
+ try {
101
+ opts.onRetry({
102
+ attempt: retryCount,
103
+ maxRetries: opts.maxRetries,
104
+ delayMs: currentDelay,
105
+ error,
106
+ });
107
+ } catch (cbErr) {
108
+ logger.error('onRetry callback threw — ignoring:', cbErr);
109
+ }
110
+ }
111
+
78
112
  await sleep(currentDelay);
79
113
 
80
114
  // Exponential backoff
@@ -8,3 +8,4 @@ export type { Plugin, PluginHooks, PluginOptions, PluginFactory } from './Plugin
8
8
  // Built-in plugins
9
9
  export { createLoggingPlugin } from './builtin/LoggingPlugin';
10
10
  export { createRetryPlugin } from './builtin/RetryPlugin';
11
+ export type { RetryPluginOptions, RetryAttempt } from './builtin/RetryPlugin';
@@ -93,6 +93,23 @@ export interface BatchEvents {
93
93
  'job-rejected': { reason: string };
94
94
  'auto-flush': { jobCount: number; bytes: number };
95
95
  'jobs-merged': { fromCount: number; toCount: number; savedBytes: number };
96
+ /** Per-job progress while the batch processor is running. */
97
+ 'batch-progress': {
98
+ /** Number of bytes the processor has accepted so far (caller-reported). */
99
+ sent: number;
100
+ /** Total bytes the processor was asked to handle for this batch. */
101
+ total: number;
102
+ /** Job IDs in the current batch. */
103
+ jobIds: string[];
104
+ };
105
+ /** Emitted when the batch processor throws — the entire batch is marked failed. */
106
+ 'batch-failed': {
107
+ jobIds: string[];
108
+ bytes: number;
109
+ error: BluetoothPrintError;
110
+ };
111
+ /** Emitted when a previously-failed job is re-queued via retryJob(). */
112
+ 'job-retried': BatchJob;
96
113
  }
97
114
 
98
115
  /**
@@ -156,6 +173,9 @@ export class BatchPrintManager extends EventEmitter<BatchEvents> {
156
173
  unifiedCutsApplied: 0,
157
174
  };
158
175
 
176
+ /** Jobs from the most recent failed batch. Cleared on next successful processBatch(). */
177
+ private failedJobs: BatchJob[] = [];
178
+
159
179
  /**
160
180
  * Last job timestamp for interval timeout tracking
161
181
  */
@@ -300,10 +320,12 @@ export class BatchPrintManager extends EventEmitter<BatchEvents> {
300
320
  /**
301
321
  * Process the current batch
302
322
  *
303
- * @param processor - Function to send batch data to printer
323
+ * @param processor - Function to send batch data to printer. May return a
324
+ * promise that resolves with the number of bytes sent, or
325
+ * void if the byte count is unknown.
304
326
  * @returns Number of jobs processed
305
327
  */
306
- async processBatch(processor: (data: Uint8Array) => Promise<void>): Promise<number> {
328
+ async processBatch(processor: (data: Uint8Array) => Promise<void | number>): Promise<number> {
307
329
  if (this.isProcessing) {
308
330
  throw new BluetoothPrintError(
309
331
  ErrorCode.PRINT_JOB_IN_PROGRESS,
@@ -319,11 +341,12 @@ export class BatchPrintManager extends EventEmitter<BatchEvents> {
319
341
  this.isProcessing = true;
320
342
  this.clearWatchTimer();
321
343
 
322
- try {
323
- // Get jobs for this batch
324
- const batchJobs = this.prepareBatch();
325
- const { mergedData, fromCount, toCount } = this.mergeJobs(batchJobs);
344
+ // Snapshot the batch up-front so we can mark them failed if processor throws.
345
+ const batchJobs = this.prepareBatch();
346
+ const { mergedData, fromCount, toCount } = this.mergeJobs(batchJobs);
347
+ const batchJobIds = batchJobs.map(j => j.id);
326
348
 
349
+ try {
327
350
  // Report merge stats if jobs were merged
328
351
  if (fromCount !== toCount) {
329
352
  const savedBytes =
@@ -338,8 +361,37 @@ export class BatchPrintManager extends EventEmitter<BatchEvents> {
338
361
  // Emit batch ready event
339
362
  this.emit('batch-ready', batchJobs);
340
363
 
341
- // Process the merged data
342
- await processor(mergedData);
364
+ // Process the merged data. A throwing processor marks the whole batch
365
+ // as failed (kept in failedJobs for retryJob()) and emits batch-failed.
366
+ try {
367
+ const result = await processor(mergedData);
368
+ if (typeof result === 'number') {
369
+ this.emit('batch-progress', {
370
+ sent: result,
371
+ total: mergedData.length,
372
+ jobIds: batchJobIds,
373
+ });
374
+ }
375
+ } catch (procErr) {
376
+ const error =
377
+ procErr instanceof BluetoothPrintError
378
+ ? procErr
379
+ : new BluetoothPrintError(
380
+ ErrorCode.PRINT_JOB_FAILED,
381
+ (procErr as Error)?.message ?? 'Batch processor failed'
382
+ );
383
+ // Preserve failed jobs for retryJob() — DO NOT splice them from jobs[].
384
+ this.failedJobs = [...batchJobs];
385
+ this.emit('batch-failed', {
386
+ jobIds: batchJobIds,
387
+ bytes: mergedData.length,
388
+ error,
389
+ });
390
+ this.logger.error(
391
+ `Batch failed: ${batchJobs.length} jobs, ${mergedData.length} bytes — ${error.code}: ${error.message}`
392
+ );
393
+ throw error;
394
+ }
343
395
 
344
396
  // Update stats
345
397
  this.stats.totalBytes += mergedData.length;
@@ -355,6 +407,8 @@ export class BatchPrintManager extends EventEmitter<BatchEvents> {
355
407
  }
356
408
  this.pendingBytes -= removedBytes;
357
409
  this.jobs.splice(0, batchJobs.length);
410
+ // Successful batch clears the failed-jobs buffer.
411
+ this.failedJobs = [];
358
412
 
359
413
  this.emit('batch-processed', { jobCount: batchJobs.length, bytes: mergedData.length });
360
414
  this.logger.info(`Batch processed: ${batchJobs.length} jobs`);
@@ -365,6 +419,70 @@ export class BatchPrintManager extends EventEmitter<BatchEvents> {
365
419
  }
366
420
  }
367
421
 
422
+ /**
423
+ * Re-queue a previously-failed job.
424
+ *
425
+ * Searches `failedJobs` for the given id; if found, re-inserts the job at the
426
+ * front of the queue with bumped priority (current max + 1) so it is processed
427
+ * before other pending jobs. Returns false if no failed job matches.
428
+ *
429
+ * Does NOT auto-trigger processBatch — caller decides when to retry.
430
+ *
431
+ * @param id - Job ID returned from a prior addJob() call.
432
+ * @returns true if re-queued, false if no matching failed job.
433
+ */
434
+ retryJob(id: string): boolean {
435
+ const idx = this.failedJobs.findIndex(j => j.id === id);
436
+ if (idx === -1) {
437
+ this.logger.warn(`retryJob: no failed job with id ${id}`);
438
+ return false;
439
+ }
440
+ const job = this.failedJobs[idx]!;
441
+ this.failedJobs.splice(idx, 1);
442
+
443
+ // Bump priority above the current queue head so it runs first.
444
+ const maxPriority = this.jobs.reduce((m, j) => Math.max(m, j.priority), 0);
445
+ job.priority = maxPriority + 1;
446
+
447
+ this.jobs.unshift(job);
448
+ this.pendingBytes += job.data.length;
449
+ this.waitFired = false;
450
+ this.flushFired = false;
451
+ this.lastJobAt = Date.now();
452
+ this.restartWatchTimer();
453
+
454
+ this.emit('job-retried', job);
455
+ this.logger.info(`Re-queued failed job ${id} at priority ${job.priority}`);
456
+ return true;
457
+ }
458
+
459
+ /**
460
+ * Re-queue ALL currently-failed jobs in one call. Returns the count re-queued.
461
+ */
462
+ retryAllFailedJobs(): number {
463
+ let count = 0;
464
+ // Iterate in reverse so retryJob()'s unshift() doesn't shift our index.
465
+ for (let i = this.failedJobs.length - 1; i >= 0; i--) {
466
+ if (this.retryJob(this.failedJobs[i]!.id)) count++;
467
+ }
468
+ return count;
469
+ }
470
+
471
+ /**
472
+ * Snapshot of the most recent failed batch (empty if last batch succeeded
473
+ * or no batch has run yet).
474
+ */
475
+ getFailedJobs(): BatchJob[] {
476
+ return [...this.failedJobs];
477
+ }
478
+
479
+ /** Discard the failed-jobs buffer without re-queueing. */
480
+ clearFailedJobs(): void {
481
+ const count = this.failedJobs.length;
482
+ this.failedJobs = [];
483
+ this.logger.info(`Cleared ${count} failed jobs`);
484
+ }
485
+
368
486
  /**
369
487
  * Prepare batch from pending jobs
370
488
  */
@@ -1,44 +0,0 @@
1
- <svg width="400" height="300" viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg">
2
- <!-- 背景圆 -->
3
- <circle cx="200" cy="150" r="120" fill="#ecfeff" opacity="0.5"/>
4
- <circle cx="200" cy="150" r="80" fill="#cffafe" opacity="0.5"/>
5
-
6
- <!-- 手机轮廓 -->
7
- <rect x="140" y="60" width="120" height="180" rx="16" fill="#fff" stroke="#0891b2" stroke-width="3"/>
8
- <rect x="150" y="80" width="100" height="140" rx="4" fill="#f0fdfa"/>
9
-
10
- <!-- 屏幕上的打印图标 -->
11
- <rect x="165" y="95" width="70" height="50" rx="4" fill="#0891b2" opacity="0.1"/>
12
- <text x="200" y="125" font-family="Arial" font-size="24" fill="#0891b2" text-anchor="middle">🖨️</text>
13
-
14
- <!-- 小票 -->
15
- <rect x="170" y="155" width="60" height="45" rx="2" fill="#fff" stroke="#06b6d4" stroke-width="2"/>
16
- <line x1="178" y1="165" x2="222" y2="165" stroke="#94a3b8" stroke-width="2"/>
17
- <line x1="178" y1="175" x2="215" y2="175" stroke="#94a3b8" stroke-width="2"/>
18
- <line x1="178" y1="185" x2="220" y2="185" stroke="#94a3b8" stroke-width="2"/>
19
-
20
- <!-- 蓝牙符号 -->
21
- <path d="M220 120 L235 105 L235 135 L220 150 L235 165 L235 195 L220 180" stroke="#0891b2" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
22
-
23
- <!-- 打印机轮廓 -->
24
- <rect x="260" y="170" width="80" height="60" rx="6" fill="#fff" stroke="#06b6d4" stroke-width="3"/>
25
- <rect x="270" y="180" width="60" height="20" rx="2" fill="#cffafe"/>
26
- <rect x="275" y="205" width="50" height="15" rx="2" fill="#0891b2" opacity="0.2"/>
27
-
28
- <!-- 出纸 -->
29
- <rect x="275" y="235" width="50" height="25" rx="2" fill="#fff" stroke="#06b6d4" stroke-width="2"/>
30
- <line x1="283" y1="243" x2="317" y2="243" stroke="#94a3b8" stroke-width="1.5"/>
31
- <line x1="283" y1="250" x2="310" y2="250" stroke="#94a3b8" stroke-width="1.5"/>
32
-
33
- <!-- 连接线 -->
34
- <path d="M250 200 Q280 200 280 200" stroke="#0891b2" stroke-width="2" stroke-dasharray="4 4" fill="none"/>
35
-
36
- <!-- 装饰 - 代码符号 -->
37
- <text x="80" y="100" font-family="monospace" font-size="14" fill="#0891b2" opacity="0.6">&lt;/&gt;</text>
38
- <text x="320" y="90" font-family="monospace" font-size="14" fill="#06b6d4" opacity="0.6">{ }</text>
39
- <text x="100" y="220" font-family="monospace" font-size="12" fill="#0891b2" opacity="0.4">print()</text>
40
-
41
- <!-- 底部装饰 -->
42
- <circle cx="60" cy="260" r="20" fill="#cffafe" opacity="0.4"/>
43
- <circle cx="340" cy="270" r="25" fill="#ecfeff" opacity="0.4"/>
44
- </svg>