codex-work-receipt 0.4.0 → 0.6.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.
@@ -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;")
@@ -26,7 +36,7 @@ function formatDateKey(value, locale) {
26
36
  return locale === "en" ? `${match[2]}/${match[3]}/${match[1]}` : `${match[1]}/${match[2]}/${match[3]}`;
27
37
  }
28
38
 
29
- export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = null }) {
39
+ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null, miniProgramCodeDataUrl = null }) {
30
40
  const locale = record.locale || DEFAULT_LOCALE;
31
41
  const copy = getReceiptCopy(locale);
32
42
  const startAt = new Date(record.period.start_at);
@@ -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)),
@@ -64,6 +85,17 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
64
85
  const miniProgramVisual = miniProgramCodeDataUrl
65
86
  ? `<img src="${miniProgramCodeDataUrl}" alt="${escapeHtml(copy.miniProgramAlt)}">`
66
87
  : `<div class="mini-placeholder" role="img" aria-label="${escapeHtml(copy.placeholderAria)}"><span>${escapeHtml(copy.placeholderLabel)}</span><strong>${escapeHtml(copy.placeholderValue)}</strong></div>`;
88
+ const qrUrls = Array.isArray(dataQrDataUrls) && dataQrDataUrls.length
89
+ ? dataQrDataUrls
90
+ : dataQrDataUrl
91
+ ? [dataQrDataUrl]
92
+ : [];
93
+ const dataQrItems = qrUrls.map((url, index) => `
94
+ <div class="qr-item">
95
+ <div class="qr-frame"><img src="${url}" alt="${escapeHtml(copy.dataQrAlt)} ${index + 1}/${qrUrls.length}"></div>
96
+ <strong>${escapeHtml(copy.importData)}${qrUrls.length > 1 ? ` ${index + 1}/${qrUrls.length}` : ""}</strong>
97
+ <span>${escapeHtml(copy.importDataHint)}</span>
98
+ </div>`).join("");
67
99
 
68
100
  return `<!doctype html>
69
101
  <html lang="${escapeHtml(copy.htmlLang)}" data-theme="${escapeHtml(record.presentation.default_theme)}">
@@ -75,39 +107,39 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
75
107
  :root {
76
108
  color-scheme: light;
77
109
  --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);
110
+ --paper: #ffffff;
111
+ --desk: #eeeae3;
112
+ --desk-soft: #f6f3ee;
113
+ --muted: #6d6c65;
114
+ --line: #aaa8a0;
115
+ --accent: #f3f2ed;
84
116
  --shadow: rgba(31, 30, 24, .18);
85
- --mark-bg: transparent;
86
- --mark-fg: var(--ink);
117
+ --mark-bg: #ffffff;
118
+ --mark-fg: #171713;
87
119
  }
88
120
  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;
121
+ --ink: #a7475d;
122
+ --paper: #f7dde3;
123
+ --desk: #f2ece8;
124
+ --desk-soft: #faf6f3;
125
+ --muted: #b46b7b;
126
+ --line: #d29aa7;
127
+ --accent: #f0cdd6;
128
+ --shadow: rgba(116, 58, 72, .18);
129
+ --mark-bg: #f7dde3;
130
+ --mark-fg: #a7475d;
99
131
  }
100
132
  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;
133
+ --ink: #ffe077;
134
+ --paper: #66742f;
135
+ --desk: #edebe3;
136
+ --desk-soft: #f7f5ef;
137
+ --muted: #e8cf74;
138
+ --line: #9aa35d;
139
+ --accent: #5c682b;
140
+ --shadow: rgba(48, 57, 19, .24);
141
+ --mark-bg: #66742f;
142
+ --mark-fg: #ffe077;
111
143
  }
112
144
  * { box-sizing: border-box; }
113
145
  body {
@@ -125,11 +157,17 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
125
157
  margin: 0 auto;
126
158
  padding: 30px 18px 54px;
127
159
  }
160
+ .toolbar {
161
+ display: grid;
162
+ justify-items: center;
163
+ gap: 12px;
164
+ margin-bottom: 22px;
165
+ }
128
166
  .theme-switcher {
129
167
  display: flex;
168
+ flex-wrap: wrap;
130
169
  justify-content: center;
131
170
  gap: 8px;
132
- margin-bottom: 22px;
133
171
  }
134
172
  .theme-button {
135
173
  appearance: none;
@@ -146,6 +184,35 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
146
184
  background: var(--ink);
147
185
  color: var(--paper);
148
186
  }
187
+ .export-actions {
188
+ display: grid;
189
+ justify-items: center;
190
+ gap: 6px;
191
+ }
192
+ .export-button {
193
+ appearance: none;
194
+ min-width: 168px;
195
+ border: 1px solid var(--ink);
196
+ border-radius: 999px;
197
+ background: var(--ink);
198
+ color: var(--paper);
199
+ cursor: pointer;
200
+ font: inherit;
201
+ font-size: 13px;
202
+ font-weight: 700;
203
+ padding: 10px 18px;
204
+ transition: opacity .15s ease, transform .15s ease;
205
+ }
206
+ .export-button:hover { transform: translateY(-1px); }
207
+ .export-button:disabled { cursor: wait; opacity: .62; transform: none; }
208
+ .export-status {
209
+ min-height: 16px;
210
+ color: var(--muted);
211
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
212
+ font-size: 11px;
213
+ }
214
+ .export-status[data-state="error"] { color: #b33a2e; }
215
+ .export-sheet { padding: 10px 0; }
149
216
  .paper {
150
217
  position: relative;
151
218
  background:
@@ -326,13 +393,20 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
326
393
  </head>
327
394
  <body>
328
395
  <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>
396
+ <div class="toolbar">
397
+ <nav class="theme-switcher" aria-label="${escapeHtml(copy.themeAria)}">
398
+ <button class="theme-button" type="button" data-theme-value="classic">${escapeHtml(copy.themes.classic)}</button>
399
+ <button class="theme-button" type="button" data-theme-value="diner">${escapeHtml(copy.themes.diner)}</button>
400
+ <button class="theme-button" type="button" data-theme-value="payroll">${escapeHtml(copy.themes.payroll)}</button>
401
+ </nav>
402
+ <div class="export-actions">
403
+ <button class="export-button" id="save-receipt-image" type="button">${escapeHtml(copy.exportImage)}</button>
404
+ <span class="export-status" id="export-status" role="status" aria-live="polite"></span>
405
+ </div>
406
+ </div>
334
407
 
335
- <article class="paper receipt" aria-label="${escapeHtml(copy.receiptAria)}">
408
+ <div class="export-sheet" id="receipt-export">
409
+ <article class="paper receipt" aria-label="${escapeHtml(copy.receiptAria)}">
336
410
  <header class="brand">
337
411
  <div class="brand-mark">C</div>
338
412
  <h1>${escapeHtml(copy.receiptTitle)}</h1>
@@ -367,9 +441,9 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
367
441
  <div>MODEL · ${escapeHtml(modelLabel)}</div>
368
442
  <div>${escapeHtml(copy.footerThanks)}</div>
369
443
  </footer>
370
- </article>
444
+ </article>
371
445
 
372
- <section class="paper transfer-stub" aria-label="${escapeHtml(copy.transferAria)}">
446
+ <section class="paper transfer-stub" aria-label="${escapeHtml(copy.transferAria)}">
373
447
  <header class="transfer-heading">
374
448
  <h2>${escapeHtml(copy.transferTitle)}</h2>
375
449
  <p>${escapeHtml(copy.transferDescription)}</p>
@@ -380,18 +454,17 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
380
454
  <strong>${escapeHtml(copy.openMiniProgram)}</strong>
381
455
  <span>${escapeHtml(copy.openMiniProgramHint)}</span>
382
456
  </div>
383
- <div class="qr-item">
384
- <div class="qr-frame"><img src="${dataQrDataUrl}" alt="${escapeHtml(copy.dataQrAlt)}"></div>
385
- <strong>${escapeHtml(copy.importData)}</strong>
386
- <span>${escapeHtml(copy.importDataHint)}</span>
387
- </div>
457
+ ${dataQrItems}
388
458
  </div>
389
459
  <p class="transfer-note">${escapeHtml(copy.transferNote)}</p>
390
- </section>
460
+ </section>
461
+ </div>
391
462
 
392
463
  <p class="privacy">${escapeHtml(copy.privacy)}</p>
393
464
  </main>
465
+ <script>${inlineScript(DOM_TO_IMAGE_SOURCE)}</script>
394
466
  <script>
467
+ const exportConfig = ${exportConfig};
395
468
  const themes = new Set(["classic", "diner", "payroll"]);
396
469
  const buttons = [...document.querySelectorAll("[data-theme-value]")];
397
470
  function applyTheme(theme) {
@@ -404,6 +477,79 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
404
477
  let savedTheme = null;
405
478
  try { savedTheme = localStorage.getItem("codex-work-receipt-theme"); } catch {}
406
479
  applyTheme(savedTheme || document.documentElement.dataset.theme);
480
+
481
+ const exportButton = document.getElementById("save-receipt-image");
482
+ const exportStatus = document.getElementById("export-status");
483
+ const exportNode = document.getElementById("receipt-export");
484
+
485
+ function setExportStatus(message, state = "") {
486
+ exportStatus.textContent = message;
487
+ exportStatus.dataset.state = state;
488
+ }
489
+
490
+ function waitForImages(node) {
491
+ return Promise.all([...node.querySelectorAll("img")].map((image) => {
492
+ if (image.complete && image.naturalWidth > 0) return Promise.resolve();
493
+ return new Promise((resolve, reject) => {
494
+ image.addEventListener("load", resolve, { once: true });
495
+ image.addEventListener("error", reject, { once: true });
496
+ });
497
+ }));
498
+ }
499
+
500
+ function downloadImage(dataUrl, filename) {
501
+ const link = document.createElement("a");
502
+ link.href = dataUrl;
503
+ link.download = filename;
504
+ link.rel = "noopener";
505
+ document.body.appendChild(link);
506
+ if (typeof link.download === "string") link.click();
507
+ else window.open(dataUrl, "_blank", "noopener");
508
+ link.remove();
509
+ }
510
+
511
+ exportButton.addEventListener("click", async () => {
512
+ if (exportButton.disabled) return;
513
+ exportButton.disabled = true;
514
+ exportButton.textContent = exportConfig.busyLabel;
515
+ setExportStatus("");
516
+
517
+ try {
518
+ if (document.fonts?.ready) await document.fonts.ready;
519
+ await waitForImages(exportNode);
520
+ const width = Math.ceil(exportNode.scrollWidth);
521
+ const height = Math.ceil(exportNode.scrollHeight);
522
+ const scale = Math.max(2, Math.min(3, 1500 / Math.max(1, width)));
523
+ const paperColor = getComputedStyle(document.documentElement).getPropertyValue("--paper").trim() || "#ffffff";
524
+ const dataUrl = await domtoimage.toPng(exportNode, {
525
+ width,
526
+ height,
527
+ scale,
528
+ pixelRatio: 1,
529
+ bgcolor: paperColor,
530
+ cacheBust: false,
531
+ style: { background: paperColor },
532
+ onclone(clone) {
533
+ clone.style.background = paperColor;
534
+ clone.querySelectorAll(".paper").forEach((paper) => {
535
+ paper.style.boxShadow = "none";
536
+ });
537
+ },
538
+ logger: {},
539
+ });
540
+ const theme = themes.has(document.documentElement.dataset.theme)
541
+ ? document.documentElement.dataset.theme
542
+ : "classic";
543
+ downloadImage(dataUrl, exportConfig.fileBase + "-" + theme + ".png");
544
+ setExportStatus(exportConfig.successLabel, "success");
545
+ } catch (error) {
546
+ console.error(error);
547
+ setExportStatus(exportConfig.errorLabel, "error");
548
+ } finally {
549
+ exportButton.disabled = false;
550
+ exportButton.textContent = exportConfig.idleLabel;
551
+ }
552
+ });
407
553
  </script>
408
554
  </body>
409
555
  </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
-