feat: multi-OpenClaw CLI detection/binding + i18n infrastructure

Multi-OpenClaw Detection & Binding:
- Add resolve_openclaw_cli_path() and classify_cli_source() in utils.rs
- Support openclawCliPath binding in clawpanel.json (user selects CLI)
- VersionInfo now includes cli_path, cli_source, all_installations
- scan_all_installations() detects all OpenClaw installs on system
- Dashboard shows CLI source label + multi-install warning
- Settings page: CLI binding UI with auto-detect and manual selection
- dev-api.js synced with cli_path/cli_source fields for Web mode

i18n Infrastructure:
- Create src/lib/i18n.js core module (t(), setLang(), initI18n())
- Create src/locales/zh-CN.json and src/locales/en.json
- Sidebar fully i18n-ized (nav labels, sections, instance switcher)
- Dashboard stat cards fully i18n-ized
- Settings page: language switcher UI (live reload)
- initI18n() called in main.js on startup
This commit is contained in:
晴天
2026-03-24 11:57:00 +08:00
parent 7aa13ff7d5
commit 0c062e93e0
12 changed files with 951 additions and 84 deletions

View File

@@ -2939,6 +2939,24 @@ const handlers = {
const current = getLocalOpenclawVersion()
const latest = await getLatestVersionFor(source)
const recommended = recommendedVersionFor(source)
// CLI 路径解析Web 模式下用 which/where
let cli_path = null
let cli_source = null
try {
const { execSync } = require('child_process')
const cmd = process.platform === 'win32' ? 'where openclaw' : 'which openclaw'
const out = execSync(cmd, { timeout: 3000 }).toString().trim()
cli_path = out.split('\n')[0]?.trim() || null
if (cli_path) {
const lower = cli_path.replace(/\\/g, '/').toLowerCase()
if (lower.includes('/programs/openclaw/') || lower.includes('/openclaw-bin/') || lower.includes('/opt/openclaw/')) cli_source = 'standalone'
else if (lower.includes('openclaw-zh') || lower.includes('@qingchencloud')) cli_source = 'npm-zh'
else if (lower.includes('/npm/') || lower.includes('/node_modules/')) cli_source = 'npm-official'
else cli_source = 'unknown'
}
} catch {}
return {
current,
latest,
@@ -2948,7 +2966,10 @@ const handlers = {
is_recommended: !!current && !!recommended && versionsMatch(current, recommended),
ahead_of_recommended: !!current && !!recommended && recommendedIsNewer(current, recommended),
panel_version: PANEL_VERSION,
source
source,
cli_path,
cli_source,
all_installations: null
}
},

View File

@@ -1365,6 +1365,16 @@ pub async fn get_version_info() -> Result<VersionInfo, String> {
(Some(c), Some(r)) => recommended_is_newer(c, r),
_ => false,
};
// 解析当前实际使用的 CLI 路径
let cli_path = crate::utils::resolve_openclaw_cli_path();
let cli_source = cli_path
.as_ref()
.map(|p| crate::utils::classify_cli_source(p));
// 扫描所有可检测到的 OpenClaw 安装
let all_installations = scan_all_installations(&cli_path);
Ok(VersionInfo {
current,
latest,
@@ -1375,9 +1385,148 @@ pub async fn get_version_info() -> Result<VersionInfo, String> {
ahead_of_recommended,
panel_version: panel_version().to_string(),
source,
cli_path,
cli_source,
all_installations: Some(all_installations),
})
}
/// 扫描系统中所有可检测到的 OpenClaw 安装
fn scan_all_installations(
active_path: &Option<String>,
) -> Vec<crate::models::types::OpenClawInstallation> {
use crate::models::types::OpenClawInstallation;
let mut results: Vec<OpenClawInstallation> = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut try_add = |path: std::path::PathBuf| {
if !path.exists() {
return;
}
let canonical = path
.canonicalize()
.unwrap_or_else(|_| path.clone())
.to_string_lossy()
.to_string();
if seen.contains(&canonical) {
return;
}
seen.insert(canonical.clone());
let path_str = path.to_string_lossy().to_string();
let source = crate::utils::classify_cli_source(&path_str);
let version = read_version_from_installation(&path);
let is_active = active_path
.as_ref()
.map(|a| {
let a_canon = std::path::Path::new(a)
.canonicalize()
.unwrap_or_else(|_| std::path::PathBuf::from(a))
.to_string_lossy()
.to_string();
a_canon == canonical
})
.unwrap_or(false);
results.push(OpenClawInstallation {
path: path_str,
source,
version,
active: is_active,
});
};
// standalone 安装目录
for sa_dir in all_standalone_dirs() {
#[cfg(target_os = "windows")]
try_add(sa_dir.join("openclaw.cmd"));
#[cfg(not(target_os = "windows"))]
try_add(sa_dir.join("openclaw"));
}
// npm 全局目录
#[cfg(target_os = "windows")]
{
if let Ok(appdata) = std::env::var("APPDATA") {
try_add(
std::path::PathBuf::from(&appdata)
.join("npm")
.join("openclaw.cmd"),
);
}
}
// PATH 中找到的所有 openclaw
let enhanced = super::enhanced_path();
#[cfg(target_os = "windows")]
let sep = ';';
#[cfg(not(target_os = "windows"))]
let sep = ':';
for dir in enhanced.split(sep) {
let dir = dir.trim();
if dir.is_empty() {
continue;
}
let base = std::path::Path::new(dir);
#[cfg(target_os = "windows")]
{
try_add(base.join("openclaw.cmd"));
}
#[cfg(not(target_os = "windows"))]
{
try_add(base.join("openclaw"));
}
}
results
}
/// 从安装路径附近读取版本信息
fn read_version_from_installation(cli_path: &std::path::Path) -> Option<String> {
// 尝试从同目录的 VERSION 文件读取
if let Some(dir) = cli_path.parent() {
let version_file = dir.join("VERSION");
if let Ok(content) = std::fs::read_to_string(&version_file) {
for line in content.lines() {
if let Some(ver) = line.strip_prefix("openclaw_version=") {
let ver = ver.trim();
if !ver.is_empty() {
return Some(ver.to_string());
}
}
}
}
// 尝试从 package.json 读取
for pkg_name in &["@qingchencloud/openclaw-zh", "openclaw"] {
let pkg_json = dir.join("node_modules").join(pkg_name).join("package.json");
if let Ok(content) = std::fs::read_to_string(&pkg_json) {
if let Some(ver) = serde_json::from_str::<serde_json::Value>(&content)
.ok()
.and_then(|v| v.get("version")?.as_str().map(String::from))
{
return Some(ver);
}
}
}
// npm shim 情况:向上查找 node_modules
if let Some(parent) = dir.parent() {
for pkg_name in &["@qingchencloud/openclaw-zh", "openclaw"] {
let pkg_json = parent
.join("node_modules")
.join(pkg_name)
.join("package.json");
if let Ok(content) = std::fs::read_to_string(&pkg_json) {
if let Some(ver) = serde_json::from_str::<serde_json::Value>(&content)
.ok()
.and_then(|v| v.get("version")?.as_str().map(String::from))
{
return Some(ver);
}
}
}
}
}
None
}
/// 获取 OpenClaw 运行时状态摘要openclaw status --json
/// 包含 runtimeVersion、会话列表含 token 用量、fastMode 等标签)
#[tauri::command]

View File

@@ -85,7 +85,7 @@ fn panel_config_path() -> PathBuf {
default_openclaw_dir().join("clawpanel.json")
}
fn read_panel_config_value() -> Option<serde_json::Value> {
pub fn read_panel_config_value() -> Option<serde_json::Value> {
std::fs::read_to_string(panel_config_path())
.ok()
.and_then(|content| serde_json::from_str(&content).ok())

View File

@@ -21,4 +21,18 @@ pub struct VersionInfo {
pub ahead_of_recommended: bool,
pub panel_version: String,
pub source: String,
/// 当前实际使用的 CLI 完整路径
pub cli_path: Option<String>,
/// CLI 安装来源标签: standalone / npm-zh / npm-official / unknown
pub cli_source: Option<String>,
/// 所有检测到的 OpenClaw 安装(路径 + 来源 + 版本)
pub all_installations: Option<Vec<OpenClawInstallation>>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OpenClawInstallation {
pub path: String,
pub source: String,
pub version: Option<String>,
pub active: bool,
}

View File

@@ -1,11 +1,30 @@
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
/// 读取 clawpanel.json 中用户绑定的 CLI 路径
fn bound_cli_path() -> Option<std::path::PathBuf> {
let config = crate::commands::read_panel_config_value()?;
let raw = config.get("openclawCliPath")?.as_str()?;
if raw.is_empty() {
return None;
}
let p = std::path::PathBuf::from(raw);
if p.exists() {
Some(p)
} else {
None
}
}
/// Windows: 在 PATH 中查找 openclaw.cmd 的完整路径
/// 避免通过 `cmd /c openclaw` 调用时 npm .cmd shim 中的引号导致
/// "\"node\"" is not recognized 错误
#[cfg(target_os = "windows")]
fn find_openclaw_cmd() -> Option<std::path::PathBuf> {
// 优先使用用户绑定的路径
if let Some(bound) = bound_cli_path() {
return Some(bound);
}
let path = crate::commands::enhanced_path();
for dir in path.split(';') {
let candidate = std::path::Path::new(dir).join("openclaw.cmd");
@@ -16,6 +35,62 @@ fn find_openclaw_cmd() -> Option<std::path::PathBuf> {
None
}
/// 解析当前实际使用的 openclaw CLI 完整路径(跨平台)
pub fn resolve_openclaw_cli_path() -> Option<String> {
// 优先使用用户绑定的路径
if let Some(bound) = bound_cli_path() {
return Some(bound.to_string_lossy().to_string());
}
#[cfg(target_os = "windows")]
{
let path = crate::commands::enhanced_path();
for dir in path.split(';') {
let candidate = std::path::Path::new(dir).join("openclaw.cmd");
if candidate.exists() {
return Some(candidate.to_string_lossy().to_string());
}
}
None
}
#[cfg(not(target_os = "windows"))]
{
let path = crate::commands::enhanced_path();
let sep = ':';
for dir in path.split(sep) {
let candidate = std::path::Path::new(dir).join("openclaw");
if candidate.exists() {
return Some(candidate.to_string_lossy().to_string());
}
}
None
}
}
/// 根据 CLI 路径判断安装来源
pub fn classify_cli_source(cli_path: &str) -> String {
let lower = cli_path.replace('\\', "/").to_lowercase();
// standalone 安装
if lower.contains("/programs/openclaw/")
|| lower.contains("/openclaw-bin/")
|| lower.contains("/opt/openclaw/")
{
return "standalone".into();
}
// npm 汉化版
if lower.contains("openclaw-zh") || lower.contains("@qingchencloud") {
return "npm-zh".into();
}
// npm 全局(大概率官方版)
if lower.contains("/npm/") || lower.contains("/node_modules/") {
return "npm-official".into();
}
// Homebrew
if lower.contains("/homebrew/") || lower.contains("/usr/local/bin") {
return "npm-global".into();
}
"unknown".into()
}
/// 跨平台获取 openclaw 命令的方法(同步版本)
#[allow(dead_code)]
pub fn openclaw_command() -> std::process::Command {
@@ -42,7 +117,10 @@ pub fn openclaw_command() -> std::process::Command {
}
#[cfg(not(target_os = "windows"))]
{
let mut cmd = std::process::Command::new("openclaw");
let bin = bound_cli_path()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "openclaw".into());
let mut cmd = std::process::Command::new(bin);
cmd.env("PATH", crate::commands::enhanced_path());
crate::commands::apply_proxy_env(&mut cmd);
cmd
@@ -74,7 +152,10 @@ pub fn openclaw_command_async() -> tokio::process::Command {
}
#[cfg(not(target_os = "windows"))]
{
let mut cmd = tokio::process::Command::new("openclaw");
let bin = bound_cli_path()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| "openclaw".into());
let mut cmd = tokio::process::Command::new(bin);
cmd.env("PATH", crate::commands::enhanced_path());
crate::commands::apply_proxy_env_tokio(&mut cmd);
cmd

View File

@@ -7,70 +7,71 @@ import { isOpenclawReady, getActiveInstance, switchInstance, onInstanceChange }
import { api } from '../lib/tauri-api.js'
import { toast } from './toast.js'
import { version as APP_VERSION } from '../../package.json'
import { t } from '../lib/i18n.js'
const NAV_ITEMS_FULL = [
function NAV_ITEMS_FULL() { return [
{
section: '概览',
section: t('sidebar.sectionMonitor'),
items: [
{ route: '/dashboard', label: '仪表盘', icon: 'dashboard' },
{ route: '/assistant', label: '晴辰助手', icon: 'assistant' },
{ route: '/chat', label: '实时聊天', icon: 'chat' },
{ route: '/services', label: '服务管理', icon: 'services' },
{ route: '/logs', label: '日志查看', icon: 'logs' },
{ route: '/dashboard', label: t('sidebar.dashboard'), icon: 'dashboard' },
{ route: '/assistant', label: t('sidebar.assistant'), icon: 'assistant' },
{ route: '/chat', label: t('sidebar.chat'), icon: 'chat' },
{ route: '/services', label: t('sidebar.services'), icon: 'services' },
{ route: '/logs', label: t('sidebar.logs'), icon: 'logs' },
]
},
{
section: '配置',
section: t('sidebar.sectionConfig'),
items: [
{ route: '/models', label: '模型配置', icon: 'models' },
{ route: '/agents', label: 'Agent 管理', icon: 'agents' },
{ route: '/gateway', label: 'Gateway', icon: 'gateway' },
{ route: '/channels', label: '消息渠道', icon: 'channels' },
{ route: '/communication', label: '通信与自动化', icon: 'settings' },
{ route: '/security', label: '安全设置', icon: 'security' },
{ route: '/models', label: t('sidebar.models'), icon: 'models' },
{ route: '/agents', label: t('sidebar.agents'), icon: 'agents' },
{ route: '/gateway', label: t('sidebar.gateway'), icon: 'gateway' },
{ route: '/channels', label: t('sidebar.channels'), icon: 'channels' },
{ route: '/communication', label: t('sidebar.communication'), icon: 'settings' },
{ route: '/security', label: t('sidebar.security'), icon: 'security' },
]
},
{
section: '数据',
section: t('sidebar.sectionData'),
items: [
{ route: '/memory', label: '记忆文件', icon: 'memory' },
{ route: '/cron', label: '定时任务', icon: 'clock' },
{ route: '/usage', label: '使用情况', icon: 'bar-chart' },
{ route: '/memory', label: t('sidebar.memory'), icon: 'memory' },
{ route: '/cron', label: t('sidebar.cron'), icon: 'clock' },
{ route: '/usage', label: t('sidebar.usage'), icon: 'bar-chart' },
]
},
{
section: '扩展',
section: t('sidebar.sectionExtension'),
items: [
{ route: '/skills', label: 'Skills', icon: 'skills' },
{ route: '/skills', label: t('sidebar.skills'), icon: 'skills' },
]
},
{
section: '',
items: [
{ route: '/settings', label: '面板设置', icon: 'settings' },
{ route: '/chat-debug', label: '系统诊断', icon: 'debug' },
{ route: '/about', label: '关于', icon: 'about' },
{ route: '/settings', label: t('sidebar.settings'), icon: 'settings' },
{ route: '/chat-debug', label: t('sidebar.chatDebug'), icon: 'debug' },
{ route: '/about', label: t('sidebar.about'), icon: 'about' },
]
}
]
] }
const NAV_ITEMS_SETUP = [
function NAV_ITEMS_SETUP() { return [
{
section: '',
items: [
{ route: '/setup', label: '初始设置', icon: 'setup' },
{ route: '/assistant', label: '晴辰助手', icon: 'assistant' },
{ route: '/setup', label: t('sidebar.setup'), icon: 'setup' },
{ route: '/assistant', label: t('sidebar.assistant'), icon: 'assistant' },
]
},
{
section: '',
items: [
{ route: '/settings', label: '面板设置', icon: 'settings' },
{ route: '/chat-debug', label: '系统诊断', icon: 'debug' },
{ route: '/about', label: '关于', icon: 'about' },
{ route: '/settings', label: t('sidebar.settings'), icon: 'settings' },
{ route: '/chat-debug', label: t('sidebar.chatDebug'), icon: 'debug' },
{ route: '/about', label: t('sidebar.about'), icon: 'about' },
]
}
]
] }
const ICONS = {
setup: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>',
@@ -152,7 +153,7 @@ export function renderSidebar(el) {
<nav class="sidebar-nav">
`
const navItems = isOpenclawReady() ? NAV_ITEMS_FULL : NAV_ITEMS_SETUP
const navItems = isOpenclawReady() ? NAV_ITEMS_FULL() : NAV_ITEMS_SETUP()
for (const section of navItems) {
html += `<div class="nav-section">
@@ -179,7 +180,7 @@ export function renderSidebar(el) {
<div class="sidebar-footer">
<div class="nav-item" id="btn-theme-toggle">
${isDark ? sunIcon : moonIcon}
<span>${isDark ? '日间模式' : '夜间模式'}</span>
<span>${isDark ? t('sidebar.themeLight') : t('sidebar.themeDark')}</span>
</div>
<div class="sidebar-meta">
<a href="https://claw.qt.cool" target="_blank" rel="noopener" class="sidebar-link">claw.qt.cool</a>
@@ -240,8 +241,8 @@ export function renderSidebar(el) {
opt.style.opacity = '0.5'
switchInstance(id).then(() => {
const inst = getActiveInstance()
const desc = inst.type === 'local' ? '本机' : inst.name
toast(`已切换到 ${desc} — 模型配置、Agent 等将管理该实例`, 'success')
const desc = inst.type === 'local' ? t('instance.local') : inst.name
toast(t('instance.switchedTo', { name: desc }), 'success')
renderSidebar(el)
reloadCurrentRoute()
})
@@ -301,19 +302,19 @@ async function _toggleInstanceDropdown(sidebarEl) {
if (!dd) return
if (dd.classList.contains('open')) { dd.classList.remove('open'); return }
dd.innerHTML = '<div style="padding:8px;color:var(--text-tertiary);font-size:12px">加载中...</div>'
dd.innerHTML = `<div style="padding:8px;color:var(--text-tertiary);font-size:12px">${t('common.loading')}</div>`
dd.classList.add('open')
try {
const [data, health] = await Promise.all([api.instanceList(), api.instanceHealthAll()])
const healthMap = Object.fromEntries((health || []).map(h => [h.id, h]))
const activeId = getActiveInstance().id
let html = '<div class="instance-hint">切换后模型配置、Agent 等页面将管理对应实例</div>'
let html = `<div class="instance-hint">${t('instance.switchHint')}</div>`
for (const inst of data.instances) {
const h = healthMap[inst.id] || {}
const active = inst.id === activeId ? ' active' : ''
const dot = h.online !== false ? 'online' : 'offline'
const badge = inst.type === 'docker' ? '<span class="instance-badge docker">Docker</span>' : inst.type === 'remote' ? '<span class="instance-badge remote">远程</span>' : ''
const badge = inst.type === 'docker' ? `<span class="instance-badge docker">${t('instance.docker')}</span>` : inst.type === 'remote' ? `<span class="instance-badge remote">${t('instance.remote')}</span>` : ''
const port = inst.endpoint ? inst.endpoint.match(/:(\d+)/)?.[1] : ''
const portTag = port ? `<span class="instance-port">:${port}</span>` : ''
html += `<div class="instance-option${active}" data-id="${inst.id}">
@@ -321,11 +322,11 @@ async function _toggleInstanceDropdown(sidebarEl) {
<span class="instance-opt-name">${_escSidebar(inst.name)}</span>
${portTag}
${badge}
${active ? '<span class="instance-active-tag">当前</span>' : ''}
${active ? `<span class="instance-active-tag">${t('instance.current')}</span>` : ''}
</div>`
}
html += '<div class="instance-divider"></div>'
html += '<div class="instance-option instance-add" id="btn-instance-add">+ 添加实例</div>'
html += `<div class="instance-option instance-add" id="btn-instance-add">+ ${t('instance.addInstance')}</div>`
dd.innerHTML = html
} catch (e) {
dd.innerHTML = `<div style="padding:8px;color:var(--error);font-size:12px">${_escSidebar(e.message)}</div>`
@@ -337,27 +338,27 @@ async function _showAddInstanceDialog(sidebarEl) {
overlay.className = 'docker-dialog-overlay'
overlay.innerHTML = `
<div class="docker-dialog">
<div class="docker-dialog-title">添加远程实例</div>
<div class="docker-dialog-title">${t('instance.addRemote')}</div>
<div class="form-group" style="margin-bottom:var(--space-md)">
<label class="form-label">名称</label>
<input class="form-input" id="inst-name" placeholder="远程服务器" />
<label class="form-label">${t('instance.nameLabel')}</label>
<input class="form-input" id="inst-name" placeholder="${t('instance.namePlaceholder')}" />
</div>
<div class="form-group" style="margin-bottom:var(--space-md)">
<label class="form-label">面板地址</label>
<label class="form-label">${t('instance.endpointLabel')}</label>
<input class="form-input" id="inst-endpoint" placeholder="http://192.168.1.100:1420" />
</div>
<div class="form-group" style="margin-bottom:var(--space-md)">
<label class="form-label">Gateway 端口(可选)</label>
<label class="form-label">${t('instance.gwPortLabel')}</label>
<input class="form-input" id="inst-gw-port" type="number" value="18789" />
</div>
<div class="docker-dialog-hint">
远程服务器需要运行 ClawPanel (serve.js)。<br/>
示例: <code>http://192.168.1.100:1420</code>
${t('instance.remoteHint')}<br/>
${t('instance.example')}: <code>http://192.168.1.100:1420</code>
</div>
<div id="inst-add-error" style="color:var(--error);font-size:12px;margin-top:var(--space-sm)"></div>
<div class="docker-dialog-actions">
<button class="btn btn-secondary btn-sm" id="inst-cancel">取消</button>
<button class="btn btn-primary btn-sm" id="inst-confirm">添加</button>
<button class="btn btn-secondary btn-sm" id="inst-cancel">${t('common.cancel')}</button>
<button class="btn btn-primary btn-sm" id="inst-confirm">${t('common.add')}</button>
</div>
</div>
`
@@ -369,16 +370,16 @@ async function _showAddInstanceDialog(sidebarEl) {
const endpoint = overlay.querySelector('#inst-endpoint').value.trim()
const gwPort = parseInt(overlay.querySelector('#inst-gw-port').value) || 18789
const errEl = overlay.querySelector('#inst-add-error')
if (!name || !endpoint) { errEl.textContent = '请填写名称和面板地址'; return }
if (!name || !endpoint) { errEl.textContent = t('instance.nameRequired'); return }
const btn = overlay.querySelector('#inst-confirm')
btn.disabled = true; btn.textContent = '添加中...'
btn.disabled = true; btn.textContent = t('instance.adding')
try {
await api.instanceAdd({ name, type: 'remote', endpoint, gatewayPort: gwPort })
overlay.remove()
renderSidebar(sidebarEl)
} catch (e) {
errEl.textContent = e.message || String(e)
btn.disabled = false; btn.textContent = '添加'
btn.disabled = false; btn.textContent = t('common.add')
}
}
}

89
src/lib/i18n.js Normal file
View File

@@ -0,0 +1,89 @@
/**
* i18n 国际化核心模块
* 支持中文(zh-CN)和英文(en),按需扩展
*/
import zhCN from '../locales/zh-CN.json'
import en from '../locales/en.json'
const LANGS = { 'zh-CN': zhCN, en }
const LANG_KEY = 'clawpanel_lang'
const FALLBACK = 'zh-CN'
let _lang = FALLBACK
let _dict = zhCN
let _listeners = []
/**
* 翻译函数
* @param {string} key - 点分隔路径,如 'sidebar.dashboard'
* @param {object} [params] - 插值参数,如 { count: 3 } 替换 {count}
* @returns {string}
*/
export function t(key, params) {
let val = _resolve(_dict, key)
if (val === undefined) {
// fallback 到中文
val = _resolve(zhCN, key)
}
if (val === undefined) return key
if (params) {
for (const [k, v] of Object.entries(params)) {
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
}
}
return val
}
function _resolve(obj, path) {
const parts = path.split('.')
let cur = obj
for (const p of parts) {
if (cur == null || typeof cur !== 'object') return undefined
cur = cur[p]
}
return typeof cur === 'string' ? cur : undefined
}
/** 获取当前语言 */
export function getLang() { return _lang }
/** 获取所有可用语言 */
export function getAvailableLangs() {
return [
{ code: 'zh-CN', label: '简体中文' },
{ code: 'en', label: 'English' },
]
}
/** 切换语言 */
export function setLang(lang) {
if (!LANGS[lang]) return
_lang = lang
_dict = LANGS[lang]
localStorage.setItem(LANG_KEY, lang)
_listeners.forEach(fn => { try { fn(lang) } catch {} })
}
/** 监听语言变化 */
export function onLangChange(fn) {
_listeners.push(fn)
return () => { _listeners = _listeners.filter(cb => cb !== fn) }
}
/** 初始化localStorage > navigator.language > fallback */
export function initI18n() {
const saved = localStorage.getItem(LANG_KEY)
if (saved && LANGS[saved]) {
_lang = saved
_dict = LANGS[saved]
return
}
// 自动检测浏览器语言
const nav = navigator.language || navigator.languages?.[0] || ''
if (nav.startsWith('zh')) {
_lang = 'zh-CN'
} else if (nav.startsWith('en')) {
_lang = 'en'
}
_dict = LANGS[_lang] || zhCN
}

189
src/locales/en.json Normal file
View File

@@ -0,0 +1,189 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"close": "Close",
"loading": "Loading...",
"retry": "Retry",
"copy": "Copy",
"copied": "Copied",
"search": "Search",
"refresh": "Refresh",
"back": "Back",
"submit": "Submit",
"reset": "Reset",
"enabled": "Enabled",
"disabled": "Disabled",
"unknown": "Unknown",
"none": "None",
"yes": "Yes",
"no": "No",
"online": "Online",
"offline": "Offline",
"running": "Running",
"stopped": "Stopped",
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Info",
"loadFailed": "Load failed",
"saveFailed": "Save failed",
"saveSuccess": "Saved successfully",
"operationFailed": "Operation failed",
"operationSuccess": "Operation succeeded",
"noData": "No data",
"unit": "",
"survivalRate": "Uptime"
},
"sidebar": {
"collapse": "Collapse / Expand",
"closeMenu": "Close menu",
"themeLight": "Light Mode",
"themeDark": "Dark Mode",
"sectionMonitor": "Monitor",
"sectionConfig": "Config",
"sectionData": "Data",
"sectionExtension": "Extensions",
"dashboard": "Dashboard",
"assistant": "Assistant",
"chat": "Live Chat",
"services": "Services",
"logs": "Logs",
"models": "Models",
"agents": "Agents",
"gateway": "Gateway",
"channels": "Channels",
"communication": "Communication",
"security": "Security",
"memory": "Memory",
"cron": "Cron Jobs",
"usage": "Usage",
"skills": "Skills",
"settings": "Settings",
"chatDebug": "Diagnostics",
"about": "About",
"setup": "Setup"
},
"instance": {
"local": "Local",
"remote": "Remote",
"docker": "Docker",
"switchHint": "After switching, Models, Agents and other pages will manage the selected instance",
"addInstance": "Add Instance",
"addRemote": "Add Remote Instance",
"namePlaceholder": "Remote Server",
"endpointPlaceholder": "http://192.168.1.100:1420",
"nameLabel": "Name",
"endpointLabel": "Panel Address",
"gwPortLabel": "Gateway Port (optional)",
"nameRequired": "Please fill in name and endpoint",
"endpointExists": "This endpoint already exists",
"adding": "Adding...",
"switchedTo": "Switched to {name} — Models, Agents, etc. will manage this instance",
"current": "Active",
"remoteHint": "The remote server must be running ClawPanel (serve.js).",
"example": "Example"
},
"dashboard": {
"title": "Dashboard",
"desc": "OpenClaw runtime status overview",
"gateway": "Gateway",
"portDetect": "Port detection",
"notStarted": "Not started",
"versionLabel": "Version",
"versionOfficial": "Official",
"versionChinese": "Chinese",
"versionUnknown": "Version info unavailable",
"versionAhead": "Current version is ahead of recommended stable {version}, may be unstable",
"versionStable": "Stable {version}",
"versionRecommend": "Recommended stable {version}",
"versionLatest": "Latest upstream {version}",
"agentFleet": "Agent Fleet",
"defaultAgent": "Default",
"modelPool": "Model Pool",
"basedOnProviders": "From {count} providers",
"baseServices": "Services",
"controlUI": "Control UI",
"controlUIDesc": "OpenClaw native panel",
"controlUIClick": "Click to open in browser",
"controlUINotRunning": "Gateway not running",
"restartGw": "Restart Gateway",
"checkUpdate": "Check Updates",
"createBackup": "Create Backup",
"recentLogs": "Recent Logs",
"cliPath": "CLI Path",
"cliSource": "Install Source",
"cliSourceStandalone": "Standalone",
"cliSourceNpmZh": "npm (Chinese)",
"cliSourceNpmOfficial": "npm (Official)",
"cliSourceNpmGlobal": "npm (Global)",
"cliSourceUnknown": "Unknown",
"multiInstall": "Multiple installations detected",
"multiInstallHint": "Choose which one to use in Settings",
"installCount": "{count} installations"
},
"services": {
"title": "Services",
"desc": "Manage OpenClaw Gateway and related services",
"start": "Start",
"stop": "Stop",
"restart": "Restart",
"install": "Install Service",
"uninstall": "Uninstall Service"
},
"settings": {
"title": "Settings",
"desc": "Manage ClawPanel network, proxy and download source settings",
"networkProxy": "Network Proxy",
"modelProxy": "Model Request Proxy",
"npmRegistry": "npm Registry",
"openclawDir": "OpenClaw Install Path",
"openclawCli": "OpenClaw CLI Binding",
"cliAutoDetect": "Auto-detect (Recommended)",
"cliBindHint": "Select which OpenClaw CLI the panel should use, useful when multiple versions coexist",
"cliCurrent": "Currently used",
"cliBound": "Bound",
"cliActive": "Active",
"cliVersion": "Version",
"cliSwitchConfirm": "Switch to this CLI? The panel will use this installation for all operations.",
"language": "Language",
"languageHint": "Switch the interface language. Some content may remain in the original language."
},
"models": {
"title": "Models",
"desc": "Manage AI model providers and model lists"
},
"agents": {
"title": "Agents",
"desc": "Manage AI Agent role configurations"
},
"channels": {
"title": "Channels",
"desc": "Manage messaging channels"
},
"chat": {
"title": "Live Chat",
"desc": "Chat with AI Agents in real-time"
},
"setup": {
"title": "Setup",
"desc": "Install and configure OpenClaw"
},
"about": {
"title": "About",
"desc": "ClawPanel version and project info"
},
"toast": {
"copySuccess": "Copied to clipboard",
"copyFailed": "Copy failed"
},
"modal": {
"confirmTitle": "Confirm",
"confirmOk": "OK",
"confirmCancel": "Cancel"
}
}

189
src/locales/zh-CN.json Normal file
View File

@@ -0,0 +1,189 @@
{
"common": {
"save": "保存",
"cancel": "取消",
"confirm": "确认",
"delete": "删除",
"edit": "编辑",
"add": "添加",
"close": "关闭",
"loading": "加载中...",
"retry": "重试",
"copy": "复制",
"copied": "已复制",
"search": "搜索",
"refresh": "刷新",
"back": "返回",
"submit": "提交",
"reset": "重置",
"enabled": "已启用",
"disabled": "已禁用",
"unknown": "未知",
"none": "无",
"yes": "是",
"no": "否",
"online": "在线",
"offline": "离线",
"running": "运行中",
"stopped": "已停止",
"error": "错误",
"success": "成功",
"warning": "警告",
"info": "提示",
"loadFailed": "加载失败",
"saveFailed": "保存失败",
"saveSuccess": "保存成功",
"operationFailed": "操作失败",
"operationSuccess": "操作成功",
"noData": "暂无数据",
"unit": "个",
"survivalRate": "存活率"
},
"sidebar": {
"collapse": "折叠/展开",
"closeMenu": "关闭菜单",
"themeLight": "日间模式",
"themeDark": "夜间模式",
"sectionMonitor": "监控",
"sectionConfig": "配置",
"sectionData": "数据",
"sectionExtension": "扩展",
"dashboard": "仪表盘",
"assistant": "晴辰助手",
"chat": "实时聊天",
"services": "服务管理",
"logs": "日志查看",
"models": "模型配置",
"agents": "Agent 管理",
"gateway": "Gateway",
"channels": "消息渠道",
"communication": "通信与自动化",
"security": "安全设置",
"memory": "记忆文件",
"cron": "定时任务",
"usage": "使用情况",
"skills": "Skills",
"settings": "面板设置",
"chatDebug": "系统诊断",
"about": "关于",
"setup": "初始设置"
},
"instance": {
"local": "本机",
"remote": "远程",
"docker": "Docker",
"switchHint": "切换后模型配置、Agent 等页面将管理对应实例",
"addInstance": "添加实例",
"addRemote": "添加远程实例",
"namePlaceholder": "远程服务器",
"endpointPlaceholder": "http://192.168.1.100:1420",
"nameLabel": "\u540d\u79f0",
"endpointLabel": "\u9762\u677f\u5730\u5740",
"gwPortLabel": "Gateway \u7aef\u53e3\uff08\u53ef\u9009\uff09",
"nameRequired": "请填写名称和面板地址",
"endpointExists": "该端点已存在",
"adding": "添加中...",
"switchedTo": "已切换到 {name} — 模型配置、Agent 等将管理该实例",
"current": "当前",
"remoteHint": "远程服务器需要运行 ClawPanel (serve.js)。",
"example": "示例"
},
"dashboard": {
"title": "仪表盘",
"desc": "OpenClaw 运行状态概览",
"gateway": "Gateway",
"portDetect": "端口检测",
"notStarted": "未启动",
"versionLabel": "版本",
"versionOfficial": "官方",
"versionChinese": "汉化",
"versionUnknown": "版本信息未获取",
"versionAhead": "当前版本高于推荐稳定版 {version},可能不稳定",
"versionStable": "稳定版 {version}",
"versionRecommend": "推荐稳定版 {version}",
"versionLatest": "最新上游 {version}",
"agentFleet": "Agent 舰队",
"defaultAgent": "默认",
"modelPool": "模型池",
"basedOnProviders": "基于 {count} 个渠道商",
"baseServices": "基础服务",
"controlUI": "Control UI",
"controlUIDesc": "OpenClaw 原生面板",
"controlUIClick": "点击打开浏览器",
"controlUINotRunning": "Gateway 未运行",
"restartGw": "重启 Gateway",
"checkUpdate": "检查更新",
"createBackup": "创建备份",
"recentLogs": "最近日志",
"cliPath": "CLI 路径",
"cliSource": "安装来源",
"cliSourceStandalone": "独立安装版",
"cliSourceNpmZh": "npm 汉化版",
"cliSourceNpmOfficial": "npm 官方版",
"cliSourceNpmGlobal": "npm 全局",
"cliSourceUnknown": "未知来源",
"multiInstall": "检测到多个安装",
"multiInstallHint": "在「面板设置」中可选择使用哪个",
"installCount": "{count} 个安装"
},
"services": {
"title": "服务管理",
"desc": "管理 OpenClaw Gateway 和相关服务",
"start": "启动",
"stop": "停止",
"restart": "重启",
"install": "安装服务",
"uninstall": "卸载服务"
},
"settings": {
"title": "面板设置",
"desc": "管理 ClawPanel 的网络、代理和下载源配置",
"networkProxy": "网络代理",
"modelProxy": "模型请求代理",
"npmRegistry": "npm 源设置",
"openclawDir": "OpenClaw 安装路径",
"openclawCli": "OpenClaw CLI 绑定",
"cliAutoDetect": "自动检测(推荐)",
"cliBindHint": "选择面板实际使用的 OpenClaw CLI适用于多版本共存场景",
"cliCurrent": "当前使用",
"cliBound": "已绑定",
"cliActive": "活跃",
"cliVersion": "版本",
"cliSwitchConfirm": "确定切换到此 CLI 吗?切换后面板将使用该安装进行所有操作。",
"language": "界面语言",
"languageHint": "切换界面显示语言,部分内容可能仍为中文"
},
"models": {
"title": "模型配置",
"desc": "管理 AI 模型供应商和模型列表"
},
"agents": {
"title": "Agent 管理",
"desc": "管理 AI Agent 角色配置"
},
"channels": {
"title": "消息渠道",
"desc": "管理消息接入渠道"
},
"chat": {
"title": "实时聊天",
"desc": "与 AI Agent 实时对话"
},
"setup": {
"title": "初始设置",
"desc": "安装和配置 OpenClaw"
},
"about": {
"title": "关于",
"desc": "ClawPanel 版本和项目信息"
},
"toast": {
"copySuccess": "已复制到剪贴板",
"copyFailed": "复制失败"
},
"modal": {
"confirmTitle": "确认操作",
"confirmOk": "确认",
"confirmCancel": "取消"
}
}

View File

@@ -14,6 +14,7 @@ import { api, checkBackendHealth, isBackendOnline, onBackendStatusChange } from
import { version as APP_VERSION } from '../package.json'
import { statusIcon } from './lib/icons.js'
import { tryShowEngagement } from './components/engagement.js'
import { initI18n } from './lib/i18n.js'
// 样式
import './style/variables.css'
@@ -27,8 +28,9 @@ import './style/debug.css'
import './style/assistant.css'
import './style/ai-drawer.css'
// 初始化主题
// 初始化主题 + 国际化
initTheme()
initI18n()
/** HTML 转义,防止 XSS 注入 */
function escapeHtml(str) {

View File

@@ -5,6 +5,7 @@ import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { onGatewayChange } from '../lib/app-state.js'
import { navigate } from '../router.js'
import { t } from '../lib/i18n.js'
let _unsubGw = null
@@ -14,8 +15,8 @@ export async function render() {
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">仪表盘</h1>
<p class="page-desc">OpenClaw 运行状态概览</p>
<h1 class="page-title">${t('dashboard.title')}</h1>
<p class="page-desc">${t('dashboard.desc')}</p>
</div>
<div class="stat-cards" id="stat-cards">
<div class="stat-card loading-placeholder"></div>
@@ -27,12 +28,12 @@ export async function render() {
</div>
<div id="dashboard-overview-container"></div>
<div class="quick-actions">
<button class="btn btn-secondary" id="btn-restart-gw">重启 Gateway</button>
<button class="btn btn-secondary" id="btn-check-update">检查更新</button>
<button class="btn btn-secondary" id="btn-create-backup">创建备份</button>
<button class="btn btn-secondary" id="btn-restart-gw">${t('dashboard.restartGw')}</button>
<button class="btn btn-secondary" id="btn-check-update">${t('dashboard.checkUpdate')}</button>
<button class="btn btn-secondary" id="btn-create-backup">${t('dashboard.createBackup')}</button>
</div>
<div class="config-section">
<div class="config-section-title">最近日志</div>
<div class="config-section-title">${t('dashboard.recentLogs')}</div>
<div class="log-viewer" id="recent-logs" style="max-height:300px"></div>
</div>
`
@@ -144,8 +145,13 @@ function renderStatCards(page, services, version, agents, config) {
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
const runningCount = services.filter(s => s.running).length
const versionMeta = version.recommended
? `${version.ahead_of_recommended ? `当前版本高于推荐稳定版 ${version.recommended},可能不稳定` : version.is_recommended ? '稳定版 ' + version.recommended : '推荐稳定版 ' + version.recommended}${version.latest_update_available && version.latest ? ' · 最新上游 ' + version.latest : ''}`
: (version.latest_update_available && version.latest ? '最新上游: ' + version.latest : '版本信息未获取')
? `${version.ahead_of_recommended ? t('dashboard.versionAhead', { version: version.recommended }) : version.is_recommended ? t('dashboard.versionStable', { version: version.recommended }) : t('dashboard.versionRecommend', { version: version.recommended })}${version.latest_update_available && version.latest ? ' · ' + t('dashboard.versionLatest', { version: version.latest }) : ''}`
: (version.latest_update_available && version.latest ? t('dashboard.versionLatest', { version: version.latest }) : t('dashboard.versionUnknown'))
// CLI 路径信息
const cliSourceLabel = { standalone: t('dashboard.cliSourceStandalone'), 'npm-zh': t('dashboard.cliSourceNpmZh'), 'npm-official': t('dashboard.cliSourceNpmOfficial'), 'npm-global': t('dashboard.cliSourceNpmGlobal') }[version.cli_source] || t('dashboard.cliSourceUnknown')
const installCount = version.all_installations?.length || 0
const multiInstall = installCount > 1
const defaultAgent = agents.find(a => a.id === 'main')?.name || 'main'
const modelCount = config?.models?.providers ? Object.values(config.models.providers).reduce((acc, p) => acc + (p.models?.length || 0), 0) : 0
@@ -154,47 +160,48 @@ function renderStatCards(page, services, version, agents, config) {
cardsEl.innerHTML = `
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">Gateway</span>
<span class="stat-card-label">${t('dashboard.gateway')}</span>
<span class="status-dot ${gw?.running ? 'running' : 'stopped'}"></span>
</div>
<div class="stat-card-value">${gw?.running ? '运行中' : '已停止'}</div>
<div class="stat-card-meta">${gw?.pid ? 'PID: ' + gw.pid : (gw?.running ? '端口检测' : '未启动')}</div>
<div class="stat-card-value">${gw?.running ? t('common.running') : t('common.stopped')}</div>
<div class="stat-card-meta">${gw?.pid ? 'PID: ' + gw.pid : (gw?.running ? t('dashboard.portDetect') : t('dashboard.notStarted'))}</div>
</div>
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">版本 · ${version.source === 'official' ? '官方' : '汉化'}</span>
<span class="stat-card-label">${t('dashboard.versionLabel')} · ${version.source === 'official' ? t('dashboard.versionOfficial') : t('dashboard.versionChinese')}</span>
</div>
<div class="stat-card-value">${version.current || '未知'}</div>
<div class="stat-card-value">${version.current || t('common.unknown')}</div>
<div class="stat-card-meta">${versionMeta}</div>
${version.cli_path ? `<div class="stat-card-meta" style="margin-top:2px;font-size:11px;opacity:0.7" title="${escapeHtml(version.cli_path)}">${cliSourceLabel}${multiInstall ? ' · <span style="color:var(--warning)">' + t('dashboard.installCount', { count: installCount }) + '</span>' : ''}</div>` : ''}
</div>
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">Agent 舰队</span>
<span class="stat-card-label">${t('dashboard.agentFleet')}</span>
</div>
<div class="stat-card-value">${agents.length} </div>
<div class="stat-card-meta">默认: ${defaultAgent}</div>
<div class="stat-card-value">${agents.length} ${t('common.unit')}</div>
<div class="stat-card-meta">${t('dashboard.defaultAgent')}: ${defaultAgent}</div>
</div>
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">模型池</span>
<span class="stat-card-label">${t('dashboard.modelPool')}</span>
</div>
<div class="stat-card-value">${modelCount} </div>
<div class="stat-card-meta">基于 ${providerCount} 个渠道商</div>
<div class="stat-card-value">${modelCount} ${t('common.unit')}</div>
<div class="stat-card-meta">${t('dashboard.basedOnProviders', { count: providerCount })}</div>
</div>
<div class="stat-card">
<div class="stat-card-header">
<span class="stat-card-label">基础服务</span>
<span class="stat-card-label">${t('dashboard.baseServices')}</span>
</div>
<div class="stat-card-value">${runningCount}/${services.length}</div>
<div class="stat-card-meta">存活率 ${services.length ? Math.round(runningCount / services.length * 100) : 0}%</div>
<div class="stat-card-meta">${t('common.survivalRate')} ${services.length ? Math.round(runningCount / services.length * 100) : 0}%</div>
</div>
<div class="stat-card stat-card-clickable" id="card-control-ui" title="打开 OpenClaw 原生控制面板">
<div class="stat-card stat-card-clickable" id="card-control-ui" title="${t('dashboard.controlUIDesc')}">
<div class="stat-card-header">
<span class="stat-card-label">Control UI</span>
<span class="stat-card-label">${t('dashboard.controlUI')}</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="opacity:0.5"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</div>
<div class="stat-card-value" style="font-size:var(--font-size-sm)">OpenClaw 原生面板</div>
<div class="stat-card-meta">${gw?.running ? '点击打开浏览器' : 'Gateway 未运行'}</div>
<div class="stat-card-value" style="font-size:var(--font-size-sm)">${t('dashboard.controlUIDesc')}</div>
<div class="stat-card-meta">${gw?.running ? t('dashboard.controlUIClick') : t('dashboard.controlUINotRunning')}</div>
</div>
`
}

View File

@@ -5,6 +5,8 @@
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showConfirm } from '../components/modal.js'
import { t, getLang, setLang, getAvailableLangs, onLangChange } from '../lib/i18n.js'
import { renderSidebar } from '../components/sidebar.js'
const isTauri = !!window.__TAURI_INTERNALS__
@@ -45,10 +47,20 @@ export async function render() {
</div>
<div class="config-section" id="openclaw-dir-section">
<div class="config-section-title">OpenClaw 安装路径</div>
<div class="config-section-title">${t('settings.openclawDir')}</div>
<div id="openclaw-dir-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
</div>
<div class="config-section" id="cli-binding-section">
<div class="config-section-title">${t('settings.openclawCli')}</div>
<div id="cli-binding-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
</div>
<div class="config-section" id="language-section">
<div class="config-section-title">${t('settings.language')}</div>
<div id="language-bar"></div>
</div>
`
bindEvents(page)
@@ -57,9 +69,10 @@ export async function render() {
}
async function loadAll(page) {
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page), loadOpenclawDir(page)]
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page), loadOpenclawDir(page), loadCliBinding(page)]
tasks.push(loadRegistry(page))
await Promise.all(tasks)
loadLanguageSwitcher(page)
}
// ===== 网络代理 =====
@@ -243,6 +256,12 @@ function bindEvents(page) {
case 'reset-openclaw-dir':
await handleResetOpenclawDir(page)
break
case 'bind-cli':
await handleBindCli(page, btn.dataset.path)
break
case 'unbind-cli':
await handleUnbindCli(page)
break
}
} catch (e) {
toast(e.toString(), 'error')
@@ -324,3 +343,109 @@ async function handleSaveRegistry(page) {
await api.setNpmRegistry(registry)
toast('npm 源已保存', 'success')
}
// ===== CLI 绑定 =====
async function loadCliBinding(page) {
const bar = page.querySelector('#cli-binding-bar')
if (!bar) return
try {
const version = await api.getVersionInfo()
const cfg = await api.readPanelConfig()
const boundPath = cfg?.openclawCliPath || ''
const installations = version.all_installations || []
const currentPath = version.cli_path || ''
const sourceLabel = (src) => ({
standalone: t('dashboard.cliSourceStandalone'),
'npm-zh': t('dashboard.cliSourceNpmZh'),
'npm-official': t('dashboard.cliSourceNpmOfficial'),
'npm-global': t('dashboard.cliSourceNpmGlobal'),
})[src] || t('dashboard.cliSourceUnknown')
let html = `<div class="form-hint" style="margin-bottom:var(--space-sm)">${t('settings.cliBindHint')}</div>`
if (currentPath) {
html += `<div style="margin-bottom:var(--space-sm);font-size:var(--font-size-sm)">
<span style="color:var(--text-secondary)">${t('settings.cliCurrent')}:</span>
<code style="font-size:var(--font-size-xs)">${escapeHtml(currentPath)}</code>
${boundPath ? `<span class="clawhub-badge" style="margin-left:var(--space-xs);background:rgba(99,102,241,0.14);color:#6366f1;font-size:var(--font-size-xs)">${t('settings.cliBound')}</span>` : ''}
</div>`
}
if (installations.length > 0) {
html += '<div style="display:flex;flex-direction:column;gap:var(--space-xs)">'
// Auto-detect option
html += `<div style="display:flex;align-items:center;gap:var(--space-sm);padding:6px 10px;border-radius:var(--radius-sm);border:1px solid var(--border);${!boundPath ? 'background:var(--bg-active);border-color:var(--accent)' : ''}">
<span style="flex:1;font-size:var(--font-size-sm)">${t('settings.cliAutoDetect')}</span>
${boundPath ? '<button class="btn btn-secondary btn-xs" data-action="unbind-cli">' + t('common.reset') + '</button>' : '<span style="color:var(--success);font-size:var(--font-size-xs)">✓ ' + t('settings.cliActive') + '</span>'}
</div>`
for (const inst of installations) {
const isActive = inst.active
const isBound = boundPath && inst.path === boundPath
html += `<div style="display:flex;align-items:center;gap:var(--space-sm);padding:6px 10px;border-radius:var(--radius-sm);border:1px solid var(--border);${isBound ? 'background:var(--bg-active);border-color:var(--accent)' : ''}">
<div style="flex:1;min-width:0">
<div style="font-size:var(--font-size-xs);font-family:var(--font-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeHtml(inst.path)}">${escapeHtml(inst.path)}</div>
<div style="font-size:11px;color:var(--text-tertiary)">${sourceLabel(inst.source)}${inst.version ? ' · v' + inst.version : ''}</div>
</div>
${isBound ? '<span style="color:var(--success);font-size:var(--font-size-xs)">✓ ' + t('settings.cliBound') + '</span>' : `<button class="btn btn-secondary btn-xs" data-action="bind-cli" data-path="${escapeHtml(inst.path)}">${t('common.confirm')}</button>`}
</div>`
}
html += '</div>'
} else {
html += `<div style="color:var(--text-tertiary);font-size:var(--font-size-sm)">${t('common.noData')}</div>`
}
bar.innerHTML = html
} catch (e) {
bar.innerHTML = `<div style="color:var(--error)">${t('common.loadFailed')}: ${escapeHtml(String(e))}</div>`
}
}
async function handleBindCli(page, path) {
if (!path) return
const ok = await showConfirm(t('settings.cliSwitchConfirm'))
if (!ok) return
const cfg = await api.readPanelConfig()
cfg.openclawCliPath = path
await api.writePanelConfig(cfg)
toast(t('common.saveSuccess'), 'success')
await loadCliBinding(page)
}
async function handleUnbindCli(page) {
const cfg = await api.readPanelConfig()
delete cfg.openclawCliPath
await api.writePanelConfig(cfg)
toast(t('common.saveSuccess'), 'success')
await loadCliBinding(page)
}
// ===== 语言切换 =====
function loadLanguageSwitcher(page) {
const bar = page.querySelector('#language-bar')
if (!bar) return
const langs = getAvailableLangs()
const current = getLang()
bar.innerHTML = `
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
<select class="form-input" id="lang-select" style="max-width:200px">
${langs.map(l => `<option value="${l.code}" ${l.code === current ? 'selected' : ''}>${l.label}</option>`).join('')}
</select>
</div>
<div class="form-hint" style="margin-top:var(--space-xs)">${t('settings.languageHint')}</div>
`
const select = bar.querySelector('#lang-select')
select.onchange = () => {
setLang(select.value)
// Re-render sidebar + current page
const sidebarEl = document.getElementById('sidebar')
if (sidebarEl) renderSidebar(sidebarEl)
// Re-render settings page
const pageEl = page.closest('.page') || page
render().then(newPage => {
pageEl.replaceWith(newPage)
}).catch(() => {})
}
}