opencode-claude-code-ua 0.0.0-stage → 0.3.9
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/LICENSE +23 -0
- package/NOTICE.md +9 -0
- package/README.md +200 -2
- package/assets/icon.svg +9 -0
- package/index.mjs +19 -0
- package/lib/parameters.mjs +108 -0
- package/lib/ua-protocol.mjs +51 -0
- package/lib/ua-test-plan.mjs +232 -0
- package/package.json +80 -4
- package/setup.mjs +39 -0
- package/src/account-profile.mjs +73 -0
- package/src/account-usage.mjs +33 -0
- package/src/accounts.mjs +354 -0
- package/src/connection.mjs +144 -0
- package/src/diagnostics.mjs +37 -0
- package/src/headers.mjs +60 -0
- package/src/key-usage.mjs +73 -0
- package/src/newapi.mjs +330 -0
- package/src/options.mjs +48 -0
- package/src/plugin.mjs +344 -0
- package/src/profile.mjs +217 -0
- package/src/runtime.mjs +39 -0
- package/src/schedule.mjs +88 -0
- package/src/scheduler.mjs +141 -0
- package/src/setup-server.mjs +478 -0
- package/src/store.mjs +58 -0
- package/src/transport.mjs +84 -0
- package/src/ua-model-catalog.mjs +218 -0
- package/src/ua-plugin.mjs +250 -0
- package/src/ua-runtime.mjs +229 -0
- package/src/ua-service.mjs +129 -0
- package/src/ua-store.mjs +660 -0
- package/src/ua-tests.mjs +153 -0
- package/src/ua-transform.mjs +478 -0
- package/src/ua-usage.mjs +64 -0
- package/ui/accounts.css +75 -0
- package/ui/accounts.html +67 -0
- package/ui/accounts.js +786 -0
- package/ui/pro.css +1683 -0
- package/ui/pro.html +347 -0
- package/ui/pro.js +900 -0
- package/ui/theme.js +39 -0
package/ui/accounts.js
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
export function formatAmount(value, signed = false) {
|
|
2
|
+
if (value === null || value === undefined || !Number.isFinite(value)) return '—';
|
|
3
|
+
const text = value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 6 });
|
|
4
|
+
return signed && value > 0 ? `+${text}` : text;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function formatConnectionTime(value) {
|
|
8
|
+
if (!value) return '尚未查询';
|
|
9
|
+
const date = new Date(value);
|
|
10
|
+
return Number.isNaN(date.getTime()) ? '尚未查询' : date.toLocaleString('zh-CN');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Classify a connection reading without ever borrowing site-account totals. */
|
|
14
|
+
export function connectionBalanceSource(result) {
|
|
15
|
+
if (!result) return 'unknown';
|
|
16
|
+
const usage = result.usage && typeof result.usage === 'object' ? result.usage : null;
|
|
17
|
+
if (!usage) return result.error ? 'error' : 'unknown';
|
|
18
|
+
if (typeof usage.error === 'string' && usage.error.trim()) return 'error';
|
|
19
|
+
if (typeof usage.balance === 'string' && usage.balance.trim()) return 'key';
|
|
20
|
+
if (Array.isArray(usage.windows) && usage.windows.some(window => typeof window?.display === 'string' && window.display.trim())) return 'key';
|
|
21
|
+
return 'unknown';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Format one configured connection row; bindings are shown, never auto-selected. */
|
|
25
|
+
export function connectionBalanceView(connection = {}, result = null, accounts = []) {
|
|
26
|
+
const usage = result?.usage && typeof result.usage === 'object' ? result.usage : null;
|
|
27
|
+
const windows = Array.isArray(usage?.windows) ? usage.windows : [];
|
|
28
|
+
const windowsText = windows
|
|
29
|
+
.map(item => typeof item?.display === 'string' ? item.display.trim() : '')
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.join(' ');
|
|
32
|
+
const balance = typeof usage?.balance === 'string' && usage.balance.trim() ? usage.balance.trim() : '余额未知';
|
|
33
|
+
const source = connectionBalanceSource(result);
|
|
34
|
+
const accountId = connection.newApiAccountId ?? '';
|
|
35
|
+
const bound = accountId ? (Array.isArray(accounts) ? accounts : []).find(account => account.id === accountId) : null;
|
|
36
|
+
const boundAccount = !accountId
|
|
37
|
+
? '未绑定签到账号'
|
|
38
|
+
: bound ? `签到账号:${bound.name}` : '绑定的签到账号已失效';
|
|
39
|
+
return {
|
|
40
|
+
name: String(connection.name ?? '').trim() || '未命名连接',
|
|
41
|
+
root: String(connection.baseURL ?? '').trim() || '—',
|
|
42
|
+
balance,
|
|
43
|
+
windows: windowsText,
|
|
44
|
+
source,
|
|
45
|
+
sourceLabel: source === 'key' ? '已配置连接 Key 用量' : source === 'error' ? '查询失败' : '尚未查询',
|
|
46
|
+
boundAccount,
|
|
47
|
+
lastAt: formatConnectionTime(result?.at),
|
|
48
|
+
error: typeof usage?.error === 'string' && usage.error.trim()
|
|
49
|
+
? usage.error
|
|
50
|
+
: typeof result?.error === 'string' ? result.error : '',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A reading is valid only for the exact id, Key revision and address it came from. */
|
|
55
|
+
export function connectionResultMatches(connection = {}, result = null) {
|
|
56
|
+
return Boolean(connection && result
|
|
57
|
+
&& result.connectionId === connection.id
|
|
58
|
+
&& result.revision === connection.revision
|
|
59
|
+
&& result.baseURL === connection.baseURL);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Drop readings after a list reload changes the Key revision or address. */
|
|
63
|
+
export function pruneConnectionResults(connections = [], results = {}) {
|
|
64
|
+
const kept = {};
|
|
65
|
+
for (const connection of Array.isArray(connections) ? connections : []) {
|
|
66
|
+
const result = results?.[connection.id];
|
|
67
|
+
if (connectionResultMatches(connection, result)) kept[connection.id] = result;
|
|
68
|
+
}
|
|
69
|
+
return kept;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Explicit same-origin refresh; the network is never touched by the initial list load. */
|
|
73
|
+
export async function requestConnectionUsage(fetcher, connection = {}) {
|
|
74
|
+
let response;
|
|
75
|
+
try {
|
|
76
|
+
response = await fetcher('./api/accounts/connection-usage', {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { 'Content-Type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({ connectionId: connection.id, confirmNetwork: true }),
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
return { ok: false, error: 'Key 用量查询未完成,请检查站点地址与网络' };
|
|
83
|
+
}
|
|
84
|
+
let data = null;
|
|
85
|
+
try { data = await response.json(); } catch { data = null; }
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
return { ok: false, error: data && typeof data.error === 'string' && data.error ? data.error : 'Key 用量查询未完成' };
|
|
88
|
+
}
|
|
89
|
+
if (!data || typeof data !== 'object' || data.connectionId !== connection.id
|
|
90
|
+
|| !data.usage || typeof data.usage !== 'object') {
|
|
91
|
+
return { ok: false, error: 'Key 用量响应无法确认,请稍后重试' };
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, data };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
export function accountValues(account = {}) {
|
|
98
|
+
return {
|
|
99
|
+
id: account.id, name: account.name ?? '', siteType: account.siteType === 'agentrouter' ? 'agentrouter' : 'newapi',
|
|
100
|
+
siteUrl: account.siteUrl ?? '', userId: account.userId ?? '', username: account.username ?? '',
|
|
101
|
+
authMode: account.authMode ?? 'token', accessToken: '', cookie: '', password: '',
|
|
102
|
+
quotaPerUnit: account.quotaPerUnit ?? 500000, unit: account.unit ?? 'USD',
|
|
103
|
+
enabled: account.enabled ?? true, checkinEnabled: account.checkinEnabled ?? true,
|
|
104
|
+
scheduleEnabled: account.schedule?.enabled ?? false,
|
|
105
|
+
scheduleTime: account.schedule?.time ?? '09:00',
|
|
106
|
+
scheduleTimeZone: account.schedule?.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC',
|
|
107
|
+
clearAccessToken: false, clearCookie: false, clearPassword: false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function accountPayload(values) {
|
|
112
|
+
const { clearAccessToken, clearCookie, clearPassword, scheduleEnabled = false, scheduleTime = '09:00', scheduleTimeZone = 'UTC', ...raw } = values;
|
|
113
|
+
const account = { ...raw, siteType: raw.siteType === 'agentrouter' ? 'agentrouter' : 'newapi', username: String(raw.username ?? '').trim(), quotaPerUnit: Number(raw.quotaPerUnit), schedule: { enabled: scheduleEnabled, time: scheduleTime, timeZone: scheduleTimeZone } };
|
|
114
|
+
if (!account.password) delete account.password;
|
|
115
|
+
const clearSecrets = [];
|
|
116
|
+
if (clearAccessToken) { clearSecrets.push('accessToken'); account.accessToken = ''; }
|
|
117
|
+
if (clearCookie) { clearSecrets.push('cookie'); account.cookie = ''; }
|
|
118
|
+
if (clearPassword) { clearSecrets.push('password'); account.password = ''; }
|
|
119
|
+
if (!account.id) delete account.id;
|
|
120
|
+
return { account, clearSecrets };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function accountSummary(account) {
|
|
124
|
+
const snapshot = account.snapshot;
|
|
125
|
+
const unit = account.unit ?? '';
|
|
126
|
+
const amount = (value, signed = false) => value === null || value === undefined ? '—' : `${formatAmount(value, signed)} ${unit}`.trim();
|
|
127
|
+
const labels = { success: '已签到', checked: '已签到', already: '今日已签到',
|
|
128
|
+
already_checked: '今日已签到', disabled: '未启用签到', verification_required: '需要验证',
|
|
129
|
+
requires_verification: '需要验证', error: '签到失败', failed: '签到失败', unknown: '尚未签到' };
|
|
130
|
+
return {
|
|
131
|
+
remaining: amount(snapshot?.remaining), used: amount(snapshot?.used),
|
|
132
|
+
deltaBalance: amount(snapshot?.deltaBalance, true), deltaUsed: amount(snapshot?.deltaUsed, true),
|
|
133
|
+
deltaRequests: snapshot?.deltaRequests == null ? '—' : String(snapshot.deltaRequests),
|
|
134
|
+
state: !account.enabled ? '已停用' : account.status?.stale ? '缓存读数' : snapshot ? '已更新' : '尚未查询',
|
|
135
|
+
error: account.status?.error ?? '',
|
|
136
|
+
checkin: account.checkin?.state === 'unknown' && account.checkin.lastAttemptAt ? '结果未确认' : labels[account.checkin?.state] ?? account.checkin?.state ?? '尚未签到',
|
|
137
|
+
schedule: !account.schedule?.enabled ? '自动签到未开启' : !account.enabled || !account.checkinEnabled
|
|
138
|
+
? '自动签到已暂停(账号或签到已停用)' : `每天 ${account.schedule.time} · ${account.schedule.timeZone}`,
|
|
139
|
+
reward: amount(account.checkin?.reward), reset: snapshot?.reset ?? false,
|
|
140
|
+
at: snapshot?.at ? new Date(snapshot.at).toLocaleString('zh-CN') : '—',
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function secretIntent(values = {}, valueKey = 'accessToken', clearKey = 'clearAccessToken', existing = false) {
|
|
145
|
+
if (values[clearKey]) return 'clear';
|
|
146
|
+
if (typeof values[valueKey] === 'string' && values[valueKey].length > 0) return 'replace';
|
|
147
|
+
return existing ? 'keep' : 'empty';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A comparable, normalized draft. Secrets never enter the draft as plaintext. */
|
|
151
|
+
export function normalizedAccountDraft(values = {}, secrets = {}) {
|
|
152
|
+
const text = (value) => value === null || value === undefined ? '' : String(value).trim();
|
|
153
|
+
const number = (value) => {
|
|
154
|
+
if (value === '' || value === null || value === undefined) return null;
|
|
155
|
+
const parsed = Number(value);
|
|
156
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
id: text(values.id),
|
|
160
|
+
name: text(values.name),
|
|
161
|
+
siteType: values.siteType === 'agentrouter' ? 'agentrouter' : 'newapi',
|
|
162
|
+
siteUrl: text(values.siteUrl),
|
|
163
|
+
userId: text(values.userId),
|
|
164
|
+
username: text(values.username),
|
|
165
|
+
authMode: values.authMode === 'cookie' ? 'cookie' : 'token',
|
|
166
|
+
quotaPerUnit: number(values.quotaPerUnit),
|
|
167
|
+
unit: text(values.unit),
|
|
168
|
+
enabled: Boolean(values.enabled),
|
|
169
|
+
checkinEnabled: Boolean(values.checkinEnabled),
|
|
170
|
+
scheduleEnabled: Boolean(values.scheduleEnabled),
|
|
171
|
+
scheduleTime: text(values.scheduleTime),
|
|
172
|
+
scheduleTimeZone: text(values.scheduleTimeZone),
|
|
173
|
+
accessToken: secretIntent(values, 'accessToken', 'clearAccessToken', Boolean(secrets.accessToken)),
|
|
174
|
+
cookie: secretIntent(values, 'cookie', 'clearCookie', Boolean(secrets.cookie)),
|
|
175
|
+
password: secretIntent(values, 'password', 'clearPassword', Boolean(secrets?.password)),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Client-side field validation; the backend repeats it authoritatively. */
|
|
180
|
+
export function accountFieldErrors(values = {}, secrets = {}) {
|
|
181
|
+
const text = (value) => value === null || value === undefined ? '' : String(value).trim();
|
|
182
|
+
const errors = {};
|
|
183
|
+
if (!text(values.name)) errors.name = '请填写账号名称';
|
|
184
|
+
if (!text(values.siteUrl)) errors.siteUrl = '请填写站点地址';
|
|
185
|
+
const siteType = values.siteType === 'agentrouter' ? 'agentrouter' : 'newapi';
|
|
186
|
+
const userId = text(values.userId);
|
|
187
|
+
if (siteType === 'newapi') {
|
|
188
|
+
if (!/^[1-9]\d*$/.test(userId)) errors.userId = '请填写正整数用户 ID';
|
|
189
|
+
} else {
|
|
190
|
+
if (userId && !/^[1-9]\d*$/.test(userId)) errors.userId = '用户 ID 必须为正整数,或留空由登录结果确定';
|
|
191
|
+
if (!text(values.username)) errors.username = '请填写 AgentRouter 登录用户名';
|
|
192
|
+
if (!text(values.password) && !values.clearPassword && !secrets?.password) {
|
|
193
|
+
errors.password = '请填写登录密码;已保存的密码可留空保留';
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return errors;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function accountDraftDirty(baseline, draft) {
|
|
200
|
+
if (!baseline || !draft) return false;
|
|
201
|
+
return JSON.stringify(baseline) !== JSON.stringify(draft);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function bulkEligibility(accounts = [], connections = []) {
|
|
205
|
+
const list = Array.isArray(accounts) ? accounts : [];
|
|
206
|
+
const refresh = list.some((account) => account?.enabled === true) || (Array.isArray(connections) && connections.some(connection => connection?.enabled === true));
|
|
207
|
+
const checkin = list.some((account) => account?.enabled === true && account?.checkinEnabled === true);
|
|
208
|
+
return {
|
|
209
|
+
refresh,
|
|
210
|
+
checkin,
|
|
211
|
+
refreshReason: refresh ? '' : '没有启用且可刷新的账号',
|
|
212
|
+
checkinReason: checkin ? '' : '没有启用且允许签到的账号',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const ACTION_LABELS = Object.freeze({ save: '保存', refresh: '刷新', checkin: '签到', bind: '绑定', remove: '删除', done: '完成并关闭' });
|
|
217
|
+
|
|
218
|
+
export function reconciliationMessage(action) {
|
|
219
|
+
return `已重新读取账号列表;原${ACTION_LABELS[action] ?? '操作'}结果仍需核对,不要直接重复提交。`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function loadFailureMessage({ loaded = false, at } = {}) {
|
|
223
|
+
if (!loaded) return '首次读取失败:尚未加载任何账号数据。';
|
|
224
|
+
const date = at === undefined ? null : new Date(at);
|
|
225
|
+
const when = date && !Number.isNaN(date.getTime()) ? date.toLocaleString('zh-CN') : '';
|
|
226
|
+
return when
|
|
227
|
+
? `重新读取失败:仍显示上次成功读取的数据(${when})。`
|
|
228
|
+
: '重新读取失败:仍显示上次成功读取的数据。';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function requestAccountAction(fetcher, action, payload) {
|
|
232
|
+
let response;
|
|
233
|
+
try {
|
|
234
|
+
response = await fetcher(`./api/accounts/${action}`, {
|
|
235
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
|
236
|
+
});
|
|
237
|
+
} catch {
|
|
238
|
+
return {
|
|
239
|
+
ok: false, unknown: true,
|
|
240
|
+
error: '操作结果未知:请求可能已提交但未收到确认。请重新读取账号列表确认结果,不要直接重复提交。',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
let data = {};
|
|
244
|
+
let confirmed = true;
|
|
245
|
+
try { data = await response.json(); } catch { data = {}; confirmed = false; }
|
|
246
|
+
if (!response.ok) return { ok: false, unknown: false, error: data.error || '账号操作未完成', fields: data.fields };
|
|
247
|
+
if (!confirmed) {
|
|
248
|
+
return {
|
|
249
|
+
ok: false, unknown: true,
|
|
250
|
+
error: '操作结果未知:服务响应无法确认,请求可能已提交。请重新读取账号列表确认结果,不要直接重复提交。',
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return { ok: true, data };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function initializeAccounts() {
|
|
257
|
+
const get = (id) => document.getElementById(id);
|
|
258
|
+
const editor = get('account-editor');
|
|
259
|
+
const history = get('account-history');
|
|
260
|
+
const controls = { name: 'account-name', siteType: 'site-type', siteUrl: 'site-url', userId: 'user-id', username: 'agent-username', authMode: 'auth-mode', accessToken: 'access-token', cookie: 'account-cookie', password: 'agent-password', quotaPerUnit: 'quota-per-unit', unit: 'unit', enabled: 'account-enabled', checkinEnabled: 'checkin-enabled', scheduleEnabled: 'schedule-enabled', scheduleTime: 'schedule-time', scheduleTimeZone: 'schedule-timezone', clearAccessToken: 'clear-token', clearCookie: 'clear-cookie', clearPassword: 'clear-password' };
|
|
261
|
+
const checks = new Set(['enabled', 'checkinEnabled', 'scheduleEnabled', 'clearAccessToken', 'clearCookie', 'clearPassword']);
|
|
262
|
+
const fieldIds = { name: 'account-name', siteUrl: 'site-url', userId: 'user-id', username: 'agent-username',
|
|
263
|
+
authMode: 'auth-mode', accessToken: 'access-token', cookie: 'account-cookie', password: 'agent-password',
|
|
264
|
+
quotaPerUnit: 'quota-per-unit', unit: 'unit',
|
|
265
|
+
'schedule.enabled': 'schedule-enabled', 'schedule.time': 'schedule-time', 'schedule.timeZone': 'schedule-timezone' };
|
|
266
|
+
const actionStatus = { refresh: '正在刷新账号余额…', checkin: '正在签到并刷新余额…',
|
|
267
|
+
bind: '正在更新当前连接绑定…', remove: '正在删除账号…' };
|
|
268
|
+
let accounts = [];
|
|
269
|
+
let binding = null;
|
|
270
|
+
let connections = [];
|
|
271
|
+
let connectionResults = {};
|
|
272
|
+
let editing;
|
|
273
|
+
let running = false;
|
|
274
|
+
let unknownOutcome = null;
|
|
275
|
+
let reconciled = false;
|
|
276
|
+
let loading = true;
|
|
277
|
+
let hasLoaded = false;
|
|
278
|
+
let lastLoadedAt = 0;
|
|
279
|
+
let loadErrorShown = false;
|
|
280
|
+
let editorBaseline;
|
|
281
|
+
let allowUnload = false;
|
|
282
|
+
|
|
283
|
+
function element(tag, text, className) {
|
|
284
|
+
const node = document.createElement(tag);
|
|
285
|
+
if (text !== undefined) node.textContent = text;
|
|
286
|
+
if (className) node.className = className;
|
|
287
|
+
return node;
|
|
288
|
+
}
|
|
289
|
+
function error(message, form = false) {
|
|
290
|
+
const target = get(form ? 'account-form-error' : 'accounts-error');
|
|
291
|
+
target.textContent = message; target.hidden = !message;
|
|
292
|
+
if (message) target.focus();
|
|
293
|
+
}
|
|
294
|
+
function clearFieldErrors() {
|
|
295
|
+
for (const node of get('account-form').querySelectorAll('[aria-invalid]')) {
|
|
296
|
+
node.removeAttribute('aria-invalid');
|
|
297
|
+
node.removeAttribute('aria-errormessage');
|
|
298
|
+
}
|
|
299
|
+
get('account-form').querySelectorAll('.account-field-error').forEach(node => node.remove());
|
|
300
|
+
}
|
|
301
|
+
function markFieldErrors(fields = {}) {
|
|
302
|
+
clearFieldErrors();
|
|
303
|
+
let first;
|
|
304
|
+
for (const [key, message] of Object.entries(fields)) {
|
|
305
|
+
const node = get(fieldIds[key]);
|
|
306
|
+
if (!node) continue;
|
|
307
|
+
first ||= node;
|
|
308
|
+
const note = element('p', typeof message === 'string' ? message : '请填写有效的值', 'account-field-error');
|
|
309
|
+
note.id = `${node.id}-error`;
|
|
310
|
+
node.insertAdjacentElement('afterend', note);
|
|
311
|
+
node.setAttribute('aria-invalid', 'true');
|
|
312
|
+
node.setAttribute('aria-errormessage', note.id);
|
|
313
|
+
}
|
|
314
|
+
if (first) { first.scrollIntoView({ block: 'center' }); first.focus({ preventScroll: true }); }
|
|
315
|
+
}
|
|
316
|
+
function fields() {
|
|
317
|
+
const values = { id: editing?.id };
|
|
318
|
+
for (const [key, id] of Object.entries(controls)) values[key] = checks.has(key) ? get(id).checked : get(id).value;
|
|
319
|
+
return values;
|
|
320
|
+
}
|
|
321
|
+
function currentDraft() {
|
|
322
|
+
return normalizedAccountDraft(fields(), editing?.secrets ?? {});
|
|
323
|
+
}
|
|
324
|
+
function isEditorDirty() {
|
|
325
|
+
return Boolean(editor.open && editorBaseline && accountDraftDirty(editorBaseline, currentDraft()));
|
|
326
|
+
}
|
|
327
|
+
function updateEditorDirty() {
|
|
328
|
+
const dirty = isEditorDirty();
|
|
329
|
+
const state = get('editor-dirty-state');
|
|
330
|
+
state.textContent = editor.open
|
|
331
|
+
? dirty ? '有未保存的修改;关闭或按 Esc 会先确认。' : '没有未保存的修改。'
|
|
332
|
+
: '';
|
|
333
|
+
state.classList.toggle('dirty', dirty);
|
|
334
|
+
}
|
|
335
|
+
function confirmDiscard() {
|
|
336
|
+
return window.confirm('有未保存的账号修改。确定放弃修改并继续吗?选择“取消”可返回继续编辑。');
|
|
337
|
+
}
|
|
338
|
+
function syncControls() {
|
|
339
|
+
const bulk = bulkEligibility(accounts, connections);
|
|
340
|
+
const blocked = running || Boolean(unknownOutcome);
|
|
341
|
+
get('refresh-all').disabled = blocked || loading || !bulk.refresh;
|
|
342
|
+
get('checkin-all').disabled = blocked || loading || !bulk.checkin;
|
|
343
|
+
get('connection-refresh-all').disabled = blocked || loading || connections.length === 0;
|
|
344
|
+
get('account-new').disabled = blocked;
|
|
345
|
+
get('accounts-done').disabled = blocked;
|
|
346
|
+
get('account-save').disabled = blocked;
|
|
347
|
+
get('editor-close').disabled = running;
|
|
348
|
+
get('account-fields').disabled = running;
|
|
349
|
+
for (const button of get('accounts-table').querySelectorAll('button')) button.disabled = blocked;
|
|
350
|
+
const ack = get('accounts-ack');
|
|
351
|
+
ack.hidden = !(unknownOutcome && reconciled);
|
|
352
|
+
ack.disabled = running;
|
|
353
|
+
const recheck = get('editor-recheck');
|
|
354
|
+
recheck.hidden = unknownOutcome?.action !== 'save' || reconciled;
|
|
355
|
+
recheck.disabled = running || loading;
|
|
356
|
+
const editorAck = get('editor-ack');
|
|
357
|
+
editorAck.hidden = !(unknownOutcome?.action === 'save' && reconciled);
|
|
358
|
+
editorAck.disabled = running;
|
|
359
|
+
const unread = !loading && !hasLoaded ? '账号数据尚未读取,请先重新读取账号列表' : '';
|
|
360
|
+
const reasons = [];
|
|
361
|
+
if (unread) reasons.push(unread);
|
|
362
|
+
else if (!loading) {
|
|
363
|
+
if (!bulk.refresh) reasons.push(bulk.refreshReason);
|
|
364
|
+
if (!bulk.checkin) reasons.push(bulk.checkinReason);
|
|
365
|
+
}
|
|
366
|
+
const reason = get('bulk-reason');
|
|
367
|
+
reason.textContent = reasons.join(';');
|
|
368
|
+
reason.hidden = loading || reasons.length === 0;
|
|
369
|
+
const reasonFor = { 'refresh-all': unread || bulk.refreshReason, 'checkin-all': unread || bulk.checkinReason };
|
|
370
|
+
for (const id of ['refresh-all', 'checkin-all']) {
|
|
371
|
+
const button = get(id);
|
|
372
|
+
const why = reasonFor[id];
|
|
373
|
+
if (!loading && why) {
|
|
374
|
+
button.title = why;
|
|
375
|
+
button.setAttribute('aria-describedby', 'bulk-reason');
|
|
376
|
+
} else {
|
|
377
|
+
button.removeAttribute('title');
|
|
378
|
+
button.removeAttribute('aria-describedby');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const retry = get('accounts-retry');
|
|
382
|
+
retry.disabled = running || loading;
|
|
383
|
+
if (unknownOutcome) {
|
|
384
|
+
for (const id of ['refresh-all', 'checkin-all', 'account-new', 'accounts-done', 'account-save']) {
|
|
385
|
+
get(id).title = '操作结果未知;请先重新读取账号列表确认。';
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
function busy(value) {
|
|
390
|
+
running = value;
|
|
391
|
+
syncControls();
|
|
392
|
+
}
|
|
393
|
+
function confirmUnknownLeave() {
|
|
394
|
+
const label = ACTION_LABELS[unknownOutcome?.action] ?? '操作';
|
|
395
|
+
return window.confirm(`“${label}”结果仍需核对。确定关闭并稍后重新打开页面核对吗?`);
|
|
396
|
+
}
|
|
397
|
+
function acknowledgeUnknown() {
|
|
398
|
+
if (!unknownOutcome || !reconciled || running) return;
|
|
399
|
+
const label = ACTION_LABELS[unknownOutcome.action] ?? '操作';
|
|
400
|
+
if (!window.confirm(`请确认已核对“${label}”结果,并且不会重复提交。继续操作?`)) return;
|
|
401
|
+
unknownOutcome = null;
|
|
402
|
+
reconciled = false;
|
|
403
|
+
error('');
|
|
404
|
+
error('', true);
|
|
405
|
+
get('editor-reconciliation').hidden = true;
|
|
406
|
+
get('accounts-status').textContent = statusSummary();
|
|
407
|
+
syncControls();
|
|
408
|
+
}
|
|
409
|
+
function credentialFields() {
|
|
410
|
+
const agentRouter = get('site-type').value === 'agentrouter';
|
|
411
|
+
get('agentrouter-fields').hidden = !agentRouter;
|
|
412
|
+
get('user-id').required = !agentRouter;
|
|
413
|
+
get('token-field').hidden = get('auth-mode').value !== 'token' && !editing?.secrets?.accessToken;
|
|
414
|
+
get('cookie-field').hidden = get('auth-mode').value !== 'cookie' && !editing?.secrets?.cookie;
|
|
415
|
+
}
|
|
416
|
+
function scheduleFields() {
|
|
417
|
+
get('schedule-settings').hidden = !get('schedule-enabled').checked;
|
|
418
|
+
}
|
|
419
|
+
function edit(account) {
|
|
420
|
+
editing = account;
|
|
421
|
+
get('editor-reconciliation').hidden = true;
|
|
422
|
+
const values = accountValues(account);
|
|
423
|
+
for (const [key, id] of Object.entries(controls)) {
|
|
424
|
+
if (checks.has(key)) get(id).checked = values[key];
|
|
425
|
+
else get(id).value = values[key];
|
|
426
|
+
}
|
|
427
|
+
get('editor-title').textContent = account ? '编辑站点账号' : '添加站点账号';
|
|
428
|
+
get('token-state').textContent = account?.secrets?.accessToken ? '已保存;留空保留原令牌' : '尚未保存系统访问令牌';
|
|
429
|
+
get('cookie-state').textContent = account?.secrets?.cookie ? '已保存;留空保留原 Cookie' : '尚未保存 Cookie';
|
|
430
|
+
get('agent-password-state').textContent = account?.secrets?.password ? '已保存;留空保留原密码' : '尚未保存登录密码';
|
|
431
|
+
error('', true);
|
|
432
|
+
clearFieldErrors();
|
|
433
|
+
credentialFields(); scheduleFields();
|
|
434
|
+
editorBaseline = currentDraft();
|
|
435
|
+
editor.showModal();
|
|
436
|
+
updateEditorDirty();
|
|
437
|
+
get('account-name').focus();
|
|
438
|
+
}
|
|
439
|
+
function showHistory(account) {
|
|
440
|
+
get('history-title').textContent = `${account.name} · 额度变化`;
|
|
441
|
+
get('history-list').replaceChildren(...[...(account.history ?? [])].reverse().map((snapshot) => {
|
|
442
|
+
const row = element('tr');
|
|
443
|
+
for (const value of [new Date(snapshot.at).toLocaleString('zh-CN'), formatAmount(snapshot.remaining), formatAmount(snapshot.used), formatAmount(snapshot.deltaBalance, true), formatAmount(snapshot.deltaUsed, true), snapshot.deltaRequests ?? '—']) row.append(element('td', String(value)));
|
|
444
|
+
return row;
|
|
445
|
+
}));
|
|
446
|
+
history.showModal();
|
|
447
|
+
}
|
|
448
|
+
async function perform(action, id) {
|
|
449
|
+
if (running || unknownOutcome) return;
|
|
450
|
+
if (action === 'remove' && !window.confirm('删除这个账号及其本地历史记录?不会注销站点账号。')) return;
|
|
451
|
+
if (action === 'checkin' && !window.confirm(id ? '确认向这个账号的站点提交签到?' : '确认向全部启用且允许签到的账号提交签到?')) return;
|
|
452
|
+
if (action === 'bind' && !window.confirm('把这个站点账号用于当前 Magpie 连接的余额和签到?点击“完成并关闭”后保存绑定,不更改 API key。')) return;
|
|
453
|
+
busy(true); error('');
|
|
454
|
+
get('accounts-status').textContent = actionStatus[action] ?? '正在处理账号…';
|
|
455
|
+
const result = await requestAccountAction(fetch.bind(globalThis), action, id ? { id } : {});
|
|
456
|
+
if (!result.ok && result.unknown) {
|
|
457
|
+
unknownOutcome = { action, id: id ?? null };
|
|
458
|
+
reconciled = false;
|
|
459
|
+
error(result.error);
|
|
460
|
+
get('accounts-status').textContent = `操作结果未知:请求可能已提交。请先重新读取账号列表核对“${ACTION_LABELS[action] ?? '操作'}”结果,不要重复提交。`;
|
|
461
|
+
get('accounts-retry').hidden = false;
|
|
462
|
+
busy(false);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (!result.ok) error(result.error);
|
|
466
|
+
await load();
|
|
467
|
+
try {
|
|
468
|
+
if (action === 'refresh' && !id) await refreshConnections();
|
|
469
|
+
} finally {
|
|
470
|
+
busy(false);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function statusSummary() {
|
|
474
|
+
if (accounts.length === 0) return '尚未添加站点账号。添加后可刷新余额或签到。';
|
|
475
|
+
return `${accounts.length} 个账号,${accounts.filter((account) => account.enabled).length} 个启用。${binding ? `当前连接 ${binding.account};绑定需点击完成后保存。` : '签到账号绑定请在连接与模型选择中操作。'}`;
|
|
476
|
+
}
|
|
477
|
+
function renderConnections() {
|
|
478
|
+
connectionResults = pruneConnectionResults(connections, connectionResults);
|
|
479
|
+
const list = get('connection-balance-list');
|
|
480
|
+
const empty = connections.length === 0;
|
|
481
|
+
list.hidden = empty;
|
|
482
|
+
get('connection-balance-rows').replaceChildren(...connections.map((connection) => {
|
|
483
|
+
const result = connectionResults[connection.id] ?? null;
|
|
484
|
+
const view = connectionBalanceView(connection, result, accounts);
|
|
485
|
+
const row = element('tr');
|
|
486
|
+
const identity = element('td');
|
|
487
|
+
identity.append(element('strong', view.name), element('div', view.root, 'detail'), element('div', view.boundAccount, 'detail'));
|
|
488
|
+
const balance = element('td');
|
|
489
|
+
balance.append(element('div', view.balance, 'primary-value'), element('div', view.sourceLabel, 'detail'));
|
|
490
|
+
const usage = element('td');
|
|
491
|
+
usage.append(element('div', view.windows || '—', 'detail'));
|
|
492
|
+
if (view.error && !view.windows.includes(view.error)) usage.append(element('div', view.error, 'detail'));
|
|
493
|
+
const last = element('td');
|
|
494
|
+
last.append(element('div', view.lastAt, 'detail'));
|
|
495
|
+
for (const [cell, label] of [[identity, '连接 / 站点'], [balance, 'Key 余额'], [usage, 'Key 用量'], [last, '最近查询']]) cell.dataset.label = label;
|
|
496
|
+
row.append(identity, balance, usage, last);
|
|
497
|
+
return row;
|
|
498
|
+
}));
|
|
499
|
+
if (empty) {
|
|
500
|
+
get('connection-balance-status').textContent = '尚未读取到已配置连接;请先在连接与模型选择中保存连接。';
|
|
501
|
+
} else if (Object.keys(connectionResults).length === 0) {
|
|
502
|
+
get('connection-balance-status').textContent = `已读取 ${connections.length} 个连接,Key 余额尚未查询;请手动刷新。`;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
async function refreshConnections() {
|
|
506
|
+
const targets = connections.slice();
|
|
507
|
+
if (targets.length === 0) {
|
|
508
|
+
get('connection-balance-status').textContent = '尚未读取到已配置连接;没有可刷新的 Key 用量。';
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
get('connection-balance-status').textContent = `正在刷新 ${targets.length} 个连接的 Key 用量…`;
|
|
512
|
+
let completed = 0;
|
|
513
|
+
let failed = 0;
|
|
514
|
+
let discarded = 0;
|
|
515
|
+
for (const target of targets) {
|
|
516
|
+
const current = connections.find((connection) => connection.id === target.id);
|
|
517
|
+
if (!current || current.revision !== target.revision || current.baseURL !== target.baseURL) continue;
|
|
518
|
+
const result = await requestConnectionUsage(fetch.bind(globalThis), current);
|
|
519
|
+
const latest = connections.find((connection) => connection.id === current.id);
|
|
520
|
+
if (!latest || latest.revision !== current.revision || latest.baseURL !== current.baseURL) { discarded++; continue; }
|
|
521
|
+
if (result.ok && result.data.revision !== current.revision) { discarded++; continue; }
|
|
522
|
+
connectionResults[current.id] = result.ok
|
|
523
|
+
? { connectionId: current.id, revision: current.revision, baseURL: current.baseURL, usage: result.data.usage, at: result.data.at || new Date().toISOString() }
|
|
524
|
+
: { connectionId: current.id, revision: current.revision, baseURL: current.baseURL, error: result.error, at: new Date().toISOString() };
|
|
525
|
+
if (result.ok) completed++;
|
|
526
|
+
else failed++;
|
|
527
|
+
renderConnections();
|
|
528
|
+
get('connection-balance-status').textContent = `正在刷新连接 Key 用量…(${completed + failed}/${targets.length})`;
|
|
529
|
+
}
|
|
530
|
+
renderConnections();
|
|
531
|
+
get('connection-balance-status').textContent = discarded
|
|
532
|
+
? `已刷新 ${completed + failed} 个连接;${discarded} 个结果因 Key 或站点已变化而丢弃,请重新读取列表。`
|
|
533
|
+
: failed
|
|
534
|
+
? `已刷新 ${completed + failed} 个连接;${failed} 个查询失败,详见各行站点返回的安全提示。`
|
|
535
|
+
: `已刷新 ${completed} 个连接的 Key 用量;不与站点账号汇总合并。`;
|
|
536
|
+
}
|
|
537
|
+
function render(data) {
|
|
538
|
+
accounts = Array.isArray(data.accounts) ? data.accounts : [];
|
|
539
|
+
connections = Array.isArray(data.connections) ? data.connections : [];
|
|
540
|
+
connectionResults = pruneConnectionResults(connections, connectionResults);
|
|
541
|
+
binding = data.binding ?? null;
|
|
542
|
+
const empty = accounts.length === 0;
|
|
543
|
+
get('account-empty').hidden = !empty;
|
|
544
|
+
get('accounts-table-scroll').hidden = empty;
|
|
545
|
+
get('accounts-table').hidden = empty;
|
|
546
|
+
get('accounts-status').textContent = statusSummary();
|
|
547
|
+
get('account-totals').replaceChildren(...Object.entries(data.totals ?? {}).map(([unit, total]) => {
|
|
548
|
+
const node = element('section', undefined, 'unit-total'); node.append(element('h2', `${unit} 站点账号汇总`));
|
|
549
|
+
const metrics = element('div', undefined, 'total-values');
|
|
550
|
+
for (const [name, value] of [['剩余额度', total.remaining], ['累计消耗', total.used], ['消耗增量', total.deltaUsed]]) {
|
|
551
|
+
const metric = element('div', undefined, 'total-metric'); metric.append(element('small', name), element('strong', formatAmount(value))); metrics.append(metric);
|
|
552
|
+
}
|
|
553
|
+
node.append(metrics, element('p', `${total.accountCount} 个独立身份,${total.staleCount ?? 0} 个缓存/异常读数;${total.availableCount ?? 0} 个已有读数。别名去重,不同单位不合并。`)); return node;
|
|
554
|
+
}));
|
|
555
|
+
get('account-list').replaceChildren(...accounts.map((account) => {
|
|
556
|
+
const row = element('tr'); const view = accountSummary(account);
|
|
557
|
+
const identity = element('td'); identity.append(element('strong', account.name), element('div', `${account.siteType === 'agentrouter' ? 'AgentRouter' : 'New API'} · ${account.siteUrl} · ID ${account.userId || (account.siteType === 'agentrouter' ? '登录后确定' : '—')}`, 'detail'), element('span', view.state, `account-state${account.status?.stale ? ' stale' : ''}`));
|
|
558
|
+
if (view.error) identity.append(element('div', view.error, 'detail'));
|
|
559
|
+
if (data.binding?.selected === account.id) identity.append(element('span', '已选为当前连接', 'account-state'));
|
|
560
|
+
const remaining = element('td'); remaining.append(element('div', view.remaining, 'primary-value'), element('div', view.at, 'detail'));
|
|
561
|
+
const used = element('td'); used.append(element('div', view.used, 'primary-value'), element('div', `累计请求 ${account.snapshot?.requestCount ?? '—'}`, 'detail'));
|
|
562
|
+
const delta = element('td'); delta.append(element('div', `消耗 ${view.deltaUsed}`), element('div', `余额 ${view.deltaBalance}`, 'detail'), element('div', `请求 ${view.deltaRequests === '—' ? '—' : `+${view.deltaRequests}`}`, 'detail'));
|
|
563
|
+
if (view.reset) delta.append(element('div', '累计计数回退,已重建基线', 'detail'));
|
|
564
|
+
const checkin = element('td'); checkin.append(element('div', view.checkin), element('div', view.schedule, 'detail'), element('div', `奖励 ${view.reward}`, 'detail'), element('div', account.checkin?.message ?? '', 'detail'));
|
|
565
|
+
const actions = element('td'); const bar = element('div', undefined, 'row-actions');
|
|
566
|
+
for (const [label, action] of [['刷新', 'refresh'], ['签到', 'checkin'], ['记录', 'history'], ['编辑', 'edit'], ['删除', 'remove']]) {
|
|
567
|
+
const button = element('button', label, `secondary${action === 'remove' ? ' danger' : ''}`); button.type = 'button';
|
|
568
|
+
button.setAttribute('aria-label', `${label} ${account.name}`);
|
|
569
|
+
button.addEventListener('click', () => action === 'edit' ? edit(account) : action === 'history' ? showHistory(account) : perform(action, account.id));
|
|
570
|
+
bar.append(button);
|
|
571
|
+
}
|
|
572
|
+
if (data.binding) {
|
|
573
|
+
const bind = element('button', data.binding.selected === account.id ? '解除绑定' : '用于当前连接', 'secondary');
|
|
574
|
+
bind.type = 'button';
|
|
575
|
+
bind.addEventListener('click', () => perform('bind', data.binding.selected === account.id ? undefined : account.id));
|
|
576
|
+
bar.append(bind);
|
|
577
|
+
}
|
|
578
|
+
actions.append(bar); row.append(identity, remaining, used, delta, checkin, actions);
|
|
579
|
+
// 窄屏下表格转为卡片,用单元格标签代替隐藏的表头。
|
|
580
|
+
for (const [cell, label] of [[identity, '账号 / 站点'], [remaining, '剩余额度'], [used, '累计消耗'], [delta, '相较上次成功刷新'], [checkin, '签到'], [actions, '操作']]) cell.dataset.label = label;
|
|
581
|
+
return row;
|
|
582
|
+
}));
|
|
583
|
+
renderConnections();
|
|
584
|
+
syncControls();
|
|
585
|
+
}
|
|
586
|
+
async function load() {
|
|
587
|
+
loading = true; syncControls();
|
|
588
|
+
try {
|
|
589
|
+
const response = await fetch('./api/accounts', { cache: 'no-store' });
|
|
590
|
+
let data = null;
|
|
591
|
+
try { data = await response.json(); } catch { data = null; }
|
|
592
|
+
if (!response.ok || !data || !Array.isArray(data.accounts)) {
|
|
593
|
+
const failure = new Error(data?.error || '无法读取本地账号列表。配置会话可能已超时,请重新打开配置页或检查账号文件权限。');
|
|
594
|
+
failure.safe = true;
|
|
595
|
+
throw failure;
|
|
596
|
+
}
|
|
597
|
+
const hadUnknown = Boolean(unknownOutcome);
|
|
598
|
+
const recovered = loadErrorShown;
|
|
599
|
+
render(data);
|
|
600
|
+
hasLoaded = true;
|
|
601
|
+
lastLoadedAt = Date.now();
|
|
602
|
+
loadErrorShown = false;
|
|
603
|
+
get('accounts-retry').hidden = true;
|
|
604
|
+
if (hadUnknown) {
|
|
605
|
+
reconciled = true;
|
|
606
|
+
const note = `${reconciliationMessage(unknownOutcome.action)} 确认无误后点击“已核对结果,继续操作”。`;
|
|
607
|
+
error('');
|
|
608
|
+
if (unknownOutcome.action === 'save' && editor.open) {
|
|
609
|
+
error(note, true);
|
|
610
|
+
const current = fields();
|
|
611
|
+
const found = accounts.find(account => account.id === unknownOutcome.id)
|
|
612
|
+
|| accounts.find(account => account.name === current.name.trim() && account.siteUrl === current.siteUrl.trim() && String(account.userId) === current.userId.trim());
|
|
613
|
+
const summary = get('editor-reconciliation'); summary.hidden = false;
|
|
614
|
+
summary.textContent = found
|
|
615
|
+
? `本次读到:${found.name} · ${found.siteUrl} · ID ${found.userId} · ${accountSummary(found).schedule}。凭据只显示是否保存,不回显;这次读取不证明原请求已经结束。`
|
|
616
|
+
: '本次列表中未找到匹配账号;原请求仍可能在处理中。请稍后再读,不要直接重复保存。';
|
|
617
|
+
} else get('accounts-status').textContent = note;
|
|
618
|
+
} else if (recovered) {
|
|
619
|
+
error('');
|
|
620
|
+
}
|
|
621
|
+
} catch (cause) {
|
|
622
|
+
loadErrorShown = true;
|
|
623
|
+
get('accounts-status').textContent = loadFailureMessage({ loaded: hasLoaded, at: hasLoaded ? lastLoadedAt : undefined });
|
|
624
|
+
error(cause?.safe && cause.message ? cause.message : '无法读取本地账号列表。配置会话可能已超时,请重新打开配置页或检查账号文件权限。');
|
|
625
|
+
get('accounts-retry').hidden = false;
|
|
626
|
+
if (!hasLoaded) {
|
|
627
|
+
accounts = [];
|
|
628
|
+
connections = [];
|
|
629
|
+
connectionResults = {};
|
|
630
|
+
get('account-empty').hidden = true;
|
|
631
|
+
get('accounts-table-scroll').hidden = true;
|
|
632
|
+
get('accounts-table').hidden = true;
|
|
633
|
+
get('account-totals').replaceChildren();
|
|
634
|
+
renderConnections();
|
|
635
|
+
get('connection-balance-status').textContent = '连接列表尚未读取;请先重新读取账号列表。';
|
|
636
|
+
}
|
|
637
|
+
} finally {
|
|
638
|
+
loading = false;
|
|
639
|
+
syncControls();
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
get('account-new').addEventListener('click', () => edit());
|
|
644
|
+
get('editor-close').addEventListener('click', () => {
|
|
645
|
+
if (running) return;
|
|
646
|
+
if (unknownOutcome) {
|
|
647
|
+
if (!confirmUnknownLeave()) return;
|
|
648
|
+
editor.close();
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (isEditorDirty() && !confirmDiscard()) return;
|
|
652
|
+
editor.close();
|
|
653
|
+
});
|
|
654
|
+
get('history-close').addEventListener('click', () => history.close());
|
|
655
|
+
get('auth-mode').addEventListener('change', credentialFields);
|
|
656
|
+
get('site-type').addEventListener('change', credentialFields);
|
|
657
|
+
get('schedule-enabled').addEventListener('change', scheduleFields);
|
|
658
|
+
get('accounts-retry').addEventListener('click', () => { if (!running && !loading) void load(); });
|
|
659
|
+
get('editor-recheck').addEventListener('click', () => { if (!running && !loading) void load(); });
|
|
660
|
+
get('accounts-ack').addEventListener('click', acknowledgeUnknown);
|
|
661
|
+
get('editor-ack').addEventListener('click', acknowledgeUnknown);
|
|
662
|
+
get('refresh-all').addEventListener('click', () => perform('refresh'));
|
|
663
|
+
get('checkin-all').addEventListener('click', () => perform('checkin'));
|
|
664
|
+
get('connection-refresh-all').addEventListener('click', async () => {
|
|
665
|
+
if (running || unknownOutcome) return;
|
|
666
|
+
busy(true); error('');
|
|
667
|
+
try {
|
|
668
|
+
await refreshConnections();
|
|
669
|
+
} catch {
|
|
670
|
+
error('连接 Key 用量刷新未完成,请稍后重试。');
|
|
671
|
+
} finally {
|
|
672
|
+
busy(false);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
get('account-form').addEventListener('input', (event) => {
|
|
676
|
+
const target = event.target;
|
|
677
|
+
if (target && typeof target.matches === 'function' && target.matches('[aria-invalid]')) {
|
|
678
|
+
target.removeAttribute('aria-invalid');
|
|
679
|
+
target.removeAttribute('aria-errormessage');
|
|
680
|
+
get(`${target.id}-error`)?.remove();
|
|
681
|
+
}
|
|
682
|
+
updateEditorDirty();
|
|
683
|
+
});
|
|
684
|
+
get('account-form').addEventListener('change', updateEditorDirty);
|
|
685
|
+
editor.addEventListener('cancel', (event) => {
|
|
686
|
+
if (running) {
|
|
687
|
+
event.preventDefault();
|
|
688
|
+
error('正在处理,完成后才能关闭账号编辑。', true);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (unknownOutcome) {
|
|
692
|
+
if (!confirmUnknownLeave()) event.preventDefault();
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (isEditorDirty() && !confirmDiscard()) event.preventDefault();
|
|
696
|
+
});
|
|
697
|
+
editor.addEventListener('close', () => {
|
|
698
|
+
editing = undefined;
|
|
699
|
+
editorBaseline = undefined;
|
|
700
|
+
get('access-token').value = '';
|
|
701
|
+
get('account-cookie').value = '';
|
|
702
|
+
clearFieldErrors();
|
|
703
|
+
error('', true);
|
|
704
|
+
updateEditorDirty();
|
|
705
|
+
syncControls();
|
|
706
|
+
});
|
|
707
|
+
document.addEventListener('click', (event) => {
|
|
708
|
+
const link = event.target && typeof event.target.closest === 'function' ? event.target.closest('a[href]') : null;
|
|
709
|
+
if (!link) return;
|
|
710
|
+
if (running) {
|
|
711
|
+
event.preventDefault();
|
|
712
|
+
get('accounts-status').textContent = '操作进行中,完成后才能离开此页面。';
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
if (unknownOutcome) {
|
|
716
|
+
if (!confirmUnknownLeave()) { event.preventDefault(); return; }
|
|
717
|
+
allowUnload = true;
|
|
718
|
+
window.setTimeout(() => { allowUnload = false; }, 1000);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (isEditorDirty()) {
|
|
722
|
+
if (!confirmDiscard()) { event.preventDefault(); return; }
|
|
723
|
+
allowUnload = true;
|
|
724
|
+
window.setTimeout(() => { allowUnload = false; }, 1000);
|
|
725
|
+
}
|
|
726
|
+
}, true);
|
|
727
|
+
window.addEventListener('beforeunload', (event) => {
|
|
728
|
+
if (allowUnload || (!running && !unknownOutcome && !isEditorDirty())) return;
|
|
729
|
+
event.preventDefault();
|
|
730
|
+
event.returnValue = '';
|
|
731
|
+
});
|
|
732
|
+
get('account-form').addEventListener('submit', async (event) => {
|
|
733
|
+
event.preventDefault();
|
|
734
|
+
if (running || unknownOutcome) return;
|
|
735
|
+
const values = fields();
|
|
736
|
+
const missing = accountFieldErrors(values, editing?.secrets ?? {});
|
|
737
|
+
if (Object.keys(missing).length) {
|
|
738
|
+
error('请补充或修正标记的字段。', true);
|
|
739
|
+
markFieldErrors(missing);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
busy(true); error('', true); clearFieldErrors();
|
|
743
|
+
const result = await requestAccountAction(fetch.bind(globalThis), 'save', accountPayload(values));
|
|
744
|
+
if (result.ok) {
|
|
745
|
+
editor.close();
|
|
746
|
+
await load();
|
|
747
|
+
busy(false);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
error(result.fields ? Object.values(result.fields).join(';') : result.error, true);
|
|
751
|
+
if (result.fields) markFieldErrors(result.fields);
|
|
752
|
+
if (result.unknown) {
|
|
753
|
+
unknownOutcome = { action: 'save', id: editing?.id ?? null };
|
|
754
|
+
reconciled = false;
|
|
755
|
+
error(`${result.error} 请点击“重新读取账号列表”核对结果,不要直接重复保存。`, true);
|
|
756
|
+
get('accounts-retry').hidden = false;
|
|
757
|
+
}
|
|
758
|
+
busy(false);
|
|
759
|
+
});
|
|
760
|
+
get('accounts-done').addEventListener('click', async () => {
|
|
761
|
+
if (running || unknownOutcome) return;
|
|
762
|
+
busy(true); error('');
|
|
763
|
+
get('accounts-status').textContent = '正在完成并关闭…';
|
|
764
|
+
const result = await requestAccountAction(fetch.bind(globalThis), 'done', {});
|
|
765
|
+
if (result.ok) {
|
|
766
|
+
busy(false);
|
|
767
|
+
allowUnload = true;
|
|
768
|
+
get('accounts-shell').hidden = true;
|
|
769
|
+
get('accounts-completion').hidden = false;
|
|
770
|
+
setTimeout(() => { try { window.close(); } catch {} }, 250);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
error(result.error);
|
|
774
|
+
if (result.unknown) {
|
|
775
|
+
unknownOutcome = { action: 'done', id: null };
|
|
776
|
+
reconciled = false;
|
|
777
|
+
get('accounts-status').textContent = '关闭结果未知:配置可能已经结束。请重新读取账号列表核对,确认后再关闭页面。';
|
|
778
|
+
get('accounts-retry').hidden = false;
|
|
779
|
+
}
|
|
780
|
+
busy(false);
|
|
781
|
+
});
|
|
782
|
+
busy(false);
|
|
783
|
+
await load();
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (typeof document !== 'undefined') void initializeAccounts();
|