diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml
new file mode 100644
index 0000000..4a894be
--- /dev/null
+++ b/.github/workflows/windows.yml
@@ -0,0 +1,66 @@
+name: Windows 构建与回归
+on:
+ push:
+ branches: [main, fix/windows-0.2]
+ workflow_dispatch:
+ inputs:
+ publish_release:
+ description: 发布已验证的 0.2.0 正式包
+ type: boolean
+ default: false
+permissions:
+ contents: read
+jobs:
+ verify:
+ runs-on: windows-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+ - name: 核心回归
+ run: dotnet run --project tests/WinCodexBar.CoreTests -c Release
+ - name: Windows 窗口回归
+ run: dotnet run --project tests/WinCodexBar.WindowsTests -c Release
+ - name: 发布三个架构
+ shell: pwsh
+ run: |
+ foreach ($rid in @('win-x64', 'win-x86', 'win-arm64')) {
+ dotnet publish windows/CodexBarWin/CodexBarWin.csproj -c Release -r $rid --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o "artifacts/$rid"
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ }
+ - name: 打包与校验清单
+ shell: pwsh
+ run: ./tools/Package-Windows.ps1
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: windows-validation-and-packages
+ path: |
+ artifacts/packages/
+ tests/WinCodexBar.WindowsTests/bin/Release/net8.0-windows/screenshots/
+
+ release:
+ if: github.event_name == 'workflow_dispatch' && inputs.publish_release && github.ref == 'refs/heads/main'
+ needs: verify
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/download-artifact@v4
+ with:
+ name: windows-validation-and-packages
+ path: release-files
+ - name: 发布正式版
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: >-
+ gh release create v0.2.0
+ release-files/artifacts/packages/*.zip
+ release-files/artifacts/packages/SHA256SUMS.txt
+ --target "$GITHUB_SHA"
+ --title "WinCodexBar 0.2.0 · Fluent 界面与自动更新"
+ --notes-file tools/Release-Notes-0.2.0.md
+ --latest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 49f09f4..7d30886 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,22 @@
+# 更新记录
+
+## 0.2.0 — 2026-09-26
+
+- 设置页统一 Fluent 图标与内置中英文字体,修复窄屏、高缩放下的卡片与页脚布局。
+- 缺少 5 小时窗口的账号只显示 7 天用量;平均值与托盘图标同步处理。
+- 新增只读重置卡次数、逐张到期时间,以及带时区的精确额度重置时间;不提供使用或兑换操作。
+
+
+- 修复托盘外部点击检测误关删除确认框、弹窗与主窗口开关竞争、右键触发操作和滚动区域点击穿透。
+- 修复小屏窗口超出工作区;新增跟随显示器 DPI、100%–300% 手动缩放、设置卡片响应式排列与滚动入口。
+- 新增额度圆环、剩余百分比、经典图标三种托盘样式;释放被替换的图标和账号行控件。
+- 新增自动/手动版本检查、SHA256 校验、覆盖更新与重启、文件替换失败回滚,以及首次升级的数据备份。
+- 添加 GPT-6 三个型号和价格预设;只读本地模型目录补充模型与推理强度;修复标准服务档位同步。
+- 优先显示 Radar 当前重置窗口,过期预测不再覆盖当前状态;网络失败标记缓存。
+- 保留关闭的唤醒策略;保存模型和模式后立即同步;限制刷新间隔,防止计时器整数溢出。
+- 本地 Token 扫描改为后台执行;工作台增加删除入口;单实例防止重复运行引发文件/网关冲突。
+- 增加独立临时数据目录下的核心回归、Windows 窗口与更新脚本验证,以及三架构构建流程。
+
# 更新日志
## 0.1.1 - 2026-06-21
diff --git a/README.en.md b/README.en.md
index e659c23..9949aa3 100644
--- a/README.en.md
+++ b/README.en.md
@@ -1,171 +1,31 @@
# WinCodexBar
-> A Windows tray workspace for Codex multi-account management, quota visibility, local token activity, and aggregate account routing.
+[简体中文](./README.md) · [Download](https://github.com/windycn/WinCodexBar/releases/latest)
-## About
+A Windows tray workspace for Codex accounts, quotas, model settings, and local usage insights.
-WinCodexBar is a Windows tray app built for Codex users who work with multiple OpenAI OAuth accounts. It combines account switching, quota tracking, token activity, session analysis, import/export, keep-awake controls, and aggregate routing in one lightweight desktop workflow.
+Version **0.2.0** adds automatic display DPI adaptation, manual scaling from 100% to 300%, three tray icon styles, GPT-6 choices and local model catalog integration. It fixes popup/dialog conflicts, deletion confirmation, clipped hit targets, and disabled wake settings being re-enabled after restart.
-## Why It Matters
+Automatic update checks run shortly after startup and every six hours. Installation requires confirmation: the app downloads the matching architecture package, verifies SHA256, backs up account/settings files, then replaces program files and restarts. Replacement failures trigger a rollback attempt with retained backups and logs.
-### Aggregate Mode Keeps Account Routing Continuous
+Download the x64, x86, or arm64 ZIP, extract it to a separate writable folder, and launch `WinCodexBar.exe`. The .NET runtime is included. For the first upgrade from 0.1.x, exit the old version and replace its files manually; subsequent releases can use the new updater.
-Aggregate mode starts a local account gateway and treats your OpenAI OAuth accounts as an account pool. New Codex instances can connect through this local gateway, so account selection and request routing are handled by WinCodexBar instead of repeatedly editing account configuration in each project.
+Account/settings files remain in `%USERPROFILE%\.codexbar` (or beneath `CODEXBAR_HOME`). Version upgrades create backups under `.codexbar\backups`. Update staging, program backups and `result.txt` are under `%LOCALAPPDATA%\WinCodexBar\updates`. Session history is not deleted. Backups and account exports contain credentials and should remain private.
-This is useful when you work across multiple projects or long-running sessions. You can inspect all account quotas in one place and continue with a healthier account when needed. Codex instances opened before aggregate mode is enabled usually need to be restarted or reopened before they use the local gateway.
+Single-click the tray icon for quick actions; double-click for the dashboard. Appearance settings offer a quota ring, remaining percentage or classic app icon. Settings also provide manual update checks. Model availability depends on the account and Codex version; existing selections are preserved.
-### Switching Accounts Does Not Mean Losing Project Memory
+Token costs are estimates, not billing records. The public reset-window summary is attributed to [Codex Radar](https://codexradar.com/); it is not evidence that an individual account has reset. Codex Radar’s protected full API is not queried.
-WinCodexBar switches OAuth identity and request routing. It does not clear your project folders, local session records, or Codex project context files. In practical terms, the account is the request identity, while the project session is local working memory; WinCodexBar changes the former and does not delete the latter.
-
-In manual mode, already-running Codex instances usually need to be restarted or reopened before they use the newly selected account. In aggregate mode, later routing decisions are centralized through the local gateway.
-
-### Quota And Token Activity Stay Visible
-
-The tray icon uses a ring indicator for recent 5-hour quota. The tray panel and dashboard show 5-hour quota, 7-day quota, health state, reset time, and local token activity for today, this week, this month, and all time. You can react before an account runs out of quota.
-
-## Screenshots
-
-### Tray Menu
-
-The compact tray panel shows the current account, quota, subscription type, and account pool state. It also gives quick access to account actions, the dashboard, and settings.
-
-
-
-
-
-### Dashboard
-
-The dashboard includes the account list, token activity, session analysis, and cost estimates. Bar charts and heatmaps make daily, weekly, monthly, and all-time token usage easier to scan.
-
-
-
-
-
-### Settings
-
-Settings cover account mode, quota display, wake strategy, and model parameters. Wake strategy supports both system keep-awake and advanced anti-sleep behavior.
-
-
-
-
-
-## Problems It Solves
-
-When you use several OpenAI accounts with Codex, the friction usually comes from:
-
-- Not knowing how much 5-hour or 7-day quota remains.
-- Editing local account configuration by hand.
-- Interrupting work when switching accounts across multiple projects.
-- Moving or backing up multiple OAuth accounts.
-- Windows sleeping or locking the screen during a long session.
-- Token activity and local session history being hard to inspect.
-
-WinCodexBar puts these controls into a tray menu, a dashboard, and a settings window designed for day-to-day Windows use.
-
-## Features
-
-### Tray Menu
-
-- Open a compact account panel from the system tray.
-- Use a ring tray icon to show recent 5-hour quota status.
-- Hover over the tray icon to inspect 5-hour and 7-day usage.
-- Open the dashboard from the tray menu.
-- Dismiss the tray panel by clicking outside it.
-
-### Account Management
-
-- Add OpenAI OAuth accounts.
-- Capture browser OAuth callbacks automatically when possible.
-- Paste the returned browser URL manually when automatic capture is unavailable.
-- Import and export multiple accounts for backup or migration.
-- Delete accounts with a confirmation step.
-- Switch the active account and write the change to Codex configuration.
-
-### Manual And Aggregate Modes
-
-- Manual mode writes the selected account to local Codex configuration. Already-running Codex instances usually need to be restarted or reopened to use the new account.
-- Aggregate mode starts a local account gateway so new Codex instances can route through a local endpoint.
-- Aggregate mode is designed for multi-project, multi-account, and long-session workflows where repeatedly editing account config is disruptive.
-- Codex instances that were already open before aggregate mode is enabled usually need to be restarted or reopened before they use the local gateway.
-
-### Usage And Quota
-
-- Show each account's subscription, health state, 5-hour quota, and 7-day quota.
-- Choose between used quota and remaining quota display modes.
-- Choose token number units: Chinese-style units or K/M/B.
-- Use green, orange, and red status colors as remaining quota crosses warning thresholds.
-- Show reset countdowns together with exact reset dates and times.
-
-### Dashboard
-
-- View account counts, current account, quota state, and health summary.
-- Refresh or switch accounts from the account list.
-- Inspect token activity with daily, weekly, monthly, and all-time views.
-- Use bar charts and a calendar-style heatmap for token trends.
-- Review local Codex sessions, including recent sessions and highest-token sessions.
-- Estimate token cost in USD and CNY using editable model price presets.
-
-### Keep Awake
-
-- Keep Awake prevents Windows from sleeping or turning off the display.
-- Advanced Keep Awake can gently move the mouse after an idle period.
-- Configure idle threshold, trigger interval, jitter duration, movement strategy, and fullscreen pause behavior.
-- Enable launch at Windows startup.
-
-### Settings
-
-- Account settings: manual mode and aggregate mode.
-- Usage settings: quota display mode, token units, auto refresh, health thresholds, and pricing presets.
-- Wake strategy: keep awake, advanced anti-sleep behavior, and startup launch.
-- Model parameters: default model, review model, reasoning effort, and service tier.
-
-## Install
-
-Choose the package that matches your Windows device:
-
-- `WinCodexBar-0.1.1-win-x64.zip`: most 64-bit Intel / AMD Windows devices.
-- `WinCodexBar-0.1.1-win-x86.zip`: older 32-bit Windows devices.
-- `WinCodexBar-0.1.1-win-arm64.zip`: Windows on ARM devices.
-
-Extract the archive and run `WinCodexBar.exe`. After launch, the app appears in the system tray.
-
-## Build From Source
-
-Requires the .NET 8 SDK.
-
-```powershell
-dotnet restore windows\CodexBarWin\CodexBarWin.csproj
-dotnet build windows\CodexBarWin\CodexBarWin.csproj -c Release
-```
-
-Publish a self-contained single-file build:
+Build using .NET 8 SDK:
```powershell
-dotnet publish windows\CodexBarWin\CodexBarWin.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true
+dotnet build windows/CodexBarWin/CodexBarWin.csproj -c Release
+dotnet run --project tests/WinCodexBar.CoreTests -c Release
+dotnet run --project tests/WinCodexBar.WindowsTests -c Release
```
-## Data And Privacy
-
-- Account data is stored locally by default.
-- The app does not display access tokens, refresh tokens, or ID tokens in logs, UI text, or summaries.
-- Exported account files are sensitive. Keep them private and do not upload them publicly.
-- Token activity and session analysis are based on local Codex session files and are used only for local display and estimation.
-
-## Notes
-
-- Switching accounts does not delete project files or local session records, but already-running Codex instances may still use the old account. Restart Codex or open a new instance to ensure the new account is used.
-- Aggregate mode requires Codex to use the local gateway endpoint. Codex instances opened before the mode switch usually do not join automatically.
-- Cost statistics are local estimates based on token counts and price presets. They are not official billing data.
-- Advanced Keep Awake simulates tiny mouse movement. Enable it only when it fits your workflow.
-- If Windows security software blocks the single-file executable, verify the file source before allowing it.
-
-## Version
-
-Current version: `0.1.1`
-
-See [CHANGELOG.md](./CHANGELOG.md) for release notes.
+Window tests require Windows and use isolated temporary data. Mixed-monitor hot-plug behavior and hardware-specific rendering still need device acceptance testing.
-## License
+[MIT License](./LICENSE) · [Third-party notices](./THIRD_PARTY_NOTICES.md)
-WinCodexBar is licensed under the MIT License. See [LICENSE](./LICENSE).
+Account quota windows are shown only when supplied by the service. Weekly-only accounts have no five-hour placeholder, and the tray indicator uses weekly usage. Reset-credit counts and individual expiration times are read-only; account details show exact reset dates, seconds, and local UTC offsets. There is no redemption action or endpoint.
diff --git a/README.md b/README.md
index cbad0cb..cffa96e 100644
--- a/README.md
+++ b/README.md
@@ -1,176 +1,78 @@
# WinCodexBar
-[English](./README.en.md)
+[English](./README.en.md) · [下载最新版](https://github.com/windycn/WinCodexBar/releases/latest) · [更新记录](./CHANGELOG.md)
-> 面向 Windows 的 Codex 托盘工作台:管理多个 OpenAI OAuth 账号,查看 5 小时 / 7 天额度,分析本地 Token 活动,并通过聚合模式让多账号使用更连续。
->
-> English: A Windows tray workspace for Codex multi-account management, quota visibility, local token activity, and aggregate account routing.
+Windows 上的 Codex 托盘工作台:管理多账号、查看额度、切换模型、分析本地用量。
-## 项目简介 / About
+## 0.2.0 有什么变化
-WinCodexBar 是一个专为 Windows Codex 用户准备的托盘工具。它把多账号管理、额度监控、Token 活动、会话分析、导入导出、保持唤醒和聚合路由放在一个轻量界面里,减少反复切换账号、修改配置和检查用量带来的中断。
+- **屏幕适配**:默认跟随当前显示器 DPI;也可选择 100%–300% 手动缩放。窗口限制在屏幕工作区内,窄屏设置卡片上下排列,超出内容可滚动。
+- **字体与图标**:随包提供中文无衬线字体与 Microsoft Fluent UI System Icons,减少系统字体缺失引起的回退和字形差异。窄屏导航自动收起为分类下拉框,设置页无横向滚动。
+- 重置卡只读展示剩余次数、各张到期时间;账号详情显示精确额度重置日期、时间与时区。不提供使用或兑换重置卡的功能。
+- 额度按账号实际提供的窗口显示;没有 5 小时额度时只显示 7 天额度,托盘图标同步使用 7 天数据。缺失数据与真实 0% 分开处理。
+- **托盘样式**:额度圆环、剩余百分比、经典应用图标。单击立即打开快捷面板,双击打开工作台;Esc 收起面板。
+- **交互修复**:删除确认不再被外部点击检测误关;滚动后的隐藏账号区域不再响应点击;打开设置、查看统计与关闭面板按顺序执行。
+- **更新更方便**:启动后和每 6 小时自动检查正式版,也可手动检查。用户确认后下载对应架构包、校验 SHA256、备份数据、覆盖程序并重启。覆盖失败尝试回滚,保留日志与旧程序备份。
+- **模型**:内置 GPT-6 Astra / Sol / Luna,并合并本机 Codex 模型缓存中的模型、推理强度与服务档位。支持手填模型 ID,升级保留已有选择。
+- **重置窗口**:读取 CodexRadar 公开摘要中的当前窗口状态,旧预测不会盖过已开启的窗口。显示来源,点击可查看网站;这不是个人账号已重置的证明。
-English: WinCodexBar is a Windows tray app for Codex users. It brings multi-account management, quota tracking, token activity, session analysis, import/export, keep-awake controls, and aggregate account routing into one lightweight desktop workflow.
+## 下载与运行
-## 核心价值
+在 [Releases](https://github.com/windycn/WinCodexBar/releases/latest) 下载与你的系统匹配的 ZIP:
-### 聚合模式让账号切换更连续
+| 安装包 | 适用设备 |
+| --- | --- |
+| `WinCodexBar-0.2.0-win-x64.zip` | Intel / AMD 64 位 Windows |
+| `WinCodexBar-0.2.0-win-arm64.zip` | Windows on ARM |
+| `WinCodexBar-0.2.0-win-x86.zip` | 32 位 Windows |
-聚合模式会启动一个本地账号网关,把多个 OpenAI OAuth 账号作为账号池管理。新的 Codex 实例接入本地网关后,账号选择和请求路由交给 WinCodexBar 处理,不需要你频繁手动修改每个项目里的账号配置。
+解压到独立、可写的文件夹,运行 `WinCodexBar.exe`。发布包自带 .NET 运行时。不要直接在 ZIP 内运行;自动更新需要对程序目录有写入权限。
-这对多项目和长会话尤其有用:你可以在一个地方查看所有账号额度,选择更健康的账号继续工作。已经接入聚合网关的新 Codex 实例可以跟随路由策略使用账号;如果某个 Codex 实例是在开启聚合模式之前启动的,通常需要重启 Codex 或新开实例后才会接入本地网关。
+首次从 0.1.x 升级:先退出旧程序,再解压覆盖或运行新目录中的程序。旧版没有自动更新入口,升级到 0.2.0 后即可使用。程序只允许一个实例运行。
-### 切换账号不等于丢失项目会话
+## 日常使用
-WinCodexBar 切换的是 OAuth 身份和请求路由,不会主动清空你的项目目录、本地会话记录或 Codex 的项目上下文文件。你可以把账号理解为“请求身份”,把项目会话理解为“本地工作记忆”:WinCodexBar 改前者,不会删除后者。
+- **托盘**:查看当前账号的 5 小时 / 7 天额度、重置时间,刷新或切换账号。
+- **工作台**:添加、导入、导出、切换、删除账号;查看 Token 活动和本地会话分析。
+- **设置 → 外观与缩放**:自动 / 手动缩放、托盘样式,保存后生效。
+- **设置 → 唤醒与更新**:保持唤醒、高级防休眠、开机启动、自动检查与手动检查更新。
+- **设置 → 模型参数**:默认模型、Review 模型、推理强度和服务等级。模型实际可用性由账号与 Codex 版本决定。
+- **设置 → 账号设置**:手动模式 / 聚合模式。手动切换同步登录配置;已运行的 Codex 通常需要重启或新开实例。聚合模式把后续请求交给本地账号网关,正在运行的响应流不被中途切换。
-实际使用时,如果你在手动模式下切换账号,已运行的 Codex 实例通常需要重启或新开一次,才能使用新的账号配置;聚合模式则把后续账号路由集中到本地网关里处理。
+关闭工作台会收回托盘;彻底退出请使用托盘菜单的“退出”。高级防休眠会模拟轻微鼠标移动,可单独关闭。
-### 额度和用量一眼看清
+## 更新、数据与备份
-托盘图标用圆环显示近 5 小时额度状态;菜单和工作台展示 5 小时 / 7 天额度、健康状态、预计重置时间,以及今日、本周、本月和累计 Token 活动。你不用等到请求失败才发现账号已经接近额度耗尽。
+账号与设置保存在 `%USERPROFILE%\.codexbar`,不在程序目录;设置 `CODEXBAR_HOME` 时使用该目录下的 `.codexbar`。升级保留旧模型、价格和外观设置,补充缺失的新字段。
-## 界面预览
+- 每个新版本首次启动,在 `.codexbar\backups\before-版本-时间` 备份现有账号与设置。
+- 自动覆盖更新前,另行备份到 `.codexbar\backups\before-update-时间`。
+- 更新包及程序回滚备份保存在 `%LOCALAPPDATA%\WinCodexBar\updates\随机目录`,内有 `result.txt` 和 `previous-program`。
+- 覆盖失败会尝试恢复被修改文件;若目录权限或文件占用阻止恢复,可退出应用后从 `previous-program` 手动还原。自动回滚针对文件替换失败,不等同于检测新版本的全部运行问题。
+- 恢复账号或设置时,先退出程序,再将备份中的 JSON 放回 `.codexbar`。备份和导出文件含登录凭证,不要公开分享。
+- 更新与删除账号不会删除 `.codex\sessions` 或 `.codex\archived_sessions`。删除账号仅从 WinCodexBar 账号池移除,不代表撤销远端登录或清除其他客户端的登录状态。
-### 托盘菜单
+自动检查只读取本仓库公开 Release 元数据,不上传账号或会话。发现新版本会通知,**不会未经确认自动安装**。网络不可用时继续使用当前版本。
-轻量弹出菜单用于快速查看当前账号、额度、订阅类型和账号池状态,也可以直接切换模式、添加账号、打开工作台或进入设置。
+## 统计与信息来源
-
-
-
+Token 活动来自本地 Codex 会话文件;金额是按所选价格预设计算的估值,不是账单,也不能反推订阅额度。GPT-6 内置价格采用标准短上下文价格,不包含 Fast、长上下文、缓存写入等附加规则。
-### 工作台
+模型资料:[OpenAI 模型目录](https://developers.openai.com/api/docs/models)。重置窗口数据来自 [Codex 雷达 codexradar.com](https://codexradar.com/) 的公开摘要,三分钟缓存;完整 API 需站方授权,本程序不请求受保护的完整接口。
-工作台提供账号列表、Token 活动、会话分析和成本估算。柱状图和热力图用于快速观察今日、本周、本月和累计 Token 使用情况。
+## 构建与验证
-
-
-
-
-### 设置页
-
-设置页集中配置账号模式、用量显示、唤醒策略和模型参数。唤醒策略支持系统保持唤醒和高级防休眠。
-
-
-
-
-
-## 解决什么问题
-
-当你同时使用多个 OpenAI 账号时,常见问题通常是:
-
-- 不知道当前账号的 5 小时 / 7 天额度还剩多少。
-- 手动改配置容易出错,也不方便回到上一个账号。
-- 多项目同时使用 Codex 时,账号切换会打断工作流。
-- 多账号导入、备份和迁移不够顺手。
-- 长时间编码时 Windows 休眠或锁屏会打断节奏。
-- 本地会话 Token 用量、今日/本周/本月趋势不够直观。
-
-WinCodexBar 把这些能力集中在托盘菜单、工作台和设置页里,适合长时间使用 Codex 的 Windows 用户。
-
-## 主要功能
-
-### 托盘菜单
-
-- 单击托盘图标打开快捷菜单。
-- 双击可打开工作台。
-- 托盘图标使用近 5 小时额度圆环显示当前账号状态。
-- 鼠标悬停托盘图标可查看当前 5 小时 / 7 天额度。
-- 点击菜单外部自动收起。
-
-### 多账号管理
-
-- 添加 OpenAI OAuth 账号。
-- 支持浏览器授权回调自动捕获。
-- 自动捕获失败时,可手动粘贴浏览器地址栏中的回调 URL。
-- 导入 / 导出多账号文件,方便备份和迁移。
-- 删除账号前会进行二次确认。
-- 当前账号切换后会同步到 Codex 配置。
-
-### 手动模式与聚合模式
-
-- 手动模式:点击账号后写入当前账号配置,已运行的 Codex 实例通常需要重启后才会使用新账号。
-- 聚合模式:启动本地账号网关,让新的 Codex 实例通过本地地址进行账号路由。
-- 聚合模式适合多项目、多账号和长会话场景,可以减少反复编辑账号配置带来的中断。
-- 如果 Codex 是开启聚合模式之前已经打开的实例,通常需要重启 Codex 或新开实例后才会接入聚合路由。
-
-### 额度显示
-
-- 展示每个账号的订阅类型、健康状态、5 小时额度和 7 天额度。
-- 可在设置中选择显示“已用额度”或“剩余额度”。
-- 可在设置中选择 Token 数字单位:中文单位或 K/M/B。
-- 剩余额度低于 30% 显示橙色,低于 10% 显示红色。
-- 可查看预计重置时间,包含倒计时和具体日期时间。
-
-### 工作台
-
-- 汇总账号数量、当前账号、额度状态和健康状态。
-- 显示全部账号列表,支持刷新、切换和查看状态。
-- Token 活动页展示今日、本周、本月柱状图和累计热力图。
-- 会话分析页展示本地会话数量、活跃/归档情况、Token 最高会话和最近会话。
-- Token 成本可按模型价格预设估算美元和人民币金额。
-
-### 防休眠
-
-- 保持唤醒:调用 Windows 系统能力阻止休眠和息屏。
-- 高级防休眠:空闲后模拟轻微鼠标移动,可配置空闲阈值、触发间隔、抖动时间和移动策略。
-- 可设置全屏时暂停高级防休眠。
-- 支持随 Windows 开机启动。
-
-### 设置
-
-- 账号设置:切换手动 / 聚合模式。
-- 用量设置:切换额度显示方式、Token 单位、自动刷新间隔、健康阈值和价格预设。
-- 唤醒策略:配置保持唤醒、高级防休眠和开机启动。
-- 模型参数:配置默认模型、Review 模型、reasoning effort 和 service tier。
-
-## 安装与运行
-
-从发布包中选择与你的系统架构匹配的版本:
-
-- `WinCodexBar-0.1.1-win-x64.zip`:大多数 Intel / AMD 64 位 Windows 设备。
-- `WinCodexBar-0.1.1-win-x86.zip`:旧的 32 位 Windows 设备。
-- `WinCodexBar-0.1.1-win-arm64.zip`:Windows on ARM 设备。
-
-解压后运行 `WinCodexBar.exe`。首次运行后会出现在系统托盘区域。
-
-## 本地构建
-
-需要 .NET 8 SDK。
+需要 .NET 8 SDK。Windows 可运行完整窗口回归;其他系统可交叉编译 Windows 程序并运行核心回归。
```powershell
-dotnet restore windows\CodexBarWin\CodexBarWin.csproj
-dotnet build windows\CodexBarWin\CodexBarWin.csproj -c Release
+dotnet build windows/CodexBarWin/CodexBarWin.csproj -c Release
+dotnet run --project tests/WinCodexBar.CoreTests -c Release
+dotnet run --project tests/WinCodexBar.WindowsTests -c Release
+dotnet publish windows/CodexBarWin/CodexBarWin.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true
```
-发布单文件执行包示例:
-
-```powershell
-dotnet publish windows\CodexBarWin\CodexBarWin.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true
-```
-
-## 数据与隐私
-
-- 账号数据默认保存在本机配置目录。
-- 应用不会在日志、界面或导出说明中显示 access token、refresh token 或 id token。
-- 导出的账号文件属于敏感数据,请妥善保存,不要公开上传。
-- Token 活动和会话分析来自本地 Codex 会话文件,仅用于本机展示和估算。
-
-## 注意事项
-
-- 切换账号不会删除项目文件或本地会话记录,但已运行的 Codex 实例可能仍使用旧账号;需要重启 Codex 或新开实例才能确保使用新账号。
-- 聚合模式需要 Codex 使用本地网关地址;切换前已经打开的 Codex 实例通常不会自动接入。
-- 金额统计是基于本地 Token 和价格预设的估算,不等于官方账单。
-- 高级防休眠会模拟轻微鼠标移动,请根据自己的使用场景谨慎开启。
-- 如果 Windows 安全软件拦截单文件执行包,请确认文件来源后再放行。
-
-## 版本
-
-当前版本:`0.1.1`
-
-更新内容见 [CHANGELOG.md](./CHANGELOG.md)。
+Windows 回归覆盖设置卡片、手动缩放、窗口开关、删除确认和真实 PowerShell 更新脚本。测试使用临时数据目录与虚构账号,不连接真实账号。多台实体显示器之间的热插拔、不同显卡与 Windows 主题仍建议在实际设备上验收。
## License
-本项目使用 MIT License。详见 [LICENSE](./LICENSE)。
+[MIT](./LICENSE) · [第三方组件声明](./THIRD_PARTY_NOTICES.md)
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index b6c8285..e096001 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -1,5 +1,11 @@
-# Third-Party Notices
+# 第三方组件声明
-WinCodexBar 使用 .NET、Windows Forms 与 Windows 平台能力构建。
+WinCodexBar 的发布包包含以下组件与资源:
-本文件用于记录后续新增依赖时需要保留的许可证信息。当前 Windows 发布包不额外捆绑需要单独列明的第三方二进制组件。
+- **.NET / Windows Forms / Microsoft.Data.Sqlite / System.Memory**:Microsoft 与 .NET contributors,MIT License。自包含 Windows 包包含 .NET 运行时。https://github.com/dotnet/runtime
+- **SQLitePCLRaw**:Eric Sink 与 contributors,Apache-2.0 License。https://github.com/ericsink/SQLitePCL.raw
+- **SQLite**:Public Domain。https://sqlite.org/copyright.html
+- **Microsoft Fluent UI System Icons**:Copyright (c) Microsoft Corporation,MIT License。图标字体随包嵌入,不依赖系统图标字体。来源:https://github.com/microsoft/fluentui-system-icons 。完整许可见 `windows/CodexBarWin/Assets/Fonts/FluentSystemIcons-LICENSE.txt`(发布包 `licenses/FluentSystemIcons-LICENSE.txt`)。
+- **Noto Sans SC**:The Noto Project Authors,SIL Open Font License 1.1。来源:https://github.com/google/fonts/tree/main/ofl/notosanssc 。本项目将字体实例化为固定字重并保留界面使用的字符范围;衍生字体家族更名为 WinCodexBar Sans。完整许可见 `windows/CodexBarWin/Assets/Fonts/NotoSansSC-OFL.txt`(发布包 `licenses/NotoSansSC-OFL.txt`)。
+
+CodexRadar 为外部信息来源,界面保留其来源标注与链接。其服务并非本程序的一部分。
diff --git a/tests/WinCodexBar.CoreTests/Program.cs b/tests/WinCodexBar.CoreTests/Program.cs
new file mode 100644
index 0000000..a26e135
--- /dev/null
+++ b/tests/WinCodexBar.CoreTests/Program.cs
@@ -0,0 +1,125 @@
+using CodexBarWin.Models;
+using CodexBarWin.Services;
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Text.Json;
+
+var scratch = Path.Combine(Path.GetTempPath(), "wincodexbar-test-" + Guid.NewGuid().ToString("N"));
+Environment.SetEnvironmentVariable("CODEXBAR_HOME", scratch);
+Directory.CreateDirectory(scratch);
+var passed = 0;
+void Check(bool result, string name) { if (!result) throw new Exception(name); Console.WriteLine("PASS " + name); passed++; }
+void Reject(Action action, string name) { try { action(); } catch (InvalidDataException) { Check(true, name); return; } throw new Exception("Expected rejection: " + name); }
+try
+{
+ var settings = new CodexBarConfigStore();
+ settings.Update(c => { c.KeepAwakeEnabled = false; c.AdvancedKeepAwakeEnabled = false; c.StartWithWindows = false; c.AutoCheckUpdates = false; c.UiScalePercent = 200; c.TrayIconStyle = "percent"; c.Global.DefaultModel = "custom-model"; });
+ AppDataMigration.EnsureVersionBackup();
+ AppDataMigration.EnsureVersionBackup();
+ Check(Directory.GetDirectories(Path.Combine(CodexPaths.CodexBarRoot,"backups")).Length == 1, "upgrade backup runs once per version");
+ settings.Load();
+ Check(!settings.Config.KeepAwakeEnabled && !settings.Config.AdvancedKeepAwakeEnabled && !settings.Config.StartWithWindows && !settings.Config.AutoCheckUpdates, "false settings survive restart");
+ Check(settings.Config.UiScalePercent == 200 && settings.Config.TrayIconStyle == "percent", "appearance survives restart");
+ Check(settings.Config.Global.DefaultModel == "custom-model", "upgrade preserves custom model");
+ Check(settings.Config.OpenAI.TokenPricePresets.ContainsKey("gpt-6-astra") && settings.Config.OpenAI.TokenPricePresets["gpt-6-sol"].OutputUsdPerMillion == 10, "GPT-6 presets merged");
+ var clone = settings.Config.Clone(); clone.OpenAI.TokenPricePresets["gpt-6-sol"].OutputUsdPerMillion = 1;
+ Check(settings.Config.OpenAI.TokenPricePresets["gpt-6-sol"].OutputUsdPerMillion == 10, "draft pricing isolated");
+ File.WriteAllText(CodexPaths.WindowsSettingsPath, "{\"KeepAwakeEnabled\":false,\"openai\":{\"auto_refresh_interval_seconds\":2147483647},\"ui_scale_percent\":-5}");
+ settings.Load();
+ Check(!settings.Config.KeepAwakeEnabled && settings.Config.OpenAI.AutoRefreshIntervalSeconds == 86400 && settings.Config.UiScalePercent == 0, "legacy settings and invalid ranges");
+ File.WriteAllText(Path.Combine(CodexPaths.CodexRoot,"models_cache.json"), "{\"models\":[{\"slug\":\"gpt-future\",\"supported_reasoning_levels\":[{\"effort\":\"ultra\"}],\"service_tiers\":[{\"id\":\"priority\"}]}]}");
+ Check(CodexModelCatalog.Models().Contains("gpt-future") && CodexModelCatalog.Efforts("gpt-future").Contains("ultra"), "local model catalog extends choices");
+ Check(CodexModelCatalog.ConfigTier("gpt-future","standard") is null && CodexModelCatalog.ConfigTier("gpt-future","priority") == "fast", "service tier writes supported values only");
+ var toml = "# settings\n[profiles.test]\nmodel = \"profile-model\"\n";
+ var edited = TomlRootEditor.Set(toml,"model","\"gpt-6-sol\"");
+ Check(edited.IndexOf("gpt-6-sol", StringComparison.Ordinal) < edited.IndexOf("[profiles", StringComparison.Ordinal) && edited.Contains("profile-model"), "model inserted at root preserves profiles");
+ var removed = TomlRootEditor.Set("service_tier = \"fast\"\n[profiles.test]\nservice_tier = \"priority\"\n","service_tier",null);
+ Check(!removed.Contains("fast") && removed.Contains("priority"), "remove root tier preserves profile tier");
+ var multiline = "instructions = \"\"\"\n[example]\nmodel = example\n\"\"\"\n[profiles.test]\n";
+ Check(TomlRootEditor.Set(multiline,"model","\"gpt-6-sol\"").StartsWith(multiline[..multiline.IndexOf("[profiles",StringComparison.Ordinal)]), "multiline instructions remain unchanged");
+ var weekly = new TokenAccount { PrimaryUsedPercent = 95, PrimaryWindowAvailable = true };
+ using var weeklyJson = JsonDocument.Parse("""{"rate_limit":{"primary_window":null,"secondary_window":{"used_percent":0,"limit_window_seconds":604800}}}""");
+ OpenAIUsageService.ApplyUsagePayload(weekly, weeklyJson.RootElement);
+ Check(AccountUsageHelpers.Windows(weekly).Count == 1 && AccountUsageHelpers.Windows(weekly)[0].Label == "7d", "missing five-hour window hidden and stale data cleared");
+ Check(AccountUsageHelpers.UsageText(weekly, UsageDisplayMode.Used) == "7d 0.0%", "real zero usage remains visible");
+ Check(AccountUsageHelpers.ResetText(weekly).Contains(AccountUsageHelpers.ExactTime(weekly.PrimaryResetAt)), "reset summary uses calendar date");
+ Check(!AccountUsageHelpers.ResetText(weekly).Contains("5h"), "missing five-hour reset hidden");
+ using var primaryWeekly = JsonDocument.Parse("""{"rate_limits":{"primary":{"used_percent":42,"window_minutes":10080},"secondary":null}}""");
+ OpenAIUsageService.ApplyUsagePayload(weekly, primaryWeekly.RootElement);
+ Check(AccountUsageHelpers.UsageText(weekly, UsageDisplayMode.Used) == "7d 42.0%", "weekly primary bucket labeled by window duration");
+ var both = new TokenAccount { PrimaryWindowAvailable = true, SecondaryWindowAvailable = true, PrimaryUsedPercent = 10, SecondaryUsedPercent = 20 };
+ Check(AccountUsageHelpers.AverageText(new[] { weekly, both }, UsageDisplayMode.Used) == "5h 10.0% · 7d 31.0%", "averages exclude missing windows");
+ Check(AccountUsageHelpers.Windows(new TokenAccount()).Count == 0, "unknown quota is not zero usage");
+ Check(AccountUsageHelpers.Windows(JsonSerializer.Deserialize(JsonSerializer.Serialize(weekly))!).Single().Label == "7d", "window availability survives restart");
+ using var cards = JsonDocument.Parse("""{"available_count":3,"credits":[{"status":"available","expires_at":"2026-10-04T08:00:00Z"},{"status":"redeemed","expires_at":"2026-10-03T08:00:00Z"},{"status":"available","expires_at":null}]}""");
+ Check(OpenAIUsageService.ApplyResetCreditPayload(weekly, cards.RootElement), "read-only reset credit data parsed");
+ Check(weekly.ResetCreditsAvailable == 3 && weekly.ResetCreditDetails!.Count == 2, "credit count independent from capped detail list");
+ Check(weekly.ResetCreditDetails![0].ExpiresAt == DateTimeOffset.Parse("2026-10-04T08:00:00Z") && weekly.ResetCreditDetails[1].ExpirationKnown, "expiry instant and explicit unlimited expiry preserved");
+ var detailsText = AccountUsageHelpers.DetailsText(weekly, UsageDisplayMode.Used);
+ Check(detailsText.Contains("其余重置卡") && detailsText.Contains("无到期限制") && detailsText.Contains("2026-10-04"), "details show precise expiry and missing detail notice");
+ using var onlyCount = JsonDocument.Parse("""{"available_count":4}""");
+ OpenAIUsageService.ApplyResetCreditPayload(weekly, onlyCount.RootElement, details:false);
+ Check(weekly.ResetCreditsAvailable == 4 && weekly.ResetCreditDetailsStale, "changed count marks old expiry details as cached");
+ using var noCards = JsonDocument.Parse("""{"available_count":0,"credits":[]}""");
+ OpenAIUsageService.ApplyResetCreditPayload(weekly,noCards.RootElement);
+ Check(weekly.ResetCreditsAvailable == 0 && weekly.ResetCreditDetails!.Count == 0 && !weekly.ResetCreditDetailsStale, "zero credits clear stale expirations");
+ Check(AccountUsageHelpers.ExactTime(DateTimeOffset.Parse("2026-10-04T08:01:02Z")).Contains(":02 "), "exact times include seconds and UTC offset");
+ using var handler = new UsageFixtureHandler();
+ var reader = new OpenAIUsageService(new HttpClient(handler));
+ var fixtureAccount = new TokenAccount { AccessToken = "fixture-token", AccountId = "fixture-account" };
+ Check(await reader.RefreshUsageAsync(fixtureAccount) == UsageRefreshOutcome.Updated && fixtureAccount.ResetCreditsAvailable == 2, "usage refresh reads reset credit endpoint");
+ Check(handler.Requests.Any(r => r.EndsWith("/rate-limit-reset-credits")) && handler.OnlyGets, "reset credit integration sends GET requests only");
+ var registry = new AccountRegistry();
+ registry.UpsertAccount(new TokenAccount { AccountId = "fake-a", Email = "a@example.test" }, true);
+ registry.UpsertAccount(new TokenAccount { AccountId = "fake-b", Email = "b@example.test" }, false);
+ Check(registry.RemoveAccount("fake-a") && registry.ActiveAccountId == "fake-b", "delete active chooses remaining account");
+ registry.Save(); registry.Load(); Check(registry.Accounts.Count == 1 && registry.Accounts[0].AccountId == "fake-b", "deleted account stays deleted after reload");
+ Check(registry.RemoveAccount("fake-b") && registry.ActiveAccountId is null, "delete last account clears selection");
+ Check(!registry.RemoveAccount("fake-b"), "repeat delete harmless");
+ using var radar = JsonDocument.Parse("{\"window_open\":true,\"window\":{\"opened_at\":\"2026-09-26T08:07:13+08:00\"},\"prediction\":{\"level\":\"low\",\"updated_at\":\"2026-07-13T22:08:06+08:00\"}}");
+ var prediction = CodexRadarService.Parse(radar.RootElement);
+ Check(prediction.WindowOpen == true && prediction.DisplayText.Contains("速蹬窗口开启") && !prediction.DisplayText.Contains("低"), "current window takes precedence over stale probability");
+ using var closed = JsonDocument.Parse("{\"window_open\":false,\"prediction\":null}");
+ Check(CodexRadarService.Parse(closed.RootElement).DisplayText.Contains("未开启"), "explicit closed window supported without prediction");
+ Check(CodexRadarPrediction.Loading.DisplayText.Contains("获取中") && CodexRadarPrediction.Unavailable.DisplayText.Contains("暂不可用"), "radar state remains visible without data");
+ using var empty = JsonDocument.Parse("{}"); Check(!CodexRadarService.Parse(empty.RootElement).IsAvailable, "missing radar data unavailable");
+ string Release(string tag, string architecture = "x64", bool preview = false) => JsonSerializer.Serialize(new {
+ tag_name = tag, draft = false, prerelease = preview,
+ assets = new[] { new { name = $"WinCodexBar-{tag.TrimStart('v')}-win-{architecture}.zip", browser_download_url = $"https://github.com/windycn/WinCodexBar/releases/download/{tag}/WinCodexBar-{tag.TrimStart('v')}-win-{architecture}.zip" },
+ new { name = "SHA256SUMS.txt", browser_download_url = $"https://github.com/windycn/WinCodexBar/releases/download/{tag}/SHA256SUMS.txt" } }
+ });
+ foreach (var arch in new[] { "x86", "x64", "arm64" })
+ Check(AppUpdateService.ParseRelease(Release("v0.3.0", arch), new Version(0,2,0,0), arch)?.AssetName.EndsWith(arch + ".zip") == true, "matching update architecture " + arch);
+ Check(AppUpdateService.ParseRelease(Release("v0.2.0"), new Version(0,2,0,0), "x64") is null, "same version not updated");
+ Check(AppUpdateService.ParseRelease(Release("v0.3.0", preview:true), new Version(0,2,0), "x64") is null, "prerelease ignored");
+ Reject(() => AppUpdateService.ParseRelease(Release("v0.3.0").Replace("https://github.com/", "https://example.test/"), new Version(0,2,0), "x64"), "foreign download rejected");
+ Reject(() => AppUpdateService.ParseRelease(Release("v0.3.0"), new Version(0,2,0), "arm64"), "missing architecture rejected");
+ var zip = Path.Combine(scratch, "test.zip");
+ using (var archive = ZipFile.Open(zip, ZipArchiveMode.Create)) { using var writer = new StreamWriter(archive.CreateEntry("WinCodexBar.exe").Open()); writer.Write("fake binary"); }
+ var hash = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(zip)));
+ AppUpdateService.VerifyChecksum(zip, "test.zip", hash + " test.zip"); Check(true, "matching checksum accepted");
+ Reject(() => AppUpdateService.VerifyChecksum(zip, "test.zip", new string('0',64) + " test.zip"), "corrupt download rejected");
+ AppUpdateService.ExtractPackage(zip, Path.Combine(scratch,"valid")); Check(File.Exists(Path.Combine(scratch,"valid","WinCodexBar.exe")), "valid update package extracted");
+ var bad = Path.Combine(scratch,"bad.zip"); using (var archive = ZipFile.Open(bad, ZipArchiveMode.Create)) archive.CreateEntry("../escape");
+ Reject(() => AppUpdateService.ExtractPackage(bad, Path.Combine(scratch,"bad")), "zip traversal rejected");
+ var script = AppUpdateService.BuildInstallScript("C:\\O'Brien\\package", "C:\\app", "C:\\data", 1234);
+ Check(script.Contains("O''Brien") && script.Contains("previous-program") && script.Contains("before-update-"), "installer escapes paths and includes backups");
+ Console.WriteLine($"{passed} checks passed");
+}
+finally { Directory.Delete(scratch, true); }
+
+internal sealed class UsageFixtureHandler : HttpMessageHandler
+{
+ public List Requests { get; } = new();
+ public bool OnlyGets { get; private set; } = true;
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ OnlyGets &= request.Method == HttpMethod.Get;
+ var path = request.RequestUri!.AbsolutePath; Requests.Add(path);
+ var json = path.EndsWith("/rate-limit-reset-credits")
+ ? """{"available_count":2,"credits":[{"status":"available","expires_at":"2026-10-04T08:00:00Z"}]}"""
+ : path.EndsWith("/usage") ? """{"rate_limit":{"primary_window":null,"secondary_window":{"used_percent":42,"limit_window_seconds":604800}},"rate_limit_reset_credits":{"available_count":2}}"""
+ : "{}";
+ return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(json) });
+ }
+}
diff --git a/tests/WinCodexBar.CoreTests/WinCodexBar.CoreTests.csproj b/tests/WinCodexBar.CoreTests/WinCodexBar.CoreTests.csproj
new file mode 100644
index 0000000..d419149
--- /dev/null
+++ b/tests/WinCodexBar.CoreTests/WinCodexBar.CoreTests.csproj
@@ -0,0 +1,21 @@
+
+ Exe net8.0 enable enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/WinCodexBar.WindowsTests/Program.cs b/tests/WinCodexBar.WindowsTests/Program.cs
new file mode 100644
index 0000000..7f91c9e
--- /dev/null
+++ b/tests/WinCodexBar.WindowsTests/Program.cs
@@ -0,0 +1,212 @@
+using CodexBarWin.Models;
+using CodexBarWin.Services;
+using CodexBarWin.UI;
+using System.Collections;
+using System.Diagnostics;
+using System.Reflection;
+using System.Runtime.InteropServices;
+
+internal static class Program
+{
+ private static int _passed;
+ private static readonly BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic;
+ private static void Check(bool condition, string label) { if (!condition) throw new Exception(label); Console.WriteLine("PASS " + label); _passed++; }
+ private static object? Field(object target, string name) => target.GetType().GetField(name, Private)!.GetValue(target);
+ private static object? Call(object target, string name, params object[] args) => target.GetType().GetMethod(name, Private)!.Invoke(target, args);
+ private static IEnumerable Descendants(Control c) => c.Controls.Cast().SelectMany(child => new[] { child }.Concat(Descendants(child)));
+ private static void Paint(Form form, string? file = null)
+ {
+ Application.DoEvents();
+ using var bitmap = new Bitmap(form.Width, form.Height);
+ form.DrawToBitmap(bitmap, new Rectangle(Point.Empty, bitmap.Size));
+ if (file is not null) bitmap.Save(Path.Combine(AppContext.BaseDirectory, "screenshots", file));
+ }
+ [STAThread]
+ private static void Main()
+ {
+ Application.SetHighDpiMode(HighDpiMode.PerMonitorV2);
+ Application.EnableVisualStyles();
+ Application.SetCompatibleTextRenderingDefault(false);
+ var home = Path.Combine(Path.GetTempPath(), "WinCodexBar UI ' " + Guid.NewGuid().ToString("N"));
+ Environment.SetEnvironmentVariable("CODEXBAR_HOME", home);
+ Directory.CreateDirectory(home);
+ Directory.CreateDirectory(Path.Combine(AppContext.BaseDirectory,"screenshots"));
+ try
+ {
+ var store = new CodexBarConfigStore();
+ var wake = new KeepAwakeService(store); // 不 Load,不改变机器唤醒状态。
+ foreach (var scale in new[] {100,150,200,250})
+ {
+ AppAppearance.ScalePercent = scale;
+ using var settings = new SettingsForm(store, wake, () => {}, () => {});
+ settings.Show();
+ if (scale == 100) ProbeFont(settings.Font);
+ Console.WriteLine($"Text font: {settings.Font.Name}; family: {settings.Font.FontFamily.Name}; style: {settings.Font.Style}; size: {settings.Font.Size}");
+ foreach (var size in new[] {new Size(800,600),new Size(1366,768),new Size(1920,1080)})
+ {
+ settings.Size = size; settings.ApplyAppearance();
+ var pageType = typeof(SettingsForm).GetNestedType("Page", BindingFlags.NonPublic)!;
+ foreach (var page in Enum.GetValues(pageType))
+ {
+ Call(settings,"SelectPage",page!); Paint(settings,$"settings-{scale}-{page}.png");
+ Check(!((ScrollableControl)settings.Controls[0]).HorizontalScroll.Visible, $"settings has no horizontal overflow {scale}% {page}");
+ foreach (var button in Descendants(settings).OfType().Where(b => b.Text is "保存" or "取消" or "配置目录"))
+ {
+ var visible = settings.RectangleToScreen(settings.ClientRectangle).Contains(button.RectangleToScreen(button.ClientRectangle));
+ if (!visible)
+ {
+ Console.WriteLine($"FORM {settings.Bounds} client={settings.ClientRectangle} font={settings.Font.Size} BUTTON {button.Bounds}");
+ for (Control? ancestor = button.Parent; ancestor is not null; ancestor = ancestor.Parent)
+ {
+ Console.WriteLine($"ANCESTOR {ancestor.GetType().Name} bounds={ancestor.Bounds} min={ancestor.MinimumSize} font={ancestor.Font.Size}");
+ if (ancestor is TableLayoutPanel table) Console.WriteLine("COLUMNS " + string.Join(",", table.GetColumnWidths()) + " ROWS " + string.Join(",",table.GetRowHeights()) + " STYLES " + string.Join(",",table.ColumnStyles.Cast().Select(c => c.SizeType + "=" + c.Width)));
+ }
+ }
+ Check(visible, $"settings footer visible {scale}% {button.Text}");
+ }
+ Check(Screen.FromControl(settings).WorkingArea.Contains(settings.Bounds), $"settings fit {scale}% {size} {page}");
+ foreach (var card in Descendants(settings).OfType())
+ {
+ if (card.Action is not { } action) continue;
+ Check(card.ClientRectangle.Contains(action.Bounds), $"action within card {scale}% {page}");
+ foreach (var label in card.Controls.OfType().Where(label => !ReferenceEquals(label, action))) Check(!label.Bounds.IntersectsWith(action.Bounds), $"text does not cover action {scale}% {page}");
+ }
+ }
+ }
+ Paint(settings,$"settings-{scale}.png");
+ settings.Close();
+ }
+ AppAppearance.ScalePercent = 100;
+ var registry = new AccountRegistry();
+ for (var i=0;i<6;i++) registry.UpsertAccount(new TokenAccount { AccountId="fake-"+i, Email=$"person-{i}@example.test", PrimaryWindowAvailable=i!=0, SecondaryWindowAvailable=true, PrimaryUsedPercent=12, SecondaryUsedPercent=42, SecondaryLimitWindowSeconds=604800, SecondaryResetAt=DateTimeOffset.Parse("2026-10-04T08:01:02Z"), ResetCreditsAvailable=3, ResetCreditsCheckedAt=DateTimeOffset.UtcNow, ResetCreditDetails=new() { new() { ExpirationKnown=true, ExpiresAt=DateTimeOffset.Parse("2026-10-04T08:00:00Z") }, new() { ExpirationKnown=true, ExpiresAt=DateTimeOffset.Parse("2026-10-05T08:00:00Z") } } },i==0);
+ using (var dashboard = new CodexBarDashboardForm(registry,store,new UsageRefreshCoordinator(new OpenAIUsageService(),new OpenAIOAuthRefreshService(),registry),_=>{},()=>{},()=>{},()=>{},()=>{},()=>false,_=>{},_=>{}))
+ {
+ dashboard.Show(); dashboard.Size=new Size(960,680); dashboard.ApplyAppearance(); Paint(dashboard,"dashboard.png");
+ foreach(var button in Descendants(dashboard).OfType().Where(b=>b.Text is "设置" or "删除" or "导出账号"))
+ Check(button.Parent!.ClientRectangle.Contains(button.Bounds),"dashboard button reachable: "+button.Text);
+ dashboard.Close(); Check(!dashboard.Visible && !dashboard.IsDisposed,"dashboard close hides for reuse");
+ dashboard.Show(); Check(dashboard.Visible,"dashboard reopens");
+ }
+ foreach (var detailScale in new[] { 100, 200 })
+ {
+ AppAppearance.ScalePercent = detailScale;
+ using var details = new AccountDetailsForm(registry.Accounts[0], UsageDisplayMode.Used);
+ details.Show(); Paint(details, $"account-details-{detailScale}.png");
+ Check(Screen.FromControl(details).WorkingArea.Contains(details.Bounds), "account details fits " + detailScale);
+ foreach (var button in Descendants(details).OfType())
+ Check(details.RectangleToScreen(details.ClientRectangle).Contains(button.RectangleToScreen(button.ClientRectangle)), "details close button visible " + detailScale);
+ Check(Descendants(details).OfType().All(b => b.Text == "关闭"), "account details has no credit redemption action");
+ Check(Descendants(details).OfType().Single().Text.Contains("2026-10-04"), "account details displays full expiry date");
+ details.Close();
+ }
+ AppAppearance.ScalePercent = 100;
+ TestPopup(registry);
+ TestInstaller(home);
+ Console.WriteLine($"{_passed} Windows checks passed");
+ }
+ finally
+ {
+ for (var attempt=0; ; attempt++)
+ {
+ try { Directory.Delete(home,true); break; }
+ catch (Exception ex) when (attempt<20 && ex is IOException or UnauthorizedAccessException) { Thread.Sleep(250); }
+ }
+ }
+ }
+ private static void ProbeFont(Font font)
+ {
+ using var probe = new Bitmap(700, 220);
+ using var g = Graphics.FromImage(probe);
+ g.Clear(Color.White);
+ using var large = new Font(font.FontFamily, 28, FontStyle.Regular, GraphicsUnit.Pixel);
+ g.DrawString("账号设置 Codex 123 (GDI+)", large, Brushes.Black, 10, 10);
+ TextRenderer.DrawText(g, "账号设置 Codex 123 (GDI)", large, new Point(10, 65), Color.Black);
+ using var named = new Font(font.Name, 28, FontStyle.Regular, GraphicsUnit.Pixel);
+ g.DrawString("账号设置 Codex 123 (named GDI+)", named, Brushes.Black, 10, 120);
+ probe.Save(Path.Combine(AppContext.BaseDirectory, "screenshots", "font-probe.png"));
+ var dc = g.GetHdc(); var handle = large.ToHfont(); var old = SelectObject(dc, handle);
+ var name = new System.Text.StringBuilder(128); GetTextFace(dc, name.Capacity, name);
+ Check(name.ToString() == "WinCodexBar Sans", "Windows selects bundled sans font");
+ SelectObject(dc, old); DeleteObject(handle); g.ReleaseHdc(dc);
+ }
+ [DllImport("gdi32.dll")] private static extern IntPtr SelectObject(IntPtr dc, IntPtr obj);
+ [DllImport("gdi32.dll")] private static extern bool DeleteObject(IntPtr obj);
+ [DllImport("gdi32.dll", CharSet=CharSet.Unicode)] private static extern int GetTextFace(IntPtr dc, int count, System.Text.StringBuilder face);
+ private static void TestPopup(AccountRegistry registry)
+ {
+ var deleted=false;
+ TrayPopupForm? popup=null;
+ popup=new TrayPopupForm(registry.Accounts,registry.ActiveAccountId,()=>false,default,CodexRadarPrediction.Unavailable,new CodexBarConfig(),_=>{},()=>{},()=>{},()=>{},()=>{},()=>{},()=>{},_=>{},()=>{},_=>{},account=>
+ {
+ using var dialog=new ConfirmActionDialog("删除账号","只删除测试账号,不影响会话。","删除");
+ using var timer=new System.Windows.Forms.Timer {Interval=100};
+ timer.Tick+=(_,_)=>
+ {
+ timer.Stop();
+ var confirm=Descendants(dialog).OfType().Single(b=>b.Text=="删除");
+ var point=confirm.PointToScreen(new Point(confirm.Width/2,confirm.Height/2));
+ var data=new MouseHookData { X=point.X,Y=point.Y };
+ var pointer=Marshal.AllocHGlobal(Marshal.SizeOf());
+ try { Marshal.StructureToPtr(data,pointer,false); Call(popup!,"MouseHookCallback",0,new IntPtr(0x0201),pointer); Application.DoEvents(); }
+ finally {Marshal.FreeHGlobal(pointer);}
+ Check(!popup!.IsDisposed,"outside-click hook preserves owned confirmation");
+ confirm.PerformClick();
+ };
+ timer.Start();
+ deleted=dialog.ShowDialog(popup)==DialogResult.OK;
+ },()=>{});
+ using(popup)
+ {
+ var watch=Stopwatch.StartNew();
+ popup.ShowNearCursor(); watch.Stop(); Console.WriteLine($"Popup show time: {watch.ElapsedMilliseconds} ms");
+ Paint(popup,"popup-unavailable.png");
+ popup.UpdateRadarPrediction(CodexRadarPrediction.Unavailable with { IsAvailable = true, WindowOpen = false });
+ Paint(popup,"popup.png");
+ Check(Screen.FromControl(popup).WorkingArea.Contains(popup.Bounds),"popup fits work area");
+ var hits=((IEnumerable)Field(popup,"_hits")!).Cast().ToArray();
+ var hit=hits.First(h=>(string?)h.GetType().GetField("Tooltip")!.GetValue(h)=="删除此账号");
+ var rect=(RectangleF)hit.GetType().GetField("Bounds")!.GetValue(hit)!;
+ var point=new Point((int)(rect.Left+rect.Width/2),(int)(rect.Top+rect.Height/2));
+ Call(popup,"OnMouseDown",new MouseEventArgs(MouseButtons.Right,1,point.X,point.Y,0));
+ Call(popup,"OnMouseUp",new MouseEventArgs(MouseButtons.Right,1,point.X,point.Y,0));
+ Check(!deleted,"right click does not delete");
+ Call(popup,"OnMouseDown",new MouseEventArgs(MouseButtons.Left,1,point.X,point.Y,0));
+ Paint(popup);
+ Call(popup,"OnMouseUp",new MouseEventArgs(MouseButtons.Left,1,point.X,point.Y,0));
+ Check(deleted && !popup.IsDisposed,"delete confirmation accepts without closing parent");
+ popup.UpdateSnapshot(null,null,new CodexBarConfig(),Array.Empty()); Paint(popup);
+ Check(popup.Height<550,"popup resizes after last account removed");
+ popup.Close();
+ }
+ }
+ private static void TestInstaller(string home)
+ {
+ var source=Path.Combine(home,"package");var target=Path.Combine(home,"installed");var data=Path.Combine(home,"data");
+ Directory.CreateDirectory(source);Directory.CreateDirectory(target);Directory.CreateDirectory(data);
+ File.Copy(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System),"where.exe"),Path.Combine(source,"WinCodexBar.exe"));
+ File.WriteAllText(Path.Combine(target,"WinCodexBar.exe"),"old-program");
+ File.WriteAllText(Path.Combine(target,"unrelated.txt"),"preserve");
+ File.WriteAllText(Path.Combine(data,"windows_accounts.json"),"fake-account-data");
+ var script=Path.Combine(home,"install.ps1");
+ File.WriteAllText(script,AppUpdateService.BuildInstallScript(source,target,data,int.MaxValue).Replace("-WorkingDirectory $target", "-WorkingDirectory $target -Wait"),new System.Text.UTF8Encoding(true));
+ var start=new ProcessStartInfo("powershell.exe") {UseShellExecute=false};
+ foreach(var arg in new[]{"-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-File",script}) start.ArgumentList.Add(arg);
+ using var process=Process.Start(start)!;Check(process.WaitForExit(30000)&&process.ExitCode==0,"installer completes on Windows");
+ Check(File.ReadAllText(Path.Combine(home,"result.txt")).Contains("更新完成"),"installer reports success");
+ Check(File.ReadAllText(Path.Combine(home,"previous-program","WinCodexBar.exe"))=="old-program","old program backed up");
+ Check(File.ReadAllText(Path.Combine(target,"unrelated.txt"))=="preserve","unrelated installation files preserved");
+ Check(Directory.GetFiles(Path.Combine(data,"backups"),"windows_accounts.json",SearchOption.AllDirectories).Length==1,"account data backed up");
+ Check(File.ReadAllText(Path.Combine(data,"windows_accounts.json"))=="fake-account-data","account data preserved");
+ File.WriteAllText(Path.Combine(target,"WinCodexBar.exe"),"rollback-original");
+ var failingScript=AppUpdateService.BuildInstallScript(source,target,data,int.MaxValue)
+ .Replace("Copy-Item -LiteralPath $file.FullName -Destination $dest -Force", "Copy-Item -LiteralPath $file.FullName -Destination $dest -Force; throw 'injected replacement failure'")
+ .Replace("[System.Windows.Forms.MessageBox]::Show($message, 'WinCodexBar 更新') | Out-Null", "'failure recorded' | Out-Null");
+ File.WriteAllText(script,failingScript,new System.Text.UTF8Encoding(true));
+ using var failedProcess=Process.Start(start)!;
+ Check(failedProcess.WaitForExit(30000),"rollback script completes");
+ Check(File.ReadAllText(Path.Combine(home,"result.txt")).Contains("更新失败"),"replacement failure reported");
+ Check(File.ReadAllText(Path.Combine(target,"WinCodexBar.exe"))=="rollback-original","replacement failure restores old program");
+ Check(File.ReadAllText(Path.Combine(data,"windows_accounts.json"))=="fake-account-data","rollback preserves account data");
+ }
+ [StructLayout(LayoutKind.Sequential)] private struct MouseHookData { public int X,Y; public uint MouseData,Flags,Time; public IntPtr Extra; }
+}
diff --git a/tests/WinCodexBar.WindowsTests/WinCodexBar.WindowsTests.csproj b/tests/WinCodexBar.WindowsTests/WinCodexBar.WindowsTests.csproj
new file mode 100644
index 0000000..0f1eeb1
--- /dev/null
+++ b/tests/WinCodexBar.WindowsTests/WinCodexBar.WindowsTests.csproj
@@ -0,0 +1,4 @@
+
+ Exe net8.0-windows true true enable enable
+
+
diff --git a/tools/Package-Windows.ps1 b/tools/Package-Windows.ps1
new file mode 100644
index 0000000..5cf74c1
--- /dev/null
+++ b/tools/Package-Windows.ps1
@@ -0,0 +1,22 @@
+param([string]$Version = '0.2.0', [string]$PublishRoot = 'artifacts', [string]$OutputRoot = 'artifacts/packages')
+$ErrorActionPreference = 'Stop'
+New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null
+$checksums = @()
+foreach ($rid in @('win-x64', 'win-x86', 'win-arm64')) {
+ $source = Join-Path $PublishRoot $rid
+ if (!(Test-Path (Join-Path $source 'WinCodexBar.exe'))) { throw "缺少 $rid 可执行文件" }
+ foreach ($document in @('README.md', 'README.en.md', 'LICENSE', 'THIRD_PARTY_NOTICES.md', 'CHANGELOG.md')) {
+ Copy-Item -LiteralPath $document -Destination $source -Force
+ }
+ $licenses = Join-Path $source 'licenses'
+ New-Item -ItemType Directory -Path $licenses -Force | Out-Null
+ Copy-Item 'windows/CodexBarWin/Assets/Fonts/*.txt' $licenses -Force
+ # 符号仅用于调试,不放入用户安装包。
+ $files = Get-ChildItem -LiteralPath $source | Where-Object { $_.Extension -ne '.pdb' }
+ $name = "WinCodexBar-$Version-$rid.zip"
+ $package = Join-Path $OutputRoot $name
+ Compress-Archive -LiteralPath $files.FullName -DestinationPath $package -Force
+ $hash = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash.ToLowerInvariant()
+ $checksums += "$hash $name"
+}
+$checksums | Set-Content -LiteralPath (Join-Path $OutputRoot 'SHA256SUMS.txt') -Encoding ascii
diff --git a/tools/Release-Notes-0.2.0.md b/tools/Release-Notes-0.2.0.md
new file mode 100644
index 0000000..53371dc
--- /dev/null
+++ b/tools/Release-Notes-0.2.0.md
@@ -0,0 +1,20 @@
+## WinCodexBar 0.2.0
+
+本次重做设置布局,统一 Fluent 风格、内置中英文字体与 Microsoft Fluent UI System Icons,并修复按钮、删除确认和窗口开关冲突。
+
+- 屏幕适配:默认跟随显示器 DPI,可手动选择 100%–300%;窄窗口自动切换紧凑导航,设置卡片上下排列,操作区不再挤出窗口。
+- 重置卡只读展示剩余次数、各张到期时间;账号详情显示精确额度重置日期、时间与时区。不提供使用或兑换重置卡的功能。
+- 额度按账号实际提供的窗口显示;没有 5 小时额度时只显示 7 天额度,托盘图标同步使用 7 天数据。缺失数据与真实 0% 分开处理。
+- 托盘:可选额度圆环、剩余百分比、经典图标;单击立即展开,双击打开工作台。用量扫描移至后台并复用缓存,减少重复绘制和等待。
+- 更新:启动后和每 6 小时检查正式版;确认后下载匹配架构、验证 SHA256、备份数据、覆盖并重启。文件替换失败时尝试回滚旧程序。
+- 模型:加入 GPT-6 Astra / Sol / Luna,读取本机 Codex 模型缓存补充选择;保留旧模型与自定义价格,修复模型配置误写入 TOML profile 的问题。
+- 重置窗口:修正 CodexRadar 摘要解析,当前开启状态优先于过期预测;显示来源与缓存状态,获取中或不可用时保留提示。
+- 数据:升级和删除账号不删除 Codex 会话;新版首次启动和自动更新前备份账号、设置。
+
+### 安装与升级
+
+一般 Intel / AMD 电脑选择 `win-x64`;Windows on ARM 选择 `win-arm64`;32 位系统选择 `win-x86`。三个 ZIP 均自带 .NET 运行时。
+
+从 0.1.x 首次升级请先退出旧版,再解压新包到可写目录。原有 `%USERPROFILE%\.codexbar` 数据会保留。升级至本版后,可使用内置检查更新。请保留包中的许可证文件。
+
+`SHA256SUMS.txt` 提供三种架构包的校验值。Windows 自动回归已覆盖设置各页、手动缩放、删除确认、窗口复用,以及实际 PowerShell 覆盖和失败回滚。实体混合 DPI 显示器热插拔及不同显卡环境尚未实机核验。
diff --git a/windows/CodexBarWin/Assets/Fonts/FluentSystemIcons-LICENSE.txt b/windows/CodexBarWin/Assets/Fonts/FluentSystemIcons-LICENSE.txt
new file mode 100644
index 0000000..bc9c36b
--- /dev/null
+++ b/windows/CodexBarWin/Assets/Fonts/FluentSystemIcons-LICENSE.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 Microsoft Corporation
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/windows/CodexBarWin/Assets/Fonts/FluentSystemIcons-Regular.ttf b/windows/CodexBarWin/Assets/Fonts/FluentSystemIcons-Regular.ttf
new file mode 100644
index 0000000..233e507
Binary files /dev/null and b/windows/CodexBarWin/Assets/Fonts/FluentSystemIcons-Regular.ttf differ
diff --git a/windows/CodexBarWin/Assets/Fonts/NotoSansSC-Bold.ttf b/windows/CodexBarWin/Assets/Fonts/NotoSansSC-Bold.ttf
new file mode 100644
index 0000000..50b13e7
Binary files /dev/null and b/windows/CodexBarWin/Assets/Fonts/NotoSansSC-Bold.ttf differ
diff --git a/windows/CodexBarWin/Assets/Fonts/NotoSansSC-OFL.txt b/windows/CodexBarWin/Assets/Fonts/NotoSansSC-OFL.txt
new file mode 100644
index 0000000..1c9f432
--- /dev/null
+++ b/windows/CodexBarWin/Assets/Fonts/NotoSansSC-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/windows/CodexBarWin/Assets/Fonts/NotoSansSC-Regular.ttf b/windows/CodexBarWin/Assets/Fonts/NotoSansSC-Regular.ttf
new file mode 100644
index 0000000..d1f86ad
Binary files /dev/null and b/windows/CodexBarWin/Assets/Fonts/NotoSansSC-Regular.ttf differ
diff --git a/windows/CodexBarWin/CodexBarWin.csproj b/windows/CodexBarWin/CodexBarWin.csproj
index 20aa488..38203fc 100644
--- a/windows/CodexBarWin/CodexBarWin.csproj
+++ b/windows/CodexBarWin/CodexBarWin.csproj
@@ -4,19 +4,24 @@
WinExe
net8.0-windows
enable
+ true
enable
true
WinCodexBar
WinCodexBar
WinCodexBar
- 0.1.1
- 0.1.1.0
- 0.1.1.0
- 0.1.1
+ 0.2.0
+ 0.2.0.0
+ 0.2.0.0
+ 0.2.0
win-x86;win-x64;win-arm64
Assets\Icons\WinCodexBar.ico
+
+
+
+
diff --git a/windows/CodexBarWin/Models/AccountUsageHelpers.cs b/windows/CodexBarWin/Models/AccountUsageHelpers.cs
index b7c289b..0b3f5eb 100644
--- a/windows/CodexBarWin/Models/AccountUsageHelpers.cs
+++ b/windows/CodexBarWin/Models/AccountUsageHelpers.cs
@@ -6,8 +6,80 @@ namespace CodexBarWin.Models;
///
/// 账号用量相关的纯逻辑计算,集中在这里方便测试和被多个 UI 复用。
///
+public sealed record AccountUsageWindow(string Label, double UsedPercent, DateTimeOffset? ResetAt);
+
public static class AccountUsageHelpers
{
+ public static IReadOnlyList Windows(TokenAccount account)
+ {
+ var windows = new List();
+ void Add(bool? available, double used, DateTimeOffset? reset, int? seconds, string fallback)
+ {
+ // 旧数据没有 available 字段时,只接受明确存在的窗口信息;未知不等于 0%。
+ if (!(available ?? (reset.HasValue || seconds.HasValue || used > 0)) || !HasUsageValue(used)) return;
+ var label = seconds == 604800 ? "7d" : seconds == 18000 ? "5h" : fallback;
+ windows.RemoveAll(w => w.Label == label);
+ windows.Add(new(label, Clamp(used), reset));
+ }
+ Add(account.PrimaryWindowAvailable, account.PrimaryUsedPercent, account.PrimaryResetAt, account.PrimaryLimitWindowSeconds, "5h");
+ Add(account.SecondaryWindowAvailable, account.SecondaryUsedPercent, account.SecondaryResetAt, account.SecondaryLimitWindowSeconds, "7d");
+ return windows.OrderBy(w => w.Label == "5h" ? 0 : 1).ToArray();
+ }
+
+ public static string UsageText(TokenAccount account, UsageDisplayMode mode) => Windows(account).Count == 0
+ ? "用量未提供" : string.Join(" · ", Windows(account).Select(w => $"{w.Label} {FormatDisplayPercent(w.UsedPercent, mode)}"));
+
+ public static string ResetText(TokenAccount account) => Windows(account).Count == 0
+ ? "等待额度数据" : string.Join(" · ", Windows(account).Select(w => $"{w.Label} {ExactTime(w.ResetAt)}"));
+
+ public static string AverageText(IEnumerable accounts, UsageDisplayMode mode)
+ {
+ var groups = accounts.SelectMany(Windows).GroupBy(w => w.Label).OrderBy(g => g.Key == "5h" ? 0 : 1);
+ var text = string.Join(" · ", groups.Select(g => $"{g.Key} {FormatDisplayPercent(g.Average(w => w.UsedPercent), mode)}"));
+ return text.Length == 0 ? "用量未提供" : text;
+ }
+
+ public static string ExactTime(DateTimeOffset? value) => value?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss zzz", CultureInfo.InvariantCulture) ?? "未提供";
+
+ public static string CreditSummary(TokenAccount account)
+ {
+ var count = account.ResetCreditsAvailable is { } n ? $"{n} 次" : "次数未提供";
+ var cached = account.ResetCreditDetailsStale || account.ResetCreditsCheckedAt is { } at && DateTimeOffset.UtcNow - at > TimeSpan.FromMinutes(10);
+ return "重置卡 " + count + (cached ? "(缓存)" : "");
+ }
+
+ public static string DetailsText(TokenAccount account, UsageDisplayMode mode)
+ {
+ var lines = new List { DisplayName(account), "", "额度窗口" };
+ foreach (var window in Windows(account))
+ {
+ lines.Add($"{(window.Label == "5h" ? "5 小时" : "7 天")}:{FormatDisplayPercent(window.UsedPercent, mode)}({(mode == UsageDisplayMode.Remaining ? "剩余" : "已用")})");
+ lines.Add("重置时间:" + ExactTime(window.ResetAt));
+ lines.Add("");
+ }
+ if (Windows(account).Count == 0) lines.Add("额度数据未提供");
+ lines.Add(CreditSummary(account));
+ if (account.ResetCreditsAvailable != 0)
+ {
+ if (account.ResetCreditDetails is null) lines.Add("到期时间:未提供");
+ else
+ {
+ for (var i = 0; i < account.ResetCreditDetails.Count; i++)
+ {
+ var credit = account.ResetCreditDetails[i];
+ var expiry = !credit.ExpirationKnown ? "未提供" : credit.ExpiresAt is null ? "无到期限制" : ExactTime(credit.ExpiresAt);
+ lines.Add($"第 {i + 1} 张到期:{expiry}");
+ }
+ if (account.ResetCreditDetails.Count < account.ResetCreditsAvailable)
+ lines.Add("其余重置卡的到期时间未提供。");
+ }
+ }
+ lines.Add("");
+ lines.Add("时间按本机时区显示,末尾为 UTC 偏移。");
+ lines.Add("重置卡资料更新时间:" + ExactTime(account.ResetCreditsCheckedAt));
+ return string.Join(Environment.NewLine, lines);
+ }
+
public static double Clamp(double value)
{
return double.IsFinite(value) ? Math.Clamp(value, 0, 100) : 0;
@@ -20,7 +92,7 @@ public static bool HasUsageValue(double value)
public static double MaxUsage(TokenAccount account)
{
- return Math.Max(Clamp(account.PrimaryUsedPercent), Clamp(account.SecondaryUsedPercent));
+ return Windows(account).Select(w => w.UsedPercent).DefaultIfEmpty(0).Max();
}
public static AccountHealthStatus Health(
@@ -54,7 +126,7 @@ public static AccountHealthStatus Health(
return AccountHealthStatus.Warning;
}
- if (account.LastChecked is null)
+ if (account.LastChecked is null || Windows(account).Count == 0)
{
return AccountHealthStatus.Unknown;
}
diff --git a/windows/CodexBarWin/Models/CodexBarConfig.cs b/windows/CodexBarWin/Models/CodexBarConfig.cs
index 6b82340..8f81281 100644
--- a/windows/CodexBarWin/Models/CodexBarConfig.cs
+++ b/windows/CodexBarWin/Models/CodexBarConfig.cs
@@ -101,6 +101,10 @@ public static Dictionary CreateDefaults()
{
return new Dictionary(StringComparer.OrdinalIgnoreCase)
{
+ // 官方标准短上下文价,2026-09-26;估值不包含长上下文/加速等倍率。
+ ["gpt-6-astra"] = new() { InputUsdPerMillion = 10, CachedInputUsdPerMillion = 1, OutputUsdPerMillion = 50 },
+ ["gpt-6-sol"] = new() { InputUsdPerMillion = 2, CachedInputUsdPerMillion = 0.2, OutputUsdPerMillion = 10 },
+ ["gpt-6-luna"] = new() { InputUsdPerMillion = 0.1, CachedInputUsdPerMillion = 0.01, OutputUsdPerMillion = 0.5 },
["gpt-5.5"] = new() { InputUsdPerMillion = 5, CachedInputUsdPerMillion = 0.5, OutputUsdPerMillion = 30 },
["gpt-5.4"] = new() { InputUsdPerMillion = 2.5, CachedInputUsdPerMillion = 0.25, OutputUsdPerMillion = 15 },
["gpt-5.4-mini"] = new() { InputUsdPerMillion = 0.75, CachedInputUsdPerMillion = 0.075, OutputUsdPerMillion = 4.5 },
@@ -215,10 +219,10 @@ private static double ClampPrice(double value, double fallback)
public sealed class CodexBarGlobalSettings
{
[JsonPropertyName("default_model")]
- public string DefaultModel { get; set; } = "gpt-5.5";
+ public string DefaultModel { get; set; } = "gpt-6-sol";
[JsonPropertyName("review_model")]
- public string ReviewModel { get; set; } = "gpt-5.5";
+ public string ReviewModel { get; set; } = "gpt-6-sol";
[JsonPropertyName("reasoning_effort")]
public string ReasoningEffort { get; set; } = "medium";
@@ -235,6 +239,15 @@ public sealed class CodexBarConfig
[JsonPropertyName("openai")]
public CodexBarOpenAISettings OpenAI { get; set; } = new();
+ [JsonPropertyName("ui_scale_percent")]
+ public int UiScalePercent { get; set; }
+
+ [JsonPropertyName("tray_icon_style")]
+ public string TrayIconStyle { get; set; } = "ring";
+
+ [JsonPropertyName("auto_check_updates")]
+ public bool AutoCheckUpdates { get; set; } = true;
+
[JsonPropertyName("keep_awake_enabled")]
public bool KeepAwakeEnabled { get; set; } = true;
@@ -263,6 +276,9 @@ public CodexBarConfig Clone()
{
return new CodexBarConfig
{
+ UiScalePercent = UiScalePercent,
+ TrayIconStyle = TrayIconStyle,
+ AutoCheckUpdates = AutoCheckUpdates,
KeepAwakeEnabled = KeepAwakeEnabled,
AdvancedKeepAwakeEnabled = AdvancedKeepAwakeEnabled,
AdvancedKeepAwakeIdleThresholdMs = AdvancedKeepAwakeIdleThresholdMs,
@@ -296,6 +312,9 @@ public CodexBarConfig Clone()
public static readonly string[] AvailableModels =
{
+ "gpt-6-astra",
+ "gpt-6-sol",
+ "gpt-6-luna",
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini",
@@ -310,6 +329,7 @@ public CodexBarConfig Clone()
"medium",
"high",
"xhigh",
+ "max",
};
public static readonly string[] AvailableServiceTiers =
diff --git a/windows/CodexBarWin/Models/ResetCreditInfo.cs b/windows/CodexBarWin/Models/ResetCreditInfo.cs
new file mode 100644
index 0000000..795c0bd
--- /dev/null
+++ b/windows/CodexBarWin/Models/ResetCreditInfo.cs
@@ -0,0 +1,7 @@
+namespace CodexBarWin.Models;
+
+public sealed class ResetCreditInfo
+{
+ public DateTimeOffset? ExpiresAt { get; set; }
+ public bool ExpirationKnown { get; set; }
+}
diff --git a/windows/CodexBarWin/Models/TokenAccount.cs b/windows/CodexBarWin/Models/TokenAccount.cs
index ba6b763..7658563 100644
--- a/windows/CodexBarWin/Models/TokenAccount.cs
+++ b/windows/CodexBarWin/Models/TokenAccount.cs
@@ -31,6 +31,12 @@ public class TokenAccount
[JsonPropertyName("plan_type")]
public string PlanType { get; set; } = "free";
+ [JsonPropertyName("primary_window_available")]
+ public bool? PrimaryWindowAvailable { get; set; }
+
+ [JsonPropertyName("secondary_window_available")]
+ public bool? SecondaryWindowAvailable { get; set; }
+
[JsonPropertyName("primary_used_percent")]
public double PrimaryUsedPercent { get; set; }
@@ -49,6 +55,18 @@ public class TokenAccount
[JsonPropertyName("secondary_limit_window_seconds")]
public int? SecondaryLimitWindowSeconds { get; set; }
+ [JsonPropertyName("reset_credits_available")]
+ public int? ResetCreditsAvailable { get; set; }
+
+ [JsonPropertyName("reset_credit_details")]
+ public List? ResetCreditDetails { get; set; }
+
+ [JsonPropertyName("reset_credits_checked_at")]
+ public DateTimeOffset? ResetCreditsCheckedAt { get; set; }
+
+ [JsonPropertyName("reset_credit_details_stale")]
+ public bool ResetCreditDetailsStale { get; set; }
+
[JsonPropertyName("last_checked")]
public DateTimeOffset? LastChecked { get; set; }
diff --git a/windows/CodexBarWin/Program.cs b/windows/CodexBarWin/Program.cs
index d875814..848b817 100644
--- a/windows/CodexBarWin/Program.cs
+++ b/windows/CodexBarWin/Program.cs
@@ -22,7 +22,19 @@ private static void Main()
AppLogService.LogException(exception, "AppDomain unhandled exception");
}
};
- Application.Run(new CodexBarTrayContext());
+ using var instance = new System.Threading.Mutex(true, "Local\\WinCodexBar-" + System.Security.Principal.WindowsIdentity.GetCurrent().User?.Value, out var created);
+ if (!created)
+ {
+ MessageBox.Show("WinCodexBar 已在运行,请从系统托盘打开。", "WinCodexBar");
+ return;
+ }
+ try
+ {
+ AppDataMigration.EnsureVersionBackup();
+ using var context = new CodexBarTrayContext();
+ Application.Run(context);
+ }
+ finally { instance.ReleaseMutex(); }
}
}
}
diff --git a/windows/CodexBarWin/Services/AccountRegistry.cs b/windows/CodexBarWin/Services/AccountRegistry.cs
index 716e353..c6acb7d 100644
--- a/windows/CodexBarWin/Services/AccountRegistry.cs
+++ b/windows/CodexBarWin/Services/AccountRegistry.cs
@@ -82,7 +82,7 @@ public void Save()
WriteIndented = true,
};
- File.WriteAllText(CodexPaths.WindowsRegistryPath, JsonSerializer.Serialize(payload, options));
+ AtomicFile.WriteAllText(CodexPaths.WindowsRegistryPath, JsonSerializer.Serialize(payload, options));
}
public void SetActive(string? accountId)
diff --git a/windows/CodexBarWin/Services/AppDataMigration.cs b/windows/CodexBarWin/Services/AppDataMigration.cs
new file mode 100644
index 0000000..e17b7b6
--- /dev/null
+++ b/windows/CodexBarWin/Services/AppDataMigration.cs
@@ -0,0 +1,21 @@
+namespace CodexBarWin.Services;
+
+/// 手动解压升级也备份旧数据;沿用原目录和 JSON 结构,不搬动 Codex 会话。
+public static class AppDataMigration
+{
+ public static void EnsureVersionBackup()
+ {
+ CodexPaths.EnsureDirectories();
+ var marker = Path.Combine(CodexPaths.CodexBarRoot, "data-version.txt");
+ var version = AppUpdateService.DisplayVersion;
+ if (File.Exists(marker) && File.ReadAllText(marker).Trim() == version) return;
+ var files = new[] { CodexPaths.WindowsRegistryPath, CodexPaths.WindowsSettingsPath }.Where(File.Exists).ToArray();
+ if (files.Length > 0)
+ {
+ var backup = Path.Combine(CodexPaths.CodexBarRoot, "backups", "before-" + version + "-" + DateTime.Now.ToString("yyyyMMdd-HHmmss-fff"));
+ Directory.CreateDirectory(backup);
+ foreach (var file in files) File.Copy(file, Path.Combine(backup, Path.GetFileName(file)));
+ }
+ AtomicFile.WriteAllText(marker, version);
+ }
+}
diff --git a/windows/CodexBarWin/Services/AppUpdateService.cs b/windows/CodexBarWin/Services/AppUpdateService.cs
new file mode 100644
index 0000000..8dae2af
--- /dev/null
+++ b/windows/CodexBarWin/Services/AppUpdateService.cs
@@ -0,0 +1,201 @@
+using System.Diagnostics;
+using System.IO.Compression;
+using System.Net.Http;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace CodexBarWin.Services;
+
+public sealed record AppUpdate(Version Version, string Tag, Uri Page, string AssetName, Uri Download, Uri Checksums);
+
+/// 仅接受本仓库正式 Release;更新包先完整下载、校验,退出后才覆盖程序。
+public sealed class AppUpdateService
+{
+ public const string ReleasesUrl = "https://github.com/windycn/WinCodexBar/releases";
+ private static readonly HttpClient Client = CreateClient();
+ public static Version CurrentVersion => typeof(AppUpdateService).Assembly.GetName().Version ?? new Version(0, 0, 0);
+ public static string DisplayVersion => CurrentVersion.ToString(3);
+ public static string ArchitectureName => RuntimeInformation.ProcessArchitecture switch
+ {
+ Architecture.X64 => "x64", Architecture.X86 => "x86", Architecture.Arm64 => "arm64",
+ _ => throw new PlatformNotSupportedException("此架构暂不支持自动更新。"),
+ };
+
+ private static HttpClient CreateClient()
+ {
+ var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("WinCodexBar/" + DisplayVersion);
+ client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json");
+ return client;
+ }
+
+ public async Task CheckAsync(CancellationToken cancellationToken = default)
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(TimeSpan.FromSeconds(20));
+ using var response = await Client.GetAsync("https://api.github.com/repos/windycn/WinCodexBar/releases/latest", timeout.Token);
+ response.EnsureSuccessStatusCode();
+ return ParseRelease(await response.Content.ReadAsStringAsync(timeout.Token), CurrentVersion, ArchitectureName);
+ }
+
+ public static AppUpdate? ParseRelease(string json, Version current, string architecture)
+ {
+ using var document = JsonDocument.Parse(json);
+ var root = document.RootElement;
+ if (root.GetProperty("draft").GetBoolean() || root.GetProperty("prerelease").GetBoolean()) return null;
+ var tag = root.GetProperty("tag_name").GetString() ?? "";
+ if (!Regex.IsMatch(tag, @"^v?\d+\.\d+\.\d+$") || !Version.TryParse(tag.TrimStart('v'), out var version))
+ throw new InvalidDataException("发布版本号格式无效。请到发布页查看。");
+ // AssemblyVersion 带第四位 0;比较时统一为三位。
+ if (version <= new Version(current.Major, current.Minor, Math.Max(0, current.Build))) return null;
+ if (architecture is not ("x64" or "x86" or "arm64")) throw new InvalidDataException("不支持的更新架构。");
+ var name = $"WinCodexBar-{version}-win-{architecture}.zip";
+ Uri? download = null, checksums = null;
+ foreach (var asset in root.GetProperty("assets").EnumerateArray())
+ {
+ var assetName = asset.GetProperty("name").GetString();
+ if (assetName != name && assetName != "SHA256SUMS.txt") continue;
+ var uri = new Uri(asset.GetProperty("browser_download_url").GetString()!);
+ var expected = $"https://github.com/windycn/WinCodexBar/releases/download/{tag}/{assetName}";
+ if (!string.Equals(uri.AbsoluteUri, expected, StringComparison.Ordinal))
+ throw new InvalidDataException("更新资源地址不属于本项目发布。已停止更新。");
+ if (assetName == name) download = uri; else checksums = uri;
+ }
+ if (download is null || checksums is null) throw new InvalidDataException("新版本缺少本机架构安装包或 SHA256 校验文件,请稍后重试。");
+ return new AppUpdate(version, tag, new Uri(ReleasesUrl + "/tag/" + tag), name, download, checksums);
+ }
+
+ public async Task DownloadAndVerifyAsync(AppUpdate update, CancellationToken cancellationToken = default)
+ {
+ var directory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WinCodexBar", "updates", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ var zip = Path.Combine(directory, update.AssetName);
+ var sums = await Client.GetStringAsync(update.Checksums, cancellationToken);
+ using (var response = await Client.GetAsync(update.Download, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
+ {
+ response.EnsureSuccessStatusCode();
+ await using var output = File.Create(zip);
+ await response.Content.CopyToAsync(output, cancellationToken);
+ }
+ VerifyChecksum(zip, update.AssetName, sums);
+ var staged = Path.Combine(directory, "package");
+ ExtractPackage(zip, staged);
+ return staged;
+ }
+
+ public static void VerifyChecksum(string zip, string assetName, string sums)
+ {
+ var line = sums.Split('\n').Select(s => s.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))
+ .SingleOrDefault(parts => parts.Length == 2 && parts[1].TrimStart('*') == assetName);
+ if (line is null || !Regex.IsMatch(line[0], "^[a-fA-F0-9]{64}$")) throw new InvalidDataException("缺少有效 SHA256 校验值。");
+ using var stream = File.OpenRead(zip);
+ var actual = Convert.ToHexString(SHA256.HashData(stream));
+ if (!actual.Equals(line[0], StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("安装包校验失败,未修改当前程序。请重新下载。");
+ }
+
+ public static void ExtractPackage(string zip, string destination)
+ {
+ using var archive = ZipFile.OpenRead(zip);
+ foreach (var entry in archive.Entries)
+ {
+ var name = entry.FullName.Replace('\\', '/');
+ if (name.StartsWith('/') || name.Contains(':') || name.Split('/').Any(part => part is ".." or "." or ".codex" or ".codexbar")
+ || (entry.ExternalAttributes >> 16 & 0xF000) == 0xA000)
+ throw new InvalidDataException("安装包包含不安全路径。");
+ }
+ if (!archive.Entries.Any(e => e.FullName == "WinCodexBar.exe" && e.Length > 0))
+ throw new InvalidDataException("安装包缺少 WinCodexBar.exe。");
+ Directory.CreateDirectory(destination);
+ archive.ExtractToDirectory(destination);
+ }
+
+ public void StartInstaller(string staged)
+ {
+ var exe = Environment.ProcessPath ?? throw new InvalidOperationException("无法确定程序位置。");
+ if (!Path.GetFileName(exe).Equals("WinCodexBar.exe", StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException("开发运行环境请使用发布包更新。");
+ var target = Path.GetDirectoryName(exe)!;
+ // 先检查写权限,失败时应用保持运行,用户可手动解压到可写目录。
+ var probe = Path.Combine(target, ".wincodexbar-update-" + Guid.NewGuid().ToString("N"));
+ using (File.Create(probe)) { }
+ File.Delete(probe);
+ var script = Path.Combine(Path.GetDirectoryName(staged)!, "install.ps1");
+ File.WriteAllText(script, BuildInstallScript(staged, target, CodexPaths.CodexBarRoot, Environment.ProcessId), new UTF8Encoding(true));
+ var start = new ProcessStartInfo("powershell.exe") { UseShellExecute = false, CreateNoWindow = true };
+ foreach (var argument in new[] { "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script }) start.ArgumentList.Add(argument);
+ using var process = Process.Start(start) ?? throw new IOException("无法启动更新程序。");
+ }
+
+ public static string BuildInstallScript(string staged, string target, string dataRoot, int processId)
+ {
+ static string Literal(string value) => "'" + value.Replace("'", "''") + "'";
+ return $$"""
+ $ErrorActionPreference = 'Stop'
+ $source = {{Literal(staged)}}
+ $target = {{Literal(target)}}
+ $dataRoot = {{Literal(dataRoot)}}
+ $work = Split-Path -Parent $source
+ $backup = Join-Path $work 'previous-program'
+ $written = [System.Collections.Generic.List[string]]::new()
+ $newFiles = [System.Collections.Generic.List[string]]::new()
+ try {
+ $old = Get-Process -Id {{processId}} -ErrorAction SilentlyContinue
+ if ($old -and !$old.WaitForExit(30000)) { throw '程序未退出,请关闭后重试更新。' }
+ $stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
+ $dataBackup = Join-Path $dataRoot ('backups\before-update-' + $stamp)
+ New-Item -ItemType Directory -Force -Path $dataBackup | Out-Null
+ foreach ($name in @('windows_accounts.json', 'windows_settings.json')) {
+ $file = Join-Path $dataRoot $name
+ if (Test-Path -LiteralPath $file) { Copy-Item -LiteralPath $file -Destination $dataBackup }
+ }
+ $files = @(Get-ChildItem -LiteralPath $source -File -Recurse)
+ # 先备份全部被覆盖文件,再写入;不删除安装目录中的其他文件。
+ foreach ($file in $files) {
+ $relative = $file.FullName.Substring($source.Length).TrimStart('\', '/')
+ $dest = Join-Path $target $relative
+ $parent = Get-Item -LiteralPath $target
+ if ($parent.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw '不支持通过目录链接覆盖更新。' }
+ $ancestor = Split-Path -Parent $dest
+ while ($ancestor.Length -ge $target.Length) {
+ if ((Test-Path -LiteralPath $ancestor) -and ((Get-Item -LiteralPath $ancestor).Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw '目标目录包含链接,已停止更新。' }
+ $ancestor = Split-Path -Parent $ancestor
+ }
+ if (Test-Path -LiteralPath $dest) {
+ if ((Get-Item -LiteralPath $dest).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw '目标文件为链接,已停止更新。' }
+ $saved = Join-Path $backup $relative
+ New-Item -ItemType Directory -Force -Path (Split-Path -Parent $saved) | Out-Null
+ Copy-Item -LiteralPath $dest -Destination $saved
+ } else { $newFiles.Add($relative) }
+ }
+ foreach ($file in $files) {
+ $relative = $file.FullName.Substring($source.Length).TrimStart('\', '/')
+ $dest = Join-Path $target $relative
+ New-Item -ItemType Directory -Force -Path (Split-Path -Parent $dest) | Out-Null
+ $written.Add($relative)
+ Copy-Item -LiteralPath $file.FullName -Destination $dest -Force
+ }
+ '更新完成,数据备份:' + $dataBackup | Set-Content -LiteralPath (Join-Path $work 'result.txt') -Encoding UTF8
+ Start-Process -FilePath (Join-Path $target 'WinCodexBar.exe') -WorkingDirectory $target
+ } catch {
+ $failure = $_.Exception.Message
+ $rollbackFailures = @()
+ foreach ($relative in $written) {
+ try {
+ $dest = Join-Path $target $relative
+ $saved = Join-Path $backup $relative
+ if (Test-Path -LiteralPath $saved) { Copy-Item -LiteralPath $saved -Destination $dest -Force }
+ elseif ($newFiles.Contains($relative)) { Remove-Item -LiteralPath $dest -Force -ErrorAction SilentlyContinue }
+ } catch { $rollbackFailures += $_.Exception.Message }
+ }
+ $message = "更新失败:$failure`n旧程序备份:$backup`n" + ($rollbackFailures -join "`n")
+ $message | Set-Content -LiteralPath (Join-Path $work 'result.txt') -Encoding UTF8
+ Add-Type -AssemblyName System.Windows.Forms
+ [System.Windows.Forms.MessageBox]::Show($message, 'WinCodexBar 更新') | Out-Null
+ }
+ """;
+ }
+}
diff --git a/windows/CodexBarWin/Services/AtomicFile.cs b/windows/CodexBarWin/Services/AtomicFile.cs
new file mode 100644
index 0000000..dd021cb
--- /dev/null
+++ b/windows/CodexBarWin/Services/AtomicFile.cs
@@ -0,0 +1,17 @@
+using System.Text;
+
+namespace CodexBarWin.Services;
+
+internal static class AtomicFile
+{
+ public static void WriteAllText(string path, string text)
+ {
+ var temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ File.WriteAllText(temporary, text, new UTF8Encoding(false));
+ File.Move(temporary, path, overwrite: true);
+ }
+ finally { if (File.Exists(temporary)) File.Delete(temporary); }
+ }
+}
diff --git a/windows/CodexBarWin/Services/CodexBarConfigStore.cs b/windows/CodexBarWin/Services/CodexBarConfigStore.cs
index f713582..3a0092a 100644
--- a/windows/CodexBarWin/Services/CodexBarConfigStore.cs
+++ b/windows/CodexBarWin/Services/CodexBarConfigStore.cs
@@ -40,22 +40,14 @@ public void Load()
var root = document.RootElement;
var config = new CodexBarConfig();
- if (root.TryGetProperty("keep_awake_enabled", out var keepAwakeNew)
- && keepAwakeNew.ValueKind == JsonValueKind.True)
- {
- config.KeepAwakeEnabled = true;
- }
- else if (root.TryGetProperty("KeepAwakeEnabled", out var keepAwakeLegacy)
- && keepAwakeLegacy.ValueKind == JsonValueKind.True)
- {
- config.KeepAwakeEnabled = true;
- }
-
- if (root.TryGetProperty("advanced_keep_awake_enabled", out var advancedKeepAwake)
- && advancedKeepAwake.ValueKind == JsonValueKind.True)
- {
- config.AdvancedKeepAwakeEnabled = true;
- }
+ config.KeepAwakeEnabled = ReadBool(root, "keep_awake_enabled",
+ ReadBool(root, "KeepAwakeEnabled", config.KeepAwakeEnabled));
+ config.AdvancedKeepAwakeEnabled = ReadBool(root, "advanced_keep_awake_enabled", config.AdvancedKeepAwakeEnabled);
+ config.AutoCheckUpdates = ReadBool(root, "auto_check_updates", true);
+ var uiScale = ReadInt(root, "ui_scale_percent", 0);
+ config.UiScalePercent = uiScale is 100 or 125 or 150 or 175 or 200 or 250 or 300 ? uiScale : 0;
+ if (root.TryGetProperty("tray_icon_style", out var trayStyle) && trayStyle.ValueKind == JsonValueKind.String)
+ config.TrayIconStyle = trayStyle.GetString() is "percent" or "classic" ? trayStyle.GetString()! : "ring";
config.AdvancedKeepAwakeIdleThresholdMs = ReadInt(root, "advanced_keep_awake_idle_threshold_ms", config.AdvancedKeepAwakeIdleThresholdMs);
config.AdvancedKeepAwakeIntervalMs = ReadInt(root, "advanced_keep_awake_interval_ms", config.AdvancedKeepAwakeIntervalMs);
@@ -91,7 +83,7 @@ public void Load()
}
// 校验阈值
- config.OpenAI.AutoRefreshIntervalSeconds = Math.Max(60, config.OpenAI.AutoRefreshIntervalSeconds);
+ config.OpenAI.AutoRefreshIntervalSeconds = Math.Clamp(config.OpenAI.AutoRefreshIntervalSeconds, 60, 86400);
config.OpenAI.WarningThresholdPercent = ClampPercent(config.OpenAI.WarningThresholdPercent, 70);
config.OpenAI.DangerThresholdPercent = ClampPercent(config.OpenAI.DangerThresholdPercent, 90);
config.OpenAI.EnsurePricingDefaults();
@@ -118,7 +110,7 @@ public void Save()
CodexPaths.EnsureDirectories();
Config.OpenAI.EnsurePricingDefaults();
var text = JsonSerializer.Serialize(Config, WriteOptions);
- File.WriteAllText(CodexPaths.WindowsSettingsPath, text);
+ AtomicFile.WriteAllText(CodexPaths.WindowsSettingsPath, text);
}
public void Update(Action mutate)
@@ -132,6 +124,10 @@ public void Update(Action mutate)
Save();
}
+ private static bool ReadBool(JsonElement root, string key, bool fallback) =>
+ root.TryGetProperty(key, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False
+ ? value.GetBoolean() : fallback;
+
private static double ClampPercent(double value, double fallback)
{
if (!double.IsFinite(value) || value <= 0 || value > 100)
diff --git a/windows/CodexBarWin/Services/CodexModelCatalog.cs b/windows/CodexBarWin/Services/CodexModelCatalog.cs
new file mode 100644
index 0000000..a016aca
--- /dev/null
+++ b/windows/CodexBarWin/Services/CodexModelCatalog.cs
@@ -0,0 +1,41 @@
+using System.Text.Json;
+using CodexBarWin.Models;
+
+namespace CodexBarWin.Services;
+
+public sealed record CodexModelOption(string Id, string[] Efforts, string[] Tiers);
+
+/// 只读本机 Codex 的模型目录;离线或旧目录时采用内置选项,允许手填模型。
+public static class CodexModelCatalog
+{
+ public static IReadOnlyList Load()
+ {
+ try
+ {
+ var path = Path.Combine(CodexPaths.CodexRoot, "models_cache.json");
+ if (!File.Exists(path)) return Array.Empty();
+ using var document = JsonDocument.Parse(File.ReadAllText(path));
+ if (!document.RootElement.TryGetProperty("models", out var models) || models.ValueKind != JsonValueKind.Array) return Array.Empty();
+ return models.EnumerateArray().Where(m => m.ValueKind == JsonValueKind.Object)
+ .Select(m => new CodexModelOption(Read(m,"slug") ?? Read(m,"id") ?? "",
+ ReadArray(m,"supported_reasoning_levels","effort"),
+ new[] { "standard" }.Concat(ReadArray(m,"service_tiers","id").Select(t => t is "priority" ? "fast" : t)).Distinct().ToArray()))
+ .Where(m => !string.IsNullOrWhiteSpace(m.Id)).ToArray();
+ }
+ catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) { return Array.Empty(); }
+ }
+ public static string[] Models() => Load().Select(m => m.Id).Concat(CodexBarConfig.AvailableModels).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
+ public static string[] Efforts(string model) => Load().FirstOrDefault(m => m.Id == model)?.Efforts is {Length: >0} efforts ? efforts : CodexBarConfig.AvailableReasoningEfforts;
+ public static string[] Tiers(string model) => Load().FirstOrDefault(m => m.Id == model)?.Tiers ?? CodexBarConfig.AvailableServiceTiers;
+ public static string? ConfigTier(string model, string? requested)
+ {
+ var tier = (requested ?? "standard").Trim().ToLowerInvariant();
+ if (tier is "standard" or "flex" or "" or "default")
+ return Tiers(model).Contains("default") ? "default" : null;
+ if (tier == "priority") tier = "fast";
+ return Tiers(model).Contains(tier) ? tier : null;
+ }
+ private static string? Read(JsonElement element,string key) => element.TryGetProperty(key,out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
+ private static string[] ReadArray(JsonElement element,string key,string value) => element.TryGetProperty(key,out var array) && array.ValueKind == JsonValueKind.Array
+ ? array.EnumerateArray().Where(x=>x.ValueKind==JsonValueKind.Object).Select(x=>Read(x,value)).Where(x=>!string.IsNullOrWhiteSpace(x)).Cast().ToArray() : Array.Empty();
+}
diff --git a/windows/CodexBarWin/Services/CodexRadarService.cs b/windows/CodexBarWin/Services/CodexRadarService.cs
index 93fe923..53e0904 100644
--- a/windows/CodexBarWin/Services/CodexRadarService.cs
+++ b/windows/CodexBarWin/Services/CodexRadarService.cs
@@ -17,6 +17,10 @@ public sealed record CodexRadarPrediction(
DateTimeOffset? UpdatedAt,
bool IsAvailable)
{
+ public bool? WindowOpen { get; init; }
+ public DateTimeOffset? WindowOpenedAt { get; init; }
+ public bool IsStale { get; init; }
+
public static CodexRadarPrediction Loading { get; } = new("loading", "获取中", string.Empty, null, null, null, false);
public static CodexRadarPrediction Unavailable { get; } = new("unknown", "暂不可用", string.Empty, null, null, null, false);
@@ -27,10 +31,18 @@ public string DisplayText
{
if (!IsAvailable)
{
- return string.Empty;
+ return Level == "loading" ? "重置窗口 · 获取中 · Codex 雷达" : "重置窗口 · 暂不可用 · Codex 雷达";
}
- var parts = new List { "预测当前重置概率", LevelLabel };
+ if (WindowOpen.HasValue)
+ {
+ var state = WindowOpen.Value ? "速蹬窗口开启" : "重置窗口未开启";
+ var time = WindowOpen.Value && WindowOpenedAt.HasValue ? $" · {WindowOpenedAt.Value.ToOffset(TimeSpan.FromHours(8)):M月d日 HH:mm} 北京时间" : "";
+ return $"{(IsStale ? "缓存 · " : "")}{state}{time} · Codex 雷达";
+ }
+ if (UpdatedAt is null || DateTimeOffset.UtcNow - UpdatedAt > TimeSpan.FromDays(3))
+ return "旧预测已过期 · 查看 Codex 雷达";
+ var parts = new List { IsStale ? "缓存预测" : "社区重置预测", LevelLabel };
var probability = ProbabilityDisplayText();
if (!string.IsNullOrWhiteSpace(probability))
{
@@ -116,23 +128,33 @@ public async Task GetCurrentAsync(bool forceRefresh = fals
return prediction;
}
- catch (Exception ex) when (ex is not OperationCanceledException)
+ catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
{
AppLogService.LogException(ex, "codex radar current.json");
lock (_gate)
{
_lastFetchedAt = DateTimeOffset.UtcNow;
+ _lastPrediction = _lastPrediction.IsAvailable ? _lastPrediction with { IsStale = true } : CodexRadarPrediction.Unavailable;
return _lastPrediction;
}
}
}
- private static CodexRadarPrediction Parse(JsonElement root)
+ public static CodexRadarPrediction Parse(JsonElement root)
{
- if (!root.TryGetProperty("prediction", out var prediction) || prediction.ValueKind != JsonValueKind.Object)
+ if (root.ValueKind != JsonValueKind.Object) return CodexRadarPrediction.Unavailable;
+ bool? windowOpen = null;
+ DateTimeOffset? openedAt = null;
+ if (root.TryGetProperty("window_open", out var flag) && flag.ValueKind is JsonValueKind.True or JsonValueKind.False)
+ windowOpen = flag.GetBoolean();
+ if (root.TryGetProperty("window", out var window) && window.ValueKind == JsonValueKind.Object)
{
- return CodexRadarPrediction.Unavailable;
+ if (!windowOpen.HasValue && window.TryGetProperty("open", out flag) && flag.ValueKind is JsonValueKind.True or JsonValueKind.False)
+ windowOpen = flag.GetBoolean();
+ if (DateTimeOffset.TryParse(ReadString(window, "opened_at"), out var time)) openedAt = time;
}
+ if (!root.TryGetProperty("prediction", out var prediction) || prediction.ValueKind != JsonValueKind.Object)
+ return windowOpen.HasValue ? CodexRadarPrediction.Unavailable with { IsAvailable = true, WindowOpen = windowOpen, WindowOpenedAt = openedAt } : CodexRadarPrediction.Unavailable;
var level = ReadString(prediction, "level") ?? "unknown";
var expectedWindow = NormalizeWhitespace(ReadString(prediction, "expected_window")
@@ -142,7 +164,7 @@ private static CodexRadarPrediction Parse(JsonElement root)
var probability48h = ReadDouble(prediction, "probability_48h");
var updatedAtText = ReadString(prediction, "updated_at") ?? ReadString(root, "monitored_at");
DateTimeOffset? updatedAt = DateTimeOffset.TryParse(updatedAtText, out var parsed) ? parsed : null;
- return new CodexRadarPrediction(level, LevelLabel(level), expectedWindow, probability24h, probability48h, updatedAt, true);
+ return new CodexRadarPrediction(level, LevelLabel(level), expectedWindow, probability24h, probability48h, updatedAt, true) { WindowOpen = windowOpen, WindowOpenedAt = openedAt };
}
private static string LevelLabel(string level)
diff --git a/windows/CodexBarWin/Services/CodexSyncService.cs b/windows/CodexBarWin/Services/CodexSyncService.cs
index 673070e..dccd503 100644
--- a/windows/CodexBarWin/Services/CodexSyncService.cs
+++ b/windows/CodexBarWin/Services/CodexSyncService.cs
@@ -101,7 +101,7 @@ private static void WriteAuthJson(TokenAccount account)
}
var options = new JsonSerializerOptions { WriteIndented = true };
- File.WriteAllText(CodexPaths.AuthPath, JsonSerializer.Serialize(payload, options));
+ AtomicFile.WriteAllText(CodexPaths.AuthPath, JsonSerializer.Serialize(payload, options));
}
private void WriteConfigToml(TokenAccount account, bool routeThroughGateway)
@@ -124,13 +124,14 @@ private void WriteConfigToml(TokenAccount account, bool routeThroughGateway)
text = UpsertKey(text, "model", Quote(settings.DefaultModel));
text = UpsertKey(text, "review_model", Quote(string.IsNullOrWhiteSpace(settings.ReviewModel) ? settings.DefaultModel : settings.ReviewModel));
text = UpsertKey(text, "model_reasoning_effort", Quote(string.IsNullOrWhiteSpace(settings.ReasoningEffort) ? "medium" : settings.ReasoningEffort));
- text = UpsertKey(text, "service_tier", Quote(string.IsNullOrWhiteSpace(settings.ServiceTier) ? "standard" : settings.ServiceTier));
+ var tier = CodexModelCatalog.ConfigTier(settings.DefaultModel, settings.ServiceTier);
+ text = tier is null ? RemoveKey(text, "service_tier") : UpsertKey(text, "service_tier", Quote(tier));
if (routeThroughGateway)
{
text = UpsertKey(text, "openai_base_url", Quote(OpenAIAccountGatewayService.BaseUrl));
}
- File.WriteAllText(CodexPaths.ConfigTomlPath, text.TrimEnd() + "\n");
+ AtomicFile.WriteAllText(CodexPaths.ConfigTomlPath, text.TrimEnd() + "\n");
}
private static string Quote(string value)
@@ -139,23 +140,9 @@ private static string Quote(string value)
return "\"" + escaped + "\"";
}
- private static string UpsertKey(string tomlText, string key, string value)
- {
- var pattern = $@"(?m)^\s*{Regex.Escape(key)}\s*=.*$";
- if (Regex.IsMatch(tomlText, pattern))
- {
- return Regex.Replace(tomlText, pattern, $"{key} = {value}");
- }
-
- var prefix = string.IsNullOrWhiteSpace(tomlText.Trim()) ? string.Empty : "\n";
- return tomlText.TrimEnd() + prefix + $"{key} = {value}\n";
- }
+ private static string UpsertKey(string tomlText, string key, string value) => TomlRootEditor.Set(tomlText, key, value);
- private static string RemoveKey(string tomlText, string key)
- {
- var pattern = $@"(?m)^\s*{Regex.Escape(key)}\s*=.*(?:\r?\n|$)";
- return Regex.Replace(tomlText, pattern, string.Empty);
- }
+ private static string RemoveKey(string tomlText, string key) => TomlRootEditor.Set(tomlText, key, null);
private static string RemoveSection(string tomlText, string sectionName)
{
diff --git a/windows/CodexBarWin/Services/OpenAIUsageService.cs b/windows/CodexBarWin/Services/OpenAIUsageService.cs
index bd3085e..1f474bb 100644
--- a/windows/CodexBarWin/Services/OpenAIUsageService.cs
+++ b/windows/CodexBarWin/Services/OpenAIUsageService.cs
@@ -62,6 +62,8 @@ public async Task RefreshUsageAsync(TokenAccount account, C
account.TokenExpired = false;
account.LastChecked = DateTimeOffset.UtcNow;
+ var creditsTask = RefreshResetCreditsAsync(account, cancellationToken);
+
try
{
var orgName = await FetchOrgNameAsync(account, cancellationToken).ConfigureAwait(false);
@@ -75,6 +77,7 @@ public async Task RefreshUsageAsync(TokenAccount account, C
// 组织名属于增益信息,失败时静默。
}
+ await creditsTask.ConfigureAwait(false);
return UsageRefreshOutcome.Updated;
}
@@ -103,6 +106,29 @@ private async Task FetchUsageAsync(TokenAccount account, Ca
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = document.RootElement;
+ ApplyUsagePayload(account, root);
+
+ return UsageRefreshOutcome.Updated;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch
+ {
+ return UsageRefreshOutcome.Failed;
+ }
+ }
+
+ public static void ApplyUsagePayload(TokenAccount account, JsonElement root)
+ {
+ if (root.TryGetProperty("rate_limit_reset_credits", out var resetCredits) || root.TryGetProperty("rateLimitResetCredits", out resetCredits))
+ ApplyResetCreditPayload(account, resetCredits, details: false);
+ account.PrimaryWindowAvailable = false;
+ account.SecondaryWindowAvailable = false;
+ account.PrimaryUsedPercent = account.SecondaryUsedPercent = 0;
+ account.PrimaryResetAt = account.SecondaryResetAt = null;
+ account.PrimaryLimitWindowSeconds = account.SecondaryLimitWindowSeconds = null;
if (root.TryGetProperty("plan_type", out var planNode) && planNode.ValueKind == JsonValueKind.String)
{
var plan = planNode.GetString();
@@ -124,16 +150,71 @@ private async Task FetchUsageAsync(TokenAccount account, Ca
ApplyCodexWindow(rateLimits, "secondary", primary: false, account);
}
- return UsageRefreshOutcome.Updated;
+ }
+
+ // 只读重置卡资料;本程序不提供任何使用、兑换或消耗重置卡的接口。
+ private async Task RefreshResetCreditsAsync(TokenAccount account, CancellationToken cancellationToken)
+ {
+ if (!account.ResetCreditDetailsStale && account.ResetCreditsCheckedAt is { } checkedAt
+ && DateTimeOffset.UtcNow - checkedAt < TimeSpan.FromMinutes(5)) return;
+ try
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(TimeSpan.FromSeconds(6));
+ using var request = BuildAuthorizedRequest(HttpMethod.Get, new Uri("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"), account);
+ request.Headers.TryAddWithoutValidation("OpenAI-Beta", "codex-1");
+ using var response = await _httpClient.SendAsync(request, timeout.Token).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(timeout.Token).ConfigureAwait(false));
+ if (!ApplyResetCreditPayload(account, document.RootElement)) throw new JsonException("重置卡数据缺少字段");
}
- catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
+ catch { account.ResetCreditDetailsStale = true; }
+ }
+
+ public static bool ApplyResetCreditPayload(TokenAccount account, JsonElement root, bool details = true)
+ {
+ if (root.ValueKind != JsonValueKind.Object) return false;
+ var count = ReadInt(root, "available_count") ?? ReadInt(root, "availableCount");
+ if (count is >= 0)
{
- throw;
+ if (account.ResetCreditsAvailable != count) account.ResetCreditDetailsStale = true;
+ account.ResetCreditsAvailable = count;
+ if (count == 0)
+ {
+ account.ResetCreditDetails = new();
+ account.ResetCreditDetailsStale = false;
+ account.ResetCreditsCheckedAt = DateTimeOffset.UtcNow;
+ }
}
- catch
+ if (!details) return count is >= 0;
+ if (root.TryGetProperty("credits", out var credits) && credits.ValueKind == JsonValueKind.Array)
{
- return UsageRefreshOutcome.Failed;
+ var items = new List();
+ foreach (var credit in credits.EnumerateArray())
+ {
+ if (credit.ValueKind != JsonValueKind.Object || !credit.TryGetProperty("status", out var status)
+ || status.ValueKind != JsonValueKind.String || status.GetString() != "available") continue;
+ var known = credit.TryGetProperty("expires_at", out var expires) || credit.TryGetProperty("expiresAt", out expires);
+ DateTimeOffset? at = null;
+ if (known && expires.ValueKind != JsonValueKind.Null)
+ {
+ if (expires.ValueKind == JsonValueKind.Number && expires.TryGetInt64(out var unix))
+ {
+ try { at = DateTimeOffset.FromUnixTimeSeconds(unix); } catch (ArgumentOutOfRangeException) { known = false; }
+ }
+ else if (expires.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(expires.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)) at = parsed;
+ else known = false;
+ }
+ items.Add(new ResetCreditInfo { ExpiresAt = at, ExpirationKnown = known });
+ }
+ account.ResetCreditDetails = items.OrderBy(c => c.ExpiresAt ?? DateTimeOffset.MaxValue).ToList();
+ account.ResetCreditDetailsStale = false;
+ account.ResetCreditsCheckedAt = DateTimeOffset.UtcNow;
+ return true;
}
+ account.ResetCreditDetailsStale = account.ResetCreditsAvailable != 0;
+ return count is >= 0;
}
private async Task FetchOrgNameAsync(TokenAccount account, CancellationToken cancellationToken)
@@ -207,14 +288,16 @@ private static void ApplyWindow(JsonElement rateLimit, string name, bool primary
var resetAt = ReadUnixTime(window, "reset_at") ?? ReadResetAfter(window, "reset_after_seconds");
var windowSeconds = ReadInt(window, "limit_window_seconds");
- if (used is not null)
+ if (used is not null && double.IsFinite(used.Value) && used.Value >= 0)
{
if (primary)
{
+ account.PrimaryWindowAvailable = true;
account.PrimaryUsedPercent = Math.Clamp(used.Value, 0, 100);
}
else
{
+ account.SecondaryWindowAvailable = true;
account.SecondaryUsedPercent = Math.Clamp(used.Value, 0, 100);
}
}
@@ -248,14 +331,16 @@ private static void ApplyCodexWindow(JsonElement rateLimits, string name, bool p
windowSeconds = minutes * 60;
}
- if (used is not null)
+ if (used is not null && double.IsFinite(used.Value) && used.Value >= 0)
{
if (primary)
{
+ account.PrimaryWindowAvailable = true;
account.PrimaryUsedPercent = Math.Clamp(used.Value, 0, 100);
}
else
{
+ account.SecondaryWindowAvailable = true;
account.SecondaryUsedPercent = Math.Clamp(used.Value, 0, 100);
}
}
diff --git a/windows/CodexBarWin/Services/TomlRootEditor.cs b/windows/CodexBarWin/Services/TomlRootEditor.cs
new file mode 100644
index 0000000..948f52f
--- /dev/null
+++ b/windows/CodexBarWin/Services/TomlRootEditor.cs
@@ -0,0 +1,69 @@
+using System.Text.RegularExpressions;
+
+namespace CodexBarWin.Services;
+
+/// 仅改 TOML 根字段,不把新增字段追加到最后一个表,也不覆盖 profiles 中的同名键。
+public static class TomlRootEditor
+{
+ public static string Set(string text, string key, string? value)
+ {
+ var newline = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
+ var lines = text.Replace("\r\n", "\n").Split('\n');
+ var result = new List();
+ var pattern = new Regex("^[ \\t]*(?:" + Regex.Escape(key) + "|\"" + Regex.Escape(key) + "\"|'" + Regex.Escape(key) + "')[ \\t]*=");
+ string? multiline = null;
+ var depth = 0;
+ var written = false;
+ var inTable = false;
+ foreach (var line in lines)
+ {
+ if (!inTable && multiline is null && depth == 0 && line.TrimStart().StartsWith('['))
+ {
+ if (!written && value is not null) { result.Add(key + " = " + value); written = true; }
+ inTable = true;
+ }
+ if (!inTable && multiline is null && depth == 0 && pattern.IsMatch(line))
+ {
+ if (!written && value is not null) result.Add(key + " = " + value);
+ written = true;
+ continue;
+ }
+ result.Add(line);
+ if (!inTable) Scan(line, ref multiline, ref depth);
+ }
+ if (!written && value is not null)
+ {
+ if (result.Count > 0 && result[^1] == "") result.RemoveAt(result.Count - 1);
+ result.Add(key + " = " + value);
+ result.Add("");
+ }
+ return string.Join(newline, result);
+ }
+
+ private static void Scan(string line, ref string? multiline, ref int depth)
+ {
+ for (var i = 0; i < line.Length; i++)
+ {
+ if (multiline is not null)
+ {
+ if (line.AsSpan(i).StartsWith(multiline) && (i == 0 || line[i-1] != '\\' || multiline == "'''")) { i += 2; multiline = null; }
+ continue;
+ }
+ if (line[i] == '#') break;
+ if (line[i] is '\'' or '"')
+ {
+ var quote = line[i];
+ var triple = new string(quote, 3);
+ if (line.AsSpan(i).StartsWith(triple)) { multiline = triple; i += 2; continue; }
+ for (++i; i < line.Length; i++)
+ {
+ if (quote == '"' && line[i] == '\\') { i++; continue; }
+ if (line[i] == quote) break;
+ }
+ continue;
+ }
+ if (line[i] is '[' or '{') depth++;
+ if (line[i] is ']' or '}') depth--;
+ }
+ }
+}
diff --git a/windows/CodexBarWin/Tray/CodexBarTrayContext.cs b/windows/CodexBarWin/Tray/CodexBarTrayContext.cs
index 2d26fe5..cae443d 100644
--- a/windows/CodexBarWin/Tray/CodexBarTrayContext.cs
+++ b/windows/CodexBarWin/Tray/CodexBarTrayContext.cs
@@ -21,10 +21,10 @@ public sealed class CodexBarTrayContext : ApplicationContext
private static readonly System.Collections.Generic.Dictionary TrayIconCache = new();
private const int TrayMenuWidth = 480;
private const int TrayVisibleAccountLimit = 8;
- private static readonly Font TrayFont = new("Microsoft YaHei UI", 9f, FontStyle.Regular);
- private static readonly Font TraySmallFont = new("Microsoft YaHei UI", 8.4f, FontStyle.Regular);
- private static readonly Font TrayTinyFont = new("Microsoft YaHei UI", 8f, FontStyle.Regular);
- private static readonly Font TrayTitleFont = new("Microsoft YaHei UI", 10.4f, FontStyle.Bold);
+ private static readonly Font TrayFont = FluentTheme.TextFontPx(12);
+ private static readonly Font TraySmallFont = FluentTheme.TextFontPx(11.2f);
+ private static readonly Font TrayTinyFont = FluentTheme.TextFontPx(10.7f);
+ private static readonly Font TrayTitleFont = FluentTheme.TextFontPx(14, FontStyle.Bold);
private static readonly Font TrayMonoFont = new("Consolas", 8.2f, FontStyle.Regular);
private static readonly Color TrayMenuBack = Color.FromArgb(248, 250, 252);
private static readonly Color TrayCardBack = Color.FromArgb(255, 255, 255);
@@ -52,6 +52,16 @@ public sealed class CodexBarTrayContext : ApplicationContext
private readonly System.Windows.Forms.Timer _autoRefreshTimer;
private TrayPopupForm? _popupForm;
private SettingsForm? _settingsForm;
+ private long _lastOutsideDismiss;
+ private TokenUsageSummary _popupTokenSummary;
+ private Task? _popupScanTask;
+ private DateTimeOffset _popupScannedAt;
+ private string? _lastTrayIconKey;
+ private readonly AppUpdateService _updateService = new();
+ private readonly System.Windows.Forms.Timer _updateTimer = new() { Interval = 30000 };
+ private bool _updateBusy;
+ private bool _isExiting;
+ private bool _updateAvailable;
private bool _activeRefreshRunning;
private bool _backgroundRefreshRunning;
@@ -60,6 +70,7 @@ public CodexBarTrayContext()
_registry = new AccountRegistry();
_configStore = new CodexBarConfigStore();
_configStore.Load();
+ AppAppearance.ScalePercent = _configStore.Config.UiScalePercent;
_registry.Load();
StartupService.SetEnabled(_configStore.Config.StartWithWindows);
@@ -87,13 +98,27 @@ public CodexBarTrayContext()
ApplyGatewayMode();
SyncActiveIfPossible();
_ = RefreshUsageInBackgroundAsync(includeInactiveStale: true);
+ _updateTimer.Tick += async (_, _) =>
+ {
+ _updateTimer.Interval = 6 * 60 * 60 * 1000;
+ if (_configStore.Config.AutoCheckUpdates) await CheckForUpdatesAsync(false);
+ };
+ _updateTimer.Start();
+ _notifyIcon.BalloonTipClicked += async (_, _) => { if (_updateAvailable) await CheckForUpdatesAsync(true); };
_notifyIcon.MouseClick += OnTrayIconMouseClick;
+ _notifyIcon.MouseDoubleClick += (_, e) =>
+ {
+ if (e.Button != MouseButtons.Left) return;
+ OpenDashboard();
+ };
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
+ _isExiting = true;
+ _updateTimer.Dispose();
_keepAwakeService.ClearForProcessExit();
_autoRefreshTimer.Stop();
_autoRefreshTimer.Dispose();
@@ -124,7 +149,8 @@ private CodexBarDashboardForm DashboardForm
ExportAccountsFromDashboard,
OpenSettings,
IsAnyKeepAwakeEnabled,
- SetKeepAwakeEnabled
+ SetKeepAwakeEnabled,
+ DeleteAccountFromPopup
);
}
@@ -134,7 +160,7 @@ private CodexBarDashboardForm DashboardForm
private void RefreshDashboardIfCreated()
{
- if (_dashboardForm is { IsDisposed: false })
+ if (_dashboardForm is { IsDisposed: false, Visible: true })
{
_dashboardForm.RefreshData();
}
@@ -161,7 +187,7 @@ private ContextMenuStrip BuildMenu()
menu.Items.Add(CreateDisabledItem("WinCodexBar", "app"));
menu.Items.Add(CreateDisabledItem(active is null ? "当前未激活账号" : $"当前:{TrimMiddle(BuildAccountLabel(active), 42)}", "star"));
- menu.Items.Add(CreateDisabledItem($"账号 {_registry.Accounts.Count} · 主池 {FormatShortUsage(primaryAvg)} · 次池 {FormatShortUsage(secondaryAvg)}", "chart"));
+ menu.Items.Add(CreateDisabledItem($"账号 {_registry.Accounts.Count} · {AccountUsageHelpers.AverageText(_registry.Accounts, _configStore.Config.OpenAI.UsageDisplayMode)}", "chart"));
menu.Items.Add(new ToolStripSeparator());
if (_registry.Accounts.Count == 0)
@@ -179,7 +205,7 @@ private ContextMenuStrip BuildMenu()
foreach (var account in accountsToShow)
{
var isActive = string.Equals(_registry.ActiveAccountId, account.AccountId, StringComparison.Ordinal);
- var text = $"{(isActive ? "✓ " : " ")}{TrimMiddle(BuildAccountLabel(account), 38)} {FormatShortUsage(account.PrimaryUsedPercent)} / {FormatShortUsage(account.SecondaryUsedPercent)}";
+ var text = $"{(isActive ? "✓ " : " ")}{TrimMiddle(BuildAccountLabel(account), 38)} {AccountUsageHelpers.UsageText(account, _configStore.Config.OpenAI.UsageDisplayMode)}";
var item = CreateActionItem(text, isActive ? "healthy" : "app", () => ActivateAccount(account));
item.Font = isActive ? new Font(TrayFont, FontStyle.Bold) : TrayFont;
item.ToolTipText = BuildUsageTooltip(account);
@@ -220,12 +246,14 @@ private ContextMenuStrip BuildMenu()
menu.Items.Add(new ToolStripSeparator());
+ menu.Items.Add(CreateActionItem("检查更新 · v" + AppUpdateService.DisplayVersion, "refresh", async () => await CheckForUpdatesAsync(true)));
+
var exitItem = CreateActionItem("退出", "unknown", ExitThread);
menu.Items.Add(exitItem);
menu.Opening += (_, _) =>
{
- _notifyIcon.Icon = CreateTrayIcon(ResolveTrayUsage());
+ UpdateTrayIcon();
var currentActive = string.IsNullOrWhiteSpace(_registry.ActiveAccountId)
? null
: _registry.Accounts.FirstOrDefault(a => string.Equals(a.AccountId, _registry.ActiveAccountId, StringComparison.Ordinal));
@@ -820,7 +848,8 @@ private void OpenSettings()
_configStore,
_keepAwakeService,
OpenConfigFolder,
- OnSettingsChanged);
+ OnSettingsChanged,
+ async () => await CheckForUpdatesAsync(true));
}
_settingsForm.StartPosition = FormStartPosition.CenterScreen;
@@ -830,9 +859,59 @@ private void OpenSettings()
_settingsForm.Activate();
}
+ private async Task CheckForUpdatesAsync(bool manual)
+ {
+ if (_updateBusy || _isExiting) return;
+ _updateBusy = true;
+ try
+ {
+ var update = await _updateService.CheckAsync();
+ if (_isExiting) return;
+ _updateAvailable = update is not null;
+ if (update is null)
+ {
+ if (manual) MessageBox.Show("当前已是最新正式版 v" + AppUpdateService.DisplayVersion, "WinCodexBar 更新");
+ return;
+ }
+ if (!manual)
+ {
+ _notifyIcon.ShowBalloonTip(5000, "WinCodexBar 新版本 " + update.Version, "点击下载更新;也可在设置中检查更新。", ToolTipIcon.Info);
+ return;
+ }
+ _popupForm?.Close();
+ IWin32Window? owner = _settingsForm is { IsDisposed: false, Visible: true } ? _settingsForm : _dashboardForm is { IsDisposed: false, Visible: true } ? _dashboardForm : null;
+ using var confirm = new ConfirmActionDialog("发现新版本 " + update.Version,
+ "将下载并校验安装包,备份账号和设置后更新并重启。不会删除 Codex 会话。", "更新并重启");
+ if ((owner is null ? confirm.ShowDialog() : confirm.ShowDialog(owner)) != DialogResult.OK) return;
+ using var cancellation = new System.Threading.CancellationTokenSource();
+ using var progress = new Form { Text = "WinCodexBar 更新", Width = 420, Height = 140,
+ StartPosition = FormStartPosition.CenterScreen, MaximizeBox = false, MinimizeBox = false };
+ progress.Controls.Add(new Label { Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleCenter,
+ Text = "正在下载并校验,请稍候…\n关闭此窗口可取消,当前程序不会被修改。" });
+ progress.FormClosing += (_, _) => cancellation.Cancel();
+ progress.Show(owner);
+ string staged;
+ try { staged = await _updateService.DownloadAndVerifyAsync(update, cancellation.Token); cancellation.Token.ThrowIfCancellationRequested(); }
+ finally { progress.Close(); }
+ _updateService.StartInstaller(staged);
+ ExitThread();
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception ex)
+ {
+ AppLogService.LogException(ex, "检查或安装更新");
+ if (manual && !_isExiting) MessageBox.Show("更新未完成:" + ex.Message + "\n当前程序和数据保留。可从发布页手动下载:\n" + AppUpdateService.ReleasesUrl, "WinCodexBar 更新", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ }
+ finally { _updateBusy = false; }
+ }
+
private void OnSettingsChanged()
{
+ AppAppearance.ScalePercent = _configStore.Config.UiScalePercent;
+ foreach (var form in Application.OpenForms.OfType().ToArray()) form.ApplyAppearance();
ApplyAutoRefreshTimer();
+ ApplyGatewayMode();
+ SyncActiveIfPossible();
RebuildMenu();
RefreshDashboardIfCreated();
}
@@ -846,7 +925,7 @@ private void ApplyAutoRefreshTimer()
return;
}
- var interval = Math.Max(60, settings.AutoRefreshIntervalSeconds);
+ var interval = Math.Clamp(settings.AutoRefreshIntervalSeconds, 60, 86400);
_autoRefreshTimer.Stop();
_autoRefreshTimer.Interval = interval * 1000;
_autoRefreshTimer.Start();
@@ -945,7 +1024,7 @@ private void DeleteAccountFromPopup(TokenAccount account)
$"确定删除“{label}”吗?\n此操作只移除 WinCodexBar 保存的账号,不会删除 Codex 会话历史。",
"删除");
- IWin32Window? owner = _popupForm is { IsDisposed: false, Visible: true } ? _popupForm : null;
+ IWin32Window? owner = _popupForm is { IsDisposed: false, Visible: true } ? _popupForm : _dashboardForm is { IsDisposed: false, Visible: true } ? _dashboardForm : null;
var result = owner is null ? dialog.ShowDialog() : dialog.ShowDialog(owner);
if (result != DialogResult.OK)
{
@@ -973,11 +1052,14 @@ private void DeleteAccountFromPopup(TokenAccount account)
private void OnTrayIconMouseClick(object? sender, MouseEventArgs e)
{
- if (e.Button != MouseButtons.Left)
- {
- return;
- }
+ if (e.Button != MouseButtons.Left) return;
+ // 全局钩子在 MouseDown 收起弹窗,随后到达的托盘 MouseClick 不应重新打开。
+ if (Environment.TickCount64 - _lastOutsideDismiss < SystemInformation.DoubleClickTime) return;
+ ToggleTrayPopup();
+ }
+ private void ToggleTrayPopup()
+ {
if (_popupForm is { IsDisposed: false, Visible: true })
{
_popupForm.Close();
@@ -986,7 +1068,7 @@ private void OnTrayIconMouseClick(object? sender, MouseEventArgs e)
}
_popupForm?.Dispose();
- var tokenSummary = TokenUsageScanService.Scan();
+ var tokenSummary = _popupTokenSummary;
_popupForm = new TrayPopupForm(
_registry.Accounts,
_registry.ActiveAccountId,
@@ -1006,17 +1088,38 @@ private void OnTrayIconMouseClick(object? sender, MouseEventArgs e)
RefreshSingleAccountFromPopup,
DeleteAccountFromPopup,
ExitThread);
- _popupForm.FormClosed += (_, _) => _popupForm = null;
+ var popup = _popupForm;
+ popup.FormClosed += (_, _) =>
+ {
+ if (popup.ClosedByOutsideClick) _lastOutsideDismiss = Environment.TickCount64;
+ if (ReferenceEquals(_popupForm, popup)) _popupForm = null;
+ };
_popupForm.ShowNearCursor();
+ _ = RefreshPopupTokensAsync(popup);
_ = RefreshActiveAccountForPopupAsync();
_ = RefreshCodexRadarForPopupAsync();
}
+ private async Task RefreshPopupTokensAsync(TrayPopupForm popup)
+ {
+ try
+ {
+ if (DateTimeOffset.UtcNow - _popupScannedAt < TimeSpan.FromSeconds(30)) return;
+ _popupScanTask ??= Task.Run(TokenUsageScanService.Scan);
+ var summary = await _popupScanTask;
+ _popupScanTask = null;
+ _popupScannedAt = DateTimeOffset.UtcNow;
+ _popupTokenSummary = summary;
+ if (!popup.IsDisposed) popup.UpdateSnapshot(_registry.ActiveAccountId, summary, _configStore.Config, _registry.Accounts);
+ }
+ catch (Exception ex) { _popupScanTask = null; AppLogService.LogException(ex, "扫描托盘 Token 用量"); }
+ }
+
private async Task RefreshCodexRadarForPopupAsync()
{
try
{
- var prediction = await _codexRadarService.GetCurrentAsync(forceRefresh: true).ConfigureAwait(true);
+ var prediction = await _codexRadarService.GetCurrentAsync(forceRefresh: false).ConfigureAwait(true);
if (_popupForm is { IsDisposed: false, Visible: true })
{
_popupForm.UpdateRadarPrediction(prediction);
@@ -1149,7 +1252,7 @@ private void RebuildMenu()
? "WinCodexBar"
: "WinCodexBar · 已激活账号";
*/
- _notifyIcon.Icon = CreateTrayIcon(ResolveTrayUsage());
+ UpdateTrayIcon();
RefreshDashboardIfCreated();
}
@@ -1157,7 +1260,7 @@ private void RebuildMenu()
private void RefreshShellOnly(bool refreshPopup = false)
{
_notifyIcon.Text = BuildTrayHoverText();
- _notifyIcon.Icon = CreateTrayIcon(ResolveTrayUsage());
+ UpdateTrayIcon();
if (refreshPopup && _popupForm is { IsDisposed: false, Visible: true })
{
_popupForm.UpdateSnapshot(_registry.ActiveAccountId, null, _configStore.Config, _registry.Accounts);
@@ -1234,7 +1337,7 @@ private string BuildTrayHoverText(TokenAccount? active = null)
var mode = _configStore.Config.OpenAI.UsageDisplayMode;
var label = mode == UsageDisplayMode.Remaining ? "剩余" : "已用";
- var text = $"{label} · 5h {AccountUsageHelpers.FormatDisplayPercent(active.PrimaryUsedPercent, mode)} · 7d {AccountUsageHelpers.FormatDisplayPercent(active.SecondaryUsedPercent, mode)}";
+ var text = $"{label} · {AccountUsageHelpers.UsageText(active, mode)}";
return text.Length <= 63 ? text : text[..63];
}
@@ -1269,13 +1372,13 @@ private async Task RefreshUsageInBackgroundAsync(bool includeInactiveStale = tru
if (!report.AnyChanged)
{
- _notifyIcon.Icon = CreateTrayIcon(ResolveTrayUsage());
+ UpdateTrayIcon();
_notifyIcon.Text = BuildTrayHoverText();
RefreshDashboardIfCreated();
return;
}
- _notifyIcon.Icon = CreateTrayIcon(ResolveTrayUsage());
+ UpdateTrayIcon();
_notifyIcon.Text = BuildTrayHoverText();
RefreshDashboardIfCreated();
RebuildMenu();
@@ -1317,11 +1420,12 @@ private static bool ShouldRefreshAccount(TokenAccount account, TimeSpan staleAft
if (active is not null)
{
- return (active.PrimaryUsedPercent, active.SecondaryUsedPercent, true);
+ var windows = AccountUsageHelpers.Windows(active);
+ return (windows.FirstOrDefault()?.UsedPercent, windows.FirstOrDefault(w => w.Label == "7d")?.UsedPercent, true);
}
var (primaryAvg, secondaryAvg, _, _, _, _) = CalculateUsageStats(_registry.Accounts);
- return (primaryAvg, secondaryAvg, false);
+ return (primaryAvg ?? secondaryAvg, secondaryAvg, false);
}
private static string BuildAccountLabel(TokenAccount account)
@@ -1340,15 +1444,7 @@ private static string BuildAccountLabel(TokenAccount account)
private static string BuildUsageText(TokenAccount account)
{
- var primary = NormalizeUsagePercent(account.PrimaryUsedPercent, "P");
- var secondary = NormalizeUsagePercent(account.SecondaryUsedPercent, "S");
-
- if (primary.hasValue == false && secondary.hasValue == false)
- {
- return "用量: 未统计";
- }
-
- return $"P:{BuildUsageBar(primary.percent)} {primary.valueText} | S:{BuildUsageBar(secondary.percent)} {secondary.valueText}";
+ return AccountUsageHelpers.UsageText(account, UsageDisplayMode.Used);
}
private static string BuildUsageBar(double? value, int width = 8)
@@ -1397,8 +1493,7 @@ private static string BuildAccountTooltip(TokenAccount account)
$"OpenAI ID: {account.OpenAIAccountId}",
$"计划: {account.PlanType}",
$"邮箱: {account.Email}",
- $"主池: {(HasUsageValue(account.PrimaryUsedPercent) ? ClampUsage(account.PrimaryUsedPercent).ToString("F1", CultureInfo.InvariantCulture) + "%" : "--")}",
- $"次池: {(HasUsageValue(account.SecondaryUsedPercent) ? ClampUsage(account.SecondaryUsedPercent).ToString("F1", CultureInfo.InvariantCulture) + "%" : "--")}",
+ AccountUsageHelpers.UsageText(account, UsageDisplayMode.Used),
$"过期: {usedAt}",
};
@@ -1408,12 +1503,12 @@ private static string BuildAccountTooltip(TokenAccount account)
private static (double? avgPrimary, double? avgSecondary, double? minPrimary, double? minSecondary, double? maxPrimary, double? maxSecondary) CalculateUsageStats(IReadOnlyList accounts)
{
var primaryValues = accounts
- .Select(a => a.PrimaryUsedPercent)
+ .SelectMany(AccountUsageHelpers.Windows).Where(w => w.Label == "5h").Select(w => w.UsedPercent)
.Where(HasUsageValue)
.Select(ClampUsage)
.ToArray();
var secondaryValues = accounts
- .Select(a => a.SecondaryUsedPercent)
+ .SelectMany(AccountUsageHelpers.Windows).Where(w => w.Label == "7d").Select(w => w.UsedPercent)
.Where(HasUsageValue)
.Select(ClampUsage)
.ToArray();
@@ -1481,12 +1576,25 @@ private static Image GenerateDotIcon(Color baseColor, Color usageColor, bool act
return bitmap;
}
- private static Icon CreateTrayIcon((double? primary, double? secondary, bool hasActive) usage)
+ private void UpdateTrayIcon()
{
- var size = 32;
+ var usage = ResolveTrayUsage();
+ var key = $"{_configStore.Config.TrayIconStyle}:{usage.primary:F0}:{usage.hasActive}";
+ if (_lastTrayIconKey == key) return;
+ _lastTrayIconKey = key;
+ var previous = _notifyIcon.Icon;
+ _notifyIcon.Icon = CreateTrayIcon(usage);
+ previous?.Dispose();
+ }
+
+ private Icon CreateTrayIcon((double? primary, double? secondary, bool hasActive) usage)
+ {
+ if (_configStore.Config.TrayIconStyle == "classic") return AppIconProvider.CreateWindowIcon() ?? (Icon)SystemIcons.Application.Clone();
+ var size = 64;
using var bmp = new Bitmap(size, size);
using var g = Graphics.FromImage(bmp);
g.Clear(Color.Transparent);
+ g.ScaleTransform(2, 2);
g.SmoothingMode = SmoothingMode.AntiAlias;
g.CompositingQuality = CompositingQuality.HighQuality;
@@ -1523,6 +1631,16 @@ private static Icon CreateTrayIcon((double? primary, double? secondary, bool has
g.FillEllipse(center, 11.5f, 11.5f, 9f, 9f);
}
+ if (_configStore.Config.TrayIconStyle == "percent")
+ {
+ g.Clear(Color.Transparent);
+ using var bg = new SolidBrush(Color.FromArgb(245, 248, 252));
+ g.FillEllipse(bg, 0, 0, 32, 32);
+ using var font = new Font("Segoe UI", hasUsage && primary == 0 ? 13 : 17, FontStyle.Bold, GraphicsUnit.Pixel);
+ using var brush = new SolidBrush(accent);
+ using var format = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
+ g.DrawString(hasUsage ? Math.Round(100 - primary).ToString(CultureInfo.InvariantCulture) : "–", font, brush, new RectangleF(0, 0, 32, 32), format);
+ }
var handle = bmp.GetHicon();
try
{
@@ -1554,22 +1672,7 @@ private void ShowQuickStats()
private static string BuildUsageTooltip(TokenAccount account)
{
- return string.Join(Environment.NewLine, new[]
- {
- $"5小时:{FormatUsagePercent(account.PrimaryUsedPercent)}",
- $"7天:{FormatUsagePercent(account.SecondaryUsedPercent)}",
- $"5小时重置:{FormatReset(account.PrimaryResetAt)}",
- $"7天重置:{FormatReset(account.SecondaryResetAt)}",
- });
- /*
- return string.Join(Environment.NewLine, new[]
- {
- $"主池:已用 {FormatUsagePercent(account.PrimaryUsedPercent)} · 剩余 {FormatRemainingPercent(account.PrimaryUsedPercent)}",
- $"次池:已用 {FormatUsagePercent(account.SecondaryUsedPercent)} · 剩余 {FormatRemainingPercent(account.SecondaryUsedPercent)}",
- $"主池重置:{FormatReset(account.PrimaryResetAt)}",
- $"次池重置:{FormatReset(account.SecondaryResetAt)}",
- });
- */
+ return AccountUsageHelpers.DetailsText(account, UsageDisplayMode.Used);
}
private static string FormatUsagePercent(double value)
diff --git a/windows/CodexBarWin/UI/AccountDetailsForm.cs b/windows/CodexBarWin/UI/AccountDetailsForm.cs
new file mode 100644
index 0000000..f892959
--- /dev/null
+++ b/windows/CodexBarWin/UI/AccountDetailsForm.cs
@@ -0,0 +1,37 @@
+using CodexBarWin.Models;
+
+namespace CodexBarWin.UI;
+
+public sealed class AccountDetailsForm : AdaptiveForm
+{
+ public AccountDetailsForm(TokenAccount account, UsageDisplayMode mode)
+ {
+ Text = "账号额度与重置卡";
+ Font = FluentTheme.TextFontPx(14);
+ BackColor = FluentTheme.LayerBackground;
+ Size = new Size(680, 600);
+ MinimumSize = new Size(380, 300);
+ StartPosition = FormStartPosition.CenterParent;
+ AppIconProvider.Apply(this);
+ var root = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(20), ColumnCount = 1, RowCount = 3 };
+ root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
+ root.RowStyles.Add(new RowStyle(SizeType.Absolute, 48));
+ root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
+ root.RowStyles.Add(new RowStyle(SizeType.Absolute, 52));
+ InstallScrollableContent(root, Size.Empty);
+ root.Controls.Add(new Label { Text = "额度与重置卡", Dock = DockStyle.Fill, Font = FluentTheme.TextFontPx(24, FontStyle.Bold), TextAlign = ContentAlignment.MiddleLeft }, 0, 0);
+ var text = new TextBox
+ {
+ Text = AccountUsageHelpers.DetailsText(account, mode), Dock = DockStyle.Fill,
+ ReadOnly = true, Multiline = true, WordWrap = true, ScrollBars = ScrollBars.Vertical,
+ BorderStyle = BorderStyle.None, BackColor = FluentTheme.CardBackground, ForeColor = FluentTheme.TextPrimary,
+ Font = FluentTheme.TextFontPx(14), TabStop = true,
+ };
+ root.Controls.Add(text, 0, 1);
+ var close = new FluentButton { Text = "关闭", Dock = DockStyle.Fill, Margin = new Padding(0, 12, 0, 0), DialogResult = DialogResult.Cancel };
+ FluentTheme.ApplyButton(close, primary: true);
+ root.Controls.Add(close, 0, 2);
+ CancelButton = close;
+ Shown += (_, _) => { text.Select(0, 0); close.Focus(); };
+ }
+}
diff --git a/windows/CodexBarWin/UI/AdaptiveForm.cs b/windows/CodexBarWin/UI/AdaptiveForm.cs
new file mode 100644
index 0000000..091a973
--- /dev/null
+++ b/windows/CodexBarWin/UI/AdaptiveForm.cs
@@ -0,0 +1,80 @@
+using System.Drawing;
+using System.Windows.Forms;
+
+namespace CodexBarWin.UI;
+
+/// 以 96 DPI 为设计基准,小屏保留滚动入口,窗口始终位于当前显示器工作区。
+public class AdaptiveForm : Form
+{
+ private Panel? _viewport;
+ private Control? _content;
+ private Size _logicalMinimum;
+ private float _manualFactor = 1f;
+
+ protected AdaptiveForm()
+ {
+ AutoScaleDimensions = new SizeF(96, 96);
+ AutoScaleMode = AutoScaleMode.None;
+ }
+
+ protected void InstallScrollableContent(Control content, Size logicalMinimum)
+ {
+ _logicalMinimum = logicalMinimum;
+ _content = content;
+ _viewport = new Panel { Dock = DockStyle.Fill, AutoScroll = logicalMinimum != Size.Empty, BackColor = Color.Transparent };
+ content.Dock = logicalMinimum == Size.Empty ? DockStyle.Fill : DockStyle.None;
+ content.Margin = Padding.Empty;
+ content.Anchor = AnchorStyles.Top | AnchorStyles.Left;
+ _viewport.Controls.Add(content);
+ Controls.Add(_viewport);
+ _viewport.ClientSizeChanged += (_, _) => LayoutContent();
+ LayoutContent();
+ }
+
+ private void LayoutContent()
+ {
+ if (_viewport is null || _content is null) return;
+ if (_logicalMinimum == Size.Empty) return;
+ var scale = AppAppearance.ScaleFor(this);
+ var minimum = new Size((int)(_logicalMinimum.Width * scale), (int)(_logicalMinimum.Height * scale));
+ var size = new Size(Math.Max(minimum.Width, _viewport.ClientSize.Width), Math.Max(minimum.Height, _viewport.ClientSize.Height));
+ if (_content.Size != size) _content.Size = size;
+ _viewport.AutoScrollMinSize = minimum;
+ }
+
+ protected override void OnShown(EventArgs e)
+ {
+ base.OnShown(e);
+ ApplyAppearance();
+ }
+
+ protected override void OnDpiChanged(DpiChangedEventArgs e)
+ {
+ base.OnDpiChanged(e);
+ ApplyAppearance();
+ }
+
+ public virtual void ApplyAppearance()
+ {
+ // 统一由此处缩放,避免 WinForms 在 FontChanged 时再次缩放同一棵控件树。
+ var factor = AppAppearance.ScaleFor(this);
+ var relative = factor / _manualFactor;
+ _manualFactor = factor;
+ AppAppearance.ScaleTree(this, relative);
+ FitToScreen();
+ LayoutContent();
+ Invalidate(true);
+ }
+
+ public void FitToScreen()
+ {
+ if (WindowState != FormWindowState.Normal) return;
+ var area = Screen.FromControl(this).WorkingArea;
+ var margin = Math.Min(8, Math.Min(area.Width, area.Height) / 10);
+ area.Inflate(-margin, -margin);
+ MinimumSize = new Size(Math.Min(MinimumSize.Width, area.Width), Math.Min(MinimumSize.Height, area.Height));
+ var width = Math.Min(Width, area.Width);
+ var height = Math.Min(Height, area.Height);
+ Bounds = new Rectangle(Math.Clamp(Left, area.Left, area.Right - width), Math.Clamp(Top, area.Top, area.Bottom - height), width, height);
+ }
+}
diff --git a/windows/CodexBarWin/UI/AppAppearance.cs b/windows/CodexBarWin/UI/AppAppearance.cs
new file mode 100644
index 0000000..ded74aa
--- /dev/null
+++ b/windows/CodexBarWin/UI/AppAppearance.cs
@@ -0,0 +1,21 @@
+namespace CodexBarWin.UI;
+
+public static class AppAppearance
+{
+ // 0 跟随显示器;其他值为用户选择的绝对缩放百分比。
+ public static int ScalePercent { get; set; }
+ public static void ScaleTree(Control root, float factor)
+ {
+ if (Math.Abs(factor - 1f) < 0.001f) return;
+ static IEnumerable Tree(Control c) => new[] { c }.Concat(c.Controls.Cast().SelectMany(Tree));
+ // Control.Scale 调整几何,不保证显式指定的字体随之变化;先取快照避免继承字体重复放大。
+ var fonts = Tree(root).Select(c => (Control: c, Font: c.Font)).ToArray();
+ root.Scale(new SizeF(factor, factor));
+ foreach (var item in fonts)
+ item.Control.Font = new Font(item.Font.FontFamily, item.Font.Size * factor, item.Font.Style, item.Font.Unit);
+ // Scale 递归时子控件可能已被父布局调整,随后又被放大。最后按父到子的顺序重新 Dock。
+ foreach (var item in fonts) item.Control.PerformLayout();
+ }
+
+ public static float ScaleFor(Control control) => ScalePercent == 0 ? control.DeviceDpi / 96f : ScalePercent / 100f;
+}
diff --git a/windows/CodexBarWin/UI/CodexBarDashboardForm.cs b/windows/CodexBarWin/UI/CodexBarDashboardForm.cs
index 1899005..1c299bf 100644
--- a/windows/CodexBarWin/UI/CodexBarDashboardForm.cs
+++ b/windows/CodexBarWin/UI/CodexBarDashboardForm.cs
@@ -12,7 +12,7 @@
namespace CodexBarWin.UI;
-public sealed class CodexBarDashboardForm : Form
+public sealed class CodexBarDashboardForm : AdaptiveForm
{
private enum DashboardView
{
@@ -29,6 +29,7 @@ private enum DashboardView
private readonly Action _importRequested;
private readonly Action _exportRequested;
private readonly Action _openSettingsRequested;
+ private readonly Action? _deleteAccountRequested;
private readonly Func _isKeepAwakeEnabled;
private readonly Action _setKeepAwakeEnabled;
private readonly ToolTip _toolTip = new() { InitialDelay = 320, ReshowDelay = 120, AutoPopDelay = 4000 };
@@ -67,8 +68,10 @@ public CodexBarDashboardForm(
Action exportRequested,
Action openSettingsRequested,
Func isKeepAwakeEnabled,
- Action setKeepAwakeEnabled)
+ Action setKeepAwakeEnabled,
+ Action? deleteAccountRequested = null)
{
+ _deleteAccountRequested = deleteAccountRequested;
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
_configStore = configStore ?? throw new ArgumentNullException(nameof(configStore));
_refreshCoordinator = refreshCoordinator ?? throw new ArgumentNullException(nameof(refreshCoordinator));
@@ -82,9 +85,9 @@ public CodexBarDashboardForm(
Text = "WinCodexBar 工作台";
Font = FluentTheme.TextFontPx(14);
- AutoScaleMode = AutoScaleMode.Dpi;
+ AutoScaleMode = AutoScaleMode.None;
StartPosition = FormStartPosition.CenterScreen;
- MinimumSize = new Size(1120, 860);
+ MinimumSize = new Size(640, 420);
Size = new Size(1280, 960);
BackColor = FluentTheme.LayerBackground;
AppIconProvider.Apply(this);
@@ -156,12 +159,12 @@ private void BuildLayout()
ColumnCount = 1,
RowCount = 5,
};
- root.RowStyles.Add(new RowStyle(SizeType.Absolute, 148));
+ root.RowStyles.Add(new RowStyle(SizeType.Absolute, 164));
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 52));
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 46));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 58));
- Controls.Add(root);
+ InstallScrollableContent(root, new Size(880, 620));
root.Controls.Add(BuildHeader(), 0, 0);
root.Controls.Add(BuildToolbar(), 0, 1);
@@ -237,18 +240,15 @@ private Control BuildHeader()
};
titlePanel.Controls.Add(provider, 1, 0);
- var metrics = new FlowLayoutPanel
+ var metrics = new TableLayoutPanel
{
- Dock = DockStyle.Fill,
- FlowDirection = FlowDirection.LeftToRight,
- WrapContents = true,
- BackColor = Color.Transparent,
+ Dock = DockStyle.Fill, BackColor = Color.Transparent, ColumnCount = 4, RowCount = 1,
Padding = new Padding(0, 8, 0, 0),
};
- metrics.Controls.Add(MetricCard("账号", _countLabel, 140));
- metrics.Controls.Add(MetricCard("当前账号", _activeLabel, 300));
- metrics.Controls.Add(MetricCard("用量", _usageLabel, 270));
- metrics.Controls.Add(MetricCard("健康", _healthLabel, 220));
+ foreach (var weight in new[] { 14f, 32f, 30f, 24f }) metrics.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, weight));
+ var cards = new[] { MetricCard("账号", _countLabel, 140), MetricCard("当前账号", _activeLabel, 280),
+ MetricCard("用量", _usageLabel, 270), MetricCard("健康", _healthLabel, 220) };
+ for (var i = 0; i < cards.Length; i++) { cards[i].Dock = DockStyle.Fill; metrics.Controls.Add(cards[i], i, 0); }
header.Controls.Add(metrics, 0, 1);
return header;
@@ -307,7 +307,7 @@ private Control BuildToolbar()
Padding = new Padding(0, 9, 0, 7),
};
toolbar.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
- toolbar.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 320));
+ toolbar.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 160));
var commands = new FlowLayoutPanel
{
@@ -616,7 +616,7 @@ private Control BuildFooter()
Padding = new Padding(0, 12, 0, 0),
};
footer.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
- footer.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 148));
+ footer.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 164));
footer.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 116));
var note = new Label
@@ -687,6 +687,7 @@ private void RefreshAccountListOnly()
var accounts = OrderedAccounts(_registry.Accounts.ToArray()).ToArray();
_accountList.SuspendLayout();
+ foreach (Control old in _accountList.Controls.Cast().ToArray()) old.Dispose();
_accountList.Controls.Clear();
if (accounts.Length == 0)
{
@@ -696,7 +697,9 @@ private void RefreshAccountListOnly()
{
foreach (var account in accounts)
{
- _accountList.Controls.Add(BuildAccountRow(account, config));
+ var row = BuildAccountRow(account, config);
+ if (IsHandleCreated) { var scale = AppAppearance.ScaleFor(this); AppAppearance.ScaleTree(row, scale); }
+ _accountList.Controls.Add(row);
}
}
_accountList.ResumeLayout();
@@ -779,7 +782,7 @@ private Control BuildAccountRow(TokenAccount account, CodexBarConfig config)
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 86));
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 260));
- layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 144));
+ layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 216));
row.Controls.Add(layout);
var marker = new Label
@@ -800,6 +803,7 @@ private Control BuildAccountRow(TokenAccount account, CodexBarConfig config)
RowCount = 3,
ColumnCount = 1,
};
+ accountInfo.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
accountInfo.RowStyles.Add(new RowStyle(SizeType.Absolute, 32));
accountInfo.RowStyles.Add(new RowStyle(SizeType.Absolute, 24));
accountInfo.RowStyles.Add(new RowStyle(SizeType.Absolute, 22));
@@ -867,12 +871,13 @@ private Control MakeUsageBlock(TokenAccount account, CodexBarConfig config)
ColumnCount = 1,
Padding = new Padding(0, 2, 0, 0),
};
+ panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 28));
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 24));
panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 20));
panel.Controls.Add(new Label
{
- Text = $"5h {AccountUsageHelpers.FormatDisplayPercent(account.PrimaryUsedPercent, config.OpenAI.UsageDisplayMode)} · 7d {AccountUsageHelpers.FormatDisplayPercent(account.SecondaryUsedPercent, config.OpenAI.UsageDisplayMode)}",
+ Text = AccountUsageHelpers.UsageText(account, config.OpenAI.UsageDisplayMode),
Dock = DockStyle.Fill,
Font = FluentTheme.TextFontPx(16, FontStyle.Bold),
ForeColor = FluentTheme.TextPrimary,
@@ -882,7 +887,7 @@ private Control MakeUsageBlock(TokenAccount account, CodexBarConfig config)
}, 0, 0);
panel.Controls.Add(new Label
{
- Text = $"重置 5h {AccountUsageHelpers.FormatResetCountdown(account.PrimaryResetAt)} · 7d {AccountUsageHelpers.FormatResetCountdown(account.SecondaryResetAt)}",
+ Text = "重置 " + AccountUsageHelpers.ResetText(account),
Dock = DockStyle.Fill,
Font = FluentTheme.TextFontPx(13),
ForeColor = FluentTheme.TextSecondary,
@@ -890,16 +895,16 @@ private Control MakeUsageBlock(TokenAccount account, CodexBarConfig config)
AutoEllipsis = true,
TextAlign = ContentAlignment.MiddleLeft,
}, 0, 1);
- panel.Controls.Add(new Label
+ var details = new LinkLabel
{
- Text = "刷新 " + AccountUsageHelpers.FormatLastChecked(account.LastChecked),
- Dock = DockStyle.Fill,
- Font = FluentTheme.TextFontPx(13),
- ForeColor = FluentTheme.TextTertiary,
- BackColor = Color.Transparent,
- AutoEllipsis = true,
- TextAlign = ContentAlignment.MiddleLeft,
- }, 0, 2);
+ Text = AccountUsageHelpers.CreditSummary(account) + " · 详情",
+ Dock = DockStyle.Fill, Font = FluentTheme.TextFontPx(13),
+ LinkColor = FluentTheme.Accent, BackColor = Color.Transparent, LinkBehavior = LinkBehavior.HoverUnderline,
+ AutoEllipsis = true, TextAlign = ContentAlignment.MiddleLeft, Cursor = Cursors.Hand, TabStop = true,
+ };
+ _toolTip.SetToolTip(details, AccountUsageHelpers.DetailsText(account, config.OpenAI.UsageDisplayMode));
+ details.LinkClicked += (_, _) => { using var form = new AccountDetailsForm(account, config.OpenAI.UsageDisplayMode); form.ShowDialog(this); };
+ panel.Controls.Add(details, 0, 2);
return panel;
}
@@ -913,6 +918,14 @@ private Control MakeRowActions(TokenAccount account, bool isActive)
BackColor = Color.Transparent,
Padding = new Padding(0, 26, 0, 0),
};
+ if (_deleteAccountRequested is not null)
+ {
+ var delete = new FluentButton();
+ ConfigureButton(delete, "删除", false, 54);
+ delete.ForeColor = FluentTheme.Critical;
+ delete.Click += (_, _) => _deleteAccountRequested(account);
+ panel.Controls.Add(delete);
+ }
var refresh = new FluentButton();
ConfigureButton(refresh, "刷新", false, 54);
_toolTip.SetToolTip(refresh, "刷新此账号");
@@ -988,8 +1001,7 @@ private async Task RefreshSingleAsync(TokenAccount account, Button trigger)
}
finally
{
- trigger.Text = oldText;
- trigger.Enabled = true;
+ if (!trigger.IsDisposed) { trigger.Text = oldText; trigger.Enabled = true; }
}
}
@@ -1028,9 +1040,7 @@ private static string BuildUsageSummary(IReadOnlyCollection accoun
return "暂无账号";
}
- var primary = accounts.Select(a => AccountUsageHelpers.Clamp(a.PrimaryUsedPercent)).DefaultIfEmpty(0).Average();
- var secondary = accounts.Select(a => AccountUsageHelpers.Clamp(a.SecondaryUsedPercent)).DefaultIfEmpty(0).Average();
- return $"5h {AccountUsageHelpers.DisplayPercent(primary, config.OpenAI.UsageDisplayMode):F1}% · 7d {AccountUsageHelpers.DisplayPercent(secondary, config.OpenAI.UsageDisplayMode):F1}%";
+ return AccountUsageHelpers.AverageText(accounts, config.OpenAI.UsageDisplayMode);
}
private static string LatestRefreshNote(IReadOnlyCollection accounts)
diff --git a/windows/CodexBarWin/UI/ConfirmActionDialog.cs b/windows/CodexBarWin/UI/ConfirmActionDialog.cs
index ead033a..8f99d21 100644
--- a/windows/CodexBarWin/UI/ConfirmActionDialog.cs
+++ b/windows/CodexBarWin/UI/ConfirmActionDialog.cs
@@ -4,7 +4,7 @@
namespace CodexBarWin.UI;
-public sealed class ConfirmActionDialog : Form
+public sealed class ConfirmActionDialog : AdaptiveForm
{
public ConfirmActionDialog(string title, string message, string confirmText = "确认")
{
@@ -12,7 +12,7 @@ public ConfirmActionDialog(string title, string message, string confirmText = "
Font = FluentTheme.TextFontPx(13);
BackColor = FluentTheme.LayerBackground;
Width = 420;
- Height = 210;
+ Height = 300;
MinimumSize = new Size(380, 190);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
@@ -28,10 +28,11 @@ public ConfirmActionDialog(string title, string message, string confirmText = "
RowCount = 3,
ColumnCount = 1,
};
+ root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 38));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 42));
- Controls.Add(root);
+ InstallScrollableContent(root, new Size(400, 240));
root.Controls.Add(new Label
{
diff --git a/windows/CodexBarWin/UI/EmbeddedFonts.cs b/windows/CodexBarWin/UI/EmbeddedFonts.cs
new file mode 100644
index 0000000..a1603cf
--- /dev/null
+++ b/windows/CodexBarWin/UI/EmbeddedFonts.cs
@@ -0,0 +1,39 @@
+using System.Drawing.Text;
+using System.Reflection;
+using System.Runtime.InteropServices;
+
+namespace CodexBarWin.UI;
+
+internal static class EmbeddedFonts
+{
+ private static readonly PrivateFontCollection Collection = new();
+ private static readonly List Buffers = new();
+ public static readonly FontFamily Icons = Load("FluentSystemIcons-Regular.ttf");
+ public static readonly FontFamily Text;
+ static EmbeddedFonts()
+ {
+ Load("NotoSansSC-Regular.ttf");
+ Load("NotoSansSC-Bold.ttf");
+ Text = Collection.Families.First(f => f.Name == "WinCodexBar Sans");
+ }
+
+ private static FontFamily Load(string filename)
+ {
+ var assembly = typeof(EmbeddedFonts).Assembly;
+ var name = assembly.GetManifestResourceNames().Single(n => n.EndsWith(filename, StringComparison.Ordinal));
+ using var stream = assembly.GetManifestResourceStream(name)!;
+ using var memory = new MemoryStream(); stream.CopyTo(memory);
+ var bytes = memory.ToArray();
+ var pointer = Marshal.AllocHGlobal(bytes.Length);
+ Marshal.Copy(bytes, 0, pointer, bytes.Length);
+ Buffers.Add(pointer); // 字体系统在进程生命周期内引用该内存。
+ Collection.AddMemoryFont(pointer, bytes.Length);
+ uint count = 0;
+ var handle = AddFontMemResourceEx(pointer, (uint)bytes.Length, IntPtr.Zero, ref count);
+ if (handle == IntPtr.Zero || count == 0) throw new InvalidOperationException("无法注册内置字体: " + filename);
+ return Collection.Families.First(f => filename.StartsWith("Fluent", StringComparison.Ordinal) ? f.Name.Contains("Fluent") : f.Name.Contains("WinCodexBar Sans"));
+ }
+
+ [DllImport("gdi32.dll")]
+ private static extern IntPtr AddFontMemResourceEx(IntPtr memory, uint size, IntPtr reserved, ref uint count);
+}
diff --git a/windows/CodexBarWin/UI/FluentButton.cs b/windows/CodexBarWin/UI/FluentButton.cs
index 4face6f..dff9209 100644
--- a/windows/CodexBarWin/UI/FluentButton.cs
+++ b/windows/CodexBarWin/UI/FluentButton.cs
@@ -10,6 +10,8 @@ internal sealed class FluentButton : Button
private bool _pressed;
public bool Primary { get; set; }
+ public string? IconGlyph { get; set; }
+ public bool NavigationSelected { get; set; }
public int CornerRadius { get; set; } = 6;
@@ -28,10 +30,10 @@ public FluentButton()
UseVisualStyleBackColor = false;
Cursor = Cursors.Hand;
TextAlign = ContentAlignment.MiddleCenter;
- TabStop = false;
+ TabStop = true;
}
- protected override bool ShowFocusCues => false;
+ protected override bool ShowFocusCues => base.ShowFocusCues;
protected override void OnMouseEnter(System.EventArgs e)
{
@@ -98,11 +100,27 @@ protected override void OnPaint(PaintEventArgs pevent)
var textColor = Enabled
? Primary ? FluentTheme.TextOnAccent : ForeColor
: FluentTheme.TextTertiary;
- var flags = TextFormatFlags.HorizontalCenter
+ var textBounds = ClientRectangle;
+ var alignment = TextAlign == ContentAlignment.MiddleLeft ? TextFormatFlags.Left : TextFormatFlags.HorizontalCenter;
+ if (!string.IsNullOrEmpty(IconGlyph))
+ {
+ var scale = AppAppearance.ScaleFor(this);
+ using var iconFont = FluentTheme.IconFontPx(20 * scale);
+ TextRenderer.DrawText(g, IconGlyph, iconFont, new Rectangle((int)(12 * scale), 0, (int)(24 * scale), Height), textColor, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
+ textBounds = new Rectangle((int)(44 * scale), 0, Math.Max(1, Width - (int)(52 * scale)), Height);
+ }
+ if (NavigationSelected)
+ {
+ using var accent = new SolidBrush(FluentTheme.Accent);
+ var scale = AppAppearance.ScaleFor(this);
+ g.FillRectangle(accent, 0, Height * .28f, 3 * scale, Height * .44f);
+ }
+ var flags = alignment
| TextFormatFlags.VerticalCenter
| TextFormatFlags.EndEllipsis
| TextFormatFlags.NoPrefix;
- TextRenderer.DrawText(g, Text, Font, ClientRectangle, textColor, flags);
+ TextRenderer.DrawText(g, Text, Font, textBounds, textColor, flags);
+ if (Focused && ShowFocusCues) ControlPaint.DrawFocusRectangle(g, Rectangle.Inflate(ClientRectangle, -4, -4));
}
private Color ResolveFill()
diff --git a/windows/CodexBarWin/UI/FluentComboBox.cs b/windows/CodexBarWin/UI/FluentComboBox.cs
index b1b9159..816bbe7 100644
--- a/windows/CodexBarWin/UI/FluentComboBox.cs
+++ b/windows/CodexBarWin/UI/FluentComboBox.cs
@@ -16,6 +16,12 @@ public FluentComboBox()
Cursor = Cursors.Hand;
}
+ protected override void OnFontChanged(EventArgs e)
+ {
+ base.OnFontChanged(e);
+ ItemHeight = Math.Max(24, Font.Height + 8);
+ }
+
protected override void OnDrawItem(DrawItemEventArgs e)
{
e.DrawBackground();
diff --git a/windows/CodexBarWin/UI/FluentIcons.cs b/windows/CodexBarWin/UI/FluentIcons.cs
index e1e43d3..4072e36 100644
--- a/windows/CodexBarWin/UI/FluentIcons.cs
+++ b/windows/CodexBarWin/UI/FluentIcons.cs
@@ -1,57 +1,54 @@
namespace CodexBarWin.UI;
-///
-/// Segoe Fluent Icons 常用 code points。
-/// 用 Segoe Fluent Icons 字体绘制时,把这些字符串当 Text 用。
-///
+/// Microsoft Fluent UI System Icons Regular,随包嵌入,独立于系统字形。
internal static class FluentIcons
{
- public const string Home = "\uE80F";
- public const string Settings = "\uE713";
- public const string Refresh = "\uE72C";
- public const string Add = "\uE710";
- public const string Save = "\uE74E";
- public const string Delete = "\uE74D";
- public const string Download = "\uE896";
- public const string Upload = "\uE898";
- public const string Import = "\uE8B5";
- public const string Export = "\uEDE1";
- public const string Cancel = "\uE711";
- public const string Close = "\uE8BB";
- public const string Chart = "\uE9D9";
- public const string Insights = "\uF4A0";
- public const string Power = "\uE7E8";
- public const string Brightness = "\uE706";
- public const string Coffee = "\uEC32";
- public const string CheckMark = "\uE73E";
- public const string Accept = "\uE8FB";
- public const string Account = "\uE77B";
- public const string Contact = "\uE779";
- public const string People = "\uE716";
- public const string Folder = "\uE8B7";
- public const string FolderOpen = "\uE838";
- public const string Globe = "\uE774";
- public const string Cloud = "\uE753";
- public const string Lock = "\uE72E";
- public const string Unlock = "\uE785";
- public const string Sync = "\uE895";
- public const string Switch = "\uE8AB";
- public const string Eye = "\uE7B3";
- public const string EyeHide = "\uED1A";
- public const string Warning = "\uE7BA";
- public const string Error = "\uE783";
- public const string Info = "\uE946";
- public const string Chevron = "\uE76C";
- public const string ChevronDown = "\uE70D";
- public const string ChevronUp = "\uE70E";
- public const string ChevronLeft = "\uE76B";
- public const string ChevronRight = "\uE76C";
- public const string Pin = "\uE718";
- public const string Star = "\uE734";
- public const string Activity = "\uE9F5";
- public const string Heart = "\uEB52";
- public const string Bell = "\uEA8F";
- public const string Plug = "\uF15E";
- public const string SignOut = "\uF3B1";
- public const string LightBulb = "\uEA80";
+ public const string Home = "\uF481";
+ public const string Settings = "\uF6AA";
+ public const string Refresh = "\uF13E";
+ public const string Add = "\uF10A";
+ public const string Save = "\uF680";
+ public const string Delete = "\uF34D";
+ public const string Download = "\uF151";
+ public const string Upload = "\uF1A5";
+ public const string Import = "\uF15A";
+ public const string Export = "\U000F0281";
+ public const string Cancel = "\uF36A";
+ public const string Close = "\uF36A";
+ public const string Chart = "\uE2DE";
+ public const string Insights = "\uE2DE";
+ public const string Power = "\uF60F";
+ public const string Brightness = "\uE1F8";
+ public const string Coffee = "\uF3C5";
+ public const string CheckMark = "\uF295";
+ public const string Accept = "\uF295";
+ public const string Account = "\uF5BE";
+ public const string Contact = "\uF5BE";
+ public const string People = "\uF5A9";
+ public const string Folder = "\uF419";
+ public const string FolderOpen = "\uF42F";
+ public const string Globe = "\uF45B";
+ public const string Cloud = "\uF2E5";
+ public const string Lock = "\uE790";
+ public const string Unlock = "\uE796";
+ public const string Sync = "\uF191";
+ public const string Switch = "\uF18E";
+ public const string Eye = "\uE5F3";
+ public const string EyeHide = "\uE5F6";
+ public const string Warning = "\uF86A";
+ public const string Error = "\uF3F2";
+ public const string Info = "\uF4A4";
+ public const string Chevron = "\uF2B1";
+ public const string ChevronDown = "\uF2A4";
+ public const string ChevronUp = "\uF2B7";
+ public const string ChevronLeft = "\uF2AB";
+ public const string ChevronRight = "\uF2B1";
+ public const string Pin = "\uF602";
+ public const string Star = "\uF710";
+ public const string Activity = "\uE9DD";
+ public const string Heart = "\uF47A";
+ public const string Bell = "\uF115";
+ public const string Plug = "\uE99A";
+ public const string SignOut = "\uF6DA";
+ public const string LightBulb = "\uF4D7";
}
diff --git a/windows/CodexBarWin/UI/FluentTheme.cs b/windows/CodexBarWin/UI/FluentTheme.cs
index 0dc2ac1..61b7fe3 100644
--- a/windows/CodexBarWin/UI/FluentTheme.cs
+++ b/windows/CodexBarWin/UI/FluentTheme.cs
@@ -10,8 +10,8 @@ namespace CodexBarWin.UI;
internal static class FluentTheme
{
// 字体族
- public static readonly string TextFontFamily = ResolveTextFontFamily();
- public static readonly string IconFontFamily = ResolveIconFontFamily();
+ public static readonly string TextFontFamily = EmbeddedFonts.Text.Name;
+ public static readonly string IconFontFamily = EmbeddedFonts.Icons.Name;
// 文本
public static readonly Color TextPrimary = Color.FromArgb(31, 31, 31); // TextFillColorPrimary
@@ -85,21 +85,21 @@ public static void ApplyButton(Button button, bool primary = false)
}
///
- /// 创建文本字体(Segoe UI Variable Text 优先,回落 Segoe UI / Microsoft YaHei UI)。
+ /// 创建内置的中英文无衬线字体,避免系统缺少字体时回退成衬线体。
///
public static Font TextFont(float size, FontStyle style = FontStyle.Regular)
{
- return new Font(TextFontFamily, size, style);
+ return new Font(EmbeddedFonts.Text, size, style);
}
public static Font TextFontPx(float pixelSize, FontStyle style = FontStyle.Regular)
{
- return new Font(TextFontFamily, pixelSize, style, GraphicsUnit.Pixel);
+ return new Font(EmbeddedFonts.Text, pixelSize, style, GraphicsUnit.Pixel);
}
public static Font IconFontPx(float pixelSize)
{
- return new Font(IconFontFamily, pixelSize, FontStyle.Regular, GraphicsUnit.Pixel);
+ return new Font(EmbeddedFonts.Icons, pixelSize, FontStyle.Regular, GraphicsUnit.Pixel);
}
///
@@ -139,34 +139,4 @@ public static GraphicsPath RoundedRectanglePath(RectangleF rect, float radius)
return path;
}
- private static string ResolveTextFontFamily()
- {
- foreach (var name in new[] { "Segoe UI Variable Text", "Segoe UI Variable", "Segoe UI", "Microsoft YaHei UI" })
- {
- if (FontFamilyExists(name)) return name;
- }
- return FontFamily.GenericSansSerif.Name;
- }
-
- private static string ResolveIconFontFamily()
- {
- foreach (var name in new[] { "Segoe Fluent Icons", "Segoe MDL2 Assets" })
- {
- if (FontFamilyExists(name)) return name;
- }
- return "Segoe UI Symbol";
- }
-
- private static bool FontFamilyExists(string name)
- {
- try
- {
- using var family = new FontFamily(name);
- return true;
- }
- catch
- {
- return false;
- }
- }
}
diff --git a/windows/CodexBarWin/UI/FluentToggleSwitch.cs b/windows/CodexBarWin/UI/FluentToggleSwitch.cs
index 2c08735..11d0fc9 100644
--- a/windows/CodexBarWin/UI/FluentToggleSwitch.cs
+++ b/windows/CodexBarWin/UI/FluentToggleSwitch.cs
@@ -19,6 +19,9 @@ public FluentToggleSwitch()
| ControlStyles.OptimizedDoubleBuffer
| ControlStyles.ResizeRedraw
| ControlStyles.SupportsTransparentBackColor, true);
+ SetStyle(ControlStyles.Selectable, true);
+ TabStop = true;
+ AccessibleRole = AccessibleRole.CheckButton;
Height = 28;
Width = 180;
Cursor = Cursors.Hand;
@@ -45,10 +48,16 @@ public bool Checked
protected override void OnClick(EventArgs e)
{
- Checked = !Checked;
+ if (Enabled) Checked = !Checked;
base.OnClick(e);
}
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ if (e.KeyCode == Keys.Space) { OnClick(EventArgs.Empty); e.Handled = true; e.SuppressKeyPress = true; }
+ base.OnKeyDown(e);
+ }
+
protected override void OnMouseEnter(EventArgs e)
{
_hovered = true;
@@ -67,6 +76,9 @@ protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
g.SmoothingMode = SmoothingMode.AntiAlias;
+ var state = g.Save();
+ var scale = AppAppearance.ScaleFor(this);
+ g.ScaleTransform(scale, scale);
var track = new RectangleF(0.5f, 4.5f, 38, 20);
using (var path = FluentTheme.RoundedRectanglePath(track, 10))
using (var fill = new SolidBrush(Checked ? (_hovered ? FluentTheme.AccentHover : FluentTheme.Accent) : Color.FromArgb(232, 232, 232)))
@@ -82,11 +94,12 @@ protected override void OnPaint(PaintEventArgs e)
g.FillEllipse(knob, knobX, 7, 14, 14);
}
+ g.Restore(state);
TextRenderer.DrawText(
g,
Text,
Font,
- new Rectangle(48, 0, Math.Max(0, Width - 48), Height),
+ new Rectangle((int)(48 * scale), 0, Math.Max(0, Width - (int)(48 * scale)), Height),
Enabled ? ForeColor : FluentTheme.TextTertiary,
TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
}
diff --git a/windows/CodexBarWin/UI/MicaSupport.cs b/windows/CodexBarWin/UI/MicaSupport.cs
index 9728cb9..ce18e83 100644
--- a/windows/CodexBarWin/UI/MicaSupport.cs
+++ b/windows/CodexBarWin/UI/MicaSupport.cs
@@ -72,7 +72,9 @@ public static void ApplyRoundedRegion(Form form)
path.AddArc(rect.Right - diameter, rect.Bottom - diameter, diameter, diameter, 0, 90);
path.AddArc(rect.Left, rect.Bottom - diameter, diameter, diameter, 90, 90);
path.CloseFigure();
+ var previous = form.Region;
form.Region = new System.Drawing.Region(path);
+ previous?.Dispose();
}
catch
{
diff --git a/windows/CodexBarWin/UI/OpenAIOAuthLoginForm.cs b/windows/CodexBarWin/UI/OpenAIOAuthLoginForm.cs
index b5103f2..25665c0 100644
--- a/windows/CodexBarWin/UI/OpenAIOAuthLoginForm.cs
+++ b/windows/CodexBarWin/UI/OpenAIOAuthLoginForm.cs
@@ -8,7 +8,7 @@
namespace CodexBarWin.UI;
-public sealed class OpenAIOAuthLoginForm : Form
+public sealed class OpenAIOAuthLoginForm : AdaptiveForm
{
private readonly OpenAIOAuthLoginService _loginService;
private readonly TextBox _authUrlBox = new();
@@ -79,7 +79,7 @@ private void BuildLayout()
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 30));
root.RowStyles.Add(new RowStyle(SizeType.Absolute, 46));
- Controls.Add(root);
+ InstallScrollableContent(root, new Size(600, 440));
root.Controls.Add(new Label
{
diff --git a/windows/CodexBarWin/UI/SettingCard.cs b/windows/CodexBarWin/UI/SettingCard.cs
index 6078d05..4c8bb8c 100644
--- a/windows/CodexBarWin/UI/SettingCard.cs
+++ b/windows/CodexBarWin/UI/SettingCard.cs
@@ -13,6 +13,10 @@ public sealed class SettingCard : Panel
private readonly Label _titleLabel;
private readonly MultilineEllipsisLabel _descriptionLabel;
private Control? _action;
+ private bool _reflowing;
+ private int _logicalActionWidth;
+ private readonly Dictionary _actionWidths = new();
+ public int LogicalMinimumHeight { get; set; } = 118;
public SettingCard(string glyph, string title, string description)
{
@@ -61,7 +65,16 @@ public Control? Action
_action = value;
if (_action != null)
{
- _action.Anchor = AnchorStyles.Top | AnchorStyles.Right;
+ _logicalActionWidth = _action.Width;
+ _actionWidths.Clear();
+ void Remember(Control control)
+ {
+ _actionWidths[control] = control.Width;
+ foreach (Control child in control.Controls) Remember(child);
+ }
+ Remember(_action);
+ _action.Dock = DockStyle.None;
+ _action.Anchor = AnchorStyles.Top | AnchorStyles.Left;
Controls.Add(_action);
LayoutAction();
}
@@ -76,29 +89,79 @@ protected override void OnSizeChanged(System.EventArgs e)
return;
}
- var actionWidth = _action?.Width ?? 0;
- var stackedAction = _action is not null && (Width < 900 && actionWidth > 360);
- var textWidth = stackedAction
- ? Math.Max(220, Width - 98)
- : Math.Max(260, Width - 122 - actionWidth);
+ Reflow();
+ }
- _titleLabel.SetBounds(74, 16, textWidth, 28);
- var descriptionHeight = stackedAction && _action is not null
- ? Math.Max(32, Height - _action.Height - 70)
- : Math.Max(46, Height - 66);
- _descriptionLabel.SetBounds(74, 48, textWidth, descriptionHeight);
- LayoutAction();
+ public void Reflow()
+ {
+ if (_titleLabel is null || _descriptionLabel is null || _reflowing) return;
+ _reflowing = true;
+ try
+ {
+ var scale = AppAppearance.ScaleFor(this);
+ int D(float n) => (int)Math.Round(n * scale);
+ var padding = D(20);
+ var desiredActionWidth = D(_logicalActionWidth);
+ var stacked = _action is not null && Width < desiredActionWidth + D(330);
+ var textLeft = D(60);
+ var textWidth = Math.Max(1, Width - textLeft - padding - (stacked ? 0 : desiredActionWidth + D(24)));
+ var titleHeight = Math.Max(D(26), _titleLabel.Font.Height + D(4));
+ var descriptionHeight = Math.Max(D(24), TextRenderer.MeasureText(_descriptionLabel.Text, _descriptionLabel.Font,
+ new Size(textWidth, int.MaxValue), TextFormatFlags.WordBreak | TextFormatFlags.NoPrefix).Height + D(4));
+ _titleLabel.SetBounds(textLeft, D(16), textWidth, titleHeight);
+ _descriptionLabel.SetBounds(textLeft, D(20) + titleHeight, textWidth, descriptionHeight);
+ var textBottom = _descriptionLabel.Bottom;
+ if (_action is not null)
+ {
+ FitAction(_action, Math.Max(1, Math.Min(desiredActionWidth, Width - padding * 2)));
+ Height = Math.Max(D(LogicalMinimumHeight), stacked ? textBottom + D(16) + _action.Height + padding : Math.Max(textBottom + padding, _action.Height + padding * 2));
+ _action.Location = new Point(stacked ? padding : Width - padding - _action.Width,
+ stacked ? textBottom + D(16) : (Height - _action.Height) / 2);
+ _action.BringToFront();
+ }
+ else Height = Math.Max(D(LogicalMinimumHeight), textBottom + padding);
+ }
+ finally { _reflowing = false; }
}
- private void LayoutAction()
+ private void FitAction(Control action, int width)
{
- if (_action == null) return;
- var stacked = Width < 900 && _action.Width > 360;
- var x = stacked ? 74 : Width - 24 - _action.Width;
- var y = stacked ? Height - _action.Height - 16 : (Height - _action.Height) / 2;
- _action.Location = new Point(x, y);
+ action.Width = width;
+ if (action is FlowLayoutPanel flow)
+ {
+ flow.WrapContents = true;
+ foreach (Control child in flow.Controls)
+ {
+ var preferred = (int)Math.Round(_actionWidths.GetValueOrDefault(child, child.Width) * AppAppearance.ScaleFor(this));
+ var available = Math.Max(1,width - child.Margin.Horizontal - flow.Padding.Horizontal);
+ child.Width = Math.Min(preferred, available);
+ if (child is FlowLayoutPanel or TableLayoutPanel) FitAction(child, child.Width);
+ }
+ flow.PerformLayout();
+ action.Height = Math.Max(1, flow.Controls.Cast().Select(c => c.Bottom + c.Margin.Bottom).DefaultIfEmpty(0).Max() + flow.Padding.Bottom);
+ }
+ else if (action is TableLayoutPanel table)
+ {
+ table.PerformLayout();
+ var totalHeight = table.Padding.Vertical;
+ for (var row = 0; row < table.RowCount; row++)
+ {
+ var height = 0;
+ foreach (Control child in table.Controls.Cast().Where(c => table.GetRow(c) == row))
+ {
+ FitAction(child, Math.Max(1,width - table.Padding.Horizontal - child.Margin.Horizontal));
+ height = Math.Max(height, child.Height + child.Margin.Vertical);
+ }
+ table.RowStyles[row].SizeType = SizeType.Absolute;
+ table.RowStyles[row].Height = height;
+ totalHeight += height;
+ }
+ table.Height = Math.Max(1,totalHeight);
+ }
}
+ private void LayoutAction() => Reflow();
+
protected override void OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
@@ -112,14 +175,15 @@ protected override void OnPaint(PaintEventArgs e)
g.DrawPath(pen, path);
// Fluent-style setting glyph tile.
- var tileRect = new RectangleF(18, (Height - 36) / 2f, 36, 36);
+ var scale = AppAppearance.ScaleFor(this);
+ var tileRect = new RectangleF(18 * scale, 16 * scale, 36 * scale, 36 * scale);
using var tileBrush = new SolidBrush(Color.FromArgb(239, 246, 253));
using var tilePen = new Pen(Color.FromArgb(218, 232, 246), 1f);
g.FillEllipse(tileBrush, tileRect);
g.DrawEllipse(tilePen, tileRect);
var iconRect = new RectangleF(tileRect.Left, tileRect.Top + 0.5f, tileRect.Width, tileRect.Height);
- using var iconFont = FluentTheme.IconFontPx(18);
+ using var iconFont = FluentTheme.IconFontPx(18 * scale);
using var iconBrush = new SolidBrush(Color.FromArgb(54, 92, 132));
using var iconFormat = new StringFormat
{
diff --git a/windows/CodexBarWin/UI/SettingsForm.cs b/windows/CodexBarWin/UI/SettingsForm.cs
index 25ad16c..a31086d 100644
--- a/windows/CodexBarWin/UI/SettingsForm.cs
+++ b/windows/CodexBarWin/UI/SettingsForm.cs
@@ -8,7 +8,7 @@
namespace CodexBarWin.UI;
-public sealed class SettingsForm : Form
+public sealed class SettingsForm : AdaptiveForm
{
private enum Page
{
@@ -16,12 +16,20 @@ private enum Page
Usage,
Windows,
Models,
+ Appearance,
}
private readonly CodexBarConfigStore _configStore;
private readonly KeepAwakeService _keepAwakeService;
private readonly Action _openConfigFolder;
private readonly Action _onSettingsChanged;
+ private readonly Action _checkUpdates;
+ private TableLayoutPanel? _root;
+ private Panel? _navCard;
+ private TableLayoutPanel? _footer;
+ private TableLayoutPanel? _main;
+ private TableLayoutPanel? _header;
+ private readonly ComboBox _compactNavigation = new FluentComboBox() { DropDownStyle = ComboBoxStyle.DropDownList, Dock = DockStyle.Fill };
private readonly FlowLayoutPanel _sidebar = new();
private readonly Panel _scrollHost = new();
private readonly Panel _content = new();
@@ -32,7 +40,6 @@ private enum Page
private readonly Button _cancelButton = new FluentButton();
private readonly Button _configButton = new FluentButton();
private readonly ToolTip _toolTip = new();
- private readonly Dictionary _pageCache = new();
private CodexBarConfig _draft;
private Page _selectedPage = Page.Accounts;
@@ -40,8 +47,10 @@ public SettingsForm(
CodexBarConfigStore configStore,
KeepAwakeService keepAwakeService,
Action openConfigFolder,
- Action onSettingsChanged)
+ Action onSettingsChanged,
+ Action? checkUpdates = null)
{
+ _checkUpdates = checkUpdates ?? (() => { });
_configStore = configStore ?? throw new ArgumentNullException(nameof(configStore));
_keepAwakeService = keepAwakeService ?? throw new ArgumentNullException(nameof(keepAwakeService));
_openConfigFolder = openConfigFolder ?? throw new ArgumentNullException(nameof(openConfigFolder));
@@ -53,9 +62,9 @@ public SettingsForm(
Text = "WinCodexBar 设置";
Font = FluentTheme.TextFontPx(14);
- AutoScaleMode = AutoScaleMode.Dpi;
+ AutoScaleMode = AutoScaleMode.None;
StartPosition = FormStartPosition.CenterScreen;
- MinimumSize = new Size(1040, 720);
+ MinimumSize = new Size(480, 360);
Size = new Size(1180, 780);
BackColor = FluentTheme.LayerBackground;
AppIconProvider.Apply(this);
@@ -75,18 +84,6 @@ protected override void Dispose(bool disposing)
if (disposing)
{
_toolTip.Dispose();
- foreach (var controls in _pageCache.Values)
- {
- foreach (var control in controls)
- {
- if (!control.IsDisposed)
- {
- control.Dispose();
- }
- }
- }
-
- _pageCache.Clear();
}
base.Dispose(disposing);
@@ -94,19 +91,32 @@ protected override void Dispose(bool disposing)
private void BuildLayout()
{
- var root = new TableLayoutPanel
+ var root = _root = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = Color.Transparent,
Padding = new Padding(16),
ColumnCount = 2,
- RowCount = 1,
+ RowCount = 2,
};
+ root.RowStyles.Add(new RowStyle(SizeType.Absolute, 0));
+ root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
root.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 184));
root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
- Controls.Add(root);
+ InstallScrollableContent(root, Size.Empty);
+ _compactNavigation.Items.AddRange(new object[] { "账号设置", "用量设置", "唤醒与更新", "模型参数", "外观与缩放" });
+ _compactNavigation.SelectedIndex = 0;
+ _compactNavigation.SelectedIndexChanged += (_, _) =>
+ {
+ if (_compactNavigation.SelectedIndex >= 0 && _selectedPage != (Page)_compactNavigation.SelectedIndex)
+ SelectPage((Page)_compactNavigation.SelectedIndex);
+ };
+ root.Controls.Add(_compactNavigation, 0, 0);
+ root.SetColumnSpan(_compactNavigation, 2);
+ ClientSizeChanged += (_, _) => LayoutResponsiveNavigation();
+ Shown += (_, _) => LayoutResponsiveNavigation();
- var navCard = new Panel
+ var navCard = _navCard = new Panel
{
Dock = DockStyle.Fill,
BackColor = FluentTheme.SubtleBackground,
@@ -114,7 +124,7 @@ private void BuildLayout()
Margin = new Padding(0, 0, 16, 0),
};
navCard.Paint += (_, e) => DrawRoundedPanel(e.Graphics, navCard.ClientRectangle, FluentTheme.SubtleBackground, FluentTheme.StrokeDefault, 8);
- root.Controls.Add(navCard, 0, 0);
+ root.Controls.Add(navCard, 0, 1);
_sidebar.Dock = DockStyle.Fill;
_sidebar.BackColor = Color.Transparent;
@@ -123,28 +133,31 @@ private void BuildLayout()
navCard.Controls.Add(_sidebar);
_sidebar.Controls.Add(MakeNavButton(Page.Accounts, FluentIcons.Account, "账号设置"));
_sidebar.Controls.Add(MakeNavButton(Page.Usage, FluentIcons.Chart, "用量设置"));
- _sidebar.Controls.Add(MakeNavButton(Page.Windows, FluentIcons.Power, "唤醒策略"));
+ _sidebar.Controls.Add(MakeNavButton(Page.Windows, FluentIcons.Power, "唤醒与更新"));
_sidebar.Controls.Add(MakeNavButton(Page.Models, FluentIcons.Settings, "模型参数"));
+ _sidebar.Controls.Add(MakeNavButton(Page.Appearance, FluentIcons.Home, "外观与缩放"));
- var main = new TableLayoutPanel
+ var main = _main = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = Color.Transparent,
ColumnCount = 1,
RowCount = 3,
};
+ main.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
main.RowStyles.Add(new RowStyle(SizeType.Absolute, 108));
main.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
main.RowStyles.Add(new RowStyle(SizeType.Absolute, 58));
- root.Controls.Add(main, 1, 0);
+ root.Controls.Add(main, 1, 1);
- var header = new TableLayoutPanel
+ var header = _header = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = Color.Transparent,
RowCount = 2,
ColumnCount = 1,
};
+ header.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
header.RowStyles.Add(new RowStyle(SizeType.Absolute, 48));
header.RowStyles.Add(new RowStyle(SizeType.Absolute, 56));
main.Controls.Add(header, 0, 0);
@@ -176,7 +189,7 @@ private void BuildLayout()
_content.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
_scrollHost.Controls.Add(_content);
- var footer = new TableLayoutPanel
+ var footer = _footer = new TableLayoutPanel
{
Dock = DockStyle.Fill,
BackColor = Color.Transparent,
@@ -184,6 +197,7 @@ private void BuildLayout()
RowCount = 1,
Padding = new Padding(0, 12, 0, 0),
};
+ footer.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
footer.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
footer.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 92));
footer.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 92));
@@ -219,6 +233,7 @@ private Button MakeNavButton(Page page, string icon, string text)
Height = 42,
Margin = new Padding(0, 0, 0, 6),
Text = text,
+ IconGlyph = icon,
TextAlign = ContentAlignment.MiddleLeft,
Font = FluentTheme.TextFontPx(16, FontStyle.Regular),
FlatStyle = FlatStyle.Flat,
@@ -234,14 +249,17 @@ private Button MakeNavButton(Page page, string icon, string text)
private void SelectPage(Page page)
{
_selectedPage = page;
+ _compactNavigation.SelectedIndex = (int)page;
foreach (Button button in _sidebar.Controls.OfType())
{
+ if (button is FluentButton nav) nav.NavigationSelected = false;
button.BackColor = FluentTheme.SubtleBackground;
button.ForeColor = FluentTheme.TextSecondary;
}
if ((int)page < _sidebar.Controls.Count && _sidebar.Controls[(int)page] is Button selected)
{
+ if (selected is FluentButton nav) nav.NavigationSelected = true;
selected.BackColor = Color.FromArgb(229, 240, 252);
selected.ForeColor = FluentTheme.Accent;
}
@@ -250,41 +268,43 @@ private void SelectPage(Page page)
_content.SuspendLayout();
_scrollHost.AutoScrollPosition = Point.Empty;
_scrollHost.AutoScrollMinSize = Size.Empty;
+ foreach (Control old in _content.Controls.Cast().ToArray()) old.Dispose();
_content.Controls.Clear();
_content.Location = new Point(0, 0);
- var useCachedPage = _pageCache.TryGetValue(page, out var cachedPageControls);
switch (page)
{
case Page.Accounts:
_title.Text = "账号设置";
_subtitle.Text = "选择账号切换与路由方式。";
- if (!useCachedPage) BuildAccountsPage();
+ BuildAccountsPage();
break;
case Page.Usage:
_title.Text = "用量设置";
_subtitle.Text = "调整额度、Token 单位和刷新。";
- if (!useCachedPage) BuildUsagePage();
+ BuildUsagePage();
break;
case Page.Windows:
- _title.Text = "唤醒策略";
- _subtitle.Text = "控制防休眠和空闲保护。";
- if (!useCachedPage) BuildWindowsPage();
+ _title.Text = "唤醒与更新";
+ _subtitle.Text = "管理更新、防休眠和空闲保护。";
+ BuildWindowsPage();
+ break;
+ case Page.Appearance:
+ _title.Text = "外观与缩放";
+ _subtitle.Text = "自动适配显示器,也可手动选择界面大小与托盘样式。";
+ BuildAppearancePage();
break;
case Page.Models:
_title.Text = "模型参数";
_subtitle.Text = "配置默认模型与请求参数。";
- if (!useCachedPage) BuildModelsPage();
+ BuildModelsPage();
break;
}
- if (useCachedPage && cachedPageControls is not null)
- {
- _content.Controls.AddRange(cachedPageControls);
- }
- else
+ if (IsHandleCreated)
{
- _pageCache[page] = _content.Controls.Cast().ToArray();
+ var scale = AppAppearance.ScaleFor(this);
+ foreach (Control card in _content.Controls) AppAppearance.ScaleTree(card, scale);
}
_content.ResumeLayout(true);
@@ -372,6 +392,8 @@ private void BuildUsagePage()
private void BuildWindowsPage()
{
+ _content.Controls.Add(MakeCheckCard(FluentIcons.Refresh, "自动检查更新", "启动后与每 6 小时检查正式版;发现更新后提示,不会自行安装。", "自动检查", _draft.AutoCheckUpdates, value => _draft.AutoCheckUpdates = value));
+ _content.Controls.Add(MakeButtonCard(FluentIcons.Info, "版本 " + AppUpdateService.DisplayVersion, "校验安装包,备份账号与设置,然后覆盖程序并重启。", "检查更新", _checkUpdates));
_content.Controls.Add(MakeCheckCard(
FluentIcons.Power,
"保持唤醒",
@@ -440,13 +462,28 @@ private void BuildWindowsPage()
_openConfigFolder));
}
+ private void BuildAppearancePage()
+ {
+ var scales = new[] { "跟随系统 DPI(推荐)", "100%", "125%", "150%", "175%", "200%", "250%", "300%" };
+ var scale = new FluentComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 230 };
+ scale.Items.AddRange(scales);
+ scale.SelectedIndex = _draft.UiScalePercent == 0 ? 0 : Math.Max(0, Array.IndexOf(scales, _draft.UiScalePercent + "%"));
+ scale.SelectedIndexChanged += (_, _) => _draft.UiScalePercent = scale.SelectedIndex == 0 ? 0 : int.Parse(scale.Text.TrimEnd('%'));
+ _content.Controls.Add(MakeCard(FluentIcons.Settings, "界面缩放", "自动模式随显示器 DPI 切换。小屏内容可滚动;保存后立即生效。", scale));
+ var styles = new FluentComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 230 };
+ styles.Items.AddRange(new object[] { "额度圆环", "剩余百分比", "经典应用图标" });
+ styles.SelectedIndex = _draft.TrayIconStyle switch { "percent" => 1, "classic" => 2, _ => 0 };
+ styles.SelectedIndexChanged += (_, _) => _draft.TrayIconStyle = styles.SelectedIndex switch { 1 => "percent", 2 => "classic", _ => "ring" };
+ _content.Controls.Add(MakeCard(FluentIcons.Info, "托盘样式", "优先显示 5 小时额度;没有时显示 7 天额度。圆环为已用,数字为剩余。", styles));
+ }
+
private void BuildModelsPage()
{
_content.Controls.Add(MakeComboCard(
FluentIcons.Settings,
"默认模型",
"Codex 默认使用的模型。",
- CodexBarConfig.AvailableModels,
+ CodexModelCatalog.Models(),
_draft.Global.DefaultModel,
value => _draft.Global.DefaultModel = value));
@@ -454,7 +491,7 @@ private void BuildModelsPage()
FluentIcons.Chart,
"Review 模型",
"代码评审使用的模型。",
- CodexBarConfig.AvailableModels,
+ CodexModelCatalog.Models(),
_draft.Global.ReviewModel,
value => _draft.Global.ReviewModel = value));
@@ -462,7 +499,7 @@ private void BuildModelsPage()
FluentIcons.Activity,
"推理强度",
"控制思考投入强度。",
- CodexBarConfig.AvailableReasoningEfforts,
+ CodexModelCatalog.Efforts(_draft.Global.DefaultModel),
_draft.Global.ReasoningEffort,
value => _draft.Global.ReasoningEffort = value));
@@ -470,7 +507,7 @@ private void BuildModelsPage()
FluentIcons.Cloud,
"服务等级",
"选择请求服务等级。",
- CodexBarConfig.AvailableServiceTiers,
+ CodexModelCatalog.Tiers(_draft.Global.DefaultModel),
_draft.Global.ServiceTier,
value => _draft.Global.ServiceTier = value));
}
@@ -902,20 +939,72 @@ private Control MakeCard(string glyph, string title, string description, Control
var card = new SettingCard(glyph, title, description)
{
Height = height,
+ LogicalMinimumHeight = height,
};
action.Margin = new Padding(0);
card.Action = action;
return card;
}
+ public override void ApplyAppearance()
+ {
+ base.ApplyAppearance();
+ // Scale 会在布局事件之后继续调整列宽,必须在整棵控件树缩放结束后再定稿。
+ LayoutResponsiveNavigation();
+ }
+
+ private void LayoutResponsiveNavigation()
+ {
+ if (_root is null || _navCard is null) return;
+ var scale = AppAppearance.ScaleFor(this);
+ var compact = ClientSize.Width / scale < 980;
+ _root.SuspendLayout();
+ _root.ColumnStyles[0].Width = compact ? 0 : 184 * scale;
+ _root.RowStyles[0].Height = compact ? 44 * scale : 0;
+ _navCard.Visible = !compact;
+ _compactNavigation.Visible = compact;
+ if (_footer is not null)
+ {
+ var narrow = ClientSize.Width / scale < 600;
+ _saveStatusLabel.Visible = !narrow;
+ _footer.ColumnStyles[0].SizeType = narrow ? SizeType.Absolute : SizeType.Percent;
+ _footer.ColumnStyles[0].Width = narrow ? 0 : 100;
+ for (var i=1;i<4;i++)
+ {
+ _footer.ColumnStyles[i].SizeType = narrow ? SizeType.Percent : SizeType.Absolute;
+ _footer.ColumnStyles[i].Width = narrow ? 33.33f : (i==3 ? 118 : 92) * scale;
+ }
+ }
+ if (_main is not null && _header is not null)
+ {
+ var titlePixels = (compact ? 22 : 28) * scale;
+ if (Math.Abs(_title.Font.Size - titlePixels) > .1f)
+ {
+ _title.Font = FluentTheme.TextFontPx(titlePixels, FontStyle.Bold);
+ }
+ _header.RowStyles[0].Height = (compact ? 36 : 48) * scale;
+ _header.RowStyles[1].Height = (compact ? 44 : 56) * scale;
+ _main.RowStyles[0].Height = (compact ? 84 : 108) * scale;
+ _main.RowStyles[2].Height = 58 * scale;
+ }
+ _root.ResumeLayout(true);
+ _root.PerformLayout();
+ _main?.PerformLayout();
+ _header?.PerformLayout();
+ _footer?.PerformLayout();
+ ResizeCards();
+ }
+
private void ResizeCards()
{
- var width = Math.Max(520, _scrollHost.ClientSize.Width - SystemInformation.VerticalScrollBarWidth - 10);
+ var width = Math.Max(1, _scrollHost.ClientSize.Width - SystemInformation.VerticalScrollBarWidth - (int)(10 * AppAppearance.ScaleFor(this)));
_content.SuspendLayout();
_content.Width = width;
var top = 0;
foreach (Control control in _content.Controls)
{
+ control.Width = width;
+ if (control is SettingCard card) card.Reflow();
control.SetBounds(0, top, width, control.Height);
top += control.Height + 12;
}
@@ -931,9 +1020,18 @@ private void SaveSettings()
_draft.OpenAI.DangerThresholdPercent = Math.Min(100, _draft.OpenAI.WarningThresholdPercent + 10);
}
+ if (_draft.Global.DefaultModel.StartsWith("gpt-6-astra", StringComparison.OrdinalIgnoreCase)
+ && _draft.Global.ReasoningEffort is "none" or "minimal")
+ {
+ MessageBox.Show(this, "GPT-6 Astra 的推理强度最低为 low,请调整后保存。", "模型参数", MessageBoxButtons.OK, MessageBoxIcon.Information);
+ return;
+ }
_draft.OpenAI.EnsurePricingDefaults();
_configStore.Update(config =>
{
+ config.UiScalePercent = _draft.UiScalePercent;
+ config.TrayIconStyle = _draft.TrayIconStyle;
+ config.AutoCheckUpdates = _draft.AutoCheckUpdates;
config.Global.DefaultModel = _draft.Global.DefaultModel;
config.Global.ReviewModel = _draft.Global.ReviewModel;
config.Global.ReasoningEffort = _draft.Global.ReasoningEffort;
@@ -963,6 +1061,8 @@ private void SaveSettings()
_keepAwakeService.SetAdvancedEnabled(_draft.AdvancedKeepAwakeEnabled);
StartupService.SetEnabled(_draft.StartWithWindows);
_onSettingsChanged();
+ LayoutResponsiveNavigation();
+ SelectPage(_selectedPage);
_saveStatusLabel.Text = $"已保存 · {DateTime.Now:HH:mm:ss}";
_saveButton.Text = "已保存";
var resetTimer = new System.Windows.Forms.Timer { Interval = 1600 };
diff --git a/windows/CodexBarWin/UI/TrayPopupForm.cs b/windows/CodexBarWin/UI/TrayPopupForm.cs
index cba58b6..6297bca 100644
--- a/windows/CodexBarWin/UI/TrayPopupForm.cs
+++ b/windows/CodexBarWin/UI/TrayPopupForm.cs
@@ -17,7 +17,7 @@ public sealed class TrayPopupForm : Form
private enum HitStyle { None, IconButton, IconButtonFlat, AccentButton, NeutralButton, AccountRow, RefreshGlyph, DeleteGlyph, CloseGlyph, SegmentTab }
private const float PopupLogicalWidth = 440f;
- private const float AccountListTop = 250f;
+ private const float AccountListTop = 282f;
private const float AccountListLeft = 16f;
private const float AccountListWidth = 408f;
private const float AccountRowHeight = 60f;
@@ -62,13 +62,17 @@ private sealed class Hit
private string? _refreshingAccountId;
private DateTime _refreshAnimationStartedAt;
private bool _refreshStopRequested;
- private bool _keepOpenForAction;
+ private bool _actionInProgress;
+ private RectangleF? _hitClip;
+ private float _viewportHeight = 110f;
+ private Point _anchor;
private int _hoveredId = -1;
private int _pressedId = -1;
private int _nextHitId;
private IntPtr _mouseHookHandle = IntPtr.Zero;
private LowLevelMouseProc? _mouseHookProc;
private bool _closingFromHook;
+ public bool ClosedByOutsideClick { get; private set; }
public TrayPopupForm(
IReadOnlyList accounts,
@@ -110,7 +114,7 @@ public TrayPopupForm(
_exit = exit;
Text = "WinCodexBar";
- AutoScaleMode = AutoScaleMode.Dpi;
+ AutoScaleMode = AutoScaleMode.None;
FormBorderStyle = FormBorderStyle.None;
StartPosition = FormStartPosition.Manual;
DoubleBuffered = true;
@@ -123,18 +127,44 @@ public TrayPopupForm(
public void ShowNearCursor()
{
- _scale = Math.Max(1f, DeviceDpi / 96f);
- Size = DpiSize(PopupLogicalWidth, PopupLogicalHeight());
-
- var screen = Screen.FromPoint(Cursor.Position).WorkingArea;
- var x = Math.Min(Math.Max(Cursor.Position.X - Width + Dpi(40), screen.Left + Dpi(8)), screen.Right - Width - Dpi(8));
- var y = Math.Min(Math.Max(Cursor.Position.Y - Height - Dpi(8), screen.Top + Dpi(8)), screen.Bottom - Height - Dpi(8));
- Location = new Point(x, y);
+ _anchor = Cursor.Position;
+ // 创建 HWND 时即置于目标显示器,避免先按主屏 DPI 计算。
+ Location = _anchor;
+ _ = Handle;
+ FitPopup();
Show();
+ FitPopup();
Activate();
InstallMouseHook();
}
+ private void FitPopup()
+ {
+ var area = Screen.FromPoint(_anchor).WorkingArea;
+ _scale = Math.Min(AppAppearance.ScaleFor(this), Math.Min((area.Width - 16) / PopupLogicalWidth, (area.Height - 16) / 386f));
+ _scale = Math.Max(0.1f, _scale);
+ var natural = _accounts.Count == 0 ? 110f : Math.Clamp(_accounts.Count, 1, 4) * 68f - 8f;
+ _viewportHeight = Math.Max(1, Math.Min(natural, (area.Height - 16) / _scale - AccountListTop - 76f));
+ Size = DpiSize(PopupLogicalWidth, PopupLogicalHeight());
+ Location = new Point(Math.Clamp(_anchor.X - Width + Dpi(40), area.Left + 8, area.Right - Width - 8),
+ Math.Clamp(_anchor.Y - Height - 8, area.Top + 8, area.Bottom - Height - 8));
+ _hits.Clear();
+ _pressedId = -1;
+ Invalidate();
+ }
+
+ protected override void OnDpiChanged(DpiChangedEventArgs e)
+ {
+ base.OnDpiChanged(e);
+ FitPopup();
+ }
+
+ protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
+ {
+ if (keyData == Keys.Escape) { Close(); return true; }
+ return base.ProcessCmdKey(ref msg, keyData);
+ }
+
public void UpdateSnapshot(
string? activeAccountId,
TokenUsageSummary? tokenSummary,
@@ -155,7 +185,7 @@ public void UpdateSnapshot(
{
_refreshStopRequested = true;
}
- Invalidate();
+ FitPopup();
}
public void UpdateRadarPrediction(CodexRadarPrediction prediction)
@@ -197,6 +227,7 @@ protected override void OnFormClosing(FormClosingEventArgs e)
protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
+ if (e.Button != MouseButtons.Left || _actionInProgress) return;
var hit = HitAt(e.Location);
_pressedId = hit?.Id ?? -1;
Invalidate();
@@ -205,13 +236,17 @@ protected override void OnMouseDown(MouseEventArgs e)
protected override void OnMouseUp(MouseEventArgs e)
{
base.OnMouseUp(e);
+ if (e.Button != MouseButtons.Left || _actionInProgress) return;
var hit = HitAt(e.Location);
var pressedId = _pressedId;
_pressedId = -1;
Invalidate();
if (hit is not null && hit.Id == pressedId)
{
- _keepOpenForAction = !hit.CloseAfter;
+ _actionInProgress = true;
+ _toolTip.Hide(this);
+ // 先结束托盘窗口生命周期,再打开其他窗口或文件对话框。
+ if (hit.CloseAfter) Close();
try
{
hit.Action();
@@ -222,10 +257,7 @@ protected override void OnMouseUp(MouseEventArgs e)
}
finally
{
- if (hit.CloseAfter)
- {
- Close();
- }
+ _actionInProgress = false;
}
}
}
@@ -273,11 +305,12 @@ protected override void OnMouseLeave(EventArgs e)
protected override void OnMouseWheel(MouseEventArgs e)
{
base.OnMouseWheel(e);
- if (_maxScroll <= 0)
+ if (_maxScroll <= 0 || !Rect(AccountListLeft, AccountListTop, AccountListWidth, AccountViewportHeight()).Contains(e.Location))
{
return;
}
+ _pressedId = -1;
_scrollOffset = Math.Clamp(_scrollOffset - Math.Sign(e.Delta) * 42, 0, _maxScroll);
Invalidate();
}
@@ -351,22 +384,17 @@ private void DrawHeader(Graphics g, TokenAccount? active)
DrawText(g, "WinCodexBar", titleFont, FluentTheme.TextPrimary, Rect(20, 18, 260, 32));
var close = Rect(396, 18, 28, 28);
- var closeId = AddHit(close, Close, "关闭", HitStyle.CloseGlyph, closeAfter: true);
+ var closeId = AddHit(close, () => { }, "关闭", HitStyle.CloseGlyph, closeAfter: true);
DrawCloseGlyph(g, close, closeId);
var accountText = active is null ? "当前账号:未激活" : $"当前账号:{TrimMiddle(BuildAccountLabel(active), 28)}";
DrawText(g, accountText, metaFont, FluentTheme.TextSecondary, Rect(20, 54, 270, 20));
- DrawRadarPrediction(g, Rect(20, 73, 270, 18));
+ DrawRadarPrediction(g, Rect(20, 79, 400, 18));
DrawSegmented(g, Rect(294, 54, 130, 26));
}
private void DrawRadarPrediction(Graphics g, RectangleF rect)
{
- if (!_radarPrediction.IsAvailable || string.IsNullOrWhiteSpace(_radarPrediction.DisplayText))
- {
- return;
- }
-
var hitId = AddHit(
rect,
() => OpenUrl("https://codexradar.com/"),
@@ -459,7 +487,7 @@ private void ToggleKeepAwakeInPlace()
private void DrawUsageOverview(Graphics g, TokenAccount? active)
{
- var card = Rect(16, 100, 408, 112);
+ var card = Rect(16, 100, 408, 140);
using var cardBg = new SolidBrush(FluentTheme.CardBackground);
using var cardEdge = new Pen(FluentTheme.StrokeDefault, Dpi(1));
FillRound(g, cardBg, card, DpiF(6));
@@ -476,23 +504,25 @@ private void DrawUsageOverview(Graphics g, TokenAccount? active)
return;
}
- var fiveHour = Clamp(active.PrimaryUsedPercent);
- var sevenDay = Clamp(active.SecondaryUsedPercent);
+ var windows = AccountUsageHelpers.Windows(active);
var modeLabel = _config.OpenAI.UsageDisplayMode == UsageDisplayMode.Remaining ? "剩余" : "已用";
- var fiveDisplay = AccountUsageHelpers.DisplayPercent(fiveHour, _config.OpenAI.UsageDisplayMode);
- var sevenDisplay = AccountUsageHelpers.DisplayPercent(sevenDay, _config.OpenAI.UsageDisplayMode);
var planLabel = AccountUsageHelpers.PlanLabel(active);
DrawPill(g, "OpenAI", Rect(28, 111, 58, 22), FluentTheme.Accent, Color.FromArgb(229, 240, 252), small);
DrawText(g, $"{planLabel} 订阅", strong, FluentTheme.TextPrimary, Rect(94, 110, 110, 24));
- DrawText(g, $"5小时 {modeLabel} {fiveDisplay:F1}% · 7天 {modeLabel} {sevenDisplay:F1}%", body, FluentTheme.TextSecondary, Rect(190, 112, 206, 22), right: true);
- DrawText(g, $"5h {ResetDetail(active.PrimaryResetAt)}", small, FluentTheme.TextTertiary, Rect(28, 136, 180, 18));
- DrawText(g, $"7d {ResetDetail(active.SecondaryResetAt)}", small, FluentTheme.TextTertiary, Rect(216, 136, 180, 18));
+ DrawText(g, modeLabel + " " + AccountUsageHelpers.UsageText(active, _config.OpenAI.UsageDisplayMode), body, FluentTheme.TextSecondary, Rect(190, 112, 206, 22), right: true);
DrawTokenUsageStrip(g, Rect(28, 156, 368, 22));
-
- DrawUsageBar(g, Rect(28, 188, 176, 6), fiveHour);
- DrawUsageBar(g, Rect(220, 188, 176, 6), sevenDay);
- DrawText(g, "5小时", small, FluentTheme.TextTertiary, Rect(28, 196, 60, 14));
- DrawText(g, "7天", small, FluentTheme.TextTertiary, Rect(220, 196, 60, 14));
+ var detailsRect = Rect(28, 214, 368, 22);
+ AddHit(detailsRect, () => { using var details = new AccountDetailsForm(active, _config.OpenAI.UsageDisplayMode); details.ShowDialog(this); }, "查看重置卡到期时间与精确重置时间", HitStyle.NeutralButton);
+ DrawText(g, AccountUsageHelpers.CreditSummary(active) + " · 查看详情", body, FluentTheme.Accent, detailsRect);
+ for (var i = 0; i < windows.Count; i++)
+ {
+ var window = windows[i];
+ var width = windows.Count == 1 ? 368 : 176;
+ var left = 28 + i * 192;
+ DrawText(g, window.Label + " " + ResetDetail(window.ResetAt), small, FluentTheme.TextTertiary, Rect(left, 136, width, 18));
+ DrawUsageBar(g, Rect(left, 188, width, 6), window.UsedPercent);
+ DrawText(g, window.Label == "5h" ? "5小时" : "7天", small, FluentTheme.TextTertiary, Rect(left, 196, width, 14));
+ }
}
private void DrawTokenUsageStrip(Graphics g, RectangleF rect)
@@ -521,10 +551,10 @@ private void DrawAccountSection(Graphics g)
var warning = _accounts.Count(a => HealthValue(a) >= 70 && HealthValue(a) < 90);
var danger = _accounts.Count(a => HealthValue(a) >= 90);
- DrawText(g, "账号信息", heading, FluentTheme.TextPrimary, Rect(20, 222, 92, 22));
- DrawText(g, $"健康 {healthy} · 警戒 {warning} · 高负载 {danger}", note, FluentTheme.TextSecondary, Rect(112, 223, 220, 22));
+ DrawText(g, "账号信息", heading, FluentTheme.TextPrimary, Rect(20, 254, 92, 22));
+ DrawText(g, $"健康 {healthy} · 警戒 {warning} · 高负载 {danger}", note, FluentTheme.TextSecondary, Rect(112, 255, 220, 22));
using var pillFont = FontPx(10, FontStyle.Bold);
- DrawPill(g, $"{_accounts.Count} 个", Rect(370, 223, 50, 22), FluentTheme.Accent, Color.FromArgb(229, 240, 252), pillFont);
+ DrawPill(g, $"{_accounts.Count} 个", Rect(370, 255, 50, 22), FluentTheme.Accent, Color.FromArgb(229, 240, 252), pillFont);
if (_accounts.Count == 0)
{
@@ -545,6 +575,7 @@ private void DrawScrollableAccounts(Graphics g, float x, float y, float width, f
var state = g.Save();
g.SetClip(viewport);
+ _hitClip = viewport;
var rowY = y - _scrollOffset;
foreach (var account in ordered)
{
@@ -555,6 +586,7 @@ private void DrawScrollableAccounts(Graphics g, float x, float y, float width, f
rowY += AccountRowHeight + AccountRowGap;
}
g.Restore(state);
+ _hitClip = null;
if (_maxScroll > 0)
{
@@ -596,7 +628,7 @@ private void DrawAccountRow(Graphics g, TokenAccount account, float y)
{
var active = string.Equals(account.AccountId, _activeAccountId, StringComparison.Ordinal);
var row = Rect(16, y, 400, 60);
- var rowId = AddHit(row, () => { _keepOpenForAction = true; _activateAccount(account); }, active ? "当前账号" : "切换到此账号", HitStyle.AccountRow);
+ var rowId = AddHit(row, () => { _activateAccount(account); }, AccountUsageHelpers.DetailsText(account, _config.OpenAI.UsageDisplayMode), HitStyle.AccountRow);
var hovered = _hoveredId == rowId;
var pressed = _pressedId == rowId;
var health = HealthValue(account);
@@ -645,20 +677,17 @@ private void DrawAccountRow(Graphics g, TokenAccount account, float y)
DrawText(g, TrimMiddle(BuildAccountLabel(account), 28), name, FluentTheme.TextPrimary, Rect(54, y + 8, 220, 20));
DrawPill(g, AccountUsageHelpers.PlanLabel(account), Rect(280, y + 9, 56, 18), PlanColor(account), Color.FromArgb(238, 238, 238), chip);
- DrawText(g, AccountUsageHelpers.HealthLabel(AccountUsageHelpers.Health(account, _config.OpenAI.WarningThresholdPercent, _config.OpenAI.DangerThresholdPercent)), meta, UsageColor(health), Rect(54, y + 32, 80, 18));
- var fiveText = AccountUsageHelpers.DisplayPercent(fiveHour, _config.OpenAI.UsageDisplayMode);
- var sevenText = AccountUsageHelpers.DisplayPercent(sevenDay, _config.OpenAI.UsageDisplayMode);
- var prefix = _config.OpenAI.UsageDisplayMode == UsageDisplayMode.Remaining ? "剩" : "用";
- DrawText(g, $"5h{prefix} {fiveText:F0}%", meta, UsageColor(fiveHour), Rect(140, y + 32, 70, 18));
- DrawText(g, $"7d{prefix} {sevenText:F0}%", meta, UsageColor(sevenDay), Rect(212, y + 32, 70, 18));
- DrawText(g, ResetHint(account.PrimaryResetAt), meta, FluentTheme.TextTertiary, Rect(286, y + 32, 50, 18));
+ DrawText(g, AccountUsageHelpers.HealthLabel(AccountUsageHelpers.Health(account, _config.OpenAI.WarningThresholdPercent, _config.OpenAI.DangerThresholdPercent)), meta, UsageColor(health), Rect(54, y + 30, 80, 14));
+ var windows = AccountUsageHelpers.Windows(account);
+ DrawText(g, AccountUsageHelpers.UsageText(account, _config.OpenAI.UsageDisplayMode), meta, UsageColor(health), Rect(140, y + 30, 196, 14));
+ DrawText(g, "重置:" + ResetDetail(windows.FirstOrDefault()?.ResetAt), meta, FluentTheme.TextTertiary, Rect(54, y + 44, 282, 14));
var refresh = Rect(342, y + 14, 26, 30);
- var refreshId = AddHit(refresh, () => { _keepOpenForAction = true; StartRefreshAnimation(account); _refreshAccount(account); }, "刷新此账号用量", HitStyle.RefreshGlyph);
+ var refreshId = AddHit(refresh, () => { StartRefreshAnimation(account); _refreshAccount(account); }, "刷新此账号用量", HitStyle.RefreshGlyph);
DrawRefreshGlyph(g, refresh, refreshId, account);
var delete = Rect(374, y + 14, 26, 30);
- var deleteId = AddHit(delete, () => { _keepOpenForAction = true; _deleteAccount(account); }, "删除此账号", HitStyle.DeleteGlyph);
+ var deleteId = AddHit(delete, () => { _deleteAccount(account); }, "删除此账号", HitStyle.DeleteGlyph);
DrawDeleteGlyph(g, delete, deleteId);
}
@@ -796,6 +825,7 @@ private void DrawAccentButton(Graphics g, RectangleF rect, string label, int hit
private int AddHit(RectangleF bounds, Action action, string? tooltip, HitStyle style, bool closeAfter = false)
{
+ if (_hitClip is { } clip) bounds = RectangleF.Intersect(bounds, clip);
var id = ++_nextHitId;
_hits.Add(new Hit
{
@@ -814,16 +844,7 @@ private float PopupLogicalHeight()
return AccountListTop + AccountViewportHeight() + 76f;
}
- private float AccountViewportHeight()
- {
- if (_accounts.Count == 0)
- {
- return 110f;
- }
-
- var visibleRows = Math.Clamp(_accounts.Count, 1, 4);
- return visibleRows * AccountRowHeight + Math.Max(0, visibleRows - 1) * AccountRowGap;
- }
+ private float AccountViewportHeight() => _viewportHeight;
private float ActionsSeparatorY()
{
@@ -839,7 +860,7 @@ private IEnumerable SortedAccounts()
{
return _accounts
.OrderBy(a => !string.Equals(a.AccountId, _activeAccountId, StringComparison.Ordinal))
- .ThenByDescending(a => HealthValue(a))
+ .ThenBy(a => HealthValue(a))
.ThenBy(a => BuildAccountLabel(a), StringComparer.CurrentCultureIgnoreCase);
}
@@ -852,7 +873,7 @@ private IEnumerable SortedAccounts()
private static double HealthValue(TokenAccount account)
{
- return Math.Max(Clamp(account.PrimaryUsedPercent), Clamp(account.SecondaryUsedPercent));
+ return AccountUsageHelpers.MaxUsage(account);
}
private static string BuildAccountLabel(TokenAccount account)
@@ -860,20 +881,10 @@ private static string BuildAccountLabel(TokenAccount account)
return !string.IsNullOrWhiteSpace(account.Email) ? account.Email : account.AccountId;
}
- private static string ResetHint(DateTimeOffset? reset)
- {
- if (reset is null) return "--";
- var span = reset.Value - DateTimeOffset.Now;
- if (span.TotalMinutes <= 0) return "可刷新";
- return span.TotalHours >= 1
- ? $"{(int)span.TotalHours}时{span.Minutes:D2}分"
- : $"{Math.Max(1, (int)span.TotalMinutes)}分钟";
- }
-
private static string ResetDetail(DateTimeOffset? reset)
{
if (reset is null) return "--";
- return $"{ResetHint(reset)} · {reset.Value.ToLocalTime():M月d日 H:mm}";
+ return reset.Value.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
}
private static string RefreshHint(DateTimeOffset checkedAt)
@@ -961,6 +972,14 @@ private void DrawPill(Graphics g, string text, RectangleF rect, Color fore, Colo
private static void DrawText(Graphics g, string text, Font font, Color color, RectangleF rect, bool center = false, bool right = false)
{
+ if (font.FontFamily.Name == EmbeddedFonts.Text.Name)
+ {
+ var alignment = center ? TextFormatFlags.HorizontalCenter : right ? TextFormatFlags.Right : TextFormatFlags.Left;
+ TextRenderer.DrawText(g, text, font, Rectangle.Round(rect), color,
+ alignment | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix
+ | TextFormatFlags.SingleLine | TextFormatFlags.NoPadding | TextFormatFlags.PreserveGraphicsClipping);
+ return;
+ }
using var brush = new SolidBrush(color);
using var format = new StringFormat
{
@@ -974,7 +993,7 @@ private static void DrawText(Graphics g, string text, Font font, Color color, Re
private Font FontPx(float size, FontStyle style)
{
- return new Font(FluentTheme.TextFontFamily, DpiF(size), style, GraphicsUnit.Pixel);
+ return FluentTheme.TextFontPx(DpiF(size), style);
}
private int Dpi(float value) => (int)Math.Round(value * _scale);
@@ -1057,7 +1076,7 @@ private void UninstallMouseHook()
private IntPtr MouseHookCallback(int nCode, IntPtr wParam, IntPtr lParam)
{
- if (nCode >= 0 && !_closingFromHook)
+ if (nCode >= 0 && !_closingFromHook && !_actionInProgress && !IsDisposed && Enabled)
{
var message = (int)wParam;
if (message == WM_LBUTTONDOWN || message == WM_RBUTTONDOWN || message == WM_MBUTTONDOWN || message == WM_NCLBUTTONDOWN)
@@ -1069,7 +1088,8 @@ private IntPtr MouseHookCallback(int nCode, IntPtr wParam, IntPtr lParam)
_closingFromHook = true;
BeginInvoke(() =>
{
- if (!IsDisposed) Close();
+ if (!IsDisposed && !_actionInProgress && Enabled) { ClosedByOutsideClick = true; Close(); }
+ _closingFromHook = false;
});
}
}
diff --git a/windows/CodexBarWin/UI/UsageStatsForm.cs b/windows/CodexBarWin/UI/UsageStatsForm.cs
index a06e2c3..ac47cf8 100644
--- a/windows/CodexBarWin/UI/UsageStatsForm.cs
+++ b/windows/CodexBarWin/UI/UsageStatsForm.cs
@@ -185,8 +185,9 @@ private Control BuildUsageRow(TokenAccount account)
row.Controls.Add(title);
row.Controls.Add(plan);
row.Controls.Add(statusLabel);
- row.Controls.Add(BuildUsageLine("5h 主池", account.PrimaryUsedPercent, account.PrimaryResetAt, new Point(16, 56), row.Width - 32, accent));
- row.Controls.Add(BuildUsageLine("7d 次池", account.SecondaryUsedPercent, account.SecondaryResetAt, new Point(16, 80), row.Width - 32, accent));
+ var windows = AccountUsageHelpers.Windows(account);
+ for (var i = 0; i < windows.Count; i++)
+ row.Controls.Add(BuildUsageLine(windows[i].Label, windows[i].UsedPercent, windows[i].ResetAt, new Point(16, 56 + i * 24), row.Width - 32, accent));
return row;
}
@@ -253,11 +254,7 @@ private string BuildAverageText()
return "暂无数据";
}
- var primary = _accounts.Select(a => AccountUsageHelpers.Clamp(a.PrimaryUsedPercent)).Average();
- var secondary = _accounts.Select(a => AccountUsageHelpers.Clamp(a.SecondaryUsedPercent)).Average();
- var primaryDisplay = AccountUsageHelpers.DisplayPercent(primary, _config.OpenAI.UsageDisplayMode);
- var secondaryDisplay = AccountUsageHelpers.DisplayPercent(secondary, _config.OpenAI.UsageDisplayMode);
- return $"5h {primaryDisplay:F1}% · 7d {secondaryDisplay:F1}%";
+ return AccountUsageHelpers.AverageText(_accounts, _config.OpenAI.UsageDisplayMode);
}
private static Color HealthAccent(AccountHealthStatus status)