balance-dsh 2.0.8 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib/client.js +2725 -376
  2. package/lib/index.js +1322 -159
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -5,9 +5,56 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react = require("react");
8
- let reactDom = require("react-dom");
9
8
  let jsx = require("react/jsx-runtime");
10
- let primitives = require("@deepseek-ai/dsh-client-ui-primitives");
9
+ // ⚠️ 顶层 require 一旦抛错,整个 factory 崩掉 —— 5 个 slot 全部不注册、界面静默消失,
10
+ // 而且没有任何日志可查。这是「DSH 兼容铁律 1」的延伸:react-dom 与官方 primitives
11
+ // 都不是绘制这些 UI 的必需品(primitives 只提供 Tooltip / Modal / 三个图标 /
12
+ // 两个定位 hook,全部有降级路径),所以一律包住,缺了只降级对应子功能。
13
+ // 报告里唯一真正会崩的是后面那些 `typeof primitives.X` —— primitives 为 undefined 时
14
+ // 读属性就 TypeError。所以失败时退成空对象,让所有探测退化为「没有这个能力」。
15
+ let reactDom;
16
+ try {
17
+ reactDom = require("react-dom");
18
+ } catch {
19
+ reactDom = null;
20
+ }
21
+ let primitives;
22
+ try {
23
+ // `|| {}` 不能省:模块存在但导出为空(或 require 返回 undefined)时同样要能探测。
24
+ primitives = require("@deepseek-ai/dsh-client-ui-primitives") || {};
25
+ } catch {
26
+ primitives = {};
27
+ }
28
+ // createPortal 缺失时直接返回节点本身:它只用来把圆钮/浮层挂到 document.body
29
+ // (因为收起侧栏会把挂载点整个 display:none),没了顶多定位不对,不该让整块 UI 崩。
30
+ const createPortal =
31
+ reactDom && typeof reactDom.createPortal === "function" ? reactDom.createPortal.bind(reactDom) : (node) => node;
32
+ // Tooltip 缺失时退成透传容器:两个调用点都是「包一层提示」,内容本身必须照常渲染。
33
+ const TooltipPassthrough = (props) => (props && props.children != null ? props.children : null);
34
+ const Tooltip = typeof primitives.Tooltip === "function" ? primitives.Tooltip : TooltipPassthrough;
35
+ // ─── 可选 hook 的恒定点 ────────────────────────────────────────────────
36
+ // 把「provider 可能没提供这个 hook」的探测提到**模块级**,这样组件里就能**无条件调用**。
37
+ // 原来写成 `typeof primitives.useAnchoredPosition === "function" ? primitives.useAnchoredPosition(...) : null`
38
+ // 是条件调用 hook —— 判定结果确实恒定(primitives 是模块级常量),运行期不会出错,
39
+ // 但它违反 hooks 规则:一旦将来改成动态加载 / 异步注入,调用次数变化就是
40
+ // `Rendered more hooks than during the previous render`,整块 UI 白掉。
41
+ // 用 no-op 占位后 hook 数量恒定,缺失时的返回值与原来的 `null` / `undefined` 完全一致,
42
+ // 所以**行为与视觉零变化**,只是把合规风险消掉了。
43
+ const useAnchoredPositionHook =
44
+ typeof primitives.useAnchoredPosition === "function"
45
+ ? primitives.useAnchoredPosition.bind(primitives)
46
+ : () => null;
47
+ const useDismissOnOutsidePointerHook =
48
+ typeof primitives.useDismissOnOutsidePointer === "function"
49
+ ? primitives.useDismissOnOutsidePointer.bind(primitives)
50
+ : () => {};
51
+ const useProjectionFallback = () => undefined;
52
+ // 稳定的空数组常量:凡是「兜底空数组又当 useMemo / effect 依赖」的地方都用它,
53
+ // 否则每次渲染都是新引用,依赖判定永远为真,memo 等于没有(2026-10-31 全量审查)。
54
+ const EMPTY_ARRAY = Object.freeze([]);
55
+ // 白雾光团的下标。原来每处都现写一个数组字面量 [0,1,2,3,4],每次渲染都是新引用,
56
+ // 而且拿 index 当 React key(2026-10-31 全量审查)。抽成常量 + 显式 key。
57
+ const ORB_INDICES = Object.freeze([0, 1, 2, 3, 4]);
11
58
 
12
59
  // 余额滚轮要"这一帧就挂上滚动结构",不能等 useEffect(它在 paint 之后跑),
13
60
  // 否则余额一变会先渲染出一帧纯文本新值、下一帧才换成滚动格 —— 看起来就是
@@ -45,22 +92,193 @@ window.__ModuleLoader__.load({
45
92
  外层 overflow:hidden 把滑动部分裁在字高内,读起来像机械计数器过位。 */
46
93
  .balance-footer__roll{display:inline-flex;align-items:baseline;white-space:pre;flex:none}
47
94
  .balance-footer__dcell{display:inline-block;vertical-align:bottom;white-space:pre}
95
+ /* ⚠️ 裁切必须用 overflow:hidden,**不要用 clip-path:inset(0)**(2026-10-31 反转了早先的决定)。
96
+ clip-path 无论值多"无操作"都会把元素推进离屏合成,小字号文字在 GPU 纹理里渲染就发虚
97
+ ——「今日消耗的滚动糊了」而会话消耗不糊,差别只在这一条上(两者 left/width 实测完全相同,
98
+ 亚像素定位不是原因)。overflow:hidden 走布局层裁剪,不层化、不发虚。
99
+ 早期担心它建立 BFC、挡住子元素外发光,实测数字没有外发光,不成立。 */
48
100
  .balance-footer__dcell--roll{position:relative;overflow:hidden}
49
101
  /* 位数减少时(11.04 → 8.04),最左边那一格的「新值」是空位。它得先把旧数字滚出去,
50
102
  再把自己收成 0 宽 —— 否则滚动结束后那一格残留的宽度把胶囊撑宽 10px,
51
103
  要等 setRoll(null) 才缩回,看起来就是数字左右跳一下。
52
- 收窄过程中 dout/din 是 absolute,会被 dcell 的 overflow:hidden 裁掉,不会和邻格重叠。
104
+ 收窄过程中 dout/din 是 absolute,会被 dcell 的裁切层裁掉,不会和邻格重叠。
53
105
  影子格必须保持 NBSP 而不是 "0":这一格要在动画期间维持原来的宽度,
54
106
  真正让它消失的是这条 max-width 动画,不是字符宽度。 */
55
107
  .balance-footer__dcell--gone{animation:balance-cell-gone .42s cubic-bezier(.33,.66,.24,1) forwards}
108
+ /* ─── 滚动播完后的「定格态」(2026-10-31 方案 3 补充)─────────────────────────
109
+ 为什么需要它:滚动动画不是 JS 触发的,而是 .balance-footer__dout / __din 上
110
+ 常驻的 animation 声明。只要这两个节点还在、类名还在、animation 声明还在,
111
+ 那么元素一旦经历 display:none → 有盒子,浏览器会**取消并重新创建**这个动画
112
+ —— 从 0 秒重播。这就是「展开侧边栏数字重播」的物理机制,改多少 JS 状态都没用,
113
+ 因为状态只决定类名,类名在、声明就在。
114
+ 所以滚动播完之后给格子加 --roll-done:**摘掉 animation 声明、只留终态几何**。
115
+ 这样节点仍常驻(不会像清回 null 那样结构切换、跳一下),但已经没有任何
116
+ 动画可以被重播了。
117
+ 几何必须与动画终点严丝合缝:dout 直接隐藏,din 去掉 animation 后回到自身样式
118
+ (没有 transform = top:0),正好就是关键帧 100% 的位置。 */
119
+ .balance-footer__dcell--roll-done .balance-footer__dout{animation:none;display:none}
120
+ .balance-footer__dcell--roll-done .balance-footer__din{animation:none;transform:none}
121
+ .balance-footer__dcell--roll-done.balance-footer__dcell--gone{animation:none;max-width:0}
56
122
  @keyframes balance-cell-gone{from{max-width:2ch}to{max-width:0}}
57
123
  .balance-footer__dghost{display:block;visibility:hidden;white-space:pre}
58
- .balance-footer__dout,.balance-footer__din{position:absolute;left:0;top:0;white-space:pre;will-change:transform,opacity}
59
- .balance-footer__dout{animation:balance-roll-out .42s cubic-bezier(.33,.66,.24,1) forwards}
60
- .balance-footer__din{animation:balance-roll-in .42s cubic-bezier(.33,.66,.24,1) forwards}
61
- @keyframes balance-roll-out{0%{transform:translateY(0);opacity:1}60%{opacity:0}100%{transform:translateY(-105%);opacity:0}}
62
- @keyframes balance-roll-in{0%{transform:translateY(105%);opacity:0}40%{opacity:0}100%{transform:translateY(0);opacity:1}}
124
+ /* ⚠️ 不要给 dout/din 加 will-change:transform,opacity。
125
+ 它们是**常驻**节点(滚动结束后不再卸掉,见「滚动结束后保持滚动态」),
126
+ 而 will-change 会让每一格独立成一个合成层;小字号文字走 GPU 纹理后
127
+ 在非整数位置渲染,整体发虚(用户 2026-10-31:「数字变糊了」)。
128
+ transform 动画本身浏览器会自动提升层,撤掉 will-change 不影响这 420ms 的流畅度。 */
129
+ .balance-footer__dout,.balance-footer__din{position:absolute;left:0;top:0;white-space:pre}
130
+ /* ⚠️ 缓动必须是 linear,**不要用 cubic-bezier(.33,.66,.24,1) 这类强 ease-out**。
131
+ ease-out 尾部速度趋近 0,最后 100ms 只走百分之一的距离,translateY 会长时间停在
132
+ 0.6px / 0.3px / 0.1px 这些亚像素值上,文字不在整像素就只能插值抗锯齿 ——
133
+ 观感就是「快到位时数字糊了」(用户 2026-10-31 直接猜中)。linear 下速度恒定,
134
+ 最后一帧跨 2.4%,一两帧就越过亚像素区间。机械计数器本来就该匀速,语义也更对。
135
+
136
+ ⚠️ 但**光靠 linear 会显得生硬**(2026-10-31:「所有的数字滚动太生硬了」):
137
+ 所有位同时起步、同时落位,整串像一块板在平移。所以加**错位起步**(stagger)——
138
+ 从右往左依次延后,个位先动、进位往左传递,读起来是机械计数器逐位落位,
139
+ 而不是整块平移。每位仍是 linear、落点分毫不差,所以不会退回发虚的老问题。
140
+ stagger 的毫秒数由 renderRollCells 按「从右数第几位」写进 animation-delay。 */
141
+ /* ⚠️ fill-mode 必须是 both(不是 forwards):
142
+ 错位起步意味着前面若干位会先等一会儿,等的时候要靠 backwards 保持在 0% 帧
143
+ (dout 停在 translateY(0)、din 停在 translateY(100%) 即下方不可见)。
144
+ 只写 forwards 的话,延迟期间应用的是**元素自身样式**(没有 transform),
145
+ din 会直接叠在 dout 上,数字糊成一团。 */
146
+ .balance-footer__dout{animation:balance-roll-out .42s linear both}
147
+ .balance-footer__din{animation:balance-roll-in .42s linear both}
148
+ /* ⚠️ 全程 opacity:1,只靠位移互补 —— 不要再用「先淡出再淡入」那套。
149
+ 旧写法是 out 到 60% 淡成 0、in 前 40% 保持 0,中间有约 160ms 两格都看不见,
150
+ 逐帧看就是「数字缺失/位置异常一下」。会话消耗因为变化频率低不显眼,
151
+ 今日每几秒补拉一次,就会反复闪(用户 2026-10-31:「还是会有这样一帧」)。
152
+ 现在两格同时在动、位置严格互补(out 向上退、in 从下方进),overflow:hidden 裁掉边外部分,
153
+ 观感是机械计数器过位,任何一帧都有完整可读的数字。 */
154
+ /* ⚠️ 用**多段关键帧**做「先快后慢」,不要用 ease-out timing-function。
155
+ (2026-10-31:「滚动速度太统一了 可以就是先快再慢」)
156
+ 直接的 cubic-bezier ease-out 是禁区:末端斜率趋 0,最后 100ms 只走 1% 的距离,
157
+ translateY 会长时间停在 0.6 / 0.3 / 0.1px 这些亚像素值上,文字只能插值抗锯齿 → 发虚
158
+ (这一条是用户自己猜中的,别再试)。所以 timing-function 保持 linear,
159
+ 把减速做进**关键帧的位移分布**里:linear 对每个关键帧区间各自生效,
160
+ 于是每段速度 = 该段位移 / 该段时间,改位移就改了速度。
161
+
162
+ 实测三段速度比(总长 420ms、每格 20px):
163
+ 0–40% 走 55% 1.37 倍速 ← 起步冲出去
164
+ 40–75% 走 35% 1.00 倍速 ← 匀速过渡
165
+ 75–100% 走 10% 0.40 倍速 ← 收尾放缓
166
+ 尾部 0.4 倍速下每 100ms 仍走 3.2px,亚像素区只占最后约 30ms —— 读起来是「落位」,
167
+ 不是「糊住」。ease-out 在那一段只走 0.2px,慢 16 倍,那才是发虚的来源。
168
+
169
+ ⚠️ out 和 in 必须用**同一套归一化进度**,否则中间会露缝或重叠:
170
+ 任一时刻 out 在 -a、in 在 100-a,两者间距恒为一整格。对应关系是
171
+ out 的 -55% ↔ in 的 45%、out 的 -90% ↔ in 的 10%(差值都是 100%)。改一边必须改另一边。 */
172
+ @keyframes balance-roll-out{
173
+ 0%{transform:translateY(0)}
174
+ 40%{transform:translateY(-55%)}
175
+ 75%{transform:translateY(-90%)}
176
+ 100%{transform:translateY(-100%)}
177
+ }
178
+ @keyframes balance-roll-in{
179
+ 0%{transform:translateY(100%)}
180
+ 40%{transform:translateY(45%)}
181
+ 75%{transform:translateY(10%)}
182
+ 100%{transform:translateY(0)}
183
+ }
63
184
  /* 逐位滚动不受 prefers-reduced-motion 影响:它是"这轮花掉多少"的即时反馈,是插件特色 */
185
+ /* ── 今日金额的「首次出场」──
186
+ 冷启动 / 打开 DSH 时:加载动效结束 → 真实金额升上来。
187
+ 刻意不复用上面的 dout/din 滚格子:balance-roll-in 的前 40% 是 opacity:0
188
+ (给"旧值滚出、新值滚入"留重叠时间),从空白往上升时那段就是纯空白,
189
+ 逐帧截图 0/70/140ms 全空、210ms 已经到位 —— 看起来就是硬切(2026-10-31 反馈)。
190
+ 这里数字全程可见,只用位移 + 淡入把它托上来,420ms 走完,缓动末端重量感。
191
+ 只作用于金额这一层,「今日」二字恒定不动;¥ 在外层,也不参与。 */
192
+ /* ⚠️ 必须是 inline-flex,不能是 inline-block(用户 2026-10-31:「今日消耗的滚动糊了」)。
193
+ 内部结构是 dghost(block 撑宽) + dout/din(absolute),inline-block 容器在这种混排下
194
+ 会把行盒算到半像素位置,小字号文字于是发虚;会话消耗用的 .balance-footer__roll 一直是
195
+ inline-flex 所以从不糊。两边保持同一套排版模型。 */
196
+ .balance-footer__today-num{display:inline-flex;align-items:baseline;white-space:pre;font-variant-numeric:tabular-nums}
197
+ /* 首次显形:**从左到右扫过**(用户 2026-10-31:「我要的从左到右的扫过了 没扫过的地方不要显示数字」)。
198
+ 早先是一段 translateY(6px) 上浮 + 淡入,看着像"滚上来"。
199
+
200
+ 结构和会话消耗的到位动画**完全同一套**(用户 2026-10-31:「你参考一下会话消耗的动画
201
+ 不就是出现方式不一样吗」),只有遮罩方向和缓动不同:
202
+ 容器 overflow:hidden → mist(遮罩挖洞,底下是加载动画) → body(数字,clip 揭幕)
203
+ mist 和 body 是**同一个容器的兄弟节点**,宽度天然相等,所以挖洞的边界和揭幕的边界
204
+ 永远严丝合缝 —— 被扫过的地方只剩数字,没扫到的地方只剩加载动画,任何一帧都不叠。
205
+
206
+ ⚠️ 早先试过"两个独立元素各自 clip-path"(overlay 反向裁 + 数字正向裁):两者盒子宽度
207
+ 不同(¥ 曾在裁剪区外),百分比算出来的边界差半条 ¥ 的宽度,交界处同时出现胶囊和数字,
208
+ 白底把浅灰字冲淡 —— 看着就是"数字显示在动画里",而不是"扫过去"。
209
+
210
+ ⚠️ 缓动不能用会话消耗那档 cubic-bezier(.2,.86,.24,1):那是"开窗",前段极猛,
211
+ 今日金额要的是**看得出边界在往右推**,所以取接近匀速的 ease-in-out、时长也放长。 */
212
+ .balance-footer__today-reveal{position:relative;display:inline-flex;align-items:baseline;overflow:hidden;white-space:pre}
213
+ /* 等待态(还没有数值):容器里只有 mist,没有任何要裁的东西。
214
+ 这条规则是**兜底** —— 万一将来给 reveal 自己挂了裁切/过渡,等待态也会被一起裁掉,
215
+ 加载动画一帧都看不见。现在它等价于"显式声明等待态不参与揭幕"。 */
216
+ .balance-footer__today-value--waiting .balance-footer__today-reveal{animation:none;clip-path:none}
217
+ /* 挖洞方向:linear 从左往右。0% 全被遮罩盖住(只剩加载动画),100% 左侧全露(只剩数字)。
218
+ --today-hole 必须用 @property 注册成 percentage 才能被 keyframes 插值,
219
+ 否则变量是离散的,遮罩会一步跳到底(与会话消耗的 --balance-hole 同理)。 */
220
+ @property --today-hole{syntax:"<percentage>";inherits:false;initial-value:0%}
221
+ /* 余额行启动入场:从下往上擦出(用户 2026-10-31 原话:「添加一个从下到上的动画
222
+ 跟浪潮一样 滑动上去 没滑到的地方不显示 一样的擦除机制」)。
223
+ —— DSH 启动时余额第一次出现,就用这条遮罩从底部往上揭开,没扫到的区域不显示。
224
+ ⚠️ 只能播一次。折叠/展开侧栏会让 BalanceFooter 重新挂载,
225
+ 而挂在元素上的 CSS 动画只要节点重建就从 0 秒起播 —— 所以「是否播过」记在
226
+ <html data-balance-footer-entered="1"> 上(跨挂载有效),重挂时读到就不挂这个类。 */
227
+ @property --balance-enter-y{syntax:"<percentage>";inherits:false;initial-value:0%}
228
+ .balance-footer--enter-up{
229
+ clip-path:inset(calc(100% - var(--balance-enter-y)) 0 0 0);
230
+ animation:balance-enter-up .34s cubic-bezier(.33,0,.67,1) both}
231
+ @keyframes balance-enter-up{from{--balance-enter-y:0%}to{--balance-enter-y:100%}}
232
+ /* ⚠️ z-index:1 是必须的,不是保险。
233
+ mist 和 body 都是**定位元素**(absolute / relative),同层级下按 DOM 顺序绘制 ——
234
+ mist 写在前面就会被 body 盖在下面,雾盖不住数字,"扫过"完全看不出来。
235
+ 会话消耗那边能工作是因为它要的恰好相反:雾在数字**背后**(数字压在最上,
236
+ 挖洞只是把数字区域的雾去掉提亮),所以那边不需要 z-index。今日要的是
237
+ "雾盖住还没扫到的部分",必须把 mist 抬到 body 上面。 */
238
+ .balance-footer__today-reveal__mist{
239
+ position:absolute;inset:0;z-index:1;display:block;pointer-events:none;
240
+ -webkit-mask-image:linear-gradient(90deg,rgba(0,0,0,0) 0,rgba(0,0,0,0) var(--today-hole),#000 var(--today-hole),#000 100%);
241
+ mask-image:linear-gradient(90deg,rgba(0,0,0,0) 0,rgba(0,0,0,0) var(--today-hole),#000 var(--today-hole),#000 100%);
242
+ animation:balance-today-hole .26s cubic-bezier(.33,0,.67,1) both;
243
+ }
244
+ .balance-footer__today-reveal__mist .balance-usage-mist{width:100%;min-width:0;height:100%}
245
+ @keyframes balance-today-hole{0%{--today-hole:0%}100%{--today-hole:100%}}
246
+ .balance-footer__today-reveal__body{position:relative;display:inline-flex;align-items:baseline;white-space:pre}
247
+ /* 揭幕动画:挂在 --enter 修饰类上,只有「今日数值变了」才挂(2026-10-31 第十轮)。
248
+ 这恢复了最初的手感 —— 值一变就擦一次;而折叠/展开侧栏不算值变化,
249
+ 序号不动、类不挂,不会重播。 */
250
+ .balance-footer__today-reveal__body--enter{animation:balance-today-num-wipe .26s cubic-bezier(.33,0,.67,1) both}
251
+ @keyframes balance-today-num-wipe{
252
+ 0%{clip-path:inset(0 100% 0 0)}
253
+ 100%{clip-path:inset(0 0 0 0)}
254
+ }
255
+ /* ── 时段词 空闲 ⇄ 高峰 ──
256
+ 换词时元素靠 key 重挂(useChangeTick),这里只负责那一次淡入。
257
+ 纯淡入、无位移、300ms:比金额入场快一点,因为它只是一个小标签换字。 */
258
+ /* 时段词(空闲 / 高峰)切换时只做**纯淡入**,不做任何位移 ——
259
+ 早期版本是 translateY(4px) 的上浮,看着像在"滚动/跳一下",
260
+ 用户 2026-10-31 明确「不是滚动动画」。
261
+ 另外这个类只在真正切换时才挂(periodTick > 0),启动首帧的「空闲」是静态出现的。 */
262
+ .balance-footer__period-swap{display:inline-block;animation:balance-period-fade .3s cubic-bezier(.22,.72,.28,1) both}
263
+ @keyframes balance-period-fade{
264
+ 0%{opacity:0}
265
+ 100%{opacity:1}
266
+ }
267
+ /* 剩余时间整串换掉时用的上浮淡入(段数变了才会走到那条分支)。
268
+ 和时段词的纯淡入刻意分开:剩余时间本来就允许有位移感,时段词不要。 */
269
+ @keyframes balance-period-swap{
270
+ 0%{opacity:0;transform:translateY(4px)}
271
+ 100%{opacity:1;transform:translateY(0)}
272
+ }
273
+ /* ── 剩余时间「剩 3h57m」的逐段滚动 ──
274
+ 数字段各自是一个 .balance-footer__roll(内含 dout/din 滚格),
275
+ 字母段(h / m / d)保持静止,段数变了就整体换一次。 */
276
+ .balance-footer__remain{display:inline-flex;align-items:baseline;white-space:pre}
277
+ .balance-footer__remain-unit{display:inline-block}
278
+ .balance-footer__remain-swap{display:inline-block;animation:balance-period-swap .3s cubic-bezier(.22,.72,.28,1) both}
279
+ /* 剩余时间里的滚格子必须 inline-block,否则在 inline-flex 里会被压成零宽 */
280
+ .balance-footer__remain .balance-footer__roll{display:inline-flex;align-items:baseline}
281
+ .balance-footer__remain .balance-footer__dcell{display:inline-block}
64
282
  .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}
65
283
  /* 前沿白条:纯白,不带品牌色辉光(2026-10-30 反馈「只保留白色光效 不要颜色」)。
66
284
  只扫一层白光,**不覆盖扫过区域**——曾经有一个 ::after 暗罩把白条右侧整体压暗,
@@ -68,12 +286,57 @@ window.__ModuleLoader__.load({
68
286
  现在动效 = 一条白光从左走到右,走完即止,行内颜色全程不变。 */
69
287
  .balance-footer__wave::before{content:"";position:absolute;top:1px;bottom:1px;left:0;width:6px;margin-left:-3px;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}
70
288
  @keyframes balance-wipe-head{0%{left:0;opacity:0}5%{opacity:1}82%{opacity:1}100%{left:100%;opacity:0}}
289
+ /* ── 收起态右键水波(2026-10-31 重新设计)─────────────────────────────────────
290
+ 需求原话:「设计成水面波浪样式的 鼠标点击的对应区域为波浪中心 向外扩张」,
291
+ 第二轮补充:「波浪要覆盖整个矩形 而且玻璃不是很明显 并且速度是从快到慢的」。
292
+ 做法:三圈同心环,圆心 = 右键点,从 scale(.04) 推到 scale(1),边推边淡出。
293
+ - 环径 420px(半径 210):卡片 112×160 的**对角线**约 195px,点击在最角落时
294
+ 最远端点离圆心也有 195px,所以半径必须 ≥195 才能覆盖整卡。210 留了余量,
295
+ 推到终点时环已经完全跑出卡片边界、被圆角裁掉,不会看到"环停在半路"。
296
+ - 只放一圈(2026-10-31:「只显示一条就够了」):三圈错开会读成重复的同心环。
297
+ - 亮度要够(2026-10-31:「波浪还是太暗了」):第一版边框 .5、辉光 .22,
298
+ 在深底上几乎看不出是玻璃。现在边 2.5px/.95、外辉光 .6、内辉光 .45,
299
+ 并把关键帧的峰值 opacity 拉到 1 —— 深色卡片上要"亮得起来"才看得见波纹。 */
300
+ .balance-fab__ripple-layer{
301
+ position:absolute;inset:0;overflow:hidden;border-radius:inherit;
302
+ pointer-events:none;z-index:2;
303
+ }
304
+ .balance-fab__ripple{
305
+ position:absolute;width:420px;height:420px;margin:-210px 0 0 -210px;
306
+ border-radius:50%;border:2.5px solid rgba(255,255,255,.95);
307
+ box-shadow:
308
+ 0 0 26px 7px rgba(255,255,255,.6),
309
+ 0 0 64px 18px rgba(255,255,255,.3),
310
+ inset 0 0 30px 9px rgba(255,255,255,.45);
311
+ transform:scale(.04);opacity:0;
312
+ animation:balance-ripple .7s linear forwards;
313
+ }
314
+ /* ⚠️ 「先快后慢」靠**关键帧的位移分布**表达,不是靠 ease-out(2026-10-31):
315
+ 强 ease-out 的末段斜率趋 0,环会在边缘长时间爬行、最后"卡住再消失",
316
+ 读起来是顿住而不是减速。这里 linear 对每个关键帧区间各自生效,
317
+ 所以每段速度 = 该段位移 / 该段时间,改位移就改了速度:
318
+ 0–12% scale .04→.22 .18/12% = 1.50 倍速 起步冲出去
319
+ 12–40% scale .22→.58 .36/28% = 1.29 倍速 惯性推进
320
+ 40–72% scale .58→.82 .24/32% = 0.75 倍速 开始减速
321
+ 72–100% scale .82→1.00 .18/28% = 0.64 倍速 收尾放缓
322
+ 亮度同步衰减,推到边缘时正好消失。 */
323
+ @keyframes balance-ripple{
324
+ 0%{transform:scale(.04);opacity:0}
325
+ 10%{transform:scale(.20);opacity:1}
326
+ 40%{transform:scale(.58);opacity:.92}
327
+ 72%{transform:scale(.82);opacity:.6}
328
+ 100%{transform:scale(1);opacity:0}
329
+ }
330
+ /* 尊重系统的「减少动态效果」:不荡水波,只留静态弱化(与展开态同一条规矩)。 */
331
+ @media (prefers-reduced-motion:reduce){
332
+ .balance-fab__ripple{animation:none;opacity:0}
333
+ }
71
334
  /* 尊重系统的「减少动态效果」:不播,只留 --stale 的静态弱化 */
72
335
  /* 浪潮白条不受 prefers-reduced-motion 影响:它是右键刷新的签名动效,故意保留 */
73
336
  /* 旧版余额行用到的图标与「余额」二字(footerStyle === "classic" 时才渲染) */
74
337
  .balance-footer__icon{display:inline-flex;flex:none;color:var(--dsw-alias-label-primary)}
75
338
  .balance-footer__label{flex:none;color:var(--dsw-alias-label-primary)}
76
- .balance-footer__value{box-sizing:border-box;position:relative;z-index:0;isolation:isolate;display:inline-flex;align-items:center;justify-content: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:auto;margin-right:4px;text-decoration:none;cursor:pointer;transition:transform .15s var(--ds-ease-in-out,.2s);flex:none;/* 宽度恒定 121px:靠「固定 px + 位数多就缩字号」两条一起实现。
339
+ .balance-footer__value{box-sizing:border-box;position:relative;z-index:0;isolation:isolate;display:inline-flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums;font-weight:500;font-synthesis:none;font-size:16px;line-height:1;white-space:nowrap;padding:7px 14px;border-radius:999px;corner-shape:round;margin-left:auto;margin-right:4px;text-decoration:none;cursor:pointer;transition:transform .15s var(--ds-ease-in-out,.2s),box-shadow .18s var(--ds-ease-in-out,.2s),color .42s var(--ds-ease-in-out,.2s),background .42s var(--ds-ease-in-out,.2s);flex:none;/* 宽度恒定 121px:靠「固定 px + 位数多就缩字号」两条一起实现。
77
340
  ⚠️ 开头的 box-sizing:border-box 是前提,不能省:min-width 默认算的是「内容盒」,
78
341
  少了它 121px 会变成 121 + 左右 padding 28px = 149px 总宽,胶囊凭空胖一圈
79
342
  (用户反馈「我的余额胶囊为什么这么长」,真实客户端实测正是 149px)。DSH 页面上
@@ -91,13 +354,37 @@ window.__ModuleLoader__.load({
91
354
  /* 余额位数多时降档,配合上面的固定宽度,让长金额也收在 121px 内 */
92
355
  .balance-footer__value--dense{font-size:13px}
93
356
  .balance-footer__value--dense2{font-size:11.5px}
94
- .balance-footer__value:hover{filter:brightness(.85);transform:scale(1.06)}
357
+ /* 悬停反馈**不许用 filter:brightness**(2026-10-31:「播放手动刷新的动画时余额颜色怎么变了」)。
358
+ filter 作用在整个元素上,连文字带背景一起乘 0.85 —— 而右键刷新时鼠标必然停在胶囊上,
359
+ 于是 :hover 和刷新动画同时生效,橙色(低余额)被压暗一档,看起来就是"动画把颜色改了"。
360
+ 实测就是这个:同一张橙底,悬停前后肉眼可辨。
361
+ 现在只做位移动画 + 底色加深,**文字颜色全程恒定**。 */
362
+ /* hover 反馈按**布局**分两套(2026-10-31:「我需要旧版是胶囊放大 新版是泛光」)。
363
+ 这里的「旧版 / 新版」指的是两种布局,不是插件版本:
364
+ · 旧版 = classic 布局(.balance-footer--classic,胶囊靠右) → 整个胶囊放大
365
+ · 新版 = stacked 布局(.balance-footer--stacked,时段词在下方) → 泛光,几何不动
366
+ 两套都保留底色加深(下面 --healthy:hover / --low:hover),文字颜色全程恒定。
367
+ ⚠️ 不要统一成一种:用户在两种布局里要的是不同手感。
368
+ ⚠️ 放大那套已知副作用:16px→16.96px 落在非整像素 + 提升合成层,次像素抗锯齿失效,
369
+ 笔画比静止时略粗、边缘略软。这是换「胶囊变大」的既定代价,别再改成 font-size。 */
370
+ /* 旧版布局:胶囊放大 */
371
+ .balance-footer--classic .balance-footer__value:hover{transform:scale(1.06)}
372
+ /* 新版布局:泛光(膨胀 + 柔光都交给 box-shadow,不重绘文字、不建合成层) */
373
+ .balance-footer--stacked .balance-footer__value:hover{
374
+ box-shadow:0 0 5px 1.5px rgba(34,197,94,.26),0 0 20px 3px rgba(34,197,94,.34)}
375
+ .balance-footer--stacked .balance-footer__value--low:hover{
376
+ box-shadow:0 0 5px 1.5px rgba(217,119,6,.26),0 0 20px 3px rgba(217,119,6,.34)}
377
+ .balance-footer__value--healthy:hover{background:rgba(34,197,94,.22)}
378
+ .balance-footer__value--low:hover{background:rgba(217,119,6,.22)}
95
379
  /* 旧版布局:胶囊靠右 —— 这是备份 v1.3.1 里的原始写法(margin-left:auto)。
96
380
  不能放到基础规则上,新版 timeline 用 margin:0 auto 居中,会和它打架。 */
97
381
  .balance-footer--classic .balance-footer__value{margin-left:auto}
98
382
  .balance-footer__value--healthy{color:var(--dsw-alias-state-success-primary);background:rgba(34,197,94,.15)}
99
383
  /* 正在刷新:金额栏压成半透明旧值,和左侧的时段占位呼应,避免旧数字看起来像刚更新的 */
100
- .balance-footer__value--stale{opacity:.9}
384
+ /* 刷新中不再整体降透明度(2026-10-31:「播放手动刷新的动画时余额颜色怎么变了」)。
385
+ 原来这里 opacity:.9,和上面的 hover 变暗叠在一起,刷新那一下颜色明显发灰。
386
+ 刷新反馈已经有白光扫过 + 数字擦除两件事,不需要再压一层透明度 ——
387
+ 金额胶囊的颜色在所有状态下必须恒定。 */
101
388
  /* 手动刷新(右键):金额被白光从左擦出来 —— 与输入区「本会话消耗」同一套手法。
102
389
  **必须是双层**(2026-10-30 用户反馈「没被光条扫到的区域接着显示 不要直接消失了」):
103
390
  底层 __reveal-base 常显(上一帧的值),上层 __reveal-sweep 才是被 clip-path 裁切的那层。
@@ -112,9 +399,16 @@ window.__ModuleLoader__.load({
112
399
  旧值的宽度照样会算进容器(实测 8.04 稳态 73.66px,带上旧值 11.04 的底层就变 83.53px)。
113
400
  后果是自动刷新时胶囊先宽 10px 再缩回,看着像数字在左右跳。
114
401
  拉出流之后宽度只由 sweep 决定,稳态与滚动过程中完全一致。 */
115
- .balance-footer__reveal-base{position:absolute;inset:0 auto 0 0;display:flex;align-items:center;white-space:nowrap;animation:balance-base-in .34s cubic-bezier(.34,.7,.26,1) both}
402
+ .balance-footer__reveal-base{position:absolute;inset:0 auto 0 0;display:flex;align-items:center;white-space:nowrap}
116
403
  @keyframes balance-base-in{from{clip-path:inset(0 0 0 0)}to{clip-path:inset(0 0 0 100%)}}
117
- .balance-footer__reveal-sweep{position:relative;overflow:hidden;animation:balance-calc-reveal .34s cubic-bezier(.34,.7,.26,1) both}
404
+ .balance-footer__reveal-sweep{position:relative;overflow:hidden}
405
+ /* ⚠️ 擦除动画**只在真正手动刷新时**才挂(2026-10-31 第三轮:「重播白光没事 关键是
406
+ 扣费动画也会重播」)。原来这两条 animation 无条件写在元素上,而它们是挂在元素上的
407
+ CSS 动画 —— 折叠/展开侧栏会让 BalanceFooter 卸载重挂、DOM 节点重建,动画就从 0 秒
408
+ 重新播一遍,看起来就是「凭空扣了一次费」。而 revealSeq 是组件 state,重挂后归 0,
409
+ 所以用 --animate 修饰类做闸门,与 revealSeq 严格同步:只有它 +1 过才有这个类。 */
410
+ .balance-footer__reveal--animate .balance-footer__reveal-base{animation:balance-base-in .34s cubic-bezier(.34,.7,.26,1) both}
411
+ .balance-footer__reveal--animate .balance-footer__reveal-sweep{animation:balance-calc-reveal .34s cubic-bezier(.34,.7,.26,1) both}
118
412
  /* 胶囊内部**不放光条**(2026-10-30 用户要求「余额内的光条撤了」):
119
413
  外面那层 .balance-footer__wave 已经在整块扫白光了,胶囊里再来一道就是两道白光打架。
120
414
  这里只保留 clip 揭示 —— 数字从左被「擦」出来,边界交给外面那道光。 */
@@ -150,18 +444,113 @@ window.__ModuleLoader__.load({
150
444
  height:auto,撑得住),而不是把胶囊顶出去。 */
151
445
  .balance-footer__timeline-meta{display:inline-flex;align-items:center;gap:6px;row-gap:3px;flex-wrap:wrap;justify-content:center;max-width:100%;font-size:11px;line-height:1;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary,#8a8a8a);margin:0}
152
446
  .balance-footer__timeline-meta-item{flex:none;white-space:nowrap}
153
- /* 「今日 ¥x」独占第一行(flex-basis:100%)并居中,与下面「空闲 剩 3h57m」那一行左对齐成组。 */
154
- .balance-footer__timeline-meta-item--today{flex:0 0 100%;text-align:center}
155
- /* 「今日 ¥x」数据没到时的占位:不可见但占位,高度先占好,数据到了只换文字、不动布局。
447
+ /* 「今日 ¥x」独占第一行(flex-basis:100%)并居中,与下面「空闲 剩 3h57m」那一行左对齐成组。
448
+ inline-flex 是为了把「今日」/「¥」/ 滚动数字排成一行(基线对齐,小数点不会上下错)。
156
449
  ⚠️ 不要改回条件渲染或 display:none —— 那会让 meta 行从 1 行变 2 行,
157
- 侧栏底部整体被顶下 14px,冷启动时就是「今日余额弹出来那下卡了」(2026-10-30 反馈)。 */
158
- .balance-footer__timeline-meta-item--pending{visibility:hidden}
159
- /* 占位换成真实值时淡入,而不是「啪」一下闪出来。
160
- ⚠️ 用 animation 不用 transition —— 元素刚从 visibility:hidden 显形,
161
- 起点值不一定被浏览器算过,transition 可能整段被吞(同指针那条教训,2026-10-30)。
162
- 类常驻但 key 不变,所以只在「占位 → 真实值」这一次播;之后金额随轮询变化不会重播。 */
163
- .balance-footer__timeline-meta-item--in{animation:balance-today-in .3s cubic-bezier(.25,.8,.35,1) both}
164
- @keyframes balance-today-in{from{opacity:0}to{opacity:1}}
450
+ 侧栏底部整体被顶下 14px,冷启动时就是「今日余额弹出来那下卡了」(2026-10-30 反馈)。
451
+ 等待态以前是整串 visibility:hidden,用户嫌「闪」;2026-10-31 改成
452
+ 「今日」二字常显、只有金额部位在等(--today-waiting),且动画只落在金额上。 */
453
+ .balance-footer__timeline-meta-item--today{flex:0 0 100%;display:inline-flex;align-items:baseline;justify-content:center;gap:3px;text-align:center}
454
+ .balance-footer__timeline-meta-item--today-waiting{align-items:center}
455
+ /* 等待态跟消耗行同一套语言(设置里选哪种,这里就哪种),但不能照搬几何尺寸:
456
+ ⚠️ 消耗行的白雾是 5 团 52×26、blur 4.2px 的光球,塞进 9px 高的 meta 行会被裁成一团
457
+ 灰板、且看不出在动(2026-10-31 实测截图)。所以小尺寸单独标定。 */
458
+ /* 宽度自适应,**不要加 min-width** —— 加了整块就不居中了(见 --today-value 的注释)。
459
+ 高度 11px:数字行高实测 13.67px,原来 6px 只能做一道扫光、看着是「很细的长条」;
460
+ 抬到 11px 才有空间做「光团 + 毛玻璃」两层。11px 仍小于行高,不会把 meta 行撑高。 */
461
+ .balance-footer__today-wait{display:inline-flex;align-items:center;justify-content:center}
462
+ /* 2026-10-31 第五轮定案:**不是斜向扫光条**。用户原话「底层白色不规则来回流动 然后顶上
463
+ 盖了层毛玻璃 雾透的感觉」。所以:
464
+ 底层 = 两个各自独立飘移的白色光团(radial-gradient,不是 linear 的条),
465
+ 半径、相位、时长、方向全都错开 —— 叠起来才有"不规则"的流动感,
466
+ 单条 linear 扫光永远是一条直线在平移。
467
+ 顶层 = .balance-footer__today-fog 用真的 backdrop-filter 做毛玻璃,
468
+ **模糊的是底下的光团本身**,光因此变成雾里的光,而不是锐利白条上蒙一层半透明色。
469
+ 伪元素 ::before / ::after 全部分给两层光团,毛玻璃层是第三个真实元素(伪元素没有第三层可用)。
470
+ isolation:isolate 让 backdrop-filter 只吃这个胶囊内部的内容,不会把外面的侧栏背景也糊进来。 */
471
+ .balance-footer__today-wait .balance-usage-mist{position:relative;overflow:hidden;isolation:isolate;width:5.5ch;min-width:5.5ch;height:11px;border-radius:999px;corner-shape:round;background:rgba(255,255,255,.03);box-shadow:0 0 6px rgba(255,255,255,.055);-webkit-mask-image:none;mask-image:none;animation:none}
472
+ /* 光团 A:两团不同大小的柔光,整体左右来回(alternate)+ 缩放 + **形状形变**。
473
+ inset 负值让光团能飘出胶囊边界,再被 overflow:hidden 裁掉 —— 进出都是渐隐的,不会硬切。
474
+ ⚠️ 半径必须小(30%/26% 一档),大了两团就糊满整条、退化成一块均匀白板,
475
+ 实测「不规则流动」立刻消失 —— 2026-10-31 第一版用 42%/30% 就是这样。
476
+ ⚠️ 液体感的来源不是位移,是**border-radius 百分比形变 + blur 融合**
477
+ (2026-10-31:「让里面看起来更像是流动的液体」)。只做 translate + scale 时,
478
+ 形状是固定的一块光在滑过,读起来是"光斑移动";让四角半径各自呼吸、
479
+ 再叠一层 blur 把两团光的边缘揉在一起,才像有粘性的流体在挤。
480
+ border-radius 的斜杠语法两半都是长度对,可插值,能进 keyframes。 */
481
+ .balance-footer__today-wait .balance-usage-mist::before{content:"";position:absolute;inset:-45% -35%;pointer-events:none;border-radius:46% 54% 62% 38% / 55% 42% 58% 45%;filter:blur(2.4px);background:radial-gradient(30% 58% at 28% 34%,rgba(255,255,255,.62),rgba(255,255,255,0) 70%),radial-gradient(22% 46% at 70% 70%,rgba(255,255,255,.34),rgba(255,255,255,0) 72%);animation:balance-today-flow-a 1.4s ease-in-out -.5s infinite alternate}
482
+ /* 光团 B:更小更亮的芯,方向与 A 相反、节奏更慢(2.2s vs 1.4s)。
483
+ 两个非整倍数的时长叠在一起,交叉点永远不会落在同一相位,视觉上就"没有周期"。
484
+ ⚠️ 这两层的 animation-delay 都是**负值**,不是随便填的:ease-in-out 从 0 速度起步,
485
+ 等待态出现的头 300~400ms 里光团几乎不动 —— 用户 2026-10-31:「里面的滚动太慢了
486
+ 导致刚加载出来看起来没在动」。负 delay 让动画直接从「速度已经起来」的相位开始,
487
+ 一出现就在流。时长 2026-10-31 再收一轮(1.9→1.4 / 3.1→2.2),非整数倍关系保持不变。 */
488
+ .balance-footer__today-wait .balance-usage-mist::after{content:"";position:absolute;inset:-45% -35%;pointer-events:none;border-radius:58% 42% 40% 60% / 44% 56% 40% 60%;filter:blur(3px);background:radial-gradient(19% 40% at 62% 40%,rgba(255,255,255,.72),rgba(255,255,255,0) 74%),radial-gradient(25% 36% at 24% 72%,rgba(255,255,255,.26),rgba(255,255,255,0) 78%);animation:balance-today-flow-b 2.2s ease-in-out -1.1s infinite alternate}
489
+ .balance-footer__today-wait .balance-usage-mist__orb{display:none}
490
+ /* 毛玻璃层:盖在两层光团之上的第三层。
491
+ ⚠️ 关键是 backdrop-filter 而不是「半透明白色贴片」—— 前者真的把底下的光团糊掉,
492
+ 再叠一层极淡的白,才有"雾透";后者只是在光上盖一块灰,看着是脏而不是雾。
493
+ 不透明度务必压低(累计约 .05~.08),高了就把两层光全吃掉、变成一块实心板。
494
+ blur 加到 3.4px:2.6px 时光团的硬芯还能透出来,看着是"亮带"而不是"雾里的光"。 */
495
+ .balance-footer__today-fog{position:absolute;inset:0;border-radius:inherit;pointer-events:none;background:linear-gradient(180deg,rgba(255,255,255,.05),rgba(255,255,255,.015));-webkit-backdrop-filter:blur(3.4px) saturate(1.2);backdrop-filter:blur(3.4px) saturate(1.2);animation:balance-today-fog-breathe 2.8s ease-in-out -.7s infinite}
496
+ /* 雾的呼吸:只动不透明度,**不动 transform** —— 位移交给底下两层光团,
497
+ 雾要是也平移,整块会跟着走,就不像"盖着的一层"了。 */
498
+ @keyframes balance-today-fog-breathe{0%{opacity:.82}50%{opacity:1}100%{opacity:.82}}
499
+ /* 光团 A 的轨迹是折线不是横线:X 来回 + **Y 大幅上下** + 正负倾斜交替。
500
+ ⚠️ 容器只有 11px 高,Y 方向的百分比是相对**元素自身高度**(inset:-45% 后约 21px)算的,
501
+ 所以 ±4% 只等于 0.84px —— 肉眼完全看不出垂直位移,读起来就是"只有左右在动"
502
+ (2026-10-31:「当前的流动感觉就是只有左右」)。Y 必须放大到 ±17% 一档(约 3.6px),
503
+ 配合 ±8deg 的倾斜,扁平区域里才读得出"斜着涌过去"。
504
+ 倾斜也参与形变:rotate 让光团的上下边缘一端抬高、一端压低,等效于不规则拉伸。 */
505
+ @keyframes balance-today-flow-a{
506
+ 0%{transform:translate3d(-14%,-17%,0) scale(1.02) rotate(-8deg);border-radius:44% 56% 64% 36% / 56% 40% 60% 44%}
507
+ 50%{transform:translate3d(2%,18%,0) scale(1.16) rotate(7deg);border-radius:62% 38% 42% 58% / 38% 62% 40% 60%}
508
+ 100%{transform:translate3d(16%,-13%,0) scale(1.06) rotate(-6deg);border-radius:36% 64% 58% 42% / 62% 44% 56% 38%}
509
+ }
510
+ /* 光团 B 的 Y 相位与 A **严格错开**(A 在上时 B 在下),两层才会互相穿过去,
511
+ 而不是一起上下晃 —— 同步就退化成整块平移。
512
+ Y 幅度同样是 ±17% 一档,方向与 A 相反,倾斜方向也相反。 */
513
+ @keyframes balance-today-flow-b{
514
+ 0%{transform:translate3d(20%,16%,0) scale(1.22) rotate(8deg);border-radius:60% 40% 38% 62% / 42% 58% 42% 58%}
515
+ 50%{transform:translate3d(2%,-18%,0) scale(.98) rotate(-7deg);border-radius:38% 62% 60% 40% / 60% 40% 62% 38%}
516
+ 100%{transform:translate3d(-16%,12%,0) scale(1.1) rotate(6deg);border-radius:56% 44% 42% 58% / 40% 60% 44% 56%}
517
+ }
518
+ @media (prefers-reduced-motion:reduce){
519
+ /* 停流但保持"有光在雾后面"的样子:光团定在中位、雾照常盖着。
520
+ 不能让它们退回透明,否则等待态就只剩一个空胶囊。 */
521
+ .balance-footer__today-wait .balance-usage-mist::before{animation:none;transform:translate3d(0,0,0);opacity:.9}
522
+ .balance-footer__today-wait .balance-usage-mist::after{animation:none;transform:translate3d(0,0,0);opacity:.8}
523
+ .balance-footer__today-fog{animation:none;opacity:.92}
524
+ }
525
+ /* 「计算中」在这里去掉胶囊底:meta 行只有 11px 字号,套上 pill 会更宽并挤出容器,
526
+ 直接内联文字反而干净(消耗行那边仍保留 pill)。 */
527
+ .balance-footer__today-wait .balance-usage-calc{background:0 0;height:auto;padding:0;font-size:9px;line-height:1;white-space:nowrap;gap:0}
528
+ /* 等待态换成真实值时不再整块淡入 —— 用户 2026-10-31 反馈「今日消耗刷新出来时
529
+ 「今日」二字也会闪一下」。现在动画只落在金额上(逐位滚动 buildRollCells),
530
+ 「今日」二字恒定不动,所以这条整块淡入规则连同它的 keyframes 一起删掉了。 */
531
+ /* 「今日」二字:恒定不动。⚠️ 绝不给它加任何 animation/transition,
532
+ 否则每次金额刷新它都会跟着闪一次(就是用户报的那个现象)。 */
533
+ /* 金额长度变化时,「今日」会被整块重新居中推到新位置。
534
+ ⚠️ 这里不再用 keyframes:5px 的假位移看着仍是"跳一下再归位",
535
+ 用户 2026-10-31 明确要「滑动过去」。真正的滑动由 JS 量出实际位移再补 ——
536
+ 见 usePrePaintEffect 里给 label 写 style 的那段(transition 由它临时加上)。 */
537
+ .balance-footer__today-label{display:inline-block}
538
+ /* ¥ 与数字之间的呼吸:¥ 是宽字符,紧贴数字会显得糊在一起
539
+ (2026-10-31 用户指出「数字是不是太靠近¥了」)。2px 在 11px 字号下刚刚好,
540
+ 再多就会被读成两个独立的词。 */
541
+ .balance-footer__today-cur{margin-right:2px}
542
+ /* 金额区:与会话消耗同一个滚动实现(buildRollCells + .balance-footer__roll)。
543
+ ¥ 放在滚动区外层 —— buildRollCells 按「数字开头」拆分,¥ 在里面会被当成整串 tail,
544
+ 变成一格整体平移,就没有逐位过位的效果了。 */
545
+ /* 宽度自适应 —— **不要加 min-width**。
546
+ 试过 min-width:48px 让「今日」彻底不动,结果是金额窄时右侧留白、
547
+ 整块不再居中,标签被推到左边,看着很不协调(2026-10-31 用户截图反馈)。
548
+ 所以宽度跟着数字走,位置变化由 usePrePaintEffect 里的位移过渡吃掉。 */
549
+ .balance-footer__today-value{position:relative;isolation:isolate;display:inline-flex;align-items:baseline;justify-content:center;white-space:pre;font-variant-numeric:tabular-nums}
550
+ /* 揭幕期间的加载动画层现在由 .balance-footer__today-reveal__mist 承担(见上方定义),
551
+ 它和数字层是同容器的兄弟节点、宽度天然相等,靠 linear 遮罩挖洞露出一部分数字。
552
+ 旧的「absolute 垫在数字下面 + 数字自己 clip」方案已废弃:两者盒子宽度不同,
553
+ 挖洞边界和揭幕边界对不齐,交界处会同时出现胶囊和数字(2026-10-31:「数字怎么显示在动画里了」)。 */
165
554
  /* meta 的第二段(时段词 + 剩余时间)作为一个整体不拆开:折行时它整段落到
166
555
  「今日 ¥x」下面那行,于是「空闲」永远和「剩 3h57m」同行。 */
167
556
  .balance-footer__timeline-meta-group{display:inline-flex;align-items:center;gap:6px;flex:0 1 auto;min-width:0;white-space:nowrap;max-width:100%}
@@ -218,6 +607,9 @@ window.__ModuleLoader__.load({
218
607
  /* 有新版本时的文字提示:品牌色实心小胶囊,绝对定位压在图标右上,
219
608
  视觉上落在「余额与用量」几个字的上方。不做脉冲 —— 那是红点的语言,且静止更醒目。 */
220
609
  .balance-panel-icon__new{position:absolute;left:10px;top:-10px;box-sizing:border-box;padding:1px 4px;border-radius:999px;corner-shape:round;background:#4d6bfe;color:var(--dsw-alias-label-primary-foreground,#fff);font-size:8.5px;line-height:11px;font-weight:600;letter-spacing:.1px;white-space:nowrap;pointer-events:none;box-shadow:0 1px 4px color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 55%,transparent)}
610
+ /* 有新功能时的入口标签(文案就是 NEW,比「新版本!」短,宽度收紧一点、
611
+ 字号抬到 9px 让三个字母站得住)。品牌色实心 + 轻投影,跟「新版本!」同一套语言。 */
612
+ .balance-panel-icon__new--feature{padding:1px 3.5px;font-size:9px;line-height:11px;letter-spacing:.4px}
221
613
  @keyframes balance-dot-pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(.82);opacity:.72}}
222
614
  @media (prefers-reduced-motion:reduce){.balance-panel-icon__dot{animation:none}}
223
615
  /* ── 侧栏面板页(点左侧「余额与用量」后渲染在主区域) ──────────────────── */
@@ -241,18 +633,111 @@ window.__ModuleLoader__.load({
241
633
  默认是 superellipse(1.5)——不覆盖的话 border-radius:50% 会被渲染成"圆角方形"
242
634
  (squircle),根本不是圆(2026-09-28 用户反馈「很奇怪啊」,截图是 squircle)。
243
635
  DSH 自己的 .pill 也是这么显式写 corner-shape:round 抵消的。 */
244
- .balance-fab{box-sizing:border-box;position:fixed;left:44px;bottom:12px;z-index:9998;width:60px;height:60px;padding:0;border-radius:50%;corner-shape:round;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:1;font-variant-numeric:tabular-nums;letter-spacing:-.01em;text-decoration:none;white-space:nowrap;overflow:hidden;cursor:pointer;pointer-events:auto;background:color-mix(in srgb,var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-base,#1b1b1c)) 58%,transparent);-webkit-backdrop-filter:blur(18px) saturate(180%);backdrop-filter:blur(18px) saturate(180%);border:1px solid color-mix(in srgb,var(--dsw-alias-label-primary) 14%,transparent);box-shadow:inset 0 1px 0 color-mix(in srgb,#fff 26%,transparent),inset 0 -1px 1px color-mix(in srgb,#fff 8%,transparent),0 6px 20px rgba(0,0,0,.28);transition:transform .18s var(--ds-ease-in-out,ease-out),background .18s var(--ds-ease-in-out,ease-out),box-shadow .18s var(--ds-ease-in-out,ease-out)}
636
+ .balance-fab{box-sizing:border-box;position:fixed;left:44px;bottom:140px;z-index:9998;width:60px;height:60px;padding:0;border-radius:50%;corner-shape:round;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:1;font-variant-numeric:tabular-nums;letter-spacing:-.01em;text-decoration:none;white-space:nowrap;overflow:hidden;cursor:pointer;pointer-events:auto;background:color-mix(in srgb,var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-base,#1b1b1c)) 58%,transparent);-webkit-backdrop-filter:blur(18px) saturate(180%);backdrop-filter:blur(18px) saturate(180%);border:1px solid color-mix(in srgb,var(--dsw-alias-label-primary) 14%,transparent);box-shadow:inset 0 1px 0 color-mix(in srgb,#fff 26%,transparent),inset 0 -1px 1px color-mix(in srgb,#fff 8%,transparent),0 6px 20px rgba(0,0,0,.28);transition:transform .18s var(--ds-ease-in-out,ease-out),color .42s var(--ds-ease-in-out,ease-out),background .42s var(--ds-ease-in-out,ease-out),box-shadow .18s var(--ds-ease-in-out,ease-out)}
245
637
  .balance-fab:hover{transform:scale(1.06);background:color-mix(in srgb,var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-base,#1b1b1c)) 76%,transparent);box-shadow:inset 0 1px 0 color-mix(in srgb,#fff 34%,transparent),inset 0 -1px 1px color-mix(in srgb,#fff 10%,transparent),0 10px 26px rgba(0,0,0,.34)}
246
638
  .balance-fab:active{transform:scale(.95)}
247
639
  /* 金额偏长时(如 ¥1234.56)自动收一号字,免得 60px 圆里挤爆 */
248
640
  .balance-fab--dense{font-size:11px;letter-spacing:-.03em}
641
+ /* ── 新版(timeline)收起态:竖排矩形小卡(2026-10-31) ─────────────────────
642
+ 用户要求「显示一个矩形 里面显示跟新版里一样的东西 动画也要在」。
643
+ 盖掉圆钮那套:圆形/居中单行 → 圆角矩形/竖排四段(余额、时段词、刻度条、今日)。
644
+ 宽度由内联 style 给(= fabSize),高度由内容撑开,所以不写死 height。
645
+ border-radius 从 50% 收到 14px:60px 宽的圆形卡片放四行内容会显得像个药丸,
646
+ 而且四角要能容纳 15px 高的刻度条。 */
647
+ .balance-fab--rect{
648
+ height:auto;padding:12px 9px 11px;border-radius:18px;
649
+ flex-direction:column;align-items:stretch;justify-content:flex-start;gap:5px;
650
+ font-size:17px;line-height:1;
651
+ /* 中文在 9px 下字号落在亚像素里,笔画会发糊;11px 起字形才对齐(2026-10-31:
652
+ 「可以再大一点 让字体显示清楚」)。所以时段/今日两行全部提到 11px,
653
+ 容器宽度同步 112px,留够「空闲 剩 1d20h」「今日 ¥21.65」的整行宽度。 */
654
+ -webkit-font-smoothing:antialiased;
655
+ text-rendering:optimizeLegibility;
656
+ /* ⚠️ 盖掉圆钮那套边缘光(2026-10-31:「外面那圈边框不要跟之前一样」)。
657
+ 圆钮的 inset 0 1px 0 #fff 26% 与 inset 0 -1px 1px #fff 8% 在 60px 圆里
658
+ 刚好读成玻璃高光,但拉成 81×148 的矩形后,两条 inset 会在内侧连成一整圈亮边,
659
+ 再叠上 border,看起来就是"内外两层描边"。矩形只要一条淡边 + 外投影。 */
660
+ border:1px solid color-mix(in srgb,var(--dsw-alias-label-primary) 9%,transparent);
661
+ box-shadow:0 4px 16px rgba(0,0,0,.24);
662
+ }
663
+ /* ⚠️ hover 也必须重写 box-shadow 和 border-color:.balance-fab:hover 里还有一整套
664
+ inset 高光,选择器优先级相同(都是 0-2-0)而它写在文件更前,所以这里必须自己声明,
665
+ 否则鼠标一移上去那圈内亮边又回来了。 */
666
+ .balance-fab--rect:hover{
667
+ transform:scale(1.03);
668
+ border-color:color-mix(in srgb,var(--dsw-alias-label-primary) 14%,transparent);
669
+ box-shadow:0 6px 20px rgba(0,0,0,.3);
670
+ }
671
+ .balance-fab--rect:active{transform:scale(.97)}
672
+ /* 余额:17px 粗体,居中;宽度锁满以便逐位滚动格对齐 */
673
+ .balance-fab__value{
674
+ display:flex;align-items:center;justify-content:center;
675
+ font-size:17px;font-weight:600;line-height:20px;letter-spacing:-.02em;
676
+ font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;
677
+ }
678
+ .balance-fab__value--low{color:var(--dsw-alias-state-error-primary,#d97706)}
679
+ /* 时段词 + 剩余时长:一行两段,剩余时长用弱化色;11px 是中文清晰度的下限 */
680
+ .balance-fab__period{display:flex;align-items:baseline;justify-content:center;gap:4px;font-size:11px;line-height:13px;font-weight:600;letter-spacing:0;white-space:nowrap;opacity:.92}
681
+ /* ⚠️ 剩余时长必须显式给 color(2026-10-31:「这个剩余怎么带颜色 展开是啥颜色就是啥颜色」)。
682
+ 它嵌在 .balance-fab__period--offpeak/--peak 里,不写 color 就继承峰谷色 → "剩 1d20h" 跟着
683
+ 变绿/变红。展开态那边它走 .balance-footer__timeline-meta-item,是普通标签色,
684
+ 只有"空闲/高峰"两个字才带峰谷色。这里对齐同一个口径。 */
685
+ .balance-fab__remain{color:var(--dsw-alias-label-primary,#e8e8ea);font-weight:500;opacity:.68;font-variant-numeric:tabular-nums}
686
+ .balance-fab__period--peak{color:var(--dsw-alias-state-error-primary,#e5484d)}
687
+ .balance-fab__period--offpeak{color:var(--dsw-alias-state-success-primary,#22c55e)}
688
+ /* 刻度条:复用展开态的 track,高度提到 16px(收起态指针要看得清) */
689
+ .balance-fab__track{display:block;width:100%;min-width:0}
690
+ .balance-fab__track .balance-footer__timeline-track{height:16px;border-radius:999px}
691
+ /* 今日行:「今日」二字常显 + 右边跟着金额/雾。
692
+ gap 用 2px 把标签和金额贴住,读起来是一个整体。 */
693
+ .balance-fab__today{display:flex;align-items:center;justify-content:center;gap:2px;min-height:16px;font-size:11px;line-height:16px;opacity:.82}
694
+ .balance-fab__today--waiting{opacity:.85}
695
+ /* 「今日」二字:弱化一档,让金额仍是主体 */
696
+ .balance-fab__label{font-weight:600;opacity:.62;margin-right:2px}
697
+ /* ── 收起态的今日等待雾(2026-10-31 定稿)────────────────────────────────────
698
+ 结构 = 消耗行那套 5 颗亮芯光球,只把尺寸收到 56px。
699
+ 曾经换成展开态今日专用的「两层 radial 光团 + backdrop-filter 毛玻璃」,效果反而更差:
700
+ 那套是为浅色侧栏背景标的,光团靠背景透出来才是雾透;卡片底是纯深色,
701
+ 同样的结构只会得到一块灰白补丁(无亮芯、边界清楚、看不出在动)。
702
+ 深底上要的是「亮的东西在流动」,所以保留 orb 的白色内核。 */
703
+ /* 雾只占金额那一格(标签「今日」在它左边常显)—— 所以宽度收到 40px,
704
+ 和 ¥21.65 差不多宽,等待态与出值态的行宽不会跳。 */
705
+ .balance-fab__today-mist{display:block;width:40px;min-width:40px;flex:none}
706
+ .balance-fab__today--waiting .balance-usage-mist{
707
+ width:40px;min-width:40px;max-width:40px;height:13px;min-height:13px;margin:0;
708
+ }
709
+ /* 光团在 13px 高度下重标:24×13、blur 2.2px、亮度提到 1.15(深底需要更亮的芯,
710
+ 展开态那套 brightness(.8) 是给浅色侧栏压对比用的)。 */
711
+ .balance-fab__today--waiting .balance-usage-mist__orb{
712
+ width:24px;height:13px;margin-top:-6.5px;
713
+ filter:blur(2.2px) brightness(1.15);opacity:.8;
714
+ }
715
+ .balance-fab__today-inner{display:inline-flex;align-items:baseline;justify-content:center;gap:1px;white-space:nowrap;overflow:hidden;font-variant-numeric:tabular-nums}
716
+ .balance-fab__cur{flex:none}
717
+ /* 收起态今日的擦出(2026-10-31:「浮窗的今日加载动画也要有 从左到右显示出来」→
718
+ 「跟展开不一样啊 展开是没擦到的地方还是加载动效」)。
719
+ 结构与展开态同构:数字在 __body 里被 clip-path 揭开,未扫到的区域由上层的
720
+ .balance-footer__today-reveal__mist 盖着(那层自带 --today-hole 遮罩挖洞动画)。
721
+ ⚠️ position:relative 必须加在 __today-inner 上:mist 是 absolute inset:0,
722
+ 没有定位祖先它会铺到整张卡片上去。 */
723
+ .balance-fab__today-inner{position:relative}
724
+ .balance-fab__today-body{display:inline-flex;align-items:baseline;justify-content:center;gap:1px;white-space:nowrap;font-variant-numeric:tabular-nums}
725
+ .balance-fab__today-inner--enter .balance-fab__today-body{animation:balance-fab-today-wipe .26s cubic-bezier(.33,0,.67,1) both}
726
+ @keyframes balance-fab-today-wipe{from{clip-path:inset(0 100% 0 0)}to{clip-path:inset(0 0 0 0)}}
727
+ /* 雾层容器(2026-10-31:「这个动画容器位置有问题」)。
728
+ ⚠️ 不要写死 height —— 它继承了 .balance-footer__today-reveal__mist 的 inset:0,
729
+ 一旦再给一个固定 height,CSS 会忽略 bottom、改成「贴 top 往下量 13px」,
730
+ 而 __today-inner 是 baseline 对齐的 inline-flex,数字的实际盒子并不从 top:0 开始,
731
+ 于是这层雾整体偏上、和数字错开。现在让它完全由 inset:0 撑满 inner,
732
+ 高度天然等于数字盒,位置就不用猜了。圆角保留,抄数字那一格的样子。 */
733
+ .balance-fab__today-wipe-mist{border-radius:3px;overflow:hidden;min-height:13px}
249
734
  .balance-fab-dark{color:#fff;background:color-mix(in srgb,#e5484d 62%,transparent);border-color:color-mix(in srgb,#fff 22%,transparent)}
250
735
  .balance-fab-dark:hover{background:color-mix(in srgb,#e5484d 78%,transparent)}
251
736
  /* ── 消耗行 pill:规格逐条照抄 DSH 原生 StatsPills 的 .pill(iq1doa_pill)──
252
737
  圆角是 --dsw-radius-sm(小圆角矩形),不是 999px 胶囊;也不要加
253
738
  corner-shape:round —— 原生 hover/展开态就是这个小圆角背景,写成胶囊对不上。 */
254
- .balance-usage-wrap{min-width:0;display:inline-flex}
255
- .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}
739
+ .balance-usage-wrap{min-width:0;display:inline-flex;align-items:center}
740
+ .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;height:calc(1lh + 2px)}
256
741
  .balance-usage-line svg{flex:none;width:14px;height:14px}
257
742
  .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)}
258
743
  .balance-usage-label{text-overflow:ellipsis;min-width:0;overflow:hidden}
@@ -287,7 +772,13 @@ window.__ModuleLoader__.load({
287
772
  /* 「计算中…」的三个点逐颗跳动(设置里 usageLoadingStyle 选 "text" 时才用)。
288
773
  文字本身不动,只有省略号在闪,这是最不打扰的一种「还在算」的信号。
289
774
  ⚠️ 点必须拆成三个独立 span,单个 "…"(U+2026)是一个字,没法分段做动画。 */
290
- .balance-usage-calc{display:inline-flex;align-items:center;height:18px;padding:0 8px;border-radius:999px;corner-shape:round;background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.06))}
775
+ /* 高度必须和其它状态统一成 1lh(20px 档),**不能是 18px**:
776
+ user 2026-10-31「1017 上下微动跳动」—— composer dock 那一行的高度取决于本插件行的
777
+ 最高子元素,等待态 calc 只有 18px、数字态 reveal/mist 是 1lh≈20px,于是每次在
778
+ 「计算中」和数字之间切换,整行就矮一下高一下,上面那排(模型名 / context 1017 / High)
779
+ 跟着上下跳 2px。加载态和结果态的高度必须完全一致,高度差一律不许出现在这一行里。
780
+ 圆角胶囊在 20px 下反而更接近官方 pill 的比例。 */
781
+ .balance-usage-calc{display:inline-flex;align-items:center;height:20px;height:1lh;padding:0 8px;border-radius:999px;corner-shape:round;background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.06))}
291
782
  .balance-usage-calc__dots{display:inline-flex;margin-left:1px}
292
783
  .balance-usage-calc__dot{animation:balance-dot-blink 1.2s ease-in-out infinite}
293
784
  .balance-usage-calc__dot:nth-child(2){animation-delay:.15s}
@@ -296,16 +787,78 @@ window.__ModuleLoader__.load({
296
787
  @media (prefers-reduced-motion:reduce){
297
788
  .balance-usage-calc__dot{animation:none;opacity:.75}
298
789
  }
299
- /* 数字跟着白光一起出来:clip 把这一层从左往右擦开,白光正好压在擦开的边缘上,
300
- 看起来就是「光扫到哪里,数字就亮到哪里」。
301
- ⚠️ 只在**数据到手那一刻**挂载(key 绑 sessionId),所以只擦一次。
302
- 数值之后的变动不走这里(外层不重挂),而是内层逐位滚动 —— 两套动效分开。
303
- 时长 .34s:0.58s 在这个窄 pill 里偏慢(2026-10-30「速度太慢」)。 */
304
- .balance-usage-reveal{position:relative;display:inline-flex;align-items:center;overflow:hidden;height:20px;height:1lh;padding-right:7px;margin-right:-7px;animation:balance-calc-reveal .34s cubic-bezier(.34,.7,.26,1) both}
305
- .balance-usage-reveal::after{content:"";position:absolute;top:50%;left:0;width:6px;height:18px;margin-top:-9px;margin-left:-3px;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 .34s cubic-bezier(.34,.7,.26,1) forwards;pointer-events:none}
790
+ /* 消耗行两段式动效(2026-10-31 用户定稿)。
791
+ ① 等待=「Siri 那种一团光在容器里游动」(用户:「就跟siri一样的动画 然后填充满余额栏」)。
792
+ 做法:把容器当画框(overflow:hidden + 胶囊圆角),里面放 4 团超大的模糊径向光,
793
+ 各自用不同的周期 / 延迟 / 路径在框内横向游走 + 上下起伏 + 缩放呼吸。
794
+ 光团交叠处更亮、被圆角裁掉边缘 —— 这就是 Siri 那种「液体能量」观感的来源。
795
+ 关键三点,少一个就不像:**铺满整条栏**(光团宽度 40–64px,容器本身 100%)、
796
+ **边缘糊开**(radial-gradient 收尾 + blur 3.5px,不能是实心圆)、
797
+ **四团不同步**(同期同相会变成整齐的呼吸灯)。
798
+ 之前的「9 颗小粒子」方案作废:1–2px 的点在深色底上根本看不出在动。
799
+ ② 到手=白光从左往右扫过把数字擦出来,见下方 .balance-usage-reveal。
800
+ 只播一次(key 绑 sessionId),之后数值变动仍走内层逐位滚动,两套动效分开。 */
801
+ /* ⚠️ 这里**不许再挂矩形 linear-gradient 遮罩**(2026-10-31:「我怎么感觉左侧的半圆颜色有问题」)。
802
+ 原来同时有 border-radius:999px(胶囊圆弧)和
803
+ mask-image:linear-gradient(90deg,transparent 0,#000 13%,#000 87%,transparent 100%)
804
+ —— 遮罩是**矩形**的,不跟随圆角:它在左端 13% 处划出一道竖直的透明度跃变,
805
+ 和圆弧边缘完全对不上,于是左半圆被从中切一刀,一半比右边暗、还能看见那条竖线。
806
+ 实测截图对比:带遮罩的左侧暗一块、无遮罩是均匀的胶囊弧。
807
+ 胶囊形状交给 border-radius + overflow:hidden;两端柔和度由 ::before 的椭圆底光
808
+ (边缘收到 .04 透明度)和光团自身的 radial-gradient 收尾负责,不需要矩形渐隐。 */
809
+ .balance-usage-mist{position:relative;display:block;width:100%;min-width:10.2ch;height:20px;height:1lh;overflow:hidden;border-radius:999px;corner-shape:round;animation:balance-siri-pulse 1.9s ease-in-out infinite}
810
+ /* 底光:保证任何时刻整条栏都有光,不留黑缝(只有游动的光团时,间隙处会断开成一截一截)。 */
811
+ .balance-usage-mist::before{content:"";position:absolute;inset:0;border-radius:inherit;background:radial-gradient(ellipse at 50% 50%,rgba(255,255,255,.24) 0%,rgba(255,255,255,.12) 58%,rgba(255,255,255,.04) 100%);animation:balance-siri-base 2.7s ease-in-out infinite}
812
+ @keyframes balance-siri-base{0%,100%{opacity:.6}50%{opacity:1}}
813
+ /* 泛光来自三层叠加:radial-gradient 从纯白起手(0% 处 .99)、blur 把边缘彻底化开、
814
+ 再挂一圈 box-shadow 做大范围外发光。brightness 1.3 把整体顶亮一档,
815
+ 否则 mix-blend-mode:screen 在深底上会显得发灰(2026-10-31 用户:「白色部分能不能亮一点 泛光的感觉」)。 */
816
+ .balance-usage-mist__orb{position:absolute;top:50%;left:-14%;width:var(--ow,52px);height:var(--oh,26px);margin-top:calc(var(--oh,26px) / -2);background:radial-gradient(closest-side,rgba(255,255,255,.99) 0%,rgba(255,255,255,.78) 26%,rgba(255,255,255,.42) 48%,rgba(255,255,255,.16) 68%,rgba(255,255,255,.04) 82%,rgba(255,255,255,0) 92%);box-shadow:0 0 14px 5px rgba(255,255,255,.22),0 0 30px 10px rgba(255,255,255,.1);filter:blur(4.2px) brightness(1.3);mix-blend-mode:screen;border-radius:52% 48% 55% 45%}
817
+ .balance-usage-mist__orb:nth-child(1){animation:balance-siri-a 2.6s cubic-bezier(.45,.05,.55,.95) infinite alternate}
818
+ .balance-usage-mist__orb:nth-child(2){animation:balance-siri-b 3.4s cubic-bezier(.4,.1,.6,.9) infinite alternate;animation-delay:-.7s;--ow:58px;--oh:30px;opacity:.85}
819
+ .balance-usage-mist__orb:nth-child(3){animation:balance-siri-c 2.1s cubic-bezier(.5,0,.5,1) infinite alternate;animation-delay:-1.5s;--ow:40px;--oh:22px}
820
+ .balance-usage-mist__orb:nth-child(4){animation:balance-siri-a 3.9s cubic-bezier(.4,.1,.6,.9) infinite alternate-reverse;animation-delay:-2.2s;--ow:64px;--oh:28px;opacity:.7}
821
+ .balance-usage-mist__orb:nth-child(5){animation:balance-siri-b 2.9s cubic-bezier(.42,.05,.58,.95) infinite alternate;animation-delay:-1.9s;--ow:44px;--oh:24px;opacity:.8}
822
+ @keyframes balance-siri-pulse{0%,100%{opacity:.84}50%{opacity:1}}
823
+ @keyframes balance-siri-a{0%{transform:translate(0,0) scale(.85);border-radius:52% 48% 55% 45%}50%{transform:translate(115%,-10%) scale(1.18);border-radius:45% 55% 42% 58%}100%{transform:translate(235%,7%) scale(.9);border-radius:58% 42% 50% 50%}}
824
+ @keyframes balance-siri-b{0%{transform:translate(175%,9%) scale(1.06);border-radius:47% 53% 58% 42%}50%{transform:translate(90%,-4%) scale(.94);border-radius:56% 44% 46% 54%}100%{transform:translate(0,-11%) scale(.8);border-radius:50% 50% 43% 57%}}
825
+ @keyframes balance-siri-c{0%{transform:translate(55%,-7%) scale(.72);border-radius:60% 40% 52% 48%}50%{transform:translate(195%,11%) scale(1.22);border-radius:44% 56% 40% 60%}100%{transform:translate(85%,4%) scale(.95);border-radius:53% 47% 57% 43%}}
306
826
  @media (prefers-reduced-motion:reduce){
307
- .balance-usage-reveal{animation:none;clip-path:none}
308
- .balance-usage-reveal::after{animation:none;content:none}
827
+ .balance-usage-mist{animation:none}
828
+ .balance-usage-mist__orb{animation:none;transform:translate(40%,0) scale(1);opacity:.55}
829
+ .balance-usage-mist__orb:nth-child(n+2){display:none}
830
+ }
831
+ /* 数字出场=开窗(2026-10-31 用户第三次定稿:「是从中间往两边打开 跟开窗一样 你现在这样太丑了」)。
832
+ 前两版都被否,原因记在这里别再走回头路:
833
+ ✗ 第一版「白光从左往右扫过」—— 一根白色竖条横穿,用户:「不要那个白色的竖条了」;
834
+ ✗ 第二版「中心光团绽开 + 数字过曝回落」—— 模糊圆光叠在胶囊里发糊,用户:「太丑了」。
835
+ 所以这一版**只留几何动作,不留任何发光物**:两扇窗从正中同时向两侧滑开,数字跟着露出来。
836
+ - clip-path 左右对称,从中缝 inset(0 50% 0 50%) 起步、满宽收尾(-4px 让最右侧字符不被切边);
837
+ - 曲线 cubic-bezier(.2,.86,.24,1):起步快、尾巴稳,读起来是「被推开」而不是「被擦过」;
838
+ - 时长 .28s,比第一版的 .22s 慢一档 —— 开窗这个动作需要看得清,扫过才需要快。
839
+ ⚠️ 只在**数据到手那一刻**挂载(key 绑 sessionId),所以只开一次;
840
+ 之后的数值变动走内层逐位滚动,两套动效分开。
841
+ ⚠️ keyframes 独立命名,**不复用 balance-calc-reveal**(那个是横扫、且被
842
+ 余额行的 .balance-footer__reveal-sweep 共用),两者互不干扰。 */
843
+ .balance-usage-reveal{position:relative;display:inline-flex;align-items:center;overflow:hidden;height:20px;height:1lh;padding:0 4px;margin:0 -4px}
844
+ /* 底层:加载态那团光,用 mask 在**正中间挖一个椭圆洞**并随开窗扩大。
845
+ 数字在洞里显出来,光留在两侧 —— 这就是用户要的「没展开到的地方还是加载动画」,
846
+ 而数字背后是干净的(洞与数字同步扩大,光不会透到字缝里)。
847
+ 洞的边缘走 58%→100% 的渐变,所以边界是柔的,不会像 clip 那样切出一条直线。
848
+ ⚠️ 为什么用 mask 而不是两片元素拼接:两片必须 overflow:hidden 才不互相盖住,
849
+ 而一旦裁切,胶囊的圆角就被切成直角,接缝处还会出现一条硬边(三版都试过,全丑)。
850
+ ⚠️ --balance-hole 必须用 @property 注册成 <percentage> 才能被动画插值,否则 keyframes 里
851
+ 的百分比是离散跳变(0% 到 100% 一帧完成,等于没有动画)。 */
852
+ @property --balance-hole{syntax:"<percentage>";inherits:false;initial-value:0%}
853
+ .balance-usage-reveal__mist{position:absolute;inset:0;display:block;pointer-events:none;-webkit-mask-image:radial-gradient(ellipse var(--balance-hole) 260% at 50% 50%,transparent 58%,#000 100%);mask-image:radial-gradient(ellipse var(--balance-hole) 260% at 50% 50%,transparent 58%,#000 100%);animation:balance-mist-hole .28s cubic-bezier(.2,.86,.24,1) both}
854
+ .balance-usage-reveal__mist .balance-usage-mist{width:100%;min-width:0;height:100%}
855
+ @keyframes balance-mist-hole{0%{--balance-hole:0%}100%{--balance-hole:86%}}
856
+ /* 上层:数字本体,clip-path 从中缝向两侧打开 —— 「开窗」这个动作只发生在这一层。 */
857
+ .balance-usage-reveal__body{position:relative;display:inline-flex;align-items:center;height:20px;height:1lh;animation:balance-window-open .28s cubic-bezier(.2,.86,.24,1) both}
858
+ @keyframes balance-window-open{0%{clip-path:inset(0 50% 0 50%)}100%{clip-path:inset(0 -4px 0 -4px)}}
859
+ @media (prefers-reduced-motion:reduce){
860
+ .balance-usage-reveal__body{animation:none;clip-path:none}
861
+ .balance-usage-reveal__mist{display:none}
309
862
  }
310
863
  .balance-usage-cost-inner{display:inline-flex;align-items:center;white-space:nowrap;height:20px;height:1lh}
311
864
  /* ≈ 的视觉修正:字形本身偏低,用 em 单位上提(跟随字号缩放),右侧留一点呼吸缝。
@@ -326,8 +879,32 @@ window.__ModuleLoader__.load({
326
879
  /* ── 跨会话统计浮层(余额元素的子级,向右/向上展开) ── */
327
880
  .balance-stats-anchor{position:relative;display:flex;min-width:0;overflow:visible}
328
881
  .balance-stats-anchor--wide{width:100%}
329
- .balance-stats-anchor--compact{position:fixed;left:44px;bottom:12px;z-index:9998}
330
- .balance-stats-anchor--compact .balance-fab{position:static}
882
+ /* ── 收起态余额行(2026-10-31)───────────────────────────────────────────────
883
+ 位置在 composer 输入框内部(conversation.composer.bar),排版对齐 DSH 原生模型名:
884
+ 12px、次要色、单行、行高紧凑,不额外撑高输入卡片。
885
+ 不要写 position:absolute / fixed —— 它现在是 composer 里的正常流内元素,
886
+ 由 DSH 的 flex 布局排布;一旦脱离流就会和输入区重叠,正是要避免的事。 */
887
+
888
+ 收起态的 .balance-fab / 统计面板锚点**永远**浮在 body 上:那一刻 DSH 已经把整个
889
+ 侧栏 footArea 设成 display:none,所以它没有被侧栏收纳的可能,和 composer 恒处同一
890
+ 垂直区间。因此避让不能按视口宽度开媒体查询 —— 宽屏只要侧栏收起,一样会压住输入框。
891
+ composer 一坨从窗口底部量:状态栏约 30 + 工具行约 32 + 输入框约 60 + 间距 ≈ 130px。
892
+ 取 140px(上面基础规则里的 bottom),卡片整块坐进消息区,右缘不再压到附件按钮和发送区。
893
+ ⚠️ 历史的「与输入框平齐(bottom:30px)」已被这条取代:平齐的前提是不遮挡。
894
+ 统计面板锚点必须同值,否则面板从旧位置弹出、和卡片错位。 */
895
+ /* 收起态定位(2026-10-31 重构:「收起状态下之前没有这个东西」+「现在界面变成这样了」)。
896
+ ⚠️ 这里两个角色的分工必须记清楚,之前搞反过:
897
+ · **FAB 自己 position:fixed**,bottom/left 直接相对视口,位置可预测;
898
+ · **anchor 只做 Tooltip 的挂载点**,position:static、不参与定位。
899
+ 上一版是反过来的(anchor:fixed + bottom:140px,FAB 在它内部 static),于是 FAB 的
900
+ 实际纵向位置 = anchor 底边 + anchor 的高度,而 anchor 高度由 Tooltip 包裹层和
901
+ 矩形卡的 height:auto 共同决定 —— bottom:140px 根本不是「距视口底 140px」,
902
+ 卡片会飘到贴着窗口底部、正好压在 composer 上。JS 里按 bottom:140 算的遮挡判断
903
+ 也就跟着全错(算的是一处、画的是另一处)。
904
+ 现在几何只有一份来源:FAB 的 bottom(见上面 .balance-fab 主规则),
905
+ JS 那边也直接量 FAB 的真实矩形,不再用常量推算。 */
906
+ .balance-stats-anchor--compact{position:static;left:auto;bottom:auto;z-index:9998}
907
+ .balance-stats-anchor--compact .balance-fab{position:fixed}
331
908
  .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}
332
909
  .balance-stats-layer--closing{pointer-events:none;animation:balance-stats-backdrop-out .14s var(--ds-ease-in-out,ease-in) both}
333
910
  .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))}
@@ -386,7 +963,11 @@ window.__ModuleLoader__.load({
386
963
  .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;white-space:nowrap;background:var(--dsw-alias-bg-module-platform,rgba(127,127,127,.04))}
387
964
  .balance-stats-link:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
388
965
  .balance-stats-link--primary{border-color:transparent;color:#fff;background:var(--dsw-alias-state-business-primary,#4d6bfe)}
389
- .balance-stats-link--primary:hover{background:var(--dsw-alias-state-business-primary-hover,#3f5ae0)}
966
+ /* ⚠️ hover 不要跟随 --dsw-alias-state-business-primary-hover(2026-10-31:
967
+ 「按钮颜色是不是搞反了 怎么鼠标放上去这么显眼」)。DSH 深色主题里那个变量是**提亮**的,
968
+ hover 反而比静止更抢眼,读起来像"反了"。这里固定往黑里兑 12% 做加深,
969
+ 不依赖主题的 hover 语义;base 本身仍走主题变量,换主题整体色相跟着变。 */
970
+ .balance-stats-link--primary:hover{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#4d6bfe) 88%,#000)}
390
971
  .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}
391
972
  .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}
392
973
  .balance-stats-group__hint{color:var(--dsw-alias-label-secondary);font-weight:400;font-variant-numeric:tabular-nums}
@@ -424,12 +1005,43 @@ window.__ModuleLoader__.load({
424
1005
  /* 顶部告示条:设置页最前面的一条范围声明。底色/边框比分组说明再重一档,
425
1006
  文字提到 primary,整块在深色背景里第一个跳出来。 */
426
1007
  .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)}
427
- .balance-settings__notice-icon{flex:none;margin-top:1px;color:var(--dsw-alias-brand-primary,#4d6bfe)}
1008
+ .balance-settings__notice-icon{flex:none;display:block;margin-top:1px;color:var(--dsw-alias-brand-primary,#4d6bfe)}
428
1009
  .balance-settings__notice-text{min-width:0}
1010
+ /* 「本版新增」横幅:不依赖侧栏入口也能知道有新功能。
1011
+ 侧栏那颗 NEW 只在入口可见时才起作用,用户把「侧边栏显示本插件」关掉、
1012
+ 或者从 DSH「插件」页直接进来时,这是唯一的提示(2026-10-31 用户问
1013
+ 「如果用户没打开这个设置页面入口 怎么提示用户有新功能」)。
1014
+ 用品牌色实心胶囊当引子 + 一行白字说明 + 右侧跳转按钮,视觉上比告示条更"新"。 */
1015
+ .balance-settings__newcue{display:flex;align-items:center;gap:9px;margin:0 0 12px;padding:9px 12px;border-radius:var(--dsw-radius-md,8px);border:.5px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 12%,transparent);font-size:12.5px;line-height:18px;color:var(--dsw-alias-label-primary)}
1016
+ .balance-settings__newcue-badge{flex:none;padding:1.5px 6px;border-radius:999px;corner-shape:round;background:var(--dsw-alias-brand-primary,#4d6bfe);color:var(--dsw-alias-label-primary-foreground,#fff);font-size:9.5px;line-height:12px;font-weight:700;letter-spacing:.4px}
1017
+ .balance-settings__newcue-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1018
+ .balance-settings__newcue-btn{flex:none;padding:2px 9px;border-radius:999px;corner-shape:round;border:.5px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 45%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 18%,transparent);color:var(--dsw-alias-brand-primary,#4d6bfe);font:inherit;font-size:11.5px;line-height:16px;cursor:pointer}
1019
+ .balance-settings__newcue-btn:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 28%,transparent)}
1020
+ /* 跳到目标行时闪一下品牌色底,指出"就是这一行"。
1021
+ 五条约束(2026-10-31 用户四轮反馈后才定下来,改之前先读完):
1022
+ 1. **必须是蓝的** —— 别用 color-mix(品牌色 x%, transparent):transparent 是
1023
+ rgba(0,0,0,0),跟它混合等于往黑里兑,落在深色底上是一块灰(被报过「没颜色」)。
1024
+ 2. **必须圆角** —— 满宽方角就是一块灰板(被问过「圆角呢」)。
1025
+ 3. **向两边各延伸一点点** —— 贴原行宽显得局促;8px 被嫌长,取 6px。
1026
+ 4. **绝对不能动文字位置** —— 前两版都是给行本身加 margin:0 -6px + 等量 padding 补偿,
1027
+ 数学上内容区宽度和起点都对,但负 margin 参与 flex 布局,浏览器重排后字就是会跳
1028
+ (被报过「字体又变动了」)。现在改用绝对定位的伪元素画底色:inset:0 -6px 相对行本身
1029
+ 定位,**完全不参与布局**,无论左右扩多少,文字都钉在原地。
1030
+ 5. 不要动 border-top —— 那是分隔线,抹掉会让相邻两行粘在一起。
1031
+ isolation:isolate 建立层叠上下文,::before 的 z-index:-1 才会落在本行背景之上、
1032
+ 内容之下(少了它会沉到卡片底色后面,整块看不见)。 */
1033
+ .balance-settings__row--flash{position:relative;isolation:isolate}
1034
+ .balance-settings__row--flash::before{content:"";position:absolute;inset:0 -6px;border-radius:var(--dsw-radius-md,8px);background:rgba(77,107,254,.17);pointer-events:none;z-index:-1;animation:balance-row-flash 1.4s ease-out both}
1035
+ /* ⚠️ 末尾的 both 不能省。少了它动画播完 opacity 会回到元素默认值 1,
1036
+ 蓝底完整地再亮一次、直到 1.5s 时 class 被 JS 摘掉才消失 ——
1037
+ 看起来就是「高亮之后又闪一下」(2026-10-31 用户报的 bug)。
1038
+ both = 播完停在 100% 的 opacity:0,跟后续移除 class 无缝衔接。 */
1039
+ @keyframes balance-row-flash{0%,30%{opacity:1}100%{opacity:0}}
1040
+ @media (prefers-reduced-motion:reduce){.balance-settings__row--flash::before{display:none}}
429
1041
  /* 分组说明条:不是灰小字,而是带图标的淡蓝提示条——要一眼看见「仅支持 DeepSeek Harness」。
430
1042
  底色用品牌色 10%、边框 26%,文字提到 secondary;图标跟文字同色。 */
431
1043
  .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)}
432
- .balance-settings__group-note-icon{flex:none;margin-top:1.5px;color:var(--dsw-alias-brand-primary,#4d6bfe);opacity:.9}
1044
+ .balance-settings__group-note-icon{flex:none;display:block;margin-top:.5px;color:var(--dsw-alias-brand-primary,#4d6bfe);opacity:.9}
433
1045
  .balance-settings__group-note-text{min-width:0}
434
1046
  .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}
435
1047
  .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}
@@ -492,6 +1104,27 @@ window.__ModuleLoader__.load({
492
1104
  .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)}
493
1105
  .balance-settings__segment:hover{color:var(--dsw-alias-label-primary)}
494
1106
  .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)}
1107
+ /* 「消耗行加载动效」那一行的实时预览(2026-10-31 用户要求:「在介绍栏预览给用户看红圈位置」)。
1108
+ 直接复用消耗行的真实类名(.balance-usage-mist / .balance-usage-calc),
1109
+ 所以这里看到的就是输入框下方真会出现的那个效果 —— 两边永远不会走样,改一处两边一起变。
1110
+ 两个样式并排常驻播放,当前选中的那个描边高亮 + 不透明,另一个压到 45% 做对照。
1111
+ 窄屏整块隐藏:640px 以下 label 已经被挤得很窄,再加 160px 预览会把文字压断。 */
1112
+ .balance-settings__preview{display:flex;align-items:center;gap:8px;margin-left:auto;margin-right:8px;flex:none}
1113
+ .balance-settings__preview-item{display:flex;flex-direction:column;align-items:center;gap:4px;padding:5px 7px;border-radius:8px;border:.5px solid transparent;opacity:.45;transition:opacity .16s ease,border-color .16s ease}
1114
+ .balance-settings__preview-item.is-on{opacity:1;border-color:color-mix(in srgb,#4d6bfe 55%,transparent);background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.08))}
1115
+ .balance-settings__preview-tag{font-size:9.5px;line-height:1;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
1116
+ /* 预览里要把「本会话消耗」这几个字一起画出来 —— 只放一条光带的话看不出这是哪一行,
1117
+ 真实的那颗胶囊就是「本会话消耗 + 效果」,预览得对得上(2026-10-31 用户:「这个不应该显示对话消耗吗」)。
1118
+ 文字压到 10.5px、效果区收窄到 44px,两个并排整体约 210px,不至于把右侧分段按钮挤走。 */
1119
+ .balance-settings__preview-stage{position:relative;display:inline-flex;align-items:center;gap:5px;height:20px}
1120
+ .balance-settings__preview-text{font-size:10.5px;line-height:14px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
1121
+ .balance-settings__preview-stage .balance-usage-mist{min-width:0;width:44px}
1122
+ /* .balance-usage-calc 本身只定了尺寸和圆角,字号/行高/nowrap 是从消耗行的 .balance-usage-line 继承来的
1123
+ —— 预览里没有那层,不补这三行「计算中」会折成「计算 / 中」两行并撑破胶囊。 */
1124
+ .balance-settings__preview-stage .balance-usage-calc{height:20px;height:1lh;padding:0 7px;font-size:calc(var(--dsh-content-font-size-secondary,13px) - 1px);line-height:18px;white-space:nowrap}
1125
+ @media (max-width:640px){
1126
+ .balance-settings__preview{display:none}
1127
+ }
495
1128
  /* 版本与更新区块:设置页第一块(紧跟计费范围告示条)。
496
1129
  默认是中性底色,只在「有新版本」时提亮成品牌色、「刚更新完」时转成功色,
497
1130
  失败不染色(网络波动不该看起来像错误告警)。 */
@@ -555,7 +1188,14 @@ window.__ModuleLoader__.load({
555
1188
  cacheWriteCnyPerMillion: Number(next.cacheWriteCnyPerMillion ?? DEFAULT_PRICING.cacheWriteCnyPerMillion),
556
1189
  outputCnyPerMillion: Number(next.outputCnyPerMillion ?? DEFAULT_PRICING.outputCnyPerMillion),
557
1190
  };
558
- for (const listener of pricingListeners) listener();
1191
+ // 逐个订阅者隔离:一个订阅者的 render 异常不能让其他组件收不到新价。
1192
+ for (const listener of pricingListeners) {
1193
+ try {
1194
+ listener();
1195
+ } catch {
1196
+ // 单个订阅者异常不影响其他订阅者
1197
+ }
1198
+ }
559
1199
  }
560
1200
  function usePricing() {
561
1201
  const [, force] = react.useState(0);
@@ -673,15 +1313,17 @@ window.__ModuleLoader__.load({
673
1313
  }
674
1314
  }
675
1315
 
676
- /** 打开平台页面:一律走系统默认浏览器,不在应用内开原生视口或内嵌浏览器。 */
677
- function openPlatformOrUrl(page, url) {
1316
+ /** 打开平台页面:一律走系统默认浏览器,不在应用内开原生视口或内嵌浏览器。
1317
+ * 原来还有一个 page 参数(原生视口标识),从未被使用——
1318
+ * 历史上那条路径已废弃,留着它只会让调用方以为存在另一种打开方式(2026-10-31 全量审查)。 */
1319
+ function openPlatformOrUrl(url) {
678
1320
  openInSystemBrowser(url);
679
1321
  }
680
1322
 
681
1323
  /**
682
1324
  * 外部链接的通用属性:保留 href(可右键复制),点击时拦截默认跳转、改为应用内打开。
683
1325
  * @param url - 平台页面地址(用作 href 与最终回退目标)。
684
- * @param page - 原生视口的页面标识;缺省时跳过原生视口(例如非平台链接)。
1326
+ * @param page - 已废弃(参数保留以免调用方改动);现在一律走系统浏览器。
685
1327
  * @param extra - 追加的事件处理器等属性。
686
1328
  */
687
1329
  function externalLink(url, page, extra) {
@@ -694,7 +1336,7 @@ window.__ModuleLoader__.load({
694
1336
  onClick: (event) => {
695
1337
  if (event && typeof event.preventDefault === "function") event.preventDefault();
696
1338
  if (typeof options.onClick === "function") options.onClick(event);
697
- openPlatformOrUrl(page, url);
1339
+ openPlatformOrUrl(url);
698
1340
  },
699
1341
  };
700
1342
  }
@@ -707,7 +1349,14 @@ window.__ModuleLoader__.load({
707
1349
  function setCurrentModel(model) {
708
1350
  if (model === currentModel) return;
709
1351
  currentModel = model;
710
- for (const listener of modelListeners) listener();
1352
+ // 逐个订阅者隔离:模型解析结果要送达所有订阅者,不能被一个坏订阅者截断。
1353
+ for (const listener of modelListeners) {
1354
+ try {
1355
+ listener();
1356
+ } catch {
1357
+ // 单个订阅者异常不影响其他订阅者
1358
+ }
1359
+ }
711
1360
  }
712
1361
  function useCurrentModel() {
713
1362
  const [model, force] = react.useState(currentModel);
@@ -742,9 +1391,54 @@ window.__ModuleLoader__.load({
742
1391
  if (model !== "") setCurrentModel(model);
743
1392
  return model;
744
1393
  }
745
- /** 请求服务端按会话检测模型(等价于 /balance-api?session= 返回的 model 字段),失败返回 "". */
1394
+ function balanceApiUrl() {
1395
+ return currentModel === "" ? "/balance-api" : "/balance-api?model=" + encodeURIComponent(currentModel);
1396
+ }
1397
+ /** 拉取余额 + 按当前模型计算的定价;成功后广播 pricing。
1398
+ * **单飞**:并发调用共用同一个 in-flight Promise。这条接口每次都要真去查官方余额
1399
+ * (实测 419ms),同一时刻发多份毫无意义,只会让服务端排队。
1400
+ * 2026-10-31 加,配合 UsageCostLine 里那两处 usageKey 依赖的修复一起堵住请求风暴。 */
1401
+ let balanceDataInFlight = null;
1402
+ async function fetchBalanceData(controller) {
1403
+ // 带 signal 的调用(外部的取消语义)不参与复用,各走各的
1404
+ if (controller && controller.signal) {
1405
+ const res = await fetch(balanceApiUrl(), { signal: controller.signal });
1406
+ if (!res.ok) throw new Error("HTTP " + res.status);
1407
+ const data = await res.json();
1408
+ if (data && data.ok) {
1409
+ if (data.pricing) setPricing(data.pricing);
1410
+ return data;
1411
+ }
1412
+ throw new Error((data && data.error) || "余额查询失败");
1413
+ }
1414
+ if (balanceDataInFlight !== null) return balanceDataInFlight;
1415
+ balanceDataInFlight = (async () => {
1416
+ const res = await fetch(balanceApiUrl());
1417
+ if (!res.ok) throw new Error("HTTP " + res.status);
1418
+ const data = await res.json();
1419
+ if (data && data.ok) {
1420
+ if (data.pricing) setPricing(data.pricing);
1421
+ return data;
1422
+ }
1423
+ throw new Error((data && data.error) || "余额查询失败");
1424
+ })();
1425
+ try {
1426
+ return await balanceDataInFlight;
1427
+ } finally {
1428
+ balanceDataInFlight = null;
1429
+ }
1430
+ }
1431
+
1432
+ /** 请求服务端按会话检测模型(等价于 /balance-api?session= 返回的 model 字段),失败返回 ""。
1433
+ * **同一会话 15 秒内最多问一次**:模型探测不出来时(新会话、空会话)上层会不停重试,
1434
+ * 而这条请求会让服务端解压整份 zstd —— 不设闸门就是灾难(2026-10-31)。 */
1435
+ const serverModelProbeAt = new Map();
746
1436
  async function fetchServerModel(sessionId) {
747
1437
  if (typeof sessionId !== "string" || sessionId === "") return "";
1438
+ const now = Date.now();
1439
+ const last = serverModelProbeAt.get(sessionId) ?? 0;
1440
+ if (now - last < 15000) return "";
1441
+ serverModelProbeAt.set(sessionId, now);
748
1442
  try {
749
1443
  const res = await fetch("/balance-api?session=" + encodeURIComponent(sessionId));
750
1444
  if (!res.ok) return "";
@@ -754,22 +1448,9 @@ window.__ModuleLoader__.load({
754
1448
  return "";
755
1449
  }
756
1450
  }
757
- function balanceApiUrl() {
758
- return currentModel === "" ? "/balance-api" : "/balance-api?model=" + encodeURIComponent(currentModel);
759
- }
760
- /** 拉取余额 + 按当前模型计算的定价;成功后广播 pricing。 */
761
- async function fetchBalanceData(controller) {
762
- const res = await fetch(balanceApiUrl(), { signal: controller && controller.signal });
763
- if (!res.ok) throw new Error("HTTP " + res.status);
764
- const data = await res.json();
765
- if (data && data.ok) {
766
- if (data.pricing) setPricing(data.pricing);
767
- return data;
768
- }
769
- throw new Error((data && data.error) || "余额查询失败");
770
- }
771
1451
 
772
- /** 拉取指定会话的分时段 usage 及子代理 usage(/balance-api?session=...),失败返回 null。 */
1452
+ /** 拉取指定会话的分时段 usage 及子代理 usage(/balance-api?session=...),失败返回 null。
1453
+ * 服务端会顺带回一个 `today`(利用本次已解压的事件现算),用它做「今日消耗」的实时刷新。 */
773
1454
  async function fetchSessionUsage(sessionId) {
774
1455
  if (typeof sessionId !== "string" || sessionId === "") return null;
775
1456
  try {
@@ -780,6 +1461,7 @@ window.__ModuleLoader__.load({
780
1461
  return {
781
1462
  usage: data.usage || null,
782
1463
  subagentUsage: data.subagentUsage || null,
1464
+ today: data.today && typeof data.today === "object" ? data.today : null,
783
1465
  };
784
1466
  } catch {
785
1467
  return null;
@@ -802,6 +1484,18 @@ window.__ModuleLoader__.load({
802
1484
  };
803
1485
  const a = split(fromText);
804
1486
  const b = split(toText);
1487
+ // ⚠️ **位数一变就不滚,直接静态换值**(2026-10-31:「20.6? CNY」叠字)。
1488
+ // 位数不同时要靠 --gone 的 max-width 动画把多出来的格子收成 0 宽(或撑开),
1489
+ // 那个宽度是**连续变化**的,而每个格子都是 position:absolute 排在同一行里——
1490
+ // 前一格在收窄的过程中,后一格的 left 跟着一起被挤,滚动中途两串数字就横向叠住了。
1491
+ // 位数相同(20.68 → 20.71)时宽度不变,逐位滚动不会挤压,照常滚。
1492
+ const sameShape = a.int.length === b.int.length
1493
+ && a.frac.length === b.frac.length
1494
+ && (a.dot !== "") === (b.dot !== "")
1495
+ && a.tail.length === b.tail.length;
1496
+ if (!sameShape) {
1497
+ return String(toText).split("").map((ch) => ({ from: ch, to: ch, changed: false }));
1498
+ }
805
1499
  const padLeft = (s, n) => s.padStart(n, ROLL_EMPTY);
806
1500
  const padRight = (s, n) => s.padEnd(n, ROLL_EMPTY);
807
1501
  const intLen = Math.max(a.int.length, b.int.length);
@@ -822,29 +1516,141 @@ window.__ModuleLoader__.load({
822
1516
  return cells;
823
1517
  }
824
1518
 
825
- /** 渲染一组滚动格。prefix 只用于 React key(两处调用各用一个前缀,互不干扰)。 */
826
- function renderRollCells(cells, prefix) {
1519
+ // ─── 动画台账(统一授权中心,2026-10-31 方案 3)────────────────────────────
1520
+ // 之前是 6 个 state 各自计时、各自判断「该不该播」(enterUp / revealSeq /
1521
+ // waveToken / roll / todayWipeSeq / todayWipeDone),重播有三个独立成因
1522
+ // (组件重挂 / display:none 切换 / 状态残留),堵住一个漏掉另外两个,
1523
+ // 于是同一个 bug 反复出现七八轮。
1524
+ // 现在收敛成两条规则:
1525
+ // ① 「只许播一次」的动画 → animOnce(name),台账在**模块级**:
1526
+ // 页面刷新 / 插件重载 → 模块重新求值 → 台账清空 → 播一次(这是想要的);
1527
+ // 折叠展开 / 组件重挂 → 模块不重新求值 → 台账还在 → 不播。
1528
+ // ② 其余「事件驱动」的动画(滚动 / 擦除 / 白光)→ 组件 state,
1529
+ // 重挂自然归零;再叠一层「重新可见时统一清零」兜住 display 切换。
1530
+ const ANIM_LEDGER_KEY = "__balanceAnimLedger";
1531
+ function animLedger() {
1532
+ const g = globalThis;
1533
+ if (!(g[ANIM_LEDGER_KEY] instanceof Map)) g[ANIM_LEDGER_KEY] = new Map();
1534
+ return g[ANIM_LEDGER_KEY];
1535
+ }
1536
+ /** 这个名字的动画是否还能播一次。返回 true 表示「本次由你播」,并立刻记账。 */
1537
+ function animOnce(name) {
1538
+ const ledger = animLedger();
1539
+ if (ledger.get(name) === true) return false;
1540
+ ledger.set(name, true);
1541
+ return true;
1542
+ }
1543
+ /** 读账本里记的「上次的值」(用于判断某个数值是否真的变了,跨重挂有效)。 */
1544
+ function animRead(name) {
1545
+ return animLedger().get(name);
1546
+ }
1547
+ function animWrite(name, value) {
1548
+ animLedger().set(name, value);
1549
+ }
1550
+
1551
+ /** 渲染一组滚动格。prefix 只用于 React key(两处调用各用一个前缀,互不干扰)。
1552
+ *
1553
+ * **错位起步**(2026-10-31:「所有的数字滚动太生硬了」):所有位同时起步时,
1554
+ * 整串像一块板在平移。这里按「从右数第几位」给每个滚动格加 animation-delay ——
1555
+ * 个位最先动,进位逐位往左传递,读起来是机械计数器逐位落位。
1556
+ * 没有变化的格子本来就不滚(渲染成静态 dcell),不占错位名额。
1557
+ * 每位自身仍是 linear 420ms、位移分毫不差,所以不会退回「快到位置时发虚」。 */
1558
+ const ROLL_STAGGER_MS = 38;
1559
+ const ROLL_STAGGER_MAX = 4;
1560
+ // 滚动播完后给 roll 打 done 标记(2026-10-31 方案 3 补充)。
1561
+ // 为什么必须做:`dout`/`din` 上的 animation 是常驻声明,只要它还在,
1562
+ // 元素经历 display:none → 有盒子时浏览器会取消并重建动画,从 0 秒重播。
1563
+ // 打了 done 之后 renderRollCells 会挂 --roll-done 把声明整个摘掉,
1564
+ // 节点仍常驻(结构不切换、不跳),但已经没有任何动画可以重播。
1565
+ // 600ms = 单格 420ms + 最长错位 4×38ms ≈ 572ms,留 30ms 余量。
1566
+ const ROLL_SETTLE_MS = 600;
1567
+ function useRollSettle(roll, setRoll) {
1568
+ react.useEffect(() => {
1569
+ if (roll === null || roll.done === true) return;
1570
+ const at = roll.at;
1571
+ const id = setTimeout(() => {
1572
+ setRoll((r) => (r !== null && r.at === at && r.done !== true ? { ...r, done: true } : r));
1573
+ }, ROLL_SETTLE_MS);
1574
+ return () => clearTimeout(id);
1575
+ }, [roll, setRoll]);
1576
+ }
1577
+
1578
+ function renderRollCells(cells, prefix, settled) {
1579
+ // 先按从右往左给「会滚的格子」编号:最右边的滚动格延迟 0
1580
+ const total = cells.length;
827
1581
  return jsx.jsx("span", {
828
1582
  className: "balance-footer__roll",
829
- children: cells.map((cell, i) => (
830
- cell.changed
831
- ? jsx.jsxs("span", {
832
- // 新值是空位(位数减少)时加 --gone:滚完自己收成 0 宽,否则尾巴多一格,
833
- // 胶囊会被撑宽再缩回(2026-10-30 实测 73.66 ↔ 83.53)。
834
- className: "balance-footer__dcell balance-footer__dcell--roll" + (cell.to === ROLL_EMPTY ? " balance-footer__dcell--gone" : ""),
835
- children: [
836
- // 影子格用「新字符」撑宽;新值是空位时退回旧字符 —— 滚动开始那一刻的宽度
837
- // 正好等于滚动前,之后由 --gone 的 max-width 动画把它平滑收到 0。
838
- jsx.jsx("span", { className: "balance-footer__dghost", children: cell.to === ROLL_EMPTY ? cell.from : cell.to }),
839
- jsx.jsx("span", { className: "balance-footer__dout", children: cell.from === ROLL_EMPTY ? "\u00a0" : cell.from }),
840
- jsx.jsx("span", { className: "balance-footer__din", children: cell.to === ROLL_EMPTY ? "\u00a0" : cell.to }),
841
- ],
842
- }, prefix + i)
843
- : jsx.jsx("span", { className: "balance-footer__dcell", children: cell.to === ROLL_EMPTY ? "\u00a0" : cell.to }, prefix + i)
844
- )),
1583
+ children: cells.map((cell, i) => {
1584
+ if (!cell.changed) {
1585
+ return jsx.jsx("span", { className: "balance-footer__dcell", children: cell.to === ROLL_EMPTY ? "\u00a0" : cell.to }, prefix + i);
1586
+ }
1587
+ const fromRight = total - 1 - i;
1588
+ const step = Math.min(fromRight, ROLL_STAGGER_MAX);
1589
+ const delay = step * ROLL_STAGGER_MS;
1590
+ const style = delay > 0 ? { animationDelay: delay + "ms" } : undefined;
1591
+ return jsx.jsxs("span", {
1592
+ // 新值是空位(位数减少)时加 --gone:滚完自己收成 0 宽,否则尾巴多一格,
1593
+ // 胶囊会被撑宽再缩回(2026-10-30 实测 73.66 ↔ 83.53)。
1594
+ // settled 为真时再加 --roll-done:把 animation 声明整个摘掉。
1595
+ // 这一步不是视觉优化,是「展开侧边栏不许重播」的根治点 ——
1596
+ // display:none 的元素重新显示会取消并重建 CSS 动画,只有让声明不存在才挡得住。
1597
+ className: "balance-footer__dcell balance-footer__dcell--roll"
1598
+ + (cell.to === ROLL_EMPTY ? " balance-footer__dcell--gone" : "")
1599
+ + (settled === true ? " balance-footer__dcell--roll-done" : ""),
1600
+ children: [
1601
+ // 影子格用「新字符」撑宽;新值是空位时退回旧字符 —— 滚动开始那一刻的宽度
1602
+ // 正好等于滚动前,之后由 --gone 的 max-width 动画把它平滑收到 0。
1603
+ jsx.jsx("span", { className: "balance-footer__dghost", children: cell.to === ROLL_EMPTY ? cell.from : cell.to }),
1604
+ jsx.jsx("span", { className: "balance-footer__dout", style: style, children: cell.from === ROLL_EMPTY ? "\u00a0" : cell.from }),
1605
+ jsx.jsx("span", { className: "balance-footer__din", style: style, children: cell.to === ROLL_EMPTY ? "\u00a0" : cell.to }),
1606
+ ],
1607
+ }, prefix + i);
1608
+ }),
845
1609
  });
846
1610
  }
847
1611
 
1612
+ // ─── 「剩 3h57m」这类混排文本的逐段滚动 ──────────────────────────────────
1613
+ // buildRollCells 只认「整个串是一个数字」(按数字开头切 int/dot/frac/tail),
1614
+ // 直接把 "3h57m" 喂进去只会滚开头的 3,h57m 全被当成静态 tail。
1615
+ // 这里先按「数字段 / 非数字段」切开,数字段各自滚、字母段保持不动。
1616
+ function splitRollTokens(text) {
1617
+ return String(text).match(/\d+|\D+/g) || [];
1618
+ }
1619
+
1620
+ /** 渲染剩余时间的滚动。段数变了(59m → 1h0m 这种跨小时)就整体换下来,
1621
+ * 逐段对齐没有意义 —— 硬凑会让 h/m 和数字错位。 */
1622
+ function renderRemainRoll(fromText, toText, prefix, settled) {
1623
+ const a = splitRollTokens(fromText);
1624
+ const b = splitRollTokens(toText);
1625
+ if (a.length !== b.length) {
1626
+ return jsx.jsx("span", { className: "balance-footer__remain-swap", children: toText }, prefix + "swap");
1627
+ }
1628
+ return jsx.jsx("span", {
1629
+ className: "balance-footer__remain",
1630
+ children: b.map((tok, i) => (
1631
+ /^\d+$/.test(tok)
1632
+ ? jsx.jsx("span", {
1633
+ className: "balance-footer__roll",
1634
+ children: renderRollCells(buildRollCells(a[i], tok), prefix + i, settled),
1635
+ }, prefix + "s" + i)
1636
+ : jsx.jsx("span", { className: "balance-footer__remain-unit", children: tok }, prefix + "s" + i)
1637
+ )),
1638
+ }, prefix + "wrap");
1639
+ }
1640
+
1641
+ /** 值变化时返回递增的 tick:用它当 key 就能让元素重挂、重播一次入场动画,
1642
+ * 不用手工管 class 的摘除时机。首帧不触发(只跟着变化走)。 */
1643
+ function useChangeTick(value) {
1644
+ const prevRef = react.useRef(value);
1645
+ const [tick, setTick] = react.useState(0);
1646
+ usePrePaintEffect(() => {
1647
+ if (prevRef.current === value) return;
1648
+ prevRef.current = value;
1649
+ setTick((n) => n + 1);
1650
+ }, [value]);
1651
+ return tick;
1652
+ }
1653
+
848
1654
  // ─── 高峰/空闲时段(纯客户端判断,无需服务端) ────────────────────────
849
1655
  // V4 峰谷定价(2026-08-17 生效):高峰价 = 空闲价 × 2。
850
1656
  // 官方高峰窗口(北京时间)为 工作日 09:00-12:00、14:00-18:00;
@@ -920,25 +1726,35 @@ const CN_CALENDAR_SOURCES = [
920
1726
  "https://raw.githubusercontent.com/NateScarlet/holiday-cn/master",
921
1727
  "https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master",
922
1728
  ];
923
- const cnCalendarYears = new Set();
1729
+ // 「已成功同步」与「正在同步」必须分成两个集合。
1730
+ // 原来只用一个 Set 做占位,三个源全挂时占位留在集合里 —— 那一年就**永远不再重试**,
1731
+ // 网络恢复后也看不到调休数据(2026-10-31 全量审查)。
1732
+ const cnCalendarYears = new Set(); // 已成功拿到数据
1733
+ const cnCalendarInFlight = new Set(); // 正在请求(防并发重复)
924
1734
  async function ensureCnCalendar(year) {
925
- if (cnCalendarYears.has(year)) return;
926
- cnCalendarYears.add(year); // 先占位,避免并发重复请求
927
- for (const base of CN_CALENDAR_SOURCES) {
928
- try {
929
- const res = await fetch(base + "/" + year + ".json", { signal: AbortSignal.timeout(6000) });
930
- if (!res.ok) continue;
931
- const data = await res.json();
932
- if (!Array.isArray(data?.days)) continue;
933
- for (const item of data.days) {
934
- if (item && typeof item.date === "string" && typeof item.isOffDay === "boolean") {
935
- CN_CALENDAR[item.date] = item.isOffDay ? "holiday" : "workday";
1735
+ if (cnCalendarYears.has(year) || cnCalendarInFlight.has(year)) return;
1736
+ cnCalendarInFlight.add(year);
1737
+ try {
1738
+ for (const base of CN_CALENDAR_SOURCES) {
1739
+ try {
1740
+ const res = await fetch(base + "/" + year + ".json", { signal: AbortSignal.timeout(6000) });
1741
+ if (!res.ok) continue;
1742
+ const data = await res.json();
1743
+ if (!Array.isArray(data?.days)) continue;
1744
+ for (const item of data.days) {
1745
+ if (item && typeof item.date === "string" && typeof item.isOffDay === "boolean") {
1746
+ CN_CALENDAR[item.date] = item.isOffDay ? "holiday" : "workday";
1747
+ }
936
1748
  }
1749
+ cnCalendarYears.add(year);
1750
+ return;
1751
+ } catch {
1752
+ // 这个源连不上:换下一个;全挂就保留内置表
937
1753
  }
938
- return;
939
- } catch {
940
- // 这个源连不上:换下一个;全挂就保留内置表
941
1754
  }
1755
+ } finally {
1756
+ // 成功也删、失败也删:成功由 cnCalendarYears 记住,失败则允许下次重试。
1757
+ cnCalendarInFlight.delete(year);
942
1758
  }
943
1759
  }
944
1760
  {
@@ -959,16 +1775,25 @@ const CN_CALENDAR_SOURCES = [
959
1775
  }
960
1776
  }
961
1777
 
962
- const NEW_TAG_TTL_MS = 3 * 24 * 60 * 60 * 1000; // NEW 徽标展示 3 天
1778
+ // 设置项旁那颗 NEW 的展示时长:24 小时(2026-10-31 由 3 天改短)。
1779
+ // 入口那颗 NEW 不看这个时长,它的熄灭条件是「用户点进去过」,见 unseenNewTags。
1780
+ const NEW_TAG_TTL_MS = 24 * 60 * 60 * 1000;
963
1781
 
964
1782
  /** 单项 NEW 判定。每项自带起点,互不影响:
965
1783
  * - record 里已记下这一版(v === intro)→ 就用 t 计时,不再判 fresh
966
1784
  * (否则刚记完就会因 "intro 不大于自己"而立刻消失)
967
1785
  * - 没记录或引入版本变了 → 比较「引入版本 vs 用户见过的版本」
968
- * 旧功能因 fresh=false 直接不显示,所以不可能被新版本重新计时。 */
969
- function newTagStateAt(introVersion, record, fallbackFrom, fallbackSince, nowMs) {
1786
+ * 旧功能因 fresh=false 直接不显示,所以不可能被新版本重新计时。
1787
+ *
1788
+ * 返回三个字段,语义各不相同,别混用:
1789
+ * - fresh:这一项「对用户算新功能」。决定要不要写记录,与显示无关。
1790
+ * - show :设置项旁那颗 NEW 现在要不要亮。按 24 小时窗口算。
1791
+ * - entered:用户有没有点进过插件设置页。侧栏入口那颗 NEW 只看它 ——
1792
+ * 点进去过就不再提示,直到下个新功能出现(2026-10-31 用户要求)。 */
1793
+ function newTagStateAt(introVersion, record, fallbackFrom, fallbackSince, nowMs, currentVersion) {
970
1794
  const intro = String(introVersion || "");
971
- if (intro === "") return { fresh: false, show: false, since: 0 };
1795
+ if (intro === "") return { fresh: false, show: false, since: 0, entered: true };
1796
+ const curVer = String(currentVersion || "");
972
1797
  const rec = record && typeof record === "object" && !Array.isArray(record) ? record : null;
973
1798
  const recV = rec && typeof rec.v === "string" ? rec.v : "";
974
1799
  const recT = rec ? Number(rec.t) : NaN;
@@ -982,17 +1807,36 @@ const CN_CALENDAR_SOURCES = [
982
1807
  const s = Number(since);
983
1808
  return Number.isFinite(s) && s > 0 ? s : 0;
984
1809
  };
985
- // 已经按这一版记录过:用户第一次见到它就是 t,直接按 t 计时
1810
+ // 本版新增的功能:即使 newTagFrom 已经被写成同一个版本号(开发期反复改版本号、
1811
+ // 或同版本重装),只要还在 24 小时窗口内就照亮 —— 用户 2026-10-31 报
1812
+ // 「NEW 标签不是要显示满 24 小时吗,怎么提前消失了」,根因就是
1813
+ // compareVersion("2.1.0", "2.1.0") === 0 让这类项被判成「老功能」。
1814
+ // 只对 intro 等于当前版本的项放宽,历史版本的功能依旧不亮。
1815
+ const isCurrentIntro = (since) => curVer !== "" && compareVersion(intro, curVer) === 0 && within(since);
1816
+ // 已经按这一版记录过:仍要**再判一次"对这名用户算不算新功能"**。
1817
+ // 早先这里无条件 fresh:true,只靠 24 小时窗口控制显不显示 —— 结果是
1818
+ // 从 2.0.8 升上来的用户,2.0.0 的老功能(余额行样式 / 侧边栏显示本插件 /
1819
+ // 右键刷新浪潮)只要 t 落在窗口里就一块标 NEW,等于"每个人高亮的东西都一样"。
1820
+ // 现在严格用 引入版本 对比 用户升级前的版本(newTagFrom),每人各不相同:
1821
+ // 跨多版升上来的会看到中间所有新功能,只升一版的老功能一条都不亮。
986
1822
  if (recV !== "" && recV === intro) {
1823
+ const baseline = String(fallbackFrom || "");
987
1824
  const since = Number.isFinite(recT) && recT > 0 ? recT : Number(fallbackSince);
988
- return { fresh: true, show: within(since), since: pick(since) };
1825
+ const reallyFresh = baseline === "" || compareVersion(intro, baseline) > 0 || isCurrentIntro(since);
1826
+ const entered = rec !== null && rec.d === true;
1827
+ return {
1828
+ fresh: reallyFresh,
1829
+ show: reallyFresh && within(since),
1830
+ since: pick(since),
1831
+ entered: entered || !reallyFresh,
1832
+ };
989
1833
  }
990
1834
  // 没记录(或引入版本变了):才判这项对用户算不算新
991
1835
  const baseline = recV !== "" ? recV : String(fallbackFrom || "");
992
- const fresh = baseline === "" || compareVersion(intro, baseline) > 0;
993
- if (!fresh) return { fresh: false, show: false, since: 0 };
994
1836
  const since = Number(fallbackSince);
995
- return { fresh: true, show: within(since), since: pick(since) };
1837
+ const fresh = baseline === "" || compareVersion(intro, baseline) > 0 || isCurrentIntro(since);
1838
+ if (!fresh) return { fresh: false, show: false, since: 0, entered: true };
1839
+ return { fresh: true, show: within(since), since: pick(since), entered: false };
996
1840
  }
997
1841
 
998
1842
  /** 按数字段比较版本号:1.3.10 > 1.3.9(字符串直接比会错)。
@@ -1009,6 +1853,100 @@ const CN_CALENDAR_SOURCES = [
1009
1853
  return 0;
1010
1854
  }
1011
1855
 
1856
+ // NEW_TAGS:标准——「最近版本加了什么」。放在模块级,因为有两处要用:
1857
+ // ① 设置卡片逐项高亮(「消耗行加载动效」旁边那颗 NEW);
1858
+ // ② 侧栏「余额与用量」入口图标右上角那颗 NEW 标签(有新功能就亮)。
1859
+ // 每项带上「引入版本」:只有比用户上一次跑的版本更新的项才标 NEW,
1860
+ // 跨几个版本一次升上来的用户会看到所有中间新功能,已经跑过的不会被重复提示。
1861
+ // 维护方式:发版时把本版新增的设置项 key 加进来,引入版本写当次的 pending 值。
1862
+ const NEW_TAGS = new Map([
1863
+ ["footerStyle", "2.0.0"], // 余额行样式(新版 timeline / 旧版 classic)
1864
+ ["showSidebarEntry", "2.0.0"], // 左侧导航栏入口
1865
+ ["refreshWave", "2.0.0"], // 右键刷新涟漪
1866
+ ["usageLoadingStyle", "2.0.9"], // 消耗行加载动效:样式1(光团开窗)/ 样式2(计算中),含行内实时预览
1867
+ ["todaySessionLimit", "2.1.0"], // 今日消耗扫描上限:多开对话 / 多跑子代理时把今日汇总算全
1868
+ ]);
1869
+
1870
+ /** NEW_TAGS 的 key → 设置项中文名。横幅要写「本版新增:消耗行加载动效」,
1871
+ * key 是内部标识,不能直接给用户看。加新功能时**这里也要补一行**。 */
1872
+ const NEW_TAG_LABELS = {
1873
+ footerStyle: "余额行样式",
1874
+ showSidebarEntry: "侧边栏显示本插件",
1875
+ refreshWave: "右键刷新浪潮",
1876
+ usageLoadingStyle: "消耗行加载动效",
1877
+ todaySessionLimit: "今日消耗扫描上限",
1878
+ };
1879
+
1880
+ /** 模板串里取一张表的三个 NEW 相关字段,缺省一律安全兜底。 */
1881
+ function newTagFields(settings) {
1882
+ const s = settings && typeof settings === "object" ? settings : {};
1883
+ return {
1884
+ seen: s.newTagSeen && typeof s.newTagSeen === "object" ? s.newTagSeen : {},
1885
+ from: String(s.newTagFrom || ""),
1886
+ since: Number(s.newTagSince),
1887
+ // 当前插件版本:用来判定「这项是不是本版新增」,见 newTagStateAt 的 isCurrentIntro。
1888
+ version: String(s.version || ""),
1889
+ };
1890
+ }
1891
+
1892
+ /** 设置项旁那颗 NEW 要亮的项(24 小时窗口内)。 */
1893
+ function freshNewTags(settings) {
1894
+ const { seen, from, since, version } = newTagFields(settings);
1895
+ const now = Date.now();
1896
+ const out = [];
1897
+ for (const [key, intro] of NEW_TAGS) {
1898
+ const st = newTagStateAt(intro, seen[key], from, since, now, version);
1899
+ if (st.fresh && st.show) out.push({ key, intro });
1900
+ }
1901
+ return out;
1902
+ }
1903
+
1904
+ /** 侧栏入口那颗 NEW 要亮的项:算新功能、还在 24 小时窗口内、而且用户没点进去过。
1905
+ * ⚠️ 必须带上 st.show(24 小时窗口)。只判 !entered 会让**老功能永远亮**:
1906
+ * newTagStateAt 对「已记录」的项是无条件 fresh:true 的(那是给写盘用的),
1907
+ * 而 footerStyle 这类 2.0.0 的老项永远不会被写上 e 标记,于是入口 NEW 挂一辈子。
1908
+ * 2026-10-31 用户报「怎么消不掉」就是这个 —— 一版之前只判了 fresh,漏了窗口。
1909
+ * 熄灭条件仍是「点进去过」,下个新功能自有自己的 24 小时窗口。 */
1910
+ function unseenNewTags(settings) {
1911
+ const { seen, from, since, version } = newTagFields(settings);
1912
+ const now = Date.now();
1913
+ const out = [];
1914
+ for (const [key, intro] of NEW_TAGS) {
1915
+ const st = newTagStateAt(intro, seen[key], from, since, now, version);
1916
+ if (st.fresh && st.show && !st.entered) out.push({ key, intro });
1917
+ }
1918
+ return out;
1919
+ }
1920
+
1921
+ /** 只留下 NEW_TAGS 里还存在的 key。
1922
+ * NEW_TAGS 会随版本增删,删掉的功能在 newTagSeen 里留的记录永远没人读,
1923
+ * 攒久了就是设置文件里一坨死数据。写盘前一律先过这一步(2026-10-31 用户问「缓存会不会越来越大」)。 */
1924
+ function pruneNewTagSeen(seen) {
1925
+ const src = seen && typeof seen === "object" ? seen : {};
1926
+ const out = {};
1927
+ for (const [key] of NEW_TAGS) {
1928
+ const rec = src[key];
1929
+ if (rec && typeof rec === "object" && !Array.isArray(rec)) out[key] = rec;
1930
+ }
1931
+ return out;
1932
+ }
1933
+
1934
+ /** 用户点进了插件设置页(点侧栏入口、或者从别处打开这张卡片):
1935
+ * 把还没看过的新功能全部标成 e:true,侧栏那颗 NEW 就此熄灭,直到下个新功能出现。
1936
+ * 设置项旁那颗 NEW 不受影响,它按自己的 24 小时窗口走。
1937
+ * 没有可标的项时返回 null(不写盘、不刷事件)。 */
1938
+ function markNewTagsEntered(settings) {
1939
+ const unseen = unseenNewTags(settings);
1940
+ const { seen, since } = newTagFields(settings);
1941
+ // 即使这次没有可标的项,也顺手做一次清理,把已删功能的残记录带走
1942
+ const next = pruneNewTagSeen(seen);
1943
+ const pruned = Object.keys(next).length !== Object.keys(seen).length;
1944
+ if (unseen.length === 0) return pruned ? next : null;
1945
+ const anchor = Number.isFinite(since) && since > 0 ? since : Date.now();
1946
+ for (const { key, intro } of unseen) next[key] = { v: intro, t: anchor, d: true };
1947
+ return next;
1948
+ }
1949
+
1012
1950
  function currentPeriod(atMs) {
1013
1951
  const d = new Date((atMs === undefined ? Date.now() : atMs) + 8 * 60 * 60 * 1000); // 北京时间
1014
1952
  const kind = CN_CALENDAR[
@@ -1107,7 +2045,15 @@ const CN_CALENDAR_SOURCES = [
1107
2045
  }
1108
2046
  /** 生成刻度条:当天色带渐变 + 时段文字 + 指针位置。
1109
2047
  * @returns {{ gradient: string, pointerPct: number }} */
2048
+ // 纯函数结果缓存:buildTimeline(now) 完全由 now 决定(内部 currentPeriod 都显式传了 t),
2049
+ // 每个渲染周期有**两个**组件各调一次同一个 now,再叠上无关状态的重复渲染 ——
2050
+ // 24 次小时采样 + 拼字符串每次都白跑(2026-10-31 全量审查)。
2051
+ // 用模块级单槽缓存而不是 useMemo:新增 hook 会往渲染体里加入员,
2052
+ // 一旦落点越过早退分支就是 "Rendered more hooks…",代价远大于这点收益。
2053
+ let timelineMemoAt = Number.NaN;
2054
+ let timelineMemoValue = null;
1110
2055
  function buildTimeline(now) {
2056
+ if (timelineMemoValue !== null && timelineMemoAt === now) return timelineMemoValue;
1111
2057
  const HOUR = 3600000;
1112
2058
  // 当天 00:00(北京时间)。BEIJING_OFFSET_MS 东为正,先平移到北京再取整到天。
1113
2059
  const dayStart = dayStartOf(now);
@@ -1135,10 +2081,13 @@ const CN_CALENDAR_SOURCES = [
1135
2081
  kind = s.kind;
1136
2082
  }
1137
2083
  parts.push(colorOf(kind) + " " + pct(from).toFixed(2) + "% 100%");
1138
- return {
2084
+ const built = {
1139
2085
  gradient: "linear-gradient(90deg," + parts.join(",") + ")",
1140
2086
  pointerPct: pct(now),
1141
2087
  };
2088
+ timelineMemoAt = now;
2089
+ timelineMemoValue = built;
2090
+ return built;
1142
2091
  }
1143
2092
  /** 按当前时段调整价格:高峰 × 倍率、空闲原价。
1144
2093
  * ⚠️ p 必须是**目录价**(服务端 pricing 字段即目录价);倍率来自设置的 peakMultiplier,
@@ -1177,7 +2126,9 @@ const CN_CALENDAR_SOURCES = [
1177
2126
  newTagSeen: {},
1178
2127
  fabSize: 60,
1179
2128
  fabLeft: 44,
1180
- statsSessionLimit: 200,
2129
+ statsSessionLimit: 80,
2130
+ // 今日消耗单独一个上限,默认比统计宽(服务端 TODAY_MAX_SESSIONS 同值)
2131
+ todaySessionLimit: 200,
1181
2132
  statsCacheSec: 300,
1182
2133
  peakWindowsText: "9-12, 14-18",
1183
2134
  peakMultiplier: 2,
@@ -1267,7 +2218,14 @@ const CN_CALENDAR_SOURCES = [
1267
2218
  }
1268
2219
 
1269
2220
  function emitSettings() {
1270
- for (const listener of settingsListeners) listener();
2221
+ // 逐个订阅者隔离:设置热更新必须送达所有订阅者。
2222
+ for (const listener of settingsListeners) {
2223
+ try {
2224
+ listener();
2225
+ } catch {
2226
+ // 单个订阅者异常不影响其他订阅者
2227
+ }
2228
+ }
1271
2229
  }
1272
2230
 
1273
2231
  /** 拉一次设置(并发自动合并);成功后写缓存并通知订阅者。 */
@@ -1300,6 +2258,13 @@ const CN_CALENDAR_SOURCES = [
1300
2258
 
1301
2259
  /** 提交设置补丁;成功后以服务端回传的完整设置为准(服务端会做校验与夹紧)。 */
1302
2260
  async function saveSettings(patch) {
2261
+ // ⚠️ 设置没加载完就绝不许写盘(2026-10-31 审查)。
2262
+ // 下面那一行会把 settingsCache.value 整份带上去,而它在加载完成前是
2263
+ // SETTINGS_DEFAULTS —— 那不是"用户现在想要的",是"出厂默认"。
2264
+ // 写下去就是一次静默的配置清空,而且界面还提示"已保存"。
2265
+ if (settingsCache.loaded !== true) {
2266
+ throw new Error("设置还没加载完成,请稍后再试");
2267
+ }
1303
2268
  const res = await fetch("/balance-api?config=1", {
1304
2269
  method: "POST",
1305
2270
  headers: { "Content-Type": "application/json" },
@@ -1307,8 +2272,17 @@ const CN_CALENDAR_SOURCES = [
1307
2272
  });
1308
2273
  const data = await res.json();
1309
2274
  if (!data || data.ok !== true) throw new Error((data && data.error) || "保存失败");
2275
+ const server = data.settings && typeof data.settings === "object" ? data.settings : {};
2276
+ // 服务端还是旧代码时不认识新加字段(normalizeSettings 会把未知键丢掉),
2277
+ // 如果直接拿它回传的对象覆盖本地,刚落下去的值会被冲回默认值 ——
2278
+ // 表现就是「点了 NEW 它又亮回来」(2026-10-31 实测)。所以把服务端**没回传**的
2279
+ // 补丁键保留在本地:会话内行为正确,重启 DSH 让服务端认识之后自然对齐。
2280
+ const kept = {};
2281
+ for (const key of Object.keys(patch)) {
2282
+ if (!(key in server)) kept[key] = patch[key];
2283
+ }
1310
2284
  settingsCache = {
1311
- value: { ...SETTINGS_DEFAULTS, ...data.settings },
2285
+ value: { ...SETTINGS_DEFAULTS, ...server, ...kept },
1312
2286
  loaded: true,
1313
2287
  version: typeof data.version === "string" ? data.version : settingsCache.version,
1314
2288
  };
@@ -1401,14 +2375,27 @@ const CN_CALENDAR_SOURCES = [
1401
2375
  // 等待期间先摆一个不可见占位,让 meta 行高度从一开始就是最终高度 ——
1402
2376
  // 否则「今日 ¥x」这个 flex-basis:100% 的元素一插入,整行就从 1 行变 2 行,
1403
2377
  // 侧栏底部整体被顶下 14px,冷启动繁忙时看着就是「今日余额弹出来那下卡了」。
1404
- const todayCache = { value: null, inFlight: null, at: 0, tried: false };
2378
+ const todayCache = { value: null, inFlight: null, at: 0, tried: false, ttl: 55000, retries: 0, settled: false };
1405
2379
  const todayListeners = new Set();
1406
2380
  function notifyToday() {
1407
- for (const listener of todayListeners) listener();
2381
+ // ⚠️ 逐个订阅者隔离(2026-10-31 全量审查)。任何一个订阅者抛错都不能
2382
+ // ① 中断后面的订阅者,② 把异常抛回调用方 —— 有好几处 emit 落在 try 之外,
2383
+ // 抛出去就是 unhandled rejection,界面上表现为「刷新一次就再也不更新了」。
2384
+ for (const listener of todayListeners) {
2385
+ try {
2386
+ listener();
2387
+ } catch {
2388
+ // 单个订阅者异常不影响其他订阅者,也不影响本次数据更新
2389
+ }
2390
+ }
1408
2391
  }
1409
- /** 拉一次今日消耗。失败/未支持时保持 null(meta 行只显示时段与剩余时间)。 */
2392
+ /** 拉一次今日消耗。失败/未支持时保持 null(meta 行只显示时段与剩余时间)。
2393
+ *
2394
+ * 服务端现在是「快照优先」:冷启动直接返回上次落盘的结果(todayPartial=true),
2395
+ * 重算在后台跑。所以拿到 partial 时不能按 55 秒压住缓存 —— 3 秒后补拉一次
2396
+ * 把后台算完的新值取回来;连补 3 次还是 partial 就放弃,等下一轮正常刷新。 */
1410
2397
  function fetchTodayOnce(force) {
1411
- if (!force && Date.now() - todayCache.at < 55000) return Promise.resolve(todayCache.value);
2398
+ if (!force && Date.now() - todayCache.at < todayCache.ttl) return Promise.resolve(todayCache.value);
1412
2399
  if (todayCache.inFlight !== null) return todayCache.inFlight;
1413
2400
  todayCache.inFlight = (async () => {
1414
2401
  try {
@@ -1418,29 +2405,115 @@ const CN_CALENDAR_SOURCES = [
1418
2405
  if (data && typeof data.today === "object" && data.today !== null) {
1419
2406
  todayCache.value = data.today;
1420
2407
  todayCache.at = Date.now();
2408
+ const partial = data.todayPartial === true && todayCache.retries < 3;
2409
+ todayCache.ttl = partial ? 3000 : 20000;
2410
+ // 「首屏数据是否已经定型」:partial 是**同一次计算的分阶段**(先给快照、
2411
+ // 后台算完再补),不是真的有新消耗。定型前所有值变化都只换基准、不播滚动,
2412
+ // 否则会先按快照揭幕、3 秒后又被补拉的数顶上去 → 用户看到「刚加载完就动」
2413
+ // (2026-10-31 反馈)。`partial` 已经含 `retries < 3`,所以重试用尽时
2414
+ // `partial` 为 false,同样会走到定型,不会永远不滚。
2415
+ if (!partial) todayCache.settled = true;
1421
2416
  notifyToday();
2417
+ if (partial) {
2418
+ todayCache.retries += 1;
2419
+ // 单飞标志要等 finally 才清,所以排到下一轮再拉
2420
+ setTimeout(() => { void fetchTodayOnce(true); }, 3200);
2421
+ } else {
2422
+ todayCache.retries = 0;
2423
+ }
1422
2424
  }
1423
2425
  } catch {
1424
2426
  // 静默:今日消耗拿不到就不显示,不影响余额
1425
2427
  } finally {
1426
2428
  todayCache.inFlight = null;
1427
2429
  // 只在「问过但没拿到」时才需要通知:成功路径上面已经 notify 过了。
1428
- // 这样失败时会撤掉占位,不会永久留一条空行。
2430
+ // 这样失败时会撤掉等待态,不会永久留一条空行。
1429
2431
  if (!todayCache.tried) {
1430
2432
  todayCache.tried = true;
1431
2433
  if (todayCache.value === null) notifyToday();
1432
2434
  }
2435
+ // 兜底定型:请求失败(异常 / 非 200)时不会走到成功分支,若不在这里置位,
2436
+ // 等待态会永远挂着(`todayWaiting` 依赖 settled)。宁可显示「暂无」也不永远转圈。
2437
+ if (!todayCache.settled && todayCache.value === null) {
2438
+ todayCache.settled = true;
2439
+ notifyToday();
2440
+ }
1433
2441
  }
1434
2442
  return todayCache.value;
1435
2443
  })();
1436
2444
  return todayCache.inFlight;
1437
2445
  }
2446
+ // 「今日」金额不和插件一起加载:插件本体(余额行 / 指针 / 色带)先渲染出来,
2447
+ // **等余额真的到位**之后才去问今日(用户 2026-10-31:打开 DSH 时先加载插件,
2448
+ // 加载完再加载今日消耗)。
2449
+ // 为什么必须错开:服务端算「今日」要读会话持久化文件,本机实测单个 35 MB 会话
2450
+ // 解压 723 ms,和余额请求撞在一起时首屏就要等它;错开之后余额先到,界面立刻有内容。
2451
+ /** 余额到位之后隔多久去问今日。
2452
+ * 要足够长,让余额那行的入场动画(约 520ms)彻底走完再发请求 ——
2453
+ * 服务端算今日是同步解压,和入场动画叠在一起会掉帧
2454
+ * (用户 2026-10-31:「启动时插件加载动画还是有概率掉帧」)。
2455
+ * 之前的 240ms 正好撞在动画中段,所以只是"有概率"不是必现。 */
2456
+ const TODAY_AFTER_BALANCE_MS = 560;
2457
+ /** 余额迟迟不回来时的兜底上限 —— 不能因为余额失败就让今日永远不加载。 */
2458
+ const TODAY_WAIT_BALANCE_MAX_MS = 2000;
2459
+ // 本会话消耗的发出延迟。**只是让首帧先画出来,不是"错峰"** ——
2460
+ // 发 fetch 不耗 CPU,真正贵的是服务端解压(同步 CPU,35 MB 会话单次 723 ms)。
2461
+ // 曾经是 600ms,用户 2026-10-31 直接问「为什么本对话消耗加载这么慢」:
2462
+ // 那 600ms 纯属空等,把同一件迟早要做的事往后推了半秒。
2463
+ const SESSION_USAGE_DELAY_MS = 80;
2464
+
2465
+ /** 「今日」金额的显示精度:数额越小位数越多,大了就逐级省略。
2466
+ * 用户 2026-10-31:如果数额小可以显示到小数点后 4 位,越大就相应省略一位。
2467
+ * 固定两位时小额永远是 ¥0.00(看不到变化),大额又显得啰嗦。 */
2468
+ function todayDecimals(value) {
2469
+ const v = Math.abs(Number(value));
2470
+ if (!Number.isFinite(v)) return 2;
2471
+ if (v < 1) return 4;
2472
+ if (v < 10) return 3;
2473
+ if (v < 100) return 2;
2474
+ if (v < 1000) return 1;
2475
+ return 0;
2476
+ }
2477
+
1438
2478
  function useToday() {
1439
2479
  const [, force] = react.useState(0);
1440
2480
  react.useEffect(() => {
1441
2481
  const listener = () => force((n) => n + 1);
1442
2482
  todayListeners.add(listener);
1443
- void fetchTodayOnce(false);
2483
+ // 已经拿到过值(切会话重新挂载)就直接补拉,不必再等
2484
+ if (todayCache.value !== null) {
2485
+ if (Date.now() - todayCache.at >= todayCache.ttl) void fetchTodayOnce(false);
2486
+ } else {
2487
+ // 用户 2026-10-31:打开 DSH 时先加载插件本体,加载完再加载今日消耗。
2488
+ // 原来是无条件等 1.2 秒 —— 那只是"错开"不是"排在后面":余额慢的时候
2489
+ // 今日照样会按自己的表出发。现在等 balanceCache 真正有结果(余额到位、
2490
+ // 侧栏已经有内容)之后再隔一小段才问今日,顺序上永远排在余额后面。
2491
+ let cancelled = false;
2492
+ let timer = null;
2493
+ let poll = null;
2494
+ const startedAt = Date.now();
2495
+ const fire = () => {
2496
+ if (cancelled) return;
2497
+ timer = setTimeout(() => { void fetchTodayOnce(false); }, TODAY_AFTER_BALANCE_MS);
2498
+ };
2499
+ const waitBalance = () => {
2500
+ if (cancelled) return;
2501
+ const balanceReady = balanceCache.status !== "loading" || balanceCache.balance !== null;
2502
+ // 兜底:余额一直回不来(未登录、断网)时也不能让今日永远不来
2503
+ if (balanceReady || Date.now() - startedAt >= TODAY_WAIT_BALANCE_MAX_MS) {
2504
+ fire();
2505
+ return;
2506
+ }
2507
+ poll = setTimeout(waitBalance, 120);
2508
+ };
2509
+ waitBalance();
2510
+ return () => {
2511
+ cancelled = true;
2512
+ if (timer !== null) clearTimeout(timer);
2513
+ if (poll !== null) clearTimeout(poll);
2514
+ todayListeners.delete(listener);
2515
+ };
2516
+ }
1444
2517
  return () => todayListeners.delete(listener);
1445
2518
  }, []);
1446
2519
  return todayCache.value;
@@ -1454,25 +2527,69 @@ const CN_CALENDAR_SOURCES = [
1454
2527
  const balanceListeners = new Set();
1455
2528
 
1456
2529
  function emitBalance() {
1457
- for (const listener of balanceListeners) listener();
2530
+ // 逐个订阅者隔离:任一订阅者抛错不得中断其他订阅者,也不得抛回调用方。
2531
+ // emitBalance 有多处调用点在 try 之外(如设置热更新、轮询回调),
2532
+ // 抛出去就是 unhandled rejection —— 症状是「某次刷新之后再也没反应」。
2533
+ for (const listener of balanceListeners) {
2534
+ try {
2535
+ listener();
2536
+ } catch {
2537
+ // 单个订阅者异常不影响其他订阅者
2538
+ }
2539
+ }
1458
2540
  }
1459
2541
 
2542
+ // ── 收起态标记(2026-10-31:「隐藏矩形 直接显示在红圈位置 跟显示模型的一样」)──
2543
+ // 收起窄屏时**不再浮一块矩形卡片**,改成在 composer 输入框内部(红圈那行)显示一行
2544
+ // 余额文本,排版语言对齐 DSH 原生的模型名:小字号、次要色、单行、不抢焦点。
2545
+ // 「收起还是展开」是 DSH 根据窗口宽度传给 slot 的 wide,只有 BalanceFooter 拿得到,
2546
+ // 而渲染那行文本的组件挂在另一个 slot(composer.bar)里 —— 所以用模块级标记 + 订阅
2547
+ // 把这一个布尔值传过去。不用 React context:两个 slot 是各自独立的渲染根。
2548
+
2549
+
1460
2550
  // 防抖/防并发:同一时刻只允许一个余额请求;两次刷新间隔不小于 800ms。
1461
2551
  // 疯狂右键/轮询/对话刷新并发时自动合并,避免打爆 DeepSeek API。
1462
2552
  let balanceFetchInFlight = false;
1463
2553
  let lastBalanceFetchAt = 0;
1464
2554
  const BALANCE_REFRESH_MIN_INTERVAL = 800;
1465
2555
 
1466
- async function fetchBalanceOnce() {
2556
+ async function fetchBalanceOnce(force) {
1467
2557
  const now = Date.now();
1468
- if (balanceFetchInFlight || now - lastBalanceFetchAt < BALANCE_REFRESH_MIN_INTERVAL) return;
2558
+ // force = 右键手动刷新。**手动必须绕过 800ms 节流**:那个节流是给自动轮询去重的,
2559
+ // 用户亲手点了一下却被"距上次不足 800ms"静默丢掉,看到的就还是上一次的值 ——
2560
+ // 2026-10-31「余额不一致」里有一部分就是这个(官方 7.36、插件停在前一次的 7.37)。
2561
+ // 防连点交给 onBalanceContextMenu 里的 manualRefreshLockedUntil(580ms 动效锁)。
2562
+ if (balanceFetchInFlight || (!force && now - lastBalanceFetchAt < BALANCE_REFRESH_MIN_INTERVAL)) {
2563
+ // 这次压根没发出请求:手动标记必须一起清掉,否则它会残留到下一次自动刷新,
2564
+ // 把自动更新的响应也标成"手动"——那正是「自动更新不滚动」的成因之一。
2565
+ manualFetchPending = false;
2566
+ return;
2567
+ }
2568
+ // 消费标记:这一次响应是不是右键触发的。跟着响应一起进 balanceCache,
2569
+ // 渲染层据此决定走「擦除」(手动)还是「滚动」(自动),不再靠时间窗猜。
2570
+ // 用户 2026-10-31:「手动更新时擦除样式的 然后滚动就不要了 分清楚了」。
2571
+ const manual = manualFetchPending;
2572
+ manualFetchPending = false;
1469
2573
  lastBalanceFetchAt = now;
1470
2574
  balanceFetchInFlight = true;
1471
2575
  try {
1472
2576
  const data = await fetchBalanceData();
1473
- balanceCache = { status: "ready", balance: data.balance, error: null, refreshing: false };
2577
+ balanceCache = { status: "ready", balance: data.balance, error: null, refreshing: false, manual };
1474
2578
  } catch (error) {
1475
- balanceCache = { status: "error", balance: null, error: error && error.message ? error.message : String(error), refreshing: false };
2579
+ // ⚠️ 失败时**保留上一次成功拿到的数字**(2026-10-31 全量审查)。
2580
+ // 原来这里写 `balance: null`,等于「一次网络抖动就把已知余额清空」:
2581
+ // 派生计算(低余额判定、剩余天数、提示文案)在 error 帧全部拿到 null,
2582
+ // 恢复之后还得重新等一轮轮询才有数。`status: "error"` 已经足够表达「这次没拿到」,
2583
+ // 不需要再用抹掉数据来表达。**渲染层照旧先判 `status === "error"` 显示失败文案**,
2584
+ // 所以可见行为零变化 —— 这里只是不再丢数据。
2585
+ const previousBalance = balanceCache.balance;
2586
+ balanceCache = {
2587
+ status: "error",
2588
+ balance: previousBalance,
2589
+ error: error && error.message ? error.message : String(error),
2590
+ refreshing: false,
2591
+ manual: false,
2592
+ };
1476
2593
  } finally {
1477
2594
  balanceFetchInFlight = false;
1478
2595
  }
@@ -1521,31 +2638,56 @@ const CN_CALENDAR_SOURCES = [
1521
2638
  // 之所以不用 animation 的重播属性,是因为 key 重挂最稳,且能保证连点每次都重新触发。
1522
2639
  let waveSeq = 0;
1523
2640
  const waveListeners = new Set();
1524
- function emitWave() {
1525
- for (const listener of waveListeners) listener();
2641
+ // 水波中心:右键点在元素内的局部坐标({x,y},px)。
2642
+ // 收起态的涟漪以它为圆心向外扩散(2026-10-31:「鼠标点击的对应区域为波浪中心 向外扩张」)。
2643
+ // 放在模块级而不是 state 里,是因为 emitWave 是被 sync 调用的、拿不到组件闭包;
2644
+ // 组件在 listener 里把它抄进 state 再渲染。展开态用不到,值留着不碍事。
2645
+ let waveOriginPoint = null;
2646
+ function emitWave(point) {
2647
+ if (point) waveOriginPoint = point;
2648
+ // 逐个订阅者隔离(同 emitBalance 的理由)。右键刷新走这条,
2649
+ // 订阅者抛错不能让白光停在半路。
2650
+ for (const listener of waveListeners) {
2651
+ try {
2652
+ listener();
2653
+ } catch {
2654
+ // 单个订阅者异常不影响其他订阅者
2655
+ }
2656
+ }
1526
2657
  }
1527
2658
 
1528
- // 手动刷新窗口:右键时记下时间戳,随后到来的那次余额变化算"手动",
1529
- // 走白条抹除;超出窗口或窗口内没等来变化,就都按自动刷新处理 —— 走数字滚轮。
1530
- // 两条动效互斥,不会同时出现。
1531
- const MANUAL_WAVE_WINDOW_MS = 4000;
2659
+ // 「下一次余额请求是不是右键触发的」。`onBalanceContextMenu` 置位,`fetchBalanceOnce`
2660
+ // 消费并写进 `balanceCache.manual`,渲染层据此二选一:手动→擦除,自动→滚动。
2661
+ // **不要退回时间窗判断**(曾经用一个 4000ms 的时间戳窗口):窗口短了,右键那次请求
2662
+ // 稍慢就被判成自动(该擦却滚了);窗口长了,会把紧随其后的自动轮询吞进来(该滚却擦)。
2663
+ // 用户 2026-10-31:「手动更新时擦除样式的 然后滚动就不要了 分清楚了」。
2664
+ let manualFetchPending = false;
1532
2665
 
1533
2666
  // 网页搜索 / 抓取:官方 2026-10-17 上线独立计费,¥5 / 千次 = ¥0.005 / 次,
1534
2667
  // 北京时间 2026-11-01 00:00 起开始收费(此前免费)。与 lib/index.js 同名常量保持一致。
1535
2668
  const WEB_TOOL_BILLING_START = Date.parse("2026-11-01T00:00:00+08:00");
1536
- let manualWaveAt = 0;
1537
- // 抹除动效时长,必须与 CSS 里 balance-wipe-head 的 .58s 一致。
1538
- // 改动画时长时这里也要改,否则锁的时间对不上(太短会重播打断,太长会像"点了没反应")。
1539
- const WAVE_DURATION_MS = 580;
2669
+ // 动效时长:**两态各算各的**(2026-10-31:「收起是收起 展开是展开 动画播放完了
2670
+ // 我还不能刷新」)。原来两态共用一个 900(取最大值),展开态白光只有 580ms,
2671
+ // 播完之后还要再被锁 320ms —— 用户点下去毫无反应,像卡住了。
2672
+ // 收起态:矩形卡的水波 balance-ripple,单圈 900ms。
2673
+ // 展开态:余额行的白光 balance-wipe-head,580ms。
2674
+ // ⚠️ 改任何一边的 CSS 时长,这里必须同步,否则锁和动画对不上(短了会打断重播,
2675
+ // 长了就是"点了没反应")。
2676
+ const WAVE_DURATION_RECT_MS = 700;
2677
+ const WAVE_DURATION_STACKED_MS = 580;
1540
2678
  // 动效播放中的排他锁:这段时间内的右键一律忽略。
1541
2679
  // 目的是保证「一次抹除 = 一次真实刷新」,既不出现"白条扫完余额没动",
1542
2680
  // 也不出现"余额变了但动效没播/播了一半被打断"(2026-10-30 反馈)。
1543
2681
  let manualRefreshLockedUntil = 0;
1544
2682
 
1545
- function waveLockDuration() {
1546
- // 恒等于动画时长。刻意**不**跟随 prefers-reduced-motion —— 白条是本插件的
1547
- // 签名动效,系统开了「减少动态效果」也照播(用户明确要求,别再加回判断)。
1548
- return WAVE_DURATION_MS;
2683
+ function waveLockDuration(element) {
2684
+ // 恒等于**当前这一态**的动画时长。刻意**不**跟随 prefers-reduced-motion ——
2685
+ // 水波/白条是本插件的签名动效,系统开了「减少动态效果」也照播(用户明确要求)。
2686
+ // 靠 classList 认收起态(.balance-fab--rect)而不是读设置:
2687
+ // 收起/展开是布局宽度决定的,不是 footerStyle 设置项。
2688
+ const list = element && element.classList;
2689
+ const isRect = !!list && typeof list.contains === "function" && list.contains("balance-fab--rect");
2690
+ return isRect ? WAVE_DURATION_RECT_MS : WAVE_DURATION_STACKED_MS;
1549
2691
  }
1550
2692
 
1551
2693
  function onBalanceContextMenu(event) {
@@ -1555,25 +2697,41 @@ const CN_CALENDAR_SOURCES = [
1555
2697
  // 下面两条限制一并取消,回到原来纯 800ms 防抖的行为。
1556
2698
  const waveOn = settingsCache.value.refreshWave !== false;
1557
2699
  // ① 动效还没播完:整次右键作废(不动效、不刷新余额),等它播完再让下一次进来。
2700
+ // 锁按时长**分态**算,读的是被点元素自己的 class —— 展开态只锁 580ms,
2701
+ // 不会被收起态的 900ms 拖着(2026-10-31:「动画播放完了 我还不能刷新」)。
1558
2702
  if (waveOn && now < manualRefreshLockedUntil) return;
1559
- // ② 这次点击是否真的会发出请求——判据与 fetchBalanceOnce 内部完全一致。
1560
- const willFetch = !balanceFetchInFlight && now - lastBalanceFetchAt >= BALANCE_REFRESH_MIN_INTERVAL;
2703
+ // ② 这次点击是否真的会发出请求。手动刷新**不受 800ms 节流限制**(那个节流是给
2704
+ // 自动轮询去重的),只防重入 —— 否则刚自动刷过 800ms 内点右键会被静默丢掉,
2705
+ // 界面停在上一次的值,看着就是「和官网不一致」。
2706
+ const willFetch = !balanceFetchInFlight;
1561
2707
  // ③ 会被 800ms 防抖吃掉、根本不会刷新的点击,连动效也不播。
1562
2708
  // 否则白条扫了一遍而余额纹丝不动,看起来像"动了但没扣费"。
1563
2709
  if (waveOn && !willFetch) return;
1564
2710
  if (waveOn) {
1565
- manualRefreshLockedUntil = now + waveLockDuration();
1566
- manualWaveAt = now;
2711
+ manualRefreshLockedUntil = now + waveLockDuration(event && event.currentTarget);
2712
+ // 白光(.balance-footer__wave)每次右键都要扫 —— 用户 2026-10-31 明确
2713
+ // 「你别把白光删了」。它是「你点了,我扫一遍」的即时反馈,跟数值变没变无关。
1567
2714
  waveSeq += 1;
1568
- emitWave();
2715
+ // 记下点击点(相对被点元素的局部坐标),收起态拿它当水波圆心。
2716
+ // 拿不到 layout 信息时传 null,渲染层会退回卡片中心。
2717
+ let point = null;
2718
+ const el = event && event.currentTarget;
2719
+ if (el && typeof el.getBoundingClientRect === "function" && typeof event.clientX === "number") {
2720
+ const rect = el.getBoundingClientRect();
2721
+ point = { x: event.clientX - rect.left, y: event.clientY - rect.top };
2722
+ }
2723
+ emitWave(point);
1569
2724
  }
2725
+ // 标记「接下来这次余额请求是右键触发的」—— `fetchBalanceOnce` 会把它塞进响应,
2726
+ // 渲染层据此决定数字走**擦除**(手动)还是**滚动**(自动)。精确到请求,不靠时间窗猜。
2727
+ if (willFetch) manualFetchPending = true;
1570
2728
  // 白条是"你点了,我扫一遍"的即时反馈;轮询/对话触发的自动刷新不扫光,
1571
2729
  // 改用数字上下滚动(见 BalanceFooter 里的 roll)。
1572
2730
  if (willFetch && !balanceCache.refreshing) {
1573
2731
  balanceCache = { ...balanceCache, refreshing: true };
1574
2732
  emitBalance();
1575
2733
  }
1576
- fetchBalanceOnce();
2734
+ fetchBalanceOnce(true);
1577
2735
  }
1578
2736
 
1579
2737
  const USAGE_STATS_CACHE_TTL = 5 * 60_000;
@@ -1699,7 +2857,11 @@ const CN_CALENDAR_SOURCES = [
1699
2857
 
1700
2858
  const data = state.data;
1701
2859
  const stats = data && data.stats;
1702
- const days = stats && Array.isArray(stats.days) ? stats.days : [];
2860
+ // ⚠️ 兜底必须是**稳定引用**(2026-10-31 全量审查)。
2861
+ // 原来兜底成空数组字面量,stats 缺 days 时每次渲染都生成新引用,
2862
+ // 作为 useMemo 依赖就等于「依赖永远在变」—— 下面 heatmap / derived 的 memo 全部作废,
2863
+ // 白算一遍还是一样慢。改成模块级冻结常量。
2864
+ const days = stats && Array.isArray(stats.days) ? stats.days : EMPTY_ARRAY;
1703
2865
  const showHeatmapTooltip = react.useCallback((event, label) => {
1704
2866
  const rect = event.currentTarget.getBoundingClientRect();
1705
2867
  const viewportWidth = document.documentElement.clientWidth;
@@ -1954,7 +3116,12 @@ const CN_CALENDAR_SOURCES = [
1954
3116
  jsx.jsx("span", {
1955
3117
  children:
1956
3118
  "已扫描 " + String(stats?.scannedSessions || 0) + " 个会话" +
1957
- (stats?.limited ? "(为保证性能仅取最近 200 个)" : "") +
3119
+ // 上限必须读用户实际设置(`statsSessionLimit` 默认 80),
3120
+ // 原来硬编码「最近 200 个」—— 那是今日扫描上限,扫的是两件事,
3121
+ // 文案长期在骗人(2026-10-31 全量审查)。
3122
+ (stats?.limited
3123
+ ? "(为保证性能仅取最近 " + String(settingsCache.value?.statsSessionLimit ?? 80) + " 个)"
3124
+ : "") +
1958
3125
  ";按北京时间和实际模型峰谷价格估算,结果缓存 5 分钟。",
1959
3126
  }),
1960
3127
  jsx.jsxs("div", {
@@ -2002,7 +3169,7 @@ const CN_CALENDAR_SOURCES = [
2002
3169
  ],
2003
3170
  }),
2004
3171
  jsx.jsx("div", { className: "balance-stats-body", children: body }),
2005
- heatmapTooltip ? reactDom.createPortal(jsx.jsx("div", {
3172
+ heatmapTooltip ? createPortal(jsx.jsx("div", {
2006
3173
  className: "balance-heatmap-tooltip balance-heatmap-tooltip--" + heatmapTooltip.align,
2007
3174
  style: heatmapTooltip.style,
2008
3175
  role: "tooltip",
@@ -2174,10 +3341,200 @@ function TimelineBand(props) {
2174
3341
  const state = useBalance();
2175
3342
  const settings = useSettings();
2176
3343
  const today = useToday();
3344
+ // ⚠️⚠️ 今日金额的派生值与 hook 必须待在这一片(所有 return 之前)。
3345
+ // 组件下半部分有多条早退分支:state.status === "error" / !state.balance / depleted,
3346
+ // 首帧 balance 还是 null 就走 2778 那条 return;等数据回来那一帧才继续往下执行。
3347
+ // 一旦把 hook 放在早退之后,两次渲染的 hook 数量就不同 —— React 抛
3348
+ // "Rendered more hooks than during the previous render",整个余额行崩溃,
3349
+ // 错误边界一兜,左下角就空了(2026-10-31 用户反馈「左下角的显示消失了」)。
3350
+ // 记牢:BalanceFooter 里新增的任何 hook 都只能加在这一片。
3351
+ const todayCost = today !== null && Number.isFinite(Number(today.cost)) ? Number(today.cost) : null;
3352
+ // 显示精度随额度分档(<1 → 4 位 … ≥1000 → 0 位),见 todayDecimals。
3353
+ const todayText = todayCost === null ? null : todayCost.toFixed(todayDecimals(todayCost));
3354
+ const [todayRoll, setTodayRoll] = react.useState(null);
3355
+ const lastTodayTextRef = react.useRef(null);
3356
+ usePrePaintEffect(() => {
3357
+ const prev = lastTodayTextRef.current;
3358
+ lastTodayTextRef.current = todayText;
3359
+ // 从等待态第一次拿到真实值:这是「首次出场」,走 CSS 入场动画(--enter),
3360
+ // 不进滚动分支 —— 滚格子前 40% 是全透明的,从空白升起时看着就是硬切。
3361
+ if (todayText === null || prev === null) return;
3362
+ if (prev === todayText) return;
3363
+ // 首屏数据还没定型(`?today=1` 的 partial 补拉阶段):这里的变化是「同一次计算
3364
+ // 算完了」,不是有新消耗,只换基准、不播滚动。否则会先按快照揭幕、3 秒后补拉
3365
+ // 的数顶上来时再滚一次 —— 用户看到的就是「刚加载完今日消耗,下一秒就动了」
3366
+ // (2026-10-31 反馈)。定型之后(settled)所有变化照常滚动。
3367
+ if (!todayCache.settled) return;
3368
+ setTodayRoll({ from: prev, to: todayText, at: Date.now() });
3369
+ }, [todayText]);
3370
+ // 值变成 null(今日数据没了)时清空,避免拿旧基准继续滚。
3371
+ react.useEffect(() => {
3372
+ if (todayText === null) setTodayRoll(null);
3373
+ }, [todayText]);
3374
+ // 滚完定格:摘掉 animation 声明,展开侧栏不再重播
3375
+ useRollSettle(todayRoll, setTodayRoll);
3376
+
3377
+ // 入场动画只播一次:拿到值后 300ms 摘掉 --enter(CSS 时长 260ms + 40ms 余量)。
3378
+ // 不摘的话每次 todayRoll 结束回到静态分支都会重播一遍。
3379
+ // ⚠️ 这个时刻同时决定**底层加载动画(mist)什么时候卸载** —— 揭幕期间它盖在数字上面,
3380
+ // 只露出没扫到的区域(用户 2026-10-31 要求)。卸载必须紧跟动画结束:早了会在
3381
+ // 数字还没铺满时露出空白,晚了则数字已完全盖住、mist 还会多挡 100ms(能看出来)。
3382
+ // ⚠️ 260ms 是 2026-10-31 定的(460 → 340 → 260,两轮「快一点」+「擦除的速度有点慢」)。
3383
+ // **不要顺手把这个 300 和下面 label 位移的 .46s 一起改** —— 那个是「今日」二字
3384
+ // 跟着金额宽度变化滑动用的过渡,和揭幕是两件事,它的时长另有标定理由。
3385
+ // 余额行「从下往上擦出」——加载态已经不渲染任何东西了(原来那行「余额加载中…」
3386
+ // 和余额胶囊高度不同,叠上今日消耗后整块高度对不齐),所以值到达时用这条遮罩
3387
+ // 从底部往上扫一遍:没扫到的区域不显示,和今日揭幕同一套擦除机制,只是方向换成正交。
3388
+ //
3389
+ // ⚠️ **标记必须在初始化时就写**(2026-10-31 第五轮)。原来是在 380ms 的定时器里
3390
+ // 写 globalThis,而那个定时器会被 effect cleanup 清掉 —— 只要你在这几百毫秒内
3391
+ // 折叠/展开一次,标记就永远设不上,下次挂载又是「首次」→ 再播一遍。
3392
+ // 用户反复看到「展开一瞬间数字又跳」,根子就在这里。
3393
+ // 现在改成:初始化时若还没播过,就**当场**把标记写成 true,再由本次挂载播一次。
3394
+ // 定时器只负责收尾(把 --enter 摘掉),即使被取消,标记也已经落定。
3395
+ // 启动入场动画:DSH 启动 / 插件重载 / 页面刷新时,余额第一次出现从下往上擦出
3396
+ // (用户 2026-10-31 原话:「添加一个从下到上的动画跟浪潮一样 滑动上去
3397
+ // 没滑到的地方不显示 一样的擦除机制」)。
3398
+ //
3399
+ // 授权只问 animOnce 一次,**不再依赖任何定时器**:
3400
+ // · 首次挂载 → true → 挂 --enter-up → 播一次
3401
+ // · 任何一次重挂(折叠/展开侧栏都会重挂)→ false → 类根本不挂 → 播不了
3402
+ // · 页面刷新 / 插件重载 → 模块重新求值、台账清空 → 又能播一次(这是想要的)
3403
+ // 定时器只负责「播完把类摘掉」,即使被 cleanup 取消也无所谓 —— 授权已经落定,
3404
+ // 下一轮挂载本来就是 false。
3405
+ const [enterUp, setEnterUp] = react.useState(() => animOnce("footer-enter"));
3406
+ react.useEffect(() => {
3407
+ if (!enterUp) return;
3408
+ // 等余额真的到了再起算(余额是纯网络,实测 419ms;从这里计时才不会
3409
+ // 出现「计时走完、数字才到」导致动画根本没播的情况)
3410
+ if (!state.balance) return;
3411
+ // 340ms 动画 + 40ms 余量
3412
+ const id = setTimeout(() => setEnterUp(false), 380);
3413
+ return () => clearTimeout(id);
3414
+ }, [enterUp, state.balance]);
3415
+ // 四个渲染分支共用,漏一处就会有一条路径不播动画。
3416
+ const footerEnterClass = enterUp ? " balance-footer--enter-up" : "";
3417
+
3418
+ // ── 唯一的「展开兜底」(2026-10-31 方案 3)──────────────────────────────
3419
+ // DSH 折叠侧栏时要么把 footArea 设成 display:none、要么把组件卸载,
3420
+ // 两条路都会让挂在元素上的 CSS 动画从 0 秒重播。所以这里只有一件事:
3421
+ // 重新可见时,把所有「事件驱动」的动画状态统一清零。
3422
+ // 「只许播一次」的动画(今日首播 / 余额入场)不在这里处理 —— 它们由
3423
+ // 模块级台账 animOnce 授权,重挂时读到的就是 false,压根没有动画可播。
3424
+ const clearAnimations = react.useCallback(() => {
3425
+ setRevealSeq(0);
3426
+ setWaveToken(0);
3427
+ setRoll(null);
3428
+ setTodayWipeSeq(0);
3429
+ setTodayRoll(null);
3430
+ setEnterUp(false);
3431
+ }, []);
3432
+ react.useEffect(() => {
3433
+ let wasHidden = false;
3434
+ const timer = setInterval(() => {
3435
+ if (typeof document === "undefined") return;
3436
+ // 窗口不可见时直接跳过:getClientRects() 会强制一次 layout,140ms 一次就是
3437
+ // 每秒 7 次无谓的整页重排(2026-10-31 全量审查)。看不见的时候没人观察动画,
3438
+ // 这里也不改 wasHidden —— 回到可见后的下一拍(≤140ms)照常判定,行为不变。
3439
+ if (document.visibilityState !== "visible") return;
3440
+ const el = document.getElementById("balance-footer-root");
3441
+ if (el === null) return;
3442
+ // getClientRects() 为空 = 祖先 display:none(或自身不可见),即「已折叠」
3443
+ const visible = el.getClientRects().length > 0;
3444
+ if (!visible) {
3445
+ wasHidden = true;
3446
+ return;
3447
+ }
3448
+ if (!wasHidden) return;
3449
+ wasHidden = false;
3450
+ clearAnimations();
3451
+ }, 140);
3452
+ return () => clearInterval(timer);
3453
+ }, [clearAnimations]);
3454
+
3455
+ // 「今日揭幕已经播过」——记在**模块级**,不放组件 state。
3456
+ // 折叠/展开侧栏会让 BalanceFooter 卸载重挂,state 全部归零;而揭幕
3457
+ // (__body 上的 balance-today-num-wipe)是挂在元素上的 CSS 动画,节点一重建就从头播,
3458
+ // 用户看到的就是「点侧边栏按钮导致今日消耗数字滚一下」(2026-10-31 反馈)。
3459
+ // 今日是全局数(不分会话),所以用一个布尔就够,不需要按 sessionId 记。
3460
+ // 「今日」的擦出动画:**值变了就播,展开侧栏不播**(2026-10-31 第十轮)。
3461
+ // 之前有两版都是错的:
3462
+ // ① 动画无条件挂在 __body 上 → 每次有值/每次重挂都播;
3463
+ // ② 改成「整页只播一次」的布尔闸门 → 用户立刻发现「擦出动画怎么改成这样了」,
3464
+ // 因为今日数值刷新(扣费)时也再也不播了,观感退化成硬跳。
3465
+ // 现在用一个序号:todayText 每变一次 +1,序号即 key,只有它变了才重新挂载播动画。
3466
+ // 展开侧栏时序号不变 → 本来不会重播;再叠上下面的可见性清零(display:none
3467
+ // 的元素重新显示时 CSS 动画会从 0 秒重播)就彻底安全。
3468
+ const [todayWipeSeq, setTodayWipeSeq] = react.useState(0);
3469
+ // 「这次擦出播完了没有」。它在擦出期间必须是 false —— 因为 mist(加载动画)
3470
+ // 要靠它决定何时卸载:用户 2026-10-31 明确要的效果是
3471
+ // 「只显示扫过区域的数字,没扫到的还是原来的加载动画」,
3472
+ // 也就是擦出过程中 mist 必须还在、盖住右半边;扫完才撤。
3473
+ const [todayWipeDone, setTodayWipeDone] = react.useState(true);
3474
+ react.useEffect(() => {
3475
+ if (todayText === null) {
3476
+ animWrite("today-last", null);
3477
+ // 回到等待态 = 下一次出值要重新擦一遍。
3478
+ // 跨天归零、切会话待算、首屏还没算出来都会走到这里。
3479
+ animWrite("today-pending", true);
3480
+ return;
3481
+ }
3482
+ // ⚠️ 「上次的值」必须存在**模块级台账**里,不能用组件 ref(2026-10-31 方案 3)。
3483
+ // 折叠/展开侧栏会让 BalanceFooter 重挂、ref 归零 → 本次 effect 会误判成
3484
+ // 「值变了」→ 又擦一遍。这就是「展开会重播扣费动画」的最后一条漏网路径。
3485
+ // 台账在模块级:重挂时记得上次的值,只有真的变了才往下走。
3486
+ if (animRead("today-last") === todayText) return;
3487
+ animWrite("today-last", todayText);
3488
+ // ⚠️ 擦出的判据是「**是不是从等待态跳出来的**」,不是「是不是第一次出值」,
3489
+ // 也不是「值有没有变」。三者的区别(2026-10-31 两轮踩完):
3490
+ // · 只看「值变了」 → 今日是每几秒自动补拉的活数据,一次对话里会反复擦,
3491
+ // 看起来像整块在重新加载(用户:「自动更新时会触发擦出」)。
3492
+ // · 只看「第一次」 → 整个页面生命周期只擦一次,刷新后/跨天后/切会话后
3493
+ // 再也看不到擦出(用户:「我的动画呢 只有加载动画」)。
3494
+ // · 看「等待态出口」→ 每次从 null 到有值都擦一次,纯数值刷新只滚动。这才是对的。
3495
+ if (animRead("today-pending") !== true) return;
3496
+ animWrite("today-pending", false);
3497
+ setTodayWipeSeq((n) => n + 1);
3498
+ // ⚠️ 这里**不要**再 setTodayWipeDone(false) 或开计时器(2026-10-31 审查发现的高危 bug)。
3499
+ // 它们原来写在这个 effect 里,cleanup 是 clearTimeout(id)。可这个 effect 的依赖是
3500
+ // todayText —— 今日是每几秒自动补拉的活数据,只要补拉在 300ms 内再来一次,
3501
+ // cleanup 就会把计时器取消,而本 effect 因为 today-pending 已经翻成 false 会直接
3502
+ // return、不再补一个新的。结果 todayWipeDone 永久停在 false:雾永远盖着、数字再也
3503
+ // 看不见,且不会自愈(后续任何一次刷新都走的是同一条 return 路径)。
3504
+ // 现在只负责递增序号,由下面那个依赖 todayWipeSeq 的 effect 独占计时器 ——
3505
+ // 它的 cleanup 只在下一次真的重新擦出时执行,普通数值刷新碰不到它。
3506
+ }, [todayText]);
3507
+ // 擦出的收尾:序号每变一次就重开一个 300ms 的窗口,窗口结束才摘掉雾。
3508
+ // 260ms 是 CSS 那条动画的时长,+40ms 余量;早了露空白、晚了雾多挡一截。
3509
+ react.useEffect(() => {
3510
+ if (todayWipeSeq === 0) return undefined;
3511
+ setTodayWipeDone(false);
3512
+ const id = setTimeout(() => setTodayWipeDone(true), 300);
3513
+ return () => clearTimeout(id);
3514
+ }, [todayWipeSeq]);
3515
+ // ⚠️ 滚动结束后**不要**把 todayRoll 置回 null(用户 2026-10-31:「被滚动的数字
3516
+ // 即将滚动到位时会有变化,看着很割裂」)。动画是 forwards,420ms 播完就停在终点,
3517
+ // 清空状态会让整串从 dghost/dout/din 结构切成单个静态 dcell —— 两种结构的高度
3518
+ // 与基线有亚像素差,看着就是"到头又跳一下"。保持滚动态,节点不动,视觉连续。
3519
+ // 下一次数值变化时 effect 会把它换成新的 roll(key 换掉、动画重播)。
2177
3520
  // 订阅浪潮计数器:右键时递增,触发重渲染;波浪层用 waveSeq 当 key 重挂来重播动画
2178
- const [, forceWave] = react.useState(0);
3521
+ // ⚠️ 白光必须由**组件内的 token** 驱动,不能用模块级的 `waveSeq` 判断(2026-10-31 第二轮):
3522
+ // `waveSeq` 是模块级变量,重挂不归零;一旦右键刷新过或发生过消费刷新,它就永远 > 0,
3523
+ // 之后每次折叠/展开侧栏(组件重挂、DOM 重建)都会把白光从头播一遍。
3524
+ // 这就是「打开 dsh 之后没消费就没事,一旦产生消费,点收起/展开就还有这个 bug」的原因。
3525
+ // `waveToken` 初值 0 = 本次挂载还没收到过 emitWave → 不渲染白光;
3526
+ // 只有真正右键(emitWave)才 +1,节点才出现、动画才播。
3527
+ const [waveToken, setWaveToken] = react.useState(0);
3528
+ // 水波圆心(相对 FAB 的局部坐标)。null = 没收到点击位置,涟漪退回卡片中心。
3529
+ const [waveOrigin, setWaveOrigin] = react.useState(null);
2179
3530
  react.useEffect(() => {
2180
- const listener = () => forceWave((n) => n + 1);
3531
+ // 每次右键都同步抄一份圆心:waveOriginPoint 是模块级的,
3532
+ // 只在 emitWave 传了坐标时才更新,所以展开态右键(余额行)也会写进去,
3533
+ // 但收起态只在它自己挂载且右键时才渲染涟漪,不影响。
3534
+ const listener = () => {
3535
+ setWaveToken((n) => n + 1);
3536
+ setWaveOrigin(waveOriginPoint);
3537
+ };
2181
3538
  waveListeners.add(listener);
2182
3539
  return () => waveListeners.delete(listener);
2183
3540
  }, []);
@@ -2197,26 +3554,49 @@ function TimelineBand(props) {
2197
3554
  react.useEffect(() => {
2198
3555
  if (rollText !== null) revealBaseRef.current = rollText;
2199
3556
  }, [rollText]);
3557
+ // 擦出真正的驱动源:**手动刷新 + 金额确实变了**才 +1(用户 2026-10-31:
3558
+ // 「这个扣费 bug 又出现了,手动刷新如果余额变了就变啊,没变就不要硬变」)。
3559
+ // 不能用 `"reveal-" + waveSeq` —— 那样每次右键都从 0 重播擦除,金额一模一样也照擦,
3560
+ // 看着就是"没扣费却硬变"。也不能把 totalText 塞进 key —— 自动刷新也会变 key,
3561
+ // 而自动刷新本来就该只滚不擦,两者叠加又是一种重影。
3562
+ // 是否"手动"由 `state.manual`(响应自带)决定,见下面 usePrePaintEffect。
3563
+ const [revealSeq, setRevealSeq] = react.useState(0);
2200
3564
  // 用 usePrePaintEffect(优先 useLayoutEffect):必须在浏览器 paint 之前就把 roll 挂上,
2201
3565
  // 否则这一帧画的是纯文本新值、下一帧才变成滚动格 —— 用户看到的就是"数字比动画先播"。
2202
3566
  usePrePaintEffect(() => {
2203
3567
  const prev = lastRollTextRef.current;
2204
3568
  lastRollTextRef.current = rollText;
3569
+ // 归属由响应自己带:`state.manual` 是这次余额变化的来源。
3570
+ // **手动 → 只擦除、绝不滚动**;**自动 → 只滚动、绝不擦除**(用户 2026-10-31:
3571
+ // 「手动更新时擦除样式的 然后滚动就不要了 分清楚了」)。
3572
+ // 这条判据精确到请求,不再有"时间窗猜错"的可能。
3573
+ const manual = state.manual === true;
2205
3574
  if (rollText === null || prev === null || prev === rollText) return;
2206
- const manual = manualWaveAt !== 0 && Date.now() - manualWaveAt < MANUAL_WAVE_WINDOW_MS;
2207
- manualWaveAt = 0;
2208
- if (manual) return;
3575
+ if (manual) {
3576
+ // 手动刷新期间不滚(擦除已经给足反馈),但**必须把 roll 同步到当前值**。
3577
+ // 只 `return` 的话 `roll` 还停在上一次的 `from`,而下面渲染用
3578
+ // `buildRollCells(roll.from, rollText)` —— 旧 from 配新 rollText 是两个不同数字,
3579
+ // 动画 key(roll.at)又没换,两串数字直接叠在一起糊掉。
3580
+ setRoll({ from: rollText, to: rollText, at: Date.now() });
3581
+ // 值确实变了(上面已确认 prev !== rollText)→ 擦一次。值没变根本走不到这里。
3582
+ setRevealSeq((n) => n + 1);
3583
+ return;
3584
+ }
2209
3585
  setRoll({ from: prev, to: rollText, at: Date.now() });
2210
- }, [rollText]);
3586
+ }, [rollText, state.manual]);
2211
3587
 
2212
3588
  // 逐位拆开:只在真正变了的数位上做上下滚动。实现见模块级的 buildRollCells ——
2213
3589
  // 「本会话消耗」用的是同一个函数,两处滚动因此逐格一致(2026-10-30 用户要求)。
2214
- const rollCells = roll === null ? null : buildRollCells(roll.from, roll.to);
3590
+ // 用「当前文本」而不是 roll.to 作终点:滚动结束后状态不再清空,若期间数值又变了
3591
+ // 但还没产生新 roll,用 roll.to 会显示上一轮的值。from 仍是上一轮起点,动画照常。
3592
+ const rollCells = roll === null ? null : buildRollCells(roll.from, rollText);
3593
+ // 同上:滚完保持滚动态,不要切回静态结构(会造成"到头跳一下")。
3594
+ // 余额消失(未登录 / 失败)时才清空基准。
2215
3595
  react.useEffect(() => {
2216
- if (roll === null) return;
2217
- const id = setTimeout(() => setRoll(null), 480);
2218
- return () => clearTimeout(id);
2219
- }, [roll]);
3596
+ if (rollText === null) setRoll(null);
3597
+ }, [rollText]);
3598
+ // 滚完定格:摘掉 animation 声明,展开侧栏不再重播
3599
+ useRollSettle(roll, setRoll);
2220
3600
  // 设置里的外观值:夹到安全范围,手改 JSON 写出畸形值也不会把 UI 搞坏
2221
3601
  const rawSize = Number(settings.fabSize);
2222
3602
  const fabSize = Number.isFinite(rawSize) ? Math.min(120, Math.max(32, rawSize)) : 60;
@@ -2264,56 +3644,103 @@ function TimelineBand(props) {
2264
3644
  }
2265
3645
  };
2266
3646
  }, []);
3647
+ // ── 时段(高峰/空闲)与剩余时间的动画 ──────────────────────────────
3648
+ // 两者都从 timelineNow 派生,所以必须在它之后、且仍在所有早退 return 之前。
3649
+ // 和今日金额同理:任何新 hook 都不许越过下面的早退分支(会触发 React 的
3650
+ // "Rendered more hooks than during the previous render",整条余额行崩掉)。
3651
+ const periodNow = currentPeriod(timelineNow);
3652
+ const periodLabelNow = periodNow === "peak" ? "高峰" : "空闲";
3653
+ // 空闲 ⇄ 高峰切换时给整词重挂一次,让它淡入上浮;首帧不触发。
3654
+ const periodTick = useChangeTick(periodLabelNow);
3655
+ const spanNow = periodSpan(timelineNow);
3656
+ // 今日金额一变化,整块宽度就变,而容器是 justify-content:center —— 整块重新居中,
3657
+ // 「今日」二字被推着左右挪。用户 2026-10-31 明确:要的是**滑过去**,不是跳一下。
3658
+ //
3659
+ // 纯 CSS 做不到:位移不是由某个可过渡的属性驱动的,是 flex 居中重算出来的。
3660
+ // 所以这里量出来再补 —— paint 前把 label 挪回「旧位置」,下一帧放开 transition
3661
+ // 让它自己滑到新位置。
3662
+ //
3663
+ // ⚠️ 量的是 **label 自己的 left**,不是金额宽度。两个原因:
3664
+ // ① 启动时最关键的那一跳发生在「等待态 → 有值」,而等待态渲染的是
3665
+ // `--today-wait`,根本没有 `.balance-footer__today-value` 节点,量宽度会直接
3666
+ // return,于是这一跳完全没有过渡(用户 2026-10-31:「启动时今日还是会跳过去」)。
3667
+ // ② 直接量 left 就是真实位移量,不用再推导 ΔW/2,也不依赖居中算法。
3668
+ //
3669
+ // 之所以能用 DOM:DSH 的 jsx 工厂不赋 ref,但 document.querySelector 照样能拿到节点。
3670
+ const todayLeftRef = react.useRef(null);
3671
+ usePrePaintEffect(() => {
3672
+ if (typeof document === "undefined" || typeof document.querySelector !== "function") return;
3673
+ const label = document.querySelector(".balance-footer__timeline-meta-item--today .balance-footer__today-label");
3674
+ if (!label) return;
3675
+ const left = label.getBoundingClientRect().left;
3676
+ const prev = todayLeftRef.current;
3677
+ todayLeftRef.current = left;
3678
+ // 首次挂载没有参照,只记位置不滑动。
3679
+ if (prev === null) return;
3680
+ // 新位置比旧位置左了多少,就先右移多少回到旧位置,再过渡回 0。
3681
+ const shift = prev - left;
3682
+ if (Math.abs(shift) < 0.5) return;
3683
+ label.style.transition = "none";
3684
+ label.style.transform = "translateX(" + shift.toFixed(2) + "px)";
3685
+ // 下一帧才放开过渡。这里用 setTimeout 而不是 rAF —— DSH 渲染进程里
3686
+ // rAF 会被窗口可见性挂起,回调可能永远不来(见 AGENTS「UI 易错点」)。
3687
+ const id = setTimeout(() => {
3688
+ // 460ms 不是随手写的:启动那一刻的位移只有 2.2px,320ms 滑完肉眼看不出在动、
3689
+ // 只觉得"顿了一下";拉到 460ms 才读得出是平滑挪过去的(2026-10-31 反馈)。
3690
+ label.style.transition = "transform .46s cubic-bezier(.22,.72,.28,1)";
3691
+ label.style.transform = "translateX(0px)";
3692
+ setTimeout(() => {
3693
+ // 清干净:transition 留着会影响后续任何位移,
3694
+ // transform 留着更糟 —— 残留的 translateX(0px) 会让这块独立成合成层,
3695
+ // 小字号中文在 GPU 纹理里渲染直接发虚(用户 2026-10-31:「数字变糊了」)。
3696
+ label.style.transition = "";
3697
+ label.style.transform = "";
3698
+ }, 500);
3699
+ }, 16);
3700
+ return () => clearTimeout(id);
3701
+ }, [todayText]);
3702
+ // 连「剩」前缀一起存:滚动要对齐补的是数字段,前缀是非数字段、保持不动。
3703
+ const remainText = spanNow === null ? null : "剩 " + formatRemain(spanNow.remainMs);
3704
+ const remainPrevRef = react.useRef(null);
3705
+ const [remainRoll, setRemainRoll] = react.useState(null);
3706
+ usePrePaintEffect(() => {
3707
+ const prev = remainPrevRef.current;
3708
+ remainPrevRef.current = remainText;
3709
+ // 首次拿到(等待 → 有值)不做滚动,交给元素自身挂载即可;
3710
+ // 之后每分钟整分钟跳一次才滚,滚的是变了的那个数字段。
3711
+ if (remainText === null || prev === null || prev === remainText) return;
3712
+ setRemainRoll({ from: prev, to: remainText, at: Date.now() });
3713
+ }, [remainText]);
3714
+ // 同上:滚完保持滚动态,不要再切回静态结构(会造成"到头跳一下")。
3715
+ // 剩余时间消失时清空即可。
3716
+ react.useEffect(() => {
3717
+ if (remainText === null) setRemainRoll(null);
3718
+ }, [remainText]);
3719
+ // 滚完定格:摘掉 animation 声明,展开侧栏不再重播
3720
+ useRollSettle(remainRoll, setRemainRoll);
2267
3721
  // 指针位置见模块作用域的 TimelinePointer —— 刻意拆成独立组件:
2268
3722
  // 补间只重渲染那一根 2px 竖条,不会带着整个余额行一起重渲染。
2269
3723
  const clickTimer = react.useRef(null);
2270
3724
  const closeTimer = react.useRef(null);
2271
- const anchorRef = react.useRef(null);
2272
3725
  const layerRef = react.useRef(null);
2273
3726
  const panelRef = react.useRef(null);
2274
3727
  react.useEffect(() => () => {
2275
3728
  if (clickTimer.current !== null) clearTimeout(clickTimer.current);
2276
3729
  if (closeTimer.current !== null) clearTimeout(closeTimer.current);
2277
3730
  }, []);
2278
- // 收起态:让圆形余额与聊天输入卡片(composer card)**垂直居中对齐**。
2279
- // 卡片位置随窗口尺寸、输入行数变化,硬编码 bottom 必然错位,所以运行时测量。
2280
- // DSH 的 slot 契约里 conversation.composer.bar = "Resident composer body"(就是那张卡片),
2281
- // 而本插件的消耗行挂在 conversation.composer.dock(卡片下方的 ambient entries)。
2282
- react.useEffect(() => {
2283
- if (wide) return;
2284
- const anchor = anchorRef.current;
2285
- if (!anchor) return;
2286
- const apply = () => {
2287
- const centerY = measureComposerCenterY();
2288
- if (centerY === null) {
2289
- // 测不到(旧版 DSH / 卡片尚未渲染)→ 交回 CSS 里的 bottom:12px 兜底
2290
- anchor.style.top = "";
2291
- anchor.style.bottom = "";
2292
- return;
2293
- }
2294
- // 必须同时清掉 bottom:fixed 元素同时有 top 和 bottom 会被拉伸
2295
- anchor.style.bottom = "auto";
2296
- anchor.style.top = Math.round(centerY - (anchor.offsetHeight || fabSize) / 2) + "px";
2297
- };
2298
- apply();
2299
- window.addEventListener("resize", apply);
2300
- let observer = null;
2301
- try {
2302
- const card = document.querySelector('[data-slot="conversation.composer.bar"]');
2303
- if (typeof ResizeObserver === "function" && card) {
2304
- observer = new ResizeObserver(apply);
2305
- observer.observe(card);
2306
- }
2307
- } catch {
2308
- /* 结构变了就只靠 resize 兜底 */
2309
- }
2310
- return () => {
2311
- window.removeEventListener("resize", apply);
2312
- if (observer) observer.disconnect();
2313
- anchor.style.top = "";
2314
- anchor.style.bottom = "";
2315
- };
2316
- }, [wide, fabSize]);
3731
+ // ── 收起态纵向定位:统一由 fabBottom(见下方测量 effect)负责 ──────────────
3732
+ // ⚠️ 这里原来有一段用 anchorRef 量 composer 中心、再写 anchor.style.top 的
3733
+ // 「垂直居中」逻辑,2026-10-31 全量审查时删掉了。原因是**方案已被取代**,
3734
+ // 不是因为 ref 拿不到节点:
3735
+ // · 那时 .balance-stats-anchor--compact 还是 position:fixed,靠 style.top 摆位置;
3736
+ // · 现在那个 anchor 已经改成 position:static(只做 Tooltip 挂载点),fixed 定位
3737
+ // 挪到了 .balance-fab 自己身上、由 fabBottom 内联注入 bottom。
3738
+ // 两套并存时,旧的 top 依然会写进 style —— 对 static 元素无效但会留在 DOM 上,
3739
+ // 一旦将来有人把 anchor 改回 fixed 就会和 bottom 打架(fixed 同时有 top 和
3740
+ // bottom 会被拉伸成一条)。所以整段清掉,纵向位置只保留一个来源。
3741
+ // 它原来还挂了 ResizeObserver 监听卡片尺寸变化 —— 那部分职责由下面测量 effect 的
3742
+ // window resize + 400ms 轮询承担,覆盖同样的场景。
3743
+ // measureComposerCenterY 保留(实现没问题),目前没有调用方。
2317
3744
  const closeStats = react.useCallback(() => {
2318
3745
  if (!statsOpen || statsClosing) return;
2319
3746
  // 任何关闭路径都先撤销待执行的「打开」定时器,避免关掉后又被自己弹回来
@@ -2391,21 +3818,20 @@ function TimelineBand(props) {
2391
3818
  // props 可能被 Tooltip 的触发逻辑覆盖,结果只有金额胶囊响应、整行看着能点却点不动
2392
3819
  // (2026-10-30 反馈)。提到 Tooltip 外层 + 捕获阶段,双保险。
2393
3820
  const withPanel = (node, hint, anchorStyle, extraProps) => jsx.jsxs("div", {
2394
- ref: anchorRef,
2395
3821
  className: "balance-stats-anchor balance-stats-anchor--" + (wide ? "wide" : "compact"),
2396
3822
  style: anchorStyle,
2397
3823
  ...(extraProps || null),
2398
3824
  children: [
2399
3825
  // 悬停提示走 DSH 主题化浮层:primitives.Tooltip 内部是 cloneElement(children, {ref}),
2400
3826
  // 不会插入额外 DOM 层,因此不破坏余额行在 flex 容器里的 42px 布局(浮层本身 portal 到 body)。
2401
- jsx.jsx(primitives.Tooltip, {
3827
+ jsx.jsx(Tooltip, {
2402
3828
  label: hint || "单击查看余额与使用统计,双击打开 DeepSeek API 用量页,右键刷新",
2403
3829
  side: "top",
2404
3830
  delayMs: 400,
2405
3831
  children: node,
2406
3832
  }),
2407
3833
  // 统计面板:居中弹层 + 半透明遮罩(点击遮罩 / Esc / 关闭按钮均可关闭)
2408
- statsOpen ? reactDom.createPortal(jsx.jsx("div", {
3834
+ statsOpen ? createPortal(jsx.jsx("div", {
2409
3835
  ref: layerRef,
2410
3836
  className: "balance-stats-layer" + (statsClosing ? " balance-stats-layer--closing" : ""),
2411
3837
  onMouseDown: handleBackdropClick,
@@ -2418,16 +3844,170 @@ function TimelineBand(props) {
2418
3844
  ],
2419
3845
  });
2420
3846
 
3847
+ // 收起态浮窗被 composer 压住时直接隐藏(2026-10-31:「如果被遮挡直接隐藏就行了」)。
3848
+ // ⚠️ 必须量**真实矩形**,不能按 bottom:140 之类常量去推算:上一版就是这样错的 ——
3849
+ // 算出来的位置和实际画出来的位置差了一百多像素,于是明明压在 composer 上却判定"没遮挡"。
3850
+ // 几何只有一个来源:DOM。谁改 CSS 都不用同步改这里。
3851
+ const [fabCovered, setFabCovered] = react.useState(false);
3852
+ // 浮窗的 bottom:与 composer 卡片**底边齐平**(2026-10-31:「浮窗跟对话框齐平」)。
3853
+ // null = 还没量到(首帧 / composer 未挂载),此时退回 CSS 里的 140px 兜底。
3854
+ // 之所以要算而不是写死:composer 离视口底的距离由 DSH 的聊天区 padding 决定,
3855
+ // 不同窗口/不同版本会变,写死任何一个值都会差出一条缝。
3856
+ const [fabBottom, setFabBottom] = react.useState(null);
3857
+ // 位置是否已经确定。首帧一律 false → 浮窗虽然渲染但 visibility:hidden,
3858
+ // 等测量写进 fabBottom 后才显形(2026-10-31:「为什么加载插件的时候 浮窗的位置会闪动」)。
3859
+ // 闪动的成因是两个渲染批次各画在不同位置:第一批吃 CSS 兜底的 140px,
3860
+ // 第二批才拿到实测值。即使把测量搬进 paint 前,只要锚点还没挂载(消耗行属于
3861
+ // 另一个 slot,可能晚于余额行),就仍然要先画一次兜底值。所以真正的修法是
3862
+ // **在知道位置之前根本不显示它** —— 闪动是"看见两个位置",不给看就没有。
3863
+ const [fabReady, setFabReady] = react.useState(false);
3864
+ // ⚠️ 光等测量还不够(2026-10-31「这个 bug 还在」)。
3865
+ // fabSize / fabLeft / footerStyle 全部来自 /balance-api?config=1,首帧用的是
3866
+ // SETTINGS_DEFAULTS,设置一回来就被真实值替换 —— 于是 effect 重跑、重新测量、
3867
+ // 浮窗换到一个新位置。测量本身没错,它诚实地量了两次**不同的东西**。
3868
+ // 所以显形条件必须是「测量完成 且 设置已就位」,两者缺一都还会闪。
3869
+ // fabSettled 是硬超时兜底:设置请求失败时 loaded 永远为 false,不能让浮窗
3870
+ // 因此永久不可见。
3871
+ // ⚠️ 它的计时器**必须放在下面主 effect 里面、并且在 wide 为 true 时归零**
3872
+ // (2026-10-31 第三次:「是在这个位置 然后跳到当前位置」)。
3873
+ // 之前写成独立的 [] effect,于是插件加载时(侧栏还展开着)计时器就开始跑,
3874
+ // 800ms 到点时一次测量都还没发生(wide === true 时 effect 直接 return),
3875
+ // fabVisible 被硬超时打开 → 浮窗先画在 CSS 兜底的 140px;等用户收起侧栏、
3876
+ // 测量真正跑起来,才跳到实测位置。红圈那个位置换算出来正是 821-140-105=576,
3877
+ // 就是这条兜底规则的位置。
3878
+ const [fabSettled, setFabSettled] = react.useState(false);
3879
+ // 800ms 之后无论如何都显示;之前必须「测到位置 + 设置已加载」双满足
3880
+ const fabVisible = fabSettled || (fabReady && settingsCache.loaded === true);
3881
+ // ⚠️ 用 usePrePaintEffect(paint 前同步)而不是 useEffect:后者在 paint 之后跑,
3882
+ // 至少会多一帧停在兜底位置。DSH 里没有 useLayoutEffect 时它会退回 useEffect。
3883
+ usePrePaintEffect(() => {
3884
+ if (wide) {
3885
+ // 展开态不需要浮窗,直接归零。同值 setState 会被 React 跳过,不会多渲染一轮。
3886
+ setFabCovered(false);
3887
+ // 收起态的"显形门控"也一起复位:下次真正进入收起态时重新计时、重新测量,
3888
+ // 绝不能让上一次(甚至从未开始过的)计时结果把浮窗提前放出来。
3889
+ setFabReady(false);
3890
+ setFabSettled(false);
3891
+ return undefined;
3892
+ }
3893
+ let stopped = false;
3894
+ const measure = () => {
3895
+ if (stopped) return;
3896
+ if (typeof document === "undefined" || typeof document.querySelector !== "function") return;
3897
+ const fab = document.querySelector(".balance-stats-anchor--compact .balance-fab")
3898
+ || document.querySelector(".balance-fab");
3899
+ // ⚠️ 锚点用 **dock 行**,不是 conversation.composer.bar(2026-10-31:「位置太靠上」)。
3900
+ // 实测 composer.bar 在真实 DSH 里挂的是**整个输入区容器**(高 700px 以上),
3901
+ // 不是那张输入卡片 —— 任何基于它的几何都会算偏:
3902
+ // · 用 c.top → 得到容器的顶部,浮窗被推到聊天区中间("太靠上");
3903
+ // · 用 c.bottom → 得到容器的底部,浮窗被推出视口("浮窗不显示了")。
3904
+ // dock 行是插在输入卡片**正下方**的那一条(本插件自己的消耗行就挂在里面),
3905
+ // 它的 top 几乎等于卡片底边,高度也只是一行 —— 几何稳定、语义明确。
3906
+ // ⚠️ 锚点有三个候选,按可靠性排序(2026-10-31:「位置太靠上」+「不是你改了什么」)。
3907
+ // 之前几版都在猜 DSH 的 DOM:
3908
+ // · conversation.composer.bar → 实测挂的是**整个输入区容器**(高 700px+),
3909
+ // 用它算出来的位置要么把浮窗顶到聊天区中间,要么推出视口。
3910
+ // · conversation.composer.dock → data-slot 属性不一定存在,查不到就整段 return,
3911
+ // 浮窗默默退回 CSS 的 140px —— 表现就是"改了跟没改一样"。
3912
+ // 所以主锚点换成**插件自己的消耗行**(.balance-usage-line):它一定挂在 dock 行里、
3913
+ // 一定在输入卡片正下方,而且是我们自己的类名,不依赖 DSH 内部属性。
3914
+ // 没开消耗行显示时才退到 dock slot,两者都没有就退回 CSS 兜底。
3915
+ const anchorEl = document.querySelector(".balance-usage-line")
3916
+ || document.querySelector(".balance-usage-wrap")
3917
+ || document.querySelector('[data-slot="conversation.composer.dock"]');
3918
+ if (fab === null || anchorEl === null) {
3919
+ setFabCovered(false);
3920
+ return;
3921
+ }
3922
+ const f = fab.getBoundingClientRect();
3923
+ const d = anchorEl.getBoundingClientRect();
3924
+ const vh = window.innerHeight;
3925
+ // 浮窗底边 = 锚点顶部再往下 8px,视觉上就是「与对话框底边齐平」。
3926
+ const rawBottom = Math.round(vh - d.top) + 8;
3927
+ // 唯一要守的约束:浮窗整块得留在视口内。锚点正常时 rawBottom 只有几十 px,
3928
+ // 一旦算出接近视口高度就说明拿到的不是那一行,宁可不设、退回 CSS 兜底。
3929
+ // ⚠️ 带 1px 死区:锚点的 sub-pixel 位置每帧可能抖动,round 之后就是 ±1px。
3930
+ // 每 400ms 为这 1px 重新 setState 一次,表现就是浮窗在轻微上下抖。
3931
+ const nextBottom = rawBottom + 140 <= vh ? Math.max(8, rawBottom) : null;
3932
+ setFabBottom((prev) => {
3933
+ if (nextBottom === null || prev === null) return nextBottom;
3934
+ return Math.abs(nextBottom - prev) <= 1 ? prev : nextBottom;
3935
+ });
3936
+ // 位置已定 → 允许显形。测不到锚点时**不要**置 ready,
3937
+ // 让浮窗维持不可见,由下面的 800ms 兜底负责"实在测不到也不能永远不显示"。
3938
+ setFabReady(true);
3939
+ if (f.width === 0 || f.height === 0) {
3940
+ setFabCovered(false);
3941
+ return;
3942
+ }
3943
+ // 遮挡判定 = 浮窗和锚点**真正相交**,不再只比 x 区间。
3944
+ // 原注释写「它和输入卡片同宽」,但锚点早就换成我们自己的窄 pill(.balance-usage-line),
3945
+ // 拿一句失效的假设去判几何迟早出事(2026-10-31 全量审查)。
3946
+ // 正常布局下浮窗底边比锚点顶边低 8px、高度 = fabSize,两者纵向必然重叠,
3947
+ // 所以补上 Y 判定不改变实际结果,只是把「横向代理」换成真判定。
3948
+ const overlapX = f.right > d.left + 4 && f.left < d.right - 4;
3949
+ const overlapY = f.bottom > d.top + 4 && f.top < d.bottom - 4;
3950
+ setFabCovered(overlapX && overlapY);
3951
+ };
3952
+ measure();
3953
+ // 不用 requestAnimationFrame:DSH 渲染进程里它会被窗口可见性挂起
3954
+ window.addEventListener("resize", measure);
3955
+ // 不可见时不测:400ms 一次 getBoundingClientRect 同样是强制 layout。
3956
+ // 位置只在看得见的时候才有意义,回到可见后 400ms 内必然重新测到一次。
3957
+ const timer = setInterval(() => {
3958
+ if (typeof document !== "undefined" && document.visibilityState !== "visible") return;
3959
+ measure();
3960
+ }, 400);
3961
+ // 兜底:进入收起态后 800ms 内一次都没测到(消耗行迟迟没挂载 / 被用户关掉)
3962
+ // 就按 CSS 的 140px 显形。宁可位置不够理想,也不能因为测量失败让浮窗永远不可见。
3963
+ // ⚠️ 计时器必须在**这里**(收起态专属),不能放到 [] 里另行启动 —— 否则侧栏还
3964
+ // 展开着的时候它就开始跑,到点时一次测量都没发生,浮窗会先画在兜底位置。
3965
+ const fallback = setTimeout(() => {
3966
+ if (!stopped) setFabSettled(true);
3967
+ }, 800);
3968
+ return () => {
3969
+ stopped = true;
3970
+ window.removeEventListener("resize", measure);
3971
+ clearInterval(timer);
3972
+ clearTimeout(fallback);
3973
+ };
3974
+ // ⚠️ 依赖里**不能**放 fabCovered / fabBottom / fabReady:它们的 setState 会重跑本
3975
+ // effect、重建定时器,变成"测一次→重建→再测一次"的抖动循环。同值写入本来就会被
3976
+ // React 跳过。
3977
+ }, [wide, fabSize, fabLeft, settings.footerStyle]);
3978
+
3979
+ // 收起态标记那套模块级广播(compactState / compactListeners / notifyCompact)
3980
+ // 和它的消费者组件已于 2026-10-31 全量审查时**整块删除**:那个 slot 注册会把
3981
+ // 整个插件搞没,组件从来不会挂载,广播零消费者。别再加回来。
3982
+
2421
3983
  if (!wide) {
2422
3984
  // 设置里关掉圆钮就整个不渲染(统计面板入口随之消失,符合「关掉显示」的预期)
2423
3985
  if (settings.showFab === false) return null;
3986
+ // 被 composer 压住就隐藏:宁可这一块不显示,也不要盖住输入框
3987
+ if (fabCovered) return null;
2424
3988
  if (state.status === "error" || !state.balance) return null;
2425
3989
  const b = state.balance;
2426
- // 尺寸 / 水平位置由设置决定,用内联样式覆盖 CSS 默认值(CSS 保留 60px/44px 兜底)
2427
- const fabStyle = { width: fabSize + "px", height: fabSize + "px", left: fabLeft + "px" };
3990
+ // 尺寸 / 水平位置由设置决定,用内联样式覆盖 CSS 默认值(CSS 保留 60px/44px 兜底)。
3991
+ // bottom 优先用测到的 composer 底边(与对话框齐平),没测到才让 CSS 的 140px 生效。
3992
+ const fabStyle = {
3993
+ width: fabSize + "px",
3994
+ height: fabSize + "px",
3995
+ left: fabLeft + "px",
3996
+ };
3997
+ if (fabBottom !== null) fabStyle.bottom = fabBottom + "px";
3998
+ // 位置未定之前不可见(见上面 fabVisible 的说明)。visibility 而不是 display:none ——
3999
+ // 后者会让元素没有盒子、getBoundingClientRect 全 0,遮挡判断就永远测不出来。
4000
+ if (!fabVisible) fabStyle.visibility = "hidden";
2428
4001
  const anchorStyle = { left: fabLeft + "px" };
2429
4002
  const total = Number.parseFloat(b.total_balance ?? "0");
2430
- const depleted = b.is_available === false || !Number.isFinite(total) || total <= 0;
4003
+ // 「余额不足」必须听服务端的 is_available,**不能**用 total <= 0 推断
4004
+ // (2026-10-31 审查):`total_balance` 只是**充值余额**,赠送钱包单列在 granted_balance 里
4005
+ // (见 AGENTS「主数字只等于充值余额」)。所以「充值花光但赠送还有」时 total 是 0、
4006
+ // is_available 却是 true —— 旧写法会把这种账号判成没钱:余额行变成「去充值」、
4007
+ // 圆钮变成充值卡,而实际还能用。只有在服务端没给这个字段时才退回按金额推断。
4008
+ const depleted = typeof b.is_available === "boolean"
4009
+ ? b.is_available === false
4010
+ : (!Number.isFinite(total) || total <= 0);
2431
4011
  // ⚠️ 必须 portal 到 body:收起态 DSH 把整个 footArea 设为 display:none
2432
4012
  // (`[data-windows-titlebar] .<hash>_collapsed .<hash>_footArea{display:none}`),
2433
4013
  // 而 `sidebar.footer.action` 就在 footArea 里。display:none 的祖先不会生成盒子,
@@ -2435,8 +4015,179 @@ function TimelineBand(props) {
2435
4015
  // portal 到 body 后 position:fixed 才真正生效(2026-09-28 用户反馈「收起侧边栏余额不显示」)。
2436
4016
  // 圆形 FAB 放不下长文本:余额显示「¥<金额>」,余额耗尽时用「充值」而不是「去充值」
2437
4017
  const balanceText = "¥" + b.total_balance;
2438
- // 两种情况都经 withPanel:收起态的定位落在它渲染的 .balance-stats-anchor--compact 上
2439
- // (测量 effect 通过 anchorRef 设 top),裸 FAB 就没有 anchor 可量了。
4018
+
4019
+ // ─── 新版(timeline)收起态:矩形卡片(2026-10-31) ────────────────────
4020
+ // 「这个同样跟随新版跟旧版的选择 当前样式是旧版 显示一个矩形 里面显示跟新版里一样的东西
4021
+ // 动画也要在」——所以新版收起态不再是圆钮,而是一块竖排小卡:
4022
+ // 余额(逐位滚动) / 时段词 / 刻度条(含指针) / 今日金额(等待态是雾)。
4023
+ // 全部沿用展开态那套 state 和 CSS 类,不另写一份动画,滚动/雾/指针原样继承。
4024
+ // 宽度锁在 fabSize(默认 60),高度由内容撑开——收起态侧栏本来就窄,只能竖排。
4025
+ if (settings.footerStyle === "timeline") {
4026
+ // ⚠️ 这里**必须继续渲染**矩形浮层(2026-10-31:「插件改没了」)。
4027
+ // 曾经为了「隐藏矩形 直接显示在红圈位置」把这段换成 return null、把整块交给
4028
+ // conversation.composer.bar 里的 CompactBalanceLine —— 结果那个 slot 根本没渲染,
4029
+ // 于是收起态两边都是空的,整个插件看起来消失了。
4030
+ // slots.register 对不存在的 slot 名是**静默失败**(不抛错、不报警),所以
4031
+ // 不能靠"注册没报错"判断它可用。
4032
+ // 现在只留浮层(bottom 已抬到 140px,不再压 composer)。原来还并着一条
4033
+ // composer.bar 文本行,那个 slot 从未渲染成功,已随 2026-10-31 死代码清理删除。
4034
+ // 宽度独立于圆钮尺寸:圆钮 60px 装四行内容字会被挤到 9px(2026-10-31 用户:
4035
+ // 「太小了吧」)。收起态侧栏约 56px,但 FAB 是 position:fixed 浮层,
4036
+ // 可以比侧栏宽——这里按 fabSize 的 1.85 倍走,默认 112px。
4037
+ // 112px 是「时段剩余时间 + 今日二字都要放得下,且中文不落到 9px 亚像素」的实测值
4038
+ // (2026-10-31:「可以再大一点 让字体显示清楚」)。
4039
+ const rectW = Math.max(96, Math.round(fabSize * 1.85));
4040
+ // ⚠️ 定位基准必须是**圆钮中心**,不是圆钮左边缘(2026-10-31:「我怎么感觉你
4041
+ // 一直在动这个矩形的位置呢」)。fabLeft 是圆钮**左缘**的坐标,圆钮只有 fabSize
4042
+ // 宽,而卡片有 1.85 倍宽 —— 直接写 left: fabLeft 时左缘被钉住、多出来的宽度
4043
+ // 全部朝右长,于是每次调整 fabSize 或宽度系数,卡片右缘就跟着往右漂一截,
4044
+ // 看起来像"卡片在移动"而不是"卡片变胖"。
4045
+ // 按中心对齐后加宽是左右对称扩张,卡片看上去定在原地。
4046
+ const rectLeft = Math.max(4, Math.round(fabLeft + fabSize / 2 - rectW / 2));
4047
+ const rectStyle = { width: rectW + "px", left: rectLeft + "px" };
4048
+ // 与对话框齐平:矩形卡用的是 rectStyle 而不是 fabStyle,两边都要带上 bottom,
4049
+ // 否则只有圆形分支对齐、矩形分支还挂在 CSS 的 140px 上(两种布局手感不一致)。
4050
+ if (fabBottom !== null) rectStyle.bottom = fabBottom + "px";
4051
+ // 同样要带可见性控制,否则矩形卡会先闪一下兜底位置(用户反馈的就是这种浮窗)
4052
+ if (!fabVisible) rectStyle.visibility = "hidden";
4053
+ // ⚠️ 余额**只用数字**,绝不用展开态的 `rollCells`:那个基于 `totalText`
4054
+ // =「17.87 CNY」,塞进 81px 会被 `overflow:hidden` 裁成「17.87 CN\」
4055
+ // (2026-10-31 实测截图)。币种在收起态没有横向空间,展开态才显示。
4056
+ // 滚动起点同样要把 `roll.from` 里的币种剥掉,否则 from 含 CNY、to 不含,
4057
+ // 逐位比对会整串错位。
4058
+ const rectNum = String(b.total_balance);
4059
+ const stripCurrency = (s) => String(s == null ? "" : s).replace(/\s*[A-Z]{3}\s*$/, "");
4060
+ const rectRoll = roll === null ? null : { from: stripCurrency(roll.from), at: roll.at, done: roll.done };
4061
+ const rectFab = withPanel(jsx.jsxs("a", {
4062
+ className: "balance-fab balance-fab--rect",
4063
+ style: rectStyle,
4064
+ ...externalLink(USAGE_URL, "usage"),
4065
+ ...interactions,
4066
+ children: [
4067
+ // 右键水波(2026-10-31:「设计成水面波浪样式的 鼠标点击的对应区域为波浪中心 向外扩张」)。
4068
+ // 只放**一圈**(2026-10-31:「只显示一条就够了」):三圈错开会读成"重复的同心环",
4069
+ // 单圈从点击点推到边缘就已经把"水面被点了一下"讲清楚了。
4070
+ // 判据与展开态一致:只有本次挂载真的收到过 emitWave 才渲染,
4071
+ // 否则重挂时 CSS 动画会从 0 重播一次("没消费却凭空荡一圈水")。
4072
+ waveToken === 0 || settings.refreshWave === false
4073
+ ? null
4074
+ : jsx.jsx("span", {
4075
+ className: "balance-fab__ripple-layer",
4076
+ key: "wave-" + waveToken,
4077
+ "aria-hidden": "true",
4078
+ children: jsx.jsx("span", {
4079
+ className: "balance-fab__ripple",
4080
+ style: {
4081
+ left: (waveOrigin === null ? 56 : waveOrigin.x) + "px",
4082
+ top: (waveOrigin === null ? 80 : waveOrigin.y) + "px",
4083
+ },
4084
+ }),
4085
+ }),
4086
+ // 余额
4087
+ jsx.jsx("span", {
4088
+ className: "balance-fab__value" + (total > 0 && total < lowThreshold ? " balance-fab__value--low" : ""),
4089
+ children: jsx.jsx("span", {
4090
+ className: "balance-footer__roll",
4091
+ children: renderRollCells(
4092
+ buildRollCells(rectRoll === null ? rectNum : rectRoll.from, rectNum),
4093
+ "f" + (rectRoll === null ? "" : rectRoll.at),
4094
+ rectRoll !== null && rectRoll.done === true,
4095
+ ),
4096
+ }),
4097
+ }),
4098
+ // 今日消费紧贴余额下方(2026-10-31 箭头标注:「今日消费显示在」这一行)。
4099
+ // 「今日」二字**常显、不进动画**(2026-10-31:「今日二字不算在动画里」),
4100
+ // 与展开态同一口径:标签恒定不动,只有金额部位在等待/滚动。
4101
+ jsx.jsxs("span", {
4102
+ className: "balance-fab__today" + (todayCost === null ? " balance-fab__today--waiting" : ""),
4103
+ children: [
4104
+ jsx.jsx("span", { className: "balance-fab__label", children: "今日" }),
4105
+ todayCost === null
4106
+ // 等待态只占金额那一格:消耗行的 5 颗亮芯光球(深底上要的是亮的东西在流动,
4107
+ // 展开态那套「两层光团 + 毛玻璃」在纯深色卡上只会得到一块灰白补丁)。
4108
+ ? jsx.jsx("span", {
4109
+ className: "balance-fab__today-mist",
4110
+ children: jsx.jsx("span", {
4111
+ className: "balance-usage-mist",
4112
+ children: ORB_INDICES.map((i) => jsx.jsx("span", { key: "orb-" + i, className: "balance-usage-mist__orb" })),
4113
+ }),
4114
+ })
4115
+ : jsx.jsxs("span", {
4116
+ // 收起态也要「从左到右扫过」的擦出(2026-10-31:「浮窗的今日加载动画也要有
4117
+ // 从左到右显示出来」)。复用展开态那条链路:todayWipeSeq 当 key(换 key 才重挂、
4118
+ // 动画才从 0 起播),todayWipeDone 决定什么时候摘掉 --enter 类。
4119
+ // 两者都由同一个 effect 驱动,所以「等待态出口才擦、自动刷新只滚动」的
4120
+ // 语义在两种布局下完全一致,不需要为收起态再写一套判据。
4121
+ key: "fab-today-" + todayWipeSeq,
4122
+ className: "balance-fab__today-inner"
4123
+ + (todayWipeSeq > 0 && !todayWipeDone ? " balance-fab__today-inner--enter" : ""),
4124
+ children: [
4125
+ jsx.jsxs("span", {
4126
+ className: "balance-fab__today-body",
4127
+ children: [
4128
+ jsx.jsx("span", { className: "balance-fab__cur", children: "¥" }),
4129
+ jsx.jsx("span", {
4130
+ className: "balance-footer__today-num",
4131
+ children: renderRollCells(
4132
+ buildRollCells(todayRoll === null ? todayText : todayRoll.from, todayText),
4133
+ "t" + (todayRoll === null ? "" : todayRoll.at),
4134
+ todayRoll !== null && todayRoll.done === true,
4135
+ ),
4136
+ }),
4137
+ ],
4138
+ }),
4139
+ // 擦出期间盖在上层的加载雾(2026-10-31:「跟展开不一样啊 展开是没擦到
4140
+ // 的地方还是加载动效」)。直接复用展开态那个类:它带着 --today-hole 的
4141
+ // 遮罩挖洞动画,洞从 0% 长到 100%,和下面数字的 clip-path 同一条缓动、
4142
+ // 同一个 260ms —— 于是"数字被扫开、没扫到的地方还是雾"这件事自然成立。
4143
+ // 只在擦出窗口内渲染:平时它是 absolute + z-index:1 的整块遮罩,
4144
+ // 常驻会一直盖着数字。
4145
+ todayWipeSeq > 0 && !todayWipeDone
4146
+ ? jsx.jsx("span", {
4147
+ className: "balance-footer__today-reveal__mist balance-fab__today-wipe-mist",
4148
+ children: jsx.jsx("span", {
4149
+ className: "balance-usage-mist",
4150
+ children: ORB_INDICES.map((i) => jsx.jsx("span", { key: "orb-" + i, className: "balance-usage-mist__orb" })),
4151
+ }),
4152
+ })
4153
+ : null,
4154
+ ],
4155
+ }),
4156
+ ],
4157
+ }),
4158
+ // 时段词 + 剩余时长(2026-10-31:「多显示一点信息 比如时段剩余时间」)。
4159
+ // spanNow 在 compact 分支之前就算好了,这里直接用;remainText 那个变量
4160
+ // 定义在分支之后,拿不到,所以现场拼一份。放不下「剩 1d20h」时靠 CSS 收字。
4161
+ jsx.jsx("span", {
4162
+ className: "balance-fab__period balance-fab__period--" + periodNow,
4163
+ children: [
4164
+ jsx.jsx("span", { children: periodLabelNow }),
4165
+ spanNow === null
4166
+ ? null
4167
+ : jsx.jsx("span", {
4168
+ className: "balance-fab__remain",
4169
+ children: "剩 " + formatRemain(spanNow.remainMs),
4170
+ }),
4171
+ ],
4172
+ }),
4173
+ // 刻度条:直接复用展开态的 track + 色带 + 指针,指针自己按 timelineNow 走
4174
+ jsx.jsx("span", {
4175
+ className: "balance-fab__track",
4176
+ children: jsx.jsx("span", {
4177
+ className: "balance-footer__timeline-track balance-footer__timeline-track--" + periodNow,
4178
+ children: [
4179
+ jsx.jsx(TimelineBand, { gradient: buildTimeline(timelineNow).gradient }),
4180
+ jsx.jsx(TimelinePointer, {}),
4181
+ ],
4182
+ }),
4183
+ }),
4184
+ ],
4185
+ }), undefined, anchorStyle);
4186
+ return typeof document === "undefined" ? rectFab : createPortal(rectFab, document.body);
4187
+ }
4188
+ // 两种情况都经 withPanel:收起态的定位落在它渲染的 .balance-stats-anchor--compact
4189
+ // 上(那个 div 只做 Tooltip 挂载点、position:static,真正的 fixed 定位在 .balance-fab
4190
+ // 自己身上,bottom 由 fabBottom 内联注入)。
2440
4191
  const fab = depleted
2441
4192
  ? withPanel(jsx.jsx("a", {
2442
4193
  className: "balance-fab balance-fab-dark",
@@ -2451,7 +4202,7 @@ function TimelineBand(props) {
2451
4202
  ...interactions,
2452
4203
  children: balanceText,
2453
4204
  }), undefined, anchorStyle);
2454
- return typeof document === "undefined" ? fab : reactDom.createPortal(fab, document.body);
4205
+ return typeof document === "undefined" ? fab : createPortal(fab, document.body);
2455
4206
  }
2456
4207
 
2457
4208
  // 展开态余额行:设置里关掉就不渲染(收起态圆钮由上面的 showFab 单独控制)
@@ -2460,18 +4211,24 @@ function TimelineBand(props) {
2460
4211
  // 右键刷新的浪潮层:整块从左往右扫一道泛光。用户 2026-10-30 明确
2461
4212
  // 「别给我整个光条删了」——所以这层保留;金额胶囊内部另有 __reveal 的擦除揭示,
2462
4213
  // 两层叠加:泛光负责「你点了」,擦出负责「新数字出现」。
2463
- // key 绑定 waveSeq,右键自增 → 重挂 → 动画重播。
2464
- const waveNode = settings.refreshWave === false
4214
+ //
4215
+ // ⚠️ 白光只在**本次挂载真的收到过 emitWave** 时才渲染(`waveToken > 0`)。
4216
+ // 判据必须用组件内的 `waveToken`,不能用模块级的 `waveSeq`:那个变量重挂不归零,
4217
+ // 只要右键过一次或发生过一次消费刷新就永远 > 0,于是每次折叠/展开侧栏
4218
+ // (组件重挂、DOM 重建、CSS 动画从 0 起播)都会凭空扫一道白光 ——
4219
+ // 这正是「没有消费时没事,一旦产生消费,点收起/展开就还有这个 bug」
4220
+ // (2026-10-31 两轮,第二轮的截图正是在展开那一瞬间拍的)。
4221
+ const waveNode = settings.refreshWave === false || waveToken === 0
2465
4222
  ? null
2466
- : jsx.jsx("span", { className: "balance-footer__wave", key: "wave-" + waveSeq });
4223
+ : jsx.jsx("span", { className: "balance-footer__wave", key: "wave-" + waveToken });
2467
4224
 
2468
4225
  if (state.status === "error") {
2469
4226
  return withPanel(jsx.jsxs("div", {
2470
- className: "balance-footer",
4227
+ className: "balance-footer" + footerEnterClass, id: "balance-footer-root",
2471
4228
  ...interactions,
2472
4229
  children: [
2473
4230
  // 失败原因统一走主题化浮层,避免把长错误塞进 42px 的余额行
2474
- jsx.jsx(primitives.Tooltip, {
4231
+ jsx.jsx(Tooltip, {
2475
4232
  label: state.error || "余额查询失败",
2476
4233
  side: "top",
2477
4234
  delayMs: 200,
@@ -2481,12 +4238,25 @@ function TimelineBand(props) {
2481
4238
  }));
2482
4239
  }
2483
4240
  if (!state.balance) {
2484
- return withPanel(jsx.jsx("div", { className: "balance-footer", ...interactions, children: "余额加载中…" }));
4241
+ // ⚠️ **不占位**(2026-10-31:「把这个不要了 不用占位了,因为当前多了个今日消耗
4242
+ // 导致高度不一致」)。原来这里渲染「余额加载中…」一行文字,它和真正的余额胶囊
4243
+ // 高度不同,所以加载中 → 加载完会顶一下;再叠上今日消耗那一条,整块高度更不齐。
4244
+ // 现在加载态直接不渲染,侧栏底部高度由「有没有余额」决定 —— 值到了才出现。
4245
+ // (原先还有一条 clip-path 从下往上擦出的入场动画,因为挂在根节点上、
4246
+ // 重挂即重播,已整条删除,原因见 CSS 里那段注释。)
4247
+ return null;
2485
4248
  }
2486
4249
 
2487
4250
  const b = state.balance;
2488
4251
  const total = Number.parseFloat(b.total_balance ?? "0");
2489
- const depleted = b.is_available === false || !Number.isFinite(total) || total <= 0;
4252
+ // 「余额不足」必须听服务端的 is_available,**不能**用 total <= 0 推断
4253
+ // (2026-10-31 审查):`total_balance` 只是**充值余额**,赠送钱包单列在 granted_balance 里
4254
+ // (见 AGENTS「主数字只等于充值余额」)。所以「充值花光但赠送还有」时 total 是 0、
4255
+ // is_available 却是 true —— 旧写法会把这种账号判成没钱:余额行变成「去充值」、
4256
+ // 圆钮变成充值卡,而实际还能用。只有在服务端没给这个字段时才退回按金额推断。
4257
+ const depleted = typeof b.is_available === "boolean"
4258
+ ? b.is_available === false
4259
+ : (!Number.isFinite(total) || total <= 0);
2490
4260
  const currency = b.currency || "CNY";
2491
4261
  const totalText = b.total_balance + " " + currency;
2492
4262
  // 位数多就降字号:一旦整数位到 4 位,16px 的胶囊会宽过 121px 的固定宽度
@@ -2495,7 +4265,7 @@ function TimelineBand(props) {
2495
4265
  const valueDensity = amountDensity(b.total_balance);
2496
4266
  if (depleted) {
2497
4267
  return withPanel(jsx.jsxs("div", {
2498
- className: "balance-footer",
4268
+ className: "balance-footer" + footerEnterClass, id: "balance-footer-root",
2499
4269
  ...interactions,
2500
4270
  children: [
2501
4271
  jsx.jsx("span", { className: "balance-footer__spacer" }),
@@ -2515,34 +4285,173 @@ function TimelineBand(props) {
2515
4285
 
2516
4286
  // 与 periodSpan / buildTimeline 同源都用 timelineNow:三者必须指向同一时刻,
2517
4287
  // 否则边界那一分钟会出现“标签已是高峰、剩余时间还按空闲算”的自相矛盾。
2518
- const period = currentPeriod(timelineNow);
4288
+ // period / spanNow / remainText 已在上面随 timelineNow 一起算好(那里也在早退之前),
4289
+ // 这里直接复用,避免同一帧内算两遍、也保证动画用的值和显示的值是同一个。
4290
+ const period = periodNow;
2519
4291
  const low = total > 0 && total < lowThreshold;
2520
4292
  // 时段模块:上方一行 meta(今日消耗 · 时段 · 剩余时间)+ 下方扁刻度条。
2521
4293
  // 色带固定 = 当天完整时段,指针 = 此刻在当天的位置。
2522
- const span = periodSpan(timelineNow);
4294
+ const span = spanNow;
2523
4295
  // meta 分两段:①「今日 ¥x」②「时段词 + 剩余时间」。
2524
4296
  // 第二段整体不换行(包在 __group 里),所以窄侧栏折行时一定是
2525
4297
  // 「今日 ¥x」独占上面一行、「空闲 剩 3h57m」落到下面一行 —— 用户 2026-10-30 指定。
2526
4298
  const metaItem = (text, cls, key) =>
2527
4299
  jsx.jsx("span", { className: "balance-footer__timeline-meta-item" + (cls || ""), children: text }, key);
2528
- const tail = [
2529
- metaItem(period === "peak" ? "高峰" : "空闲", " balance-footer__timeline-meta-item--" + period, "period"),
2530
- ];
2531
- if (span !== null) tail.push(metaItem("剩 " + formatRemain(span.remainMs), "", "remain"));
4300
+ // 时段词挂 key="${periodTick}":空闲 ⇄ 高峰 切换时重挂一次,播 --period-swap 的淡入。
4301
+ // ⚠️ 启动首帧**不许播**:periodTick 首帧是 0,以此判断「还没切换过」,
4302
+ // 这时不挂动画类 —— 打开 DSH 时「空闲」就是静态出现的,不该有任何动效
4303
+ // (用户 2026-10-31 明确)。切换过一次之后 tick ≥ 1,类会一直挂着,
4304
+ // 但因为 key 只在换词时变,动画也只在换词那一帧重播。
4305
+ const periodNodeItem = jsx.jsx("span", {
4306
+ className: "balance-footer__timeline-meta-item balance-footer__timeline-meta-item--" + period
4307
+ + (periodTick > 0 ? " balance-footer__period-swap" : ""),
4308
+ children: periodLabelNow,
4309
+ }, "period-" + periodTick);
4310
+ const tail = [periodNodeItem];
4311
+ if (span !== null) {
4312
+ tail.push(jsx.jsx("span", {
4313
+ className: "balance-footer__timeline-meta-item",
4314
+ style: { fontVariantNumeric: "tabular-nums" },
4315
+ children: remainRoll === null
4316
+ ? remainText
4317
+ // 终点用当前文本而不是 remainRoll.to:滚动结束后状态不再清空,
4318
+ // 若期间又跳了一分钟但还没产生新 roll,用 .to 会停在上一轮的值。
4319
+ : renderRemainRoll(remainRoll.from, remainText, "r" + remainRoll.at, remainRoll.done === true),
4320
+ // key 跟着滚动回合走:每轮重挂,滚动态和静态之间切换不会被 React 复用成同一个元素,
4321
+ // 否则 dout/din 的动画不会重新起播。
4322
+ }, remainRoll === null ? "remain-" + remainText : "remain-roll-" + remainRoll.at));
4323
+ }
2532
4324
  // 「今日 ¥x」是 meta 里的第一项(flex-basis:100%,独占第一行)——
2533
- // 数据没到之前先摆一个不可见占位,让这一行的高度从一开始就是最终高度 ——
4325
+ // 数据没到之前先摆一个等待态,让这一行的高度从一开始就是最终高度 ——
2534
4326
  // 条件渲染会让整块从 1 行变 2 行,侧栏底部(含设置按钮)整体被顶下 14px,
2535
4327
  // 那一下重排就是「今日余额弹出来时卡住」。
2536
- // 占位只在「还在等」时出现;确认拿不到(tried 且为 null)就整项不渲染,不留空行。
2537
- const todayCost = today !== null && Number.isFinite(Number(today.cost)) ? Number(today.cost) : null;
2538
- const todayPending = todayCost === null && !todayCache.tried;
4328
+ // 等待态只在「还在等」时出现;确认拿不到(tried 且为 null)就整项不渲染,不留空行。
4329
+ // todayCost / todayText / 滚动 hook 都在函数最顶部(早退分支之前)算好了 ——
4330
+ // 这里是普通派生,不是 hook,放在哪都行。
4331
+ // 等待态覆盖两种情况:
4332
+ // 1) 还没拿到任何值(`tried === false`)—— 老语义
4333
+ // 2) 拿到了但**还没定型**(`settled === false`,即 `?today=1` 的 partial 补拉阶段)
4334
+ // 第 2 条是 2026-10-31 补的:冷启动首次返回的是落盘快照,3 秒后才补拉出精算值,
4335
+ // 而快照偏高 → 用户看到「¥17.29 刚显示完就往下变成 ¥16.x」。那不是新消耗,
4336
+ // 是同一次计算的两个阶段,所以**连揭幕都要等定型**,宁可多等 3 秒也不显示一个
4337
+ // 会当场缩水的数。定型后只揭幕一次,之后的值变化才走滚动。
4338
+ // 定型之前一律保持等待态(有值也先不揭幕);定型之后才按「有没有值」决定显示还是整项隐藏。
4339
+ // `fetchTodayOnce` 保证一定会定型(成功非 partial、重试用尽、或彻底失败都会置 true),
4340
+ // 所以这里不会永久挂住。
4341
+ const todayWaiting = !todayCache.settled || (todayCost === null && !todayCache.tried);
4342
+ // 等待时「今日」二字常显,只有金额部位在动(用户 2026-10-31:今日二字固定显示)。
4343
+ // 动效语言跟随设置里的「消耗行加载动效」,两处不混用。
4344
+ const todayLoadingIsText = settings.usageLoadingStyle === "text";
2539
4345
  const metaChildren = [];
2540
- if (todayCost !== null || todayPending) {
4346
+ if (todayCost !== null || todayWaiting) {
2541
4347
  metaChildren.push(jsx.jsx("span", {
2542
4348
  className:
2543
4349
  "balance-footer__timeline-meta-item balance-footer__timeline-meta-item--today" +
2544
- (todayCost === null ? " balance-footer__timeline-meta-item--pending" : " balance-footer__timeline-meta-item--in"),
2545
- children: todayCost === null ? "今日 ¥--" : "今日 ¥" + todayCost.toFixed(2),
4350
+ (todayCost === null ? " balance-footer__timeline-meta-item--today-waiting" : ""),
4351
+ children: [
4352
+ // 「今日」二字恒定不动:**不给它挂任何动画类**(用户 2026-10-31 反馈
4353
+ // 「刷新出来的时候今日二字也会闪一下」—— 根因就是以前整块挂了淡入动画)。
4354
+ // 唯一的动作是金额宽度变化时的真实滑动,由上面的 usePrePaintEffect
4355
+ // 直接写 style 驱动(不走类名,也不换 key,所以不会重挂)。
4356
+ jsx.jsx("span", { key: "label", className: "balance-footer__today-label", children: "今日" }),
4357
+ // ⚠️ 等待态和出数值态必须是**同一个节点树**(2026-10-31:「触发从左往右的擦除动画时
4358
+ // 右边的未被经过的位置变回了动画初始样子 看起来就是闪回去了 很割裂」)。
4359
+ // 原来分成 key:"wait" / key:"value" 两个分支,数据一到 React 就把整块卸载重挂,
4360
+ // mist 是**新插入的 DOM**,里面两层 radial 光团(::before / ::after)的 CSS 动画
4361
+ // 从 0 重新起播 —— 已经流动到某个相位的雾突然跳回初始形状,看起来就是"闪回去"。
4362
+ // 现在只有一个 .balance-footer__today-value 容器,mist 用恒定 key 常驻,
4363
+ // 等待态只是「没有 body」而已,节点不动、动画不断。
4364
+ jsx.jsx("span", {
4365
+ key: "value",
4366
+ className: "balance-footer__today-value"
4367
+ + (todayCost === null ? " balance-footer__today-value--waiting" : ""),
4368
+ children: jsx.jsx("span", {
4369
+ key: "reveal",
4370
+ className: "balance-footer__today-reveal",
4371
+ children: [
4372
+ // mist 的卸载条件:**有数值 且 这次擦出已经播完**。
4373
+ // 等待态(todayCost === null)永远保留 mist —— 它就是加载动画本体,
4374
+ // 同时保证「等待 ⇄ 有值」之间 mist 是同一个 DOM 节点、光团动画不重播
4375
+ // (2026-10-31:「触发从左往右的擦除动画时 右边的未被经过的位置变回了
4376
+ // 动画初始样子 看起来就是闪回去了」)。
4377
+ // 擦出期间(todayWipeDone === false)也必须保留:没扫到的右半边
4378
+ // 要露出原来的加载动画,而不是一块空白(用户 2026-10-31 定案)。
4379
+ todayCost !== null && todayWipeDone
4380
+ ? null
4381
+ : jsx.jsx("span", {
4382
+ key: "mist",
4383
+ // ⚠️ 必须同时挂 balance-footer__today-wait:
4384
+ // 今日那套"11px 胶囊 + 两层 radial 光团 + 毛玻璃"的样式全部写在
4385
+ // .balance-footer__today-wait .balance-usage-mist 选择器下面,
4386
+ // 漏了这个父类,里面的 .balance-usage-mist 就只剩消耗行那套默认样式,
4387
+ // 而今日的雾是靠 ::before/::after 两层光团实现的 —— 结果就是完全透明,
4388
+ // 看着像"mist 根本没渲染"(2026-10-31 实测踩过)。
4389
+ className: "balance-footer__today-wait"
4390
+ + (todayCost === null ? "" : " balance-footer__today-reveal__mist"),
4391
+ "aria-hidden": "true",
4392
+ children: todayLoadingIsText
4393
+ ? jsx.jsx("span", {
4394
+ className: "balance-usage-calc",
4395
+ children: [
4396
+ "计算中",
4397
+ jsx.jsx("span", {
4398
+ className: "balance-usage-calc__dots",
4399
+ "aria-hidden": "true",
4400
+ children: [0, 1, 2].map((i) => jsx.jsx("span", { className: "balance-usage-calc__dot", children: "." }, i)),
4401
+ }, "dots"),
4402
+ ],
4403
+ })
4404
+ : jsx.jsx("span", {
4405
+ className: "balance-usage-mist",
4406
+ children: [
4407
+ ...ORB_INDICES.map((i) => jsx.jsx("span", { key: "orb-" + i, className: "balance-usage-mist__orb" })),
4408
+ jsx.jsx("span", { className: "balance-footer__today-fog" }, "fog"),
4409
+ ],
4410
+ }),
4411
+ }),
4412
+ todayCost === null
4413
+ ? null
4414
+ : jsx.jsx("span", {
4415
+ // key 绑序号:值每变一次就重挂一次,动画随之从 0 播;
4416
+ // 值没变(折叠/展开侧栏)序号不动,节点不重挂,不重播。
4417
+ key: "body-" + todayWipeSeq,
4418
+ className: "balance-footer__today-reveal__body"
4419
+ + (todayWipeSeq > 0 && !todayWipeDone ? " balance-footer__today-reveal__body--enter" : ""),
4420
+ children: [
4421
+ // ¥ 仍然在滚动区外层:buildRollCells 按「数字开头」拆分,
4422
+ // 把它包进去会被当成整串 tail,变成一格整体平移。
4423
+ // 单独给个类做右间距:¥ 是宽字符,直接挨着数字会显得贴在一起。
4424
+ jsx.jsx("span", { key: "cur", className: "balance-footer__today-cur", children: "¥" }),
4425
+ // ⚠️ 外层 key 必须恒定("num"),只有内层变化的格子才重挂。
4426
+ // 之前是 todayRoll === null ? key:"text" : key:"roll-"+at —— 滚动结束
4427
+ // todayRoll 清空时 key 变了,React 把整串卸载重挂,那一帧里被滚动的格子
4428
+ // 从动画终态重新起算,看着就是「位置异常一下再回正」
4429
+ // (2026-10-31 用户反馈)。现在外层永不重挂,只看内层的 changed 标志。
4430
+ jsx.jsx("span", {
4431
+ key: "num",
4432
+ // 首次出场(等待态结束、冷启动拿到金额)走独立入场动画:
4433
+ // 不复用 dout/din 那套滚格子 —— balance-roll-in 前 40% 是 opacity:0
4434
+ // (给「旧值滚出、新值滚入」留的重叠时间),从空白升起时那段就是纯空白,
4435
+ // 看起来跟直接蹦出来没区别(逐帧截图确认 0/70/140ms 三帧全空)。
4436
+ className: "balance-footer__today-num",
4437
+ children: renderRollCells(
4438
+ // from 取滚动起点;静态时 from === to,所有格 changed=false,等价纯静态。
4439
+ // 静态也走逐字格,是为了让宽度和滚动态完全相等(裸文本没有逐字 inline-block
4440
+ // 的字距补偿,来回切会让整串左右挪)。
4441
+ buildRollCells(todayRoll === null ? todayText : todayRoll.from, todayText),
4442
+ // prefix 里带 at:每次滚动只让「变化的那一格」换 key 重挂,动画才重新起播;
4443
+ // 没变的格 key 保持稳定,不会被牵连重挂。
4444
+ "t" + (todayRoll === null ? "" : todayRoll.at),
4445
+ // 播完就定格:摘掉 animation 声明,展开侧栏不再重播
4446
+ todayRoll !== null && todayRoll.done === true,
4447
+ ),
4448
+ }),
4449
+ ],
4450
+ }),
4451
+ ],
4452
+ }),
4453
+ }, "value"),
4454
+ ],
2546
4455
  }, "today"));
2547
4456
  }
2548
4457
  metaChildren.push(jsx.jsx("span", { className: "balance-footer__timeline-meta-group", children: tail }, "tail"));
@@ -2590,7 +4499,7 @@ function TimelineBand(props) {
2590
4499
  children: period === "peak" ? "高峰" : "空闲",
2591
4500
  });
2592
4501
  return withPanel(jsx.jsxs("div", {
2593
- className: "balance-footer balance-footer--classic",
4502
+ className: "balance-footer balance-footer--classic" + footerEnterClass, id: "balance-footer-root",
2594
4503
  children: [
2595
4504
  jsx.jsx("span", { className: "balance-footer__icon", children: jsx.jsx(WalletIcon, {}) }),
2596
4505
  jsx.jsx("span", { className: "balance-footer__label", children: "余额" }),
@@ -2608,7 +4517,7 @@ function TimelineBand(props) {
2608
4517
  }), undefined, undefined, rowInteractions);
2609
4518
  }
2610
4519
  return withPanel(jsx.jsxs("div", {
2611
- className: "balance-footer balance-footer--stacked",
4520
+ className: "balance-footer balance-footer--stacked" + footerEnterClass, id: "balance-footer-root",
2612
4521
  children: [
2613
4522
  // 抹除层覆盖整块显示区(today 行 + meta 行 + 时段条):白条经过哪里,哪里才显影
2614
4523
  waveNode,
@@ -2616,13 +4525,20 @@ function TimelineBand(props) {
2616
4525
  ? jsx.jsx("span", { className: "balance-footer__spacer" })
2617
4526
  : periodNode,
2618
4527
  jsx.jsx("span", {
2619
- className: "balance-footer__value" + (low ? " balance-footer__value--low" : " balance-footer__value--healthy") + (state.refreshing ? " balance-footer__value--stale" : "") + valueDensity,
4528
+ className: "balance-footer__value" + (low ? " balance-footer__value--low" : " balance-footer__value--healthy") + valueDensity,
2620
4529
  // key 绑定 waveSeq:只有右键手动刷新才 +1 → 重挂 → 白光把金额重新擦一遍。
2621
4530
  // 自动刷新(60s 轮询 / 对话完成触发的 fetch)不动 waveSeq,所以不会擦,
2622
4531
  // 数字继续走下面那套逐位滚动 —— 两套动效完全分开(2026-10-30 用户要求)。
2623
4532
  children: jsx.jsx("span", {
2624
- className: "balance-footer__reveal" + (settings.refreshWave === false ? " balance-footer__reveal--off" : ""),
2625
- key: "reveal-" + waveSeq,
4533
+ className: "balance-footer__reveal"
4534
+ + (settings.refreshWave === false ? " balance-footer__reveal--off" : "")
4535
+ // 擦除动画的闸门:只有 revealSeq 真的 +1 过(手动刷新 + 金额确实变了)
4536
+ // 才挂,重挂时 revealSeq 归 0 → 不挂 → 不会凭空重播一次「扣费动画」。
4537
+ + (revealSeq > 0 ? " balance-footer__reveal--animate" : ""),
4538
+ // key 用 `revealSeq`:**只有「手动刷新 + 金额真的变了」才 +1**,值没变就绝不重挂、
4539
+ // 绝不擦(用户 2026-10-31:「手动刷新如果余额变了就变啊,没变就不要硬变」)。
4540
+ // 白光(.balance-footer__wave)仍然是每次右键必扫,它才是「你点了」的固定反馈。
4541
+ key: "reveal-" + revealSeq,
2626
4542
  children: [
2627
4543
  // 底层(常显):光条没扫到的区域就是它,所以内容不会先消失再长出来。
2628
4544
  jsx.jsx("span", {
@@ -2632,10 +4548,18 @@ function TimelineBand(props) {
2632
4548
  // 上层:被 clip-path 从左边擦开的那一层,光条走到哪,哪才盖住底层。
2633
4549
  jsx.jsx("span", {
2634
4550
  className: "balance-footer__reveal-sweep",
2635
- // 滚动格与「本会话消耗」共用 renderRollCells —— 两处必须逐格一致
2636
- children: rollCells === null
2637
- ? totalText
2638
- : jsx.jsx("span", { key: "roll-" + roll.at, children: renderRollCells(rollCells, "f") }),
4551
+ // 滚动格与「本会话消耗」共用 renderRollCells —— 两处必须逐格一致。
4552
+ // 静态时就地渲染同一套逐字格(from === to),宽度与滚动态完全相等;
4553
+ // 外层 key 恒定,只有变化的格子换 key 重挂,滚动结束不会整串重挂闪一下(2026-10-31)。
4554
+ children: jsx.jsx("span", {
4555
+ key: "f",
4556
+ children: renderRollCells(
4557
+ rollCells === null ? buildRollCells(totalText, totalText) : rollCells,
4558
+ "f" + (rollCells === null ? "" : roll.at),
4559
+ // 播完就定格:摘掉 animation 声明,展开侧栏不再重播
4560
+ roll !== null && roll.done === true,
4561
+ ),
4562
+ }),
2639
4563
  }, "sweep"),
2640
4564
  ],
2641
4565
  }),
@@ -2644,6 +4568,7 @@ function TimelineBand(props) {
2644
4568
  }), undefined, undefined, rowInteractions);
2645
4569
  }
2646
4570
 
4571
+
2647
4572
  // ─── 当前会话消耗:输入区 dock ──────────────────────────────────────────
2648
4573
  function UsageCostLine({ useProjection, sessionId }) {
2649
4574
  const settings = useSettings();
@@ -2654,34 +4579,40 @@ function UsageCostLine({ useProjection, sessionId }) {
2654
4579
  return Number.isFinite(n) && n > 0 ? n : 2;
2655
4580
  })();
2656
4581
  const model = useCurrentModel();
2657
- const usage = typeof useProjection === "function" ? useProjection("tokenUsage") : undefined;
4582
+ // `useProjection` 是 DSH 通过 slot props 传进来的**可选** hook。
4583
+ // 原来写成 `typeof useProjection === "function" ? useProjection("tokenUsage") : undefined`
4584
+ // —— 条件调用 hook,同一个合规问题(判定恒定,故运行期没错,但规则上一旦变就崩)。
4585
+ // 改成先落到恒定函数再无条件调用:缺失时 useProjectionFallback 返回 undefined,
4586
+ // 和原来的分支返回值一模一样,行为零变化。
4587
+ const projectionHook = typeof useProjection === "function" ? useProjection : useProjectionFallback;
4588
+ const usage = projectionHook("tokenUsage");
2658
4589
  // 分时段分桶按「会话 → 分桶」存档。切换会话时 React 不会重建组件,旧会话的分桶
2659
4590
  // 会留在 state 里;不带会话归属就会拿上一个会话的金额当本会话显示
2660
4591
  // (切对话瞬间金额先跳到别的数字、再跳回正确值——2026-10-30 反馈)。
2661
4592
  // 存成表而不是单值:来回切会话时命中缓存,立刻就是正确金额,不闪。
2662
- const SPLIT_CACHE_MAX = 6;
4593
+ const SPLIT_CACHE_MAX = 32;
2663
4594
  const [splitMap, setSplitMap] = react.useState(() => ({}));
4595
+ // splitMap 的镜像:下面那个请求 effect 需要知道「这个会话问过没有」来决定用
4596
+ // 首挂载延迟还是 debounce 延迟,但直接把 splitMap 放进依赖会让每次响应都重跑 effect
4597
+ // (响应 → setSplitMap → effect 重跑 → 再请求),死循环。用 ref 读当前值。
4598
+ const splitMapRef = react.useRef(splitMap);
4599
+ splitMapRef.current = splitMap;
2664
4600
  // 消耗明细面板:**点击展开**(对齐 DSH 原生「会话统计」的交互——点开切换 + 选中态 + portal 浮层)
2665
4601
  const [detailsOpen, setDetailsOpen] = react.useState(false);
2666
4602
  const detailsAnchorRef = react.useRef(null);
2667
4603
  const detailsPanelRef = react.useRef(null);
2668
4604
  // 定位与"点外部关闭"复用 DSH primitives 的 hook,行为/视觉都与原生浮层一致。
2669
- // 运行时探测(DSH 内部 API 无兼容承诺):hook 不存在时退化为无定位(面板仍渲染),不拖垮消耗行。
2670
- // 注意这里的条件调用是安全的——primitives 是模块级常量,同一组件生命周期内判定结果恒定。
2671
- const anchoredPosition =
2672
- typeof primitives.useAnchoredPosition === "function"
2673
- ? primitives.useAnchoredPosition({
2674
- open: detailsOpen,
2675
- anchorRef: detailsAnchorRef,
2676
- panelRef: detailsPanelRef,
2677
- side: "top",
2678
- gap: 6,
2679
- margin: 8,
2680
- })
2681
- : null;
2682
- if (typeof primitives.useDismissOnOutsidePointer === "function") {
2683
- primitives.useDismissOnOutsidePointer(detailsAnchorRef, detailsOpen, setDetailsOpen, detailsPanelRef);
2684
- }
4605
+ // hook 本身在模块级已规范化成「恒定可调用」(缺失时是 no-op,返回 null),
4606
+ // 所以这里**无条件调用**,不再写在条件分支里 —— 见模块顶部「可选 hook 的恒定点」。
4607
+ const anchoredPosition = useAnchoredPositionHook({
4608
+ open: detailsOpen,
4609
+ anchorRef: detailsAnchorRef,
4610
+ panelRef: detailsPanelRef,
4611
+ side: "top",
4612
+ gap: 6,
4613
+ margin: 8,
4614
+ });
4615
+ useDismissOnOutsidePointerHook(detailsAnchorRef, detailsOpen, setDetailsOpen, detailsPanelRef);
2685
4616
  react.useEffect(() => {
2686
4617
  if (!detailsOpen) return;
2687
4618
  const onKeyDown = (event) => {
@@ -2708,11 +4639,23 @@ function UsageCostLine({ useProjection, sessionId }) {
2708
4639
  });
2709
4640
 
2710
4641
  // 解析本会话使用的模型,并按模型刷新定价(/balance-api?model=...)
2711
- // 依赖 [sessionId, usageKey]:切对话或会话内容推进(新消息)都会触发;若还没解析到模型则总是重试。
4642
+ //
4643
+ // ⚠️ 2026-10-31 性能修复:原来这里只要 `resolveSessionModel` 返回非空就
4644
+ // `fetchBalanceData()`,而依赖是 [sessionId, usageKey] —— `usageKey` 是 token 总数,
4645
+ // **流式输出时每来一个 chunk 就变一次**。`resolveSessionModel` 命中缓存时依旧返回
4646
+ // 模型名(不是 ""),于是早退条件永远不成立 → **每个 token 打一次 /balance-api**,
4647
+ // 而那条接口每次都要真去查官方余额(实测 419ms)。一个十秒回复能把官方接口打几十次,
4648
+ // 服务端排队后整体变慢 —— 用户 2026-10-31 就是问这个:「以前没这么慢的啊」。
4649
+ // 现在用 ref 记住"已经为哪个会话的哪个模型刷过定价",只有模型真的换了才重发。
4650
+ const pricingModelRef = react.useRef("");
2712
4651
  react.useEffect(() => {
2713
4652
  let disposed = false;
2714
4653
  resolveSessionModel(sessionId).then((resolved) => {
2715
4654
  if (disposed || resolved === "") return;
4655
+ // key 里带 sessionId:切会话时即使模型名相同也要重新取一次定价
4656
+ const stamp = String(sessionId) + "\u0000" + resolved;
4657
+ if (pricingModelRef.current === stamp) return;
4658
+ pricingModelRef.current = stamp;
2716
4659
  fetchBalanceData().catch(() => {});
2717
4660
  });
2718
4661
  return () => {
@@ -2722,23 +4665,152 @@ function UsageCostLine({ useProjection, sessionId }) {
2722
4665
 
2723
4666
  // 会话 token 总量变化(新消息/新消耗)时,拉取服务端分时段 usage,跨时段精确计费
2724
4667
  // 同时"对话完成"即刷新一次余额(token 变化 = 一条新回复落库)
4668
+ //
4669
+ // ⚠️ 这条请求服务端要**解压会话持久化文件**(本机实测:35 MB 会话 = 19331 帧、
4670
+ // 解压 134 MB、单次 723 ms,同步 CPU)。和余额请求同时发出去时两者互相拖,
4671
+ // 首屏就要等它 —— 这就是「插件加载慢 + 掉帧」的直接来源。
4672
+ //
4673
+ // 原来这里等 600ms 才发,理由是「错开首屏那一下」。但那 600ms 是**纯客户端空等**:
4674
+ // 发一条 fetch 本身不耗 CPU,真正贵的是服务端解压,而那是服务端自己的线程、
4675
+ // 迟早要做。等 600ms 只是把同一件事推后,用户却实打实多等半秒。
4676
+ //
4677
+ // ⚠️ 2026-10-31 性能修复(第二处):这个 effect 的依赖同样含 `usageKey`,
4678
+ // 而 `usageKey` 在流式输出时每来一个 chunk 就变一次。原来固定 80ms 延迟 →
4679
+ // chunk 间隔一旦超过 80ms 就会连续发 `?session=`,而这条请求会让服务端
4680
+ // **整份解压会话 zstd(42 MB 实测 ~900ms 同步 CPU)**,几个窗口叠起来足以把界面拖死。
4681
+ // 现在按「问过没有」分流:
4682
+ // · 首挂载(该会话从没问过)→ 80ms 立刻发,用户第一次看这个会话要快;
4683
+ // · 已问过(流式进行中的反复变化)→ 600ms **debounce**:每次变化都 clearTimeout
4684
+ // 重排,流式期间一个包都不发,等输出停下来才问一次。
4685
+ // 不能简单把延迟调到 600ms —— 那会把首屏也一起推迟半秒(正是上一轮犯过的错)。
2725
4686
  react.useEffect(() => {
2726
4687
  if (usageKey === 0) return;
4688
+ // ⚠️ 「这个会话的用量在第几版 token 数上问过」——记在**模块级**,不随组件卸载丢失。
4689
+ // 折叠/展开侧栏会让 BalanceFooter 卸载重挂 → 组件内 ref/state 全部归零 →
4690
+ // 这个 effect 会重新跑一遍并立刻发 `?session=`,而那条请求会顺带刷新今日缓存;
4691
+ // 值一变,今日和余额的数字就当场滚一次 —— 用户看到的就是
4692
+ // 「点击侧边栏按钮导致刷新异常,今日消耗和余额数字滚动」(2026-10-31 反馈)。
4693
+ // 现在按 `usageKey` 去重:token 数没变就绝不再问,重挂也不问。
4694
+ //
4695
+ // ⚠️ **只允许在请求真正发出之后才记录**(2026-10-31:「会话消耗一直在加载」)。
4696
+ // 曾经在 effect 顶部就 `set`,而下面 `fire` 里的 timer 在 `disposed` 时会直接 return——
4697
+ // 记录写了、请求没发,此后同一 usageKey 被永久跳过,本会话消耗永远拿不到数据、
4698
+ // 一直停在加载态。所以把 set 挪进 timer 内部、紧随 `disposed` 判断之后。
4699
+ const usageAskedMap = globalThis.__balanceUsageAsked instanceof Map
4700
+ ? globalThis.__balanceUsageAsked
4701
+ : (globalThis.__balanceUsageAsked = new Map());
4702
+ // ⚠️ **去重判据必须同时确认「结果真的还在」**(2026-10-31:「还是会一直处在加载动画
4703
+ // 不知道怎么复现的」)。
4704
+ // 原来只判 `usageAskedMap.get(sessionId) === usageKey` 就 return,而两者生命周期不同:
4705
+ // · usageAskedMap → globalThis,跨挂载存活
4706
+ // · splitMap → useState,只在当前挂载内有效
4707
+ // 折叠/展开侧栏、切布局、改设置都会让 BalanceFooter 卸载重挂 → splitMap 清空、
4708
+ // usageAskedMap 还留着记录 → 这里直接 return,请求永远不发 → splitAsked 恒为 false
4709
+ // → costPending 恒为 true → 本会话消耗永久停在加载动画。
4710
+ // 重挂本身是常态,所以这个 bug 看起来「不知道怎么复现」,其实只要重挂就中。
4711
+ // 补上 haveResult 之后:结果丢了就重问一次,代价只是那一次解压。
4712
+ const haveSplitResult = Object.prototype.hasOwnProperty.call(splitMapRef.current, sessionId);
4713
+ if (usageAskedMap.get(sessionId) === usageKey && haveSplitResult) return;
2727
4714
  let disposed = false;
2728
- fetchSessionUsage(sessionId).then((u) => {
4715
+ // `asked` 也走模块级:重挂后仍记得「问过」,走 600ms debounce 而不是首挂载快路径。
4716
+ const askedSessions = globalThis.__balanceAsked instanceof Set
4717
+ ? globalThis.__balanceAsked
4718
+ : (globalThis.__balanceAsked = new Set());
4719
+ const asked = askedSessions.has(sessionId) || Object.prototype.hasOwnProperty.call(splitMapRef.current, sessionId);
4720
+ askedSessions.add(sessionId);
4721
+ let timer = null;
4722
+ let poll = null;
4723
+ // ⚠️ **不要 abort**(2026-10-31 第十一轮:「我快速点了几个对话就这样了,
4724
+ // 只有在对话消耗刷新的时候才会在刷新过了的对话正常显示」)。
4725
+ // 这个 effect 依赖 [sessionId, usageKey],快速切会话时它会反复重跑,
4726
+ // 每次 cleanup 都把正在飞的 `?session=` 中断掉 —— 结果永远拿不到,
4727
+ // splitMap 里永远没有该会话的数据,于是「快速点几个对话就一直加载」。
4728
+ // 正确的做法是让请求自然跑完:结果按 sessionId 落表(下面的 setSplitMap
4729
+ // 不判断 disposed),切回来时立刻就有数。代价只是服务端那一次解压不会白省,
4730
+ // 但那本来就是它该做的工作,比"永远加载不出来"好得多。
4731
+ const fire = () => {
2729
4732
  if (disposed) return;
2730
- // 拿不到也记一条(usage:null),表示"这个会话已经问过了",按本地估算走
2731
- setSplitMap((prev) => {
2732
- const next = { ...prev, [sessionId]: { usage: u ? u.usage : null, subagentUsage: u ? u.subagentUsage : null } };
2733
- // 对象保持插入顺序,最旧的在最前;超出上限就淘汰它(避免长会话列表无限增长)
2734
- const keys = Object.keys(next);
2735
- for (let i = 0; i < keys.length - SPLIT_CACHE_MAX; i += 1) delete next[keys[i]];
2736
- return next;
2737
- });
2738
- });
2739
- fetchBalanceData().catch(() => {});
4733
+ // 已问过 → 600ms debounce(流式期间每次 usageKey 变化都会重跑本 effect 并清掉这个
4734
+ // timer,所以一个包都不会发,等输出停下来才问一次)。
4735
+ // 首挂载 → SESSION_USAGE_DELAY_MS,只留够画首帧。
4736
+ timer = setTimeout(() => {
4737
+ if (disposed) return;
4738
+ // 到这里才记录:请求确实要发了。写在 effect 顶部会让「被取消的那次」也留下
4739
+ // 记录,于是同一 usageKey 永远不再重试 —— 会话消耗卡在加载态。
4740
+ usageAskedMap.set(sessionId, usageKey);
4741
+ fetchSessionUsage(sessionId).then((u) => {
4742
+ // ⚠️ `disposed` 只表示「这一轮 effect 失效」(切会话 / 依赖变化),
4743
+ // **不表示组件卸载,更不表示这个结果没用**。
4744
+ // 2026-10-31:「点击 A 对话还在加载,我这时候立即点击 B 对话 就能卡出这个 bug」——
4745
+ // A 的请求已发出,切到 B 后 A 的 effect 被清理,响应回来若直接 return,
4746
+ // splitMap 里就没有 A 的数据;而 A 的去重记录还在,再点回 A 会命中去重、
4747
+ // 永不重发 → 一直加载。
4748
+ // 所以:结果照写进 splitMap(它按 sessionId 索引,对全部会话有效),
4749
+ // 只跳过「扣费/今日刷新」这类只该由当前会话触发的事。
4750
+ // ⚠️ **必须等今日值定型(settled)之后才允许由 ?session= 触发刷新**
4751
+ // (2026-10-31:「打开 dsh 之后没有运行的对话 但是它自己跳动了 就很奇怪」)。
4752
+ // 原因:`?session=` 的响应里带着服务端用 updateTodayCostWithSession 增量算出的
4753
+ // today,而 `?today=1` 那边还有一条「快照 → 精算」的补拉。两路同时写同一个数,
4754
+ // 用户什么都没做,数字就自己变了两次。
4755
+ // 定型前只让 `?today=1` 这一条路径有机会改值 —— 它自己是串行的(partial → 补拉)。
4756
+ if (!disposed && todayCache.settled && u && u.today && typeof u.today.cost === "number") {
4757
+ void fetchTodayOnce(false);
4758
+ }
4759
+ // 拿不到也记一条(usage:null),表示"这个会话已经问过了",按本地估算走。
4760
+ // ⚠️ 这里**不判断 disposed**:`splitMap` 按 sessionId 索引、对全部会话有效,
4761
+ // 切走后收到的结果照样要落表,否则点回去还得再等一轮解压。
4762
+ setSplitMap((prev) => {
4763
+ const next = { ...prev, [sessionId]: { usage: u ? u.usage : null, subagentUsage: u ? u.subagentUsage : null } };
4764
+ // 对象保持插入顺序,最旧的在最前;超出上限就淘汰它(避免长会话列表无限增长)
4765
+ const keys = Object.keys(next);
4766
+ for (let i = 0; i < keys.length - SPLIT_CACHE_MAX; i += 1) {
4767
+ // ⚠️ **必须同步删掉去重记录**(2026-10-31:「点了几个会话之后就开始一直加载了,
4768
+ // 别的对话也是的」)。原来只删 splitMap 条目、usageAskedMap 还留着,
4769
+ // 那个会话再进来时命中去重 → 请求被跳过 → splitMap 里又没有它的数据 →
4770
+ // costPending 永远为 true,本会话消耗一直转圈。
4771
+ // 上限当时是 6,所以「点几个会话」就必然触发。
4772
+ delete next[keys[i]];
4773
+ if (typeof usageAskedMap?.delete === "function") usageAskedMap.delete(keys[i]);
4774
+ }
4775
+ return next;
4776
+ });
4777
+ }).catch(() => {
4778
+ // 请求本身失败(网络 / 后端异常)时把去重记录撤掉,否则同一 usageKey
4779
+ // 再也不会重试,本会话消耗会一直停在加载态。
4780
+ usageAskedMap.delete(sessionId);
4781
+ // ⚠️ **还要给 splitMap 写一条占位结果**(2026-10-31)。
4782
+ // 只删去重记录不够:对话已经结束、`usageKey` 不再变化时这个 effect 不会重跑,
4783
+ // 于是没有任何人再去发请求 → `splitAsked` 恒 false → 永久加载。
4784
+ // 写 `{usage:null}` 让 `splitAsked` 立刻成立,UI 退出加载态、改走本地估算显示
4785
+ // 一个数字;下次 usageKey 变化时去重记录已删,会正常重试拿到真实值。
4786
+ setSplitMap((prev) => (Object.prototype.hasOwnProperty.call(prev, sessionId)
4787
+ ? prev
4788
+ : { ...prev, [sessionId]: { usage: null, subagentUsage: null } }));
4789
+ });
4790
+ fetchBalanceData().catch(() => {});
4791
+ }, asked ? 600 : SESSION_USAGE_DELAY_MS);
4792
+ };
4793
+ if (asked) {
4794
+ fire();
4795
+ } else {
4796
+ // 首挂载:**等余额先回来再发**。
4797
+ // 余额是纯网络(实测 419ms);`?session=` 可能要服务端整份解压会话 zstd
4798
+ // (42 MB 实测 ~900ms 同步 CPU)。两者同时出发会互相拖,首屏反而更慢 ——
4799
+ // 曾经用固定 600ms 硬等是为了这个,但那 600ms 不管余额快慢都照等。
4800
+ // 现在跟着余额的真实就绪时刻走:余额一到就发,和今日那套同一个判据。
4801
+ const startedAt = Date.now();
4802
+ const waitBalance = () => {
4803
+ if (disposed) return;
4804
+ const ready = balanceCache.status !== "loading" || balanceCache.balance !== null;
4805
+ if (ready || Date.now() - startedAt >= 1500) { fire(); return; }
4806
+ poll = setTimeout(waitBalance, 100);
4807
+ };
4808
+ waitBalance();
4809
+ }
2740
4810
  return () => {
2741
4811
  disposed = true;
4812
+ if (timer !== null) clearTimeout(timer);
4813
+ if (poll !== null) clearTimeout(poll);
2742
4814
  };
2743
4815
  }, [sessionId, usageKey]);
2744
4816
 
@@ -2760,9 +4832,45 @@ function UsageCostLine({ useProjection, sessionId }) {
2760
4832
  const splitAsked =
2761
4833
  sessionId != null && sessionId !== "" && Object.prototype.hasOwnProperty.call(splitMap, sessionId);
2762
4834
  const costPending = !splitAsked && usageKey > 0;
4835
+
4836
+ // 「这个会话已经出过数字了」—— 出过就再也不显示加载动效。
4837
+ // 用户 2026-10-31:「如果对话在工作时,实时的对话消耗会导致会话加载动效重新播放,这样看起来很割裂」。
4838
+ // 每轮对话结束 usageKey 都会变、就会重新请求,任何一个让 splitAsked 短暂为 false 的帧
4839
+ // (响应被丢弃、请求重排、缓存未就绪)都会让迷雾态重新插入 DOM,CSS 动画从 0 秒重播一遍。
4840
+ // 加载动效的语义是「还没算出来」,不是「正在刷新」,所以按会话记住是否已经出过数:
4841
+ // 同一会话内此后只走数字/滚动,绝不再闪迷雾;换会话仍然是新会话、照常首次加载。
4842
+ const readySessionsRef = react.useRef(null);
4843
+ if (readySessionsRef.current === null) readySessionsRef.current = new Set();
4844
+ const readySessions = readySessionsRef.current;
4845
+ const sessionReady = sessionId != null && sessionId !== "" && readySessions.has(sessionId);
4846
+ const shouldMarkReady = !costPending && splitAsked && usageKey > 0 && sessionId != null && sessionId !== "";
4847
+ // ⚠️ 「这个会话已经出过数」的记忆**不许在渲染期写**(2026-10-31 全量审查)。
4848
+ // 渲染期改 ref 在 StrictMode 双调用 / 并发渲染下会写进一次被丢弃的渲染里,
4849
+ // 于是这个会话被记成「已就绪」但界面从没出过数 → 加载动效永不再播,一直是空白。
4850
+ // 放到 layout effect(paint 之前同步)里写,时序与原意一致:
4851
+ // 数据到手那一帧 showCostLoading 已经是 false(costPending/staleUsage 都为假),
4852
+ // 所以「什么时候开始记忆」没有任何可见差异,只是挪出了渲染期。
4853
+ usePrePaintEffect(() => {
4854
+ if (!shouldMarkReady) return;
4855
+ readySessions.add(sessionId);
4856
+ }, [shouldMarkReady, sessionId]);
4857
+ // 只有「这个会话从来没出过数」才播加载动效;已出过数的会话,刷新期间保持旧数字不动。
4858
+ const showCostLoading = (staleUsage || costPending) && !sessionReady;
4859
+ // 子代理消耗(2026-10-31:「把字智能体的消费也算上 并且显示在这里」)。
4860
+ // 主会话和子代理分开算(各自有 byModel / peak / offpeak 桶),但**合计进同一个数字**:
4861
+ // 消耗行金额、明细浮层的「Token 用量」和「本会话金额」都是「含子代理」的口径,
4862
+ // 浮层里再用独立几行把子代理自己那一份拆出来,数字可对账。
4863
+ // ⚠️ 服务端曾经永远返回 null(枚举路径在 DSH 上必抛),所以这几行的历史行为是
4864
+ // 「从来没出现过」—— 如果它们又不出现,先查 lib/index.js 的 sumSubagentUsage。
4865
+ const hasSubagent = subagentUsage != null && (subagentUsage.totalTokens ?? 0) > 0;
4866
+ const subagentCost = hasSubagent ? computeSplitCost(subagentUsage, p, peakMultiplier) : 0;
4867
+ const subagentNewTokens = hasSubagent
4868
+ ? (subagentUsage.inputTokens ?? 0) + (subagentUsage.outputTokens ?? 0)
4869
+ : 0;
4870
+ const subagentCount = hasSubagent ? (subagentUsage.sessions ?? 0) : 0;
2763
4871
  const cost = splitUsable
2764
- ? computeSplitCost(split, p, peakMultiplier)
2765
- : computeCost(usage ?? {}, periodAdjustedPricing(p, peakMultiplier));
4872
+ ? computeSplitCost(split, p, peakMultiplier) + subagentCost
4873
+ : computeCost(usage ?? {}, periodAdjustedPricing(p, peakMultiplier)) + subagentCost;
2766
4874
  const costText = cost.toFixed(4);
2767
4875
 
2768
4876
  // 逐位滚动:数值一变就翻(和余额胶囊自动刷新共用 buildRollCells/renderRollCells)。
@@ -2771,7 +4879,7 @@ function UsageCostLine({ useProjection, sessionId }) {
2771
4879
  // skipCostRoll:切会话/待算/无 usage 时把基准清空,这样下次出现是「首次显示」而不是「从某个值滚过来」。
2772
4880
  // 加载动效风格:决定等待时显示什么、数据到手怎么进场(两种语言不混用)
2773
4881
  const loadingIsText = settings.usageLoadingStyle === "text";
2774
- const skipCostRoll = staleUsage || costPending || !usage;
4882
+ const skipCostRoll = staleUsage || costPending || !usage || showCostLoading;
2775
4883
  const [costRoll, setCostRoll] = react.useState(null);
2776
4884
  const lastCostTextRef = react.useRef(null);
2777
4885
  usePrePaintEffect(() => {
@@ -2780,11 +4888,14 @@ function UsageCostLine({ useProjection, sessionId }) {
2780
4888
  if (skipCostRoll || prev === null || prev === costText) return;
2781
4889
  setCostRoll({ from: prev, to: costText, at: Date.now() });
2782
4890
  }, [costText, skipCostRoll]);
2783
- usePrePaintEffect(() => {
2784
- if (costRoll === null) return;
2785
- const id = setTimeout(() => setCostRoll(null), 480);
2786
- return () => clearTimeout(id);
2787
- }, [costRoll]);
4891
+ // 同上:滚完保持滚动态(forward 停在终点),不要切回静态结构。
4892
+ // 只在「整段失效」时清空 —— 切会话 / 待算 / 无 usage 时必须丢弃旧会话的滚动基准,
4893
+ // 否则会拿上一个会话的数字当起点。
4894
+ react.useEffect(() => {
4895
+ if (skipCostRoll) setCostRoll(null);
4896
+ }, [skipCostRoll]);
4897
+ // 滚完定格:摘掉 animation 声明,展开侧栏不再重播
4898
+ useRollSettle(costRoll, setCostRoll);
2788
4899
 
2789
4900
  if (!usage) return null;
2790
4901
 
@@ -2796,18 +4907,16 @@ function UsageCostLine({ useProjection, sessionId }) {
2796
4907
  const newTokens = uncachedInput + output;
2797
4908
  // 计费总量 = 未缓存输入 + 缓存重读/写入 + 输出,与 DeepSeek 账单口径一致
2798
4909
  const billedTotal = uncachedInput + cacheRead + cacheWrite + output;
4910
+ // 面板顶部那颗大数字和「计费 tokens」都用**含子代理**的口径,
4911
+ // 否则标题 ¥/tok 和下面拆出来的子代理几行加起来对不上。
4912
+ const panelBilledTotal = billedTotal + (hasSubagent ? (subagentUsage.totalTokens ?? 0) : 0);
2799
4913
  if (newTokens === 0 && billedTotal === 0) return null;
2800
4914
 
2801
4915
  const peakTokens = splitUsable ? split.peak.inputTokens + split.peak.cacheReadTokens + split.peak.cacheWriteTokens + split.peak.outputTokens : 0;
2802
4916
  const offpeakTokens = splitUsable ? split.offpeak.inputTokens + split.offpeak.cacheReadTokens + split.offpeak.cacheWriteTokens + split.offpeak.outputTokens : 0;
2803
4917
 
2804
- // 子代理消耗(与主会话分开,同样跨时段精确计费)
2805
- const hasSubagent = subagentUsage != null && subagentUsage.totalTokens > 0;
2806
- const subCost = hasSubagent ? computeSplitCost(subagentUsage, p, peakMultiplier) : 0;
2807
- const subNewTokens = hasSubagent
2808
- ? (subagentUsage.inputTokens ?? 0) + (subagentUsage.outputTokens ?? 0)
2809
- : 0;
2810
- const subCount = hasSubagent ? (subagentUsage.sessions ?? 0) : 0;
4918
+ // 子代理那几项已经在上面(cost 之前)算过了 —— 它们必须参与合计,
4919
+ // 而合计又被 cost 依赖,所以只能在 cost 前面算,这里不再重复声明。
2811
4920
 
2812
4921
  // 缓存命中率口径与 DSH 相同:缓存读取 / (缓存读取 + 未缓存输入)
2813
4922
  const cacheHitBase = cacheRead + uncachedInput;
@@ -2825,13 +4934,14 @@ function UsageCostLine({ useProjection, sessionId }) {
2825
4934
  jsx.jsx("span", { key: "label", className: "balance-usage-label", children: "本会话消耗" }),
2826
4935
  jsx.jsx("span", {
2827
4936
  key: "cost",
2828
- className: "balance-usage-cost" + (costPending ? " balance-usage-cost--pending" : ""),
4937
+ className: "balance-usage-cost" + (showCostLoading ? " balance-usage-cost--pending" : ""),
2829
4938
  // 两种加载动效二选一(usageLoadingStyle),**两套语言不混用**:
2830
- // "wipe"(默认):等待时留空 → 数据到手挂 .balance-usage-reveal,白光擦过、数字跟着亮。
2831
- // "text":等待时显示带呼吸的「计算中…」胶囊 → 数据到手**直接渐入**,不放光条
4939
+ // "wipe"(默认):等待时一团白色迷雾在原地呼吸 → 数据到手「炸开」显示数字
4940
+ // (2026-10-31 用户:「加载的时候是一团白色迷雾 然后像爆炸一样显示出来」)。
4941
+ // "text":等待时显示带呼吸的「计算中…」胶囊 → 数据到手**直接渐入**,不放光不放雾
2832
4942
  // (2026-10-30 用户:「用的如果是计算中就不要用光条动画了 计算完直接渐入就行了」)。
2833
4943
  // 两种都只在等待期间出现,都不亮错误值。
2834
- children: staleUsage || costPending
4944
+ children: showCostLoading
2835
4945
  ? (loadingIsText
2836
4946
  ? jsx.jsxs("span", {
2837
4947
  className: "balance-usage-calc",
@@ -2846,24 +4956,79 @@ function UsageCostLine({ useProjection, sessionId }) {
2846
4956
  }, "dots"),
2847
4957
  ],
2848
4958
  })
2849
- : null)
2850
- : jsx.jsx("span", {
2851
- className: loadingIsText ? "balance-usage-fade" : "balance-usage-reveal",
2852
- key: (loadingIsText ? "fade-" : "reveal-") + (sessionId || ""),
2853
- children: jsx.jsxs("span", {
2854
- className: "balance-usage-cost-inner",
4959
+ // 迷雾态:一团白光,不是文字也不是光条。宽度由 CSS 撑满 min-width:10.2ch,
4960
+ // 迷雾态:4 团模糊的白色光在整条余额栏里游动(Siri 那种液体观感)。
4961
+ // 尺寸/周期/路径全在 CSS 的 :nth-child 里定,这里只要 4 个空壳 ——
4962
+ // 它们都是绝对定位,不参与布局,宽度由容器 100% 撑满 min-width:10.2ch。
4963
+ : jsx.jsx("span", {
4964
+ className: "balance-usage-mist",
4965
+ key: "mist-" + (sessionId || ""),
4966
+ "aria-hidden": "true",
4967
+ children: ORB_INDICES.map((i) => jsx.jsx("span", { key: "orb-" + i, className: "balance-usage-mist__orb" })),
4968
+ }))
4969
+ : (loadingIsText
4970
+ // 样式2(计算中):就是一层淡入,没有开窗也没有光
4971
+ ? jsx.jsx("span", {
4972
+ className: "balance-usage-fade",
4973
+ key: "fade-" + (sessionId || ""),
4974
+ children: jsx.jsxs("span", {
4975
+ className: "balance-usage-cost-inner",
4976
+ children: [
4977
+ jsx.jsx("span", { className: "balance-usage-approx", children: "≈" }, "approx"),
4978
+ jsx.jsx("span", { children: "¥" }, "cur"),
4979
+ // 静态也走逐字格:裸文本与 inline-block 逐字格的宽度/字距不等,
4980
+ // 来回切换会让整串左右挪一下(同今日金额,2026-10-31)。
4981
+ // 外层 key 恒定,只有变化的那一格换 key 重挂,否则滚动结束整串重挂会闪一下。
4982
+ jsx.jsx("span", {
4983
+ children: renderRollCells(
4984
+ costRoll === null ? buildRollCells(costText, costText) : buildRollCells(costRoll.from, costText),
4985
+ "u" + (costRoll === null ? "" : costRoll.at),
4986
+ // 播完就定格:摘掉 animation 声明,展开侧栏不再重播
4987
+ costRoll !== null && costRoll.done === true,
4988
+ ),
4989
+ }, "txt"),
4990
+ ],
4991
+ }),
4992
+ })
4993
+ : jsx.jsxs("span", {
4994
+ className: "balance-usage-reveal",
4995
+ key: "reveal-" + (sessionId || ""),
2855
4996
  children: [
2856
- // ≈ 单独成节点:U+2248 在中文字体里落在数学符号的中线,比数字基线低一截,
2857
- // 直接跟「¥7.4619」串在一起会显得整个符号往下掉(2026-10-30 用户指出「有点靠下」)。
2858
- jsx.jsx("span", { className: "balance-usage-approx", children: "≈" }, "approx"),
2859
- jsx.jsx("span", { children: "¥" }, "cur"),
2860
- // 只有数字参与逐位滚动:¥ 与 ≈ 是定值,跟着滚会把整个符号一起翻。
2861
- costRoll === null
2862
- ? jsx.jsx("span", { children: costText }, "txt")
2863
- : jsx.jsx("span", { key: "roll-" + costRoll.at, children: renderRollCells(buildRollCells(costRoll.from, costRoll.to), "u") }),
2864
- ],
2865
- }),
2866
- }),
4997
+ // 底层:加载态那团光(一整条,不切)。mask 在正中间挖一个随开窗扩大的椭圆洞,
4998
+ // 数字从洞里显出来,光留在两侧并且永不透到字缝里。
4999
+ jsx.jsx("span", {
5000
+ className: "balance-usage-reveal__mist",
5001
+ "aria-hidden": "true",
5002
+ children: jsx.jsx("span", {
5003
+ className: "balance-usage-mist",
5004
+ children: ORB_INDICES.map((i) => jsx.jsx("span", { key: "orb-" + i, className: "balance-usage-mist__orb" })),
5005
+ }),
5006
+ }, "mist"),
5007
+ // 上层:数字本体,被 clip-path 从中缝向两侧打开(真正的「开窗」动作在这一层)
5008
+ jsx.jsx("span", {
5009
+ className: "balance-usage-reveal__body",
5010
+ children: jsx.jsxs("span", {
5011
+ className: "balance-usage-cost-inner",
5012
+ children: [
5013
+ // ≈ 单独成节点:U+2248 在中文字体里落在数学符号的中线,比数字基线低一截,
5014
+ // 直接跟「¥7.4619」串在一起会显得整个符号往下掉(2026-10-30 用户指出「有点靠下」)。
5015
+ jsx.jsx("span", { className: "balance-usage-approx", children: "≈" }, "approx"),
5016
+ jsx.jsx("span", { children: "¥" }, "cur"),
5017
+ // 只有数字参与逐位滚动:¥ 与 ≈ 是定值,跟着滚会把整个符号一起翻。
5018
+ // 静态也走逐字格 + 外层 key 恒定,理由同上(宽度恒定、滚动结束不重挂)。
5019
+ jsx.jsx("span", {
5020
+ children: renderRollCells(
5021
+ costRoll === null ? buildRollCells(costText, costText) : buildRollCells(costRoll.from, costText),
5022
+ "u" + (costRoll === null ? "" : costRoll.at),
5023
+ // 播完就定格:摘掉 animation 声明,展开侧栏不再重播
5024
+ costRoll !== null && costRoll.done === true,
5025
+ ),
5026
+ }, "txt"),
5027
+ ],
5028
+ }),
5029
+ }, "body"),
5030
+ ],
5031
+ })),
2867
5032
  }),
2868
5033
  ];
2869
5034
 
@@ -2889,7 +5054,7 @@ function UsageCostLine({ useProjection, sessionId }) {
2889
5054
 
2890
5055
  const costDetails = [
2891
5056
  ["新增 tokens", formatTokens(newTokens) + " tok"],
2892
- ["计费 tokens", formatTokens(billedTotal) + " tok"],
5057
+ ["计费 tokens", formatTokens(panelBilledTotal) + " tok"],
2893
5058
  ["模型", model || "未知(按 flash 价估算)"],
2894
5059
  ];
2895
5060
  // 金额口径要写明白:算完 = 服务端逐条按发生时段分桶;算完前 / 拿不到 = 本地按当前时段估算。
@@ -2907,8 +5072,9 @@ function UsageCostLine({ useProjection, sessionId }) {
2907
5072
  }
2908
5073
  if (hasSubagent) {
2909
5074
  costDetails.push(
2910
- ["子代理", subCount + " 个"],
2911
- ["子代理 tokens", "+" + formatTokens(subNewTokens) + " / 计费 " + formatTokens(subagentUsage.totalTokens)],
5075
+ ["子代理", subagentCount + " 个 · 已计入上方合计"],
5076
+ ["子代理 tokens", "+" + formatTokens(subagentNewTokens) + " / 计费 " + formatTokens(subagentUsage.totalTokens)],
5077
+ ["子代理金额", "≈ ¥" + subagentCost.toFixed(4)],
2912
5078
  );
2913
5079
  }
2914
5080
  // 网页搜索 / 抓取:按次计费,既不属于高峰也不属于空闲,所以单列。
@@ -2950,7 +5116,7 @@ function UsageCostLine({ useProjection, sessionId }) {
2950
5116
  "Token 用量",
2951
5117
  ],
2952
5118
  }),
2953
- jsx.jsx("span", { className: "balance-usage-panel__value", children: formatTokens(billedTotal) + " tok" }),
5119
+ jsx.jsx("span", { className: "balance-usage-panel__value", children: formatTokens(panelBilledTotal) + " tok" }),
2954
5120
  ],
2955
5121
  }),
2956
5122
  jsx.jsx("div", { className: "balance-usage-panel__rule" }),
@@ -2991,7 +5157,7 @@ function UsageCostLine({ useProjection, sessionId }) {
2991
5157
  children,
2992
5158
  }),
2993
5159
  detailsOpen
2994
- ? reactDom.createPortal(
5160
+ ? createPortal(
2995
5161
  jsx.jsx("div", {
2996
5162
  ref: detailsPanelRef,
2997
5163
  className: "balance-usage-panel",
@@ -3021,7 +5187,14 @@ function UsageCostLine({ useProjection, sessionId }) {
3021
5187
  // 有新版本、且还没点开过面板 → 在图标右上角挂一个「新版本!」胶囊。
3022
5188
  // 用户要求:别再让它跟「有新内容」的未读红点用同一个提示(重复且不显眼)。
3023
5189
  const updateHint = notice.latest !== "" && notice.seen !== true && !active;
3024
- const unread = settings.entrySeen !== true && !active && !updateHint;
5190
+ // 有新功能、而且用户还没点进去过 → 入口挂一颗 NEW 标签。
5191
+ // 熄灭条件是「点进去过」,不是时间:点进去之后外侧这颗就没了,
5192
+ // 直到下个新功能出现(2026-10-31 用户要求)。
5193
+ // 这一套**固定开启**,不提供关闭开关(用户明确说过「这个强制打开 不需要这个开关」)——
5194
+ // 它只在一个短窗口内出现,不构成长期打扰,关掉反而让用户错过新功能。
5195
+ const newFeature = !updateHint && !active && unseenNewTags(settings).length > 0;
5196
+ // 优先级:新版本!> NEW > 未读红点。三者永不同时出现(挤在一格里谁也看不清)。
5197
+ const unread = settings.entrySeen !== true && !active && !updateHint && !newFeature;
3025
5198
  // 关掉「侧边栏显示本插件」→ 这一格直接不渲染(图标消失)。
3026
5199
  // 位置必须在所有 hook 之后,否则不同渲染间 hook 数量不一致,React 会直接报错。
3027
5200
  if (settings.showSidebarEntry === false) return null;
@@ -3043,19 +5216,30 @@ function UsageCostLine({ useProjection, sessionId }) {
3043
5216
  jsx.jsx("path", { d: "M18 12a2 2 0 0 0 0 4h4v-4Z" }),
3044
5217
  ],
3045
5218
  });
3046
- if (!unread && !updateHint) return svg;
3047
- // 点一下图标(红点就在图标右上角,点哪都算)就立刻熄灯:
3048
- // 本地先乐观置 true 并广播,红点在同一帧消失,不等 HTTP 往返;
5219
+ if (!unread && !updateHint && !newFeature) return svg;
5220
+ // 点一下图标(标签就在图标右上角,点哪都算)就立刻熄灯:
5221
+ // 本地先乐观置 true 并广播,标签在同一帧消失,不等 HTTP 往返;
3049
5222
  // 持久化走 fire-and-forget,失败也只是下次进来再写一次。
3050
5223
  // 用 pointerdown 而不是 click —— panellist 那一行的 click 归 DSH 的 selectPanel 处理,
3051
5224
  // pointerdown 只负责"标记已读",不抢它的点击行为(事件照常冒泡)。
3052
5225
  const markSeen = () => {
3053
- // 两个红点原因各自熄各自的:不能因为 entrySeen 已是 true 就整段 early return,
3054
- // 否则「有新版本」那颗点永远清不掉。
5226
+ // 三个提示原因各自熄各自的:不能因为 entrySeen 已是 true 就整段 early return,
5227
+ // 否则「有新版本」「有新功能」那两颗点永远清不掉。
3055
5228
  if (updateHint) {
3056
5229
  updateNotice = { ...updateNotice, seen: true };
3057
5230
  emitUpdateNotice();
3058
5231
  }
5232
+ // NEW 标签:用户点进来了,把还没看过的新功能标成 d:true —— 外侧这颗立即熄灭,
5233
+ // 一直保持熄灭,直到下一个新功能出现(没有总开关,也就没有"要不要关"的问题)。
5234
+ const entered = markNewTagsEntered(settingsCache.value);
5235
+ if (entered) {
5236
+ settingsCache = {
5237
+ ...settingsCache,
5238
+ value: { ...settingsCache.value, newTagSeen: entered },
5239
+ };
5240
+ emitSettings();
5241
+ void saveSettings({ newTagSeen: entered }).catch(() => {});
5242
+ }
3059
5243
  if (settingsCache.value.entrySeen !== true) {
3060
5244
  settingsCache = {
3061
5245
  ...settingsCache,
@@ -3066,8 +5250,8 @@ function UsageCostLine({ useProjection, sessionId }) {
3066
5250
  }
3067
5251
  };
3068
5252
  // 外层 span 只为了挂定位;DSH 的 panellist 容器不裁子元素
3069
- // 有新版 → 「新版本!」胶囊(品牌色实心,压在图标右上、落在入口文字上方);
3070
- // 否则若没点开过 → 原来那颗未读红点。两者不同时出现。
5253
+ // 三种提示按优先级取一个:有新版 → 「新版本!」胶囊;否则有未读新功能 → 「NEW」标签;
5254
+ // 否则若没点开过 → 原来那颗未读红点。
3071
5255
  return jsx.jsxs("span", {
3072
5256
  className:
3073
5257
  "balance-panel-icon" + (unread ? " balance-panel-icon--unread" : ""),
@@ -3080,6 +5264,12 @@ function UsageCostLine({ useProjection, sessionId }) {
3080
5264
  updateHint
3081
5265
  ? jsx.jsx("span", { className: "balance-panel-icon__new", children: "新版本!" })
3082
5266
  : null,
5267
+ newFeature
5268
+ ? jsx.jsx("span", {
5269
+ className: "balance-panel-icon__new balance-panel-icon__new--feature",
5270
+ children: "NEW",
5271
+ })
5272
+ : null,
3083
5273
  ],
3084
5274
  });
3085
5275
  }
@@ -3095,6 +5285,10 @@ function UsageCostLine({ useProjection, sessionId }) {
3095
5285
  if (settings.entrySeen !== true) {
3096
5286
  void saveSettings({ entrySeen: true }).catch(() => {});
3097
5287
  }
5288
+ // 打开这一页也算「点进去过」:侧栏那颗 NEW 到此为止,直到下个新功能。
5289
+ // 直接打开面板页(不经过侧栏图标)的用户同样要熄灭它。
5290
+ const entered = markNewTagsEntered(settingsCache.value);
5291
+ if (entered) void saveSettings({ newTagSeen: entered }).catch(() => {});
3098
5292
  // 打开面板就能看到顶部的「版本与更新」区块,所以新版本红点到此为止。
3099
5293
  // seen 只存在内存里(不写盘):下次启动重新检查,真有新版会再亮。
3100
5294
  if (updateNotice.latest !== "" && updateNotice.seen !== true) {
@@ -3144,8 +5338,14 @@ function UsageCostLine({ useProjection, sessionId }) {
3144
5338
  // error — 查询或更新失败
3145
5339
  const [updateCheck, setUpdateCheck] = react.useState({ kind: "idle" });
3146
5340
 
3147
- // 拿到设置后灌一次草稿;draft 已有内容就不再覆盖,避免打断正在输入的用户
5341
+ // 拿到设置后灌一次草稿;draft 已有内容就不再覆盖,避免打断正在输入的用户。
5342
+ // ⚠️ 必须等 `loaded === true` 再灌(2026-10-31 审查):
5343
+ // `useSettings()` 的初值是 SETTINGS_DEFAULTS,真正的值要等 /balance-api?config=1。
5344
+ // 原来无条件灌一次,于是草稿被**出厂默认值冻结** —— 之后 patch() 提交的是整份草稿,
5345
+ // 用户改一个开关,轮询间隔 / 阈值 / 扫描上限 / apiKey 全部被默认值覆盖回出厂。
5346
+ // 这正好踩中「设置绝不许被默认值覆盖」那条铁律。
3148
5347
  react.useEffect(() => {
5348
+ if (settingsCache.loaded !== true) return;
3149
5349
  setDraft((current) => (current === null ? { ...settings } : current));
3150
5350
  }, [settings]);
3151
5351
 
@@ -3157,7 +5357,20 @@ function UsageCostLine({ useProjection, sessionId }) {
3157
5357
  const saveTimer = react.useRef(null);
3158
5358
  const pendingRef = react.useRef(null);
3159
5359
  react.useEffect(() => () => {
3160
- if (saveTimer.current) clearTimeout(saveTimer.current);
5360
+ if (!saveTimer.current) return;
5361
+ clearTimeout(saveTimer.current);
5362
+ saveTimer.current = null;
5363
+ // ⚠️ 卸载时要把还没发出去的改动**补发**(2026-10-31 全量审查)。
5364
+ // 原来只 clearTimeout:改动在 350ms debounce 里,用户改完立刻切页/关面板
5365
+ // 就整条丢掉 —— 而界面刚刚还提示过「修改中…」,属于静默数据丢失。
5366
+ // saveSettings 是模块级函数,组件卸载后照常可调用。
5367
+ const payload = pendingRef.current;
5368
+ pendingRef.current = null;
5369
+ if (payload) {
5370
+ void saveSettings(payload).catch(() => {
5371
+ // 补发失败只能放弃:此时已经没有 UI 可以提示了
5372
+ });
5373
+ }
3161
5374
  }, []);
3162
5375
 
3163
5376
  const commit = async (next) => {
@@ -3253,9 +5466,12 @@ function UsageCostLine({ useProjection, sessionId }) {
3253
5466
  });
3254
5467
 
3255
5468
  const patch = (key, next) => {
3256
- const merged = { ...valueRef.current, [key]: next };
3257
- setDraft(merged);
3258
- queueSave(merged);
5469
+ // ⚠️ 只提交改动的这一个键,不再整份草稿一起发(2026-10-31 审查)。
5470
+ // saveSettings 会和服务端自己那份设置合并,所以单键提交既够用又安全;
5471
+ // 整份提交一旦碰上草稿没跟上真实值(设置还没加载完 / 别的页刚改过),
5472
+ // 就会把其余字段静默写回旧值。
5473
+ setDraft((current) => ({ ...(current === null ? valueRef.current : current), [key]: next }));
5474
+ queueSave({ [key]: next });
3259
5475
  };
3260
5476
  const onReset = () => {
3261
5477
  const next = { ...SETTINGS_DEFAULTS };
@@ -3352,6 +5568,58 @@ function UsageCostLine({ useProjection, sessionId }) {
3352
5568
  return "";
3353
5569
  })();
3354
5570
 
5571
+ // NEW 相关:NEW_TAGS / freshNewTags / markNewTagsEntered 都在模块级
5572
+ // (侧栏入口那颗 NEW 标签也要用同一套判定,不能在组件里再定义一份)。
5573
+ // ⚠️ 这几个派生值 + 下面那个记录 effect + 折叠状态,**必须写在早退之前**。
5574
+ // 它们原来排在 `if (view === "summary")` 后面,于是同一实例从 summary 切到 page 时
5575
+ // hook 数量从 N 变成 N+2,React 抛 "Rendered more hooks than during the previous render",
5576
+ // 设置卡片连同整页一起白掉。设置卡片是常驻 UI,正确性优先于就近定义。
5577
+ const newTagSince = Number(value.newTagSince);
5578
+ // 升级前的版本(空串 = 全新安装)
5579
+ const newTagFrom = String(value.newTagFrom || "");
5580
+ const newTagSeen = value.newTagSeen && typeof value.newTagSeen === "object" ? value.newTagSeen : {};
5581
+ // ⚠️ 版本号走 settingsCache.version,**不是** value.version(2026-10-31 审查)。
5582
+ // 服务端把 version 放在 settings 的**兄弟字段**里(见 lib/index.js 的响应体),
5583
+ // settings 对象里从来没有这个键,所以 String(value.version || "") 恒为空串。
5584
+ // 而 isCurrentIntro 见到空串恒判 false,于是「本版新增必须亮满 24 小时」会提前熄灭 ——
5585
+ // 那正是 2026-10-31 修过一次的回归,别再退回 value.version。
5586
+ const currentVersion = String(settingsCache.version || value.version || "");
5587
+ // 逐项记录时用的起点是**升级时刻**(服务端在版本变化那一刻写下的 newTagSince),
5588
+ // 不是「这次渲染的时刻」。用户 2026-10-31 明确要求:「更新完开始计算,不需要打开插件设置页才显示」——
5589
+ // 侧栏入口会挂「新版本!」胶囊,看到就会点进来,窗口不该等他点进来才起跑。
5590
+ // 所以升级后隔两天才打开设置页,看到的就只剩一天,而不是又白送三天。
5591
+ react.useEffect(() => {
5592
+ if (settingsCache.loaded !== true) return;
5593
+ const now = Date.now();
5594
+ const anchor = Number(newTagSince) > 0 ? Number(newTagSince) : now;
5595
+ const patch = {};
5596
+ for (const [key, intro] of NEW_TAGS) {
5597
+ const rec = newTagSeen[key];
5598
+ // 已按这个版本记过了,不重复写盘、也不把起点往后推
5599
+ if (rec && typeof rec === "object" && rec.v === intro) continue;
5600
+ // 对用户不是新功能就不记(否则以后 baseline 变了会误显示)
5601
+ if (!newTagStateAt(intro, rec, newTagFrom, newTagSince, now, currentVersion).fresh) continue;
5602
+ patch[key] = { v: intro, t: anchor };
5603
+ }
5604
+ if (Object.keys(patch).length === 0) {
5605
+ // 没新东西要记,但顺手把已删功能的残记录清理掉(写盘前统一 prune)
5606
+ const pruned = pruneNewTagSeen(newTagSeen);
5607
+ if (Object.keys(pruned).length !== Object.keys(newTagSeen).length) {
5608
+ void saveSettings({ newTagSeen: pruned }).catch(() => {});
5609
+ }
5610
+ return;
5611
+ }
5612
+ void saveSettings({ newTagSeen: { ...pruneNewTagSeen(newTagSeen), ...patch } }).catch(() => {
5613
+ // 写盘失败不影响本次渲染:下次进入会重试
5614
+ });
5615
+ // currentVersion 必须进依赖:它来自异步加载的 settingsCache,加载完成前是空串,
5616
+ // 判定结果会从"不是新功能"翻成"是新功能"。
5617
+ }, [newTagFrom, newTagSince, newTagSeen, currentVersion]);
5618
+ // 整张设置卡片可折叠,形式对齐 DSH 原生 DisclosureRow —— 一行标题 + 描述 + 右侧 chevron,
5619
+ // 展开时箭头翻转 180 度。**默认展开**:只有插件列表里的单行摘要(view === "summary")
5620
+ // 保持收起,点进详情页 / 设置页 / 侧栏面板页时一进来就摊开所有选项。
5621
+ const [openSettings, setOpenSettings] = react.useState(view !== "summary");
5622
+
3355
5623
  // 插件列表里的单行摘要
3356
5624
  if (view === "summary") {
3357
5625
  const shown = [
@@ -3365,51 +5633,41 @@ function UsageCostLine({ useProjection, sessionId }) {
3365
5633
  });
3366
5634
  }
3367
5635
 
3368
- // NEW_TAGS:标准——「最近版本加了什么」。
3369
- // 显示窗口 = 当前版本首次启动后 3 天(2026-10-30 反馈):
3370
- // 起点由服务端记在设置文件里(newTagSince / newTagVersion),
3371
- // 版本一变就重置——所以用户一升级就会看到新的 3 天。不看 entrySeen。
3372
- // 维护方式:发版时把本版新增的设置项 key 加进来,把上一版的移出去。
3373
- // 每项带上「引入版本」:只有比用户上一次跑的版本更新的项才标 NEW。
3374
- // 这样跨几个版本一次升上来的用户会看到所有中间新功能,
3375
- // 而已经跑过这些功能的用户不会被重复提示。
3376
- // 项目不再需要发版时手动「把上一版的移出去」——版本比较会自动过滤。
3377
- const NEW_TAGS = new Map([
3378
- ["footerStyle", "2.0.0"], // 余额行样式(新版 timeline / 旧版 classic)
3379
- ["showSidebarEntry", "2.0.0"], // 左侧导航栏入口
3380
- ["refreshWave", "2.0.0"], // 右键刷新涟漪
3381
- ["usageLoadingStyle", "2.0.7"], // 消耗行加载动效:白光 / 计算中
3382
- ]);
3383
- const newTagSince = Number(value.newTagSince);
3384
- // 升级前的版本(空串 = 全新安装)
3385
- const newTagFrom = String(value.newTagFrom || "");
3386
- const newTagSeen = value.newTagSeen && typeof value.newTagSeen === "object" ? value.newTagSeen : {};
5636
+ // NEW 相关:NEW_TAGS / freshNewTags / markNewTagsEntered 都在模块级
5637
+ // (侧栏入口那颗 NEW 标签也要用同一套判定,不能在组件里再定义一份)。
3387
5638
  const isNew = (key) => {
3388
5639
  const intro = NEW_TAGS.get(key);
3389
5640
  if (!intro) return false;
3390
- return newTagStateAt(intro, newTagSeen[key], newTagFrom, newTagSince, Date.now()).show;
5641
+ return newTagStateAt(intro, newTagSeen[key], newTagFrom, newTagSince, Date.now(), currentVersion).show;
3391
5642
  };
3392
- // 逐项记录「第一次见到」的时间:每个新功能各自从自己出现那一刻算 3 天,
3393
- // 与其它功能的过期时间完全独立。设置还没加载完不能记(会误当全新安装)。
3394
- react.useEffect(() => {
3395
- if (settingsCache.loaded !== true) return;
3396
- const now = Date.now();
3397
- const patch = {};
3398
- for (const [key, intro] of NEW_TAGS) {
3399
- const rec = newTagSeen[key];
3400
- // 已按这个版本记过了,不重复写盘、也不把起点往后推
3401
- if (rec && typeof rec === "object" && rec.v === intro) continue;
3402
- // 对用户不是新功能就不记(否则以后 baseline 变了会误显示)
3403
- if (!newTagStateAt(intro, rec, newTagFrom, newTagSince, now).fresh) continue;
3404
- patch[key] = { v: intro, t: now };
5643
+ // 顶部「本版新增」横幅:和逐项 isNew 同一套判定(24 小时窗口),
5644
+ // 所以横幅消失的时机跟设置项旁那颗 NEW 完全一致,不会出现"横幅没了但某行还有 NEW"。
5645
+ const pendingNew = freshNewTags(value);
5646
+ // 点名跳到那一行:设置卡片很长,光说"有新功能"用户还得自己找。
5647
+ const focusNewRow = (key) => {
5648
+ try {
5649
+ const el = document.querySelector('[data-newtag="' + key + '"]');
5650
+ if (!el) return;
5651
+ if (typeof el.scrollIntoView === "function") {
5652
+ el.scrollIntoView({ block: "center", behavior: "smooth" });
5653
+ }
5654
+ // 先摘再强排一次:连点同一个按钮也要重播闪一下,而不是没反应
5655
+ el.classList.remove("balance-settings__row--flash");
5656
+ void el.offsetWidth;
5657
+ el.classList.add("balance-settings__row--flash");
5658
+ // 动画是 1.4s,1.5s 后才摘 class:留 100ms 余量,别在动画没播完时把伪元素抽走。
5659
+ // 伪元素本身靠 animation-fill-mode:both 停在 opacity:0,所以中间不会有回跳。
5660
+ window.setTimeout(() => el.classList.remove("balance-settings__row--flash"), 1500);
5661
+ } catch {
5662
+ // 定位失败只是不滚、不闪,设置本身照常可用
3405
5663
  }
3406
- if (Object.keys(patch).length === 0) return;
3407
- void saveSettings({ newTagSeen: { ...newTagSeen, ...patch } }).catch(() => {
3408
- // 写盘失败不影响本次渲染:下次进入会重试
3409
- });
3410
- }, [newTagFrom, newTagSince, newTagSeen]);
3411
- const row = (key, label, hint, control) => jsx.jsxs("div", {
5664
+ };
5665
+ // 逐项记录用的 effect 已经上移到早退之前(见「NEW 相关」那段说明)——
5666
+ // hook 不能跟在条件 return 后面。这里只留纯计算。
5667
+ const row = (key, label, hint, control, extra) => jsx.jsxs("div", {
3412
5668
  className: "balance-settings__row",
5669
+ // 顶部「本版新增」横幅靠它定位跳转(querySelector('[data-newtag="key"]'))
5670
+ "data-newtag": key,
3413
5671
  children: [
3414
5672
  jsx.jsxs("div", {
3415
5673
  className: "balance-settings__label",
@@ -3426,6 +5684,9 @@ function UsageCostLine({ useProjection, sessionId }) {
3426
5684
  hint ? jsx.jsx("span", { className: "balance-settings__hint", children: hint }) : null,
3427
5685
  ],
3428
5686
  }),
5687
+ // extra 是可选的「中间预览」:夹在说明文字和右侧控件之间,
5688
+ // 由 margin-left:auto 贴向控件一侧(用户指的那块空白就在这里)。
5689
+ extra || null,
3429
5690
  jsx.jsx("div", { className: "balance-settings__control", children: control }),
3430
5691
  ],
3431
5692
  }, key);
@@ -3482,8 +5743,8 @@ function UsageCostLine({ useProjection, sessionId }) {
3482
5743
  toggle(key, label, onToggle ? () => onToggle(value[key] !== true) : undefined),
3483
5744
  );
3484
5745
 
3485
- // 二选一分段控件:用于「余额行外观」这类互斥选项
3486
- const choiceRow = (key, label, hint, options) => row(key, label, hint, jsx.jsx("div", {
5746
+ // 二选一分段控件:用于「余额行外观」这类互斥选项。第 5 参 extra 是可选的行内预览。
5747
+ const choiceRow = (key, label, hint, options, extra) => row(key, label, hint, jsx.jsx("div", {
3487
5748
  className: "balance-settings__segmented",
3488
5749
  role: "radiogroup",
3489
5750
  "aria-label": label,
@@ -3496,12 +5757,9 @@ function UsageCostLine({ useProjection, sessionId }) {
3496
5757
  onClick: () => patch(key, opt.value),
3497
5758
  children: opt.label,
3498
5759
  }, opt.value)),
3499
- }));
5760
+ }), extra);
3500
5761
 
3501
- // 整张设置卡片可折叠,形式对齐 DSH 原生 DisclosureRow —— 一行标题 + 描述 + 右侧 chevron,
3502
- // 展开时箭头翻转 180 度。**默认展开**:只有插件列表里的单行摘要(view === "summary")
3503
- // 保持收起,点进详情页 / 设置页 / 侧栏面板页时一进来就摊开所有选项。
3504
- const [openSettings, setOpenSettings] = react.useState(view !== "summary");
5762
+ // 折叠状态 openSettings 已经上移到早退之前(hook 不能跟在条件 return 后面)。
3505
5763
  const chevron = jsx.jsx("svg", {
3506
5764
  className: "balance-settings__chevron",
3507
5765
  viewBox: "0 0 16 16",
@@ -3526,11 +5784,12 @@ function UsageCostLine({ useProjection, sessionId }) {
3526
5784
  ? jsx.jsxs("div", {
3527
5785
  className: "balance-settings__group-note",
3528
5786
  children: [
5787
+ // 同顶部告示那颗:宽高必须等于 viewBox 的 16,14/16 的非整数缩放会把描边抹糊。
3529
5788
  jsx.jsxs("svg", {
3530
5789
  className: "balance-settings__group-note-icon",
3531
5790
  viewBox: "0 0 16 16",
3532
- width: 14,
3533
- height: 14,
5791
+ width: 16,
5792
+ height: 16,
3534
5793
  fill: "none",
3535
5794
  stroke: "currentColor",
3536
5795
  strokeWidth: 1.5,
@@ -3588,16 +5847,42 @@ function UsageCostLine({ useProjection, sessionId }) {
3588
5847
  openSettings ? jsx.jsxs("div", {
3589
5848
  className: "balance-settings__body",
3590
5849
  children: [
5850
+ // 「本版新增」横幅:只在有条目新功能未过期时出现。
5851
+ // 它不依赖侧栏入口 —— 用户关闭「侧边栏显示本插件」、或从 DSH「插件」
5852
+ // 页直接进来时,这是唯一能看到"有新功能"的地方(2026-10-31 用户提问)。
5853
+ pendingNew.length > 0
5854
+ ? jsx.jsxs("div", {
5855
+ className: "balance-settings__newcue",
5856
+ children: [
5857
+ jsx.jsx("span", { className: "balance-settings__newcue-badge", children: "NEW" }),
5858
+ jsx.jsx("span", {
5859
+ className: "balance-settings__newcue-text",
5860
+ children: "本版新增:" + pendingNew
5861
+ .map((item) => NEW_TAG_LABELS[item.key] || item.key)
5862
+ .join(" · "),
5863
+ }),
5864
+ jsx.jsx("button", {
5865
+ type: "button",
5866
+ className: "balance-settings__newcue-btn",
5867
+ onClick: () => focusNewRow(pendingNew[0].key),
5868
+ children: "去看看",
5869
+ }),
5870
+ ],
5871
+ })
5872
+ : null,
3591
5873
  // 顶部告示:计费范围限制放在最前面,打开设置第一眼就看到,
3592
5874
  // 不用翻到「计费」分组才发现。
3593
5875
  jsx.jsxs("div", {
3594
5876
  className: "balance-settings__notice",
3595
5877
  children: [
5878
+ // ⚠️ 宽高必须等于 viewBox 的 16,**不要**写成 15(2026-10-31 用户报「感叹号是糊的」):
5879
+ // 15/16 = 0.9375 的非整数缩放会让 1.5px 的描边落在半像素上,
5880
+ // 抗锯齿把圆圈和竖线一起抹成灰边。1:1 映射后每个单位正好一个设备像素。
3596
5881
  jsx.jsxs("svg", {
3597
5882
  className: "balance-settings__notice-icon",
3598
5883
  viewBox: "0 0 16 16",
3599
- width: 15,
3600
- height: 15,
5884
+ width: 16,
5885
+ height: 16,
3601
5886
  fill: "none",
3602
5887
  stroke: "currentColor",
3603
5888
  strokeWidth: 1.5,
@@ -3765,11 +6050,63 @@ function UsageCostLine({ useProjection, sessionId }) {
3765
6050
  ? toggleRow("refreshWave", "右键刷新浪潮", "右键余额时,金额会被一道白光从左往右擦出来(和输入区「本会话消耗」同一个动效)。")
3766
6051
  : null,
3767
6052
  toggleRow("showUsagePill", "会话消耗行", "输入框下方的「本会话消耗」胶囊(含点开的明细浮层)。"),
3768
- // 两种加载动效互斥,用分段按钮选(和「余额行外观」同一个控件,视觉一致)
3769
- choiceRow("usageLoadingStyle", "消耗行加载动效", "等分时段用量时显示什么。", [
3770
- { value: "wipe", label: "白光" },
3771
- { value: "text", label: "计算中" },
3772
- ]),
6053
+ // 两种加载动效互斥,用分段按钮选(和「余额行外观」同一个控件,视觉一致)。
6054
+ // 选项文字用「样式1 / 样式2」而不是效果名,所以在说明栏里直接并排播两个真实预览 ——
6055
+ // 用户不用读描述、看动画就知道哪个是哪个(2026-10-31 要求「在介绍栏预览给用户看」)。
6056
+ choiceRow("usageLoadingStyle", "消耗行加载动效", "等对话用量时显示什么。", [
6057
+ { value: "wipe", label: "样式1" },
6058
+ { value: "text", label: "样式2" },
6059
+ ], jsx.jsxs("div", {
6060
+ className: "balance-settings__preview",
6061
+ "aria-hidden": "true",
6062
+ children: [
6063
+ jsx.jsxs("div", {
6064
+ className: "balance-settings__preview-item" + (value.usageLoadingStyle === "text" ? "" : " is-on"),
6065
+ children: [
6066
+ jsx.jsx("span", { className: "balance-settings__preview-tag", children: "样式1" }),
6067
+ jsx.jsxs("span", {
6068
+ className: "balance-settings__preview-stage",
6069
+ children: [
6070
+ jsx.jsx("span", {
6071
+ className: "balance-settings__preview-text",
6072
+ children: "本会话消耗",
6073
+ }),
6074
+ jsx.jsx("span", {
6075
+ className: "balance-usage-mist",
6076
+ children: ORB_INDICES.map((i) => jsx.jsx("span", { key: "orb-" + i, className: "balance-usage-mist__orb" })),
6077
+ }),
6078
+ ],
6079
+ }),
6080
+ ],
6081
+ }, "p1"),
6082
+ jsx.jsxs("div", {
6083
+ className: "balance-settings__preview-item" + (value.usageLoadingStyle === "text" ? " is-on" : ""),
6084
+ children: [
6085
+ jsx.jsx("span", { className: "balance-settings__preview-tag", children: "样式2" }),
6086
+ jsx.jsxs("span", {
6087
+ className: "balance-settings__preview-stage",
6088
+ children: [
6089
+ jsx.jsx("span", {
6090
+ className: "balance-settings__preview-text",
6091
+ children: "本会话消耗",
6092
+ }),
6093
+ jsx.jsx("span", {
6094
+ className: "balance-usage-calc",
6095
+ children: [
6096
+ "计算中",
6097
+ jsx.jsx("span", {
6098
+ className: "balance-usage-calc__dots",
6099
+ "aria-hidden": "true",
6100
+ children: [0, 1, 2].map((i) => jsx.jsx("span", { className: "balance-usage-calc__dot", children: "." }, i)),
6101
+ }, "dots"),
6102
+ ],
6103
+ }),
6104
+ ],
6105
+ }),
6106
+ ],
6107
+ }, "p2"),
6108
+ ],
6109
+ })),
3773
6110
  value.footerStyle === "classic"
3774
6111
  ? toggleRow("showPeriodLabel", "高峰 / 空闲标签", "旧版余额行里「金额 • 时段」的时段部分;关掉后只留金额。")
3775
6112
  : null,
@@ -3782,6 +6119,7 @@ function UsageCostLine({ useProjection, sessionId }) {
3782
6119
  ]),
3783
6120
 
3784
6121
  group("stats", "用量统计", [
6122
+ numberRow("todaySessionLimit", "今日消耗扫描上限", "启动时最多统计今天动过的多少个会话(多开对话、多跑子代理时调大,越大越全也可能越慢)。", 10, 2000, "个"),
3785
6123
  numberRow("statsSessionLimit", "扫描会话数上限", "统计面板最多回看多少个历史会话(越大越慢)。", 10, 2000, "个"),
3786
6124
  numberRow("statsCacheSec", "统计缓存时长", "统计结果缓存多久;0 = 每次重新扫描。", 0, 3600, "秒"),
3787
6125
  ]),
@@ -3839,11 +6177,17 @@ function UsageCostLine({ useProjection, sessionId }) {
3839
6177
  }
3840
6178
 
3841
6179
  // ─── 插件主体 ───────────────────────────────────────────────────────────
3842
- const inject = ["slots", "sessions"];
6180
+ // ⚠️ 只 inject slots。`sessions` 是**可选**服务,只用来解析当前会话的模型
6181
+ // (取不到就退回服务端 detectModel / 默认 flash 价),把它写进 inject 意味着
6182
+ // DSH 哪天不再提供它,整个 apply 会被跳过 —— 5 个 slot 全部不注册、界面静默消失,
6183
+ // 而这个插件已经因为 DSH 内部 API 变动崩过多次(见 AGENTS 铁律 4:
6184
+ // 可选服务用 ctx.get() 拿,不要放进 inject)。改成运行时探测,缺了只降级模型解析。
6185
+ const inject = ["slots"];
3843
6186
 
3844
6187
  function apply(ctx) {
3845
- // 拿到 sessions 服务,用于解析当前会话使用的模型(按模型取价)
3846
- sessionsFace = ctx.sessions;
6188
+ // 拿到 sessions 服务,用于解析当前会话使用的模型(按模型取价)。
6189
+ // ctx.get() 不会因未声明注入而抛错,服务不存在时返回 undefined。
6190
+ sessionsFace = typeof ctx.get === "function" ? ctx.get("sessions") : void 0;
3847
6191
  // 余额显示:展开态底部胶囊(order 20 排在市场 10 之后、紧挨设置);收起态悬浮 FAB
3848
6192
  ctx.slots.inject("sidebar.footer.action", () =>
3849
6193
  ctx.slots.register(
@@ -3857,6 +6201,11 @@ function UsageCostLine({ useProjection, sessionId }) {
3857
6201
  );
3858
6202
 
3859
6203
  // 对话统计区(对话框下方)的会话消耗行
6204
+ // ⚠️ conversation.composer.bar 的注册已撤除(2026-10-31)。
6205
+ // 试过把收起态余额塞进 composer 内部(「红圈是在聊天框里面」),但注册后
6206
+ // 侧栏余额行和消耗行**同时消失**,整个插件看起来没加载。DSH 的这个 slot
6207
+ // 名即使存在也不接受这种用法,且 slots.register 静默失败、无日志可查。
6208
+ // 收起态继续用矩阵浮层(bottom 已抬到 140px,不会压 composer)。
3860
6209
  ctx.slots.inject("conversation.composer.dock", () =>
3861
6210
  ctx.slots.register(
3862
6211
  {