balance-dsh 1.3.1 → 2.0.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 CHANGED
@@ -17,21 +17,105 @@ window.__ModuleLoader__.load({
17
17
  /* ── 基础元素(自定义类,稳定;纯文字样式,不用胶囊) ── */
18
18
  /* 完全复刻设置按钮(.VOzbGW_trigger):width calc(100%+4px)、height 42px、margin 4px -2px、
19
19
  padding 0 10px 0 8px、gap 8px、font 14px/22px、color label-primary;
20
- 左缘与设置按钮严格对齐,高度/颜色一致(图标与"余额"文字也统一 label-primary,金额 600 加粗) */
21
- .balance-footer{box-sizing:border-box;display:flex;align-items:center;justify-content:flex-start;gap:8px;width:100%;min-width:0;height:42px;margin:0;padding:0 0 0 8px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;text-decoration:none;cursor:pointer;white-space:nowrap;overflow:visible;-webkit-user-select:none;user-select:none}
20
+ 左缘与设置按钮严格对齐,高度/颜色一致(金额 600 加粗)。
21
+ 布局:[meta + 刻度条(竖排,居中)] [金额(最右)] */
22
+ .balance-footer{position:relative;isolation:isolate;box-sizing:border-box;display:flex;align-items:center;justify-content:flex-start;gap:8px;width:100%;min-width:0;height:42px;margin:0;padding:0 0 0 8px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;text-decoration:none;cursor:pointer;white-space:nowrap;overflow:visible;-webkit-user-select:none;user-select:none}
23
+ /* 带 meta 行时整块变高:42px 装不下「一行 11px 文字 + 13px 轨道」,改成自适应 */
24
+ .balance-footer--stacked{height:auto;min-height:42px;padding-top:5px;padding-bottom:5px}
25
+ /* 整行都是热区(单击统计面板 / 双击用量页 / 右键刷新;交互挂在 .balance-stats-anchor、捕获阶段)。
26
+ 不给整行画 hover / active 底色——那圈圆角高亮框在侧边栏里太显眼,
27
+ 且余白区也被涂上颜色很怪(2026-10-30 反馈「我不要高亮显示」)。
28
+ 可点指示只靠右端胶囊自己的 hover 缩放。 */
29
+ .balance-footer__spacer{flex:1 1 0;min-width:0}
30
+ /* 右键刷新的「抹除」效果:白条从左划到右,白条**左边**的数字已经"显影"(新值),
31
+ 右边仍被一层暗罩压着(旧值)。所以视觉上就是「只有被白条经过的地方才更新」。
32
+ ::before = 前沿白条(3px 实心 + 双层光晕)
33
+ ::after = 暗罩(全遮 → clip-path 左边界推进到 100% → 从左往右退去)
34
+ 两者同一条时间曲线,边界始终对齐。用 key 重挂来重播。 */
35
+ /* 自动刷新的数字滚轮:逐位滚动。
36
+ 整串按字符切成格子,只有值真正变了的格子才画三层(影子撑尺寸 / 旧字上滑出 / 新字下滑入),
37
+ 没变的字符就是普通文本 —— 所以花掉 0.01 只有末位在翻,小数点和高位数字纹丝不动。
38
+ 外层 overflow:hidden 把滑动部分裁在字高内,读起来像机械计数器过位。 */
39
+ .balance-footer__roll{display:inline-flex;align-items:baseline;white-space:pre}
40
+ .balance-footer__dcell{display:inline-block;vertical-align:bottom;white-space:pre}
41
+ .balance-footer__dcell--roll{position:relative;overflow:hidden}
42
+ .balance-footer__dghost{display:block;visibility:hidden;white-space:pre}
43
+ .balance-footer__dout,.balance-footer__din{position:absolute;left:0;top:0;white-space:pre;will-change:transform,opacity}
44
+ .balance-footer__dout{animation:balance-roll-out .42s cubic-bezier(.33,.66,.24,1) forwards}
45
+ .balance-footer__din{animation:balance-roll-in .42s cubic-bezier(.33,.66,.24,1) forwards}
46
+ @keyframes balance-roll-out{0%{transform:translateY(0);opacity:1}60%{opacity:0}100%{transform:translateY(-105%);opacity:0}}
47
+ @keyframes balance-roll-in{0%{transform:translateY(105%);opacity:0}40%{opacity:0}100%{transform:translateY(0);opacity:1}}
48
+ @media (prefers-reduced-motion:reduce){.balance-footer__dout,.balance-footer__din{animation:none}.balance-footer__dout{display:none}.balance-footer__din{position:static}}
49
+ .balance-footer__wave{position:absolute;left:0;right:0;top:0;bottom:0;overflow:hidden;border-radius:var(--dsw-radius-lg,14px);corner-shape:round;pointer-events:none;z-index:2}
50
+ /* 前沿白条:纯白,不带品牌色辉光(2026-10-30 反馈「只保留白色光效 不要颜色」)。
51
+ 只扫一层白光,**不覆盖扫过区域**——曾经有一个 ::after 暗罩把白条右侧整体压暗,
52
+ 视觉上等于给扫过的部分蒙了层色,已删除(2026-10-30 反馈「不要为扫过区域的颜色覆盖了」)。
53
+ 现在动效 = 一条白光从左走到右,走完即止,行内颜色全程不变。 */
54
+ .balance-footer__wave::before{content:"";position:absolute;top:1px;bottom:1px;left:0;width:2.5px;margin-left:-1.25px;border-radius:999px;corner-shape:round;background:#fff;box-shadow:0 0 3px 1px rgba(255,255,255,.95),0 0 8px 3px rgba(255,255,255,.42),0 0 14px 5px rgba(255,255,255,.16);animation:balance-wipe-head .58s cubic-bezier(.34,.7,.26,1) forwards}
55
+ @keyframes balance-wipe-head{0%{left:0;opacity:0}5%{opacity:1}82%{opacity:1}100%{left:100%;opacity:0}}
56
+ /* 尊重系统的「减少动态效果」:不播,只留 --stale 的静态弱化 */
57
+ @media (prefers-reduced-motion:reduce){.balance-footer__wave::before{animation:none;opacity:0}}
58
+ /* 旧版余额行用到的图标与「余额」二字(footerStyle === "classic" 时才渲染) */
22
59
  .balance-footer__icon{display:inline-flex;flex:none;color:var(--dsw-alias-label-primary)}
23
- .balance-footer__label{white-space:nowrap;color:var(--dsw-alias-label-primary)}
24
- .balance-footer__value{font-variant-numeric:tabular-nums;font-weight:600;white-space:nowrap;padding:2px 8px;border-radius:999px;margin-left:auto;margin-right:6px;text-decoration:none;cursor:pointer;transition:transform .15s var(--ds-ease-in-out,.2s)}
60
+ .balance-footer__label{flex:none;color:var(--dsw-alias-label-primary)}
61
+ .balance-footer__value{position:relative;z-index:0;isolation:isolate;display:inline-flex;align-items:center;font-variant-numeric:tabular-nums;font-weight:600;font-size:16px;line-height:1;white-space:nowrap;padding:7px 14px;border-radius:999px;corner-shape:round;margin-left:0;margin-right:6px;text-decoration:none;cursor:pointer;transition:transform .15s var(--ds-ease-in-out,.2s);flex:none}
25
62
  .balance-footer__value:hover{filter:brightness(.85);transform:scale(1.06)}
63
+ /* 旧版布局:胶囊靠右 —— 这是备份 v1.3.1 里的原始写法(margin-left:auto)。
64
+ 不能放到基础规则上,新版 timeline 用 margin:0 auto 居中,会和它打架。 */
65
+ .balance-footer--classic .balance-footer__value{margin-left:auto}
26
66
  .balance-footer__value--healthy{color:var(--dsw-alias-state-success-primary);background:rgba(34,197,94,.15)}
67
+ /* 正在刷新:金额栏压成半透明旧值,和左侧的时段占位呼应,避免旧数字看起来像刚更新的 */
68
+ .balance-footer__value--stale{opacity:.9}
27
69
  .balance-footer__value--low{color:#d97706;background:rgba(217,119,6,.15)}
28
70
  .balance-footer__value--empty{color:var(--dsw-alias-state-error-primary,#e5484d);background:rgba(229,72,77,.15)}
29
71
  .balance-footer__topup{margin-left:auto;flex:none;box-sizing:border-box;padding:2px 10px;border-radius:999px;font-weight:500;white-space:nowrap;color:var(--dsw-alias-state-error-primary,#e5484d);text-decoration:none;background:rgba(229,72,77,.15);transition:transform .15s var(--ds-ease-in-out,.2s)}
30
72
  .balance-footer__topup:hover{filter:brightness(.85);transform:scale(1.06)}
73
+ /* 旧版金额胶囊里的分隔点:两侧各 5px,柔化到 50% 不透明,不抢金额 */
74
+ .balance-footer__sep{flex:none;margin:0 5px;opacity:.5;font-weight:400}
31
75
  .balance-footer__period-inline{font-weight:500;white-space:nowrap}
76
+ .balance-footer__period-inline--refreshing{color:var(--dsw-alias-label-secondary)}
32
77
  .balance-footer__period-inline--peak{color:var(--dsw-alias-state-error-primary,#e5484d)}
33
78
  .balance-footer__period-inline--offpeak{color:var(--dsw-alias-state-success-primary)}
34
- .balance-footer__period-inline--refreshing{color:var(--dsw-alias-label-secondary)}
79
+ /* 时段刻度条:色带固定 = 一整天,指针随时刻从左走到右。
80
+ 结构:[meta 行(今日/时段/剩余)] + [轨道(边框) → 裁剪层(1px 内缩 → 泛光 + 色带) + 指针]。
81
+ 轨道刻意做扁(20px 压到 13px),上方留一行给文字,整块像个"仪表盘"而不是"进度胶囊"。 */
82
+ .balance-footer__timeline{position:relative;z-index:0;display:inline-flex;flex-direction:column;align-items:center;gap:4px;flex:none;white-space:nowrap;margin:0 auto}
83
+ /* meta 行:等宽数字,三项之间用中点分隔感靠 gap 制造 */
84
+ .balance-footer__timeline-meta{display:inline-flex;align-items:center;gap:6px;font-size:11px;line-height:1;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary,#8a8a8a);margin:0}
85
+ .balance-footer__timeline-meta-item{flex:none;white-space:nowrap}
86
+ /* 时段词单独上色:高峰红、空闲绿(其余两项保持中性灰,避免整行花掉) */
87
+ .balance-footer__timeline-meta-item--peak{font-weight:600;color:var(--dsw-alias-state-error-primary,#e5484d)}
88
+ .balance-footer__timeline-meta-item--offpeak{font-weight:600;color:var(--dsw-alias-state-success-primary,#22c55e)}
89
+ .balance-footer__timeline-track{position:relative;display:inline-block;box-sizing:border-box;width:145px;height:15px;border:2px solid color-mix(in srgb,var(--dsw-alias-label-tertiary,#8a8a8a) 70%,transparent);border-radius:999px;corner-shape:round;flex:none}
90
+ /* 描边统一中性灰:时段已经由 meta 行的「高峰 / 空闲」文字给出,
91
+ 轨道再上色就是同一信息说三遍,还会和色带里的红绿抢注意力。
92
+ 类名保留(--peak / --offpeak 仍在 JSX 上),将来想恢复彩色描边直接补规则即可。 */
93
+ /* 内嵌裁剪层:四边各让 1px,色带裁在这里,颜色与边框之间留一线空隙 */
94
+ .balance-footer__timeline-clip{position:absolute;left:1px;right:1px;top:1px;bottom:1px;overflow:hidden;border-radius:999px;corner-shape:round;isolation:isolate}
95
+ /* 泛光层:同一渐变模糊后叠在色带**上面**,靠模糊边缘往外散光。
96
+ 刻意不用 screen 混合、也不把 brightness 拉太高——那会把 #e5484d 推成粉白,
97
+ 红色看起来"发怪"。这里只轻微提亮 + 降透明度,色相保持在原色上。 */
98
+ .balance-footer__timeline-glow{position:absolute;left:-3px;right:-3px;top:-3px;bottom:-3px;pointer-events:none;filter:blur(3px) saturate(1.5) brightness(1.15);opacity:.45;z-index:2}
99
+ /* 色带本身:不透明 + 轻微提饱和,颜色是主角 */
100
+ .balance-footer__timeline-band{position:absolute;left:0;top:0;right:0;bottom:0;pointer-events:none;filter:saturate(1.25) brightness(1.12);z-index:1}
101
+ .balance-footer__timeline-pointer{position:absolute;top:-1px;bottom:-1px;width:2px;margin-left:-1px;background:var(--dsw-alias-label-primary,#fff);box-shadow:0 0 3px rgba(255,255,255,.7),0 0 2px rgba(0,0,0,.55);pointer-events:none;z-index:2;transition:left 60s linear}
102
+ /* ── 侧栏入口的红点 + 未读高亮 ─────────────────────────────────────────── */
103
+ .balance-panel-icon{position:relative;display:inline-flex;align-items:center;justify-content:center}
104
+ /* 未读时图标本身染成品牌色,比周围灰图标更跳 */
105
+ .balance-panel-icon--unread{color:var(--dsw-alias-brand-primary,#4d6bfe)}
106
+ /* 红点:贴在图标右上角,深色描边保证在任意底色上都看得清 */
107
+ .balance-panel-icon__dot{position:absolute;top:-2px;right:-3px;box-sizing:border-box;width:7px;height:7px;border-radius:50%;corner-shape:round;background:var(--dsw-alias-state-error-primary,#e5484d);box-shadow:0 0 0 1.5px var(--dsw-alias-bg-module-platform,var(--dsw-alias-base,#1b1b1c)),0 0 5px 1px color-mix(in srgb,var(--dsw-alias-state-error-primary,#e5484d) 65%,transparent);animation:balance-dot-pulse 2.2s ease-in-out infinite}
108
+ @keyframes balance-dot-pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(.82);opacity:.72}}
109
+ @media (prefers-reduced-motion:reduce){.balance-panel-icon__dot{animation:none}}
110
+ /* ── 侧栏面板页(点左侧「余额与用量」后渲染在主区域) ──────────────────── */
111
+ /* 页面根:DSH 的主区 mP3x-q_centerCol 是 display:flex;overflow:hidden(不滚),
112
+ slot 出口容器又是 display:contents,所以本元素直接就是那个 flex 的 item。
113
+ 要让它自己滚,必须 flex:1 + min-height:0(否则 flex item 的 min-height:auto
114
+ 会撑到内容高度,overflow 永远不触发),再配 overflow-y:auto。 */
115
+ .balance-panel-page{box-sizing:border-box;display:flex;flex:1 1 auto;flex-direction:column;gap:14px;min-height:0;max-height:100%;width:100%;max-width:820px;margin:0 auto;padding:28px 24px 40px;overflow-y:auto;overscroll-behavior:contain;scrollbar-gutter:stable}
116
+ .balance-panel-page__head{display:flex;flex-direction:column;gap:6px}
117
+ .balance-panel-page__title{margin:0;font-size:18px;line-height:26px;font-weight:600;color:var(--dsw-alias-label-primary)}
118
+ .balance-panel-page__desc{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary)}
35
119
  .balance-footer__error{color:var(--dsw-alias-state-error-primary)}
36
120
  .balance-topup{box-sizing:border-box;display:flex;align-items:center;justify-content:flex-start;gap:8px;width:calc(100% + 4px);min-width:0;height:42px;margin:0 -2px;padding:0 10px 0 8px;color:var(--dsw-alias-state-error-primary,#e5484d);font-size:14px;font-weight:600;line-height:22px;text-decoration:none;cursor:pointer;white-space:nowrap;overflow:hidden}
37
121
  .balance-topup:hover{text-decoration:underline}
@@ -51,9 +135,11 @@ window.__ModuleLoader__.load({
51
135
  .balance-fab--dense{font-size:11px;letter-spacing:-.03em}
52
136
  .balance-fab-dark{color:#fff;background:color-mix(in srgb,#e5484d 62%,transparent);border-color:color-mix(in srgb,#fff 22%,transparent)}
53
137
  .balance-fab-dark:hover{background:color-mix(in srgb,#e5484d 78%,transparent)}
54
- /* ── 消耗行 pill:规格逐条照抄 DSH 原生 StatsPills 的 .pill(_091hzG_pill)── */
138
+ /* ── 消耗行 pill:规格逐条照抄 DSH 原生 StatsPills 的 .pill(iq1doa_pill)──
139
+ 圆角是 --dsw-radius-sm(小圆角矩形),不是 999px 胶囊;也不要加
140
+ corner-shape:round —— 原生 hover/展开态就是这个小圆角背景,写成胶囊对不上。 */
55
141
  .balance-usage-wrap{min-width:0;display:inline-flex}
56
- .balance-usage-line{box-sizing:border-box;corner-shape:round;max-width:100%;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:calc(var(--dsh-content-font-size-secondary,13px) - 1px);font-variant-numeric:tabular-nums;line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px));white-space:nowrap;background:0 0;border:none;border-radius:999px;align-items:center;gap:6px;padding:1px 8px;display:inline-flex;cursor:pointer;transition:background .15s var(--ds-ease-in-out,.2s)}
142
+ .balance-usage-line{box-sizing:border-box;max-width:100%;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:calc(var(--dsh-content-font-size-secondary,13px) - 1px);font-variant-numeric:tabular-nums;line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px));white-space:nowrap;background:0 0;border:none;border-radius:var(--dsw-radius-sm);align-items:center;gap:6px;padding:1px 8px;display:inline-flex;cursor:pointer}
57
143
  .balance-usage-line svg{flex:none;width:14px;height:14px}
58
144
  .balance-usage-line:hover,.balance-usage-line[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-secondary)}
59
145
  .balance-usage-label{text-overflow:ellipsis;min-width:0;overflow:hidden}
@@ -156,32 +242,67 @@ window.__ModuleLoader__.load({
156
242
  用插件自己的稳定类名(不依赖 DSH 的 CSS Modules hash);主题变量一律带回退值,
157
243
  变量缺失时仍然可读,不会变成透明文字。开关/按钮的真圆角要显式 corner-shape:round,
158
244
  否则会被 DSH 主题的全局 superellipse 画成方圆角。 */
159
- .balance-settings{box-sizing:border-box;display:flex;flex-direction:column;width:100%;margin:0;padding:16px 18px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.22));border-radius:var(--dsw-radius-lg,16px);background:var(--dsw-alias-bg-module-platform,transparent)}
245
+ .balance-settings{box-sizing:border-box;display:flex;flex-direction:column;width:100%;margin:0;padding:18px 20px;border:1px solid var(--dsw-alias-border-l2,rgba(127,127,127,.22));border-radius:var(--dsw-radius-lg,16px);background:var(--dsw-alias-bg-module-platform,transparent)}
160
246
  .balance-settings__head{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
161
247
  .balance-settings__title{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}
162
248
  .balance-settings__version{font-size:12px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
163
249
  .balance-settings__desc{margin:4px 0 0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
164
- .balance-settings__group{margin:14px 0 0;padding:12px 0 0;border-top:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.22))}
165
- .balance-settings__group-title{margin:0 0 2px;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary)}
250
+ .balance-settings__group{margin:18px 0 0;padding:14px 0 0;border-top:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.22))}
251
+ /* 第一个分组紧接折叠头,顶线和额外内边距都去掉,避免双线 */
252
+ .balance-settings__body > .balance-settings__group:first-child{margin-top:0;padding-top:0;border-top:0}
253
+ /* 分组标题:左侧一道 3px 品牌色竖条做视觉分隔,字号收到 11px 并加字距,比正文弱、比说明强,避免和行内容糊在一起 */
254
+ .balance-settings__group-title{display:flex;align-items:center;gap:7px;margin:0 0 4px;font-size:11px;font-weight:600;letter-spacing:.3px;color:var(--dsw-alias-label-secondary)}
255
+ /* 顶部告示条:设置页最前面的一条范围声明。底色/边框比分组说明再重一档,
256
+ 文字提到 primary,整块在深色背景里第一个跳出来。 */
257
+ .balance-settings__notice{display:flex;align-items:flex-start;gap:8px;margin:0 0 14px;padding:10px 12px;border-radius:var(--dsw-radius-md,8px);border:.5px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 34%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 13%,transparent);font-size:12.5px;line-height:18px;color:var(--dsw-alias-label-primary)}
258
+ .balance-settings__notice-icon{flex:none;margin-top:1px;color:var(--dsw-alias-brand-primary,#4d6bfe)}
259
+ .balance-settings__notice-text{min-width:0}
260
+ /* 分组说明条:不是灰小字,而是带图标的淡蓝提示条——要一眼看见「仅支持 DeepSeek Harness」。
261
+ 底色用品牌色 10%、边框 26%,文字提到 secondary;图标跟文字同色。 */
262
+ .balance-settings__group-note{display:flex;align-items:flex-start;gap:7px;margin:3px 0 5px;padding:8px 10px;border-radius:var(--dsw-radius-md,8px);border:.5px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 26%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 10%,transparent);font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary)}
263
+ .balance-settings__group-note-icon{flex:none;margin-top:1.5px;color:var(--dsw-alias-brand-primary,#4d6bfe);opacity:.9}
264
+ .balance-settings__group-note-text{min-width:0}
265
+ .balance-settings__group-title::before{content:"";flex:none;width:3px;height:11px;border-radius:1px;corner-shape:round;background:var(--dsw-alias-brand-primary,#4d6bfe);opacity:.75}
166
266
  .balance-settings__head-btn{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;box-sizing:border-box;padding:0;border:0;background:0 0;font:inherit;text-align:left;cursor:pointer;color:inherit}
167
267
  .balance-settings__head-btn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px;border-radius:var(--dsw-radius-sm,6px)}
168
268
  .balance-settings__head-text{flex:1;min-width:0}
169
269
  .balance-settings__chevron{flex:none;color:var(--dsw-alias-label-tertiary);transition:transform .16s ease}
170
270
  .balance-settings[data-open="true"] .balance-settings__chevron{transform:rotate(180deg)}
171
- .balance-settings__body{margin:12px 0 0;padding:12px 0 0;border-top:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.22))}
172
- .balance-settings__row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0}
173
- .balance-settings__label{display:flex;flex-direction:column;gap:2px;min-width:0}
271
+ .balance-settings__body{margin:10px 0 0;padding:10px 0 0;border-top:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.22))}
272
+ /* 标签行:文字 + NEW 徽标并排 */
273
+ .balance-settings__label-line{display:inline-flex;align-items:center;gap:6px;min-width:0}
274
+ /* NEW 徽标:低调底色 + 微弱呼吸光晕。
275
+ 先前用的是实心品牌色渐变 + 白字 + 三层强光晕,
276
+ 在深色设置页里太刺眼(2026-10-30 反馈「太亮了」)。
277
+ 现在:品牌色 18% 薄底 + 6px 弱光晕,呼吸幅度也收窄。 */
278
+ .balance-settings__new-badge{flex:none;box-sizing:border-box;padding:0 5px;border-radius:5px;corner-shape:round;font-size:9px;line-height:14px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 62%,#ffffff);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 18%,transparent);animation:balance-new-glow 3.2s ease-in-out infinite}
279
+ /* 呼吸式光晕:只有两层,最大 7px / 32%,足以有「光」的感觉但不刺眼 */
280
+ @keyframes balance-new-glow{
281
+ 0%,100%{box-shadow:0 0 4px color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 18%,transparent),0 0 8px color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 8%,transparent)}
282
+ 50%{box-shadow:0 0 7px color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 32%,transparent),0 0 14px color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 14%,transparent)}
283
+ }
284
+ /* 尊重减少动态效果:不呼吸,保持平均亮度 */
285
+ @media (prefers-reduced-motion:reduce){.balance-settings__new-badge{animation:none;box-shadow:0 0 5px color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 24%,transparent)}}
286
+ .balance-settings__row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:11px 0}
287
+ /* 行与行之间用半像素淡线分开,避免几行文字堆成一片 */
288
+ .balance-settings__row + .balance-settings__row{border-top:.5px solid var(--dsw-alias-border-l1,rgba(127,127,127,.12))}
289
+ .balance-settings__label{display:flex;flex-direction:column;gap:3px;min-width:0}
174
290
  .balance-settings__label-text{font-size:13px;line-height:19px;color:var(--dsw-alias-label-primary)}
175
291
  .balance-settings__hint{font-size:12px;line-height:17px;color:var(--dsw-alias-label-tertiary)}
176
292
  .balance-settings__control{display:flex;align-items:center;gap:6px;flex:none}
177
293
  .balance-settings__unit{font-size:12px;color:var(--dsw-alias-label-tertiary)}
178
- .balance-settings__input{box-sizing:border-box;width:92px;height:34px;padding:0 12px;border:.5px solid var(--dsw-alias-border-l4,rgba(127,127,127,.35));border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-bg-layer-3,rgba(127,127,127,.1));color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;font-variant-numeric:tabular-nums;outline:none}
294
+ .balance-settings__input{box-sizing:border-box;width:96px;height:34px;padding:0 10px;text-align:right;font-variant-numeric:tabular-nums;border:.5px solid var(--dsw-alias-border-l4,rgba(127,127,127,.35));border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-bg-layer-3,rgba(127,127,127,.1));color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;font-variant-numeric:tabular-nums;outline:none}
179
295
  .balance-settings__input:focus-visible{outline:none;border-color:var(--dsw-alias-state-business-primary,#4d6bfe)}
180
- .balance-settings__input--wide{width:100%;min-width:0}
296
+ .balance-settings__input--wide{width:100%;max-width:340px;min-width:0;text-align:left}
181
297
  .balance-settings__toggle{position:relative;box-sizing:border-box;flex:none;width:36px;height:20px;padding:2px;border:none;border-radius:999px;corner-shape:round;background:var(--dsw-alias-border-l3,rgba(127,127,127,.35));cursor:pointer;transition:background .12s ease}
182
298
  .balance-settings__toggle[aria-checked="true"]{background:var(--dsw-alias-brand-primary,#4d6bfe)}
183
299
  .balance-settings__toggle::after{content:"";display:block;width:16px;height:16px;border-radius:50%;corner-shape:round;background:var(--dsw-alias-switch-thumb,#fff);transition:transform .12s ease}
184
300
  .balance-settings__toggle[aria-checked="true"]::after{background:var(--dsw-alias-label-primary-foreground,#fff);transform:translateX(16px)}
301
+ /* 二选一分段控件(余额行外观 新版/旧版) */
302
+ .balance-settings__segmented{display:inline-flex;flex:none;padding:2px;gap:2px;border-radius:999px;corner-shape:round;background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14))}
303
+ .balance-settings__segment{box-sizing:border-box;padding:2px 12px;border:none;border-radius:999px;corner-shape:round;background:0 0;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;font-weight:500;cursor:pointer;white-space:nowrap;transition:background .15s var(--ds-ease-in-out,.2s),color .15s var(--ds-ease-in-out,.2s)}
304
+ .balance-settings__segment:hover{color:var(--dsw-alias-label-primary)}
305
+ .balance-settings__segment--on{background:var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-base,#2a2a2c));color:var(--dsw-alias-label-primary);box-shadow:0 1px 2px rgba(0,0,0,.2)}
185
306
  .balance-settings__actions{display:flex;align-items:center;gap:8px;margin:14px 0 0;padding:12px 0 0;border-top:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.22))}
186
307
  .balance-settings__status{flex:1;min-width:0;font-size:12px;line-height:17px;color:var(--dsw-alias-label-tertiary)}
187
308
  .balance-settings__status--error{color:var(--dsw-alias-state-error-primary,#e5484d)}
@@ -253,19 +374,22 @@ window.__ModuleLoader__.load({
253
374
  * 无 byModel 时回退到旧的单模型逻辑:高峰桶按目录价 × 倍率、空闲桶按目录价(兼容子代理/旧数据)。
254
375
  * ⚠️ p 必须是**目录价**(服务端 pricing 字段即目录价),倍率由 peakMultiplier 单独给,
255
376
  * 否则会与这里的乘法重复翻倍(高峰变 ×4)。 */
377
+ /** 网页搜索 / 抓取按次计费,不属于峰谷,不乘时段倍率。缺失时按 0。 */
378
+ function webToolCostOf(usage) {
379
+ const v = Number(usage?.webToolCost);
380
+ return Number.isFinite(v) && v > 0 ? v : 0;
381
+ }
256
382
  function computeSplitCost(usage, p, multiplier = 2) {
257
- let total = 0;
383
+ // 网页工具费单独累加:它既不属于高峰也不属于空闲,跟着总额走。
384
+ const web = webToolCostOf(usage);
258
385
  if (Array.isArray(usage.byModel) && usage.byModel.length > 0) {
386
+ let total = 0;
259
387
  for (const b of usage.byModel) {
260
388
  total += typeof b.cost === "number" && Number.isFinite(b.cost) ? b.cost : computeSingleSplitCost(b, p, multiplier);
261
389
  }
262
- } else {
263
- total = computeSingleSplitCost(usage, p, multiplier);
390
+ return total + web;
264
391
  }
265
- // 网页搜索 / 抓取按次费用:服务端已按官方「每千次 ¥5」、且只对 2026-11-01 00:00
266
- // (北京)之后的成功调用计价,这里直接累加,不再乘峰谷倍率。
267
- total += typeof usage.webToolCost === "number" && Number.isFinite(usage.webToolCost) ? usage.webToolCost : 0;
268
- return total;
392
+ return computeSingleSplitCost(usage, p, multiplier) + web;
269
393
  }
270
394
  function computeSingleSplitCost(usage, p, multiplier = 2) {
271
395
  const costOf = (u, factor) =>
@@ -516,6 +640,68 @@ window.__ModuleLoader__.load({
516
640
  void ensureCnCalendar(y);
517
641
  void ensureCnCalendar(y + 1);
518
642
  }
643
+ /** 点击目标是否落在统计层 / 平台层内部。
644
+ * 这两层虽然 portal 到 body,但 React 里仍是余额行的子节点,事件会沿组件树冒泡回来;
645
+ * 它们自带关闭逻辑,不能被当成「点了余额行」再 arm 一次打开定时器(2026-10-30 回归:
646
+ * 点关闭按钮 → 捕获阶段先 arm 230ms 打开定时器 → 按钮随即关闭 → 230ms 后又被打开,面板关不掉)。 */
647
+ function isOverlayTarget(target) {
648
+ try {
649
+ return Boolean(target && typeof target.closest === "function" && target.closest(".balance-stats-layer, .balance-platform-layer"));
650
+ } catch {
651
+ return false;
652
+ }
653
+ }
654
+
655
+ const NEW_TAG_TTL_MS = 3 * 24 * 60 * 60 * 1000; // NEW 徽标展示 3 天
656
+
657
+ /** 单项 NEW 判定。每项自带起点,互不影响:
658
+ * - record 里已记下这一版(v === intro)→ 就用 t 计时,不再判 fresh
659
+ * (否则刚记完就会因 "intro 不大于自己"而立刻消失)
660
+ * - 没记录或引入版本变了 → 比较「引入版本 vs 用户见过的版本」
661
+ * 旧功能因 fresh=false 直接不显示,所以不可能被新版本重新计时。 */
662
+ function newTagStateAt(introVersion, record, fallbackFrom, fallbackSince, nowMs) {
663
+ const intro = String(introVersion || "");
664
+ if (intro === "") return { fresh: false, show: false, since: 0 };
665
+ const rec = record && typeof record === "object" && !Array.isArray(record) ? record : null;
666
+ const recV = rec && typeof rec.v === "string" ? rec.v : "";
667
+ const recT = rec ? Number(rec.t) : NaN;
668
+ const now = Number(nowMs);
669
+ const within = (since) => {
670
+ const s = Number(since);
671
+ if (!Number.isFinite(s) || s <= 0) return true;
672
+ return now - s < NEW_TAG_TTL_MS;
673
+ };
674
+ const pick = (since) => {
675
+ const s = Number(since);
676
+ return Number.isFinite(s) && s > 0 ? s : 0;
677
+ };
678
+ // 已经按这一版记录过:用户第一次见到它就是 t,直接按 t 计时
679
+ if (recV !== "" && recV === intro) {
680
+ const since = Number.isFinite(recT) && recT > 0 ? recT : Number(fallbackSince);
681
+ return { fresh: true, show: within(since), since: pick(since) };
682
+ }
683
+ // 没记录(或引入版本变了):才判这项对用户算不算新
684
+ const baseline = recV !== "" ? recV : String(fallbackFrom || "");
685
+ const fresh = baseline === "" || compareVersion(intro, baseline) > 0;
686
+ if (!fresh) return { fresh: false, show: false, since: 0 };
687
+ const since = Number(fallbackSince);
688
+ return { fresh: true, show: within(since), since: pick(since) };
689
+ }
690
+
691
+ /** 按数字段比较版本号:1.3.10 > 1.3.9(字符串直接比会错)。
692
+ * 任一段非数字按 0 处理;相等返回 0。 */
693
+ function compareVersion(a, b) {
694
+ const pa = String(a === undefined || a === null ? "" : a).split(".");
695
+ const pb = String(b === undefined || b === null ? "" : b).split(".");
696
+ const len = Math.max(pa.length, pb.length);
697
+ for (let i = 0; i < len; i += 1) {
698
+ const x = Number.parseInt(pa[i], 10) || 0;
699
+ const y = Number.parseInt(pb[i], 10) || 0;
700
+ if (x !== y) return x > y ? 1 : -1;
701
+ }
702
+ return 0;
703
+ }
704
+
519
705
  function currentPeriod(atMs) {
520
706
  const d = new Date((atMs === undefined ? Date.now() : atMs) + 8 * 60 * 60 * 1000); // 北京时间
521
707
  const kind = CN_CALENDAR[
@@ -524,8 +710,101 @@ window.__ModuleLoader__.load({
524
710
  if (kind === "holiday") return "offpeak"; // 法定节假日:全天按空闲
525
711
  const hour = d.getUTCHours();
526
712
  const weekday = d.getUTCDay(); // 0=周日 … 6=周六
527
- const onDuty = PEAK_WEEKDAYS.includes(weekday) || kind === "workday"; // 调休上班的周末按工作日算
528
- return onDuty && PEAK_WINDOWS.some((w) => hour >= w.start && hour < w.end) ? "peak" : "offpeak";
713
+ // 与服务端 isPeakAt 同口径:官方定价页把「周末及法定节假日全天」都算空闲,
714
+ // 没有调休例外,所以 workday(调休上班的周末)也按周末处理。
715
+ return PEAK_WEEKDAYS.includes(weekday) && PEAK_WINDOWS.some((w) => hour >= w.start && hour < w.end) ? "peak" : "offpeak";
716
+ }
717
+ const BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000;
718
+ /** 当前时段的区间与剩余时间(供刻度条上方那行 meta 用)。
719
+ * 高峰 = 所在高峰窗口;空闲 = 上一个高峰结束 → 下一个高峰开始
720
+ * (可能是一整夜、整个周末或整个法定节假日)。
721
+ * @returns {{ kind: "peak"|"offpeak", start: number, end: number, remainMs: number } | null} */
722
+ function periodSpan(atMs) {
723
+ const now = atMs === undefined ? Date.now() : atMs;
724
+ const kind = currentPeriod(now);
725
+ const HOUR = 3600000;
726
+ const bj = new Date(now + BEIJING_OFFSET_MS);
727
+ const dayStart = Date.UTC(bj.getUTCFullYear(), bj.getUTCMonth(), bj.getUTCDate()) - BEIJING_OFFSET_MS;
728
+ let start = null;
729
+ let end = null;
730
+ if (kind === "peak") {
731
+ const h = (now - dayStart) / HOUR;
732
+ for (const w of PEAK_WINDOWS) {
733
+ if (h >= w.start && h < w.end) {
734
+ start = dayStart + w.start * HOUR;
735
+ end = dayStart + w.end * HOUR;
736
+ break;
737
+ }
738
+ }
739
+ }
740
+ if (start === null || end === null) {
741
+ // 空闲:向后找上一个高峰结束、向前找下一个高峰开始(最多看 60 天,覆盖长假)
742
+ let t = Math.floor(now / HOUR) * HOUR;
743
+ for (let i = 0; i < 24 * 60; i++) {
744
+ const probe = t - HOUR;
745
+ if (currentPeriod(probe) === "peak") { start = t; break; }
746
+ t = probe;
747
+ }
748
+ t = Math.floor(now / HOUR) * HOUR;
749
+ for (let i = 0; i < 24 * 60; i++) {
750
+ const probe = t + HOUR;
751
+ if (currentPeriod(probe) === "peak") { end = probe; break; }
752
+ t = probe;
753
+ }
754
+ }
755
+ if (start === null || end === null || end <= start) return null;
756
+ return { kind, start, end, remainMs: end - now };
757
+ }
758
+ /** 把剩余毫秒压成「45m」「3h12m」「2d7h」这种短文案。 */
759
+ function formatRemain(ms) {
760
+ const totalMin = Math.max(0, Math.round(ms / 60000));
761
+ const h = Math.floor(totalMin / 60);
762
+ const m = totalMin % 60;
763
+ if (h <= 0) return m + "m";
764
+ if (h < 24) return m === 0 ? h + "h" : h + "h" + m + "m";
765
+ const d = Math.floor(h / 24);
766
+ const rh = h % 24;
767
+ return rh === 0 ? d + "d" : d + "d" + rh + "h";
768
+ }
769
+ // ─── 时段刻度条:色带固定 = 一整天,指针随时刻从左走到右 ────────────────
770
+ // 轨道铺满当天 00:00 → 次日 00:00 的时段色带(不滚动),指针位置 = 现在时刻在当天的比例。
771
+ // 指针每 20s 前进一次,CSS transition 补成连续滑动;跨零点时自动跳到新一天的起点。
772
+ const DAY_MS = 24 * 3600000;
773
+ /** 生成刻度条:当天色带渐变 + 时段文字 + 指针位置。
774
+ * @returns {{ gradient: string, pointerPct: number }} */
775
+ function buildTimeline(now) {
776
+ const HOUR = 3600000;
777
+ // 当天 00:00(北京时间)。BEIJING_OFFSET_MS 东为正,先平移到北京再取整到天。
778
+ const bj = now + BEIJING_OFFSET_MS;
779
+ const dayStart = Math.floor(bj / DAY_MS) * DAY_MS - BEIJING_OFFSET_MS;
780
+ const dayEnd = dayStart + DAY_MS;
781
+ // 逐小时采样找出切换点(官方时段都在整点切换,1 小时精度足够)
782
+ const stops = [];
783
+ let prevKind = currentPeriod(dayStart);
784
+ for (let t = dayStart + HOUR; t < dayEnd; t += HOUR) {
785
+ const k = currentPeriod(t);
786
+ if (k !== prevKind) {
787
+ stops.push({ at: t, kind: k });
788
+ prevKind = k;
789
+ }
790
+ }
791
+ const peakColor = "var(--dsw-alias-state-error-primary,#e5484d)";
792
+ const offpeakColor = "var(--dsw-alias-state-success-primary,#22c55e)";
793
+ const colorOf = (k) => (k === "peak" ? peakColor : offpeakColor);
794
+ const pct = (ms) => ((ms - dayStart) / DAY_MS) * 100;
795
+ const parts = [];
796
+ let from = dayStart;
797
+ let kind = currentPeriod(dayStart);
798
+ for (const s of stops) {
799
+ parts.push(colorOf(kind) + " " + pct(from).toFixed(2) + "% " + pct(s.at).toFixed(2) + "%");
800
+ from = s.at;
801
+ kind = s.kind;
802
+ }
803
+ parts.push(colorOf(kind) + " " + pct(from).toFixed(2) + "% 100%");
804
+ return {
805
+ gradient: "linear-gradient(90deg," + parts.join(",") + ")",
806
+ pointerPct: pct(now),
807
+ };
529
808
  }
530
809
  /** 按当前时段调整价格:高峰 × 倍率、空闲原价。
531
810
  * ⚠️ p 必须是**目录价**(服务端 pricing 字段即目录价);倍率来自设置的 peakMultiplier,
@@ -552,6 +831,14 @@ window.__ModuleLoader__.load({
552
831
  showUsagePill: true,
553
832
  showPeriodLabel: true,
554
833
  showFab: true,
834
+ footerStyle: "timeline",
835
+ showSidebarEntry: true,
836
+ refreshWave: true,
837
+ entrySeen: false,
838
+ newTagVersion: "",
839
+ newTagSince: 0,
840
+ newTagFrom: "",
841
+ newTagSeen: {},
555
842
  fabSize: 60,
556
843
  fabLeft: 44,
557
844
  statsSessionLimit: 200,
@@ -625,6 +912,46 @@ window.__ModuleLoader__.load({
625
912
  return settingsCache.value;
626
913
  }
627
914
 
915
+ // ─── 今日消耗:随余额一起刷新(服务端 60s 缓存,只扫今天动过的会话) ──
916
+ const todayCache = { value: null, inFlight: null, at: 0 };
917
+ const todayListeners = new Set();
918
+ function notifyToday() {
919
+ for (const listener of todayListeners) listener();
920
+ }
921
+ /** 拉一次今日消耗。失败/未支持时保持 null(meta 行只显示时段与剩余时间)。 */
922
+ function fetchTodayOnce(force) {
923
+ if (!force && Date.now() - todayCache.at < 55000) return Promise.resolve(todayCache.value);
924
+ if (todayCache.inFlight !== null) return todayCache.inFlight;
925
+ todayCache.inFlight = (async () => {
926
+ try {
927
+ const res = await fetch("/balance-api?today=1", { headers: { accept: "application/json" } });
928
+ if (!res.ok) return todayCache.value;
929
+ const data = await res.json();
930
+ if (data && typeof data.today === "object" && data.today !== null) {
931
+ todayCache.value = data.today;
932
+ todayCache.at = Date.now();
933
+ notifyToday();
934
+ }
935
+ } catch {
936
+ // 静默:今日消耗拿不到就不显示,不影响余额
937
+ } finally {
938
+ todayCache.inFlight = null;
939
+ }
940
+ return todayCache.value;
941
+ })();
942
+ return todayCache.inFlight;
943
+ }
944
+ function useToday() {
945
+ const [, force] = react.useState(0);
946
+ react.useEffect(() => {
947
+ const listener = () => force((n) => n + 1);
948
+ todayListeners.add(listener);
949
+ void fetchTodayOnce(false);
950
+ return () => todayListeners.delete(listener);
951
+ }, []);
952
+ return todayCache.value;
953
+ }
954
+
628
955
  // ─── 余额数据:模块级共享缓存 + 轮询(间隔取自 refreshIntervalSec) ────
629
956
  // 切换会话时 header 组件会重新挂载,若每个实例各自拉取会先消失再出现(闪烁);
630
957
  // 缓存全局存一份,任何实例挂载都直接读缓存,无需重新请求。
@@ -696,10 +1023,65 @@ window.__ModuleLoader__.load({
696
1023
 
697
1024
  // 右键余额:阻止默认菜单、立即显示"刷新中"反馈(防止连按),并触发刷新(防抖合并)。
698
1025
  // 仅当本次点击真的会发起请求时才显示反馈,避免被防抖合并的点击留下悬挂的"刷新中"状态。
1026
+ // 浪潮计数器:右键时自增,余额行把它当 key 用,key 变 → 波浪层重挂 → 动画从头播。
1027
+ // 之所以不用 animation 的重播属性,是因为 key 重挂最稳,且能保证连点每次都重新触发。
1028
+ let waveSeq = 0;
1029
+ const waveListeners = new Set();
1030
+ function emitWave() {
1031
+ for (const listener of waveListeners) listener();
1032
+ }
1033
+
1034
+ // 手动刷新窗口:右键时记下时间戳,随后到来的那次余额变化算"手动",
1035
+ // 走白条抹除;超出窗口或窗口内没等来变化,就都按自动刷新处理 —— 走数字滚轮。
1036
+ // 两条动效互斥,不会同时出现。
1037
+ const MANUAL_WAVE_WINDOW_MS = 4000;
1038
+
1039
+ // 网页搜索 / 抓取:官方 2026-10-17 上线独立计费,¥5 / 千次 = ¥0.005 / 次,
1040
+ // 北京时间 2026-11-01 00:00 起开始收费(此前免费)。与 lib/index.js 同名常量保持一致。
1041
+ const WEB_TOOL_BILLING_START = Date.parse("2026-11-01T00:00:00+08:00");
1042
+ let manualWaveAt = 0;
1043
+ // 抹除动效时长,必须与 CSS 里 balance-wipe-head 的 .58s 一致。
1044
+ // 改动画时长时这里也要改,否则锁的时间对不上(太短会重播打断,太长会像"点了没反应")。
1045
+ const WAVE_DURATION_MS = 580;
1046
+ // 动效播放中的排他锁:这段时间内的右键一律忽略。
1047
+ // 目的是保证「一次抹除 = 一次真实刷新」,既不出现"白条扫完余额没动",
1048
+ // 也不出现"余额变了但动效没播/播了一半被打断"(2026-10-30 反馈)。
1049
+ let manualRefreshLockedUntil = 0;
1050
+
1051
+ function reducedMotion() {
1052
+ try {
1053
+ return typeof window !== "undefined" && typeof window.matchMedia === "function"
1054
+ && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1055
+ } catch {
1056
+ return false;
1057
+ }
1058
+ }
1059
+ // 系统开了"减少动态效果"时 CSS 那份动画本来就是 none,锁个 580ms 只会像卡住,所以放行。
1060
+ function waveLockDuration() {
1061
+ return reducedMotion() ? 0 : WAVE_DURATION_MS;
1062
+ }
1063
+
699
1064
  function onBalanceContextMenu(event) {
700
1065
  if (event && typeof event.preventDefault === "function") event.preventDefault();
701
1066
  const now = Date.now();
1067
+ // 浪潮特效只在 timeline 版且没关时才播。关掉动效就没有可播的东西,
1068
+ // 下面两条限制一并取消,回到原来纯 800ms 防抖的行为。
1069
+ const waveOn = settingsCache.value.refreshWave !== false;
1070
+ // ① 动效还没播完:整次右键作废(不动效、不刷新余额),等它播完再让下一次进来。
1071
+ if (waveOn && now < manualRefreshLockedUntil) return;
1072
+ // ② 这次点击是否真的会发出请求——判据与 fetchBalanceOnce 内部完全一致。
702
1073
  const willFetch = !balanceFetchInFlight && now - lastBalanceFetchAt >= BALANCE_REFRESH_MIN_INTERVAL;
1074
+ // ③ 会被 800ms 防抖吃掉、根本不会刷新的点击,连动效也不播。
1075
+ // 否则白条扫了一遍而余额纹丝不动,看起来像"动了但没扣费"。
1076
+ if (waveOn && !willFetch) return;
1077
+ if (waveOn) {
1078
+ manualRefreshLockedUntil = now + waveLockDuration();
1079
+ manualWaveAt = now;
1080
+ waveSeq += 1;
1081
+ emitWave();
1082
+ }
1083
+ // 白条是"你点了,我扫一遍"的即时反馈;轮询/对话触发的自动刷新不扫光,
1084
+ // 改用数字上下滚动(见 BalanceFooter 里的 roll)。
703
1085
  if (willFetch && !balanceCache.refreshing) {
704
1086
  balanceCache = { ...balanceCache, refreshing: true };
705
1087
  emitBalance();
@@ -926,8 +1308,15 @@ window.__ModuleLoader__.load({
926
1308
  offpeakTokens: bucketTokens(t.offpeak),
927
1309
  peakCost: num(t.peakCost),
928
1310
  offpeakCost: num(t.offpeakCost),
1311
+ // 网页搜索 / 抓取:按次计费,不计入峰谷
1312
+ webSearchCalls: num(t.webSearchCalls),
1313
+ webFetchCalls: num(t.webFetchCalls),
1314
+ webSearchBillable: num(t.webSearchBillable),
1315
+ webFetchBillable: num(t.webFetchBillable),
1316
+ webToolCost: num(t.webToolCost),
929
1317
  };
930
1318
  }, [days, total]);
1319
+ const webBillingStarted = Date.now() >= WEB_TOOL_BILLING_START;
931
1320
  const fmtCost = (v) => "¥" + num(v).toFixed(4);
932
1321
  const denomCost = num(total && total.cost) > 0 ? num(total.cost) : 1;
933
1322
  const rangeDays = Number(stats && stats.rangeDays) || 90;
@@ -1017,8 +1406,27 @@ window.__ModuleLoader__.load({
1017
1406
  ],
1018
1407
  })
1019
1408
  : null,
1409
+ derived.webSearchCalls + derived.webFetchCalls > 0
1410
+ ? jsx.jsxs(StatsGroup, {
1411
+ index: 3,
1412
+ title: "网页工具",
1413
+ hint: webBillingStarted ? "¥5 / 千次" : "免费期内",
1414
+ children: [
1415
+ jsx.jsx(StatsRow, {
1416
+ name: "Search / Fetch",
1417
+ value: derived.webSearchCalls + " / " + derived.webFetchCalls + " 次",
1418
+ }),
1419
+ jsx.jsx(StatsRow, {
1420
+ name: "网页工具费用",
1421
+ value: webBillingStarted
1422
+ ? fmtCost(derived.webToolCost) + " · 计费 " + (derived.webSearchBillable + derived.webFetchBillable) + " 次"
1423
+ : "免费期内 · 11-01 起按 ¥5 / 千次计费",
1424
+ }),
1425
+ ],
1426
+ })
1427
+ : null,
1020
1428
  jsx.jsxs(StatsGroup, {
1021
- index: 3,
1429
+ index: 4,
1022
1430
  title: "近期消耗",
1023
1431
  children: [
1024
1432
  jsx.jsx(StatsRow, {
@@ -1036,7 +1444,7 @@ window.__ModuleLoader__.load({
1036
1444
  ],
1037
1445
  }),
1038
1446
  jsx.jsxs(StatsGroup, {
1039
- index: 4,
1447
+ index: 5,
1040
1448
  title: "日均与峰值",
1041
1449
  children: [
1042
1450
  // 日均 = 总额 / 活跃天,没有「占比」语义,因此不画条(否则是条空轨道)
@@ -1117,6 +1525,7 @@ window.__ModuleLoader__.load({
1117
1525
  });
1118
1526
  }
1119
1527
 
1528
+ // ─── 旧版余额行用的钱包图标(footerStyle === "classic" 时才渲染) ──────
1120
1529
  function WalletIcon() {
1121
1530
  return jsx.jsxs("svg", {
1122
1531
  width: 16,
@@ -1141,6 +1550,75 @@ window.__ModuleLoader__.load({
1141
1550
  function BalanceFooter({ wide }) {
1142
1551
  const state = useBalance();
1143
1552
  const settings = useSettings();
1553
+ const today = useToday();
1554
+ // 订阅浪潮计数器:右键时递增,触发重渲染;波浪层用 waveSeq 当 key 重挂来重播动画
1555
+ const [, forceWave] = react.useState(0);
1556
+ react.useEffect(() => {
1557
+ const listener = () => forceWave((n) => n + 1);
1558
+ waveListeners.add(listener);
1559
+ return () => waveListeners.delete(listener);
1560
+ }, []);
1561
+
1562
+ // ── 自动刷新的数字滚轮 ────────────────────────────────────────────────
1563
+ // 金额一变化就播一次:旧值向上滑出、新值从下方滑入,像翻页计数器。
1564
+ // 手动刷新(4s 内右键过)由白条负责,这里直接跳过,避免两种动效打架。
1565
+ const rollText = state.status !== "error" && state.balance
1566
+ ? String(state.balance.total_balance ?? "") + " " + (state.balance.currency || "CNY")
1567
+ : null;
1568
+ const [roll, setRoll] = react.useState(null);
1569
+ const lastRollTextRef = react.useRef(null);
1570
+ react.useEffect(() => {
1571
+ const prev = lastRollTextRef.current;
1572
+ lastRollTextRef.current = rollText;
1573
+ if (rollText === null || prev === null || prev === rollText) return;
1574
+ const manual = manualWaveAt !== 0 && Date.now() - manualWaveAt < MANUAL_WAVE_WINDOW_MS;
1575
+ manualWaveAt = 0;
1576
+ if (manual) return;
1577
+ setRoll({ from: prev, to: rollText, at: Date.now() });
1578
+ }, [rollText]);
1579
+
1580
+ // 逐位拆开:只有真正变了的数位才做上下滚动,其余(小数点、货币后缀、
1581
+ // 以及没变的高位数字)原样静止。所以花掉 0.01 时只有末位在翻,
1582
+ // 花掉 1 块才会带动个位 —— 滚动范围跟着实际扣减的量走。
1583
+ //
1584
+ // 关键在于"按小数点对齐"而不是整串左对齐:
1585
+ // 11.04 CNY → 8.04 CNY,左对齐会变成 1↔8 / 1↔. / .↔0 …整串错位,
1586
+ // 按小数点分三段(整数右对齐、小数点一位、小数左对齐)之后,
1587
+ // 整数位各自对位、小数 .04 原样不动,只有该变的位在翻。
1588
+ const rollCells = roll === null ? null : (() => {
1589
+ const split = (text) => {
1590
+ const m = /^([0-9]*)(\.?)([0-9]*)([\s\S]*)$/.exec(text);
1591
+ return m
1592
+ ? { int: m[1], dot: m[2], frac: m[3], tail: m[4] }
1593
+ : { int: "", dot: "", frac: "", tail: text };
1594
+ };
1595
+ const a = split(roll.from);
1596
+ const b = split(roll.to);
1597
+ const EMPTY = "\u0000";
1598
+ const padLeft = (s, n) => s.padStart(n, EMPTY);
1599
+ const padRight = (s, n) => s.padEnd(n, EMPTY);
1600
+ const intLen = Math.max(a.int.length, b.int.length);
1601
+ const fracLen = Math.max(a.frac.length, b.frac.length);
1602
+ const tailLen = Math.max(a.tail.length, b.tail.length);
1603
+ const rows = [
1604
+ [padLeft(a.int, intLen), padLeft(b.int, intLen)],
1605
+ [a.dot || b.dot, b.dot || a.dot],
1606
+ [padRight(a.frac, fracLen), padRight(b.frac, fracLen)],
1607
+ [padRight(a.tail, tailLen), padRight(b.tail, tailLen)],
1608
+ ];
1609
+ const cells = [];
1610
+ for (const [left, right] of rows) {
1611
+ for (let i = 0; i < left.length; i++) {
1612
+ cells.push({ from: left[i], to: right[i], changed: left[i] !== right[i] });
1613
+ }
1614
+ }
1615
+ return cells;
1616
+ })();
1617
+ react.useEffect(() => {
1618
+ if (roll === null) return;
1619
+ const id = setTimeout(() => setRoll(null), 480);
1620
+ return () => clearTimeout(id);
1621
+ }, [roll]);
1144
1622
  // 设置里的外观值:夹到安全范围,手改 JSON 写出畸形值也不会把 UI 搞坏
1145
1623
  const rawSize = Number(settings.fabSize);
1146
1624
  const fabSize = Number.isFinite(rawSize) ? Math.min(120, Math.max(32, rawSize)) : 60;
@@ -1153,6 +1631,41 @@ window.__ModuleLoader__.load({
1153
1631
  const fabFontDense = Math.max(8, Math.round((fabSize * 11) / 60));
1154
1632
  const [statsOpen, setStatsOpen] = react.useState(false);
1155
1633
  const [statsClosing, setStatsClosing] = react.useState(false);
1634
+ // 时间轴的"此刻":每分钟推进一次(剩余时间“剩 5h40m”的分钟位需要这个节拍)。
1635
+ // 必须对齐到整分钟:若用 setInterval 从挂载时刻起算,刷新点会漂在任意秒数,
1636
+ // 且与真实时间最多错开 60 秒——在 12:00 / 14:00 / 18:00 这些峰谷边界上,
1637
+ // 会出现“标签还在空闲、剩余时间已归零”的矛盾画面。
1638
+ const [timelineNow, setTimelineNow] = react.useState(() => Date.now());
1639
+ react.useEffect(() => {
1640
+ let timer = null;
1641
+ const tick = () => {
1642
+ setTimelineNow(Date.now());
1643
+ // 距下一个整分钟的毫秒数;再自校正一次,避免 setTimeout 累计漂移
1644
+ timer = setTimeout(tick, 60000 - (Date.now() % 60000) + 30);
1645
+ };
1646
+ timer = setTimeout(tick, 60000 - (Date.now() % 60000) + 30);
1647
+ // 窗口最小化 / 切换回来时补一次:后台定时器可能被浏览器节流,回来时先对齐
1648
+ const onVisible = () => {
1649
+ if (typeof document === "undefined" || document.visibilityState !== "hidden") {
1650
+ setTimelineNow(Date.now());
1651
+ }
1652
+ };
1653
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
1654
+ document.addEventListener("visibilitychange", onVisible);
1655
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
1656
+ window.addEventListener("focus", onVisible);
1657
+ }
1658
+ }
1659
+ return () => {
1660
+ if (timer !== null) clearTimeout(timer);
1661
+ if (typeof document !== "undefined" && typeof document.removeEventListener === "function") {
1662
+ document.removeEventListener("visibilitychange", onVisible);
1663
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
1664
+ window.removeEventListener("focus", onVisible);
1665
+ }
1666
+ }
1667
+ };
1668
+ }, []);
1156
1669
  const clickTimer = react.useRef(null);
1157
1670
  const closeTimer = react.useRef(null);
1158
1671
  const anchorRef = react.useRef(null);
@@ -1203,6 +1716,11 @@ window.__ModuleLoader__.load({
1203
1716
  }, [wide, fabSize]);
1204
1717
  const closeStats = react.useCallback(() => {
1205
1718
  if (!statsOpen || statsClosing) return;
1719
+ // 任何关闭路径都先撤销待执行的「打开」定时器,避免关掉后又被自己弹回来
1720
+ if (clickTimer.current !== null) {
1721
+ clearTimeout(clickTimer.current);
1722
+ clickTimer.current = null;
1723
+ }
1206
1724
  setStatsClosing(true);
1207
1725
  if (closeTimer.current !== null) clearTimeout(closeTimer.current);
1208
1726
  closeTimer.current = setTimeout(() => {
@@ -1241,6 +1759,7 @@ window.__ModuleLoader__.load({
1241
1759
  openPlatformOrUrl("usage", USAGE_URL);
1242
1760
  };
1243
1761
  // 余额元素的交互。悬停提示不用原生 title,统一走主题化浮层(见 withPanel)。
1762
+ // 余额元素的交互。悬停提示不用原生 title,统一走主题化浮层(见 withPanel)。
1244
1763
  const interactions = {
1245
1764
  // 单击打开统计面板;顺便阻止 <a href> 的默认跳转(否则会同时弹系统浏览器)
1246
1765
  onClick: (event) => {
@@ -1250,10 +1769,32 @@ window.__ModuleLoader__.load({
1250
1769
  onDoubleClick: openUsagePage,
1251
1770
  onContextMenu: onBalanceContextMenu,
1252
1771
  };
1253
- const withPanel = (node, hint, anchorStyle) => jsx.jsxs("div", {
1772
+ // 展开态整行用的是同一套动作,但挂到 .balance-stats-anchor(primitives.Tooltip 的外层)
1773
+ // 并走捕获阶段 —— 内层任何元素都吃不掉事件,Tooltip 也覆盖不到这层 props
1774
+ // (2026-10-30 反馈「整栏显示了,只有右侧余额胶囊可点击」就是挂在 cloneElement 目标那层惹的)。
1775
+ const rowInteractions = {
1776
+ onClickCapture: (event) => {
1777
+ if (isOverlayTarget(event && event.target)) return;
1778
+ interactions.onClick(event);
1779
+ },
1780
+ onDoubleClick: (event) => {
1781
+ if (isOverlayTarget(event && event.target)) return;
1782
+ interactions.onDoubleClick(event);
1783
+ },
1784
+ onContextMenu: (event) => {
1785
+ if (isOverlayTarget(event && event.target)) return;
1786
+ interactions.onContextMenu(event);
1787
+ },
1788
+ };
1789
+ // extraProps:展开态把「单击统计 / 双击用量页 / 右键刷新」挂到 anchor 这一层。
1790
+ // 之前挂在内部的 .balance-footer 上,而那一层正是 primitives.Tooltip 的 cloneElement 目标,
1791
+ // props 可能被 Tooltip 的触发逻辑覆盖,结果只有金额胶囊响应、整行看着能点却点不动
1792
+ // (2026-10-30 反馈)。提到 Tooltip 外层 + 捕获阶段,双保险。
1793
+ const withPanel = (node, hint, anchorStyle, extraProps) => jsx.jsxs("div", {
1254
1794
  ref: anchorRef,
1255
1795
  className: "balance-stats-anchor balance-stats-anchor--" + (wide ? "wide" : "compact"),
1256
1796
  style: anchorStyle,
1797
+ ...(extraProps || null),
1257
1798
  children: [
1258
1799
  // 悬停提示走 DSH 主题化浮层:primitives.Tooltip 内部是 cloneElement(children, {ref}),
1259
1800
  // 不会插入额外 DOM 层,因此不破坏余额行在 flex 容器里的 42px 布局(浮层本身 portal 到 body)。
@@ -1316,17 +1857,25 @@ window.__ModuleLoader__.load({
1316
1857
  // 展开态余额行:设置里关掉就不渲染(收起态圆钮由上面的 showFab 单独控制)
1317
1858
  if (settings.showBalanceRow === false) return null;
1318
1859
 
1860
+ // 右键刷新的浪潮层:只服务新版(timeline)。旧版保持备份里的原样反馈——
1861
+ // 时段文字换成灰色「刷新中…」,不扫光。key 绑定 waveSeq,右键自增 → 重挂 → 动画重播。
1862
+ const waveNode = settings.refreshWave === false
1863
+ ? null
1864
+ : jsx.jsx("span", { className: "balance-footer__wave", key: "wave-" + waveSeq });
1865
+
1319
1866
  if (state.status === "error") {
1320
- return withPanel(jsx.jsx("div", {
1867
+ return withPanel(jsx.jsxs("div", {
1321
1868
  className: "balance-footer",
1322
1869
  ...interactions,
1323
- // 失败原因统一走主题化浮层,避免把长错误塞进 42px 的余额行
1324
- children: jsx.jsx(primitives.Tooltip, {
1325
- label: state.error || "余额查询失败",
1326
- side: "top",
1327
- delayMs: 200,
1328
- children: jsx.jsx("span", { className: "balance-footer__error", children: "余额读取失败" }),
1329
- }),
1870
+ children: [
1871
+ // 失败原因统一走主题化浮层,避免把长错误塞进 42px 的余额行
1872
+ jsx.jsx(primitives.Tooltip, {
1873
+ label: state.error || "余额查询失败",
1874
+ side: "top",
1875
+ delayMs: 200,
1876
+ children: jsx.jsx("span", { className: "balance-footer__error", children: "余额读取失败" }),
1877
+ }),
1878
+ ],
1330
1879
  }));
1331
1880
  }
1332
1881
  if (!state.balance) {
@@ -1343,8 +1892,7 @@ window.__ModuleLoader__.load({
1343
1892
  className: "balance-footer",
1344
1893
  ...interactions,
1345
1894
  children: [
1346
- jsx.jsx("span", { className: "balance-footer__icon", children: jsx.jsx(WalletIcon, {}) }),
1347
- jsx.jsx("span", { className: "balance-footer__label", children: "余额" }),
1895
+ jsx.jsx("span", { className: "balance-footer__spacer" }),
1348
1896
  jsx.jsx("a", {
1349
1897
  className: "balance-footer__topup",
1350
1898
  ...externalLink(TOP_UP_URL, "top-up", {
@@ -1359,30 +1907,126 @@ window.__ModuleLoader__.load({
1359
1907
  }));
1360
1908
  }
1361
1909
 
1362
- const period = currentPeriod();
1910
+ // 与 periodSpan / buildTimeline 同源都用 timelineNow:三者必须指向同一时刻,
1911
+ // 否则边界那一分钟会出现“标签已是高峰、剩余时间还按空闲算”的自相矛盾。
1912
+ const period = currentPeriod(timelineNow);
1363
1913
  const low = total > 0 && total < lowThreshold;
1364
- // 时段标签可关:关掉后只留金额(「刷新中…」的反馈也一起隐藏,保持一行干净)
1365
- const periodNode = settings.showPeriodLabel === false
1366
- ? null
1367
- : state.refreshing
1368
- ? jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--refreshing", children: "刷新中…" })
1369
- : jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--" + period, children: period === "peak" ? "高峰" : "空闲" });
1914
+ // 时段模块:上方一行 meta(今日消耗 · 时段 · 剩余时间)+ 下方扁刻度条。
1915
+ // 色带固定 = 当天完整时段,指针 = 此刻在当天的位置。
1916
+ const span = periodSpan(timelineNow);
1917
+ const metaItems = [];
1918
+ if (today !== null && Number.isFinite(Number(today.cost))) {
1919
+ metaItems.push({ text: "今日 ¥" + Number(today.cost).toFixed(2), cls: "" });
1920
+ }
1921
+ metaItems.push({
1922
+ text: period === "peak" ? "高峰" : "空闲",
1923
+ cls: " balance-footer__timeline-meta-item--" + period,
1924
+ });
1925
+ if (span !== null) metaItems.push({ text: "剩 " + formatRemain(span.remainMs), cls: "" });
1926
+ const metaNode = jsx.jsx("span", {
1927
+ className: "balance-footer__timeline-meta",
1928
+ children: metaItems.map((item, i) =>
1929
+ jsx.jsx("span", { className: "balance-footer__timeline-meta-item" + item.cls, children: item.text }, item.text + i)
1930
+ ),
1931
+ });
1932
+ // 新版把「meta 行 + 刻度条」当结构主体,不存在"关掉只剩金额"的形态,
1933
+ // 所以这里不看 showPeriodLabel;那个开关只在旧版有意义(见下面的 classic 分支)。
1934
+ const timelineBand = buildTimeline(timelineNow);
1935
+ // 刷新时不替换时段模块:刻度条保持原样,刷新反馈只落在右侧金额(半透明 + 浪潮)。
1936
+ const periodNode = jsx.jsxs("span", {
1937
+ className: "balance-footer__timeline",
1938
+ children: [
1939
+ metaNode,
1940
+ jsx.jsxs("span", {
1941
+ className: "balance-footer__timeline-track balance-footer__timeline-track--" + period,
1942
+ children: [
1943
+ // 裁剪层让四周各留 1px,颜色不贴边框
1944
+ jsx.jsxs("span", {
1945
+ className: "balance-footer__timeline-clip",
1946
+ children: [
1947
+ // 泛光:同一渐变模糊后叠在色带上面,靠柔边往外散光
1948
+ jsx.jsx("span", {
1949
+ className: "balance-footer__timeline-glow",
1950
+ style: { background: timelineBand.gradient },
1951
+ }),
1952
+ // 色带本体:纯色块,不带任何文字
1953
+ jsx.jsx("span", {
1954
+ className: "balance-footer__timeline-band",
1955
+ style: { background: timelineBand.gradient },
1956
+ }),
1957
+ ],
1958
+ }),
1959
+ // 指针:位置 = 此刻在当天的比例,随时间从左走到右
1960
+ jsx.jsx("span", {
1961
+ className: "balance-footer__timeline-pointer",
1962
+ style: { left: timelineBand.pointerPct.toFixed(3) + "%" },
1963
+ }),
1964
+ ],
1965
+ }),
1966
+ ],
1967
+ });
1968
+ // 布局:左侧 [meta + 刻度条] 竖排,右侧金额。行高不够时整块自动变高。
1969
+ // 旧版样式(footerStyle === "classic"):回到早期那版「钱包图标 + 余额 + 金额」的纯文字行,
1970
+ // 时段提示退化成金额前的一小段文字,不再画刻度条。
1971
+ if (settings.footerStyle === "classic") {
1972
+ // 逐字还原 1.3.1 备份里的旧版:图标 + 「余额」 + 单个胶囊,胶囊内是「金额 • 时段」。
1973
+ // 胶囊靠 margin-left:auto 推到右缘(见 .balance-footer--classic 规则),
1974
+ // 刷新反馈也照备份:时段文字变成灰色「刷新中…」,不扫光、不改透明度。
1975
+ const classicPeriod = settings.showPeriodLabel === false
1976
+ ? null
1977
+ : state.refreshing
1978
+ ? jsx.jsx("span", { className: "balance-footer__period-inline balance-footer__period-inline--refreshing", children: "刷新中…" })
1979
+ : jsx.jsx("span", {
1980
+ className: "balance-footer__period-inline balance-footer__period-inline--" + period,
1981
+ children: period === "peak" ? "高峰" : "空闲",
1982
+ });
1983
+ return withPanel(jsx.jsxs("div", {
1984
+ className: "balance-footer balance-footer--classic",
1985
+ children: [
1986
+ jsx.jsx("span", { className: "balance-footer__icon", children: jsx.jsx(WalletIcon, {}) }),
1987
+ jsx.jsx("span", { className: "balance-footer__label", children: "余额" }),
1988
+ jsx.jsxs("span", {
1989
+ className: "balance-footer__value" + (low ? " balance-footer__value--low" : " balance-footer__value--healthy"),
1990
+ children: [
1991
+ totalText,
1992
+ // 分隔点用元素而不是字符串:inline-flex 会把匿名文本条目的首尾空白按规范去掉,
1993
+ // 写成 " • " 就只剩一个紧贴的圆点。间距交给 CSS margin,两侧才能真对称。
1994
+ classicPeriod === null ? null : jsx.jsx("span", { className: "balance-footer__sep", children: "•" }),
1995
+ classicPeriod,
1996
+ ],
1997
+ }),
1998
+ ],
1999
+ }), undefined, undefined, rowInteractions);
2000
+ }
1370
2001
  return withPanel(jsx.jsxs("div", {
1371
- className: "balance-footer",
1372
- ...interactions,
2002
+ className: "balance-footer balance-footer--stacked",
1373
2003
  children: [
1374
- jsx.jsx("span", { className: "balance-footer__icon", children: jsx.jsx(WalletIcon, {}) }),
1375
- jsx.jsx("span", { className: "balance-footer__label", children: "余额" }),
1376
- jsx.jsxs("span", {
1377
- className: "balance-footer__value" + (low ? " balance-footer__value--low" : " balance-footer__value--healthy"),
1378
- children: [
1379
- totalText,
1380
- periodNode === null ? null : " • ",
1381
- periodNode,
1382
- ],
2004
+ // 抹除层覆盖整块显示区(meta 行 + 时段条 + 金额):白条经过哪里,哪里才显影
2005
+ waveNode,
2006
+ periodNode === null
2007
+ ? jsx.jsx("span", { className: "balance-footer__spacer" })
2008
+ : periodNode,
2009
+ jsx.jsx("span", {
2010
+ className: "balance-footer__value" + (low ? " balance-footer__value--low" : " balance-footer__value--healthy") + (state.refreshing ? " balance-footer__value--stale" : ""),
2011
+ children: rollCells === null
2012
+ ? totalText
2013
+ : jsx.jsx("span", {
2014
+ className: "balance-footer__roll",
2015
+ key: "roll-" + roll.at,
2016
+ children: rollCells.map((cell, i) =>
2017
+ cell.changed
2018
+ ? jsx.jsxs("span", { className: "balance-footer__dcell balance-footer__dcell--roll", children: [
2019
+ // 影子撑住这一格的尺寸(等于新字符),不显示
2020
+ jsx.jsx("span", { className: "balance-footer__dghost", children: cell.to === "\u0000" ? "\u00a0" : cell.to }),
2021
+ jsx.jsx("span", { className: "balance-footer__dout", children: cell.from === "\u0000" ? "\u00a0" : cell.from }),
2022
+ jsx.jsx("span", { className: "balance-footer__din", children: cell.to === "\u0000" ? "\u00a0" : cell.to }),
2023
+ ] }, i)
2024
+ : jsx.jsx("span", { className: "balance-footer__dcell", children: cell.to === "\u0000" ? "\u00a0" : cell.to }, i)
2025
+ ),
2026
+ }),
1383
2027
  }),
1384
2028
  ],
1385
- }));
2029
+ }), undefined, undefined, rowInteractions);
1386
2030
  }
1387
2031
 
1388
2032
  // ─── 当前会话消耗:输入区 dock ──────────────────────────────────────────
@@ -1396,9 +2040,12 @@ function UsageCostLine({ useProjection, sessionId }) {
1396
2040
  })();
1397
2041
  const model = useCurrentModel();
1398
2042
  const usage = typeof useProjection === "function" ? useProjection("tokenUsage") : undefined;
1399
- const [splitUsage, setSplitUsage] = react.useState(null);
1400
- const [subagentUsage, setSubagentUsage] = react.useState(null);
1401
- const [splitReady, setSplitReady] = react.useState(false);
2043
+ // 分时段分桶按「会话 → 分桶」存档。切换会话时 React 不会重建组件,旧会话的分桶
2044
+ // 会留在 state 里;不带会话归属就会拿上一个会话的金额当本会话显示
2045
+ // (切对话瞬间金额先跳到别的数字、再跳回正确值——2026-10-30 反馈)。
2046
+ // 存成表而不是单值:来回切会话时命中缓存,立刻就是正确金额,不闪。
2047
+ const SPLIT_CACHE_MAX = 6;
2048
+ const [splitMap, setSplitMap] = react.useState(() => ({}));
1402
2049
  // 消耗明细面板:**点击展开**(对齐 DSH 原生「会话统计」的交互——点开切换 + 选中态 + portal 浮层)
1403
2050
  const [detailsOpen, setDetailsOpen] = react.useState(false);
1404
2051
  const detailsAnchorRef = react.useRef(null);
@@ -1454,11 +2101,14 @@ function UsageCostLine({ useProjection, sessionId }) {
1454
2101
  let disposed = false;
1455
2102
  fetchSessionUsage(sessionId).then((u) => {
1456
2103
  if (disposed) return;
1457
- if (u) {
1458
- setSplitUsage(u.usage);
1459
- setSubagentUsage(u.subagentUsage);
1460
- }
1461
- setSplitReady(true);
2104
+ // 拿不到也记一条(usage:null),表示"这个会话已经问过了",按本地估算走
2105
+ setSplitMap((prev) => {
2106
+ const next = { ...prev, [sessionId]: { usage: u ? u.usage : null, subagentUsage: u ? u.subagentUsage : null } };
2107
+ // 对象保持插入顺序,最旧的在最前;超出上限就淘汰它(避免长会话列表无限增长)
2108
+ const keys = Object.keys(next);
2109
+ for (let i = 0; i < keys.length - SPLIT_CACHE_MAX; i += 1) delete next[keys[i]];
2110
+ return next;
2111
+ });
1462
2112
  });
1463
2113
  fetchBalanceData().catch(() => {});
1464
2114
  return () => {
@@ -1478,8 +2128,11 @@ function UsageCostLine({ useProjection, sessionId }) {
1478
2128
  const billedTotal = uncachedInput + cacheRead + cacheWrite + output;
1479
2129
  if (newTokens === 0 && billedTotal === 0) return null;
1480
2130
 
1481
- // 金额:优先用服务端分时段 usage(高峰桶×2+空闲桶);未就绪时按当前时段价估算
1482
- const split = splitReady && splitUsage ? splitUsage : null;
2131
+ // 金额:优先用服务端分时段 usage(高峰桶×2+空闲桶);未就绪时按当前时段价估算。
2132
+ // 只认本会话的分桶 —— 切到没缓存的会话时立刻退回本地估算,绝不用上一个会话的数据。
2133
+ const splitFor = sessionId ? splitMap[sessionId] || null : null;
2134
+ const split = splitFor ? splitFor.usage : null;
2135
+ const subagentUsage = splitFor ? splitFor.subagentUsage : null;
1483
2136
  // 服务端分桶可能"非 null 但为空"(拿不到该会话事件),此时必须回退本地投影估算,否则金额会显示 ¥0.0000
1484
2137
  const splitUsable =
1485
2138
  split != null && ((Array.isArray(split.byModel) && split.byModel.length > 0) || (split.totalTokens ?? 0) > 0);
@@ -1488,7 +2141,7 @@ function UsageCostLine({ useProjection, sessionId }) {
1488
2141
  const offpeakTokens = splitUsable ? split.offpeak.inputTokens + split.offpeak.cacheReadTokens + split.offpeak.cacheWriteTokens + split.offpeak.outputTokens : 0;
1489
2142
 
1490
2143
  // 子代理消耗(与主会话分开,同样跨时段精确计费)
1491
- const hasSubagent = splitReady && subagentUsage && subagentUsage.totalTokens > 0;
2144
+ const hasSubagent = subagentUsage != null && subagentUsage.totalTokens > 0;
1492
2145
  const subCost = hasSubagent ? computeSplitCost(subagentUsage, p, peakMultiplier) : 0;
1493
2146
  const subNewTokens = hasSubagent
1494
2147
  ? (subagentUsage.inputTokens ?? 0) + (subagentUsage.outputTokens ?? 0)
@@ -1538,20 +2191,22 @@ function UsageCostLine({ useProjection, sessionId }) {
1538
2191
  ["子代理 tokens", "+" + formatTokens(subNewTokens) + " / 计费 " + formatTokens(subagentUsage.totalTokens)],
1539
2192
  );
1540
2193
  }
1541
- // 网页搜索 / 抓取:官方按次计费(每千次 ¥5),2026-11-01 00:00(北京)起收费。
1542
- // 次数来自 tool/call 与 tool/result 配对,金额由服务端算好放在 webToolCost。
1543
- const webSearch = splitUsable ? (split.webSearchCalls ?? 0) : 0;
1544
- const webFetch = splitUsable ? (split.webFetchCalls ?? 0) : 0;
1545
- const webCost = splitUsable && Number.isFinite(split.webToolCost) ? split.webToolCost : 0;
1546
- if (webSearch > 0 || webFetch > 0) {
1547
- const parts = [];
1548
- if (webSearch > 0) parts.push("搜索 " + webSearch + " 次");
1549
- if (webFetch > 0) parts.push("抓取 " + webFetch + " 次");
2194
+ // 网页搜索 / 抓取:按次计费,既不属于高峰也不属于空闲,所以单列。
2195
+ // 官方 2026-10-17 上线 Search / Fetch API,北京时间 2026-11-01 00:00 起收费(¥5 / 千次)。
2196
+ // 之前这一行只在统计弹窗里有,消耗明细浮层里漏了(2026-10-30 反馈)。
2197
+ const webSearchCalls = Number(split?.webSearchCalls ?? 0) || 0;
2198
+ const webFetchCalls = Number(split?.webFetchCalls ?? 0) || 0;
2199
+ const webBillable =
2200
+ (Number(split?.webSearchBillable ?? 0) || 0) + (Number(split?.webFetchBillable ?? 0) || 0);
2201
+ const webToolCost = Number(split?.webToolCost ?? 0) || 0;
2202
+ if (webSearchCalls + webFetchCalls > 0) {
1550
2203
  costDetails.push(
1551
- ["网页工具", parts.join(" · ")],
2204
+ ["网页搜索 / 抓取", webSearchCalls + " / " + webFetchCalls + " 次"],
1552
2205
  [
1553
2206
  "网页工具费用",
1554
- webCost > 0 ? "¥" + webCost.toFixed(4) : "免费期内",
2207
+ webToolCost > 0
2208
+ ? "¥" + webToolCost.toFixed(4) + " · 计费 " + webBillable + " 次"
2209
+ : "免费期内(11-01 起收费)",
1555
2210
  ],
1556
2211
  );
1557
2212
  }
@@ -1632,6 +2287,88 @@ function UsageCostLine({ useProjection, sessionId }) {
1632
2287
  });
1633
2288
  }
1634
2289
 
2290
+ // ─── 左侧导航栏的「余额与用量」面板 ─────────────────────────────────────
2291
+ // 图标:sidebar.panellist 只渲染这一小块(DSH 负责旁边的文字与点击),
2292
+ // 宽展开时 16px、收起成 rail 时 18px,由 slot props.size 给。
2293
+ function BalancePanelIcon({ size, active }) {
2294
+ const p = typeof size === "number" ? size : 16;
2295
+ const settings = useSettings();
2296
+ // 未读 = 这个按钮还没被点开过。跟版本号完全无关:
2297
+ // · 用户从 1.3.0 直接跳到 1.3.5(跳过中间版本)也会看到红点
2298
+ // · 点过之后写回 entrySeen: true,红点永久消失,重启也不回来
2299
+ // · 判定用 !== true 而不是 === false,老设置文件里没有这个键也照样算未读
2300
+ const unread = settings.entrySeen !== true && !active;
2301
+ const svg = jsx.jsx("svg", {
2302
+ width: p,
2303
+ height: p,
2304
+ viewBox: "0 0 24 24",
2305
+ fill: "none",
2306
+ xmlns: "http://www.w3.org/2000/svg",
2307
+ stroke: "currentColor",
2308
+ strokeWidth: 2,
2309
+ strokeLinecap: "round",
2310
+ strokeLinejoin: "round",
2311
+ "aria-hidden": "true",
2312
+ style: { display: "block" },
2313
+ children: [
2314
+ jsx.jsx("path", { d: "M21 12V7H5a2 2 0 0 1 0-4h14v4" }),
2315
+ jsx.jsx("path", { d: "M3 5v14a2 2 0 0 0 2 2h16v-5" }),
2316
+ jsx.jsx("path", { d: "M18 12a2 2 0 0 0 0 4h4v-4Z" }),
2317
+ ],
2318
+ });
2319
+ if (!unread) return svg;
2320
+ // 点一下图标(红点就在图标右上角,点哪都算)就立刻熄灯:
2321
+ // 本地先乐观置 true 并广播,红点在同一帧消失,不等 HTTP 往返;
2322
+ // 持久化走 fire-and-forget,失败也只是下次进来再写一次。
2323
+ // 用 pointerdown 而不是 click —— panellist 那一行的 click 归 DSH 的 selectPanel 处理,
2324
+ // pointerdown 只负责"标记已读",不抢它的点击行为(事件照常冒泡)。
2325
+ const markSeen = () => {
2326
+ if (settingsCache.value.entrySeen === true) return;
2327
+ settingsCache = {
2328
+ ...settingsCache,
2329
+ value: { ...settingsCache.value, entrySeen: true },
2330
+ };
2331
+ emitSettings();
2332
+ void saveSettings({ entrySeen: true }).catch(() => {});
2333
+ };
2334
+ // 外层 span 只为了挂定位;DSH 的 panellist 容器不裁子元素
2335
+ return jsx.jsxs("span", {
2336
+ className: "balance-panel-icon balance-panel-icon--unread",
2337
+ onPointerDown: markSeen,
2338
+ children: [svg, jsx.jsx("span", { className: "balance-panel-icon__dot", "aria-hidden": "true" })],
2339
+ });
2340
+ }
2341
+
2342
+ // 页面:点侧栏「余额与用量」后,主区域按 keyed slot "main" 渲染这个组件。
2343
+ // 内容直接复用配置卡片(它自带折叠头、分组、即时保存),外面套一层页面留白。
2344
+ function BalancePanelPage() {
2345
+ const settings = useSettings();
2346
+ // 打开即视为已读:只写一次,失败也不打扰(下次打开再试)
2347
+ react.useEffect(() => {
2348
+ // 只要不是确切的 true 就写一次(undefined / false 都算未读),
2349
+ // 保证「点开过」这件事一定被持久化,红点之后不会再回来。
2350
+ if (settings.entrySeen !== true) {
2351
+ void saveSettings({ entrySeen: true }).catch(() => {});
2352
+ }
2353
+ }, [settings.entrySeen]);
2354
+ return jsx.jsxs("div", {
2355
+ className: "balance-panel-page",
2356
+ children: [
2357
+ jsx.jsxs("div", {
2358
+ className: "balance-panel-page__head",
2359
+ children: [
2360
+ jsx.jsx("h2", { className: "balance-panel-page__title", children: "余额与用量" }),
2361
+ jsx.jsx("p", {
2362
+ className: "balance-panel-page__desc",
2363
+ children: "DeepSeek 账户余额与本机会话消耗统计的设置。改动即时保存;标了「需重启」的项在重启 DeepSeek Harness 后生效。",
2364
+ }),
2365
+ ],
2366
+ }),
2367
+ jsx.jsx(BalanceSettingsCard, { view: "page" }),
2368
+ ],
2369
+ });
2370
+ }
2371
+
1635
2372
  // ─── 插件配置卡片:DSH「插件」页 → balance-dsh → 设置 ─────────────────────
1636
2373
  // slot 契约(dsh-client-ui-plugin-manager 的 plugins.bundle.config,keyed 类型):
1637
2374
  // key = bundle 的包名,本插件即 "balance-dsh"
@@ -1710,13 +2447,63 @@ function UsageCostLine({ useProjection, sessionId }) {
1710
2447
  });
1711
2448
  }
1712
2449
 
2450
+ // NEW_TAGS:标准——「最近版本加了什么」。
2451
+ // 显示窗口 = 当前版本首次启动后 3 天(2026-10-30 反馈):
2452
+ // 起点由服务端记在设置文件里(newTagSince / newTagVersion),
2453
+ // 版本一变就重置——所以用户一升级就会看到新的 3 天。不看 entrySeen。
2454
+ // 维护方式:发版时把本版新增的设置项 key 加进来,把上一版的移出去。
2455
+ // 每项带上「引入版本」:只有比用户上一次跑的版本更新的项才标 NEW。
2456
+ // 这样跨几个版本一次升上来的用户会看到所有中间新功能,
2457
+ // 而已经跑过这些功能的用户不会被重复提示。
2458
+ // 项目不再需要发版时手动「把上一版的移出去」——版本比较会自动过滤。
2459
+ const NEW_TAGS = new Map([
2460
+ ["footerStyle", "2.0.0"], // 余额行样式(新版 timeline / 旧版 classic)
2461
+ ["showSidebarEntry", "2.0.0"], // 左侧导航栏入口
2462
+ ["refreshWave", "2.0.0"], // 右键刷新涟漪
2463
+ ]);
2464
+ const newTagSince = Number(value.newTagSince);
2465
+ // 升级前的版本(空串 = 全新安装)
2466
+ const newTagFrom = String(value.newTagFrom || "");
2467
+ const newTagSeen = value.newTagSeen && typeof value.newTagSeen === "object" ? value.newTagSeen : {};
2468
+ const isNew = (key) => {
2469
+ const intro = NEW_TAGS.get(key);
2470
+ if (!intro) return false;
2471
+ return newTagStateAt(intro, newTagSeen[key], newTagFrom, newTagSince, Date.now()).show;
2472
+ };
2473
+ // 逐项记录「第一次见到」的时间:每个新功能各自从自己出现那一刻算 3 天,
2474
+ // 与其它功能的过期时间完全独立。设置还没加载完不能记(会误当全新安装)。
2475
+ react.useEffect(() => {
2476
+ if (settingsCache.loaded !== true) return;
2477
+ const now = Date.now();
2478
+ const patch = {};
2479
+ for (const [key, intro] of NEW_TAGS) {
2480
+ const rec = newTagSeen[key];
2481
+ // 已按这个版本记过了,不重复写盘、也不把起点往后推
2482
+ if (rec && typeof rec === "object" && rec.v === intro) continue;
2483
+ // 对用户不是新功能就不记(否则以后 baseline 变了会误显示)
2484
+ if (!newTagStateAt(intro, rec, newTagFrom, newTagSince, now).fresh) continue;
2485
+ patch[key] = { v: intro, t: now };
2486
+ }
2487
+ if (Object.keys(patch).length === 0) return;
2488
+ void saveSettings({ newTagSeen: { ...newTagSeen, ...patch } }).catch(() => {
2489
+ // 写盘失败不影响本次渲染:下次进入会重试
2490
+ });
2491
+ }, [newTagFrom, newTagSince, newTagSeen]);
1713
2492
  const row = (key, label, hint, control) => jsx.jsxs("div", {
1714
2493
  className: "balance-settings__row",
1715
2494
  children: [
1716
2495
  jsx.jsxs("div", {
1717
2496
  className: "balance-settings__label",
1718
2497
  children: [
1719
- jsx.jsx("span", { className: "balance-settings__label-text", children: label }),
2498
+ jsx.jsxs("span", {
2499
+ className: "balance-settings__label-line",
2500
+ children: [
2501
+ jsx.jsx("span", { className: "balance-settings__label-text", children: label }),
2502
+ isNew(key)
2503
+ ? jsx.jsx("span", { className: "balance-settings__new-badge", children: "NEW" })
2504
+ : null,
2505
+ ],
2506
+ }),
1720
2507
  hint ? jsx.jsx("span", { className: "balance-settings__hint", children: hint }) : null,
1721
2508
  ],
1722
2509
  }),
@@ -1751,9 +2538,26 @@ function UsageCostLine({ useProjection, sessionId }) {
1751
2538
 
1752
2539
  const toggleRow = (key, label, hint) => row(key, label, hint, toggle(key, label));
1753
2540
 
1754
- // 整张设置卡片可折叠(默认收起):形式对齐 DSH 原生 DisclosureRow —— 一行
1755
- // 标题 + 描述 + 右侧 chevron,展开时箭头翻转 180 度。
1756
- const [openSettings, setOpenSettings] = react.useState(false);
2541
+ // 二选一分段控件:用于「余额行外观」这类互斥选项
2542
+ const choiceRow = (key, label, hint, options) => row(key, label, hint, jsx.jsx("div", {
2543
+ className: "balance-settings__segmented",
2544
+ role: "radiogroup",
2545
+ "aria-label": label,
2546
+ children: options.map((opt) => jsx.jsx("button", {
2547
+ key: opt.value,
2548
+ type: "button",
2549
+ role: "radio",
2550
+ "aria-checked": value[key] === opt.value,
2551
+ className: "balance-settings__segment" + (value[key] === opt.value ? " balance-settings__segment--on" : ""),
2552
+ onClick: () => patch(key, opt.value),
2553
+ children: opt.label,
2554
+ }, opt.value)),
2555
+ }));
2556
+
2557
+ // 整张设置卡片可折叠,形式对齐 DSH 原生 DisclosureRow —— 一行标题 + 描述 + 右侧 chevron,
2558
+ // 展开时箭头翻转 180 度。**默认展开**:只有插件列表里的单行摘要(view === "summary")
2559
+ // 保持收起,点进详情页 / 设置页 / 侧栏面板页时一进来就摊开所有选项。
2560
+ const [openSettings, setOpenSettings] = react.useState(view !== "summary");
1757
2561
  const chevron = jsx.jsx("svg", {
1758
2562
  className: "balance-settings__chevron",
1759
2563
  viewBox: "0 0 16 16",
@@ -1769,10 +2573,34 @@ function UsageCostLine({ useProjection, sessionId }) {
1769
2573
  strokeLinejoin: "round",
1770
2574
  }),
1771
2575
  });
1772
- const group = (key, title, rows) => jsx.jsxs("div", {
2576
+ // note:分组级说明(可选),渲染在分组标题下方、行上方,用来说明这组的口径/适用范围
2577
+ const group = (key, title, rows, note) => jsx.jsxs("div", {
1773
2578
  className: "balance-settings__group",
1774
2579
  children: [
1775
2580
  jsx.jsx("div", { className: "balance-settings__group-title", children: title }),
2581
+ note
2582
+ ? jsx.jsxs("div", {
2583
+ className: "balance-settings__group-note",
2584
+ children: [
2585
+ jsx.jsxs("svg", {
2586
+ className: "balance-settings__group-note-icon",
2587
+ viewBox: "0 0 16 16",
2588
+ width: 14,
2589
+ height: 14,
2590
+ fill: "none",
2591
+ stroke: "currentColor",
2592
+ strokeWidth: 1.5,
2593
+ strokeLinecap: "round",
2594
+ children: [
2595
+ jsx.jsx("circle", { cx: 8, cy: 8, r: 6.4 }),
2596
+ jsx.jsx("path", { d: "M8 7.3v3.9" }),
2597
+ jsx.jsx("circle", { cx: 8, cy: 4.9, r: 0.85, fill: "currentColor", stroke: "none" }),
2598
+ ],
2599
+ }),
2600
+ jsx.jsx("span", { className: "balance-settings__group-note-text", children: note }),
2601
+ ],
2602
+ })
2603
+ : null,
1776
2604
  ...rows,
1777
2605
  ],
1778
2606
  }, key);
@@ -1805,7 +2633,7 @@ function UsageCostLine({ useProjection, sessionId }) {
1805
2633
  }),
1806
2634
  jsx.jsx("p", {
1807
2635
  className: "balance-settings__desc",
1808
- children: "余额刷新频率、界面显示开关、收起态圆钮外观、统计范围与计费口径。改动即时保存并生效;其中服务端相关项需重启 DSH。",
2636
+ children: "余额刷新、界面显示与计费口径。改动即时保存;服务端相关项需重启 DeepSeek Harness。",
1809
2637
  }),
1810
2638
  ],
1811
2639
  }),
@@ -1816,6 +2644,32 @@ function UsageCostLine({ useProjection, sessionId }) {
1816
2644
  openSettings ? jsx.jsxs("div", {
1817
2645
  className: "balance-settings__body",
1818
2646
  children: [
2647
+ // 顶部告示:计费范围限制放在最前面,打开设置第一眼就看到,
2648
+ // 不用翻到「计费」分组才发现。
2649
+ jsx.jsxs("div", {
2650
+ className: "balance-settings__notice",
2651
+ children: [
2652
+ jsx.jsxs("svg", {
2653
+ className: "balance-settings__notice-icon",
2654
+ viewBox: "0 0 16 16",
2655
+ width: 15,
2656
+ height: 15,
2657
+ fill: "none",
2658
+ stroke: "currentColor",
2659
+ strokeWidth: 1.5,
2660
+ strokeLinecap: "round",
2661
+ children: [
2662
+ jsx.jsx("circle", { cx: 8, cy: 8, r: 6.4 }),
2663
+ jsx.jsx("path", { d: "M8 7.3v3.9" }),
2664
+ jsx.jsx("circle", { cx: 8, cy: 4.9, r: 0.85, fill: "currentColor", stroke: "none" }),
2665
+ ],
2666
+ }),
2667
+ jsx.jsx("span", {
2668
+ className: "balance-settings__notice-text",
2669
+ children: "计费仅支持 DeepSeek Harness 内的 DeepSeek 官方模型与账号;其他大模型不计入金额。",
2670
+ }),
2671
+ ],
2672
+ }),
1819
2673
  group("balance", "余额", [
1820
2674
  numberRow("refreshIntervalSec", "余额刷新间隔", "轮询间隔;对话产生新消耗时也会顺带刷新。", 5, 3600, "秒"),
1821
2675
  numberRow("lowBalanceThreshold", "低余额警示阈值", "余额低于该值时金额转为琥珀色。", 0, 100000, "元"),
@@ -1833,8 +2687,21 @@ function UsageCostLine({ useProjection, sessionId }) {
1833
2687
 
1834
2688
  group("display", "界面显示", [
1835
2689
  toggleRow("showBalanceRow", "侧边栏余额行", "展开侧边栏时底部紧挨设置齿轮的那一行。"),
2690
+ choiceRow("footerStyle", "余额行样式", "新版:上方一行文字信息 + 扁刻度条;旧版:钱包图标 + 「余额」 + 金额的纯文字行。", [
2691
+ { value: "timeline", label: "新版" },
2692
+ { value: "classic", label: "旧版" },
2693
+ ]),
2694
+ toggleRow("showSidebarEntry", "左侧导航栏入口", "在左侧导航栏底部(插件 / 自动化任务 那一组)加一个「余额与用量」入口,点开是本页。改这一项需刷新页面。"),
2695
+ // 这两项各只属于一种样式,按当前选择出现:
2696
+ // 浪潮 = 新版独有(旧版刷新走「刷新中…」文字,没有白条可关)
2697
+ // 时段标签 = 新版不可关闭(刻度条是新版的结构主体),只有旧版才允许收成纯金额
2698
+ value.footerStyle === "timeline"
2699
+ ? toggleRow("refreshWave", "右键刷新浪潮", "右键余额时,余额行从左往右扫过一道泛光亮带作为反馈。")
2700
+ : null,
1836
2701
  toggleRow("showUsagePill", "会话消耗行", "输入框下方的「本会话消耗」胶囊(含点开的明细浮层)。"),
1837
- toggleRow("showPeriodLabel", "高峰 / 空闲标签", "余额行里金额后面的时段提示。"),
2702
+ value.footerStyle === "classic"
2703
+ ? toggleRow("showPeriodLabel", "高峰 / 空闲标签", "旧版余额行里「金额 • 时段」的时段部分;关掉后只留金额。")
2704
+ : null,
1838
2705
  ]),
1839
2706
 
1840
2707
  group("fab", "收起态圆钮", [
@@ -1905,6 +2772,35 @@ function UsageCostLine({ useProjection, sessionId }) {
1905
2772
  ),
1906
2773
  );
1907
2774
 
2775
+ // 左侧导航栏面板入口(1.3.2):
2776
+ // sidebar.panellist = 列表型 slot,DSH 拿 options.id / options.label / options.order
2777
+ // 生成「图标 + 文字」那一行(插件 / 自动化任务 / Purge / 更多 同一组),
2778
+ // slot 本体只负责画图标(wide 时尺寸 16,收起态 18)。
2779
+ // 点击后 DSH 调 layout.selectPanel(id),主区域用 keyed slot "main" 按 activePanelId 取内容,
2780
+ // 所以再注册一个 key 同名的 main 条目就是点开后的页面。
2781
+ // 关掉「左侧导航栏入口」就不注册;slot 注册无法在运行时撤销,所以这一项改动需刷新页面。
2782
+ ctx.slots.inject("sidebar.panellist", () => {
2783
+ if (settingsCache.value.showSidebarEntry === false) return;
2784
+ return ctx.slots.register(
2785
+ {
2786
+ name: "sidebar.panellist",
2787
+ id: "balance",
2788
+ order: 30,
2789
+ label: "余额与用量",
2790
+ },
2791
+ BalancePanelIcon,
2792
+ );
2793
+ });
2794
+ ctx.slots.inject("main", () =>
2795
+ ctx.slots.register(
2796
+ {
2797
+ name: "main",
2798
+ key: "balance",
2799
+ },
2800
+ BalancePanelPage,
2801
+ ),
2802
+ );
2803
+
1908
2804
  // DSH「插件」页 → 点 balance-dsh → 详情页里的配置卡片。
1909
2805
  // slot 契约:plugins.bundle.config 是 keyed 类型,key 必须是 **bundle 的包名**
1910
2806
  // (本插件 = "balance-dsh"),DSH 的插件管理页按这个 key 派发。