balance-dsh 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,9 +7,10 @@ DSH 插件:在 Web UI 中直接显示 DeepSeek 账户余额和当前会话 tok
7
7
  - **侧边栏余额行**:侧边栏展开时显示在底部(紧贴"设置"按钮上方),格式:钱包图标 + `余额` + 金额(带货币单位)+ 时段标签(高峰红 / 空闲绿)。金额按余额状态变色:充足绿色、偏低(<10)琥珀黄、为 0 显示红色"去充值"。
8
8
  - **收起态 FAB**:侧边栏收起时,余额显示为左下角悬浮胶囊。
9
9
  - **自动刷新**:每 60 秒自动刷新一次;每次对话完成(新回复落库)也会立即刷新;右键余额任意区域可手动刷新。
10
- - **余额与使用统计**:单击余额在余额项旁以轻量动画打开局部快捷菜单,展示账户余额、近 90 天计费 Token、估算费用、扫描会话数和按日使用热力图;点击菜单外区域会播放关闭动画,双击余额打开 DeepSeek 官方 API 用量页。
11
- - **高峰/空闲计费**:官方高峰窗口为**周一至周五 09:00-12:00、14:00-18:00(北京时间)**,周末全天空闲。消耗金额**跨时段精确计费**:高峰时段的 token 按高峰价 ×2、空闲时段按目录价,按事件时间分桶计算。
10
+ - **余额与使用统计**:单击余额打开**居中弹窗**(带半透明模糊遮罩),展示账户余额、近 90 天计费 Token、估算费用、活跃天数、按模型与高峰/空闲拆分、今日/近 7 天/近 30 天、日均与峰值日,以及按日使用热力图;点遮罩 / 按 `Esc` / 点关闭按钮均可关闭,双击余额在**应用内**打开 DeepSeek 官方用量页(官方版自带内嵌浏览器,不再弹出系统浏览器;旧版环境自动回退到浏览器)。
11
+ - **高峰/空闲计费**:官方高峰窗口为**工作日 09:00-12:00、14:00-18:00(北京时间)**;周末、**中国法定节假日全天**都按空闲计费,**调休上班的周末按工作日**计峰谷。节假日安排会自动从公开数据源补录(内置 2026 年表作离线兜底,无需手动更新)。消耗金额**跨时段精确计费**:高峰时段的 token 按高峰价 ×2、空闲时段按目录价,按事件时间分桶计算。
12
12
  - **会话消耗行**:对话框下方实时显示当前会话消耗(新增 tokens + 估算金额),悬停查看完整明细(模型、时段拆分、计费口径)。
13
+ - **应用内查看官方用量**:双击余额、或点统计面板里的「查看官方用量」,都会在应用内嵌浏览器里打开官方用量页(余额、消费金额、请求次数、Tokens);「去充值」同理。旧版 DSH 缺少内嵌浏览器时自动回退系统浏览器。
13
14
  - **`/balance` 命令**:命令行查询余额明细和当前会话消耗。
14
15
 
15
16
  ### 使用统计口径
@@ -17,7 +18,7 @@ DSH 插件:在 Web UI 中直接显示 DeepSeek 账户余额和当前会话 tok
17
18
  - 热力图按北京时间聚合最近 90 天持久化会话中的 `assistant/message` Token usage。
18
19
  - 费用按照每条消息的实际模型和峰谷时段估算,最终金额以 DeepSeek 官方账单为准。
19
20
  - 为避免历史会话过多拖慢 DSH,最多扫描最近 200 个会话;服务端统计和客户端展示结果均缓存 5 分钟,重复打开菜单不会重复请求。
20
- - 若未配置 API Key,仍可查看本地 Token 与费用统计,但账户余额会显示为不可用。
21
+ - 账户余额优先读取 **DSH 官方账号**(官方版登录后即可用,无需 API Key);未登录官方账号时回退到 API Key。两者都不可用时仍可查看本地 Token 与费用统计,只是余额会显示为不可用。
21
22
 
22
23
  ## 安装
23
24
 
@@ -53,7 +54,11 @@ dsh plugin --profile desktop add github:Rannist/balance-dsh
53
54
  name: balance-dsh
54
55
  ```
55
56
 
56
- ### 设置 DeepSeek API Key(三选一)
57
+ ### 余额来源
58
+
59
+ **官方 DeepSeek Harness 桌面版**:在应用内登录 DeepSeek 账号后,插件会**自动读取官方余额,无需任何配置**。
60
+
61
+ **其他环境**(或不想登录官方账号时):改用 API Key,三选一——
57
62
 
58
63
  1. 环境变量 `DEEPSEEK_API_KEY`,或
59
64
  2. 在注册项的 `config.apiKey` 中设置,或
@@ -77,10 +82,10 @@ dsh plugin --profile desktop add github:Rannist/balance-dsh
77
82
  - {start: 14, end: 18}
78
83
  peakWeekdays: [1, 2, 3, 4, 5] # 可选;1=周一 … 5=周五,0/6=周末
79
84
  price: # 可选;逐项覆盖对应模型价格(元/百万 tokens)
80
- inputCnyPerMillion: 1.5
81
- cacheReadCnyPerMillion: 0.05
85
+ inputCnyPerMillion: 1
86
+ cacheReadCnyPerMillion: 0.02
82
87
  cacheWriteCnyPerMillion: 0
83
- outputCnyPerMillion: 4.5
88
+ outputCnyPerMillion: 4
84
89
  ```
85
90
 
86
91
  > 注意:插件已自动按高峰/空闲时段计费(高峰价 = 目录价 × 2),**不要再设置 `config.peak: true`**,否则会重复翻倍。
@@ -96,20 +101,21 @@ pnpm remove balance-dsh
96
101
 
97
102
  ## 权限 / 数据
98
103
 
99
- - 仅在你**主动刷新**或对话完成时,会向 `api.deepseek.com` 的 `/user/balance` 端点发起一次余额查询。
100
- - 插件需要你的 DeepSeek API Key(仅用于余额查询),不收集、不上传任何会话内容到第三方。
104
+ - 已登录官方账号时,余额通过 **DSH 内置账号服务**读取(登录凭据由应用自身管理,插件不接触、不保存任何 token)。
105
+ - 仅当回退到 API Key 时,才会向 `api.deepseek.com` 的 `/user/balance` 端点查询余额(且只在你**主动刷新**或对话完成时)。
106
+ - 插件不收集、不上传任何会话内容到第三方。
101
107
  - 消费统计完全基于本地会话事件(`request/header`、`assistant/message` 的 token usage)计算,不向 DeepSeek 之外的服务发送数据。
102
108
 
103
- ## 价格表(2026-08-17 生效的 V4 峰谷定价·空闲时段价,元/百万 tokens)
109
+ ## 价格表(空闲时段价,元/百万 tokens)
104
110
 
105
111
  | 模型 | 输入(缓存未命中) | 输入(缓存命中) | 输出 |
106
112
  |---|---|---|---|
107
- | deepseek-v4-flash | 1.5 | 0.05 | 4.5 |
113
+ | deepseek-flash | 1 | 0.02 | 4 |
108
114
  | deepseek-v4-pro | 4.5 | 0.15 | 13.5 |
109
115
  | deepseek-chat(旧) | 2 | 0.5 | 3 |
110
116
  | deepseek-reasoner(旧) | 4 | 1 | 16 |
111
117
 
112
- 未识别模型按 deepseek-v4-flash 兜底;DeepSeek 调价后需同步更新 `lib/index.js` 的 `MODEL_PRICING` 与 `lib/client.js` 的 `DEFAULT_PRICING`。
118
+ 价格自 **2026-09-10 12:00(北京时间)** 起生效(flash 系列由 1.5 / 0.05 / 4.5 下调);**高峰时段 = 空闲价 × 2**。调价前的历史消耗仍按当时的旧价计算,不会被重算。DeepSeek 再次调价时需同步更新 `lib/index.js` 的 `MODEL_PRICING` 与 `lib/client.js` 的 `DEFAULT_PRICING`,并把被替换的旧价移入 `LEGACY_MODEL_PRICING`。
113
119
 
114
120
  ## License
115
121
 
package/lib/client.js CHANGED
@@ -47,6 +47,12 @@ window.__ModuleLoader__.load({
47
47
  .balance-stats-anchor--wide{width:100%}
48
48
  .balance-stats-anchor--compact{position:fixed;left:68px;bottom:12px;z-index:10000}
49
49
  .balance-stats-anchor--compact .balance-fab{position:static}
50
+ .balance-platform-layer{box-sizing:border-box;position:fixed;inset:0;z-index:2147483000;display:flex;flex-direction:column;background:var(--dsw-alias-bg-base-platform,var(--dsw-alias-bg-base,#1b1b1c));animation:balance-stats-backdrop-in .16s var(--ds-ease-in-out,ease-out) both}
51
+ .balance-platform-header{flex:none;display:flex;align-items:center;gap:8px;height:44px;padding:0 12px;border-bottom:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.2));-webkit-app-region:drag}
52
+ .balance-platform-back{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary,inherit);font:inherit;font-size:13px;cursor:pointer;-webkit-app-region:no-drag;transition:background .15s var(--ds-ease-in-out,.2s)}
53
+ .balance-platform-back svg{flex:none;display:block}
54
+ .balance-platform-back:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
55
+ .balance-platform-viewport{flex:1;min-height:0}
50
56
  .balance-stats-layer{box-sizing:border-box;position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.24));backdrop-filter:var(--dsw-mask-blur,blur(2px));-webkit-backdrop-filter:var(--dsw-mask-blur,blur(2px));animation:balance-stats-backdrop-in .16s var(--ds-ease-in-out,ease-out) both}
51
57
  .balance-stats-layer--closing{pointer-events:none;animation:balance-stats-backdrop-out .14s var(--ds-ease-in-out,ease-in) both}
52
58
  .balance-stats-panel{box-sizing:border-box;position:relative;z-index:10000;width:min(800px,100%);max-height:100%;overflow-x:hidden;overflow-y:auto;scrollbar-gutter:stable;padding:0 0 24px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));border:0;border-radius:24px;box-shadow:var(--dsw-elevation-prominent,0 14px 36px rgba(0,0,0,.24));transform-origin:center center;will-change:transform,opacity;contain:layout style;animation:balance-stats-panel-in .26s var(--ds-ease-in-out,ease-out) .04s both;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2,rgba(127,127,127,.35));--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2,rgba(127,127,127,.55))}
@@ -205,6 +211,184 @@ window.__ModuleLoader__.load({
205
211
  return String(n);
206
212
  }
207
213
 
214
+ // ─── 打开 DeepSeek 平台页面(用量 / 充值) ─────────────────────────────
215
+ // 三级策略,逐级回退:
216
+ // 1) DSH 官方版的「原生平台视口」——preload 通过 contextBridge 暴露的 globalThis.dshPlatform
217
+ // (接口:open(page, bounds) / setBounds(bounds) / close(),page 取 "usage" | "top-up")。
218
+ // DSH 设置→账号与余额里的「查询用量」按钮就走它(dsh-client-ui-settings-account 的
219
+ // PlatformOverlay + dshPlatform.open)。这个原生子窗口自带平台凭据(官方注释:
220
+ // "the native child owns remote content and credentials"),所以**不需要网页登录**。
221
+ // 注意:那个包的 platformPages 是包内私有 store,插件无法触发 DSH 自己的 host,
222
+ // 因此这里自己画同款覆盖层并把视口坐标喂给 bridge。
223
+ // 2) 应用内侧栏浏览器(dsh-client-ui-sidebar-browser)——旧版/无原生视口时用,
224
+ // 按 DSH 自身 openExternalLink 的判定:sidebarRightTabs 注册了 "browser" 才可用。
225
+ // 该视口**没有**平台凭据,打开 platform.deepseek.com 会要求网页登录。
226
+ // 3) window.open 交给系统浏览器。
227
+ // 全程 try/catch:DSH 内部机制无兼容承诺,任一级失败都要继续降级,不能拖垮插件。
228
+ let pluginCtx = null;
229
+ const USAGE_URL = "https://platform.deepseek.com/usage";
230
+ const TOP_UP_URL = "https://platform.deepseek.com/top_up";
231
+
232
+ /** 当前原生平台视口的覆盖层;null 表示未打开。 */
233
+ let platformOverlay = null;
234
+
235
+ /** 关闭原生平台视口覆盖层(幂等;bridge.close + 摘除 DOM + 解绑监听)。 */
236
+ function closePlatformPage() {
237
+ if (platformOverlay === null) return;
238
+ const { layer, bridge, observer, onKey } = platformOverlay;
239
+ platformOverlay = null;
240
+ try {
241
+ observer.disconnect();
242
+ } catch {
243
+ /* ignore */
244
+ }
245
+ try {
246
+ window.removeEventListener("keydown", onKey, true);
247
+ } catch {
248
+ /* ignore */
249
+ }
250
+ try {
251
+ const closing = bridge.close();
252
+ if (closing && typeof closing.catch === "function") closing.catch(() => {});
253
+ } catch {
254
+ /* ignore */
255
+ }
256
+ try {
257
+ layer.remove();
258
+ } catch {
259
+ /* ignore */
260
+ }
261
+ }
262
+
263
+ /**
264
+ * 用原生平台视口打开官方平台页面。
265
+ * @param page - "usage"(用量)或 "top-up"(充值)。
266
+ * @returns 是否成功接管(false 时调用方继续降级)。
267
+ */
268
+ function openPlatformPage(page) {
269
+ const bridge = typeof globalThis === "undefined" ? void 0 : globalThis.dshPlatform;
270
+ if (!bridge || typeof bridge.open !== "function") return false;
271
+ try {
272
+ closePlatformPage();
273
+ const layer = document.createElement("div");
274
+ layer.className = "balance-platform-layer";
275
+ const header = document.createElement("header");
276
+ header.className = "balance-platform-header";
277
+ // 与 DSH 的 PlatformOverlay 一致:让标题栏可拖动窗口
278
+ header.setAttribute("data-window-drag", "");
279
+ const back = document.createElement("button");
280
+ back.type = "button";
281
+ back.className = "balance-platform-back";
282
+ // 与 DSH 的 PlatformOverlay 一致:chevron 图标 + 文案。DSH 用的是 primitives 的
283
+ // IconChevronLeftOutlineRegular 组件;这里覆盖层是纯 DOM,自绘同款 SVG,
284
+ // 免得为了一个图标把 React 渲染引进覆盖层。
285
+ const chevron = document.createElementNS("http://www.w3.org/2000/svg", "svg");
286
+ chevron.setAttribute("viewBox", "0 0 16 16");
287
+ chevron.setAttribute("width", "16");
288
+ chevron.setAttribute("height", "16");
289
+ chevron.setAttribute("aria-hidden", "true");
290
+ const chevronPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
291
+ chevronPath.setAttribute("d", "M10 3.5 5.5 8 10 12.5");
292
+ chevronPath.setAttribute("fill", "none");
293
+ chevronPath.setAttribute("stroke", "currentColor");
294
+ chevronPath.setAttribute("stroke-width", "1.6");
295
+ chevronPath.setAttribute("stroke-linecap", "round");
296
+ chevronPath.setAttribute("stroke-linejoin", "round");
297
+ chevron.appendChild(chevronPath);
298
+ back.appendChild(chevron);
299
+ back.appendChild(document.createTextNode("返回 DeepSeek Harness"));
300
+ back.addEventListener("click", closePlatformPage);
301
+ header.appendChild(back);
302
+ const viewport = document.createElement("div");
303
+ viewport.className = "balance-platform-viewport";
304
+ layer.appendChild(header);
305
+ layer.appendChild(viewport);
306
+ document.body.appendChild(layer);
307
+
308
+ // 原生视口按 CSS 像素贴合这个占位元素(DSH 也是拿 getBoundingClientRect())
309
+ const bounds = () => {
310
+ const rect = viewport.getBoundingClientRect();
311
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
312
+ };
313
+ const observer = new ResizeObserver(() => {
314
+ if (platformOverlay === null) return;
315
+ try {
316
+ const moved = bridge.setBounds(bounds());
317
+ if (moved && typeof moved.catch === "function") moved.catch(() => {});
318
+ } catch {
319
+ /* ignore */
320
+ }
321
+ });
322
+ const onKey = (event) => {
323
+ if (event.key === "Escape") closePlatformPage();
324
+ };
325
+ platformOverlay = { layer, bridge, observer, onKey };
326
+ observer.observe(viewport);
327
+ window.addEventListener("keydown", onKey, true);
328
+ const opened = bridge.open(page, bounds());
329
+ if (opened && typeof opened.catch === "function") opened.catch(() => closePlatformPage());
330
+ return true;
331
+ } catch {
332
+ closePlatformPage();
333
+ return false;
334
+ }
335
+ }
336
+
337
+ /** 打开应用内嵌浏览器的标签页(无原生视口时的二级回退)。 */
338
+ function openSidebarBrowser(url) {
339
+ try {
340
+ const get = pluginCtx && typeof pluginCtx.get === "function" ? pluginCtx.get.bind(pluginCtx) : null;
341
+ if (get === null) return false;
342
+ const right = get("sidebarRight");
343
+ const tabs = get("sidebarRightTabs");
344
+ const hasBrowser = tabs && typeof tabs.get === "function" && tabs.get("browser") !== undefined;
345
+ if (right && typeof right.openTab === "function" && hasBrowser) {
346
+ right.openTab("browser", { params: { url } });
347
+ return true;
348
+ }
349
+ } catch {
350
+ // 侧栏浏览器不可用(旧版 DSH / 服务未就绪)
351
+ }
352
+ return false;
353
+ }
354
+
355
+ /** 最终回退:交给系统浏览器。 */
356
+ function openInSystemBrowser(url) {
357
+ try {
358
+ window.open(url, "_blank", "noopener,noreferrer");
359
+ } catch {
360
+ /* ignore */
361
+ }
362
+ }
363
+
364
+ /** 打开平台页面:原生视口 → 侧栏浏览器 → 系统浏览器。 */
365
+ function openPlatformOrUrl(page, url) {
366
+ if (openPlatformPage(page)) return;
367
+ if (openSidebarBrowser(url)) return;
368
+ openInSystemBrowser(url);
369
+ }
370
+
371
+ /**
372
+ * 外部链接的通用属性:保留 href(可右键复制),点击时拦截默认跳转、改为应用内打开。
373
+ * @param url - 平台页面地址(用作 href 与最终回退目标)。
374
+ * @param page - 原生视口的页面标识;缺省时跳过原生视口(例如非平台链接)。
375
+ * @param extra - 追加的事件处理器等属性。
376
+ */
377
+ function externalLink(url, page, extra) {
378
+ const options = extra || {};
379
+ return {
380
+ href: url,
381
+ target: "_blank",
382
+ rel: "noreferrer",
383
+ ...options,
384
+ onClick: (event) => {
385
+ if (event && typeof event.preventDefault === "function") event.preventDefault();
386
+ if (typeof options.onClick === "function") options.onClick(event);
387
+ openPlatformOrUrl(page, url);
388
+ },
389
+ };
390
+ }
391
+
208
392
  // ─── 当前会话模型:按模型取价(服务端 /balance-api 按 ?model= 返回对应价格) ───
209
393
  let sessionsFace = void 0;
210
394
  let currentModel = "";
@@ -294,19 +478,76 @@ window.__ModuleLoader__.load({
294
478
 
295
479
  // ─── 高峰/空闲时段(纯客户端判断,无需服务端) ────────────────────────
296
480
  // V4 峰谷定价(2026-08-17 生效):高峰价 = 空闲价 × 2。
297
- // 官方高峰窗口(北京时间)为 周一至周五 09:00-12:00、14:00-18:00;周末全天空闲。
481
+ // 官方高峰窗口(北京时间)为 工作日 09:00-12:00、14:00-18:00;
482
+ // 周末与法定节假日全天空闲,但「调休上班的周末」按工作日算(2026-09-20 DeepSeek 明确)。
298
483
  const PEAK_WINDOWS = [
299
484
  { start: 9, end: 12 },
300
485
  { start: 14, end: 18 },
301
486
  ];
302
487
  const PEAK_WEEKDAYS = [1, 2, 3, 4, 5]; // 1=周一 … 5=周五,0/6=周末
303
- function currentPeriod() {
304
- const d = new Date(Date.now() + 8 * 60 * 60 * 1000); // 北京时间
488
+ // 中国大陆法定节假日与调休(北京时区)。**必须与 lib/index.js 的 CN_CALENDAR 完全一致**
489
+ // (服务端负责算钱、客户端只负责显示时段标签)。来源:国办发明电〔2025〕7号,每年需补录。
490
+ const CN_CALENDAR = {
491
+ // 元旦:1/1-1/3 放假,1/4(周日) 上班
492
+ "2026-01-01": "holiday", "2026-01-02": "holiday", "2026-01-03": "holiday", "2026-01-04": "workday",
493
+ // 春节:2/15-2/23 放假,2/14(周六)、2/28(周六) 上班
494
+ "2026-02-14": "workday",
495
+ "2026-02-15": "holiday", "2026-02-16": "holiday", "2026-02-17": "holiday", "2026-02-18": "holiday",
496
+ "2026-02-19": "holiday", "2026-02-20": "holiday", "2026-02-21": "holiday", "2026-02-22": "holiday",
497
+ "2026-02-23": "holiday", "2026-02-28": "workday",
498
+ // 清明节:4/4-4/6 放假(无调休)
499
+ "2026-04-04": "holiday", "2026-04-05": "holiday", "2026-04-06": "holiday",
500
+ // 劳动节:5/1-5/5 放假,5/9(周六) 上班
501
+ "2026-05-01": "holiday", "2026-05-02": "holiday", "2026-05-03": "holiday", "2026-05-04": "holiday",
502
+ "2026-05-05": "holiday", "2026-05-09": "workday",
503
+ // 端午节:6/19-6/21 放假(无调休)
504
+ "2026-06-19": "holiday", "2026-06-20": "holiday", "2026-06-21": "holiday",
505
+ // 国庆节调休:9/20(周日) 上班
506
+ "2026-09-20": "workday",
507
+ // 中秋节:9/25-9/27 放假(无调休)
508
+ "2026-09-25": "holiday", "2026-09-26": "holiday", "2026-09-27": "holiday",
509
+ // 国庆节:10/1-10/7 放假,10/10(周六) 上班
510
+ "2026-10-01": "holiday", "2026-10-02": "holiday", "2026-10-03": "holiday", "2026-10-04": "holiday",
511
+ "2026-10-05": "holiday", "2026-10-06": "holiday", "2026-10-07": "holiday", "2026-10-10": "workday",
512
+ };
513
+
514
+ // 节假日自动补录:与 lib/index.js 同源、同逻辑(客户端只用来显示时段标签)。
515
+ // 拉取失败静默 —— 拿不到就继续用上面的内置表。
516
+ const CN_CALENDAR_SOURCE = "https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master";
517
+ const cnCalendarYears = new Set();
518
+ async function ensureCnCalendar(year) {
519
+ if (cnCalendarYears.has(year)) return;
520
+ cnCalendarYears.add(year); // 先占位,避免并发重复请求
521
+ try {
522
+ const res = await fetch(CN_CALENDAR_SOURCE + "/" + year + ".json", { signal: AbortSignal.timeout(8000) });
523
+ if (!res.ok) return;
524
+ const data = await res.json();
525
+ if (!Array.isArray(data?.days)) return;
526
+ for (const item of data.days) {
527
+ if (item && typeof item.date === "string" && typeof item.isOffDay === "boolean") {
528
+ CN_CALENDAR[item.date] = item.isOffDay ? "holiday" : "workday";
529
+ }
530
+ }
531
+ } catch {
532
+ // 网络不可用:保留内置表
533
+ }
534
+ }
535
+ {
536
+ const y = new Date(Date.now() + 8 * 60 * 60 * 1000).getUTCFullYear();
537
+ void ensureCnCalendar(y - 1);
538
+ void ensureCnCalendar(y);
539
+ void ensureCnCalendar(y + 1);
540
+ }
541
+ function currentPeriod(atMs) {
542
+ const d = new Date((atMs === undefined ? Date.now() : atMs) + 8 * 60 * 60 * 1000); // 北京时间
543
+ const kind = CN_CALENDAR[
544
+ d.getUTCFullYear() + "-" + String(d.getUTCMonth() + 1).padStart(2, "0") + "-" + String(d.getUTCDate()).padStart(2, "0")
545
+ ];
546
+ if (kind === "holiday") return "offpeak"; // 法定节假日:全天按空闲
305
547
  const hour = d.getUTCHours();
306
548
  const weekday = d.getUTCDay(); // 0=周日 … 6=周六
307
- return PEAK_WEEKDAYS.includes(weekday) && PEAK_WINDOWS.some((w) => hour >= w.start && hour < w.end)
308
- ? "peak"
309
- : "offpeak";
549
+ const onDuty = PEAK_WEEKDAYS.includes(weekday) || kind === "workday"; // 调休上班的周末按工作日算
550
+ return onDuty && PEAK_WINDOWS.some((w) => hour >= w.start && hour < w.end) ? "peak" : "offpeak";
310
551
  }
311
552
  /** 按当前时段调整价格:高峰 ×2、空闲原价。注意:若服务端已配 config.peak:true
312
553
  * (强制高峰价翻倍),此处会再乘一次,两种方式不要同时使用。 */
@@ -740,9 +981,7 @@ window.__ModuleLoader__.load({
740
981
  }),
741
982
  jsx.jsx("a", {
742
983
  className: "balance-stats-link",
743
- href: "https://platform.deepseek.com/usage",
744
- target: "_blank",
745
- rel: "noreferrer",
984
+ ...externalLink(USAGE_URL, "usage"),
746
985
  children: "查看官方用量",
747
986
  }),
748
987
  ],
@@ -847,11 +1086,17 @@ window.__ModuleLoader__.load({
847
1086
  closeTimer.current = null;
848
1087
  setStatsClosing(false);
849
1088
  setStatsOpen(false);
850
- window.open("https://platform.deepseek.com/usage", "_blank", "noopener");
1089
+ // 双击:在应用内打开官方用量页(官方版自带内嵌浏览器;旧版回退系统浏览器)
1090
+ // 双击:打开官方用量页(官方版用带平台凭据的原生视口,无需网页登录)
1091
+ openPlatformOrUrl("usage", USAGE_URL);
851
1092
  };
852
1093
  // 余额元素的交互。悬停提示不用原生 title,统一走主题化浮层(见 withPanel)。
853
1094
  const interactions = {
854
- onClick: openStats,
1095
+ // 单击打开统计面板;顺便阻止 <a href> 的默认跳转(否则会同时弹系统浏览器)
1096
+ onClick: (event) => {
1097
+ if (event && typeof event.preventDefault === "function") event.preventDefault();
1098
+ openStats(event);
1099
+ },
855
1100
  onDoubleClick: openUsagePage,
856
1101
  onContextMenu: onBalanceContextMenu,
857
1102
  };
@@ -889,16 +1134,13 @@ window.__ModuleLoader__.load({
889
1134
  if (depleted) {
890
1135
  return jsx.jsx("a", {
891
1136
  className: "balance-fab balance-fab-dark",
892
- href: "https://platform.deepseek.com/top_up",
893
- target: "_blank",
894
- rel: "noreferrer",
895
- onContextMenu: onBalanceContextMenu,
1137
+ ...externalLink(TOP_UP_URL, "top-up", { onContextMenu: onBalanceContextMenu }),
896
1138
  children: "去充值",
897
1139
  });
898
1140
  }
899
1141
  return withPanel(jsx.jsx("a", {
900
1142
  className: "balance-fab",
901
- href: "https://platform.deepseek.com/usage",
1143
+ ...externalLink(USAGE_URL, "usage"),
902
1144
  ...interactions,
903
1145
  children: "¥" + b.total_balance,
904
1146
  }));
@@ -908,7 +1150,13 @@ window.__ModuleLoader__.load({
908
1150
  return withPanel(jsx.jsx("div", {
909
1151
  className: "balance-footer",
910
1152
  ...interactions,
911
- children: jsx.jsx("span", { className: "balance-footer__error", children: "余额查询失败" }),
1153
+ // 失败原因统一走主题化浮层,避免把长错误塞进 42px 的余额行
1154
+ children: jsx.jsx(primitives.Tooltip, {
1155
+ label: state.error || "余额查询失败",
1156
+ side: "top",
1157
+ delayMs: 200,
1158
+ children: jsx.jsx("span", { className: "balance-footer__error", children: "余额读取失败" }),
1159
+ }),
912
1160
  }));
913
1161
  }
914
1162
  if (!state.balance) {
@@ -929,12 +1177,12 @@ window.__ModuleLoader__.load({
929
1177
  jsx.jsx("span", { className: "balance-footer__label", children: "余额" }),
930
1178
  jsx.jsx("a", {
931
1179
  className: "balance-footer__topup",
932
- href: "https://platform.deepseek.com/top_up",
933
- target: "_blank",
934
- rel: "noreferrer",
935
- onClick: (event) => event.stopPropagation(),
936
- onDoubleClick: (event) => event.stopPropagation(),
937
- onContextMenu: onBalanceContextMenu,
1180
+ ...externalLink(TOP_UP_URL, "top-up", {
1181
+ // 阻止冒泡:不要把"去充值"的点击误当成本行余额的单击/双击
1182
+ onClick: (event) => event.stopPropagation(),
1183
+ onDoubleClick: (event) => event.stopPropagation(),
1184
+ onContextMenu: onBalanceContextMenu,
1185
+ }),
938
1186
  children: "去充值",
939
1187
  }),
940
1188
  ],
@@ -1085,6 +1333,8 @@ function UsageCostLine({ useProjection, sessionId }) {
1085
1333
  const inject = ["slots", "sessions"];
1086
1334
 
1087
1335
  function apply(ctx) {
1336
+ // 保存 ctx:用于探测官方版 DSH 的侧栏内嵌浏览器(ctx.sidebarRight / ctx.sidebarRightTabs)
1337
+ pluginCtx = ctx;
1088
1338
  // 拿到 sessions 服务,用于解析当前会话使用的模型(按模型取价)
1089
1339
  sessionsFace = ctx.sessions;
1090
1340
  // 余额显示:展开态底部胶囊(order 20 排在市场 10 之后、紧挨设置);收起态悬浮 FAB
package/lib/index.js CHANGED
@@ -79,7 +79,8 @@ function pricingEraAt(time) {
79
79
  }
80
80
 
81
81
  // 高峰/空闲时段(V4 峰谷定价,2026-08-17 生效):高峰价 = 空闲价 × 2。
82
- // 官方高峰窗口(北京时间)为 周一至周五 09:00-12:00、14:00-18:00;周末全天空闲。
82
+ // 官方高峰窗口(北京时间)为 工作日 09:00-12:00、14:00-18:00;
83
+ // 周末与法定节假日全天空闲,但**调休上班的周末**按工作日算(2026-09-20 DeepSeek 明确此规则)。
83
84
  // 可用 config.peakWindows 覆盖小时窗口(如 [{start:9,end:12},{start:14,end:18}]),
84
85
  // 可用 config.peakWeekdays 覆盖周几(1=周一 … 5=周五,默认 [1,2,3,4,5],0/6=周末)。
85
86
  const DEFAULT_PEAK_WINDOWS = [
@@ -88,7 +89,63 @@ const DEFAULT_PEAK_WINDOWS = [
88
89
  ];
89
90
  const DEFAULT_PEAK_WEEKDAYS = [1, 2, 3, 4, 5];
90
91
 
91
- /** 判断某时间戳(毫秒)是否处于高峰时段(按北京时间 UTC+8 计算,含周几判断)。 */
92
+ // 中国大陆法定节假日与调休(日期为北京时区)。来源:国务院办公厅《关于2026年部分节假日安排的通知》
93
+ // (国办发明电〔2025〕7号)。holiday = 法定节假日(全天按空闲计费);workday = 调休上班的周末(按工作日峰谷计费)。
94
+ // ⚠️ 国务院每年 11 月前后公布次年安排,届时需补录。lib/client.js 有一份同样的表(用于时段标签),两处必须同步。
95
+ const CN_CALENDAR = {
96
+ // 元旦:1/1-1/3 放假,1/4(周日) 上班
97
+ "2026-01-01": "holiday", "2026-01-02": "holiday", "2026-01-03": "holiday", "2026-01-04": "workday",
98
+ // 春节:2/15-2/23 放假,2/14(周六)、2/28(周六) 上班
99
+ "2026-02-14": "workday",
100
+ "2026-02-15": "holiday", "2026-02-16": "holiday", "2026-02-17": "holiday", "2026-02-18": "holiday",
101
+ "2026-02-19": "holiday", "2026-02-20": "holiday", "2026-02-21": "holiday", "2026-02-22": "holiday",
102
+ "2026-02-23": "holiday", "2026-02-28": "workday",
103
+ // 清明节:4/4-4/6 放假(无调休)
104
+ "2026-04-04": "holiday", "2026-04-05": "holiday", "2026-04-06": "holiday",
105
+ // 劳动节:5/1-5/5 放假,5/9(周六) 上班
106
+ "2026-05-01": "holiday", "2026-05-02": "holiday", "2026-05-03": "holiday", "2026-05-04": "holiday",
107
+ "2026-05-05": "holiday", "2026-05-09": "workday",
108
+ // 端午节:6/19-6/21 放假(无调休)
109
+ "2026-06-19": "holiday", "2026-06-20": "holiday", "2026-06-21": "holiday",
110
+ // 国庆节调休:9/20(周日) 上班
111
+ "2026-09-20": "workday",
112
+ // 中秋节:9/25-9/27 放假(无调休)
113
+ "2026-09-25": "holiday", "2026-09-26": "holiday", "2026-09-27": "holiday",
114
+ // 国庆节:10/1-10/7 放假,10/10(周六) 上班
115
+ "2026-10-01": "holiday", "2026-10-02": "holiday", "2026-10-03": "holiday", "2026-10-04": "holiday",
116
+ "2026-10-05": "holiday", "2026-10-06": "holiday", "2026-10-07": "holiday", "2026-10-10": "workday",
117
+ };
118
+
119
+ // 节假日自动补录:内置表仅作**离线兜底**,运行时从社区维护的源拉取(其 papers 字段直接引用国务院公告)。
120
+ // 源不可用/无网络/被 CSP 拦截时静默失败,继续用内置表;拉取结果按日期覆盖同键条目。
121
+ const CN_CALENDAR_SOURCE = "https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master";
122
+ const cnCalendarYears = new Set();
123
+
124
+ /** 拉取指定年份的节假日安排并合并进 CN_CALENDAR(幂等;失败静默,不阻塞调用方)。 */
125
+ async function ensureCnCalendar(year) {
126
+ if (cnCalendarYears.has(year)) return;
127
+ cnCalendarYears.add(year); // 先占位,避免并发重复请求
128
+ try {
129
+ const res = await fetch(CN_CALENDAR_SOURCE + "/" + year + ".json", { signal: AbortSignal.timeout(8000) });
130
+ if (!res.ok) return;
131
+ const data = await res.json();
132
+ if (!Array.isArray(data?.days)) return;
133
+ for (const item of data.days) {
134
+ if (item && typeof item.date === "string" && typeof item.isOffDay === "boolean") {
135
+ CN_CALENDAR[item.date] = item.isOffDay ? "holiday" : "workday";
136
+ }
137
+ }
138
+ } catch {
139
+ // 网络不可用:保留内置表(该年调休会退化为「周末全天空闲」)
140
+ }
141
+ }
142
+
143
+ /** 北京时区日期键 YYYY-MM-DD(传入已 +8h 偏移的 Date)。 */
144
+ function cnDateKey(d) {
145
+ return d.getUTCFullYear() + "-" + String(d.getUTCMonth() + 1).padStart(2, "0") + "-" + String(d.getUTCDate()).padStart(2, "0");
146
+ }
147
+
148
+ /** 判断某时间戳(毫秒)是否处于高峰时段(北京时间;含周末、法定节假日与调休上班判断)。 */
92
149
  function isPeakAt(timeMs, config) {
93
150
  const windows = (Array.isArray(config?.peakWindows) && config.peakWindows.length > 0
94
151
  ? config.peakWindows
@@ -98,9 +155,12 @@ function isPeakAt(timeMs, config) {
98
155
  ? config.peakWeekdays.map(Number)
99
156
  : DEFAULT_PEAK_WEEKDAYS;
100
157
  const d = new Date(Number(timeMs) + 8 * 60 * 60 * 1000); // 北京时间
158
+ const kind = CN_CALENDAR[cnDateKey(d)];
159
+ if (kind === "holiday") return false; // 法定节假日:全天按空闲计费
101
160
  const hour = d.getUTCHours();
102
161
  const weekday = d.getUTCDay(); // 0=周日 … 6=周六
103
- return weekdays.includes(weekday) && windows.some((w) => hour >= w.start && hour < w.end);
162
+ const onDuty = weekdays.includes(weekday) || kind === "workday"; // 调休上班的周末按工作日算
163
+ return onDuty && windows.some((w) => hour >= w.start && hour < w.end);
104
164
  }
105
165
 
106
166
  /** 把模型字符串规整到定价表键;未识别返回 ""(用 Flash 价兜底)。
@@ -254,6 +314,78 @@ function formatBalance(balance) {
254
314
  return `余额(${balance.currency || "CNY"}):${balance.total_balance}\n 充值余额:${balance.topped_up_balance}\n 赠送余额:${balance.granted_balance}`;
255
315
  }
256
316
 
317
+ // ── 官方 DeepSeek Harness 账号服务(ctx.deepseekAccount)──────────────────────
318
+ // 官方桌面版内置账号登录(系统浏览器 PKCE),登录后可直接读到官方余额,无需 API Key。
319
+ // 服务由 @deepseek-ai/dsh-deepseek-account-platform 提供,已被 dsh-base 默认依赖,故官方版开箱即有。
320
+ // 接口:getBalance(client) → { status:"ready", value: 充值钱包[], bonusWallets: 赠送钱包[] }
321
+ // | null (未登录 / 凭据已变更)
322
+ // | { status:"failed" } (网络失败;官方明确“失败绝不变成 0 余额”)
323
+ // client 是 AccountClientMetadata(version / locale / timezoneOffsetSeconds),
324
+ // 缺字段会在平台侧组装请求头时抛错,所以三个字段都要给。
325
+ const PLUGIN_VERSION = "1.2.2";
326
+
327
+ /**
328
+ * 读取官方账号余额并归一化成与 normalizeBalance 相同的形状。
329
+ * 服务不存在、未登录、接口报错一律返回 null,由调用方回退 API Key,绝不因此抛错。
330
+ */
331
+ async function fetchAccountBalance(ctx) {
332
+ const account = typeof ctx.get === "function" ? ctx.get("deepseekAccount") : void 0;
333
+ if (account === void 0 || account === null || typeof account.getBalance !== "function") return null;
334
+ let result;
335
+ try {
336
+ result = await account.getBalance({
337
+ version: PLUGIN_VERSION,
338
+ locale: "zh-CN",
339
+ // 平台要求“东为正”的整秒偏移;JS 的 getTimezoneOffset() 是西为正,故取反。
340
+ timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
341
+ });
342
+ } catch {
343
+ return null;
344
+ }
345
+ if (result === null || result === void 0 || result.status !== "ready") return null;
346
+ return normalizeAccountBalance(result);
347
+ }
348
+
349
+ /** 官方钱包结构 → 统一余额形状。value 是充值钱包,bonusWallets 是赠送钱包(官方不计入充值余额)。 */
350
+ function normalizeAccountBalance(result) {
351
+ const pick = (list) => {
352
+ const arr = Array.isArray(list) ? list.filter((w) => w && typeof w.balance === "string") : [];
353
+ return arr.find((w) => w.currency === "CNY") ?? arr[0] ?? null;
354
+ };
355
+ const topped = pick(result.value);
356
+ const granted = pick(result.bonusWallets);
357
+ // 官方金额字符串遵循 big.js 文法(允许 0E-16 这类写法),Number() 能正确解析。
358
+ const toppedRaw = Number(topped?.balance ?? 0);
359
+ const grantedRaw = Number(granted?.balance ?? 0);
360
+ const toppedUp = Number.isFinite(toppedRaw) ? toppedRaw : 0;
361
+ const grantedAmount = Number.isFinite(grantedRaw) ? grantedRaw : 0;
362
+ const total = toppedUp + grantedAmount;
363
+ return {
364
+ is_available: total > 0,
365
+ currency: topped?.currency || granted?.currency || "CNY",
366
+ total_balance: total.toFixed(2),
367
+ granted_balance: grantedAmount.toFixed(2),
368
+ topped_up_balance: toppedUp.toFixed(2),
369
+ source: "account",
370
+ };
371
+ }
372
+
373
+ /** 取余额:官方账号优先,回退 DeepSeek API Key。返回 { balance, error },二者必有其一。 */
374
+ async function resolveBalance(ctx, config, signal) {
375
+ const fromAccount = await fetchAccountBalance(ctx);
376
+ if (fromAccount !== null) return { balance: fromAccount, error: null };
377
+ const apiKey = await resolveApiKey(ctx, config);
378
+ if (apiKey === "") {
379
+ return { balance: null, error: "未登录 DeepSeek 账号,也未设置 DEEPSEEK_API_KEY" };
380
+ }
381
+ try {
382
+ const data = await fetchBalance(apiKey, signal);
383
+ return { balance: { ...normalizeBalance(data), source: "apikey" }, error: null };
384
+ } catch (error) {
385
+ return { balance: null, error: error instanceof Error ? error.message : String(error) };
386
+ }
387
+ }
388
+
257
389
  /** 构建模型切换点列表(按时间升序)。request/header、request/context 都在模型或请求设置变化时写入,
258
390
  * 故可用它们把会话切成若干"模型段",每条 assistant/message 归属到"它之前最近一个切换点"的模型。
259
391
  * 这样换不同价模型时(如 flash→pro)金额能按各段模型分别计价,而不是整会话用最后一个模型。 */
@@ -736,38 +868,36 @@ export function apply(ctx, config = {}) {
736
868
  // 时统一清理,保证重复 apply 不冲突。
737
869
  const disposers = [];
738
870
 
871
+ // 后台补录节假日数据(当年 ±1 年,覆盖 90 天统计窗口与跨年场景)。
872
+ // 故意不 await:拉取失败不能影响插件启动;isPeakAt 保持同步,未拉到时继续用内置表。
873
+ {
874
+ const y = new Date(Date.now() + 8 * 60 * 60 * 1000).getUTCFullYear();
875
+ void ensureCnCalendar(y - 1);
876
+ void ensureCnCalendar(y);
877
+ void ensureCnCalendar(y + 1);
878
+ }
879
+
739
880
  // 1) /balance 命令:保留命令行查询入口
740
881
  ctx.inject(["commands"], (commandCtx) => {
741
882
  commandCtx.commands.register({
742
883
  name: "balance",
743
884
  description: "查询 DeepSeek 余额与当前会话消耗",
744
885
  handler: async ({ agent, signal }) => {
745
- const apiKey = await resolveApiKey(ctx, config);
746
- if (apiKey === "") {
747
- return {
748
- kind: "error",
749
- text: "未找到 DeepSeek API Key,请设置环境变量 DEEPSEEK_API_KEY 后重试。",
750
- };
751
- }
752
-
753
886
  const usage = sumSessionUsage(agent.session, config);
754
887
  const model = detectModel(agent.session);
755
888
  const pricing = resolvePricing(config, model);
756
-
757
- try {
758
- const data = await fetchBalance(apiKey, signal);
759
- const balanceText = formatBalance(normalizeBalance(data));
760
- return {
761
- kind: "success",
762
- text: `${balanceText}\n\n${formatUsage(usage, pricing, model)}`,
763
- };
764
- } catch (error) {
765
- const message = error instanceof Error ? error.message : String(error);
889
+ // 余额来源:官方账号优先(官方版内置登录,无需 API Key),回退 API Key。
890
+ const { balance, error } = await resolveBalance(ctx, config, signal);
891
+ if (balance === null) {
766
892
  return {
767
893
  kind: "error",
768
- text: `${message}\n\n${formatUsage(usage, pricing, model)}`,
894
+ text: `${error}\n\n${formatUsage(usage, pricing, model)}`,
769
895
  };
770
896
  }
897
+ return {
898
+ kind: "success",
899
+ text: `${formatBalance(balance)}\n\n${formatUsage(usage, pricing, model)}`,
900
+ };
771
901
  },
772
902
  });
773
903
  });
@@ -816,22 +946,6 @@ export function apply(ctx, config = {}) {
816
946
  statsError = error instanceof Error ? error.message : String(error);
817
947
  }
818
948
  }
819
- const apiKey = await resolveApiKey(ctx, config);
820
- if (apiKey === "") {
821
- if (wantStats) {
822
- writeJson(res, 200, {
823
- ok: true,
824
- balance: null,
825
- balanceError: "未设置 DEEPSEEK_API_KEY",
826
- pricing: resolvePricing(config, model),
827
- stats,
828
- statsError,
829
- });
830
- } else {
831
- writeJson(res, 200, { ok: false, error: "未设置 DEEPSEEK_API_KEY" });
832
- }
833
- return;
834
- }
835
949
  // 按 session id 取会话并分时段汇总 usage(拿不到则返回 null,客户端回退用本地投影)
836
950
  let usage = null;
837
951
  // 子代理后代会话的 usage 汇总(与主会话分开)
@@ -884,33 +998,23 @@ export function apply(ctx, config = {}) {
884
998
  if (subagentUsage && Array.isArray(subagentUsage.byModel)) {
885
999
  subagentUsage.byModel = subagentUsage.byModel.map((b) => ({ ...b, cost: estimateCost(b, resolvePricing(config, b.model, b.era)) }));
886
1000
  }
887
- try {
888
- const data = await fetchBalance(apiKey);
889
- writeJson(res, 200, {
890
- ok: true,
891
- balance: normalizeBalance(data),
892
- pricing: resolvePricing(config, effectiveModel),
893
- model: effectiveModel,
894
- usage,
895
- subagentUsage,
896
- stats,
897
- statsError,
898
- });
899
- } catch (error) {
900
- const message = error instanceof Error ? error.message : String(error);
901
- if (wantStats) {
902
- writeJson(res, 200, {
903
- ok: true,
904
- balance: null,
905
- balanceError: message,
906
- pricing: resolvePricing(config, effectiveModel),
907
- stats,
908
- statsError,
909
- });
910
- } else {
911
- writeJson(res, 200, { ok: false, error: message });
912
- }
1001
+ // 余额来源:官方账号优先(官方桌面版内置登录,无需 API Key),回退 DeepSeek API Key。
1002
+ const { balance, error: balanceError } = await resolveBalance(ctx, config);
1003
+ if (balance === null && !wantStats) {
1004
+ writeJson(res, 200, { ok: false, error: balanceError });
1005
+ return;
913
1006
  }
1007
+ writeJson(res, 200, {
1008
+ ok: true,
1009
+ balance,
1010
+ balanceError: balance === null ? balanceError : null,
1011
+ pricing: resolvePricing(config, effectiveModel),
1012
+ model: effectiveModel,
1013
+ usage,
1014
+ subagentUsage,
1015
+ stats,
1016
+ statsError,
1017
+ });
914
1018
  },
915
1019
  });
916
1020
  if (typeof balanceRouteDisposer === "function") disposers.push(balanceRouteDisposer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "查询 DeepSeek 余额并显示当前会话 token 消耗。DeepSeek 账户余额 + 会话 token/费用显示,含高峰/空闲峰谷计费。",
5
5
  "author": "Rannist",
6
6
  "type": "module",