codex-work-receipt 0.6.0 → 0.6.1

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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - 将 HTML 小票底部的重复渐变装饰替换为按小票编号生成的真实 Code 128 B 条形码,并保留适合浏览器和长图导出的静区与清晰度。
6
+ - 优化 multipart 扫码交互:单数据码继续与小程序码并排,多分片时先展示小程序码,再自动逐张轮播唯一可见的数据码,避免镜头同时识别多个二维码。
7
+
3
8
  ## 0.6.0
4
9
 
5
10
  - 新增 cwr2 协议:按“会话 × 记账自然日”生成稳定的 canonical facts,重叠的今日、近 7 日和本周小票可由接收端可靠去重。
package/README.en.md CHANGED
@@ -22,7 +22,7 @@
22
22
 
23
23
  Codex Work Receipt summarizes turns, tool calls, Tokens, duration, and models, then turns them into playful AI work points, a job title, and a review. Receipts never include prompts, responses, source code, project paths, or file names.
24
24
 
25
- Version `0.6.0` uses the cwr2 protocol to create stable privacy-safe facts for each session and calendar day. Overlapping today, last-seven-days, and this-week receipts can be deduplicated by the receiver, and oversized payloads are automatically split into reorderable multipart QR codes.
25
+ Version `0.6.0` uses the cwr2 protocol to create stable privacy-safe facts for each session and calendar day. Overlapping today, last-seven-days, and this-week receipts can be deduplicated by the receiver, and oversized payloads are automatically split into reorderable multipart QR codes that rotate one at a time in the HTML so the camera never sees multiple data codes at once.
26
26
 
27
27
  ## Quickstart
28
28
 
package/README.md CHANGED
@@ -22,7 +22,7 @@
22
22
 
23
23
  它会统计 Codex 会话中的轮次、工具调用、Token、时长和模型,生成带有 AI 工分、今日工种和点评的小票。不会把 Prompt、回复正文、代码、项目路径或文件名写入小票。
24
24
 
25
- `0.6.0` 使用 cwr2 协议,按“会话 × 自然日”生成稳定的脱敏事实。今日、近 7 日和本周小票即使范围重叠,接收端也能识别相同工作;数据过大时会自动生成多张可重组的分片二维码。
25
+ `0.6.0` 使用 cwr2 协议,按“会话 × 自然日”生成稳定的脱敏事实。今日、近 7 日和本周小票即使范围重叠,接收端也能识别相同工作;数据过大时会自动生成多张可重组的分片二维码,并在 HTML 中逐张自动轮播,避免镜头同时识别多个数据码。
26
26
 
27
27
  ## Quickstart
28
28
 
package/docs/cli.en.md CHANGED
@@ -79,7 +79,7 @@ Default output:
79
79
 
80
80
  Default filenames include the calendar range. Latest-session and selected-session receipts include a short identifier so receipts from different dates or sessions do not overwrite one another.
81
81
 
82
- Open the generated HTML and click “Save full PNG” to download a high-resolution image. It includes the main receipt, mini-program code, and data QR import stub in the currently selected theme. Theme controls, the page background, and the footer note are excluded. When cwr2 data exceeds the safe capacity of one QR code, the import stub automatically shows numbered multipart codes that must all be scanned in the mini program.
82
+ Open the generated HTML and click “Save full PNG” to download a high-resolution image. It includes the main receipt, mini-program code, and data QR import stub in the currently selected theme. Theme controls, the page background, and the footer note are excluded. With one data QR, the mini-program code and data code remain side by side. When cwr2 exceeds the safe capacity of one QR code, the page shows the mini-program code first and then rotates multipart data codes one at a time without desktop clicks. The full PNG still includes every numbered part so the offline payload remains complete.
83
83
 
84
84
  Set a timezone and output path:
85
85
 
package/docs/cli.md CHANGED
@@ -79,7 +79,7 @@ npx codex-work-receipt@latest --latest --theme diner
79
79
 
80
80
  默认文件名会携带统计日期范围;最近会话和指定会话则携带短编号,避免不同日期或会话的小票互相覆盖。
81
81
 
82
- 打开生成的 HTML 后,点击“保存完整长图”即可下载高清 PNG。图片包含主小票、小程序码和数据二维码导入联,并自动使用当前选择的主题;主题按钮、网页背景和底部说明不会进入图片。cwr2 数据超过单个二维码的安全容量时,导入联会自动展示多张带序号的分片二维码,需在小程序中全部扫完。
82
+ 打开生成的 HTML 后,点击“保存完整长图”即可下载高清 PNG。图片包含主小票、小程序码和数据二维码导入联,并自动使用当前选择的主题;主题按钮、网页背景和底部说明不会进入图片。只有一个数据二维码时,小程序码与数据码继续并排展示。cwr2 数据超过单码安全容量时,网页先单独展示小程序码,随后自动逐张轮播分片数据码;用户无需在电脑上切换上一张或下一张,但仍需在小程序中收齐全部分片。完整长图为保留离线数据,会继续包含全部带序号的分片二维码。
83
83
 
84
84
  指定时区和输出路径:
85
85
 
@@ -24,8 +24,12 @@ The QR payload uses compact fields:
24
24
 
25
25
  ```text
26
26
  cwr1.<checksum>.<Base64URL of deflateRaw(JSON)>
27
+ cwr2.<checksum>.<Base64URL of deflateRaw(JSON)>
28
+ cwr2p.<transferId>.<partIndex>.<partCount>.<totalChecksum>.<partChecksum>.<chunk>
27
29
  ```
28
30
 
31
+ `cwr1` and `cwr2` are complete single-code payloads. `cwr2p` splits the cwr2 string into up to 12 reorderable parts. The mini program collects a transfer by transferId, validates the total checksum, reconstructs cwr2, and only then decompresses it. Multipart HTML rotates one data code at a time; the protocol does not require scan order.
32
+
29
33
  The mini program validates the prefix and checksum before decompressing and parsing the payload. Future schema versions use the compact `v` field for compatibility.
30
34
 
31
35
  Compact field `o` explicitly carries `latest`, `session`, `today`, `last-7-days`, or `this-week`. `d[3]` and `d[4]` carry the calendar boundaries. These additive fields keep the `cwr1` prefix and schema version `1`, so the updated mini program can still import older QR codes.
@@ -24,8 +24,12 @@
24
24
 
25
25
  ```text
26
26
  cwr1.<checksum>.<deflateRaw(JSON) 的 Base64URL>
27
+ cwr2.<checksum>.<deflateRaw(JSON) 的 Base64URL>
28
+ cwr2p.<transferId>.<partIndex>.<partCount>.<totalChecksum>.<partChecksum>.<chunk>
27
29
  ```
28
30
 
31
+ `cwr1` 和 `cwr2` 是完整单码。`cwr2p` 是 cwr2 单码字符串的可乱序分片,最多 12 片;小程序先按 transferId 收集全部分片,再校验总 checksum、拼回 cwr2 并解压。HTML 在多分片时每次只轮播一个数据码,协议本身不依赖扫描顺序。
32
+
29
33
  小程序应检查前缀和校验值,再解压并解析数据。未来结构升级通过 `v` 字段兼容。
30
34
 
31
35
  精简字段 `o` 显式携带 `latest`、`session`、`today`、`last-7-days` 或 `this-week`;`d[3]` 和 `d[4]` 携带自然日边界。新增字段保持 `cwr1` 和结构版本 `1`,旧二维码仍可由新版小程序导入。
@@ -6,9 +6,9 @@ The desktop page shows both the fixed mini-program code and a data QR code for t
6
6
 
7
7
  1. Scan the fixed mini-program code in WeChat and open the companion mini program.
8
8
  2. Tap the desktop-import action.
9
- 3. Scan the adjacent data QR code.
9
+ 3. Scan the adjacent data QR code. Multipart receipts rotate one part at a time on the computer, while the mini program automatically reopens scanning until all parts are collected.
10
10
  4. Validate the `cwr1`, `cwr2`, or multipart `cwr2p` prefix, checksum, and schema version.
11
- 5. Decompress and parse the privacy-safe metrics.
11
+ 5. Single codes decompress immediately. Multipart codes can arrive out of order, and missed parts are collected on the next rotation before the privacy-safe metrics are parsed.
12
12
  6. Recognize a session, today, last-seven-days, or this-week range. New receipts use canonical facts for deduplication and are stored in the anonymous account database by default.
13
13
  7. Render the selected template with Canvas and save it to the phone.
14
14
 
@@ -6,9 +6,9 @@
6
6
 
7
7
  1. 微信扫描固定小程序码并打开配套小程序。
8
8
  2. 点击“从电脑导入”。
9
- 3. 扫描网页旁边的数据二维码。
9
+ 3. 扫描网页旁边的数据二维码;多分片小票会在电脑上自动逐张轮播,小程序在未集齐时自动继续打开扫码。
10
10
  4. 小程序校验 `cwr1`、`cwr2` 或 `cwr2p` 分片前缀、checksum 和结构版本。
11
- 5. 解压并解析脱敏统计数据。
11
+ 5. 单码立即解压;多分片允许乱序、漏扫后等待下一轮补齐,全部到齐再解析脱敏统计数据。
12
12
  6. 识别单会话、今日、近七日或本周范围;新版小票按规范事实去重,完整记录默认保存到匿名账号数据库。
13
13
  7. 使用 Canvas 重新绘制所选模板并保存到手机相册。
14
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-work-receipt",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "description": "Generate a privacy-first Codex work receipt from local session metadata.",
6
6
  "author": "a-bai-2026",
@@ -0,0 +1,52 @@
1
+ const CODE128_PATTERNS = [
2
+ "212222", "222122", "222221", "121223", "121322", "131222", "122213", "122312", "132212", "221213", "221312", "231212",
3
+ "112232", "122132", "122231", "113222", "123122", "123221", "223211", "221132", "221231", "213212", "223112", "312131",
4
+ "311222", "321122", "321221", "312212", "322112", "322211", "212123", "212321", "232121", "111323", "131123", "131321",
5
+ "112313", "132113", "132311", "211313", "231113", "231311", "112133", "112331", "132131", "113123", "113321", "133121",
6
+ "313121", "211331", "231131", "213113", "213311", "213131", "311123", "311321", "331121", "312113", "312311", "332111",
7
+ "314111", "221411", "431111", "111224", "111422", "121124", "121421", "141122", "141221", "112214", "112412", "122114",
8
+ "122411", "142112", "142211", "241211", "221114", "413111", "241112", "134111", "111242", "121142", "121241", "114212",
9
+ "124112", "124211", "411212", "421112", "421211", "212141", "214121", "412121", "111143", "111341", "131141", "114113",
10
+ "114311", "411113", "411311", "113141", "114131", "311141", "411131", "211412", "211214", "211232", "2331112",
11
+ ];
12
+
13
+ const START_CODE_B = 104;
14
+ const STOP_CODE = 106;
15
+
16
+ function normalizeCode128Value(value) {
17
+ return [...String(value)]
18
+ .map((character) => {
19
+ const code = character.charCodeAt(0);
20
+ return code >= 32 && code <= 126 ? character : "?";
21
+ })
22
+ .join("")
23
+ .slice(0, 48) || "CWR";
24
+ }
25
+
26
+ export function buildCode128B(value) {
27
+ const normalized = normalizeCode128Value(value);
28
+ const dataCodes = [...normalized].map((character) => character.charCodeAt(0) - 32);
29
+ const checksum = dataCodes.reduce(
30
+ (total, code, index) => total + code * (index + 1),
31
+ START_CODE_B,
32
+ ) % 103;
33
+ const codes = [START_CODE_B, ...dataCodes, checksum, STOP_CODE];
34
+ const segments = [];
35
+
36
+ for (const code of codes) {
37
+ const pattern = CODE128_PATTERNS[code];
38
+ for (const [index, width] of [...pattern].entries()) {
39
+ segments.push({
40
+ isBar: index % 2 === 0,
41
+ width: Number(width),
42
+ });
43
+ }
44
+ }
45
+
46
+ return {
47
+ value: normalized,
48
+ checksum,
49
+ segments,
50
+ totalModules: segments.reduce((total, segment) => total + segment.width, 0),
51
+ };
52
+ }
@@ -133,6 +133,12 @@ const RECEIPT_COPY = {
133
133
  openMiniProgramHint: "微信扫码进入 AI 打工图鉴",
134
134
  importData: "2 · 扫描导入数据",
135
135
  importDataHint: "在小程序里点击“从电脑导入”后扫描",
136
+ multipartOpenTitle: "先打开小程序",
137
+ multipartOpenHint: "数据码将在 {seconds} 秒后自动开始轮播,无需点击电脑",
138
+ multipartTransferTitle: "保持手机对准屏幕",
139
+ multipartTransferHint: "数据码会自动逐张轮播;漏扫或重复都没关系",
140
+ multipartPartLabel: "数据分片 {current}/{total}",
141
+ multipartRestart: "重新显示小程序码",
136
142
  dataQrAlt: "当前小票数据二维码",
137
143
  miniProgramAlt: "微信小程序码",
138
144
  placeholderLabel: "小程序码",
@@ -199,6 +205,12 @@ const RECEIPT_COPY = {
199
205
  openMiniProgramHint: "Scan with WeChat to open AI Work Archive",
200
206
  importData: "2 · Import receipt data",
201
207
  importDataHint: "Tap “从电脑导入小票” in the mini program, then scan this code",
208
+ multipartOpenTitle: "Open the mini program first",
209
+ multipartOpenHint: "The data codes will start rotating automatically in {seconds}s. No desktop click is required.",
210
+ multipartTransferTitle: "Keep your phone pointed at the screen",
211
+ multipartTransferHint: "Data codes rotate automatically. Missed or duplicate scans are safe.",
212
+ multipartPartLabel: "Data part {current}/{total}",
213
+ multipartRestart: "Show mini-program code again",
202
214
  dataQrAlt: "Current receipt data QR code",
203
215
  miniProgramAlt: "WeChat mini-program code",
204
216
  placeholderLabel: "Mini program",
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
 
4
+ import { buildCode128B } from "../core/barcode.mjs";
4
5
  import { formatDate, formatDuration, formatNumber, formatTime } from "../lib/time.mjs";
5
6
  import { buildCompensation, DEFAULT_LOCALE, getReceiptCopy } from "../core/presentation.mjs";
6
7
 
@@ -24,6 +25,16 @@ function receiptRow(label, value, emphasize = false) {
24
25
  return `<div class="receipt-row${emphasize ? " receipt-row--emphasize" : ""}"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
25
26
  }
26
27
 
28
+ function receiptBarcode(value) {
29
+ const barcode = buildCode128B(value);
30
+ const segments = barcode.segments.map((segment) => {
31
+ const kind = segment.isBar ? "bar" : "space";
32
+ return `<span class="barcode-segment barcode-segment--${kind}" style="flex-grow:${segment.width}"></span>`;
33
+ }).join("");
34
+
35
+ return `<div class="barcode" data-barcode-value="${escapeHtml(barcode.value)}" aria-hidden="true">${segments}</div>`;
36
+ }
37
+
27
38
  function formatCount(value, units, locale) {
28
39
  const amount = Math.max(0, Math.round(value || 0));
29
40
  const unit = units[amount === 1 ? 0 : 1];
@@ -36,6 +47,13 @@ function formatDateKey(value, locale) {
36
47
  return locale === "en" ? `${match[2]}/${match[3]}/${match[1]}` : `${match[1]}/${match[2]}/${match[3]}`;
37
48
  }
38
49
 
50
+ function formatCopy(template, values) {
51
+ return Object.entries(values).reduce(
52
+ (result, [key, value]) => result.replaceAll(`{${key}}`, String(value)),
53
+ String(template),
54
+ );
55
+ }
56
+
39
57
  export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null, miniProgramCodeDataUrl = null }) {
40
58
  const locale = record.locale || DEFAULT_LOCALE;
41
59
  const copy = getReceiptCopy(locale);
@@ -56,7 +74,8 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
56
74
  : `${formatTime(startAt, timezone, locale)}—${formatTime(endAt, timezone, locale)}`;
57
75
  const businessPeriodLabel = isCalendarScope ? copy.meta.period : copy.meta.hours;
58
76
  const modelLabel = record.stats.models.length ? record.stats.models.join(" / ") : copy.modelMissing;
59
- const receiptNumber = `${record.id.slice(4, 12).toUpperCase()}-${String(record.stats.completed_turns).padStart(3, "0")}`;
77
+ const shortReceiptId = record.id.replace(/^cwr2?_/, "").slice(0, 8).toUpperCase() || "UNKNOWN";
78
+ const receiptNumber = `${shortReceiptId}-${String(record.stats.completed_turns).padStart(3, "0")}`;
60
79
  const compensation = record.presentation.compensation || buildCompensation(record.source.scope, 0, locale);
61
80
  const responseSeconds = new Intl.NumberFormat(locale === "en" ? "en-US" : "zh-CN", {
62
81
  minimumFractionDigits: 1,
@@ -91,11 +110,56 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
91
110
  ? [dataQrDataUrl]
92
111
  : [];
93
112
  const dataQrItems = qrUrls.map((url, index) => `
94
- <div class="qr-item">
113
+ <div class="qr-item" data-data-qr-index="${index}">
95
114
  <div class="qr-frame"><img src="${url}" alt="${escapeHtml(copy.dataQrAlt)} ${index + 1}/${qrUrls.length}"></div>
96
115
  <strong>${escapeHtml(copy.importData)}${qrUrls.length > 1 ? ` ${index + 1}/${qrUrls.length}` : ""}</strong>
97
116
  <span>${escapeHtml(copy.importDataHint)}</span>
98
117
  </div>`).join("");
118
+ const isMultipart = qrUrls.length > 1;
119
+ const multipartSetupSeconds = 10;
120
+ const multipartConfig = JSON.stringify({
121
+ enabled: isMultipart,
122
+ setupSeconds: multipartSetupSeconds,
123
+ frameMs: 4000,
124
+ blankMs: 240,
125
+ setupHint: copy.multipartOpenHint,
126
+ partLabel: copy.multipartPartLabel,
127
+ }).replaceAll("<", "\\u003c");
128
+ const transferVisual = isMultipart
129
+ ? `
130
+ <div class="multipart-live" id="multipart-live">
131
+ <div class="multipart-panel multipart-setup" id="multipart-setup">
132
+ <div class="qr-item multipart-setup__item">
133
+ <div class="qr-frame qr-frame--large">${miniProgramVisual}</div>
134
+ <strong>${escapeHtml(copy.multipartOpenTitle)}</strong>
135
+ <span id="multipart-setup-hint">${escapeHtml(formatCopy(copy.multipartOpenHint, { seconds: multipartSetupSeconds }))}</span>
136
+ </div>
137
+ </div>
138
+ <div class="multipart-panel multipart-stage" id="multipart-stage" hidden>
139
+ <div class="qr-frame qr-frame--large"><img id="multipart-active-qr" alt="${escapeHtml(copy.dataQrAlt)}"></div>
140
+ <strong class="multipart-stage__title">${escapeHtml(copy.multipartTransferTitle)}</strong>
141
+ <span class="multipart-stage__part" id="multipart-part-label">${escapeHtml(formatCopy(copy.multipartPartLabel, { current: 1, total: qrUrls.length }))}</span>
142
+ <span class="multipart-stage__hint">${escapeHtml(copy.multipartTransferHint)}</span>
143
+ <button class="multipart-secondary" id="multipart-show-mini" type="button">${escapeHtml(copy.multipartRestart)}</button>
144
+ </div>
145
+ </div>
146
+ <div class="qr-grid qr-grid--export-only" hidden>
147
+ <div class="qr-item">
148
+ <div class="qr-frame">${miniProgramVisual}</div>
149
+ <strong>${escapeHtml(copy.openMiniProgram)}</strong>
150
+ <span>${escapeHtml(copy.openMiniProgramHint)}</span>
151
+ </div>
152
+ ${dataQrItems}
153
+ </div>`
154
+ : `
155
+ <div class="qr-grid qr-grid--single">
156
+ <div class="qr-item">
157
+ <div class="qr-frame">${miniProgramVisual}</div>
158
+ <strong>${escapeHtml(copy.openMiniProgram)}</strong>
159
+ <span>${escapeHtml(copy.openMiniProgramHint)}</span>
160
+ </div>
161
+ ${dataQrItems}
162
+ </div>`;
99
163
 
100
164
  return `<!doctype html>
101
165
  <html lang="${escapeHtml(copy.htmlLang)}" data-theme="${escapeHtml(record.presentation.default_theme)}">
@@ -313,11 +377,28 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
313
377
  color: var(--muted);
314
378
  }
315
379
  .barcode {
380
+ display: flex;
381
+ align-items: stretch;
382
+ box-sizing: border-box;
383
+ width: 100%;
316
384
  height: 48px;
317
385
  margin: 18px auto 8px;
318
- max-width: 280px;
319
- background: repeating-linear-gradient(90deg, var(--ink) 0 2px, transparent 2px 4px, var(--ink) 4px 5px, transparent 5px 8px);
320
- opacity: .84;
386
+ max-width: 320px;
387
+ padding: 0 20px;
388
+ overflow: hidden;
389
+ }
390
+ .barcode-segment {
391
+ display: block;
392
+ flex-basis: 0;
393
+ flex-shrink: 0;
394
+ min-width: 0;
395
+ height: 100%;
396
+ }
397
+ .barcode-segment--bar {
398
+ background: var(--ink);
399
+ }
400
+ .barcode-segment--space {
401
+ background: transparent;
321
402
  }
322
403
  .footer {
323
404
  text-align: center;
@@ -352,6 +433,39 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
352
433
  .qr-frame img { display: block; width: 100%; height: 100%; object-fit: contain; }
353
434
  .qr-item strong { display: block; font-size: 12px; }
354
435
  .qr-item span { display: block; margin-top: 4px; color: var(--muted); font-size: 10px; line-height: 1.45; }
436
+ .qr-grid--export-only { display: none; }
437
+ .multipart-live { margin-top: 20px; }
438
+ .multipart-panel {
439
+ display: grid;
440
+ place-items: center;
441
+ min-height: 310px;
442
+ padding: 18px;
443
+ border: 1px solid var(--line);
444
+ background: color-mix(in srgb, var(--paper) 94%, var(--ink));
445
+ text-align: center;
446
+ }
447
+ .multipart-panel[hidden] { display: none; }
448
+ .multipart-setup__item { width: 100%; }
449
+ .qr-frame--large { width: min(100%, 250px); }
450
+ .multipart-stage__title,
451
+ .multipart-stage__part,
452
+ .multipart-stage__hint { display: block; }
453
+ .multipart-stage__title { margin-top: 4px; font-size: 14px; }
454
+ .multipart-stage__part { margin-top: 8px; font-size: 12px; font-weight: 800; letter-spacing: .06em; }
455
+ .multipart-stage__hint { max-width: 360px; margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.5; }
456
+ #multipart-active-qr[aria-busy="true"] { visibility: hidden; }
457
+ .multipart-secondary {
458
+ margin-top: 14px;
459
+ padding: 7px 12px;
460
+ border: 1px solid var(--line);
461
+ border-radius: 999px;
462
+ color: var(--ink);
463
+ background: transparent;
464
+ font: inherit;
465
+ font-size: 10px;
466
+ cursor: pointer;
467
+ }
468
+ .multipart-secondary:hover { background: color-mix(in srgb, var(--ink) 7%, transparent); }
355
469
  .mini-placeholder {
356
470
  display: grid;
357
471
  place-content: center;
@@ -436,7 +550,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
436
550
  <strong>${escapeHtml(formatNumber(compensation.amount, locale))}<small>${escapeHtml(compensation.unit)}</small></strong>
437
551
  </div>
438
552
  </section>
439
- <div class="barcode" aria-hidden="true"></div>
553
+ ${receiptBarcode(receiptNumber)}
440
554
  <footer class="footer">
441
555
  <div>MODEL · ${escapeHtml(modelLabel)}</div>
442
556
  <div>${escapeHtml(copy.footerThanks)}</div>
@@ -448,14 +562,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
448
562
  <h2>${escapeHtml(copy.transferTitle)}</h2>
449
563
  <p>${escapeHtml(copy.transferDescription)}</p>
450
564
  </header>
451
- <div class="qr-grid">
452
- <div class="qr-item">
453
- <div class="qr-frame">${miniProgramVisual}</div>
454
- <strong>${escapeHtml(copy.openMiniProgram)}</strong>
455
- <span>${escapeHtml(copy.openMiniProgramHint)}</span>
456
- </div>
457
- ${dataQrItems}
458
- </div>
565
+ ${transferVisual}
459
566
  <p class="transfer-note">${escapeHtml(copy.transferNote)}</p>
460
567
  </section>
461
568
  </div>
@@ -465,6 +572,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
465
572
  <script>${inlineScript(DOM_TO_IMAGE_SOURCE)}</script>
466
573
  <script>
467
574
  const exportConfig = ${exportConfig};
575
+ const multipartConfig = ${multipartConfig};
468
576
  const themes = new Set(["classic", "diner", "payroll"]);
469
577
  const buttons = [...document.querySelectorAll("[data-theme-value]")];
470
578
  function applyTheme(theme) {
@@ -478,6 +586,88 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
478
586
  try { savedTheme = localStorage.getItem("codex-work-receipt-theme"); } catch {}
479
587
  applyTheme(savedTheme || document.documentElement.dataset.theme);
480
588
 
589
+ const multipartLive = document.getElementById("multipart-live");
590
+ if (multipartConfig.enabled && multipartLive) {
591
+ const setup = document.getElementById("multipart-setup");
592
+ const setupHint = document.getElementById("multipart-setup-hint");
593
+ const stage = document.getElementById("multipart-stage");
594
+ const activeQr = document.getElementById("multipart-active-qr");
595
+ const partLabel = document.getElementById("multipart-part-label");
596
+ const showMiniButton = document.getElementById("multipart-show-mini");
597
+ const urls = [...document.querySelectorAll(".qr-grid--export-only [data-data-qr-index] img")]
598
+ .map((image) => image.src)
599
+ .filter(Boolean);
600
+ let setupTimer = null;
601
+ let rotationTimer = null;
602
+ let switchTimer = null;
603
+ let activeIndex = 0;
604
+
605
+ function stopMultipartTimers() {
606
+ if (setupTimer !== null) clearInterval(setupTimer);
607
+ if (rotationTimer !== null) clearInterval(rotationTimer);
608
+ if (switchTimer !== null) clearTimeout(switchTimer);
609
+ setupTimer = null;
610
+ rotationTimer = null;
611
+ switchTimer = null;
612
+ }
613
+
614
+ function setupHintText(seconds) {
615
+ return String(multipartConfig.setupHint).replaceAll("{seconds}", String(seconds));
616
+ }
617
+
618
+ function partLabelText(index) {
619
+ return String(multipartConfig.partLabel)
620
+ .replaceAll("{current}", String(index + 1))
621
+ .replaceAll("{total}", String(urls.length));
622
+ }
623
+
624
+ function showPart(index, immediate = false) {
625
+ if (!urls.length) return;
626
+ activeIndex = (index + urls.length) % urls.length;
627
+ const applyPart = () => {
628
+ activeQr.src = urls[activeIndex];
629
+ activeQr.alt = partLabelText(activeIndex);
630
+ activeQr.setAttribute("aria-busy", "false");
631
+ partLabel.textContent = partLabelText(activeIndex);
632
+ switchTimer = null;
633
+ };
634
+ if (immediate) {
635
+ applyPart();
636
+ return;
637
+ }
638
+ activeQr.setAttribute("aria-busy", "true");
639
+ switchTimer = setTimeout(applyPart, multipartConfig.blankMs);
640
+ }
641
+
642
+ function startMultipartTransfer() {
643
+ stopMultipartTimers();
644
+ setup.hidden = true;
645
+ stage.hidden = false;
646
+ showPart(0, true);
647
+ rotationTimer = setInterval(() => showPart(activeIndex + 1), multipartConfig.frameMs);
648
+ }
649
+
650
+ function showMultipartSetup() {
651
+ stopMultipartTimers();
652
+ stage.hidden = true;
653
+ setup.hidden = false;
654
+ let remaining = multipartConfig.setupSeconds;
655
+ setupHint.textContent = setupHintText(remaining);
656
+ setupTimer = setInterval(() => {
657
+ remaining -= 1;
658
+ if (remaining <= 0) {
659
+ startMultipartTransfer();
660
+ return;
661
+ }
662
+ setupHint.textContent = setupHintText(remaining);
663
+ }, 1000);
664
+ }
665
+
666
+ showMiniButton.addEventListener("click", showMultipartSetup);
667
+ window.addEventListener("beforeunload", stopMultipartTimers);
668
+ showMultipartSetup();
669
+ }
670
+
481
671
  const exportButton = document.getElementById("save-receipt-image");
482
672
  const exportStatus = document.getElementById("export-status");
483
673
  const exportNode = document.getElementById("receipt-export");
@@ -488,7 +678,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
488
678
  }
489
679
 
490
680
  function waitForImages(node) {
491
- return Promise.all([...node.querySelectorAll("img")].map((image) => {
681
+ return Promise.all([...node.querySelectorAll("img")].filter((image) => image.getAttribute("src")).map((image) => {
492
682
  if (image.complete && image.naturalWidth > 0) return Promise.resolve();
493
683
  return new Promise((resolve, reject) => {
494
684
  image.addEventListener("load", resolve, { once: true });
@@ -531,6 +721,13 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
531
721
  style: { background: paperColor },
532
722
  onclone(clone) {
533
723
  clone.style.background = paperColor;
724
+ const multipartLiveClone = clone.querySelector(".multipart-live");
725
+ if (multipartLiveClone) multipartLiveClone.remove();
726
+ const exportGridClone = clone.querySelector(".qr-grid--export-only");
727
+ if (exportGridClone) {
728
+ exportGridClone.removeAttribute("hidden");
729
+ exportGridClone.style.display = "grid";
730
+ }
534
731
  clone.querySelectorAll(".paper").forEach((paper) => {
535
732
  paper.style.boxShadow = "none";
536
733
  });