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 +5 -0
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/docs/cli.en.md +1 -1
- package/docs/cli.md +1 -1
- package/docs/data-schema.en.md +4 -0
- package/docs/data-schema.md +4 -0
- package/docs/mobile-import.en.md +2 -2
- package/docs/mobile-import.md +2 -2
- package/package.json +1 -1
- package/src/core/barcode.mjs +52 -0
- package/src/core/presentation.mjs +12 -0
- package/src/renderers/html.mjs +212 -15
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
|
|
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
|
|
82
|
+
打开生成的 HTML 后,点击“保存完整长图”即可下载高清 PNG。图片包含主小票、小程序码和数据二维码导入联,并自动使用当前选择的主题;主题按钮、网页背景和底部说明不会进入图片。只有一个数据二维码时,小程序码与数据码继续并排展示。cwr2 数据超过单码安全容量时,网页先单独展示小程序码,随后自动逐张轮播分片数据码;用户无需在电脑上切换上一张或下一张,但仍需在小程序中收齐全部分片。完整长图为保留离线数据,会继续包含全部带序号的分片二维码。
|
|
83
83
|
|
|
84
84
|
指定时区和输出路径:
|
|
85
85
|
|
package/docs/data-schema.en.md
CHANGED
|
@@ -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.
|
package/docs/data-schema.md
CHANGED
|
@@ -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`,旧二维码仍可由新版小程序导入。
|
package/docs/mobile-import.en.md
CHANGED
|
@@ -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.
|
|
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
|
|
package/docs/mobile-import.md
CHANGED
|
@@ -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
|
@@ -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",
|
package/src/renderers/html.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|