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 <farion1231@gmail.com>
This commit is contained in:
WormW
2025-10-05 23:33:07 +08:00
committed by GitHub
parent f8c40d591f
commit 3ad11acdb2
11 changed files with 523 additions and 2 deletions

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

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

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

@@ -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<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;
}
},
};
// 创建全局 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>;