Feat: 修复 Rust 部分异常,支持通过目录读取文件内容

This commit is contained in:
2026-01-21 17:29:39 +08:00
parent 510821020c
commit cc3a8984c1
16 changed files with 1539 additions and 33 deletions
+68
View File
@@ -84,7 +84,9 @@ dependencies = [
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log",
"walkdir",
]
[[package]]
@@ -666,6 +668,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.10.0",
"block2",
"libc",
"objc2",
]
@@ -2757,6 +2761,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "rkyv"
version = "0.7.46"
@@ -3501,6 +3529,46 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.17",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
dependencies = [
"anyhow",
"dunce",
"glob",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.17",
"toml 0.9.11+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-log"
version = "2.8.0"
+2
View File
@@ -21,5 +21,7 @@ tauri-build = { version = "2.5.3", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
walkdir = "2.5"
tauri = { version = "2.9.5", features = [] }
tauri-plugin-log = "2"
tauri-plugin-dialog = "2.6.0"
+2 -1
View File
@@ -6,6 +6,7 @@
"main"
],
"permissions": [
"core:default"
"core:default",
"dialog:allow-open"
]
}
+11
View File
@@ -1,6 +1,16 @@
mod sources;
use sources::{load_project_sources, register_directory_sources, save_language_file, scan_language_directory};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
scan_language_directory,
register_directory_sources,
load_project_sources,
save_language_file
])
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
@@ -11,6 +21,7 @@ pub fn run() {
}
Ok(())
})
.plugin(tauri_plugin_dialog::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+381
View File
@@ -0,0 +1,381 @@
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
fs,
io,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
};
use tauri::AppHandle;
use tauri::Manager;
use walkdir::WalkDir;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SourceMode {
Directory,
Flat,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectorySourceConfig {
pub base_dir: String,
pub default_language: String,
pub relative_path: String,
pub files: BTreeMap<String, String>, // language -> absolute path
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlatSourceConfig {
pub placeholder: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum ProjectSourceConfig {
Directory(DirectorySourceConfig),
Flat(FlatSourceConfig),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectsConfig {
pub projects: BTreeMap<String, ProjectSourceConfig>,
}
#[derive(Debug, Serialize)]
pub struct ScanLanguageDirectoryResult {
pub base_dir: String,
pub languages: Vec<LanguageFolderSummary>,
}
#[derive(Debug, Serialize)]
pub struct LanguageFolderSummary {
pub language: String,
pub file_count: usize,
pub files: Vec<LanguageFileSummary>,
}
#[derive(Debug, Serialize)]
pub struct LanguageFileSummary {
pub file_name: String,
pub relative_path: String,
pub absolute_path: String,
}
#[derive(Debug, Deserialize)]
pub struct RegisterDirectorySourcesPayload {
pub project_id: String,
pub base_dir: String,
pub default_language: String,
pub relative_path: String,
}
#[derive(Debug, Serialize)]
pub struct LoadedProjectSources {
pub project_id: String,
pub mode: SourceMode,
pub base_dir: Option<String>,
pub default_language: Option<String>,
pub relative_path: Option<String>,
pub languages: Vec<LoadedLanguageFile>,
}
#[derive(Debug, Serialize)]
pub struct LoadedLanguageFile {
pub language: String,
pub path: String,
pub exists: bool,
pub modified_ms: Option<i64>,
pub content: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SaveLanguageFilePayload {
pub project_id: String,
pub language: String,
pub content: String,
}
#[tauri::command]
pub fn scan_language_directory(dir_path: String) -> Result<ScanLanguageDirectoryResult, String> {
let base_path = PathBuf::from(dir_path.trim());
if !base_path.is_dir() {
return Err("目录不存在或不可访问".into());
}
let canonical = canonical_string(&base_path)?;
let languages = collect_language_folders(&base_path)?;
if languages.is_empty() {
return Err("该目录下未找到任何语言子目录或 JSON 文件".into());
}
Ok(ScanLanguageDirectoryResult {
base_dir: canonical,
languages,
})
}
#[tauri::command]
pub fn register_directory_sources(app: AppHandle, payload: RegisterDirectorySourcesPayload) -> Result<ProjectSourceConfig, String> {
let project_id = payload.project_id.trim();
if project_id.is_empty() {
return Err("project_id 不能为空".into());
}
let base_dir = PathBuf::from(payload.base_dir.trim());
if !base_dir.is_dir() {
return Err("基础目录不存在或不可访问".into());
}
let canonical_base = canonical_string(&base_dir)?;
let default_language = payload.default_language.trim();
if default_language.is_empty() {
return Err("默认语言不能为空".into());
}
let relative_path = payload.relative_path.trim();
if relative_path.is_empty() {
return Err("语言包路径不能为空".into());
}
let default_dir = base_dir.join(default_language);
if !default_dir.is_dir() {
return Err("默认语言目录不存在".into());
}
let default_file = default_dir.join(relative_path);
if !default_file.is_file() {
return Err(format!("默认语言文件不存在:{}", default_file.display()));
}
let mut files = BTreeMap::new();
for entry in list_language_dirs(&base_dir)? {
let language = entry.file_name().to_string_lossy().trim().to_string();
if language.is_empty() {
continue;
}
let candidate = entry.path().join(relative_path);
if candidate.is_file() {
let canonical = canonical_string(&candidate)?;
files.insert(language, canonical);
}
}
if files.is_empty() {
return Err("未找到任何可用的语言包文件,请检查目录结构".into());
}
let config = DirectorySourceConfig {
base_dir: canonical_base,
default_language: default_language.to_string(),
relative_path: relative_path.to_string(),
files,
updated_at: now_ms(),
};
let mut projects = load_projects_config(&app)?;
projects.projects.insert(project_id.to_string(), ProjectSourceConfig::Directory(config.clone()));
save_projects_config(&app, &projects)?;
Ok(ProjectSourceConfig::Directory(config))
}
#[tauri::command]
pub fn load_project_sources(app: AppHandle, project_id: String) -> Result<LoadedProjectSources, String> {
let trimmed_id = project_id.trim().to_string();
let projects = load_projects_config(&app)?;
let config = projects.projects.get(&trimmed_id).ok_or_else(|| "未找到项目配置".to_string())?;
match config {
ProjectSourceConfig::Directory(cfg) => {
let languages = cfg
.files
.iter()
.map(|(language, path)| {
let (exists, content, modified_ms) = read_language_file(Path::new(path));
LoadedLanguageFile {
language: language.clone(),
path: path.clone(),
exists,
modified_ms,
content,
}
})
.collect();
Ok(LoadedProjectSources {
project_id: trimmed_id,
mode: SourceMode::Directory,
base_dir: Some(cfg.base_dir.clone()),
default_language: Some(cfg.default_language.clone()),
relative_path: Some(cfg.relative_path.clone()),
languages,
})
}
ProjectSourceConfig::Flat(_) => Err("单文件模式尚未实现".into()),
}
}
#[tauri::command]
pub fn save_language_file(app: AppHandle, payload: SaveLanguageFilePayload) -> Result<(), String> {
let mut projects = load_projects_config(&app)?;
let config = projects
.projects
.get_mut(payload.project_id.trim())
.ok_or_else(|| "未找到项目配置".to_string())?;
match config {
ProjectSourceConfig::Directory(cfg) => {
let language = payload.language.trim();
if language.is_empty() {
return Err("语言代码不能为空".into());
}
let path = determine_language_path(cfg, language)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败: {e}"))?;
}
fs::write(&path, payload.content.as_bytes()).map_err(|e| format!("写入文件失败: {e}"))?;
let canonical = canonical_string(&path)?;
cfg.files.insert(language.to_string(), canonical);
cfg.updated_at = now_ms();
save_projects_config(&app, &projects)
}
ProjectSourceConfig::Flat(_) => Err("单文件模式尚未实现".into()),
}
}
fn collect_language_folders(base_dir: &Path) -> Result<Vec<LanguageFolderSummary>, String> {
let mut folders = Vec::new();
for entry in list_language_dirs(base_dir)? {
let language = entry.file_name().to_string_lossy().trim().to_string();
if language.is_empty() {
continue;
}
let files = collect_json_files(entry.path())?;
if files.is_empty() {
continue;
}
folders.push(LanguageFolderSummary {
language,
file_count: files.len(),
files,
});
}
folders.sort_by(|a, b| a.language.cmp(&b.language));
Ok(folders)
}
fn collect_json_files(dir: PathBuf) -> Result<Vec<LanguageFileSummary>, String> {
let mut files = Vec::new();
for entry in WalkDir::new(&dir).into_iter().filter_map(|e| e.ok()) {
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
if !has_json_extension(path) {
continue;
}
let relative = path
.strip_prefix(&dir)
.unwrap_or(path)
.to_string_lossy()
.trim_start_matches(std::path::MAIN_SEPARATOR)
.to_string();
let file_name = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| relative.clone());
files.push(LanguageFileSummary {
file_name,
relative_path: if relative.is_empty() {
path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default()
} else {
relative
},
absolute_path: path.to_string_lossy().to_string(),
});
}
files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
Ok(files)
}
fn list_language_dirs(base_dir: &Path) -> Result<Vec<fs::DirEntry>, String> {
let mut dirs = Vec::new();
for entry in fs::read_dir(base_dir).map_err(|e| format!("读取目录失败: {e}"))? {
let entry = entry.map_err(|e| format!("读取目录失败: {e}"))?;
if entry.file_type().map_err(|e| format!("读取目录失败: {e}"))?.is_dir() {
dirs.push(entry);
}
}
dirs.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
Ok(dirs)
}
fn has_json_extension(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("json"))
.unwrap_or(false)
}
fn read_language_file(path: &Path) -> (bool, Option<String>, Option<i64>) {
if !path.is_file() {
return (false, None, None);
}
let metadata = match fs::metadata(path) {
Ok(meta) => meta,
Err(_) => return (false, None, None),
};
let modified_ms = metadata
.modified()
.ok()
.and_then(|mtime| mtime.duration_since(UNIX_EPOCH).ok())
.map(|dur| dur.as_millis().min(i64::MAX as u128) as i64);
let content = match fs::read_to_string(path) {
Ok(text) => Some(text),
Err(_) => None,
};
(true, content, modified_ms)
}
fn determine_language_path(cfg: &DirectorySourceConfig, language: &str) -> Result<PathBuf, String> {
if let Some(existing) = cfg.files.get(language) {
return Ok(PathBuf::from(existing));
}
let path = PathBuf::from(&cfg.base_dir).join(language).join(&cfg.relative_path);
Ok(path)
}
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|dur| dur.as_millis().min(i64::MAX as u128) as i64)
.unwrap_or(0)
}
fn canonical_string(path: &Path) -> Result<String, String> {
let resolved = match fs::canonicalize(path) {
Ok(p) => p,
Err(err) if err.kind() == io::ErrorKind::NotFound => path.to_path_buf(),
Err(err) => return Err(format!("解析路径失败: {err}")),
};
Ok(resolved.to_string_lossy().to_string())
}
fn config_file_path(app: &AppHandle) -> Result<PathBuf, String> {
let mut dir = app.path().app_config_dir().map_err(|e| format!("获取配置目录失败: {e}"))?;
dir.push("sources");
fs::create_dir_all(&dir).map_err(|e| format!("创建配置目录失败: {e}"))?;
dir.push("projects.json");
Ok(dir)
}
fn load_projects_config(app: &AppHandle) -> Result<ProjectsConfig, String> {
let path = config_file_path(app)?;
if !path.is_file() {
return Ok(ProjectsConfig::default());
}
let text = fs::read_to_string(&path).map_err(|e| format!("读取配置失败: {e}"))?;
let parsed: ProjectsConfig = serde_json::from_str(&text).map_err(|e| format!("解析配置失败: {e}"))?;
Ok(parsed)
}
fn save_projects_config(app: &AppHandle, config: &ProjectsConfig) -> Result<(), String> {
let path = config_file_path(app)?;
let text = serde_json::to_string_pretty(config).map_err(|e| format!("序列化配置失败: {e}"))?;
fs::write(path, text).map_err(|e| format!("写入配置失败: {e}"))
}
+3 -3
View File
@@ -2,7 +2,7 @@
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "translate-it",
"version": "0.1.0",
"identifier": "com.tauri.dev",
"identifier": "com.dreamer-paul.translate-it",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5007",
@@ -13,8 +13,8 @@
"windows": [
{
"title": "Translate It",
"width": 800,
"height": 600,
"width": 1400,
"height": 900,
"resizable": true,
"fullscreen": false
}