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

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/src/main.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { t, h, getLocale, initLanguage, languagePickerMarkup, reportMessage } from './i18n.js';
1
2
  import { api } from './api.js';
2
3
  import { expireSshSnapshots, deviceStatus, collectionAvailability, DEVICE_TYPE_LABELS } from './device-state.js';
3
4
  import { OFFICIAL_CONTROL_ORIGIN, connectCommand, logAccessCommands, agentInstallPrompt } from './onboarding.js';
@@ -13,9 +14,9 @@ let cloudRefreshTimer, cloudRefreshBusy = false, cloudRefreshFailures = 0;
13
14
  Object.assign(state, { profileDevice: null, profileDraft: null, profileError: null, profileSaving: false, collectingDevice: null, pendingCollections: {} });
14
15
  Object.assign(state, { installMode: 'local', installOrigin: '', installBackground: true });
15
16
  const RELAY_PROVIDERS = [
16
- { id: 'self-hosted', name: '用户自建服务器', description: '设备 Agent 可使用自建 HTTPS 控制面进行密文中转;已有 SSH 设备仍沿用原配置,此处只保存计划。' },
17
- { id: 'managed', name: 'Device Center 服务', description: '设备 Agent 可使用官方 HTTPS 密文中转;需绑定自己的账号并单独授权日志。此处不会把 SSH 路径改成 Agent 通道。' },
18
- { id: 'cloudflare', name: 'Cloudflare', description: '使用 Cloudflare Tunnel / Access。设备侧或可访问设备的网关需要 cloudflared,管理端也需要相应客户端与认证方式;不是填写 CF 地址就能直连。' },
17
+ { id: 'self-hosted', get name() { return t("panel.1"); }, get description() { return t("panel.2"); } },
18
+ { id: 'managed', get name() { return t("panel.3"); }, get description() { return t("panel.4"); } },
19
+ { id: 'cloudflare', name: 'Cloudflare', get description() { return t("panel.5"); } },
19
20
  ];
20
21
  const ICONS = {
21
22
  device: '<rect x="4" y="3" width="16" height="13" rx="2"/><path d="M2 20h20M8 16l-1 4m9-4 1 4"/>',
@@ -33,23 +34,23 @@ const ICONS = {
33
34
  const icon = (name, size = 18) => `<svg aria-hidden="true" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${ICONS[name] ?? ICONS.device}</svg>`;
34
35
  const escapeHtml = (value = '') => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
35
36
  function formatAgo(time) {
36
- if (time == null) return '尚未采集';
37
+ if (time == null) return t("panel.6");
37
38
  const elapsed = Math.max(0, Date.now() - time);
38
- if (elapsed < 60_000) return '刚刚';
39
- if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)} 分钟前`;
40
- return `${Math.floor(elapsed / 3_600_000)} 小时前`;
39
+ if (elapsed < 60_000) return t("panel.7");
40
+ return new Intl.RelativeTimeFormat(getLocale(), { numeric: 'always' }).format(-Math.floor(elapsed / (elapsed < 3_600_000 ? 60_000 : 3_600_000)), elapsed < 3_600_000 ? 'minute' : 'hour');
41
41
  }
42
42
  function metricMarkup(device, kind) {
43
43
  const memory = kind === 'memory';
44
44
  const metric = device[kind];
45
- const label = memory ? '内存' : '磁盘';
45
+ const label = memory ? t("panel.8") : t("panel.9");
46
46
  const used = memory ? metric.usedMb : metric.usedGb;
47
47
  const total = memory ? metric.totalMb : metric.totalGb;
48
- const format = (n) => memory ? `${(n / 1024).toFixed(1)} GB` : `${Math.round(n)} GB`;
48
+ const number = new Intl.NumberFormat(getLocale(), { minimumFractionDigits: memory ? 1 : 0, maximumFractionDigits: memory ? 1 : 0 });
49
+ const format = n => `${number.format(memory ? n / 1024 : n)} GB`;
49
50
  const valid = metric.status === 'ok' && used != null && total != null && total > 0;
50
51
  const percent = valid ? Math.min(100, Math.max(0, Math.round(used / total * 100))) : 0;
51
- const description = device.state === 'expired' ? '上次采集快照' : device.source === 'local-runtime' || device.transport === 'ssh' ? memory ? metric.basis === 'total-minus-available' ? '系统总量减可用' : '系统总量减空闲 · 含缓存' : '用户目录所在卷' : '最近回报';
52
- return `<section class="resource" title="${escapeHtml(valid ? description : metric.error ?? '等待设备 Agent 回报')}"><div class="resource-top">${icon(kind, 13)}<span>${label}</span><small>${valid ? `${percent}% 已用` : metric.status === 'failed' ? '读取失败' : '未采集'}</small></div><div class="resource-value">${valid ? `<span>${format(used)}</span><small>/ ${format(total)}</small>` : '<span>—</span><small>/ —</small>'}</div><progress max="100" value="${percent}" aria-label="${label}用量"></progress><p class="${valid ? 'resource-basis' : 'resource-error'}">${valid ? description : escapeHtml(metric.error ?? '等待设备 Agent 回报')}</p></section>`;
52
+ const description = device.state === 'expired' ? t("panel.10") : device.source === 'local-runtime' || device.transport === 'ssh' ? memory ? metric.basis === 'total-minus-available' ? t("panel.11") : t("panel.12") : t("panel.13") : t("panel.14");
53
+ return `<section class="resource" title="${escapeHtml(valid ? description : reportMessage(metric.error) ?? t("panel.15"))}"><div class="resource-top">${icon(kind, 13)}<span>${label}</span><small>${valid ? `${percent}${h("panel.16")}` : metric.status === 'failed' ? t("panel.17") : t("panel.18")}</small></div><div class="resource-value">${valid ? `<span>${format(used)}</span><small>/ ${format(total)}</small>` : '<span>—</span><small>/ —</small>'}</div><progress max="100" value="${percent}" aria-label="${h("a11y.usage", { label })}"></progress><p class="${valid ? 'resource-basis' : 'resource-error'}">${valid ? description : escapeHtml(reportMessage(metric.error) ?? t("panel.15"))}</p></section>`;
53
54
  }
54
55
  function connectivityState(device) {
55
56
  if (state.checkingSshAlias && state.checkingSshAlias === device.ssh?.alias) return 'checking';
@@ -58,23 +59,23 @@ function connectivityState(device) {
58
59
  }
59
60
  function connectivityMarkup(device) {
60
61
  const value = connectivityState(device);
61
- const label = ({ queued: '等待检查', checking: '检查中', passed: 'SSH 连通', failed: '检查失败', stale: '上次连通 · 待检查', 'configuration-changed': '配置待核对', unobserved: '尚未检查' })[value];
62
- const detail = ['passed', 'stale'].includes(value) ? '已通过现有认证与主机指纹校验,仅代表检查时当前 SSH 路径可达。' : device.ssh?.connectivity?.error ? sshErrorText(device.ssh.connectivity.error) : '导入后检查当前 SSH 路径;新填写的局域网/中转计划不会自动生效。';
62
+ const label = ({ queued: t("panel.20"), checking: t("panel.21"), passed: t("panel.22"), failed: t("panel.23"), stale: t("panel.24"), 'configuration-changed': t("panel.25"), unobserved: t("panel.26") })[value];
63
+ const detail = ['passed', 'stale'].includes(value) ? t("panel.27") : device.ssh?.connectivity?.error ? sshErrorText(device.ssh.connectivity.error) : t("panel.28");
63
64
  return `<span class="ssh-connectivity ${escapeHtml(value)}" title="${escapeHtml(detail)}">${escapeHtml(label)}</span>`;
64
65
  }
65
66
  function deviceCard(device) {
66
67
  const local = device.source === 'local-runtime';
67
- const subtitle = local ? `${device.platform} · ${device.scope === 'host' ? '本机 Driver' : '容器 Driver'}` : device.transport === 'ssh' ? device.ssh.checkState === 'verified' ? device.platform.split(' · ')[0] : '系统未知' : device.platform;
68
+ const subtitle = local ? `${device.platform === 'Docker 容器' ? 'Docker' : device.platform} · ${device.scope === 'host' ? t("panel.29") : t("panel.30")}` : device.transport === 'ssh' ? device.ssh.checkState === 'verified' ? device.platform.split(' · ')[0] : t("panel.31") : device.platform;
68
69
  const route = device.ssh?.route;
69
- const path = local ? '本机进程 · 无网络转发' : device.source === 'driver-report' ? `${device.managementDriver.name} → ${new URL(device.managementDriver.origin).host} · HTTPS 回报` : route ? `本机 → ${route.hops.length ? `${route.hops.map((hop) => hop.alias).join(' → ')} → 设备` : endpointText(route.target)}` : '访问路径未核验';
70
- const detail = device.transport === 'ssh' ? !device.ssh.configured ? '本机 SSH 配置不可用' : device.ssh.error ? sshErrorText(device.ssh.error) : device.ssh.checkState !== 'verified' ? 'SSH 配置已关联 · 尚未检查' : null : local ? null : '设备状态回报';
70
+ const path = local ? t("panel.32") : device.source === 'driver-report' ? `${device.managementDriver.name} → ${new URL(device.managementDriver.origin).host} ${t("panel.33")}` : route ? `${t("panel.35")} ${route.hops.length ? `${route.hops.map((hop) => hop.alias).join(' → ')} ${t("panel.34")}` : endpointText(route.target)}` : t("panel.36");
71
+ const detail = device.transport === 'ssh' ? !device.ssh.configured ? t("panel.37") : device.ssh.error ? sshErrorText(device.ssh.error) : device.ssh.checkState !== 'verified' ? t("panel.38") : null : local ? null : t("panel.39");
71
72
  const status = deviceStatus(device), availability = collectionAvailability(device);
72
73
  const collecting = state.collectingDevice === device.id || device.transport === 'ssh' && (state.checkingSshAlias === device.ssh?.alias || device.ssh?.checking || device.ssh?.queued);
73
- const collectButton = `<button class="button button-light ssh-check-button" data-action="collect-device" data-device="${escapeHtml(device.id)}" aria-label="采集 ${escapeHtml(device.name)}" title="${escapeHtml(availability.hint)}" ${!availability.available || collecting || state.collectingDevice ? 'disabled' : ''}>${collecting ? '正在采集…' : availability.pending ? '等待设备采集…' : '采集一次'}</button>`;
74
+ const collectButton = `<button class="button button-light ssh-check-button" data-action="collect-device" data-device="${escapeHtml(device.id)}" aria-label="${h("a11y.collect", { name: device.name })}" title="${escapeHtml(availability.hint)}" ${!availability.available || collecting || state.collectingDevice ? 'disabled' : ''}>${collecting ? t("panel.40") : availability.pending ? t("panel.41") : t("panel.42")}</button>`;
74
75
  const profile = device.profile;
75
76
  const description = profile && (profile.type !== 'unspecified' || profile.tags.length || profile.purpose) ? `<div class="device-description">${profile.type !== 'unspecified' ? `<span class="device-type">${DEVICE_TYPE_LABELS[profile.type]}</span>` : ''}${profile.tags.slice(0, 3).map(tag => `<span class="device-tag">${escapeHtml(tag)}</span>`).join('')}${profile.purpose ? `<p title="${escapeHtml(profile.purpose)}">${escapeHtml(profile.purpose)}</p>` : ''}</div>` : '';
76
77
  const identityHint = device.managementDriver?.fingerprint ? ` · ${device.managementDriver.fingerprint.slice(0, 8)}` : '';
77
- return `<article class="device-card"><div class="device-identity"><div class="device-heading"><span class="device-icon">${icon('device', 17)}</span><div><h2 title="${escapeHtml(device.name)}">${escapeHtml(device.name)}</h2><p>${escapeHtml(subtitle + identityHint)}</p></div><span class="status-pill ${device.state}" title="${escapeHtml(status.title)}">${status.label}</span></div>${description}<div class="device-path-line"><button class="device-route" data-action="route" data-device="${escapeHtml(device.id)}" aria-label="${escapeHtml(device.name)}的访问路径" title="查看连接方式与当前访问路径">${escapeHtml(path)}</button>${device.transport === 'ssh' ? connectivityMarkup(device) : ''}</div>${detail ? `<div class="device-detail">${escapeHtml(detail)}</div>` : ''}</div><div class="resource-grid">${metricMarkup(device, 'memory')}${metricMarkup(device, 'disk')}</div><div class="device-actions"><div class="device-footer">最近采集 <span>${formatAgo(device.lastHeartbeat)}</span></div>${collectButton}${!availability.available && !availability.pending && !collecting ? `<small class="collection-hint">${escapeHtml(availability.hint)}</small>` : ''}<button class="text-button profile-edit" data-action="profile" data-device="${escapeHtml(device.id)}" aria-label="编辑 ${escapeHtml(device.name)} 的设备说明">设备说明</button></div></article>`;
78
+ return `<article class="device-card"><div class="device-identity"><div class="device-heading"><span class="device-icon">${icon('device', 17)}</span><div><h2 title="${escapeHtml(device.name)}">${escapeHtml(device.name)}</h2><p>${escapeHtml(subtitle + identityHint)}</p></div><span class="status-pill ${device.state}" title="${escapeHtml(status.title)}">${status.label}</span></div>${description}<div class="device-path-line"><button class="device-route" data-action="route" data-device="${escapeHtml(device.id)}" aria-label="${h("a11y.route", { name: device.name })}" title="${h("panel.45")}">${escapeHtml(path)}</button>${device.transport === 'ssh' ? connectivityMarkup(device) : ''}</div>${detail ? `<div class="device-detail">${escapeHtml(detail)}</div>` : ''}</div><div class="resource-grid">${metricMarkup(device, 'memory')}${metricMarkup(device, 'disk')}</div><div class="device-actions"><div class="device-footer">${h("panel.46")} <span>${formatAgo(device.lastHeartbeat)}</span></div>${collectButton}${!availability.available && !availability.pending && !collecting ? `<small class="collection-hint">${escapeHtml(availability.hint)}</small>` : ''}<button class="text-button profile-edit" data-action="profile" data-device="${escapeHtml(device.id)}" aria-label="${h("a11y.profile", { name: device.name })}">${h("panel.49")}</button></div></article>`;
78
79
  }
79
80
 
80
81
  function openDeviceProfile(deviceId) {
@@ -87,9 +88,9 @@ function openDeviceProfile(deviceId) {
87
88
  }
88
89
  function profileBody() {
89
90
  const device = state.overview?.devices.find(d => d.id === state.profileDevice), draft = state.profileDraft;
90
- if (!device || !draft) return '<p class="modal-intro">设备已不在列表,请刷新后查看。</p>';
91
+ if (!device || !draft) return `<p class="modal-intro">${h("panel.50")}</p>`;
91
92
  const disabled = state.profileSaving ? 'disabled' : '';
92
- return `<p class="modal-intro">给设备一个容易辨认的名字,让智能体知道它适合做什么。</p><form id="device-profile-form"><div class="profile-fields"><label class="field-label" for="profile-name">设备名称<input id="profile-name" data-profile-field="displayName" value="${escapeHtml(draft.displayName)}" placeholder="${escapeHtml(device.reportedName ?? device.name)}" maxlength="80" ${disabled}></label><div><label class="field-label" for="profile-type">设备类型</label><select id="profile-type" data-profile-field="type" ${disabled}>${Object.entries(DEVICE_TYPE_LABELS).map(([value, label]) => `<option value="${value}" ${draft.type === value ? 'selected' : ''}>${label}</option>`).join('')}</select></div></div><label class="field-label" for="profile-purpose">用途<textarea id="profile-purpose" data-profile-field="purpose" maxlength="240" rows="2" placeholder="例如:Windows 程序验收、读取测试日志" ${disabled}>${escapeHtml(draft.purpose)}</textarea></label><label class="field-label" for="profile-tags">标签<input id="profile-tags" data-profile-field="tags" value="${escapeHtml(draft.tags)}" maxlength="200" placeholder="例如:开发,测试,东京 · 最多 8 个" ${disabled}></label><label class="field-label" for="profile-services">服务与应用<input id="profile-services" data-profile-field="services" value="${escapeHtml(draft.services)}" maxlength="500" placeholder="例如:Docker,PostgreSQL,Windows SDK" ${disabled}></label><p class="small-note profile-service-note">标签、服务与应用用逗号分隔。服务清单为你手动登记,未自动检测安装或运行状态。</p><details class="profile-identity"><summary>原始名称与设备身份</summary><p>原始名称:${escapeHtml(device.reportedName ?? device.name)}</p><code class="fingerprint">${escapeHtml(device.id)}</code>${device.managementDriver?.fingerprint ? `<p>身份指纹</p><code class="fingerprint">${escapeHtml(device.managementDriver.fingerprint)}</code>` : ''}<p>自定义名称不改变主机名、SSH 别名、身份密钥或权限。</p></details><p class="small-note">这些说明会出现在智能体的资源清单中;具体操作仍需单独授权。${cloudMode() ? '保存到当前账号的云端设备清单。' : '保存到本机清单;本轮尚未同步到云端。'}</p>${state.profileError ? `<p class="form-error" role="alert">${escapeHtml(state.profileError)}</p>` : ''}<button type="submit" class="button button-primary save-profile" ${disabled}>${state.profileSaving ? '正在保存…' : '保存设备说明'}</button></form>`;
93
+ return `<p class="modal-intro">${h("panel.56")}</p><form id="device-profile-form"><div class="profile-fields"><label class="field-label" for="profile-name">${h("panel.57")}<input id="profile-name" data-profile-field="displayName" value="${escapeHtml(draft.displayName)}" placeholder="${escapeHtml(device.reportedName ?? device.name)}" maxlength="80" ${disabled}></label><div><label class="field-label" for="profile-type">${h("panel.58")}</label><select id="profile-type" data-profile-field="type" ${disabled}>${Object.entries(DEVICE_TYPE_LABELS).map(([value, label]) => `<option value="${value}" ${draft.type === value ? 'selected' : ''}>${label}</option>`).join('')}</select></div></div><label class="field-label" for="profile-purpose">${h("panel.59")}<textarea id="profile-purpose" data-profile-field="purpose" maxlength="240" rows="2" placeholder="${h("panel.60")}" ${disabled}>${escapeHtml(draft.purpose)}</textarea></label><label class="field-label" for="profile-tags">${h("panel.61")}<input id="profile-tags" data-profile-field="tags" value="${escapeHtml(draft.tags)}" maxlength="200" placeholder="${h("panel.62")}" ${disabled}></label><label class="field-label" for="profile-services">${h("panel.63")}<input id="profile-services" data-profile-field="services" value="${escapeHtml(draft.services)}" maxlength="500" placeholder="${h("panel.64")}" ${disabled}></label><p class="small-note profile-service-note">${h("panel.65")}</p><details class="profile-identity"><summary>${h("panel.66")}</summary><p>${h("panel.67")}${escapeHtml(device.reportedName ?? device.name)}</p><code class="fingerprint">${escapeHtml(device.id)}</code>${device.managementDriver?.fingerprint ? `<p>${h("panel.51")}</p><code class="fingerprint">${escapeHtml(device.managementDriver.fingerprint)}</code>` : ''}<p>${h("panel.68")}</p></details><p class="small-note">${h("panel.69")} ${cloudMode() ? t("panel.52") : t("panel.53")}</p>${state.profileError ? `<p class="form-error" role="alert">${escapeHtml(t(state.profileError))}</p>` : ''}<button type="submit" class="button button-primary save-profile" ${disabled}>${state.profileSaving ? t("panel.54") : t("panel.55")}</button></form>`;
93
94
  }
94
95
  async function saveDeviceProfile() {
95
96
  if (state.profileSaving || !state.profileDraft) return;
@@ -100,9 +101,9 @@ async function saveDeviceProfile() {
100
101
  await api.saveDeviceProfile({ deviceId, revision: draft.revision, profile: { displayName: draft.displayName, type: draft.type, purpose: draft.purpose, tags: split(draft.tags), services: split(draft.services) } });
101
102
  state.overview = await api.overview();
102
103
  if (state.dialog === 'profile' && state.profileDevice === deviceId) state.dialog = null;
103
- toast('设备说明已保存,智能体可在资源清单中读取。');
104
+ toast(t("panel.70"));
104
105
  } catch (cause) {
105
- if (state.dialog === 'profile' && state.profileDevice === deviceId) state.profileError = ({ invalid_device_profile: '请检查字数与数量:名称 80 字,用途 240 字,标签最多 8 个(每个 24 字),服务最多 12 个(每个 40 字)。', device_profile_conflict: '其他页面已修改此设备。请关闭后重新打开,核对最新说明再保存。', device_not_found: '设备已移除或撤销,请刷新列表。' })[cause.message] ?? '未能确认保存结果,请重新打开设备说明核对后再试。';
106
+ if (state.dialog === 'profile' && state.profileDevice === deviceId) state.profileError = ({ invalid_device_profile: "panel.71", device_profile_conflict: "panel.72", device_not_found: "panel.73" })[cause.message] ?? "panel.74";
106
107
  } finally { state.profileSaving = false; render(); }
107
108
  }
108
109
  function updateOverview(overview) {
@@ -111,7 +112,7 @@ function updateOverview(overview) {
111
112
  const device = overview.devices.find(d => d.id === id), result = device?.collection;
112
113
  if (result?.requestedAt === requestedAt && result.status === 'pending') continue;
113
114
  delete state.pendingCollections[id];
114
- toast(result?.requestedAt === requestedAt && result.status === 'completed' ? `${device.name} 已返回新的资源数据。` : '未收到本次新数据,请确认设备上的 Driver 和网络正常后重试。');
115
+ toast(result?.requestedAt === requestedAt && result.status === 'completed' ? `${device.name} ${t("panel.75")}` : t("panel.76"));
115
116
  }
116
117
  }
117
118
  async function collectDevice(deviceId) {
@@ -122,9 +123,9 @@ async function collectDevice(deviceId) {
122
123
  const result = await api.collectDevice(deviceId);
123
124
  if (result.status === 'pending') state.pendingCollections[deviceId] = result.collection.requestedAt;
124
125
  updateOverview(await api.overview());
125
- if (result.status !== 'pending') toast(result.status === 'failed' ? sshErrorText(result.error) : `${device.name} 已重新采集内存与磁盘。`);
126
+ if (result.status !== 'pending') toast(result.status === 'failed' ? sshErrorText(result.error) : `${device.name} ${t("panel.77")}`);
126
127
  } catch (cause) {
127
- toast(({ driver_not_connected: '设备未回报,请先启动设备上的 Driver;刷新列表不会让离线设备上线。', driver_upgrade_required: '当前 Driver 尚不支持手动采集,更新设备 Driver 后重试。', collection_rate_limited: '采集过于频繁,请稍等后重试。', collection_requires_local_ssh_check: '请在管理电脑检查这台 SSH 设备,云端不会替你执行 SSH。', collection_unavailable: '当前设备不支持采集,请在宿主运行 Driver。' })[cause.message] ?? '未能确认本次采集,请刷新列表核对时间;旧数据不会标为新数据。');
128
+ toast(({ driver_not_connected: t("panel.78"), driver_upgrade_required: t("panel.79"), collection_rate_limited: t("panel.80"), collection_requires_local_ssh_check: t("panel.81"), collection_unavailable: t("panel.82") })[cause.message] ?? t("panel.83"));
128
129
  } finally { state.collectingDevice = null; render(); }
129
130
  }
130
131
  function endpointText(endpoint) {
@@ -132,16 +133,16 @@ function endpointText(endpoint) {
132
133
  }
133
134
  function currentRouteBody() {
134
135
  const device = state.overview?.devices.find((item) => item.id === state.routeDevice);
135
- if (!device) return '<p class="modal-intro">设备已不在当前列表,请刷新后查看。</p>';
136
+ if (!device) return `<p class="modal-intro">${h("panel.84")}</p>`;
136
137
  const local = device.source === 'local-runtime';
137
- if (local) return `<p class="modal-intro">${escapeHtml(device.name)} 的资源由当前${device.scope === 'host' ? '本机' : '容器'}进程直接采集,无 SSH、跳板或网络转发。</p>`;
138
- if (device.source === 'driver-report') return `<p class="modal-intro"><strong>${escapeHtml(device.name)}</strong> · 只读资源回报路径</p><ol class="route-path"><li><span class="route-node-icon">${icon('device', 16)}</span><div><strong>${escapeHtml(device.managementDriver.name)}</strong><small>${device.kind === 'management' ? '在本机采集内存与磁盘' : '读取本机已关联 SSH 的缓存快照;不自动检查 SSH'}</small></div></li><li><span class="route-node-icon">${icon('cloud', 16)}</span><div><strong>${escapeHtml(new URL(device.managementDriver.origin).host)}</strong><small>Driver 主动发起 HTTPS 请求 · 身份签名校验</small></div></li></ol><p class="small-note">管理电脑最近回报:${formatAgo(device.managementDriver.lastSeen)}。这是回报通道,尚不能从云端执行命令或传输文件;本机已有 SSH 路径独立使用。</p><details class="route-auth"><summary>身份与回报范围</summary><p>身份私钥保存在管理电脑的私有文件中,云端仅保存公钥。默认只回报这台电脑的名称、系统、内存与磁盘;SSH 名称和缓存状态需要另外批准。</p><code class="fingerprint">${escapeHtml(device.managementDriver.fingerprint)}</code><p>可在“管理连接”中撤销;撤销后拒绝后续回报,不删除本机身份文件或 SSH 配置。</p></details>`;
138
+ if (local) return `<p class="modal-intro">${h("route.local", { name: device.name, scope: t(device.scope === 'host' ? "panel.85" : "panel.86") })}</p>`;
139
+ if (device.source === 'driver-report') return `<p class="modal-intro"><strong>${escapeHtml(device.name)}</strong> ${h("panel.91")}</p><ol class="route-path"><li><span class="route-node-icon">${icon('device', 16)}</span><div><strong>${escapeHtml(device.managementDriver.name)}</strong><small>${device.kind === 'management' ? t("panel.89") : t("panel.90")}</small></div></li><li><span class="route-node-icon">${icon('cloud', 16)}</span><div><strong>${escapeHtml(new URL(device.managementDriver.origin).host)}</strong><small>${h("panel.92")}</small></div></li></ol><p class="small-note">${h("panel.93")}${formatAgo(device.managementDriver.lastSeen)}${h("panel.94")}</p><details class="route-auth"><summary>${h("panel.95")}</summary><p>${h("panel.96")}</p><code class="fingerprint">${escapeHtml(device.managementDriver.fingerprint)}</code><p>${h("panel.97")}</p></details>`;
139
140
  const route = device.ssh?.route;
140
- if (!route) return '<p class="modal-intro">无法安全解析当前访问路径。请先核验完整 SSH 配置;不根据别名猜测跳板或连通性。</p>';
141
- const scope = (endpoint) => ({ loopback: '回环地址', private: '私网地址(不证明同一局域网)', 'link-local': '链路本地地址', 'other-ip': 'IP 地址', hostname: '主机名(未探测 DNS)' })[endpoint.addressScope];
141
+ if (!route) return `<p class="modal-intro">${h("panel.98")}</p>`;
142
+ const scope = (endpoint) => ({ loopback: t("panel.99"), private: t("panel.100"), 'link-local': t("panel.101"), 'other-ip': t("panel.102"), hostname: t("panel.103") })[endpoint.addressScope];
142
143
  const forwarded = route.hops.length && route.target.addressScope === 'loopback';
143
- const node = (endpoint, target = false) => `<li><span class="route-node-icon">${icon(target ? 'device' : 'cloud', 16)}</span><div><strong>${target && forwarded ? '跳板端的设备入口' : escapeHtml(endpoint.alias)}</strong><code>${escapeHtml(endpointText(endpoint))}</code><small>${escapeHtml(scope(endpoint))}${target && forwarded ? ' · 位于最后一个跳板端' : ''}</small></div></li>`;
144
- return `<p class="modal-intro"><strong>${escapeHtml(device.name)}</strong> · 当前${route.mode === 'jump' ? '经跳板' : '直接访问'}配置</p><ol class="route-path" aria-label="SSH 配置路径"><li><span class="route-node-icon">${icon('device', 16)}</span><div><strong>本机 Driver</strong><small>SSH 客户端 · 登录身份保留在本机</small></div></li>${route.hops.map((hop) => node(hop)).join('')}${node(route.target, true)}${forwarded ? `<li class="route-unverified"><span class="route-node-icon">${icon('device', 16)}</span><div><strong>${escapeHtml(device.name)}</strong><small>设备 SSH · 后段端口映射机制未核验</small></div></li>` : ''}</ol><p class="small-note">${device.ssh.lastVerifiedRoute ? `上次成功检查:${formatAgo(device.ssh.lastVerifiedRoute.checkedAt)},使用与上述相同的 SSH 配置。${device.state === 'expired' ? '资源快照已超过 5 分钟,需手动刷新;不代表密钥过期或设备断线。' : '这是一次检查的结果,不是持续连接。'}` : '这只是当前配置。尚无与之匹配的成功检查,不能据此判断连通。'}</p><details class="route-auth"><summary>在哪里认证,密钥放在哪里?</summary><p>本机 SSH 客户端使用本机已有身份,分别认证${route.hops.length ? '跳板和目标设备' : '目标设备'},并严格校验主机密钥。Device Center 只引用身份文件,不读取或托管私钥内容。</p>${route.hops.length ? '<p>跳板转发到目标的 SSH 流量,无需把本机用于登录目标的私钥复制到跳板。设备的 authorized_keys 通常保存获准客户端的公钥,公钥不能代替客户端私钥登录。路径可达不能证明云上保存了任何私钥。</p>' : ''}<p>${forwarded ? '当前配置指向跳板侧的回环端口,可能由既有反向转发等方式提供;仅凭这份配置,无法确认由谁建立后段链路或其密钥存放位置。' : '这里只展示 SSH 配置,不探测底层网络或导入密钥。'}</p></details><details class="route-policy"><summary>如何做到局域网优先?</summary><p>目前按上述 SSH 配置访问,尚未自动切换。为同一设备登记准确的局域网入口,并通过可信渠道确认它与原路径是同一主机身份;随后只对这个获准入口尝试认证直连。</p><p>直连成功就使用直连;连接超时或网络不可达时,可回退获准跳板并记录原因。身份不匹配或认证失败时停止并提示;正在进行的大文件传输不应自动换路重放。</p><p>智能体需要通过 Device Center 的连接器请求获准操作,才能复用这套选择;自行执行原 SSH 别名仍会沿用原配置。</p></details><p class="small-note">路径来源是经过核验的配置,不表示持续连接或实时带宽。资源采集可用;自动选路与智能体远程任务尚未实现。</p>`;
144
+ const node = (endpoint, target = false) => `<li><span class="route-node-icon">${icon(target ? 'device' : 'cloud', 16)}</span><div><strong>${target && forwarded ? t("panel.104") : escapeHtml(endpoint.alias)}</strong><code>${escapeHtml(endpointText(endpoint))}</code><small>${escapeHtml(scope(endpoint))}${target && forwarded ? t("panel.105") : ''}</small></div></li>`;
145
+ return `<p class="modal-intro"><strong>${escapeHtml(device.name)}</strong> ${h("panel.119")}${route.mode === 'jump' ? t("panel.106") : t("panel.107")}${h("panel.120")}</p><ol class="route-path" aria-label="${h("panel.121")}"><li><span class="route-node-icon">${icon('device', 16)}</span><div><strong>${h("panel.29")}</strong><small>${h("panel.122")}</small></div></li>${route.hops.map((hop) => node(hop)).join('')}${node(route.target, true)}${forwarded ? `<li class="route-unverified"><span class="route-node-icon">${icon('device', 16)}</span><div><strong>${escapeHtml(device.name)}</strong><small>${h("panel.108")}</small></div></li>` : ''}</ol><p class="small-note">${device.ssh.lastVerifiedRoute ? `${h("panel.111")}${formatAgo(device.ssh.lastVerifiedRoute.checkedAt)}${h("panel.112")}${device.state === 'expired' ? t("panel.109") : t("panel.110")}` : t("panel.113")}</p><details class="route-auth"><summary>${h("panel.123")}</summary><p>${h("panel.124")}${route.hops.length ? t("panel.114") : t("panel.115")}${h("panel.125")}</p>${route.hops.length ? `<p>${h("panel.116")}</p>` : ''}<p>${forwarded ? t("panel.117") : t("panel.118")}</p></details><details class="route-policy"><summary>${h("panel.126")}</summary><p>${h("panel.127")}</p><p>${h("panel.128")}</p><p>${h("panel.129")}</p></details><p class="small-note">${h("panel.130")}</p>`;
145
146
  }
146
147
  function routeBody() {
147
148
  const device = state.overview?.devices.find((item) => item.id === state.routeDevice);
@@ -150,20 +151,20 @@ function routeBody() {
150
151
  const plan = device.connections;
151
152
  const provider = RELAY_PROVIDERS.find((item) => item.id === draft.relayProvider);
152
153
  const disabled = state.connectionSaving ? 'disabled' : '';
153
- return `<p class="modal-intro"><strong>${escapeHtml(device.name)}</strong> · 一台设备,两种连接方式</p>
154
- <section class="connection-mode"><header><h3>${icon('device', 16)} 局域网直连</h3><span class="mode-status">${draft.lanHost !== plan.lan.host || draft.lanPort !== plan.lan.port ? '修改待保存' : plan.lan.host ? '已登记 · 待验证' : '未登记入口'}</span></header><p>本机 Driver → 局域网 → 设备 SSH</p>
155
- <div class="lan-fields"><label>设备地址<input id="lan-host" data-field="lanHost" aria-label="局域网设备地址" value="${escapeHtml(draft.lanHost)}" placeholder="设备的局域网 IP 或主机名" maxlength="253" autocomplete="off" ${disabled}></label><label>SSH 端口<input id="lan-port" data-field="lanPort" type="number" min="1" max="65535" aria-label="局域网 SSH 端口" value="${draft.lanPort}" ${disabled}></label></div>
154
+ return `<p class="modal-intro"><strong>${escapeHtml(device.name)}</strong> ${h("panel.142")}</p>
155
+ <section class="connection-mode"><header><h3>${icon('device', 16)} ${h("panel.143")}</h3><span class="mode-status">${draft.lanHost !== plan.lan.host || draft.lanPort !== plan.lan.port ? t("panel.131") : plan.lan.host ? t("panel.132") : t("panel.133")}</span></header><p>${h("panel.144")}</p>
156
+ <div class="lan-fields"><label>${h("panel.145")}<input id="lan-host" data-field="lanHost" aria-label="${h("panel.146")}" value="${escapeHtml(draft.lanHost)}" placeholder="${h("panel.147")}" maxlength="253" autocomplete="off" ${disabled}></label><label>${h("panel.148")}<input id="lan-port" data-field="lanPort" type="number" min="1" max="65535" aria-label="${h("panel.149")}" value="${draft.lanPort}" ${disabled}></label></div>
156
157
  </section>
157
- <section class="connection-mode"><header><h3>${icon('cloud', 16)} 中转隧道</h3><span class="mode-status">${draft.relayProvider !== plan.relay.provider ? '选择待保存' : plan.relay.provider ? '计划已保存 · 待接入' : '未选择方案'}</span></header><p>本机 Driver → 中转服务 → 设备</p>
158
- <div class="relay-options" role="radiogroup" aria-label="中转服务方案">${RELAY_PROVIDERS.map((item) => `<label class="relay-choice"><input type="radio" name="relay-provider" data-field="relayProvider" data-provider="${item.id}" value="${item.id}" ${draft.relayProvider === item.id ? 'checked' : ''} ${disabled}>${item.name}</label>`).join('')}</div>
159
- <p class="provider-description">${provider ? escapeHtml(provider.description) : '选择用户自建、项目提供的服务,或 Cloudflare。两种连接方式可同时登记。'}</p>${draft.relayProvider === 'cloudflare' ? '<a class="provider-link" href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/use-cases/ssh/ssh-cloudflared-authentication/" target="_blank" rel="noopener noreferrer">查看 Cloudflare 官方 SSH 接入说明 ↗</a>' : ''}
160
- ${draft.relayProvider ? `<button class="text-button clear-relay" data-action="clear-relay" ${disabled}>取消中转选择</button>` : ''}
158
+ <section class="connection-mode"><header><h3>${icon('cloud', 16)} ${h("panel.150")}</h3><span class="mode-status">${draft.relayProvider !== plan.relay.provider ? t("panel.134") : plan.relay.provider ? t("panel.135") : t("panel.136")}</span></header><p>${h("panel.151")}</p>
159
+ <div class="relay-options" role="radiogroup" aria-label="${h("panel.152")}">${RELAY_PROVIDERS.map((item) => `<label class="relay-choice"><input type="radio" name="relay-provider" data-field="relayProvider" data-provider="${item.id}" value="${item.id}" ${draft.relayProvider === item.id ? 'checked' : ''} ${disabled}>${item.name}</label>`).join('')}</div>
160
+ <p class="provider-description">${provider ? escapeHtml(provider.description) : t("panel.137")}</p>${draft.relayProvider === 'cloudflare' ? `<a class="provider-link" href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/use-cases/ssh/ssh-cloudflared-authentication/" target="_blank" rel="noopener noreferrer">${h("panel.138")}</a>` : ''}
161
+ ${draft.relayProvider ? `<button class="text-button clear-relay" data-action="clear-relay" ${disabled}>${h("panel.139")}</button>` : ''}
161
162
  </section>
162
- <label class="connection-preference">连接策略<select id="connection-preference" data-field="preference" aria-label="连接策略" ${disabled}><option value="lan-first" ${draft.preference === 'lan-first' ? 'selected' : ''}>局域网优先,中转备用(待启用)</option><option value="relay-only" ${draft.preference === 'relay-only' ? 'selected' : ''}>仅使用中转(待启用)</option></select></label>
163
- <p class="connection-note">这里只保存本机连接计划。入口尚未验证,自动选路与新的中继接入尚未启用;检查 SSH 仍使用下方的现有配置。</p>
164
- ${state.connectionError ? `<p class="form-error" role="alert">${escapeHtml(state.connectionError)}</p>` : ''}<button class="button button-primary save-connections" data-action="save-connections" ${disabled}>${state.connectionSaving ? '正在保存…' : '保存连接计划'}</button>
165
- ${plan.saved ? `<p class="saved-plan-note">已保存于本机 · ${formatAgo(plan.updatedAt)}</p>` : ''}
166
- <details class="current-route"><summary>当前 SSH 访问路径与认证</summary>${currentRouteBody()}</details>`;
163
+ <label class="connection-preference">${h("panel.153")}<select id="connection-preference" data-field="preference" aria-label="${h("panel.153")}" ${disabled}><option value="lan-first" ${draft.preference === 'lan-first' ? 'selected' : ''}>${h("panel.154")}</option><option value="relay-only" ${draft.preference === 'relay-only' ? 'selected' : ''}>${h("panel.155")}</option></select></label>
164
+ <p class="connection-note">${h("panel.156")}</p>
165
+ ${state.connectionError ? `<p class="form-error" role="alert">${escapeHtml(t(state.connectionError))}</p>` : ''}<button class="button button-primary save-connections" data-action="save-connections" ${disabled}>${state.connectionSaving ? t("panel.54") : t("panel.140")}</button>
166
+ ${plan.saved ? `<p class="saved-plan-note">${h("panel.141")} ${formatAgo(plan.updatedAt)}</p>` : ''}
167
+ <details class="current-route"><summary>${h("panel.157")}</summary>${currentRouteBody()}</details>`;
167
168
  }
168
169
  function openConnections(deviceId) {
169
170
  const device = state.overview?.devices.find((item) => item.id === deviceId);
@@ -177,21 +178,21 @@ async function saveConnections() {
177
178
  try {
178
179
  await api.saveConnections({ ...state.connectionDraft });
179
180
  state.overview = await api.overview();
180
- toast('连接计划已保存到本机,尚未启用自动选路。');
181
+ toast(t("panel.158"));
181
182
  } catch (cause) {
182
183
  focusField = cause.message === 'invalid_lan_host' ? 'lan-host' : cause.message === 'invalid_lan_port' ? 'lan-port' : null;
183
- state.connectionError = ({ invalid_lan_host: '请输入设备地址,不含协议、路径或凭据;不能使用跳板侧回环地址。', invalid_lan_port: 'SSH 端口必须是 1 到 65535 的整数。', invalid_relay_provider: '请选择有效的中转方案。', ssh_alias_unavailable: '此设备引用不可用,请刷新后重试。' })[cause.message] ?? '保存失败,请确认本机服务仍在运行。';
184
+ state.connectionError = ({ invalid_lan_host: "panel.159", invalid_lan_port: "panel.160", invalid_relay_provider: "panel.161", ssh_alias_unavailable: "panel.162" })[cause.message] ?? "panel.163";
184
185
  } finally { state.connectionSaving = false; render(); (focusField ? root.querySelector(`#${focusField}`) : root.querySelector('[data-action="save-connections"]'))?.focus({ preventScroll: true }); }
185
186
  }
186
187
  function sshErrorText(code) {
187
- return ({ ssh_timeout: 'SSH 连接超时', ssh_host_key_unverified: '主机指纹尚未通过校验', ssh_authentication_failed: 'SSH 认证失败,请检查本机认证', ssh_connection_unavailable: 'SSH 入口暂时不可达', ssh_name_unresolved: 'SSH 地址无法解析', ssh_profile_needs_review: 'SSH 配置需人工核验', ssh_profile_incomplete: 'SSH 连接参数不完整', ssh_configuration_changed: 'SSH 配置已变化,请重新检查', ssh_check_in_progress: 'SSH 检查正在进行', ssh_observation_unsupported: 'SSH 已连通,目标系统暂不支持基础采集', ssh_observation_invalid: 'SSH 已连通,资源回报格式不正确', ssh_observation_command_failed: 'SSH 已连通,目标无法运行当前资源采集脚本' })[code] ?? 'SSH 检查失败,请核对现有连接';
188
+ return ({ ssh_timeout: t("panel.164"), ssh_host_key_unverified: t("panel.165"), ssh_authentication_failed: t("panel.166"), ssh_connection_unavailable: t("panel.167"), ssh_name_unresolved: t("panel.168"), ssh_profile_needs_review: t("panel.169"), ssh_profile_incomplete: t("panel.170"), ssh_configuration_changed: t("panel.171"), ssh_check_in_progress: t("panel.172"), ssh_observation_unsupported: t("panel.173"), ssh_observation_invalid: t("panel.174"), ssh_observation_command_failed: t("panel.175") })[code] ?? t("panel.176");
188
189
  }
189
190
  function scheduleCheckRefresh() {
190
191
  clearTimeout(checkRefreshTimer);
191
192
  if (checkRefreshFailures >= 3 || !(state.overview?.devices ?? []).some((device) => device.ssh?.checking || device.ssh?.queued)) return;
192
193
  checkRefreshTimer = setTimeout(async () => {
193
194
  try { state.overview = await api.overview(); checkRefreshFailures = 0; }
194
- catch { if (++checkRefreshFailures >= 3) toast('检查结果暂时无法读取;恢复服务后刷新设备查看。'); }
195
+ catch { if (++checkRefreshFailures >= 3) toast(t("panel.177")); }
195
196
  render();
196
197
  }, 1000);
197
198
  }
@@ -201,14 +202,14 @@ async function checkSshDevice(alias) {
201
202
  try {
202
203
  const result = await api.checkSsh(alias);
203
204
  state.overview = await api.overview();
204
- toast(result.ok ? 'SSH 已通过认证并采集基础资源,这是本次检查的快照。' : sshErrorText(result.error));
205
+ toast(result.ok ? t("panel.178") : sshErrorText(result.error));
205
206
  } catch (cause) { toast(sshErrorText(cause.message)); }
206
207
  finally { state.checkingSshAlias = null; render(); }
207
208
  }
208
209
  function clientLabel() {
209
- if (!state.status) return '正在检查';
210
- if (state.status.client.state === 'observed' && !recentMcpInitialize()) return '接入记录较早,请重新检查';
211
- return ({ waiting: '等待智能体接入', observed: '已收到 MCP 接入', stale: '接入记录较早,请重新检查' })[state.status.client.state];
210
+ if (!state.status) return t("panel.179");
211
+ if (state.status.client.state === 'observed' && !recentMcpInitialize()) return t("panel.180");
212
+ return ({ waiting: t("panel.181"), observed: t("panel.182"), stale: t("panel.180") })[state.status.client.state];
212
213
  }
213
214
  function recentMcpInitialize() {
214
215
  const time = state.status?.client.lastInitializeAt;
@@ -216,14 +217,14 @@ function recentMcpInitialize() {
216
217
  }
217
218
  function renderSidebar() {
218
219
  const projects = state.overview?.projects ?? [];
219
- if (cloudMode()) return `<aside class="sidebar"><a class="brand" href="/" aria-label="Device Center 首页"><span class="brand-mark">${icon('cloud')}</span><span><strong>Device Center</strong><small>云端控制面</small></span></a><div class="nav-caption">工作空间</div><nav aria-label="主导航"><button class="nav-item active" data-action="project" data-project="all">${icon('device')}<span>所有设备</span><small>${state.overview?.devices.length ?? 0}</small></button><button class="nav-item" data-action="secure-help">${icon('link')}<span>远程日志</span></button></nav><div class="nav-caption">项目</div><nav aria-label="项目导航"><p class="nav-empty">暂无项目</p></nav><div class="sidebar-footer"><span class="local-dot"></span> 云端运行<button class="text-button" data-action="logout">退出登录</button></div></aside>`;
220
- return `<aside class="sidebar"><a class="brand" href="/" aria-label="Device Center 首页"><span class="brand-mark">${icon('cloud')}</span><span><strong>Device Center</strong><small>本机连接器</small></span></a><div class="nav-caption">工作空间</div><nav aria-label="主导航"><button class="nav-item ${state.project === 'all' ? 'active' : ''}" data-action="project" data-project="all">${icon('device')}<span>所有设备</span><small>${state.overview?.devices.length ?? 0}</small></button><button class="nav-item" data-action="connect">${icon('link')}<span>智能体连接</span></button><button class="nav-item" data-action="secure-help">${icon('link')}<span>远程日志</span></button><button class="nav-item" data-action="cloud-connect">${icon('cloud')}<span>云端面板</span></button></nav><div class="nav-caption">项目</div><nav aria-label="项目导航">${projects.length ? projects.map((p) => `<button class="nav-item ${state.project === p.id ? 'active' : ''}" data-action="project" data-project="${escapeHtml(p.id)}">${icon('folder')}<span>${escapeHtml(p.name)}</span></button>`).join('') : '<p class="nav-empty">暂无项目</p>'}</nav><div class="sidebar-footer"><span class="local-dot"></span> 本机运行<button class="text-button" data-action="resident">后台运行说明</button></div></aside>`;
220
+ if (cloudMode()) return `<aside class="sidebar"><a class="brand" href="/?lang=${getLocale()}" aria-label="${h("panel.183")}"><span class="brand-mark">${icon('cloud')}</span><span><strong>Device Center</strong><small>${h("panel.184")}</small></span></a><div class="nav-caption">${h("panel.185")}</div><nav aria-label="${h("panel.186")}"><button class="nav-item active" data-action="project" data-project="all">${icon('device')}<span>${h("panel.187")}</span><small>${state.overview?.devices.length ?? 0}</small></button><button class="nav-item" data-action="secure-help">${icon('link')}<span>${h("panel.188")}</span></button></nav><div class="nav-caption">${h("panel.189")}</div><nav class="project-navigation" aria-label="${h("panel.190")}"><p class="nav-empty">${h("panel.191")}</p></nav><div class="sidebar-footer"><span class="local-dot"></span> ${h("panel.192")}<button class="text-button" data-action="logout">${h("panel.193")}</button></div></aside>`;
221
+ return `<aside class="sidebar"><a class="brand" href="/?lang=${getLocale()}" aria-label="${h("panel.183")}"><span class="brand-mark">${icon('cloud')}</span><span><strong>Device Center</strong><small>${h("panel.194")}</small></span></a><div class="nav-caption">${h("panel.185")}</div><nav aria-label="${h("panel.186")}"><button class="nav-item ${state.project === 'all' ? 'active' : ''}" data-action="project" data-project="all">${icon('device')}<span>${h("panel.187")}</span><small>${state.overview?.devices.length ?? 0}</small></button><button class="nav-item" data-action="connect">${icon('link')}<span>${h("panel.195")}</span></button><button class="nav-item" data-action="secure-help">${icon('link')}<span>${h("panel.188")}</span></button><button class="nav-item" data-action="cloud-connect">${icon('cloud')}<span>${h("panel.196")}</span></button></nav><div class="nav-caption">${h("panel.189")}</div><nav class="project-navigation" aria-label="${h("panel.190")}">${projects.length ? projects.map((p) => `<button class="nav-item ${state.project === p.id ? 'active' : ''}" data-action="project" data-project="${escapeHtml(p.id)}">${icon('folder')}<span>${escapeHtml(p.name)}</span></button>`).join('') : `<p class="nav-empty">${h("panel.191")}</p>`}</nav><div class="sidebar-footer"><span class="local-dot"></span> ${h("panel.197")}<button class="text-button" data-action="resident">${h("panel.198")}</button></div></aside>`;
221
222
  }
222
223
  function renderConnection() {
223
224
  if (state.loading && !state.overview) return '';
224
- if (cloudMode()) { const count = (state.overview?.drivers ?? []).filter(item => item.revokedAt == null).length; return `<section class="driver-summary" aria-label="云端接入状态"><span class="local-dot"></span><strong>${count ? `已绑定 ${count} 台管理电脑` : '控制面已上线'}</strong><p>${state.cloudRefreshError ?? (count ? '只读回报 · 约每 15 秒刷新面板' : '先连接自己的电脑,再查看资源')}</p></section>`; }
225
+ if (cloudMode()) { const count = (state.overview?.drivers ?? []).filter(item => item.revokedAt == null).length; return `<section class="driver-summary" aria-label="${h("panel.204")}"><span class="local-dot"></span><strong>${count ? h("count.bound", { count }) : t("panel.201")}</strong><p>${state.cloudRefreshError ? t(state.cloudRefreshError) : (count ? t("panel.202") : t("panel.203"))}</p></section>`; }
225
226
  const container = state.status?.service.runtime === 'container';
226
- return `<section class="driver-summary" aria-label="本机 Driver"><span class="local-dot"></span><strong>${container ? '容器 Driver 已运行' : '本机已作为 Driver'}</strong><p>${container ? '宿主设备不可见' : '已有 SSH 可直接导入'}</p><button class="text-button initialize-entry" data-action="start">开始使用</button></section>`;
227
+ return `<section class="driver-summary" aria-label="${h("panel.29")}"><span class="local-dot"></span><strong>${container ? t("panel.205") : t("panel.206")}</strong><p>${container ? t("panel.207") : t("panel.208")}</p><button class="text-button initialize-entry" data-action="start">${h("panel.209")}</button></section>`;
227
228
  }
228
229
  function managementComputer() { return state.overview?.devices.find((item) => item.source === 'local-runtime'); }
229
230
  function openAgentInstall() {
@@ -233,12 +234,12 @@ function openAgentInstall() {
233
234
  state.toast = null; state.dialog = 'agent-install';
234
235
  }
235
236
  function installDraft() {
236
- try { return { text: agentInstallPrompt({ mode: state.installMode, background: state.installBackground, ...(state.installMode === 'self-hosted' ? { origin: state.installOrigin.trim() } : {}) }), error: null }; }
237
- catch (error) { return { text: '', error: error.message }; }
237
+ try { return { text: agentInstallPrompt({ locale: getLocale(), mode: state.installMode, background: state.installBackground, ...(state.installMode === 'self-hosted' ? { origin: state.installOrigin.trim() } : {}) }), error: null }; }
238
+ catch (error) { return { text: '', error: t(error.translationKey ?? 'error.install') }; }
238
239
  }
239
240
  function agentInstallBody() {
240
241
  const draft = installDraft();
241
- return `<p class="modal-intro">复制一次,交给正在这台电脑上运行的 Agent。它会检查环境、安装本机 Driver,并配置智能体连接。</p><div class="method-tabs install-mode-tabs" role="group" aria-label="安装方式">${[['local', '本机使用'], ['official', '官方托管'], ['self-hosted', '自建服务']].map(([mode, label]) => `<button data-action="install-mode" data-mode="${mode}" aria-pressed="${state.installMode === mode}" class="${state.installMode === mode ? 'active' : ''}">${label}</button>`).join('')}</div><p class="small-note">${state.installMode === 'local' ? '无需账号。本机面板与 MCP 都在电脑上,默认不申请云端绑定。' : state.installMode === 'official' ? '使用你的 App Services 账号管理设备,官方服务提供面板与加密中转;Driver 和私钥仍在设备上。' : '接入已经部署的自建控制面。这里不自动部署服务器或迁移已有绑定。'}</p>${state.installMode === 'self-hosted' ? `<label class="field-label" for="install-origin">自建控制面的 HTTPS 地址</label><input id="install-origin" data-field="installOrigin" type="url" value="${escapeHtml(state.installOrigin)}" placeholder="https://devices.example.com" autocomplete="off" spellcheck="false">` : ''}<label class="binding-scope"><input type="checkbox" data-field="installBackground" ${state.installBackground ? 'checked' : ''}>登录系统后在后台运行(用户级服务)</label><ol class="install-flow"><li>Agent 检查环境并安装</li><li>${state.installMode === 'local' ? '无需云端配对' : '你登录账号并核对绑定'}</li><li>Agent 核验面板、资源和 MCP</li></ol><p id="install-error" class="form-error" role="alert" ${draft.error ? '' : 'hidden'}>${escapeHtml(draft.error ?? '')}</p><button class="button button-primary install-copy" data-action="agent-install-copy" ${draft.error ? 'disabled' : ''}>${icon('copy', 15)} 复制给 Agent</button><details class="install-details"><summary>查看完整安装说明</summary><textarea id="agent-install-text" aria-label="交给 Agent 的安装说明" readonly rows="7" spellcheck="false">${escapeHtml(draft.text)}</textarea></details><p class="small-note">支持 macOS / Linux,需要 Node.js 24。说明固定使用 0.2.0-beta.2。本页只复制说明;安装由你交给的 Agent 在本机执行。</p>`;
242
+ return `<p class="modal-intro">${h("panel.219")}</p><div class="method-tabs install-mode-tabs" role="group" aria-label="${h("panel.220")}">${[['local', t("panel.210")], ['official', t("panel.211")], ['self-hosted', t("panel.212")]].map(([mode, label]) => `<button data-action="install-mode" data-mode="${mode}" aria-pressed="${state.installMode === mode}" class="${state.installMode === mode ? 'active' : ''}">${label}</button>`).join('')}</div><p class="small-note">${state.installMode === 'local' ? t("panel.213") : state.installMode === 'official' ? t("panel.214") : t("panel.215")}</p>${state.installMode === 'self-hosted' ? `<label class="field-label" for="install-origin">${h("panel.216")}</label><input id="install-origin" data-field="installOrigin" type="url" value="${escapeHtml(state.installOrigin)}" placeholder="https://devices.example.com" autocomplete="off" spellcheck="false">` : ''}<label class="binding-scope"><input type="checkbox" data-field="installBackground" ${state.installBackground ? 'checked' : ''}>${h("panel.221")}</label><ol class="install-flow"><li>${h("panel.222")}</li><li>${state.installMode === 'local' ? t("panel.217") : t("panel.218")}</li><li>${h("panel.223")}</li></ol><p id="install-error" class="form-error" role="alert" ${draft.error ? '' : 'hidden'}>${escapeHtml(draft.error ?? '')}</p><button class="button button-primary install-copy" data-action="agent-install-copy" ${draft.error ? 'disabled' : ''}>${icon('copy', 15)} ${h("panel.224")}</button><details class="install-details"><summary>${h("panel.225")}</summary><textarea id="agent-install-text" aria-label="${h("panel.226")}" readonly rows="7" spellcheck="false">${escapeHtml(draft.text)}</textarea></details><p class="small-note">${h("panel.227")}</p>`;
242
243
  }
243
244
  async function copyAgentInstall() {
244
245
  const field = root.querySelector('#agent-install-text');
@@ -249,23 +250,23 @@ async function copyAgentInstall() {
249
250
  root.querySelector('.install-details').open = true; field.focus(); field.select();
250
251
  try { copied = document.execCommand('copy'); } catch { /* manual selection remains available */ }
251
252
  }
252
- toast(copied ? '已复制。粘贴给目标电脑上的 Agent;需要绑定时由你登录并核对。' : '请手动复制已选中的安装说明。');
253
+ toast(copied ? t("panel.228") : t("panel.229"));
253
254
  if (!copied) { root.querySelector('.install-details').open = true; root.querySelector('#agent-install-text')?.focus(); root.querySelector('#agent-install-text')?.select(); }
254
255
  }
255
256
  function cloudMode() { return state.overview?.mode === 'cloud' || state.status?.service.runtime === 'cloud'; }
256
257
  function managementReady() { return state.status?.service.runtime === 'native' && managementComputer()?.scope === 'host'; }
257
258
  function managementBody() {
258
259
  const device = managementComputer();
259
- if (!state.status || !device) return '<div class="plain-panel"><h3>无法确认管理电脑</h3><p>请确认本机服务仍在运行,再重新读取。</p><button class="button button-light" data-action="refresh">重新读取本机服务</button></div>';
260
+ if (!state.status || !device) return `<div class="plain-panel"><h3>${h("panel.230")}</h3><p>${h("panel.231")}</p><button class="button button-light" data-action="refresh">${h("panel.232")}</button></div>`;
260
261
  const ready = managementReady();
261
- return `<p class="modal-intro">管理电脑是你平时使用智能体、保存 SSH 配置的电脑。</p><div class="management-card"><span>${icon('device', 22)}</span><div><strong>${ready ? escapeHtml(device.name) : '管理电脑尚未接入'}</strong><p>${ready ? `${escapeHtml(device.platform)} · 当前服务所在电脑,已自动加入设备列表` : '当前服务在容器内,不能读取宿主电脑的 SSH 配置'}</p></div><small>${ready ? '本机资源可读' : '待接入'}</small></div><p class="small-note">${ready ? '这台电脑已经就绪,下一步连接智能体。不需要重新添加本机或复制密钥。' : '需要在你的电脑运行本机 Driver。在宿主电脑运行源码 Driver,通过 HTTPS 控制面配对;容器不能替你导入宿主配置。'}</p><details class="hosted-onboarding"><summary>服务部署在服务器或 Docker 上?</summary><p>服务所在电脑和你正在操作的电脑可能不同。云端面板应先让你连接自己的管理电脑,再从那台电脑导入 SSH 配置。</p><ol class="simple-steps"><li>在云端点“连接我的电脑”。</li><li>在自己的电脑安装或启动 Driver,核对控制面身份并批准一次绑定。</li><li>在本机面板连接智能体,一键导入该电脑的 SSH 主机。</li></ol><p>私钥、SSH 认证与局域网地址留在管理电脑。云端只同步获准的设备登记信息;管理电脑离线时,依赖它的 SSH 路径不能继续使用。</p><p>当前可从云端下载源码 Driver、运行接入命令并核对配对;签名安装包和配置迁移仍待实现。不能用服务器自己的 SSH 配置代替你的电脑。</p><p class="small-note">请在已部署的云端面板点“连接我的电脑”。</p></details>`;
262
+ return `<p class="modal-intro">${h("panel.240")}</p><div class="management-card"><span>${icon('device', 22)}</span><div><strong>${ready ? escapeHtml(device.name) : t("panel.233")}</strong><p>${ready ? `${escapeHtml(device.platform)} ${h("panel.234")}` : t("panel.235")}</p></div><small>${ready ? t("panel.236") : t("panel.237")}</small></div><p class="small-note">${ready ? t("panel.238") : t("panel.239")}</p><details class="hosted-onboarding"><summary>${h("panel.241")}</summary><p>${h("panel.242")}</p><ol class="simple-steps"><li>${h("panel.243")}</li><li>${h("panel.244")}</li><li>${h("panel.245")}</li></ol><p>${h("panel.246")}</p><p>${h("panel.247")}</p><p class="small-note">${h("panel.248")}</p></details>`;
262
263
  }
263
264
  function onboardingBody() {
264
265
  const ready = managementReady();
265
266
  const seen = recentMcpInitialize();
266
- const steps = ['管理电脑', '智能体', '设备'];
267
+ const steps = [t("panel.249"), t("panel.250"), t("panel.251")];
267
268
  const body = state.setupStep === 0 ? managementBody() : state.setupStep === 1 ? connectionBody() : initializationBody();
268
- return `<nav class="onboarding-steps" aria-label="首次使用步骤">${steps.map((label, index) => `<button data-action="setup-step" data-step="${index}" ${index > 0 && !ready ? 'disabled' : ''} ${index === state.setupStep ? 'aria-current="step"' : ''}><span>${index === 0 && ready || index === 1 && seen ? icon('check', 12) : index + 1}</span>${label}</button>`).join('')}</nav>${body}<footer class="onboarding-footer">${state.setupStep > 0 ? '<button class="button button-light" data-action="setup-step" data-step="0">管理电脑</button>' : ''}${state.setupStep === 0 ? `<button class="button button-primary" data-action="setup-step" data-step="1" ${!ready ? 'disabled' : ''}>下一步:连接智能体</button>` : state.setupStep === 1 ? `<button class="button ${seen ? 'button-primary' : 'button-light'}" data-action="setup-step" data-step="2">${seen ? '下一步:导入设备' : '稍后连接,先导入设备'}</button>` : '<button class="button button-primary" data-action="setup-finish">查看设备列表</button>'}</footer>`;
269
+ return `<nav class="onboarding-steps" aria-label="${h("panel.256")}">${steps.map((label, index) => `<button data-action="setup-step" data-step="${index}" ${index > 0 && !ready ? 'disabled' : ''} ${index === state.setupStep ? 'aria-current="step"' : ''}><span>${index === 0 && ready || index === 1 && seen ? icon('check', 12) : index + 1}</span>${label}</button>`).join('')}</nav>${body}<footer class="onboarding-footer">${state.setupStep > 0 ? `<button class="button button-light" data-action="setup-step" data-step="0">${h("panel.249")}</button>` : ''}${state.setupStep === 0 ? `<button class="button button-primary" data-action="setup-step" data-step="1" ${!ready ? 'disabled' : ''}>${h("panel.252")}</button>` : state.setupStep === 1 ? `<button class="button ${seen ? 'button-primary' : 'button-light'}" data-action="setup-step" data-step="2">${seen ? t("panel.253") : t("panel.254")}</button>` : `<button class="button button-primary" data-action="setup-finish">${h("panel.255")}</button>`}</footer>`;
269
270
  }
270
271
  function openSetup(entry = 'start', step) {
271
272
  state.setupReturn = entry; state.toast = null; state.checkMessage = null;
@@ -280,12 +281,12 @@ function selectSetupStep(step) {
280
281
  }
281
282
  function connectionBody() {
282
283
  const terminal = state.method === 'terminal';
283
- return `<p class="modal-intro">复制下面的地址,添加到你这台电脑上的 Codex。已有配置可直接检查,无需重复添加。</p><div class="method-tabs" role="group" aria-label="接入方式"><button data-action="method" data-method="settings" aria-pressed="${!terminal}" class="${!terminal ? 'active' : ''}">在 Codex 设置中添加</button><button data-action="method" data-method="terminal" aria-pressed="${terminal}" class="${terminal ? 'active' : ''}">用命令行添加</button></div>${terminal ? `<ol class="simple-steps"><li>在本机终端运行下面这一行。</li><li>在 Codex 的 MCP 设置中重新启动连接,或重开会话。</li></ol><label class="field-label" for="copy-field">Codex 接入命令</label><textarea id="copy-field" readonly rows="3" spellcheck="false">${escapeHtml(state.setup?.setupCommand ?? '')}</textarea><button class="button button-primary" data-action="copy" data-copy="command">${icon('copy', 15)} 复制命令</button>` : `<ol class="simple-steps"><li>打开 Codex <strong>设置 → MCP servers → Add server</strong>。</li><li>名称填 <code>device-center-connector</code>,类型选 <strong>Streamable HTTP</strong>,粘贴下面的地址并保存。</li><li>点击 Restart,再回到这里检查接入。</li></ol><label class="field-label" for="copy-field">本机连接地址</label><div class="copy-row"><input id="copy-field" value="${escapeHtml(state.setup?.url ?? '')}" readonly aria-label="本机连接地址"><button class="button button-primary" data-action="copy" data-copy="url">${icon('copy', 15)} 复制地址</button></div>`}<div class="connection-check"><div><strong>${clientLabel()}</strong><p>${state.checkMessage ?? (state.status?.client.lastInitializeAt ? `${formatAgo(state.status.client.lastInitializeAt)}收到本机 MCP 初始化。` : '保存配置后,需要在智能体中启动连接。')}</p></div><button class="button button-light" data-action="check" ${state.checking ? 'disabled' : ''}>${state.checking ? '检查中…' : '检查接入'}</button></div><p class="small-note">接下来导入设备,智能体即可读取资源清单。远程任务仍待实现,接入不授予操作权限。</p><details class="connector-help"><summary>接入说明与排查</summary><p>接入后可让 Codex 调用 <code>device_center_list_resources</code>。检查按钮只读取接入记录,不会模拟握手;记录不证明身份或持续在线。</p><p>确认本机服务还在运行、地址端口一致、类型选了 Streamable HTTP。重新启动 Codex 中的连接后再检查。远程或云端会话无法通过这个地址访问你的电脑。</p></details>`;
284
+ return `<p class="modal-intro">${h("panel.273")}</p><div class="method-tabs" role="group" aria-label="${h("panel.274")}"><button data-action="method" data-method="settings" aria-pressed="${!terminal}" class="${!terminal ? 'active' : ''}">${h("panel.275")}</button><button data-action="method" data-method="terminal" aria-pressed="${terminal}" class="${terminal ? 'active' : ''}">${h("panel.276")}</button></div>${terminal ? `<ol class="simple-steps"><li>${h("panel.257")}</li><li>${h("panel.258")}</li></ol><label class="field-label" for="copy-field">${h("panel.259")}</label><textarea id="copy-field" readonly rows="3" spellcheck="false">${escapeHtml(state.setup?.setupCommand ?? '')}</textarea><button class="button button-primary" data-action="copy" data-copy="command">${icon('copy', 15)} ${h("panel.260")}</button>` : `<ol class="simple-steps"><li>${h("panel.261")} <strong>${h("panel.262")}</strong>.</li><li>${h("panel.263")} <code>device-center-connector</code>${h("panel.264")} <strong>Streamable HTTP</strong>${h("panel.265")}</li><li>${h("panel.266")}</li></ol><label class="field-label" for="copy-field">${h("panel.267")}</label><div class="copy-row"><input id="copy-field" value="${escapeHtml(state.setup?.url ?? '')}" readonly aria-label="${h("panel.267")}"><button class="button button-primary" data-action="copy" data-copy="url">${icon('copy', 15)} ${h("panel.268")}</button></div>`}<div class="connection-check"><div><strong>${clientLabel()}</strong><p>${state.checkMessage ? t(state.checkMessage) : (state.status?.client.lastInitializeAt ? `${formatAgo(state.status.client.lastInitializeAt)}${h("panel.269")}` : t("panel.270"))}</p></div><button class="button button-light" data-action="check" ${state.checking ? 'disabled' : ''}>${state.checking ? t("panel.271") : t("panel.272")}</button></div><p class="small-note">${h("panel.277")}</p><details class="connector-help"><summary>${h("panel.278")}</summary><p>${h("panel.279")} <code>device_center_list_resources</code>${h("panel.280")}</p><p>${h("panel.281")}</p></details>`;
284
285
  }
285
286
  function addBody() {
286
287
  if (cloudMode()) return cloudBindingBody();
287
288
  const candidates = state.ssh?.candidates ?? [];
288
- return `<p class="modal-intro">本机已在设备列表中。选择已有 SSH 主机,交给本机 Driver 管理。</p><div class="ssh-toolbar"><strong>本机 SSH 配置 · ${candidates.length} 个候选</strong><button class="button button-light" data-action="ssh-refresh" ${state.sshLoading || state.saving ? 'disabled' : ''}>${state.sshLoading ? '读取中…' : '重新读取'}</button></div>${state.sshError ? `<p class="form-error" role="alert">${escapeHtml(state.sshError)}</p>` : ''}${state.sshLoading ? '<p class="small-note">正在读取本机 SSH 配置…</p>' : candidates.length ? `<div class="ssh-candidates" role="group" aria-label="SSH 主机候选">${candidates.map((candidate, index) => `<label class="ssh-candidate"><input id="ssh-choice-${index}" type="checkbox" data-field="ssh-alias" data-alias="${escapeHtml(candidate.alias)}" ${state.selectedAliases.includes(candidate.alias) ? 'checked' : ''} ${candidate.associated || state.saving ? 'disabled' : ''}><span><strong>${escapeHtml(candidate.alias)}</strong><small>${escapeHtml(candidate.source)}</small></span><small>${candidate.associated ? '已关联' : '未连接'}</small></label>`).join('')}</div><button class="button button-primary associate-button" data-action="associate" ${!state.selectedAliases.length || state.saving ? 'disabled' : ''}>${state.saving ? '保存中…' : `关联选中的 ${state.selectedAliases.length} 台设备`}</button>` : '<div class="plain-panel"><h3>没有找到明确的 SSH 主机别名</h3><p>先在系统 SSH 配置中添加目标主机的 Host 别名,再回来重新读取。通配规则不会当作设备。</p></div>'}<p class="small-note">关联后自动检查一次当前 SSH 路径,并标记结果;使用本机现有认证,最多同时检查两台。失败会保留设备和原因,可在卡片重试。私钥留在本机。</p>${state.ssh?.notices?.length ? `<details><summary>配置读取说明</summary>${state.ssh.notices.map((notice) => `<p>${escapeHtml(notice)}</p>`).join('')}</details>` : ''}<details><summary>目标主机还没有 SSH?</summary><p>这一步需要目标机已有 SSH 服务和本机的 SSH 配置。局域网与公网都复用已有配置,不扫描网络或打开端口。</p><p>可在该设备运行 npm 接入命令,先绑定同一云端账号,再单独授权日志。点击侧栏“云端面板”获取命令;不需要 SSH 服务。设备在本机生成身份密钥,私钥留在设备,配对码单独输入。</p></details>`;
289
+ return `<p class="modal-intro">${h("panel.294")}</p><div class="ssh-toolbar"><strong>${h("count.candidates", { count: candidates.length })}</strong><button class="button button-light" data-action="ssh-refresh" ${state.sshLoading || state.saving ? 'disabled' : ''}>${state.sshLoading ? t("panel.282") : t("panel.283")}</button></div>${state.sshError ? `<p class="form-error" role="alert">${escapeHtml(t(state.sshError))}</p>` : ''}${state.sshLoading ? `<p class="small-note">${h("panel.284")}</p>` : candidates.length ? `<div class="ssh-candidates" role="group" aria-label="${h("panel.290")}">${candidates.map((candidate, index) => `<label class="ssh-candidate"><input id="ssh-choice-${index}" type="checkbox" data-field="ssh-alias" data-alias="${escapeHtml(candidate.alias)}" ${state.selectedAliases.includes(candidate.alias) ? 'checked' : ''} ${candidate.associated || state.saving ? 'disabled' : ''}><span><strong>${escapeHtml(candidate.alias)}</strong><small>${escapeHtml(candidate.source)}</small></span><small>${candidate.associated ? t("panel.285") : t("panel.286")}</small></label>`).join('')}</div><button class="button button-primary associate-button" data-action="associate" ${!state.selectedAliases.length || state.saving ? 'disabled' : ''}>${state.saving ? t("panel.287") : h("count.associate", { count: state.selectedAliases.length })}</button>` : `<div class="plain-panel"><h3>${h("panel.291")}</h3><p>${h("panel.292")}</p></div>`}<p class="small-note">${h("panel.297")}</p>${state.ssh?.notices?.length ? `<details><summary>${h("panel.293")}</summary>${state.ssh.notices.map((notice) => `<p>${escapeHtml(reportMessage(notice))}</p>`).join('')}</details>` : ''}<details><summary>${h("panel.298")}</summary><p>${h("panel.299")}</p><p>${h("panel.300")}</p></details>`;
289
290
  }
290
291
  function driverCommand() {
291
292
  return connectCommand(state.overview?.controlOrigin ?? window.location.origin, state.requestSsh);
@@ -294,46 +295,46 @@ function cloudConnectDraft() {
294
295
  const official = state.cloudChoice === 'official';
295
296
  let command = '', error = null;
296
297
  try { command = connectCommand(official ? OFFICIAL_CONTROL_ORIGIN : state.customOrigin.trim()); }
297
- catch (cause) { if (state.customOrigin.trim()) error = cause.message; }
298
+ catch (cause) { if (state.customOrigin.trim()) error = t(cause.translationKey ?? 'error.origin'); }
298
299
  return { command, error };
299
300
  }
300
301
  function cloudConnectBody() {
301
302
  const official = state.cloudChoice === 'official';
302
303
  const { command, error } = cloudConnectDraft();
303
- return `<p class="modal-intro">本机与已有 SSH 设备可直接使用,无需平台注册。需要在外查看资源时,再绑定云端账号。</p><div class="method-tabs" role="group" aria-label="控制面选择"><button data-action="cloud-choice" data-provider="official" aria-pressed="${official}" class="${official ? 'active' : ''}">官方控制面</button><button data-action="cloud-choice" data-provider="self-hosted" aria-pressed="${!official}" class="${!official ? 'active' : ''}">自建服务</button></div>${official ? '<p class="small-note">官方服务使用 App Services 统一账号,每个账号独立管理设备。日志密文中转默认 100MiB/账号/日;操作需在设备端单独授权。</p>' : '<label class="field-label" for="custom-origin">自建控制面的 HTTPS 地址</label><input id="custom-origin" data-field="customOrigin" type="url" placeholder="https://devices.example.com" value="' + escapeHtml(state.customOrigin) + '" autocomplete="off" spellcheck="false">'}<ol class="simple-steps"><li>需要 Node.js 24,支持 macOS / Linux。在自己的电脑终端运行下面的命令。</li><li>打开对应云端面板,登录自己的账号。</li><li>输入终端显示的配对码,核对完整指纹后批准。保持终端打开。</li></ol><textarea id="cloud-connect-command" readonly rows="3" spellcheck="false" aria-label="云端接入命令">${escapeHtml(command)}</textarea><p id="cloud-command-error" class="form-error" role="alert" ${!error ? 'hidden' : ''}>${escapeHtml(error ?? '')}</p><button class="button button-primary" data-action="cloud-command-copy" ${!command ? 'disabled' : ''}>${icon('copy', 15)} 复制接入命令</button><p class="small-note">命令中没有私钥或登记凭据。执行后才在本机生成私有身份;本页不会替你执行命令或绑定。已绑定其他控制面时,需要先明确断开。</p><details class="connector-help"><summary>绑定后可以做什么?</summary><p>当前仅回报本机名称、系统、内存与磁盘,不分享 SSH 配置。私钥留在本机;绑定不授予远程操作权限。</p><p>指定日志可经双向 TLS 认证读取:明确授权的局域网地址优先,网络不可达才走云端密文中转。点击侧栏“远程日志”查看授权步骤。通用文件传输和任意命令尚未提供。</p></details>`;
304
+ return `<p class="modal-intro">${h("panel.302")}</p><div class="method-tabs" role="group" aria-label="${h("panel.303")}"><button data-action="cloud-choice" data-provider="official" aria-pressed="${official}" class="${official ? 'active' : ''}">${h("panel.304")}</button><button data-action="cloud-choice" data-provider="self-hosted" aria-pressed="${!official}" class="${!official ? 'active' : ''}">${h("panel.212")}</button></div>${official ? `<p class="small-note">${h("panel.301")}</p>` : `<label class="field-label" for="custom-origin">${h("panel.216")}</label><input id="custom-origin" data-field="customOrigin" type="url" placeholder="https://devices.example.com" value="` + escapeHtml(state.customOrigin) + '" autocomplete="off" spellcheck="false">'}<ol class="simple-steps"><li>${h("panel.305")}</li><li>${h("panel.306")}</li><li>${h("panel.307")}</li></ol><textarea id="cloud-connect-command" readonly rows="3" spellcheck="false" aria-label="${h("panel.308")}">${escapeHtml(command)}</textarea><p id="cloud-command-error" class="form-error" role="alert" ${!error ? 'hidden' : ''}>${escapeHtml(error ?? '')}</p><button class="button button-primary" data-action="cloud-command-copy" ${!command ? 'disabled' : ''}>${icon('copy', 15)} ${h("panel.309")}</button><p class="small-note">${h("panel.310")}</p><details class="connector-help"><summary>${h("panel.311")}</summary><p>${h("panel.312")}</p><p>${h("panel.313")}</p></details>`;
304
305
  }
305
306
  function logCommandDraft() {
306
307
  const peers = cloudMode() ? state.overview?.drivers?.filter(p=>p.revokedAt == null) ?? [] : state.remotePeers;
307
308
  const manager = peers.find(p=>p.id === state.logManager), target = peers.find(p=>p.id === state.logTarget);
308
309
  try { return { ...logAccessCommands({manager:manager?.fingerprint,target:target?.fingerprint,path:state.logPath,label:state.logLabel}), error:null }; }
309
- catch(error) { return {trust:'',grant:'',error:error.message}; }
310
+ catch(error) { return {trust:'',grant:'',error:t(error.translationKey ?? 'error.devices')}; }
310
311
  }
311
312
  function logCommandFields() {
312
313
  const peers = cloudMode() ? state.overview?.drivers?.filter(p=>p.revokedAt == null) ?? [] : state.remotePeers;
313
- if (state.remoteLoading) return '<p class="small-note">正在读取同账号设备…</p>';
314
- if (state.remoteError) return '<p class="form-error">无法读取设备,请检查本机绑定、网络和云端状态。</p>';
315
- if (peers.length < 2) return '<p class="small-note">接入两台设备后,这里可以选择管理电脑与目标并生成具体授权命令。下方提供手动步骤。</p>';
316
- const options = selected => '<option value="">请选择设备</option>' + peers.map(p=>`<option value="${escapeHtml(p.id)}" ${p.id===selected?'selected':''}>${escapeHtml(p.name)} · ${escapeHtml(p.fingerprint.slice(0,12))}…</option>`).join('');
314
+ if (state.remoteLoading) return `<p class="small-note">${h("panel.314")}</p>`;
315
+ if (state.remoteError) return `<p class="form-error">${h("panel.315")}</p>`;
316
+ if (peers.length < 2) return `<p class="small-note">${h("panel.316")}</p>`;
317
+ const options = selected => `<option value="">${h("panel.317")}</option>` + peers.map(p=>`<option value="${escapeHtml(p.id)}" ${p.id===selected?'selected':''}>${escapeHtml(p.name)} · ${escapeHtml(p.fingerprint.slice(0,12))}…</option>`).join('');
317
318
  const draft=logCommandDraft();
318
- return `<div class="plain-panel"><h3>生成授权命令</h3><label class="field-label" for="log-manager">管理电脑(运行智能体)</label><select id="log-manager" data-log-field="logManager">${options(state.logManager)}</select><label class="field-label" for="log-target">目标设备(日志所在)</label><select id="log-target" data-log-field="logTarget">${options(state.logTarget)}</select><label class="field-label" for="log-path">目标上的日志绝对路径</label><input id="log-path" data-log-field="logPath" value="${escapeHtml(state.logPath)}" placeholder="/home/me/app.log" autocomplete="off" spellcheck="false"><label class="field-label" for="log-label">日志标签</label><input id="log-label" data-log-field="logLabel" value="${escapeHtml(state.logLabel)}" maxlength="40"><p id="log-command-error" class="small-note">${escapeHtml(draft.error??'核对两端完整指纹后分别执行;60分钟有效。')}</p><label class="field-label" for="log-trust-command">在管理电脑运行</label><textarea id="log-trust-command" readonly rows="2">${escapeHtml(draft.trust)}</textarea><button class="button button-light" data-action="log-command-copy" data-field-id="log-trust-command" ${draft.error?'disabled':''}>复制管理电脑命令</button><label class="field-label" for="log-grant-command">在目标设备运行</label><textarea id="log-grant-command" readonly rows="3">${escapeHtml(draft.grant)}</textarea><button class="button button-primary" data-action="log-command-copy" data-field-id="log-grant-command" ${draft.error?'disabled':''}>复制目标授权命令</button><p class="small-note">不会在网页执行或授予权限。日志路径只用于本页生成命令,不提交到云端或写入浏览器存储。</p></div>`;
319
+ return `<div class="plain-panel"><h3>${h("panel.319")}</h3><label class="field-label" for="log-manager">${h("panel.320")}</label><select id="log-manager" data-log-field="logManager">${options(state.logManager)}</select><label class="field-label" for="log-target">${h("panel.321")}</label><select id="log-target" data-log-field="logTarget">${options(state.logTarget)}</select><label class="field-label" for="log-path">${h("panel.322")}</label><input id="log-path" data-log-field="logPath" value="${escapeHtml(state.logPath)}" placeholder="/home/me/app.log" autocomplete="off" spellcheck="false"><label class="field-label" for="log-label">${h("panel.323")}</label><input id="log-label" data-log-field="logLabel" value="${escapeHtml(state.logLabel)}" maxlength="40"><p id="log-command-error" class="small-note">${escapeHtml(draft.error??t("panel.318"))}</p><label class="field-label" for="log-trust-command">${h("panel.324")}</label><textarea id="log-trust-command" readonly rows="2">${escapeHtml(draft.trust)}</textarea><button class="button button-light" data-action="log-command-copy" data-field-id="log-trust-command" ${draft.error?'disabled':''}>${h("panel.325")}</button><label class="field-label" for="log-grant-command">${h("panel.326")}</label><textarea id="log-grant-command" readonly rows="3">${escapeHtml(draft.grant)}</textarea><button class="button button-primary" data-action="log-command-copy" data-field-id="log-grant-command" ${draft.error?'disabled':''}>${h("panel.327")}</button><p class="small-note">${h("panel.328")}</p></div>`;
319
320
  }
320
321
  function secureHelpBody() {
321
- return `<p class="modal-intro">先将管理电脑与目标设备接入同一个账号。绑定只回报资源;以下授权在各自电脑的终端完成。</p>${logCommandFields()}<ol class="simple-steps"><li>两台电脑固定安装 <code>npm install -g device-center@0.2.0-beta.2</code>,保持 Driver 运行。旧版身份先运行 <code>device-center access enable --confirm</code>。</li><li>运行 <code>device-center access peers</code>,通过可信渠道核对两台电脑的完整指纹。</li><li>管理电脑信任目标;目标只授予指定日志,有效期默认 60 分钟。</li></ol><details open><summary>在管理电脑运行</summary><code class="block-code">device-center access trust --peer 目标完整指纹 --confirm</code></details><details open><summary>在目标设备运行</summary><code class="block-code">device-center access grant --peer 管理电脑完整指纹 --log /绝对路径/app.log --label app --minutes 60 --confirm</code></details><p>授权完成后,让本机智能体调用 <code>device_center_read_log</code>,传入目标 fingerprint 与 label。每次最多 64KiB,返回实际访问路径。</p><details><summary>可选:同局域网优先</summary><p>目标执行 <code>device-center access listen --bind 192.168.1.10 --port 5181 --confirm</code>;管理电脑重新 trust,并增加 <code>--lan 192.168.1.10 --port 5181</code>。替换为已确认的目标地址;不会扫描网段或修改防火墙。</p><p>网络不可达时走云端中转;证书、身份或权限错误直接停止。两种路径都需要控制面在线核验设备是否已撤销。</p></details><p class="small-note">目标设备可运行 <code>device-center access revoke --peer 管理电脑完整指纹 --confirm</code> 随时撤销。私钥与日志授权路径留在本机;云端仅转发 TLS 密文。Windows 原生 Agent 尚未提供。</p>`;
322
+ return `<p class="modal-intro">${h("panel.329")}</p>${logCommandFields()}<ol class="simple-steps"><li>${h("panel.330")} <code>npm install -g device-center@0.2.0-beta.3</code>${h("panel.331")} <code>device-center access enable --confirm</code>.</li><li>${h("panel.332")} <code>device-center access peers</code>${h("panel.333")}</li><li>${h("panel.334")}</li></ol><details open><summary>${h("panel.324")}</summary><code class="block-code">${h("panel.335")}</code></details><details open><summary>${h("panel.326")}</summary><code class="block-code">${h("panel.336")}</code></details><p>${h("panel.337")} <code>device_center_read_log</code>${h("panel.338")}</p><details><summary>${h("panel.339")}</summary><p>${h("panel.340")} <code>device-center access listen --bind 192.168.1.10 --port 5181 --confirm</code>${h("panel.341")} <code>--lan 192.168.1.10 --port 5181</code>${h("panel.342")}</p><p>${h("panel.343")}</p></details><p class="small-note">${h("panel.344")} <code>${h("panel.345")}</code> ${h("panel.346")}</p>`;
322
323
  }
323
324
  function cloudBindingBody() {
324
325
  const pairing = state.pairing, disabled = state.pairBusy ? 'disabled' : '';
325
326
  const sourceCommand = driverCommand().replace(/^npx --yes device-center@\S+/, 'npm run driver --');
326
327
  const bindings = state.overview?.drivers ?? [];
327
- return `<p class="modal-intro">在自己的管理电脑运行 Driver,然后回到这里批准。云端不会读取浏览器所在电脑的文件。</p>
328
- <section class="binding-step"><h3><span>1</span> 准备自己的电脑</h3><p>支持 macOS / Linux,需要 Node.js 24。下面的 npx 命令会取得固定版本 Driver,无需先下载源码或全局安装。</p><details><summary>离线准备或使用源码</summary><a class="button button-light" href="/api/driver/download" download>下载 Driver 源码包</a><p>解压后先在源码目录运行 <code>npm ci --ignore-scripts</code>,再运行 <code>${escapeHtml(sourceCommand)}</code>。包内没有身份、配对码或本机配置。</p></details></section>
329
- <section class="binding-step"><h3><span>2</span> 复制并在自己的电脑运行</h3><label class="binding-scope"><input id="request-ssh" type="checkbox" data-field="requestSsh" ${state.requestSsh ? 'checked' : ''} ${disabled}>另申请分享已关联 SSH 的名称和缓存资源状态</label><textarea id="driver-command" readonly rows="3" spellcheck="false" aria-label="本机 Driver 接入命令">${escapeHtml(driverCommand())}</textarea><button class="button button-primary" data-action="driver-copy">${icon('copy', 15)} 复制接入命令</button><p>这一步会在本机生成身份私钥并申请配对。终端会显示 5 分钟有效的配对码和公钥指纹;保持终端打开。只读回报默认仅含本机名称、系统、内存和磁盘。</p></section>
330
- <section class="binding-step"><h3><span>3</span> 输入配对码并核对</h3><div class="pair-code-row"><label for="pair-code" class="sr-only">本机终端显示的配对码</label><input id="pair-code" data-field="pairCode" autocomplete="off" spellcheck="false" maxlength="9" placeholder="XXXX-XXXX" value="${escapeHtml(state.pairCode)}" ${disabled}><button class="button button-light" data-action="pair-lookup" ${disabled}>${state.pairBusy ? '处理中…' : '查找请求'}</button></div>${state.pairError ? `<p class="form-error" role="alert">${escapeHtml(state.pairError)}</p>` : ''}
331
- ${pairing ? `<div class="pair-review"><strong>${escapeHtml(pairing.name)}</strong><p>${escapeHtml(pairing.platform)} · 有效至 ${escapeHtml(new Date(pairing.expiresAt).toLocaleTimeString())}</p><label class="field-label">请与本机终端的完整公钥指纹核对</label><code class="fingerprint">${escapeHtml(pairing.fingerprint)}</code><p>只批准你刚刚亲自在这台电脑发起的请求。名称相同不能证明设备身份。</p>${pairing.requestedSsh ? `<label class="binding-scope"><input id="pair-share" type="checkbox" data-field="pairShare" ${state.pairShare ? 'checked' : ''} ${disabled}>允许分享至多 15 个已关联 SSH 名称和缓存状态(不含地址、用户、路径、配置或私钥)</label>` : ''}<button class="button button-primary" data-action="pair-approve" ${disabled}>核对一致,绑定这台电脑</button></div>` : '<p class="small-note">配对码只在这里输入,不放进智能体 prompt、脚本或链接。</p>'}</section>
332
- ${bindings.length ? `<details class="binding-management" open><summary>已登记的管理电脑 · ${bindings.filter(item => item.revokedAt == null).length} 台有效</summary>${bindings.map(item => `<div class="binding-row"><div><strong>${escapeHtml(item.name)}</strong><small>${item.revokedAt != null ? '已撤销' : item.lastSeen == null ? '已批准 · 等待首次回报' : `最近回报 ${formatAgo(item.lastSeen)}`}</small><code class="fingerprint">${escapeHtml(item.fingerprint)}</code></div>${item.revokedAt == null ? state.revokeId === item.id ? `<div class="revoke-confirm"><p>撤销会停止这台电脑后续回报,保留本机文件。</p><button class="button button-light" data-action="revoke-confirm" data-device="${item.id}" ${disabled}>确认撤销</button><button class="text-button" data-action="revoke-cancel" ${disabled}>取消</button></div>` : `<button class="text-button" data-action="revoke-arm" data-device="${item.id}" ${disabled}>撤销</button>` : ''}</div>`).join('')}</details>` : ''}
333
- <p class="small-note">${state.overview?.accountMode === 'multi-user' ? '设备归属当前登录账号,其他账号不能读取或撤销。' : '当前为个人模式,仅部署所有者可登录。'}绑定只授权资源回报;日志读取需在目标设备单独批准,可选择局域网直连或云端密文中转。任意命令、通用文件传输和云端 MCP 尚未提供。私钥为本机 0600 文件,尚未封装到系统钥匙串。</p>`;
328
+ return `<p class="modal-intro">${h("panel.365")}</p>
329
+ <section class="binding-step"><h3><span>1</span> ${h("panel.366")}</h3><p>${h("panel.367")}</p><details><summary>${h("panel.368")}</summary><a class="button button-light" href="/api/driver/download" download>${h("panel.369")}</a><p>${h("panel.370")} <code>npm ci --ignore-scripts</code>${h("panel.371")} <code>${escapeHtml(sourceCommand)}</code>${h("panel.372")}</p></details></section>
330
+ <section class="binding-step"><h3><span>2</span> ${h("panel.373")}</h3><label class="binding-scope"><input id="request-ssh" type="checkbox" data-field="requestSsh" ${state.requestSsh ? 'checked' : ''} ${disabled}>${h("panel.374")}</label><textarea id="driver-command" readonly rows="3" spellcheck="false" aria-label="${h("panel.375")}">${escapeHtml(driverCommand())}</textarea><button class="button button-primary" data-action="driver-copy">${icon('copy', 15)} ${h("panel.309")}</button><p>${h("panel.376")}</p></section>
331
+ <section class="binding-step"><h3><span>3</span> ${h("panel.377")}</h3><div class="pair-code-row"><label for="pair-code" class="sr-only">${h("panel.378")}</label><input id="pair-code" data-field="pairCode" autocomplete="off" spellcheck="false" maxlength="9" placeholder="XXXX-XXXX" value="${escapeHtml(state.pairCode)}" ${disabled}><button class="button button-light" data-action="pair-lookup" ${disabled}>${state.pairBusy ? t("panel.347") : t("panel.348")}</button></div>${state.pairError ? `<p class="form-error" role="alert">${escapeHtml(t(state.pairError))}</p>` : ''}
332
+ ${pairing ? `<div class="pair-review"><strong>${escapeHtml(pairing.name)}</strong><p>${escapeHtml(pairing.platform)} ${h("panel.350")} ${escapeHtml(new Date(pairing.expiresAt).toLocaleTimeString(getLocale()))}</p><label class="field-label">${h("panel.351")}</label><code class="fingerprint">${escapeHtml(pairing.fingerprint)}</code><p>${h("panel.352")}</p>${pairing.requestedSsh ? `<label class="binding-scope"><input id="pair-share" type="checkbox" data-field="pairShare" ${state.pairShare ? 'checked' : ''} ${disabled}>${h("panel.349")}</label>` : ''}<button class="button button-primary" data-action="pair-approve" ${disabled}>${h("panel.353")}</button></div>` : `<p class="small-note">${h("panel.354")}</p>`}</section>
333
+ ${bindings.length ? `<details class="binding-management" open><summary>${h("count.active", { count: bindings.filter(item => item.revokedAt == null).length })}</summary>${bindings.map(item => `<div class="binding-row"><div><strong>${escapeHtml(item.name)}</strong><small>${item.revokedAt != null ? t("panel.355") : item.lastSeen == null ? t("panel.356") : `${h("panel.14")} ${formatAgo(item.lastSeen)}`}</small><code class="fingerprint">${escapeHtml(item.fingerprint)}</code></div>${item.revokedAt == null ? state.revokeId === item.id ? `<div class="revoke-confirm"><p>${h("panel.357")}</p><button class="button button-light" data-action="revoke-confirm" data-device="${item.id}" ${disabled}>${h("panel.358")}</button><button class="text-button" data-action="revoke-cancel" ${disabled}>${h("panel.359")}</button></div>` : `<button class="text-button" data-action="revoke-arm" data-device="${item.id}" ${disabled}>${h("panel.360")}</button>` : ''}</div>`).join('')}</details>` : ''}
334
+ <p class="small-note">${state.overview?.accountMode === 'multi-user' ? t("panel.363") : t("panel.364")}${h("panel.379")}</p>`;
334
335
  }
335
336
  function pairErrorText(code) {
336
- return ({ invalid_pairing_code: '请输入终端上的配对码,格式为 XXXX-XXXX。', driver_pairing_confirmation_required: '配对码或指纹不匹配,请重新查找并与本机终端核对。', driver_pairing_unavailable: '找不到有效请求;配对码可能已过期或已使用,请在本机重新发起。', driver_pairing_expired: '配对已过期,请在本机重新发起。', driver_pairing_consumed: '这个请求已批准,请刷新查看连接状态。', driver_rate_limited: '请求过于频繁,请一分钟后重试。', driver_limit: '当前账号已达到 16 台管理电脑上限,请先撤销不再使用的绑定。' })[code] ?? '云端暂时没有响应,请刷新后重试;不要重复批准陌生请求。';
337
+ return ({ invalid_pairing_code: "panel.380", driver_pairing_confirmation_required: "panel.381", driver_pairing_unavailable: "panel.382", driver_pairing_expired: "panel.383", driver_pairing_consumed: "panel.384", driver_rate_limited: "panel.385", driver_limit: "panel.386" })[code] ?? "panel.387";
337
338
  }
338
339
  async function pairAction(action, id) {
339
340
  if (state.pairBusy) return;
@@ -343,9 +344,9 @@ async function pairAction(action, id) {
343
344
  else if (action === 'pair-approve' && state.pairing) {
344
345
  await api.approvePairing(state.pairing.id, Boolean(state.pairing.requestedSsh && state.pairShare), state.pairing.code, state.pairing.fingerprint);
345
346
  state.pairing = null; state.pairCode = ''; state.pairShare = false;
346
- state.toast = '已批准,等待本机首次回报;保持本机终端打开。';
347
+ state.toast = t("panel.388");
347
348
  state.overview = await api.overview();
348
- } else if (action === 'revoke-confirm' && id === state.revokeId) { await api.revokeDriver(id); state.revokeId = null; state.overview = await api.overview(); state.toast = '已撤销,云端将拒绝这台电脑的后续回报。'; }
349
+ } else if (action === 'revoke-confirm' && id === state.revokeId) { await api.revokeDriver(id); state.revokeId = null; state.overview = await api.overview(); state.toast = t("panel.389"); }
349
350
  } catch (error) { state.pairError = pairErrorText(error.message); }
350
351
  finally { state.pairBusy = false; render(); }
351
352
  }
@@ -354,43 +355,44 @@ async function copyDriverCommand() {
354
355
  field.focus(); field.select(); let copied = false;
355
356
  try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(field.value); copied = true; } } catch { /* manual selection remains available */ }
356
357
  if (!copied) try { copied = document.execCommand('copy'); } catch { /* manual selection */ }
357
- toast(copied ? '已复制。到自己的电脑终端运行,再回来输入配对码。' : '请手动复制已选中的接入命令。');
358
+ toast(copied ? t("panel.390") : t("panel.391"));
358
359
  if (!copied) { const current = root.querySelector(state.dialog === 'cloud-connect' ? '#cloud-connect-command' : '#driver-command'); current?.focus(); current?.select(); }
359
360
  }
360
361
  function initializationBody() {
361
- if (state.sshLoading) return '<p class="modal-intro">正在检查本机 SSH 配置…</p>';
362
+ if (state.sshLoading) return `<p class="modal-intro">${h("panel.392")}</p>`;
362
363
  const candidates = state.ssh?.candidates ?? [];
363
364
  const ready = candidates.filter((item) => item.importable);
364
365
  const pending = ready.filter((item) => !item.associated);
365
366
  const skipped = candidates.filter((item) => !item.importable);
366
- const reason = (item) => ({ host_driver_required: '需要在宿主运行本机 Driver', ssh_runtime_unsupported: '当前系统暂不支持此导入检查', ssh_identity_unavailable: '身份文件引用不可用', ssh_profile_incomplete: '连接参数不完整', ssh_profile_needs_review: '配置含未支持的规则或跳板,需人工核对' })[item.reason] ?? '配置需要核对';
367
- const rows = ready.map((item) => { const device = state.overview?.devices.find((entry) => entry.ssh?.alias === item.alias); return `<div class="ssh-candidate"><span><strong>${escapeHtml(item.alias)}</strong><small>${escapeHtml(item.source)} · ${item.routeMode === 'jump' ? '经 SSH 跳板' : 'SSH 直连'}</small></span>${item.associated && device ? connectivityMarkup(device) : `<small>${item.associated ? '已导入' : '可导入 · 导入后检查'}</small>`}</div>`; }).join('');
368
- return `<p class="modal-intro">一键导入管理电脑已有的 SSH 主机,无需重新填写连接信息。</p><div class="ssh-toolbar"><strong>${pending.length} 个可导入 · ${ready.length - pending.length} 个已导入</strong><button class="button button-light" data-action="ssh-refresh" ${state.saving ? 'disabled' : ''}>重新读取</button></div>${state.sshError ? `<p class="form-error" role="alert">${escapeHtml(state.sshError)}</p>` : ''}${ready.length ? `<div class="ssh-candidates initialization-candidates" role="region" aria-label="可导入的 SSH 配置">${rows}</div>` : `<div class="plain-panel"><h3>${state.ssh?.state === 'unavailable' ? '请在宿主电脑上运行 Driver' : '没有可一键导入的配置'}</h3><p>${state.ssh?.state === 'unavailable' ? 'Docker 内不能读取正在操作的电脑的 SSH 配置。' : '支持配置完整的明确 Host 与命名 ProxyJump;身份文件需在本机可读取。'}</p></div>`}<button class="button button-primary associate-button" data-action="initialize-import" ${!pending.length || state.saving ? 'disabled' : ''}>${state.saving ? '导入中…' : pending.length ? `一键导入 ${pending.length} 个配置` : ready.length ? '可用配置均已导入' : '暂无可导入配置'}</button><p class="small-note">私钥留在本机。导入后自动检查一次当前 SSH 路径,显示连通或失败原因;未取得资源的设备仍为未知。重复导入不新增设备或重复检查。</p>${skipped.length ? `<details class="ssh-import-skipped"><summary>${skipped.length} 个配置需核对 · 本次跳过</summary>${skipped.map((item) => `<p><strong>${escapeHtml(item.alias)}</strong>${item.associated ? ' · 已导入' : ''}<br>${reason(item)}</p>`).join('')}</details>` : ''}${state.ssh?.notices?.length ? `<details class="ssh-import-notices"><summary>配置读取说明</summary>${state.ssh.notices.map((notice) => `<p>${escapeHtml(notice)}</p>`).join('')}</details>` : ''}`;
367
+ const reason = (item) => ({ host_driver_required: t("panel.393"), ssh_runtime_unsupported: t("panel.394"), ssh_identity_unavailable: t("panel.395"), ssh_profile_incomplete: t("panel.396"), ssh_profile_needs_review: t("panel.397") })[item.reason] ?? t("panel.398");
368
+ const rows = ready.map((item) => { const device = state.overview?.devices.find((entry) => entry.ssh?.alias === item.alias); return `<div class="ssh-candidate"><span><strong>${escapeHtml(item.alias)}</strong><small>${escapeHtml(item.source)} · ${item.routeMode === 'jump' ? t("panel.399") : t("panel.400")}</small></span>${item.associated && device ? connectivityMarkup(device) : `<small>${item.associated ? t("panel.401") : t("panel.402")}</small>`}</div>`; }).join('');
369
+ return `<p class="modal-intro">${h("panel.415")}</p><div class="ssh-toolbar"><strong>${h("count.importable", { pending: pending.length, imported: ready.length - pending.length })}</strong><button class="button button-light" data-action="ssh-refresh" ${state.saving ? 'disabled' : ''}>${h("panel.283")}</button></div>${state.sshError ? `<p class="form-error" role="alert">${escapeHtml(t(state.sshError))}</p>` : ''}${ready.length ? `<div class="ssh-candidates initialization-candidates" role="region" aria-label="${h("panel.403")}">${rows}</div>` : `<div class="plain-panel"><h3>${state.ssh?.state === 'unavailable' ? t("panel.404") : t("panel.405")}</h3><p>${state.ssh?.state === 'unavailable' ? t("panel.406") : t("panel.407")}</p></div>`}<button class="button button-primary associate-button" data-action="initialize-import" ${!pending.length || state.saving ? 'disabled' : ''}>${state.saving ? t("panel.408") : pending.length ? h("count.import", { count: pending.length }) : ready.length ? t("panel.411") : t("panel.412")}</button><p class="small-note">${h("panel.418")}</p>${skipped.length ? `<details class="ssh-import-skipped"><summary>${h("count.skipped", { count: skipped.length })}</summary>${skipped.map((item) => `<p><strong>${escapeHtml(item.alias)}</strong>${item.associated ? t("panel.413") : ''}<br>${reason(item)}</p>`).join('')}</details>` : ''}${state.ssh?.notices?.length ? `<details class="ssh-import-notices"><summary>${h("panel.293")}</summary>${state.ssh.notices.map((notice) => `<p>${escapeHtml(reportMessage(notice))}</p>`).join('')}</details>` : ''}`;
369
370
  }
370
371
  function residentBody() {
371
372
  const port = new URL(state.setup?.url ?? location.href).port;
372
373
  const portOption = port ? ` --port ${port}` : "";
373
- return `<p class="modal-intro">关掉网页不需要停止服务。普通用户的默认方向是:安装一次,登录系统后自动在后台运行。</p><div class="plain-panel"><h3>本机安装 · 推荐</h3><p>仓库提供用户级服务脚本:macOS 使用 launchd,Linux 使用 systemd。先预览安装计划,再由你执行安装。</p><code class="block-code">npm run service -- plan${portOption}</code><code class="block-code">npm run service -- install --confirm${portOption}</code><p class="small-note">先手动停止本次同端口前台预览,再安装。需要 Node.js 24 和稳定的仓库目录。安装会登记本用户的后台服务、日志和数据目录;不需要管理员权限。Windows 后台安装尚未支持,可先用前台方式或 Docker。</p></div><details><summary>用 Docker,如何连接本机智能体?</summary><p>Docker 只运行 Device Center。Codex 留在本机,通过相同的 <code>http://127.0.0.1:端口/mcp</code> 地址访问容器服务。</p><p>仓库中的 Compose 配置只映射到本机回环地址,并保留数据卷与重启策略。本机 AI → 本机地址 → 容器服务。无需挂载 Docker socket、Codex 配置或宿主机目录。</p><p>Docker 引擎必须运行;不会因为使用容器就自动取得本机的操作权限。容器清单只表示容器,宿主的内存、磁盘与 SSH 配置不可见。请在宿主运行原生 Driver。</p></details><p class="small-note">这次只提供脚本与运行配置,没有安装常驻服务或启动 Docker。当前进程仍是本机预览。</p>`;
374
+ return `<p class="modal-intro">${h("panel.419")}</p><div class="plain-panel"><h3>${h("panel.420")}</h3><p>${h("panel.421")}</p><code class="block-code">npm run service -- plan${portOption}</code><code class="block-code">npm run service -- install --confirm${portOption}</code><p class="small-note">${h("panel.422")}</p></div><details><summary>${h("panel.423")}</summary><p>${h("panel.424")} <code>${h("panel.425")}</code> ${h("panel.426")}</p><p>${h("panel.427")}</p><p>${h("panel.428")}</p></details><p class="small-note">${h("panel.429")}</p>`;
374
375
  }
375
376
  function renderDialog() {
376
377
  if (!state.dialog) return '';
377
- const title = { 'agent-install': '交给 Agent 安装', profile: '设备说明', 'secure-help': '授权远程日志', setup: '开始使用', connect: '连接本机 Codex', 'cloud-connect': '云端面板 · 可选', add: cloudMode() ? '连接我的电脑' : '添加设备', initialize: '初始化 · 导入本机 SSH', resident: '让服务在后台运行', route: '连接方式' }[state.dialog];
378
+ const title = { 'agent-install': t("panel.430"), profile: t("panel.49"), 'secure-help': t("panel.431"), setup: t("panel.209"), connect: t("panel.432"), 'cloud-connect': t("panel.433"), add: cloudMode() ? t("panel.434") : t("panel.435"), initialize: t("panel.436"), resident: t("panel.437"), route: t("panel.438") }[state.dialog];
378
379
  const body = state.dialog === 'agent-install' ? agentInstallBody() : state.dialog === 'profile' ? profileBody() : state.dialog === 'secure-help' ? secureHelpBody() : state.dialog === 'cloud-connect' ? cloudConnectBody() : state.dialog === 'setup' ? onboardingBody() : state.dialog === 'route' ? routeBody() : state.dialog === 'connect' ? connectionBody() : state.dialog === 'add' ? addBody() : state.dialog === 'initialize' ? initializationBody() : residentBody();
379
- return `<div class="modal-scrim" data-action="close"><section class="add-modal ${state.dialog === 'setup' ? 'onboarding-modal' : ''}" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><header class="modal-heading"><h2 id="dialog-title">${title}</h2><button class="icon-button" data-action="close" aria-label="关闭">${icon('close')}</button></header>${body}${state.toast ? `<div class="toast" role="status">${escapeHtml(state.toast)}</div>` : ''}</section></div>`;
380
+ return `<div class="modal-scrim" data-action="close"><section class="add-modal ${state.dialog === 'setup' ? 'onboarding-modal' : ''}" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><header class="modal-heading"><h2 id="dialog-title">${title}</h2>${languagePickerMarkup()}<button class="icon-button" data-action="close" aria-label="${h("panel.439")}">${icon('close')}</button></header>${body}${state.toast ? `<div class="toast" role="status">${escapeHtml(state.toast)}</div>` : ''}</section></div>`;
380
381
  }
381
382
  function filteredDevices() {
382
383
  const needle = state.query.trim().toLocaleLowerCase();
383
384
  return (state.overview?.devices ?? []).filter((d) => (state.project === 'all' || d.projectId === state.project) && (state.filter === 'all' || d.state === state.filter) && (!needle || [d.name, d.reportedName, d.platform, DEVICE_TYPE_LABELS[d.profile?.type], d.profile?.purpose, ...(d.profile?.tags ?? []), ...(d.profile?.services ?? [])].join(' ').toLocaleLowerCase().includes(needle)));
384
385
  }
385
386
  function renderDevices() {
386
- if (state.loading) return '<div class="empty-state"><p>正在读取设备…</p></div>';
387
- if (state.error) return `<div class="empty-state"><h3>暂时无法读取设备</h3><p>请检查网络和服务状态,再重新连接。</p><button class="button button-light" data-action="refresh">重新连接</button></div>`;
387
+ if (state.loading) return `<div class="empty-state"><p>${h("panel.440")}</p></div>`;
388
+ if (state.error) return `<div class="empty-state"><h3>${h("panel.441")}</h3><p>${h("panel.442")}</p><button class="button button-light" data-action="refresh">${h("panel.443")}</button></div>`;
388
389
  const devices = filteredDevices();
389
390
  if (devices.length) return devices.map(deviceCard).join('');
390
391
  const any = state.overview?.devices.length;
391
- return `<div class="empty-state"><span class="empty-icon">${icon('device', 26)}</span><h3>${any ? '没有匹配的设备' : '尚未接入资源设备'}</h3><p>${any ? '调整搜索内容或状态筛选。' : cloudMode() ? '点击“连接我的电脑”,在自己的电脑运行接入命令。' : '尚无设备记录。容器模式不能读取宿主设备。'}</p>${any ? '' : `<span class="empty-hint">${cloudMode() ? '配对批准后,等待首次真实回报' : '请检查本机服务状态'}</span>`}</div>`;
392
+ return `<div class="empty-state"><span class="empty-icon">${icon('device', 26)}</span><h3>${any ? t("panel.444") : t("panel.445")}</h3><p>${any ? t("panel.446") : cloudMode() ? t("panel.447") : t("panel.448")}</p>${any ? '' : `<span class="empty-hint">${cloudMode() ? t("panel.449") : t("panel.450")}</span>`}</div>`;
392
393
  }
393
394
  function render() {
395
+ document.title = "Device Center · " + t(cloudMode() ? "panel.184" : "panel.194");
394
396
  clearTimeout(snapshotExpiryTimer);
395
397
  scheduleCheckRefresh();
396
398
  scheduleCloudRefresh();
@@ -406,31 +408,32 @@ function render() {
406
408
  const dialogScroll = oldDialog?.scrollTop ?? 0;
407
409
  const listScroll = root.querySelector('.device-grid')?.scrollTop ?? 0;
408
410
  const focused = document.activeElement;
411
+ const languageFocused = Boolean(focused?.closest('[data-language-picker]'));
409
412
  const focusAction = focused?.dataset.action;
410
413
  const focusId = oldDialog?.contains(focused) ? focused.id : null;
411
414
  const focusField = focused?.dataset.field;
412
415
  const focusQualifier = ['method', 'copy', 'filter', 'project', 'alias', 'device', 'provider', 'step', 'mode']
413
416
  .filter((key) => focused?.dataset[key])
414
417
  .map((key) => `[data-${key}="${CSS.escape(focused.dataset[key])}"]`).join('');
415
- const findFocus = (container) => focusId
418
+ const findFocus = (container) => languageFocused ? container.querySelector('[data-language-trigger]') : focusId
416
419
  ? container.querySelector(`#${CSS.escape(focusId)}`)
417
420
  : focusAction ? container.querySelector(`[data-action="${CSS.escape(focusAction)}"]${focusQualifier}:not(:disabled)`)
418
421
  : focusField ? container.querySelector(`[data-field="${CSS.escape(focusField)}"]${focusQualifier}:not(:disabled)`) : null;
419
422
  const project = (state.overview?.projects ?? []).find((p) => p.id === state.project);
420
423
  const counts = state.overview?.stats ?? { total: 0, online: 0, unknown: 0, expired: 0 };
421
424
  root.innerHTML = `<div class="app-shell">${renderSidebar()}<div class="main-area">
422
- <header class="topbar"><span>${cloudMode() ? '我的云端设备' : '本机工作空间'}</span><span class="environment-badge">${cloudMode() ? 'HTTPS · 已登录' : '本机 Driver'}</span></header>
425
+ <header class="topbar"><span>${cloudMode() ? t("panel.451") : t("panel.452")}</span><div class="header-actions"><span class="environment-badge">${cloudMode() ? t("panel.453") : t("panel.29")}</span>${languagePickerMarkup()}</div></header>
423
426
  <main class="workspace">
424
- <section class="page-heading"><div><h1>${escapeHtml(project?.name ?? '所有设备')}</h1><p>把自己的电脑,变成智能体可用的资源。</p></div><div class="heading-actions"><button class="button button-light" data-action="agent-install">交给 Agent 安装</button><button class="button button-primary" data-action="add">${icon('plus', 16)} ${cloudMode() ? state.overview?.drivers?.some(item => item.revokedAt == null) ? '管理连接' : '连接我的电脑' : '添加设备'}</button></div></section>
427
+ <section class="page-heading"><div><h1>${escapeHtml(project?.name ?? t("panel.187"))}</h1><p>${h("panel.467")}</p></div><div class="heading-actions"><button class="button button-light" data-action="agent-install">${h("panel.430")}</button><button class="button button-primary" data-action="add">${icon('plus', 16)} ${cloudMode() ? state.overview?.drivers?.some(item => item.revokedAt == null) ? t("panel.454") : t("panel.434") : t("panel.435")}</button></div></section>
425
428
  ${state.error ? '' : renderConnection()}
426
- <section class="list-section" aria-label="设备资源">
427
- <div class="list-toolbar"><div class="filter-tabs" role="group" aria-label="设备状态">${[['all', '全部', counts.total], ['online', '在线', counts.online], ['unknown', '未知', counts.unknown], ['expired', '需更新', counts.expired]].map(([key, label, count]) => `<button class="filter-tab ${state.filter === key ? 'active' : ''}" data-action="filter" data-filter="${key}" aria-pressed="${state.filter === key}">${label}<span>${count}</span></button>`).join('')}</div>
428
- <div class="list-controls">${counts.total ? `<input class="search-input" type="search" data-field="search" placeholder="搜索设备" aria-label="搜索设备" value="${escapeHtml(state.query)}">` : ''}<button class="icon-button" data-action="refresh" aria-label="刷新列表" title="只读取最新回报;需要重新采集时点击设备上的采集一次">${icon('refresh', 17)}</button></div>
429
+ <section class="list-section" aria-label="${h("panel.468")}">
430
+ <div class="list-toolbar"><div class="filter-tabs" role="group" aria-label="${h("panel.469")}">${[['all', t("panel.455"), counts.total], ['online', t("panel.456"), counts.online], ['unknown', t("panel.457"), counts.unknown], ['expired', t("panel.458"), counts.expired]].map(([key, label, count]) => `<button class="filter-tab ${state.filter === key ? 'active' : ''}" data-action="filter" data-filter="${key}" aria-pressed="${state.filter === key}">${label}<span>${count}</span></button>`).join('')}</div>
431
+ <div class="list-controls">${counts.total ? `<input class="search-input" type="search" data-field="search" placeholder="${h("panel.459")}" aria-label="${h("panel.459")}" value="${escapeHtml(state.query)}">` : ''}<button class="icon-button" data-action="refresh" aria-label="${h("panel.470")}" title="${h("panel.471")}">${icon('refresh', 17)}</button></div>
429
432
  </div>
430
- <div class="device-grid" role="region" aria-label="设备列表" tabindex="0">${renderDevices()}</div>
431
- <p class="list-footnote">${cloudMode() ? '设备约每 30 秒回报 · 停止回报时保留旧数据 · 支持手动采集' : '采集一次更新资源 · 设备说明可命名与标记用途 · 点击路径看详情'}</p>
433
+ <div class="device-grid" role="region" aria-label="${h("panel.472")}" tabindex="0">${renderDevices()}</div>
434
+ <p class="list-footnote">${cloudMode() ? t("panel.460") : t("panel.461")}</p>
432
435
  </section>
433
- <footer class="page-footer"><span>${cloudMode() && state.overview?.relayUsage ? `中转 ${(state.overview.relayUsage.bytes / 1048576).toFixed(1)} / ${state.overview.relayUsage.limitBytes / 1048576} MiB · UTC 当日` : '资源采集 · 日志需单独授权'}</span><details class="status-help"><summary>状态说明</summary><p>${cloudMode() ? '未知:尚无资源回报。在线:管理电脑最近 90 秒内回报,且资源快照未过期。未回报:超过 90 秒未收到 Driver 数据;请检查休眠、Driver 进程和网络。待采集:资源快照较早;均不代表密钥过期。采集一次只要求在线 Driver 重采资源,等待真实返回后才更新。SSH 快照需在管理电脑检查,云端不会执行 SSH。回报路径为管理电脑主动连接云端 HTTPS,不是 SSH 隧道。云端不能执行远程任务或自动选路。' : '本机在线表示服务进程运行。SSH 在线表示近期取得资源快照;连通标记表示当前 SSH 路径检查成功。未取得资源的设备为未知。超过 5 分钟显示待采集,只说明快照较早,不代表设备断线或密钥过期。刷新列表只读取现有结果,采集一次才重新读取资源;SSH 设备会使用当前配置进行只读检查。自动选路、远程任务尚未实现。'}内存与磁盘分别采集;内存含系统缓存,磁盘为用户目录所在卷。</p></details></footer>
436
+ <footer class="page-footer"><span>${cloudMode() && state.overview?.relayUsage ? `${h("panel.462")} ${(state.overview.relayUsage.bytes / 1048576).toFixed(1)} / ${state.overview.relayUsage.limitBytes / 1048576} ${h("panel.463")}` : t("panel.464")}</span><details class="status-help"><summary>${h("panel.473")}</summary><p>${cloudMode() ? t("panel.465") : t("panel.466")} ${h("panel.474")}</p></details></footer>
434
437
  </main>
435
438
  </div>${renderDialog()}${state.toast && !state.dialog ? `<div class="toast page-toast" role="status">${escapeHtml(state.toast)}</div>` : ''}</div>`;
436
439
  const dialog = root.querySelector('[role="dialog"]');
@@ -440,7 +443,8 @@ function render() {
440
443
  root.querySelector('.main-area').inert = Boolean(dialog);
441
444
  if (dialog) {
442
445
  dialog.scrollTop = dialogScroll;
443
- (oldDialog ? findFocus(dialog) ?? dialog.querySelector('button') : dialog.querySelector('button'))?.focus({ preventScroll: true });
446
+ const initialFocus = dialog.querySelector('button[data-action="close"]') ?? dialog.querySelector('button');
447
+ (oldDialog ? findFocus(dialog) ?? initialFocus : initialFocus)?.focus({ preventScroll: true });
444
448
  } else if (oldDialog) root.querySelector(oldDialog.dataset.kind === 'setup' ? `[data-action="${CSS.escape(state.setupReturn)}"]` : oldDialog.dataset.kind === 'profile' ? `[data-action="profile"][data-device="${CSS.escape(state.profileDevice)}"]` : oldDialog.dataset.kind === 'route' ? `[data-action="route"][data-device="${CSS.escape(state.routeDevice)}"]` : `[data-action="${{ 'agent-install': 'agent-install', connect: 'connect', 'cloud-connect': 'cloud-connect', add: 'add', initialize: 'initialize', resident: 'resident' }[oldDialog.dataset.kind] ?? 'connect'}"]`)?.focus({ preventScroll: true });
445
449
  else findFocus(root)?.focus({ preventScroll: true });
446
450
  if (dialog) dialog.dataset.kind = state.dialog;
@@ -453,7 +457,7 @@ function scheduleCloudRefresh() {
453
457
  if (state.pairBusy || cloudRefreshBusy) { scheduleCloudRefresh(); return; }
454
458
  cloudRefreshBusy = true;
455
459
  try { updateOverview(await api.overview()); state.cloudRefreshError = null; cloudRefreshFailures = 0; }
456
- catch { cloudRefreshFailures++; state.cloudRefreshError = cloudRefreshFailures >= 3 ? '自动刷新已暂停,请手动刷新' : '暂时无法刷新,正在重试'; }
460
+ catch { cloudRefreshFailures++; state.cloudRefreshError = cloudRefreshFailures >= 3 ? "panel.475" : "panel.476"; }
457
461
  finally { cloudRefreshBusy = false; render(); }
458
462
  }, Object.keys(state.pendingCollections).length ? 3000 : 15_000);
459
463
  }
@@ -474,7 +478,7 @@ async function loadSsh() {
474
478
  try {
475
479
  state.ssh = await (state.dialog === 'initialize' || state.dialog === 'setup' ? api.sshInitialization() : api.sshCandidates());
476
480
  state.selectedAliases = state.selectedAliases.filter((alias) => state.ssh.candidates.some((item) => item.alias === alias && !item.associated));
477
- } catch { state.sshError = '无法读取本机配置,请确认服务仍在运行。'; }
481
+ } catch { state.sshError = "panel.477"; }
478
482
  finally { state.sshLoading = false; render(); }
479
483
  }
480
484
  async function associateSsh() {
@@ -485,8 +489,8 @@ async function associateSsh() {
485
489
  state.overview = await api.overview();
486
490
  state.filter = 'all'; state.query = ''; state.project = 'all'; state.selectedAliases = []; state.dialog = null;
487
491
  checkRefreshFailures = 0;
488
- toast(result.checks.aliases.length ? `已关联 ${result.added} 台设备,正在检查 SSH 连通性。` : '这些设备已经关联,没有重复导入或检查。');
489
- } catch (cause) { state.sshError = cause.message === 'ssh_alias_unavailable' ? 'SSH 配置已变化,请重新读取后再选择。' : '关联失败,请确认服务仍在运行后重试。'; }
492
+ toast(result.checks.aliases.length ? t("count.associated", { count: result.added }) : t("panel.479"));
493
+ } catch (cause) { state.sshError = cause.message === 'ssh_alias_unavailable' ? "panel.480" : "panel.481"; }
490
494
  finally { state.saving = false; render(); }
491
495
  }
492
496
  async function initializeSsh() {
@@ -499,8 +503,8 @@ async function initializeSsh() {
499
503
  state.ssh = await api.sshInitialization();
500
504
  state.filter = 'all'; state.query = ''; state.project = 'all';
501
505
  checkRefreshFailures = 0;
502
- toast(result.checks.aliases.length ? `已导入 ${result.added} 个配置,正在检查 SSH 连通性;结果会标在设备上。` : '这些配置已经导入,没有新增重复设备。');
503
- } catch (cause) { state.sshError = cause.message === 'ssh_import_needs_refresh' ? '配置或身份文件已变化,请重新读取后再导入。' : '导入失败,请确认本机服务仍在运行。'; }
506
+ toast(result.checks.aliases.length ? t("count.imported", { count: result.added }) : t("panel.483"));
507
+ } catch (cause) { state.sshError = cause.message === 'ssh_import_needs_refresh' ? "panel.484" : "panel.485"; }
504
508
  finally { state.saving = false; render(); root.querySelector('[data-action="ssh-refresh"]')?.focus({ preventScroll: true }); }
505
509
  }
506
510
  let toastTimer;
@@ -511,7 +515,7 @@ function toast(message) {
511
515
  async function copyField(kind) {
512
516
  const text = kind === 'command' ? state.setup?.setupCommand : state.setup?.url;
513
517
  const field = root.querySelector('#copy-field');
514
- if (!text || !field) return toast('地址尚未就绪,请先重新连接服务。');
518
+ if (!text || !field) return toast(t("panel.486"));
515
519
  let copied = false;
516
520
  let timer;
517
521
  if (navigator.clipboard?.writeText) {
@@ -519,7 +523,7 @@ async function copyField(kind) {
519
523
  catch { /* retain manual selection */ } finally { clearTimeout(timer); }
520
524
  }
521
525
  if (!copied) try { copied = document.execCommand('copy'); } catch { /* retain manual selection */ }
522
- toast(copied ? `${kind === 'command' ? '命令' : '地址'}已复制,去 Codex 完成配置。` : '无法自动复制,请手动复制已选中的内容。');
526
+ toast(copied ? t("copy.codex") : t("panel.490"));
523
527
  if (!copied) { root.querySelector('#copy-field')?.focus(); root.querySelector('#copy-field')?.select(); }
524
528
  }
525
529
  root.addEventListener('click', async (event) => {
@@ -529,11 +533,11 @@ root.addEventListener('click', async (event) => {
529
533
  if (action === 'agent-install') { openAgentInstall(); render(); return; }
530
534
  if (action === 'install-mode') { state.installMode = button.dataset.mode; state.toast = null; render(); return; }
531
535
  if (action === 'agent-install-copy') return copyAgentInstall();
532
- if (action === 'logout') { try { await api.logout(); location.replace('/login'); } catch { state.toast = '退出失败,请重试'; render(); } return; }
536
+ if (action === 'logout') { try { await api.logout(); location.replace('/login?lang=' + getLocale()); } catch { state.toast = t("panel.491"); render(); } return; }
533
537
  if (cloudMode() && action === 'add') { state.dialog = 'add'; state.toast = null; state.pairError = null; state.revokeId = null; render(); return; }
534
538
  if (['driver-copy', 'cloud-command-copy'].includes(action)) return copyDriverCommand();
535
539
  if (action === 'secure-help') { state.dialog='secure-help'; state.toast=null; state.remoteError=null; render(); if (!cloudMode()) { state.remoteLoading=true; render(); try { const data=await api.remotePeers(); state.remotePeers=data.peers; state.logManager=data.driverId??''; } catch { state.remoteError=true; } finally { state.remoteLoading=false; render(); } } return; }
536
- if (action === 'log-command-copy') { const field=root.querySelector('#'+button.dataset.fieldId); if(!field?.value)return;field.focus();field.select();try {await navigator.clipboard.writeText(field.value);toast('已复制,请在对应设备终端核对并运行。');}catch{toast('请手动复制选中的命令。');} return; }
540
+ if (action === 'log-command-copy') { const field=root.querySelector('#'+button.dataset.fieldId); if(!field?.value)return;field.focus();field.select();try {await navigator.clipboard.writeText(field.value);toast(t("panel.492"));}catch{toast(t("panel.493"));} return; }
537
541
  if (action === 'cloud-connect') { state.dialog = 'cloud-connect'; state.toast = null; render(); return; }
538
542
  if (action === 'cloud-choice') { state.cloudChoice = button.dataset.provider; state.toast = null; render(); return; }
539
543
  if (['pair-lookup', 'pair-approve', 'revoke-confirm'].includes(action)) return pairAction(action, button.dataset.device);
@@ -564,8 +568,8 @@ root.addEventListener('click', async (event) => {
564
568
  state.checking = true; state.checkMessage = null; render();
565
569
  try {
566
570
  state.status = await api.connectorStatus();
567
- state.checkMessage = state.status.client.state === 'observed' ? '已收到真实 MCP 初始化;它不代表其他设备已连通。' : state.status.client.state === 'stale' ? '上次初始化已超过 5 分钟,请在 Codex 中重新启动连接。' : '尚未收到 MCP 初始化。请在 Codex 中保存并重新启动连接。';
568
- } catch { state.checkMessage = '本机服务没有响应,请启动服务后重试。'; }
571
+ state.checkMessage = state.status.client.state === 'observed' ? "panel.494" : state.status.client.state === 'stale' ? "panel.495" : "panel.496";
572
+ } catch { state.checkMessage = "panel.497"; }
569
573
  finally { state.checking = false; }
570
574
  render();
571
575
  root.querySelector('[data-action="check"]')?.focus({ preventScroll: true });
@@ -624,7 +628,6 @@ root.addEventListener('keydown', (event) => {
624
628
  else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
625
629
  });
626
630
  document.addEventListener('visibilitychange', () => { if (!document.hidden) { cloudRefreshFailures = 0; scheduleCloudRefresh(); } else clearTimeout(cloudRefreshTimer); });
627
- load();
628
631
 
629
632
  root.addEventListener('input', event => {
630
633
  const field=event.target.dataset.logField;
@@ -633,6 +636,9 @@ root.addEventListener('input', event => {
633
636
  const draft=logCommandDraft();
634
637
  const trust=root.querySelector('#log-trust-command'),grant=root.querySelector('#log-grant-command');
635
638
  if(trust)trust.value=draft.trust;if(grant)grant.value=draft.grant;
636
- const error=root.querySelector('#log-command-error');if(error)error.textContent=draft.error??'核对两端完整指纹后分别执行;60分钟有效。';
639
+ const error=root.querySelector('#log-command-error');if(error)error.textContent=draft.error??t("panel.318");
637
640
  for(const button of root.querySelectorAll('[data-action="log-command-copy"]'))button.disabled=Boolean(draft.error);
638
641
  });
642
+
643
+ initLanguage({ onChange: () => { state.toast = null; render(); } });
644
+ load();