balance-dsh 2.0.3 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -137,11 +137,27 @@ window.__ModuleLoader__.load({
137
137
  .balance-footer__timeline-glow{position:absolute;left:-3px;right:-3px;top:-3px;bottom:-3px;pointer-events:none;filter:blur(3px) saturate(1.5) brightness(1.15);opacity:.45;z-index:2}
138
138
  /* 色带本身:不透明 + 轻微提饱和,颜色是主角 */
139
139
  .balance-footer__timeline-band{position:absolute;left:0;top:0;right:0;bottom:0;pointer-events:none;filter:saturate(1.25) brightness(1.12);z-index:1}
140
- /* 指针位置由 JS 每 500ms 直接写(见 BalanceFooter 里的 pointerRef effect),刻意不用 transition:
141
- 一天走完 100%,每分钟才 0.069%(200px 轨道上 0.14px),过渡的连续性完全依赖浏览器
142
- 是否继续推进动画时钟——后台标签页会把过渡暂停,切回来时指针直接落到终点,那就是
143
- 用户看到的「瞬移」。逐帧写 left 才是绝对连续的,跨零点也自然回到起点。 */
144
- .balance-footer__timeline-pointer{position:absolute;top:-1px;bottom:-1px;width:2px;margin-left:-1px;background:var(--dsw-alias-label-primary,#fff);box-shadow:0 0 3px rgba(255,255,255,.7),0 0 2px rgba(0,0,0,.55);pointer-events:none;z-index:2}
140
+ /* 换日时色带的交叉淡入:两条 linear-gradient 之间不可插值,只能新旧两层叠着换 opacity。
141
+ TimelineBand 把旧分布垫在下面,新分布挂这个类淡入盖上去,播完摘掉旧层。
142
+ 终点 opacity 读 --balance-band-to:色带本体 1、泛光层 .45 —— 写死 1 会把泛光拉成实心。 */
143
+ .balance-footer__timeline-fade{animation:balance-band-fade 620ms cubic-bezier(.4,0,.25,1) both}
144
+ @keyframes balance-band-fade{from{opacity:0}to{opacity:var(--balance-band-to,1)}}
145
+ /* 指针位置由 TimelinePointer 写 inline style 的 left(3px 端内缩,见组件里的 calc)。
146
+ 入场走 **CSS animation**(--intro 类 + balance-ptr-in),不是 transition:
147
+ - transition 的起点取「上一次样式计算的值」,而重启 DSH 时首屏繁忙、浏览器可能还没为
148
+ 这个元素算过样式,那次变更就被当成首次赋值 → 直接跳到终点、一帧都不播
149
+ (2026-10-30 反馈「有时候重启时指针动画会吞掉」)。
150
+ - animation 的 keyframes 有**显式的 from/to**,元素一插入就带着动画,浏览器时间线怎么
151
+ 排都不会漏播;首屏忙只会晚一点开始,但一定播完整。补间在浏览器动画管线里跑,
152
+ React 全程只渲染 2 次(挂载、摘类),不抢主线程(逐帧 setState 就是「掉帧」的元凶)。
153
+ - 稳态(每分钟一次落位)**不带**动画:0.069% 的变化直接跳过去,肉眼无感;顺带也
154
+ 避开了「后台标签页暂停动画、切回来瞬移」的老问题。
155
+ 时长与曲线与刷新白条 balance-wipe-head 完全一致(.58s cubic-bezier(.34,.7,.26,1))。 */
156
+ .balance-footer__timeline-pointer{position:absolute;left:0;top:-1px;bottom:-1px;width:2px;margin-left:-1px;background:var(--dsw-alias-label-primary,#fff);box-shadow:0 0 3px rgba(255,255,255,.7),0 0 2px rgba(0,0,0,.55);pointer-events:none;z-index:2}
157
+ .balance-footer__timeline-pointer--intro{animation:balance-ptr-in 580ms cubic-bezier(.34,.7,.26,1) both;will-change:left}
158
+ /* from 固定 = pct 0 的位置(0% + 3px 内缩);to 读元素上的自定义属性,即最终落点。
159
+ both 让动画结束后停在 to,摘掉 --intro 时 inline left 恰好同值,不会跳。 */
160
+ @keyframes balance-ptr-in{from{left:3px}to{left:var(--balance-ptr-to,3px)}}
145
161
  /* ── 侧栏入口的红点 + 未读高亮 ─────────────────────────────────────────── */
146
162
  .balance-panel-icon{position:relative;display:inline-flex;align-items:center;justify-content:center}
147
163
  /* 未读时图标本身染成品牌色,比周围灰图标更跳 */
@@ -1839,6 +1855,142 @@ const CN_CALENDAR_SOURCES = [
1839
1855
  }
1840
1856
 
1841
1857
  // ─── 余额显示:单击统计,双击 DeepSeek 用量页,右键刷新 ─────────────
1858
+ // 指针入场动画:从刻度 0 滑到当前时刻,只在页面生命周期的第一次挂载播一次。
1859
+ // ⚠️ **不要用「模块加载时刻 + 时间窗口」判断**(试过,不行):client 模块在页面启动早期就加载了,
1860
+ // 而侧栏组件要等首屏渲染 + 会话恢复完才挂载,中间经常超过好几秒,于是动画被判定成"过期"、
1861
+ // 指针直接落在终点 —— 用户看到的就是「当前不是从 0 开始的」(2026-10-30 第四次现场反馈)。
1862
+ // 正确做法:用「是否已经播完过一次」的标记,播完才置位。挂载被中断 / StrictMode 双跑都能补播。
1863
+ // POINTER_INTRO_MAX_AGE_MS 只是兜底上限,防止挂载后很久才展开侧栏又莫名滑一次。
1864
+ const POINTER_INTRO_MAX_AGE_MS = 30000;
1865
+ const PLUGIN_LOADED_AT = Date.now();
1866
+ let pointerIntroDone = false;
1867
+
1868
+ // 时间轴指针:一根 2px 竖条,位置 = 此刻在当天的百分比。
1869
+ //
1870
+ // ⚠️ 四件事必须守住:
1871
+ // 1) 位置由 **React state 渲染**(inline style),不是 ref 里手写 style.left ——
1872
+ // 在 DSH 里 ref 拿不到 DOM,指针会永远停在 0%(2026-10-30 两次现场反馈)。
1873
+ // 2) **不要用 requestAnimationFrame**:DSH 渲染进程里它会被窗口可见性挂起。
1874
+ // 3) 它必须**独立成一个组件**:补间每拍都 setState,挂在 BalanceFooter 里会带着
1875
+ // 余额行、时间轴、胶囊全部重渲染 —— 一次入场动画要重渲上百次整块 footer,
1876
+ // 桌面版启动首屏本来就忙,那就是用户看到的「掉帧 / 初始化很卡」。
1877
+ // 4) 稳态**不要**跑逐帧定时器:指针每分钟才动 0.069%(约 0.14px),按分钟对齐一次就够。
1878
+ // 旧版稳态也跑 16ms 定时器,等于页面开着就一直 62Hz 唤醒,纯浪费。
1879
+ function TimelinePointer() {
1880
+ // 入场动画 = 一条 CSS animation,React 全程只渲染 2 次(挂载一次、摘类一次)。
1881
+ //
1882
+ // ⚠️ 三条历史教训,都别再退回去:
1883
+ // 1) 逐帧 setState(16ms × 37 拍)会在重启 DSH 的首屏高峰上抢主线程 → 掉帧。
1884
+ // 2) 用 CSS transition + 延后一拍设终点,**有时候动画会被吞**:transition 的起点取
1885
+ // 「上一次样式计算的值」,而首屏繁忙时浏览器可能还没为这个元素算过样式,
1886
+ // 那次变更就被当成首次赋值,直接跳到终点、一帧都不播(2026-10-30 反馈「有时候重启时指针动画会吞掉」)。
1887
+ // 3) 所以现在用 animation:keyframes 里有**显式的 from/to**,不依赖上一次计算值。
1888
+ // 元素一插入就带着动画,opacity/left 从 3px 补到终点,浏览器时间线怎么排都不会漏播;
1889
+ // 首屏忙时它只会晚一点开始,但一定播完整。
1890
+ const [view, setView] = react.useState(() => {
1891
+ const expired = Date.now() - PLUGIN_LOADED_AT > POINTER_INTRO_MAX_AGE_MS;
1892
+ return { pct: pointerPercent(Date.now()), intro: !pointerIntroDone && !expired };
1893
+ });
1894
+ react.useEffect(() => {
1895
+ const expired = Date.now() - PLUGIN_LOADED_AT > POINTER_INTRO_MAX_AGE_MS;
1896
+ const intro = !pointerIntroDone && !expired;
1897
+ // 与刷新白条(CSS 里 balance-wipe-head 的 .58s)同一个时长、同一条曲线,
1898
+ // 两者在同一次渲染里插入,动画时钟同起同落。
1899
+ const INTRO_MS = 580;
1900
+ const IDLE_MS = 60000;
1901
+ let introTimer = 0;
1902
+ let idleTimer = 0;
1903
+ const idleIn = (now) => IDLE_MS - (now % IDLE_MS) + 30;
1904
+ const scheduleIdle = () => {
1905
+ idleTimer = setTimeout(tick, idleIn(Date.now()));
1906
+ };
1907
+ function tick() {
1908
+ // 稳态:不带动画直接落位(每分钟才动 0.069%,跳过去看不出来),
1909
+ // 下一拍对齐到下一个整分钟。跨零点也走这里,不会反向滑回去。
1910
+ setView({ pct: pointerPercent(Date.now()), intro: false });
1911
+ scheduleIdle();
1912
+ }
1913
+ if (intro) {
1914
+ introTimer = setTimeout(() => {
1915
+ // 到站:置位标记(之后的重挂载不再重播),并摘掉动画类。
1916
+ // animation 是 both,摘类前后都在终点,不会跳。
1917
+ pointerIntroDone = true;
1918
+ setView((v) => ({ pct: v.pct, intro: false }));
1919
+ scheduleIdle();
1920
+ }, INTRO_MS + 40);
1921
+ } else {
1922
+ scheduleIdle();
1923
+ }
1924
+ return () => {
1925
+ clearTimeout(introTimer);
1926
+ clearTimeout(idleTimer);
1927
+ };
1928
+ // 依赖故意为空:只在挂载时读一次初值,之后由 CSS animation + 分钟对齐自己跑。
1929
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1930
+ }, []);
1931
+ // 刻度两端各留 3px 余量:指针 2px 宽、margin-left 让它以 left 为中心线,直接贴 0%/100%
1932
+ // 会有一半被父级 overflow 裁掉,看着就像指针跑到轨道外面去了。
1933
+ // 把 [0,100] 线性压进 [3px, 100% - 3px]:中心线 = inset + (p/100) × (宽 - 2 × inset),
1934
+ // 展开成 calc 就是「p% + inset px - (p/100) × 2 × inset px」。
1935
+ const pct = view.pct;
1936
+ const inset = 3;
1937
+ const shift = inset - (pct / 100) * 2 * inset;
1938
+ const pos = "calc(" + pct.toFixed(4) + "% + " + shift.toFixed(2) + "px)";
1939
+ return jsx.jsx("span", {
1940
+ className: view.intro ? "balance-footer__timeline-pointer balance-footer__timeline-pointer--intro" : "balance-footer__timeline-pointer",
1941
+ // left 给稳态用;--balance-ptr-to 是 keyframes 的终点(两者取值相同)。
1942
+ style: { left: pos, "--balance-ptr-to": pos },
1943
+ });
1944
+ }
1945
+
1946
+ // 色带分布变化时的交叉淡入(2026-10-30 加,用户「如果第二天的时段分布不一样 时刻条里的颜色分布我需要有渐变动画」)。
1947
+ //
1948
+ // 色带是一条 linear-gradient,而 background-image 在 CSS 里 **不可插值** —— 直接换 gradient
1949
+ // 永远是硬切:周五 → 周六(整条从「红绿相间」变成「通体绿」)会啪一下换掉,很扎眼。
1950
+ // 所以做两层交叉淡入:新的那条从 opacity 0 淡到本该有的不透明度,盖住旧的那条,
1951
+ // 播完把旧层摘掉。视觉上就是颜色分布自己慢慢挪过去。
1952
+ //
1953
+ // 触发时机只有两个,都不会天天发生:
1954
+ // 1) 跨零点 —— 第二天的分布不同(工作日 ↔ 周末、节前 ↔ 假期);
1955
+ // 2) 节假日表刚同步完,把当天的口径纠正了。
1956
+ // 没有定时器:只跟着传入的 gradient 字符串变化走,一天最多一次。
1957
+ //
1958
+ // 首挂不播(seq === 0 时不挂动画类)—— 色带不该有自己的入场动画,那会让首屏又多一份动效。
1959
+ const BAND_FADE_MS = 620;
1960
+ function TimelineBand(props) {
1961
+ const gradient = props.gradient;
1962
+ const [view, setView] = react.useState(() => ({ now: gradient, prev: null, seq: 0 }));
1963
+ // 分布变了:把当前这条降级成 prev(留在下面),新的那条顶上来并 +1 换 key。
1964
+ // key 变了才是新元素,CSS animation 才会重新播。
1965
+ react.useEffect(() => {
1966
+ setView((v) => (v.now === gradient ? v : { now: gradient, prev: v.now, seq: v.seq + 1 }));
1967
+ }, [gradient]);
1968
+ // 淡入播完再摘掉旧层,防止 prev 一天天堆下去。
1969
+ react.useEffect(() => {
1970
+ if (!view.prev) return;
1971
+ const timer = setTimeout(() => {
1972
+ setView((v) => (v.prev ? { now: v.now, prev: null, seq: v.seq } : v));
1973
+ }, BAND_FADE_MS + 80);
1974
+ return () => clearTimeout(timer);
1975
+ }, [view.prev, view.seq]);
1976
+ // seq === 0 是首挂,不带动画类;之后每一层都带,靠 key 变化重新起播。
1977
+ const fadeCls = view.seq > 0 ? " balance-footer__timeline-fade" : "";
1978
+ // --balance-band-to 是 keyframes 的终点 opacity:色带本体 1、泛光层保持原本的 .45,
1979
+ // 否则泛光会被动画拉成实心(它静态就是半透明的)。
1980
+ const layer = (cls, bg, to, key) => jsx.jsx("span", {
1981
+ className: cls + fadeCls,
1982
+ style: { background: bg, "--balance-band-to": to },
1983
+ }, key);
1984
+ const layers = [];
1985
+ if (view.prev) {
1986
+ layers.push(jsx.jsx("span", { className: "balance-footer__timeline-glow", style: { background: view.prev } }, "pg"));
1987
+ layers.push(jsx.jsx("span", { className: "balance-footer__timeline-band", style: { background: view.prev } }, "pb"));
1988
+ }
1989
+ layers.push(layer("balance-footer__timeline-glow", view.now, "0.45", "g" + view.seq));
1990
+ layers.push(layer("balance-footer__timeline-band", view.now, "1", "b" + view.seq));
1991
+ return jsx.jsx("span", { className: "balance-footer__timeline-clip", children: layers });
1992
+ }
1993
+
1842
1994
  function BalanceFooter({ wide }) {
1843
1995
  const state = useBalance();
1844
1996
  const settings = useSettings();
@@ -1958,23 +2110,8 @@ const CN_CALENDAR_SOURCES = [
1958
2110
  }
1959
2111
  };
1960
2112
  }, []);
1961
- // 指针位置:500ms 一次直接改 DOM 的 left,不经 React state、也不用 CSS transition。
1962
- // 一天走完 100%,每分钟只有 0.069%(200px 轨道上 0.14px):
1963
- // · 按分钟 tick + 长过渡 —— 静止 59 秒再窜一下,且连续性押在浏览器动画时钟上,
1964
- // 后台标签页会把过渡暂停,切回来时直接落到终点,那就是用户看到的「瞬移」;
1965
- // · 每 500ms 重算一次 —— 相邻两次只差 0.0006%(0.0012px),视觉上就是匀速滑,
1966
- // 不依赖过渡调度,也不会因为后台暂停而攒成一跳。
1967
- // 只有 2px 的元素改 left,代价可忽略;不碰 state 就不触发组件重渲染。
1968
- const pointerRef = react.useRef(null);
1969
- react.useEffect(() => {
1970
- const paint = () => {
1971
- const el = pointerRef.current;
1972
- if (el) el.style.left = pointerPercent(Date.now()).toFixed(4) + "%";
1973
- };
1974
- paint();
1975
- const timer = setInterval(paint, 500);
1976
- return () => clearInterval(timer);
1977
- }, []);
2113
+ // 指针位置见模块作用域的 TimelinePointer —— 刻意拆成独立组件:
2114
+ // 补间只重渲染那一根 2px 竖条,不会带着整个余额行一起重渲染。
1978
2115
  const clickTimer = react.useRef(null);
1979
2116
  const closeTimer = react.useRef(null);
1980
2117
  const anchorRef = react.useRef(null);
@@ -2270,29 +2407,12 @@ const CN_CALENDAR_SOURCES = [
2270
2407
  jsx.jsxs("span", {
2271
2408
  className: "balance-footer__timeline-track balance-footer__timeline-track--" + period,
2272
2409
  children: [
2273
- // 裁剪层让四周各留 1px,颜色不贴边框
2274
- jsx.jsxs("span", {
2275
- className: "balance-footer__timeline-clip",
2276
- children: [
2277
- // 泛光:同一渐变模糊后叠在色带上面,靠柔边往外散光
2278
- jsx.jsx("span", {
2279
- className: "balance-footer__timeline-glow",
2280
- style: { background: timelineBand.gradient },
2281
- }),
2282
- // 色带本体:纯色块,不带任何文字
2283
- jsx.jsx("span", {
2284
- className: "balance-footer__timeline-band",
2285
- style: { background: timelineBand.gradient },
2286
- }),
2287
- ],
2288
- }),
2289
- // 指针:位置 = 此刻在当天的比例,随时间从左走到右
2290
- jsx.jsx("span", {
2291
- className: "balance-footer__timeline-pointer",
2292
- ref: pointerRef,
2293
- // 首帧先摆到位(rAF 还没起来),之后由 rAF 每帧接管
2294
- style: { left: pointerPercent(Date.now()).toFixed(3) + "%" },
2295
- }),
2410
+ // 裁剪层让四周各留 1px,颜色不贴边框。
2411
+ // 里面是泛光 + 色带两层,换日时由 TimelineBand 做交叉淡入(gradient 不可插值)。
2412
+ jsx.jsx(TimelineBand, { gradient: timelineBand.gradient }),
2413
+ // 指针:位置 = 此刻在当天的比例,随时间从左走到右。
2414
+ // 指针是独立组件,自己管 state 和补间,不带着余额行重渲染。
2415
+ jsx.jsx(TimelinePointer, {}),
2296
2416
  ],
2297
2417
  }),
2298
2418
  ],
@@ -3299,6 +3419,38 @@ function UsageCostLine({ useProjection, sessionId }) {
3299
3419
  ],
3300
3420
  }),
3301
3421
 
3422
+ // 节假日同步紧跟在「版本与更新」下面:两者都是「后台自动补一次 + 可手动触发」,
3423
+ // 放一起比塞在「计费」末尾好找。数据本身仍然决定算钱口径,只是入口挪了位置。
3424
+ group("calendar", "节假日数据", [
3425
+ row(
3426
+ "calendar",
3427
+ "同步状态",
3428
+ "法定节假日与调休从国务院公告同步。插件每 30 天自动同步一次,这里可以手动立即更新。",
3429
+ jsx.jsxs("div", {
3430
+ className: "balance-settings__calendar",
3431
+ children: [
3432
+ jsx.jsx("span", {
3433
+ className:
3434
+ "balance-settings__calendar-note" +
3435
+ (calendarSync.kind === "error"
3436
+ ? " balance-settings__calendar-note--error"
3437
+ : calendar.fetchedAt > 0
3438
+ ? ""
3439
+ : " balance-settings__calendar-note--warn"),
3440
+ children: calendarSummary,
3441
+ }),
3442
+ jsx.jsx("button", {
3443
+ type: "button",
3444
+ className: "balance-settings__btn",
3445
+ onClick: onSyncCalendar,
3446
+ disabled: calendarBusy,
3447
+ children: calendarBusy ? "同步中…" : "立即同步",
3448
+ }),
3449
+ ],
3450
+ }),
3451
+ ),
3452
+ ]),
3453
+
3302
3454
  group("balance", "余额", [
3303
3455
  numberRow("refreshIntervalSec", "余额刷新间隔", "轮询间隔;对话产生新消耗时也会顺带刷新。", 5, 3600, "秒"),
3304
3456
  numberRow("lowBalanceThreshold", "低余额警示阈值", "余额低于该值时金额转为琥珀色。", 0, 100000, "元"),
@@ -3362,36 +3514,6 @@ function UsageCostLine({ useProjection, sessionId }) {
3362
3514
  "9-12, 14-18",
3363
3515
  ),
3364
3516
  numberRow("peakMultiplier", "高峰时段倍率", "官方为 2(高峰价 = 空闲价 × 倍率)。", 1, 10, "倍"),
3365
- // 法定节假日/调休直接决定某天算高峰还是空闲(算钱和标签都靠它),
3366
- // 内置表只是离线兜底。给一个手动同步按钮:即使哪天没人维护插件,
3367
- // 用户自己点一下就能把下一年的国务院公告补进来。
3368
- row(
3369
- "calendar",
3370
- "节假日数据",
3371
- "法定节假日与调休从国务院公告同步。插件每 30 天自动同步一次,这里可以手动立即更新。",
3372
- jsx.jsxs("div", {
3373
- className: "balance-settings__calendar",
3374
- children: [
3375
- jsx.jsx("span", {
3376
- className:
3377
- "balance-settings__calendar-note" +
3378
- (calendarSync.kind === "error"
3379
- ? " balance-settings__calendar-note--error"
3380
- : calendar.fetchedAt > 0
3381
- ? ""
3382
- : " balance-settings__calendar-note--warn"),
3383
- children: calendarSummary,
3384
- }),
3385
- jsx.jsx("button", {
3386
- type: "button",
3387
- className: "balance-settings__btn",
3388
- onClick: onSyncCalendar,
3389
- disabled: calendarBusy,
3390
- children: calendarBusy ? "同步中…" : "立即同步",
3391
- }),
3392
- ],
3393
- }),
3394
- ),
3395
3517
  ]),
3396
3518
 
3397
3519
  jsx.jsxs("div", {
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.3";
530
+ const PLUGIN_VERSION = "2.0.4";
531
531
 
532
532
  // ─── 检查更新 ───────────────────────────────────────────────────────────────
533
533
  // 只查 npm registry 的 latest 标签(一个很小的 JSON),不下载任何东西。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "description": "在 DeepSeek Harness 中显示 DeepSeek 账户余额和当前会话 token/费用消耗(含高峰/空闲峰谷计费)。仅支持 DeepSeek 官方模型与账号,暂不支持其他大模型。",
5
5
  "author": "Rannist",
6
6
  "type": "module",