dsh-email 0.10.7 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +86 -8
- package/README.md +66 -9
- package/lib/client.js +2344 -191
- package/lib/config.d.ts +161 -1
- package/lib/config.js +244 -11
- package/lib/index.d.ts +6 -2
- package/lib/index.js +3 -2
- package/lib/mail-client.d.ts +102 -3
- package/lib/mail-client.js +318 -73
- package/lib/oauth2.d.ts +124 -0
- package/lib/oauth2.js +417 -0
- package/lib/runtime.js +28 -8
- package/lib/settings.d.ts +22 -1
- package/lib/settings.js +122 -34
- package/lib/tool-contract.d.ts +3 -0
- package/lib/tool-contract.js +11 -2
- package/lib/tools.js +22 -6
- package/lib/types.d.ts +7 -0
- package/lib/web.d.ts +209 -2
- package/lib/web.js +917 -14
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
+
// NOTE(维护者请看这里):这个文件就是插件网页端的**源码**,不经过 tsc —— 仓库里没有
|
|
2
|
+
// src/client.ts,`pnpm run build` 也不会重写它。改设置面板 / 鲸鱼娘弹窗请直接改这里,
|
|
3
|
+
// 改完用 `node --check lib/client.js` 自检(CI 里也会跑这一步)。
|
|
1
4
|
window.__ModuleLoader__.load({ id: "dsh-email", factory: (require) => {
|
|
2
5
|
var module = { exports: {} }; var exports = module.exports;
|
|
3
6
|
"use strict";
|
|
4
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
8
|
|
|
6
9
|
const React = require("react");
|
|
7
|
-
const { useState, useEffect, useCallback } = React;
|
|
10
|
+
const { useState, useEffect, useCallback, useRef } = React;
|
|
8
11
|
const h = React.createElement;
|
|
9
12
|
|
|
10
13
|
const ROUTE = "/_dsh/dsh-email/settings";
|
|
14
|
+
/** 「编辑即保存」的防抖窗口:连续打字只落一次盘。 */
|
|
15
|
+
const SAVE_DEBOUNCE_MS = 800;
|
|
11
16
|
|
|
12
17
|
async function api(action, payload) {
|
|
13
18
|
const init = action === undefined
|
|
@@ -21,21 +26,91 @@ async function api(action, payload) {
|
|
|
21
26
|
const res = await fetch(ROUTE, init);
|
|
22
27
|
const body = await res.json();
|
|
23
28
|
if (!res.ok || !body.ok) {
|
|
24
|
-
|
|
29
|
+
const error = new Error((body && body.error && body.error.message) || ("request failed " + res.status));
|
|
30
|
+
// Callers branch on these: a revision conflict is retryable after a rebase,
|
|
31
|
+
// anything else is not, and the message alone cannot tell them apart.
|
|
32
|
+
error.code = body && body.error ? body.error.code : undefined;
|
|
33
|
+
error.status = res.status;
|
|
34
|
+
throw error;
|
|
25
35
|
}
|
|
26
36
|
return body.value;
|
|
27
37
|
}
|
|
28
38
|
|
|
39
|
+
/**
|
|
40
|
+
* 登录失败的文案:平铺的 message 优先,退回 error.message,再退回调用方给的兜底。
|
|
41
|
+
*/
|
|
42
|
+
function oauthErrorMessage(body, fallback) {
|
|
43
|
+
if (body && typeof body.message === "string" && body.message !== "") return body.message;
|
|
44
|
+
if (body && body.error && typeof body.error.message === "string" && body.error.message !== "") return body.error.message;
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 只发请求、拆出 OAuth2 的契约体。
|
|
50
|
+
*
|
|
51
|
+
* 两个 action 的语义就在 { ok, status } 上,不是「成功即 value」,所以不能走那个
|
|
52
|
+
* 会抛的 api()。而且信封有两种,必须都认:
|
|
53
|
+
* - 后端实际发的是和其它 action 一致的信封 { ok:true, value:{ status, url… } };
|
|
54
|
+
* 抛错时是 HTTP 400 + { ok:false, error:{ message } }。
|
|
55
|
+
* - 契约本身写成平铺的 { ok:true, status… } / { ok:false, message }。
|
|
56
|
+
* 归一化成同一个平铺结构再交给调用方,调用方就不用管信封了。
|
|
57
|
+
* 网络/非 JSON 响应仍照抛,由调用方兜成一条可重试的错误。
|
|
58
|
+
*/
|
|
59
|
+
async function apiOauth(action, payload) {
|
|
60
|
+
const res = await fetch(ROUTE, {
|
|
61
|
+
credentials: "same-origin",
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
body: JSON.stringify(Object.assign({ action }, payload)),
|
|
65
|
+
});
|
|
66
|
+
const body = await res.json();
|
|
67
|
+
if (body === null || typeof body !== "object") return { ok: false, message: t("oauth.badResponse") };
|
|
68
|
+
// 信封里的 value 才是契约体;平铺时就整个 body 当契约体(value 缺失即此情形)。
|
|
69
|
+
const inner = body.value !== null && body.value !== undefined && typeof body.value === "object" ? body.value : body;
|
|
70
|
+
if (body.ok === false) {
|
|
71
|
+
return { ok: false, message: oauthErrorMessage(body) || oauthErrorMessage(inner) || t("oauth.loginFailed") };
|
|
72
|
+
}
|
|
73
|
+
// 契约体自己也可能说 ok:false(contract 的 { ok:false, message } 形态),它的字段优先。
|
|
74
|
+
return Object.assign({ ok: true }, inner);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 组装要落盘的完整 value:整份表单 + 当前有效的 accountsYaml,maxBodyChars 缺省
|
|
79
|
+
* 20000。provider/user/password/inboxFolder 已经不在页面上编辑(账号都在卡片里、
|
|
80
|
+
* 只存服务商 id),但它们仍随 value 原样带着 —— 后端 resolveEmailSettings 要拿它们
|
|
81
|
+
* 做默认账号兜底。imap/smtp 同理只是随行,端点由预设展开,这里不再定型端口。
|
|
82
|
+
*/
|
|
83
|
+
function settingsValueOf(draft, accountsYaml) {
|
|
84
|
+
if (draft === null || draft === undefined) return null;
|
|
85
|
+
return {
|
|
86
|
+
...draft,
|
|
87
|
+
accountsYaml: typeof accountsYaml === "string" ? accountsYaml : "",
|
|
88
|
+
maxBodyChars: typeof draft.maxBodyChars === "number" ? draft.maxBodyChars : 20000,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 「这份草稿是否已经落过盘」的签名:和最近一次成功保存的签名比对,一样就跳过。
|
|
94
|
+
* 加载完、保存成功后都会对齐,所以「编辑即保存」不会变成「一动就存」。
|
|
95
|
+
*/
|
|
96
|
+
function signatureOf(draft, accountsYaml) {
|
|
97
|
+
const value = settingsValueOf(draft, accountsYaml);
|
|
98
|
+
return value === null ? "" : JSON.stringify(value);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 内置服务商:[值, 文案 key]。这里刻意存 key 而不是译文本体 —— 这张表在模块初始化
|
|
103
|
+
* 时求值,那时 UI 字典还没建立;取文案一律走 t(entry[1]),只发生在渲染期。
|
|
104
|
+
*/
|
|
29
105
|
const PROVIDERS = [
|
|
30
|
-
["qq", "
|
|
31
|
-
["163", "163
|
|
32
|
-
["126", "126
|
|
33
|
-
["sina", "
|
|
34
|
-
["aliyun", "
|
|
35
|
-
["gmail", "
|
|
36
|
-
["outlook", "
|
|
37
|
-
["icloud", "
|
|
38
|
-
["", "自定义(手填 IMAP/SMTP)"],
|
|
106
|
+
["qq", "provider.qq"],
|
|
107
|
+
["163", "provider.163"],
|
|
108
|
+
["126", "provider.126"],
|
|
109
|
+
["sina", "provider.sina"],
|
|
110
|
+
["aliyun", "provider.aliyun"],
|
|
111
|
+
["gmail", "provider.gmail"],
|
|
112
|
+
["outlook", "provider.outlook"],
|
|
113
|
+
["icloud", "provider.icloud"],
|
|
39
114
|
];
|
|
40
115
|
|
|
41
116
|
const EMPTY = {
|
|
@@ -46,44 +121,561 @@ const EMPTY = {
|
|
|
46
121
|
sendApproval: true,
|
|
47
122
|
downloadDir: "",
|
|
48
123
|
accountsYaml: "",
|
|
124
|
+
serverPresets: "",
|
|
49
125
|
imap: { host: "", port: 993, secure: true },
|
|
50
126
|
smtp: { host: "", port: 465, secure: true },
|
|
51
127
|
};
|
|
52
128
|
|
|
129
|
+
/**
|
|
130
|
+
* 走 OAuth2 的服务商(后端 authKind === 'oauth2' 的同一份名单)。这张表只是
|
|
131
|
+
* 「还没保存、卡片数据没回来」时的本地预判:新选一个 provider,快照要等下一次
|
|
132
|
+
* reload 才会说它是不是 oauth2,本地按名字先切成登录区,免得先让用户看到一个
|
|
133
|
+
* 密码框、存完再变成登录按钮。
|
|
134
|
+
*/
|
|
135
|
+
const OAUTH2_PROVIDERS = ["outlook"];
|
|
136
|
+
/** 兜底轮询间隔:后端没给 interval 时按 5 秒问一次。 */
|
|
137
|
+
const OAUTH_POLL_FALLBACK_MS = 5000;
|
|
138
|
+
/** 后端返回的 expires_in 缺省是秒;缺省值取 15 分钟(和设备的码同寿)。 */
|
|
139
|
+
const OAUTH_EXPIRES_FALLBACK_S = 900;
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
|
|
53
143
|
const CSS = [
|
|
54
|
-
".dshe-settings{display:grid;gap:14px;
|
|
144
|
+
".dshe-settings{display:grid;gap:14px;padding:8px 2px 32px;color:var(--dsw-alias-label-primary,#26231f);color-scheme:light dark}",
|
|
55
145
|
".dshe-header{display:grid;gap:4px;padding:8px 2px}",
|
|
56
146
|
".dshe-header h2{font-size:22px;letter-spacing:-.02em;margin:0}",
|
|
57
|
-
".dshe-header p{max-width:640px;margin:4px 0 0;color:var(--dsw-alias-
|
|
58
|
-
".dshe-kicker{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color
|
|
59
|
-
".dshe-panel{display:grid;gap:12px;padding:15px;border:1px solid var(--dsw-alias-border-
|
|
147
|
+
".dshe-header p{max-width:640px;margin:4px 0 0;color:var(--dsw-alias-label-tertiary,#77736d);font-size:13px;line-height:1.55}",
|
|
148
|
+
".dshe-kicker{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--dsw-alias-state-business-primary,#0b6c9f);font-weight:700}",
|
|
149
|
+
".dshe-panel{display:grid;gap:12px;padding:15px;border:1px solid var(--dsw-alias-border-l2,#dedbd5);border-radius:14px;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:0 1px 1px rgba(0,0,0,.02)}",
|
|
60
150
|
".dshe-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}",
|
|
61
151
|
".dshe-field{display:grid;gap:6px}",
|
|
62
|
-
".dshe-field label{font-size:12px;font-weight:600;color:var(--dsw-alias-
|
|
63
|
-
".dshe-field input[type=text],.dshe-field input[type=password],.dshe-field input[type=number],.dshe-field select,.dshe-field textarea{width:100%;box-sizing:border-box;padding:8px 10px;border:1px solid var(--dsw-alias-border-
|
|
64
|
-
".dshe-field select option{background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-
|
|
152
|
+
".dshe-field label{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary,#26231f)}",
|
|
153
|
+
".dshe-field input[type=text],.dshe-field input[type=password],.dshe-field input[type=number],.dshe-field select,.dshe-field textarea{width:100%;box-sizing:border-box;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,#dedbd5);border-radius:9px;background:var(--dsw-specific-input-major,#fff);color:var(--dsw-alias-label-primary,#26231f);font:inherit;font-size:13px}",
|
|
154
|
+
".dshe-field select option{background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#26231f)}",
|
|
65
155
|
".dshe-check{display:flex;gap:8px;align-items:center;font-size:13px}",
|
|
156
|
+
".dshe-global{display:grid;gap:10px;padding:11px 12px;border:1px dashed var(--dsw-alias-border-l2,#dedbd5);border-radius:11px}",
|
|
66
157
|
".dshe-actions{display:flex;gap:8px;flex-wrap:wrap}",
|
|
67
|
-
".dshe-btn{display:inline-flex;align-items:center;height:32px;padding:0 14px;border-radius:999px;border:1px solid var(--dsw-alias-border-
|
|
68
|
-
".dshe-btn.primary{background
|
|
158
|
+
".dshe-btn{display:inline-flex;align-items:center;height:32px;padding:0 14px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,#dedbd5);background:var(--dsw-alias-button-elevated-fill,transparent);color:inherit;font-size:13px;font-weight:600;cursor:pointer}",
|
|
159
|
+
".dshe-btn.primary{background:var(--dsw-alias-button-primary-fill,#0b6c9f);border-color:var(--dsw-alias-button-primary-fill,#0b6c9f);color:var(--dsw-alias-label-primary-foreground,#fff)}",
|
|
160
|
+
".dshe-btn.primary:hover{background:var(--dsw-alias-button-primary-hover,#0b6c9f);border-color:var(--dsw-alias-button-primary-hover,#0b6c9f)}",
|
|
69
161
|
".dshe-btn:disabled{opacity:.55;cursor:default}",
|
|
70
|
-
".dshe-alert{padding:10px 12px;border-radius:10px;font-size:12px;line-height:1.5}",
|
|
71
|
-
".dshe-alert.error{background:
|
|
72
|
-
".dshe-alert.success{background:
|
|
73
|
-
".dshe-alert.info{background:
|
|
74
|
-
".dshe-
|
|
75
|
-
".dshe-
|
|
162
|
+
".dshe-alert{padding:10px 12px;border-radius:10px;font-size:12px;line-height:1.5;border:1px solid transparent}",
|
|
163
|
+
".dshe-alert.error{background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#aa3939) 10%,transparent);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary,#aa3939) 22%,transparent);color:var(--dsw-alias-state-error-primary,#aa3939)}",
|
|
164
|
+
".dshe-alert.success{background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#267d52) 10%,transparent);border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary,#267d52) 22%,transparent);color:var(--dsw-alias-state-success-primary,#267d52)}",
|
|
165
|
+
".dshe-alert.info{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#0b5c86) 8%,transparent);border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary,#0b5c86) 20%,transparent);color:var(--dsw-alias-state-business-primary,#0b5c86)}",
|
|
166
|
+
".dshe-alert.warn{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#9a6700) 10%,transparent);border-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#9a6700) 22%,transparent);color:var(--dsw-alias-state-warn-primary,#9a6700)}",
|
|
167
|
+
".dshe-details summary{font-size:12px;font-weight:600;cursor:pointer;color:var(--dsw-alias-label-tertiary,#77736d)}",
|
|
168
|
+
".dshe-hint{font-size:12px;color:var(--dsw-alias-label-tertiary,#77736d);line-height:1.5}",
|
|
76
169
|
".dshe-whale-root{position:fixed;right:20px;bottom:20px;z-index:2147483000;pointer-events:none}",
|
|
77
|
-
".dshe-whale-card{pointer-events:auto;position:relative;width:300px;border:1px solid var(--dsw-alias-border-
|
|
170
|
+
".dshe-whale-card{pointer-events:auto;position:relative;width:300px;border:1px solid var(--dsw-alias-border-l2,#e5e2db);border-radius:14px;background:var(--dsw-alias-bg-layer-1,#fff);box-shadow:var(--dsw-elevation-prominent,0 8px 24px rgba(0,0,0,.18));overflow:hidden;animation:dshe-whale-in .35s ease-out;font-size:13px;color:var(--dsw-alias-label-primary,#26231f);color-scheme:light dark}",
|
|
78
171
|
"@keyframes dshe-whale-in{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}",
|
|
79
|
-
".dshe-whale-img{display:block;width:100%;height:120px;object-fit:cover;object-position:center top;background
|
|
172
|
+
".dshe-whale-img{display:block;width:100%;height:120px;object-fit:cover;object-position:center top;background:var(--dsw-alias-bg-layer-2,#eaf3f8)}",
|
|
80
173
|
".dshe-whale-body{padding:10px 12px 12px;display:grid;gap:6px}",
|
|
81
174
|
".dshe-whale-title{font-weight:700;font-size:13px}",
|
|
82
|
-
".dshe-whale-item{color:var(--dsw-alias-
|
|
83
|
-
".dshe-whale-credit{font-size:10px;color:var(--dsw-alias-
|
|
84
|
-
".dshe-whale-close{position:absolute;top:6px;right:6px;width:22px;height:22px;border-radius:50%;border:none;background:rgba(0,0,0,.35);color
|
|
175
|
+
".dshe-whale-item{color:var(--dsw-alias-label-tertiary,#77736d);font-size:12px;line-height:1.45;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
176
|
+
".dshe-whale-credit{font-size:10px;color:var(--dsw-alias-label-tertiary,#77736d);opacity:.8;line-height:1.4}",
|
|
177
|
+
".dshe-whale-close{position:absolute;top:6px;right:6px;width:22px;height:22px;border-radius:50%;border:none;background:var(--dsw-alias-bg-mask-3,rgba(0,0,0,.35));color:var(--dsw-alias-label-primary-inverted,#fff);cursor:pointer;font-size:12px;line-height:1}",
|
|
178
|
+
".dshe-acc-list{display:grid;gap:10px}",
|
|
179
|
+
".dshe-acc-card{display:grid;gap:9px;padding:11px 12px;border:1px solid var(--dsw-alias-border-l2,#dedbd5);border-radius:11px;background:var(--dsw-alias-bg-layer-2,#f7f6f2)}",
|
|
180
|
+
".dshe-acc-head{display:flex;flex-wrap:wrap;gap:8px;align-items:center}",
|
|
181
|
+
".dshe-acc-name{font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
182
|
+
".dshe-acc-badge{display:inline-flex;align-items:center;height:18px;padding:0 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,#dedbd5);font-size:10px;font-weight:700;color:var(--dsw-alias-state-business-primary,#0b5c86)}",
|
|
183
|
+
".dshe-acc-badge.todo{border-style:dashed;color:inherit;opacity:.75}",
|
|
184
|
+
".dshe-acc-badge.is-default{border-color:transparent;background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#267d52) 12%,transparent);color:var(--dsw-alias-state-success-primary,#267d52)}",
|
|
185
|
+
".dshe-acc-meta{font-size:12px;opacity:.72;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
186
|
+
".dshe-acc-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-left:auto}",
|
|
187
|
+
".dshe-acc-danger{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary,#aa3939) 38%,transparent);color:var(--dsw-alias-state-error-primary,#aa3939)}",
|
|
188
|
+
".dshe-acc-body{display:grid;gap:12px;padding-top:2px}",
|
|
189
|
+
".dshe-acc-confirm{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:8px 10px;border-radius:10px;font-size:12px;line-height:1.5;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary,#aa3939) 22%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#aa3939) 10%,transparent);color:var(--dsw-alias-state-error-primary,#aa3939)}",
|
|
190
|
+
".dshe-acc-empty{padding:12px;border:1px dashed var(--dsw-alias-border-l2,#dedbd5);border-radius:11px;font-size:12px;text-align:center;opacity:.72}",
|
|
191
|
+
".dshe-acc-warn{color:var(--dsw-alias-state-error-primary,#aa3939)}",
|
|
192
|
+
".dshe-prst-list{display:grid;gap:10px}",
|
|
193
|
+
".dshe-prst-card{display:grid;gap:9px;padding:11px 12px;border:1px solid var(--dsw-alias-border-l2,#dedbd5);border-radius:11px;background:var(--dsw-alias-bg-layer-2,#f7f6f2)}",
|
|
194
|
+
".dshe-prst-head{display:flex;flex-wrap:wrap;gap:8px;align-items:center}",
|
|
195
|
+
".dshe-prst-name{font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
196
|
+
".dshe-prst-badge{display:inline-flex;align-items:center;height:18px;padding:0 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,#dedbd5);font-size:10px;font-weight:700;color:var(--dsw-alias-state-business-primary,#0b5c86)}",
|
|
197
|
+
".dshe-prst-badge.todo{border-style:dashed;color:inherit;opacity:.75}",
|
|
198
|
+
".dshe-prst-badge.done{border-color:transparent;background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#267d52) 12%,transparent);color:var(--dsw-alias-state-success-primary,#267d52)}",
|
|
199
|
+
".dshe-prst-meta{font-size:12px;opacity:.72;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
|
200
|
+
".dshe-prst-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-left:auto}",
|
|
201
|
+
".dshe-prst-danger{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary,#aa3939) 38%,transparent);color:var(--dsw-alias-state-error-primary,#aa3939)}",
|
|
202
|
+
".dshe-prst-body{display:grid;gap:12px;padding-top:2px}",
|
|
203
|
+
".dshe-prst-confirm{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:8px 10px;border-radius:10px;font-size:12px;line-height:1.5;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary,#aa3939) 22%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary,#aa3939) 10%,transparent);color:var(--dsw-alias-state-error-primary,#aa3939)}",
|
|
204
|
+
".dshe-prst-empty{padding:12px;border:1px dashed var(--dsw-alias-border-l2,#dedbd5);border-radius:11px;font-size:12px;text-align:center;opacity:.72}",
|
|
205
|
+
".dshe-prst-warn{color:var(--dsw-alias-state-error-primary,#aa3939)}",
|
|
206
|
+
".dshe-prst-section{margin-top:4px}",
|
|
207
|
+
".dshe-auto-status{font-size:12px;font-weight:600;align-self:center;color:var(--dsw-alias-label-tertiary,#77736d)}",
|
|
208
|
+
".dshe-auto-status.is-saved{color:var(--dsw-alias-state-success-primary,#267d52)}",
|
|
209
|
+
".dshe-auto-status.is-error{color:var(--dsw-alias-state-error-primary,#aa3939)}",
|
|
210
|
+
".dshe-acc-badge.is-oauth{border-color:transparent;background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#267d52) 12%,transparent);color:var(--dsw-alias-state-success-primary,#267d52)}",
|
|
211
|
+
".dshe-oauth{display:grid;gap:8px;padding:10px 11px;border:1px dashed var(--dsw-alias-border-l2,#dedbd5);border-radius:10px;background:var(--dsw-alias-bg-layer-1,#fff)}",
|
|
212
|
+
".dshe-oauth.is-logged-in{border-style:solid;border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary,#267d52) 34%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#267d52) 8%,transparent)}",
|
|
213
|
+
".dshe-oauth-head{font-size:12px;font-weight:600;line-height:1.5}",
|
|
214
|
+
".dshe-oauth-ok{color:var(--dsw-alias-state-success-primary,#267d52)}",
|
|
215
|
+
".dshe-oauth-wait{font-size:12px;color:var(--dsw-alias-label-tertiary,#77736d)}",
|
|
216
|
+
".dshe-oauth-code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:20px;font-weight:700;letter-spacing:.14em;padding:8px 10px;border-radius:9px;border:1px solid var(--dsw-alias-border-l2,#dedbd5);background:var(--dsw-alias-bg-layer-2,#f7f6f2);text-align:center;user-select:all;overflow-wrap:anywhere}",
|
|
217
|
+
".dshe-oauth-url{font-size:12px;line-height:1.5;overflow-wrap:anywhere}",
|
|
218
|
+
".dshe-oauth-url a{color:var(--dsw-alias-state-business-primary,#0b5c86)}",
|
|
219
|
+
".dshe-oauth-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}",
|
|
85
220
|
].join("\n");
|
|
86
221
|
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// 界面文案字典。zh 是原文,en 是对照译文;两张表的 key 集必须逐键相同。
|
|
224
|
+
//
|
|
225
|
+
// 几条规矩,改文案时照做:
|
|
226
|
+
// - key 是「区域.名字」的短标识,跟文案内容无关,改文案不动 key;
|
|
227
|
+
// - 占位符写成 {name},由 t() 做字面替换。**替换是字面量级的**,所以一条文案里
|
|
228
|
+
// 的 {x} 顺序由这条文案自己决定(中英文语序不同也不需要动调用点);
|
|
229
|
+
// - key 不存在时 t() 原样返回 key —— 界面上会直接露出 'card.add' 这样的字符串,
|
|
230
|
+
// 这是故意的:漏译要看得见,不能静默变成空字符串。
|
|
231
|
+
// - 全角空格一律写成 \u3000,别在这份字典里直接敲。
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
var UI = {
|
|
234
|
+
zh: {
|
|
235
|
+
// 通用
|
|
236
|
+
"common.cancel": "取消",
|
|
237
|
+
"common.retry": "重试",
|
|
238
|
+
"common.delete": "删除",
|
|
239
|
+
"common.edit": "编辑",
|
|
240
|
+
"common.collapse": "收起",
|
|
241
|
+
"common.confirmDelete": "确认删除",
|
|
242
|
+
"common.loading": "加载中…",
|
|
243
|
+
"common.listSeparator": "、",
|
|
244
|
+
|
|
245
|
+
// 导航 / 段标题
|
|
246
|
+
"nav.title": "邮件 (dsh-email)",
|
|
247
|
+
"section.title": "邮件",
|
|
248
|
+
"section.intro": "在这里配置邮箱账号,即可使用 10 个 email_* 工具。每个账号只记服务商与凭证,端点在连接时从服务器预设展开;改动自动保存、立即生效。",
|
|
249
|
+
"section.readonly": "当前 settings 存储是只读的,只能查看不能保存。",
|
|
250
|
+
"section.activeAccounts": "当前生效账号:{accounts}",
|
|
251
|
+
|
|
252
|
+
// 内置服务商名(PROVIDERS 里存的就是这些 key)
|
|
253
|
+
"provider.qq": "QQ 邮箱",
|
|
254
|
+
"provider.163": "163 邮箱",
|
|
255
|
+
"provider.126": "126 邮箱",
|
|
256
|
+
"provider.sina": "新浪邮箱",
|
|
257
|
+
"provider.aliyun": "阿里邮箱",
|
|
258
|
+
"provider.gmail": "Gmail",
|
|
259
|
+
"provider.outlook": "Outlook",
|
|
260
|
+
"provider.icloud": "iCloud",
|
|
261
|
+
"provider.custom": "自定义",
|
|
262
|
+
"provider.noneInYaml": "(YAML 未指定服务商)",
|
|
263
|
+
"provider.undefinedPresetSuffix": "(未定义预设)",
|
|
264
|
+
|
|
265
|
+
// 全局设置
|
|
266
|
+
"global.sendApproval": "发信前弹确认(强烈建议保留;Full Access 模式下会被自动拒绝)",
|
|
267
|
+
"global.downloadDirLabel": "附件下载目录(默认 $DSH_HOME/email-downloads)",
|
|
268
|
+
"global.downloadDirPlaceholder": "留空使用默认",
|
|
269
|
+
"global.hint": "以上两项对所有账号统一生效。",
|
|
270
|
+
|
|
271
|
+
// 自动保存状态条
|
|
272
|
+
"status.saved": "已保存",
|
|
273
|
+
"status.saving": "保存中…",
|
|
274
|
+
"status.saveFailed": "保存失败:{message}(点击重试)",
|
|
275
|
+
|
|
276
|
+
// 账号卡片
|
|
277
|
+
"card.add": "+ 添加账号",
|
|
278
|
+
"card.empty": "还没有账号。点「+ 添加账号」新增一个。",
|
|
279
|
+
"card.unnamed": "(未命名)",
|
|
280
|
+
"card.noAddress": "(未填邮箱地址)",
|
|
281
|
+
"card.meta": "{address} · {folder} · {provider}",
|
|
282
|
+
"card.noProvider": "未选服务商",
|
|
283
|
+
"card.defaultBadge": "默认",
|
|
284
|
+
"card.defaultAccount": "默认账号",
|
|
285
|
+
"card.setDefault": "设为默认",
|
|
286
|
+
"card.setDefaultAs": "设为默认:{name}",
|
|
287
|
+
"card.incomplete": "未完成",
|
|
288
|
+
"card.renamedBadge": "改名 → 按新名保存",
|
|
289
|
+
"card.notLoggedIn": "未登录",
|
|
290
|
+
"card.loggedInAs": "已登录 {user}",
|
|
291
|
+
"card.testConnect": "测试连接",
|
|
292
|
+
"card.testing": "测试中…",
|
|
293
|
+
"card.testOk": "连接成功({ms} ms)· {host}:{port}",
|
|
294
|
+
"card.testFailed": "连接失败:{message}",
|
|
295
|
+
"card.pickDefault": "配置了多个账号,请设置默认账号:",
|
|
296
|
+
"card.errNoName": "有账号还没填账号名,这份改动先不保存。",
|
|
297
|
+
"card.errNoProvider": "有账号还没选服务商,这份改动先不保存。",
|
|
298
|
+
"card.errDuplicateName": "已经有同名账号了,改名会覆盖掉它,这份改动先不保存。",
|
|
299
|
+
"card.errReservedName": "账号名不能是 defaultAccount(该键保留给默认账号),这份改动先不保存。",
|
|
300
|
+
"card.errNoDefault": "有多个账号还没指定默认账号,这份改动先不保存。",
|
|
301
|
+
"card.yamlParseFailed": "accountsYaml 解析失败:{error}",
|
|
302
|
+
"card.confirmDelete": "删除账号「{name}」会在保存后从 YAML 里移除,已存密码一并丢失。",
|
|
303
|
+
"card.nameLabel": "账号名(工具调用的 account 参数)",
|
|
304
|
+
"card.nameHint": "不能叫 defaultAccount(该键保留给默认账号)。改名会删旧键、建新键。",
|
|
305
|
+
"card.reservedNameWarn": "defaultAccount 是保留键(表示默认账号),请换一个名字。",
|
|
306
|
+
"card.providerLabel": "服务商",
|
|
307
|
+
"card.addressLabel": "邮箱地址",
|
|
308
|
+
"card.authKindLabel": "认证方式",
|
|
309
|
+
"card.authKindAuto": "自动(按服务商判断)",
|
|
310
|
+
"card.authKindOAuth2": "OAuth2 设备码登录",
|
|
311
|
+
"card.authKindPassword": "密码 / 授权码",
|
|
312
|
+
"card.authKindHint": "默认按服务商判断。仍能用应用密码连 Exchange Online 的租户(混合或本地部署、SMTP AUTH 未关闭)选「密码 / 授权码」;改动后请重新「测试连接」。",
|
|
313
|
+
"card.passwordLabel": "授权码 / 应用专用密码",
|
|
314
|
+
"card.passwordPlaceholder": "留空保持不变",
|
|
315
|
+
"card.passwordSaved": "已存有授权码:留空保持不变,清空后填内容即覆盖。",
|
|
316
|
+
"card.passwordHint": "留空即不写入 password 键。",
|
|
317
|
+
"card.senderNameLabel": "发件显示名(可选)",
|
|
318
|
+
"card.senderNamePlaceholder": "只影响收件人看到的名称",
|
|
319
|
+
"card.senderNameHint": "只改收件人看到的显示名,发件地址仍是上面的邮箱地址。",
|
|
320
|
+
"card.authUserLabel": "登录账号(与邮箱地址不同时填)",
|
|
321
|
+
"card.authUserPlaceholder": "留空 = 用上面的邮箱地址登录",
|
|
322
|
+
"card.authUserHint": "别名或中继场景:上面填信件发出的地址,这里填真正用于 IMAP/SMTP 登录的账号。留空即与邮箱地址相同。",
|
|
323
|
+
"card.authPasswordLabel": "登录账号的密码(可选)",
|
|
324
|
+
"card.authPasswordSaved": "已存有登录密码:留空保持不变,清空后填内容即覆盖。",
|
|
325
|
+
"card.authPasswordHint": "留空即沿用上面那栏的授权码 / 应用专用密码;只有登录账号与邮箱地址不同、且密码也不一样时才需要单独填。",
|
|
326
|
+
"card.inboxLabel": "收件文件夹(默认 INBOX)",
|
|
327
|
+
"card.autoSaveHint": "改动会自动保存。",
|
|
328
|
+
"card.autoSaveHintNoDefault": " 现在有多个账号但没有默认账号,必须先指定一个。",
|
|
329
|
+
"card.builtinEndpoints": " · {imap} | {smtp}",
|
|
330
|
+
"card.customEndpoint": " · {endpoint}",
|
|
331
|
+
|
|
332
|
+
// OAuth2 登录区
|
|
333
|
+
"oauth.clientIdLabel": "应用(客户端)ID",
|
|
334
|
+
"oauth.clientIdPlaceholder": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
|
335
|
+
"oauth.clientIdHint": "本插件不内置任何第三方应用注册,请用自己注册的 Azure(Entra)公共客户端 ID:免费,需勾选 IMAP/SMTP 委托权限并开启设备码流,步骤见 README 的「Outlook OAuth2」一节。改动它需要重新登录。",
|
|
336
|
+
"oauth.clientIdMissing": "还没填应用(客户端)ID,设备码登录无法开始:按下面的提示注册一个,填进来即可。",
|
|
337
|
+
"oauth.passwordless": "这个服务商使用 OAuth2 授权,不需要密码{label}。",
|
|
338
|
+
"oauth.passwordlessLabel": "({label})",
|
|
339
|
+
"oauth.signIn.outlook": "登录 Microsoft 账号",
|
|
340
|
+
"oauth.signIn.generic": "登录 {provider} 账号",
|
|
341
|
+
"oauth.requestingCode": "正在获取授权码…",
|
|
342
|
+
"oauth.signingIn": "登录中…",
|
|
343
|
+
"oauth.reLogin": "重新登录",
|
|
344
|
+
"oauth.pendingHint": "在浏览器打开下面的地址,输入代码完成授权:",
|
|
345
|
+
"oauth.noUrl": "(后端没有返回授权地址,请点取消后重试)",
|
|
346
|
+
"oauth.polling": "轮询中…(每 {seconds} 秒检查一次,授权链接约 {minutes} 分钟内有效)",
|
|
347
|
+
"oauth.loggedInAs": "已登录:{user}",
|
|
348
|
+
"oauth.unknownUser": "(未知账号)",
|
|
349
|
+
"oauth.loginOk": "登录成功。",
|
|
350
|
+
"oauth.loginOkAs": "登录成功:{user}",
|
|
351
|
+
"oauth.loginFailed": "登录失败",
|
|
352
|
+
"oauth.badResponse": "服务端返回了无法识别的响应",
|
|
353
|
+
|
|
354
|
+
// 服务器预设
|
|
355
|
+
"preset.sectionTitle": "服务器预设(自定义服务商,高级)",
|
|
356
|
+
"preset.add": "+ 添加预设",
|
|
357
|
+
"preset.empty": "还没有自定义服务器预设。点「+ 添加预设」,账号卡片的服务商下拉里就会多出这个名字。",
|
|
358
|
+
"preset.cleared": "卡片已清空:填完剩下的字段即会写回,删除全部 {count} 条预设。",
|
|
359
|
+
"preset.badgeUnnamed": "未命名",
|
|
360
|
+
"preset.badgeRenamed": "改名 → 删旧建新",
|
|
361
|
+
"preset.badgeDuplicate": "重名",
|
|
362
|
+
"preset.noHost": "(未填主机)",
|
|
363
|
+
"preset.endpointMissing": "端点未填",
|
|
364
|
+
"preset.metaImap": "imap {host}:{port}",
|
|
365
|
+
"preset.metaSmtp": " | smtp {host}:{port}",
|
|
366
|
+
"preset.ssl": " · SSL",
|
|
367
|
+
"preset.plain": " · 明文",
|
|
368
|
+
"preset.confirmDelete": "删除预设「{name}」后,指向它的账号卡片会失去端点(服务商下拉里也不再列出它)。",
|
|
369
|
+
"preset.nameLabel": "预设名(serverPresets 的键,也是账号 provider 的值)",
|
|
370
|
+
"preset.nameHint": "不能和内置服务商同名(qq/163/126/sina/aliyun/gmail/outlook/icloud)—— 那会被内置预设挡住。改名会删旧键、建新键。",
|
|
371
|
+
"preset.duplicateWarn": "已经有同名预设了:预设名是 YAML 的键,必须唯一。",
|
|
372
|
+
"preset.labelLabel": "显示名 label(可选)",
|
|
373
|
+
"preset.labelPlaceholder": "公司邮箱",
|
|
374
|
+
"preset.imapHostLabel": "IMAP 主机",
|
|
375
|
+
"preset.imapPortLabel": "IMAP 端口",
|
|
376
|
+
"preset.imapPortHint": "留空即用默认 993。",
|
|
377
|
+
"preset.imapSsl": "IMAP SSL",
|
|
378
|
+
"preset.smtpHostLabel": "SMTP 主机",
|
|
379
|
+
"preset.smtpPortLabel": "SMTP 端口",
|
|
380
|
+
"preset.smtpPortHint": "留空即用默认 465。",
|
|
381
|
+
"preset.smtpSsl": "SMTP SSL(587 端口请取消勾选)",
|
|
382
|
+
"preset.portProblem": "{problem}。",
|
|
383
|
+
"preset.yamlParseFailed": "serverPresets 解析失败:{error}",
|
|
384
|
+
"preset.yamlParseFailedHint": "下面的卡片是从快照里的(空)预设表建的,不代表这份文本。可以直接改下面的 YAML 重新写一份。",
|
|
385
|
+
"preset.footerHint": "预设不含邮箱地址和授权码,只记连接参数;账号只存服务商名,端点在连接时从预设展开。",
|
|
386
|
+
"preset.portNumeric": "端口必须是数字",
|
|
387
|
+
"preset.portRange": "端口必须在 1-65535 之间",
|
|
388
|
+
"preset.errNoName": "有预设还没填名称(预设名就是 YAML 的键)。",
|
|
389
|
+
"preset.errDuplicate": "有两张预设卡都叫「{name}」:预设名是 YAML 的键,不能重名。",
|
|
390
|
+
"preset.errNoImapHost": "预设「{name}」还没填 IMAP 主机。",
|
|
391
|
+
"preset.errNoSmtpHost": "预设「{name}」还没填 SMTP 主机。",
|
|
392
|
+
"preset.errImapPort": "预设「{name}」的 IMAP {problem}。",
|
|
393
|
+
"preset.errSmtpPort": "预设「{name}」的 SMTP {problem}。",
|
|
394
|
+
|
|
395
|
+
// 卡片级提示
|
|
396
|
+
"notice.commentsDropped": "原文档无法就地编辑,注释已丢失;",
|
|
397
|
+
"notice.passwordsDropped": "有账号的已存密码无法保留,请在对应卡片重输;",
|
|
398
|
+
"notice.restSaved": "其余改动已自动保存。",
|
|
399
|
+
"notice.cardsNotWritten": "账号改动没能写进 YAML:{message}",
|
|
400
|
+
|
|
401
|
+
// 鲸鱼娘弹窗
|
|
402
|
+
"whale.title": "鲸鱼娘递信:有 {count} 封新邮件",
|
|
403
|
+
"whale.unknownSender": "(未知)",
|
|
404
|
+
"whale.noSubject": "(无主题)",
|
|
405
|
+
"whale.row": "{who} · {subject}",
|
|
406
|
+
"whale.more": "…其余 {count} 封,用 email_watch / email_read 查看",
|
|
407
|
+
"whale.close": "关闭",
|
|
408
|
+
},
|
|
409
|
+
|
|
410
|
+
// 与 zh 逐键对照。技术名词(IMAP/SMTP/OAuth2/INBOX/SSL/YAML、键名与预设名)原样保留;
|
|
411
|
+
// 占位符逐键与 zh 相同,语序按英文自己排。
|
|
412
|
+
en: {
|
|
413
|
+
// 通用
|
|
414
|
+
"common.cancel": "Cancel",
|
|
415
|
+
"common.retry": "Retry",
|
|
416
|
+
"common.delete": "Delete",
|
|
417
|
+
"common.edit": "Edit",
|
|
418
|
+
"common.collapse": "Collapse",
|
|
419
|
+
"common.confirmDelete": "Confirm delete",
|
|
420
|
+
"common.loading": "Loading…",
|
|
421
|
+
"common.listSeparator": ", ",
|
|
422
|
+
|
|
423
|
+
// 导航 / 段标题
|
|
424
|
+
"nav.title": "Email (dsh-email)",
|
|
425
|
+
"section.title": "Email",
|
|
426
|
+
"section.intro": "Configure mailbox accounts here to use the ten email_* tools. An account stores only its provider and credentials; endpoints are expanded from the server preset at connect time. Changes save automatically and take effect at once.",
|
|
427
|
+
"section.readonly": "The settings store is read-only right now: you can view the settings but not save them.",
|
|
428
|
+
"section.activeAccounts": "Active accounts: {accounts}",
|
|
429
|
+
|
|
430
|
+
// 内置服务商名(PROVIDERS 里存的就是这些 key)
|
|
431
|
+
"provider.qq": "QQ Mail",
|
|
432
|
+
"provider.163": "163 Mail",
|
|
433
|
+
"provider.126": "126 Mail",
|
|
434
|
+
"provider.sina": "Sina Mail",
|
|
435
|
+
"provider.aliyun": "Alibaba Mail",
|
|
436
|
+
"provider.gmail": "Gmail",
|
|
437
|
+
"provider.outlook": "Outlook",
|
|
438
|
+
"provider.icloud": "iCloud",
|
|
439
|
+
"provider.custom": "Custom",
|
|
440
|
+
"provider.noneInYaml": "(no provider in YAML)",
|
|
441
|
+
"provider.undefinedPresetSuffix": "(undefined preset)",
|
|
442
|
+
|
|
443
|
+
// 全局设置
|
|
444
|
+
"global.sendApproval": "Ask for confirmation before sending (strongly recommended; auto-denied under Full Access)",
|
|
445
|
+
"global.downloadDirLabel": "Attachment download directory (defaults to $DSH_HOME/email-downloads)",
|
|
446
|
+
"global.downloadDirPlaceholder": "Leave empty for the default",
|
|
447
|
+
"global.hint": "These two settings apply to every account.",
|
|
448
|
+
|
|
449
|
+
// 自动保存状态条
|
|
450
|
+
"status.saved": "Saved",
|
|
451
|
+
"status.saving": "Saving…",
|
|
452
|
+
"status.saveFailed": "Save failed: {message} (click to retry)",
|
|
453
|
+
|
|
454
|
+
// 账号卡片
|
|
455
|
+
"card.add": "+ Add account",
|
|
456
|
+
"card.empty": "No accounts yet. Click “+ Add account” to create one.",
|
|
457
|
+
"card.unnamed": "(unnamed)",
|
|
458
|
+
"card.noAddress": "(no email address)",
|
|
459
|
+
"card.meta": "{address} · {folder} · {provider}",
|
|
460
|
+
"card.noProvider": "No provider",
|
|
461
|
+
"card.defaultBadge": "Default",
|
|
462
|
+
"card.defaultAccount": "Default account",
|
|
463
|
+
"card.setDefault": "Set as default",
|
|
464
|
+
"card.setDefaultAs": "Set as default: {name}",
|
|
465
|
+
"card.incomplete": "Incomplete",
|
|
466
|
+
"card.renamedBadge": "Renamed → saves under the new name",
|
|
467
|
+
"card.notLoggedIn": "Not signed in",
|
|
468
|
+
"card.loggedInAs": "Signed in as {user}",
|
|
469
|
+
"card.testConnect": "Test connection",
|
|
470
|
+
"card.testing": "Testing…",
|
|
471
|
+
"card.testOk": "Connected ({ms} ms) · {host}:{port}",
|
|
472
|
+
"card.testFailed": "Connection failed: {message}",
|
|
473
|
+
"card.pickDefault": "Several accounts are configured; choose the default account:",
|
|
474
|
+
"card.errNoName": "An account has no name yet; this change will not be saved.",
|
|
475
|
+
"card.errNoProvider": "An account has no provider selected; this change will not be saved.",
|
|
476
|
+
"card.errDuplicateName": "Another account already has that name; renaming would overwrite it, so this change will not be saved.",
|
|
477
|
+
"card.errReservedName": "An account cannot be named defaultAccount (that key is reserved for the default account); this change will not be saved.",
|
|
478
|
+
"card.errNoDefault": "Several accounts exist but no default account was chosen; this change will not be saved.",
|
|
479
|
+
"card.yamlParseFailed": "accountsYaml failed to parse: {error}",
|
|
480
|
+
"card.confirmDelete": "Deleting account “{name}” removes it from the YAML once saved, and its stored password is lost with it.",
|
|
481
|
+
"card.nameLabel": "Account name (the account argument of tool calls)",
|
|
482
|
+
"card.nameHint": "Cannot be defaultAccount (that key is reserved for the default account). Renaming deletes the old key and creates a new one.",
|
|
483
|
+
"card.reservedNameWarn": "defaultAccount is a reserved key (it means the default account); pick another name.",
|
|
484
|
+
"card.providerLabel": "Provider",
|
|
485
|
+
"card.addressLabel": "Email address",
|
|
486
|
+
"card.authKindLabel": "Authentication",
|
|
487
|
+
"card.authKindAuto": "Automatic (decided by the provider)",
|
|
488
|
+
"card.authKindOAuth2": "OAuth2 device-code login",
|
|
489
|
+
"card.authKindPassword": "Password / app password",
|
|
490
|
+
"card.authKindHint": "Decided by the provider by default. Choose「Password / app password」for a tenant that still accepts basic auth against Exchange Online (hybrid or on-premises, SMTP AUTH left enabled); re-run「Test connection」after changing it.",
|
|
491
|
+
"card.passwordLabel": "Authorization code / app password",
|
|
492
|
+
"card.passwordPlaceholder": "Leave empty to keep it",
|
|
493
|
+
"card.passwordSaved": "A password is already stored: leave empty to keep it, or clear the field and type to overwrite.",
|
|
494
|
+
"card.passwordHint": "Leave it empty and the password key is not written.",
|
|
495
|
+
"card.senderNameLabel": "Sender display name (optional)",
|
|
496
|
+
"card.senderNamePlaceholder": "Only the name recipients see",
|
|
497
|
+
"card.senderNameHint": "Changes only the name recipients see; the address stays the one above.",
|
|
498
|
+
"card.authUserLabel": "Login user (when it differs from the address)",
|
|
499
|
+
"card.authUserPlaceholder": "Empty = log in with the address above",
|
|
500
|
+
"card.authUserHint": "Alias or relay: the address above is what mail is sent from, this is the account IMAP/SMTP authenticates. Empty means they are the same.",
|
|
501
|
+
"card.authPasswordLabel": "Password for the login user (optional)",
|
|
502
|
+
"card.authPasswordSaved": "A login password is stored: leave it empty to keep it, or type to replace it.",
|
|
503
|
+
"card.authPasswordHint": "Leave empty to reuse the authorization code / app password above; only needed when the login user has a password of its own.",
|
|
504
|
+
"card.inboxLabel": "Inbox folder (default INBOX)",
|
|
505
|
+
"card.autoSaveHint": "Changes save automatically.",
|
|
506
|
+
"card.autoSaveHintNoDefault": " There are several accounts but no default account yet; you must pick one first.",
|
|
507
|
+
"card.builtinEndpoints": " · {imap} | {smtp}",
|
|
508
|
+
"card.customEndpoint": " · {endpoint}",
|
|
509
|
+
|
|
510
|
+
// OAuth2 登录区
|
|
511
|
+
"oauth.clientIdLabel": "Application (client) ID",
|
|
512
|
+
"oauth.clientIdPlaceholder": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
|
513
|
+
"oauth.clientIdHint": "This plugin ships no third-party application registration, so use your own Azure (Entra) public client ID: free, with the IMAP/SMTP delegated permissions and the device-code flow enabled. The README's「Outlook OAuth2」section walks through it. Changing it requires signing in again.",
|
|
514
|
+
"oauth.clientIdMissing": "No application (client) ID yet, so the device-code login cannot start: register one as described below and paste it here.",
|
|
515
|
+
"oauth.passwordless": "This provider authorizes over OAuth2, so no password is needed{label}.",
|
|
516
|
+
"oauth.passwordlessLabel": " ({label})",
|
|
517
|
+
"oauth.signIn.outlook": "Sign in with Microsoft",
|
|
518
|
+
"oauth.signIn.generic": "Sign in to {provider}",
|
|
519
|
+
"oauth.requestingCode": "Requesting a code…",
|
|
520
|
+
"oauth.signingIn": "Signing in…",
|
|
521
|
+
"oauth.reLogin": "Sign in again",
|
|
522
|
+
"oauth.pendingHint": "Open the address below in your browser and enter the code to finish authorizing:",
|
|
523
|
+
"oauth.noUrl": "(the backend returned no authorization URL; cancel and try again)",
|
|
524
|
+
"oauth.polling": "Waiting… (checking every {seconds} seconds; the authorization link stays valid for about {minutes} minutes)",
|
|
525
|
+
"oauth.loggedInAs": "Signed in: {user}",
|
|
526
|
+
"oauth.unknownUser": "(unknown account)",
|
|
527
|
+
"oauth.loginOk": "Signed in.",
|
|
528
|
+
"oauth.loginOkAs": "Signed in: {user}",
|
|
529
|
+
"oauth.loginFailed": "Sign-in failed",
|
|
530
|
+
"oauth.badResponse": "The server returned a response we do not recognize",
|
|
531
|
+
|
|
532
|
+
// 服务器预设
|
|
533
|
+
"preset.sectionTitle": "Server presets (custom providers, advanced)",
|
|
534
|
+
"preset.add": "+ Add preset",
|
|
535
|
+
"preset.empty": "No custom server presets yet. Click “+ Add preset” and the name shows up in the provider dropdown of the account cards.",
|
|
536
|
+
"preset.cleared": "Cards cleared: finish the remaining fields and it is written back, deleting all {count} presets.",
|
|
537
|
+
"preset.badgeUnnamed": "Unnamed",
|
|
538
|
+
"preset.badgeRenamed": "Renamed → old key deleted, new one created",
|
|
539
|
+
"preset.badgeDuplicate": "Duplicate",
|
|
540
|
+
"preset.noHost": "(no host)",
|
|
541
|
+
"preset.endpointMissing": "No endpoint",
|
|
542
|
+
"preset.metaImap": "imap {host}:{port}",
|
|
543
|
+
"preset.metaSmtp": " | smtp {host}:{port}",
|
|
544
|
+
"preset.ssl": " · SSL",
|
|
545
|
+
"preset.plain": " · plain",
|
|
546
|
+
"preset.confirmDelete": "Deleting preset “{name}” leaves the account cards pointing at it without endpoints (and it is no longer listed in the provider dropdown).",
|
|
547
|
+
"preset.nameLabel": "Preset name (the serverPresets key, also the account's provider value)",
|
|
548
|
+
"preset.nameHint": "Cannot collide with a built-in provider (qq/163/126/sina/aliyun/gmail/outlook/icloud) — the built-in preset would shadow it. Renaming deletes the old key and creates a new one.",
|
|
549
|
+
"preset.duplicateWarn": "A preset with this name already exists: preset names are YAML keys and must be unique.",
|
|
550
|
+
"preset.labelLabel": "Display label (label key, optional)",
|
|
551
|
+
"preset.labelPlaceholder": "Work email",
|
|
552
|
+
"preset.imapHostLabel": "IMAP host",
|
|
553
|
+
"preset.imapPortLabel": "IMAP port",
|
|
554
|
+
"preset.imapPortHint": "Leave empty to use the default 993.",
|
|
555
|
+
"preset.imapSsl": "IMAP SSL",
|
|
556
|
+
"preset.smtpHostLabel": "SMTP host",
|
|
557
|
+
"preset.smtpPortLabel": "SMTP port",
|
|
558
|
+
"preset.smtpPortHint": "Leave empty to use the default 465.",
|
|
559
|
+
"preset.smtpSsl": "SMTP SSL (uncheck it for port 587)",
|
|
560
|
+
"preset.portProblem": "{problem}.",
|
|
561
|
+
"preset.yamlParseFailed": "serverPresets failed to parse: {error}",
|
|
562
|
+
"preset.yamlParseFailedHint": "The cards below were built from the (empty) preset table in the snapshot, not from this text. Edit the YAML below to write a new one from scratch.",
|
|
563
|
+
"preset.footerHint": "A preset carries no email address and no password, only connection parameters; an account stores just the provider name and expands its endpoints from the preset when it connects.",
|
|
564
|
+
"preset.portNumeric": "The port must be a number",
|
|
565
|
+
"preset.portRange": "The port must be between 1 and 65535",
|
|
566
|
+
"preset.errNoName": "A preset has no name yet (the preset name is the YAML key).",
|
|
567
|
+
"preset.errDuplicate": "Two preset cards are both named “{name}”: preset names are YAML keys and must be unique.",
|
|
568
|
+
"preset.errNoImapHost": "Preset “{name}” has no IMAP host yet.",
|
|
569
|
+
"preset.errNoSmtpHost": "Preset “{name}” has no SMTP host yet.",
|
|
570
|
+
"preset.errImapPort": "Preset “{name}” has an invalid IMAP port: {problem}.",
|
|
571
|
+
"preset.errSmtpPort": "Preset “{name}” has an invalid SMTP port: {problem}.",
|
|
572
|
+
|
|
573
|
+
// 卡片级提示
|
|
574
|
+
"notice.commentsDropped": "Comments were dropped: the original document cannot be edited in place. ",
|
|
575
|
+
"notice.passwordsDropped": "Some stored passwords could not be kept; re-enter them on the matching cards. ",
|
|
576
|
+
"notice.restSaved": "Everything else was saved automatically.",
|
|
577
|
+
"notice.cardsNotWritten": "The account changes could not be written to the YAML: {message}",
|
|
578
|
+
|
|
579
|
+
// 鲸鱼娘弹窗
|
|
580
|
+
"whale.title": "Whale courier: {count} new messages",
|
|
581
|
+
"whale.unknownSender": "(unknown)",
|
|
582
|
+
"whale.noSubject": "(no subject)",
|
|
583
|
+
"whale.row": "{who} · {subject}",
|
|
584
|
+
"whale.more": "…and {count} more; use email_watch / email_read to see them",
|
|
585
|
+
"whale.close": "Close",
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
/**
|
|
589
|
+
* 当前界面语言('zh' | 'en')。初值 zh,挂载时由 locale 服务改写(见 subscribeLocale)。
|
|
590
|
+
* 模块级变量而不是 React state:t() 是普通函数,调用点遍布组件树和命令式 DOM 代码
|
|
591
|
+
* (鲸鱼娘弹窗);React 组件靠各自的 setState 触发重渲染,弹窗由订阅回调直接重画。
|
|
592
|
+
*/
|
|
593
|
+
var UI_LANG = "zh";
|
|
594
|
+
/**
|
|
595
|
+
* 语言决定:只认前缀。zh* 一律中文,en* 英文,其余(含空值、旧宿主的怪值)回退 zh ——
|
|
596
|
+
* 本插件的原文就是中文,退回它最不容易读出错误的界面。
|
|
597
|
+
*/
|
|
598
|
+
function localeToLang(active) {
|
|
599
|
+
const tag = String(active === undefined || active === null ? "" : active).toLowerCase();
|
|
600
|
+
if (tag.indexOf("zh") === 0) return "zh";
|
|
601
|
+
if (tag.indexOf("en") === 0) return "en";
|
|
602
|
+
return "zh";
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* 语言切换后的重绘钩子。React 那边由各自的 setState / 外壳的 locale 订阅兜底,
|
|
607
|
+
* 命令式 DOM(鲸鱼娘弹窗)没有那一层,只能自己登记一个。同一时刻只有可能开着一个
|
|
608
|
+
* 弹窗,所以这里是单槽而不是列表;弹窗销毁时它会把自己的钩子摘掉。
|
|
609
|
+
*/
|
|
610
|
+
var relocalize = null;
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* 取 locale 服务。两条路都试,因为「服务在不在」和「ctx 长什么样」是两件事:
|
|
614
|
+
* - ctx.get('locale') 是可选服务的正规读法(服务缺席时返回 undefined);
|
|
615
|
+
* - ctx.locale 直接读,留给没有 get() 的旧 ctx。
|
|
616
|
+
* 两条都拿不到就返回 null —— 调用方按「没有 locale」处理。
|
|
617
|
+
*/
|
|
618
|
+
function localeServiceOf(ctx) {
|
|
619
|
+
try {
|
|
620
|
+
if (typeof ctx.get === "function") {
|
|
621
|
+
const viaGet = ctx.get("locale");
|
|
622
|
+
if (viaGet !== undefined && viaGet !== null) return viaGet;
|
|
623
|
+
}
|
|
624
|
+
} catch (e) { /* 落到下面那条路 */ }
|
|
625
|
+
const direct = ctx.locale;
|
|
626
|
+
return direct === undefined || direct === null ? null : direct;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* 挂上 locale 服务:读一次当前语言,再订阅后续切换。
|
|
631
|
+
*
|
|
632
|
+
* 服务缺失是**正常路径**,不是异常:旧宿主 / 没启用 locale 的 profile 里根本
|
|
633
|
+
* 没有这个服务;就算有,getSnapshot / subscribe 也是别的包提供的,行为不可假定。
|
|
634
|
+
* 所以每个入口都先查存在性再 try —— 任何一步失败都只是留在 zh,绝不把插件的挂载
|
|
635
|
+
* 拖垮(这里抛出去,整个 settings 段和鲸鱼娘都会一起不出现)。
|
|
636
|
+
*
|
|
637
|
+
* 服务缺失时返回的也是一个函数(no-op),调用方不必再判空。
|
|
638
|
+
*/
|
|
639
|
+
function subscribeLocale(ctx) {
|
|
640
|
+
const service = localeServiceOf(ctx);
|
|
641
|
+
if (service === null) return () => {};
|
|
642
|
+
const apply = () => {
|
|
643
|
+
try {
|
|
644
|
+
if (typeof service.getSnapshot === "function") {
|
|
645
|
+
UI_LANG = localeToLang(service.getSnapshot().active);
|
|
646
|
+
}
|
|
647
|
+
} catch (e) {
|
|
648
|
+
UI_LANG = "zh";
|
|
649
|
+
}
|
|
650
|
+
if (typeof relocalize === "function") {
|
|
651
|
+
try { relocalize(); } catch (e) { /* 重绘失败不该让订阅链断掉 */ }
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
apply();
|
|
655
|
+
try {
|
|
656
|
+
if (typeof service.subscribe !== "function") return () => {};
|
|
657
|
+
const off = service.subscribe(apply);
|
|
658
|
+
return typeof off === "function" ? off : () => {};
|
|
659
|
+
} catch (e) {
|
|
660
|
+
return () => {};
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** 取一条界面文案。{name} 占位符按字面替换(不是正则、也不做 HTML 转义 ——
|
|
665
|
+
* 这些文案全部进 DOM 文本节点或 textContent,从不拼成 HTML)。
|
|
666
|
+
*
|
|
667
|
+
* 回退只有一级:**语言表里没有 key、或整张语言表不存在,都返回 key 本身**。
|
|
668
|
+
* 刻意不退回 zh —— 两张表理应逐键相同,真漏一条就露一条 'card.add',漏译在界面
|
|
669
|
+
* 上现形;静默退回中文会让「英文界面里混着中文」这种半成品看起来像做完了。
|
|
670
|
+
*/
|
|
671
|
+
function t(key, params) {
|
|
672
|
+
const table = UI[UI_LANG];
|
|
673
|
+
const raw = table && Object.prototype.hasOwnProperty.call(table, key) ? table[key] : key;
|
|
674
|
+
if (typeof raw !== "string" || params === undefined || params === null) return raw;
|
|
675
|
+
return raw.replace(/\{(\w+)\}/g, (whole, name) =>
|
|
676
|
+
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : whole);
|
|
677
|
+
}
|
|
678
|
+
|
|
87
679
|
function fieldInput(type, value, onChange, placeholder) {
|
|
88
680
|
return h("input", {
|
|
89
681
|
type,
|
|
@@ -93,14 +685,1452 @@ function fieldInput(type, value, onChange, placeholder) {
|
|
|
93
685
|
});
|
|
94
686
|
}
|
|
95
687
|
|
|
688
|
+
// ---------------------------------------------------------------------------
|
|
689
|
+
// 多账户卡片编辑器 helper。纯函数,不认识 React state:卡片是「账号名 -> 草稿」
|
|
690
|
+
// 的扁平映射,任何字段都可能是半填状态。
|
|
691
|
+
// ---------------------------------------------------------------------------
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* 卡片行的稳定内部 id。React key 只认它:账号名/预设名每敲一个字符都变,用名字
|
|
695
|
+
* 当 key 会让整张卡重挂载、输入框失焦。id 只在「卡片集合来源变化」(加载 / 解析
|
|
696
|
+
* YAML / 从快照重建)时重新分配,改名不动它。
|
|
697
|
+
*/
|
|
698
|
+
let nextCardId = 1;
|
|
699
|
+
let nextPresetId = 1;
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* 卡片集合来源变化(加载 / 解析 YAML / 从快照重建)时重建顺序表:每行拿一个新 id。
|
|
703
|
+
* 行 = { id, key },key 是草稿映射的键(账号名 / 预设名,也就是 YAML 的键)。
|
|
704
|
+
*/
|
|
705
|
+
function cardRows(names) {
|
|
706
|
+
return (names || []).map((key) => ({ id: nextCardId++, key }));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function presetRows(names) {
|
|
710
|
+
return (names || []).map((key) => ({ id: nextPresetId++, key }));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** 改名只换行的 key,id 不动 —— React key 不变,输入框不会重挂载失焦。 */
|
|
714
|
+
function rekeyRows(rows, oldKey, nextKey) {
|
|
715
|
+
return rows.map((row) => (row.key === oldKey ? { id: row.id, key: nextKey } : row));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** 新卡片的空草稿。账号只认服务商 + 凭证,端点由预设展开,草稿里不存端点。 */
|
|
719
|
+
function emptyAccountDraft(name) {
|
|
720
|
+
return {
|
|
721
|
+
name: name === undefined ? "" : name,
|
|
722
|
+
provider: "",
|
|
723
|
+
user: "",
|
|
724
|
+
password: "",
|
|
725
|
+
inboxFolder: "INBOX",
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** 卡片上的 provider 名 -> 那个 provider 的登录方式。没有卡片数据时按本地名单预判。 */
|
|
730
|
+
function authKindOf(provider) {
|
|
731
|
+
return OAUTH2_PROVIDERS.indexOf(String(provider === undefined || provider === null ? "" : provider)) >= 0
|
|
732
|
+
? "oauth2"
|
|
733
|
+
: "password";
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** 这张卡片是不是走 OAuth2:服务端字段优先(它知道真实预设表),否则本地按名单判。 */
|
|
737
|
+
function isOauthDraft(draft) {
|
|
738
|
+
if (draft === null || draft === undefined) return false;
|
|
739
|
+
if (draft.authKind === "oauth2") return true;
|
|
740
|
+
if (draft.authKind === "password") return false;
|
|
741
|
+
return authKindOf(draft.provider) === "oauth2";
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* 服务端卡片(永不含密码明文)-> 草稿。user 为空表示「就留空」,不是未加载。
|
|
746
|
+
* 账号在 YAML 里只存 provider id:卡片带着的 imap/smtp 是「这个预设展开出来的
|
|
747
|
+
* 端点」(快照算给你看的),不是账号自己的字段,所以一概不读 —— 读了就会在保存
|
|
748
|
+
* 时把一份会过期的端点抄回 YAML。卡片上的端点显示改从 presets 查(providerMetaText)。
|
|
749
|
+
* authKind/oauthState/oauthUser 是卡片的登录态投影,只用于显示与本地判定,
|
|
750
|
+
* 不进 draftToInput(它们不是 YAML 的字段,后端认的是账号自己的 oauth 段)。
|
|
751
|
+
*/
|
|
752
|
+
function draftFromCard(card) {
|
|
753
|
+
return {
|
|
754
|
+
name: String(card.name || ""),
|
|
755
|
+
originalName: String(card.name || ""),
|
|
756
|
+
imap: card.imap,
|
|
757
|
+
smtp: card.smtp,
|
|
758
|
+
provider: card.provider === undefined || card.provider === null ? "" : String(card.provider),
|
|
759
|
+
user: card.user === undefined || card.user === null ? "" : String(card.user),
|
|
760
|
+
password: "",
|
|
761
|
+
// 登录密码与 password 同一套三态契约:卡片永远拿不到明文,只有"是否已存"。
|
|
762
|
+
authPassword: "",
|
|
763
|
+
hasAuthPassword: card.hasAuthPassword === true,
|
|
764
|
+
senderName: card.senderName === undefined || card.senderName === null ? "" : String(card.senderName),
|
|
765
|
+
authUser: card.authUser === undefined || card.authUser === null ? "" : String(card.authUser),
|
|
766
|
+
clientId: card.clientId === undefined || card.clientId === null ? "" : String(card.clientId),
|
|
767
|
+
// authKind 是生效裁决(决定显示登录区还是密码框),authKindSetting 是用户钉住
|
|
768
|
+
// 了什么('' = 自动)。两者必须分开:都从裁决读的话,「自动」与「显式密码」在
|
|
769
|
+
// 编辑器里长得一样,那条给混合/本地租户留的退路就没法从面板操作。
|
|
770
|
+
authKindSetting: card.authKindDeclared === "oauth2" || card.authKindDeclared === "password" ? card.authKindDeclared : "",
|
|
771
|
+
inboxFolder: card.inboxFolder === undefined || card.inboxFolder === null ? "INBOX" : String(card.inboxFolder),
|
|
772
|
+
authKind: card.authKind === "oauth2" || card.authKind === "password" ? card.authKind : undefined,
|
|
773
|
+
oauthState: card.oauthState === "pending" || card.oauthState === "logged-in" ? card.oauthState : "none",
|
|
774
|
+
oauthUser: card.oauthUser === undefined || card.oauthUser === null ? "" : String(card.oauthUser),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** 卡片列表 -> { 账号名: 草稿 }。同名重复只留第一个(账号名是映射键)。 */
|
|
779
|
+
function draftsFromCards(list) {
|
|
780
|
+
const map = {};
|
|
781
|
+
for (const card of list || []) {
|
|
782
|
+
const name = String(card.name || "");
|
|
783
|
+
if (name === "" || Object.prototype.hasOwnProperty.call(map, name)) continue;
|
|
784
|
+
map[name] = draftFromCard(card);
|
|
785
|
+
}
|
|
786
|
+
return map;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* 下拉的选项集合:8 个内置 + serverPresets 里的自定义名。自定义项的显示文案取预设
|
|
791
|
+
* 自己的 label(预设编辑器里那个「显示名」),没写 label 才退回预设名。
|
|
792
|
+
*/
|
|
793
|
+
function providerOptions(presets) {
|
|
794
|
+
const out = PROVIDERS.map(([value, labelKey]) => ({ value, label: t(labelKey) }));
|
|
795
|
+
const custom = (presets && presets.custom) || {};
|
|
796
|
+
for (const name of Object.keys(custom)) {
|
|
797
|
+
if (name === "") continue;
|
|
798
|
+
if (out.some((option) => option.value === name)) continue;
|
|
799
|
+
const entry = custom[name] || {};
|
|
800
|
+
out.push({ value: name, label: presetLabelOf(name, entry) });
|
|
801
|
+
}
|
|
802
|
+
return out;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** 预设的显示名:label 优先,没有就用预设名本身(label 只是显示名,不是键)。 */
|
|
806
|
+
function presetLabelOf(name, preset) {
|
|
807
|
+
const label = preset && typeof preset.label === "string" ? preset.label.trim() : "";
|
|
808
|
+
return label !== "" ? label : String(name);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* select 的 value 必须能在选项里找到:否则浏览器把选中项显示成空/错位,等于把
|
|
813
|
+
* 真实值藏起来。YAML 里本来就没有 provider、或用了一个没写进 serverPresets 的
|
|
814
|
+
* 名字时,补一条只代表「当前值」的占位项(不改变 provider 本身,也不新增端点编辑)。
|
|
815
|
+
*/
|
|
816
|
+
function providerOptionsFor(presets, current) {
|
|
817
|
+
const out = providerOptions(presets);
|
|
818
|
+
const value = current === undefined || current === null ? "" : String(current);
|
|
819
|
+
if (out.some((option) => option.value === value)) return out;
|
|
820
|
+
const placeholder = { value, label: value === "" ? t("provider.noneInYaml") : value + t("provider.undefinedPresetSuffix") };
|
|
821
|
+
return value === "" ? [placeholder].concat(out) : out.concat([placeholder]);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* 选服务商:只把 provider 键写进草稿。端点一个都不展开 —— 账号在 YAML 里只记
|
|
826
|
+
* provider id,host/port/secure 由后端在解析时从预设表展开,前端抄一份只会是
|
|
827
|
+
* 一份会过期的副本(预设改了它不会跟着变,保存时还会把它写回 YAML)。
|
|
828
|
+
* 预设名合不合法由后端裁定:它才是预设表的持有者。
|
|
829
|
+
*
|
|
830
|
+
* 换服务商 = 换一套凭证:旧服务商的密码留着毫无意义(后端也不会拿它去连新主机),
|
|
831
|
+
* 而 oauth2 的服务商本来就不要密码。所以这里顺手清掉 password 和登录态投影,
|
|
832
|
+
* 免得「qq 的授权码」被当成 outlook 的密码交上去。
|
|
833
|
+
*/
|
|
834
|
+
function applyPreset(draft, provider) {
|
|
835
|
+
const next = provider === undefined || provider === null ? "" : String(provider);
|
|
836
|
+
return Object.assign({}, draft, {
|
|
837
|
+
provider: next,
|
|
838
|
+
password: "",
|
|
839
|
+
authKind: undefined,
|
|
840
|
+
oauthState: "none",
|
|
841
|
+
oauthUser: "",
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* 草稿 -> serializeAccounts 的输入。密码三态在这里定型:'' 或 untouched 都不带
|
|
847
|
+
* password 键(继承已存密码),只有用户真的打了字才写进去。端点一概不带:账号只存
|
|
848
|
+
* provider id,后端 resolveAccount() 在连接时从预设表展开(带上也会被后端洗掉)。
|
|
849
|
+
*
|
|
850
|
+
* OAuth2 账号没有密码可带:登录结果(refresh token)由后端 oauthLogin/oauthPoll
|
|
851
|
+
* 自己落盘,前端一个字都不写。authKind/oauthState/oauthUser 是显示用的投影,
|
|
852
|
+
* 不是 YAML 字段,一个都不进这份输入。
|
|
853
|
+
*/
|
|
854
|
+
function draftToInput(draft) {
|
|
855
|
+
const input = {
|
|
856
|
+
name: draft.name,
|
|
857
|
+
originalName: draft.originalName,
|
|
858
|
+
user: draft.user,
|
|
859
|
+
inboxFolder: draft.inboxFolder,
|
|
860
|
+
};
|
|
861
|
+
if (draft.provider) input.provider = draft.provider;
|
|
862
|
+
if (!isOauthDraft(draft) && draft.password) input.password = draft.password;
|
|
863
|
+
// 显示名/登录名是普通三态字段(留空 = 清掉该键),密码与 password 同规:只有真打了字才写。
|
|
864
|
+
if (draft.senderName !== undefined) input.senderName = String(draft.senderName || "").trim();
|
|
865
|
+
if (!isOauthDraft(draft) && draft.authUser !== undefined) input.authUser = String(draft.authUser || "").trim();
|
|
866
|
+
if (!isOauthDraft(draft) && draft.authPassword) input.authPassword = draft.authPassword;
|
|
867
|
+
// Only an OAuth2 card models the application id; for any other account the
|
|
868
|
+
// field stays undefined, which the backend reads as「没说」and leaves the
|
|
869
|
+
// stored key alone. An empty string here is the user clearing it on purpose.
|
|
870
|
+
if (isOauthDraft(draft)) input.clientId = String(draft.clientId || "").trim();
|
|
871
|
+
// '' is a real value here, not an omission: it takes the pin off and returns
|
|
872
|
+
// the account to the derivation. Only a draft that models the selector speaks.
|
|
873
|
+
if (draft.authKindSetting !== undefined) input.authKind = String(draft.authKindSetting || "");
|
|
874
|
+
return input;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* 半填账号:名字、邮箱地址、服务商三者缺一都算。服务商是账号的连接身份 —— 没有它
|
|
879
|
+
* 就没有端点可展开,后端 persist 时会把这个键删掉,卡片上必须先把这件事说出来。
|
|
880
|
+
* OAuth2 账号不要密码:有 (name, provider, user) 就算填完了。
|
|
881
|
+
*/
|
|
882
|
+
function accountIncomplete(draft) {
|
|
883
|
+
return String(draft.name || "").trim() === ""
|
|
884
|
+
|| String(draft.user || "").trim() === ""
|
|
885
|
+
|| String(draft.provider || "").trim() === "";
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** 改过名字后草稿要跟着键走:旧键删掉,整份草稿(含 imap/smtp)搬到新键下。 */
|
|
889
|
+
function renameDrafts(drafts, oldName, nextName) {
|
|
890
|
+
if (nextName !== oldName && Object.prototype.hasOwnProperty.call(drafts, nextName)) {
|
|
891
|
+
throw new Error(t("card.errDuplicateName"));
|
|
892
|
+
}
|
|
893
|
+
const next = {};
|
|
894
|
+
for (const key of Object.keys(drafts)) {
|
|
895
|
+
if (key === oldName) continue;
|
|
896
|
+
next[key] = drafts[key];
|
|
897
|
+
}
|
|
898
|
+
const entry = drafts[oldName];
|
|
899
|
+
if (entry !== undefined) next[nextName] = Object.assign({}, entry, { name: nextName });
|
|
900
|
+
return next;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* 卡片徽标:内置服务商用中文名,自定义预设用它自己的 label(没 label 用预设名),
|
|
905
|
+
* 未知/未指定一律「自定义」。徽标只负责显示,provider 的键始终是预设名。
|
|
906
|
+
* 「自定义」判据用 hasOwnProperty 而不是端点是否到位:刚存下的预设名会先一步进
|
|
907
|
+
* presets.custom(值还是 undefined,端点要等下一次快照),它已经是合法预设了。
|
|
908
|
+
*/
|
|
909
|
+
function accountSummary(draft, presets) {
|
|
910
|
+
const provider = draft.provider || "";
|
|
911
|
+
const entry = PROVIDERS.filter(([value]) => value === provider)[0];
|
|
912
|
+
if (entry) return { label: t(entry[1]), isCustom: false };
|
|
913
|
+
const custom = (presets && presets.custom) || {};
|
|
914
|
+
if (provider !== "" && Object.prototype.hasOwnProperty.call(custom, provider)) {
|
|
915
|
+
return { label: presetLabelOf(provider, custom[provider]), isCustom: true };
|
|
916
|
+
}
|
|
917
|
+
return { label: t("provider.custom"), isCustom: true };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** 预设端点写成 host:port;主机没填就说没填,端口缺省不装成合法值。 */
|
|
921
|
+
function presetEndpointText(endpoint) {
|
|
922
|
+
const host = endpoint && typeof endpoint.host === "string" ? endpoint.host.trim() : "";
|
|
923
|
+
if (host === "") return t("preset.endpointMissing");
|
|
924
|
+
return host + ":" + portLabel(endpoint.port);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* 卡片 meta 行里的「连去哪」:账号只存 provider id,端点是预设展开的,所以这里
|
|
929
|
+
* 一律回头查预设表,不读草稿自己的 imap/smtp(那份副本已经不存在了)。
|
|
930
|
+
* 内置预设显示内置名 + 它的端点;自定义预设显示 label(没 label 用预设名)+ 端点;
|
|
931
|
+
* 名字连预设都没定义时明说「未定义预设」;压根没选服务商就直说。
|
|
932
|
+
*/
|
|
933
|
+
function providerMetaText(draft, presets) {
|
|
934
|
+
const provider = String(draft.provider || "").trim();
|
|
935
|
+
if (provider === "") return t("card.noProvider");
|
|
936
|
+
const entry = PROVIDERS.filter(([value]) => value === provider)[0];
|
|
937
|
+
if (entry) {
|
|
938
|
+
const builtin = (presets && presets.builtin && presets.builtin[provider]) || undefined;
|
|
939
|
+
if (builtin === undefined) return t(entry[1]);
|
|
940
|
+
return t(entry[1]) + t("card.builtinEndpoints", { imap: presetEndpointText(builtin.imap), smtp: presetEndpointText(builtin.smtp) });
|
|
941
|
+
}
|
|
942
|
+
const custom = (presets && presets.custom) || {};
|
|
943
|
+
if (!Object.prototype.hasOwnProperty.call(custom, provider)) return provider + t("provider.undefinedPresetSuffix");
|
|
944
|
+
const preset = custom[provider] || undefined;
|
|
945
|
+
if (preset === undefined) return presetLabelOf(provider, undefined);
|
|
946
|
+
return presetLabelOf(provider, preset) + t("card.customEndpoint", { endpoint: presetEndpointText(preset.imap) });
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// ---------------------------------------------------------------------------
|
|
950
|
+
// 服务器预设 helper。同样是纯函数:预设是「预设名 -> 草稿」的扁平映射,端口在
|
|
951
|
+
// 草稿里保持用户输入的原文(可能半填),序列化时才定型。预设不含凭证。
|
|
952
|
+
// ---------------------------------------------------------------------------
|
|
953
|
+
|
|
954
|
+
/** 空预设草稿:端口默认对齐端点回退值(IMAP 993 / SMTP 465)。 */
|
|
955
|
+
function emptyPresetDraft(name) {
|
|
956
|
+
return {
|
|
957
|
+
name: name === undefined ? "" : name,
|
|
958
|
+
label: "",
|
|
959
|
+
imap: { host: "", port: "993", secure: true },
|
|
960
|
+
smtp: { host: "", port: "465", secure: true },
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* 快照里的一个自定义预设 -> 草稿。缺省端口按端点回退值显示(993/465):
|
|
966
|
+
* 那正是它解析出来的端口,写回去只是把它显式化,语义不变。
|
|
967
|
+
*/
|
|
968
|
+
function draftFromPreset(name, preset) {
|
|
969
|
+
const value = preset || {};
|
|
970
|
+
const imap = value.imap || {};
|
|
971
|
+
const smtp = value.smtp || {};
|
|
972
|
+
return {
|
|
973
|
+
name: String(name),
|
|
974
|
+
label: value.label === undefined || value.label === null ? "" : String(value.label),
|
|
975
|
+
imap: {
|
|
976
|
+
host: imap.host === undefined || imap.host === null ? "" : String(imap.host),
|
|
977
|
+
port: typeof imap.port === "number" ? String(imap.port) : "993",
|
|
978
|
+
secure: imap.secure !== false,
|
|
979
|
+
},
|
|
980
|
+
smtp: {
|
|
981
|
+
host: smtp.host === undefined || smtp.host === null ? "" : String(smtp.host),
|
|
982
|
+
port: typeof smtp.port === "number" ? String(smtp.port) : "465",
|
|
983
|
+
secure: smtp.secure !== false,
|
|
984
|
+
},
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** 快照的 presets.custom -> { 预设名: 草稿 }。 */
|
|
989
|
+
function presetDrafts(custom) {
|
|
990
|
+
const map = {};
|
|
991
|
+
for (const name of Object.keys(custom || {})) map[name] = draftFromPreset(name, custom[name]);
|
|
992
|
+
return map;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/** 端口前端拦:空 = 用默认(序列化不写这个键);否则必须是 1-65535 的整数。 */
|
|
996
|
+
function portTextProblem(value) {
|
|
997
|
+
const text = String(value === undefined || value === null ? "" : value).trim();
|
|
998
|
+
if (text === "") return "";
|
|
999
|
+
if (!/^[0-9]+$/.test(text)) return t("preset.portNumeric");
|
|
1000
|
+
const n = Number(text);
|
|
1001
|
+
if (n < 1 || n > 65535) return t("preset.portRange");
|
|
1002
|
+
return "";
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** 端口原文 -> 写进 YAML 的整数文本("0993" -> "993")。 */
|
|
1006
|
+
function portScalar(value) {
|
|
1007
|
+
return String(Number(String(value).trim()));
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** 卡片 meta 里的端口显示:空或非法一律「—」,不把半填状态装成合法值。 */
|
|
1011
|
+
function portLabel(value) {
|
|
1012
|
+
const text = String(value === undefined || value === null ? "" : value).trim();
|
|
1013
|
+
return text !== "" && portTextProblem(text) === "" ? text : "—";
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* YAML 标量。字段值都在我们控制范围内,只放行「字母开头、纯标识符样」的裸值;
|
|
1018
|
+
* 其余(含 : # { } [ ] , & * ' "、首尾空白、空串、YAML 保留字)一律走
|
|
1019
|
+
* JSON.stringify 的双引号形式 —— JSON 字符串转义是 YAML 双引号风格的子集。
|
|
1020
|
+
*/
|
|
1021
|
+
function yamlScalar(value) {
|
|
1022
|
+
const text = value === undefined || value === null ? "" : String(value);
|
|
1023
|
+
const plain = /^[A-Za-z_][A-Za-z0-9_.@+-]*$/;
|
|
1024
|
+
const reserved = /^(?:y|n|yes|no|true|false|on|off|null|none)$/i;
|
|
1025
|
+
if (text !== "" && plain.test(text) && !reserved.test(text)) return text;
|
|
1026
|
+
return JSON.stringify(text);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* 卡片态 -> serverPresets YAML 文本。空列表 -> ''(解析侧用 .trim() 判断「有没有
|
|
1031
|
+
* 预设」,空映射不能写成 '{}')。返回 { text } 或 { error }:预设名缺失/重名、
|
|
1032
|
+
* host 缺失、端口非法都在这里拦下,报的是第一处问题。
|
|
1033
|
+
*/
|
|
1034
|
+
function serializePresetDrafts(names, drafts) {
|
|
1035
|
+
const lines = [];
|
|
1036
|
+
const seen = {};
|
|
1037
|
+
for (const key of names) {
|
|
1038
|
+
const draft = drafts[key];
|
|
1039
|
+
if (draft === undefined) continue;
|
|
1040
|
+
const name = String(draft.name === undefined || draft.name === null ? "" : draft.name).trim();
|
|
1041
|
+
if (name === "") return { error: t("preset.errNoName") };
|
|
1042
|
+
if (Object.prototype.hasOwnProperty.call(seen, name)) {
|
|
1043
|
+
return { error: t("preset.errDuplicate", { name }) };
|
|
1044
|
+
}
|
|
1045
|
+
seen[name] = true;
|
|
1046
|
+
const label = String(draft.label === undefined || draft.label === null ? "" : draft.label).trim();
|
|
1047
|
+
const imapHost = String(draft.imap.host || "").trim();
|
|
1048
|
+
const smtpHost = String(draft.smtp.host || "").trim();
|
|
1049
|
+
if (imapHost === "") return { error: t("preset.errNoImapHost", { name }) };
|
|
1050
|
+
if (smtpHost === "") return { error: t("preset.errNoSmtpHost", { name }) };
|
|
1051
|
+
const imapProblem = portTextProblem(draft.imap.port);
|
|
1052
|
+
if (imapProblem !== "") return { error: t("preset.errImapPort", { name, problem: imapProblem }) };
|
|
1053
|
+
const smtpProblem = portTextProblem(draft.smtp.port);
|
|
1054
|
+
if (smtpProblem !== "") return { error: t("preset.errSmtpPort", { name, problem: smtpProblem }) };
|
|
1055
|
+
const imapPort = String(draft.imap.port).trim();
|
|
1056
|
+
const smtpPort = String(draft.smtp.port).trim();
|
|
1057
|
+
|
|
1058
|
+
lines.push(yamlScalar(name) + ":");
|
|
1059
|
+
if (label !== "") lines.push(" label: " + yamlScalar(label));
|
|
1060
|
+
lines.push(" imap:");
|
|
1061
|
+
lines.push(" host: " + yamlScalar(imapHost));
|
|
1062
|
+
if (imapPort !== "") lines.push(" port: " + portScalar(imapPort));
|
|
1063
|
+
lines.push(" secure: " + (draft.imap.secure === true ? "true" : "false"));
|
|
1064
|
+
lines.push(" smtp:");
|
|
1065
|
+
lines.push(" host: " + yamlScalar(smtpHost));
|
|
1066
|
+
if (smtpPort !== "") lines.push(" port: " + portScalar(smtpPort));
|
|
1067
|
+
lines.push(" secure: " + (draft.smtp.secure === true ? "true" : "false"));
|
|
1068
|
+
}
|
|
1069
|
+
if (lines.length === 0) return { text: "" };
|
|
1070
|
+
return { text: lines.join("\n") + "\n" };
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* serverPresets 文本里的顶层键名(预设名)。不引 yaml 包:顶层键就是行首标识符,
|
|
1075
|
+
* 够用来在保存落地之前先把新预设名喂给账号卡片的服务商下拉。
|
|
1076
|
+
*/
|
|
1077
|
+
function presetNamesOf(text) {
|
|
1078
|
+
const names = [];
|
|
1079
|
+
const lines = String(text === undefined || text === null ? "" : text).split(/\r?\n/);
|
|
1080
|
+
for (const line of lines) {
|
|
1081
|
+
const match = /^([A-Za-z0-9_-]+):/.exec(line);
|
|
1082
|
+
if (match && names.indexOf(match[1]) < 0) names.push(match[1]);
|
|
1083
|
+
}
|
|
1084
|
+
return names;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* 快照 presets + 父级刚解析出的名字:名字先到、端点随后(保存成功后由快照补齐)。
|
|
1089
|
+
* 只有快照的 custom 里没有的名字才补一个空位 —— 端点永远以快照为准。
|
|
1090
|
+
*/
|
|
1091
|
+
function presetsWithNames(presets, names) {
|
|
1092
|
+
const base = presets || { builtin: {}, custom: {} };
|
|
1093
|
+
if (!names || names.length === 0) return base;
|
|
1094
|
+
const custom = Object.assign({}, base.custom || {});
|
|
1095
|
+
for (const name of names) {
|
|
1096
|
+
if (!Object.prototype.hasOwnProperty.call(custom, name)) custom[name] = undefined;
|
|
1097
|
+
}
|
|
1098
|
+
return Object.assign({}, base, { custom });
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** OAuth2 服务商的中文登录按钮文案;没登记的按 provider id 拼一个,不装成认识的。 */
|
|
1102
|
+
const OAUTH2_SIGN_IN_LABELS = { outlook: "oauth.signIn.outlook" };
|
|
1103
|
+
function oauthSignInLabel(provider) {
|
|
1104
|
+
const key = String(provider === undefined || provider === null ? "" : provider);
|
|
1105
|
+
const known = OAUTH2_SIGN_IN_LABELS[key];
|
|
1106
|
+
return known === undefined ? t("oauth.signIn.generic", { provider: key || "OAuth2" }) : t(known);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* 登录区该显示哪一态。这是一次纯投影,优先级写死在这里:
|
|
1111
|
+
* 进行中的会话 > 失败 > 卡片快照说已登录 > 未登录。
|
|
1112
|
+
* 「pending 优先」是必要的:点完登录之后卡片数据还是旧的(oauthState === 'none'),
|
|
1113
|
+
* 若先看卡片就会把刚弹出来的指引面板又按回去。
|
|
1114
|
+
*/
|
|
1115
|
+
function oauthLoginState(draft, session, view) {
|
|
1116
|
+
if (session && session.state === "pending") return "pending";
|
|
1117
|
+
if (session && session.state === "error") return "error";
|
|
1118
|
+
const loggedIn = view ? view.oauthState === "logged-in" : (draft && draft.oauthState === "logged-in");
|
|
1119
|
+
return loggedIn ? "logged-in" : "none";
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* 卡片登录态的「当前事实」:草稿自己的 provider 和快照里那张卡一致时,服务端的
|
|
1124
|
+
* authKind / oauthState / oauthUser 才作数;不一致(刚在本地换了服务商、或名字刚改
|
|
1125
|
+
* 过)就按本地名单判 —— 服务端那三个字段描述的是**已保存的那个 provider**。
|
|
1126
|
+
*
|
|
1127
|
+
* 为什么不直接用草稿里的副本:卡片草稿只在 listKey 变化时从快照重建,而登录成功
|
|
1128
|
+
* 后的重拉快照账号名没变、listKey 也没变,草稿里那份就停在旧值上。这正是文件里
|
|
1129
|
+
* hasPassword 一律现查 cards 的原因,这里沿用同一条规矩。
|
|
1130
|
+
*/
|
|
1131
|
+
function oauthViewOf(draft, cards) {
|
|
1132
|
+
const live = (cards || []).filter((c) => String(c.name) === String(draft.name))[0] || null;
|
|
1133
|
+
const sameProvider = live !== null
|
|
1134
|
+
&& String(live.provider === undefined || live.provider === null ? "" : live.provider) === String(draft.provider || "");
|
|
1135
|
+
const draftKind = draft.authKind === "oauth2" || draft.authKind === "password" ? draft.authKind : "";
|
|
1136
|
+
const liveKind = live && (live.authKind === "oauth2" || live.authKind === "password") ? live.authKind : "";
|
|
1137
|
+
const authKind = sameProvider && liveKind !== "" ? liveKind : (draftKind !== "" ? draftKind : authKindOf(draft.provider));
|
|
1138
|
+
const draftState = draft.oauthState === "logged-in" || draft.oauthState === "pending" ? draft.oauthState : "none";
|
|
1139
|
+
const liveState = live && (live.oauthState === "logged-in" || live.oauthState === "pending") ? live.oauthState : "";
|
|
1140
|
+
return {
|
|
1141
|
+
authKind,
|
|
1142
|
+
isOauth: authKind === "oauth2",
|
|
1143
|
+
oauthState: sameProvider && liveState !== "" ? liveState : draftState,
|
|
1144
|
+
oauthUser: sameProvider && live && typeof live.oauthUser === "string" ? live.oauthUser : String(draft.oauthUser || ""),
|
|
1145
|
+
hasPassword: live !== null && live.hasPassword === true,
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* 轮询间隔(秒)。后端给的 interval 就是权威值,**0 也是合法值**(表示不用等,
|
|
1151
|
+
* 每 0.5 秒问一次)—— 只有缺失、非数字、或负数才退回兜底 5 秒。
|
|
1152
|
+
*/
|
|
1153
|
+
function oauthIntervalSeconds(session) {
|
|
1154
|
+
const raw = session ? session.interval : undefined;
|
|
1155
|
+
if (typeof raw === "number") return isFinite(raw) && raw >= 0 ? raw : OAUTH_POLL_FALLBACK_MS / 1000;
|
|
1156
|
+
if (typeof raw === "string" && raw.trim() !== "") {
|
|
1157
|
+
const n = Number(raw);
|
|
1158
|
+
if (isFinite(n) && n >= 0) return n;
|
|
1159
|
+
}
|
|
1160
|
+
return OAUTH_POLL_FALLBACK_MS / 1000;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** 授权链接的有效期(秒):后端给了就用它的,没给按 15 分钟说。 */
|
|
1164
|
+
function oauthExpiresSeconds(session) {
|
|
1165
|
+
const raw = session ? session.expiresIn : undefined;
|
|
1166
|
+
const n = typeof raw === "number" ? raw : Number(raw);
|
|
1167
|
+
return isFinite(n) && n > 0 ? n : OAUTH_EXPIRES_FALLBACK_S;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* OAuth2 卡片的登录区。设备码流程没有密码可填:这一块就是凭证本身,所以它
|
|
1172
|
+
* 和数据字段同级,但驱动它的状态(session)由卡片持有 —— 轮询是「这次点击」的
|
|
1173
|
+
* 生命周期,不是配置的一部分,快照回来不该把它打断。
|
|
1174
|
+
*/
|
|
1175
|
+
function OauthLoginPanel(props) {
|
|
1176
|
+
const draft = props.draft || {};
|
|
1177
|
+
const session = props.session || null;
|
|
1178
|
+
const view = props.view || { oauthState: "none", oauthUser: "" };
|
|
1179
|
+
const state = oauthLoginState(draft, session, view);
|
|
1180
|
+
const disabled = props.disabled === true;
|
|
1181
|
+
const label = props.providerLabel || draft.provider || "OAuth2";
|
|
1182
|
+
|
|
1183
|
+
// 插件不内置任何第三方应用注册,所以「用哪个应用登录」是账号自己的配置项,
|
|
1184
|
+
// 面板是用户唯一的常规入口。它不是机密(公共客户端 ID 每次都随请求发出),
|
|
1185
|
+
// 因此可以直接回填、直接编辑。
|
|
1186
|
+
const clientIdField = () => h("div", { className: "dshe-field dshe-oauth-client" }, [
|
|
1187
|
+
h("label", null, t("oauth.clientIdLabel")),
|
|
1188
|
+
h("input", {
|
|
1189
|
+
type: "text",
|
|
1190
|
+
value: String(draft.clientId || ""),
|
|
1191
|
+
disabled: disabled,
|
|
1192
|
+
spellcheck: "false",
|
|
1193
|
+
placeholder: t("oauth.clientIdPlaceholder"),
|
|
1194
|
+
onChange: (e) => { if (props.onClientId) props.onClientId(e.target.value); },
|
|
1195
|
+
}),
|
|
1196
|
+
h("div", { className: "dshe-hint" }, t("oauth.clientIdHint")),
|
|
1197
|
+
]);
|
|
1198
|
+
|
|
1199
|
+
if (state === "pending") {
|
|
1200
|
+
const url = String(session.url || "");
|
|
1201
|
+
const code = String(session.code || "");
|
|
1202
|
+
const seconds = oauthIntervalSeconds(session);
|
|
1203
|
+
return h("div", { className: "dshe-oauth" }, [
|
|
1204
|
+
h("div", { className: "dshe-oauth-head" }, t("oauth.pendingHint")),
|
|
1205
|
+
url === ""
|
|
1206
|
+
? h("div", { className: "dshe-oauth-url" }, t("oauth.noUrl"))
|
|
1207
|
+
: h("div", { className: "dshe-oauth-url" }, h("a", { href: url, target: "_blank", rel: "noreferrer" }, url)),
|
|
1208
|
+
code === "" ? null : h("div", { className: "dshe-oauth-code" }, code),
|
|
1209
|
+
h("div", { className: "dshe-oauth-wait" },
|
|
1210
|
+
t("oauth.polling", {
|
|
1211
|
+
seconds,
|
|
1212
|
+
minutes: Math.max(1, Math.round(oauthExpiresSeconds(session) / 60)),
|
|
1213
|
+
})),
|
|
1214
|
+
h("div", { className: "dshe-oauth-actions" }, [
|
|
1215
|
+
h("button", {
|
|
1216
|
+
type: "button",
|
|
1217
|
+
className: "dshe-btn",
|
|
1218
|
+
disabled: disabled,
|
|
1219
|
+
onClick: () => props.onCancel(),
|
|
1220
|
+
}, t("common.cancel")),
|
|
1221
|
+
]),
|
|
1222
|
+
]);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
if (state === "error") {
|
|
1226
|
+
return h("div", { className: "dshe-oauth" }, [
|
|
1227
|
+
h("div", { className: "dshe-alert error" }, String(session.message || t("oauth.loginFailed"))),
|
|
1228
|
+
clientIdField(),
|
|
1229
|
+
h("div", { className: "dshe-oauth-actions" }, [
|
|
1230
|
+
h("button", {
|
|
1231
|
+
type: "button",
|
|
1232
|
+
className: "dshe-btn primary",
|
|
1233
|
+
disabled: disabled,
|
|
1234
|
+
onClick: () => props.onStart(),
|
|
1235
|
+
}, t("common.retry")),
|
|
1236
|
+
h("button", {
|
|
1237
|
+
type: "button",
|
|
1238
|
+
className: "dshe-btn",
|
|
1239
|
+
disabled: disabled,
|
|
1240
|
+
onClick: () => props.onCancel(),
|
|
1241
|
+
}, t("common.cancel")),
|
|
1242
|
+
]),
|
|
1243
|
+
]);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (state === "logged-in") {
|
|
1247
|
+
return h("div", { className: "dshe-oauth is-logged-in" }, [
|
|
1248
|
+
h("div", { className: "dshe-oauth-head dshe-oauth-ok" },
|
|
1249
|
+
t("oauth.loggedInAs", { user: view.oauthUser || String(draft.user || "") || t("oauth.unknownUser") })),
|
|
1250
|
+
clientIdField(),
|
|
1251
|
+
h("div", { className: "dshe-oauth-actions" }, [
|
|
1252
|
+
h("button", {
|
|
1253
|
+
type: "button",
|
|
1254
|
+
className: "dshe-btn",
|
|
1255
|
+
disabled: disabled,
|
|
1256
|
+
onClick: () => props.onStart(),
|
|
1257
|
+
}, props.restarting === true ? t("oauth.signingIn") : t("oauth.reLogin")),
|
|
1258
|
+
]),
|
|
1259
|
+
]);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
return h("div", { className: "dshe-oauth" }, [
|
|
1263
|
+
h("div", { className: "dshe-oauth-head" },
|
|
1264
|
+
t("oauth.passwordless", { label: label ? t("oauth.passwordlessLabel", { label }) : "" })),
|
|
1265
|
+
clientIdField(),
|
|
1266
|
+
String(draft.clientId || "").trim() === ""
|
|
1267
|
+
? h("div", { className: "dshe-alert warn" }, t("oauth.clientIdMissing"))
|
|
1268
|
+
: null,
|
|
1269
|
+
h("div", { className: "dshe-oauth-actions" }, [
|
|
1270
|
+
h("button", {
|
|
1271
|
+
type: "button",
|
|
1272
|
+
className: "dshe-btn primary",
|
|
1273
|
+
disabled: disabled,
|
|
1274
|
+
onClick: () => props.onStart(),
|
|
1275
|
+
}, props.starting === true ? t("oauth.requestingCode") : oauthSignInLabel(draft.provider)),
|
|
1276
|
+
]),
|
|
1277
|
+
]);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* 账号卡片编辑器。YAML 仍然是唯一的真相源,但改动是「编辑即保存」:每次改动都
|
|
1282
|
+
* 把 {卡片快照, 默认账号} 抛给父级,由父级统一 serializeAccounts + 防抖落盘。
|
|
1283
|
+
* 卡片自己不再写 YAML,也不再区分「已写入 / 未写入」。
|
|
1284
|
+
*/
|
|
1285
|
+
function AccountCardsEditor(props) {
|
|
1286
|
+
const presets = props.presets || { builtin: {}, custom: {} };
|
|
1287
|
+
const detail = props.detail || { raw: {}, list: [] };
|
|
1288
|
+
const cards = detail.list || [];
|
|
1289
|
+
|
|
1290
|
+
const [drafts, setDrafts] = React.useState(() => draftsFromCards(cards));
|
|
1291
|
+
// order 是 [{ id, key }]:id 是 React key(稳定,只随卡片集合来源变化而重建),
|
|
1292
|
+
// key 是草稿映射的键(账号名,也就是 YAML 的键)。展开态/确认态一律按 id 记。
|
|
1293
|
+
const [order, setOrder] = React.useState(() => cardRows(cards.map((c) => String(c.name))));
|
|
1294
|
+
const [open, setOpen] = React.useState(0);
|
|
1295
|
+
const [defaultAccount, setDefaultAccount] = React.useState(detail.defaultAccount || "");
|
|
1296
|
+
const [confirming, setConfirming] = React.useState(0);
|
|
1297
|
+
const [busy, setBusy] = React.useState("");
|
|
1298
|
+
const [rowStatus, setRowStatus] = React.useState({});
|
|
1299
|
+
const [notice, setNotice] = React.useState(null);
|
|
1300
|
+
/**
|
|
1301
|
+
* OAuth2 的登录会话:{ [账号名]: { state:'pending'|'error', url, code, interval,
|
|
1302
|
+
* expiresIn, message, startedAt } }。它刻意和卡片草稿分开 —— 草稿是「要落盘的
|
|
1303
|
+
* 配置」,会话是「这次点击的进度」,一次性、不落盘、刷新即丢(码也就作废了)。
|
|
1304
|
+
* 取消 = 删掉这一项,轮询的 effect 跟着这一项存亡。
|
|
1305
|
+
*/
|
|
1306
|
+
const [oauth, setOauth] = React.useState({});
|
|
1307
|
+
/** 每个账号的会话序号:取消 / 重启 / 换服务商都 +1,晚到的轮询结果据此作废。 */
|
|
1308
|
+
const oauthSeqRef = React.useRef({});
|
|
1309
|
+
|
|
1310
|
+
const listKey = cards.map((c) => String(c.name) + ":" + c.isDefault).join("|") + "|" + (detail.defaultAccount || "");
|
|
1311
|
+
|
|
1312
|
+
React.useEffect(() => {
|
|
1313
|
+
setDrafts(draftsFromCards(cards));
|
|
1314
|
+
setOrder(cardRows(cards.map((c) => String(c.name))));
|
|
1315
|
+
setOpen(0);
|
|
1316
|
+
setConfirming(0);
|
|
1317
|
+
setDefaultAccount(detail.defaultAccount || "");
|
|
1318
|
+
// 详情对象每次快照都是新的:只依赖它的投影,免得无谓重建草稿。
|
|
1319
|
+
// eslint-disable-next-line
|
|
1320
|
+
}, [listKey]);
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* 最新草稿/顺序的镜像。改动处理函数如果直接闭包 state,两次改动落在同一批次里
|
|
1324
|
+
* 时后一次会拿着旧快照算,把前一次改的字段抹掉(快速连填两个输入框就会)。
|
|
1325
|
+
* 一律从镜像读、算完再 setState,改动之间就不会互相丢。
|
|
1326
|
+
*/
|
|
1327
|
+
const draftsRef = React.useRef(drafts);
|
|
1328
|
+
const orderRef = React.useRef(order);
|
|
1329
|
+
draftsRef.current = drafts;
|
|
1330
|
+
orderRef.current = order;
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* 卡片的当前快照 -> 父级。顺序来自 order,内容来自 drafts(键已随改名同步)。
|
|
1334
|
+
* 半填的账号照抛不误 —— 这正是草稿的意义;名字没填、重名、或「多账号却没指定
|
|
1335
|
+
* 默认账号」是「还不能写进 YAML」,只提示、不抛给父级(否则一次半填就会把
|
|
1336
|
+
* YAML 里的账号整片抹掉)。改名/删除这类中间态同理,等名字打完自然就存了。
|
|
1337
|
+
*/
|
|
1338
|
+
const autoSave = (draftsNow, orderNow, defaultNow, immediate) => {
|
|
1339
|
+
if (typeof props.onAutoSave !== "function") return null;
|
|
1340
|
+
const names = orderNow.map((row) => row.key).filter((name) => Object.prototype.hasOwnProperty.call(draftsNow, name));
|
|
1341
|
+
let built;
|
|
1342
|
+
try {
|
|
1343
|
+
built = names.map((name) => draftToInput(draftsNow[name]));
|
|
1344
|
+
} catch (e) {
|
|
1345
|
+
console.error("[dsh-email] 卡片快照组装失败", e, names, draftsNow);
|
|
1346
|
+
return null;
|
|
1347
|
+
}
|
|
1348
|
+
if (built.some((card) => String(card.name || "").trim() === "")) {
|
|
1349
|
+
setNotice({ kind: "error", text: t("card.errNoName") });
|
|
1350
|
+
return null;
|
|
1351
|
+
}
|
|
1352
|
+
if (names.some((name) => !draftsNow[name].provider && !(draftsNow[name].imap?.host && draftsNow[name].smtp?.host))) {
|
|
1353
|
+
// 服务商就是账号的连接身份:没有它,后端写回时会把 provider 键删掉,这个账号
|
|
1354
|
+
// 永远连不上。名字和它都属于「这还是一张半填的卡」,一起拦在这里。
|
|
1355
|
+
setNotice({ kind: "error", text: t("card.errNoProvider") });
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
if (built.some((card) => card.name === "defaultAccount")) {
|
|
1359
|
+
setNotice({ kind: "error", text: t("card.errReservedName") });
|
|
1360
|
+
return null;
|
|
1361
|
+
}
|
|
1362
|
+
if (built.length > 1 && String(defaultNow || "") === "") {
|
|
1363
|
+
setNotice({ kind: "error", text: t("card.errNoDefault") });
|
|
1364
|
+
return null;
|
|
1365
|
+
}
|
|
1366
|
+
setNotice(null);
|
|
1367
|
+
// 返回值只给 OAuth 登录用:它必须等这份配置真的落盘(后端按账号名去读 provider
|
|
1368
|
+
// 和端点)。其余调用方照旧忽略返回值。
|
|
1369
|
+
return props.onAutoSave(built, String(defaultNow || ""), immediate === true);
|
|
1370
|
+
};
|
|
1371
|
+
|
|
1372
|
+
const patchDraft = (name, patch) => {
|
|
1373
|
+
const next = Object.assign({}, draftsRef.current, { [name]: Object.assign({}, draftsRef.current[name], patch) });
|
|
1374
|
+
draftsRef.current = next;
|
|
1375
|
+
setDrafts(next);
|
|
1376
|
+
autoSave(next, orderRef.current, defaultAccount);
|
|
1377
|
+
};
|
|
1378
|
+
|
|
1379
|
+
// -------------------------------------------------------------------------
|
|
1380
|
+
// OAuth2 登录。会话只存在于内存(见 oauth state 的注释),三个动作:
|
|
1381
|
+
// startOauth 取设备码、pollOauth 轮询、cancelOauth 放弃。
|
|
1382
|
+
// -------------------------------------------------------------------------
|
|
1383
|
+
|
|
1384
|
+
/** 会话的序号:取消/重启/换服务商都会 +1,晚到的轮询结果据此作废。 */
|
|
1385
|
+
const bumpOauth = (name) => {
|
|
1386
|
+
oauthSeqRef.current[name] = (oauthSeqRef.current[name] || 0) + 1;
|
|
1387
|
+
return oauthSeqRef.current[name];
|
|
1388
|
+
};
|
|
1389
|
+
const oauthCurrent = (name) => oauthSeqRef.current[name] || 0;
|
|
1390
|
+
|
|
1391
|
+
const setOauthOf = (name, entry) => {
|
|
1392
|
+
setOauth((cur) => {
|
|
1393
|
+
const next = Object.assign({}, cur);
|
|
1394
|
+
if (entry === null) delete next[name];
|
|
1395
|
+
else next[name] = entry;
|
|
1396
|
+
return next;
|
|
1397
|
+
});
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
const cancelOauth = (name) => {
|
|
1401
|
+
bumpOauth(name);
|
|
1402
|
+
setOauthOf(name, null);
|
|
1403
|
+
};
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* 登录前先把这张卡片落盘:oauthLogin 只认账号名,后端要从已保存的配置里读这个
|
|
1407
|
+
* 账号的 provider 与端点。草稿没写进 YAML 就点登录的话,后端根本找不到它。
|
|
1408
|
+
* 等这次落盘真的结束(成功或失败),别让 oauthLogin 和后端的写盘抢。
|
|
1409
|
+
*/
|
|
1410
|
+
const ensureSaved = async () => {
|
|
1411
|
+
const pending = autoSave(draftsRef.current, orderRef.current, defaultAccount, true);
|
|
1412
|
+
if (pending && typeof pending.then === "function") {
|
|
1413
|
+
try { await pending; } catch (e) { /* 保存失败由父级的状态条报,这里不重复说 */ }
|
|
1414
|
+
}
|
|
1415
|
+
};
|
|
1416
|
+
|
|
1417
|
+
const startOauth = async (name) => {
|
|
1418
|
+
const draft = draftsRef.current[name];
|
|
1419
|
+
if (draft === undefined) return;
|
|
1420
|
+
const seq = bumpOauth(name);
|
|
1421
|
+
setRowStatus((cur) => Object.assign({}, cur, { [name]: { kind: "busy", text: t("oauth.requestingCode") } }));
|
|
1422
|
+
setOauthOf(name, { state: "starting" });
|
|
1423
|
+
await ensureSaved();
|
|
1424
|
+
if (seq !== oauthCurrent(name)) return;
|
|
1425
|
+
try {
|
|
1426
|
+
const body = await apiOauth("oauthLogin", { account: name });
|
|
1427
|
+
if (seq !== oauthCurrent(name)) return;
|
|
1428
|
+
if (!body.ok) {
|
|
1429
|
+
setOauthOf(name, { state: "error", message: oauthErrorMessage(body, t("oauth.loginFailed")) });
|
|
1430
|
+
setRowStatus((cur) => { const next = Object.assign({}, cur); delete next[name]; return next; });
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
setRowStatus((cur) => { const next = Object.assign({}, cur); delete next[name]; return next; });
|
|
1434
|
+
if (body.status === "pending") {
|
|
1435
|
+
setOauthOf(name, {
|
|
1436
|
+
state: "pending",
|
|
1437
|
+
url: String(body.url === undefined || body.url === null ? "" : body.url),
|
|
1438
|
+
code: String(body.code === undefined || body.code === null ? "" : body.code),
|
|
1439
|
+
// 后端给的是 interval(秒)与 expiresIn(秒);契约文本里叫 expires_in。
|
|
1440
|
+
interval: body.interval,
|
|
1441
|
+
expiresIn: body.expires_in !== undefined ? body.expires_in : body.expiresIn,
|
|
1442
|
+
});
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
// status === 'already':后端说这个账号已经登录过了,没有码可取。
|
|
1446
|
+
setOauthOf(name, null);
|
|
1447
|
+
await finishOauth(name, "");
|
|
1448
|
+
} catch (e) {
|
|
1449
|
+
if (seq !== oauthCurrent(name)) return;
|
|
1450
|
+
setRowStatus((cur) => { const next = Object.assign({}, cur); delete next[name]; return next; });
|
|
1451
|
+
setOauthOf(name, { state: "error", message: e && e.message ? e.message : String(e) });
|
|
1452
|
+
}
|
|
1453
|
+
};
|
|
1454
|
+
|
|
1455
|
+
/** 轮询一次。pending 就等下一轮(定时器由 effect 按会话自己排),ok 就收尾。 */
|
|
1456
|
+
const pollOauth = async (name) => {
|
|
1457
|
+
const seq = oauthCurrent(name);
|
|
1458
|
+
let body;
|
|
1459
|
+
try {
|
|
1460
|
+
body = await apiOauth("oauthPoll", { account: name });
|
|
1461
|
+
} catch (e) {
|
|
1462
|
+
if (seq !== oauthCurrent(name)) return;
|
|
1463
|
+
setOauthOf(name, { state: "error", message: e && e.message ? e.message : String(e) });
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
if (seq !== oauthCurrent(name)) return;
|
|
1467
|
+
if (body.ok && body.status === "ok") {
|
|
1468
|
+
bumpOauth(name); // 会话结束:晚到的结果不再有意义
|
|
1469
|
+
setOauthOf(name, null);
|
|
1470
|
+
await finishOauth(name, String(body.user === undefined || body.user === null ? "" : body.user));
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
if (!body.ok) {
|
|
1474
|
+
bumpOauth(name);
|
|
1475
|
+
setOauthOf(name, { state: "error", message: oauthErrorMessage(body, t("oauth.loginFailed")) });
|
|
1476
|
+
}
|
|
1477
|
+
};
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* 登录成功:重新拉一次卡片快照(和加载/保存同一条路径),让卡片拿到
|
|
1481
|
+
* oauthState/oauthUser,再报一句成功。
|
|
1482
|
+
*/
|
|
1483
|
+
const finishOauth = async (name, user) => {
|
|
1484
|
+
setNotice({ kind: "success", text: user === "" ? t("oauth.loginOk") : t("oauth.loginOkAs", { user }) });
|
|
1485
|
+
if (typeof props.onReload === "function") await props.onReload();
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
/** 每次渲染都刷新这个 ref:定时器回调永远拿到最新的 pollOauth,不会闭包旧 state。 */
|
|
1489
|
+
const pollOauthRef = React.useRef(pollOauth);
|
|
1490
|
+
pollOauthRef.current = pollOauth;
|
|
1491
|
+
|
|
1492
|
+
/**
|
|
1493
|
+
* 轮询排期。签名里带账号名和它自己的间隔,所以「谁在 pending、间隔是多少」一变
|
|
1494
|
+
* 就重排一次定时器;取消或成功后签名变空,定时器全清。多个账号可以同时 pending,
|
|
1495
|
+
* 一个账号一个定时器。
|
|
1496
|
+
*/
|
|
1497
|
+
const oauthPendingSig = Object.keys(oauth)
|
|
1498
|
+
.filter((name) => oauth[name] && oauth[name].state === "pending")
|
|
1499
|
+
.map((name) => name + "\t" + oauthIntervalSeconds(oauth[name]))
|
|
1500
|
+
.join("|");
|
|
1501
|
+
|
|
1502
|
+
React.useEffect(() => {
|
|
1503
|
+
if (oauthPendingSig === "") return undefined;
|
|
1504
|
+
const timers = oauthPendingSig.split("|").map((entry) => {
|
|
1505
|
+
const cut = entry.lastIndexOf("\t");
|
|
1506
|
+
const name = entry.slice(0, cut);
|
|
1507
|
+
const delay = (Number(entry.slice(cut + 1)) + 0.5) * 1000;
|
|
1508
|
+
return setInterval(() => { pollOauthRef.current(name); }, delay);
|
|
1509
|
+
});
|
|
1510
|
+
return () => { timers.forEach((timer) => clearInterval(timer)); };
|
|
1511
|
+
// eslint-disable-next-line
|
|
1512
|
+
}, [oauthPendingSig]);
|
|
1513
|
+
|
|
1514
|
+
/** 换服务商 = 换一套凭证:进行中的登录会话跟着作废。 */
|
|
1515
|
+
const changeProvider = (name, provider) => {
|
|
1516
|
+
cancelOauth(name);
|
|
1517
|
+
patchDraft(name, applyPreset(draftsRef.current[name], provider));
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
const renameDraft = (oldName, nextName) => {
|
|
1521
|
+
// 映射键必须立刻跟着名字走,否则改名会留下旧键的孤儿。
|
|
1522
|
+
// 行的 id 不动,所以改名不会让输入框重挂载失焦。
|
|
1523
|
+
let nextDrafts;
|
|
1524
|
+
try { nextDrafts = renameDrafts(draftsRef.current, oldName, nextName); }
|
|
1525
|
+
catch (error) { setNotice({ kind: "error", text: error.message }); return; }
|
|
1526
|
+
const nextOrder = rekeyRows(orderRef.current, oldName, nextName);
|
|
1527
|
+
const nextDefault = defaultAccount === oldName ? nextName : defaultAccount;
|
|
1528
|
+
draftsRef.current = nextDrafts;
|
|
1529
|
+
orderRef.current = nextOrder;
|
|
1530
|
+
setDrafts(nextDrafts);
|
|
1531
|
+
setOrder(nextOrder);
|
|
1532
|
+
setDefaultAccount(nextDefault);
|
|
1533
|
+
autoSave(nextDrafts, nextOrder, nextDefault);
|
|
1534
|
+
};
|
|
1535
|
+
|
|
1536
|
+
const addAccount = () => {
|
|
1537
|
+
let n = Object.keys(draftsRef.current).length + 1;
|
|
1538
|
+
let name = "account" + n;
|
|
1539
|
+
while (Object.prototype.hasOwnProperty.call(draftsRef.current, name)) {
|
|
1540
|
+
n += 1;
|
|
1541
|
+
name = "account" + n;
|
|
1542
|
+
}
|
|
1543
|
+
const nextDrafts = Object.assign({}, draftsRef.current, { [name]: emptyAccountDraft(name) });
|
|
1544
|
+
const row = cardRows([name])[0];
|
|
1545
|
+
const nextOrder = orderRef.current.concat([row]);
|
|
1546
|
+
setDrafts(nextDrafts);
|
|
1547
|
+
setOrder(nextOrder);
|
|
1548
|
+
setOpen(row.id);
|
|
1549
|
+
autoSave(nextDrafts, nextOrder, defaultAccount);
|
|
1550
|
+
};
|
|
1551
|
+
|
|
1552
|
+
const removeAccount = (id) => {
|
|
1553
|
+
const row = orderRef.current.filter((item) => item.id === id)[0];
|
|
1554
|
+
if (row === undefined) return;
|
|
1555
|
+
const name = row.key;
|
|
1556
|
+
const nextDrafts = Object.assign({}, draftsRef.current);
|
|
1557
|
+
delete nextDrafts[name];
|
|
1558
|
+
const nextOrder = orderRef.current.filter((item) => item.id !== id);
|
|
1559
|
+
const nextDefault = defaultAccount === name ? "" : defaultAccount;
|
|
1560
|
+
setDrafts(nextDrafts);
|
|
1561
|
+
setOrder(nextOrder);
|
|
1562
|
+
setDefaultAccount(nextDefault);
|
|
1563
|
+
setConfirming(0);
|
|
1564
|
+
setOpen((cur) => (cur === id ? 0 : cur));
|
|
1565
|
+
// 删除是明确动作:确认之后立刻落盘,不等防抖(用户可能马上就走)。
|
|
1566
|
+
autoSave(nextDrafts, nextOrder, nextDefault, true);
|
|
1567
|
+
};
|
|
1568
|
+
|
|
1569
|
+
/** 默认账号也是配置的一部分:换一个就等于一次改动,同样立刻抛给父级保存。 */
|
|
1570
|
+
const setDefaultAccountAndSave = (name) => {
|
|
1571
|
+
setDefaultAccount(name);
|
|
1572
|
+
autoSave(draftsRef.current, orderRef.current, name);
|
|
1573
|
+
};
|
|
1574
|
+
|
|
1575
|
+
/** 传的是卡片上的「当前账号名」(可能刚改过名),行内提示按它归类。 */
|
|
1576
|
+
const testCard = async (draftName) => {
|
|
1577
|
+
setBusy(draftName);
|
|
1578
|
+
setRowStatus((cur) => Object.assign({}, cur, { [draftName]: { kind: "busy", text: t("card.testing") } }));
|
|
1579
|
+
try {
|
|
1580
|
+
// 测试也要看到「你正在编辑的这份表单」,所以抛给父级组装完整 value。
|
|
1581
|
+
const cardsNow = orderRef.current.map((row) => row.key)
|
|
1582
|
+
.filter((name) => Object.prototype.hasOwnProperty.call(draftsRef.current, name))
|
|
1583
|
+
.map((name) => draftToInput(draftsRef.current[name]));
|
|
1584
|
+
const result = await props.onTest(draftName, cardsNow, defaultAccount);
|
|
1585
|
+
setRowStatus((cur) => Object.assign({}, cur, {
|
|
1586
|
+
[draftName]: {
|
|
1587
|
+
kind: "success",
|
|
1588
|
+
text: t("card.testOk", { ms: result.ms, host: result.imapHost, port: result.imapPort }),
|
|
1589
|
+
},
|
|
1590
|
+
}));
|
|
1591
|
+
} catch (e) {
|
|
1592
|
+
setRowStatus((cur) => Object.assign({}, cur, {
|
|
1593
|
+
[draftName]: { kind: "error", text: t("card.testFailed", { message: e && e.message ? e.message : String(e) }) },
|
|
1594
|
+
}));
|
|
1595
|
+
} finally {
|
|
1596
|
+
setBusy("");
|
|
1597
|
+
}
|
|
1598
|
+
};
|
|
1599
|
+
|
|
1600
|
+
const defaultMissing = order.length > 1 && !defaultAccount;
|
|
1601
|
+
|
|
1602
|
+
return h("div", { className: "dshe-acc-list" }, [
|
|
1603
|
+
detail.error
|
|
1604
|
+
? h("div", { className: "dshe-alert error" }, t("card.yamlParseFailed", { error: detail.error }))
|
|
1605
|
+
: null,
|
|
1606
|
+
props.extraNotice
|
|
1607
|
+
? h("div", { className: "dshe-alert " + props.extraNotice.kind }, props.extraNotice.text)
|
|
1608
|
+
: null,
|
|
1609
|
+
defaultMissing
|
|
1610
|
+
? h("div", { className: "dshe-alert error" }, [
|
|
1611
|
+
t("card.pickDefault"),
|
|
1612
|
+
h("span", { className: "dshe-actions", style: { marginTop: 6 } },
|
|
1613
|
+
order.map((row) => h("button", {
|
|
1614
|
+
key: row.id,
|
|
1615
|
+
type: "button",
|
|
1616
|
+
className: "dshe-btn",
|
|
1617
|
+
onClick: () => setDefaultAccountAndSave(row.key),
|
|
1618
|
+
}, t("card.setDefaultAs", { name: drafts[row.key] ? drafts[row.key].name || row.key : row.key })))),
|
|
1619
|
+
])
|
|
1620
|
+
: null,
|
|
1621
|
+
|
|
1622
|
+
order.length === 0
|
|
1623
|
+
? h("div", { className: "dshe-acc-empty" }, t("card.empty"))
|
|
1624
|
+
: null,
|
|
1625
|
+
|
|
1626
|
+
order.map((row) => {
|
|
1627
|
+
const id = row.id;
|
|
1628
|
+
const name = row.key;
|
|
1629
|
+
const draft = drafts[name];
|
|
1630
|
+
if (draft === undefined) return null;
|
|
1631
|
+
const summary = accountSummary(draft, presets);
|
|
1632
|
+
const isDefault = defaultAccount !== "" && defaultAccount === draft.name;
|
|
1633
|
+
const expanded = open === id;
|
|
1634
|
+
const status = rowStatus[name];
|
|
1635
|
+
const renamed = draft.name !== name;
|
|
1636
|
+
const busyHere = busy === name;
|
|
1637
|
+
const view = oauthViewOf(draft, cards);
|
|
1638
|
+
const oauthCard = view.isOauth;
|
|
1639
|
+
const oauthSession = oauthCard ? (oauth[name] || null) : null;
|
|
1640
|
+
const oauthLive = oauthLoginState(draft, oauthSession, view);
|
|
1641
|
+
/** 卡片的登录态显示:服务端字段说了算,进行中的会话盖过它。 */
|
|
1642
|
+
const oauthLabel = oauthLive === "pending"
|
|
1643
|
+
? t("oauth.signingIn")
|
|
1644
|
+
: (view.oauthState === "logged-in"
|
|
1645
|
+
? t("card.loggedInAs", { user: view.oauthUser || draft.user || t("oauth.unknownUser") })
|
|
1646
|
+
: t("card.notLoggedIn"));
|
|
1647
|
+
const oauthBusy = oauthSession !== null && oauthSession.state === "starting";
|
|
1648
|
+
return h("div", { key: id, className: "dshe-acc-card" }, [
|
|
1649
|
+
h("div", { className: "dshe-acc-head" }, [
|
|
1650
|
+
h("span", { className: "dshe-acc-name" }, draft.name || t("card.unnamed")),
|
|
1651
|
+
h("span", { className: "dshe-acc-badge" + (summary.isCustom ? " todo" : "") }, summary.label),
|
|
1652
|
+
oauthCard
|
|
1653
|
+
? h("span", { className: "dshe-acc-badge" + (view.oauthState === "logged-in" ? " is-oauth" : " todo") }, oauthLabel)
|
|
1654
|
+
: null,
|
|
1655
|
+
isDefault ? h("span", { className: "dshe-acc-badge is-default" }, t("card.defaultBadge")) : null,
|
|
1656
|
+
accountIncomplete(draft) ? h("span", { className: "dshe-acc-badge todo" }, t("card.incomplete")) : null,
|
|
1657
|
+
renamed ? h("span", { className: "dshe-acc-badge todo dshe-acc-warn" }, t("card.renamedBadge")) : null,
|
|
1658
|
+
h("span", { className: "dshe-acc-actions" }, [
|
|
1659
|
+
h("button", {
|
|
1660
|
+
type: "button",
|
|
1661
|
+
className: "dshe-btn",
|
|
1662
|
+
onClick: () => setOpen(expanded ? 0 : id),
|
|
1663
|
+
}, expanded ? t("common.collapse") : t("common.edit")),
|
|
1664
|
+
h("button", {
|
|
1665
|
+
type: "button",
|
|
1666
|
+
className: "dshe-btn",
|
|
1667
|
+
disabled: busy !== "" || draft.name === "",
|
|
1668
|
+
onClick: () => testCard(draft.name),
|
|
1669
|
+
}, busyHere ? t("card.testing") : t("card.testConnect")),
|
|
1670
|
+
h("button", {
|
|
1671
|
+
type: "button",
|
|
1672
|
+
className: "dshe-btn",
|
|
1673
|
+
disabled: isDefault || draft.name === "",
|
|
1674
|
+
onClick: () => setDefaultAccountAndSave(draft.name),
|
|
1675
|
+
}, isDefault ? t("card.defaultAccount") : t("card.setDefault")),
|
|
1676
|
+
h("button", {
|
|
1677
|
+
type: "button",
|
|
1678
|
+
className: "dshe-btn dshe-acc-danger",
|
|
1679
|
+
onClick: () => setConfirming(confirming === id ? 0 : id),
|
|
1680
|
+
}, t("common.delete")),
|
|
1681
|
+
]),
|
|
1682
|
+
]),
|
|
1683
|
+
h("div", { className: "dshe-acc-meta" },
|
|
1684
|
+
t("card.meta", {
|
|
1685
|
+
address: draft.user || t("card.noAddress"),
|
|
1686
|
+
folder: draft.inboxFolder || "INBOX",
|
|
1687
|
+
provider: providerMetaText(draft, presets),
|
|
1688
|
+
})),
|
|
1689
|
+
status
|
|
1690
|
+
? h("div", { className: "dshe-hint" + (status.kind === "error" ? " dshe-acc-warn" : "") }, status.text)
|
|
1691
|
+
: null,
|
|
1692
|
+
confirming === id
|
|
1693
|
+
? h("div", { className: "dshe-acc-confirm" }, [
|
|
1694
|
+
t("card.confirmDelete", { name: draft.name || t("card.unnamed") }),
|
|
1695
|
+
h("span", { className: "dshe-actions" }, [
|
|
1696
|
+
h("button", {
|
|
1697
|
+
type: "button",
|
|
1698
|
+
className: "dshe-btn dshe-acc-danger",
|
|
1699
|
+
onClick: () => removeAccount(id),
|
|
1700
|
+
}, t("common.confirmDelete")),
|
|
1701
|
+
h("button", { type: "button", className: "dshe-btn", onClick: () => setConfirming(0) }, t("common.cancel")),
|
|
1702
|
+
]),
|
|
1703
|
+
])
|
|
1704
|
+
: null,
|
|
1705
|
+
expanded
|
|
1706
|
+
? h("div", { className: "dshe-acc-body" }, [
|
|
1707
|
+
h("div", { className: "dshe-grid" }, [
|
|
1708
|
+
h("div", { className: "dshe-field" }, [
|
|
1709
|
+
h("label", null, t("card.nameLabel")),
|
|
1710
|
+
h("input", {
|
|
1711
|
+
type: "text",
|
|
1712
|
+
value: draft.name,
|
|
1713
|
+
placeholder: "work",
|
|
1714
|
+
onChange: (e) => renameDraft(name, e.target.value),
|
|
1715
|
+
}),
|
|
1716
|
+
h("div", { className: "dshe-hint" },
|
|
1717
|
+
draft.name === "defaultAccount"
|
|
1718
|
+
? h("span", { className: "dshe-acc-warn" }, t("card.reservedNameWarn"))
|
|
1719
|
+
: t("card.nameHint")),
|
|
1720
|
+
]),
|
|
1721
|
+
h("div", { className: "dshe-field" }, [
|
|
1722
|
+
h("label", null, t("card.providerLabel")),
|
|
1723
|
+
h("select", {
|
|
1724
|
+
value: draft.provider || "",
|
|
1725
|
+
onChange: (e) => changeProvider(name, e.target.value),
|
|
1726
|
+
}, providerOptionsFor(presets, draft.provider).map((option) => h("option", { key: option.value, value: option.value }, option.label))),
|
|
1727
|
+
]),
|
|
1728
|
+
h("div", { className: "dshe-field" }, [
|
|
1729
|
+
h("label", null, t("card.addressLabel")),
|
|
1730
|
+
fieldInput("text", draft.user, (v) => patchDraft(name, { user: v }), "you@example.com"),
|
|
1731
|
+
]),
|
|
1732
|
+
h("div", { className: "dshe-field" }, [
|
|
1733
|
+
h("label", null, t("card.senderNameLabel")),
|
|
1734
|
+
fieldInput("text", draft.senderName, (v) => patchDraft(name, { senderName: v }), t("card.senderNamePlaceholder")),
|
|
1735
|
+
h("div", { className: "dshe-hint" }, t("card.senderNameHint")),
|
|
1736
|
+
]),
|
|
1737
|
+
// 认证方式只在「可能是 OAuth2」的账号上出现:给 QQ/163 用户多一个
|
|
1738
|
+
// 下拉框只是噪音。它是那条逃生舱的唯一面板入口——仍能用应用密码连
|
|
1739
|
+
// Exchange Online 的租户(混合/本地部署、SMTP AUTH 未关)靠它才不用
|
|
1740
|
+
// 去手改 YAML。
|
|
1741
|
+
(oauthCard || authKindOf(draft.provider) === "oauth2")
|
|
1742
|
+
? h("div", { className: "dshe-field" }, [
|
|
1743
|
+
h("label", null, t("card.authKindLabel")),
|
|
1744
|
+
h("select", {
|
|
1745
|
+
value: draft.authKindSetting || "",
|
|
1746
|
+
disabled: busy !== "",
|
|
1747
|
+
onChange: (e) => patchDraft(name, {
|
|
1748
|
+
authKindSetting: e.target.value,
|
|
1749
|
+
// 「自动」= 不钉住:把裁决交回派生,isOauthDraft 会按
|
|
1750
|
+
// provider 立刻切回对应的凭证区。
|
|
1751
|
+
authKind: e.target.value === "" ? undefined : e.target.value,
|
|
1752
|
+
}),
|
|
1753
|
+
}, [
|
|
1754
|
+
h("option", { key: "auto", value: "" }, t("card.authKindAuto")),
|
|
1755
|
+
h("option", { key: "oauth2", value: "oauth2" }, t("card.authKindOAuth2")),
|
|
1756
|
+
h("option", { key: "password", value: "password" }, t("card.authKindPassword")),
|
|
1757
|
+
]),
|
|
1758
|
+
h("div", { className: "dshe-hint" }, t("card.authKindHint")),
|
|
1759
|
+
])
|
|
1760
|
+
: null,
|
|
1761
|
+
// OAuth2 的服务商没有密码可填:这一格的凭证就是下面那块登录区。
|
|
1762
|
+
// 留着密码框只会让人以为「填了密码就能用」,而后端根本不会读它。
|
|
1763
|
+
oauthCard
|
|
1764
|
+
? h(OauthLoginPanel, {
|
|
1765
|
+
draft: draft,
|
|
1766
|
+
view: view,
|
|
1767
|
+
session: oauthSession,
|
|
1768
|
+
providerLabel: summary.label,
|
|
1769
|
+
disabled: busy !== "" || oauthBusy,
|
|
1770
|
+
starting: oauthBusy,
|
|
1771
|
+
restarting: oauthBusy,
|
|
1772
|
+
onStart: () => startOauth(name),
|
|
1773
|
+
onCancel: () => cancelOauth(name),
|
|
1774
|
+
onClientId: (v) => patchDraft(name, { clientId: v }),
|
|
1775
|
+
})
|
|
1776
|
+
: h(React.Fragment, null, [
|
|
1777
|
+
h("div", { className: "dshe-field" }, [
|
|
1778
|
+
h("label", null, t("card.passwordLabel")),
|
|
1779
|
+
h("input", {
|
|
1780
|
+
type: "password",
|
|
1781
|
+
value: draft.password,
|
|
1782
|
+
disabled: busy !== "",
|
|
1783
|
+
placeholder: t("card.passwordPlaceholder"),
|
|
1784
|
+
onChange: (e) => patchDraft(name, { password: e.target.value }),
|
|
1785
|
+
}),
|
|
1786
|
+
h("div", { className: "dshe-hint" },
|
|
1787
|
+
view.hasPassword
|
|
1788
|
+
? t("card.passwordSaved")
|
|
1789
|
+
: t("card.passwordHint")),
|
|
1790
|
+
]),
|
|
1791
|
+
// 别名/中继:登录名与登录密码都只在密码类账号上有意义(OAuth2 用 token 登录)。
|
|
1792
|
+
h("div", { className: "dshe-field" }, [
|
|
1793
|
+
h("label", null, t("card.authUserLabel")),
|
|
1794
|
+
fieldInput("text", draft.authUser, (v) => patchDraft(name, { authUser: v }), t("card.authUserPlaceholder")),
|
|
1795
|
+
h("div", { className: "dshe-hint" }, t("card.authUserHint")),
|
|
1796
|
+
]),
|
|
1797
|
+
h("div", { className: "dshe-field" }, [
|
|
1798
|
+
h("label", null, t("card.authPasswordLabel")),
|
|
1799
|
+
h("input", {
|
|
1800
|
+
type: "password",
|
|
1801
|
+
value: draft.authPassword,
|
|
1802
|
+
disabled: busy !== "",
|
|
1803
|
+
placeholder: t("card.passwordPlaceholder"),
|
|
1804
|
+
onChange: (e) => patchDraft(name, { authPassword: e.target.value }),
|
|
1805
|
+
}),
|
|
1806
|
+
h("div", { className: "dshe-hint" },
|
|
1807
|
+
view.hasAuthPassword
|
|
1808
|
+
? t("card.authPasswordSaved")
|
|
1809
|
+
: t("card.authPasswordHint")),
|
|
1810
|
+
]),
|
|
1811
|
+
]),
|
|
1812
|
+
h("div", { className: "dshe-field" }, [
|
|
1813
|
+
h("label", null, t("card.inboxLabel")),
|
|
1814
|
+
fieldInput("text", draft.inboxFolder, (v) => patchDraft(name, { inboxFolder: v }), "INBOX"),
|
|
1815
|
+
]),
|
|
1816
|
+
]),
|
|
1817
|
+
])
|
|
1818
|
+
: null,
|
|
1819
|
+
]);
|
|
1820
|
+
}),
|
|
1821
|
+
|
|
1822
|
+
h("div", { className: "dshe-actions" }, [
|
|
1823
|
+
h("button", { type: "button", className: "dshe-btn", onClick: addAccount, disabled: busy !== "" }, t("card.add")),
|
|
1824
|
+
]),
|
|
1825
|
+
h("div", { className: "dshe-hint" },
|
|
1826
|
+
t("card.autoSaveHint") + (defaultMissing ? t("card.autoSaveHintNoDefault") : "")),
|
|
1827
|
+
notice
|
|
1828
|
+
? h("div", { className: "dshe-alert " + notice.kind }, notice.text)
|
|
1829
|
+
: null,
|
|
1830
|
+
]);
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
/**
|
|
1834
|
+
* 服务器预设编辑器。快照的 presets.custom 是卡片的数据源,YAML 是真相源;改动
|
|
1835
|
+
* 「编辑即保存」:每次改动序列化后抛给父级,由父级防抖落盘。
|
|
1836
|
+
* 预设不含凭证;内置的 8 个服务商不在这里编辑(它们由 PROVIDERS 定义)。
|
|
1837
|
+
*/
|
|
1838
|
+
function ServerPresetsEditor(props) {
|
|
1839
|
+
const presets = props.presets || { builtin: {}, custom: {} };
|
|
1840
|
+
const custom = presets.custom || {};
|
|
1841
|
+
|
|
1842
|
+
const [drafts, setDrafts] = React.useState(() => presetDrafts(custom));
|
|
1843
|
+
// 同账号卡片:行 = { id, key },id 是 React key,key 是预设名(YAML 的键)。
|
|
1844
|
+
const [order, setOrder] = React.useState(() => presetRows(Object.keys(custom)));
|
|
1845
|
+
/** 同账号卡片:最新草稿/顺序的镜像,改动处理函数一律从它读(见那边的说明)。 */
|
|
1846
|
+
const draftsRef = React.useRef(drafts);
|
|
1847
|
+
const orderRef = React.useRef(order);
|
|
1848
|
+
draftsRef.current = drafts;
|
|
1849
|
+
orderRef.current = order;
|
|
1850
|
+
const [open, setOpen] = React.useState(0);
|
|
1851
|
+
const [confirming, setConfirming] = React.useState(0);
|
|
1852
|
+
const [notice, setNotice] = React.useState(null);
|
|
1853
|
+
|
|
1854
|
+
// 预设列表每次快照都是一个新对象:只依赖它的投影重建卡片。
|
|
1855
|
+
const listKey = Object.keys(custom).map((name) => {
|
|
1856
|
+
const entry = custom[name] || {};
|
|
1857
|
+
const imap = entry.imap || {};
|
|
1858
|
+
const smtp = entry.smtp || {};
|
|
1859
|
+
return [name, entry.label || "", imap.host || "", imap.port, imap.secure, smtp.host || "", smtp.port, smtp.secure].join(":");
|
|
1860
|
+
}).join("|");
|
|
1861
|
+
|
|
1862
|
+
React.useEffect(() => {
|
|
1863
|
+
setDrafts(presetDrafts(custom));
|
|
1864
|
+
setOrder(presetRows(Object.keys(custom)));
|
|
1865
|
+
setOpen(0);
|
|
1866
|
+
setConfirming(0);
|
|
1867
|
+
// eslint-disable-next-line
|
|
1868
|
+
}, [listKey]);
|
|
1869
|
+
|
|
1870
|
+
/**
|
|
1871
|
+
* 序列化 + 抛给父级。预设名缺失/重名、主机缺失、端口非法都在这里被拦下:只提示,
|
|
1872
|
+
* 不动父级的文本(半填状态不该把已保存的预设整片清掉)。
|
|
1873
|
+
*/
|
|
1874
|
+
const autoSave = (nextDrafts, nextOrder) => {
|
|
1875
|
+
if (typeof props.onAutoSave !== "function") return;
|
|
1876
|
+
const result = serializePresetDrafts(nextOrder.map((row) => row.key), nextDrafts);
|
|
1877
|
+
if (result.error !== undefined) {
|
|
1878
|
+
setNotice({ kind: "error", text: result.error });
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
setNotice(null);
|
|
1882
|
+
props.onAutoSave(result.text);
|
|
1883
|
+
};
|
|
1884
|
+
|
|
1885
|
+
const patchDraft = (key, patch) => {
|
|
1886
|
+
const next = Object.assign({}, draftsRef.current, { [key]: Object.assign({}, draftsRef.current[key], patch) });
|
|
1887
|
+
setDrafts(next);
|
|
1888
|
+
autoSave(next, orderRef.current);
|
|
1889
|
+
};
|
|
1890
|
+
|
|
1891
|
+
const renameDraft = (oldKey, nextName) => {
|
|
1892
|
+
// 预设名就是 YAML 的键:映射键必须立刻跟着走,否则改名会留下旧键的孤儿。
|
|
1893
|
+
const next = {};
|
|
1894
|
+
for (const key of Object.keys(draftsRef.current)) {
|
|
1895
|
+
if (key === oldKey) continue;
|
|
1896
|
+
next[key] = draftsRef.current[key];
|
|
1897
|
+
}
|
|
1898
|
+
const entry = draftsRef.current[oldKey];
|
|
1899
|
+
if (entry !== undefined) next[nextName] = Object.assign({}, entry, { name: nextName });
|
|
1900
|
+
const nextOrder = rekeyRows(orderRef.current, oldKey, nextName);
|
|
1901
|
+
setDrafts(next);
|
|
1902
|
+
setOrder(nextOrder);
|
|
1903
|
+
autoSave(next, nextOrder);
|
|
1904
|
+
};
|
|
1905
|
+
|
|
1906
|
+
const addPreset = () => {
|
|
1907
|
+
let n = orderRef.current.length + 1;
|
|
1908
|
+
let name = "preset" + n;
|
|
1909
|
+
while (Object.prototype.hasOwnProperty.call(draftsRef.current, name)) {
|
|
1910
|
+
n += 1;
|
|
1911
|
+
name = "preset" + n;
|
|
1912
|
+
}
|
|
1913
|
+
const next = Object.assign({}, draftsRef.current, { [name]: emptyPresetDraft(name) });
|
|
1914
|
+
const row = presetRows([name])[0];
|
|
1915
|
+
const nextOrder = orderRef.current.concat([row]);
|
|
1916
|
+
setDrafts(next);
|
|
1917
|
+
setOrder(nextOrder);
|
|
1918
|
+
setOpen(row.id);
|
|
1919
|
+
setConfirming(0);
|
|
1920
|
+
// 新预设还没有主机,autoSave 只会提示「还没填 IMAP 主机」,不会动文本。
|
|
1921
|
+
autoSave(next, nextOrder);
|
|
1922
|
+
};
|
|
1923
|
+
|
|
1924
|
+
const removePreset = (id) => {
|
|
1925
|
+
const row = orderRef.current.filter((item) => item.id === id)[0];
|
|
1926
|
+
if (row === undefined) return;
|
|
1927
|
+
const key = row.key;
|
|
1928
|
+
const next = Object.assign({}, draftsRef.current);
|
|
1929
|
+
delete next[key];
|
|
1930
|
+
const nextOrder = orderRef.current.filter((item) => item.id !== id);
|
|
1931
|
+
setDrafts(next);
|
|
1932
|
+
setOrder(nextOrder);
|
|
1933
|
+
setConfirming(0);
|
|
1934
|
+
setOpen((cur) => (cur === id ? 0 : cur));
|
|
1935
|
+
autoSave(next, nextOrder);
|
|
1936
|
+
};
|
|
1937
|
+
|
|
1938
|
+
const rebuiltCount = Object.keys(custom).length;
|
|
1939
|
+
|
|
1940
|
+
return h("div", { className: "dshe-prst-list" }, [
|
|
1941
|
+
presets.error
|
|
1942
|
+
? h("div", { className: "dshe-alert error" }, [
|
|
1943
|
+
t("preset.yamlParseFailed", { error: presets.error }),
|
|
1944
|
+
h("div", { className: "dshe-hint", style: { marginTop: 4 } },
|
|
1945
|
+
t("preset.yamlParseFailedHint")),
|
|
1946
|
+
])
|
|
1947
|
+
: null,
|
|
1948
|
+
|
|
1949
|
+
order.length === 0
|
|
1950
|
+
? h("div", { className: "dshe-prst-empty" },
|
|
1951
|
+
rebuiltCount === 0
|
|
1952
|
+
? t("preset.empty")
|
|
1953
|
+
: t("preset.cleared", { count: rebuiltCount }))
|
|
1954
|
+
: null,
|
|
1955
|
+
|
|
1956
|
+
order.map((row) => {
|
|
1957
|
+
const id = row.id;
|
|
1958
|
+
const key = row.key;
|
|
1959
|
+
const draft = drafts[key];
|
|
1960
|
+
if (draft === undefined) return null;
|
|
1961
|
+
const name = String(draft.name === undefined || draft.name === null ? "" : draft.name);
|
|
1962
|
+
const trimmed = name.trim();
|
|
1963
|
+
const expanded = open === id;
|
|
1964
|
+
const renamed = trimmed !== key;
|
|
1965
|
+
const duplicate = trimmed !== "" && order.some((other) => other.key !== key && String((drafts[other.key] || {}).name || "").trim() === trimmed);
|
|
1966
|
+
const empty = trimmed === "";
|
|
1967
|
+
const label = String(draft.label || "").trim();
|
|
1968
|
+
return h("div", { key: id, className: "dshe-prst-card" }, [
|
|
1969
|
+
h("div", { className: "dshe-prst-head" }, [
|
|
1970
|
+
h("span", { className: "dshe-prst-name" }, trimmed || t("card.unnamed")),
|
|
1971
|
+
label === "" ? null : h("span", { className: "dshe-prst-badge" }, label),
|
|
1972
|
+
empty ? h("span", { className: "dshe-prst-badge todo" }, t("preset.badgeUnnamed")) : null,
|
|
1973
|
+
renamed ? h("span", { className: "dshe-prst-badge todo dshe-prst-warn" }, t("preset.badgeRenamed")) : null,
|
|
1974
|
+
duplicate ? h("span", { className: "dshe-prst-badge todo dshe-prst-warn" }, t("preset.badgeDuplicate")) : null,
|
|
1975
|
+
h("span", { className: "dshe-prst-actions" }, [
|
|
1976
|
+
h("button", {
|
|
1977
|
+
type: "button",
|
|
1978
|
+
className: "dshe-btn",
|
|
1979
|
+
onClick: () => setOpen(expanded ? 0 : id),
|
|
1980
|
+
}, expanded ? t("common.collapse") : t("common.edit")),
|
|
1981
|
+
h("button", {
|
|
1982
|
+
type: "button",
|
|
1983
|
+
className: "dshe-btn dshe-prst-danger",
|
|
1984
|
+
onClick: () => setConfirming(confirming === id ? 0 : id),
|
|
1985
|
+
}, t("common.delete")),
|
|
1986
|
+
]),
|
|
1987
|
+
]),
|
|
1988
|
+
h("div", { className: "dshe-prst-meta" },
|
|
1989
|
+
t("preset.metaImap", { host: draft.imap.host || t("preset.noHost"), port: portLabel(draft.imap.port) })
|
|
1990
|
+
+ (draft.imap.secure === true ? t("preset.ssl") : t("preset.plain"))
|
|
1991
|
+
+ t("preset.metaSmtp", { host: draft.smtp.host || t("preset.noHost"), port: portLabel(draft.smtp.port) })
|
|
1992
|
+
+ (draft.smtp.secure === true ? t("preset.ssl") : t("preset.plain"))),
|
|
1993
|
+
confirming === id
|
|
1994
|
+
? h("div", { className: "dshe-prst-confirm" }, [
|
|
1995
|
+
t("preset.confirmDelete", { name: trimmed || t("card.unnamed") }),
|
|
1996
|
+
h("span", { className: "dshe-actions" }, [
|
|
1997
|
+
h("button", { type: "button", className: "dshe-btn dshe-prst-danger", onClick: () => removePreset(id) }, t("common.confirmDelete")),
|
|
1998
|
+
h("button", { type: "button", className: "dshe-btn", onClick: () => setConfirming(0) }, t("common.cancel")),
|
|
1999
|
+
]),
|
|
2000
|
+
])
|
|
2001
|
+
: null,
|
|
2002
|
+
expanded
|
|
2003
|
+
? h("div", { className: "dshe-prst-body" }, [
|
|
2004
|
+
h("div", { className: "dshe-grid" }, [
|
|
2005
|
+
h("div", { className: "dshe-field" }, [
|
|
2006
|
+
h("label", null, t("preset.nameLabel")),
|
|
2007
|
+
h("input", {
|
|
2008
|
+
type: "text",
|
|
2009
|
+
value: name,
|
|
2010
|
+
placeholder: "corp",
|
|
2011
|
+
onChange: (e) => renameDraft(key, e.target.value),
|
|
2012
|
+
}),
|
|
2013
|
+
h("div", { className: "dshe-hint" },
|
|
2014
|
+
duplicate
|
|
2015
|
+
? h("span", { className: "dshe-prst-warn" }, t("preset.duplicateWarn"))
|
|
2016
|
+
: t("preset.nameHint")),
|
|
2017
|
+
]),
|
|
2018
|
+
h("div", { className: "dshe-field" }, [
|
|
2019
|
+
h("label", null, t("preset.labelLabel")),
|
|
2020
|
+
fieldInput("text", draft.label, (v) => patchDraft(key, { label: v }), t("preset.labelPlaceholder")),
|
|
2021
|
+
]),
|
|
2022
|
+
]),
|
|
2023
|
+
h("div", { className: "dshe-grid" }, [
|
|
2024
|
+
h("div", { className: "dshe-field" }, [
|
|
2025
|
+
h("label", null, t("preset.imapHostLabel")),
|
|
2026
|
+
fieldInput("text", draft.imap.host, (v) => patchDraft(key, { imap: Object.assign({}, draft.imap, { host: v }) }), "imap.corp.com"),
|
|
2027
|
+
]),
|
|
2028
|
+
h("div", { className: "dshe-field" }, [
|
|
2029
|
+
h("label", null, t("preset.imapPortLabel")),
|
|
2030
|
+
h("input", {
|
|
2031
|
+
type: "text",
|
|
2032
|
+
inputMode: "numeric",
|
|
2033
|
+
value: draft.imap.port,
|
|
2034
|
+
placeholder: "993",
|
|
2035
|
+
onChange: (e) => patchDraft(key, { imap: Object.assign({}, draft.imap, { port: e.target.value }) }),
|
|
2036
|
+
}),
|
|
2037
|
+
portTextProblem(draft.imap.port) === ""
|
|
2038
|
+
? h("div", { className: "dshe-hint" }, t("preset.imapPortHint"))
|
|
2039
|
+
: h("div", { className: "dshe-hint dshe-prst-warn" }, t("preset.portProblem", { problem: portTextProblem(draft.imap.port) })),
|
|
2040
|
+
]),
|
|
2041
|
+
h("label", { className: "dshe-check" }, [
|
|
2042
|
+
h("input", {
|
|
2043
|
+
type: "checkbox",
|
|
2044
|
+
checked: draft.imap.secure === true,
|
|
2045
|
+
onChange: (e) => patchDraft(key, { imap: Object.assign({}, draft.imap, { secure: e.target.checked }) }),
|
|
2046
|
+
}),
|
|
2047
|
+
t("preset.imapSsl"),
|
|
2048
|
+
]),
|
|
2049
|
+
]),
|
|
2050
|
+
h("div", { className: "dshe-grid" }, [
|
|
2051
|
+
h("div", { className: "dshe-field" }, [
|
|
2052
|
+
h("label", null, t("preset.smtpHostLabel")),
|
|
2053
|
+
fieldInput("text", draft.smtp.host, (v) => patchDraft(key, { smtp: Object.assign({}, draft.smtp, { host: v }) }), "smtp.corp.com"),
|
|
2054
|
+
]),
|
|
2055
|
+
h("div", { className: "dshe-field" }, [
|
|
2056
|
+
h("label", null, t("preset.smtpPortLabel")),
|
|
2057
|
+
h("input", {
|
|
2058
|
+
type: "text",
|
|
2059
|
+
inputMode: "numeric",
|
|
2060
|
+
value: draft.smtp.port,
|
|
2061
|
+
placeholder: "465",
|
|
2062
|
+
onChange: (e) => patchDraft(key, { smtp: Object.assign({}, draft.smtp, { port: e.target.value }) }),
|
|
2063
|
+
}),
|
|
2064
|
+
portTextProblem(draft.smtp.port) === ""
|
|
2065
|
+
? h("div", { className: "dshe-hint" }, t("preset.smtpPortHint"))
|
|
2066
|
+
: h("div", { className: "dshe-hint dshe-prst-warn" }, t("preset.portProblem", { problem: portTextProblem(draft.smtp.port) })),
|
|
2067
|
+
]),
|
|
2068
|
+
h("label", { className: "dshe-check" }, [
|
|
2069
|
+
h("input", {
|
|
2070
|
+
type: "checkbox",
|
|
2071
|
+
checked: draft.smtp.secure === true,
|
|
2072
|
+
onChange: (e) => patchDraft(key, { smtp: Object.assign({}, draft.smtp, { secure: e.target.checked }) }),
|
|
2073
|
+
}),
|
|
2074
|
+
t("preset.smtpSsl"),
|
|
2075
|
+
]),
|
|
2076
|
+
]),
|
|
2077
|
+
])
|
|
2078
|
+
: null,
|
|
2079
|
+
]);
|
|
2080
|
+
}),
|
|
2081
|
+
|
|
2082
|
+
h("div", { className: "dshe-actions" }, [
|
|
2083
|
+
h("button", { type: "button", className: "dshe-btn", onClick: addPreset }, t("preset.add")),
|
|
2084
|
+
]),
|
|
2085
|
+
h("div", { className: "dshe-hint" },
|
|
2086
|
+
t("preset.footerHint") + t("card.autoSaveHint")),
|
|
2087
|
+
|
|
2088
|
+
notice ? h("div", { className: "dshe-alert " + notice.kind }, notice.text) : null,
|
|
2089
|
+
]);
|
|
2090
|
+
}
|
|
2091
|
+
|
|
96
2092
|
function EmailSettingsSection() {
|
|
97
2093
|
const [draft, setDraft] = useState(null);
|
|
98
2094
|
const [snapshot, setSnapshot] = useState(undefined);
|
|
99
2095
|
const [busy, setBusy] = useState(false);
|
|
100
|
-
const [testing, setTesting] = useState(false);
|
|
101
|
-
const [testResult, setTestResult] = useState(undefined);
|
|
102
2096
|
const [error, setError] = useState("");
|
|
103
|
-
|
|
2097
|
+
/** 自动保存的可见状态:null 视同「已保存」(刚加载完、什么都没改)。 */
|
|
2098
|
+
const [saveState, setSaveState] = useState(null);
|
|
2099
|
+
/** 卡片级提示(注释丢失、序列化失败…):走卡片自己的 notice 位,不打断顶部状态。 */
|
|
2100
|
+
const [cardsNotice, setCardsNotice] = useState(null);
|
|
2101
|
+
/**
|
|
2102
|
+
* 账号卡片的初始快照。只在 load 时取一次,保存之后**故意不刷新**:自动保存的
|
|
2103
|
+
* 往返若按新快照重建卡片,正在输入的密码框会被清空(用户接着打的就成了半截
|
|
2104
|
+
* 密码)。编辑期的卡片状态由卡片自己持有,要按 YAML 重建就点那个按钮。
|
|
2105
|
+
*/
|
|
2106
|
+
const [cardsDetail, setCardsDetail] = useState(undefined);
|
|
2107
|
+
/**
|
|
2108
|
+
* 服务器预设的初始快照(同样只在 load 时取一次)。预设编辑器的卡片列表由它派生:
|
|
2109
|
+
* 自动保存的往返若让列表跟着快照重建,用户正在填的那张卡会被收起、未提交的字段
|
|
2110
|
+
* 会被旧值盖掉。账号卡片要的是「最新端点」,所以它们另走 live 快照 + customNames。
|
|
2111
|
+
*/
|
|
2112
|
+
const [presetsAtLoad, setPresetsAtLoad] = useState(undefined);
|
|
2113
|
+
/**
|
|
2114
|
+
* 刚保存成功的预设名。端点仍以快照为准,这里只让「新预设名」先一步出现在账号卡片
|
|
2115
|
+
* 的服务商下拉里 —— 不然要等下一次 GET 才看得到自己刚加的名字。
|
|
2116
|
+
*/
|
|
2117
|
+
const [customNames, setCustomNames] = useState([]);
|
|
2118
|
+
|
|
2119
|
+
/** 最近一次成功保存/序列化后的 accountsYaml 原文:serializeAccounts 的种子(注释保留链)。 */
|
|
2120
|
+
const savedYamlRef = useRef(null);
|
|
2121
|
+
/** 当前该写进 value.accountsYaml 的文本(卡片序列化产物优先)。 */
|
|
2122
|
+
const accountsYamlRef = useRef(null);
|
|
2123
|
+
/** 已落盘的 value 签名:和当前值一样就不重复提交(加载完不会立刻白存一次)。 */
|
|
2124
|
+
const savedSignatureRef = useRef(null);
|
|
2125
|
+
/** 已排队/正在飞的签名:防止「刚 flush 完,effect 又排一次同样的值」。 */
|
|
2126
|
+
const pendingSignatureRef = useRef(null);
|
|
2127
|
+
const saveTimerRef = useRef(0);
|
|
2128
|
+
/** 每次「改动 / 开始保存」都 +1:结果回来时对不上就说明用户又改过了。 */
|
|
2129
|
+
const seqRef = useRef(0);
|
|
2130
|
+
const draftRef = useRef(null);
|
|
2131
|
+
const snapshotRef = useRef(undefined);
|
|
2132
|
+
draftRef.current = draft;
|
|
2133
|
+
snapshotRef.current = snapshot;
|
|
104
2134
|
|
|
105
2135
|
const load = useCallback(async () => {
|
|
106
2136
|
setBusy(true); setError("");
|
|
@@ -108,12 +2138,22 @@ function EmailSettingsSection() {
|
|
|
108
2138
|
const snap = await api();
|
|
109
2139
|
setSnapshot(snap);
|
|
110
2140
|
const value = (snap && snap.settings && snap.settings.value) || EMPTY;
|
|
111
|
-
|
|
2141
|
+
const next = {
|
|
112
2142
|
...EMPTY,
|
|
113
2143
|
...value,
|
|
114
2144
|
imap: { ...EMPTY.imap, ...(value.imap || {}) },
|
|
115
2145
|
smtp: { ...EMPTY.smtp, ...(value.smtp || {}) },
|
|
116
|
-
}
|
|
2146
|
+
};
|
|
2147
|
+
setDraft(next);
|
|
2148
|
+
// 新快照 = 新的 accountsYaml 基线:卡片产物作废,签名对齐,免得回存一次。
|
|
2149
|
+
savedYamlRef.current = next.accountsYaml || "";
|
|
2150
|
+
accountsYamlRef.current = next.accountsYaml || "";
|
|
2151
|
+
savedSignatureRef.current = signatureOf(next, next.accountsYaml || "");
|
|
2152
|
+
setSaveState(null);
|
|
2153
|
+
setCardsNotice(null);
|
|
2154
|
+
setCustomNames(presetNamesOf(next.serverPresets));
|
|
2155
|
+
setCardsDetail(snap.accountsDetail);
|
|
2156
|
+
setPresetsAtLoad(snap.presets);
|
|
117
2157
|
} catch (e) {
|
|
118
2158
|
setError(e && e.message ? e.message : String(e));
|
|
119
2159
|
} finally {
|
|
@@ -125,184 +2165,250 @@ function EmailSettingsSection() {
|
|
|
125
2165
|
|
|
126
2166
|
const update = (patch) => setDraft((cur) => Object.assign({}, cur, patch));
|
|
127
2167
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}));
|
|
141
|
-
} else {
|
|
142
|
-
update({ provider: value });
|
|
2168
|
+
/**
|
|
2169
|
+
* 防抖排期。immediate = 明确动作(确认删除 / 登录前落盘),立刻落盘、不等那 800ms。
|
|
2170
|
+
* immediate 时把落盘的 promise 交出去,调用方要等「真的写完了」可以先 await 它;
|
|
2171
|
+
* 防抖那条路交不出 promise(定时器还没到点),返回 null。
|
|
2172
|
+
*/
|
|
2173
|
+
const scheduleSave = (immediate) => {
|
|
2174
|
+
// 用户又改了:还在飞的那次保存结果就作废,别拿旧结果盖住新状态。
|
|
2175
|
+
seqRef.current += 1;
|
|
2176
|
+
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
2177
|
+
if (immediate === true) {
|
|
2178
|
+
saveTimerRef.current = 0;
|
|
2179
|
+
return flushSave();
|
|
143
2180
|
}
|
|
2181
|
+
saveTimerRef.current = setTimeout(() => {
|
|
2182
|
+
saveTimerRef.current = 0;
|
|
2183
|
+
flushSave();
|
|
2184
|
+
}, SAVE_DEBOUNCE_MS);
|
|
2185
|
+
return null;
|
|
144
2186
|
};
|
|
145
|
-
const updateImap = (patch) => setDraft((cur) => Object.assign({}, cur, { imap: Object.assign({}, cur.imap, patch) }));
|
|
146
|
-
const updateSmtp = (patch) => setDraft((cur) => Object.assign({}, cur, { smtp: Object.assign({}, cur.smtp, patch) }));
|
|
147
2187
|
|
|
148
|
-
|
|
149
|
-
|
|
2188
|
+
/**
|
|
2189
|
+
* 落盘。读到的是 ref 里的最新值,所以「最后一次防抖周期」写的一定是用户最新的字。
|
|
2190
|
+
*/
|
|
2191
|
+
const flushSave = async () => {
|
|
2192
|
+
const cur = draftRef.current;
|
|
2193
|
+
if (cur === null) return;
|
|
2194
|
+
if (snapshotRef.current && snapshotRef.current.writable === false) return;
|
|
2195
|
+
const seq = ++seqRef.current;
|
|
2196
|
+
const value = settingsValueOf(cur, accountsYamlRef.current === null ? (cur.accountsYaml || "") : accountsYamlRef.current);
|
|
2197
|
+
pendingSignatureRef.current = signatureOf(cur, value.accountsYaml);
|
|
2198
|
+
setSaveState({ kind: "busy" });
|
|
150
2199
|
try {
|
|
151
|
-
const rev =
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
2200
|
+
const rev = snapshotRef.current && snapshotRef.current.settings ? snapshotRef.current.settings.revision : 0;
|
|
2201
|
+
let snap;
|
|
2202
|
+
try {
|
|
2203
|
+
snap = await api("save", { value, expectedRevision: rev });
|
|
2204
|
+
} catch (e) {
|
|
2205
|
+
// Another writer moved the revision. Re-read it — and only it: the
|
|
2206
|
+
// snapshot's draft fields are somebody else's truth, while the keystrokes
|
|
2207
|
+
// in `value` are the user's. Retrying with the stale revision, which is
|
|
2208
|
+
// what a bare retry button does, would conflict forever.
|
|
2209
|
+
if (!e || e.code !== "settings-conflict") throw e;
|
|
2210
|
+
const fresh = await api();
|
|
2211
|
+
const rebased = fresh && fresh.settings ? fresh.settings.revision : rev;
|
|
2212
|
+
snap = await api("save", { value, expectedRevision: rebased });
|
|
2213
|
+
}
|
|
2214
|
+
savedYamlRef.current = value.accountsYaml;
|
|
2215
|
+
savedSignatureRef.current = signatureOf(cur, value.accountsYaml);
|
|
2216
|
+
pendingSignatureRef.current = savedSignatureRef.current;
|
|
2217
|
+
if (seq !== seqRef.current) {
|
|
2218
|
+
// 保存期间又改过:只吸收新 revision(否则下一次保存会拿旧 revision 撞车),
|
|
2219
|
+
// 其余一律不动 —— 快照里的 accountsDetail / presets 是「上一次的值」,
|
|
2220
|
+
// 用它重建会把用户正在打的字盖掉。状态指示交给新一轮防抖周期。
|
|
2221
|
+
setSnapshot((old) => Object.assign({}, old, {
|
|
2222
|
+
settings: snap.settings,
|
|
2223
|
+
writable: snap.writable,
|
|
2224
|
+
}));
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
159
2227
|
setSnapshot(snap);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
2228
|
+
setSaveState({ kind: "saved" });
|
|
2229
|
+
const nextNames = presetNamesOf(value.serverPresets);
|
|
2230
|
+
setCustomNames((old) => (old.join("|") === nextNames.join("|") ? old : nextNames));
|
|
163
2231
|
} catch (e) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
2232
|
+
if (seq !== seqRef.current) return;
|
|
2233
|
+
// 失败时清掉「正在飞」的签名,否则同一个值再也不会被重排(点重试即可再存)。
|
|
2234
|
+
pendingSignatureRef.current = null;
|
|
2235
|
+
setSaveState({ kind: "error", text: e && e.message ? e.message : String(e) });
|
|
167
2236
|
}
|
|
168
2237
|
};
|
|
169
2238
|
|
|
170
|
-
|
|
171
|
-
|
|
2239
|
+
// 唯一的自动保存触发点:draft 变了就排一次防抖(不含 revision,不会自激)。
|
|
2240
|
+
useEffect(() => {
|
|
2241
|
+
if (draft === null) return undefined;
|
|
2242
|
+
if (snapshotRef.current && snapshotRef.current.writable === false) return undefined;
|
|
2243
|
+
const text = accountsYamlRef.current === null ? (draft.accountsYaml || "") : accountsYamlRef.current;
|
|
2244
|
+
const signature = signatureOf(draft, text);
|
|
2245
|
+
// 已落盘、或正在飞的就是这份值 → 不重复提交。
|
|
2246
|
+
if (signature === savedSignatureRef.current) return undefined;
|
|
2247
|
+
if (signature === pendingSignatureRef.current) return undefined;
|
|
2248
|
+
scheduleSave(false);
|
|
2249
|
+
return undefined;
|
|
2250
|
+
// eslint-disable-next-line
|
|
2251
|
+
}, [draft]);
|
|
2252
|
+
|
|
2253
|
+
useEffect(() => () => {
|
|
2254
|
+
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
|
2255
|
+
}, []);
|
|
2256
|
+
|
|
2257
|
+
// 有没落盘的内容(保存中或保存失败)才拦离开:这正是「编辑即保存」剩下的窗口。
|
|
2258
|
+
useEffect(() => {
|
|
2259
|
+
const guard = (e) => {
|
|
2260
|
+
if (saveState === null || saveState.kind === "saved") return undefined;
|
|
2261
|
+
e.preventDefault();
|
|
2262
|
+
e.returnValue = "";
|
|
2263
|
+
return "";
|
|
2264
|
+
};
|
|
2265
|
+
window.addEventListener("beforeunload", guard);
|
|
2266
|
+
return () => window.removeEventListener("beforeunload", guard);
|
|
2267
|
+
}, [saveState]);
|
|
2268
|
+
|
|
2269
|
+
/** 卡片序列化的往返序号:打字快时多个 serializeAccounts 会并发,只认最后一个。 */
|
|
2270
|
+
const cardsSeqRef = useRef(0);
|
|
2271
|
+
|
|
2272
|
+
/**
|
|
2273
|
+
* 卡片快照 -> YAML 文本。种子用最近一次落盘的原文,注释能保就保。
|
|
2274
|
+
*
|
|
2275
|
+
* serverPresets 必须随行:账号只存 provider id,后端要认「这个自定义预设名是不是
|
|
2276
|
+
* 合法名字」只能查预设表。不带的话,一个刚定义、还没落盘的自定义预设名会被当成
|
|
2277
|
+
* 未知名字丢掉,账号的 provider 键就白选了。这里发的是「当前正在编辑的这份文本」,
|
|
2278
|
+
* 而不是已落盘的那份。
|
|
2279
|
+
*/
|
|
2280
|
+
const serializeCards = async (cards, defaultAccount) => {
|
|
2281
|
+
const seed = savedYamlRef.current === null
|
|
2282
|
+
? (draftRef.current ? draftRef.current.accountsYaml || "" : "")
|
|
2283
|
+
: savedYamlRef.current;
|
|
2284
|
+
const serverPresets = draftRef.current && typeof draftRef.current.serverPresets === "string"
|
|
2285
|
+
? draftRef.current.serverPresets
|
|
2286
|
+
: "";
|
|
2287
|
+
return api("serializeAccounts", {
|
|
2288
|
+
accountsYaml: seed,
|
|
2289
|
+
accounts: cards,
|
|
2290
|
+
defaultAccount: defaultAccount || "",
|
|
2291
|
+
serverPresets,
|
|
2292
|
+
});
|
|
2293
|
+
};
|
|
2294
|
+
|
|
2295
|
+
/**
|
|
2296
|
+
* 卡片改动:先序列化,再交给同一条防抖管道保存(immediate = 删除这类明确动作)。
|
|
2297
|
+
* 返回 { ok } 而不是抛:调用方大多忽略它,只有 OAuth 登录前那次要等「真的写完了」,
|
|
2298
|
+
* 而又没有任何地方需要为一个后台保存失败去 catch(失败由状态条 / 卡片提示说)。
|
|
2299
|
+
*/
|
|
2300
|
+
const onCardsAutoSave = async (cards, defaultAccount, immediate) => {
|
|
2301
|
+
const seq = ++cardsSeqRef.current;
|
|
172
2302
|
try {
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
2303
|
+
const result = await serializeCards(cards, defaultAccount);
|
|
2304
|
+
if (seq !== cardsSeqRef.current) return { ok: false, reason: "superseded" }; // 更晚的一次已经在路上,别用旧结果盖新状态
|
|
2305
|
+
const text = result && typeof result.accountsYaml === "string" ? result.accountsYaml : "";
|
|
2306
|
+
accountsYamlRef.current = text;
|
|
2307
|
+
update({ accountsYaml: text });
|
|
2308
|
+
setCardsNotice(
|
|
2309
|
+
result.commentsDropped || result.passwordsDropped
|
|
2310
|
+
? {
|
|
2311
|
+
kind: "info",
|
|
2312
|
+
text: (result.commentsDropped ? t("notice.commentsDropped") : "")
|
|
2313
|
+
+ (result.passwordsDropped ? t("notice.passwordsDropped") : "")
|
|
2314
|
+
+ t("notice.restSaved"),
|
|
2315
|
+
}
|
|
2316
|
+
: null,
|
|
2317
|
+
);
|
|
2318
|
+
// immediate 时 scheduleSave 把落盘的 promise 交出来,等它才算「写完了」。
|
|
2319
|
+
const flushed = scheduleSave(immediate === true);
|
|
2320
|
+
if (flushed && typeof flushed.then === "function") await flushed;
|
|
2321
|
+
return { ok: true };
|
|
181
2322
|
} catch (e) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
2323
|
+
const message = e && e.message ? e.message : String(e);
|
|
2324
|
+
setCardsNotice({ kind: "error", text: t("notice.cardsNotWritten", { message }) });
|
|
2325
|
+
return { ok: false, reason: message };
|
|
185
2326
|
}
|
|
186
2327
|
};
|
|
187
2328
|
|
|
2329
|
+
/**
|
|
2330
|
+
* 卡片「测试连接」:先把卡片序列化成 YAML,再用整份表单去测。
|
|
2331
|
+
* 历史 bug 是这里只送了 accountsYaml —— 表单里的 provider/邮箱地址根本没进请求。
|
|
2332
|
+
* 现在唯一的 test 入口就是卡片上的按钮:单账户表单没了,顶部那个「测试连接」
|
|
2333
|
+
* 也就没有「测哪个账号」可言,一并去掉。
|
|
2334
|
+
*/
|
|
2335
|
+
const onCardTest = async (accountName, cards, defaultAccount) => {
|
|
2336
|
+
const result = await serializeCards(cards, defaultAccount);
|
|
2337
|
+
const text = result && typeof result.accountsYaml === "string" ? result.accountsYaml : "";
|
|
2338
|
+
const value = settingsValueOf(draftRef.current, text);
|
|
2339
|
+
return api("test", { value, account: accountName });
|
|
2340
|
+
};
|
|
2341
|
+
|
|
188
2342
|
if (draft === null) {
|
|
189
2343
|
return h("div", { className: "dshe-settings" }, [
|
|
190
|
-
h("div", { className: "dshe-alert info" }, busy ? "
|
|
2344
|
+
h("div", { className: "dshe-alert info" }, busy ? t("common.loading") : (error || t("common.loading"))),
|
|
191
2345
|
]);
|
|
192
2346
|
}
|
|
193
2347
|
|
|
194
2348
|
const accounts = snapshot && snapshot.accounts ? snapshot.accounts : [];
|
|
2349
|
+
const presetsMerged = presetsWithNames((snapshot && snapshot.presets) || undefined, customNames);
|
|
2350
|
+
const statusText = saveState === null
|
|
2351
|
+
? t("status.saved")
|
|
2352
|
+
: saveState.kind === "busy"
|
|
2353
|
+
? t("status.saving")
|
|
2354
|
+
: saveState.kind === "saved"
|
|
2355
|
+
? t("status.saved")
|
|
2356
|
+
: t("status.saveFailed", { message: saveState.text });
|
|
2357
|
+
const status = h("span", {
|
|
2358
|
+
className: "dshe-auto-status" + (saveState === null ? "" : " is-" + saveState.kind),
|
|
2359
|
+
onClick: saveState !== null && saveState.kind === "error" ? () => flushSave() : undefined,
|
|
2360
|
+
style: saveState !== null && saveState.kind === "error" ? { cursor: "pointer" } : undefined,
|
|
2361
|
+
}, statusText);
|
|
195
2362
|
|
|
196
2363
|
return h("div", { className: "dshe-settings" }, [
|
|
197
2364
|
h("header", { className: "dshe-header" }, [
|
|
198
2365
|
h("span", { className: "dshe-kicker" }, "dsh-email · IMAP/SMTP"),
|
|
199
|
-
h("h2", null, "
|
|
200
|
-
h("p", null, "
|
|
2366
|
+
h("h2", null, t("section.title")),
|
|
2367
|
+
h("p", null, t("section.intro")),
|
|
201
2368
|
]),
|
|
2369
|
+
status,
|
|
202
2370
|
h("section", { className: "dshe-panel" }, [
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
PROVIDERS.map(([value, label]) => h("option", { key: value, value }, label))),
|
|
208
|
-
]),
|
|
209
|
-
h("div", { className: "dshe-field" }, [
|
|
210
|
-
h("label", null, "邮箱地址"),
|
|
211
|
-
fieldInput("text", draft.user, (v) => update({ user: v }), "you@example.com"),
|
|
212
|
-
]),
|
|
213
|
-
]),
|
|
214
|
-
h("div", { className: "dshe-grid" }, [
|
|
215
|
-
h("div", { className: "dshe-field" }, [
|
|
216
|
-
h("label", null, "授权码 / 应用专用密码"),
|
|
2371
|
+
// 全局设置:发信确认和下载目录对所有账号一视同仁,不属于任何一张卡片,
|
|
2372
|
+
// 所以摆在卡片列表上方,而不是塞进某一张卡里。
|
|
2373
|
+
h("div", { className: "dshe-global" }, [
|
|
2374
|
+
h("label", { className: "dshe-check" }, [
|
|
217
2375
|
h("input", {
|
|
218
|
-
type: "
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
onChange: (e) => update({ password: e.target.value }),
|
|
2376
|
+
type: "checkbox",
|
|
2377
|
+
checked: draft.sendApproval === true,
|
|
2378
|
+
onChange: (e) => update({ sendApproval: e.target.checked }),
|
|
222
2379
|
}),
|
|
223
|
-
|
|
2380
|
+
t("global.sendApproval"),
|
|
224
2381
|
]),
|
|
225
2382
|
h("div", { className: "dshe-field" }, [
|
|
226
|
-
h("label", null, "
|
|
227
|
-
fieldInput("text", draft.
|
|
2383
|
+
h("label", null, t("global.downloadDirLabel")),
|
|
2384
|
+
fieldInput("text", draft.downloadDir, (v) => update({ downloadDir: v }), t("global.downloadDirPlaceholder")),
|
|
228
2385
|
]),
|
|
2386
|
+
h("div", { className: "dshe-hint" }, t("global.hint")),
|
|
229
2387
|
]),
|
|
230
|
-
h(
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
2388
|
+
h(AccountCardsEditor, {
|
|
2389
|
+
detail: cardsDetail,
|
|
2390
|
+
presets: presetsMerged,
|
|
2391
|
+
extraNotice: cardsNotice,
|
|
2392
|
+
onAutoSave: onCardsAutoSave,
|
|
2393
|
+
onTest: onCardTest,
|
|
2394
|
+
// OAuth2 登录成功后重拉整份快照:卡片的 authKind/oauthState/oauthUser 是
|
|
2395
|
+
// 服务端算出来的,只有新快照才拿得到(和加载走同一条路径)。
|
|
2396
|
+
onReload: load,
|
|
2397
|
+
}),
|
|
2398
|
+
h("details", { className: "dshe-details dshe-prst-section" }, [
|
|
2399
|
+
h("summary", null, t("preset.sectionTitle")),
|
|
2400
|
+
h(ServerPresetsEditor, {
|
|
2401
|
+
presets: presetsAtLoad,
|
|
2402
|
+
onAutoSave: (text) => update({ serverPresets: typeof text === "string" ? text : "" }),
|
|
235
2403
|
}),
|
|
236
|
-
"发信前弹确认(强烈建议保留;Full Access 模式下会被自动拒绝)",
|
|
237
|
-
]),
|
|
238
|
-
h("div", { className: "dshe-field" }, [
|
|
239
|
-
h("label", null, "附件下载目录(默认 $DSH_HOME/email-downloads)"),
|
|
240
|
-
fieldInput("text", draft.downloadDir, (v) => update({ downloadDir: v }), "留空使用默认"),
|
|
241
|
-
]),
|
|
242
|
-
h("details", { className: "dshe-details" }, [
|
|
243
|
-
h("summary", null, "多账号(高级,YAML)"),
|
|
244
|
-
h("div", { className: "dshe-field", style: { marginTop: 10 } }, [
|
|
245
|
-
h("textarea", {
|
|
246
|
-
rows: 7,
|
|
247
|
-
value: draft.accountsYaml || "",
|
|
248
|
-
placeholder: "work: { provider: qq, user: work@qq.com, password: 授权码1 }\nhome: { provider: '163', user: home@163.com, password: 授权码2 }\ndefaultAccount: work",
|
|
249
|
-
onChange: (e) => update({ accountsYaml: e.target.value }),
|
|
250
|
-
}),
|
|
251
|
-
h("div", { className: "dshe-hint" }, "账号名 -> 配置的映射,可含 defaultAccount 键。填了这里会覆盖 cordis.patch.yml 里的 accounts 映射;工具调用时用 account 参数选账号。"),
|
|
252
|
-
]),
|
|
253
|
-
]),
|
|
254
|
-
h("details", { className: "dshe-details" }, [
|
|
255
|
-
h("summary", null, "高级:自定义服务器(选了预设可留空)"),
|
|
256
|
-
h("div", { className: "dshe-grid", style: { marginTop: 10 } }, [
|
|
257
|
-
h("div", { className: "dshe-field" }, [
|
|
258
|
-
h("label", null, "IMAP 主机"),
|
|
259
|
-
fieldInput("text", draft.imap.host, (v) => updateImap({ host: v }), "imap.qq.com"),
|
|
260
|
-
]),
|
|
261
|
-
h("div", { className: "dshe-field" }, [
|
|
262
|
-
h("label", null, "IMAP 端口"),
|
|
263
|
-
fieldInput("number", draft.imap.port, (v) => updateImap({ port: Number(v) }), "993"),
|
|
264
|
-
]),
|
|
265
|
-
h("label", { className: "dshe-check" }, [
|
|
266
|
-
h("input", {
|
|
267
|
-
type: "checkbox",
|
|
268
|
-
checked: draft.imap.secure === true,
|
|
269
|
-
onChange: (e) => updateImap({ secure: e.target.checked }),
|
|
270
|
-
}),
|
|
271
|
-
"IMAP SSL",
|
|
272
|
-
]),
|
|
273
|
-
h("div", { className: "dshe-field" }, [
|
|
274
|
-
h("label", null, "SMTP 主机"),
|
|
275
|
-
fieldInput("text", draft.smtp.host, (v) => updateSmtp({ host: v }), "smtp.qq.com"),
|
|
276
|
-
]),
|
|
277
|
-
h("div", { className: "dshe-field" }, [
|
|
278
|
-
h("label", null, "SMTP 端口"),
|
|
279
|
-
fieldInput("number", draft.smtp.port, (v) => updateSmtp({ port: Number(v) }), "465"),
|
|
280
|
-
]),
|
|
281
|
-
h("label", { className: "dshe-check" }, [
|
|
282
|
-
h("input", {
|
|
283
|
-
type: "checkbox",
|
|
284
|
-
checked: draft.smtp.secure === true,
|
|
285
|
-
onChange: (e) => updateSmtp({ secure: e.target.checked }),
|
|
286
|
-
}),
|
|
287
|
-
"SMTP SSL(Outlook/iCloud 587 端口请取消勾选)",
|
|
288
|
-
]),
|
|
289
|
-
]),
|
|
290
|
-
]),
|
|
291
|
-
h("div", { className: "dshe-actions" }, [
|
|
292
|
-
h("button", { className: "dshe-btn primary", disabled: busy || testing, onClick: doSave }, busy ? "处理中…" : "保存并应用"),
|
|
293
|
-
h("button", { className: "dshe-btn", disabled: busy || testing, onClick: doTest }, testing ? "测试中…" : "测试连接"),
|
|
294
2404
|
]),
|
|
295
2405
|
snapshot && snapshot.writable === false
|
|
296
|
-
? h("div", { className: "dshe-alert info" }, "
|
|
2406
|
+
? h("div", { className: "dshe-alert info" }, t("section.readonly"))
|
|
297
2407
|
: null,
|
|
298
2408
|
]),
|
|
299
2409
|
error ? h("div", { className: "dshe-alert error" }, error) : null,
|
|
300
|
-
message ? h("div", { className: "dshe-alert success" }, message) : null,
|
|
301
|
-
testResult
|
|
302
|
-
? h("div", { className: testResult.ok ? "dshe-alert success" : "dshe-alert error" }, testResult.text)
|
|
303
|
-
: null,
|
|
304
2410
|
accounts.length > 0
|
|
305
|
-
? h("div", { className: "dshe-hint" }, "
|
|
2411
|
+
? h("div", { className: "dshe-hint" }, t("section.activeAccounts", { accounts: accounts.join(t("common.listSeparator")) }))
|
|
306
2412
|
: null,
|
|
307
2413
|
]);
|
|
308
2414
|
}
|
|
@@ -322,6 +2428,8 @@ function startWhaleWidget() {
|
|
|
322
2428
|
let hideTimer = 0;
|
|
323
2429
|
let whaleUrl = "";
|
|
324
2430
|
let whaleCredit = "";
|
|
2431
|
+
/** 当前弹窗的数据。语言切了就靠它原样重画一遍,无需再拉一次接口。 */
|
|
2432
|
+
let current = null;
|
|
325
2433
|
|
|
326
2434
|
const closePopup = () => {
|
|
327
2435
|
if (hideTimer) { clearTimeout(hideTimer); hideTimer = 0; }
|
|
@@ -329,6 +2437,7 @@ function startWhaleWidget() {
|
|
|
329
2437
|
};
|
|
330
2438
|
|
|
331
2439
|
const showPopup = (value) => {
|
|
2440
|
+
current = value;
|
|
332
2441
|
closePopup();
|
|
333
2442
|
const card = document.createElement("div");
|
|
334
2443
|
card.className = "dshe-whale-card";
|
|
@@ -344,19 +2453,19 @@ function startWhaleWidget() {
|
|
|
344
2453
|
body.className = "dshe-whale-body";
|
|
345
2454
|
const title = document.createElement("div");
|
|
346
2455
|
title.className = "dshe-whale-title";
|
|
347
|
-
title.textContent = "
|
|
2456
|
+
title.textContent = t("whale.title", { count: value.newCount });
|
|
348
2457
|
body.appendChild(title);
|
|
349
2458
|
(value.messages || []).slice(0, 3).forEach((m) => {
|
|
350
2459
|
const row = document.createElement("div");
|
|
351
2460
|
row.className = "dshe-whale-item";
|
|
352
|
-
const who = (m.from || []).map((a) => a.name || a.address).filter(Boolean).join(", ") || "
|
|
353
|
-
row.textContent =
|
|
2461
|
+
const who = (m.from || []).map((a) => a.name || a.address).filter(Boolean).join(", ") || t("whale.unknownSender");
|
|
2462
|
+
row.textContent = t("whale.row", { who, subject: m.subject || t("whale.noSubject") });
|
|
354
2463
|
body.appendChild(row);
|
|
355
2464
|
});
|
|
356
2465
|
if (value.newCount > 3) {
|
|
357
2466
|
const more = document.createElement("div");
|
|
358
2467
|
more.className = "dshe-whale-item";
|
|
359
|
-
more.textContent = "
|
|
2468
|
+
more.textContent = t("whale.more", { count: value.newCount - 3 });
|
|
360
2469
|
body.appendChild(more);
|
|
361
2470
|
}
|
|
362
2471
|
if (whaleCredit) {
|
|
@@ -369,40 +2478,84 @@ function startWhaleWidget() {
|
|
|
369
2478
|
const closeBtn = document.createElement("button");
|
|
370
2479
|
closeBtn.className = "dshe-whale-close";
|
|
371
2480
|
closeBtn.textContent = "\u2715";
|
|
372
|
-
closeBtn.setAttribute("aria-label", "
|
|
2481
|
+
closeBtn.setAttribute("aria-label", t("whale.close"));
|
|
373
2482
|
closeBtn.onclick = closePopup;
|
|
374
2483
|
card.appendChild(closeBtn);
|
|
375
2484
|
root.appendChild(card);
|
|
376
2485
|
hideTimer = setTimeout(closePopup, 12000);
|
|
377
2486
|
};
|
|
378
2487
|
|
|
2488
|
+
// 轮询策略:页面不可见时完全不打扰邮箱服务器;失败指数退避(上限 10 分钟),
|
|
2489
|
+
// 成功立刻回到 30s;快照(皮肤 / 账号列表)变化很慢,最多 10 分钟刷新一次。
|
|
2490
|
+
const POLL_MS = 30000;
|
|
2491
|
+
const SNAPSHOT_MS = 600000;
|
|
2492
|
+
const MAX_BACKOFF_MS = 600000;
|
|
2493
|
+
let failures = 0;
|
|
2494
|
+
let lastSnapshot = 0;
|
|
2495
|
+
let hasAccounts = true;
|
|
2496
|
+
|
|
379
2497
|
const tick = async () => {
|
|
2498
|
+
if (document.hidden) return;
|
|
380
2499
|
try {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
2500
|
+
if (!hasAccounts || Date.now() - lastSnapshot > SNAPSHOT_MS) {
|
|
2501
|
+
const snap = await api();
|
|
2502
|
+
lastSnapshot = Date.now();
|
|
2503
|
+
if (snap && snap.whale) {
|
|
2504
|
+
whaleUrl = snap.whale.url || "";
|
|
2505
|
+
whaleCredit = snap.whale.credit || "";
|
|
2506
|
+
}
|
|
2507
|
+
hasAccounts = !!(snap && snap.accounts && snap.accounts.length > 0);
|
|
385
2508
|
}
|
|
386
|
-
if (!
|
|
2509
|
+
if (!hasAccounts) return;
|
|
387
2510
|
const value = await api("watch", { limit: 5 });
|
|
388
2511
|
if (value && value.newCount > 0) showPopup(value);
|
|
2512
|
+
failures = 0;
|
|
389
2513
|
} catch (e) {
|
|
390
|
-
// Not configured or transient error: stay silent
|
|
2514
|
+
// Not configured or transient error: stay silent and back off.
|
|
2515
|
+
failures = Math.min(failures + 1, 8);
|
|
391
2516
|
}
|
|
392
2517
|
};
|
|
393
2518
|
|
|
394
|
-
|
|
395
|
-
|
|
2519
|
+
const schedule = () => {
|
|
2520
|
+
clearTimeout(pollTimer);
|
|
2521
|
+
if (document.hidden) return; // 回到前台时 onVisibility 会立刻补一次
|
|
2522
|
+
const delay = failures === 0 ? POLL_MS : Math.min(POLL_MS * Math.pow(2, failures - 1), MAX_BACKOFF_MS);
|
|
2523
|
+
pollTimer = setTimeout(() => { void tick().then(schedule); }, delay);
|
|
2524
|
+
};
|
|
2525
|
+
|
|
2526
|
+
const onVisibility = () => {
|
|
2527
|
+
clearTimeout(pollTimer);
|
|
2528
|
+
if (document.hidden) return;
|
|
2529
|
+
failures = 0;
|
|
2530
|
+
void tick().then(schedule);
|
|
2531
|
+
};
|
|
2532
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
2533
|
+
void tick().then(schedule);
|
|
2534
|
+
// 弹窗是命令式 DOM,没有 React 那层重渲染:语言切换后自己重画一次(开着的才重画)。
|
|
2535
|
+
relocalize = () => { if (current !== null && root.childElementCount > 0) showPopup(current); };
|
|
396
2536
|
return () => {
|
|
397
|
-
|
|
2537
|
+
clearTimeout(pollTimer);
|
|
2538
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
398
2539
|
if (hideTimer) clearTimeout(hideTimer);
|
|
2540
|
+
relocalize = null;
|
|
399
2541
|
root.remove();
|
|
400
2542
|
};
|
|
401
2543
|
}
|
|
402
2544
|
|
|
403
|
-
|
|
2545
|
+
// 'locale' 在这里是**硬依赖**(cordis 语义:inject 里列了就等于「缺它就不启动」,
|
|
2546
|
+
// 服务缺席时 fiber 停在 pending,apply 根本不会被调用)。官方每个带 UI 文案的插件
|
|
2547
|
+
// 都这么写,本插件照抄:Web profile 的 bundle 无条件提供 locale 这一行。
|
|
2548
|
+
//
|
|
2549
|
+
// 那下面为什么还要那一整套存在性判断?因为它挡的不是「服务没列进 compose」,而是
|
|
2550
|
+
// 「服务在,但不是这个形状」——旧 ctx 没有 get()、getSnapshot/subscribe 由别的包
|
|
2551
|
+
// 提供且行为不同、测试里塞进来的替身只有一半。这些是 subscribeLocale 真正兜住的东西,
|
|
2552
|
+
// 兜不住也不会连带把 settings 段和鲸鱼娘一起拖垮。
|
|
2553
|
+
const inject = ["slots", "locale"];
|
|
404
2554
|
|
|
405
2555
|
function apply(ctx) {
|
|
2556
|
+
// 语言要在任何 t() 之前定下来:槽位的 label thunk 和鲸鱼娘都在挂载时就取文案。
|
|
2557
|
+
ctx.effect(() => subscribeLocale(ctx), "dsh-email: locale");
|
|
2558
|
+
|
|
406
2559
|
ctx.effect(() => {
|
|
407
2560
|
const id = "dsh-email/client";
|
|
408
2561
|
if (document.querySelector('style[data-plugin-css="' + id + '"]')) return () => {};
|
|
@@ -420,7 +2573,7 @@ function apply(ctx) {
|
|
|
420
2573
|
name: "settings.section",
|
|
421
2574
|
id: "dsh-email",
|
|
422
2575
|
order: 45,
|
|
423
|
-
label: () => "
|
|
2576
|
+
label: () => t("nav.title"),
|
|
424
2577
|
inject: () => ({}),
|
|
425
2578
|
}, EmailSettingsSection));
|
|
426
2579
|
}
|