dsh-whale-widget 0.3.16 → 0.3.18

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.
@@ -308,8 +308,26 @@ function dshwvSound(url) {
308
308
 
309
309
  // 自动播放策略:AudioContext 初始是 suspended,要有一次用户手势才能出声;任务结束音不是手势触发的,
310
310
  // 所以挂一次性解锁(首次点击/按键后移除)。
311
+ // v789(issue #179):**输入法组字期间一律不做事**。用户实测"装了插件后微软拼音打字会自动上屏、
312
+ // 光标跳到最前端"(0.3.16/0.3.17 均复现,卸载即恢复)。我们能做的、也是最有把握的一条,是
313
+ // 让挂件在 composition 期间**完全不碰 DOM、不做重活**:往 document.body 挂节点会触发布局,
314
+ // 而首次按键同步创建/预热 AudioContext 也会让组字被打断。所以:
315
+ // ① 记录 composition 状态,组字中的 keydown 不解锁音频;
316
+ // ② 组字期间所有 body 增删一律排队,等 compositionend 之后再补(见 dshwBodyAppend)。
317
+ var dshwvComposing = false
318
+ var dshwvDeferredBody = []
319
+ function dshwvComposingNow() { return dshwvComposing === true }
311
320
  try {
312
- var dshwvAudioUnlock = function () {
321
+ document.addEventListener('compositionstart', function () { dshwvComposing = true }, true)
322
+ document.addEventListener('compositionend', function () {
323
+ dshwvComposing = false
324
+ setTimeout(function () { try { dshwvFlushDeferredBody() } catch (err) {} }, 0)
325
+ }, true)
326
+ } catch (err) {}
327
+ try {
328
+ var dshwvAudioUnlock = function (ev) {
329
+ // v789(issue #179):组字中的按键不做音频解锁(isComposing / keyCode 229 = IME 处理中)
330
+ try { if (dshwvComposing || (ev && (ev.isComposing === true || ev.keyCode === 229))) return } catch (err) {}
313
331
  // v753(issue #135):音效关掉时**不预解锁** —— 否则一次普通点击就会把 context 转成 running,
314
332
  // 断言照挂。注意这里**不摘监听**:之后重新打开开关,下一次点击仍能完成解锁。
315
333
  if (dshwvSoundOff()) return
@@ -891,9 +909,23 @@ document.head.appendChild(styleEl)
891
909
  // 所以这里统一登记:所有挂到 body 的节点都走 dshwBodyAppend(),守护时逐个补挂;
892
910
  // 主动移除(目前只有一处)走 dshwBodyDetach(),避免被守护逻辑"复活"。
893
911
  var dshwBodyNodes = []
912
+ // v789(issue #179):输入法组字期间把"往 body 挂节点"的请求排队 —— 改 document.body 的子节点会触发布局,
913
+ // 在 composition 期间做这件事会让浏览器/输入法重置组字(用户实测:打字自动上屏、光标跳到最前端)。
914
+ // 排队到 compositionend 之后再补挂(正常输入时行为完全不变:非组字状态下一律立即挂)。
915
+ function dshwvFlushDeferredBody() {
916
+ try {
917
+ if (dshwvComposing || !dshwvDeferredBody.length) return
918
+ var list = dshwvDeferredBody.slice()
919
+ dshwvDeferredBody.length = 0
920
+ for (var i = 0; i < list.length; i++) {
921
+ try { if (list[i] && list[i].__detach) dshwBodyDetach(list[i].el); else dshwBodyAppend(list[i]) } catch (err) {}
922
+ }
923
+ } catch (err) {}
924
+ }
894
925
  function dshwBodyAppend(el) {
895
926
  try {
896
927
  if (!el) return el
928
+ if (dshwvComposing && dshwvDeferredBody.indexOf(el) < 0) { dshwvDeferredBody.push(el); return el }
897
929
  // 注意:这里必须是**原始**的 document.body.appendChild —— 不能走 dshwBodyAppend 自己
898
930
  // (v743 批量改写时曾误替换成自我递归,被 try/catch 吞掉后表现为"登记了但从未挂上")
899
931
  document.body.appendChild(el)
@@ -903,6 +935,8 @@ function dshwBodyAppend(el) {
903
935
  }
904
936
  function dshwBodyDetach(el) {
905
937
  try {
938
+ // v789(issue #179):组字期间连"移除"也排队(同样是 body 子节点变更,会打断组字)
939
+ if (dshwvComposing) { dshwvDeferredBody.push({ __detach: true, el: el }); return }
906
940
  var i = dshwBodyNodes.indexOf(el)
907
941
  if (i >= 0) dshwBodyNodes.splice(i, 1)
908
942
  if (el && el.parentNode) el.parentNode.removeChild(el)
@@ -1038,6 +1072,8 @@ if (!window.__dshwCustBound) {
1038
1072
  dshwCustSelClose()
1039
1073
  }, true)
1040
1074
  document.addEventListener('keydown', function (e) {
1075
+ // v789(issue #179):组字中按 Esc 是输入法的「取消组字」,别抢
1076
+ if (dshwvComposingNow()) return
1041
1077
  if (e.key === 'Escape') dshwCustCloseNow()
1042
1078
  }, true)
1043
1079
  window.addEventListener('resize', function () { dshwCustCloseNow() })
@@ -1389,21 +1425,37 @@ function soundVolClamped(fallback) {
1389
1425
  if (!isFinite(v)) v = (typeof fallback === "number") ? fallback : 0.9
1390
1426
  return Math.max(0, Math.min(1, v))
1391
1427
  }
1428
+ // v778:**音量解析器 = 音量的唯一真源**。所有播放入口都必须经过它取音量(禁止各自去读全局),
1429
+ // 优先级:① 显式 override(绑定事件 / 试听传进来的值)→ ② 该事件**用户显式调过**的音量
1430
+ // (`cfg.volSet === true`)→ ③ ①区「按压音量」。
1431
+ // 为什么需要 volSet 这一层:出厂默认 `events.turnCost.vol = 1`,而面板每次保存都会把这个键写进磁盘
1432
+ // ⇒ 只看"有没有值"无法区分「默认 100%」和「用户设了 100%」;若让每事件无条件优先,会把
1433
+ // 「只调过①区、没碰过②区」的老用户的任务结束音静默抬到 100%(见 SPEC §24)。
1434
+ // ⚠️ 约定:本文件里 `soundVolClamped(` 只允许出现在本函数与它自己的定义处 —— 由 `_v778` 静态钉住。
1435
+ function soundVolumeOf(kind, override) {
1436
+ try {
1437
+ if (typeof override === 'number' && isFinite(override) && override >= 0) {
1438
+ return Math.max(0, Math.min(1, override))
1439
+ }
1440
+ if (kind) {
1441
+ var cfg = soundEventCfg(kind) || {}
1442
+ if (cfg.volSet === true) {
1443
+ var v = Number(cfg.vol)
1444
+ if (isFinite(v) && v >= 0) return Math.max(0, Math.min(1, v))
1445
+ }
1446
+ }
1447
+ return soundVolClamped(0.9)
1448
+ } catch (err) { return soundVolClamped(0.9) }
1449
+ }
1450
+ // v778:绑定音(按压 / 提问 / 授权 / 试听)—— 把 `{sel, vol}` **显式传进去**。
1451
+ // 旧实现靠"临时把全局 soundVol / usageSet.taskEnd 改掉、finally 再改回来"来传值;那正是
1452
+ // "每轮结束音读错音量"的同源隐患:任何新增播放入口忘了临时改一下,就会静默读全局(正是 0.3.16 的 bug)。
1392
1453
  function playBindingSound(bind, ev) {
1393
1454
  try {
1394
1455
  if (!bind || !bind.on || soundOn === false) return
1395
1456
  var sel = bind.sel || ''
1396
1457
  if (!sel) return // 防御:空选择不出声(v762 起下拉里已无「静音」项,正常不会再为空)
1397
- var savedTaskEnd = usageSet ? usageSet.taskEnd : undefined
1398
- var savedVol = soundVol
1399
- try {
1400
- if (usageSet) usageSet.taskEnd = { on: true, sel: sel }
1401
- if (ev && typeof ev.vol === 'number' && ev.vol >= 0) soundVol = ev.vol
1402
- playTaskEndSound()
1403
- } finally {
1404
- if (usageSet) usageSet.taskEnd = savedTaskEnd
1405
- soundVol = savedVol
1406
- }
1458
+ playTaskEndSound({ sel: sel, vol: (ev && typeof ev.vol === 'number') ? ev.vol : undefined })
1407
1459
  } catch (err) {}
1408
1460
  }
1409
1461
  // v761:等待交互时的**会话名**(wait.json 每秒轮询回填)+ 超长截断(默认 12 字符 + …)
@@ -1425,13 +1477,19 @@ function bubbleSessionText(m) {
1425
1477
  m = m || {}
1426
1478
  return bubbleSessionLabel(m.len === undefined || m.len === null ? WAIT_SESSION_MAX : m.len)
1427
1479
  }
1428
- function playTaskEndSound() {
1480
+ // v778:`opts` = { sel?, vol? } —— 传入 sel 表示"播这个绑定音效"(绕过 taskEnd 开关,因为它自带 on);
1481
+ // 不传 sel = 路径 A(每轮结束音),按 usageSet.taskEnd 的门控与选择。
1482
+ function playTaskEndSound(opts) {
1429
1483
  try {
1430
- if (!usageSet || !usageSet.taskEnd || !usageSet.taskEnd.on || soundOn === false) return
1431
- var sel = usageSet.taskEnd.sel || taskEndSel.value || ''
1484
+ var o = opts || {}
1485
+ if (soundOn === false) return
1486
+ if (!o.sel) {
1487
+ if (!usageSet || !usageSet.taskEnd || !usageSet.taskEnd.on) return
1488
+ }
1489
+ var sel = o.sel || usageSet.taskEnd.sel || taskEndSel.value || ''
1432
1490
  var url = ''
1433
1491
  var altUrl = ''
1434
- if (sel.indexOf('grp:') === 0) { playTaskEndGroupClick(sel.slice(4)); return }
1492
+ if (sel.indexOf('grp:') === 0) { playTaskEndGroupClick(sel.slice(4), o.vol); return }
1435
1493
  if (sel.indexOf('frag:') === 0) url = '/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(sel.slice(5))
1436
1494
  else if (sel.indexOf('preset:') === 0) {
1437
1495
  // v752:预设片段也改走片段路由(与本体按压同一套选择逻辑 + 备用路由)
@@ -1443,13 +1501,14 @@ function playTaskEndSound() {
1443
1501
  if (!url) return
1444
1502
  var a = dshwvSound(url)
1445
1503
  if (altUrl) a._alt = altUrl
1446
- try { a.volume = soundVolClamped(0.9) } catch (err) {}
1504
+ // v778:音量统一走解析器(②区「提示音量」显式设置过就用它,否则跟随①区「按压音量」)
1505
+ try { a.volume = soundVolumeOf('turnCost', o.vol) } catch (err) {}
1447
1506
  a.play().catch(function () {})
1448
1507
  } catch (err) {}
1449
1508
  }
1450
1509
  // 任务结束音=音效组时:模拟点按一次该组——音1完整播放结束后立即接音2,
1451
1510
  // 即“按下到音1结束才松开”的无缝连续点按听感;槽位留空(该事件静音)时跳过对应音频
1452
- function playTaskEndGroupClick(groupId) {
1511
+ function playTaskEndGroupClick(groupId, volOverride) {
1453
1512
  try {
1454
1513
  if (!groupId) return
1455
1514
  var g = null
@@ -1457,7 +1516,8 @@ function playTaskEndGroupClick(groupId) {
1457
1516
  var pressEmpty = !!(g && g.press === '') // 仅显式留空算静音;缺失字段(null)按旧数据回落预设
1458
1517
  var releaseEmpty = !!(g && g.release === '')
1459
1518
  if (pressEmpty && releaseEmpty) return
1460
- var vol = soundVolClamped(0.9)
1519
+ // v778:**"任务结束音 = 音效组"也必须走解析器**(第三方补丁只改了单片段那条路,音效组仍读①区 ⇒ 滑块照旧无效)
1520
+ var vol = soundVolumeOf('turnCost', volOverride)
1461
1521
  // v752:任务结束音同样改走片段路由(与本体按压同一套 URL 选择 + 备用路由逻辑)——
1462
1522
  // 老路由 /dsh-whale/sound/*.mp3?set=… 在部分环境会被本机那层东西拦成空的 204,
1463
1523
  // 不改的话"点按有声、但每轮结束音没声"会变成同一个问题的另一半。
@@ -1743,17 +1803,37 @@ function loadUsageSettings(cb) {
1743
1803
  .catch(function () { if (cb) cb() })
1744
1804
  } catch (err) { if (cb) cb() }
1745
1805
  }
1746
- function saveUsageSettings(patch) {
1747
- try {
1748
- fetch(USAGE_SET_URL, {
1749
- method: 'PUT',
1750
- headers: { 'Content-Type': 'application/json' },
1751
- body: JSON.stringify(patch || {}),
1806
+ // v778:与 `configPut()` **同一套语义**(读响应 → 失败静默重试一次 → 仍失败才提示用户)。
1807
+ // 旧实现只 `fetch(...).then(r=>r.json()).then(d=>{...}).catch(function(){})`:**不查 HTTP 状态码、
1808
+ // 失败完全静默** ⇒ 用户点了「保存」、面板正常关闭,其实一个字节都没写进磁盘(第三方分析报告点出的
1809
+ // 现象是真的,只是它把归因写成了"消耗数据丢存"——这个接口存的是**设置**,消耗记录由宿主自己记账)。
1810
+ function usageSettingsPut(patch, retried) {
1811
+ return fetch(USAGE_SET_URL, {
1812
+ method: 'PUT',
1813
+ headers: { 'Content-Type': 'application/json' },
1814
+ body: JSON.stringify(patch || {}),
1815
+ })
1816
+ .then(function (r) {
1817
+ return r.json().catch(function () { return null }).then(function (d) { return { ok: r.ok, status: r.status, d: d } })
1818
+ })
1819
+ .then(function (x) {
1820
+ if (x.ok && (!x.d || x.d.ok !== false)) {
1821
+ if (x.d && x.d.settings) usageSet = x.d.settings
1822
+ return true
1823
+ }
1824
+ if (!retried) return new Promise(function (res) { setTimeout(function () { res(usageSettingsPut(patch, true)) }, 900) })
1825
+ // 复用既有 toast(内部已对动态值做转义,见 v756/issue #143)
1826
+ configSaveFailNotice((x.d && x.d.error) || ('HTTP ' + x.status))
1827
+ return false
1828
+ })
1829
+ .catch(function (err) {
1830
+ if (!retried) return new Promise(function (res) { setTimeout(function () { res(usageSettingsPut(patch, true)) }, 900) })
1831
+ configSaveFailNotice((err && err.message) || err)
1832
+ return false
1752
1833
  })
1753
- .then(function (r) { return r.json() })
1754
- .then(function (d) { if (d && d.ok && d.settings) usageSet = d.settings })
1755
- .catch(function () {})
1756
- } catch (err) {}
1834
+ }
1835
+ function saveUsageSettings(patch) {
1836
+ try { return usageSettingsPut(patch, false) } catch (err) { return null }
1757
1837
  }
1758
1838
  // 把主菜单已有行收进 menuRootView;用量记录作为 menuBox 内的子视图切换
1759
1839
  var menuRootView = document.createElement('div')
@@ -2002,7 +2082,11 @@ function soundEventDefaults() {
2002
2082
  // ⚠️ v764:turnCost **也不再带** autoClose / ttlSec —— 它们从来没有消费方,而面板 ② 区
2003
2083
  // 「自动关闭」的真实落点是 .dshw-size.json 的 turnCostCloseMs(见 openSoundSettingsPanel)。
2004
2084
  // 给 turnCost 重新长出这两个键,会让同一个设置又变回两个来源(其中一个还是假的)。
2005
- turnCost: { vol: 1, bubbleOn: true },
2085
+ turnCost: { vol: 1, volSet: false, bubbleOn: true },
2086
+ // ⚠️ v778:`volSet` = "用户**显式**调过这一事件的音量"(默认 false)。它只表达"要不要覆盖①区",
2087
+ // 不是另一个音量来源 —— 取值永远看 `vol`。没有它就无法区分"默认 100%"与"用户设了 100%",
2088
+ // 会让"②区优先"静默改掉老用户的响度(详见 soundVolumeOf() 与 SPEC §24)。
2089
+ // ⚠️ 与宿主 soundEventsDefaults() 的同名字段**必须逐字段一致**(由探针 §⑥ 跨端一致钉住)。
2006
2090
  // v774:出厂默认 = 作者当前用法:提问/授权**开着**(要冒泡),但音效**不响**(soundOn: false)。
2007
2091
  // 要响就把音效行的 [✓] 勾上;不想冒泡就取消入口行的 [✓]。
2008
2092
  question: { on: true, soundOn: false, sel: 'frag:exp_orb', vol: 1, autoClose: true, ttlSec: 180, bubbleOn: true },
@@ -2029,6 +2113,9 @@ function soundEventOut(cfg, withBind) {
2029
2113
  vol: Number(cfg.vol) || 0,
2030
2114
  bubbleOn: cfg.bubbleOn !== false,
2031
2115
  }
2116
+ // v778:`volSet` 只属于 turnCost(= `withBind` 为假的那一个)。提问/授权的音量在"用户勾了音效开关"
2117
+ // 时才出声,不存在"默认值 vs 显式值"的歧义,所以不需要这个键。
2118
+ if (!withBind) o.volSet = cfg.volSet === true
2032
2119
  if (withBind) {
2033
2120
  // v764:autoClose / ttlSec 跟着 on/sel/soundOn 一起,只属于提问/授权。turnCost 的「自动关闭」
2034
2121
  // 真实落点是 .dsh-size.json 的 turnCostCloseMs、**不走 events 载荷** —— 别再无条件塞进去,
@@ -2067,6 +2154,11 @@ function openSoundSettingsPanel() {
2067
2154
  // 唯一消费点 sceneOpen('cost', …, turnCostCloseMs > 0 ? turnCostCloseMs : 0)),所以它也要进快照、
2068
2155
  // 也要能随「取消」还原(否则面板那行「自动关闭」就是个死控件)。
2069
2156
  var snapTurnCostCloseMs = Math.max(0, Math.round(Number(turnCostCloseMs) || 0))
2157
+ // v777(用户要求):底部新增「点按角色关闭提示气泡」——默认关(默认只有点泡泡能收起等待提示)。
2158
+ // 存在 usage.json 的 settings.wait.charClose(宿主 usageSettingsDefaults() 里默认 false);
2159
+ // 窗口期间只改缓冲,保存才 PUT,取消按快照还原,与其它设置项同一套语义。
2160
+ var bufWaitCharClose = !!(((usageSet || {}).wait || {}).charClose)
2161
+ var snapWaitCharClose = bufWaitCharClose
2070
2162
  // 内存缓冲:窗口期间只改这里
2071
2163
  var sndBuf = {
2072
2164
  turnCost: soundEventCfg('turnCost'),
@@ -2168,7 +2260,14 @@ function openSoundSettingsPanel() {
2168
2260
  var pre = (tcCloseBuf.autoClose !== false && sec > 0) ? ('自动关 ' + sec + 's') : '不自动关'
2169
2261
  // v768:静音时**不再显示已选的音效名**,直接告诉用户去哪儿改(用户要求的确切文案)
2170
2262
  txt = pre + ' · ' + (bufTaskEndOn ? (soundNameOf(e.sel) || '—') : '当前为静音,在下拉设置中修改')
2171
- if (bufTaskEndOn && sndPct(sndBuf.turnCost.vol) !== '100%') txt += ' · ' + sndPct(sndBuf.turnCost.vol)
2263
+ // v778:音量的两种状态写清楚 —— 没拖过 ②区滑块 = **跟随①区按压音量**;拖过 = 用这里的值。
2264
+ if (bufTaskEndOn) {
2265
+ if (sndBuf.turnCost.volSet === true) {
2266
+ if (sndPct(sndBuf.turnCost.vol) !== '100%') txt += ' · ' + sndPct(sndBuf.turnCost.vol)
2267
+ } else {
2268
+ txt += ' · 跟随按压音量 ' + sndPct(soundVol)
2269
+ }
2270
+ }
2172
2271
  }
2173
2272
  } else {
2174
2273
  var cfg = sndBuf[e.kind] || {}
@@ -2181,6 +2280,16 @@ function openSoundSettingsPanel() {
2181
2280
  }
2182
2281
  try { if (e.summary) e.summary.textContent = txt } catch (err) {}
2183
2282
  }
2283
+ // v778:②区「提示音量」滑块在**未显式设置**时显示"实际生效的音量"(= ①区按压音量),
2284
+ // 否则滑块停在 100% 而实际按 60% 播 —— 那就又变成"界面在骗人"(这次修的就是这类问题)。
2285
+ try {
2286
+ var tcCfg = sndBuf.turnCost || {}
2287
+ if (tcVol && tcVol.input && tcCfg.volSet !== true) {
2288
+ var eff = soundVolumeOf('turnCost')
2289
+ tcVol.input.value = String(eff)
2290
+ if (tcVol.pct) tcVol.pct.textContent = Math.round(eff * 100) + '%'
2291
+ }
2292
+ } catch (err) {}
2184
2293
  // v773:摘要与"置灰状态"永远一起刷(所有既有刷新点都会走到这里:开面板 / 卡内 input·change /
2185
2294
  // 点文档任意处 / 「恢复默认」末尾)—— 所以开关一动,相关设置立刻跟着灰/亮,不需要另挂监听。
2186
2295
  applySndDisabled()
@@ -2385,7 +2494,13 @@ function openSoundSettingsPanel() {
2385
2494
  fillSoundSelect(teSel, bufTaskEndSel)
2386
2495
  // 「静音」项删除后下拉不该留空:把兜底后的真实值回写缓冲(旧数据 sel 为空串时兜底成列表里的默认音)
2387
2496
  if (teSel.value) bufTaskEndSel = teSel.value
2388
- var tcVol = volSlider(function () { return sndBuf.turnCost.vol }, function (v) { sndBuf.turnCost.vol = v })
2497
+ // v778:②区「提示音量」—— 拖动即视为**用户显式设置**(volSet = true),此后该事件用自己的值;
2498
+ // 没拖过则保持"跟随①区按压音量"(见 soundVolumeOf),摘要里会写明,避免再出现"滑块看着能调、其实无效"。
2499
+ var tcVol = volSlider(function () { return sndBuf.turnCost.vol }, function (v) {
2500
+ sndBuf.turnCost.vol = v
2501
+ sndBuf.turnCost.volSet = true
2502
+ try { refreshSummaries() } catch (err) {}
2503
+ })
2389
2504
  var rTcVol = row('提示音量', tcEnt.body)
2390
2505
  rTcVol.appendChild(tcVol.input)
2391
2506
  rTcVol.appendChild(tcVol.pct)
@@ -2480,6 +2595,9 @@ function openSoundSettingsPanel() {
2480
2595
  setTurnCostClose(Math.round(snapTurnCostCloseMs / 1000))
2481
2596
  }
2482
2597
  } catch (err) {}
2598
+ // v777:底部「点按角色关闭提示气泡」——回滚缓冲与控件(它只活在缓冲里,没保存过就没落盘)
2599
+ try { bufWaitCharClose = snapWaitCharClose } catch (err) {}
2600
+ try { if (waitCharChk) waitCharChk.checked = snapWaitCharClose } catch (err) {}
2483
2601
  }
2484
2602
  try { audioGroupPanel.style.zIndex = '' } catch (err) {}
2485
2603
  try { audioEditMask.style.zIndex = '' } catch (err) {}
@@ -2540,7 +2658,12 @@ function openSoundSettingsPanel() {
2540
2658
  usageSet.taskEnd = te
2541
2659
  try { applyTaskEndLocal(te.on, te.sel) } catch (err) {}
2542
2660
  mergeEventsLocal(ev)
2543
- saveUsageSettings({ events: ev, taskEnd: te })
2661
+ // v777:底部「点按角色关闭提示气泡」跟着同一次 PUT 落盘(键 = settings.wait.charClose)
2662
+ try {
2663
+ usageSet = usageSet || {}
2664
+ usageSet.wait = Object.assign({}, usageSet.wait || {}, { charClose: !!bufWaitCharClose })
2665
+ } catch (err) {}
2666
+ saveUsageSettings({ events: ev, taskEnd: te, wait: { charClose: !!bufWaitCharClose } })
2544
2667
  // v763:按压音量 / 音效总开关 / 每轮消耗提示开关这三项**不在**上面这次 PUT 的覆盖范围里
2545
2668
  //(它们存在 .dsh-size.json,走 saveConfig())⇒ 保存时必须各自按**当前内存值**提交一次,
2546
2669
  // 否则「恢复默认」对它们不生效(用户会觉得恢复默认对音量/开关没作用)。
@@ -2601,6 +2724,11 @@ function openSoundSettingsPanel() {
2601
2724
  bufTaskEndSel = 'frag:end_a' // = 宿主 usageSettingsDefaults().taskEnd.sel
2602
2725
  bufTaskEndKeep = 'frag:end_a' // = 宿主 usageSettingsDefaults().taskEnd.sel
2603
2726
  bufTaskEndOn = true // v774:② 音效行的 [✓] 复位(宿主 taskEnd.on 默认 true)
2727
+ // v778:②区「提示音量」也复位成"跟随①区"(volSet=false;滑块显示由 refreshSummaries 同步成①区值)
2728
+ try { sndBuf.turnCost.volSet = false } catch (err) {}
2729
+ // v777:底部「点按角色关闭提示气泡」恢复出厂默认(false = 只有点泡泡能收)
2730
+ bufWaitCharClose = false
2731
+ try { if (waitCharChk) waitCharChk.checked = false } catch (err) {}
2604
2732
  // 控件回填
2605
2733
  try { pressOnChk.checked = true } catch (err) {}
2606
2734
  try { soundToggle.checked = true } catch (err) {} // v763:与 setSoundOn() 同步的那只开关(只改内存显示)
@@ -2639,6 +2767,54 @@ function openSoundSettingsPanel() {
2639
2767
  // · usageSet.events 的 lines(提问/授权提示内容)不再被「恢复默认」清空(与按钮 title 一致)
2640
2768
  } catch (err) {}
2641
2769
  }
2770
+ // v777(用户要求):「取消/恢复默认/保存」上面一行 —— 与「自定义泡泡」窗口的
2771
+ // 「点按角色推进泡泡队列」同款排版([✓] + 标签 + ? 圈圈),说明收进 ? 圈圈里(复用 v647 的 dshwvAskDot)。
2772
+ var waitCharRow = document.createElement('div')
2773
+ waitCharRow.className = 'dshwv-bubsec'
2774
+ waitCharRow.style.display = 'flex'
2775
+ waitCharRow.style.alignItems = 'center'
2776
+ waitCharRow.style.flexWrap = 'wrap'
2777
+ waitCharRow.style.gap = '4px 6px'
2778
+ waitCharRow.style.color = '#203170'
2779
+ waitCharRow.style.marginTop = '8px'
2780
+ // v777c(2026-09-27 追加要求):
2781
+ // ① 与上面那几行(入口行 `dshwvFoldEntry`,如「授权提示」)的复选框**左对齐** —— 入口行是
2782
+ // `rowEl.style.padding = '6px 6px'` 直接挂在 card 上 ⇒ 复选框在 6px 处;本行 `dshwv-bubsec`
2783
+ // 没有左内边距(默认 0)⇒ 补 6px 即对齐。(⚠️ 注意区分:展开后**折叠体里**的行额外有
2784
+ // `body.style.paddingLeft = 10px`,在 10px 处;本行是全局开关、不属于任何折叠体,故按入口行对齐。)
2785
+ // ② 本行**不要鼠标悬浮提示**(原生 title):问号圈圈自带的「查看说明」与复选框那句说明都清掉 ——
2786
+ // 说明统一收进问号圈圈的弹层里(悬停/点击问号时才显示),避免两个入口说两遍。
2787
+ waitCharRow.style.paddingLeft = '6px'
2788
+ var waitCharAsk = dshwvAskDot(
2789
+ '等待提问 / 授权的提示气泡:<b>默认点气泡就能收起来</b>(点一下收起,同一条挂起不会再自动弹回;' +
2790
+ '你回答或批准之后、或下一次新的提问 / 授权到来时,照常冒泡并响铃)。<br><br>' +
2791
+ '打开下面这个开关后,<b>点角色(鲸鱼)也能收掉提示气泡</b> —— 和「自定义泡泡」里的' +
2792
+ '「点按角色推进泡泡队列」是同一类手感。<br><br>默认关闭。'
2793
+ )
2794
+ // v777b(2026-09-27 追加要求):? 圈圈放到**文字后面**,并把与文字的间距收紧 ——
2795
+ // .dshwv-askq 自带 margin-right:5px,加上行的 column-gap 6px,放前面时离文字有 11px;
2796
+ // v777b 先用行内 margin-left:-3px 压到约 3px;
2797
+ // v777d(2026-09-27 追加要求「再稍稍右移」):改成 0 ⇒ 间距回到行的自然列间距 6px(右移 3px)。
2798
+ waitCharAsk.style.marginLeft = '0'
2799
+ waitCharAsk.style.marginRight = '0'
2800
+ waitCharAsk.title = '' // v777c:清掉 dshwvAskDot 自带的「查看说明」原生 tooltip(用户要求本行不要悬浮提示)
2801
+ var waitCharChk = document.createElement('input')
2802
+ waitCharChk.type = 'checkbox'
2803
+ waitCharChk.className = 'dshwv-check'
2804
+ waitCharChk.id = 'dshwv-waitcharclose'
2805
+ waitCharChk.checked = bufWaitCharClose
2806
+ // v777c:复选框也不挂原生 tooltip(原来那句"打开后:点角色也能收起…"已写进问号圈圈的说明里)
2807
+ waitCharChk.title = ''
2808
+ waitCharChk.addEventListener('change', function () { bufWaitCharClose = !!waitCharChk.checked })
2809
+ waitCharRow.appendChild(waitCharChk)
2810
+ var waitCharLab = document.createElement('label')
2811
+ waitCharLab.setAttribute('for', 'dshwv-waitcharclose')
2812
+ waitCharLab.style.cursor = 'pointer'
2813
+ waitCharLab.style.fontSize = '12px'
2814
+ waitCharLab.textContent = '点按角色关闭提示气泡'
2815
+ waitCharRow.appendChild(waitCharLab)
2816
+ waitCharRow.appendChild(waitCharAsk)
2817
+ card.appendChild(waitCharRow)
2642
2818
  var btns = document.createElement('div')
2643
2819
  btns.className = 'dshwv-bubbtns'
2644
2820
  var cancelBtn = document.createElement('button')
@@ -3436,6 +3612,33 @@ function openApiModelPanel(modelId) {
3436
3612
  var authInp = apiTextInput(bal.auth == null ? 'Bearer {key}' : bal.auth, '请求头模板,{key} 会被替换成密钥')
3437
3613
  var authRow = apiPanelRow('请求头', authInp)
3438
3614
  card.appendChild(authRow)
3615
+ // v789(issue #190):余额接口不再只能是 GET —— 可选 POST/PUT… + JSON 请求体。
3616
+ // 请求体支持 {key}(密钥)、{base}(Base URL)、{uuid}(下面「请求参数」里的键)
3617
+ var balMethod = apiSelectEl([['GET', 'GET(默认)'], ['POST', 'POST'], ['PUT', 'PUT'], ['PATCH', 'PATCH']],
3618
+ String(bal.method || 'GET').toUpperCase() === 'POST' ? 'POST' : String(bal.method || 'GET').toUpperCase() === 'PUT' ? 'PUT' : String(bal.method || 'GET').toUpperCase() === 'PATCH' ? 'PATCH' : 'GET')
3619
+ var balMethodRow = apiPanelRow('请求方法', balMethod)
3620
+ card.appendChild(balMethodRow)
3621
+ var balBody = apiTextInput(bal.body || '', '如 {"uuid":"{uuid}"}(非 GET 时以 JSON 发送)')
3622
+ var balBodyRow = apiPanelRow('请求体', balBody)
3623
+ card.appendChild(balBodyRow)
3624
+ var prm = (m && m.params) || {}
3625
+ var prmInp = apiTextInput(Object.keys(prm).map(function (k) { return k + '=' + prm[k] }).join(','), '请求体占位符,如 uuid=abc123(逗号分隔)')
3626
+ var prmRow = apiPanelRow('请求参数', prmInp)
3627
+ card.appendChild(prmRow)
3628
+ // v789(issue #189):0.3.15 起的"凭据目的地白名单"唯一例外必须能在界面上勾 ——
3629
+ // 宿主侧 model.allowCustomHost 一直是齐的,缺的就是这个入口(README/FAQ 都在指引用户来勾)。
3630
+ // 勾选/取消都**显式**传 true/false(宿主按 === true / === false 分支,缺省表示"不改动")。
3631
+ var allowHost = document.createElement('input')
3632
+ allowHost.type = 'checkbox'
3633
+ allowHost.checked = !!(m && m.balanceDesc && m.balanceDesc.allowCustomHost === true) || !!(m && m.allowCustomHost === true)
3634
+ var allowHostRow = apiPanelRow('允许把凭据发送到自定义地址', allowHost)
3635
+ card.appendChild(allowHostRow)
3636
+ var allowHint = document.createElement('div')
3637
+ allowHint.className = 'dshwv-bubhint'
3638
+ allowHint.style.margin = '0 0 6px'
3639
+ allowHint.textContent = '默认不勾:地址不是该厂商内置端点时,插件会拒绝把密钥发出去。自建网关(New API / 自托管 / Ollama 等)确认是自己的服务后再勾。' +
3640
+ '该开关只能由本机(回环)来源的写请求置位,远端会话改不了它。'
3641
+ card.appendChild(allowHint)
3439
3642
  var jr = (bal.json || {})
3440
3643
  var jRem = apiTextInput(jr.remaining || '', '如 balance_infos[0].total_balance')
3441
3644
  var jTot = apiTextInput(jr.total || '', '如 data.total_credits')
@@ -3508,8 +3711,24 @@ function openApiModelPanel(modelId) {
3508
3711
  balance: {
3509
3712
  url: (balUrl.value || '').trim(),
3510
3713
  auth: (authInp.value || '').trim(),
3714
+ // v789:显式带上方法/请求体(GET + 空体 = 老行为;宿主侧空值不覆盖模板)
3715
+ method: (balMethod.value || 'GET').toUpperCase(),
3716
+ body: (balBody.value || '').trim(),
3511
3717
  json: json,
3512
3718
  },
3719
+ // v789(issue #189):显式 true/false(不是"仅 true 才传")—— 用户要能**取消**勾选
3720
+ allowCustomHost: !!allowHost.checked,
3721
+ params: (function () {
3722
+ var o = {}
3723
+ String(prmInp.value || '').split(',').forEach(function (kv) {
3724
+ var i = kv.indexOf('=')
3725
+ if (i <= 0) return
3726
+ var k = kv.slice(0, i).trim()
3727
+ var v = kv.slice(i + 1).trim()
3728
+ if (/^[A-Za-z_][A-Za-z0-9_]{0,30}$/.test(k)) o[k] = v
3729
+ })
3730
+ return o
3731
+ })(),
3513
3732
  matchIds: (matchInp.value || '').split(',').map(function (s) { return s.trim() }).filter(function (s) { return s.length > 0 }),
3514
3733
  price: { hit: (pHit.value || '').trim(), miss: (pMiss.value || '').trim(), out: (pOut.value || '').trim(), cur: pCur.value, rate: (pRate.value || '').trim() },
3515
3734
  },
@@ -4854,7 +5073,8 @@ function resPlayFragment(fid) {
4854
5073
  if (!fid) return
4855
5074
  if (resAudEl) { try { resAudEl.pause() } catch (err) {} resAudEl = null }
4856
5075
  var a = dshwvSound('/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(fid))
4857
- try { a.volume = soundVolClamped(0.9) } catch (err) {}
5076
+ // v778:资源窗口的片段试听也走音量解析器(它是"这一条片段本身的试听",与按压音量同源)
5077
+ try { a.volume = soundVolumeOf('press') } catch (err) {}
4858
5078
  a.onended = function () { resAudEl = null }
4859
5079
  resAudEl = a
4860
5080
  a.play().catch(function () { resAudEl = null })
@@ -6346,7 +6566,7 @@ var bubbleMoreListEl = null
6346
6566
  var bubbleFirstChipEl = null
6347
6567
  var BUBBLE_KIND_LABEL = { normal: '余额内容', random: '随机语句', custom: '自定义内容' }
6348
6568
  // v209 默认泡泡内容 = 与开发者当前线上生效配置一致(全新安装/恢复默认时即此体验)
6349
- // —— 首次点击:标题文本 + 余额数值 + 今日已用 + 峰谷时段
6569
+ // —— 首次点击:标题文本 + 总余额 + 今日已用 + 峰谷时段
6350
6570
  function bubbleDefaultFirstModules() {
6351
6571
  // v630:normal/首次 兜底默认 = v615 冻结出厂默认第 1 泡(靛蓝余额卡 5 模块),与「恢复默认」一致;
6352
6572
  // 以下旧体(macaron 版)仅在常量缺失时作兜底参照,不再作为默认内容
@@ -7177,6 +7397,13 @@ var BUBBLE_DEFAULT_ITEMS = [
7177
7397
  "rgb": "macaron",
7178
7398
  "italic": true,
7179
7399
  "ul": false
7400
+ },
7401
+ {
7402
+ "t": "token 来!",
7403
+ "w": 3,
7404
+ "size": 16,
7405
+ "rgb": "candy",
7406
+ "color": ""
7180
7407
  }
7181
7408
  ],
7182
7409
  "size": 8
@@ -7469,7 +7696,10 @@ function bubbleDefaultModules(kind) {
7469
7696
  }
7470
7697
  function bubbleModuleSummary(m) {
7471
7698
  m = m || {}
7472
- if (m.type === 'balance') return bubbleIsModelMod(m) ? ('余额·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '余额数值'
7699
+ if (m.type === 'balance') return bubbleIsModelMod(m) ? ('余额·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '总余额(充值金额+赠金)'
7700
+ // v782:赠金 / 充值余额(均为内置数值模块,无 modelId);v787 按用户要求改显示文案
7701
+ if (m.type === 'bonus') return '赠金'
7702
+ if (m.type === 'recharge') return '充值余额'
7473
7703
  if (m.type === 'today') return bubbleIsModelMod(m) ? ('今日已用·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '今日已用'
7474
7704
  if (m.type === 'session') return '对话名' + (Number(m.len) > 0 ? '(保留 ' + Math.round(Number(m.len)) + ' 字)' : '(不截断)')
7475
7705
  if (m.type === 'quota') return '额度·' + ((apiModelById(m.modelId) || {}).name || m.modelId)
@@ -7487,7 +7717,10 @@ function bubbleModuleListLabel(m) {
7487
7717
  if (m.type === 'text') return '文本: ' + (String(m.text || '').slice(0, 24) || '(空)')
7488
7718
  if (m.type === 'link') return '超链接: ' + (String(m.text || '').slice(0, 24) || '打开链接')
7489
7719
  if (m.type === 'random') return m.name || '随机语句' // 只显示模块名,不展示内部句子
7490
- if (m.type === 'balance') return bubbleIsModelMod(m) ? ('余额·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '余额数值'
7720
+ if (m.type === 'balance') return bubbleIsModelMod(m) ? ('余额·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '总余额(充值金额+赠金)'
7721
+ // v782:赠金 / 充值余额(v787 改显示文案)
7722
+ if (m.type === 'bonus') return '赠金'
7723
+ if (m.type === 'recharge') return '充值余额'
7491
7724
  if (m.type === 'today') return bubbleIsModelMod(m) ? ('今日已用·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '今日已用'
7492
7725
  if (m.type === 'session') return '对话名' + (Number(m.len) > 0 ? '(保留 ' + Math.round(Number(m.len)) + ' 字)' : '(不截断)')
7493
7726
  if (m.type === 'quota') return '额度·' + ((apiModelById(m.modelId) || {}).name || m.modelId)
@@ -8027,7 +8260,31 @@ function bubbleEditorDirty() {
8027
8260
  return bubbleEditorSnap !== JSON.stringify([bubbleEditItems, bubbleLib, bubbleTapAdvChk.checked])
8028
8261
  } catch (err) { return true }
8029
8262
  }
8263
+ // v783:打开编辑器前**先从宿主重读一遍配置**。为什么必须重读:泡泡配置只在页面加载时取一次
8264
+ // (loadBubbleCfg),之后一直用内存里的 bubbleCfg 副本;而桌面端 + 网页端(或两个标签页)会**各持一份**,
8265
+ // 谁最后点「保存」谁就把自己那份写回去 ⇒ 在 A 里删掉的模块(或模块库条目)会被 B 的过期副本**写回来**,
8266
+ // 表现就是"删了很久的自定义模块又冒出来了"。读失败时保持原值(离线/接口异常行为不变)。
8267
+ function refreshBubbleCfgFromHost(cb) {
8268
+ var done = function () { try { cb() } catch (err) {} }
8269
+ try {
8270
+ fetch(BUBBLE_URL, { cache: 'no-store' })
8271
+ .then(function (r) { return r.json() })
8272
+ .then(function (d) {
8273
+ if (d && d.ok && d.config) {
8274
+ bubbleCfg = d.config
8275
+ bubbleLib = (d.config.lib && Array.isArray(d.config.lib)) ? JSON.parse(JSON.stringify(d.config.lib)) : []
8276
+ bubbleTapAdvance = d.config.tapAdvance === true // v727
8277
+ try { applyBubbleCfgSeq() } catch (err) {}
8278
+ }
8279
+ })
8280
+ .catch(function () {})
8281
+ .then(done, done)
8282
+ } catch (err) { done() }
8283
+ }
8030
8284
  function openBubbleEditor() {
8285
+ refreshBubbleCfgFromHost(function () { openBubbleEditorWithCache() })
8286
+ }
8287
+ function openBubbleEditorWithCache() {
8031
8288
  try {
8032
8289
  closeRolePanel()
8033
8290
  closeAudioGroupPanel()
@@ -8214,6 +8471,8 @@ function qeditEnsure() {
8214
8471
  qeditClose()
8215
8472
  }, true)
8216
8473
  document.addEventListener('keydown', function (e) {
8474
+ // v789(issue #179):组字中按 Esc 是输入法的事,别抢
8475
+ if (dshwvComposingNow()) return
8217
8476
  if (e.key === 'Escape') qeditClose()
8218
8477
  })
8219
8478
  }
@@ -8535,8 +8794,8 @@ function openQuickModuleEditor(m, anchorBtn) {
8535
8794
  var isModelBal = bubbleIsModelMod(m) && (m.type === 'balance' || m.type === 'today')
8536
8795
  var isModelQuota = bubbleIsModelMod(m) && m.type === 'quota'
8537
8796
  var isModelPlan = bubbleIsModelMod(m) && m.type === 'plan'
8538
- inp.placeholder = isModelPlan ? '例: {plan} 额度 · {plan_reset} 刷新时间' : (isModelQuota ? '例: 额度 {quota} · 剩 {quota_left}' : (isModelBal ? '例: {balance} 或 今日 {today}' : (m.type === 'balance' ? '例: {balance_ds}' : (m.type === 'today' ? '例: 今日已用 {expense_ds}' : (m.type === 'session' ? '例: {session} 或 当前对话 {session}' : (bubbleIsPeakCount(m) ? '例: 距空闲 {countdown}' : '例: 当前 {status}'))))))
8539
- inp.title = '可用占位符(英文): ' + (isModelPlan ? '{plan} 额度 / {plan_left} 剩余 / {plan_reset} 刷新时间(多窗口时随「显示样式」所选窗口变化)' : (isModelQuota ? '{quota} / {quota_used} / {quota_left} / {quota_total} / {quota_reset}' : (isModelBal ? '{balance} / {today}' : (m.type === 'peak' || m.type === 'nextpeak' ? '{status} / {countdown}' : (m.type === 'balance' ? '{balance_ds}' : (m.type === 'today' ? '{expense_ds}' : (m.type === 'session' ? '{session} 当前对话名(按「保留长度」截断)' : '{expense_ds}')))))))
8797
+ inp.placeholder = isModelPlan ? '例: {plan} 额度 · {plan_reset} 刷新时间' : (isModelQuota ? '例: 额度 {quota} · 剩 {quota_left}' : (isModelBal ? '例: {balance} 或 今日 {today}' : (m.type === 'balance' ? '例: {balance_ds}' : (m.type === 'bonus' ? '例: {bonus_ds} 或 赠金 {bonus_ds}' : (m.type === 'recharge' ? '例: {recharge_ds} 或 余额 {recharge_ds}' : (m.type === 'today' ? '例: 今日已用 {expense_ds}' : (m.type === 'session' ? '例: {session} 或 当前对话 {session}' : (bubbleIsPeakCount(m) ? '例: 距空闲 {countdown}' : '例: 当前 {status}'))))))))
8798
+ inp.title = '可用占位符(英文): ' + (isModelPlan ? '{plan} 额度 / {plan_left} 剩余 / {plan_reset} 刷新时间(多窗口时随「显示样式」所选窗口变化)' : (isModelQuota ? '{quota} / {quota_used} / {quota_left} / {quota_total} / {quota_reset}' : (isModelBal ? '{balance} / {today}' : (m.type === 'peak' || m.type === 'nextpeak' ? '{status} / {countdown}' : (m.type === 'balance' ? '{balance_ds}' : (m.type === 'bonus' ? '{bonus_ds}' : (m.type === 'recharge' ? '{recharge_ds}' : (m.type === 'today' ? '{expense_ds}' : (m.type === 'session' ? '{session} 当前对话名(按「保留长度」截断)' : '{expense_ds}')))))))))
8540
8799
  inp.addEventListener('input', function () { m.tpl = inp.value; changed() })
8541
8800
  r.appendChild(inp)
8542
8801
  var qb2 = document.createElement('button')
@@ -8733,7 +8992,12 @@ function renderBubblePal() {
8733
8992
  bubblePalEl.innerHTML = ''
8734
8993
  var defs = [
8735
8994
  { key: 'text', label: '文本', cb: function () { bubbleModuleAdd({ type: 'text', text: '新内容', size: 6, bold: true }) } },
8736
- { key: 'balance', label: '余额数值', pin: true, cb: function () { bubbleModuleAdd({ type: 'balance', size: 11, tpl: '{balance_ds}' }) } },
8995
+ { key: 'balance', label: '总余额(充值金额+赠金)', pin: true, cb: function () { bubbleModuleAdd({ type: 'balance', size: 11, tpl: '{balance_ds}' }) } },
8996
+ // v782:把「总余额」拆出的两个分量各自做成模块 —— 赠金(未过期赠送余额)与充值余额。
8997
+ // ⚠️ 上面那条 `balance` / `{balance_ds}` 的**代码名字与行为一律不动**(历史泡泡与已保存的模块里
8998
+ // 存的是 type/tpl 字面量,改代码名会让老内容显示不出来);v787 只按用户要求改了**显示文案**。
8999
+ { key: 'bonus', label: '赠金', pin: true, cb: function () { bubbleModuleAdd({ type: 'bonus', size: 11, tpl: '{bonus_ds}' }) } },
9000
+ { key: 'recharge', label: '充值余额', pin: true, cb: function () { bubbleModuleAdd({ type: 'recharge', size: 11, tpl: '{recharge_ds}' }) } },
8737
9001
  { key: 'today', label: '今日已用', pin: true, cb: function () { bubbleModuleAdd({ type: 'today', size: 1, tpl: '今日已用 {expense_ds}' }) } },
8738
9002
  { key: 'peak', label: '峰谷时段', pin: true, cb: function () { bubbleModuleAdd({ type: 'peak', size: 4, peakColor: '#e0433f', offColor: '#2fa24c', tpl: '{status}' }) } },
8739
9003
  { key: 'nextpeak', label: '时段倒计时', pin: true, cb: function () { bubbleModuleAdd({ type: 'peak', size: 6, bold: true, peakStyle: 'count', peakColor: '#e0433f', offColor: '#2fa24c', tpl: '{countdown}' }) } },
@@ -8957,7 +9221,8 @@ function bubbleModuleEdit(m, anchorBtn) {
8957
9221
  try {
8958
9222
  if (m && (m.type === 'text' || m.type === 'link')) { openQuickTextEditor(m, anchorBtn); return }
8959
9223
  // v769:对话名也走悬浮编辑(像文本那样边改边看效果),不再开整窗编辑器
8960
- if (m && (m.type === 'balance' || m.type === 'today' || m.type === 'peak' || m.type === 'nextpeak' || m.type === 'session')) { openQuickModuleEditor(m, anchorBtn); return }
9224
+ // v782:赠金 / 充值余额同样是内置数值模块 ⇒ 也走悬浮编辑(内容锁定,只调字号/颜色/模板)
9225
+ if (m && (m.type === 'balance' || m.type === 'bonus' || m.type === 'recharge' || m.type === 'today' || m.type === 'peak' || m.type === 'nextpeak' || m.type === 'session')) { openQuickModuleEditor(m, anchorBtn); return }
8961
9226
  openModuleEditor(m, function (saved) { if (saved) renderBubblePv() })
8962
9227
  } catch (err) {}
8963
9228
  }
@@ -9056,6 +9321,9 @@ function bubblePvPaletteToRow(key, ri) {
9056
9321
  function bubblePaletteModule(key) {
9057
9322
  if (key === 'text') return { type: 'text', text: '新内容', size: 6, bold: true }
9058
9323
  if (key === 'balance') return { type: 'balance', size: 11, tpl: '{balance_ds}' }
9324
+ // v782:赠金 / 充值余额(拖拽路径;与上面「总余额(充值金额+赠金)」并列的两个内置数值模块)
9325
+ if (key === 'bonus') return { type: 'bonus', size: 11, tpl: '{bonus_ds}' }
9326
+ if (key === 'recharge') return { type: 'recharge', size: 11, tpl: '{recharge_ds}' }
9059
9327
  if (key === 'today') return { type: 'today', size: 1, tpl: '今日已用 {expense_ds}' }
9060
9328
  if (key === 'peak') return { type: 'peak', size: 4, peakColor: '#e0433f', offColor: '#2fa24c', tpl: '{status}' }
9061
9329
  if (key === 'nextpeak') return { type: 'peak', size: 6, bold: true, peakStyle: 'count', peakColor: '#e0433f', offColor: '#2fa24c', tpl: '{countdown}' }
@@ -9522,19 +9790,31 @@ var bubbleTapAdvChk = document.createElement('input')
9522
9790
  bubbleTapAdvChk.type = 'checkbox'
9523
9791
  bubbleTapAdvChk.className = 'dshwv-check'
9524
9792
  bubbleTapAdvChk.id = 'dshwv-tapadv'
9525
- bubbleTapAdvChk.title = '开启后:点一下角色=往后推进一项(不再回到首次点击泡泡);走到最后一项再点=收起泡泡'
9793
+ // v777e(2026-09-27 用户要求):本行复选框**不再挂原生 tooltip**(原来那句"开启后:点一下角色=往后推进一项…"
9794
+ // 与问号圈圈里的说明重复)。说明统一收进 ? 圈圈的弹层里(与「提示与音效设置」面板底部那行一致)。
9795
+ bubbleTapAdvChk.title = ''
9526
9796
  var bubbleTapAdvLab = document.createElement('label')
9527
9797
  bubbleTapAdvLab.setAttribute('for', 'dshwv-tapadv')
9528
9798
  bubbleTapAdvLab.style.cursor = 'pointer'
9529
9799
  bubbleTapAdvLab.style.fontSize = '12px'
9530
9800
  bubbleTapAdvLab.textContent = '点按角色推进泡泡队列'
9531
- var bubbleTapAdvHint = document.createElement('span')
9532
- bubbleTapAdvHint.className = 'dshwv-bubhint'
9533
- bubbleTapAdvHint.style.margin = '0'
9534
- bubbleTapAdvHint.textContent = '(关闭=点角色回到第 1 个泡泡;开启=点一下往后一个)'
9801
+ // v777(用户要求):原来跟在标签后面的那一行说明字(「关闭=点角色回到第 1 个泡泡;开启=点一下往后一个」)
9802
+ // 收进问号圈圈里 —— 复用 v647 的 dshwvAskDot(悬停即显示、点击固定、触摸端点按开关),与
9803
+ // 「提示与音效设置」面板底部那一行同款。说明里顺带把原来 title 里的完整描述也写进去。
9804
+ // v777b(2026-09-27 追加要求):? 圈圈放在**文字后面**,并把与文字的间距收紧(同面板那处)。
9805
+ // v777d:「再稍稍右移」⇒ margin-left 由 -3px 改成 0(间距 = 行的自然列间距 6px)。
9806
+ var bubbleTapAdvAsk = dshwvAskDot(
9807
+ '关闭(默认):<b>点角色</b>=回到第 1 个泡泡(点泡泡才是往后推进)。<br><br>' +
9808
+ '开启:<b>点一下角色 = 往后推进一项</b>(不再回到首次点击泡泡);走到最后一项再点=收起泡泡。'
9809
+ )
9810
+ bubbleTapAdvAsk.style.marginLeft = '0'
9811
+ bubbleTapAdvAsk.style.marginRight = '0'
9812
+ // v777e:与面板底部那行同款处理 —— 清掉问号圈圈自带的「查看说明」原生 tooltip,
9813
+ // 说明只在悬停/点击问号时以自绘弹层出现(本行鼠标悬浮不再出现浏览器小黄条)。
9814
+ bubbleTapAdvAsk.title = ''
9535
9815
  bubbleTapAdvRow.appendChild(bubbleTapAdvChk)
9536
9816
  bubbleTapAdvRow.appendChild(bubbleTapAdvLab)
9537
- bubbleTapAdvRow.appendChild(bubbleTapAdvHint)
9817
+ bubbleTapAdvRow.appendChild(bubbleTapAdvAsk)
9538
9818
  bubbleCard.appendChild(bubbleTapAdvRow)
9539
9819
  // 按钮行
9540
9820
  var bubbleBtns = document.createElement('div')
@@ -10125,7 +10405,10 @@ function bubbleFontEditRow(getVal, setVal) {
10125
10405
  return row
10126
10406
  }
10127
10407
  function moduleTypeName(t, m) {
10128
- if (t === 'balance') return '余额数值'
10408
+ if (t === 'balance') return '总余额(充值金额+赠金)'
10409
+ // v782:赠金 / 充值余额两个数值模块的类型名(v787 改显示文案)
10410
+ if (t === 'bonus') return '赠金'
10411
+ if (t === 'recharge') return '充值余额'
10129
10412
  if (t === 'today') return '今日已用'
10130
10413
  if (t === 'session') return '对话名'
10131
10414
  if (t === 'peak' || t === 'nextpeak') {
@@ -10173,6 +10456,8 @@ function renderModuleEditor() {
10173
10456
  inp.value = m.tpl || ''
10174
10457
  function hintOf() {
10175
10458
  if (m.type === 'balance') return '例: {balance_ds}'
10459
+ if (m.type === 'bonus') return '例: {bonus_ds} 或 赠金 {bonus_ds}'
10460
+ if (m.type === 'recharge') return '例: {recharge_ds} 或 余额 {recharge_ds}'
10176
10461
  if (m.type === 'today') return '例: 今日已用 {expense_ds}'
10177
10462
  if (m.type === 'session') return '例: {session} 或 当前对话 {session}'
10178
10463
  if (bubbleIsPeakCount(m)) return '例: 距空闲 {countdown}'
@@ -11575,6 +11860,42 @@ bubbleBox.addEventListener('click', function (e) {
11575
11860
  bubbleNext()
11576
11861
  })
11577
11862
 
11863
+ // ===== v785:桌面客户端「窗口控件带」不进入挂件的可移动范围 =====
11864
+ // 背景(用户 2026-10-02 反馈):客户端右上角的最小化/最大化/关闭会盖在泡泡上方。
11865
+ // 查证:DSH 桌面端产品窗口用 Electron `titleBarStyle:'hidden'` + **`titleBarOverlay`**
11866
+ // (主进程写死 height:40;macOS 是 `hiddenInset` + 左上 traffic light)—— 那三个键由**浏览器引擎**
11867
+ // 画在所有页面内容之上 ⇒ **CSS/z-index 永远盖不过去**(不是我们层级写低了)。
11868
+ // 修法(用户指定):不做层级对抗、也不平移泡泡,而是**把整条顶带排除在挂件的可移动范围之外**:
11869
+ // 挂件顶边(root.top)一律 ≥ 叠层下沿 ⇒ 画在 root 内部的泡泡自然也在带下,
11870
+ // 而鲸鱼与泡泡的相对位置**完全不变**(比"泡泡自己下移"更干净)。
11871
+ var DESKTOP_TITLEBAR_FALLBACK = 44 // 主进程 titleBarOverlay.height = 40,+4 余量
11872
+ function desktopTitlebarBottom() {
11873
+ var wco = null
11874
+ try { wco = navigator.windowControlsOverlay } catch (err) { wco = null }
11875
+ // 没有叠层对象(普通浏览器 / 手机浏览器)或叠层不可见 ⇒ 不需要排除
11876
+ if (!wco || wco.visible === false) return 0
11877
+ // 有叠层:优先用官方矩形;取不到(旧内核/异常)就退回主进程里那个高度,**不能返回 0**
11878
+ try {
11879
+ if (typeof wco.getTitlebarAreaRect === 'function') {
11880
+ var r = wco.getTitlebarAreaRect()
11881
+ if (r && isFinite(r.y) && isFinite(r.height) && r.height > 0) return Math.max(0, Math.round(r.y + r.height))
11882
+ }
11883
+ } catch (err) {}
11884
+ return DESKTOP_TITLEBAR_FALLBACK
11885
+ }
11886
+ // 挂件顶边的**下限**(0 = 不限制,普通浏览器就是 0)
11887
+ function widgetTopMin() {
11888
+ try { return Math.max(0, desktopTitlebarBottom()) } catch (err) { return 0 }
11889
+ }
11890
+ // 把「挂件顶边」夹进可移动范围:[顶带下沿, 视口内上限]
11891
+ function clampWidgetTop(v, maxT) {
11892
+ var min = widgetTopMin()
11893
+ var t = Number(v)
11894
+ if (!isFinite(t)) t = min
11895
+ var hi = Infinity
11896
+ if (maxT !== undefined && isFinite(Number(maxT))) hi = Math.max(min, Number(maxT))
11897
+ return Math.max(min, Math.min(t, hi))
11898
+ }
11578
11899
  var body = document.createElement('div')
11579
11900
  body.className = 'dshwv-body'
11580
11901
  body.appendChild(img)
@@ -11675,6 +11996,10 @@ var state = {
11675
11996
  top: 0,
11676
11997
  balance: null,
11677
11998
  currency: null,
11999
+ // v781:余额的两个分量(赠金 / 充值余额)。宿主在 API key 路与 DSH 账号路都会下发;
12000
+ // 取不到时保持 null ⇒ 模块显示 `—`(见 bubbleBonusText/bubbleRechargeText)。
12001
+ bonusBalance: null,
12002
+ rechargeBalance: null,
11678
12003
  todayUsage: null,
11679
12004
  todayUsageCurrency: 'CNY',
11680
12005
  usageLabel: '本地估算',
@@ -11927,6 +12252,32 @@ function restoreBubbleLines() {
11927
12252
  // 消耗=showCostBubble)独立触发, 消耗优先级最高可顶掉当前并暂停手动轮。
11928
12253
  var costBubbleTimer = null // 旧版计时器保留声明(新版 bubbleClearAll 仍清理)
11929
12254
  var bubbleTtlTimer = null
12255
+ // v789(issue #188/#172):泡泡的自动收起**不能只靠 setTimeout** —— 页面被遮挡 / 最小化 / 切到后台时,
12256
+ // 浏览器会把定时器节流甚至挂起,于是"5 秒后收起"变成几十秒甚至永远不收(用户实测:点击泡一直不消失、
12257
+ // 刷新即恢复)。所以同时记一个**截止时刻**,在页面重新可见 / 拿到焦点时结算一次;再配一个每秒巡检兜底。
12258
+ var bubbleTtlDeadline = 0
12259
+ function bubbleTtlArmed(ttlMs) {
12260
+ try { if (ttlMs > 0) bubbleTtlDeadline = Date.now() + ttlMs; else bubbleTtlDeadline = 0 } catch (err) { bubbleTtlDeadline = 0 }
12261
+ }
12262
+ function bubbleTtlClear() { bubbleTtlDeadline = 0 }
12263
+ // 巡检:只在"本该已经到期"且计时器已经不在了(被节流/挂起)时补收一次
12264
+ function bubbleTtlSweep() {
12265
+ try {
12266
+ if (bubbleTtlTimer || !bubbleTtlDeadline) return
12267
+ if (!bubbleScene || !(bubbleScene.ttlMs > 0)) { bubbleTtlDeadline = 0; return }
12268
+ if (Date.now() < bubbleTtlDeadline) return
12269
+ bubbleTtlDeadline = 0
12270
+ bubbleAutoClose()
12271
+ } catch (err) {}
12272
+ }
12273
+ // 三条触发路径:页面重新可见(切标签回来)、窗口重新拿到焦点(最小化还原)、以及每秒一次的兜底巡检。
12274
+ // 正常前台运行时计时器按时收敛,巡检永远提前返回(不会误收下一个泡泡)。
12275
+ try {
12276
+ document.addEventListener('visibilitychange', function () { try { if (!document.hidden) bubbleTtlSweep() } catch (err) {} })
12277
+ window.addEventListener('focus', function () { bubbleTtlSweep() })
12278
+ window.addEventListener('pageshow', function () { bubbleTtlSweep() })
12279
+ setInterval(bubbleTtlSweep, 1000)
12280
+ } catch (err) {}
11930
12281
  var bubbleScene = null // { kind:'normal'|'random'|'cost', ttlMs }
11931
12282
  var bubbleSeq = bubbleDefaultQueue() // 默认序列 = 与开发者线上生效一致(首次=余额,再次=随机语句);有配置后由 applyBubbleCfgSeq 覆盖
11932
12283
  var bubbleSeqIdx = 0 // 下一项下标(手动轮内推进)
@@ -12148,6 +12499,8 @@ function bubbleClearAll() {
12148
12499
  try { if (settleTimer) { clearTimeout(settleTimer); settleTimer = null } } catch (err) {}
12149
12500
  }
12150
12501
  function bubbleCloseVisual() {
12502
+ // v789(issue #188):视觉收起是"泡泡真的收掉了"的唯一出口 ⇒ 顺带把截止时刻清零(三处对齐之二)
12503
+ bubbleTtlClear()
12151
12504
  // v771:泡泡一旦真的收起,等待标记必须一起复位。`waitShown` 平时由 bubbleRenderModules 按场景重算,
12152
12505
  // 但"整泡关闭"(hideBubble/hideCostBubble/hideWaitBubble…)不走渲染 ⇒ 不复位就会残留成 true,
12153
12506
  // 之后 showWaitBubble 会误判"已经在显示等待泡泡"(历史上正是这类不同步把等待泡泡卡死的)。
@@ -12196,7 +12549,8 @@ function sceneOpen(kind, renderFn, ttlMs) {
12196
12549
  }, 180)
12197
12550
  } catch (err) {}
12198
12551
  }
12199
- if (ttlMs > 0) bubbleTtlTimer = setTimeout(bubbleAutoClose, ttlMs)
12552
+ if (ttlMs > 0) { bubbleTtlArmed(ttlMs); bubbleTtlTimer = setTimeout(bubbleAutoClose, ttlMs) }
12553
+ else bubbleTtlClear()
12200
12554
  }
12201
12555
  if (wasOpen) {
12202
12556
  // 内容切换:旧内容先淡出,再换新内容并淡入(可被新场景/关闭随时打断)
@@ -12216,6 +12570,8 @@ function sceneOpen(kind, renderFn, ttlMs) {
12216
12570
  }
12217
12571
  function bubbleAutoClose() {
12218
12572
  bubbleTtlTimer = null
12573
+ // v789:走完这条就说明本次 TTL 已经结算,截止时刻必须清零,否则巡检会误收下一个泡泡
12574
+ bubbleTtlClear()
12219
12575
  if (bubbleScene && bubbleScene.kind === 'cost') { if (whaleSysSwapNext()) return; hideCostBubble(); return }
12220
12576
  if (bubbleScene && bubbleScene.kind === 'alert') { if (whaleSysSwapNext()) return; hideUsageAlertBubble(); return }
12221
12577
  // v761(#161 C5):等待交互泡泡是**常驻**的(sceneOpen 收到 ttl 0 不布计时器),正常永远走不到这里;
@@ -12226,7 +12582,9 @@ function bubbleAutoClose() {
12226
12582
  // 重置当前泡泡的留存计时(点鲸鱼给第 1 泡续时,不清内容)
12227
12583
  function bubbleResetTtl() {
12228
12584
  try { if (bubbleTtlTimer) { clearTimeout(bubbleTtlTimer); bubbleTtlTimer = null } } catch (err) {}
12229
- if (bubbleScene && bubbleScene.ttlMs > 0) bubbleTtlTimer = setTimeout(bubbleAutoClose, bubbleScene.ttlMs)
12585
+ // v789:续时也要同步截止时刻,否则巡检会在"刚续过"的泡泡上提前动作(issue #188 提醒的三处对齐之一)
12586
+ if (bubbleScene && bubbleScene.ttlMs > 0) { bubbleTtlArmed(bubbleScene.ttlMs); bubbleTtlTimer = setTimeout(bubbleAutoClose, bubbleScene.ttlMs) }
12587
+ else bubbleTtlClear()
12230
12588
  }
12231
12589
  // 默认内容视图 = 现在待机内容(余额/今日已用),由 render/restore 维护
12232
12590
  function bubbleRenderDefault() { restoreBubbleLines() }
@@ -12291,8 +12649,11 @@ function bubbleShowSeqNext() {
12291
12649
  }
12292
12650
  }
12293
12651
  // ===== 模块渲染引擎(B1) =====
12294
- // 模块:{type:'text'|'balance'|'today'|'peak'|'session'|'image'|'random', text?, imgId?, color?, size?(1..8 档),
12652
+ // 模块:{type:'text'|'balance'|'bonus'|'recharge'|'today'|'peak'|'session'|'image'|'random', text?, imgId?, color?, size?(1..8 档),
12295
12653
  // lines?:[{t,w}] (random 自带句子列表), tpl?(占位符模板), len?(session 的「保留长度」,0=不截断)}
12654
+ // v782 新增 'bonus'(赠金)与 'recharge'(充值余额):与 'balance' 并列的内置数值模块,
12655
+ // 分别用占位符 {bonus_ds} / {recharge_ds};**'balance' / '{balance_ds}' 的名字与行为一律不动**
12656
+ // (历史泡泡里存的是这些字面量,改名会让老内容显示不出来)。
12296
12657
  // 字号档位 1..50,线性细分(1→40u … 50→240u,相对 --dshw-u 的倍数)
12297
12658
  function bubbleModuleFontU(level) {
12298
12659
  var n = Number(level) || 6
@@ -12307,6 +12668,19 @@ function bubbleAmountText() {
12307
12668
  function bubbleTodayText() {
12308
12669
  return (state.usageLabel || '今日已用') + ' ' + (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.todayUsageCurrency || state.currency) : '--')
12309
12670
  }
12671
+ // v782:赠金 / 充值余额两个数值模块的取数(与「总余额(充值金额+赠金)」同源:宿主余额返回体)。
12672
+ // ⚠️ 两个数字**可能拿不到**(厂商没有该字段、或未登录账号)⇒ 显示 `—`,**不显示 0**
12673
+ // —— 0 的意思是"确实没有了",与"读不到"是两件事(记账也依赖这个区分)。
12674
+ function bubbleBonusText() {
12675
+ var v = state.bonusBalance
12676
+ if (v === null || v === undefined || !isFinite(Number(v))) return '—'
12677
+ return fmt(Number(v), state.currency)
12678
+ }
12679
+ function bubbleRechargeText() {
12680
+ var v = state.rechargeBalance
12681
+ if (v === null || v === undefined || !isFinite(Number(v))) return '—'
12682
+ return fmt(Number(v), state.currency)
12683
+ }
12310
12684
  // 模块「内容」模板:占位符统一英文(便于兼容其他模型 API 时区分来源/字段):
12311
12685
  // {expense_ds} 今日已用金额 · {balance_ds} 余额 · {status} 高峰/空闲状态字 · {countdown} 倒计时
12312
12686
  // —— 自定义 API 模型的余额/今日已用(Phase 3)——
@@ -12676,6 +13050,12 @@ function bubbleContentTokenMap(m) {
12676
13050
  if (m.type === 'balance') {
12677
13051
  v = bubbleAmountText()
12678
13052
  map['balance_ds'] = v
13053
+ } else if (m.type === 'bonus') {
13054
+ // v782:赠金模块({bonus_ds})。取不到数 = '—',不显示 0。
13055
+ map['bonus_ds'] = bubbleBonusText()
13056
+ } else if (m.type === 'recharge') {
13057
+ // v782:充值余额模块({recharge_ds})。
13058
+ map['recharge_ds'] = bubbleRechargeText()
12679
13059
  } else if (m.type === 'today') {
12680
13060
  v = (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.todayUsageCurrency || state.currency) : '--')
12681
13061
  map['expense_ds'] = v
@@ -12713,7 +13093,9 @@ function bubbleTplHelpItems(m) {
12713
13093
  add('plan_reset', '订阅额度刷新倒计时(同上;全部窗口时为紧凑倒计时)')
12714
13094
  return arr
12715
13095
  }
12716
- if (m.type === 'balance') add('balance_ds', '余额数值')
13096
+ if (m.type === 'balance') add('balance_ds', '总余额(充值金额+赠金)')
13097
+ if (m.type === 'bonus') add('bonus_ds', '赠金余额(未过期的赠送余额;取不到显示 —)')
13098
+ if (m.type === 'recharge') add('recharge_ds', '充值余额(不含赠金;取不到显示 —)')
12717
13099
  else if (m.type === 'today') add('expense_ds', '今日已用金额')
12718
13100
  else if (m.type === 'session') add('session', '当前对话名(超过「保留长度」会截断为 前N字...)')
12719
13101
  else if (m.type === 'peak' || m.type === 'nextpeak') {
@@ -12736,7 +13118,7 @@ function bubbleTplHelpToggle(m, anchor) {
12736
13118
  } catch (err) {}
12737
13119
  dshwvTplHelpEl.style.display = 'none'
12738
13120
  }, true)
12739
- document.addEventListener('keydown', function (e) { if (e.key === 'Escape') dshwvTplHelpEl.style.display = 'none' })
13121
+ document.addEventListener('keydown', function (e) { if (dshwvComposingNow()) return; if (e.key === 'Escape') dshwvTplHelpEl.style.display = 'none' })
12740
13122
  }
12741
13123
  if (dshwvTplHelpEl.style.display === 'block') { dshwvTplHelpEl.style.display = 'none'; return }
12742
13124
  var items = bubbleTplHelpItems(m)
@@ -12780,7 +13162,7 @@ function dshwvHintEnsure() {
12780
13162
  } catch (err) {}
12781
13163
  dshwvHintHide()
12782
13164
  }, true)
12783
- document.addEventListener('keydown', function (e) { if (e.key === 'Escape') dshwvHintHide() })
13165
+ document.addEventListener('keydown', function (e) { if (dshwvComposingNow()) return; if (e.key === 'Escape') dshwvHintHide() })
12784
13166
  return dshwvHintEl
12785
13167
  }
12786
13168
  function dshwvHintShow(html, anchor, pinned) {
@@ -13111,6 +13493,9 @@ function bubbleRowContentOf(mod) {
13111
13493
  var bv = bubbleIsModelMod(mod) ? apiModelBalanceText(mod.modelId) : bubbleAmountText()
13112
13494
  return { txt: bubbleContentText(mod, bv), line: null }
13113
13495
  }
13496
+ // v782:赠金 / 充值余额两个数值模块(与「总余额(充值金额+赠金)」并列,取不到数时自动文案是 —)
13497
+ if (mod.type === 'bonus') return { txt: bubbleContentText(mod, bubbleBonusText()), line: null }
13498
+ if (mod.type === 'recharge') return { txt: bubbleContentText(mod, bubbleRechargeText()), line: null }
13114
13499
  if (mod.type === 'today') {
13115
13500
  var tv2 = bubbleIsModelMod(mod) ? apiModelTodayText(mod.modelId) : (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.todayUsageCurrency || state.currency) : '--')
13116
13501
  return { txt: bubbleContentText(mod, '今日已用 ' + tv2), line: null }
@@ -13503,6 +13888,12 @@ function whaleClick() {
13503
13888
  bubbleShowSeqNext()
13504
13889
  return
13505
13890
  }
13891
+ // v777:等待交互提示期间点角色 —— 开关「点按角色关闭提示气泡」打开时,点角色=收起这条等待提示
13892
+ //(默认关:保持"点角色不动等待提示",只有点泡泡才关)。收起来后 pollWaitState 不会把它弹回来。
13893
+ if (bubbleScene && bubbleScene.kind === 'wait') {
13894
+ if (waitCharCloseOn()) dismissWaitBubble()
13895
+ return
13896
+ }
13506
13897
  // v727:开启「点按角色推进泡泡队列」→ 点角色=往后推进一项(不再回到第 1 项);
13507
13898
  // 已是最后一项时与「点泡泡」一致:收起泡泡,下次点按从第 1 项开始。
13508
13899
  if (bubbleTapAdvance) { bubbleNext(); return }
@@ -13523,9 +13914,12 @@ function bubbleNext() {
13523
13914
  if (!bubbleShown) return
13524
13915
  if (bubbleScene && bubbleScene.kind === 'cost') { hideCostBubble(); return }
13525
13916
  if (bubbleScene && bubbleScene.kind === 'alert') { hideUsageAlertBubble(); return }
13526
- // v761(#161 C5):等待交互泡泡必须**常驻到被回答/批准**——点它不关(点击泡泡 = 推进/收起是给
13527
- // 手动轮的语义);避免用户误点把「正在等待你的回答」收掉、而后台其实还挂着。
13528
- if (bubbleScene && bubbleScene.kind === 'wait') return
13917
+ // v777:等待交互泡泡**可以点掉**(用户反馈「无法点击关闭」)。原来的实现在这里是 `return`
13918
+ //(设计意图是"别让误点把'正在等待你回答'收掉"),但真机上用户就是想把它点掉 ⇒ 改成:
13919
+ // 点泡泡 = 收起,并记住"这条挂起已被点掉",pollWaitState 不再自动弹回(回答/批准或下一次新挂起照常)。
13920
+ if (bubbleScene && bubbleScene.kind === 'wait') { dismissWaitBubble(); return }
13921
+ // v761(#161 C5)曾在此处"等待交互泡泡点它不关"(必须等被回答/批准)——
13922
+ // v777 按用户反馈改成"可点关",语义与实现见上面的 dismissWaitBubble();这里不再拦截。
13529
13923
  if (bubbleRoundOn && bubbleSeqIdx < bubbleSeq.length) { bubbleShowSeqNext(); return }
13530
13924
  hideBubble()
13531
13925
  } catch (err) {}
@@ -13552,6 +13946,13 @@ function hideBubble() {
13552
13946
  }
13553
13947
  function showCostBubble(amount) {
13554
13948
  if (!bubbleOn || !turnCostOn) return
13949
+ // v778:②区「冒泡提示」勾选框(events.turnCost.bubbleOn)以前**只写不读** —— 取消勾选照样弹。
13950
+ // 同类问题("控件有值、没有消费方")与本次修的音量滑块同源,一并接上:
13951
+ // 缺省(未设置/旧数据)= 照常冒泡,只有**显式**取消勾选才不弹 ⇒ 老用户行为不变。
13952
+ try {
13953
+ var tcCfg = soundEventCfg('turnCost') || {}
13954
+ if (tcCfg.bubbleOn === false) return
13955
+ } catch (err) {}
13555
13956
  // 进入系统泡泡队列(等级3):若同批有预警/预算,则排在它们之后展示
13556
13957
  whaleSysPush({ kind: 'cost', amount: amount, rank: 3 })
13557
13958
  }
@@ -13769,6 +14170,23 @@ function hideWaitBubble() {
13769
14170
  if (whaleSysQueue.length !== beforeLen) whaleSysTick()
13770
14171
  } catch (err) {}
13771
14172
  }
14173
+ // v777(用户反馈「授权提示/提问提示无法点击关闭」):
14174
+ // · 默认:**点泡泡**即可关掉等待气泡(点角色不管,除非打开下面的开关);
14175
+ // · 开关「点按角色关闭提示气泡」打开后:**点角色**也能关;
14176
+ // · 关掉的是"这条挂起"——宿主每秒还会下发同一条挂起(pollWaitState 是每分钟…不是,是每秒对齐一次),
14177
+ // 所以必须记住"用户把这条点掉了、别再自动弹回",否则关掉后 1 秒内又回来 = 看起来根本关不掉。
14178
+ var waitDismissedId = '' // 被用户点掉的挂起 id('' = 没有)
14179
+ var waitPendingId = '' // 宿主最近一次上报的挂起 id(用于"点掉的就是当前这条")
14180
+ function waitCharCloseOn() {
14181
+ try { return !!(((usageSet || {}).wait || {}).charClose) } catch (err) { return false }
14182
+ }
14183
+ // 用户主动收起等待气泡:记住这条挂起已被点掉,然后收起
14184
+ function dismissWaitBubble() {
14185
+ try {
14186
+ if (waitPendingId) waitDismissedId = waitPendingId
14187
+ hideWaitBubble()
14188
+ } catch (err) {}
14189
+ }
13772
14190
  function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v) }
13773
14191
  function viewport() {
13774
14192
  return {
@@ -13846,6 +14264,10 @@ function express() {
13846
14264
  root.style.right = 'auto'
13847
14265
  root.style.bottom = 'auto'
13848
14266
  root.style.left = state.left + 'px'
14267
+ // v785:最后一道保险 —— 所有"写 state.top"的路径最终都会走到这里,统一把顶边夹出"窗口控件带"
14268
+ // (拖动/吸附/settle 各自也夹了一次;这里兜住启动、自愈、外部直接改 state 等路径)。
14269
+ // 见 clampWidgetTop:下限 = 叠层下沿(普通浏览器为 0 ⇒ 与以前完全一致)。
14270
+ state.top = clampWidgetTop(state.top)
13849
14271
  root.style.top = state.top + 'px'
13850
14272
  root.classList.toggle('dshwv-left', !!state.flip)
13851
14273
  }
@@ -13856,7 +14278,7 @@ function settle() {
13856
14278
  if (drag && drag.active) {
13857
14279
  // mid-drag resize: keep the pointer-follow position, just clamp into view
13858
14280
  state.left = clamp(state.left, 0, Math.max(0, vp.w - w - rightGap()))
13859
- state.top = clamp(state.top, 0, Math.max(0, vp.h - h))
14281
+ state.top = clampWidgetTop(state.top, Math.max(0, vp.h - h))
13860
14282
  express()
13861
14283
  return
13862
14284
  }
@@ -13880,22 +14302,22 @@ function settle() {
13880
14302
  }
13881
14303
  if (state.v === 'bottom') {
13882
14304
  var rawB = vp.h - h - state.vOff
13883
- state.top = clamp(rawB, 0, maxT)
14305
+ state.top = clampWidgetTop(rawB, maxT)
13884
14306
  if (state.top !== rawB) outOfRange = true
13885
14307
  } else if (state.v === 'top') {
13886
14308
  var rawT = state.vOff
13887
- state.top = clamp(rawT, 0, maxT)
14309
+ state.top = clampWidgetTop(rawT, maxT)
13888
14310
  if (state.top !== rawT) outOfRange = true
13889
14311
  } else {
13890
- state.top = clamp(state.top, 0, maxT)
14312
+ state.top = clampWidgetTop(state.top, maxT)
13891
14313
  }
13892
14314
  // 偏移确实越界(脏数据 / 视口变小):先把偏移夹回合法范围再落盘,下次启动不再复现。
13893
14315
  // 只在真的越界时写,正常 resize 不会产生额外的 localStorage 写入。
13894
14316
  if (outOfRange) {
13895
14317
  if (state.h === 'right') state.hOff = clamp(state.hOff, 0, Math.max(0, vp.w - w - rightGap()))
13896
14318
  else if (state.h === 'left') state.hOff = clamp(state.hOff, 0, maxLAnchor)
13897
- if (state.v === 'bottom') state.vOff = clamp(state.vOff, 0, maxT)
13898
- else if (state.v === 'top') state.vOff = clamp(state.vOff, 0, maxT)
14319
+ if (state.v === 'bottom') state.vOff = clamp(state.vOff, 0, Math.max(0, maxT - widgetTopMin()))
14320
+ else if (state.v === 'top') state.vOff = clamp(state.vOff, widgetTopMin(), maxT)
13899
14321
  try { saveAnchorPos() } catch (err) {}
13900
14322
  }
13901
14323
  refreshFlip()
@@ -14003,6 +14425,9 @@ function refresh(manual) {
14003
14425
  balanceRetryLeft = 2
14004
14426
  state.todayUsage = data.todayUsage !== undefined ? data.todayUsage : null
14005
14427
  state.todayUsageCurrency = data.todayUsageCurrency || data.currency || 'CNY'
14428
+ // v781:赠金 / 充值余额(宿主两条路都下发;缺失给 null ⇒ 模块显示 —)
14429
+ state.bonusBalance = isFinite(Number(data.bonusBalance)) ? Number(data.bonusBalance) : null
14430
+ state.rechargeBalance = isFinite(Number(data.rechargeBalance)) ? Number(data.rechargeBalance) : null
14006
14431
  state.usageLabel = data.usageLabel || '本地估算'
14007
14432
  if (data.stale) state.usageLabel += ' · 余额未刷新'
14008
14433
  state.isPeak = !!data.isPeak
@@ -14037,6 +14462,9 @@ function refresh(manual) {
14037
14462
  } else {
14038
14463
  state.status = 'error'
14039
14464
  state.message = (data && data.error) ? String(data.error) : '获取失败'
14465
+ // v781:把宿主的错误码也打到控制台 —— "只登录客户端读不到余额"这类反馈里,
14466
+ // 界面只显示一句人类可读的文案,而 code(NO_KEY / BOTH_FAILED / SHAPE …)才是排查入口。
14467
+ try { console.warn('[dsh-whale] 余额读取失败', (data && data.code) || '', state.message) } catch (err) {}
14040
14468
  render()
14041
14469
  balanceRetryLater()
14042
14470
  }
@@ -14292,7 +14720,7 @@ function setScale(v) {
14292
14720
  } else {
14293
14721
  state.left = Math.min(Math.max(fx - r2.width, 0), Math.max(0, vp.w - r2.width))
14294
14722
  }
14295
- state.top = Math.min(Math.max(fy - r2.height, 0), Math.max(0, vp.h - r2.height))
14723
+ state.top = clampWidgetTop(Math.min(Math.max(fy - r2.height, 0), Math.max(0, vp.h - r2.height)))
14296
14724
  express()
14297
14725
  // 恢复过渡必须延迟到下一帧:本帧 left/top 已在 none 下设置并提交,
14298
14726
  // 立即恢复会让浏览器对「刚改过的 left/top」重新评估并播放过渡动画
@@ -16303,7 +16731,8 @@ function onDocPointerMove(e) {
16303
16731
  // were); on release endDrag() recomputes the anchors and settle() flips the
16304
16732
  // class with a smooth transition instead of reverting instantly.
16305
16733
  state.left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w))
16306
- state.top = clamp(drag.origTop + dy, 0, Math.max(0, drag.vp.h - drag.h))
16734
+ // v785:拖拽时就把"桌面端窗口控件带"排除在可移动范围外(顶边下限 = 叠层下沿)
16735
+ state.top = clampWidgetTop(drag.origTop + dy, Math.max(0, drag.vp.h - drag.h))
16307
16736
  express()
16308
16737
  }
16309
16738
  function onDocPointerUp(e) {
@@ -16555,7 +16984,8 @@ function endDrag(e, clickAllowed, cancelled) {
16555
16984
  var dx = e.clientX - drag.startX
16556
16985
  var dy = e.clientY - drag.startY
16557
16986
  var left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w))
16558
- var top = clamp(drag.origTop + dy, 0, Math.max(0, drag.vp.h - drag.h))
16987
+ // v785:落位计算也要先排除"窗口控件带"(否则吸附判定点会用到带内的 top)
16988
+ var top = clampWidgetTop(drag.origTop + dy, Math.max(0, drag.vp.h - drag.h))
16559
16989
  // 自定义吸附区(比例/绝对/关闭)。判定点:左右吸附/翻转 = 图像中心 x,
16560
16990
  // 下吸附 = 图像中心 y,上吸附 = 挂件盒中心 y。
16561
16991
  // 解析计算,避免拖动结束过渡期强制布局。
@@ -16611,7 +17041,8 @@ function applyAnchorPos() {
16611
17041
  var l = a.hAnchor === 'left' ? hDist : vp.w - effectiveRightDist - w
16612
17042
  var t = a.vAnchor === 'top' ? vDist : vp.h - vDist - h
16613
17043
  state.left = clamp(l, 0, maxOffH)
16614
- state.top = clamp(t, 0, maxOffV)
17044
+ // v785:锚点自愈/恢复也要排除"窗口控件带"(这条路径不一定紧跟 express,所以就地夹)
17045
+ state.top = clampWidgetTop(clamp(t, 0, maxOffV), maxOffV)
16615
17046
  state.h = a.hAnchor
16616
17047
  // 净距离直接还给 hOff/vOff:settle() 对锚定状态从偏移量重算,
16617
17048
  // 若置 0 会把刚恢复的距离覆盖成贴边(issue #43)
@@ -16783,17 +17214,27 @@ function pollWaitState() {
16783
17214
  if (!p || !p.kind) {
16784
17215
  // 挂起已解除:收起泡泡,并允许下一次挂起照常响
16785
17216
  waitSeenId = ''
17217
+ waitPendingId = '' // v777:挂起解除 ⇒ "被点掉"的标记一并作废(下一次挂起照常冒泡)
17218
+ waitDismissedId = ''
16786
17219
  if (typeof hideWaitBubble === 'function') hideWaitBubble()
16787
17220
  return
16788
17221
  }
16789
17222
  var kind = p.kind === 'approval' ? 'approval' : 'question'
16790
17223
  var ev = waitEventCfg(kind)
17224
+ // v777:这条挂起已经被用户点掉了 ⇒ 不再自动弹回(但仍然照常响铃判定与去重;回答/批准后
17225
+ // 上面的 !p 分支会把标记清掉,所以"下一次新的提问/授权"照常冒泡)。
17226
+ waitPendingId = String(p.id || '')
17227
+ if (waitPendingId && waitPendingId === waitDismissedId) {
17228
+ // 什么都不做:既不显示,也不清 waitSeenId(同一挂起不重复响)
17229
+ } else {
17230
+ if (ev.bubbleOn !== false && typeof showWaitBubble === 'function') showWaitBubble(kind, p)
17231
+ }
16791
17232
  // v771:**每秒都把泡泡与宿主的挂起状态对齐一次**(showWaitBubble 幂等:同类直接返回 false,
16792
17233
  // 不会重渲染)。旧实现只在"新 id"时才调显示,于是一旦出现瞬时不同步(换类型 / 泡泡被别的
16793
17234
  // 场景顶掉 / 某次渲染失败),泡泡就会卡在那个状态再也回不来 —— 这正是真机那次卡死的另一半原因。
16794
17235
  // 现在任何不同步都会在 1 秒内自愈:该换内容就换、该收就收(收在下面 !p 分支)。
16795
17236
  // 门控照旧:该事件的「冒泡提示」关掉就不冒泡(与声音各自独立判)。
16796
- if (ev.bubbleOn !== false && typeof showWaitBubble === 'function') showWaitBubble(kind, p)
17237
+ // v777:显示那一步已上移到"被点掉的挂起"判定里(见上面的 waitDismissedId 分支),这里不再重复调用。
16797
17238
  // 声音:同一个未回答的挂起只响一次(刷新页面也不重复响)
16798
17239
  var key = kind + ':' + String(p.id || '')
16799
17240
  var remembered = ''