balance-dsh 2.0.8 → 2.0.9

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 +481 -79
  2. package/lib/index.js +26 -9
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -218,6 +218,9 @@ window.__ModuleLoader__.load({
218
218
  /* 有新版本时的文字提示:品牌色实心小胶囊,绝对定位压在图标右上,
219
219
  视觉上落在「余额与用量」几个字的上方。不做脉冲 —— 那是红点的语言,且静止更醒目。 */
220
220
  .balance-panel-icon__new{position:absolute;left:10px;top:-10px;box-sizing:border-box;padding:1px 4px;border-radius:999px;corner-shape:round;background:#4d6bfe;color:var(--dsw-alias-label-primary-foreground,#fff);font-size:8.5px;line-height:11px;font-weight:600;letter-spacing:.1px;white-space:nowrap;pointer-events:none;box-shadow:0 1px 4px color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 55%,transparent)}
221
+ /* 有新功能时的入口标签(文案就是 NEW,比「新版本!」短,宽度收紧一点、
222
+ 字号抬到 9px 让三个字母站得住)。品牌色实心 + 轻投影,跟「新版本!」同一套语言。 */
223
+ .balance-panel-icon__new--feature{padding:1px 3.5px;font-size:9px;line-height:11px;letter-spacing:.4px}
221
224
  @keyframes balance-dot-pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(.82);opacity:.72}}
222
225
  @media (prefers-reduced-motion:reduce){.balance-panel-icon__dot{animation:none}}
223
226
  /* ── 侧栏面板页(点左侧「余额与用量」后渲染在主区域) ──────────────────── */
@@ -296,16 +299,70 @@ window.__ModuleLoader__.load({
296
299
  @media (prefers-reduced-motion:reduce){
297
300
  .balance-usage-calc__dot{animation:none;opacity:.75}
298
301
  }
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}
302
+ /* 消耗行两段式动效(2026-10-31 用户定稿)。
303
+ ① 等待=「Siri 那种一团光在容器里游动」(用户:「就跟siri一样的动画 然后填充满余额栏」)。
304
+ 做法:把容器当画框(overflow:hidden + 胶囊圆角),里面放 4 团超大的模糊径向光,
305
+ 各自用不同的周期 / 延迟 / 路径在框内横向游走 + 上下起伏 + 缩放呼吸。
306
+ 光团交叠处更亮、被圆角裁掉边缘 —— 这就是 Siri 那种「液体能量」观感的来源。
307
+ 关键三点,少一个就不像:**铺满整条栏**(光团宽度 40–64px,容器本身 100%)、
308
+ **边缘糊开**(radial-gradient 收尾 + blur 3.5px,不能是实心圆)、
309
+ **四团不同步**(同期同相会变成整齐的呼吸灯)。
310
+ 之前的「9 颗小粒子」方案作废:1–2px 的点在深色底上根本看不出在动。
311
+ ② 到手=白光从左往右扫过把数字擦出来,见下方 .balance-usage-reveal。
312
+ 只播一次(key 绑 sessionId),之后数值变动仍走内层逐位滚动,两套动效分开。 */
313
+ .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;-webkit-mask-image:linear-gradient(90deg,transparent 0,#000 13%,#000 87%,transparent 100%);mask-image:linear-gradient(90deg,transparent 0,#000 13%,#000 87%,transparent 100%)}
314
+ /* 底光:保证任何时刻整条栏都有光,不留黑缝(只有游动的光团时,间隙处会断开成一截一截)。 */
315
+ .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}
316
+ @keyframes balance-siri-base{0%,100%{opacity:.6}50%{opacity:1}}
317
+ /* 泛光来自三层叠加:radial-gradient 从纯白起手(0% 处 .99)、blur 把边缘彻底化开、
318
+ 再挂一圈 box-shadow 做大范围外发光。brightness 1.3 把整体顶亮一档,
319
+ 否则 mix-blend-mode:screen 在深底上会显得发灰(2026-10-31 用户:「白色部分能不能亮一点 泛光的感觉」)。 */
320
+ .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%}
321
+ .balance-usage-mist__orb:nth-child(1){animation:balance-siri-a 2.6s cubic-bezier(.45,.05,.55,.95) infinite alternate}
322
+ .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}
323
+ .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}
324
+ .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}
325
+ .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}
326
+ @keyframes balance-siri-pulse{0%,100%{opacity:.84}50%{opacity:1}}
327
+ @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%}}
328
+ @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%}}
329
+ @keyframes balance-siri-c{0%{transform:translate(55%,-7%) scale(.72);border-radius:60% 40% 52% 48%}50%{transform:translate(195%,11%) scale(1.22);border-radius:44% 56% 40% 60%}100%{transform:translate(85%,4%) scale(.95);border-radius:53% 47% 57% 43%}}
306
330
  @media (prefers-reduced-motion:reduce){
307
- .balance-usage-reveal{animation:none;clip-path:none}
308
- .balance-usage-reveal::after{animation:none;content:none}
331
+ .balance-usage-mist{animation:none}
332
+ .balance-usage-mist__orb{animation:none;transform:translate(40%,0) scale(1);opacity:.55}
333
+ .balance-usage-mist__orb:nth-child(n+2){display:none}
334
+ }
335
+ /* 数字出场=开窗(2026-10-31 用户第三次定稿:「是从中间往两边打开 跟开窗一样 你现在这样太丑了」)。
336
+ 前两版都被否,原因记在这里别再走回头路:
337
+ ✗ 第一版「白光从左往右扫过」—— 一根白色竖条横穿,用户:「不要那个白色的竖条了」;
338
+ ✗ 第二版「中心光团绽开 + 数字过曝回落」—— 模糊圆光叠在胶囊里发糊,用户:「太丑了」。
339
+ 所以这一版**只留几何动作,不留任何发光物**:两扇窗从正中同时向两侧滑开,数字跟着露出来。
340
+ - clip-path 左右对称,从中缝 inset(0 50% 0 50%) 起步、满宽收尾(-4px 让最右侧字符不被切边);
341
+ - 曲线 cubic-bezier(.2,.86,.24,1):起步快、尾巴稳,读起来是「被推开」而不是「被擦过」;
342
+ - 时长 .28s,比第一版的 .22s 慢一档 —— 开窗这个动作需要看得清,扫过才需要快。
343
+ ⚠️ 只在**数据到手那一刻**挂载(key 绑 sessionId),所以只开一次;
344
+ 之后的数值变动走内层逐位滚动,两套动效分开。
345
+ ⚠️ keyframes 独立命名,**不复用 balance-calc-reveal**(那个是横扫、且被
346
+ 余额行的 .balance-footer__reveal-sweep 共用),两者互不干扰。 */
347
+ .balance-usage-reveal{position:relative;display:inline-flex;align-items:center;overflow:hidden;height:20px;height:1lh;padding:0 4px;margin:0 -4px}
348
+ /* 底层:加载态那团光,用 mask 在**正中间挖一个椭圆洞**并随开窗扩大。
349
+ 数字在洞里显出来,光留在两侧 —— 这就是用户要的「没展开到的地方还是加载动画」,
350
+ 而数字背后是干净的(洞与数字同步扩大,光不会透到字缝里)。
351
+ 洞的边缘走 58%→100% 的渐变,所以边界是柔的,不会像 clip 那样切出一条直线。
352
+ ⚠️ 为什么用 mask 而不是两片元素拼接:两片必须 overflow:hidden 才不互相盖住,
353
+ 而一旦裁切,胶囊的圆角就被切成直角,接缝处还会出现一条硬边(三版都试过,全丑)。
354
+ ⚠️ --balance-hole 必须用 @property 注册成 <percentage> 才能被动画插值,否则 keyframes 里
355
+ 的百分比是离散跳变(0% 到 100% 一帧完成,等于没有动画)。 */
356
+ @property --balance-hole{syntax:"<percentage>";inherits:false;initial-value:0%}
357
+ .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}
358
+ .balance-usage-reveal__mist .balance-usage-mist{width:100%;min-width:0;height:100%}
359
+ @keyframes balance-mist-hole{0%{--balance-hole:0%}100%{--balance-hole:86%}}
360
+ /* 上层:数字本体,clip-path 从中缝向两侧打开 —— 「开窗」这个动作只发生在这一层。 */
361
+ .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}
362
+ @keyframes balance-window-open{0%{clip-path:inset(0 50% 0 50%)}100%{clip-path:inset(0 -4px 0 -4px)}}
363
+ @media (prefers-reduced-motion:reduce){
364
+ .balance-usage-reveal__body{animation:none;clip-path:none}
365
+ .balance-usage-reveal__mist{display:none}
309
366
  }
310
367
  .balance-usage-cost-inner{display:inline-flex;align-items:center;white-space:nowrap;height:20px;height:1lh}
311
368
  /* ≈ 的视觉修正:字形本身偏低,用 em 单位上提(跟随字号缩放),右侧留一点呼吸缝。
@@ -424,12 +481,43 @@ window.__ModuleLoader__.load({
424
481
  /* 顶部告示条:设置页最前面的一条范围声明。底色/边框比分组说明再重一档,
425
482
  文字提到 primary,整块在深色背景里第一个跳出来。 */
426
483
  .balance-settings__notice{display:flex;align-items:flex-start;gap:8px;margin:0 0 14px;padding:10px 12px;border-radius:var(--dsw-radius-md,8px);border:.5px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 34%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 13%,transparent);font-size:12.5px;line-height:18px;color:var(--dsw-alias-label-primary)}
427
- .balance-settings__notice-icon{flex:none;margin-top:1px;color:var(--dsw-alias-brand-primary,#4d6bfe)}
484
+ .balance-settings__notice-icon{flex:none;display:block;margin-top:1px;color:var(--dsw-alias-brand-primary,#4d6bfe)}
428
485
  .balance-settings__notice-text{min-width:0}
486
+ /* 「本版新增」横幅:不依赖侧栏入口也能知道有新功能。
487
+ 侧栏那颗 NEW 只在入口可见时才起作用,用户把「侧边栏显示本插件」关掉、
488
+ 或者从 DSH「插件」页直接进来时,这是唯一的提示(2026-10-31 用户问
489
+ 「如果用户没打开这个设置页面入口 怎么提示用户有新功能」)。
490
+ 用品牌色实心胶囊当引子 + 一行白字说明 + 右侧跳转按钮,视觉上比告示条更"新"。 */
491
+ .balance-settings__newcue{display:flex;align-items:center;gap:9px;margin:0 0 12px;padding:9px 12px;border-radius:var(--dsw-radius-md,8px);border:.5px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 12%,transparent);font-size:12.5px;line-height:18px;color:var(--dsw-alias-label-primary)}
492
+ .balance-settings__newcue-badge{flex:none;padding:1.5px 6px;border-radius:999px;corner-shape:round;background:var(--dsw-alias-brand-primary,#4d6bfe);color:var(--dsw-alias-label-primary-foreground,#fff);font-size:9.5px;line-height:12px;font-weight:700;letter-spacing:.4px}
493
+ .balance-settings__newcue-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
494
+ .balance-settings__newcue-btn{flex:none;padding:2px 9px;border-radius:999px;corner-shape:round;border:.5px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 45%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 18%,transparent);color:var(--dsw-alias-brand-primary,#4d6bfe);font:inherit;font-size:11.5px;line-height:16px;cursor:pointer}
495
+ .balance-settings__newcue-btn:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 28%,transparent)}
496
+ /* 跳到目标行时闪一下品牌色底,指出"就是这一行"。
497
+ 五条约束(2026-10-31 用户四轮反馈后才定下来,改之前先读完):
498
+ 1. **必须是蓝的** —— 别用 color-mix(品牌色 x%, transparent):transparent 是
499
+ rgba(0,0,0,0),跟它混合等于往黑里兑,落在深色底上是一块灰(被报过「没颜色」)。
500
+ 2. **必须圆角** —— 满宽方角就是一块灰板(被问过「圆角呢」)。
501
+ 3. **向两边各延伸一点点** —— 贴原行宽显得局促;8px 被嫌长,取 6px。
502
+ 4. **绝对不能动文字位置** —— 前两版都是给行本身加 margin:0 -6px + 等量 padding 补偿,
503
+ 数学上内容区宽度和起点都对,但负 margin 参与 flex 布局,浏览器重排后字就是会跳
504
+ (被报过「字体又变动了」)。现在改用绝对定位的伪元素画底色:inset:0 -6px 相对行本身
505
+ 定位,**完全不参与布局**,无论左右扩多少,文字都钉在原地。
506
+ 5. 不要动 border-top —— 那是分隔线,抹掉会让相邻两行粘在一起。
507
+ isolation:isolate 建立层叠上下文,::before 的 z-index:-1 才会落在本行背景之上、
508
+ 内容之下(少了它会沉到卡片底色后面,整块看不见)。 */
509
+ .balance-settings__row--flash{position:relative;isolation:isolate}
510
+ .balance-settings__row--flash::before{content:"";position:absolute;inset:0 -6px;border-radius:var(--dsw-radius-md,8px);background:rgba(77,107,254,.17);pointer-events:none;z-index:-1;animation:balance-row-flash 1.4s ease-out both}
511
+ /* ⚠️ 末尾的 both 不能省。少了它动画播完 opacity 会回到元素默认值 1,
512
+ 蓝底完整地再亮一次、直到 1.5s 时 class 被 JS 摘掉才消失 ——
513
+ 看起来就是「高亮之后又闪一下」(2026-10-31 用户报的 bug)。
514
+ both = 播完停在 100% 的 opacity:0,跟后续移除 class 无缝衔接。 */
515
+ @keyframes balance-row-flash{0%,30%{opacity:1}100%{opacity:0}}
516
+ @media (prefers-reduced-motion:reduce){.balance-settings__row--flash::before{display:none}}
429
517
  /* 分组说明条:不是灰小字,而是带图标的淡蓝提示条——要一眼看见「仅支持 DeepSeek Harness」。
430
518
  底色用品牌色 10%、边框 26%,文字提到 secondary;图标跟文字同色。 */
431
519
  .balance-settings__group-note{display:flex;align-items:flex-start;gap:7px;margin:3px 0 5px;padding:8px 10px;border-radius:var(--dsw-radius-md,8px);border:.5px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 26%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 10%,transparent);font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary)}
432
- .balance-settings__group-note-icon{flex:none;margin-top:1.5px;color:var(--dsw-alias-brand-primary,#4d6bfe);opacity:.9}
520
+ .balance-settings__group-note-icon{flex:none;display:block;margin-top:.5px;color:var(--dsw-alias-brand-primary,#4d6bfe);opacity:.9}
433
521
  .balance-settings__group-note-text{min-width:0}
434
522
  .balance-settings__group-title::before{content:"";flex:none;width:3px;height:11px;border-radius:1px;corner-shape:round;background:var(--dsw-alias-brand-primary,#4d6bfe);opacity:.75}
435
523
  .balance-settings__head-btn{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;box-sizing:border-box;padding:0;border:0;background:0 0;font:inherit;text-align:left;cursor:pointer;color:inherit}
@@ -492,6 +580,27 @@ window.__ModuleLoader__.load({
492
580
  .balance-settings__segment{box-sizing:border-box;padding:2px 12px;border:none;border-radius:999px;corner-shape:round;background:0 0;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;font-weight:500;cursor:pointer;white-space:nowrap;transition:background .15s var(--ds-ease-in-out,.2s),color .15s var(--ds-ease-in-out,.2s)}
493
581
  .balance-settings__segment:hover{color:var(--dsw-alias-label-primary)}
494
582
  .balance-settings__segment--on{background:var(--dsw-alias-bg-module-platform,var(--dsw-alias-bg-base,#2a2a2c));color:var(--dsw-alias-label-primary);box-shadow:0 1px 2px rgba(0,0,0,.2)}
583
+ /* 「消耗行加载动效」那一行的实时预览(2026-10-31 用户要求:「在介绍栏预览给用户看红圈位置」)。
584
+ 直接复用消耗行的真实类名(.balance-usage-mist / .balance-usage-calc),
585
+ 所以这里看到的就是输入框下方真会出现的那个效果 —— 两边永远不会走样,改一处两边一起变。
586
+ 两个样式并排常驻播放,当前选中的那个描边高亮 + 不透明,另一个压到 45% 做对照。
587
+ 窄屏整块隐藏:640px 以下 label 已经被挤得很窄,再加 160px 预览会把文字压断。 */
588
+ .balance-settings__preview{display:flex;align-items:center;gap:8px;margin-left:auto;margin-right:8px;flex:none}
589
+ .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}
590
+ .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))}
591
+ .balance-settings__preview-tag{font-size:9.5px;line-height:1;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
592
+ /* 预览里要把「本会话消耗」这几个字一起画出来 —— 只放一条光带的话看不出这是哪一行,
593
+ 真实的那颗胶囊就是「本会话消耗 + 效果」,预览得对得上(2026-10-31 用户:「这个不应该显示对话消耗吗」)。
594
+ 文字压到 10.5px、效果区收窄到 44px,两个并排整体约 210px,不至于把右侧分段按钮挤走。 */
595
+ .balance-settings__preview-stage{position:relative;display:inline-flex;align-items:center;gap:5px;height:20px}
596
+ .balance-settings__preview-text{font-size:10.5px;line-height:14px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}
597
+ .balance-settings__preview-stage .balance-usage-mist{min-width:0;width:44px}
598
+ /* .balance-usage-calc 本身只定了尺寸和圆角,字号/行高/nowrap 是从消耗行的 .balance-usage-line 继承来的
599
+ —— 预览里没有那层,不补这三行「计算中」会折成「计算 / 中」两行并撑破胶囊。 */
600
+ .balance-settings__preview-stage .balance-usage-calc{height:18px;padding:0 7px;font-size:calc(var(--dsh-content-font-size-secondary,13px) - 1px);line-height:18px;white-space:nowrap}
601
+ @media (max-width:640px){
602
+ .balance-settings__preview{display:none}
603
+ }
495
604
  /* 版本与更新区块:设置页第一块(紧跟计费范围告示条)。
496
605
  默认是中性底色,只在「有新版本」时提亮成品牌色、「刚更新完」时转成功色,
497
606
  失败不染色(网络波动不该看起来像错误告警)。 */
@@ -959,16 +1068,24 @@ const CN_CALENDAR_SOURCES = [
959
1068
  }
960
1069
  }
961
1070
 
962
- const NEW_TAG_TTL_MS = 3 * 24 * 60 * 60 * 1000; // NEW 徽标展示 3 天
1071
+ // 设置项旁那颗 NEW 的展示时长:24 小时(2026-10-31 由 3 天改短)。
1072
+ // 入口那颗 NEW 不看这个时长,它的熄灭条件是「用户点进去过」,见 unseenNewTags。
1073
+ const NEW_TAG_TTL_MS = 24 * 60 * 60 * 1000;
963
1074
 
964
1075
  /** 单项 NEW 判定。每项自带起点,互不影响:
965
1076
  * - record 里已记下这一版(v === intro)→ 就用 t 计时,不再判 fresh
966
1077
  * (否则刚记完就会因 "intro 不大于自己"而立刻消失)
967
1078
  * - 没记录或引入版本变了 → 比较「引入版本 vs 用户见过的版本」
968
- * 旧功能因 fresh=false 直接不显示,所以不可能被新版本重新计时。 */
1079
+ * 旧功能因 fresh=false 直接不显示,所以不可能被新版本重新计时。
1080
+ *
1081
+ * 返回三个字段,语义各不相同,别混用:
1082
+ * - fresh:这一项「对用户算新功能」。决定要不要写记录,与显示无关。
1083
+ * - show :设置项旁那颗 NEW 现在要不要亮。按 24 小时窗口算。
1084
+ * - entered:用户有没有点进过插件设置页。侧栏入口那颗 NEW 只看它 ——
1085
+ * 点进去过就不再提示,直到下个新功能出现(2026-10-31 用户要求)。 */
969
1086
  function newTagStateAt(introVersion, record, fallbackFrom, fallbackSince, nowMs) {
970
1087
  const intro = String(introVersion || "");
971
- if (intro === "") return { fresh: false, show: false, since: 0 };
1088
+ if (intro === "") return { fresh: false, show: false, since: 0, entered: true };
972
1089
  const rec = record && typeof record === "object" && !Array.isArray(record) ? record : null;
973
1090
  const recV = rec && typeof rec.v === "string" ? rec.v : "";
974
1091
  const recT = rec ? Number(rec.t) : NaN;
@@ -982,17 +1099,30 @@ const CN_CALENDAR_SOURCES = [
982
1099
  const s = Number(since);
983
1100
  return Number.isFinite(s) && s > 0 ? s : 0;
984
1101
  };
985
- // 已经按这一版记录过:用户第一次见到它就是 t,直接按 t 计时
1102
+ // 已经按这一版记录过:仍要**再判一次"对这名用户算不算新功能"**。
1103
+ // 早先这里无条件 fresh:true,只靠 24 小时窗口控制显不显示 —— 结果是
1104
+ // 从 2.0.8 升上来的用户,2.0.0 的老功能(余额行样式 / 侧边栏显示本插件 /
1105
+ // 右键刷新浪潮)只要 t 落在窗口里就一块标 NEW,等于"每个人高亮的东西都一样"。
1106
+ // 现在严格用 引入版本 对比 用户升级前的版本(newTagFrom),每人各不相同:
1107
+ // 跨多版升上来的会看到中间所有新功能,只升一版的老功能一条都不亮。
986
1108
  if (recV !== "" && recV === intro) {
1109
+ const baseline = String(fallbackFrom || "");
1110
+ const reallyFresh = baseline === "" || compareVersion(intro, baseline) > 0;
987
1111
  const since = Number.isFinite(recT) && recT > 0 ? recT : Number(fallbackSince);
988
- return { fresh: true, show: within(since), since: pick(since) };
1112
+ const entered = rec !== null && rec.d === true;
1113
+ return {
1114
+ fresh: reallyFresh,
1115
+ show: reallyFresh && within(since),
1116
+ since: pick(since),
1117
+ entered: entered || !reallyFresh,
1118
+ };
989
1119
  }
990
1120
  // 没记录(或引入版本变了):才判这项对用户算不算新
991
1121
  const baseline = recV !== "" ? recV : String(fallbackFrom || "");
992
1122
  const fresh = baseline === "" || compareVersion(intro, baseline) > 0;
993
- if (!fresh) return { fresh: false, show: false, since: 0 };
1123
+ if (!fresh) return { fresh: false, show: false, since: 0, entered: true };
994
1124
  const since = Number(fallbackSince);
995
- return { fresh: true, show: within(since), since: pick(since) };
1125
+ return { fresh: true, show: within(since), since: pick(since), entered: false };
996
1126
  }
997
1127
 
998
1128
  /** 按数字段比较版本号:1.3.10 > 1.3.9(字符串直接比会错)。
@@ -1009,6 +1139,96 @@ const CN_CALENDAR_SOURCES = [
1009
1139
  return 0;
1010
1140
  }
1011
1141
 
1142
+ // NEW_TAGS:标准——「最近版本加了什么」。放在模块级,因为有两处要用:
1143
+ // ① 设置卡片逐项高亮(「消耗行加载动效」旁边那颗 NEW);
1144
+ // ② 侧栏「余额与用量」入口图标右上角那颗 NEW 标签(有新功能就亮)。
1145
+ // 每项带上「引入版本」:只有比用户上一次跑的版本更新的项才标 NEW,
1146
+ // 跨几个版本一次升上来的用户会看到所有中间新功能,已经跑过的不会被重复提示。
1147
+ // 维护方式:发版时把本版新增的设置项 key 加进来,引入版本写当次的 pending 值。
1148
+ const NEW_TAGS = new Map([
1149
+ ["footerStyle", "2.0.0"], // 余额行样式(新版 timeline / 旧版 classic)
1150
+ ["showSidebarEntry", "2.0.0"], // 左侧导航栏入口
1151
+ ["refreshWave", "2.0.0"], // 右键刷新涟漪
1152
+ ["usageLoadingStyle", "2.0.9"], // 消耗行加载动效:样式1(光团开窗)/ 样式2(计算中),含行内实时预览
1153
+ ]);
1154
+
1155
+ /** NEW_TAGS 的 key → 设置项中文名。横幅要写「本版新增:消耗行加载动效」,
1156
+ * key 是内部标识,不能直接给用户看。加新功能时**这里也要补一行**。 */
1157
+ const NEW_TAG_LABELS = {
1158
+ footerStyle: "余额行样式",
1159
+ showSidebarEntry: "侧边栏显示本插件",
1160
+ refreshWave: "右键刷新浪潮",
1161
+ usageLoadingStyle: "消耗行加载动效",
1162
+ };
1163
+
1164
+ /** 模板串里取一张表的三个 NEW 相关字段,缺省一律安全兜底。 */
1165
+ function newTagFields(settings) {
1166
+ const s = settings && typeof settings === "object" ? settings : {};
1167
+ return {
1168
+ seen: s.newTagSeen && typeof s.newTagSeen === "object" ? s.newTagSeen : {},
1169
+ from: String(s.newTagFrom || ""),
1170
+ since: Number(s.newTagSince),
1171
+ };
1172
+ }
1173
+
1174
+ /** 设置项旁那颗 NEW 要亮的项(24 小时窗口内)。 */
1175
+ function freshNewTags(settings) {
1176
+ const { seen, from, since } = newTagFields(settings);
1177
+ const now = Date.now();
1178
+ const out = [];
1179
+ for (const [key, intro] of NEW_TAGS) {
1180
+ const st = newTagStateAt(intro, seen[key], from, since, now);
1181
+ if (st.fresh && st.show) out.push({ key, intro });
1182
+ }
1183
+ return out;
1184
+ }
1185
+
1186
+ /** 侧栏入口那颗 NEW 要亮的项:算新功能、还在 24 小时窗口内、而且用户没点进去过。
1187
+ * ⚠️ 必须带上 st.show(24 小时窗口)。只判 !entered 会让**老功能永远亮**:
1188
+ * newTagStateAt 对「已记录」的项是无条件 fresh:true 的(那是给写盘用的),
1189
+ * 而 footerStyle 这类 2.0.0 的老项永远不会被写上 e 标记,于是入口 NEW 挂一辈子。
1190
+ * 2026-10-31 用户报「怎么消不掉」就是这个 —— 一版之前只判了 fresh,漏了窗口。
1191
+ * 熄灭条件仍是「点进去过」,下个新功能自有自己的 24 小时窗口。 */
1192
+ function unseenNewTags(settings) {
1193
+ const { seen, from, since } = newTagFields(settings);
1194
+ const now = Date.now();
1195
+ const out = [];
1196
+ for (const [key, intro] of NEW_TAGS) {
1197
+ const st = newTagStateAt(intro, seen[key], from, since, now);
1198
+ if (st.fresh && st.show && !st.entered) out.push({ key, intro });
1199
+ }
1200
+ return out;
1201
+ }
1202
+
1203
+ /** 只留下 NEW_TAGS 里还存在的 key。
1204
+ * NEW_TAGS 会随版本增删,删掉的功能在 newTagSeen 里留的记录永远没人读,
1205
+ * 攒久了就是设置文件里一坨死数据。写盘前一律先过这一步(2026-10-31 用户问「缓存会不会越来越大」)。 */
1206
+ function pruneNewTagSeen(seen) {
1207
+ const src = seen && typeof seen === "object" ? seen : {};
1208
+ const out = {};
1209
+ for (const [key] of NEW_TAGS) {
1210
+ const rec = src[key];
1211
+ if (rec && typeof rec === "object" && !Array.isArray(rec)) out[key] = rec;
1212
+ }
1213
+ return out;
1214
+ }
1215
+
1216
+ /** 用户点进了插件设置页(点侧栏入口、或者从别处打开这张卡片):
1217
+ * 把还没看过的新功能全部标成 e:true,侧栏那颗 NEW 就此熄灭,直到下个新功能出现。
1218
+ * 设置项旁那颗 NEW 不受影响,它按自己的 24 小时窗口走。
1219
+ * 没有可标的项时返回 null(不写盘、不刷事件)。 */
1220
+ function markNewTagsEntered(settings) {
1221
+ const unseen = unseenNewTags(settings);
1222
+ const { seen, since } = newTagFields(settings);
1223
+ // 即使这次没有可标的项,也顺手做一次清理,把已删功能的残记录带走
1224
+ const next = pruneNewTagSeen(seen);
1225
+ const pruned = Object.keys(next).length !== Object.keys(seen).length;
1226
+ if (unseen.length === 0) return pruned ? next : null;
1227
+ const anchor = Number.isFinite(since) && since > 0 ? since : Date.now();
1228
+ for (const { key, intro } of unseen) next[key] = { v: intro, t: anchor, d: true };
1229
+ return next;
1230
+ }
1231
+
1012
1232
  function currentPeriod(atMs) {
1013
1233
  const d = new Date((atMs === undefined ? Date.now() : atMs) + 8 * 60 * 60 * 1000); // 北京时间
1014
1234
  const kind = CN_CALENDAR[
@@ -1307,8 +1527,17 @@ const CN_CALENDAR_SOURCES = [
1307
1527
  });
1308
1528
  const data = await res.json();
1309
1529
  if (!data || data.ok !== true) throw new Error((data && data.error) || "保存失败");
1530
+ const server = data.settings && typeof data.settings === "object" ? data.settings : {};
1531
+ // 服务端还是旧代码时不认识新加字段(normalizeSettings 会把未知键丢掉),
1532
+ // 如果直接拿它回传的对象覆盖本地,刚落下去的值会被冲回默认值 ——
1533
+ // 表现就是「点了 NEW 它又亮回来」(2026-10-31 实测)。所以把服务端**没回传**的
1534
+ // 补丁键保留在本地:会话内行为正确,重启 DSH 让服务端认识之后自然对齐。
1535
+ const kept = {};
1536
+ for (const key of Object.keys(patch)) {
1537
+ if (!(key in server)) kept[key] = patch[key];
1538
+ }
1310
1539
  settingsCache = {
1311
- value: { ...SETTINGS_DEFAULTS, ...data.settings },
1540
+ value: { ...SETTINGS_DEFAULTS, ...server, ...kept },
1312
1541
  loaded: true,
1313
1542
  version: typeof data.version === "string" ? data.version : settingsCache.version,
1314
1543
  };
@@ -2827,8 +3056,9 @@ function UsageCostLine({ useProjection, sessionId }) {
2827
3056
  key: "cost",
2828
3057
  className: "balance-usage-cost" + (costPending ? " balance-usage-cost--pending" : ""),
2829
3058
  // 两种加载动效二选一(usageLoadingStyle),**两套语言不混用**:
2830
- // "wipe"(默认):等待时留空 → 数据到手挂 .balance-usage-reveal,白光擦过、数字跟着亮。
2831
- // "text":等待时显示带呼吸的「计算中…」胶囊 → 数据到手**直接渐入**,不放光条
3059
+ // "wipe"(默认):等待时一团白色迷雾在原地呼吸 → 数据到手「炸开」显示数字
3060
+ // (2026-10-31 用户:「加载的时候是一团白色迷雾 然后像爆炸一样显示出来」)。
3061
+ // "text":等待时显示带呼吸的「计算中…」胶囊 → 数据到手**直接渐入**,不放光不放雾
2832
3062
  // (2026-10-30 用户:「用的如果是计算中就不要用光条动画了 计算完直接渐入就行了」)。
2833
3063
  // 两种都只在等待期间出现,都不亮错误值。
2834
3064
  children: staleUsage || costPending
@@ -2846,24 +3076,65 @@ function UsageCostLine({ useProjection, sessionId }) {
2846
3076
  }, "dots"),
2847
3077
  ],
2848
3078
  })
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",
3079
+ // 迷雾态:一团白光,不是文字也不是光条。宽度由 CSS 撑满 min-width:10.2ch,
3080
+ // 迷雾态:4 团模糊的白色光在整条余额栏里游动(Siri 那种液体观感)。
3081
+ // 尺寸/周期/路径全在 CSS 的 :nth-child 里定,这里只要 4 个空壳 ——
3082
+ // 它们都是绝对定位,不参与布局,宽度由容器 100% 撑满 min-width:10.2ch。
3083
+ : jsx.jsx("span", {
3084
+ className: "balance-usage-mist",
3085
+ key: "mist-" + (sessionId || ""),
3086
+ "aria-hidden": "true",
3087
+ children: [0, 1, 2, 3, 4].map((i) => jsx.jsx("span", { className: "balance-usage-mist__orb" }, i)),
3088
+ }))
3089
+ : (loadingIsText
3090
+ // 样式2(计算中):就是一层淡入,没有开窗也没有光
3091
+ ? jsx.jsx("span", {
3092
+ className: "balance-usage-fade",
3093
+ key: "fade-" + (sessionId || ""),
3094
+ children: jsx.jsxs("span", {
3095
+ className: "balance-usage-cost-inner",
3096
+ children: [
3097
+ jsx.jsx("span", { className: "balance-usage-approx", children: "≈" }, "approx"),
3098
+ jsx.jsx("span", { children: "¥" }, "cur"),
3099
+ costRoll === null
3100
+ ? jsx.jsx("span", { children: costText }, "txt")
3101
+ : jsx.jsx("span", { key: "roll-" + costRoll.at, children: renderRollCells(buildRollCells(costRoll.from, costRoll.to), "u") }),
3102
+ ],
3103
+ }),
3104
+ })
3105
+ : jsx.jsxs("span", {
3106
+ className: "balance-usage-reveal",
3107
+ key: "reveal-" + (sessionId || ""),
2855
3108
  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
- }),
3109
+ // 底层:加载态那团光(一整条,不切)。mask 在正中间挖一个随开窗扩大的椭圆洞,
3110
+ // 数字从洞里显出来,光留在两侧并且永不透到字缝里。
3111
+ jsx.jsx("span", {
3112
+ className: "balance-usage-reveal__mist",
3113
+ "aria-hidden": "true",
3114
+ children: jsx.jsx("span", {
3115
+ className: "balance-usage-mist",
3116
+ children: [0, 1, 2, 3, 4].map((i) => jsx.jsx("span", { className: "balance-usage-mist__orb" }, i)),
3117
+ }),
3118
+ }, "mist"),
3119
+ // 上层:数字本体,被 clip-path 从中缝向两侧打开(真正的「开窗」动作在这一层)
3120
+ jsx.jsx("span", {
3121
+ className: "balance-usage-reveal__body",
3122
+ children: jsx.jsxs("span", {
3123
+ className: "balance-usage-cost-inner",
3124
+ children: [
3125
+ // ≈ 单独成节点:U+2248 在中文字体里落在数学符号的中线,比数字基线低一截,
3126
+ // 直接跟「¥7.4619」串在一起会显得整个符号往下掉(2026-10-30 用户指出「有点靠下」)。
3127
+ jsx.jsx("span", { className: "balance-usage-approx", children: "≈" }, "approx"),
3128
+ jsx.jsx("span", { children: "¥" }, "cur"),
3129
+ // 只有数字参与逐位滚动:¥ 与 ≈ 是定值,跟着滚会把整个符号一起翻。
3130
+ costRoll === null
3131
+ ? jsx.jsx("span", { children: costText }, "txt")
3132
+ : jsx.jsx("span", { key: "roll-" + costRoll.at, children: renderRollCells(buildRollCells(costRoll.from, costRoll.to), "u") }),
3133
+ ],
3134
+ }),
3135
+ }, "body"),
3136
+ ],
3137
+ })),
2867
3138
  }),
2868
3139
  ];
2869
3140
 
@@ -3021,7 +3292,14 @@ function UsageCostLine({ useProjection, sessionId }) {
3021
3292
  // 有新版本、且还没点开过面板 → 在图标右上角挂一个「新版本!」胶囊。
3022
3293
  // 用户要求:别再让它跟「有新内容」的未读红点用同一个提示(重复且不显眼)。
3023
3294
  const updateHint = notice.latest !== "" && notice.seen !== true && !active;
3024
- const unread = settings.entrySeen !== true && !active && !updateHint;
3295
+ // 有新功能、而且用户还没点进去过 → 入口挂一颗 NEW 标签。
3296
+ // 熄灭条件是「点进去过」,不是时间:点进去之后外侧这颗就没了,
3297
+ // 直到下个新功能出现(2026-10-31 用户要求)。
3298
+ // 这一套**固定开启**,不提供关闭开关(用户明确说过「这个强制打开 不需要这个开关」)——
3299
+ // 它只在一个短窗口内出现,不构成长期打扰,关掉反而让用户错过新功能。
3300
+ const newFeature = !updateHint && !active && unseenNewTags(settings).length > 0;
3301
+ // 优先级:新版本!> NEW > 未读红点。三者永不同时出现(挤在一格里谁也看不清)。
3302
+ const unread = settings.entrySeen !== true && !active && !updateHint && !newFeature;
3025
3303
  // 关掉「侧边栏显示本插件」→ 这一格直接不渲染(图标消失)。
3026
3304
  // 位置必须在所有 hook 之后,否则不同渲染间 hook 数量不一致,React 会直接报错。
3027
3305
  if (settings.showSidebarEntry === false) return null;
@@ -3043,19 +3321,30 @@ function UsageCostLine({ useProjection, sessionId }) {
3043
3321
  jsx.jsx("path", { d: "M18 12a2 2 0 0 0 0 4h4v-4Z" }),
3044
3322
  ],
3045
3323
  });
3046
- if (!unread && !updateHint) return svg;
3047
- // 点一下图标(红点就在图标右上角,点哪都算)就立刻熄灯:
3048
- // 本地先乐观置 true 并广播,红点在同一帧消失,不等 HTTP 往返;
3324
+ if (!unread && !updateHint && !newFeature) return svg;
3325
+ // 点一下图标(标签就在图标右上角,点哪都算)就立刻熄灯:
3326
+ // 本地先乐观置 true 并广播,标签在同一帧消失,不等 HTTP 往返;
3049
3327
  // 持久化走 fire-and-forget,失败也只是下次进来再写一次。
3050
3328
  // 用 pointerdown 而不是 click —— panellist 那一行的 click 归 DSH 的 selectPanel 处理,
3051
3329
  // pointerdown 只负责"标记已读",不抢它的点击行为(事件照常冒泡)。
3052
3330
  const markSeen = () => {
3053
- // 两个红点原因各自熄各自的:不能因为 entrySeen 已是 true 就整段 early return,
3054
- // 否则「有新版本」那颗点永远清不掉。
3331
+ // 三个提示原因各自熄各自的:不能因为 entrySeen 已是 true 就整段 early return,
3332
+ // 否则「有新版本」「有新功能」那两颗点永远清不掉。
3055
3333
  if (updateHint) {
3056
3334
  updateNotice = { ...updateNotice, seen: true };
3057
3335
  emitUpdateNotice();
3058
3336
  }
3337
+ // NEW 标签:用户点进来了,把还没看过的新功能标成 d:true —— 外侧这颗立即熄灭,
3338
+ // 一直保持熄灭,直到下一个新功能出现(没有总开关,也就没有"要不要关"的问题)。
3339
+ const entered = markNewTagsEntered(settingsCache.value);
3340
+ if (entered) {
3341
+ settingsCache = {
3342
+ ...settingsCache,
3343
+ value: { ...settingsCache.value, newTagSeen: entered },
3344
+ };
3345
+ emitSettings();
3346
+ void saveSettings({ newTagSeen: entered }).catch(() => {});
3347
+ }
3059
3348
  if (settingsCache.value.entrySeen !== true) {
3060
3349
  settingsCache = {
3061
3350
  ...settingsCache,
@@ -3066,8 +3355,8 @@ function UsageCostLine({ useProjection, sessionId }) {
3066
3355
  }
3067
3356
  };
3068
3357
  // 外层 span 只为了挂定位;DSH 的 panellist 容器不裁子元素
3069
- // 有新版 → 「新版本!」胶囊(品牌色实心,压在图标右上、落在入口文字上方);
3070
- // 否则若没点开过 → 原来那颗未读红点。两者不同时出现。
3358
+ // 三种提示按优先级取一个:有新版 → 「新版本!」胶囊;否则有未读新功能 → 「NEW」标签;
3359
+ // 否则若没点开过 → 原来那颗未读红点。
3071
3360
  return jsx.jsxs("span", {
3072
3361
  className:
3073
3362
  "balance-panel-icon" + (unread ? " balance-panel-icon--unread" : ""),
@@ -3080,6 +3369,12 @@ function UsageCostLine({ useProjection, sessionId }) {
3080
3369
  updateHint
3081
3370
  ? jsx.jsx("span", { className: "balance-panel-icon__new", children: "新版本!" })
3082
3371
  : null,
3372
+ newFeature
3373
+ ? jsx.jsx("span", {
3374
+ className: "balance-panel-icon__new balance-panel-icon__new--feature",
3375
+ children: "NEW",
3376
+ })
3377
+ : null,
3083
3378
  ],
3084
3379
  });
3085
3380
  }
@@ -3095,6 +3390,10 @@ function UsageCostLine({ useProjection, sessionId }) {
3095
3390
  if (settings.entrySeen !== true) {
3096
3391
  void saveSettings({ entrySeen: true }).catch(() => {});
3097
3392
  }
3393
+ // 打开这一页也算「点进去过」:侧栏那颗 NEW 到此为止,直到下个新功能。
3394
+ // 直接打开面板页(不经过侧栏图标)的用户同样要熄灭它。
3395
+ const entered = markNewTagsEntered(settingsCache.value);
3396
+ if (entered) void saveSettings({ newTagSeen: entered }).catch(() => {});
3098
3397
  // 打开面板就能看到顶部的「版本与更新」区块,所以新版本红点到此为止。
3099
3398
  // seen 只存在内存里(不写盘):下次启动重新检查,真有新版会再亮。
3100
3399
  if (updateNotice.latest !== "" && updateNotice.seen !== true) {
@@ -3365,21 +3664,8 @@ function UsageCostLine({ useProjection, sessionId }) {
3365
3664
  });
3366
3665
  }
3367
3666
 
3368
- // NEW_TAGS:标准——「最近版本加了什么」。
3369
- // 显示窗口 = 当前版本首次启动后 3 天(2026-10-30 反馈):
3370
- // 起点由服务端记在设置文件里(newTagSince / newTagVersion),
3371
- // 版本一变就重置——所以用户一升级就会看到新的 3 天。不看 entrySeen。
3372
- // 维护方式:发版时把本版新增的设置项 key 加进来,把上一版的移出去。
3373
- // 每项带上「引入版本」:只有比用户上一次跑的版本更新的项才标 NEW。
3374
- // 这样跨几个版本一次升上来的用户会看到所有中间新功能,
3375
- // 而已经跑过这些功能的用户不会被重复提示。
3376
- // 项目不再需要发版时手动「把上一版的移出去」——版本比较会自动过滤。
3377
- const NEW_TAGS = new Map([
3378
- ["footerStyle", "2.0.0"], // 余额行样式(新版 timeline / 旧版 classic)
3379
- ["showSidebarEntry", "2.0.0"], // 左侧导航栏入口
3380
- ["refreshWave", "2.0.0"], // 右键刷新涟漪
3381
- ["usageLoadingStyle", "2.0.7"], // 消耗行加载动效:白光 / 计算中
3382
- ]);
3667
+ // NEW 相关:NEW_TAGS / freshNewTags / markNewTagsEntered 都在模块级
3668
+ // (侧栏入口那颗 NEW 标签也要用同一套判定,不能在组件里再定义一份)。
3383
3669
  const newTagSince = Number(value.newTagSince);
3384
3670
  // 升级前的版本(空串 = 全新安装)
3385
3671
  const newTagFrom = String(value.newTagFrom || "");
@@ -3389,11 +3675,36 @@ function UsageCostLine({ useProjection, sessionId }) {
3389
3675
  if (!intro) return false;
3390
3676
  return newTagStateAt(intro, newTagSeen[key], newTagFrom, newTagSince, Date.now()).show;
3391
3677
  };
3392
- // 逐项记录「第一次见到」的时间:每个新功能各自从自己出现那一刻算 3 天,
3393
- // 与其它功能的过期时间完全独立。设置还没加载完不能记(会误当全新安装)。
3678
+ // 顶部「本版新增」横幅:和逐项 isNew 同一套判定(24 小时窗口),
3679
+ // 所以横幅消失的时机跟设置项旁那颗 NEW 完全一致,不会出现"横幅没了但某行还有 NEW"。
3680
+ const pendingNew = freshNewTags(value);
3681
+ // 点名跳到那一行:设置卡片很长,光说"有新功能"用户还得自己找。
3682
+ const focusNewRow = (key) => {
3683
+ try {
3684
+ const el = document.querySelector('[data-newtag="' + key + '"]');
3685
+ if (!el) return;
3686
+ if (typeof el.scrollIntoView === "function") {
3687
+ el.scrollIntoView({ block: "center", behavior: "smooth" });
3688
+ }
3689
+ // 先摘再强排一次:连点同一个按钮也要重播闪一下,而不是没反应
3690
+ el.classList.remove("balance-settings__row--flash");
3691
+ void el.offsetWidth;
3692
+ el.classList.add("balance-settings__row--flash");
3693
+ // 动画是 1.4s,1.5s 后才摘 class:留 100ms 余量,别在动画没播完时把伪元素抽走。
3694
+ // 伪元素本身靠 animation-fill-mode:both 停在 opacity:0,所以中间不会有回跳。
3695
+ window.setTimeout(() => el.classList.remove("balance-settings__row--flash"), 1500);
3696
+ } catch {
3697
+ // 定位失败只是不滚、不闪,设置本身照常可用
3698
+ }
3699
+ };
3700
+ // 逐项记录时用的起点是**升级时刻**(服务端在版本变化那一刻写下的 newTagSince),
3701
+ // 不是「这次渲染的时刻」。用户 2026-10-31 明确要求:「更新完开始计算,不需要打开插件设置页才显示」——
3702
+ // 侧栏入口会挂「新版本!」胶囊,看到就会点进来,窗口不该等他点进来才起跑。
3703
+ // 所以升级后隔两天才打开设置页,看到的就只剩一天,而不是又白送三天。
3394
3704
  react.useEffect(() => {
3395
3705
  if (settingsCache.loaded !== true) return;
3396
3706
  const now = Date.now();
3707
+ const anchor = Number(newTagSince) > 0 ? Number(newTagSince) : now;
3397
3708
  const patch = {};
3398
3709
  for (const [key, intro] of NEW_TAGS) {
3399
3710
  const rec = newTagSeen[key];
@@ -3401,15 +3712,24 @@ function UsageCostLine({ useProjection, sessionId }) {
3401
3712
  if (rec && typeof rec === "object" && rec.v === intro) continue;
3402
3713
  // 对用户不是新功能就不记(否则以后 baseline 变了会误显示)
3403
3714
  if (!newTagStateAt(intro, rec, newTagFrom, newTagSince, now).fresh) continue;
3404
- patch[key] = { v: intro, t: now };
3715
+ patch[key] = { v: intro, t: anchor };
3716
+ }
3717
+ if (Object.keys(patch).length === 0) {
3718
+ // 没新东西要记,但顺手把已删功能的残记录清理掉(写盘前统一 prune)
3719
+ const pruned = pruneNewTagSeen(newTagSeen);
3720
+ if (Object.keys(pruned).length !== Object.keys(newTagSeen).length) {
3721
+ void saveSettings({ newTagSeen: pruned }).catch(() => {});
3722
+ }
3723
+ return;
3405
3724
  }
3406
- if (Object.keys(patch).length === 0) return;
3407
- void saveSettings({ newTagSeen: { ...newTagSeen, ...patch } }).catch(() => {
3725
+ void saveSettings({ newTagSeen: { ...pruneNewTagSeen(newTagSeen), ...patch } }).catch(() => {
3408
3726
  // 写盘失败不影响本次渲染:下次进入会重试
3409
3727
  });
3410
3728
  }, [newTagFrom, newTagSince, newTagSeen]);
3411
- const row = (key, label, hint, control) => jsx.jsxs("div", {
3729
+ const row = (key, label, hint, control, extra) => jsx.jsxs("div", {
3412
3730
  className: "balance-settings__row",
3731
+ // 顶部「本版新增」横幅靠它定位跳转(querySelector('[data-newtag="key"]'))
3732
+ "data-newtag": key,
3413
3733
  children: [
3414
3734
  jsx.jsxs("div", {
3415
3735
  className: "balance-settings__label",
@@ -3426,6 +3746,9 @@ function UsageCostLine({ useProjection, sessionId }) {
3426
3746
  hint ? jsx.jsx("span", { className: "balance-settings__hint", children: hint }) : null,
3427
3747
  ],
3428
3748
  }),
3749
+ // extra 是可选的「中间预览」:夹在说明文字和右侧控件之间,
3750
+ // 由 margin-left:auto 贴向控件一侧(用户指的那块空白就在这里)。
3751
+ extra || null,
3429
3752
  jsx.jsx("div", { className: "balance-settings__control", children: control }),
3430
3753
  ],
3431
3754
  }, key);
@@ -3482,8 +3805,8 @@ function UsageCostLine({ useProjection, sessionId }) {
3482
3805
  toggle(key, label, onToggle ? () => onToggle(value[key] !== true) : undefined),
3483
3806
  );
3484
3807
 
3485
- // 二选一分段控件:用于「余额行外观」这类互斥选项
3486
- const choiceRow = (key, label, hint, options) => row(key, label, hint, jsx.jsx("div", {
3808
+ // 二选一分段控件:用于「余额行外观」这类互斥选项。第 5 参 extra 是可选的行内预览。
3809
+ const choiceRow = (key, label, hint, options, extra) => row(key, label, hint, jsx.jsx("div", {
3487
3810
  className: "balance-settings__segmented",
3488
3811
  role: "radiogroup",
3489
3812
  "aria-label": label,
@@ -3496,7 +3819,7 @@ function UsageCostLine({ useProjection, sessionId }) {
3496
3819
  onClick: () => patch(key, opt.value),
3497
3820
  children: opt.label,
3498
3821
  }, opt.value)),
3499
- }));
3822
+ }), extra);
3500
3823
 
3501
3824
  // 整张设置卡片可折叠,形式对齐 DSH 原生 DisclosureRow —— 一行标题 + 描述 + 右侧 chevron,
3502
3825
  // 展开时箭头翻转 180 度。**默认展开**:只有插件列表里的单行摘要(view === "summary")
@@ -3526,11 +3849,12 @@ function UsageCostLine({ useProjection, sessionId }) {
3526
3849
  ? jsx.jsxs("div", {
3527
3850
  className: "balance-settings__group-note",
3528
3851
  children: [
3852
+ // 同顶部告示那颗:宽高必须等于 viewBox 的 16,14/16 的非整数缩放会把描边抹糊。
3529
3853
  jsx.jsxs("svg", {
3530
3854
  className: "balance-settings__group-note-icon",
3531
3855
  viewBox: "0 0 16 16",
3532
- width: 14,
3533
- height: 14,
3856
+ width: 16,
3857
+ height: 16,
3534
3858
  fill: "none",
3535
3859
  stroke: "currentColor",
3536
3860
  strokeWidth: 1.5,
@@ -3588,16 +3912,42 @@ function UsageCostLine({ useProjection, sessionId }) {
3588
3912
  openSettings ? jsx.jsxs("div", {
3589
3913
  className: "balance-settings__body",
3590
3914
  children: [
3915
+ // 「本版新增」横幅:只在有条目新功能未过期时出现。
3916
+ // 它不依赖侧栏入口 —— 用户关闭「侧边栏显示本插件」、或从 DSH「插件」
3917
+ // 页直接进来时,这是唯一能看到"有新功能"的地方(2026-10-31 用户提问)。
3918
+ pendingNew.length > 0
3919
+ ? jsx.jsxs("div", {
3920
+ className: "balance-settings__newcue",
3921
+ children: [
3922
+ jsx.jsx("span", { className: "balance-settings__newcue-badge", children: "NEW" }),
3923
+ jsx.jsx("span", {
3924
+ className: "balance-settings__newcue-text",
3925
+ children: "本版新增:" + pendingNew
3926
+ .map((item) => NEW_TAG_LABELS[item.key] || item.key)
3927
+ .join(" · "),
3928
+ }),
3929
+ jsx.jsx("button", {
3930
+ type: "button",
3931
+ className: "balance-settings__newcue-btn",
3932
+ onClick: () => focusNewRow(pendingNew[0].key),
3933
+ children: "去看看",
3934
+ }),
3935
+ ],
3936
+ })
3937
+ : null,
3591
3938
  // 顶部告示:计费范围限制放在最前面,打开设置第一眼就看到,
3592
3939
  // 不用翻到「计费」分组才发现。
3593
3940
  jsx.jsxs("div", {
3594
3941
  className: "balance-settings__notice",
3595
3942
  children: [
3943
+ // ⚠️ 宽高必须等于 viewBox 的 16,**不要**写成 15(2026-10-31 用户报「感叹号是糊的」):
3944
+ // 15/16 = 0.9375 的非整数缩放会让 1.5px 的描边落在半像素上,
3945
+ // 抗锯齿把圆圈和竖线一起抹成灰边。1:1 映射后每个单位正好一个设备像素。
3596
3946
  jsx.jsxs("svg", {
3597
3947
  className: "balance-settings__notice-icon",
3598
3948
  viewBox: "0 0 16 16",
3599
- width: 15,
3600
- height: 15,
3949
+ width: 16,
3950
+ height: 16,
3601
3951
  fill: "none",
3602
3952
  stroke: "currentColor",
3603
3953
  strokeWidth: 1.5,
@@ -3765,11 +4115,63 @@ function UsageCostLine({ useProjection, sessionId }) {
3765
4115
  ? toggleRow("refreshWave", "右键刷新浪潮", "右键余额时,金额会被一道白光从左往右擦出来(和输入区「本会话消耗」同一个动效)。")
3766
4116
  : null,
3767
4117
  toggleRow("showUsagePill", "会话消耗行", "输入框下方的「本会话消耗」胶囊(含点开的明细浮层)。"),
3768
- // 两种加载动效互斥,用分段按钮选(和「余额行外观」同一个控件,视觉一致)
3769
- choiceRow("usageLoadingStyle", "消耗行加载动效", "等分时段用量时显示什么。", [
3770
- { value: "wipe", label: "白光" },
3771
- { value: "text", label: "计算中" },
3772
- ]),
4118
+ // 两种加载动效互斥,用分段按钮选(和「余额行外观」同一个控件,视觉一致)。
4119
+ // 选项文字用「样式1 / 样式2」而不是效果名,所以在说明栏里直接并排播两个真实预览 ——
4120
+ // 用户不用读描述、看动画就知道哪个是哪个(2026-10-31 要求「在介绍栏预览给用户看」)。
4121
+ choiceRow("usageLoadingStyle", "消耗行加载动效", "等对话用量时显示什么。", [
4122
+ { value: "wipe", label: "样式1" },
4123
+ { value: "text", label: "样式2" },
4124
+ ], jsx.jsxs("div", {
4125
+ className: "balance-settings__preview",
4126
+ "aria-hidden": "true",
4127
+ children: [
4128
+ jsx.jsxs("div", {
4129
+ className: "balance-settings__preview-item" + (value.usageLoadingStyle === "text" ? "" : " is-on"),
4130
+ children: [
4131
+ jsx.jsx("span", { className: "balance-settings__preview-tag", children: "样式1" }),
4132
+ jsx.jsxs("span", {
4133
+ className: "balance-settings__preview-stage",
4134
+ children: [
4135
+ jsx.jsx("span", {
4136
+ className: "balance-settings__preview-text",
4137
+ children: "本会话消耗",
4138
+ }),
4139
+ jsx.jsx("span", {
4140
+ className: "balance-usage-mist",
4141
+ children: [0, 1, 2, 3, 4].map((i) => jsx.jsx("span", { className: "balance-usage-mist__orb" }, i)),
4142
+ }),
4143
+ ],
4144
+ }),
4145
+ ],
4146
+ }, "p1"),
4147
+ jsx.jsxs("div", {
4148
+ className: "balance-settings__preview-item" + (value.usageLoadingStyle === "text" ? " is-on" : ""),
4149
+ children: [
4150
+ jsx.jsx("span", { className: "balance-settings__preview-tag", children: "样式2" }),
4151
+ jsx.jsxs("span", {
4152
+ className: "balance-settings__preview-stage",
4153
+ children: [
4154
+ jsx.jsx("span", {
4155
+ className: "balance-settings__preview-text",
4156
+ children: "本会话消耗",
4157
+ }),
4158
+ jsx.jsx("span", {
4159
+ className: "balance-usage-calc",
4160
+ children: [
4161
+ "计算中",
4162
+ jsx.jsx("span", {
4163
+ className: "balance-usage-calc__dots",
4164
+ "aria-hidden": "true",
4165
+ children: [0, 1, 2].map((i) => jsx.jsx("span", { className: "balance-usage-calc__dot", children: "." }, i)),
4166
+ }, "dots"),
4167
+ ],
4168
+ }),
4169
+ ],
4170
+ }),
4171
+ ],
4172
+ }, "p2"),
4173
+ ],
4174
+ })),
3773
4175
  value.footerStyle === "classic"
3774
4176
  ? toggleRow("showPeriodLabel", "高峰 / 空闲标签", "旧版余额行里「金额 • 时段」的时段部分;关掉后只留金额。")
3775
4177
  : 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.8";
530
+ const PLUGIN_VERSION = "2.0.9";
531
531
 
532
532
  // ─── 检查更新 ───────────────────────────────────────────────────────────────
533
533
  // 只查 npm registry 的 latest 标签(一个很小的 JSON),不下载任何东西。
@@ -1187,7 +1187,7 @@ const DEFAULT_SETTINGS = {
1187
1187
  // 与版本号无关:只有用户真正点开过这个面板才会被写成 true,之后永不回退。
1188
1188
  entrySeen: false,
1189
1189
  // NEW 徽标的计时起点:记录「当前版本首次启动」的时间戳与版本号。
1190
- // 版本一变(用户升级了)就重置计时;满 3 天后客户端自动不再显示 NEW。
1190
+ // 版本一变(用户升级了)就重置计时;设置项旁那颗 NEW 亮 24 小时。
1191
1191
  newTagVersion: "",
1192
1192
  newTagSince: 0,
1193
1193
  // 升级前的版本(用户上一次跑的是哪个版本)。
@@ -1195,10 +1195,11 @@ const DEFAULT_SETTINGS = {
1195
1195
  // 这样跨几个版本一次升上来的用户能看到中间所有新功能,
1196
1196
  // 已经跑过某个版本的用户不会被重复提示。
1197
1197
  newTagFrom: "",
1198
- // 逐项计时:{ [配置项 key]: { v: 引入版本, t: 首次看到的时间戳 } }。
1199
- // 每个新功能各自走自己的 3 天,互不影响:
1200
- // 旧功能没过完 3 天时又升级,也不会被这次升级重新计时——
1198
+ // 逐项计时:{ [配置项 key]: { v: 引入版本, t: 首次看到的时间戳, e?: 点进过设置页 } }。
1199
+ // 每个新功能各自走自己的 24 小时,互不影响:
1200
+ // 旧功能没亮完时又升级,也不会被这次升级重新计时——
1201
1201
  // 因为它记录的版本已经是用户见过的版本了。
1202
+ // e:true 只影响侧栏入口那颗 NEW(点进去过就不再提示),不影响设置项旁那颗。
1202
1203
  newTagSeen: {},
1203
1204
  // 收起态圆钮外观
1204
1205
  fabSize: 60, // 直径(px)
@@ -1234,16 +1235,32 @@ function clampNumber(value, min, max, fallback) {
1234
1235
  * 其余一律丢弃(宁可丢一条记录重新计时,也不要带着脏数据跑)。 */
1235
1236
  function normalizeNewTagSeen(raw) {
1236
1237
  if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return {};
1237
- const out = {};
1238
+ const entries = [];
1238
1239
  for (const key of Object.keys(raw)) {
1239
- if (typeof key !== "string" || key === "") continue;
1240
+ if (typeof key !== "string" || key === "" || key.length > 64) continue;
1240
1241
  const rec = raw[key];
1241
1242
  if (rec == null || typeof rec !== "object" || Array.isArray(rec)) continue;
1242
1243
  const v = typeof rec.v === "string" ? rec.v.trim() : "";
1243
1244
  const t = Number(rec.t);
1244
1245
  if (v === "" || !Number.isFinite(t) || t <= 0) continue;
1245
- out[key] = { v, t: Math.floor(t) };
1246
+ const item = { v, t: Math.floor(t) };
1247
+ // 额外的布尔标记(e = 用户点进过设置页)原样保留。
1248
+ // ⚠️ 这里**不要**逐字段白名单(只认 e、扔掉别的):每加一个标记就得改服务端,
1249
+ // 漏改一次就是「客户端写了、服务端一存一取给丢了」,表现为标签点了又亮回来
1250
+ // (2026-10-31 用户报「点进去消失 出来继续显示」,就是这个)。
1251
+ // 只收布尔值 + 短键名,杜绝被塞进大对象把设置文件撑爆。
1252
+ for (const mark of Object.keys(rec)) {
1253
+ if (mark === "v" || mark === "t") continue;
1254
+ if (mark.length > 8) continue;
1255
+ if (rec[mark] === true) item[mark] = true;
1256
+ }
1257
+ entries.push([key, item]);
1246
1258
  }
1259
+ // 硬上限 24 条,按时间取最近的。NEW_TAGS 现在只有 4 项,正常永远碰不到这条;
1260
+ // 它是给「版本不断加新功能、老 key 却没人删」兜底的,防止设置文件越写越大。
1261
+ entries.sort((a, b) => b[1].t - a[1].t);
1262
+ const out = {};
1263
+ for (const [key, item] of entries.slice(0, 24)) out[key] = item;
1247
1264
  return out;
1248
1265
  }
1249
1266
 
@@ -1997,7 +2014,7 @@ export function apply(ctx, config = {}) {
1997
2014
  }
1998
2015
  // NEW 徽标的计时:记下「这个版本首次启动」的时间。
1999
2016
  // 版本号变了(用户升级了)就重置,否则沿用上次记录的起点——
2000
- // 否则每次启动都会把计时推到“现在”,永远过不了 3 天。
2017
+ // 否则每次启动都会把计时推到“现在”,永远过不了 24 小时。
2001
2018
  const tagVersion = settingsState.value.newTagVersion || "";
2002
2019
  const tagSince = Number(settingsState.value.newTagSince) || 0;
2003
2020
  if (tagVersion !== PLUGIN_VERSION || !(tagSince > 0)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "2.0.8",
3
+ "version": "2.0.9",
4
4
  "description": "在 DeepSeek Harness 中显示 DeepSeek 账户余额和当前会话 token/费用消耗(含高峰/空闲峰谷计费)。仅支持 DeepSeek 官方模型与账号,暂不支持其他大模型。",
5
5
  "author": "Rannist",
6
6
  "type": "module",