diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 55e0bc6..42e7b9b 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -25,7 +25,7 @@ const DEFAULT_OPENLIST_VERSION: &str = "v4.1.10"; // - NETMOUNT_SKIP_TAURI_BUILD: skip tauri_build::try_build to avoid transient Windows file lock issues // 版本标记文件 -const OPENLIST_VERSION_FILE: &str = "binaries/openlist/.version"; +const OPENLIST_VERSION_FILE: &str = "binaries/openlist.version"; struct ResBinUrls { rclone: &'static str, diff --git a/src-tauri/locales/en.json b/src-tauri/locales/en.json index d3c3f14..b681b6e 100644 --- a/src-tauri/locales/en.json +++ b/src-tauri/locales/en.json @@ -687,5 +687,16 @@ "project_id": "Project ID", "use_s3_upload_method": "Use S3 Upload Method", "mode": "Mode", - "level": "Level" + "level": "Level", + "saving": "Saving...", + "data_management": "Data Management", + "export_config": "Export Configuration", + "export_config_description": "Package current configuration into a ZIP file (excluding logs)", + "export": "Export", + "config_exported": "Configuration exported", + "import_config": "Import Configuration", + "import_config_description": "Restore configuration from ZIP file (requires restart)", + "import": "Import", + "confirm_import": "Confirm Import", + "confirm_import_description": "Importing configuration will overwrite all current settings and automatically restart the software. Continue?" } diff --git a/src-tauri/locales/zh-cn.json b/src-tauri/locales/zh-cn.json index c57a1e7..979be9d 100644 --- a/src-tauri/locales/zh-cn.json +++ b/src-tauri/locales/zh-cn.json @@ -687,5 +687,16 @@ "project_id": "项目ID", "use_s3_upload_method":"使用S3上传方式", "mode": "模式", - "level": "等级" + "level": "等级", + "saving": "保存中...", + "data_management": "数据管理", + "export_config": "导出配置", + "export_config_description": "将当前配置打包为 ZIP 文件(排除日志)", + "export": "导出", + "config_exported": "配置已导出", + "import_config": "导入配置", + "import_config_description": "从 ZIP 文件恢复配置(需要重启)", + "import": "导入", + "confirm_import": "确认导入", + "confirm_import_description": "导入配置将覆盖当前所有配置并自动重启软件,是否继续?" } diff --git a/src-tauri/locales/zh-hant.json b/src-tauri/locales/zh-hant.json index 8698968..55fe074 100644 --- a/src-tauri/locales/zh-hant.json +++ b/src-tauri/locales/zh-hant.json @@ -684,8 +684,19 @@ "owner": "所有者", "shareUrl": "分享連結", "user_agent": "User-Agent", - "project_id": "專案ID", - "use_s3_upload_method": "使用S3上傳方式", + "project_id": "專案 ID", + "use_s3_upload_method": "使用 S3 上傳方式", "mode": "模式", - "level": "等級" + "level": "等級", + "saving": "保存中...", + "data_management": "數據管理", + "export_config": "導出配置", + "export_config_description": "將當前配置打包為 ZIP 文件(排除日誌)", + "export": "導出", + "config_exported": "配置已導出", + "import_config": "導入配置", + "import_config_description": "從 ZIP 文件恢復配置(需要重啓)", + "import": "導入", + "confirm_import": "確認導入", + "confirm_import_description": "導入配置將覆蓋當前所有配置並自動重啓軟件,是否繼續?" } diff --git a/src-tauri/src/fs.rs b/src-tauri/src/fs.rs index 05d0db7..d8ebe0c 100644 --- a/src-tauri/src/fs.rs +++ b/src-tauri/src/fs.rs @@ -1,6 +1,6 @@ use std::{ fs, - io::{Read, Seek, SeekFrom}, + io::{Read, Seek, SeekFrom, Write}, path::{Path, PathBuf}, }; @@ -121,3 +121,290 @@ pub fn copy_file(src: &str, dest: &str) -> Result<(), String> { .map_err(|io_error| format!("Failed to copy file: {}", io_error))?; Ok(()) } + +// ============================================ +// 配置导入导出功能 +// ============================================ + +fn resolve_tilde(app: &tauri::AppHandle, path: &str) -> anyhow::Result { + if path.starts_with('~') { + let home = app + .path() + .home_dir() + .map_err(|e| anyhow::anyhow!("Failed to get home dir: {}", e))?; + let rest = path + .trim_start_matches('~') + .trim_start_matches(['/', '\\']); + if rest.is_empty() { + Ok(home) + } else { + Ok(home.join(rest)) + } + } else { + Ok(Path::new(path).to_owned()) + } +} + +/// 验证 zip 文件的目录结构 +fn validate_zip_structure(zip: &mut zip::ZipArchive) -> anyhow::Result<()> { + let required_files = [ + "config.json", + "openlist/config.json", + ]; + + let mut found_files = std::collections::HashSet::new(); + + for i in 0..zip.len() { + let entry = zip.by_index(i)?; + let name = entry.name(); + found_files.insert(name.to_string()); + } + + // 检查必需文件是否存在 + for required in &required_files { + if !found_files.contains(*required) { + return Err(anyhow::anyhow!( + "Invalid backup structure: missing required file '{}'", + required + )); + } + } + + Ok(()) +} + +/// 递归复制目录 +fn copy_dir_all(src: impl AsRef, dst: impl AsRef) -> anyhow::Result<()> { + let dst = dst.as_ref(); + fs::create_dir_all(&dst)?; + + for entry in fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if ty.is_dir() { + copy_dir_all(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path)?; + } + } + + Ok(()) +} + +/// 导出配置到 zip 文件 +#[tauri::command] +pub fn export_config( + app: tauri::AppHandle, + out_path: String, +) -> anyhow_tauri::TAResult { + fn inner(app: &tauri::AppHandle, out_path: &str) -> anyhow::Result { + let out_path = out_path.trim(); + if out_path.is_empty() { + return Err(anyhow::anyhow!("Output path is required")); + } + if !out_path.to_ascii_lowercase().ends_with(".zip") { + return Err(anyhow::anyhow!("Output file must end with .zip")); + } + + let out = resolve_tilde(app, out_path)?; + if let Some(parent) = out.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent).map_err(anyhow::Error::from)?; + } + } + + let file = fs::File::create(&out).map_err(anyhow::Error::from)?; + let mut zip = zip::ZipWriter::new(file); + let options: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + + let data_dir = app + .path() + .home_dir() + .map_err(|e| anyhow::anyhow!("Failed to get home dir: {}", e))? + .join(".netmount"); + + // 递归添加目录内容(排除 log 目录) + fn add_dir_to_zip( + zip: &mut zip::ZipWriter, + options: &zip::write::FileOptions<'_, ()>, + src_dir: &Path, + base_dir: &Path, + ) -> anyhow::Result<()> { + for entry in fs::read_dir(src_dir)? { + let entry = entry?; + let path = entry.path(); + let file_name = entry.file_name(); + let file_name_str = file_name.to_string_lossy().to_lowercase(); + + // 排除 log 目录 + if path.is_dir() && file_name_str == "log" { + continue; + } + + let relative_path = path.strip_prefix(base_dir)?; + + if path.is_dir() { + add_dir_to_zip(zip, options, &path, base_dir)?; + } else { + let entry_name = relative_path.to_string_lossy().replace('\\', "/"); + zip.start_file(&entry_name, *options)?; + let mut file = fs::File::open(&path)?; + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer)?; + zip.write_all(&buffer)?; + } + } + Ok(()) + } + + if data_dir.exists() { + add_dir_to_zip(&mut zip, &options, &data_dir, &data_dir)?; + } + + zip.finish().map_err(anyhow::Error::from)?; + Ok(out.to_string_lossy().to_string()) + } + + inner(&app, &out_path).map_err(Into::into) +} + +/// 从 zip 文件导入配置 +#[tauri::command] +pub fn import_config( + app: tauri::AppHandle, + zip_path: String, +) -> anyhow_tauri::TAResult { + fn inner(app: &tauri::AppHandle, zip_path: &str) -> anyhow::Result { + let zip_path = resolve_tilde(app, zip_path)?; + + if !zip_path.exists() { + return Err(anyhow::anyhow!("Backup file does not exist")); + } + + if !zip_path.to_string_lossy().to_ascii_lowercase().ends_with(".zip") { + return Err(anyhow::anyhow!("Backup file must be a zip file")); + } + + // 打开 zip 文件 + let file = fs::File::open(&zip_path)?; + let mut zip = zip::ZipArchive::new(file)?; + + // 验证目录结构 + validate_zip_structure(&mut zip)?; + + // 解压到临时目录 + let data_dir = app + .path() + .home_dir() + .map_err(|e| anyhow::anyhow!("Failed to get home dir: {}", e))? + .join(".netmount"); + let temp_dir = data_dir.join(".backup_temp"); + + // 如果临时目录已存在,先删除 + if temp_dir.exists() { + fs::remove_dir_all(&temp_dir)?; + } + fs::create_dir_all(&temp_dir)?; + + // 解压所有文件到临时目录 + for i in 0..zip.len() { + let mut entry = zip.by_index(i)?; + let entry_name = entry.name().to_string(); + + // 跳过目录条目(zip 中目录条目通常以/结尾) + if entry_name.ends_with('/') { + continue; + } + + let target_path = temp_dir.join(&entry_name); + + // 确保父目录存在 + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent)?; + } + + // 如果是目录,创建它 + if entry.is_dir() { + fs::create_dir_all(&target_path)?; + } else { + // 如果是文件,解压它 + let mut outfile = fs::File::create(&target_path)?; + std::io::copy(&mut entry, &mut outfile)?; + } + } + + // 备份当前配置(以防万一) + let backup_dir = data_dir.join(".backup_old"); + if data_dir.exists() { + if backup_dir.exists() { + fs::remove_dir_all(&backup_dir)?; + } + // 复制当前配置到备份目录(排除临时目录) + for entry in fs::read_dir(&data_dir)? { + let entry = entry?; + let file_name = entry.file_name(); + let file_name_str = file_name.to_string_lossy(); + + // 跳过临时目录和备份目录 + if file_name_str == ".backup_temp" || file_name_str == ".backup_old" { + continue; + } + + let src = entry.path(); + let dst = backup_dir.join(&file_name); + + if src.is_dir() { + copy_dir_all(&src, &dst)?; + } else { + fs::copy(&src, &dst)?; + } + } + } + + // 删除旧配置 + for entry in fs::read_dir(&data_dir)? { + let entry = entry?; + let file_name = entry.file_name(); + let file_name_str = file_name.to_string_lossy(); + + // 跳过临时目录和备份目录 + if file_name_str == ".backup_temp" || file_name_str == ".backup_old" { + continue; + } + + let path = entry.path(); + if path.is_dir() { + fs::remove_dir_all(&path)?; + } else { + fs::remove_file(&path)?; + } + } + + // 将新配置从临时目录移动到数据目录 + for entry in fs::read_dir(&temp_dir)? { + let entry = entry?; + let src = entry.path(); + let dst = data_dir.join(entry.file_name()); + + if src.is_dir() { + copy_dir_all(&src, &dst)?; + } else { + fs::copy(&src, &dst)?; + } + } + + // 清理临时目录 + fs::remove_dir_all(&temp_dir)?; + + // 保留旧备份(可选:可以在成功后删除) + // fs::remove_dir_all(&backup_dir)?; + + Ok(format!("Configuration imported successfully from {}", zip_path.display())) + } + + inner(&app, &zip_path).map_err(Into::into) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7de6a27..a5d4ad1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -219,7 +219,9 @@ pub fn init() -> anyhow::Result<()> { register_sidecar_pid, spawn_sidecar, run_sidecar_once, - kill_sidecar + kill_sidecar, + fs::export_config, + fs::import_config ]) .setup(|app| { // 初始化 Job Object(Windows 进程树管理) diff --git a/src/controller/storage/framework/openlist/providers.ts b/src/controller/storage/framework/openlist/providers.ts index 6e19042..9aae749 100644 --- a/src/controller/storage/framework/openlist/providers.ts +++ b/src/controller/storage/framework/openlist/providers.ts @@ -222,7 +222,7 @@ async function updateOpenlistStorageInfoList() { // 设置不缓存 if (option.name === 'cache_expiration') { - storageParam.default = "0"; // 不缓存 + storageParam.default = 0; // 不缓存 (数字类型) } // 设置容量使用显示 diff --git a/src/page/setting/setting.tsx b/src/page/setting/setting.tsx index 4876f0c..bbad21b 100644 --- a/src/page/setting/setting.tsx +++ b/src/page/setting/setting.tsx @@ -154,6 +154,107 @@ export default function Setting_page() { + + + +
+
+
{t('export_config')}
+
+ {t('export_config_description')} +
+
+ +
+ +
+ +
+
+
{t('import_config')}
+
+ {t('import_config_description')} +
+
+ +
+ { shell.open(roConfig.url.rclone) }}>Rclone( { diff --git a/src/page/storage/add.tsx b/src/page/storage/add.tsx index 709c29d..d9385ec 100644 --- a/src/page/storage/add.tsx +++ b/src/page/storage/add.tsx @@ -43,6 +43,7 @@ function AddStorage_page() { const isEditMode = (getURLSearchParam('edit') == 'true') const [formHook, setFormHook] = useState();//表单实例 const [searchStr, setSearchStr] = useState('')//搜索存储 + const [submitting, setSubmitting] = useState(false)//提交中状态 const [storageParams, setStorageParams] = useState()//编辑模式下,覆盖默认参数 @@ -175,9 +176,9 @@ function AddStorage_page() { header={} data={[...storageInfo.defaultParams.parameters/* ,...storageInfo.defaultParams.exParameters?.openlist?.additional||[] */]} - showAdvanced={showAdvanced} overwriteValues={storageParams || {}} setFormHook={(hook) => { setFormHook(hook) }} /> + showAdvanced={showAdvanced} overwriteValues={storageParams || {}} setFormHook={(hook) => { setFormHook(hook) }} disabled={submitting} />
@@ -199,6 +200,12 @@ function AddStorage_page() { + } type='primary' loading={submitting} disabled={submitting}>{submitting ? t('saving') : t('save')}