dsh-whale-widget 0.3.6 → 0.3.8

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.
@@ -18,8 +18,17 @@ function dshwIsChatRoot(r) {
18
18
  return !!(
19
19
  r.querySelector('textarea') ||
20
20
  r.querySelector('[contenteditable="true"]') ||
21
+ // DSH 0.1.6-alpha.1 起的新版 composer:data-composer-input 在整个 DSH 前端产物里
22
+ // **只出现在对话组件**(v742 时实测:1515 个前端文件里仅 dsh-client-ui-conversation 命中),
23
+ // 所以它是最精确的判据。
21
24
  r.querySelector('[data-composer-input]') ||
22
- r.querySelector('[role="textbox"]')
25
+ // 同一层的 composer 容器属性(data-composer-seat / -card 也各只有一个出处),
26
+ // 万一将来输入框本身的属性改名,容器还在就仍能识别。
27
+ r.querySelector('[data-composer-seat],[data-composer-card]') ||
28
+ // 通用兜底:多行 textbox。注意**不要**用裸的 [role="textbox"] —— pdf.js 的批注编辑器
29
+ // (dsh-client-ui-sidebar-documentpreview)也会设 role=textbox,用它会把判定放宽到
30
+ // 非输入框的元素;加上 aria-multiline 至少排除掉单行输入框。
31
+ r.querySelector('[role="textbox"][aria-multiline="true"]')
23
32
  )
24
33
  }
25
34
  var dshwStarted = false
@@ -70,21 +79,34 @@ function dshwInit() {
70
79
  if (window.__dshWhaleInit) return
71
80
  window.__dshWhaleInit = true
72
81
 
73
- // ===== v739:音效改用 Web Audio 播放(不再用 <audio> / HTMLAudioElement)=====
74
- // 为什么改:HTMLAudioElement 会被 macOS 注册进系统「正在播放」,带 Touch Bar 的机器上
75
- // 每次按小鲸鱼都会弹出音频播放条并持续动画 明显卡顿(用户反馈)。AudioBufferSourceNode
76
- // 不经过媒体元素,系统媒体控件不会出现,也没有那层额外的解码/合成开销。
77
- // 兼容做法:给一个与 HTMLAudioElement 常用表面一致的 shim(play/pause/currentTime/volume/
78
- // onended/preload/src),这样既有的播放、音量、重播、结束回调逻辑一行都不用改。
82
+ // ===== 音效播放(v745:Web Audio + 预解码 + 同步起播 + 可调衔接)=====
83
+ // 目标:既要 0.3.0 那种"贴手"的响应,又不让 macOS 把音效注册进系统「正在播放」(Touch Bar 播放条 + 卡顿)。
84
+ // 之前 0.3.3~0.3.7 换成 Web Audio 后手感变钝,不是引擎的问题,而是丢了四样里的三样:
85
+ // 垫片的 preload='auto' 只是占位 → 不预取、不解码 → 第一次点按要现 fetch+decode;
86
+ // 垫片没有 duration、currentTime 也不前进 → 点按退化成 onended 兜底「按压音放完才接松开音」;
87
+ // 起播要经过一次 Promise/微任务。
88
+ // 现在四件齐备:
89
+ // ① 引擎仍是不经过 media element 的 AudioBufferSourceNode(系统媒体控件不会出现 → 无 Touch Bar 条);
90
+ // ② dshwvWarm() 在切音效组 / 打开试听 / 页面初始化时就**预取 + 预解码**(URL 级缓存,只解一次);
91
+ // ③ 补齐 duration / currentTime(播放中真实前进)→ 能算"按压音还剩多久";
92
+ // ④ **同步起播**:缓冲区已预热时,start() 在 pointerdown 的**同一个任务**里调用(不再经过 Promise)。
93
+ // 衔接时机由 RELEASE_LEAD_MS 决定(0 = 正好接上;30/50 = 轻微交叠)—— 改这个数字即可按耳朵微调,
94
+ // 不用动任何逻辑。
79
95
  var dshwvAudioCtx = null
80
96
  function dshwvAudio() {
81
97
  try {
82
- if (!dshwvAudioCtx) dshwvAudioCtx = new (window.AudioContext || window.webkitAudioContext)()
98
+ if (!dshwvAudioCtx) {
99
+ var AC = window.AudioContext || window.webkitAudioContext
100
+ // 显式用 'interactive'(该 API 的最低延迟档),起播尽量贴手
101
+ dshwvAudioCtx = new AC({ latencyHint: 'interactive' })
102
+ }
83
103
  if (dshwvAudioCtx.state === 'suspended') { try { dshwvAudioCtx.resume() } catch (err) {} }
84
104
  return dshwvAudioCtx
85
105
  } catch (err) { return null }
86
106
  }
87
- var dshwvAudioBuffers = {} // url -> Promise<AudioBuffer>(解码结果缓存,同一片段不重复下载/解码)
107
+ var dshwvAudioBuffers = {} // url -> Promise<AudioBuffer>(同一片段不重复下载/解码)
108
+ var dshwvAudioDecoded = {} // url -> AudioBuffer(解码完成后**同步可读**:起播走同步路径的关键)
109
+ function nowMs() { try { return (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now() } catch (err) { return Date.now() } }
88
110
  function dshwvAudioBuffer(url) {
89
111
  if (!url) return Promise.reject(new Error('empty url'))
90
112
  if (!dshwvAudioBuffers[url]) {
@@ -95,62 +117,99 @@ function dshwvAudioBuffer(url) {
95
117
  if (!c) throw new Error('no audio context')
96
118
  return new Promise(function (res, rej) { c.decodeAudioData(raw, res, rej) })
97
119
  })
98
- .catch(function (err) { delete dshwvAudioBuffers[url]; throw err })
120
+ .then(function (buf) { dshwvAudioDecoded[url] = buf; return buf })
121
+ .catch(function (err) { delete dshwvAudioBuffers[url]; delete dshwvAudioDecoded[url]; throw err })
99
122
  }
100
123
  return dshwvAudioBuffers[url]
101
124
  }
125
+ // 预热:垫片的 preload='auto' 在 Web Audio 下不解码,必须显式预取+预解码。
126
+ // 不预热 → 第一次点按要现 fetch+decodeAudioData,表现就是"按下音慢半拍、中间衔接发飘"。
127
+ // URL 级缓存保证同一片段只解一次;失败静默吞掉(真正播放时还会自己重试一次)。
128
+ function dshwvWarm(urls) {
129
+ try { dshwvAudio() } catch (err) {}
130
+ for (var i = 0; i < (urls || []).length; i++) {
131
+ var u = urls[i]
132
+ if (!u) continue
133
+ try { dshwvAudioBuffer(u).catch(function () {}) } catch (err) {}
134
+ }
135
+ }
102
136
  function dshwvSoundStop(el) {
103
137
  el._token = (el._token || 0) + 1
104
138
  var node = el._node
105
139
  el._node = null
140
+ el._playingSince = 0
106
141
  if (node) {
107
142
  try { node.onended = null } catch (err) {}
108
143
  try { node.stop() } catch (err) {}
109
144
  }
110
145
  }
111
146
  function dshwvSound(url) {
112
- var el = { preload: 'auto', volume: 1, onended: null, loop: false, _url: String(url || ''), _node: null, _gain: null, _offset: 0, _token: 0 }
147
+ var el = { preload: 'auto', volume: 1, onended: null, loop: false, _url: String(url || ''), _node: null, _gain: null, _offset: 0, _token: 0, _playingSince: 0 }
113
148
  Object.defineProperty(el, 'src', {
114
149
  get: function () { return el._url },
115
150
  set: function (v) { dshwvSoundStop(el); el._url = String(v || ''); el._offset = 0 },
116
151
  })
117
152
  Object.defineProperty(el, 'currentTime', {
118
- get: function () { return el._offset },
153
+ // 播放中真实前进:pressUp 靠它算"按压音还剩多久",据此把松开音排到按压音结束(或提前 lead)那一刻
154
+ get: function () {
155
+ if (el._node && el._playingSince) {
156
+ var t = el._offset + (nowMs() - el._playingSince) / 1000
157
+ var d = el.duration
158
+ return Math.max(0, isFinite(d) && d > 0 ? Math.min(t, d) : t)
159
+ }
160
+ return el._offset
161
+ },
119
162
  // 既有逻辑用「currentTime = 0」表示重播 → 这里顺手停掉正在播的那一份
120
- set: function (v) { el._offset = Number(v) || 0; dshwvSoundStop(el) },
163
+ set: function (v) { el._offset = Number(v) || 0; el._playingSince = 0; dshwvSoundStop(el) },
121
164
  })
122
- el.play = function () {
123
- var c = dshwvAudio()
124
- if (!c || !el._url) return Promise.resolve()
165
+ Object.defineProperty(el, 'duration', {
166
+ // HTMLAudioElement 对齐:未解码时 NaN(调用方用 isFinite 判定),解码后是真实时长
167
+ get: function () {
168
+ var b = dshwvAudioDecoded[el._url]
169
+ return b && isFinite(b.duration) && b.duration > 0 ? b.duration : NaN
170
+ },
171
+ })
172
+ // 拿到 buffer 后真正起播。delay>0 时用音频线程时间轴(start(when))排期,不受主线程抖动影响。
173
+ function beginWithBuffer(c, buf, delay) {
125
174
  try {
126
175
  if (!el._gain) { el._gain = c.createGain(); el._gain.connect(c.destination) }
127
176
  el._gain.gain.value = Math.max(0, Math.min(1, Number(el.volume) || 0))
128
- } catch (err) { return Promise.resolve() }
177
+ var src = c.createBufferSource()
178
+ src.buffer = buf
179
+ src.connect(el._gain)
180
+ src.onended = function () {
181
+ if (el._node !== src) return
182
+ el._node = null
183
+ el._playingSince = 0
184
+ if (typeof el.onended === 'function') { try { el.onended() } catch (err) {} }
185
+ }
186
+ el._node = src
187
+ el._playingSince = nowMs() + delay * 1000
188
+ var dur = Math.max(0.001, buf.duration)
189
+ src.start(delay > 0 ? c.currentTime + delay : 0, Math.max(0, el._offset) % dur)
190
+ } catch (err) {}
191
+ }
192
+ function startSound(delaySec) {
193
+ var c = dshwvAudio()
194
+ if (!c || !el._url) return Promise.resolve()
129
195
  var token = (el._token = (el._token || 0) + 1)
130
- var url = el._url
131
- dshwvAudioBuffer(url).then(function (buf) {
196
+ var delay = Math.max(0, Number(delaySec) || 0)
197
+ // ④ 同步起播:缓冲区已预热(dshwvWarm)过 → 直接在当前任务里 start(),不再等 Promise
198
+ var cached = dshwvAudioDecoded[el._url]
199
+ if (cached) { beginWithBuffer(c, cached, delay); return Promise.resolve() }
200
+ dshwvAudioBuffer(el._url).then(function (buf) {
132
201
  if (token !== el._token) return // 期间被重播/暂停/换源 → 丢弃这次
133
- try {
134
- var src = c.createBufferSource()
135
- src.buffer = buf
136
- src.connect(el._gain)
137
- src.onended = function () {
138
- if (el._node !== src) return
139
- el._node = null
140
- if (typeof el.onended === 'function') { try { el.onended() } catch (err) {} }
141
- }
142
- el._node = src
143
- var dur = Math.max(0.001, buf.duration)
144
- src.start(0, Math.max(0, el._offset) % dur)
145
- } catch (err) {}
202
+ beginWithBuffer(c, buf, delay)
146
203
  }).catch(function () {})
147
204
  return Promise.resolve()
148
205
  }
206
+ el.play = function () { return startSound(0) }
207
+ el.playAt = function (delaySec) { return startSound(delaySec) }
149
208
  el.pause = function () { dshwvSoundStop(el) }
150
209
  return el
151
210
  }
152
- // 自动播放策略:AudioContext 初始是 suspended,要有一次用户手势才能出声。
153
- // 任务结束音不是手势触发的,所以先挂一次性解锁(首次点击/按键后移除)。
211
+ // 自动播放策略:AudioContext 初始是 suspended,要有一次用户手势才能出声;任务结束音不是手势触发的,
212
+ // 所以挂一次性解锁(首次点击/按键后移除)。
154
213
  try {
155
214
  var dshwvAudioUnlock = function () {
156
215
  dshwvAudio()
@@ -690,6 +749,38 @@ styleEl.setAttribute('data-plugin', 'dsh-whale-widget')
690
749
  styleEl.textContent = css
691
750
  document.head.appendChild(styleEl)
692
751
 
752
+ // ===== v743:body 挂载登记器(DOM 守护的基础设施,见下面 dshwReattachRoot)=====
753
+ // 挂件会把 30 多个节点挂到 document.body 上(主节点、菜单、各种遮罩/面板、隐藏的 file input…)。
754
+ // 早先的守护只补挂 root + menuBox:SPA 切路由或别的插件整体替换 body 子树后,其余节点会变成
755
+ // "存在但不在文档里"的孤儿 —— 界面看起来恢复了,可一旦点开对应功能就静默失效。
756
+ // 所以这里统一登记:所有挂到 body 的节点都走 dshwBodyAppend(),守护时逐个补挂;
757
+ // 主动移除(目前只有一处)走 dshwBodyDetach(),避免被守护逻辑"复活"。
758
+ var dshwBodyNodes = []
759
+ function dshwBodyAppend(el) {
760
+ try {
761
+ if (!el) return el
762
+ // 注意:这里必须是**原始**的 document.body.appendChild —— 不能走 dshwBodyAppend 自己
763
+ // (v743 批量改写时曾误替换成自我递归,被 try/catch 吞掉后表现为"登记了但从未挂上")
764
+ document.body.appendChild(el)
765
+ if (dshwBodyNodes.indexOf(el) < 0) dshwBodyNodes.push(el)
766
+ } catch (err) {}
767
+ return el
768
+ }
769
+ function dshwBodyDetach(el) {
770
+ try {
771
+ var i = dshwBodyNodes.indexOf(el)
772
+ if (i >= 0) dshwBodyNodes.splice(i, 1)
773
+ if (el && el.parentNode) el.parentNode.removeChild(el)
774
+ } catch (err) {}
775
+ }
776
+ // 兼容性:老 WebView 可能没有 Element.isConnected(Chrome 51+ 才有)。
777
+ // 若直接 `!el.isConnected`,在那种环境里会恒为 true → 每个 DOM 变更批次都会重复 appendChild。
778
+ function dshwConnected(el) {
779
+ if (!el) return false
780
+ try { if (typeof el.isConnected === 'boolean') return el.isConnected } catch (err) {}
781
+ try { return document.documentElement.contains(el) } catch (err) { return true }
782
+ }
783
+
693
784
  var root = document.createElement('div')
694
785
  root.className = 'dshwv-root'
695
786
 
@@ -774,7 +865,7 @@ audioImportBtn.textContent = '导入'
774
865
  audioImportBtn.title = '新建/编辑音效组'
775
866
  audioGroupBtn.addEventListener('click', function (e) { e.stopPropagation(); toggleAudioGroupPanel() })
776
867
  audioImportBtn.addEventListener('click', function (e) { e.stopPropagation(); openAudioGroupEditor(null) })
777
- document.body.appendChild(audioGroupPanel)
868
+ dshwBodyAppend(audioGroupPanel)
778
869
  function soundOpt(value, label) {
779
870
  var o = document.createElement('option')
780
871
  o.value = value
@@ -792,7 +883,7 @@ function dshwCustSelClose() {
792
883
  if (!o) return
793
884
  try {
794
885
  o.menu.classList.remove('dshwv-rgbopen')
795
- if (o.menu.parentNode === document.body) document.body.removeChild(o.menu)
886
+ dshwBodyDetach(o.menu) // v744:登记过的 body 节点必须走 detach,否则会被 DOM 守护补挂回来
796
887
  } catch (err) {}
797
888
  }
798
889
  if (!window.__dshwCustBound) {
@@ -910,7 +1001,7 @@ function dshwCustSel(sel, opts) {
910
1001
  dshwCustSelClose()
911
1002
  fill()
912
1003
  sync()
913
- if (menu.parentNode !== document.body) document.body.appendChild(menu)
1004
+ if (menu.parentNode !== document.body) dshwBodyAppend(menu)
914
1005
  dshwDropOpen(menu, btn)
915
1006
  // 可选底部参照元素:展开高度不超过该元素的上缘(内部滚动),用于
916
1007
  // 主菜单内“任务结束音效”等下拉,避免列表盖住底部「小鲸鱼记账」按钮
@@ -1148,6 +1239,7 @@ function playTaskEndGroupClick(groupId) {
1148
1239
  // 按压留空:无按下音,直接播松开(模拟按下即松开的完整点按);松开留空:只播按压
1149
1240
  if (pressEmpty) {
1150
1241
  if (!releaseEmpty) {
1242
+ dshwvWarm(['/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId)]) // v745:先预热
1151
1243
  var relOnly = dshwvSound('/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId))
1152
1244
  try { relOnly.volume = vol } catch (err) {}
1153
1245
  relOnly.currentTime = 0
@@ -1158,6 +1250,11 @@ function playTaskEndGroupClick(groupId) {
1158
1250
  }
1159
1251
  var press = dshwvSound('/dsh-whale/sound/press.mp3?set=' + encodeURIComponent(groupId))
1160
1252
  try { press.volume = vol } catch (err) {}
1253
+ // v745:菜单里的"点一下试听"同样先预热解码,否则第一次听有明显延迟
1254
+ dshwvWarm([
1255
+ '/dsh-whale/sound/press.mp3?set=' + encodeURIComponent(groupId),
1256
+ releaseEmpty ? '' : '/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId),
1257
+ ])
1161
1258
  if (releaseEmpty) {
1162
1259
  press.currentTime = 0
1163
1260
  var pp = press.play()
@@ -1872,7 +1969,7 @@ function usageAlertBudgetEditor(key, onSave) {
1872
1969
  turnCostCloseDefer = false
1873
1970
  turnCostCloseInput.disabled = !turnCostOn // 回到菜单态的禁用逻辑
1874
1971
  }
1875
- document.body.removeChild(mask)
1972
+ dshwBodyDetach(mask) // v744:走 detach 注销登记,否则 DOM 守护会把这个刚关掉的编辑器补挂回来
1876
1973
  bubbleEditItems = bkEditItems
1877
1974
  bubbleEditorSnap = bkEditorSnap
1878
1975
  bubbleItemSnap = bkItemSnap
@@ -1991,7 +2088,7 @@ function usageAlertBudgetEditor(key, onSave) {
1991
2088
  mask.appendChild(card)
1992
2089
  mask.addEventListener('click', function (e) { if (e.target === mask) cleanup() })
1993
2090
  window.__dshwRemindMask = mask
1994
- document.body.appendChild(mask)
2091
+ dshwBodyAppend(mask)
1995
2092
  renderBubblePal()
1996
2093
  renderBubblePv()
1997
2094
  } catch (err) {}
@@ -2455,7 +2552,9 @@ function openApiModelPanel(modelId) {
2455
2552
  // 新建模型:保存后给一个明确的收尾——弹「保存成功」,点确认即关掉本面板。
2456
2553
  // (原先是重新打开为编辑态,新建流程会停在一个没有明显关闭入口的面板上)
2457
2554
  if (isNew) {
2458
- try { confirmMask.style.setProperty('z-index', '29500', 'important') } catch (err) {}
2555
+ // v744 清理:这里原先有 `confirmMask.style.zIndex = '29500' !important`,但紧接着的
2556
+ // showConfirm() 会把确认框统一提到 40000 !important,所以那行是**无效设置**(立刻被覆盖),
2557
+ // 只会让"层级"更难读。已删除 —— 确认框永远由 showConfirm 统一抬到 40000。
2459
2558
  showConfirm('✓ 保存成功:' + (nm || '新模型') + '\n已加入模型列表', function () {
2460
2559
  try { closeApiModelPanel() } catch (err) {}
2461
2560
  // 背后的「- = 小鲸鱼记账 = -」列表同步刷新(模型行在静态区,需重建子界面)
@@ -2563,7 +2662,7 @@ function openApiModelPanel(modelId) {
2563
2662
  } catch (err) {}
2564
2663
  mask.appendChild(card)
2565
2664
  mask.addEventListener('click', function (e) { if (e.target === mask) closeApiModelPanel() })
2566
- document.body.appendChild(mask)
2665
+ dshwBodyAppend(mask)
2567
2666
  apiModelMaskEl = mask
2568
2667
  // 模板切换:把模板默认值填进各字段(仅新增态)
2569
2668
  if (isNew) {
@@ -2614,7 +2713,10 @@ function openApiModelPanel(modelId) {
2614
2713
  }
2615
2714
  var apiModelMaskEl = null
2616
2715
  function closeApiModelPanel() {
2617
- try { if (apiModelMaskEl && apiModelMaskEl.parentNode) apiModelMaskEl.parentNode.removeChild(apiModelMaskEl) } catch (err) {}
2716
+ // v744:必须走 dshwBodyDetach —— 这个遮罩是登记在案的 body 节点,
2717
+ // 若用 parentNode.removeChild 直接摘掉,DOM 守护会认为"被别的插件摘走了"并把它补挂回来,
2718
+ // 表现就是**点「取消」关不掉这个窗口**(0.3.7 引入的回归)。
2719
+ try { if (apiModelMaskEl) dshwBodyDetach(apiModelMaskEl) } catch (err) {}
2618
2720
  apiModelMaskEl = null
2619
2721
  }
2620
2722
  // 列宽受限 + 悬浮滚动:内容超出列宽时,鼠标移上去文字自动横向滚动,移开回到起点。
@@ -2914,7 +3016,7 @@ function openModelQuotaEditor(modelId) {
2914
3016
  card.appendChild(btns)
2915
3017
  mask.appendChild(card)
2916
3018
  mask.addEventListener('click', function (e) { if (e.target === mask) closeApiModelPanel() })
2917
- document.body.appendChild(mask)
3019
+ dshwBodyAppend(mask)
2918
3020
  apiModelMaskEl = mask
2919
3021
  } catch (err) {}
2920
3022
  }
@@ -3102,7 +3204,7 @@ function openApiModelMenu(modelId) {
3102
3204
  card.appendChild(btns)
3103
3205
  mask.appendChild(card)
3104
3206
  mask.addEventListener('click', function (e) { if (e.target === mask) closeApiModelPanel() })
3105
- document.body.appendChild(mask)
3207
+ dshwBodyAppend(mask)
3106
3208
  apiModelMaskEl = mask
3107
3209
  } catch (err) {}
3108
3210
  }
@@ -3260,7 +3362,8 @@ function openBalanceAdjustment(modelId) {
3260
3362
  function close() {
3261
3363
  if (busy) return
3262
3364
  document.removeEventListener('keydown', keyHandler)
3263
- mask.remove()
3365
+ // v743:主动移除要走 dshwBodyDetach —— 否则它已被登记,DOM 守护会把这个刚关掉的窗口"复活"
3366
+ dshwBodyDetach(mask)
3264
3367
  accountingMask = null
3265
3368
  // 关掉校正窗口后回到同一个设置菜单,并把焦点放回「校正」按钮
3266
3369
  openApiModelMenu(modelId)
@@ -3293,7 +3396,7 @@ function openBalanceAdjustment(modelId) {
3293
3396
  card.appendChild(actions)
3294
3397
  mask.appendChild(card)
3295
3398
  mask.addEventListener('click', function (e) { if (e.target === mask) close() })
3296
- document.body.appendChild(mask)
3399
+ dshwBodyAppend(mask)
3297
3400
  accountingMask = mask
3298
3401
  document.addEventListener('keydown', keyHandler)
3299
3402
  var rows = []
@@ -3489,7 +3592,7 @@ var usageMoreCard = document.createElement('div')
3489
3592
  usageMoreCard.className = 'dshwv-usage-card'
3490
3593
  usageMoreMask.appendChild(usageMoreCard)
3491
3594
  usageMoreMask.addEventListener('click', function (e) { if (e.target === usageMoreMask) closeUsageRecordsWindow() })
3492
- document.body.appendChild(usageMoreMask)
3595
+ dshwBodyAppend(usageMoreMask)
3493
3596
  function openUsageRecordsWindow() {
3494
3597
  usageMoreCard.innerHTML = '<div style="padding:10px;color:#203170">加载中…</div>'
3495
3598
  usageMoreMask.style.display = 'flex'
@@ -3512,7 +3615,7 @@ function resMaskOpen() {
3512
3615
  resCardEl.className = 'dshwv-usage-card dshwv-rescard'
3513
3616
  resMaskEl.appendChild(resCardEl)
3514
3617
  resMaskEl.addEventListener('click', function (e) { if (e.target === resMaskEl) resManagerClose() })
3515
- document.body.appendChild(resMaskEl)
3618
+ dshwBodyAppend(resMaskEl)
3516
3619
  }
3517
3620
  resManagerRender()
3518
3621
  resMaskEl.style.display = 'flex'
@@ -4107,12 +4210,12 @@ function usagePopupCard(title, content, below, amount) {
4107
4210
  ok.type = 'button'
4108
4211
  ok.className = 'dshwv-bubbtn dshwv-bubbtn-ok'
4109
4212
  ok.textContent = '知道了'
4110
- ok.addEventListener('click', function () { try { document.body.removeChild(mask) } catch (err) {} })
4213
+ ok.addEventListener('click', function () { try { dshwBodyDetach(mask) } catch (err) {} })
4111
4214
  btns.appendChild(ok)
4112
4215
  card.appendChild(btns)
4113
4216
  mask.appendChild(card)
4114
- mask.addEventListener('click', function (e) { if (e.target === mask) { try { document.body.removeChild(mask) } catch (err) {} } })
4115
- document.body.appendChild(mask)
4217
+ mask.addEventListener('click', function (e) { if (e.target === mask) { try { dshwBodyDetach(mask) } catch (err) {} } })
4218
+ dshwBodyAppend(mask)
4116
4219
  } catch (err) {}
4117
4220
  }
4118
4221
  var USAGE_PALETTE = ['#203170', '#e0433f', '#2fa24c', '#b060c8', '#e89a2e', '#3aa6c8', '#d06a8a', '#7a8b2f', '#6a6ad0', '#c84a8a']
@@ -4617,7 +4720,7 @@ function fillUsageRecordsWindow(d) {
4617
4720
  renderGroups('')
4618
4721
  })
4619
4722
  }
4620
- document.body.appendChild(rolePanel)
4723
+ dshwBodyAppend(rolePanel)
4621
4724
 
4622
4725
  // —— 吸附与翻转自定义弹窗 ——
4623
4726
  // 预览方框内五条可拖线:左/右/上/下四条吸附区边界 + 红色翻转线(竖中线位置)。
@@ -5034,7 +5137,7 @@ snapBtns.appendChild(snapBtn('重置', 'dshwv-snapbtn-no', resetSnapEdit))
5034
5137
  snapBtns.appendChild(snapBtn('确认', 'dshwv-snapbtn-ok', function () { closeSnapModal(true) }))
5035
5138
  snapCard.appendChild(snapBtns)
5036
5139
  snapMask.appendChild(snapCard)
5037
- document.body.appendChild(snapMask)
5140
+ dshwBodyAppend(snapMask)
5038
5141
 
5039
5142
  // —— 自定义泡泡:主编辑窗口(点击队列) + 单泡模块编辑 + 模块编辑 ——
5040
5143
  var bubbleMask = null
@@ -6890,7 +6993,7 @@ function qeditEnsure() {
6890
6993
  qeditEl = document.createElement('div')
6891
6994
  qeditEl.className = 'dshwv-qedit'
6892
6995
  qeditEl.style.display = 'none'
6893
- document.body.appendChild(qeditEl)
6996
+ dshwBodyAppend(qeditEl)
6894
6997
  if (!window.__dshwQeditBound) {
6895
6998
  window.__dshwQeditBound = true
6896
6999
  document.addEventListener('pointerdown', function (e) {
@@ -8212,7 +8315,7 @@ bubbleBtns.appendChild(bubbleBtn('重置', 'dshwv-bubbtn-no', bubbleEditorReset)
8212
8315
  bubbleBtns.appendChild(bubbleBtn('保存', 'dshwv-bubbtn-ok', bubbleEditorSave))
8213
8316
  bubbleCard.appendChild(bubbleBtns)
8214
8317
  bubbleMask.appendChild(bubbleCard)
8215
- document.body.appendChild(bubbleMask)
8318
+ dshwBodyAppend(bubbleMask)
8216
8319
 
8217
8320
  // ===== W2 单泡编辑窗口 =====
8218
8321
  bubbleItemMask = document.createElement('div')
@@ -8296,7 +8399,7 @@ bubbleItemBtns.appendChild(bubbleBtn('恢复默认', 'dshwv-bubbtn-no', bubbleIt
8296
8399
  bubbleItemBtns.appendChild(bubbleBtn('保存', 'dshwv-bubbtn-ok', bubbleItemSave))
8297
8400
  bubbleItemCard.appendChild(bubbleItemBtns)
8298
8401
  bubbleItemMask.appendChild(bubbleItemCard)
8299
- document.body.appendChild(bubbleItemMask)
8402
+ dshwBodyAppend(bubbleItemMask)
8300
8403
 
8301
8404
  // ===== W3 模块编辑器(文本/随机/图片 + 样式) =====
8302
8405
  var moduleMask = null
@@ -8395,9 +8498,46 @@ var bubbleRgbOpenMenu = null // 当前展开的 .dshwv-rgbmenu
8395
8498
  var bubbleFontOpenMenu = null // 当前展开的字体下拉(复用 rgbmenu 类,额外限高)
8396
8499
  var bubbleColorOpenMenu = null // 当前展开的颜色下拉(纯色/跑马灯,限高)
8397
8500
  // —— 弹层/菜单层级统一助手(所有下拉与弹窗据此取高于当前可见层,避免互相压制) ——
8501
+ // ============================================================================
8502
+ // 层级(z-index)分层表 —— **改层之前先读这里**
8503
+ // 所有浮层都直接挂在 <body> 上,谁盖谁完全由 z-index 决定。历史上出过
8504
+ // 「子窗口被父窗口盖住点不到」「窗口关不掉」这类问题,所以这里把层段定死,并约定三条铁律:
8505
+ // ① 新增浮层**先在下面的表里选一个层段**,不要随手写数字;
8506
+ // ② 凡是「可能从别的窗口里被打开」的窗口,必须用 dshwLayerUp(el, 本段起点),
8507
+ // 这样父窗口在 29000 时子窗口自动落到 29010,永远不会被盖住;
8508
+ // ③ visibleTopZ() 的候选表必须包含**全部**浮层节点(漏一个就会低估"当前最高层"),
8509
+ // 唯一例外是 toast —— 它是刻意的最顶层,不参与追赶。
8510
+ //
8511
+ // 层段 用途 载体
8512
+ // ---------------------------------------------------------------------------
8513
+ // 1 – 999 挂件本体内部元素 .dshwv-pop(1) / .dshwv-menu-btn(2) / 拖拽把手(3-5)
8514
+ // 30 / 60 面板内的自绘下拉 .dshwv-colpop(30) / .dshwv-rgbmenu(60)
8515
+ // 9999 挂件本体 .dshwv-root
8516
+ // 10000 – 19999 主菜单与其列表 .dshwv-menu(10000) / .dshwv-rolelist·audiolist(10001)
8517
+ // 20000 – 20999 一级编辑器 .dshwv-cropmask·gifmask(20000) / .dshwv-resmask(20300) /
8518
+ // .dshwv-audiomask·bubmask(20500) / .dshwv-slotlist(20600)
8519
+ // 21000 – 21999 对话框基础层 .dshwv-confirmmask(21000,实际被 showConfirm 提到 40000)
8520
+ // 22000 – 22999 记账 / 吸附窗口 .dshwv-snapmask·usage-mask(22000)
8521
+ // 26000 – 26999 小浮层 .dshwv-qedit(26000) / .dshwv-usagepanel(26020) /
8522
+ // .dshwv-tplhelp·动态提示(26080+)
8523
+ // 29000 – 29999 模型子菜单 / 模型设置 JS 显式写入(refreshModelList / openApiModelMenu)
8524
+ // 30000 – 30999 提醒·额度·余额校正编辑器 JS 显式写入(含 openModelQuotaEditor / openBalanceAdjustment)
8525
+ // 31000 – 31999 提醒编辑期间需提到顶层的浮层 JS 显式写入(moduleMask / qedit / token 提示)
8526
+ // 32000 – 32999 提醒编辑期间统一置顶的遮罩 remindZStyle 的 !important 规则
8527
+ // 40000 确认对话框(永远在所有窗口之上) showConfirm() 的 !important
8528
+ // 2147483600 提示条 toast(最高,且不参与 visibleTopZ)
8529
+ // ============================================================================
8398
8530
  function visibleTopZ() {
8399
8531
  var top = 20500
8400
- var cand = [bubbleMask, bubbleItemMask, moduleMask, usageMoreMask, qeditEl, window.__dshwRemindMask, apiModelMaskEl, accountingMask]
8532
+ // 注意:这里必须列全 —— 少一个浮层,dshwLayerUp/下拉/提示就会低估"当前最高层"而被盖住。
8533
+ // toast 刻意不列入(它是永远的最顶层,不该让别的层去追它)。
8534
+ var cand = [
8535
+ bubbleMask, bubbleItemMask, moduleMask, moduleNamePromptMask,
8536
+ cropMask, gifMask, audioCropMask, audioEditMask, resMaskEl,
8537
+ confirmMask, snapMask, usageMask, usageMoreMask,
8538
+ qeditEl, dshwvTplHelpEl, dshwvHintEl,
8539
+ apiModelMaskEl, accountingMask, window.__dshwRemindMask
8540
+ ]
8401
8541
  function eff(el) {
8402
8542
  try {
8403
8543
  if (!el) return 0
@@ -8417,6 +8557,13 @@ function visibleTopZ() {
8417
8557
  }
8418
8558
  return top
8419
8559
  }
8560
+ // 「永远在打开它的那个窗口之上」:取 本层段起点 与 当前可见最高层+10 的较大值。
8561
+ // 用在裁剪 / GIF / 音频裁剪 / 模块编辑器这些**既可能从主菜单(10000)打开、也可能从资源管理(20300)、
8562
+ // 泡泡编辑器(20500)、模型设置(29000)里打开**的窗口上 —— 固定层号在后者场景会被父窗口盖住。
8563
+ function dshwLayerUp(el, base) {
8564
+ try { if (el && el.style) el.style.zIndex = String(Math.max(base, Math.round(visibleTopZ()) + 10)) } catch (err) {}
8565
+ return el
8566
+ }
8420
8567
  // 打开主要编辑器前清理可能残留的临时层级(提醒会话遗留的 moduleMask/qedit 提升与样式)
8421
8568
  function whaleZClean() {
8422
8569
  try {
@@ -8432,7 +8579,7 @@ function whaleZClean() {
8432
8579
  // 菜单宽度与触发按钮保持一致(原样式 min-width:100% 在 body 下会按视口撑满,须归零)
8433
8580
  function dshwDropOpen(menuEl, anchorEl) {
8434
8581
  try {
8435
- if (menuEl.parentNode !== document.body) document.body.appendChild(menuEl)
8582
+ if (menuEl.parentNode !== document.body) dshwBodyAppend(menuEl)
8436
8583
  menuEl.style.position = 'fixed'
8437
8584
  menuEl.style.minWidth = '0px'
8438
8585
  menuEl.style.left = '0px'
@@ -9428,6 +9575,9 @@ function openModuleEditor(m, onSave, isNew) {
9428
9575
  moduleOnSave = onSave || null
9429
9576
  moduleEditNew = !!isNew
9430
9577
  renderModuleEditor()
9578
+ // v744:模块编辑器从泡泡编辑器(20500)/资源管理(20300)里打开时,固定 20500 会与父窗口同层
9579
+ // (只靠 DOM 顺序决定谁在上面),这里统一抬到"当前最高层之上"
9580
+ dshwLayerUp(moduleMask, 20500)
9431
9581
  moduleMask.style.display = 'flex'
9432
9582
  } catch (err) {}
9433
9583
  }
@@ -9483,7 +9633,7 @@ moduleBtns.appendChild(saveAsBtn)
9483
9633
  moduleBtns.appendChild(bubbleBtn('保存', 'dshwv-bubbtn-ok', function () { closeModuleEditor(true) }))
9484
9634
  moduleCard.appendChild(moduleBtns)
9485
9635
  moduleMask.appendChild(moduleCard)
9486
- document.body.appendChild(moduleMask)
9636
+ dshwBodyAppend(moduleMask)
9487
9637
  // 存为可选模块:名称输入弹窗
9488
9638
  var moduleNamePromptMask = document.createElement('div')
9489
9639
  moduleNamePromptMask.className = 'dshwv-confirmmask'
@@ -9516,7 +9666,7 @@ moduleNameBtns.appendChild(moduleNameCancel)
9516
9666
  moduleNameBtns.appendChild(moduleNameOk)
9517
9667
  moduleNamePromptCard.appendChild(moduleNameBtns)
9518
9668
  moduleNamePromptMask.appendChild(moduleNamePromptCard)
9519
- document.body.appendChild(moduleNamePromptMask)
9669
+ dshwBodyAppend(moduleNamePromptMask)
9520
9670
  // 回车保存/关闭
9521
9671
  moduleNameInput.addEventListener('keydown', function (e) {
9522
9672
  try {
@@ -9629,7 +9779,7 @@ cropCard.appendChild(cropZoomWrap)
9629
9779
  cropCard.appendChild(cropAngleWrap)
9630
9780
  cropCard.appendChild(cropBtns)
9631
9781
  cropMask.appendChild(cropCard)
9632
- document.body.appendChild(cropMask)
9782
+ dshwBodyAppend(cropMask)
9633
9783
  cropBox.addEventListener('pointerdown', onCropDown)
9634
9784
  cropBox.addEventListener('pointermove', onCropMove)
9635
9785
  cropBox.addEventListener('pointerup', onCropUp)
@@ -9729,7 +9879,7 @@ gifCard.appendChild(gifHint)
9729
9879
  gifCard.appendChild(gifNameInput)
9730
9880
  gifCard.appendChild(gifBtns)
9731
9881
  gifMask.appendChild(gifCard)
9732
- document.body.appendChild(gifMask)
9882
+ dshwBodyAppend(gifMask)
9733
9883
  gifCancelBtn.addEventListener('click', hideGifRoleModal)
9734
9884
  gifOkBtn.addEventListener('click', confirmGifRole)
9735
9885
  var gifRoleDataUrl = null
@@ -9749,6 +9899,7 @@ function openGifRoleModal(dataUrl, fileName, animType) {
9749
9899
  }
9750
9900
  gifNameInput.value = ''
9751
9901
  gifPreviewImg.src = dataUrl
9902
+ dshwLayerUp(gifMask, 20000) // v744:同上,GIF 窗口也可能从资源管理/泡泡编辑器里打开
9752
9903
  gifMask.style.display = 'flex'
9753
9904
  }
9754
9905
  function hideGifRoleModal() {
@@ -9810,7 +9961,7 @@ confirmBtns.appendChild(confirmYesBtn)
9810
9961
  confirmCard.appendChild(confirmText)
9811
9962
  confirmCard.appendChild(confirmBtns)
9812
9963
  confirmMask.appendChild(confirmCard)
9813
- document.body.appendChild(confirmMask)
9964
+ dshwBodyAppend(confirmMask)
9814
9965
  confirmNoBtn.addEventListener('click', hideConfirm)
9815
9966
  confirmYesBtn.addEventListener('click', function () {
9816
9967
  var cb = confirmCb
@@ -9902,7 +10053,7 @@ audioEditCard.appendChild(audioEditPressRow)
9902
10053
  audioEditCard.appendChild(audioEditReleaseRow)
9903
10054
  audioEditCard.appendChild(audioEditBtns)
9904
10055
  audioEditMask.appendChild(audioEditCard)
9905
- document.body.appendChild(audioEditMask)
10056
+ dshwBodyAppend(audioEditMask)
9906
10057
  audioEditCancel.addEventListener('click', hideAudioEditor)
9907
10058
  audioEditPlay.addEventListener('pointerdown', function (e) { e.stopPropagation(); audioEditPreviewDown() })
9908
10059
  audioEditPlay.addEventListener('pointerup', function (e) { e.stopPropagation(); audioEditPreviewUp() })
@@ -10055,7 +10206,7 @@ audioCropCard.appendChild(audioCropTime)
10055
10206
  audioCropCard.appendChild(audioCropNameRow)
10056
10207
  audioCropCard.appendChild(audioCropBtns)
10057
10208
  audioCropMask.appendChild(audioCropCard)
10058
- document.body.appendChild(audioCropMask)
10209
+ dshwBodyAppend(audioCropMask)
10059
10210
  audioCropCancel.addEventListener('click', hideAudioCrop)
10060
10211
  audioCropOk.addEventListener('click', confirmAudioCrop)
10061
10212
  audioCropPlay.addEventListener('click', previewAudioCrop)
@@ -10126,8 +10277,8 @@ body.appendChild(img)
10126
10277
  body.appendChild(bubbleBox)
10127
10278
  root.appendChild(body)
10128
10279
  root.appendChild(menuBtn)
10129
- document.body.appendChild(root)
10130
- document.body.appendChild(menuBox)
10280
+ dshwBodyAppend(root)
10281
+ dshwBodyAppend(menuBox)
10131
10282
 
10132
10283
  // ===== PR #105 后半:DOM 守护(SPA 切路由 / 别的插件替换 body 子树时把节点摘掉)=====
10133
10284
  // 背景:DSH 是 SPA,切到会话列表 / 设置 / 插件市场再回来、或其它客户端插件整体替换
@@ -10135,19 +10286,29 @@ document.body.appendChild(menuBox)
10135
10286
  // 做法:暴露 window.__dshWhaleRoot 供外部定位/调试,并用一个 MutationObserver 盯着;
10136
10287
  // 一旦发现节点已不在文档里就**把同一个节点补挂回 body**(不重建、不重新初始化,
10137
10288
  // 位置/设置/状态全部保留)。
10289
+ // v743:补挂范围从 root+menuBox 扩到**全部登记过的 body 节点**(见 dshwBodyNodes)——
10290
+ // 否则被整体替换后,遮罩/面板/隐藏 file input 会变成孤儿,功能静默失效;
10291
+ // 另外 root 还在、只有个别节点被摘掉的"局部移除"也要能自愈,所以做了节流的全量核对。
10138
10292
  try { window.__dshWhaleRoot = root } catch (err) {}
10139
10293
  function dshwReattachRoot() {
10140
10294
  try {
10141
- if (!root || root.isConnected) return
10142
- document.body.appendChild(root)
10143
- // 菜单是独立挂在 body 上的浮层:它也被摘掉且当前正打开时一并补回,否则菜单会"消失"
10144
- if (menuBox && menuOpen && !menuBox.isConnected) document.body.appendChild(menuBox)
10295
+ for (var i = 0; i < dshwBodyNodes.length; i++) {
10296
+ var el = dshwBodyNodes[i]
10297
+ if (el && !dshwConnected(el)) dshwBodyAppend(el)
10298
+ }
10145
10299
  } catch (err) {}
10146
10300
  }
10147
10301
  try {
10148
10302
  if (typeof MutationObserver === 'function') {
10303
+ var dshwGuardLastFull = 0
10149
10304
  var dshwRootGuard = new MutationObserver(function () {
10150
- if (root && !root.isConnected) dshwReattachRoot()
10305
+ try {
10306
+ if (!root) return
10307
+ if (!dshwConnected(root)) { dshwReattachRoot(); dshwGuardLastFull = Date.now(); return }
10308
+ // root 正常时也定期全量核对一次(最多 1.5 秒一次):覆盖"只有个别浮层被摘掉"的情况
10309
+ var now = Date.now()
10310
+ if (now - dshwGuardLastFull > 1500) { dshwGuardLastFull = now; dshwReattachRoot() }
10311
+ } catch (err) {}
10151
10312
  })
10152
10313
  dshwRootGuard.observe(document.documentElement, { childList: true, subtree: true })
10153
10314
  }
@@ -10209,6 +10370,8 @@ var state = {
10209
10370
  todayUsageCurrency: 'CNY',
10210
10371
  usageLabel: '本地估算',
10211
10372
  isPeak: false,
10373
+ peakNextChangeAt: null,
10374
+ peakHolidays: null,
10212
10375
  status: 'loading',
10213
10376
  message: '',
10214
10377
  flip: false
@@ -11236,7 +11399,7 @@ function bubbleTplHelpToggle(m, anchor) {
11236
11399
  if (!dshwvTplHelpEl) {
11237
11400
  dshwvTplHelpEl = document.createElement('div')
11238
11401
  dshwvTplHelpEl.className = 'dshwv-tplhelp'
11239
- document.body.appendChild(dshwvTplHelpEl)
11402
+ dshwBodyAppend(dshwvTplHelpEl)
11240
11403
  document.addEventListener('pointerdown', function (e) {
11241
11404
  if (!dshwvTplHelpEl || dshwvTplHelpEl.style.display === 'none') return
11242
11405
  try {
@@ -11280,7 +11443,7 @@ function dshwvHintEnsure() {
11280
11443
  dshwvHintEl = document.createElement('div')
11281
11444
  dshwvHintEl.className = 'dshwv-tplhelp dshwv-hintbox'
11282
11445
  dshwvHintEl.style.display = 'none'
11283
- document.body.appendChild(dshwvHintEl)
11446
+ dshwBodyAppend(dshwvHintEl)
11284
11447
  document.addEventListener('pointerdown', function (e) {
11285
11448
  if (!dshwvHintEl || dshwvHintEl.style.display === 'none') return
11286
11449
  try {
@@ -11423,9 +11586,28 @@ function bubbleModuleText(m, avoidIdx) {
11423
11586
  return t
11424
11587
  }
11425
11588
  // —— 下个时段倒计时模块:按“工作日9-12/14-18为高峰,周末全天谷价”推算下一时段切换 ——
11589
+ // v746:法定节假日同样全天谷价。前端无法自己知道放假安排,节假日清单由宿主随余额接口下发
11590
+ // (state.peakHolidays,单一来源),切换点也优先用宿主算好的绝对值(state.peakNextChangeAt)。
11591
+ var bubbleHolidaySet = null
11592
+ var bubbleHolidaySetKey = ''
11593
+ function bubbleHolidaySetOf() {
11594
+ var list = (state && state.peakHolidays) || null
11595
+ var key = Array.isArray(list) ? list.join(',') : ''
11596
+ if (key !== bubbleHolidaySetKey) {
11597
+ bubbleHolidaySetKey = key
11598
+ bubbleHolidaySet = {}
11599
+ if (Array.isArray(list)) for (var i = 0; i < list.length; i++) bubbleHolidaySet[String(list[i])] = 1
11600
+ }
11601
+ return bubbleHolidaySet
11602
+ }
11603
+ function bubbleBJHolidayKey(bj) {
11604
+ try { return bj.toISOString().slice(0, 10) } catch (err) { return '' }
11605
+ }
11426
11606
  function bubbleCountdownIsPeak(sec) {
11427
11607
  sec = isFinite(Number(sec)) ? Number(sec) : Math.floor(Date.now() / 1000)
11428
11608
  var bj = new Date((sec + 8 * 3600) * 1000)
11609
+ var hs = bubbleHolidaySetOf()
11610
+ if (hs && hs[bubbleBJHolidayKey(bj)]) return false // 法定节假日全天谷价
11429
11611
  var dow = bj.getUTCDay()
11430
11612
  var h = bj.getUTCHours()
11431
11613
  if (dow === 0 || dow === 6) return false
@@ -11433,10 +11615,14 @@ function bubbleCountdownIsPeak(sec) {
11433
11615
  }
11434
11616
  function bubbleCountdownNextChange(sec) {
11435
11617
  sec = isFinite(Number(sec)) ? Number(sec) : Math.floor(Date.now() / 1000)
11618
+ // 宿主已就绪时直接用其算好的切换点(与计费同源,含法定节假日);
11619
+ // 只接受未来 12 天内的值——切过去之后该值会过期,回退到本地推算,避免倒计时卡在 00:00:00
11620
+ var hostCand = Number(state && state.peakNextChangeAt)
11621
+ if (isFinite(hostCand) && hostCand > sec + 1 && hostCand - sec <= 12 * 86400) return hostCand
11436
11622
  var cur = bubbleCountdownIsPeak(sec)
11437
11623
  var bjDay0 = Math.floor((sec + 8 * 3600) / 86400) * 86400
11438
11624
  var best = null
11439
- for (var d = 0; d <= 8 && best === null; d++) {
11625
+ for (var d = 0; d <= 12 && best === null; d++) {
11440
11626
  var dayStartBj = bjDay0 + d * 86400
11441
11627
  var edges = [0, 9 * 3600, 12 * 3600, 14 * 3600, 18 * 3600]
11442
11628
  for (var i = 0; i < edges.length; i++) {
@@ -12345,6 +12531,8 @@ function refresh(manual) {
12345
12531
  state.usageLabel = data.usageLabel || '本地估算'
12346
12532
  if (data.stale) state.usageLabel += ' · 余额未刷新'
12347
12533
  state.isPeak = !!data.isPeak
12534
+ state.peakNextChangeAt = isFinite(Number(data.peakNextChangeAt)) ? Number(data.peakNextChangeAt) : null
12535
+ state.peakHolidays = Array.isArray(data.peakHolidays) ? data.peakHolidays : null
12348
12536
  checkUsageAlerts(nb, state.todayUsage)
12349
12537
  if (changed && !currencyChanged) {
12350
12538
  if (!manual) {
@@ -12420,13 +12608,14 @@ function dshwvToast(msg) {
12420
12608
  'max-width:min(560px,calc(100vw - 32px));box-sizing:border-box;padding:10px 14px;border-radius:10px;' +
12421
12609
  'background:#8a1f1f;color:#fff;font-size:13px;line-height:1.6;box-shadow:0 6px 20px rgba(0,0,0,.28);' +
12422
12610
  'pointer-events:none;text-align:center'
12423
- document.body.appendChild(el)
12611
+ dshwBodyAppend(el)
12424
12612
  dshwvToastEl = el
12425
12613
  }
12426
12614
  dshwvToastEl.innerHTML = msg
12427
12615
  if (dshwvToastTimer) clearTimeout(dshwvToastTimer)
12428
12616
  dshwvToastTimer = setTimeout(function () {
12429
- try { if (dshwvToastEl && dshwvToastEl.parentNode) dshwvToastEl.parentNode.removeChild(dshwvToastEl) } catch (err) {}
12617
+ // v744:toast 也是登记过的 body 节点,必须 detach(否则 DOM 守护会把它补挂回来 提示条永不消失)
12618
+ try { if (dshwvToastEl) dshwBodyDetach(dshwvToastEl) } catch (err) {}
12430
12619
  dshwvToastEl = null
12431
12620
  }, 8000)
12432
12621
  } catch (err) {}
@@ -12459,12 +12648,15 @@ function configPut(payload, retried) {
12459
12648
  }
12460
12649
  function saveConfig() {
12461
12650
  // issue #97:加载完成前只记待办,绝不 PUT(否则把默认值整包写进服务端)
12462
- if (!configLoaded) { configSavePending = true; return }
12651
+ if (!configLoaded) { configSavePending = true; return null }
12463
12652
  try {
12464
- configPut(configPayload(), false)
12653
+ // 返回 Promise(v743:Codex 统计开关需要"等服务端确认后再刷新")。
12654
+ // configPut 自带重试与失败提示、且链尾有 catch,所以不会有 unhandled rejection。
12655
+ var p = configPut(configPayload(), false)
12465
12656
  // 锚点位置记忆:记录相对边框的离边距离,窗口 resize 后保持(localStorage)。
12466
12657
  saveAnchorPos()
12467
- } catch (err) {}
12658
+ return p
12659
+ } catch (err) { return null }
12468
12660
  }
12469
12661
  // 单独抽出:只写 localStorage 锚点(不碰尺寸设置);applyAnchorPos / settle 自愈时也要用。
12470
12662
  // v:2 = 净距离格式(剥离避让距离),v:1 旧格式含避让距离,恢复时废弃旧格式。
@@ -12572,8 +12764,12 @@ function setMenuBtnHide(v) {
12572
12764
  function setCodexStatsOn(v) {
12573
12765
  codexStatsOn = v !== false
12574
12766
  if (codexStatsToggle) codexStatsToggle.checked = codexStatsOn
12575
- saveConfig()
12576
- try { refreshModelList() } catch (err) {}
12767
+ var after = function () { try { refreshModelList() } catch (err) {} }
12768
+ var p = null
12769
+ try { p = saveConfig() } catch (err) {}
12770
+ // 等服务端确认落盘后再重取模型列表:否则可能读到旧配置,那一行会先显示上一次的状态再跳变
12771
+ if (p && typeof p.then === 'function') p.then(after, after)
12772
+ else after()
12577
12773
  }
12578
12774
  function scaleToDisplay(s) {
12579
12775
  return Math.round((s - MIN_SCALE) / ((MAX_SCALE - MIN_SCALE) / 19)) + 1
@@ -12639,12 +12835,11 @@ var releaseAudio = null
12639
12835
  var pressing = false
12640
12836
  var pressEnded = false
12641
12837
  var releasePlayed = false
12642
- var releaseTimer = null
12838
+ // v745:不再需要 releaseTimer —— 点按时由 playReleaseAt() 在**音频线程**排期(见 pressUp)
12643
12839
  function applySoundSet() {
12644
12840
  try {
12645
12841
  // v729:切音效组 / 开关音效时把本轮播放状态一并复位,
12646
12842
  // 避免残留 releasePlayed=true 把新组的松开音整体吃掉(与 playPress 的修复配套)
12647
- if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null }
12648
12843
  pressEnded = false
12649
12844
  releasePlayed = false
12650
12845
  // 槽位显式留空(该事件静音)时,对应音频元素置空;playPress/playRelease 已判空
@@ -12660,6 +12855,12 @@ function applySoundSet() {
12660
12855
  releaseAudio.preload = 'auto'
12661
12856
  releaseAudio.volume = soundVol
12662
12857
  }
12858
+ // v745:把这组的按压/松开音**预取+预解码**(Web Audio 下 preload='auto' 不解码)。
12859
+ // 预热过之后,起播走 dshwvSound 的同步路径(pointerdown 同一任务里 start),手感才贴手。
12860
+ dshwvWarm([
12861
+ pEmpty ? '' : '/dsh-whale/sound/press.mp3?set=' + soundSet,
12862
+ rEmpty ? '' : '/dsh-whale/sound/release.mp3?set=' + soundSet,
12863
+ ])
12663
12864
  } catch (err) {}
12664
12865
  }
12665
12866
  function playPress() {
@@ -12668,7 +12869,6 @@ function playPress() {
12668
12869
  // 原实现里 pressAudio 为空时直接 return,跳过了 releasePlayed = false;而 playRelease()
12669
12870
  // 一旦把 releasePlayed 置为 true 就再没有任何地方复位它 → 结果是只有第一次松开有声音,
12670
12871
  // 之后每次点击都静音(用户实测:新建音效组只填松开音时复现)。
12671
- if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null }
12672
12872
  if (releaseAudio) {
12673
12873
  releaseAudio.pause()
12674
12874
  releaseAudio.currentTime = 0
@@ -12692,6 +12892,13 @@ function playPress() {
12692
12892
  if (p && typeof p.catch === 'function') p.catch(function () {})
12693
12893
  } catch (err) {}
12694
12894
  }
12895
+ // 点按时"松开音提前多少毫秒进场"(v745 把它做成**可调参数**,按耳朵微调即可):
12896
+ // 0 = 松开音正好接在按压音结束那一刻(无缝、不重叠)
12897
+ // 30 / 50 = 轻微交叠(更"黏")
12898
+ // 100 = 明显重叠(听感上可能像"重复播放"。0.3.0 代码里写的是 100,但它的主线程 setTimeout
12899
+ // 经常迟到、实际几乎听不到重叠;我们用音频线程精确排期,所以别照抄 100)
12900
+ // 当前取值:**40**(用户在 30/50 之间试听后选定)
12901
+ var RELEASE_LEAD_MS = 40
12695
12902
  function playRelease() {
12696
12903
  if (releasePlayed || !releaseAudio || !soundOn) return
12697
12904
  releasePlayed = true
@@ -12701,6 +12908,20 @@ function playRelease() {
12701
12908
  if (p && typeof p.catch === 'function') p.catch(function () {})
12702
12909
  } catch (err) {}
12703
12910
  }
12911
+ // 把松开音**排期到 delaySec 秒之后**(音频线程时间轴):起播时刻精确、不受主线程抖动影响。
12912
+ function playReleaseAt(delaySec) {
12913
+ if (releasePlayed || !releaseAudio || !soundOn) return
12914
+ releasePlayed = true
12915
+ try {
12916
+ releaseAudio.currentTime = 0 // 复位(同时停掉上一条已排期/在播的松开音)
12917
+ if (delaySec > 0 && typeof releaseAudio.playAt === 'function') {
12918
+ releaseAudio.playAt(delaySec)
12919
+ return
12920
+ }
12921
+ var p = releaseAudio.play()
12922
+ if (p && typeof p.catch === 'function') p.catch(function () {})
12923
+ } catch (err) {}
12924
+ }
12704
12925
  function pressDown() {
12705
12926
  body.style.transform = SQUISH
12706
12927
  pressing = true
@@ -12714,23 +12935,21 @@ function pressUp() {
12714
12935
  playRelease()
12715
12936
  return
12716
12937
  }
12717
- // click: start Ya2 in the last 100ms of Ya1's playback
12938
+ // click:把松开音排到"按压音结束前 RELEASE_LEAD_MS 毫秒"(默认 0 = 正好接上)
12718
12939
  var durKnown = false
12719
- var remainMs = 0
12940
+ var remainSec = 0
12720
12941
  try {
12721
12942
  var dur = pressAudio ? pressAudio.duration : 0
12722
12943
  if (isFinite(dur) && dur > 0) {
12723
12944
  durKnown = true
12724
- remainMs = (dur - pressAudio.currentTime) * 1000
12945
+ remainSec = Math.max(0, dur - pressAudio.currentTime)
12725
12946
  }
12726
12947
  } catch (err) {}
12727
12948
  if (durKnown) {
12728
- releaseTimer = setTimeout(function () {
12729
- releaseTimer = null
12730
- playRelease()
12731
- }, Math.max(0, remainMs - 100))
12949
+ playReleaseAt(Math.max(0, remainSec - RELEASE_LEAD_MS / 1000))
12950
+ return
12732
12951
  }
12733
- // duration unknown pressAudio.onended fallback plays Ya2 after Ya1 ends
12952
+ // 时长未知(预热失败/解码异常)→ 交给 pressAudio.onended 兜底(见 playPress)
12734
12953
  }
12735
12954
  var menuOpen = false
12736
12955
  var menuClosedAt = 0 // 最近一次关闭菜单的时刻(用于避免"关掉后同一次手势又把它长按打开")
@@ -13218,6 +13437,9 @@ function openCropModal(dataUrl, fileName) {
13218
13437
  cropAngle.value = '0'
13219
13438
  cropAngleNum.value = '0'
13220
13439
  positionCrop()
13440
+ // v744:裁剪窗口既可能从主菜单打开,也可能从资源管理(20300)/泡泡编辑器(20500)里打开,
13441
+ // 固定 20000 在后者会被父窗口盖住 → 动态抬层(见 visibleTopZ 上方的分层表)
13442
+ dshwLayerUp(cropMask, 20000)
13221
13443
  cropMask.style.display = 'flex'
13222
13444
  }
13223
13445
  imgEl.onerror = function () {}
@@ -13668,10 +13890,10 @@ function hideAudioEditor() {
13668
13890
  }
13669
13891
  // 组编辑弹窗试听:完全模拟挂件按压交互。
13670
13892
  // pointerdown(按下)→ 播放按压片段,进入"按住"状态(按压音播完仍按着则静默等待,不重复);
13671
- // pointerup/cancel/leave(松开)→ 若按压音已结束则立即播松开片段,否则在按压音最后 100ms 播松开(同挂件 click 重叠逻辑)。
13893
+ // pointerup/cancel/leave(松开)→ 若按压音已结束则立即播松开片段,
13894
+ // 否则把松开音**排期到按压音正好放完**的那一刻(音频线程排期;与挂件本体同一套做法,不重叠)。
13672
13895
  var audioEditPreviewEl = null
13673
13896
  var audioEditPreviewRelease = null
13674
- var audioEditPreviewTimer = null
13675
13897
  var audioEditPreviewReady = false
13676
13898
  var audioEditPreviewPressing = false // 按住状态
13677
13899
  var audioEditPreviewPressEnded = false // 按压音已播完
@@ -13699,6 +13921,11 @@ function audioEditPreviewEnsure(force) {
13699
13921
  audioEditPreviewRelease.preload = 'auto'
13700
13922
  audioEditPreviewRelease.volume = soundVol
13701
13923
  }
13924
+ // v745:试听也要预热(否则第一次点按试听同样是"按下音慢半拍")
13925
+ dshwvWarm([
13926
+ pressId ? '/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(pressId) : '',
13927
+ releaseId ? '/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(releaseId) : '',
13928
+ ])
13702
13929
  audioEditPreviewReady = true
13703
13930
  return true
13704
13931
  } catch (err) { return false }
@@ -13715,8 +13942,11 @@ function audioEditPreviewDown() {
13715
13942
  // 按下:播放按压音
13716
13943
  audioEditPreviewEl.onended = function () {
13717
13944
  audioEditPreviewPressEnded = true
13718
- // fallback:若松手发生在按压音结束之后,立即播松开
13719
- if (!audioEditPreviewPressing && !audioEditPreviewReleasePlayed) audioEditPreviewUp()
13945
+ // fallback:松手发生在按压音结束**之前**(= 直接点按)时,按压音播完立刻补上松开音。
13946
+ // ⚠️ 这里必须直接调 audioEditPreviewPlayRelease(),**不能**调 audioEditPreviewUp()
13947
+ // 后者开头是 `if (!audioEditPreviewPressing) return`,而点按松手时 pressing 已经是 false
13948
+ // → 直接 return,松开音永远不响(这就是"直接点按只有按下音、长按才听到松开音"的根因)。
13949
+ if (!audioEditPreviewPressing && !audioEditPreviewReleasePlayed) audioEditPreviewPlayRelease()
13720
13950
  }
13721
13951
  var p = audioEditPreviewEl.play()
13722
13952
  if (p && typeof p.catch === 'function') p.catch(function () {})
@@ -13732,38 +13962,42 @@ function audioEditPreviewUp() {
13732
13962
  audioEditPreviewPlayRelease()
13733
13963
  return
13734
13964
  }
13735
- // 快速点击(松手时按压音未播完):在按压音最后 100ms 播松开
13965
+ // 快速点击(松手时按压音未播完):与本体一致 —— 排到"按压音结束前 RELEASE_LEAD_MS"
13966
+ // (默认 0 = 正好接上;音频线程排期,不依赖主线程定时器)
13736
13967
  var durKnown = false
13737
- var remainMs = 0
13968
+ var remainSec = 0
13738
13969
  try {
13739
13970
  var dur = audioEditPreviewEl ? audioEditPreviewEl.duration : 0
13740
13971
  if (isFinite(dur) && dur > 0) {
13741
13972
  durKnown = true
13742
- remainMs = (dur - audioEditPreviewEl.currentTime) * 1000
13973
+ remainSec = Math.max(0, dur - audioEditPreviewEl.currentTime)
13743
13974
  }
13744
13975
  } catch (err) {}
13745
13976
  if (durKnown) {
13746
- audioEditPreviewTimer = setTimeout(function () {
13747
- audioEditPreviewTimer = null
13748
- audioEditPreviewPlayRelease()
13749
- }, Math.max(0, remainMs - 100))
13977
+ audioEditPreviewPlayRelease(Math.max(0, remainSec - RELEASE_LEAD_MS / 1000))
13978
+ return
13750
13979
  }
13751
13980
  // duration 未知 → onended fallback 播放松开
13752
13981
  } catch (err) {}
13753
13982
  }
13754
- function audioEditPreviewPlayRelease() {
13983
+ // 兜底里必须**直接**调它(不能调 audioEditPreviewUp() —— 后者以 pressing 为前置,
13984
+ // 点按松手时已为 false 会直接 return,导致"直接点按只有按下音")。delaySec>0 时走音频线程排期。
13985
+ function audioEditPreviewPlayRelease(delaySec) {
13755
13986
  try {
13756
13987
  if (audioEditPreviewReleasePlayed || !audioEditPreviewRelease) return
13757
13988
  audioEditPreviewReleasePlayed = true
13758
13989
  audioEditPreviewRelease.currentTime = 0
13990
+ if (delaySec > 0 && typeof audioEditPreviewRelease.playAt === 'function') {
13991
+ audioEditPreviewRelease.playAt(delaySec)
13992
+ return
13993
+ }
13759
13994
  var p = audioEditPreviewRelease.play()
13760
13995
  if (p && typeof p.catch === 'function') p.catch(function () {})
13761
13996
  } catch (err) {}
13762
13997
  }
13763
13998
  function stopAudioEditPreview() {
13764
13999
  try {
13765
- if (audioEditPreviewTimer) { clearTimeout(audioEditPreviewTimer); audioEditPreviewTimer = null }
13766
- // 不销毁 Audio 元素(保持预加载状态,与挂件持久 Audio 一致),只暂停复位
14000
+ // v745:松开音改由音频线程排期,没有主线程定时器要清
13767
14001
  if (audioEditPreviewEl) {
13768
14002
  audioEditPreviewEl.pause()
13769
14003
  audioEditPreviewEl.currentTime = 0
@@ -13819,7 +14053,7 @@ var audioCropFileInput = document.createElement('input')
13819
14053
  audioCropFileInput.type = 'file'
13820
14054
  audioCropFileInput.accept = 'audio/*'
13821
14055
  audioCropFileInput.style.display = 'none'
13822
- document.body.appendChild(audioCropFileInput)
14056
+ dshwBodyAppend(audioCropFileInput)
13823
14057
  var audioCropTarget = null // 'press' | 'release'
13824
14058
  var audioCropCtx = null // AudioContext
13825
14059
  var audioCropBuffer = null // AudioBuffer
@@ -13866,6 +14100,7 @@ function openAudioCrop(arrayBuf, fileName) {
13866
14100
  drawAudioCrop()
13867
14101
  try { audioCropName.value = '' } catch (err) {}
13868
14102
  updateAudioCropOkState()
14103
+ dshwLayerUp(audioCropMask, 20500) // v744:音频裁剪也可能从资源管理(20300)里打开
13869
14104
  audioCropMask.style.display = 'flex'
13870
14105
  }, function () { alert('音频解码失败') })
13871
14106
  } catch (err) {}
package/lib/index.js CHANGED
@@ -434,10 +434,15 @@ const RUA_GIF_CANDIDATES = [
434
434
  path.join(PACKAGE_ROOT, 'assets', 'rua.gif'),
435
435
  ]
436
436
  // DeepSeek CNY prices per million tokens: [空闲时段价, 高峰时段价].
437
- // 高峰时段:工作日 9:00–12:00 和 14:00–18:00(北京时间);2026-08-23 起周末全天谷价。
437
+ // 高峰时段(官方 2026-09-19 说明,见 docs 脚注 (2)):**北京时间周一至周五(不含中国法定节假日)
438
+ // 9:00–12:00、14:00–18:00**;其余时段 —— 包括**周末、调休上班的周末、以及中国法定节假日全天** ——
439
+ // 一律按空闲时段计费。
440
+ // 时间线:2026-08-17 峰谷定价正式实施 → 2026-08-23 00:00 起周末全天谷价 →
441
+ // 2026-09-19 明确「调休上班的周末 + 法定节假日全天」也按空闲计。
438
442
  // 价格来源:官方 https://api-docs.deepseek.com/zh-cn/quick_start/pricing
439
443
  // 2026-09-10 起 Flash 系列降价:缓存命中 0.05→0.02、未命中 1.5→1、输出 4.5→4(高峰=空闲×2)。
440
- // 2026-09-14 复核:价格数字与上表一致,无需改动。
444
+ // 2026-09-19 复核:价格数字与上表一致(Flash 0.02/1/4 与 0.04/2/8;Pro 0.15/4.5/13.5 与 0.30/9/27),
445
+ // 峰谷规则新增「法定节假日全天谷价」这一条,见下方 HOLIDAY_VALLEY。
441
446
  const PEAK_HOURS = [
442
447
  [9, 12],
443
448
  [14, 18],
@@ -483,6 +488,37 @@ function priceFor(model) {
483
488
  // 2026-08-23 起(北京时间)周末(周六/周日)全天按谷价;生效时刻之前的历史
484
489
  // 分桶仍按旧规则计价,所以周末判定带生效分界。
485
490
  const WEEKEND_VALLEY_FROM_SEC = Math.floor(Date.UTC(2026, 7, 22, 16, 0, 0) / 1000) // = 北京时间 2026-08-23 00:00
491
+ // ===== 法定节假日全天谷价(官方 2026-09-19 说明)=====
492
+ // 依据:《国务院办公厅关于 2026 年部分节假日安排的通知》(国办发明电〔2025〕7 号,2025-11-04)
493
+ // + DeepSeek API 官方计费脚注「周一至周五(不含中国法定节假日)…其余时段,包括周末及
494
+ // 中国法定节假日全天均为空闲时段」。
495
+ // 只需列**放假**的日期:调休上班日全部落在周末(2026 年为 1/4、2/14、2/28、5/9、9/20、10/10),
496
+ // 按"周末也算谷价"的规则本来就是谷价,无需单列。
497
+ // ⚠️ **每年 11 月国务院发布次年安排后,必须在这里补下一年的日期**(探针 `_peak-holiday-check.mjs`
498
+ // 会检查当年是否已覆盖并给出提醒)。
499
+ const HOLIDAY_VALLEY = {
500
+ '2026-01-01': 1, '2026-01-02': 1, '2026-01-03': 1, // 元旦 1/1–1/3(1/4 周日上班)
501
+ '2026-02-15': 1, '2026-02-16': 1, '2026-02-17': 1, '2026-02-18': 1, '2026-02-19': 1, // 春节 2/15–2/23(9 天)
502
+ '2026-02-20': 1, '2026-02-21': 1, '2026-02-22': 1, '2026-02-23': 1,
503
+ '2026-04-04': 1, '2026-04-05': 1, '2026-04-06': 1, // 清明 4/4–4/6
504
+ '2026-05-01': 1, '2026-05-02': 1, '2026-05-03': 1, '2026-05-04': 1, '2026-05-05': 1, // 劳动节 5/1–5/5(5/9 周六上班)
505
+ '2026-06-19': 1, '2026-06-20': 1, '2026-06-21': 1, // 端午 6/19–6/21
506
+ '2026-09-25': 1, '2026-09-26': 1, '2026-09-27': 1, // 中秋 9/25–9/27
507
+ '2026-10-01': 1, '2026-10-02': 1, '2026-10-03': 1, '2026-10-04': 1, // 国庆 10/1–10/7(9/20 周日、10/10 周六上班)
508
+ '2026-10-05': 1, '2026-10-06': 1, '2026-10-07': 1,
509
+ }
510
+ // 这条规则自 2026-09-19 起生效(此前历史分桶按旧规则计价)。2026 年内落在 8/17(峰谷机制实施)
511
+ // 与 9/19 之间没有任何"工作日的法定节假日",所以对 2026 年的账目没有影响;次年(2027)之后
512
+ // 整年的节假日都会走到这个分支。
513
+ const HOLIDAY_VALLEY_FROM_SEC = Math.floor(Date.UTC(2026, 8, 18, 16, 0, 0) / 1000) // = 北京时间 2026-09-19 00:00
514
+ // 下发给前端(挂件)的节假日清单:前端「时段倒计时」也要按节假日算下一切换点,
515
+ // 而前端无法自己知道法定安排,所以由宿主单一来源下发。每次 /dsh-whale/balance 都会带上。
516
+ const HOLIDAY_VALLEY_LIST = Object.keys(HOLIDAY_VALLEY).sort()
517
+ function isHolidayValley(bjDate) {
518
+ try {
519
+ return !!HOLIDAY_VALLEY[bjDate.toISOString().slice(0, 10)]
520
+ } catch (err) { return false }
521
+ }
486
522
  function isPeakTime(timeSec) {
487
523
  if (!isFinite(Number(timeSec))) return false
488
524
  const n = Number(timeSec)
@@ -491,12 +527,32 @@ function isPeakTime(timeSec) {
491
527
  const dow = bj.getUTCDay() // 0=周日 6=周六(bj 按 UTC 读即为北京日历日)
492
528
  if (dow === 0 || dow === 6) return false
493
529
  }
530
+ // 法定节假日全天谷价(含调休放假的工作日)
531
+ if (n >= HOLIDAY_VALLEY_FROM_SEC && isHolidayValley(bj)) return false
494
532
  const hour = bj.getUTCHours()
495
533
  for (const [start, end] of PEAK_HOURS) {
496
534
  if (hour >= start && hour < end) return true
497
535
  }
498
536
  return false
499
537
  }
538
+ // 下一个峰谷切换时刻(epoch 秒):与 isPeakTime 完全同源(含周末 + 法定节假日规则)。
539
+ // 只需扫北京时间的小时边界 0/9/12/14/18 点;最长假期(春节 9 天)也只到第 9 天,
540
+ // 这里扫 12 天留足余量。返回 null 表示 12 天内没有切换点(当前规则下不会发生),
541
+ // 由前端回退到自己的本地推算。
542
+ function nextPeakChangeAt(timeSec) {
543
+ const n = Number(timeSec)
544
+ if (!isFinite(n)) return null
545
+ const cur = isPeakTime(n)
546
+ const day0 = Math.floor((n + 8 * 3600) / 86400) * 86400 // 北京当日 00:00(在 +8h 平移坐标系里)
547
+ for (let d = 0; d <= 12; d++) {
548
+ for (const edge of [0, 9, 12, 14, 18]) {
549
+ const cand = day0 + d * 86400 + edge * 3600 - 8 * 3600
550
+ if (cand <= n + 1) continue
551
+ if (isPeakTime(cand) !== cur) return cand
552
+ }
553
+ }
554
+ return null
555
+ }
500
556
 
501
557
  const JSON_HEADERS = {
502
558
  'Content-Type': 'application/json; charset=utf-8',
@@ -929,7 +985,7 @@ export default {
929
985
  const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
930
986
  for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
931
987
  return {
932
- ok: true, version: '0.3.6', today: todayData, days7,
988
+ ok: true, version: '0.3.8', today: todayData, days7,
933
989
  total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
934
990
  all: {
935
991
  days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
@@ -1312,20 +1368,30 @@ export default {
1312
1368
  let codexCatchupT = null
1313
1369
  // 配置变更(例如用户在设置里关掉 Codex 统计)后必须让快照失效,
1314
1370
  // 否则下一次请求还会命中关闭前的旧快照、开关看起来"没生效"。
1315
- function codexInvalidate() { codexSnap = null; codexSnapAt = 0 }
1371
+ function codexInvalidate() { codexSnap = null; codexSnapAt = 0; codexStatsOnV = null; codexStatsOnAt = 0 }
1372
+ // 开关读取加 2 秒缓存:codexBackgroundRefresh 可能被高频调用(快照过期时每次 payload
1373
+ // 构建都会试一次),而 readSizeConfig() 是同步文件读 —— 不能放在热路径上。
1374
+ // 用户改设置时 codexInvalidate() 会清掉缓存,所以开关仍然是"立刻生效"。
1375
+ let codexStatsOnV = null, codexStatsOnAt = 0
1316
1376
  function codexStatsOn() {
1377
+ const now = Date.now()
1378
+ if (codexStatsOnV !== null && now - codexStatsOnAt < 2000) return codexStatsOnV
1379
+ let v = true
1317
1380
  try {
1318
1381
  const cfg = readSizeConfig()
1319
- return !cfg || cfg.codexStatsOn !== false
1320
- } catch (err) { return true }
1382
+ v = !cfg || cfg.codexStatsOn !== false
1383
+ } catch (err) { v = true }
1384
+ codexStatsOnV = v
1385
+ codexStatsOnAt = now
1386
+ return v
1321
1387
  }
1322
1388
  function codexBackgroundRefresh(delayMs) {
1389
+ if (codexScanP) return codexScanP // 已在扫:先返回,避免重复读配置/重复排队
1323
1390
  if (!codexStatsOn()) {
1324
1391
  codexSnap = { ok: false, disabled: true, error: 'Codex 统计已在设置里关闭' }
1325
1392
  codexSnapAt = Date.now()
1326
1393
  return null
1327
1394
  }
1328
- if (codexScanP) return codexScanP
1329
1395
  if (delayMs > 0) {
1330
1396
  if (codexCatchupT) return null
1331
1397
  codexCatchupT = setTimeout(() => { codexCatchupT = null; codexBackgroundRefresh(0) }, delayMs)
@@ -2093,8 +2159,12 @@ export default {
2093
2159
  const { accountTag, ...visible } = payload
2094
2160
  const led = readUsageLedger()
2095
2161
  const summary = daySummary(led, todayKey())
2162
+ const nowSec = Math.floor(Date.now() / 1000)
2096
2163
  return {
2097
- ...visible, version: '0.3.6', isPeak: isPeakTime(Math.floor(Date.now() / 1000)),
2164
+ ...visible, version: '0.3.8', isPeak: isPeakTime(nowSec),
2165
+ // 峰谷切换点与节假日清单:前端倒计时要与宿主同源(否则法定节假日会算错切换点)
2166
+ peakNextChangeAt: nextPeakChangeAt(nowSec),
2167
+ peakHolidays: HOLIDAY_VALLEY_LIST,
2098
2168
  todayUsage: summary.amount, todayUsageCurrency: summary.currency,
2099
2169
  usageSource: summary.source, usageLabel: summary.label, usageMode: 'ledger',
2100
2170
  accounting: balanceSummary(led, todayKey()),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-widget",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件:余额/今日已用/峰谷定价、自定义泡泡点击序列(文本/余额/今日/峰谷/图片/随机语句与并列加权选择)、逐行样式与字体、悬浮快捷编辑、音效与每轮消耗、自定义角色/动图/音效、吸附与翻转自定义",
5
5
  "keywords": [
6
6
  "dsh",