codex-work-receipt 0.3.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 +23 -0
- package/CODE_OF_CONDUCT.md +4 -0
- package/CONTRIBUTING.md +23 -0
- package/LICENSE +674 -0
- package/README.en.md +167 -0
- package/README.md +244 -0
- package/SECURITY.md +7 -0
- package/assets/README.md +11 -0
- package/assets/miniprogram-code.png +0 -0
- package/docs/data-schema.md +25 -0
- package/docs/images/readme-hero.jpg +0 -0
- package/docs/images/receipts/receipt-classic.jpg +0 -0
- package/docs/images/receipts/receipt-diner.jpg +0 -0
- package/docs/images/receipts/receipt-payroll.jpg +0 -0
- package/docs/images/sponsors/modelflare-logo.png +0 -0
- package/docs/mobile-import.md +15 -0
- package/docs/privacy.md +12 -0
- package/package.json +57 -0
- package/skills/ai-work-receipt/SKILL.md +42 -0
- package/skills/ai-work-receipt/agents/openai.yaml +4 -0
- package/src/cli.mjs +109 -0
- package/src/core/args.mjs +84 -0
- package/src/core/metrics.mjs +149 -0
- package/src/core/presentation.mjs +239 -0
- package/src/core/qr-payload.mjs +69 -0
- package/src/core/receipt-record.mjs +121 -0
- package/src/core/skill-installer.mjs +38 -0
- package/src/lib/time.mjs +53 -0
- package/src/parsers/codex.mjs +51 -0
- package/src/renderers/html.mjs +398 -0
- package/templates/README.md +10 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
buildCompensation,
|
|
8
|
+
DEFAULT_LOCALE,
|
|
9
|
+
getWorkProfileCopy,
|
|
10
|
+
} from "./presentation.mjs";
|
|
11
|
+
|
|
12
|
+
const SCHEMA_VERSION = 1;
|
|
13
|
+
const COLLECTOR_VERSION = "0.3.0";
|
|
14
|
+
|
|
15
|
+
function fingerprintSessionIds(sessionIds) {
|
|
16
|
+
return crypto.createHash("sha256").update([...sessionIds].sort().join("|")).digest("hex").slice(0, 16);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = DEFAULT_LOCALE) {
|
|
20
|
+
const sessionFingerprint = fingerprintSessionIds(metrics.sessionIds);
|
|
21
|
+
const logicalKey = metrics.mode === "latest"
|
|
22
|
+
? `latest:${sessionFingerprint}`
|
|
23
|
+
: `today:${metrics.targetDate}:${metrics.timezone}`;
|
|
24
|
+
const id = `cwr_${crypto.createHash("sha256").update(logicalKey).digest("hex").slice(0, 16)}`;
|
|
25
|
+
const snapshotHash = crypto.createHash("sha256").update(JSON.stringify({
|
|
26
|
+
start: metrics.startAt.toISOString(),
|
|
27
|
+
end: metrics.endAt.toISOString(),
|
|
28
|
+
tokens: metrics.tokens,
|
|
29
|
+
tools: metrics.toolCalls,
|
|
30
|
+
turns: metrics.completedTurns,
|
|
31
|
+
interruptions: metrics.interruptions,
|
|
32
|
+
})).digest("hex").slice(0, 16);
|
|
33
|
+
const workProfileId = metrics.workProfileId || "temporary-hire";
|
|
34
|
+
const workProfile = getWorkProfileCopy(workProfileId, locale);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
schema_version: SCHEMA_VERSION,
|
|
38
|
+
locale,
|
|
39
|
+
id,
|
|
40
|
+
generated_at: new Date().toISOString(),
|
|
41
|
+
source: {
|
|
42
|
+
type: "codex",
|
|
43
|
+
scope: metrics.mode,
|
|
44
|
+
collector_version: COLLECTOR_VERSION,
|
|
45
|
+
logical_key: logicalKey,
|
|
46
|
+
session_fingerprint: sessionFingerprint,
|
|
47
|
+
snapshot_hash: snapshotHash,
|
|
48
|
+
},
|
|
49
|
+
period: {
|
|
50
|
+
start_at: metrics.startAt.toISOString(),
|
|
51
|
+
end_at: metrics.endAt.toISOString(),
|
|
52
|
+
timezone: metrics.timezone,
|
|
53
|
+
},
|
|
54
|
+
stats: {
|
|
55
|
+
session_count: metrics.sessionCount,
|
|
56
|
+
completed_turns: metrics.completedTurns,
|
|
57
|
+
user_messages: metrics.userMessages,
|
|
58
|
+
tool_calls: metrics.toolCalls,
|
|
59
|
+
interruptions: metrics.interruptions,
|
|
60
|
+
work_duration_ms: Math.round(metrics.workDurationMs),
|
|
61
|
+
average_first_token_ms: Math.round(metrics.averageFirstTokenMs),
|
|
62
|
+
tokens: { ...metrics.tokens },
|
|
63
|
+
models: [...metrics.models],
|
|
64
|
+
},
|
|
65
|
+
presentation: {
|
|
66
|
+
default_theme: defaultTheme,
|
|
67
|
+
work_profile: workProfileId,
|
|
68
|
+
work_title: workProfile.title,
|
|
69
|
+
review: workProfile.review,
|
|
70
|
+
compensation: buildCompensation(metrics.mode, metrics.workPoints, locale),
|
|
71
|
+
},
|
|
72
|
+
privacy: {
|
|
73
|
+
contains_prompts: false,
|
|
74
|
+
contains_responses: false,
|
|
75
|
+
contains_code: false,
|
|
76
|
+
contains_paths: false,
|
|
77
|
+
contains_filenames: false,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function persistReceiptRecord(record, outputHtmlPath, requestedDataDir = null) {
|
|
83
|
+
const dataDir = path.resolve(
|
|
84
|
+
requestedDataDir || process.env.CODEX_WORK_RECEIPT_HOME || path.join(os.homedir(), ".codex-work-receipt"),
|
|
85
|
+
);
|
|
86
|
+
const receiptsDir = path.join(dataDir, "receipts");
|
|
87
|
+
fs.mkdirSync(receiptsDir, { recursive: true });
|
|
88
|
+
|
|
89
|
+
const receiptPath = path.join(receiptsDir, `${record.id}.json`);
|
|
90
|
+
fs.writeFileSync(receiptPath, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
91
|
+
fs.writeFileSync(path.join(dataDir, "latest.json"), `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
92
|
+
|
|
93
|
+
const allRecords = fs.readdirSync(receiptsDir)
|
|
94
|
+
.filter((name) => name.endsWith(".json"))
|
|
95
|
+
.map((name) => JSON.parse(fs.readFileSync(path.join(receiptsDir, name), "utf8")));
|
|
96
|
+
const deduplicated = new Map();
|
|
97
|
+
for (const item of allRecords) {
|
|
98
|
+
const logicalKey = item.source?.logical_key || (
|
|
99
|
+
item.source?.scope === "latest" && item.source?.session_fingerprint
|
|
100
|
+
? `latest:${item.source.session_fingerprint}`
|
|
101
|
+
: item.id
|
|
102
|
+
);
|
|
103
|
+
const current = deduplicated.get(logicalKey);
|
|
104
|
+
if (!current || String(item.generated_at).localeCompare(String(current.generated_at)) > 0) {
|
|
105
|
+
deduplicated.set(logicalKey, item);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const history = [...deduplicated.values()]
|
|
109
|
+
.sort((left, right) => String(left.period?.end_at).localeCompare(String(right.period?.end_at)));
|
|
110
|
+
fs.writeFileSync(
|
|
111
|
+
path.join(dataDir, "history.jsonl"),
|
|
112
|
+
history.length ? `${history.map((item) => JSON.stringify(item)).join("\n")}\n` : "",
|
|
113
|
+
"utf8",
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const companionPath = /\.html?$/i.test(outputHtmlPath)
|
|
117
|
+
? outputHtmlPath.replace(/\.html?$/i, ".json")
|
|
118
|
+
: `${outputHtmlPath}.json`;
|
|
119
|
+
fs.writeFileSync(companionPath, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
120
|
+
return { dataDir, receiptPath, companionPath };
|
|
121
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const CODEX_SKILL_NAME = "ai-work-receipt";
|
|
6
|
+
|
|
7
|
+
export function getCodexSkillInstallPath(homeDir = os.homedir()) {
|
|
8
|
+
return path.join(homeDir, ".agents", "skills", CODEX_SKILL_NAME);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function installCodexSkill({ projectDir, homeDir = os.homedir() } = {}) {
|
|
12
|
+
if (!projectDir) throw new Error("安装 Skill 时缺少项目目录");
|
|
13
|
+
|
|
14
|
+
const sourceDir = path.join(projectDir, "skills", CODEX_SKILL_NAME);
|
|
15
|
+
const sourceEntry = path.join(sourceDir, "SKILL.md");
|
|
16
|
+
if (!fs.existsSync(sourceEntry)) {
|
|
17
|
+
throw new Error(`发布包中缺少 Skill:${sourceEntry}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const targetDir = getCodexSkillInstallPath(homeDir);
|
|
21
|
+
const targetParent = path.dirname(targetDir);
|
|
22
|
+
const stagingDir = path.join(
|
|
23
|
+
targetParent,
|
|
24
|
+
`.${CODEX_SKILL_NAME}.install-${process.pid}-${Date.now()}`,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
fs.mkdirSync(targetParent, { recursive: true });
|
|
28
|
+
try {
|
|
29
|
+
fs.cpSync(sourceDir, stagingDir, { recursive: true });
|
|
30
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
31
|
+
fs.renameSync(stagingDir, targetDir);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { sourceDir, targetDir };
|
|
38
|
+
}
|
package/src/lib/time.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export function toDate(value) {
|
|
2
|
+
const date = new Date(value);
|
|
3
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function rowDate(row) {
|
|
7
|
+
return toDate(
|
|
8
|
+
row.timestamp ||
|
|
9
|
+
row.payload?.completed_at ||
|
|
10
|
+
row.payload?.started_at ||
|
|
11
|
+
row.payload?.timestamp,
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function dateKey(date, timezone) {
|
|
16
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
17
|
+
timeZone: timezone,
|
|
18
|
+
year: "numeric",
|
|
19
|
+
month: "2-digit",
|
|
20
|
+
day: "2-digit",
|
|
21
|
+
}).format(date);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatDate(date, timezone, locale = "zh-CN") {
|
|
25
|
+
const formatted = new Intl.DateTimeFormat(locale === "en" ? "en-CA" : "zh-CN", {
|
|
26
|
+
timeZone: timezone,
|
|
27
|
+
year: "numeric",
|
|
28
|
+
month: "2-digit",
|
|
29
|
+
day: "2-digit",
|
|
30
|
+
}).format(date);
|
|
31
|
+
return locale === "en" ? formatted : formatted.replaceAll("/", ".");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatTime(date, timezone, locale = "zh-CN") {
|
|
35
|
+
return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "zh-CN", {
|
|
36
|
+
timeZone: timezone,
|
|
37
|
+
hour: "2-digit",
|
|
38
|
+
minute: "2-digit",
|
|
39
|
+
hour12: false,
|
|
40
|
+
}).format(date);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatDuration(milliseconds) {
|
|
44
|
+
const totalSeconds = Math.max(0, Math.round(milliseconds / 1000));
|
|
45
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
46
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
47
|
+
const seconds = totalSeconds % 60;
|
|
48
|
+
return [hours, minutes, seconds].map((value) => String(value).padStart(2, "0")).join(":");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatNumber(value, locale = "zh-CN") {
|
|
52
|
+
return new Intl.NumberFormat(locale === "en" ? "en-US" : "zh-CN").format(Math.max(0, Math.round(value || 0)));
|
|
53
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
function walkJsonlFiles(directory, accumulator = []) {
|
|
6
|
+
if (!fs.existsSync(directory)) return accumulator;
|
|
7
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
8
|
+
const entryPath = path.join(directory, entry.name);
|
|
9
|
+
if (entry.isDirectory()) walkJsonlFiles(entryPath, accumulator);
|
|
10
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) accumulator.push(entryPath);
|
|
11
|
+
}
|
|
12
|
+
return accumulator;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readJsonl(filePath) {
|
|
16
|
+
const rows = [];
|
|
17
|
+
const source = fs.readFileSync(filePath, "utf8");
|
|
18
|
+
for (const [lineIndex, line] of source.split("\n").entries()) {
|
|
19
|
+
if (!line.trim()) continue;
|
|
20
|
+
try {
|
|
21
|
+
rows.push(JSON.parse(line));
|
|
22
|
+
} catch {
|
|
23
|
+
console.warn(`跳过无法解析的记录:${path.basename(filePath)}:${lineIndex + 1}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return rows;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function loadCodexSessions(mode) {
|
|
30
|
+
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
|
|
31
|
+
const sessionsDirectory = path.join(codexHome, "sessions");
|
|
32
|
+
const files = walkJsonlFiles(sessionsDirectory)
|
|
33
|
+
.map((filePath) => ({ filePath, modifiedAt: fs.statSync(filePath).mtimeMs }))
|
|
34
|
+
.sort((left, right) => right.modifiedAt - left.modifiedAt);
|
|
35
|
+
|
|
36
|
+
if (!files.length) throw new Error(`没有在 ${sessionsDirectory} 找到 Codex 会话记录`);
|
|
37
|
+
|
|
38
|
+
const selected = mode === "latest"
|
|
39
|
+
? files.slice(0, 1)
|
|
40
|
+
: files.filter((file) => file.modifiedAt >= Date.now() - 72 * 60 * 60 * 1000);
|
|
41
|
+
|
|
42
|
+
return selected.map((file) => {
|
|
43
|
+
const rows = readJsonl(file.filePath);
|
|
44
|
+
const meta = rows.find((row) => row.type === "session_meta")?.payload || {};
|
|
45
|
+
return {
|
|
46
|
+
rows,
|
|
47
|
+
sessionId: meta.session_id || meta.id || path.basename(file.filePath, ".jsonl"),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { formatDate, formatDuration, formatNumber, formatTime } from "../lib/time.mjs";
|
|
2
|
+
import { buildCompensation, DEFAULT_LOCALE, getReceiptCopy } from "../core/presentation.mjs";
|
|
3
|
+
|
|
4
|
+
function escapeHtml(value) {
|
|
5
|
+
return String(value)
|
|
6
|
+
.replaceAll("&", "&")
|
|
7
|
+
.replaceAll("<", "<")
|
|
8
|
+
.replaceAll(">", ">")
|
|
9
|
+
.replaceAll('"', """)
|
|
10
|
+
.replaceAll("'", "'");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function receiptRow(label, value, emphasize = false) {
|
|
14
|
+
return `<div class="receipt-row${emphasize ? " receipt-row--emphasize" : ""}"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatCount(value, units, locale) {
|
|
18
|
+
const amount = Math.max(0, Math.round(value || 0));
|
|
19
|
+
const unit = units[amount === 1 ? 0 : 1];
|
|
20
|
+
return `${formatNumber(amount, locale)} ${unit}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = null }) {
|
|
24
|
+
const locale = record.locale || DEFAULT_LOCALE;
|
|
25
|
+
const copy = getReceiptCopy(locale);
|
|
26
|
+
const startAt = new Date(record.period.start_at);
|
|
27
|
+
const endAt = new Date(record.period.end_at);
|
|
28
|
+
const timezone = record.period.timezone;
|
|
29
|
+
const displayDate = formatDate(endAt, timezone, locale);
|
|
30
|
+
const businessHours = `${formatTime(startAt, timezone, locale)}—${formatTime(endAt, timezone, locale)}`;
|
|
31
|
+
const scopeLabel = copy.scope[record.source.scope] || copy.scope.latest;
|
|
32
|
+
const modelLabel = record.stats.models.length ? record.stats.models.join(" / ") : copy.modelMissing;
|
|
33
|
+
const receiptNumber = `${record.id.slice(4, 12).toUpperCase()}-${String(record.stats.completed_turns).padStart(3, "0")}`;
|
|
34
|
+
const compensation = record.presentation.compensation || buildCompensation(record.source.scope, 0, locale);
|
|
35
|
+
const responseSeconds = new Intl.NumberFormat(locale === "en" ? "en-US" : "zh-CN", {
|
|
36
|
+
minimumFractionDigits: 1,
|
|
37
|
+
maximumFractionDigits: 1,
|
|
38
|
+
}).format(record.stats.average_first_token_ms / 1000);
|
|
39
|
+
const rows = [
|
|
40
|
+
receiptRow(copy.rows.scope, scopeLabel),
|
|
41
|
+
receiptRow(copy.rows.sessions, formatCount(record.stats.session_count, copy.units.sessions, locale)),
|
|
42
|
+
receiptRow(copy.rows.turns, formatCount(record.stats.completed_turns, copy.units.turns, locale)),
|
|
43
|
+
receiptRow(copy.rows.messages, formatCount(record.stats.user_messages, copy.units.messages, locale)),
|
|
44
|
+
receiptRow(copy.rows.tools, formatCount(record.stats.tool_calls, copy.units.tools, locale)),
|
|
45
|
+
receiptRow(copy.rows.tokens, formatNumber(record.stats.tokens.total_tokens, locale), true),
|
|
46
|
+
receiptRow(copy.rows.cachedTokens, formatNumber(record.stats.tokens.cached_input_tokens, locale)),
|
|
47
|
+
receiptRow(copy.rows.interruptions, formatCount(record.stats.interruptions, copy.units.interruptions, locale)),
|
|
48
|
+
receiptRow(copy.rows.firstResponse, `${responseSeconds} ${copy.units.seconds[1]}`),
|
|
49
|
+
receiptRow(copy.rows.duration, formatDuration(record.stats.work_duration_ms), true),
|
|
50
|
+
].join("");
|
|
51
|
+
|
|
52
|
+
const miniProgramVisual = miniProgramCodeDataUrl
|
|
53
|
+
? `<img src="${miniProgramCodeDataUrl}" alt="${escapeHtml(copy.miniProgramAlt)}">`
|
|
54
|
+
: `<div class="mini-placeholder" role="img" aria-label="${escapeHtml(copy.placeholderAria)}"><span>${escapeHtml(copy.placeholderLabel)}</span><strong>${escapeHtml(copy.placeholderValue)}</strong></div>`;
|
|
55
|
+
|
|
56
|
+
return `<!doctype html>
|
|
57
|
+
<html lang="${escapeHtml(copy.htmlLang)}" data-theme="${escapeHtml(record.presentation.default_theme)}">
|
|
58
|
+
<head>
|
|
59
|
+
<meta charset="utf-8">
|
|
60
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
61
|
+
<title>${escapeHtml(copy.pageTitle)} · ${escapeHtml(displayDate)}</title>
|
|
62
|
+
<style>
|
|
63
|
+
:root {
|
|
64
|
+
color-scheme: light;
|
|
65
|
+
--ink: #171713;
|
|
66
|
+
--paper: #fffef4;
|
|
67
|
+
--desk: #d9d7cb;
|
|
68
|
+
--desk-soft: #cfcdc1;
|
|
69
|
+
--muted: #68675f;
|
|
70
|
+
--line: rgba(23, 23, 19, .42);
|
|
71
|
+
--accent: rgba(23, 23, 19, .055);
|
|
72
|
+
--shadow: rgba(31, 30, 24, .18);
|
|
73
|
+
--mark-bg: transparent;
|
|
74
|
+
--mark-fg: var(--ink);
|
|
75
|
+
}
|
|
76
|
+
html[data-theme="diner"] {
|
|
77
|
+
--ink: #7f1e18;
|
|
78
|
+
--paper: #fff8e8;
|
|
79
|
+
--desk: #d8a58e;
|
|
80
|
+
--desk-soft: #f0d4bd;
|
|
81
|
+
--muted: #9a4b3e;
|
|
82
|
+
--line: rgba(127, 30, 24, .42);
|
|
83
|
+
--accent: rgba(198, 54, 38, .08);
|
|
84
|
+
--shadow: rgba(89, 34, 24, .2);
|
|
85
|
+
--mark-bg: #bd2f23;
|
|
86
|
+
--mark-fg: #fff8e8;
|
|
87
|
+
}
|
|
88
|
+
html[data-theme="payroll"] {
|
|
89
|
+
--ink: #183d67;
|
|
90
|
+
--paper: #f4f9ff;
|
|
91
|
+
--desk: #aebdcb;
|
|
92
|
+
--desk-soft: #dbe5ee;
|
|
93
|
+
--muted: #53718f;
|
|
94
|
+
--line: rgba(24, 61, 103, .38);
|
|
95
|
+
--accent: rgba(40, 104, 166, .075);
|
|
96
|
+
--shadow: rgba(28, 52, 79, .18);
|
|
97
|
+
--mark-bg: #183d67;
|
|
98
|
+
--mark-fg: #f4f9ff;
|
|
99
|
+
}
|
|
100
|
+
* { box-sizing: border-box; }
|
|
101
|
+
body {
|
|
102
|
+
margin: 0;
|
|
103
|
+
min-height: 100vh;
|
|
104
|
+
background:
|
|
105
|
+
radial-gradient(circle at 20% 10%, rgba(255,255,255,.7), transparent 30%),
|
|
106
|
+
linear-gradient(135deg, var(--desk-soft), var(--desk));
|
|
107
|
+
color: var(--ink);
|
|
108
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
109
|
+
transition: background .2s ease, color .2s ease;
|
|
110
|
+
}
|
|
111
|
+
.page {
|
|
112
|
+
width: min(100%, 540px);
|
|
113
|
+
margin: 0 auto;
|
|
114
|
+
padding: 30px 18px 54px;
|
|
115
|
+
}
|
|
116
|
+
.theme-switcher {
|
|
117
|
+
display: flex;
|
|
118
|
+
justify-content: center;
|
|
119
|
+
gap: 8px;
|
|
120
|
+
margin-bottom: 22px;
|
|
121
|
+
}
|
|
122
|
+
.theme-button {
|
|
123
|
+
appearance: none;
|
|
124
|
+
border: 1px solid color-mix(in srgb, var(--ink) 28%, transparent);
|
|
125
|
+
border-radius: 999px;
|
|
126
|
+
background: color-mix(in srgb, var(--paper) 74%, transparent);
|
|
127
|
+
color: var(--ink);
|
|
128
|
+
cursor: pointer;
|
|
129
|
+
font: inherit;
|
|
130
|
+
font-size: 12px;
|
|
131
|
+
padding: 8px 12px;
|
|
132
|
+
}
|
|
133
|
+
.theme-button[aria-pressed="true"] {
|
|
134
|
+
background: var(--ink);
|
|
135
|
+
color: var(--paper);
|
|
136
|
+
}
|
|
137
|
+
.paper {
|
|
138
|
+
position: relative;
|
|
139
|
+
background:
|
|
140
|
+
repeating-linear-gradient(0deg, color-mix(in srgb, var(--ink) 2%, transparent) 0, color-mix(in srgb, var(--ink) 2%, transparent) 1px, transparent 1px, transparent 4px),
|
|
141
|
+
var(--paper);
|
|
142
|
+
box-shadow: 0 22px 60px var(--shadow);
|
|
143
|
+
filter: contrast(.985);
|
|
144
|
+
}
|
|
145
|
+
.paper::before,
|
|
146
|
+
.paper::after {
|
|
147
|
+
content: "";
|
|
148
|
+
position: absolute;
|
|
149
|
+
left: 0;
|
|
150
|
+
width: 100%;
|
|
151
|
+
height: 10px;
|
|
152
|
+
background: linear-gradient(135deg, transparent 7px, var(--paper) 0) 0 0 / 14px 14px repeat-x;
|
|
153
|
+
}
|
|
154
|
+
.paper::before { top: -9px; transform: rotate(180deg); }
|
|
155
|
+
.paper::after { bottom: -9px; }
|
|
156
|
+
.receipt { padding: 34px 28px 32px; }
|
|
157
|
+
.brand { text-align: center; }
|
|
158
|
+
.brand-mark {
|
|
159
|
+
display: inline-grid;
|
|
160
|
+
place-items: center;
|
|
161
|
+
width: 46px;
|
|
162
|
+
height: 46px;
|
|
163
|
+
margin-bottom: 12px;
|
|
164
|
+
border: 2px solid var(--ink);
|
|
165
|
+
border-radius: 50%;
|
|
166
|
+
background: var(--mark-bg);
|
|
167
|
+
color: var(--mark-fg);
|
|
168
|
+
font-size: 22px;
|
|
169
|
+
font-weight: 800;
|
|
170
|
+
}
|
|
171
|
+
h1 {
|
|
172
|
+
margin: 0;
|
|
173
|
+
font-size: clamp(22px, 7vw, 30px);
|
|
174
|
+
letter-spacing: .08em;
|
|
175
|
+
}
|
|
176
|
+
.subtitle {
|
|
177
|
+
margin: 9px 0 0;
|
|
178
|
+
color: var(--muted);
|
|
179
|
+
font-size: 12px;
|
|
180
|
+
letter-spacing: .12em;
|
|
181
|
+
}
|
|
182
|
+
.meta {
|
|
183
|
+
display: grid;
|
|
184
|
+
grid-template-columns: 1fr 1fr;
|
|
185
|
+
gap: 6px 18px;
|
|
186
|
+
margin: 26px 0 16px;
|
|
187
|
+
font-size: 12px;
|
|
188
|
+
}
|
|
189
|
+
.meta div:nth-child(even) { text-align: right; }
|
|
190
|
+
.divider {
|
|
191
|
+
height: 1px;
|
|
192
|
+
margin: 18px 0;
|
|
193
|
+
border-top: 1px dashed var(--line);
|
|
194
|
+
}
|
|
195
|
+
.receipt-row {
|
|
196
|
+
display: flex;
|
|
197
|
+
align-items: baseline;
|
|
198
|
+
justify-content: space-between;
|
|
199
|
+
gap: 24px;
|
|
200
|
+
padding: 6px 0;
|
|
201
|
+
font-size: 14px;
|
|
202
|
+
}
|
|
203
|
+
.receipt-row span { color: var(--muted); }
|
|
204
|
+
.receipt-row strong { text-align: right; font-weight: 700; }
|
|
205
|
+
.receipt-row--emphasize {
|
|
206
|
+
margin: 3px -6px;
|
|
207
|
+
padding: 8px 6px;
|
|
208
|
+
background: var(--accent);
|
|
209
|
+
}
|
|
210
|
+
.verdict { text-align: center; }
|
|
211
|
+
.verdict h2 { margin: 0; font-size: 22px; letter-spacing: .04em; }
|
|
212
|
+
.review {
|
|
213
|
+
margin: 12px auto 0;
|
|
214
|
+
max-width: 30em;
|
|
215
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
|
216
|
+
font-size: 14px;
|
|
217
|
+
line-height: 1.7;
|
|
218
|
+
}
|
|
219
|
+
.salary {
|
|
220
|
+
padding: 10px 0 2px;
|
|
221
|
+
font-size: 14px;
|
|
222
|
+
}
|
|
223
|
+
.salary-line {
|
|
224
|
+
display: flex;
|
|
225
|
+
justify-content: space-between;
|
|
226
|
+
align-items: baseline;
|
|
227
|
+
gap: 18px;
|
|
228
|
+
}
|
|
229
|
+
.salary-line span { color: var(--muted); }
|
|
230
|
+
.salary strong { font-size: 28px; }
|
|
231
|
+
.salary strong small {
|
|
232
|
+
margin-left: 6px;
|
|
233
|
+
font-size: 12px;
|
|
234
|
+
color: var(--muted);
|
|
235
|
+
}
|
|
236
|
+
.barcode {
|
|
237
|
+
height: 48px;
|
|
238
|
+
margin: 18px auto 8px;
|
|
239
|
+
max-width: 280px;
|
|
240
|
+
background: repeating-linear-gradient(90deg, var(--ink) 0 2px, transparent 2px 4px, var(--ink) 4px 5px, transparent 5px 8px);
|
|
241
|
+
opacity: .84;
|
|
242
|
+
}
|
|
243
|
+
.footer {
|
|
244
|
+
text-align: center;
|
|
245
|
+
font-size: 11px;
|
|
246
|
+
line-height: 1.65;
|
|
247
|
+
color: var(--muted);
|
|
248
|
+
}
|
|
249
|
+
.transfer-stub {
|
|
250
|
+
margin-top: 32px;
|
|
251
|
+
padding: 26px 24px 24px;
|
|
252
|
+
}
|
|
253
|
+
.transfer-heading { text-align: center; }
|
|
254
|
+
.transfer-heading h2 { margin: 0; font-size: 18px; letter-spacing: .08em; }
|
|
255
|
+
.transfer-heading p { margin: 7px 0 0; color: var(--muted); font-size: 11px; }
|
|
256
|
+
.qr-grid {
|
|
257
|
+
display: grid;
|
|
258
|
+
grid-template-columns: 1fr 1fr;
|
|
259
|
+
gap: 18px;
|
|
260
|
+
margin-top: 20px;
|
|
261
|
+
}
|
|
262
|
+
.qr-item { text-align: center; }
|
|
263
|
+
.qr-frame {
|
|
264
|
+
display: grid;
|
|
265
|
+
place-items: center;
|
|
266
|
+
width: min(100%, 176px);
|
|
267
|
+
aspect-ratio: 1;
|
|
268
|
+
margin: 0 auto 10px;
|
|
269
|
+
padding: 10px;
|
|
270
|
+
border: 1px solid var(--line);
|
|
271
|
+
background: #fff;
|
|
272
|
+
}
|
|
273
|
+
.qr-frame img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
|
274
|
+
.qr-item strong { display: block; font-size: 12px; }
|
|
275
|
+
.qr-item span { display: block; margin-top: 4px; color: var(--muted); font-size: 10px; line-height: 1.45; }
|
|
276
|
+
.mini-placeholder {
|
|
277
|
+
display: grid;
|
|
278
|
+
place-content: center;
|
|
279
|
+
width: 100%;
|
|
280
|
+
height: 100%;
|
|
281
|
+
border: 2px dashed #9a9a92;
|
|
282
|
+
color: #5f5f59;
|
|
283
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
|
284
|
+
}
|
|
285
|
+
.mini-placeholder span { font-size: 13px; }
|
|
286
|
+
.mini-placeholder strong { margin-top: 4px; font-size: 18px; }
|
|
287
|
+
.transfer-note {
|
|
288
|
+
margin: 18px 0 0;
|
|
289
|
+
padding-top: 14px;
|
|
290
|
+
border-top: 1px dashed var(--line);
|
|
291
|
+
color: var(--muted);
|
|
292
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
|
293
|
+
font-size: 11px;
|
|
294
|
+
line-height: 1.65;
|
|
295
|
+
text-align: center;
|
|
296
|
+
}
|
|
297
|
+
.privacy {
|
|
298
|
+
margin: 24px auto 0;
|
|
299
|
+
max-width: 440px;
|
|
300
|
+
color: color-mix(in srgb, var(--ink) 64%, transparent);
|
|
301
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
|
|
302
|
+
font-size: 12px;
|
|
303
|
+
line-height: 1.6;
|
|
304
|
+
text-align: center;
|
|
305
|
+
}
|
|
306
|
+
@media (max-width: 420px) {
|
|
307
|
+
.page { padding-inline: 10px; }
|
|
308
|
+
.receipt { padding-inline: 20px; }
|
|
309
|
+
.meta { grid-template-columns: 1fr; }
|
|
310
|
+
.meta div:nth-child(even) { text-align: left; }
|
|
311
|
+
.qr-grid { grid-template-columns: 1fr; }
|
|
312
|
+
}
|
|
313
|
+
</style>
|
|
314
|
+
</head>
|
|
315
|
+
<body>
|
|
316
|
+
<main class="page">
|
|
317
|
+
<nav class="theme-switcher" aria-label="${escapeHtml(copy.themeAria)}">
|
|
318
|
+
<button class="theme-button" type="button" data-theme-value="classic">${escapeHtml(copy.themes.classic)}</button>
|
|
319
|
+
<button class="theme-button" type="button" data-theme-value="diner">${escapeHtml(copy.themes.diner)}</button>
|
|
320
|
+
<button class="theme-button" type="button" data-theme-value="payroll">${escapeHtml(copy.themes.payroll)}</button>
|
|
321
|
+
</nav>
|
|
322
|
+
|
|
323
|
+
<article class="paper receipt" aria-label="${escapeHtml(copy.receiptAria)}">
|
|
324
|
+
<header class="brand">
|
|
325
|
+
<div class="brand-mark">C</div>
|
|
326
|
+
<h1>${escapeHtml(copy.receiptTitle)}</h1>
|
|
327
|
+
<p class="subtitle">CODEX WORK RECEIPT</p>
|
|
328
|
+
</header>
|
|
329
|
+
|
|
330
|
+
<section class="meta">
|
|
331
|
+
<div>${escapeHtml(copy.meta.date)}: ${escapeHtml(displayDate)}</div>
|
|
332
|
+
<div>${escapeHtml(copy.meta.hours)}: ${escapeHtml(businessHours)}</div>
|
|
333
|
+
<div>${escapeHtml(copy.meta.number)}: ${escapeHtml(receiptNumber)}</div>
|
|
334
|
+
<div>${escapeHtml(copy.meta.timezone)}: ${escapeHtml(timezone)}</div>
|
|
335
|
+
</section>
|
|
336
|
+
|
|
337
|
+
<div class="divider"></div>
|
|
338
|
+
<section>${rows}</section>
|
|
339
|
+
<div class="divider"></div>
|
|
340
|
+
|
|
341
|
+
<section class="verdict">
|
|
342
|
+
<h2>${escapeHtml(record.presentation.work_title)}</h2>
|
|
343
|
+
<p class="review">${escapeHtml(record.presentation.review)}</p>
|
|
344
|
+
</section>
|
|
345
|
+
|
|
346
|
+
<div class="divider"></div>
|
|
347
|
+
<section class="salary">
|
|
348
|
+
<div class="salary-line">
|
|
349
|
+
<span>${escapeHtml(compensation.label)}</span>
|
|
350
|
+
<strong>${escapeHtml(formatNumber(compensation.amount, locale))}<small>${escapeHtml(compensation.unit)}</small></strong>
|
|
351
|
+
</div>
|
|
352
|
+
</section>
|
|
353
|
+
<div class="barcode" aria-hidden="true"></div>
|
|
354
|
+
<footer class="footer">
|
|
355
|
+
<div>MODEL · ${escapeHtml(modelLabel)}</div>
|
|
356
|
+
<div>${escapeHtml(copy.footerThanks)}</div>
|
|
357
|
+
</footer>
|
|
358
|
+
</article>
|
|
359
|
+
|
|
360
|
+
<section class="paper transfer-stub" aria-label="${escapeHtml(copy.transferAria)}">
|
|
361
|
+
<header class="transfer-heading">
|
|
362
|
+
<h2>${escapeHtml(copy.transferTitle)}</h2>
|
|
363
|
+
<p>${escapeHtml(copy.transferDescription)}</p>
|
|
364
|
+
</header>
|
|
365
|
+
<div class="qr-grid">
|
|
366
|
+
<div class="qr-item">
|
|
367
|
+
<div class="qr-frame">${miniProgramVisual}</div>
|
|
368
|
+
<strong>${escapeHtml(copy.openMiniProgram)}</strong>
|
|
369
|
+
<span>${escapeHtml(copy.openMiniProgramHint)}</span>
|
|
370
|
+
</div>
|
|
371
|
+
<div class="qr-item">
|
|
372
|
+
<div class="qr-frame"><img src="${dataQrDataUrl}" alt="${escapeHtml(copy.dataQrAlt)}"></div>
|
|
373
|
+
<strong>${escapeHtml(copy.importData)}</strong>
|
|
374
|
+
<span>${escapeHtml(copy.importDataHint)}</span>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
<p class="transfer-note">${escapeHtml(copy.transferNote)}</p>
|
|
378
|
+
</section>
|
|
379
|
+
|
|
380
|
+
<p class="privacy">${escapeHtml(copy.privacy)}</p>
|
|
381
|
+
</main>
|
|
382
|
+
<script>
|
|
383
|
+
const themes = new Set(["classic", "diner", "payroll"]);
|
|
384
|
+
const buttons = [...document.querySelectorAll("[data-theme-value]")];
|
|
385
|
+
function applyTheme(theme) {
|
|
386
|
+
const selected = themes.has(theme) ? theme : "classic";
|
|
387
|
+
document.documentElement.dataset.theme = selected;
|
|
388
|
+
buttons.forEach((button) => button.setAttribute("aria-pressed", String(button.dataset.themeValue === selected)));
|
|
389
|
+
try { localStorage.setItem("codex-work-receipt-theme", selected); } catch {}
|
|
390
|
+
}
|
|
391
|
+
buttons.forEach((button) => button.addEventListener("click", () => applyTheme(button.dataset.themeValue)));
|
|
392
|
+
let savedTheme = null;
|
|
393
|
+
try { savedTheme = localStorage.getItem("codex-work-receipt-theme"); } catch {}
|
|
394
|
+
applyTheme(savedTheme || document.documentElement.dataset.theme);
|
|
395
|
+
</script>
|
|
396
|
+
</body>
|
|
397
|
+
</html>`;
|
|
398
|
+
}
|