@cowwo/dsh-lookout 0.2.0 → 0.5.0

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/client/client.js CHANGED
@@ -5039,13 +5039,22 @@ window.__ModuleLoader__.load({
5039
5039
  //
5040
5040
  // 这一页只回答两个问题:
5041
5041
  // 1. 配好了吗 → 分组状态点(已配置 / 未配置 / 不适用)
5042
- // 2. 能发出去吗 → 顶部状态条 + 发送测试邮件 + 最近一次投递结果
5042
+ // 2. 能发出去吗 → 顶部状态条 + 发送测试邮件(点了当场回执成败与原始报错)
5043
5043
  // 其余一切让路:不额外套卡片、不加编号、不做中英同屏。
5044
5044
  //
5045
- // 编辑模型:**没有保存/放弃按钮,改完即时生效**。按字段类型区别对待:
5046
- // - 开关 / 下拉 / 分段:立刻写
5047
- // - 数字 / 文本:防抖(停手再写)+ 失焦兜底
5048
- // - 密码:只在失焦或回车时写,且**每个凭据引用名各写各的**
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 线),说明由问号**点击**展开。
5052
+ // 字号阶梯全部落在宿主网格上(只用整像素档,不再有半档字号);圆角只用 4/8/12/16/20/28。
5053
+ //
5054
+ // 编辑模型:**显式保存**。所有控件只改本地草稿,点「保存」才一次性写回:
5055
+ // - 配置字段:与基线不同的项合成一次 mutate(found.ns, ops, revision)
5056
+ // - 授权码:草稿只活在内存里,保存成功时才写凭据库(写入目标由那一行决定)
5057
+ // - 「放弃改动」把草稿整体回滚到基线;「恢复全部默认」不受保存按钮约束,立即生效
5049
5058
  //
5050
5059
  // 六条硬约束(前三条踩过,后三条是本轮修掉的):
5051
5060
  // 1. 注册页面只依赖 `slots`。远程命名空间 / locale 各开一个非阻塞的 ctx.inject 去填;
@@ -5077,24 +5086,25 @@ window.__ModuleLoader__.load({
5077
5086
  /** 只等必需服务:页面注册本身只需要 slots(硬约束 1)。 */
5078
5087
  const inject = ['slots'];
5079
5088
 
5080
- const NS = 'dsh-notify-p';
5089
+ /**
5090
+ * 文案字典的命名空间(宿主的 profile 入口 id)。
5091
+ *
5092
+ * **不是设置命名空间**:配置的 ns 每次都由 describe() 返回项里的 schema 签名认领
5093
+ * (见 load()),客户端不允许写死——profile 入口 id 换了,这里的所有读写都得跟着换。
5094
+ */
5095
+ const LOCALE_NS = 'lookout';
5081
5096
 
5082
5097
  /** Host Remote 的命名空间(与 lib/typert.host.js 清单一致)。 */
5083
5098
  const REMOTE_NS = 'mailNotify';
5084
5099
 
5085
- /** 文本 / 数字字段的防抖时长(毫秒)。 */
5086
- const TEXT_DEBOUNCE_MS = 600;
5087
- const NUMBER_DEBOUNCE_MS = 500;
5088
-
5089
5100
  // ---------------------------------------------------------------------------
5090
5101
  // 文案:zh 是内置兜底,en 走 locale 字典;界面永远只显示一种语言(不同屏并列)。
5091
5102
  // ---------------------------------------------------------------------------
5092
5103
  const ZH = {
5093
5104
  'group.status': '投递状态',
5094
- 'evidence.label': '最近一次发送',
5095
- 'evidence.never': '还没有试过',
5096
- 'evidence.ok': '成功,用时 {ms} 毫秒,{ago}',
5097
- 'evidence.fail': '失败',
5105
+ 'test.sent': '已发送',
5106
+ 'test.sentMs': '用时 {ms} 毫秒',
5107
+ 'test.failed': '这次没发出去',
5098
5108
  'cred.saved': '已保存',
5099
5109
  'cred.notSaved': '还没保存',
5100
5110
  'cred.replace': '更换',
@@ -5103,7 +5113,7 @@ window.__ModuleLoader__.load({
5103
5113
  'err.auth': '服务端拒绝了这组账号密码',
5104
5114
  'err.authHint': '多半是授权码不对或过期了:QQ 邮箱要的是「授权码」而不是登录密码,重新生成一个再填。',
5105
5115
  'err.rejected': '发件人被拒',
5106
- 'err.rejectedHint': '收件服务器不接受这个发件人。换个发件地址(一般在「发件人与链接」里)再试。',
5116
+ 'err.rejectedHint': '收件服务器不接受这个发件人。发件地址由插件按服务端设置决定,确认这个邮箱被允许用来发信后再试。',
5107
5117
  'err.network': '连不上服务器',
5108
5118
  'err.networkHint': '检查主机名与端口;本机防火墙或公司网络也可能挡住 465 / 587。',
5109
5119
  'err.other': '发送失败',
@@ -5112,11 +5122,24 @@ window.__ModuleLoader__.load({
5112
5122
  nav: '邮件通知',
5113
5123
  lead: '会话干完活、出错或卡住时,给你发一封邮件。',
5114
5124
 
5115
- 'save.auto': '改动即时生效',
5116
- 'save.saving': '正在保存…',
5117
- 'save.savedAgo': '已保存,{n} 秒前',
5125
+ 'save.clean': '没有未保存的改动',
5126
+ 'save.dirty': '有 {n} 项改动未保存',
5127
+ 'save.saving': '保存中…',
5128
+ 'save.savedJust': '已保存 · 刚刚',
5129
+ 'save.savedAgo': '已保存 · {n} 秒前',
5118
5130
  'save.error': '保存失败:{error}',
5131
+ 'save.retry': '重试',
5119
5132
  'save.reload': '重新读取',
5133
+ // 读失败是**独立的一档**:它不是"保存失败",重试也不该走保存那条路。
5134
+ 'save.loadFailed': '读不到本插件的配置:{error}',
5135
+ // 冲突恢复:别人先改了 → 已自动重读,草稿没动,让用户再点一次。
5136
+ 'save.conflictReloaded': '配置已被别处改动,已读到最新值,请再点一次保存',
5137
+ // 半成功:必须点出**哪一半**成功、哪一半没成,别用一句"保存失败"把成功的半吞掉。
5138
+ 'save.credFailed': '配置已保存,但授权码没写进去:{error}',
5139
+ 'save.configFailed': '授权码已保存,但配置没写进去:{error}',
5140
+ 'save.credAlsoFailed': '授权码也没写进去:{error}',
5141
+ 'save.retryCred': '重试授权码',
5142
+ 'save.retryConfig': '重试配置',
5120
5143
  'save.checkingCred': '正在确认授权码是否已写入…',
5121
5144
  'save.credCheckFailed': '凭据状态读取失败:{error}',
5122
5145
 
@@ -5126,7 +5149,6 @@ window.__ModuleLoader__.load({
5126
5149
  'status.needUser': '还差 SMTP 用户名',
5127
5150
  'status.needCredential': '还差{cred}',
5128
5151
  'status.ready': '可以发信了',
5129
- 'status.lastFailed': '上次发信失败',
5130
5152
  'status.notServed': '这一页读不到本插件的配置',
5131
5153
 
5132
5154
  // 判决下面那句人话。只说用户的事,不说实现的事。
@@ -5136,7 +5158,8 @@ window.__ModuleLoader__.load({
5136
5158
  'status.why.needUser': '多数邮箱要求用户名和授权码成对出现。',
5137
5159
  'status.why.needCredential': '没填它,服务端会拒收这封信。',
5138
5160
  'status.why.ready': '配置齐了。发一封测试邮件,确认服务端真的收下。',
5139
- 'status.why.lastFailed': '配置看着没问题,但上一次没发出去。',
5161
+ // 判决条只按已保存的配置判定,这句把口径说透,免得"我明明填好了"变成困惑。
5162
+ 'status.unsavedDraft': '未保存的改动不参与判定。',
5140
5163
 
5141
5164
  'act.enable': '启用',
5142
5165
  'act.goFill': '去填写',
@@ -5144,15 +5167,15 @@ window.__ModuleLoader__.load({
5144
5167
  'act.testing': '正在发送…',
5145
5168
  'act.retest': '重新测试',
5146
5169
  'act.needsRestart': '需要重启 DSH 后才能测试',
5147
- 'act.testHint': '按现在的配置真发一封到收件人,不用等某个会话干完。',
5170
+ // 明说用**哪一份**配置:测试走的是已保存配置,草稿还没落盘,别让人以为测的是屏幕上这些字。
5171
+ 'act.testHint': '用已保存的配置真发一封到收件人,不用等某个会话干完。',
5172
+ 'act.save': '保存',
5173
+ 'act.discard': '放弃改动',
5174
+ 'act.saveHint': '把这一页的改动一次性写回配置。',
5148
5175
 
5149
5176
  'group.recipients': '收件人',
5150
5177
  'group.transport': '怎么发',
5151
5178
  'group.outcomes': '什么时候发给我',
5152
- 'group.optional': '可选设置',
5153
- 'group.sender': '发件人与链接',
5154
- 'group.advanced': '高级',
5155
-
5156
5179
 
5157
5180
  'recipients.label': '收件邮箱',
5158
5181
  'recipients.placeholder': 'you@qq.com',
@@ -5161,12 +5184,6 @@ window.__ModuleLoader__.load({
5161
5184
  'recipients.none': '还没填',
5162
5185
  'recipients.invalid': '格式不对,不会被发送:{list}',
5163
5186
 
5164
- 'time.justNow': '刚刚',
5165
- 'time.secondsAgo': '{n} 秒前',
5166
- 'time.minutesAgo': '{n} 分钟前',
5167
- 'time.hoursAgo': '{n} 小时前',
5168
- 'time.daysAgo': '{n} 天前',
5169
-
5170
5187
  'transportState.done': '已配好',
5171
5188
  'transportState.todo': '还没配好',
5172
5189
 
@@ -5175,6 +5192,12 @@ window.__ModuleLoader__.load({
5175
5192
  'preset.label': '邮箱服务商',
5176
5193
  'preset.other': '其他',
5177
5194
  'preset.detected': '已按「{name}」填好',
5195
+ // 端口 / 加密收进「更多」:收起来不等于藏起来——偏离宿主默认就把现值摆到标题上。
5196
+ 'transport.more': '更多',
5197
+ 'transport.moreWith': '更多({value})',
5198
+ // 事件开关的问号按钮:只做可访问名(说明正文仍走 outcomes.*Hint)。
5199
+ 'hint.show': '展开「{title}」的说明',
5200
+ 'hint.hide': '收起「{title}」的说明',
5178
5201
 
5179
5202
  'smtp.host': 'SMTP 主机',
5180
5203
  'smtp.port': '端口',
@@ -5183,7 +5206,7 @@ window.__ModuleLoader__.load({
5183
5206
  'smtp.accountMismatch': '{host} 是{name}的服务器,但账号是 {user}:对不上,认证会失败。把账号换成 {suffix} 结尾的邮箱,或把服务商改成账号所属的那一家。',
5184
5207
 
5185
5208
  'cred.smtp': 'SMTP 授权码',
5186
- 'cred.smtpHint': '不是登录密码。只写进凭据库,永不回显;离开输入框时保存。',
5209
+ 'cred.smtpHint': '不是登录密码。只写进凭据库,永不回显;点「保存」时才写入。',
5187
5210
  'cred.smtpHelp': '怎么拿授权码?',
5188
5211
  'cred.smtpHelpTitle': '怎么拿 SMTP 授权码',
5189
5212
  'cred.smtpHelpBody': '授权码不是登录密码,是邮箱服务商单独发的口令(QQ 是 16 位)。\n\nQQ 邮箱:设置 → 账户 → 开启「POP3/SMTP 服务」→ 生成授权码。\n163 / 126:设置 → POP3/SMTP/IMAP → 开启服务 → 新增授权码。\nGmail:账号 → 安全性 → 两步验证 → 应用专用密码。\niCloud:Apple ID → 登录与安全 → App 专用密码。\nOutlook:账户 → 安全性 → 高级安全选项 → 应用密码。',
@@ -5207,52 +5230,12 @@ window.__ModuleLoader__.load({
5207
5230
  'outcomes.interruptedHint': 'DSH 重启 / 热更新 / 被父级取消。',
5208
5231
  'outcomes.maxTokens': '达到长度上限',
5209
5232
  'outcomes.maxTokensHint': '回复被长度上限截断。',
5210
- 'outcomes.minDuration': '完成时长下限',
5211
- 'outcomes.minDurationHint': '短于这个时长的完成不发信;只对「任务完成」生效,出错、卡住、审批不受此限。',
5212
5233
  'outcomes.subagents': '也通知子代理会话',
5213
5234
  'outcomes.subagentsHint': '一个 workflow 能扇出几十个子会话,默认不打扰。',
5214
5235
 
5215
- 'sender.name': '发件人显示名',
5216
- 'sender.nameHint': '收件人看到的发件人名字,留空就用默认名。',
5217
- 'sender.address': '发件人地址',
5218
- 'sender.addressHint': '必须是能用的邮箱地址;格式不对或与清单里已有的重复,会在弹层里就地报错。',
5219
- 'sender.listLabel': '发件人',
5220
- 'sender.listHint': '这里保存所有发件人,发信时用选中的那一条。点某一行选中它,✎ 改,× 删。',
5221
- 'sender.empty': '还没有保存发件人 · 将回退到默认发件人',
5222
- 'sender.add': '+ 添加发件人',
5223
- 'sender.addTitle': '添加发件人',
5224
- 'sender.editTitle': '编辑发件人',
5225
- 'sender.save': '保存',
5226
- 'sender.cancel': '取消',
5227
- 'sender.inUse': '发信时用它',
5228
- 'sender.edit': '编辑',
5229
- 'sender.remove': '删除',
5230
- 'sender.nameUnset': '(用默认显示名)',
5231
- 'sender.useThis': '用 {address} 发信',
5232
- 'sender.issue.needAddress': '请填发件人地址',
5233
- 'sender.issue.badAddress': '地址格式不对,不会被接受',
5234
- 'sender.issue.duplicate': '这个地址已经在清单里了',
5235
-
5236
- 'link.enabled': '邮件里带直达会话链接',
5237
- 'link.enabledHint': '需要另装深链插件才能被识别。',
5238
- 'link.base': '链接对外地址',
5239
- 'link.baseHint': '留空则读 DSH_WEB_URL;手机要能点开就填局域网地址或域名。',
5240
-
5241
- 'adv.settle': '合并窗口',
5242
- 'adv.settleHint': '毫秒。期间同一会话又开新轮,就合并成一封。',
5243
- 'adv.rate': '发送速率上限',
5244
- 'adv.rateHint': '封/分钟,多会话并停时保护邮箱服务商。',
5245
- 'adv.pwRef': 'SMTP 凭据引用名',
5246
- 'adv.pwRefHint': '凭据库里的键名,改这里不会动已存的值。',
5247
- 'adv.statePath': '投递账本路径',
5248
- 'adv.statePathHint': '留空 = $DSH_HOME/dsh-notify-p/state.json。排错时 cat 它。',
5249
-
5250
- 'unit.ms': '毫秒',
5251
- 'unit.perMin': '封/分钟',
5252
-
5253
5236
  'danger.reset': '恢复全部默认',
5254
5237
  'danger.title': '恢复全部默认?',
5255
- 'danger.body': '这会清掉本插件在 settings.yaml 里的全部覆盖(收件人、事件开关、节奏参数都会回到默认)。',
5238
+ 'danger.body': '这会清掉本插件在 profile 里的全部覆盖(收件人、事件开关、节奏参数都会回到默认),并立即生效——它不受「保存」按钮约束,这一页还没保存的草稿也会一起丢掉。',
5256
5239
  'danger.keep': '凭据库里已存的授权码不受影响。',
5257
5240
  'danger.ok': '恢复默认',
5258
5241
  'danger.cancel': '取消',
@@ -5260,10 +5243,9 @@ window.__ModuleLoader__.load({
5260
5243
 
5261
5244
  const EN = {
5262
5245
  'group.status': 'Delivery status',
5263
- 'evidence.label': 'Last send',
5264
- 'evidence.never': 'Not tried yet',
5265
- 'evidence.ok': 'Sent in {ms} ms, {ago}',
5266
- 'evidence.fail': 'Failed',
5246
+ 'test.sent': 'Sent',
5247
+ 'test.sentMs': 'in {ms} ms',
5248
+ 'test.failed': 'This send did not go out',
5267
5249
  'cred.saved': 'Saved',
5268
5250
  'cred.notSaved': 'Not saved yet',
5269
5251
  'cred.replace': 'Replace',
@@ -5272,7 +5254,7 @@ window.__ModuleLoader__.load({
5272
5254
  'err.auth': 'The server rejected these credentials',
5273
5255
  'err.authHint': 'Usually a wrong or expired authorization code: QQ Mail needs an app code, not your login password. Generate a new one and paste it here.',
5274
5256
  'err.rejected': 'Sender rejected',
5275
- 'err.rejectedHint': 'The receiving server will not accept this sender. Change the From address under "Sender and links" and retry.',
5257
+ 'err.rejectedHint': 'The receiving server will not accept this sender. The From address comes from the plugin’s server-side settings; check that the mailbox is allowed to send.',
5276
5258
  'err.network': 'Cannot reach the server',
5277
5259
  'err.networkHint': 'Check the host and port; a local firewall or corporate network may block 465 / 587.',
5278
5260
  'err.other': 'Send failed',
@@ -5281,11 +5263,21 @@ window.__ModuleLoader__.load({
5281
5263
  nav: 'Mail notification',
5282
5264
  lead: 'Get one email when a session finishes, fails, or gets stuck.',
5283
5265
 
5284
- 'save.auto': 'Changes apply immediately',
5266
+ 'save.clean': 'No unsaved changes',
5267
+ 'save.dirty': 'Unsaved changes: {n}',
5285
5268
  'save.saving': 'Saving…',
5286
- 'save.savedAgo': 'Saved, {n}s ago',
5269
+ 'save.savedJust': 'Saved · just now',
5270
+ 'save.savedAgo': 'Saved · {n}s ago',
5287
5271
  'save.error': 'Save failed: {error}',
5272
+ 'save.retry': 'Retry',
5288
5273
  'save.reload': 'Reload',
5274
+ 'save.loadFailed': 'Cannot read this plugin’s settings: {error}',
5275
+ 'save.conflictReloaded': 'The settings were changed elsewhere. The latest values are loaded — press Save again.',
5276
+ 'save.credFailed': 'Settings saved, but the authorization code was not written: {error}',
5277
+ 'save.configFailed': 'Authorization code saved, but the settings were not written: {error}',
5278
+ 'save.credAlsoFailed': 'The authorization code was not written either: {error}',
5279
+ 'save.retryCred': 'Retry the code',
5280
+ 'save.retryConfig': 'Retry the settings',
5289
5281
  'save.checkingCred': 'Checking whether the code was written…',
5290
5282
  'save.credCheckFailed': 'Could not read the credential state: {error}',
5291
5283
 
@@ -5295,7 +5287,6 @@ window.__ModuleLoader__.load({
5295
5287
  'status.needUser': 'SMTP user name missing',
5296
5288
  'status.needCredential': '{cred} missing',
5297
5289
  'status.ready': 'Ready to send',
5298
- 'status.lastFailed': 'The last send failed',
5299
5290
  'status.notServed': 'This page cannot read the plugin settings',
5300
5291
 
5301
5292
  'status.why.disabled': 'Turn on any case below and notifications start.',
@@ -5304,7 +5295,7 @@ window.__ModuleLoader__.load({
5304
5295
  'status.why.needUser': 'Most providers want the user name and the authorization code together.',
5305
5296
  'status.why.needCredential': 'Without it the server will refuse the message.',
5306
5297
  'status.why.ready': 'Configuration is complete. Send a test email to confirm the server accepts it.',
5307
- 'status.why.lastFailed': 'The configuration looks fine, but the last attempt did not go out.',
5298
+ 'status.unsavedDraft': 'Unsaved changes are not part of this verdict.',
5308
5299
 
5309
5300
  'act.enable': 'Turn on',
5310
5301
  'act.goFill': 'Fill it in',
@@ -5312,15 +5303,14 @@ window.__ModuleLoader__.load({
5312
5303
  'act.testing': 'Sending…',
5313
5304
  'act.retest': 'Test again',
5314
5305
  'act.needsRestart': 'Restart DSH to test',
5315
- 'act.testHint': 'Really sends one email to the recipients right now, without waiting for a session to finish.',
5306
+ 'act.testHint': 'Really sends one email to the recipients with the saved configuration, without waiting for a session to finish.',
5307
+ 'act.save': 'Save',
5308
+ 'act.discard': 'Discard changes',
5309
+ 'act.saveHint': 'Writes every change on this page back in one go.',
5316
5310
 
5317
5311
  'group.recipients': 'Recipients',
5318
5312
  'group.transport': 'How to send',
5319
5313
  'group.outcomes': 'When to notify me',
5320
- 'group.optional': 'Optional',
5321
- 'group.sender': 'Sender and links',
5322
- 'group.advanced': 'Advanced',
5323
-
5324
5314
 
5325
5315
  'recipients.label': 'Recipient addresses',
5326
5316
  'recipients.placeholder': 'you@qq.com, one per line',
@@ -5329,12 +5319,6 @@ window.__ModuleLoader__.load({
5329
5319
  'recipients.none': 'None yet',
5330
5320
  'recipients.invalid': 'Bad format, will not be sent: {list}',
5331
5321
 
5332
- 'time.justNow': 'just now',
5333
- 'time.secondsAgo': '{n}s ago',
5334
- 'time.minutesAgo': '{n} min ago',
5335
- 'time.hoursAgo': '{n} h ago',
5336
- 'time.daysAgo': '{n} d ago',
5337
-
5338
5322
  'transportState.done': 'Configured',
5339
5323
  'transportState.todo': 'Not configured yet',
5340
5324
 
@@ -5343,6 +5327,10 @@ window.__ModuleLoader__.load({
5343
5327
  'preset.label': 'Mail provider',
5344
5328
  'preset.other': 'Other',
5345
5329
  '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}”',
5346
5334
 
5347
5335
  'smtp.host': 'SMTP host',
5348
5336
  'smtp.port': 'Port',
@@ -5351,7 +5339,7 @@ window.__ModuleLoader__.load({
5351
5339
  'smtp.accountMismatch': '{host} is {name}’s server but the account is {user} — they do not match, so authentication will fail. Use a {suffix} address, or switch the provider to the account’s own.',
5352
5340
 
5353
5341
  'cred.smtp': 'SMTP authorization code',
5354
- 'cred.smtpHint': 'Not your login password. Written to the credential store only, never echoed; saved when you leave the field.',
5342
+ 'cred.smtpHint': 'Not your login password. Written to the credential store only, never echoed; written when you press Save.',
5355
5343
  'cred.smtpHelp': 'How do I get one?',
5356
5344
  'cred.smtpHelpTitle': 'Getting an SMTP authorization code',
5357
5345
  '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.',
@@ -5375,52 +5363,12 @@ window.__ModuleLoader__.load({
5375
5363
  'outcomes.interruptedHint': 'Harness restart / hot reload / cancelled by a parent.',
5376
5364
  'outcomes.maxTokens': 'Token limit reached',
5377
5365
  'outcomes.maxTokensHint': 'The reply was cut off by the length limit.',
5378
- 'outcomes.minDuration': 'Minimum duration',
5379
- 'outcomes.minDurationHint': 'Completions shorter than this are not sent; applies to “Task completed” only — failures, blocks and approvals always get through.',
5380
5366
  'outcomes.subagents': 'Also notify subagent sessions',
5381
5367
  'outcomes.subagentsHint': 'One workflow can fan out dozens of sub-sessions; off by default.',
5382
5368
 
5383
- 'sender.name': 'Sender display name',
5384
- 'sender.nameHint': 'The name your recipients see; leave empty to use the default.',
5385
- 'sender.address': 'From address',
5386
- 'sender.addressHint': 'Must be a usable email address; a bad one or a duplicate is reported in the dialog.',
5387
- 'sender.listLabel': 'Senders',
5388
- 'sender.listHint': 'All senders are kept here; the selected one is used to send. Click a row to select it, ✎ to edit, × to remove.',
5389
- 'sender.empty': 'No sender saved yet · will fall back to the default sender',
5390
- 'sender.add': '+ Add sender',
5391
- 'sender.addTitle': 'Add sender',
5392
- 'sender.editTitle': 'Edit sender',
5393
- 'sender.save': 'Save',
5394
- 'sender.cancel': 'Cancel',
5395
- 'sender.inUse': 'In use',
5396
- 'sender.edit': 'Edit',
5397
- 'sender.remove': 'Remove',
5398
- 'sender.nameUnset': '(uses the default name)',
5399
- 'sender.useThis': 'Send from {address}',
5400
- 'sender.issue.needAddress': 'A from address is required',
5401
- 'sender.issue.badAddress': 'Not a valid email address',
5402
- 'sender.issue.duplicate': 'This address is already in the list',
5403
-
5404
- 'link.enabled': 'Include a link to the session',
5405
- 'link.enabledHint': 'Needs a deep-link plugin installed to be recognized.',
5406
- 'link.base': 'Public base URL',
5407
- 'link.baseHint': 'Empty reads DSH_WEB_URL. To open links on your phone, put a LAN address or domain here.',
5408
-
5409
- 'adv.settle': 'Coalesce window',
5410
- 'adv.settleHint': 'Milliseconds. A new turn in the same session during this window is folded into one email.',
5411
- 'adv.rate': 'Send rate limit',
5412
- 'adv.rateHint': 'Emails per minute; protects your mail provider when many sessions stop at once.',
5413
- 'adv.pwRef': 'SMTP credential reference',
5414
- 'adv.pwRefHint': 'The key name in the credential store. Renaming does not touch the stored value.',
5415
- 'adv.statePath': 'Delivery ledger path',
5416
- 'adv.statePathHint': 'Empty = $DSH_HOME/dsh-notify-p/state.json. cat it when debugging.',
5417
-
5418
- 'unit.ms': 'ms',
5419
- 'unit.perMin': 'per min',
5420
-
5421
5369
  'danger.reset': 'Reset everything to defaults',
5422
5370
  'danger.title': 'Reset everything to defaults?',
5423
- 'danger.body': 'This clears every override this plugin stored in settings.yaml — recipients, event switches and pacing all return to defaults.',
5371
+ 'danger.body': 'This clears every override this plugin stored in the profile — recipients, event switches and pacing all return to defaults — and takes effect immediately, outside the Save button. Unsaved drafts on this page are dropped as well.',
5424
5372
  'danger.keep': 'Stored authorization codes are not affected.',
5425
5373
  'danger.ok': 'Reset',
5426
5374
  'danger.cancel': 'Cancel',
@@ -5464,6 +5412,12 @@ window.__ModuleLoader__.load({
5464
5412
  { id: 'icloud', name: 'iCloud', host: 'smtp.mail.me.com', port: 587, secure: 'starttls', suffix: '@icloud.com', domains: ['icloud.com', 'me.com', 'mac.com'] },
5465
5413
  ];
5466
5414
 
5415
+ /**
5416
+ * 端口 / 加密的宿主默认值(逐字对齐 lib/notify/config.js:端口 465、加密 auto)。
5417
+ * 「更多」折叠标题靠它判断这一对值是不是偏离默认,偏离才在后面写当前值。
5418
+ */
5419
+ const DEFAULT_TRANSPORT = { port: 465, secure: 'auto' };
5420
+
5467
5421
  /**
5468
5422
  * 解析收件人:一行一个(兼容逗号/分号),去空、去重、丢弃明显不含 @ 的项。
5469
5423
  * 规则必须与 Host 侧 lib/config.js 的 parseRecipients 保持一致。
@@ -5487,97 +5441,6 @@ window.__ModuleLoader__.load({
5487
5441
  return { valid, invalid };
5488
5442
  }
5489
5443
 
5490
- /**
5491
- * 归一化发件人清单,并把老配置迁移成清单。
5492
- * 规则必须与 Host 侧 lib/config.js 的 senderList 保持一致(迁移、去空、去重)。
5493
- * @param {object} config 当前草稿
5494
- * @returns {{ name: string, address: string }[]} 清单(可能为空)
5495
- */
5496
- function migrateSenders(config) {
5497
- const raw = Array.isArray(config?.senders) ? config.senders : [];
5498
- const list = [];
5499
- const seen = new Set();
5500
- for (const entry of raw) {
5501
- const address = String(entry?.address ?? '').trim();
5502
- if (address === '') continue;
5503
- const key = address.toLowerCase();
5504
- if (seen.has(key)) continue;
5505
- seen.add(key);
5506
- list.push({ name: String(entry?.name ?? '').trim(), address });
5507
- }
5508
- if (list.length > 0) return list;
5509
- const address = String(config?.fromAddress ?? '').trim();
5510
- if (address === '') return [];
5511
- return [{ name: String(config?.senderName ?? '').trim(), address }];
5512
- }
5513
-
5514
- /**
5515
- * 哪一条是当前生效的。
5516
- * 与 Host 侧 resolveSender 同规则:按**地址**匹配,找不到就退回第一条。
5517
- * @param {object} config 当前草稿
5518
- * @param {{name: string, address: string}[]} list 已归一化的清单
5519
- * @returns {string} 生效的地址;清单为空时 ''
5520
- */
5521
- function activeSenderAddress(config, list) {
5522
- const wanted = String(config?.activeSender ?? '').trim().toLowerCase();
5523
- const hit = list.find((entry) => entry.address.toLowerCase() === wanted);
5524
- return (hit ?? list[0])?.address ?? '';
5525
- }
5526
-
5527
- /**
5528
- * 校验一条发件人。
5529
- * @param {{name: string, address: string}[]} list 现有清单
5530
- * @param {number} editing 正在编辑的下标;等于 list.length 表示新增
5531
- * @param {{name: string, address: string}} value 待保存的值
5532
- * @returns {''|'needAddress'|'badAddress'|'duplicate'} '' 表示通过
5533
- */
5534
- function senderIssue(list, editing, value) {
5535
- const address = String(value?.address ?? '').trim();
5536
- if (address === '') return 'needAddress';
5537
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)) return 'badAddress';
5538
- const key = address.toLowerCase();
5539
- for (let i = 0; i < list.length; i += 1) {
5540
- if (i !== editing && list[i].address.toLowerCase() === key) return 'duplicate';
5541
- }
5542
- return '';
5543
- }
5544
-
5545
- /**
5546
- * 保存一条(新增或就地修改)。
5547
- * 新增的**自动成为生效项**;改地址时若它正是生效项,生效项跟着新地址走。
5548
- * @param {{list: object[], active: string, editing: number}} state 当前状态
5549
- * @param {{name: string, address: string}} value 待保存的值
5550
- * @returns {{list: object[], active: string}} 新状态
5551
- */
5552
- function senderCommit(state, value) {
5553
- const list = state.list.map((entry) => ({ ...entry }));
5554
- const name = String(value?.name ?? '').trim();
5555
- const address = String(value?.address ?? '').trim();
5556
- if (state.editing >= list.length) {
5557
- list.push({ name, address });
5558
- return { list, active: address };
5559
- }
5560
- const before = list[state.editing].address;
5561
- list[state.editing] = { name, address };
5562
- return { list, active: state.active === before ? address : state.active };
5563
- }
5564
-
5565
- /**
5566
- * 删一条,并决定谁来接替生效位。
5567
- * 删掉的正是生效项时,选它下面那条(没有下面则选最后一条);删光则清空生效位。
5568
- * @param {{list: object[], active: string}} state 当前状态
5569
- * @param {number} index 要删的下标
5570
- * @returns {{list: object[], active: string}} 新状态
5571
- */
5572
- function senderRemove(state, index) {
5573
- const list = state.list.map((entry) => ({ ...entry }));
5574
- const wasActive = list[index].address === state.active;
5575
- list.splice(index, 1);
5576
- if (list.length === 0) return { list, active: '' };
5577
- if (!wasActive) return { list, active: state.active };
5578
- return { list, active: list[Math.min(index, list.length - 1)].address };
5579
- }
5580
-
5581
5444
  /**
5582
5445
  * 把投递失败的原因翻译成「该动哪里」。
5583
5446
  *
@@ -5605,25 +5468,6 @@ window.__ModuleLoader__.load({
5605
5468
  };
5606
5469
  }
5607
5470
 
5608
- /**
5609
- * 「多久以前」的人话。只给投递结果用,所以粒度到分钟就够。
5610
- * @param {unknown} at 时间戳(毫秒)
5611
- * @param {number} now 当前时间戳
5612
- * @returns {string} 如「刚刚」「3 分钟前」
5613
- */
5614
- function relativeTime(at, now) {
5615
- const stamp = typeof at === 'number' && Number.isFinite(at) ? at : null;
5616
- if (stamp === null) return '';
5617
- const seconds = Math.max(0, Math.round((now - stamp) / 1000));
5618
- if (seconds < 10) return i18n.t('time.justNow');
5619
- if (seconds < 60) return i18n.t('time.secondsAgo', { n: seconds });
5620
- const minutes = Math.round(seconds / 60);
5621
- if (minutes < 60) return i18n.t('time.minutesAgo', { n: minutes });
5622
- const hours = Math.round(minutes / 60);
5623
- if (hours < 24) return i18n.t('time.hoursAgo', { n: hours });
5624
- return i18n.t('time.daysAgo', { n: Math.round(hours / 24) });
5625
- }
5626
-
5627
5471
  /**
5628
5472
  * 按主机名匹配预设。
5629
5473
  * @param {unknown} host SMTP 主机
@@ -5679,14 +5523,13 @@ window.__ModuleLoader__.load({
5679
5523
  return hit === undefined ? '' : hit;
5680
5524
  }
5681
5525
 
5682
- /** 状态条的取值顺序:先看能不能发,再看有没有真的发出去过。 */
5526
+ /** 状态条的取值只由「配置齐不齐」决定;投递历史已随账本一起删除。 */
5683
5527
  const STATUS_TONE = {
5684
5528
  disabled: 'idle',
5685
5529
  needRecipients: 'warning',
5686
5530
  needHost: 'warning',
5687
5531
  needUser: 'warning',
5688
5532
  needCredential: 'warning',
5689
- lastFailed: 'error',
5690
5533
  ready: 'done',
5691
5534
  };
5692
5535
 
@@ -5694,22 +5537,43 @@ window.__ModuleLoader__.load({
5694
5537
  * 推导状态条要显示哪个结论。纯函数,输入全部来自草稿与凭据状态。
5695
5538
  * SMTP 是唯一的发送方式,必填项是「主机 + 用户名 + 凭据」,缺哪个就报哪个——
5696
5539
  * 只报主机会让用户填完主机才发现还是发不出去。
5540
+ * 投递历史不参与:账本已删,这里只回答「配置齐不齐」。
5697
5541
  * @param {object} input 判定输入
5698
5542
  * @returns {string} 状态键
5699
5543
  */
5700
5544
  function deriveStatus(input) {
5701
- const { enabled, recipientCount, host, user, credentialConfigured, lastDelivery } = input ?? {};
5545
+ const { enabled, recipientCount, host, user, credentialConfigured } = input ?? {};
5702
5546
  if (enabled !== true) return 'disabled';
5703
5547
  if (!(recipientCount > 0)) return 'needRecipients';
5704
5548
  if (String(host ?? '').trim() === '') return 'needHost';
5705
5549
  if (String(user ?? '').trim() === '') return 'needUser';
5706
5550
  if (credentialConfigured !== true) return 'needCredential';
5707
- if (lastDelivery && lastDelivery.ok === false) return 'lastFailed';
5708
5551
  return 'ready';
5709
5552
  }
5710
5553
 
5711
5554
  /**
5712
- * 某一组的状态点。optional 组只区分「已自定义 / 走默认」。
5555
+ * 草稿与基线的差异 → mutate 的 ops。
5556
+ *
5557
+ * 一次保存只发一条 mutate:ops 里只放与基线不同的字段,空值走 unset(清掉覆盖)。
5558
+ * @param {object} draft 当前草稿
5559
+ * @param {object} base 上一次落盘的基线
5560
+ * @returns {Array<{op: string, path: string[], value?: unknown}>} ops
5561
+ */
5562
+ function diffOps(draft, base) {
5563
+ const left = draft && typeof draft === 'object' ? draft : {};
5564
+ const right = base && typeof base === 'object' ? base : {};
5565
+ const ops = [];
5566
+ for (const key of new Set([...Object.keys(left), ...Object.keys(right)])) {
5567
+ const value = left[key];
5568
+ if (value === right[key]) continue;
5569
+ if (value === '' || value === null || value === undefined) ops.push({ op: 'unset', path: [key] });
5570
+ else ops.push({ op: 'set', path: [key], value });
5571
+ }
5572
+ return ops;
5573
+ }
5574
+
5575
+ /**
5576
+ * 某一组的状态点。不适用时给 idle——这一组本来就不参与,别画成告警。
5713
5577
  * @param {boolean} done 是否已配置好
5714
5578
  * @param {boolean} [notApplicable] 是否不适用
5715
5579
  * @returns {'done'|'warning'|'idle'} 状态点状态
@@ -5736,65 +5600,127 @@ window.__ModuleLoader__.load({
5736
5600
  // 面板是它的后代,直接引用按继承链正常求值,暗色自动跟着走。
5737
5601
  //
5738
5602
  // 量度(全部照抄宿主自己的设置页,别自己发明):
5739
- // 内容列 ≈ 564px · 段标题 14/22·500 · 行标题 14/22 · 字段标签 12/18·500
5740
- // 说明 12/18 tertiary · 行间 .5px border-l2 · 段头下 .5px border-l3
5603
+ // 内容列 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
5606
+ // 行 = padding 16px 0 + 行自身 .5px border-l2(最后一行没有);段头 / 页脚一律不画线
5741
5607
  // 填充面 = bg-module-platform(宿主"编辑器/选中态"用的就是这个)
5742
5608
  //
5743
5609
  // 纪律:全页零硬编码颜色(单测里有审计)。**全页只有一个填充面 —— 判决条。**
5744
5610
  // ---------------------------------------------------------------------------
5745
5611
 
5746
5612
  /** 一眼看出浏览器加载的是哪一版。放在根节点的 data-build 上,检查 DOM 可见,不占视觉。 */
5747
- const BUILD = 'ui11';
5748
-
5749
- const CSS = `
5750
- /* dsh-notify-p · 设置页样式(量度对齐宿主 settings 页;全页零硬编码颜色) */
5751
- .dshmn-page{display:flex;flex-direction:column;min-width:0;max-width:100%;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
5752
- .dshmn-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:18px;font-variant-numeric:tabular-nums}
5753
-
5754
- /* 页头:一句用途 + 落盘读数(读数只报事实,不报"操作成功") */
5755
- .dshmn-head{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin:0 0 14px;min-width:0}
5756
- .dshmn-lead{margin:0;min-width:0;color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px}
5757
- .dshmn-stamp{flex:none;display:inline-flex;align-items:center;gap:6px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
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%。 */
5623
+ .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
+ .dshmn-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}
5625
+
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}
5630
+ .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}
5632
+ .dshmn-headActions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex:none;flex-wrap:wrap;min-width:0}
5633
+ .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}
5758
5634
  .dshmn-stamp[data-tone=busy]{color:var(--dsw-alias-label-secondary)}
5759
5635
  .dshmn-stamp[data-tone=error]{color:var(--dsw-alias-state-error-primary)}
5760
-
5761
- /* ── 判决条:全页唯一的填充面 ────────────────────────────────────────────── */
5762
- .dshmn-verdict{display:flex;flex-direction:column;gap:8px;padding:14px 16px;border-radius:12px;min-width:0;
5763
- background:var(--dsw-alias-bg-module-platform);border:.5px solid var(--dsw-alias-border-l3)}
5636
+ .dshmn-stamp[data-tone=dirty]{color:var(--dsw-alias-label-primary)}
5637
+
5638
+ /* ── 判决条:全页唯一的填充面,也是唯一的状态色 hero ─────────────────────
5639
+ 左侧 3px 竖条与标题色都由 data-tone 决定,颜色全部来自 state token(不硬编码)。
5640
+ 三态:可发信 = success / 需补配置 = warn / 已关闭 = label-tertiary。 */
5641
+ /* 按钮:按宿主线上规则(.primary/.outline + .sm)自绘 —— 见 Button() 注释里为什么不能等宿主原语 */
5642
+ .dshmn-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;box-sizing:border-box;
5643
+ height:28px;padding:0 10px;border:none;border-radius:8px;font-family:inherit;
5644
+ font-size:12px;line-height:18px;cursor:pointer;background:transparent;color:var(--dsw-alias-label-primary)}
5645
+ .dshmn-btn[data-variant=primary]{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}
5646
+ .dshmn-btn[data-variant=primary]:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}
5647
+ .dshmn-btn[data-variant=outline]{border:.5px solid var(--dsw-alias-border-l3)}
5648
+ .dshmn-btn[data-variant=outline]:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
5649
+ .dshmn-btn:disabled{opacity:.5;cursor:not-allowed}
5650
+ .dshmn-btn:focus-visible{outline:2px solid var(--dsw-alias-border-l3);outline-offset:2px}
5651
+
5652
+ .dshmn-verdict{display:flex;flex-direction:column;gap:8px;min-width:0;padding:18px 20px 16px;border-radius:20px;
5653
+ background:var(--dsw-alias-bg-module-platform);border-left:3px solid var(--dsw-alias-label-tertiary)}
5654
+ .dshmn-verdict[data-tone=done]{border-left-color:var(--dsw-alias-state-success-primary)}
5655
+ .dshmn-verdict[data-tone=warning]{border-left-color:var(--dsw-alias-state-warn-primary)}
5656
+ .dshmn-verdict[data-tone=idle]{border-left-color:var(--dsw-alias-label-tertiary)}
5657
+ .dshmn-verdict[data-tone=error]{border-left-color:var(--dsw-alias-state-error-primary)}
5764
5658
  .dshmn-verdictHead{display:flex;align-items:center;gap:8px;min-width:0;flex-wrap:wrap}
5765
- .dshmn-verdictTitle{margin:0;min-width:0;color:var(--dsw-alias-label-primary);font-size:16px;line-height:24px;font-weight:500}
5766
- .dshmn-verdictWhy{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
5767
- .dshmn-verdictFoot{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin-top:2px}
5768
- .dshmn-evidence{display:flex;flex-direction:column;gap:2px;margin-top:2px;padding-top:10px;min-width:0;
5769
- border-top:.5px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
5659
+ .dshmn-verdictTitle{margin:0;min-width:0;color:var(--dsw-alias-label-primary);font-size:18px;line-height:26px;font-weight:600}
5660
+ .dshmn-verdict[data-tone=done] .dshmn-verdictTitle{color:var(--dsw-alias-state-success-primary)}
5661
+ .dshmn-verdict[data-tone=warning] .dshmn-verdictTitle{color:var(--dsw-alias-state-warn-primary)}
5662
+ .dshmn-verdict[data-tone=idle] .dshmn-verdictTitle{color:var(--dsw-alias-label-tertiary)}
5663
+ .dshmn-verdict[data-tone=error] .dshmn-verdictTitle{color:var(--dsw-alias-state-error-primary)}
5664
+ .dshmn-verdictWhy{margin:0;color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px}
5665
+ .dshmn-verdictFoot{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin-top:4px}
5666
+ .dshmn-evidence{display:flex;flex-direction:column;gap:4px;min-width:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
5770
5667
  .dshmn-evidenceKey{color:var(--dsw-alias-label-tertiary)}
5771
5668
  .dshmn-evidence[data-ok=false] .dshmn-evidenceKey{color:var(--dsw-alias-state-error-primary)}
5772
5669
  .dshmn-evidence .dshmn-mono{color:var(--dsw-alias-label-secondary);word-break:break-all}
5773
5670
 
5774
- /* ── 分组:段头 + 发丝线。不做卡片、不编号 ──────────────────────────────── */
5775
- .dshmn-group{display:flex;flex-direction:column;margin-top:24px;min-width:0}
5776
- .dshmn-groupHead{display:flex;align-items:baseline;justify-content:space-between;gap:12px;flex-wrap:wrap;
5777
- padding-bottom:6px;border-bottom:.5px solid var(--dsw-alias-border-l3)}
5778
- .dshmn-groupTitle{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;font-weight:500}
5779
- .dshmn-groupState{flex:none;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:22px;font-variant-numeric:tabular-nums}
5780
- .dshmn-groupBody{display:flex;flex-direction:column;min-width:0}
5781
-
5782
- /* ── 条目:分隔线长在条目上,这样"父行 + 缩进子行"之间只画一条线 ─────────── */
5783
- .dshmn-item{display:flex;flex-direction:column;gap:8px;padding:12px 0;min-width:0;
5671
+ /* ── 三张卡片:靠 .5px 描边成立,不靠底色(浅色主题下所有 bg-layer 都是纯白)──
5672
+ 描边 + radius 20 + padding 14 16 + 内部 gap 12,与宿主 settings 的 rowCard 同构。 */
5673
+ .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}
5675
+ .dshmn-card + .dshmn-card{margin-top:12px}
5676
+ .dshmn-verdict + .dshmn-card{margin-top:16px}
5677
+ .dshmn-cardHead{display:flex;align-items:baseline;justify-content:space-between;gap:12px;flex-wrap:wrap;min-width:0}
5678
+ .dshmn-cardTitle{margin:0;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;font-weight:500}
5679
+ .dshmn-cardState{flex:none;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;font-variant-numeric:tabular-nums}
5680
+ .dshmn-cardBody{display:flex;flex-direction:column;min-width:0}
5681
+ /* 卡片自己已经给了 14px 上内边距:里面的第一个元素不要再叠一层上边距 */
5682
+ .dshmn-cardBody>:first-child{padding-top:0}
5683
+ .dshmn-cardBody>:last-child{padding-bottom:0;border-bottom:none}
5684
+
5685
+ /* ── 行:线只长在行自己身上(.5px border-l2,最后一行没有),上下各 16px ────── */
5686
+ .dshmn-item{display:flex;flex-direction:column;gap:8px;padding:16px 0;min-width:0;
5784
5687
  border-bottom:.5px solid var(--dsw-alias-border-l2)}
5785
5688
  .dshmn-item:last-child{padding-bottom:0;border-bottom:none}
5786
- .dshmn-itemHead{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;min-width:0}
5787
- .dshmn-itemText{display:flex;flex-direction:column;gap:4px;flex:1 1 auto;min-width:0}
5788
- .dshmn-itemTitle{color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px}
5789
- .dshmn-itemCtrl{flex:none;display:flex;align-items:center;gap:8px;min-height:22px}
5790
- /* 缩进子行:把"它只对上面那一项生效"画成结构,而不是再写一句说明 */
5791
- .dshmn-sub{display:flex;flex-direction:column;gap:8px;margin-left:3px;padding-left:12px;min-width:0;
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}
5691
+
5692
+ /* ── 开关行:标题 14/22·400 + 问号 + 开关。说明折进问号里,默认不占版面 ───────── */
5693
+ .dshmn-row{display:flex;flex-direction:column;gap:8px;padding:16px 0;min-width:0;
5694
+ 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;
5792
5717
  border-left:.5px solid var(--dsw-alias-border-l2)}
5793
- .dshmn-sub[data-muted=true]{opacity:.5}
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}
5794
5720
 
5795
- /* ── 字段:标签在上、控件满宽(宿主 settings-models 的 fieldLabel 同款) ── */
5796
- .dshmn-field{display:flex;flex-direction:column;gap:6px;min-width:0}
5797
- .dshmn-label{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;font-weight:500}
5721
+ /* ── 字段:标签在上、控件满宽(宿主 settings 的字段同款)────────────────────── */
5722
+ .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}
5798
5724
  .dshmn-hint{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;max-width:44em}
5799
5725
  .dshmn-error{margin:0;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;word-break:break-word}
5800
5726
  .dshmn-credState{display:inline-flex;align-items:center;gap:6px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
@@ -5803,94 +5729,58 @@ window.__ModuleLoader__.load({
5803
5729
 
5804
5730
  /* ── 输入 ──────────────────────────────────────────────────────────────── */
5805
5731
  /* 宿主的 Input 原语是 content-box 的 inline-flex(padding 0 8px + .5px 描边),
5806
- 所以光给 width:100% 会让它比父级宽出 18px、顶出内容列。必须一起给 border-box。 */
5732
+ 所以光给 width:100% 会让它比父级宽出 18px、顶出内容列。必须一起给 border-box。
5733
+ 这里是原语缺席时的兜底输入框:底色 / 描边 / 圆角与宿主 Input 对齐(bg-layer-3 + border-l4 + 12)。 */
5807
5734
  .dshmn-inputWrap{box-sizing:border-box;display:flex;width:100%;min-width:0}
5808
5735
  .dshmn-inputWrap>*{box-sizing:border-box;width:100%;min-width:0}
5809
- .dshmn-textarea{box-sizing:border-box;width:100%;min-height:64px;padding:8px 10px;resize:vertical;outline:none;
5810
- border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-1);
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);
5811
5738
  color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px}
5812
5739
  .dshmn-textarea::placeholder{color:var(--dsw-alias-label-tertiary)}
5813
- .dshmn-select{box-sizing:border-box;width:100%;height:36px;padding:0 10px;outline:none;
5814
- border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:var(--dsw-alias-bg-layer-1);
5815
- color:var(--dsw-alias-label-primary);font:inherit;font-size:14px}
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}
5816
5743
  .dshmn-textarea:focus-visible,.dshmn-select:focus-visible{border-color:var(--dsw-alias-brand-primary);
5817
5744
  box-shadow:0 0 0 2px var(--dsw-alias-border-l3)}
5818
5745
  .dshmn-textarea[aria-invalid=true]{border-color:var(--dsw-alias-state-error-primary)}
5819
5746
 
5820
- /* ── 发件人清单:一行一个身份,选中用 module 面(与 segBtn 的选中语言一致)──── */
5821
- /* 清单本身是个带描边的容器,条目之间只画发丝线——和 Group 的语言保持一致。 */
5822
- .dshmn-senders{display:flex;flex-direction:column;border:.5px solid var(--dsw-alias-border-l4);
5823
- border-radius:8px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}
5824
- .dshmn-sender{display:flex;align-items:center;gap:10px;padding:0 6px 0 10px;min-height:46px;cursor:pointer;
5825
- border-bottom:.5px solid var(--dsw-alias-border-l2)}
5826
- .dshmn-sender:last-child{border-bottom:none}
5827
- .dshmn-sender:hover{background:var(--dsw-alias-interactive-bg-hover)}
5828
- .dshmn-sender[data-active=true]{background:var(--dsw-alias-bg-module-platform)}
5829
- .dshmn-senderRadio{flex:none;width:15px;height:15px;margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary)}
5830
- .dshmn-senderBody{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;padding:7px 0}
5831
- .dshmn-senderName{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
5832
- color:var(--dsw-alias-label-primary);font-size:14px;line-height:20px}
5833
- .dshmn-sender[data-active=true] .dshmn-senderName{font-weight:500}
5834
- .dshmn-senderAddr{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
5835
- color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
5836
- .dshmn-senderBadge{flex:none;color:var(--dsw-alias-state-success-primary);font-size:12px;line-height:18px}
5837
- .dshmn-senderIcon{flex:none;box-sizing:border-box;width:26px;height:26px;display:inline-flex;align-items:center;
5838
- justify-content:center;padding:0;cursor:pointer;border:none;border-radius:6px;background:none;
5839
- color:var(--dsw-alias-label-tertiary);font:inherit;font-size:15px;line-height:1}
5840
- .dshmn-senderIcon:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
5841
- .dshmn-senderIcon:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}
5842
- .dshmn-senderEmpty{padding:12px;color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:22px}
5843
- .dshmn-senderAdd{box-sizing:border-box;align-self:flex-start;display:inline-flex;align-items:center;gap:6px;
5844
- margin:8px 10px;height:30px;padding:0 13px;cursor:pointer;font:inherit;font-size:13px;line-height:18px;
5845
- border:.5px dashed var(--dsw-alias-border-l4);border-radius:15px;background:transparent;
5846
- color:var(--dsw-alias-label-secondary)}
5847
- .dshmn-senderAdd:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
5848
- .dshmn-senderAdd:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
5849
-
5850
- /* SMTP 三个短字段排一行;窄屏折成一列——别竖着排三个满宽输入。 */
5851
- .dshmn-grid{display:grid;grid-template-columns:minmax(0,1fr) 88px 104px;gap:10px;align-items:start;min-width:0}
5852
- .dshmn-num{display:inline-flex;align-items:center;gap:6px;min-width:0}
5853
- .dshmn-numBox{width:78px;min-width:0}
5854
- .dshmn-numBox>*,.dshmn-numBox input{box-sizing:border-box;width:100%;min-width:0}
5855
- .dshmn-unit{flex:none;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
5856
-
5857
- /* ── 单选:宿主"外观"三张卡的形态 —— 未选=白底+发丝线,选中=module 面 ──── */
5858
- .dshmn-seg{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
5859
- .dshmn-segBtn{box-sizing:border-box;height:32px;padding:0 14px;cursor:pointer;font:inherit;font-size:13px;line-height:20px;
5860
- border:.5px solid var(--dsw-alias-border-l4);border-radius:16px;background:var(--dsw-alias-bg-layer-1);
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}
5750
+
5751
+ /* ── 预设 chips:一行横向滚动(窄屏也不折成三排),滚动条藏起来 ─────────────── */
5752
+ .dshmn-chips{display:flex;align-items:center;gap:8px;min-width:0;overflow-x:auto;overflow-y:hidden;padding:2px 0;
5753
+ scrollbar-width:none;-ms-overflow-style:none}
5754
+ .dshmn-chips::-webkit-scrollbar{display:none}
5755
+ .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;
5757
+ border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;background:none;
5861
5758
  color:var(--dsw-alias-label-secondary)}
5862
5759
  .dshmn-segBtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
5863
- .dshmn-segBtn[data-active=true]{background:var(--dsw-alias-bg-module-platform);border-color:var(--dsw-alias-border-l3);
5864
- color:var(--dsw-alias-label-primary);font-weight:500}
5865
- .dshmn-segBtn:focus-visible,.dshmn-link:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
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;
5761
+ color:var(--dsw-alias-label-primary);font-weight:600}
5866
5762
 
5867
5763
  /* ── 文字按钮 ──────────────────────────────────────────────────────────── */
5868
- .dshmn-link{color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:18px;cursor:pointer;
5764
+ .dshmn-link{color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;line-height:20px;cursor:pointer;
5869
5765
  padding:0;border:none;background:none;text-decoration:underline;text-underline-offset:3px;
5870
5766
  text-decoration-color:var(--dsw-alias-border-l4)}
5871
5767
  .dshmn-link:hover{color:var(--dsw-alias-label-primary)}
5872
5768
  .dshmn-link:disabled{color:var(--dsw-alias-label-tertiary);cursor:default;text-decoration:none}
5873
5769
 
5874
- /* ── 折叠区与页脚 ──────────────────────────────────────────────────────── */
5875
- .dshmn-disclose{margin-top:0}
5876
- .dshmn-discloseBody{display:flex;flex-direction:column;gap:4px;padding-top:10px;min-width:0}
5877
- /* 展开后的内容:缩进 + 一道左竖线,跟 SMTP 子块同一套语言——
5878
- 折叠区打开后必须一眼看出"这些东西属于上面那一行",而不是又一组平铺的设置。 */
5879
- .dshmn-stack{display:flex;flex-direction:column;gap:14px;min-width:0;margin-top:10px;
5880
- padding-left:12px;border-left:1px solid var(--dsw-alias-border-l2)}
5881
- /* 内容自带行节奏时(Row 自己有内边距和发丝线)只保留缩进,别再叠一层间距 */
5882
- .dshmn-stack[data-rows=true]{gap:0}
5883
- .dshmn-foot{display:flex;align-items:center;justify-content:flex-end;gap:12px;flex-wrap:wrap;margin-top:24px;
5884
- padding-top:14px;border-top:.5px solid var(--dsw-alias-border-l3)}
5885
- .dshmn-helpBody{white-space:pre-wrap;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:21px}
5770
+ /* ── 页脚与帮助正文:页脚不画线,只留一条 12/18 的链接 ───────────────────── */
5771
+ .dshmn-foot{display:flex;align-items:center;justify-content:flex-end;gap:12px;flex-wrap:wrap;margin-top:16px;padding-top:16px}
5772
+ .dshmn-foot .dshmn-link{font-size:12px;line-height:18px}
5773
+ .dshmn-helpBody{white-space:pre-wrap;color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px}
5886
5774
 
5887
5775
  /* ── 窄屏:宿主 settings 页的断点就是 560px ─────────────────────────────── */
5888
5776
  @media (max-width:560px){
5889
- .dshmn-head{flex-direction:column;gap:4px}
5777
+ .dshmn-head{flex-direction:column;align-items:stretch;gap:12px}
5778
+ .dshmn-headActions{justify-content:flex-start}
5890
5779
  .dshmn-verdictFoot{justify-content:flex-start}
5891
- .dshmn-grid{grid-template-columns:minmax(0,1fr)}
5892
- .dshmn-numBox{width:auto;flex:1 1 auto}
5780
+ .dshmn-two,.dshmn-twoPort{grid-template-columns:minmax(0,1fr)}
5781
+ .dshmn-card{padding:12px 14px}
5893
5782
  }
5783
+
5894
5784
  `;
5895
5785
 
5896
5786
 
@@ -5936,10 +5826,25 @@ window.__ModuleLoader__.load({
5936
5826
  return h('span', { 'aria-hidden': 'true' }, '✓');
5937
5827
  }
5938
5828
 
5939
- /** 按钮。 */
5829
+ /**
5830
+ * 按钮。
5831
+ *
5832
+ * 实测:当前宿主版本**没有** P.Button 这个原语(web bundle 里只有 MenuItemButton),
5833
+ * 所以兜底分支才是实际走的那条。之前它退化成 .dshmn-link(带下划线的文字),
5834
+ * 页面上因此一个"有体量"的按钮都没有——这是整页显得扁平的重要原因。
5835
+ * 这里按宿主线上按钮的规则自绘:primary = button-primary-fill + label-primary-foreground,
5836
+ * outline = 0.5px border-l3 + 透明底,尺寸照 sm(高 28 / 12px 字 / 圆角 8)。
5837
+ */
5940
5838
  function Button(props) {
5941
5839
  if (typeof P.Button === 'function') return h(P.Button, props);
5942
- return h('button', { type: 'button', className: 'dshmn-link', onClick: props.onClick, disabled: props.disabled }, props.children);
5840
+ return h('button', {
5841
+ type: 'button',
5842
+ className: 'dshmn-btn',
5843
+ 'data-variant': props.variant ?? 'outline',
5844
+ disabled: props.disabled,
5845
+ title: props.title,
5846
+ onClick: props.onClick,
5847
+ }, props.children);
5943
5848
  }
5944
5849
 
5945
5850
  /** 输入框(宿主原语自带 0.5px 发丝描边与 focus-within 主题色)。 */
@@ -5971,102 +5876,55 @@ window.__ModuleLoader__.load({
5971
5876
  }, children);
5972
5877
  }
5973
5878
 
5974
- /**
5975
- * 折叠区。优先用宿主原语,取不到再退化为本地实现。
5976
- * @param {object} props 折叠参数
5977
- * @returns {object} React 元素
5978
- */
5979
- function Disclosure(props) {
5980
- const { title, open, onToggle, children } = props;
5981
- if (typeof P.DisclosureRow === 'function') {
5982
- return h('div', { className: 'dshmn-disclose' }, h(P.DisclosureRow, {
5983
- title,
5984
- open: open === true,
5985
- expandable: true,
5986
- expandOnRowClick: true,
5987
- onToggle,
5988
- }, children));
5989
- }
5990
- const head = h('button', {
5991
- type: 'button',
5992
- className: 'dshmn-link',
5993
- 'aria-expanded': open === true,
5994
- onClick: onToggle,
5995
- }, `${open === true ? '▾' : '▸'} ${title}`);
5996
- const body = open === true ? h('div', { className: 'dshmn-discloseBody' }, children) : null;
5997
- return h('div', { className: 'dshmn-disclose' }, head, body);
5998
- }
5879
+ // 折叠区(「更多」「还有 3 种情况」)统一用下面这个 foldHead 拼在页内:
5880
+ // 开合状态留在 MailNotifyPage 自己的 useState 里,于是「收起时标题带当前值」与
5881
+ // 「开合状态可断言」两件事同时成立;旧的 Disclosure 包装随之删除。
5999
5882
 
6000
5883
  // ---------------------------------------------------------------------------
6001
5884
  // 页面
6002
5885
  // ---------------------------------------------------------------------------
6003
5886
 
6004
5887
  /**
6005
- * 一个分组:段头(名字 + 这一段的结论)+ 一条发丝线,内容靠行与行之间的分隔线组织。
5888
+ * 一张卡片(宿主 models / agent-preset / account 三页同款):
5889
+ * .5px 描边 + radius 20 + padding 14 16 + 内部 gap 12;标题 14/22·500,右侧可带一条 12/18 状态读数。
6006
5890
  *
6007
- * **不编号、不套卡片。** 以前是 `1 收件人 / 2 怎么发 / 3 什么时候发给我` 三张同款灰卡,
6008
- * 编号把并列的三个设置组伪装成步骤序列(内容并不是步骤),三块同款灰又让主次消失。
6009
- * @param {object} props 分组参数
5891
+ * **不编号。** 三张卡片是并列的三个设置组,编号会把并列伪装成步骤序列(内容并不是步骤)。
5892
+ * **不靠底色。** 卡片底色等于面板底色(浅色主题下所有 bg-layer 都是纯白),
5893
+ * 卡片只靠描边成立——这也正是宿主 settings 卡片的样子。
5894
+ * 每张卡片另带一个语义修饰类(dshmn-card-recipients / -transport / -outcomes),
5895
+ * 它们只是给 DOM 一个稳定的抓手(和宿主的 data-* 一个用途),样式仍由 .dshmn-card 一份承担。
5896
+ * @param {object} props 卡片参数(kind 是修饰类后缀)
6010
5897
  * @returns {object} React 元素
6011
5898
  */
6012
- function Group(props) {
6013
- const { title, state, children } = props;
6014
- return h('section', { className: 'dshmn-group', 'aria-label': title },
6015
- h('div', { className: 'dshmn-groupHead' },
6016
- h('h3', { className: 'dshmn-groupTitle' }, title),
6017
- state === undefined ? null : h('span', { className: 'dshmn-groupState' }, state)),
6018
- h('div', { className: 'dshmn-groupBody' }, children));
5899
+ function Card(props) {
5900
+ const { title, state, kind, children } = props;
5901
+ const className = kind === undefined ? 'dshmn-card' : `dshmn-card dshmn-card-${kind}`;
5902
+ return h('section', { className, 'aria-label': title },
5903
+ h('div', { className: 'dshmn-cardHead' },
5904
+ h('h3', { className: 'dshmn-cardTitle' }, title),
5905
+ state === undefined ? null : h('span', { className: 'dshmn-cardState' }, state)),
5906
+ h('div', { className: 'dshmn-cardBody' }, children));
6019
5907
  }
6020
5908
 
6021
5909
  /**
6022
- * 一行:标题 + 说明在左,控件在右。
6023
- * @param {object} props 行参数
6024
- * @returns {object} React 元素
6025
- */
6026
- function Row(props) {
6027
- const { title, hint, control, htmlFor, children } = props;
6028
- return h('div', { className: 'dshmn-item' },
6029
- h('div', { className: 'dshmn-itemHead' },
6030
- h('div', { className: 'dshmn-itemText' },
6031
- htmlFor === undefined
6032
- ? h('div', { className: 'dshmn-itemTitle' }, title)
6033
- : h('label', { className: 'dshmn-itemTitle', htmlFor }, title),
6034
- hint === undefined ? null : h('div', { className: 'dshmn-hint' }, hint)),
6035
- control === undefined ? null : h('div', { className: 'dshmn-itemCtrl' }, control)),
6036
- children);
6037
- }
6038
-
6039
- /**
6040
- * 缩进子行:表达"这一项只对上面那一项生效",而不是再补一句说明。
6041
- * 父项关掉时整块压暗,但仍然可见——它没被删掉,只是暂时不参与。
6042
- * @param {object} props 子行参数
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 点击开合
6043
5918
  * @returns {object} React 元素
6044
5919
  */
6045
- function Sub(props) {
6046
- const { title, hint, control, muted } = props;
6047
- return h('div', { className: 'dshmn-sub', 'data-muted': muted === true ? 'true' : 'false' },
6048
- h('div', { className: 'dshmn-itemHead' },
6049
- h('div', { className: 'dshmn-itemText' },
6050
- h('div', { className: 'dshmn-itemTitle' }, title),
6051
- hint === undefined ? null : h('div', { className: 'dshmn-hint' }, hint)),
6052
- control === undefined ? null : h('div', { className: 'dshmn-itemCtrl' }, control)));
6053
- }
6054
-
6055
- /** 带单位后缀的数字输入(贴在一行里,所以宽度定死;单位常驻可见)。 */
6056
- function NumberField({ id, value, unit, onChange, onBlur, placeholder, label }) {
6057
- return h('span', { className: 'dshmn-num' },
6058
- h('span', { className: 'dshmn-numBox' },
6059
- h(TextInput, {
6060
- id,
6061
- type: 'number',
6062
- inputMode: 'numeric',
6063
- value: value ?? '',
6064
- placeholder,
6065
- 'aria-label': label,
6066
- onChange: (event) => onChange(event.target.value === '' ? '' : Number(event.target.value)),
6067
- onBlur,
6068
- })),
6069
- h('span', { className: 'dshmn-unit' }, unit));
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);
6070
5928
  }
6071
5929
 
6072
5930
  /**
@@ -6078,9 +5936,15 @@ window.__ModuleLoader__.load({
6078
5936
  const { remotes } = props;
6079
5937
  const t = (key, params) => i18n.t(key, params);
6080
5938
 
6081
- const [ready, setReady] = React.useState(remotes.current !== null);
5939
+ /** settings 远程真的可用了吗。ready 只认它——rpc / credentials 先到都不算。 */
5940
+ const settingsAvailable = () => typeof remotes.current?.settings?.describe === 'function';
5941
+ // 就绪的判定必须收到 settings 上:只看 remotes.current 非空的话,connection 先注入就会把 ready
5942
+ // 烧成 true,而 load() 当时读不到 settings、直接返回——之后 settings 真的到了也没人再叫它,
5943
+ // 这一页就永远停在空态(按钮全禁用,连读失败都不报)。
5944
+ const [ready, setReady] = React.useState(() => settingsAvailable());
6082
5945
  const [attempt, setAttempt] = React.useState(0);
6083
5946
  const [draft, setDraft] = React.useState({});
5947
+ const [baseline, setBaseline] = React.useState({});
6084
5948
  const [save, setSave] = React.useState({ phase: 'idle', at: 0, error: '' });
6085
5949
  const [now, setNow] = React.useState(() => Date.now());
6086
5950
  const [credentialState, setCredentialState] = React.useState({});
@@ -6088,26 +5952,20 @@ window.__ModuleLoader__.load({
6088
5952
  const [credCheck, setCredCheck] = React.useState(0);
6089
5953
  const [pwDraft, setPwDraft] = React.useState({});
6090
5954
  const [pwClear, setPwClear] = React.useState({});
5955
+ const [pwEdit, setPwEdit] = React.useState({});
6091
5956
  const [resetting, setResetting] = React.useState(false);
6092
5957
  const [confirmReset, setConfirmReset] = React.useState(false);
6093
5958
  const [helpKind, setHelpKind] = React.useState(null);
6094
- const [test, setTest] = React.useState({ phase: 'idle', transport: '', ms: 0, error: '' });
6095
- const [lastDelivery, setLastDelivery] = React.useState(null);
6096
- const [capability, setCapability] = React.useState('unknown');
5959
+ const [test, setTest] = React.useState({ phase: 'idle', ms: 0, error: '' });
6097
5960
  const [showMoreOutcomes, setShowMoreOutcomes] = React.useState(false);
6098
- const [showSender, setShowSender] = React.useState(false);
6099
- /** 发件人弹层的编辑态:null = 关闭;{ index, name, address },index === 清单长度 表示新增。 */
6100
- const [senderEdit, setSenderEdit] = React.useState(null);
6101
- /** 发件人弹层的就地报错文案(空串 = 没报错)。 */
6102
- const [senderErr, setSenderErr] = React.useState('');
6103
- const [showAdvanced, setShowAdvanced] = React.useState(false);
6104
- const [customHost, setCustomHost] = React.useState(false);
5961
+ /** 端口 / 加密收在「更多」里,默认收起(偏离默认值时标题会带上现值)。 */
5962
+ const [moreOpen, setMoreOpen] = React.useState(false);
5963
+ /** 每个事件开关行自己的说明开合:点问号切一行,互不影响。 */
5964
+ const [openHints, setOpenHints] = React.useState({});
6105
5965
 
6106
5966
  const draftRef = React.useRef({});
6107
5967
  const baselineRef = React.useRef({});
6108
5968
  const viewRef = React.useRef(null);
6109
- const timers = React.useRef({});
6110
- const pwTimers = React.useRef({});
6111
5969
  // 语言切换时强制重渲染(locale 服务只在变化时通知,不驱动 React)
6112
5970
  const [, setLocaleRev] = React.useState(0);
6113
5971
 
@@ -6117,33 +5975,62 @@ window.__ModuleLoader__.load({
6117
5975
  return locale.subscribe(() => setLocaleRev((n) => n + 1));
6118
5976
  }, []);
6119
5977
 
6120
- // 远程命名空间迟到:最多轮询 40 次(约 6 秒)
5978
+ // 远程命名空间迟到:最多轮询 40 次(约 6 秒)。只认 settings,别的服务晚到不算就绪。
6121
5979
  React.useEffect(() => {
6122
5980
  if (ready || attempt >= 40) return undefined;
6123
5981
  const timer = setTimeout(() => {
6124
- if (remotes.current !== null) setReady(true);
5982
+ if (settingsAvailable()) setReady(true);
6125
5983
  setAttempt((n) => n + 1);
6126
5984
  }, 150);
6127
5985
  return () => clearTimeout(timer);
6128
- }, [ready, attempt, remotes]);
5986
+ }, [ready, attempt]);
6129
5987
 
6130
- const load = React.useCallback(async () => {
5988
+ /**
5989
+ * 读一次配置,并按 **配置指纹** 认领本插件的那一项。
5990
+ *
5991
+ * 认领规则:ns 是 profile 入口 id(换 profile 就变),客户端不许写死。别拿 describe() 里的
5992
+ * schema 当指纹——它是 schema.toJSON() 的快照(只有 uid/refs,没有 dict),按 dict 找永远
5993
+ * 找不到。稳定指纹在 value 里:只有本插件同时声明 smtpHost 与 smtpPasswordRef。
5994
+ *
5995
+ * @param {{ preserveDraft?: boolean }} [options] preserveDraft = 冲突恢复模式:
5996
+ * 用户的草稿**一个字都不动**(那是他刚填、还没写进去的东西),只把基线对齐服务端最新值、
5997
+ * revision 换成新的(viewRef.current 就是 revision 的持有者),下一次保存才不会再撞同一个值。
5998
+ * @returns {Promise<void>}
5999
+ */
6000
+ const load = React.useCallback(async (options) => {
6131
6001
  const bridge = remotes.current;
6002
+ const preserveDraft = options?.preserveDraft === true;
6132
6003
  if (bridge === null) return;
6004
+ // settings 还没注入 ≠ 读不到配置:它只是还没到。这一档不落成 loadFailed,
6005
+ // 免得把一次正常的迟到渲染成红色报错(ready 会为它再放行一次装载)。
6006
+ if (typeof bridge?.settings?.describe !== 'function') {
6007
+ setSave({ phase: 'unserved', at: 0, error: '' });
6008
+ return;
6009
+ }
6133
6010
  try {
6134
6011
  const described = unwrap(await bridge.settings.describe());
6135
6012
  const namespaces = Array.isArray(described?.namespaces) ? described.namespaces : [];
6136
- const found = namespaces.find((item) => item && item.ns === NS) ?? null;
6013
+ const found = namespaces.find((d) => d && d.value && typeof d.value === 'object'
6014
+ && Object.hasOwn(d.value, 'smtpHost') && Object.hasOwn(d.value, 'smtpPasswordRef')) ?? null;
6137
6015
  viewRef.current = found;
6138
6016
  const value = found && found.value && typeof found.value === 'object' ? { ...found.value } : {};
6139
- draftRef.current = value;
6140
- baselineRef.current = value;
6141
- setDraft(value);
6017
+ baselineRef.current = { ...value };
6018
+ setBaseline({ ...value });
6019
+ if (preserveDraft) {
6020
+ // 冲突恢复:草稿留着,只把基线与 revision 抬到最新——这样再过一次保存就是干净的写。
6021
+ setSave(found === null
6022
+ ? { phase: 'unserved', at: 0, error: '' }
6023
+ : { phase: 'conflictReloaded', at: 0, error: '' });
6024
+ return;
6025
+ }
6026
+ draftRef.current = { ...value };
6027
+ setDraft({ ...value });
6142
6028
  setSave(found === null
6143
- ? { phase: 'error', at: 0, error: t('status.notServed') }
6029
+ ? { phase: 'unserved', at: 0, error: '' }
6144
6030
  : { phase: 'idle', at: 0, error: '' });
6145
6031
  } catch (err) {
6146
- setSave({ phase: 'error', at: 0, error: String(err?.message ?? err) });
6032
+ // 读失败是**独立的一档**:它不是「保存失败」,重试也不该去调 saveAll()。
6033
+ setSave({ phase: 'loadFailed', at: 0, error: String(err?.message ?? err) });
6147
6034
  }
6148
6035
  }, [remotes]);
6149
6036
 
@@ -6169,43 +6056,27 @@ window.__ModuleLoader__.load({
6169
6056
  }
6170
6057
  }, [remotes]);
6171
6058
 
6172
- /** 最近一次投递结果 + Host 能力探测。 */
6173
- const refreshDelivery = React.useCallback(async () => {
6174
- // 顺带探一次能力:typert 清单是**服务器启动时**扫描注册的,
6175
- // 所以"connection 在"不等于"mailNotify 端点在"。探不到就老实说不可用,
6176
- // 而不是给一个点下去必然报错的按钮。
6177
- if (remotes.current?.rpc === undefined) {
6178
- setCapability('missing');
6179
- return;
6180
- }
6181
- try {
6182
- const state = unwrap(await callRemote('state'));
6183
- setCapability('ready');
6184
- setLastDelivery(state?.recent?.[0] ?? null);
6185
- } catch (err) {
6186
- setCapability('missing');
6187
- console.warn('[dsh-notify-p] Host 未提供 mailNotify 端点(重启 DSH 后生效)/ endpoint unavailable:', err);
6188
- }
6189
- }, [remotes]);
6190
-
6191
- // 首次就绪后装载
6059
+ // 首次就绪后装载。读失败 / 认领不到之后**不再自动重试**:
6060
+ // 那两档各自有出路(「重新读取」按钮 / 换个 profile 再看),自动重试只会把读数一直抖回别处,
6061
+ // 也会让「重新读取」到底点了没点变得看不出来。
6192
6062
  React.useEffect(() => {
6193
- if (ready && viewRef.current === null) void load();
6194
- }, [ready, load, attempt]);
6063
+ if (!ready || viewRef.current !== null) return;
6064
+ if (save.phase === 'loadFailed' || save.phase === 'unserved') return;
6065
+ void load();
6066
+ }, [ready, load, attempt, save.phase]);
6195
6067
 
6196
6068
  // `viewRef.current` 是 load() 里赋的**引用**,赋值不会触发重渲染。
6197
6069
  // 所以依赖数组里写 viewRef.current 是无效的:首次挂载时它还是 null,
6198
6070
  // load() 完成后依赖没变,这个 effect 永远不会再跑一遍 —— 结果就是
6199
- // 凭据永远是「还没保存」、投递账本永远读不到、测试按钮永远不出现。
6071
+ // 凭据永远是「还没保存」、测试按钮永远不出现。
6200
6072
  // 在渲染期把它取成一个普通值参与依赖,false→true 那一次就会把下面几条读都带起来。
6201
6073
  const loaded = viewRef.current !== null;
6202
6074
 
6203
6075
  React.useEffect(() => {
6204
6076
  if (ready && loaded) {
6205
6077
  void refreshCredentials([viewRef.current?.value?.smtpPasswordRef]);
6206
- void refreshDelivery();
6207
6078
  }
6208
- }, [ready, loaded, refreshCredentials, refreshDelivery, attempt]);
6079
+ }, [ready, loaded, refreshCredentials, attempt]);
6209
6080
 
6210
6081
  // 「已保存 · N 秒前」需要一个会走的钟;只在保存过之后跑,离开保存态就停
6211
6082
  React.useEffect(() => {
@@ -6244,161 +6115,209 @@ window.__ModuleLoader__.load({
6244
6115
  }, [refreshCredentials]);
6245
6116
 
6246
6117
  /**
6247
- * 把若干字段写回 Host(即时生效的核心)。
6248
- * @param {string[]} keys 要提交的字段名
6249
- * @returns {Promise<void>}
6250
- */
6251
- const commit = React.useCallback(async (keys) => {
6252
- const bridge = remotes.current;
6253
- const view = viewRef.current;
6254
- if (bridge === null || view === null) return;
6255
- const base = baselineRef.current ?? {};
6256
- const ops = [];
6257
- for (const key of keys) {
6258
- const value = draftRef.current[key];
6259
- if (value === base[key]) continue;
6260
- if (value === '' || value === null || value === undefined) ops.push({ op: 'unset', path: [key] });
6261
- else ops.push({ op: 'set', path: [key], value });
6262
- }
6263
- if (ops.length === 0) return;
6264
- setSave({ phase: 'saving', at: 0, error: '' });
6265
- try {
6266
- const next = unwrap(await bridge.settings.mutate(NS, ops, view.revision));
6267
- if (next && typeof next === 'object') {
6268
- viewRef.current = next;
6269
- baselineRef.current = next.value && typeof next.value === 'object' ? { ...next.value } : {};
6270
- }
6271
- setSave({ phase: 'saved', at: Date.now(), error: '' });
6272
- } catch (err) {
6273
- setSave({ phase: 'error', at: 0, error: String(err?.message ?? err) });
6274
- // 冲突或失败都重读一次,避免本地草稿与 Host 漂移
6275
- await load();
6276
- }
6277
- }, [remotes, load]);
6278
-
6279
- /**
6280
- * 改一个字段。
6118
+ * 改一个字段:**只改本地草稿**,不碰 Host。写回只发生在点「保存」的时候。
6281
6119
  * @param {string} key 字段名
6282
6120
  * @param {unknown} value 新值
6283
- * @param {{ immediate?: boolean, delay?: number }} [opts] 提交策略
6284
6121
  * @returns {void}
6285
6122
  */
6286
- const setField = React.useCallback((key, value, opts = {}) => {
6123
+ const setField = React.useCallback((key, value) => {
6287
6124
  draftRef.current = { ...draftRef.current, [key]: value };
6288
6125
  setDraft(draftRef.current);
6289
- clearTimeout(timers.current[key]);
6290
- if (opts.immediate === true) {
6291
- void commit([key]);
6292
- return;
6293
- }
6294
- timers.current[key] = setTimeout(() => void commit([key]), opts.delay ?? TEXT_DEBOUNCE_MS);
6295
- }, [commit]);
6296
-
6297
- /** 立刻提交某字段(失焦兜底)。 */
6298
- const flush = React.useCallback((key) => {
6299
- clearTimeout(timers.current[key]);
6300
- void commit([key]);
6301
- }, [commit]);
6126
+ }, []);
6302
6127
 
6303
6128
  /**
6304
- * 一次改多个字段并**合成一次提交**。
6305
- *
6306
- * 必须批量:`commit` 带的是同一个 revision,连续发多个即时写入会互相冲突,
6307
- * 后发的会被 Host 拒绝并被随后的 load() 回滚——表面看就是"预设填了一半"。
6129
+ * 一次改多个字段,合成同一份草稿(邮箱服务商预设用)。
6130
+ * 保存是一次 mutate,所以这里不需要按字段拆开写。
6308
6131
  * @param {object} patch 字段名到新值的映射
6309
6132
  * @returns {void}
6310
6133
  */
6311
6134
  const setFields = React.useCallback((patch) => {
6312
6135
  draftRef.current = { ...draftRef.current, ...patch };
6313
6136
  setDraft(draftRef.current);
6314
- for (const key of Object.keys(patch)) clearTimeout(timers.current[key]);
6315
- void commit(Object.keys(patch));
6316
- }, [commit]);
6317
-
6318
- /** 当前生效的发件人清单(含老配置迁移)。纯函数很便宜,不必 memo。 */
6319
- const senders = migrateSenders(draft);
6320
- /** 当前生效的地址。 */
6321
- const senderAddr = activeSenderAddress(draft, senders);
6137
+ }, []);
6322
6138
 
6323
6139
  /**
6324
- * 写发件人清单。
6325
- *
6326
- * **必须同时清掉旧的 senderName / fromAddress**:清单为空时 Host 会回落到旧字段,
6327
- * 不清就会"刚删掉的那条又回来了"。
6328
- * 用 setFields 合成一次提交,两个字段共用同一个 revision,不会互相冲突。
6329
- * @param {{name: string, address: string}[]} list 新清单
6330
- * @param {string} active 新的生效地址('' 表示清单为空)
6331
- * @returns {void}
6140
+ * 还挂在草稿里的凭据引用名。引用名由凭据行自己给出(硬约束 4),不从别处猜。
6141
+ * @returns {string[]} 需要写进凭据库的引用名
6332
6142
  */
6333
- const writeSenders = React.useCallback((list, active) => {
6334
- setFields({ senders: list, activeSender: active, senderName: '', fromAddress: '' });
6335
- }, [setFields]);
6336
-
6337
- /** 打开发件人弹层:index 传清单长度即"新增"。 */
6338
- const openSenderDialog = React.useCallback((index) => {
6339
- const entry = index < senders.length ? senders[index] : { name: '', address: '' };
6340
- setSenderEdit({ index, name: entry.name, address: entry.address });
6341
- setSenderErr('');
6342
- }, [senders]);
6343
-
6344
- /** 关闭发件人弹层。 */
6345
- const closeSenderDialog = React.useCallback(() => {
6346
- setSenderEdit(null);
6347
- setSenderErr('');
6348
- }, []);
6349
-
6350
- /** 保存发件人弹层里的内容;不合法就地报错、不写入。 */
6351
- const saveSenderDialog = React.useCallback(() => {
6352
- const issue = senderIssue(senders, senderEdit.index, senderEdit);
6353
- if (issue !== '') {
6354
- setSenderErr(t(`sender.issue.${issue}`));
6355
- return;
6143
+ const pendingCredentialRefs = React.useCallback(() => {
6144
+ const refs = new Set();
6145
+ for (const [ref, value] of Object.entries(pwDraft)) {
6146
+ if (String(value ?? '').trim() !== '') refs.add(ref);
6147
+ }
6148
+ for (const [ref, flag] of Object.entries(pwClear)) {
6149
+ if (flag === true) refs.add(ref);
6356
6150
  }
6357
- const next = senderCommit({ list: senders, active: senderAddr, editing: senderEdit.index }, senderEdit);
6358
- writeSenders(next.list, next.active);
6359
- setSenderEdit(null);
6360
- setSenderErr('');
6361
- }, [senders, senderEdit, senderAddr, writeSenders, t]);
6362
-
6363
- /** 选中某条发件人。 */
6364
- const pickSender = React.useCallback((address) => {
6365
- writeSenders(senders, address);
6366
- }, [senders, writeSenders]);
6367
-
6368
- /** 删掉某条发件人。 */
6369
- const removeSender = React.useCallback((index) => {
6370
- const next = senderRemove({ list: senders, active: senderAddr }, index);
6371
- writeSenders(next.list, next.active);
6372
- }, [senders, senderAddr, writeSenders]);
6151
+ return [...refs].filter((ref) => String(ref ?? '').trim() !== '');
6152
+ }, [pwDraft, pwClear]);
6373
6153
 
6374
6154
  /**
6375
- * 写凭据。**目标引用名由调用方给定**(硬约束 4),绝不从草稿里猜。
6155
+ * 写一条凭据。**目标引用名由调用方给定**(硬约束 4),绝不从草稿里猜。
6156
+ * 只有 saveAll 会调它:保存是唯一写凭据库的时机;失败就抛出,由调用方转成「保存失败」。
6376
6157
  * @param {string} ref 凭据引用名
6377
6158
  * @returns {Promise<void>}
6378
6159
  */
6379
6160
  const commitCredential = React.useCallback(async (ref) => {
6380
6161
  const bridge = remotes.current;
6381
- const view = viewRef.current;
6382
- if (bridge === null || view === null) return;
6162
+ if (bridge === null) return;
6383
6163
  const name = String(ref ?? '').trim();
6384
6164
  if (name === '') return;
6385
- const value = pwDraft[name] ?? '';
6165
+ const value = String(pwDraft[name] ?? '');
6386
6166
  const clearing = pwClear[name] === true;
6387
- if (!clearing && value === '') return;
6167
+ if (!clearing && value.trim() === '') return;
6168
+ if (clearing) unwrap(await bridge.credentials.unset(name));
6169
+ else unwrap(await bridge.credentials.set(name, value));
6170
+ setPwDraft((prev) => ({ ...prev, [name]: '' }));
6171
+ setPwClear((prev) => ({ ...prev, [name]: false }));
6172
+ await refreshCredentials([name]);
6173
+ }, [remotes, pwDraft, pwClear, refreshCredentials]);
6174
+
6175
+ /**
6176
+ * 保存:把草稿与基线的差异一次性写回。
6177
+ *
6178
+ * 顺序与原子性(这是这一页唯一的写入口):
6179
+ * 1. 配置字段合成**一次** mutate(found.ns, ops, revision);
6180
+ * 2. 授权码草稿与已存值不同时,再把它写进凭据库;
6181
+ * 3. 两步都成功才进入「已保存」,并把 revision 换成 mutate 返回的新值。
6182
+ *
6183
+ * 三种失败各有各的出路,**都不清空草稿**(用户填的东西留在原地):
6184
+ * - revision 冲突(settings/conflict):自动重读,草稿不动、revision 换新,让用户再点一次;
6185
+ * - 只写了半截:分开报「哪一半成功、哪一半没成」,并只给失败那一半的重试按钮;
6186
+ * - 两半都没成:退回「保存失败:原因」+ 重试(凭据那边的原因一并带上)。
6187
+ * @returns {Promise<void>}
6188
+ */
6189
+ const saveAll = React.useCallback(async () => {
6190
+ const bridge = remotes.current;
6191
+ const view = viewRef.current;
6192
+ if (bridge === null || view === null) return;
6193
+ const ops = diffOps(draftRef.current, baselineRef.current);
6194
+ const credRefs = pendingCredentialRefs();
6195
+ if (ops.length === 0 && credRefs.length === 0) {
6196
+ setSave({ phase: 'idle', at: 0, error: '' });
6197
+ return;
6198
+ }
6199
+ setSave({ phase: 'saving', at: 0, error: '' });
6200
+ // 两半各自记账。一句「保存失败」会把已经成功的那一半也抹掉,用户根本不知道该重试哪边,
6201
+ // 于是他会把两边都重做一遍——凭据那半还可能是清空再重填。所以这里分开记、分开报。
6202
+ let configError = '';
6203
+ let credError = '';
6204
+ if (ops.length > 0) {
6205
+ try {
6206
+ const next = unwrap(await bridge.settings.mutate(view.ns, ops, view.revision));
6207
+ const after = next && typeof next === 'object' ? next : view;
6208
+ // ns 一律以这次返回的为准:认领来的 ns 不能被任何写死的东西盖回去。
6209
+ viewRef.current = {
6210
+ ...after,
6211
+ ns: typeof after.ns === 'string' && after.ns !== '' ? after.ns : view.ns,
6212
+ };
6213
+ // 配置这一段已经真的落盘,基线抬到刚发出去的那份草稿;
6214
+ // 万一接下来凭据写失败,脏读数就只剩那一条凭据。
6215
+ baselineRef.current = { ...draftRef.current };
6216
+ setBaseline({ ...draftRef.current });
6217
+ } catch (err) {
6218
+ if (isConflict(err)) {
6219
+ // 别人先改了:**自动重读**(草稿原样保留、revision 换成新的),再让用户点一次保存。
6220
+ // 不重读的话重试会拿着旧 revision 再撞一次,这条路是死的。
6221
+ await load({ preserveDraft: true });
6222
+ return;
6223
+ }
6224
+ configError = String(err?.message ?? err);
6225
+ }
6226
+ }
6227
+ // 配置那半失败也照旧写凭据:两半互不依赖,一次点击把能落盘的那半落盘。
6228
+ for (const ref of credRefs) {
6229
+ try {
6230
+ await commitCredential(ref);
6231
+ } catch (err) {
6232
+ credError = String(err?.message ?? err);
6233
+ break;
6234
+ }
6235
+ }
6236
+ const credWrote = credRefs.length > 0 && credError === '';
6237
+ if (configError === '' && credError === '') {
6238
+ setSave({ phase: 'saved', at: Date.now(), error: '' });
6239
+ return;
6240
+ }
6241
+ if (configError === '') {
6242
+ // 配置成功、凭据失败:只该重试凭据。
6243
+ setSave({ phase: 'partialCred', at: 0, error: credError, credError: '' });
6244
+ return;
6245
+ }
6246
+ if (credWrote) {
6247
+ // 凭据成功、配置失败:只该重试配置。
6248
+ setSave({ phase: 'partialConfig', at: 0, error: configError, credError: '' });
6249
+ return;
6250
+ }
6251
+ // 两半都没成(或压根没凭据可写):退回到原来的「保存失败」一档,但把凭据的原因也带上。
6252
+ setSave({ phase: 'error', at: 0, error: configError, credError });
6253
+ }, [remotes, pendingCredentialRefs, commitCredential, load]);
6254
+
6255
+ /** 只重试授权码(配置那一半已经写成功了)。 */
6256
+ const retryCredentials = React.useCallback(async () => {
6257
+ const credRefs = pendingCredentialRefs();
6258
+ if (credRefs.length === 0) {
6259
+ setSave({ phase: 'idle', at: 0, error: '' });
6260
+ return;
6261
+ }
6388
6262
  setSave({ phase: 'saving', at: 0, error: '' });
6389
6263
  try {
6390
- if (clearing) unwrap(await bridge.credentials.unset(name));
6391
- else unwrap(await bridge.credentials.set(name, value));
6392
- setPwDraft((prev) => ({ ...prev, [name]: '' }));
6393
- setPwClear((prev) => ({ ...prev, [name]: false }));
6264
+ for (const ref of credRefs) await commitCredential(ref);
6394
6265
  setSave({ phase: 'saved', at: Date.now(), error: '' });
6395
- await refreshCredentials([name, draftRef.current?.smtpPasswordRef]);
6396
6266
  } catch (err) {
6397
- setSave({ phase: 'error', at: 0, error: String(err?.message ?? err) });
6267
+ setSave({ phase: 'partialCred', at: 0, error: String(err?.message ?? err), credError: '' });
6398
6268
  }
6399
- }, [remotes, pwDraft, pwClear, refreshCredentials]);
6269
+ }, [pendingCredentialRefs, commitCredential]);
6270
+
6271
+ /** 只重试配置(授权码那一半已经写成功了)。 */
6272
+ const retryConfig = React.useCallback(async () => {
6273
+ const bridge = remotes.current;
6274
+ const view = viewRef.current;
6275
+ if (bridge === null || view === null) return;
6276
+ const ops = diffOps(draftRef.current, baselineRef.current);
6277
+ if (ops.length === 0) {
6278
+ setSave({ phase: 'idle', at: 0, error: '' });
6279
+ return;
6280
+ }
6281
+ setSave({ phase: 'saving', at: 0, error: '' });
6282
+ try {
6283
+ const next = unwrap(await bridge.settings.mutate(view.ns, ops, view.revision));
6284
+ const after = next && typeof next === 'object' ? next : view;
6285
+ viewRef.current = {
6286
+ ...after,
6287
+ ns: typeof after.ns === 'string' && after.ns !== '' ? after.ns : view.ns,
6288
+ };
6289
+ baselineRef.current = { ...draftRef.current };
6290
+ setBaseline({ ...draftRef.current });
6291
+ setSave({ phase: 'saved', at: Date.now(), error: '' });
6292
+ } catch (err) {
6293
+ if (isConflict(err)) {
6294
+ await load({ preserveDraft: true });
6295
+ return;
6296
+ }
6297
+ setSave({ phase: 'partialConfig', at: 0, error: String(err?.message ?? err), credError: '' });
6298
+ }
6299
+ }, [remotes, load]);
6400
6300
 
6401
- /** 恢复默认:清掉本命名空间在 settings.yaml 里的全部覆盖。 */
6301
+ /**
6302
+ * 放弃改动:草稿整体回滚到基线,授权码草稿一并清掉,读数回到「没有未保存的改动」。
6303
+ * @returns {void}
6304
+ */
6305
+ const discardAll = React.useCallback(() => {
6306
+ const base = baselineRef.current ?? {};
6307
+ draftRef.current = { ...base };
6308
+ setDraft({ ...base });
6309
+ setPwDraft({});
6310
+ setPwClear({});
6311
+ setPwEdit({});
6312
+ setSave({ phase: 'idle', at: 0, error: '' });
6313
+ }, []);
6314
+
6315
+ /**
6316
+ * 恢复全部默认:清掉本命名空间在 profile 里的全部覆盖,**立即生效**。
6317
+ *
6318
+ * 它是破坏性操作,所以照旧先弹确认;确认之后不再受「保存」按钮约束——
6319
+ * 语义就是「把服务端的覆盖清空」,草稿随后按服务端的新值重建。
6320
+ */
6402
6321
  const resetAll = React.useCallback(async () => {
6403
6322
  const bridge = remotes.current;
6404
6323
  const view = viewRef.current;
@@ -6406,65 +6325,75 @@ window.__ModuleLoader__.load({
6406
6325
  setResetting(true);
6407
6326
  setSave({ phase: 'saving', at: 0, error: '' });
6408
6327
  try {
6409
- const next = unwrap(await bridge.settings.replace(NS, {}, view.revision));
6410
- if (next && typeof next === 'object') viewRef.current = next;
6328
+ const next = unwrap(await bridge.settings.replace(view.ns, {}, view.revision));
6329
+ if (next && typeof next === 'object') {
6330
+ viewRef.current = {
6331
+ ...next,
6332
+ ns: typeof next.ns === 'string' && next.ns !== '' ? next.ns : view.ns,
6333
+ };
6334
+ }
6335
+ // 服务端覆盖已清空:**凭据草稿一并丢掉**。留着它在草稿里,会立刻又算出一项
6336
+ // 「有 1 项改动未保存」,而那一项其实属于刚被重置掉的旧配置(授权码本身不受重置影响)。
6337
+ setPwDraft({});
6338
+ setPwClear({});
6339
+ setPwEdit({});
6411
6340
  await load();
6412
6341
  setSave({ phase: 'saved', at: Date.now(), error: '' });
6413
6342
  } catch (err) {
6343
+ // 失败不重读:重读会把用户还没保存的草稿冲掉。
6414
6344
  setSave({ phase: 'error', at: 0, error: String(err?.message ?? err) });
6415
- await load();
6416
6345
  } finally {
6417
6346
  setResetting(false);
6418
6347
  setConfirmReset(false);
6419
6348
  }
6420
6349
  }, [remotes, load]);
6421
6350
 
6422
- /** 发一封测试邮件。Host 没提供该能力时不显示按钮。 */
6351
+ /**
6352
+ * 发一封测试邮件。
6353
+ *
6354
+ * 结果是 RPC **当场**返回的:成败与原始报错直接进 test 态,
6355
+ * 因为账本删掉之后,这条路径就是唯一的「真投递过没有」的证据。
6356
+ */
6423
6357
  const sendTest = React.useCallback(async () => {
6424
- if (capability !== 'ready') return;
6425
- setTest({ phase: 'sending', transport: '', ms: 0, error: '' });
6358
+ if (typeof remotes.current?.rpc?.call !== 'function') return;
6359
+ setTest({ phase: 'sending', ms: 0, error: '' });
6426
6360
  try {
6427
6361
  const result = unwrap(await callRemote('test'));
6428
6362
  if (result && result.ok === false) {
6429
- setTest({ phase: 'fail', transport: String(result.transport ?? ''), ms: 0, error: String(result.error ?? '') });
6363
+ setTest({ phase: 'fail', ms: 0, error: String(result.error ?? '') });
6430
6364
  } else {
6431
6365
  setTest({
6432
6366
  phase: 'ok',
6433
- transport: String(result?.transport ?? ''),
6434
6367
  ms: Number(result?.ms ?? 0),
6435
6368
  error: '',
6436
6369
  });
6437
6370
  }
6438
- setLastDelivery(result ?? null);
6439
6371
  } catch (err) {
6440
- setTest({ phase: 'fail', transport: '', ms: 0, error: String(err?.message ?? err) });
6441
- }
6442
- }, [remotes, capability]);
6443
-
6444
- // 离开页面时把还在防抖里的改动落盘,避免"改完就走"丢改动
6445
- React.useEffect(() => () => {
6446
- for (const [key, timer] of Object.entries(timers.current)) {
6447
- clearTimeout(timer);
6448
- void commit([key]);
6372
+ setTest({ phase: 'fail', ms: 0, error: String(err?.message ?? err) });
6449
6373
  }
6450
- }, [commit]);
6374
+ }, [remotes]);
6451
6375
 
6452
6376
  // ---- 派生值 ----
6453
6377
  const recipients = parseRecipients(draft.recipients);
6378
+ // 凭据行的写入目标由**草稿**给出(硬约束 4:写入目标跟着这一行走)。
6454
6379
  const smtpPasswordRef = String(draft.smtpPasswordRef ?? '').trim();
6455
6380
  const configured = (ref) => ref !== '' && credentialState[ref] === true;
6381
+ // 判决条一律按**已保存的配置**(baseline)判定。
6382
+ // 拿草稿下结论会当场骗人:草稿填齐就报「可以发信了」,而「发送测试邮件」用的正是已保存
6383
+ // 配置,用户一点就失败——先被夸一句、再被打一巴掌,比什么都不说更糟。
6384
+ const savedRecipients = parseRecipients(baseline.recipients);
6385
+ const savedPasswordRef = String(baseline.smtpPasswordRef ?? '').trim();
6456
6386
  const statusKey = deriveStatus({
6457
- enabled: draft.enabled === true,
6458
- recipientCount: recipients.valid.length,
6459
- host: draft.smtpHost,
6460
- user: draft.smtpUser,
6461
- credentialConfigured: configured(smtpPasswordRef),
6462
- lastDelivery,
6387
+ enabled: baseline.enabled === true,
6388
+ recipientCount: savedRecipients.valid.length,
6389
+ host: baseline.smtpHost,
6390
+ user: baseline.smtpUser,
6391
+ credentialConfigured: configured(savedPasswordRef),
6463
6392
  });
6464
- /** SMTP 这一段自己配好了没有——凭据行在下面很远,段头这一格是它唯一的上层读数。 */
6465
- const transportReady = configured(smtpPasswordRef)
6466
- && String(draft.smtpHost ?? '').trim() !== ''
6467
- && String(draft.smtpUser ?? '').trim() !== '';
6393
+ /** SMTP 这一段自己配好了没有——凭据行在下面很远,段头这一格是它唯一的上层读数(同样按已保存值)。 */
6394
+ const transportReady = configured(savedPasswordRef)
6395
+ && String(baseline.smtpHost ?? '').trim() !== ''
6396
+ && String(baseline.smtpUser ?? '').trim() !== '';
6468
6397
  const transportState = t(transportReady ? 'transportState.done' : 'transportState.todo');
6469
6398
  const STATUS_TITLE_KEY = {
6470
6399
  disabled: 'status.disabled',
@@ -6473,7 +6402,6 @@ window.__ModuleLoader__.load({
6473
6402
  needUser: 'status.needUser',
6474
6403
  needCredential: 'status.needCredential',
6475
6404
  ready: 'status.ready',
6476
- lastFailed: 'status.lastFailed',
6477
6405
  };
6478
6406
  /** 判决句:**全页最大的字号**,也是这一页真正要回答的那句话。 */
6479
6407
  const statusTitle = statusKey === 'needCredential'
@@ -6485,32 +6413,105 @@ window.__ModuleLoader__.load({
6485
6413
  'notifyAbortedByUser', 'notifyInterrupted', 'notifyMaxTokens']
6486
6414
  .filter((key) => draft[key] === true).length;
6487
6415
  const preset = matchPreset(draft.smtpHost);
6488
- const testSupported = capability === 'ready';
6416
+ /** 远程桥接有没有接上;没接上时这一页连草稿都读不到,先不给人按钮。 */
6417
+ const bridgeReady = remotes.current !== null;
6418
+ /** 能不能真发一封:只看 connection 有没有把 RPC 接进来(不再探 Host 端点)。 */
6419
+ const rpcReady = typeof remotes.current?.rpc?.call === 'function';
6489
6420
  const sending = test.phase === 'sending';
6490
6421
  /** 判决条那一档:状态→色调的映射只此一份。 */
6491
6422
  const tone = STATUS_TONE[statusKey] ?? 'idle';
6492
6423
 
6424
+ // ---- 草稿 vs 基线:保存按钮的可用状态与整条读数都由这份差异决定 ----
6425
+ /** 与基线不同的配置字段(保存时合成一条 mutate 的 ops)。 */
6426
+ const pendingOps = diffOps(draft, baseline);
6427
+ /** 还挂在草稿里的凭据引用名(授权码也算一项改动)。 */
6428
+ const pendingCredRefs = pendingCredentialRefs();
6429
+ /** 未保存的改动数:N 个配置字段 + 至多一条授权码。 */
6430
+ const dirtyCount = pendingOps.length + pendingCredRefs.length;
6431
+ /** 读数那一档的色调。半成功也算异常:那种状态绝不能长成「一切正常」的样子。 */
6432
+ const saveTone = save.phase === 'error' || save.phase === 'unserved' || save.phase === 'loadFailed'
6433
+ || save.phase === 'partialCred' || save.phase === 'partialConfig'
6434
+ ? 'error'
6435
+ : (save.phase === 'saving' ? 'busy' : (dirtyCount > 0 ? 'dirty' : 'idle'));
6493
6436
  /**
6494
- * 最近一次投递。没有就返回 null(此时只说"还没试过",不摆一行空的)。
6495
- *
6496
- * 它同时是页面的第一条排错线索:分类(认证被拒 / 发件人被拒 / 网络)给出"该动哪里",
6497
- * 原始报错原样附在后面——不改写才是真的可排错。
6437
+ * 保存读数。只说这一刻的事实:干净 / 脏 / 保存中 / 已保存多久 / 失败原因 + 重试,
6438
+ * 以及三种非干净结局各自的动作——读失败给「重新读取」、冲突给「再点一次保存」、
6439
+ * 半成功只给失败那一半的重试。动作永远指向**这一次真正该做的事**,不是笼统的「重试」。
6440
+ * @returns {object[]} 读数节点
6498
6441
  */
6499
- const deliveryView = lastDelivery === null ? null : (() => {
6500
- const error = classifySendError(lastDelivery.error);
6501
- return {
6502
- ok: lastDelivery.ok === true,
6503
- ago: relativeTime(lastDelivery.at, now),
6504
- elapsed: Number(lastDelivery.ms ?? 0),
6505
- raw: String(lastDelivery.error ?? ''),
6506
- error,
6442
+ const saveReadout = () => {
6443
+ if (save.phase === 'unserved') return [t('status.notServed')];
6444
+ if (save.phase === 'loadFailed') {
6445
+ // 读失败不是「保存失败」:这里一个字都不许写回,只重读。
6446
+ return [
6447
+ t('save.loadFailed', { error: save.error }),
6448
+ h('button', {
6449
+ type: 'button',
6450
+ className: 'dshmn-link',
6451
+ onClick: () => void load(),
6452
+ }, t('save.reload')),
6453
+ ];
6454
+ }
6455
+ if (save.phase === 'conflictReloaded') return [t('save.conflictReloaded')];
6456
+ if (save.phase === 'partialCred') {
6457
+ // 配置写进去了、授权码没写进去:只给授权码那一半的重试。
6458
+ return [
6459
+ t('save.credFailed', { error: save.error }),
6460
+ h('button', {
6461
+ type: 'button',
6462
+ className: 'dshmn-link',
6463
+ onClick: () => void retryCredentials(),
6464
+ }, t('save.retryCred')),
6465
+ ];
6466
+ }
6467
+ if (save.phase === 'partialConfig') {
6468
+ // 授权码写进去了、配置没写进去:只给配置那一半的重试。
6469
+ return [
6470
+ t('save.configFailed', { error: save.error }),
6471
+ h('button', {
6472
+ type: 'button',
6473
+ className: 'dshmn-link',
6474
+ onClick: () => void retryConfig(),
6475
+ }, t('save.retryConfig')),
6476
+ ];
6477
+ }
6478
+ if (save.phase === 'error') {
6479
+ return [
6480
+ t('save.error', { error: save.error }),
6481
+ String(save.credError ?? '') === '' ? null : t('save.credAlsoFailed', { error: save.credError }),
6482
+ h('button', {
6483
+ type: 'button',
6484
+ className: 'dshmn-link',
6485
+ onClick: () => void saveAll(),
6486
+ }, t('save.retry')),
6487
+ ].filter((node) => node !== null);
6488
+ }
6489
+ if (save.phase === 'saving') return [t('save.saving')];
6490
+ if (dirtyCount > 0) return [t('save.dirty', { n: dirtyCount })];
6491
+ 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 })];
6494
+ }
6495
+ return [t('save.clean')];
6496
+ };
6497
+
6498
+ // 有未保存改动时,关闭 / 离开页面要拦一下;没有改动就完全静默(日常切页不该被打断)。
6499
+ React.useEffect(() => {
6500
+ if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return undefined;
6501
+ if (dirtyCount === 0) return undefined;
6502
+ const guard = (event) => {
6503
+ event.preventDefault();
6504
+ event.returnValue = t('save.dirty', { n: dirtyCount });
6505
+ return event.returnValue;
6507
6506
  };
6508
- })();
6507
+ window.addEventListener('beforeunload', guard);
6508
+ return () => window.removeEventListener('beforeunload', guard);
6509
+ }, [dirtyCount]);
6509
6510
 
6510
- /** 测试按钮文案:发的时候说在发;之前失败过就说重测。 */
6511
+ /** 测试按钮文案:发的时候说在发;刚才那次没成功就说重测。 */
6511
6512
  const testLabel = sending
6512
6513
  ? t('act.testing')
6513
- : (test.phase === 'fail' || (lastDelivery !== null && lastDelivery.ok === false) ? t('act.retest') : t('act.test'));
6514
+ : (test.phase === 'fail' ? t('act.retest') : t('act.test'));
6514
6515
 
6515
6516
  /**
6516
6517
  * 「发送测试邮件」——本页唯一的主行动。
@@ -6522,10 +6523,10 @@ window.__ModuleLoader__.load({
6522
6523
  * @returns {object|null} React 元素
6523
6524
  */
6524
6525
  const testButton = (options) => {
6525
- if (capability === 'unknown') return null;
6526
- if (!testSupported) return h('span', { className: 'dshmn-hint' }, t('act.needsRestart'));
6527
- // 凭据行里用文字链接:手刚离开输入框,验证就在原地,不用滚回页顶;
6528
- // 但它不抢判决条的主按钮,所以只做链接。
6526
+ if (!bridgeReady) return null;
6527
+ if (!rpcReady) return h('span', { className: 'dshmn-hint' }, t('act.needsRestart'));
6528
+ // 凭据行那处用文字链接:手刚离开授权码输入框,验证就在原地,不必滚回页首。
6529
+ // 它和判决条是同一个动作,但主次分明——判决条是主按钮,这里是弱入口。
6529
6530
  if (options?.variant === 'link') {
6530
6531
  return h('button', {
6531
6532
  type: 'button',
@@ -6546,68 +6547,78 @@ window.__ModuleLoader__.load({
6546
6547
  };
6547
6548
 
6548
6549
  /**
6549
- * 判决条的动作 = **下一步该做的那一件事**(主按钮)+ 可选的一次验证。
6550
+ * 判决条的动作区:**全页唯一主行动**是「发送测试邮件」,实心主按钮落在右下角。
6550
6551
  *
6551
- * 还没配完时主按钮是"去填写"(把光标送到缺的那一格),配好了才是发测试邮件;
6552
- * 验证按钮任何时候都在,因为"还差什么"恰恰要靠它一次点击问出来。
6552
+ * 它不挑状态:配置只填了一半时,"怎么验证"恰恰最需要出现(一次点击就把缺什么问出来)。
6553
+ * 没配完时左边再给一个次级按钮「启用 / 去填写」——它是下一步,但不跟主行动抢主次:
6554
+ * 已关闭时是「启用」,缺字段时是「去填写」(把光标送到缺的那一格)。
6555
+ * @returns {object[]} [次要的下一步, 实心主按钮],主按钮永远在最后(右下角)
6553
6556
  */
6554
- const configAction = (() => {
6555
- if (statusKey === 'disabled') {
6556
- return h(Button, {
6557
- variant: 'primary',
6558
- size: 'sm',
6559
- onClick: () => setField('enabled', true, { immediate: true }),
6560
- }, t('act.enable'));
6561
- }
6562
- if (statusKey === 'ready' || statusKey === 'lastFailed') return testButton({ variant: 'primary' });
6563
- return h(Button, {
6564
- variant: 'primary',
6565
- size: 'sm',
6566
- onClick: () => focusMissing(),
6567
- }, t('act.goFill'));
6568
- })();
6569
- /** 还没配完时,测试仍然给,但降为次级:它是验证,不是下一步。 */
6570
- const secondaryTest = (statusKey === 'ready' || statusKey === 'lastFailed') ? null : testButton();
6557
+ const verdictActions = () => {
6558
+ const nextStep = statusKey === 'ready'
6559
+ ? null
6560
+ : (statusKey === 'disabled'
6561
+ ? h(Button, {
6562
+ variant: 'outline',
6563
+ size: 'sm',
6564
+ onClick: () => setField('enabled', true),
6565
+ }, t('act.enable'))
6566
+ : h(Button, {
6567
+ variant: 'outline',
6568
+ size: 'sm',
6569
+ onClick: () => focusMissing(),
6570
+ }, t('act.goFill')));
6571
+ return [nextStep, testButton({ variant: 'primary' })];
6572
+ };
6573
+
6574
+ /** 把光标送到某一格(元素不在就什么也不做)。 */
6575
+ const focusField = (id) => {
6576
+ const element = typeof document === 'undefined' || typeof document.getElementById !== 'function'
6577
+ ? null
6578
+ : document.getElementById(id);
6579
+ if (element === null || element === undefined) return;
6580
+ if (typeof element.scrollIntoView === 'function') element.scrollIntoView({ block: 'center' });
6581
+ if (typeof element.focus === 'function') element.focus();
6582
+ };
6571
6583
 
6572
6584
  /** 把用户送到缺的那一格,而不是让他自己在一屏里找。 */
6573
6585
  const focusMissing = () => {
6574
- const target = statusKey === 'needRecipients' ? 'dshmn-recipients'
6586
+ focusField(statusKey === 'needRecipients' ? 'dshmn-recipients'
6575
6587
  : (statusKey === 'needHost' ? 'dshmn-smtp-host'
6576
- : (statusKey === 'needUser' ? 'dshmn-smtp-user' : 'dshmn-cred-smtp'));
6577
- const element = typeof document === 'undefined' ? null : document.getElementById(target);
6578
- if (element === null || element === undefined) return;
6579
- element.scrollIntoView({ block: 'center' });
6580
- element.focus();
6588
+ : (statusKey === 'needUser' ? 'dshmn-smtp-user' : 'dshmn-cred-smtp')));
6581
6589
  };
6582
6590
 
6583
6591
  /**
6584
- * 判决条底部那条"读数"。
6592
+ * 「发送测试邮件」这次点击的回执。
6585
6593
  *
6586
- * 它是这一页唯一能回答"真发得出去吗"的东西,所以放在判决条里、用一条发丝线跟判决分开。
6587
- * 失败时分类 + 原文都给:只给"发送失败"四个字等于没给线索。
6594
+ * 账本删掉之后,这里是**唯一**能回答「真投递过没有」的地方,所以它必须
6595
+ * 成功给用时、失败给分类 + 原文:只报一句「已发送」等于把人推回日志里。
6596
+ * 状态只活在内存里、刷新页面即消失——它报的是这一次点击,不是历史。
6588
6597
  * @returns {object|null} React 元素
6589
6598
  */
6590
- const evidence = () => {
6591
- if (deliveryView === null) {
6599
+ const testResult = () => {
6600
+ if (test.phase === 'sending') {
6592
6601
  return h('div', { className: 'dshmn-evidence' },
6593
- h('span', { className: 'dshmn-evidenceKey' }, t('evidence.label')),
6594
- h('span', null, t('evidence.never')));
6602
+ h('span', { className: 'dshmn-evidenceKey' }, t('act.testing')));
6595
6603
  }
6596
- const view = deliveryView;
6597
- const detail = view.ok
6598
- ? [h('span', { key: 'ok' }, t('evidence.ok', { ms: view.elapsed, ago: view.ago }))]
6599
- : [
6600
- h('span', { key: 'reason' }, `${t('evidence.fail')}:${view.error.label}(${view.ago})`),
6601
- h('span', { key: 'hint', className: 'dshmn-hint' }, view.error.hint),
6602
- h('span', { key: 'raw', className: 'dshmn-mono' }, view.raw),
6603
- ];
6604
- return h('div', { className: 'dshmn-evidence', 'data-ok': view.ok ? 'true' : 'false' },
6605
- h('span', { className: 'dshmn-evidenceKey' }, t('evidence.label')),
6606
- ...detail);
6604
+ if (test.phase === 'ok') {
6605
+ return h('div', { className: 'dshmn-evidence', 'data-ok': 'true' },
6606
+ h('span', { className: 'dshmn-evidenceKey' }, t('test.sent')),
6607
+ h('span', null, t('test.sentMs', { ms: Number(test.ms ?? 0) })));
6608
+ }
6609
+ if (test.phase === 'fail') {
6610
+ const reason = classifySendError(test.error);
6611
+ return h('div', { className: 'dshmn-evidence', 'data-ok': 'false' },
6612
+ h('span', { className: 'dshmn-evidenceKey' }, t('test.failed')),
6613
+ h('span', null, reason.label),
6614
+ h('span', { className: 'dshmn-hint' }, reason.hint),
6615
+ h('span', { className: 'dshmn-mono' }, String(test.error ?? '')));
6616
+ }
6617
+ return null;
6607
6618
  };
6608
6619
  /**
6609
6620
  * 凭据行。保存前给输入框(这里才是要动手的地方);保存后**不再摆一个空输入框**——
6610
- * 那样只会让人怀疑"是不是没存上"。存好之后是:✓ 已保存 + 测试 + 更换 + 清除。
6621
+ * 那样只会让人怀疑"是不是没存上"。存好之后是:✓ 已保存 + 更换 + 清除(测试入口全页只在判决条一处)。
6611
6622
  * @param {string} fieldId 输入框 id
6612
6623
  * @param {string} labelKey 名称文案键
6613
6624
  * @param {string} ref 凭据引用名(写入目标由调用方给定,硬约束 4)
@@ -6617,16 +6628,21 @@ window.__ModuleLoader__.load({
6617
6628
  */
6618
6629
  const credentialField = (fieldId, labelKey, ref, hintKey, helpKey) => {
6619
6630
  const confirmed = configured(ref);
6620
- const clear = pwClear[ref] === true;
6621
- const editing = confirmed !== true || clear;
6631
+ // 「更换」只是把输入框展开,不算一项改动;真正待清理的是 pwClear。
6632
+ const editing = confirmed !== true || pwEdit[ref] === true;
6633
+ // 「怎么拿授权码?」从行内链接收成标题旁的问号:和 8 个事件开关同一套交互,
6634
+ // 省一行宽度,把位置让给真正的动作(更换 / 清除 / 发送测试邮件)。
6622
6635
  const helpButton = h('button', {
6623
6636
  type: 'button',
6624
- className: 'dshmn-link',
6637
+ className: 'dshmn-q',
6638
+ 'aria-label': t(helpKey),
6625
6639
  onClick: () => setHelpKind(true),
6626
- }, t(helpKey));
6640
+ }, '?');
6627
6641
  return h('div', { className: 'dshmn-item' },
6628
6642
  h('div', { className: 'dshmn-itemHead' },
6629
- h('label', { className: 'dshmn-label', htmlFor: fieldId }, t(labelKey)),
6643
+ h('span', { className: 'dshmn-labelRow' },
6644
+ h('label', { className: 'dshmn-label', htmlFor: fieldId }, t(labelKey)),
6645
+ helpButton),
6630
6646
  h('span', { className: 'dshmn-credState', 'data-ok': confirmed ? 'true' : 'false' },
6631
6647
  confirmed ? h('span', { 'aria-hidden': 'true' }, h(CheckedMark, null)) : null,
6632
6648
  confirmed ? t('cred.saved') : t('cred.notSaved'))),
@@ -6641,8 +6657,6 @@ window.__ModuleLoader__.load({
6641
6657
  setPwDraft((prev) => ({ ...prev, [ref]: value }));
6642
6658
  setPwClear((prev) => ({ ...prev, [ref]: false }));
6643
6659
  },
6644
- onBlur: () => void commitCredential(ref),
6645
- onKeyDown: (event) => { if (event.key === 'Enter') void commitCredential(ref); },
6646
6660
  })
6647
6661
  : h('div', null,
6648
6662
  h(TextInput, {
@@ -6652,7 +6666,7 @@ window.__ModuleLoader__.load({
6652
6666
  readOnly: true,
6653
6667
  'aria-label': `${t(labelKey)}:${t('cred.saved')}`,
6654
6668
  placeholder: t('cred.saved'),
6655
- onFocus: () => setPwClear((prev) => ({ ...prev, [ref]: true })),
6669
+ onFocus: () => setPwEdit((prev) => ({ ...prev, [ref]: true })),
6656
6670
  })),
6657
6671
  h('div', { className: 'dshmn-hint' }, t(hintKey)),
6658
6672
  save.phase === 'saving' ? h('div', { className: 'dshmn-hint' }, t('save.checkingCred')) : null,
@@ -6669,7 +6683,7 @@ window.__ModuleLoader__.load({
6669
6683
  ? h('button', {
6670
6684
  type: 'button',
6671
6685
  className: 'dshmn-link',
6672
- onClick: () => setPwClear((prev) => ({ ...prev, [ref]: true })),
6686
+ onClick: () => setPwEdit((prev) => ({ ...prev, [ref]: true })),
6673
6687
  }, t('cred.replace'))
6674
6688
  : null,
6675
6689
  confirmed
@@ -6679,40 +6693,97 @@ window.__ModuleLoader__.load({
6679
6693
  onClick: () => {
6680
6694
  setPwDraft((prev) => ({ ...prev, [ref]: '' }));
6681
6695
  setPwClear((prev) => ({ ...prev, [ref]: true }));
6682
- void commitCredential(ref);
6683
6696
  },
6684
6697
  }, t('cred.clear'))
6685
6698
  : null,
6686
- helpButton,
6687
6699
  testButton({ variant: 'link' })));
6688
6700
  };
6689
6701
 
6690
- /** 分组头右侧那一格:只说这一段的结论,不摆装饰。 */
6702
+ /**
6703
+ * 一个事件开关的紧凑行:标题 + 问号 + 开关。
6704
+ *
6705
+ * 说明不再常显(8 行说明会把这一组撑成一屏),收进问号后面:**点击**开合而不是 hover,
6706
+ * 触屏点得到;原生 button + aria-expanded / aria-controls,键盘 tab 得到。
6707
+ * 说明文案仍然来自原来那对键(outcomes.*Hint),一个字都没重写。
6708
+ * @param {string} field 配置字段名
6709
+ * @param {string} titleKey 标题文案键
6710
+ * @param {string} hintKey 说明文案键
6711
+ * @returns {object} React 元素
6712
+ */
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');
6750
+
6751
+ /** 收件人那一组的读数:只说个数,落在段头的小字里(不再占一行正文)。 */
6691
6752
  const recipientsState = recipients.valid.length > 0
6692
6753
  ? t('recipients.ok', { n: recipients.valid.length })
6693
6754
  : t('recipients.none');
6694
6755
 
6695
6756
  return h('div', { className: 'dshmn-page', 'data-build': BUILD },
6696
6757
 
6697
- // ---- 页头:这一页干什么 + 落盘读数(读数常驻,不自灭)----
6698
- h('div', { className: 'dshmn-head' },
6699
- h('p', { className: 'dshmn-lead' }, t('lead')),
6700
- h('div', {
6701
- className: 'dshmn-stamp',
6702
- 'data-tone': save.phase === 'error' ? 'error' : (save.phase === 'saving' ? 'busy' : 'idle'),
6703
- 'aria-live': 'polite',
6704
- },
6705
- save.phase === 'error'
6706
- ? h(React.Fragment, null,
6707
- t('save.error', { error: save.error }),
6708
- h('button', { type: 'button', className: 'dshmn-link', onClick: () => void load() }, t('save.reload')))
6709
- : (save.phase === 'saving'
6710
- ? t('save.saving')
6711
- : (save.phase === 'saved' && save.at > 0
6712
- ? t('save.savedAgo', { n: Math.max(0, Math.round((now - save.at) / 1000)) })
6713
- : t('save.auto'))))),
6714
-
6715
- // ---- 判决条:全页唯一的填充面 ----
6758
+ // ---- 页头:标题独占一行(18/600),右上只有动作组 ----
6759
+ // 「脏读数 + 保存 + 放弃改动」是一个动作区:不画线、不放说明、不占第二层高度。
6760
+ // 宿主的设置面板头(h54)只放 actions + close,所以内容区第一行就该是标题。
6761
+ h('header', { className: 'dshmn-head' },
6762
+ h('div', { className: 'dshmn-headText' },
6763
+ h('h2', { className: 'dshmn-title' }, t('nav')),
6764
+ h('p', { className: 'dshmn-lead' }, t('lead'))),
6765
+ h('div', { className: 'dshmn-headActions' },
6766
+ h('div', {
6767
+ className: 'dshmn-stamp',
6768
+ 'data-tone': saveTone,
6769
+ 'aria-live': 'polite',
6770
+ }, ...saveReadout()),
6771
+ h(Button, {
6772
+ variant: 'primary',
6773
+ size: 'sm',
6774
+ // 没有改动就禁用:主按钮不该在无事可做时还亮着。
6775
+ disabled: dirtyCount === 0 || !bridgeReady || save.phase === 'saving',
6776
+ title: t('act.saveHint'),
6777
+ onClick: () => void saveAll(),
6778
+ }, t('act.save')),
6779
+ h(Button, {
6780
+ variant: 'outline',
6781
+ size: 'sm',
6782
+ disabled: dirtyCount === 0 || !bridgeReady || save.phase === 'saving',
6783
+ onClick: () => discardAll(),
6784
+ }, t('act.discard')))),
6785
+
6786
+ // ---- 判决条:全页唯一的填充面(状态色 hero);结论句 + 右下角那处主行动 ----
6716
6787
  h('section', {
6717
6788
  className: 'dshmn-verdict',
6718
6789
  'data-tone': tone,
@@ -6723,11 +6794,13 @@ window.__ModuleLoader__.load({
6723
6794
  h(Dot, { state: tone === 'done' ? 'done' : (tone === 'error' ? 'error' : (tone === 'warning' ? 'warning' : 'idle')) }),
6724
6795
  h('h2', { className: 'dshmn-verdictTitle' }, statusTitle)),
6725
6796
  h('p', { className: 'dshmn-verdictWhy' }, statusWhy),
6726
- evidence(),
6727
- h('div', { className: 'dshmn-verdictFoot' }, secondaryTest, configAction)),
6797
+ // 口径明写:这句判决算的是**已保存的配置**,屏幕上没保存的改动不算数。
6798
+ dirtyCount > 0 ? h('p', { className: 'dshmn-hint' }, t('status.unsavedDraft')) : null,
6799
+ testResult(),
6800
+ h('div', { className: 'dshmn-verdictFoot' }, ...verdictActions())),
6728
6801
 
6729
- // ---- 收件人 ----
6730
- h(Group, { title: t('group.recipients'), state: recipientsState },
6802
+ // ---- 卡片一:收件人 ----
6803
+ h(Card, { kind: 'recipients', title: t('group.recipients'), state: recipientsState },
6731
6804
  h('div', { className: 'dshmn-item' },
6732
6805
  h('div', { className: 'dshmn-field' },
6733
6806
  h('label', { className: 'dshmn-label', htmlFor: 'dshmn-recipients' }, t('recipients.label')),
@@ -6738,41 +6811,40 @@ window.__ModuleLoader__.load({
6738
6811
  placeholder: t('recipients.placeholder'),
6739
6812
  'aria-invalid': recipients.invalid.length > 0 ? 'true' : 'false',
6740
6813
  onChange: (event) => setField('recipients', event.target.value),
6741
- onBlur: () => flush('recipients'),
6742
6814
  }),
6743
6815
  h('div', { className: 'dshmn-hint' }, t('recipients.hint')),
6744
6816
  recipients.invalid.length > 0
6745
6817
  ? h('div', { className: 'dshmn-error', role: 'alert' }, t('recipients.invalid', { list: recipients.invalid.join('、') }))
6746
6818
  : null))),
6747
6819
 
6748
- // ---- 怎么发(只剩 SMTP 一种发送方式,不再让人选通道)----
6749
- h(Group, { title: t('group.transport'), state: transportState },
6820
+ // ---- 卡片二:怎么发(只剩 SMTP 一种发送方式,不再让人选通道)----
6821
+ h(Card, { kind: 'transport', title: t('group.transport'), state: transportState },
6750
6822
  h('div', { className: 'dshmn-item' },
6751
6823
  h('div', { className: 'dshmn-hint' }, t('smtp.note')),
6752
- h('div', { className: 'dshmn-sub' },
6753
6824
 
6754
- h('div', { className: 'dshmn-field' },
6755
- h('div', { className: 'dshmn-label' }, t('preset.label')),
6756
- h('div', { className: 'dshmn-seg', role: 'radiogroup', 'aria-label': t('preset.label') },
6757
- ...PRESETS.map((item) => h(Segment, {
6758
- key: item.id,
6759
- active: preset !== null && preset.id === item.id,
6760
- onClick: () => setFields({
6761
- smtpHost: item.host,
6762
- smtpPort: item.port,
6763
- smtpSecure: item.secure,
6764
- ...(draft.smtpUser === '' && userForPreset(item, recipients.valid) !== ''
6765
- ? { smtpUser: userForPreset(item, recipients.valid) }
6766
- : {}),
6767
- }),
6768
- }, item.name)),
6769
- h(Segment, {
6770
- active: preset === null,
6771
- onClick: () => setShowAdvanced(true),
6772
- }, t('preset.other'))),
6773
- preset === null ? null : h('div', { className: 'dshmn-hint' }, t('preset.detected', { name: preset.name }))),
6774
-
6775
- h('div', { className: 'dshmn-grid' },
6825
+ // 7 个服务商收成一行横向滚动的 chips:窄屏不折成三排,「其他」永远在最右。
6826
+ h('div', { className: 'dshmn-chips', role: 'radiogroup', 'aria-label': t('preset.label') },
6827
+ ...PRESETS.map((item) => h(Segment, {
6828
+ key: item.id,
6829
+ active: preset !== null && preset.id === item.id,
6830
+ onClick: () => setFields({
6831
+ smtpHost: item.host,
6832
+ smtpPort: item.port,
6833
+ smtpSecure: item.secure,
6834
+ ...(draft.smtpUser === '' && userForPreset(item, recipients.valid) !== ''
6835
+ ? { smtpUser: userForPreset(item, recipients.valid) }
6836
+ : {}),
6837
+ }),
6838
+ }, item.name)),
6839
+ h(Segment, {
6840
+ active: preset === null,
6841
+ // 「其他」= 自建中继:光标直接送到主机那一格
6842
+ onClick: () => focusField('dshmn-smtp-host'),
6843
+ }, t('preset.other'))),
6844
+ preset === null ? null : h('div', { className: 'dshmn-hint' }, t('preset.detected', { name: preset.name })),
6845
+
6846
+ // 主行:主机 + 用户名(两列)。端口 / 加密折进下面的「更多」。
6847
+ h('div', { className: 'dshmn-two' },
6776
6848
  h('div', { className: 'dshmn-field' },
6777
6849
  h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-host' }, t('smtp.host')),
6778
6850
  h(TextInput, {
@@ -6781,293 +6853,86 @@ window.__ModuleLoader__.load({
6781
6853
  value: draft.smtpHost ?? '',
6782
6854
  placeholder: 'smtp.example.com',
6783
6855
  onChange: (event) => setField('smtpHost', event.target.value),
6784
- onBlur: () => flush('smtpHost'),
6785
6856
  })),
6786
6857
  h('div', { className: 'dshmn-field' },
6787
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-port' }, t('smtp.port')),
6858
+ h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-user' }, t('smtp.user')),
6788
6859
  h(TextInput, {
6789
- id: 'dshmn-smtp-port',
6860
+ id: 'dshmn-smtp-user',
6790
6861
  type: 'text',
6791
- inputMode: 'numeric',
6792
- value: draft.smtpPort ?? '',
6793
- placeholder: '465',
6794
- 'aria-label': t('smtp.port'),
6795
- onChange: (event) => setField('smtpPort', event.target.value === '' ? '' : Number(event.target.value), { delay: NUMBER_DEBOUNCE_MS }),
6796
- onBlur: () => flush('smtpPort'),
6797
- })),
6798
- h('div', { className: 'dshmn-field' },
6799
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-secure' }, t('smtp.secure')),
6800
- h('select', {
6801
- id: 'dshmn-smtp-secure',
6802
- className: 'dshmn-select',
6803
- value: draft.smtpSecure ?? 'auto',
6804
- onChange: (event) => setField('smtpSecure', event.target.value, { immediate: true }),
6805
- }, ...[['auto', 'auto'], ['tls', 'tls'], ['starttls', 'starttls'], ['plain', 'plain']]
6806
- .map(([value, text]) => h('option', { key: value, value }, text))))),
6807
-
6862
+ value: draft.smtpUser ?? '',
6863
+ placeholder: `you${preset?.suffix ?? '@example.com'}`,
6864
+ onChange: (event) => setField('smtpUser', event.target.value),
6865
+ }),
6866
+ accountMismatch(preset, draft.smtpUser)
6867
+ ? h('div', { className: 'dshmn-error', role: 'alert' }, t('smtp.accountMismatch', {
6868
+ host: String(draft.smtpHost ?? ''),
6869
+ name: preset?.name ?? '',
6870
+ user: String(draft.smtpUser ?? ''),
6871
+ suffix: preset?.suffix ?? '',
6872
+ }))
6873
+ : null)),
6874
+
6875
+ // 端口 / 加密:默认收起。收起时标题上写清当前值(偏离默认才有内容),
6876
+ // 所以"收起来"不等于"藏起来"——想知道现在用什么,看一眼标题就够。
6808
6877
  h('div', { className: 'dshmn-field' },
6809
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-user' }, t('smtp.user')),
6810
- h(TextInput, {
6811
- id: 'dshmn-smtp-user',
6812
- type: 'text',
6813
- value: draft.smtpUser ?? '',
6814
- placeholder: `you${preset?.suffix ?? '@example.com'}`,
6815
- onChange: (event) => setField('smtpUser', event.target.value),
6816
- onBlur: () => flush('smtpUser'),
6817
- }),
6818
- accountMismatch(preset, draft.smtpUser)
6819
- ? h('div', { className: 'dshmn-error', role: 'alert' }, t('smtp.accountMismatch', {
6820
- host: String(draft.smtpHost ?? ''),
6821
- name: preset?.name ?? '',
6822
- user: String(draft.smtpUser ?? ''),
6823
- suffix: preset?.suffix ?? '',
6824
- }))
6825
- : null),
6826
-
6827
- credentialField('dshmn-cred-smtp', 'cred.smtp', smtpPasswordRef, 'cred.smtpHint', 'cred.smtpHelp')))),
6828
-
6829
- // ---- 什么时候发给我 ----
6830
- h(Group, {
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)))))))),
6910
+ credentialField('dshmn-cred-smtp', 'cred.smtp', smtpPasswordRef, 'cred.smtpHint', 'cred.smtpHelp')),
6911
+
6912
+ // ---- 卡片三:什么时候发给我(8 个开关单列,宿主同款行;窄屏最稳)----
6913
+ // 说明不再常显:每行标题旁一个问号,点开才显示那一行的说明(文案键原样复用)。
6914
+ h(Card, {
6915
+ kind: 'outcomes',
6831
6916
  title: t('group.outcomes'),
6832
6917
  state: outcomesOn > 0 ? t('outcomes.countOn', { n: outcomesOn }) : t('outcomes.countOff'),
6833
6918
  },
6834
- // 时长下限是"任务完成"的子项(只对它生效),所以缩进成子行——因果关系看得见,
6835
- // 而不是隔着十行再补一句"只对完成生效"。
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'),
6836
6924
  h('div', { className: 'dshmn-item' },
6837
- h('div', { className: 'dshmn-itemHead' },
6838
- h('div', { className: 'dshmn-itemText' },
6839
- h('div', { className: 'dshmn-itemTitle' }, t('outcomes.completed')),
6840
- h('div', { className: 'dshmn-hint' }, t('outcomes.completedHint'))),
6841
- h('div', { className: 'dshmn-itemCtrl' },
6842
- h(Switch, {
6843
- checked: draft.notifyCompleted === true,
6844
- label: t('outcomes.completed'),
6845
- onChange: (next) => setField('notifyCompleted', next, { immediate: true }),
6846
- }))),
6847
- h('div', { className: 'dshmn-sub', 'data-muted': draft.notifyCompleted === true ? 'false' : 'true' },
6848
- h('div', { className: 'dshmn-itemHead' },
6849
- h('div', { className: 'dshmn-itemText' },
6850
- h('label', { className: 'dshmn-itemTitle', htmlFor: 'dshmn-minduration' }, t('outcomes.minDuration')),
6851
- h('div', { className: 'dshmn-hint' }, t('outcomes.minDurationHint'))),
6852
- h('div', { className: 'dshmn-itemCtrl' },
6853
- h(NumberField, {
6854
- id: 'dshmn-minduration',
6855
- value: draft.minDurationMs,
6856
- placeholder: '2000',
6857
- unit: t('unit.ms'),
6858
- label: t('outcomes.minDuration'),
6859
- onChange: (value) => setField('minDurationMs', value, { delay: NUMBER_DEBOUNCE_MS }),
6860
- onBlur: () => flush('minDurationMs'),
6861
- }))))),
6862
- h(Row, {
6863
- title: t('outcomes.error'),
6864
- hint: t('outcomes.errorHint'),
6865
- control: h(Switch, {
6866
- checked: draft.notifyError === true,
6867
- label: t('outcomes.error'),
6868
- onChange: (next) => setField('notifyError', next, { immediate: true }),
6869
- }),
6870
- }),
6871
- h(Row, {
6872
- title: t('outcomes.blocked'),
6873
- hint: t('outcomes.blockedHint'),
6874
- control: h(Switch, {
6875
- checked: draft.notifyBlocked === true,
6876
- label: t('outcomes.blocked'),
6877
- onChange: (next) => setField('notifyBlocked', next, { immediate: true }),
6878
- }),
6879
- }),
6880
- h(Row, {
6881
- title: t('outcomes.approval'),
6882
- hint: t('outcomes.approvalHint'),
6883
- control: h(Switch, {
6884
- checked: draft.notifyApproval === true,
6885
- label: t('outcomes.approval'),
6886
- onChange: (next) => setField('notifyApproval', next, { immediate: true }),
6887
- }),
6888
- }),
6889
- h(Row, {
6890
- title: t('outcomes.subagents'),
6891
- hint: t('outcomes.subagentsHint'),
6892
- control: h(Switch, {
6893
- checked: draft.includeSubagents === true,
6894
- label: t('outcomes.subagents'),
6895
- onChange: (next) => setField('includeSubagents', next, { immediate: true }),
6896
- }),
6897
- }),
6898
- h('div', { className: 'dshmn-item' },
6899
- h(Disclosure, {
6900
- title: t('outcomes.more'),
6901
- open: showMoreOutcomes,
6902
- onToggle: () => setShowMoreOutcomes((value) => !value),
6903
- },
6904
- h('div', { className: 'dshmn-stack', 'data-rows': 'true' },
6905
- h(Row, {
6906
- title: t('outcomes.aborted'),
6907
- hint: t('outcomes.abortedHint'),
6908
- control: h(Switch, {
6909
- checked: draft.notifyAbortedByUser === true,
6910
- label: t('outcomes.aborted'),
6911
- onChange: (next) => setField('notifyAbortedByUser', next, { immediate: true }),
6912
- }),
6913
- }),
6914
- h(Row, {
6915
- title: t('outcomes.interrupted'),
6916
- hint: t('outcomes.interruptedHint'),
6917
- control: h(Switch, {
6918
- checked: draft.notifyInterrupted === true,
6919
- label: t('outcomes.interrupted'),
6920
- onChange: (next) => setField('notifyInterrupted', next, { immediate: true }),
6921
- }),
6922
- }),
6923
- h(Row, {
6924
- title: t('outcomes.maxTokens'),
6925
- hint: t('outcomes.maxTokensHint'),
6926
- control: h(Switch, {
6927
- checked: draft.notifyMaxTokens === true,
6928
- label: t('outcomes.maxTokens'),
6929
- onChange: (next) => setField('notifyMaxTokens', next, { immediate: true }),
6930
- }),
6931
- })))),
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')))),
6932
6933
 
6933
- // ---- 可选设置(发件人、链接、排错)----
6934
- h('section', { className: 'dshmn-group', 'aria-label': t('group.optional') },
6935
- h('div', { className: 'dshmn-groupHead' },
6936
- h('h3', { className: 'dshmn-groupTitle' }, t('group.optional'))),
6937
- h('div', { className: 'dshmn-groupBody' },
6938
- h('div', { className: 'dshmn-item' },
6939
- h(Disclosure, {
6940
- title: t('group.sender'),
6941
- open: showSender,
6942
- onToggle: () => setShowSender((value) => !value),
6943
- },
6944
- h('div', { className: 'dshmn-stack' },
6945
- h('div', { className: 'dshmn-field' },
6946
- h('label', { className: 'dshmn-label' }, t('sender.listLabel')),
6947
- h('div', { className: 'dshmn-senders', role: 'radiogroup', 'aria-label': t('sender.listLabel') },
6948
- senders.length === 0
6949
- ? h('div', { className: 'dshmn-senderEmpty' }, t('sender.empty'))
6950
- : null,
6951
- ...senders.map((entry, index) => {
6952
- const isActive = entry.address === senderAddr;
6953
- return h('div', {
6954
- key: entry.address,
6955
- className: 'dshmn-sender',
6956
- 'data-active': isActive ? 'true' : 'false',
6957
- onClick: () => pickSender(entry.address),
6958
- },
6959
- h('input', {
6960
- type: 'radio',
6961
- className: 'dshmn-senderRadio',
6962
- name: 'dshmn-sender',
6963
- checked: isActive,
6964
- onChange: () => pickSender(entry.address),
6965
- 'aria-label': t('sender.useThis', { address: entry.address }),
6966
- }),
6967
- h('div', { className: 'dshmn-senderBody' },
6968
- h('div', { className: 'dshmn-senderName' }, entry.name !== '' ? entry.name : t('sender.nameUnset')),
6969
- h('div', { className: 'dshmn-senderAddr' }, entry.address)),
6970
- isActive ? h('span', { className: 'dshmn-senderBadge' }, t('sender.inUse')) : null,
6971
- h('button', {
6972
- type: 'button',
6973
- className: 'dshmn-senderIcon',
6974
- title: t('sender.edit'),
6975
- 'aria-label': `${t('sender.edit')} ${entry.address}`,
6976
- onClick: (event) => { event.stopPropagation(); openSenderDialog(index); },
6977
- }, '✎'),
6978
- h('button', {
6979
- type: 'button',
6980
- className: 'dshmn-senderIcon',
6981
- title: t('sender.remove'),
6982
- 'aria-label': `${t('sender.remove')} ${entry.address}`,
6983
- onClick: (event) => { event.stopPropagation(); removeSender(index); },
6984
- }, '×'));
6985
- }),
6986
- h('button', {
6987
- type: 'button',
6988
- className: 'dshmn-senderAdd',
6989
- onClick: () => openSenderDialog(senders.length),
6990
- }, t('sender.add'))),
6991
- h('div', { className: 'dshmn-hint' }, t('sender.listHint'))),
6992
- h(Row, {
6993
- title: t('link.enabled'),
6994
- hint: t('link.enabledHint'),
6995
- control: h(Switch, {
6996
- checked: draft.linkEnabled === true,
6997
- label: t('link.enabled'),
6998
- onChange: (next) => setField('linkEnabled', next, { immediate: true }),
6999
- }),
7000
- }),
7001
- h('div', { className: 'dshmn-field' },
7002
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-base' }, t('link.base')),
7003
- h(TextInput, {
7004
- id: 'dshmn-base',
7005
- type: 'text',
7006
- value: draft.publicBaseUrl ?? '',
7007
- placeholder: 'http://127.0.0.1:3081',
7008
- onChange: (event) => setField('publicBaseUrl', event.target.value),
7009
- onBlur: () => flush('publicBaseUrl'),
7010
- }),
7011
- h('div', { className: 'dshmn-hint' }, t('link.baseHint')))))),
7012
-
7013
- // ---- 可选:高级(排错用)----
7014
- h('div', { className: 'dshmn-item' },
7015
- h(Disclosure, {
7016
- title: t('group.advanced'),
7017
- open: showAdvanced,
7018
- onToggle: () => setShowAdvanced((value) => !value),
7019
- },
7020
- h('div', { className: 'dshmn-stack' },
7021
- h('div', { className: 'dshmn-field' },
7022
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-settle' }, t('adv.settle')),
7023
- h(NumberField, {
7024
- id: 'dshmn-settle',
7025
- value: draft.settleMs,
7026
- placeholder: '1500',
7027
- unit: t('unit.ms'),
7028
- label: t('adv.settle'),
7029
- onChange: (value) => setField('settleMs', value, { delay: NUMBER_DEBOUNCE_MS }),
7030
- onBlur: () => flush('settleMs'),
7031
- }),
7032
- h('div', { className: 'dshmn-hint' }, t('adv.settleHint'))),
7033
- h('div', { className: 'dshmn-field' },
7034
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-rate' }, t('adv.rate')),
7035
- h(NumberField, {
7036
- id: 'dshmn-rate',
7037
- value: draft.ratePerMinute,
7038
- placeholder: '10',
7039
- unit: t('unit.perMin'),
7040
- label: t('adv.rate'),
7041
- onChange: (value) => setField('ratePerMinute', value, { delay: NUMBER_DEBOUNCE_MS }),
7042
- onBlur: () => flush('ratePerMinute'),
7043
- }),
7044
- h('div', { className: 'dshmn-hint' }, t('adv.rateHint'))),
7045
- h('div', { className: 'dshmn-field' },
7046
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-pwref' }, t('adv.pwRef')),
7047
- h(TextInput, {
7048
- id: 'dshmn-pwref',
7049
- type: 'text',
7050
- value: draft.smtpPasswordRef ?? '',
7051
- onChange: (event) => setField('smtpPasswordRef', event.target.value),
7052
- onBlur: () => {
7053
- flush('smtpPasswordRef');
7054
- void refreshCredentials([draftRef.current.smtpPasswordRef]);
7055
- },
7056
- }),
7057
- h('div', { className: 'dshmn-hint' }, t('adv.pwRefHint'))),
7058
- h('div', { className: 'dshmn-field' },
7059
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-statepath' }, t('adv.statePath')),
7060
- h(TextInput, {
7061
- id: 'dshmn-statepath',
7062
- type: 'text',
7063
- value: draft.statePath ?? '',
7064
- onChange: (event) => setField('statePath', event.target.value),
7065
- onBlur: () => flush('statePath'),
7066
- }),
7067
- h('div', { className: 'dshmn-hint' }, t('adv.statePathHint')))))),
7068
- )),
7069
-
7070
- // ---- 页脚:清空覆盖是破坏性操作,所以压到最下面、只用文字链接 ----
6934
+ // ---- 页脚:清空覆盖是破坏性操作,所以压到最下面、只用一条 12/18 的文字链接 ----
6935
+ // 页脚不画线:线只长在行自己身上,页脚不是一段内容。
7071
6936
  h('div', { className: 'dshmn-foot' },
7072
6937
  h('button', {
7073
6938
  type: 'button',
@@ -7099,40 +6964,7 @@ window.__ModuleLoader__.load({
7099
6964
  closeLabel: t('cred.helpClose'),
7100
6965
  footer: h(Button, { variant: 'outline', size: 'sm', onClick: () => setHelpKind(null) }, t('cred.helpClose')),
7101
6966
  }, h('div', { className: 'dshmn-helpBody' }, t('cred.smtpHelpBody')))
7102
- : null,
7103
-
7104
- // ---- 发件人弹层(添加 / 编辑同一套)----
7105
- // 保存按钮**不置灰**:不合法时点一下就地报错,比灰着一个说不清原因的按钮好。
7106
- (typeof P.Modal === 'function' && senderEdit !== null
7107
- ? h(P.Modal, {
7108
- open: true,
7109
- onClose: closeSenderDialog,
7110
- title: t(senderEdit.index >= senders.length ? 'sender.addTitle' : 'sender.editTitle'),
7111
- closeLabel: t('sender.cancel'),
7112
- footer: h(React.Fragment, null,
7113
- h(Button, { variant: 'outline', size: 'sm', onClick: closeSenderDialog }, t('sender.cancel')),
7114
- h(Button, { variant: 'primary', size: 'sm', onClick: saveSenderDialog }, t('sender.save'))),
7115
- },
7116
- h('div', { className: 'dshmn-field' },
7117
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-sender-name' }, t('sender.name')),
7118
- h(TextInput, {
7119
- id: 'dshmn-sender-name',
7120
- type: 'text',
7121
- value: senderEdit.name,
7122
- onChange: (event) => setSenderEdit({ ...senderEdit, name: event.target.value }),
7123
- }),
7124
- h('div', { className: 'dshmn-hint' }, t('sender.nameHint'))),
7125
- h('div', { className: 'dshmn-field' },
7126
- h('label', { className: 'dshmn-label', htmlFor: 'dshmn-sender-addr' }, t('sender.address')),
7127
- h(TextInput, {
7128
- id: 'dshmn-sender-addr',
7129
- type: 'text',
7130
- value: senderEdit.address,
7131
- onChange: (event) => setSenderEdit({ ...senderEdit, address: event.target.value }),
7132
- }),
7133
- h('div', { className: 'dshmn-hint' }, t('sender.addressHint'))),
7134
- senderErr !== '' ? h('div', { className: 'dshmn-error', role: 'alert' }, senderErr) : null)
7135
- : null))));
6967
+ : null));
7136
6968
  }
7137
6969
 
7138
6970
  /**
@@ -7146,7 +6978,29 @@ window.__ModuleLoader__.load({
7146
6978
  const message = typeof error === 'string'
7147
6979
  ? error
7148
6980
  : (error && typeof error === 'object' && typeof error.message === 'string' ? error.message : JSON.stringify(error ?? response));
7149
- throw new Error(message);
6981
+ const failure = new Error(message);
6982
+ // RemoteError 的真实形状(@deepseek-ai/dsh-typert-protocol):一个真 Error,带 code 与 details。
6983
+ // **一定要把 code 带出来**:冲突恢复只认 code,把它丢掉就只剩一句人话,
6984
+ // 重试必然拿着旧 revision 再撞一次,用户只能关掉设置页再进来。
6985
+ if (error && typeof error === 'object') {
6986
+ if (typeof error.code === 'string') failure.code = error.code;
6987
+ if (error.details !== undefined) failure.details = error.details;
6988
+ }
6989
+ throw failure;
6990
+ }
6991
+
6992
+ /**
6993
+ * 这次写失败,是不是「我读到之后别人又改了」?
6994
+ *
6995
+ * 判别一律靠 code(settings 控制器对 provider 的拒绝只分两档):
6996
+ * - settings/conflict:revision 被别处推走了,正确反应是**重读 + 重放**;
6997
+ * - settings/rejected:请求本身不成立(只读 provider / schema 校验 / 存储故障),重试没用。
6998
+ * @param {unknown} error 捕获到的异常
6999
+ * @returns {boolean} true 表示是 revision 冲突
7000
+ */
7001
+ function isConflict(error) {
7002
+ const code = typeof error?.code === 'string' ? error.code : '';
7003
+ return code === 'settings/conflict' || code === 'conflict';
7150
7004
  }
7151
7005
 
7152
7006
  /**
@@ -7154,8 +7008,8 @@ window.__ModuleLoader__.load({
7154
7008
  *
7155
7009
  * 走 connection 的通用 RPC 而不是挂 remote 命名空间:本插件的 client 半是手写包,
7156
7010
  * 没有生成出来的 remote 声明,而 `/api` + `<namespace>/<method>` 是同一套网关,
7157
- * 参数与清单里声明的 parameters 必须完全一致(这里两个方法都是零参数)。
7158
- * @param {'test'|'state'} method 方法名
7011
+ * 参数与清单里声明的 parameters 必须完全一致(test 是零参数)。
7012
+ * @param {'test'} method 方法名
7159
7013
  * @returns {Promise<unknown>} Remote 的 `{ ok, value }` 信封
7160
7014
  */
7161
7015
  function callRemote(method) {
@@ -7191,7 +7045,7 @@ window.__ModuleLoader__.load({
7191
7045
  }
7192
7046
 
7193
7047
  // Host 动作走 connection 的通用 RPC:`/api` + `<namespace>/<method>`。
7194
- // 参数必须与清单声明的 parameters 完全一致——test/state 都是零参数,所以是空对象。
7048
+ // 参数必须与清单声明的 parameters 完全一致——test 是零参数,所以是空对象。
7195
7049
  try {
7196
7050
  ctx.inject(['connection'], (scoped) => {
7197
7051
  const rpc = scoped.connection?.rpc;
@@ -7205,7 +7059,7 @@ window.__ModuleLoader__.load({
7205
7059
 
7206
7060
  try {
7207
7061
  ctx.inject(['locale'], (scoped) => {
7208
- ctx.effect(() => scoped.locale.register(NS, { zh: ZH, en: EN }), 'dsh-notify-p: dictionaries');
7062
+ ctx.effect(() => scoped.locale.register(LOCALE_NS, { zh: ZH, en: EN }), 'dsh-notify-p: dictionaries');
7209
7063
  i18n.locale = scoped.locale;
7210
7064
  console.info('[dsh-notify-p] 文案字典已注册 / locale dictionaries registered');
7211
7065
  });
@@ -7230,7 +7084,7 @@ window.__ModuleLoader__.load({
7230
7084
  }
7231
7085
  },
7232
7086
  /** 给离线单测用的接线口(浏览器运行时不会读它)。 */
7233
- __internals: { parseRecipients, migrateSenders, activeSenderAddress, senderIssue, senderCommit, senderRemove, deriveStatus, matchPreset, presetForAddress, dotState, accountMismatch, userForPreset, relativeTime, PRESETS, ZH, EN, CSS, fmt, i18n },
7087
+ __internals: { parseRecipients, deriveStatus, matchPreset, presetForAddress, dotState, accountMismatch, userForPreset, PRESETS, ZH, EN, CSS, fmt, diffOps, i18n },
7234
7088
  };
7235
7089
  },
7236
7090
  });