feat: add multilingual support with language toggle and localization

- Implemented language loading and switching functionality, allowing users to toggle between Chinese (zh-CN) and English (en).
- Introduced a new LocalizedMessage interface and updated existing messages to support bilingual translations.
- Refactored event labels and server messages to use a translation array for better localization management.
- Added CSS styles for language toggle button and icons.
- Created a new language-bootstrap module to handle initial language setup based on local storage or browser settings.
This commit is contained in:
cmliu committed 2026-07-27 20:34:21 +08:00
1 parent 62ca474f7b
commit bb1c045dd5
5 files changed
+487 -121

No files matched your search

+1
View File
@@ -8,3 +8,4 @@ tests-edge/
.dev.vars
.env*
!.env.example
.workbuddy/
+187 -70
View File
@@ -2,58 +2,111 @@
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="UTF-8" />
<script type="module" src="/src/language-bootstrap.ts"></script>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#080b10" />
<meta name="description" content="安全的 Cloudflare Workers 原生 WebSSH 终端 / A secure, native Cloudflare Workers WebSSH terminal" />
<meta
name="description"
content="安全的 Cloudflare Workers 原生 WebSSH 终端"
data-i18n-content-zh="安全的 Cloudflare Workers 原生 WebSSH 终端"
data-i18n-content-en="A secure, native Cloudflare Workers WebSSH terminal"
/>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>Workers WebSSH</title>
</head>
<body>
<div id="app">
<div id="app" hidden>
<header class="topbar">
<div class="brand-group">
<button id="panel-toggle" class="icon-button mobile-only" type="button" aria-label="打开连接面板 / Open connection panel" aria-controls="connection-panel" aria-expanded="false">
<button
id="panel-toggle"
class="icon-button mobile-only"
type="button"
aria-label="打开连接面板"
data-i18n-aria-label-zh="打开连接面板"
data-i18n-aria-label-en="Open connection panel"
aria-controls="connection-panel"
aria-expanded="false"
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16" /></svg>
</button>
<a class="brand" href="/" aria-label="Workers WebSSH 首页 / Workers WebSSH home">
<a
class="brand"
href="/"
aria-label="Workers WebSSH 首页"
data-i18n-aria-label-zh="Workers WebSSH 首页"
data-i18n-aria-label-en="Workers WebSSH home"
>
<span class="brand-mark" aria-hidden="true">&gt;_</span>
<span><strong>Workers</strong> WebSSH</span>
</a>
<span class="runtime-badge">边缘原生 / EDGE NATIVE</span>
<span class="runtime-badge" data-i18n-zh="边缘原生" data-i18n-en="EDGE NATIVE">边缘原生</span>
</div>
<div class="topbar-actions">
<div id="global-status" class="status-pill" data-state="idle" role="status" aria-live="polite">
<span class="status-dot"></span>
<span id="global-status-label">离线 / Offline</span>
<span id="global-status-label">离线</span>
</div>
<button id="theme-toggle" class="icon-button" type="button" aria-label="切换颜色主题 / Toggle color theme">
<button id="language-toggle" class="icon-button" type="button" aria-label="切换到英文" title="切换到英文" data-language="zh-CN">
<span class="language-icon" aria-hidden="true"><span>中</span><span>E</span></span>
</button>
<button
id="theme-toggle"
class="icon-button"
type="button"
aria-label="切换颜色主题"
data-i18n-aria-label-zh="切换颜色主题"
data-i18n-aria-label-en="Toggle color theme"
>
<svg class="sun-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.65 17.65l1.42 1.42M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.65 6.35l1.42-1.42" /></svg>
</button>
<a class="icon-button" href="https://github.com/cmliu/CF-Workers-WebSSH" target="_blank" rel="noreferrer" aria-label="打开源代码仓库 / Open source repository">
<a
class="icon-button"
href="https://github.com/cmliu/CF-Workers-WebSSH"
target="_blank"
rel="noreferrer"
aria-label="打开源代码仓库"
data-i18n-aria-label-zh="打开源代码仓库"
data-i18n-aria-label-en="Open source repository"
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.87c-2.78.6-3.37-1.18-3.37-1.18-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.35 1.09 2.92.83.09-.65.35-1.09.64-1.34-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.64 0 0 .84-.27 2.75 1.02A9.56 9.56 0 0 1 12 6.82c.85 0 1.71.11 2.51.34 1.91-1.29 2.75-1.02 2.75-1.02.55 1.37.2 2.39.1 2.64.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.68-4.57 4.93.36.31.68.92.68 1.86v2.76c0 .27.18.58.69.48A10 10 0 0 0 12 2Z" /></svg>
</a>
</div>
</header>
<div class="app-shell">
<button id="panel-scrim" class="panel-scrim" type="button" aria-label="关闭连接面板 / Close connection panel" hidden></button>
<aside id="connection-panel" class="connection-panel" aria-label="连接管理 / Connection management">
<button
id="panel-scrim"
class="panel-scrim"
type="button"
aria-label="关闭连接面板"
data-i18n-aria-label-zh="关闭连接面板"
data-i18n-aria-label-en="Close connection panel"
hidden
></button>
<aside
id="connection-panel"
class="connection-panel"
aria-label="连接管理"
data-i18n-aria-label-zh="连接管理"
data-i18n-aria-label-en="Connection management"
>
<div class="panel-heading">
<div>
<p class="eyebrow">连接管理 / CONNECTIONS</p>
<h1>SSH 工作台 <small>SSH workspace</small></h1>
<p class="eyebrow" data-i18n-zh="连接管理" data-i18n-en="CONNECTIONS">连接管理</p>
<h1 data-i18n-zh="SSH 工作台" data-i18n-en="SSH workspace">SSH 工作台</h1>
</div>
<button id="new-profile" class="small-button" type="button">新建 / New</button>
<button id="new-profile" class="small-button" type="button" data-i18n-zh="新建" data-i18n-en="New">新建</button>
</div>
<section class="profiles-section" aria-labelledby="profiles-title">
<div class="section-title-row">
<h2 id="profiles-title">已保存目标 / Saved targets</h2>
<h2 id="profiles-title" data-i18n-zh="已保存目标" data-i18n-en="Saved targets">已保存目标</h2>
<span id="profile-count" class="count-badge">0</span>
</div>
<div id="profile-list" class="profile-list">
<p class="empty-list">暂无已保存目标 / No saved targets yet.</p>
<p class="empty-list" data-i18n-zh="暂无已保存目标" data-i18n-en="No saved targets yet.">暂无已保存目标</p>
</div>
</section>
@@ -61,67 +114,87 @@
<input id="profile-id" type="hidden" />
<div class="field-grid single">
<label class="field">
<span>连接名称 <small>Profile name</small></span>
<input id="profile-name" name="profileName" type="text" maxlength="64" placeholder="生产环境 / Production" />
<span data-i18n-zh="连接名称" data-i18n-en="Profile name">连接名称</span>
<input
id="profile-name"
name="profileName"
type="text"
maxlength="253"
placeholder="生产环境"
data-i18n-placeholder-zh="生产环境"
data-i18n-placeholder-en="Production"
/>
</label>
</div>
<div class="field-grid host-grid">
<label class="field">
<span>主机 <small>Host</small></span>
<span data-i18n-zh="主机" data-i18n-en="Host">主机</span>
<input id="host" name="host" type="text" maxlength="253" placeholder="ssh.example.com" required spellcheck="false" />
</label>
<label class="field port-field">
<span>端口 <small>Port</small></span>
<span data-i18n-zh="端口" data-i18n-en="Port">端口</span>
<input id="port" name="port" type="number" value="22" min="1" max="65535" required inputmode="numeric" />
</label>
</div>
<div class="field-grid single">
<label class="field">
<span>用户名 <small>Username</small></span>
<input id="username" name="username" type="text" maxlength="128" placeholder="root" required autocomplete="username" spellcheck="false" />
<span data-i18n-zh="用户名" data-i18n-en="Username">用户名</span>
<input id="username" name="username" type="text" value="root" maxlength="128" required autocomplete="username" spellcheck="false" />
</label>
</div>
<fieldset class="auth-fieldset">
<legend>身份认证 / Authentication</legend>
<div class="segmented-control" role="radiogroup" aria-label="身份认证方式 / Authentication method">
<label><input type="radio" name="authMethod" value="password" checked /><span>密码 / Password</span></label>
<label><input type="radio" name="authMethod" value="publickey" /><span>SSH 密钥 / SSH key</span></label>
<legend data-i18n-zh="身份认证" data-i18n-en="Authentication">身份认证</legend>
<div
class="segmented-control"
role="radiogroup"
aria-label="身份认证方式"
data-i18n-aria-label-zh="身份认证方式"
data-i18n-aria-label-en="Authentication method"
>
<label><input type="radio" name="authMethod" value="password" checked /><span data-i18n-zh="密码" data-i18n-en="Password">密码</span></label>
<label><input type="radio" name="authMethod" value="publickey" /><span data-i18n-zh="SSH 密钥" data-i18n-en="SSH key">SSH 密钥</span></label>
</div>
<div id="password-field" class="field secret-field">
<label for="password">密码 <small>Password · 永不存储 / never stored</small></label>
<label for="password">
<span data-i18n-zh="密码" data-i18n-en="Password">密码</span>
<small data-i18n-zh="永不存储" data-i18n-en="never stored">永不存储</small>
</label>
<span class="input-action-wrap">
<input id="password" name="password" type="password" maxlength="4096" autocomplete="current-password" />
<button id="reveal-password" class="input-action" type="button" aria-label="显示密码 / Show password">显示 / Show</button>
<button id="reveal-password" class="input-action" type="button" aria-label="显示密码">显示</button>
</span>
</div>
<div id="key-field" class="key-field" hidden>
<label class="field">
<span>私钥 <small>Private key · 未加密 OpenSSH / unencrypted OpenSSH</small></span>
<span>
<span data-i18n-zh="私钥" data-i18n-en="Private key">私钥</span>
<small data-i18n-zh="未加密 OpenSSH" data-i18n-en="unencrypted OpenSSH">未加密 OpenSSH</small>
</span>
<textarea id="private-key" name="privateKey" rows="5" maxlength="65536" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
</label>
<label class="file-picker">
<input id="key-file" type="file" accept=".pem,.key,.openssh,text/plain" />
<span>选择密钥文件 / Choose key file</span>
<small id="key-file-name">未选择文件 / No file selected</small>
<span data-i18n-zh="选择密钥文件" data-i18n-en="Choose key file">选择密钥文件</span>
<small id="key-file-name">未选择文件</small>
</label>
</div>
</fieldset>
<details class="advanced-options">
<summary>高级选项 / Advanced options</summary>
<summary data-i18n-zh="高级选项" data-i18n-en="Advanced options">高级选项</summary>
<div class="advanced-body">
<label class="field">
<span>初始命令 <small>Initial command</small></span>
<span data-i18n-zh="初始命令" data-i18n-en="Initial command">初始命令</span>
<input id="initial-command" name="initialCommand" type="text" maxlength="4096" placeholder="tmux attach || tmux" spellcheck="false" />
</label>
<div class="field-grid two">
<label class="field">
<span>终端类型 <small>Terminal</small></span>
<span data-i18n-zh="终端类型" data-i18n-en="Terminal">终端类型</span>
<select id="term-type" name="termType">
<option value="xterm-256color">xterm-256color</option>
<option value="xterm">xterm</option>
@@ -129,7 +202,7 @@
</select>
</label>
<label class="field">
<span>字符编码 <small>Encoding</small></span>
<span data-i18n-zh="字符编码" data-i18n-en="Encoding">字符编码</span>
<select id="encoding" name="encoding">
<option value="utf-8">UTF-8</option>
<option value="gb18030">GB18030</option>
@@ -138,11 +211,26 @@
</label>
</div>
<label class="field">
<span>预期主机指纹 <small>Expected host fingerprint · SHA-256</small></span>
<input id="fingerprint" name="fingerprint" type="text" maxlength="128" placeholder="首次使用后自动保存 / Saved after first use" spellcheck="false" />
<span>
<span data-i18n-zh="预期主机指纹" data-i18n-en="Expected host fingerprint">预期主机指纹</span>
<small>SHA-256</small>
</span>
<input
id="fingerprint"
name="fingerprint"
type="text"
maxlength="128"
placeholder="首次使用后自动保存"
data-i18n-placeholder-zh="首次使用后自动保存"
data-i18n-placeholder-en="Saved after first use"
spellcheck="false"
/>
</label>
<label class="field">
<span>网关访问令牌 <small>Gateway access token · 可选且永不存储 / optional, never stored</small></span>
<span>
<span data-i18n-zh="网关访问令牌" data-i18n-en="Gateway access token">网关访问令牌</span>
<small data-i18n-zh="可选且永不存储" data-i18n-en="optional, never stored">可选且永不存储</small>
</span>
<input id="access-token" name="accessToken" type="password" maxlength="4096" autocomplete="off" />
</label>
</div>
@@ -152,59 +240,82 @@
<div class="form-actions">
<button id="connect-button" class="primary-button" type="submit">
<span class="button-icon" aria-hidden="true">&gt;_</span>
<span>连接 / Connect</span>
<span>连接</span>
</button>
<button id="save-profile" class="secondary-button" type="button">保存 / Save</button>
<button id="share-link" class="icon-button bordered" type="button" aria-label="复制安全连接链接 / Copy safe connection link">
<button id="save-profile" class="secondary-button" type="button" data-i18n-zh="保存" data-i18n-en="Save">保存</button>
<button
id="share-link"
class="icon-button bordered"
type="button"
aria-label="复制安全连接链接"
data-i18n-aria-label-zh="复制安全连接链接"
data-i18n-aria-label-en="Copy safe connection link"
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></svg>
</button>
</div>
</form>
<p class="privacy-note">
凭据仅通过本页面的 TLS WebSocket 传输,并只在当前会话内存中保留。 / Credentials travel only through this page's TLS WebSocket and remain in memory for the active session.
</p>
<p
class="privacy-note"
data-i18n-zh="凭据仅通过本页面的 TLS WebSocket 传输,并只在当前会话内存中保留。"
data-i18n-en="Credentials travel only through this page's TLS WebSocket and remain in memory for the active session."
>凭据仅通过本页面的 TLS WebSocket 传输,并只在当前会话内存中保留。</p>
</aside>
<main class="workspace">
<section class="session-strip" aria-label="实时连接状态 / Live connection status">
<section
class="session-strip"
aria-label="实时连接状态"
data-i18n-aria-label-zh="实时连接状态"
data-i18n-aria-label-en="Live connection status"
>
<div class="session-target">
<span class="live-orb" id="live-orb"></span>
<div>
<strong id="session-title">无活动会话 / No active session</strong>
<span id="session-subtitle">选择目标并连接 / Choose a target and connect</span>
<strong id="session-title">无活动会话</strong>
<span id="session-subtitle">选择目标并连接</span>
</div>
</div>
<div class="session-metrics">
<div><span>边缘 / EDGE</span><strong id="metric-edge">--</strong></div>
<div><span>延迟 / RTT</span><strong id="metric-rtt">--</strong></div>
<div><span>时长 / UPTIME</span><strong id="metric-uptime">00:00</strong></div>
<div><span>主机密钥 / HOST KEY</span><strong id="metric-host-key">--</strong></div>
<div><span data-i18n-zh="边缘" data-i18n-en="EDGE">边缘</span><strong id="metric-edge">--</strong></div>
<div><span data-i18n-zh="延迟" data-i18n-en="RTT">延迟</span><strong id="metric-rtt">--</strong></div>
<div><span data-i18n-zh="时长" data-i18n-en="UPTIME">时长</span><strong id="metric-uptime">00:00</strong></div>
<div><span data-i18n-zh="主机密钥" data-i18n-en="HOST KEY">主机密钥</span><strong id="metric-host-key">--</strong></div>
</div>
</section>
<section id="terminal-card" class="terminal-card">
<div class="terminal-toolbar">
<div class="window-controls" aria-hidden="true"><i></i><i></i><i></i></div>
<div id="terminal-label" class="terminal-label">终端 / terminal · 等待 / waiting</div>
<div id="terminal-label" class="terminal-label">终端 · 等待</div>
<div class="terminal-actions">
<button id="clear-terminal" class="toolbar-button" type="button">清屏 / Clear</button>
<button id="fullscreen-terminal" class="toolbar-button" type="button">全屏 / Fullscreen</button>
<button id="disconnect-button" class="toolbar-button danger" type="button" disabled>断开 / Disconnect</button>
<button id="clear-terminal" class="toolbar-button" type="button" data-i18n-zh="清屏" data-i18n-en="Clear">清屏</button>
<button id="fullscreen-terminal" class="toolbar-button" type="button" data-i18n-zh="全屏" data-i18n-en="Fullscreen">全屏</button>
<button id="disconnect-button" class="toolbar-button danger" type="button" disabled data-i18n-zh="断开" data-i18n-en="Disconnect">断开</button>
</div>
</div>
<div id="terminal-stage" class="terminal-stage">
<div id="terminal" role="application" aria-label="SSH 终端 / SSH terminal"></div>
<div
id="terminal"
role="application"
aria-label="SSH 终端"
data-i18n-aria-label-zh="SSH 终端"
data-i18n-aria-label-en="SSH terminal"
></div>
<div id="terminal-empty" class="terminal-empty">
<div class="empty-glyph" aria-hidden="true">&gt;_</div>
<h2>终端已就绪 / Your terminal is ready</h2>
<p>从连接面板建立 SSH 连接,开始加密的边缘会话。<br /><span lang="en">Open an SSH connection from the panel to start an encrypted edge session.</span></p>
<button id="empty-connect" class="small-button" type="button">配置连接 / Configure connection</button>
<h2 data-i18n-zh="终端已就绪" data-i18n-en="Your terminal is ready">终端已就绪</h2>
<p
data-i18n-zh="从连接面板建立 SSH 连接,开始加密的边缘会话。"
data-i18n-en="Open an SSH connection from the panel to start an encrypted edge session."
>从连接面板建立 SSH 连接,开始加密的边缘会话。</p>
<button id="empty-connect" class="small-button" type="button" data-i18n-zh="配置连接" data-i18n-en="Configure connection">配置连接</button>
</div>
</div>
<div class="event-bar">
<span id="event-message">Worker 运行时待命 / Worker runtime standing by</span>
<button id="event-toggle" type="button" aria-expanded="false">会话日志 / Session log</button>
<span id="event-message">Worker 运行时待命</span>
<button id="event-toggle" type="button" aria-expanded="false" data-i18n-zh="会话日志" data-i18n-en="Session log">会话日志</button>
</div>
<div id="event-log" class="event-log" hidden aria-live="polite"></div>
</section>
@@ -215,21 +326,27 @@
<dialog id="host-key-dialog" class="host-key-dialog" aria-labelledby="host-key-title">
<form method="dialog">
<span class="dialog-icon" aria-hidden="true">密钥<br />KEY</span>
<span class="dialog-icon" aria-hidden="true" data-i18n-zh="密钥" data-i18n-en="KEY">密钥</span>
<div>
<p class="eyebrow">主机身份 / HOST IDENTITY</p>
<h2 id="host-key-title">信任此 SSH 主机? / Trust this SSH host?</h2>
<p>服务器提供了尚未为此目标固定的密钥。继续前,请通过可信渠道核验。<br /><span lang="en">The server presented a key that has not been pinned for this target. Verify it through a trusted channel before continuing.</span></p>
<p class="eyebrow" data-i18n-zh="主机身份" data-i18n-en="HOST IDENTITY">主机身份</p>
<h2 id="host-key-title" data-i18n-zh="信任此 SSH 主机?" data-i18n-en="Trust this SSH host?">信任此 SSH 主机?</h2>
<p
data-i18n-zh="服务器提供了尚未为此目标固定的密钥。继续前,请通过可信渠道核验。"
data-i18n-en="The server presented a key that has not been pinned for this target. Verify it through a trusted channel before continuing."
>服务器提供了尚未为此目标固定的密钥。继续前,请通过可信渠道核验。</p>
</div>
<dl>
<div><dt>目标 / Target</dt><dd id="host-key-target">--</dd></div>
<div><dt>密钥类型 / Key type</dt><dd id="host-key-type">--</dd></div>
<div><dt>SHA-256 指纹 / fingerprint</dt><dd id="host-key-fingerprint">--</dd></div>
<div><dt data-i18n-zh="目标" data-i18n-en="Target">目标</dt><dd id="host-key-target">--</dd></div>
<div><dt data-i18n-zh="密钥类型" data-i18n-en="Key type">密钥类型</dt><dd id="host-key-type">--</dd></div>
<div><dt data-i18n-zh="SHA-256 指纹" data-i18n-en="SHA-256 fingerprint">SHA-256 指纹</dt><dd id="host-key-fingerprint">--</dd></div>
</dl>
<label class="trust-check"><input id="remember-host-key" type="checkbox" checked /> 下次记住并验证此指纹 / Remember and verify this fingerprint next time</label>
<label class="trust-check">
<input id="remember-host-key" type="checkbox" checked />
<span data-i18n-zh="下次记住并验证此指纹" data-i18n-en="Remember and verify this fingerprint next time">下次记住并验证此指纹</span>
</label>
<div class="dialog-actions">
<button id="reject-host-key" class="secondary-button" value="reject" type="submit">取消连接 / Cancel</button>
<button id="accept-host-key" class="primary-button" value="accept" type="submit">信任并继续 / Trust &amp; continue</button>
<button id="reject-host-key" class="secondary-button" value="reject" type="submit" data-i18n-zh="取消连接" data-i18n-en="Cancel">取消连接</button>
<button id="accept-host-key" class="primary-button" value="accept" type="submit" data-i18n-zh="信任并继续" data-i18n-en="Trust &amp; continue">信任并继续</button>
</div>
</form>
</dialog>
+13
View File
@@ -0,0 +1,13 @@
const LANGUAGE_STORAGE_KEY = 'workers-webssh.language';
let language: 'zh-CN' | 'en' | null = null;
try {
const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
if (stored === 'zh-CN' || stored === 'en') language = stored;
} catch {
// The main module will use the browser language when storage is unavailable.
}
if (!language) language = navigator.language.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en';
document.documentElement.lang = language;
document.documentElement.dataset.language = language;
+225 -51
View File
@@ -5,6 +5,13 @@ import './style.css';
type AuthMethod = 'password' | 'publickey';
type ConnectionState = 'idle' | 'connecting' | 'connected' | 'error';
type Language = 'zh-CN' | 'en';
type Translation = readonly [zh: string, en: string];
interface LocalizedMessage {
zh: string;
en: string;
}
interface SavedProfile {
id: string;
@@ -80,41 +87,96 @@ declare global {
const PROFILE_STORAGE_KEY = 'workers-webssh.profiles.v1';
const HOST_KEY_STORAGE_KEY = 'workers-webssh.hostkeys.v1';
const THEME_STORAGE_KEY = 'workers-webssh.theme';
const LANGUAGE_STORAGE_KEY = 'workers-webssh.language';
const MAX_PROFILES = 30;
const MAX_KEY_BYTES = 65_536;
const PING_INTERVAL_MS = 25_000;
function bilingual(zh: string, en: string): string {
return `${zh} / ${en}`;
function loadLanguage(): Language {
try {
const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
if (stored === 'zh-CN' || stored === 'en') return stored;
} catch {
// Fall back to the browser language when storage is unavailable.
}
return navigator.language.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en';
}
const EVENT_LABELS: Record<string, string> = {
session: bilingual('会话', 'session'),
connect: bilingual('连接', 'connect'),
transport: bilingual('传输', 'transport'),
authorization: bilingual('授权', 'authorization'),
disconnect: bilingual('断开', 'disconnect'),
protocol: bilingual('协议', 'protocol'),
status: bilingual('状态', 'status'),
ready: bilingual('就绪', 'ready'),
error: bilingual('错误', 'error'),
debug: bilingual('调试', 'debug'),
'host-key': bilingual('主机密钥', 'host key'),
let currentLanguage = loadLanguage();
const generatedTranslations = new Map<string, LocalizedMessage>();
function bilingual(zh: string, en: string): string {
const translation = localized(zh, en);
generatedTranslations.set(zh, translation);
generatedTranslations.set(en, translation);
return currentLanguage === 'zh-CN' ? zh : en;
}
function localized(zh: string, en: string): LocalizedMessage {
return { zh, en };
}
function localize(message: LocalizedMessage): string {
return bilingual(message.zh, message.en);
}
const STATIC_MESSAGE_TRANSLATIONS: Record<string, string> = {
'请输入有效的主机名或 IP 地址。': 'Enter a valid hostname or IP address.',
'端口必须介于 1 和 65535 之间。': 'Port must be between 1 and 65535.',
'请输入有效的 SSH 用户名。': 'Enter a valid SSH username.',
'主机指纹必须使用 SHA256:base64 格式。': 'Host fingerprint must use the SHA256:base64 format.',
'请粘贴或选择未加密的 OpenSSH 私钥。': 'Paste or choose an unencrypted OpenSSH private key.',
'私钥大于 64 KiB。': 'The private key is larger than 64 KiB.',
'仅支持未加密的 OpenSSH 私钥。': 'Only unencrypted OpenSSH private keys are supported.',
'请检查必填项和字段格式。': 'Check the required fields and their formats.',
};
const SERVER_EVENT_MESSAGES: Record<string, string> = {
version_exchange: bilingual('正在交换 SSH 协议版本', 'Exchanging SSH protocol versions'),
version_ready: bilingual('版本交换完成,正在协商密钥', 'Version exchange complete; negotiating keys'),
tcp_connecting: bilingual('正在连接 SSH 服务器', 'Connecting to the SSH server'),
authenticating: bilingual('加密传输已建立,正在认证', 'Encrypted transport established; authenticating'),
host_key_confirmation: bilingual('发送凭据前请确认此主机密钥', 'Confirm this host key before credentials are sent'),
auth_success: bilingual('SSH 认证成功,正在打开终端', 'SSH authentication succeeded; opening terminal'),
shell_ready: bilingual('Shell 已就绪', 'Shell is ready'),
ready: bilingual('交互式 Shell 已就绪', 'Interactive shell ready'),
remote_closed: bilingual('SSH 服务器已关闭连接', 'The SSH server closed the connection'),
remote_eof: bilingual('SSH 服务器已结束输出', 'SSH server finished sending output'),
session_ended: bilingual('SSH 会话已结束', 'SSH session ended'),
keepalive_timeout: bilingual('SSH 保活响应超时', 'SSH keepalive timed out'),
function messageTranslation(message: string, alternate?: string): LocalizedMessage {
if (alternate) return currentLanguage === 'zh-CN' ? localized(message, alternate) : localized(alternate, message);
const generated = generatedTranslations.get(message);
if (generated) return generated;
const english = STATIC_MESSAGE_TRANSLATIONS[message];
if (english) return localized(message, english);
const staticChinese = Object.entries(STATIC_MESSAGE_TRANSLATIONS).find(([, value]) => value === message)?.[0];
if (staticChinese) return localized(staticChinese, message);
const chinese = SERVER_MESSAGE_TRANSLATIONS[message];
if (chinese) return localized(chinese, message);
const serverEnglish = Object.entries(SERVER_MESSAGE_TRANSLATIONS).find(([, value]) => value === message)?.[0];
if (serverEnglish) return localized(message, serverEnglish);
return localized(message, message);
}
function translate([zh, en]: Translation): string {
return bilingual(zh, en);
}
const EVENT_LABELS: Record<string, Translation> = {
session: ['会话', 'session'],
connect: ['连接', 'connect'],
transport: ['传输', 'transport'],
authorization: ['授权', 'authorization'],
disconnect: ['断开', 'disconnect'],
protocol: ['协议', 'protocol'],
status: ['状态', 'status'],
ready: ['就绪', 'ready'],
error: ['错误', 'error'],
debug: ['调试', 'debug'],
'host-key': ['主机密钥', 'host key'],
};
const SERVER_EVENT_MESSAGES: Record<string, Translation> = {
version_exchange: ['正在交换 SSH 协议版本', 'Exchanging SSH protocol versions'],
version_ready: ['版本交换完成,正在协商密钥', 'Version exchange complete; negotiating keys'],
tcp_connecting: ['正在连接 SSH 服务器', 'Connecting to the SSH server'],
authenticating: ['加密传输已建立,正在认证', 'Encrypted transport established; authenticating'],
host_key_confirmation: ['发送凭据前请确认此主机密钥', 'Confirm this host key before credentials are sent'],
auth_success: ['SSH 认证成功,正在打开终端', 'SSH authentication succeeded; opening terminal'],
shell_ready: ['Shell 已就绪', 'Shell is ready'],
ready: ['交互式 Shell 已就绪', 'Interactive shell ready'],
remote_closed: ['SSH 服务器已关闭连接', 'The SSH server closed the connection'],
remote_eof: ['SSH 服务器已结束输出', 'SSH server finished sending output'],
session_ended: ['SSH 会话已结束', 'SSH session ended'],
keepalive_timeout: ['SSH 保活响应超时', 'SSH keepalive timed out'],
};
const SERVER_MESSAGE_TRANSLATIONS: Record<string, string> = {
@@ -149,12 +211,12 @@ const SERVER_MESSAGE_TRANSLATIONS: Record<string, string> = {
function bilingualServerMessage(message: string | undefined, eventName?: string, fallback?: string, summary = 'SSH 状态更新'): string {
const english = message?.trim() || fallback || eventName || 'SSH status';
if (/[\u3400-\u9fff].* \/ /.test(english)) return english;
const eventText = eventName ? SERVER_EVENT_MESSAGES[eventName] : undefined;
if (eventText && (!message || message === eventName || eventText.endsWith(` / ${english}`))) return eventText;
if (eventText && (!message || message === eventName || eventText[1] === english)) return translate(eventText);
if (currentLanguage === 'en') return english;
if (/[\u3400-\u9fff]/.test(english)) return english;
const chinese = SERVER_MESSAGE_TRANSLATIONS[english];
if (chinese) return bilingual(chinese, english);
return bilingual(summary, english);
return chinese ?? english ?? summary;
}
function element<T extends HTMLElement>(id: string): T {
@@ -194,6 +256,7 @@ const ui = {
shareLink: element<HTMLButtonElement>('share-link'),
globalStatus: element<HTMLElement>('global-status'),
globalStatusLabel: element<HTMLElement>('global-status-label'),
languageToggle: element<HTMLButtonElement>('language-toggle'),
themeToggle: element<HTMLButtonElement>('theme-toggle'),
sessionTitle: element<HTMLElement>('session-title'),
sessionSubtitle: element<HTMLElement>('session-subtitle'),
@@ -224,6 +287,56 @@ const ui = {
acceptHostKey: element<HTMLButtonElement>('accept-host-key'),
};
function updateRevealPasswordButton(): void {
const revealed = ui.password.type === 'text';
ui.revealPassword.textContent = revealed ? bilingual('隐藏', 'Hide') : bilingual('显示', 'Show');
ui.revealPassword.setAttribute('aria-label', revealed
? bilingual('隐藏密码', 'Hide password')
: bilingual('显示密码', 'Show password'));
}
function applyLanguage(language: Language, persist = false): void {
currentLanguage = language;
document.documentElement.lang = language;
document.documentElement.dataset.language = language;
for (const node of document.querySelectorAll<HTMLElement>('[data-i18n-zh][data-i18n-en]')) {
node.textContent = language === 'zh-CN' ? node.dataset.i18nZh! : node.dataset.i18nEn!;
}
for (const node of document.querySelectorAll<HTMLElement>('[data-i18n-placeholder-zh][data-i18n-placeholder-en]')) {
node.setAttribute('placeholder', language === 'zh-CN' ? node.dataset.i18nPlaceholderZh! : node.dataset.i18nPlaceholderEn!);
}
for (const node of document.querySelectorAll<HTMLElement>('[data-i18n-aria-label-zh][data-i18n-aria-label-en]')) {
node.setAttribute('aria-label', language === 'zh-CN' ? node.dataset.i18nAriaLabelZh! : node.dataset.i18nAriaLabelEn!);
}
for (const node of document.querySelectorAll<HTMLElement>('[data-i18n-content-zh][data-i18n-content-en]')) {
node.setAttribute('content', language === 'zh-CN' ? node.dataset.i18nContentZh! : node.dataset.i18nContentEn!);
}
const toggleLabel = language === 'zh-CN' ? '切换到英文' : 'Switch to Chinese';
ui.languageToggle.dataset.language = language;
ui.languageToggle.setAttribute('aria-label', toggleLabel);
ui.languageToggle.title = toggleLabel;
updateRevealPasswordButton();
if (!ui.keyFile.files?.length) ui.keyFileName.textContent = bilingual('未选择文件', 'No file selected');
ui.sessionSubtitle.textContent = localize(currentSessionSubtitle);
ui.eventMessage.textContent = localize(currentEventMessage);
if (currentFormError && !ui.formError.hidden) ui.formError.textContent = localize(currentFormError);
for (const line of ui.eventLog.querySelectorAll<HTMLElement>('.event-line')) {
const category = line.dataset.category ?? 'session';
const label = line.querySelector<HTMLElement>('strong');
const copy = line.querySelector<HTMLElement>('span');
if (label) label.textContent = EVENT_LABELS[category] ? translate(EVENT_LABELS[category]) : bilingual('SSH 事件', category);
if (copy?.dataset.messageZh && copy.dataset.messageEn) {
copy.textContent = bilingual(copy.dataset.messageZh, copy.dataset.messageEn);
}
}
if (persist) {
try { localStorage.setItem(LANGUAGE_STORAGE_KEY, language); } catch { /* Language still applies for this page. */ }
}
}
let profiles = loadProfiles();
let hostKeys = loadHostKeys();
let socket: WebSocket | null = null;
@@ -242,6 +355,9 @@ let awaitingHostKeyDecision = false;
let connectGeneration = 0;
let authorizationAbort: AbortController | null = null;
let currentExpectedFingerprint = '';
let currentSessionSubtitle: LocalizedMessage = { zh: '选择目标并连接', en: 'Choose a target and connect' };
let currentEventMessage: LocalizedMessage = { zh: 'Worker 运行时待命', en: 'Worker runtime standing by' };
let currentFormError: LocalizedMessage | null = null;
const terminal = new Terminal({
allowProposedApi: false,
@@ -293,7 +409,7 @@ function isSavedProfile(value: unknown): value is SavedProfile {
return (
typeof item.id === 'string' &&
typeof item.name === 'string' &&
item.name.length <= 64 &&
item.name.length >= 1 && item.name.length <= 253 &&
typeof item.host === 'string' &&
item.host.length >= 1 && item.host.length <= 253 && !/[\s/?#]/.test(item.host) &&
typeof item.port === 'number' &&
@@ -361,8 +477,7 @@ function setAuthMethod(method: AuthMethod): void {
function resetPasswordField(): void {
ui.password.value = '';
ui.password.type = 'password';
ui.revealPassword.textContent = bilingual('显示', 'Show');
ui.revealPassword.setAttribute('aria-label', bilingual('显示密码', 'Show password'));
updateRevealPasswordButton();
}
function clearPrivateKeyFields(): void {
@@ -387,13 +502,19 @@ function targetKey(host = normalizeHost(ui.host.value), port = Number(ui.port.va
return `${username}@${host.toLowerCase()}:${port}`;
}
function applyFormDefaults(): void {
if (!ui.username.value.trim()) ui.username.value = 'root';
if (!ui.port.value.trim() && !ui.port.validity.badInput) ui.port.value = '22';
}
function readProfileFromForm(): SavedProfile {
applyFormDefaults();
const host = normalizeHost(ui.host.value);
const port = Number(ui.port.value);
const username = ui.username.value.trim();
return {
id: ui.profileId.value || crypto.randomUUID(),
name: ui.profileName.value.trim() || `${username || 'ssh'}@${host || 'host'}`,
name: ui.profileName.value.trim() || host,
host,
port,
username,
@@ -407,6 +528,7 @@ function readProfileFromForm(): SavedProfile {
}
function validateProfileFields(): string | null {
applyFormDefaults();
const host = normalizeHost(ui.host.value);
const port = Number(ui.port.value);
const username = ui.username.value.trim();
@@ -431,6 +553,7 @@ function validateConnection(): string | null {
}
function validateConnectForm(): string | null {
applyFormDefaults();
const browserInvalid = [...ui.form.elements].find((control): control is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement =>
control instanceof HTMLInputElement || control instanceof HTMLSelectElement || control instanceof HTMLTextAreaElement
? !control.validity.valid
@@ -459,10 +582,12 @@ function clearForm(): void {
clearCredentials();
ui.profileId.value = '';
ui.port.value = '22';
ui.username.value = 'root';
ui.termType.value = 'xterm-256color';
ui.encoding.value = 'utf-8';
ui.fingerprint.value = '';
ui.formError.hidden = true;
currentFormError = null;
setAuthMethod('password');
renderProfiles();
ui.profileName.focus();
@@ -475,6 +600,7 @@ function saveCurrentProfile(): void {
return;
}
const profile = readProfileFromForm();
if (!ui.profileName.value.trim()) ui.profileName.value = profile.name;
const index = profiles.findIndex((item) => item.id === profile.id);
if (index >= 0) profiles[index] = profile;
else profiles.unshift(profile);
@@ -534,8 +660,9 @@ function renderProfiles(): void {
}
}
function showFormError(message: string): void {
ui.formError.textContent = message;
function showFormError(message: string, alternate?: string): void {
currentFormError = messageTranslation(message, alternate);
ui.formError.textContent = localize(currentFormError);
ui.formError.hidden = false;
}
@@ -564,16 +691,21 @@ function setState(state: ConnectionState, label?: string): void {
ui.disconnect.disabled = state !== 'connecting' && state !== 'connected';
}
function event(message: string, category = 'session', error = false): void {
ui.eventMessage.textContent = message;
function event(message: string, category = 'session', error = false, alternate?: string): void {
const eventTranslation = messageTranslation(message, alternate);
currentEventMessage = eventTranslation;
ui.eventMessage.textContent = localize(currentEventMessage);
const line = document.createElement('div');
line.className = `event-line${error ? ' error' : ''}`;
line.dataset.category = category;
const time = document.createElement('time');
time.textContent = new Date().toLocaleTimeString([], { hour12: false });
const type = document.createElement('strong');
type.textContent = EVENT_LABELS[category] ?? bilingual('SSH 事件', category);
type.textContent = EVENT_LABELS[category] ? translate(EVENT_LABELS[category]) : bilingual('SSH 事件', category);
const copy = document.createElement('span');
copy.textContent = message;
copy.dataset.messageZh = eventTranslation.zh;
copy.dataset.messageEn = eventTranslation.en;
line.append(time, type, copy);
ui.eventLog.append(line);
while (ui.eventLog.childElementCount > 100) ui.eventLog.firstElementChild?.remove();
@@ -645,6 +777,7 @@ function markReady(message = bilingual('交互式 Shell 已就绪', 'Interactive
if (connectionState === 'connected') return;
setState('connected');
startTimers();
currentSessionSubtitle = messageTranslation(message);
ui.sessionSubtitle.textContent = message;
event(message, 'ready');
if (currentInitialCommand && !initialCommandSent) {
@@ -746,6 +879,7 @@ function handleServerMessage(message: ServerMessage): void {
if (type === 'status') {
const text = bilingualServerMessage(message.message, message.event, 'SSH handshake in progress');
event(text, message.event ?? 'status');
currentSessionSubtitle = messageTranslation(text);
ui.sessionSubtitle.textContent = text;
if (message.event === 'shell_ready' || message.event === 'ready') markReady(text);
}
@@ -793,6 +927,7 @@ async function issueTicket(accessToken: string, signal: AbortSignal): Promise<{
async function connect(): Promise<void> {
if (connectionState === 'connecting' || connectionState === 'connected') return;
applyFormDefaults();
const validationError = validateConnectForm();
if (validationError) {
showFormError(validationError);
@@ -804,6 +939,7 @@ async function connect(): Promise<void> {
}
ui.formError.hidden = true;
currentFormError = null;
const generation = ++connectGeneration;
const abortController = new AbortController();
authorizationAbort?.abort();
@@ -826,11 +962,12 @@ async function connect(): Promise<void> {
awaitingHostKeyDecision = false;
decoder = createDecoder(ui.encoding.value);
ui.sessionTitle.textContent = `${username}@${host}:${port}`;
ui.sessionSubtitle.textContent = bilingual('正在授权 Worker 会话...', 'Authorizing Worker session...');
currentSessionSubtitle = localized('正在授权 Worker 会话...', 'Authorizing Worker session...');
ui.sessionSubtitle.textContent = localize(currentSessionSubtitle);
ui.metricEdge.textContent = '--';
ui.metricRtt.textContent = '--';
ui.metricHostKey.textContent = '--';
ui.terminalLabel.textContent = `终端 / terminal · ${username}@${host}`;
ui.terminalLabel.textContent = `${bilingual('终端', 'terminal')} · ${username}@${host}`;
event(bilingual(`正在连接 ${username}@${host}:${port}`, `Starting ${username}@${host}:${port}`), 'connect');
try {
@@ -873,7 +1010,8 @@ async function connect(): Promise<void> {
else config.privateKey = privateKey;
if (pinnedKey) config.expectedFingerprint = pinnedKey;
activeSocket.send(JSON.stringify(config));
ui.sessionSubtitle.textContent = bilingual('正在打开 TCP 连接...', 'Opening TCP connection...');
currentSessionSubtitle = localized('正在打开 TCP 连接...', 'Opening TCP connection...');
ui.sessionSubtitle.textContent = localize(currentSessionSubtitle);
event(bilingual('WebSocket 已建立,正在打开 SSH 传输。', 'WebSocket established; opening SSH transport.'), 'transport');
}, { once: true });
activeSocket.addEventListener('message', (socketEvent) => {
@@ -898,6 +1036,7 @@ async function connect(): Promise<void> {
? bilingual('会话已关闭。', 'Session closed.')
: bilingual(`会话已关闭(${closeEvent.code})。`, `Session closed (${closeEvent.code}).`);
event(reason, 'disconnect', closeEvent.code !== 1000 && closeEvent.code !== 1005);
currentSessionSubtitle = messageTranslation(reason);
ui.sessionSubtitle.textContent = reason;
setState(closeEvent.code === 1000 || closeEvent.code === 1005 ? 'idle' : 'error');
ui.disconnect.disabled = true;
@@ -930,6 +1069,7 @@ function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected
clearHostKeyPrompt();
currentExpectedFingerprint = '';
if (activeSocket && activeSocket.readyState < WebSocket.CLOSING) activeSocket.close(1000, 'Disconnected by user');
currentSessionSubtitle = messageTranslation(reason);
ui.sessionSubtitle.textContent = reason;
event(reason, 'disconnect');
setState('idle');
@@ -946,6 +1086,7 @@ function createDecoder(encoding: string): TextDecoder {
}
function copySafeLink(): void {
applyFormDefaults();
const error = validateProfileFields();
if (error) {
showFormError(error);
@@ -964,13 +1105,33 @@ function copySafeLink(): void {
);
}
function setPortValue(value: string | number, source: string): void {
if (String(value).trim() === '') {
ui.port.value = '22';
return;
}
const port = typeof value === 'number' ? value : Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new Error(bilingual(`${source}中的端口无效`, `Invalid port in ${source}`));
}
ui.port.value = String(port);
}
function applyURLParameters(): boolean {
const query = new URLSearchParams(location.search);
const fragment = new URLSearchParams(location.hash.replace(/^#/, ''));
const value = (key: string): string | null => query.get(key) ?? fragment.get(key);
const host = value('hostname') ?? value('host');
if (host) ui.host.value = host;
if (value('port')) ui.port.value = value('port')!;
const port = value('port');
if (port !== null) {
try {
setPortValue(port, bilingual('连接链接', 'connection link'));
} catch (error) {
showFormError(error instanceof Error ? error.message : String(error));
return false;
}
}
if (value('username')) ui.username.value = value('username')!;
if (value('command')) ui.initialCommand.value = value('command')!;
if (value('term')) ui.termType.value = value('term')!;
@@ -988,14 +1149,14 @@ function applyURLParameters(): boolean {
toast(bilingual('密码 URL 参数不是有效的 Base64。', 'The password URL parameter is not valid Base64.'), 'error');
}
}
return Boolean(host && value('username') && (value('autoconnect') === '1' || legacyPassword));
return Boolean(host && (value('autoconnect') === '1' || legacyPassword));
}
function applyWSSHOptions(options: WSSHOptions): void {
clearCredentials();
ui.host.value = options.host ?? options.hostname ?? ui.host.value;
if (options.port !== undefined) ui.port.value = String(options.port);
if (options.username !== undefined) ui.username.value = options.username;
setPortValue(options.port ?? 22, 'wssh.connect()');
ui.username.value = options.username?.trim() || 'root';
if (options.password !== undefined) {
setAuthMethod('password');
ui.password.value = options.password;
@@ -1057,10 +1218,7 @@ ui.shareLink.addEventListener('click', copySafeLink);
ui.revealPassword.addEventListener('click', () => {
const reveal = ui.password.type === 'password';
ui.password.type = reveal ? 'text' : 'password';
ui.revealPassword.textContent = reveal ? bilingual('隐藏', 'Hide') : bilingual('显示', 'Show');
ui.revealPassword.setAttribute('aria-label', reveal
? bilingual('隐藏密码', 'Hide password')
: bilingual('显示密码', 'Show password'));
updateRevealPasswordButton();
});
ui.keyFile.addEventListener('change', async () => {
const file = ui.keyFile.files?.[0];
@@ -1115,6 +1273,16 @@ ui.eventToggle.addEventListener('click', () => {
ui.eventLog.hidden = !ui.eventLog.hidden;
ui.eventToggle.setAttribute('aria-expanded', String(!ui.eventLog.hidden));
fitTerminal(true);
});
ui.languageToggle.addEventListener('click', () => {
applyLanguage(currentLanguage === 'zh-CN' ? 'en' : 'zh-CN', true);
renderProfiles();
setState(connectionState);
openPanel(ui.panel.classList.contains('open'));
if (connectionState === 'idle') ui.sessionTitle.textContent = bilingual('无活动会话', 'No active session');
ui.terminalLabel.textContent = connectionState === 'idle'
? bilingual('终端 · 等待', 'terminal · waiting')
: `${bilingual('终端', 'terminal')} · ${ui.username.value.trim()}@${normalizeHost(ui.host.value)}`;
});
ui.themeToggle.addEventListener('click', () => {
const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
@@ -1142,10 +1310,16 @@ window.matchMedia('(max-width: 760px)').addEventListener('change', () => openPan
let storedTheme: string | null = null;
try { storedTheme = localStorage.getItem(THEME_STORAGE_KEY); } catch { /* Storage can be disabled. */ }
if (storedTheme === 'light' || storedTheme === 'dark') document.documentElement.dataset.theme = storedTheme;
applyLanguage(currentLanguage);
element<HTMLElement>('app').hidden = false;
renderProfiles();
openPanel(false);
setAuthMethod('password');
setState('idle');
ui.sessionTitle.textContent = bilingual('无活动会话', 'No active session');
ui.sessionSubtitle.textContent = bilingual('选择目标并连接', 'Choose a target and connect');
ui.terminalLabel.textContent = bilingual('终端 · 等待', 'terminal · waiting');
ui.eventMessage.textContent = bilingual('Worker 运行时待命', 'Worker runtime standing by');
initializeCompatibilityAPI();
const shouldAutoConnect = applyURLParameters();
requestAnimationFrame(() => {
+61
View File
@@ -53,6 +53,10 @@
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
html,
body,
#app {
@@ -200,6 +204,63 @@ button:disabled {
color: var(--text);
}
.language-icon {
position: relative;
display: block;
width: 23px;
height: 21px;
}
.language-icon span {
position: absolute;
display: grid;
width: 16px;
height: 16px;
place-items: center;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
font-size: 10px;
font-weight: 800;
line-height: 1;
}
.language-icon span:first-child {
top: 0;
left: 0;
}
.language-icon span:last-child {
right: 0;
bottom: 0;
font-family: var(--mono);
}
#language-toggle[data-language='zh-CN'] .language-icon span:first-child,
#language-toggle[data-language='en'] .language-icon span:last-child {
z-index: 2;
border-color: color-mix(in srgb, var(--accent) 52%, var(--border));
background: var(--surface-2);
color: var(--accent);
}
#language-toggle[data-language='zh-CN'] .language-icon span:last-child,
#language-toggle[data-language='en'] .language-icon span:first-child {
z-index: 1;
color: var(--muted);
}
#language-toggle:hover .language-icon span {
color: var(--text);
}
#language-toggle:hover[data-language='zh-CN'] .language-icon span:first-child,
#language-toggle:hover[data-language='en'] .language-icon span:last-child {
border-color: var(--accent);
color: var(--accent);
}
.icon-button.bordered {
border: 1px solid var(--border);
}