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