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 +9 -0
- package/README.en.md +3 -3
- package/README.md +3 -3
- package/docs/cli.en.md +7 -3
- package/docs/cli.md +7 -3
- package/package.json +2 -1
- package/src/cli.mjs +2 -2
- package/src/core/presentation.mjs +14 -6
- package/src/core/range.mjs +24 -5
- package/src/core/receipt-record.mjs +1 -1
- package/src/renderers/html.mjs +180 -41
- package/templates/README.md +3 -4
package/CHANGELOG.md
CHANGED
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>
|
|
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
|
|
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
|
|
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
|
|
73
|
+
<sub>让个人开发者也可以以很低的成本、稳定地接入前沿 AI 模型的 API,借助模型能力快速构建自己的产品。</sub>
|
|
74
74
|
</p>
|
|
75
75
|
|
|
76
76
|
## License
|
|
77
77
|
|
|
78
|
-
电脑端源码采用 [GNU GPL v3](LICENSE)(`GPL-3.0-only
|
|
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-
|
|
77
|
-
└── codex-receipt-
|
|
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
|
-
-
|
|
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-
|
|
77
|
-
└── codex-receipt-
|
|
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.
|
|
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 {
|
|
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-${
|
|
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
|
|
148
|
-
diner: "Vintage
|
|
149
|
-
payroll: "
|
|
155
|
+
classic: "Classic White",
|
|
156
|
+
diner: "Vintage Pink",
|
|
157
|
+
payroll: "Night Shift Green",
|
|
150
158
|
},
|
|
151
159
|
scope: {
|
|
152
160
|
latest: "Latest session",
|
package/src/core/range.mjs
CHANGED
|
@@ -91,9 +91,28 @@ export function calendarDayCount(range) {
|
|
|
91
91
|
return count;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
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);
|
package/src/renderers/html.mjs
CHANGED
|
@@ -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("&", "&")
|
|
@@ -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
|
|
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 =
|
|
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: #
|
|
79
|
-
--desk: #
|
|
80
|
-
--desk-soft: #
|
|
81
|
-
--muted: #
|
|
82
|
-
--line:
|
|
83
|
-
--accent:
|
|
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:
|
|
86
|
-
--mark-fg:
|
|
106
|
+
--mark-bg: #ffffff;
|
|
107
|
+
--mark-fg: #171713;
|
|
87
108
|
}
|
|
88
109
|
html[data-theme="diner"] {
|
|
89
|
-
--ink: #
|
|
90
|
-
--paper: #
|
|
91
|
-
--desk: #
|
|
92
|
-
--desk-soft: #
|
|
93
|
-
--muted: #
|
|
94
|
-
--line:
|
|
95
|
-
--accent:
|
|
96
|
-
--shadow: rgba(
|
|
97
|
-
--mark-bg: #
|
|
98
|
-
--mark-fg: #
|
|
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: #
|
|
102
|
-
--paper: #
|
|
103
|
-
--desk: #
|
|
104
|
-
--desk-soft: #
|
|
105
|
-
--muted: #
|
|
106
|
-
--line:
|
|
107
|
-
--accent:
|
|
108
|
-
--shadow: rgba(
|
|
109
|
-
--mark-bg: #
|
|
110
|
-
--mark-fg: #
|
|
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
|
-
<
|
|
330
|
-
<
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
433
|
+
</article>
|
|
371
434
|
|
|
372
|
-
|
|
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
|
-
|
|
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>`;
|