From 5dc59dc7f8f70039ece7cb552ebc5f33fc7f9ce6 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 7 Oct 2025 19:06:27 +0800 Subject: [PATCH] - merge: merge origin/main, resolve conflicts and preserve both feature sets - feat(tauri): register import/export and file dialogs; keep endpoint speed test and custom endpoints - feat(api): add updateTrayMenu and onProviderSwitched; wire import/export APIs - feat(types): extend global API declarations (import/export) - chore(presets): GLM preset supports both new and legacy model keys - chore(rust): add chrono dependency; refresh lockfile --- .node-version | 1 + src-tauri/Cargo.lock | 3 + src-tauri/Cargo.toml | 1 + src-tauri/src/import_export.rs | 170 +++++++++++++++++++++++++ src-tauri/src/lib.rs | 7 + src/App.tsx | 14 +- src/components/ImportProgressModal.tsx | 103 +++++++++++++++ src/components/ProviderList.tsx | 2 +- src/components/SettingsModal.tsx | 143 ++++++++++++++++++++- src/config/providerPresets.ts | 8 +- src/i18n/locales/en.json | 13 ++ src/i18n/locales/zh.json | 13 ++ src/lib/tauri-api.ts | 140 +++++++++++--------- src/vite-env.d.ts | 12 ++ 14 files changed, 565 insertions(+), 65 deletions(-) create mode 100644 .node-version create mode 100644 src-tauri/src/import_export.rs create mode 100644 src/components/ImportProgressModal.tsx diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..adb0705 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +v22.4.1 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e7b011c..823e265 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -565,6 +565,7 @@ dependencies = [ name = "cc-switch" version = "3.4.0" dependencies = [ + "chrono", "dirs 5.0.1", "futures", "log", @@ -631,8 +632,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.0", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 89b39f7..91a8620 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,6 +21,7 @@ tauri-build = { version = "2.4.0", features = [] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" +chrono = "0.4" tauri = { version = "2.8.2", features = ["tray-icon"] } tauri-plugin-log = "2" tauri-plugin-opener = "2" diff --git a/src-tauri/src/import_export.rs b/src-tauri/src/import_export.rs new file mode 100644 index 0000000..6f500b3 --- /dev/null +++ b/src-tauri/src/import_export.rs @@ -0,0 +1,170 @@ +use chrono::Utc; +use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; + +// 默认仅保留最近 10 份备份,避免目录无限膨胀 +const MAX_BACKUPS: usize = 10; + +/// 创建配置文件备份 +pub fn create_backup(config_path: &PathBuf) -> Result { + if !config_path.exists() { + return Ok(String::new()); + } + + let timestamp = Utc::now().format("%Y%m%d_%H%M%S"); + let backup_id = format!("backup_{}", timestamp); + + let backup_dir = config_path + .parent() + .ok_or("Invalid config path")? + .join("backups"); + + // 创建备份目录 + fs::create_dir_all(&backup_dir) + .map_err(|e| format!("Failed to create backup directory: {}", e))?; + + let backup_path = backup_dir.join(format!("{}.json", backup_id)); + + // 复制配置文件到备份 + fs::copy(config_path, backup_path).map_err(|e| format!("Failed to create backup: {}", e))?; + + // 备份完成后清理旧的备份文件(仅保留最近 MAX_BACKUPS 份) + cleanup_old_backups(&backup_dir, MAX_BACKUPS)?; + + Ok(backup_id) +} + +fn cleanup_old_backups(backup_dir: &PathBuf, retain: usize) -> Result<(), String> { + if retain == 0 { + return Ok(()); + } + + let mut entries: Vec<_> = match fs::read_dir(backup_dir) { + Ok(iter) => iter + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .path() + .extension() + .map(|ext| ext == "json") + .unwrap_or(false) + }) + .collect(), + Err(_) => return Ok(()), + }; + + if entries.len() <= retain { + return Ok(()); + } + + let remove_count = entries.len().saturating_sub(retain); + + entries.sort_by(|a, b| { + let a_time = a.metadata().and_then(|m| m.modified()).ok(); + let b_time = b.metadata().and_then(|m| m.modified()).ok(); + a_time.cmp(&b_time) + }); + + for entry in entries.into_iter().take(remove_count) { + if let Err(err) = fs::remove_file(entry.path()) { + log::warn!( + "Failed to remove old backup {}: {}", + entry.path().display(), + err + ); + } + } + + Ok(()) +} + +/// 导出配置文件 +#[tauri::command] +pub async fn export_config_to_file(file_path: String) -> Result { + // 读取当前配置文件 + let config_path = crate::config::get_app_config_path(); + let config_content = fs::read_to_string(&config_path) + .map_err(|e| format!("Failed to read configuration: {}", e))?; + + // 写入到指定文件 + fs::write(&file_path, &config_content).map_err(|e| format!("Failed to write file: {}", e))?; + + Ok(json!({ + "success": true, + "message": "Configuration exported successfully", + "filePath": file_path + })) +} + +/// 从文件导入配置 +#[tauri::command] +pub async fn import_config_from_file( + file_path: String, + state: tauri::State<'_, crate::store::AppState>, +) -> Result { + // 读取导入的文件 + let import_content = + fs::read_to_string(&file_path).map_err(|e| format!("Failed to read import file: {}", e))?; + + // 验证并解析为配置对象 + let new_config: crate::app_config::MultiAppConfig = serde_json::from_str(&import_content) + .map_err(|e| format!("Invalid configuration file: {}", e))?; + + // 备份当前配置 + let config_path = crate::config::get_app_config_path(); + let backup_id = create_backup(&config_path)?; + + // 写入新配置到磁盘 + fs::write(&config_path, &import_content) + .map_err(|e| format!("Failed to write configuration: {}", e))?; + + // 更新内存中的状态 + { + let mut config_state = state + .config + .lock() + .map_err(|e| format!("Failed to lock config: {}", e))?; + *config_state = new_config; + } + + Ok(json!({ + "success": true, + "message": "Configuration imported successfully", + "backupId": backup_id + })) +} + +/// 保存文件对话框 +#[tauri::command] +pub async fn save_file_dialog( + app: tauri::AppHandle, + default_name: String, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + + let dialog = app.dialog(); + let result = dialog + .file() + .add_filter("JSON", &["json"]) + .set_file_name(&default_name) + .blocking_save_file(); + + Ok(result.map(|p| p.to_string())) +} + +/// 打开文件对话框 +#[tauri::command] +pub async fn open_file_dialog( + app: tauri::AppHandle, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + + let dialog = app.dialog(); + let result = dialog + .file() + .add_filter("JSON", &["json"]) + .blocking_pick_file(); + + Ok(result.map(|p| p.to_string())) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6c838eb..2454fa8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod claude_plugin; mod codex_config; mod commands; mod config; +mod import_export; mod migration; mod provider; mod settings; @@ -420,11 +421,17 @@ pub fn run() { commands::read_claude_plugin_config, commands::apply_claude_plugin_config, commands::is_claude_plugin_applied, + // ours: endpoint speed test + custom endpoint management commands::test_api_endpoints, commands::get_custom_endpoints, commands::add_custom_endpoint, commands::remove_custom_endpoint, commands::update_endpoint_last_used, + // theirs: config import/export and dialogs + import_export::export_config_to_file, + import_export::import_config_from_file, + import_export::save_file_dialog, + import_export::open_file_dialog, update_tray_menu, ]); diff --git a/src/App.tsx b/src/App.tsx index a900ffd..8d5f4a6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -229,6 +229,15 @@ function App() { } }; + const handleImportSuccess = async () => { + await loadProviders(); + try { + await window.api.updateTrayMenu(); + } catch (error) { + console.error("[App] Failed to refresh tray menu after import", error); + } + }; + // 自动从 live 导入一条默认供应商(仅首次初始化时) const handleAutoImportDefault = async () => { try { @@ -357,7 +366,10 @@ function App() { )} {isSettingsOpen && ( - setIsSettingsOpen(false)} /> + setIsSettingsOpen(false)} + onImportSuccess={handleImportSuccess} + /> )} ); diff --git a/src/components/ImportProgressModal.tsx b/src/components/ImportProgressModal.tsx new file mode 100644 index 0000000..d6ad7ab --- /dev/null +++ b/src/components/ImportProgressModal.tsx @@ -0,0 +1,103 @@ +import { useEffect } from "react"; +import { CheckCircle, Loader2, AlertCircle } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +interface ImportProgressModalProps { + status: 'importing' | 'success' | 'error'; + message?: string; + backupId?: string; + onComplete?: () => void; + onSuccess?: () => void; +} + +export function ImportProgressModal({ + status, + message, + backupId, + onComplete, + onSuccess +}: ImportProgressModalProps) { + const { t } = useTranslation(); + + useEffect(() => { + if (status === 'success') { + console.log('[ImportProgressModal] Success detected, starting 2 second countdown'); + // 成功后等待2秒自动关闭并刷新数据 + const timer = setTimeout(() => { + console.log('[ImportProgressModal] 2 seconds elapsed, calling callbacks...'); + if (onSuccess) { + onSuccess(); + } + if (onComplete) { + onComplete(); + } + }, 2000); + + return () => { + console.log('[ImportProgressModal] Cleanup timer'); + clearTimeout(timer); + }; + } + }, [status, onComplete, onSuccess]); + + return ( +
+
+ +
+
+ {status === 'importing' && ( + <> + +

+ {t("settings.importing")} +

+

+ {t("common.loading")} +

+ + )} + + {status === 'success' && ( + <> + +

+ {t("settings.importSuccess")} +

+ {backupId && ( +

+ {t("settings.backupId")}: {backupId} +

+ )} +

+ {t("settings.autoReload")} +

+ + )} + + {status === 'error' && ( + <> + +

+ {t("settings.importFailed")} +

+

+ {message || t("settings.configCorrupted")} +

+ + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/ProviderList.tsx b/src/components/ProviderList.tsx index cd187d5..290c674 100644 --- a/src/components/ProviderList.tsx +++ b/src/components/ProviderList.tsx @@ -199,7 +199,7 @@ const ProviderList: React.FC = ({
{appType === "claude" ? ( -
+
{provider.category !== "official" && isCurrent && ( + + {/* 导入区域 */} +
+
+ + +
+ + {/* 显示选择的文件 */} + {selectedImportFile && ( +
+ {selectedImportFile.split('/').pop() || selectedImportFile.split('\\').pop() || selectedImportFile} +
+ )} +
+
+
+
+ {/* 关于 */}

@@ -636,6 +755,28 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {

+ + {/* Import Progress Modal */} + {importStatus !== 'idle' && ( + { + setImportStatus('idle'); + setImportError(''); + setSelectedImportFile(''); + }} + onSuccess={() => { + if (onImportSuccess) { + void onImportSuccess(); + } + void window.api + .updateTrayMenu() + .catch((error) => console.error("[SettingsModal] Failed to refresh tray menu", error)); + }} + /> + )} ); } diff --git a/src/config/providerPresets.ts b/src/config/providerPresets.ts index 225584d..99d4091 100644 --- a/src/config/providerPresets.ts +++ b/src/config/providerPresets.ts @@ -54,8 +54,12 @@ export const providerPresets: ProviderPreset[] = [ env: { ANTHROPIC_BASE_URL: "https://open.bigmodel.cn/api/anthropic", ANTHROPIC_AUTH_TOKEN: "", - ANTHROPIC_MODEL: "GLM-4.5", - ANTHROPIC_SMALL_FAST_MODEL: "GLM-4.5-Air", + // 兼容旧键名,保持前端读取一致 + ANTHROPIC_MODEL: "GLM-4.6", + ANTHROPIC_SMALL_FAST_MODEL: "glm-4.5-air", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-4.5-air", + ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-4.6", + ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-4.6", }, }, category: "cn_official", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 71bd97c..4b86198 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -61,6 +61,19 @@ "title": "Settings", "general": "General", "language": "Language", + "importExport": "Import/Export Config", + "exportConfig": "Export Config to File", + "selectConfigFile": "Select Config File", + "import": "Import", + "importing": "Importing...", + "importSuccess": "Import Successful!", + "importFailed": "Import Failed", + "configExported": "Config exported to:", + "exportFailed": "Export failed", + "selectFileFailed": "Failed to select file", + "configCorrupted": "Config file may be corrupted or invalid", + "backupId": "Backup ID", + "autoReload": "Data will refresh automatically in 2 seconds...", "languageOptionChinese": "中文", "languageOptionEnglish": "English", "windowBehavior": "Window Behavior", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 9ca0b09..20996b2 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -61,6 +61,19 @@ "title": "设置", "general": "通用", "language": "界面语言", + "importExport": "导入导出配置", + "exportConfig": "导出配置到文件", + "selectConfigFile": "选择配置文件", + "import": "导入", + "importing": "导入中...", + "importSuccess": "导入成功!", + "importFailed": "导入失败", + "configExported": "配置已导出到:", + "exportFailed": "导出失败", + "selectFileFailed": "选择文件失败", + "configCorrupted": "配置文件可能已损坏或格式不正确", + "backupId": "备份ID", + "autoReload": "数据将在2秒后自动刷新...", "languageOptionChinese": "中文", "languageOptionEnglish": "English", "windowBehavior": "窗口行为", diff --git a/src/lib/tauri-api.ts b/src/lib/tauri-api.ts index 7d5c521..f4e773d 100644 --- a/src/lib/tauri-api.ts +++ b/src/lib/tauri-api.ts @@ -139,40 +139,22 @@ export const tauriAPI = { } }, - // 获取 Claude Code 配置状态 - getClaudeConfigStatus: async (): Promise => { - try { - return await invoke("get_claude_config_status"); - } catch (error) { - console.error("获取配置状态失败:", error); - return { - exists: false, - path: "", - error: String(error), - }; - } - }, - - // 获取应用配置状态(通用) - getConfigStatus: async (app?: AppType): Promise => { - try { - return await invoke("get_config_status", { app_type: app, app }); - } catch (error) { - console.error("获取配置状态失败:", error); - return { - exists: false, - path: "", - error: String(error), - }; - } - }, - - // 打开配置文件夹 + // 打开配置目录(按应用类型) openConfigFolder: async (app?: AppType): Promise => { try { await invoke("open_config_folder", { app_type: app, app }); } catch (error) { - console.error("打开配置文件夹失败:", error); + console.error("打开配置目录失败:", error); + } + }, + + // 选择配置目录(可选默认路径) + selectConfigDirectory: async (defaultPath?: string): Promise => { + try { + return await invoke("pick_directory", { defaultPath }); + } catch (error) { + console.error("选择配置目录失败:", error); + return null; } }, @@ -188,47 +170,20 @@ export const tauriAPI = { // 更新托盘菜单 updateTrayMenu: async (): Promise => { try { - return await invoke("update_tray_menu"); + return await invoke("update_tray_menu"); } catch (error) { console.error("更新托盘菜单失败:", error); return false; } }, - // 监听供应商切换事件 - onProviderSwitched: async ( - callback: (data: { appType: string; providerId: string }) => void, - ): Promise => { - return await listen("provider-switched", (event) => { - callback(event.payload as { appType: string; providerId: string }); - }); - }, - - // 选择配置目录 - selectConfigDirectory: async ( - defaultPath?: string, - ): Promise => { - try { - const sanitized = - defaultPath && defaultPath.trim() !== "" - ? defaultPath - : undefined; - return await invoke("pick_directory", { - defaultPath: sanitized, - }); - } catch (error) { - console.error("选择配置目录失败:", error); - return null; - } - }, - - // 获取设置 + // 获取应用设置 getSettings: async (): Promise => { try { return await invoke("get_settings"); } catch (error) { console.error("获取设置失败:", error); - return { showInTray: true, minimizeToTrayOnClose: true }; + throw error; } }, @@ -320,6 +275,7 @@ export const tauriAPI = { } }, + // ours: 第三方/自定义供应商——测速与端点管理 // 第三方/自定义供应商:批量测试端点延迟 testApiEndpoints: async ( urls: string[], @@ -423,6 +379,70 @@ export const tauriAPI = { // 不抛出错误,因为这不是关键操作 } }, + + // theirs: 导入导出与文件对话框 + // 导出配置到文件 + exportConfigToFile: async (filePath: string): Promise<{ + success: boolean; + message: string; + filePath: string; + }> => { + try { + return await invoke("export_config_to_file", { filePath }); + } catch (error) { + throw new Error(`导出配置失败: ${String(error)}`); + } + }, + + // 从文件导入配置 + importConfigFromFile: async (filePath: string): Promise<{ + success: boolean; + message: string; + backupId?: string; + }> => { + try { + return await invoke("import_config_from_file", { filePath }); + } catch (error) { + throw new Error(`导入配置失败: ${String(error)}`); + } + }, + + // 保存文件对话框 + saveFileDialog: async (defaultName: string): Promise => { + try { + const result = await invoke("save_file_dialog", { defaultName }); + return result; + } catch (error) { + console.error("打开保存对话框失败:", error); + return null; + } + }, + + // 打开文件对话框 + openFileDialog: async (): Promise => { + try { + const result = await invoke("open_file_dialog"); + return result; + } catch (error) { + console.error("打开文件对话框失败:", error); + return null; + } + }, + + // 监听供应商切换事件 + onProviderSwitched: async ( + callback: (data: { appType: string; providerId: string }) => void, + ): Promise => { + const unlisten = await listen("provider-switched", (event) => { + try { + // 事件 payload 形如 { appType: string, providerId: string } + callback(event.payload as any); + } catch (e) { + console.error("处理 provider-switched 事件失败: ", e); + } + }); + return unlisten; + }, }; // 创建全局 API 对象,兼容现有代码 diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 32b0a5d..44a0148 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -29,6 +29,18 @@ declare global { getClaudeConfigStatus: () => Promise; getConfigStatus: (app?: AppType) => Promise; getConfigDir: (app?: AppType) => Promise; + saveFileDialog: (defaultName: string) => Promise; + openFileDialog: () => Promise; + exportConfigToFile: (filePath: string) => Promise<{ + success: boolean; + message: string; + filePath: string; + }>; + importConfigFromFile: (filePath: string) => Promise<{ + success: boolean; + message: string; + backupId?: string; + }>; selectConfigDirectory: (defaultPath?: string) => Promise; openConfigFolder: (app?: AppType) => Promise; openExternal: (url: string) => Promise;