@mzzsfy/dsh-turn-notify 0.7.2

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/src/client.js ADDED
@@ -0,0 +1,1857 @@
1
+ // dsh-turn-notify Client 半区:轮询投影 + localStorage 认领 + 三通道发声。
2
+ // 以 DSH client-modules 自注册格式发布:__ModuleLoader__.load({id, factory}),
3
+ // factory(require) 中 require('react') 由 DSH client runtime 模块表解析。
4
+ // 认领锁/完成标记走 localStorage(非 secure context 也可用的唯一跨窗口原语)。
5
+ // 页内通知通道经公共依赖 @mzzsfy/dsh-toast 展示,本包不自带通知 UI。
6
+
7
+ window.__ModuleLoader__.load({
8
+ id: '@mzzsfy/dsh-turn-notify',
9
+ factory(require) {
10
+ const React = require('react')
11
+ const { useState, useEffect } = React
12
+
13
+ // 通知出口:公共依赖 @mzzsfy/dsh-toast,可选消费——共享依赖包的模块表注入
14
+ // 全仓收敛到唯一权威消费方(session-manager),本插件不再代挂占位条目;
15
+ // 权威方未安装时模块表缺失,干净禁用 toast 通道(其余通道不受影响)
16
+ let toast = null
17
+ try {
18
+ toast = require('@mzzsfy/dsh-toast/client').show
19
+ } catch {
20
+ // 模块表无 toast → 页内通知通道置空,调用点统一走 toast 判空
21
+ }
22
+
23
+ // 长轮询请求超时上限,须大于服务端挂起上限,保证客户端总在服务端放弃后才断开
24
+ const REQUEST_TIMEOUT_MS = 30 * 1000
25
+ // 失败重连退避:指数增长至上限,防网络中断期间打爆服务端
26
+ const RETRY_MIN_MS = 2 * 1000
27
+ const RETRY_MAX_MS = 30 * 1000
28
+ // 页内通知展示期,经公共依赖 holdMs 传入
29
+ const TOAST_MS = 6 * 1000
30
+ const BLINK_MS = 1 * 1000
31
+ // 系统通知测试延迟:浏览器对聚焦窗口抑制系统弹窗,倒计时供用户切出窗口
32
+ const SYSTEM_TEST_DELAY_MS = 5 * 1000
33
+ // 显示回执等待上限:超时未回执按环境层拦截给出诊断
34
+ const SYSTEM_SHOW_TIMEOUT_MS = 3 * 1000
35
+
36
+ // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染(本插件分区 → bell);
37
+ // 该插件未就绪时入队,由其启动时排空
38
+ const NAV_ICON = { '消息通知': 'bell' }
39
+ if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
40
+ else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
41
+ else window.__navicIconQueue = [NAV_ICON]
42
+
43
+ // 内置合成音音色表:波形 + 音符序列(频率 Hz / 时长),零音频文件。
44
+ const TONES = {
45
+ 'up-arpeggio': { type: 'sine', notes: [[523.25, 0.12], [659.25, 0.12], [783.99, 0.2]] },
46
+ bell: { type: 'sine', notes: [[880, 0.5], [1174.66, 0.7]] },
47
+ duo: { type: 'triangle', notes: [[659.25, 0.12], [987.77, 0.2]] },
48
+ 'alarm-square': { type: 'square', notes: [[440, 0.15], [329.63, 0.15], [440, 0.15], [329.63, 0.15]] },
49
+ 'low-hum': { type: 'sine', notes: [[110, 0.6]] },
50
+ 'double-ping': { type: 'sine', notes: [[987.77, 0.1], [1318.51, 0.25]] },
51
+ tick: { type: 'square', notes: [[1567.98, 0.04], [1567.98, 0.04]] },
52
+ 'down-slide': { type: 'sawtooth', notes: [[392, 0.15], [311.13, 0.15], [233.08, 0.3]] },
53
+ }
54
+ /* LOGIC-BEGIN */
55
+ // 纯逻辑段:与 src/core.mjs 保持行为一致,由 parity 测试保证。
56
+ // localStorage 不可用时认领退化为"本窗口直接发声",状态记录在 storageState。
57
+
58
+ // 数据镜像常量:与 core.mjs 的 AUDIO_EXTS/MIME_BY_EXT/CATEGORY_LABELS 同源,parity 锁定
59
+ const AUDIO_EXTS = ['wav', 'mp3', 'ogg']
60
+ const MIME_BY_EXT = { wav: 'audio/wav', ogg: 'audio/ogg', mp3: 'audio/mpeg' }
61
+
62
+ const CATEGORY_LABELS = {
63
+ completed: '任务完成',
64
+ error: '任务出错',
65
+ interrupted: '被中断',
66
+ approval: '等待审批',
67
+ ask: 'AI 提问',
68
+ 'max-tokens': '达到上限',
69
+ }
70
+ const CATEGORIES = Object.keys(CATEGORY_LABELS)
71
+
72
+ const TONE_LABELS = {
73
+ 'up-arpeggio': '上行琶音', bell: '铃铛', duo: '清脆双音', 'alarm-square': '警报方波',
74
+ 'low-hum': '低鸣', 'double-ping': '双音提示', tick: '嘀嗒', 'down-slide': '低音下滑',
75
+ }
76
+
77
+ const DEFAULT_TONES = {
78
+ completed: 'up-arpeggio', error: 'alarm-square', interrupted: 'alarm-square',
79
+ approval: 'double-ping', ask: 'double-ping', 'max-tokens': 'down-slide',
80
+ }
81
+
82
+ const CLAIM_LOCK_TTL_MS = 30 * 1000
83
+
84
+ // 未显式设置音量时的默认值。
85
+ const DEFAULT_VOLUME = 0.6
86
+
87
+ // 音量解析:未设置或非法回落默认,显式零(静音)保留。
88
+ function parseVolume(raw) {
89
+ if (raw === null || raw === undefined) return DEFAULT_VOLUME
90
+ const value = Number(raw)
91
+ return value >= 0 && value <= 1 ? value : DEFAULT_VOLUME
92
+ }
93
+
94
+ const KEY_WID = 'turn-notify:wid'
95
+ const KEY_LOCK = 'turn-notify:lock:'
96
+ const KEY_DONE = 'turn-notify:done:'
97
+ const KEY_DND = 'turn-notify:dnd'
98
+ const KEY_VOLUME = 'turn-notify:volume'
99
+ const KEY_DEGRADE_HINT = 'turn-notify:degrade-hint'
100
+ const KEY_TOAST = 'turn-notify:toast'
101
+ const KEY_SOUND = 'turn-notify:sound'
102
+ const KEY_SYSTEM = 'turn-notify:system'
103
+ const KEY_PAGE_SOUND = 'turn-notify:page-sound'
104
+ // 分类提示音配置:JSON 对象,缺省键=出声,显式 false=该分类静音
105
+ const KEY_SOUND_CATEGORIES = 'turn-notify:sound-categories'
106
+ const KEY_PAGE_SOUND_CATEGORIES = 'turn-notify:page-sound-categories'
107
+ // 页内提示音场景映射:本机存储,缺省键沿用通知映射,UI 空值即删键
108
+ const KEY_PAGE_MAPPING = 'turn-notify:page-mapping'
109
+ // 映射双作用域:本地映射与开关均存本机浏览器,音效库保持 host 共享
110
+ const KEY_MAPPING = 'turn-notify:mapping'
111
+ const KEY_MAPPING_LOCAL = 'turn-notify:mapping-local'
112
+ // 轮询单例令牌:HMR/插件重载重建模块闭包时防轮询线程累积
113
+ const KEY_POLL_TOKEN = 'turn-notify:polling'
114
+
115
+ const storageState = { broken: false }
116
+
117
+ // 诚实降级:轮询与存储两类降级各自提示一次;轮询恢复后复位,存储不可用不自动复位
118
+ const degradeAnnounced = { poll: false, storage: false }
119
+ function announceDegrade(kind, reason) {
120
+ if (degradeAnnounced[kind]) return
121
+ degradeAnnounced[kind] = true
122
+ console.warn('[dsh-turn-notify] 通知降级,本窗口直接发声: ' + reason)
123
+ }
124
+
125
+ // 降级发声去重:同一事件只发一次;过期按投影窗口清理,防 Map 无界增长
126
+ const ANNOUNCED_TTL_MS = 60 * 1000
127
+ const announcedIds = new Map()
128
+ function announcedOnce(id, now) {
129
+ for (const [key, at] of announcedIds) {
130
+ if (now - at >= ANNOUNCED_TTL_MS) announcedIds.delete(key)
131
+ }
132
+ if (announcedIds.has(id)) return false
133
+ announcedIds.set(id, now)
134
+ return true
135
+ }
136
+
137
+ const localGet = (key) => {
138
+ try {
139
+ return window.localStorage.getItem(key)
140
+ } catch {
141
+ storageState.broken = true
142
+ return null
143
+ }
144
+ }
145
+ const localSet = (key, value) => {
146
+ try {
147
+ window.localStorage.setItem(key, value)
148
+ } catch {
149
+ storageState.broken = true
150
+ }
151
+ }
152
+ const localDel = (key) => {
153
+ try {
154
+ window.localStorage.removeItem(key)
155
+ } catch {
156
+ storageState.broken = true
157
+ }
158
+ }
159
+
160
+ // 本地映射读取:JSON 解析失败或形态非对象回空对象
161
+ function readLocalMapping() {
162
+ try {
163
+ const parsed = JSON.parse(localGet(KEY_MAPPING))
164
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
165
+ } catch {
166
+ return {}
167
+ }
168
+ }
169
+
170
+ // 本地作用域开关:仅显式开启生效,缺省为全局
171
+ const localMappingEnabled = () => localGet(KEY_MAPPING_LOCAL) === '1'
172
+
173
+ // JSON 对象存储读取:解析失败或形态非对象回空对象,空串值视为未配置剔除
174
+ function readJsonObject(key) {
175
+ try {
176
+ const parsed = JSON.parse(localGet(key))
177
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
178
+ for (const key2 of Object.keys(parsed)) {
179
+ if (parsed[key2] === '') delete parsed[key2]
180
+ }
181
+ return parsed
182
+ } catch {
183
+ return {}
184
+ }
185
+ }
186
+
187
+ // 分类提示音读取:显式 false=该分类静音,缺省键=出声
188
+ const readSoundCategories = () => readJsonObject(KEY_SOUND_CATEGORIES)
189
+
190
+ // 页内提示音分类读取:与 readSoundCategories 同构,独立存储互不影响
191
+ const readPageSoundCategories = () => readJsonObject(KEY_PAGE_SOUND_CATEGORIES)
192
+
193
+ // 页内提示音场景映射读取:缺省键沿用通知映射,UI 空值即删键,空串残留视同未配置
194
+ const readPageMapping = () => readJsonObject(KEY_PAGE_MAPPING)
195
+
196
+ function windowId() {
197
+ let wid = localGet(KEY_WID)
198
+ if (wid === null) {
199
+ wid = 'w-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1000000).toString(36)
200
+ localSet(KEY_WID, wid)
201
+ }
202
+ return wid
203
+ }
204
+
205
+ // 认领读阶段决策:done 终态 / 他锁跳过 / 过期接管 / 自锁或无锁认领
206
+ // undefined 判定与 core.mjs decideClaim 同形:镜像语义含 undefined 域,parity 锁定
207
+ function decideClaim(stored, done, now, wid) {
208
+ if (done !== null && done !== undefined) return 'done'
209
+ if (stored === null || stored === undefined) return 'claim'
210
+ let lock = null
211
+ try { lock = JSON.parse(stored) } catch { lock = null }
212
+ if (lock === null || typeof lock !== 'object' || typeof lock.at !== 'number' || typeof lock.wid !== 'string') {
213
+ return 'takeover'
214
+ }
215
+ if (now - lock.at >= CLAIM_LOCK_TTL_MS) return 'takeover'
216
+ return lock.wid === wid ? 'claim' : 'skip'
217
+ }
218
+
219
+ // 写后读回确认,非自己则放弃,通过者为唯一发声窗口
220
+ function claimEvent(id) {
221
+ const wid = windowId()
222
+ const now = Date.now()
223
+ const stored = localGet(KEY_LOCK + id)
224
+ const done = localGet(KEY_DONE + id)
225
+ // 存储不可用:无锁可依,退化为本窗口直接发声(诚实降级,可能多窗口重复);
226
+ // 投影窗口内同一事件只发一次
227
+ if (storageState.broken) {
228
+ if (!announcedOnce(id, now)) return false
229
+ announceDegrade('storage', 'localStorage 不可用')
230
+ return true
231
+ }
232
+ const verdict = decideClaim(stored, done, now, wid)
233
+ if (verdict === 'done' || verdict === 'skip') return false
234
+ localSet(KEY_LOCK + id, JSON.stringify({ wid, at: now }))
235
+ // 读回值解析防护:他窗写坏该键时按放弃处理,不中断整轮投影
236
+ let confirmed = null
237
+ try { confirmed = JSON.parse(localGet(KEY_LOCK + id)) } catch { confirmed = null }
238
+ return confirmed !== null && confirmed.wid === wid
239
+ }
240
+
241
+ function markDone(id) { localSet(KEY_DONE + id, '1') }
242
+
243
+ // 分类音效解析:映射命中已上传 id 用自定义,指向内置音名用该内置,否则回落内置默认
244
+ function resolveSound(category, mapping, uploadedIds) {
245
+ const wanted = (mapping || {})[category]
246
+ if (typeof wanted === 'string' && wanted.length > 0) {
247
+ if (uploadedIds.indexOf(wanted) >= 0) return { kind: 'custom', id: wanted }
248
+ if (Object.prototype.hasOwnProperty.call(TONE_LABELS, wanted)) return { kind: 'builtin', name: wanted }
249
+ }
250
+ return { kind: 'builtin', name: DEFAULT_TONES[category] }
251
+ }
252
+
253
+ // 映射双作用域合并:与 core.mjs mergeMapping 行为一致,parity 测试锁定
254
+ function mergeMapping(globalMapping, localMapping) {
255
+ const merged = {}
256
+ for (const key of Object.keys(globalMapping || {})) merged[key] = globalMapping[key]
257
+ for (const key of Object.keys(localMapping || {})) merged[key] = localMapping[key]
258
+ return merged
259
+ }
260
+
261
+ // 死链识别:与 core.mjs deadCustomIds 行为一致,parity 测试锁定
262
+ function deadCustomIds(mapping, uploadedIds) {
263
+ const uploaded = uploadedIds || []
264
+ const dead = []
265
+ for (const value of Object.values(mapping || {})) {
266
+ if (typeof value !== 'string' || value.length === 0) continue
267
+ if (Object.prototype.hasOwnProperty.call(TONE_LABELS, value)) continue
268
+ if (uploaded.indexOf(value) >= 0) continue
269
+ if (dead.indexOf(value) < 0) dead.push(value)
270
+ }
271
+ return dead
272
+ }
273
+
274
+ // 发声通道判定:与 core.mjs chooseChannels 行为一致,通道开关来自 localStorage,
275
+ // 放入 LOGIC 段由 parity 测试保证双实现不漂移
276
+ const IDLE_AWAY_MS = 5 * 60 * 1000
277
+
278
+ function chooseChannels(hasFocus, permission, idleMs, soundCategories, category) {
279
+ const idleAway = typeof idleMs === 'number' && idleMs >= IDLE_AWAY_MS
280
+ const quiet = hasFocus && localGet(KEY_DND) !== '0' && !idleAway
281
+ const systemEnabled = localGet(KEY_SYSTEM) !== '0'
282
+ const soundEnabled = localGet(KEY_SOUND) !== '0'
283
+ const toastEnabled = localGet(KEY_TOAST) !== '0'
284
+ const pageSoundCategories = readPageSoundCategories()
285
+ const categoryMuted = soundCategories != null && category != null && soundCategories[category] === false
286
+ const pageCategoryMuted = pageSoundCategories != null && category != null && pageSoundCategories[category] === false
287
+ const sound = !quiet && soundEnabled && !categoryMuted
288
+ return {
289
+ toast: toastEnabled,
290
+ sound,
291
+ system: !quiet && systemEnabled && permission === 'granted',
292
+ blink: !quiet && systemEnabled && permission !== 'granted',
293
+ pageSound: localGet(KEY_PAGE_SOUND) === '1' && toastEnabled && !pageCategoryMuted && !sound,
294
+ }
295
+ }
296
+
297
+ // IM 投递目标列表操作:与 core.mjs 同源,parity 测试保证双实现不漂移
298
+ // botId/targetId 字符集均不含 '/',拼接键无歧义;与 host 侧写入校验共用 dsh-im ID 规格
299
+ const imTargetKey = (item) => item.botId + '/' + item.targetId
300
+
301
+ // 勾选幂等:同一 botId+targetId 只保留一份;勾选追加到尾部,取消即移除
302
+ function toggleImTargetList(list, botId, targetId, checked) {
303
+ const wanted = { botId, targetId }
304
+ const rest = list.filter((item) => imTargetKey(item) !== imTargetKey(wanted))
305
+ return checked ? rest.concat([wanted]) : rest
306
+ }
307
+
308
+ function removeImTargetFromList(list, botId, targetId) {
309
+ return list.filter((item) => imTargetKey(item) !== botId + '/' + targetId)
310
+ }
311
+
312
+ // 取消注册:移除该 bot 全部目标
313
+ function unregisterImBotList(list, botId) {
314
+ return list.filter((item) => item.botId !== botId)
315
+ }
316
+
317
+ // 已绑 bot:按首次绑定顺序去重
318
+ function imBoundBotIds(list) {
319
+ const botIds = []
320
+ for (const item of list) {
321
+ if (!botIds.includes(item.botId)) botIds.push(item.botId)
322
+ }
323
+ return botIds
324
+ }
325
+ /* LOGIC-END */
326
+
327
+ // ---- 声音:Web Audio,autoplay 解锁依赖首次用户交互,解锁前静默 ----
328
+
329
+ let audioCtx = null
330
+ const decodedCache = new Map()
331
+
332
+ function ensureAudioCtx() {
333
+ if (audioCtx === null) audioCtx = new (window.AudioContext || window.webkitAudioContext)()
334
+ // resume 仅触发不等待(通知链路 fire-and-forget);手动播放由 ensureRunnableCtx 等待并给可见反馈
335
+ if (audioCtx.state === 'suspended') audioCtx.resume().catch(() => {})
336
+ return audioCtx
337
+ }
338
+ // 首次交互解锁:解锁前静默不视为故障
339
+ window.addEventListener('pointerdown', () => { ensureAudioCtx() }, { once: true })
340
+
341
+ // 用户行动时刻:空闲满阈值视为离开,聚焦静默不再适用;
342
+ // handler 挂 window 共享并按标记幂等注册,防模块重载后监听累积
343
+ if (!window.__tnActionHandler) {
344
+ window.__tnLastActionAt = Date.now()
345
+ window.__tnActionHandler = () => { window.__tnLastActionAt = Date.now() }
346
+ for (const type of ['pointerdown', 'keydown', 'mousemove', 'wheel']) {
347
+ window.addEventListener(type, window.__tnActionHandler)
348
+ }
349
+ }
350
+ const lastActionAt = () => window.__tnLastActionAt ?? Date.now()
351
+
352
+ function volume() { return parseVolume(localGet(KEY_VOLUME)) }
353
+
354
+ function playTone(ctx2, spec) {
355
+ const master = ctx2.createGain()
356
+ master.gain.value = volume()
357
+ master.connect(ctx2.destination)
358
+ let at = ctx2.currentTime
359
+ for (const [freq, dur] of spec.notes) {
360
+ const osc = ctx2.createOscillator()
361
+ const gain = ctx2.createGain()
362
+ osc.type = spec.type
363
+ osc.frequency.value = freq
364
+ gain.gain.setValueAtTime(1, at)
365
+ gain.gain.exponentialRampToValueAtTime(0.001, at + dur)
366
+ osc.connect(gain)
367
+ gain.connect(master)
368
+ osc.start(at)
369
+ osc.stop(at + dur)
370
+ at += dur
371
+ }
372
+ }
373
+
374
+ function playBuffer(ctx2, buffer) {
375
+ const source = ctx2.createBufferSource()
376
+ const master = ctx2.createGain()
377
+ master.gain.value = volume()
378
+ source.buffer = buffer
379
+ source.connect(master)
380
+ master.connect(ctx2.destination)
381
+ source.start()
382
+ }
383
+
384
+ async function playSound(sound) {
385
+ if (sound.kind === 'builtin') {
386
+ const spec = TONES[sound.name]
387
+ if (spec) playTone(ensureAudioCtx(), spec)
388
+ return
389
+ }
390
+ const ctx2 = ensureAudioCtx()
391
+ let buffer = decodedCache.get(sound.id)
392
+ if (buffer === undefined) {
393
+ const response = await fetch('/api/turn-notify/sound?id=' + encodeURIComponent(sound.id))
394
+ if (!response.ok) throw new Error('读取音效失败: HTTP ' + response.status)
395
+ buffer = await ctx2.decodeAudioData(await response.arrayBuffer())
396
+ decodedCache.set(sound.id, buffer)
397
+ }
398
+ playBuffer(ctx2, buffer)
399
+ }
400
+
401
+ // resume 等待上限:部分环境挂起态的 resume 永不 resolve,超时后按不可播报告
402
+ const AUDIO_RESUME_WAIT_MS = 300
403
+
404
+ // 手动播放前置检查:等待浏览器放行,音量为零或通道仍挂起时给出可见原因
405
+ async function ensureRunnableCtx() {
406
+ const ctx2 = ensureAudioCtx()
407
+ if (ctx2.state === 'suspended') {
408
+ try {
409
+ await Promise.race([
410
+ ctx2.resume(),
411
+ new Promise((resolve) => { setTimeout(resolve, AUDIO_RESUME_WAIT_MS) }),
412
+ ])
413
+ } catch { }
414
+ }
415
+ return ctx2
416
+ }
417
+
418
+ function playbackBlockReason(ctx2) {
419
+ if (ctx2.state !== 'running') return '浏览器未放行音频播放,请重试或检查浏览器自动播放设置'
420
+ if (volume() === 0) return '当前音量为 0,请在本机偏好中调高'
421
+ return null
422
+ }
423
+
424
+ // 手动播放前置:等待浏览器放行;任何异常转为可见的受阻原因,调用方无需兜 catch
425
+ async function playableCtx() {
426
+ try {
427
+ const ctx2 = await ensureRunnableCtx()
428
+ return { blocked: playbackBlockReason(ctx2), ctx2 }
429
+ } catch (error) {
430
+ return { blocked: error && error.message ? error.message : String(error) }
431
+ }
432
+ }
433
+
434
+ // 手动播放统一出口:失败原因可见,不再无声无息
435
+ async function playAudible(sound) {
436
+ const pre = await playableCtx()
437
+ if (pre.blocked) return { ok: false, reason: pre.blocked }
438
+ try {
439
+ await playSound(sound)
440
+ return { ok: true }
441
+ } catch (error) {
442
+ return { ok: false, reason: error && error.message ? error.message : String(error) }
443
+ }
444
+ }
445
+
446
+ // 待确认音效试听:直接解码内存 buffer,不经服务器;失败带原因返回
447
+ async function previewPending(raw) {
448
+ const pre = await playableCtx()
449
+ if (pre.blocked) return { ok: false, reason: pre.blocked }
450
+ try {
451
+ const buffer = await pre.ctx2.decodeAudioData(raw.slice(0))
452
+ playBuffer(pre.ctx2, buffer)
453
+ return { ok: true }
454
+ } catch (error) {
455
+ return { ok: false, reason: '音频解码失败: ' + (error && error.message ? error.message : String(error)) }
456
+ }
457
+ }
458
+
459
+ async function previewBuiltin(name) {
460
+ const pre = await playableCtx()
461
+ if (pre.blocked) return { ok: false, reason: pre.blocked }
462
+ const spec = TONES[name]
463
+ if (!spec) return { ok: false, reason: '未知内置音: ' + name }
464
+ try {
465
+ playTone(pre.ctx2, spec)
466
+ return { ok: true }
467
+ } catch (error) {
468
+ return { ok: false, reason: error && error.message ? error.message : String(error) }
469
+ }
470
+ }
471
+
472
+ // ---- 页内通知与标题闪烁 ----
473
+
474
+ // 页内通知经公共依赖 @mzzsfy/dsh-toast 展示(栈式多条并存),
475
+ // 本包只保留标题闪烁通道
476
+
477
+ let blinkTimer = null
478
+ const baseTitle = () => document.title.replace(/^⏳ /, '')
479
+
480
+ function startTitleBlink() {
481
+ if (blinkTimer !== null) return
482
+ blinkTimer = setInterval(() => {
483
+ document.title = document.title.startsWith('⏳ ') ? baseTitle() : '⏳ ' + baseTitle()
484
+ }, BLINK_MS)
485
+ setTimeout(stopTitleBlink, TOAST_MS)
486
+ }
487
+
488
+ function stopTitleBlink() {
489
+ if (blinkTimer === null) return
490
+ clearInterval(blinkTimer)
491
+ blinkTimer = null
492
+ document.title = baseTitle()
493
+ }
494
+
495
+ const notificationPermission = () => (typeof Notification === 'undefined' ? 'denied' : Notification.permission)
496
+
497
+ // onOutcome 仅供测试路径取显示回执(onshow/onerror),真实路径吞错降级已在链路内
498
+ function notifySystem(unit, onOutcome) {
499
+ try {
500
+ const notification = new Notification(unit.text, { tag: unit.id })
501
+ if (typeof onOutcome === 'function') {
502
+ notification.onshow = () => { onOutcome(true) }
503
+ notification.onerror = () => { onOutcome(false) }
504
+ }
505
+ } catch {
506
+ if (typeof onOutcome === 'function') onOutcome(false)
507
+ }
508
+ }
509
+
510
+ // ---- 投影轮询与认领 ----
511
+
512
+ let soundMapping = {}
513
+ let uploadedIds = []
514
+ let running = false
515
+ // 已见投影版本:空即未首拉;长轮询续传游标,响应后随 payload 推进
516
+ let projectionCursor = null
517
+
518
+ // 生效映射:开关开时本地覆盖全局,关时本地整体休眠(结果即全局)
519
+ const effectiveMapping = () => mergeMapping(soundMapping, localMappingEnabled() ? readLocalMapping() : {})
520
+
521
+ async function pollOnce(signal) {
522
+ let payload
523
+ try {
524
+ // cursor 为空即首拉,服务端立即返回全量;之后携带版本挂起等待增量。
525
+ // 组合代际中止与请求超时:服务端挂起上限低于此超时,超时即故障进入退避
526
+ const query = projectionCursor === null ? '' : '?cursor=' + projectionCursor
527
+ const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS)
528
+ const requestSignal = signal === undefined ? timeoutSignal : AbortSignal.any([signal, timeoutSignal])
529
+ const response = await fetch('/api/turn-notify/projection' + query, { signal: requestSignal })
530
+ if (!response.ok) {
531
+ void response.body?.cancel()
532
+ return false
533
+ }
534
+ payload = await response.json()
535
+ } catch {
536
+ return false
537
+ }
538
+ // 版本缺失即异常响应:按失败退避,防游标停滞退化成紧密首拉循环
539
+ if (typeof payload.version !== 'number') return false
540
+ projectionCursor = payload.version
541
+ soundMapping = payload.soundMapping || {}
542
+ sessionHighlightEnabled = readSessionHighlightEnabled()
543
+ if (!sessionHighlightEnabled && sessionHighlights.size > 0) {
544
+ sessionHighlights.clear()
545
+ clearSessionHighlightClasses()
546
+ }
547
+ const units = payload.units || []
548
+ const liveIds = new Set(units.map((unit) => unit.id))
549
+ // 投影中已过期的本地残留清理,防旧锁与完成标记滞留;
550
+ // 访问失败即标记 broken 并整段跳过,防抛出被外层吞掉、发声链路失效
551
+ try {
552
+ for (let index = window.localStorage.length - 1; index >= 0; index -= 1) {
553
+ const key = window.localStorage.key(index)
554
+ if (key === null || (key.indexOf(KEY_LOCK) !== 0 && key.indexOf(KEY_DONE) !== 0)) continue
555
+ const id = key.indexOf(KEY_LOCK) === 0 ? key.slice(KEY_LOCK.length) : key.slice(KEY_DONE.length)
556
+ if (!liveIds.has(id)) localDel(key)
557
+ }
558
+ } catch {
559
+ storageState.broken = true
560
+ }
561
+ for (const unit of units) {
562
+ if (!claimEvent(unit.id)) continue
563
+ markDone(unit.id)
564
+ const channels = chooseChannels(document.hasFocus(), notificationPermission(), Date.now() - lastActionAt(), readSoundCategories(), unit.category)
565
+ const sound = resolveSound(unit.category, effectiveMapping(), uploadedIds)
566
+ if (channels.toast || channels.sound || channels.system || channels.blink) {
567
+ if (sessionHighlights.size >= SESSION_HL_MAX) sessionHighlights.delete(sessionHighlights.keys().next().value)
568
+ // 投影字段名为 session(buildUnit 输出形态,webhook 结构化字段同名);
569
+ // 聚焦时正在查看的会话不闪烁——与 dsh 原版一致,正在看的会话不做未读强调;
570
+ // 失焦期间照常挂,返回页面时由可见性同步清除(回来即已读)
571
+ if (unit.session && !(document.hasFocus() && isCurrentSessionTitle(unit.session))) sessionHighlights.set(unit.session, unit.category)
572
+ }
573
+ if (channels.toast) toast?.(unit.text, { holdMs: TOAST_MS })
574
+ // 页内提示音与通知声音互斥(pageSound 已含 !sound),同一通知至多一声;
575
+ // toast 库缺失时卡片不存在,补位音随之禁用(声明与可用分离,可用性在调用点合流);
576
+ // 页内音色按页内场景映射解析:通知生效映射为底,页内显式配置覆盖
577
+ if (channels.pageSound && toast) {
578
+ playSound(resolveSound(unit.category, mergeMapping(effectiveMapping(), readPageMapping()), uploadedIds)).catch(() => {})
579
+ }
580
+ if (!channels.sound) continue
581
+ playSound(sound).catch(() => {})
582
+ if (channels.system) notifySystem(unit)
583
+ else if (channels.blink && localGet(KEY_DEGRADE_HINT) !== '0') startTitleBlink()
584
+ }
585
+ applySessionHighlights()
586
+ return true
587
+ }
588
+
589
+ // 单次拉取:测试入口与循环体共用;失败返回假,由调用方决定退避;
590
+ // 代际中止(HMR 换代)不算降级,不通告
591
+ async function poll(signal) {
592
+ const ok = await pollOnce(signal)
593
+ if (ok) degradeAnnounced.poll = false
594
+ else if (signal === undefined || !signal.aborted) announceDegrade('poll', '投影拉取失败')
595
+ return ok
596
+ }
597
+
598
+ function sleep(ms, signal) {
599
+ return new Promise((resolve) => {
600
+ if (signal.aborted) {
601
+ resolve()
602
+ return
603
+ }
604
+ const timer = setTimeout(() => {
605
+ signal.removeEventListener('abort', onAbort)
606
+ resolve()
607
+ }, ms)
608
+ if (typeof timer.unref === 'function') timer.unref()
609
+ const onAbort = () => {
610
+ clearTimeout(timer)
611
+ resolve()
612
+ }
613
+ signal.addEventListener('abort', onAbort, { once: true })
614
+ })
615
+ }
616
+
617
+ // 长轮询主循环:成功即立即重连(空闲期由服务端挂起兜底),失败指数退避;
618
+ // 处理段异常同样按失败消化,循环不静默死亡
619
+ async function runLoop(signal) {
620
+ let backoffMs = RETRY_MIN_MS
621
+ while (!signal.aborted) {
622
+ let ok = false
623
+ try {
624
+ ok = await poll(signal)
625
+ } catch (error) {
626
+ if (!signal.aborted) announceDegrade('poll', error && error.message ? error.message : String(error))
627
+ }
628
+ if (signal.aborted) break
629
+ if (ok) {
630
+ backoffMs = RETRY_MIN_MS
631
+ // 让出一拍:防测试 stub 立即响应时循环退化成紧密空转
632
+ await sleep(0, signal)
633
+ } else {
634
+ await sleep(backoffMs, signal)
635
+ backoffMs = Math.min(backoffMs * 2, RETRY_MAX_MS)
636
+ }
637
+ }
638
+ }
639
+
640
+ async function refreshSounds() {
641
+ try {
642
+ const response = await fetch('/api/turn-notify/sounds')
643
+ const payload = await response.json()
644
+ uploadedIds = (payload.sounds || []).map((sound) => sound.id)
645
+ } catch { uploadedIds = [] }
646
+ }
647
+
648
+ // ---- 会话行高亮:通知投递即脉冲闪烁侧边栏对应会话,点击该行清除 ----
649
+
650
+ const SESSION_HL_CLASS = 'tn-sess-hl'
651
+ // 集合上限:用户始终不点击时防无界增长,超限淘汰最旧(插入序即迭代序)
652
+ const SESSION_HL_MAX = 20
653
+ const sessionHighlights = new Map()
654
+ // 会话高亮为纯本机 UI 行为:开关存 localStorage,存储不可用按默认开
655
+ const KEY_SESSION_HL = 'turn-notify:session-hl'
656
+ function readSessionHighlightEnabled() {
657
+ try { return window.localStorage.getItem(KEY_SESSION_HL) !== '0' } catch { return true }
658
+ }
659
+ function writeSessionHighlightEnabled(on) {
660
+ try {
661
+ if (on) window.localStorage.removeItem(KEY_SESSION_HL)
662
+ else window.localStorage.setItem(KEY_SESSION_HL, '0')
663
+ } catch { /* 存储不可用时开关仍可点,本次会话生效 */ }
664
+ sessionHighlightEnabled = on
665
+ if (!on) {
666
+ sessionHighlights.clear()
667
+ clearSessionHighlightClasses()
668
+ }
669
+ }
670
+ let sessionHighlightEnabled = readSessionHighlightEnabled()
671
+
672
+ // document.title 首段为当前会话名(切会话即变);标题闪烁的前缀符号
673
+ // 落在同段首部,indexOf 匹配天然免疫;投影标题可能截断故用单向前缀匹配
674
+ function isCurrentSessionTitle(title) {
675
+ if (typeof document === 'undefined' || typeof document.title !== 'string') return false
676
+ const segment = document.title.split(' — ')[0]
677
+ return segment.indexOf(title) >= 0
678
+ }
679
+
680
+ // 会话行探测:先定位“类名含 _list 段且子树含多个标题”的最内层列表容器,
681
+ // 再按标题文本下钻;行级确认要求标题叶文本与 title 全等或前缀互含
682
+ // (行标题完整而投影标题可能截断),防止同前缀会话被子串误吸;
683
+ // 全等行优先于前缀行;本体改版探测不到即静默失效,不报错
684
+ const TITLE_LEAF_SELECTOR = '[class*="_title"]'
685
+ const SESSION_LIST_SUFFIX = '_list'
686
+ const leafTextOf = (row) => {
687
+ if (typeof row.querySelector !== 'function') return ''
688
+ const leaf = row.querySelector(TITLE_LEAF_SELECTOR)
689
+ return leaf && typeof leaf.textContent === 'string' ? leaf.textContent : ''
690
+ }
691
+ const titleMatchesRow = (title, row) => {
692
+ const leafText = leafTextOf(row)
693
+ return leafText === title || leafText.startsWith(title) || title.startsWith(leafText)
694
+ }
695
+ function findSessionRow(title) {
696
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return null
697
+ let list = null
698
+ let listCount = 0
699
+ for (const el of document.querySelectorAll('[class*="' + SESSION_LIST_SUFFIX + '"]')) {
700
+ if (typeof el.className !== 'string' || !el.className.split(' ').some((name) => name.indexOf(SESSION_LIST_SUFFIX) >= 0)) continue
701
+ const count = el.querySelectorAll(TITLE_LEAF_SELECTOR).length
702
+ if (count > 1 && (list === null || count < listCount)) { list = el; listCount = count }
703
+ }
704
+ if (list === null) return null
705
+ const prefixMatches = []
706
+ const walk = (node) => {
707
+ for (const kid of node.children) {
708
+ if (typeof kid.textContent !== 'string' || kid.textContent.indexOf(title) < 0) continue
709
+ if (typeof kid.querySelectorAll !== 'function') continue
710
+ if (kid.querySelectorAll(TITLE_LEAF_SELECTOR).length === 1) {
711
+ if (leafTextOf(kid) === title) return kid
712
+ if (titleMatchesRow(title, kid)) prefixMatches.push(kid)
713
+ } else if (kid.children.length > 0) {
714
+ const deeper = walk(kid)
715
+ if (deeper !== null && deeper !== undefined) return deeper
716
+ }
717
+ }
718
+ return null
719
+ }
720
+ return walk(list) ?? prefixMatches[0] ?? null
721
+ }
722
+
723
+ // 行状态与 SessionStatusDots 对齐:运行中的会话由原生状态点表达注意力,
724
+ // 闪烁只表达"状态已更新待查看"——行进入运行状态即让位,文案取官方 zh/en 两种
725
+ const isRowRunning = (row) => {
726
+ const text = typeof row.textContent === 'string' ? row.textContent : ''
727
+ return text.indexOf('进行中') >= 0 || text.toLowerCase().indexOf('running') >= 0
728
+ }
729
+
730
+ // 增量重应用:仅补缺失类,不产生多余 DOM 写,防 MutationObserver 回调自我触发成环;
731
+ // 快照遍历:行进入运行状态时清理对应条目,迭代中删除不改快照
732
+ function applySessionHighlights() {
733
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return
734
+ if (!sessionHighlightEnabled) return
735
+ for (const [title, category] of [...sessionHighlights]) {
736
+ const row = findSessionRow(title)
737
+ if (!row) continue
738
+ if (isRowRunning(row)) {
739
+ sessionHighlights.delete(title)
740
+ removeRowHighlight(row)
741
+ continue
742
+ }
743
+ if (row.classList.contains(SESSION_HL_CLASS)) continue
744
+ const colorClass = CATEGORIES.indexOf(category) >= 0 ? SESSION_HL_CLASS + '--' + category : SESSION_HL_CLASS + '--ask'
745
+ row.classList.add(SESSION_HL_CLASS, colorClass)
746
+ }
747
+ }
748
+
749
+ // 开关关闭或点击清除后的类清理:移除本插件前缀的全部类
750
+ function clearSessionHighlightClasses() {
751
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return
752
+ const marked = document.querySelectorAll('.' + SESSION_HL_CLASS)
753
+ for (let index = 0; index < marked.length; index += 1) {
754
+ marked[index].className = marked[index].className.split(' ').filter((name) => name !== SESSION_HL_CLASS && name.indexOf(SESSION_HL_CLASS + '--') !== 0).join(' ')
755
+ }
756
+ }
757
+
758
+ // 宿主重渲染会重建行节点抹掉高亮类,观察器在同一帧内补齐;
759
+ // applySessionHighlights 幂等(类齐不写 DOM),观察器链自然收敛;
760
+ // 令牌承载 observer:HMR 重建闭包后先断开旧代再挂新代,旧闭包不滞留
761
+ const KEY_HL_OBSERVER = 'turn-notify:hl-observer'
762
+ function ensureHighlightObserver() {
763
+ if (typeof document === 'undefined' || typeof document.body === 'undefined' || typeof MutationObserver === 'undefined') return
764
+ if (window[KEY_HL_OBSERVER] !== undefined && typeof window[KEY_HL_OBSERVER].disconnect === 'function') window[KEY_HL_OBSERVER].disconnect()
765
+ const observer = new MutationObserver(() => {
766
+ if (sessionHighlightEnabled && sessionHighlights.size > 0) applySessionHighlights()
767
+ })
768
+ observer.observe(document.body, { childList: true, subtree: true })
769
+ window[KEY_HL_OBSERVER] = observer
770
+ }
771
+
772
+ // 点击清除走捕获委托;令牌承载 listener:HMR 重建闭包后先摘旧代再挂新代,
773
+ // 旧闭包不滞留(否则点击清除永远操作旧代高亮状态)。
774
+ // 行标题按叶文本与 Map 键前缀互含精确删除,只清该行——其他会话的进行中提示不受影响
775
+ const KEY_HL_LISTENER = 'turn-notify:hl-listener'
776
+ function removeRowHighlight(row) {
777
+ if (typeof row.className !== 'string') return
778
+ row.className = row.className.split(' ').filter((name) => name !== SESSION_HL_CLASS && name.indexOf(SESSION_HL_CLASS + '--') !== 0).join(' ')
779
+ }
780
+ // 页面重新可见即视为已读:清除当前会话的高亮,其他会话的提醒保留
781
+ function clearCurrentSessionHighlights() {
782
+ for (const title of [...sessionHighlights.keys()]) {
783
+ if (!isCurrentSessionTitle(title)) continue
784
+ sessionHighlights.delete(title)
785
+ const row = findSessionRow(title)
786
+ if (row) removeRowHighlight(row)
787
+ }
788
+ }
789
+ function ensureHighlightListener() {
790
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return
791
+ if (typeof window[KEY_HL_LISTENER] === 'function') document.removeEventListener('click', window[KEY_HL_LISTENER], true)
792
+ const listener = (event) => {
793
+ const target = event.target && event.target.closest ? event.target.closest('.' + SESSION_HL_CLASS) : null
794
+ if (!target) return
795
+ const rowTitle = leafTextOf(target)
796
+ if (!rowTitle) return
797
+ let cleared = false
798
+ for (const title of [...sessionHighlights.keys()]) {
799
+ if (!titleMatchesRow(title, target)) continue
800
+ sessionHighlights.delete(title)
801
+ cleared = true
802
+ }
803
+ if (!cleared) return
804
+ removeRowHighlight(target)
805
+ }
806
+ window[KEY_HL_LISTENER] = listener
807
+ document.addEventListener('click', listener, true)
808
+ }
809
+
810
+ // 返回即已读:窗口重获焦点或页面重新可见时清除当前会话高亮。
811
+ // 单靠 visibilitychange 不够——切到别的应用窗口(不切标签、不最小化)时
812
+ // 本标签页 hidden 恒为 false,事件永不触发,焦点通道覆盖这一场景;
813
+ // 令牌承载 dispose,HMR 重建闭包后先摘旧代再挂新代
814
+ const KEY_HL_VISIBLE = 'turn-notify:hl-visible'
815
+ function ensureHighlightVisibilitySync() {
816
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return
817
+ if (window[KEY_HL_VISIBLE] && typeof window[KEY_HL_VISIBLE].dispose === 'function') window[KEY_HL_VISIBLE].dispose()
818
+ const onVisible = () => {
819
+ if (document.hidden) return
820
+ clearCurrentSessionHighlights()
821
+ }
822
+ const onFocus = () => clearCurrentSessionHighlights()
823
+ const dispose = () => {
824
+ if (typeof window.removeEventListener === 'function') window.removeEventListener('focus', onFocus)
825
+ document.removeEventListener('visibilitychange', onVisible)
826
+ }
827
+ if (typeof window.addEventListener === 'function') window.addEventListener('focus', onFocus)
828
+ document.addEventListener('visibilitychange', onVisible)
829
+ window[KEY_HL_VISIBLE] = { dispose }
830
+ }
831
+
832
+ function start() {
833
+ // 代际令牌自愈:HMR 重建模块闭包后 running 归零,首启 abort 旧代长轮询,
834
+ // 旧连接断开、旧循环退出;遗留的非 AbortController 令牌(旧版 interval 形态)
835
+ // 无法中止,由页面刷新自然清偿
836
+ if (running) return
837
+ running = true
838
+ if (window[KEY_POLL_TOKEN] instanceof AbortController) window[KEY_POLL_TOKEN].abort()
839
+ const controller = new AbortController()
840
+ window[KEY_POLL_TOKEN] = controller
841
+ ensureHighlightListener()
842
+ ensureHighlightVisibilitySync()
843
+ ensureHighlightObserver()
844
+ refreshSounds()
845
+ void runLoop(controller.signal)
846
+ }
847
+
848
+ // 分类通知开关串行提交链:请求按点击顺序入队,host 终值恒为最后一次点击;
849
+ // 乐观回填由调用方先行(连点取反基点恒新),失败时拉取权威配置纠偏收敛 UI 与 host
850
+ let categoryToggleChain = Promise.resolve()
851
+ function submitCategoryToggle(category, checked, { apiImpl, onConfig, onError }) {
852
+ const run = categoryToggleChain.catch(() => {}).then(async () => {
853
+ try {
854
+ onConfig(await apiImpl('/api/turn-notify/config', {
855
+ method: 'POST',
856
+ body: JSON.stringify({ enabled: { [category]: checked } }),
857
+ }))
858
+ } catch (error) {
859
+ onError('开关失败:' + (error && error.message ? error.message : String(error)))
860
+ try {
861
+ onConfig(await apiImpl('/api/turn-notify/config'))
862
+ } catch { /* 权威配置拉取失败则保持乐观值,由后续操作收敛 */ }
863
+ }
864
+ })
865
+ categoryToggleChain = run
866
+ return run
867
+ }
868
+
869
+ // ---- 设置面板 ----
870
+
871
+ const PERMISSION_LABELS = { granted: '已授权', denied: '已拒绝', default: '未授权' }
872
+
873
+ // 面板表单占位:GET config 返回前展示;webhookUrl 凭据不出主机,面板只见是否已配置;
874
+ // imAvailable 缺省为假,加载响应后 dsh-im 在场才渲染 IM 投递卡
875
+ const DEFAULT_CONFIG = {
876
+ webhookConfigured: false,
877
+ minTurnDurationMs: 5 * 1000,
878
+ rootsOnly: true,
879
+ suppressSubagentWake: true,
880
+ enabled: Object.fromEntries(CATEGORIES.map((key) => [key, true])),
881
+ imTargets: [],
882
+ }
883
+
884
+ const CSS = [
885
+ // 令牌全部取宿主 --dsw-* 体系,明暗模式由宿主切换自动生效
886
+ '.tn-panel { display:flex; flex-direction:column; gap:14px; color:inherit; font-size:13px; }',
887
+ '.tn-head { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }',
888
+ '.tn-head__title { font-weight:650; font-size:15px; letter-spacing:0.2px; }',
889
+ '.tn-head__hint { color:var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); font-size:12px; }',
890
+ // tab 栏:segmented 胶囊组,激活态 brand 底,面板一屏只呈现一类配置
891
+ '.tn-tabs { display:flex; gap:2px; padding:3px; border-radius:10px;',
892
+ ' background:var(--dsw-alias-bg-layer-2, rgba(128,128,128,0.12)); }',
893
+ '.tn-tab { flex:1; min-width:0; border:none; background:transparent; cursor:pointer;',
894
+ ' padding:6px 8px; border-radius:8px; font-size:12.5px; font-family:inherit; text-align:center;',
895
+ ' color:var(--dsw-alias-label-secondary, inherit); transition:background 0.15s, color 0.15s; }',
896
+ '.tn-tab:hover { color:var(--dsw-alias-label-primary);',
897
+ ' background:var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.15)); }',
898
+ '.tn-tab--on, .tn-tab--on:hover { background:var(--dsw-alias-brand-primary);',
899
+ ' color:var(--dsw-alias-bg-base, #fff); font-weight:600; }',
900
+ '.tn-tab:focus-visible { outline:2px solid var(--dsw-alias-brand-primary); outline-offset:1px; }',
901
+ '.tn-tabpanel { display:flex; flex-direction:column; gap:14px; }',
902
+ '.tn-card { border:1px solid var(--dsw-alias-border-l1, var(--dsw-alias-separator-primary, rgba(128,128,128,0.35)));',
903
+ ' border-radius:12px; padding:14px 16px; background:var(--dsw-alias-bg-layer-1, transparent);',
904
+ ' display:flex; flex-direction:column; gap:12px; }',
905
+ '.tn-card__head { display:flex; flex-direction:column; gap:2px; }',
906
+ '.tn-card__title { font-weight:600; font-size:13px; color:var(--dsw-alias-label-primary); }',
907
+ '.tn-card__sub { color:var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); font-size:12px; }',
908
+ // 表单行:左标签右控件的 grid,说明文字折行到控件列下方,行结构不随内容换行漂移
909
+ '.tn-field { display:grid; grid-template-columns:76px minmax(0, 1fr); gap:8px 12px; align-items:center; }',
910
+ '.tn-field__label { color:var(--dsw-alias-label-secondary); font-size:12px; }',
911
+ '.tn-field__control { display:flex; align-items:center; gap:8px; flex-wrap:wrap; min-width:0; }',
912
+ '.tn-field__hint { grid-column:2; color:var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary));',
913
+ ' font-size:11.5px; line-height:1.5; }',
914
+ // 卡片底部动作区:主操作右对齐
915
+ '.tn-actions { display:flex; justify-content:flex-end; gap:8px;',
916
+ ' border-top:1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.25)); padding-top:10px; }',
917
+ '.tn-meta { color:var(--dsw-alias-label-secondary); font-size:12px; }',
918
+ '.tn-error { color:var(--dsw-alias-state-error-primary, #d43a3a); }',
919
+ '.tn-btn { cursor:pointer; border:1px solid var(--dsw-alias-border-l2, var(--dsw-alias-separator-primary, rgba(128,128,128,0.35)));',
920
+ ' background:var(--dsw-alias-bg-layer-2, transparent); color:var(--dsw-alias-label-primary, inherit);',
921
+ ' border-radius:8px; padding:5px 14px; font-size:12px; font-family:inherit; transition:background 0.15s, border-color 0.15s, color 0.15s; }',
922
+ '.tn-btn:hover { background:var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2, transparent)); }',
923
+ '.tn-btn:disabled { opacity:0.45; cursor:default; }',
924
+ '.tn-btn--primary { background:var(--dsw-alias-brand-primary); border-color:var(--dsw-alias-brand-primary);',
925
+ ' color:var(--dsw-alias-bg-base, #fff); font-weight:600; }',
926
+ '.tn-btn--primary:hover { background:var(--dsw-alias-button-primary-hover, var(--dsw-alias-brand-primary)); }',
927
+ '.tn-btn--ghost { background:transparent; border-color:transparent; color:var(--dsw-alias-label-secondary); }',
928
+ '.tn-btn--ghost:hover { color:var(--dsw-alias-state-error-primary, #d43a3a);',
929
+ ' background:var(--dsw-alias-interactive-bg-hover, transparent); }',
930
+ '.tn-btn--danger:hover { border-color:var(--dsw-alias-state-error-primary, #d43a3a);',
931
+ ' color:var(--dsw-alias-state-error-primary, #d43a3a); }',
932
+ '.tn-select, .tn-input { background:var(--dsw-specific-input-major, var(--dsw-alias-bg-layer-2, transparent)); color:var(--dsw-alias-label-primary, inherit);',
933
+ ' border:1px solid var(--dsw-alias-border-l1, var(--dsw-alias-separator-primary, rgba(128,128,128,0.35)));',
934
+ ' border-radius:8px; padding:5px 9px; font-size:12px; font-family:inherit; transition:border-color 0.15s; }',
935
+ '.tn-select:focus, .tn-input:focus { outline:none; border-color:var(--dsw-alias-brand-primary); }',
936
+ '.tn-fill { flex:1; min-width:200px; }',
937
+ // 测试卡按钮组:横排可换行
938
+ '.tn-btngroup { display:flex; gap:8px; flex-wrap:wrap; }',
939
+ // pill 开关组:成组分类的快捷切换,选中态 brand 底色
940
+ '.tn-pills { display:flex; gap:6px; flex-wrap:wrap; }',
941
+ '.tn-pill { cursor:pointer; border:1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35));',
942
+ ' border-radius:999px; padding:3px 12px; font-size:12px; user-select:none;',
943
+ ' background:var(--dsw-alias-bg-layer-2, transparent); color:var(--dsw-alias-label-secondary);',
944
+ ' transition:all 0.15s; }',
945
+ '.tn-pill:hover { border-color:var(--dsw-alias-brand-primary); }',
946
+ '.tn-pill--on { background:var(--dsw-alias-brand-primary); border-color:var(--dsw-alias-brand-primary);',
947
+ ' color:var(--dsw-alias-bg-base, #fff); font-weight:600; }',
948
+ // bot 标签:名称与取消注册组合为一个 chip
949
+ '.tn-chip { display:inline-flex; align-items:center; gap:2px; border:1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35));',
950
+ ' border-radius:999px; overflow:hidden; font-size:12px; }',
951
+ '.tn-chip__name { cursor:pointer; border:none; background:transparent; color:var(--dsw-alias-label-primary, inherit);',
952
+ ' padding:3px 10px; font-size:12px; }',
953
+ '.tn-chip__name:hover { background:var(--dsw-alias-interactive-bg-hover, transparent); }',
954
+ '.tn-chip__name--active { color:var(--dsw-alias-brand-primary); font-weight:600; }',
955
+ '.tn-chip__x { cursor:pointer; border:none; background:transparent; color:var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary));',
956
+ ' padding:3px 8px; font-size:13px; line-height:1; border-left:1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.35)); }',
957
+ '.tn-chip__x:hover { color:var(--dsw-alias-state-error-primary, #d43a3a); background:var(--dsw-alias-interactive-bg-hover, transparent); }',
958
+ // 开关:隐藏原生 checkbox,选中态 track 与 thumb 位移用过渡呈现
959
+ '.tn-switch { display:inline-flex; align-items:center; cursor:pointer; }',
960
+ '.tn-switch input[type="checkbox"] { position:absolute; opacity:0; width:0; height:0; }',
961
+ '.tn-switch__track { position:relative; width:34px; height:19px; border-radius:999px; box-sizing:border-box;',
962
+ ' background:var(--dsw-alias-bg-layer-2, rgba(128,128,128,0.35));',
963
+ ' border:1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35));',
964
+ ' transition:background 0.15s, border-color 0.15s; }',
965
+ '.tn-switch__thumb { position:absolute; top:50%; left:2px; width:13px; height:13px; border-radius:50%;',
966
+ ' background:var(--dsw-alias-label-tertiary, rgba(128,128,128,0.6));',
967
+ ' transform:translateY(-50%); transition:left 0.15s, background 0.15s; }',
968
+ '.tn-switch:hover .tn-switch__track { border-color:var(--dsw-alias-brand-primary); }',
969
+ '.tn-switch input[type="checkbox"]:checked + .tn-switch__track { background:var(--dsw-alias-brand-primary); border-color:var(--dsw-alias-brand-primary); }',
970
+ '.tn-switch input[type="checkbox"]:checked + .tn-switch__track .tn-switch__thumb { left:17px; background:var(--dsw-alias-bg-base, #fff); }',
971
+ '.tn-switch input[type="checkbox"]:focus-visible + .tn-switch__track { outline:2px solid var(--dsw-alias-brand-primary); outline-offset:1px; }',
972
+ // 目标列表:按行呈现,勾选/名称/移除右对齐
973
+ '.tn-list { display:flex; flex-direction:column; }',
974
+ '.tn-list__item { display:flex; align-items:center; gap:10px; padding:6px 2px; font-size:12px;',
975
+ ' border-top:1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.35)); }',
976
+ '.tn-list__item:first-child { border-top:none; }',
977
+ '.tn-list__grow { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;',
978
+ ' color:var(--dsw-alias-label-primary, inherit); }',
979
+ '.tn-list__tag { color:var(--dsw-alias-label-tertiary, var(--dsw-alias-label-secondary)); font-size:11px; }',
980
+ '.tn-divider { border:none; border-top:1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.35)); margin:2px 0; }',
981
+ 'input[type="range"].tn-range { accent-color:var(--dsw-alias-brand-primary); flex:1; min-width:120px; }',
982
+ // 会话行高亮:背景在透明与“背景色与分类色各半混合”间脉冲;分类色用 dsw 语义变量,缺省回退具名色
983
+ '.tn-sess-hl--completed { --tn-sess-color:var(--dsw-alias-state-success-primary, #34a853); }',
984
+ '.tn-sess-hl--error { --tn-sess-color:var(--dsw-alias-state-error-primary, #d43a3a); }',
985
+ '.tn-sess-hl--interrupted { --tn-sess-color:#e08c2e; }',
986
+ '.tn-sess-hl--approval { --tn-sess-color:#c9a227; }',
987
+ '.tn-sess-hl--ask { --tn-sess-color:var(--dsw-alias-brand-primary, #4c8dff); }',
988
+ '.tn-sess-hl--max-tokens { --tn-sess-color:#9a6fe0; }',
989
+ '.tn-sess-hl { animation:tn-sess-pulse 1.6s ease-in-out infinite; border-radius:8px; }',
990
+ '@keyframes tn-sess-pulse {',
991
+ ' 0%,100% { background-color:transparent; }',
992
+ ' 50% { background-color:color-mix(in srgb, var(--tn-sess-color, #4c8dff) 50%, var(--dsw-alias-bg-base, #202020) 50%); }',
993
+ '}',
994
+ '@media (prefers-reduced-motion: reduce) {',
995
+ ' .tn-sess-hl { animation:none; background-color:color-mix(in srgb, var(--tn-sess-color, #4c8dff) 50%, var(--dsw-alias-bg-base, #202020) 50%); }',
996
+ '}',
997
+ ].join('\n')
998
+
999
+ function h(type, props) {
1000
+ const children = Array.prototype.slice.call(arguments, 2)
1001
+ return React.createElement.apply(React, [type, props || null].concat(children))
1002
+ }
1003
+
1004
+ // 开关的 checkbox + 轨道对,checkbox 语义保留仅视觉隐藏
1005
+ function switchToggle(props) {
1006
+ return [
1007
+ h('input', { type: 'checkbox', ...props }),
1008
+ h('span', { className: 'tn-switch__track' }, h('span', { className: 'tn-switch__thumb' })),
1009
+ ]
1010
+ }
1011
+
1012
+ // 表单行:左标签右控件的固定两列,说明文字折行到控件列下方
1013
+ function field(label, control, hint) {
1014
+ return h('div', { className: 'tn-field' },
1015
+ h('span', { className: 'tn-field__label' }, label),
1016
+ h('div', { className: 'tn-field__control' }, control),
1017
+ hint !== undefined ? h('div', { className: 'tn-field__hint' }, hint) : null,
1018
+ )
1019
+ }
1020
+
1021
+ async function api(path, options) {
1022
+ const response = await fetch(path, { headers: { 'content-type': 'application/json' }, ...options })
1023
+ const payload = await response.json().catch(() => ({}))
1024
+ if (!response.ok) throw new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
1025
+ return payload
1026
+ }
1027
+
1028
+ function TurnNotifyApp() {
1029
+ const [sounds, setSounds] = useState([])
1030
+ const [busy, setBusy] = useState(false)
1031
+ const [config, setConfig] = useState(DEFAULT_CONFIG)
1032
+ const [configLoaded, setConfigLoaded] = useState(false)
1033
+ const [urlDraft, setUrlDraft] = useState('')
1034
+ const [permission, setPermission] = useState(notificationPermission())
1035
+ // IM 投递:botId 草稿与当前加载到的目标目录(目录只在勾选时消费,不直接决定已选)
1036
+ const [imBotIdDraft, setImBotIdDraft] = useState('')
1037
+ const [imCatalog, setImCatalog] = useState(null)
1038
+ // 分类映射的全局镜像:直接由全局配置/映射响应回填,不再依赖轮询变量驱动渲染
1039
+ const [mapping, setMappingState] = useState({})
1040
+ // 本地作用域镜像:开关与映射改动即写 localStorage,仅作用域为当前域名
1041
+ const [localMode, setLocalMode] = useState(() => localMappingEnabled())
1042
+ const [localMapping, setLocalMappingState] = useState(() => readLocalMapping())
1043
+ // 分类提示音镜像:pill 点击即写 localStorage,发声链路直读不依赖本 state
1044
+ const [soundCategories, setSoundCategoriesState] = useState(() => readSoundCategories())
1045
+ const [pageSoundCategories, setPageSoundCategoriesState] = useState(() => readPageSoundCategories())
1046
+ // 页内提示音场景映射镜像:select 变更即写 localStorage,仅存本机
1047
+ const [pageMapping, setPageMappingState] = useState(() => readPageMapping())
1048
+ // 待确认上传:文件选中且解码校验通过后挂起,用户试听并确认才落盘
1049
+ const [pendingUploads, setPendingUploads] = useState([])
1050
+ // 面板分区:tab 切换仅显隐,不触碰任何已装载状态
1051
+ const [tab, setTab] = useState('通知')
1052
+
1053
+ useEffect(() => {
1054
+ start()
1055
+ api('/api/turn-notify/sounds').then((res) => setSounds(res.sounds || [])).catch(() => {})
1056
+ api('/api/turn-notify/config')
1057
+ .then((res) => {
1058
+ setConfig({ ...DEFAULT_CONFIG, ...res })
1059
+ setMappingState(res.soundMapping || {})
1060
+ setConfigLoaded(true)
1061
+ })
1062
+ .catch(() => {})
1063
+ }, [])
1064
+
1065
+ // 操作反馈出口:统一浮出通知,成功 ok / 失败 error;toast 模块缺失降级 console
1066
+ const patch = (text, kind) => {
1067
+ if (toast) toast(text, { kind: kind === 'error' ? 'error' : 'ok' })
1068
+ else console.warn('[dsh-turn-notify] ' + text)
1069
+ }
1070
+
1071
+ async function onPickFiles(files) {
1072
+ if (files.length === 0) return
1073
+ setBusy(true)
1074
+ const accepted = []
1075
+ let rejected = 0
1076
+ let rejectReason = ''
1077
+ try {
1078
+ for (const file of files) {
1079
+ try {
1080
+ const ext = (/\.([^.]+)$/.exec(file.name) || [])[1]?.toLowerCase() || ''
1081
+ if (AUDIO_EXTS.indexOf(ext) < 0) throw new Error('仅支持 ' + AUDIO_EXTS.join(' / '))
1082
+ const raw = await file.arrayBuffer()
1083
+ // 入库前双重校验的浏览器半区:解码失败即拒绝;
1084
+ // 解码吃副本,原始 buffer 留给确认后的上传与试听
1085
+ await ensureAudioCtx().decodeAudioData(raw.slice(0))
1086
+ accepted.push({ name: file.name, raw })
1087
+ } catch (error) {
1088
+ rejected += 1
1089
+ rejectReason = error && error.message ? error.message : String(error)
1090
+ }
1091
+ }
1092
+ } finally { setBusy(false) }
1093
+ if (accepted.length > 0) setPendingUploads(pendingUploads.concat(accepted))
1094
+ if (rejected > 0) {
1095
+ patch(accepted.length + ' 个通过校验待确认,' + rejected + ' 个被拒(' + rejectReason + ')', accepted.length > 0 ? 'ok' : 'error')
1096
+ } else {
1097
+ patch(accepted.length + ' 个通过校验,试听后确认保存')
1098
+ }
1099
+ }
1100
+
1101
+ async function savePending(item) {
1102
+ setBusy(true)
1103
+ try {
1104
+ await api('/api/turn-notify/upload?name=' + encodeURIComponent(item.name), {
1105
+ method: 'POST',
1106
+ body: item.raw,
1107
+ })
1108
+ setPendingUploads(pendingUploads.filter((pending) => pending !== item))
1109
+ patch('已保存 ' + item.name)
1110
+ } catch (error) {
1111
+ patch('保存失败:' + (error && error.message ? error.message : String(error)), 'error')
1112
+ } finally { setBusy(false) }
1113
+ // 落盘成功的回执不因列表刷新失败而翻转为失败;列表暂旧由后续操作收敛
1114
+ try {
1115
+ const res = await api('/api/turn-notify/sounds')
1116
+ setSounds(res.sounds || [])
1117
+ await refreshSounds()
1118
+ } catch { }
1119
+ }
1120
+
1121
+ async function removeSound(sound) {
1122
+ setBusy(true)
1123
+ try {
1124
+ await api('/api/turn-notify/sound?id=' + encodeURIComponent(sound.id), { method: 'DELETE' })
1125
+ const res = await api('/api/turn-notify/sounds')
1126
+ setSounds(res.sounds || [])
1127
+ await refreshSounds()
1128
+ decodedCache.delete(sound.id)
1129
+ patch('已删除,引用该音效的分类已回落内置默认')
1130
+ } catch (error) {
1131
+ patch('删除失败:' + (error && error.message ? error.message : String(error)), 'error')
1132
+ } finally { setBusy(false) }
1133
+ }
1134
+
1135
+ // 重命名编辑态:同一时刻至多一行处于编辑;提交成功后缓存按旧 id 失效
1136
+ const [renamingId, setRenamingId] = useState(null)
1137
+ const [renameDraft, setRenameDraft] = useState('')
1138
+
1139
+ function startRename(sound) {
1140
+ setRenamingId(sound.id)
1141
+ setRenameDraft(sound.name || sound.id)
1142
+ }
1143
+
1144
+ function cancelRename() {
1145
+ setRenamingId(null)
1146
+ setRenameDraft('')
1147
+ }
1148
+
1149
+ async function renameSound(sound) {
1150
+ setBusy(true)
1151
+ try {
1152
+ const res = await api('/api/turn-notify/sound', { method: 'PUT', body: JSON.stringify({ id: sound.id, name: renameDraft }) })
1153
+ decodedCache.delete(sound.id)
1154
+ patch('已重命名为 ' + res.name)
1155
+ cancelRename()
1156
+ } catch (error) {
1157
+ patch('重命名失败:' + (error && error.message ? error.message : String(error)), 'error')
1158
+ } finally { setBusy(false) }
1159
+ // 改名成功的回执不因列表刷新失败而翻转为失败;列表暂旧由后续操作收敛
1160
+ try {
1161
+ const soundsRes = await api('/api/turn-notify/sounds')
1162
+ setSounds(soundsRes.sounds || [])
1163
+ await refreshSounds()
1164
+ } catch { }
1165
+ }
1166
+
1167
+ // 作用域切换:开=映射改动只写本机 localStorage,读合并;关=读写全走全局,本地数据保留但休眠
1168
+ function toggleLocalMapping(checked) {
1169
+ setLocalMode(checked)
1170
+ localSet(KEY_MAPPING_LOCAL, checked ? '1' : '0')
1171
+ }
1172
+
1173
+ // 分类提示音切换:显式 false=静音,删除键=恢复出声;发声链路每次直读 localStorage
1174
+ function toggleSoundCategory(category) {
1175
+ const next = { ...soundCategories }
1176
+ if (next[category] === false) delete next[category]
1177
+ else next[category] = false
1178
+ setSoundCategoriesState(next)
1179
+ localSet(KEY_SOUND_CATEGORIES, JSON.stringify(next))
1180
+ }
1181
+
1182
+ // 页内提示音分类切换:与提示音分类同构,独立存储互不影响
1183
+ function togglePageSoundCategory(category) {
1184
+ const next = { ...pageSoundCategories }
1185
+ if (next[category] === false) delete next[category]
1186
+ else next[category] = false
1187
+ setPageSoundCategoriesState(next)
1188
+ localSet(KEY_PAGE_SOUND_CATEGORIES, JSON.stringify(next))
1189
+ }
1190
+
1191
+ // 页内提示音场景映射:空值删除键(沿用通知映射),非空(含内置音名/上传 id)覆盖
1192
+ function setPageMappingCategory(category, id) {
1193
+ const next = { ...pageMapping }
1194
+ if (id.length === 0) delete next[category]
1195
+ else next[category] = id
1196
+ setPageMappingState(next)
1197
+ localSet(KEY_PAGE_MAPPING, JSON.stringify(next))
1198
+ }
1199
+
1200
+ async function setMapping(category, id) {
1201
+ // 本地模式:空串为显式内置默认,同样保留为键值
1202
+ if (localMode) {
1203
+ const next = { ...localMapping, [category]: id }
1204
+ setLocalMappingState(next)
1205
+ localSet(KEY_MAPPING, JSON.stringify(next))
1206
+ patch(CATEGORY_LABELS[category] + ' 音效已更新(仅存本机浏览器)')
1207
+ return
1208
+ }
1209
+ try {
1210
+ const res = await api('/api/turn-notify/mapping', { method: 'POST', body: JSON.stringify({ category, id }) })
1211
+ setMappingState(res.soundMapping || {})
1212
+ patch(CATEGORY_LABELS[category] + ' 音效已更新')
1213
+ } catch (error) {
1214
+ patch('映射失败:' + (error && error.message ? error.message : String(error)), 'error')
1215
+ }
1216
+ }
1217
+
1218
+ async function saveConfig() {
1219
+ if (!configLoaded) {
1220
+ patch('配置尚未加载,不能保存(刷新页面重试)', 'error')
1221
+ return
1222
+ }
1223
+ setBusy(true)
1224
+ try {
1225
+ const raw = typeof config.minTurnDurationMs === 'string'
1226
+ ? config.minTurnDurationMs.trim()
1227
+ : String(config.minTurnDurationMs)
1228
+ if (raw.length === 0) throw new Error('最短回合时长不能为空')
1229
+ const trimmedUrl = urlDraft.trim()
1230
+ // imTargets 由勾选单独即时保存,不随此入口提交;会话高亮为本机开关,不进 host 配置
1231
+ const patchBody = { minTurnDurationMs: Number(raw), rootsOnly: config.rootsOnly, suppressSubagentWake: config.suppressSubagentWake }
1232
+ // 只写语义:输入留空即保持现有 webhook 不变
1233
+ if (trimmedUrl.length > 0) patchBody.webhookUrl = trimmedUrl
1234
+ const res = await api('/api/turn-notify/config', { method: 'POST', body: JSON.stringify(patchBody) })
1235
+ setConfig({ ...DEFAULT_CONFIG, ...res })
1236
+ if (res.soundMapping) setMappingState(res.soundMapping)
1237
+ setUrlDraft('')
1238
+ patch('配置已保存,立即生效')
1239
+ } catch (error) {
1240
+ patch('保存失败:' + (error && error.message ? error.message : String(error)), 'error')
1241
+ } finally { setBusy(false) }
1242
+ }
1243
+
1244
+ async function clearWebhook() {
1245
+ setBusy(true)
1246
+ try {
1247
+ const res = await api('/api/turn-notify/config', { method: 'POST', body: JSON.stringify({ webhookUrl: '' }) })
1248
+ setConfig({ ...DEFAULT_CONFIG, ...res })
1249
+ if (res.soundMapping) setMappingState(res.soundMapping)
1250
+ setUrlDraft('')
1251
+ patch('webhook 已清除')
1252
+ } catch (error) {
1253
+ patch('清除失败:' + (error && error.message ? error.message : String(error)), 'error')
1254
+ } finally { setBusy(false) }
1255
+ }
1256
+
1257
+ // 分类通知开关:乐观回填为连点提供正确取反基点,提交交由模块级串行链收敛
1258
+ function toggleCategory(category, checked) {
1259
+ setConfig((prev) => ({ ...prev, enabled: { ...prev.enabled, [category]: checked } }))
1260
+ submitCategoryToggle(category, checked, {
1261
+ apiImpl: api,
1262
+ onConfig: (res) => {
1263
+ setConfig({ ...DEFAULT_CONFIG, ...res })
1264
+ if (res.soundMapping) setMappingState(res.soundMapping)
1265
+ },
1266
+ onError: (text) => patch(text, 'error'),
1267
+ })
1268
+ }
1269
+
1270
+ async function requestPermission() {
1271
+ try {
1272
+ const next = await Notification.requestPermission()
1273
+ setPermission(next)
1274
+ patch(next === 'granted' ? '弹窗授权成功,失焦时将走系统弹窗' : '弹窗被拒,可在浏览器地址栏权限或系统设置中恢复', next === 'granted' ? 'ok' : 'error')
1275
+ } catch (error) {
1276
+ patch('授权失败:' + (error && error.message ? error.message : String(error)), 'error')
1277
+ }
1278
+ }
1279
+
1280
+ // 页内通知通道单独测试:弹页内提示,不涉及系统通知;页内提示音开关开启时随卡片补一声
1281
+ function testPageNotification() {
1282
+ if (!toast) {
1283
+ patch('toast 库未装载(权威消费方未安装),页内通知通道不可用', 'error')
1284
+ return
1285
+ }
1286
+ toast('[dsh] 页内通知测试', { holdMs: TOAST_MS })
1287
+ // 复用页内提示音开关,点火即播,音色按页内场景映射解析;播放结果回执可见
1288
+ if (localGet(KEY_PAGE_SOUND) === '1') {
1289
+ const sound = resolveSound('completed', effectivePageMapping, soundIds)
1290
+ playAudible(sound).then((result) => {
1291
+ if (!result.ok) patch('页内提示音未播放:' + result.reason, 'error')
1292
+ })
1293
+ }
1294
+ patch('页内通知已发送')
1295
+ }
1296
+
1297
+ // 系统通知通道单独测试:浏览器对聚焦窗口抑制系统弹窗,延迟发送模拟真实场景
1298
+ // (真实链路里系统通知只在用户切出窗口后触发);依据浏览器显示回执与
1299
+ // 超时兜底给出诊断,环境层拦截(系统通知设置/专注助手)在此显性化
1300
+ function testSystemNotification() {
1301
+ const current = notificationPermission()
1302
+ if (current === 'default') {
1303
+ void requestPermission()
1304
+ return
1305
+ }
1306
+ if (current !== 'granted') {
1307
+ patch('Notification 不可用或未授权(HTTP 非回环地址或曾被拒绝),已改为标题闪烁', 'error')
1308
+ return
1309
+ }
1310
+ patch('请在 ' + Math.round(SYSTEM_TEST_DELAY_MS / 1000) + ' 秒内切出本窗口,随后到达的才是系统通知')
1311
+ setTimeout(() => {
1312
+ const focused = document.hasFocus()
1313
+ let reported = false
1314
+ notifySystem({ id: 'ui-test', text: '[dsh] 测试系统通知', category: 'completed' }, (shown) => {
1315
+ reported = true
1316
+ if (focused) return
1317
+ patch(shown ? '系统通知已显示,浏览器确认送达' : '系统通知显示失败(浏览器报告错误)', shown ? 'ok' : 'error')
1318
+ })
1319
+ if (focused) {
1320
+ patch('已发送,但窗口仍聚焦,浏览器会抑制弹窗;请切出窗口后重新测试', 'error')
1321
+ return
1322
+ }
1323
+ setTimeout(() => {
1324
+ if (reported) return
1325
+ patch('未收到浏览器显示回执:请检查 Windows 设置 > 通知 中浏览器的通知权限,以及专注助手 / 勿扰是否拦截', 'error')
1326
+ }, SYSTEM_SHOW_TIMEOUT_MS)
1327
+ }, SYSTEM_TEST_DELAY_MS)
1328
+ }
1329
+
1330
+ async function testWebhook() {
1331
+ if (urlDraft.trim().length > 0) {
1332
+ patch('表单中的新 webhook URL 尚未保存,本次测试的是已保存配置;请先保存再测试', 'error')
1333
+ return
1334
+ }
1335
+ try {
1336
+ const result = await api('/api/turn-notify/test-webhook', { method: 'POST' })
1337
+ patch(result.ok ? 'webhook 已送达(' + result.detail + ')' : 'webhook 发送失败:' + result.detail, result.ok ? 'ok' : 'error')
1338
+ } catch (error) {
1339
+ patch('发送失败:' + (error && error.message ? error.message : String(error)), 'error')
1340
+ }
1341
+ }
1342
+
1343
+ // IM 目标加载:目录来自 dsh-im 已保存目标;失败(离线/ID 复制错误)如实展示错误码
1344
+ async function loadImTargets(botIdOverride) {
1345
+ const botId = (typeof botIdOverride === 'string' ? botIdOverride : imBotIdDraft).trim()
1346
+ if (botId.length === 0) {
1347
+ patch('请先粘贴 Bot ID(设置页 IM机器人 卡片右上角齿轮)', 'error')
1348
+ return
1349
+ }
1350
+ setBusy(true)
1351
+ try {
1352
+ const res = await api('/api/turn-notify/im-targets?botId=' + encodeURIComponent(botId))
1353
+ setImCatalog({ botId, targets: res.targets || [] })
1354
+ patch('已加载 ' + (res.targets || []).length + ' 个目标,勾选即保存')
1355
+ } catch (error) {
1356
+ patch('加载失败:' + (error && error.message ? error.message : String(error)), 'error')
1357
+ } finally { setBusy(false) }
1358
+ }
1359
+
1360
+ // 已绑 bot chips 由 imBoundBotIds 去重(见 LOGIC 段同源函数)
1361
+ const imBoundBots = imBoundBotIds(config.imTargets)
1362
+
1363
+ // 勾选即存:与分类开关同模式,列表整体替换,连续操作以最新一次请求为准
1364
+ let imPersistSeq = 0
1365
+ async function persistImTargets(next, okMessage) {
1366
+ const seq = ++imPersistSeq
1367
+ setConfig({ ...config, imTargets: next })
1368
+ try {
1369
+ const res = await api('/api/turn-notify/config', { method: 'POST', body: JSON.stringify({ imTargets: next }) })
1370
+ if (seq === imPersistSeq) {
1371
+ setConfig({ ...DEFAULT_CONFIG, ...res })
1372
+ if (res.soundMapping) setMappingState(res.soundMapping)
1373
+ if (okMessage !== undefined) patch(okMessage)
1374
+ }
1375
+ } catch (error) {
1376
+ patch('IM 目标保存失败:' + (error && error.message ? error.message : String(error)), 'error')
1377
+ }
1378
+ }
1379
+
1380
+ function toggleImTarget(botId, target, checked) {
1381
+ void persistImTargets(toggleImTargetList(config.imTargets, botId, target.targetId, checked))
1382
+ }
1383
+
1384
+ function removeImTarget(item) {
1385
+ void persistImTargets(removeImTargetFromList(config.imTargets, item.botId, item.targetId))
1386
+ }
1387
+
1388
+ // 取消注册:移除该 bot 全部目标;bot 在 dsh-im 已删除时借此清理残留绑定
1389
+ function unregisterImBot(botId) {
1390
+ void persistImTargets(
1391
+ unregisterImBotList(config.imTargets, botId),
1392
+ '已取消注册 ' + botId,
1393
+ )
1394
+ }
1395
+
1396
+ async function testIm() {
1397
+ try {
1398
+ const result = await api('/api/turn-notify/test-im', { method: 'POST' })
1399
+ if (!result.results) {
1400
+ patch('IM 测试失败:' + result.detail, 'error')
1401
+ return
1402
+ }
1403
+ const failed = result.results.filter((item) => !item.ok)
1404
+ patch(failed.length === 0
1405
+ ? 'IM 通知已全部送达(' + result.results.length + ' 个目标)'
1406
+ : '部分失败:' + failed.map((item) => item.botId + '/' + item.targetId + ' ' + item.detail).join('; '),
1407
+ failed.length === 0 ? 'ok' : 'error')
1408
+ } catch (error) {
1409
+ patch('发送失败:' + (error && error.message ? error.message : String(error)), 'error')
1410
+ }
1411
+ }
1412
+
1413
+ // 音效描述:试听回执指明实际播放对象,映射失效回落内置时可见
1414
+ function describeSound(sound) {
1415
+ return sound.kind === 'custom' ? '上传音效 ' + sound.id : '内置 ' + (TONE_LABELS[sound.name] || sound.name)
1416
+ }
1417
+
1418
+ // 生效映射与死链:合并全局镜像与本地镜像(开关关时本地休眠不参与)
1419
+ const soundIds = sounds.map((sound) => sound.id)
1420
+ const effective = mergeMapping(mapping, localMode ? localMapping : {})
1421
+ const deadIds = deadCustomIds(effective, soundIds)
1422
+
1423
+ // 分类试听:播放该分类当前实际生效的音效(自定义 / 内置 / 失效回落),与通知真实发声同语义;
1424
+ // 命中死链时回执归因,引导重传或改选
1425
+ function previewCategory(category) {
1426
+ const sound = resolveSound(category, effective, soundIds)
1427
+ playAudible(sound).then((result) => {
1428
+ if (!result.ok) patch(CATEGORY_LABELS[category] + ' 试听未播放:' + result.reason, 'error')
1429
+ else if (deadIds.indexOf(effective[category]) >= 0) patch('映射 ' + effective[category] + ' 已失效,已播放内置默认,请重新上传音效或改选映射', 'error')
1430
+ else patch('已试听 ' + CATEGORY_LABELS[category] + ':' + describeSound(sound))
1431
+ })
1432
+ }
1433
+
1434
+ // 页内场景映射:通知生效映射为底、页内显式配置覆盖,与聚焦补位音真实发声同语义;
1435
+ // 死链按合并视图计算:页内自身死链(删音效不清理本机键)与继承的通知死链都需呈现
1436
+ const effectivePageMapping = mergeMapping(effective, pageMapping)
1437
+ const pageDeadIds = deadCustomIds(effectivePageMapping, soundIds)
1438
+
1439
+ function previewPageCategory(category) {
1440
+ const sound = resolveSound(category, effectivePageMapping, soundIds)
1441
+ playAudible(sound).then((result) => {
1442
+ if (!result.ok) patch(CATEGORY_LABELS[category] + ' 试听未播放:' + result.reason, 'error')
1443
+ else if (pageDeadIds.indexOf(effectivePageMapping[category]) >= 0) patch('映射 ' + effectivePageMapping[category] + ' 已失效,已播放内置默认,请重新上传音效或改选映射', 'error')
1444
+ else patch('已试听页内 ' + CATEGORY_LABELS[category] + ':' + describeSound(sound))
1445
+ })
1446
+ }
1447
+
1448
+ // 两个场景映射的选项尾段同集,差异仅在首项(通知=内置默认,页内=沿用通知音效)与死链呈现
1449
+ const toneOptionTail = Object.keys(TONE_LABELS).map((name) => h('option', { key: name, value: name }, '内置 · ' + TONE_LABELS[name]))
1450
+ .concat(sounds.map((sound) => h('option', { key: sound.id, value: sound.id }, '上传 · ' + (sound.name || sound.id))))
1451
+ .concat(deadIds.map((id) => h('option', { key: id, value: id }, '失效 · ' + id)))
1452
+
1453
+ const soundOptions = [h('option', { key: '', value: '' }, '内置默认')].concat(toneOptionTail)
1454
+
1455
+ // 页内场景选项:首项为沿用通知音效,滤除通知死链防与页内死链尾段重复,呈现以页内合并视图为准
1456
+ const pageSoundOptions = [h('option', { key: '', value: '' }, '沿用通知音效')].concat(
1457
+ toneOptionTail.filter((option) => deadIds.indexOf(option.props.value) < 0),
1458
+ ).concat(pageDeadIds.map((id) => h('option', { key: id, value: id }, '失效 · ' + id)))
1459
+
1460
+ // 分区定义:IM 卡随 dsh-im 在场与否出现;activeTab 兜底防 imAvailable 回落时落空
1461
+ const tabs = [
1462
+ { id: '通知', label: '通知' },
1463
+ { id: '偏好', label: '偏好' },
1464
+ { id: '音效', label: '音效' },
1465
+ ...(config.imAvailable ? [{ id: 'IM', label: 'IM' }] : []),
1466
+ { id: '测试', label: '测试' },
1467
+ ]
1468
+ const activeTab = tabs.some((item) => item.id === tab) ? tab : tabs[0].id
1469
+
1470
+ return h('div', { className: 'tn-panel' },
1471
+ h('div', { className: 'tn-head' },
1472
+ h('span', { className: 'tn-head__title' }, '消息通知'),
1473
+ h('span', { className: 'tn-head__hint' }, '保存即生效;标签页全关时仅 webhook 与 IM 送达'),
1474
+ ),
1475
+ h('div', { className: 'tn-tabs', role: 'tablist' },
1476
+ tabs.map((item) => h('button', {
1477
+ key: item.id,
1478
+ type: 'button',
1479
+ role: 'tab',
1480
+ 'aria-selected': activeTab === item.id,
1481
+ className: 'tn-tab' + (activeTab === item.id ? ' tn-tab--on' : ''),
1482
+ onClick: () => setTab(item.id),
1483
+ }, item.label)),
1484
+ ),
1485
+ h('div', { className: 'tn-tabpanel', role: 'tabpanel' },
1486
+ activeTab === '通知' ? h('div', { className: 'tn-card' },
1487
+ h('div', { className: 'tn-card__head' },
1488
+ h('span', { className: 'tn-card__title' }, '通知配置'),
1489
+ h('span', { className: 'tn-card__sub' }, '六类事件的触发与过滤;开关即时生效,数值改动需点保存'),
1490
+ ),
1491
+ field('webhook', [
1492
+ h('input', {
1493
+ className: 'tn-input tn-fill', type: 'text',
1494
+ title: '通知由 host 直接 POST 到该地址,标签页全关也送达;Slack 兼容 JSON 格式,超时 10 秒不重试,凭据不回显',
1495
+ placeholder: config.webhookConfigured ? '已配置(输入新 URL 替换,留空保持不变)' : 'Slack-compatible URL,留空禁用',
1496
+ value: urlDraft,
1497
+ onChange: (e) => setUrlDraft(e.target.value),
1498
+ }),
1499
+ config.webhookConfigured
1500
+ ? h('button', { className: 'tn-btn tn-btn--danger', disabled: busy, title: '清除已配置的 webhook,清除后该通道禁用', onClick: () => void clearWebhook() }, '清除')
1501
+ : null,
1502
+ ], 'URL 只写不回显'),
1503
+ field('最短回合时长', [
1504
+ h('input', {
1505
+ className: 'tn-input', type: 'number', min: 0, step: 500, style: { width: '90px' },
1506
+ title: '过滤连续快速的小回合(如自动压缩、状态刷新);默认 5000 毫秒,设为 0 关闭过滤',
1507
+ value: config.minTurnDurationMs,
1508
+ onChange: (e) => setConfig({ ...config, minTurnDurationMs: e.target.value }),
1509
+ }),
1510
+ h('span', { className: 'tn-meta' }, '毫秒,回合结束类通知短于此时长不送达;提问与审批请求即时送达'),
1511
+ ]),
1512
+ field('子代理过滤', [
1513
+ h('label', { className: 'tn-meta tn-switch', title: '子代理是主会话委托出去的独立会话;开启后子代理自身的完成/出错不通知,只有主会话通知' },
1514
+ ...switchToggle({
1515
+ checked: config.rootsOnly,
1516
+ onChange: (e) => setConfig({ ...config, rootsOnly: e.target.checked }),
1517
+ }),
1518
+ ' 子代理会话不通知'),
1519
+ h('label', { className: 'tn-meta tn-switch', title: '发起后台委托后主回合先结束的等待期,以及子代理完成后唤醒父会话继续工作的回合,任务完成通知均静默;整条委托链只在最终回合响一次' },
1520
+ ...switchToggle({
1521
+ checked: config.suppressSubagentWake,
1522
+ onChange: (e) => setConfig({ ...config, suppressSubagentWake: e.target.checked }),
1523
+ }),
1524
+ ' 后台委托未收尾或收尾唤醒的回合不通知(仅完成类)'),
1525
+ ]),
1526
+ field('事件分类', h('div', { className: 'tn-pills' },
1527
+ CATEGORIES.map((category) => h('span', {
1528
+ className: 'tn-pill' + (config.enabled[category] ? ' tn-pill--on' : ''),
1529
+ key: category,
1530
+ title: CATEGORY_LABELS[category] + ':当前' + (config.enabled[category] ? '触发通知,点击停用' : '不触发,点击启用'),
1531
+ onClick: () => void toggleCategory(category, !config.enabled[category]),
1532
+ }, CATEGORY_LABELS[category])),
1533
+ ), '亮=触发通知,暗=不触发,点击即存即时生效'),
1534
+ h('div', { className: 'tn-actions' },
1535
+ h('button', { className: 'tn-btn tn-btn--primary', disabled: busy, title: '保存 webhook、时长与子代理过滤的改动;六类事件开关点击时已即时保存', onClick: () => void saveConfig() }, '保存'),
1536
+ ),
1537
+ ) : null,
1538
+ activeTab === '偏好' ? h('div', { className: 'tn-card' },
1539
+ h('div', { className: 'tn-card__head' },
1540
+ h('span', { className: 'tn-card__title' }, '本机偏好'),
1541
+ h('span', { className: 'tn-card__sub' }, '仅存当前浏览器(同浏览器各窗口共用),不影响其他浏览器与设备'),
1542
+ ),
1543
+ field('会话高亮', [
1544
+ h('label', { className: 'tn-meta tn-switch', title: '通知触发时脉冲闪烁侧边栏对应会话行(完成绿/出错红/提问蓝等六类各一色),点击该会话行即停止闪烁;仅本机开关,各浏览器独立' },
1545
+ ...switchToggle({
1546
+ defaultChecked: readSessionHighlightEnabled(),
1547
+ onChange: (e) => writeSessionHighlightEnabled(e.target.checked),
1548
+ }),
1549
+ ' 通知高亮侧边栏会话行'),
1550
+ ], '通知送达时对应会话行整行脉冲闪烁,一眼定位刚有动静的会话;点击闪烁的行或切走后自然停止。'),
1551
+ field('提示音', [
1552
+ h('label', { className: 'tn-meta tn-switch', title: '通知声音总开关,失焦时播报;关闭后通知声音静默,已开启的页内提示音会在无声时补位' },
1553
+ ...switchToggle({
1554
+ defaultChecked: localGet(KEY_SOUND) !== '0',
1555
+ onChange: (e) => localSet(KEY_SOUND, e.target.checked ? '1' : '0'),
1556
+ }),
1557
+ ' 开启'),
1558
+ h('div', { className: 'tn-pills' },
1559
+ CATEGORIES.map((category) => h('span', {
1560
+ className: 'tn-pill' + (soundCategories[category] !== false ? ' tn-pill--on' : ''),
1561
+ key: category,
1562
+ title: soundCategories[category] !== false
1563
+ ? CATEGORY_LABELS[category] + ':当前出声,点击静音'
1564
+ : CATEGORY_LABELS[category] + ':当前静音,点击恢复出声',
1565
+ onClick: () => toggleSoundCategory(category),
1566
+ }, CATEGORY_LABELS[category])),
1567
+ ),
1568
+ ], '点分类单独控制该类事件是否出声:亮=出声,暗=静音;总开关关闭时全部静音。页内提示卡片与会话高亮不受影响;被静音分类的系统弹窗与标题闪烁随之静默;页内提示音有独立的开关、分类与音色映射,不随此处变化。'),
1569
+ field('系统弹窗', [
1570
+ h('label', { className: 'tn-meta tn-switch', title: '窗口失焦时弹系统级通知,聚焦时静默(见聚焦静默);未授权且声音开启时降级为标题闪烁' },
1571
+ ...switchToggle({
1572
+ defaultChecked: localGet(KEY_SYSTEM) !== '0',
1573
+ onChange: (e) => localSet(KEY_SYSTEM, e.target.checked ? '1' : '0'),
1574
+ }),
1575
+ ' 开启'),
1576
+ h('span', { className: 'tn-meta' }, '权限:'
1577
+ + (typeof Notification === 'undefined' ? '不可用(非安全上下文)' : (PERMISSION_LABELS[permission] || permission))),
1578
+ typeof Notification !== 'undefined' && permission === 'default'
1579
+ ? h('button', { className: 'tn-btn', title: '向浏览器申请通知权限,授权后系统弹窗生效', onClick: () => void requestPermission() }, '授权')
1580
+ : null,
1581
+ ]),
1582
+ field('页内提示', [
1583
+ h('label', { className: 'tn-meta tn-switch', title: '页面角落浮出卡片提示,6 秒自动消失;聚焦窗口内唯一常开的提醒形态' },
1584
+ ...switchToggle({
1585
+ defaultChecked: localGet(KEY_TOAST) !== '0',
1586
+ onChange: (e) => localSet(KEY_TOAST, e.target.checked ? '1' : '0'),
1587
+ }),
1588
+ ' 开启'),
1589
+ ], '页面角落浮出卡片提示;聚焦时通知声音静默,提示音的听觉提醒由页内提示音场景独立承担。'),
1590
+ field('页内提示音', [
1591
+ h('label', { className: 'tn-meta tn-switch', title: '页内提示音总开关:页内提示弹出且未播放通知声音时补一声提示(聚焦时通知声音静默,靠它保留听觉提醒);与通知声音互斥,同一通知至多一声,音量与本页音量滑块共用' },
1592
+ ...switchToggle({
1593
+ defaultChecked: localGet(KEY_PAGE_SOUND) === '1',
1594
+ onChange: (e) => localSet(KEY_PAGE_SOUND, e.target.checked ? '1' : '0'),
1595
+ }),
1596
+ ' 开启'),
1597
+ h('div', { className: 'tn-pills' },
1598
+ CATEGORIES.map((category) => h('span', {
1599
+ className: 'tn-pill' + (pageSoundCategories[category] !== false ? ' tn-pill--on' : ''),
1600
+ key: category,
1601
+ title: pageSoundCategories[category] !== false
1602
+ ? CATEGORY_LABELS[category] + ':当前补位出声,点击静音'
1603
+ : CATEGORY_LABELS[category] + ':当前静音,点击恢复',
1604
+ onClick: () => togglePageSoundCategory(category),
1605
+ }, CATEGORY_LABELS[category])),
1606
+ ),
1607
+ ], '聚焦场景的独立声音:总开关与分类静音在此,音色在音效页的页内提示音映射卡单独指定(未配置的分类沿用通知音效);失焦场景的通知声音不受本行影响。'),
1608
+ field('音量', h('input', {
1609
+ className: 'tn-range', type: 'range', min: 0, max: 1, step: 0.05,
1610
+ title: '通知声音与页内提示音共用,按 5% 步进调节,本机记忆',
1611
+ defaultValue: volume(),
1612
+ onChange: (e) => localSet(KEY_VOLUME, e.target.value),
1613
+ })),
1614
+ field('行为', [
1615
+ h('label', { className: 'tn-meta tn-switch', title: '窗口聚焦时只保留页内提示,声音、系统弹窗与标题闪烁全部静默;离开键盘满 5 分钟视为不在电脑前,聚焦也全通道提醒' },
1616
+ ...switchToggle({
1617
+ defaultChecked: localGet(KEY_DND) !== '0',
1618
+ onChange: (e) => localSet(KEY_DND, e.target.checked ? '1' : '0'),
1619
+ }),
1620
+ ' 聚焦静默'),
1621
+ h('label', { className: 'tn-meta tn-switch', title: '系统弹窗未授权或不可用(HTTP 非回环地址、曾被拒绝)且声音通道开启时,标签页标题以 ⏳ 前缀闪烁替代弹窗' },
1622
+ ...switchToggle({
1623
+ defaultChecked: localGet(KEY_DEGRADE_HINT) !== '0',
1624
+ onChange: (e) => localSet(KEY_DEGRADE_HINT, e.target.checked ? '1' : '0'),
1625
+ }),
1626
+ ' 弹窗不可用时以标题闪烁替代'),
1627
+ ]),
1628
+ ) : null,
1629
+ activeTab === '音效' ? [
1630
+ h('div', { className: 'tn-card' },
1631
+ h('div', { className: 'tn-card__head' },
1632
+ h('span', { className: 'tn-card__title' }, '音效管理'),
1633
+ h('span', { className: 'tn-card__sub' }, 'wav / mp3 / ogg,可多选,单文件上限 2MB'),
1634
+ ),
1635
+ field('上传音效', h('input', {
1636
+ type: 'file', multiple: true, accept: AUDIO_EXTS.map((ext) => '.' + ext).join(','), disabled: busy,
1637
+ title: '可一次多选;上传前进待保存列表逐个试听,点保存才落盘;同一文件重复上传自动识别不产生重复;总库上限 10MB',
1638
+ onChange: (e) => {
1639
+ const files = e.target.files ? Array.from(e.target.files) : []
1640
+ e.target.value = ''
1641
+ void onPickFiles(files)
1642
+ },
1643
+ })),
1644
+ pendingUploads.map((item, index) => h('div', { className: 'tn-list__item', key: 'pending-' + index },
1645
+ h('span', { className: 'tn-list__grow' }, item.name),
1646
+ h('span', { className: 'tn-list__tag' }, '待保存'),
1647
+ h('button', {
1648
+ className: 'tn-btn',
1649
+ title: '播放该文件,确认效果后再保存',
1650
+ onClick: () => {
1651
+ previewPending(item.raw).then((result) => {
1652
+ if (!result.ok) patch('试听未播放:' + result.reason, 'error')
1653
+ })
1654
+ },
1655
+ }, '试听'),
1656
+ h('button', { className: 'tn-btn', disabled: busy, title: '上传到 host 音效库,保存后才可在分类映射中选用', onClick: () => void savePending(item) }, '保存'),
1657
+ h('button', {
1658
+ className: 'tn-btn tn-btn--ghost', disabled: busy,
1659
+ title: '从待保存列表移除,不产生任何存储',
1660
+ onClick: () => setPendingUploads(pendingUploads.filter((pending) => pending !== item)),
1661
+ }, '移除'),
1662
+ )),
1663
+ sounds.length === 0 ? h('span', { className: 'tn-meta' }, '暂无上传音效') :
1664
+ h('div', { className: 'tn-list' },
1665
+ sounds.map((sound) => renamingId === sound.id
1666
+ ? h('div', { className: 'tn-list__item', key: sound.id },
1667
+ h('input', {
1668
+ className: 'tn-input tn-fill', type: 'text', autoFocus: true,
1669
+ title: '输入新的展示名,回车确认',
1670
+ value: renameDraft,
1671
+ onChange: (e) => setRenameDraft(e.target.value),
1672
+ // Enter 提交:IME 组词确认(229)不算提交,busy 期间忽略防并发提交
1673
+ onKeyDown: (e) => {
1674
+ if (e.key === 'Enter' && !busy && !e.nativeEvent.isComposing && e.nativeEvent.keyCode !== 229) void renameSound(sound)
1675
+ },
1676
+ }),
1677
+ h('button', { className: 'tn-btn', disabled: busy, title: '提交重命名', onClick: () => void renameSound(sound) }, '确认'),
1678
+ h('button', { className: 'tn-btn tn-btn--ghost', disabled: busy, title: '放弃本次重命名', onClick: cancelRename }, '取消'),
1679
+ )
1680
+ : h('div', { className: 'tn-list__item', key: sound.id },
1681
+ h('span', { className: 'tn-list__grow' }, (sound.name || sound.id) + '.' + sound.ext),
1682
+ h('button', {
1683
+ className: 'tn-btn',
1684
+ title: '播放该音效',
1685
+ onClick: () => {
1686
+ playAudible({ kind: 'custom', id: sound.id }).then((result) => {
1687
+ if (!result.ok) patch('试听未播放:' + result.reason, 'error')
1688
+ })
1689
+ },
1690
+ }, '试听'),
1691
+ h('button', { className: 'tn-btn', disabled: busy, title: '只改展示名,不影响分类映射引用;同一文件重新上传按内容自动恢复映射', onClick: () => startRename(sound) }, '重命名'),
1692
+ h('button', { className: 'tn-btn tn-btn--ghost', disabled: busy, title: '从音效库删除;引用它的分类映射自动清空,回落内置默认', onClick: () => void removeSound(sound) }, '删除'),
1693
+ )),
1694
+ ),
1695
+ ),
1696
+ h('div', { className: 'tn-card' },
1697
+ h('div', { className: 'tn-card__head' },
1698
+ h('span', { className: 'tn-card__title' }, '分类音效映射'),
1699
+ h('span', { className: 'tn-card__sub' }, '每类事件可指定上传音效或内置音,失效自动回落内置默认'),
1700
+ ),
1701
+ field('作用域', [
1702
+ h('label', { className: 'tn-switch', title: '开启后音效映射仅对本浏览器(域名)生效' },
1703
+ ...switchToggle({
1704
+ checked: localMode,
1705
+ onChange: (e) => toggleLocalMapping(e.target.checked),
1706
+ })),
1707
+ h('span', { className: 'tn-meta' }, localMode ? '当前域名独立' : '全部域名共用'),
1708
+ ], '开启:映射改动只保存在本浏览器(按访问域名隔离),本地优先于全局,公司/家里的配置互不影响。关闭:全域名共用 host 全局配置(settings.yaml)。'),
1709
+ !localMode && Object.keys(localMapping).length > 0
1710
+ ? h('div', { className: 'tn-field' },
1711
+ h('span', { className: 'tn-field__label' }),
1712
+ h('div', { className: 'tn-field__control' },
1713
+ h('span', { className: 'tn-meta' },
1714
+ '已保存 ' + Object.keys(localMapping).length + ' 项本地映射,当前休眠,重新开启即恢复生效。'),
1715
+ ))
1716
+ : null,
1717
+ CATEGORIES.map((category) => field(CATEGORY_LABELS[category], [
1718
+ h('select', {
1719
+ className: 'tn-select tn-fill', value: effective[category] || '',
1720
+ title: '该类事件触发时播放的音效,选择即保存;空为内置默认',
1721
+ onChange: (e) => void setMapping(category, e.target.value),
1722
+ }, soundOptions),
1723
+ h('button', { className: 'tn-btn', title: '播放该分类当前生效的音效;映射失效时回落内置默认', onClick: () => previewCategory(category) }, '试听'),
1724
+ ])),
1725
+ ),
1726
+ h('div', { className: 'tn-card' },
1727
+ h('div', { className: 'tn-card__head' },
1728
+ h('span', { className: 'tn-card__title' }, '页内提示音映射'),
1729
+ h('span', { className: 'tn-card__sub' }, '聚焦补位音场景,独立于上方通知音效;仅存本机浏览器,未配置的分类沿用通知音效'),
1730
+ ),
1731
+ CATEGORIES.map((category) => field(CATEGORY_LABELS[category], [
1732
+ h('select', {
1733
+ className: 'tn-select tn-fill', value: pageMapping[category] || '',
1734
+ title: '聚焦时页内提示音播放的音效,选择即保存;空为沿用通知音效',
1735
+ onChange: (e) => setPageMappingCategory(category, e.target.value),
1736
+ }, pageSoundOptions),
1737
+ h('button', { className: 'tn-btn', title: '按页内场景解析播放该分类音效(页内配置覆盖,缺省沿用通知映射)', onClick: () => previewPageCategory(category) }, '试听'),
1738
+ ])),
1739
+ ),
1740
+ ] : null,
1741
+ activeTab === 'IM' ? h('div', { className: 'tn-card' },
1742
+ h('div', { className: 'tn-card__head' },
1743
+ h('span', { className: 'tn-card__title' }, 'IM 投递(dsh-im)'),
1744
+ h('span', { className: 'tn-card__sub' }, '勾选目标即自动保存;支持绑定多个 bot,点 bot 名加载其目录,× 取消注册'),
1745
+ ),
1746
+ field('Bot ID', [
1747
+ h('input', {
1748
+ className: 'tn-input tn-fill', type: 'text',
1749
+ title: '在设置页 IM机器人 卡片复制 Bot ID 粘贴到这里;需先安装 dsh-im 并保持 bot 在线',
1750
+ placeholder: '从设置页 IM机器人 卡片复制 Bot ID',
1751
+ value: imBotIdDraft,
1752
+ onChange: (e) => setImBotIdDraft(e.target.value),
1753
+ }),
1754
+ h('button', { className: 'tn-btn', disabled: busy, title: '拉取该 bot 已保存的投递目标列表', onClick: () => void loadImTargets() }, '加载目标'),
1755
+ ]),
1756
+ imBoundBots.length > 0 ? field('已绑 bot',
1757
+ imBoundBots.map((botId) => h('span', { className: 'tn-chip', key: botId },
1758
+ h('button', {
1759
+ className: 'tn-chip__name'
1760
+ + (imCatalog !== null && imCatalog.botId === botId ? ' tn-chip__name--active' : ''),
1761
+ disabled: busy,
1762
+ title: '点击加载该 bot 的目标目录',
1763
+ onClick: () => { setImBotIdDraft(botId); void loadImTargets(botId) },
1764
+ }, botId),
1765
+ h('button', {
1766
+ className: 'tn-chip__x', disabled: busy, title: '取消注册(移除该 bot 全部目标)',
1767
+ onClick: () => unregisterImBot(botId),
1768
+ }, '×'),
1769
+ )),
1770
+ ) : null,
1771
+ imCatalog !== null
1772
+ ? imCatalog.targets.length === 0
1773
+ ? h('div', { className: 'tn-meta' }, '该 bot 尚无已保存投递目标,先在 dsh-im 设置页新建并测试')
1774
+ : h('div', { className: 'tn-list' },
1775
+ imCatalog.targets.map((target) => {
1776
+ const checked = config.imTargets.some((item) => item.botId === imCatalog.botId && item.targetId === target.targetId)
1777
+ return h('label', { className: 'tn-list__item tn-switch', key: target.targetId,
1778
+ title: '勾选即保存,通知将推送到该目标;目标的新建与平台侧测试在 dsh-im 设置页完成' },
1779
+ ...switchToggle({
1780
+ checked,
1781
+ onChange: (e) => toggleImTarget(imCatalog.botId, target, e.target.checked),
1782
+ }),
1783
+ h('span', { className: 'tn-list__grow' },
1784
+ target.targetId + (target.name ? ' (' + target.name + ')' : '')),
1785
+ h('span', { className: 'tn-list__tag' }, target.kind || ''),
1786
+ )
1787
+ }),
1788
+ )
1789
+ : null,
1790
+ config.imTargets.length > 0 ? h('hr', { className: 'tn-divider' }) : null,
1791
+ config.imTargets.length === 0
1792
+ ? h('div', { className: 'tn-meta' }, '尚未绑定投递目标,通知不会推送 IM')
1793
+ : h('div', { className: 'tn-list' },
1794
+ config.imTargets.map((item) => h('div', { className: 'tn-list__item', key: imTargetKey(item) },
1795
+ h('span', { className: 'tn-list__grow' }, item.targetId),
1796
+ h('span', { className: 'tn-list__tag' }, item.botId),
1797
+ h('button', {
1798
+ className: 'tn-btn tn-btn--ghost', disabled: busy,
1799
+ title: '从通知目标中移除,不影响 dsh-im 侧已保存的目标本身',
1800
+ onClick: () => removeImTarget(item),
1801
+ }, '移除'),
1802
+ )),
1803
+ ),
1804
+ ) : null,
1805
+ activeTab === '测试' ? h('div', { className: 'tn-card' },
1806
+ h('div', { className: 'tn-card__head' },
1807
+ h('span', { className: 'tn-card__title' }, '测试'),
1808
+ h('span', { className: 'tn-card__sub' }, '各通道逐一点火,回执即真实结果'),
1809
+ ),
1810
+ h('div', { className: 'tn-btngroup' },
1811
+ h('button', {
1812
+ className: 'tn-btn',
1813
+ title: '按任务完成分类当前生效的音效播放;测其他分类请到音效页对该分类试听',
1814
+ // 测试声音读当前生效映射:播放任务完成分类实际生效的音效,而非固定参考音
1815
+ onClick: () => {
1816
+ const sound = resolveSound('completed', effective, soundIds)
1817
+ playAudible(sound).then((result) => {
1818
+ patch(result.ok
1819
+ ? '测试声音已触发:' + describeSound(sound) + ',若未听到请检查系统音量与输出设备'
1820
+ : '测试声音未播放:' + result.reason, result.ok ? 'ok' : 'error')
1821
+ })
1822
+ },
1823
+ }, '测试声音'),
1824
+ h('button', { className: 'tn-btn', title: '弹出一条页内卡片;页内提示音已开启时随卡片补一声(点火测试,不经分类静音约束)', onClick: testPageNotification }, '测试页内通知'),
1825
+ h('button', { className: 'tn-btn', title: '弹一条系统通知验证授权与送达;未授权会先引导授权', onClick: testSystemNotification }, '测试系统通知'),
1826
+ h('button', { className: 'tn-btn', title: '向已配置的 webhook 发送真实测试事件,回执显示投递结果;未配置时提示失败', onClick: () => void testWebhook() }, '测试 webhook'),
1827
+ config.imAvailable ? h('button', { className: 'tn-btn', disabled: busy, title: '向全部已配置目标发送真实测试事件,逐目标显示结果', onClick: () => void testIm() }, '测试 IM 通知') : null,
1828
+ ),
1829
+ ) : null,
1830
+ ),
1831
+ )
1832
+ }
1833
+
1834
+ return {
1835
+ inject: ['slots'],
1836
+ apply(ctx) {
1837
+ // 样式挂载宿主文档级:通知栈在面板未打开时也要有完整样式
1838
+ ctx.effect(() => {
1839
+ const style = document.createElement('style')
1840
+ style.textContent = CSS
1841
+ document.head.appendChild(style)
1842
+ return () => style.remove()
1843
+ }, 'turn-notify styles')
1844
+ // 激活即轮询:通知链路不依赖设置面板是否打开过
1845
+ start()
1846
+ ctx.slots.inject('settings.section', () =>
1847
+ ctx.slots.register(
1848
+ { name: 'settings.section', id: 'turn-notify', order: 41, label: '消息通知' },
1849
+ () => React.createElement(TurnNotifyApp),
1850
+ ))
1851
+ },
1852
+ // 测试钩子:供全链路集成测试注入 stub 后取内部函数,生产无消费方;
1853
+ // 页内通知的展示结果经 require 桩捕获,不在本包断言
1854
+ __test: { poll, pollOnce, storageState, announcedIds, submitCategoryToggle, start },
1855
+ }
1856
+ },
1857
+ })