balance-dsh 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/lib/client.js +548 -175
  2. package/lib/index.js +82 -20
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -239,9 +239,51 @@ window.__ModuleLoader__.load({
239
239
  position:absolute;inset:0;z-index:1;display:block;pointer-events:none;
240
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
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
242
  }
243
+ /* ⚠️ 挖洞动画**必须挂在修饰类上**(2026-10-31 第十六轮:「是bug吗在展开侧边栏的时候
244
+ 这个动画也会触发」)。原来 animation:balance-today-hole … 是直接写在 __mist 上的,于是只要
245
+ 节点重建(折叠/展开侧栏让 BalanceFooter 重挂、DOM 重造),CSS 就从 0 秒重播一次 ——
246
+ 表现为「已经出好的 ¥35.0 又被雾盖住、又扫一遍」。今日那边 body 早就用 --enter 闸门治过,
247
+ 漏的就是 mist 这一层。
248
+ 现在按状态拆成两个修饰类,节点怎么重挂都不会凭空起播:
249
+ · --wipe :擦出窗口内(todayWipeSeq > 0 && !todayWipeDone)才挂,动画 0%→100%;
250
+ · --clear :已经擦完(有值 + done),直接钉在 --today-hole:100%,也就是"整层透明",
251
+ 与动画终点逐像素一致,而且**没有任何 animation 声明可以重播**。
252
+ 等待态两个类都不挂,--today-hole 停在注册初值 0% → 雾整块可见,就是加载动画。 */
253
+ .balance-footer__today-reveal__mist--wipe{animation:balance-today-hole .26s cubic-bezier(.33,0,.67,1) both}
254
+ .balance-footer__today-reveal__mist--clear{--today-hole:100%}
244
255
  .balance-footer__today-reveal__mist .balance-usage-mist{width:100%;min-width:0;height:100%}
256
+ /* ⚠️ 展开态的雾层**不许被拉伸**(2026-10-31 第五轮:「不要因为加载出来导致动画重头开始放」)。
257
+ 上面那条 100% 是给**收起态浮窗**的(它没有 balance-footer__today-wait,
258
+ 只能靠这条把雾铺满金额格)。但展开态同时挂着 today-wait,一旦也被拉成 100%×100%,
259
+ 等待态的 5.5ch×11px 小胶囊会在"值到手"那一帧突然变成「数字框那么大」——
260
+ 两层 radial 光团全是百分比尺寸与百分比位移,框一变,每团光的位置/大小整体重算,
261
+ 看起来就是动画从头初始化了一遍(这正是用户反复反馈的那一条)。
262
+ 所以这里把几何**钉回等待态的数值**,让它自始至终是同一颗胶囊:
263
+ · 尺寸 5.5ch × 11px 不变 → 伪元素的 % 全部不变 → 光团相位不动;
264
+ · 外层 __mist 是 inset:0 的居中 flex,整行又是 justify-content:center,
265
+ 胶囊中心恒等于数字框中心 —— 数字变宽时它在**屏幕坐标里其实没动**。
266
+ 收起态(.balance-fab__today-wipe-mist,不带 today-wait)不受这条影响。 */
267
+ .balance-footer__today-reveal__mist.balance-footer__today-wait .balance-usage-mist{
268
+ width:5.5ch;min-width:5.5ch;height:11px;flex:none;
269
+ /* ⚠️ 揭幕态必须补一层**实底**,否则亮度/模糊度会和加载态不一样(2026-10-31 第十一轮:
270
+ 「擦出动画开始之后 雾的亮度或者说是模糊度跟在加载的时候不一样」)。
271
+ 原因不在 alpha,而在 backdrop root:给这层加上 mask-image 之后,它自己就成了新的
272
+ backdrop root,里层 .balance-footer__today-fog 的 backdrop-filter **再也采不到胶囊
273
+ 背后的侧栏底色**,只剩下面那两团光 —— 玻璃少掉一层深色,整体白一档、也更"糊"。
274
+ 实测(同一胶囊、同一光团相位,取胶囊内部):加载态 mean 68.1 / peak 127;
275
+ 揭幕态 mean 81.5 / peak 175(+20% / +38%)。放到深红底上更明显:加载态能透出
276
+ rgb(93,51,51),揭幕态变成 rgb(100,67,67) —— 底色被采样这件事直接消失。
277
+ 所以揭幕态给胶囊铺「侧栏底色 + 3% 白」的实底,把那层丢失的背景补回来:
278
+ 实测 mean 69.3 / peak 137,和加载态只差 1.2 / 10(0.5% / 4%,肉眼无感)。
279
+ 底色必须用 --dsw-specific-sidebar-fill(深色主题 = neutral-bluish-900 = #1b1b1c),
280
+ **不是 --dsw-alias-bg-layer-1(#232324)**:用户 65×15 那个今日胶囊截图四角恒为
281
+ rgb(27,27,28),说明侧栏底就是 #1b1b1c,用 layer-1 会偏高 8 级、擦出瞬间亮一下。
282
+ 换成变量后未来换主题/换宿主容器只改 --today-mist-bed 一处;
283
+ 收起态浮窗那颗(.balance-fab__today-wipe-mist,没有 fog 层、也不带 today-wait)不受影响。 */
284
+ background:linear-gradient(rgba(255,255,255,.03),rgba(255,255,255,.03)),
285
+ var(--today-mist-bed,var(--dsw-specific-sidebar-fill,#1b1b1c));
286
+ }
245
287
  @keyframes balance-today-hole{0%{--today-hole:0%}100%{--today-hole:100%}}
246
288
  .balance-footer__today-reveal__body{position:relative;display:inline-flex;align-items:baseline;white-space:pre}
247
289
  /* 揭幕动画:挂在 --enter 修饰类上,只有「今日数值变了」才挂(2026-10-31 第十轮)。
@@ -477,15 +519,24 @@ window.__ModuleLoader__.load({
477
519
  (2026-10-31:「让里面看起来更像是流动的液体」)。只做 translate + scale 时,
478
520
  形状是固定的一块光在滑过,读起来是"光斑移动";让四角半径各自呼吸、
479
521
  再叠一层 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}
522
+ border-radius 的斜杠语法两半都是长度对,可插值,能进 keyframes。
523
+ ⚠️ delay 走 var(--today-flow-a),由渲染层每次挂载随机给一个负值 —— 原来写死 -.5s,
524
+ 每次重挂的第一帧都落在同一点,见下面 B 那条和 makeTodayFlowStyle 的注释。 */
525
+ .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 var(--today-flow-a,-.5s) infinite alternate}
482
526
  /* 光团 B:更小更亮的芯,方向与 A 相反、节奏更慢(2.2s vs 1.4s)。
483
527
  两个非整倍数的时长叠在一起,交叉点永远不会落在同一相位,视觉上就"没有周期"。
484
528
  ⚠️ 这两层的 animation-delay 都是**负值**,不是随便填的:ease-in-out 从 0 速度起步,
485
529
  等待态出现的头 300~400ms 里光团几乎不动 —— 用户 2026-10-31:「里面的滚动太慢了
486
530
  导致刚加载出来看起来没在动」。负 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}
531
+ 一出现就在流。时长 2026-10-31 再收一轮(1.9→1.4 / 3.1→2.2),非整数倍关系保持不变。
532
+ ⚠️ 但**负 delay 写死 = 每次挂载都从同一相位起步**(2026-10-31 第八轮:「每次开始动画
533
+ 的时候 都是右下角发白」)。B 的 0% 帧是 translate3d(20%,16%),那团 .72 alpha 的亮芯
534
+ 被推到右下角;写死 -1.1s 时首帧相位固定,于是每次重挂(展开侧栏 / 切会话 / 重开)
535
+ 第一眼都是同一张「右下角发白」,两段录像逐帧一对比几乎一模一样。
536
+ 而且从挂载到出值再到擦出,整条时序是确定的(+560ms 发请求 → 约 400ms 回),
537
+ 所以连「擦出那一刻的光团相位」每次也几乎一样。
538
+ 现在延迟由 makeTodayFlowStyle 每次挂载随机取,整条时序被去同步,各次不再复读。 */
539
+ .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 var(--today-flow-b,-1.1s) infinite alternate}
489
540
  .balance-footer__today-wait .balance-usage-mist__orb{display:none}
490
541
  /* 毛玻璃层:盖在两层光团之上的第三层。
491
542
  ⚠️ 关键是 backdrop-filter 而不是「半透明白色贴片」—— 前者真的把底下的光团糊掉,
@@ -806,23 +857,71 @@ window.__ModuleLoader__.load({
806
857
  实测截图对比:带遮罩的左侧暗一块、无遮罩是均匀的胶囊弧。
807
858
  胶囊形状交给 border-radius + overflow:hidden;两端柔和度由 ::before 的椭圆底光
808
859
  (边缘收到 .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}
860
+ /* 玻璃底(2026-10-31 三轮:「本会话消耗的加载动画的毛玻璃颜色有点深」
861
+ →「灰色部分太黑了 导致动效看起来不是很明显」
862
+ →「这有点太显眼了 发白了都」)。
863
+ 第一版这条胶囊**完全没有底色**,只有一层最外圈 .04 的白光,灰色部分压到 #232324
864
+ —— 和 composer 底**同一个色**,光团飞过两端时整条像融进背景、看不出是个容器。
865
+ 第二版把底色拉到 .20 补"体",结果过头了:整条胶囊变成一块亮白砖,压在灰字旁边太抢眼。
866
+ 现在取中:底色 .07(有"体"但明显是灰玻璃),底光 .13/.08/.04(负责渐变),
867
+ 同时把**光团自己的白**收一档 —— 渐变核心 .99→.74、泛光 box-shadow .22/.10→.15/.065,
868
+ 雾膜再薄一档,底下的"光在玻璃里"才透得出来(第四轮「最顶上那一层蒙版可以稍微透一点」)。
869
+ ⚠️ 收光团而不是只收底色,是这轮的关键:胶囊只有 65px 宽、5 团 40~64px 的光团几乎铺满,
870
+ screen 混合后**整条都被光斑盖住**,此时底色再暗也压不住白。实测(同位置测胶囊区):
871
+ 无底色 .24/.12/.04 p10 85 p50 115 p90 148
872
+ .20 .12/.07/.03 p10 109 p50 131 p90 160 ← 第二版,整条发白
873
+ .08 .14/.08/.04 球.84 p10 70 p50 117 p90 154 ← 第三版
874
+ .07 .13/.08/.04 球.74 p10 66 p50 106 p90 140 ← 采用,雾膜再薄一档
875
+ 抬亮度靠**底色**、不能靠**底光**(底光是径向的,越强越把整条抹平);
876
+ 但**降低白感必须同时收光团**,只降底色会让亮芯反而更扎眼。
877
+ 只动静态颜色,**光团几何 / 周期 / 缓动 / 相位一个字节都没碰**。
878
+ ⚠️ 今日等待胶囊走 .balance-footer__today-wait .balance-usage-mist(.03 底色 + 自己那套光团),
879
+ 选择器权重更高,写在前面也照样生效,所以今日那格不受这条影响。 */
880
+ .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;background:rgba(255,255,255,.07);animation:balance-siri-pulse 1.9s ease-in-out infinite}
881
+ /* 底光:保证任何时刻整条栏都有光,不留黑缝(只有游动的光团时,间隙处会断开成一截一截)。
882
+ 强度必须比底色低一档 —— 它的作用是让玻璃有渐变,不是给玻璃上色。 */
883
+ .balance-usage-mist::before{content:"";position:absolute;inset:0;border-radius:inherit;background:radial-gradient(ellipse at 50% 50%,rgba(255,255,255,.13) 0%,rgba(255,255,255,.08) 58%,rgba(255,255,255,.04) 100%);animation:balance-siri-base 2.7s ease-in-out infinite}
812
884
  @keyframes balance-siri-base{0%,100%{opacity:.6}50%{opacity:1}}
813
885
  /* 泛光来自三层叠加:radial-gradient 从纯白起手(0% 处 .99)、blur 把边缘彻底化开、
814
886
  再挂一圈 box-shadow 做大范围外发光。brightness 1.3 把整体顶亮一档,
815
887
  否则 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%}
888
+ .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,.74) 0%,rgba(255,255,255,.56) 26%,rgba(255,255,255,.30) 48%,rgba(255,255,255,.12) 68%,rgba(255,255,255,.02) 82%,rgba(255,255,255,0) 92%);box-shadow:0 0 14px 5px rgba(255,255,255,.15),0 0 30px 10px rgba(255,255,255,.065);filter:blur(4.2px) brightness(1.3);mix-blend-mode:screen;border-radius:52% 48% 55% 45%}
817
889
  .balance-usage-mist__orb:nth-child(1){animation:balance-siri-a 2.6s cubic-bezier(.45,.05,.55,.95) infinite alternate}
818
890
  .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
891
  .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
892
  .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
893
  .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}
894
+ /* 本会话消耗胶囊的透明度对齐「今日」那颗(2026-10-31 第九轮:「这个透明的就刚刚好 改到本会话的动画透明度」)。
895
+ 用户拿今日等待胶囊当标尺 —— 同样在 #232324 上,本会话那条明显更白更实。
896
+ 实测(动画冻结、4× 像素、取胶囊盒内):今日 peakΔ 92.1 / meanΔ 43.9;本会话 peakΔ 123.0 / meanΔ 75.4。
897
+ 把三处白色 alpha 统一乘 0.55 后:peakΔ 77.1 / meanΔ 43.9 —— 平均亮度与今日那颗完全对齐,
898
+ 亮芯只低一档(今日那颗的亮芯本来也是自己那一套 blur 光团,不必逐像素相同)。
899
+ ⚠️ 用 .balance-usage-cost 限定范围,**不能改 base 的 .balance-usage-mist / __orb**:
900
+ 那条 base 还被收起态浮窗那颗今日胶囊(13px 高、深卡片底)用着,那边是特意调亮的
901
+ (brightness 1.15 + 24×13 的芯),跟着一起压暗就丢了「深底上亮的东西在流动」。
902
+ 设置卡片里的样式预览同属「本会话消耗」,一起压,预览才不会骗人。
903
+ ⚠️ 光团那几条 alpha 在 0.55 之上**再乘了 0.8**(2026-10-31 第十三轮):X 左移之后有更多光团面积
904
+ 留在胶囊里,实测平均亮度 46.3 → 54.8(+18%)、峰值 109.4 → 124.1。乘 0.8 后回到 47.1 / 110.7,
905
+ 和原来的 46.3 / 109.4 持平 —— 这一轮只许修位置,亮度必须保持用户已经确认过的那一档。
906
+ 光团几何 / 周期 / 缓动 / 相位 / mix-blend-mode 全未动。 */
907
+ .balance-usage-cost .balance-usage-mist,.balance-settings__preview-stage .balance-usage-mist{background:rgba(255,255,255,.038)}
908
+ .balance-usage-cost .balance-usage-mist::before,.balance-settings__preview-stage .balance-usage-mist::before{background:radial-gradient(ellipse at 50% 50%,rgba(255,255,255,.072) 0%,rgba(255,255,255,.044) 58%,rgba(255,255,255,.022) 100%)}
909
+ .balance-usage-cost .balance-usage-mist__orb,.balance-settings__preview-stage .balance-usage-mist__orb{background:radial-gradient(closest-side,rgba(255,255,255,.328) 0%,rgba(255,255,255,.248) 26%,rgba(255,255,255,.136) 48%,rgba(255,255,255,.053) 68%,rgba(255,255,255,.01) 82%,rgba(255,255,255,0) 92%);box-shadow:0 0 14px 5px rgba(255,255,255,.066),0 0 30px 10px rgba(255,255,255,.029)}
822
910
  @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%}}
911
+ /* 光团的走位:**左右之外必须有上下与形变**(2026-10-31 第十二轮:「这个加载框够大了吧 能不能让光圈不只是左右晃」)。
912
+ 原来三条 keyframes 的 X 位移是 Y 的 15~25 倍(a 的 X 到 235%、Y 只有 ±11%),而 Y 的百分比
913
+ 是按**元素自身高度**算的 —— 26px 的光团 ±11% 只有 ±2.9px,被 20px 高的胶囊裁掉上下缘之后
914
+ 根本看不出来,于是整条读起来就是一条横线在平移。
915
+ 所以 Y 放到 ±18~20%(约 ±5px),scale 拆成双值(sy .76~1.34,做出"涨起来/缩回去"的呼吸),
916
+ 再加 rotate ±14deg(椭圆光团一斜,轮廓就变)—— 横向照旧流动,纵向有了涌起来的体积感。
917
+ ⚠️ 光团还要**两端对称**(2026-10-31 第十三轮:「我怎么感觉光圈全都靠左一点」):原来右侧那条
918
+ 关键帧停在 235%,光团早飞出胶囊外,只有左边的停留看得见,于是亮芯大量时间贴在某一端边缘。
919
+ 实测 65.8px 胶囊、26 帧取样:亮芯横向中位在 67% 宽处,两端亮度 3.4% / 9.1% —— 一头明显更空。
920
+ 现在三个关键帧的 X **各自 -60**(每条跨度不变,只是整体左移),亮芯中位回到 55%、两端 7.2% / 6.2%。
921
+ 周期(2.6/3.4/2.1/3.9/2.9s)、缓动、方向和相位延迟全部没动。 */
922
+ @keyframes balance-siri-a{0%{transform:translate(-60%,14%) scale(.92,.8) rotate(-9deg);border-radius:52% 48% 55% 45%}50%{transform:translate(55%,-18%) scale(1.14,1.34) rotate(9deg);border-radius:45% 55% 42% 58%}100%{transform:translate(175%,13%) scale(.94,.76) rotate(-13deg);border-radius:58% 42% 50% 50%}}
923
+ @keyframes balance-siri-b{0%{transform:translate(115%,-13%) scale(1.02,.92) rotate(10deg);border-radius:47% 53% 58% 42%}50%{transform:translate(30%,7%) scale(.94,1.18) rotate(-8deg);border-radius:56% 44% 46% 54%}100%{transform:translate(-60%,18%) scale(.88,.8) rotate(13deg);border-radius:50% 50% 43% 57%}}
924
+ @keyframes balance-siri-c{0%{transform:translate(-5%,-16%) scale(.8,.86) rotate(-14deg);border-radius:60% 40% 52% 48%}50%{transform:translate(135%,20%) scale(1.26,1.1) rotate(13deg);border-radius:44% 56% 40% 60%}100%{transform:translate(25%,9%) scale(.96,.8) rotate(-7deg);border-radius:53% 47% 57% 43%}}
826
925
  @media (prefers-reduced-motion:reduce){
827
926
  .balance-usage-mist{animation:none}
828
927
  .balance-usage-mist__orb{animation:none;transform:translate(40%,0) scale(1);opacity:.55}
@@ -834,31 +933,72 @@ window.__ModuleLoader__.load({
834
933
  ✗ 第二版「中心光团绽开 + 数字过曝回落」—— 模糊圆光叠在胶囊里发糊,用户:「太丑了」。
835
934
  所以这一版**只留几何动作,不留任何发光物**:两扇窗从正中同时向两侧滑开,数字跟着露出来。
836
935
  - clip-path 左右对称,从中缝 inset(0 50% 0 50%) 起步、满宽收尾(-4px 让最右侧字符不被切边);
837
- - 曲线 cubic-bezier(.2,.86,.24,1):起步快、尾巴稳,读起来是「被推开」而不是「被擦过」;
936
+ - 曲线 **cubic-bezier(.33,0,.67,1)**(2026-10-31 第十四轮换的近匀速)。原文是 (.2,.86,.24,1)
937
+ 「起步快、尾巴稳」,那时窗只开过数字那么宽,快起步还能读成「被推开」;现在窗钉成整颗胶囊、
938
+ 数字只占中段 79%,那条曲线 140ms 就走到 72%,数字在头 90ms 里就全露完了,剩下的行程
939
+ 只剩两侧空光在动 —— 用户反馈「展开时的动画被吞了」就是这个。换成近匀速后,数字要走到
940
+ 79% 的行程 ≈ 时序的 79%(220ms)才露全,最后 60ms 收掉两侧的光,整段都看得见在动。
941
+ 和今日那条擦出用的是同一条曲线,两处手感统一;
838
942
  - 时长 .28s,比第一版的 .22s 慢一档 —— 开窗这个动作需要看得清,扫过才需要快。
839
943
  ⚠️ 只在**数据到手那一刻**挂载(key 绑 sessionId),所以只开一次;
840
944
  之后的数值变动走内层逐位滚动,两套动效分开。
841
945
  ⚠️ keyframes 独立命名,**不复用 balance-calc-reveal**(那个是横扫、且被
842
946
  余额行的 .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}
947
+ /* ⚠️ 容器自己要有 min-width(2026-10-31 第十八轮):等待态它里面只有 __mist 一团光、
948
+ 没有 __body,宽度会塌成 padding(8px)→ 加载胶囊消失。钉成 10.2ch(content-box,
949
+ 加两侧 padding、抵掉负 margin 后外观宽 = 10.2ch + 8px,和数字态一致)。 */
950
+ .balance-usage-reveal{position:relative;display:inline-flex;align-items:center;justify-content:center;overflow:hidden;height:20px;height:1lh;padding:0 4px;margin:0 -4px;min-width:10.2ch}
844
951
  /* 底层:加载态那团光,用 mask 在**正中间挖一个椭圆洞**并随开窗扩大。
845
952
  数字在洞里显出来,光留在两侧 —— 这就是用户要的「没展开到的地方还是加载动画」,
846
953
  而数字背后是干净的(洞与数字同步扩大,光不会透到字缝里)。
847
954
  洞的边缘走 58%→100% 的渐变,所以边界是柔的,不会像 clip 那样切出一条直线。
955
+ ⚠️ inset 是 0 4px 不是 0:容器上下左右各有 4px padding(给 clip 出血用),
956
+ 而加载态那颗胶囊的宽度不含这 8px。写成 inset:0 会让揭幕这颗胶囊比加载态宽 8px,
957
+ 出数那一瞬间整颗胶囊"胖一圈"—— 和「展开量不许随数字变」是同一个诉求。
848
958
  ⚠️ 为什么用 mask 而不是两片元素拼接:两片必须 overflow:hidden 才不互相盖住,
849
959
  而一旦裁切,胶囊的圆角就被切成直角,接缝处还会出现一条硬边(三版都试过,全丑)。
850
960
  ⚠️ --balance-hole 必须用 @property 注册成 <percentage> 才能被动画插值,否则 keyframes 里
851
- 的百分比是离散跳变(0% 到 100% 一帧完成,等于没有动画)。 */
961
+ 的百分比是离散跳变(0% 到 86% 一帧完成,等于没有动画)。
962
+ ⚠️ 上面那句注释**必须留在这一个注释块里**:2026-10-31 第十四轮我在 0 4px 那句后面多写了一个
963
+ 注释结束符,把后面两段甩到注释外面,于是这条 @property 被 CSS 解析器当成非法规则整条丢掉 ——
964
+ --balance-hole 退回未注册状态,洞改成离散跳变,动画看起来就是"被吞了"。 */
852
965
  @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}
966
+ .balance-usage-reveal__mist{position:absolute;inset:0 4px;display:block;pointer-events:none;--balance-hole:86%;-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%)}
854
967
  .balance-usage-reveal__mist .balance-usage-mist{width:100%;min-width:0;height:100%}
968
+ /* ⚠️ 等待态:洞钉在 0%,也就是遮罩整块不透明=雾全可见。
969
+ 它和揭幕态是**同一个节点**(2026-10-31 第十八轮,用户:「不重播的意思是开始展开的时候
970
+ 没有被触及到的区域继续播放动画 而不是重头开始播放」)—— 出数那一帧只是把这个类摘掉、
971
+ 加挂 --enter 的开窗动画,里面 5 团光球的 keyframes 一次都没有重跑过,
972
+ 所以没被洞扫到的地方,光是接着原来那口气继续流的。 */
973
+ .balance-usage-reveal__mist--wait{--balance-hole:0%}
855
974
  @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}
975
+ /* 上层:数字本体,clip-path 从中缝向两侧打开 —— 「开窗」这个动作只发生在这一层。
976
+ ⚠️ 这一层必须**和加载胶囊一样宽**(2026-10-31 第十四轮:「展开动画要应用到整个胶囊,
977
+ 不是数字多长展开量就多长」)。原来它是 inline-flex 跟着数字走,于是胶囊和窗都只有数字那么宽:
978
+ 数字短(¥0.1234)窗就窄、数字长窗就宽,同一段 .28s 里走过的距离每次都不一样,读起来
979
+ 像是"胶囊被数字撑大"。现在钉 min-width:10.2ch(就是加载态那颗 .balance-usage-mist 的
980
+ min-width),窗永远开过整颗胶囊,数字居中待在里头 —— 顺带消掉了「加载态 66px、出数后变窄」
981
+ 那次宽度跳变。box-sizing 必须显式写(DSH 页面没有全局 border-box)。 */
982
+ .balance-usage-reveal__body{position:relative;display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;min-width:10.2ch;height:20px;height:1lh}
858
983
  @keyframes balance-window-open{0%{clip-path:inset(0 50% 0 50%)}100%{clip-path:inset(0 -4px 0 -4px)}}
984
+ /* ⚠️ 两条 animation **只在 --enter 这一档上声明**(2026-10-31 第十五轮:
985
+ 「这个动画的逻辑也跟今日消耗的逻辑一样 不要在触发展开的动画时 动画重头播放」)。
986
+ 原来它们写在元素自身规则里,于是只要节点是新建的(折叠/展开侧栏让组件重挂、mist 分支
987
+ 来回切、DSH 重渲染 composer),CSS 动画就从 0 秒重放一次 —— 用户看到的就是"开窗又重头演"。
988
+ 今日那条擦出就是这么治的:闸门类 + 模块级台账(animOnce/animRead/animWrite),
989
+ 台账在模块级,页面刷新才清空,折叠展开不重新求值、台账还在 → 不再播。
990
+ 这里同样:只有台账允许的那一次渲染才挂 --enter;播完就摘(声明整个消失,节点不动,
991
+ 落在与动画终点完全一致的静止态 —— mist 洞开到底 86%、body 不裁切)。 */
992
+ .balance-usage-reveal--enter .balance-usage-reveal__mist{animation:balance-mist-hole .28s cubic-bezier(.33,0,.67,1) both}
993
+ .balance-usage-reveal--enter .balance-usage-reveal__body{animation:balance-window-open .28s cubic-bezier(.33,0,.67,1) both}
859
994
  @media (prefers-reduced-motion:reduce){
995
+ .balance-usage-reveal--enter .balance-usage-reveal__body,
860
996
  .balance-usage-reveal__body{animation:none;clip-path:none}
861
- .balance-usage-reveal__mist{display:none}
997
+ /* ⚠️ 只隐藏揭幕态的那层光(它存在的意义就是"扫过时还留着的加载动画")。
998
+ 等待态(--wait)那层光就是加载动画本身,2026-10-31 第十八轮合并成同一棵树之后
999
+ 这里如果不加 :not(),系统开了"减少动态效果"的用户会连加载胶囊都看不到,只剩空白。 */
1000
+ .balance-usage-reveal__mist:not(.balance-usage-reveal__mist--wait),
1001
+ .balance-usage-reveal--enter .balance-usage-reveal__mist{display:none}
862
1002
  }
863
1003
  .balance-usage-cost-inner{display:inline-flex;align-items:center;white-space:nowrap;height:20px;height:1lh}
864
1004
  /* ≈ 的视觉修正:字形本身偏低,用 em 单位上提(跟随字号缩放),右侧留一点呼吸缝。
@@ -883,7 +1023,7 @@ window.__ModuleLoader__.load({
883
1023
  位置在 composer 输入框内部(conversation.composer.bar),排版对齐 DSH 原生模型名:
884
1024
  12px、次要色、单行、行高紧凑,不额外撑高输入卡片。
885
1025
  不要写 position:absolute / fixed —— 它现在是 composer 里的正常流内元素,
886
- 由 DSH 的 flex 布局排布;一旦脱离流就会和输入区重叠,正是要避免的事。 */
1026
+ 由 DSH 的 flex 布局排布;一旦脱离流就会和输入区重叠,正是要避免的事。
887
1027
 
888
1028
  收起态的 .balance-fab / 统计面板锚点**永远**浮在 body 上:那一刻 DSH 已经把整个
889
1029
  侧栏 footArea 设成 display:none,所以它没有被侧栏收纳的可能,和 composer 恒处同一
@@ -1107,18 +1247,22 @@ window.__ModuleLoader__.load({
1107
1247
  /* 「消耗行加载动效」那一行的实时预览(2026-10-31 用户要求:「在介绍栏预览给用户看红圈位置」)。
1108
1248
  直接复用消耗行的真实类名(.balance-usage-mist / .balance-usage-calc),
1109
1249
  所以这里看到的就是输入框下方真会出现的那个效果 —— 两边永远不会走样,改一处两边一起变。
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))}
1250
+ **只预览当前选择的那一个**(2026-10-31 第十三轮:「只预览当前选择的样式」):
1251
+ 并排播两个会让人以为「两个都生效」,而右边分段控件已经标出选中的是哪个,预览只负责「长什么样」。
1252
+ 环形高亮保留 —— 它读起来就是「这就是你现在的选择」。
1253
+ stage 的 min-width 钉住两种样式的宽度差(计算中那颗比光胶囊宽),切样式时右侧按钮不会左右挪。
1254
+ 窄屏整块隐藏:640px 以下 label 已经被挤得很窄,再加预览会把文字压断。 */
1255
+ .balance-settings__preview{display:flex;align-items:center;margin-left:auto;margin-right:8px;flex:none}
1256
+ .balance-settings__preview-item{display:flex;flex-direction:column;align-items:center;gap:4px;padding:5px 7px;border-radius:8px;border:.5px solid color-mix(in srgb,#4d6bfe 55%,transparent);background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.08))}
1115
1257
  .balance-settings__preview-tag{font-size:9.5px;line-height:1;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
1116
1258
  /* 预览里要把「本会话消耗」这几个字一起画出来 —— 只放一条光带的话看不出这是哪一行,
1117
1259
  真实的那颗胶囊就是「本会话消耗 + 效果」,预览得对得上(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}
1260
+ 胶囊宽度取 72px:真身是 min-width:10.2ch(13px 字号下约 73px),预览要是收成 44px,
1261
+ 光团被裁得更狠、纵向那点涌动就看不出来了 —— 预览跟着真身的尺寸走才不会骗人。
1262
+ stage 的 min-width 钉住两种样式的宽度差(「计算中」那颗比光胶囊宽),切样式时右侧按钮不会左右挪。 */
1263
+ .balance-settings__preview-stage{position:relative;display:inline-flex;align-items:center;gap:5px;height:20px;min-width:130px}
1120
1264
  .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}
1265
+ .balance-settings__preview-stage .balance-usage-mist{min-width:0;width:72px}
1122
1266
  /* .balance-usage-calc 本身只定了尺寸和圆角,字号/行高/nowrap 是从消耗行的 .balance-usage-line 继承来的
1123
1267
  —— 预览里没有那层,不补这三行「计算中」会折成「计算 / 中」两行并撑破胶囊。 */
1124
1268
  .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}
@@ -1368,7 +1512,13 @@ window.__ModuleLoader__.load({
1368
1512
  return model;
1369
1513
  }
1370
1514
  /** 通过官方 sessions RPC 解析某会话使用的模型,缓存结果并广播;RPC 拿不到时回退到服务端 detectModel。
1371
- * 只缓存"取到模型"的结果;取到空串不缓存,以便会话后续可能产出模型时能重新解析。 */
1515
+ * 只缓存"取到模型"的结果;取到空串不缓存,以便会话后续可能产出模型时能重新解析。
1516
+ * ⚠️ 空结果必须节流(2026-10-31 第二十二轮审计):调用方是 `usageKey` 依赖的 effect,
1517
+ * 而 usageKey 在流式输出时每个 chunk 都变。取不到模型时不写缓存 → 每个 chunk 都会走一次
1518
+ * sessionsFace.models() RPC(IPC,不是 HTTP,探针抓不到)+ 一条 fetchServerModel 的 ?session=。
1519
+ * serverModelProbeAt 只挡了后者。这里对"没取到"也记时间,15 秒内不再重试。 */
1520
+ const modelMissAt = new Map(); // sessionId -> 上次取到空模型的时刻
1521
+ const MODEL_MISS_RETRY_MS = 15000;
1372
1522
  async function resolveSessionModel(sessionId) {
1373
1523
  if (typeof sessionId !== "string" || sessionId === "") return "";
1374
1524
  if (modelCache.has(sessionId)) {
@@ -1376,6 +1526,7 @@ window.__ModuleLoader__.load({
1376
1526
  if (cached !== "") setCurrentModel(cached);
1377
1527
  return cached;
1378
1528
  }
1529
+ if (Date.now() - (modelMissAt.get(sessionId) ?? 0) < MODEL_MISS_RETRY_MS) return "";
1379
1530
  let model = "";
1380
1531
  if (sessionsFace !== void 0) {
1381
1532
  try {
@@ -1387,8 +1538,15 @@ window.__ModuleLoader__.load({
1387
1538
  }
1388
1539
  // 回退:服务端 /balance-api?session= 已检测实际模型(遍历会话事件)
1389
1540
  if (model === "") model = await fetchServerModel(sessionId);
1390
- if (model !== "") modelCache.set(sessionId, model);
1391
- if (model !== "") setCurrentModel(model);
1541
+ if (model !== "") {
1542
+ modelCache.set(sessionId, model);
1543
+ capSessions(modelCache);
1544
+ modelMissAt.delete(sessionId);
1545
+ setCurrentModel(model);
1546
+ } else {
1547
+ modelMissAt.set(sessionId, Date.now());
1548
+ capSessions(modelMissAt);
1549
+ }
1392
1550
  return model;
1393
1551
  }
1394
1552
  function balanceApiUrl() {
@@ -1429,6 +1587,17 @@ window.__ModuleLoader__.load({
1429
1587
  }
1430
1588
  }
1431
1589
 
1590
+ // 按会话增长的表一律夹住上限(2026-10-31 第二十二轮审计):Map 保持插入序,
1591
+ // 超了就淘汰最旧的键。长命页面里访问几百个会话也只是几十 KB,但没必要留着。
1592
+ const CACHE_MAX_SESSIONS = 64;
1593
+ function capSessions(map, max = CACHE_MAX_SESSIONS) {
1594
+ while (map.size > max) {
1595
+ const oldest = map.keys().next();
1596
+ if (oldest.done) return;
1597
+ map.delete(oldest.value);
1598
+ }
1599
+ }
1600
+
1432
1601
  /** 请求服务端按会话检测模型(等价于 /balance-api?session= 返回的 model 字段),失败返回 ""。
1433
1602
  * **同一会话 15 秒内最多问一次**:模型探测不出来时(新会话、空会话)上层会不停重试,
1434
1603
  * 而这条请求会让服务端解压整份 zstd —— 不设闸门就是灾难(2026-10-31)。 */
@@ -1439,6 +1608,7 @@ window.__ModuleLoader__.load({
1439
1608
  const last = serverModelProbeAt.get(sessionId) ?? 0;
1440
1609
  if (now - last < 15000) return "";
1441
1610
  serverModelProbeAt.set(sessionId, now);
1611
+ capSessions(serverModelProbeAt);
1442
1612
  try {
1443
1613
  const res = await fetch("/balance-api?session=" + encodeURIComponent(sessionId));
1444
1614
  if (!res.ok) return "";
@@ -2607,9 +2777,26 @@ const CN_CALENDAR_SOURCES = [
2607
2777
  clearInterval(balancePollTimer);
2608
2778
  balancePollTimer = null;
2609
2779
  }
2780
+ // ⚠️ 跨模块实例的单例(2026-10-31 第二十二轮审计):插件热重载会换一份 client.js,
2781
+ // 旧实例的 interval 没人清,于是每 60 秒打两次 /balance-api(一次 419ms 的真查询)。
2782
+ if (globalThis.__balancePollTimer !== null && globalThis.__balancePollTimer !== undefined) {
2783
+ clearInterval(globalThis.__balancePollTimer);
2784
+ globalThis.__balancePollTimer = null;
2785
+ }
2610
2786
  balancePollIntervalSec = sec;
2611
2787
  fetchBalanceOnce();
2612
- balancePollTimer = setInterval(fetchBalanceOnce, sec * 1000);
2788
+ const tick = () => {
2789
+ // 没有任何界面在听(组件全卸载)就别再打官方接口;顺手把定时器收掉
2790
+ if (balanceListeners.size === 0) {
2791
+ if (balancePollTimer !== null) clearInterval(balancePollTimer);
2792
+ balancePollTimer = null;
2793
+ globalThis.__balancePollTimer = null;
2794
+ return;
2795
+ }
2796
+ fetchBalanceOnce();
2797
+ };
2798
+ balancePollTimer = setInterval(tick, sec * 1000);
2799
+ globalThis.__balancePollTimer = balancePollTimer;
2613
2800
  }
2614
2801
 
2615
2802
  function useBalance() {
@@ -3337,6 +3524,28 @@ function TimelineBand(props) {
3337
3524
  return jsx.jsx("span", { className: "balance-footer__timeline-clip", children: layers });
3338
3525
  }
3339
3526
 
3527
+ /* 今日等待胶囊两层光团的**起始相位**:每次挂载现取一次随机。
3528
+ 为什么必须随机:两层光团的 animation-delay 原来写死 -.5s / -1.1s,于是每次重挂
3529
+ (展开侧栏 / 切会话 / 重开 DSH)第一帧都落在同一姿态,B 那团 .72 的亮芯正好被
3530
+ 推到右下角 —— 用户 2026-10-31:「每次开始动画的时候 都是右下角发白」。
3531
+ 而且挂载→出值→擦出 这条时序本身是确定的(余额就绪后 +560ms 发请求、约 400ms 回),
3532
+ 所以连擦出那一瞬间的光团相位每次也基本一致,两次录像截出来能一模一样。
3533
+ 取随机相位后整条时序被去同步,各次不再复读。
3534
+ ⚠️ 必须用 useState 的初始化函数取值 —— 每次挂载算一次。放在渲染体里直接
3535
+ Math.random() 会让每次余额轮询重渲染都换一次相位,光团原地乱跳。 */
3536
+ const TODAY_FLOW_PERIOD_A_MS = 1400;
3537
+ const TODAY_FLOW_PERIOD_B_MS = 2200;
3538
+ function makeTodayFlowStyle() {
3539
+ // 只在周期的 20%~80% 里取:两端是 ease-in-out 速度趋 0 的位置,
3540
+ // 落在那里会重演「刚加载出来看着没在动」。
3541
+ const fracA = 0.2 + Math.random() * 0.6;
3542
+ const fracB = 0.2 + Math.random() * 0.6;
3543
+ return {
3544
+ "--today-flow-a": "-" + (fracA * TODAY_FLOW_PERIOD_A_MS / 1000).toFixed(3) + "s",
3545
+ "--today-flow-b": "-" + (fracB * TODAY_FLOW_PERIOD_B_MS / 1000).toFixed(3) + "s",
3546
+ };
3547
+ }
3548
+
3340
3549
  function BalanceFooter({ wide }) {
3341
3550
  const state = useBalance();
3342
3551
  const settings = useSettings();
@@ -3352,6 +3561,9 @@ function TimelineBand(props) {
3352
3561
  // 显示精度随额度分档(<1 → 4 位 … ≥1000 → 0 位),见 todayDecimals。
3353
3562
  const todayText = todayCost === null ? null : todayCost.toFixed(todayDecimals(todayCost));
3354
3563
  const [todayRoll, setTodayRoll] = react.useState(null);
3564
+ // 今日等待胶囊的光团起始相位:**每次挂载随机一次**(见 makeTodayFlowStyle 的注释)。
3565
+ // 不能用 useRef 装 —— 那样每次渲染都会重新算;useState 的初始化函数只跑一次。
3566
+ const [todayFlowStyle] = react.useState(makeTodayFlowStyle);
3355
3567
  const lastTodayTextRef = react.useRef(null);
3356
3568
  usePrePaintEffect(() => {
3357
3569
  const prev = lastTodayTextRef.current;
@@ -3425,7 +3637,7 @@ function TimelineBand(props) {
3425
3637
  setRevealSeq(0);
3426
3638
  setWaveToken(0);
3427
3639
  setRoll(null);
3428
- setTodayWipeSeq(0);
3640
+ setTodayWipe({ seq: 0, done: true });
3429
3641
  setTodayRoll(null);
3430
3642
  setEnterUp(false);
3431
3643
  }, []);
@@ -3465,13 +3677,30 @@ function TimelineBand(props) {
3465
3677
  // 现在用一个序号:todayText 每变一次 +1,序号即 key,只有它变了才重新挂载播动画。
3466
3678
  // 展开侧栏时序号不变 → 本来不会重播;再叠上下面的可见性清零(display:none
3467
3679
  // 的元素重新显示时 CSS 动画会从 0 秒重播)就彻底安全。
3468
- const [todayWipeSeq, setTodayWipeSeq] = react.useState(0);
3680
+ // 擦出序号 + 「这次擦出播完了没有」,**必须是同一个 state**(2026-10-31 第三轮:
3681
+ // 「今日加载的动画 未扫过的区域动画不要初始化 继续播放」)。
3682
+ // 拆成两个 state 会漏出一整帧:todayText 变成值的那个 commit 里,todayWipeDone
3683
+ // 还是上一轮的 true,而雾的卸载条件(todayCost !== null && todayWipeDone)当场成立
3684
+ // → 雾被卸载;等 effect 再把 done 置 false,下一次渲染才把它重新挂上去。
3685
+ // 于是右半边(没扫到的那块)的加载动画是**全新插入的 DOM**,
3686
+ // ::before / ::after 两层 radial 光团的 CSS 动画从 0 秒重播 —— 用户看到的就是
3687
+ // 「未扫过的区域闪回初始形状」。合并成一个对象后,「新值 + 未播完」在同一次
3688
+ // setState 里一起生效,DOM 节点自始至终没离开过,动画一路接着播。
3689
+ const [todayWipe, setTodayWipe] = react.useState({ seq: 0, done: true });
3690
+ // 拆成两个局部量只是给下面所有读点(含 FAB 与各条 selftest 断言)保留原名。
3691
+ const todayWipeSeq = todayWipe.seq;
3692
+ const todayWipeDone = todayWipe.done;
3469
3693
  // 「这次擦出播完了没有」。它在擦出期间必须是 false —— 因为 mist(加载动画)
3470
3694
  // 要靠它决定何时卸载:用户 2026-10-31 明确要的效果是
3471
3695
  // 「只显示扫过区域的数字,没扫到的还是原来的加载动画」,
3472
3696
  // 也就是擦出过程中 mist 必须还在、盖住右半边;扫完才撤。
3473
- const [todayWipeDone, setTodayWipeDone] = react.useState(true);
3474
- react.useEffect(() => {
3697
+ // done 与 seq 同一个 state,见上面的注释。
3698
+ // ⚠️ 必须是 paint 之前跑的 effect(usePrePaintEffect,2026-10-31 第十六轮)。
3699
+ // 用 react.useEffect 的话它在 paint 之后才跑,于是"值到手"那一帧先被画出来是
3700
+ // done=true 的状态:雾层拿到 --clear(全透明),下一帧才翻成 --wipe 从 0% 重新盖住 ——
3701
+ // 肉眼就是「数字先闪现一帧、然后被雾盖住再扫一遍」。挪到 paint 前,这一次 commit
3702
+ // 重渲染完才出图,第一张图就是雾已经盖好的擦出首帧。
3703
+ usePrePaintEffect(() => {
3475
3704
  if (todayText === null) {
3476
3705
  animWrite("today-last", null);
3477
3706
  // 回到等待态 = 下一次出值要重新擦一遍。
@@ -3494,12 +3723,14 @@ function TimelineBand(props) {
3494
3723
  // · 看「等待态出口」→ 每次从 null 到有值都擦一次,纯数值刷新只滚动。这才是对的。
3495
3724
  if (animRead("today-pending") !== true) return;
3496
3725
  animWrite("today-pending", false);
3497
- setTodayWipeSeq((n) => n + 1);
3498
- // ⚠️ 这里**不要**再 setTodayWipeDone(false) 或开计时器(2026-10-31 审查发现的高危 bug)。
3499
- // 它们原来写在这个 effect 里,cleanup 是 clearTimeout(id)。可这个 effect 的依赖是
3726
+ // ⚠️ seq 和 done 必须**在同一次 setState 里**一起翻,这是「未扫过的区域不要
3727
+ // 初始化」的关键:分开写就会漏出「done 还是 true」的那一帧,雾被卸载又重挂。
3728
+ setTodayWipe((w) => ({ seq: w.seq + 1, done: false }));
3729
+ // ⚠️ 这里**不要**开计时器(2026-10-31 审查发现的高危 bug)。
3730
+ // 计时器原来写在这个 effect 里,cleanup 是 clearTimeout(id)。可这个 effect 的依赖是
3500
3731
  // todayText —— 今日是每几秒自动补拉的活数据,只要补拉在 300ms 内再来一次,
3501
3732
  // cleanup 就会把计时器取消,而本 effect 因为 today-pending 已经翻成 false 会直接
3502
- // return、不再补一个新的。结果 todayWipeDone 永久停在 false:雾永远盖着、数字再也
3733
+ // return、不再补一个新的。结果 done 永久停在 false:雾永远盖着、数字再也
3503
3734
  // 看不见,且不会自愈(后续任何一次刷新都走的是同一条 return 路径)。
3504
3735
  // 现在只负责递增序号,由下面那个依赖 todayWipeSeq 的 effect 独占计时器 ——
3505
3736
  // 它的 cleanup 只在下一次真的重新擦出时执行,普通数值刷新碰不到它。
@@ -3508,8 +3739,7 @@ function TimelineBand(props) {
3508
3739
  // 260ms 是 CSS 那条动画的时长,+40ms 余量;早了露空白、晚了雾多挡一截。
3509
3740
  react.useEffect(() => {
3510
3741
  if (todayWipeSeq === 0) return undefined;
3511
- setTodayWipeDone(false);
3512
- const id = setTimeout(() => setTodayWipeDone(true), 300);
3742
+ const id = setTimeout(() => setTodayWipe((w) => ({ seq: w.seq, done: true })), 300);
3513
3743
  return () => clearTimeout(id);
3514
3744
  }, [todayWipeSeq]);
3515
3745
  // ⚠️ 滚动结束后**不要**把 todayRoll 置回 null(用户 2026-10-31:「被滚动的数字
@@ -4144,7 +4374,9 @@ function TimelineBand(props) {
4144
4374
  // 常驻会一直盖着数字。
4145
4375
  todayWipeSeq > 0 && !todayWipeDone
4146
4376
  ? jsx.jsx("span", {
4147
- className: "balance-footer__today-reveal__mist balance-fab__today-wipe-mist",
4377
+ // 收起态只在擦出窗口内渲染这一层,所以直接挂 --wipe
4378
+ // (动画声明在修饰类上,见上方 .balance-footer__today-reveal__mist--wipe)
4379
+ className: "balance-footer__today-reveal__mist balance-footer__today-reveal__mist--wipe balance-fab__today-wipe-mist",
4148
4380
  children: jsx.jsx("span", {
4149
4381
  className: "balance-usage-mist",
4150
4382
  children: ORB_INDICES.map((i) => jsx.jsx("span", { key: "orb-" + i, className: "balance-usage-mist__orb" })),
@@ -4369,46 +4601,58 @@ function TimelineBand(props) {
4369
4601
  key: "reveal",
4370
4602
  className: "balance-footer__today-reveal",
4371
4603
  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
- }),
4604
+ // mist **永久常驻,不再按条件卸载**(2026-10-31 第四轮:
4605
+ // 「今日加载的动画 未扫过的区域动画不要初始化 继续播放」)。
4606
+ // 原来写的是 `todayCost !== null && todayWipeDone ? null : <mist>`,
4607
+ // 但 todayWipeDone 只能在 effect / 定时器里翻,而 effect 跑在**渲染之后** ——
4608
+ // 于是「值到手」的那一帧 done 还是 true,mist 当场被卸载,下一帧再挂回来。
4609
+ // 这一来一回是两个不同的 DOM 节点,::before / ::after 两层 radial 光团的
4610
+ // CSS 动画从 0 秒重播 —— 没扫到的右半边"闪回初始形状",正是用户说的"初始化"。
4611
+ // 现在干脆不卸载:`.balance-footer__today-reveal__mist` 的遮罩动画
4612
+ // (--today-hole 0% → 100%)播完后整层就是全透明,本来就看不见了,
4613
+ // 根本不需要摘 DOM。元素自始至终是同一个,光团动画一路接着播。
4614
+ // 回到等待态时只是这个类被去掉(inner 没有蒙版、恢复可见),节点依然不动。
4615
+ jsx.jsx("span", {
4616
+ key: "mist",
4617
+ // ⚠️ 必须同时挂 balance-footer__today-wait:
4618
+ // 今日那套"11px 胶囊 + 两层 radial 光团 + 毛玻璃"的样式全部写在
4619
+ // .balance-footer__today-wait .balance-usage-mist 选择器下面,
4620
+ // 漏了这个父类,里面的 .balance-usage-mist 就只剩消耗行那套默认样式,
4621
+ // 而今日的雾是靠 ::before/::after 两层光团实现的 —— 结果就是完全透明,
4622
+ // 看着像"mist 根本没渲染"(2026-10-31 实测踩过)。
4623
+ className: "balance-footer__today-wait"
4624
+ + (todayCost === null ? "" : " balance-footer__today-reveal__mist")
4625
+ // 擦出窗口内挂 --wipe(动画从 0 起播);已经擦完则挂 --clear
4626
+ // (钉在洞开到底的透明态,且没有任何 animation 声明)。
4627
+ // 两个都不挂 ⇒ 等待态,雾整块可见。
4628
+ // ⚠️ 判据只看 done、不看 seq:值到手那一帧就必须已经是 --wipe,
4629
+ // 否则会漏出"雾先透明一帧、再被盖住扫一遍"的闪烁。
4630
+ + (todayCost === null ? "" : (todayWipeDone
4631
+ ? " balance-footer__today-reveal__mist--clear"
4632
+ : " balance-footer__today-reveal__mist--wipe")),
4633
+ "aria-hidden": "true",
4634
+ // 光团起始相位(每次挂载随机一次),把 CSS 里写死的 -.5s / -1.1s 换成变量
4635
+ style: todayFlowStyle,
4636
+ children: todayLoadingIsText
4637
+ ? jsx.jsx("span", {
4638
+ className: "balance-usage-calc",
4639
+ children: [
4640
+ "计算中",
4641
+ jsx.jsx("span", {
4642
+ className: "balance-usage-calc__dots",
4643
+ "aria-hidden": "true",
4644
+ children: [0, 1, 2].map((i) => jsx.jsx("span", { className: "balance-usage-calc__dot", children: "." }, i)),
4645
+ }, "dots"),
4646
+ ],
4647
+ })
4648
+ : jsx.jsx("span", {
4649
+ className: "balance-usage-mist",
4650
+ children: [
4651
+ ...ORB_INDICES.map((i) => jsx.jsx("span", { key: "orb-" + i, className: "balance-usage-mist__orb" })),
4652
+ jsx.jsx("span", { className: "balance-footer__today-fog" }, "fog"),
4653
+ ],
4654
+ }),
4655
+ }),
4412
4656
  todayCost === null
4413
4657
  ? null
4414
4658
  : jsx.jsx("span", {
@@ -4636,7 +4880,9 @@ function UsageCostLine({ useProjection, sessionId }) {
4636
4880
  usageStampRef.current.sessionId !== sessionId && usageStampRef.current.usageKey === usageKey;
4637
4881
  react.useEffect(() => {
4638
4882
  usageStampRef.current = { sessionId, usageKey };
4639
- });
4883
+ // ⚠️ 必须带依赖(2026-10-31 第二十二轮审计):原来没有依赖数组,
4884
+ // 每个 chunk / 输入框每次按键引起的重渲染都会跑一遍并新建对象。
4885
+ }, [sessionId, usageKey]);
4640
4886
 
4641
4887
  // 解析本会话使用的模型,并按模型刷新定价(/balance-api?model=...)
4642
4888
  //
@@ -4683,6 +4929,41 @@ function UsageCostLine({ useProjection, sessionId }) {
4683
4929
  // · 已问过(流式进行中的反复变化)→ 600ms **debounce**:每次变化都 clearTimeout
4684
4930
  // 重排,流式期间一个包都不发,等输出停下来才问一次。
4685
4931
  // 不能简单把延迟调到 600ms —— 那会把首屏也一起推迟半秒(正是上一轮犯过的错)。
4932
+ // ── 进场:每「换进一个对话」算一次新进场 ────────────────────────────────
4933
+ // 用户 2026-10-31 第十八轮:「怎么点击过的对话只有展开动画 没有加载动画了」。
4934
+ // 之前把「出过数」「开过窗」都记成整页生命周期内的永久事实,于是点回任何看过的对话
4935
+ // 都直接显示旧数字、一个动画都没有。真正要挡的只有一件事:
4936
+ // **同一个对话内部的重挂/重渲染不许把动画从头再放一遍**(折叠展开侧栏就属于这种)。
4937
+ // 所以拿模块级台账记「当前这个对话是第几次进场」,判据是「会话 id 换了没有」:
4938
+ // · 点进另一个对话(含点回看过的)→ 改台账 + 清掉该会话的就绪/开窗记忆 → 重新进场;
4939
+ // · 同一对话里重挂(侧栏折叠展开、DSH 重渲染 composer)→ 台账不变 → 什么都不重放。
4940
+ // ⚠️ 这个 effect 必须声明在下面那个 ?session= 请求 effect **之前**:
4941
+ // 进场要把「同一 usageKey 不再问」的去重记录清掉,让请求真发出去 —— 否则请求被去重跳过、
4942
+ // 数据永远是缓存里那一份,"加载动画"就永远等不到它该等的东西。
4943
+ const readySessions = (() => {
4944
+ const cur = animRead("cost-ready-sessions");
4945
+ if (cur instanceof Set) return cur;
4946
+ const set = new Set();
4947
+ animWrite("cost-ready-sessions", set);
4948
+ return set;
4949
+ })();
4950
+ const costEpisodePrev = animRead("cost-episode");
4951
+ const costEpisodeFresh = sessionId != null && sessionId !== "" && costEpisodePrev !== sessionId;
4952
+ const splitNow = sessionId ? splitMap[sessionId] || null : null;
4953
+ // 「这次进场开始时手上那份 split」。等它被新数据换掉,就说明这一次的加载结束了。
4954
+ // 用它而不是计时器:缓存命中时也能在响应回来那一刻准时收雾,不靠猜时间。
4955
+ const [costEpisodeFrom, setCostEpisodeFrom] = react.useState(splitNow);
4956
+ usePrePaintEffect(() => {
4957
+ if (!costEpisodeFresh) return;
4958
+ animWrite("cost-episode", sessionId);
4959
+ // 这一次进场要重新演:迷雾 + 开窗都得重现。
4960
+ readySessions.delete(sessionId);
4961
+ animWrite("cost-reveal-played", "");
4962
+ // 清掉去重记录 → 下面那个 effect 会真的发一次 ?session=(服务端有指纹缓存,没变就是纯读)。
4963
+ const askedMap = globalThis.__balanceUsageAsked;
4964
+ if (askedMap instanceof Map) askedMap.delete(sessionId);
4965
+ setCostEpisodeFrom(splitNow);
4966
+ }, [costEpisodeFresh, sessionId, splitNow]);
4686
4967
  react.useEffect(() => {
4687
4968
  if (usageKey === 0) return;
4688
4969
  // ⚠️ 「这个会话的用量在第几版 token 数上问过」——记在**模块级**,不随组件卸载丢失。
@@ -4832,6 +5113,32 @@ function UsageCostLine({ useProjection, sessionId }) {
4832
5113
  const splitAsked =
4833
5114
  sessionId != null && sessionId !== "" && Object.prototype.hasOwnProperty.call(splitMap, sessionId);
4834
5115
  const costPending = !splitAsked && usageKey > 0;
5116
+ // ⚠️ 这一行必须声明在下面那个 shouldMarkReady 的 **deps 数组**之前(2026-10-31 第十九轮:
5117
+ // 「我的消耗呢」—— 整行消失)。上一版把它写在 effect 之后,React 求值 deps 数组时
5118
+ // 这个 const 还没初始化 → `Cannot access 'costEpisodeAwaiting' before initialization`
5119
+ // → 组件抛错 → DSH 把这个 slot 整块吞掉,界面上连「本会话消耗」几个字都没有。
5120
+ // 判据是 split 对象的**身份**:进场时记下手上那份缓存,新响应会写进一个新对象,
5121
+ // 一旦换了身份就说明这一次真的问完了 → 收雾、接着播开窗。
5122
+ // ⚠️ 判据里**必须含 costEpisodeFresh**(2026-10-31 第十九轮:「顺序错了…先展开然后再加载」)。
5123
+ // 切会话那一帧 `costEpisodeFrom` 还停在上一个对话的对象上,只比身份会算出「不在等待」→
5124
+ // 先渲染出数字并把开窗授权领掉,下一帧才变成迷雾 —— 顺序整个反过来。
5125
+ // 进场那一帧一律算等待;等 `costEpisodeFrom` 落成本次进场起点之后,再由身份判断接管。
5126
+ // ⚠️ 必须带一条**超时兜底**(2026-10-31 第二十一轮审计):
5127
+ // 判断"数据回来了"靠 split 对象换身份,可请求失败时 catch 分支只在「表里没有该会话」时
5128
+ // 才写占位;点回一个**看过的**对话、而那次请求失败 → 对象永远不换 → 迷雾永远不收,
5129
+ // 界面上就是一直转圈、数字再也不出现。4 秒后放弃等待,退回缓存里那个数字。
5130
+ const [costEpisodeExpired, setCostEpisodeExpired] = react.useState(false);
5131
+ usePrePaintEffect(() => {
5132
+ if (!costEpisodeFresh) return;
5133
+ setCostEpisodeExpired(false);
5134
+ }, [costEpisodeFresh]);
5135
+ // ⚠️ `costEpisodeFresh` 必须在 expired 之外单独判(2026-10-31 第二十二轮审计)。
5136
+ // 上一趟进场超时过(expired=true)之后,再切进任何对话:首个 commit 里 expired 还是 true,
5137
+ // 若它参与进来,这一帧就不是「等待」→ 先渲染数字、当场把开窗授权领掉,layout effect
5138
+ // 才把 expired 翻回 false,之后才进迷雾 —— 数据回来时授权已用完,数字凭空跳出。
5139
+ // 进场那一帧永远算等待;超时只用来结束「同一次进场里」的等待。
5140
+ const costEpisodeAwaiting = sessionId != null && sessionId !== ""
5141
+ && (costEpisodeFresh || (!costEpisodeExpired && costEpisodeFrom === splitFor));
4835
5142
 
4836
5143
  // 「这个会话已经出过数字了」—— 出过就再也不显示加载动效。
4837
5144
  // 用户 2026-10-31:「如果对话在工作时,实时的对话消耗会导致会话加载动效重新播放,这样看起来很割裂」。
@@ -4841,8 +5148,19 @@ function UsageCostLine({ useProjection, sessionId }) {
4841
5148
  // 同一会话内此后只走数字/滚动,绝不再闪迷雾;换会话仍然是新会话、照常首次加载。
4842
5149
  const readySessionsRef = react.useRef(null);
4843
5150
  if (readySessionsRef.current === null) readySessionsRef.current = new Set();
4844
- const readySessions = readySessionsRef.current;
4845
- const sessionReady = sessionId != null && sessionId !== "" && readySessions.has(sessionId);
5151
+ // ⚠️ 「本会话消耗已经出过数」的记忆必须是**模块级**的(2026-10-31 第十五轮)。
5152
+ // 原来这里是 per-instance 的 ref,而折叠/展开侧栏会让 BalanceFooter 卸载重挂、ref 归零 →
5153
+ // sessionReady 变回 false → 下一次 staleUsage 就把迷雾态重新插进 DOM,开窗也跟着从 0 重播。
5154
+ // 复用今日那本 animLedger(模块级 Map,页面刷新/插件重载才清空)。
5155
+ // 这个 Set 本身在「进场」那一段就取出来了(进场要能清掉它),这里只把 ref 接上。
5156
+ readySessionsRef.current = readySessions;
5157
+ // ⚠️ `!costEpisodeFresh` 不能省(2026-10-31 第二十轮:「先是展开 然后什么都不显示 然后加载」)。
5158
+ // 切到一个**看过的**对话时,readySessions 里还有它 —— 进场那一帧 `sessionReady` 若为 true,
5159
+ // 迷雾就被整个跳过:先渲染出数字、把开窗授权领掉(用户看到的第一帧是展开),
5160
+ // 紧接着进场逻辑把它删掉、下一帧才切成迷雾,数据回来时授权已用完 → 数字直接跳出来。
5161
+ // 进场那一帧一律按「还没就绪」算,等这次进场的数据到了再记就绪。
5162
+ const sessionReady = sessionId != null && sessionId !== ""
5163
+ && !costEpisodeFresh && readySessions.has(sessionId);
4846
5164
  const shouldMarkReady = !costPending && splitAsked && usageKey > 0 && sessionId != null && sessionId !== "";
4847
5165
  // ⚠️ 「这个会话已经出过数」的记忆**不许在渲染期写**(2026-10-31 全量审查)。
4848
5166
  // 渲染期改 ref 在 StrictMode 双调用 / 并发渲染下会写进一次被丢弃的渲染里,
@@ -4851,11 +5169,34 @@ function UsageCostLine({ useProjection, sessionId }) {
4851
5169
  // 数据到手那一帧 showCostLoading 已经是 false(costPending/staleUsage 都为假),
4852
5170
  // 所以「什么时候开始记忆」没有任何可见差异,只是挪出了渲染期。
4853
5171
  usePrePaintEffect(() => {
4854
- if (!shouldMarkReady) return;
5172
+ // ⚠️ 进场等待期内不许记账(2026-10-31 第十八轮)。缓存命中时 shouldMarkReady 立刻为真,
5173
+ // 而进场那一段刚把该会话从 Set 里删掉 —— 这里要是马上加回去,sessionReady 立刻变 true,
5174
+ // 迷雾就被掐掉,又变成「点回看过的对话没有加载动画」。
5175
+ // 两个条件都要:costEpisodeFresh 是「进场那一帧」(此刻 episodeFrom 还没设好,
5176
+ // costEpisodeAwaiting 可能是 false,光靠它挡不住),costEpisodeAwaiting 是「还在等数据」。
5177
+ if (!shouldMarkReady || costEpisodeAwaiting || costEpisodeFresh) return;
4855
5178
  readySessions.add(sessionId);
4856
- }, [shouldMarkReady, sessionId]);
5179
+ // 长命页面里访问过的会话只会越攒越多(第二十二轮审计):夹住上限,淘汰最早记的。
5180
+ while (readySessions.size > 64) {
5181
+ const oldest = readySessions.values().next();
5182
+ if (oldest.done) break;
5183
+ readySessions.delete(oldest.value);
5184
+ }
5185
+ }, [shouldMarkReady, sessionId, costEpisodeAwaiting, costEpisodeFresh]);
4857
5186
  // 只有「这个会话从来没出过数」才播加载动效;已出过数的会话,刷新期间保持旧数字不动。
4858
- const showCostLoading = (staleUsage || costPending) && !sessionReady;
5187
+ // ⚠️ 还要并上「本次进场的数据还没回来」(2026-10-31 第十八轮:点回看过的对话要有加载动画)。
5188
+ // 缓存命中时不猜时间,靠响应到位的这一刻切换,所以「迷雾 → 开窗」是一条连续的进场动画。
5189
+ // (costEpisodeAwaiting 在 costPending 那边就算好了,见那里的注释。)
5190
+ const showCostLoading = (staleUsage || costPending || costEpisodeAwaiting) && !sessionReady;
5191
+ // 等待兜底:4 秒还没等到(请求一直不返回 / 服务端卡住),停止等、退回缓存值。
5192
+ // ⚠️ 依赖只留这三个(2026-10-31 第二十二轮审计):早先 `showCostLoading` 也在依赖里,
5193
+ // 而它含 staleUsage / costPending,流式期间每变一次就 cleanup 重排 → 计时器被无限顺延,
5194
+ // 卡住的会话永远等不到 expired,兜底等于没写。
5195
+ react.useEffect(() => {
5196
+ if (!costEpisodeAwaiting || costEpisodeExpired) return undefined;
5197
+ const id = setTimeout(() => setCostEpisodeExpired(true), 4000);
5198
+ return () => clearTimeout(id);
5199
+ }, [costEpisodeAwaiting, costEpisodeExpired, sessionId]);
4859
5200
  // 子代理消耗(2026-10-31:「把字智能体的消费也算上 并且显示在这里」)。
4860
5201
  // 主会话和子代理分开算(各自有 byModel / peak / offpeak 桶),但**合计进同一个数字**:
4861
5202
  // 消耗行金额、明细浮层的「Token 用量」和「本会话金额」都是「含子代理」的口径,
@@ -4897,6 +5238,49 @@ function UsageCostLine({ useProjection, sessionId }) {
4897
5238
  // 滚完定格:摘掉 animation 声明,展开侧栏不再重播
4898
5239
  useRollSettle(costRoll, setCostRoll);
4899
5240
 
5241
+ // ── 开窗(数字出场)的「只许播一次」闸门,和今日消耗同一套逻辑 ─────────────
5242
+ // 用户 2026-10-31 第十五轮:「这个动画的逻辑也跟今日消耗的逻辑一样 不要在触发展开的
5243
+ // 动画时 动画重头播放」。今日那边靠 ① 模块级台账判「该不该播」② 播完把 animation
5244
+ // 声明整个摘掉(节点不动、但已经没有任何动画可以重播)。这里照抄这两条:
5245
+ // · 台账键按会话隔离:换会话=新会话,照常开一次窗;同一会话重挂/重渲染绝不再开。
5246
+ // · 授权在 paint 之前落定(usePrePaintEffect),所以带闸门类的那一帧就是首帧,
5247
+ // 不会先露一帧「已经开好」的静止态再倒回 0 秒。
5248
+ // · 340ms(280ms 动画 + 60ms 余量)后把类摘掉:类一摘,两条 animation
5249
+ // 声明就消失,此后节点怎么重挂都没有动画可重播,落点与动画终点逐像素一致
5250
+ // (mist 的洞本来就是 86%、body 本来就不裁切)。
5251
+ // ⚠️ 授权必须**等数字真的要出现那一帧**才发(2026-10-31 第十七轮:「动画彻底消失了」)。
5252
+ // 上一版是在组件挂载时就授权记账 —— 但挂载时还在迷雾态(showCostLoading),数字要等
5253
+ // 服务端算完才出来,几百毫秒到几秒之后。等数字真出来时台账早被记成"播过"、
5254
+ // 闸门类也不再挂,于是那一趟开窗一次都没演过。判据必须带上 showCostLoading:
5255
+ // 只有「迷雾已经退场、数字这一帧要渲染」时才算一次出数。
5256
+ // ⚠️ 记忆用**单槽**「最后开过窗的会话」而不是按会话各存一条(第十八轮):
5257
+ // 按会话存等于"整页生命周期只给每个对话一次",第二次点进去什么都不播。
5258
+ // 单槽的语义正好是要的那条:换会话 → 槽里的不是它 → 播;同一会话重挂 → 槽里是它 → 不播。
5259
+ const costRevealKey = sessionId != null && sessionId !== "" ? "cost-reveal:" + sessionId : "";
5260
+ const [costRevealFor, setCostRevealFor] = react.useState("");
5261
+ const costRevealOn = costRevealKey !== "" && costRevealFor === costRevealKey;
5262
+ const costRevealPlayed = animRead("cost-reveal-played");
5263
+ usePrePaintEffect(() => {
5264
+ // ⚠️ `!usage` 必须挡掉(2026-10-31 第十八轮:「还是没有」)。挂载时 usage 往往还是
5265
+ // null(首屏还没拿到),而那时 staleUsage / costPending 可能都是 false ——
5266
+ // showCostLoading 算出来是 false,于是额度被当场领走,而这一帧组件在下面
5267
+ // `if (!usage) return null` 直接返回、什么都没渲染。等真数据到了一看:额度已用完,
5268
+ // 迷雾那一趟也就永远等不到开窗。判据要覆盖「这一帧到底会不会渲染出数字」:
5269
+ // usage 有值 + 不在迷雾 + 不是样式2。
5270
+ // loadingIsText(样式2「计算中」)根本不会渲染 reveal 这棵树,同理别烧额度。
5271
+ if (showCostLoading || loadingIsText || !usage || costRevealKey === "" || costRevealOn) return;
5272
+ if (costRevealPlayed === costRevealKey) return;
5273
+ animWrite("cost-reveal-played", costRevealKey);
5274
+ setCostRevealFor(costRevealKey);
5275
+ }, [showCostLoading, loadingIsText, usage, costRevealKey, costRevealOn, costRevealPlayed]);
5276
+ react.useEffect(() => {
5277
+ // 依赖是「被授权的那个会话键」而不是任何数值:普通刷新碰不到这个计时器,
5278
+ // 只有真的换会话(键变了)才会清掉 —— 与今日那边同一条教训。
5279
+ if (costRevealFor === "") return undefined;
5280
+ const id = setTimeout(() => setCostRevealFor(""), 340);
5281
+ return () => clearTimeout(id);
5282
+ }, [costRevealFor]);
5283
+
4900
5284
  if (!usage) return null;
4901
5285
 
4902
5286
  const uncachedInput = usage.uncachedInputTokens ?? 0;
@@ -4941,8 +5325,15 @@ function UsageCostLine({ useProjection, sessionId }) {
4941
5325
  // "text":等待时显示带呼吸的「计算中…」胶囊 → 数据到手**直接渐入**,不放光不放雾
4942
5326
  // (2026-10-30 用户:「用的如果是计算中就不要用光条动画了 计算完直接渐入就行了」)。
4943
5327
  // 两种都只在等待期间出现,都不亮错误值。
4944
- children: showCostLoading
4945
- ? (loadingIsText
5328
+ // ⚠️ 样式1 的等待态和揭幕态现在是**同一棵树**(2026-10-31 第十八轮:
5329
+ // 「不重播的意思是开始展开的时候 没有被触及到的区域继续播放动画 而不是重头开始播放」)。
5330
+ // 原来等待态渲染 .balance-usage-mist、揭幕态另起一棵 .balance-usage-reveal__mist,
5331
+ // 数字一到手就是**换节点**,里面 5 团光球的 CSS 动画全部从 0 秒重跑 ——
5332
+ // 于是开窗那一刻,没被扫到的区域里那团光会"跳回去重新流"。
5333
+ // 现在容器只有一棵:__mist 常驻(等待态靠 --wait 把洞钉在 0%,也就是整块可见),
5334
+ // 出数时只多挂一个 __body,光球节点的身份不变、相位一路接着走。
5335
+ children: loadingIsText
5336
+ ? (showCostLoading
4946
5337
  ? jsx.jsxs("span", {
4947
5338
  className: "balance-usage-calc",
4948
5339
  key: "calc-" + (sessionId || ""),
@@ -4956,19 +5347,8 @@ function UsageCostLine({ useProjection, sessionId }) {
4956
5347
  }, "dots"),
4957
5348
  ],
4958
5349
  })
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
5350
  // 样式2(计算中):就是一层淡入,没有开窗也没有光
4971
- ? jsx.jsx("span", {
5351
+ : jsx.jsx("span", {
4972
5352
  className: "balance-usage-fade",
4973
5353
  key: "fade-" + (sessionId || ""),
4974
5354
  children: jsx.jsxs("span", {
@@ -4989,46 +5369,51 @@ function UsageCostLine({ useProjection, sessionId }) {
4989
5369
  }, "txt"),
4990
5370
  ],
4991
5371
  }),
4992
- })
4993
- : jsx.jsxs("span", {
4994
- className: "balance-usage-reveal",
4995
- key: "reveal-" + (sessionId || ""),
4996
- children: [
4997
- // 底层:加载态那团光(一整条,不切)。mask 在正中间挖一个随开窗扩大的椭圆洞,
4998
- // 数字从洞里显出来,光留在两侧并且永不透到字缝里。
5372
+ }))
5373
+ : jsx.jsxs("span", {
5374
+ className: "balance-usage-reveal" + (costRevealOn ? " balance-usage-reveal--enter" : ""),
5375
+ key: "reveal-" + (sessionId || ""),
5376
+ children: [
5377
+ // 底层:加载态那团光(一整条,不切),**等待态和揭幕态是同一个节点**。
5378
+ // mask 在正中间挖一个随开窗扩大的椭圆洞,数字从洞里显出来,光留在两侧。
5379
+ // 等待态挂 --wait 把洞钉在 0%(整块可见=加载动画),出数时摘掉它、挂 --enter 播开窗。
4999
5380
  jsx.jsx("span", {
5000
- className: "balance-usage-reveal__mist",
5381
+ className: "balance-usage-reveal__mist"
5382
+ + (showCostLoading ? " balance-usage-reveal__mist--wait" : ""),
5001
5383
  "aria-hidden": "true",
5002
5384
  children: jsx.jsx("span", {
5003
5385
  className: "balance-usage-mist",
5004
5386
  children: ORB_INDICES.map((i) => jsx.jsx("span", { key: "orb-" + i, className: "balance-usage-mist__orb" })),
5005
5387
  }),
5006
5388
  }, "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"),
5389
+ // 上层:数字本体,被 clip-path 从中缝向两侧打开(真正的「开窗」动作在这一层)。
5390
+ // 只在出数之后才存在 —— 等待期容器里只有光,宽度靠 .balance-usage-reveal 的 min-width 撑住。
5391
+ showCostLoading
5392
+ ? null
5393
+ : jsx.jsx("span", {
5394
+ className: "balance-usage-reveal__body",
5395
+ children: jsx.jsxs("span", {
5396
+ className: "balance-usage-cost-inner",
5397
+ children: [
5398
+ // ≈ 单独成节点:U+2248 在中文字体里落在数学符号的中线,比数字基线低一截,
5399
+ // 直接跟「¥7.4619」串在一起会显得整个符号往下掉(2026-10-30 用户指出「有点靠下」)。
5400
+ jsx.jsx("span", { className: "balance-usage-approx", children: "≈" }, "approx"),
5401
+ jsx.jsx("span", { children: "¥" }, "cur"),
5402
+ // 只有数字参与逐位滚动:¥ 与 ≈ 是定值,跟着滚会把整个符号一起翻。
5403
+ // 静态也走逐字格 + 外层 key 恒定,理由同上(宽度恒定、滚动结束不重挂)。
5404
+ jsx.jsx("span", {
5405
+ children: renderRollCells(
5406
+ costRoll === null ? buildRollCells(costText, costText) : buildRollCells(costRoll.from, costText),
5407
+ "u" + (costRoll === null ? "" : costRoll.at),
5408
+ // 播完就定格:摘掉 animation 声明,展开侧栏不再重播
5409
+ costRoll !== null && costRoll.done === true,
5410
+ ),
5411
+ }, "txt"),
5412
+ ],
5413
+ }),
5414
+ }, "body"),
5030
5415
  ],
5031
- })),
5416
+ }),
5032
5417
  }),
5033
5418
  ];
5034
5419
 
@@ -6051,46 +6436,31 @@ function UsageCostLine({ useProjection, sessionId }) {
6051
6436
  : null,
6052
6437
  toggleRow("showUsagePill", "会话消耗行", "输入框下方的「本会话消耗」胶囊(含点开的明细浮层)。"),
6053
6438
  // 两种加载动效互斥,用分段按钮选(和「余额行外观」同一个控件,视觉一致)。
6054
- // 选项文字用「样式1 / 样式2」而不是效果名,所以在说明栏里直接并排播两个真实预览 ——
6055
- // 用户不用读描述、看动画就知道哪个是哪个(2026-10-31 要求「在介绍栏预览给用户看」)。
6439
+ // 选项文字用「样式1 / 样式2」而不是效果名,所以旁边直接播一个真预览 ——
6440
+ // 用户不用读描述、看动画就知道选的是哪个(2026-10-31 要求「在介绍栏预览给用户看」)。
6441
+ // 预览**只画当前选中的那一个**(第十三轮:「只预览当前选择的样式」),节点跟着 value 走。
6056
6442
  choiceRow("usageLoadingStyle", "消耗行加载动效", "等对话用量时显示什么。", [
6057
6443
  { value: "wipe", label: "样式1" },
6058
6444
  { value: "text", label: "样式2" },
6059
- ], jsx.jsxs("div", {
6445
+ ], jsx.jsx("div", {
6060
6446
  className: "balance-settings__preview",
6061
6447
  "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", {
6448
+ children: jsx.jsxs("div", {
6449
+ className: "balance-settings__preview-item",
6450
+ children: [
6451
+ jsx.jsx("span", {
6452
+ className: "balance-settings__preview-tag",
6453
+ children: value.usageLoadingStyle === "text" ? "样式2" : "样式1",
6454
+ }),
6455
+ jsx.jsxs("span", {
6456
+ className: "balance-settings__preview-stage",
6457
+ children: [
6458
+ jsx.jsx("span", {
6459
+ className: "balance-settings__preview-text",
6460
+ children: "本会话消耗",
6461
+ }),
6462
+ value.usageLoadingStyle === "text"
6463
+ ? jsx.jsxs("span", {
6094
6464
  className: "balance-usage-calc",
6095
6465
  children: [
6096
6466
  "计算中",
@@ -6100,12 +6470,15 @@ function UsageCostLine({ useProjection, sessionId }) {
6100
6470
  children: [0, 1, 2].map((i) => jsx.jsx("span", { className: "balance-usage-calc__dot", children: "." }, i)),
6101
6471
  }, "dots"),
6102
6472
  ],
6473
+ })
6474
+ : jsx.jsx("span", {
6475
+ className: "balance-usage-mist",
6476
+ children: ORB_INDICES.map((i) => jsx.jsx("span", { key: "orb-" + i, className: "balance-usage-mist__orb" })),
6103
6477
  }),
6104
- ],
6105
- }),
6106
- ],
6107
- }, "p2"),
6108
- ],
6478
+ ],
6479
+ }),
6480
+ ],
6481
+ }, "p-cur"),
6109
6482
  })),
6110
6483
  value.footerStyle === "classic"
6111
6484
  ? toggleRow("showPeriodLabel", "高峰 / 空闲标签", "旧版余额行里「金额 • 时段」的时段部分;关掉后只留金额。")
package/lib/index.js CHANGED
@@ -124,13 +124,24 @@ async function sessionStampMap(ctx) {
124
124
  * 拿到最新数字,而余额/今日本来就是几十秒一轮的节奏,感知不到。
125
125
  */
126
126
  const persistReadAt = new Map(); // sessionId -> 上次真正解压的时刻
127
+ // 按会话增长的表一律夹上限(2026-10-31 第二十二轮审计):Map 保持插入序,淘汰最旧的键。
128
+ const PERSIST_READ_MAX = 240;
129
+ function capSessionMap(map, max = PERSIST_READ_MAX) {
130
+ while (map.size > max) {
131
+ const oldest = map.keys().next();
132
+ if (oldest.done) return;
133
+ map.delete(oldest.value);
134
+ }
135
+ }
127
136
  async function inspectPersistedCached(ctx, sessionId, minIntervalMs = 0) {
128
137
  if (typeof sessionId !== "string" || sessionId === "") return null;
129
- // 每帧按 4 KB 估算占用:实测 35.8 MB 的 zstd 会话 = 19331 帧 / 解压后 134 MB
130
- // (约 6.9 KB/帧)。这个数只用来做淘汰决策,不参与任何计费。
138
+ // 每帧按 7 KB 估算占用:实测 35.8 MB 的 zstd 会话 = 19331 帧 / 解压后 134 MB
139
+ // (约 6.9 KB/帧)。⚠️ 2026-10-31 第二十二轮审计:原来写 4096,低估约 40% ——
140
+ // 512 MB 的预算实际能吃到 ~800 MB 常驻,和 DSH 同进程时会 OOM。
141
+ // 这个数只用来做淘汰决策,不参与任何计费。
131
142
  const estimateBytes = (value) => {
132
143
  const events = value?.events;
133
- return Array.isArray(events) ? events.length * 4096 : 0;
144
+ return Array.isArray(events) ? events.length * 7000 : 0;
134
145
  };
135
146
  const dropEntry = (key) => {
136
147
  const rec = persistCache.get(key);
@@ -143,7 +154,10 @@ async function inspectPersistedCached(ctx, sessionId, minIntervalMs = 0) {
143
154
  };
144
155
  const now = Date.now();
145
156
  const stamps = await sessionStampMap(ctx);
146
- const hasStamp = stamps.has(sessionId);
157
+ // ⚠️ 列表里有这条 id,但 updatedAt/createdAt 都是空(新版本 DSH 的某些条目如此)时,
158
+ // 指纹恒为 "",`hit.stamp === stamp` 永远成立 → 走 30 分钟长效 TTL,内容更新了也读到
159
+ // 冻结的旧事件(2026-10-31 第二十二轮审计)。所以「空指纹」一律按拿不到指纹处理。
160
+ const hasStamp = stamps.has(sessionId) && stamps.get(sessionId) !== "";
147
161
  const stamp = hasStamp ? stamps.get(sessionId) : "";
148
162
 
149
163
  const hit = persistCache.get(sessionId);
@@ -198,6 +212,11 @@ async function inspectPersistedCached(ctx, sessionId, minIntervalMs = 0) {
198
212
  return value;
199
213
  } finally {
200
214
  persistInFlight.delete(sessionId);
215
+ // ⚠️ 失败也要记重读时刻(2026-10-31 第二十二轮审计):这条只写在成功路径时,
216
+ // 大会话文件损坏/被占的情况下 minIntervalMs 的节流永远不生效 ——
217
+ // `?session=` 每次请求都完整解压一遍(约 800 ms 同步 CPU),切会话还会再试第二个 id。
218
+ if (minIntervalMs > 0) persistReadAt.set(sessionId, Date.now());
219
+ capSessionMap(persistReadAt);
201
220
  }
202
221
  })();
203
222
  persistInFlight.set(sessionId, task);
@@ -739,7 +758,7 @@ function formatBalance(balance) {
739
758
  // | { status:"failed" } (网络失败;官方明确“失败绝不变成 0 余额”)
740
759
  // client 是 AccountClientMetadata(version / locale / timezoneOffsetSeconds),
741
760
  // 缺字段会在平台侧组装请求头时抛错,所以三个字段都要给。
742
- const PLUGIN_VERSION = "2.1.0";
761
+ const PLUGIN_VERSION = "2.1.1";
743
762
 
744
763
  /** 磁盘上**实际安装**的版本号。
745
764
  *
@@ -1774,7 +1793,9 @@ async function writeJsonAtomic(file, value) {
1774
1793
  const { mkdir, writeFile, rename } = await import("node:fs/promises");
1775
1794
  const { dirname } = await import("node:path");
1776
1795
  await mkdir(dirname(file), { recursive: true });
1777
- const tmp = `${file}.tmp`;
1796
+ // ⚠️ 临时名必须唯一(2026-10-31 第二十二轮审计):固定 `.tmp` 时两次并发写会互相截断,
1797
+ // rename 也可能报 ENOENT。saveCalendarCache 早就改成唯一名了,这里跟上。
1798
+ const tmp = file + "." + process.pid + "." + Math.random().toString(36).slice(2) + ".tmp";
1778
1799
  await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
1779
1800
  await rename(tmp, file);
1780
1801
  }
@@ -1918,7 +1939,8 @@ async function writeSettingsFile(file, settings) {
1918
1939
  } catch {
1919
1940
  // 首次写盘(还没有原文件):没有可备份的东西,继续
1920
1941
  }
1921
- const tmp = `${file}.tmp`;
1942
+ // 唯一临时名:并发写(设置卡片连点保存)时固定 `.tmp` 会互相截断。
1943
+ const tmp = file + "." + process.pid + "." + Math.random().toString(36).slice(2) + ".tmp";
1922
1944
  await writeFile(tmp, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
1923
1945
  await rename(tmp, file);
1924
1946
  }
@@ -2082,9 +2104,16 @@ async function updateTodayCostWithSession(ctx, config, state, sessionId, events)
2082
2104
  );
2083
2105
  const localRec = local.get(todayKey)?.bySession?.get(sessionId);
2084
2106
  const bySession = { ...(base.bySession ?? {}) };
2107
+ // ⚠️ 桶只能往大走(2026-10-31 第二十二轮审计):`events` 可能是被
2108
+ // SESSION_REREAD_MIN_MS 节流后的**旧数组**(`?session=` 4 秒内复用上一份),
2109
+ // 直接覆盖会让今日金额往回跳(实测 0.168 → 0.112),而且这个较小的 provisional
2110
+ // 值还会写进 state.cache.value。当天消耗只增不减,取较大者即可。
2111
+ const prevRec = bySession[sessionKey];
2112
+ const nextCost = Number(localRec?.cost ?? 0);
2113
+ const nextTokens = Number(localRec?.totalTokens ?? 0);
2085
2114
  bySession[sessionKey] = {
2086
- cost: Number(localRec?.cost ?? 0),
2087
- totalTokens: Number(localRec?.totalTokens ?? 0),
2115
+ cost: Math.max(Number(prevRec?.cost ?? 0), nextCost),
2116
+ totalTokens: Math.max(Number(prevRec?.totalTokens ?? 0), nextTokens),
2088
2117
  };
2089
2118
  let cost = 0;
2090
2119
  let totalTokens = 0;
@@ -2276,8 +2305,7 @@ async function buildTodayCost(ctx, config, reuse = null) {
2276
2305
  // 假的 0 一旦落盘会被 readTodaySnapshot 认成"今天的值",界面就一直显示 ¥0.0000,
2277
2306
  // 而且后台重算只要继续踩同一个坑就永远修不回来。所以这种情况宁可不落盘,
2278
2307
  // 让下次冷启动重新现算。
2279
- const suspiciousEmpty = listedCount > 0 && candidates.length === 0;
2280
- const selected = candidates
2308
+ const suspiciousEmpty = listedCount > 0 && candidates.length === 0; const selected = candidates
2281
2309
  // 今日用自己的上限(默认 200),不再跟统计面板的 80 绑在一起 —— 见 TODAY_MAX_SESSIONS。
2282
2310
  .slice(0, clampNumber(config?.todaySessionLimit, 10, 2000, TODAY_MAX_SESSIONS))
2283
2311
  .map((x) => x.id);
@@ -2298,6 +2326,10 @@ async function buildTodayCost(ctx, config, reuse = null) {
2298
2326
  }
2299
2327
  const daily = new Map();
2300
2328
  let cursor = 0;
2329
+ // 成功读到事件的会话数(2026-10-31 第二十二轮审计)。⚠️ 原来只要「候选非空」就当正常,
2330
+ // 而候选是按 list() 的 updatedAt 筛出来的、跟"读不读得出来"无关 —— 所有会话的 inspect
2331
+ // 都抛错时 cost 会是 0,却带着新鲜 at 落盘,界面长期 ¥0.0000(被当成今天的定型值)。
2332
+ let okCount = 0;
2301
2333
  const worker = async () => {
2302
2334
  while (cursor < selected.length) {
2303
2335
  const sessionId = selected[cursor++];
@@ -2314,6 +2346,7 @@ async function buildTodayCost(ctx, config, reuse = null) {
2314
2346
  daily,
2315
2347
  sessionId,
2316
2348
  );
2349
+ okCount += 1;
2317
2350
  } catch {
2318
2351
  // 单个会话读不出来不影响总额
2319
2352
  }
@@ -2328,8 +2361,7 @@ async function buildTodayCost(ctx, config, reuse = null) {
2328
2361
  // 单线程下并发不会更快,但能让不同会话的解压机会**交错**,切会话时不必等前一个走完。
2329
2362
  // 真正的阻塞控制靠下面每会话解压之后的 setImmediate 让出。
2330
2363
  await Promise.all(Array.from({ length: Math.min(3, selected.length) }, () => worker()));
2331
- const day = daily.get(todayKey);
2332
- // bySession 摊平成普通对象:快照要能 JSON 序列化,Map 存不了。
2364
+ const day = daily.get(todayKey); // bySession 摊平成普通对象:快照要能 JSON 序列化,Map 存不了。
2333
2365
  const bySession = {};
2334
2366
  if (day?.bySession != null) {
2335
2367
  for (const [id, rec] of day.bySession) bySession[id] = { cost: rec.cost, totalTokens: rec.totalTokens };
@@ -2341,7 +2373,10 @@ async function buildTodayCost(ctx, config, reuse = null) {
2341
2373
  sessions: day?.sessionIds?.size ?? 0,
2342
2374
  bySession,
2343
2375
  // 只给 computeAndStoreToday 用(决定要不要落盘),客户端不读这个字段。
2344
- suspiciousEmpty,
2376
+ // ⚠️ 除了「候选被筛空」,还要判「一个会话都没读成功」(2026-10-31 第二十二轮审计):
2377
+ // list() 正常、每个 inspect 都抛错时 candidates 非空、cost 却是 0 —— 那种 0 是假的,
2378
+ // 带着新鲜 at 落盘就会长期显示 ¥0.0000。
2379
+ suspiciousEmpty: suspiciousEmpty || (listedCount > 0 && okCount === 0),
2345
2380
  };
2346
2381
  }
2347
2382
 
@@ -2356,7 +2391,18 @@ async function computeAndStoreToday(ctx, config, state, reuse = null) {
2356
2391
  // 就永远修不回来 —— 两害相权,宁可不写。
2357
2392
  const snapshot = value.suspiciousEmpty ? { ...value, at: 0 } : { ...value, at: Date.now() };
2358
2393
  try {
2359
- if (!value.suspiciousEmpty) await writeTodaySnapshot(state.snapshotFile, snapshot);
2394
+ if (!value.suspiciousEmpty) {
2395
+ // ⚠️ 写今天这份之前,先把昨天那份结算进 balance-daily-settled.json
2396
+ // (2026-10-31 第二十二轮审计):结算原来只在 apply 的启动块里跑一次,
2397
+ // 桌面端常驻不重启、跨过北京时间 0 点后,昨天快照的收尾部分会被今天这份直接覆盖,
2398
+ // 统计里昨天就少一截。这里是"即将覆盖"的唯一位置,放这里天然只多一次小 JSON 读。
2399
+ try {
2400
+ await settleStaleTodaySnapshot(ctx);
2401
+ } catch {
2402
+ // 结算失败不影响今天的数字
2403
+ }
2404
+ await writeTodaySnapshot(state.snapshotFile, snapshot);
2405
+ }
2360
2406
  } catch {
2361
2407
  // 落盘失败:当作只有内存缓存,下次启动再算一遍
2362
2408
  }
@@ -2411,12 +2457,21 @@ async function resolveTodayCost(ctx, config, state, reuse = null) {
2411
2457
  }
2412
2458
  const snapshot = await readTodaySnapshot(state.snapshotFile);
2413
2459
  if (snapshot != null) {
2414
- state.cache.value = snapshot;
2415
- const age = snapshot.at > 0 ? now - snapshot.at : Number.POSITIVE_INFINITY;
2460
+ // ⚠️ 增量值(provisional)可能**比快照大**(快照是几十秒前算的),直接拿快照覆盖
2461
+ // 会让今日金额往回跳、而且带着 partial=false 让客户端不再补拉,锁死在旧值上
2462
+ // (2026-10-31 第二十二轮审计:实测 17.3 → 16.0)。取较大者,并强制 partial=true
2463
+ // 让客户端继续补拉、后台重算把真值补上。
2464
+ const prev = state.cache.value;
2465
+ const keepPrev = prev != null && prev.provisional === true
2466
+ && Number(prev.cost ?? 0) > Number(snapshot.cost ?? 0);
2467
+ const chosen = keepPrev ? prev : snapshot;
2468
+ const forcedPartial = keepPrev;
2469
+ state.cache.value = chosen;
2470
+ const age = chosen.at > 0 ? now - chosen.at : Number.POSITIVE_INFINITY;
2416
2471
  const fresh = age < state.staleMs;
2417
- state.cache.expiresAt = now + (fresh ? state.staleMs - age : 3000);
2418
- if (!fresh) scheduleTodayRecompute(ctx, config, state);
2419
- return { value: snapshot, partial: !fresh };
2472
+ state.cache.expiresAt = now + (fresh && !forcedPartial ? state.staleMs - age : 3000);
2473
+ if (!fresh || forcedPartial) scheduleTodayRecompute(ctx, config, state);
2474
+ return { value: chosen, partial: !fresh || forcedPartial };
2420
2475
  }
2421
2476
  }
2422
2477
  // 快照不可用 / 带了 reuse:现算。reuse 路径算完同样落盘,下一次冷启动照样秒开。
@@ -3145,6 +3200,13 @@ let statsInFlight = null;
3145
3200
  }
3146
3201
  if (wantUpdate) {
3147
3202
  if (req.method === "POST") {
3203
+ // ⚠️ 必须和设置写入同一道同源校验(2026-10-31 第二十二轮审计):
3204
+ // 这条分支会真的下载 npm 包并覆盖插件安装目录。少了校验,本机任意网页
3205
+ // 用一个 text/plain 的简单请求就能触发写盘(CSRF)。
3206
+ if (!isSameOriginJsonRequest(req)) {
3207
+ writeJson(res, 403, { ok: false, error: "forbidden" });
3208
+ return;
3209
+ }
3148
3210
  // POST → 下载 npm 上最新 tarball 覆盖本机安装(见 installLatestVersion)。
3149
3211
  // 下载 + 逐文件写盘期间 await 会让出事件循环,不会卡住余额请求。
3150
3212
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "在 DeepSeek Harness 中显示 DeepSeek 账户余额和当前会话 token/费用消耗(含高峰/空闲峰谷计费)。仅支持 DeepSeek 官方模型与账号,暂不支持其他大模型。",
5
5
  "author": "Rannist",
6
6
  "type": "module",