balance-dsh 2.0.5 → 2.0.7

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 +358 -115
  2. package/lib/index.js +10 -1
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -9,6 +9,13 @@ window.__ModuleLoader__.load({
9
9
  let jsx = require("react/jsx-runtime");
10
10
  let primitives = require("@deepseek-ai/dsh-client-ui-primitives");
11
11
 
12
+ // 余额滚轮要"这一帧就挂上滚动结构",不能等 useEffect(它在 paint 之后跑),
13
+ // 否则余额一变会先渲染出一帧纯文本新值、下一帧才换成滚动格 —— 看起来就是
14
+ // "数字比动画先出来"(2026-10-30 用户反馈)。优先用 useLayoutEffect(paint 前同步),
15
+ // 老 React 没有就退回 useEffect(纯静态 stub 环境也不至于炸)。
16
+ const usePrePaintEffect =
17
+ react && typeof react.useLayoutEffect === "function" ? react.useLayoutEffect : (react && react.useEffect);
18
+
12
19
  // ─── 样式 ────────────────────────────────────────────────────────────────
13
20
  const css = `
14
21
  /* 兜底定义主题过渡变量:上游 deepsuite theme/global.css 未加载时,
@@ -20,7 +27,7 @@ window.__ModuleLoader__.load({
20
27
  左缘与设置按钮严格对齐,高度/颜色一致(金额 600 加粗)。
21
28
  布局:[meta + 刻度条(竖排,居中)] [金额(最右)] */
22
29
  .balance-footer{position:relative;isolation:isolate;box-sizing:border-box;display:flex;align-items:center;justify-content:flex-start;gap:8px;width:100%;min-width:0;height:42px;margin:0;padding:0 0 0 8px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;text-decoration:none;cursor:pointer;white-space:nowrap;overflow:visible;-webkit-user-select:none;user-select:none}
23
- /* 带 meta 行时整块变高:42px 装不下「一行 11px 文字 + 13px 轨道」,改成自适应 */
30
+ /* 带 meta 行时整块变高:42px 装不下「一行 11px 文字 + 13px 轨道」,改成自适应。 */
24
31
  .balance-footer--stacked{height:auto;min-height:42px;padding-top:5px;padding-bottom:5px}
25
32
  /* 整行都是热区(单击统计面板 / 双击用量页 / 右键刷新;交互挂在 .balance-stats-anchor、捕获阶段)。
26
33
  不给整行画 hover / active 底色——那圈圆角高亮框在侧边栏里太显眼,
@@ -39,6 +46,14 @@ window.__ModuleLoader__.load({
39
46
  .balance-footer__roll{display:inline-flex;align-items:baseline;white-space:pre;flex:none}
40
47
  .balance-footer__dcell{display:inline-block;vertical-align:bottom;white-space:pre}
41
48
  .balance-footer__dcell--roll{position:relative;overflow:hidden}
49
+ /* 位数减少时(11.04 → 8.04),最左边那一格的「新值」是空位。它得先把旧数字滚出去,
50
+ 再把自己收成 0 宽 —— 否则滚动结束后那一格残留的宽度把胶囊撑宽 10px,
51
+ 要等 setRoll(null) 才缩回,看起来就是数字左右跳一下。
52
+ 收窄过程中 dout/din 是 absolute,会被 dcell 的 overflow:hidden 裁掉,不会和邻格重叠。
53
+ 影子格必须保持 NBSP 而不是 "0":这一格要在动画期间维持原来的宽度,
54
+ 真正让它消失的是这条 max-width 动画,不是字符宽度。 */
55
+ .balance-footer__dcell--gone{animation:balance-cell-gone .42s cubic-bezier(.33,.66,.24,1) forwards}
56
+ @keyframes balance-cell-gone{from{max-width:2ch}to{max-width:0}}
42
57
  .balance-footer__dghost{display:block;visibility:hidden;white-space:pre}
43
58
  .balance-footer__dout,.balance-footer__din{position:absolute;left:0;top:0;white-space:pre;will-change:transform,opacity}
44
59
  .balance-footer__dout{animation:balance-roll-out .42s cubic-bezier(.33,.66,.24,1) forwards}
@@ -51,7 +66,7 @@ window.__ModuleLoader__.load({
51
66
  只扫一层白光,**不覆盖扫过区域**——曾经有一个 ::after 暗罩把白条右侧整体压暗,
52
67
  视觉上等于给扫过的部分蒙了层色,已删除(2026-10-30 反馈「不要为扫过区域的颜色覆盖了」)。
53
68
  现在动效 = 一条白光从左走到右,走完即止,行内颜色全程不变。 */
54
- .balance-footer__wave::before{content:"";position:absolute;top:1px;bottom:1px;left:0;width:2.5px;margin-left:-1.25px;border-radius:999px;corner-shape:round;background:#fff;box-shadow:0 0 3px 1px rgba(255,255,255,.95),0 0 8px 3px rgba(255,255,255,.42),0 0 14px 5px rgba(255,255,255,.16);animation:balance-wipe-head .58s cubic-bezier(.34,.7,.26,1) forwards}
69
+ .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}
55
70
  @keyframes balance-wipe-head{0%{left:0;opacity:0}5%{opacity:1}82%{opacity:1}100%{left:100%;opacity:0}}
56
71
  /* 尊重系统的「减少动态效果」:不播,只留 --stale 的静态弱化 */
57
72
  /* 浪潮白条不受 prefers-reduced-motion 影响:它是右键刷新的签名动效,故意保留 */
@@ -83,6 +98,36 @@ window.__ModuleLoader__.load({
83
98
  .balance-footer__value--healthy{color:var(--dsw-alias-state-success-primary);background:rgba(34,197,94,.15)}
84
99
  /* 正在刷新:金额栏压成半透明旧值,和左侧的时段占位呼应,避免旧数字看起来像刚更新的 */
85
100
  .balance-footer__value--stale{opacity:.9}
101
+ /* 手动刷新(右键):金额被白光从左擦出来 —— 与输入区「本会话消耗」同一套手法。
102
+ **必须是双层**(2026-10-30 用户反馈「没被光条扫到的区域接着显示 不要直接消失了」):
103
+ 底层 __reveal-base 常显(上一帧的值),上层 __reveal-sweep 才是被 clip-path 裁切的那层。
104
+ 裁切挂在 sweep 自己身上而不是外层容器,所以光条没扫到的地方照旧显示旧值,不会先变空白。 */
105
+ .balance-footer__reveal{display:inline-grid;position:relative;height:20px;height:1lh}
106
+ .balance-footer__reveal>*{grid-area:1/1;white-space:nowrap}
107
+ /* 底层同样要走裁剪,不能靠 sweep 盖上来:文字是笔画不是色块,两层的字形会叠成一团糊字
108
+ (2026-10-30 用户截图「5.96 CNY」中间叠着别的数字,就是这么来的)。
109
+ 所以 base 从左边被裁掉、sweep 从左边展开 —— 交界处永远只有一层内容。
110
+ 收尾时 base 已被裁光,右侧不会留旧值的尾巴,也不需要额外淡出。 */
111
+ /* 底层**必须绝对定位**:clip-path 只作用于绘制,不影响布局 —— 留在 grid 流里时,
112
+ 旧值的宽度照样会算进容器(实测 8.04 稳态 73.66px,带上旧值 11.04 的底层就变 83.53px)。
113
+ 后果是自动刷新时胶囊先宽 10px 再缩回,看着像数字在左右跳。
114
+ 拉出流之后宽度只由 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}
116
+ @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}
118
+ /* 胶囊内部**不放光条**(2026-10-30 用户要求「余额内的光条撤了」):
119
+ 外面那层 .balance-footer__wave 已经在整块扫白光了,胶囊里再来一道就是两道白光打架。
120
+ 这里只保留 clip 揭示 —— 数字从左被「擦」出来,边界交给外面那道光。 */
121
+ /* 设置里关掉「右键刷新浪潮」:不载入动画,金额直接显示。
122
+ ⚠️ 底层必须一起 display:none —— 去掉裁剪后 sweep 会全显,两层文字重叠成一团糊字
123
+ (2026-10-30 用户截图报的正是这个:5.96 CNY 中间叠了个别的数字)。 */
124
+ .balance-footer__reveal--off .balance-footer__reveal-sweep{animation:none;clip-path:none}
125
+ .balance-footer__reveal--off .balance-footer__reveal-base{display:none}
126
+ /* 同上:reduce 时没有动画,只留新值那一层,否则同样是两层重叠 */
127
+ @media (prefers-reduced-motion:reduce){
128
+ .balance-footer__reveal-sweep{animation:none;clip-path:none}
129
+ .balance-footer__reveal-base{display:none}
130
+ }
86
131
  .balance-footer__value--low{color:#d97706;background:rgba(217,119,6,.15)}
87
132
  .balance-footer__value--empty{color:var(--dsw-alias-state-error-primary,#e5484d);background:rgba(229,72,77,.15)}
88
133
  .balance-footer__topup{margin-left:auto;flex:none;box-sizing:border-box;padding:2px 10px;border-radius:999px;font-weight:500;white-space:nowrap;color:var(--dsw-alias-state-error-primary,#e5484d);text-decoration:none;background:rgba(229,72,77,.15);transition:transform .15s var(--ds-ease-in-out,.2s)}
@@ -105,11 +150,7 @@ window.__ModuleLoader__.load({
105
150
  height:auto,撑得住),而不是把胶囊顶出去。 */
106
151
  .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}
107
152
  .balance-footer__timeline-meta-item{flex:none;white-space:nowrap}
108
- /* 「今日 ¥x」独占第一行(flex-basis:100%),于是下面那行永远是「空闲 剩 3h57m」。
109
- 不加这条时宽度够就会并成一行,用户看到的顺序就变成「今日 … 空闲 剩 …」,
110
- 得不到他要的「空闲跟剩余时间一栏」(2026-10-30 指定)。
111
- ⚠️ 必须配 text-align:center:占满 100% 之后默认是左对齐,而下面那行 group 是居中的,
112
- 两行就会错开(2026-10-30 反馈「今日消费位置不对」)。 */
153
+ /* 「今日 ¥x」独占第一行(flex-basis:100%)并居中,与下面「空闲 剩 3h57m」那一行左对齐成组。 */
113
154
  .balance-footer__timeline-meta-item--today{flex:0 0 100%;text-align:center}
114
155
  /* 「今日 ¥x」数据没到时的占位:不可见但占位,高度先占好,数据到了只换文字、不动布局。
115
156
  ⚠️ 不要改回条件渲染或 display:none —— 那会让 meta 行从 1 行变 2 行,
@@ -215,6 +256,62 @@ window.__ModuleLoader__.load({
215
256
  .balance-usage-line svg{flex:none;width:14px;height:14px}
216
257
  .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)}
217
258
  .balance-usage-label{text-overflow:ellipsis;min-width:0;overflow:hidden}
259
+ /* 金额那一段单独成类。**宽度必须恒定**:这段一变宽变窄,composer dock 那一行就重排,
260
+ 输入框会被顶得上下跳(2026-10-30 用户实测反馈)。
261
+ 真正的元凶不是「计算中」三个字,而是位数本身:toFixed(4) 下 0.4218 是 6 个数字位(57.58px)、
262
+ 69.2184 是 7 位(64.63px)、123.4567 是 8 位(71.66px),金额一涨就变宽。
263
+ 所以用 min-width 按最宽的 8 位兜住(1ch = 一个数字宽,随字号自动缩放),
264
+ 短金额右侧留白也不补前导零;pending 那侧另用不可见锚占同样的位。 */
265
+ /* 高度也必须锁死:inline-block 一旦带上 overflow:hidden,行盒会被撑高,
266
+ 数字就比「本会话消耗」高出两三个像素(2026-10-30 用户反馈「这个数字还变高了」)。
267
+ 所以宽高一起固定 —— inline-flex + align-items:center + height:1lh(= 继承的行高,
268
+ 跟随 DSH 字号设置走),待算与算完两态的高度完全一致。 */
269
+ .balance-usage-cost{white-space:nowrap;font-variant-numeric:tabular-nums;display:inline-flex;align-items:center;min-width:10.2ch;height:20px;height:1lh}
270
+ .balance-usage-cost--pending{opacity:1}
271
+ /* 待算时**什么都不画**:既没有「计算中」,也没有循环白条。
272
+ 用户 2026-10-30 的原话是「为什么会扫两遍,如果没扫到就让他空着就行了」——
273
+ 循环那道和擦出那道叠在一起就是两遍。现在只留擦出这一遍。 */
274
+ /* 数字出场=被光条擦出来(2026-10-30 用户要「光条滑过的地方开始显示数字 就跟窗户一样」):
275
+ 外层 clip-path 从左往右展开,边界正好压住光条,所以看上去是光条把数字一格一格擦亮;
276
+ ::after 那颗光条与 boundary 用同一条 balance-wipe-head 曲线,二者永远同步,
277
+ 拉到右端时外层已全开、光条也淡出,收尾干净。只播一次(both),数字变了也不重播。 */
278
+ /* 时长从 .58s 收到 .34s:用户 2026-10-30 反馈「速度太慢了,点进会话会停顿 0.5 秒左右」。
279
+ 0.34s 刚好够看清"光条擦过去",又不至于让人等。白条那份 balance-wipe-head 不动 ——
280
+ 它和指针入场同源,selftest 会核对二者时长一致。 */
281
+ /* 「计算中…」模式下数据到手用淡入,不放光条(两种加载语言不混用)。 */
282
+ .balance-usage-fade{position:relative;display:inline-flex;align-items:center;height:20px;height:1lh;animation:balance-usage-fade-in .2s ease-out both}
283
+ @keyframes balance-usage-fade-in{from{opacity:0}to{opacity:1}}
284
+ @media (prefers-reduced-motion:reduce){
285
+ .balance-usage-fade{animation:none}
286
+ }
287
+ /* 「计算中…」的三个点逐颗跳动(设置里 usageLoadingStyle 选 "text" 时才用)。
288
+ 文字本身不动,只有省略号在闪,这是最不打扰的一种「还在算」的信号。
289
+ ⚠️ 点必须拆成三个独立 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))}
291
+ .balance-usage-calc__dots{display:inline-flex;margin-left:1px}
292
+ .balance-usage-calc__dot{animation:balance-dot-blink 1.2s ease-in-out infinite}
293
+ .balance-usage-calc__dot:nth-child(2){animation-delay:.15s}
294
+ .balance-usage-calc__dot:nth-child(3){animation-delay:.3s}
295
+ @keyframes balance-dot-blink{0%,70%,100%{opacity:.22}35%{opacity:1}}
296
+ @media (prefers-reduced-motion:reduce){
297
+ .balance-usage-calc__dot{animation:none;opacity:.75}
298
+ }
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}
306
+ @media (prefers-reduced-motion:reduce){
307
+ .balance-usage-reveal{animation:none;clip-path:none}
308
+ .balance-usage-reveal::after{animation:none;content:none}
309
+ }
310
+ .balance-usage-cost-inner{display:inline-flex;align-items:center;white-space:nowrap;height:20px;height:1lh}
311
+ /* ≈ 的视觉修正:字形本身偏低,用 em 单位上提(跟随字号缩放),右侧留一点呼吸缝。
312
+ 不要改成 vertical-align —— inline-flex 子项走的是 align-items:center,两者会打架。 */
313
+ .balance-usage-approx{position:relative;top:-.12em;margin-right:.28em}
314
+ @keyframes balance-calc-reveal{from{clip-path:inset(0 100% 0 0)}to{clip-path:inset(0 -4px 0 0)}}
218
315
  /* ── 消耗明细浮层:规格照抄 DSH 原生「会话统计」面板(.HS2XBG_panel/.title/.titleRule/.details)── */
219
316
  .balance-usage-panel{box-sizing:border-box;position:fixed;z-index:1100;border:0;border-radius:var(--dsw-radius-lg,12px);background:var(--dsw-specific-menu,var(--dsw-alias-bg-module-platform,#242526));width:max-content;min-width:min(300px,100vw - 24px);max-width:min(440px,100vw - 24px);backdrop-filter:var(--dsw-menu-backdrop-filter,none);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent,0 8px 24px rgba(0,0,0,.32));color:var(--dsw-alias-label-secondary);cursor:default;padding:16px;font-size:12px;line-height:18px;animation:balance-stats-panel-in .12s var(--ds-ease-in-out,ease-out) both}
220
317
  .balance-usage-panel__title{display:flex;justify-content:space-between;gap:16px;margin-bottom:8px;color:var(--dsw-alias-label-primary);font-weight:500}
@@ -380,8 +477,9 @@ window.__ModuleLoader__.load({
380
477
  .balance-tip-body{margin:0;font-size:13px;line-height:1.7;color:var(--dsw-alias-label-secondary)}
381
478
  /* 节假日数据行:右侧是「状态文案 + 立即同步」两段,窄屏时自动换行靠右。 */
382
479
  .balance-settings__calendar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end}
383
- .balance-settings__calendar-note{font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-tertiary);text-align:right;font-variant-numeric:tabular-nums;max-width:200px}
480
+ .balance-settings__calendar-note{font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-tertiary);text-align:right;font-variant-numeric:tabular-nums;max-width:100%;word-break:keep-all}
384
481
  .balance-settings__calendar-note--warn{color:var(--dsw-alias-state-warn-primary,#d97706)}
482
+ .balance-settings__calendar-note--ok{color:var(--dsw-alias-state-success-primary,#22c55e)}
385
483
  .balance-settings__calendar-note--error{color:var(--dsw-alias-state-error-primary,#e5484d)}
386
484
  .balance-tip-ok{padding:5px 14px;border:.5px solid transparent;border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;cursor:pointer}
387
485
  .balance-tip-ok:hover{opacity:.88}
@@ -688,6 +786,65 @@ window.__ModuleLoader__.load({
688
786
  }
689
787
  }
690
788
 
789
+ // ─── 逐位滚动(数字每帧上下翻) ──────────────────────────────────────
790
+ // 把两段金额文本按「小数点对齐」拆成逐位滚动的格子:整数右对齐、小数点一位、
791
+ // 小数左对齐,只有真正变了的数位才滚,其余(小数点、货币后缀、没变的高位)静止。
792
+ // 空位用 \u0000 标记,渲染时换成 NBSP 或退回旧字符撑宽。
793
+ // 余额胶囊(自动刷新)与「本会话消耗」共用这一套 —— 两处滚动观感必须一致
794
+ // (2026-10-30 用户要求「对话消耗的数字变动动画跟余额的自动刷新一样」)。
795
+ const ROLL_EMPTY = "\u0000";
796
+ function buildRollCells(fromText, toText) {
797
+ const split = (text) => {
798
+ const m = /^([0-9]*)(\.?)([0-9]*)([\s\S]*)$/.exec(text);
799
+ return m
800
+ ? { int: m[1], dot: m[2], frac: m[3], tail: m[4] }
801
+ : { int: "", dot: "", frac: "", tail: text };
802
+ };
803
+ const a = split(fromText);
804
+ const b = split(toText);
805
+ const padLeft = (s, n) => s.padStart(n, ROLL_EMPTY);
806
+ const padRight = (s, n) => s.padEnd(n, ROLL_EMPTY);
807
+ const intLen = Math.max(a.int.length, b.int.length);
808
+ const fracLen = Math.max(a.frac.length, b.frac.length);
809
+ const tailLen = Math.max(a.tail.length, b.tail.length);
810
+ const rows = [
811
+ [padLeft(a.int, intLen), padLeft(b.int, intLen)],
812
+ [a.dot || b.dot, b.dot || a.dot],
813
+ [padRight(a.frac, fracLen), padRight(b.frac, fracLen)],
814
+ [padRight(a.tail, tailLen), padRight(b.tail, tailLen)],
815
+ ];
816
+ const cells = [];
817
+ for (const [left, right] of rows) {
818
+ for (let i = 0; i < left.length; i += 1) {
819
+ cells.push({ from: left[i], to: right[i], changed: left[i] !== right[i] });
820
+ }
821
+ }
822
+ return cells;
823
+ }
824
+
825
+ /** 渲染一组滚动格。prefix 只用于 React key(两处调用各用一个前缀,互不干扰)。 */
826
+ function renderRollCells(cells, prefix) {
827
+ return jsx.jsx("span", {
828
+ 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
+ )),
845
+ });
846
+ }
847
+
691
848
  // ─── 高峰/空闲时段(纯客户端判断,无需服务端) ────────────────────────
692
849
  // V4 峰谷定价(2026-08-17 生效):高峰价 = 空闲价 × 2。
693
850
  // 官方高峰窗口(北京时间)为 工作日 09:00-12:00、14:00-18:00;
@@ -1011,6 +1168,8 @@ const CN_CALENDAR_SOURCES = [
1011
1168
  footerStyle: "timeline",
1012
1169
  showSidebarEntry: true,
1013
1170
  refreshWave: true,
1171
+ // 与服务端 DEFAULT_SETTINGS 逐字段保持一致(selftest 会逐键比对求值后的对象)
1172
+ usageLoadingStyle: "wipe",
1014
1173
  entrySeen: false,
1015
1174
  newTagVersion: "",
1016
1175
  newTagSince: 0,
@@ -1215,7 +1374,7 @@ const CN_CALENDAR_SOURCES = [
1215
1374
  try {
1216
1375
  const info = await fetchUpdateInfo();
1217
1376
  if (!info || info.ok !== true || info.newer !== true) return;
1218
- // latest 变了才重置 seen —— 用户已经无视过 2.1.0,再出 2.2.0 仍然要提示
1377
+ // latest 变了才重置 seen —— 用户已经无视过 2.0.7,再出 2.0.8 仍然要提示
1219
1378
  if (updateNotice.latest !== info.latest) {
1220
1379
  updateNotice = { latest: info.latest, seen: false };
1221
1380
  emitUpdateNotice();
@@ -2031,7 +2190,16 @@ function TimelineBand(props) {
2031
2190
  : null;
2032
2191
  const [roll, setRoll] = react.useState(null);
2033
2192
  const lastRollTextRef = react.useRef(null);
2193
+ // 手动擦出时的底层内容=「上一帧显示的值」。ref 天然滞后一帧,正好就是它。
2194
+ // 用户 2026-10-30 的原话:「没被光条扫到的区域接着显示 不要直接消失了」——
2195
+ // 所以扫过的那层(sweep)才是新内容,没扫到的部分由 base 撑着,仍是旧值。
2196
+ const revealBaseRef = react.useRef(null);
2034
2197
  react.useEffect(() => {
2198
+ if (rollText !== null) revealBaseRef.current = rollText;
2199
+ }, [rollText]);
2200
+ // 用 usePrePaintEffect(优先 useLayoutEffect):必须在浏览器 paint 之前就把 roll 挂上,
2201
+ // 否则这一帧画的是纯文本新值、下一帧才变成滚动格 —— 用户看到的就是"数字比动画先播"。
2202
+ usePrePaintEffect(() => {
2035
2203
  const prev = lastRollTextRef.current;
2036
2204
  lastRollTextRef.current = rollText;
2037
2205
  if (rollText === null || prev === null || prev === rollText) return;
@@ -2041,43 +2209,9 @@ function TimelineBand(props) {
2041
2209
  setRoll({ from: prev, to: rollText, at: Date.now() });
2042
2210
  }, [rollText]);
2043
2211
 
2044
- // 逐位拆开:只有真正变了的数位才做上下滚动,其余(小数点、货币后缀、
2045
- // 以及没变的高位数字)原样静止。所以花掉 0.01 时只有末位在翻,
2046
- // 花掉 1 块才会带动个位 —— 滚动范围跟着实际扣减的量走。
2047
- //
2048
- // 关键在于"按小数点对齐"而不是整串左对齐:
2049
- // 11.04 CNY → 8.04 CNY,左对齐会变成 1↔8 / 1↔. / .↔0 …整串错位,
2050
- // 按小数点分三段(整数右对齐、小数点一位、小数左对齐)之后,
2051
- // 整数位各自对位、小数 .04 原样不动,只有该变的位在翻。
2052
- const rollCells = roll === null ? null : (() => {
2053
- const split = (text) => {
2054
- const m = /^([0-9]*)(\.?)([0-9]*)([\s\S]*)$/.exec(text);
2055
- return m
2056
- ? { int: m[1], dot: m[2], frac: m[3], tail: m[4] }
2057
- : { int: "", dot: "", frac: "", tail: text };
2058
- };
2059
- const a = split(roll.from);
2060
- const b = split(roll.to);
2061
- const EMPTY = "\u0000";
2062
- const padLeft = (s, n) => s.padStart(n, EMPTY);
2063
- const padRight = (s, n) => s.padEnd(n, EMPTY);
2064
- const intLen = Math.max(a.int.length, b.int.length);
2065
- const fracLen = Math.max(a.frac.length, b.frac.length);
2066
- const tailLen = Math.max(a.tail.length, b.tail.length);
2067
- const rows = [
2068
- [padLeft(a.int, intLen), padLeft(b.int, intLen)],
2069
- [a.dot || b.dot, b.dot || a.dot],
2070
- [padRight(a.frac, fracLen), padRight(b.frac, fracLen)],
2071
- [padRight(a.tail, tailLen), padRight(b.tail, tailLen)],
2072
- ];
2073
- const cells = [];
2074
- for (const [left, right] of rows) {
2075
- for (let i = 0; i < left.length; i++) {
2076
- cells.push({ from: left[i], to: right[i], changed: left[i] !== right[i] });
2077
- }
2078
- }
2079
- return cells;
2080
- })();
2212
+ // 逐位拆开:只在真正变了的数位上做上下滚动。实现见模块级的 buildRollCells ——
2213
+ // 「本会话消耗」用的是同一个函数,两处滚动因此逐格一致(2026-10-30 用户要求)。
2214
+ const rollCells = roll === null ? null : buildRollCells(roll.from, roll.to);
2081
2215
  react.useEffect(() => {
2082
2216
  if (roll === null) return;
2083
2217
  const id = setTimeout(() => setRoll(null), 480);
@@ -2323,8 +2457,10 @@ function TimelineBand(props) {
2323
2457
  // 展开态余额行:设置里关掉就不渲染(收起态圆钮由上面的 showFab 单独控制)
2324
2458
  if (settings.showBalanceRow === false) return null;
2325
2459
 
2326
- // 右键刷新的浪潮层:只服务新版(timeline)。旧版保持备份里的原样反馈——
2327
- // 时段文字换成灰色「刷新中…」,不扫光。key 绑定 waveSeq,右键自增 → 重挂 → 动画重播。
2460
+ // 右键刷新的浪潮层:整块从左往右扫一道泛光。用户 2026-10-30 明确
2461
+ // 「别给我整个光条删了」——所以这层保留;金额胶囊内部另有 __reveal 的擦除揭示,
2462
+ // 两层叠加:泛光负责「你点了」,擦出负责「新数字出现」。
2463
+ // key 绑定 waveSeq,右键自增 → 重挂 → 动画重播。
2328
2464
  const waveNode = settings.refreshWave === false
2329
2465
  ? null
2330
2466
  : jsx.jsx("span", { className: "balance-footer__wave", key: "wave-" + waveSeq });
@@ -2393,41 +2529,34 @@ function TimelineBand(props) {
2393
2529
  metaItem(period === "peak" ? "高峰" : "空闲", " balance-footer__timeline-meta-item--" + period, "period"),
2394
2530
  ];
2395
2531
  if (span !== null) tail.push(metaItem("剩 " + formatRemain(span.remainMs), "", "remain"));
2396
- const metaChildren = [];
2397
- // 「今日 ¥x」独占一行(CSS 里 flex-basis:100%)。数据没到之前先摆一个不可见占位,
2398
- // 让这一行的高度从一开始就是最终高度 —— 条件渲染会让整块从 1 行变 2 行,
2399
- // 侧栏底部(含设置按钮)整体被顶下 14px,那一下重排就是「今日余额弹出来时卡住」。
2532
+ // 「今日 ¥x」是 meta 里的第一项(flex-basis:100%,独占第一行)——
2533
+ // 数据没到之前先摆一个不可见占位,让这一行的高度从一开始就是最终高度 ——
2534
+ // 条件渲染会让整块从 1 行变 2 行,侧栏底部(含设置按钮)整体被顶下 14px,
2535
+ // 那一下重排就是「今日余额弹出来时卡住」。
2400
2536
  // 占位只在「还在等」时出现;确认拿不到(tried 且为 null)就整项不渲染,不留空行。
2401
2537
  const todayCost = today !== null && Number.isFinite(Number(today.cost)) ? Number(today.cost) : null;
2402
2538
  const todayPending = todayCost === null && !todayCache.tried;
2539
+ const metaChildren = [];
2403
2540
  if (todayCost !== null || todayPending) {
2404
- metaChildren.push(
2405
- jsx.jsx(
2406
- "span",
2407
- {
2408
- className:
2409
- "balance-footer__timeline-meta-item balance-footer__timeline-meta-item--today" +
2410
- (todayCost === null ? " balance-footer__timeline-meta-item--pending" : " balance-footer__timeline-meta-item--in"),
2411
- children: todayCost === null ? "今日 ¥--" : "今日 ¥" + todayCost.toFixed(2),
2412
- },
2413
- "today",
2414
- ),
2415
- );
2541
+ metaChildren.push(jsx.jsx("span", {
2542
+ className:
2543
+ "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),
2546
+ }, "today"));
2416
2547
  }
2417
- metaChildren.push(
2418
- jsx.jsx("span", { className: "balance-footer__timeline-meta-group", children: tail }, "tail"),
2419
- );
2420
- const metaNode = jsx.jsx("span", {
2421
- className: "balance-footer__timeline-meta",
2422
- children: metaChildren,
2423
- });
2548
+ metaChildren.push(jsx.jsx("span", { className: "balance-footer__timeline-meta-group", children: tail }, "tail"));
2549
+ const metaNode = jsx.jsx("span", { className: "balance-footer__timeline-meta", children: metaChildren });
2424
2550
  // 新版把「meta 行 + 刻度条」当结构主体,不存在"关掉只剩金额"的形态,
2425
2551
  // 所以这里不看 showPeriodLabel;那个开关只在旧版有意义(见下面的 classic 分支)。
2426
2552
  const timelineBand = buildTimeline(timelineNow);
2427
2553
  // 刷新时不替换时段模块:刻度条保持原样,刷新反馈只落在右侧金额(半透明 + 浪潮)。
2428
- // 刻度条在 timeline 内部、meta 下方,宽度跟随侧栏(width:100%):
2429
- // [今日 ¥x / 空闲 剩 3h57m] [胶囊]
2554
+ // 刻度条在 timeline 内部、meta 下方,宽度跟随侧栏(width:100%)。
2555
+ // 容器是普通 flex(不 wrap),实际排版是两段:
2556
+ // [ 今日 ¥x] ← meta 第一行(独占、居中)
2557
+ // [空闲 剩 3h57m] [¥x • 空闲] ← meta 第二行 + 金额胶囊
2430
2558
  // [==== 刻度条(跟随侧栏宽度)====]
2559
+ // 「今日」占满左列宽度并居中,与下面「空闲 剩 3h57m」在左列内部成组。
2431
2560
  const periodNode = jsx.jsxs("span", {
2432
2561
  className: "balance-footer__timeline",
2433
2562
  children: [
@@ -2481,29 +2610,35 @@ function TimelineBand(props) {
2481
2610
  return withPanel(jsx.jsxs("div", {
2482
2611
  className: "balance-footer balance-footer--stacked",
2483
2612
  children: [
2484
- // 抹除层覆盖整块显示区(meta 行 + 时段条 + 金额):白条经过哪里,哪里才显影
2613
+ // 抹除层覆盖整块显示区(today 行 + meta 行 + 时段条):白条经过哪里,哪里才显影
2485
2614
  waveNode,
2486
2615
  periodNode === null
2487
2616
  ? jsx.jsx("span", { className: "balance-footer__spacer" })
2488
2617
  : periodNode,
2489
2618
  jsx.jsx("span", {
2490
2619
  className: "balance-footer__value" + (low ? " balance-footer__value--low" : " balance-footer__value--healthy") + (state.refreshing ? " balance-footer__value--stale" : "") + valueDensity,
2491
- children: rollCells === null
2492
- ? totalText
2493
- : jsx.jsx("span", {
2494
- className: "balance-footer__roll",
2495
- key: "roll-" + roll.at,
2496
- children: rollCells.map((cell, i) =>
2497
- cell.changed
2498
- ? jsx.jsxs("span", { className: "balance-footer__dcell balance-footer__dcell--roll", children: [
2499
- // 影子撑住这一格的尺寸(等于新字符),不显示
2500
- jsx.jsx("span", { className: "balance-footer__dghost", children: cell.to === "\u0000" ? "\u00a0" : cell.to }),
2501
- jsx.jsx("span", { className: "balance-footer__dout", children: cell.from === "\u0000" ? "\u00a0" : cell.from }),
2502
- jsx.jsx("span", { className: "balance-footer__din", children: cell.to === "\u0000" ? "\u00a0" : cell.to }),
2503
- ] }, i)
2504
- : jsx.jsx("span", { className: "balance-footer__dcell", children: cell.to === "\u0000" ? "\u00a0" : cell.to }, i)
2505
- ),
2506
- }),
2620
+ // key 绑定 waveSeq:只有右键手动刷新才 +1 → 重挂 → 白光把金额重新擦一遍。
2621
+ // 自动刷新(60s 轮询 / 对话完成触发的 fetch)不动 waveSeq,所以不会擦,
2622
+ // 数字继续走下面那套逐位滚动 —— 两套动效完全分开(2026-10-30 用户要求)。
2623
+ children: jsx.jsx("span", {
2624
+ className: "balance-footer__reveal" + (settings.refreshWave === false ? " balance-footer__reveal--off" : ""),
2625
+ key: "reveal-" + waveSeq,
2626
+ children: [
2627
+ // 底层(常显):光条没扫到的区域就是它,所以内容不会先消失再长出来。
2628
+ jsx.jsx("span", {
2629
+ className: "balance-footer__reveal-base",
2630
+ children: revealBaseRef.current !== null ? revealBaseRef.current : totalText,
2631
+ }, "base"),
2632
+ // 上层:被 clip-path 从左边擦开的那一层,光条走到哪,哪才盖住底层。
2633
+ jsx.jsx("span", {
2634
+ 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") }),
2639
+ }, "sweep"),
2640
+ ],
2641
+ }),
2507
2642
  }),
2508
2643
  ],
2509
2644
  }), undefined, undefined, rowInteractions);
@@ -2561,6 +2696,17 @@ function UsageCostLine({ useProjection, sessionId }) {
2561
2696
  ? (usage.uncachedInputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0) + (usage.outputTokens ?? 0)
2562
2697
  : 0;
2563
2698
 
2699
+ // 切会话的第一帧:projection 里的 usage 可能还是上一个会话的,这一帧算出来的金额
2700
+ // 会属于**旧对话**(用户 2026-10-30 反馈「点对话之前会显示余额,而且这个余额不是正确余额」)。
2701
+ // 判据:sessionId 变了、usageKey 没跟着变 → usage 还没换过来 → 这一帧不显示数字。
2702
+ // 只影响 1 帧(下一帧 effect 就更新 stamp),不会造成可见停顿。
2703
+ const usageStampRef = react.useRef({ sessionId, usageKey });
2704
+ const staleUsage =
2705
+ usageStampRef.current.sessionId !== sessionId && usageStampRef.current.usageKey === usageKey;
2706
+ react.useEffect(() => {
2707
+ usageStampRef.current = { sessionId, usageKey };
2708
+ });
2709
+
2564
2710
  // 解析本会话使用的模型,并按模型刷新定价(/balance-api?model=...)
2565
2711
  // 依赖 [sessionId, usageKey]:切对话或会话内容推进(新消息)都会触发;若还没解析到模型则总是重试。
2566
2712
  react.useEffect(() => {
@@ -2596,6 +2742,50 @@ function UsageCostLine({ useProjection, sessionId }) {
2596
2742
  };
2597
2743
  }, [sessionId, usageKey]);
2598
2744
 
2745
+ // ── 金额与逐位滚动 ────────────────────────────────────────────────────
2746
+ // ⚠️ 这段是**计算 + hooks**,必须写在下面两处早退之前:hook 不能跟在条件 return 后面,
2747
+ // 否则「usage 为 null 的帧」与「有 usage 的帧」hook 数量不同,React 会直接抛错。
2748
+ // 所以 splitFor / split / splitUsable / cost 在这里算,早退之后不再重复定义。
2749
+ const splitFor = sessionId ? splitMap[sessionId] || null : null;
2750
+ const split = splitFor ? splitFor.usage : null;
2751
+ const subagentUsage = splitFor ? splitFor.subagentUsage : null;
2752
+ // 服务端分桶可能"非 null 但为空"(拿不到该会话事件),此时必须回退本地投影估算,否则金额会显示 ¥0.0000
2753
+ const splitUsable =
2754
+ split != null && ((Array.isArray(split.byModel) && split.byModel.length > 0) || (split.totalTokens ?? 0) > 0);
2755
+ // 「这个会话问过服务端没有」——没问过就还在算。
2756
+ // 本地估算此时只是临时值:它把**整段历史**按当前时段价一次性乘(高峰窗口里就是 ×2),
2757
+ // 而服务端是按每条消息实际发生的时段分桶。所以高峰时段点进旧会话必然先高后低
2758
+ // (2026-10-30 实测:先显 ¥107、随即跳到 ¥69,比值 1.55 正好是「一半历史在空闲时段」)。
2759
+ // 与其先闪一个会变的数字,不如先留空,拿到结果后一次到位。
2760
+ const splitAsked =
2761
+ sessionId != null && sessionId !== "" && Object.prototype.hasOwnProperty.call(splitMap, sessionId);
2762
+ const costPending = !splitAsked && usageKey > 0;
2763
+ const cost = splitUsable
2764
+ ? computeSplitCost(split, p, peakMultiplier)
2765
+ : computeCost(usage ?? {}, periodAdjustedPricing(p, peakMultiplier));
2766
+ const costText = cost.toFixed(4);
2767
+
2768
+ // 逐位滚动:数值一变就翻(和余额胶囊自动刷新共用 buildRollCells/renderRollCells)。
2769
+ // usePrePaintEffect 是必须的 —— 等 useEffect(paint 之后)才挂滚动结构的话,
2770
+ // 会先渲染一帧纯文本新值,用户看到的就是「数字比动画先出来」(2026-10-30 反馈)。
2771
+ // skipCostRoll:切会话/待算/无 usage 时把基准清空,这样下次出现是「首次显示」而不是「从某个值滚过来」。
2772
+ // 加载动效风格:决定等待时显示什么、数据到手怎么进场(两种语言不混用)
2773
+ const loadingIsText = settings.usageLoadingStyle === "text";
2774
+ const skipCostRoll = staleUsage || costPending || !usage;
2775
+ const [costRoll, setCostRoll] = react.useState(null);
2776
+ const lastCostTextRef = react.useRef(null);
2777
+ usePrePaintEffect(() => {
2778
+ const prev = lastCostTextRef.current;
2779
+ lastCostTextRef.current = skipCostRoll ? null : costText;
2780
+ if (skipCostRoll || prev === null || prev === costText) return;
2781
+ setCostRoll({ from: prev, to: costText, at: Date.now() });
2782
+ }, [costText, skipCostRoll]);
2783
+ usePrePaintEffect(() => {
2784
+ if (costRoll === null) return;
2785
+ const id = setTimeout(() => setCostRoll(null), 480);
2786
+ return () => clearTimeout(id);
2787
+ }, [costRoll]);
2788
+
2599
2789
  if (!usage) return null;
2600
2790
 
2601
2791
  const uncachedInput = usage.uncachedInputTokens ?? 0;
@@ -2608,15 +2798,6 @@ function UsageCostLine({ useProjection, sessionId }) {
2608
2798
  const billedTotal = uncachedInput + cacheRead + cacheWrite + output;
2609
2799
  if (newTokens === 0 && billedTotal === 0) return null;
2610
2800
 
2611
- // 金额:优先用服务端分时段 usage(高峰桶×2+空闲桶);未就绪时按当前时段价估算。
2612
- // 只认本会话的分桶 —— 切到没缓存的会话时立刻退回本地估算,绝不用上一个会话的数据。
2613
- const splitFor = sessionId ? splitMap[sessionId] || null : null;
2614
- const split = splitFor ? splitFor.usage : null;
2615
- const subagentUsage = splitFor ? splitFor.subagentUsage : null;
2616
- // 服务端分桶可能"非 null 但为空"(拿不到该会话事件),此时必须回退本地投影估算,否则金额会显示 ¥0.0000
2617
- const splitUsable =
2618
- split != null && ((Array.isArray(split.byModel) && split.byModel.length > 0) || (split.totalTokens ?? 0) > 0);
2619
- const cost = splitUsable ? computeSplitCost(split, p, peakMultiplier) : computeCost(usage, periodAdjustedPricing(p, peakMultiplier));
2620
2801
  const peakTokens = splitUsable ? split.peak.inputTokens + split.peak.cacheReadTokens + split.peak.cacheWriteTokens + split.peak.outputTokens : 0;
2621
2802
  const offpeakTokens = splitUsable ? split.offpeak.inputTokens + split.offpeak.cacheReadTokens + split.offpeak.cacheWriteTokens + split.offpeak.outputTokens : 0;
2622
2803
 
@@ -2642,7 +2823,48 @@ function UsageCostLine({ useProjection, sessionId }) {
2642
2823
  ? jsx.jsx(primitives.IconDatabaseOutlineRegular, { key: "icon" })
2643
2824
  : null,
2644
2825
  jsx.jsx("span", { key: "label", className: "balance-usage-label", children: "本会话消耗" }),
2645
- jsx.jsx("span", { key: "cost", className: "balance-usage-label", children: "≈ ¥" + cost.toFixed(4) }),
2826
+ jsx.jsx("span", {
2827
+ key: "cost",
2828
+ className: "balance-usage-cost" + (costPending ? " balance-usage-cost--pending" : ""),
2829
+ // 两种加载动效二选一(usageLoadingStyle),**两套语言不混用**:
2830
+ // "wipe"(默认):等待时留空 → 数据到手挂 .balance-usage-reveal,白光擦过、数字跟着亮。
2831
+ // "text":等待时显示带呼吸的「计算中…」胶囊 → 数据到手**直接渐入**,不放光条
2832
+ // (2026-10-30 用户:「用的如果是计算中就不要用光条动画了 计算完直接渐入就行了」)。
2833
+ // 两种都只在等待期间出现,都不亮错误值。
2834
+ children: staleUsage || costPending
2835
+ ? (loadingIsText
2836
+ ? jsx.jsxs("span", {
2837
+ className: "balance-usage-calc",
2838
+ key: "calc-" + (sessionId || ""),
2839
+ children: [
2840
+ "计算中",
2841
+ // 三个点拆开逐个闪:单个 "…" 是一个字,没法分段动
2842
+ jsx.jsx("span", {
2843
+ className: "balance-usage-calc__dots",
2844
+ "aria-hidden": "true",
2845
+ children: [0, 1, 2].map((i) => jsx.jsx("span", { className: "balance-usage-calc__dot", children: "." }, i)),
2846
+ }, "dots"),
2847
+ ],
2848
+ })
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",
2855
+ 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
+ }),
2867
+ }),
2646
2868
  ];
2647
2869
 
2648
2870
  // ── 点击展开的明细浮层(结构与 DSH 原生「会话统计」一致:标题 + 分隔线 + dl 两列网格)──
@@ -2670,6 +2892,13 @@ function UsageCostLine({ useProjection, sessionId }) {
2670
2892
  ["计费 tokens", formatTokens(billedTotal) + " tok"],
2671
2893
  ["模型", model || "未知(按 flash 价估算)"],
2672
2894
  ];
2895
+ // 金额口径要写明白:算完 = 服务端逐条按发生时段分桶;算完前 / 拿不到 = 本地按当前时段估算。
2896
+ // 估算会把整段历史按「此刻」的峰谷价乘一次,高峰窗口里自然偏高 —— 说清楚,别让它冒充精确值。
2897
+ if (costPending) {
2898
+ costDetails.push(["金额", "正在取服务端分时段用量"]);
2899
+ } else if (!splitUsable) {
2900
+ costDetails.push(["金额", "按当前时段估算(服务端未返回分桶)"]);
2901
+ }
2673
2902
  if (splitUsable) {
2674
2903
  costDetails.push(
2675
2904
  ["高峰 tokens", formatTokens(peakTokens) + " tok"],
@@ -2965,19 +3194,30 @@ function UsageCostLine({ useProjection, sessionId }) {
2965
3194
  const [calendarSync, setCalendarSync] = react.useState({ kind: "idle", text: "" });
2966
3195
  const calendarBusy = calendarSync.kind === "syncing";
2967
3196
  const calendarSummary = (() => {
2968
- if (calendarSync.kind === "ok" || calendarSync.kind === "error") return calendarSync.text;
3197
+ // 同步失败:直接把服务端给的原因摆出来,别拿旧数据冒充成功。
3198
+ if (calendarSync.kind === "error") return calendarSync.text;
3199
+ // 只要有数据就一律显示同一句「已同步 N 天 · 上次同步时刻 · 待公布」——
3200
+ // **同步成功之后同样要显示时刻**(2026-10-30 反馈「只有在同步过程中才显示上次同步时间」)。
3201
+ // 旧写法让 ok 分支返回另拼的一句「已更新 2025 / 2026(72 天)(2027 待公布)」:
3202
+ // 既丢了时刻,又把年份列表塞回来,200px 宽里直接折成两行、断在「公布)」中间。
3203
+ // 现在 ok 只负责变色(--ok 绿),文案一律由这里按缓存渲染。
2969
3204
  if (calendar.fetchedAt > 0) {
2970
3205
  const at = new Date(calendar.fetchedAt);
2971
3206
  const stamp =
2972
3207
  at.getFullYear() + "-" + String(at.getMonth() + 1).padStart(2, "0") + "-" +
2973
3208
  String(at.getDate()).padStart(2, "0") + " " + String(at.getHours()).padStart(2, "0") + ":" +
2974
3209
  String(at.getMinutes()).padStart(2, "0");
2975
- const years = calendar.years.length > 0 ? calendar.years.join(" / ") : "—";
2976
3210
  // 只报日期与时刻,不报完整年份列表:那串太长会把左边的标题挤成一字一行。
2977
3211
  // 「同步了哪几年」由服务端 days 的键直接决定,用户点「立即同步」那边也能看到。
2978
- const pending = calendar.missing.length > 0 ? " · " + calendar.missing.join(" / ") + " 待公布" : "";
3212
+ // 「2027 待公布」内部用不换行空格(\\u00A0)焊死:卡片窄到 298px 时,
3213
+ // 普通空格会允许断在「2027」和「待公布」之间,把这三个字拆成两行(实测)。
3214
+ // 焊死后只可能断在前面的「 · 」处,两行就是「已同步…时刻」/「· 2027 待公布」。
3215
+ const pending = calendar.missing.length > 0
3216
+ ? " · " + calendar.missing.map(String).join("\u00A0/\u00A0") + "\u00A0待公布"
3217
+ : "";
2979
3218
  return "已同步 " + calendar.count + " 天 · " + stamp.slice(5) + pending;
2980
3219
  }
3220
+ if (calendarSync.kind === "syncing") return "正在同步…";
2981
3221
  return "尚未同步,正在用插件内置表。";
2982
3222
  })();
2983
3223
  const onSyncCalendar = async () => {
@@ -2986,14 +3226,9 @@ function UsageCostLine({ useProjection, sessionId }) {
2986
3226
  try {
2987
3227
  const data = await syncCalendar();
2988
3228
  if (data && data.ok === true) {
2989
- const years = Array.isArray(data.years) && data.years.length > 0 ? data.years.join(" / ") : "—";
2990
- const pending = Array.isArray(data.pending) && data.pending.length > 0
2991
- ? "(" + data.pending.join(" / ") + " 待公布)"
2992
- : "";
2993
- setCalendarSync({
2994
- kind: "ok",
2995
- text: "已更新 " + years + "(" + (Number(data.added) || 0) + " 天)" + pending,
2996
- });
3229
+ // 成功不另拼一句文案:syncCalendar() 内部已经 adoptCalendar(data) 刷新了缓存,
3230
+ // calendarSummary 会带着新的同步时刻自己重渲染;这里只把状态切成 ok(绿色)。
3231
+ setCalendarSync({ kind: "ok", text: "" });
2997
3232
  } else {
2998
3233
  setCalendarSync({
2999
3234
  kind: "error",
@@ -3143,6 +3378,7 @@ function UsageCostLine({ useProjection, sessionId }) {
3143
3378
  ["footerStyle", "2.0.0"], // 余额行样式(新版 timeline / 旧版 classic)
3144
3379
  ["showSidebarEntry", "2.0.0"], // 左侧导航栏入口
3145
3380
  ["refreshWave", "2.0.0"], // 右键刷新涟漪
3381
+ ["usageLoadingStyle", "2.0.7"], // 消耗行加载动效:白光 / 计算中
3146
3382
  ]);
3147
3383
  const newTagSince = Number(value.newTagSince);
3148
3384
  // 升级前的版本(空串 = 全新安装)
@@ -3473,9 +3709,11 @@ function UsageCostLine({ useProjection, sessionId }) {
3473
3709
  "balance-settings__calendar-note" +
3474
3710
  (calendarSync.kind === "error"
3475
3711
  ? " balance-settings__calendar-note--error"
3476
- : calendar.fetchedAt > 0
3477
- ? ""
3478
- : " balance-settings__calendar-note--warn"),
3712
+ : calendarSync.kind === "ok"
3713
+ ? " balance-settings__calendar-note--ok"
3714
+ : calendar.fetchedAt > 0
3715
+ ? ""
3716
+ : " balance-settings__calendar-note--warn"),
3479
3717
  children: calendarSummary,
3480
3718
  }),
3481
3719
  jsx.jsx("button", {
@@ -3524,9 +3762,14 @@ function UsageCostLine({ useProjection, sessionId }) {
3524
3762
  // 浪潮 = 新版独有(旧版刷新走「刷新中…」文字,没有白条可关)
3525
3763
  // 时段标签 = 新版不可关闭(刻度条是新版的结构主体),只有旧版才允许收成纯金额
3526
3764
  value.footerStyle === "timeline"
3527
- ? toggleRow("refreshWave", "右键刷新浪潮", "右键余额时,余额行从左往右扫过一道泛光亮带作为反馈。")
3765
+ ? toggleRow("refreshWave", "右键刷新浪潮", "右键余额时,金额会被一道白光从左往右擦出来(和输入区「本会话消耗」同一个动效)。")
3528
3766
  : null,
3529
3767
  toggleRow("showUsagePill", "会话消耗行", "输入框下方的「本会话消耗」胶囊(含点开的明细浮层)。"),
3768
+ // 两种加载动效互斥,用分段按钮选(和「余额行外观」同一个控件,视觉一致)
3769
+ choiceRow("usageLoadingStyle", "消耗行加载动效", "等分时段用量时显示什么。", [
3770
+ { value: "wipe", label: "白光" },
3771
+ { value: "text", label: "计算中" },
3772
+ ]),
3530
3773
  value.footerStyle === "classic"
3531
3774
  ? toggleRow("showPeriodLabel", "高峰 / 空闲标签", "旧版余额行里「金额 • 时段」的时段部分;关掉后只留金额。")
3532
3775
  : null,
package/lib/index.js CHANGED
@@ -527,7 +527,7 @@ function formatBalance(balance) {
527
527
  // | { status:"failed" } (网络失败;官方明确“失败绝不变成 0 余额”)
528
528
  // client 是 AccountClientMetadata(version / locale / timezoneOffsetSeconds),
529
529
  // 缺字段会在平台侧组装请求头时抛错,所以三个字段都要给。
530
- const PLUGIN_VERSION = "2.0.5";
530
+ const PLUGIN_VERSION = "2.0.7";
531
531
 
532
532
  // ─── 检查更新 ───────────────────────────────────────────────────────────────
533
533
  // 只查 npm registry 的 latest 标签(一个很小的 JSON),不下载任何东西。
@@ -1178,6 +1178,11 @@ const DEFAULT_SETTINGS = {
1178
1178
  showSidebarEntry: true,
1179
1179
  // 2.0.0:右键刷新时,余额行从左往右扫过一道泛光亮带
1180
1180
  refreshWave: true,
1181
+ // 2.0.7:「本会话消耗」在等分时段用量时用哪种加载动效。
1182
+ // "wipe"(默认)= 白光擦出:数据到手那一刻一道白光从左扫过,数字跟着光显现。
1183
+ // "text" = 显示带呼吸动画的「计算中…」文字,数据到手后直接显示数字。
1184
+ // 两种都只在等待期间出现,不会先亮一个错误值再换成正确值。
1185
+ usageLoadingStyle: "wipe",
1181
1186
  // 侧栏「余额与用量」入口是否已被点开过。false = 显示红点。
1182
1187
  // 与版本号无关:只有用户真正点开过这个面板才会被写成 true,之后永不回退。
1183
1188
  entrySeen: false,
@@ -1305,6 +1310,10 @@ function normalizeSettings(raw) {
1305
1310
  footerStyle: src.footerStyle === "classic" ? "classic" : d.footerStyle,
1306
1311
  showSidebarEntry: bool(src.showSidebarEntry, d.showSidebarEntry),
1307
1312
  refreshWave: bool(src.refreshWave, d.refreshWave),
1313
+ // 枚举值只认已知项,其余(含旧版的布尔写入)一律回落默认
1314
+ usageLoadingStyle: src.usageLoadingStyle === "text" || src.usageLoadingStyle === "wipe"
1315
+ ? src.usageLoadingStyle
1316
+ : d.usageLoadingStyle,
1308
1317
  entrySeen: bool(src.entrySeen, d.entrySeen),
1309
1318
  newTagVersion: typeof src.newTagVersion === "string" ? src.newTagVersion : d.newTagVersion,
1310
1319
  newTagFrom: typeof src.newTagFrom === "string" ? src.newTagFrom : d.newTagFrom,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "2.0.5",
3
+ "version": "2.0.7",
4
4
  "description": "在 DeepSeek Harness 中显示 DeepSeek 账户余额和当前会话 token/费用消耗(含高峰/空闲峰谷计费)。仅支持 DeepSeek 官方模型与账号,暂不支持其他大模型。",
5
5
  "author": "Rannist",
6
6
  "type": "module",