dsh-desktop-statusbar 2.0.1 → 2.0.2

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/README.en.md CHANGED
@@ -4,7 +4,7 @@
4
4
  <a href="https://github.com/raphael-y7/dsh-desktop-statusbar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/raphael-y7/dsh-desktop-statusbar?style=social"></a>
5
5
  <a href="https://www.npmjs.com/package/dsh-desktop-statusbar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-desktop-statusbar?color=red"></a>
6
6
  <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-PolyForm%20Noncommercial%201.0.0-green"></a>
7
- <a href="https://github.com/deepseek-ai/deepseek-harness"><img alt="DeepSeek Harness" src="https://img.shields.io/badge/DeepSeek%20Harness-0.1.7-%234d6bfe"></a>
7
+ <a href="https://github.com/deepseek-ai/deepseek-harness"><img alt="DeepSeek Harness" src="https://img.shields.io/badge/DeepSeek%20Harness-0.2.0--rc.1-%234d6bfe"></a>
8
8
  <a href="https://nodejs.org/"><img alt="node" src="https://img.shields.io/badge/node-%5E22.19%20%7C%7C%20%3E%3D24-%235fa04e"></a>
9
9
  <img alt="language" src="https://img.shields.io/github/languages/top/raphael-y7/dsh-desktop-statusbar?color=yellow">
10
10
  <a href="https://dshfind.com/zh/plugins/raphael-y7/dsh-desktop-statusbar"><img alt="dshfind" src="https://img.shields.io/badge/dshfind-listed-%2300a884"></a>
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  <a href="https://github.com/raphael-y7/dsh-desktop-statusbar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/raphael-y7/dsh-desktop-statusbar?style=social"></a>
5
5
  <a href="https://www.npmjs.com/package/dsh-desktop-statusbar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-desktop-statusbar?color=red"></a>
6
6
  <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-PolyForm%20Noncommercial%201.0.0-green"></a>
7
- <a href="https://github.com/deepseek-ai/deepseek-harness"><img alt="DeepSeek Harness" src="https://img.shields.io/badge/DeepSeek%20Harness-0.1.7-%234d6bfe"></a>
7
+ <a href="https://github.com/deepseek-ai/deepseek-harness"><img alt="DeepSeek Harness" src="https://img.shields.io/badge/DeepSeek%20Harness-0.2.0--rc.1-%234d6bfe"></a>
8
8
  <a href="https://nodejs.org/"><img alt="node" src="https://img.shields.io/badge/node-%5E22.19%20%7C%7C%20%3E%3D24-%235fa04e"></a>
9
9
  <img alt="language" src="https://img.shields.io/github/languages/top/raphael-y7/dsh-desktop-statusbar?color=yellow">
10
10
  <a href="https://dshfind.com/zh/plugins/raphael-y7/dsh-desktop-statusbar"><img alt="dshfind" src="https://img.shields.io/badge/dshfind-listed-%2300a884"></a>
@@ -0,0 +1,224 @@
1
+ /**
2
+ * 多平台余额:把「当前会话用的模型/provider」映射到「该平台的余额接口 + 响应解析」。
3
+ *
4
+ * 只收录**已核实过官方文档**的平台;每家的端点、鉴权、返回字段都写在注释里,方便日后复核。
5
+ * 本模块只做纯逻辑(选适配器、拼请求、解析响应),真正发请求由 index.js 负责 —— 这样能单测。
6
+ *
7
+ * 核实时间:2026-09。未核实到的平台宁可不写,也不猜端点。
8
+ */
9
+
10
+ /* 余额响应统一成这个形状:
11
+ * { currency: 'CNY' | 'USD' | ..., total: '12.34', parts: [{ label, amount }] }
12
+ * currency 缺失时由调用方按站点/域名推断;parts 用来填气泡里的构成行(充值 / 赠金等)。 */
13
+
14
+ const adapters = [
15
+ {
16
+ id: 'deepseek',
17
+ label: 'DeepSeek',
18
+ /* 核实:GET https://api.deepseek.com/user/balance,Authorization: Bearer <key>
19
+ 响应 { balance_infos: [{ currency, total_balance, granted_balance, topped_up_balance }] } */
20
+ keys: ['deepseek', 'deepseek-official'],
21
+ refs: ['DEEPSEEK_API_KEY', 'deepseek-official', 'deepseek'],
22
+ request: (key) => ({
23
+ url: 'https://api.deepseek.com/user/balance',
24
+ headers: { authorization: 'Bearer ' + key, accept: 'application/json' }
25
+ }),
26
+ parse: (data) => {
27
+ const infos = Array.isArray(data && data.balance_infos) ? data.balance_infos : [];
28
+ if (infos.length === 0) return null;
29
+ const one = infos[0];
30
+ const total = typeof one.total_balance === 'string' ? one.total_balance : null;
31
+ if (total === null || total.length === 0) return null;
32
+ const parts = [];
33
+ if (typeof one.topped_up_balance === 'string') parts.push({ label: 'toppedUp', amount: one.topped_up_balance });
34
+ if (typeof one.granted_balance === 'string') parts.push({ label: 'granted', amount: one.granted_balance });
35
+ return { currency: typeof one.currency === 'string' ? one.currency : 'CNY', total, parts };
36
+ }
37
+ },
38
+ {
39
+ id: 'openrouter',
40
+ label: 'OpenRouter',
41
+ /* 核实:GET https://openrouter.ai/api/v1/credits,Authorization: Bearer <key>
42
+ 响应 { data: { total_credits, total_usage } },可用额度 = total_credits - total_usage */
43
+ keys: ['openrouter'],
44
+ refs: ['OPENROUTER_API_KEY'],
45
+ request: (key) => ({
46
+ url: 'https://openrouter.ai/api/v1/credits',
47
+ headers: { authorization: 'Bearer ' + key, accept: 'application/json' }
48
+ }),
49
+ parse: (data) => {
50
+ const inner = data && data.data ? data.data : null;
51
+ if (inner === null) return null;
52
+ const total = Number(inner.total_credits);
53
+ const used = Number(inner.total_usage);
54
+ if (Number.isFinite(total) !== true) return null;
55
+ const left = total - (Number.isFinite(used) ? used : 0);
56
+ return {
57
+ currency: 'USD',
58
+ total: left.toFixed(2),
59
+ parts: [
60
+ { label: 'toppedUp', amount: total.toFixed(2) },
61
+ { label: 'used', amount: (Number.isFinite(used) ? used : 0).toFixed(2) }
62
+ ]
63
+ };
64
+ }
65
+ },
66
+ {
67
+ id: 'bai',
68
+ label: 'B.AI',
69
+ /* 核实:文档 docs.b.ai/llmservice/api/balance/ 给出 GET /v1/balance,
70
+ 鉴权 Authorization: Bearer <api_key>,响应含 balance 字段。
71
+ 注:文档站是 docs.b.ai,API 域名按 api.b.ai 拼接,未经真实调用验证。 */
72
+ keys: ['b-ai', 'bai', 'b.ai'],
73
+ refs: ['B_AI_API_KEY', 'BAI_API_KEY'],
74
+ request: (key) => ({
75
+ url: 'https://api.b.ai/v1/balance',
76
+ headers: { authorization: 'Bearer ' + key, accept: 'application/json' }
77
+ }),
78
+ parse: (data) => {
79
+ const raw = data && (data.balance ?? (data.data ? data.data.balance : undefined));
80
+ if (raw === undefined || raw === null) return null;
81
+ const total = String(raw);
82
+ if (total.length === 0) return null;
83
+ return { currency: 'CNY', total, parts: [] };
84
+ }
85
+ },
86
+ {
87
+ id: 'siliconflow',
88
+ label: 'SiliconFlow',
89
+ /* 核实:GET https://api.siliconflow.com/v1/user/info(国内站 api.siliconflow.cn),
90
+ Authorization: Bearer <key>;响应 data.{balance, chargeBalance, totalBalance}(均为字符串)。
91
+ 官方未声明币种,国际站按 USD、国内站按 CNY 处理。 */
92
+ keys: ['siliconflow'],
93
+ refs: ['SILICONFLOW_API_KEY'],
94
+ request: (key, base) => ({
95
+ url: (base || 'https://api.siliconflow.com') + '/v1/user/info',
96
+ headers: { authorization: 'Bearer ' + key, accept: 'application/json' }
97
+ }),
98
+ parse: (data) => {
99
+ const inner = data && data.data ? data.data : null;
100
+ if (inner === null) return null;
101
+ const total = typeof inner.totalBalance === 'string' ? inner.totalBalance : null;
102
+ if (total === null || total.length === 0) return null;
103
+ const parts = [];
104
+ if (typeof inner.chargeBalance === 'string') parts.push({ label: 'toppedUp', amount: inner.chargeBalance });
105
+ if (typeof inner.balance === 'string') parts.push({ label: 'granted', amount: inner.balance });
106
+ return { currency: 'USD', total, parts };
107
+ }
108
+ },
109
+ {
110
+ id: 'moonshot',
111
+ label: 'Kimi',
112
+ /* 核实:GET https://api.moonshot.ai/v1/users/me/balance(国内站 api.moonshot.cn),
113
+ Authorization: Bearer <key>;响应 { code, data: { available_balance, voucher_balance, cash_balance } }。
114
+ 两站的 key 不通用,单位也不同:国际站 USD、国内站 CNY。 */
115
+ keys: ['moonshot', 'kimi'],
116
+ refs: ['MOONSHOT_API_KEY', 'KIMI_API_KEY'],
117
+ request: (key, base) => ({
118
+ url: (base || 'https://api.moonshot.ai') + '/v1/users/me/balance',
119
+ headers: { authorization: 'Bearer ' + key, accept: 'application/json' }
120
+ }),
121
+ parse: (data) => {
122
+ const inner = data && data.data ? data.data : null;
123
+ if (inner === null) return null;
124
+ const total = inner.available_balance;
125
+ if (typeof total !== 'number' || Number.isFinite(total) !== true) return null;
126
+ const parts = [];
127
+ if (typeof inner.cash_balance === 'number') parts.push({ label: 'toppedUp', amount: inner.cash_balance.toFixed(2) });
128
+ if (typeof inner.voucher_balance === 'number') parts.push({ label: 'granted', amount: inner.voucher_balance.toFixed(2) });
129
+ return { currency: 'USD', total: total.toFixed(2), parts };
130
+ }
131
+ },
132
+ {
133
+ id: 'zhipu',
134
+ label: 'GLM 余额',
135
+ /* 来源:cc-switch PR #6978 的自定义模板(截至 2026-09-27 未合并,社区实测方案)
136
+ GET https://open.bigmodel.cn/api/biz/account/query-customer-account-report
137
+ 鉴权:Authorization: Bearer <api_key> —— 注意与 coding plan 配额的「裸 key」不同
138
+ 响应 { code, msg, data: { availableBalance, balance, rechargeAmount, totalSpendAmount } }
139
+ code === 200 才算成功;可用余额 = availableBalance ?? balance,单位 CNY。 */
140
+ keys: ['zhipu', 'glm', 'bigmodel'],
141
+ refs: ['ZAI_API_KEY', 'ZHIPU_API_KEY', 'GLM_API_KEY', 'BIGMODEL_API_KEY'],
142
+ request: (key) => ({
143
+ /* 实测 2026-09-27:open.bigmodel.cn 与 api.z.ai 同后端,两个域名 + Bearer/裸 key 四种组合都返回 code=200 */
144
+ url: 'https://open.bigmodel.cn/api/biz/account/query-customer-account-report',
145
+ headers: { authorization: 'Bearer ' + key, accept: 'application/json' }
146
+ }),
147
+ parse: (data) => {
148
+ if (data === null || data === undefined) return null;
149
+ if (Number(data.code) !== 200) return null;
150
+ const inner = data.data ? data.data : {};
151
+ const raw = inner.availableBalance !== undefined && inner.availableBalance !== null ? inner.availableBalance : inner.balance;
152
+ const remaining = Number(raw);
153
+ if (Number.isFinite(remaining) !== true) return null;
154
+ const parts = [];
155
+ const recharge = Number(inner.rechargeAmount);
156
+ const gift = Number(inner.giveAmount);
157
+ const spent = Number(inner.totalSpendAmount);
158
+ if (Number.isFinite(recharge)) parts.push({ label: 'toppedUp', amount: recharge.toFixed(2) });
159
+ if (Number.isFinite(gift)) parts.push({ label: 'granted', amount: gift.toFixed(2) });
160
+ if (Number.isFinite(spent)) parts.push({ label: 'spent', amount: spent.toFixed(2) });
161
+ return { currency: 'CNY', total: remaining.toFixed(2), parts };
162
+ }
163
+ },
164
+ {
165
+ id: 'zhipu-quota',
166
+ label: 'GLM 配额',
167
+ /* 核实:cc-switch v3.20.4 的 src-tauri/src/services/coding_plan.rs:332-346
168
+ GET https://open.bigmodel.cn/api/monitor/usage/quota/limit(国际站 https://api.z.ai)
169
+ 鉴权:Authorization: <api_key> —— 智谱**不加 Bearer 前缀**(源码注释两次强调)
170
+ 响应 { success, data: { level, limits: [{ type: TOKENS_LIMIT|CREDIT_LIMIT, percentage, ... }] } }
171
+ percentage 就是配额使用率,直接当百分比显示,**不换算金额**。注意这是 coding plan
172
+ 配额,不是按量计费的现金余额 —— 现金余额只有控制台接口能给,需要网页登录态。 */
173
+ keys: ['zhipu', 'glm', 'bigmodel'],
174
+ refs: ['ZHIPU_API_KEY', 'GLM_API_KEY', 'BIGMODEL_API_KEY'],
175
+ request: (key, base) => ({
176
+ url: (base || 'https://open.bigmodel.cn') + '/api/monitor/usage/quota/limit',
177
+ headers: {
178
+ authorization: key,
179
+ 'content-type': 'application/json',
180
+ 'accept-language': 'en-US,en'
181
+ }
182
+ }),
183
+ parse: (data) => {
184
+ if (data === null || data === undefined || data.success === false) return null;
185
+ const inner = data.data ? data.data : null;
186
+ const limits = inner !== null && Array.isArray(inner.limits) ? inner.limits : [];
187
+ let percent = null;
188
+ for (let i = 0; i < limits.length; i += 1) {
189
+ const one = limits[i];
190
+ const kind = typeof one.type === 'string' ? one.type.toUpperCase() : '';
191
+ if (kind !== 'TOKENS_LIMIT' && kind !== 'CREDIT_LIMIT') continue;
192
+ const value = Number(one.percentage);
193
+ if (Number.isFinite(value) !== true) continue;
194
+ /* 取用得最紧的那个窗口,保守一点 */
195
+ if (percent === null || value > percent) percent = value;
196
+ }
197
+ if (percent === null) return null;
198
+ return { currency: '', total: percent.toFixed(2) + '%', parts: [], isPercent: true };
199
+ }
200
+ }
201
+ ];
202
+
203
+ /** 会话线索(provider + 模型名)里出现这些词就认为是哪一家。 */
204
+ function adapterFor(provider, model) {
205
+ const text = (String(provider === undefined || provider === null ? '' : provider) + ' '
206
+ + String(model === undefined || model === null ? '' : model)).toLowerCase();
207
+ if (text.trim().length === 0) return null;
208
+ for (let i = 0; i < adapters.length; i += 1) {
209
+ const one = adapters[i];
210
+ for (let k = 0; k < one.keys.length; k += 1) {
211
+ if (text.includes(one.keys[k])) return one;
212
+ }
213
+ }
214
+ return null;
215
+ }
216
+
217
+ /** 会话模型属于 DeepSeek 时,优先走官方账号通道(账号登录没有 API key 也能查)。 */
218
+ function isDeepSeek(provider, model) {
219
+ const text = (String(provider === undefined || provider === null ? '' : provider) + ' '
220
+ + String(model === undefined || model === null ? '' : model)).toLowerCase();
221
+ return text.includes('deepseek');
222
+ }
223
+
224
+ export { adapters, adapterFor, isDeepSeek };
package/lib/client.js CHANGED
@@ -41,7 +41,7 @@ window.__ModuleLoader__.load({
41
41
  */
42
42
  const CONFIG_VERSION = 6;
43
43
  /** 插件版本:显示在设置页底部,用来确认页面加载的是哪一版客户端代码。 */
44
- const VERSION = "2.0.1";
44
+ const VERSION = "2.0.2";
45
45
  /** 官方参考价的版本:官方调价时改这个数字,老价格库会自动并入新参考价。 */
46
46
  const PRICE_VERSION = 2;
47
47
 
@@ -348,7 +348,10 @@ window.__ModuleLoader__.load({
348
348
  * 累加用 costOf 的原始值,只在这里做一次舍入。
349
349
  */
350
350
  function costText(amount) {
351
- return amount.toFixed(2);
351
+ const n = Number(amount);
352
+ /* 兜住 NaN / -0 / 负数:它们会显示成 "NaN" 或 "-0.00",看着像坏掉了 */
353
+ if (Number.isFinite(n) !== true || n <= 0) return "0.00";
354
+ return n.toFixed(2);
352
355
  }
353
356
 
354
357
  /** 逐字段相减,得到两次全量用量之间的增量 —— 即本轮消耗。 */
@@ -457,6 +460,7 @@ window.__ModuleLoader__.load({
457
460
  tipTpsSlow: "最慢输出速度",
458
461
  tipTopUp: "充值余额",
459
462
  tipGranted: "赠金余额",
463
+ tipSpent: "累计消费",
460
464
  tipHitTurn: "本轮缓存命中率",
461
465
  tipHitHigh: "最高缓存命中率",
462
466
  tipHitLow: "最低缓存命中率",
@@ -469,7 +473,7 @@ window.__ModuleLoader__.load({
469
473
  f_cost: "总计 {symbol}{cost}",
470
474
  priceTierPeak: "高峰时段",
471
475
  priceTierOffPeak: "空闲时段",
472
- f_balanceDash: "余额 -",
476
+ f_balanceDash: "余额 {symbol}-",
473
477
  f_lastCost: "本轮 {symbol}{cost}",
474
478
  f_balance: "余额 {symbol}{amount}",
475
479
  f_balanceSubCent: "余额 <{symbol}0.01",
@@ -546,6 +550,7 @@ window.__ModuleLoader__.load({
546
550
  tipTpsSlow: "Slowest TPS",
547
551
  tipTopUp: "Top-up balance",
548
552
  tipGranted: "Granted balance",
553
+ tipSpent: "Total spent",
549
554
  tipHitTurn: "Cache hit rate this turn",
550
555
  tipHitHigh: "Highest cache hit rate",
551
556
  tipHitLow: "Lowest cache hit rate",
@@ -581,7 +586,7 @@ window.__ModuleLoader__.load({
581
586
  unitCountSteps: " steps",
582
587
  unitSpeed: "t/s",
583
588
  priceSuggestedHint: "The price book uses the DeepSeek reference prices.",
584
- f_balanceDash: "Balance -",
589
+ f_balanceDash: "Balance {symbol}-",
585
590
  priceNewModel: "Custom model",
586
591
  priceAdd: "Add",
587
592
  priceEmpty: "Price book is empty — type a model name (e.g. deepseek-flash) and click Add.",
@@ -770,18 +775,34 @@ window.__ModuleLoader__.load({
770
775
  return !(Array.isArray(cfg.hidden) && cfg.hidden.indexOf(id) !== -1);
771
776
  }
772
777
  /**
773
- * 把 fromId 插到 toId 的前面(after=false)或后面(after=true)。
774
- * 先摘出被拖项再按目标定位,避免向下拖时索引差一位。
778
+ * 拖动中算落点:浮层的下边界压过哪一行的中线,影子就往下滑到那一行;
779
+ * 上边界举过哪一行的中线,就往上滑。
780
+ * slots 是按下那一刻按原始顺序量下的各行矩形,shift 是这期间页面滚动的位移
781
+ * (每次移动都重新量锚点得到)—— 直接用真实矩形,行距里有没有 gap、
782
+ * 某行是不是比别人高,都不影响判定。
775
783
  */
776
- function reorderSegments(fromId, toId, after) {
777
- if (fromId === null || fromId === undefined || toId === null || toId === undefined) return;
778
- if (fromId === toId) return;
779
- const next = config.segments.slice();
780
- if (next.indexOf(fromId) === -1 || next.indexOf(toId) === -1) return;
781
- next.splice(next.indexOf(fromId), 1);
782
- const at = next.indexOf(toId) + (after === true ? 1 : 0);
783
- next.splice(at, 0, fromId);
784
- setConfig({ segments: next });
784
+ function dropIndexAt(slots, shift, from, top, bottom) {
785
+ let to = from;
786
+ for (let i = from + 1; i < slots.length; i += 1) {
787
+ const rect = slots[i];
788
+ if (rect === null || rect === undefined) continue;
789
+ if (bottom <= rect.top + shift + rect.height / 2) break;
790
+ to = i;
791
+ }
792
+ for (let i = from - 1; i >= 0; i -= 1) {
793
+ const rect = slots[i];
794
+ if (rect === null || rect === undefined) continue;
795
+ if (top >= rect.top + shift + rect.height / 2) break;
796
+ to = i;
797
+ }
798
+ return to;
799
+ }
800
+
801
+ /** 把 from 位置的元素挪到 to 位置(不动原数组)。 */
802
+ function moveItem(list, from, to) {
803
+ const next = list.slice();
804
+ next.splice(to, 0, next.splice(from, 1)[0]);
805
+ return next;
785
806
  }
786
807
 
787
808
  /* ---------------------------------------------------------------- 工具 */
@@ -923,6 +944,19 @@ window.__ModuleLoader__.load({
923
944
  * 令牌面板数据:第一行是总用量,第二行三段占比(顶满 100%),下面三行明细。
924
945
  * 数字一律千分位精确值,不带单位(面板标题已经说明是 token 用量)。
925
946
  */
947
+ /** 没有 token 数据时的面板:不画三段条,三行都给横杠 —— 保证气泡在任何情况下都点得开。 */
948
+ function dashTokenPanel(t) {
949
+ return {
950
+ totalText: "-",
951
+ segments: [],
952
+ rows: [
953
+ { label: t("f_tokensHit"), color: "var(--dsw-static-neutral-bluish-400)", value: "-" },
954
+ { label: t("f_tokensMiss"), color: "#a78bfa", value: "-" },
955
+ { label: t("f_tokensOut"), color: "var(--dsw-static-blue-450)", value: "-" }
956
+ ]
957
+ };
958
+ }
959
+
926
960
  function tokenPanelView(cached, missInput, output, t) {
927
961
  const total = cached + missInput + output;
928
962
  if (total <= 0) return null;
@@ -1001,10 +1035,10 @@ window.__ModuleLoader__.load({
1001
1035
  });
1002
1036
  }
1003
1037
 
1004
- /** 令牌面板的 tip;三段全 0 时不给面板(省得弹一个空泡泡)。 */
1038
+ /** 令牌面板的 tip。没有数据也给面板(三行横杠)—— 气泡必须任何情况下都点得开。 */
1005
1039
  function tokensTip(parts, title, t) {
1006
1040
  const panel = parts === null ? null : tokenPanelView(parts.cached, parts.missInput, parts.output, t);
1007
- return panel === null ? null : { kind: "tokens", title: title, panel: panel };
1041
+ return { kind: "tokens", title: title, panel: panel === null ? dashTokenPanel(t) : panel };
1008
1042
  }
1009
1043
 
1010
1044
  /** 毫秒 → 面板里的时长文本(精确到秒);0 或缺数据给横杠。 */
@@ -1258,9 +1292,9 @@ window.__ModuleLoader__.load({
1258
1292
 
1259
1293
  if (id === "cost") {
1260
1294
  const sessionUsage = src.sessionUsage;
1261
- if (sessionUsage === undefined || sessionUsage === null) return null;
1262
- const calls = Array.isArray(sessionUsage.calls) ? sessionUsage.calls : null;
1263
- if (calls === null || calls.length === 0) return null;
1295
+ const calls = sessionUsage === undefined || sessionUsage === null || Array.isArray(sessionUsage.calls) !== true
1296
+ ? []
1297
+ : sessionUsage.calls;
1264
1298
  /* 每条调用按自己发生的那一刻、自己的模型定峰谷:跨时段、跨模型都不会串价。
1265
1299
  累加的是未舍入金额,最后一次性舍入 —— 逐条舍入会累积出几毛钱的误差。 */
1266
1300
  const fallbackModel = src.sessionModel !== undefined && src.sessionModel !== null ? src.sessionModel.model : null;
@@ -1273,10 +1307,10 @@ window.__ModuleLoader__.load({
1273
1307
  priced = true;
1274
1308
  total += one;
1275
1309
  }
1276
- if (!priced) return null;
1277
1310
  return {
1278
1311
  id: id,
1279
- text: t("f_cost", { symbol: symbol, cost: costText(total) }),
1312
+ /* 算不出价(价格库里没这个模型)时给 ¥-:与"这笔真的是 0"区分开 */
1313
+ text: t("f_cost", { symbol: symbol, cost: priced ? costText(total) : "-" }),
1280
1314
  /* token 计数并进总计:点这一段看会话的 token 明细 */
1281
1315
  tip: tokensTip(sessionTokenParts(src.usage), t("f_tokensTotal"), t)
1282
1316
  };
@@ -1291,29 +1325,31 @@ window.__ModuleLoader__.load({
1291
1325
  && Array.isArray(sessionUsageNow.current.calls)
1292
1326
  ? sessionUsageNow.current.calls
1293
1327
  : null;
1294
- if (turnCalls !== null && turnCalls.length > 0) {
1295
- let turnTotal = 0;
1296
- let turnPriced = false;
1297
- const turnParts = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
1298
- for (let i = 0; i < turnCalls.length; i += 1) {
1299
- const callModel = typeof turnCalls[i].model === "string" ? turnCalls[i].model : fallbackModel;
1300
- const one = costOf(turnCalls[i], callModel, turnCalls[i].at);
1301
- turnParts.input += turnCalls[i].input || 0;
1302
- turnParts.cacheRead += turnCalls[i].cacheRead || 0;
1303
- turnParts.cacheWrite += turnCalls[i].cacheWrite || 0;
1304
- turnParts.output += turnCalls[i].output || 0;
1305
- if (one === null) continue;
1306
- turnPriced = true;
1307
- turnTotal += Number(one);
1308
- }
1309
- if (turnPriced) {
1310
- return {
1311
- id: id,
1312
- text: t("f_lastCost", { symbol: symbol, cost: costText(turnTotal) }),
1313
- /* 本轮点开的是本轮 token 明细,读法与总计那个面板同一套 */
1314
- tip: tokensTip(billableParts(turnParts), t("f_tokensTurn"), t)
1315
- };
1316
- }
1328
+ /* 本轮的 token 汇总与定价一起算:即使价格库里没有这个模型(算不出钱),
1329
+ 也要留住 token 汇总 —— 本轮气泡照样能点开看明细。 */
1330
+ const turnParts = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
1331
+ let turnTotal = 0;
1332
+ let turnPriced = false;
1333
+ const turnList = turnCalls === null ? [] : turnCalls;
1334
+ for (let i = 0; i < turnList.length; i += 1) {
1335
+ const callModel = typeof turnList[i].model === "string" ? turnList[i].model : fallbackModel;
1336
+ const one = costOf(turnList[i], callModel, turnList[i].at);
1337
+ turnParts.input += turnList[i].input || 0;
1338
+ turnParts.cacheRead += turnList[i].cacheRead || 0;
1339
+ turnParts.cacheWrite += turnList[i].cacheWrite || 0;
1340
+ turnParts.output += turnList[i].output || 0;
1341
+ if (one === null) continue;
1342
+ turnPriced = true;
1343
+ turnTotal += Number(one);
1344
+ }
1345
+ const turnTip = tokensTip(billableParts(turnParts), t("f_tokensTurn"), t);
1346
+ if (turnPriced) {
1347
+ return {
1348
+ id: id,
1349
+ text: t("f_lastCost", { symbol: symbol, cost: costText(turnTotal) }),
1350
+ /* 本轮点开的是本轮 token 明细,读法与总计那个面板同一套 */
1351
+ tip: turnTip
1352
+ };
1317
1353
  }
1318
1354
  /* 本轮是不是正在跑(与状态段同一套判断) */
1319
1355
  const active = src.running === true
@@ -1330,37 +1366,77 @@ window.__ModuleLoader__.load({
1330
1366
  if (active === true && bucket !== null && sameTurn === true) {
1331
1367
  const runningCost = costOf(bucket, fallbackModel, src.now);
1332
1368
  if (runningCost !== null) {
1333
- return { id: id, text: t("f_lastCost", { symbol: symbol, cost: costText(runningCost) }), tip: null };
1369
+ return { id: id, text: t("f_lastCost", { symbol: symbol, cost: costText(runningCost) }), tip: turnTip };
1334
1370
  }
1335
1371
  }
1336
- /* 新一轮已经开跑、但还没有属于它的调用:本轮就是 0。
1337
- 这里必须拦住 —— 否则会退回上一轮(节点折叠还错位过一轮,显示成上上轮)的金额,
1338
- 发完消息底栏挂着一个旧数字,看着像没重置。 */
1372
+ /* 新一轮已经开跑、但还没有属于它的调用:必须归零,不能退回上一轮
1373
+ —— 节点折叠曾错位一轮,底栏显示成上上轮的金额,这条就是为堵它留的。
1374
+ 一次模型调用都还没完成过(第一轮)时没有"归零"可言,跟总计一样给横杠。
1375
+ 等这一轮真的产生了调用,上面那条精确口径就接管了。 */
1339
1376
  if (active === true) {
1340
- return { id: id, text: t("f_lastCost", { symbol: symbol, cost: costText(0) }), tip: null };
1377
+ const settled = src.sessionUsage !== undefined && src.sessionUsage !== null
1378
+ && Array.isArray(src.sessionUsage.calls) === true
1379
+ && src.sessionUsage.calls.length > 0;
1380
+ return {
1381
+ id: id,
1382
+ text: t("f_lastCost", { symbol: symbol, cost: settled ? costText(0) : "-" }),
1383
+ tip: turnTip
1384
+ };
1341
1385
  }
1342
- /* 空闲:显示最近完成的一轮(节点折叠 → 官方差分兜底),同样只出金额 */
1343
- if (bucket === null) return null;
1344
- const cost = costOf(bucket, fallbackModel, src.now);
1345
- if (cost === null) return null;
1346
- return { id: id, text: t("f_lastCost", { symbol: symbol, cost: costText(cost) }), tip: null };
1386
+ /* 空闲:显示最近完成的一轮(节点折叠 → 官方差分兜底),同样只出金额。
1387
+ 没有可用数据时也给 ¥0.00:本轮不整段消失,底栏长度稳定,
1388
+ 也不会退回上一轮(节点折叠曾错位一轮,显示成上上轮)的旧金额。 */
1389
+ const cost = bucket === null ? null : costOf(bucket, fallbackModel, src.now);
1390
+ return {
1391
+ id: id,
1392
+ text: t("f_lastCost", { symbol: symbol, cost: cost === null ? "-" : costText(cost) }),
1393
+ tip: turnTip
1394
+ };
1347
1395
  }
1348
1396
 
1349
1397
  if (id === "balance") {
1398
+ /* 查不到余额(非 DeepSeek 会话 / 还没查到 / 查询失败)时用同一套两行结构,值都是横杠:
1399
+ 气泡里仍然是「充值余额 / 赠金余额」两行,只是没有数字。 */
1400
+ const dashBalance = () => {
1401
+ const dashSymbol = currencySymbol(src.currency);
1402
+ return {
1403
+ id: id,
1404
+ text: t("f_balanceDash", { symbol: dashSymbol }),
1405
+ tip: {
1406
+ kind: "rows",
1407
+ title: t("segBalance"),
1408
+ rows: [
1409
+ { label: t("tipTopUp"), value: dashSymbol + "-" },
1410
+ { label: t("tipGranted"), value: dashSymbol + "-" }
1411
+ ]
1412
+ }
1413
+ };
1414
+ };
1415
+ const sessionModelNow = src.sessionModel;
1416
+ const modelText = sessionModelNow === undefined || sessionModelNow === null
1417
+ ? ""
1418
+ : String(sessionModelNow.provider === undefined ? "" : sessionModelNow.provider)
1419
+ + " " + String(sessionModelNow.model === undefined ? "" : sessionModelNow.model);
1420
+ if (modelText.toLowerCase().includes("deepseek") !== true) return dashBalance();
1350
1421
  const balance = src.balance;
1351
- if (balance === null || balance === undefined) return null;
1352
- if (balance.ok !== true) return null;
1353
- if (typeof balance.total !== "string") return null;
1422
+ if (balance === null || balance === undefined) return dashBalance();
1423
+ if (balance.ok !== true) return dashBalance();
1424
+ if (typeof balance.total !== "string") return dashBalance();
1354
1425
  /* 余额用接口自己报的币种:账户里是人民币就显示 ¥,不跟着上面的计价单位走 */
1355
- const balSymbol = currencySymbol(balance.currency !== undefined && balance.currency !== null ? balance.currency : src.currency);
1426
+ /* 百分比口径(GLM 配额)不带货币符号,否则会显示成 ¥37.50% */
1427
+ const balSymbol = balance.isPercent === true ? "" : currencySymbol(balance.currency !== undefined && balance.currency !== null ? balance.currency : src.currency);
1356
1428
  /* 点开看构成:total = 充值余额 + 赠金余额;没有赠金时接口不给这个字段,按 0.00 显示 */
1357
1429
  const tip = {
1358
1430
  kind: "rows",
1359
1431
  title: t("segBalance"),
1360
1432
  rows: [
1361
1433
  { label: t("tipTopUp"), value: balSymbol + balanceAmount(balance.toppedUp) },
1362
- { label: t("tipGranted"), value: balSymbol + balanceAmount(balance.granted) }
1363
- ]
1434
+ { label: t("tipGranted"), value: balSymbol + balanceAmount(balance.granted) },
1435
+ /* 累计消费只有部分平台能给(智谱有,DeepSeek 的接口没有),没有就不显示这一行 */
1436
+ balance.spent === undefined || balance.spent === null
1437
+ ? null
1438
+ : { label: t("tipSpent"), value: balSymbol + balanceAmount(balance.spent) }
1439
+ ].filter((one) => one !== null)
1364
1440
  };
1365
1441
  /* 不足一分钱时官方显示 <¥0.01,这里跟着来 */
1366
1442
  if (balance.subCent === true) return { id: id, text: t("f_balanceSubCent", { symbol: balSymbol }), tip: tip };
@@ -1390,8 +1466,8 @@ window.__ModuleLoader__.load({
1390
1466
  /* 速度占位不带单位:英文那边标签已经是 TPS,再跟 t/s 就重复了 */
1391
1467
  if (id === "tps") return label + " " + dash;
1392
1468
  if (id === "sessionTime") return label + " " + dash + "m " + dash + "s";
1393
- if (id === "cost" || id === "lastCost") return label + " " + dash + " " + currencySymbol(config.currency);
1394
- if (id === "balance") return t("f_balanceDash");
1469
+ if (id === "cost" || id === "lastCost") return label + " " + currencySymbol(config.currency) + dash;
1470
+ if (id === "balance") return t("f_balanceDash", { symbol: currencySymbol(config.currency) });
1395
1471
  return dash;
1396
1472
  }
1397
1473
  /* ---------------------------------------------------------------- css */
@@ -1467,21 +1543,29 @@ window.__ModuleLoader__.load({
1467
1543
  ".dsb-settings .dsb-section{display:flex;flex-direction:column;gap:8px}",
1468
1544
  ".dsb-settings .dsb-sectitle{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;letter-spacing:.02em}",
1469
1545
  ".dsb-settings .dsb-hint{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6}",
1470
- ".dsb-settings .dsb-row{display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:8px;padding:6px 8px;background:var(--dsw-alias-bg-layer-1,transparent)}",
1546
+ ".dsb-settings .dsb-row{display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:8px;padding:6px 8px;background:var(--dsw-alias-bg-layer-1,transparent);user-select:none;-webkit-user-select:none}",
1471
1547
  ".dsb-settings .dsb-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.08))}",
1548
+ /* 拖动中不给悬停反馈:浮层不吃指针事件,鼠标其实悬在下面那行上,会莫名发灰 */
1549
+ ".dsb-settings:has(.dsb-dragging) .dsb-row:hover{background:var(--dsw-alias-bg-layer-1,transparent)}",
1550
+ /* 拖动中光标一律抓手:光标由鼠标底下那个元素决定,而浮层不吃指针事件,
1551
+ 底下既可能是握把(grab)、文字(pointer)、也可能是行间空隙(默认箭头)。
1552
+ !important 是因为这些元素各自都写了 cursor,靠继承压不住握把那条。 */
1553
+ ".dsb-settings:has(.dsb-dragging) *{cursor:grabbing !important}",
1472
1554
  ".dsb-settings .dsb-check{display:flex;align-items:flex-start;gap:8px;cursor:pointer;flex:1;min-width:0}",
1473
1555
  ".dsb-settings .dsb-check input{flex:none;margin:2px 0 0}",
1474
1556
  ".dsb-settings input[type=checkbox]{accent-color:var(--dsw-alias-label-primary,#202020)}",
1475
1557
  ".dsb-settings .dsb-labels{display:flex;flex-direction:column;gap:2px;min-width:0}",
1476
1558
  ".dsb-settings .dsb-name{color:var(--dsw-alias-label-primary)}",
1477
1559
  ".dsb-settings .dsb-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}",
1478
- ".dsb-settings .dsb-row.dsb-dragging{opacity:.35}",
1479
- ".dsb-settings .dsb-row.dsb-over-before{box-shadow:inset 0 2px 0 var(--dsw-alias-brand-primary,#4d6bfe)}",
1480
- ".dsb-settings .dsb-row.dsb-over-after{box-shadow:inset 0 -2px 0 var(--dsw-alias-brand-primary,#4d6bfe)}",
1560
+ /* 被拿起来的那一行:原位留个淡影,跟手的是下面那张卡 */
1561
+ ".dsb-settings .dsb-row.dsb-dragging{opacity:.32}",
1562
+ /* 跟手的浮层:气泡同款材质(只有 .5px 描边,没有柔光),不吃指针事件 */
1563
+ ".dsb-settings .dsb-drag-card{position:fixed;z-index:40;pointer-events:none;box-sizing:border-box;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 8px;border-radius:var(--dsw-radius-md,12px);background:rgb(from var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#1f1f1f)) r g b / .382);backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));box-shadow:0 0 0 .5px var(--dsw-alias-border-l1,rgba(127,127,127,.3))}",
1481
1564
  ".dsb-settings .dsb-handle{position:relative;flex:none;width:16px;height:16px;color:var(--dsw-alias-label-tertiary);cursor:grab}",
1482
1565
  '.dsb-settings .dsb-handle::before{content:"";position:absolute;left:4px;top:1.5px;width:3px;height:3px;border-radius:50%;background:currentColor;box-shadow:5px 0 0 currentColor,0 5px 0 currentColor,5px 5px 0 currentColor,0 10px 0 currentColor,5px 10px 0 currentColor}',
1483
1566
  ".dsb-settings .dsb-handle:active{cursor:grabbing}",
1484
- ".dsb-settings .dsb-handle-off{opacity:.25;cursor:default}",
1567
+ /* 未勾选的段也能调位置,所以光标跟勾选时一样是抓手,只是点阵淡一些 */
1568
+ ".dsb-settings .dsb-handle-off{opacity:.25}",
1485
1569
  /* 价格库 */
1486
1570
  ".dsb-settings .dsb-card{border:1px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.25));border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:10px}",
1487
1571
  ".dsb-settings .dsb-current{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.6}",
@@ -1699,19 +1783,34 @@ window.__ModuleLoader__.load({
1699
1783
  }, []);
1700
1784
 
1701
1785
  const wantsBalance = isSegmentOn(cfg, "balance");
1786
+ /* 当前会话用的 provider / model:host 按它决定查哪个平台的余额。
1787
+ 这两个值变了要重新查,所以下面 effect 的依赖里带着它们。 */
1788
+ const balanceProvider = sessionModel !== undefined && sessionModel !== null ? sessionModel.provider : null;
1789
+ const balanceModel = sessionModel !== undefined && sessionModel !== null ? sessionModel.model : null;
1790
+ const balanceQuery = "?provider=" + encodeURIComponent(String(balanceProvider === undefined || balanceProvider === null ? "" : balanceProvider))
1791
+ + "&model=" + encodeURIComponent(String(balanceModel === undefined || balanceModel === null ? "" : balanceModel));
1792
+ /* 点开余额气泡时先强制重查一次:host 端余额不缓存,带上 force 就不用等
1793
+ 下一次轮询(最多 60 秒),气泡里直接是此刻的数字。轮询本身不带 force。 */
1794
+ const balanceReloadRef = react.useRef(null);
1702
1795
  react.useEffect(() => {
1703
1796
  if (!wantsBalance) return undefined;
1704
1797
  let alive = true;
1705
- const load = () => {
1706
- window.fetch(BALANCE_URL, { cache: "no-store" })
1798
+ const load = (force) => {
1799
+ const url = BALANCE_URL + balanceQuery + (force === true ? "&force=1" : "");
1800
+ window.fetch(url, { cache: "no-store" })
1707
1801
  .then((response) => response.json())
1708
1802
  .then((data) => { if (alive) setBalance(data); })
1709
1803
  .catch(() => { if (alive) setBalance({ ok: false, reason: "fetch-failed" }); });
1710
1804
  };
1711
- load();
1712
- const timer = window.setInterval(load, BALANCE_POLL_MS);
1713
- return () => { alive = false; window.clearInterval(timer); };
1714
- }, [wantsBalance]);
1805
+ balanceReloadRef.current = () => load(true);
1806
+ load(false);
1807
+ const timer = window.setInterval(() => load(false), BALANCE_POLL_MS);
1808
+ return () => {
1809
+ alive = false;
1810
+ balanceReloadRef.current = null;
1811
+ window.clearInterval(timer);
1812
+ };
1813
+ }, [wantsBalance, balanceProvider, balanceModel]);
1715
1814
 
1716
1815
  /* 只在"点别处"时收起。这里刻意不监听 scroll:底栏固定在视口底部,对话区滚动
1717
1816
  不会移动触发项,但每来一次工具调用/新一轮,对话区都会自动滚到底 ——
@@ -1851,7 +1950,9 @@ window.__ModuleLoader__.load({
1851
1950
  const at = tipKeys.indexOf(key);
1852
1951
  if (at < 0) return "center";
1853
1952
  if (at === 0) return "left";
1854
- return at === tipKeys.length - 1 ? "right" : "center";
1953
+ /* 只有余额贴右边(它固定在栏尾)。其余一律居中、装不下时再夹 ——
1954
+ 否则某一段没有气泡时会被当成"最右项"改成右对齐,看起来就不居中了。 */
1955
+ return key === "balance" ? "right" : "center";
1855
1956
  };
1856
1957
  /* 打开时保存触发项的位置并收起上一次的气泡;真正的坐标等面板挂上、量到宽度后再算 */
1857
1958
  const toggleTip = (id, event, mode) => {
@@ -1860,6 +1961,8 @@ window.__ModuleLoader__.load({
1860
1961
  setTipPos(null);
1861
1962
  return;
1862
1963
  }
1964
+ /* 点开余额:顺手重查一次(收起那一下不查),数据回来气泡自己更新 */
1965
+ if (id === "balance" && balanceReloadRef.current !== null) balanceReloadRef.current();
1863
1966
  const target = event !== undefined && event !== null ? event.currentTarget : null;
1864
1967
  /* 存节点而不是当次的矩形:窗口缩放后要重新量 */
1865
1968
  anchorRef.current = target !== null && typeof target.getBoundingClientRect === "function" ? target : null;
@@ -1918,9 +2021,9 @@ window.__ModuleLoader__.load({
1918
2021
  const t = props.t;
1919
2022
  const cfg = useConfig();
1920
2023
 
1921
- const [dragId, setDragId] = react.useState(null);
1922
- const [overId, setOverId] = react.useState(null);
1923
- const [overAfter, setOverAfter] = react.useState(false); /* 落点画在目标行的下半区 */
2024
+ /* 自绘拖拽:全程用 pointer 事件自己跟手。不用原生 HTML5 拖拽 ——
2025
+ 原生拖拽会话期间浏览器不给页面 wheel 事件(滚轮失灵),而且只有松手才换位。 */
2026
+ const [drag, setDrag] = react.useState(null); /* { id, from, to, dy, startY, height, rects } */
1924
2027
  const [newModel, setNewModel] = react.useState("");
1925
2028
  const [hostModel, setHostModel] = react.useState(null);
1926
2029
  const [editing, setEditing] = react.useState(null);
@@ -2097,7 +2200,168 @@ window.__ModuleLoader__.load({
2097
2200
  .map((id) => byId[id])
2098
2201
  .filter((segment) => segment !== undefined && segment !== null);
2099
2202
 
2100
- const contextRow = h("div", { className: "dsb-row", key: "__context" },
2203
+ /* 行节点 + 拖前位置:前者用来量落点,后者给 FLIP 补位移动画 */
2204
+ const rowNodes = react.useRef(new Map());
2205
+ const rowTops = react.useRef(null);
2206
+ /* 拖动是否还活着:pointerup 会被行和 window 各收到一次,靠它兜住重复落盘 */
2207
+ const draggingRef = react.useRef(false);
2208
+ const captureTops = () => {
2209
+ const tops = new Map();
2210
+ rowNodes.current.forEach((node, id) => {
2211
+ if (node !== null && node !== undefined && typeof node.getBoundingClientRect === "function") {
2212
+ tops.set(id, node.getBoundingClientRect().top);
2213
+ }
2214
+ });
2215
+ rowTops.current = tops;
2216
+ };
2217
+
2218
+ const measure = (segmentId) => {
2219
+ const node = rowNodes.current.get(segmentId);
2220
+ return node !== null && node !== undefined && typeof node.getBoundingClientRect === "function"
2221
+ ? node.getBoundingClientRect()
2222
+ : null;
2223
+ };
2224
+
2225
+ const startDrag = (id, index, event) => {
2226
+ const at = measure(id);
2227
+ const anchor = measure("__context");
2228
+ draggingRef.current = true;
2229
+ setDrag({
2230
+ id: id, from: index, to: index, at: at,
2231
+ /* 指针在行内的抓取位置:浮层位置和落点都由它推出来,不依赖按下时的绝对坐标 */
2232
+ grab: at === null || at === undefined ? 0 : event.clientY - at.top,
2233
+ cardTop: at === null || at === undefined ? event.clientY : at.top,
2234
+ pointerY: event.clientY,
2235
+ /* 各槽位按下那一刻的真实矩形;滚动多少由锚点差值补回来 */
2236
+ slots: orderedSegments.map((one) => measure(one.id)),
2237
+ anchorTop: anchor === null || anchor === undefined ? 0 : anchor.top,
2238
+ /* base 是按下那一刻的顺序:每次换位都从它重算,不会累积误差 */
2239
+ base: cfg.segments.slice(),
2240
+ order: cfg.segments.slice()
2241
+ });
2242
+ };
2243
+
2244
+ /* 按给定指针位置重算落点。单独抽出来是因为滚轮滚动不产生 pointermove,
2245
+ 滚动时得靠 scroll 事件带着"上一次的指针位置"再算一遍,影子才不会落在后面。 */
2246
+ const applyDrop = (current, pointerY) => {
2247
+ const cardTop = pointerY - current.grab;
2248
+ const height = current.at === null || current.at === undefined ? 0 : current.at.height;
2249
+ /* 锚点每次实时量:中途滚了滚轮,按下时的坐标早就失效了 */
2250
+ const anchor = measure("__context");
2251
+ const shift = anchor === null || anchor === undefined ? 0 : anchor.top - current.anchorTop;
2252
+ const to = dropIndexAt(current.slots, shift, current.from, cardTop, cardTop + height);
2253
+ /* 落点和指针都没动就别重渲染:滚动事件很密,大多数帧其实什么都没变 */
2254
+ if (to === current.to && cardTop === current.cardTop) return;
2255
+ if (to !== current.to) captureTops(); /* 顺序要变了,先记下当前位置 */
2256
+ setDrag({
2257
+ id: current.id, from: current.from, to: to, cardTop: cardTop, pointerY: pointerY,
2258
+ grab: current.grab, at: current.at, slots: current.slots, anchorTop: current.anchorTop,
2259
+ base: current.base,
2260
+ order: moveItem(current.base, current.from, to)
2261
+ });
2262
+ };
2263
+
2264
+ const moveDrag = (event) => {
2265
+ if (drag === null) return;
2266
+ /* 指针已经松开(截屏工具之类把 pointerup 吃掉了):按取消处理,回原位 */
2267
+ if (event.buttons === 0) {
2268
+ cancelDrag();
2269
+ return;
2270
+ }
2271
+ applyDrop(drag, event.clientY);
2272
+ };
2273
+
2274
+ /* 松手才落盘:拖动全程只在内存里重排,config 一动不动 */
2275
+ const endDrag = () => {
2276
+ if (draggingRef.current !== true || drag === null) return;
2277
+ draggingRef.current = false;
2278
+ if (drag.to !== drag.from) setConfig({ segments: drag.order });
2279
+ setDrag(null);
2280
+ };
2281
+
2282
+ /* 拖动被系统打断(截屏、切窗口、指针事件丢失):回原位,什么账都不记 */
2283
+ const cancelDrag = () => {
2284
+ if (draggingRef.current !== true) return;
2285
+ draggingRef.current = false;
2286
+ setDrag(null);
2287
+ };
2288
+
2289
+ /* 拖动中的重排没有过渡可言:DOM 顺序一变,行就是瞬移。
2290
+ 这里把移动补回来 —— 变之前记下位置,变之后先反推回原处,再过渡回去。 */
2291
+ useTipLayout(() => {
2292
+ const before = rowTops.current;
2293
+ if (before === null) return;
2294
+ rowTops.current = null;
2295
+ rowNodes.current.forEach((node, id) => {
2296
+ if (node === null || node === undefined || node.style === undefined) return;
2297
+ const was = before.get(id);
2298
+ if (was === undefined) return;
2299
+ const delta = was - node.getBoundingClientRect().top;
2300
+ if (delta === 0) return;
2301
+ node.style.transition = "none";
2302
+ node.style.transform = "translateY(" + delta + "px)";
2303
+ void node.offsetHeight; /* 强制回流,否则紧接着的过渡不会触发 */
2304
+ node.style.transition = "transform .18s ease";
2305
+ node.style.transform = "";
2306
+ });
2307
+ }, [drag === null ? "" : drag.order.join(",")]);
2308
+
2309
+ /* 指针事件再挂一份到 window 兜底:被拖的那一行在拖动中会重排,指针捕获一旦丢了,
2310
+ pointerup 就永远收不到,浮层会一直粘着鼠标。挂在 window 上不受重排影响。
2311
+ 顺带在拖动期间禁掉文本选中 —— 长按拖动会把整页文字刷成蓝底。 */
2312
+ const dragMoveRef = react.useRef(null);
2313
+ const dragEndRef = react.useRef(null);
2314
+ const dragCancelRef = react.useRef(null);
2315
+ const dragRef = react.useRef(null);
2316
+ const dragDropRef = react.useRef(null);
2317
+ dragMoveRef.current = moveDrag;
2318
+ dragEndRef.current = endDrag;
2319
+ dragCancelRef.current = cancelDrag;
2320
+ dragRef.current = drag;
2321
+ dragDropRef.current = applyDrop;
2322
+ react.useEffect(() => {
2323
+ if (drag === null) return undefined;
2324
+ const onMove = (event) => { if (dragMoveRef.current !== null) dragMoveRef.current(event); };
2325
+ const onUp = () => { if (dragEndRef.current !== null) dragEndRef.current(); };
2326
+ /* 打断 = 取消(回原位),绝不能当成落点:截屏工具常把 pointerup 整个吃掉 */
2327
+ const onCancel = () => { if (dragCancelRef.current !== null) dragCancelRef.current(); };
2328
+ window.addEventListener("pointermove", onMove);
2329
+ window.addEventListener("pointerup", onUp);
2330
+ window.addEventListener("pointercancel", onCancel);
2331
+ window.addEventListener("blur", onCancel);
2332
+ /* 滚轮滚动不产生 pointermove:不补这一手,影子会落在后面,要再动一下鼠标才跟上 */
2333
+ const onScroll = () => {
2334
+ const current = dragRef.current;
2335
+ if (current !== null && dragDropRef.current !== null) dragDropRef.current(current, current.pointerY);
2336
+ };
2337
+ window.addEventListener("scroll", onScroll, true);
2338
+ const body = document === undefined || document === null ? undefined : document.body;
2339
+ const style = body === undefined || body === null ? undefined : body.style;
2340
+ /* 禁选:长按拖动会把整页文字刷成蓝底;抓手:光标统一,不再忽抓忽指 */
2341
+ const hadSelect = style === undefined ? null : style.userSelect;
2342
+ const hadCursor = style === undefined ? null : style.cursor;
2343
+ if (hadSelect !== null) style.userSelect = "none";
2344
+ if (hadCursor !== null) style.cursor = "grabbing";
2345
+ return () => {
2346
+ window.removeEventListener("pointermove", onMove);
2347
+ window.removeEventListener("pointerup", onUp);
2348
+ window.removeEventListener("pointercancel", onCancel);
2349
+ window.removeEventListener("blur", onCancel);
2350
+ window.removeEventListener("scroll", onScroll, true);
2351
+ if (hadSelect !== null) style.userSelect = hadSelect;
2352
+ if (hadCursor !== null) style.cursor = hadCursor;
2353
+ };
2354
+ }, [drag === null]);
2355
+
2356
+ const contextRow = h("div", {
2357
+ className: "dsb-row",
2358
+ key: "__context",
2359
+ /* 落点靠它算槽位几何:它在栏首、不参与排序、也没有过渡动画,位置最稳 */
2360
+ ref: (node) => {
2361
+ if (node === null || node === undefined) rowNodes.current.delete("__context");
2362
+ else rowNodes.current.set("__context", node);
2363
+ }
2364
+ },
2101
2365
  h("label", { className: "dsb-check" },
2102
2366
  h("input", {
2103
2367
  type: "checkbox",
@@ -2111,75 +2375,74 @@ window.__ModuleLoader__.load({
2111
2375
  )
2112
2376
  );
2113
2377
 
2114
- const segmentRows = orderedSegments.map((segment) => {
2378
+ /* 行内容抽出来:列表里的行和跟手的浮层共用同一份 */
2379
+ const rowParts = (segment, on) => [
2380
+ h("label", { className: "dsb-check", key: "__check" },
2381
+ h("input", {
2382
+ type: "checkbox",
2383
+ checked: on,
2384
+ onChange: (event) => toggleSegment(segment.id, event.target.checked)
2385
+ }),
2386
+ h("span", { className: "dsb-labels" },
2387
+ h("span", { className: "dsb-name" }, t(segment.label)),
2388
+ h("span", { className: "dsb-desc" }, t(segment.hint))
2389
+ )
2390
+ ),
2391
+ h("span", {
2392
+ className: on === true ? "dsb-handle" : "dsb-handle dsb-handle-off",
2393
+ title: on === true ? t("dragHint") : "",
2394
+ key: "__handle"
2395
+ })
2396
+ ];
2397
+
2398
+ /* 拖动中就地按落点排好:列表里被拖那一格留淡影,跟手的是上面的浮层 */
2399
+ const visibleOrder = drag === null ? cfg.segments : drag.order;
2400
+ const visibleSegments = visibleOrder
2401
+ .map((id) => byId[id])
2402
+ .filter((segment) => segment !== undefined && segment !== null);
2403
+
2404
+ const segmentRows = visibleSegments.map((segment, index) => {
2115
2405
  const on = isSegmentOn(cfg, segment.id);
2116
- const classes = ["dsb-row"];
2117
- if (dragId === segment.id) classes.push("dsb-dragging");
2118
- if (overId === segment.id && dragId !== segment.id) {
2119
- classes.push(overAfter === true ? "dsb-over-after" : "dsb-over-before");
2120
- }
2406
+ const dragged = drag !== null && drag.id === segment.id;
2121
2407
  return h("div", {
2122
- className: classes.join(" "),
2408
+ className: dragged === true ? "dsb-row dsb-dragging" : "dsb-row",
2123
2409
  key: segment.id,
2124
- draggable: true, /* 未勾选的段也能调位置 */
2125
- onDragStart: (event) => {
2126
- setDragId(segment.id);
2127
- try {
2128
- event.dataTransfer.effectAllowed = "move";
2129
- event.dataTransfer.setData("text/plain", segment.id);
2130
- } catch (error) { /* 某些环境 dataTransfer 受限 */ }
2410
+ ref: (node) => {
2411
+ if (node === null || node === undefined) rowNodes.current.delete(segment.id);
2412
+ else rowNodes.current.set(segment.id, node);
2131
2413
  },
2132
- onDragOver: (event) => {
2133
- if (dragId === null || dragId === segment.id) return;
2134
- event.preventDefault();
2135
- try { event.dataTransfer.dropEffect = "move"; } catch (error) { /* ignore */ }
2136
- /* 以目标行中线分前后;中线附近 4px 内保持原状态,避免来回闪 */
2137
- const rect = event.currentTarget.getBoundingClientRect();
2138
- const middle = rect.top + rect.height / 2;
2139
- if (Math.abs(event.clientY - middle) < 4) return;
2140
- const after = event.clientY > middle;
2141
- if (overId !== segment.id || overAfter !== after) {
2142
- setOverId(segment.id);
2143
- setOverAfter(after);
2144
- }
2145
- },
2146
- onDragLeave: () => {
2147
- if (overId === segment.id) {
2148
- setOverId(null);
2149
- setOverAfter(false);
2150
- }
2414
+ onPointerDown: (event) => {
2415
+ if (event.button !== 0) return;
2416
+ /* 只有右侧那六个点能抓:整行可抓会跟勾选、选中文字打架 */
2417
+ const target = event.target;
2418
+ if (target === null || target === undefined) return;
2419
+ if (String(target.className === undefined ? "" : target.className).indexOf("dsb-handle") === -1) return;
2420
+ startDrag(segment.id, index, event);
2151
2421
  },
2152
- onDrop: (event) => {
2153
- event.preventDefault();
2154
- reorderSegments(dragId, segment.id, overId === segment.id && overAfter === true);
2155
- setDragId(null);
2156
- setOverId(null);
2157
- setOverAfter(false);
2158
- },
2159
- onDragEnd: () => { setDragId(null); setOverId(null); setOverAfter(false); }
2160
- },
2161
- h("label", { className: "dsb-check" },
2162
- h("input", {
2163
- type: "checkbox",
2164
- checked: on,
2165
- onChange: (event) => toggleSegment(segment.id, event.target.checked)
2166
- }),
2167
- h("span", { className: "dsb-labels" },
2168
- h("span", { className: "dsb-name" }, t(segment.label)),
2169
- h("span", { className: "dsb-desc" }, t(segment.hint))
2170
- )
2171
- ),
2172
- h("span", {
2173
- className: on === true ? "dsb-handle" : "dsb-handle dsb-handle-off",
2174
- title: on === true ? t("dragHint") : ""
2175
- })
2176
- );
2422
+ onPointerMove: dragged === true ? moveDrag : undefined,
2423
+ onPointerUp: dragged === true ? endDrag : undefined,
2424
+ onPointerCancel: dragged === true ? cancelDrag : undefined
2425
+ }, rowParts(segment, on));
2177
2426
  });
2427
+
2428
+ /* 跟着鼠标的那张卡:位置锁在按下那一行的矩形上,纵向随 dy 走 */
2429
+ const dragCard = drag === null || drag.at === null || drag.at === undefined ? null : h("div", {
2430
+ className: "dsb-drag-card",
2431
+ key: "__dragcard",
2432
+ style: {
2433
+ left: drag.at.left + "px",
2434
+ top: drag.cardTop + "px",
2435
+ width: drag.at.width + "px",
2436
+ height: drag.at.height + "px"
2437
+ }
2438
+ }, rowParts(byId[drag.id], isSegmentOn(cfg, drag.id)));
2439
+
2178
2440
  sections.push(h("div", { className: "dsb-section", key: "__segments" },
2179
2441
  h("div", { className: "dsb-sectitle" }, t("secSegments")),
2180
2442
  h("p", { className: "dsb-hint" }, t("secSegmentsHint")),
2181
2443
  contextRow,
2182
- segmentRows
2444
+ segmentRows,
2445
+ dragCard
2183
2446
  ));
2184
2447
 
2185
2448
  /* 自定义模型价格 */
package/lib/index.js CHANGED
@@ -22,6 +22,7 @@
22
22
  * @module dsh-desktop-statusbar
23
23
  */
24
24
  import { z } from 'zod'
25
+ import { adapterFor } from './balance-api.js'
25
26
  import { holidayHtmlToText, parseHolidayNotice } from './holiday.js'
26
27
  import { readFileSync } from 'node:fs'
27
28
  import { join } from 'node:path'
@@ -640,6 +641,82 @@ function keyFromCredentialStore(ctx) {
640
641
  return null
641
642
  }
642
643
 
644
+ /**
645
+ * 按适配器的凭据引用名逐个解析 key。用 credentials 服务的 resolve()(子代理核实:
646
+ * describe() 只回答"配了没",resolve() 才给出真实值 { value, source })。
647
+ * ref 在运行时就是普通字符串 —— credentialRef() 只做编译期品牌标记,所以不必 import 它。
648
+ */
649
+ async function resolveAdapterKey(ctx, adapter) {
650
+ const credentials = ctx !== null && ctx !== undefined && typeof ctx.get === 'function' ? ctx.get('credentials') : null
651
+ if (credentials !== null && credentials !== undefined && typeof credentials.resolve === 'function') {
652
+ for (let i = 0; i < adapter.refs.length; i += 1) {
653
+ try {
654
+ const hit = await credentials.resolve(adapter.refs[i])
655
+ if (hit !== undefined && hit !== null && typeof hit.value === 'string' && hit.value.length > 0) {
656
+ return { key: hit.value, source: adapter.refs[i] }
657
+ }
658
+ } catch (error) {
659
+ /* 换下一个引用名 */
660
+ }
661
+ }
662
+ }
663
+ /* 没挂凭据服务时的旧路径:环境变量 / .credentials.yaml */
664
+ const fallback = findApiKey(ctx)
665
+ return { key: fallback.key, source: fallback.source }
666
+ }
667
+
668
+ /** 用适配器查第三方平台的余额,返回与 DeepSeek 那条路一致的形状。 */
669
+ /** 从适配器返回的 parts 里按 label 取金额;没有这个 label 就返回 undefined(气泡少一行)。 */
670
+ function partAmount(parts, label) {
671
+ const list = Array.isArray(parts) ? parts : []
672
+ for (let i = 0; i < list.length; i += 1) {
673
+ if (list[i] !== null && list[i] !== undefined && list[i].label === label) return list[i].amount
674
+ }
675
+ return undefined
676
+ }
677
+
678
+ async function queryWithAdapter(ctx, adapter, now) {
679
+ const found = await resolveAdapterKey(ctx, adapter)
680
+ if (found.key === null) {
681
+ logHost(ctx, 'balance(' + adapter.id + '): 没取到 key,试过 ' + adapter.refs.join(' / '))
682
+ return { ok: false, at: now, reason: 'no-credential', provider: adapter.id }
683
+ }
684
+ const spec = adapter.request(found.key)
685
+ try {
686
+ const response = await fetch(spec.url, {
687
+ method: 'GET',
688
+ headers: spec.headers,
689
+ signal: AbortSignal.timeout(10000),
690
+ })
691
+ if (!response.ok) {
692
+ logHost(ctx, 'balance(' + adapter.id + '): HTTP ' + String(response.status))
693
+ return { ok: false, at: now, reason: 'http-' + String(response.status), source: found.source, provider: adapter.id }
694
+ }
695
+ const parsed = adapter.parse(await response.json())
696
+ if (parsed === null) {
697
+ logHost(ctx, 'balance(' + adapter.id + '): 响应里解析不出余额')
698
+ return { ok: false, at: now, reason: 'no-balance-info', source: found.source, provider: adapter.id }
699
+ }
700
+ return {
701
+ ok: true,
702
+ at: now,
703
+ source: found.source,
704
+ provider: adapter.id,
705
+ currency: parsed.currency,
706
+ total: parsed.total,
707
+ isPercent: parsed.isPercent === true,
708
+ parts: parsed.parts,
709
+ /* 余额气泡那几行读的是顶层字段(与 DeepSeek 那条路保持同一形状),这里按 label 摊平 */
710
+ toppedUp: partAmount(parsed.parts, 'toppedUp'),
711
+ granted: partAmount(parsed.parts, 'granted'),
712
+ spent: partAmount(parsed.parts, 'spent'),
713
+ }
714
+ } catch (error) {
715
+ logHost(ctx, 'balance(' + adapter.id + '): ' + String(error === null || error === undefined ? 'unknown' : error.message))
716
+ return { ok: false, at: now, reason: 'network', source: found.source, provider: adapter.id }
717
+ }
718
+ }
719
+
643
720
  function findApiKey(ctx) {
644
721
  const fromEnv = keyFromEnv()
645
722
  if (fromEnv !== null) return { key: fromEnv, source: 'env' }
@@ -730,12 +807,21 @@ async function queryAccountBalance(ctx) {
730
807
  }
731
808
  }
732
809
 
733
- async function queryBalance(ctx, force) {
810
+ async function queryBalance(ctx, force, provider, model) {
734
811
  const now = Date.now()
735
812
  if (force !== true && balanceCache.value !== null && now - balanceCache.at < BALANCE_TTL_MS) {
736
813
  return balanceCache.value
737
814
  }
738
815
 
816
+ /* 当前会话用的是第三方平台就交给适配器:每个平台有自己的端点、鉴权与字段。
817
+ DeepSeek(含识别不出来的情况)继续走下面的账号服务 + API key 老路。 */
818
+ const adapter = adapterFor(provider, model)
819
+ if (adapter !== null && adapter.id !== 'deepseek') {
820
+ const third = await queryWithAdapter(ctx, adapter, now)
821
+ balanceCache = { at: now, value: third, provider: adapter.id }
822
+ return third
823
+ }
824
+
739
825
  /* 先问官方账号服务:与设置页同一份数据(充值 + 赠金),拿不到才走下面的 API key 查询 */
740
826
  const fromAccount = await queryAccountBalance(ctx)
741
827
  if (fromAccount !== null) {
@@ -936,7 +1022,7 @@ export function apply(ctx) {
936
1022
  return
937
1023
  }
938
1024
  const force = url.searchParams.get('force') === '1'
939
- const value = await queryBalance(ctx, force)
1025
+ const value = await queryBalance(ctx, force, url.searchParams.get('provider'), url.searchParams.get('model'))
940
1026
  sendJson(res, 200, Object.assign({}, value, { probe: accountProbeNote }))
941
1027
  },
942
1028
  }), 'dsh-desktop-statusbar: balance api')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-desktop-statusbar",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "type": "module",
5
5
  "description": "把 DSH 桌面端对话区底部的统计行换成可配置状态栏:9 个字段可选可排序,按官方峰谷口径估算费用,支持自定义模型单价与账户余额。",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",