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