Feat: 修复 Rust 部分异常,支持通过目录读取文件内容
This commit is contained in:
parent
510821020c
commit
cc3a8984c1
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
|
|
@ -14,10 +14,13 @@
|
|||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@tauri-apps/api": "^2.0.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.552.0",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ importers:
|
|||
'@radix-ui/react-dropdown-menu':
|
||||
specifier: ^2.1.16
|
||||
version: 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-menubar':
|
||||
specifier: ^1.1.16
|
||||
version: 1.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-separator':
|
||||
specifier: ^1.1.8
|
||||
version: 1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
|
|
@ -29,6 +32,12 @@ importers:
|
|||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.16
|
||||
version: 4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.0.2
|
||||
version: 2.9.1
|
||||
'@tauri-apps/plugin-dialog':
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
|
|
@ -602,6 +611,19 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-menubar@1.1.16':
|
||||
resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==}
|
||||
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
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popper@1.2.8':
|
||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||
peerDependencies:
|
||||
|
|
@ -1015,6 +1037,9 @@ packages:
|
|||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7
|
||||
|
||||
'@tauri-apps/api@2.9.1':
|
||||
resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.9.6':
|
||||
resolution: {integrity: sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
|
@ -1086,6 +1111,9 @@ packages:
|
|||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.6.0':
|
||||
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||
|
||||
|
|
@ -2418,6 +2446,24 @@ snapshots:
|
|||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
|
||||
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.2
|
||||
'@types/react-dom': 19.2.2(@types/react@19.2.2)
|
||||
|
||||
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
|
|
@ -2735,6 +2781,8 @@ snapshots:
|
|||
tailwindcss: 4.1.16
|
||||
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)
|
||||
|
||||
'@tauri-apps/api@2.9.1': {}
|
||||
|
||||
'@tauri-apps/cli-darwin-arm64@2.9.6':
|
||||
optional: true
|
||||
|
||||
|
|
@ -2782,6 +2830,10 @@ snapshots:
|
|||
'@tauri-apps/cli-win32-ia32-msvc': 2.9.6
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.9.6
|
||||
|
||||
'@tauri-apps/plugin-dialog@2.6.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.9.1
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.5
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
"core:default",
|
||||
"dialog:allow-open"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarShortcut,
|
||||
MenubarTrigger,
|
||||
} from "@/components/ui/menubar";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export type CommonMenubarItem =
|
||||
| "read"
|
||||
| "save"
|
||||
| "import-with-directory"
|
||||
| "import-with-files"
|
||||
| "export";
|
||||
|
||||
interface CommonMenubarProps {
|
||||
disabledItems?: Record<CommonMenubarItem, boolean>;
|
||||
onClickItem: (item: CommonMenubarItem) => void;
|
||||
}
|
||||
|
||||
function CommonMenubar({ disabledItems, onClickItem }: CommonMenubarProps) {
|
||||
return (
|
||||
<Menubar className="border-0 p-0 shadow-none">
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>
|
||||
文件 <ChevronDown className="size-4 opacity-30" />
|
||||
</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={() => onClickItem("read")} disabled={disabledItems?.read}>
|
||||
读取 <MenubarShortcut>⌘R</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={() => onClickItem("save")} disabled={disabledItems?.save}>
|
||||
保存 <MenubarShortcut>⌘S</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>
|
||||
导入 <ChevronDown className="size-4 opacity-30" />
|
||||
</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={() => onClickItem("import-with-directory")} disabled={disabledItems?.["import-with-directory"]}>
|
||||
通过目录导入 <MenubarShortcut>⌘L</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={() => onClickItem("import-with-files")} disabled={disabledItems?.["import-with-files"]}>
|
||||
单文件导入 <MenubarShortcut>⌘I</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>
|
||||
导出 <ChevronDown className="size-4 opacity-30" />
|
||||
</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={() => onClickItem("export")} disabled={disabledItems?.export}>导出 JSON</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommonMenubar;
|
||||
|
|
@ -0,0 +1,382 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { registerDirectorySourcesNative, type LoadedProjectSources } from "@/lib/native-sources";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (next: boolean) => void;
|
||||
projectId: string;
|
||||
onCompleted?: () => void | Promise<void>;
|
||||
onSourcesLoaded?: (loaded: LoadedProjectSources) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type ScanLanguageDirectoryResult = {
|
||||
base_dir: string;
|
||||
languages: LanguageFolderSummary[];
|
||||
};
|
||||
|
||||
type LanguageFolderSummary = {
|
||||
language: string;
|
||||
file_count: number;
|
||||
files: LanguageFileSummary[];
|
||||
};
|
||||
|
||||
type LanguageFileSummary = {
|
||||
file_name: string;
|
||||
relative_path: string;
|
||||
absolute_path: string;
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{ title: "选择目录", description: "选择包含语言子目录的根目录" },
|
||||
{ title: "默认语言", description: "指定目录中的默认语言" },
|
||||
{ title: "确认语言包", description: "选择默认语言下要关联的语言包文件" },
|
||||
];
|
||||
|
||||
export function ProjectSourcesWizard({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
onCompleted,
|
||||
onSourcesLoaded,
|
||||
}: Props) {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [scanResult, setScanResult] =
|
||||
useState<ScanLanguageDirectoryResult | null>(null);
|
||||
const [scanLoading, setScanLoading] = useState(false);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
const [defaultLanguage, setDefaultLanguage] = useState("");
|
||||
const [relativePath, setRelativePath] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const isTauriEnv = useMemo(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return Boolean(
|
||||
(window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__
|
||||
);
|
||||
}, []);
|
||||
|
||||
const selectedLanguageSummary = useMemo(() => {
|
||||
if (!scanResult || !defaultLanguage) return null;
|
||||
return (
|
||||
scanResult.languages.find((lang) => lang.language === defaultLanguage) ??
|
||||
null
|
||||
);
|
||||
}, [defaultLanguage, scanResult]);
|
||||
|
||||
function resetState() {
|
||||
setCurrentStep(0);
|
||||
setScanResult(null);
|
||||
setScanError(null);
|
||||
setDefaultLanguage("");
|
||||
setRelativePath("");
|
||||
setScanLoading(false);
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
resetState();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const pickDirectory = useCallback(async () => {
|
||||
if (!isTauriEnv) {
|
||||
setScanError("当前环境不支持目录选择,请在桌面端运行 Tauri 应用");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setScanLoading(true);
|
||||
setScanError(null);
|
||||
const picked = await openDialog({ directory: true, multiple: false });
|
||||
|
||||
if (!picked) {
|
||||
setScanLoading(false);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(picked)) {
|
||||
setScanError("请仅选择一个目录");
|
||||
setScanLoading(false);
|
||||
return;
|
||||
}
|
||||
const result = await invoke<ScanLanguageDirectoryResult>(
|
||||
"scan_language_directory",
|
||||
{ dirPath: picked }
|
||||
);
|
||||
setScanResult(result);
|
||||
const firstLanguage = result.languages[0]?.language ?? "";
|
||||
setDefaultLanguage(firstLanguage);
|
||||
const firstPath = result.languages[0]?.files[0]?.relative_path ?? "";
|
||||
setRelativePath(firstPath);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setScanError((err as Error)?.message ?? "扫描目录失败");
|
||||
} finally {
|
||||
setScanLoading(false);
|
||||
}
|
||||
}, [isTauriEnv]);
|
||||
|
||||
const handleRegister = useCallback(async () => {
|
||||
if (!projectId || !scanResult || !defaultLanguage || !relativePath) return;
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
const loaded = await registerDirectorySourcesNative({
|
||||
project_id: projectId,
|
||||
base_dir: scanResult.base_dir,
|
||||
default_language: defaultLanguage,
|
||||
relative_path: relativePath,
|
||||
});
|
||||
|
||||
if (onSourcesLoaded) {
|
||||
await onSourcesLoaded(loaded);
|
||||
}
|
||||
|
||||
toast.success("目录绑定成功");
|
||||
if (onCompleted) {
|
||||
await onCompleted();
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error((err as Error)?.message ?? "绑定目录失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [
|
||||
defaultLanguage,
|
||||
onCompleted,
|
||||
onOpenChange,
|
||||
onSourcesLoaded,
|
||||
projectId,
|
||||
relativePath,
|
||||
scanResult,
|
||||
]);
|
||||
|
||||
const canNextFromStep = useMemo(() => {
|
||||
if (currentStep === 0) return Boolean(scanResult);
|
||||
if (currentStep === 1) return Boolean(defaultLanguage);
|
||||
return Boolean(relativePath);
|
||||
}, [currentStep, defaultLanguage, relativePath, scanResult]);
|
||||
|
||||
function stepContent() {
|
||||
if (currentStep === 0) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
请选择一个目录,其中包含多个语言子目录(如{" "}
|
||||
<code>en/messages.json</code>、<code>zh-CN/messages.json</code>)。
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={pickDirectory}
|
||||
disabled={scanLoading}
|
||||
>
|
||||
{scanLoading ? "扫描中..." : "选择目录"}
|
||||
</Button>
|
||||
{scanResult?.base_dir && (
|
||||
<span className="text-sm text-muted-foreground truncate">
|
||||
已选择:<code>{scanResult.base_dir}</code>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{scanResult && (
|
||||
<div className="rounded-md border bg-muted/40 p-3 text-sm">
|
||||
共找到 {scanResult.languages.length} 个语言目录。
|
||||
</div>
|
||||
)}
|
||||
{scanError && (
|
||||
<div className="text-sm text-red-600" role="alert">
|
||||
{scanError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (currentStep === 1) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
请选择默认语言,后续将以它的结构为基准。
|
||||
</p>
|
||||
<div className="max-h-64 overflow-auto rounded-md border">
|
||||
{scanResult?.languages.map((lang) => {
|
||||
const active = defaultLanguage === lang.language;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={lang.language}
|
||||
className={`flex w-full items-center justify-between px-4 py-2 text-left text-sm transition ${
|
||||
active ? "bg-primary/10 text-primary" : "hover:bg-muted"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setDefaultLanguage(lang.language);
|
||||
if (!relativePath && lang.files[0]) {
|
||||
setRelativePath(lang.files[0].relative_path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{lang.language}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{lang.file_count} 个文件
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
请选择默认语言 <strong>{defaultLanguage}</strong>{" "}
|
||||
下需要绑定的语言包文件。
|
||||
</p>
|
||||
{selectedLanguageSummary?.files.length ? (
|
||||
<select
|
||||
className="w-full rounded-md border px-3 py-2 text-sm"
|
||||
value={relativePath}
|
||||
onChange={(e) => setRelativePath(e.target.value)}
|
||||
>
|
||||
{selectedLanguageSummary.files.map((file) => (
|
||||
<option key={file.relative_path} value={file.relative_path}>
|
||||
{file.relative_path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||
该语言目录下没有找到 JSON 文件,请返回上一步重新选择。
|
||||
</div>
|
||||
)}
|
||||
{selectedLanguageSummary && (
|
||||
<div className="rounded-md border bg-muted/40 p-3 text-xs text-muted-foreground">
|
||||
将自动匹配其他语言目录中的同名文件({relativePath || "未选择"})。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!submitting) {
|
||||
onOpenChange(next);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="grid-cols-[minmax(0,1fr)] max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>批量导入语言目录</DialogTitle>
|
||||
<DialogDescription>
|
||||
按照步骤绑定包含多个语言包的目录,下次启动即可自动加载。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
{steps.map((step, idx) => {
|
||||
const active = idx === currentStep;
|
||||
const completed = idx < currentStep;
|
||||
return (
|
||||
<div key={step.title} className="flex items-center gap-2 text-sm">
|
||||
<div
|
||||
className={`flex size-8 items-center justify-center rounded-full border ${
|
||||
active
|
||||
? "border-primary bg-primary text-white"
|
||||
: completed
|
||||
? "border-green-500 bg-green-500 text-white"
|
||||
: "border-muted-foreground/40 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className={`font-medium ${active ? "text-primary" : ""}`}
|
||||
>
|
||||
{step.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{step.description}
|
||||
</div>
|
||||
</div>
|
||||
{idx < steps.length - 1 && (
|
||||
<div className="h-px w-4 bg-border" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{stepContent()}
|
||||
|
||||
{scanResult && (
|
||||
<div className="mt-4 rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||
<div>目录:{scanResult.base_dir}</div>
|
||||
<div>默认语言:{defaultLanguage || "未选择"}</div>
|
||||
<div>语言包:{relativePath || "未选择"}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
resetState();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
{currentStep > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCurrentStep((prev) => Math.max(0, prev - 1))}
|
||||
disabled={submitting}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
{currentStep < steps.length - 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setCurrentStep((prev) => Math.min(steps.length - 1, prev + 1))
|
||||
}
|
||||
disabled={!canNextFromStep || submitting}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === steps.length - 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleRegister}
|
||||
disabled={!canNextFromStep || submitting}
|
||||
>
|
||||
{submitting ? "绑定中..." : "完成绑定"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
import * as React from "react"
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Menubar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
||||
return (
|
||||
<MenubarPrimitive.Root
|
||||
data-slot="menubar"
|
||||
className={cn(
|
||||
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
|
||||
return (
|
||||
<MenubarPrimitive.Trigger
|
||||
data-slot="menubar-trigger"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
|
||||
return (
|
||||
<MenubarPortal>
|
||||
<MenubarPrimitive.Content
|
||||
data-slot="menubar-content"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Item
|
||||
data-slot="menubar-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
data-slot="menubar-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioItem
|
||||
data-slot="menubar-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Label
|
||||
data-slot="menubar-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
|
||||
return (
|
||||
<MenubarPrimitive.Separator
|
||||
data-slot="menubar-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="menubar-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
|
||||
}
|
||||
|
||||
function MenubarSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
data-slot="menubar-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
||||
return (
|
||||
<MenubarPrimitive.SubContent
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarPortal,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarItem,
|
||||
MenubarShortcut,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export function isTauriEnv(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
const anyWindow = window as Window & { __TAURI_INTERNALS__?: unknown; __TAURI_IPC__?: unknown };
|
||||
return Boolean(anyWindow.__TAURI_INTERNALS__ || anyWindow.__TAURI_IPC__);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { isTauriEnv } from "./is-tauri";
|
||||
import type { ProjectSourcesState } from "@/store/sources-store";
|
||||
|
||||
export type LoadedLanguageFile = {
|
||||
language: string;
|
||||
path: string;
|
||||
exists: boolean;
|
||||
modified_ms?: number | null;
|
||||
content?: string | null;
|
||||
};
|
||||
|
||||
export type LoadedProjectSources = {
|
||||
project_id: string;
|
||||
mode: "directory" | "flat";
|
||||
base_dir?: string | null;
|
||||
default_language?: string | null;
|
||||
relative_path?: string | null;
|
||||
languages: LoadedLanguageFile[];
|
||||
};
|
||||
|
||||
export type RegisterDirectorySourcesInput = {
|
||||
project_id: string;
|
||||
base_dir: string;
|
||||
default_language: string;
|
||||
relative_path: string;
|
||||
};
|
||||
|
||||
export async function registerDirectorySourcesNative(input: RegisterDirectorySourcesInput): Promise<LoadedProjectSources> {
|
||||
ensureTauri();
|
||||
|
||||
await invoke("register_directory_sources", {
|
||||
payload: input,
|
||||
});
|
||||
|
||||
return loadNativeProjectSources(input.project_id);
|
||||
}
|
||||
|
||||
export async function loadNativeProjectSources(projectId: string): Promise<LoadedProjectSources> {
|
||||
ensureTauri();
|
||||
const loaded = await invoke<LoadedProjectSources>("load_project_sources", { projectId });
|
||||
return loaded;
|
||||
}
|
||||
|
||||
export async function saveNativeLanguageFile(projectId: string, language: string, content: string): Promise<void> {
|
||||
ensureTauri();
|
||||
await invoke("save_language_file", {
|
||||
payload: {
|
||||
project_id: projectId,
|
||||
language,
|
||||
content,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function toProjectSourcesState(loaded: LoadedProjectSources): ProjectSourcesState {
|
||||
return {
|
||||
projectId: loaded.project_id,
|
||||
mode: loaded.mode,
|
||||
baseDir: loaded.base_dir,
|
||||
defaultLanguage: loaded.default_language,
|
||||
relativePath: loaded.relative_path,
|
||||
languages: loaded.languages.map((lang) => ({
|
||||
language: lang.language,
|
||||
path: lang.path,
|
||||
exists: lang.exists,
|
||||
modified_ms: lang.modified_ms ?? undefined,
|
||||
})),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureTauri() {
|
||||
if (!isTauriEnv()) {
|
||||
throw new Error("当前环境不支持此操作,请在 Tauri 桌面应用中运行。");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type React from "react";
|
||||
import { Link, useParams, useNavigate } from "react-router";
|
||||
import { useParams, useNavigate } from "react-router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
|
|
@ -17,9 +18,9 @@ import {
|
|||
updateProject,
|
||||
deleteProjectDeep,
|
||||
} from "@/lib/db";
|
||||
import { ArrowBigDownDash, ArrowBigUpDash, ArrowLeft, Brackets, CaseSensitive, Download, Filter, Languages, LocateFixed, MoreVertical, PencilLine, Reply, Save, Settings, Trash2 } from "lucide-react";
|
||||
import { ArrowBigDownDash, ArrowBigUpDash, Brackets, CaseSensitive, Filter, FolderOpen, Languages, LocateFixed, MoreVertical, PencilLine, Reply, Save, Settings, Trash2 } from "lucide-react";
|
||||
import { useTranslationInlineEdit } from "@/hooks/biz/use-translation-inline-edit";
|
||||
import { flattenEntries, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath, moveEntryByOffset } from "@/lib/i18n-structure";
|
||||
import { buildStructureFromObject, flattenEntries, flattenValues, type FlatEntry, insertEntrySibling, removeEntryAtPath, renameEntryAtPath, moveEntryByOffset } from "@/lib/i18n-structure";
|
||||
import { ImportLanguageModal } from "@/components/biz/import-language-modal";
|
||||
import { ExportLanguageModal } from "@/components/biz/export-language-modal";
|
||||
import { EntryNameModal } from "@/components/biz/entry-name-modal";
|
||||
|
|
@ -44,6 +45,11 @@ import { HeaderConnectionIndicator } from "@/components/biz/header-connection-in
|
|||
import { SyncFromFilesButton } from "@/components/biz/sync-from-files-button";
|
||||
import { useProjectTabsStore } from "@/store/project-tabs-store";
|
||||
import { ProjectTabsBar } from "@/components/biz/project-tabs-bar";
|
||||
import { ProjectSourcesWizard } from "@/components/biz/project-sources-wizard";
|
||||
import { isTauriEnv } from "@/lib/is-tauri";
|
||||
import { loadNativeProjectSources, saveNativeLanguageFile, type LoadedProjectSources, toProjectSourcesState } from "@/lib/native-sources";
|
||||
import { useProjectSourcesStore } from "@/store/sources-store";
|
||||
import CommonMenubar, { type CommonMenubarItem } from "@/components/biz/editor/common-menubar";
|
||||
|
||||
export default function Editor() {
|
||||
const { id: projectId } = useParams();
|
||||
|
|
@ -55,6 +61,7 @@ export default function Editor() {
|
|||
const [pageError, setPageError] = useState<string | null>(null);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [sourcesWizardOpen, setSourcesWizardOpen] = useState(false);
|
||||
const [addModal, setAddModal] = useState<{ open: boolean; path: string; position: "above" | "below" } | null>(null);
|
||||
const [renameModal, setRenameModal] = useState<{ open: boolean; path: string } | null>(null);
|
||||
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
|
||||
|
|
@ -73,6 +80,19 @@ export default function Editor() {
|
|||
|
||||
const { copy } = useClipboard();
|
||||
const connSnap = useFileConnections(projectId ?? "");
|
||||
const setSourcesMeta = useProjectSourcesStore((state) => state.setSources);
|
||||
const clearSourcesMeta = useProjectSourcesStore((state) => state.clear);
|
||||
const getLanguagePath = useProjectSourcesStore((state) => state.getLanguagePath);
|
||||
// const nativeLanguageTargets = [];
|
||||
const sourcesLanguages = useProjectSourcesStore((state) => state.languages);
|
||||
|
||||
const nativeLanguageTargets = useMemo(() => {
|
||||
return sourcesLanguages.filter((meta) => !!meta.path).map((meta) => meta.language);
|
||||
}, [sourcesLanguages]);
|
||||
|
||||
const hasNativeSources = useProjectSourcesStore(
|
||||
(state) => state.projectId === (projectId ?? "") && state.mode === "directory"
|
||||
);
|
||||
const openProjectTab = useProjectTabsStore((state) => state.openProjectTab);
|
||||
const upsertProjectMeta = useProjectTabsStore((state) => state.upsertProjectMeta);
|
||||
const forgetProjectInTabs = useProjectTabsStore((state) => state.forgetProject);
|
||||
|
|
@ -103,6 +123,68 @@ export default function Editor() {
|
|||
requestAnimationFrame(() => tryFindAndAnimate(0));
|
||||
}
|
||||
|
||||
const applyLoadedSources =
|
||||
async (loaded: LoadedProjectSources, opts?: { silent?: boolean }) => {
|
||||
if (!projectId) return false;
|
||||
setSourcesMeta(toProjectSourcesState(loaded));
|
||||
const nextValues: Record<string, Record<string, string>> = {};
|
||||
for (const file of loaded.languages) {
|
||||
if (!file.content) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(file.content);
|
||||
} catch (err) {
|
||||
console.error(`解析语言文件 ${file.language} 失败`, err);
|
||||
continue;
|
||||
}
|
||||
const flattened = flattenValues(parsed);
|
||||
nextValues[file.language] = flattened;
|
||||
await upsertLanguageTranslations(projectId, file.language, flattened);
|
||||
if (!structure && loaded.default_language && file.language === loaded.default_language) {
|
||||
try {
|
||||
const root = buildStructureFromObject(parsed);
|
||||
await upsertStructure({ projectId, root });
|
||||
setStructure({ projectId, root });
|
||||
} catch (err) {
|
||||
console.error("生成结构失败", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
const loadedLanguages = Object.keys(nextValues);
|
||||
if (loadedLanguages.length === 0) {
|
||||
return false;
|
||||
}
|
||||
setValuesByLang((old) => ({ ...old, ...nextValues }));
|
||||
setLanguages(loadedLanguages);
|
||||
if (!opts?.silent) {
|
||||
toast.success("翻译内容已同步", {
|
||||
description: loaded.base_dir ? `来源:${loaded.base_dir}` : undefined,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const syncNativeSources = useCallback(
|
||||
async (opts?: { silent?: boolean }) => {
|
||||
if (!projectId || !isTauriEnv()) return false;
|
||||
try {
|
||||
const loaded = await loadNativeProjectSources(projectId);
|
||||
return await applyLoadedSources(loaded, opts);
|
||||
} catch (err) {
|
||||
console.error("同步本地语言文件失败", err);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[applyLoadedSources, projectId]
|
||||
);
|
||||
|
||||
const handleWizardSourcesLoaded = useCallback(
|
||||
async (loaded: LoadedProjectSources) => {
|
||||
await applyLoadedSources(loaded, { silent: true });
|
||||
},
|
||||
[applyLoadedSources]
|
||||
);
|
||||
|
||||
const handleMove = useCallback(async (path: string, offset: number) => {
|
||||
if (!projectId || !structure) return;
|
||||
try {
|
||||
|
|
@ -147,6 +229,7 @@ export default function Editor() {
|
|||
if (s) setStructure(s);
|
||||
const langs = await listLanguages(projectId);
|
||||
setLanguages(langs);
|
||||
await syncNativeSources();
|
||||
} catch (e) {
|
||||
setPageError((e as Error)?.message ?? "加载失败");
|
||||
} finally {
|
||||
|
|
@ -159,6 +242,10 @@ export default function Editor() {
|
|||
openProjectTab({ id: projectId });
|
||||
}, [projectId, openProjectTab]);
|
||||
|
||||
useEffect(() => {
|
||||
clearSourcesMeta();
|
||||
}, [projectId, clearSourcesMeta]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
|
@ -188,20 +275,31 @@ export default function Editor() {
|
|||
|
||||
const handleSaveAllConnected = useCallback(async () => {
|
||||
if (!projectId || !structure) return;
|
||||
const connectionEntries = Object.entries(connSnap.connections);
|
||||
if (connectionEntries.length === 0) {
|
||||
toast.info("没有已连接的语言");
|
||||
const targetLanguages = new Set<string>(Object.keys(connSnap.connections));
|
||||
if (hasNativeSources) {
|
||||
nativeLanguageTargets.forEach((lang) => targetLanguages.add(lang));
|
||||
}
|
||||
if (targetLanguages.size === 0) {
|
||||
toast.info("没有可保存的语言");
|
||||
return;
|
||||
}
|
||||
setSavingAll(true);
|
||||
try {
|
||||
const orderedPaths = flattenEntries(structure.root).map((e) => e.path);
|
||||
const results = await Promise.allSettled(
|
||||
connectionEntries.map(async ([lang]) => {
|
||||
Array.from(targetLanguages).map(async (lang) => {
|
||||
const jsonText = generateLanguageJson(valuesByLang, lang, orderedPaths);
|
||||
const hasNativePath = hasNativeSources && isTauriEnv() && !!getLanguagePath(lang);
|
||||
if (hasNativePath) {
|
||||
await saveNativeLanguageFile(projectId, lang, jsonText);
|
||||
return lang;
|
||||
}
|
||||
if (connSnap.connections[lang]) {
|
||||
const ok = await writeLanguageToConnectedFile(projectId, lang, jsonText);
|
||||
if (!ok) throw new Error(lang);
|
||||
return lang;
|
||||
}
|
||||
throw new Error(`${lang} 未绑定到任何文件`);
|
||||
})
|
||||
);
|
||||
const failed: string[] = [];
|
||||
|
|
@ -220,7 +318,7 @@ export default function Editor() {
|
|||
} finally {
|
||||
setSavingAll(false);
|
||||
}
|
||||
}, [projectId, structure, connSnap.connections, valuesByLang]);
|
||||
}, [projectId, structure, connSnap.connections, valuesByLang, hasNativeSources, nativeLanguageTargets, getLanguagePath]);
|
||||
|
||||
function computeSuggestedLanguages(pathsInput: string[]): string[] {
|
||||
if (languages.length === 0 || pathsInput.length === 0) return [];
|
||||
|
|
@ -469,30 +567,60 @@ export default function Editor() {
|
|||
};
|
||||
}, [projectId, languages]);
|
||||
|
||||
const disabledItems = useMemo(() => {
|
||||
return {
|
||||
read: !structure,
|
||||
save: !structure,
|
||||
"import-with-directory": !structure,
|
||||
"import-with-files": !structure,
|
||||
export: languages.length === 0,
|
||||
};
|
||||
}, [structure, languages.length]);
|
||||
|
||||
const onClickItem = useCallback((item: CommonMenubarItem) => {
|
||||
switch (item) {
|
||||
case "read":
|
||||
// setSourcesWizardOpen(true);
|
||||
break;
|
||||
case "save":
|
||||
handleSaveAllConnected();
|
||||
break;
|
||||
case "import-with-directory":
|
||||
setSourcesWizardOpen(true);
|
||||
break;
|
||||
case "import-with-files":
|
||||
setImportOpen(true);
|
||||
break;
|
||||
case "export":
|
||||
setExportOpen(true);
|
||||
break;
|
||||
}
|
||||
}, [setSourcesWizardOpen, handleSaveAllConnected]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background">
|
||||
<ProjectTabsBar />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="h-14 bg-linear-to-r from-blue-400 to-cyan-400 px-2 text-white md:px-4">
|
||||
<div className="grid grid-cols-3 h-full items-center">
|
||||
<div className="text-center font-medium truncate">
|
||||
{project?.name ?? "编辑器"}
|
||||
</div>
|
||||
<header className="flex flex-row items-center justify-between px-2 md:px-4 py-2 border-b border-border/70">
|
||||
<CommonMenubar disabledItems={disabledItems} onClickItem={onClickItem} />
|
||||
<div className="flex items-center justify-end gap-2 text-foreground">
|
||||
<HeaderConnectionIndicator projectId={projectId ?? ""} />
|
||||
{!structure ? (
|
||||
<Button onClick={() => { setImportOpen(true); }}>构建翻译结构</Button>
|
||||
<Button onClick={() => { setSourcesWizardOpen(true); }}>
|
||||
<FolderOpen />
|
||||
通过目录导入
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => { setSourcesWizardOpen(true); }}>
|
||||
<FolderOpen />
|
||||
目录导入
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => { setImportOpen(true); }}>
|
||||
<Reply />
|
||||
导入
|
||||
</Button>
|
||||
)}
|
||||
{languages.length > 0 && (
|
||||
<Button variant="outline" onClick={() => setExportOpen(true)}>
|
||||
<Download />
|
||||
导出
|
||||
单文件导入
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{projectId && (
|
||||
<SyncFromFilesButton
|
||||
|
|
@ -518,7 +646,6 @@ export default function Editor() {
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-auto p-4 md:p-6">
|
||||
|
|
@ -534,7 +661,10 @@ export default function Editor() {
|
|||
该项目还没有翻译结构。请导入一份 JSON 文件来构建结构,并同时录入该语言的翻译内容。
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button onClick={() => { setImportOpen(true); }}>构建翻译结构</Button>
|
||||
<Button onClick={() => { setSourcesWizardOpen(true); }}>
|
||||
<FolderOpen />
|
||||
通过目录导入
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -662,6 +792,14 @@ export default function Editor() {
|
|||
</main>
|
||||
</div>
|
||||
|
||||
<ProjectSourcesWizard
|
||||
open={sourcesWizardOpen}
|
||||
onOpenChange={setSourcesWizardOpen}
|
||||
projectId={projectId ?? ""}
|
||||
onCompleted={refresh}
|
||||
onSourcesLoaded={handleWizardSourcesLoaded}
|
||||
/>
|
||||
|
||||
<ImportLanguageModal
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
export type LanguageFileMeta = {
|
||||
language: string;
|
||||
path: string;
|
||||
exists: boolean;
|
||||
modified_ms?: number | null;
|
||||
};
|
||||
|
||||
export type ProjectSourcesState = {
|
||||
projectId: string | null;
|
||||
mode: "directory" | "flat" | null;
|
||||
baseDir?: string | null;
|
||||
defaultLanguage?: string | null;
|
||||
relativePath?: string | null;
|
||||
languages: LanguageFileMeta[];
|
||||
syncedAt?: number | null;
|
||||
};
|
||||
|
||||
type ProjectSourcesActions = {
|
||||
setSources: (state: ProjectSourcesState) => void;
|
||||
clear: () => void;
|
||||
getLanguagePath: (language: string) => string | undefined;
|
||||
};
|
||||
|
||||
const initialState: ProjectSourcesState = {
|
||||
projectId: null,
|
||||
mode: null,
|
||||
languages: [],
|
||||
syncedAt: null,
|
||||
};
|
||||
|
||||
export const useProjectSourcesStore = create<ProjectSourcesState & ProjectSourcesActions>((set, get) => ({
|
||||
...initialState,
|
||||
setSources: (state) =>
|
||||
set(() => ({
|
||||
...initialState,
|
||||
...state,
|
||||
})),
|
||||
clear: () => set(() => initialState),
|
||||
getLanguagePath: (language) => {
|
||||
const meta = get().languages.find((lang) => lang.language === language);
|
||||
return meta?.path;
|
||||
},
|
||||
}));
|
||||
Loading…
Reference in New Issue