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