balance-dsh 1.0.2 → 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.
package/README.md CHANGED
@@ -4,13 +4,21 @@ DSH 插件:在 Web UI 中直接显示 DeepSeek 账户余额和当前会话 tok
4
4
 
5
5
  ## 功能
6
6
 
7
- - **侧边栏余额行**:侧边栏展开时显示在底部(紧贴"设置"按钮上方),格式:钱包图标 + `余额` + 金额(带货币单位)+ 时段标签(高峰红 / 空闲绿)。金额按余额状态变色:充足绿色、偏低(<10)琥珀黄、为 0 显示红色"去充值"。悬停显示明细(总余额、充值余额),点击金额跳转 DeepSeek 用量页。
7
+ - **侧边栏余额行**:侧边栏展开时显示在底部(紧贴"设置"按钮上方),格式:钱包图标 + `余额` + 金额(带货币单位)+ 时段标签(高峰红 / 空闲绿)。金额按余额状态变色:充足绿色、偏低(<10)琥珀黄、为 0 显示红色"去充值"。
8
8
  - **收起态 FAB**:侧边栏收起时,余额显示为左下角悬浮胶囊。
9
9
  - **自动刷新**:每 60 秒自动刷新一次;每次对话完成(新回复落库)也会立即刷新;右键余额任意区域可手动刷新。
10
+ - **余额与使用统计**:单击余额在余额项旁以轻量动画打开局部快捷菜单,展示账户余额、近 90 天计费 Token、估算费用、扫描会话数和按日使用热力图;点击菜单外区域会播放关闭动画,双击余额打开 DeepSeek 官方 API 用量页。
10
11
  - **高峰/空闲计费**:官方高峰窗口为**周一至周五 09:00-12:00、14:00-18:00(北京时间)**,周末全天空闲。消耗金额**跨时段精确计费**:高峰时段的 token 按高峰价 ×2、空闲时段按目录价,按事件时间分桶计算。
11
12
  - **会话消耗行**:对话框下方实时显示当前会话消耗(新增 tokens + 估算金额),悬停查看完整明细(模型、时段拆分、计费口径)。
12
13
  - **`/balance` 命令**:命令行查询余额明细和当前会话消耗。
13
14
 
15
+ ### 使用统计口径
16
+
17
+ - 热力图按北京时间聚合最近 90 天持久化会话中的 `assistant/message` Token usage。
18
+ - 费用按照每条消息的实际模型和峰谷时段估算,最终金额以 DeepSeek 官方账单为准。
19
+ - 为避免历史会话过多拖慢 DSH,最多扫描最近 200 个会话;服务端统计和客户端展示结果均缓存 5 分钟,重复打开菜单不会重复请求。
20
+ - 若未配置 API Key,仍可查看本地 Token 与费用统计,但账户余额会显示为不可用。
21
+
14
22
  ## 安装
15
23
 
16
24
  前提:已安装 DSH Desktop,且知道自己的 profile 目录(通常是 `C:\Users\<你的用户名>\.dsh\profiles\desktop\`)。
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,7 +42,50 @@ 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
+ /* ── 跨会话统计浮层(余额元素的子级,向右/向上展开) ── */
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}
56
+ .balance-stats-subtitle{margin:4px 0 0;color:var(--dsw-alias-label-secondary);font-size:12px}
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}
58
+ .balance-stats-close:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
59
+ .balance-stats-error{padding:12px;border-radius:10px;color:var(--dsw-alias-state-error-primary,#e5484d);background:rgba(229,72,77,.10)}
60
+ .balance-stats-loading{padding:48px 12px;text-align:center;color:var(--dsw-alias-label-secondary)}
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)}}
65
+ .balance-stats-card__label{display:block;margin-bottom:6px;color:var(--dsw-alias-label-secondary);font-size:12px}
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}
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}
68
+ .balance-stats-legend{display:flex;align-items:center;gap:4px;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:400}
69
+ .balance-heatmap-wrap{min-width:0;overflow:hidden;padding:4px 2px 8px}
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%}
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}}
79
+ .balance-heatmap-cell--1{background:rgba(34,197,94,.25)}
80
+ .balance-heatmap-cell--2{background:rgba(34,197,94,.45)}
81
+ .balance-heatmap-cell--3{background:rgba(34,197,94,.68)}
82
+ .balance-heatmap-cell--4{background:rgba(22,163,74,.95)}
83
+ .balance-heatmap-cell--blank{visibility:hidden}
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}
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))}
86
+ .balance-stats-link:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
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}}
45
89
  /* ── 展开态底部一行:[设置齿轮][余额胶囊],余额在设置右边 ──
46
90
  全部使用稳定锚点,不依赖 CSS Modules hash 类名(DSH 升级后仍有效):
47
91
  - wide 态特征:底部存在 .balance-footer / .balance-topup(收起态渲染 position:fixed 的 .balance-fab,不影响)
@@ -55,12 +99,15 @@ window.__ModuleLoader__.load({
55
99
  `;
56
100
 
57
101
  const tagId = "balance/balance-widget.css";
58
- if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
59
- const tag = document.createElement("style");
60
- tag.dataset.plugin = "balance";
61
- 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
+ }
62
110
  tag.textContent = css;
63
- document.head.appendChild(tag);
64
111
  }
65
112
 
66
113
  // ─── 价格(与服务端默认保持一致;服务端 /balance-api 返回后会覆盖) ───
@@ -310,13 +357,316 @@ window.__ModuleLoader__.load({
310
357
  fetchBalanceOnce();
311
358
  }
312
359
 
313
- // ─── 余额显示:展开态侧栏底部文字(¥ 图标+金额);收起态悬浮 FAB ──
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;
389
+ }
390
+
391
+ const StatsCard = react.memo(function StatsCard({ label, value }) {
392
+ return jsx.jsxs("div", {
393
+ className: "balance-stats-card",
394
+ children: [
395
+ jsx.jsx("span", { className: "balance-stats-card__label", children: label }),
396
+ jsx.jsx("span", { className: "balance-stats-card__value", title: value, children: value }),
397
+ ],
398
+ });
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
+ });
410
+ react.useEffect(() => {
411
+ let disposed = false;
412
+ fetchUsageStats().then(
413
+ (data) => { if (!disposed) setState({ loading: false, data, error: "" }); },
414
+ (error) => {
415
+ if (disposed) return;
416
+ setState({ loading: false, data: null, error: error && error.message ? error.message : String(error) });
417
+ },
418
+ );
419
+ return () => { disposed = true; };
420
+ }, []);
421
+ react.useEffect(() => {
422
+ const onKeyDown = (event) => { if (event.key === "Escape") onClose(); };
423
+ window.addEventListener("keydown", onKeyDown);
424
+ return () => window.removeEventListener("keydown", onKeyDown);
425
+ }, [onClose]);
426
+
427
+ const data = state.data;
428
+ const stats = data && data.stats;
429
+ const days = stats && Array.isArray(stats.days) ? stats.days : [];
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);
475
+ const total = stats && stats.total;
476
+ const balanceText = balance
477
+ ? String(balance.total_balance ?? "0.00") + " " + String(balance.currency || "CNY")
478
+ : "不可用";
479
+ const body = state.loading
480
+ ? jsx.jsx("div", { className: "balance-stats-loading", children: "正在汇总会话记录…" })
481
+ : state.error
482
+ ? jsx.jsx("div", { className: "balance-stats-error", children: state.error })
483
+ : jsx.jsxs(jsx.Fragment, {
484
+ children: [
485
+ !liveBalance.balance && data.balanceError ? jsx.jsx("div", { className: "balance-stats-error", children: "余额:" + data.balanceError }) : null,
486
+ jsx.jsxs("div", {
487
+ className: "balance-stats-cards",
488
+ children: [
489
+ jsx.jsx(StatsCard, { label: "账户余额", value: balanceText }),
490
+ jsx.jsx(StatsCard, { label: "90 天计费 Token", value: formatTokens(Number(total?.totalTokens || 0)) }),
491
+ jsx.jsx(StatsCard, { label: "90 天估算费用", value: "¥" + Number(total?.cost || 0).toFixed(4) }),
492
+ jsx.jsx(StatsCard, { label: "已扫描会话", value: String(stats?.scannedSessions || 0) }),
493
+ ],
494
+ }),
495
+ jsx.jsxs("div", {
496
+ className: "balance-stats-section-title",
497
+ children: [
498
+ jsx.jsx("span", { children: "近 90 天使用热力图" }),
499
+ jsx.jsxs("span", {
500
+ className: "balance-stats-legend",
501
+ children: ["少", 0, 1, 2, 3, 4].map((item, index) => index === 0
502
+ ? item
503
+ : jsx.jsx("i", { className: "balance-heatmap-cell balance-heatmap-cell--" + item }, "legend-" + item)).concat(["多"]),
504
+ }),
505
+ ],
506
+ }),
507
+ jsx.jsx("div", { className: "balance-heatmap-wrap", children: jsx.jsx("div", { className: "balance-heatmap", children: heatmap }) }),
508
+ jsx.jsxs("div", {
509
+ className: "balance-stats-footer",
510
+ children: [
511
+ jsx.jsx("span", {
512
+ children: stats?.limited
513
+ ? "为保证性能,仅统计最近 200 个会话;结果缓存 5 分钟。"
514
+ : "按北京时间和实际模型峰谷价格估算;结果缓存 5 分钟。",
515
+ }),
516
+ jsx.jsx("a", {
517
+ className: "balance-stats-link",
518
+ href: "https://platform.deepseek.com/usage",
519
+ target: "_blank",
520
+ rel: "noreferrer",
521
+ children: "查看官方用量",
522
+ }),
523
+ ],
524
+ }),
525
+ ],
526
+ });
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
+ ],
554
+ });
555
+ }
556
+
557
+ function WalletIcon() {
558
+ return jsx.jsxs("svg", {
559
+ width: 16,
560
+ height: 16,
561
+ viewBox: "0 0 24 24",
562
+ fill: "none",
563
+ xmlns: "http://www.w3.org/2000/svg",
564
+ stroke: "currentColor",
565
+ strokeWidth: 2,
566
+ strokeLinecap: "round",
567
+ strokeLinejoin: "round",
568
+ "aria-hidden": "true",
569
+ children: [
570
+ jsx.jsx("path", { d: "M21 12V7H5a2 2 0 0 1 0-4h14v4" }),
571
+ jsx.jsx("path", { d: "M3 5v14a2 2 0 0 0 2 2h16v-5" }),
572
+ jsx.jsx("path", { d: "M18 12a2 2 0 0 0 0 4h4v-4Z" }),
573
+ ],
574
+ });
575
+ }
576
+
577
+ // ─── 余额显示:单击统计,双击 DeepSeek 用量页,右键刷新 ─────────────
314
578
  function BalanceFooter({ wide }) {
315
579
  const state = useBalance();
316
- const openUsagePage = (e) => { e && e.preventDefault(); window.open("https://platform.deepseek.com/usage", "_blank", "noopener"); };
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 });
583
+ const clickTimer = react.useRef(null);
584
+ const closeTimer = react.useRef(null);
585
+ const anchorRef = react.useRef(null);
586
+ const panelRef = react.useRef(null);
587
+ react.useEffect(() => () => {
588
+ if (clickTimer.current !== null) clearTimeout(clickTimer.current);
589
+ if (closeTimer.current !== null) clearTimeout(closeTimer.current);
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]);
616
+ const openStats = (event) => {
617
+ if (event && typeof event.preventDefault === "function") event.preventDefault();
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
+ }
633
+ clickTimer.current = setTimeout(() => {
634
+ clickTimer.current = null;
635
+ setStatsClosing(false);
636
+ setStatsOpen(true);
637
+ }, 230);
638
+ };
639
+ const openUsagePage = (event) => {
640
+ if (event && typeof event.preventDefault === "function") event.preventDefault();
641
+ if (clickTimer.current !== null) clearTimeout(clickTimer.current);
642
+ clickTimer.current = null;
643
+ if (closeTimer.current !== null) clearTimeout(closeTimer.current);
644
+ closeTimer.current = null;
645
+ setStatsClosing(false);
646
+ setStatsOpen(false);
647
+ window.open("https://platform.deepseek.com/usage", "_blank", "noopener");
648
+ };
649
+ const interactions = {
650
+ onClick: openStats,
651
+ onDoubleClick: openUsagePage,
652
+ onContextMenu: onBalanceContextMenu,
653
+ title: "单击查看余额与使用统计,双击打开 DeepSeek API 用量页,右键刷新",
654
+ };
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
+ ],
667
+ });
317
668
 
318
669
  if (!wide) {
319
- // 收起态:悬浮 FAB(原样保留)
320
670
  if (state.status === "error" || !state.balance) return null;
321
671
  const b = state.balance;
322
672
  const total = Number.parseFloat(b.total_balance ?? "0");
@@ -331,34 +681,23 @@ window.__ModuleLoader__.load({
331
681
  children: "去充值",
332
682
  });
333
683
  }
334
- const currency = b.currency || "CNY";
335
- const detailText = "总余额 " + b.total_balance + " " + currency + " · 充值余额 " + b.topped_up_balance + " " + currency;
336
- return jsx.jsx("a", {
684
+ return withPanel(jsx.jsx("a", {
337
685
  className: "balance-fab",
338
686
  href: "https://platform.deepseek.com/usage",
339
- target: "_blank",
340
- rel: "noreferrer",
341
- onContextMenu: onBalanceContextMenu,
687
+ ...interactions,
342
688
  children: "¥" + b.total_balance,
343
- });
689
+ }));
344
690
  }
345
691
 
346
- // 展开态:底部文字(加载中/失败/去充值/正常)
347
692
  if (state.status === "error") {
348
- return jsx.jsx("div", {
693
+ return withPanel(jsx.jsx("div", {
349
694
  className: "balance-footer",
350
- title: "余额查询失败:" + state.error,
351
- onContextMenu: onBalanceContextMenu,
695
+ ...interactions,
352
696
  children: jsx.jsx("span", { className: "balance-footer__error", children: "余额查询失败" }),
353
- });
697
+ }));
354
698
  }
355
-
356
699
  if (!state.balance) {
357
- return jsx.jsx("div", {
358
- className: "balance-footer",
359
- onContextMenu: onBalanceContextMenu,
360
- children: "余额加载中…",
361
- });
700
+ return withPanel(jsx.jsx("div", { className: "balance-footer", ...interactions, children: "余额加载中…" }));
362
701
  }
363
702
 
364
703
  const b = state.balance;
@@ -366,96 +705,47 @@ window.__ModuleLoader__.load({
366
705
  const depleted = b.is_available === false || !Number.isFinite(total) || total <= 0;
367
706
  const currency = b.currency || "CNY";
368
707
  const totalText = b.total_balance + " " + currency;
369
- const detailText = "总余额 " + totalText + " · 充值余额 " + b.topped_up_balance + " " + currency;
370
- const period = currentPeriod();
371
- const low = total > 0 && total < 10;
372
-
373
- // 金额胶囊:正常显示 金额•时段(语义色);没余额显示 0(红色),并追加"去充值"胶囊
374
- // 没余额:保留图标+"余额"字样,去掉具体金额,右侧只显示"去充值"按钮
375
708
  if (depleted) {
376
- return jsx.jsx("div", {
709
+ return withPanel(jsx.jsxs("div", {
377
710
  className: "balance-footer",
378
- onContextMenu: onBalanceContextMenu,
711
+ ...interactions,
379
712
  children: [
380
- jsx.jsx("span", {
381
- className: "balance-footer__icon",
382
- children: jsx.jsxs("svg", {
383
- width: 16,
384
- height: 16,
385
- viewBox: "0 0 24 24",
386
- fill: "none",
387
- xmlns: "http://www.w3.org/2000/svg",
388
- stroke: "currentColor",
389
- strokeWidth: 2,
390
- strokeLinecap: "round",
391
- strokeLinejoin: "round",
392
- "aria-hidden": "true",
393
- children: [
394
- jsx.jsx("path", { d: "M21 12V7H5a2 2 0 0 1 0-4h14v4" }),
395
- jsx.jsx("path", { d: "M3 5v14a2 2 0 0 0 2 2h16v-5" }),
396
- jsx.jsx("path", { d: "M18 12a2 2 0 0 0 0 4h4v-4Z" }),
397
- ],
398
- }),
399
- }),
713
+ jsx.jsx("span", { className: "balance-footer__icon", children: jsx.jsx(WalletIcon, {}) }),
400
714
  jsx.jsx("span", { className: "balance-footer__label", children: "余额" }),
401
715
  jsx.jsx("a", {
402
716
  className: "balance-footer__topup",
403
717
  href: "https://platform.deepseek.com/top_up",
404
718
  target: "_blank",
405
719
  rel: "noreferrer",
720
+ onClick: (event) => event.stopPropagation(),
721
+ onDoubleClick: (event) => event.stopPropagation(),
406
722
  onContextMenu: onBalanceContextMenu,
407
723
  children: "去充值",
408
724
  }),
409
725
  ],
410
- });
726
+ }));
411
727
  }
412
728
 
413
- const valueClass = low ? " balance-footer__value--low" : " balance-footer__value--healthy";
414
- const valueChildren = [
415
- totalText,
416
- " • ",
417
- state.refreshing
418
- ? jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--refreshing", children: "刷新中…" })
419
- : jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--" + period, children: period === "peak" ? "高峰" : "空闲" }),
420
- ];
421
- const inner = [
422
- jsx.jsx("span", {
423
- className: "balance-footer__icon",
424
- children: jsx.jsxs("svg", {
425
- width: 16,
426
- height: 16,
427
- viewBox: "0 0 24 24",
428
- fill: "none",
429
- xmlns: "http://www.w3.org/2000/svg",
430
- stroke: "currentColor",
431
- strokeWidth: 2,
432
- strokeLinecap: "round",
433
- strokeLinejoin: "round",
434
- "aria-hidden": "true",
729
+ const period = currentPeriod();
730
+ const low = total > 0 && total < 10;
731
+ return withPanel(jsx.jsxs("div", {
732
+ className: "balance-footer",
733
+ ...interactions,
734
+ children: [
735
+ jsx.jsx("span", { className: "balance-footer__icon", children: jsx.jsx(WalletIcon, {}) }),
736
+ jsx.jsx("span", { className: "balance-footer__label", children: "余额" }),
737
+ jsx.jsxs("span", {
738
+ className: "balance-footer__value" + (low ? " balance-footer__value--low" : " balance-footer__value--healthy"),
435
739
  children: [
436
- jsx.jsx("path", { d: "M21 12V7H5a2 2 0 0 1 0-4h14v4" }),
437
- jsx.jsx("path", { d: "M3 5v14a2 2 0 0 0 2 2h16v-5" }),
438
- jsx.jsx("path", { d: "M18 12a2 2 0 0 0 0 4h4v-4Z" }),
740
+ totalText,
741
+ " • ",
742
+ state.refreshing
743
+ ? jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--refreshing", children: "刷新中…" })
744
+ : jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--" + period, children: period === "peak" ? "高峰" : "空闲" }),
439
745
  ],
440
746
  }),
441
- }),
442
- jsx.jsx("span", {
443
- className: "balance-footer__label",
444
- children: "余额",
445
- }),
446
- jsx.jsx("span", {
447
- className: "balance-footer__value" + valueClass,
448
- onClick: openUsagePage,
449
- onContextMenu: onBalanceContextMenu,
450
- title: "打开 DeepSeek 用量页",
451
- children: valueChildren,
452
- }),
453
- ];
454
- return jsx.jsx("div", {
455
- className: "balance-footer",
456
- onContextMenu: onBalanceContextMenu,
457
- children: inner,
458
- });
747
+ ],
748
+ }));
459
749
  }
460
750
 
461
751
  // ─── 当前会话消耗:输入区 dock ──────────────────────────────────────────
package/lib/index.js CHANGED
@@ -284,6 +284,150 @@ function estimateCost(usage, pricing) {
284
284
  return costOf(usage.peak) * 2 + costOf(usage.offpeak);
285
285
  }
286
286
 
287
+ const STATS_DAYS = 90;
288
+ const STATS_MAX_SESSIONS = 200;
289
+ const STATS_CACHE_MS = 5 * 60 * 1000;
290
+
291
+ function beijingDayKey(timeMs) {
292
+ return new Date(Number(timeMs) + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
293
+ }
294
+
295
+ function emptyTokenBucket() {
296
+ return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
297
+ }
298
+
299
+ function addTokenUsage(target, usage) {
300
+ target.inputTokens += usage.inputTokens ?? 0;
301
+ target.outputTokens += usage.outputTokens ?? 0;
302
+ target.cacheReadTokens += usage.cacheReadTokens ?? 0;
303
+ target.cacheWriteTokens += usage.cacheWriteTokens ?? 0;
304
+ }
305
+
306
+ function tokenTotal(usage) {
307
+ return usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens;
308
+ }
309
+
310
+ /** 按北京时间把一个会话的最终 assistant/message usage 聚合到每日统计。 */
311
+ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessionId) {
312
+ const switches = collectModelSwitches(events);
313
+ const fallback = fallbackModel || "deepseek-v4-flash";
314
+ const latestByStep = new Map();
315
+ for (const event of events ?? []) {
316
+ if (event.type !== "assistant/message") continue;
317
+ const usage = event.data?.usage;
318
+ const turn = event.data?.turn;
319
+ const step = event.data?.step;
320
+ if (usage == null || turn == null || step == null) continue;
321
+ latestByStep.set(`${turn}/${step}`, { usage, time: Number(event.time ?? 0) });
322
+ }
323
+ for (const { usage, time } of latestByStep.values()) {
324
+ if (!Number.isFinite(time) || time < cutoffMs) continue;
325
+ const key = beijingDayKey(time);
326
+ let day = days.get(key);
327
+ if (day == null) {
328
+ day = { date: key, ...emptyTokenBucket(), totalTokens: 0, cost: 0, sessionIds: new Set() };
329
+ days.set(key, day);
330
+ }
331
+ addTokenUsage(day, usage);
332
+ day.totalTokens += tokenTotal({
333
+ inputTokens: usage.inputTokens ?? 0,
334
+ outputTokens: usage.outputTokens ?? 0,
335
+ cacheReadTokens: usage.cacheReadTokens ?? 0,
336
+ cacheWriteTokens: usage.cacheWriteTokens ?? 0,
337
+ });
338
+ day.sessionIds.add(sessionId);
339
+ const model = modelAtTime(switches, time) || fallback;
340
+ const bucket = { peak: emptyTokenBucket(), offpeak: emptyTokenBucket() };
341
+ addTokenUsage(isPeakAt(time, config) ? bucket.peak : bucket.offpeak, usage);
342
+ day.cost += estimateCost(bucket, resolvePricing(config, model));
343
+ }
344
+ }
345
+
346
+ function recentDayKeys(days, now = Date.now()) {
347
+ const result = [];
348
+ const beijingNow = now + 8 * 60 * 60 * 1000;
349
+ const current = new Date(beijingNow);
350
+ current.setUTCHours(0, 0, 0, 0);
351
+ for (let offset = days - 1; offset >= 0; offset--) {
352
+ result.push(new Date(current.getTime() - offset * 24 * 60 * 60 * 1000).toISOString().slice(0, 10));
353
+ }
354
+ return result;
355
+ }
356
+
357
+ async function buildUsageStats(ctx, config) {
358
+ const persistence = typeof ctx.get === "function" ? ctx.get("sessionPersistence") : void 0;
359
+ if (persistence == null || typeof persistence.list !== "function" || typeof persistence.inspect !== "function") {
360
+ throw new Error("会话持久化服务不可用");
361
+ }
362
+ const now = Date.now();
363
+ const cutoffMs = now - (STATS_DAYS - 1) * 24 * 60 * 60 * 1000;
364
+ const headers = await persistence.list();
365
+ const selected = [...headers]
366
+ .filter((header) => header && typeof header.id === "string")
367
+ .sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))
368
+ .slice(0, STATS_MAX_SESSIONS);
369
+ const daily = new Map();
370
+ let cursor = 0;
371
+ let scannedSessions = 0;
372
+ let failedSessions = 0;
373
+ const worker = async () => {
374
+ while (cursor < selected.length) {
375
+ const header = selected[cursor++];
376
+ try {
377
+ const inspected = await persistence.inspect(header.id);
378
+ collectDailyUsage(
379
+ inspected?.events ?? [],
380
+ config,
381
+ detectModel({ requestHeader: () => void 0, requestContext: () => void 0, events: inspected?.events ?? [] }),
382
+ cutoffMs,
383
+ daily,
384
+ header.id,
385
+ );
386
+ scannedSessions++;
387
+ } catch {
388
+ failedSessions++;
389
+ }
390
+ }
391
+ };
392
+ await Promise.all(Array.from({ length: Math.min(3, selected.length) }, () => worker()));
393
+
394
+ const rows = recentDayKeys(STATS_DAYS, now).map((date) => {
395
+ const day = daily.get(date);
396
+ if (day == null) {
397
+ return { date, ...emptyTokenBucket(), totalTokens: 0, cost: 0, sessions: 0 };
398
+ }
399
+ return {
400
+ date,
401
+ inputTokens: day.inputTokens,
402
+ outputTokens: day.outputTokens,
403
+ cacheReadTokens: day.cacheReadTokens,
404
+ cacheWriteTokens: day.cacheWriteTokens,
405
+ totalTokens: day.totalTokens,
406
+ cost: day.cost,
407
+ sessions: day.sessionIds.size,
408
+ };
409
+ });
410
+ const total = rows.reduce((acc, day) => {
411
+ acc.inputTokens += day.inputTokens;
412
+ acc.outputTokens += day.outputTokens;
413
+ acc.cacheReadTokens += day.cacheReadTokens;
414
+ acc.cacheWriteTokens += day.cacheWriteTokens;
415
+ acc.totalTokens += day.totalTokens;
416
+ acc.cost += day.cost;
417
+ return acc;
418
+ }, { ...emptyTokenBucket(), totalTokens: 0, cost: 0 });
419
+ return {
420
+ days: rows,
421
+ total,
422
+ rangeDays: STATS_DAYS,
423
+ scannedSessions,
424
+ failedSessions,
425
+ availableSessions: headers.length,
426
+ limited: headers.length > selected.length,
427
+ generatedAt: now,
428
+ };
429
+ }
430
+
287
431
  /** 汇总某会话的所有子代理后代会话的 usage(跨时段精确计费,与主会话分开)。
288
432
  * @param ctx DSH 上下文(用于 subagents 服务 + inspectApiRemoteSession)
289
433
  * @param sessionId 父会话 id
@@ -403,6 +547,8 @@ function writeJson(res, status, payload) {
403
547
  }
404
548
 
405
549
  export function apply(ctx, config = {}) {
550
+ let statsCache = { expiresAt: 0, value: null };
551
+
406
552
  // 1) /balance 命令:保留命令行查询入口
407
553
  ctx.inject(["commands"], (commandCtx) => {
408
554
  commandCtx.commands.register({
@@ -457,17 +603,46 @@ export function apply(ctx, config = {}) {
457
603
  // 可选 ?session= 传入会话 id,服务端返回该会话的分时段 usage(跨时段精确计费用)
458
604
  let model = "";
459
605
  let sessionParam = "";
606
+ let wantStats = false;
460
607
  try {
461
608
  const search = new URL(req.url, "http://localhost").searchParams;
462
609
  model = search.get("model") ?? "";
463
610
  sessionParam = search.get("session") ?? "";
611
+ wantStats = search.get("stats") === "1";
464
612
  } catch {
465
613
  model = "";
466
614
  sessionParam = "";
615
+ wantStats = false;
616
+ }
617
+ let stats = null;
618
+ let statsError = "";
619
+ if (wantStats) {
620
+ try {
621
+ if (statsCache.value == null || statsCache.expiresAt <= Date.now()) {
622
+ statsCache = {
623
+ value: await buildUsageStats(httpCtx, config),
624
+ expiresAt: Date.now() + STATS_CACHE_MS,
625
+ };
626
+ }
627
+ stats = statsCache.value;
628
+ } catch (error) {
629
+ statsError = error instanceof Error ? error.message : String(error);
630
+ }
467
631
  }
468
632
  const apiKey = await resolveApiKey(ctx, config);
469
633
  if (apiKey === "") {
470
- writeJson(res, 200, { ok: false, error: "未设置 DEEPSEEK_API_KEY" });
634
+ if (wantStats) {
635
+ writeJson(res, 200, {
636
+ ok: true,
637
+ balance: null,
638
+ balanceError: "未设置 DEEPSEEK_API_KEY",
639
+ pricing: resolvePricing(config, model),
640
+ stats,
641
+ statsError,
642
+ });
643
+ } else {
644
+ writeJson(res, 200, { ok: false, error: "未设置 DEEPSEEK_API_KEY" });
645
+ }
471
646
  return;
472
647
  }
473
648
  // 按 session id 取会话并分时段汇总 usage(拿不到则返回 null,客户端回退用本地投影)
@@ -510,10 +685,23 @@ export function apply(ctx, config = {}) {
510
685
  model: effectiveModel,
511
686
  usage,
512
687
  subagentUsage,
688
+ stats,
689
+ statsError,
513
690
  });
514
691
  } catch (error) {
515
692
  const message = error instanceof Error ? error.message : String(error);
516
- writeJson(res, 200, { ok: false, error: message });
693
+ if (wantStats) {
694
+ writeJson(res, 200, {
695
+ ok: true,
696
+ balance: null,
697
+ balanceError: message,
698
+ pricing: resolvePricing(config, effectiveModel),
699
+ stats,
700
+ statsError,
701
+ });
702
+ } else {
703
+ writeJson(res, 200, { ok: false, error: message });
704
+ }
517
705
  }
518
706
  },
519
707
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "1.0.2",
3
+ "version": "1.0.8",
4
4
  "description": "查询 DeepSeek 余额并显示当前会话 token 消耗。DeepSeek 账户余额 + 会话 token/费用显示,含高峰/空闲峰谷计费。",
5
5
  "author": "Rannist",
6
6
  "type": "module",