diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 454c914..6eae28f 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -70,54 +70,130 @@ fn get_local_version() -> Option { } /// 从 npm registry 获取最新版本号,超时 5 秒 -async fn get_latest_version() -> Option { +async fn get_latest_version_for(source: &str) -> Option { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(5)) .build() .ok()?; - let resp = client - .get("https://registry.npmjs.org/@qingchencloud%2Fopenclaw-zh/latest") - .send() - .await - .ok()?; + let pkg = npm_package_name(source).replace('/', "%2F").replace('@', "%40"); + let url = format!("https://registry.npmjs.org/{pkg}/latest"); + let resp = client.get(&url).send().await.ok()?; let json: Value = resp.json().await.ok()?; json.get("version") .and_then(|v| v.as_str()) .map(String::from) } +/// 检测当前安装的是官方版还是汉化版 +fn detect_installed_source() -> String { + let output = Command::new("npm") + .args(["list", "-g", "@qingchencloud/openclaw-zh", "--depth=0"]) + .output(); + if let Ok(o) = output { + let text = String::from_utf8_lossy(&o.stdout); + if text.contains("openclaw-zh@") { + return "chinese".into(); + } + } + "official".into() +} + #[tauri::command] pub async fn get_version_info() -> Result { let current = get_local_version(); - let latest = get_latest_version().await; + let source = detect_installed_source(); + let latest = get_latest_version_for(&source).await; + let parse_ver = |v: &str| -> Vec { + v.split(|c: char| !c.is_ascii_digit()) + .filter_map(|s| s.parse().ok()) + .collect() + }; let update_available = match (¤t, &latest) { - (Some(c), Some(l)) => l != c, + (Some(c), Some(l)) => parse_ver(l) > parse_ver(c), _ => false, }; Ok(VersionInfo { current, latest, update_available, + source, }) } -/// 执行 npm 全局升级 openclaw +/// npm 包名映射 +fn npm_package_name(source: &str) -> &'static str { + match source { + "official" => "openclaw", + _ => "@qingchencloud/openclaw-zh", + } +} + +/// 执行 npm 全局升级 openclaw(流式推送日志) #[tauri::command] -pub async fn upgrade_openclaw() -> Result { - let output = Command::new("npm") - .args(["install", "-g", "@qingchencloud/openclaw-zh@latest"]) - .output() - .map_err(|e| format!("执行升级命令失败: {e}"))?; +pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result { + use std::process::Stdio; + use std::io::{BufRead, BufReader}; + use tauri::Emitter; - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let current_source = detect_installed_source(); + let pkg = format!("{}@latest", npm_package_name(&source)); - if !output.status.success() { - return Err(format!("升级失败: {stderr}")); + // 切换源时先卸载旧包,避免 bin 冲突 + if current_source != source { + let old_pkg = npm_package_name(¤t_source); + let _ = app.emit("upgrade-log", format!("正在卸载旧版本 ({old_pkg})...")); + let _ = app.emit("upgrade-progress", 5); + let _ = Command::new("npm") + .args(["uninstall", "-g", old_pkg]) + .output(); + } + + let _ = app.emit("upgrade-log", format!("$ npm install -g {pkg}")); + let _ = app.emit("upgrade-progress", 10); + + let mut child = Command::new("npm") + .args(["install", "-g", &pkg]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("执行升级命令失败: {e}"))?; + + // 读取 stderr(npm 主要输出在 stderr) + let stderr = child.stderr.take(); + let stdout = child.stdout.take(); + + let _ = app.emit("upgrade-progress", 30); + + let app2 = app.clone(); + let handle = std::thread::spawn(move || { + if let Some(pipe) = stderr { + for line in BufReader::new(pipe).lines().map_while(Result::ok) { + let _ = app2.emit("upgrade-log", &line); + } + } + }); + + if let Some(pipe) = stdout { + for line in BufReader::new(pipe).lines().map_while(Result::ok) { + let _ = app.emit("upgrade-log", &line); + } + } + + let _ = handle.join(); + let _ = app.emit("upgrade-progress", 80); + + let status = child.wait().map_err(|e| format!("等待进程失败: {e}"))?; + let _ = app.emit("upgrade-progress", 100); + + if !status.success() { + let _ = app.emit("upgrade-log", "❌ 升级失败"); + return Err("升级失败,请查看日志".into()); } - // 获取升级后的版本号 let new_ver = get_local_version().unwrap_or_else(|| "未知".into()); - Ok(format!("升级成功,当前版本: {new_ver}")) + let msg = format!("✅ 升级成功,当前版本: {new_ver}"); + let _ = app.emit("upgrade-log", &msg); + Ok(msg) } #[tauri::command] diff --git a/src-tauri/src/models/types.rs b/src-tauri/src/models/types.rs index 9946452..b4e5e4f 100644 --- a/src-tauri/src/models/types.rs +++ b/src-tauri/src/models/types.rs @@ -13,4 +13,5 @@ pub struct VersionInfo { pub current: Option, pub latest: Option, pub update_available: bool, + pub source: String, } diff --git a/src/components/modal.js b/src/components/modal.js index 8a4b735..bc7e9ad 100644 --- a/src/components/modal.js +++ b/src/components/modal.js @@ -134,3 +134,63 @@ export function showModal({ title, fields, onConfirm }) { const firstInput = overlay.querySelector('input, select') if (firstInput) firstInput.focus() } + +/** + * 升级进度弹窗 — 带进度条和实时日志 + * @returns {{ appendLog, setProgress, setDone, setError, destroy }} + */ +export function showUpgradeModal() { + const overlay = document.createElement('div') + overlay.className = 'modal-overlay' + overlay.innerHTML = ` + + ` + document.body.appendChild(overlay) + + const fill = overlay.querySelector('.upgrade-progress-fill') + const text = overlay.querySelector('.upgrade-progress-text') + const logBox = overlay.querySelector('.upgrade-log-box') + const closeBtn = overlay.querySelector('[data-action="close"]') + + closeBtn.onclick = () => overlay.remove() + overlay.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !closeBtn.disabled) overlay.remove() + }) + + return { + appendLog(line) { + const div = document.createElement('div') + div.textContent = line + logBox.appendChild(div) + logBox.scrollTop = logBox.scrollHeight + }, + setProgress(pct) { + fill.style.width = pct + '%' + text.textContent = pct >= 100 ? '完成' : `升级中... ${pct}%` + }, + setDone(msg) { + text.textContent = msg || '升级完成' + fill.style.width = '100%' + fill.classList.add('done') + closeBtn.disabled = false + closeBtn.focus() + }, + setError(msg) { + text.textContent = msg || '升级失败' + fill.classList.add('error') + closeBtn.disabled = false + closeBtn.focus() + }, + destroy() { overlay.remove() }, + } +} diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index df45b9f..ada0765 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -141,7 +141,7 @@ export const api = { readMcpConfig: () => invoke('read_mcp_config'), writeMcpConfig: (config) => invoke('write_mcp_config', { config }), reloadGateway: () => invoke('reload_gateway'), - upgradeOpenclaw: () => invoke('upgrade_openclaw'), + upgradeOpenclaw: (source = 'chinese') => invoke('upgrade_openclaw', { source }), installGateway: () => invoke('install_gateway'), uninstallGateway: () => invoke('uninstall_gateway'), testModel: (baseUrl, apiKey, modelId) => invoke('test_model', { baseUrl, apiKey, modelId }), diff --git a/src/pages/about.js b/src/pages/about.js index e934639..1844f3b 100644 --- a/src/pages/about.js +++ b/src/pages/about.js @@ -4,6 +4,7 @@ */ import { api } from '../lib/tauri-api.js' import { toast } from '../components/toast.js' +import { showUpgradeModal } from '../components/modal.js' export async function render() { const page = document.createElement('div') @@ -71,7 +72,7 @@ async function loadData(page) {
Tauri v2 桌面应用
-
OpenClaw
+
OpenClaw · ${version.source === 'official' ? '官方版' : '汉化版'}
${version.current || '未安装'}
${version.update_available @@ -90,17 +91,21 @@ async function loadData(page) { const upgradeBtn = cards.querySelector('#btn-upgrade') if (upgradeBtn) { upgradeBtn.onclick = async () => { - upgradeBtn.disabled = true - upgradeBtn.textContent = '升级中...' + const modal = showUpgradeModal() + let unlistenLog, unlistenProgress try { + const { listen } = await import('@tauri-apps/api/event') + unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload)) + unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload)) const msg = await api.upgradeOpenclaw() - upgradeBtn.textContent = '完成' - // 刷新版本信息 + modal.setDone(msg) loadData(page) } catch (e) { - upgradeBtn.disabled = false - upgradeBtn.textContent = '升级失败' - toast('升级失败: ' + e, 'error') + modal.appendLog(String(e)) + modal.setError('升级失败') + } finally { + unlistenLog?.() + unlistenProgress?.() } } } diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index d39be13..dfd3bca 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -70,7 +70,7 @@ function renderStatCards(page, services, version) {
- 版本 + 版本 · ${version.source === 'official' ? '官方' : '汉化'}
${version.current || '未知'}
${version.update_available ? '有新版本: ' + version.latest : '已是最新'}
diff --git a/src/pages/services.js b/src/pages/services.js index 79d4251..44c4002 100644 --- a/src/pages/services.js +++ b/src/pages/services.js @@ -4,7 +4,7 @@ */ import { api } from '../lib/tauri-api.js' import { toast } from '../components/toast.js' -import { showConfirm } from '../components/modal.js' +import { showConfirm, showUpgradeModal } from '../components/modal.js' // HTML 转义,防止 XSS function escapeHtml(str) { @@ -51,21 +51,32 @@ async function loadAll(page) { // ===== 版本检测 ===== +// 后端检测到的当前安装源 +let detectedSource = 'chinese' + async function loadVersion(page) { const bar = page.querySelector('#version-bar') try { const info = await api.getVersionInfo() + detectedSource = info.source || 'chinese' const ver = info.current || '未知' const hasUpdate = info.update_available + const isChinese = detectedSource === 'chinese' + const sourceTag = isChinese ? '汉化优化版' : '官方原版' + const switchLabel = isChinese ? '切换到官方版' : '切换到汉化版' + const switchTarget = isChinese ? 'official' : 'chinese' bar.innerHTML = `
- 当前版本 + 当前版本 · ${sourceTag}
${ver}
${hasUpdate ? '新版本: ' + info.latest : '已是最新版本'}
- ${hasUpdate ? '' : ''} +
+ ${hasUpdate ? '' : ''} + +
` @@ -194,6 +205,9 @@ function bindEvents(page) { case 'upgrade': await handleUpgrade(btn, page) break + case 'switch-source': + await handleSwitchSource(btn.dataset.source, page) + break case 'install-gateway': await handleInstallGateway(btn, page) break @@ -246,13 +260,37 @@ async function handleDeleteBackup(name, page) { // ===== 升级操作 ===== +async function doUpgradeWithModal(source, page) { + const modal = showUpgradeModal() + let unlistenLog, unlistenProgress + try { + const { listen } = await import('@tauri-apps/api/event') + unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload)) + unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload)) + const msg = await api.upgradeOpenclaw(source) + modal.setDone(msg) + await loadVersion(page) + } catch (e) { + modal.appendLog(String(e)) + modal.setError('升级失败') + } finally { + unlistenLog?.() + unlistenProgress?.() + } +} + async function handleUpgrade(btn, page) { - const yes = await showConfirm('确定要升级 OpenClaw 到最新版本吗?\n升级过程中 Gateway 会短暂中断。') + const sourceLabel = detectedSource === 'official' ? '官方原版' : '汉化优化版' + const yes = await showConfirm(`确定要升级 OpenClaw 到最新${sourceLabel}吗?\n升级过程中 Gateway 会短暂中断。`) if (!yes) return - btn.textContent = '升级中...' - const msg = await api.upgradeOpenclaw() - toast(msg, 'success') - await loadVersion(page) + await doUpgradeWithModal(detectedSource, page) +} + +async function handleSwitchSource(target, page) { + const targetLabel = target === 'official' ? '官方原版' : '汉化优化版' + const yes = await showConfirm(`确定要切换到${targetLabel}吗?\n这会安装对应的 npm 包,配置数据不受影响。`) + if (!yes) return + await doUpgradeWithModal(target, page) } // ===== Gateway 安装/卸载 ===== diff --git a/src/style/pages.css b/src/style/pages.css index 885fad0..25cb58c 100644 --- a/src/style/pages.css +++ b/src/style/pages.css @@ -173,3 +173,45 @@ padding: var(--space-sm) var(--space-lg); border-bottom: 1px solid var(--border-secondary); } + +/* 升级进度弹窗 */ +.upgrade-progress-wrap { + margin-bottom: var(--space-md); +} + +.upgrade-progress-bar { + height: 6px; + background: var(--bg-tertiary); + border-radius: 3px; + overflow: hidden; + margin-bottom: var(--space-xs); +} + +.upgrade-progress-fill { + height: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 0.4s ease; +} + +.upgrade-progress-fill.done { background: var(--success); } +.upgrade-progress-fill.error { background: var(--error); } + +.upgrade-progress-text { + font-size: var(--font-size-xs); + color: var(--text-secondary); +} + +.upgrade-log-box { + max-height: 220px; + overflow-y: auto; + background: var(--bg-tertiary); + border-radius: var(--radius-md); + padding: var(--space-sm) var(--space-md); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.6; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-all; +}