balance-dsh 1.0.10 → 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 +292 -74
  2. package/lib/index.js +166 -28
  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,不影响)
@@ -111,12 +136,13 @@ window.__ModuleLoader__.load({
111
136
  }
112
137
 
113
138
  // ─── 价格(与服务端默认保持一致;服务端 /balance-api 返回后会覆盖) ───
114
- // 2026-08-17 生效的 DeepSeek V4-Flash 空闲时段价(元/百万 tokens)
139
+ // 北京时间 2026-09-10 12:00 起生效的 DeepSeek V4-Flash 空闲时段价(元/百万 tokens)
140
+ // 注:历史消耗由服务端按当时价格回传 cost;这里的默认值只用于拉不到服务端分桶时的本地估算。
115
141
  const DEFAULT_PRICING = {
116
- inputCnyPerMillion: 1.5, // 输入(缓存未命中)
117
- cacheReadCnyPerMillion: 0.05, // 输入(缓存命中)
142
+ inputCnyPerMillion: 1, // 输入(缓存未命中)
143
+ cacheReadCnyPerMillion: 0.02, // 输入(缓存命中)
118
144
  cacheWriteCnyPerMillion: 0, // DeepSeek 无缓存写入计费
119
- outputCnyPerMillion: 4.5, // 输出
145
+ outputCnyPerMillion: 4, // 输出
120
146
  };
121
147
 
122
148
  let pricing = DEFAULT_PRICING;
@@ -388,17 +414,71 @@ window.__ModuleLoader__.load({
388
414
  return usageStatsInFlight;
389
415
  }
390
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 主题不一致。 */
391
438
  const StatsCard = react.memo(function StatsCard({ label, value }) {
392
439
  return jsx.jsxs("div", {
393
440
  className: "balance-stats-card",
394
441
  children: [
395
442
  jsx.jsx("span", { className: "balance-stats-card__label", children: label }),
396
- 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 }),
397
458
  ],
398
459
  });
399
460
  });
400
461
 
401
- 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 }) {
402
482
  const liveBalance = useBalance();
403
483
  const [heatmapTooltip, setHeatmapTooltip] = react.useState(null);
404
484
  const [state, setState] = react.useState(() => {
@@ -476,6 +556,58 @@ window.__ModuleLoader__.load({
476
556
  const balanceText = balance
477
557
  ? String(balance.total_balance ?? "0.00") + " " + String(balance.currency || "CNY")
478
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
+
479
611
  const body = state.loading
480
612
  ? jsx.jsx("div", { className: "balance-stats-loading", children: "正在汇总会话记录…" })
481
613
  : state.error
@@ -484,34 +616,127 @@ window.__ModuleLoader__.load({
484
616
  children: [
485
617
  !liveBalance.balance && data.balanceError ? jsx.jsx("div", { className: "balance-stats-error", children: "余额:" + data.balanceError }) : null,
486
618
  jsx.jsxs("div", {
487
- className: "balance-stats-cards",
619
+ className: "balance-stats-top",
488
620
  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) }),
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
+ }),
493
648
  ],
494
649
  }),
495
650
  jsx.jsxs("div", {
496
- className: "balance-stats-section-title",
651
+ className: "balance-stats-grid",
497
652
  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(["多"]),
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
+ ],
504
663
  }),
505
- ],
506
- }),
507
- 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
+ ] }),
508
732
  jsx.jsxs("div", {
509
733
  className: "balance-stats-footer",
510
734
  children: [
511
735
  jsx.jsx("span", {
512
- children: stats?.limited
513
- ? "为保证性能,仅统计最近 200 个会话;结果缓存 5 分钟。"
514
- : "按北京时间和实际模型峰谷价格估算;结果缓存 5 分钟。",
736
+ children:
737
+ "已扫描 " + String(stats?.scannedSessions || 0) + " 个会话" +
738
+ (stats?.limited ? "(为保证性能仅取最近 200 个)" : "") +
739
+ ";按北京时间和实际模型峰谷价格估算,结果缓存 5 分钟。",
515
740
  }),
516
741
  jsx.jsx("a", {
517
742
  className: "balance-stats-link",
@@ -526,7 +751,6 @@ window.__ModuleLoader__.load({
526
751
  });
527
752
  return jsx.jsxs("section", {
528
753
  ref: panelRef,
529
- style,
530
754
  className: "balance-stats-panel" + (closing ? " balance-stats-panel--closing" : ""),
531
755
  role: "dialog",
532
756
  "aria-label": "余额与使用统计",
@@ -543,7 +767,7 @@ window.__ModuleLoader__.load({
543
767
  jsx.jsx("button", { className: "balance-stats-close", type: "button", onClick: onClose, "aria-label": "关闭", children: "×" }),
544
768
  ],
545
769
  }),
546
- body,
770
+ jsx.jsx("div", { className: "balance-stats-body", children: body }),
547
771
  heatmapTooltip ? reactDom.createPortal(jsx.jsx("div", {
548
772
  className: "balance-heatmap-tooltip balance-heatmap-tooltip--" + heatmapTooltip.align,
549
773
  style: heatmapTooltip.style,
@@ -579,10 +803,10 @@ window.__ModuleLoader__.load({
579
803
  const state = useBalance();
580
804
  const [statsOpen, setStatsOpen] = react.useState(false);
581
805
  const [statsClosing, setStatsClosing] = react.useState(false);
582
- const [statsPosition, setStatsPosition] = react.useState({ left: 12, bottom: 56, width: 460 });
583
806
  const clickTimer = react.useRef(null);
584
807
  const closeTimer = react.useRef(null);
585
808
  const anchorRef = react.useRef(null);
809
+ const layerRef = react.useRef(null);
586
810
  const panelRef = react.useRef(null);
587
811
  react.useEffect(() => () => {
588
812
  if (clickTimer.current !== null) clearTimeout(clickTimer.current);
@@ -598,38 +822,17 @@ window.__ModuleLoader__.load({
598
822
  setStatsClosing(false);
599
823
  }, 140);
600
824
  }, [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]);
825
+ // 点击遮罩(即 layer 自身)关闭;点击面板内部不关闭。
826
+ // 窗口缩放不再关闭面板(居中布局会自适应,旧实现是锚定按钮需要重算位置)。
827
+ const handleBackdropClick = (event) => {
828
+ if (event.target === layerRef.current) closeStats();
829
+ };
616
830
  const openStats = (event) => {
617
831
  if (event && typeof event.preventDefault === "function") event.preventDefault();
618
832
  if (clickTimer.current !== null) clearTimeout(clickTimer.current);
619
833
  if (closeTimer.current !== null) clearTimeout(closeTimer.current);
620
834
  closeTimer.current = null;
621
835
  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
836
  clickTimer.current = setTimeout(() => {
634
837
  clickTimer.current = null;
635
838
  setStatsClosing(false);
@@ -646,22 +849,34 @@ window.__ModuleLoader__.load({
646
849
  setStatsOpen(false);
647
850
  window.open("https://platform.deepseek.com/usage", "_blank", "noopener");
648
851
  };
852
+ // 余额元素的交互。悬停提示不用原生 title,统一走主题化浮层(见 withPanel)。
649
853
  const interactions = {
650
854
  onClick: openStats,
651
855
  onDoubleClick: openUsagePage,
652
856
  onContextMenu: onBalanceContextMenu,
653
- title: "单击查看余额与使用统计,双击打开 DeepSeek API 用量页,右键刷新",
654
857
  };
655
858
  const withPanel = (node) => jsx.jsxs("div", {
656
859
  ref: anchorRef,
657
860
  className: "balance-stats-anchor balance-stats-anchor--" + (wide ? "wide" : "compact"),
658
861
  children: [
659
- node,
660
- statsOpen ? reactDom.createPortal(jsx.jsx(UsageStatsPanel, {
661
- onClose: closeStats,
662
- panelRef,
663
- style: statsPosition,
664
- 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
+ }),
665
880
  }), document.body) : null,
666
881
  ],
667
882
  });
@@ -808,9 +1023,12 @@ function UsageCostLine({ useProjection, sessionId }) {
808
1023
 
809
1024
  // 金额:优先用服务端分时段 usage(高峰桶×2+空闲桶);未就绪时按当前时段价估算
810
1025
  const split = splitReady && splitUsage ? splitUsage : null;
811
- const cost = split ? computeSplitCost(split, p) : computeCost(usage, periodAdjustedPricing(p));
812
- const peakTokens = split ? split.peak.inputTokens + split.peak.cacheReadTokens + split.peak.cacheWriteTokens + split.peak.outputTokens : 0;
813
- const offpeakTokens = split ? split.offpeak.inputTokens + split.offpeak.cacheReadTokens + split.offpeak.cacheWriteTokens + split.offpeak.outputTokens : 0;
1026
+ // 服务端分桶可能"非 null 但为空"(拿不到该会话事件),此时必须回退本地投影估算,否则金额会显示 ¥0.0000
1027
+ const splitUsable =
1028
+ split != null && ((Array.isArray(split.byModel) && split.byModel.length > 0) || (split.totalTokens ?? 0) > 0);
1029
+ const cost = splitUsable ? computeSplitCost(split, p) : computeCost(usage, periodAdjustedPricing(p));
1030
+ const peakTokens = splitUsable ? split.peak.inputTokens + split.peak.cacheReadTokens + split.peak.cacheWriteTokens + split.peak.outputTokens : 0;
1031
+ const offpeakTokens = splitUsable ? split.offpeak.inputTokens + split.offpeak.cacheReadTokens + split.offpeak.cacheWriteTokens + split.offpeak.outputTokens : 0;
814
1032
 
815
1033
  // 子代理消耗(与主会话分开,同样跨时段精确计费)
816
1034
  const hasSubagent = splitReady && subagentUsage && subagentUsage.totalTokens > 0;
@@ -825,7 +1043,7 @@ function UsageCostLine({ useProjection, sessionId }) {
825
1043
  "新增:" + formatTokens(newTokens) + " tokens(未缓存输入 " + formatTokens(uncachedInput) + " + 输出 " + formatTokens(output) + ")",
826
1044
  "计费:" + formatTokens(billedTotal) + " tokens(含缓存重读 " + formatTokens(cacheRead) + "、缓存写入 " + formatTokens(cacheWrite) + ")",
827
1045
  ];
828
- if (split) {
1046
+ if (splitUsable) {
829
1047
  detailLines.push(
830
1048
  "时段拆分:高峰 " + formatTokens(peakTokens) + " tokens + 空闲 " + formatTokens(offpeakTokens) + " tokens",
831
1049
  "估算金额:¥" + cost.toFixed(4) + "(高峰桶按目录价×2、空闲桶按目录价)",
package/lib/index.js CHANGED
@@ -55,10 +55,10 @@ const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
55
55
  // DeepSeek 目录价(人民币/百万 tokens),按模型区分。默认取 2026-08-17 生效的
56
56
  // V4 峰谷定价之空闲时段价;高峰时段(每日 9:00 / 14:00)价格为下表 2 倍,
57
57
  // 可在 config.peak: true 时统一翻倍,或在 config.price 中逐项覆盖。
58
- // V4-Flash 空闲:输入未命中 1.5 / 命中 0.05 / 输出 4.5
59
- // V4-Pro 空闲:输入未命中 4.5 / 命中 0.15 / 输出 13.5
58
+ // V4-Flash 空闲:输入未命中 1 / 命中 0.02 / 输出 4(北京时间 2026-09-10 12:00 起的新价)
59
+ // V4-Pro 空闲:输入未命中 4.5 / 命中 0.15 / 输出 13.5(官方 2026-09-14 后继续提供,计费不变)
60
60
  const MODEL_PRICING = {
61
- "deepseek-v4-flash": { inputCnyPerMillion: 1.5, cacheReadCnyPerMillion: 0.05, cacheWriteCnyPerMillion: 0, outputCnyPerMillion: 4.5 },
61
+ "deepseek-v4-flash": { inputCnyPerMillion: 1, cacheReadCnyPerMillion: 0.02, cacheWriteCnyPerMillion: 0, outputCnyPerMillion: 4 },
62
62
  "deepseek-v4-pro": { inputCnyPerMillion: 4.5, cacheReadCnyPerMillion: 0.15, cacheWriteCnyPerMillion: 0, outputCnyPerMillion: 13.5 },
63
63
  // 旧模型兜底(V3.2 时代目录价)
64
64
  "deepseek-chat": { inputCnyPerMillion: 2, cacheReadCnyPerMillion: 0.5, cacheWriteCnyPerMillion: 0, outputCnyPerMillion: 3 },
@@ -66,6 +66,18 @@ const MODEL_PRICING = {
66
66
  };
67
67
  const FALLBACK_PRICING = MODEL_PRICING["deepseek-v4-flash"];
68
68
 
69
+ // 定价历史:官方自北京时间 2026-09-10 12:00 起下调 flash 系列价格。
70
+ // 调价前产生的 token 仍按当时旧价计算(历史消耗金额不因后续调价而改变),
71
+ // 之后的消耗用 MODEL_PRICING(新价)。按每条 usage 的事件时间戳自动选择。
72
+ const FLASH_REPRICED_AT = Date.parse("2026-09-10T12:00:00+08:00");
73
+ const LEGACY_MODEL_PRICING = {
74
+ "deepseek-v4-flash": { inputCnyPerMillion: 1.5, cacheReadCnyPerMillion: 0.05, cacheWriteCnyPerMillion: 0, outputCnyPerMillion: 4.5 },
75
+ };
76
+ /** 事件时间在调价前返回 "legacy"(旧价),之后返回 "current"(新价)。 */
77
+ function pricingEraAt(time) {
78
+ return Number(time) < FLASH_REPRICED_AT ? "legacy" : "current";
79
+ }
80
+
69
81
  // 高峰/空闲时段(V4 峰谷定价,2026-08-17 生效):高峰价 = 空闲价 × 2。
70
82
  // 官方高峰窗口(北京时间)为 周一至周五 09:00-12:00、14:00-18:00;周末全天空闲。
71
83
  // 可用 config.peakWindows 覆盖小时窗口(如 [{start:9,end:12},{start:14,end:18}]),
@@ -91,19 +103,23 @@ function isPeakAt(timeMs, config) {
91
103
  return weekdays.includes(weekday) && windows.some((w) => hour >= w.start && hour < w.end);
92
104
  }
93
105
 
94
- /** 把模型字符串规整到定价表键;未识别返回 ""(用 Flash 价兜底)。 */
106
+ /** 把模型字符串规整到定价表键;未识别返回 ""(用 Flash 价兜底)。
107
+ * 注意:官方已把 Flash 主名改为 `deepseek-flash`,旧名 `deepseek-v4-flash` /
108
+ * `deepseek-v4-flash-vision-exp` 虽已退役但仍按 Flash 价计费,因此统一按 "flash" 匹配。 */
95
109
  function modelKey(model) {
96
110
  const m = String(model || "").toLowerCase();
97
- if (m.includes("v4-pro") || m.includes("v4pro")) return "deepseek-v4-pro";
98
- if (m.includes("v4-flash") || m.includes("v4flash")) return "deepseek-v4-flash";
111
+ if (m.includes("v4-pro") || m.includes("v4pro") || m.includes("-pro")) return "deepseek-v4-pro";
112
+ if (m.includes("flash")) return "deepseek-v4-flash";
99
113
  if (m.includes("reasoner")) return "deepseek-reasoner";
100
114
  if (m.includes("chat")) return "deepseek-chat";
101
115
  return "";
102
116
  }
103
117
 
104
- /** 解析价格:模型基价 + config.price 覆盖,config.peak: true 时按高峰价翻倍。 */
105
- function resolvePricing(config, model = "") {
106
- const base = MODEL_PRICING[modelKey(model)] ?? FALLBACK_PRICING;
118
+ /** 解析价格:模型基价 + config.price 覆盖,config.peak: true 时按高峰价翻倍。
119
+ * era = "legacy" 时取调价前的历史价(目前仅 flash 系列与现价不同)。 */
120
+ function resolvePricing(config, model = "", era = "current") {
121
+ const key = modelKey(model);
122
+ const base = (era === "legacy" && LEGACY_MODEL_PRICING[key]) || MODEL_PRICING[key] || FALLBACK_PRICING;
107
123
  const price = config?.price ?? {};
108
124
  const multiplier = config?.peak === true ? 2 : 1;
109
125
  const pick = (name, fallback) => Number(price[name] ?? fallback) * multiplier;
@@ -115,9 +131,25 @@ function resolvePricing(config, model = "") {
115
131
  };
116
132
  }
117
133
 
134
+ /** 取会话事件数组。DSH 2.0.4 起 live session **不再有 `.events` 属性**,改用 `snapshotEvents()` 方法
135
+ * (签名 `snapshotEvents(fromSeq = 0, toSeqExclusive = seq)`,可无参调用取全部事件);
136
+ * 旧版仍读 `.events` 属性。两者都拿不到时返回空数组(由调用方降级)。 */
137
+ function sessionEvents(session) {
138
+ if (!session) return [];
139
+ if (typeof session.snapshotEvents === "function") {
140
+ try {
141
+ const events = session.snapshotEvents();
142
+ if (Array.isArray(events)) return events;
143
+ } catch {
144
+ // 落到旧属性兜底
145
+ }
146
+ }
147
+ return Array.isArray(session.events) ? session.events : [];
148
+ }
149
+
118
150
  /** 从会话中识别实际使用的模型。
119
151
  * 优先用 DSH 会话的 requestHeader()/requestContext() 折叠结果(即官方 session.models 取 current 的同一来源),
120
- * 取不到再回退遍历 session.events 找 request/header / request/context 事件。 */
152
+ * 取不到再回退遍历会话事件找 request/header / request/context 事件。 */
121
153
  function detectModel(session) {
122
154
  if (!session) return "";
123
155
  // 1) DSH 官方折叠 API:requestHeader().config.model 或 requestContext().model
@@ -133,7 +165,7 @@ function detectModel(session) {
133
165
  }
134
166
  // 2) 兜底:遍历事件
135
167
  let model = "";
136
- for (const event of session?.events ?? []) {
168
+ for (const event of sessionEvents(session)) {
137
169
  if (event.type === "request/header") {
138
170
  const m = event.data?.header?.config?.model;
139
171
  if (typeof m === "string" && m !== "") model = m;
@@ -272,11 +304,14 @@ function sumEventsUsage(events, config, fallbackModel = "") {
272
304
  bucket.cacheWriteTokens += usage.cacheWriteTokens ?? 0;
273
305
  };
274
306
  const empty = () => ({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
275
- const byModel = new Map(); // model -> { model, peak, offpeak }
307
+ const byModel = new Map(); // `${model}\0${era}` -> { model, era, peak, offpeak }
276
308
  for (const { usage, time } of latestByStep.values()) {
277
309
  const model = modelAtTime(switches, time) || fallback;
278
- let e = byModel.get(model);
279
- if (!e) { e = { model, peak: empty(), offpeak: empty() }; byModel.set(model, e); }
310
+ // 同一模型在调价前后的 token 分开分桶,各自用当时的价格计算(历史金额不变)。
311
+ const era = pricingEraAt(time);
312
+ const key = `${model}\u0000${era}`;
313
+ let e = byModel.get(key);
314
+ if (!e) { e = { model, era, peak: empty(), offpeak: empty() }; byModel.set(key, e); }
280
315
  add(isPeakAt(time, config) ? e.peak : e.offpeak, usage);
281
316
  }
282
317
  // 聚合 + 转换 byModel(按金额未在前面算,这里只分桶 + 汇总字段)
@@ -284,7 +319,7 @@ function sumEventsUsage(events, config, fallbackModel = "") {
284
319
  const offpeak = empty();
285
320
  const modelList = [];
286
321
  for (const e of byModel.values()) {
287
- modelList.push({ model: e.model, peak: e.peak, offpeak: e.offpeak });
322
+ modelList.push({ model: e.model, era: e.era, peak: e.peak, offpeak: e.offpeak, cost: estimateCost(e, resolvePricing(config, e.model, e.era)) });
288
323
  peak.inputTokens += e.peak.inputTokens;
289
324
  peak.outputTokens += e.peak.outputTokens;
290
325
  peak.cacheReadTokens += e.peak.cacheReadTokens;
@@ -315,7 +350,7 @@ function sumEventsUsage(events, config, fallbackModel = "") {
315
350
  function sumSessionUsage(session, config) {
316
351
  if (session == null) return null;
317
352
  // fallbackModel = detectModel(session):当 message 早于所有模型切换点时兜底,避免落到默认 flash 价。
318
- return sumEventsUsage(session.events ?? [], config, detectModel(session));
353
+ return sumEventsUsage(sessionEvents(session), config, detectModel(session));
319
354
  }
320
355
 
321
356
  function estimateCost(usage, pricing) {
@@ -371,21 +406,54 @@ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessio
371
406
  const key = beijingDayKey(time);
372
407
  let day = days.get(key);
373
408
  if (day == null) {
374
- 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
+ };
375
422
  days.set(key, day);
376
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
+
377
431
  addTokenUsage(day, usage);
378
- day.totalTokens += tokenTotal({
432
+ const tokens = tokenTotal({
379
433
  inputTokens: usage.inputTokens ?? 0,
380
434
  outputTokens: usage.outputTokens ?? 0,
381
435
  cacheReadTokens: usage.cacheReadTokens ?? 0,
382
436
  cacheWriteTokens: usage.cacheWriteTokens ?? 0,
383
437
  });
438
+ day.totalTokens += tokens;
439
+ day.cost += stepCost;
384
440
  day.sessionIds.add(sessionId);
385
- const model = modelAtTime(switches, time) || fallback;
386
- const bucket = { peak: emptyTokenBucket(), offpeak: emptyTokenBucket() };
387
- addTokenUsage(isPeakAt(time, config) ? bucket.peak : bucket.offpeak, usage);
388
- day.cost += estimateCost(bucket, resolvePricing(config, model));
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;
389
457
  }
390
458
  }
391
459
 
@@ -459,7 +527,18 @@ async function buildUsageStats(ctx, config) {
459
527
  const rows = recentDayKeys(STATS_DAYS, now).map((date) => {
460
528
  const day = daily.get(date);
461
529
  if (day == null) {
462
- 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
+ };
463
542
  }
464
543
  return {
465
544
  date,
@@ -470,6 +549,11 @@ async function buildUsageStats(ctx, config) {
470
549
  totalTokens: day.totalTokens,
471
550
  cost: day.cost,
472
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,
473
557
  };
474
558
  });
475
559
  const total = rows.reduce((acc, day) => {
@@ -479,11 +563,36 @@ async function buildUsageStats(ctx, config) {
479
563
  acc.cacheWriteTokens += day.cacheWriteTokens;
480
564
  acc.totalTokens += day.totalTokens;
481
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
+ }
482
581
  return acc;
483
- }, { ...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
+ });
484
592
  return {
485
593
  days: rows,
486
- total,
594
+ // byModel 内部用 Map 聚合,返回前转成可直接 JSON 序列化的数组
595
+ total: { ...total, byModel: [...total.byModel.values()] },
487
596
  rangeDays: STATS_DAYS,
488
597
  scannedSessions,
489
598
  failedSessions,
@@ -583,7 +692,15 @@ async function sumSubagentUsage(ctx, sessionId, config) {
583
692
  }
584
693
 
585
694
  function formatUsage(usage, pricing, model = "") {
586
- const cost = estimateCost(usage, pricing);
695
+ // 优先按 byModel 逐段累加(每段用各自模型 + 价格时代的价格,历史消耗不因调价而变);
696
+ // 无 byModel 时回退单一价格估算(兼容旧数据)。
697
+ const cost =
698
+ Array.isArray(usage.byModel) && usage.byModel.length > 0
699
+ ? usage.byModel.reduce(
700
+ (sum, b) => sum + (typeof b.cost === "number" && Number.isFinite(b.cost) ? b.cost : estimateCost(b, pricing)),
701
+ 0,
702
+ )
703
+ : estimateCost(usage, pricing);
587
704
  const newTokens = usage.inputTokens + usage.outputTokens;
588
705
  const peakTokens = usage.peak.inputTokens + usage.peak.cacheReadTokens + usage.peak.cacheWriteTokens + usage.peak.outputTokens;
589
706
  const offpeakTokens = usage.offpeak.inputTokens + usage.offpeak.cacheReadTokens + usage.offpeak.cacheWriteTokens + usage.offpeak.outputTokens;
@@ -729,6 +846,27 @@ export function apply(ctx, config = {}) {
729
846
  usage = sumSessionUsage(session, config);
730
847
  detectedModel = detectModel(session);
731
848
  }
849
+ // DSH 2.0.4 起 live session(sessions.get)可能拿不到 events,导致 usage 全 0。
850
+ // 此时回退从持久化读事件,避免客户端拿到"空但非 null"的 usage 而把金额显示成 ¥0.0000。
851
+ if (usage == null || usage.totalTokens === 0) {
852
+ try {
853
+ const persisted = await inspectPersistedSession(ctx, sessionParam);
854
+ if (Array.isArray(persisted?.events) && persisted.events.length > 0) {
855
+ const persistedUsage = sumEventsUsage(persisted.events, config, detectedModel);
856
+ if (persistedUsage.totalTokens > 0) {
857
+ usage = persistedUsage;
858
+ if (detectedModel === "") {
859
+ const switches = collectModelSwitches(persisted.events);
860
+ if (switches.length > 0) detectedModel = switches[switches.length - 1].model;
861
+ }
862
+ }
863
+ }
864
+ } catch {
865
+ // 持久化读取不可用:忽略,走下方统一降级
866
+ }
867
+ }
868
+ // 仍无任何 token 就返回 null,让客户端回退本地投影估算(而不是当成 0 消耗显示 ¥0.0000)
869
+ if (usage != null && usage.totalTokens === 0) usage = null;
732
870
  // 子代理枚举走持久化目录(不依赖 live 会话),即使父会话不在内存也能拿到
733
871
  const subRes = await sumSubagentUsage(httpCtx, sessionParam, config);
734
872
  subagentUsage = subRes.usage;
@@ -741,10 +879,10 @@ export function apply(ctx, config = {}) {
741
879
  const effectiveModel = model || detectedModel;
742
880
  // 逐模型标注 cost(高峰期×2、空闲期×1),客户端直接按各模型 cost 累加,无需自己定价。
743
881
  if (usage && Array.isArray(usage.byModel)) {
744
- usage.byModel = usage.byModel.map((b) => ({ ...b, cost: estimateCost(b, resolvePricing(config, b.model)) }));
882
+ usage.byModel = usage.byModel.map((b) => ({ ...b, cost: estimateCost(b, resolvePricing(config, b.model, b.era)) }));
745
883
  }
746
884
  if (subagentUsage && Array.isArray(subagentUsage.byModel)) {
747
- subagentUsage.byModel = subagentUsage.byModel.map((b) => ({ ...b, cost: estimateCost(b, resolvePricing(config, b.model)) }));
885
+ subagentUsage.byModel = subagentUsage.byModel.map((b) => ({ ...b, cost: estimateCost(b, resolvePricing(config, b.model, b.era)) }));
748
886
  }
749
887
  try {
750
888
  const data = await fetchBalance(apiKey);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "1.0.10",
3
+ "version": "1.2.0",
4
4
  "description": "查询 DeepSeek 余额并显示当前会话 token 消耗。DeepSeek 账户余额 + 会话 token/费用显示,含高峰/空闲峰谷计费。",
5
5
  "author": "Rannist",
6
6
  "type": "module",