@cowwo/dsh-lookout 0.5.0 → 0.5.1

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.
Files changed (3) hide show
  1. package/README.md +10 -6
  2. package/client/client.js +414 -316
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  会话**跑完 / 出错 / 卡住 / 等你审批**时发一封邮件。正文写清是哪个会话:事件、会话标题、工作区、时间、用时、轮次、会话 ID。SMTP 配置与收件人在设置页填写。
20
20
 
21
- **改完要点保存**:邮件通知这一页是显式保存——改字段只是改草稿,点右上角「保存」才写进配置,点「放弃改动」退回上次保存的值。有没存的改动时状态行写着「有 N 项改动未保存」;保存成功后变成「已保存 · N 秒前」;保存失败会保留草稿并写明原因,可以直接重试;如果是别处先改过配置(revision 冲突),页面会自动重读最新一份、你的草稿原样留着,再点一次保存即可。
21
+ **改完要点保存**:邮件通知这一页是显式保存——改字段只是改草稿,点「保存」才写进配置,点「放弃改动」退回上次保存的值。保存组吸在页头,往下滚到哪儿都看得见;页面底部还压着一条吸底保存条,只有「保存 / 放弃改动」,两处都能点。有没存的改动时状态行写着「有 N 项改动未保存」;授权码粘贴了还没存时,它单独写「有未保存的授权码」,不会假装已经存好;保存成功后读数会随时间退化——刚刚、N 秒前、N 分钟前、N 小时前、N 天前;保存失败会保留草稿并写明原因,可以直接重试;如果是别处先改过配置(revision 冲突),页面会自动重读最新一份、你的草稿原样留着,再点一次保存即可。
22
22
 
23
23
  **保存只写配置与授权码**。「发送测试邮件」和「恢复全部默认」不归保存按钮管:前者随时能点、用当前**已经保存**的配置真发一封,按钮旁会写明这一点——草稿里没保存的改动不参与;后者点下去就清空本插件的全部覆盖并立即生效。
24
24
 
@@ -28,6 +28,7 @@
28
28
 
29
29
  发送行为(已经固化在代码里,不需要也不提供调参):
30
30
 
31
+ - **总开关压在所有开关上面**:设置页那个「邮件通知」总开关一关,一封都不发;8 个事件开关只是变灰,不会被藏起来,总开关重新打开就照原样继续用。
31
32
  - **能触发的事件共 8 类**:完成、出错、卡住、等审批、主动停止、系统中断、命中长度上限、子代理;每类一个开关,默认只开前四类(子代理默认关)。
32
33
  - **重复事件只发一封**:同一会话、同一回合、同一种结局只投递一次;重放、重订阅、刷新都不会再发。
33
34
  - **同一会话的新回合会顶掉上一封待发邮件**:上一封还在等发时这个会话又动了,就取消旧的那封、只留最新结论——一个会话在同一时刻只有一封待发的信。(回合结束后先等约 1.5 秒,确认没有新回合才发。)
@@ -65,11 +66,14 @@
65
66
  - 图标上传与 PWA / 角标开关
66
67
  - 未读规则、进行中规则的阈值
67
68
  - 邮件通知(页面从上到下):
68
- - **保存组**:右上角的状态行(「有 N 项改动未保存」/「已保存 · N 秒前」/「保存失败:…」)加上「保存」「放弃改动」,这一页唯一的写盘入口;改字段不会立刻生效
69
- - **判决条**:顶部那条唯一的填充色横条,一句话判定**已保存的这套配置**能不能发信以及不能的原因,旁边给补配置的入口和「发送测试邮件」(随时可点,用已保存的配置;有未保存改动时读数提示「未保存的改动不参与判定」)
70
- - **收件人**:收件人清单,格式不对就地标红
71
- - **怎么发**:SMTP 主机 / 端口 / 加密方式 / 账号 + 授权码(不是邮箱登录密码),顶部 6 家常见邮箱预设点一下就填好
72
- - **什么时候发给我**:8 个事件开关;「完成」下面标着固定门槛(不足 2 秒不打扰),默认关掉的 3 类收在「还有 3 种情况」折叠层里
69
+ - **吸顶保存组**:状态行(「有 N 项改动未保存」/「有未保存的授权码」/「已保存 · 刚刚 / N 秒前 / N 分钟前 / N 小时前 / N 天前」/「保存失败:…」)加上「保存」「放弃改动」,这一页唯一的写盘入口;改字段不会立刻生效。它吸在页头,滚动时始终可见
70
+ - **判决条**:页面顶部那条唯一的填充色横条,一句话判定**已保存的这套配置**能不能发信以及不能的原因,另有一行人话摘要(例:「QQ 邮箱 · smtp.qq.com → you@qq.com · 出错、等审批时通知」);总开关关着时写「已关闭,不会发任何通知」,8 个事件全关时写「通知已全部关闭」;旁边给补配置的入口和「发送测试邮件」(随时可点,用已保存的配置;有未保存改动时读数提示「未保存的改动不参与判定」)
71
+ - **总开关「邮件通知」**:关掉之后不再发任何邮件,8 个事件开关跟着变灰但不隐藏
72
+ - **收件人**:一个单行输入框,可以直接粘贴多个地址——逗号、分号、换行分隔都认;格式不对就地标红
73
+ - **发信方式**:SMTP 主机 / 端口 / 加密方式 / 账号 + 授权码(不是邮箱登录密码)都并排常显;6 家常见邮箱预设点一下就填好;每行说明文字直接显示
74
+ - **通知时机**:8 个事件开关一律常显,默认关掉的 3 类也在里面,和其他开关排在一起;「完成」下面标着固定门槛(不足 2 秒不打扰);每行说明文字直接显示
75
+ - **吸底保存条**:页面底部那条,只有「保存 / 放弃改动」
76
+ - 视觉上进一步对齐宿主:说明文字用与宿主同级的小字号,每个区块是带底色的卡片,输入框底色也与宿主一致
73
77
  - 会话 ID 显示开关
74
78
  - 文件行 ⋯ 菜单开关
75
79
  - 工作区管理(标签 / 颜色 / 置顶 / 隐藏,以及「清理失效项」)
package/client/client.js CHANGED
@@ -5042,13 +5042,15 @@ window.__ModuleLoader__.load({
5042
5042
  // 2. 能发出去吗 → 顶部状态条 + 发送测试邮件(点了当场回执成败与原始报错)
5043
5043
  // 其余一切让路:不额外套卡片、不加编号、不做中英同屏。
5044
5044
  //
5045
- // 信息架构(本轮只重做视觉骨架,信息架构与功能不动):
5046
- // 内容列 max-width 760 居中;页标题 18/600 + 说明 12/18;右上只有动作组
5047
- // (脏读数 + 保存 + 放弃改动),它是动作区、不是第二层页头;
5048
- // 判决条 = 状态色 hero(bg-module-platform + 左侧 3px 状态色 + 18/600 标题 + 主按钮在右下);
5049
- // 下面三张描边卡片:收件人 / 怎么发 / 什么时候发给我(14/22·500 卡片标题 + 12/18 状态读数);
5050
- // 7 个预设收成一行横向滚动 chips;端口 / 加密折进「更多」(收起时标题带当前值);
5051
- // 8 个事件开关排成宿主同款行(16px 上下内边距 + 行自身 .5px 线),说明由问号**点击**展开。
5045
+ // 信息架构(ui18 = 零折叠骨架):
5046
+ // 内容列 max-width 760 居中;页头**吸顶**(标题 18/26·600 + 说明 13/20 + 右侧脏读数/保存/放弃改动);
5047
+ // 判决条 = 状态色 hero(bg-module-platform + 左侧 3px 状态色 + 18/26·600 标题 + 14/22 结论句
5048
+ // + 12/18 人话摘要 + 主按钮在右下);
5049
+ // 下面三张卡片:收件人 / 发信方式 / 通知时机(14/22·500 卡片标题 + 12/18 状态读数);
5050
+ // **全页零折叠**:没有「▸ 更多」、没有「还有 3 种情况」、没有问号按钮——
5051
+ // 端口 / 加密 / 主机 / 用户名宽屏一行四列(≤560px 两行两列);
5052
+ // 8 个事件开关三列网格(窄屏一列、不分组),说明常显在格子下方(一行 12/18);
5053
+ // 通知时机第一行是总开关(关闭后 8 个事件开关变灰禁用但不隐藏);吸底条常驻「放弃 + 保存」。
5052
5054
  // 字号阶梯全部落在宿主网格上(只用整像素档,不再有半档字号);圆角只用 4/8/12/16/20/28。
5053
5055
  //
5054
5056
  // 编辑模型:**显式保存**。所有控件只改本地草稿,点「保存」才一次性写回:
@@ -5107,6 +5109,7 @@ window.__ModuleLoader__.load({
5107
5109
  'test.failed': '这次没发出去',
5108
5110
  'cred.saved': '已保存',
5109
5111
  'cred.notSaved': '还没保存',
5112
+ 'cred.draft': '有未保存的授权码',
5110
5113
  'cred.replace': '更换',
5111
5114
  'cred.clear': '清除',
5112
5115
  'cred.placeholder': '粘贴授权码',
@@ -5127,6 +5130,9 @@ window.__ModuleLoader__.load({
5127
5130
  'save.saving': '保存中…',
5128
5131
  'save.savedJust': '已保存 · 刚刚',
5129
5132
  'save.savedAgo': '已保存 · {n} 秒前',
5133
+ 'save.savedMin': '已保存 · {n} 分钟前',
5134
+ 'save.savedHour': '已保存 · {n} 小时前',
5135
+ 'save.savedDay': '已保存 · {n} 天前',
5130
5136
  'save.error': '保存失败:{error}',
5131
5137
  'save.retry': '重试',
5132
5138
  'save.reload': '重新读取',
@@ -5143,7 +5149,9 @@ window.__ModuleLoader__.load({
5143
5149
  'save.checkingCred': '正在确认授权码是否已写入…',
5144
5150
  'save.credCheckFailed': '凭据状态读取失败:{error}',
5145
5151
 
5152
+ // 判决条只按已保存的配置判定,四档会给四句不同的话(总开关关 > 事件全关 > 缺配置 > 可以发)。
5146
5153
  'status.disabled': '已关闭,不会发任何通知',
5154
+ 'status.allOff': '通知已全部关闭',
5147
5155
  'status.needRecipients': '还差一个收件人',
5148
5156
  'status.needHost': '还差 SMTP 服务器地址',
5149
5157
  'status.needUser': '还差 SMTP 用户名',
@@ -5153,6 +5161,7 @@ window.__ModuleLoader__.load({
5153
5161
 
5154
5162
  // 判决下面那句人话。只说用户的事,不说实现的事。
5155
5163
  'status.why.disabled': '打开下面任意一种情况,才会开始通知你。',
5164
+ 'status.why.allOff': '至少打开一个事件才会收到邮件。',
5156
5165
  'status.why.needRecipients': '没有收件邮箱,邮件没有地方可去。',
5157
5166
  'status.why.needHost': '还没填服务器地址。',
5158
5167
  'status.why.needUser': '多数邮箱要求用户名和授权码成对出现。',
@@ -5174,8 +5183,8 @@ window.__ModuleLoader__.load({
5174
5183
  'act.saveHint': '把这一页的改动一次性写回配置。',
5175
5184
 
5176
5185
  'group.recipients': '收件人',
5177
- 'group.transport': '怎么发',
5178
- 'group.outcomes': '什么时候发给我',
5186
+ 'group.transport': '发信方式',
5187
+ 'group.outcomes': '通知时机',
5179
5188
 
5180
5189
  'recipients.label': '收件邮箱',
5181
5190
  'recipients.placeholder': 'you@qq.com',
@@ -5192,12 +5201,10 @@ window.__ModuleLoader__.load({
5192
5201
  'preset.label': '邮箱服务商',
5193
5202
  'preset.other': '其他',
5194
5203
  'preset.detected': '已按「{name}」填好',
5195
- // 端口 / 加密收进「更多」:收起来不等于藏起来——偏离宿主默认就把现值摆到标题上。
5196
- 'transport.more': '更多',
5197
- 'transport.moreWith': '更多({value})',
5198
- // 事件开关的问号按钮:只做可访问名(说明正文仍走 outcomes.*Hint)。
5199
- 'hint.show': '展开「{title}」的说明',
5200
- 'hint.hide': '收起「{title}」的说明',
5204
+
5205
+ // 通知时机第一行:总开关。关掉之后 8 个事件开关变灰禁用(但不隐藏)。
5206
+ 'notify.master': '邮件通知',
5207
+ 'notify.masterHint': '关闭后不再发送任何邮件',
5201
5208
 
5202
5209
  'smtp.host': 'SMTP 主机',
5203
5210
  'smtp.port': '端口',
@@ -5211,27 +5218,45 @@ window.__ModuleLoader__.load({
5211
5218
  'cred.smtpHelpTitle': '怎么拿 SMTP 授权码',
5212
5219
  'cred.smtpHelpBody': '授权码不是登录密码,是邮箱服务商单独发的口令(QQ 是 16 位)。\n\nQQ 邮箱:设置 → 账户 → 开启「POP3/SMTP 服务」→ 生成授权码。\n163 / 126:设置 → POP3/SMTP/IMAP → 开启服务 → 新增授权码。\nGmail:账号 → 安全性 → 两步验证 → 应用专用密码。\niCloud:Apple ID → 登录与安全 → App 专用密码。\nOutlook:账户 → 安全性 → 高级安全选项 → 应用密码。',
5213
5220
  'cred.helpClose': '知道了',
5214
- 'cred.clear': '清除',
5215
5221
 
5222
+ // 8 个事件:标题是名词短语,说明压到一行(≤16 个中文字),常显在格子下方。
5216
5223
  'outcomes.completed': '任务完成',
5217
- 'outcomes.completedHint': '会话正常干完一件活。',
5224
+ 'outcomes.completedHint': '正常跑完时',
5218
5225
  'outcomes.error': '任务出错',
5219
- 'outcomes.errorHint': '会话报错终止。',
5220
- 'outcomes.blocked': '卡住,需要你介入',
5221
- 'outcomes.blockedHint': 'agent 需要你补充信息才能继续。',
5226
+ 'outcomes.errorHint': '报错终止时',
5227
+ 'outcomes.blocked': '卡住',
5228
+ 'outcomes.blockedHint': '等你补充信息时',
5222
5229
  'outcomes.approval': '等你审批',
5223
- 'outcomes.approvalHint': '立即发送,不参与合并窗口。',
5224
- 'outcomes.more': '还有 3 种情况(默认关)',
5225
- 'outcomes.countOn': '{n} 项开启',
5226
- 'outcomes.countOff': '全部关闭,不会通知你',
5230
+ 'outcomes.approvalHint': '等你点审批时',
5231
+ 'outcomes.subagents': '也通知子代理',
5232
+ 'outcomes.subagentsHint': '子代理也通知',
5227
5233
  'outcomes.aborted': '你主动停止',
5228
- 'outcomes.abortedHint': '只包含你自己按停止的那次。',
5234
+ 'outcomes.abortedHint': '你按了停止',
5229
5235
  'outcomes.interrupted': '系统中断',
5230
- 'outcomes.interruptedHint': 'DSH 重启 / 热更新 / 被父级取消。',
5231
- 'outcomes.maxTokens': '达到长度上限',
5232
- 'outcomes.maxTokensHint': '回复被长度上限截断。',
5233
- 'outcomes.subagents': '也通知子代理会话',
5234
- 'outcomes.subagentsHint': '一个 workflow 能扇出几十个子会话,默认不打扰。',
5236
+ 'outcomes.interruptedHint': '系统中断时',
5237
+ 'outcomes.maxTokens': '长度上限',
5238
+ 'outcomes.maxTokensHint': '达到上限时',
5239
+ 'outcomes.countOn': '{n} 项开启',
5240
+ 'outcomes.countOff': '全部关闭',
5241
+
5242
+ // 判决条第三行的「人话摘要」用短名,一句话最多列两个事件。
5243
+ 'short.completed': '跑完',
5244
+ 'short.error': '出错',
5245
+ 'short.blocked': '卡住',
5246
+ 'short.approval': '等审批',
5247
+ 'short.subagents': '子代理',
5248
+ 'short.aborted': '停止',
5249
+ 'short.interrupted': '中断',
5250
+ 'short.maxTokens': '长度上限',
5251
+
5252
+ 'summary.line': '{provider} · {host} → {to} · {events}',
5253
+ 'summary.noHost': '还没填主机',
5254
+ 'summary.noRecipient': '还没填收件人',
5255
+ 'summary.recipients': '{n} 位收件人',
5256
+ 'summary.custom': '自定义',
5257
+ 'summary.events': '{list}时通知',
5258
+ 'summary.eventsMore': '{list} 等 {n} 项时通知',
5259
+ 'summary.eventsOff': '通知已关闭',
5235
5260
 
5236
5261
  'danger.reset': '恢复全部默认',
5237
5262
  'danger.title': '恢复全部默认?',
@@ -5248,6 +5273,7 @@ window.__ModuleLoader__.load({
5248
5273
  'test.failed': 'This send did not go out',
5249
5274
  'cred.saved': 'Saved',
5250
5275
  'cred.notSaved': 'Not saved yet',
5276
+ 'cred.draft': 'An unsaved authorization code',
5251
5277
  'cred.replace': 'Replace',
5252
5278
  'cred.clear': 'Clear',
5253
5279
  'cred.placeholder': 'Paste the authorization code',
@@ -5268,6 +5294,9 @@ window.__ModuleLoader__.load({
5268
5294
  'save.saving': 'Saving…',
5269
5295
  'save.savedJust': 'Saved · just now',
5270
5296
  'save.savedAgo': 'Saved · {n}s ago',
5297
+ 'save.savedMin': 'Saved · {n} min ago',
5298
+ 'save.savedHour': 'Saved · {n} h ago',
5299
+ 'save.savedDay': 'Saved · {n} d ago',
5271
5300
  'save.error': 'Save failed: {error}',
5272
5301
  'save.retry': 'Retry',
5273
5302
  'save.reload': 'Reload',
@@ -5282,6 +5311,7 @@ window.__ModuleLoader__.load({
5282
5311
  'save.credCheckFailed': 'Could not read the credential state: {error}',
5283
5312
 
5284
5313
  'status.disabled': 'Off — no notifications will be sent',
5314
+ 'status.allOff': 'All notifications are off',
5285
5315
  'status.needRecipients': 'One recipient missing',
5286
5316
  'status.needHost': 'SMTP host missing',
5287
5317
  'status.needUser': 'SMTP user name missing',
@@ -5290,6 +5320,7 @@ window.__ModuleLoader__.load({
5290
5320
  'status.notServed': 'This page cannot read the plugin settings',
5291
5321
 
5292
5322
  'status.why.disabled': 'Turn on any case below and notifications start.',
5323
+ 'status.why.allOff': 'Turn on at least one event to get an email.',
5293
5324
  'status.why.needRecipients': 'Without a recipient address there is nowhere for the mail to go.',
5294
5325
  'status.why.needHost': 'No server address is set.',
5295
5326
  'status.why.needUser': 'Most providers want the user name and the authorization code together.',
@@ -5309,8 +5340,8 @@ window.__ModuleLoader__.load({
5309
5340
  'act.saveHint': 'Writes every change on this page back in one go.',
5310
5341
 
5311
5342
  'group.recipients': 'Recipients',
5312
- 'group.transport': 'How to send',
5313
- 'group.outcomes': 'When to notify me',
5343
+ 'group.transport': 'Sending method',
5344
+ 'group.outcomes': 'When to notify',
5314
5345
 
5315
5346
  'recipients.label': 'Recipient addresses',
5316
5347
  'recipients.placeholder': 'you@qq.com, one per line',
@@ -5327,10 +5358,9 @@ window.__ModuleLoader__.load({
5327
5358
  'preset.label': 'Mail provider',
5328
5359
  'preset.other': 'Other',
5329
5360
  'preset.detected': 'Filled in for “{name}”',
5330
- 'transport.more': 'More',
5331
- 'transport.moreWith': 'More ({value})',
5332
- 'hint.show': 'Show the note for “{title}”',
5333
- 'hint.hide': 'Hide the note for “{title}”',
5361
+
5362
+ 'notify.master': 'Email notifications',
5363
+ 'notify.masterHint': 'When off, no email is sent at all',
5334
5364
 
5335
5365
  'smtp.host': 'SMTP host',
5336
5366
  'smtp.port': 'Port',
@@ -5344,27 +5374,43 @@ window.__ModuleLoader__.load({
5344
5374
  'cred.smtpHelpTitle': 'Getting an SMTP authorization code',
5345
5375
  'cred.smtpHelpBody': 'An authorization code is not your login password — the mail provider issues it separately.\n\nQQ Mail: Settings → Account → enable “POP3/SMTP service” → generate the code.\n163 / 126: Settings → POP3/SMTP/IMAP → enable the service → add an authorization code.\nGmail: Account → Security → 2-Step Verification → App passwords.\niCloud: Apple ID → Sign-In and Security → App-Specific Passwords.\nOutlook: Account → Security → Advanced security options → App password.',
5346
5376
  'cred.helpClose': 'Got it',
5347
- 'cred.clear': 'Clear',
5348
5377
 
5349
5378
  'outcomes.completed': 'Task completed',
5350
- 'outcomes.completedHint': 'A session finished its work normally.',
5379
+ 'outcomes.completedHint': 'Finished normally',
5351
5380
  'outcomes.error': 'Task failed',
5352
- 'outcomes.errorHint': 'A session ended with an error.',
5353
- 'outcomes.blocked': 'Stuck — needs you',
5354
- 'outcomes.blockedHint': 'The agent needs more from you to continue.',
5381
+ 'outcomes.errorHint': 'Ended with an error',
5382
+ 'outcomes.blocked': 'Stuck',
5383
+ 'outcomes.blockedHint': 'Needs your input',
5355
5384
  'outcomes.approval': 'Waiting for your approval',
5356
- 'outcomes.approvalHint': 'Sent immediately, outside the coalesce window.',
5357
- 'outcomes.more': '3 more cases (off by default)',
5358
- 'outcomes.countOn': '{n} on',
5359
- 'outcomes.countOff': 'All off — you will not be notified',
5385
+ 'outcomes.approvalHint': 'Waits for your approval',
5386
+ 'outcomes.subagents': 'Subagents too',
5387
+ 'outcomes.subagentsHint': 'Subagents too',
5360
5388
  'outcomes.aborted': 'You stopped it',
5361
- 'outcomes.abortedHint': 'Only when you pressed stop yourself.',
5389
+ 'outcomes.abortedHint': 'You pressed stop',
5362
5390
  'outcomes.interrupted': 'Interrupted by the system',
5363
- 'outcomes.interruptedHint': 'Harness restart / hot reload / cancelled by a parent.',
5364
- 'outcomes.maxTokens': 'Token limit reached',
5365
- 'outcomes.maxTokensHint': 'The reply was cut off by the length limit.',
5366
- 'outcomes.subagents': 'Also notify subagent sessions',
5367
- 'outcomes.subagentsHint': 'One workflow can fan out dozens of sub-sessions; off by default.',
5391
+ 'outcomes.interruptedHint': 'System interrupt',
5392
+ 'outcomes.maxTokens': 'Length cap',
5393
+ 'outcomes.maxTokensHint': 'Hit the cap',
5394
+ 'outcomes.countOn': '{n} on',
5395
+ 'outcomes.countOff': 'All off',
5396
+
5397
+ 'short.completed': 'done',
5398
+ 'short.error': 'error',
5399
+ 'short.blocked': 'stuck',
5400
+ 'short.approval': 'approval',
5401
+ 'short.subagents': 'subagents',
5402
+ 'short.aborted': 'stop',
5403
+ 'short.interrupted': 'interrupt',
5404
+ 'short.maxTokens': 'token cap',
5405
+
5406
+ 'summary.line': '{provider} · {host} → {to} · {events}',
5407
+ 'summary.noHost': 'no host yet',
5408
+ 'summary.noRecipient': 'no recipient yet',
5409
+ 'summary.recipients': '{n} recipients',
5410
+ 'summary.custom': 'Custom',
5411
+ 'summary.events': 'notify on {list}',
5412
+ 'summary.eventsMore': 'notify on {list} and {n} in all',
5413
+ 'summary.eventsOff': 'notifications are off',
5368
5414
 
5369
5415
  'danger.reset': 'Reset everything to defaults',
5370
5416
  'danger.title': 'Reset everything to defaults?',
@@ -5413,10 +5459,21 @@ window.__ModuleLoader__.load({
5413
5459
  ];
5414
5460
 
5415
5461
  /**
5416
- * 端口 / 加密的宿主默认值(逐字对齐 lib/notify/config.js:端口 465、加密 auto)。
5417
- * 「更多」折叠标题靠它判断这一对值是不是偏离默认,偏离才在后面写当前值。
5462
+ * 8 个事件开关,一份真源:字段名 / 标题键 / 说明键 / 摘要短名。
5463
+ *
5464
+ * 通知时机卡片、卡片头读数、判决条的「事件全关」判定、人话摘要都读这一份表——
5465
+ * 各写一遍必然漂:加了第 9 个事件时会漏掉某处。顺序就是格子顺序。
5418
5466
  */
5419
- const DEFAULT_TRANSPORT = { port: 465, secure: 'auto' };
5467
+ const OUTCOME_FIELDS = [
5468
+ { field: 'notifyCompleted', title: 'outcomes.completed', hint: 'outcomes.completedHint', short: 'short.completed' },
5469
+ { field: 'notifyError', title: 'outcomes.error', hint: 'outcomes.errorHint', short: 'short.error' },
5470
+ { field: 'notifyBlocked', title: 'outcomes.blocked', hint: 'outcomes.blockedHint', short: 'short.blocked' },
5471
+ { field: 'notifyApproval', title: 'outcomes.approval', hint: 'outcomes.approvalHint', short: 'short.approval' },
5472
+ { field: 'includeSubagents', title: 'outcomes.subagents', hint: 'outcomes.subagentsHint', short: 'short.subagents' },
5473
+ { field: 'notifyAbortedByUser', title: 'outcomes.aborted', hint: 'outcomes.abortedHint', short: 'short.aborted' },
5474
+ { field: 'notifyInterrupted', title: 'outcomes.interrupted', hint: 'outcomes.interruptedHint', short: 'short.interrupted' },
5475
+ { field: 'notifyMaxTokens', title: 'outcomes.maxTokens', hint: 'outcomes.maxTokensHint', short: 'short.maxTokens' },
5476
+ ];
5420
5477
 
5421
5478
  /**
5422
5479
  * 解析收件人:一行一个(兼容逗号/分号),去空、去重、丢弃明显不含 @ 的项。
@@ -5523,9 +5580,10 @@ window.__ModuleLoader__.load({
5523
5580
  return hit === undefined ? '' : hit;
5524
5581
  }
5525
5582
 
5526
- /** 状态条的取值只由「配置齐不齐」决定;投递历史已随账本一起删除。 */
5583
+ /** 状态条的取值只由「配置齐不齐」与「有没有事件要通知」决定;投递历史已随账本一起删除。 */
5527
5584
  const STATUS_TONE = {
5528
5585
  disabled: 'idle',
5586
+ allOff: 'warning',
5529
5587
  needRecipients: 'warning',
5530
5588
  needHost: 'warning',
5531
5589
  needUser: 'warning',
@@ -5535,15 +5593,18 @@ window.__ModuleLoader__.load({
5535
5593
 
5536
5594
  /**
5537
5595
  * 推导状态条要显示哪个结论。纯函数,输入全部来自草稿与凭据状态。
5538
- * SMTP 是唯一的发送方式,必填项是「主机 + 用户名 + 凭据」,缺哪个就报哪个——
5539
- * 只报主机会让用户填完主机才发现还是发不出去。
5596
+ *
5597
+ * 优先级(上一档压住下一档,顺序就是这个人写下来的顺序):
5598
+ * 总开关关闭 > 8 个事件全关 > 缺配置(收件人 / 主机 / 用户名 / 凭据)> 可以发信。
5599
+ * 总开关关着时谈"缺什么"没有意义;事件全关时谈「可以发信了」是骗人——信不会发出去。
5540
5600
  * 投递历史不参与:账本已删,这里只回答「配置齐不齐」。
5541
- * @param {object} input 判定输入
5601
+ * @param {object} input 判定输入(不传 eventsOn 时跳过「事件全关」这一档)
5542
5602
  * @returns {string} 状态键
5543
5603
  */
5544
5604
  function deriveStatus(input) {
5545
- const { enabled, recipientCount, host, user, credentialConfigured } = input ?? {};
5605
+ const { enabled, eventsOn, recipientCount, host, user, credentialConfigured } = input ?? {};
5546
5606
  if (enabled !== true) return 'disabled';
5607
+ if (eventsOn === 0) return 'allOff';
5547
5608
  if (!(recipientCount > 0)) return 'needRecipients';
5548
5609
  if (String(host ?? '').trim() === '') return 'needHost';
5549
5610
  if (String(user ?? '').trim() === '') return 'needUser';
@@ -5583,6 +5644,24 @@ window.__ModuleLoader__.load({
5583
5644
  return done === true ? 'done' : 'warning';
5584
5645
  }
5585
5646
 
5647
+
5648
+ /**
5649
+ * 保存读数的"多久之前"。按量级退化,别让人去数 320 秒:
5650
+ * <60 秒 → 刚刚 / N 秒前;<60 分 → N 分钟前;<24 时 → N 小时前;否则 N 天前。
5651
+ * @param {number} ms 距保存成功的毫秒数
5652
+ * @param {Function} t 文案函数
5653
+ * @returns {string} 读数
5654
+ */
5655
+ function agoText(ms, t) {
5656
+ const seconds = Math.max(0, Math.floor(ms / 1000));
5657
+ if (seconds < 60) return seconds === 0 ? t('save.savedJust') : t('save.savedAgo', { n: seconds });
5658
+ const minutes = Math.floor(seconds / 60);
5659
+ if (minutes < 60) return t('save.savedMin', { n: minutes });
5660
+ const hours = Math.floor(minutes / 60);
5661
+ if (hours < 24) return t('save.savedHour', { n: hours });
5662
+ return t('save.savedDay', { n: Math.floor(hours / 24) });
5663
+ }
5664
+
5586
5665
  // ---------------------------------------------------------------------------
5587
5666
  // 样式
5588
5667
  //
@@ -5601,43 +5680,45 @@ window.__ModuleLoader__.load({
5601
5680
  //
5602
5681
  // 量度(全部照抄宿主自己的设置页,别自己发明):
5603
5682
  // 内容列 max-width 760 居中 · 页标题 18/26·600 · 卡片标题 14/22·500 · 行标题 14/22·400
5604
- // 字段标签 13/1.5·500(primary) · 说明 12/18 tertiary · 小读数 11/16
5605
- // 卡片 = .5px border-l4 + radius 20 + padding 14 16 + gap 12;卡片之间 gap 12
5683
+ // 字段标签 13/20·500(primary) · 说明 12/18 tertiary · 小读数 11/16
5684
+ // 卡片 = .5px settings-card-stroke + settings-card-fill + radius 20 + padding 14 16 + gap 12
5606
5685
  // 行 = padding 16px 0 + 行自身 .5px border-l2(最后一行没有);段头 / 页脚一律不画线
5607
- // 填充面 = bg-module-platform(宿主"编辑器/选中态"用的就是这个)
5686
+ // 填充面 = bg-module-platform(宿主"编辑器/选中态"用的就是这个;卡片另用 settings-card-fill)
5608
5687
  //
5609
- // 纪律:全页零硬编码颜色(单测里有审计)。**全页只有一个填充面 —— 判决条。**
5688
+ // 纪律:全页零硬编码颜色(单测里有审计)。**状态色填充面只有判决条一处**;
5689
+ // 卡片的 fill / stroke 用宿主 settings 自己的 token(暗色下才不与页面同色)。
5610
5690
  // ---------------------------------------------------------------------------
5611
5691
 
5612
5692
  /** 一眼看出浏览器加载的是哪一版。放在根节点的 data-build 上,检查 DOM 可见,不占视觉。 */
5613
- const BUILD = 'ui17';
5614
-
5615
- const CSS = `/* dsh-notify-p · 设置页样式(视觉骨架照抄宿主 settings 页;全页零硬编码颜色)
5616
- 字号阶梯(每一档都落在宿主网格上,全页只用整像素档,不许半档):
5617
- 页标题 18/26·600 = 判决标题 18/26·600 > 卡片标题 14/22·500
5618
- > 字段标签 13/1.5·500 > 行标题 14/22·400 > 说明 12/18 > 小读数 11/16
5619
- 骨架:内容列 max-width 760 居中;页头只有标题 + 动作组;三张描边卡片;
5620
- 判决条是唯一填充面(bg-module-platform)+ 左侧 3px 状态色;页脚无边框。
5621
- 线只长在行自己身上(.5px border-l2,最后一行没有),段头与页脚一律不画线。
5622
- 圆角只用宿主那几档:卡片 / 判决条 20、输入框 12、chips 8、问号 50%。 */
5693
+ const BUILD = 'ui18';
5694
+
5695
+ const CSS = `/* dsh-notify-p · 设置页样式(ui18 零折叠骨架;全页零硬编码颜色)
5696
+ 字号阶梯(每一档都落在宿主网格上,全页只用整像素档,不许半档):
5697
+ 页标题 18/26·600 = 判决标题 18/26·600 > 卡片标题 14/22·500 > 行标题 14/22·400
5698
+ > 页头说明 13/20 = 字段标签 13/20·500 = chips 13/18 > 说明 12/18 > 小读数 11/16
5699
+ 骨架:内容列 max-width 760 居中;页头吸顶、吸底保存条常驻;三张卡片(card-fill 底 + card-stroke 描边);
5700
+ 判决条是唯一的状态色填充面(bg-module-platform)+ 左侧 3px 状态色;页脚无边框。
5701
+ 线只长在行自己身上(.5px border-l2,最后一行没有),段头与页脚一律不画线。
5702
+ **零折叠**:没有「▸ 更多」、没有「还有 3 种情况」、没有问号按钮——这一页所有内容默认都在。
5703
+ 圆角只用宿主那几档:卡片 / 判决条 20、输入框 12、chips 8。 */
5623
5704
  .dshmn-page{display:flex;flex-direction:column;width:100%;max-width:760px;margin:0 auto;min-width:0;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
5624
5705
  .dshmn-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}
5625
5706
 
5626
- /* 页头:标题 18/600 独占一行,右上只有动作组(脏读数 + 保存 + 放弃改动)。
5627
- 不画线、不放说明、不占第二层高度——它是内容区第一行,不是第二条标题栏。 */
5628
- .dshmn-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px 16px;margin:0 0 24px;min-width:0;flex-wrap:wrap}
5629
- .dshmn-headText{display:flex;flex-direction:column;gap:12px;min-width:0;flex:1 1 auto}
5707
+ /* ── 页头:吸顶常驻(自己带背景与内边距,滚过去不会盖住内容)───────────────
5708
+ 内容 = 标题 + 一行说明 + 右侧动作组(脏读数 + 保存 + 放弃改动)。 */
5709
+ .dshmn-head{position:sticky;top:0;z-index:2;display:flex;align-items:flex-start;justify-content:space-between;gap:12px 16px;margin:0 0 16px;padding:12px 0;min-width:0;flex-wrap:wrap;background:var(--dsw-alias-bg-layer-2)}
5710
+ .dshmn-headText{display:flex;flex-direction:column;gap:8px;min-width:0;flex:1 1 auto}
5630
5711
  .dshmn-title{margin:0;min-width:0;color:var(--dsw-alias-label-primary);font-size:18px;line-height:26px;font-weight:600}
5631
- .dshmn-lead{margin:0;min-width:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
5712
+ .dshmn-lead{margin:0;min-width:0;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:20px}
5632
5713
  .dshmn-headActions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex:none;flex-wrap:wrap;min-width:0}
5633
5714
  .dshmn-stamp{flex:none;display:inline-flex;align-items:center;gap:6px;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}
5634
5715
  .dshmn-stamp[data-tone=busy]{color:var(--dsw-alias-label-secondary)}
5635
5716
  .dshmn-stamp[data-tone=error]{color:var(--dsw-alias-state-error-primary)}
5636
5717
  .dshmn-stamp[data-tone=dirty]{color:var(--dsw-alias-label-primary)}
5637
5718
 
5638
- /* ── 判决条:全页唯一的填充面,也是唯一的状态色 hero ─────────────────────
5719
+ /* ── 判决条:全页唯一的状态色填充面,也是唯一的状态色 hero ─────────────────
5639
5720
  左侧 3px 竖条与标题色都由 data-tone 决定,颜色全部来自 state token(不硬编码)。
5640
- 三态:可发信 = success / 需补配置 = warn / 已关闭 = label-tertiary。 */
5721
+ 四档 tone:done(可发信)/ warning(缺配置、事件全关)/ idle(总开关关闭)/ error。 */
5641
5722
  /* 按钮:按宿主线上规则(.primary/.outline + .sm)自绘 —— 见 Button() 注释里为什么不能等宿主原语 */
5642
5723
  .dshmn-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;box-sizing:border-box;
5643
5724
  height:28px;padding:0 10px;border:none;border-radius:8px;font-family:inherit;
@@ -5662,16 +5743,18 @@ window.__ModuleLoader__.load({
5662
5743
  .dshmn-verdict[data-tone=idle] .dshmn-verdictTitle{color:var(--dsw-alias-label-tertiary)}
5663
5744
  .dshmn-verdict[data-tone=error] .dshmn-verdictTitle{color:var(--dsw-alias-state-error-primary)}
5664
5745
  .dshmn-verdictWhy{margin:0;color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px}
5746
+ /* 人话摘要:服务商 · 主机 → 收件人 · 已开启的事件。只说事实,不下判决。 */
5747
+ .dshmn-verdictSummary{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;word-break:break-word}
5665
5748
  .dshmn-verdictFoot{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin-top:4px}
5666
5749
  .dshmn-evidence{display:flex;flex-direction:column;gap:4px;min-width:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
5667
5750
  .dshmn-evidenceKey{color:var(--dsw-alias-label-tertiary)}
5668
5751
  .dshmn-evidence[data-ok=false] .dshmn-evidenceKey{color:var(--dsw-alias-state-error-primary)}
5669
5752
  .dshmn-evidence .dshmn-mono{color:var(--dsw-alias-label-secondary);word-break:break-all}
5670
5753
 
5671
- /* ── 三张卡片:靠 .5px 描边成立,不靠底色(浅色主题下所有 bg-layer 都是纯白)──
5754
+ /* ── 三张卡片:card-stroke 描边 + card-fill 底(暗色下才不与页面同色)──────────
5672
5755
  描边 + radius 20 + padding 14 16 + 内部 gap 12,与宿主 settings 的 rowCard 同构。 */
5673
5756
  .dshmn-card{display:flex;flex-direction:column;gap:12px;min-width:0;padding:14px 16px;
5674
- border:.5px solid var(--dsw-alias-border-l4);border-radius:20px}
5757
+ background:var(--dsw-alias-settings-card-fill);border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:20px}
5675
5758
  .dshmn-card + .dshmn-card{margin-top:12px}
5676
5759
  .dshmn-verdict + .dshmn-card{margin-top:16px}
5677
5760
  .dshmn-cardHead{display:flex;align-items:baseline;justify-content:space-between;gap:12px;flex-wrap:wrap;min-width:0}
@@ -5686,98 +5769,83 @@ window.__ModuleLoader__.load({
5686
5769
  .dshmn-item{display:flex;flex-direction:column;gap:8px;padding:16px 0;min-width:0;
5687
5770
  border-bottom:.5px solid var(--dsw-alias-border-l2)}
5688
5771
  .dshmn-item:last-child{padding-bottom:0;border-bottom:none}
5689
- .dshmn-itemHead{display:flex;align-items:baseline;justify-content:space-between;gap:12px;min-width:0}
5690
- .dshmn-labelRow{display:inline-flex;align-items:center;gap:6px;min-width:0}
5772
+ .dshmn-itemHead{display:flex;align-items:baseline;justify-content:space-between;gap:12px 16px;min-width:0;flex-wrap:wrap}
5773
+ .dshmn-labelRow{display:inline-flex;align-items:center;gap:8px;min-width:0;flex-wrap:wrap}
5691
5774
 
5692
- /* ── 开关行:标题 14/22·400 + 问号 + 开关。说明折进问号里,默认不占版面 ───────── */
5693
- .dshmn-row{display:flex;flex-direction:column;gap:8px;padding:16px 0;min-width:0;
5775
+ /* ── 通知时机:第一行总开关 + 8 个事件三列网格(窄屏一列,不分组)────────────── */
5776
+ .dshmn-master{display:flex;flex-direction:column;gap:6px;min-width:0;padding:16px 0 12px;
5694
5777
  border-bottom:.5px solid var(--dsw-alias-border-l2)}
5695
- .dshmn-row:last-child{padding-bottom:0;border-bottom:none}
5696
- .dshmn-rowHead{display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0}
5697
- .dshmn-rowTitle{display:flex;align-items:center;gap:6px;min-width:0;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;font-weight:400}
5698
- .dshmn-rowName{min-width:0}
5699
- .dshmn-rowCtrl{flex:none;display:flex;align-items:center;gap:8px;min-height:22px}
5700
- .dshmn-rowNote{margin:0;max-width:44em;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
5701
- /* 问号:原生 button(点击开合,触屏点得到,键盘 tab 得到),16×16 只占一个行内空位 */
5702
- .dshmn-q{flex:none;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;
5703
- width:16px;height:16px;padding:0;cursor:pointer;
5704
- border:.5px solid var(--dsw-alias-border-l4);border-radius:50%;background:none;
5705
- color:var(--dsw-alias-label-tertiary);font:inherit;font-size:11px;line-height:1}
5706
- .dshmn-q:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}
5707
- .dshmn-q[aria-expanded=true]{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover);
5708
- border-color:var(--dsw-alias-border-l3)}
5709
-
5710
- /* ── 折叠标题(「更多」「还有 3 种情况」):一行文字按钮,展开后缩进一道竖线 ── */
5711
- .dshmn-fold{display:inline-flex;align-items:center;align-self:flex-start;gap:4px;padding:0;cursor:pointer;
5712
- border:none;background:none;color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;line-height:20px;
5713
- text-decoration:underline;text-underline-offset:3px;text-decoration-color:var(--dsw-alias-border-l4)}
5714
- .dshmn-fold:hover{color:var(--dsw-alias-label-primary)}
5715
- .dshmn-fold[aria-expanded=true]{color:var(--dsw-alias-label-primary);text-decoration:none}
5716
- .dshmn-foldBody{display:flex;flex-direction:column;gap:8px;min-width:0;margin-top:8px;padding-left:12px;
5717
- border-left:.5px solid var(--dsw-alias-border-l2)}
5718
- .dshmn-q:focus-visible,.dshmn-fold:focus-visible,.dshmn-link:focus-visible,.dshmn-segBtn:focus-visible{
5719
- outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
5778
+ .dshmn-masterHead{display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0}
5779
+ .dshmn-events{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px 28px;min-width:0;padding-top:4px}
5780
+ .dshmn-event{display:flex;flex-direction:column;gap:4px;min-width:0}
5781
+ .dshmn-eventHead{display:flex;align-items:center;justify-content:flex-start;gap:8px;min-width:0;min-height:22px;cursor:pointer}
5782
+ .dshmn-check{flex:none;width:16px;height:16px;margin:0;accent-color:var(--dsw-alias-brand-primary);cursor:pointer}
5783
+ .dshmn-check:disabled{cursor:not-allowed;opacity:.5}
5784
+ .dshmn-eventTitle{min-width:0;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;font-weight:400}
5785
+ .dshmn-eventNote{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
5720
5786
 
5721
5787
  /* ── 字段:标签在上、控件满宽(宿主 settings 的字段同款)────────────────────── */
5722
5788
  .dshmn-field{display:flex;flex-direction:column;gap:8px;min-width:0}
5723
- .dshmn-label{color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;font-weight:500}
5789
+ .dshmn-label{color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px;font-weight:500}
5724
5790
  .dshmn-hint{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;max-width:44em}
5725
5791
  .dshmn-error{margin:0;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;word-break:break-word}
5726
5792
  .dshmn-credState{display:inline-flex;align-items:center;gap:6px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
5727
5793
  .dshmn-credState[data-ok=true]{color:var(--dsw-alias-state-success-primary)}
5794
+ .dshmn-credActions{display:inline-flex;align-items:center;gap:12px;flex-wrap:wrap;min-width:0}
5728
5795
  .dshmn-linkRow{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
5729
5796
 
5730
- /* ── 输入 ──────────────────────────────────────────────────────────────── */
5797
+ /* ── 输入 ────────────────────────────────────────────────────────────────── */
5731
5798
  /* 宿主的 Input 原语是 content-box 的 inline-flex(padding 0 8px + .5px 描边),
5732
5799
  所以光给 width:100% 会让它比父级宽出 18px、顶出内容列。必须一起给 border-box。
5733
- 这里是原语缺席时的兜底输入框:底色 / 描边 / 圆角与宿主 Input 对齐(bg-layer-3 + border-l4 + 12)。 */
5800
+ 兜底输入框:底色 / 描边 / 圆角与宿主 Input 对齐(bg-layer-1 + border-l4 + 12)。 */
5734
5801
  .dshmn-inputWrap{box-sizing:border-box;display:flex;width:100%;min-width:0}
5735
5802
  .dshmn-inputWrap>*{box-sizing:border-box;width:100%;min-width:0}
5736
- .dshmn-textarea{box-sizing:border-box;width:100%;min-height:52px;padding:8px 10px;resize:vertical;outline:none;
5737
- border:.5px solid var(--dsw-alias-border-l4);border-radius:12px;background:var(--dsw-alias-bg-layer-3);
5803
+ .dshmn-input,.dshmn-select{box-sizing:border-box;width:100%;height:32px;padding:0 10px;outline:none;
5804
+ border:.5px solid var(--dsw-alias-border-l4);border-radius:12px;background:var(--dsw-alias-bg-layer-1);
5738
5805
  color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px}
5739
- .dshmn-textarea::placeholder{color:var(--dsw-alias-label-tertiary)}
5740
- .dshmn-select{box-sizing:border-box;width:100%;height:32px;padding:0 10px;outline:none;
5741
- border:.5px solid var(--dsw-alias-border-l4);border-radius:12px;background:var(--dsw-alias-bg-layer-3);
5742
- color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px}
5743
- .dshmn-textarea:focus-visible,.dshmn-select:focus-visible{border-color:var(--dsw-alias-brand-primary);
5806
+ .dshmn-input::placeholder{color:var(--dsw-alias-label-tertiary)}
5807
+ .dshmn-input:focus-visible,.dshmn-select:focus-visible{border-color:var(--dsw-alias-brand-primary);
5744
5808
  box-shadow:0 0 0 2px var(--dsw-alias-border-l3)}
5745
- .dshmn-textarea[aria-invalid=true]{border-color:var(--dsw-alias-state-error-primary)}
5809
+ .dshmn-input[aria-invalid=true]{border-color:var(--dsw-alias-state-error-primary)}
5746
5810
 
5747
- /* 两列:主机 + 用户名。端口 / 加密在「更多」里,同样两列,只是端口窄一点。 */
5748
- .dshmn-two{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px 16px;align-items:start;min-width:0}
5749
- .dshmn-twoPort{display:grid;grid-template-columns:96px minmax(0,1fr);gap:12px 16px;align-items:start;min-width:0}
5811
+ /* 主机 / 端口 / 加密 / 用户名:宽屏一行四列(主机与用户名吃剩余宽度,端口与加密固定窄)。 */
5812
+ .dshmn-smtpGrid{display:grid;grid-template-columns:minmax(0,1fr) 88px 104px minmax(0,1fr);gap:12px 16px;align-items:start;min-width:0}
5750
5813
 
5751
5814
  /* ── 预设 chips:一行横向滚动(窄屏也不折成三排),滚动条藏起来 ─────────────── */
5752
5815
  .dshmn-chips{display:flex;align-items:center;gap:8px;min-width:0;overflow-x:auto;overflow-y:hidden;padding:2px 0;
5753
5816
  scrollbar-width:none;-ms-overflow-style:none}
5754
5817
  .dshmn-chips::-webkit-scrollbar{display:none}
5755
5818
  .dshmn-segBtn{box-sizing:border-box;flex:none;height:28px;padding:0 12px;cursor:pointer;white-space:nowrap;
5756
- font:inherit;font-size:12px;line-height:18px;
5819
+ font:inherit;font-size:13px;line-height:18px;
5757
5820
  border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:none;
5758
5821
  color:var(--dsw-alias-label-secondary)}
5759
5822
  .dshmn-segBtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
5760
- .dshmn-segBtn[data-active=true]{background:var(--dsw-alias-bg-module-platform);border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary);font-weight:600;
5823
+ .dshmn-segBtn[data-active=true]{background:var(--dsw-alias-bg-module-platform);border-color:var(--dsw-alias-brand-primary);
5761
5824
  color:var(--dsw-alias-label-primary);font-weight:600}
5762
5825
 
5763
- /* ── 文字按钮 ──────────────────────────────────────────────────────────── */
5826
+ /* ── 文字按钮 ─────────────────────────────────────────────────────────────── */
5764
5827
  .dshmn-link{color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;line-height:20px;cursor:pointer;
5765
5828
  padding:0;border:none;background:none;text-decoration:underline;text-underline-offset:3px;
5766
5829
  text-decoration-color:var(--dsw-alias-border-l4)}
5767
5830
  .dshmn-link:hover{color:var(--dsw-alias-label-primary)}
5768
5831
  .dshmn-link:disabled{color:var(--dsw-alias-label-tertiary);cursor:default;text-decoration:none}
5832
+ .dshmn-link:focus-visible,.dshmn-segBtn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
5769
5833
 
5770
- /* ── 页脚与帮助正文:页脚不画线,只留一条 12/18 的链接 ───────────────────── */
5834
+ /* ── 页脚与吸底条:页脚只留一条文字链;吸底条常驻底部,只放两个动作 ────────── */
5771
5835
  .dshmn-foot{display:flex;align-items:center;justify-content:flex-end;gap:12px;flex-wrap:wrap;margin-top:16px;padding-top:16px}
5772
5836
  .dshmn-foot .dshmn-link{font-size:12px;line-height:18px}
5837
+ .dshmn-savebar{position:sticky;bottom:0;z-index:2;display:flex;align-items:center;justify-content:flex-end;gap:8px;
5838
+ flex-wrap:wrap;margin-top:8px;padding:12px 0;background:var(--dsw-alias-bg-layer-2)}
5773
5839
  .dshmn-helpBody{white-space:pre-wrap;color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px}
5774
5840
 
5775
- /* ── 窄屏:宿主 settings 页的断点就是 560px ─────────────────────────────── */
5841
+ /* ── 窄屏:宿主 settings 页的断点就是 560px ──────────────────────────────── */
5776
5842
  @media (max-width:560px){
5777
5843
  .dshmn-head{flex-direction:column;align-items:stretch;gap:12px}
5778
5844
  .dshmn-headActions{justify-content:flex-start}
5779
5845
  .dshmn-verdictFoot{justify-content:flex-start}
5780
- .dshmn-two,.dshmn-twoPort{grid-template-columns:minmax(0,1fr)}
5846
+ .dshmn-savebar{justify-content:flex-start}
5847
+ .dshmn-smtpGrid{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}
5848
+ .dshmn-events{grid-template-columns:minmax(0,1fr)}
5781
5849
  .dshmn-card{padding:12px 14px}
5782
5850
  }
5783
5851
 
@@ -5800,12 +5868,16 @@ window.__ModuleLoader__.load({
5800
5868
  /** 开关。原语缺失时用原生 checkbox 顶上。 */
5801
5869
  function Switch(props) {
5802
5870
  if (typeof P.Switch === 'function') return h(P.Switch, props);
5871
+ // 兜底的 checkbox 要把额外 props 透传下去:事件格子用 data-event、总开关用 data-master
5872
+ // 给自己一个稳定的 DOM 抓手(和宿主用 data-* 是同一个用途),视觉上不占位。
5873
+ const { checked, disabled, label, onChange, ...rest } = props;
5803
5874
  return h('input', {
5804
5875
  type: 'checkbox',
5805
- checked: props.checked === true,
5806
- disabled: props.disabled === true,
5807
- 'aria-label': props.label,
5808
- onChange: () => props.onChange(props.checked !== true),
5876
+ checked: checked === true,
5877
+ disabled: disabled === true,
5878
+ 'aria-label': label,
5879
+ onChange: () => onChange(checked !== true),
5880
+ ...rest,
5809
5881
  });
5810
5882
  }
5811
5883
 
@@ -5853,7 +5925,31 @@ window.__ModuleLoader__.load({
5853
5925
  if (typeof P.Input === 'function') {
5854
5926
  return h(P.Input, { ...rest, className: `dshmn-inputWrap${className === undefined ? '' : ` ${className}`}` });
5855
5927
  }
5856
- return h('input', { ...rest, className: 'dshmn-textarea', style: { minHeight: 0, height: 32 } });
5928
+ return h('input', { ...rest, className: 'dshmn-input', style: { minHeight: 0, height: 32 } });
5929
+ }
5930
+
5931
+ /**
5932
+ * 勾选框(多选清单用)。
5933
+ *
5934
+ * 事件开关为什么用勾选而不是开关:三列窄格里,拨动式开关没有"把手",
5935
+ * 一排放三个时分不清哪个属于哪个标题;勾选框贴着标题,读起来就是
5936
+ * 「☑ 任务完成」这种清单句,选中与否一眼可读,也更贴合"我会收到哪些通知"。
5937
+ * 总开关仍用拨动开关——它管的是整件事开不开,语义不同。
5938
+ */
5939
+ function Check(props) {
5940
+ const { checked, disabled, label, onChange, ...rest } = props;
5941
+ // 关键:**不能把 label 作为文本传给宿主原语**——宿主的 Checkbox 会把 label 渲染成
5942
+ // 可见文字,于是标题出现两次(「☑ 任务完成 任务完成」),格子也被撑到换行。
5943
+ // 名字只走 aria-label(读屏需要),可见文字由调用方自己排版。
5944
+ const common = {
5945
+ ...rest,
5946
+ checked: checked === true,
5947
+ disabled: disabled === true,
5948
+ 'aria-label': label,
5949
+ onChange: () => onChange(checked !== true),
5950
+ };
5951
+ if (typeof P.Checkbox === 'function') return h(P.Checkbox, common);
5952
+ return h('input', { type: 'checkbox', className: 'dshmn-check', ...common });
5857
5953
  }
5858
5954
 
5859
5955
  /**
@@ -5876,9 +5972,7 @@ window.__ModuleLoader__.load({
5876
5972
  }, children);
5877
5973
  }
5878
5974
 
5879
- // 折叠区(「更多」「还有 3 种情况」)统一用下面这个 foldHead 拼在页内:
5880
- // 开合状态留在 MailNotifyPage 自己的 useState 里,于是「收起时标题带当前值」与
5881
- // 「开合状态可断言」两件事同时成立;旧的 Disclosure 包装随之删除。
5975
+ // 全页零折叠:这一页没有折叠区、没有「▸ 更多」、也没有问号按钮——所有内容默认都在版面上。
5882
5976
 
5883
5977
  // ---------------------------------------------------------------------------
5884
5978
  // 页面
@@ -5906,27 +6000,6 @@ window.__ModuleLoader__.load({
5906
6000
  h('div', { className: 'dshmn-cardBody' }, children));
5907
6001
  }
5908
6002
 
5909
- /**
5910
- * 折叠标题:一行原生按钮(点击开合,键盘 tab 得到),标题自带 ▸/▾ 表示开合状态。
5911
- *
5912
- * 不用宿主原语 DisclosureRow:它把标题包进自己的结构里,而这一轮要求
5913
- * 「收起时把当前值写在标题上」,也需要单测能直接看见开合状态。
5914
- * @param {string} id 受控内容区的 id(aria-controls)
5915
- * @param {string} label 标题文字(已含当前值)
5916
- * @param {boolean} open 是否展开
5917
- * @param {Function} onToggle 点击开合
5918
- * @returns {object} React 元素
5919
- */
5920
- function foldHead(id, label, open, onToggle) {
5921
- return h('button', {
5922
- type: 'button',
5923
- className: 'dshmn-fold',
5924
- 'aria-expanded': open === true ? 'true' : 'false',
5925
- 'aria-controls': id,
5926
- onClick: onToggle,
5927
- }, (open === true ? '▾ ' : '▸ ') + label);
5928
- }
5929
-
5930
6003
  /**
5931
6004
  * 设置页组件。
5932
6005
  * @param {object} props settings.section 传入的 props,外加 remotes / i18n 持有者
@@ -5957,11 +6030,6 @@ window.__ModuleLoader__.load({
5957
6030
  const [confirmReset, setConfirmReset] = React.useState(false);
5958
6031
  const [helpKind, setHelpKind] = React.useState(null);
5959
6032
  const [test, setTest] = React.useState({ phase: 'idle', ms: 0, error: '' });
5960
- const [showMoreOutcomes, setShowMoreOutcomes] = React.useState(false);
5961
- /** 端口 / 加密收在「更多」里,默认收起(偏离默认值时标题会带上现值)。 */
5962
- const [moreOpen, setMoreOpen] = React.useState(false);
5963
- /** 每个事件开关行自己的说明开合:点问号切一行,互不影响。 */
5964
- const [openHints, setOpenHints] = React.useState({});
5965
6033
 
5966
6034
  const draftRef = React.useRef({});
5967
6035
  const baselineRef = React.useRef({});
@@ -6383,8 +6451,11 @@ window.__ModuleLoader__.load({
6383
6451
  // 配置,用户一点就失败——先被夸一句、再被打一巴掌,比什么都不说更糟。
6384
6452
  const savedRecipients = parseRecipients(baseline.recipients);
6385
6453
  const savedPasswordRef = String(baseline.smtpPasswordRef ?? '').trim();
6454
+ /** 已保存的配置里有几个事件是开的(判决条「事件全关」那一档要用)。 */
6455
+ const savedEventsOn = OUTCOME_FIELDS.filter((item) => baseline[item.field] === true).length;
6386
6456
  const statusKey = deriveStatus({
6387
6457
  enabled: baseline.enabled === true,
6458
+ eventsOn: savedEventsOn,
6388
6459
  recipientCount: savedRecipients.valid.length,
6389
6460
  host: baseline.smtpHost,
6390
6461
  user: baseline.smtpUser,
@@ -6397,6 +6468,7 @@ window.__ModuleLoader__.load({
6397
6468
  const transportState = t(transportReady ? 'transportState.done' : 'transportState.todo');
6398
6469
  const STATUS_TITLE_KEY = {
6399
6470
  disabled: 'status.disabled',
6471
+ allOff: 'status.allOff',
6400
6472
  needRecipients: 'status.needRecipients',
6401
6473
  needHost: 'status.needHost',
6402
6474
  needUser: 'status.needUser',
@@ -6409,9 +6481,7 @@ window.__ModuleLoader__.load({
6409
6481
  : t(STATUS_TITLE_KEY[statusKey] ?? 'status.ready');
6410
6482
  /** 判决下面那句人话:为什么是这个判决、下一步该动哪里。只说用户的事。 */
6411
6483
  const statusWhy = viewRef.current === null ? t('status.notServed') : t(`status.why.${statusKey}`);
6412
- const outcomesOn = ['notifyCompleted', 'notifyError', 'notifyBlocked', 'notifyApproval',
6413
- 'notifyAbortedByUser', 'notifyInterrupted', 'notifyMaxTokens']
6414
- .filter((key) => draft[key] === true).length;
6484
+ const outcomesOn = OUTCOME_FIELDS.filter((item) => draft[item.field] === true).length;
6415
6485
  const preset = matchPreset(draft.smtpHost);
6416
6486
  /** 远程桥接有没有接上;没接上时这一页连草稿都读不到,先不给人按钮。 */
6417
6487
  const bridgeReady = remotes.current !== null;
@@ -6489,8 +6559,7 @@ window.__ModuleLoader__.load({
6489
6559
  if (save.phase === 'saving') return [t('save.saving')];
6490
6560
  if (dirtyCount > 0) return [t('save.dirty', { n: dirtyCount })];
6491
6561
  if (save.phase === 'saved' && save.at > 0) {
6492
- const seconds = Math.max(0, Math.round((now - save.at) / 1000));
6493
- return [seconds === 0 ? t('save.savedJust') : t('save.savedAgo', { n: seconds })];
6562
+ return [agoText(now - save.at, t)];
6494
6563
  }
6495
6564
  return [t('save.clean')];
6496
6565
  };
@@ -6585,7 +6654,8 @@ window.__ModuleLoader__.load({
6585
6654
  const focusMissing = () => {
6586
6655
  focusField(statusKey === 'needRecipients' ? 'dshmn-recipients'
6587
6656
  : (statusKey === 'needHost' ? 'dshmn-smtp-host'
6588
- : (statusKey === 'needUser' ? 'dshmn-smtp-user' : 'dshmn-cred-smtp')));
6657
+ : (statusKey === 'needUser' ? 'dshmn-smtp-user'
6658
+ : (statusKey === 'allOff' ? 'dshmn-master' : 'dshmn-cred-smtp'))));
6589
6659
  };
6590
6660
 
6591
6661
  /**
@@ -6617,35 +6687,56 @@ window.__ModuleLoader__.load({
6617
6687
  return null;
6618
6688
  };
6619
6689
  /**
6620
- * 凭据行。保存前给输入框(这里才是要动手的地方);保存后**不再摆一个空输入框**——
6621
- * 那样只会让人怀疑"是不是没存上"。存好之后是:✓ 已保存 + 更换 + 清除(测试入口全页只在判决条一处)。
6690
+ * 授权码行。一行之内四个元素:状态(✓ 已保存 / 有未保存的授权码 / 还没保存)
6691
+ * + 更换 + 清除 + 发送测试邮件(文字链接)。
6692
+ *
6693
+ * 「怎么拿授权码?」改成行内文字链接——问号按钮随「全页零折叠」一起删了。
6694
+ * **只读密码框保留**:存好之后仍然摆着那一格,让人一眼看见"这里有一份东西"。
6695
+ * 脏态诚实化:草稿里有未保存的授权码(或标了清除)时,绝不再显示「✓ 已保存」。
6622
6696
  * @param {string} fieldId 输入框 id
6623
6697
  * @param {string} labelKey 名称文案键
6624
6698
  * @param {string} ref 凭据引用名(写入目标由调用方给定,硬约束 4)
6625
6699
  * @param {string} hintKey 说明文案键
6626
- * @param {string} helpKey 帮助按钮文案键
6700
+ * @param {string} helpKey 帮助链接文案键
6627
6701
  * @returns {object} React 元素
6628
6702
  */
6629
6703
  const credentialField = (fieldId, labelKey, ref, hintKey, helpKey) => {
6630
6704
  const confirmed = configured(ref);
6631
6705
  // 「更换」只是把输入框展开,不算一项改动;真正待清理的是 pwClear。
6632
6706
  const editing = confirmed !== true || pwEdit[ref] === true;
6633
- // 「怎么拿授权码?」从行内链接收成标题旁的问号:和 8 个事件开关同一套交互,
6634
- // 省一行宽度,把位置让给真正的动作(更换 / 清除 / 发送测试邮件)。
6635
- const helpButton = h('button', {
6636
- type: 'button',
6637
- className: 'dshmn-q',
6638
- 'aria-label': t(helpKey),
6639
- onClick: () => setHelpKind(true),
6640
- }, '?');
6707
+ const drafting = String(pwDraft[ref] ?? '').trim() !== '' || pwClear[ref] === true;
6708
+ const showSaved = confirmed === true && drafting !== true;
6641
6709
  return h('div', { className: 'dshmn-item' },
6642
6710
  h('div', { className: 'dshmn-itemHead' },
6643
6711
  h('span', { className: 'dshmn-labelRow' },
6644
6712
  h('label', { className: 'dshmn-label', htmlFor: fieldId }, t(labelKey)),
6645
- helpButton),
6646
- h('span', { className: 'dshmn-credState', 'data-ok': confirmed ? 'true' : 'false' },
6647
- confirmed ? h('span', { 'aria-hidden': 'true' }, h(CheckedMark, null)) : null,
6648
- confirmed ? t('cred.saved') : t('cred.notSaved'))),
6713
+ h('button', {
6714
+ type: 'button',
6715
+ className: 'dshmn-link',
6716
+ onClick: () => setHelpKind(true),
6717
+ }, t(helpKey))),
6718
+ h('span', { className: 'dshmn-credActions' },
6719
+ h('span', { className: 'dshmn-credState', 'data-ok': showSaved ? 'true' : 'false' },
6720
+ showSaved ? h('span', { 'aria-hidden': 'true' }, h(CheckedMark, null)) : null,
6721
+ showSaved ? t('cred.saved') : (drafting ? t('cred.draft') : t('cred.notSaved'))),
6722
+ confirmed
6723
+ ? h('button', {
6724
+ type: 'button',
6725
+ className: 'dshmn-link',
6726
+ onClick: () => setPwEdit((prev) => ({ ...prev, [ref]: true })),
6727
+ }, t('cred.replace'))
6728
+ : null,
6729
+ confirmed
6730
+ ? h('button', {
6731
+ type: 'button',
6732
+ className: 'dshmn-link',
6733
+ onClick: () => {
6734
+ setPwDraft((prev) => ({ ...prev, [ref]: '' }));
6735
+ setPwClear((prev) => ({ ...prev, [ref]: true }));
6736
+ },
6737
+ }, t('cred.clear'))
6738
+ : null,
6739
+ testButton({ variant: 'link' }))),
6649
6740
  editing
6650
6741
  ? h(TextInput, {
6651
6742
  id: fieldId,
@@ -6677,87 +6768,86 @@ window.__ModuleLoader__.load({
6677
6768
  type: 'button',
6678
6769
  className: 'dshmn-link',
6679
6770
  onClick: () => void refreshCredentials([ref]),
6680
- }, `${t('save.credCheckFailed', { error: credError })},${t('save.reload')}`)),
6681
- h('div', { className: 'dshmn-linkRow' },
6682
- confirmed
6683
- ? h('button', {
6684
- type: 'button',
6685
- className: 'dshmn-link',
6686
- onClick: () => setPwEdit((prev) => ({ ...prev, [ref]: true })),
6687
- }, t('cred.replace'))
6688
- : null,
6689
- confirmed
6690
- ? h('button', {
6691
- type: 'button',
6692
- className: 'dshmn-link',
6693
- onClick: () => {
6694
- setPwDraft((prev) => ({ ...prev, [ref]: '' }));
6695
- setPwClear((prev) => ({ ...prev, [ref]: true }));
6696
- },
6697
- }, t('cred.clear'))
6698
- : null,
6699
- testButton({ variant: 'link' })));
6771
+ }, `${t('save.credCheckFailed', { error: credError })},${t('save.reload')}`)));
6700
6772
  };
6701
6773
 
6702
6774
  /**
6703
- * 一个事件开关的紧凑行:标题 + 问号 + 开关。
6775
+ * 一个事件开关格子:标题 + 开关,说明常显在下面一行(12/18)。
6704
6776
  *
6705
- * 说明不再常显(8 行说明会把这一组撑成一屏),收进问号后面:**点击**开合而不是 hover,
6706
- * 触屏点得到;原生 button + aria-expanded / aria-controls,键盘 tab 得到。
6707
- * 说明文案仍然来自原来那对键(outcomes.*Hint),一个字都没重写。
6777
+ * 全页零折叠:说明不再藏在问号后面,也没有"点一下才知道"的东西。
6778
+ * 8 个格子排成三列网格(窄屏一列),**不分组**——分组标题把并列的开关伪装成
6779
+ * 有层级,用户还得先学会"另外 3 个藏在哪"。
6708
6780
  * @param {string} field 配置字段名
6709
6781
  * @param {string} titleKey 标题文案键
6710
6782
  * @param {string} hintKey 说明文案键
6711
6783
  * @returns {object} React 元素
6712
6784
  */
6713
- const outcomeRow = (field, titleKey, hintKey) => {
6714
- const open = openHints[field] === true;
6715
- const noteId = `dshmn-note-${field}`;
6716
- return h('div', { className: 'dshmn-row', key: field },
6717
- h('div', { className: 'dshmn-rowHead' },
6718
- h('div', { className: 'dshmn-rowTitle' },
6719
- h('span', { className: 'dshmn-rowName' }, t(titleKey)),
6720
- h('button', {
6721
- type: 'button',
6722
- className: 'dshmn-q',
6723
- 'aria-label': t(open ? 'hint.hide' : 'hint.show', { title: t(titleKey) }),
6724
- 'aria-expanded': open ? 'true' : 'false',
6725
- 'aria-controls': noteId,
6726
- onClick: () => setOpenHints((prev) => ({ ...prev, [field]: prev[field] !== true })),
6727
- }, '?')),
6728
- h('div', { className: 'dshmn-rowCtrl' }, h(Switch, {
6729
- checked: draft[field] === true,
6730
- label: t(titleKey),
6731
- onChange: (next) => setField(field, next),
6732
- }))),
6733
- open ? h('p', { className: 'dshmn-rowNote', id: noteId }, t(hintKey)) : null);
6734
- };
6735
-
6736
- /**
6737
- * 「更多」标题上的当前值。
6738
- *
6739
- * 端口 / 加密收进折叠区,但"现在到底用什么"必须一眼看得到:只要这一对值偏离宿主
6740
- * 默认(465 · auto,见 lib/notify/config.js),就把它们摆到标题上。
6741
- */
6742
- const draftPort = draft.smtpPort === undefined || draft.smtpPort === null ? '' : String(draft.smtpPort);
6743
- const draftSecure = String(draft.smtpSecure ?? '');
6744
- const transportTweaked = (draftPort !== '' && Number(draftPort) !== DEFAULT_TRANSPORT.port)
6745
- || (draftSecure !== '' && draftSecure !== DEFAULT_TRANSPORT.secure);
6746
- const transportValue = [draftPort, draftSecure].filter((part) => part !== '').join(' · ');
6747
- const moreTitle = transportTweaked && transportValue !== ''
6748
- ? t('transport.moreWith', { value: transportValue })
6749
- : t('transport.more');
6785
+ const eventCell = (field, titleKey, hintKey) => h('div', { className: 'dshmn-event', key: field },
6786
+ h('label', { className: 'dshmn-eventHead' },
6787
+ h(Check, {
6788
+ checked: draft[field] === true,
6789
+ disabled: draft.enabled === false,
6790
+ label: t(titleKey),
6791
+ 'data-event': field,
6792
+ onChange: (next) => setField(field, next),
6793
+ }),
6794
+ h('span', { className: 'dshmn-eventTitle' }, t(titleKey))),
6795
+ h('p', { className: 'dshmn-eventNote' }, t(hintKey)));
6750
6796
 
6751
6797
  /** 收件人那一组的读数:只说个数,落在段头的小字里(不再占一行正文)。 */
6752
6798
  const recipientsState = recipients.valid.length > 0
6753
6799
  ? t('recipients.ok', { n: recipients.valid.length })
6754
6800
  : t('recipients.none');
6755
6801
 
6802
+ /**
6803
+ * 「恢复全部默认」的入口:宿主给了 Modal 就弹确认框;没给就退回 window.confirm。
6804
+ * 两条路都必须点得动——点了什么都不发生,是这一页最不该有的反应。
6805
+ * @returns {void}
6806
+ */
6807
+ const askReset = () => {
6808
+ if (typeof P.Modal === 'function') {
6809
+ setConfirmReset(true);
6810
+ return;
6811
+ }
6812
+ const accepted = typeof window !== 'undefined' && typeof window.confirm === 'function'
6813
+ ? window.confirm(t('danger.body'))
6814
+ : true;
6815
+ if (accepted) void resetAll();
6816
+ };
6817
+
6818
+ // ---- 判决条第三行的「人话摘要」:跟着草稿实时变(判决口径仍是已保存配置)----
6819
+ /** 服务商:认得出来用预设名,认不出来就是「自定义」。 */
6820
+ const summaryProvider = preset === null ? t('summary.custom') : preset.name;
6821
+ const summaryHost = String(draft.smtpHost ?? '').trim() === ''
6822
+ ? t('summary.noHost')
6823
+ : String(draft.smtpHost).trim();
6824
+ const summaryTo = recipients.valid.length === 0
6825
+ ? t('summary.noRecipient')
6826
+ : (recipients.valid.length === 1
6827
+ ? recipients.valid[0]
6828
+ : t('summary.recipients', { n: recipients.valid.length }));
6829
+ const summaryEventNames = OUTCOME_FIELDS
6830
+ .filter((item) => draft[item.field] === true)
6831
+ .map((item) => t(item.short));
6832
+ const summaryEvents = draft.enabled !== true || summaryEventNames.length === 0
6833
+ ? t('summary.eventsOff')
6834
+ : (summaryEventNames.length > 2
6835
+ ? t('summary.eventsMore', { list: summaryEventNames.slice(0, 2).join('、'), n: summaryEventNames.length })
6836
+ : t('summary.events', { list: summaryEventNames.join('、') }));
6837
+ const summaryLine = t('summary.line', {
6838
+ provider: summaryProvider,
6839
+ host: summaryHost,
6840
+ to: summaryTo,
6841
+ events: summaryEvents,
6842
+ });
6843
+ /** 总开关关着时,8 个事件开关变灰禁用(但不隐藏)。 */
6844
+ const eventsDisabled = draft.enabled === false;
6845
+ /** 页头那组按钮与吸底条共用同一份可用条件(脏读数只在页头)。 */
6846
+ const actionDisabled = dirtyCount === 0 || !bridgeReady || save.phase === 'saving';
6847
+
6756
6848
  return h('div', { className: 'dshmn-page', 'data-build': BUILD },
6757
6849
 
6758
- // ---- 页头:标题独占一行(18/600),右上只有动作组 ----
6759
- // 「脏读数 + 保存 + 放弃改动」是一个动作区:不画线、不放说明、不占第二层高度。
6760
- // 宿主的设置面板头(h54)只放 actions + close,所以内容区第一行就该是标题。
6850
+ // ---- 页头(吸顶):标题 + 一行说明;右侧脏读数 + 保存 + 放弃改动 ----
6761
6851
  h('header', { className: 'dshmn-head' },
6762
6852
  h('div', { className: 'dshmn-headText' },
6763
6853
  h('h2', { className: 'dshmn-title' }, t('nav')),
@@ -6772,18 +6862,18 @@ window.__ModuleLoader__.load({
6772
6862
  variant: 'primary',
6773
6863
  size: 'sm',
6774
6864
  // 没有改动就禁用:主按钮不该在无事可做时还亮着。
6775
- disabled: dirtyCount === 0 || !bridgeReady || save.phase === 'saving',
6865
+ disabled: actionDisabled,
6776
6866
  title: t('act.saveHint'),
6777
6867
  onClick: () => void saveAll(),
6778
6868
  }, t('act.save')),
6779
6869
  h(Button, {
6780
6870
  variant: 'outline',
6781
6871
  size: 'sm',
6782
- disabled: dirtyCount === 0 || !bridgeReady || save.phase === 'saving',
6872
+ disabled: actionDisabled,
6783
6873
  onClick: () => discardAll(),
6784
6874
  }, t('act.discard')))),
6785
6875
 
6786
- // ---- 判决条:全页唯一的填充面(状态色 hero);结论句 + 右下角那处主行动 ----
6876
+ // ---- 判决条:全页唯一的状态色 hero;结论句 + 人话摘要 + 右下角主行动 ----
6787
6877
  h('section', {
6788
6878
  className: 'dshmn-verdict',
6789
6879
  'data-tone': tone,
@@ -6794,6 +6884,8 @@ window.__ModuleLoader__.load({
6794
6884
  h(Dot, { state: tone === 'done' ? 'done' : (tone === 'error' ? 'error' : (tone === 'warning' ? 'warning' : 'idle')) }),
6795
6885
  h('h2', { className: 'dshmn-verdictTitle' }, statusTitle)),
6796
6886
  h('p', { className: 'dshmn-verdictWhy' }, statusWhy),
6887
+ // 人话摘要:一眼看全「哪家邮箱 · 哪个服务器 → 发给谁 · 什么时候发」。
6888
+ h('p', { className: 'dshmn-verdictSummary' }, summaryLine),
6797
6889
  // 口径明写:这句判决算的是**已保存的配置**,屏幕上没保存的改动不算数。
6798
6890
  dirtyCount > 0 ? h('p', { className: 'dshmn-hint' }, t('status.unsavedDraft')) : null,
6799
6891
  testResult(),
@@ -6804,9 +6896,10 @@ window.__ModuleLoader__.load({
6804
6896
  h('div', { className: 'dshmn-item' },
6805
6897
  h('div', { className: 'dshmn-field' },
6806
6898
  h('label', { className: 'dshmn-label', htmlFor: 'dshmn-recipients' }, t('recipients.label')),
6807
- h('textarea', {
6899
+ // 单行输入:粘贴逗号 / 分号 / 换行分隔的多个地址照样一个不丢(解析函数没动)。
6900
+ h(TextInput, {
6808
6901
  id: 'dshmn-recipients',
6809
- className: 'dshmn-textarea',
6902
+ type: 'text',
6810
6903
  value: draft.recipients ?? '',
6811
6904
  placeholder: t('recipients.placeholder'),
6812
6905
  'aria-invalid': recipients.invalid.length > 0 ? 'true' : 'false',
@@ -6817,7 +6910,7 @@ window.__ModuleLoader__.load({
6817
6910
  ? h('div', { className: 'dshmn-error', role: 'alert' }, t('recipients.invalid', { list: recipients.invalid.join('、') }))
6818
6911
  : null))),
6819
6912
 
6820
- // ---- 卡片二:怎么发(只剩 SMTP 一种发送方式,不再让人选通道)----
6913
+ // ---- 卡片二:发信方式(只剩 SMTP 一种发送方式,不再让人选通道)----
6821
6914
  h(Card, { kind: 'transport', title: t('group.transport'), state: transportState },
6822
6915
  h('div', { className: 'dshmn-item' },
6823
6916
  h('div', { className: 'dshmn-hint' }, t('smtp.note')),
@@ -6843,8 +6936,9 @@ window.__ModuleLoader__.load({
6843
6936
  }, t('preset.other'))),
6844
6937
  preset === null ? null : h('div', { className: 'dshmn-hint' }, t('preset.detected', { name: preset.name })),
6845
6938
 
6846
- // 主行:主机 + 用户名(两列)。端口 / 加密折进下面的「更多」。
6847
- h('div', { className: 'dshmn-two' },
6939
+ // 主机 / 端口 / 加密 / 用户名:宽屏一行四列(主机与用户名吃剩余宽度),
6940
+ // 窄屏两行两列。端口与加密**默认可见**——它们是发不发得出去的一部分,不该藏在折叠里。
6941
+ h('div', { className: 'dshmn-smtpGrid' },
6848
6942
  h('div', { className: 'dshmn-field' },
6849
6943
  h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-host' }, t('smtp.host')),
6850
6944
  h(TextInput, {
@@ -6854,6 +6948,33 @@ window.__ModuleLoader__.load({
6854
6948
  placeholder: 'smtp.example.com',
6855
6949
  onChange: (event) => setField('smtpHost', event.target.value),
6856
6950
  })),
6951
+ h('div', { className: 'dshmn-field' },
6952
+ h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-port' }, t('smtp.port')),
6953
+ h(TextInput, {
6954
+ id: 'dshmn-smtp-port',
6955
+ type: 'text',
6956
+ inputMode: 'numeric',
6957
+ value: draft.smtpPort ?? '',
6958
+ placeholder: '465',
6959
+ 'aria-label': t('smtp.port'),
6960
+ onChange: (event) => {
6961
+ const raw = String(event.target.value ?? '');
6962
+ // 端口只收数字。Number('46a') 是 NaN:宿主会拒写,但报的是英文 SDK 错误,
6963
+ // 而且 NaN 跟谁都不相等,脏计数会一直挂着不清。非法字符根本别进草稿——
6964
+ // 受控输入会把框里的内容退回上一次的合法值。
6965
+ if (raw !== '' && !/^\d+$/.test(raw)) return;
6966
+ setField('smtpPort', raw === '' ? '' : Number(raw));
6967
+ },
6968
+ })),
6969
+ h('div', { className: 'dshmn-field' },
6970
+ h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-secure' }, t('smtp.secure')),
6971
+ h('select', {
6972
+ id: 'dshmn-smtp-secure',
6973
+ className: 'dshmn-select',
6974
+ value: draft.smtpSecure ?? 'auto',
6975
+ onChange: (event) => setField('smtpSecure', event.target.value),
6976
+ }, ...[['auto', 'auto'], ['tls', 'tls'], ['starttls', 'starttls'], ['plain', 'plain']]
6977
+ .map(([value, text]) => h('option', { key: value, value }, text)))),
6857
6978
  h('div', { className: 'dshmn-field' },
6858
6979
  h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-user' }, t('smtp.user')),
6859
6980
  h(TextInput, {
@@ -6870,77 +6991,54 @@ window.__ModuleLoader__.load({
6870
6991
  user: String(draft.smtpUser ?? ''),
6871
6992
  suffix: preset?.suffix ?? '',
6872
6993
  }))
6873
- : null)),
6874
-
6875
- // 端口 / 加密:默认收起。收起时标题上写清当前值(偏离默认才有内容),
6876
- // 所以"收起来"不等于"藏起来"——想知道现在用什么,看一眼标题就够。
6877
- h('div', { className: 'dshmn-field' },
6878
- foldHead('dshmn-more-transport', moreTitle, moreOpen, () => setMoreOpen((value) => value !== true)),
6879
- moreOpen !== true
6880
- ? null
6881
- : h('div', { className: 'dshmn-foldBody', id: 'dshmn-more-transport' },
6882
- h('div', { className: 'dshmn-twoPort' },
6883
- h('div', { className: 'dshmn-field' },
6884
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-port' }, t('smtp.port')),
6885
- h(TextInput, {
6886
- id: 'dshmn-smtp-port',
6887
- type: 'text',
6888
- inputMode: 'numeric',
6889
- value: draft.smtpPort ?? '',
6890
- placeholder: '465',
6891
- 'aria-label': t('smtp.port'),
6892
- onChange: (event) => {
6893
- const raw = String(event.target.value ?? '');
6894
- // 端口只收数字。Number('46a') 是 NaN:宿主会拒写,但报的是英文 SDK 错误,
6895
- // 而且 NaN 跟谁都不相等,脏计数会一直挂着不清。非法字符根本别进草稿——
6896
- // 受控输入会把框里的内容退回上一次的合法值。
6897
- if (raw !== '' && !/^\d+$/.test(raw)) return;
6898
- setField('smtpPort', raw === '' ? '' : Number(raw));
6899
- },
6900
- })),
6901
- h('div', { className: 'dshmn-field' },
6902
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-secure' }, t('smtp.secure')),
6903
- h('select', {
6904
- id: 'dshmn-smtp-secure',
6905
- className: 'dshmn-select',
6906
- value: draft.smtpSecure ?? 'auto',
6907
- onChange: (event) => setField('smtpSecure', event.target.value),
6908
- }, ...[['auto', 'auto'], ['tls', 'tls'], ['starttls', 'starttls'], ['plain', 'plain']]
6909
- .map(([value, text]) => h('option', { key: value, value }, text)))))))),
6994
+ : null))),
6910
6995
  credentialField('dshmn-cred-smtp', 'cred.smtp', smtpPasswordRef, 'cred.smtpHint', 'cred.smtpHelp')),
6911
6996
 
6912
- // ---- 卡片三:什么时候发给我(8 个开关单列,宿主同款行;窄屏最稳)----
6913
- // 说明不再常显:每行标题旁一个问号,点开才显示那一行的说明(文案键原样复用)。
6997
+ // ---- 卡片三:通知时机(第一行总开关;下面 8 个事件三列网格,说明常显)----
6914
6998
  h(Card, {
6915
6999
  kind: 'outcomes',
6916
7000
  title: t('group.outcomes'),
6917
7001
  state: outcomesOn > 0 ? t('outcomes.countOn', { n: outcomesOn }) : t('outcomes.countOff'),
6918
7002
  },
6919
- outcomeRow('notifyCompleted', 'outcomes.completed', 'outcomes.completedHint'),
6920
- outcomeRow('notifyError', 'outcomes.error', 'outcomes.errorHint'),
6921
- outcomeRow('notifyBlocked', 'outcomes.blocked', 'outcomes.blockedHint'),
6922
- outcomeRow('notifyApproval', 'outcomes.approval', 'outcomes.approvalHint'),
6923
- outcomeRow('includeSubagents', 'outcomes.subagents', 'outcomes.subagentsHint'),
6924
- h('div', { className: 'dshmn-item' },
6925
- foldHead('dshmn-more-outcomes', t('outcomes.more'), showMoreOutcomes,
6926
- () => setShowMoreOutcomes((value) => value !== true)),
6927
- showMoreOutcomes !== true
6928
- ? null
6929
- : h('div', { className: 'dshmn-foldBody', id: 'dshmn-more-outcomes' },
6930
- outcomeRow('notifyAbortedByUser', 'outcomes.aborted', 'outcomes.abortedHint'),
6931
- outcomeRow('notifyInterrupted', 'outcomes.interrupted', 'outcomes.interruptedHint'),
6932
- outcomeRow('notifyMaxTokens', 'outcomes.maxTokens', 'outcomes.maxTokensHint')))),
6933
-
6934
- // ---- 页脚:清空覆盖是破坏性操作,所以压到最下面、只用一条 12/18 的文字链接 ----
6935
- // 页脚不画线:线只长在行自己身上,页脚不是一段内容。
7003
+ h('div', { className: 'dshmn-master' },
7004
+ h('div', { className: 'dshmn-masterHead' },
7005
+ h('span', { className: 'dshmn-label' }, t('notify.master')),
7006
+ h(Switch, {
7007
+ id: 'dshmn-master',
7008
+ checked: draft.enabled === true,
7009
+ label: t('notify.master'),
7010
+ 'data-master': 'true',
7011
+ onChange: (next) => setField('enabled', next),
7012
+ })),
7013
+ h('p', { className: 'dshmn-hint' }, t('notify.masterHint'))),
7014
+ h('div', { className: 'dshmn-events' },
7015
+ ...OUTCOME_FIELDS.map((item) => eventCell(item.field, item.title, item.hint)))),
7016
+
7017
+ // ---- 页脚:清空覆盖是破坏性操作,压到页面最底部、只用一条文字链接 ----
6936
7018
  h('div', { className: 'dshmn-foot' },
6937
7019
  h('button', {
6938
7020
  type: 'button',
6939
7021
  className: 'dshmn-link',
6940
7022
  disabled: resetting,
6941
- onClick: () => setConfirmReset(true),
7023
+ onClick: askReset,
6942
7024
  }, t('danger.reset'))),
6943
7025
 
7026
+ // ---- 吸底条:常驻底部,只放两个动作(脏读数只在页头)----
7027
+ h('div', { className: 'dshmn-savebar' },
7028
+ h(Button, {
7029
+ variant: 'outline',
7030
+ size: 'sm',
7031
+ disabled: actionDisabled,
7032
+ onClick: () => discardAll(),
7033
+ }, t('act.discard')),
7034
+ h(Button, {
7035
+ variant: 'primary',
7036
+ size: 'sm',
7037
+ disabled: actionDisabled,
7038
+ title: t('act.saveHint'),
7039
+ onClick: () => void saveAll(),
7040
+ }, t('act.save'))),
7041
+
6944
7042
  typeof P.Modal === 'function'
6945
7043
  ? h(P.Modal, {
6946
7044
  open: confirmReset,
@@ -7084,7 +7182,7 @@ window.__ModuleLoader__.load({
7084
7182
  }
7085
7183
  },
7086
7184
  /** 给离线单测用的接线口(浏览器运行时不会读它)。 */
7087
- __internals: { parseRecipients, deriveStatus, matchPreset, presetForAddress, dotState, accountMismatch, userForPreset, PRESETS, ZH, EN, CSS, fmt, diffOps, i18n },
7185
+ __internals: { parseRecipients, deriveStatus, matchPreset, presetForAddress, dotState, accountMismatch, userForPreset, PRESETS, ZH, EN, CSS, fmt, diffOps, agoText, i18n },
7088
7186
  };
7089
7187
  },
7090
7188
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-lookout",
3
3
  "description": "瞭望哨:盯着你的每个会话——谁跑完了、谁出错了、谁卡住了、谁在等你审批,用浏览器图标角标、红点/黄点和邮件通知你;顺带显示当前会话 ID。· Lookout: watches every session — who finished, who errored, who is stuck, and who is waiting for your approval — and notifies you through the browser favicon badge, red/yellow dots, and email, while also showing the current session ID.",
4
- "version": "0.5.0",
4
+ "version": "0.5.1",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",