opencode-claude-code-ua 0.0.0-stage → 0.3.6

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.
@@ -0,0 +1,57 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="light dark">
7
+ <title>账号余额与签到 · Magpie Claude Code UA</title>
8
+ <script src="./theme.js"></script>
9
+ <link rel="stylesheet" href="./accounts.css">
10
+ <link rel="icon" type="image/svg+xml" href="./icon.svg">
11
+ </head>
12
+ <body>
13
+ <div id="accounts-shell" class="accounts-shell">
14
+ <header class="accounts-header"><a class="brand" href="./"><img class="brand-icon" src="./icon.svg" width="44" height="44" alt=""><span>Magpie<br><strong>Claude Code UA</strong></span></a><nav aria-label="插件设置"><a href="./">请求配置</a><a href="./accounts" aria-current="page">账号余额与签到</a></nav><div class="theme-control"><label for="theme-mode">外观</label><select id="theme-mode" data-theme-control><option value="system">跟随系统</option><option value="dark">夜间模式</option><option value="light">浅色模式</option></select></div><button id="accounts-done" class="secondary" type="button">完成并关闭</button></header>
15
+ <main class="accounts-main">
16
+ <div class="accounts-title"><div><h1>每个账号,独立记账</h1><p>管理 New API 站点余额、累计消耗与签到奖励,不改变现有模型路由。</p></div><button id="account-new" class="primary" type="button">添加账号</button></div>
17
+ <div id="accounts-error" class="accounts-error" role="alert" hidden tabindex="-1"></div>
18
+ <div id="account-totals" class="account-totals" aria-label="按单位汇总"></div>
19
+ <div class="account-toolbar">
20
+ <div class="account-toolbar-status"><p id="accounts-status" role="status">正在读取本地账号…</p><button id="accounts-retry" class="secondary" type="button" hidden>重新读取账号列表</button><button id="accounts-ack" class="secondary" type="button" hidden>已核对结果,继续操作</button></div>
21
+ <div class="bulk-actions"><button id="refresh-all" class="secondary" type="button" disabled>刷新全部余额</button><button id="checkin-all" class="secondary" type="button" disabled>签到全部启用账号</button><span id="bulk-reason" class="bulk-reason" role="status" hidden></span></div>
22
+ </div>
23
+ <div id="account-empty" class="account-empty" hidden><h2>还没有站点账号</h2><p>添加站点地址、用户 ID 和系统访问令牌或 Cookie。模型 API key 不能代替站点账号凭据。</p></div>
24
+ <div id="accounts-table-scroll" class="account-table-scroll"><table id="accounts-table"><thead><tr><th scope="col">账号 / 站点</th><th scope="col">剩余额度</th><th scope="col">累计消耗</th><th scope="col">相较上次成功刷新</th><th scope="col">签到</th><th scope="col">操作</th></tr></thead><tbody id="account-list"></tbody></table></div>
25
+ <div class="account-notes"><p>增量从两次成功读取计算,不等于“今日用量”。首次读取无基线;计数回退时重新建立基线。查询失败保留旧读数并标记缓存。</p><p>不同单位分别汇总,不做汇率转换。自动签到需在账号中单独开启;请勿同时启用宿主的另一套自动签到计划。验证码请到站点完成。</p></div>
26
+ </main>
27
+ </div>
28
+ <dialog id="account-editor" aria-labelledby="editor-title">
29
+ <form id="account-form" novalidate aria-describedby="account-form-error">
30
+ <header class="dialog-header"><div><h2 id="editor-title">添加站点账号</h2><p>账号凭据与模型路由 API key 分开保存。</p></div><button id="editor-close" class="secondary" type="button" aria-label="关闭账号编辑">×</button></header>
31
+ <div id="account-form-error" class="accounts-error" role="alert" hidden tabindex="-1"></div>
32
+ <div id="editor-reconciliation" class="reconciliation-summary" role="status" hidden></div>
33
+ <div id="account-scroll"><fieldset id="account-fields">
34
+ <div class="field"><label for="account-name">账号名称</label><input id="account-name" required placeholder="便于识别的名称" autocomplete="off"></div>
35
+ <div class="field"><label for="site-url">New API 站点地址</label><input id="site-url" type="url" required placeholder="https://你的站点地址" autocomplete="off" spellcheck="false"><p class="hint">填写站点根地址或部署基础路径,不是模型 API 的 /v1 地址。</p></div>
36
+ <div class="field-grid"><div class="field"><label for="user-id">用户 ID</label><input id="user-id" inputmode="numeric" required placeholder="站点个人资料中的数字 ID"></div><div class="field"><label for="auth-mode">站点鉴权方式</label><select id="auth-mode"><option value="token">系统访问令牌</option><option value="cookie">登录 Cookie</option></select></div></div>
37
+ <div id="token-field" class="field"><label for="access-token">系统访问令牌</label><input type="password" id="access-token" autocomplete="new-password" placeholder="不是 sk- 开头的模型调用 key"><div class="secret-meta"><span id="token-state" class="hint">留空保留已有值</span><label class="check-label" for="clear-token"><input type="checkbox" id="clear-token">清除已存令牌</label></div></div>
38
+ <div id="cookie-field" class="field"><label for="account-cookie">登录 Cookie</label><input type="password" id="account-cookie" autocomplete="new-password" placeholder="session=…"><div class="secret-meta"><span id="cookie-state" class="hint">留空保留已有值</span><label class="check-label" for="clear-cookie"><input type="checkbox" id="clear-cookie">清除已存 Cookie</label></div><p class="hint">Cookie 过期后需从自己账号的站点重新获取。含登录凭据,仅保存在本机。</p></div>
39
+ <div class="field-grid"><div class="field"><label for="quota-per-unit">每单位对应 quota</label><input id="quota-per-unit" type="number" min="0.000001" step="any" value="500000"><p class="hint">New API 常见默认 500000;按站点实际设置修改。</p></div><div class="field"><label for="unit">显示单位</label><input id="unit" value="USD" maxlength="20"><p class="hint">仅显示标签,不代表已验证美元汇率。</p></div></div>
40
+ <div class="account-toggles"><label class="check-label" for="account-enabled"><input id="account-enabled" type="checkbox" checked>启用账号,参与批量刷新</label><label class="check-label" for="checkin-enabled"><input id="checkin-enabled" type="checkbox" checked>允许手动、定时或宿主发起签到</label></div>
41
+ <section class="account-schedule" aria-label="自动签到时间">
42
+ <label class="check-label" for="schedule-enabled"><input id="schedule-enabled" type="checkbox">每天自动签到(可选)</label>
43
+ <div id="schedule-settings" class="field-grid" hidden>
44
+ <div class="field"><label for="schedule-time">每天几点签到</label><input id="schedule-time" type="time" step="60" value="09:00"></div>
45
+ <div class="field"><label for="schedule-timezone">按哪个时区</label><input id="schedule-timezone" list="schedule-timezones" autocomplete="off" spellcheck="false" placeholder="Asia/Shanghai"><datalist id="schedule-timezones"><option value="Asia/Shanghai">北京时间</option><option value="Asia/Hong_Kong">香港</option><option value="Asia/Tokyo">东京</option><option value="UTC">世界标准时间</option><option value="America/New_York">纽约</option><option value="America/Los_Angeles">洛杉矶</option><option value="Europe/London">伦敦</option></datalist><p class="hint">默认使用你浏览器的时区,也可填写其他 IANA 时区名称。</p></div>
46
+ </div>
47
+ <p class="hint">需要 Magpie 插件进程保持运行;关掉此配置页不影响。错过时间不补跑,同一天自动尝试一次,失败不自动重试。夏令时跳过的分钟不执行。</p>
48
+ </section>
49
+ </fieldset></div>
50
+ <footer class="dialog-footer"><div class="dialog-footer-note"><p class="hint">保存不会发送登录或签到请求。密钥不会回传浏览器。</p><p id="editor-dirty-state" class="hint" role="status"></p></div><div class="dialog-footer-actions"><button id="editor-recheck" class="secondary" type="button" hidden>重新读取账号列表</button><button id="editor-ack" class="secondary" type="button" hidden>已核对结果,继续操作</button><button id="account-save" class="primary" type="submit">保存账号</button></div></footer>
51
+ </form>
52
+ </dialog>
53
+ <dialog id="account-history" aria-labelledby="history-title"><header class="dialog-header"><h2 id="history-title">额度变化记录</h2><button id="history-close" class="secondary" type="button">关闭</button></header><div class="history-scroll"><table><thead><tr><th>读取时间</th><th>剩余额度</th><th>累计消耗</th><th>余额变化</th><th>消耗增加</th><th>请求增加</th></tr></thead><tbody id="history-list"></tbody></table></div><p class="hint">最多保留最近 90 条成功读取快照。缓存错误不会新增零值快照。</p></dialog>
54
+ <main id="accounts-completion" class="completion" hidden><div class="completion-icon" aria-hidden="true">✓</div><h1>账号设置已保存</h1><p>可以返回 Magpie。浏览器未自动关闭时可手动关闭此页面。</p></main>
55
+ <script type="module" src="./accounts.js"></script>
56
+ </body>
57
+ </html>
package/ui/accounts.js ADDED
@@ -0,0 +1,582 @@
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
+ export function accountValues(account = {}) {
8
+ return {
9
+ id: account.id, name: account.name ?? '', siteUrl: account.siteUrl ?? '', userId: account.userId ?? '',
10
+ authMode: account.authMode ?? 'token', accessToken: '', cookie: '',
11
+ quotaPerUnit: account.quotaPerUnit ?? 500000, unit: account.unit ?? 'USD',
12
+ enabled: account.enabled ?? true, checkinEnabled: account.checkinEnabled ?? true,
13
+ scheduleEnabled: account.schedule?.enabled ?? false,
14
+ scheduleTime: account.schedule?.time ?? '09:00',
15
+ scheduleTimeZone: account.schedule?.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC',
16
+ clearAccessToken: false, clearCookie: false,
17
+ };
18
+ }
19
+
20
+ export function accountPayload(values) {
21
+ const { clearAccessToken, clearCookie, scheduleEnabled = false, scheduleTime = '09:00', scheduleTimeZone = 'UTC', ...raw } = values;
22
+ const account = { ...raw, quotaPerUnit: Number(raw.quotaPerUnit), schedule: { enabled: scheduleEnabled, time: scheduleTime, timeZone: scheduleTimeZone } };
23
+ const clearSecrets = [];
24
+ if (clearAccessToken) { clearSecrets.push('accessToken'); account.accessToken = ''; }
25
+ if (clearCookie) { clearSecrets.push('cookie'); account.cookie = ''; }
26
+ if (!account.id) delete account.id;
27
+ return { account, clearSecrets };
28
+ }
29
+
30
+ export function accountSummary(account) {
31
+ const snapshot = account.snapshot;
32
+ const unit = account.unit ?? '';
33
+ const amount = (value, signed = false) => value === null || value === undefined ? '—' : `${formatAmount(value, signed)} ${unit}`.trim();
34
+ const labels = { success: '已签到', checked: '已签到', already: '今日已签到',
35
+ already_checked: '今日已签到', disabled: '未启用签到', verification_required: '需要验证',
36
+ requires_verification: '需要验证', error: '签到失败', failed: '签到失败', unknown: '尚未签到' };
37
+ return {
38
+ remaining: amount(snapshot?.remaining), used: amount(snapshot?.used),
39
+ deltaBalance: amount(snapshot?.deltaBalance, true), deltaUsed: amount(snapshot?.deltaUsed, true),
40
+ deltaRequests: snapshot?.deltaRequests == null ? '—' : String(snapshot.deltaRequests),
41
+ state: !account.enabled ? '已停用' : account.status?.stale ? '缓存读数' : snapshot ? '已更新' : '尚未查询',
42
+ error: account.status?.error ?? '',
43
+ checkin: labels[account.checkin?.state] ?? account.checkin?.state ?? '尚未签到',
44
+ schedule: !account.schedule?.enabled ? '自动签到未开启' : !account.enabled || !account.checkinEnabled
45
+ ? '自动签到已暂停(账号或签到已停用)' : `每天 ${account.schedule.time} · ${account.schedule.timeZone}`,
46
+ reward: amount(account.checkin?.reward), reset: snapshot?.reset ?? false,
47
+ at: snapshot?.at ? new Date(snapshot.at).toLocaleString('zh-CN') : '—',
48
+ };
49
+ }
50
+
51
+ export function secretIntent(values = {}, valueKey = 'accessToken', clearKey = 'clearAccessToken', existing = false) {
52
+ if (values[clearKey]) return 'clear';
53
+ if (typeof values[valueKey] === 'string' && values[valueKey].length > 0) return 'replace';
54
+ return existing ? 'keep' : 'empty';
55
+ }
56
+
57
+ /** A comparable, normalized draft. Secrets never enter the draft as plaintext. */
58
+ export function normalizedAccountDraft(values = {}, secrets = {}) {
59
+ const text = (value) => value === null || value === undefined ? '' : String(value).trim();
60
+ const number = (value) => {
61
+ if (value === '' || value === null || value === undefined) return null;
62
+ const parsed = Number(value);
63
+ return Number.isFinite(parsed) ? parsed : null;
64
+ };
65
+ return {
66
+ id: text(values.id),
67
+ name: text(values.name),
68
+ siteUrl: text(values.siteUrl),
69
+ userId: text(values.userId),
70
+ authMode: values.authMode === 'cookie' ? 'cookie' : 'token',
71
+ quotaPerUnit: number(values.quotaPerUnit),
72
+ unit: text(values.unit),
73
+ enabled: Boolean(values.enabled),
74
+ checkinEnabled: Boolean(values.checkinEnabled),
75
+ scheduleEnabled: Boolean(values.scheduleEnabled),
76
+ scheduleTime: text(values.scheduleTime),
77
+ scheduleTimeZone: text(values.scheduleTimeZone),
78
+ accessToken: secretIntent(values, 'accessToken', 'clearAccessToken', Boolean(secrets.accessToken)),
79
+ cookie: secretIntent(values, 'cookie', 'clearCookie', Boolean(secrets.cookie)),
80
+ };
81
+ }
82
+
83
+ export function accountDraftDirty(baseline, draft) {
84
+ if (!baseline || !draft) return false;
85
+ return JSON.stringify(baseline) !== JSON.stringify(draft);
86
+ }
87
+
88
+ export function bulkEligibility(accounts = []) {
89
+ const list = Array.isArray(accounts) ? accounts : [];
90
+ const refresh = list.some((account) => account?.enabled === true);
91
+ const checkin = list.some((account) => account?.enabled === true && account?.checkinEnabled === true);
92
+ return {
93
+ refresh,
94
+ checkin,
95
+ refreshReason: refresh ? '' : '没有启用且可刷新的账号',
96
+ checkinReason: checkin ? '' : '没有启用且允许签到的账号',
97
+ };
98
+ }
99
+
100
+ const ACTION_LABELS = Object.freeze({ save: '保存', refresh: '刷新', checkin: '签到', bind: '绑定', remove: '删除', done: '完成并关闭' });
101
+
102
+ export function reconciliationMessage(action) {
103
+ return `已重新读取账号列表;原${ACTION_LABELS[action] ?? '操作'}结果仍需核对,不要直接重复提交。`;
104
+ }
105
+
106
+ export function loadFailureMessage({ loaded = false, at } = {}) {
107
+ if (!loaded) return '首次读取失败:尚未加载任何账号数据。';
108
+ const date = at === undefined ? null : new Date(at);
109
+ const when = date && !Number.isNaN(date.getTime()) ? date.toLocaleString('zh-CN') : '';
110
+ return when
111
+ ? `重新读取失败:仍显示上次成功读取的数据(${when})。`
112
+ : '重新读取失败:仍显示上次成功读取的数据。';
113
+ }
114
+
115
+ export async function requestAccountAction(fetcher, action, payload) {
116
+ let response;
117
+ try {
118
+ response = await fetcher(`./api/accounts/${action}`, {
119
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
120
+ });
121
+ } catch {
122
+ return {
123
+ ok: false, unknown: true,
124
+ error: '操作结果未知:请求可能已提交但未收到确认。请重新读取账号列表确认结果,不要直接重复提交。',
125
+ };
126
+ }
127
+ let data = {};
128
+ let confirmed = true;
129
+ try { data = await response.json(); } catch { data = {}; confirmed = false; }
130
+ if (!response.ok) return { ok: false, unknown: false, error: data.error || '账号操作未完成', fields: data.fields };
131
+ if (!confirmed) {
132
+ return {
133
+ ok: false, unknown: true,
134
+ error: '操作结果未知:服务响应无法确认,请求可能已提交。请重新读取账号列表确认结果,不要直接重复提交。',
135
+ };
136
+ }
137
+ return { ok: true, data };
138
+ }
139
+
140
+ export async function initializeAccounts() {
141
+ const get = (id) => document.getElementById(id);
142
+ const editor = get('account-editor');
143
+ const history = get('account-history');
144
+ const controls = { name: 'account-name', siteUrl: 'site-url', userId: 'user-id', authMode: 'auth-mode', accessToken: 'access-token', cookie: 'account-cookie', 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' };
145
+ const checks = new Set(['enabled', 'checkinEnabled', 'scheduleEnabled', 'clearAccessToken', 'clearCookie']);
146
+ const fieldIds = { name: 'account-name', siteUrl: 'site-url', userId: 'user-id', authMode: 'auth-mode',
147
+ accessToken: 'access-token', cookie: 'account-cookie', quotaPerUnit: 'quota-per-unit', unit: 'unit',
148
+ 'schedule.enabled': 'schedule-enabled', 'schedule.time': 'schedule-time', 'schedule.timeZone': 'schedule-timezone' };
149
+ const actionStatus = { refresh: '正在刷新账号余额…', checkin: '正在签到并刷新余额…',
150
+ bind: '正在更新当前连接绑定…', remove: '正在删除账号…' };
151
+ let accounts = [];
152
+ let binding = null;
153
+ let editing;
154
+ let running = false;
155
+ let unknownOutcome = null;
156
+ let reconciled = false;
157
+ let loading = true;
158
+ let hasLoaded = false;
159
+ let lastLoadedAt = 0;
160
+ let loadErrorShown = false;
161
+ let editorBaseline;
162
+ let allowUnload = false;
163
+
164
+ function element(tag, text, className) {
165
+ const node = document.createElement(tag);
166
+ if (text !== undefined) node.textContent = text;
167
+ if (className) node.className = className;
168
+ return node;
169
+ }
170
+ function error(message, form = false) {
171
+ const target = get(form ? 'account-form-error' : 'accounts-error');
172
+ target.textContent = message; target.hidden = !message;
173
+ if (message) target.focus();
174
+ }
175
+ function clearFieldErrors() {
176
+ for (const node of get('account-form').querySelectorAll('[aria-invalid]')) {
177
+ node.removeAttribute('aria-invalid');
178
+ node.removeAttribute('aria-errormessage');
179
+ }
180
+ get('account-form').querySelectorAll('.account-field-error').forEach(node => node.remove());
181
+ }
182
+ function markFieldErrors(fields = {}) {
183
+ clearFieldErrors();
184
+ let first;
185
+ for (const [key, message] of Object.entries(fields)) {
186
+ const node = get(fieldIds[key]);
187
+ if (!node) continue;
188
+ first ||= node;
189
+ const note = element('p', typeof message === 'string' ? message : '请填写有效的值', 'account-field-error');
190
+ note.id = `${node.id}-error`;
191
+ node.insertAdjacentElement('afterend', note);
192
+ node.setAttribute('aria-invalid', 'true');
193
+ node.setAttribute('aria-errormessage', note.id);
194
+ }
195
+ if (first) { first.scrollIntoView({ block: 'center' }); first.focus({ preventScroll: true }); }
196
+ }
197
+ function fields() {
198
+ const values = { id: editing?.id };
199
+ for (const [key, id] of Object.entries(controls)) values[key] = checks.has(key) ? get(id).checked : get(id).value;
200
+ return values;
201
+ }
202
+ function currentDraft() {
203
+ return normalizedAccountDraft(fields(), editing?.secrets ?? {});
204
+ }
205
+ function isEditorDirty() {
206
+ return Boolean(editor.open && editorBaseline && accountDraftDirty(editorBaseline, currentDraft()));
207
+ }
208
+ function updateEditorDirty() {
209
+ const dirty = isEditorDirty();
210
+ const state = get('editor-dirty-state');
211
+ state.textContent = editor.open
212
+ ? dirty ? '有未保存的修改;关闭或按 Esc 会先确认。' : '没有未保存的修改。'
213
+ : '';
214
+ state.classList.toggle('dirty', dirty);
215
+ }
216
+ function confirmDiscard() {
217
+ return window.confirm('有未保存的账号修改。确定放弃修改并继续吗?选择“取消”可返回继续编辑。');
218
+ }
219
+ function syncControls() {
220
+ const bulk = bulkEligibility(accounts);
221
+ const blocked = running || Boolean(unknownOutcome);
222
+ get('refresh-all').disabled = blocked || loading || !bulk.refresh;
223
+ get('checkin-all').disabled = blocked || loading || !bulk.checkin;
224
+ get('account-new').disabled = blocked;
225
+ get('accounts-done').disabled = blocked;
226
+ get('account-save').disabled = blocked;
227
+ get('editor-close').disabled = running;
228
+ get('account-fields').disabled = running;
229
+ for (const button of get('accounts-table').querySelectorAll('button')) button.disabled = blocked;
230
+ const ack = get('accounts-ack');
231
+ ack.hidden = !(unknownOutcome && reconciled);
232
+ ack.disabled = running;
233
+ const recheck = get('editor-recheck');
234
+ recheck.hidden = unknownOutcome?.action !== 'save' || reconciled;
235
+ recheck.disabled = running || loading;
236
+ const editorAck = get('editor-ack');
237
+ editorAck.hidden = !(unknownOutcome?.action === 'save' && reconciled);
238
+ editorAck.disabled = running;
239
+ const unread = !loading && !hasLoaded ? '账号数据尚未读取,请先重新读取账号列表' : '';
240
+ const reasons = [];
241
+ if (unread) reasons.push(unread);
242
+ else if (!loading) {
243
+ if (!bulk.refresh) reasons.push(bulk.refreshReason);
244
+ if (!bulk.checkin) reasons.push(bulk.checkinReason);
245
+ }
246
+ const reason = get('bulk-reason');
247
+ reason.textContent = reasons.join(';');
248
+ reason.hidden = loading || reasons.length === 0;
249
+ const reasonFor = { 'refresh-all': unread || bulk.refreshReason, 'checkin-all': unread || bulk.checkinReason };
250
+ for (const id of ['refresh-all', 'checkin-all']) {
251
+ const button = get(id);
252
+ const why = reasonFor[id];
253
+ if (!loading && why) {
254
+ button.title = why;
255
+ button.setAttribute('aria-describedby', 'bulk-reason');
256
+ } else {
257
+ button.removeAttribute('title');
258
+ button.removeAttribute('aria-describedby');
259
+ }
260
+ }
261
+ const retry = get('accounts-retry');
262
+ retry.disabled = running || loading;
263
+ if (unknownOutcome) {
264
+ for (const id of ['refresh-all', 'checkin-all', 'account-new', 'accounts-done', 'account-save']) {
265
+ get(id).title = '操作结果未知;请先重新读取账号列表确认。';
266
+ }
267
+ }
268
+ }
269
+ function busy(value) {
270
+ running = value;
271
+ syncControls();
272
+ }
273
+ function confirmUnknownLeave() {
274
+ const label = ACTION_LABELS[unknownOutcome?.action] ?? '操作';
275
+ return window.confirm(`“${label}”结果仍需核对。确定关闭并稍后重新打开页面核对吗?`);
276
+ }
277
+ function acknowledgeUnknown() {
278
+ if (!unknownOutcome || !reconciled || running) return;
279
+ const label = ACTION_LABELS[unknownOutcome.action] ?? '操作';
280
+ if (!window.confirm(`请确认已核对“${label}”结果,并且不会重复提交。继续操作?`)) return;
281
+ unknownOutcome = null;
282
+ reconciled = false;
283
+ error('');
284
+ error('', true);
285
+ get('editor-reconciliation').hidden = true;
286
+ get('accounts-status').textContent = statusSummary();
287
+ syncControls();
288
+ }
289
+ function credentialFields() {
290
+ get('token-field').hidden = get('auth-mode').value !== 'token' && !editing?.secrets.accessToken;
291
+ get('cookie-field').hidden = get('auth-mode').value !== 'cookie' && !editing?.secrets.cookie;
292
+ }
293
+ function scheduleFields() {
294
+ get('schedule-settings').hidden = !get('schedule-enabled').checked;
295
+ }
296
+ function edit(account) {
297
+ editing = account;
298
+ get('editor-reconciliation').hidden = true;
299
+ const values = accountValues(account);
300
+ for (const [key, id] of Object.entries(controls)) {
301
+ if (checks.has(key)) get(id).checked = values[key];
302
+ else get(id).value = values[key];
303
+ }
304
+ get('editor-title').textContent = account ? '编辑站点账号' : '添加站点账号';
305
+ get('token-state').textContent = account?.secrets.accessToken ? '已保存;留空保留原令牌' : '尚未保存系统访问令牌';
306
+ get('cookie-state').textContent = account?.secrets.cookie ? '已保存;留空保留原 Cookie' : '尚未保存 Cookie';
307
+ error('', true);
308
+ clearFieldErrors();
309
+ credentialFields(); scheduleFields();
310
+ editorBaseline = currentDraft();
311
+ editor.showModal();
312
+ updateEditorDirty();
313
+ get('account-name').focus();
314
+ }
315
+ function showHistory(account) {
316
+ get('history-title').textContent = `${account.name} · 额度变化`;
317
+ get('history-list').replaceChildren(...[...(account.history ?? [])].reverse().map((snapshot) => {
318
+ const row = element('tr');
319
+ 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)));
320
+ return row;
321
+ }));
322
+ history.showModal();
323
+ }
324
+ async function perform(action, id) {
325
+ if (running || unknownOutcome) return;
326
+ if (action === 'remove' && !window.confirm('删除这个账号及其本地历史记录?不会注销站点账号。')) return;
327
+ if (action === 'checkin' && !window.confirm(id ? '确认向这个账号的站点提交签到?' : '确认向全部启用且允许签到的账号提交签到?')) return;
328
+ if (action === 'bind' && !window.confirm('把这个站点账号用于当前 Magpie 连接的余额和签到?点击“完成并关闭”后保存绑定,不更改 API key。')) return;
329
+ busy(true); error('');
330
+ get('accounts-status').textContent = actionStatus[action] ?? '正在处理账号…';
331
+ const result = await requestAccountAction(fetch.bind(globalThis), action, id ? { id } : {});
332
+ if (!result.ok && result.unknown) {
333
+ unknownOutcome = { action, id: id ?? null };
334
+ reconciled = false;
335
+ error(result.error);
336
+ get('accounts-status').textContent = `操作结果未知:请求可能已提交。请先重新读取账号列表核对“${ACTION_LABELS[action] ?? '操作'}”结果,不要重复提交。`;
337
+ get('accounts-retry').hidden = false;
338
+ busy(false);
339
+ return;
340
+ }
341
+ if (!result.ok) error(result.error);
342
+ busy(false);
343
+ await load();
344
+ }
345
+ function statusSummary() {
346
+ if (accounts.length === 0) return '尚未添加站点账号。添加后可刷新余额或签到。';
347
+ return `${accounts.length} 个账号,${accounts.filter((account) => account.enabled).length} 个启用。${binding ? `当前连接 ${binding.account};绑定需点击完成后保存。` : '从 Magpie 当前账号的管理入口可绑定余额与签到。'}`;
348
+ }
349
+ function render(data) {
350
+ accounts = Array.isArray(data.accounts) ? data.accounts : [];
351
+ binding = data.binding ?? null;
352
+ const empty = accounts.length === 0;
353
+ get('account-empty').hidden = !empty;
354
+ get('accounts-table-scroll').hidden = empty;
355
+ get('accounts-table').hidden = empty;
356
+ get('accounts-status').textContent = statusSummary();
357
+ get('account-totals').replaceChildren(...Object.entries(data.totals ?? {}).map(([unit, total]) => {
358
+ const node = element('section', undefined, 'unit-total'); node.append(element('h2', `${unit} 账号汇总`));
359
+ const metrics = element('div', undefined, 'total-values');
360
+ for (const [name, value] of [['剩余额度', total.remaining], ['累计消耗', total.used], ['消耗增量', total.deltaUsed]]) {
361
+ const metric = element('div', undefined, 'total-metric'); metric.append(element('small', name), element('strong', formatAmount(value))); metrics.append(metric);
362
+ }
363
+ node.append(metrics, element('p', `${total.accountCount} 个独立身份,${total.staleCount ?? 0} 个缓存/异常读数;${total.availableCount ?? 0} 个已有读数。别名去重,不同单位不合并。`)); return node;
364
+ }));
365
+ get('account-list').replaceChildren(...accounts.map((account) => {
366
+ const row = element('tr'); const view = accountSummary(account);
367
+ const identity = element('td'); identity.append(element('strong', account.name), element('div', `${account.siteUrl} · ID ${account.userId}`, 'detail'), element('span', view.state, `account-state${account.status?.stale ? ' stale' : ''}`));
368
+ if (view.error) identity.append(element('div', view.error, 'detail'));
369
+ if (data.binding?.selected === account.id) identity.append(element('span', '已选为当前连接', 'account-state'));
370
+ const remaining = element('td'); remaining.append(element('div', view.remaining, 'primary-value'), element('div', view.at, 'detail'));
371
+ const used = element('td'); used.append(element('div', view.used, 'primary-value'), element('div', `累计请求 ${account.snapshot?.requestCount ?? '—'}`, 'detail'));
372
+ const delta = element('td'); delta.append(element('div', `消耗 ${view.deltaUsed}`), element('div', `余额 ${view.deltaBalance}`, 'detail'), element('div', `请求 ${view.deltaRequests === '—' ? '—' : `+${view.deltaRequests}`}`, 'detail'));
373
+ if (view.reset) delta.append(element('div', '累计计数回退,已重建基线', 'detail'));
374
+ 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'));
375
+ const actions = element('td'); const bar = element('div', undefined, 'row-actions');
376
+ for (const [label, action] of [['刷新', 'refresh'], ['签到', 'checkin'], ['记录', 'history'], ['编辑', 'edit'], ['删除', 'remove']]) {
377
+ const button = element('button', label, `secondary${action === 'remove' ? ' danger' : ''}`); button.type = 'button';
378
+ button.setAttribute('aria-label', `${label} ${account.name}`);
379
+ button.addEventListener('click', () => action === 'edit' ? edit(account) : action === 'history' ? showHistory(account) : perform(action, account.id));
380
+ bar.append(button);
381
+ }
382
+ if (data.binding) {
383
+ const bind = element('button', data.binding.selected === account.id ? '解除绑定' : '用于当前连接', 'secondary');
384
+ bind.type = 'button';
385
+ bind.addEventListener('click', () => perform('bind', data.binding.selected === account.id ? undefined : account.id));
386
+ bar.append(bind);
387
+ }
388
+ actions.append(bar); row.append(identity, remaining, used, delta, checkin, actions);
389
+ // 窄屏下表格转为卡片,用单元格标签代替隐藏的表头。
390
+ for (const [cell, label] of [[identity, '账号 / 站点'], [remaining, '剩余额度'], [used, '累计消耗'], [delta, '相较上次成功刷新'], [checkin, '签到'], [actions, '操作']]) cell.dataset.label = label;
391
+ return row;
392
+ }));
393
+ syncControls();
394
+ }
395
+ async function load() {
396
+ loading = true; syncControls();
397
+ try {
398
+ const response = await fetch('./api/accounts', { cache: 'no-store' });
399
+ let data = null;
400
+ try { data = await response.json(); } catch { data = null; }
401
+ if (!response.ok || !data || !Array.isArray(data.accounts)) {
402
+ const failure = new Error(data?.error || '无法读取本地账号列表。配置会话可能已超时,请重新打开配置页或检查账号文件权限。');
403
+ failure.safe = true;
404
+ throw failure;
405
+ }
406
+ const hadUnknown = Boolean(unknownOutcome);
407
+ const recovered = loadErrorShown;
408
+ render(data);
409
+ hasLoaded = true;
410
+ lastLoadedAt = Date.now();
411
+ loadErrorShown = false;
412
+ get('accounts-retry').hidden = true;
413
+ if (hadUnknown) {
414
+ reconciled = true;
415
+ const note = `${reconciliationMessage(unknownOutcome.action)} 确认无误后点击“已核对结果,继续操作”。`;
416
+ error('');
417
+ if (unknownOutcome.action === 'save' && editor.open) {
418
+ error(note, true);
419
+ const current = fields();
420
+ const found = accounts.find(account => account.id === unknownOutcome.id)
421
+ || accounts.find(account => account.name === current.name.trim() && account.siteUrl === current.siteUrl.trim() && String(account.userId) === current.userId.trim());
422
+ const summary = get('editor-reconciliation'); summary.hidden = false;
423
+ summary.textContent = found
424
+ ? `本次读到:${found.name} · ${found.siteUrl} · ID ${found.userId} · ${accountSummary(found).schedule}。凭据只显示是否保存,不回显;这次读取不证明原请求已经结束。`
425
+ : '本次列表中未找到匹配账号;原请求仍可能在处理中。请稍后再读,不要直接重复保存。';
426
+ } else get('accounts-status').textContent = note;
427
+ } else if (recovered) {
428
+ error('');
429
+ }
430
+ } catch (cause) {
431
+ loadErrorShown = true;
432
+ get('accounts-status').textContent = loadFailureMessage({ loaded: hasLoaded, at: hasLoaded ? lastLoadedAt : undefined });
433
+ error(cause?.safe && cause.message ? cause.message : '无法读取本地账号列表。配置会话可能已超时,请重新打开配置页或检查账号文件权限。');
434
+ get('accounts-retry').hidden = false;
435
+ if (!hasLoaded) {
436
+ accounts = [];
437
+ get('account-empty').hidden = true;
438
+ get('accounts-table-scroll').hidden = true;
439
+ get('accounts-table').hidden = true;
440
+ get('account-totals').replaceChildren();
441
+ }
442
+ } finally {
443
+ loading = false;
444
+ syncControls();
445
+ }
446
+ }
447
+
448
+ get('account-new').addEventListener('click', () => edit());
449
+ get('editor-close').addEventListener('click', () => {
450
+ if (running) return;
451
+ if (unknownOutcome) {
452
+ if (!confirmUnknownLeave()) return;
453
+ editor.close();
454
+ return;
455
+ }
456
+ if (isEditorDirty() && !confirmDiscard()) return;
457
+ editor.close();
458
+ });
459
+ get('history-close').addEventListener('click', () => history.close());
460
+ get('auth-mode').addEventListener('change', credentialFields);
461
+ get('schedule-enabled').addEventListener('change', scheduleFields);
462
+ get('accounts-retry').addEventListener('click', () => { if (!running && !loading) void load(); });
463
+ get('editor-recheck').addEventListener('click', () => { if (!running && !loading) void load(); });
464
+ get('accounts-ack').addEventListener('click', acknowledgeUnknown);
465
+ get('editor-ack').addEventListener('click', acknowledgeUnknown);
466
+ get('refresh-all').addEventListener('click', () => perform('refresh'));
467
+ get('checkin-all').addEventListener('click', () => perform('checkin'));
468
+ get('account-form').addEventListener('input', (event) => {
469
+ const target = event.target;
470
+ if (target && typeof target.matches === 'function' && target.matches('[aria-invalid]')) {
471
+ target.removeAttribute('aria-invalid');
472
+ target.removeAttribute('aria-errormessage');
473
+ get(`${target.id}-error`)?.remove();
474
+ }
475
+ updateEditorDirty();
476
+ });
477
+ get('account-form').addEventListener('change', updateEditorDirty);
478
+ editor.addEventListener('cancel', (event) => {
479
+ if (running) {
480
+ event.preventDefault();
481
+ error('正在处理,完成后才能关闭账号编辑。', true);
482
+ return;
483
+ }
484
+ if (unknownOutcome) {
485
+ if (!confirmUnknownLeave()) event.preventDefault();
486
+ return;
487
+ }
488
+ if (isEditorDirty() && !confirmDiscard()) event.preventDefault();
489
+ });
490
+ editor.addEventListener('close', () => {
491
+ editing = undefined;
492
+ editorBaseline = undefined;
493
+ get('access-token').value = '';
494
+ get('account-cookie').value = '';
495
+ clearFieldErrors();
496
+ error('', true);
497
+ updateEditorDirty();
498
+ syncControls();
499
+ });
500
+ document.addEventListener('click', (event) => {
501
+ const link = event.target && typeof event.target.closest === 'function' ? event.target.closest('a[href]') : null;
502
+ if (!link) return;
503
+ if (running) {
504
+ event.preventDefault();
505
+ get('accounts-status').textContent = '操作进行中,完成后才能离开此页面。';
506
+ return;
507
+ }
508
+ if (unknownOutcome) {
509
+ if (!confirmUnknownLeave()) { event.preventDefault(); return; }
510
+ allowUnload = true;
511
+ window.setTimeout(() => { allowUnload = false; }, 1000);
512
+ return;
513
+ }
514
+ if (isEditorDirty()) {
515
+ if (!confirmDiscard()) { event.preventDefault(); return; }
516
+ allowUnload = true;
517
+ window.setTimeout(() => { allowUnload = false; }, 1000);
518
+ }
519
+ }, true);
520
+ window.addEventListener('beforeunload', (event) => {
521
+ if (allowUnload || (!running && !unknownOutcome && !isEditorDirty())) return;
522
+ event.preventDefault();
523
+ event.returnValue = '';
524
+ });
525
+ get('account-form').addEventListener('submit', async (event) => {
526
+ event.preventDefault();
527
+ if (running || unknownOutcome) return;
528
+ const values = fields();
529
+ const missing = {};
530
+ if (!values.name.trim()) missing.name = true;
531
+ if (!values.siteUrl.trim()) missing.siteUrl = true;
532
+ if (!/^[1-9]\d*$/.test(values.userId)) missing.userId = true;
533
+ if (Object.keys(missing).length) {
534
+ error('请填写账号名称、站点地址与正整数用户 ID', true);
535
+ markFieldErrors(missing);
536
+ return;
537
+ }
538
+ busy(true); error('', true); clearFieldErrors();
539
+ const result = await requestAccountAction(fetch.bind(globalThis), 'save', accountPayload(values));
540
+ if (result.ok) {
541
+ editor.close();
542
+ await load();
543
+ busy(false);
544
+ return;
545
+ }
546
+ error(result.fields ? Object.values(result.fields).join(';') : result.error, true);
547
+ if (result.fields) markFieldErrors(result.fields);
548
+ if (result.unknown) {
549
+ unknownOutcome = { action: 'save', id: editing?.id ?? null };
550
+ reconciled = false;
551
+ error(`${result.error} 请点击“重新读取账号列表”核对结果,不要直接重复保存。`, true);
552
+ get('accounts-retry').hidden = false;
553
+ }
554
+ busy(false);
555
+ });
556
+ get('accounts-done').addEventListener('click', async () => {
557
+ if (running || unknownOutcome) return;
558
+ busy(true); error('');
559
+ get('accounts-status').textContent = '正在完成并关闭…';
560
+ const result = await requestAccountAction(fetch.bind(globalThis), 'done', {});
561
+ if (result.ok) {
562
+ busy(false);
563
+ allowUnload = true;
564
+ get('accounts-shell').hidden = true;
565
+ get('accounts-completion').hidden = false;
566
+ setTimeout(() => { try { window.close(); } catch {} }, 250);
567
+ return;
568
+ }
569
+ error(result.error);
570
+ if (result.unknown) {
571
+ unknownOutcome = { action: 'done', id: null };
572
+ reconciled = false;
573
+ get('accounts-status').textContent = '关闭结果未知:配置可能已经结束。请重新读取账号列表核对,确认后再关闭页面。';
574
+ get('accounts-retry').hidden = false;
575
+ }
576
+ busy(false);
577
+ });
578
+ busy(false);
579
+ await load();
580
+ }
581
+
582
+ if (typeof document !== 'undefined') void initializeAccounts();