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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Device Center
2
2
 
3
+ 界面与 Agent 安装说明支持简中、繁中、英、日、韩、西、法、德、巴西葡语。页面顶部可切换,也可分享 `/start?lang=en`、`/start?lang=ja`。详见[多语言说明](docs/i18n.md)。
4
+
3
5
  让主力电脑上的智能体,使用自己的闲置设备。MIT · Node.js 24 · macOS / Linux · beta。
4
6
 
5
7
  一个本机连接器,加一个可选的云端控制面。真实本机内存、磁盘分别展示;已有 SSH 配置可一键关联并执行固定只读检查;设备 Agent 可通过域名绑定账号,主动回报状态。指定日志经设备主人单独授权后,以双向 TLS 1.3 直连或密文中转读取。没有默认示例设备或虚构在线状态。
@@ -8,6 +10,8 @@
8
10
 
9
11
  希望交给本机 Agent 安装时,按[Agent 安装指南](docs/agent-install.md)选择本机、官方托管或已有自建服务。“交给 Agent 安装”可直接复制完整说明;登录和设备绑定仍由用户核对。朋友试用从[一页指南](https://devices.owenshen.top/start)开始,无需先登录。
10
12
 
13
+ 0.2.0-beta.3 增加九语言界面与安装说明,语言菜单支持键盘、窄屏与输入保留。长文档和 CLI 仍以中文为主。
14
+
11
15
  0.2.0-beta.2 增加设备命名、类型、用途、标签、手动服务清单及“采集一次”,见[设备说明与手动采集](docs/device-profiles.md)。旧 Driver 需升级后才能响应云端手动采集;服务与应用仍由用户手动登记。
12
16
 
13
17
  第一次使用可按 [新用户上手教程](docs/quick-start.md) 完成安装、设备配对和指定日志授权;[验收记录](docs/user-acceptance.md)列出已验证项目与限制。
@@ -15,7 +19,7 @@
15
19
  只用本机,不注册账号:
16
20
 
17
21
  ```sh
18
- npx --yes device-center@0.2.0-beta.2 start
22
+ npx --yes device-center@0.2.0-beta.3 start
19
23
  ```
20
24
 
21
25
  打开 `http://127.0.0.1:5174`,将 `/mcp` 添加到本机智能体。侧栏“开始使用”按管理电脑 → 智能体 → 设备引导;导入 SSH 后检查一次连通性。没有可用 Agent/SSH 的设备保持未知;资源快照过期显示“待刷新”,不代表密钥过期。列表刷新不主动连接 SSH。
@@ -23,8 +27,8 @@ npx --yes device-center@0.2.0-beta.2 start
23
27
  需要在外查看设备、使用日志中转:
24
28
 
25
29
  ```sh
26
- npx --yes device-center@0.2.0-beta.2 plan
27
- npx --yes device-center@0.2.0-beta.2 connect --confirm
30
+ npx --yes device-center@0.2.0-beta.3 plan
31
+ npx --yes device-center@0.2.0-beta.3 connect --confirm
28
32
  # 自建控制面可指定 --cloud https://devices.example.com
29
33
  ```
30
34
 
@@ -66,4 +70,4 @@ npm run verify:package
66
70
 
67
71
  需要已有受信任 App Services 共享账号服务与同父域会话;不是通用第三方 OAuth。个人部署可选 personal + ownerEmail。旧个人数据切多用户前按[迁移与回退说明](docs/hosted-access.md)核验原所有者映射;不能只切回旧代码而保留多用户数据库。
68
72
 
69
- 当前没有密钥托管、安装时自动联网或远程 shell。安全边界与披露方式见 [SECURITY](SECURITY.md)。npm 包公开不自动改变 GitHub 仓库可见性;本次发布是可追溯工作区快照,不是 main CI 构建。
73
+ 当前没有密钥托管、安装时自动联网或远程 shell。安全边界与披露方式见 [SECURITY](SECURITY.md)。npm 包公开不自动改变 GitHub 仓库可见性;发布包按固定源码提交构建;手工发布不等同主线 CI 自动交付。
@@ -2,7 +2,7 @@
2
2
 
3
3
  用户选择模式后,把完整安装说明交给**正在目标电脑上运行、具备本机安装能力的 Agent**。网页只复制说明,不会远程替浏览器所在电脑安装软件。
4
4
 
5
- `device-center@0.2.0-beta.2` 提供“交给 Agent 安装”、设备说明与手动采集。新用户可在[一页试用指南](https://devices.owenshen.top/start)免登录选择方式并复制完整说明。
5
+ `device-center@0.2.0-beta.3` 提供“交给 Agent 安装”、设备说明与手动采集。新用户可在[一页试用指南](https://devices.owenshen.top/start)免登录选择方式并复制完整说明。
6
6
 
7
7
  ## 用户只需要选择一种方式
8
8
 
@@ -29,7 +29,7 @@
29
29
  全新安装可以使用:
30
30
 
31
31
  ```sh
32
- npm install --global --prefix "$HOME/.local" --ignore-scripts device-center@0.2.0-beta.2
32
+ npm install --global --prefix "$HOME/.local" --ignore-scripts device-center@0.2.0-beta.3
33
33
  "$HOME/.local/bin/device-center" --version
34
34
  ```
35
35
 
@@ -1,6 +1,6 @@
1
1
  # Device Center · 朋友试用指南
2
2
 
3
- 让本机 Agent 看见自己的电脑与闲置设备:真实内存、磁盘、设备用途,以及单独授权的指定日志。当前为 `0.2.0-beta.2` 公开试用版。
3
+ 让本机 Agent 看见自己的电脑与闲置设备:真实内存、磁盘、设备用途,以及单独授权的指定日志。当前为 `0.2.0-beta.3` 公开试用版。
4
4
 
5
5
  **入口:https://devices.owenshen.top/start**。支持 macOS / Linux,需要 Node.js 24+;Windows 原生版尚未支持。
6
6
 
@@ -14,7 +14,7 @@
14
14
 
15
15
  Agent 按说明检查已有配置、固定版本安装、设置用户级后台服务、配置 MCP 并核验结果。已有配置冲突时解释并让你选择,不自动覆盖。系统授权或当前 Agent 无法配置 MCP 时,会请你完成对应一步。
16
16
 
17
- 不使用 Agent 时,可在全新环境运行 `npx --yes device-center@0.2.0-beta.2 start` 体验本机面板;这是前台方式,终端关闭后停止。长期使用按[完整安装指南](https://devices.owenshen.top/install/agent.md)固定安装。
17
+ 不使用 Agent 时,可在全新环境运行 `npx --yes device-center@0.2.0-beta.3 start` 体验本机面板;这是前台方式,终端关闭后停止。长期使用按[完整安装指南](https://devices.owenshen.top/install/agent.md)固定安装。
18
18
 
19
19
  ## 2. 托管模式才需要登录绑定
20
20
 
@@ -1,6 +1,6 @@
1
1
  # 本机、官方与自建接入
2
2
 
3
- 本版本 0.2.0-beta.2 使用同一 CLI 支持官方与自建 HTTPS 控制面;每个账号独立管理自己的设备。安装不自动接入,connect --confirm 默认申请官方服务,用户登录并核对批准后才绑定。
3
+ 本版本 0.2.0-beta.3 使用同一 CLI 支持官方与自建 HTTPS 控制面;每个账号独立管理自己的设备。安装不自动接入,connect --confirm 默认申请官方服务,用户登录并核对批准后才绑定。
4
4
 
5
5
  ## 用户流程
6
6
 
package/docs/i18n.md ADDED
@@ -0,0 +1,32 @@
1
+ # 多语言界面
2
+
3
+ 支持简体中文(`zh-CN`)、繁體中文(`zh-TW`)、English(`en`)、日本語(`ja`)、한국어(`ko`)、Español(`es`)、Français(`fr`)、Deutsch(`de`)、Português do Brasil(`pt-BR`)。繁中采用台湾常用术语,葡语采用巴西用法。
4
+
5
+ ## 使用
6
+
7
+ 页面顶部点击地球图标与当前语言,展开双列菜单;各语言使用自己的原名,当前语言显示勾选。登录页和弹窗复用同一组件。选择后自动收起,点击外部或按 Esc 可关闭;支持方向键、Home / End、回车和 Tab。窄屏下菜单位置保持在视口内。
8
+
9
+ 切换不刷新页面,保留设备说明、搜索与安装地址等当前输入,不触发配对、采集或授权。
10
+
11
+ 首次按浏览器语言选择;之后记住当前浏览器、当前站点的选择。优先级是 URL 的 `lang` → 本机保存的偏好 → 浏览器语言 → 英文。支持地区变体,如 `en-US`、`ja-JP`、`zh-Hant-HK`。本机存储不可用时仍可切换,并通过 URL 保持当前语言。
12
+
13
+ 可以分享指定语言的页面,例如 `/start?lang=en` 或 `/start?lang=ja`。内部导航、登录重定向与退出登录会带上所选语言。
14
+
15
+ ## 覆盖与边界
16
+
17
+ - 公开试用指南、登录页、设备面板、设备说明、状态与采集提示、SSH 导入与连接计划、配对和日志授权说明。
18
+ - 复制给 Agent 的完整安装说明按所选语言生成;具体命令、版本、域名与参数保持原样。复制不会安装软件。
19
+ - 设备名称、用途、标签、服务、SSH 别名、路径、指纹等用户数据不翻译。状态代码、API、认证、有效期与权限契约不变。
20
+ - 浏览器内使用静态词典,不发送设备数据到外部翻译服务。已有服务端固定诊断仅通过明确的文案映射翻译;未知诊断原样显示。
21
+ - Markdown 长文档与 CLI 输出目前以简体中文为主。外语页面的长文档链接注明中文,不能将界面翻译等同于所有文档与 CLI 已本地化。
22
+ - 九种语言已做目录完整性与交互检查;公开推广前仍欢迎母语使用者改进术语和语气。
23
+
24
+ ## 维护
25
+
26
+ `src/messages.js` 是词典真源;各语言必须拥有同一组键、相同的 `{placeholder}` 参数,不能把旧键重新分配给不同语义。`panel.N` 是现有面板的稳定文案键;新增文案优先用语义键,如 `count.bound`。
27
+
28
+ `src/i18n.js` 负责语言归一化、选择与安全插值。文本使用 `t`,HTML 模板使用会转义参数的 `h`。静态页面只翻译显式 `data-i18n` 标记;禁止遍历用户文本或对整个 DOM 做替换。布局选择器使用稳定 class,不能依赖翻译后的 aria-label。
29
+
30
+ `src/install-prompt.js` 负责说明组装,`src/onboarding.js` 保持命令和验证规则。翻译安全说明时保留本人核对完整指纹、配对码不进入 prompt、私钥留在设备、操作单独授权等约束。
31
+
32
+ 运行 `npm run check`、`npm test`。`tests/i18n.test.mjs` 覆盖词典、占位符、引用、地区语言归一化、安装模式与命令不变性。新增公共脚本时须核对云端静态白名单、未登录 API 隔离和安装包清单。
package/docs/npm.md CHANGED
@@ -1,16 +1,16 @@
1
1
  # npm / npx 分发
2
2
 
3
- MIT,Node.js 24,macOS/Linux。当前版本 `0.2.0-beta.2`,使用 npm `beta` 渠道;是否上架以公开 registry 为准。
3
+ MIT,Node.js 24,macOS/Linux。当前版本 `0.2.0-beta.3`,使用 npm `beta` 渠道;是否上架以公开 registry 为准。
4
4
 
5
5
  ```sh
6
- npx --yes device-center@0.2.0-beta.2 --help
7
- npx --yes device-center@0.2.0-beta.2 plan
6
+ npx --yes device-center@0.2.0-beta.3 --help
7
+ npx --yes device-center@0.2.0-beta.3 plan
8
8
  # 主动接入默认官方服务,执行后在终端与面板之间核对配对
9
- npx --yes device-center@0.2.0-beta.2 connect --confirm
9
+ npx --yes device-center@0.2.0-beta.3 connect --confirm
10
10
  # 自建服务
11
- npx --yes device-center@0.2.0-beta.2 connect --cloud https://你的控制面域名 --confirm
11
+ npx --yes device-center@0.2.0-beta.3 connect --cloud https://你的控制面域名 --confirm
12
12
  # 仅本机,无账号要求
13
- npx --yes device-center@0.2.0-beta.2 start
13
+ npx --yes device-center@0.2.0-beta.3 start
14
14
  ```
15
15
 
16
16
  安装与无参/help/version/plan 不生成身份,不配对,不自动启动后台进程。复制命令不含凭据。只有 connect --confirm 才生成本机身份并申请绑定;默认官方地址不会自动批准,用户需登录自己的账号。SSH 缓存分享须 --share-ssh 与面板再次批准。
@@ -18,7 +18,7 @@ npx --yes device-center@0.2.0-beta.2 start
18
18
  面板和本机 MCP:`http://127.0.0.1:5174/`、`/mcp`。常驻先固定安装:
19
19
 
20
20
  ```sh
21
- npm install -g device-center@0.2.0-beta.2
21
+ npm install -g device-center@0.2.0-beta.3
22
22
  device-center service plan --port 5174
23
23
  # 自行停止同端口前台进程,再主动安装
24
24
  device-center service install --port 5174 --confirm
@@ -32,7 +32,7 @@ Driver 主动建立获准的安全出站连接。短时一次性登记凭据应
32
32
  | 目标设备条件 | 用户最少操作 | 结果条件 |
33
33
  | --- | --- | --- |
34
34
  | 管理电脑已有可用 SSH 配置 | 一键导入引用 | 导入后自动检查一次,取得真实资源后才显示近期在线与资源 |
35
- | 目标设备已有智能体 | 复制安装指导,交给目标机智能体;确认安装与绑定 | 0.2.0-beta.2 可复制完整安装说明,本机生成身份并核对配对与真实回报;签名安装器待实现 |
35
+ | 目标设备已有智能体 | 复制安装指导,交给目标机智能体;确认安装与绑定 | 0.2.0-beta.3 可复制完整安装说明,本机生成身份并核对配对与真实回报;签名安装器待实现 |
36
36
  | 目标设备没有智能体 | 下载对应平台的验签安装包,在目标机运行并批准绑定 | 与智能体辅助方式使用同一接入协议;待实现 |
37
37
  | 不能安装 Agent,也没有可用 SSH | 明示缺少接入条件 | 保持未知,不伪装接通 |
38
38
 
@@ -1,13 +1,13 @@
1
1
  # 远程日志:明确授权后使用
2
2
 
3
- 本版本 0.2.0-beta.2 支持 macOS / Linux、Node.js 24。两台设备主动连接同一 HTTPS 控制面,默认使用 `https://devices.owenshen.top`;可用 `--cloud https://你的域名` 替换。安装或查看帮助不会产生身份、配对或运行远程操作。
3
+ 本版本 0.2.0-beta.3 支持 macOS / Linux、Node.js 24。两台设备主动连接同一 HTTPS 控制面,默认使用 `https://devices.owenshen.top`;可用 `--cloud https://你的域名` 替换。安装或查看帮助不会产生身份、配对或运行远程操作。
4
4
 
5
5
  ## 最短流程
6
6
 
7
7
  在管理电脑和目标设备分别运行:
8
8
 
9
9
  ```sh
10
- npm install -g device-center@0.2.0-beta.2
10
+ npm install -g device-center@0.2.0-beta.3
11
11
  device-center connect --confirm
12
12
  ```
13
13
 
package/docs/roadmap.md CHANGED
@@ -14,6 +14,12 @@
14
14
  - “交给 Agent 安装”:本机、官方托管、已有自建服务;固定版本、用户级后台与 MCP 步骤。
15
15
  - 免登录的一页试用指南。登录、完整指纹核对和设备批准仍由用户完成。
16
16
 
17
+ ## 0.2.0-beta.3
18
+
19
+ - 简中、繁中、英、日、韩、西、法、德、巴葡九种语言,覆盖指南、登录、面板与 Agent 安装说明。
20
+ - 统一语言菜单、浏览器偏好和分享链接,切换保留未保存输入;命令与设备数据保持原样。
21
+ - 长文档和 CLI 仍以中文为主,翻译尚待母语者校对。
22
+
17
23
  ## 后续候选
18
24
 
19
25
  1. 验证各平台真实常驻、升级和恢复;减少 Node 与终端依赖。
package/index.html CHANGED
@@ -6,6 +6,7 @@
6
6
  <meta name="theme-color" content="#f4f5f1" />
7
7
  <meta name="description" content="Device Center · 云端控制面与设备 Agent 接入原型" />
8
8
  <link rel="stylesheet" href="/src/styles.css" />
9
+ <link rel="stylesheet" href="/src/i18n.css" />
9
10
  <title>Device Center · 设备控制面</title>
10
11
  </head>
11
12
  <body>
package/login.html CHANGED
@@ -1,5 +1,5 @@
1
1
  <!doctype html>
2
2
  <html lang="zh-CN">
3
- <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>登录 · Device Center</title><link rel="stylesheet" href="/src/login.css"></head>
4
- <body><main><div class="mark">DC</div><h1>Device Center</h1><p>登录你的设备控制面</p><p id="loading" role="status">正在读取登录方式…</p><form id="login" hidden><label>用户名<input name="username" autocomplete="username" required maxlength="64"></label><label>密码<input name="password" type="password" autocomplete="current-password" required maxlength="256"></label><button type="submit">登录</button><p id="message" role="alert"></p></form><section id="unified" hidden><p>使用 App Services/个人站的统一账号。</p><a id="unified-login" class="login-link" target="_blank" rel="noopener noreferrer">使用统一账号登录 ↗</a><p class="help">在新页面用邮箱或 Google 完成登录,回到这里会自动进入设备面板。</p><button id="continue-login" type="button">我已登录,继续</button><p id="unified-message" role="status"></p></section><p class="install-help">还未安装?<a href="/start" target="_blank" rel="noopener noreferrer">查看一页试用指南 ↗</a><br><span>本机使用无需账号;官方托管需登录并核对绑定。</span></p><small id="access-notes">账号登录 · HTTPS 保护</small></main><script type="module" src="/src/login.js"></script></body>
3
+ <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title data-i18n="login.title">登录 · Device Center</title><link rel="stylesheet" href="/src/login.css"><link rel="stylesheet" href="/src/i18n.css"></head>
4
+ <body class="login-page"><main><div class="language-picker" data-language-picker></div><div class="mark">DC</div><h1>Device Center</h1><p data-i18n="login.subtitle">登录你的设备控制面</p><p id="loading" role="status" data-i18n="login.loading">正在读取登录方式…</p><form id="login" hidden><label><span data-i18n="login.username">用户名</span><input name="username" autocomplete="username" required maxlength="64"></label><label><span data-i18n="login.password">密码</span><input name="password" type="password" autocomplete="current-password" required maxlength="256"></label><button type="submit" data-i18n="login.submit">登录</button><p id="message" role="alert"></p></form><section id="unified" hidden><p data-i18n="login.unified">使用 App Services/个人站的统一账号。</p><a id="unified-login" class="login-link" target="_blank" rel="noopener noreferrer" data-i18n="login.link">使用统一账号登录 ↗</a><p class="help" data-i18n="login.help">在新页面用邮箱或 Google 完成登录,回到这里会自动进入设备面板。</p><button id="continue-login" type="button" data-i18n="login.continue">我已登录,继续</button><p id="unified-message" role="status"></p></section><p class="install-help"><span data-i18n="login.notInstalled">还未安装?</span><a href="/start" target="_blank" rel="noopener noreferrer" data-language-link data-i18n="login.guide">查看一页试用指南 ↗</a><br><span data-i18n="login.installHint">本机使用无需账号;官方托管需登录并核对绑定。</span></p><small id="access-notes" data-i18n="login.multi">账号设备隔离 · HTTPS 保护</small></main><script type="module" src="/src/login.js"></script></body>
5
5
  </html>
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "device-center",
3
- "version": "0.2.0-beta.2",
3
+ "version": "0.2.0-beta.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "device-center",
9
- "version": "0.2.0-beta.2",
9
+ "version": "0.2.0-beta.3",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@peculiar/x509": "2.1.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "device-center",
3
- "version": "0.2.0-beta.2",
3
+ "version": "0.2.0-beta.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Local device connector with account-bound pairing, TLS encrypted relay and explicitly authorized log access",
@@ -37,7 +37,8 @@
37
37
  "docs/remote-access.md",
38
38
  "npm-shrinkwrap.json",
39
39
  "start.html",
40
- "docs/friend-trial.md"
40
+ "docs/friend-trial.md",
41
+ "docs/i18n.md"
41
42
  ],
42
43
  "publishConfig": {
43
44
  "access": "public",
package/server/index.mjs CHANGED
@@ -82,7 +82,7 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
82
82
  const url = new URL(request.url ?? '/', origin);
83
83
  const path = url.pathname;
84
84
  // Public static installation reference only. No identity, configuration or owner API.
85
- if (['/start', '/start.html', '/src/start.js', '/src/start.css', '/src/onboarding.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
85
+ if (['/start', '/start.html', '/src/start.js', '/src/start.css', '/src/onboarding.js', '/src/i18n.js', '/src/i18n.css', '/src/messages.js', '/src/install-prompt.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
86
86
  if (Object.hasOwn(PUBLIC_GUIDES, path) && request.method === 'GET') {
87
87
  const body = await readFile(resolve(PROJECT_ROOT, PUBLIC_GUIDES[path]));
88
88
  response.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'content-security-policy': "default-src 'none'; frame-ancestors 'none'", 'referrer-policy': 'no-referrer' });
@@ -115,7 +115,7 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
115
115
  }
116
116
  const principal = await cloudAccess.resolvePrincipal(request);
117
117
  if (!principal) {
118
- if (path === '/' && request.method === 'GET') { response.writeHead(303, { location: '/login', 'cache-control': 'no-store' }); return response.end(); }
118
+ if (path === '/' && request.method === 'GET') { response.writeHead(303, { location: `/login${/^(zh-CN|zh-TW|en|ja|ko|es|fr|de|pt-BR)$/.test(url.searchParams.get('lang') ?? '') ? '?lang=' + url.searchParams.get('lang') : ''}`, 'cache-control': 'no-store' }); return response.end(); }
119
119
  return sendJson(response, 401, { error: 'authentication_required' });
120
120
  }
121
121
  accountRegistry = registry.forTenant(principal.tenantId);
package/src/api.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { getLocale } from './i18n.js';
1
2
  async function request(path, { timeoutMs = 12000, ...options } = {}) {
2
3
  const response = await fetch(path, { ...options, cache: 'no-store', signal: AbortSignal.timeout(timeoutMs) });
3
4
  const payload = await response.json().catch(() => ({}));
4
- if (response.status === 401) { location.replace('/login'); throw new Error('authentication_required'); }
5
+ if (response.status === 401) { location.replace('/login?lang=' + getLocale()); throw new Error('authentication_required'); }
5
6
  if (!response.ok) throw new Error(payload.error ?? `http_${response.status}`);
6
7
  return payload;
7
8
  }
@@ -1,3 +1,4 @@
1
+ import { t } from './i18n.js';
1
2
  // Expire displayed SSH snapshots without a network request or a background SSH check.
2
3
  export function expireSshSnapshots(overview, now = Date.now()) {
3
4
  const expired = device => device.state === 'online' && (device.transport === 'ssh' && now - device.lastHeartbeat > 300_000 || device.source === 'driver-report' && (now - device.managementDriver.lastSeen > 90_000 || now - device.lastHeartbeat > (device.kind === 'management' ? 90_000 : 300_000)));
@@ -8,21 +9,21 @@ export function expireSshSnapshots(overview, now = Date.now()) {
8
9
  return { ...overview, devices, stats };
9
10
  }
10
11
 
11
- export const DEVICE_TYPE_LABELS = { unspecified: '未分类', personal: '个人电脑', server: '服务器', workstation: '工作站', other: '其他' };
12
+ export const DEVICE_TYPE_LABELS = { get unspecified() { return t("panel.498"); }, get personal() { return t("panel.499"); }, get server() { return t("panel.500"); }, get workstation() { return t("panel.501"); }, get other() { return t("panel.502"); } };
12
13
  export function deviceStatus(device, now = Date.now()) {
13
- if (device.state === 'unknown') return { label: '未知', title: '尚未取得资源数据;未把设备视为已连接。' };
14
- if (device.source === 'driver-report' && (device.managementDriver.lastSeen == null || now - device.managementDriver.lastSeen > 90_000)) return { label: '未回报', title: '超过 90 秒未收到 Driver 回报。请确认设备已唤醒、Driver 正在运行且能连接云端。旧数据保留,不表示密钥过期。' };
15
- if (device.state === 'expired') return { label: '待采集', title: '上次资源快照较早,点击采集一次取得新数据;不表示设备断线或密钥过期。' };
16
- return { label: '在线', title: device.transport === 'ssh' ? '最近一次 SSH 检查成功,不表示持续连接。' : '最近收到真实资源数据。' };
14
+ if (device.state === 'unknown') return { label: t("panel.457"), title: t("panel.503") };
15
+ if (device.source === 'driver-report' && (device.managementDriver.lastSeen == null || now - device.managementDriver.lastSeen > 90_000)) return { label: t("panel.504"), title: t("panel.505") };
16
+ if (device.state === 'expired') return { label: t("panel.506"), title: t("panel.507") };
17
+ return { label: t("panel.456"), title: device.transport === 'ssh' ? t("panel.508") : t("panel.509") };
17
18
  }
18
19
  export function collectionAvailability(device, now = Date.now()) {
19
20
  if (device.source === 'driver-report') {
20
- if (device.collection?.status === 'pending' && device.collection.expiresAt > now) return { available: false, pending: true, hint: '等待设备采集…' };
21
- if (device.kind !== 'management') return { available: false, hint: '请在管理电脑检查 SSH' };
22
- if (device.managementDriver.lastSeen == null || now - device.managementDriver.lastSeen > 90_000) return { available: false, hint: '请启动设备上的 Driver' };
23
- if (!device.collection?.supported) return { available: false, hint: device.collection?.reason === 'driver_control_unavailable' ? '采集通道暂不可用' : '需更新设备 Driver' };
24
- return { available: true, hint: '通知 Driver 重新采集内存与磁盘;不执行远程命令。' };
21
+ if (device.collection?.status === 'pending' && device.collection.expiresAt > now) return { available: false, pending: true, hint: t("panel.41") };
22
+ if (device.kind !== 'management') return { available: false, hint: t("panel.510") };
23
+ if (device.managementDriver.lastSeen == null || now - device.managementDriver.lastSeen > 90_000) return { available: false, hint: t("panel.511") };
24
+ if (!device.collection?.supported) return { available: false, hint: device.collection?.reason === 'driver_control_unavailable' ? t("panel.512") : t("panel.513") };
25
+ return { available: true, hint: t("panel.514") };
25
26
  }
26
- if (device.transport === 'ssh') return { available: Boolean(device.ssh?.checkAvailable && !device.ssh.checking && !device.ssh.queued), hint: '使用当前 SSH 配置进行一次只读检查并采集。' };
27
- return { available: device.source === 'local-runtime' && device.scope === 'host', hint: '从当前本机进程重新读取内存与磁盘。' };
27
+ if (device.transport === 'ssh') return { available: Boolean(device.ssh?.checkAvailable && !device.ssh.checking && !device.ssh.queued), hint: t("panel.515") };
28
+ return { available: device.source === 'local-runtime' && device.scope === 'host', hint: t("panel.516") };
28
29
  }
package/src/i18n.css ADDED
@@ -0,0 +1,61 @@
1
+ .header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 20px; flex-wrap: wrap; }
2
+ .language-picker { display: inline-flex; flex: 0 0 auto; min-width: 0; color: #344d3e; font-size: 12px; }
3
+ .language-picker button { font-family: inherit; cursor: pointer; filter: none; }
4
+ .language-picker .language-trigger { display: inline-flex; align-items: center; justify-content: center; gap: 7px; width: auto; height: 34px; min-height: 34px; margin: 0; padding: 0 9px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: inherit; font-size: 12px; font-weight: 500; line-height: 1; white-space: nowrap; transition: background .15s, border-color .15s; }
5
+ .language-trigger > svg { width: 16px; height: 16px; flex-shrink: 0; color: #758375; }
6
+ .language-trigger > .language-chevron { width: 12px; height: 12px; transition: transform .15s; }
7
+ .language-trigger[aria-expanded="true"] > .language-chevron { transform: rotate(180deg); }
8
+ .language-picker .language-trigger:hover, .language-trigger[aria-expanded="true"] { background: #eaf0e6; border-color: #dde6d8; }
9
+ .language-picker button:focus-visible { outline: 2px solid #5c965d; outline-offset: 2px; }
10
+ .language-menu { position: fixed; z-index: 40; width: 304px; max-width: calc(100vw - 24px); max-height: calc(100dvh - 32px); overflow: auto; padding: 8px; border: 1px solid #e0e7dc; border-radius: 13px; background: #fff; color: #344d3e; box-shadow: 0 12px 36px #2035241a, 0 2px 6px #20352409; }
11
+ .language-menu[hidden] { display: none; }
12
+ .language-menu-title { padding: 7px 9px 10px; color: #84907f; font-size: 11px; font-weight: 500; line-height: 1.3; }
13
+ .language-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 3px; }
14
+ .language-picker .language-option { display: flex; align-items: center; justify-content: space-between; gap: 5px; width: 100%; height: auto; min-height: 40px; padding: 8px 9px; margin: 0; border: 0; border-radius: 7px; background: transparent; color: #475449; text-align: left; font-size: 12px; font-weight: 400; line-height: 1.4; }
15
+ .language-option > span { min-width: 0; }
16
+ .language-option > svg { width: 14px; height: 14px; flex-shrink: 0; opacity: 0; color: #356c4c; }
17
+ .language-picker .language-option:hover, .language-picker .language-option:focus-visible { background: #f2f5ef; }
18
+ .language-picker .language-option[aria-checked="true"] { background: #eaf2e5; color: #2f6545; font-weight: 600; }
19
+ .language-option[aria-checked="true"] > svg { opacity: 1; }
20
+ .topbar { position: relative; z-index: 4; }
21
+ .login-page main { position: relative; }
22
+ .login-page main > .language-picker { position: absolute; top: 36px; right: 24px; }
23
+ @media (max-width: 560px) { .header-actions { gap: 8px; } }
24
+ @media (prefers-reduced-motion: reduce) { .language-trigger, .language-chevron { transition: none !important; } }
25
+ /* Longer translations must wrap without moving the sidebar or top bar. */
26
+ .modal-heading { gap: 12px; flex-wrap: wrap; }
27
+ .modal-heading h2 { flex: 1 1 140px; min-width: 0; }
28
+ .modal-heading .language-picker { margin-left: auto; }
29
+ .nav-item svg { flex-shrink: 0; }
30
+ .nav-item > span { min-width: 0; overflow-wrap: anywhere; }
31
+ .resource-top { flex-wrap: wrap; }
32
+ .resource-top > span { overflow-wrap: anywhere; white-space: normal; }
33
+ @media (max-width: 760px) {
34
+ .topbar .environment-badge { display: none; }
35
+ .topbar .header-actions { flex-wrap: nowrap; gap: 0; }
36
+ .topbar > span { min-width: 0; }
37
+ .page-heading { flex-wrap: wrap; align-items: flex-start; gap: 8px; }
38
+ .page-heading > div:first-child { width: 100%; min-width: 0; }
39
+ .page-heading h1 { overflow-wrap: anywhere; line-height: 1.25; }
40
+ .heading-actions { max-width: none; width: 100%; justify-content: flex-start; }
41
+ .heading-actions > button { white-space: normal; max-width: 100%; }
42
+ .driver-summary { flex-wrap: wrap; }
43
+ .driver-summary p { flex-basis: 100%; margin-left: 12px; }
44
+ .initialize-entry { margin-left: auto; }
45
+ .filter-tabs { flex-wrap: wrap; }
46
+ .page-footer { gap: 8px; flex-wrap: wrap; }
47
+ .method-tabs { flex-wrap: wrap; }
48
+ .method-tabs button { white-space: normal; min-width: 0; }
49
+ .start-page header { flex-wrap: wrap; }
50
+ }
51
+
52
+ @media (max-width: 440px) {
53
+ .nav-item svg { display: none; }
54
+ .nav-item > span { overflow-wrap: normal; hyphens: auto; }
55
+ }
56
+ @media (max-width: 560px) {
57
+ .modal-heading { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
58
+ .modal-heading h2 { grid-column: 1; grid-row: 1; }
59
+ .modal-heading .icon-button { grid-column: 2; grid-row: 1; }
60
+ .modal-heading .language-picker { grid-column: 1 / -1; grid-row: 2; margin-left: 0; }
61
+ }
package/src/i18n.js ADDED
@@ -0,0 +1,174 @@
1
+ import { messages } from './messages.js';
2
+
3
+ export const LANGUAGES = Object.freeze([
4
+ { code: 'zh-CN', name: '简体中文' }, { code: 'zh-TW', name: '繁體中文' },
5
+ { code: 'en', name: 'English' }, { code: 'ja', name: '日本語' },
6
+ { code: 'ko', name: '한국어' }, { code: 'es', name: 'Español' },
7
+ { code: 'fr', name: 'Français' }, { code: 'de', name: 'Deutsch' },
8
+ { code: 'pt-BR', name: 'Português (Brasil)' },
9
+ ]);
10
+ const STORAGE_KEY = 'device-center.locale';
11
+ export function normalizeLocale(value) {
12
+ if (typeof value !== 'string') return null;
13
+ const tag = value.replaceAll('_', '-').toLowerCase();
14
+ if (/^zh(?:-|$)/.test(tag)) return /(?:hant|tw|hk|mo)(?:-|$)/.test(tag) ? 'zh-TW' : 'zh-CN';
15
+ if (/^pt(?:-|$)/.test(tag)) return 'pt-BR';
16
+ return LANGUAGES.find(item => item.code === tag.split('-')[0])?.code ?? null;
17
+ }
18
+ export function resolveLocale({ query, saved, languages = [] } = {}) {
19
+ return normalizeLocale(query) ?? normalizeLocale(saved) ?? languages.map(normalizeLocale).find(Boolean) ?? 'en';
20
+ }
21
+ export function translate(locale, key, values = {}) {
22
+ const code = normalizeLocale(locale) ?? 'en';
23
+ const entry = lang => Object.hasOwn(messages[lang], key) ? messages[lang][key] : undefined;
24
+ const text = entry(code) ?? entry('en') ?? entry('zh-CN') ?? key;
25
+ return text.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, name) => Object.hasOwn(values, name) ? String(values[name]) : match);
26
+ }
27
+ let locale = 'zh-CN';
28
+ export const getLocale = () => locale;
29
+ export const t = (key, values) => translate(locale, key, values);
30
+ export const h = (key, values) => t(key, values).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
31
+ let pickerSequence = 0;
32
+ const globe = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a17 17 0 0 1 0 18 17 17 0 0 1 0-18Z"/></svg>';
33
+ const chevron = '<svg class="language-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg>';
34
+ function pickerContents() {
35
+ const id = `language-menu-${++pickerSequence}`;
36
+ const current = LANGUAGES.find(item => item.code === locale);
37
+ return `<button type="button" class="language-trigger" data-language-trigger aria-label="${h('language')}: ${current.name}" aria-haspopup="menu" aria-expanded="false" aria-controls="${id}">${globe}<span>${current.code === 'pt-BR' ? 'Português' : current.name}</span>${chevron}</button>
38
+ <div class="language-menu" id="${id}" data-language-menu role="menu" aria-label="${h('language')}" hidden><div class="language-menu-title" aria-hidden="true">${h('language')}</div><div class="language-options" role="none">${LANGUAGES.map(item => `<button type="button" class="language-option" data-language-option="${item.code}" role="menuitemradio" aria-checked="${locale === item.code}" tabindex="-1" lang="${item.code}"><span>${item.name}</span><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="m3 8 3 3 7-7"/></svg></button>`).join('')}</div></div>`;
39
+ }
40
+ export function languagePickerMarkup() {
41
+ return `<div class="language-picker" data-language-picker>${pickerContents()}</div>`;
42
+ }
43
+ export function applyTranslations(root = document) {
44
+ for (const element of root.querySelectorAll('[data-i18n]')) element.textContent = t(element.dataset.i18n);
45
+ for (const attribute of ['placeholder', 'title', 'aria-label', 'content']) {
46
+ for (const element of root.querySelectorAll(`[data-i18n-${attribute}]`)) element.setAttribute(attribute, t(element.getAttribute(`data-i18n-${attribute}`)));
47
+ }
48
+ }
49
+ // Only explicit UI markers are translated. User names, tags, code and paths are untouched.
50
+ export function initLanguage({ onChange = () => {} } = {}) {
51
+ let saved;
52
+ try { saved = localStorage.getItem(STORAGE_KEY); } catch { /* Private mode can deny storage. */ }
53
+ const params = new URLSearchParams(location.search);
54
+ locale = resolveLocale({ query: params.get('lang'), saved, languages: navigator.languages ?? [navigator.language] });
55
+ if (normalizeLocale(params.get('lang'))) {
56
+ try { localStorage.setItem(STORAGE_KEY, locale); } catch { /* Share links also work without storage. */ }
57
+ }
58
+ const apply = () => {
59
+ document.documentElement.lang = locale;
60
+ document.documentElement.dir = 'ltr';
61
+ applyTranslations();
62
+ for (const picker of document.querySelectorAll('[data-language-picker]')) picker.innerHTML = pickerContents();
63
+ // Language links stay on this origin and never include account/pairing information.
64
+ for (const link of document.querySelectorAll('a[data-language-link]')) {
65
+ const url = new URL(link.getAttribute('href'), location.origin);
66
+ if (url.origin === location.origin) { url.searchParams.set('lang', locale); link.setAttribute('href', `${url.pathname}${url.search}${url.hash}`); }
67
+ }
68
+ onChange(locale);
69
+ };
70
+ const closeMenus = (restoreFocus = false) => {
71
+ for (const picker of document.querySelectorAll('[data-language-picker]')) {
72
+ const trigger = picker.querySelector('[data-language-trigger]');
73
+ const menu = picker.querySelector('[data-language-menu]');
74
+ if (!menu || menu.hidden) continue;
75
+ menu.hidden = true; trigger.setAttribute('aria-expanded', 'false');
76
+ if (restoreFocus) trigger.focus({ preventScroll: true });
77
+ }
78
+ };
79
+ const openMenu = (picker, last = false) => {
80
+ closeMenus();
81
+ const trigger = picker.querySelector('[data-language-trigger]');
82
+ const menu = picker.querySelector('[data-language-menu]');
83
+ menu.hidden = false; trigger.setAttribute('aria-expanded', 'true');
84
+ const anchor = trigger.getBoundingClientRect();
85
+ const size = menu.getBoundingClientRect();
86
+ menu.style.left = `${Math.max(12, Math.min(anchor.right - size.width, innerWidth - size.width - 12))}px`;
87
+ const top = anchor.bottom + 8;
88
+ menu.style.top = `${Math.max(12, top + size.height <= innerHeight - 12 ? top : anchor.top - size.height - 8)}px`;
89
+ const options = [...menu.querySelectorAll('[data-language-option]')];
90
+ (last ? options.at(-1) : menu.querySelector('[aria-checked="true"]') ?? options[0])?.focus({ preventScroll: true });
91
+ };
92
+ const choose = (value, picker) => {
93
+ const next = normalizeLocale(value); if (!next) return;
94
+ const inDialog = Boolean(picker.closest('[role="dialog"]'));
95
+ closeMenus(true);
96
+ locale = next;
97
+ try { localStorage.setItem(STORAGE_KEY, locale); } catch { /* Selection still works in memory. */ }
98
+ const url = new URL(location.href); url.searchParams.set('lang', locale);
99
+ try { history.replaceState(null, '', url); } catch { /* Do not prevent language selection. */ }
100
+ apply();
101
+ const container = picker.isConnected ? picker : document.querySelector(inDialog ? '[role="dialog"]' : '.topbar') ?? document;
102
+ container.querySelector('[data-language-trigger]')?.focus({ preventScroll: true });
103
+ };
104
+ // Capture events before the page's dialog handlers; Escape closes this menu first.
105
+ document.addEventListener('click', event => {
106
+ const trigger = event.target.closest('[data-language-trigger]');
107
+ const option = event.target.closest('[data-language-option]');
108
+ if (trigger) {
109
+ event.stopPropagation();
110
+ if (trigger.getAttribute('aria-expanded') === 'true') closeMenus(true);
111
+ else openMenu(trigger.closest('[data-language-picker]'));
112
+ } else if (option) {
113
+ event.stopPropagation(); choose(option.dataset.languageOption, option.closest('[data-language-picker]'));
114
+ } else if (!event.target.closest('[data-language-menu]')) closeMenus();
115
+ }, true);
116
+ document.addEventListener('keydown', event => {
117
+ const picker = event.target.closest('[data-language-picker]');
118
+ if (!picker) return;
119
+ const menu = picker.querySelector('[data-language-menu]');
120
+ if (menu.hidden) {
121
+ if (event.target.matches('[data-language-trigger]') && ['ArrowDown', 'ArrowUp'].includes(event.key)) {
122
+ event.preventDefault(); event.stopPropagation(); openMenu(picker, event.key === 'ArrowUp');
123
+ }
124
+ return;
125
+ }
126
+ if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeMenus(true); return; }
127
+ const options = [...menu.querySelectorAll('[data-language-option]')];
128
+ const index = options.indexOf(document.activeElement);
129
+ let next;
130
+ if (event.key === 'Home') next = 0;
131
+ else if (event.key === 'End') next = options.length - 1;
132
+ else if (event.key === 'ArrowDown') next = (index + 2) % options.length;
133
+ else if (event.key === 'ArrowUp') next = (index - 2 + options.length) % options.length;
134
+ else if (event.key === 'ArrowRight') next = (index + 1) % options.length;
135
+ else if (event.key === 'ArrowLeft') next = (index - 1 + options.length) % options.length;
136
+ if (next !== undefined) { event.preventDefault(); event.stopPropagation(); options[next].focus(); }
137
+ if (event.key === 'Tab') closeMenus(true);
138
+ }, true);
139
+ document.addEventListener('focusout', event => {
140
+ const picker = event.target.closest('[data-language-picker]');
141
+ if (picker?.isConnected && !picker.contains(event.relatedTarget)) closeMenus();
142
+ });
143
+ document.addEventListener('scroll', event => { if (!event.target.closest?.('[data-language-menu]')) closeMenus(); }, true);
144
+ window.addEventListener('resize', () => closeMenus());
145
+ apply();
146
+ return locale;
147
+ }
148
+
149
+ // Compatibility for server-owned diagnostics only; never pass device names or user input.
150
+ const REPORT_MESSAGE_KEYS = {
151
+ "容器不能代表宿主内存": "metric.containerMemory",
152
+ "容器不能代表宿主磁盘": "metric.containerDisk",
153
+ "无法读取系统内存": "metric.memoryFailure",
154
+ "无法读取用户目录所在卷": "metric.diskFailure",
155
+ "尚无有效 SSH 采集": "metric.sshPending",
156
+ "SSH 内存采集失败": "metric.sshMemory",
157
+ "SSH 磁盘采集失败": "metric.sshDisk",
158
+ "容器不读取宿主 SSH 配置。请在宿主运行 Driver。": "notice.container",
159
+ "本机尚无可读取的 SSH 配置。": "notice.missing",
160
+ "Include 路径过深,已跳过。": "notice.deep",
161
+ "Include 目录读取达到上限,候选可能不完整。": "notice.directoryLimit",
162
+ "已跳过目录外的 Include。": "notice.outside",
163
+ "部分 Include 无法读取。": "notice.unreadable",
164
+ "配置读取达到上限,部分候选未列出。": "notice.limit",
165
+ "已跳过受限目录外或非 config / .conf 的 Include。": "notice.restricted",
166
+ "已跳过循环 Include。": "notice.loop",
167
+ "配置文件过大或不是普通文件,已跳过。": "notice.fileSize",
168
+ "配置文件过大,已跳过。": "notice.large",
169
+ "文件不是 SSH 配置,已忽略。": "notice.notConfig",
170
+ "候选数达到上限。": "notice.candidateLimit",
171
+ "含 Match 条件:仅登记别名,未解析条件或执行命令。": "notice.match",
172
+ "已跳过 Host / Match 内的条件 Include,候选可能不完整。": "notice.conditional"
173
+ };
174
+ export const reportMessage = value => Object.hasOwn(REPORT_MESSAGE_KEYS, value) ? t(REPORT_MESSAGE_KEYS[value]) : value;
@@ -0,0 +1,19 @@
1
+ import { translate } from './i18n.js';
2
+
3
+ export function localizedInstallPrompt(plan, locale) {
4
+ const c = plan.commands;
5
+ const tr = (key, values) => translate(locale, key, values);
6
+ return [
7
+ tr('prompt.intro'),
8
+ tr('prompt.choice', { mode: tr(plan.mode === 'local' ? 'guide.local' : plan.mode === 'official' ? 'guide.hosted' : 'guide.custom'), version: plan.version, run: tr(plan.background ? 'prompt.background' : 'prompt.foreground') }),
9
+ plan.controlOrigin ? tr('prompt.private', { origin: plan.controlOrigin }) : '',
10
+ `1. ${tr('prompt.environment')}`,
11
+ `2. ${tr('prompt.existing', { url: plan.url, status: c.bindingStatus, service: c.serviceStatus })}`,
12
+ `3. ${tr('prompt.userInstall')}\n ${c.install}\n ${c.version}\n ${tr('prompt.config')}`,
13
+ `4. ${plan.background ? `${tr('prompt.bg')}\n ${c.servicePlan}\n ${c.serviceInstall}\n ${c.serviceStatus}` : `${tr('prompt.fg')}${plan.controlOrigin ? '' : `\n ${c.foreground}`}`}`,
14
+ `5. ${plan.controlOrigin ? `${tr('prompt.pairBefore')}\n ${c.pairingPlan}\n ${c.connect}\n ${tr('prompt.pairAfter', { origin: plan.controlOrigin })}` : tr('prompt.local')}`,
15
+ `6. ${tr('prompt.mcp', { url: plan.url, mcp: plan.mcpUrl })}`,
16
+ `7. ${tr('prompt.verify')}\n ${tr('prompt.uninstall')}\n ${c.uninstallService}`,
17
+ tr('prompt.boundaries'), tr('prompt.end'),
18
+ ].filter(Boolean).join('\n\n');
19
+ }