feat(config): 添加配置导入导出功能

- 在设置页面添加数据管理卡片,支持导出配置为ZIP文件和从ZIP导入配置
- 实现后端导出导入逻辑,包括文件验证、临时目录处理和错误处理
- 添加多语言支持,完善相关UI交互和状态提示
- 修复存储添加页面重复提交问题,添加提交中状态
- 修正版本标记文件路径错误
This commit is contained in:
VirtualHotBar
2026-03-04 21:51:04 +08:00
parent 01761f8cbd
commit 2988382b35
9 changed files with 447 additions and 12 deletions

View File

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

View File

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

View File

@@ -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": "导入配置将覆盖当前所有配置并自动重启软件,是否继续?"
}

View File

@@ -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": "導入配置將覆蓋當前所有配置並自動重啓軟件,是否繼續?"
}

View File

@@ -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<Runtime>, path: &str) -> anyhow::Result<PathBuf> {
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<impl Read + Seek>) -> 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<Path>, dst: impl AsRef<Path>) -> 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<Runtime>,
out_path: String,
) -> anyhow_tauri::TAResult<String> {
fn inner(app: &tauri::AppHandle<Runtime>, out_path: &str) -> anyhow::Result<String> {
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<fs::File>,
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<Runtime>,
zip_path: String,
) -> anyhow_tauri::TAResult<String> {
fn inner(app: &tauri::AppHandle<Runtime>, zip_path: &str) -> anyhow::Result<String> {
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)
}

View File

@@ -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 ObjectWindows 进程树管理)

View File

@@ -222,7 +222,7 @@ async function updateOpenlistStorageInfoList() {
// 设置不缓存
if (option.name === 'cache_expiration') {
storageParam.default = "0"; // 不缓存
storageParam.default = 0; // 不缓存 (数字类型)
}
// 设置容量使用显示

View File

@@ -154,6 +154,107 @@ export default function Setting_page() {
</Form>
</Card>
<Card title={t('data_management')} style={{}} size='small'>
<Space direction="vertical" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 500, marginBottom: '0.5rem' }}>{t('export_config')}</div>
<div style={{ fontSize: '0.85rem', color: '#86909c' }}>
{t('export_config_description')}
</div>
</div>
<Button type="primary" status="success" onClick={async () => {
try {
const ts = new Date().toISOString().replace(/[:.]/g, '-')
const path = await dialog.save({
title: t('export_config'),
defaultPath: `netmount-config-${ts}.zip`,
filters: [{ name: 'Zip', extensions: ['zip'] }],
})
if (!path) return
const out = await invoke<string>('export_config', { outPath: path })
Message.success(`${t('config_exported')}: ${out}`)
} catch (e) {
const msg = (() => {
if (typeof e === 'string') return e
if (e && typeof e === 'object' && 'message' in e && typeof (e as { message?: unknown }).message === 'string') {
return (e as { message: string }).message
}
try {
return JSON.stringify(e)
} catch {
return String(e)
}
})()
Message.error(msg)
}
}}>{t('export')}</Button>
</div>
<div style={{ height: '1px', backgroundColor: '#e5e6eb', margin: '0.5rem 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 500, marginBottom: '0.5rem' }}>{t('import_config')}</div>
<div style={{ fontSize: '0.85rem', color: '#86909c' }}>
{t('import_config_description')}
</div>
</div>
<Button type="primary" status="warning" onClick={async () => {
try {
const path = await dialog.open({
title: t('import_config'),
multiple: false,
filters: [{ name: 'Zip', extensions: ['zip'] }],
})
if (!path) return
Modal.confirm({
title: t('confirm_import'),
content: t('confirm_import_description'),
okButtonProps: { status: 'warning' },
onOk: async () => {
try {
const result = await invoke<string>('import_config', { zipPath: path })
Message.success(result)
// 延迟重启
setTimeout(() => {
exit(true)
}, 1000)
} catch (e) {
const msg = (() => {
if (typeof e === 'string') return e
if (e && typeof e === 'object' && 'message' in e && typeof (e as { message?: unknown }).message === 'string') {
return (e as { message: string }).message
}
try {
return JSON.stringify(e)
} catch {
return String(e)
}
})()
Message.error(msg)
}
},
})
} catch (e) {
const msg = (() => {
if (typeof e === 'string') return e
if (e && typeof e === 'object' && 'message' in e && typeof (e as { message?: unknown }).message === 'string') {
return (e as { message: string }).message
}
try {
return JSON.stringify(e)
} catch {
return String(e)
}
})()
Message.error(msg)
}
}}>{t('import')}</Button>
</div>
</Space>
</Card>
<Card title={t('components')} style={{}} size='small'>
<Link onClick={() => { shell.open(roConfig.url.rclone) }}>Rclone</Link>(<Link onClick={() => {

View File

@@ -43,6 +43,7 @@ function AddStorage_page() {
const isEditMode = (getURLSearchParam('edit') == 'true')
const [formHook, setFormHook] = useState<FormInstance>();//表单实例
const [searchStr, setSearchStr] = useState('')//搜索存储
const [submitting, setSubmitting] = useState(false)//提交中状态
const [storageParams, setStorageParams] = useState<ParametersType>()//编辑模式下,覆盖默认参数
@@ -175,9 +176,9 @@ function AddStorage_page() {
header={<FormItem label={'*' + t('storage_name')} hidden={isEditMode}>
<Input value={storageName || ''} onChange={(value) => {
setStorageName(value)
}} />
}} disabled={submitting} />
</FormItem>} 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} />
<br />
<Row style={{ width: '100%' }}>
@@ -199,6 +200,12 @@ function AddStorage_page() {
<Button onClick={() => { getURLSearchParam('edit') ? navigate('/storage/manage') : setStep('selectType') }}>{t('step_back')}</Button>
<Button onClick={async () => {
if (!formHook) return;
// 防止重复提交
if (submitting) return;
setSubmitting(true);
try {
await formHook.validate()
} catch (error) {
@@ -209,6 +216,7 @@ function AddStorage_page() {
Message.error(t(err.key) + t(errorValue.message.replace(err.key, '')))
}
})
setSubmitting(false);
return
}
@@ -216,12 +224,14 @@ function AddStorage_page() {
if (!isEditMode) {
if (searchStorage(storageName)?.name) {
Message.error(t('storage_name_already_exists'))
setSubmitting(false);
return
}
}
if (!storageName) {
Message.error(t('storage_name_cannot_be_empty'))
setSubmitting(false);
return
}
@@ -236,14 +246,16 @@ function AddStorage_page() {
content: t('Storage_added_successfully'),
})
navigate('/storage/manage')
// 成功后不需要恢复状态,因为会跳转
} else {
Notification.error({
title: t('error'),
content: t('Storage_added_failed'),
})
setSubmitting(false);
}
}
} type='primary'>{t('save')}</Button>
} type='primary' loading={submitting} disabled={submitting}>{submitting ? t('saving') : t('save')}</Button>
</Space>
</Col>
</Row>