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

@@ -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(() => {})
}
}