balance-dsh 1.0.3 → 1.0.9

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,7 +7,7 @@ DSH 插件:在 Web UI 中直接显示 DeepSeek 账户余额和当前会话 tok
7
7
  - **侧边栏余额行**:侧边栏展开时显示在底部(紧贴"设置"按钮上方),格式:钱包图标 + `余额` + 金额(带货币单位)+ 时段标签(高峰红 / 空闲绿)。金额按余额状态变色:充足绿色、偏低(<10)琥珀黄、为 0 显示红色"去充值"。
8
8
  - **收起态 FAB**:侧边栏收起时,余额显示为左下角悬浮胶囊。
9
9
  - **自动刷新**:每 60 秒自动刷新一次;每次对话完成(新回复落库)也会立即刷新;右键余额任意区域可手动刷新。
10
- - **余额与使用统计**:单击余额打开页面级统计弹窗,展示账户余额、近 90 天计费 Token、估算费用、扫描会话数和按日使用热力图;双击余额打开 DeepSeek 官方 API 用量页。
10
+ - **余额与使用统计**:单击余额在余额项旁以轻量动画打开局部快捷菜单,展示账户余额、近 90 天计费 Token、估算费用、扫描会话数和按日使用热力图;点击菜单外区域会播放关闭动画,双击余额打开 DeepSeek 官方 API 用量页。
11
11
  - **高峰/空闲计费**:官方高峰窗口为**周一至周五 09:00-12:00、14:00-18:00(北京时间)**,周末全天空闲。消耗金额**跨时段精确计费**:高峰时段的 token 按高峰价 ×2、空闲时段按目录价,按事件时间分桶计算。
12
12
  - **会话消耗行**:对话框下方实时显示当前会话消耗(新增 tokens + 估算金额),悬停查看完整明细(模型、时段拆分、计费口径)。
13
13
  - **`/balance` 命令**:命令行查询余额明细和当前会话消耗。
@@ -16,7 +16,7 @@ DSH 插件:在 Web UI 中直接显示 DeepSeek 账户余额和当前会话 tok
16
16
 
17
17
  - 热力图按北京时间聚合最近 90 天持久化会话中的 `assistant/message` Token usage。
18
18
  - 费用按照每条消息的实际模型和峰谷时段估算,最终金额以 DeepSeek 官方账单为准。
19
- - 为避免历史会话过多拖慢 DSH,最多扫描最近 200 个会话,统计结果缓存 5 分钟。
19
+ - 为避免历史会话过多拖慢 DSH,最多扫描最近 200 个会话;服务端统计和客户端展示结果均缓存 5 分钟,重复打开菜单不会重复请求。
20
20
  - 若未配置 API Key,仍可查看本地 Token 与费用统计,但账户余额会显示为不可用。
21
21
 
22
22
  ## 安装
package/lib/client.js CHANGED
@@ -5,6 +5,7 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react = require("react");
8
+ let reactDom = require("react-dom");
8
9
  let jsx = require("react/jsx-runtime");
9
10
  let primitives = require("@deepseek-ai/dsh-client-ui-primitives");
10
11
 
@@ -41,25 +42,40 @@ window.__ModuleLoader__.load({
41
42
  .balance-fab-dark:hover{background:#cf3a3f}
42
43
  .balance-usage-line{box-sizing:border-box;display:flex;justify-content:center;align-items:center;gap:10px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
43
44
  .balance-usage-line strong{color:var(--dsw-alias-label-primary);font-weight:600}
44
- /* ── 跨会话统计面板(单击余额弹出) ── */
45
- .balance-stats-backdrop{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(15,23,42,.48);backdrop-filter:blur(3px);cursor:default}
46
- .balance-stats-panel{box-sizing:border-box;width:min(820px,calc(100vw - 40px));max-height:min(720px,calc(100vh - 40px));overflow:auto;padding:22px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base,#fff);border:1px solid var(--dsw-alias-border-l2,#d9d9df);border-radius:16px;box-shadow:0 24px 70px rgba(0,0,0,.28)}
47
- .balance-stats-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:18px}
48
- .balance-stats-title{margin:0;font-size:20px;line-height:28px;font-weight:700}
45
+ /* ── 跨会话统计浮层(余额元素的子级,向右/向上展开) ── */
46
+ .balance-stats-anchor{position:relative;display:flex;min-width:0;overflow:visible}
47
+ .balance-stats-anchor--wide{width:100%}
48
+ .balance-stats-anchor--compact{position:fixed;left:68px;bottom:12px;z-index:10000}
49
+ .balance-stats-anchor--compact .balance-fab{position:static}
50
+ .balance-stats-panel{box-sizing:border-box;position:fixed;z-index:10000;width:460px;max-height:min(620px,calc(100vh - 24px));overflow-x:hidden;overflow-y:auto;scrollbar-gutter:stable;padding:16px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base,#fff);border:1px solid var(--dsw-alias-border-l2,#d9d9df);border-radius:12px;box-shadow:0 14px 36px rgba(0,0,0,.24);transform-origin:left bottom;will-change:transform,opacity;contain:layout style;animation:balance-stats-panel-in .18s var(--ds-ease-in-out,ease-out) both}
51
+ .balance-stats-panel--closing{pointer-events:none;animation:balance-stats-panel-out .14s var(--ds-ease-in-out,ease-in) both}
52
+ @keyframes balance-stats-panel-in{from{opacity:0;transform:translateY(8px) scale(.975)}to{opacity:1;transform:translateY(0) scale(1)}}
53
+ @keyframes balance-stats-panel-out{from{opacity:1;transform:translateY(0) scale(1)}to{opacity:0;transform:translateY(6px) scale(.985)}}
54
+ .balance-stats-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}
55
+ .balance-stats-title{margin:0;font-size:16px;line-height:24px;font-weight:700}
49
56
  .balance-stats-subtitle{margin:4px 0 0;color:var(--dsw-alias-label-secondary);font-size:12px}
50
57
  .balance-stats-close{width:32px;height:32px;flex:none;border:0;border-radius:8px;color:var(--dsw-alias-label-secondary);background:transparent;font-size:22px;line-height:1;cursor:pointer}
51
58
  .balance-stats-close:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
52
59
  .balance-stats-error{padding:12px;border-radius:10px;color:var(--dsw-alias-state-error-primary,#e5484d);background:rgba(229,72,77,.10)}
53
60
  .balance-stats-loading{padding:48px 12px;text-align:center;color:var(--dsw-alias-label-secondary)}
54
- .balance-stats-cards{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:20px}
55
- .balance-stats-card{min-width:0;padding:14px;border:1px solid var(--dsw-alias-border-l2,#d9d9df);border-radius:12px;background:var(--dsw-alias-bg-module-platform,rgba(127,127,127,.04))}
61
+ .balance-stats-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-bottom:16px}
62
+ .balance-stats-card{min-width:0;padding:11px;border:1px solid var(--dsw-alias-border-l2,#d9d9df);border-radius:10px;background:var(--dsw-alias-bg-module-platform,rgba(127,127,127,.04));animation:balance-stats-card-in .2s var(--ds-ease-in-out,ease-out) both}
63
+ .balance-stats-card:nth-child(2){animation-delay:.025s}.balance-stats-card:nth-child(3){animation-delay:.05s}.balance-stats-card:nth-child(4){animation-delay:.075s}
64
+ @keyframes balance-stats-card-in{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:translateY(0)}}
56
65
  .balance-stats-card__label{display:block;margin-bottom:6px;color:var(--dsw-alias-label-secondary);font-size:12px}
57
- .balance-stats-card__value{display:block;overflow:hidden;color:var(--dsw-alias-label-primary);font-size:18px;font-weight:700;font-variant-numeric:tabular-nums;text-overflow:ellipsis;white-space:nowrap}
66
+ .balance-stats-card__value{display:block;overflow:hidden;color:var(--dsw-alias-label-primary);font-size:16px;font-weight:700;font-variant-numeric:tabular-nums;text-overflow:ellipsis;white-space:nowrap}
58
67
  .balance-stats-section-title{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 10px;font-size:14px;font-weight:650}
59
68
  .balance-stats-legend{display:flex;align-items:center;gap:4px;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:400}
60
- .balance-heatmap-wrap{overflow-x:auto;padding:4px 2px 8px}
69
+ .balance-heatmap-wrap{min-width:0;overflow:hidden;padding:4px 2px 8px}
61
70
  .balance-heatmap{display:grid;grid-template-rows:repeat(7,14px);grid-auto-flow:column;grid-auto-columns:14px;gap:4px;width:max-content;min-width:100%}
62
- .balance-heatmap-cell{width:14px;height:14px;border-radius:3px;background:rgba(127,127,127,.10)}
71
+ .balance-heatmap-cell{display:block;box-sizing:border-box;width:14px;height:14px;padding:0;border:0;border-radius:3px;background:rgba(127,127,127,.10);appearance:none;transition:transform .12s var(--ds-ease-in-out,ease-out),filter .12s var(--ds-ease-in-out,ease-out)}
72
+ .balance-heatmap-cell[aria-label]{cursor:default}
73
+ .balance-heatmap-cell[aria-label]:hover{transform:scale(1.18);filter:brightness(1.14)}
74
+ .balance-heatmap-tooltip{box-sizing:border-box;position:fixed;z-index:10020;max-width:min(300px,calc(100vw - 24px));padding:6px 9px;border:1px solid var(--dsw-alias-border-l2,#45454d);border-radius:6px;background:var(--dsw-alias-bg-module-platform,#27272b);box-shadow:0 6px 18px rgba(0,0,0,.22);color:var(--dsw-alias-label-primary,#f4f4f5);font-size:11px;font-weight:400;line-height:1.45;white-space:nowrap;pointer-events:none;animation:balance-heatmap-tooltip-in .12s var(--ds-ease-in-out,ease-out) both}
75
+ .balance-heatmap-tooltip--left{transform:translateY(-100%)}
76
+ .balance-heatmap-tooltip--center{transform:translate(-50%,-100%)}
77
+ .balance-heatmap-tooltip--right{transform:translateY(-100%)}
78
+ @keyframes balance-heatmap-tooltip-in{from{opacity:0}to{opacity:1}}
63
79
  .balance-heatmap-cell--1{background:rgba(34,197,94,.25)}
64
80
  .balance-heatmap-cell--2{background:rgba(34,197,94,.45)}
65
81
  .balance-heatmap-cell--3{background:rgba(34,197,94,.68)}
@@ -68,7 +84,8 @@ window.__ModuleLoader__.load({
68
84
  .balance-stats-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:14px;color:var(--dsw-alias-label-secondary);font-size:11px}
69
85
  .balance-stats-link{padding:7px 12px;border:1px solid var(--dsw-alias-border-l2,#d9d9df);border-radius:8px;color:var(--dsw-alias-label-primary);text-decoration:none;background:var(--dsw-alias-bg-module-platform,rgba(127,127,127,.04))}
70
86
  .balance-stats-link:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
71
- @media(max-width:680px){.balance-stats-cards{grid-template-columns:repeat(2,minmax(0,1fr))}.balance-stats-panel{padding:16px}.balance-stats-backdrop{padding:10px}}
87
+ @media(max-width:680px){.balance-stats-panel{max-height:calc(100vh - 68px)}}
88
+ @media(prefers-reduced-motion:reduce){.balance-stats-panel,.balance-stats-panel--closing,.balance-stats-card{animation:none}.balance-heatmap-cell{transition:none}}
72
89
  /* ── 展开态底部一行:[设置齿轮][余额胶囊],余额在设置右边 ──
73
90
  全部使用稳定锚点,不依赖 CSS Modules hash 类名(DSH 升级后仍有效):
74
91
  - wide 态特征:底部存在 .balance-footer / .balance-topup(收起态渲染 position:fixed 的 .balance-fab,不影响)
@@ -82,12 +99,15 @@ window.__ModuleLoader__.load({
82
99
  `;
83
100
 
84
101
  const tagId = "balance/balance-widget.css";
85
- if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
86
- const tag = document.createElement("style");
87
- tag.dataset.plugin = "balance";
88
- tag.dataset.pluginCss = tagId;
102
+ if (typeof document !== "undefined") {
103
+ let tag = document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]");
104
+ if (tag === null) {
105
+ tag = document.createElement("style");
106
+ tag.dataset.plugin = "balance";
107
+ tag.dataset.pluginCss = tagId;
108
+ document.head.appendChild(tag);
109
+ }
89
110
  tag.textContent = css;
90
- document.head.appendChild(tag);
91
111
  }
92
112
 
93
113
  // ─── 价格(与服务端默认保持一致;服务端 /balance-api 返回后会覆盖) ───
@@ -337,16 +357,38 @@ window.__ModuleLoader__.load({
337
357
  fetchBalanceOnce();
338
358
  }
339
359
 
340
- async function fetchUsageStats(signal) {
341
- const res = await fetch("/balance-api?stats=1", { signal });
342
- if (!res.ok) throw new Error("HTTP " + res.status);
343
- const data = await res.json();
344
- if (!data || !data.ok) throw new Error((data && data.error) || "统计加载失败");
345
- if (!data.stats) throw new Error(data.statsError || "暂无可用的会话统计");
346
- return data;
360
+ const USAGE_STATS_CACHE_TTL = 5 * 60_000;
361
+ let usageStatsCache = null;
362
+ let usageStatsCacheAt = 0;
363
+ let usageStatsInFlight = null;
364
+
365
+ function getCachedUsageStats() {
366
+ return usageStatsCache && Date.now() - usageStatsCacheAt < USAGE_STATS_CACHE_TTL
367
+ ? usageStatsCache
368
+ : null;
369
+ }
370
+
371
+ function fetchUsageStats() {
372
+ const cached = getCachedUsageStats();
373
+ if (cached) return Promise.resolve(cached);
374
+ if (usageStatsInFlight) return usageStatsInFlight;
375
+ usageStatsInFlight = fetch("/balance-api?stats=1")
376
+ .then(async (res) => {
377
+ if (!res.ok) throw new Error("HTTP " + res.status);
378
+ const data = await res.json();
379
+ if (!data || !data.ok) throw new Error((data && data.error) || "统计加载失败");
380
+ if (!data.stats) throw new Error(data.statsError || "暂无可用的会话统计");
381
+ usageStatsCache = data;
382
+ usageStatsCacheAt = Date.now();
383
+ return data;
384
+ })
385
+ .finally(() => {
386
+ usageStatsInFlight = null;
387
+ });
388
+ return usageStatsInFlight;
347
389
  }
348
390
 
349
- function StatsCard({ label, value }) {
391
+ const StatsCard = react.memo(function StatsCard({ label, value }) {
350
392
  return jsx.jsxs("div", {
351
393
  className: "balance-stats-card",
352
394
  children: [
@@ -354,20 +396,27 @@ window.__ModuleLoader__.load({
354
396
  jsx.jsx("span", { className: "balance-stats-card__value", title: value, children: value }),
355
397
  ],
356
398
  });
357
- }
358
-
359
- function UsageStatsPanel({ onClose }) {
360
- const [state, setState] = react.useState({ loading: true, data: null, error: "" });
399
+ });
400
+
401
+ function UsageStatsPanel({ onClose, panelRef, style, closing }) {
402
+ const liveBalance = useBalance();
403
+ const [heatmapTooltip, setHeatmapTooltip] = react.useState(null);
404
+ const [state, setState] = react.useState(() => {
405
+ const cached = getCachedUsageStats();
406
+ return cached
407
+ ? { loading: false, data: cached, error: "" }
408
+ : { loading: true, data: null, error: "" };
409
+ });
361
410
  react.useEffect(() => {
362
- const controller = new AbortController();
363
- fetchUsageStats(controller.signal).then(
364
- (data) => setState({ loading: false, data, error: "" }),
411
+ let disposed = false;
412
+ fetchUsageStats().then(
413
+ (data) => { if (!disposed) setState({ loading: false, data, error: "" }); },
365
414
  (error) => {
366
- if (controller.signal.aborted) return;
415
+ if (disposed) return;
367
416
  setState({ loading: false, data: null, error: error && error.message ? error.message : String(error) });
368
417
  },
369
418
  );
370
- return () => controller.abort();
419
+ return () => { disposed = true; };
371
420
  }, []);
372
421
  react.useEffect(() => {
373
422
  const onKeyDown = (event) => { if (event.key === "Escape") onClose(); };
@@ -378,25 +427,51 @@ window.__ModuleLoader__.load({
378
427
  const data = state.data;
379
428
  const stats = data && data.stats;
380
429
  const days = stats && Array.isArray(stats.days) ? stats.days : [];
381
- const maxTokens = Math.max(0, ...days.map((day) => Number(day.totalTokens || 0)));
382
- const firstDay = days[0] && days[0].date;
383
- const leading = firstDay ? (new Date(firstDay + "T00:00:00Z").getUTCDay() + 6) % 7 : 0;
384
- const heatmap = [];
385
- for (let i = 0; i < leading; i++) {
386
- heatmap.push(jsx.jsx("span", { className: "balance-heatmap-cell balance-heatmap-cell--blank" }, "blank-" + i));
387
- }
388
- for (const day of days) {
389
- const tokens = Number(day.totalTokens || 0);
390
- const ratio = maxTokens > 0 ? tokens / maxTokens : 0;
391
- const level = tokens === 0 ? 0 : ratio <= 0.25 ? 1 : ratio <= 0.5 ? 2 : ratio <= 0.75 ? 3 : 4;
392
- const title = day.date + "\nToken:" + formatTokens(tokens) + "\n费用:¥" + Number(day.cost || 0).toFixed(4) + "\n会话:" + Number(day.sessions || 0);
393
- heatmap.push(jsx.jsx("span", {
394
- className: "balance-heatmap-cell" + (level > 0 ? " balance-heatmap-cell--" + level : ""),
395
- title,
396
- "aria-label": title,
397
- }, day.date));
398
- }
399
- const balance = data && data.balance;
430
+ const showHeatmapTooltip = react.useCallback((event, label) => {
431
+ const rect = event.currentTarget.getBoundingClientRect();
432
+ const viewportWidth = document.documentElement.clientWidth;
433
+ const center = rect.left + rect.width / 2;
434
+ const top = Math.max(12, rect.top - 7);
435
+ if (center < 162) {
436
+ setHeatmapTooltip({ label, align: "left", style: { left: Math.max(12, rect.left), top } });
437
+ } else if (center > viewportWidth - 162) {
438
+ setHeatmapTooltip({ label, align: "right", style: { right: Math.max(12, viewportWidth - rect.right), top } });
439
+ } else {
440
+ setHeatmapTooltip({ label, align: "center", style: { left: center, top } });
441
+ }
442
+ }, []);
443
+ const hideHeatmapTooltip = react.useCallback(() => setHeatmapTooltip(null), []);
444
+ const heatmap = react.useMemo(() => {
445
+ const cells = [];
446
+ const maxTokens = Math.max(0, ...days.map((day) => Number(day.totalTokens || 0)));
447
+ const firstDay = days[0] && days[0].date;
448
+ const leading = firstDay ? (new Date(firstDay + "T00:00:00Z").getUTCDay() + 6) % 7 : 0;
449
+ for (let i = 0; i < leading; i++) {
450
+ cells.push(jsx.jsx("span", { className: "balance-heatmap-cell balance-heatmap-cell--blank" }, "blank-" + i));
451
+ }
452
+ for (let dayIndex = 0; dayIndex < days.length; dayIndex++) {
453
+ const day = days[dayIndex];
454
+ const tokens = Number(day.totalTokens || 0);
455
+ const ratio = maxTokens > 0 ? tokens / maxTokens : 0;
456
+ const level = tokens === 0 ? 0 : ratio <= 0.25 ? 1 : ratio <= 0.5 ? 2 : ratio <= 0.75 ? 3 : 4;
457
+ const costText = "¥" + Number(day.cost || 0).toFixed(4);
458
+ const sessionsText = String(Number(day.sessions || 0));
459
+ const ariaLabel = day.date + ",消耗 " + formatTokens(tokens) + " Token,费用 " + costText + ",会话 " + sessionsText;
460
+ const tooltip = day.date + " · 消耗 " + formatTokens(tokens) + " Token · 费用 " + costText + " · 会话 " + sessionsText;
461
+ cells.push(jsx.jsx("button", {
462
+ type: "button",
463
+ tabIndex: -1,
464
+ className: "balance-heatmap-cell" + (level > 0 ? " balance-heatmap-cell--" + level : ""),
465
+ "aria-label": ariaLabel,
466
+ onMouseEnter: (event) => showHeatmapTooltip(event, tooltip),
467
+ onMouseLeave: hideHeatmapTooltip,
468
+ onFocus: (event) => showHeatmapTooltip(event, tooltip),
469
+ onBlur: hideHeatmapTooltip,
470
+ }, day.date));
471
+ }
472
+ return cells;
473
+ }, [days, hideHeatmapTooltip, showHeatmapTooltip]);
474
+ const balance = liveBalance.balance || (data && data.balance);
400
475
  const total = stats && stats.total;
401
476
  const balanceText = balance
402
477
  ? String(balance.total_balance ?? "0.00") + " " + String(balance.currency || "CNY")
@@ -407,7 +482,7 @@ window.__ModuleLoader__.load({
407
482
  ? jsx.jsx("div", { className: "balance-stats-error", children: state.error })
408
483
  : jsx.jsxs(jsx.Fragment, {
409
484
  children: [
410
- data.balanceError ? jsx.jsx("div", { className: "balance-stats-error", children: "余额:" + data.balanceError }) : null,
485
+ !liveBalance.balance && data.balanceError ? jsx.jsx("div", { className: "balance-stats-error", children: "余额:" + data.balanceError }) : null,
411
486
  jsx.jsxs("div", {
412
487
  className: "balance-stats-cards",
413
488
  children: [
@@ -449,31 +524,33 @@ window.__ModuleLoader__.load({
449
524
  }),
450
525
  ],
451
526
  });
452
- return jsx.jsx("div", {
453
- className: "balance-stats-backdrop",
454
- role: "presentation",
455
- onMouseDown: (event) => { if (event.target === event.currentTarget) onClose(); },
456
- children: jsx.jsxs("section", {
457
- className: "balance-stats-panel",
458
- role: "dialog",
459
- "aria-modal": "true",
460
- "aria-label": "余额与使用统计",
461
- children: [
462
- jsx.jsxs("header", {
463
- className: "balance-stats-header",
464
- children: [
465
- jsx.jsxs("div", {
466
- children: [
467
- jsx.jsx("h2", { className: "balance-stats-title", children: "余额与使用统计" }),
468
- jsx.jsx("p", { className: "balance-stats-subtitle", children: "单击查看统计 · 双击余额打开 DeepSeek API 用量页" }),
469
- ],
470
- }),
471
- jsx.jsx("button", { className: "balance-stats-close", type: "button", onClick: onClose, "aria-label": "关闭", children: "×" }),
472
- ],
473
- }),
474
- body,
475
- ],
476
- }),
527
+ return jsx.jsxs("section", {
528
+ ref: panelRef,
529
+ style,
530
+ className: "balance-stats-panel" + (closing ? " balance-stats-panel--closing" : ""),
531
+ role: "dialog",
532
+ "aria-label": "余额与使用统计",
533
+ children: [
534
+ jsx.jsxs("header", {
535
+ className: "balance-stats-header",
536
+ children: [
537
+ jsx.jsxs("div", {
538
+ children: [
539
+ jsx.jsx("h2", { className: "balance-stats-title", children: "余额与使用统计" }),
540
+ jsx.jsx("p", { className: "balance-stats-subtitle", children: "余额快捷菜单 · 双击打开 DeepSeek API 用量页" }),
541
+ ],
542
+ }),
543
+ jsx.jsx("button", { className: "balance-stats-close", type: "button", onClick: onClose, "aria-label": "关闭", children: "×" }),
544
+ ],
545
+ }),
546
+ body,
547
+ heatmapTooltip ? reactDom.createPortal(jsx.jsx("div", {
548
+ className: "balance-heatmap-tooltip balance-heatmap-tooltip--" + heatmapTooltip.align,
549
+ style: heatmapTooltip.style,
550
+ role: "tooltip",
551
+ children: heatmapTooltip.label,
552
+ }), document.body) : null,
553
+ ],
477
554
  });
478
555
  }
479
556
 
@@ -501,15 +578,61 @@ window.__ModuleLoader__.load({
501
578
  function BalanceFooter({ wide }) {
502
579
  const state = useBalance();
503
580
  const [statsOpen, setStatsOpen] = react.useState(false);
581
+ const [statsClosing, setStatsClosing] = react.useState(false);
582
+ const [statsPosition, setStatsPosition] = react.useState({ left: 12, bottom: 56, width: 460 });
504
583
  const clickTimer = react.useRef(null);
584
+ const closeTimer = react.useRef(null);
585
+ const anchorRef = react.useRef(null);
586
+ const panelRef = react.useRef(null);
505
587
  react.useEffect(() => () => {
506
588
  if (clickTimer.current !== null) clearTimeout(clickTimer.current);
589
+ if (closeTimer.current !== null) clearTimeout(closeTimer.current);
507
590
  }, []);
591
+ const closeStats = react.useCallback(() => {
592
+ if (!statsOpen || statsClosing) return;
593
+ setStatsClosing(true);
594
+ if (closeTimer.current !== null) clearTimeout(closeTimer.current);
595
+ closeTimer.current = setTimeout(() => {
596
+ closeTimer.current = null;
597
+ setStatsOpen(false);
598
+ setStatsClosing(false);
599
+ }, 140);
600
+ }, [statsOpen, statsClosing]);
601
+ react.useEffect(() => {
602
+ if (!statsOpen) return undefined;
603
+ const closeOnOutsideClick = (event) => {
604
+ if (anchorRef.current && anchorRef.current.contains(event.target)) return;
605
+ if (panelRef.current && panelRef.current.contains(event.target)) return;
606
+ closeStats();
607
+ };
608
+ const closeOnResize = () => closeStats();
609
+ document.addEventListener("mousedown", closeOnOutsideClick);
610
+ window.addEventListener("resize", closeOnResize);
611
+ return () => {
612
+ document.removeEventListener("mousedown", closeOnOutsideClick);
613
+ window.removeEventListener("resize", closeOnResize);
614
+ };
615
+ }, [statsOpen, closeStats]);
508
616
  const openStats = (event) => {
509
617
  if (event && typeof event.preventDefault === "function") event.preventDefault();
510
618
  if (clickTimer.current !== null) clearTimeout(clickTimer.current);
619
+ if (closeTimer.current !== null) clearTimeout(closeTimer.current);
620
+ closeTimer.current = null;
621
+ setStatsClosing(false);
622
+ const trigger = event && event.currentTarget;
623
+ if (trigger && typeof trigger.getBoundingClientRect === "function") {
624
+ const rect = trigger.getBoundingClientRect();
625
+ const width = Math.max(1, Math.min(460, window.innerWidth - 24));
626
+ const desiredLeft = wide ? rect.right + 8 : rect.left;
627
+ const left = Math.max(12, Math.min(desiredLeft, window.innerWidth - width - 12));
628
+ const bottom = wide
629
+ ? Math.max(12, window.innerHeight - rect.bottom)
630
+ : Math.max(12, window.innerHeight - rect.top + 8);
631
+ setStatsPosition({ left, bottom, width });
632
+ }
511
633
  clickTimer.current = setTimeout(() => {
512
634
  clickTimer.current = null;
635
+ setStatsClosing(false);
513
636
  setStatsOpen(true);
514
637
  }, 230);
515
638
  };
@@ -517,6 +640,9 @@ window.__ModuleLoader__.load({
517
640
  if (event && typeof event.preventDefault === "function") event.preventDefault();
518
641
  if (clickTimer.current !== null) clearTimeout(clickTimer.current);
519
642
  clickTimer.current = null;
643
+ if (closeTimer.current !== null) clearTimeout(closeTimer.current);
644
+ closeTimer.current = null;
645
+ setStatsClosing(false);
520
646
  setStatsOpen(false);
521
647
  window.open("https://platform.deepseek.com/usage", "_blank", "noopener");
522
648
  };
@@ -526,8 +652,18 @@ window.__ModuleLoader__.load({
526
652
  onContextMenu: onBalanceContextMenu,
527
653
  title: "单击查看余额与使用统计,双击打开 DeepSeek API 用量页,右键刷新",
528
654
  };
529
- const withPanel = (node) => jsx.jsxs(jsx.Fragment, {
530
- children: [node, statsOpen ? jsx.jsx(UsageStatsPanel, { onClose: () => setStatsOpen(false) }) : null],
655
+ const withPanel = (node) => jsx.jsxs("div", {
656
+ ref: anchorRef,
657
+ className: "balance-stats-anchor balance-stats-anchor--" + (wide ? "wide" : "compact"),
658
+ children: [
659
+ node,
660
+ statsOpen ? reactDom.createPortal(jsx.jsx(UsageStatsPanel, {
661
+ onClose: closeStats,
662
+ panelRef,
663
+ style: statsPosition,
664
+ closing: statsClosing,
665
+ }), document.body) : null,
666
+ ],
531
667
  });
532
668
 
533
669
  if (!wide) {
package/lib/index.js CHANGED
@@ -1,9 +1,55 @@
1
1
  // dsh 插件:余额与当前会话消耗
2
2
  // 服务端:注册 /balance 命令 + /balance-api HTTP 接口(供 Web UI 插件直接渲染)
3
- import { inspectApiRemoteSession } from "@deepseek-ai/dsh-api-remotes";
4
-
5
3
  export const name = "balance-dsh";
6
4
 
5
+ let legacySessionInspector;
6
+ let legacySessionInspectorLoaded = false;
7
+
8
+ /**
9
+ * 跨 DSH 版本读取会话,且不在模块加载阶段绑定可能被删除的命名导出。
10
+ * 新版优先使用 sessionController;其次直接使用 sessionQuery;旧版最后回退
11
+ * @deepseek-ai/dsh-api-remotes.inspectApiRemoteSession。接口全部不可用时抛错,
12
+ * 由子代理统计调用方跳过该会话,不影响余额插件本身启动。
13
+ */
14
+ async function inspectPersistedSession(ctx, sessionId) {
15
+ const getService = typeof ctx?.get === "function" ? ctx.get.bind(ctx) : null;
16
+
17
+ const sessionController = getService?.("sessionController");
18
+ if (typeof sessionController?.inspect === "function") {
19
+ return sessionController.inspect(sessionId);
20
+ }
21
+
22
+ const sessionQuery = getService?.("sessionQuery");
23
+ if (typeof sessionQuery?.observeSession === "function") {
24
+ const observation = await sessionQuery.observeSession(sessionId, { projectionMode: "none" });
25
+ try {
26
+ return {
27
+ meta: observation.header,
28
+ events: [...(observation.events ?? [])],
29
+ };
30
+ } finally {
31
+ observation?.[Symbol.dispose]?.();
32
+ }
33
+ }
34
+
35
+ if (!legacySessionInspectorLoaded) {
36
+ legacySessionInspectorLoaded = true;
37
+ try {
38
+ const remotes = await import("@deepseek-ai/dsh-api-remotes");
39
+ if (typeof remotes.inspectApiRemoteSession === "function") {
40
+ legacySessionInspector = remotes.inspectApiRemoteSession;
41
+ }
42
+ } catch {
43
+ // 新版可能不再提供旧模块或旧导出;由下方统一处理。
44
+ }
45
+ }
46
+ if (typeof legacySessionInspector === "function") {
47
+ return legacySessionInspector(ctx, sessionId);
48
+ }
49
+
50
+ throw new Error("当前 DSH 未提供兼容的持久化会话读取接口");
51
+ }
52
+
7
53
  const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
8
54
 
9
55
  // DeepSeek 目录价(人民币/百万 tokens),按模型区分。默认取 2026-08-17 生效的
@@ -429,12 +475,12 @@ async function buildUsageStats(ctx, config) {
429
475
  }
430
476
 
431
477
  /** 汇总某会话的所有子代理后代会话的 usage(跨时段精确计费,与主会话分开)。
432
- * @param ctx DSH 上下文(用于 subagents 服务 + inspectApiRemoteSession)
478
+ * @param ctx DSH 上下文(用于 subagents 服务 + 跨版本持久化会话读取)
433
479
  * @param sessionId 父会话 id
434
480
  * @param config 插件配置
435
481
  * 说明:子代理会话结束后会被从 live 会话 store 移除,故不能用 store.list() 枚举;
436
482
  * 这里用 subagents 服务的 listDescendants(含已结束/嵌套子代理)取后代 id,
437
- * 再对每个子代理用 inspectApiRemoteSession 读取持久化事件计算 usage。无子代理返回 {usage:null,debug}。 */
483
+ * 再对每个子代理用兼容读取器取得持久化事件并计算 usage。无子代理返回 {usage:null,debug}。 */
438
484
  async function sumSubagentUsage(ctx, sessionId, config) {
439
485
  if (ctx == null || sessionId === "") return { usage: null };
440
486
  // 用 ctx.get("subagents") 安全获取(非注入,可空);不要直接读 ctx.subagents 属性,
@@ -466,7 +512,7 @@ async function sumSubagentUsage(ctx, sessionId, config) {
466
512
  let count = 0;
467
513
  for (const id of childIds) {
468
514
  try {
469
- const inspected = await inspectApiRemoteSession(ctx, id);
515
+ const inspected = await inspectPersistedSession(ctx, id);
470
516
  const events = Array.isArray(inspected?.events) ? inspected.events : [];
471
517
  const u = sumEventsUsage(events, config);
472
518
  if (u === null || u === void 0) continue;
@@ -548,6 +594,11 @@ function writeJson(res, status, payload) {
548
594
 
549
595
  export function apply(ctx, config = {}) {
550
596
  let statsCache = { expiresAt: 0, value: null };
597
+ // DSH 热重载(reload)时会重新 apply 本插件;若旧实例注册的 /balance-api 路由
598
+ // 未被清理,新实例再次 register 就会抛出 duplicate route,导致整个 apply 失败、
599
+ // 余额不显示。这里收集每次 register 返回的 disposer,并在插件卸载(ctx.on("dispose"))
600
+ // 时统一清理,保证重复 apply 不冲突。
601
+ const disposers = [];
551
602
 
552
603
  // 1) /balance 命令:保留命令行查询入口
553
604
  ctx.inject(["commands"], (commandCtx) => {
@@ -591,7 +642,7 @@ export function apply(ctx, config = {}) {
591
642
  // /balance-api 不注册(404)。改为只声 inject ["webServer"],子代理在
592
643
  // sumSubagentUsage 内部用可空 ctx.get("subagents") 获取。
593
644
  ctx.inject(["webServer"], (httpCtx) => {
594
- httpCtx.webServer.register({
645
+ const balanceRouteDisposer = httpCtx.webServer.register({
595
646
  kind: "exact",
596
647
  path: "/balance-api",
597
648
  handler: async (req, res) => {
@@ -705,6 +756,15 @@ export function apply(ctx, config = {}) {
705
756
  }
706
757
  },
707
758
  });
708
-
759
+ if (typeof balanceRouteDisposer === "function") disposers.push(balanceRouteDisposer);
709
760
  });
761
+
762
+ // 插件卸载/dispose 时清理本实例注册的路由,避免 DSH 热重载时旧实例残留
763
+ // 的路由导致新实例 register /balance-api 抛 duplicate route、插件启动失败。
764
+ const release = () => {
765
+ for (const d of disposers) {
766
+ try { d(); } catch { /* ignore */ }
767
+ }
768
+ };
769
+ if (typeof ctx.on === "function") ctx.on("dispose", release);
710
770
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "1.0.3",
3
+ "version": "1.0.9",
4
4
  "description": "查询 DeepSeek 余额并显示当前会话 token 消耗。DeepSeek 账户余额 + 会话 token/费用显示,含高峰/空闲峰谷计费。",
5
5
  "author": "Rannist",
6
6
  "type": "module",
@@ -26,10 +26,10 @@
26
26
  "patch": "./cordis.patch.yml"
27
27
  },
28
28
  "client": {
29
- "platform": "web",
30
29
  "inject": [
31
30
  "@deepseek-ai/dsh-client-runtime"
32
31
  ],
32
+ "platform": "web",
33
33
  "immediately": true
34
34
  }
35
35
  },