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.
- package/lib/client.js +292 -74
- package/lib/index.js +166 -28
- 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-
|
|
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(
|
|
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:
|
|
55
|
-
.balance-stats-
|
|
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:
|
|
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-
|
|
62
|
-
.balance-stats-
|
|
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-
|
|
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
|
-
|
|
88
|
-
|
|
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-
|
|
139
|
+
// 北京时间 2026-09-10 12:00 起生效的 DeepSeek V4-Flash 空闲时段价(元/百万 tokens)
|
|
140
|
+
// 注:历史消耗由服务端按当时价格回传 cost;这里的默认值只用于拉不到服务端分桶时的本地估算。
|
|
115
141
|
const DEFAULT_PRICING = {
|
|
116
|
-
inputCnyPerMillion: 1
|
|
117
|
-
cacheReadCnyPerMillion: 0.
|
|
142
|
+
inputCnyPerMillion: 1, // 输入(缓存未命中)
|
|
143
|
+
cacheReadCnyPerMillion: 0.02, // 输入(缓存命中)
|
|
118
144
|
cacheWriteCnyPerMillion: 0, // DeepSeek 无缓存写入计费
|
|
119
|
-
outputCnyPerMillion: 4
|
|
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",
|
|
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
|
-
|
|
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-
|
|
619
|
+
className: "balance-stats-top",
|
|
488
620
|
children: [
|
|
489
|
-
jsx.
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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-
|
|
651
|
+
className: "balance-stats-grid",
|
|
497
652
|
children: [
|
|
498
|
-
jsx.
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
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:
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
812
|
-
const
|
|
813
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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("
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
279
|
-
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
addTokenUsage(
|
|
388
|
-
day.
|
|
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 {
|
|
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
|
-
}, {
|
|
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
|
-
|
|
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
|
-
|
|
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);
|