dsh-whale-widget 0.3.7 → 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.
@@ -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()
@@ -1913,7 +1969,7 @@ function usageAlertBudgetEditor(key, onSave) {
1913
1969
  turnCostCloseDefer = false
1914
1970
  turnCostCloseInput.disabled = !turnCostOn // 回到菜单态的禁用逻辑
1915
1971
  }
1916
- document.body.removeChild(mask)
1972
+ dshwBodyDetach(mask) // v744:走 detach 注销登记,否则 DOM 守护会把这个刚关掉的编辑器补挂回来
1917
1973
  bubbleEditItems = bkEditItems
1918
1974
  bubbleEditorSnap = bkEditorSnap
1919
1975
  bubbleItemSnap = bkItemSnap
@@ -2496,7 +2552,9 @@ function openApiModelPanel(modelId) {
2496
2552
  // 新建模型:保存后给一个明确的收尾——弹「保存成功」,点确认即关掉本面板。
2497
2553
  // (原先是重新打开为编辑态,新建流程会停在一个没有明显关闭入口的面板上)
2498
2554
  if (isNew) {
2499
- try { confirmMask.style.setProperty('z-index', '29500', 'important') } catch (err) {}
2555
+ // v744 清理:这里原先有 `confirmMask.style.zIndex = '29500' !important`,但紧接着的
2556
+ // showConfirm() 会把确认框统一提到 40000 !important,所以那行是**无效设置**(立刻被覆盖),
2557
+ // 只会让"层级"更难读。已删除 —— 确认框永远由 showConfirm 统一抬到 40000。
2500
2558
  showConfirm('✓ 保存成功:' + (nm || '新模型') + '\n已加入模型列表', function () {
2501
2559
  try { closeApiModelPanel() } catch (err) {}
2502
2560
  // 背后的「- = 小鲸鱼记账 = -」列表同步刷新(模型行在静态区,需重建子界面)
@@ -2655,7 +2713,10 @@ function openApiModelPanel(modelId) {
2655
2713
  }
2656
2714
  var apiModelMaskEl = null
2657
2715
  function closeApiModelPanel() {
2658
- 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) {}
2659
2720
  apiModelMaskEl = null
2660
2721
  }
2661
2722
  // 列宽受限 + 悬浮滚动:内容超出列宽时,鼠标移上去文字自动横向滚动,移开回到起点。
@@ -4149,11 +4210,11 @@ function usagePopupCard(title, content, below, amount) {
4149
4210
  ok.type = 'button'
4150
4211
  ok.className = 'dshwv-bubbtn dshwv-bubbtn-ok'
4151
4212
  ok.textContent = '知道了'
4152
- ok.addEventListener('click', function () { try { document.body.removeChild(mask) } catch (err) {} })
4213
+ ok.addEventListener('click', function () { try { dshwBodyDetach(mask) } catch (err) {} })
4153
4214
  btns.appendChild(ok)
4154
4215
  card.appendChild(btns)
4155
4216
  mask.appendChild(card)
4156
- mask.addEventListener('click', function (e) { if (e.target === mask) { try { document.body.removeChild(mask) } catch (err) {} } })
4217
+ mask.addEventListener('click', function (e) { if (e.target === mask) { try { dshwBodyDetach(mask) } catch (err) {} } })
4157
4218
  dshwBodyAppend(mask)
4158
4219
  } catch (err) {}
4159
4220
  }
@@ -8437,9 +8498,46 @@ var bubbleRgbOpenMenu = null // 当前展开的 .dshwv-rgbmenu
8437
8498
  var bubbleFontOpenMenu = null // 当前展开的字体下拉(复用 rgbmenu 类,额外限高)
8438
8499
  var bubbleColorOpenMenu = null // 当前展开的颜色下拉(纯色/跑马灯,限高)
8439
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
+ // ============================================================================
8440
8530
  function visibleTopZ() {
8441
8531
  var top = 20500
8442
- 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
+ ]
8443
8541
  function eff(el) {
8444
8542
  try {
8445
8543
  if (!el) return 0
@@ -8459,6 +8557,13 @@ function visibleTopZ() {
8459
8557
  }
8460
8558
  return top
8461
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
+ }
8462
8567
  // 打开主要编辑器前清理可能残留的临时层级(提醒会话遗留的 moduleMask/qedit 提升与样式)
8463
8568
  function whaleZClean() {
8464
8569
  try {
@@ -9470,6 +9575,9 @@ function openModuleEditor(m, onSave, isNew) {
9470
9575
  moduleOnSave = onSave || null
9471
9576
  moduleEditNew = !!isNew
9472
9577
  renderModuleEditor()
9578
+ // v744:模块编辑器从泡泡编辑器(20500)/资源管理(20300)里打开时,固定 20500 会与父窗口同层
9579
+ // (只靠 DOM 顺序决定谁在上面),这里统一抬到"当前最高层之上"
9580
+ dshwLayerUp(moduleMask, 20500)
9473
9581
  moduleMask.style.display = 'flex'
9474
9582
  } catch (err) {}
9475
9583
  }
@@ -9791,6 +9899,7 @@ function openGifRoleModal(dataUrl, fileName, animType) {
9791
9899
  }
9792
9900
  gifNameInput.value = ''
9793
9901
  gifPreviewImg.src = dataUrl
9902
+ dshwLayerUp(gifMask, 20000) // v744:同上,GIF 窗口也可能从资源管理/泡泡编辑器里打开
9794
9903
  gifMask.style.display = 'flex'
9795
9904
  }
9796
9905
  function hideGifRoleModal() {
@@ -10261,6 +10370,8 @@ var state = {
10261
10370
  todayUsageCurrency: 'CNY',
10262
10371
  usageLabel: '本地估算',
10263
10372
  isPeak: false,
10373
+ peakNextChangeAt: null,
10374
+ peakHolidays: null,
10264
10375
  status: 'loading',
10265
10376
  message: '',
10266
10377
  flip: false
@@ -11475,9 +11586,28 @@ function bubbleModuleText(m, avoidIdx) {
11475
11586
  return t
11476
11587
  }
11477
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
+ }
11478
11606
  function bubbleCountdownIsPeak(sec) {
11479
11607
  sec = isFinite(Number(sec)) ? Number(sec) : Math.floor(Date.now() / 1000)
11480
11608
  var bj = new Date((sec + 8 * 3600) * 1000)
11609
+ var hs = bubbleHolidaySetOf()
11610
+ if (hs && hs[bubbleBJHolidayKey(bj)]) return false // 法定节假日全天谷价
11481
11611
  var dow = bj.getUTCDay()
11482
11612
  var h = bj.getUTCHours()
11483
11613
  if (dow === 0 || dow === 6) return false
@@ -11485,10 +11615,14 @@ function bubbleCountdownIsPeak(sec) {
11485
11615
  }
11486
11616
  function bubbleCountdownNextChange(sec) {
11487
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
11488
11622
  var cur = bubbleCountdownIsPeak(sec)
11489
11623
  var bjDay0 = Math.floor((sec + 8 * 3600) / 86400) * 86400
11490
11624
  var best = null
11491
- for (var d = 0; d <= 8 && best === null; d++) {
11625
+ for (var d = 0; d <= 12 && best === null; d++) {
11492
11626
  var dayStartBj = bjDay0 + d * 86400
11493
11627
  var edges = [0, 9 * 3600, 12 * 3600, 14 * 3600, 18 * 3600]
11494
11628
  for (var i = 0; i < edges.length; i++) {
@@ -12397,6 +12531,8 @@ function refresh(manual) {
12397
12531
  state.usageLabel = data.usageLabel || '本地估算'
12398
12532
  if (data.stale) state.usageLabel += ' · 余额未刷新'
12399
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
12400
12536
  checkUsageAlerts(nb, state.todayUsage)
12401
12537
  if (changed && !currencyChanged) {
12402
12538
  if (!manual) {
@@ -12478,7 +12614,8 @@ function dshwvToast(msg) {
12478
12614
  dshwvToastEl.innerHTML = msg
12479
12615
  if (dshwvToastTimer) clearTimeout(dshwvToastTimer)
12480
12616
  dshwvToastTimer = setTimeout(function () {
12481
- 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) {}
12482
12619
  dshwvToastEl = null
12483
12620
  }, 8000)
12484
12621
  } catch (err) {}
@@ -12698,12 +12835,11 @@ var releaseAudio = null
12698
12835
  var pressing = false
12699
12836
  var pressEnded = false
12700
12837
  var releasePlayed = false
12701
- var releaseTimer = null
12838
+ // v745:不再需要 releaseTimer —— 点按时由 playReleaseAt() 在**音频线程**排期(见 pressUp)
12702
12839
  function applySoundSet() {
12703
12840
  try {
12704
12841
  // v729:切音效组 / 开关音效时把本轮播放状态一并复位,
12705
12842
  // 避免残留 releasePlayed=true 把新组的松开音整体吃掉(与 playPress 的修复配套)
12706
- if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null }
12707
12843
  pressEnded = false
12708
12844
  releasePlayed = false
12709
12845
  // 槽位显式留空(该事件静音)时,对应音频元素置空;playPress/playRelease 已判空
@@ -12719,6 +12855,12 @@ function applySoundSet() {
12719
12855
  releaseAudio.preload = 'auto'
12720
12856
  releaseAudio.volume = soundVol
12721
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
+ ])
12722
12864
  } catch (err) {}
12723
12865
  }
12724
12866
  function playPress() {
@@ -12727,7 +12869,6 @@ function playPress() {
12727
12869
  // 原实现里 pressAudio 为空时直接 return,跳过了 releasePlayed = false;而 playRelease()
12728
12870
  // 一旦把 releasePlayed 置为 true 就再没有任何地方复位它 → 结果是只有第一次松开有声音,
12729
12871
  // 之后每次点击都静音(用户实测:新建音效组只填松开音时复现)。
12730
- if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null }
12731
12872
  if (releaseAudio) {
12732
12873
  releaseAudio.pause()
12733
12874
  releaseAudio.currentTime = 0
@@ -12751,6 +12892,13 @@ function playPress() {
12751
12892
  if (p && typeof p.catch === 'function') p.catch(function () {})
12752
12893
  } catch (err) {}
12753
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
12754
12902
  function playRelease() {
12755
12903
  if (releasePlayed || !releaseAudio || !soundOn) return
12756
12904
  releasePlayed = true
@@ -12760,6 +12908,20 @@ function playRelease() {
12760
12908
  if (p && typeof p.catch === 'function') p.catch(function () {})
12761
12909
  } catch (err) {}
12762
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
+ }
12763
12925
  function pressDown() {
12764
12926
  body.style.transform = SQUISH
12765
12927
  pressing = true
@@ -12773,23 +12935,21 @@ function pressUp() {
12773
12935
  playRelease()
12774
12936
  return
12775
12937
  }
12776
- // click: start Ya2 in the last 100ms of Ya1's playback
12938
+ // click:把松开音排到"按压音结束前 RELEASE_LEAD_MS 毫秒"(默认 0 = 正好接上)
12777
12939
  var durKnown = false
12778
- var remainMs = 0
12940
+ var remainSec = 0
12779
12941
  try {
12780
12942
  var dur = pressAudio ? pressAudio.duration : 0
12781
12943
  if (isFinite(dur) && dur > 0) {
12782
12944
  durKnown = true
12783
- remainMs = (dur - pressAudio.currentTime) * 1000
12945
+ remainSec = Math.max(0, dur - pressAudio.currentTime)
12784
12946
  }
12785
12947
  } catch (err) {}
12786
12948
  if (durKnown) {
12787
- releaseTimer = setTimeout(function () {
12788
- releaseTimer = null
12789
- playRelease()
12790
- }, Math.max(0, remainMs - 100))
12949
+ playReleaseAt(Math.max(0, remainSec - RELEASE_LEAD_MS / 1000))
12950
+ return
12791
12951
  }
12792
- // duration unknown pressAudio.onended fallback plays Ya2 after Ya1 ends
12952
+ // 时长未知(预热失败/解码异常)→ 交给 pressAudio.onended 兜底(见 playPress)
12793
12953
  }
12794
12954
  var menuOpen = false
12795
12955
  var menuClosedAt = 0 // 最近一次关闭菜单的时刻(用于避免"关掉后同一次手势又把它长按打开")
@@ -13277,6 +13437,9 @@ function openCropModal(dataUrl, fileName) {
13277
13437
  cropAngle.value = '0'
13278
13438
  cropAngleNum.value = '0'
13279
13439
  positionCrop()
13440
+ // v744:裁剪窗口既可能从主菜单打开,也可能从资源管理(20300)/泡泡编辑器(20500)里打开,
13441
+ // 固定 20000 在后者会被父窗口盖住 → 动态抬层(见 visibleTopZ 上方的分层表)
13442
+ dshwLayerUp(cropMask, 20000)
13280
13443
  cropMask.style.display = 'flex'
13281
13444
  }
13282
13445
  imgEl.onerror = function () {}
@@ -13727,10 +13890,10 @@ function hideAudioEditor() {
13727
13890
  }
13728
13891
  // 组编辑弹窗试听:完全模拟挂件按压交互。
13729
13892
  // pointerdown(按下)→ 播放按压片段,进入"按住"状态(按压音播完仍按着则静默等待,不重复);
13730
- // pointerup/cancel/leave(松开)→ 若按压音已结束则立即播松开片段,否则在按压音最后 100ms 播松开(同挂件 click 重叠逻辑)。
13893
+ // pointerup/cancel/leave(松开)→ 若按压音已结束则立即播松开片段,
13894
+ // 否则把松开音**排期到按压音正好放完**的那一刻(音频线程排期;与挂件本体同一套做法,不重叠)。
13731
13895
  var audioEditPreviewEl = null
13732
13896
  var audioEditPreviewRelease = null
13733
- var audioEditPreviewTimer = null
13734
13897
  var audioEditPreviewReady = false
13735
13898
  var audioEditPreviewPressing = false // 按住状态
13736
13899
  var audioEditPreviewPressEnded = false // 按压音已播完
@@ -13758,6 +13921,11 @@ function audioEditPreviewEnsure(force) {
13758
13921
  audioEditPreviewRelease.preload = 'auto'
13759
13922
  audioEditPreviewRelease.volume = soundVol
13760
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
+ ])
13761
13929
  audioEditPreviewReady = true
13762
13930
  return true
13763
13931
  } catch (err) { return false }
@@ -13774,8 +13942,11 @@ function audioEditPreviewDown() {
13774
13942
  // 按下:播放按压音
13775
13943
  audioEditPreviewEl.onended = function () {
13776
13944
  audioEditPreviewPressEnded = true
13777
- // fallback:若松手发生在按压音结束之后,立即播松开
13778
- if (!audioEditPreviewPressing && !audioEditPreviewReleasePlayed) audioEditPreviewUp()
13945
+ // fallback:松手发生在按压音结束**之前**(= 直接点按)时,按压音播完立刻补上松开音。
13946
+ // ⚠️ 这里必须直接调 audioEditPreviewPlayRelease(),**不能**调 audioEditPreviewUp()
13947
+ // 后者开头是 `if (!audioEditPreviewPressing) return`,而点按松手时 pressing 已经是 false
13948
+ // → 直接 return,松开音永远不响(这就是"直接点按只有按下音、长按才听到松开音"的根因)。
13949
+ if (!audioEditPreviewPressing && !audioEditPreviewReleasePlayed) audioEditPreviewPlayRelease()
13779
13950
  }
13780
13951
  var p = audioEditPreviewEl.play()
13781
13952
  if (p && typeof p.catch === 'function') p.catch(function () {})
@@ -13791,38 +13962,42 @@ function audioEditPreviewUp() {
13791
13962
  audioEditPreviewPlayRelease()
13792
13963
  return
13793
13964
  }
13794
- // 快速点击(松手时按压音未播完):在按压音最后 100ms 播松开
13965
+ // 快速点击(松手时按压音未播完):与本体一致 —— 排到"按压音结束前 RELEASE_LEAD_MS"
13966
+ // (默认 0 = 正好接上;音频线程排期,不依赖主线程定时器)
13795
13967
  var durKnown = false
13796
- var remainMs = 0
13968
+ var remainSec = 0
13797
13969
  try {
13798
13970
  var dur = audioEditPreviewEl ? audioEditPreviewEl.duration : 0
13799
13971
  if (isFinite(dur) && dur > 0) {
13800
13972
  durKnown = true
13801
- remainMs = (dur - audioEditPreviewEl.currentTime) * 1000
13973
+ remainSec = Math.max(0, dur - audioEditPreviewEl.currentTime)
13802
13974
  }
13803
13975
  } catch (err) {}
13804
13976
  if (durKnown) {
13805
- audioEditPreviewTimer = setTimeout(function () {
13806
- audioEditPreviewTimer = null
13807
- audioEditPreviewPlayRelease()
13808
- }, Math.max(0, remainMs - 100))
13977
+ audioEditPreviewPlayRelease(Math.max(0, remainSec - RELEASE_LEAD_MS / 1000))
13978
+ return
13809
13979
  }
13810
13980
  // duration 未知 → onended fallback 播放松开
13811
13981
  } catch (err) {}
13812
13982
  }
13813
- function audioEditPreviewPlayRelease() {
13983
+ // 兜底里必须**直接**调它(不能调 audioEditPreviewUp() —— 后者以 pressing 为前置,
13984
+ // 点按松手时已为 false 会直接 return,导致"直接点按只有按下音")。delaySec>0 时走音频线程排期。
13985
+ function audioEditPreviewPlayRelease(delaySec) {
13814
13986
  try {
13815
13987
  if (audioEditPreviewReleasePlayed || !audioEditPreviewRelease) return
13816
13988
  audioEditPreviewReleasePlayed = true
13817
13989
  audioEditPreviewRelease.currentTime = 0
13990
+ if (delaySec > 0 && typeof audioEditPreviewRelease.playAt === 'function') {
13991
+ audioEditPreviewRelease.playAt(delaySec)
13992
+ return
13993
+ }
13818
13994
  var p = audioEditPreviewRelease.play()
13819
13995
  if (p && typeof p.catch === 'function') p.catch(function () {})
13820
13996
  } catch (err) {}
13821
13997
  }
13822
13998
  function stopAudioEditPreview() {
13823
13999
  try {
13824
- if (audioEditPreviewTimer) { clearTimeout(audioEditPreviewTimer); audioEditPreviewTimer = null }
13825
- // 不销毁 Audio 元素(保持预加载状态,与挂件持久 Audio 一致),只暂停复位
14000
+ // v745:松开音改由音频线程排期,没有主线程定时器要清
13826
14001
  if (audioEditPreviewEl) {
13827
14002
  audioEditPreviewEl.pause()
13828
14003
  audioEditPreviewEl.currentTime = 0
@@ -13925,6 +14100,7 @@ function openAudioCrop(arrayBuf, fileName) {
13925
14100
  drawAudioCrop()
13926
14101
  try { audioCropName.value = '' } catch (err) {}
13927
14102
  updateAudioCropOkState()
14103
+ dshwLayerUp(audioCropMask, 20500) // v744:音频裁剪也可能从资源管理(20300)里打开
13928
14104
  audioCropMask.style.display = 'flex'
13929
14105
  }, function () { alert('音频解码失败') })
13930
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.7', 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),
@@ -2103,8 +2159,12 @@ export default {
2103
2159
  const { accountTag, ...visible } = payload
2104
2160
  const led = readUsageLedger()
2105
2161
  const summary = daySummary(led, todayKey())
2162
+ const nowSec = Math.floor(Date.now() / 1000)
2106
2163
  return {
2107
- ...visible, version: '0.3.7', 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,
2108
2168
  todayUsage: summary.amount, todayUsageCurrency: summary.currency,
2109
2169
  usageSource: summary.source, usageLabel: summary.label, usageMode: 'ledger',
2110
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.7",
3
+ "version": "0.3.8",
4
4
  "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件:余额/今日已用/峰谷定价、自定义泡泡点击序列(文本/余额/今日/峰谷/图片/随机语句与并列加权选择)、逐行样式与字体、悬浮快捷编辑、音效与每轮消耗、自定义角色/动图/音效、吸附与翻转自定义",
5
5
  "keywords": [
6
6
  "dsh",