- 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
This commit is contained in:
Jason
2025-10-07 19:06:27 +08:00
parent 331e48a530
commit 5dc59dc7f8
14 changed files with 565 additions and 65 deletions

1
.node-version Normal file
View File

@@ -0,0 +1 @@
v22.4.1

3
src-tauri/Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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"

View File

@@ -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<String, String> {
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<Value, String> {
// 读取当前配置文件
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<Value, String> {
// 读取导入的文件
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<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
default_name: String,
) -> Result<Option<String>, 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<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Option<String>, 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()))
}

View File

@@ -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,
]);

View File

@@ -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 && (
<SettingsModal onClose={() => setIsSettingsOpen(false)} />
<SettingsModal
onClose={() => setIsSettingsOpen(false)}
onImportSuccess={handleImportSuccess}
/>
)}
</div>
);

View File

@@ -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 (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 dark:bg-black/70 backdrop-blur-sm" />
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-2xl p-8 max-w-md w-full mx-4">
<div className="flex flex-col items-center text-center">
{status === 'importing' && (
<>
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{t("settings.importing")}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t("common.loading")}
</p>
</>
)}
{status === 'success' && (
<>
<CheckCircle className="w-12 h-12 text-green-500 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{t("settings.importSuccess")}
</h3>
{backupId && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
{t("settings.backupId")}: {backupId}
</p>
)}
<p className="text-sm text-gray-600 dark:text-gray-400">
{t("settings.autoReload")}
</p>
</>
)}
{status === 'error' && (
<>
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{t("settings.importFailed")}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{message || t("settings.configCorrupted")}
</p>
<button
onClick={() => {
if (onComplete) {
onComplete();
}
}}
className="mt-4 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
>
{t("common.close")}
</button>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -199,7 +199,7 @@ const ProviderList: React.FC<ProviderListProps> = ({
<div className="flex items-center gap-2 ml-4">
{appType === "claude" ? (
<div className="w-[130px]">
<div className="flex-shrink-0">
{provider.category !== "official" && isCurrent && (
<button
onClick={() =>

View File

@@ -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<void>;
}
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<string>("");
const [importBackupId, setImportBackupId] = useState<string>("");
const [selectedImportFile, setSelectedImportFile] = useState<string>('');
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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
@@ -542,6 +611,56 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
</div>
</div>
{/* 导入导出 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
{t("settings.importExport")}
</h3>
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="space-y-3">
{/* 导出按钮 */}
<button
onClick={handleExportConfig}
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium rounded-lg transition-colors bg-gray-500 hover:bg-gray-600 dark:bg-gray-600 dark:hover:bg-gray-700 text-white"
>
<Save size={12} />
{t("settings.exportConfig")}
</button>
{/* 导入区域 */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={handleSelectImportFile}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 text-xs font-medium rounded-lg transition-colors bg-gray-500 hover:bg-gray-600 dark:bg-gray-600 dark:hover:bg-gray-700 text-white"
>
<FolderOpen size={12} />
{t("settings.selectConfigFile")}
</button>
<button
onClick={handleExecuteImport}
disabled={!selectedImportFile || isImporting}
className={`px-3 py-2 text-xs font-medium rounded-lg transition-colors text-white ${
!selectedImportFile || isImporting
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700'
}`}
>
{isImporting ? t("settings.importing") : t("settings.import")}
</button>
</div>
{/* 显示选择的文件 */}
{selectedImportFile && (
<div className="text-xs text-gray-600 dark:text-gray-400 px-2 py-1 bg-gray-50 dark:bg-gray-900 rounded break-all">
{selectedImportFile.split('/').pop() || selectedImportFile.split('\\').pop() || selectedImportFile}
</div>
)}
</div>
</div>
</div>
</div>
{/* 关于 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
@@ -636,6 +755,28 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
</button>
</div>
</div>
{/* Import Progress Modal */}
{importStatus !== 'idle' && (
<ImportProgressModal
status={importStatus}
message={importError}
backupId={importBackupId}
onComplete={() => {
setImportStatus('idle');
setImportError('');
setSelectedImportFile('');
}}
onSuccess={() => {
if (onImportSuccess) {
void onImportSuccess();
}
void window.api
.updateTrayMenu()
.catch((error) => console.error("[SettingsModal] Failed to refresh tray menu", error));
}}
/>
)}
</div>
);
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "窗口行为",

View File

@@ -139,40 +139,22 @@ export const tauriAPI = {
}
},
// 获取 Claude Code 配置状态
getClaudeConfigStatus: async (): Promise<ConfigStatus> => {
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<ConfigStatus> => {
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<void> => {
try {
await invoke("open_config_folder", { app_type: app, app });
} catch (error) {
console.error("打开配置文件夹失败:", error);
console.error("打开配置目录失败:", error);
}
},
// 选择配置目录(可选默认路径)
selectConfigDirectory: async (defaultPath?: string): Promise<string | null> => {
try {
return await invoke("pick_directory", { defaultPath });
} catch (error) {
console.error("选择配置目录失败:", error);
return null;
}
},
@@ -188,47 +170,20 @@ export const tauriAPI = {
// 更新托盘菜单
updateTrayMenu: async (): Promise<boolean> => {
try {
return await invoke("update_tray_menu");
return await invoke<boolean>("update_tray_menu");
} catch (error) {
console.error("更新托盘菜单失败:", error);
return false;
}
},
// 监听供应商切换事件
onProviderSwitched: async (
callback: (data: { appType: string; providerId: string }) => void,
): Promise<UnlistenFn> => {
return await listen("provider-switched", (event) => {
callback(event.payload as { appType: string; providerId: string });
});
},
// 选择配置目录
selectConfigDirectory: async (
defaultPath?: string,
): Promise<string | null> => {
try {
const sanitized =
defaultPath && defaultPath.trim() !== ""
? defaultPath
: undefined;
return await invoke<string | null>("pick_directory", {
defaultPath: sanitized,
});
} catch (error) {
console.error("选择配置目录失败:", error);
return null;
}
},
// 获取设置
// 获取应用设置
getSettings: async (): Promise<Settings> => {
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<string | null> => {
try {
const result = await invoke<string | null>("save_file_dialog", { defaultName });
return result;
} catch (error) {
console.error("打开保存对话框失败:", error);
return null;
}
},
// 打开文件对话框
openFileDialog: async (): Promise<string | null> => {
try {
const result = await invoke<string | null>("open_file_dialog");
return result;
} catch (error) {
console.error("打开文件对话框失败:", error);
return null;
}
},
// 监听供应商切换事件
onProviderSwitched: async (
callback: (data: { appType: string; providerId: string }) => void,
): Promise<UnlistenFn> => {
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 对象,兼容现有代码

12
src/vite-env.d.ts vendored
View File

@@ -29,6 +29,18 @@ declare global {
getClaudeConfigStatus: () => Promise<ConfigStatus>;
getConfigStatus: (app?: AppType) => Promise<ConfigStatus>;
getConfigDir: (app?: AppType) => Promise<string>;
saveFileDialog: (defaultName: string) => Promise<string | null>;
openFileDialog: () => Promise<string | null>;
exportConfigToFile: (filePath: string) => Promise<{
success: boolean;
message: string;
filePath: string;
}>;
importConfigFromFile: (filePath: string) => Promise<{
success: boolean;
message: string;
backupId?: string;
}>;
selectConfigDirectory: (defaultPath?: string) => Promise<string | null>;
openConfigFolder: (app?: AppType) => Promise<void>;
openExternal: (url: string) => Promise<void>;