diff --git a/bun.lock b/bun.lock index 21fc085..6180935 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-popover": "^1.1.4", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-select": "^2.1.3", "@radix-ui/react-switch": "^1.1.3", @@ -23,6 +24,7 @@ "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.0", "@tauri-apps/plugin-dialog": "^2.0.2", + "@tauri-apps/plugin-fs": "^2.4.2", "@tauri-apps/plugin-global-shortcut": "^2.0.0", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-shell": "^2.0.1", @@ -345,6 +347,8 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "https://registry.npmmirror.com/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="], + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g=="], "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="], @@ -489,6 +493,8 @@ "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.2.2", "", { "dependencies": { "@tauri-apps/api": "^2.0.0" } }, "sha512-Pm9qnXQq8ZVhAMFSEPwxvh+nWb2mk7LASVlNEHYaksHvcz8P6+ElR5U5dNL9Ofrm+uwhh1/gYKWswK8JJJAh6A=="], + "@tauri-apps/plugin-fs": ["@tauri-apps/plugin-fs@2.4.2", "https://registry.npmmirror.com/@tauri-apps/plugin-fs/-/plugin-fs-2.4.2.tgz", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-YGhmYuTgXGsi6AjoV+5mh2NvicgWBfVJHHheuck6oHD+HC9bVWPaHvCP0/Aw4pHDejwrvT8hE3+zZAaWf+hrig=="], + "@tauri-apps/plugin-global-shortcut": ["@tauri-apps/plugin-global-shortcut@2.2.1", "", { "dependencies": { "@tauri-apps/api": "^2.0.0" } }, "sha512-b64/TI1t5LIi2JY4OWlYjZpPRq60T5GVVL/no27sUuxaNUZY8dVtwsMtDUgxUpln2yR+P2PJsYlqY5V8sLSxEw=="], "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.0.0" } }, "sha512-yAbauwp8BCHIhhA48NN8rEf6OtfZBPCgTOCa10gmtoVCpmic5Bq+1Ba7C+NZOjogedkSiV7hAotjYnnbUVmYrw=="], @@ -1413,6 +1419,8 @@ "@tauri-apps/plugin-clipboard-manager/@tauri-apps/api": ["@tauri-apps/api@2.7.0", "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.7.0.tgz", {}, "sha512-v7fVE8jqBl8xJFOcBafDzXFc8FnicoH3j8o8DNNs0tHuEBmXUDqrCOAzMRX0UkfpwqZLqvrvK0GNQ45DfnoVDg=="], + "@tauri-apps/plugin-fs/@tauri-apps/api": ["@tauri-apps/api@2.8.0", "https://registry.npmmirror.com/@tauri-apps/api/-/api-2.8.0.tgz", {}, "sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw=="], + "@types/estree-jsx/@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], diff --git a/package.json b/package.json index a462814..3e9eb05 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-popover": "^1.1.4", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-select": "^2.1.3", "@radix-ui/react-switch": "^1.1.3", @@ -33,6 +34,7 @@ "@tauri-apps/api": "^2.1.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.0", "@tauri-apps/plugin-dialog": "^2.0.2", + "@tauri-apps/plugin-fs": "^2.4.2", "@tauri-apps/plugin-global-shortcut": "^2.0.0", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-shell": "^2.0.1", diff --git a/src-tauri/src/commands/relay_stations.rs b/src-tauri/src/commands/relay_stations.rs index 98da128..8cdbde9 100644 --- a/src-tauri/src/commands/relay_stations.rs +++ b/src-tauri/src/commands/relay_stations.rs @@ -699,4 +699,212 @@ pub async fn relay_station_get_current_config() -> Result) -> Result, String> { + let conn = db.0.lock().map_err(|e| { + log::error!("Failed to acquire database lock: {}", e); + i18n::t("database.lock_failed") + })?; + + // 确保表存在 + init_relay_stations_tables(&conn).map_err(|e| { + log::error!("Failed to initialize relay stations tables: {}", e); + i18n::t("database.init_failed") + })?; + + let mut stmt = conn.prepare("SELECT * FROM relay_stations ORDER BY created_at DESC") + .map_err(|e| { + log::error!("Failed to prepare statement: {}", e); + i18n::t("database.query_failed") + })?; + + let stations = stmt.query_map([], |row| RelayStation::from_row(row)) + .map_err(|e| { + log::error!("Failed to query relay stations: {}", e); + i18n::t("database.query_failed") + })? + .collect::, _>>() + .map_err(|e| { + log::error!("Failed to collect relay stations: {}", e); + i18n::t("database.query_failed") + })?; + + log::info!("Exported {} relay stations", stations.len()); + Ok(stations) +} + +/// 导入结果统计 +#[derive(Debug, Serialize, Deserialize)] +pub struct ImportResult { + pub total: usize, // 总数 + pub imported: usize, // 成功导入数 + pub skipped: usize, // 跳过数(重复) + pub failed: usize, // 失败数 + pub message: String, // 结果消息 +} + +/// 导入中转站配置 +#[derive(Debug, Serialize, Deserialize)] +pub struct ImportRelayStationsRequest { + pub stations: Vec, + pub clear_existing: bool, // 是否清除现有配置 +} + +#[command] +pub async fn relay_stations_import( + request: ImportRelayStationsRequest, + db: State<'_, AgentDb> +) -> Result { + let mut conn = db.0.lock().map_err(|e| { + log::error!("Failed to acquire database lock: {}", e); + i18n::t("database.lock_failed") + })?; + + // 确保表存在 + init_relay_stations_tables(&conn).map_err(|e| { + log::error!("Failed to initialize relay stations tables: {}", e); + i18n::t("database.init_failed") + })?; + + // 开始事务 + let tx = conn.transaction().map_err(|e| { + log::error!("Failed to start transaction: {}", e); + i18n::t("database.transaction_failed") + })?; + + // 如果需要清除现有配置 + if request.clear_existing { + tx.execute("DELETE FROM relay_stations", []) + .map_err(|e| { + log::error!("Failed to clear existing relay stations: {}", e); + i18n::t("relay_station.clear_failed") + })?; + log::info!("Cleared existing relay stations"); + } + + // 获取现有的中转站列表(用于重复检查) + let existing_stations: Vec<(String, String)> = if !request.clear_existing { + let mut stmt = tx.prepare("SELECT api_url, system_token FROM relay_stations") + .map_err(|e| { + log::error!("Failed to prepare statement: {}", e); + i18n::t("database.query_failed") + })?; + + let stations_iter = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| { + log::error!("Failed to query existing stations: {}", e); + i18n::t("database.query_failed") + })?; + + // 立即收集结果,避免生命周期问题 + let mut existing = Vec::new(); + for station_result in stations_iter { + match station_result { + Ok(station) => existing.push(station), + Err(e) => { + log::error!("Failed to read existing station: {}", e); + return Err(i18n::t("database.query_failed")); + } + } + } + existing + } else { + Vec::new() + }; + + // 导入新的中转站 + let total = request.stations.len(); + let mut imported_count = 0; + let mut skipped_count = 0; + let mut failed_count = 0; + let now = Utc::now().timestamp(); + + for station_request in request.stations { + // 验证输入 + if let Err(e) = validate_relay_station_request(&station_request.name, &station_request.api_url, &station_request.system_token) { + log::warn!("Skipping invalid station {}: {}", station_request.name, e); + failed_count += 1; + continue; + } + + // 检查是否重复(同时匹配 api_url 和 system_token) + let is_duplicate = existing_stations.iter().any(|(url, token)| { + url == &station_request.api_url && token == &station_request.system_token + }); + + if is_duplicate { + log::info!("Skipping duplicate station: {} ({})", station_request.name, station_request.api_url); + skipped_count += 1; + continue; + } + + let id = Uuid::new_v4().to_string(); + + let adapter_str = serde_json::to_string(&station_request.adapter) + .map_err(|_| i18n::t("relay_station.invalid_adapter"))? + .trim_matches('"').to_string(); + + let auth_method_str = serde_json::to_string(&station_request.auth_method) + .map_err(|_| i18n::t("relay_station.invalid_auth_method"))? + .trim_matches('"').to_string(); + + let adapter_config_str = station_request.adapter_config.as_ref() + .map(|config| serde_json::to_string(config)) + .transpose() + .map_err(|_| i18n::t("relay_station.invalid_config"))?; + + match tx.execute( + r#" + INSERT INTO relay_stations + (id, name, description, api_url, adapter, auth_method, system_token, user_id, adapter_config, enabled, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) + "#, + params![ + id, + station_request.name, + station_request.description, + station_request.api_url, + adapter_str, + auth_method_str, + station_request.system_token, + station_request.user_id, + adapter_config_str, + if station_request.enabled { 1 } else { 0 }, + now, + now + ], + ) { + Ok(_) => imported_count += 1, + Err(e) => { + log::error!("Failed to import relay station: {}", e); + failed_count += 1; + } + } + } + + // 提交事务 + tx.commit().map_err(|e| { + log::error!("Failed to commit transaction: {}", e); + i18n::t("database.transaction_failed") + })?; + + let message = format!( + "导入完成:总计 {} 个,成功 {} 个,跳过 {} 个(重复),失败 {} 个", + total, imported_count, skipped_count, failed_count + ); + + log::info!("{}", message); + + Ok(ImportResult { + total, + imported: imported_count, + skipped: skipped_count, + failed: failed_count, + message, + }) } \ No newline at end of file diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ff11068..53c8c0b 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -58,6 +58,7 @@ use commands::relay_stations::{ relay_stations_list, relay_station_get, relay_station_create, relay_station_update, relay_station_delete, relay_station_toggle_enable, relay_station_sync_config, relay_station_restore_config, relay_station_get_current_config, + relay_stations_export, relay_stations_import, }; use commands::relay_adapters::{ relay_station_get_info, relay_station_get_user_info, @@ -97,6 +98,7 @@ fn main() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_log::Builder::new() .level(log::LevelFilter::Debug) @@ -382,6 +384,8 @@ fn main() { relay_station_sync_config, relay_station_restore_config, relay_station_get_current_config, + relay_stations_export, + relay_stations_import, relay_station_get_info, relay_station_get_user_info, relay_station_test_connection, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fe69810..08c4a6a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -28,22 +28,6 @@ } }, "plugins": { - "fs": { - "scope": [ - "$HOME/**" - ], - "allow": [ - "readFile", - "writeFile", - "readDir", - "copyFile", - "createDir", - "removeDir", - "removeFile", - "renameFile", - "exists" - ] - }, "shell": { "open": true } diff --git a/src/components/RelayStationManager.tsx b/src/components/RelayStationManager.tsx index abcc600..3c3860e 100644 --- a/src/components/RelayStationManager.tsx +++ b/src/components/RelayStationManager.tsx @@ -4,6 +4,8 @@ import MonacoEditor from '@monaco-editor/react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { Dialog, DialogContent, @@ -27,6 +29,7 @@ import { RelayStationAdapter, AuthMethod, PackycodeUserQuota, + ImportResult, api } from '@/lib/api'; import { @@ -42,7 +45,9 @@ import { Eye, Edit3, Save, - X + X, + Download, + Upload } from 'lucide-react'; interface RelayStationManagerProps { @@ -66,6 +71,11 @@ const RelayStationManager: React.FC = ({ onBack }) => const [savingConfig, setSavingConfig] = useState(false); const [flushingDns, setFlushingDns] = useState(false); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); + + // 导入进度相关状态 + const [importing, setImporting] = useState(false); + const [importProgress, setImportProgress] = useState(0); + const [importResult, setImportResult] = useState(null); // PackyCode 额度相关状态 const [quotaData, setQuotaData] = useState>({}); @@ -199,6 +209,124 @@ const RelayStationManager: React.FC = ({ onBack }) => } }; + // 导出中转站配置 + const handleExportStations = async () => { + try { + const stations = await api.relayStationsExport(); + const jsonData = JSON.stringify(stations, null, 2); + + // 使用 Tauri 的保存文件对话框 + const { save } = await import('@tauri-apps/plugin-dialog'); + const filePath = await save({ + defaultPath: `relay-stations-${new Date().toISOString().slice(0, 10)}.json`, + filters: [{ + name: 'JSON', + extensions: ['json'] + }] + }); + + if (filePath) { + // 使用 Tauri 的文件系统 API 写入文件 + const { writeTextFile } = await import('@tauri-apps/plugin-fs'); + await writeTextFile(filePath, jsonData); + showToast(t('relayStation.exportSuccess'), 'success'); + } + } catch (error) { + console.error('Failed to export stations:', error); + showToast(t('relayStation.exportFailed'), 'error'); + } + }; + + // 导入中转站配置 + const handleImportStations = async () => { + try { + setImporting(true); + setImportProgress(0); + setImportResult(null); + + // 使用 Tauri 的文件选择对话框 + const { open } = await import('@tauri-apps/plugin-dialog'); + const selected = await open({ + multiple: false, + filters: [{ + name: 'JSON', + extensions: ['json'] + }] + }); + + if (!selected) { + setImporting(false); + return; + } + + setImportProgress(20); + + // 使用 Tauri 的文件系统 API 读取文件 + const { readTextFile } = await import('@tauri-apps/plugin-fs'); + const text = await readTextFile(selected as string); + const stations = JSON.parse(text) as RelayStation[]; + + setImportProgress(40); + + // 转换为 CreateRelayStationRequest 格式 + const importRequests: CreateRelayStationRequest[] = stations.map(station => ({ + name: station.name, + description: station.description, + api_url: station.api_url, + adapter: station.adapter, + auth_method: station.auth_method, + system_token: station.system_token, + user_id: station.user_id, + adapter_config: station.adapter_config, + enabled: station.enabled + })); + + setImportProgress(60); + + // 显示确认对话框 + const confirmed = await new Promise((resolve) => { + if (window.confirm(t('relayStation.importConfirm', { count: stations.length }))) { + resolve(true); + } else { + resolve(false); + } + }); + + if (confirmed) { + setImportProgress(80); + const result = await api.relayStationsImport(importRequests, false); + setImportProgress(100); + setImportResult(result); + + // 显示结果 + if (result.imported > 0) { + showToast(result.message, 'success'); + loadStations(); + } else if (result.skipped === result.total) { + showToast(t('relayStation.allDuplicate'), 'error'); + } else { + showToast(result.message, 'success'); + } + + // 3秒后清除结果 + setTimeout(() => { + setImportResult(null); + setImporting(false); + setImportProgress(0); + }, 3000); + } else { + setImporting(false); + setImportProgress(0); + } + } catch (error) { + console.error('Failed to import stations:', error); + showToast(t('relayStation.importFailed'), 'error'); + setImporting(false); + setImportProgress(0); + setImportResult(null); + } + }; + // 删除中转站 const deleteStation = async () => { if (!stationToDelete) return; @@ -308,25 +436,85 @@ const RelayStationManager: React.FC = ({ onBack }) =>

{t('relayStation.description')}

- - - - - - { - setShowCreateDialog(false); - loadStations(); - showToast(t('relayStation.createSuccess'), "success"); - }} - /> - - +
+ + + + + + + + { + setShowCreateDialog(false); + loadStations(); + showToast(t('relayStation.createSuccess'), "success"); + }} + /> + + +
+ {/* 导入进度 */} + {importing && ( + + +
+
+
+ {t('relayStation.importing')} + {importProgress}% +
+ +
+ {importResult && ( + + +
{importResult.message}
+
+
+ {t('relayStation.importTotal')}: + {importResult.total} +
+
+ {t('relayStation.importSuccess')}: + {importResult.imported} +
+
+ {t('relayStation.importSkipped')}: + {importResult.skipped} +
+ {importResult.failed > 0 && ( +
+ {t('relayStation.importFailed')}: + {importResult.failed} +
+ )} +
+
+
+ )} +
+
+
+ )} + {/* 当前配置状态 */} @@ -737,12 +925,12 @@ const RelayStationManager: React.FC = ({ onBack }) => {t('relayStation.confirmDeleteTitle')} {t('relayStation.deleteConfirm')} - {stationToDelete && ( -
- {stationToDelete.name} -
- )}
+ {stationToDelete && ( +
+ {stationToDelete.name} +
+ )}