device-center 0.2.0-beta.2 → 0.2.0-beta.4

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.
@@ -20,6 +20,8 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
20
20
  }
21
21
  db.exec('CREATE INDEX IF NOT EXISTS cloud_drivers_tenant_created ON cloud_drivers(tenant_id, created_at)');
22
22
  for (const table of ['driver_pairings', 'cloud_drivers']) if (!db.prepare(`PRAGMA table_info(${table})`).all().some(c => c.name === 'certificate')) db.exec(`ALTER TABLE ${table} ADD COLUMN certificate TEXT`);
23
+ if (!db.prepare('PRAGMA table_info(driver_pairings)').all().some(c => c.name === 'browser_hash')) db.exec('ALTER TABLE driver_pairings ADD COLUMN browser_hash TEXT');
24
+ db.exec('CREATE UNIQUE INDEX IF NOT EXISTS driver_pairings_browser ON driver_pairings(browser_hash)');
23
25
  if (!db.prepare('PRAGMA table_info(cloud_drivers)').all().some(c => c.name === 'control_seen_at')) db.exec('ALTER TABLE cloud_drivers ADD COLUMN control_seen_at INTEGER');
24
26
  db.exec(`CREATE TABLE IF NOT EXISTS driver_collection_requests (driver_id TEXT PRIMARY KEY, request_id TEXT NOT NULL,
25
27
  requested_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, completed_at INTEGER)`);
@@ -43,6 +45,13 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
43
45
  if (!row || row.expires_at <= now()) fail('driver_pairing_expired', 410);
44
46
  return row;
45
47
  }
48
+ function credential(input) {
49
+ if (input.browserTicket !== undefined) {
50
+ if (input.code !== undefined || typeof input.browserTicket !== 'string' || !/^[a-f0-9]{64}$/.test(input.browserTicket)) fail('invalid_pairing_code');
51
+ return { column: 'browser_hash', hash: publicCodeHash(input.browserTicket) };
52
+ }
53
+ return { column: 'code_hash', hash: hashCode(input.code) };
54
+ }
46
55
  function proof(row, path, input) {
47
56
  verifyRequest(row.public_key, origin, path, input, now());
48
57
  db.prepare('DELETE FROM driver_nonces WHERE expires_at<=?').run(now());
@@ -76,7 +85,8 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
76
85
  const registry = {
77
86
  start(input) {
78
87
  starts = limited(starts, 8);
79
- object(input, ['publicKey', 'name', 'platform', 'shareSsh', 'certificate']);
88
+ object(input, ['publicKey', 'name', 'platform', 'shareSsh', 'certificate', 'browser']);
89
+ if (input.browser !== undefined && typeof input.browser !== 'boolean') fail('invalid_driver_scope');
80
90
  const identity = publicIdentity(input.publicKey);
81
91
  const certificate = input.certificate == null ? null : validateCertificate(input.certificate, identity.fingerprint, now());
82
92
  const name = text(input.name), platform = text(input.platform, 64);
@@ -87,6 +97,11 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
87
97
  if (db.prepare('SELECT count(*) AS n FROM driver_pairings').get().n >= 32) fail('driver_pairing_busy', 429);
88
98
  const id = randomUUID(), raw = randomBytes(4).toString('hex').toUpperCase(), code = `${raw.slice(0,4)}-${raw.slice(4)}`;
89
99
  db.prepare('INSERT INTO driver_pairings (id,code_hash,public_key,fingerprint,name,platform,requested_ssh,created_at,expires_at,certificate) VALUES (?,?,?,?,?,?,?,?,?,?)').run(id, hashCode(code), identity.publicKey, identity.fingerprint, name, platform, Number(input.shareSsh), now(), now() + PAIR_AGE, certificate);
100
+ if (input.browser) {
101
+ const browserTicket = randomBytes(32).toString('hex');
102
+ db.prepare('UPDATE driver_pairings SET browser_hash=? WHERE id=?').run(publicCodeHash(browserTicket), id);
103
+ return { id, browserTicket, fingerprint: identity.fingerprint, expiresAt: now() + PAIR_AGE, pollIntervalMs: 5000 };
104
+ }
90
105
  return { id, code, fingerprint: identity.fingerprint, expiresAt: now() + PAIR_AGE, verificationUrl: origin, pollIntervalMs: 5000 };
91
106
  },
92
107
  poll(input) {
@@ -102,24 +117,26 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
102
117
  },
103
118
  lookup(input, tenantId) {
104
119
  globalLookups = limited(globalLookups, 64);
105
- limitAccount(lookups, tenantId); object(input, ['code']);
106
- const row = db.prepare('SELECT * FROM driver_pairings WHERE code_hash=?').get(hashCode(input.code));
120
+ limitAccount(lookups, tenantId); object(input, ['code', 'browserTicket']);
121
+ const proof = credential(input);
122
+ const row = db.prepare(`SELECT * FROM driver_pairings WHERE ${proof.column}=?`).get(proof.hash);
107
123
  if (!row || row.expires_at <= now() || row.driver_id) fail('driver_pairing_unavailable', 404);
108
124
  return { id: row.id, name: row.name, platform: row.platform, fingerprint: row.fingerprint, requestedSsh: Boolean(row.requested_ssh), expiresAt: row.expires_at };
109
125
  },
110
126
  approve(input, tenantId) {
111
127
  globalApprovals = limited(globalApprovals, 64); limitAccount(approvals, tenantId);
112
- object(input, ['id', 'code', 'fingerprint', 'shareSsh']);
128
+ object(input, ['id', 'code', 'browserTicket', 'fingerprint', 'shareSsh']);
113
129
  if (typeof input.id !== 'string' || typeof input.shareSsh !== 'boolean') fail('invalid_driver_approval');
114
- if (multiUser && (typeof input.code !== 'string' || typeof input.fingerprint !== 'string')) fail('driver_pairing_confirmation_required');
130
+ if ((multiUser || input.browserTicket !== undefined) && ((typeof input.code !== 'string' && typeof input.browserTicket !== 'string') || typeof input.fingerprint !== 'string')) fail('driver_pairing_confirmation_required');
115
131
  db.exec('BEGIN IMMEDIATE');
116
132
  try {
117
133
  const row = pairing(input.id);
134
+ if (row.browser_hash != null && ((input.code === undefined && input.browserTicket === undefined) || typeof input.fingerprint !== 'string')) fail('driver_pairing_confirmation_required');
118
135
  if (row.driver_id) {
119
136
  if (!db.prepare('SELECT id FROM cloud_drivers WHERE id=? AND tenant_id=?').get(row.driver_id, tenantId)) fail('driver_pairing_unavailable', 404);
120
137
  fail('driver_pairing_consumed', 409);
121
138
  }
122
- if (input.code !== undefined && hashCode(input.code) !== row.code_hash) fail('driver_pairing_unavailable', 404);
139
+ if (input.code !== undefined || input.browserTicket !== undefined) { const proof = credential(input); if (proof.hash !== row[proof.column]) fail('driver_pairing_unavailable', 404); }
123
140
  if (input.fingerprint !== undefined && input.fingerprint !== row.fingerprint) fail('driver_pairing_confirmation_required');
124
141
  if (input.shareSsh && !row.requested_ssh) fail('driver_scope_violation');
125
142
  if (db.prepare('SELECT count(*) AS n FROM cloud_drivers WHERE tenant_id=? AND revoked_at IS NULL').get(tenantId).n >= 16) fail('driver_limit', 409);
package/server/index.mjs CHANGED
@@ -42,7 +42,9 @@ async function readJson(request) {
42
42
  async function serveStatic(pathname, response) {
43
43
  let filePath;
44
44
  if (pathname === '/' || pathname === '/index.html') filePath = resolve(PROJECT_ROOT, 'index.html');
45
+ else if (pathname === '/favicon.svg') filePath = resolve(PROJECT_ROOT, 'favicon.svg');
45
46
  else if (pathname === '/login') filePath = resolve(PROJECT_ROOT, 'login.html');
47
+ else if (pathname === '/pair') filePath = resolve(PROJECT_ROOT, 'login.html');
46
48
  else if (pathname === '/start' || pathname === '/start.html') filePath = resolve(PROJECT_ROOT, 'start.html');
47
49
  else if (pathname.startsWith('/src/')) {
48
50
  filePath = resolve(PROJECT_ROOT, `.${pathname}`);
@@ -81,8 +83,9 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
81
83
  const origin = cloudAccess ? cloudAccess.validate(request) : validateLocalRequest(request);
82
84
  const url = new URL(request.url ?? '/', origin);
83
85
  const path = url.pathname;
86
+ if (['/pair', '/src/pair.js', '/src/pair.css', '/src/pairing-handoff.js', '/src/api.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
84
87
  // Public static installation reference only. No identity, configuration or owner API.
85
- if (['/start', '/start.html', '/src/start.js', '/src/start.css', '/src/onboarding.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
88
+ if (['/start', '/start.html', '/favicon.svg', '/src/start.js', '/src/start.css', '/src/onboarding.js', '/src/i18n.js', '/src/i18n.css', '/src/messages.js', '/src/install-prompt.js', '/src/install-walkthrough.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
86
89
  if (Object.hasOwn(PUBLIC_GUIDES, path) && request.method === 'GET') {
87
90
  const body = await readFile(resolve(PROJECT_ROOT, PUBLIC_GUIDES[path]));
88
91
  response.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'content-security-policy': "default-src 'none'; frame-ancestors 'none'", 'referrer-policy': 'no-referrer' });
@@ -115,7 +118,7 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
115
118
  }
116
119
  const principal = await cloudAccess.resolvePrincipal(request);
117
120
  if (!principal) {
118
- if (path === '/' && request.method === 'GET') { response.writeHead(303, { location: '/login', 'cache-control': 'no-store' }); return response.end(); }
121
+ if (path === '/' && request.method === 'GET') { response.writeHead(303, { location: `/login${/^(zh-CN|zh-TW|en|ja|ko|es|fr|de|pt-BR)$/.test(url.searchParams.get('lang') ?? '') ? '?lang=' + url.searchParams.get('lang') : ''}`, 'cache-control': 'no-store' }); return response.end(); }
119
122
  return sendJson(response, 401, { error: 'authentication_required' });
120
123
  }
121
124
  accountRegistry = registry.forTenant(principal.tenantId);
package/src/api.js CHANGED
@@ -1,11 +1,14 @@
1
+ import { getLocale } from './i18n.js';
1
2
  async function request(path, { timeoutMs = 12000, ...options } = {}) {
2
3
  const response = await fetch(path, { ...options, cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
3
4
  const payload = await response.json().catch(() => ({}));
4
- if (response.status === 401) { location.replace('/login'); throw new Error('authentication_required'); }
5
+ if (response.status === 401) { location.replace('/login?lang=' + getLocale()); throw new Error('authentication_required'); }
5
6
  if (!response.ok) throw new Error(payload.error ?? `http_${response.status}`);
6
7
  return payload;
7
8
  }
8
9
  export const api = {
10
+ lookupBrowserPairing: browserTicket => request('/api/drivers/pairing/lookup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ browserTicket }) }),
11
+ approveBrowserPairing: input => request('/api/drivers/pairing/approve', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) }),
9
12
  saveDeviceProfile: (profile) => request('/api/devices/profile', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(profile) }),
10
13
  collectDevice: (deviceId) => request('/api/devices/collect', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ deviceId }), timeoutMs: 30000 }),
11
14
  remotePeers: () => request('/api/remote/peers'),
@@ -1,3 +1,4 @@
1
+ import { t } from './i18n.js';
1
2
  // Expire displayed SSH snapshots without a network request or a background SSH check.
2
3
  export function expireSshSnapshots(overview, now = Date.now()) {
3
4
  const expired = device => device.state === 'online' && (device.transport === 'ssh' && now - device.lastHeartbeat > 300_000 || device.source === 'driver-report' && (now - device.managementDriver.lastSeen > 90_000 || now - device.lastHeartbeat > (device.kind === 'management' ? 90_000 : 300_000)));
@@ -8,21 +9,21 @@ export function expireSshSnapshots(overview, now = Date.now()) {
8
9
  return { ...overview, devices, stats };
9
10
  }
10
11
 
11
- export const DEVICE_TYPE_LABELS = { unspecified: '未分类', personal: '个人电脑', server: '服务器', workstation: '工作站', other: '其他' };
12
+ export const DEVICE_TYPE_LABELS = { get unspecified() { return t("panel.498"); }, get personal() { return t("panel.499"); }, get server() { return t("panel.500"); }, get workstation() { return t("panel.501"); }, get other() { return t("panel.502"); } };
12
13
  export function deviceStatus(device, now = Date.now()) {
13
- if (device.state === 'unknown') return { label: '未知', title: '尚未取得资源数据;未把设备视为已连接。' };
14
- if (device.source === 'driver-report' && (device.managementDriver.lastSeen == null || now - device.managementDriver.lastSeen > 90_000)) return { label: '未回报', title: '超过 90 秒未收到 Driver 回报。请确认设备已唤醒、Driver 正在运行且能连接云端。旧数据保留,不表示密钥过期。' };
15
- if (device.state === 'expired') return { label: '待采集', title: '上次资源快照较早,点击采集一次取得新数据;不表示设备断线或密钥过期。' };
16
- return { label: '在线', title: device.transport === 'ssh' ? '最近一次 SSH 检查成功,不表示持续连接。' : '最近收到真实资源数据。' };
14
+ if (device.state === 'unknown') return { label: t("panel.457"), title: t("panel.503") };
15
+ if (device.source === 'driver-report' && (device.managementDriver.lastSeen == null || now - device.managementDriver.lastSeen > 90_000)) return { label: t("panel.504"), title: t("panel.505") };
16
+ if (device.state === 'expired') return { label: t("panel.506"), title: t("panel.507") };
17
+ return { label: t("panel.456"), title: device.transport === 'ssh' ? t("panel.508") : t("panel.509") };
17
18
  }
18
19
  export function collectionAvailability(device, now = Date.now()) {
19
20
  if (device.source === 'driver-report') {
20
- if (device.collection?.status === 'pending' && device.collection.expiresAt > now) return { available: false, pending: true, hint: '等待设备采集…' };
21
- if (device.kind !== 'management') return { available: false, hint: '请在管理电脑检查 SSH' };
22
- if (device.managementDriver.lastSeen == null || now - device.managementDriver.lastSeen > 90_000) return { available: false, hint: '请启动设备上的 Driver' };
23
- if (!device.collection?.supported) return { available: false, hint: device.collection?.reason === 'driver_control_unavailable' ? '采集通道暂不可用' : '需更新设备 Driver' };
24
- return { available: true, hint: '通知 Driver 重新采集内存与磁盘;不执行远程命令。' };
21
+ if (device.collection?.status === 'pending' && device.collection.expiresAt > now) return { available: false, pending: true, hint: t("panel.41") };
22
+ if (device.kind !== 'management') return { available: false, hint: t("panel.510") };
23
+ if (device.managementDriver.lastSeen == null || now - device.managementDriver.lastSeen > 90_000) return { available: false, hint: t("panel.511") };
24
+ if (!device.collection?.supported) return { available: false, hint: device.collection?.reason === 'driver_control_unavailable' ? t("panel.512") : t("panel.513") };
25
+ return { available: true, hint: t("panel.514") };
25
26
  }
26
- if (device.transport === 'ssh') return { available: Boolean(device.ssh?.checkAvailable && !device.ssh.checking && !device.ssh.queued), hint: '使用当前 SSH 配置进行一次只读检查并采集。' };
27
- return { available: device.source === 'local-runtime' && device.scope === 'host', hint: '从当前本机进程重新读取内存与磁盘。' };
27
+ if (device.transport === 'ssh') return { available: Boolean(device.ssh?.checkAvailable && !device.ssh.checking && !device.ssh.queued), hint: t("panel.515") };
28
+ return { available: device.source === 'local-runtime' && device.scope === 'host', hint: t("panel.516") };
28
29
  }
package/src/i18n.css ADDED
@@ -0,0 +1,61 @@
1
+ .header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 20px; flex-wrap: wrap; }
2
+ .language-picker { display: inline-flex; flex: 0 0 auto; min-width: 0; color: #344d3e; font-size: 12px; }
3
+ .language-picker button { font-family: inherit; cursor: pointer; filter: none; }
4
+ .language-picker .language-trigger { display: inline-flex; align-items: center; justify-content: center; gap: 7px; width: auto; height: 34px; min-height: 34px; margin: 0; padding: 0 9px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: inherit; font-size: 12px; font-weight: 500; line-height: 1; white-space: nowrap; transition: background .15s, border-color .15s; }
5
+ .language-trigger > svg { width: 16px; height: 16px; flex-shrink: 0; color: #758375; }
6
+ .language-trigger > .language-chevron { width: 12px; height: 12px; transition: transform .15s; }
7
+ .language-trigger[aria-expanded="true"] > .language-chevron { transform: rotate(180deg); }
8
+ .language-picker .language-trigger:hover, .language-trigger[aria-expanded="true"] { background: #eaf0e6; border-color: #dde6d8; }
9
+ .language-picker button:focus-visible { outline: 2px solid #5c965d; outline-offset: 2px; }
10
+ .language-menu { position: fixed; z-index: 40; width: 304px; max-width: calc(100vw - 24px); max-height: calc(100dvh - 32px); overflow: auto; padding: 8px; border: 1px solid #e0e7dc; border-radius: 13px; background: #fff; color: #344d3e; box-shadow: 0 12px 36px #2035241a, 0 2px 6px #20352409; }
11
+ .language-menu[hidden] { display: none; }
12
+ .language-menu-title { padding: 7px 9px 10px; color: #84907f; font-size: 11px; font-weight: 500; line-height: 1.3; }
13
+ .language-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px; }
14
+ .language-picker .language-option { display: flex; align-items: center; justify-content: space-between; gap: 5px; width: 100%; height: auto; min-height: 40px; padding: 8px 9px; margin: 0; border: 0; border-radius: 7px; background: transparent; color: #475449; text-align: left; font-size: 12px; font-weight: 400; line-height: 1.4; }
15
+ .language-option > span { min-width: 0; }
16
+ .language-option > svg { width: 14px; height: 14px; flex-shrink: 0; opacity: 0; color: #356c4c; }
17
+ .language-picker .language-option:hover, .language-picker .language-option:focus-visible { background: #f2f5ef; }
18
+ .language-picker .language-option[aria-checked="true"] { background: #eaf2e5; color: #2f6545; font-weight: 600; }
19
+ .language-option[aria-checked="true"] > svg { opacity: 1; }
20
+ .topbar { position: relative; z-index: 4; }
21
+ .login-page main { position: relative; }
22
+ .login-page main > .language-picker { position: absolute; top: 36px; right: 24px; }
23
+ @media (max-width: 560px) { .header-actions { gap: 8px; } }
24
+ @media (prefers-reduced-motion: reduce) { .language-trigger, .language-chevron { transition: none !important; } }
25
+ /* Longer translations must wrap without moving the sidebar or top bar. */
26
+ .modal-heading { gap: 12px; flex-wrap: wrap; }
27
+ .modal-heading h2 { flex: 1 1 140px; min-width: 0; }
28
+ .modal-heading .language-picker { margin-left: auto; }
29
+ .nav-item svg { flex-shrink: 0; }
30
+ .nav-item > span { min-width: 0; overflow-wrap: anywhere; }
31
+ .resource-top { flex-wrap: wrap; }
32
+ .resource-top > span { overflow-wrap: anywhere; white-space: normal; }
33
+ @media (max-width: 760px) {
34
+ .topbar .environment-badge { display: none; }
35
+ .topbar .header-actions { flex-wrap: nowrap; gap: 0; }
36
+ .topbar > span { min-width: 0; }
37
+ .page-heading { flex-wrap: wrap; align-items: flex-start; gap: 8px; }
38
+ .page-heading > div:first-child { width: 100%; min-width: 0; }
39
+ .page-heading h1 { overflow-wrap: anywhere; line-height: 1.25; }
40
+ .heading-actions { max-width: none; width: 100%; justify-content: flex-start; }
41
+ .heading-actions > button { white-space: normal; max-width: 100%; }
42
+ .driver-summary { flex-wrap: wrap; }
43
+ .driver-summary p { flex-basis: 100%; margin-left: 12px; }
44
+ .initialize-entry { margin-left: auto; }
45
+ .filter-tabs { flex-wrap: wrap; }
46
+ .page-footer { gap: 8px; flex-wrap: wrap; }
47
+ .method-tabs { flex-wrap: wrap; }
48
+ .method-tabs button { white-space: normal; min-width: 0; }
49
+ .start-page header { flex-wrap: wrap; }
50
+ }
51
+
52
+ @media (max-width: 440px) {
53
+ .nav-item svg { display: none; }
54
+ .nav-item > span { overflow-wrap: normal; hyphens: auto; }
55
+ }
56
+ @media (max-width: 560px) {
57
+ .modal-heading { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
58
+ .modal-heading h2 { grid-column: 1; grid-row: 1; }
59
+ .modal-heading .icon-button { grid-column: 2; grid-row: 1; }
60
+ .modal-heading .language-picker { grid-column: 1 / -1; grid-row: 2; margin-left: 0; }
61
+ }
package/src/i18n.js ADDED
@@ -0,0 +1,174 @@
1
+ import { messages } from './messages.js';
2
+
3
+ export const LANGUAGES = Object.freeze([
4
+ { code: 'zh-CN', name: '简体中文' }, { code: 'zh-TW', name: '繁體中文' },
5
+ { code: 'en', name: 'English' }, { code: 'ja', name: '日本語' },
6
+ { code: 'ko', name: '한국어' }, { code: 'es', name: 'Español' },
7
+ { code: 'fr', name: 'Français' }, { code: 'de', name: 'Deutsch' },
8
+ { code: 'pt-BR', name: 'Português (Brasil)' },
9
+ ]);
10
+ const STORAGE_KEY = 'device-center.locale';
11
+ export function normalizeLocale(value) {
12
+ if (typeof value !== 'string') return null;
13
+ const tag = value.replaceAll('_', '-').toLowerCase();
14
+ if (/^zh(?:-|$)/.test(tag)) return /(?:hant|tw|hk|mo)(?:-|$)/.test(tag) ? 'zh-TW' : 'zh-CN';
15
+ if (/^pt(?:-|$)/.test(tag)) return 'pt-BR';
16
+ return LANGUAGES.find(item => item.code === tag.split('-')[0])?.code ?? null;
17
+ }
18
+ export function resolveLocale({ query, saved, languages = [] } = {}) {
19
+ return normalizeLocale(query) ?? normalizeLocale(saved) ?? languages.map(normalizeLocale).find(Boolean) ?? 'en';
20
+ }
21
+ export function translate(locale, key, values = {}) {
22
+ const code = normalizeLocale(locale) ?? 'en';
23
+ const entry = lang => Object.hasOwn(messages[lang], key) ? messages[lang][key] : undefined;
24
+ const text = entry(code) ?? entry('en') ?? entry('zh-CN') ?? key;
25
+ return text.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, name) => Object.hasOwn(values, name) ? String(values[name]) : match);
26
+ }
27
+ let locale = 'zh-CN';
28
+ export const getLocale = () => locale;
29
+ export const t = (key, values) => translate(locale, key, values);
30
+ export const h = (key, values) => t(key, values).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
31
+ let pickerSequence = 0;
32
+ const globe = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a17 17 0 0 1 0 18 17 17 0 0 1 0-18Z"/></svg>';
33
+ const chevron = '<svg class="language-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg>';
34
+ function pickerContents() {
35
+ const id = `language-menu-${++pickerSequence}`;
36
+ const current = LANGUAGES.find(item => item.code === locale);
37
+ return `<button type="button" class="language-trigger" data-language-trigger aria-label="${h('language')}: ${current.name}" aria-haspopup="menu" aria-expanded="false" aria-controls="${id}">${globe}<span>${current.code === 'pt-BR' ? 'Português' : current.name}</span>${chevron}</button>
38
+ <div class="language-menu" id="${id}" data-language-menu role="menu" aria-label="${h('language')}" hidden><div class="language-menu-title" aria-hidden="true">${h('language')}</div><div class="language-options" role="none">${LANGUAGES.map(item => `<button type="button" class="language-option" data-language-option="${item.code}" role="menuitemradio" aria-checked="${locale === item.code}" tabindex="-1" lang="${item.code}"><span>${item.name}</span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="m3 8 3 3 7-7"/></svg></button>`).join('')}</div></div>`;
39
+ }
40
+ export function languagePickerMarkup() {
41
+ return `<div class="language-picker" data-language-picker>${pickerContents()}</div>`;
42
+ }
43
+ export function applyTranslations(root = document) {
44
+ for (const element of root.querySelectorAll('[data-i18n]')) element.textContent = t(element.dataset.i18n);
45
+ for (const attribute of ['placeholder', 'title', 'aria-label', 'content']) {
46
+ for (const element of root.querySelectorAll(`[data-i18n-${attribute}]`)) element.setAttribute(attribute, t(element.getAttribute(`data-i18n-${attribute}`)));
47
+ }
48
+ }
49
+ // Only explicit UI markers are translated. User names, tags, code and paths are untouched.
50
+ export function initLanguage({ onChange = () => {} } = {}) {
51
+ let saved;
52
+ try { saved = localStorage.getItem(STORAGE_KEY); } catch { /* Private mode can deny storage. */ }
53
+ const params = new URLSearchParams(location.search);
54
+ locale = resolveLocale({ query: params.get('lang'), saved, languages: navigator.languages ?? [navigator.language] });
55
+ if (normalizeLocale(params.get('lang'))) {
56
+ try { localStorage.setItem(STORAGE_KEY, locale); } catch { /* Share links also work without storage. */ }
57
+ }
58
+ const apply = () => {
59
+ document.documentElement.lang = locale;
60
+ document.documentElement.dir = 'ltr';
61
+ applyTranslations();
62
+ for (const picker of document.querySelectorAll('[data-language-picker]')) picker.innerHTML = pickerContents();
63
+ // Language links stay on this origin and never include account/pairing information.
64
+ for (const link of document.querySelectorAll('a[data-language-link]')) {
65
+ const url = new URL(link.getAttribute('href'), location.origin);
66
+ if (url.origin === location.origin) { url.searchParams.set('lang', locale); link.setAttribute('href', `${url.pathname}${url.search}${url.hash}`); }
67
+ }
68
+ onChange(locale);
69
+ };
70
+ const closeMenus = (restoreFocus = false) => {
71
+ for (const picker of document.querySelectorAll('[data-language-picker]')) {
72
+ const trigger = picker.querySelector('[data-language-trigger]');
73
+ const menu = picker.querySelector('[data-language-menu]');
74
+ if (!menu || menu.hidden) continue;
75
+ menu.hidden = true; trigger.setAttribute('aria-expanded', 'false');
76
+ if (restoreFocus) trigger.focus({ preventScroll: true });
77
+ }
78
+ };
79
+ const openMenu = (picker, last = false) => {
80
+ closeMenus();
81
+ const trigger = picker.querySelector('[data-language-trigger]');
82
+ const menu = picker.querySelector('[data-language-menu]');
83
+ menu.hidden = false; trigger.setAttribute('aria-expanded', 'true');
84
+ const anchor = trigger.getBoundingClientRect();
85
+ const size = menu.getBoundingClientRect();
86
+ menu.style.left = `${Math.max(12, Math.min(anchor.right - size.width, innerWidth - size.width - 12))}px`;
87
+ const top = anchor.bottom + 8;
88
+ menu.style.top = `${Math.max(12, top + size.height <= innerHeight - 12 ? top : anchor.top - size.height - 8)}px`;
89
+ const options = [...menu.querySelectorAll('[data-language-option]')];
90
+ (last ? options.at(-1) : menu.querySelector('[aria-checked="true"]') ?? options[0])?.focus({ preventScroll: true });
91
+ };
92
+ const choose = (value, picker) => {
93
+ const next = normalizeLocale(value); if (!next) return;
94
+ const inDialog = Boolean(picker.closest('[role="dialog"]'));
95
+ closeMenus(true);
96
+ locale = next;
97
+ try { localStorage.setItem(STORAGE_KEY, locale); } catch { /* Selection still works in memory. */ }
98
+ const url = new URL(location.href); url.searchParams.set('lang', locale);
99
+ try { history.replaceState(null, '', url); } catch { /* Do not prevent language selection. */ }
100
+ apply();
101
+ const container = picker.isConnected ? picker : document.querySelector(inDialog ? '[role="dialog"]' : '.topbar') ?? document;
102
+ container.querySelector('[data-language-trigger]')?.focus({ preventScroll: true });
103
+ };
104
+ // Capture events before the page's dialog handlers; Escape closes this menu first.
105
+ document.addEventListener('click', event => {
106
+ const trigger = event.target.closest('[data-language-trigger]');
107
+ const option = event.target.closest('[data-language-option]');
108
+ if (trigger) {
109
+ event.stopPropagation();
110
+ if (trigger.getAttribute('aria-expanded') === 'true') closeMenus(true);
111
+ else openMenu(trigger.closest('[data-language-picker]'));
112
+ } else if (option) {
113
+ event.stopPropagation(); choose(option.dataset.languageOption, option.closest('[data-language-picker]'));
114
+ } else if (!event.target.closest('[data-language-menu]')) closeMenus();
115
+ }, true);
116
+ document.addEventListener('keydown', event => {
117
+ const picker = event.target.closest('[data-language-picker]');
118
+ if (!picker) return;
119
+ const menu = picker.querySelector('[data-language-menu]');
120
+ if (menu.hidden) {
121
+ if (event.target.matches('[data-language-trigger]') && ['ArrowDown', 'ArrowUp'].includes(event.key)) {
122
+ event.preventDefault(); event.stopPropagation(); openMenu(picker, event.key === 'ArrowUp');
123
+ }
124
+ return;
125
+ }
126
+ if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeMenus(true); return; }
127
+ const options = [...menu.querySelectorAll('[data-language-option]')];
128
+ const index = options.indexOf(document.activeElement);
129
+ let next;
130
+ if (event.key === 'Home') next = 0;
131
+ else if (event.key === 'End') next = options.length - 1;
132
+ else if (event.key === 'ArrowDown') next = (index + 2) % options.length;
133
+ else if (event.key === 'ArrowUp') next = (index - 2 + options.length) % options.length;
134
+ else if (event.key === 'ArrowRight') next = (index + 1) % options.length;
135
+ else if (event.key === 'ArrowLeft') next = (index - 1 + options.length) % options.length;
136
+ if (next !== undefined) { event.preventDefault(); event.stopPropagation(); options[next].focus(); }
137
+ if (event.key === 'Tab') closeMenus(true);
138
+ }, true);
139
+ document.addEventListener('focusout', event => {
140
+ const picker = event.target.closest('[data-language-picker]');
141
+ if (picker?.isConnected && !picker.contains(event.relatedTarget)) closeMenus();
142
+ });
143
+ document.addEventListener('scroll', event => { if (!event.target.closest?.('[data-language-menu]')) closeMenus(); }, true);
144
+ window.addEventListener('resize', () => closeMenus());
145
+ apply();
146
+ return locale;
147
+ }
148
+
149
+ // Compatibility for server-owned diagnostics only; never pass device names or user input.
150
+ const REPORT_MESSAGE_KEYS = {
151
+ "容器不能代表宿主内存": "metric.containerMemory",
152
+ "容器不能代表宿主磁盘": "metric.containerDisk",
153
+ "无法读取系统内存": "metric.memoryFailure",
154
+ "无法读取用户目录所在卷": "metric.diskFailure",
155
+ "尚无有效 SSH 采集": "metric.sshPending",
156
+ "SSH 内存采集失败": "metric.sshMemory",
157
+ "SSH 磁盘采集失败": "metric.sshDisk",
158
+ "容器不读取宿主 SSH 配置。请在宿主运行 Driver。": "notice.container",
159
+ "本机尚无可读取的 SSH 配置。": "notice.missing",
160
+ "Include 路径过深,已跳过。": "notice.deep",
161
+ "Include 目录读取达到上限,候选可能不完整。": "notice.directoryLimit",
162
+ "已跳过目录外的 Include。": "notice.outside",
163
+ "部分 Include 无法读取。": "notice.unreadable",
164
+ "配置读取达到上限,部分候选未列出。": "notice.limit",
165
+ "已跳过受限目录外或非 config / .conf 的 Include。": "notice.restricted",
166
+ "已跳过循环 Include。": "notice.loop",
167
+ "配置文件过大或不是普通文件,已跳过。": "notice.fileSize",
168
+ "配置文件过大,已跳过。": "notice.large",
169
+ "文件不是 SSH 配置,已忽略。": "notice.notConfig",
170
+ "候选数达到上限。": "notice.candidateLimit",
171
+ "含 Match 条件:仅登记别名,未解析条件或执行命令。": "notice.match",
172
+ "已跳过 Host / Match 内的条件 Include,候选可能不完整。": "notice.conditional"
173
+ };
174
+ export const reportMessage = value => Object.hasOwn(REPORT_MESSAGE_KEYS, value) ? t(REPORT_MESSAGE_KEYS[value]) : value;
@@ -0,0 +1,19 @@
1
+ import { translate } from './i18n.js';
2
+
3
+ export function localizedInstallPrompt(plan, locale) {
4
+ const c = plan.commands;
5
+ const tr = (key, values) => translate(locale, key, values);
6
+ return [
7
+ tr('prompt.intro'),
8
+ tr('prompt.choice', { mode: tr(plan.mode === 'local' ? 'guide.local' : plan.mode === 'official' ? 'guide.hosted' : 'guide.custom'), version: plan.version, run: tr(plan.background ? 'prompt.background' : 'prompt.foreground') }),
9
+ plan.controlOrigin ? tr('prompt.private', { origin: plan.controlOrigin }) : '',
10
+ `1. ${tr('prompt.environment')}`,
11
+ `2. ${tr('prompt.existing', { url: plan.url, status: c.bindingStatus, service: c.serviceStatus })}`,
12
+ `3. ${tr('prompt.userInstall')}\n ${c.install}\n ${c.version}\n ${tr('prompt.config')}`,
13
+ `4. ${plan.background ? `${tr('prompt.bg')}\n ${c.servicePlan}\n ${c.serviceInstall}\n ${c.serviceStatus}` : `${tr('prompt.fg')}${plan.controlOrigin ? '' : `\n ${c.foreground}`}`}`,
14
+ `5. ${plan.controlOrigin ? `${tr('prompt.pairBefore')}\n ${c.pairingPlan}\n ${c.connect}\n ${tr('prompt.pairAfter', { origin: plan.controlOrigin })}` : tr('prompt.local')}`,
15
+ `6. ${tr('prompt.mcp', { url: plan.url, mcp: plan.mcpUrl })}`,
16
+ `7. ${tr('prompt.verify')}\n ${tr('prompt.uninstall')}\n ${c.uninstallService}`,
17
+ tr('prompt.boundaries'), tr('prompt.end'),
18
+ ].filter(Boolean).join('\n\n');
19
+ }
@@ -0,0 +1,49 @@
1
+ import { translate } from './i18n.js';
2
+
3
+ // Instruction illustrations only. Navigation never installs software or confirms a binding.
4
+ export function installationSteps({ method = 'agent', mode = 'local', pairingMethod = 'browser' } = {}) {
5
+ if (!['agent', 'manual'].includes(method) || !['local', 'official', 'self-hosted'].includes(mode) || !['browser', 'headless'].includes(pairingMethod)) throw new Error('invalid_installation_walkthrough');
6
+ const manual = method === 'manual', cloud = mode !== 'local', headless = manual && pairingMethod === 'headless';
7
+ return [
8
+ { tab: 'walk.pasteTab', title: manual ? 'walk.terminalTitle' : 'walk.agentTitle', body: manual ? 'walk.terminalBody' : 'walk.agentBody', picture: manual ? 'terminal' : 'chat' },
9
+ { tab: cloud ? 'walk.bindTab' : 'walk.installTab', title: cloud ? headless ? 'walk.headlessTitle' : 'walk.browserTitle' : 'walk.installTitle', body: cloud ? headless ? 'walk.headlessBody' : 'walk.browserBody' : manual ? 'walk.installBody' : 'walk.agentInstallBody', picture: cloud ? headless ? 'headless' : 'pair' : 'install' },
10
+ { tab: 'walk.checkTab', title: 'walk.checkTitle', body: cloud ? 'walk.cloudCheckBody' : 'walk.localCheckBody', picture: 'dashboard' },
11
+ ];
12
+ }
13
+
14
+ const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
15
+ const arrows = '<svg viewBox="0 0 32 20" aria-hidden="true"><path d="M2 10h26M20 3l8 7-8 7"/></svg>';
16
+ const clipboard = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5H5v16h14V5h-3M8 3h8v5H8zM8 12h8m-8 4h5"/></svg>';
17
+ const pointer = '<svg class="picture-pointer" viewBox="0 0 30 36" aria-hidden="true"><path d="m4 3 21 18-10 1-5 10z"/></svg>';
18
+
19
+ export function installWalkthroughMarkup({ method = 'agent', mode = 'local', pairingMethod = 'browser', step = 0, copied = false, controlOrigin = '', localUrl = 'http://127.0.0.1:5174', locale = 'zh-CN' } = {}) {
20
+ const steps = installationSteps({ method, mode, pairingMethod });
21
+ if (!Number.isInteger(step) || step < 0 || step >= steps.length) throw new Error('invalid_installation_step');
22
+ const tr = (key, values) => escape(translate(locale, key, values));
23
+ const active = steps[step], cloud = mode !== 'local';
24
+ const window = (title, contents, type = '') => `<div class="picture-window ${type}"><div class="picture-titlebar"><i></i><i></i><i></i><span>${title}</span></div>${contents}</div>`;
25
+ const paste = type => `<div class="picture-transfer"><span>${clipboard}${tr('walk.copiedContent')}</span>${arrows}<span>${tr(type === 'terminal' ? 'walk.terminal' : 'walk.chat')}</span></div>`;
26
+ const fingerprint = `<div class="picture-fingerprints"><span>${tr('walk.terminal')}</span><code>${tr('walk.fullFingerprint')}</code><b>=</b><span>${tr('walk.browser')}</span><code>${tr('walk.fullFingerprint')}</code></div>`;
27
+ const approval = `<div class="picture-approval">${tr('walk.approve')}${pointer}</div>`;
28
+ const browserTitle = `<span class="picture-address">${escape(controlOrigin || 'https://devices.example.com')}</span>`;
29
+ let picture;
30
+ if (active.picture === 'terminal') {
31
+ picture = `${paste('terminal')}${window(tr('walk.targetTerminal'), `<div class="picture-terminal"><div class="picture-paste-line"><span>$</span><div>${tr('walk.pasteScript')}<i class="picture-caret"></i></div>${pointer}</div><div class="picture-keyline"><span>macOS <kbd>⌘ V</kbd> · Linux <kbd>Ctrl Shift V</kbd></span><strong>${tr('walk.pressEnter')} <kbd>Enter ↵</kbd></strong></div></div>`, 'terminal-picture')}<div class="picture-launch"><span>${tr('walk.openMac')}</span><span>${tr('walk.openLinux')}</span></div>`;
32
+ } else if (active.picture === 'chat') {
33
+ picture = `${paste('chat')}${window(tr('walk.targetChat'), `<div class="picture-chat"><div class="picture-chat-message">${tr('walk.pastePrompt')}<i class="picture-caret"></i></div><div class="picture-send"><span>${tr('walk.send')}</span><b>↑</b>${pointer}</div></div>`, 'chat-picture')}`;
34
+ } else if (active.picture === 'pair' || active.picture === 'headless') {
35
+ const headless = active.picture === 'headless';
36
+ const transfer = headless ? `<div class="picture-transfer headless-transfer"><span>${tr('walk.targetTerminal')}<code>${tr('walk.pairCode')}</code></span>${arrows}<span>${tr('walk.otherBrowser')}</span></div>` : `<div class="picture-auto-open">${tr('walk.autoBrowser')}</div>`;
37
+ picture = `${transfer}${window(browserTitle, `<div class="picture-pair"><div class="picture-signin"><span>01</span>${tr('walk.signIn')}</div>${headless ? `<div class="picture-code-entry"><span>${tr('walk.openPairing', { action: translate(locale, 'panel.434') })}</span><div>${tr('walk.pasteCode')}${pointer}</div></div>` : ''}<div class="picture-signin"><span>02</span>${tr('walk.compare')}</div>${fingerprint}${approval}</div>`, 'browser-picture')}`;
38
+ } else if (active.picture === 'install') {
39
+ picture = window(tr('walk.targetTerminal'), `<div class="picture-install"><div class="picture-install-command"><span>$</span> device-center</div><div class="picture-install-row"><span>01</span>${tr('walk.driver')}</div><div class="picture-install-row"><span>02</span>${tr('walk.service')}</div>${method === 'agent' ? `<div class="picture-install-row"><span>03</span>${tr('walk.mcp')}</div>` : ''}<div class="picture-install-track"><i></i><i></i><i></i></div></div>`, 'terminal-picture');
40
+ } else {
41
+ picture = `${!cloud ? `<div class="picture-auto-open">${tr('walk.sameComputer')}</div>` : ''}${window(`<span class="picture-address">${escape(cloud ? controlOrigin || 'https://devices.example.com' : localUrl)}</span>`, `<div class="picture-dashboard"><div class="picture-device"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="13" rx="2"/><path d="M1 21h22M8 16l-1 5m9-5 1 5"/></svg><strong>${tr('walk.yourDevice')}</strong><span>${tr('walk.awaitReport')}</span></div><div class="picture-metrics"><div><span>${tr('panel.8')}</span><b>— / —</b><i></i></div><div><span>${tr('panel.9')}</span><b>— / —</b><i></i></div></div><div class="picture-report-time">${tr('walk.reportTime')}${pointer}</div></div>`, 'browser-picture')}`;
42
+ }
43
+ return `<ol class="walkthrough-tabs" aria-label="${tr('walk.steps')}">${steps.map((item, index) => `<li><button type="button" data-walk-step="${index}" ${index === step ? 'aria-current="step"' : ''}><span>0${index + 1}</span>${tr(item.tab)}</button></li>`).join('')}</ol>
44
+ <div class="walkthrough-caption" aria-live="polite"><span>${tr(active.title)}</span><small>${tr('walk.counter', { current: step + 1, total: steps.length })}</small></div>
45
+ <div class="walkthrough-picture" role="img" aria-label="${tr(active.title)}. ${tr(active.body)}">${picture}</div>
46
+ <p class="walkthrough-description">${tr(active.body)}</p>
47
+ ${step === 0 && !copied ? `<p class="walkthrough-copy-first">${tr('walk.copyFirst')}</p>` : ''}
48
+ <div class="walkthrough-navigation"><button type="button" data-walk-step="${step - 1}" ${step === 0 ? 'disabled' : ''}>${tr('walk.previous')}</button><button type="button" data-walk-step="${step === 2 ? 0 : step + 1}">${tr(step === 2 ? 'walk.restart' : 'walk.next')}</button></div>`;
49
+ }
package/src/login.js CHANGED
@@ -1,3 +1,10 @@
1
+ import { initLanguage, getLocale, t } from './i18n.js';
2
+ import { loginDestination } from './pairing-handoff.js';
3
+ if (location.pathname === '/pair') {
4
+ await import('./pair.js');
5
+ } else {
6
+ initLanguage();
7
+ function setMessage(element, key) { if (key) element.dataset.i18n = key; else delete element.dataset.i18n; element.textContent = key ? t(key) : ''; }
1
8
  const form = document.querySelector('#login');
2
9
  const loading = document.querySelector('#loading');
3
10
  let checking = false, watchingUntil = 0, timer, options;
@@ -8,19 +15,19 @@ async function continueUnified(manual = false) {
8
15
  button.disabled = true;
9
16
  try {
10
17
  const response = await fetch('/api/auth/continue', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', signal: AbortSignal.timeout(10_000) });
11
- if (response.ok) { clearInterval(timer); location.replace('/'); return; }
12
- if (response.status === 401) message.textContent = manual ? '请先在统一账号页面完成邮箱或 Google 登录,再回到这里。' : '';
18
+ if (response.ok) { clearInterval(timer); location.replace(loginDestination(getLocale())); return; }
19
+ if (response.status === 401) setMessage(message, manual ? 'login.first' : null);
13
20
  else {
14
21
  clearInterval(timer);
15
- message.textContent = response.status === 403 ? '当前账号没有此个人面板的访问权限,请使用面板所有者的账号。' : response.status === 429 ? '尝试过于频繁,请一分钟后重试。' : '统一账号服务暂不可用,请稍后重试。';
22
+ setMessage(message, response.status === 403 ? 'login.owner' : response.status === 429 ? 'login.rate' : 'login.unavailable');
16
23
  }
17
- } catch { message.textContent = '连接账号服务超时,请重试。'; clearInterval(timer); }
24
+ } catch { setMessage(message, 'login.timeout'); clearInterval(timer); }
18
25
  finally { checking = false; button.disabled = false; }
19
26
  }
20
27
  document.querySelector('#continue-login').addEventListener('click', () => continueUnified(true));
21
28
  document.querySelector('#unified-login').addEventListener('click', () => {
22
29
  watchingUntil = Date.now() + 5 * 60_000;
23
- document.querySelector('#unified-message').textContent = '完成统一账号登录后,回到此页即可。';
30
+ setMessage(document.querySelector('#unified-message'), 'login.return');
24
31
  clearInterval(timer);
25
32
  timer = setInterval(() => { if (Date.now() > watchingUntil) clearInterval(timer); else void continueUnified(); }, 4000);
26
33
  });
@@ -30,7 +37,7 @@ try {
30
37
  const response = await fetch('/api/auth/options', { signal: AbortSignal.timeout(5000) });
31
38
  if (!response.ok) throw new Error();
32
39
  options = await response.json();
33
- document.querySelector('#access-notes').textContent = options.accountMode === 'multi-user' ? '账号设备隔离 · HTTPS 保护' : '个人访问 · HTTPS 保护';
40
+ setMessage(document.querySelector('#access-notes'), options.accountMode === 'multi-user' ? 'login.multi' : 'login.personal');
34
41
  loading.hidden = true;
35
42
  if (options.provider === 'app-services') {
36
43
  const target = new URL(options.accountLoginUrl);
@@ -40,17 +47,19 @@ try {
40
47
  if (options.automaticContinue) void continueUnified();
41
48
  } else if (options.provider === 'password') { form.hidden = false; form.elements.username.focus(); }
42
49
  else throw new Error();
43
- } catch { loading.hidden = false; loading.textContent = '无法读取登录方式,请刷新重试。'; }
50
+ } catch { loading.hidden = false; setMessage(loading, 'login.optionsError'); }
44
51
  form.addEventListener('submit', async (event) => {
45
52
  event.preventDefault();
46
53
  const button = form.querySelector('button');
47
54
  const message = document.querySelector('#message');
48
- button.disabled = true; message.textContent = '';
55
+ button.disabled = true; setMessage(message, null);
49
56
  try {
50
57
  const data = Object.fromEntries(new FormData(form));
51
58
  const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(data), signal: AbortSignal.timeout(10_000) });
52
- if (!response.ok) throw new Error(response.status === 429 ? '尝试过于频繁,请一分钟后重试。' : '用户名或密码不正确。');
53
- form.reset(); location.replace('/');
54
- } catch (cause) { message.textContent = cause.name === 'TimeoutError' ? '连接超时,请重试。' : cause.message; }
59
+ if (!response.ok) throw new Error(response.status === 429 ? 'login.rate' : 'login.credentials');
60
+ form.reset(); location.replace(loginDestination(getLocale()));
61
+ } catch (cause) { setMessage(message, cause.name === 'TimeoutError' ? 'login.timeout' : cause.message.startsWith('login.') ? cause.message : 'login.unavailable'); }
55
62
  finally { button.disabled = false; }
56
63
  });
64
+
65
+ }