dsh-notify-say 0.4.52 → 0.4.55

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/lib/client.js CHANGED
@@ -173,30 +173,45 @@ window.__ModuleLoader__.load({
173
173
 
174
174
  /** 极简样式(内联):不依赖任何 CSS 模块(哈希会随构建漂)。 */
175
175
  var S = {
176
- box: { padding: '12px', fontSize: '13px', lineHeight: '1.8' },
176
+ // 内容宽度约束:设置卡片可能被拉得很宽,长行读起来很累。
177
+ // 680px 是「一行能放下标签+控件+数值,又不至于横跨整屏」的折中。
178
+ // 正文字号 13.5px:Windows 100% 缩放下 13px 偏小(尤其中文),
179
+ // 13.5 是「不显大、但明显好读」的折中。行高 1.75 保持呼吸感。
180
+ box: {
181
+ padding: '14px 14px 16px', fontSize: '13.5px', lineHeight: '1.75',
182
+ maxWidth: '680px', boxSizing: 'border-box',
183
+ },
177
184
  title: { fontWeight: 600, marginBottom: '8px' },
178
185
  dim: { opacity: 0.75 },
179
186
  gap: { opacity: 0.75, marginTop: '6px' },
180
187
  code: { fontFamily: 'ui-monospace,Menlo,Consolas,monospace', fontSize: '12px' },
181
- row: { display: 'flex', alignItems: 'center', gap: '8px', margin: '6px 0' },
182
- label: { minWidth: '86px' },
183
- hint: { opacity: 0.6, fontSize: '12px', marginLeft: '6px' },
188
+ // flexWrap:窄窗口下允许控件换到下一行,避免横向溢出。
189
+ row: { display: 'flex', alignItems: 'center', gap: '8px', margin: '6px 0', flexWrap: 'wrap' },
190
+ // 标签统一宽度:让所有行的控件左边缘对齐(视觉整齐的关键)。
191
+ label: { minWidth: '92px', flex: '0 0 auto', opacity: 0.95 },
192
+ hint: { opacity: 0.72, fontSize: '12.5px', marginLeft: '6px' },
184
193
  err: { color: '#c0392b', fontSize: '12px', marginTop: '6px' },
185
194
  ok: { color: '#1e8449', fontSize: '12px', marginTop: '6px' },
186
- foot: { opacity: 0.6, marginTop: '10px', fontSize: '12px' },
195
+ foot: { opacity: 0.65, marginTop: '12px', fontSize: '12.5px', lineHeight: '1.6' },
187
196
  // 事件开关:勾选框 + 可换行的说明文字
188
197
  toggleRow: { display: 'flex', alignItems: 'flex-start', gap: '8px', margin: '8px 0' },
189
198
  toggleBox: { marginTop: '3px', flex: '0 0 auto' },
190
199
  toggleText: { flex: '1', lineHeight: '1.5' },
191
200
  toggleLabel: { fontWeight: 500 },
192
- toggleDesc: { opacity: 0.62, fontSize: '12px' },
201
+ // 说明另起一行:原来用「 — 说明」挤在名称后面,
202
+ // 长中文说明换行后与下一个开关混在一起,难以扫读。
203
+ toggleDesc: { opacity: 0.72, fontSize: '12.5px', marginTop: '2px', lineHeight: '1.6' },
193
204
  // 音源说明(弱化、单独一行)
194
- subHint: { opacity: 0.62, fontSize: '12px', margin: '2px 0 8px' },
205
+ subHint: { opacity: 0.72, fontSize: '12.5px', margin: '3px 0 9px', lineHeight: '1.6' },
195
206
  btn: {
196
- padding: '4px 10px', fontSize: '12px', cursor: 'pointer',
197
- border: '1px solid rgba(128,128,128,.45)', borderRadius: '4px',
207
+ padding: '5px 12px', fontSize: '12.5px', cursor: 'pointer',
208
+ minHeight: '28px', lineHeight: '1.2',
209
+ border: '1px solid rgba(128,128,128,.45)', borderRadius: '5px',
198
210
  background: 'transparent', color: 'inherit',
211
+ transition: 'background .12s ease, border-color .12s ease',
199
212
  },
213
+ // 悬停/聚焦反馈:桌面端点击目标小的时候,反馈很重要(知道点到了)。
214
+ btnHover: { background: 'rgba(128,128,128,.14)', borderColor: 'rgba(128,128,128,.65)' },
200
215
  // 高级折叠区
201
216
  advHead: { margin: '14px 0 4px' },
202
217
  advBtn: {
@@ -204,9 +219,17 @@ window.__ModuleLoader__.load({
204
219
  border: 'none', background: 'transparent', color: 'inherit', fontWeight: 600,
205
220
  },
206
221
  // 台词输入框:与下拉同样的边框风格,占满剩余宽度
222
+ // 输入类控件统一高度 28px,与按钮对齐(视觉整齐 + 点击目标够大)。
207
223
  textInput: {
208
- flex: '1', minWidth: '120px', padding: '3px 8px', fontSize: '13px',
209
- border: '1px solid rgba(128,128,128,.45)', borderRadius: '4px',
224
+ flex: '1 1 160px', minWidth: '120px', padding: '4px 8px', fontSize: '13px',
225
+ minHeight: '28px', boxSizing: 'border-box',
226
+ border: '1px solid rgba(128,128,128,.45)', borderRadius: '5px',
227
+ background: 'transparent', color: 'inherit',
228
+ },
229
+ select: {
230
+ padding: '4px 8px', fontSize: '13px', minHeight: '28px',
231
+ boxSizing: 'border-box', maxWidth: '100%',
232
+ border: '1px solid rgba(128,128,128,.45)', borderRadius: '5px',
210
233
  background: 'transparent', color: 'inherit',
211
234
  },
212
235
  // 高级区里每行的「子设置」容器:左侧留出缩进,表明从属于上一行
@@ -215,7 +238,94 @@ window.__ModuleLoader__.load({
215
238
  borderLeft: '2px solid rgba(128,128,128,.28)',
216
239
  },
217
240
  subRow: { display: 'flex', alignItems: 'center', gap: '8px', margin: '4px 0' },
218
- subLabel: { minWidth: '64px', fontSize: '12px', opacity: 0.82 },
241
+ subLabel: { minWidth: '68px', fontSize: '12.5px', opacity: 0.88 },
242
+
243
+ // ── 分区(v0.4.53 第一阶段 UI)──────────────────────────────────
244
+ //
245
+ // 用「上边框 + 较大上间距」分隔,不用卡片/阴影 ——
246
+ // 与 DSH 原生设置页的克制风格一致,也避免视觉噪音。
247
+ sectionTitle: {
248
+ fontSize: '14.5px', fontWeight: 600, letterSpacing: '.01em',
249
+ margin: '24px 0 3px', paddingTop: '15px',
250
+ borderTop: '1px solid rgba(128,128,128,.22)',
251
+ },
252
+ sectionDesc: { fontSize: '12.5px', opacity: 0.72, margin: '3px 0 11px', lineHeight: '1.6' },
253
+
254
+ // ── 滑杆(v0.4.53)──────────────────────────────────────────────
255
+ //
256
+ // 原来用 `flex: 1`:flex-basis 默认 auto → 滑杆按内容宽度生长,
257
+ // 窄窗口下会把右侧数值挤出可视区,出现横向滚动。
258
+ // 改成显式 basis + minWidth + maxWidth,让它可伸缩但有上下限。
259
+ slider: { flex: '1 1 120px', minWidth: '100px', maxWidth: '100%', margin: 0 },
260
+
261
+ // 滑杆右侧的数值:固定宽度 + 右对齐 + 等宽数字。
262
+ // 固定宽度是为了滑动时数字位不跳;右对齐让它不贴窗口边缘。
263
+ value: {
264
+ minWidth: '48px', textAlign: 'right',
265
+ fontVariantNumeric: 'tabular-nums',
266
+ opacity: 0.78, fontSize: '12.5px',
267
+ },
268
+
269
+ // ── 试听按钮组(v0.4.55)────────────────────────────────────────
270
+ //
271
+ // 用户反馈:420px 下五个按钮挤成 4+1,最后一个「拦截」孤零零一行。
272
+ //
273
+ // 改法:整组**独占一行**(flex-basis 100%),用 grid 自动定列数。
274
+ // · 420px:内容宽约 364 → 排得下 5 列(每列约 63px)→ 一行五个
275
+ // · 更窄时自动降列 → 自然换行,行距列距一致(都是 6px)
276
+ // · maxWidth 340:720px 下不让按钮被拉成大块头
277
+ //
278
+ // 不缩小按钮:minmax 下限 56px 比原来的约 51px 还宽一点。
279
+ pvGroup: {
280
+ display: 'grid',
281
+ gridTemplateColumns: 'repeat(auto-fit, minmax(56px, 1fr))',
282
+ gap: '6px',
283
+ flex: '1 1 100%',
284
+ maxWidth: '340px',
285
+ marginTop: '2px',
286
+ },
287
+
288
+ // ── 中性提示框(v0.4.55)────────────────────────────────────────
289
+ //
290
+ // 用于「不是错误、但用户应当知道」的状态(如弹窗与声音都关掉)。
291
+ // 刻意**不用红/黄**:这不是故障,是用户自己的选择,只需告知结果。
292
+ noticeBox: {
293
+ margin: '8px 0 2px',
294
+ padding: '8px 10px',
295
+ borderLeft: '3px solid rgba(128,128,128,.5)',
296
+ background: 'rgba(128,128,128,.08)',
297
+ fontSize: '12.5px',
298
+ lineHeight: '1.6',
299
+ opacity: 0.92,
300
+ },
301
+
302
+ // 可点击的折叠标题(v0.4.55):整行可点,箭头固定在最右。
303
+ foldHead: {
304
+ display: 'flex',
305
+ alignItems: 'center',
306
+ justifyContent: 'space-between',
307
+ gap: '8px',
308
+ cursor: 'pointer',
309
+ userSelect: 'none',
310
+ margin: '16px 0 2px',
311
+ padding: '2px 4px',
312
+ marginLeft: '-4px',
313
+ borderRadius: '4px',
314
+ },
315
+ // 悬停/键盘焦点时的反馈(同一套,键盘用户也要看得见)。
316
+ foldHeadActive: { background: 'rgba(128,128,128,.14)' },
317
+ foldArrow: { flex: '0 0 auto', opacity: 0.6, fontSize: '11px' },
318
+
319
+ // 说明文字:弱化但保持可读对比度(不要低到看不清)。
320
+ fieldHint: { fontSize: '12.5px', opacity: 0.72, lineHeight: '1.6', flex: '1 1 100%', margin: '0 0 7px' },
321
+
322
+ // 分组标签(v0.4.53):像「语音包」「版本与更新」这种 ——
323
+ // 它下面跟着一整块内容,不是一个带控件的字段。
324
+ // 与 sectionTitle 的区别:**不带上边框**(它从属于当前分区,不是新分区)。
325
+ groupLabel: {
326
+ fontSize: '13px', fontWeight: 600, opacity: 0.9,
327
+ margin: '16px 0 5px',
328
+ },
219
329
  }
220
330
 
221
331
  /** 音源 id → 显示名("跟随默认(xxx)"里要用)。 */
@@ -356,30 +466,84 @@ window.__ModuleLoader__.load({
356
466
  'shikoku-metang', 'ameharu-hau', 'namiine-ritsu', 'shirakami-kotaro',
357
467
  ]
358
468
 
469
+ /**
470
+ * 音色友好显示名(v0.4.55)。
471
+ *
472
+ * 用户反馈:下拉里显示 `zundamon-normal` 是**开发者内部标识**,
473
+ * 普通用户看不懂。改为「角色名 · 风格名」;原 key 只在 title 悬停提示
474
+ * 与诊断日志里出现。
475
+ *
476
+ * ⚠️ 只影响**显示**:保存提交的仍是原 key(voiceName / kindVoiceName),
477
+ * 配置格式、默认值、后端一字未动。
478
+ *
479
+ * ⚠️ 名字与 `tools/build-voice-packs.ps1` 的 $Approved.Name 同源,
480
+ * 由测试 cross-check 防漂移(见 test/voice-label.test.mjs)。
481
+ */
482
+ var VOICE_LABELS = {
483
+ 'zundamon-normal': 'ずんだもん · ノーマル',
484
+ 'zundamon-amama': 'ずんだもん · あまあま',
485
+ 'ankomon': 'あんこもん',
486
+ 'goki-nuigurumi': '後鬼 · ぬいぐるみ',
487
+ 'kyushu-sora': '九州そら',
488
+ 'manbetsu-hanamaru-genki': '満別花丸 · 元気',
489
+ 'meimei-meria': '冥鳴ひまり',
490
+ 'shikoku-metang': '四国めたん',
491
+ 'ameharu-hau': '雨晴はう',
492
+ 'namiine-ritsu': '波音リツ',
493
+ 'shirakami-kotaro': '白上虎太郎',
494
+ }
495
+
496
+ /**
497
+ * key → 友好名。未知 key **原样返回**(不猜、不美化)——
498
+ * 用户手填的自定义 key 显示成它自己,比显示一个错的别名安全。
499
+ */
500
+ function voiceLabel(key) {
501
+ if (!key) return ''
502
+ return VOICE_LABELS[key] || key
503
+ }
504
+
505
+ /**
506
+ * 语音包元数据里的官方名(`ずんだもん(ノーマル)`)
507
+ * → 统一成「角色名 · 风格名」,与 VOICE_LABELS 风格一致。
508
+ *
509
+ * 否则「语音包列表」用括号、「音色下拉」用点,同一件事两种叫法。
510
+ */
511
+ function prettyPackName(name) {
512
+ var s = String(name || '')
513
+ var m = /^(.+?)((.+?))$/.exec(s)
514
+ return m ? (m[1] + ' · ' + m[2]) : s
515
+ }
516
+
359
517
  /** 事件开关定义(键 → 中文名)。 */
360
518
  // 事件开关:[键, 名称, 一句话说明]。
361
519
  //
362
520
  // 说明文案是给**不了解内部术语**的人看的:
363
521
  // - "空闲门" 是内部叫法,必须在界面上解释清楚它在防什么;
364
522
  // - 其余各项也用"什么时候会响"的口语描述,而不是复述配置键的含义。
523
+ // 事件开关:[键, 名称, 一句话说明, 分组, (可选)更长的解释]。
524
+ //
525
+ // 分组(v0.4.54 第二阶段 UI)—— 八个开关平铺太长,按「什么时候会用到」分三组:
526
+ // result 任务结果:跑完了 / 出错了 / 被拦了 / 你停了
527
+ // action 需要你操作:AI 在等你
528
+ // collab 子任务与协作:跟子代理、空闲门有关
529
+ //
530
+ // 第 3 项保持「一至两行」;更长的解释放第 5 项,渲染时作为 title 提示,
531
+ // 悬停可见、不占版面(信息不丢,版面不乱)。
365
532
  var TOGGLES = [
366
- ['notifyOnComplete', '回合完成',
367
- '这一轮正常跑完时提醒你'],
368
- ['notifyOnError', '回合出错',
369
- '这一轮以错误结束时提醒(会播日语道歉语音)'],
370
- ['notifyOnBlocked', '被拦截',
371
- '这一轮还没开始就被拦下时提醒(例如钩子拒绝了指令)'],
372
- ['notifyOnAborted', '已中止',
373
- '你手动按停止、或超出长度限制时提醒'],
374
- ['notifyOnApproval', '待批准',
375
- 'AI 要做敏感操作、等你点同意时提醒'],
376
- ['notifyOnQuestion', '要提问',
377
- 'AI 有问题要问你、停下来等你回答时提醒'],
378
- ['subagents', '子代理也提醒',
379
- '连子代理自己的每一轮结束也提醒;默认只提醒你直接对话的那个会话'],
380
- ['idleGate', '等真正干完再提醒',
381
- '别被半路打断的"完成"骗了:AI 派出去的子任务还在跑时先不响,'
382
- + '等全部结束再响一次。关掉的话,一结束就响(即使还有子任务在跑)'],
533
+ ['notifyOnComplete', '回合完成', '这一轮正常跑完时提醒你', 'result'],
534
+ ['notifyOnError', '回合出错', '这一轮以错误结束时提醒(会播道歉语音)', 'result'],
535
+ ['notifyOnBlocked', '被拦截', '还没开始就被拦下时提醒', 'result',
536
+ '这一轮还没开始就被 AI 拦下时提醒(例如钩子拒绝了指令)。'],
537
+ ['notifyOnAborted', '已中止', '你按了停止、或超出长度限制时提醒', 'result'],
538
+
539
+ ['notifyOnApproval', '待批准', 'AI 要做敏感操作、等你点同意时提醒', 'action'],
540
+ ['notifyOnQuestion', '要提问', 'AI 有问题问你、等你回答时提醒', 'action'],
541
+
542
+ ['subagents', '子代理也提醒', '子代理每轮结束也提醒', 'collab',
543
+ '连子代理自己的每一轮结束也提醒;默认只提醒你直接对话的那个会话。'],
544
+ ['idleGate', '等真正干完再提醒', '子代理还在跑时先不响,全部结束再响一次', 'collab',
545
+ '别被半路打断的「完成」骗了:AI 派出去的子任务还在跑时先不响,' +
546
+ '等全部结束再响一次。关掉的话,一结束就响(即使还有子任务在跑)。'],
383
547
  ]
384
548
 
385
549
  /**
@@ -409,6 +573,31 @@ window.__ModuleLoader__.load({
409
573
  var vset = vst[1]
410
574
  var vs = vst[0]
411
575
 
576
+ // 高级设置内部四个二级分组的展开状态(v0.4.54)。
577
+ //
578
+ // 同样用**独立 state**:主 state 有 25 处 set() 逐字段重建,
579
+ // 往里加字段要改 25 处(已因此踩过 4 次坑)。
580
+ // 分组展开状态与配置无关,放独立 state 也不会因保存配置被重置。
581
+ // 试听状态(v0.4.54):记住「正在试听哪个事件」,让按钮有忙碌反馈。
582
+ //
583
+ // 为什么需要:原来点试听后按钮没有任何变化,要等下面的提示行才
584
+ // 知道有没有执行;而试听是"要听才知道"的操作,没有即时反馈会让人
585
+ // 反复点(还会撞上 3 秒限流)。独立 state,与主 state 无关。
586
+ var pvst = React.useState({ kind: "" })
587
+ var pvset = pvst[1]
588
+ var pvState = pvst[0]
589
+
590
+ var avst = React.useState({})
591
+ var avset = avst[1]
592
+ var advSec = avst[0]
593
+
594
+ // 高级子组标题的悬停/焦点高亮(v0.4.55)。
595
+ // 独立 state:它纯粹是瞬时交互反馈,与配置无关,
596
+ // 也不该因为保存配置(25 处 set 重建主 state)被重置。
597
+ var hvst = React.useState({ id: "" })
598
+ var hvset = hvst[1]
599
+ var advHover = hvst[0].id || ""
600
+
412
601
  var st = React.useState({
413
602
  loaded: false,
414
603
  values: {
@@ -434,15 +623,15 @@ window.__ModuleLoader__.load({
434
623
 
435
624
  React.useEffect(function () {
436
625
  var alive = true
437
- // 版本信息:挂载时查一次(宿主侧有 24 小时缓存,很便宜)。
438
- //
439
- // ⚠️ 必须走 Promise:直接 set 会触发重渲染 → effect 重跑 → 死循环
440
- // (v0.4.33 语音包那块就是这么把 4GB 堆打满的)。
441
- // 异步 .then 里 set 是安全的 —— 那时 effect 已经跑完了。
442
- fetchVersion(false).then(function (r) {
443
- if (!alive || !r) return
444
- vset(function (p) { return { info: r, loading: false, copied: p.copied } })
445
- })
626
+ // 版本信息:挂载时查一次(宿主侧有 24 小时缓存,很便宜)。
627
+ //
628
+ // ⚠️ 必须走 Promise:直接 set 会触发重渲染 → effect 重跑 → 死循环
629
+ // (v0.4.33 语音包那块就是这么把 4GB 堆打满的)。
630
+ // 异步 .then 里 set 是安全的 —— 那时 effect 已经跑完了。
631
+ fetchVersion(false).then(function (r) {
632
+ if (!alive || !r) return
633
+ vset(function (p) { return { info: r, loading: false, copied: p.copied } })
634
+ })
446
635
  loadConfig().then(function (v) {
447
636
  if (!alive) return
448
637
  set(function (p) {
@@ -796,11 +985,72 @@ window.__ModuleLoader__.load({
796
985
  var v = s.values || {}
797
986
  var rows = []
798
987
 
799
- rows.push(h('div', { key: 't', style: S.title }, '桌面通知'))
988
+ // ── 分区标题(v0.4.53 第一阶段 UI)──────────────────────────────
989
+ //
990
+ // 五个分区:通知方式 / 声音设置 / 提醒规则 / 通知行为 / 高级设置。
991
+ // 用「上边框 + 较大上间距」分隔,不用卡片或阴影 ——
992
+ // 与 DSH 原生设置页的克制风格一致,也避免视觉噪音。
993
+ // 条件插入:按当前音源决定某个区块显不显示。
994
+ //
995
+ // 为什么用 `pushIf(cond, node)` 而不是把整块包进 if { }:
996
+ // · 闭合括号完全不用改(`rows.push(h(...))` 与 `pushIf(c, h(...))` 同形)
997
+ // · 只改每块的开头一行,改动面最小
998
+ //
999
+ // ⚠️ 只影响**可见性**,不碰任何值:切换音源不会删除/重置别的音源的配置。
1000
+ function pushIf(cond, node) { if (cond) rows.push(node) }
1001
+
1002
+ function section(key, title, desc) {
1003
+ return h('div', { key: 'sec-' + key },
1004
+ h('div', { style: S.sectionTitle }, title),
1005
+ desc ? h('div', { style: S.sectionDesc }, desc) : null,
1006
+ )
1007
+ }
1008
+
1009
+ // 旧的整卡标题已由「通知方式」分区标题取代(v0.4.53)。
800
1010
  // 注意:**不因"未加载完"就提前 return**。初值已按默认配置填好,
801
1011
  // 控件立即可用;真实值到达后自动覆盖(见 useEffect 的 merge)。
802
1012
  // 早期版本在这里 return 了一行"读取配置中…",导致首屏只有文字没有控件。
803
1013
 
1014
+
1015
+ // ── 一、通知方式 ────────────────────────────────────────────────
1016
+ rows.push(section('notify', '通知方式', '怎么提醒你:弹窗与声音分别开关,声音来源决定播什么。'))
1017
+
1018
+ // ── 弹窗开关(v0.4.4)────────────────────────────────────────────────
1019
+ // 有人只想听声音、嫌弹窗打断;也有人只要弹窗、不要声音。两者都要能关。
1020
+ rows.push(h('div', { key: 'toast', style: S.row },
1021
+ h('span', { style: S.label }, '显示通知弹窗'),
1022
+ h('input', {
1023
+ type: 'checkbox',
1024
+ checked: v.showToast !== false,
1025
+ disabled: s.busy,
1026
+ onChange: function (e) { apply({ showToast: !!e.target.checked }, '弹窗开关已更新') },
1027
+ }),
1028
+ h('span', { style: S.hint }, v.showToast === false ? '关闭后只有声音' : '关掉后只听声音、不弹窗'),
1029
+ ))
1030
+
1031
+ // 语音开关
1032
+ rows.push(h('div', { key: 'voice', style: S.row },
1033
+ h('span', { style: S.label }, '语音播报'),
1034
+ h('input', {
1035
+ type: 'checkbox',
1036
+ checked: v.voice !== false,
1037
+ disabled: s.busy,
1038
+ onChange: function (e) { apply({ voice: !!e.target.checked }, '语音开关已更新') },
1039
+ }),
1040
+ h('span', { style: S.hint }, '关掉只弹 Toast'),
1041
+ ))
1042
+
1043
+ // ⚠️ 两个都关掉 = 完全没有任何提醒(v0.4.55)。
1044
+ //
1045
+ // 用户指出这个歧义:不禁止这样设置(那是用户的选择),
1046
+ // 但必须**如实告知结果**,否则用户以为"我配过了怎么没反应"。
1047
+ // 用中性样式,不用红/黄 —— 这不是故障。
1048
+ if (v.showToast === false && v.voice === false) {
1049
+ rows.push(h('div', { key: 'all-silent', style: S.noticeBox },
1050
+ '弹窗与声音都已关闭 —— 插件不会产生任何可见或可听的提醒。'
1051
+ + '(提醒规则里的事件仍会照常触发,只是没有任何表现。)'))
1052
+ }
1053
+
804
1054
  // ── 音源选择(v0.4.4)─────────────────────────────────────────────────
805
1055
  //
806
1056
  // 这是本插件最核心的可配置项:用户想听系统语音、想听语音包、想要系统音效、
@@ -817,6 +1067,7 @@ window.__ModuleLoader__.load({
817
1067
  rows.push(h('div', { key: 'src', style: S.row },
818
1068
  h('span', { style: S.label }, '声音来源'),
819
1069
  h('select', {
1070
+ style: S.select,
820
1071
  value: curSource,
821
1072
  disabled: s.busy,
822
1073
  onChange: function (e) { apply({ audioSource: e.target.value }, '声音来源已更新') },
@@ -832,39 +1083,17 @@ window.__ModuleLoader__.load({
832
1083
  }
833
1084
  rows.push(h('div', { key: 'srchint', style: S.subHint }, SRC_HINT[curSource] || ''))
834
1085
 
835
- // ── 弹窗开关(v0.4.4)────────────────────────────────────────────────
836
- // 有人只想听声音、嫌弹窗打断;也有人只要弹窗、不要声音。两者都要能关。
837
- rows.push(h('div', { key: 'toast', style: S.row },
838
- h('span', { style: S.label }, '显示通知弹窗'),
839
- h('input', {
840
- type: 'checkbox',
841
- checked: v.showToast !== false,
842
- disabled: s.busy,
843
- onChange: function (e) { apply({ showToast: !!e.target.checked }, '弹窗开关已更新') },
844
- }),
845
- h('span', { style: S.hint }, v.showToast === false ? '关闭后只有声音' : '关掉后只听声音、不弹窗'),
846
- ))
847
-
848
- // 语音开关
849
- rows.push(h('div', { key: 'voice', style: S.row },
850
- h('span', { style: S.label }, '语音播报'),
851
- h('input', {
852
- type: 'checkbox',
853
- checked: v.voice !== false,
854
- disabled: s.busy,
855
- onChange: function (e) { apply({ voice: !!e.target.checked }, '语音开关已更新') },
856
- }),
857
- h('span', { style: S.hint }, '关掉只弹 Toast'),
858
- ))
859
-
860
1086
  // 音量
861
- rows.push(h('div', { key: 'vol', style: S.row },
1087
+ // ── 二、声音设置 ────────────────────────────────────────────────
1088
+ rows.push(section('sound', '声音设置', '音量、音色与试听;语音包可按需下载。'))
1089
+
1090
+ pushIf(curSource !== 'none', h('div', { key: 'vol', style: S.row },
862
1091
  h('span', { style: S.label }, '音量'),
863
1092
  h('input', {
864
1093
  type: 'range', min: 0, max: 100, step: 1,
865
1094
  value: typeof v.volume === 'number' ? v.volume : 80,
866
1095
  disabled: s.busy,
867
- style: { flex: '1' },
1096
+ style: S.slider,
868
1097
  // onChange 必须有:React 对「有 value 无 onChange」的受控表单元素会
869
1098
  // 降级成只读并告警(本机是 React 19,受控组件语义更严格)。
870
1099
  // 拖拽过程只改本地显示,不写盘 —— 避免每像素一次请求。
@@ -886,7 +1115,7 @@ window.__ModuleLoader__.load({
886
1115
  onBlur: function (e) { apply({ volume: Number(e.currentTarget.value) }, '音量已更新') },
887
1116
  onKeyUp: function (e) { apply({ volume: Number(e.currentTarget.value) }, '音量已更新') },
888
1117
  }),
889
- h('span', { style: S.hint }, String(typeof v.volume === 'number' ? v.volume : 80)),
1118
+ h('span', { style: S.value }, String(typeof v.volume === 'number' ? v.volume : 80)),
890
1119
  ))
891
1120
 
892
1121
  // ── 语音包增益(v0.4.16)─────────────────────────────────────────────
@@ -897,13 +1126,13 @@ window.__ModuleLoader__.load({
897
1126
  // 这个滑杆做**数字增益**:把样本乘一个系数,绕开 1.0 倍上限。
898
1127
  //
899
1128
  // ps1 侧会按峰值自动限幅(不超过 95%),所以不用担心调大破音。
900
- rows.push(h('div', { key: 'vogain', style: S.row },
1129
+ pushIf(curSource === 'bundled', h('div', { key: 'vogain', style: S.row },
901
1130
  h('span', { style: S.label }, '语音包增益'),
902
1131
  h('input', {
903
1132
  type: 'range', min: 1.0, max: 4.0, step: 0.1,
904
1133
  value: typeof v.voiceGain === 'number' ? v.voiceGain : 2.0,
905
1134
  disabled: s.busy,
906
- style: { flex: '1' },
1135
+ style: S.slider,
907
1136
  onChange: function (e) {
908
1137
  var n = Number(e.target.value)
909
1138
  set(function (p) {
@@ -920,37 +1149,21 @@ window.__ModuleLoader__.load({
920
1149
  onBlur: function (e) { apply({ voiceGain: Number(e.currentTarget.value) }, '增益已更新') },
921
1150
  onKeyUp: function (e) { apply({ voiceGain: Number(e.currentTarget.value) }, '增益已更新') },
922
1151
  }),
923
- h('span', { style: S.hint }, 'x' + (typeof v.voiceGain === 'number' ? v.voiceGain.toFixed(1) : '2.0')),
924
- // 试听:只听声音、不弹窗。调增益/换音色这类"要听才知道"的设置,
925
- // 没有试听就只能盲调(用户 2026-10-18 指出)。
926
- h('button', {
927
- type: 'button',
928
- key: 'pv-gain',
929
- disabled: s.busy,
930
- style: S.btn,
931
- onClick: function () {
932
- previewSound('done').then(function (r) {
933
- set(function (p) {
934
- return {
935
- loaded: p.loaded, values: p.values, packs: p.packs, advOpen: p.advOpen,
936
- termsAck: p.termsAck, termsPending: p.termsPending,
937
- busy: false,
938
- msg: r && r.ok ? ('试听中(' + (r.source || 'bundled') + ')') : '试听失败',
939
- err: p.err,
940
- }
941
- })
942
- })
943
- },
944
- }, '试听'),
1152
+ h('span', { style: S.value }, 'x' + (typeof v.voiceGain === 'number' ? v.voiceGain.toFixed(1) : '2.0')),
1153
+ // ⚠️ 2026-10-18 第二阶段 UI 去掉这里的「试听」按钮:
1154
+ // 它调的是 `previewSound('done')`,与下方「试听」行的「完成」按钮
1155
+ // **完全同功能**;而用户在这里调的是增益/音色,容易以为试听的是
1156
+ // 当前事件(比如正在调失败音)。重复入口 + 语义误导,所以只保留
1157
+ // 下方那一处(它能分别试听五种事件)。
945
1158
  ))
946
- rows.push(h('div', { key: 'vogainhint', style: S.subHint },
1159
+ pushIf(curSource === 'bundled', h('div', { key: 'vogainhint', style: S.subHint },
947
1160
  '语音包素材本身录得偏轻,这里补上。自动限幅,调大不会破音。'))
948
1161
 
949
1162
  // ── 自定义音频标准化(v0.4.16)──────────────────────────────────────
950
1163
  //
951
1164
  // 默认关:那是用户自己的素材,可能特意做得轻或响,不该擅自改。
952
1165
  // 打开后才按统一响度处理(只对 16-bit PCM wav 生效)。
953
- rows.push(h('div', { key: 'normcust', style: S.row },
1166
+ pushIf(curSource === 'custom', h('div', { key: 'normcust', style: S.row },
954
1167
  h('span', { style: S.label }, '自定义音频标准化'),
955
1168
  h('input', {
956
1169
  type: 'checkbox',
@@ -965,6 +1178,13 @@ window.__ModuleLoader__.load({
965
1178
  : '关:尊重你原文件的响度'),
966
1179
  ))
967
1180
 
1181
+ // 不出声时,音频相关的滑块/试听都隐藏了 —— 但要让用户知道为什么。
1182
+ if (curSource === 'none') {
1183
+ rows.push(h('div', { key: 'none-hint', style: S.subHint },
1184
+ '当前是「不出声,只显示通知」:音频相关设置已隐藏。'
1185
+ + '通知弹窗与下方「提醒规则」照常生效。'))
1186
+ }
1187
+
968
1188
  // ── 试听(v0.4.17)──────────────────────────────────────────────────
969
1189
  //
970
1190
  // 为什么单独一行放五种事件:用户常给不同事件配不同音源
@@ -972,43 +1192,67 @@ window.__ModuleLoader__.load({
972
1192
  //
973
1193
  // 试听**只出声、不弹窗**(ps1 的 -Preview)——
974
1194
  // 它的用途是"听效果",不是验证通知通路(那是「测试通知」的事)。
975
- rows.push(h('div', { key: 'pv-row', style: S.row },
1195
+ pushIf(curSource !== 'none', h('div', { key: 'pv-row', style: S.row },
976
1196
  h('span', { style: S.label }, '试听'),
977
1197
  (function () {
978
1198
  var KINDS = [
979
1199
  ['done', '完成'], ['fail', '失败'], ['question', '提问'],
980
1200
  ['approval', '批准'], ['blocked', '拦截'],
981
1201
  ]
982
- return h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap', flex: '1' } },
1202
+ return h('div', { style: S.pvGroup },
983
1203
  KINDS.map(function (pair) {
1204
+ var isBusy = pvState.kind === pair[0]
984
1205
  return h('button', {
985
1206
  type: 'button',
986
1207
  key: 'pv-' + pair[0],
987
- disabled: s.busy,
988
- style: S.btn,
1208
+ // 正在试听某个事件时,全部按钮先禁用 —— 避免连点撞上 3 秒限流
1209
+ disabled: s.busy || (pvState.kind !== ""),
1210
+ style: Object.assign({}, S.btn, isBusy ? {
1211
+ borderColor: 'rgba(128,128,128,.8)',
1212
+ background: 'rgba(128,128,128,.16)',
1213
+ } : null),
989
1214
  onClick: function () {
1215
+ // 立刻给反馈:按钮变「…」并禁用,用户知道点到了
1216
+ pvset({ kind: pair[0] })
990
1217
  // 先写盘再试听:让试听用的就是刚才调的值
991
1218
  previewSound(pair[0]).then(function (r) {
1219
+ pvset({ kind: "" })
1220
+ var ok = Boolean(r && r.ok)
992
1221
  set(function (p) {
993
1222
  return {
994
1223
  loaded: p.loaded, values: p.values, packs: p.packs, advOpen: p.advOpen,
995
1224
  termsAck: p.termsAck, termsPending: p.termsPending,
996
1225
  busy: false,
997
- msg: r && r.ok
998
- ? ('试听「' + pair[1] + '」(' + (r.source || 'bundled') + ')')
999
- : '试听失败',
1000
- err: p.err,
1226
+ // 成功写 msg、失败写 err —— 两者用不同颜色,不用猜
1227
+ msg: ok
1228
+ ? ('已试听「' + pair[1] + '」' + (r.source ? '(' + r.source + ')' : ''))
1229
+ : '',
1230
+ err: ok
1231
+ ? ''
1232
+ : ('试听「' + pair[1] + '」没有成功。可能是限流(3 秒一次)或播放失败,'
1233
+ + '稍等一下再点,或查看诊断日志。'),
1234
+ }
1235
+ })
1236
+ }).catch(function () {
1237
+ pvset({ kind: "" })
1238
+ set(function (p) {
1239
+ return {
1240
+ loaded: p.loaded, values: p.values, packs: p.packs, advOpen: p.advOpen,
1241
+ termsAck: p.termsAck, termsPending: p.termsPending,
1242
+ busy: false, msg: "", err: "试听请求失败(网络或路由不可用)。",
1001
1243
  }
1002
1244
  })
1003
1245
  })
1004
1246
  },
1005
- }, pair[1])
1247
+ }, isBusy ? '…' : pair[1])
1006
1248
  }),
1007
1249
  )
1008
1250
  })(),
1009
1251
  ))
1010
- rows.push(h('div', { key: 'pv-hint', style: S.subHint },
1252
+ pushIf(curSource !== 'none', h('div', { key: 'pv-hint', style: S.subHint },
1011
1253
  '试听只出声、不弹窗。调完增益或换了音色,点一下就能听效果。'))
1254
+ pushIf(curSource !== 'none', h('div', { key: 'pv-rate', style: S.subHint },
1255
+ '每 3 秒最多一次;点完按钮会短暂变「…」,那是在播放。'))
1012
1256
 
1013
1257
  // ── 语音包管理(v0.4.32)──────────────────────────────────────
1014
1258
  //
@@ -1019,7 +1263,7 @@ window.__ModuleLoader__.load({
1019
1263
  // 变成死循环(实测把 4GB 堆打满)。改为用户点按钮触发。
1020
1264
  if (curSource === 'bundled') {
1021
1265
  var vp = s.packs || { loading: false, list: null, error: null }
1022
- rows.push(h('div', { key: 'vp-title', style: S.label }, '语音包'))
1266
+ rows.push(h('div', { key: 'vp-title', style: S.groupLabel }, '语音包'))
1023
1267
 
1024
1268
  if (vp.loading) {
1025
1269
  rows.push(h('div', { key: 'vp-loading', style: S.subHint }, '正在获取可下载音色…'))
@@ -1027,7 +1271,7 @@ window.__ModuleLoader__.load({
1027
1271
  rows.push(h('div', { key: 'vp-idle', style: S.subHint },
1028
1272
  vp.error
1029
1273
  ? '未能获取可下载列表:' + vp.error + '。已装的音色仍可正常使用。'
1030
- : '核心包只带 1 个音色。点右边按钮看看还有哪些可以下载。'))
1274
+ : '核心包只带 1 个音色。点下面的「加载列表」看看还有哪些可以下载。'))
1031
1275
  rows.push(h('div', { key: 'vp-load-row', style: S.row },
1032
1276
  h('button', {
1033
1277
  type: 'button',
@@ -1047,7 +1291,8 @@ window.__ModuleLoader__.load({
1047
1291
  // 条款要**下载前**可见 —— 不能只靠 zip 里的 TERMS.md(那要下载后才看得到)。
1048
1292
  return h('div', { key: 'vp-item-' + p.key, style: { display: 'flex', flexDirection: 'column', gap: '2px', padding: '3px 0' } },
1049
1293
  h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
1050
- h('span', { style: { flex: '1' } }, (p.name || p.key) + (mb ? '(' + mb + ')' : '')),
1294
+ h('span', { style: { flex: '1' }, title: p.key },
1295
+ (prettyPackName(p.name) || p.key) + (mb ? '(' + mb + ')' : '')),
1051
1296
  p.installed ? h('span', { style: { color: '#2a7' } }, '已安装') : null,
1052
1297
  h('button', {
1053
1298
  type: 'button',
@@ -1093,6 +1338,7 @@ window.__ModuleLoader__.load({
1093
1338
  rows.push(h('div', { key: 'mode', style: S.row },
1094
1339
  h('span', { style: S.label }, '音色模式'),
1095
1340
  h('select', {
1341
+ style: S.select,
1096
1342
  value: v.voiceMode || 'fixed',
1097
1343
  disabled: s.busy,
1098
1344
  onChange: function (e) { apply({ voiceMode: e.target.value }, '音色模式已更新') },
@@ -1109,10 +1355,14 @@ window.__ModuleLoader__.load({
1109
1355
  rows.push(h('div', { key: 'name', style: S.row },
1110
1356
  h('span', { style: S.label }, '固定音色'),
1111
1357
  h('select', {
1358
+ style: S.select,
1112
1359
  value: v.voiceName || 'zundamon-normal',
1113
1360
  disabled: s.busy || !fixedMode,
1114
1361
  onChange: function (e) { apply({ voiceName: e.target.value }, '音色已更新') },
1115
- }, keyOpts.map(function (k) { return h('option', { key: k, value: k }, k) })),
1362
+ }, keyOpts.map(function (k) {
1363
+ // value 仍是原 key(保存用它),只改显示文本;原 key 放 title
1364
+ return h('option', { key: k, value: k, title: k }, voiceLabel(k))
1365
+ })),
1116
1366
  fixedMode ? null : h('span', { style: S.hint }, '随机模式下不生效'),
1117
1367
  ))
1118
1368
  }
@@ -1124,6 +1374,7 @@ window.__ModuleLoader__.load({
1124
1374
  rows.push(h('div', { key: 'ttsv', style: S.row },
1125
1375
  h('span', { style: S.label }, '朗读声音'),
1126
1376
  h('select', {
1377
+ style: S.select,
1127
1378
  value: v.ttsVoice || '',
1128
1379
  disabled: s.busy,
1129
1380
  onChange: function (e) { apply({ ttsVoice: e.target.value }, '朗读声音已更新') },
@@ -1135,7 +1386,7 @@ window.__ModuleLoader__.load({
1135
1386
  type: 'range', min: -10, max: 10, step: 1,
1136
1387
  value: typeof v.ttsRate === 'number' ? v.ttsRate : 0,
1137
1388
  disabled: s.busy,
1138
- style: { flex: '1' },
1389
+ style: S.slider,
1139
1390
  onChange: function (e) {
1140
1391
  var n = Number(e.target.value)
1141
1392
  set(function (p) {
@@ -1152,7 +1403,7 @@ window.__ModuleLoader__.load({
1152
1403
  onBlur: function (e) { apply({ ttsRate: Number(e.currentTarget.value) }, '语速已更新') },
1153
1404
  onKeyUp: function (e) { apply({ ttsRate: Number(e.currentTarget.value) }, '语速已更新') },
1154
1405
  }),
1155
- h('span', { style: S.hint }, (function () {
1406
+ h('span', { style: S.value }, (function () {
1156
1407
  var n = typeof v.ttsRate === 'number' ? v.ttsRate : 0
1157
1408
  return n === 0 ? '正常' : (n < 0 ? '慢 ' + Math.abs(n) : '快 ' + n)
1158
1409
  })()),
@@ -1290,35 +1541,55 @@ window.__ModuleLoader__.load({
1290
1541
  notifyOnAborted: false, notifyOnApproval: true, notifyOnQuestion: true,
1291
1542
  subagents: false, idleGate: true,
1292
1543
  }
1293
- rows.push(h('div', { key: 'sep', style: { margin: '10px 0 4px', fontWeight: 600 } }, '提醒哪些事件'))
1294
- TOGGLES.forEach(function (pair) {
1295
- var key = pair[0]
1296
- var label = pair[1]
1297
- var desc = pair[2] || ''
1298
- var cur = (typeof v[key] === 'boolean') ? v[key] : DEFAULTS[key]
1299
- // 一行 = [勾选框] [名称] [说明]。说明用弱化色、允许换行,
1300
- // 这样"等真正干完再提醒"这种较长的解释也能完整读到。
1301
- rows.push(h('div', { key: 'tg-' + key, style: S.toggleRow },
1302
- h('input', {
1303
- type: 'checkbox',
1304
- checked: cur === true,
1305
- disabled: s.busy,
1306
- style: S.toggleBox,
1307
- onChange: (function (k) {
1308
- return function (e) {
1309
- var o = {}
1310
- o[k] = !!e.target.checked
1311
- apply(o, '已更新')
1312
- }
1313
- })(key),
1314
- }),
1315
- h('div', { style: S.toggleText },
1316
- h('span', { style: S.toggleLabel }, label),
1317
- desc ? h('span', { style: S.toggleDesc }, ' — ' + desc) : null,
1318
- ),
1319
- ))
1544
+ // ── 三、提醒规则 ────────────────────────────────────────────────
1545
+ rows.push(section('rules', '提醒规则', '哪些事件要提醒你。默认只提醒需要你关注的那些。'))
1546
+ // 按「什么时候会用到」分三组渲染(v0.4.54)。
1547
+ // 每组一个小标题;组内仍是一行一个独立开关(开关语义与默认值完全不变)。
1548
+ var TOGGLE_GROUPS = [
1549
+ ['result', '任务结果'],
1550
+ ['action', '需要你操作'],
1551
+ ['collab', '子任务与协作'],
1552
+ ]
1553
+ TOGGLE_GROUPS.forEach(function (g) {
1554
+ var groupId = g[0]
1555
+ var groupName = g[1]
1556
+ var items = TOGGLES.filter(function (p) { return p[3] === groupId })
1557
+ if (items.length === 0) return
1558
+ rows.push(h('div', { key: 'tg-head-' + groupId, style: S.groupLabel }, groupName))
1559
+
1560
+ items.forEach(function (pair) {
1561
+ var key = pair[0]
1562
+ var label = pair[1]
1563
+ var desc = pair[2] || ''
1564
+ var detail = pair[4] || ''
1565
+ var cur = (typeof v[key] === 'boolean') ? v[key] : DEFAULTS[key]
1566
+ // 名称独占一行(扫读先看到项目名),说明在下方弱化色。
1567
+ // 更长的解释放 title(悬停可见)—— 版面保持一至两行,信息不丢。
1568
+ rows.push(h('div', { key: 'tg-' + key, style: S.toggleRow, title: detail || desc },
1569
+ h('input', {
1570
+ type: 'checkbox',
1571
+ checked: cur === true,
1572
+ disabled: s.busy,
1573
+ style: S.toggleBox,
1574
+ onChange: (function (k) {
1575
+ return function (e) {
1576
+ var o = {}
1577
+ o[k] = !!e.target.checked
1578
+ apply(o, '已更新')
1579
+ }
1580
+ })(key),
1581
+ }),
1582
+ h('div', { style: S.toggleText },
1583
+ h('div', { style: S.toggleLabel }, label),
1584
+ desc ? h('div', { style: S.toggleDesc }, desc) : null,
1585
+ ),
1586
+ ))
1587
+ })
1320
1588
  })
1321
1589
 
1590
+ // ── 四、通知行为 ────────────────────────────────────────────────
1591
+ rows.push(section('behavior', '通知行为', '什么时候跳回会话,以及多久算“重复提醒”。'))
1592
+
1322
1593
  // ── 会话跳转(v0.4.6)──────────────────────────────────────────────
1323
1594
  //
1324
1595
  // 为什么这里是**两个**开关而不是一个:
@@ -1408,6 +1679,7 @@ window.__ModuleLoader__.load({
1408
1679
  opts = opts.concat([h('option', { key: 'custom', value: String(cur) }, cur + ' 秒')])
1409
1680
  }
1410
1681
  return h('select', {
1682
+ style: S.select,
1411
1683
  value: String(cur),
1412
1684
  disabled: s.busy,
1413
1685
  onChange: (function (k) {
@@ -1442,453 +1714,539 @@ window.__ModuleLoader__.load({
1442
1714
  // 只把**偏离默认的项**存进配置(kindSourceFail='system'),
1443
1715
  // 所以以后改全局默认,没单独改过的事件会跟着变 —— 符合直觉。
1444
1716
  var advOpen = st[0].advOpen === true
1445
- rows.push(h('div', { key: 'adv', style: S.advHead },
1446
- h('button', {
1447
- type: 'button',
1448
- style: S.advBtn,
1449
- onClick: function () {
1450
- set(function (p) {
1451
- return {
1452
- loaded: p.loaded, values: p.values, packs: p.packs, busy: p.busy, msg: p.msg, err: p.err,
1453
- advOpen: p.advOpen !== true,
1454
- termsAck: p.termsAck, termsPending: p.termsPending,
1455
- }
1456
- })
1457
- },
1458
- }, (advOpen ? '▾ ' : '▸ ') + '高级:给每种提醒单独设置'),
1717
+
1718
+ // ── 五、高级设置(默认折叠)─────────────────────────────────────
1719
+ //
1720
+ // 折叠是刻意的:大多数人永远不需要「给某一种提醒单独换音源」。
1721
+ // 标题可点击展开/收起(保持原有交互,只是换成分区样式)。
1722
+ function toggleAdv() {
1723
+ set(function (p) {
1724
+ return {
1725
+ loaded: p.loaded, values: p.values, packs: p.packs, busy: p.busy, msg: p.msg, err: p.err,
1726
+ advOpen: p.advOpen !== true,
1727
+ termsAck: p.termsAck, termsPending: p.termsPending,
1728
+ }
1729
+ })
1730
+ }
1731
+ rows.push(h('div', { key: 'sec-adv' },
1732
+ h('div', {
1733
+ style: Object.assign({}, S.sectionTitle, {
1734
+ cursor: 'pointer',
1735
+ userSelect: 'none',
1736
+ borderTop: '1px solid rgba(128,128,128,.22)',
1737
+ }),
1738
+ onClick: toggleAdv,
1739
+ }, '高级设置' + (advOpen ? ' ▾' : ' ▸')),
1740
+ h('div', { style: S.sectionDesc },
1741
+ '给每种提醒单独设声音来源、日志与诊断、版本更新、开发者选项。点标题展开。'),
1459
1742
  ))
1460
1743
 
1461
1744
  if (advOpen) {
1462
- var KIND_ROWS = [['Done', '任务完成'], ['Fail', '任务失败'],
1463
- ['Question', '向你提问'], ['Approval', '待你批准'], ['Blocked', '被拦截']]
1464
- var FOLLOW = '__follow__'
1465
- rows.push(h('div', { key: 'advhint', style: S.subHint },
1466
- '默认跟随上面的「声音来源」。只有你改过的那几行才会单独生效。'))
1467
- KIND_ROWS.forEach(function (pair) {
1468
- var suffix = pair[0]
1469
- var label = pair[1]
1470
- // ⚠️ 读与写**用的字段名不同**,这是 v0.4.10 修的一个真 bug:
1471
- //
1472
- // 写入:扁平键 kindSourceFail = 'system'(config-store 白名单只认标量)
1473
- // 读回:backend 的 normalizeConfig 会把它聚合成
1474
- // audioSourceByKind = { fail: 'system' }
1475
- // —— 生效值里**没有** kindSourceFail 这个键
1476
- //
1477
- // 原先前端读写都用扁平键,于是 GET 回来的 v 里取不到值,
1478
- // 每行永远显示「跟随默认」,用户以为设置没生效(实际存进去了)。
1479
- var writeKey = 'kindSource' + suffix
1480
- var kindLower = suffix.toLowerCase()
1481
- var byKind = (v.audioSourceByKind && typeof v.audioSourceByKind === 'object')
1482
- ? v.audioSourceByKind : {}
1483
- var cur = byKind[kindLower] || FOLLOW
1484
- rows.push(h('div', { key: 'ks-' + suffix, style: S.row },
1485
- h('span', { style: S.label }, label),
1486
- h('select', {
1487
- value: cur,
1488
- disabled: s.busy,
1489
- onChange: (function (k) {
1490
- return function (e) {
1491
- var patch = {}
1492
- // 选「跟随默认」就把键清空,让配置回到最小状态
1493
- patch[k] = e.target.value === FOLLOW ? '' : e.target.value
1494
- apply(patch, '已更新')
1495
- }
1496
- })(writeKey),
1497
- }, [
1498
- h('option', { key: FOLLOW, value: FOLLOW }, '跟随默认(' + (SRC_NAME[curSource] || curSource) + ')'),
1499
- h('option', { key: 'tts', value: 'tts' }, '系统语音合成'),
1500
- h('option', { key: 'bundled', value: 'bundled' }, '语音包'),
1501
- h('option', { key: 'system', value: 'system' }, '系统提示音'),
1502
- h('option', { key: 'custom', value: 'custom' }, '我的音频文件'),
1503
- h('option', { key: 'none', value: 'none' }, '不出声'),
1504
- ]),
1505
- cur === FOLLOW ? null : h('span', { style: S.hint }, '(已单独设置)'),
1506
- ))
1745
+ // ── 高级设置内部的二级分组(v0.4.54)──────────────────────────
1746
+ //
1747
+ // 用户要求:不能「点一次高级设置就把所有内容全展开」。
1748
+ // 但也别做太深 —— 只加一层:高级设置 → 四个子组 → 内容。
1749
+ //
1750
+ // 展开状态用**独立 state**(avst),不塞进主 state:
1751
+ // 主 state 有 25 处 set() 逐字段重建,加字段要改 25 处(已踩过 4 次)。
1752
+ var ADV_SECTIONS = [
1753
+ ['sound', '按事件定制声音', '给每种提醒单独指定音源;不改就跟随上面的「声音来源」。'],
1754
+ ['diag', '诊断与日志', '日志详细度,以及导出脱敏后的诊断文件。'],
1755
+ ['update', '版本与更新', '查看运行中的版本、检查有没有新版。'],
1756
+ ['dev', '开发者选项', '实验性功能,普通使用不需要开。'],
1757
+ ]
1758
+ function toggleAdvSec(id) {
1759
+ avset(function (prev) {
1760
+ var n = {}
1761
+ for (var kk in prev) n[kk] = prev[kk]
1762
+ n[id] = prev[id] !== true
1763
+ return n
1764
+ })
1765
+ }
1507
1766
 
1508
- // ── 该事件的**子设置**(v0.4.9)──────────────────────────────────
1767
+ ADV_SECTIONS.forEach(function (sec) {
1768
+ var id = sec[0]
1769
+ var isOpen = advSec[id] === true
1770
+ // 整行可点 + 箭头固定最右 + 悬停/焦点反馈 + 键盘可操作(v0.4.55)。
1509
1771
  //
1510
- // 用户 2026-10-17 指出:只选了音源类型,但"念什么话、什么声音、
1511
- // 什么音效"还得回上面用全局的,不合逻辑。所以每行在**覆盖了音源**时
1512
- // 展开该音源自己的参数;「跟随默认」时不展开(避免界面爆炸)。
1513
- //
1514
- // 参数矩阵:
1515
- // tts → 发音人 + 语速 + 台词
1516
- // bundled → 音色模式 + 音色名
1517
- // system → 系统音效名
1518
- // custom → 选择文件
1519
- var effSrc = (cur === FOLLOW) ? curSource : cur
1520
- if (cur === FOLLOW) return // 跟随默认 → 不展开
1521
-
1522
- var sub = []
1523
- var kKey = function (base) { return base + suffix }
1524
-
1525
- /**
1526
- * 读按事件覆盖的值。
1527
- *
1528
- * ⚠️ 写入用**扁平键**(kindTtsVoiceDone),读回用**字典**
1529
- * (values.kindTtsVoice.done)—— 因为 backend 的 normalizeConfig
1530
- * 会把扁平键聚合成字典,生效值里没有扁平键。
1531
- * 第一版读写都用扁平键,导致界面永远显示"跟随默认"(实际已存)。
1532
- *
1533
- * @param {string} dictName 字典名(如 'kindTtsVoice')
1534
- * @param {*} dflt 取不到时的默认值
1535
- */
1536
- var readKind = function (dictName, dflt) {
1537
- var m = v[dictName]
1538
- if (m && typeof m === 'object') {
1539
- var got = m[kindLower]
1540
- if (got !== undefined && got !== null && got !== '') return got
1541
- }
1542
- return dflt
1543
- }
1544
-
1545
- if (effSrc === 'tts') {
1546
- // 发音人
1547
- var vNameKey = kKey('kindTtsVoice')
1548
- var vNameCur = readKind('kindTtsVoice', '')
1549
- sub.push(h('div', { key: 'ttsv-' + suffix, style: S.subRow },
1550
- h('span', { style: S.subLabel }, '朗读声音'),
1772
+ // 用户反馈:原来箭头跟在文字后面(`高级设置 ▸`),不明显能点。
1773
+ // 现在整行是点击区、箭头靠右、Enter/Space 也能展开 ——
1774
+ // 键盘用户与鼠标用户看到同一套反馈(foldHeadActive)。
1775
+ var headActive = advHover === id
1776
+ rows.push(h('div', {
1777
+ key: 'adv-sec-' + id,
1778
+ role: 'button',
1779
+ tabIndex: 0,
1780
+ 'aria-expanded': isOpen ? 'true' : 'false',
1781
+ style: Object.assign({}, S.foldHead, headActive ? S.foldHeadActive : null),
1782
+ onClick: function () { toggleAdvSec(id) },
1783
+ onMouseEnter: function () { hvset({ id: id }) },
1784
+ onMouseLeave: function () { hvset({ id: advHover === id ? '' : advHover }) },
1785
+ onFocus: function () { hvset({ id: id }) },
1786
+ onBlur: function () { hvset({ id: advHover === id ? '' : advHover }) },
1787
+ onKeyDown: function (e) {
1788
+ if (e.key === 'Enter' || e.key === ' ') {
1789
+ e.preventDefault()
1790
+ toggleAdvSec(id)
1791
+ }
1792
+ },
1793
+ },
1794
+ h('span', { style: { fontWeight: 600, fontSize: '13px' } }, sec[1]),
1795
+ h('span', { style: S.foldArrow }, isOpen ? '▾' : '▸'),
1796
+ ))
1797
+ rows.push(h('div', { key: 'adv-sec-desc-' + id, style: S.subHint }, sec[2]))
1798
+
1799
+ if (isOpen) {
1800
+ if (id === 'sound') {
1801
+ // ⚠️ 这两行原本在 if (advOpen) 块的开头(我重排时漏掉了,C19 抓到):
1802
+ // 事件行与「跟随默认」哨兵值都依赖它们。
1803
+ var KIND_ROWS = [['Done', '任务完成'], ['Fail', '任务失败'],
1804
+ ['Question', '向你提问'], ['Approval', '待你批准'], ['Blocked', '被拦截']]
1805
+ var FOLLOW = '__follow__'
1806
+ rows.push(h('div', { key: 'adv-group', style: S.groupLabel }, '按事件覆盖声音来源'))
1807
+ rows.push(h('div', { key: 'advhint', style: S.subHint },
1808
+ '默认跟随上面的「声音来源」。只有你改过的那几行才会单独生效。'))
1809
+ KIND_ROWS.forEach(function (pair) {
1810
+ var suffix = pair[0]
1811
+ var label = pair[1]
1812
+ // ⚠️ 读与写**用的字段名不同**,这是 v0.4.10 修的一个真 bug:
1813
+ //
1814
+ // 写入:扁平键 kindSourceFail = 'system'(config-store 白名单只认标量)
1815
+ // 读回:backend 的 normalizeConfig 会把它聚合成
1816
+ // audioSourceByKind = { fail: 'system' }
1817
+ // —— 生效值里**没有** kindSourceFail 这个键
1818
+ //
1819
+ // 原先前端读写都用扁平键,于是 GET 回来的 v 里取不到值,
1820
+ // 每行永远显示「跟随默认」,用户以为设置没生效(实际存进去了)。
1821
+ var writeKey = 'kindSource' + suffix
1822
+ var kindLower = suffix.toLowerCase()
1823
+ var byKind = (v.audioSourceByKind && typeof v.audioSourceByKind === 'object')
1824
+ ? v.audioSourceByKind : {}
1825
+ var cur = byKind[kindLower] || FOLLOW
1826
+ rows.push(h('div', { key: 'ks-' + suffix, style: S.row },
1827
+ h('span', { style: S.label }, label),
1551
1828
  h('select', {
1552
- value: vNameCur,
1829
+ style: S.select,
1830
+ value: cur,
1553
1831
  disabled: s.busy,
1554
1832
  onChange: (function (k) {
1555
1833
  return function (e) {
1556
- var o = {}; o[k] = e.target.value; apply(o, '已更新')
1834
+ var patch = {}
1835
+ // 选「跟随默认」就把键清空,让配置回到最小状态
1836
+ patch[k] = e.target.value === FOLLOW ? '' : e.target.value
1837
+ apply(patch, '已更新')
1557
1838
  }
1558
- })(vNameKey),
1839
+ })(writeKey),
1559
1840
  }, [
1560
- h('option', { key: '', value: '' }, '跟随全局'),
1561
- h('option', { key: 'hui', value: 'Microsoft Huihui Desktop' }, 'Huihui(中文女声)'),
1562
- h('option', { key: 'zira', value: 'Microsoft Zira Desktop' }, 'Zira(英文女声)'),
1841
+ h('option', { key: FOLLOW, value: FOLLOW }, '跟随默认(' + (SRC_NAME[curSource] || curSource) + ')'),
1842
+ h('option', { key: 'tts', value: 'tts' }, '系统语音合成'),
1843
+ h('option', { key: 'bundled', value: 'bundled' }, '语音包'),
1844
+ h('option', { key: 'system', value: 'system' }, '系统提示音'),
1845
+ h('option', { key: 'custom', value: 'custom' }, '我的音频文件'),
1846
+ h('option', { key: 'none', value: 'none' }, '不出声'),
1563
1847
  ]),
1848
+ cur === FOLLOW ? null : h('span', { style: S.hint }, '(已单独设置)'),
1564
1849
  ))
1565
- // 语速
1566
- var vRateKey = kKey('kindTtsRate')
1567
- var vRateCur = Number(readKind('kindTtsRate', 0)) || 0
1568
- sub.push(h('div', { key: 'ttsr-' + suffix, style: S.subRow },
1569
- h('span', { style: S.subLabel }, '语速'),
1570
- h('input', {
1571
- type: 'range', min: -10, max: 10, step: 1,
1572
- value: vRateCur,
1573
- disabled: s.busy,
1574
- style: { flex: '1' },
1575
- onChange: (function (k) {
1576
- return function (e) {
1577
- var n = Number(e.target.value)
1578
- set(function (p) {
1579
- var nv = {}; for (var kk in p.values) nv[kk] = p.values[kk]
1580
- nv[k] = n
1581
- return {
1582
- loaded: p.loaded, values: nv, advOpen: p.advOpen, packs: p.packs,
1583
- termsAck: p.termsAck, termsPending: p.termsPending,
1584
- busy: p.busy, msg: '', err: p.err,
1585
- }
1586
- })
1587
- }
1588
- })(vRateKey),
1589
- onPointerUp: (function (k) {
1590
- return function (e) {
1591
- var o = {}; o[k] = Number(e.currentTarget.value); apply(o, '已更新')
1592
- }
1593
- })(vRateKey),
1594
- onBlur: (function (k) {
1595
- return function (e) {
1596
- var o = {}; o[k] = Number(e.currentTarget.value); apply(o, '已更新')
1597
- }
1598
- })(vRateKey),
1599
- }),
1600
- h('span', { style: S.hint }, vRateCur === 0 ? '跟随全局' : (vRateCur < 0 ? '慢' : '快')),
1601
- ))
1602
- // 台词(这个字段本来就按事件,直接复用)
1603
- var txtKey = 'ttsText' + suffix
1604
- var txtCur = String(readKind('ttsTexts', '') || '')
1605
- sub.push(h('div', { key: 'ttst-' + suffix, style: S.subRow },
1606
- h('span', { style: S.subLabel }, '台词'),
1607
- h('input', {
1608
- type: 'text',
1609
- maxLength: 60,
1610
- placeholder: '留空 = 内置随机',
1611
- value: txtCur,
1612
- disabled: s.busy,
1613
- style: S.textInput,
1614
- onChange: (function (k) {
1615
- return function (e) {
1616
- var val = e.target.value
1617
- set(function (p) {
1618
- var nv = {}; for (var kk in p.values) nv[kk] = p.values[kk]
1619
- nv[k] = val
1620
- return {
1621
- loaded: p.loaded, values: nv, advOpen: p.advOpen, packs: p.packs,
1622
- termsAck: p.termsAck, termsPending: p.termsPending,
1623
- busy: p.busy, msg: '', err: p.err,
1624
- }
1625
- })
1626
- }
1627
- })(txtKey),
1628
- onBlur: (function (k) {
1629
- return function (e) {
1630
- var o = {}; o[k] = e.target.value; apply(o, '已更新')
1631
- }
1632
- })(txtKey),
1633
- onKeyDown: function (e) { if (e.key === 'Enter') { try { e.target.blur() } catch (x) { /* ignore */ } } },
1634
- }),
1635
- ))
1636
- } else if (effSrc === 'bundled') {
1637
- // 音色模式
1638
- var mKey = kKey('kindVoiceMode')
1639
- var mCur = readKind('kindVoiceMode', '')
1640
- sub.push(h('div', { key: 'km-' + suffix, style: S.subRow },
1641
- h('span', { style: S.subLabel }, '音色模式'),
1642
- h('select', {
1643
- value: mCur,
1644
- disabled: s.busy,
1645
- onChange: (function (k) {
1646
- return function (e) { var o = {}; o[k] = e.target.value; apply(o, '已更新') }
1647
- })(mKey),
1648
- }, [
1649
- h('option', { key: '', value: '' }, '跟随全局'),
1650
- h('option', { key: 'fixed', value: 'fixed' }, '固定音色'),
1651
- h('option', { key: 'random', value: 'random' }, '每次随机'),
1652
- ]),
1653
- ))
1654
- // 音色名
1655
- var nKey = kKey('kindVoiceName')
1656
- var nCur = readKind('kindVoiceName', '')
1657
- var kOpts2 = VOICE_KEYS.slice()
1658
- if (nCur && kOpts2.indexOf(nCur) < 0) kOpts2.unshift(nCur)
1659
- sub.push(h('div', { key: 'kn-' + suffix, style: S.subRow },
1660
- h('span', { style: S.subLabel }, '音色'),
1661
- h('select', {
1662
- value: nCur,
1663
- disabled: s.busy || mCur === 'random',
1664
- onChange: (function (k) {
1665
- return function (e) { var o = {}; o[k] = e.target.value; apply(o, '已更新') }
1666
- })(nKey),
1667
- }, [h('option', { key: '', value: '' }, '跟随全局')].concat(
1668
- kOpts2.map(function (x) { return h('option', { key: x, value: x }, x) }),
1669
- )),
1670
- mCur === 'random' ? h('span', { style: S.hint }, '随机模式下不生效') : null,
1671
- ))
1672
- } else if (effSrc === 'system') {
1673
- // 系统音效名
1674
- var sKey = kKey('kindSystemSound')
1675
- var sCur = readKind('kindSystemSound', '')
1676
- sub.push(h('div', { key: 'kss-' + suffix, style: S.subRow },
1677
- h('span', { style: S.subLabel }, '音效'),
1678
- h('select', {
1679
- value: sCur,
1680
- disabled: s.busy,
1681
- onChange: (function (k) {
1682
- return function (e) { var o = {}; o[k] = e.target.value; apply(o, '已更新') }
1683
- })(sKey),
1684
- }, [
1685
- h('option', { key: '', value: '' }, '按事件自动选'),
1686
- h('option', { key: 'Asterisk', value: 'Asterisk' }, '提示音(轻快)'),
1687
- h('option', { key: 'Question', value: 'Question' }, '询问音'),
1688
- h('option', { key: 'Exclamation', value: 'Exclamation' }, '感叹音'),
1689
- h('option', { key: 'Hand', value: 'Hand' }, '警告音(低沉)'),
1690
- h('option', { key: 'Beep', value: 'Beep' }, '哔声'),
1691
- ]),
1692
- ))
1693
- } else if (effSrc === 'custom') {
1694
- // 选择文件(复用已有的 customFile* 键)
1695
- var cKey = 'customFile' + suffix
1696
- var cCur = readKind('customFiles', '')
1697
- sub.push(h('div', { key: 'kc-' + suffix, style: S.subRow },
1698
- h('span', { style: S.subLabel }, '音频'),
1699
- h('button', {
1700
- type: 'button',
1701
- disabled: s.busy,
1702
- style: S.btn,
1703
- onClick: (function (k) {
1704
- return function () {
1705
- pickCustomFile().then(function (p) {
1706
- if (p) { var o = {}; o[k] = p; apply(o, '已选择音频') }
1707
- }).catch(function () { /* 内部已兜底 */ })
1708
- }
1709
- })(cKey),
1710
- }, cCur ? '更换…' : '选择文件…'),
1711
- h('span', { style: S.hint }, cCur || '(未选择,将用系统提示音)'),
1712
- ))
1713
- }
1714
1850
 
1715
- if (sub.length) {
1716
- rows.push(h('div', { key: 'sub-' + suffix, style: S.subBox }, sub))
1717
- }
1718
- })
1719
-
1720
-
1721
- // ── 开发选项:热重载 ────────────────────────────────────────────────
1722
- //
1723
- // 让用户自己选,而不是我替他决定。默认关(生产安装不需要)。
1724
- // 开启后:改插件源码保存 → 自动换成新代码,不用重启 DSH。
1725
- rows.push(h('div', { key: 'sepHr', style: { margin: '10px 0 4px', borderTop: '1px solid rgba(128,128,128,.25)' } }))
1726
- rows.push(h('div', { key: 'dr', style: S.toggleRow },
1727
- h('input', {
1728
- type: 'checkbox',
1729
- checked: v.devReload === true,
1730
- disabled: s.busy,
1731
- style: S.toggleBox,
1732
- onChange: function (e) { apply({ devReload: !!e.target.checked }, '热重载设置已更新') },
1733
- }),
1734
- h('div', { style: S.toggleText },
1735
- h('span', { style: S.toggleLabel }, '开发:改代码后自动热重载'),
1736
- h('span', { style: S.toggleDesc },
1737
- ' — 保存插件源码后自动生效,不必重启 DSH。默认关:'
1738
- + '它依赖宿主内部结构,升级后可能失效;失效时只报错,不影响正常播报。'),
1739
- ),
1740
- ))
1851
+ // ── 该事件的**子设置**(v0.4.9)──────────────────────────────────
1852
+ //
1853
+ // 用户 2026-10-17 指出:只选了音源类型,但"念什么话、什么声音、
1854
+ // 什么音效"还得回上面用全局的,不合逻辑。所以每行在**覆盖了音源**时
1855
+ // 展开该音源自己的参数;「跟随默认」时不展开(避免界面爆炸)。
1856
+ //
1857
+ // 参数矩阵:
1858
+ // tts → 发音人 + 语速 + 台词
1859
+ // bundled → 音色模式 + 音色名
1860
+ // system → 系统音效名
1861
+ // custom → 选择文件
1862
+ var effSrc = (cur === FOLLOW) ? curSource : cur
1863
+ if (cur === FOLLOW) return // 跟随默认 → 不展开
1864
+
1865
+ var sub = []
1866
+ var kKey = function (base) { return base + suffix }
1867
+
1868
+ /**
1869
+ * 读按事件覆盖的值。
1870
+ *
1871
+ * ⚠️ 写入用**扁平键**(kindTtsVoiceDone),读回用**字典**
1872
+ * (values.kindTtsVoice.done)—— 因为 backend 的 normalizeConfig
1873
+ * 会把扁平键聚合成字典,生效值里没有扁平键。
1874
+ * 第一版读写都用扁平键,导致界面永远显示"跟随默认"(实际已存)。
1875
+ *
1876
+ * @param {string} dictName 字典名(如 'kindTtsVoice')
1877
+ * @param {*} dflt 取不到时的默认值
1878
+ */
1879
+ var readKind = function (dictName, dflt) {
1880
+ var m = v[dictName]
1881
+ if (m && typeof m === 'object') {
1882
+ var got = m[kindLower]
1883
+ if (got !== undefined && got !== null && got !== '') return got
1884
+ }
1885
+ return dflt
1886
+ }
1741
1887
 
1742
- // ── 日志级别(v0.4.34)──────────────────────────────────────────────
1743
- //
1744
- // 默认 info:只记元数据(事件、音源、成败、数量)。
1745
- // debug:额外记台词正文与 Toast 标题正文 —— **可能含敏感信息**。
1746
- //
1747
- // 为什么放在高级区:多数人不需要改,但排查问题时要能开。
1748
- rows.push(h('div', { key: 'll-row', style: S.row },
1749
- h('span', { style: S.label }, '日志详细度'),
1750
- h('select', {
1751
- value: v.logLevel || 'info',
1752
- disabled: s.busy,
1753
- onChange: function (e) { apply({ logLevel: e.target.value }, '日志级别已更新') },
1754
- },
1755
- h('option', { value: 'info' }, '只记元数据(默认)'),
1756
- h('option', { value: 'debug' }, '含台词正文(可能敏感)'),
1757
- ),
1758
- ))
1759
- rows.push(h('div', { key: 'll-hint', style: S.subHint },
1760
- v.logLevel === 'debug'
1761
- ? '⚠️ debug 档会把台词和通知正文写进日志。日志在 %TEMP%\\dsh-notify-say\\notify.log,'
1762
- + '提 Issue 前请先切回「只记元数据」或自行删改。'
1763
- : '日志只记事件类型、音源、成败与数量,不含台词正文。排查问题时可切到 debug。'))
1888
+ if (effSrc === 'tts') {
1889
+ // 发音人
1890
+ var vNameKey = kKey('kindTtsVoice')
1891
+ var vNameCur = readKind('kindTtsVoice', '')
1892
+ sub.push(h('div', { key: 'ttsv-' + suffix, style: S.subRow },
1893
+ h('span', { style: S.subLabel }, '朗读声音'),
1894
+ h('select', {
1895
+ style: S.select,
1896
+ value: vNameCur,
1897
+ disabled: s.busy,
1898
+ onChange: (function (k) {
1899
+ return function (e) {
1900
+ var o = {}; o[k] = e.target.value; apply(o, '已更新')
1901
+ }
1902
+ })(vNameKey),
1903
+ }, [
1904
+ h('option', { key: '', value: '' }, '跟随全局'),
1905
+ h('option', { key: 'hui', value: 'Microsoft Huihui Desktop' }, 'Huihui(中文女声)'),
1906
+ h('option', { key: 'zira', value: 'Microsoft Zira Desktop' }, 'Zira(英文女声)'),
1907
+ ]),
1908
+ ))
1909
+ // 语速
1910
+ var vRateKey = kKey('kindTtsRate')
1911
+ var vRateCur = Number(readKind('kindTtsRate', 0)) || 0
1912
+ sub.push(h('div', { key: 'ttsr-' + suffix, style: S.subRow },
1913
+ h('span', { style: S.subLabel }, '语速'),
1914
+ h('input', {
1915
+ type: 'range', min: -10, max: 10, step: 1,
1916
+ value: vRateCur,
1917
+ disabled: s.busy,
1918
+ style: S.slider,
1919
+ onChange: (function (k) {
1920
+ return function (e) {
1921
+ var n = Number(e.target.value)
1922
+ set(function (p) {
1923
+ var nv = {}; for (var kk in p.values) nv[kk] = p.values[kk]
1924
+ nv[k] = n
1925
+ return {
1926
+ loaded: p.loaded, values: nv, advOpen: p.advOpen, packs: p.packs,
1927
+ termsAck: p.termsAck, termsPending: p.termsPending,
1928
+ busy: p.busy, msg: '', err: p.err,
1929
+ }
1930
+ })
1931
+ }
1932
+ })(vRateKey),
1933
+ onPointerUp: (function (k) {
1934
+ return function (e) {
1935
+ var o = {}; o[k] = Number(e.currentTarget.value); apply(o, '已更新')
1936
+ }
1937
+ })(vRateKey),
1938
+ onBlur: (function (k) {
1939
+ return function (e) {
1940
+ var o = {}; o[k] = Number(e.currentTarget.value); apply(o, '已更新')
1941
+ }
1942
+ })(vRateKey),
1943
+ }),
1944
+ h('span', { style: S.value }, vRateCur === 0 ? '跟随全局' : (vRateCur < 0 ? '慢' : '快')),
1945
+ ))
1946
+ // 台词(这个字段本来就按事件,直接复用)
1947
+ var txtKey = 'ttsText' + suffix
1948
+ var txtCur = String(readKind('ttsTexts', '') || '')
1949
+ sub.push(h('div', { key: 'ttst-' + suffix, style: S.subRow },
1950
+ h('span', { style: S.subLabel }, '台词'),
1951
+ h('input', {
1952
+ type: 'text',
1953
+ maxLength: 60,
1954
+ placeholder: '留空 = 内置随机',
1955
+ value: txtCur,
1956
+ disabled: s.busy,
1957
+ style: S.textInput,
1958
+ onChange: (function (k) {
1959
+ return function (e) {
1960
+ var val = e.target.value
1961
+ set(function (p) {
1962
+ var nv = {}; for (var kk in p.values) nv[kk] = p.values[kk]
1963
+ nv[k] = val
1964
+ return {
1965
+ loaded: p.loaded, values: nv, advOpen: p.advOpen, packs: p.packs,
1966
+ termsAck: p.termsAck, termsPending: p.termsPending,
1967
+ busy: p.busy, msg: '', err: p.err,
1968
+ }
1969
+ })
1970
+ }
1971
+ })(txtKey),
1972
+ onBlur: (function (k) {
1973
+ return function (e) {
1974
+ var o = {}; o[k] = e.target.value; apply(o, '已更新')
1975
+ }
1976
+ })(txtKey),
1977
+ onKeyDown: function (e) { if (e.key === 'Enter') { try { e.target.blur() } catch (x) { /* ignore */ } } },
1978
+ }),
1979
+ ))
1980
+ } else if (effSrc === 'bundled') {
1981
+ // 音色模式
1982
+ var mKey = kKey('kindVoiceMode')
1983
+ var mCur = readKind('kindVoiceMode', '')
1984
+ sub.push(h('div', { key: 'km-' + suffix, style: S.subRow },
1985
+ h('span', { style: S.subLabel }, '音色模式'),
1986
+ h('select', {
1987
+ style: S.select,
1988
+ value: mCur,
1989
+ disabled: s.busy,
1990
+ onChange: (function (k) {
1991
+ return function (e) { var o = {}; o[k] = e.target.value; apply(o, '已更新') }
1992
+ })(mKey),
1993
+ }, [
1994
+ h('option', { key: '', value: '' }, '跟随全局'),
1995
+ h('option', { key: 'fixed', value: 'fixed' }, '固定音色'),
1996
+ h('option', { key: 'random', value: 'random' }, '每次随机'),
1997
+ ]),
1998
+ ))
1999
+ // 音色名
2000
+ var nKey = kKey('kindVoiceName')
2001
+ var nCur = readKind('kindVoiceName', '')
2002
+ var kOpts2 = VOICE_KEYS.slice()
2003
+ if (nCur && kOpts2.indexOf(nCur) < 0) kOpts2.unshift(nCur)
2004
+ sub.push(h('div', { key: 'kn-' + suffix, style: S.subRow },
2005
+ h('span', { style: S.subLabel }, '音色'),
2006
+ h('select', {
2007
+ style: S.select,
2008
+ value: nCur,
2009
+ disabled: s.busy || mCur === 'random',
2010
+ onChange: (function (k) {
2011
+ return function (e) { var o = {}; o[k] = e.target.value; apply(o, '已更新') }
2012
+ })(nKey),
2013
+ }, [h('option', { key: '', value: '' }, '跟随全局')].concat(
2014
+ kOpts2.map(function (x) {
2015
+ return h('option', { key: x, value: x, title: x }, voiceLabel(x))
2016
+ }),
2017
+ )),
2018
+ mCur === 'random' ? h('span', { style: S.hint }, '随机模式下不生效') : null,
2019
+ ))
2020
+ } else if (effSrc === 'system') {
2021
+ // 系统音效名
2022
+ var sKey = kKey('kindSystemSound')
2023
+ var sCur = readKind('kindSystemSound', '')
2024
+ sub.push(h('div', { key: 'kss-' + suffix, style: S.subRow },
2025
+ h('span', { style: S.subLabel }, '音效'),
2026
+ h('select', {
2027
+ style: S.select,
2028
+ value: sCur,
2029
+ disabled: s.busy,
2030
+ onChange: (function (k) {
2031
+ return function (e) { var o = {}; o[k] = e.target.value; apply(o, '已更新') }
2032
+ })(sKey),
2033
+ }, [
2034
+ h('option', { key: '', value: '' }, '按事件自动选'),
2035
+ h('option', { key: 'Asterisk', value: 'Asterisk' }, '提示音(轻快)'),
2036
+ h('option', { key: 'Question', value: 'Question' }, '询问音'),
2037
+ h('option', { key: 'Exclamation', value: 'Exclamation' }, '感叹音'),
2038
+ h('option', { key: 'Hand', value: 'Hand' }, '警告音(低沉)'),
2039
+ h('option', { key: 'Beep', value: 'Beep' }, '哔声'),
2040
+ ]),
2041
+ ))
2042
+ } else if (effSrc === 'custom') {
2043
+ // 选择文件(复用已有的 customFile* 键)
2044
+ var cKey = 'customFile' + suffix
2045
+ var cCur = readKind('customFiles', '')
2046
+ sub.push(h('div', { key: 'kc-' + suffix, style: S.subRow },
2047
+ h('span', { style: S.subLabel }, '音频'),
2048
+ h('button', {
2049
+ type: 'button',
2050
+ disabled: s.busy,
2051
+ style: S.btn,
2052
+ onClick: (function (k) {
2053
+ return function () {
2054
+ pickCustomFile().then(function (p) {
2055
+ if (p) { var o = {}; o[k] = p; apply(o, '已选择音频') }
2056
+ }).catch(function () { /* 内部已兜底 */ })
2057
+ }
2058
+ })(cKey),
2059
+ }, cCur ? '更换…' : '选择文件…'),
2060
+ h('span', { style: S.hint }, cCur || '(未选择,将用系统提示音)'),
2061
+ ))
2062
+ }
1764
2063
 
1765
- // ── 导出诊断(v0.4.35)───────────────────────────────────────────
1766
- //
1767
- // 用户提 Issue 时最需要的东西:一份**已脱敏**的日志。
1768
- // 让他自己去 %TEMP% 找、手动删用户名,多数人不会做。
1769
- rows.push(h('div', { key: 'dg-row', style: S.row },
1770
- h('span', { style: S.label }, '诊断'),
1771
- h('button', {
1772
- type: 'button',
1773
- disabled: s.busy,
1774
- style: S.btn,
1775
- onClick: function () { doExportDiagnostics() },
1776
- }, '导出诊断文件'),
1777
- ))
1778
- rows.push(h('div', { key: 'dg-hint', style: S.subHint },
1779
- '导出到日志同目录(%TEMP%\\dsh-notify-say\\)。会自动把用户名替换为 <user>、'
1780
- + '绝对路径替换为 <path>,但**仍请自己再看一遍**再上传。'))
2064
+ if (sub.length) {
2065
+ rows.push(h('div', { key: 'sub-' + suffix, style: S.subBox }, sub))
2066
+ }
2067
+ })
2068
+ }
2069
+ if (id === 'diag') {
2070
+ // ── 日志级别(v0.4.34)──────────────────────────────────────────────
2071
+ //
2072
+ // 默认 info:只记元数据(事件、音源、成败、数量)。
2073
+ // debug:额外记台词正文与 Toast 标题正文 —— **可能含敏感信息**。
2074
+ //
2075
+ // 为什么放在高级区:多数人不需要改,但排查问题时要能开。
2076
+ rows.push(h('div', { key: 'll-row', style: S.row },
2077
+ h('span', { style: S.label }, '日志详细度'),
2078
+ h('select', {
2079
+ style: S.select,
2080
+ value: v.logLevel || 'info',
2081
+ disabled: s.busy,
2082
+ onChange: function (e) { apply({ logLevel: e.target.value }, '日志级别已更新') },
2083
+ },
2084
+ h('option', { value: 'info' }, '只记元数据(默认)'),
2085
+ h('option', { value: 'debug' }, '含台词正文(可能敏感)'),
2086
+ ),
2087
+ ))
2088
+ rows.push(h('div', { key: 'll-hint', style: S.subHint },
2089
+ v.logLevel === 'debug'
2090
+ ? '⚠️ debug 档会把台词和通知正文写进日志。日志在 %TEMP%\\dsh-notify-say\\notify.log,'
2091
+ + '提 Issue 前请先切回「只记元数据」或自行删改。'
2092
+ : '日志只记事件类型、音源、成败与数量,不含台词正文。排查问题时可切到 debug。'))
1781
2093
 
1782
- // ── 版本与更新(v0.4.48)─────────────────────────────────────
1783
- //
1784
- // 只显示版本状态 + 给升级指引。**不自动下载、不覆盖文件**:
1785
- // 本机可能是 Junction 指向开发源码,自动覆盖会直接毁掉源码;
1786
- // 而且 DSH 官方 CLI 已经能升级插件。
1787
- rows.push(h('div', { key: 'up-title', style: S.label }, '版本与更新'))
1788
- var vi = vs.info
2094
+ // ── 导出诊断(v0.4.35)───────────────────────────────────────────
2095
+ //
2096
+ // 用户提 Issue 时最需要的东西:一份**已脱敏**的日志。
2097
+ // 让他自己去 %TEMP% 找、手动删用户名,多数人不会做。
2098
+ rows.push(h('div', { key: 'dg-row', style: S.row },
2099
+ h('span', { style: S.label }, '诊断'),
2100
+ h('button', {
2101
+ type: 'button',
2102
+ disabled: s.busy,
2103
+ style: S.btn,
2104
+ onClick: function () { doExportDiagnostics() },
2105
+ }, '导出诊断文件'),
2106
+ ))
2107
+ rows.push(h('div', { key: 'dg-hint', style: S.subHint },
2108
+ '导出到日志同目录(%TEMP%\\dsh-notify-say\\)。会自动把用户名替换为 <user>、'
2109
+ + '绝对路径替换为 <path>,但仍请自己再看一遍再上传。'))
2110
+ }
2111
+ if (id === 'update') {
2112
+ // ── 版本与更新(v0.4.48)─────────────────────────────────────
2113
+ //
2114
+ // 只显示版本状态 + 给升级指引。**不自动下载、不覆盖文件**:
2115
+ // 本机可能是 Junction 指向开发源码,自动覆盖会直接毁掉源码;
2116
+ // 而且 DSH 官方 CLI 已经能升级插件。
2117
+ // 内层不再重复「版本与更新」标题 —— 它已经是二级分组的标题了(v0.4.54)。
2118
+ var vi = vs.info
1789
2119
 
1790
- // 运行中版本 vs 磁盘安装版本(v0.4.51)
1791
- //
1792
- // 两者可能不同:用户在 DSH 运行期间升级了插件 ——
1793
- // 磁盘 package.json 已是新版,但进程里跑的还是旧 JS 模块。
1794
- // 只显示磁盘版本会让人以为"已生效",其实要重启。
1795
- var vRun = vi && vi.running ? vi.running : ''
1796
- var vIns = vi && vi.installed ? vi.installed : vRun
1797
- var vLat = vi && vi.latest ? vi.latest : null
1798
-
1799
- rows.push(h('div', { key: 'up-cur', style: S.row },
1800
- h('span', { style: S.label }, '运行中版本'),
1801
- h('span', null, vRun ? 'v' + vRun : '(未知)')))
1802
-
1803
- // 磁盘装了但没重启 → 明确告诉他该做什么
1804
- if (vi && vi.needsRestart) {
1805
- rows.push(h('div', { key: 'up-restart', style: {
1806
- marginTop: '4px', padding: '8px', borderRadius: '4px',
1807
- background: 'rgba(255,200,80,.12)',
1808
- border: '1px solid rgba(255,200,80,.4)', fontSize: '12.5px',
1809
- } },
1810
- '磁盘上已是 v' + vIns + ',但当前运行的还是 v' + vRun + '。',
1811
- h('div', { style: { marginTop: '4px' } },
1812
- '请重启 DSH 让新版本生效。')))
1813
- }
2120
+ // 运行中版本 vs 磁盘安装版本(v0.4.51)
2121
+ //
2122
+ // 两者可能不同:用户在 DSH 运行期间升级了插件 ——
2123
+ // 磁盘 package.json 已是新版,但进程里跑的还是旧 JS 模块。
2124
+ // 只显示磁盘版本会让人以为"已生效",其实要重启。
2125
+ var vRun = vi && vi.running ? vi.running : ''
2126
+ var vIns = vi && vi.installed ? vi.installed : vRun
2127
+ var vLat = vi && vi.latest ? vi.latest : null
2128
+
2129
+ rows.push(h('div', { key: 'up-cur', style: S.row },
2130
+ h('span', { style: S.label }, '运行中版本'),
2131
+ h('span', null, vRun ? 'v' + vRun : '(未知)')))
2132
+
2133
+ // 磁盘装了但没重启 → 明确告诉他该做什么
2134
+ if (vi && vi.needsRestart) {
2135
+ rows.push(h('div', { key: 'up-restart', style: {
2136
+ marginTop: '4px', padding: '8px', borderRadius: '4px',
2137
+ background: 'rgba(255,200,80,.12)',
2138
+ border: '1px solid rgba(255,200,80,.4)', fontSize: '12.5px',
2139
+ } },
2140
+ '磁盘上已是 v' + vIns + ',但当前运行的还是 v' + vRun + '。',
2141
+ h('div', { style: { marginTop: '4px' } },
2142
+ '请重启 DSH 让新版本生效。')))
2143
+ }
1814
2144
 
1815
- if (vs.loading) {
1816
- rows.push(h('div', { key: 'up-loading', style: S.subHint }, '正在检查…'))
1817
- } else if (!vi) {
1818
- rows.push(h('div', { key: 'up-idle', style: S.subHint },
1819
- '还没检查过。点下面的按钮查一下有没有新版本。'))
1820
- } else if (!vi.ok) {
1821
- rows.push(h('div', { key: 'up-err', style: S.subHint },
1822
- '暂时无法检查(' + (vi.error || '网络不可用') + ')。'
1823
- + '本地通知和语音不依赖它,照常工作。'))
1824
- } else {
1825
- // 两个来源各自报的版本(可能不同:npm 有暂存队列会落后)
1826
- var vNpm = vi.npmLatest || ''
1827
- var vGh = vi.githubLatest || ''
1828
- var differ = vNpm && vGh && vNpm !== vGh
1829
-
1830
- if (vi.hasUpdate === true) {
1831
- rows.push(h('div', { key: 'up-new', style: S.row },
1832
- h('span', { style: S.label }, '最新版本'),
1833
- h('span', { style: { color: '#e0a030', fontWeight: 600 } }, 'v' + vLat + '(有新版)')))
1834
- } else if (vi.hasUpdate === false) {
1835
- rows.push(h('div', { key: 'up-same', style: S.row },
1836
- h('span', { style: S.label }, '最新版本'),
1837
- h('span', { style: { color: '#2a7' } }, 'v' + vLat + '(已是最新)')))
2145
+ if (vs.loading) {
2146
+ rows.push(h('div', { key: 'up-loading', style: S.subHint }, '正在检查…'))
2147
+ } else if (!vi) {
2148
+ rows.push(h('div', { key: 'up-idle', style: S.subHint },
2149
+ '还没检查过。点下面的按钮查一下有没有新版本。'))
2150
+ } else if (!vi.ok) {
2151
+ rows.push(h('div', { key: 'up-err', style: S.subHint },
2152
+ '暂时无法检查(' + (vi.error || '网络不可用') + ')。'
2153
+ + '本地通知和语音不依赖它,照常工作。'))
1838
2154
  } else {
1839
- rows.push(h('div', { key: 'up-unknown', style: S.subHint },
1840
- '最新版本 v' + vLat + '(本地版本格式无法比较)'))
1841
- }
2155
+ // 两个来源各自报的版本(可能不同:npm 有暂存队列会落后)
2156
+ var vNpm = vi.npmLatest || ''
2157
+ var vGh = vi.githubLatest || ''
2158
+ var differ = vNpm && vGh && vNpm !== vGh
2159
+
2160
+ if (vi.hasUpdate === true) {
2161
+ rows.push(h('div', { key: 'up-new', style: S.row },
2162
+ h('span', { style: S.label }, '最新版本'),
2163
+ h('span', { style: { color: '#e0a030', fontWeight: 600 } }, 'v' + vLat + '(有新版)')))
2164
+ } else if (vi.hasUpdate === false) {
2165
+ rows.push(h('div', { key: 'up-same', style: S.row },
2166
+ h('span', { style: S.label }, '最新版本'),
2167
+ h('span', { style: { color: '#2a7' } }, 'v' + vLat + '(已是最新)')))
2168
+ } else {
2169
+ rows.push(h('div', { key: 'up-unknown', style: S.subHint },
2170
+ '最新版本 v' + vLat + '(本地版本格式无法比较)'))
2171
+ }
1842
2172
 
1843
- // ⚠️ 两个来源不一致时如实说明:
1844
- // 只报一个合并后的 latest,会出现"提示有新版,但 npm 安装的用户
1845
- // 执行升级命令装不到那个版本"。
1846
- if (differ) {
1847
- rows.push(h('div', { key: 'up-sources', style: S.subHint },
1848
- 'npm: v' + vNpm + ' | GitHub: v' + vGh
1849
- + ' (两个来源不同步时以较高的为准,但升级方式可能受限)'))
1850
- }
2173
+ // ⚠️ 两个来源不一致时如实说明:
2174
+ // 只报一个合并后的 latest,会出现"提示有新版,但 npm 安装的用户
2175
+ // 执行升级命令装不到那个版本"。
2176
+ if (differ) {
2177
+ rows.push(h('div', { key: 'up-sources', style: S.subHint },
2178
+ 'npm: v' + vNpm + ' | GitHub: v' + vGh
2179
+ + ' (两个来源不同步时以较高的为准,但升级方式可能受限)'))
2180
+ }
1851
2181
 
1852
- if (vi.hasUpdate === true) {
1853
- if (vi.command) {
1854
- rows.push(h('div', { key: 'up-cmd', style: {
1855
- marginTop: '4px', padding: '8px', borderRadius: '4px',
1856
- background: 'rgba(255,255,255,.06)',
1857
- fontFamily: 'monospace', fontSize: '12px', wordBreak: 'break-all',
1858
- } }, vi.command))
2182
+ if (vi.hasUpdate === true) {
2183
+ if (vi.command) {
2184
+ rows.push(h('div', { key: 'up-cmd', style: {
2185
+ marginTop: '4px', padding: '8px', borderRadius: '4px',
2186
+ background: 'rgba(255,255,255,.06)',
2187
+ fontFamily: 'monospace', fontSize: '12px', wordBreak: 'break-all',
2188
+ } }, vi.command))
2189
+ }
2190
+ rows.push(h('div', { key: 'up-note', style: S.subHint }, vi.note || ''))
2191
+ } else if (vi.note) {
2192
+ rows.push(h('div', { key: 'up-mode', style: S.subHint }, vi.note))
1859
2193
  }
1860
- rows.push(h('div', { key: 'up-note', style: S.subHint }, vi.note || ''))
1861
- } else if (vi.note) {
1862
- rows.push(h('div', { key: 'up-mode', style: S.subHint }, vi.note))
1863
2194
  }
1864
- }
1865
2195
 
1866
- rows.push(h('div', { key: 'up-btn', style: S.row },
1867
- h('button', {
1868
- type: 'button',
1869
- disabled: vs.loading,
1870
- style: S.btn,
1871
- onClick: function () { checkVersion(true) },
1872
- }, vs.loading ? '检查中…' : '检查更新'),
1873
- vi && vi.hasUpdate === true && vi.command
1874
- ? h('button', {
2196
+ rows.push(h('div', { key: 'up-btn', style: S.row },
2197
+ h('button', {
1875
2198
  type: 'button',
2199
+ disabled: vs.loading,
1876
2200
  style: S.btn,
1877
- onClick: function () { copyUpgradeCommand(vi.command) },
1878
- }, vs.copied ? '已复制' : '复制命令')
1879
- : null,
1880
- vi && vi.changelogUrl
1881
- ? h('a', {
1882
- href: vi.changelogUrl,
1883
- target: '_blank',
1884
- rel: 'noreferrer noopener',
1885
- style: { color: '#4a9', fontSize: '12px' },
1886
- }, '更新日志 ↗')
1887
- : null,
1888
- ))
1889
- rows.push(h('div', { key: 'up-offline', style: S.subHint },
1890
- '检查更新会访问 npm registry 与 GitHub(每 24 小时最多一次)。'
1891
- + '通知与语音不依赖它;网络不可用时照常工作。'))
2201
+ onClick: function () { checkVersion(true) },
2202
+ }, vs.loading ? '检查中…' : '检查更新'),
2203
+ vi && vi.hasUpdate === true && vi.command
2204
+ ? h('button', {
2205
+ type: 'button',
2206
+ style: S.btn,
2207
+ onClick: function () { copyUpgradeCommand(vi.command) },
2208
+ }, vs.copied ? '已复制' : '复制命令')
2209
+ : null,
2210
+ vi && vi.changelogUrl
2211
+ ? h('a', {
2212
+ href: vi.changelogUrl,
2213
+ target: '_blank',
2214
+ rel: 'noreferrer noopener',
2215
+ style: { color: '#4a9', fontSize: '12px' },
2216
+ }, '更新日志 ↗')
2217
+ : null,
2218
+ ))
2219
+ rows.push(h('div', { key: 'up-offline', style: S.subHint },
2220
+ '检查更新会访问 npm registry 与 GitHub(每 24 小时最多一次)。'
2221
+ + '通知与语音不依赖它;网络不可用时照常工作。'))
2222
+ }
2223
+ if (id === 'dev') {
2224
+ // 明确告知这是实验性功能(用户要求,v0.4.54)。
2225
+ rows.push(h('div', { key: 'dev-warn', style: S.subHint },
2226
+ '⚠️ 实验性:依赖宿主内部结构与 Node 模块加载机制,'
2227
+ + 'DSH 升级后可能失效。失效时只报错,不影响播报。'))
2228
+ // ── 开发选项:热重载 ────────────────────────────────────────────────
2229
+ //
2230
+ // 让用户自己选,而不是我替他决定。默认关(生产安装不需要)。
2231
+ // 开启后:改插件源码保存 → 自动换成新代码,不用重启 DSH。
2232
+ rows.push(h('div', { key: 'sepHr', style: { margin: '10px 0 4px', borderTop: '1px solid rgba(128,128,128,.25)' } }))
2233
+ rows.push(h('div', { key: 'dr', style: S.toggleRow },
2234
+ h('input', {
2235
+ type: 'checkbox',
2236
+ checked: v.devReload === true,
2237
+ disabled: s.busy,
2238
+ style: S.toggleBox,
2239
+ onChange: function (e) { apply({ devReload: !!e.target.checked }, '热重载设置已更新') },
2240
+ }),
2241
+ h('div', { style: S.toggleText },
2242
+ h('span', { style: S.toggleLabel }, '改代码后自动热重载(实验性)'),
2243
+ h('span', { style: S.toggleDesc },
2244
+ '保存插件源码后自动生效,不必重启 DSH。'),
2245
+ ),
2246
+ ))
2247
+ }
2248
+ }
2249
+ })
1892
2250
  }
1893
2251
 
1894
2252
  if (s.msg) rows.push(h('div', { key: 'msg', style: S.ok }, s.msg))