起因
最近一直在用智谱 AI 的 API 跑各种实验,Token 消耗速度完全心里没数。打开后台网页看一眼用量,操作路径太深,刷新一下页面又忘了。我想要一个东西——能直接趴在桌面角落里,实时告诉我「你今天又烧了多少 Token」。
找了一圈没有现成的工具,干脆自己写一个。于是有了 ZKanban——一个 Windows 桌面悬浮小组件,专门监控智谱 AI 的 API 用量。

先放一张成品截图。一个半透明的毛玻璃小卡片,始终悬浮在桌面右上角,支持拖拽、折叠、自动刷新。支持曲线图模式和概览模式切换,可以查看 1 天到 60 天的 Token 消耗趋势,还能多模型叠加对比。
核心功能
- 曲线图模式:1/7/30/60 天 Token 消耗趋势,多模型叠加,Catmull-Rom 样条平滑曲线
- 概览模式:配额卡片 + 模型消耗 Top N,一眼掌握全局
- 自动刷新:可配置 1-240 分钟间隔,后台保持登录态
- 历史缓存:每天一个 JSON 文件,60 天历史回溯,断档检测
- 安全存储:Windows DPAPI 加密凭证,仅限当前用户解密
- 桌面悬浮:始终置顶、可拖拽、可折叠,暗色毛玻璃主题
技术架构
整个项目基于 Avalonia UI 12(.NET 10),构建出一个单文件自包含 exe,不需要用户安装任何运行时。
| 层次 | 技术选型 | 说明 |
|---|---|---|
| UI 框架 | Avalonia UI 12 | 跨平台 .NET UI 框架,本文写作时最新版本 |
| 数据采集 | Avalonia.Controls.WebView (WebView2) | 内嵌浏览器引擎,自动化登录 + API 代理 |
| 凭证加密 | Windows DPAPI | CurrentUser scope,系统级加密 |
| 图表渲染 | 纯 Avalonia Canvas | 手写 Catmull-Rom 样条,无第三方图表库 |
| 构建分发 | Self-contained SingleFile + Trimmed | GitHub Actions 自动构建,单 exe 发布 |
最值得一提的不是 UI,而是数据采集的方式。智谱开放平台没有公开的用量查询 API,所以我用了一个比较 hack 的思路——让 WebView2 充当无头浏览器代理。
亮点一:WebView2 无头浏览器代理
这是整个项目最有趣的部分。思路很简单但很实用:
- 在后台启动一个隐藏的 WebView2 浏览器实例
- 自动导航到
bigmodel.cn的登录页 - 模拟用户操作完成登录(填表单、点按钮)
- 登录成功后,提取
bigmodel_token_productioncookie 和localStorage中的组织/项目信息 - 用这些凭证,在浏览器上下文内直接发 XHR 请求,调用智谱后台的内部 API
为什么要「在浏览器上下文内」发请求?因为智谱后台的 API 有 Referer 和 Origin 校验,直接用 HttpClient 发请求会被拦截。但在 WebView2 内部执行 JavaScript 发 XHR,天然带着正确的 Origin 和 Cookie,完美绕过。
来看核心的 FetchUsageDataAsync 方法:
private async Task<T?> FetchUsageDataAsync<T>(NativeWebView webView, string relativeUrl)
{
if (_cachedAuth is null)
{
var cookieManager = webView.TryGetCookieManager()
?? throw new InvalidOperationException("WebView 未初始化或 Cookie 管理器不可用。");
var cookies = await cookieManager.GetCookiesAsync();
var token = cookies.FirstOrDefault(cookie =>
cookie.Name.Equals("bigmodel_token_production",
StringComparison.OrdinalIgnoreCase))?.Value ?? string.Empty;
var organization = await ExecuteJsonAsync<string>(webView,
"localStorage.getItem('Bigmodel-Organization') || ''");
var project = await ExecuteJsonAsync<string>(webView,
"localStorage.getItem('Bigmodel-Project') || ''");
_cachedAuth = new AuthContext(token, organization, project);
}
var script = $"""
(() => {
const xhr = new XMLHttpRequest();
xhr.open('GET', {{JsonSerializer.Serialize(relativeUrl)}}, false);
xhr.withCredentials = true;
xhr.setRequestHeader('Authorization',
{{JsonSerializer.Serialize(Uri.UnescapeDataString(_cachedAuth.Token))}});
xhr.setRequestHeader('Bigmodel-Organization',
{{JsonSerializer.Serialize(_cachedAuth.Organization)}});
xhr.setRequestHeader('Bigmodel-Project',
{{JsonSerializer.Serialize(_cachedAuth.Project)}});
xhr.setRequestHeader('Set-Language', 'zh');
xhr.send();
return xhr.responseText;
})();
""";
var raw = await ExecuteJsonAsync<string>(webView, script);
return JsonSerializer.Deserialize<T>(raw);
}注意这个 XHR 是同步的(xhr.open('GET', url, false))。因为这段代码是在 WebView2 的 InvokeScript 中执行的,异步操作没法直接返回结果给 C# 端。同步 XHR 虽然不推荐在网页中使用,但在这种「脚本注入 + 立即返回」的场景下是最简单的方案。
自动登录部分同样有趣——需要切换到「账号登录」Tab,填入用户名密码,再点登录按钮。关键是要正确触发 Vue/React 的数据绑定事件:
const setValue = (selector, value) => {
const input = [...document.querySelectorAll(selector)].find(visible);
if (!input) return false;
// 必须通过原生 setter 设值,否则 Vue/React 无法感知变化
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype, 'value')?.set;
if (setter) setter.call(input, value); else input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
return true;
};这里有一个很关键的细节:Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set。直接设置 input.value 会被 Vue/React 的双向绑定拦截,框架内部可能重写了 setter。通过拿到原生 setter 来设值,再手动触发 input 和 change 事件,就能让框架正确感知到值的变化。
亮点二:纯 Canvas 手绘图表
整个图表模块没有用任何第三方图表库(OxyPlot、LiveChart 什么的统统没用),直接用 Avalonia Canvas 手绘。理由很简单:我只需要一种图表——折线趋势图,且需要多模型叠加。引入一个图表库太重了。
最关键的部分是平滑曲线的实现。原始数据点是离散的,直接连线会有尖角,不好看。我用的是 Catmull-Rom 样条转三次贝塞尔曲线,只需要约 30 行核心代码:
private Geometry BuildSmoothPath()
{
var pts = Points;
var minY = double.MaxValue;
var maxY = double.MinValue;
for (var i = 0; i < pts.Count; i++)
{
if (pts[i].Y < minY) minY = pts[i].Y;
if (pts[i].Y > maxY) maxY = pts[i].Y;
}
var sb = new System.Text.StringBuilder();
sb.Append(CultureInfo.InvariantCulture, $"M {pts[0].X},{pts[0].Y}");
for (var i = 0; i < pts.Count - 1; i++)
{
var p0 = i > 0 ? pts[i - 1] : pts[i];
var p1 = pts[i];
var p2 = pts[i + 1];
var p3 = i < pts.Count - 2 ? pts[i + 2] : pts[i + 1];
// Catmull-Rom → Cubic Bézier 控制点
// τ = 1/6 是均匀参数化下的标准张力系数
var cp1x = p1.X + (p2.X - p0.X) / 6;
var cp1y = Math.Clamp(p1.Y + (p2.Y - p0.Y) / 6, minY, maxY);
var cp2x = p2.X - (p3.X - p1.X) / 6;
var cp2y = Math.Clamp(p2.Y - (p3.Y - p1.Y) / 6, minY, maxY);
sb.Append(CultureInfo.InvariantCulture,
$" C {cp1x},{cp1y} {cp2x},{cp2y} {p2.X},{p2.Y}");
}
return Geometry.Parse(sb.ToString());
}数学原理很直观:给定四个点 P0、P1、P2、P3,P1→P2 这段贝塞尔曲线的两个控制点分别是:
CP1 = P1 + (P2 - P0) / 6
CP2 = P2 - (P3 - P1) / 6
除以 6 对应的是均匀 Catmull-Rom 参数化下的张力系数 τ=1/6,正好映射到三次贝塞尔基函数。一个细节是对 Y 值做了 Math.Clamp,防止曲线超出数据范围产生过冲(overshoot)——这在 Token 用量数据中很重要,因为负数的 Token 消耗没有意义。
最后通过 Avalonia 的 Geometry.Parse 解析路径迷你语言(M x,y C cp1x,cp1y cp2x,cp2y x,y)直接生成几何体,再丢给 Avalonia.Controls.Shapes.Path 渲染。整个过程干净利落,13 个单元测试专门覆盖图表布局计算的各种边界情况。
亮点三:DPAPI 凭证加密
用户的账号密码需要存储在本地,但又不能明文放在 JSON 里。这里直接用了 Windows DPAPI(Data Protection API),一行代码搞定:
private static string Encrypt(string plainText)
{
var bytes = Encoding.UTF8.GetBytes(plainText);
var encrypted = ProtectedData.Protect(
bytes, null, DataProtectionScope.CurrentUser);
return Convert.ToBase64String(encrypted);
}
private static string Decrypt(string? protectedText)
{
var bytes = Convert.FromBase64String(protectedText);
var decrypted = ProtectedData.Unprotect(
bytes, null, DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(decrypted);
}DataProtectionScope.CurrentUser 意味着加密后的数据只有当前 Windows 用户才能解密,其他用户或同一台机器上的其他账户都无法读取。不需要自己管理密钥,Windows 底层用用户的登录凭据来派生加密密钥。
没有用 AES、没有硬编码密钥、没有引入任何第三方加密库——DPAPI 就是为此而生的。
从 WPF 到 Avalonia 的迁移
ZKanban 最初是用 WPF 写的。写完第一版之后,我花了一个下午把它迁移到了 Avalonia。原因有两个:
- Avalonia 12 刚发布,正式支持了 WebView2 控件(
Avalonia.Controls.WebView),之前一直缺这个能力 - 未来可能跨平台。虽然目前 WebView2 只支持 Windows,但 Avalonia 本身是跨平台的,如果以后有 Linux/macOS 的 WebView 方案,迁移成本几乎为零
迁移过程其实挺顺利的,4 个 commit 就搞定了:
- MainWindow 从 WPF 迁移到 Avalonia,替换为 NativeWebView
- SettingsWindow 迁移,自己写了个 SimpleMessageBox 替代 WPF 的 MessageBox
- 自动化服务和 ViewModel 全部替换为 Avalonia 类型(IBrush、Point、Geometry 等)
- 更新构建工具,删除旧的 WPF 截图
主要的工作量在于类型映射:SolidColorBrush 换命名空间、Geometry 换命名空间、XAML 命名空间从 WPF 的换到 Avalonia 的。逻辑代码几乎不需要改动。
开发中的几个感悟
「没有 API」不等于「不能做」。很多开发者遇到平台没有公开 API 就放弃了,但只要有一个浏览器能访问的网页,理论上你就能通过 WebView 自动化来「创造」一个 API。这个思路在监控类、数据采集类工具中非常实用。
不要为了用库而用库。图表场景我只用了 Canvas + 30 行样条代码,如果引入 LiveChart 或者 OxyPlot,不仅包体积变大,样式自定义反而更麻烦。自己写的 30 行代码,完全可控,出了问题也好 debug。
Single-file publish 真香。.NET 的自包含单文件发布已经很成熟了,Trimmed 之后体积合理,用户下载一个 exe 双击就能跑,不需要安装任何运行时。配合 GitHub Actions 自动发布,推一个 tag 就出 Release,体验非常丝滑。
总结
ZKanban 整个项目的代码量大约 3,400 行,其中 UI 样式 496 行纯手写 XAML、自动化服务 580 行、主窗口逻辑 700+ 行。麻雀虽小,五脏俱全——从数据采集到图表渲染到安全存储,每一层都有值得聊的技术选择。
项目已在 GitHub 开源(MIT 协议),欢迎 star 和 issue。如果你也在用智谱 AI 的 API,不妨试试这个小组件。
未来计划:
- 支持更多 API 平台(OpenAI、Anthropic 等)的用量监控
- 添加用量预警通知(Token 消耗超过阈值时弹窗提醒)
- 探索 Avalonia 跨平台的可能性