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.
Files changed (3) hide show
  1. package/lib/client.js +343 -72
  2. package/lib/index.js +155 -12
  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,不影响)
@@ -269,19 +294,76 @@ window.__ModuleLoader__.load({
269
294
 
270
295
  // ─── 高峰/空闲时段(纯客户端判断,无需服务端) ────────────────────────
271
296
  // V4 峰谷定价(2026-08-17 生效):高峰价 = 空闲价 × 2。
272
- // 官方高峰窗口(北京时间)为 周一至周五 09:00-12:00、14:00-18:00;周末全天空闲。
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
- function currentPeriod() {
279
- const d = new Date(Date.now() + 8 * 60 * 60 * 1000); // 北京时间
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
- return PEAK_WEEKDAYS.includes(weekday) && PEAK_WINDOWS.some((w) => hour >= w.start && hour < w.end)
283
- ? "peak"
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", title: value, children: value }),
500
+ jsx.jsx("span", { className: "balance-stats-card__value", children: value }),
398
501
  ],
399
502
  });
400
503
  });
401
504
 
402
- function UsageStatsPanel({ onClose, panelRef, style, closing }) {
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-cards",
676
+ className: "balance-stats-top",
489
677
  children: [
490
- jsx.jsx(StatsCard, { label: "账户余额", value: balanceText }),
491
- jsx.jsx(StatsCard, { label: "90 天计费 Token", value: formatTokens(Number(total?.totalTokens || 0)) }),
492
- jsx.jsx(StatsCard, { label: "90 天估算费用", value: "¥" + Number(total?.cost || 0).toFixed(4) }),
493
- jsx.jsx(StatsCard, { label: "已扫描会话", value: String(stats?.scannedSessions || 0) }),
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-section-title",
708
+ className: "balance-stats-grid",
498
709
  children: [
499
- jsx.jsx("span", { children: "近 90 天使用热力图" }),
500
- jsx.jsxs("span", {
501
- className: "balance-stats-legend",
502
- children: ["少", 0, 1, 2, 3, 4].map((item, index) => index === 0
503
- ? item
504
- : jsx.jsx("i", { className: "balance-heatmap-cell balance-heatmap-cell--" + item }, "legend-" + item)).concat(["多"]),
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
- jsx.jsx("div", { className: "balance-heatmap-wrap", children: jsx.jsx("div", { className: "balance-heatmap", children: heatmap }) }),
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: stats?.limited
514
- ? "为保证性能,仅统计最近 200 个会话;结果缓存 5 分钟。"
515
- : "按北京时间和实际模型峰谷价格估算;结果缓存 5 分钟。",
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
- react.useEffect(() => {
603
- if (!statsOpen) return undefined;
604
- const closeOnOutsideClick = (event) => {
605
- if (anchorRef.current && anchorRef.current.contains(event.target)) return;
606
- if (panelRef.current && panelRef.current.contains(event.target)) return;
607
- closeStats();
608
- };
609
- const closeOnResize = () => closeStats();
610
- document.addEventListener("mousedown", closeOnOutsideClick);
611
- window.addEventListener("resize", closeOnResize);
612
- return () => {
613
- document.removeEventListener("mousedown", closeOnOutsideClick);
614
- window.removeEventListener("resize", closeOnResize);
615
- };
616
- }, [statsOpen, closeStats]);
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
- node,
661
- statsOpen ? reactDom.createPortal(jsx.jsx(UsageStatsPanel, {
662
- onClose: closeStats,
663
- panelRef,
664
- style: statsPosition,
665
- closing: statsClosing,
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
- // 官方高峰窗口(北京时间)为 周一至周五 09:00-12:00、14:00-18:00;周末全天空闲。
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
- /** 判断某时间戳(毫秒)是否处于高峰时段(按北京时间 UTC+8 计算,含周几判断)。 */
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
- return weekdays.includes(weekday) && windows.some((w) => hour >= w.start && hour < w.end);
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 = { date: key, ...emptyTokenBucket(), totalTokens: 0, cost: 0, sessionIds: new Set() };
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
- day.totalTokens += tokenTotal({
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
- const model = modelAtTime(switches, time) || fallback;
421
- const bucket = { peak: emptyTokenBucket(), offpeak: emptyTokenBucket() };
422
- addTokenUsage(isPeakAt(time, config) ? bucket.peak : bucket.offpeak, usage);
423
- day.cost += estimateCost(bucket, resolvePricing(config, model, pricingEraAt(time)));
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 { date, ...emptyTokenBucket(), totalTokens: 0, cost: 0, sessions: 0 };
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
- }, { ...emptyTokenBucket(), totalTokens: 0, cost: 0 });
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
- total,
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({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "查询 DeepSeek 余额并显示当前会话 token 消耗。DeepSeek 账户余额 + 会话 token/费用显示,含高峰/空闲峰谷计费。",
5
5
  "author": "Rannist",
6
6
  "type": "module",