codex-work-receipt 0.4.0 → 0.5.0

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - 新增网页版“保存完整长图”功能。
6
+ - 高清 PNG 包含主小票、小程序码和数据二维码导入联。
7
+ - 导出图片自动使用当前主题,并排除主题按钮、网页背景和其他页面元素。
8
+ - 网页主题与小程序统一为经典白票、复古粉票和夜班绿票。
9
+ - 小票顶部明确展示统计日期范围,默认文件名同步携带日期范围或会话短编号。
10
+ - 图片在浏览器本地渲染,不上传小票数据。
11
+
3
12
  ## 0.4.0
4
13
 
5
14
  - 新增无参数命令行范围选择器。
package/README.en.md CHANGED
@@ -36,7 +36,7 @@ Summarize all Codex activity from today:
36
36
  npx codex-work-receipt@latest --today --lang en
37
37
  ```
38
38
 
39
- HTML and structured data are written to `./codex-work-receipt-output/` by default. Non-interactive options such as `--latest` and `--today` remain available; see the [CLI guide](docs/cli.en.md).
39
+ HTML and structured data are written to `./codex-work-receipt-output/` by default. The generated page can save a high-resolution PNG containing the full receipt and QR import stub. Non-interactive options such as `--latest` and `--today` remain available; see the [CLI guide](docs/cli.en.md).
40
40
 
41
41
  ## Ask Codex directly
42
42
 
@@ -70,12 +70,12 @@ The desktop page generates a privacy-safe data QR code. The companion WeChat min
70
70
  <p align="center">
71
71
  <a href="https://modelflare.dev/"><img src="docs/images/sponsors/modelflare-logo.png" alt="ModelFlare Logo" width="56"></a><br>
72
72
  <a href="https://modelflare.dev/"><strong>ModelFlare</strong></a> · <a href="https://modelflare.dev/">modelflare.dev</a><br>
73
- <sub>Stable access to leading AI model APIs at a very low cost, with transparent pricing and clear usage.</sub>
73
+ <sub>Helping independent developers access leading AI model APIs reliably and at a very low cost, so they can quickly build their own products with advanced model capabilities.</sub>
74
74
  </p>
75
75
 
76
76
  ## License
77
77
 
78
- The desktop source is licensed under [GNU GPL v3](LICENSE) (`GPL-3.0-only`). This repository does not contain the companion mini-program or backend source code. This is an unofficial community project and is not affiliated with OpenAI.
78
+ The desktop source is licensed under [GNU GPL v3](LICENSE) (`GPL-3.0-only`). This is an unofficial community project and is not affiliated with OpenAI.
79
79
 
80
80
  ---
81
81
 
package/README.md CHANGED
@@ -36,7 +36,7 @@ npx codex-work-receipt@latest
36
36
  npx codex-work-receipt@latest --today
37
37
  ```
38
38
 
39
- 网页和结构数据默认保存在 `./codex-work-receipt-output/`。也可以继续使用 `--latest`、`--today` 等非交互参数,详见 [CLI 使用文档](docs/cli.md)。
39
+ 网页和结构数据默认保存在 `./codex-work-receipt-output/`。生成的网页支持一键保存包含主小票和二维码导入联的高清长图。也可以继续使用 `--latest`、`--today` 等非交互参数,详见 [CLI 使用文档](docs/cli.md)。
40
40
 
41
41
  ## 直接跟 Codex 说
42
42
 
@@ -70,12 +70,12 @@ Codex 会选择统计范围和主题、执行命令并打开小票。详见 [Cod
70
70
  <p align="center">
71
71
  <a href="https://modelflare.dev/"><img src="docs/images/sponsors/modelflare-logo.png" alt="ModelFlare Logo" width="56"></a><br>
72
72
  <a href="https://modelflare.dev/"><strong>ModelFlare</strong></a> · <a href="https://modelflare.dev/">modelflare.dev</a><br>
73
- <sub>以很低的成本稳定接入前沿 AI 模型 API,价格友好、计费透明、用量清晰可见。</sub>
73
+ <sub>让个人开发者也可以以很低的成本、稳定地接入前沿 AI 模型的 API,借助模型能力快速构建自己的产品。</sub>
74
74
  </p>
75
75
 
76
76
  ## License
77
77
 
78
- 电脑端源码采用 [GNU GPL v3](LICENSE)(`GPL-3.0-only`)。本仓库不包含配套小程序和后台服务源码;本项目是非 OpenAI 官方社区工具。
78
+ 电脑端源码采用 [GNU GPL v3](LICENSE)(`GPL-3.0-only`)。本项目是非 OpenAI 官方社区工具。
79
79
 
80
80
  ---
81
81
 
package/docs/cli.en.md CHANGED
@@ -73,10 +73,14 @@ Default output:
73
73
 
74
74
  ```text
75
75
  ./codex-work-receipt-output/
76
- ├── codex-receipt-latest.html
77
- └── codex-receipt-latest.json
76
+ ├── codex-receipt-today-2026-07-18.html
77
+ └── codex-receipt-today-2026-07-18.json
78
78
  ```
79
79
 
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
+
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.
83
+
80
84
  Set a timezone and output path:
81
85
 
82
86
  ```bash
@@ -130,4 +134,4 @@ See the [data schema and QR protocol](data-schema.en.md).
130
134
  - Codex is the only supported data source; Cursor and WorkBuddy are planned
131
135
  - Changed-file and line counts are intentionally omitted until they can be measured consistently
132
136
  - Calendar ranges filter individual events by local date and calculate a Token delta for each session
133
- - Image export is handled by the companion mini program rather than the desktop page
137
+ - Desktop PNG export is rendered locally in the browser and does not upload receipt data
package/docs/cli.md CHANGED
@@ -73,10 +73,14 @@ npx codex-work-receipt@latest --latest --theme diner
73
73
 
74
74
  ```text
75
75
  ./codex-work-receipt-output/
76
- ├── codex-receipt-latest.html
77
- └── codex-receipt-latest.json
76
+ ├── codex-receipt-today-2026-07-18.html
77
+ └── codex-receipt-today-2026-07-18.json
78
78
  ```
79
79
 
80
+ 默认文件名会携带统计日期范围;最近会话和指定会话则携带短编号,避免不同日期或会话的小票互相覆盖。
81
+
82
+ 打开生成的 HTML 后,点击“保存完整长图”即可下载高清 PNG。图片包含主小票、小程序码和数据二维码导入联,并自动使用当前选择的主题;主题按钮、网页背景和底部说明不会进入图片。
83
+
80
84
  指定时区和输出路径:
81
85
 
82
86
  ```bash
@@ -130,4 +134,4 @@ npx codex-work-receipt@latest --help
130
134
  - 当前只支持 Codex;Cursor、WorkBuddy 等数据源仍在规划中
131
135
  - 暂不统计修改文件数和代码行数,避免不同工具调用方式产生误导
132
136
  - 时间范围按每条事件的本地日期筛选,并分别计算每个会话的 Token 区间增量
133
- - 网页端不直接导出图片,移动端渲染和保存由配套小程序完成
137
+ - 桌面网页导出 PNG 使用浏览器本地渲染,不上传小票数据
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-work-receipt",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Generate a privacy-first Codex work receipt from local session metadata.",
6
6
  "author": "a-bai-2026",
@@ -52,6 +52,7 @@
52
52
  "node": ">=20"
53
53
  },
54
54
  "dependencies": {
55
+ "dom-to-image-more": "^3.10.2",
55
56
  "qrcode": "^1.5.4"
56
57
  }
57
58
  }
package/src/cli.mjs CHANGED
@@ -10,7 +10,7 @@ import { parseArgs, printHelp } from "./core/args.mjs";
10
10
  import { collectMetrics } from "./core/metrics.mjs";
11
11
  import { getReceiptCopy } from "./core/presentation.mjs";
12
12
  import { encodeReceiptPayload } from "./core/qr-payload.mjs";
13
- import { outputSlugForScope, resolveRange } from "./core/range.mjs";
13
+ import { outputSlugForRange, resolveRange } from "./core/range.mjs";
14
14
  import { buildReceiptRecord, persistReceiptRecord } from "./core/receipt-record.mjs";
15
15
  import { promptForRange } from "./core/selector.mjs";
16
16
  import { installCodexSkill } from "./core/skill-installer.mjs";
@@ -88,7 +88,7 @@ async function main() {
88
88
 
89
89
  const requestedOutput = options.output || path.join(
90
90
  DEFAULT_OUTPUT_DIR,
91
- `codex-receipt-${outputSlugForScope(options.mode)}.html`,
91
+ `codex-receipt-${outputSlugForRange(range, record.id)}.html`,
92
92
  );
93
93
  const outputFile = path.resolve(/\.html?$/i.test(requestedOutput) ? requestedOutput : `${requestedOutput}.html`);
94
94
  fs.mkdirSync(path.dirname(outputFile), { recursive: true });
@@ -80,11 +80,15 @@ const RECEIPT_COPY = {
80
80
  pageTitle: "Codex AI 打工小票",
81
81
  receiptTitle: "AI 打工小票",
82
82
  themeAria: "小票主题",
83
+ exportImage: "保存完整长图",
84
+ exportingImage: "正在生成…",
85
+ exportSuccess: "图片已保存",
86
+ exportError: "图片生成失败,请重试",
83
87
  receiptAria: "Codex AI 打工小票",
84
88
  themes: {
85
- classic: "经典热敏",
86
- diner: "餐馆结账",
87
- payroll: "办公室工资条",
89
+ classic: "经典白票",
90
+ diner: "复古粉票",
91
+ payroll: "夜班绿票",
88
92
  },
89
93
  scope: {
90
94
  latest: "最近一次会话",
@@ -142,11 +146,15 @@ const RECEIPT_COPY = {
142
146
  pageTitle: "Codex AI Work Receipt",
143
147
  receiptTitle: "AI Work Receipt",
144
148
  themeAria: "Receipt theme",
149
+ exportImage: "Save full PNG",
150
+ exportingImage: "Rendering…",
151
+ exportSuccess: "Image saved",
152
+ exportError: "Could not generate the image. Please try again.",
145
153
  receiptAria: "Codex AI Work Receipt",
146
154
  themes: {
147
- classic: "Classic Thermal",
148
- diner: "Vintage Diner",
149
- payroll: "Office Payroll",
155
+ classic: "Classic White",
156
+ diner: "Vintage Pink",
157
+ payroll: "Night Shift Green",
150
158
  },
151
159
  scope: {
152
160
  latest: "Latest session",
@@ -91,9 +91,28 @@ export function calendarDayCount(range) {
91
91
  return count;
92
92
  }
93
93
 
94
- export function outputSlugForScope(scope) {
95
- if (scope === "last-7-days") return "last-7-days";
96
- if (scope === "this-week") return "this-week";
97
- if (scope === "session") return "session";
98
- return scope;
94
+ function safeSlugSegment(value, fallback = "receipt") {
95
+ const normalized = String(value || "")
96
+ .trim()
97
+ .replace(/^cwr_/, "")
98
+ .replace(/[^a-zA-Z0-9-]+/g, "-")
99
+ .replace(/^-+|-+$/g, "")
100
+ .slice(0, 24);
101
+ return normalized || fallback;
102
+ }
103
+
104
+ export function outputSlugForRange(range, receiptId = "") {
105
+ const scope = normalizeScope(range?.scope) || "latest";
106
+ const startDate = range?.startDate || range?.targetDate || "";
107
+ const endDate = range?.endDate || range?.targetDate || startDate;
108
+
109
+ if (scope === "today") return `today-${safeSlugSegment(endDate, "today")}`;
110
+ if (scope === "last-7-days" || scope === "this-week") {
111
+ const dateSpan = startDate === endDate ? endDate : `${startDate}-to-${endDate}`;
112
+ return `${scope}-${safeSlugSegment(dateSpan, scope)}`;
113
+ }
114
+ if (scope === "session") {
115
+ return `session-${safeSlugSegment(range?.sessionId || receiptId, "selected")}`;
116
+ }
117
+ return `latest-${safeSlugSegment(receiptId || endDate, "receipt")}`;
99
118
  }
@@ -10,7 +10,7 @@ import {
10
10
  } from "./presentation.mjs";
11
11
 
12
12
  const SCHEMA_VERSION = 1;
13
- const COLLECTOR_VERSION = "0.4.0";
13
+ const COLLECTOR_VERSION = "0.5.0";
14
14
 
15
15
  function fingerprintSessionIds(sessionIds) {
16
16
  return crypto.createHash("sha256").update([...sessionIds].sort().join("|")).digest("hex").slice(0, 16);
@@ -1,6 +1,16 @@
1
+ import fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+
1
4
  import { formatDate, formatDuration, formatNumber, formatTime } from "../lib/time.mjs";
2
5
  import { buildCompensation, DEFAULT_LOCALE, getReceiptCopy } from "../core/presentation.mjs";
3
6
 
7
+ const require = createRequire(import.meta.url);
8
+ const DOM_TO_IMAGE_SOURCE = fs.readFileSync(require.resolve("dom-to-image-more"), "utf8");
9
+
10
+ function inlineScript(value) {
11
+ return String(value).replaceAll("</script", "<\\/script");
12
+ }
13
+
4
14
  function escapeHtml(value) {
5
15
  return String(value)
6
16
  .replaceAll("&", "&amp;")
@@ -35,12 +45,16 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
35
45
  const displayDate = formatDate(endAt, timezone, locale);
36
46
  const rangeStartDate = record.period.range_start_date || formatDateKey(record.period.start_at.slice(0, 10), "zh-CN").replaceAll("/", "-");
37
47
  const rangeEndDate = record.period.range_end_date || formatDateKey(record.period.end_at.slice(0, 10), "zh-CN").replaceAll("/", "-");
48
+ const isCalendarScope = new Set(["today", "last-7-days", "this-week"]).has(record.source.scope);
38
49
  const spansMultipleDates = rangeStartDate !== rangeEndDate;
39
- const businessPeriod = spansMultipleDates
50
+ const scopeLabel = copy.scope[record.source.scope] || copy.scope.latest;
51
+ const dateRange = spansMultipleDates
40
52
  ? `${formatDateKey(rangeStartDate, locale)}—${formatDateKey(rangeEndDate, locale)}`
53
+ : formatDateKey(rangeEndDate, locale);
54
+ const businessPeriod = isCalendarScope
55
+ ? `${dateRange} · ${scopeLabel}`
41
56
  : `${formatTime(startAt, timezone, locale)}—${formatTime(endAt, timezone, locale)}`;
42
- const businessPeriodLabel = spansMultipleDates ? copy.meta.period : copy.meta.hours;
43
- const scopeLabel = copy.scope[record.source.scope] || copy.scope.latest;
57
+ const businessPeriodLabel = isCalendarScope ? copy.meta.period : copy.meta.hours;
44
58
  const modelLabel = record.stats.models.length ? record.stats.models.join(" / ") : copy.modelMissing;
45
59
  const receiptNumber = `${record.id.slice(4, 12).toUpperCase()}-${String(record.stats.completed_turns).padStart(3, "0")}`;
46
60
  const compensation = record.presentation.compensation || buildCompensation(record.source.scope, 0, locale);
@@ -48,6 +62,13 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
48
62
  minimumFractionDigits: 1,
49
63
  maximumFractionDigits: 1,
50
64
  }).format(record.stats.average_first_token_ms / 1000);
65
+ const exportConfig = JSON.stringify({
66
+ idleLabel: copy.exportImage,
67
+ busyLabel: copy.exportingImage,
68
+ successLabel: copy.exportSuccess,
69
+ errorLabel: copy.exportError,
70
+ fileBase: `codex-work-receipt-${record.source.scope}-${spansMultipleDates ? `${rangeStartDate}-to-${rangeEndDate}` : `${rangeEndDate}-${record.id.slice(4, 12)}`}`,
71
+ }).replaceAll("<", "\\u003c");
51
72
  const rows = [
52
73
  receiptRow(copy.rows.scope, scopeLabel),
53
74
  receiptRow(copy.rows.sessions, formatCount(record.stats.session_count, copy.units.sessions, locale)),
@@ -75,39 +96,39 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
75
96
  :root {
76
97
  color-scheme: light;
77
98
  --ink: #171713;
78
- --paper: #fffef4;
79
- --desk: #d9d7cb;
80
- --desk-soft: #cfcdc1;
81
- --muted: #68675f;
82
- --line: rgba(23, 23, 19, .42);
83
- --accent: rgba(23, 23, 19, .055);
99
+ --paper: #ffffff;
100
+ --desk: #eeeae3;
101
+ --desk-soft: #f6f3ee;
102
+ --muted: #6d6c65;
103
+ --line: #aaa8a0;
104
+ --accent: #f3f2ed;
84
105
  --shadow: rgba(31, 30, 24, .18);
85
- --mark-bg: transparent;
86
- --mark-fg: var(--ink);
106
+ --mark-bg: #ffffff;
107
+ --mark-fg: #171713;
87
108
  }
88
109
  html[data-theme="diner"] {
89
- --ink: #7f1e18;
90
- --paper: #fff8e8;
91
- --desk: #d8a58e;
92
- --desk-soft: #f0d4bd;
93
- --muted: #9a4b3e;
94
- --line: rgba(127, 30, 24, .42);
95
- --accent: rgba(198, 54, 38, .08);
96
- --shadow: rgba(89, 34, 24, .2);
97
- --mark-bg: #bd2f23;
98
- --mark-fg: #fff8e8;
110
+ --ink: #a7475d;
111
+ --paper: #f7dde3;
112
+ --desk: #f2ece8;
113
+ --desk-soft: #faf6f3;
114
+ --muted: #b46b7b;
115
+ --line: #d29aa7;
116
+ --accent: #f0cdd6;
117
+ --shadow: rgba(116, 58, 72, .18);
118
+ --mark-bg: #f7dde3;
119
+ --mark-fg: #a7475d;
99
120
  }
100
121
  html[data-theme="payroll"] {
101
- --ink: #183d67;
102
- --paper: #f4f9ff;
103
- --desk: #aebdcb;
104
- --desk-soft: #dbe5ee;
105
- --muted: #53718f;
106
- --line: rgba(24, 61, 103, .38);
107
- --accent: rgba(40, 104, 166, .075);
108
- --shadow: rgba(28, 52, 79, .18);
109
- --mark-bg: #183d67;
110
- --mark-fg: #f4f9ff;
122
+ --ink: #ffe077;
123
+ --paper: #66742f;
124
+ --desk: #edebe3;
125
+ --desk-soft: #f7f5ef;
126
+ --muted: #e8cf74;
127
+ --line: #9aa35d;
128
+ --accent: #5c682b;
129
+ --shadow: rgba(48, 57, 19, .24);
130
+ --mark-bg: #66742f;
131
+ --mark-fg: #ffe077;
111
132
  }
112
133
  * { box-sizing: border-box; }
113
134
  body {
@@ -125,11 +146,17 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
125
146
  margin: 0 auto;
126
147
  padding: 30px 18px 54px;
127
148
  }
149
+ .toolbar {
150
+ display: grid;
151
+ justify-items: center;
152
+ gap: 12px;
153
+ margin-bottom: 22px;
154
+ }
128
155
  .theme-switcher {
129
156
  display: flex;
157
+ flex-wrap: wrap;
130
158
  justify-content: center;
131
159
  gap: 8px;
132
- margin-bottom: 22px;
133
160
  }
134
161
  .theme-button {
135
162
  appearance: none;
@@ -146,6 +173,35 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
146
173
  background: var(--ink);
147
174
  color: var(--paper);
148
175
  }
176
+ .export-actions {
177
+ display: grid;
178
+ justify-items: center;
179
+ gap: 6px;
180
+ }
181
+ .export-button {
182
+ appearance: none;
183
+ min-width: 168px;
184
+ border: 1px solid var(--ink);
185
+ border-radius: 999px;
186
+ background: var(--ink);
187
+ color: var(--paper);
188
+ cursor: pointer;
189
+ font: inherit;
190
+ font-size: 13px;
191
+ font-weight: 700;
192
+ padding: 10px 18px;
193
+ transition: opacity .15s ease, transform .15s ease;
194
+ }
195
+ .export-button:hover { transform: translateY(-1px); }
196
+ .export-button:disabled { cursor: wait; opacity: .62; transform: none; }
197
+ .export-status {
198
+ min-height: 16px;
199
+ color: var(--muted);
200
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
201
+ font-size: 11px;
202
+ }
203
+ .export-status[data-state="error"] { color: #b33a2e; }
204
+ .export-sheet { padding: 10px 0; }
149
205
  .paper {
150
206
  position: relative;
151
207
  background:
@@ -326,13 +382,20 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
326
382
  </head>
327
383
  <body>
328
384
  <main class="page">
329
- <nav class="theme-switcher" aria-label="${escapeHtml(copy.themeAria)}">
330
- <button class="theme-button" type="button" data-theme-value="classic">${escapeHtml(copy.themes.classic)}</button>
331
- <button class="theme-button" type="button" data-theme-value="diner">${escapeHtml(copy.themes.diner)}</button>
332
- <button class="theme-button" type="button" data-theme-value="payroll">${escapeHtml(copy.themes.payroll)}</button>
333
- </nav>
385
+ <div class="toolbar">
386
+ <nav class="theme-switcher" aria-label="${escapeHtml(copy.themeAria)}">
387
+ <button class="theme-button" type="button" data-theme-value="classic">${escapeHtml(copy.themes.classic)}</button>
388
+ <button class="theme-button" type="button" data-theme-value="diner">${escapeHtml(copy.themes.diner)}</button>
389
+ <button class="theme-button" type="button" data-theme-value="payroll">${escapeHtml(copy.themes.payroll)}</button>
390
+ </nav>
391
+ <div class="export-actions">
392
+ <button class="export-button" id="save-receipt-image" type="button">${escapeHtml(copy.exportImage)}</button>
393
+ <span class="export-status" id="export-status" role="status" aria-live="polite"></span>
394
+ </div>
395
+ </div>
334
396
 
335
- <article class="paper receipt" aria-label="${escapeHtml(copy.receiptAria)}">
397
+ <div class="export-sheet" id="receipt-export">
398
+ <article class="paper receipt" aria-label="${escapeHtml(copy.receiptAria)}">
336
399
  <header class="brand">
337
400
  <div class="brand-mark">C</div>
338
401
  <h1>${escapeHtml(copy.receiptTitle)}</h1>
@@ -367,9 +430,9 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
367
430
  <div>MODEL · ${escapeHtml(modelLabel)}</div>
368
431
  <div>${escapeHtml(copy.footerThanks)}</div>
369
432
  </footer>
370
- </article>
433
+ </article>
371
434
 
372
- <section class="paper transfer-stub" aria-label="${escapeHtml(copy.transferAria)}">
435
+ <section class="paper transfer-stub" aria-label="${escapeHtml(copy.transferAria)}">
373
436
  <header class="transfer-heading">
374
437
  <h2>${escapeHtml(copy.transferTitle)}</h2>
375
438
  <p>${escapeHtml(copy.transferDescription)}</p>
@@ -387,11 +450,14 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
387
450
  </div>
388
451
  </div>
389
452
  <p class="transfer-note">${escapeHtml(copy.transferNote)}</p>
390
- </section>
453
+ </section>
454
+ </div>
391
455
 
392
456
  <p class="privacy">${escapeHtml(copy.privacy)}</p>
393
457
  </main>
458
+ <script>${inlineScript(DOM_TO_IMAGE_SOURCE)}</script>
394
459
  <script>
460
+ const exportConfig = ${exportConfig};
395
461
  const themes = new Set(["classic", "diner", "payroll"]);
396
462
  const buttons = [...document.querySelectorAll("[data-theme-value]")];
397
463
  function applyTheme(theme) {
@@ -404,6 +470,79 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
404
470
  let savedTheme = null;
405
471
  try { savedTheme = localStorage.getItem("codex-work-receipt-theme"); } catch {}
406
472
  applyTheme(savedTheme || document.documentElement.dataset.theme);
473
+
474
+ const exportButton = document.getElementById("save-receipt-image");
475
+ const exportStatus = document.getElementById("export-status");
476
+ const exportNode = document.getElementById("receipt-export");
477
+
478
+ function setExportStatus(message, state = "") {
479
+ exportStatus.textContent = message;
480
+ exportStatus.dataset.state = state;
481
+ }
482
+
483
+ function waitForImages(node) {
484
+ return Promise.all([...node.querySelectorAll("img")].map((image) => {
485
+ if (image.complete && image.naturalWidth > 0) return Promise.resolve();
486
+ return new Promise((resolve, reject) => {
487
+ image.addEventListener("load", resolve, { once: true });
488
+ image.addEventListener("error", reject, { once: true });
489
+ });
490
+ }));
491
+ }
492
+
493
+ function downloadImage(dataUrl, filename) {
494
+ const link = document.createElement("a");
495
+ link.href = dataUrl;
496
+ link.download = filename;
497
+ link.rel = "noopener";
498
+ document.body.appendChild(link);
499
+ if (typeof link.download === "string") link.click();
500
+ else window.open(dataUrl, "_blank", "noopener");
501
+ link.remove();
502
+ }
503
+
504
+ exportButton.addEventListener("click", async () => {
505
+ if (exportButton.disabled) return;
506
+ exportButton.disabled = true;
507
+ exportButton.textContent = exportConfig.busyLabel;
508
+ setExportStatus("");
509
+
510
+ try {
511
+ if (document.fonts?.ready) await document.fonts.ready;
512
+ await waitForImages(exportNode);
513
+ const width = Math.ceil(exportNode.scrollWidth);
514
+ const height = Math.ceil(exportNode.scrollHeight);
515
+ const scale = Math.max(2, Math.min(3, 1500 / Math.max(1, width)));
516
+ const paperColor = getComputedStyle(document.documentElement).getPropertyValue("--paper").trim() || "#ffffff";
517
+ const dataUrl = await domtoimage.toPng(exportNode, {
518
+ width,
519
+ height,
520
+ scale,
521
+ pixelRatio: 1,
522
+ bgcolor: paperColor,
523
+ cacheBust: false,
524
+ style: { background: paperColor },
525
+ onclone(clone) {
526
+ clone.style.background = paperColor;
527
+ clone.querySelectorAll(".paper").forEach((paper) => {
528
+ paper.style.boxShadow = "none";
529
+ });
530
+ },
531
+ logger: {},
532
+ });
533
+ const theme = themes.has(document.documentElement.dataset.theme)
534
+ ? document.documentElement.dataset.theme
535
+ : "classic";
536
+ downloadImage(dataUrl, exportConfig.fileBase + "-" + theme + ".png");
537
+ setExportStatus(exportConfig.successLabel, "success");
538
+ } catch (error) {
539
+ console.error(error);
540
+ setExportStatus(exportConfig.errorLabel, "error");
541
+ } finally {
542
+ exportButton.disabled = false;
543
+ exportButton.textContent = exportConfig.idleLabel;
544
+ }
545
+ });
407
546
  </script>
408
547
  </body>
409
548
  </html>`;
@@ -2,9 +2,8 @@
2
2
 
3
3
  首批内置主题:
4
4
 
5
- - `classic`:经典热敏小票
6
- - `diner`:红白餐馆结账单
7
- - `payroll`:蓝色办公室工资条
5
+ - `classic`:经典白票
6
+ - `diner`:复古粉票
7
+ - `payroll`:夜班绿票
8
8
 
9
9
  主题只改变 CSS 变量和少量装饰,不改变小票数据。
10
-