From 04491d1c09cfcfce3148743a749868e8a7d4addd Mon Sep 17 00:00:00 2001
From: Entropy-R <144404583+Entropy-R@users.noreply.github.com>
Date: Tue, 21 Jul 2026 15:20:56 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=A1=B5=E9=9D=A2?=
=?UTF-8?q?=E5=88=B7=E6=96=B0=E8=87=AA=E5=8A=A8=E6=9B=B4=E6=96=B0=E6=95=B0?=
=?UTF-8?q?=E6=8D=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/test.yml | 6 +-
README.md | 10 +-
assets/app.js | 73 ++++++++++--
assets/styles.css | 7 +-
index.html | 3 +-
scripts/refresh-data.ps1 | 13 ++-
scripts/serve-dashboard.ps1 | 219 ++++++++++++++++++++++++++++++++++++
start-dashboard.cmd | 16 ++-
8 files changed, 319 insertions(+), 28 deletions(-)
create mode 100644 scripts/serve-dashboard.ps1
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 2515119..86a2bf9 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -17,6 +17,10 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- - name: Run PowerShell tests
+ - name: Run PowerShell 7 tests
+ shell: pwsh
+ run: .\tests\test-refresh.ps1
+
+ - name: Run Windows PowerShell 5.1 tests
shell: powershell
run: .\tests\test-refresh.ps1
diff --git a/README.md b/README.md
index 9309590..1dea60f 100644
--- a/README.md
+++ b/README.md
@@ -19,9 +19,10 @@
- 展示缓存命中率、月度环比、日均用量与单次响应均值。
- 支持年度相对色阶与固定色阶,兼顾个人高峰和跨月比较。
- 提供当前指标的月度趋势图,并区分今天、未来日期和无使用日期。
+- 刷新浏览器或点击页面按钮时自动重新读取本地统计。
- 汇总模型响应次数与本地会话文件数量。
- 提供官方 API 单价参考和基础费用估算。
-- 无需安装依赖或启动 Web 服务,双击即可运行。
+- 无需安装第三方依赖,双击即可启动本地看板服务。
- 只在本机处理数据,不上传会话记录或统计结果。
## 系统要求
@@ -32,14 +33,17 @@
## 使用方法
-下载或克隆本仓库,然后双击项目根目录中的 `start-dashboard.cmd`。
+下载或克隆本仓库,然后双击项目根目录中的 `start-dashboard.cmd`。启动后请保留命令窗口;关闭窗口或按 `Ctrl+C` 会停止本地服务。
启动程序会:
1. 从 `$env:CODEX_HOME` 读取 Codex 数据;未设置时使用 `~/.codex`。
2. 扫描 `sessions` 和 `archived_sessions` 下的 JSONL 会话记录。
3. 只提取日期和 Token 统计,不读取或保存提示词、回答正文、凭据或项目内容。
-4. 更新本地数据文件,并用默认浏览器打开日历。
+4. 启动只监听 `127.0.0.1:8765` 的本地服务,并用默认浏览器打开日历。
+5. 每次刷新浏览器或点击“刷新数据”时,重新生成并展示最新统计。
+
+本地服务只允许本机访问,不会把会话记录或汇总数据发送到外部网络。直接双击 `index.html` 仍可查看上一次生成的数据,但页面内刷新功能不可用。
也可以只刷新数据:
diff --git a/assets/app.js b/assets/app.js
index 2b852b0..8343453 100644
--- a/assets/app.js
+++ b/assets/app.js
@@ -1,7 +1,7 @@
(() => {
'use strict';
- const data = window.CODEX_TOKEN_CALENDAR_DATA || null;
+ let data = window.CODEX_TOKEN_CALENDAR_DATA || null;
const pricing = window.CODEX_PRICING_REFERENCE || null;
const metrics = {
total: { label: '总 Token', description: '输入 Token 与输出 Token 之和' },
@@ -15,6 +15,7 @@
const elements = {
status: document.getElementById('data-status'),
+ refresh: document.getElementById('refresh-data'),
metric: document.getElementById('metric-select'),
scale: document.getElementById('scale-select'),
picker: document.getElementById('month-picker'),
@@ -55,7 +56,7 @@
const detailDate = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: 'long', day: 'numeric', weekday: 'short'
});
- const byDate = new Map((data?.days || []).map(day => [day.date, day]));
+ let byDate = new Map((data?.days || []).map(day => [day.date, day]));
const now = new Date();
const todayKey = isoDate(now.getFullYear(), now.getMonth(), now.getDate());
let viewDate = new Date(now.getFullYear(), now.getMonth(), 1);
@@ -73,6 +74,62 @@
return Number(day?.[metric] || 0);
}
+ function renderDataStatus(prefix) {
+ if (!data) {
+ elements.status.textContent = prefix || '尚未生成数据,请运行 start-dashboard.cmd。';
+ return;
+ }
+
+ const generated = new Date(data.generatedAt);
+ const summary = data.sourceSummary;
+ elements.status.textContent =
+ `${prefix ? `${prefix} · ` : ''}更新于 ${generated.toLocaleString('zh-CN')} · ` +
+ `${summary.sessionFiles} 个会话文件 · ${summary.tokenRecords} 条 Token 记录` +
+ (summary.parseErrors ? ` · 跳过 ${summary.parseErrors} 条异常记录` : '');
+ }
+
+ async function refreshData() {
+ if (location.protocol === 'file:') {
+ elements.status.textContent = '页面内刷新需要通过 start-dashboard.cmd 启动本地服务。';
+ elements.refresh.className = 'refresh-button is-error';
+ return;
+ }
+
+ elements.refresh.disabled = true;
+ elements.refresh.className = 'refresh-button is-loading';
+ elements.refresh.textContent = '正在刷新…';
+ elements.status.textContent = '正在重新读取本地 Codex Token 统计…';
+
+ try {
+ const response = await fetch('/api/refresh', {
+ method: 'POST',
+ cache: 'no-store',
+ headers: { Accept: 'application/json' }
+ });
+ const payload = await response.json();
+ if (!response.ok || !payload.ok || !payload.data) {
+ throw new Error(payload.error || `服务返回 ${response.status}`);
+ }
+
+ data = payload.data;
+ window.CODEX_TOKEN_CALENDAR_DATA = data;
+ byDate = new Map((data.days || []).map(day => [day.date, day]));
+ selectedDate = null;
+ renderDataStatus('刷新成功');
+ renderCalendar();
+ elements.refresh.className = 'refresh-button is-success';
+ elements.refresh.textContent = '刷新完成';
+ }
+ catch (error) {
+ renderDataStatus(`刷新失败:${error.message}`);
+ elements.refresh.className = 'refresh-button is-error';
+ elements.refresh.textContent = '重试刷新';
+ }
+ finally {
+ elements.refresh.disabled = false;
+ }
+ }
+
function formatValue(value, metric, compact = true) {
if (metrics[metric].percentage) return `${value.toFixed(1)}%`;
return (compact ? compactNumber : fullNumber).format(value);
@@ -398,17 +455,9 @@
`价格核对日期:${pricing.verifiedAt}。${pricing.notes.join(' ')}`;
}
- if (data) {
- const generated = new Date(data.generatedAt);
- const summary = data.sourceSummary;
- elements.status.textContent =
- `更新于 ${generated.toLocaleString('zh-CN')} · ${summary.sessionFiles} 个会话文件 · ` +
- `${summary.tokenRecords} 条 Token 记录` +
- (summary.parseErrors ? ` · 跳过 ${summary.parseErrors} 条异常记录` : '');
- } else {
- elements.status.textContent = '尚未生成数据,请运行 start-dashboard.cmd。';
- }
+ renderDataStatus();
+ elements.refresh.addEventListener('click', refreshData);
elements.metric.addEventListener('change', renderCalendar);
elements.scale.addEventListener('change', renderCalendar);
elements.previous.addEventListener('click', () => shiftMonth(-1));
diff --git a/assets/styles.css b/assets/styles.css
index 0a69f78..efb4ff2 100644
--- a/assets/styles.css
+++ b/assets/styles.css
@@ -116,7 +116,12 @@ h2 { margin-bottom: 0; font-size: clamp(21px, 3vw, 30px); }
.toolbar select, .toolbar input[type="month"] { min-height: 42px; padding: 0 12px; }
.toolbar select { min-width: 190px; }
.month-controls { display: flex; flex-wrap: wrap; gap: 8px; }
-.month-controls button:first-child, .month-controls button:last-child { font-size: 24px; }
+.month-controls button[aria-label="上一个月"],
+.month-controls button[aria-label="下一个月"] { font-size: 24px; }
+.refresh-button { border-color: var(--accent); color: var(--accent); font-weight: 600; }
+.refresh-button.is-loading { cursor: wait; opacity: 0.72; }
+.refresh-button.is-success { border-color: var(--level-1); color: var(--level-1); }
+.refresh-button.is-error { border-color: var(--level-4); color: var(--level-4); }
abbr[title] {
display: inline-grid;
diff --git a/index.html b/index.html
index 91a6d45..ff1bed1 100644
--- a/index.html
+++ b/index.html
@@ -14,7 +14,7 @@
LOCAL CODEX USAGE
Codex Token 日历
-
正在读取本地统计数据…
+
正在读取本地统计数据…
@@ -40,6 +40,7 @@ Codex Token 日历
+
diff --git a/scripts/refresh-data.ps1 b/scripts/refresh-data.ps1
index 2f3669b..e3304d7 100644
--- a/scripts/refresh-data.ps1
+++ b/scripts/refresh-data.ps1
@@ -76,7 +76,18 @@ foreach ($filePath in @($filesByName.Values | Sort-Object)) {
}
try {
- $timestamp = [DateTimeOffset]::Parse([string]$record.timestamp)
+ # PowerShell 7 会把 ISO 时间戳反序列化为 DateTime;先转成字符串会丢失
+ # Utc Kind,导致跨日记录不再按北京时间偏移。Windows PowerShell 5.1
+ # 保留字符串,因此需要同时兼容两种运行时返回类型。
+ if ($record.timestamp -is [DateTime]) {
+ $timestamp = [DateTimeOffset]$record.timestamp
+ }
+ elseif ($record.timestamp -is [DateTimeOffset]) {
+ $timestamp = $record.timestamp
+ }
+ else {
+ $timestamp = [DateTimeOffset]::Parse([string]$record.timestamp)
+ }
$localTime = [TimeZoneInfo]::ConvertTime($timestamp, $chinaTimeZone)
$dateKey = $localTime.ToString('yyyy-MM-dd')
$usage = $record.payload.info.last_token_usage
diff --git a/scripts/serve-dashboard.ps1 b/scripts/serve-dashboard.ps1
new file mode 100644
index 0000000..3874bf3
--- /dev/null
+++ b/scripts/serve-dashboard.ps1
@@ -0,0 +1,219 @@
+param(
+ [int]$Port = 8765,
+ [string]$CodexHome,
+ [switch]$NoBrowser
+)
+
+$ErrorActionPreference = 'Stop'
+
+$projectRoot = Split-Path $PSScriptRoot -Parent
+$refreshScript = Join-Path $PSScriptRoot 'refresh-data.ps1'
+$usageDataPath = Join-Path $projectRoot 'data\usage-data.js'
+$baseUrl = "http://127.0.0.1:$Port/"
+$serviceName = 'codex-token-calendar'
+$allowedStaticFiles = [Collections.Generic.HashSet[string]]::new(
+ [StringComparer]::OrdinalIgnoreCase
+)
+@(
+ 'index.html'
+ 'assets/app.js'
+ 'assets/styles.css'
+ 'assets/screenshot.png'
+ 'config/pricing.js'
+ 'data/usage-data.js'
+) | ForEach-Object { [void]$allowedStaticFiles.Add($_) }
+
+function Write-Response {
+ param(
+ [System.Net.HttpListenerResponse]$Response,
+ [byte[]]$Body,
+ [string]$ContentType,
+ [int]$StatusCode = 200
+ )
+
+ $Response.StatusCode = $StatusCode
+ $Response.ContentType = $ContentType
+ $Response.ContentLength64 = $Body.Length
+ $Response.Headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
+ $Response.Headers['Pragma'] = 'no-cache'
+ $Response.Headers['X-Content-Type-Options'] = 'nosniff'
+ $Response.Headers['X-Frame-Options'] = 'DENY'
+ $Response.Headers['Content-Security-Policy'] =
+ "default-src 'self'; script-src 'self'; style-src 'self'; " +
+ "img-src 'self' data:; connect-src 'self'; base-uri 'none'; " +
+ "frame-ancestors 'none'"
+ $Response.OutputStream.Write($Body, 0, $Body.Length)
+ $Response.OutputStream.Close()
+}
+
+function Write-TextResponse {
+ param(
+ [System.Net.HttpListenerResponse]$Response,
+ [string]$Text,
+ [string]$ContentType = 'text/plain; charset=utf-8',
+ [int]$StatusCode = 200
+ )
+
+ $utf8WithoutBom = New-Object System.Text.UTF8Encoding($false)
+ Write-Response -Response $Response -Body $utf8WithoutBom.GetBytes($Text) `
+ -ContentType $ContentType -StatusCode $StatusCode
+}
+
+function Invoke-DataRefresh {
+ & $refreshScript -CodexHome $CodexHome -OutputPath $usageDataPath | Out-Host
+ return [IO.File]::ReadAllText($usageDataPath)
+}
+
+function Convert-UsageScriptToJson {
+ param([string]$Javascript)
+
+ $json = $Javascript -replace '^window\.CODEX_TOKEN_CALENDAR_DATA\s*=\s*', ''
+ return $json -replace ';\s*$', ''
+}
+
+function Get-ContentType {
+ param([string]$Path)
+
+ switch ([IO.Path]::GetExtension($Path).ToLowerInvariant()) {
+ '.html' { return 'text/html; charset=utf-8' }
+ '.css' { return 'text/css; charset=utf-8' }
+ '.js' { return 'application/javascript; charset=utf-8' }
+ '.png' { return 'image/png' }
+ default { return 'application/octet-stream' }
+ }
+}
+
+function Test-ExistingService {
+ try {
+ $health = Invoke-RestMethod -Uri "${baseUrl}api/health" -TimeoutSec 2
+ return $health.service -eq $serviceName
+ }
+ catch {
+ return $false
+ }
+}
+
+if (Test-ExistingService) {
+ Write-Host "Codex Token 看板已在运行:$baseUrl"
+ if (-not $NoBrowser) {
+ Start-Process $baseUrl
+ }
+ exit 0
+}
+
+$listener = New-Object System.Net.HttpListener
+$listener.Prefixes.Add($baseUrl)
+
+try {
+ # 启动时先验证数据源,避免浏览器打开后才发现 Codex 目录不可用。
+ Invoke-DataRefresh | Out-Null
+ $listener.Start()
+}
+catch {
+ Write-Error "无法启动本地看板服务($baseUrl):$($_.Exception.Message)"
+ exit 1
+}
+
+Write-Host "Codex Token 看板已启动:$baseUrl"
+Write-Host '刷新浏览器或点击“刷新数据”都会重新读取本地 Codex 统计。'
+Write-Host '关闭此窗口或按 Ctrl+C 可停止服务。'
+
+if (-not $NoBrowser) {
+ Start-Process $baseUrl
+}
+
+try {
+ while ($listener.IsListening) {
+ $context = $listener.GetContext()
+ $request = $context.Request
+ $response = $context.Response
+
+ try {
+ $path = $request.Url.AbsolutePath
+
+ if ($path -eq '/api/health') {
+ if ($request.HttpMethod -ne 'GET') {
+ Write-TextResponse -Response $response -Text 'Method Not Allowed' -StatusCode 405
+ continue
+ }
+ Write-TextResponse -Response $response `
+ -Text "{`"service`":`"$serviceName`",`"status`":`"ok`"}" `
+ -ContentType 'application/json; charset=utf-8'
+ continue
+ }
+
+ if ($path -eq '/api/refresh') {
+ if ($request.HttpMethod -ne 'POST') {
+ Write-TextResponse -Response $response -Text 'Method Not Allowed' -StatusCode 405
+ continue
+ }
+ $origin = $request.Headers['Origin']
+ $expectedOrigin = $baseUrl.TrimEnd('/')
+ if ($origin -and -not $origin.Equals(
+ $expectedOrigin,
+ [StringComparison]::OrdinalIgnoreCase
+ )) {
+ Write-TextResponse -Response $response -Text 'Forbidden' -StatusCode 403
+ continue
+ }
+ $javascript = Invoke-DataRefresh
+ $json = Convert-UsageScriptToJson $javascript
+ Write-TextResponse -Response $response -Text "{`"ok`":true,`"data`":$json}" `
+ -ContentType 'application/json; charset=utf-8'
+ continue
+ }
+
+ if ($request.HttpMethod -ne 'GET') {
+ Write-TextResponse -Response $response -Text 'Method Not Allowed' -StatusCode 405
+ continue
+ }
+
+ $relativePath = [Uri]::UnescapeDataString($path.TrimStart('/'))
+ if ([string]::IsNullOrWhiteSpace($relativePath)) {
+ $relativePath = 'index.html'
+ }
+ $relativePath = $relativePath.Replace('/', [IO.Path]::DirectorySeparatorChar)
+ $publicPath = $relativePath.Replace('\', '/')
+
+ if ($relativePath -eq 'data\usage-data.js') {
+ Invoke-DataRefresh | Out-Null
+ }
+
+ $candidatePath = [IO.Path]::GetFullPath((Join-Path $projectRoot $relativePath))
+ $rootPrefix = $projectRoot.TrimEnd('\') + '\'
+ $isInsideRoot = $candidatePath.StartsWith(
+ $rootPrefix,
+ [StringComparison]::OrdinalIgnoreCase
+ )
+ $isAllowedStaticFile = $allowedStaticFiles.Contains($publicPath)
+
+ if (-not $isInsideRoot -or -not $isAllowedStaticFile -or
+ -not (Test-Path -LiteralPath $candidatePath -PathType Leaf)) {
+ Write-TextResponse -Response $response -Text 'Not Found' -StatusCode 404
+ continue
+ }
+
+ $bytes = [IO.File]::ReadAllBytes($candidatePath)
+ Write-Response -Response $response -Body $bytes `
+ -ContentType (Get-ContentType $candidatePath)
+ }
+ catch {
+ if ($request.Url.AbsolutePath -like '/api/*') {
+ $errorJson = @{
+ ok = $false
+ error = $_.Exception.Message
+ } | ConvertTo-Json -Compress
+ Write-TextResponse -Response $response -Text $errorJson `
+ -ContentType 'application/json; charset=utf-8' -StatusCode 500
+ }
+ else {
+ Write-TextResponse -Response $response `
+ -Text "刷新数据失败:$($_.Exception.Message)" -StatusCode 500
+ }
+ }
+ }
+}
+finally {
+ $listener.Stop()
+ $listener.Close()
+}
diff --git a/start-dashboard.cmd b/start-dashboard.cmd
index b9191f6..742a975 100644
--- a/start-dashboard.cmd
+++ b/start-dashboard.cmd
@@ -2,21 +2,19 @@
setlocal
set "ROOT=%~dp0"
-powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%ROOT%scripts\refresh-data.ps1"
+where pwsh.exe >nul 2>&1
+if errorlevel 1 (
+ powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%ROOT%scripts\serve-dashboard.ps1"
+) else (
+ pwsh.exe -NoProfile -ExecutionPolicy Bypass -File "%ROOT%scripts\serve-dashboard.ps1"
+)
if errorlevel 1 (
echo.
- echo [ERROR] Failed to refresh Codex Token data.
- if exist "%ROOT%data\usage-data.js" (
- echo Opening the last successful dashboard data.
- start "" "%ROOT%index.html"
- ) else (
- echo No dashboard data is available.
- )
+ echo [ERROR] Failed to start the Codex Token dashboard service.
echo.
pause
exit /b 1
)
-start "" "%ROOT%index.html"
exit /b 0