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.
- package/CHANGELOG.md +93 -0
- package/README.md +4 -4
- package/dist/adapters/index.cjs.js +1 -0
- package/dist/adapters/index.es.js +1 -0
- package/dist/chunks/AdapterFactory-CkjGKGZN.js +1 -0
- package/dist/chunks/AdapterFactory-DA4PnIrY.js +1 -0
- package/dist/chunks/EscPosDriver-BEwkA9KT.js +1 -0
- package/dist/chunks/EscPosDriver-ju4j3bXJ.js +1 -0
- package/dist/chunks/gbk-data-BhfiCGRq.js +1 -0
- package/dist/chunks/gbk-data-Cunq2WDW.js +1 -0
- package/dist/chunks/index-Cee15KIh.js +1 -0
- package/dist/chunks/index-DIquif_6.js +1 -0
- package/dist/chunks/shared-C7eA759x.js +1 -0
- package/dist/chunks/shared-CvHoYVGG.js +1 -0
- package/dist/core/index.cjs.js +1 -0
- package/dist/core/index.es.js +1 -0
- package/dist/drivers/index.cjs.js +1 -0
- package/dist/drivers/index.es.js +1 -0
- package/dist/encoding/index.cjs.js +1 -0
- package/dist/encoding/index.es.js +1 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.es.js +1 -1
- package/dist/types/config/PrinterConfigManager.d.ts +58 -7
- package/dist/types/core/BluetoothPrinter.d.ts +54 -0
- package/dist/types/encoding/GbkTable.d.ts +14 -5
- package/dist/types/index.d.ts +3 -3
- package/dist/types/plugins/builtin/RetryPlugin.d.ts +19 -1
- package/dist/types/plugins/index.d.ts +1 -0
- package/dist/types/services/BatchPrintManager.d.ts +48 -2
- package/package.json +7 -3
- package/src/config/PrinterConfigManager.ts +150 -45
- package/src/core/BluetoothPrinter.ts +115 -5
- package/src/encoding/GbkTable.ts +18 -7
- package/src/index.ts +15 -3
- package/src/plugins/builtin/RetryPlugin.ts +46 -12
- package/src/plugins/index.ts +1 -0
- package/src/services/BatchPrintManager.ts +126 -8
- package/dist/hero-illustration.svg +0 -44
- package/dist/index.umd.js +0 -1
- package/dist/logo.svg +0 -17
- package/dist/manifest.webmanifest +0 -17
- package/dist/offline.html +0 -146
- 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
|
-
//
|
|
82
|
-
if (typeof (arg as
|
|
83
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/encoding/GbkTable.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* GBK Encoding Table - 懒加载版本
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 1. 默认使用精简版编码表 (
|
|
6
|
-
* 2.
|
|
7
|
-
* 3.
|
|
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
|
-
|
|
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 {
|
|
121
|
-
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
package/src/plugins/index.ts
CHANGED
|
@@ -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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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"></></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>
|