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.
- package/assets/whale-widget.js +307 -83
- package/lib/index.js +91 -7
- package/package.json +1 -1
package/assets/whale-widget.js
CHANGED
|
@@ -79,21 +79,34 @@ function dshwInit() {
|
|
|
79
79
|
if (window.__dshWhaleInit) return
|
|
80
80
|
window.__dshWhaleInit = true
|
|
81
81
|
|
|
82
|
-
// =====
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
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)
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
codexStatsToggle
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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 <=
|
|
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
|
|
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
|
-
|
|
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 () {
|
|
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
|
-
|
|
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
|
|
12986
|
+
// click:把松开音排到"按压音结束前 RELEASE_LEAD_MS 毫秒"(默认 0 = 正好接上)
|
|
12777
12987
|
var durKnown = false
|
|
12778
|
-
var
|
|
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
|
-
|
|
12993
|
+
remainSec = Math.max(0, dur - pressAudio.currentTime)
|
|
12784
12994
|
}
|
|
12785
12995
|
} catch (err) {}
|
|
12786
12996
|
if (durKnown) {
|
|
12787
|
-
|
|
12788
|
-
|
|
12789
|
-
playRelease()
|
|
12790
|
-
}, Math.max(0, remainMs - 100))
|
|
12997
|
+
playReleaseAt(Math.max(0, remainSec - RELEASE_LEAD_MS / 1000))
|
|
12998
|
+
return
|
|
12791
12999
|
}
|
|
12792
|
-
//
|
|
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(松开)→
|
|
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
|
-
|
|
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
|
-
//
|
|
14013
|
+
// 快速点击(松手时按压音未播完):与本体一致 —— 排到"按压音结束前 RELEASE_LEAD_MS"
|
|
14014
|
+
// (默认 0 = 正好接上;音频线程排期,不依赖主线程定时器)
|
|
13795
14015
|
var durKnown = false
|
|
13796
|
-
var
|
|
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
|
-
|
|
14021
|
+
remainSec = Math.max(0, dur - audioEditPreviewEl.currentTime)
|
|
13802
14022
|
}
|
|
13803
14023
|
} catch (err) {}
|
|
13804
14024
|
if (durKnown) {
|
|
13805
|
-
|
|
13806
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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-
|
|
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.
|
|
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
|
-
|
|
1326
|
-
|
|
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:
|
|
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.
|
|
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