mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
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:
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
89
src/lib/i18n.js
Normal 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
189
src/locales/en.json
Normal 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
189
src/locales/zh-CN.json
Normal 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": "取消"
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user