balance-dsh 1.0.3 → 1.0.8

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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/lib/client.js +216 -80
  3. package/package.json +1 -1
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "1.0.3",
3
+ "version": "1.0.8",
4
4
  "description": "查询 DeepSeek 余额并显示当前会话 token 消耗。DeepSeek 账户余额 + 会话 token/费用显示,含高峰/空闲峰谷计费。",
5
5
  "author": "Rannist",
6
6
  "type": "module",