摘要:本文记录从零开发一个桌面挂件工具 credgauge 的完整过程。该工具用 Electron 实现桌面置顶小窗,实时轮询 DeepSeek 官方 API 和 ApiNebula 中转站的余额信息,每 60 秒自动刷新。文章涵盖需求分析、架构选型、配置与隐私方案、三种启动方式(终端/双击/开机自启)的实现,并重点复盘了 Windows 桌面开发中四个典型坑:VBScript 中文编码、spawn 链路里的 cmd 窗口、Nushell PATH 继承、开机自启方案选型。适合对 Electron 桌面应用、Node.js CLI、Windows 系统编程感兴趣的开发者阅读。


适合阅读人群:Electron 初学者 / Node.js 桌面工具开发者 / 经常使用 AI API 的开发者

涉及技术:Electron、Node.js (ESM)、VBScript、Windows 启动机制、IPC 通信、零依赖 .env 加载

阅读收获:

  1. 掌握 Electron 无边框置顶挂件的实现方式

  2. 学会 Windows 下真正的"无终端窗口"静默启动方案

  3. 理解 VBScript 编码陷阱与 Node.js spawn 的 shell 选项差异

  4. 获得一个可直接使用的开源小工具


@TOC

一、需求背景:为什么需要余额挂件

1.1 痛点描述

日常使用 AI API 的开发者大概都有过这样的体验:

  • 打开 DeepSeek 控制台查余额 → 登录、点菜单、等加载

  • 切到中转站 ApiNebula 看额度 → 又一轮登录、点菜单、等加载

  • 回到 IDE 继续写代码

  • 一小时后重复上述动作

尤其是使用中转站的场景,中转站余额与官方余额是两套独立体系,必须分别查看。一旦中转站额度耗尽,请求开始报 401 Unauthorized,才发现该充值——这种"事后发现"的体验很糟。

1.2 需求拆解

把这个问题抽象成具体需求:

需求点解决方案
多服务余额聚合展示统一 provider 接口,并发查询
常驻可见Electron 桌面置顶小窗
自动刷新定时轮询(60s)
不打扰半透明、无边框、可拖动
低门槛启动双击即开、支持开机自启
配置安全.env 本地存储,不进版本库

这就是 credgauge 的设计起点。

二、整体设计

2.1 技术选型

层选型理由
运行时Node.js 18+ (ESM)内置 fetch,无需 axios
桌面框架Electron跨平台、窗口可控性强
配置.env(自实现加载)零依赖,不引 dotenv
启动器VBScript + NodeWindows 原生,无终端窗口
开机自启启动文件夹快捷方式比 Electron 官方 API 更可控

核心原则:零运行时依赖。整个 package.json 的 dependencies 只有 electron 一个,其余全部用 Node 内置模块。

2.2 项目结构

 credgauge/
 ├── start.vbs                 # 双击静默启动入口(无终端窗口)
 ├── .env.example              # 配置模板(不含真实凭证)
 ├── .env                      # 真实配置(.gitignore 排除)
 ├── package.json
 └── src/
     ├── index.js              # 库入口,导出各 provider
     ├── cli.js                # CLI(查询/挂件/开机自启)
     ├── cre.js                # 简写入口(交互式配置 + 启动)
     ├── silent.js             # 静默启动入口(供 start.vbs)
     ├── env.js                # .env 加载器(零依赖)
     ├── setup.js              # 交互式配置引导
     ├── providers/
     │   ├── deepseek.js       # DeepSeek API 封装
     │   └── apinebula.js      # ApiNebula (New API) 封装
     └── widget/
         ├── main.js           # Electron 主进程
         ├── preload.js        # IPC 桥
         └── renderer/
             └── index.html    # 挂件 UI

2.3 数据流

 ┌─────────────┐    fetch     ┌──────────────┐
 │ DeepSeek API │ ──────────► │              │
 └─────────────┘              │              │
                              │  widget/main │ ── IPC ──► renderer
 ┌─────────────┐    fetch     │  (并发查询)   │
 │ ApiNebula API│ ──────────► │              │
 └─────────────┘              └──────────────┘
         ▲                            │
         │        60s 轮询             ▼
         └──────────────────── renderer 渲染

每个 provider 返回统一格式 { name, balance, currency, available },主进程并发查询所有已配置的服务,通过 IPC 推给渲染进程。

三、核心实现

3.1 Provider 统一接口

两个 provider 都返回相同结构,便于主进程统一处理:

 // 返回格式
 {
   name: "DeepSeek",        // 服务名
   balance: 0.39,           // 余额数值
   currency: "CNY",         // 货币
   available: true          // 是否可用
 }

DeepSeek 调用官方 /user/balance 接口,ApiNebula 调用 New API 架构的 /api/user/self 接口。两者都用 Node 内置 fetch,无需引入 HTTP 库。

3.2 Electron 主进程:无边框置顶小窗

挂件窗口的关键参数:

 function createWindow() {
   const count = configuredCount();
   // 根据已配置服务数量自适应高度
   const height = count === 0 ? 56 : count === 1 ? 56 : 80;
   win = new BrowserWindow({
     width: 170,
     height,
     frame: false,           // 无边框
     transparent: true,      // 透明背景
     resizable: false,
     alwaysOnTop: true,      // 置顶
     skipTaskbar: true,      // 不显示在任务栏
     show: false,
     webPreferences: {
       preload: join(__dirname, "preload.js"),
       contextIsolation: true,
       nodeIntegration: false,
     },
   });
   win.loadFile(join(__dirname, "renderer", "index.html"));
   win.once("ready-to-show", () => {
     win.show();
     refresh();
   });
   ipcMain.on("widget:close", () => app.quit());
   ipcMain.on("widget:refresh", () => refresh());
 }

定时轮询用 setInterval,60 秒一次:

 app.whenReady().then(() => {
   createWindow();
   timer = setInterval(refresh, 60_000);
 });
 app.on("window-all-closed", () => {
   if (timer) clearInterval(timer);
   app.quit();
 });

3.3 零依赖 .env 加载器

为了不引入 dotenv,手写了一个 20 行的加载器。支持注释、去引号、不覆盖已存在的环境变量:

 // src/env.js
 import { readFileSync, existsSync } from "node:fs";
 import { fileURLToPath } from "node:url";
 import { dirname, join } from "node:path";
 ​
 export function loadEnv() {
   const dir = dirname(fileURLToPath(import.meta.url));
   const envPath = join(dir, "..", ".env");
   if (!existsSync(envPath)) return;
   const content = readFileSync(envPath, "utf-8");
   for (const line of content.split(/\r?\n/)) {
     const trimmed = line.trim();
     if (!trimmed || trimmed.startsWith("#")) continue;  // 跳过注释和空行
     const eq = trimmed.indexOf("=");
     if (eq === -1) continue;
     const key = trimmed.slice(0, eq).trim();
     let val = trimmed.slice(eq + 1).trim();
     // 去除首尾引号
     if ((val.startsWith('"') && val.endsWith('"')) ||
         (val.startsWith("'") && val.endsWith("'"))) {
       val = val.slice(1, -1);
     }
     // 不覆盖已存在的环境变量
     if (key && !(key in process.env)) process.env[key] = val;
   }
 }

3.4 隐私保护:.env 不进版本库

这是公开项目必须严守的底线。.gitignore 里排除 .env,仓库只提交 .env.example 模板:

 # .gitignore
 .env
 .env.local
 # .env.example(模板,不含真实凭证)
 DEEPSEEK_API_KEY=sk-your-deepseek-key
 APINEBULA_BASE_URL=https://apinebula.ai
 APINEBULA_TOKEN=your-system-token
 APINEBULA_USER_ID=your-user-id

每次提交前用 git ls-files .env 确认未被跟踪,是个好习惯。

四、三种启动方式的实现

这是本项目打磨最久的部分。一个好工具应该"打开就用",而不是让用户每次开终端敲命令。

4.1 终端启动(首次配置用)

cre                        # 简写命令,首次会交互式引导配置
credgauge widget           # 等同效果

首次运行会引导填入 API Key / 令牌,写入 .env。配置一次,后续不用再管。交互逻辑用 Node 内置 readline/promises 实现,零依赖。

4.2 双击启动(日常用)

项目根目录的 start.vbs,双击即可静默启动挂件,不弹出任何终端窗口。

' start.vbs —— Silent launcher for credgauge widget
Dim fso, sh, here
Set sh = CreateObject("WScript.Shell")
Set fso = CreateObject("Scripting.FileSystemObject")
here = fso.GetParentFolderName(WScript.ScriptFullName)
sh.CurrentDirectory = here
sh.Run "node src\silent.js", 0, False    ' 0 = SW_HIDE 隐藏窗口
Set sh = Nothing
Set fso = Nothing

配合 src/silent.js(跳过交互配置,直接拉起 Electron):

// src/silent.js
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { loadEnv } from "./env.js";
import electronPath from "electron";  // 直接拿到 electron.exe 绝对路径

loadEnv();

const __dirname = dirname(fileURLToPath(import.meta.url));
const mainFile = join(__dirname, "widget", "main.js");

const child = spawn(electronPath, [mainFile], {
  stdio: "ignore",
  shell: false,          // 关键:不经过 shell,避免 cmd 窗口
  windowsHide: true,     // 隐藏子进程窗口
  detached: true,        // 脱离父进程
});
child.on("error", () => process.exit(1));
child.unref();

4.3 开机自启

credgauge autostart on       # 开启
credgauge autostart status   # 查看状态
credgauge autostart off      # 关闭

原理:在 Windows 启动文件夹创建指向 start.vbs 的快捷方式。

// cli.js 中的 cmdAutostart 实现(精简版)
function cmdAutostart(sub) {
  const lnkPath = join(getStartupDir(), "credgauge.lnk");
  const vbsPath = join(__dirname, "..", "start.vbs");

  if (sub === "on") {
    // 用 PowerShell 的 WScript.Shell COM 对象生成快捷方式
    const ps = `$ws=New-Object -ComObject WScript.Shell;
      $s=$ws.CreateShortcut('${lnkPath}');
      $s.TargetPath='wscript.exe';
      $s.Arguments='"${vbsPath}"';
      $s.WindowStyle=7;
      $s.Save()`;
    execSync(`powershell -NoProfile -Command "${ps}"`, { stdio: "ignore" });
  } else if (sub === "off") {
    execSync(`powershell -NoProfile -Command "Remove-Item '${lnkPath}' -Force"`,
             { stdio: "ignore" });
  }
}

快捷方式位于:%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\credgauge.lnk

五、踩坑记录(重点)

开发过程中踩了四个 Windows 桌面开发的经典坑,逐个记录。

5.1 坑一:VBScript 中文注释导致"缺少对象"

现象:双击 start.vbs 报错 缺少对象 'sh'。

第一版代码:

' 双击静默启动 credgauge 挂件(无终端窗口)
Set sh = CreateObject("WScript.Shell")

排查过程:报错指向 sh,但 Set sh = ... 明明写了。折腾半天才意识到——VBScript 默认按 ANSI 编码解析,而文件存成 UTF-8。中文注释是多字节字符,ANSI 解析时字节错位,导致 Set sh = ... 这行没被正确识别,后续用到 sh 就报缺少对象。

修复:注释和字符串全改成 ASCII。

' Silent launcher for credgauge widget (no console window)
Dim fso, sh, here
Set sh = CreateObject("WScript.Shell")

经验:VBScript 里想写中文,要么存成 UTF-16 LE with BOM,要么干脆别写中文。这个坑在英文资料里几乎找不到,中文开发者特别容易踩。

5.2 坑二:终端窗口怎么都去不掉

现象:双击 start.vbs 后挂件起来了,但伴随一个一闪而过的黑色 cmd 窗口。

原因有两层:

  1. start.vbs 用 sh.Run "cmd /c node src\silent.js", 0, False —— 经由 cmd 起子进程,cmd 窗口闪现

  2. silent.js 用 spawn(electronPath, ..., { shell: true }) —— shell: true 又起了一个 cmd

关键认知:Node.js 的 spawn 在 Windows 上,shell: true 会走 cmd.exe /c,即使 windowsHide: true 也可能闪窗。要彻底无窗口,必须 shell: false 并直接传 .exe 路径。

最终方案:

// silent.js —— 直接 import electron 包,拿到 exe 绝对路径
import electronPath from "electron";

const child = spawn(electronPath, [mainFile], {
  stdio: "ignore",
  shell: false,          // 不经过 shell
  windowsHide: true,
  detached: true,
});
child.unref();
' start.vbs —— 直接调用 node,不再经由 cmd
sh.Run "node src\silent.js", 0, False

启动链路变成 wscript → node(SW_HIDE)→ electron.exe,全程无 cmd 窗口。

经验:electron 这个 npm 包的默认导出就是 electron.exe 的绝对路径,很多人不知道这点,还在用 node_modules/.bin/electron(那个是 shell 脚本,会起 cmd)。

5.3 坑三:Nushell 找不到全局命令

现象:cre 在 cmd / PowerShell 正常,但在 Nushell 报 Command cre not found。

原因:cre 通过 npm link 注册到 npm 全局 bin 目录,但 Nushell 不继承这个目录到 PATH。

修复:在 config.nu 手动追加:

$env.PATH = ($env.PATH | append "C:/Users/<用户名>/AppData/Roaming/.../node")

关键陷阱:路径必须用正斜杠。Windows 反斜杠在 Nushell 字符串里是转义符,写反斜杠会报:

Error: × Invalid literal
  ╭─[config.nu:899:36]
  │ $env.PATH = ($env.PATH | append "C:\Users\...")
  ·                                    ─┬─
  ·                                     ╰── unrecognized escape after '\' in string

经验:跨 shell 兼容是个无底洞。cmd、PowerShell、Nushell、Git Bash 的 PATH、转义、引号规则都不一样。能避开就避开,避不开就老老实实查文档。

5.4 坑四:开机自启方案选型

Electron 官方 API 的局限:

// 官方 API,看似优雅
app.setLoginItemSettings({ openAtLogin: true });

实际用起来有两个问题:

  1. Windows 上稳定性一般,有时不生效

  2. 它启动的是 electron 进程,无法接我这套 VBS 静默启动链路

最终方案:弃用官方 API,改用 Windows 启动文件夹 + 快捷方式 的老办法。

方案优点缺点
Electron 官方 API跨平台、代码少Windows 不稳、无法接 VBS 链路
启动文件夹快捷方式可靠、可控、能接 VBS仅 Windows、需写 COM 代码

选了后者。用 PowerShell 调 WScript.Shell COM 对象生成 .lnk,几行代码搞定,从此开机自启稳如老狗。

六、安装与使用

6.1 安装三步走

git clone https://github.com/w-zjj/credgauge.git
cd credgauge
npm install

6.2 配置凭证

Copy-Item .env.example .env
# 编辑 .env 填入你的凭证

需要配置的内容:

服务变量获取方式
DeepSeekDEEPSEEK_API_KEYhttps://platform.deepseek.com
ApiNebulaAPINEBULA_TOKEN控制台个人中心生成系统令牌
ApiNebulaAPINEBULA_USER_IDF12 控制台执行 JSON.parse(localStorage.getItem('user')).id
ApiNebulaAPINEBULA_BASE_URL默认 https://apinebula.ai,一般不改

6.3 启动

npm link              # 全局注册 cre 命令(可选)
cre                   # 首次启动并配置
credgauge autostart on  # 想开机自启就加上这条

配置完成后,日常使用双击 start.vbs 即可,或开机自动启动。

七、命令一览

命令说明
cre启动桌面挂件(简写)
credgauge widget启动桌面挂件
credgauge deepseek查询 DeepSeek 余额
credgauge apinebula查询 ApiNebula 余额
credgauge all查询所有已配置的服务
credgauge autostart on开启开机自启
credgauge autostart off关闭开机自启
credgauge autostart status查看开机自启状态
credgauge -v显示版本
credgauge -h显示帮助

八、总结与反思

8.1 做对了什么

  1. 零运行时依赖:除了 electron,不引任何包,安装快、体积小、维护成本低

  2. 配置安全:.env 严格排除在版本库之外,模板与真实凭证分离

  3. 启动体验:三种启动方式覆盖不同场景,双击和开机自启真正做到了"无感"

  4. provider 抽象:统一接口让新增服务变得容易,未来加 OpenAI、Claude 只需写新 provider

8.2 待改进点

  1. 跨平台:目前 start.vbs 和启动文件夹方案是 Windows 专属,macOS/Linux 需要另写(可用 plist / .desktop)

  2. 错误提示:挂件内错误提示较简陋,令牌失效时只是状态点变红,用户不一定知道原因

  3. 配置 UI:目前首次配置在终端交互,非技术用户不友好,可考虑加图形化配置窗口

8.3 一点感悟

credgauge 不是什么复杂项目,代码量也不大,但它解决了一个真实的、反复出现的小烦扰。做这类小工具的乐趣在于:把一个具体的痛点想清楚,用最轻的方式解决掉,然后在和系统底层(VBS 编码、进程窗口、PATH、快捷方式)打交道的过程中学到一堆细节。

这些东西单独看都不值一提,但攒起来就是对系统的一份理解。


项目地址:https://github.com/w-zjj/credgauge

License:MIT,欢迎白嫖和 PR。


关键词:Electron、Node.js、桌面挂件、Windows 静默启动、VBScript、开机自启、AI API、DeepSeek、ApiNebula、零依赖

版权声明:本文为原创内容,转载请注明出处。项目代码基于 MIT 协议开源。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐