dsh-whale-widget 0.3.7 → 0.3.9

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.
@@ -79,21 +79,34 @@ function dshwInit() {
79
79
  if (window.__dshWhaleInit) return
80
80
  window.__dshWhaleInit = true
81
81
 
82
- // ===== v739:音效改用 Web Audio 播放(不再用 <audio> / HTMLAudioElement)=====
83
- // 为什么改:HTMLAudioElement 会被 macOS 注册进系统「正在播放」,带 Touch Bar 的机器上
84
- // 每次按小鲸鱼都会弹出音频播放条并持续动画 明显卡顿(用户反馈)。AudioBufferSourceNode
85
- // 不经过媒体元素,系统媒体控件不会出现,也没有那层额外的解码/合成开销。
86
- // 兼容做法:给一个与 HTMLAudioElement 常用表面一致的 shim(play/pause/currentTime/volume/
87
- // 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
+ // 不用动任何逻辑。
88
95
  var dshwvAudioCtx = null
89
96
  function dshwvAudio() {
90
97
  try {
91
- 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
+ }
92
103
  if (dshwvAudioCtx.state === 'suspended') { try { dshwvAudioCtx.resume() } catch (err) {} }
93
104
  return dshwvAudioCtx
94
105
  } catch (err) { return null }
95
106
  }
96
- 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() } }
97
110
  function dshwvAudioBuffer(url) {
98
111
  if (!url) return Promise.reject(new Error('empty url'))
99
112
  if (!dshwvAudioBuffers[url]) {
@@ -104,62 +117,99 @@ function dshwvAudioBuffer(url) {
104
117
  if (!c) throw new Error('no audio context')
105
118
  return new Promise(function (res, rej) { c.decodeAudioData(raw, res, rej) })
106
119
  })
107
- .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 })
108
122
  }
109
123
  return dshwvAudioBuffers[url]
110
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
+ }
111
136
  function dshwvSoundStop(el) {
112
137
  el._token = (el._token || 0) + 1
113
138
  var node = el._node
114
139
  el._node = null
140
+ el._playingSince = 0
115
141
  if (node) {
116
142
  try { node.onended = null } catch (err) {}
117
143
  try { node.stop() } catch (err) {}
118
144
  }
119
145
  }
120
146
  function dshwvSound(url) {
121
- 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 }
122
148
  Object.defineProperty(el, 'src', {
123
149
  get: function () { return el._url },
124
150
  set: function (v) { dshwvSoundStop(el); el._url = String(v || ''); el._offset = 0 },
125
151
  })
126
152
  Object.defineProperty(el, 'currentTime', {
127
- 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
+ },
128
162
  // 既有逻辑用「currentTime = 0」表示重播 → 这里顺手停掉正在播的那一份
129
- set: function (v) { el._offset = Number(v) || 0; dshwvSoundStop(el) },
163
+ set: function (v) { el._offset = Number(v) || 0; el._playingSince = 0; dshwvSoundStop(el) },
130
164
  })
131
- el.play = function () {
132
- var c = dshwvAudio()
133
- 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) {
134
174
  try {
135
175
  if (!el._gain) { el._gain = c.createGain(); el._gain.connect(c.destination) }
136
176
  el._gain.gain.value = Math.max(0, Math.min(1, Number(el.volume) || 0))
137
- } 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()
138
195
  var token = (el._token = (el._token || 0) + 1)
139
- var url = el._url
140
- 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) {
141
201
  if (token !== el._token) return // 期间被重播/暂停/换源 → 丢弃这次
142
- try {
143
- var src = c.createBufferSource()
144
- src.buffer = buf
145
- src.connect(el._gain)
146
- src.onended = function () {
147
- if (el._node !== src) return
148
- el._node = null
149
- if (typeof el.onended === 'function') { try { el.onended() } catch (err) {} }
150
- }
151
- el._node = src
152
- var dur = Math.max(0.001, buf.duration)
153
- src.start(0, Math.max(0, el._offset) % dur)
154
- } catch (err) {}
202
+ beginWithBuffer(c, buf, delay)
155
203
  }).catch(function () {})
156
204
  return Promise.resolve()
157
205
  }
206
+ el.play = function () { return startSound(0) }
207
+ el.playAt = function (delaySec) { return startSound(delaySec) }
158
208
  el.pause = function () { dshwvSoundStop(el) }
159
209
  return el
160
210
  }
161
- // 自动播放策略:AudioContext 初始是 suspended,要有一次用户手势才能出声。
162
- // 任务结束音不是手势触发的,所以先挂一次性解锁(首次点击/按键后移除)。
211
+ // 自动播放策略:AudioContext 初始是 suspended,要有一次用户手势才能出声;任务结束音不是手势触发的,
212
+ // 所以挂一次性解锁(首次点击/按键后移除)。
163
213
  try {
164
214
  var dshwvAudioUnlock = function () {
165
215
  dshwvAudio()
@@ -833,7 +883,7 @@ function dshwCustSelClose() {
833
883
  if (!o) return
834
884
  try {
835
885
  o.menu.classList.remove('dshwv-rgbopen')
836
- if (o.menu.parentNode === document.body) document.body.removeChild(o.menu)
886
+ dshwBodyDetach(o.menu) // v744:登记过的 body 节点必须走 detach,否则会被 DOM 守护补挂回来
837
887
  } catch (err) {}
838
888
  }
839
889
  if (!window.__dshwCustBound) {
@@ -1189,6 +1239,7 @@ function playTaskEndGroupClick(groupId) {
1189
1239
  // 按压留空:无按下音,直接播松开(模拟按下即松开的完整点按);松开留空:只播按压
1190
1240
  if (pressEmpty) {
1191
1241
  if (!releaseEmpty) {
1242
+ dshwvWarm(['/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId)]) // v745:先预热
1192
1243
  var relOnly = dshwvSound('/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId))
1193
1244
  try { relOnly.volume = vol } catch (err) {}
1194
1245
  relOnly.currentTime = 0
@@ -1199,6 +1250,11 @@ function playTaskEndGroupClick(groupId) {
1199
1250
  }
1200
1251
  var press = dshwvSound('/dsh-whale/sound/press.mp3?set=' + encodeURIComponent(groupId))
1201
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
+ ])
1202
1258
  if (releaseEmpty) {
1203
1259
  press.currentTime = 0
1204
1260
  var pp = press.play()
@@ -1394,17 +1450,21 @@ var rowHide = menuRow()
1394
1450
  rowHide.appendChild(menuLabel('隐藏菜单按钮'))
1395
1451
  rowHide.appendChild(menuHideToggle)
1396
1452
  menuBox.appendChild(rowHide)
1397
- // —— Codex 本机统计开关(issue #116):关掉后宿主不再扫描 ~/.codex/sessions ——
1398
- var codexStatsToggle = document.createElement('input')
1399
- codexStatsToggle.type = 'checkbox'
1400
- codexStatsToggle.className = 'dshwv-check'
1401
- codexStatsToggle.checked = true
1402
- codexStatsToggle.title = '关闭后不再读取 ~/.codex/sessions 统计本机 Codex 用量(会话日志很大时建议关闭)'
1403
- codexStatsToggle.addEventListener('change', function () { setCodexStatsOn(codexStatsToggle.checked) })
1404
- var rowCodexStats = menuRow()
1405
- rowCodexStats.appendChild(menuLabel('Codex 本机统计'))
1406
- rowCodexStats.appendChild(codexStatsToggle)
1407
- menuBox.appendChild(rowCodexStats)
1453
+ // —— Codex 本机统计开关(issue #116):v748 起**从主菜单挪进「Codex 模型的设置子菜单」** ——
1454
+ // 主菜单不再有这一项(它对没配 Codex 模型的用户没有意义)。宿主侧同样按"有没有 Codex 模型"兜底:
1455
+ // 没配 → 默认关闭、完全不扫 ~/.codex/sessions。这里只保留一个"当前挂载的那个复选框"的引用,
1456
+ // setCodexStatsOn() 与配置读回时同步勾选状态(菜单是每次重建的,所以元素现建现用)。
1457
+ var codexStatsToggle = null
1458
+ function codexStatsCheckbox() {
1459
+ var el = document.createElement('input')
1460
+ el.type = 'checkbox'
1461
+ el.className = 'dshwv-check'
1462
+ el.checked = codexStatsOn !== false
1463
+ el.title = '关闭后不再读取 ~/.codex/sessions 统计本机 Codex 用量(会话日志很大时建议关闭)'
1464
+ el.addEventListener('change', function () { setCodexStatsOn(el.checked) })
1465
+ codexStatsToggle = el
1466
+ return el
1467
+ }
1408
1468
  // —— 资源管理:集中查看/删除已导入的图片与音频(角色图/泡泡图/音频片段/音效组) ——
1409
1469
  var rowRes = menuRow()
1410
1470
  var resOpenBtn = document.createElement('button')
@@ -1913,7 +1973,7 @@ function usageAlertBudgetEditor(key, onSave) {
1913
1973
  turnCostCloseDefer = false
1914
1974
  turnCostCloseInput.disabled = !turnCostOn // 回到菜单态的禁用逻辑
1915
1975
  }
1916
- document.body.removeChild(mask)
1976
+ dshwBodyDetach(mask) // v744:走 detach 注销登记,否则 DOM 守护会把这个刚关掉的编辑器补挂回来
1917
1977
  bubbleEditItems = bkEditItems
1918
1978
  bubbleEditorSnap = bkEditorSnap
1919
1979
  bubbleItemSnap = bkItemSnap
@@ -2496,7 +2556,9 @@ function openApiModelPanel(modelId) {
2496
2556
  // 新建模型:保存后给一个明确的收尾——弹「保存成功」,点确认即关掉本面板。
2497
2557
  // (原先是重新打开为编辑态,新建流程会停在一个没有明显关闭入口的面板上)
2498
2558
  if (isNew) {
2499
- try { confirmMask.style.setProperty('z-index', '29500', 'important') } catch (err) {}
2559
+ // v744 清理:这里原先有 `confirmMask.style.zIndex = '29500' !important`,但紧接着的
2560
+ // showConfirm() 会把确认框统一提到 40000 !important,所以那行是**无效设置**(立刻被覆盖),
2561
+ // 只会让"层级"更难读。已删除 —— 确认框永远由 showConfirm 统一抬到 40000。
2500
2562
  showConfirm('✓ 保存成功:' + (nm || '新模型') + '\n已加入模型列表', function () {
2501
2563
  try { closeApiModelPanel() } catch (err) {}
2502
2564
  // 背后的「- = 小鲸鱼记账 = -」列表同步刷新(模型行在静态区,需重建子界面)
@@ -2654,9 +2716,26 @@ function openApiModelPanel(modelId) {
2654
2716
  } catch (err) {}
2655
2717
  }
2656
2718
  var apiModelMaskEl = null
2719
+ // v748:Codex 模型的设置菜单打开时,记下"当前是哪个模型"和"用量行元素",
2720
+ // 这样拨动「Codex 本机统计」开关、等宿主确认后可以**原地**刷新那一行,不必关窗重开。
2721
+ var apiModelMenuId = null
2722
+ var codexUsageTextEl = null
2723
+ function refreshOpenCodexRow() {
2724
+ try {
2725
+ if (!codexUsageTextEl || !apiModelMenuId) return
2726
+ var t = apiCodexDetailText(apiModelMenuId)
2727
+ codexUsageTextEl.textContent = t
2728
+ codexUsageTextEl.title = t
2729
+ } catch (err) {}
2730
+ }
2657
2731
  function closeApiModelPanel() {
2658
- try { if (apiModelMaskEl && apiModelMaskEl.parentNode) apiModelMaskEl.parentNode.removeChild(apiModelMaskEl) } catch (err) {}
2732
+ // v744:必须走 dshwBodyDetach —— 这个遮罩是登记在案的 body 节点,
2733
+ // 若用 parentNode.removeChild 直接摘掉,DOM 守护会认为"被别的插件摘走了"并把它补挂回来,
2734
+ // 表现就是**点「取消」关不掉这个窗口**(0.3.7 引入的回归)。
2735
+ try { if (apiModelMaskEl) dshwBodyDetach(apiModelMaskEl) } catch (err) {}
2659
2736
  apiModelMaskEl = null
2737
+ apiModelMenuId = null
2738
+ codexUsageTextEl = null
2660
2739
  }
2661
2740
  // 列宽受限 + 悬浮滚动:内容超出列宽时,鼠标移上去文字自动横向滚动,移开回到起点。
2662
2741
  // 需要 .dshwv-marq(外层 overflow:hidden)+ 内层 inline-block span 配合。
@@ -3018,8 +3097,26 @@ function openApiModelMenu(modelId) {
3018
3097
  }, function () { openModelAlertBudget(modelId, 'budget', function () { openApiModelMenu(modelId) }) })
3019
3098
  rowOf('额度(订阅/资源包)', function () { return apiQuotaSummary(modelId) },
3020
3099
  function () { openModelQuotaEditor(modelId) })
3021
- // Codex 模式:只读展示本地会话统计(机器级)
3022
- if (apiCodexOf(modelId) && apiCodexOf(modelId).ok) {
3100
+ // Codex 模式:开关(v748 从主菜单挪到这里)+ 只读展示本地会话统计(机器级)
3101
+ // 注意:开关按「是不是 Codex 模型」(apiCodexOf 非空)显示,**不能**按 .ok 判断 ——
3102
+ // 关掉时 .ok 就是 false,那样开关会自己消失、再也没法打开。
3103
+ if (apiCodexOf(modelId)) {
3104
+ var ct = document.createElement('div')
3105
+ ct.className = 'dshwv-audiorow'
3106
+ var ctl = document.createElement('span')
3107
+ ctl.textContent = 'Codex 本机统计'
3108
+ ctl.style.flex = '0 0 auto'
3109
+ ct.appendChild(ctl)
3110
+ var cth = document.createElement('span')
3111
+ cth.className = 'dshwv-usage-hint'
3112
+ cth.style.flex = '1'
3113
+ cth.style.textAlign = 'right'
3114
+ cth.style.paddingRight = '6px'
3115
+ cth.textContent = '读取 ~/.codex/sessions'
3116
+ ct.appendChild(cth)
3117
+ ct.appendChild(codexStatsCheckbox())
3118
+ card.appendChild(ct)
3119
+ // 用量行:关闭/出错时同样显示(文字就是原因),开着时显示今日/近7天/累计
3023
3120
  var cr = document.createElement('div')
3024
3121
  cr.className = 'dshwv-audiorow'
3025
3122
  var cl = document.createElement('span')
@@ -3038,6 +3135,9 @@ function openApiModelMenu(modelId) {
3038
3135
  ci.title = ci.textContent
3039
3136
  cr.appendChild(ci)
3040
3137
  card.appendChild(cr)
3138
+ // 记下来:开关切完拿到服务端确认后,原地刷新这一行(不用关窗重开)
3139
+ codexUsageTextEl = ci
3140
+ apiModelMenuId = modelId
3041
3141
  }
3042
3142
  // 厂商订阅额度(kind='quota'):只读展示,来自厂商接口
3043
3143
  if (apiPlanSupport(modelId)) {
@@ -4149,11 +4249,11 @@ function usagePopupCard(title, content, below, amount) {
4149
4249
  ok.type = 'button'
4150
4250
  ok.className = 'dshwv-bubbtn dshwv-bubbtn-ok'
4151
4251
  ok.textContent = '知道了'
4152
- ok.addEventListener('click', function () { try { document.body.removeChild(mask) } catch (err) {} })
4252
+ ok.addEventListener('click', function () { try { dshwBodyDetach(mask) } catch (err) {} })
4153
4253
  btns.appendChild(ok)
4154
4254
  card.appendChild(btns)
4155
4255
  mask.appendChild(card)
4156
- mask.addEventListener('click', function (e) { if (e.target === mask) { try { document.body.removeChild(mask) } catch (err) {} } })
4256
+ mask.addEventListener('click', function (e) { if (e.target === mask) { try { dshwBodyDetach(mask) } catch (err) {} } })
4157
4257
  dshwBodyAppend(mask)
4158
4258
  } catch (err) {}
4159
4259
  }
@@ -8437,9 +8537,46 @@ var bubbleRgbOpenMenu = null // 当前展开的 .dshwv-rgbmenu
8437
8537
  var bubbleFontOpenMenu = null // 当前展开的字体下拉(复用 rgbmenu 类,额外限高)
8438
8538
  var bubbleColorOpenMenu = null // 当前展开的颜色下拉(纯色/跑马灯,限高)
8439
8539
  // —— 弹层/菜单层级统一助手(所有下拉与弹窗据此取高于当前可见层,避免互相压制) ——
8540
+ // ============================================================================
8541
+ // 层级(z-index)分层表 —— **改层之前先读这里**
8542
+ // 所有浮层都直接挂在 <body> 上,谁盖谁完全由 z-index 决定。历史上出过
8543
+ // 「子窗口被父窗口盖住点不到」「窗口关不掉」这类问题,所以这里把层段定死,并约定三条铁律:
8544
+ // ① 新增浮层**先在下面的表里选一个层段**,不要随手写数字;
8545
+ // ② 凡是「可能从别的窗口里被打开」的窗口,必须用 dshwLayerUp(el, 本段起点),
8546
+ // 这样父窗口在 29000 时子窗口自动落到 29010,永远不会被盖住;
8547
+ // ③ visibleTopZ() 的候选表必须包含**全部**浮层节点(漏一个就会低估"当前最高层"),
8548
+ // 唯一例外是 toast —— 它是刻意的最顶层,不参与追赶。
8549
+ //
8550
+ // 层段 用途 载体
8551
+ // ---------------------------------------------------------------------------
8552
+ // 1 – 999 挂件本体内部元素 .dshwv-pop(1) / .dshwv-menu-btn(2) / 拖拽把手(3-5)
8553
+ // 30 / 60 面板内的自绘下拉 .dshwv-colpop(30) / .dshwv-rgbmenu(60)
8554
+ // 9999 挂件本体 .dshwv-root
8555
+ // 10000 – 19999 主菜单与其列表 .dshwv-menu(10000) / .dshwv-rolelist·audiolist(10001)
8556
+ // 20000 – 20999 一级编辑器 .dshwv-cropmask·gifmask(20000) / .dshwv-resmask(20300) /
8557
+ // .dshwv-audiomask·bubmask(20500) / .dshwv-slotlist(20600)
8558
+ // 21000 – 21999 对话框基础层 .dshwv-confirmmask(21000,实际被 showConfirm 提到 40000)
8559
+ // 22000 – 22999 记账 / 吸附窗口 .dshwv-snapmask·usage-mask(22000)
8560
+ // 26000 – 26999 小浮层 .dshwv-qedit(26000) / .dshwv-usagepanel(26020) /
8561
+ // .dshwv-tplhelp·动态提示(26080+)
8562
+ // 29000 – 29999 模型子菜单 / 模型设置 JS 显式写入(refreshModelList / openApiModelMenu)
8563
+ // 30000 – 30999 提醒·额度·余额校正编辑器 JS 显式写入(含 openModelQuotaEditor / openBalanceAdjustment)
8564
+ // 31000 – 31999 提醒编辑期间需提到顶层的浮层 JS 显式写入(moduleMask / qedit / token 提示)
8565
+ // 32000 – 32999 提醒编辑期间统一置顶的遮罩 remindZStyle 的 !important 规则
8566
+ // 40000 确认对话框(永远在所有窗口之上) showConfirm() 的 !important
8567
+ // 2147483600 提示条 toast(最高,且不参与 visibleTopZ)
8568
+ // ============================================================================
8440
8569
  function visibleTopZ() {
8441
8570
  var top = 20500
8442
- var cand = [bubbleMask, bubbleItemMask, moduleMask, usageMoreMask, qeditEl, window.__dshwRemindMask, apiModelMaskEl, accountingMask]
8571
+ // 注意:这里必须列全 —— 少一个浮层,dshwLayerUp/下拉/提示就会低估"当前最高层"而被盖住。
8572
+ // toast 刻意不列入(它是永远的最顶层,不该让别的层去追它)。
8573
+ var cand = [
8574
+ bubbleMask, bubbleItemMask, moduleMask, moduleNamePromptMask,
8575
+ cropMask, gifMask, audioCropMask, audioEditMask, resMaskEl,
8576
+ confirmMask, snapMask, usageMask, usageMoreMask,
8577
+ qeditEl, dshwvTplHelpEl, dshwvHintEl,
8578
+ apiModelMaskEl, accountingMask, window.__dshwRemindMask
8579
+ ]
8443
8580
  function eff(el) {
8444
8581
  try {
8445
8582
  if (!el) return 0
@@ -8459,6 +8596,13 @@ function visibleTopZ() {
8459
8596
  }
8460
8597
  return top
8461
8598
  }
8599
+ // 「永远在打开它的那个窗口之上」:取 本层段起点 与 当前可见最高层+10 的较大值。
8600
+ // 用在裁剪 / GIF / 音频裁剪 / 模块编辑器这些**既可能从主菜单(10000)打开、也可能从资源管理(20300)、
8601
+ // 泡泡编辑器(20500)、模型设置(29000)里打开**的窗口上 —— 固定层号在后者场景会被父窗口盖住。
8602
+ function dshwLayerUp(el, base) {
8603
+ try { if (el && el.style) el.style.zIndex = String(Math.max(base, Math.round(visibleTopZ()) + 10)) } catch (err) {}
8604
+ return el
8605
+ }
8462
8606
  // 打开主要编辑器前清理可能残留的临时层级(提醒会话遗留的 moduleMask/qedit 提升与样式)
8463
8607
  function whaleZClean() {
8464
8608
  try {
@@ -9470,6 +9614,9 @@ function openModuleEditor(m, onSave, isNew) {
9470
9614
  moduleOnSave = onSave || null
9471
9615
  moduleEditNew = !!isNew
9472
9616
  renderModuleEditor()
9617
+ // v744:模块编辑器从泡泡编辑器(20500)/资源管理(20300)里打开时,固定 20500 会与父窗口同层
9618
+ // (只靠 DOM 顺序决定谁在上面),这里统一抬到"当前最高层之上"
9619
+ dshwLayerUp(moduleMask, 20500)
9473
9620
  moduleMask.style.display = 'flex'
9474
9621
  } catch (err) {}
9475
9622
  }
@@ -9791,6 +9938,7 @@ function openGifRoleModal(dataUrl, fileName, animType) {
9791
9938
  }
9792
9939
  gifNameInput.value = ''
9793
9940
  gifPreviewImg.src = dataUrl
9941
+ dshwLayerUp(gifMask, 20000) // v744:同上,GIF 窗口也可能从资源管理/泡泡编辑器里打开
9794
9942
  gifMask.style.display = 'flex'
9795
9943
  }
9796
9944
  function hideGifRoleModal() {
@@ -10261,6 +10409,8 @@ var state = {
10261
10409
  todayUsageCurrency: 'CNY',
10262
10410
  usageLabel: '本地估算',
10263
10411
  isPeak: false,
10412
+ peakNextChangeAt: null,
10413
+ peakHolidays: null,
10264
10414
  status: 'loading',
10265
10415
  message: '',
10266
10416
  flip: false
@@ -11475,9 +11625,28 @@ function bubbleModuleText(m, avoidIdx) {
11475
11625
  return t
11476
11626
  }
11477
11627
  // —— 下个时段倒计时模块:按“工作日9-12/14-18为高峰,周末全天谷价”推算下一时段切换 ——
11628
+ // v746:法定节假日同样全天谷价。前端无法自己知道放假安排,节假日清单由宿主随余额接口下发
11629
+ // (state.peakHolidays,单一来源),切换点也优先用宿主算好的绝对值(state.peakNextChangeAt)。
11630
+ var bubbleHolidaySet = null
11631
+ var bubbleHolidaySetKey = ''
11632
+ function bubbleHolidaySetOf() {
11633
+ var list = (state && state.peakHolidays) || null
11634
+ var key = Array.isArray(list) ? list.join(',') : ''
11635
+ if (key !== bubbleHolidaySetKey) {
11636
+ bubbleHolidaySetKey = key
11637
+ bubbleHolidaySet = {}
11638
+ if (Array.isArray(list)) for (var i = 0; i < list.length; i++) bubbleHolidaySet[String(list[i])] = 1
11639
+ }
11640
+ return bubbleHolidaySet
11641
+ }
11642
+ function bubbleBJHolidayKey(bj) {
11643
+ try { return bj.toISOString().slice(0, 10) } catch (err) { return '' }
11644
+ }
11478
11645
  function bubbleCountdownIsPeak(sec) {
11479
11646
  sec = isFinite(Number(sec)) ? Number(sec) : Math.floor(Date.now() / 1000)
11480
11647
  var bj = new Date((sec + 8 * 3600) * 1000)
11648
+ var hs = bubbleHolidaySetOf()
11649
+ if (hs && hs[bubbleBJHolidayKey(bj)]) return false // 法定节假日全天谷价
11481
11650
  var dow = bj.getUTCDay()
11482
11651
  var h = bj.getUTCHours()
11483
11652
  if (dow === 0 || dow === 6) return false
@@ -11485,10 +11654,14 @@ function bubbleCountdownIsPeak(sec) {
11485
11654
  }
11486
11655
  function bubbleCountdownNextChange(sec) {
11487
11656
  sec = isFinite(Number(sec)) ? Number(sec) : Math.floor(Date.now() / 1000)
11657
+ // 宿主已就绪时直接用其算好的切换点(与计费同源,含法定节假日);
11658
+ // 只接受未来 12 天内的值——切过去之后该值会过期,回退到本地推算,避免倒计时卡在 00:00:00
11659
+ var hostCand = Number(state && state.peakNextChangeAt)
11660
+ if (isFinite(hostCand) && hostCand > sec + 1 && hostCand - sec <= 12 * 86400) return hostCand
11488
11661
  var cur = bubbleCountdownIsPeak(sec)
11489
11662
  var bjDay0 = Math.floor((sec + 8 * 3600) / 86400) * 86400
11490
11663
  var best = null
11491
- for (var d = 0; d <= 8 && best === null; d++) {
11664
+ for (var d = 0; d <= 12 && best === null; d++) {
11492
11665
  var dayStartBj = bjDay0 + d * 86400
11493
11666
  var edges = [0, 9 * 3600, 12 * 3600, 14 * 3600, 18 * 3600]
11494
11667
  for (var i = 0; i < edges.length; i++) {
@@ -12397,6 +12570,8 @@ function refresh(manual) {
12397
12570
  state.usageLabel = data.usageLabel || '本地估算'
12398
12571
  if (data.stale) state.usageLabel += ' · 余额未刷新'
12399
12572
  state.isPeak = !!data.isPeak
12573
+ state.peakNextChangeAt = isFinite(Number(data.peakNextChangeAt)) ? Number(data.peakNextChangeAt) : null
12574
+ state.peakHolidays = Array.isArray(data.peakHolidays) ? data.peakHolidays : null
12400
12575
  checkUsageAlerts(nb, state.todayUsage)
12401
12576
  if (changed && !currencyChanged) {
12402
12577
  if (!manual) {
@@ -12453,8 +12628,10 @@ var costBubbleActive = false
12453
12628
  var scrollGapOn = false
12454
12629
  var scrollGapPx = 17
12455
12630
  var menuBtnHide = false // 主菜单开关:隐藏挂件菜单按钮,改为右键小鲸鱼唤出菜单
12456
- // issue #116:Codex 本机统计开关(默认开)。关掉后宿主完全不扫 ~/.codex/sessions
12457
- // 适合会话日志很大的机器(超大日志会让统计扫描变得很重)。
12631
+ // issue #116:Codex 本机统计开关。关掉后宿主完全不扫 ~/.codex/sessions(适合会话日志很大的机器)。
12632
+ // v748:默认值仍是「开」,但**宿主会先看有没有 Codex 模型** —— 没配就一律按关闭处理,
12633
+ // 所以这里保持默认 true 是安全的(不会让没配 Codex 的用户被扫盘),也不会因为一次普通保存
12634
+ // 就把老用户的开关写成 false。开关 UI 现只在 Codex 模型的设置子菜单里(见 codexStatsCheckbox)。
12458
12635
  var codexStatsOn = true
12459
12636
  // —— v734(issue #97 / #88):设置保存的「防覆盖 + 失败可见」——
12460
12637
  // #97 根因:首次 GET 还没落地就 PUT,会把内存里的默认值整包写进服务端(重启后设置被洗成默认值)。
@@ -12478,7 +12655,8 @@ function dshwvToast(msg) {
12478
12655
  dshwvToastEl.innerHTML = msg
12479
12656
  if (dshwvToastTimer) clearTimeout(dshwvToastTimer)
12480
12657
  dshwvToastTimer = setTimeout(function () {
12481
- try { if (dshwvToastEl && dshwvToastEl.parentNode) dshwvToastEl.parentNode.removeChild(dshwvToastEl) } catch (err) {}
12658
+ // v744:toast 也是登记过的 body 节点,必须 detach(否则 DOM 守护会把它补挂回来 提示条永不消失)
12659
+ try { if (dshwvToastEl) dshwBodyDetach(dshwvToastEl) } catch (err) {}
12482
12660
  dshwvToastEl = null
12483
12661
  }, 8000)
12484
12662
  } catch (err) {}
@@ -12627,7 +12805,14 @@ function setMenuBtnHide(v) {
12627
12805
  function setCodexStatsOn(v) {
12628
12806
  codexStatsOn = v !== false
12629
12807
  if (codexStatsToggle) codexStatsToggle.checked = codexStatsOn
12630
- var after = function () { try { refreshModelList() } catch (err) {} }
12808
+ var after = function () {
12809
+ // v748:这里原来调的是 openApiModelPanel 内部的局部函数 refreshModelList(),
12810
+ // 在顶层作用域里根本不存在 → 异常被 try/catch 吞掉,"重取模型列表"其实从没发生过。
12811
+ // 现在按别处同一套做法刷新:模型列表 + (若开着)记账子界面,再原地刷新 Codex 用量行
12812
+ // (用量行的文字来自刚取回的模型列表,所以要等回调,不能立刻读旧缓存)
12813
+ try { loadApiModels(refreshOpenCodexRow, true) } catch (err) { refreshOpenCodexRow() }
12814
+ try { if (usagePanelOpen && usageSet !== null) rebuildUsageSubShell() } catch (err) {}
12815
+ }
12631
12816
  var p = null
12632
12817
  try { p = saveConfig() } catch (err) {}
12633
12818
  // 等服务端确认落盘后再重取模型列表:否则可能读到旧配置,那一行会先显示上一次的状态再跳变
@@ -12698,12 +12883,11 @@ var releaseAudio = null
12698
12883
  var pressing = false
12699
12884
  var pressEnded = false
12700
12885
  var releasePlayed = false
12701
- var releaseTimer = null
12886
+ // v745:不再需要 releaseTimer —— 点按时由 playReleaseAt() 在**音频线程**排期(见 pressUp)
12702
12887
  function applySoundSet() {
12703
12888
  try {
12704
12889
  // v729:切音效组 / 开关音效时把本轮播放状态一并复位,
12705
12890
  // 避免残留 releasePlayed=true 把新组的松开音整体吃掉(与 playPress 的修复配套)
12706
- if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null }
12707
12891
  pressEnded = false
12708
12892
  releasePlayed = false
12709
12893
  // 槽位显式留空(该事件静音)时,对应音频元素置空;playPress/playRelease 已判空
@@ -12719,6 +12903,12 @@ function applySoundSet() {
12719
12903
  releaseAudio.preload = 'auto'
12720
12904
  releaseAudio.volume = soundVol
12721
12905
  }
12906
+ // v745:把这组的按压/松开音**预取+预解码**(Web Audio 下 preload='auto' 不解码)。
12907
+ // 预热过之后,起播走 dshwvSound 的同步路径(pointerdown 同一任务里 start),手感才贴手。
12908
+ dshwvWarm([
12909
+ pEmpty ? '' : '/dsh-whale/sound/press.mp3?set=' + soundSet,
12910
+ rEmpty ? '' : '/dsh-whale/sound/release.mp3?set=' + soundSet,
12911
+ ])
12722
12912
  } catch (err) {}
12723
12913
  }
12724
12914
  function playPress() {
@@ -12727,7 +12917,6 @@ function playPress() {
12727
12917
  // 原实现里 pressAudio 为空时直接 return,跳过了 releasePlayed = false;而 playRelease()
12728
12918
  // 一旦把 releasePlayed 置为 true 就再没有任何地方复位它 → 结果是只有第一次松开有声音,
12729
12919
  // 之后每次点击都静音(用户实测:新建音效组只填松开音时复现)。
12730
- if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null }
12731
12920
  if (releaseAudio) {
12732
12921
  releaseAudio.pause()
12733
12922
  releaseAudio.currentTime = 0
@@ -12751,6 +12940,13 @@ function playPress() {
12751
12940
  if (p && typeof p.catch === 'function') p.catch(function () {})
12752
12941
  } catch (err) {}
12753
12942
  }
12943
+ // 点按时"松开音提前多少毫秒进场"(v745 把它做成**可调参数**,按耳朵微调即可):
12944
+ // 0 = 松开音正好接在按压音结束那一刻(无缝、不重叠)
12945
+ // 30 / 50 = 轻微交叠(更"黏")
12946
+ // 100 = 明显重叠(听感上可能像"重复播放"。0.3.0 代码里写的是 100,但它的主线程 setTimeout
12947
+ // 经常迟到、实际几乎听不到重叠;我们用音频线程精确排期,所以别照抄 100)
12948
+ // 当前取值:**40**(用户在 30/50 之间试听后选定)
12949
+ var RELEASE_LEAD_MS = 40
12754
12950
  function playRelease() {
12755
12951
  if (releasePlayed || !releaseAudio || !soundOn) return
12756
12952
  releasePlayed = true
@@ -12760,6 +12956,20 @@ function playRelease() {
12760
12956
  if (p && typeof p.catch === 'function') p.catch(function () {})
12761
12957
  } catch (err) {}
12762
12958
  }
12959
+ // 把松开音**排期到 delaySec 秒之后**(音频线程时间轴):起播时刻精确、不受主线程抖动影响。
12960
+ function playReleaseAt(delaySec) {
12961
+ if (releasePlayed || !releaseAudio || !soundOn) return
12962
+ releasePlayed = true
12963
+ try {
12964
+ releaseAudio.currentTime = 0 // 复位(同时停掉上一条已排期/在播的松开音)
12965
+ if (delaySec > 0 && typeof releaseAudio.playAt === 'function') {
12966
+ releaseAudio.playAt(delaySec)
12967
+ return
12968
+ }
12969
+ var p = releaseAudio.play()
12970
+ if (p && typeof p.catch === 'function') p.catch(function () {})
12971
+ } catch (err) {}
12972
+ }
12763
12973
  function pressDown() {
12764
12974
  body.style.transform = SQUISH
12765
12975
  pressing = true
@@ -12773,23 +12983,21 @@ function pressUp() {
12773
12983
  playRelease()
12774
12984
  return
12775
12985
  }
12776
- // click: start Ya2 in the last 100ms of Ya1's playback
12986
+ // click:把松开音排到"按压音结束前 RELEASE_LEAD_MS 毫秒"(默认 0 = 正好接上)
12777
12987
  var durKnown = false
12778
- var remainMs = 0
12988
+ var remainSec = 0
12779
12989
  try {
12780
12990
  var dur = pressAudio ? pressAudio.duration : 0
12781
12991
  if (isFinite(dur) && dur > 0) {
12782
12992
  durKnown = true
12783
- remainMs = (dur - pressAudio.currentTime) * 1000
12993
+ remainSec = Math.max(0, dur - pressAudio.currentTime)
12784
12994
  }
12785
12995
  } catch (err) {}
12786
12996
  if (durKnown) {
12787
- releaseTimer = setTimeout(function () {
12788
- releaseTimer = null
12789
- playRelease()
12790
- }, Math.max(0, remainMs - 100))
12997
+ playReleaseAt(Math.max(0, remainSec - RELEASE_LEAD_MS / 1000))
12998
+ return
12791
12999
  }
12792
- // duration unknown pressAudio.onended fallback plays Ya2 after Ya1 ends
13000
+ // 时长未知(预热失败/解码异常)→ 交给 pressAudio.onended 兜底(见 playPress)
12793
13001
  }
12794
13002
  var menuOpen = false
12795
13003
  var menuClosedAt = 0 // 最近一次关闭菜单的时刻(用于避免"关掉后同一次手势又把它长按打开")
@@ -13277,6 +13485,9 @@ function openCropModal(dataUrl, fileName) {
13277
13485
  cropAngle.value = '0'
13278
13486
  cropAngleNum.value = '0'
13279
13487
  positionCrop()
13488
+ // v744:裁剪窗口既可能从主菜单打开,也可能从资源管理(20300)/泡泡编辑器(20500)里打开,
13489
+ // 固定 20000 在后者会被父窗口盖住 → 动态抬层(见 visibleTopZ 上方的分层表)
13490
+ dshwLayerUp(cropMask, 20000)
13280
13491
  cropMask.style.display = 'flex'
13281
13492
  }
13282
13493
  imgEl.onerror = function () {}
@@ -13727,10 +13938,10 @@ function hideAudioEditor() {
13727
13938
  }
13728
13939
  // 组编辑弹窗试听:完全模拟挂件按压交互。
13729
13940
  // pointerdown(按下)→ 播放按压片段,进入"按住"状态(按压音播完仍按着则静默等待,不重复);
13730
- // pointerup/cancel/leave(松开)→ 若按压音已结束则立即播松开片段,否则在按压音最后 100ms 播松开(同挂件 click 重叠逻辑)。
13941
+ // pointerup/cancel/leave(松开)→ 若按压音已结束则立即播松开片段,
13942
+ // 否则把松开音**排期到按压音正好放完**的那一刻(音频线程排期;与挂件本体同一套做法,不重叠)。
13731
13943
  var audioEditPreviewEl = null
13732
13944
  var audioEditPreviewRelease = null
13733
- var audioEditPreviewTimer = null
13734
13945
  var audioEditPreviewReady = false
13735
13946
  var audioEditPreviewPressing = false // 按住状态
13736
13947
  var audioEditPreviewPressEnded = false // 按压音已播完
@@ -13758,6 +13969,11 @@ function audioEditPreviewEnsure(force) {
13758
13969
  audioEditPreviewRelease.preload = 'auto'
13759
13970
  audioEditPreviewRelease.volume = soundVol
13760
13971
  }
13972
+ // v745:试听也要预热(否则第一次点按试听同样是"按下音慢半拍")
13973
+ dshwvWarm([
13974
+ pressId ? '/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(pressId) : '',
13975
+ releaseId ? '/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(releaseId) : '',
13976
+ ])
13761
13977
  audioEditPreviewReady = true
13762
13978
  return true
13763
13979
  } catch (err) { return false }
@@ -13774,8 +13990,11 @@ function audioEditPreviewDown() {
13774
13990
  // 按下:播放按压音
13775
13991
  audioEditPreviewEl.onended = function () {
13776
13992
  audioEditPreviewPressEnded = true
13777
- // fallback:若松手发生在按压音结束之后,立即播松开
13778
- if (!audioEditPreviewPressing && !audioEditPreviewReleasePlayed) audioEditPreviewUp()
13993
+ // fallback:松手发生在按压音结束**之前**(= 直接点按)时,按压音播完立刻补上松开音。
13994
+ // ⚠️ 这里必须直接调 audioEditPreviewPlayRelease(),**不能**调 audioEditPreviewUp()
13995
+ // 后者开头是 `if (!audioEditPreviewPressing) return`,而点按松手时 pressing 已经是 false
13996
+ // → 直接 return,松开音永远不响(这就是"直接点按只有按下音、长按才听到松开音"的根因)。
13997
+ if (!audioEditPreviewPressing && !audioEditPreviewReleasePlayed) audioEditPreviewPlayRelease()
13779
13998
  }
13780
13999
  var p = audioEditPreviewEl.play()
13781
14000
  if (p && typeof p.catch === 'function') p.catch(function () {})
@@ -13791,38 +14010,42 @@ function audioEditPreviewUp() {
13791
14010
  audioEditPreviewPlayRelease()
13792
14011
  return
13793
14012
  }
13794
- // 快速点击(松手时按压音未播完):在按压音最后 100ms 播松开
14013
+ // 快速点击(松手时按压音未播完):与本体一致 —— 排到"按压音结束前 RELEASE_LEAD_MS"
14014
+ // (默认 0 = 正好接上;音频线程排期,不依赖主线程定时器)
13795
14015
  var durKnown = false
13796
- var remainMs = 0
14016
+ var remainSec = 0
13797
14017
  try {
13798
14018
  var dur = audioEditPreviewEl ? audioEditPreviewEl.duration : 0
13799
14019
  if (isFinite(dur) && dur > 0) {
13800
14020
  durKnown = true
13801
- remainMs = (dur - audioEditPreviewEl.currentTime) * 1000
14021
+ remainSec = Math.max(0, dur - audioEditPreviewEl.currentTime)
13802
14022
  }
13803
14023
  } catch (err) {}
13804
14024
  if (durKnown) {
13805
- audioEditPreviewTimer = setTimeout(function () {
13806
- audioEditPreviewTimer = null
13807
- audioEditPreviewPlayRelease()
13808
- }, Math.max(0, remainMs - 100))
14025
+ audioEditPreviewPlayRelease(Math.max(0, remainSec - RELEASE_LEAD_MS / 1000))
14026
+ return
13809
14027
  }
13810
14028
  // duration 未知 → onended fallback 播放松开
13811
14029
  } catch (err) {}
13812
14030
  }
13813
- function audioEditPreviewPlayRelease() {
14031
+ // 兜底里必须**直接**调它(不能调 audioEditPreviewUp() —— 后者以 pressing 为前置,
14032
+ // 点按松手时已为 false 会直接 return,导致"直接点按只有按下音")。delaySec>0 时走音频线程排期。
14033
+ function audioEditPreviewPlayRelease(delaySec) {
13814
14034
  try {
13815
14035
  if (audioEditPreviewReleasePlayed || !audioEditPreviewRelease) return
13816
14036
  audioEditPreviewReleasePlayed = true
13817
14037
  audioEditPreviewRelease.currentTime = 0
14038
+ if (delaySec > 0 && typeof audioEditPreviewRelease.playAt === 'function') {
14039
+ audioEditPreviewRelease.playAt(delaySec)
14040
+ return
14041
+ }
13818
14042
  var p = audioEditPreviewRelease.play()
13819
14043
  if (p && typeof p.catch === 'function') p.catch(function () {})
13820
14044
  } catch (err) {}
13821
14045
  }
13822
14046
  function stopAudioEditPreview() {
13823
14047
  try {
13824
- if (audioEditPreviewTimer) { clearTimeout(audioEditPreviewTimer); audioEditPreviewTimer = null }
13825
- // 不销毁 Audio 元素(保持预加载状态,与挂件持久 Audio 一致),只暂停复位
14048
+ // v745:松开音改由音频线程排期,没有主线程定时器要清
13826
14049
  if (audioEditPreviewEl) {
13827
14050
  audioEditPreviewEl.pause()
13828
14051
  audioEditPreviewEl.currentTime = 0
@@ -13925,6 +14148,7 @@ function openAudioCrop(arrayBuf, fileName) {
13925
14148
  drawAudioCrop()
13926
14149
  try { audioCropName.value = '' } catch (err) {}
13927
14150
  updateAudioCropOkState()
14151
+ dshwLayerUp(audioCropMask, 20500) // v744:音频裁剪也可能从资源管理(20300)里打开
13928
14152
  audioCropMask.style.display = 'flex'
13929
14153
  }, function () { alert('音频解码失败') })
13930
14154
  } 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.7', today: todayData, days7,
988
+ ok: true, version: '0.3.9', 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),
@@ -1317,22 +1373,42 @@ export default {
1317
1373
  // 构建都会试一次),而 readSizeConfig() 是同步文件读 —— 不能放在热路径上。
1318
1374
  // 用户改设置时 codexInvalidate() 会清掉缓存,所以开关仍然是"立刻生效"。
1319
1375
  let codexStatsOnV = null, codexStatsOnAt = 0
1376
+ // v748:**没配置 Codex 模型时「本机统计」默认不启用** —— 完全不去读 ~/.codex/sessions
1377
+ // (用户没加 Codex 模型时那些日志与他无关,白扫一遍既费磁盘又占事件循环)。
1378
+ // 判定按模板 kind === 'codex'(与模型列表里"Codex 用量"那一行的判定同源)。
1379
+ function hasCodexModel() {
1380
+ try {
1381
+ for (const m of readApiRegistry().models) {
1382
+ const tpl = apiTemplateOf(m && m.provider)
1383
+ if (tpl && tpl.kind === 'codex') return true
1384
+ }
1385
+ } catch (err) {}
1386
+ return false
1387
+ }
1320
1388
  function codexStatsOn() {
1321
1389
  const now = Date.now()
1322
1390
  if (codexStatsOnV !== null && now - codexStatsOnAt < 2000) return codexStatsOnV
1323
1391
  let v = true
1324
1392
  try {
1325
- const cfg = readSizeConfig()
1326
- v = !cfg || cfg.codexStatsOn !== false
1393
+ if (!hasCodexModel()) {
1394
+ v = false // 没有 Codex 模型 → 默认关闭(不是"用户关掉的",见下面的提示文案)
1395
+ } else {
1396
+ const cfg = readSizeConfig()
1397
+ v = !cfg || cfg.codexStatsOn !== false
1398
+ }
1327
1399
  } catch (err) { v = true }
1328
1400
  codexStatsOnV = v
1329
1401
  codexStatsOnAt = now
1330
1402
  return v
1331
1403
  }
1404
+ // 关闭原因的文案:区分「用户自己关的」与「压根没配 Codex 模型(默认关)」
1405
+ function codexDisabledReason() {
1406
+ try { return hasCodexModel() ? 'Codex 统计已在设置里关闭' : '未添加 Codex 模型,本机统计默认关闭' } catch (err) { return 'Codex 统计已关闭' }
1407
+ }
1332
1408
  function codexBackgroundRefresh(delayMs) {
1333
1409
  if (codexScanP) return codexScanP // 已在扫:先返回,避免重复读配置/重复排队
1334
1410
  if (!codexStatsOn()) {
1335
- codexSnap = { ok: false, disabled: true, error: 'Codex 统计已在设置里关闭' }
1411
+ codexSnap = { ok: false, disabled: true, error: codexDisabledReason() }
1336
1412
  codexSnapAt = Date.now()
1337
1413
  return null
1338
1414
  }
@@ -1838,6 +1914,8 @@ export default {
1838
1914
  }
1839
1915
  if (changed) writeBubbleConfig(cfg)
1840
1916
  } catch (err) {}
1917
+ // v748:删掉的可能是最后一个 Codex 模型 → 立即重算「本机统计」是否启用(含清掉 2s 开关缓存)
1918
+ try { codexInvalidate() } catch (err) {}
1841
1919
  return { ok: true, removedModules: removed }
1842
1920
  }
1843
1921
  // 新增/更新模型;密钥(keyValue)若提供则写入 DSH 官方凭据
@@ -1947,6 +2025,8 @@ export default {
1947
2025
  writeApiRegistry(reg)
1948
2026
  // 改完单价立即失效缓存:否则 refreshCustomPrices 的 10 秒节流会让新价最坏 10 秒后才生效
1949
2027
  customPriceAt = 0
2028
+ // v748:新增/编辑模型可能改变"有没有 Codex 模型",让 Codex 统计开关与快照立刻重算
2029
+ try { codexInvalidate() } catch (err) {}
1950
2030
  let keySaved = false
1951
2031
  if (p.keyValue !== undefined && String(p.keyValue).length) {
1952
2032
  try {
@@ -2103,8 +2183,12 @@ export default {
2103
2183
  const { accountTag, ...visible } = payload
2104
2184
  const led = readUsageLedger()
2105
2185
  const summary = daySummary(led, todayKey())
2186
+ const nowSec = Math.floor(Date.now() / 1000)
2106
2187
  return {
2107
- ...visible, version: '0.3.7', isPeak: isPeakTime(Math.floor(Date.now() / 1000)),
2188
+ ...visible, version: '0.3.9', isPeak: isPeakTime(nowSec),
2189
+ // 峰谷切换点与节假日清单:前端倒计时要与宿主同源(否则法定节假日会算错切换点)
2190
+ peakNextChangeAt: nextPeakChangeAt(nowSec),
2191
+ peakHolidays: HOLIDAY_VALLEY_LIST,
2108
2192
  todayUsage: summary.amount, todayUsageCurrency: summary.currency,
2109
2193
  usageSource: summary.source, usageLabel: summary.label, usageMode: 'ledger',
2110
2194
  accounting: balanceSummary(led, todayKey()),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-widget",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件:余额/今日已用/峰谷定价、自定义泡泡点击序列(文本/余额/今日/峰谷/图片/随机语句与并列加权选择)、逐行样式与字体、悬浮快捷编辑、音效与每轮消耗、自定义角色/动图/音效、吸附与翻转自定义",
5
5
  "keywords": [
6
6
  "dsh",