balance-dsh 1.1.0 → 1.2.0

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/lib/client.js +280 -66
  2. package/lib/index.js +83 -9
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -47,24 +47,41 @@ window.__ModuleLoader__.load({
47
47
  .balance-stats-anchor--wide{width:100%}
48
48
  .balance-stats-anchor--compact{position:fixed;left:68px;bottom:12px;z-index:10000}
49
49
  .balance-stats-anchor--compact .balance-fab{position:static}
50
- .balance-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}
50
+ .balance-stats-layer{box-sizing:border-box;position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.24));backdrop-filter:var(--dsw-mask-blur,blur(2px));-webkit-backdrop-filter:var(--dsw-mask-blur,blur(2px));animation:balance-stats-backdrop-in .16s var(--ds-ease-in-out,ease-out) both}
51
+ .balance-stats-layer--closing{pointer-events:none;animation:balance-stats-backdrop-out .14s var(--ds-ease-in-out,ease-in) both}
52
+ .balance-stats-panel{box-sizing:border-box;position:relative;z-index:10000;width:min(800px,100%);max-height:100%;overflow-x:hidden;overflow-y:auto;scrollbar-gutter:stable;padding:0 0 24px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));border:0;border-radius:24px;box-shadow:var(--dsw-elevation-prominent,0 14px 36px rgba(0,0,0,.24));transform-origin:center center;will-change:transform,opacity;contain:layout style;animation:balance-stats-panel-in .26s var(--ds-ease-in-out,ease-out) .04s both;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2,rgba(127,127,127,.35));--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2,rgba(127,127,127,.55))}
53
+ /* 滚动条对齐 DSH 设置窗口:宽度 12px,滑块靠 transparent 边框内缩到 4px(永远不贴 24px 圆角),
54
+ 轨道上下各留 10px,颜色沿用 DSH 自己的 --dsh-scrollbar-thumb 变量。 */
55
+ .balance-stats-panel::-webkit-scrollbar{width:12px;height:12px}
56
+ .balance-stats-panel::-webkit-scrollbar-track{background:transparent;margin:10px}
57
+ .balance-stats-panel::-webkit-scrollbar-thumb{border:4px solid transparent;background-clip:padding-box;border-radius:6px;background-color:var(--dsh-scrollbar-thumb)}
58
+ .balance-stats-panel::-webkit-scrollbar-thumb:hover{background-color:var(--dsh-scrollbar-thumb-hover)}
59
+ .balance-stats-panel::-webkit-scrollbar-corner{background:transparent}
60
+ @keyframes balance-stats-backdrop-in{from{opacity:0}to{opacity:1}}
61
+ @keyframes balance-stats-backdrop-out{from{opacity:1}to{opacity:0}}
51
62
  .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)}}
63
+ @keyframes balance-stats-panel-in{from{opacity:0;transform:translateY(18px) scale(.955)}to{opacity:1;transform:translateY(0) scale(1)}}
53
64
  @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}
65
+ .balance-stats-header{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:22px 14px 12px 24px}
66
+ .balance-stats-body{display:flex;flex-direction:column;min-width:0;padding:0 24px}
67
+ .balance-stats-title{margin:0;font-size:16px;line-height:24px;font-weight:500}
56
68
  .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}
69
+ .balance-stats-close{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:8px;color:var(--dsw-alias-label-secondary);background:transparent;font-size:22px;line-height:1;cursor:pointer}
58
70
  .balance-stats-close:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
59
71
  .balance-stats-error{padding:12px;border-radius:10px;color:var(--dsw-alias-state-error-primary,#e5484d);background:rgba(229,72,77,.10)}
60
72
  .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}
73
+ .balance-stats-top{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;margin-bottom:8px}
74
+ .balance-stats-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-bottom:8px}
75
+ /* 分组数为奇数时(「按模型」「时段拆分」条件渲染可能缺席),最后一个横跨整行,避免半空行 */
76
+ .balance-stats-grid>:last-child:nth-child(odd){grid-column:1/-1}
77
+ .balance-stats-cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-auto-rows:1fr;gap:8px;min-width:0}
78
+ .balance-stats-card{display:flex;flex-direction:column;justify-content:center;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
79
  .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
80
  @keyframes balance-stats-card-in{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:translateY(0)}}
65
81
  .balance-stats-card__label{display:block;margin-bottom:6px;color:var(--dsw-alias-label-secondary);font-size:12px}
66
82
  .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}
83
+ .balance-stats-heatmap-card{box-sizing:border-box;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 .22s var(--ds-ease-in-out,ease-out) .08s both}
84
+ .balance-stats-section-title{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 8px;font-size:12px;font-weight:600}
68
85
  .balance-stats-legend{display:flex;align-items:center;gap:4px;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:400}
69
86
  .balance-heatmap-wrap{min-width:0;overflow:hidden;padding:4px 2px 8px}
70
87
  .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%}
@@ -84,8 +101,16 @@ window.__ModuleLoader__.load({
84
101
  .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
102
  .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
103
  .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}}
104
+ .balance-stats-group{box-sizing:border-box;margin-bottom: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 .22s var(--ds-ease-in-out,ease-out) both}
105
+ .balance-stats-group__title{display:flex;align-items:baseline;justify-content:space-between;gap:8px;margin:0 0 6px;color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600}
106
+ .balance-stats-group__hint{color:var(--dsw-alias-label-secondary);font-weight:400;font-variant-numeric:tabular-nums}
107
+ .balance-stats-row{display:flex;align-items:baseline;justify-content:space-between;gap:12px;min-width:0;padding:6px 0;font-size:12px}
108
+ .balance-stats-row+.balance-stats-row{border-top:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.16))}
109
+ .balance-stats-row__name{min-width:0;overflow:hidden;color:var(--dsw-alias-label-primary);text-overflow:ellipsis;white-space:nowrap}
110
+ .balance-stats-row__value{flex:none;text-align:right;color:var(--dsw-alias-label-primary);font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
111
+ @media(max-width:900px){.balance-stats-top{grid-template-columns:minmax(0,1fr)}.balance-stats-grid{grid-template-columns:minmax(0,1fr)}}
112
+ @media(max-width:760px){.balance-stats-layer{padding:10px}}
113
+ @media(prefers-reduced-motion:reduce){.balance-stats-panel,.balance-stats-panel--closing,.balance-stats-layer,.balance-stats-layer--closing,.balance-stats-card,.balance-stats-group,.balance-stats-heatmap-card{animation:none}.balance-heatmap-cell{transition:none}}
89
114
  /* ── 展开态底部一行:[设置齿轮][余额胶囊],余额在设置右边 ──
90
115
  全部使用稳定锚点,不依赖 CSS Modules hash 类名(DSH 升级后仍有效):
91
116
  - wide 态特征:底部存在 .balance-footer / .balance-topup(收起态渲染 position:fixed 的 .balance-fab,不影响)
@@ -389,17 +414,71 @@ window.__ModuleLoader__.load({
389
414
  return usageStatsInFlight;
390
415
  }
391
416
 
417
+ /** 定价表内部键 -> 展示名 */
418
+ function modelLabel(key) {
419
+ if (key === "deepseek-v4-flash") return "Flash";
420
+ if (key === "deepseek-v4-pro") return "Pro";
421
+ if (key === "deepseek-chat") return "Chat";
422
+ if (key === "deepseek-reasoner") return "Reasoner";
423
+ return String(key || "未知");
424
+ }
425
+
426
+ /** token 桶求和(服务端统计返回的 peak / offpeak 桶) */
427
+ function bucketTokens(bucket) {
428
+ if (!bucket) return 0;
429
+ return (
430
+ Number(bucket.inputTokens || 0) +
431
+ Number(bucket.outputTokens || 0) +
432
+ Number(bucket.cacheReadTokens || 0) +
433
+ Number(bucket.cacheWriteTokens || 0)
434
+ );
435
+ }
436
+
437
+ /** 概览卡片。数值已完整展示,**不挂原生 `title`**——浏览器系统灰框与 DSH 主题不一致。 */
392
438
  const StatsCard = react.memo(function StatsCard({ label, value }) {
393
439
  return jsx.jsxs("div", {
394
440
  className: "balance-stats-card",
395
441
  children: [
396
442
  jsx.jsx("span", { className: "balance-stats-card__label", children: label }),
397
- jsx.jsx("span", { className: "balance-stats-card__value", title: value, children: value }),
443
+ jsx.jsx("span", { className: "balance-stats-card__value", children: value }),
444
+ ],
445
+ });
446
+ });
447
+
448
+ /** 明细行:左侧名称、右侧数值(两端对齐)。
449
+ * 曾经画过占比条,但各分组的基准并不统一(Token 构成按 token、按模型/时段按费用、
450
+ * 近期消耗按 90 天总额),同一屏三种尺子无法横向比较,而数值本身已表达量级,
451
+ * 条只会让人猜「这到底占谁的比例」,故去掉。 */
452
+ const StatsRow = react.memo(function StatsRow({ name, value }) {
453
+ return jsx.jsxs("div", {
454
+ className: "balance-stats-row",
455
+ children: [
456
+ jsx.jsx("span", { className: "balance-stats-row__name", children: name }),
457
+ jsx.jsx("span", { className: "balance-stats-row__value", children: value }),
398
458
  ],
399
459
  });
400
460
  });
401
461
 
402
- function UsageStatsPanel({ onClose, panelRef, style, closing }) {
462
+ /** 明细分组:小标题(可带右侧提示) + 若干明细行。
463
+ * `index` 只用于卡片逐个浮现的阶梯动画延迟——面板先浮现、各卡片随后依次入场。 */
464
+ function StatsGroup({ title, hint, children, index = 0 }) {
465
+ return jsx.jsxs("div", {
466
+ className: "balance-stats-group",
467
+ style: { animationDelay: (0.08 + index * 0.035).toFixed(3) + "s" },
468
+ children: [
469
+ jsx.jsxs("div", {
470
+ className: "balance-stats-group__title",
471
+ children: [
472
+ jsx.jsx("span", { children: title }),
473
+ hint ? jsx.jsx("span", { className: "balance-stats-group__hint", children: hint }) : null,
474
+ ],
475
+ }),
476
+ children,
477
+ ],
478
+ });
479
+ }
480
+
481
+ function UsageStatsPanel({ onClose, panelRef, closing }) {
403
482
  const liveBalance = useBalance();
404
483
  const [heatmapTooltip, setHeatmapTooltip] = react.useState(null);
405
484
  const [state, setState] = react.useState(() => {
@@ -477,6 +556,58 @@ window.__ModuleLoader__.load({
477
556
  const balanceText = balance
478
557
  ? String(balance.total_balance ?? "0.00") + " " + String(balance.currency || "CNY")
479
558
  : "不可用";
559
+
560
+ // —— 派生统计:全部对旧缓存 / 缺失字段容错(数值缺失按 0、数组缺失按空) ——
561
+ const num = (v) => Number(v || 0);
562
+ const derived = react.useMemo(() => {
563
+ const t = total || {};
564
+ const dayList = Array.isArray(days) ? days : [];
565
+ const activeDays = dayList.filter((d) => num(d.totalTokens) > 0);
566
+ const sumRange = (n) =>
567
+ dayList.slice(-n).reduce(
568
+ (acc, d) => {
569
+ acc.tokens += num(d.totalTokens);
570
+ acc.cost += num(d.cost);
571
+ return acc;
572
+ },
573
+ { tokens: 0, cost: 0 },
574
+ );
575
+ const today = dayList.length > 0 ? dayList[dayList.length - 1] : null;
576
+ const peakDay = activeDays.reduce(
577
+ (best, d) => (best == null || num(d.totalTokens) > num(best.totalTokens) ? d : best),
578
+ null,
579
+ );
580
+ const inputTokens = num(t.inputTokens);
581
+ const cacheReadTokens = num(t.cacheReadTokens);
582
+ const cacheWriteTokens = num(t.cacheWriteTokens);
583
+ const outputTokens = num(t.outputTokens);
584
+ const billedInput = inputTokens + cacheReadTokens + cacheWriteTokens;
585
+ const activeCount = activeDays.length;
586
+ return {
587
+ activeCount,
588
+ todayTokens: num(today && today.totalTokens),
589
+ todayCost: num(today && today.cost),
590
+ week: sumRange(7),
591
+ month: sumRange(30),
592
+ avgTokens: activeCount > 0 ? num(t.totalTokens) / activeCount : 0,
593
+ avgCost: activeCount > 0 ? num(t.cost) / activeCount : 0,
594
+ peakDay,
595
+ inputTokens,
596
+ cacheReadTokens,
597
+ cacheWriteTokens,
598
+ outputTokens,
599
+ hitRate: billedInput > 0 ? cacheReadTokens / billedInput : 0,
600
+ byModel: Array.isArray(t.byModel) ? t.byModel.filter((m) => m && num(m.totalTokens) > 0) : [],
601
+ peakTokens: bucketTokens(t.peak),
602
+ offpeakTokens: bucketTokens(t.offpeak),
603
+ peakCost: num(t.peakCost),
604
+ offpeakCost: num(t.offpeakCost),
605
+ };
606
+ }, [days, total]);
607
+ const fmtCost = (v) => "¥" + num(v).toFixed(4);
608
+ const denomCost = num(total && total.cost) > 0 ? num(total.cost) : 1;
609
+ const rangeDays = Number(stats && stats.rangeDays) || 90;
610
+
480
611
  const body = state.loading
481
612
  ? jsx.jsx("div", { className: "balance-stats-loading", children: "正在汇总会话记录…" })
482
613
  : state.error
@@ -485,34 +616,127 @@ window.__ModuleLoader__.load({
485
616
  children: [
486
617
  !liveBalance.balance && data.balanceError ? jsx.jsx("div", { className: "balance-stats-error", children: "余额:" + data.balanceError }) : null,
487
618
  jsx.jsxs("div", {
488
- className: "balance-stats-cards",
619
+ className: "balance-stats-top",
489
620
  children: [
490
- jsx.jsx(StatsCard, { label: "账户余额", value: balanceText }),
491
- jsx.jsx(StatsCard, { label: "90 天计费 Token", value: formatTokens(Number(total?.totalTokens || 0)) }),
492
- jsx.jsx(StatsCard, { label: "90 天估算费用", value: "¥" + Number(total?.cost || 0).toFixed(4) }),
493
- jsx.jsx(StatsCard, { label: "已扫描会话", value: String(stats?.scannedSessions || 0) }),
621
+ jsx.jsxs("div", {
622
+ className: "balance-stats-cards",
623
+ children: [
624
+ jsx.jsx(StatsCard, { label: "账户余额", value: balanceText }),
625
+ jsx.jsx(StatsCard, { label: "90 天估算费用", value: fmtCost(total && total.cost) }),
626
+ jsx.jsx(StatsCard, { label: "90 天计费 Token", value: formatTokens(Number(total?.totalTokens || 0)) }),
627
+ jsx.jsx(StatsCard, { label: "活跃天数", value: derived.activeCount + " / " + rangeDays + " 天" }),
628
+ ],
629
+ }),
630
+ jsx.jsxs("div", {
631
+ className: "balance-stats-heatmap-card",
632
+ children: [
633
+ jsx.jsxs("div", {
634
+ className: "balance-stats-section-title",
635
+ children: [
636
+ jsx.jsx("span", { children: "近 90 天使用热力图" }),
637
+ jsx.jsxs("span", {
638
+ className: "balance-stats-legend",
639
+ children: ["少", 0, 1, 2, 3, 4].map((item, index) => index === 0
640
+ ? item
641
+ : jsx.jsx("i", { className: "balance-heatmap-cell balance-heatmap-cell--" + item }, "legend-" + item)).concat(["多"]),
642
+ }),
643
+ ],
644
+ }),
645
+ jsx.jsx("div", { className: "balance-heatmap-wrap", children: jsx.jsx("div", { className: "balance-heatmap", children: heatmap }) }),
646
+ ],
647
+ }),
494
648
  ],
495
649
  }),
496
650
  jsx.jsxs("div", {
497
- className: "balance-stats-section-title",
651
+ className: "balance-stats-grid",
498
652
  children: [
499
- jsx.jsx("span", { children: "近 90 天使用热力图" }),
500
- jsx.jsxs("span", {
501
- className: "balance-stats-legend",
502
- children: ["少", 0, 1, 2, 3, 4].map((item, index) => index === 0
503
- ? item
504
- : jsx.jsx("i", { className: "balance-heatmap-cell balance-heatmap-cell--" + item }, "legend-" + item)).concat(["多"]),
653
+ jsx.jsxs(StatsGroup, {
654
+ index: 0,
655
+ title: "Token 构成",
656
+ hint: "缓存命中率 " + (derived.hitRate * 100).toFixed(1) + "%",
657
+ children: [
658
+ jsx.jsx(StatsRow, { name: "未缓存输入", value: formatTokens(derived.inputTokens)}),
659
+ jsx.jsx(StatsRow, { name: "缓存读取", value: formatTokens(derived.cacheReadTokens)}),
660
+ jsx.jsx(StatsRow, { name: "缓存写入", value: formatTokens(derived.cacheWriteTokens)}),
661
+ jsx.jsx(StatsRow, { name: "输出", value: formatTokens(derived.outputTokens)}),
662
+ ],
505
663
  }),
506
- ],
507
- }),
508
- jsx.jsx("div", { className: "balance-heatmap-wrap", children: jsx.jsx("div", { className: "balance-heatmap", children: heatmap }) }),
664
+ derived.byModel.length > 0
665
+ ? jsx.jsxs(StatsGroup, {
666
+ index: 1,
667
+ title: "按模型",
668
+ children: derived.byModel.map((item) =>
669
+ jsx.jsx(
670
+ StatsRow,
671
+ {
672
+ name: modelLabel(item.model),
673
+ value: fmtCost(item.cost) + " · " + formatTokens(Number(item.totalTokens || 0)),
674
+ },
675
+ "model-" + item.model,
676
+ )),
677
+ })
678
+ : null,
679
+ derived.peakTokens > 0 || derived.offpeakTokens > 0
680
+ ? jsx.jsxs(StatsGroup, {
681
+ index: 2,
682
+ title: "时段拆分",
683
+ hint: "高峰占 " + ((derived.peakCost / denomCost) * 100).toFixed(0) + "%",
684
+ children: [
685
+ jsx.jsx(StatsRow, {
686
+ name: "高峰(×2)",
687
+ value: fmtCost(derived.peakCost) + " · " + formatTokens(derived.peakTokens),
688
+ }),
689
+ jsx.jsx(StatsRow, {
690
+ name: "空闲",
691
+ value: fmtCost(derived.offpeakCost) + " · " + formatTokens(derived.offpeakTokens),
692
+ }),
693
+ ],
694
+ })
695
+ : null,
696
+ jsx.jsxs(StatsGroup, {
697
+ index: 3,
698
+ title: "近期消耗",
699
+ children: [
700
+ jsx.jsx(StatsRow, {
701
+ name: "今日",
702
+ value: formatTokens(derived.todayTokens) + " · " + fmtCost(derived.todayCost),
703
+ }),
704
+ jsx.jsx(StatsRow, {
705
+ name: "近 7 天",
706
+ value: formatTokens(derived.week.tokens) + " · " + fmtCost(derived.week.cost),
707
+ }),
708
+ jsx.jsx(StatsRow, {
709
+ name: "近 30 天",
710
+ value: formatTokens(derived.month.tokens) + " · " + fmtCost(derived.month.cost),
711
+ }),
712
+ ],
713
+ }),
714
+ jsx.jsxs(StatsGroup, {
715
+ index: 4,
716
+ title: "日均与峰值",
717
+ children: [
718
+ // 日均 = 总额 / 活跃天,没有「占比」语义,因此不画条(否则是条空轨道)
719
+ jsx.jsx(StatsRow, {
720
+ name: "日均(按活跃天)",
721
+ value: formatTokens(derived.avgTokens) + " · " + fmtCost(derived.avgCost),
722
+ }),
723
+ jsx.jsx(StatsRow, {
724
+ name: derived.peakDay ? "峰值日 " + derived.peakDay.date : "峰值日",
725
+ value: derived.peakDay
726
+ ? formatTokens(Number(derived.peakDay.totalTokens || 0)) + " · " + fmtCost(derived.peakDay.cost)
727
+ : "—",
728
+ }),
729
+ ],
730
+ }),
731
+ ] }),
509
732
  jsx.jsxs("div", {
510
733
  className: "balance-stats-footer",
511
734
  children: [
512
735
  jsx.jsx("span", {
513
- children: stats?.limited
514
- ? "为保证性能,仅统计最近 200 个会话;结果缓存 5 分钟。"
515
- : "按北京时间和实际模型峰谷价格估算;结果缓存 5 分钟。",
736
+ children:
737
+ "已扫描 " + String(stats?.scannedSessions || 0) + " 个会话" +
738
+ (stats?.limited ? "(为保证性能仅取最近 200 个)" : "") +
739
+ ";按北京时间和实际模型峰谷价格估算,结果缓存 5 分钟。",
516
740
  }),
517
741
  jsx.jsx("a", {
518
742
  className: "balance-stats-link",
@@ -527,7 +751,6 @@ window.__ModuleLoader__.load({
527
751
  });
528
752
  return jsx.jsxs("section", {
529
753
  ref: panelRef,
530
- style,
531
754
  className: "balance-stats-panel" + (closing ? " balance-stats-panel--closing" : ""),
532
755
  role: "dialog",
533
756
  "aria-label": "余额与使用统计",
@@ -544,7 +767,7 @@ window.__ModuleLoader__.load({
544
767
  jsx.jsx("button", { className: "balance-stats-close", type: "button", onClick: onClose, "aria-label": "关闭", children: "×" }),
545
768
  ],
546
769
  }),
547
- body,
770
+ jsx.jsx("div", { className: "balance-stats-body", children: body }),
548
771
  heatmapTooltip ? reactDom.createPortal(jsx.jsx("div", {
549
772
  className: "balance-heatmap-tooltip balance-heatmap-tooltip--" + heatmapTooltip.align,
550
773
  style: heatmapTooltip.style,
@@ -580,10 +803,10 @@ window.__ModuleLoader__.load({
580
803
  const state = useBalance();
581
804
  const [statsOpen, setStatsOpen] = react.useState(false);
582
805
  const [statsClosing, setStatsClosing] = react.useState(false);
583
- const [statsPosition, setStatsPosition] = react.useState({ left: 12, bottom: 56, width: 460 });
584
806
  const clickTimer = react.useRef(null);
585
807
  const closeTimer = react.useRef(null);
586
808
  const anchorRef = react.useRef(null);
809
+ const layerRef = react.useRef(null);
587
810
  const panelRef = react.useRef(null);
588
811
  react.useEffect(() => () => {
589
812
  if (clickTimer.current !== null) clearTimeout(clickTimer.current);
@@ -599,38 +822,17 @@ window.__ModuleLoader__.load({
599
822
  setStatsClosing(false);
600
823
  }, 140);
601
824
  }, [statsOpen, statsClosing]);
602
- react.useEffect(() => {
603
- if (!statsOpen) return undefined;
604
- const closeOnOutsideClick = (event) => {
605
- if (anchorRef.current && anchorRef.current.contains(event.target)) return;
606
- if (panelRef.current && panelRef.current.contains(event.target)) return;
607
- closeStats();
608
- };
609
- const closeOnResize = () => closeStats();
610
- document.addEventListener("mousedown", closeOnOutsideClick);
611
- window.addEventListener("resize", closeOnResize);
612
- return () => {
613
- document.removeEventListener("mousedown", closeOnOutsideClick);
614
- window.removeEventListener("resize", closeOnResize);
615
- };
616
- }, [statsOpen, closeStats]);
825
+ // 点击遮罩(即 layer 自身)关闭;点击面板内部不关闭。
826
+ // 窗口缩放不再关闭面板(居中布局会自适应,旧实现是锚定按钮需要重算位置)。
827
+ const handleBackdropClick = (event) => {
828
+ if (event.target === layerRef.current) closeStats();
829
+ };
617
830
  const openStats = (event) => {
618
831
  if (event && typeof event.preventDefault === "function") event.preventDefault();
619
832
  if (clickTimer.current !== null) clearTimeout(clickTimer.current);
620
833
  if (closeTimer.current !== null) clearTimeout(closeTimer.current);
621
834
  closeTimer.current = null;
622
835
  setStatsClosing(false);
623
- const trigger = event && event.currentTarget;
624
- if (trigger && typeof trigger.getBoundingClientRect === "function") {
625
- const rect = trigger.getBoundingClientRect();
626
- const width = Math.max(1, Math.min(460, window.innerWidth - 24));
627
- const desiredLeft = wide ? rect.right + 8 : rect.left;
628
- const left = Math.max(12, Math.min(desiredLeft, window.innerWidth - width - 12));
629
- const bottom = wide
630
- ? Math.max(12, window.innerHeight - rect.bottom)
631
- : Math.max(12, window.innerHeight - rect.top + 8);
632
- setStatsPosition({ left, bottom, width });
633
- }
634
836
  clickTimer.current = setTimeout(() => {
635
837
  clickTimer.current = null;
636
838
  setStatsClosing(false);
@@ -647,22 +849,34 @@ window.__ModuleLoader__.load({
647
849
  setStatsOpen(false);
648
850
  window.open("https://platform.deepseek.com/usage", "_blank", "noopener");
649
851
  };
852
+ // 余额元素的交互。悬停提示不用原生 title,统一走主题化浮层(见 withPanel)。
650
853
  const interactions = {
651
854
  onClick: openStats,
652
855
  onDoubleClick: openUsagePage,
653
856
  onContextMenu: onBalanceContextMenu,
654
- title: "单击查看余额与使用统计,双击打开 DeepSeek API 用量页,右键刷新",
655
857
  };
656
858
  const withPanel = (node) => jsx.jsxs("div", {
657
859
  ref: anchorRef,
658
860
  className: "balance-stats-anchor balance-stats-anchor--" + (wide ? "wide" : "compact"),
659
861
  children: [
660
- node,
661
- statsOpen ? reactDom.createPortal(jsx.jsx(UsageStatsPanel, {
662
- onClose: closeStats,
663
- panelRef,
664
- style: statsPosition,
665
- closing: statsClosing,
862
+ // 悬停提示走 DSH 主题化浮层:primitives.Tooltip 内部是 cloneElement(children, {ref}),
863
+ // 不会插入额外 DOM 层,因此不破坏余额行在 flex 容器里的 42px 布局(浮层本身 portal 到 body)。
864
+ jsx.jsx(primitives.Tooltip, {
865
+ label: "单击查看余额与使用统计,双击打开 DeepSeek API 用量页,右键刷新",
866
+ side: "top",
867
+ delayMs: 400,
868
+ children: node,
869
+ }),
870
+ // 统计面板:居中弹层 + 半透明遮罩(点击遮罩 / Esc / 关闭按钮均可关闭)
871
+ statsOpen ? reactDom.createPortal(jsx.jsx("div", {
872
+ ref: layerRef,
873
+ className: "balance-stats-layer" + (statsClosing ? " balance-stats-layer--closing" : ""),
874
+ onMouseDown: handleBackdropClick,
875
+ children: jsx.jsx(UsageStatsPanel, {
876
+ onClose: closeStats,
877
+ panelRef,
878
+ closing: statsClosing,
879
+ }),
666
880
  }), document.body) : null,
667
881
  ],
668
882
  });
package/lib/index.js CHANGED
@@ -406,21 +406,54 @@ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessio
406
406
  const key = beijingDayKey(time);
407
407
  let day = days.get(key);
408
408
  if (day == null) {
409
- day = { date: key, ...emptyTokenBucket(), totalTokens: 0, cost: 0, sessionIds: new Set() };
409
+ day = {
410
+ date: key,
411
+ ...emptyTokenBucket(),
412
+ totalTokens: 0,
413
+ cost: 0,
414
+ sessionIds: new Set(),
415
+ // —— 以下为细分维度,供统计面板展示;不改动上方总额口径 ——
416
+ byModel: new Map(), // modelKey -> { model, tokens..., totalTokens, cost }
417
+ peak: emptyTokenBucket(),
418
+ offpeak: emptyTokenBucket(),
419
+ peakCost: 0,
420
+ offpeakCost: 0,
421
+ };
410
422
  days.set(key, day);
411
423
  }
424
+ const model = modelAtTime(switches, time) || fallback;
425
+ const isPeak = isPeakAt(time, config);
426
+ // 分时段精确计费:高峰桶按高峰价(= 目录价 ×2)、空闲桶按目录价
427
+ const bucket = { peak: emptyTokenBucket(), offpeak: emptyTokenBucket() };
428
+ addTokenUsage(isPeak ? bucket.peak : bucket.offpeak, usage);
429
+ const stepCost = estimateCost(bucket, resolvePricing(config, model, pricingEraAt(time)));
430
+
412
431
  addTokenUsage(day, usage);
413
- day.totalTokens += tokenTotal({
432
+ const tokens = tokenTotal({
414
433
  inputTokens: usage.inputTokens ?? 0,
415
434
  outputTokens: usage.outputTokens ?? 0,
416
435
  cacheReadTokens: usage.cacheReadTokens ?? 0,
417
436
  cacheWriteTokens: usage.cacheWriteTokens ?? 0,
418
437
  });
438
+ day.totalTokens += tokens;
439
+ day.cost += stepCost;
419
440
  day.sessionIds.add(sessionId);
420
- const model = modelAtTime(switches, time) || fallback;
421
- const bucket = { peak: emptyTokenBucket(), offpeak: emptyTokenBucket() };
422
- addTokenUsage(isPeakAt(time, config) ? bucket.peak : bucket.offpeak, usage);
423
- day.cost += estimateCost(bucket, resolvePricing(config, model, pricingEraAt(time)));
441
+
442
+ // 高峰 / 空闲拆分(token 与费用)
443
+ addTokenUsage(isPeak ? day.peak : day.offpeak, usage);
444
+ if (isPeak) day.peakCost += stepCost;
445
+ else day.offpeakCost += stepCost;
446
+
447
+ // 按模型拆分:复用同一步的 stepCost,保证各模型费用之和 = day.cost
448
+ const mk = modelKey(model) || "deepseek-v4-flash";
449
+ let entry = day.byModel.get(mk);
450
+ if (entry == null) {
451
+ entry = { model: mk, ...emptyTokenBucket(), totalTokens: 0, cost: 0 };
452
+ day.byModel.set(mk, entry);
453
+ }
454
+ addTokenUsage(entry, usage);
455
+ entry.totalTokens += tokens;
456
+ entry.cost += stepCost;
424
457
  }
425
458
  }
426
459
 
@@ -494,7 +527,18 @@ async function buildUsageStats(ctx, config) {
494
527
  const rows = recentDayKeys(STATS_DAYS, now).map((date) => {
495
528
  const day = daily.get(date);
496
529
  if (day == null) {
497
- return { date, ...emptyTokenBucket(), totalTokens: 0, cost: 0, sessions: 0 };
530
+ return {
531
+ date,
532
+ ...emptyTokenBucket(),
533
+ totalTokens: 0,
534
+ cost: 0,
535
+ sessions: 0,
536
+ byModel: [],
537
+ peak: emptyTokenBucket(),
538
+ offpeak: emptyTokenBucket(),
539
+ peakCost: 0,
540
+ offpeakCost: 0,
541
+ };
498
542
  }
499
543
  return {
500
544
  date,
@@ -505,6 +549,11 @@ async function buildUsageStats(ctx, config) {
505
549
  totalTokens: day.totalTokens,
506
550
  cost: day.cost,
507
551
  sessions: day.sessionIds.size,
552
+ byModel: [...day.byModel.values()],
553
+ peak: day.peak,
554
+ offpeak: day.offpeak,
555
+ peakCost: day.peakCost,
556
+ offpeakCost: day.offpeakCost,
508
557
  };
509
558
  });
510
559
  const total = rows.reduce((acc, day) => {
@@ -514,11 +563,36 @@ async function buildUsageStats(ctx, config) {
514
563
  acc.cacheWriteTokens += day.cacheWriteTokens;
515
564
  acc.totalTokens += day.totalTokens;
516
565
  acc.cost += day.cost;
566
+ // —— 细分维度:与上方总额同口径,只做拆分,不改变金额 ——
567
+ addTokenUsage(acc.peak, day.peak);
568
+ addTokenUsage(acc.offpeak, day.offpeak);
569
+ acc.peakCost += day.peakCost;
570
+ acc.offpeakCost += day.offpeakCost;
571
+ for (const item of day.byModel ?? []) {
572
+ let entry = acc.byModel.get(item.model);
573
+ if (entry == null) {
574
+ entry = { model: item.model, ...emptyTokenBucket(), totalTokens: 0, cost: 0 };
575
+ acc.byModel.set(item.model, entry);
576
+ }
577
+ addTokenUsage(entry, item);
578
+ entry.totalTokens += item.totalTokens;
579
+ entry.cost += item.cost;
580
+ }
517
581
  return acc;
518
- }, { ...emptyTokenBucket(), totalTokens: 0, cost: 0 });
582
+ }, {
583
+ ...emptyTokenBucket(),
584
+ totalTokens: 0,
585
+ cost: 0,
586
+ byModel: new Map(),
587
+ peak: emptyTokenBucket(),
588
+ offpeak: emptyTokenBucket(),
589
+ peakCost: 0,
590
+ offpeakCost: 0,
591
+ });
519
592
  return {
520
593
  days: rows,
521
- total,
594
+ // byModel 内部用 Map 聚合,返回前转成可直接 JSON 序列化的数组
595
+ total: { ...total, byModel: [...total.byModel.values()] },
522
596
  rangeDays: STATS_DAYS,
523
597
  scannedSessions,
524
598
  failedSessions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "查询 DeepSeek 余额并显示当前会话 token 消耗。DeepSeek 账户余额 + 会话 token/费用显示,含高峰/空闲峰谷计费。",
5
5
  "author": "Rannist",
6
6
  "type": "module",