dsh-whale-widget 0.3.17 → 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.
package/README.md CHANGED
@@ -50,7 +50,8 @@ DeepSeek Harness(DSH)Web 界面右下角的常驻挂件:小鲸鱼气泡图
50
50
  - **随机语句**(多条句子带权重,每次显示抽 1 条且不连续重复)
51
51
  - **图片/动图**(从泡泡图库选,独占一整行)
52
52
  - **随机图片**(多张图带权重,抽 1 张且不连续重复,同样独占一行)
53
- - 余额数值、今日已用、峰谷时段、**对话名**(内置数值,内容自动获取,可调占位符与样式;对话名可设「**保留长度**」,超出部分显示为 `...`,`0` = 不截断)
53
+ - **总余额(充值金额+赠金)**、**赠金**、**充值余额**、今日已用、峰谷时段、**对话名**(内置数值,内容自动获取,可调占位符与样式;对话名可设「**保留长度**」,超出部分显示为 `...`,`0` = 不截断)
54
+ - 三个余额类模块的分工:**总余额(充值金额+赠金)** = 充值余额 + 赠金(`{balance_ds}`,老配置里的模块照旧显示,无需重配);**赠金**(`{bonus_ds}`)与**充值余额**(`{recharge_ds}`)是单独拆出来的两个模块,取不到数时显示 `—`(真的为 0 才显示 `0.00`)
54
55
  - 🎨 **逐模块样式**:字体(含自定义字体)、字号、加粗/斜体/下划线、纯色或**跑马灯渐变**配色、底色;文本与随机语句支持悬浮快捷编辑
55
56
  - 🖐️ **拖拽排版**:桌面端原生拖拽、移动端长按拖拽;模块可并入某行首/尾、可拆行、整行可排序;**每行最多 6 个模块、泡泡最多 6 行**,图片类模块独占一行且一个泡泡只允许一个
56
57
  - 📚 **模块库**:把常用模块"另存"进库,之后在任意泡泡里点击或拖入复用
@@ -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() })
@@ -3576,6 +3612,33 @@ function openApiModelPanel(modelId) {
3576
3612
  var authInp = apiTextInput(bal.auth == null ? 'Bearer {key}' : bal.auth, '请求头模板,{key} 会被替换成密钥')
3577
3613
  var authRow = apiPanelRow('请求头', authInp)
3578
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)
3579
3642
  var jr = (bal.json || {})
3580
3643
  var jRem = apiTextInput(jr.remaining || '', '如 balance_infos[0].total_balance')
3581
3644
  var jTot = apiTextInput(jr.total || '', '如 data.total_credits')
@@ -3648,8 +3711,24 @@ function openApiModelPanel(modelId) {
3648
3711
  balance: {
3649
3712
  url: (balUrl.value || '').trim(),
3650
3713
  auth: (authInp.value || '').trim(),
3714
+ // v789:显式带上方法/请求体(GET + 空体 = 老行为;宿主侧空值不覆盖模板)
3715
+ method: (balMethod.value || 'GET').toUpperCase(),
3716
+ body: (balBody.value || '').trim(),
3651
3717
  json: json,
3652
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
+ })(),
3653
3732
  matchIds: (matchInp.value || '').split(',').map(function (s) { return s.trim() }).filter(function (s) { return s.length > 0 }),
3654
3733
  price: { hit: (pHit.value || '').trim(), miss: (pMiss.value || '').trim(), out: (pOut.value || '').trim(), cur: pCur.value, rate: (pRate.value || '').trim() },
3655
3734
  },
@@ -6487,7 +6566,7 @@ var bubbleMoreListEl = null
6487
6566
  var bubbleFirstChipEl = null
6488
6567
  var BUBBLE_KIND_LABEL = { normal: '余额内容', random: '随机语句', custom: '自定义内容' }
6489
6568
  // v209 默认泡泡内容 = 与开发者当前线上生效配置一致(全新安装/恢复默认时即此体验)
6490
- // —— 首次点击:标题文本 + 余额数值 + 今日已用 + 峰谷时段
6569
+ // —— 首次点击:标题文本 + 总余额 + 今日已用 + 峰谷时段
6491
6570
  function bubbleDefaultFirstModules() {
6492
6571
  // v630:normal/首次 兜底默认 = v615 冻结出厂默认第 1 泡(靛蓝余额卡 5 模块),与「恢复默认」一致;
6493
6572
  // 以下旧体(macaron 版)仅在常量缺失时作兜底参照,不再作为默认内容
@@ -7318,6 +7397,13 @@ var BUBBLE_DEFAULT_ITEMS = [
7318
7397
  "rgb": "macaron",
7319
7398
  "italic": true,
7320
7399
  "ul": false
7400
+ },
7401
+ {
7402
+ "t": "token 来!",
7403
+ "w": 3,
7404
+ "size": 16,
7405
+ "rgb": "candy",
7406
+ "color": ""
7321
7407
  }
7322
7408
  ],
7323
7409
  "size": 8
@@ -7610,7 +7696,10 @@ function bubbleDefaultModules(kind) {
7610
7696
  }
7611
7697
  function bubbleModuleSummary(m) {
7612
7698
  m = m || {}
7613
- 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 '充值余额'
7614
7703
  if (m.type === 'today') return bubbleIsModelMod(m) ? ('今日已用·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '今日已用'
7615
7704
  if (m.type === 'session') return '对话名' + (Number(m.len) > 0 ? '(保留 ' + Math.round(Number(m.len)) + ' 字)' : '(不截断)')
7616
7705
  if (m.type === 'quota') return '额度·' + ((apiModelById(m.modelId) || {}).name || m.modelId)
@@ -7628,7 +7717,10 @@ function bubbleModuleListLabel(m) {
7628
7717
  if (m.type === 'text') return '文本: ' + (String(m.text || '').slice(0, 24) || '(空)')
7629
7718
  if (m.type === 'link') return '超链接: ' + (String(m.text || '').slice(0, 24) || '打开链接')
7630
7719
  if (m.type === 'random') return m.name || '随机语句' // 只显示模块名,不展示内部句子
7631
- 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 '充值余额'
7632
7724
  if (m.type === 'today') return bubbleIsModelMod(m) ? ('今日已用·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '今日已用'
7633
7725
  if (m.type === 'session') return '对话名' + (Number(m.len) > 0 ? '(保留 ' + Math.round(Number(m.len)) + ' 字)' : '(不截断)')
7634
7726
  if (m.type === 'quota') return '额度·' + ((apiModelById(m.modelId) || {}).name || m.modelId)
@@ -8168,7 +8260,31 @@ function bubbleEditorDirty() {
8168
8260
  return bubbleEditorSnap !== JSON.stringify([bubbleEditItems, bubbleLib, bubbleTapAdvChk.checked])
8169
8261
  } catch (err) { return true }
8170
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
+ }
8171
8284
  function openBubbleEditor() {
8285
+ refreshBubbleCfgFromHost(function () { openBubbleEditorWithCache() })
8286
+ }
8287
+ function openBubbleEditorWithCache() {
8172
8288
  try {
8173
8289
  closeRolePanel()
8174
8290
  closeAudioGroupPanel()
@@ -8355,6 +8471,8 @@ function qeditEnsure() {
8355
8471
  qeditClose()
8356
8472
  }, true)
8357
8473
  document.addEventListener('keydown', function (e) {
8474
+ // v789(issue #179):组字中按 Esc 是输入法的事,别抢
8475
+ if (dshwvComposingNow()) return
8358
8476
  if (e.key === 'Escape') qeditClose()
8359
8477
  })
8360
8478
  }
@@ -8676,8 +8794,8 @@ function openQuickModuleEditor(m, anchorBtn) {
8676
8794
  var isModelBal = bubbleIsModelMod(m) && (m.type === 'balance' || m.type === 'today')
8677
8795
  var isModelQuota = bubbleIsModelMod(m) && m.type === 'quota'
8678
8796
  var isModelPlan = bubbleIsModelMod(m) && m.type === 'plan'
8679
- 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}'))))))
8680
- 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}')))))))))
8681
8799
  inp.addEventListener('input', function () { m.tpl = inp.value; changed() })
8682
8800
  r.appendChild(inp)
8683
8801
  var qb2 = document.createElement('button')
@@ -8874,7 +8992,12 @@ function renderBubblePal() {
8874
8992
  bubblePalEl.innerHTML = ''
8875
8993
  var defs = [
8876
8994
  { key: 'text', label: '文本', cb: function () { bubbleModuleAdd({ type: 'text', text: '新内容', size: 6, bold: true }) } },
8877
- { 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}' }) } },
8878
9001
  { key: 'today', label: '今日已用', pin: true, cb: function () { bubbleModuleAdd({ type: 'today', size: 1, tpl: '今日已用 {expense_ds}' }) } },
8879
9002
  { key: 'peak', label: '峰谷时段', pin: true, cb: function () { bubbleModuleAdd({ type: 'peak', size: 4, peakColor: '#e0433f', offColor: '#2fa24c', tpl: '{status}' }) } },
8880
9003
  { key: 'nextpeak', label: '时段倒计时', pin: true, cb: function () { bubbleModuleAdd({ type: 'peak', size: 6, bold: true, peakStyle: 'count', peakColor: '#e0433f', offColor: '#2fa24c', tpl: '{countdown}' }) } },
@@ -9098,7 +9221,8 @@ function bubbleModuleEdit(m, anchorBtn) {
9098
9221
  try {
9099
9222
  if (m && (m.type === 'text' || m.type === 'link')) { openQuickTextEditor(m, anchorBtn); return }
9100
9223
  // v769:对话名也走悬浮编辑(像文本那样边改边看效果),不再开整窗编辑器
9101
- 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 }
9102
9226
  openModuleEditor(m, function (saved) { if (saved) renderBubblePv() })
9103
9227
  } catch (err) {}
9104
9228
  }
@@ -9197,6 +9321,9 @@ function bubblePvPaletteToRow(key, ri) {
9197
9321
  function bubblePaletteModule(key) {
9198
9322
  if (key === 'text') return { type: 'text', text: '新内容', size: 6, bold: true }
9199
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}' }
9200
9327
  if (key === 'today') return { type: 'today', size: 1, tpl: '今日已用 {expense_ds}' }
9201
9328
  if (key === 'peak') return { type: 'peak', size: 4, peakColor: '#e0433f', offColor: '#2fa24c', tpl: '{status}' }
9202
9329
  if (key === 'nextpeak') return { type: 'peak', size: 6, bold: true, peakStyle: 'count', peakColor: '#e0433f', offColor: '#2fa24c', tpl: '{countdown}' }
@@ -10278,7 +10405,10 @@ function bubbleFontEditRow(getVal, setVal) {
10278
10405
  return row
10279
10406
  }
10280
10407
  function moduleTypeName(t, m) {
10281
- if (t === 'balance') return '余额数值'
10408
+ if (t === 'balance') return '总余额(充值金额+赠金)'
10409
+ // v782:赠金 / 充值余额两个数值模块的类型名(v787 改显示文案)
10410
+ if (t === 'bonus') return '赠金'
10411
+ if (t === 'recharge') return '充值余额'
10282
10412
  if (t === 'today') return '今日已用'
10283
10413
  if (t === 'session') return '对话名'
10284
10414
  if (t === 'peak' || t === 'nextpeak') {
@@ -10326,6 +10456,8 @@ function renderModuleEditor() {
10326
10456
  inp.value = m.tpl || ''
10327
10457
  function hintOf() {
10328
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}'
10329
10461
  if (m.type === 'today') return '例: 今日已用 {expense_ds}'
10330
10462
  if (m.type === 'session') return '例: {session} 或 当前对话 {session}'
10331
10463
  if (bubbleIsPeakCount(m)) return '例: 距空闲 {countdown}'
@@ -11728,6 +11860,42 @@ bubbleBox.addEventListener('click', function (e) {
11728
11860
  bubbleNext()
11729
11861
  })
11730
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
+ }
11731
11899
  var body = document.createElement('div')
11732
11900
  body.className = 'dshwv-body'
11733
11901
  body.appendChild(img)
@@ -11828,6 +11996,10 @@ var state = {
11828
11996
  top: 0,
11829
11997
  balance: null,
11830
11998
  currency: null,
11999
+ // v781:余额的两个分量(赠金 / 充值余额)。宿主在 API key 路与 DSH 账号路都会下发;
12000
+ // 取不到时保持 null ⇒ 模块显示 `—`(见 bubbleBonusText/bubbleRechargeText)。
12001
+ bonusBalance: null,
12002
+ rechargeBalance: null,
11831
12003
  todayUsage: null,
11832
12004
  todayUsageCurrency: 'CNY',
11833
12005
  usageLabel: '本地估算',
@@ -12080,6 +12252,32 @@ function restoreBubbleLines() {
12080
12252
  // 消耗=showCostBubble)独立触发, 消耗优先级最高可顶掉当前并暂停手动轮。
12081
12253
  var costBubbleTimer = null // 旧版计时器保留声明(新版 bubbleClearAll 仍清理)
12082
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) {}
12083
12281
  var bubbleScene = null // { kind:'normal'|'random'|'cost', ttlMs }
12084
12282
  var bubbleSeq = bubbleDefaultQueue() // 默认序列 = 与开发者线上生效一致(首次=余额,再次=随机语句);有配置后由 applyBubbleCfgSeq 覆盖
12085
12283
  var bubbleSeqIdx = 0 // 下一项下标(手动轮内推进)
@@ -12301,6 +12499,8 @@ function bubbleClearAll() {
12301
12499
  try { if (settleTimer) { clearTimeout(settleTimer); settleTimer = null } } catch (err) {}
12302
12500
  }
12303
12501
  function bubbleCloseVisual() {
12502
+ // v789(issue #188):视觉收起是"泡泡真的收掉了"的唯一出口 ⇒ 顺带把截止时刻清零(三处对齐之二)
12503
+ bubbleTtlClear()
12304
12504
  // v771:泡泡一旦真的收起,等待标记必须一起复位。`waitShown` 平时由 bubbleRenderModules 按场景重算,
12305
12505
  // 但"整泡关闭"(hideBubble/hideCostBubble/hideWaitBubble…)不走渲染 ⇒ 不复位就会残留成 true,
12306
12506
  // 之后 showWaitBubble 会误判"已经在显示等待泡泡"(历史上正是这类不同步把等待泡泡卡死的)。
@@ -12349,7 +12549,8 @@ function sceneOpen(kind, renderFn, ttlMs) {
12349
12549
  }, 180)
12350
12550
  } catch (err) {}
12351
12551
  }
12352
- if (ttlMs > 0) bubbleTtlTimer = setTimeout(bubbleAutoClose, ttlMs)
12552
+ if (ttlMs > 0) { bubbleTtlArmed(ttlMs); bubbleTtlTimer = setTimeout(bubbleAutoClose, ttlMs) }
12553
+ else bubbleTtlClear()
12353
12554
  }
12354
12555
  if (wasOpen) {
12355
12556
  // 内容切换:旧内容先淡出,再换新内容并淡入(可被新场景/关闭随时打断)
@@ -12369,6 +12570,8 @@ function sceneOpen(kind, renderFn, ttlMs) {
12369
12570
  }
12370
12571
  function bubbleAutoClose() {
12371
12572
  bubbleTtlTimer = null
12573
+ // v789:走完这条就说明本次 TTL 已经结算,截止时刻必须清零,否则巡检会误收下一个泡泡
12574
+ bubbleTtlClear()
12372
12575
  if (bubbleScene && bubbleScene.kind === 'cost') { if (whaleSysSwapNext()) return; hideCostBubble(); return }
12373
12576
  if (bubbleScene && bubbleScene.kind === 'alert') { if (whaleSysSwapNext()) return; hideUsageAlertBubble(); return }
12374
12577
  // v761(#161 C5):等待交互泡泡是**常驻**的(sceneOpen 收到 ttl 0 不布计时器),正常永远走不到这里;
@@ -12379,7 +12582,9 @@ function bubbleAutoClose() {
12379
12582
  // 重置当前泡泡的留存计时(点鲸鱼给第 1 泡续时,不清内容)
12380
12583
  function bubbleResetTtl() {
12381
12584
  try { if (bubbleTtlTimer) { clearTimeout(bubbleTtlTimer); bubbleTtlTimer = null } } catch (err) {}
12382
- 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()
12383
12588
  }
12384
12589
  // 默认内容视图 = 现在待机内容(余额/今日已用),由 render/restore 维护
12385
12590
  function bubbleRenderDefault() { restoreBubbleLines() }
@@ -12444,8 +12649,11 @@ function bubbleShowSeqNext() {
12444
12649
  }
12445
12650
  }
12446
12651
  // ===== 模块渲染引擎(B1) =====
12447
- // 模块:{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 档),
12448
12653
  // lines?:[{t,w}] (random 自带句子列表), tpl?(占位符模板), len?(session 的「保留长度」,0=不截断)}
12654
+ // v782 新增 'bonus'(赠金)与 'recharge'(充值余额):与 'balance' 并列的内置数值模块,
12655
+ // 分别用占位符 {bonus_ds} / {recharge_ds};**'balance' / '{balance_ds}' 的名字与行为一律不动**
12656
+ // (历史泡泡里存的是这些字面量,改名会让老内容显示不出来)。
12449
12657
  // 字号档位 1..50,线性细分(1→40u … 50→240u,相对 --dshw-u 的倍数)
12450
12658
  function bubbleModuleFontU(level) {
12451
12659
  var n = Number(level) || 6
@@ -12460,6 +12668,19 @@ function bubbleAmountText() {
12460
12668
  function bubbleTodayText() {
12461
12669
  return (state.usageLabel || '今日已用') + ' ' + (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.todayUsageCurrency || state.currency) : '--')
12462
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
+ }
12463
12684
  // 模块「内容」模板:占位符统一英文(便于兼容其他模型 API 时区分来源/字段):
12464
12685
  // {expense_ds} 今日已用金额 · {balance_ds} 余额 · {status} 高峰/空闲状态字 · {countdown} 倒计时
12465
12686
  // —— 自定义 API 模型的余额/今日已用(Phase 3)——
@@ -12829,6 +13050,12 @@ function bubbleContentTokenMap(m) {
12829
13050
  if (m.type === 'balance') {
12830
13051
  v = bubbleAmountText()
12831
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()
12832
13059
  } else if (m.type === 'today') {
12833
13060
  v = (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.todayUsageCurrency || state.currency) : '--')
12834
13061
  map['expense_ds'] = v
@@ -12866,7 +13093,9 @@ function bubbleTplHelpItems(m) {
12866
13093
  add('plan_reset', '订阅额度刷新倒计时(同上;全部窗口时为紧凑倒计时)')
12867
13094
  return arr
12868
13095
  }
12869
- 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', '充值余额(不含赠金;取不到显示 —)')
12870
13099
  else if (m.type === 'today') add('expense_ds', '今日已用金额')
12871
13100
  else if (m.type === 'session') add('session', '当前对话名(超过「保留长度」会截断为 前N字...)')
12872
13101
  else if (m.type === 'peak' || m.type === 'nextpeak') {
@@ -12889,7 +13118,7 @@ function bubbleTplHelpToggle(m, anchor) {
12889
13118
  } catch (err) {}
12890
13119
  dshwvTplHelpEl.style.display = 'none'
12891
13120
  }, true)
12892
- 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' })
12893
13122
  }
12894
13123
  if (dshwvTplHelpEl.style.display === 'block') { dshwvTplHelpEl.style.display = 'none'; return }
12895
13124
  var items = bubbleTplHelpItems(m)
@@ -12933,7 +13162,7 @@ function dshwvHintEnsure() {
12933
13162
  } catch (err) {}
12934
13163
  dshwvHintHide()
12935
13164
  }, true)
12936
- document.addEventListener('keydown', function (e) { if (e.key === 'Escape') dshwvHintHide() })
13165
+ document.addEventListener('keydown', function (e) { if (dshwvComposingNow()) return; if (e.key === 'Escape') dshwvHintHide() })
12937
13166
  return dshwvHintEl
12938
13167
  }
12939
13168
  function dshwvHintShow(html, anchor, pinned) {
@@ -13264,6 +13493,9 @@ function bubbleRowContentOf(mod) {
13264
13493
  var bv = bubbleIsModelMod(mod) ? apiModelBalanceText(mod.modelId) : bubbleAmountText()
13265
13494
  return { txt: bubbleContentText(mod, bv), line: null }
13266
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 }
13267
13499
  if (mod.type === 'today') {
13268
13500
  var tv2 = bubbleIsModelMod(mod) ? apiModelTodayText(mod.modelId) : (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.todayUsageCurrency || state.currency) : '--')
13269
13501
  return { txt: bubbleContentText(mod, '今日已用 ' + tv2), line: null }
@@ -14032,6 +14264,10 @@ function express() {
14032
14264
  root.style.right = 'auto'
14033
14265
  root.style.bottom = 'auto'
14034
14266
  root.style.left = state.left + 'px'
14267
+ // v785:最后一道保险 —— 所有"写 state.top"的路径最终都会走到这里,统一把顶边夹出"窗口控件带"
14268
+ // (拖动/吸附/settle 各自也夹了一次;这里兜住启动、自愈、外部直接改 state 等路径)。
14269
+ // 见 clampWidgetTop:下限 = 叠层下沿(普通浏览器为 0 ⇒ 与以前完全一致)。
14270
+ state.top = clampWidgetTop(state.top)
14035
14271
  root.style.top = state.top + 'px'
14036
14272
  root.classList.toggle('dshwv-left', !!state.flip)
14037
14273
  }
@@ -14042,7 +14278,7 @@ function settle() {
14042
14278
  if (drag && drag.active) {
14043
14279
  // mid-drag resize: keep the pointer-follow position, just clamp into view
14044
14280
  state.left = clamp(state.left, 0, Math.max(0, vp.w - w - rightGap()))
14045
- state.top = clamp(state.top, 0, Math.max(0, vp.h - h))
14281
+ state.top = clampWidgetTop(state.top, Math.max(0, vp.h - h))
14046
14282
  express()
14047
14283
  return
14048
14284
  }
@@ -14066,22 +14302,22 @@ function settle() {
14066
14302
  }
14067
14303
  if (state.v === 'bottom') {
14068
14304
  var rawB = vp.h - h - state.vOff
14069
- state.top = clamp(rawB, 0, maxT)
14305
+ state.top = clampWidgetTop(rawB, maxT)
14070
14306
  if (state.top !== rawB) outOfRange = true
14071
14307
  } else if (state.v === 'top') {
14072
14308
  var rawT = state.vOff
14073
- state.top = clamp(rawT, 0, maxT)
14309
+ state.top = clampWidgetTop(rawT, maxT)
14074
14310
  if (state.top !== rawT) outOfRange = true
14075
14311
  } else {
14076
- state.top = clamp(state.top, 0, maxT)
14312
+ state.top = clampWidgetTop(state.top, maxT)
14077
14313
  }
14078
14314
  // 偏移确实越界(脏数据 / 视口变小):先把偏移夹回合法范围再落盘,下次启动不再复现。
14079
14315
  // 只在真的越界时写,正常 resize 不会产生额外的 localStorage 写入。
14080
14316
  if (outOfRange) {
14081
14317
  if (state.h === 'right') state.hOff = clamp(state.hOff, 0, Math.max(0, vp.w - w - rightGap()))
14082
14318
  else if (state.h === 'left') state.hOff = clamp(state.hOff, 0, maxLAnchor)
14083
- if (state.v === 'bottom') state.vOff = clamp(state.vOff, 0, maxT)
14084
- 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)
14085
14321
  try { saveAnchorPos() } catch (err) {}
14086
14322
  }
14087
14323
  refreshFlip()
@@ -14189,6 +14425,9 @@ function refresh(manual) {
14189
14425
  balanceRetryLeft = 2
14190
14426
  state.todayUsage = data.todayUsage !== undefined ? data.todayUsage : null
14191
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
14192
14431
  state.usageLabel = data.usageLabel || '本地估算'
14193
14432
  if (data.stale) state.usageLabel += ' · 余额未刷新'
14194
14433
  state.isPeak = !!data.isPeak
@@ -14223,6 +14462,9 @@ function refresh(manual) {
14223
14462
  } else {
14224
14463
  state.status = 'error'
14225
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) {}
14226
14468
  render()
14227
14469
  balanceRetryLater()
14228
14470
  }
@@ -14478,7 +14720,7 @@ function setScale(v) {
14478
14720
  } else {
14479
14721
  state.left = Math.min(Math.max(fx - r2.width, 0), Math.max(0, vp.w - r2.width))
14480
14722
  }
14481
- 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)))
14482
14724
  express()
14483
14725
  // 恢复过渡必须延迟到下一帧:本帧 left/top 已在 none 下设置并提交,
14484
14726
  // 立即恢复会让浏览器对「刚改过的 left/top」重新评估并播放过渡动画
@@ -16489,7 +16731,8 @@ function onDocPointerMove(e) {
16489
16731
  // were); on release endDrag() recomputes the anchors and settle() flips the
16490
16732
  // class with a smooth transition instead of reverting instantly.
16491
16733
  state.left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w))
16492
- 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))
16493
16736
  express()
16494
16737
  }
16495
16738
  function onDocPointerUp(e) {
@@ -16741,7 +16984,8 @@ function endDrag(e, clickAllowed, cancelled) {
16741
16984
  var dx = e.clientX - drag.startX
16742
16985
  var dy = e.clientY - drag.startY
16743
16986
  var left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w))
16744
- 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))
16745
16989
  // 自定义吸附区(比例/绝对/关闭)。判定点:左右吸附/翻转 = 图像中心 x,
16746
16990
  // 下吸附 = 图像中心 y,上吸附 = 挂件盒中心 y。
16747
16991
  // 解析计算,避免拖动结束过渡期强制布局。
@@ -16797,7 +17041,8 @@ function applyAnchorPos() {
16797
17041
  var l = a.hAnchor === 'left' ? hDist : vp.w - effectiveRightDist - w
16798
17042
  var t = a.vAnchor === 'top' ? vDist : vp.h - vDist - h
16799
17043
  state.left = clamp(l, 0, maxOffH)
16800
- state.top = clamp(t, 0, maxOffV)
17044
+ // v785:锚点自愈/恢复也要排除"窗口控件带"(这条路径不一定紧跟 express,所以就地夹)
17045
+ state.top = clampWidgetTop(clamp(t, 0, maxOffV), maxOffV)
16801
17046
  state.h = a.hAnchor
16802
17047
  // 净距离直接还给 hOff/vOff:settle() 对锚定状态从偏移量重算,
16803
17048
  // 若置 0 会把刚恢复的距离覆盖成贴边(issue #43)
@@ -29,6 +29,41 @@ export function dayOffset(day, offset) {
29
29
  return beijingDay(Date.parse(day + 'T00:00:00+08:00') + offset * 86400000)
30
30
  }
31
31
 
32
+ // v789(issue #184):partialDay 改为**按实际观测缺口就地计算**,不再是硬编码 true。
33
+ // 为什么要有这个:记账只发生在「客户端界面开着 + 余额能取到」的时候,任一条不成立就完全不观测;
34
+ // 跨零点的那一段(新建当日行时 openingUnits 直接取"此刻余额")会被新当天的起点吞掉,
35
+ // 于是「今日已用」长期显示一个明显偏小的数字,而界面上**没有任何"这是部分数据"的提示**。
36
+ // 判据(issue 里给出的算法):观测起点距当日 00:00、或(对已经过去的日子)最后一次观测距次日 00:00,
37
+ // 超过 GAP_MS 就视为部分数据。正常运行时两个缺口都只有几分钟 ⇒ false,不会误伤正常日。
38
+ const PARTIAL_GAP_MS = 10 * 60 * 1000
39
+ function partialDayOf(day, row, now) {
40
+ try {
41
+ const start = Date.parse(String(day) + 'T00:00:00+08:00')
42
+ if (!Number.isFinite(start)) return true
43
+ const end = start + 86400000
44
+ const first = Number(row && row.firstAt)
45
+ const last = Number(row && row.lastAt)
46
+ if (!Number.isFinite(first) || !Number.isFinite(last)) return true
47
+ const beforeGap = first - start
48
+ const afterGap = Number(now) >= end ? (end - last) : 0
49
+ return beforeGap > PARTIAL_GAP_MS || afterGap > PARTIAL_GAP_MS
50
+ } catch (err) { return true }
51
+ }
52
+ export function partialDayInfo(day, row, now = Date.now()) {
53
+ const start = Date.parse(String(day) + 'T00:00:00+08:00')
54
+ const partialDay = partialDayOf(day, row, now)
55
+ const first = Number(row && row.firstAt)
56
+ const last = Number(row && row.lastAt)
57
+ return {
58
+ partialDay,
59
+ // 给界面用:从几点才开始有观测(缺口在起点)/ 那天几点之后就没再观测(缺口在末尾)
60
+ observedFromMs: Number.isFinite(first) ? first : null,
61
+ observedToMs: Number.isFinite(last) ? last : null,
62
+ leadingGapMs: Number.isFinite(first) && Number.isFinite(start) ? Math.max(0, first - start) : null,
63
+ trailingGapMs: Number.isFinite(last) && Number.isFinite(start) && now >= start + 86400000 ? Math.max(0, start + 86400000 - last) : 0,
64
+ }
65
+ }
66
+
32
67
  function currentBook(ledger) {
33
68
  const a = ledger.accounting
34
69
  return a && a.version === ACCOUNTING_VERSION && a.books && a.books[a.active]
@@ -98,7 +133,8 @@ export function balanceSummary(ledger, day = beijingDay()) {
98
133
  firstObservedAt: row.firstAt, lastObservedAt: row.lastAt,
99
134
  openingBalance: row.openingUnits / SCALE, currentBalance: row.lastUnits / SCALE,
100
135
  observedDecrease: row.debitUnits / SCALE, observedIncrease: row.creditUnits / SCALE,
101
- needsReview, partialDay: true, revision: revisionOf(ledger, row),
136
+ needsReview, revision: revisionOf(ledger, row),
137
+ ...partialDayInfo(day, row),
102
138
  credits: correction ? correction.creditsUnits / SCALE : null,
103
139
  otherDebits: correction ? correction.otherDebitsUnits / SCALE : null,
104
140
  correctedAt: correction ? correction.at : null,
@@ -237,7 +273,8 @@ export function daySummary(ledger, day) {
237
273
  day, amount: fromOther.amount, currency: fromOther.currency,
238
274
  source: 'balance-observed-other-account', label: '已观测消费 · 历史账户',
239
275
  firstObservedAt: fromOther.firstObservedAt, lastObservedAt: fromOther.lastObservedAt,
240
- needsReview: fromOther.needsReview, partialDay: true, ...common,
276
+ needsReview: fromOther.needsReview, ...common,
277
+ ...partialDayInfo(day, { firstAt: fromOther.firstObservedAt, lastAt: fromOther.lastObservedAt }),
241
278
  }
242
279
  }
243
280
  // An explicit historical total (even zero) wins over a conflicting estimate.
package/lib/index.js CHANGED
@@ -28,9 +28,20 @@ const IMAGE_CANDIDATES = [
28
28
  path.join(PACKAGE_ROOT, 'assets', 'DSniang02.png'),
29
29
  ]
30
30
  // 自定义角色图片存放目录(第一个可写的会被使用;roles.json 存角色元数据)
31
+ // ⭐ 2026-10-02(用户要求"并成一份"):**用户数据的候选表一律 $DSH_HOME 优先**,开发机路径降级为兜底。
32
+ // 原因:桌面客户端加载的是 strip 过的发布副本(只认 $DSH_HOME),而开发版宿主原先把开发机路径排在第一位
33
+ // ⇒ 本机上网页端与桌面端**各写各的**(实测两端同时各写一份 .dshw-usage.json / .dshw-bubble.json),
34
+ // 互相看不到对方的改动 —— 表现就是"删掉的模块又冒出来""记账数对不上"。
35
+ // ⚠️ 这个常量必须在**所有**用到它的候选表之前定义(const 有 TDZ),所以放在最前面。
36
+ // ⚠️ `_strip-dev-paths.mjs` 会把发布副本里含开发机路径的行删掉,发布版只剩前两条 $DSH_HOME ✓
37
+ const DATA_CANDIDATES_TAIL = [
38
+ ]
39
+ // ⭐ 2026-10-02(用户要求"并成一份"):**角色图库目录也 $DSH_HOME 优先**(开发机路径降级为兜底)。
31
40
  const ROLE_DIR_CANDIDATES = [
32
41
  path.join(DSH_HOME, 'whale-roles'),
33
42
  path.join(DSH_HOME, 'profiles', 'web', 'whale-roles'),
43
+ DATA_CANDIDATES_TAIL[0] + 'whale-roles/',
44
+ DATA_CANDIDATES_TAIL[1] + 'whale-roles/',
34
45
  ]
35
46
  const ROLE_INDEX_NAME = 'roles.json'
36
47
  const ROLE_DEFAULT_ID = 'default'
@@ -38,6 +49,8 @@ const ROLE_DEFAULT_ID = 'default'
38
49
  const AUDIO_DIR_CANDIDATES = [
39
50
  path.join(DSH_HOME, 'whale-audio'),
40
51
  path.join(DSH_HOME, 'profiles', 'web', 'whale-audio'),
52
+ DATA_CANDIDATES_TAIL[0] + 'whale-audio/',
53
+ DATA_CANDIDATES_TAIL[1] + 'whale-audio/',
41
54
  ]
42
55
  const AUDIO_INDEX_NAME = 'audio.json'
43
56
  // 内置预设音效组(不可删、不可改),对应 assets 里的 Ya/D 系列
@@ -70,9 +83,12 @@ const BUILTIN_FRAGMENT_FILES = {
70
83
  }
71
84
  // —— 自定义 API 模型(v655):非 DeepSeek 厂商的余额 / 用量 / 提醒 ——
72
85
  // 注册表落在 $DSH_HOME/.dshw-api.json;密钥走 DSH 官方凭据(ctx.credentials),本文件不存明文。
86
+ // ⭐ 2026-10-02(用户要求"并成一份"):$DSH_HOME 优先(自定义 API 模型清单也属于用户数据,原先两端各存一份)。
73
87
  const API_FILE_CANDIDATES = [
74
88
  path.join(DSH_HOME, '.dshw-api.json'),
75
89
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-api.json'),
90
+ DATA_CANDIDATES_TAIL[0] + '.dshw-api.json',
91
+ DATA_CANDIDATES_TAIL[1] + '.dshw-api.json',
76
92
  ]
77
93
  const API_BUILTIN_ID = 'deepseek'
78
94
  // 「充值 / 余额校正」只属于固定的 DeepSeek(内置):模型条目下发 canAdjustBalance,路由层再校验一次。
@@ -163,7 +179,17 @@ const API_TEMPLATES = {
163
179
  quota: {
164
180
  url: 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
165
181
  auth: '{key}', // 智谱此接口不带 Bearer
166
- json: { percent: 'data.limits[0].TOKENS_LIMIT.percentage', resetAt: 'data.limits[0].nextResetTime', level: 'data.level' },
182
+ // v789(issue #180/#181 修):`data.limits[]` 是扁平条目数组,`TOKENS_LIMIT` 是条目的 `type` **值**,
183
+ // 且 5h 窗口不保证排在 [0](pro 账号 [0] 是 TIME_LIMIT、max 账号 [0] 才是 TOKENS_LIMIT)
184
+ // ⇒ 一律按 type(+unit) 选条目:unit=3 = 5 小时窗口、unit=6 = 周窗口;
185
+ // 新注册/credit 制账号的 type 是 CREDIT_LIMIT ⇒ 一并兼容。
186
+ json: {
187
+ level: 'data.level',
188
+ windows: [
189
+ { key: 'rolling', label: '5h', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 3 }, percent: 'percentage', resetAt: 'nextResetTime' },
190
+ { key: 'weekly', label: '周', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 6 }, percent: 'percentage', resetAt: 'nextResetTime' },
191
+ ],
192
+ },
167
193
  },
168
194
  probeUrl: 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
169
195
  },
@@ -346,7 +372,14 @@ const API_TEMPLATES = {
346
372
  quota: {
347
373
  url: 'https://api.z.ai/api/monitor/usage/quota/limit',
348
374
  auth: '{key}', // 与国内站一样不带 Bearer
349
- json: { percent: 'data.limits[0].TOKENS_LIMIT.percentage', resetAt: 'data.limits[0].nextResetTime', level: 'data.level' },
375
+ // v789:与国内站同一套 `data.limits[]` 结构(见 zhipu_glm_coding 的注释),一并按 type+unit 选条目
376
+ json: {
377
+ level: 'data.level',
378
+ windows: [
379
+ { key: 'rolling', label: '5h', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 3 }, percent: 'percentage', resetAt: 'nextResetTime' },
380
+ { key: 'weekly', label: '周', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 6 }, percent: 'percentage', resetAt: 'nextResetTime' },
381
+ ],
382
+ },
350
383
  },
351
384
  probeUrl: 'https://api.z.ai/api/monitor/usage/quota/limit',
352
385
  },
@@ -365,20 +398,28 @@ const API_TEMPLATES = {
365
398
  },
366
399
  }
367
400
 
401
+ // ⭐ 2026-10-02(用户要求"并成一份"):这些**用户数据文件一律 $DSH_HOME 优先**,开发机路径降级为只读兜底。
402
+ // 原因见上方 AUDIO_DIR_CANDIDATES 的说明(桌面端只认 $DSH_HOME,两端原先各写各的)。
368
403
  // Size memory file: prefer writable DSH home locations, then legacy fallbacks.
369
404
  const SIZE_FILE_CANDIDATES = [
370
405
  path.join(DSH_HOME, '.dshw-size.json'),
371
406
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-size.json'),
407
+ DATA_CANDIDATES_TAIL[0] + '.dshw-size.json',
408
+ DATA_CANDIDATES_TAIL[1] + '.dshw-size.json',
372
409
  ]
373
410
  // Usage ledger file (小鲸鱼记账 mode): same policy as the size file.
374
411
  const USAGE_FILE_CANDIDATES = [
375
412
  path.join(DSH_HOME, '.dshw-usage.json'),
376
413
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-usage.json'),
414
+ DATA_CANDIDATES_TAIL[0] + '.dshw-usage.json',
415
+ DATA_CANDIDATES_TAIL[1] + '.dshw-usage.json',
377
416
  ]
378
417
  // 每轮消耗 seq 持久化文件:避免插件热重载后 seq 归零导致页面把新轮次误判为旧轮次吞掉
379
418
  const TURN_FILE_CANDIDATES = [
380
419
  path.join(DSH_HOME, '.dshw-turn.json'),
381
420
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-turn.json'),
421
+ DATA_CANDIDATES_TAIL[0] + '.dshw-turn.json',
422
+ DATA_CANDIDATES_TAIL[1] + '.dshw-turn.json',
382
423
  ]
383
424
  // Sound assets: package-relative first (ship Ya1/Ya2/D1/D2.mp3 in assets/),
384
425
  // legacy absolute paths as fallback.
@@ -387,6 +428,8 @@ const BUBBLE_FILE_CANDIDATES = [
387
428
  // 否则清完这个数组会变成空的:泡泡配置读不了也存不了(自定义泡泡整体失效)。
388
429
  path.join(DSH_HOME, '.dshw-bubble.json'),
389
430
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-bubble.json'),
431
+ DATA_CANDIDATES_TAIL[0] + '.dshw-bubble.json',
432
+ DATA_CANDIDATES_TAIL[1] + '.dshw-bubble.json',
390
433
  ]
391
434
  // 泡泡图库(独立于角色图库):图片文件目录 + 索引
392
435
  const BUBBLE_IMG_DIR_CANDIDATES = [
@@ -394,6 +437,8 @@ const BUBBLE_IMG_DIR_CANDIDATES = [
394
437
  // 空数组会让图库上传直接报错。
395
438
  path.join(DSH_HOME, 'whale-bubble-imgs'),
396
439
  path.join(DSH_HOME, 'profiles', 'web', 'whale-bubble-imgs'),
440
+ DATA_CANDIDATES_TAIL[0] + 'whale-bubble-imgs/',
441
+ DATA_CANDIDATES_TAIL[1] + 'whale-bubble-imgs/',
397
442
  ]
398
443
  const BUBBLE_IMG_INDEX_NAME = 'bubble-imgs.json'
399
444
  // 内置默认泡泡图(语义 id → assets 文件):面板常驻可选;全新安装无用户图库时
@@ -430,6 +475,8 @@ function soundSetFromUrl(url) {
430
475
  }
431
476
  const BALANCE_URL = 'https://api.deepseek.com/user/balance'
432
477
  const BALANCE_TTL_MS = 25000
478
+ // v781:账号登录路的兜底超时(毫秒)。DSH 账号服务自身默认 30s,太长会把前端 FETCH_TIMEOUT_MS(25s) 先耗掉。
479
+ const ACCOUNT_FALLBACK_MS = 5000
433
480
  const RUA_GIF_CANDIDATES = [
434
481
  path.join(PACKAGE_ROOT, 'assets', 'rua.gif'),
435
482
  ]
@@ -1017,10 +1064,13 @@ export default {
1017
1064
  // ⚠️ 必须用 `ctx.get('deepseekAccount')` 读**可选服务**,**绝不能写进 inject** —— 老宿主根本没有这个服务,
1018
1065
  // 一旦进 inject,整个插件会永远等不到服务而**完全不 apply**(比没有这个功能严重得多)。
1019
1066
  // ⚠️ 与 API key 路径**互不干扰**(API key 永远优先;DSH 自己的注释也是 "Neither route falls back to the other")。
1067
+ // v781:账号余额路的失败原因(只用于错误文案,不参与任何判定)。fetchAccountBalance 每次开头清空。
1068
+ let accountDiag = ''
1020
1069
  async function fetchAccountBalance() {
1021
1070
  let account = null
1071
+ accountDiag = ''
1022
1072
  try { account = typeof ctx.get === 'function' ? ctx.get('deepseekAccount') : null } catch (err) { account = null }
1023
- if (!account || typeof account.getBalance !== 'function') return null
1073
+ if (!account || typeof account.getBalance !== 'function') { accountDiag = '当前 DSH 没有账号服务'; return null }
1024
1074
  // 账户标识:优先用账号服务给的稳定 id(不同账号 → 不同账本 scope);取不到就退回固定标识。
1025
1075
  // 一律 sha256[:24] 十六进制 —— accounting.mjs 校验 scope 必须匹配 /^[a-zA-Z0-9_-]{1,80}$/
1026
1076
  // (带冒号这种可读写法会直接抛「账户标识无效」,见 issue #157 报告人踩的坑 1)。
@@ -1036,25 +1086,52 @@ export default {
1036
1086
  locale: String(process.env.DSH_LOCALE || '') || 'zh_CN',
1037
1087
  timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60, // 东为正的整秒
1038
1088
  })
1039
- } catch (err) { return null }
1089
+ } catch (err) {
1090
+ accountDiag = '账号余额接口调用失败: ' + String((err && err.message) || err).slice(0, 120)
1091
+ return null
1092
+ }
1040
1093
  // 未登录 / 没有 grant / 平台失败 → 一律返回 null,让调用方退回原来的文案(**不缓存错误**)
1041
- if (!result || result.status !== 'ready' || !Array.isArray(result.value) || result.value.length === 0) return null
1094
+ // v781:失败时记下**原因**(accountDiag),供 fetchBalance 把两条路的原因一起报给界面 ——
1095
+ // 以前只有一个笼统的"账号余额不可用",用户/支持方分不清"没登录"和"接口失败"。
1096
+ if (!result) { accountDiag = '未登录 DeepSeek 账号(没有账号凭据)'; return null }
1097
+ if (result.status !== 'ready') { accountDiag = '账号余额接口未就绪(' + String(result.status || 'unknown') + ')'; return null }
1042
1098
  const accNum = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null }
1043
- const wallets = result.value.filter((w) => w && accNum(w.balance) !== null)
1044
- if (wallets.length === 0) return null
1045
- const currency = wallets.some((w) => String(w.currency || '').toUpperCase() === 'CNY')
1046
- ? 'CNY'
1047
- : String((wallets[0] && wallets[0].currency) || 'CNY').toUpperCase()
1048
- const sumOf = (list) => (Array.isArray(list) ? list : [])
1049
- .filter((w) => w && String(w.currency || 'CNY').toUpperCase() === currency && accNum(w.balance) !== null)
1099
+ // v781:**"充值钱包为空、只有赠金"必须算成功** —— 这是"只登录客户端读不到余额"的主因。
1100
+ // 平台把钱包拆成 `normal_wallets`(充值)与 `bonus_wallets`(赠金);新用户/没充过值的用户
1101
+ // 常常**只有赠金**(甚至 normal_wallets 是空数组)⇒ 旧代码要求 `result.value` 非空,
1102
+ // 直接把这类账号判成"没有余额钱包",界面就只显示"读取不到余额"。
1103
+ // 判据对齐 DSH 自己的账号卡片(dsh-client-ui-settings-account):
1104
+ // `[...balance.value, ...balance.bonusWallets].some(w => new Big(w.balance).gt(0))`
1105
+ // —— 两个钱包一起看,不再单看充值钱包。
1106
+ const normRaw = Array.isArray(result.value) ? result.value : []
1107
+ const bonusRaw = Array.isArray(result.bonusWallets) ? result.bonusWallets : []
1108
+ const usable = (list) => list.filter((w) => w && accNum(w.balance) !== null)
1109
+ const normWallets = usable(normRaw)
1110
+ const bonusWallets = usable(bonusRaw)
1111
+ const allWallets = normWallets.concat(bonusWallets)
1112
+ if (allWallets.length === 0) {
1113
+ // 两边都没有可用钱包 = 真的没有数据(未激活/账号没有任何钱包),不是"余额 0"
1114
+ accountDiag = normRaw.length || bonusRaw.length ? '钱包字段不可用' : '账号没有余额钱包'
1115
+ return null
1116
+ }
1117
+ // 币种:优先「有余额的那个钱包」的币种(CNY 优先),其次任何 CNY 钱包,最后第一个
1118
+ const curOf = (w) => String((w && w.currency) || 'CNY').toUpperCase()
1119
+ const nonzero = allWallets.filter((w) => Number(w.balance) > 0)
1120
+ const curPick = nonzero.find((w) => curOf(w) === 'CNY') || nonzero[0]
1121
+ || allWallets.find((w) => curOf(w) === 'CNY') || allWallets[0]
1122
+ const currency = curOf(curPick)
1123
+ const sumOf = (list) => list
1124
+ .filter((w) => w && curOf(w) === currency && accNum(w.balance) !== null)
1050
1125
  .reduce((s, w) => s + Number(w.balance), 0)
1051
- const recharge = sumOf(result.value)
1052
- const bonus = sumOf(result.bonusWallets)
1126
+ const recharge = sumOf(normWallets)
1127
+ const bonus = sumOf(bonusWallets)
1128
+ accountDiag = ''
1053
1129
  return {
1054
1130
  ok: true,
1055
1131
  // 充值 + 赠金:与 API key 路径的 `total_balance` 同口径(那个字段本身也是两者相加),
1056
1132
  // 否则"按余额差"推算的今日已用会偏小(issue #157 报告人的坑 2)。
1057
1133
  totalBalance: Number((recharge + bonus).toFixed(6)),
1134
+ // v781:两个分量单独下发,供「赠金 / 账户余额」两个自定义模块使用(见 SPEC §25)
1058
1135
  rechargeBalance: Number(recharge.toFixed(6)),
1059
1136
  bonusBalance: Number(bonus.toFixed(6)),
1060
1137
  currency,
@@ -1064,30 +1141,64 @@ export default {
1064
1141
  }
1065
1142
  }
1066
1143
 
1144
+ // v781:账号路的兜底(**两条路都失败才报错,并把两条路的原因都带上**)。
1145
+ // 为什么需要它:旧实现只在"完全没有 API key"时才试账号路 ⇒ 曾经配过 key(过期/写错/被删)
1146
+ // 但现在只用客户端登录的用户,余额**永远读不到**("只登录客户端读不到余额"报告里的另一大类)。
1147
+ // ⚠️ 超时上限:账号路走 DSH 自己的服务(默认 30s)⇒ 我们用 ACCOUNT_FALLBACK_MS 截断,
1148
+ // 保证"API key 两次 8s + 退避"之后仍在**前端的 25s** 之内回来,不让界面先 abort。
1149
+ async function accountFallback(keyErr) {
1150
+ let acc = null
1151
+ try {
1152
+ acc = await Promise.race([
1153
+ fetchAccountBalance(),
1154
+ new Promise((r) => setTimeout(() => { accountDiag = '账号余额请求超时'; r(null) }, ACCOUNT_FALLBACK_MS)),
1155
+ ])
1156
+ } catch (err) {
1157
+ acc = null
1158
+ accountDiag = accountDiag || ('账号余额异常: ' + String((err && err.message) || err).slice(0, 120))
1159
+ }
1160
+ if (acc) return acc
1161
+ const keyPart = keyErr
1162
+ ? ('API key 路:' + String((keyErr.error || keyErr.code || '失败')).slice(0, 160))
1163
+ : '未配置 DEEPSEEK_API_KEY'
1164
+ return {
1165
+ ok: false,
1166
+ code: keyErr ? 'BOTH_FAILED' : 'NO_KEY',
1167
+ error: keyPart + ';账号登录路:' + (accountDiag || '拿不到余额'),
1168
+ }
1169
+ }
1067
1170
  async function fetchBalance() {
1068
1171
  // v739(用户反馈「每次新实例的第一次余额请求都失败」):
1069
1172
  // ① 冷启动时凭据服务可能尚未就绪(resolve 抛错),这与「确实没配置」不同 → 短延迟重试一次;
1070
1173
  // ② 单次超时从 20s 收到 8s,保证「2 次尝试 + 退避」明显短于前端 FETCH_TIMEOUT_MS(25s) ——
1071
1174
  // 否则前端先 abort,用户看到的永远是「第一次失败」,只能干等 60 秒后的下一轮。
1072
1175
  let cred = null
1176
+ let credErr = null
1073
1177
  for (let attempt = 0; attempt < 2; attempt++) {
1074
1178
  try {
1075
1179
  cred = await ctx.credentials.resolve('DEEPSEEK_API_KEY')
1180
+ credErr = null
1076
1181
  break
1077
1182
  } catch (err) {
1183
+ credErr = err
1078
1184
  if (attempt === 0) { await new Promise((r) => setTimeout(r, 700)); continue }
1079
- return { ok: false, code: 'NO_KEY', error: '凭据读取失败: ' + String((err && err.message) || err).slice(0, 160) }
1185
+ // v781:凭据服务报错时**也要试账号路**(以前直接 return,登录态用户就被这条挡住)
1186
+ return await accountFallback({ code: 'NO_KEY', error: '凭据读取失败: ' + String((err && err.message) || err).slice(0, 160) })
1080
1187
  }
1081
1188
  }
1082
1189
  if (!cred) {
1083
1190
  // v759(issue #157):没有 API key 时退回 DSH 账号登录态;两条路互不干扰,API key 始终第一优先。
1084
- const accountPayload = await fetchAccountBalance()
1085
- if (accountPayload) return accountPayload
1086
- return {
1087
- ok: false, code: 'NO_KEY',
1088
- error: '未配置 DEEPSEEK_API_KEY,且账号余额不可用(未登录 DeepSeek 账号,或该账号没有余额钱包)',
1089
- }
1191
+ return await accountFallback(null)
1090
1192
  }
1193
+ const apiFail = await fetchApiKeyBalance(cred)
1194
+ if (apiFail.ok) return apiFail
1195
+ // v781:key 路**任何**失败都再试一次账号路(401/结构异常/超时都算)
1196
+ void credErr
1197
+ return await accountFallback(apiFail)
1198
+ }
1199
+
1200
+ // API key 路(原 fetchBalance 的主体,v781 抽出来以便"失败后回退账号路")
1201
+ async function fetchApiKeyBalance(cred) {
1091
1202
  let lastErr = null
1092
1203
  for (let attempt = 0; attempt < 2; attempt++) {
1093
1204
  let res
@@ -1117,9 +1228,15 @@ export default {
1117
1228
  if (!info || info.total_balance === undefined || !Number.isFinite(Number(info.total_balance))) {
1118
1229
  return { ok: false, code: 'SHAPE', error: '余额接口返回结构异常' }
1119
1230
  }
1231
+ const numOr = (x) => { const n = Number(x); return Number.isFinite(n) ? n : null }
1120
1232
  return {
1121
1233
  ok: true,
1122
1234
  totalBalance: Number(info.total_balance),
1235
+ // v781:官方 /user/balance 除 total_balance 外还给了 granted_balance(**未过期**赠金)与
1236
+ // topped_up_balance(充值)。取不到就是 null(界面显示 —,**不臆造 0** —— 0 会被记账
1237
+ // 当成"余额真的见底",那是另一种语义)。两个分量供自定义模块里的「赠金 / 账户余额」使用。
1238
+ bonusBalance: numOr(info.granted_balance),
1239
+ rechargeBalance: numOr(info.topped_up_balance),
1123
1240
  accountTag: createHash('sha256').update(String(cred.value)).digest('hex').slice(0, 24),
1124
1241
  currency: String(info.currency || 'CNY'),
1125
1242
  balanceSource: 'apikey',
@@ -1285,7 +1402,7 @@ export default {
1285
1402
  const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
1286
1403
  for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
1287
1404
  return {
1288
- ok: true, version: '0.3.17', today: todayData, days7,
1405
+ ok: true, version: '0.3.18', today: todayData, days7,
1289
1406
  total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
1290
1407
  all: {
1291
1408
  days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
@@ -1999,14 +2116,48 @@ export default {
1999
2116
  }
2000
2117
  return { key, code: '', error: '' }
2001
2118
  }
2002
- async function apiFetchJson(url, auth, key) {
2119
+ // v789(issue #190):余额/额度接口不再局限于 GET —— 模板或模型条目可声明 method / body。
2120
+ // ① method 默认 GET;非 GET 时带 `Content-Type: application/json` 并把 body 发出去;
2121
+ // ② body 支持占位符:`{key}`(凭据)、`{base}`(Base URL)、以及 `model.params` 里的任意键(如 `{uuid}`)
2122
+ // —— 这样"POST /v1/enterprise/account {"uuid":"…"}" 这类自建网关不需要再额外挂一层转换服务;
2123
+ // ③ 安全边界不变:body 只来自配置(不接受请求方传入),目的地仍走 credentialMayGoTo() 白名单,
2124
+ // URL 仍过 assertSafeApiUrl()。
2125
+ function bodyWithPlaceholders(body, key, base, params) {
2126
+ let s = String(body == null ? '' : body)
2127
+ s = s.split('{key}').join(String(key == null ? '' : key))
2128
+ s = s.split('{base}').join(String(base == null ? '' : base))
2129
+ if (params && typeof params === 'object') {
2130
+ for (const k of Object.keys(params)) {
2131
+ const v = params[k]
2132
+ if (v === undefined || v === null || typeof v === 'object') continue
2133
+ s = s.split('{' + k + '}').join(String(v))
2134
+ }
2135
+ }
2136
+ return s
2137
+ }
2138
+ function httpMethodOf(cfg) {
2139
+ const m = String((cfg && cfg.method) || 'GET').trim().toUpperCase()
2140
+ return /^[A-Z]{3,7}$/.test(m) ? m : 'GET'
2141
+ }
2142
+ async function apiFetchJson(url, auth, key, opts) {
2143
+ const o = opts || {}
2003
2144
  const headers = {}
2004
2145
  const a = String(auth == null ? 'Bearer {key}' : auth)
2005
2146
  if (a) headers.Authorization = a.replace('{key}', key)
2006
2147
  // v724:URL 里也支持 {key}(如 Gemini 的 ?key=… 形式;auth 传空串时不带 Authorization 头)
2007
2148
  const u = String(url).replace('{key}', key)
2008
2149
  assertSafeApiUrl(u)
2009
- const res = await fetch(u, { headers, signal: AbortSignal.timeout(15000) })
2150
+ const init = { headers, signal: AbortSignal.timeout(15000) }
2151
+ const method = httpMethodOf(o)
2152
+ if (method !== 'GET') {
2153
+ init.method = method
2154
+ const raw = o.body
2155
+ if (raw !== undefined && raw !== null && String(raw).length) {
2156
+ headers['Content-Type'] = 'application/json'
2157
+ init.body = bodyWithPlaceholders(raw, key, o.base, o.params)
2158
+ }
2159
+ }
2160
+ const res = await fetch(u, init)
2010
2161
  if (!res.ok) throw new Error('HTTP ' + res.status)
2011
2162
  return await res.json()
2012
2163
  }
@@ -2042,14 +2193,17 @@ export default {
2042
2193
  }
2043
2194
  const b = mergeNonEmpty(tpl.balance, model.balance) // v724:模型侧空串不再覆盖模板
2044
2195
  const base = String(model.baseUrl || '').replace(/\/+$/, '')
2045
- const url = String(tpl.probeUrl || b.url || (base ? base + '/v1/models' : '')).replace('{base}', base)
2196
+ // v789(issue #190):声明了 POST 等非 GET 方法时,测试就直接打**用户配置的余额接口**(带同一个 body)——
2197
+ // 否则会拿 GET 去打一个只支持 POST 的地址、误报"测试失败"(probeUrl 多是 models 列表,body 不通用)。
2198
+ const nonGet = httpMethodOf(b) !== 'GET'
2199
+ const url = String(nonGet ? (b.url || tpl.probeUrl || '') : (tpl.probeUrl || b.url || (base ? base + '/v1/models' : ''))).replace('{base}', base)
2046
2200
  if (!url) return { ok: false, error: '没有可测试的接口(请填余额接口或 Base URL)' }
2047
2201
  // v761(安全修复 S1):只在目的地可信时才带上凭据
2048
2202
  const k = await keyForDestination(model, tpl, url)
2049
2203
  if (!k.key) return { ok: false, code: k.code, error: k.error }
2050
2204
  const key = k.key
2051
2205
  try {
2052
- const data = await apiFetchJson(url, b.auth, key)
2206
+ const data = await apiFetchJson(url, b.auth, key, { method: b.method, body: b.body, base: base, params: model.params })
2053
2207
  const bizErr = apiBusinessError(data)
2054
2208
  if (bizErr) return { ok: false, error: '测试失败: ' + bizErr }
2055
2209
  let detail = 'HTTP 200'
@@ -2060,14 +2214,41 @@ export default {
2060
2214
  return { ok: false, error: '测试失败: ' + String((err && err.message) || err).slice(0, 140) }
2061
2215
  }
2062
2216
  }
2217
+ // v789(issue #180/#181):额度接口里"多窗口 + 数组顺序不保证"的通用取数。
2218
+ // 智谱 `GET /api/monitor/usage/quota/limit` 返回的 `data.limits[]` 是**扁平条目数组**:
2219
+ // `TOKENS_LIMIT` 是每条的 `type` 字段值(不是 `limits[0]` 的子对象),5h 窗口不保证排在 `[0]`
2220
+ // (实测:pro 账号 `limits[0]` 是 TIME_LIMIT,max 账号 `limits[0]` 是 TOKENS_LIMIT ⇒ 按位置取数必然出错过),
2221
+ // 而且新注册/credit 制账号把 `type` 改成了 `CREDIT_LIMIT`。
2222
+ // 所以模板可以声明 `pick: { list, type/types, unit, number }`:先选中**那一条**,再把它的字段当作取数根。
2223
+ function pickQuotaEntry(data, pick) {
2224
+ try {
2225
+ if (!pick || !pick.list) return null
2226
+ const arr = pickJsonPath(data, pick.list)
2227
+ if (!Array.isArray(arr)) return null
2228
+ const types = []
2229
+ if (pick.type) types.push(String(pick.type))
2230
+ if (Array.isArray(pick.types)) for (const t of pick.types) types.push(String(t))
2231
+ const unit = (pick.unit === undefined || pick.unit === null) ? null : Number(pick.unit)
2232
+ const number = (pick.number === undefined || pick.number === null) ? null : Number(pick.number)
2233
+ for (const it of arr) {
2234
+ if (!it || typeof it !== 'object') continue
2235
+ if (types.length && types.indexOf(String(it.type)) < 0) continue
2236
+ if (unit !== null && Number(it.unit) !== unit) continue
2237
+ if (number !== null && Number(it.number) !== number) continue
2238
+ return it
2239
+ }
2240
+ return null
2241
+ } catch (err) { return null }
2242
+ }
2063
2243
  // 订阅额度(Coding Plan)解析:各厂商字段不同,统一归一成「已用% + 重置时间 + 档位」
2064
- // zhipu: data.limits[0].TOKENS_LIMIT.percentage(已用%)+ nextResetTime
2244
+ // zhipu: data.limits[] 里 type=TOKENS_LIMIT/CREDIT_LIMIT + unit=3(5h)/6(周) 的条目(v789 起按 pick 选)
2065
2245
  // kimi: usage.remaining / usage.limit(剩余/总量)
2066
2246
  // minimax: model_remains[0].current_interval_remaining_percent(剩余%)+ end_time(ms)
2067
2247
  async function fetchModelQuota(model) {
2068
2248
  if (!model) return { ok: false, error: '模型不存在' }
2069
2249
  const tpl = apiTemplateOf(model.provider) || {}
2070
- const q = tpl.quota
2250
+ // v789(issue #180 建议 3):模型条目也能覆盖额度配置 —— 厂商接口变动时用户可自救(不留空即覆盖)
2251
+ const q = mergeNonEmpty(tpl.quota, model.quota)
2071
2252
  if (!q || !q.url) return { ok: false, code: 'NO_QUOTA', error: '该厂商没有订阅额度接口' }
2072
2253
  const base = String(model.baseUrl || '').replace(/\/+$/, '')
2073
2254
  const url = String(q.url).replace('{base}', base)
@@ -2076,31 +2257,33 @@ export default {
2076
2257
  if (!k.key) return { ok: false, code: k.code, error: k.error }
2077
2258
  const key = k.key
2078
2259
  try {
2079
- const data = await apiFetchJson(url, q.auth, key)
2260
+ const data = await apiFetchJson(url, q.auth, key, { method: q.method, body: q.body, base: base, params: model.params })
2080
2261
  const bizErr = apiBusinessError(data)
2081
2262
  if (bizErr) return { ok: false, code: 'BIZ', error: bizErr }
2082
2263
  const j = q.json || {}
2083
2264
  const num = (v) => { const n = Number(v); return isFinite(n) ? n : null }
2265
+ // v789:若模板声明了顶层 pick(单窗口 + 数组筛选),先把取数根换成选中的那条
2266
+ const jroot = j.pick ? (pickQuotaEntry(data, j.pick) || {}) : data
2084
2267
  let usedPct = null, remainPct = null, resetAt = null, level = '', weeklyUsedPct = null
2085
- if (j.percent) { const p = num(pickJsonPath(data, j.percent)); if (p !== null) usedPct = p }
2268
+ if (j.percent) { const p = num(pickJsonPath(jroot, j.percent)); if (p !== null) usedPct = p }
2086
2269
  if (j.remainPct) {
2087
- const p = num(pickJsonPath(data, j.remainPct))
2270
+ const p = num(pickJsonPath(jroot, j.remainPct))
2088
2271
  if (p !== null) { remainPct = p; if (usedPct === null) usedPct = Math.max(0, 100 - p) }
2089
2272
  }
2090
2273
  if (j.weeklyRemainPct) {
2091
- const p = num(pickJsonPath(data, j.weeklyRemainPct))
2274
+ const p = num(pickJsonPath(jroot, j.weeklyRemainPct))
2092
2275
  if (p !== null) weeklyUsedPct = Math.max(0, 100 - p)
2093
2276
  }
2094
2277
  if (j.remain && j.total) {
2095
- const r0 = num(pickJsonPath(data, j.remain))
2096
- const t0 = num(pickJsonPath(data, j.total))
2278
+ const r0 = num(pickJsonPath(jroot, j.remain))
2279
+ const t0 = num(pickJsonPath(jroot, j.total))
2097
2280
  if (r0 !== null && t0) {
2098
2281
  remainPct = Math.max(0, Math.min(100, r0 / t0 * 100))
2099
2282
  usedPct = Math.max(0, 100 - remainPct)
2100
2283
  }
2101
2284
  }
2102
- if (j.resetAt) resetAt = pickJsonPath(data, j.resetAt)
2103
- if (j.resetAtMs) { const ms = num(pickJsonPath(data, j.resetAtMs)); if (ms !== null) resetAt = ms }
2285
+ if (j.resetAt) resetAt = pickJsonPath(jroot, j.resetAt)
2286
+ if (j.resetAtMs) { const ms = num(pickJsonPath(jroot, j.resetAtMs)); if (ms !== null) resetAt = ms }
2104
2287
  if (j.level) level = String(pickJsonPath(data, j.level) || '')
2105
2288
  // v0.3.1:多窗口额度 —— 有些订阅额度接口一次返回多个窗口(如 OpenCode Go 的
2106
2289
  // rolling / weekly / monthly),每个窗口各有「已用% + 重置时间」。模板用
@@ -2111,11 +2294,13 @@ export default {
2111
2294
  const list = []
2112
2295
  for (const w of j.windows) {
2113
2296
  if (!w) continue
2114
- let wp = w.percent ? num(pickJsonPath(data, w.percent)) : null
2297
+ // v789:每个窗口可以各自 pick 一条(智谱 5h=unit3 / 周=unit6),选中后以该条为取数根
2298
+ const wroot = w.pick ? (pickQuotaEntry(data, w.pick) || {}) : data
2299
+ let wp = w.percent ? num(pickJsonPath(wroot, w.percent)) : null
2115
2300
  if (wp !== null) wp = Math.max(0, Math.min(100, wp))
2116
2301
  let wr = null
2117
2302
  if (w.resetAt) {
2118
- const rv = pickJsonPath(data, w.resetAt)
2303
+ const rv = pickJsonPath(wroot, w.resetAt)
2119
2304
  if (rv !== undefined && rv !== null && rv !== '') wr = rv
2120
2305
  }
2121
2306
  if (wp === null && wr === null) continue
@@ -2168,7 +2353,8 @@ export default {
2168
2353
  if (!k.key) return { ok: false, code: k.code, error: k.error }
2169
2354
  const key = k.key
2170
2355
  try {
2171
- const data = await apiFetchJson(url, b.auth, key)
2356
+ // v789(issue #190):把 method / body / params 一并交给取数层(默认 GET,与以前完全一致)
2357
+ const data = await apiFetchJson(url, b.auth, key, { method: b.method, body: b.body, base: base, params: model.params })
2172
2358
  const j = b.json || {}
2173
2359
  let remaining = null
2174
2360
  let total = null
@@ -2407,12 +2593,56 @@ export default {
2407
2593
  if (p.allowCustomHost === true) model.allowCustomHost = true
2408
2594
  else if (p.allowCustomHost === false) delete model.allowCustomHost
2409
2595
  if (p.baseUrl !== undefined) model.baseUrl = String(p.baseUrl || '').slice(0, 300)
2596
+ // v789(issue #190):请求体里可用的 `{占位符}`(如 {uuid})—— 只允许标量,键名受限
2597
+ if (p.params && typeof p.params === 'object') {
2598
+ const pr = {}
2599
+ for (const k of Object.keys(p.params)) {
2600
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,30}$/.test(k)) continue
2601
+ const v = p.params[k]
2602
+ if (v === undefined || v === null || typeof v === 'object') continue
2603
+ pr[k] = String(v).slice(0, 300)
2604
+ }
2605
+ if (Object.keys(pr).length) model.params = pr
2606
+ else delete model.params
2607
+ }
2608
+ if (p.quota && typeof p.quota === 'object' && p.quota.json && typeof p.quota.json === 'object') {
2609
+ // v789(issue #180 建议 3):允许模型条目覆盖额度配置(厂商接口变动时用户可自救)。
2610
+ // 只收 JSON-safe 的形状,且路径字符串限长 —— 与模板同构,不做二次校验的滥用面。
2611
+ const qj = {}
2612
+ const pathOf = (v) => String(v || '').slice(0, 200)
2613
+ if (p.quota.url) qj.url = String(p.quota.url).slice(0, 500)
2614
+ if (p.quota.auth !== undefined) qj.auth = String(p.quota.auth || '').slice(0, 200)
2615
+ const jin = p.quota.json
2616
+ for (const k of ['percent', 'remainPct', 'weeklyRemainPct', 'remain', 'total', 'resetAt', 'resetAtMs', 'level']) {
2617
+ if (jin[k]) qj[k] = pathOf(jin[k])
2618
+ }
2619
+ if (Array.isArray(jin.windows)) {
2620
+ qj.windows = jin.windows.slice(0, 6).map((w) => {
2621
+ const o = { key: String((w && w.key) || '').slice(0, 20), label: String((w && w.label) || '').slice(0, 20) }
2622
+ if (w && w.percent) o.percent = pathOf(w.percent)
2623
+ if (w && w.resetAt) o.resetAt = pathOf(w.resetAt)
2624
+ if (w && w.pick && typeof w.pick === 'object') {
2625
+ const pk = { list: pathOf(w.pick.list) }
2626
+ if (w.pick.type) pk.type = String(w.pick.type).slice(0, 40)
2627
+ if (Array.isArray(w.pick.types)) pk.types = w.pick.types.slice(0, 4).map((t) => String(t).slice(0, 40))
2628
+ if (w.pick.unit !== undefined) pk.unit = Number(w.pick.unit)
2629
+ if (w.pick.number !== undefined) pk.number = Number(w.pick.number)
2630
+ o.pick = pk
2631
+ }
2632
+ return o
2633
+ })
2634
+ }
2635
+ if (qj.url && (Object.keys(qj).length > 1)) model.quota = qj
2636
+ }
2410
2637
  if (p.balance && typeof p.balance === 'object') {
2411
2638
  // v724:空字段不写入(stripEmptyDeep)→ 模板默认值继续生效;
2412
2639
  // 全部为空时直接删掉 model.balance,等于"完全用模板"
2413
2640
  const balIn = stripEmptyDeep({
2414
2641
  url: String(p.balance.url || '').slice(0, 500),
2415
2642
  auth: String(p.balance.auth || '').slice(0, 200),
2643
+ // v789(issue #190):可选的请求方法与请求体(留空 ⇒ 沿用模板/GET)
2644
+ method: String(p.balance.method || '').slice(0, 8).toUpperCase(),
2645
+ body: String(p.balance.body || '').slice(0, 4000),
2416
2646
  json: {
2417
2647
  remaining: String((p.balance.json && p.balance.json.remaining) || '').slice(0, 200),
2418
2648
  total: String((p.balance.json && p.balance.json.total) || '').slice(0, 200),
@@ -2513,6 +2743,10 @@ export default {
2513
2743
  // entry.balance 是数值,不能当描述用(否则面板保存会用空值覆盖描述)
2514
2744
  const tplE = apiTemplateOf(m.provider) || {}
2515
2745
  entry.balanceDesc = mergeNonEmpty(tplE.balance, m.balance) // v724:空串不覆盖模板
2746
+ // v789(issue #189):把当前的凭据放行标志也下发 —— 前端勾选框要能**显示现状**,
2747
+ // 否则"看不到 → 保存时显式传 false"会把用户手改文件开的口子又关掉(反而更糟)。
2748
+ entry.allowCustomHost = m.allowCustomHost === true
2749
+ entry.params = (m.params && typeof m.params === 'object') ? m.params : null
2516
2750
  if (m.id === API_BUILTIN_ID) {
2517
2751
  entry.balanceMode = 'api'
2518
2752
  entry.hasBalanceApi = true
@@ -2635,7 +2869,7 @@ export default {
2635
2869
  const summary = daySummary(led, todayKey())
2636
2870
  const nowSec = Math.floor(Date.now() / 1000)
2637
2871
  return {
2638
- ...visible, version: '0.3.17', isPeak: isPeakTime(nowSec),
2872
+ ...visible, version: '0.3.18', isPeak: isPeakTime(nowSec),
2639
2873
  // 峰谷切换点与节假日清单:前端倒计时要与宿主同源(否则法定节假日会算错切换点)
2640
2874
  peakNextChangeAt: nextPeakChangeAt(nowSec),
2641
2875
  peakHolidays: HOLIDAY_VALLEY_LIST,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-widget",
3
- "version": "0.3.17",
3
+ "version": "0.3.18",
4
4
  "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件:余额/今日已用/峰谷定价、自定义泡泡点击序列(文本/余额/今日/峰谷/图片/随机语句与并列加权选择)、逐行样式与字体、悬浮快捷编辑、音效与每轮消耗、自定义角色/动图/音效、吸附与翻转自定义",
5
5
  "keywords": [
6
6
  "dsh",