优化调整
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
//! 快速面板:基于 7-Zip(binaries/7z.exe)的批量文件操作。
|
||||
//!
|
||||
//! - 批量解压:逐文件调用 7z 控制台,支持一次传入统一密码(`-p`),
|
||||
//! 每完成一个文件通过事件推送进度,前端可实时展示。
|
||||
//! - 批量重命名:`regex` 匹配文件名生成预览,确认后执行 `fs::rename`。
|
||||
//! - 目录列表:供前端展示当前目录的压缩包 / 文件列表。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
use crate::constants::events::QUICKPANEL_EXTRACT_PROGRESS;
|
||||
|
||||
/// 7z 可执行文件名(与 7z.dll 同目录,位于 resources/binaries)。
|
||||
const _7Z_EXE: &str = "binaries/7z.exe";
|
||||
|
||||
/// 支持的压缩包扩展名(解压面板中列出)。
|
||||
const ARCHIVE_EXTS: &[&str] = &[
|
||||
".zip", ".7z", ".rar", ".tar", ".gz", ".tgz", ".bz2", ".tbz", ".xz", ".txz", ".zst",
|
||||
".tzst", ".cab", ".lzma",
|
||||
];
|
||||
|
||||
/// 定位 7z 可执行文件(resources/binaries/7z.exe)。
|
||||
fn locate_7z(app: &AppHandle) -> Option<PathBuf> {
|
||||
let path = app
|
||||
.path()
|
||||
.resolve(_7Z_EXE, tauri::path::BaseDirectory::Resource)
|
||||
.ok()?;
|
||||
if path.exists() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArchiveInfo {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub is_dir: bool,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExtractResult {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub ok: bool,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExtractProgress {
|
||||
pub done: usize,
|
||||
pub total: usize,
|
||||
pub current: String,
|
||||
pub ok: bool,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RenamePreview {
|
||||
pub path: String,
|
||||
pub old_name: String,
|
||||
pub new_name: String,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RenameItem {
|
||||
pub path: String,
|
||||
pub old_name: String,
|
||||
pub new_name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RenameResult {
|
||||
pub old_name: String,
|
||||
pub new_name: String,
|
||||
pub ok: bool,
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
fn is_archive(name: &str) -> bool {
|
||||
let lower = name.to_lowercase();
|
||||
ARCHIVE_EXTS.iter().any(|ext| lower.ends_with(ext))
|
||||
}
|
||||
|
||||
/// 列出目录下的压缩包文件(供批量解压面板使用)。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn quickpanel_list_archives(dir: String) -> Result<Vec<ArchiveInfo>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let d = Path::new(&dir);
|
||||
if !d.is_dir() {
|
||||
return Err(format!("目录不存在: {dir}"));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for entry in std::fs::read_dir(d).map_err(|e| format!("读取目录失败: {e}"))? {
|
||||
let Ok(entry) = entry else { continue };
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
if is_archive(&name) {
|
||||
let size = path.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
out.push(ArchiveInfo {
|
||||
name,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
size,
|
||||
});
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(out)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("读取压缩包失败: {e}"))?
|
||||
}
|
||||
|
||||
/// 列出目录下的全部条目(供批量重命名/删除面板使用,不含子目录递归)。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn quickpanel_list_dir(dir: String) -> Result<Vec<FileEntry>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let d = Path::new(&dir);
|
||||
if !d.is_dir() {
|
||||
return Err(format!("目录不存在: {dir}"));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for entry in std::fs::read_dir(d).map_err(|e| format!("读取目录失败: {e}"))? {
|
||||
let Ok(entry) = entry else { continue };
|
||||
let path = entry.path();
|
||||
let is_dir = path.is_dir();
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let size = if is_dir {
|
||||
0
|
||||
} else {
|
||||
path.metadata().map(|m| m.len()).unwrap_or(0)
|
||||
};
|
||||
out.push(FileEntry {
|
||||
name,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
is_dir,
|
||||
size,
|
||||
});
|
||||
}
|
||||
out.sort_by(|a, b| {
|
||||
b.is_dir
|
||||
.cmp(&a.is_dir)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
Ok(out)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("读取目录失败: {e}"))?
|
||||
}
|
||||
|
||||
fn extract_one(
|
||||
exe: &Path,
|
||||
archive: &str,
|
||||
dest: &str,
|
||||
password: Option<&str>,
|
||||
into_subfolder: bool,
|
||||
) -> Result<(), String> {
|
||||
let archive_path = Path::new(archive);
|
||||
let mut cmd = Command::new(exe);
|
||||
cmd.arg("x").arg(archive_path);
|
||||
if into_subfolder {
|
||||
// 解压到「压缩包同名子文件夹」,避免文件散落在当前目录。
|
||||
let sub = archive_path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "extracted".into());
|
||||
let sub_dir = Path::new(dest).join(&sub);
|
||||
std::fs::create_dir_all(&sub_dir).map_err(|e| format!("创建目录 {sub} 失败: {e}"))?;
|
||||
cmd.arg(format!("-o{}", sub_dir.display()));
|
||||
} else {
|
||||
cmd.arg(format!("-o{}", dest));
|
||||
}
|
||||
cmd.arg("-y"); // 全自动覆盖确认
|
||||
if let Some(p) = password.filter(|p| !p.is_empty()) {
|
||||
cmd.arg(format!("-p{p}"));
|
||||
}
|
||||
// 静默普通输出,仅错误进 stderr,逐文件粒度足够时无需 -bsp1 进度。
|
||||
cmd.arg("-bso0").arg("-bse1").arg("-bsp0");
|
||||
// 以 7z.exe 所在目录为工作目录,确保同目录的 7z.dll 可被加载。
|
||||
if let Some(dir) = exe.parent() {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
let output = cmd.output().map_err(|e| format!("启动 7-Zip 失败: {e}"))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
Err(if msg.is_empty() {
|
||||
format!("退出码 {code}")
|
||||
} else {
|
||||
msg
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量解压。`files` 为压缩包路径列表,`dest_dir` 为目标目录,
|
||||
/// `password` 为统一解压密码(可空),`into_subfolder` 是否解压到同名子文件夹。
|
||||
/// 每完成一个文件通过 `quickpanel-extract-progress` 事件推送进度。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn quickpanel_batch_extract(
|
||||
app: AppHandle,
|
||||
files: Vec<String>,
|
||||
dest_dir: String,
|
||||
password: Option<String>,
|
||||
into_subfolder: bool,
|
||||
) -> Result<Vec<ExtractResult>, String> {
|
||||
let exe = locate_7z(&app).ok_or("未找到 7-Zip 组件(binaries/7z.exe),请重新安装")?;
|
||||
let total = files.len();
|
||||
let results = tauri::async_runtime::spawn_blocking(move || {
|
||||
let mut results = Vec::with_capacity(total);
|
||||
for (idx, file) in files.iter().enumerate() {
|
||||
let err = extract_one(&exe, file, &dest_dir, password.as_deref(), into_subfolder);
|
||||
let name = Path::new(file)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| file.clone());
|
||||
let (ok, error) = match err {
|
||||
Ok(()) => (true, String::new()),
|
||||
Err(e) => (false, e),
|
||||
};
|
||||
results.push(ExtractResult {
|
||||
name,
|
||||
path: file.clone(),
|
||||
ok,
|
||||
error: error.clone(),
|
||||
});
|
||||
let _ = app.emit(
|
||||
QUICKPANEL_EXTRACT_PROGRESS,
|
||||
ExtractProgress {
|
||||
done: idx + 1,
|
||||
total,
|
||||
current: results[idx].name.clone(),
|
||||
ok,
|
||||
error,
|
||||
},
|
||||
);
|
||||
}
|
||||
results
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("解压任务异常终止: {e}"))?;
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 正则批量重命名预览:对每个文件名应用 `pattern → replacement`,
|
||||
/// 仅返回有匹配的文件,`newName` 为替换结果。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn quickpanel_preview_rename(
|
||||
files: Vec<String>,
|
||||
pattern: String,
|
||||
replacement: String,
|
||||
) -> Result<Vec<RenamePreview>, String> {
|
||||
let re = regex::Regex::new(&pattern).map_err(|e| format!("正则表达式无效: {e}"))?;
|
||||
let mut out = Vec::new();
|
||||
for file in files {
|
||||
let name = Path::new(&file)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
if !re.is_match(&name) {
|
||||
continue;
|
||||
}
|
||||
let new_name = re.replace_all(&name, replacement.as_str()).to_string();
|
||||
let error = if new_name.is_empty() || new_name == name {
|
||||
"名称未变化".to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
out.push(RenamePreview {
|
||||
path: file,
|
||||
old_name: name,
|
||||
new_name,
|
||||
error,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 执行重命名。同一目录下若目标已存在则跳过该项。
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn quickpanel_apply_rename(items: Vec<RenameItem>) -> Result<Vec<RenameResult>, String> {
|
||||
let mut out = Vec::new();
|
||||
for item in items {
|
||||
let result = (|| -> Result<(), String> {
|
||||
if item.new_name.is_empty() {
|
||||
return Err("新文件名为空".into());
|
||||
}
|
||||
if item.new_name.contains(['/', '\\', ':', '*', '?', '"', '<', '>', '|']) {
|
||||
return Err("文件名包含非法字符".into());
|
||||
}
|
||||
let old = Path::new(&item.path);
|
||||
let parent = old.parent().unwrap_or(Path::new("."));
|
||||
let new_path = parent.join(&item.new_name);
|
||||
if new_path.exists() {
|
||||
return Err("目标已存在".into());
|
||||
}
|
||||
std::fs::rename(old, &new_path).map_err(|e| format!("重命名失败: {e}"))?;
|
||||
Ok(())
|
||||
})();
|
||||
out.push(RenameResult {
|
||||
old_name: item.old_name,
|
||||
new_name: item.new_name,
|
||||
ok: result.is_ok(),
|
||||
error: result.err().unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Reference in New Issue
Block a user