@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/README.md +34 -8
- package/client/client.js +938 -1084
- package/lib/index.js +29 -3
- package/lib/notify/config.js +119 -153
- package/lib/notify/index.js +47 -112
- package/lib/notify/render.js +4 -18
- package/lib/notify/send/index.js +10 -13
- package/lib/notify/service.js +4 -16
- package/lib/notify/test-send.js +5 -8
- package/lib/notify/typert.host.js +2 -24
- package/lib/typert.host.js +2 -23
- package/package.json +1 -1
- package/lib/notify/state.js +0 -102
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
|
-
|
|
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
|
-
'
|
|
5095
|
-
'
|
|
5096
|
-
'
|
|
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.
|
|
5116
|
-
'save.
|
|
5117
|
-
'save.
|
|
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
|
-
|
|
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
|
-
|
|
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': '这会清掉本插件在
|
|
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
|
-
'
|
|
5264
|
-
'
|
|
5265
|
-
'
|
|
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.
|
|
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.
|
|
5266
|
+
'save.clean': 'No unsaved changes',
|
|
5267
|
+
'save.dirty': 'Unsaved changes: {n}',
|
|
5285
5268
|
'save.saving': 'Saving…',
|
|
5286
|
-
'save.
|
|
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.
|
|
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
|
|
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;
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
// 内容列
|
|
5740
|
-
// 说明 12/18 tertiary ·
|
|
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 = '
|
|
5748
|
-
|
|
5749
|
-
const CSS =
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
.
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
.dshmn-
|
|
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
|
-
|
|
5763
|
-
|
|
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:
|
|
5766
|
-
.dshmn-
|
|
5767
|
-
.dshmn-
|
|
5768
|
-
.dshmn-
|
|
5769
|
-
|
|
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
|
-
|
|
5776
|
-
.dshmn-
|
|
5777
|
-
|
|
5778
|
-
.dshmn-
|
|
5779
|
-
.dshmn-
|
|
5780
|
-
.dshmn-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
.dshmn-
|
|
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:
|
|
5787
|
-
.dshmn-
|
|
5788
|
-
|
|
5789
|
-
|
|
5790
|
-
|
|
5791
|
-
|
|
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-
|
|
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
|
|
5796
|
-
.dshmn-field{display:flex;flex-direction:column;gap:
|
|
5797
|
-
.dshmn-label{color:var(--dsw-alias-label-
|
|
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:
|
|
5810
|
-
border:.5px solid var(--dsw-alias-border-l4);border-radius:
|
|
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:
|
|
5814
|
-
border:.5px solid var(--dsw-alias-border-l4);border-radius:
|
|
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
|
-
/*
|
|
5821
|
-
|
|
5822
|
-
.dshmn-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
.dshmn-
|
|
5828
|
-
.dshmn-
|
|
5829
|
-
|
|
5830
|
-
|
|
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-
|
|
5864
|
-
color:var(--dsw-alias-label-primary);font-weight:
|
|
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:
|
|
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-
|
|
5876
|
-
.dshmn-
|
|
5877
|
-
|
|
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:
|
|
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-
|
|
5892
|
-
.dshmn-
|
|
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', {
|
|
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
|
-
|
|
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
|
-
*
|
|
6008
|
-
*
|
|
6009
|
-
*
|
|
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
|
|
6013
|
-
const { title, state, children } = props;
|
|
6014
|
-
|
|
6015
|
-
|
|
6016
|
-
|
|
6017
|
-
|
|
6018
|
-
|
|
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
|
-
*
|
|
6024
|
-
*
|
|
6025
|
-
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
|
|
6029
|
-
|
|
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
|
|
6046
|
-
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
6099
|
-
|
|
6100
|
-
|
|
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 (
|
|
5982
|
+
if (settingsAvailable()) setReady(true);
|
|
6125
5983
|
setAttempt((n) => n + 1);
|
|
6126
5984
|
}, 150);
|
|
6127
5985
|
return () => clearTimeout(timer);
|
|
6128
|
-
}, [ready, attempt
|
|
5986
|
+
}, [ready, attempt]);
|
|
6129
5987
|
|
|
6130
|
-
|
|
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((
|
|
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
|
-
|
|
6140
|
-
|
|
6141
|
-
|
|
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: '
|
|
6029
|
+
? { phase: 'unserved', at: 0, error: '' }
|
|
6144
6030
|
: { phase: 'idle', at: 0, error: '' });
|
|
6145
6031
|
} catch (err) {
|
|
6146
|
-
|
|
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
|
-
|
|
6173
|
-
|
|
6174
|
-
|
|
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
|
|
6194
|
-
|
|
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,
|
|
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
|
-
*
|
|
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
|
|
6123
|
+
const setField = React.useCallback((key, value) => {
|
|
6287
6124
|
draftRef.current = { ...draftRef.current, [key]: value };
|
|
6288
6125
|
setDraft(draftRef.current);
|
|
6289
|
-
|
|
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
|
-
|
|
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
|
|
6334
|
-
|
|
6335
|
-
|
|
6336
|
-
|
|
6337
|
-
|
|
6338
|
-
|
|
6339
|
-
|
|
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
|
-
|
|
6358
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
6267
|
+
setSave({ phase: 'partialCred', at: 0, error: String(err?.message ?? err), credError: '' });
|
|
6398
6268
|
}
|
|
6399
|
-
}, [
|
|
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
|
-
/**
|
|
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(
|
|
6410
|
-
if (next && typeof next === 'object')
|
|
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
|
-
/**
|
|
6351
|
+
/**
|
|
6352
|
+
* 发一封测试邮件。
|
|
6353
|
+
*
|
|
6354
|
+
* 结果是 RPC **当场**返回的:成败与原始报错直接进 test 态,
|
|
6355
|
+
* 因为账本删掉之后,这条路径就是唯一的「真投递过没有」的证据。
|
|
6356
|
+
*/
|
|
6423
6357
|
const sendTest = React.useCallback(async () => {
|
|
6424
|
-
if (
|
|
6425
|
-
setTest({ phase: 'sending',
|
|
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',
|
|
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',
|
|
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
|
-
}, [
|
|
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:
|
|
6458
|
-
recipientCount:
|
|
6459
|
-
host:
|
|
6460
|
-
user:
|
|
6461
|
-
credentialConfigured: configured(
|
|
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(
|
|
6466
|
-
&& String(
|
|
6467
|
-
&& String(
|
|
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
|
-
|
|
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
|
-
*
|
|
6495
|
-
*
|
|
6496
|
-
*
|
|
6497
|
-
*
|
|
6437
|
+
* 保存读数。只说这一刻的事实:干净 / 脏 / 保存中 / 已保存多久 / 失败原因 + 重试,
|
|
6438
|
+
* 以及三种非干净结局各自的动作——读失败给「重新读取」、冲突给「再点一次保存」、
|
|
6439
|
+
* 半成功只给失败那一半的重试。动作永远指向**这一次真正该做的事**,不是笼统的「重试」。
|
|
6440
|
+
* @returns {object[]} 读数节点
|
|
6498
6441
|
*/
|
|
6499
|
-
const
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
|
|
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'
|
|
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 (
|
|
6526
|
-
if (!
|
|
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
|
|
6555
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
6562
|
-
|
|
6563
|
-
|
|
6564
|
-
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
|
|
6569
|
-
|
|
6570
|
-
|
|
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
|
-
|
|
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
|
|
6591
|
-
if (
|
|
6599
|
+
const testResult = () => {
|
|
6600
|
+
if (test.phase === 'sending') {
|
|
6592
6601
|
return h('div', { className: 'dshmn-evidence' },
|
|
6593
|
-
h('span', { className: 'dshmn-evidenceKey' }, t('
|
|
6594
|
-
h('span', null, t('evidence.never')));
|
|
6602
|
+
h('span', { className: 'dshmn-evidenceKey' }, t('act.testing')));
|
|
6595
6603
|
}
|
|
6596
|
-
|
|
6597
|
-
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
6602
|
-
|
|
6603
|
-
|
|
6604
|
-
|
|
6605
|
-
|
|
6606
|
-
|
|
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
|
-
|
|
6621
|
-
const editing = confirmed !== true ||
|
|
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-
|
|
6637
|
+
className: 'dshmn-q',
|
|
6638
|
+
'aria-label': t(helpKey),
|
|
6625
6639
|
onClick: () => setHelpKind(true),
|
|
6626
|
-
},
|
|
6640
|
+
}, '?');
|
|
6627
6641
|
return h('div', { className: 'dshmn-item' },
|
|
6628
6642
|
h('div', { className: 'dshmn-itemHead' },
|
|
6629
|
-
h('
|
|
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: () =>
|
|
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: () =>
|
|
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
|
-
|
|
6699
|
-
|
|
6700
|
-
|
|
6701
|
-
|
|
6702
|
-
'
|
|
6703
|
-
'
|
|
6704
|
-
},
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
|
|
6708
|
-
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
:
|
|
6712
|
-
|
|
6713
|
-
|
|
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
|
-
|
|
6727
|
-
h('
|
|
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(
|
|
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
|
-
// ----
|
|
6749
|
-
h(
|
|
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
|
-
|
|
6755
|
-
|
|
6756
|
-
h(
|
|
6757
|
-
|
|
6758
|
-
|
|
6759
|
-
|
|
6760
|
-
|
|
6761
|
-
|
|
6762
|
-
|
|
6763
|
-
|
|
6764
|
-
|
|
6765
|
-
|
|
6766
|
-
|
|
6767
|
-
|
|
6768
|
-
|
|
6769
|
-
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
|
|
6774
|
-
|
|
6775
|
-
|
|
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-
|
|
6858
|
+
h('label', { className: 'dshmn-label', htmlFor: 'dshmn-smtp-user' }, t('smtp.user')),
|
|
6788
6859
|
h(TextInput, {
|
|
6789
|
-
id: 'dshmn-smtp-
|
|
6860
|
+
id: 'dshmn-smtp-user',
|
|
6790
6861
|
type: 'text',
|
|
6791
|
-
|
|
6792
|
-
|
|
6793
|
-
|
|
6794
|
-
|
|
6795
|
-
|
|
6796
|
-
|
|
6797
|
-
|
|
6798
|
-
|
|
6799
|
-
|
|
6800
|
-
|
|
6801
|
-
|
|
6802
|
-
|
|
6803
|
-
|
|
6804
|
-
|
|
6805
|
-
|
|
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
|
-
|
|
6810
|
-
|
|
6811
|
-
|
|
6812
|
-
|
|
6813
|
-
|
|
6814
|
-
|
|
6815
|
-
|
|
6816
|
-
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
|
|
6822
|
-
|
|
6823
|
-
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
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
|
-
|
|
6838
|
-
|
|
6839
|
-
|
|
6840
|
-
|
|
6841
|
-
h('div', { className: 'dshmn-
|
|
6842
|
-
|
|
6843
|
-
|
|
6844
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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
|
|
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(
|
|
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,
|
|
7087
|
+
__internals: { parseRecipients, deriveStatus, matchPreset, presetForAddress, dotState, accountMismatch, userForPreset, PRESETS, ZH, EN, CSS, fmt, diffOps, i18n },
|
|
7234
7088
|
};
|
|
7235
7089
|
},
|
|
7236
7090
|
});
|