From 3ad11acdb23513d63909ac6e68b65b05cc5352f0 Mon Sep 17 00:00:00 2001 From: WormW Date: Sun, 5 Oct 2025 23:33:07 +0800 Subject: [PATCH] add: local config import and export (#84) * add: local config import and export * Fix import refresh flow and typings * Clarify import refresh messaging * Limit stored import backups --------- Co-authored-by: Jason --- src-tauri/Cargo.lock | 3 + src-tauri/Cargo.toml | 1 + src-tauri/src/import_export.rs | 170 +++++++++++++++++++++++++ src-tauri/src/lib.rs | 5 + src/App.tsx | 14 +- src/components/ImportProgressModal.tsx | 103 +++++++++++++++ src/components/SettingsModal.tsx | 143 ++++++++++++++++++++- src/i18n/locales/en.json | 13 ++ src/i18n/locales/zh.json | 13 ++ src/lib/tauri-api.ts | 48 +++++++ src/vite-env.d.ts | 12 ++ 11 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/import_export.rs create mode 100644 src/components/ImportProgressModal.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 911b3b6..8260ee2 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", "log", "objc2 0.5.2", @@ -628,8 +629,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 2133db3..8c81d3f 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 5569b1d..39c20f7 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; @@ -419,6 +420,10 @@ pub fn run() { commands::read_claude_plugin_config, commands::apply_claude_plugin_config, commands::is_claude_plugin_applied, + 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/SettingsModal.tsx b/src/components/SettingsModal.tsx index ca727ef..032c625 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -12,6 +12,7 @@ import { Save, } from "lucide-react"; import { getVersion } from "@tauri-apps/api/app"; +import { ImportProgressModal } from "./ImportProgressModal"; import { homeDir, join } from "@tauri-apps/api/path"; import "../lib/tauri-api"; import { relaunchApp } from "../lib/updater"; @@ -22,9 +23,10 @@ import { isLinux } from "../lib/platform"; interface SettingsModalProps { onClose: () => void; + onImportSuccess?: () => void | Promise; } -export default function SettingsModal({ onClose }: SettingsModalProps) { +export default function SettingsModal({ onClose, onImportSuccess }: SettingsModalProps) { const { t, i18n } = useTranslation(); const normalizeLanguage = (lang?: string | null): "zh" | "en" => @@ -63,6 +65,13 @@ export default function SettingsModal({ onClose }: SettingsModalProps) { const { hasUpdate, updateInfo, updateHandle, checkUpdate, resetDismiss } = useUpdate(); + // 导入/导出相关状态 + const [isImporting, setIsImporting] = useState(false); + const [importStatus, setImportStatus] = useState<'idle' | 'importing' | 'success' | 'error'>('idle'); + const [importError, setImportError] = useState(""); + const [importBackupId, setImportBackupId] = useState(""); + const [selectedImportFile, setSelectedImportFile] = useState(''); + useEffect(() => { loadSettings(); loadConfigPath(); @@ -346,6 +355,66 @@ export default function SettingsModal({ onClose }: SettingsModalProps) { } }; + // 导出配置处理函数 + const handleExportConfig = async () => { + try { + const defaultName = `cc-switch-config-${new Date().toISOString().split('T')[0]}.json`; + const filePath = await window.api.saveFileDialog(defaultName); + + if (!filePath) return; // 用户取消了 + + const result = await window.api.exportConfigToFile(filePath); + + if (result.success) { + alert(`${t("settings.configExported")}\n${result.filePath}`); + } + } catch (error) { + console.error("导出配置失败:", error); + alert(`${t("settings.exportFailed")}: ${error}`); + } + }; + + // 选择要导入的文件 + const handleSelectImportFile = async () => { + try { + const filePath = await window.api.openFileDialog(); + if (filePath) { + setSelectedImportFile(filePath); + setImportStatus('idle'); // 重置状态 + setImportError(''); + } + } catch (error) { + console.error('选择文件失败:', error); + alert(`${t("settings.selectFileFailed")}: ${error}`); + } + }; + + // 执行导入 + const handleExecuteImport = async () => { + if (!selectedImportFile || isImporting) return; + + setIsImporting(true); + setImportStatus('importing'); + + try { + const result = await window.api.importConfigFromFile(selectedImportFile); + + if (result.success) { + setImportBackupId(result.backupId || ''); + setImportStatus('success'); + // ImportProgressModal 会在2秒后触发数据刷新回调 + } else { + setImportError(result.message || t("settings.configCorrupted")); + setImportStatus('error'); + } + } catch (error) { + setImportError(String(error)); + setImportStatus('error'); + } finally { + setIsImporting(false); + } + }; + return (
+ {/* 导入导出 */} +
+

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

+
+
+ {/* 导出按钮 */} + + + {/* 导入区域 */} +
+
+ + +
+ + {/* 显示选择的文件 */} + {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/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 f480ef3..7304ddb 100644 --- a/src/lib/tauri-api.ts +++ b/src/lib/tauri-api.ts @@ -312,6 +312,54 @@ export const tauriAPI = { throw new Error(`检测 Claude 插件配置失败: ${String(error)}`); } }, + + // 导出配置到文件 + 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; + } + }, }; // 创建全局 API 对象,兼容现有代码 diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index c44ff28..4d6968b 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;