device-center 0.2.0-beta.1 → 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,6 +1,7 @@
1
+ import { t, h, getLocale, initLanguage, languagePickerMarkup, reportMessage } from './i18n.js';
1
2
  import { api } from './api.js';
2
- import { expireSshSnapshots } from './device-state.js';
3
- import { OFFICIAL_CONTROL_ORIGIN, connectCommand, logAccessCommands } from './onboarding.js';
3
+ import { expireSshSnapshots, deviceStatus, collectionAvailability, DEVICE_TYPE_LABELS } from './device-state.js';
4
+ import { OFFICIAL_CONTROL_ORIGIN, connectCommand, logAccessCommands, agentInstallPrompt } from './onboarding.js';
4
5
 
5
6
  const root = document.querySelector('#root');
6
7
  const state = { loading: true, error: null, overview: null, setup: null, status: null, ssh: null, selectedAliases: [], saving: false, sshLoading: false, sshError: null, checkingSshAlias: null, dialog: null, routeDevice: null, method: 'settings', filter: 'all', query: '', project: 'all', toast: null, checking: false, checkMessage: null };
@@ -10,10 +11,12 @@ Object.assign(state, { connectionDraft: null, connectionSaving: false, connectio
10
11
  Object.assign(state, { pairCode: '', pairing: null, pairBusy: false, pairError: null, pairShare: false, requestSsh: false, revokeId: null, cloudRefreshError: null });
11
12
  Object.assign(state, { cloudChoice: 'official', customOrigin: '', logManager: '', logTarget: '', logPath: '', logLabel: 'app', remotePeers: [], remoteLoading: false, remoteError: null });
12
13
  let cloudRefreshTimer, cloudRefreshBusy = false, cloudRefreshFailures = 0;
14
+ Object.assign(state, { profileDevice: null, profileDraft: null, profileError: null, profileSaving: false, collectingDevice: null, pendingCollections: {} });
15
+ Object.assign(state, { installMode: 'local', installOrigin: '', installBackground: true });
13
16
  const RELAY_PROVIDERS = [
14
- { id: 'self-hosted', name: '用户自建服务器', description: '设备 Agent 可使用自建 HTTPS 控制面进行密文中转;已有 SSH 设备仍沿用原配置,此处只保存计划。' },
15
- { id: 'managed', name: 'Device Center 服务', description: '设备 Agent 可使用官方 HTTPS 密文中转;需绑定自己的账号并单独授权日志。此处不会把 SSH 路径改成 Agent 通道。' },
16
- { 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"); } },
17
20
  ];
18
21
  const ICONS = {
19
22
  device: '<rect x="4" y="3" width="16" height="13" rx="2"/><path d="M2 20h20M8 16l-1 4m9-4 1 4"/>',
@@ -30,25 +33,24 @@ const ICONS = {
30
33
  };
31
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>`;
32
35
  const escapeHtml = (value = '') => String(value).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
33
- const statusText = (value) => ({ online: '在线', unknown: '未知', expired: '过期' })[value] ?? '未知';
34
36
  function formatAgo(time) {
35
- if (time == null) return '尚未采集';
37
+ if (time == null) return t("panel.6");
36
38
  const elapsed = Math.max(0, Date.now() - time);
37
- if (elapsed < 60_000) return '刚刚';
38
- if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)} 分钟前`;
39
- 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');
40
41
  }
41
42
  function metricMarkup(device, kind) {
42
43
  const memory = kind === 'memory';
43
44
  const metric = device[kind];
44
- const label = memory ? '内存' : '磁盘';
45
+ const label = memory ? t("panel.8") : t("panel.9");
45
46
  const used = memory ? metric.usedMb : metric.usedGb;
46
47
  const total = memory ? metric.totalMb : metric.totalGb;
47
- 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`;
48
50
  const valid = metric.status === 'ok' && used != null && total != null && total > 0;
49
51
  const percent = valid ? Math.min(100, Math.max(0, Math.round(used / total * 100))) : 0;
50
- const description = device.state === 'expired' ? '上次采集快照' : device.source === 'local-runtime' || device.transport === 'ssh' ? memory ? metric.basis === 'total-minus-available' ? '系统总量减可用' : '系统总量减空闲 · 含缓存' : '用户目录所在卷' : '最近回报';
51
- 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>`;
52
54
  }
53
55
  function connectivityState(device) {
54
56
  if (state.checkingSshAlias && state.checkingSshAlias === device.ssh?.alias) return 'checking';
@@ -57,38 +59,90 @@ function connectivityState(device) {
57
59
  }
58
60
  function connectivityMarkup(device) {
59
61
  const value = connectivityState(device);
60
- const label = ({ queued: '等待检查', checking: '检查中', passed: 'SSH 连通', failed: '检查失败', stale: '上次连通 · 待检查', 'configuration-changed': '配置待核对', unobserved: '尚未检查' })[value];
61
- 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");
62
64
  return `<span class="ssh-connectivity ${escapeHtml(value)}" title="${escapeHtml(detail)}">${escapeHtml(label)}</span>`;
63
65
  }
64
66
  function deviceCard(device) {
65
67
  const local = device.source === 'local-runtime';
66
- 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;
67
69
  const route = device.ssh?.route;
68
- 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)}` : '访问路径未核验';
69
- 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 statusLabel = device.transport === 'ssh' && device.state === 'expired' ? '待刷新' : statusText(device.state);
71
- const statusTitle = device.transport === 'ssh' && device.state === 'expired' ? '上次采集超过 5 分钟;数据待刷新,不代表设备断线或密钥过期。' : device.transport === 'ssh' && device.state === 'online' ? '最近一次 SSH 检查成功,不表示持续连接。' : statusLabel;
72
- const checking = state.checkingSshAlias === device.ssh?.alias || device.ssh?.checking;
73
- const queued = device.ssh?.queued;
74
- const checkButton = device.transport === 'ssh' ? `<button class="button button-light ssh-check-button" data-action="ssh-check" data-alias="${escapeHtml(device.ssh.alias)}" ${!device.ssh.checkAvailable || state.checkingSshAlias || checking || queued ? 'disabled' : ''}>${checking ? '正在检查…' : queued ? '等待检查…' : '检查 SSH(只读)'}</button>` : '';
75
- return `<article class="device-card"><div class="device-identity"><div class="device-heading"><span class="device-icon">${icon('device', 17)}</span><div><h2>${escapeHtml(device.name)}</h2><p>${escapeHtml(subtitle)}</p></div><span class="status-pill ${device.state}" title="${escapeHtml(statusTitle)}">${statusLabel}</span></div><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">${device.transport === 'ssh' ? '最近检查' : '最近采集'} <span>${formatAgo(device.transport === 'ssh' ? device.ssh.lastCheckedAt : device.lastHeartbeat)}</span></div>${checkButton}</div></article>`;
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");
72
+ const status = deviceStatus(device), availability = collectionAvailability(device);
73
+ const collecting = state.collectingDevice === device.id || device.transport === 'ssh' && (state.checkingSshAlias === device.ssh?.alias || device.ssh?.checking || device.ssh?.queued);
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>`;
75
+ const profile = device.profile;
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>` : '';
77
+ const identityHint = device.managementDriver?.fingerprint ? ` · ${device.managementDriver.fingerprint.slice(0, 8)}` : '';
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>`;
79
+ }
80
+
81
+ function openDeviceProfile(deviceId) {
82
+ const device = state.overview?.devices.find(d => d.id === deviceId);
83
+ if (!device) return;
84
+ const profile = device.profile ?? { displayName: '', type: 'unspecified', purpose: '', tags: [], services: [], revision: 0 };
85
+ state.profileDevice = deviceId;
86
+ state.profileDraft = { ...profile, tags: profile.tags.join(','), services: profile.services.join(',') };
87
+ state.profileError = null; state.toast = null; state.dialog = 'profile';
88
+ }
89
+ function profileBody() {
90
+ const device = state.overview?.devices.find(d => d.id === state.profileDevice), draft = state.profileDraft;
91
+ if (!device || !draft) return `<p class="modal-intro">${h("panel.50")}</p>`;
92
+ const disabled = state.profileSaving ? 'disabled' : '';
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>`;
94
+ }
95
+ async function saveDeviceProfile() {
96
+ if (state.profileSaving || !state.profileDraft) return;
97
+ const draft = { ...state.profileDraft }, deviceId = state.profileDevice;
98
+ const split = value => value.split(/[,,\n]/).map(v => v.trim()).filter(Boolean);
99
+ state.profileSaving = true; state.profileError = null; render();
100
+ try {
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) } });
102
+ state.overview = await api.overview();
103
+ if (state.dialog === 'profile' && state.profileDevice === deviceId) state.dialog = null;
104
+ toast(t("panel.70"));
105
+ } catch (cause) {
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";
107
+ } finally { state.profileSaving = false; render(); }
108
+ }
109
+ function updateOverview(overview) {
110
+ state.overview = overview;
111
+ for (const [id, requestedAt] of Object.entries(state.pendingCollections)) {
112
+ const device = overview.devices.find(d => d.id === id), result = device?.collection;
113
+ if (result?.requestedAt === requestedAt && result.status === 'pending') continue;
114
+ delete state.pendingCollections[id];
115
+ toast(result?.requestedAt === requestedAt && result.status === 'completed' ? `${device.name} ${t("panel.75")}` : t("panel.76"));
116
+ }
117
+ }
118
+ async function collectDevice(deviceId) {
119
+ const device = state.overview?.devices.find(d => d.id === deviceId);
120
+ if (!device || state.collectingDevice || !collectionAvailability(device).available) return;
121
+ state.collectingDevice = deviceId; state.toast = null; render();
122
+ try {
123
+ const result = await api.collectDevice(deviceId);
124
+ if (result.status === 'pending') state.pendingCollections[deviceId] = result.collection.requestedAt;
125
+ updateOverview(await api.overview());
126
+ if (result.status !== 'pending') toast(result.status === 'failed' ? sshErrorText(result.error) : `${device.name} ${t("panel.77")}`);
127
+ } catch (cause) {
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"));
129
+ } finally { state.collectingDevice = null; render(); }
76
130
  }
77
131
  function endpointText(endpoint) {
78
132
  return `${endpoint.host.includes(':') ? `[${endpoint.host}]` : endpoint.host}:${endpoint.port}`;
79
133
  }
80
134
  function currentRouteBody() {
81
135
  const device = state.overview?.devices.find((item) => item.id === state.routeDevice);
82
- if (!device) return '<p class="modal-intro">设备已不在当前列表,请刷新后查看。</p>';
136
+ if (!device) return `<p class="modal-intro">${h("panel.84")}</p>`;
83
137
  const local = device.source === 'local-runtime';
84
- if (local) return `<p class="modal-intro">${escapeHtml(device.name)} 的资源由当前${device.scope === 'host' ? '本机' : '容器'}进程直接采集,无 SSH、跳板或网络转发。</p>`;
85
- 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>`;
86
140
  const route = device.ssh?.route;
87
- if (!route) return '<p class="modal-intro">无法安全解析当前访问路径。请先核验完整 SSH 配置;不根据别名猜测跳板或连通性。</p>';
88
- 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];
89
143
  const forwarded = route.hops.length && route.target.addressScope === 'loopback';
90
- 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>`;
91
- 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>`;
92
146
  }
93
147
  function routeBody() {
94
148
  const device = state.overview?.devices.find((item) => item.id === state.routeDevice);
@@ -97,20 +151,20 @@ function routeBody() {
97
151
  const plan = device.connections;
98
152
  const provider = RELAY_PROVIDERS.find((item) => item.id === draft.relayProvider);
99
153
  const disabled = state.connectionSaving ? 'disabled' : '';
100
- return `<p class="modal-intro"><strong>${escapeHtml(device.name)}</strong> · 一台设备,两种连接方式</p>
101
- <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>
102
- <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>
103
157
  </section>
104
- <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>
105
- <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>
106
- <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>' : ''}
107
- ${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>` : ''}
108
162
  </section>
109
- <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>
110
- <p class="connection-note">这里只保存本机连接计划。入口尚未验证,自动选路与新的中继接入尚未启用;检查 SSH 仍使用下方的现有配置。</p>
111
- ${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>
112
- ${plan.saved ? `<p class="saved-plan-note">已保存于本机 · ${formatAgo(plan.updatedAt)}</p>` : ''}
113
- <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>`;
114
168
  }
115
169
  function openConnections(deviceId) {
116
170
  const device = state.overview?.devices.find((item) => item.id === deviceId);
@@ -124,21 +178,21 @@ async function saveConnections() {
124
178
  try {
125
179
  await api.saveConnections({ ...state.connectionDraft });
126
180
  state.overview = await api.overview();
127
- toast('连接计划已保存到本机,尚未启用自动选路。');
181
+ toast(t("panel.158"));
128
182
  } catch (cause) {
129
183
  focusField = cause.message === 'invalid_lan_host' ? 'lan-host' : cause.message === 'invalid_lan_port' ? 'lan-port' : null;
130
- 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";
131
185
  } finally { state.connectionSaving = false; render(); (focusField ? root.querySelector(`#${focusField}`) : root.querySelector('[data-action="save-connections"]'))?.focus({ preventScroll: true }); }
132
186
  }
133
187
  function sshErrorText(code) {
134
- 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");
135
189
  }
136
190
  function scheduleCheckRefresh() {
137
191
  clearTimeout(checkRefreshTimer);
138
192
  if (checkRefreshFailures >= 3 || !(state.overview?.devices ?? []).some((device) => device.ssh?.checking || device.ssh?.queued)) return;
139
193
  checkRefreshTimer = setTimeout(async () => {
140
194
  try { state.overview = await api.overview(); checkRefreshFailures = 0; }
141
- catch { if (++checkRefreshFailures >= 3) toast('检查结果暂时无法读取;恢复服务后刷新设备查看。'); }
195
+ catch { if (++checkRefreshFailures >= 3) toast(t("panel.177")); }
142
196
  render();
143
197
  }, 1000);
144
198
  }
@@ -148,14 +202,14 @@ async function checkSshDevice(alias) {
148
202
  try {
149
203
  const result = await api.checkSsh(alias);
150
204
  state.overview = await api.overview();
151
- toast(result.ok ? 'SSH 已通过认证并采集基础资源,这是本次检查的快照。' : sshErrorText(result.error));
205
+ toast(result.ok ? t("panel.178") : sshErrorText(result.error));
152
206
  } catch (cause) { toast(sshErrorText(cause.message)); }
153
207
  finally { state.checkingSshAlias = null; render(); }
154
208
  }
155
209
  function clientLabel() {
156
- if (!state.status) return '正在检查';
157
- if (state.status.client.state === 'observed' && !recentMcpInitialize()) return '接入记录较早,请重新检查';
158
- 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];
159
213
  }
160
214
  function recentMcpInitialize() {
161
215
  const time = state.status?.client.lastInitializeAt;
@@ -163,30 +217,56 @@ function recentMcpInitialize() {
163
217
  }
164
218
  function renderSidebar() {
165
219
  const projects = state.overview?.projects ?? [];
166
- 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>`;
167
- 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>`;
168
222
  }
169
223
  function renderConnection() {
170
224
  if (state.loading && !state.overview) return '';
171
- 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>`; }
172
226
  const container = state.status?.service.runtime === 'container';
173
- 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>`;
174
228
  }
175
229
  function managementComputer() { return state.overview?.devices.find((item) => item.source === 'local-runtime'); }
230
+ function openAgentInstall() {
231
+ const origin = cloudMode() ? state.overview.controlOrigin : null;
232
+ state.installMode = origin ? origin === OFFICIAL_CONTROL_ORIGIN ? 'official' : 'self-hosted' : 'local';
233
+ state.installOrigin = origin && origin !== OFFICIAL_CONTROL_ORIGIN ? origin : '';
234
+ state.toast = null; state.dialog = 'agent-install';
235
+ }
236
+ function installDraft() {
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') }; }
239
+ }
240
+ function agentInstallBody() {
241
+ const draft = installDraft();
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>`;
243
+ }
244
+ async function copyAgentInstall() {
245
+ const field = root.querySelector('#agent-install-text');
246
+ if (!field?.value) return;
247
+ let copied = false;
248
+ try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(field.value); copied = true; } } catch { /* manual selection remains available */ }
249
+ if (!copied) {
250
+ root.querySelector('.install-details').open = true; field.focus(); field.select();
251
+ try { copied = document.execCommand('copy'); } catch { /* manual selection remains available */ }
252
+ }
253
+ toast(copied ? t("panel.228") : t("panel.229"));
254
+ if (!copied) { root.querySelector('.install-details').open = true; root.querySelector('#agent-install-text')?.focus(); root.querySelector('#agent-install-text')?.select(); }
255
+ }
176
256
  function cloudMode() { return state.overview?.mode === 'cloud' || state.status?.service.runtime === 'cloud'; }
177
257
  function managementReady() { return state.status?.service.runtime === 'native' && managementComputer()?.scope === 'host'; }
178
258
  function managementBody() {
179
259
  const device = managementComputer();
180
- 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>`;
181
261
  const ready = managementReady();
182
- 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>`;
183
263
  }
184
264
  function onboardingBody() {
185
265
  const ready = managementReady();
186
266
  const seen = recentMcpInitialize();
187
- const steps = ['管理电脑', '智能体', '设备'];
267
+ const steps = [t("panel.249"), t("panel.250"), t("panel.251")];
188
268
  const body = state.setupStep === 0 ? managementBody() : state.setupStep === 1 ? connectionBody() : initializationBody();
189
- 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>`;
190
270
  }
191
271
  function openSetup(entry = 'start', step) {
192
272
  state.setupReturn = entry; state.toast = null; state.checkMessage = null;
@@ -201,12 +281,12 @@ function selectSetupStep(step) {
201
281
  }
202
282
  function connectionBody() {
203
283
  const terminal = state.method === 'terminal';
204
- 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>`;
205
285
  }
206
286
  function addBody() {
207
287
  if (cloudMode()) return cloudBindingBody();
208
288
  const candidates = state.ssh?.candidates ?? [];
209
- 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>`;
210
290
  }
211
291
  function driverCommand() {
212
292
  return connectCommand(state.overview?.controlOrigin ?? window.location.origin, state.requestSsh);
@@ -215,46 +295,46 @@ function cloudConnectDraft() {
215
295
  const official = state.cloudChoice === 'official';
216
296
  let command = '', error = null;
217
297
  try { command = connectCommand(official ? OFFICIAL_CONTROL_ORIGIN : state.customOrigin.trim()); }
218
- catch (cause) { if (state.customOrigin.trim()) error = cause.message; }
298
+ catch (cause) { if (state.customOrigin.trim()) error = t(cause.translationKey ?? 'error.origin'); }
219
299
  return { command, error };
220
300
  }
221
301
  function cloudConnectBody() {
222
302
  const official = state.cloudChoice === 'official';
223
303
  const { command, error } = cloudConnectDraft();
224
- 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>`;
225
305
  }
226
306
  function logCommandDraft() {
227
307
  const peers = cloudMode() ? state.overview?.drivers?.filter(p=>p.revokedAt == null) ?? [] : state.remotePeers;
228
308
  const manager = peers.find(p=>p.id === state.logManager), target = peers.find(p=>p.id === state.logTarget);
229
309
  try { return { ...logAccessCommands({manager:manager?.fingerprint,target:target?.fingerprint,path:state.logPath,label:state.logLabel}), error:null }; }
230
- catch(error) { return {trust:'',grant:'',error:error.message}; }
310
+ catch(error) { return {trust:'',grant:'',error:t(error.translationKey ?? 'error.devices')}; }
231
311
  }
232
312
  function logCommandFields() {
233
313
  const peers = cloudMode() ? state.overview?.drivers?.filter(p=>p.revokedAt == null) ?? [] : state.remotePeers;
234
- if (state.remoteLoading) return '<p class="small-note">正在读取同账号设备…</p>';
235
- if (state.remoteError) return '<p class="form-error">无法读取设备,请检查本机绑定、网络和云端状态。</p>';
236
- if (peers.length < 2) return '<p class="small-note">接入两台设备后,这里可以选择管理电脑与目标并生成具体授权命令。下方提供手动步骤。</p>';
237
- 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('');
238
318
  const draft=logCommandDraft();
239
- 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>`;
240
320
  }
241
321
  function secureHelpBody() {
242
- return `<p class="modal-intro">先将管理电脑与目标设备接入同一个账号。绑定只回报资源;以下授权在各自电脑的终端完成。</p>${logCommandFields()}<ol class="simple-steps"><li>两台电脑固定安装 <code>npm install -g device-center@0.2.0-beta.1</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>`;
243
323
  }
244
324
  function cloudBindingBody() {
245
325
  const pairing = state.pairing, disabled = state.pairBusy ? 'disabled' : '';
246
326
  const sourceCommand = driverCommand().replace(/^npx --yes device-center@\S+/, 'npm run driver --');
247
327
  const bindings = state.overview?.drivers ?? [];
248
- return `<p class="modal-intro">在自己的管理电脑运行 Driver,然后回到这里批准。云端不会读取浏览器所在电脑的文件。</p>
249
- <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>
250
- <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>
251
- <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>` : ''}
252
- ${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>
253
- ${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>` : ''}
254
- <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>`;
255
335
  }
256
336
  function pairErrorText(code) {
257
- 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";
258
338
  }
259
339
  async function pairAction(action, id) {
260
340
  if (state.pairBusy) return;
@@ -264,9 +344,9 @@ async function pairAction(action, id) {
264
344
  else if (action === 'pair-approve' && state.pairing) {
265
345
  await api.approvePairing(state.pairing.id, Boolean(state.pairing.requestedSsh && state.pairShare), state.pairing.code, state.pairing.fingerprint);
266
346
  state.pairing = null; state.pairCode = ''; state.pairShare = false;
267
- state.toast = '已批准,等待本机首次回报;保持本机终端打开。';
347
+ state.toast = t("panel.388");
268
348
  state.overview = await api.overview();
269
- } 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"); }
270
350
  } catch (error) { state.pairError = pairErrorText(error.message); }
271
351
  finally { state.pairBusy = false; render(); }
272
352
  }
@@ -275,43 +355,44 @@ async function copyDriverCommand() {
275
355
  field.focus(); field.select(); let copied = false;
276
356
  try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(field.value); copied = true; } } catch { /* manual selection remains available */ }
277
357
  if (!copied) try { copied = document.execCommand('copy'); } catch { /* manual selection */ }
278
- toast(copied ? '已复制。到自己的电脑终端运行,再回来输入配对码。' : '请手动复制已选中的接入命令。');
358
+ toast(copied ? t("panel.390") : t("panel.391"));
279
359
  if (!copied) { const current = root.querySelector(state.dialog === 'cloud-connect' ? '#cloud-connect-command' : '#driver-command'); current?.focus(); current?.select(); }
280
360
  }
281
361
  function initializationBody() {
282
- if (state.sshLoading) return '<p class="modal-intro">正在检查本机 SSH 配置…</p>';
362
+ if (state.sshLoading) return `<p class="modal-intro">${h("panel.392")}</p>`;
283
363
  const candidates = state.ssh?.candidates ?? [];
284
364
  const ready = candidates.filter((item) => item.importable);
285
365
  const pending = ready.filter((item) => !item.associated);
286
366
  const skipped = candidates.filter((item) => !item.importable);
287
- const reason = (item) => ({ host_driver_required: '需要在宿主运行本机 Driver', ssh_runtime_unsupported: '当前系统暂不支持此导入检查', ssh_identity_unavailable: '身份文件引用不可用', ssh_profile_incomplete: '连接参数不完整', ssh_profile_needs_review: '配置含未支持的规则或跳板,需人工核对' })[item.reason] ?? '配置需要核对';
288
- 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('');
289
- 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>` : ''}`;
290
370
  }
291
371
  function residentBody() {
292
372
  const port = new URL(state.setup?.url ?? location.href).port;
293
373
  const portOption = port ? ` --port ${port}` : "";
294
- 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>`;
295
375
  }
296
376
  function renderDialog() {
297
377
  if (!state.dialog) return '';
298
- const title = { 'secure-help': '授权远程日志', setup: '开始使用', connect: '连接本机 Codex', 'cloud-connect': '云端面板 · 可选', add: cloudMode() ? '连接我的电脑' : '添加设备', initialize: '初始化 · 导入本机 SSH', resident: '让服务在后台运行', route: '连接方式' }[state.dialog];
299
- const body = 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();
300
- 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>`;
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];
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();
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>`;
301
381
  }
302
382
  function filteredDevices() {
303
383
  const needle = state.query.trim().toLocaleLowerCase();
304
- return (state.overview?.devices ?? []).filter((d) => (state.project === 'all' || d.projectId === state.project) && (state.filter === 'all' || d.state === state.filter) && (!needle || `${d.name} ${d.platform}`.toLocaleLowerCase().includes(needle)));
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)));
305
385
  }
306
386
  function renderDevices() {
307
- if (state.loading) return '<div class="empty-state"><p>正在读取设备…</p></div>';
308
- 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>`;
309
389
  const devices = filteredDevices();
310
390
  if (devices.length) return devices.map(deviceCard).join('');
311
391
  const any = state.overview?.devices.length;
312
- 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>`;
313
393
  }
314
394
  function render() {
395
+ document.title = "Device Center · " + t(cloudMode() ? "panel.184" : "panel.194");
315
396
  clearTimeout(snapshotExpiryTimer);
316
397
  scheduleCheckRefresh();
317
398
  scheduleCloudRefresh();
@@ -327,31 +408,32 @@ function render() {
327
408
  const dialogScroll = oldDialog?.scrollTop ?? 0;
328
409
  const listScroll = root.querySelector('.device-grid')?.scrollTop ?? 0;
329
410
  const focused = document.activeElement;
411
+ const languageFocused = Boolean(focused?.closest('[data-language-picker]'));
330
412
  const focusAction = focused?.dataset.action;
331
413
  const focusId = oldDialog?.contains(focused) ? focused.id : null;
332
414
  const focusField = focused?.dataset.field;
333
- const focusQualifier = ['method', 'copy', 'filter', 'project', 'alias', 'device', 'provider', 'step']
415
+ const focusQualifier = ['method', 'copy', 'filter', 'project', 'alias', 'device', 'provider', 'step', 'mode']
334
416
  .filter((key) => focused?.dataset[key])
335
417
  .map((key) => `[data-${key}="${CSS.escape(focused.dataset[key])}"]`).join('');
336
- const findFocus = (container) => focusId
418
+ const findFocus = (container) => languageFocused ? container.querySelector('[data-language-trigger]') : focusId
337
419
  ? container.querySelector(`#${CSS.escape(focusId)}`)
338
420
  : focusAction ? container.querySelector(`[data-action="${CSS.escape(focusAction)}"]${focusQualifier}:not(:disabled)`)
339
421
  : focusField ? container.querySelector(`[data-field="${CSS.escape(focusField)}"]${focusQualifier}:not(:disabled)`) : null;
340
422
  const project = (state.overview?.projects ?? []).find((p) => p.id === state.project);
341
423
  const counts = state.overview?.stats ?? { total: 0, online: 0, unknown: 0, expired: 0 };
342
424
  root.innerHTML = `<div class="app-shell">${renderSidebar()}<div class="main-area">
343
- <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>
344
426
  <main class="workspace">
345
- <section class="page-heading"><div><h1>${escapeHtml(project?.name ?? '所有设备')}</h1><p>把自己的电脑,变成智能体可用的资源。</p></div><button class="button button-primary" data-action="add">${icon('plus', 16)} ${cloudMode() ? state.overview?.drivers?.some(item => item.revokedAt == null) ? '管理连接' : '连接我的电脑' : '添加设备'}</button></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>
346
428
  ${state.error ? '' : renderConnection()}
347
- <section class="list-section" aria-label="设备资源">
348
- <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>
349
- <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="刷新设备">${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>
350
432
  </div>
351
- <div class="device-grid" role="region" aria-label="设备列表" tabindex="0">${renderDevices()}</div>
352
- <p class="list-footnote">${cloudMode() ? '本机回报每 30 秒 · 超过 90 秒待刷新 · SSH 缓存超过 5 分钟待刷新' : '导入后检查 SSH · 快照超过 5 分钟待刷新 · 点击路径看详情'}</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>
353
435
  </section>
354
- <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 秒,或 SSH 缓存超过 5 分钟;不代表密钥过期。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>
355
437
  </main>
356
438
  </div>${renderDialog()}${state.toast && !state.dialog ? `<div class="toast page-toast" role="status">${escapeHtml(state.toast)}</div>` : ''}</div>`;
357
439
  const dialog = root.querySelector('[role="dialog"]');
@@ -361,8 +443,9 @@ function render() {
361
443
  root.querySelector('.main-area').inert = Boolean(dialog);
362
444
  if (dialog) {
363
445
  dialog.scrollTop = dialogScroll;
364
- (oldDialog ? findFocus(dialog) ?? dialog.querySelector('button') : dialog.querySelector('button'))?.focus({ preventScroll: true });
365
- } else if (oldDialog) root.querySelector(oldDialog.dataset.kind === 'setup' ? `[data-action="${CSS.escape(state.setupReturn)}"]` : oldDialog.dataset.kind === 'route' ? `[data-action="route"][data-device="${CSS.escape(state.routeDevice)}"]` : `[data-action="${{ connect: 'connect', 'cloud-connect': 'cloud-connect', add: 'add', initialize: 'initialize', resident: 'resident' }[oldDialog.dataset.kind] ?? 'connect'}"]`)?.focus({ preventScroll: true });
446
+ const initialFocus = dialog.querySelector('button[data-action="close"]') ?? dialog.querySelector('button');
447
+ (oldDialog ? findFocus(dialog) ?? initialFocus : initialFocus)?.focus({ preventScroll: true });
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 });
366
449
  else findFocus(root)?.focus({ preventScroll: true });
367
450
  if (dialog) dialog.dataset.kind = state.dialog;
368
451
  }
@@ -373,10 +456,10 @@ function scheduleCloudRefresh() {
373
456
  cloudRefreshTimer = setTimeout(async () => {
374
457
  if (state.pairBusy || cloudRefreshBusy) { scheduleCloudRefresh(); return; }
375
458
  cloudRefreshBusy = true;
376
- try { state.overview = await api.overview(); state.cloudRefreshError = null; cloudRefreshFailures = 0; }
377
- catch { cloudRefreshFailures++; state.cloudRefreshError = cloudRefreshFailures >= 3 ? '自动刷新已暂停,请手动刷新' : '暂时无法刷新,正在重试'; }
459
+ try { updateOverview(await api.overview()); state.cloudRefreshError = null; cloudRefreshFailures = 0; }
460
+ catch { cloudRefreshFailures++; state.cloudRefreshError = cloudRefreshFailures >= 3 ? "panel.475" : "panel.476"; }
378
461
  finally { cloudRefreshBusy = false; render(); }
379
- }, 15_000);
462
+ }, Object.keys(state.pendingCollections).length ? 3000 : 15_000);
380
463
  }
381
464
  async function load() {
382
465
  checkRefreshFailures = 0;
@@ -386,7 +469,7 @@ async function load() {
386
469
  try {
387
470
  const [overview, setup, status] = await Promise.all([api.overview(), api.connectorSetup(), api.connectorStatus()]);
388
471
  if (version !== requestVersion) return;
389
- Object.assign(state, { overview, setup, status });
472
+ Object.assign(state, { setup, status }); updateOverview(overview);
390
473
  } catch { if (version === requestVersion) state.error = 'service_unavailable'; }
391
474
  finally { if (version === requestVersion) { state.loading = false; render(); } }
392
475
  }
@@ -395,7 +478,7 @@ async function loadSsh() {
395
478
  try {
396
479
  state.ssh = await (state.dialog === 'initialize' || state.dialog === 'setup' ? api.sshInitialization() : api.sshCandidates());
397
480
  state.selectedAliases = state.selectedAliases.filter((alias) => state.ssh.candidates.some((item) => item.alias === alias && !item.associated));
398
- } catch { state.sshError = '无法读取本机配置,请确认服务仍在运行。'; }
481
+ } catch { state.sshError = "panel.477"; }
399
482
  finally { state.sshLoading = false; render(); }
400
483
  }
401
484
  async function associateSsh() {
@@ -406,8 +489,8 @@ async function associateSsh() {
406
489
  state.overview = await api.overview();
407
490
  state.filter = 'all'; state.query = ''; state.project = 'all'; state.selectedAliases = []; state.dialog = null;
408
491
  checkRefreshFailures = 0;
409
- toast(result.checks.aliases.length ? `已关联 ${result.added} 台设备,正在检查 SSH 连通性。` : '这些设备已经关联,没有重复导入或检查。');
410
- } 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"; }
411
494
  finally { state.saving = false; render(); }
412
495
  }
413
496
  async function initializeSsh() {
@@ -420,8 +503,8 @@ async function initializeSsh() {
420
503
  state.ssh = await api.sshInitialization();
421
504
  state.filter = 'all'; state.query = ''; state.project = 'all';
422
505
  checkRefreshFailures = 0;
423
- toast(result.checks.aliases.length ? `已导入 ${result.added} 个配置,正在检查 SSH 连通性;结果会标在设备上。` : '这些配置已经导入,没有新增重复设备。');
424
- } 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"; }
425
508
  finally { state.saving = false; render(); root.querySelector('[data-action="ssh-refresh"]')?.focus({ preventScroll: true }); }
426
509
  }
427
510
  let toastTimer;
@@ -432,8 +515,7 @@ function toast(message) {
432
515
  async function copyField(kind) {
433
516
  const text = kind === 'command' ? state.setup?.setupCommand : state.setup?.url;
434
517
  const field = root.querySelector('#copy-field');
435
- if (!text || !field) return toast('地址尚未就绪,请先重新连接服务。');
436
- field.focus(); field.select();
518
+ if (!text || !field) return toast(t("panel.486"));
437
519
  let copied = false;
438
520
  let timer;
439
521
  if (navigator.clipboard?.writeText) {
@@ -441,30 +523,35 @@ async function copyField(kind) {
441
523
  catch { /* retain manual selection */ } finally { clearTimeout(timer); }
442
524
  }
443
525
  if (!copied) try { copied = document.execCommand('copy'); } catch { /* retain manual selection */ }
444
- toast(copied ? `${kind === 'command' ? '命令' : '地址'}已复制,去 Codex 完成配置。` : '无法自动复制,请手动复制已选中的内容。');
526
+ toast(copied ? t("copy.codex") : t("panel.490"));
445
527
  if (!copied) { root.querySelector('#copy-field')?.focus(); root.querySelector('#copy-field')?.select(); }
446
528
  }
447
529
  root.addEventListener('click', async (event) => {
448
530
  const button = event.target.closest('[data-action]');
449
531
  if (!button) return;
450
532
  const action = button.dataset.action;
451
- if (action === 'logout') { try { await api.logout(); location.replace('/login'); } catch { state.toast = '退出失败,请重试'; render(); } return; }
533
+ if (action === 'agent-install') { openAgentInstall(); render(); return; }
534
+ if (action === 'install-mode') { state.installMode = button.dataset.mode; state.toast = null; render(); return; }
535
+ if (action === 'agent-install-copy') return copyAgentInstall();
536
+ if (action === 'logout') { try { await api.logout(); location.replace('/login?lang=' + getLocale()); } catch { state.toast = t("panel.491"); render(); } return; }
452
537
  if (cloudMode() && action === 'add') { state.dialog = 'add'; state.toast = null; state.pairError = null; state.revokeId = null; render(); return; }
453
538
  if (['driver-copy', 'cloud-command-copy'].includes(action)) return copyDriverCommand();
454
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; }
455
- 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; }
456
541
  if (action === 'cloud-connect') { state.dialog = 'cloud-connect'; state.toast = null; render(); return; }
457
542
  if (action === 'cloud-choice') { state.cloudChoice = button.dataset.provider; state.toast = null; render(); return; }
458
543
  if (['pair-lookup', 'pair-approve', 'revoke-confirm'].includes(action)) return pairAction(action, button.dataset.device);
459
544
  if (action === 'revoke-arm') { state.revokeId = button.dataset.device; render(); return; }
460
545
  if (action === 'revoke-cancel') { state.revokeId = null; render(); return; }
461
- if (action === 'close') { if (button.classList.contains('modal-scrim') && button !== event.target) return; state.dialog = null; state.toast = null; }
546
+ if (action === 'close') { if (state.dialog === 'profile' && state.profileSaving || button.classList.contains('modal-scrim') && button !== event.target) return; state.dialog = null; state.toast = null; }
462
547
  else if (action === 'start') return openSetup('start');
463
548
  else if (action === 'connect') return openSetup('connect', 1);
464
549
  else if (action === 'initialize') return openSetup('start', 2);
465
550
  else if (action === 'setup-step') return selectSetupStep(Number(button.dataset.step));
466
551
  else if (action === 'setup-finish') { state.dialog = null; state.toast = null; state.filter = 'all'; state.query = ''; state.project = 'all'; }
467
552
  else if (action === 'route') openConnections(button.dataset.device);
553
+ else if (action === 'profile') openDeviceProfile(button.dataset.device);
554
+ else if (action === 'collect-device') return collectDevice(button.dataset.device);
468
555
  else if (action === 'save-connections') return saveConnections();
469
556
  else if (action === 'clear-relay') state.connectionDraft.relayProvider = null;
470
557
  else if (['connect', 'resident', 'add', 'initialize'].includes(action)) { state.dialog = action; state.toast = null; state.checkMessage = null; if (['add', 'initialize'].includes(action)) { state.ssh = null; state.selectedAliases = []; state.sshError = null; render(); return loadSsh(); } }
@@ -481,8 +568,8 @@ root.addEventListener('click', async (event) => {
481
568
  state.checking = true; state.checkMessage = null; render();
482
569
  try {
483
570
  state.status = await api.connectorStatus();
484
- state.checkMessage = state.status.client.state === 'observed' ? '已收到真实 MCP 初始化;它不代表其他设备已连通。' : state.status.client.state === 'stale' ? '上次初始化已超过 5 分钟,请在 Codex 中重新启动连接。' : '尚未收到 MCP 初始化。请在 Codex 中保存并重新启动连接。';
485
- } 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"; }
486
573
  finally { state.checking = false; }
487
574
  render();
488
575
  root.querySelector('[data-action="check"]')?.focus({ preventScroll: true });
@@ -491,6 +578,8 @@ root.addEventListener('click', async (event) => {
491
578
  render();
492
579
  });
493
580
  root.addEventListener('change', (event) => {
581
+ if (event.target.dataset.field === 'installBackground') { state.installBackground = event.target.checked; render(); return; }
582
+ if (event.target.dataset.profileField && state.profileDraft) { state.profileDraft[event.target.dataset.profileField] = event.target.value; return; }
494
583
  if (['requestSsh', 'pairShare'].includes(event.target.dataset.field)) { state[event.target.dataset.field] = event.target.checked; render(); return; }
495
584
  if (['relayProvider', 'preference'].includes(event.target.dataset.field) && state.connectionDraft) { state.connectionDraft[event.target.dataset.field] = event.target.value; state.connectionError = null; render(); return; }
496
585
  if (event.target.dataset.field !== 'ssh-alias') return;
@@ -499,6 +588,15 @@ root.addEventListener('change', (event) => {
499
588
  render();
500
589
  });
501
590
  root.addEventListener('input', (event) => {
591
+ if (event.target.dataset.field === 'installOrigin') {
592
+ state.installOrigin = event.target.value;
593
+ const draft = installDraft();
594
+ root.querySelector('#agent-install-text').value = draft.text;
595
+ const message = root.querySelector('#install-error'); message.textContent = draft.error ?? ''; message.hidden = !draft.error;
596
+ root.querySelector('[data-action="agent-install-copy"]').disabled = Boolean(draft.error);
597
+ return;
598
+ }
599
+ if (event.target.dataset.profileField && state.profileDraft) { state.profileDraft[event.target.dataset.profileField] = event.target.value; return; }
502
600
  if (event.target.dataset.field === 'customOrigin') {
503
601
  state.customOrigin = event.target.value;
504
602
  const { command, error } = cloudConnectDraft();
@@ -515,10 +613,14 @@ root.addEventListener('input', (event) => {
515
613
  root.querySelector('.device-grid').innerHTML = renderDevices();
516
614
  root.querySelector('.device-grid').scrollTop = 0;
517
615
  });
616
+ root.addEventListener('submit', (event) => {
617
+ if (event.target.id !== 'device-profile-form') return;
618
+ event.preventDefault(); void saveDeviceProfile();
619
+ });
518
620
  root.addEventListener('keydown', (event) => {
519
621
  const dialog = root.querySelector('[role="dialog"]');
520
622
  if (!dialog) return;
521
- if (event.key === 'Escape') { state.dialog = null; state.toast = null; render(); return; }
623
+ if (event.key === 'Escape') { if (state.dialog === 'profile' && state.profileSaving) return; state.dialog = null; state.toast = null; render(); return; }
522
624
  if (event.key !== 'Tab') return;
523
625
  const controls = [...dialog.querySelectorAll('button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled), summary, a[href]')].filter((el) => el.getClientRects().length);
524
626
  const first = controls[0], last = controls.at(-1);
@@ -526,7 +628,6 @@ root.addEventListener('keydown', (event) => {
526
628
  else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
527
629
  });
528
630
  document.addEventListener('visibilitychange', () => { if (!document.hidden) { cloudRefreshFailures = 0; scheduleCloudRefresh(); } else clearTimeout(cloudRefreshTimer); });
529
- load();
530
631
 
531
632
  root.addEventListener('input', event => {
532
633
  const field=event.target.dataset.logField;
@@ -535,6 +636,9 @@ root.addEventListener('input', event => {
535
636
  const draft=logCommandDraft();
536
637
  const trust=root.querySelector('#log-trust-command'),grant=root.querySelector('#log-grant-command');
537
638
  if(trust)trust.value=draft.trust;if(grant)grant.value=draft.grant;
538
- 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");
539
640
  for(const button of root.querySelectorAll('[data-action="log-command-copy"]'))button.disabled=Boolean(draft.error);
540
641
  });
642
+
643
+ initLanguage({ onChange: () => { state.toast = null; render(); } });
644
+ load();