balance-dsh 1.1.0 → 1.2.1
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 +343 -72
- package/lib/index.js +155 -12
- 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,不影响)
|
|
@@ -269,19 +294,76 @@ window.__ModuleLoader__.load({
|
|
|
269
294
|
|
|
270
295
|
// ─── 高峰/空闲时段(纯客户端判断,无需服务端) ────────────────────────
|
|
271
296
|
// V4 峰谷定价(2026-08-17 生效):高峰价 = 空闲价 × 2。
|
|
272
|
-
// 官方高峰窗口(北京时间)为
|
|
297
|
+
// 官方高峰窗口(北京时间)为 工作日 09:00-12:00、14:00-18:00;
|
|
298
|
+
// 周末与法定节假日全天空闲,但「调休上班的周末」按工作日算(2026-09-20 DeepSeek 明确)。
|
|
273
299
|
const PEAK_WINDOWS = [
|
|
274
300
|
{ start: 9, end: 12 },
|
|
275
301
|
{ start: 14, end: 18 },
|
|
276
302
|
];
|
|
277
303
|
const PEAK_WEEKDAYS = [1, 2, 3, 4, 5]; // 1=周一 … 5=周五,0/6=周末
|
|
278
|
-
|
|
279
|
-
|
|
304
|
+
// 中国大陆法定节假日与调休(北京时区)。**必须与 lib/index.js 的 CN_CALENDAR 完全一致**
|
|
305
|
+
// (服务端负责算钱、客户端只负责显示时段标签)。来源:国办发明电〔2025〕7号,每年需补录。
|
|
306
|
+
const CN_CALENDAR = {
|
|
307
|
+
// 元旦:1/1-1/3 放假,1/4(周日) 上班
|
|
308
|
+
"2026-01-01": "holiday", "2026-01-02": "holiday", "2026-01-03": "holiday", "2026-01-04": "workday",
|
|
309
|
+
// 春节:2/15-2/23 放假,2/14(周六)、2/28(周六) 上班
|
|
310
|
+
"2026-02-14": "workday",
|
|
311
|
+
"2026-02-15": "holiday", "2026-02-16": "holiday", "2026-02-17": "holiday", "2026-02-18": "holiday",
|
|
312
|
+
"2026-02-19": "holiday", "2026-02-20": "holiday", "2026-02-21": "holiday", "2026-02-22": "holiday",
|
|
313
|
+
"2026-02-23": "holiday", "2026-02-28": "workday",
|
|
314
|
+
// 清明节:4/4-4/6 放假(无调休)
|
|
315
|
+
"2026-04-04": "holiday", "2026-04-05": "holiday", "2026-04-06": "holiday",
|
|
316
|
+
// 劳动节:5/1-5/5 放假,5/9(周六) 上班
|
|
317
|
+
"2026-05-01": "holiday", "2026-05-02": "holiday", "2026-05-03": "holiday", "2026-05-04": "holiday",
|
|
318
|
+
"2026-05-05": "holiday", "2026-05-09": "workday",
|
|
319
|
+
// 端午节:6/19-6/21 放假(无调休)
|
|
320
|
+
"2026-06-19": "holiday", "2026-06-20": "holiday", "2026-06-21": "holiday",
|
|
321
|
+
// 国庆节调休:9/20(周日) 上班
|
|
322
|
+
"2026-09-20": "workday",
|
|
323
|
+
// 中秋节:9/25-9/27 放假(无调休)
|
|
324
|
+
"2026-09-25": "holiday", "2026-09-26": "holiday", "2026-09-27": "holiday",
|
|
325
|
+
// 国庆节:10/1-10/7 放假,10/10(周六) 上班
|
|
326
|
+
"2026-10-01": "holiday", "2026-10-02": "holiday", "2026-10-03": "holiday", "2026-10-04": "holiday",
|
|
327
|
+
"2026-10-05": "holiday", "2026-10-06": "holiday", "2026-10-07": "holiday", "2026-10-10": "workday",
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// 节假日自动补录:与 lib/index.js 同源、同逻辑(客户端只用来显示时段标签)。
|
|
331
|
+
// 拉取失败静默 —— 拿不到就继续用上面的内置表。
|
|
332
|
+
const CN_CALENDAR_SOURCE = "https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master";
|
|
333
|
+
const cnCalendarYears = new Set();
|
|
334
|
+
async function ensureCnCalendar(year) {
|
|
335
|
+
if (cnCalendarYears.has(year)) return;
|
|
336
|
+
cnCalendarYears.add(year); // 先占位,避免并发重复请求
|
|
337
|
+
try {
|
|
338
|
+
const res = await fetch(CN_CALENDAR_SOURCE + "/" + year + ".json", { signal: AbortSignal.timeout(8000) });
|
|
339
|
+
if (!res.ok) return;
|
|
340
|
+
const data = await res.json();
|
|
341
|
+
if (!Array.isArray(data?.days)) return;
|
|
342
|
+
for (const item of data.days) {
|
|
343
|
+
if (item && typeof item.date === "string" && typeof item.isOffDay === "boolean") {
|
|
344
|
+
CN_CALENDAR[item.date] = item.isOffDay ? "holiday" : "workday";
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
} catch {
|
|
348
|
+
// 网络不可用:保留内置表
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
{
|
|
352
|
+
const y = new Date(Date.now() + 8 * 60 * 60 * 1000).getUTCFullYear();
|
|
353
|
+
void ensureCnCalendar(y - 1);
|
|
354
|
+
void ensureCnCalendar(y);
|
|
355
|
+
void ensureCnCalendar(y + 1);
|
|
356
|
+
}
|
|
357
|
+
function currentPeriod(atMs) {
|
|
358
|
+
const d = new Date((atMs === undefined ? Date.now() : atMs) + 8 * 60 * 60 * 1000); // 北京时间
|
|
359
|
+
const kind = CN_CALENDAR[
|
|
360
|
+
d.getUTCFullYear() + "-" + String(d.getUTCMonth() + 1).padStart(2, "0") + "-" + String(d.getUTCDate()).padStart(2, "0")
|
|
361
|
+
];
|
|
362
|
+
if (kind === "holiday") return "offpeak"; // 法定节假日:全天按空闲
|
|
280
363
|
const hour = d.getUTCHours();
|
|
281
364
|
const weekday = d.getUTCDay(); // 0=周日 … 6=周六
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
: "offpeak";
|
|
365
|
+
const onDuty = PEAK_WEEKDAYS.includes(weekday) || kind === "workday"; // 调休上班的周末按工作日算
|
|
366
|
+
return onDuty && PEAK_WINDOWS.some((w) => hour >= w.start && hour < w.end) ? "peak" : "offpeak";
|
|
285
367
|
}
|
|
286
368
|
/** 按当前时段调整价格:高峰 ×2、空闲原价。注意:若服务端已配 config.peak:true
|
|
287
369
|
* (强制高峰价翻倍),此处会再乘一次,两种方式不要同时使用。 */
|
|
@@ -389,17 +471,71 @@ window.__ModuleLoader__.load({
|
|
|
389
471
|
return usageStatsInFlight;
|
|
390
472
|
}
|
|
391
473
|
|
|
474
|
+
/** 定价表内部键 -> 展示名 */
|
|
475
|
+
function modelLabel(key) {
|
|
476
|
+
if (key === "deepseek-v4-flash") return "Flash";
|
|
477
|
+
if (key === "deepseek-v4-pro") return "Pro";
|
|
478
|
+
if (key === "deepseek-chat") return "Chat";
|
|
479
|
+
if (key === "deepseek-reasoner") return "Reasoner";
|
|
480
|
+
return String(key || "未知");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** token 桶求和(服务端统计返回的 peak / offpeak 桶) */
|
|
484
|
+
function bucketTokens(bucket) {
|
|
485
|
+
if (!bucket) return 0;
|
|
486
|
+
return (
|
|
487
|
+
Number(bucket.inputTokens || 0) +
|
|
488
|
+
Number(bucket.outputTokens || 0) +
|
|
489
|
+
Number(bucket.cacheReadTokens || 0) +
|
|
490
|
+
Number(bucket.cacheWriteTokens || 0)
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** 概览卡片。数值已完整展示,**不挂原生 `title`**——浏览器系统灰框与 DSH 主题不一致。 */
|
|
392
495
|
const StatsCard = react.memo(function StatsCard({ label, value }) {
|
|
393
496
|
return jsx.jsxs("div", {
|
|
394
497
|
className: "balance-stats-card",
|
|
395
498
|
children: [
|
|
396
499
|
jsx.jsx("span", { className: "balance-stats-card__label", children: label }),
|
|
397
|
-
jsx.jsx("span", { className: "balance-stats-card__value",
|
|
500
|
+
jsx.jsx("span", { className: "balance-stats-card__value", children: value }),
|
|
398
501
|
],
|
|
399
502
|
});
|
|
400
503
|
});
|
|
401
504
|
|
|
402
|
-
|
|
505
|
+
/** 明细行:左侧名称、右侧数值(两端对齐)。
|
|
506
|
+
* 曾经画过占比条,但各分组的基准并不统一(Token 构成按 token、按模型/时段按费用、
|
|
507
|
+
* 近期消耗按 90 天总额),同一屏三种尺子无法横向比较,而数值本身已表达量级,
|
|
508
|
+
* 条只会让人猜「这到底占谁的比例」,故去掉。 */
|
|
509
|
+
const StatsRow = react.memo(function StatsRow({ name, value }) {
|
|
510
|
+
return jsx.jsxs("div", {
|
|
511
|
+
className: "balance-stats-row",
|
|
512
|
+
children: [
|
|
513
|
+
jsx.jsx("span", { className: "balance-stats-row__name", children: name }),
|
|
514
|
+
jsx.jsx("span", { className: "balance-stats-row__value", children: value }),
|
|
515
|
+
],
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
/** 明细分组:小标题(可带右侧提示) + 若干明细行。
|
|
520
|
+
* `index` 只用于卡片逐个浮现的阶梯动画延迟——面板先浮现、各卡片随后依次入场。 */
|
|
521
|
+
function StatsGroup({ title, hint, children, index = 0 }) {
|
|
522
|
+
return jsx.jsxs("div", {
|
|
523
|
+
className: "balance-stats-group",
|
|
524
|
+
style: { animationDelay: (0.08 + index * 0.035).toFixed(3) + "s" },
|
|
525
|
+
children: [
|
|
526
|
+
jsx.jsxs("div", {
|
|
527
|
+
className: "balance-stats-group__title",
|
|
528
|
+
children: [
|
|
529
|
+
jsx.jsx("span", { children: title }),
|
|
530
|
+
hint ? jsx.jsx("span", { className: "balance-stats-group__hint", children: hint }) : null,
|
|
531
|
+
],
|
|
532
|
+
}),
|
|
533
|
+
children,
|
|
534
|
+
],
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function UsageStatsPanel({ onClose, panelRef, closing }) {
|
|
403
539
|
const liveBalance = useBalance();
|
|
404
540
|
const [heatmapTooltip, setHeatmapTooltip] = react.useState(null);
|
|
405
541
|
const [state, setState] = react.useState(() => {
|
|
@@ -477,6 +613,58 @@ window.__ModuleLoader__.load({
|
|
|
477
613
|
const balanceText = balance
|
|
478
614
|
? String(balance.total_balance ?? "0.00") + " " + String(balance.currency || "CNY")
|
|
479
615
|
: "不可用";
|
|
616
|
+
|
|
617
|
+
// —— 派生统计:全部对旧缓存 / 缺失字段容错(数值缺失按 0、数组缺失按空) ——
|
|
618
|
+
const num = (v) => Number(v || 0);
|
|
619
|
+
const derived = react.useMemo(() => {
|
|
620
|
+
const t = total || {};
|
|
621
|
+
const dayList = Array.isArray(days) ? days : [];
|
|
622
|
+
const activeDays = dayList.filter((d) => num(d.totalTokens) > 0);
|
|
623
|
+
const sumRange = (n) =>
|
|
624
|
+
dayList.slice(-n).reduce(
|
|
625
|
+
(acc, d) => {
|
|
626
|
+
acc.tokens += num(d.totalTokens);
|
|
627
|
+
acc.cost += num(d.cost);
|
|
628
|
+
return acc;
|
|
629
|
+
},
|
|
630
|
+
{ tokens: 0, cost: 0 },
|
|
631
|
+
);
|
|
632
|
+
const today = dayList.length > 0 ? dayList[dayList.length - 1] : null;
|
|
633
|
+
const peakDay = activeDays.reduce(
|
|
634
|
+
(best, d) => (best == null || num(d.totalTokens) > num(best.totalTokens) ? d : best),
|
|
635
|
+
null,
|
|
636
|
+
);
|
|
637
|
+
const inputTokens = num(t.inputTokens);
|
|
638
|
+
const cacheReadTokens = num(t.cacheReadTokens);
|
|
639
|
+
const cacheWriteTokens = num(t.cacheWriteTokens);
|
|
640
|
+
const outputTokens = num(t.outputTokens);
|
|
641
|
+
const billedInput = inputTokens + cacheReadTokens + cacheWriteTokens;
|
|
642
|
+
const activeCount = activeDays.length;
|
|
643
|
+
return {
|
|
644
|
+
activeCount,
|
|
645
|
+
todayTokens: num(today && today.totalTokens),
|
|
646
|
+
todayCost: num(today && today.cost),
|
|
647
|
+
week: sumRange(7),
|
|
648
|
+
month: sumRange(30),
|
|
649
|
+
avgTokens: activeCount > 0 ? num(t.totalTokens) / activeCount : 0,
|
|
650
|
+
avgCost: activeCount > 0 ? num(t.cost) / activeCount : 0,
|
|
651
|
+
peakDay,
|
|
652
|
+
inputTokens,
|
|
653
|
+
cacheReadTokens,
|
|
654
|
+
cacheWriteTokens,
|
|
655
|
+
outputTokens,
|
|
656
|
+
hitRate: billedInput > 0 ? cacheReadTokens / billedInput : 0,
|
|
657
|
+
byModel: Array.isArray(t.byModel) ? t.byModel.filter((m) => m && num(m.totalTokens) > 0) : [],
|
|
658
|
+
peakTokens: bucketTokens(t.peak),
|
|
659
|
+
offpeakTokens: bucketTokens(t.offpeak),
|
|
660
|
+
peakCost: num(t.peakCost),
|
|
661
|
+
offpeakCost: num(t.offpeakCost),
|
|
662
|
+
};
|
|
663
|
+
}, [days, total]);
|
|
664
|
+
const fmtCost = (v) => "¥" + num(v).toFixed(4);
|
|
665
|
+
const denomCost = num(total && total.cost) > 0 ? num(total.cost) : 1;
|
|
666
|
+
const rangeDays = Number(stats && stats.rangeDays) || 90;
|
|
667
|
+
|
|
480
668
|
const body = state.loading
|
|
481
669
|
? jsx.jsx("div", { className: "balance-stats-loading", children: "正在汇总会话记录…" })
|
|
482
670
|
: state.error
|
|
@@ -485,34 +673,127 @@ window.__ModuleLoader__.load({
|
|
|
485
673
|
children: [
|
|
486
674
|
!liveBalance.balance && data.balanceError ? jsx.jsx("div", { className: "balance-stats-error", children: "余额:" + data.balanceError }) : null,
|
|
487
675
|
jsx.jsxs("div", {
|
|
488
|
-
className: "balance-stats-
|
|
676
|
+
className: "balance-stats-top",
|
|
489
677
|
children: [
|
|
490
|
-
jsx.
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
678
|
+
jsx.jsxs("div", {
|
|
679
|
+
className: "balance-stats-cards",
|
|
680
|
+
children: [
|
|
681
|
+
jsx.jsx(StatsCard, { label: "账户余额", value: balanceText }),
|
|
682
|
+
jsx.jsx(StatsCard, { label: "90 天估算费用", value: fmtCost(total && total.cost) }),
|
|
683
|
+
jsx.jsx(StatsCard, { label: "90 天计费 Token", value: formatTokens(Number(total?.totalTokens || 0)) }),
|
|
684
|
+
jsx.jsx(StatsCard, { label: "活跃天数", value: derived.activeCount + " / " + rangeDays + " 天" }),
|
|
685
|
+
],
|
|
686
|
+
}),
|
|
687
|
+
jsx.jsxs("div", {
|
|
688
|
+
className: "balance-stats-heatmap-card",
|
|
689
|
+
children: [
|
|
690
|
+
jsx.jsxs("div", {
|
|
691
|
+
className: "balance-stats-section-title",
|
|
692
|
+
children: [
|
|
693
|
+
jsx.jsx("span", { children: "近 90 天使用热力图" }),
|
|
694
|
+
jsx.jsxs("span", {
|
|
695
|
+
className: "balance-stats-legend",
|
|
696
|
+
children: ["少", 0, 1, 2, 3, 4].map((item, index) => index === 0
|
|
697
|
+
? item
|
|
698
|
+
: jsx.jsx("i", { className: "balance-heatmap-cell balance-heatmap-cell--" + item }, "legend-" + item)).concat(["多"]),
|
|
699
|
+
}),
|
|
700
|
+
],
|
|
701
|
+
}),
|
|
702
|
+
jsx.jsx("div", { className: "balance-heatmap-wrap", children: jsx.jsx("div", { className: "balance-heatmap", children: heatmap }) }),
|
|
703
|
+
],
|
|
704
|
+
}),
|
|
494
705
|
],
|
|
495
706
|
}),
|
|
496
707
|
jsx.jsxs("div", {
|
|
497
|
-
className: "balance-stats-
|
|
708
|
+
className: "balance-stats-grid",
|
|
498
709
|
children: [
|
|
499
|
-
jsx.
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
710
|
+
jsx.jsxs(StatsGroup, {
|
|
711
|
+
index: 0,
|
|
712
|
+
title: "Token 构成",
|
|
713
|
+
hint: "缓存命中率 " + (derived.hitRate * 100).toFixed(1) + "%",
|
|
714
|
+
children: [
|
|
715
|
+
jsx.jsx(StatsRow, { name: "未缓存输入", value: formatTokens(derived.inputTokens)}),
|
|
716
|
+
jsx.jsx(StatsRow, { name: "缓存读取", value: formatTokens(derived.cacheReadTokens)}),
|
|
717
|
+
jsx.jsx(StatsRow, { name: "缓存写入", value: formatTokens(derived.cacheWriteTokens)}),
|
|
718
|
+
jsx.jsx(StatsRow, { name: "输出", value: formatTokens(derived.outputTokens)}),
|
|
719
|
+
],
|
|
505
720
|
}),
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
721
|
+
derived.byModel.length > 0
|
|
722
|
+
? jsx.jsxs(StatsGroup, {
|
|
723
|
+
index: 1,
|
|
724
|
+
title: "按模型",
|
|
725
|
+
children: derived.byModel.map((item) =>
|
|
726
|
+
jsx.jsx(
|
|
727
|
+
StatsRow,
|
|
728
|
+
{
|
|
729
|
+
name: modelLabel(item.model),
|
|
730
|
+
value: fmtCost(item.cost) + " · " + formatTokens(Number(item.totalTokens || 0)),
|
|
731
|
+
},
|
|
732
|
+
"model-" + item.model,
|
|
733
|
+
)),
|
|
734
|
+
})
|
|
735
|
+
: null,
|
|
736
|
+
derived.peakTokens > 0 || derived.offpeakTokens > 0
|
|
737
|
+
? jsx.jsxs(StatsGroup, {
|
|
738
|
+
index: 2,
|
|
739
|
+
title: "时段拆分",
|
|
740
|
+
hint: "高峰占 " + ((derived.peakCost / denomCost) * 100).toFixed(0) + "%",
|
|
741
|
+
children: [
|
|
742
|
+
jsx.jsx(StatsRow, {
|
|
743
|
+
name: "高峰(×2)",
|
|
744
|
+
value: fmtCost(derived.peakCost) + " · " + formatTokens(derived.peakTokens),
|
|
745
|
+
}),
|
|
746
|
+
jsx.jsx(StatsRow, {
|
|
747
|
+
name: "空闲",
|
|
748
|
+
value: fmtCost(derived.offpeakCost) + " · " + formatTokens(derived.offpeakTokens),
|
|
749
|
+
}),
|
|
750
|
+
],
|
|
751
|
+
})
|
|
752
|
+
: null,
|
|
753
|
+
jsx.jsxs(StatsGroup, {
|
|
754
|
+
index: 3,
|
|
755
|
+
title: "近期消耗",
|
|
756
|
+
children: [
|
|
757
|
+
jsx.jsx(StatsRow, {
|
|
758
|
+
name: "今日",
|
|
759
|
+
value: formatTokens(derived.todayTokens) + " · " + fmtCost(derived.todayCost),
|
|
760
|
+
}),
|
|
761
|
+
jsx.jsx(StatsRow, {
|
|
762
|
+
name: "近 7 天",
|
|
763
|
+
value: formatTokens(derived.week.tokens) + " · " + fmtCost(derived.week.cost),
|
|
764
|
+
}),
|
|
765
|
+
jsx.jsx(StatsRow, {
|
|
766
|
+
name: "近 30 天",
|
|
767
|
+
value: formatTokens(derived.month.tokens) + " · " + fmtCost(derived.month.cost),
|
|
768
|
+
}),
|
|
769
|
+
],
|
|
770
|
+
}),
|
|
771
|
+
jsx.jsxs(StatsGroup, {
|
|
772
|
+
index: 4,
|
|
773
|
+
title: "日均与峰值",
|
|
774
|
+
children: [
|
|
775
|
+
// 日均 = 总额 / 活跃天,没有「占比」语义,因此不画条(否则是条空轨道)
|
|
776
|
+
jsx.jsx(StatsRow, {
|
|
777
|
+
name: "日均(按活跃天)",
|
|
778
|
+
value: formatTokens(derived.avgTokens) + " · " + fmtCost(derived.avgCost),
|
|
779
|
+
}),
|
|
780
|
+
jsx.jsx(StatsRow, {
|
|
781
|
+
name: derived.peakDay ? "峰值日 " + derived.peakDay.date : "峰值日",
|
|
782
|
+
value: derived.peakDay
|
|
783
|
+
? formatTokens(Number(derived.peakDay.totalTokens || 0)) + " · " + fmtCost(derived.peakDay.cost)
|
|
784
|
+
: "—",
|
|
785
|
+
}),
|
|
786
|
+
],
|
|
787
|
+
}),
|
|
788
|
+
] }),
|
|
509
789
|
jsx.jsxs("div", {
|
|
510
790
|
className: "balance-stats-footer",
|
|
511
791
|
children: [
|
|
512
792
|
jsx.jsx("span", {
|
|
513
|
-
children:
|
|
514
|
-
|
|
515
|
-
|
|
793
|
+
children:
|
|
794
|
+
"已扫描 " + String(stats?.scannedSessions || 0) + " 个会话" +
|
|
795
|
+
(stats?.limited ? "(为保证性能仅取最近 200 个)" : "") +
|
|
796
|
+
";按北京时间和实际模型峰谷价格估算,结果缓存 5 分钟。",
|
|
516
797
|
}),
|
|
517
798
|
jsx.jsx("a", {
|
|
518
799
|
className: "balance-stats-link",
|
|
@@ -527,7 +808,6 @@ window.__ModuleLoader__.load({
|
|
|
527
808
|
});
|
|
528
809
|
return jsx.jsxs("section", {
|
|
529
810
|
ref: panelRef,
|
|
530
|
-
style,
|
|
531
811
|
className: "balance-stats-panel" + (closing ? " balance-stats-panel--closing" : ""),
|
|
532
812
|
role: "dialog",
|
|
533
813
|
"aria-label": "余额与使用统计",
|
|
@@ -544,7 +824,7 @@ window.__ModuleLoader__.load({
|
|
|
544
824
|
jsx.jsx("button", { className: "balance-stats-close", type: "button", onClick: onClose, "aria-label": "关闭", children: "×" }),
|
|
545
825
|
],
|
|
546
826
|
}),
|
|
547
|
-
body,
|
|
827
|
+
jsx.jsx("div", { className: "balance-stats-body", children: body }),
|
|
548
828
|
heatmapTooltip ? reactDom.createPortal(jsx.jsx("div", {
|
|
549
829
|
className: "balance-heatmap-tooltip balance-heatmap-tooltip--" + heatmapTooltip.align,
|
|
550
830
|
style: heatmapTooltip.style,
|
|
@@ -580,10 +860,10 @@ window.__ModuleLoader__.load({
|
|
|
580
860
|
const state = useBalance();
|
|
581
861
|
const [statsOpen, setStatsOpen] = react.useState(false);
|
|
582
862
|
const [statsClosing, setStatsClosing] = react.useState(false);
|
|
583
|
-
const [statsPosition, setStatsPosition] = react.useState({ left: 12, bottom: 56, width: 460 });
|
|
584
863
|
const clickTimer = react.useRef(null);
|
|
585
864
|
const closeTimer = react.useRef(null);
|
|
586
865
|
const anchorRef = react.useRef(null);
|
|
866
|
+
const layerRef = react.useRef(null);
|
|
587
867
|
const panelRef = react.useRef(null);
|
|
588
868
|
react.useEffect(() => () => {
|
|
589
869
|
if (clickTimer.current !== null) clearTimeout(clickTimer.current);
|
|
@@ -599,38 +879,17 @@ window.__ModuleLoader__.load({
|
|
|
599
879
|
setStatsClosing(false);
|
|
600
880
|
}, 140);
|
|
601
881
|
}, [statsOpen, statsClosing]);
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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]);
|
|
882
|
+
// 点击遮罩(即 layer 自身)关闭;点击面板内部不关闭。
|
|
883
|
+
// 窗口缩放不再关闭面板(居中布局会自适应,旧实现是锚定按钮需要重算位置)。
|
|
884
|
+
const handleBackdropClick = (event) => {
|
|
885
|
+
if (event.target === layerRef.current) closeStats();
|
|
886
|
+
};
|
|
617
887
|
const openStats = (event) => {
|
|
618
888
|
if (event && typeof event.preventDefault === "function") event.preventDefault();
|
|
619
889
|
if (clickTimer.current !== null) clearTimeout(clickTimer.current);
|
|
620
890
|
if (closeTimer.current !== null) clearTimeout(closeTimer.current);
|
|
621
891
|
closeTimer.current = null;
|
|
622
892
|
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
893
|
clickTimer.current = setTimeout(() => {
|
|
635
894
|
clickTimer.current = null;
|
|
636
895
|
setStatsClosing(false);
|
|
@@ -647,22 +906,34 @@ window.__ModuleLoader__.load({
|
|
|
647
906
|
setStatsOpen(false);
|
|
648
907
|
window.open("https://platform.deepseek.com/usage", "_blank", "noopener");
|
|
649
908
|
};
|
|
909
|
+
// 余额元素的交互。悬停提示不用原生 title,统一走主题化浮层(见 withPanel)。
|
|
650
910
|
const interactions = {
|
|
651
911
|
onClick: openStats,
|
|
652
912
|
onDoubleClick: openUsagePage,
|
|
653
913
|
onContextMenu: onBalanceContextMenu,
|
|
654
|
-
title: "单击查看余额与使用统计,双击打开 DeepSeek API 用量页,右键刷新",
|
|
655
914
|
};
|
|
656
915
|
const withPanel = (node) => jsx.jsxs("div", {
|
|
657
916
|
ref: anchorRef,
|
|
658
917
|
className: "balance-stats-anchor balance-stats-anchor--" + (wide ? "wide" : "compact"),
|
|
659
918
|
children: [
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
919
|
+
// 悬停提示走 DSH 主题化浮层:primitives.Tooltip 内部是 cloneElement(children, {ref}),
|
|
920
|
+
// 不会插入额外 DOM 层,因此不破坏余额行在 flex 容器里的 42px 布局(浮层本身 portal 到 body)。
|
|
921
|
+
jsx.jsx(primitives.Tooltip, {
|
|
922
|
+
label: "单击查看余额与使用统计,双击打开 DeepSeek API 用量页,右键刷新",
|
|
923
|
+
side: "top",
|
|
924
|
+
delayMs: 400,
|
|
925
|
+
children: node,
|
|
926
|
+
}),
|
|
927
|
+
// 统计面板:居中弹层 + 半透明遮罩(点击遮罩 / Esc / 关闭按钮均可关闭)
|
|
928
|
+
statsOpen ? reactDom.createPortal(jsx.jsx("div", {
|
|
929
|
+
ref: layerRef,
|
|
930
|
+
className: "balance-stats-layer" + (statsClosing ? " balance-stats-layer--closing" : ""),
|
|
931
|
+
onMouseDown: handleBackdropClick,
|
|
932
|
+
children: jsx.jsx(UsageStatsPanel, {
|
|
933
|
+
onClose: closeStats,
|
|
934
|
+
panelRef,
|
|
935
|
+
closing: statsClosing,
|
|
936
|
+
}),
|
|
666
937
|
}), document.body) : null,
|
|
667
938
|
],
|
|
668
939
|
});
|
package/lib/index.js
CHANGED
|
@@ -79,7 +79,8 @@ function pricingEraAt(time) {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
// 高峰/空闲时段(V4 峰谷定价,2026-08-17 生效):高峰价 = 空闲价 × 2。
|
|
82
|
-
// 官方高峰窗口(北京时间)为
|
|
82
|
+
// 官方高峰窗口(北京时间)为 工作日 09:00-12:00、14:00-18:00;
|
|
83
|
+
// 周末与法定节假日全天空闲,但**调休上班的周末**按工作日算(2026-09-20 DeepSeek 明确此规则)。
|
|
83
84
|
// 可用 config.peakWindows 覆盖小时窗口(如 [{start:9,end:12},{start:14,end:18}]),
|
|
84
85
|
// 可用 config.peakWeekdays 覆盖周几(1=周一 … 5=周五,默认 [1,2,3,4,5],0/6=周末)。
|
|
85
86
|
const DEFAULT_PEAK_WINDOWS = [
|
|
@@ -88,7 +89,63 @@ const DEFAULT_PEAK_WINDOWS = [
|
|
|
88
89
|
];
|
|
89
90
|
const DEFAULT_PEAK_WEEKDAYS = [1, 2, 3, 4, 5];
|
|
90
91
|
|
|
91
|
-
|
|
92
|
+
// 中国大陆法定节假日与调休(日期为北京时区)。来源:国务院办公厅《关于2026年部分节假日安排的通知》
|
|
93
|
+
// (国办发明电〔2025〕7号)。holiday = 法定节假日(全天按空闲计费);workday = 调休上班的周末(按工作日峰谷计费)。
|
|
94
|
+
// ⚠️ 国务院每年 11 月前后公布次年安排,届时需补录。lib/client.js 有一份同样的表(用于时段标签),两处必须同步。
|
|
95
|
+
const CN_CALENDAR = {
|
|
96
|
+
// 元旦:1/1-1/3 放假,1/4(周日) 上班
|
|
97
|
+
"2026-01-01": "holiday", "2026-01-02": "holiday", "2026-01-03": "holiday", "2026-01-04": "workday",
|
|
98
|
+
// 春节:2/15-2/23 放假,2/14(周六)、2/28(周六) 上班
|
|
99
|
+
"2026-02-14": "workday",
|
|
100
|
+
"2026-02-15": "holiday", "2026-02-16": "holiday", "2026-02-17": "holiday", "2026-02-18": "holiday",
|
|
101
|
+
"2026-02-19": "holiday", "2026-02-20": "holiday", "2026-02-21": "holiday", "2026-02-22": "holiday",
|
|
102
|
+
"2026-02-23": "holiday", "2026-02-28": "workday",
|
|
103
|
+
// 清明节:4/4-4/6 放假(无调休)
|
|
104
|
+
"2026-04-04": "holiday", "2026-04-05": "holiday", "2026-04-06": "holiday",
|
|
105
|
+
// 劳动节:5/1-5/5 放假,5/9(周六) 上班
|
|
106
|
+
"2026-05-01": "holiday", "2026-05-02": "holiday", "2026-05-03": "holiday", "2026-05-04": "holiday",
|
|
107
|
+
"2026-05-05": "holiday", "2026-05-09": "workday",
|
|
108
|
+
// 端午节:6/19-6/21 放假(无调休)
|
|
109
|
+
"2026-06-19": "holiday", "2026-06-20": "holiday", "2026-06-21": "holiday",
|
|
110
|
+
// 国庆节调休:9/20(周日) 上班
|
|
111
|
+
"2026-09-20": "workday",
|
|
112
|
+
// 中秋节:9/25-9/27 放假(无调休)
|
|
113
|
+
"2026-09-25": "holiday", "2026-09-26": "holiday", "2026-09-27": "holiday",
|
|
114
|
+
// 国庆节:10/1-10/7 放假,10/10(周六) 上班
|
|
115
|
+
"2026-10-01": "holiday", "2026-10-02": "holiday", "2026-10-03": "holiday", "2026-10-04": "holiday",
|
|
116
|
+
"2026-10-05": "holiday", "2026-10-06": "holiday", "2026-10-07": "holiday", "2026-10-10": "workday",
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// 节假日自动补录:内置表仅作**离线兜底**,运行时从社区维护的源拉取(其 papers 字段直接引用国务院公告)。
|
|
120
|
+
// 源不可用/无网络/被 CSP 拦截时静默失败,继续用内置表;拉取结果按日期覆盖同键条目。
|
|
121
|
+
const CN_CALENDAR_SOURCE = "https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master";
|
|
122
|
+
const cnCalendarYears = new Set();
|
|
123
|
+
|
|
124
|
+
/** 拉取指定年份的节假日安排并合并进 CN_CALENDAR(幂等;失败静默,不阻塞调用方)。 */
|
|
125
|
+
async function ensureCnCalendar(year) {
|
|
126
|
+
if (cnCalendarYears.has(year)) return;
|
|
127
|
+
cnCalendarYears.add(year); // 先占位,避免并发重复请求
|
|
128
|
+
try {
|
|
129
|
+
const res = await fetch(CN_CALENDAR_SOURCE + "/" + year + ".json", { signal: AbortSignal.timeout(8000) });
|
|
130
|
+
if (!res.ok) return;
|
|
131
|
+
const data = await res.json();
|
|
132
|
+
if (!Array.isArray(data?.days)) return;
|
|
133
|
+
for (const item of data.days) {
|
|
134
|
+
if (item && typeof item.date === "string" && typeof item.isOffDay === "boolean") {
|
|
135
|
+
CN_CALENDAR[item.date] = item.isOffDay ? "holiday" : "workday";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// 网络不可用:保留内置表(该年调休会退化为「周末全天空闲」)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 北京时区日期键 YYYY-MM-DD(传入已 +8h 偏移的 Date)。 */
|
|
144
|
+
function cnDateKey(d) {
|
|
145
|
+
return d.getUTCFullYear() + "-" + String(d.getUTCMonth() + 1).padStart(2, "0") + "-" + String(d.getUTCDate()).padStart(2, "0");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 判断某时间戳(毫秒)是否处于高峰时段(北京时间;含周末、法定节假日与调休上班判断)。 */
|
|
92
149
|
function isPeakAt(timeMs, config) {
|
|
93
150
|
const windows = (Array.isArray(config?.peakWindows) && config.peakWindows.length > 0
|
|
94
151
|
? config.peakWindows
|
|
@@ -98,9 +155,12 @@ function isPeakAt(timeMs, config) {
|
|
|
98
155
|
? config.peakWeekdays.map(Number)
|
|
99
156
|
: DEFAULT_PEAK_WEEKDAYS;
|
|
100
157
|
const d = new Date(Number(timeMs) + 8 * 60 * 60 * 1000); // 北京时间
|
|
158
|
+
const kind = CN_CALENDAR[cnDateKey(d)];
|
|
159
|
+
if (kind === "holiday") return false; // 法定节假日:全天按空闲计费
|
|
101
160
|
const hour = d.getUTCHours();
|
|
102
161
|
const weekday = d.getUTCDay(); // 0=周日 … 6=周六
|
|
103
|
-
|
|
162
|
+
const onDuty = weekdays.includes(weekday) || kind === "workday"; // 调休上班的周末按工作日算
|
|
163
|
+
return onDuty && windows.some((w) => hour >= w.start && hour < w.end);
|
|
104
164
|
}
|
|
105
165
|
|
|
106
166
|
/** 把模型字符串规整到定价表键;未识别返回 ""(用 Flash 价兜底)。
|
|
@@ -406,21 +466,54 @@ function collectDailyUsage(events, config, fallbackModel, cutoffMs, days, sessio
|
|
|
406
466
|
const key = beijingDayKey(time);
|
|
407
467
|
let day = days.get(key);
|
|
408
468
|
if (day == null) {
|
|
409
|
-
day = {
|
|
469
|
+
day = {
|
|
470
|
+
date: key,
|
|
471
|
+
...emptyTokenBucket(),
|
|
472
|
+
totalTokens: 0,
|
|
473
|
+
cost: 0,
|
|
474
|
+
sessionIds: new Set(),
|
|
475
|
+
// —— 以下为细分维度,供统计面板展示;不改动上方总额口径 ——
|
|
476
|
+
byModel: new Map(), // modelKey -> { model, tokens..., totalTokens, cost }
|
|
477
|
+
peak: emptyTokenBucket(),
|
|
478
|
+
offpeak: emptyTokenBucket(),
|
|
479
|
+
peakCost: 0,
|
|
480
|
+
offpeakCost: 0,
|
|
481
|
+
};
|
|
410
482
|
days.set(key, day);
|
|
411
483
|
}
|
|
484
|
+
const model = modelAtTime(switches, time) || fallback;
|
|
485
|
+
const isPeak = isPeakAt(time, config);
|
|
486
|
+
// 分时段精确计费:高峰桶按高峰价(= 目录价 ×2)、空闲桶按目录价
|
|
487
|
+
const bucket = { peak: emptyTokenBucket(), offpeak: emptyTokenBucket() };
|
|
488
|
+
addTokenUsage(isPeak ? bucket.peak : bucket.offpeak, usage);
|
|
489
|
+
const stepCost = estimateCost(bucket, resolvePricing(config, model, pricingEraAt(time)));
|
|
490
|
+
|
|
412
491
|
addTokenUsage(day, usage);
|
|
413
|
-
|
|
492
|
+
const tokens = tokenTotal({
|
|
414
493
|
inputTokens: usage.inputTokens ?? 0,
|
|
415
494
|
outputTokens: usage.outputTokens ?? 0,
|
|
416
495
|
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
417
496
|
cacheWriteTokens: usage.cacheWriteTokens ?? 0,
|
|
418
497
|
});
|
|
498
|
+
day.totalTokens += tokens;
|
|
499
|
+
day.cost += stepCost;
|
|
419
500
|
day.sessionIds.add(sessionId);
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
addTokenUsage(
|
|
423
|
-
day.
|
|
501
|
+
|
|
502
|
+
// 高峰 / 空闲拆分(token 与费用)
|
|
503
|
+
addTokenUsage(isPeak ? day.peak : day.offpeak, usage);
|
|
504
|
+
if (isPeak) day.peakCost += stepCost;
|
|
505
|
+
else day.offpeakCost += stepCost;
|
|
506
|
+
|
|
507
|
+
// 按模型拆分:复用同一步的 stepCost,保证各模型费用之和 = day.cost
|
|
508
|
+
const mk = modelKey(model) || "deepseek-v4-flash";
|
|
509
|
+
let entry = day.byModel.get(mk);
|
|
510
|
+
if (entry == null) {
|
|
511
|
+
entry = { model: mk, ...emptyTokenBucket(), totalTokens: 0, cost: 0 };
|
|
512
|
+
day.byModel.set(mk, entry);
|
|
513
|
+
}
|
|
514
|
+
addTokenUsage(entry, usage);
|
|
515
|
+
entry.totalTokens += tokens;
|
|
516
|
+
entry.cost += stepCost;
|
|
424
517
|
}
|
|
425
518
|
}
|
|
426
519
|
|
|
@@ -494,7 +587,18 @@ async function buildUsageStats(ctx, config) {
|
|
|
494
587
|
const rows = recentDayKeys(STATS_DAYS, now).map((date) => {
|
|
495
588
|
const day = daily.get(date);
|
|
496
589
|
if (day == null) {
|
|
497
|
-
return {
|
|
590
|
+
return {
|
|
591
|
+
date,
|
|
592
|
+
...emptyTokenBucket(),
|
|
593
|
+
totalTokens: 0,
|
|
594
|
+
cost: 0,
|
|
595
|
+
sessions: 0,
|
|
596
|
+
byModel: [],
|
|
597
|
+
peak: emptyTokenBucket(),
|
|
598
|
+
offpeak: emptyTokenBucket(),
|
|
599
|
+
peakCost: 0,
|
|
600
|
+
offpeakCost: 0,
|
|
601
|
+
};
|
|
498
602
|
}
|
|
499
603
|
return {
|
|
500
604
|
date,
|
|
@@ -505,6 +609,11 @@ async function buildUsageStats(ctx, config) {
|
|
|
505
609
|
totalTokens: day.totalTokens,
|
|
506
610
|
cost: day.cost,
|
|
507
611
|
sessions: day.sessionIds.size,
|
|
612
|
+
byModel: [...day.byModel.values()],
|
|
613
|
+
peak: day.peak,
|
|
614
|
+
offpeak: day.offpeak,
|
|
615
|
+
peakCost: day.peakCost,
|
|
616
|
+
offpeakCost: day.offpeakCost,
|
|
508
617
|
};
|
|
509
618
|
});
|
|
510
619
|
const total = rows.reduce((acc, day) => {
|
|
@@ -514,11 +623,36 @@ async function buildUsageStats(ctx, config) {
|
|
|
514
623
|
acc.cacheWriteTokens += day.cacheWriteTokens;
|
|
515
624
|
acc.totalTokens += day.totalTokens;
|
|
516
625
|
acc.cost += day.cost;
|
|
626
|
+
// —— 细分维度:与上方总额同口径,只做拆分,不改变金额 ——
|
|
627
|
+
addTokenUsage(acc.peak, day.peak);
|
|
628
|
+
addTokenUsage(acc.offpeak, day.offpeak);
|
|
629
|
+
acc.peakCost += day.peakCost;
|
|
630
|
+
acc.offpeakCost += day.offpeakCost;
|
|
631
|
+
for (const item of day.byModel ?? []) {
|
|
632
|
+
let entry = acc.byModel.get(item.model);
|
|
633
|
+
if (entry == null) {
|
|
634
|
+
entry = { model: item.model, ...emptyTokenBucket(), totalTokens: 0, cost: 0 };
|
|
635
|
+
acc.byModel.set(item.model, entry);
|
|
636
|
+
}
|
|
637
|
+
addTokenUsage(entry, item);
|
|
638
|
+
entry.totalTokens += item.totalTokens;
|
|
639
|
+
entry.cost += item.cost;
|
|
640
|
+
}
|
|
517
641
|
return acc;
|
|
518
|
-
}, {
|
|
642
|
+
}, {
|
|
643
|
+
...emptyTokenBucket(),
|
|
644
|
+
totalTokens: 0,
|
|
645
|
+
cost: 0,
|
|
646
|
+
byModel: new Map(),
|
|
647
|
+
peak: emptyTokenBucket(),
|
|
648
|
+
offpeak: emptyTokenBucket(),
|
|
649
|
+
peakCost: 0,
|
|
650
|
+
offpeakCost: 0,
|
|
651
|
+
});
|
|
519
652
|
return {
|
|
520
653
|
days: rows,
|
|
521
|
-
|
|
654
|
+
// byModel 内部用 Map 聚合,返回前转成可直接 JSON 序列化的数组
|
|
655
|
+
total: { ...total, byModel: [...total.byModel.values()] },
|
|
522
656
|
rangeDays: STATS_DAYS,
|
|
523
657
|
scannedSessions,
|
|
524
658
|
failedSessions,
|
|
@@ -662,6 +796,15 @@ export function apply(ctx, config = {}) {
|
|
|
662
796
|
// 时统一清理,保证重复 apply 不冲突。
|
|
663
797
|
const disposers = [];
|
|
664
798
|
|
|
799
|
+
// 后台补录节假日数据(当年 ±1 年,覆盖 90 天统计窗口与跨年场景)。
|
|
800
|
+
// 故意不 await:拉取失败不能影响插件启动;isPeakAt 保持同步,未拉到时继续用内置表。
|
|
801
|
+
{
|
|
802
|
+
const y = new Date(Date.now() + 8 * 60 * 60 * 1000).getUTCFullYear();
|
|
803
|
+
void ensureCnCalendar(y - 1);
|
|
804
|
+
void ensureCnCalendar(y);
|
|
805
|
+
void ensureCnCalendar(y + 1);
|
|
806
|
+
}
|
|
807
|
+
|
|
665
808
|
// 1) /balance 命令:保留命令行查询入口
|
|
666
809
|
ctx.inject(["commands"], (commandCtx) => {
|
|
667
810
|
commandCtx.commands.register({
|