codex-work-receipt 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/args.mjs CHANGED
@@ -7,13 +7,16 @@ Codex AI Work Receipt
7
7
  Usage:
8
8
  npx codex-work-receipt@latest
9
9
  npx codex-work-receipt@latest --latest --lang en
10
+ npx codex-work-receipt@latest --hours 3 --lang en
10
11
  npx codex-work-receipt@latest --today --lang en
11
12
  npx codex-work-receipt@latest --range last-7-days --lang en
12
13
  npx codex-work-receipt@latest --range this-week --lang en
13
14
  npx codex-work-receipt@latest --install-skill --lang en
15
+ npx codex-work-receipt@latest --install-companion --lang en
14
16
 
15
17
  Options:
16
- --range <name> Range: latest, today, last-7-days, this-week
18
+ --range <name> Range: latest, last-hours, today, last-7-days, this-week
19
+ --hours <number> Summarize the last 1-168 hours
17
20
  --latest Summarize the latest active Codex session (default)
18
21
  --today Summarize all Codex activity from today
19
22
  --session <id> Summarize one specific Codex session
@@ -23,6 +26,9 @@ Options:
23
26
  --output <file> Set the generated HTML path
24
27
  --data-dir <directory> Set the local structured-history directory
25
28
  --install-skill Install the natural-language Codex skill
29
+ --install-pet Install the Codex pet only
30
+ --uninstall-pet Remove the installed Codex pet
31
+ --install-companion Install both the skill and Codex pet
26
32
  --no-open Do not open the browser after generation
27
33
  --help Show help
28
34
  ` : `
@@ -31,13 +37,16 @@ Codex AI 打工小票
31
37
  用法:
32
38
  npx codex-work-receipt@latest
33
39
  npx codex-work-receipt@latest --latest
40
+ npx codex-work-receipt@latest --hours 3
34
41
  npx codex-work-receipt@latest --today
35
42
  npx codex-work-receipt@latest --range last-7-days
36
43
  npx codex-work-receipt@latest --range this-week
37
44
  npx codex-work-receipt@latest --install-skill
45
+ npx codex-work-receipt@latest --install-companion
38
46
 
39
47
  选项:
40
- --range <name> 统计范围:latest、today、last-7-days、this-week
48
+ --range <name> 统计范围:latest、last-hours、today、last-7-days、this-week
49
+ --hours <number> 统计最近 1~168 小时
41
50
  --latest 统计最近活跃的 Codex 会话(默认)
42
51
  --today 统计本地时区今天发生的全部 Codex 活动
43
52
  --session <id> 统计指定的 Codex 会话
@@ -47,6 +56,9 @@ Codex AI 打工小票
47
56
  --output <file> 指定生成的 HTML 文件,默认写入 ./codex-work-receipt-output/
48
57
  --data-dir <directory> 指定本地结构数据目录
49
58
  --install-skill 安装可通过自然语言调用的 Codex Skill
59
+ --install-pet 只安装 Codex 桌宠
60
+ --uninstall-pet 卸载 AI 打工小票 Codex 桌宠
61
+ --install-companion 同时安装 Skill 和 Codex 桌宠
50
62
  --no-open 生成后不自动打开浏览器
51
63
  --help 显示帮助
52
64
  `);
@@ -57,12 +69,16 @@ export function parseArgs(argv) {
57
69
  mode: "latest",
58
70
  modeExplicit: false,
59
71
  sessionId: null,
72
+ hours: null,
60
73
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Shanghai",
61
74
  locale: "zh-CN",
62
75
  theme: "classic",
63
76
  output: null,
64
77
  dataDir: null,
65
78
  installSkill: false,
79
+ installPet: false,
80
+ uninstallPet: false,
81
+ installCompanion: false,
66
82
  open: true,
67
83
  };
68
84
 
@@ -73,6 +89,7 @@ export function parseArgs(argv) {
73
89
  ["--output", "output"],
74
90
  ["--data-dir", "dataDir"],
75
91
  ["--session", "sessionId"],
92
+ ["--hours", "hours"],
76
93
  ]);
77
94
 
78
95
  for (let index = 0; index < argv.length; index += 1) {
@@ -83,7 +100,10 @@ export function parseArgs(argv) {
83
100
  } else if (argument === "--today") {
84
101
  result.mode = "today";
85
102
  result.modeExplicit = true;
86
- } else if (argument === "--range") {
103
+ } else if (argument === "--install-pet") result.installPet = true;
104
+ else if (argument === "--uninstall-pet") result.uninstallPet = true;
105
+ else if (argument === "--install-companion") result.installCompanion = true;
106
+ else if (argument === "--range") {
87
107
  const value = argv[++index];
88
108
  if (!value) throw new Error("--range 需要提供值");
89
109
  const scope = normalizeScope(value);
@@ -101,6 +121,9 @@ export function parseArgs(argv) {
101
121
  if (key === "sessionId") {
102
122
  result.mode = "session";
103
123
  result.modeExplicit = true;
124
+ } else if (key === "hours") {
125
+ result.mode = "last-hours";
126
+ result.modeExplicit = true;
104
127
  }
105
128
  } else throw new Error(`不认识的参数:${argument}`);
106
129
  }
@@ -111,5 +134,12 @@ export function parseArgs(argv) {
111
134
  if (!new Set(["zh-CN", "en"]).has(result.locale)) {
112
135
  throw new Error(`不支持的语言:${result.locale}`);
113
136
  }
137
+ if (result.hours !== null) {
138
+ result.hours = Number(result.hours);
139
+ if (!Number.isInteger(result.hours) || result.hours < 1 || result.hours > 168) {
140
+ throw new Error("--hours 需要 1 至 168 之间的整数");
141
+ }
142
+ }
143
+ if (result.mode === "last-hours" && result.hours === null) result.hours = 3;
114
144
  return result;
115
145
  }
@@ -28,12 +28,19 @@ export function buildLogicalReceiptKey(metrics) {
28
28
  if (metrics.mode === "latest" || metrics.mode === "session") {
29
29
  return `${metrics.mode}:${sessionPart}:${metrics.timezone}`;
30
30
  }
31
+ if (metrics.mode === "last-hours") {
32
+ return `last-hours:${metrics.windowHours}:${metrics.windowStartAt.toISOString()}:${metrics.windowEndAt.toISOString()}:${metrics.timezone}`;
33
+ }
31
34
  if (metrics.mode === "this-week") {
32
35
  return `this-week:${metrics.rangeStartDate}:${metrics.timezone}`;
33
36
  }
34
37
  return `${metrics.mode}:${metrics.rangeStartDate}:${metrics.rangeEndDate}:${metrics.timezone}`;
35
38
  }
36
39
 
40
+ export function buildSummaryReceiptId(logicalReceiptKey) {
41
+ return stableId("cwr", "codex-work-receipt/receipt/v1", [logicalReceiptKey]);
42
+ }
43
+
37
44
  export function buildProtocolReceiptId(sourceVersion, logicalReceiptKey) {
38
45
  return stableId("cwr2", "codex-work-receipt/receipt/v2", [sourceVersion, logicalReceiptKey]);
39
46
  }
@@ -1,5 +1,10 @@
1
1
  import { dateKey, rowDate } from "../lib/time.mjs";
2
- import { calendarDayCount, isCalendarScope, isDateInRange } from "./range.mjs";
2
+ import {
3
+ calendarDayCount,
4
+ isCalendarScope,
5
+ isDateInRange,
6
+ isTimeWindowScope,
7
+ } from "./range.mjs";
3
8
  import { selectWorkProfileId } from "./presentation.mjs";
4
9
 
5
10
  function zeroUsage() {
@@ -28,7 +33,8 @@ function sessionTokenUsage(rows, range) {
28
33
  for (const row of events) {
29
34
  const currentUsage = row.payload.info.total_token_usage;
30
35
  const date = rowDate(row);
31
- const selected = !isCalendarScope(range.scope) || isDateInRange(date, range);
36
+ const selected = (!isCalendarScope(range.scope) && !isTimeWindowScope(range.scope))
37
+ || isDateInRange(date, range);
32
38
 
33
39
  for (const key of Object.keys(totals)) {
34
40
  const current = Math.max(0, Number(currentUsage[key] || 0));
@@ -56,6 +62,7 @@ function calculateWorkPoints(metrics) {
56
62
  }
57
63
 
58
64
  function emptyRangeMessage(range) {
65
+ if (range.scope === "last-hours") return `最近 ${range.hours} 小时没有找到 Codex 活动`;
59
66
  if (range.scope === "today") return `没有找到 ${range.targetDate} 的 Codex 活动`;
60
67
  if (range.scope === "last-7-days") return `没有找到 ${range.startDate} 至 ${range.endDate} 的 Codex 活动`;
61
68
  if (range.scope === "this-week") return `本周暂时没有找到 Codex 活动`;
@@ -78,7 +85,7 @@ export function collectMetrics(sessions, range) {
78
85
  const models = new Set();
79
86
 
80
87
  for (const session of sessions) {
81
- const scopedRows = isCalendarScope(range.scope)
88
+ const scopedRows = isCalendarScope(range.scope) || isTimeWindowScope(range.scope)
82
89
  ? session.rows.filter((row) => isDateInRange(rowDate(row), range))
83
90
  : session.rows;
84
91
 
@@ -134,6 +141,9 @@ export function collectMetrics(sessions, range) {
134
141
  targetDate: range.targetDate,
135
142
  rangeStartDate,
136
143
  rangeEndDate,
144
+ windowStartAt: range.startAt,
145
+ windowEndAt: range.endAt,
146
+ windowHours: range.hours,
137
147
  calendarDayCount: isCalendarScope(range.scope) ? calendarDayCount(range) : 1,
138
148
  activeDayCount: Math.max(1, activeDateKeys.size),
139
149
  sessionIds,
@@ -0,0 +1,50 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export const CODEX_PET_NAME = "ai-work-receipt";
6
+
7
+ export function getCodexPetHome({ homeDir = os.homedir(), codexHome = null } = {}) {
8
+ return path.resolve(codexHome || process.env.CODEX_HOME || path.join(homeDir, ".codex"));
9
+ }
10
+
11
+ export function getCodexPetInstallPath(options = {}) {
12
+ return path.join(getCodexPetHome(options), "pets", CODEX_PET_NAME);
13
+ }
14
+
15
+ export function installCodexPet({ projectDir, homeDir = os.homedir(), codexHome = null } = {}) {
16
+ if (!projectDir) throw new Error("安装 Pet 时缺少项目目录");
17
+
18
+ const sourceDir = path.join(projectDir, "assets", "codex-pet", CODEX_PET_NAME);
19
+ const sourceManifest = path.join(sourceDir, "pet.json");
20
+ const sourceSpritesheet = path.join(sourceDir, "spritesheet.webp");
21
+ if (!fs.existsSync(sourceManifest) || !fs.existsSync(sourceSpritesheet)) {
22
+ throw new Error(`发布包中缺少 Codex Pet:${sourceDir}`);
23
+ }
24
+
25
+ const targetDir = getCodexPetInstallPath({ homeDir, codexHome });
26
+ const targetParent = path.dirname(targetDir);
27
+ const stagingDir = path.join(
28
+ targetParent,
29
+ `.${CODEX_PET_NAME}.install-${process.pid}-${Date.now()}`,
30
+ );
31
+
32
+ fs.mkdirSync(targetParent, { recursive: true });
33
+ try {
34
+ fs.cpSync(sourceDir, stagingDir, { recursive: true });
35
+ fs.rmSync(targetDir, { recursive: true, force: true });
36
+ fs.renameSync(stagingDir, targetDir);
37
+ } catch (error) {
38
+ fs.rmSync(stagingDir, { recursive: true, force: true });
39
+ throw error;
40
+ }
41
+
42
+ return { sourceDir, targetDir };
43
+ }
44
+
45
+ export function uninstallCodexPet({ homeDir = os.homedir(), codexHome = null } = {}) {
46
+ const targetDir = getCodexPetInstallPath({ homeDir, codexHome });
47
+ const existed = fs.existsSync(targetDir);
48
+ fs.rmSync(targetDir, { recursive: true, force: true });
49
+ return { targetDir, existed };
50
+ }
@@ -93,6 +93,7 @@ const RECEIPT_COPY = {
93
93
  scope: {
94
94
  latest: "最近一次会话",
95
95
  session: "指定会话",
96
+ "last-hours": "最近 {hours} 小时",
96
97
  today: "今日全部会话",
97
98
  "last-7-days": "最近 7 个自然日",
98
99
  "this-week": "本周全部会话",
@@ -141,6 +142,7 @@ const RECEIPT_COPY = {
141
142
  multipartRestart: "重新显示小程序码",
142
143
  dataQrAlt: "当前小票数据二维码",
143
144
  miniProgramAlt: "微信小程序码",
145
+ exportMiniProgramLabel: "微信扫码打开小程序",
144
146
  placeholderLabel: "小程序码",
145
147
  placeholderValue: "待接入",
146
148
  placeholderAria: "小程序码待接入",
@@ -165,6 +167,7 @@ const RECEIPT_COPY = {
165
167
  scope: {
166
168
  latest: "Latest session",
167
169
  session: "Selected session",
170
+ "last-hours": "Last {hours} hours",
168
171
  today: "All sessions today",
169
172
  "last-7-days": "Last 7 calendar days",
170
173
  "this-week": "All sessions this week",
@@ -213,6 +216,7 @@ const RECEIPT_COPY = {
213
216
  multipartRestart: "Show mini-program code again",
214
217
  dataQrAlt: "Current receipt data QR code",
215
218
  miniProgramAlt: "WeChat mini-program code",
219
+ exportMiniProgramLabel: "Scan to open the mini program",
216
220
  placeholderLabel: "Mini program",
217
221
  placeholderValue: "Pending",
218
222
  placeholderAria: "Mini-program code pending",
@@ -225,6 +229,7 @@ const COMPENSATION_COPY = {
225
229
  "zh-CN": {
226
230
  latest: "本单工资",
227
231
  session: "本单工资",
232
+ "last-hours": "本段工资",
228
233
  today: "本日工资",
229
234
  "last-7-days": "近七日工资",
230
235
  "this-week": "本周工资",
@@ -234,6 +239,7 @@ const COMPENSATION_COPY = {
234
239
  en: {
235
240
  latest: "SHIFT PAY",
236
241
  session: "SHIFT PAY",
242
+ "last-hours": "WINDOW PAY",
237
243
  today: "TODAY'S PAY",
238
244
  "last-7-days": "7-DAY PAY",
239
245
  "this-week": "THIS WEEK'S PAY",
@@ -262,6 +268,11 @@ export function getReceiptCopy(locale = DEFAULT_LOCALE) {
262
268
  return RECEIPT_COPY[SUPPORTED_LOCALES.has(locale) ? locale : DEFAULT_LOCALE];
263
269
  }
264
270
 
271
+ export function getScopeLabel(scope, locale = DEFAULT_LOCALE, hours = null) {
272
+ const template = getReceiptCopy(locale).scope[scope] || getReceiptCopy(locale).scope.latest;
273
+ return String(template).replaceAll("{hours}", String(hours || 3));
274
+ }
275
+
265
276
  export function buildCompensation(scope, amount, locale = DEFAULT_LOCALE) {
266
277
  const copy = COMPENSATION_COPY[SUPPORTED_LOCALES.has(locale) ? locale : DEFAULT_LOCALE];
267
278
  return {
@@ -3,6 +3,7 @@ import { dateKey } from "../lib/time.mjs";
3
3
  export const RECEIPT_SCOPES = new Set([
4
4
  "latest",
5
5
  "session",
6
+ "last-hours",
6
7
  "today",
7
8
  "last-7-days",
8
9
  "this-week",
@@ -11,6 +12,8 @@ export const RECEIPT_SCOPES = new Set([
11
12
  const SCOPE_ALIASES = new Map([
12
13
  ["latest", "latest"],
13
14
  ["session", "session"],
15
+ ["hours", "last-hours"],
16
+ ["last-hours", "last-hours"],
14
17
  ["today", "today"],
15
18
  ["7d", "last-7-days"],
16
19
  ["last-7-days", "last-7-days"],
@@ -38,7 +41,13 @@ function mondayDateKey(value) {
38
41
  return shiftDateKey(value, -daysSinceMonday);
39
42
  }
40
43
 
41
- export function resolveRange(scope, timezone, now = new Date(), sessionId = null) {
44
+ function floorToMinute(value) {
45
+ const date = new Date(value);
46
+ date.setUTCSeconds(0, 0);
47
+ return date;
48
+ }
49
+
50
+ export function resolveRange(scope, timezone, now = new Date(), sessionId = null, hours = null) {
42
51
  const normalizedScope = normalizeScope(scope);
43
52
  if (!normalizedScope || !RECEIPT_SCOPES.has(normalizedScope)) {
44
53
  throw new Error(`不支持的统计范围:${scope}`);
@@ -47,8 +56,20 @@ export function resolveRange(scope, timezone, now = new Date(), sessionId = null
47
56
  const targetDate = dateKey(now, timezone);
48
57
  let startDate = null;
49
58
  let endDate = null;
59
+ let startAt = null;
60
+ let endAt = null;
61
+ let windowHours = null;
50
62
 
51
- if (normalizedScope === "today") {
63
+ if (normalizedScope === "last-hours") {
64
+ windowHours = Number(hours ?? 3);
65
+ if (!Number.isInteger(windowHours) || windowHours < 1 || windowHours > 168) {
66
+ throw new Error("最近小时数必须是 1 至 168 之间的整数");
67
+ }
68
+ endAt = floorToMinute(now);
69
+ startAt = new Date(endAt.getTime() - windowHours * 60 * 60 * 1000);
70
+ startDate = dateKey(startAt, timezone);
71
+ endDate = dateKey(endAt, timezone);
72
+ } else if (normalizedScope === "today") {
52
73
  startDate = targetDate;
53
74
  endDate = targetDate;
54
75
  } else if (normalizedScope === "last-7-days") {
@@ -65,6 +86,9 @@ export function resolveRange(scope, timezone, now = new Date(), sessionId = null
65
86
  targetDate,
66
87
  startDate,
67
88
  endDate,
89
+ startAt,
90
+ endAt,
91
+ hours: windowHours,
68
92
  sessionId: sessionId || null,
69
93
  };
70
94
  }
@@ -73,8 +97,15 @@ export function isCalendarScope(scope) {
73
97
  return scope === "today" || scope === "last-7-days" || scope === "this-week";
74
98
  }
75
99
 
100
+ export function isTimeWindowScope(scope) {
101
+ return scope === "last-hours";
102
+ }
103
+
76
104
  export function isDateInRange(date, range) {
77
105
  if (!date) return false;
106
+ if (isTimeWindowScope(range.scope)) {
107
+ return date >= range.startAt && date <= range.endAt;
108
+ }
78
109
  if (!isCalendarScope(range.scope)) return true;
79
110
  const key = dateKey(date, range.timezone);
80
111
  return key >= range.startDate && key <= range.endDate;
@@ -107,6 +138,13 @@ export function outputSlugForRange(range, receiptId = "") {
107
138
  const endDate = range?.endDate || range?.targetDate || startDate;
108
139
 
109
140
  if (scope === "today") return `today-${safeSlugSegment(endDate, "today")}`;
141
+ if (scope === "last-hours") {
142
+ const endAt = range?.endAt instanceof Date ? range.endAt : new Date(range?.endAt || 0);
143
+ const endStamp = Number.isNaN(endAt.getTime())
144
+ ? safeSlugSegment(endDate, "window")
145
+ : endAt.toISOString().replace(/[-:]/g, "").slice(0, 13);
146
+ return `last-${Number(range?.hours || 3)}-hours-${safeSlugSegment(endStamp, "window")}`;
147
+ }
110
148
  if (scope === "last-7-days" || scope === "this-week") {
111
149
  const dateSpan = startDate === endDate ? endDate : `${startDate}-to-${endDate}`;
112
150
  return `${scope}-${safeSlugSegment(dateSpan, scope)}`;
@@ -4,7 +4,11 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
6
  import { canonicalStringify, sha256Hex } from "./canonical.mjs";
7
- import { buildLogicalReceiptKey, buildProtocolReceiptId } from "./fact-identity.mjs";
7
+ import {
8
+ buildLogicalReceiptKey,
9
+ buildProtocolReceiptId,
10
+ buildSummaryReceiptId,
11
+ } from "./fact-identity.mjs";
8
12
  import {
9
13
  buildCompensation,
10
14
  DEFAULT_LOCALE,
@@ -22,7 +26,12 @@ function fingerprintSessionIds(sessionIds) {
22
26
  export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = DEFAULT_LOCALE, canonical = {}) {
23
27
  const sessionFingerprint = fingerprintSessionIds(metrics.sessionIds);
24
28
  const logicalKey = buildLogicalReceiptKey(metrics);
25
- const id = buildProtocolReceiptId(SOURCE_VERSION, logicalKey);
29
+ const summaryOnly = metrics.mode === "last-hours";
30
+ const schemaVersion = summaryOnly ? 1 : SCHEMA_VERSION;
31
+ const sourceVersion = summaryOnly ? "cwr1" : SOURCE_VERSION;
32
+ const id = summaryOnly
33
+ ? buildSummaryReceiptId(logicalKey)
34
+ : buildProtocolReceiptId(SOURCE_VERSION, logicalKey);
26
35
  const snapshotHash = sha256Hex({
27
36
  start: metrics.startAt.toISOString(),
28
37
  end: metrics.endAt.toISOString(),
@@ -33,6 +42,8 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
33
42
  scope: metrics.mode,
34
43
  rangeStartDate: metrics.rangeStartDate,
35
44
  rangeEndDate: metrics.rangeEndDate,
45
+ windowStartAt: metrics.windowStartAt?.toISOString() || null,
46
+ windowEndAt: metrics.windowEndAt?.toISOString() || null,
36
47
  });
37
48
  const workProfileId = metrics.workProfileId || "temporary-hire";
38
49
  const workProfile = getWorkProfileCopy(workProfileId, locale);
@@ -59,23 +70,24 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
59
70
  manifest_hash: sha256Hex(canonicalStringify(manifestCore)),
60
71
  };
61
72
 
62
- return {
63
- schema_version: SCHEMA_VERSION,
73
+ const record = {
74
+ schema_version: schemaVersion,
64
75
  locale,
65
76
  id,
66
77
  generated_at: new Date().toISOString(),
67
78
  source: {
68
79
  type: "codex",
69
- version: SOURCE_VERSION,
80
+ version: sourceVersion,
70
81
  scope: metrics.mode,
82
+ hours: metrics.windowHours || null,
71
83
  collector_version: COLLECTOR_VERSION,
72
84
  logical_key: logicalKey,
73
85
  session_fingerprint: sessionFingerprint,
74
86
  snapshot_hash: snapshotHash,
75
87
  },
76
88
  period: {
77
- start_at: metrics.startAt.toISOString(),
78
- end_at: metrics.endAt.toISOString(),
89
+ start_at: (metrics.windowStartAt || metrics.startAt).toISOString(),
90
+ end_at: (metrics.windowEndAt || metrics.endAt).toISOString(),
79
91
  timezone: metrics.timezone,
80
92
  range_start_date: metrics.rangeStartDate,
81
93
  range_end_date: metrics.rangeEndDate,
@@ -107,9 +119,12 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
107
119
  contains_paths: false,
108
120
  contains_filenames: false,
109
121
  },
110
- manifest,
111
- facts,
112
122
  };
123
+ if (!summaryOnly) {
124
+ record.manifest = manifest;
125
+ record.facts = facts;
126
+ }
127
+ return record;
113
128
  }
114
129
 
115
130
  export function persistReceiptRecord(record, outputHtmlPath, requestedDataDir = null) {
@@ -37,21 +37,23 @@ export async function promptForRange({ locale, timezone, loadRecentSessions }) {
37
37
  try {
38
38
  console.log(isEnglish ? "\nChoose a receipt range:\n" : "\n请选择小票统计范围:\n");
39
39
  console.log(isEnglish ? "1. All activity today (recommended)" : "1. 今天全部活动(推荐)");
40
- console.log(isEnglish ? "2. Last 7 calendar days" : "2. 最近 7 个自然日");
41
- console.log(isEnglish ? "3. This week (Monday to now)" : "3. 本周(周一至今)");
42
- console.log(isEnglish ? "4. Choose a specific session" : "4. 选择一个具体会话");
40
+ console.log(isEnglish ? "2. Last 3 hours" : "2. 最近 3 小时");
41
+ console.log(isEnglish ? "3. Last 7 calendar days" : "3. 最近 7 个自然日");
42
+ console.log(isEnglish ? "4. This week (Monday to now)" : "4. 本周(周一至今)");
43
+ console.log(isEnglish ? "5. Choose a specific session" : "5. 选择一个具体会话");
43
44
 
44
45
  const choice = await askForNumber(
45
46
  readline,
46
- isEnglish ? "\nEnter 1–4 (default 1): " : "\n请输入 1–4(默认 1):",
47
+ isEnglish ? "\nEnter 1–5 (default 1): " : "\n请输入 1–5(默认 1):",
47
48
  1,
48
- 4,
49
+ 5,
49
50
  1,
50
51
  );
51
52
 
52
53
  if (choice === 1) return { mode: "today", sessionId: null };
53
- if (choice === 2) return { mode: "last-7-days", sessionId: null };
54
- if (choice === 3) return { mode: "this-week", sessionId: null };
54
+ if (choice === 2) return { mode: "last-hours", sessionId: null, hours: 3 };
55
+ if (choice === 3) return { mode: "last-7-days", sessionId: null };
56
+ if (choice === 4) return { mode: "this-week", sessionId: null };
55
57
 
56
58
  const sessions = await loadRecentSessions();
57
59
  if (!sessions.length) throw new Error(isEnglish ? "No Codex sessions found" : "没有找到可选择的 Codex 会话");
@@ -3,7 +3,12 @@ import { createRequire } from "node:module";
3
3
 
4
4
  import { buildCode128B } from "../core/barcode.mjs";
5
5
  import { formatDate, formatDuration, formatNumber, formatTime } from "../lib/time.mjs";
6
- import { buildCompensation, DEFAULT_LOCALE, getReceiptCopy } from "../core/presentation.mjs";
6
+ import {
7
+ buildCompensation,
8
+ DEFAULT_LOCALE,
9
+ getReceiptCopy,
10
+ getScopeLabel,
11
+ } from "../core/presentation.mjs";
7
12
 
8
13
  const require = createRequire(import.meta.url);
9
14
  const DOM_TO_IMAGE_SOURCE = fs.readFileSync(require.resolve("dom-to-image-more"), "utf8");
@@ -65,7 +70,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
65
70
  const rangeEndDate = record.period.range_end_date || formatDateKey(record.period.end_at.slice(0, 10), "zh-CN").replaceAll("/", "-");
66
71
  const isCalendarScope = new Set(["today", "last-7-days", "this-week"]).has(record.source.scope);
67
72
  const spansMultipleDates = rangeStartDate !== rangeEndDate;
68
- const scopeLabel = copy.scope[record.source.scope] || copy.scope.latest;
73
+ const scopeLabel = getScopeLabel(record.source.scope, locale, record.source.hours);
69
74
  const dateRange = spansMultipleDates
70
75
  ? `${formatDateKey(rangeStartDate, locale)}—${formatDateKey(rangeEndDate, locale)}`
71
76
  : formatDateKey(rangeEndDate, locale);
@@ -86,6 +91,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
86
91
  busyLabel: copy.exportingImage,
87
92
  successLabel: copy.exportSuccess,
88
93
  errorLabel: copy.exportError,
94
+ miniProgramLabel: copy.exportMiniProgramLabel,
89
95
  fileBase: `codex-work-receipt-${record.source.scope}-${spansMultipleDates ? `${rangeStartDate}-to-${rangeEndDate}` : `${rangeEndDate}-${record.id.slice(4, 12)}`}`,
90
96
  }).replaceAll("<", "\\u003c");
91
97
  const rows = [
@@ -146,7 +152,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
146
152
  <div class="qr-grid qr-grid--export-only" hidden>
147
153
  <div class="qr-item">
148
154
  <div class="qr-frame">${miniProgramVisual}</div>
149
- <strong>${escapeHtml(copy.openMiniProgram)}</strong>
155
+ <strong data-export-mini-label>${escapeHtml(copy.openMiniProgram)}</strong>
150
156
  <span>${escapeHtml(copy.openMiniProgramHint)}</span>
151
157
  </div>
152
158
  ${dataQrItems}
@@ -155,7 +161,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
155
161
  <div class="qr-grid qr-grid--single">
156
162
  <div class="qr-item">
157
163
  <div class="qr-frame">${miniProgramVisual}</div>
158
- <strong>${escapeHtml(copy.openMiniProgram)}</strong>
164
+ <strong data-export-mini-label>${escapeHtml(copy.openMiniProgram)}</strong>
159
165
  <span>${escapeHtml(copy.openMiniProgramHint)}</span>
160
166
  </div>
161
167
  ${dataQrItems}
@@ -687,6 +693,50 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
687
693
  }));
688
694
  }
689
695
 
696
+ function sanitizeExportNode(node) {
697
+ const multipartLiveNode = node.querySelector(".multipart-live");
698
+ if (multipartLiveNode) multipartLiveNode.remove();
699
+
700
+ const exportGridNode = node.querySelector(".qr-grid--export-only");
701
+ if (exportGridNode) {
702
+ exportGridNode.removeAttribute("hidden");
703
+ exportGridNode.style.display = "grid";
704
+ }
705
+
706
+ node.querySelectorAll("[data-data-qr-index]").forEach((item) => item.remove());
707
+ node.querySelectorAll(".qr-grid").forEach((grid) => {
708
+ grid.style.gridTemplateColumns = "minmax(0, 1fr)";
709
+ grid.style.justifyItems = "center";
710
+ });
711
+
712
+ const miniProgramLabel = node.querySelector("[data-export-mini-label]");
713
+ if (miniProgramLabel) miniProgramLabel.textContent = exportConfig.miniProgramLabel;
714
+ const transferDescription = node.querySelector(".transfer-heading p");
715
+ if (transferDescription) transferDescription.remove();
716
+ const transferNote = node.querySelector(".transfer-note");
717
+ if (transferNote) transferNote.remove();
718
+
719
+ node.querySelectorAll(".paper").forEach((paper) => {
720
+ paper.style.boxShadow = "none";
721
+ });
722
+ }
723
+
724
+ function createExportNode(source, paperColor) {
725
+ const clone = source.cloneNode(true);
726
+ sanitizeExportNode(clone);
727
+ const sourceWidth = Math.ceil(source.getBoundingClientRect().width || source.scrollWidth);
728
+ Object.assign(clone.style, {
729
+ position: "absolute",
730
+ left: "-100000px",
731
+ top: "0",
732
+ width: sourceWidth + "px",
733
+ background: paperColor,
734
+ pointerEvents: "none",
735
+ });
736
+ document.body.appendChild(clone);
737
+ return clone;
738
+ }
739
+
690
740
  function downloadImage(dataUrl, filename) {
691
741
  const link = document.createElement("a");
692
742
  link.href = dataUrl;
@@ -703,15 +753,17 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
703
753
  exportButton.disabled = true;
704
754
  exportButton.textContent = exportConfig.busyLabel;
705
755
  setExportStatus("");
756
+ let renderNode = null;
706
757
 
707
758
  try {
708
759
  if (document.fonts?.ready) await document.fonts.ready;
709
- await waitForImages(exportNode);
710
- const width = Math.ceil(exportNode.scrollWidth);
711
- const height = Math.ceil(exportNode.scrollHeight);
712
- const scale = Math.max(2, Math.min(3, 1500 / Math.max(1, width)));
713
760
  const paperColor = getComputedStyle(document.documentElement).getPropertyValue("--paper").trim() || "#ffffff";
714
- const dataUrl = await domtoimage.toPng(exportNode, {
761
+ renderNode = createExportNode(exportNode, paperColor);
762
+ await waitForImages(renderNode);
763
+ const width = Math.ceil(renderNode.scrollWidth);
764
+ const height = Math.ceil(renderNode.scrollHeight);
765
+ const scale = Math.max(2, Math.min(3, 1500 / Math.max(1, width)));
766
+ const dataUrl = await domtoimage.toPng(renderNode, {
715
767
  width,
716
768
  height,
717
769
  scale,
@@ -720,17 +772,12 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
720
772
  cacheBust: false,
721
773
  style: { background: paperColor },
722
774
  onclone(clone) {
775
+ sanitizeExportNode(clone);
776
+ clone.style.position = "static";
777
+ clone.style.left = "auto";
778
+ clone.style.top = "auto";
779
+ clone.style.width = width + "px";
723
780
  clone.style.background = paperColor;
724
- const multipartLiveClone = clone.querySelector(".multipart-live");
725
- if (multipartLiveClone) multipartLiveClone.remove();
726
- const exportGridClone = clone.querySelector(".qr-grid--export-only");
727
- if (exportGridClone) {
728
- exportGridClone.removeAttribute("hidden");
729
- exportGridClone.style.display = "grid";
730
- }
731
- clone.querySelectorAll(".paper").forEach((paper) => {
732
- paper.style.boxShadow = "none";
733
- });
734
781
  },
735
782
  logger: {},
736
783
  });
@@ -743,6 +790,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
743
790
  console.error(error);
744
791
  setExportStatus(exportConfig.errorLabel, "error");
745
792
  } finally {
793
+ if (renderNode) renderNode.remove();
746
794
  exportButton.disabled = false;
747
795
  exportButton.textContent = exportConfig.idleLabel;
748
796
  }