dsh-desktop-statusbar 2.0.0 → 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/lib/client.js CHANGED
@@ -40,6 +40,8 @@ window.__ModuleLoader__.load({
40
40
  * v6 起:`segments` 存全部段的有序列表(顺序对整份列表生效),`hidden` 存未勾选的段。
41
41
  */
42
42
  const CONFIG_VERSION = 6;
43
+ /** 插件版本:显示在设置页底部,用来确认页面加载的是哪一版客户端代码。 */
44
+ const VERSION = "2.0.2";
43
45
  /** 官方参考价的版本:官方调价时改这个数字,老价格库会自动并入新参考价。 */
44
46
  const PRICE_VERSION = 2;
45
47
 
@@ -346,7 +348,10 @@ window.__ModuleLoader__.load({
346
348
  * 累加用 costOf 的原始值,只在这里做一次舍入。
347
349
  */
348
350
  function costText(amount) {
349
- 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);
350
355
  }
351
356
 
352
357
  /** 逐字段相减,得到两次全量用量之间的增量 —— 即本轮消耗。 */
@@ -447,12 +452,16 @@ window.__ModuleLoader__.load({
447
452
  f_tokensTurn: "本轮用量",
448
453
  tipModelTime: "模型用时",
449
454
  tipToolTime: "工具调用",
455
+ tipTtftTurn: "本轮平均首字",
450
456
  tipTtftFast: "最快首字延迟",
451
457
  tipTtftSlow: "最慢首字延迟",
458
+ tipTpsTurn: "本轮平均速度",
452
459
  tipTpsFast: "最快输出速度",
453
460
  tipTpsSlow: "最慢输出速度",
454
461
  tipTopUp: "充值余额",
455
462
  tipGranted: "赠金余额",
463
+ tipSpent: "累计消费",
464
+ tipHitTurn: "本轮缓存命中率",
456
465
  tipHitHigh: "最高缓存命中率",
457
466
  tipHitLow: "最低缓存命中率",
458
467
  contextAria: "上下文已用 {percent}",
@@ -464,7 +473,7 @@ window.__ModuleLoader__.load({
464
473
  f_cost: "总计 {symbol}{cost}",
465
474
  priceTierPeak: "高峰时段",
466
475
  priceTierOffPeak: "空闲时段",
467
- f_balanceDash: "余额 -",
476
+ f_balanceDash: "余额 {symbol}-",
468
477
  f_lastCost: "本轮 {symbol}{cost}",
469
478
  f_balance: "余额 {symbol}{amount}",
470
479
  f_balanceSubCent: "余额 <{symbol}0.01",
@@ -533,12 +542,16 @@ window.__ModuleLoader__.load({
533
542
  f_tokensTurn: "This turn usage",
534
543
  tipModelTime: "Model time",
535
544
  tipToolTime: "Tool calls",
545
+ tipTtftTurn: "Average TTFT this turn",
536
546
  tipTtftFast: "Fastest TTFT",
537
547
  tipTtftSlow: "Slowest TTFT",
548
+ tipTpsTurn: "Average TPS this turn",
538
549
  tipTpsFast: "Fastest TPS",
539
550
  tipTpsSlow: "Slowest TPS",
540
551
  tipTopUp: "Top-up balance",
541
552
  tipGranted: "Granted balance",
553
+ tipSpent: "Total spent",
554
+ tipHitTurn: "Cache hit rate this turn",
542
555
  tipHitHigh: "Highest cache hit rate",
543
556
  tipHitLow: "Lowest cache hit rate",
544
557
  contextAria: "{percent} of context used",
@@ -573,7 +586,7 @@ window.__ModuleLoader__.load({
573
586
  unitCountSteps: " steps",
574
587
  unitSpeed: "t/s",
575
588
  priceSuggestedHint: "The price book uses the DeepSeek reference prices.",
576
- f_balanceDash: "Balance -",
589
+ f_balanceDash: "Balance {symbol}-",
577
590
  priceNewModel: "Custom model",
578
591
  priceAdd: "Add",
579
592
  priceEmpty: "Price book is empty — type a model name (e.g. deepseek-flash) and click Add.",
@@ -762,18 +775,34 @@ window.__ModuleLoader__.load({
762
775
  return !(Array.isArray(cfg.hidden) && cfg.hidden.indexOf(id) !== -1);
763
776
  }
764
777
  /**
765
- * 把 fromId 插到 toId 的前面(after=false)或后面(after=true)。
766
- * 先摘出被拖项再按目标定位,避免向下拖时索引差一位。
778
+ * 拖动中算落点:浮层的下边界压过哪一行的中线,影子就往下滑到那一行;
779
+ * 上边界举过哪一行的中线,就往上滑。
780
+ * slots 是按下那一刻按原始顺序量下的各行矩形,shift 是这期间页面滚动的位移
781
+ * (每次移动都重新量锚点得到)—— 直接用真实矩形,行距里有没有 gap、
782
+ * 某行是不是比别人高,都不影响判定。
767
783
  */
768
- function reorderSegments(fromId, toId, after) {
769
- if (fromId === null || fromId === undefined || toId === null || toId === undefined) return;
770
- if (fromId === toId) return;
771
- const next = config.segments.slice();
772
- if (next.indexOf(fromId) === -1 || next.indexOf(toId) === -1) return;
773
- next.splice(next.indexOf(fromId), 1);
774
- const at = next.indexOf(toId) + (after === true ? 1 : 0);
775
- next.splice(at, 0, fromId);
776
- 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;
777
806
  }
778
807
 
779
808
  /* ---------------------------------------------------------------- 工具 */
@@ -915,6 +944,19 @@ window.__ModuleLoader__.load({
915
944
  * 令牌面板数据:第一行是总用量,第二行三段占比(顶满 100%),下面三行明细。
916
945
  * 数字一律千分位精确值,不带单位(面板标题已经说明是 token 用量)。
917
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
+
918
960
  function tokenPanelView(cached, missInput, output, t) {
919
961
  const total = cached + missInput + output;
920
962
  if (total <= 0) return null;
@@ -993,18 +1035,58 @@ window.__ModuleLoader__.load({
993
1035
  });
994
1036
  }
995
1037
 
996
- /** 令牌面板的 tip;三段全 0 时不给面板(省得弹一个空泡泡)。 */
1038
+ /** 令牌面板的 tip。没有数据也给面板(三行横杠)—— 气泡必须任何情况下都点得开。 */
997
1039
  function tokensTip(parts, title, t) {
998
1040
  const panel = parts === null ? null : tokenPanelView(parts.cached, parts.missInput, parts.output, t);
999
- return panel === null ? null : { kind: "tokens", title: title, panel: panel };
1041
+ return { kind: "tokens", title: title, panel: panel === null ? dashTokenPanel(t) : panel };
1000
1042
  }
1001
1043
 
1002
- /** 毫秒 → 面板里的时长文本;0 或缺数据给横杠。 */
1044
+ /** 毫秒 → 面板里的时长文本(精确到秒);0 或缺数据给横杠。 */
1003
1045
  function tipDuration(ms) {
1004
- const text = formatDuration(ms);
1046
+ const text = formatDurationFine(ms);
1005
1047
  return text === null ? "-" : text;
1006
1048
  }
1007
1049
 
1050
+ /** 时长精确到秒:1h23m45s / 23m45s / 45s。底栏那条用 formatDuration(省掉秒,宽度有限)。 */
1051
+ function formatDurationFine(ms) {
1052
+ const n = Number(ms) || 0;
1053
+ if (n <= 0) return null;
1054
+ const whole = Math.round(n / 1000);
1055
+ if (whole < 60) return whole + "s";
1056
+ const minutes = Math.floor(whole / 60);
1057
+ if (minutes < 60) return minutes + "m" + (whole % 60) + "s";
1058
+ return Math.floor(minutes / 60) + "h" + (minutes % 60) + "m" + (whole % 60) + "s";
1059
+ }
1060
+
1061
+ /** 本轮缓存命中率:这一轮的输入 token 里命中缓存的比例;本轮还没有调用给横杠。
1062
+ 读插件自己的 desktopStatusbarUsage 投影 —— 官方 tokenUsage 只有会话累计值。
1063
+ 注意字段是短名(input / cacheRead / cacheWrite),与官方投影的 *Tokens 不同名。 */
1064
+ function turnHitRate(sessionUsage) {
1065
+ const current = sessionUsage === undefined || sessionUsage === null ? null : sessionUsage.current;
1066
+ if (current === null || current === undefined) return "-";
1067
+ const denominator = (current.input || 0) + (current.cacheRead || 0) + (current.cacheWrite || 0);
1068
+ if (denominator <= 0) return "-";
1069
+ return hitRateText(((current.cacheRead || 0) / denominator) * 100);
1070
+ }
1071
+
1072
+ /** 本轮平均首字;本轮还没有样本给横杠。 */
1073
+ function turnAverageTtft(timing) {
1074
+ if (timing === null || timing === undefined) return "-";
1075
+ const samples = timing.turnTtftSamples || 0;
1076
+ if (samples <= 0) return "-";
1077
+ const text = formatSeconds(timing.turnTtftMs / samples);
1078
+ return text === null ? "-" : text + "s";
1079
+ }
1080
+
1081
+ /** 本轮平均输出速度;本轮还没有可测样本给横杠。 */
1082
+ function turnAverageTps(timing, t) {
1083
+ if (timing === null || timing === undefined) return "-";
1084
+ const decodeMs = timing.turnDecodeMs || 0;
1085
+ const tokens = timing.turnDecodeTokens || 0;
1086
+ if (decodeMs <= 0 || tokens <= 0) return "-";
1087
+ return formatThroughput(tokens / (decodeMs / 1000)) + t("unitSpeed");
1088
+ }
1089
+
1008
1090
  /** 首字极值(毫秒)→ "9.3s";没有样本给横杠。 */
1009
1091
  function tipSeconds(mark) {
1010
1092
  if (mark === null || mark === undefined) return "-";
@@ -1126,6 +1208,7 @@ window.__ModuleLoader__.load({
1126
1208
  kind: "rows",
1127
1209
  title: t("segTtft"),
1128
1210
  rows: [
1211
+ { label: t("tipTtftTurn"), value: turnAverageTtft(timing) },
1129
1212
  { label: t("tipTtftFast"), value: tipSeconds(timing === null ? null : timing.fastestTtft) },
1130
1213
  { label: t("tipTtftSlow"), value: tipSeconds(timing === null ? null : timing.slowestTtft) }
1131
1214
  ]
@@ -1147,6 +1230,7 @@ window.__ModuleLoader__.load({
1147
1230
  kind: "rows",
1148
1231
  title: t("segCacheHit"),
1149
1232
  rows: [
1233
+ { label: t("tipHitTurn"), value: turnHitRate(src.sessionUsage) },
1150
1234
  { label: t("tipHitHigh"), value: extremes === null ? "-" : extremes.high },
1151
1235
  { label: t("tipHitLow"), value: extremes === null ? "-" : extremes.low }
1152
1236
  ]
@@ -1165,6 +1249,7 @@ window.__ModuleLoader__.load({
1165
1249
  kind: "rows",
1166
1250
  title: t("segTps"),
1167
1251
  rows: [
1252
+ { label: t("tipTpsTurn"), value: turnAverageTps(timing, t) },
1168
1253
  { label: t("tipTpsFast"), value: tipSpeed(timing === null ? null : timing.fastestTps, t) },
1169
1254
  { label: t("tipTpsSlow"), value: tipSpeed(timing === null ? null : timing.slowestTps, t) }
1170
1255
  ]
@@ -1207,9 +1292,9 @@ window.__ModuleLoader__.load({
1207
1292
 
1208
1293
  if (id === "cost") {
1209
1294
  const sessionUsage = src.sessionUsage;
1210
- if (sessionUsage === undefined || sessionUsage === null) return null;
1211
- const calls = Array.isArray(sessionUsage.calls) ? sessionUsage.calls : null;
1212
- if (calls === null || calls.length === 0) return null;
1295
+ const calls = sessionUsage === undefined || sessionUsage === null || Array.isArray(sessionUsage.calls) !== true
1296
+ ? []
1297
+ : sessionUsage.calls;
1213
1298
  /* 每条调用按自己发生的那一刻、自己的模型定峰谷:跨时段、跨模型都不会串价。
1214
1299
  累加的是未舍入金额,最后一次性舍入 —— 逐条舍入会累积出几毛钱的误差。 */
1215
1300
  const fallbackModel = src.sessionModel !== undefined && src.sessionModel !== null ? src.sessionModel.model : null;
@@ -1222,10 +1307,10 @@ window.__ModuleLoader__.load({
1222
1307
  priced = true;
1223
1308
  total += one;
1224
1309
  }
1225
- if (!priced) return null;
1226
1310
  return {
1227
1311
  id: id,
1228
- text: t("f_cost", { symbol: symbol, cost: costText(total) }),
1312
+ /* 算不出价(价格库里没这个模型)时给 ¥-:与"这笔真的是 0"区分开 */
1313
+ text: t("f_cost", { symbol: symbol, cost: priced ? costText(total) : "-" }),
1229
1314
  /* token 计数并进总计:点这一段看会话的 token 明细 */
1230
1315
  tip: tokensTip(sessionTokenParts(src.usage), t("f_tokensTotal"), t)
1231
1316
  };
@@ -1240,29 +1325,31 @@ window.__ModuleLoader__.load({
1240
1325
  && Array.isArray(sessionUsageNow.current.calls)
1241
1326
  ? sessionUsageNow.current.calls
1242
1327
  : null;
1243
- if (turnCalls !== null && turnCalls.length > 0) {
1244
- let turnTotal = 0;
1245
- let turnPriced = false;
1246
- const turnParts = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
1247
- for (let i = 0; i < turnCalls.length; i += 1) {
1248
- const callModel = typeof turnCalls[i].model === "string" ? turnCalls[i].model : fallbackModel;
1249
- const one = costOf(turnCalls[i], callModel, turnCalls[i].at);
1250
- turnParts.input += turnCalls[i].input || 0;
1251
- turnParts.cacheRead += turnCalls[i].cacheRead || 0;
1252
- turnParts.cacheWrite += turnCalls[i].cacheWrite || 0;
1253
- turnParts.output += turnCalls[i].output || 0;
1254
- if (one === null) continue;
1255
- turnPriced = true;
1256
- turnTotal += Number(one);
1257
- }
1258
- if (turnPriced) {
1259
- return {
1260
- id: id,
1261
- text: t("f_lastCost", { symbol: symbol, cost: costText(turnTotal) }),
1262
- /* 本轮点开的是本轮 token 明细,读法与总计那个面板同一套 */
1263
- tip: tokensTip(billableParts(turnParts), t("f_tokensTurn"), t)
1264
- };
1265
- }
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
+ };
1266
1353
  }
1267
1354
  /* 本轮是不是正在跑(与状态段同一套判断) */
1268
1355
  const active = src.running === true
@@ -1279,37 +1366,77 @@ window.__ModuleLoader__.load({
1279
1366
  if (active === true && bucket !== null && sameTurn === true) {
1280
1367
  const runningCost = costOf(bucket, fallbackModel, src.now);
1281
1368
  if (runningCost !== null) {
1282
- 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 };
1283
1370
  }
1284
1371
  }
1285
- /* 新一轮已经开跑、但还没有属于它的调用:本轮就是 0。
1286
- 这里必须拦住 —— 否则会退回上一轮(节点折叠还错位过一轮,显示成上上轮)的金额,
1287
- 发完消息底栏挂着一个旧数字,看着像没重置。 */
1372
+ /* 新一轮已经开跑、但还没有属于它的调用:必须归零,不能退回上一轮
1373
+ —— 节点折叠曾错位一轮,底栏显示成上上轮的金额,这条就是为堵它留的。
1374
+ 一次模型调用都还没完成过(第一轮)时没有"归零"可言,跟总计一样给横杠。
1375
+ 等这一轮真的产生了调用,上面那条精确口径就接管了。 */
1288
1376
  if (active === true) {
1289
- 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
+ };
1290
1385
  }
1291
- /* 空闲:显示最近完成的一轮(节点折叠 → 官方差分兜底),同样只出金额 */
1292
- if (bucket === null) return null;
1293
- const cost = costOf(bucket, fallbackModel, src.now);
1294
- if (cost === null) return null;
1295
- 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
+ };
1296
1395
  }
1297
1396
 
1298
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();
1299
1421
  const balance = src.balance;
1300
- if (balance === null || balance === undefined) return null;
1301
- if (balance.ok !== true) return null;
1302
- 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();
1303
1425
  /* 余额用接口自己报的币种:账户里是人民币就显示 ¥,不跟着上面的计价单位走 */
1304
- 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);
1305
1428
  /* 点开看构成:total = 充值余额 + 赠金余额;没有赠金时接口不给这个字段,按 0.00 显示 */
1306
1429
  const tip = {
1307
1430
  kind: "rows",
1308
1431
  title: t("segBalance"),
1309
1432
  rows: [
1310
1433
  { label: t("tipTopUp"), value: balSymbol + balanceAmount(balance.toppedUp) },
1311
- { label: t("tipGranted"), value: balSymbol + balanceAmount(balance.granted) }
1312
- ]
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)
1313
1440
  };
1314
1441
  /* 不足一分钱时官方显示 <¥0.01,这里跟着来 */
1315
1442
  if (balance.subCent === true) return { id: id, text: t("f_balanceSubCent", { symbol: balSymbol }), tip: tip };
@@ -1339,8 +1466,8 @@ window.__ModuleLoader__.load({
1339
1466
  /* 速度占位不带单位:英文那边标签已经是 TPS,再跟 t/s 就重复了 */
1340
1467
  if (id === "tps") return label + " " + dash;
1341
1468
  if (id === "sessionTime") return label + " " + dash + "m " + dash + "s";
1342
- if (id === "cost" || id === "lastCost") return label + " " + dash + " " + currencySymbol(config.currency);
1343
- 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) });
1344
1471
  return dash;
1345
1472
  }
1346
1473
  /* ---------------------------------------------------------------- css */
@@ -1416,21 +1543,29 @@ window.__ModuleLoader__.load({
1416
1543
  ".dsb-settings .dsb-section{display:flex;flex-direction:column;gap:8px}",
1417
1544
  ".dsb-settings .dsb-sectitle{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;letter-spacing:.02em}",
1418
1545
  ".dsb-settings .dsb-hint{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.6}",
1419
- ".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}",
1420
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}",
1421
1554
  ".dsb-settings .dsb-check{display:flex;align-items:flex-start;gap:8px;cursor:pointer;flex:1;min-width:0}",
1422
1555
  ".dsb-settings .dsb-check input{flex:none;margin:2px 0 0}",
1423
1556
  ".dsb-settings input[type=checkbox]{accent-color:var(--dsw-alias-label-primary,#202020)}",
1424
1557
  ".dsb-settings .dsb-labels{display:flex;flex-direction:column;gap:2px;min-width:0}",
1425
1558
  ".dsb-settings .dsb-name{color:var(--dsw-alias-label-primary)}",
1426
1559
  ".dsb-settings .dsb-desc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}",
1427
- ".dsb-settings .dsb-row.dsb-dragging{opacity:.35}",
1428
- ".dsb-settings .dsb-row.dsb-over-before{box-shadow:inset 0 2px 0 var(--dsw-alias-brand-primary,#4d6bfe)}",
1429
- ".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))}",
1430
1564
  ".dsb-settings .dsb-handle{position:relative;flex:none;width:16px;height:16px;color:var(--dsw-alias-label-tertiary);cursor:grab}",
1431
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}',
1432
1566
  ".dsb-settings .dsb-handle:active{cursor:grabbing}",
1433
- ".dsb-settings .dsb-handle-off{opacity:.25;cursor:default}",
1567
+ /* 未勾选的段也能调位置,所以光标跟勾选时一样是抓手,只是点阵淡一些 */
1568
+ ".dsb-settings .dsb-handle-off{opacity:.25}",
1434
1569
  /* 价格库 */
1435
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}",
1436
1571
  ".dsb-settings .dsb-current{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1.6}",
@@ -1475,6 +1610,8 @@ window.__ModuleLoader__.load({
1475
1610
  ".dsb-settings .dsb-action:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1))}",
1476
1611
  ".dsb-settings .dsb-action-sm{height:24px;padding:0 8px;font-size:12px}",
1477
1612
  ".dsb-settings .dsb-foot{display:flex;align-items:center;gap:12px;flex-wrap:wrap}",
1613
+ /* 版本号贴右侧、与按钮底边对齐 */
1614
+ ".dsb-settings .dsb-version{margin-left:auto;align-self:flex-end;font-size:11px;line-height:1;color:var(--dsw-alias-label-tertiary);opacity:.75}",
1478
1615
  ".dsb-settings .dsb-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}"
1479
1616
  ].join("");
1480
1617
 
@@ -1646,19 +1783,34 @@ window.__ModuleLoader__.load({
1646
1783
  }, []);
1647
1784
 
1648
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);
1649
1795
  react.useEffect(() => {
1650
1796
  if (!wantsBalance) return undefined;
1651
1797
  let alive = true;
1652
- const load = () => {
1653
- 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" })
1654
1801
  .then((response) => response.json())
1655
1802
  .then((data) => { if (alive) setBalance(data); })
1656
1803
  .catch(() => { if (alive) setBalance({ ok: false, reason: "fetch-failed" }); });
1657
1804
  };
1658
- load();
1659
- const timer = window.setInterval(load, BALANCE_POLL_MS);
1660
- return () => { alive = false; window.clearInterval(timer); };
1661
- }, [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]);
1662
1814
 
1663
1815
  /* 只在"点别处"时收起。这里刻意不监听 scroll:底栏固定在视口底部,对话区滚动
1664
1816
  不会移动触发项,但每来一次工具调用/新一轮,对话区都会自动滚到底 ——
@@ -1798,7 +1950,9 @@ window.__ModuleLoader__.load({
1798
1950
  const at = tipKeys.indexOf(key);
1799
1951
  if (at < 0) return "center";
1800
1952
  if (at === 0) return "left";
1801
- return at === tipKeys.length - 1 ? "right" : "center";
1953
+ /* 只有余额贴右边(它固定在栏尾)。其余一律居中、装不下时再夹 ——
1954
+ 否则某一段没有气泡时会被当成"最右项"改成右对齐,看起来就不居中了。 */
1955
+ return key === "balance" ? "right" : "center";
1802
1956
  };
1803
1957
  /* 打开时保存触发项的位置并收起上一次的气泡;真正的坐标等面板挂上、量到宽度后再算 */
1804
1958
  const toggleTip = (id, event, mode) => {
@@ -1807,6 +1961,8 @@ window.__ModuleLoader__.load({
1807
1961
  setTipPos(null);
1808
1962
  return;
1809
1963
  }
1964
+ /* 点开余额:顺手重查一次(收起那一下不查),数据回来气泡自己更新 */
1965
+ if (id === "balance" && balanceReloadRef.current !== null) balanceReloadRef.current();
1810
1966
  const target = event !== undefined && event !== null ? event.currentTarget : null;
1811
1967
  /* 存节点而不是当次的矩形:窗口缩放后要重新量 */
1812
1968
  anchorRef.current = target !== null && typeof target.getBoundingClientRect === "function" ? target : null;
@@ -1865,9 +2021,9 @@ window.__ModuleLoader__.load({
1865
2021
  const t = props.t;
1866
2022
  const cfg = useConfig();
1867
2023
 
1868
- const [dragId, setDragId] = react.useState(null);
1869
- const [overId, setOverId] = react.useState(null);
1870
- 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 } */
1871
2027
  const [newModel, setNewModel] = react.useState("");
1872
2028
  const [hostModel, setHostModel] = react.useState(null);
1873
2029
  const [editing, setEditing] = react.useState(null);
@@ -1878,21 +2034,38 @@ window.__ModuleLoader__.load({
1878
2034
  const currencyRef = react.useRef(null);
1879
2035
  const editPopRef = react.useRef(null);
1880
2036
 
1881
- /* 编辑浮层比卡片本身高,会被设置页的滚动容器裁掉下半截 —— 打开后滚一下,
1882
- 把它的底边对齐到可视区底部,整块就都看得见了。 */
2037
+ /* 编辑浮层打开后按需滚动:block:"nearest" 只补上被遮住的那一段,
2038
+ 本来就在视野里就一动不动(旧版用 "end" 会平白往上滚,明明下面有地方)。 */
1883
2039
  react.useEffect(() => {
1884
2040
  if (editing === null) return;
1885
2041
  const node = editPopRef.current;
1886
2042
  if (node === null || node === undefined || typeof node.scrollIntoView !== "function") return;
1887
- node.scrollIntoView({ block: "end", behavior: "smooth" });
2043
+ node.scrollIntoView({ block: "nearest", behavior: "smooth" });
1888
2044
  }, [editing]);
1889
2045
 
1890
- /* 编辑浮层:点别处就收起,草稿一律丢弃 —— 改了一半的数字、新模型里填的空值都不留下 */
2046
+ /* 编辑浮层:点别处收起并丢弃草稿。判断"里面"上三重保险 —— 浮层节点、价格卡节点,
2047
+ 以及鼠标坐标是否落在卡片或币种下拉的矩形内。切币种时点在卡片内的控件上,
2048
+ 无论 DOM 结构怎么变都不该把编辑区收掉。 */
1891
2049
  react.useEffect(() => {
1892
2050
  if (editing === null) return undefined;
1893
- const onDocClick = (event) => {
2051
+ const inside = (event) => {
2052
+ const target = event.target;
2053
+ if (target !== null && target !== undefined && typeof target.closest === "function"
2054
+ && target.closest(".dsb-model") !== null) return true;
1894
2055
  const node = editPopRef.current;
1895
- if (node !== null && node !== undefined && typeof node.contains === "function" && node.contains(event.target)) return;
2056
+ if (node !== null && node !== undefined && typeof node.contains === "function" && node.contains(target)) return true;
2057
+ const x = event.clientX;
2058
+ const y = event.clientY;
2059
+ if (typeof x !== "number" || typeof y !== "number") return false;
2060
+ return [node, currencyRef.current].some((one) => {
2061
+ if (one === null || one === undefined || typeof one.getBoundingClientRect !== "function") return false;
2062
+ const box = one.getBoundingClientRect();
2063
+ return box.width > 0 && box.height > 0
2064
+ && x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
2065
+ });
2066
+ };
2067
+ const onDocClick = (event) => {
2068
+ if (inside(event)) return;
1896
2069
  cancelEdit();
1897
2070
  };
1898
2071
  document.addEventListener("click", onDocClick);
@@ -2027,7 +2200,168 @@ window.__ModuleLoader__.load({
2027
2200
  .map((id) => byId[id])
2028
2201
  .filter((segment) => segment !== undefined && segment !== null);
2029
2202
 
2030
- 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
+ },
2031
2365
  h("label", { className: "dsb-check" },
2032
2366
  h("input", {
2033
2367
  type: "checkbox",
@@ -2041,75 +2375,74 @@ window.__ModuleLoader__.load({
2041
2375
  )
2042
2376
  );
2043
2377
 
2044
- 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) => {
2045
2405
  const on = isSegmentOn(cfg, segment.id);
2046
- const classes = ["dsb-row"];
2047
- if (dragId === segment.id) classes.push("dsb-dragging");
2048
- if (overId === segment.id && dragId !== segment.id) {
2049
- classes.push(overAfter === true ? "dsb-over-after" : "dsb-over-before");
2050
- }
2406
+ const dragged = drag !== null && drag.id === segment.id;
2051
2407
  return h("div", {
2052
- className: classes.join(" "),
2408
+ className: dragged === true ? "dsb-row dsb-dragging" : "dsb-row",
2053
2409
  key: segment.id,
2054
- draggable: true, /* 未勾选的段也能调位置 */
2055
- onDragStart: (event) => {
2056
- setDragId(segment.id);
2057
- try {
2058
- event.dataTransfer.effectAllowed = "move";
2059
- event.dataTransfer.setData("text/plain", segment.id);
2060
- } catch (error) { /* 某些环境 dataTransfer 受限 */ }
2061
- },
2062
- onDragOver: (event) => {
2063
- if (dragId === null || dragId === segment.id) return;
2064
- event.preventDefault();
2065
- try { event.dataTransfer.dropEffect = "move"; } catch (error) { /* ignore */ }
2066
- /* 以目标行中线分前后;中线附近 4px 内保持原状态,避免来回闪 */
2067
- const rect = event.currentTarget.getBoundingClientRect();
2068
- const middle = rect.top + rect.height / 2;
2069
- if (Math.abs(event.clientY - middle) < 4) return;
2070
- const after = event.clientY > middle;
2071
- if (overId !== segment.id || overAfter !== after) {
2072
- setOverId(segment.id);
2073
- setOverAfter(after);
2074
- }
2075
- },
2076
- onDragLeave: () => {
2077
- if (overId === segment.id) {
2078
- setOverId(null);
2079
- setOverAfter(false);
2080
- }
2410
+ ref: (node) => {
2411
+ if (node === null || node === undefined) rowNodes.current.delete(segment.id);
2412
+ else rowNodes.current.set(segment.id, node);
2081
2413
  },
2082
- onDrop: (event) => {
2083
- event.preventDefault();
2084
- reorderSegments(dragId, segment.id, overId === segment.id && overAfter === true);
2085
- setDragId(null);
2086
- setOverId(null);
2087
- setOverAfter(false);
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);
2088
2421
  },
2089
- onDragEnd: () => { setDragId(null); setOverId(null); setOverAfter(false); }
2090
- },
2091
- h("label", { className: "dsb-check" },
2092
- h("input", {
2093
- type: "checkbox",
2094
- checked: on,
2095
- onChange: (event) => toggleSegment(segment.id, event.target.checked)
2096
- }),
2097
- h("span", { className: "dsb-labels" },
2098
- h("span", { className: "dsb-name" }, t(segment.label)),
2099
- h("span", { className: "dsb-desc" }, t(segment.hint))
2100
- )
2101
- ),
2102
- h("span", {
2103
- className: on === true ? "dsb-handle" : "dsb-handle dsb-handle-off",
2104
- title: on === true ? t("dragHint") : ""
2105
- })
2106
- );
2422
+ onPointerMove: dragged === true ? moveDrag : undefined,
2423
+ onPointerUp: dragged === true ? endDrag : undefined,
2424
+ onPointerCancel: dragged === true ? cancelDrag : undefined
2425
+ }, rowParts(segment, on));
2107
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
+
2108
2440
  sections.push(h("div", { className: "dsb-section", key: "__segments" },
2109
2441
  h("div", { className: "dsb-sectitle" }, t("secSegments")),
2110
2442
  h("p", { className: "dsb-hint" }, t("secSegmentsHint")),
2111
2443
  contextRow,
2112
- segmentRows
2444
+ segmentRows,
2445
+ dragCard
2113
2446
  ));
2114
2447
 
2115
2448
  /* 自定义模型价格 */
@@ -2280,7 +2613,9 @@ window.__ModuleLoader__.load({
2280
2613
  modelOrder: Object.keys(DEFAULT_PRICES)
2281
2614
  })
2282
2615
  }, t("reset")),
2283
- h("div", { className: "dsb-actions", key: "__actions" }, holidayActions)
2616
+ h("div", { className: "dsb-actions", key: "__actions" }, holidayActions),
2617
+ /* 版本标记贴右、与行内底边对齐:改了客户端代码要刷新页面才生效,这行字让"跑的是哪版"一眼可见 */
2618
+ h("span", { className: "dsb-version", key: "__version" }, "dsh-desktop-statusbar " + VERSION)
2284
2619
  ]));
2285
2620
 
2286
2621
  return h("div", { className: "dsb-settings" }, sections);