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:
3
src-tauri/Cargo.lock
generated
3
src-tauri/Cargo.lock
generated
@@ -565,6 +565,7 @@ dependencies = [
|
|||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"log",
|
"log",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
@@ -628,8 +629,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
|
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
|
"js-sys",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"serde",
|
"serde",
|
||||||
|
"wasm-bindgen",
|
||||||
"windows-link 0.2.0",
|
"windows-link 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ tauri-build = { version = "2.4.0", features = [] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
chrono = "0.4"
|
||||||
tauri = { version = "2.8.2", features = ["tray-icon"] }
|
tauri = { version = "2.8.2", features = ["tray-icon"] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
|
|||||||
170
src-tauri/src/import_export.rs
Normal file
170
src-tauri/src/import_export.rs
Normal 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()))
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ mod claude_plugin;
|
|||||||
mod codex_config;
|
mod codex_config;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod import_export;
|
||||||
mod migration;
|
mod migration;
|
||||||
mod provider;
|
mod provider;
|
||||||
mod settings;
|
mod settings;
|
||||||
@@ -419,6 +420,10 @@ pub fn run() {
|
|||||||
commands::read_claude_plugin_config,
|
commands::read_claude_plugin_config,
|
||||||
commands::apply_claude_plugin_config,
|
commands::apply_claude_plugin_config,
|
||||||
commands::is_claude_plugin_applied,
|
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,
|
update_tray_menu,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
14
src/App.tsx
14
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 导入一条默认供应商(仅首次初始化时)
|
// 自动从 live 导入一条默认供应商(仅首次初始化时)
|
||||||
const handleAutoImportDefault = async () => {
|
const handleAutoImportDefault = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -357,7 +366,10 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isSettingsOpen && (
|
{isSettingsOpen && (
|
||||||
<SettingsModal onClose={() => setIsSettingsOpen(false)} />
|
<SettingsModal
|
||||||
|
onClose={() => setIsSettingsOpen(false)}
|
||||||
|
onImportSuccess={handleImportSuccess}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
103
src/components/ImportProgressModal.tsx
Normal file
103
src/components/ImportProgressModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
|
import { ImportProgressModal } from "./ImportProgressModal";
|
||||||
import { homeDir, join } from "@tauri-apps/api/path";
|
import { homeDir, join } from "@tauri-apps/api/path";
|
||||||
import "../lib/tauri-api";
|
import "../lib/tauri-api";
|
||||||
import { relaunchApp } from "../lib/updater";
|
import { relaunchApp } from "../lib/updater";
|
||||||
@@ -22,9 +23,10 @@ import { isLinux } from "../lib/platform";
|
|||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onImportSuccess?: () => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsModal({ onClose }: SettingsModalProps) {
|
export default function SettingsModal({ onClose, onImportSuccess }: SettingsModalProps) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
const normalizeLanguage = (lang?: string | null): "zh" | "en" =>
|
const normalizeLanguage = (lang?: string | null): "zh" | "en" =>
|
||||||
@@ -63,6 +65,13 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
|||||||
const { hasUpdate, updateInfo, updateHandle, checkUpdate, resetDismiss } =
|
const { hasUpdate, updateInfo, updateHandle, checkUpdate, resetDismiss } =
|
||||||
useUpdate();
|
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(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
loadConfigPath();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
@@ -542,6 +611,56 @@ export default function SettingsModal({ onClose }: SettingsModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,19 @@
|
|||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"language": "Language",
|
"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": "中文",
|
"languageOptionChinese": "中文",
|
||||||
"languageOptionEnglish": "English",
|
"languageOptionEnglish": "English",
|
||||||
"windowBehavior": "Window Behavior",
|
"windowBehavior": "Window Behavior",
|
||||||
|
|||||||
@@ -61,6 +61,19 @@
|
|||||||
"title": "设置",
|
"title": "设置",
|
||||||
"general": "通用",
|
"general": "通用",
|
||||||
"language": "界面语言",
|
"language": "界面语言",
|
||||||
|
"importExport": "导入导出配置",
|
||||||
|
"exportConfig": "导出配置到文件",
|
||||||
|
"selectConfigFile": "选择配置文件",
|
||||||
|
"import": "导入",
|
||||||
|
"importing": "导入中...",
|
||||||
|
"importSuccess": "导入成功!",
|
||||||
|
"importFailed": "导入失败",
|
||||||
|
"configExported": "配置已导出到:",
|
||||||
|
"exportFailed": "导出失败",
|
||||||
|
"selectFileFailed": "选择文件失败",
|
||||||
|
"configCorrupted": "配置文件可能已损坏或格式不正确",
|
||||||
|
"backupId": "备份ID",
|
||||||
|
"autoReload": "数据将在2秒后自动刷新...",
|
||||||
"languageOptionChinese": "中文",
|
"languageOptionChinese": "中文",
|
||||||
"languageOptionEnglish": "English",
|
"languageOptionEnglish": "English",
|
||||||
"windowBehavior": "窗口行为",
|
"windowBehavior": "窗口行为",
|
||||||
|
|||||||
@@ -312,6 +312,54 @@ export const tauriAPI = {
|
|||||||
throw new Error(`检测 Claude 插件配置失败: ${String(error)}`);
|
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 对象,兼容现有代码
|
// 创建全局 API 对象,兼容现有代码
|
||||||
|
|||||||
12
src/vite-env.d.ts
vendored
12
src/vite-env.d.ts
vendored
@@ -29,6 +29,18 @@ declare global {
|
|||||||
getClaudeConfigStatus: () => Promise<ConfigStatus>;
|
getClaudeConfigStatus: () => Promise<ConfigStatus>;
|
||||||
getConfigStatus: (app?: AppType) => Promise<ConfigStatus>;
|
getConfigStatus: (app?: AppType) => Promise<ConfigStatus>;
|
||||||
getConfigDir: (app?: AppType) => Promise<string>;
|
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>;
|
selectConfigDirectory: (defaultPath?: string) => Promise<string | null>;
|
||||||
openConfigFolder: (app?: AppType) => Promise<void>;
|
openConfigFolder: (app?: AppType) => Promise<void>;
|
||||||
openExternal: (url: string) => Promise<void>;
|
openExternal: (url: string) => Promise<void>;
|
||||||
|
|||||||
Reference in New Issue
Block a user