From cc3a8984c1579c30602772b3115ae8a0fbc13a59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A5=87=E8=B6=A3=E4=BF=9D=E7=BD=97?= Date: Wed, 21 Jan 2026 17:29:39 +0800 Subject: [PATCH] =?UTF-8?q?Feat:=20=E4=BF=AE=E5=A4=8D=20Rust=20=E9=83=A8?= =?UTF-8?q?=E5=88=86=E5=BC=82=E5=B8=B8=EF=BC=8C=E6=94=AF=E6=8C=81=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E7=9B=AE=E5=BD=95=E8=AF=BB=E5=8F=96=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/extensions.json | 3 + package.json | 3 + pnpm-lock.yaml | 52 +++ src-tauri/Cargo.lock | 68 ++++ src-tauri/Cargo.toml | 2 + src-tauri/capabilities/default.json | 3 +- src-tauri/src/lib.rs | 11 + src-tauri/src/sources.rs | 381 +++++++++++++++++ src-tauri/tauri.conf.json | 6 +- src/components/biz/editor/common-menubar.tsx | 64 +++ src/components/biz/project-sources-wizard.tsx | 382 ++++++++++++++++++ src/components/ui/menubar.tsx | 274 +++++++++++++ src/lib/is-tauri.ts | 5 + src/lib/native-sources.ts | 77 ++++ src/pages/editor.tsx | 196 +++++++-- src/store/sources-store.ts | 45 +++ 16 files changed, 1539 insertions(+), 33 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 src-tauri/src/sources.rs create mode 100644 src/components/biz/editor/common-menubar.tsx create mode 100644 src/components/biz/project-sources-wizard.tsx create mode 100644 src/components/ui/menubar.tsx create mode 100644 src/lib/is-tauri.ts create mode 100644 src/lib/native-sources.ts create mode 100644 src/store/sources-store.ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..24d7cc6 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] +} diff --git a/package.json b/package.json index 8b728b7..b5e7900 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e519fc4..aba679e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e7aad54..576a00f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 23b83e3..b1231d4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c135d7f..5b5e8de 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "main" ], "permissions": [ - "core:default" + "core:default", + "dialog:allow-open" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9c3118c..3b8d7b5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); } diff --git a/src-tauri/src/sources.rs b/src-tauri/src/sources.rs new file mode 100644 index 0000000..a873ab2 --- /dev/null +++ b/src-tauri/src/sources.rs @@ -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, // 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, +} + +#[derive(Debug, Serialize)] +pub struct ScanLanguageDirectoryResult { + pub base_dir: String, + pub languages: Vec, +} + +#[derive(Debug, Serialize)] +pub struct LanguageFolderSummary { + pub language: String, + pub file_count: usize, + pub files: Vec, +} + +#[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, + pub default_language: Option, + pub relative_path: Option, + pub languages: Vec, +} + +#[derive(Debug, Serialize)] +pub struct LoadedLanguageFile { + pub language: String, + pub path: String, + pub exists: bool, + pub modified_ms: Option, + pub content: Option, +} + +#[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 { + 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 { + 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 { + 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, 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, 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, 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, Option) { + 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 { + 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 { + 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 { + 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 { + 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}")) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7e1888d..61760f2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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 } diff --git a/src/components/biz/editor/common-menubar.tsx b/src/components/biz/editor/common-menubar.tsx new file mode 100644 index 0000000..7a477bf --- /dev/null +++ b/src/components/biz/editor/common-menubar.tsx @@ -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; + onClickItem: (item: CommonMenubarItem) => void; +} + +function CommonMenubar({ disabledItems, onClickItem }: CommonMenubarProps) { + return ( + + + + 文件 + + + onClickItem("read")} disabled={disabledItems?.read}> + 读取 ⌘R + + onClickItem("save")} disabled={disabledItems?.save}> + 保存 ⌘S + + + + + + 导入 + + + onClickItem("import-with-directory")} disabled={disabledItems?.["import-with-directory"]}> + 通过目录导入 ⌘L + + onClickItem("import-with-files")} disabled={disabledItems?.["import-with-files"]}> + 单文件导入 ⌘I + + + + + + 导出 + + + onClickItem("export")} disabled={disabledItems?.export}>导出 JSON + + + + ); +} + +export default CommonMenubar; diff --git a/src/components/biz/project-sources-wizard.tsx b/src/components/biz/project-sources-wizard.tsx new file mode 100644 index 0000000..f80b2e9 --- /dev/null +++ b/src/components/biz/project-sources-wizard.tsx @@ -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; + onSourcesLoaded?: (loaded: LoadedProjectSources) => void | Promise; +}; + +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(null); + const [scanLoading, setScanLoading] = useState(false); + const [scanError, setScanError] = useState(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( + "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 ( +
+

+ 请选择一个目录,其中包含多个语言子目录(如{" "} + en/messages.jsonzh-CN/messages.json)。 +

+
+ + {scanResult?.base_dir && ( + + 已选择:{scanResult.base_dir} + + )} +
+ {scanResult && ( +
+ 共找到 {scanResult.languages.length} 个语言目录。 +
+ )} + {scanError && ( +
+ {scanError} +
+ )} +
+ ); + } + if (currentStep === 1) { + return ( +
+

+ 请选择默认语言,后续将以它的结构为基准。 +

+
+ {scanResult?.languages.map((lang) => { + const active = defaultLanguage === lang.language; + return ( + + ); + })} +
+
+ ); + } + return ( +
+

+ 请选择默认语言 {defaultLanguage}{" "} + 下需要绑定的语言包文件。 +

+ {selectedLanguageSummary?.files.length ? ( + + ) : ( +
+ 该语言目录下没有找到 JSON 文件,请返回上一步重新选择。 +
+ )} + {selectedLanguageSummary && ( +
+ 将自动匹配其他语言目录中的同名文件({relativePath || "未选择"})。 +
+ )} +
+ ); + } + + return ( + { + if (!submitting) { + onOpenChange(next); + } + }} + > + + + 批量导入语言目录 + + 按照步骤绑定包含多个语言包的目录,下次启动即可自动加载。 + + + +
+ {steps.map((step, idx) => { + const active = idx === currentStep; + const completed = idx < currentStep; + return ( +
+
+ {idx + 1} +
+
+
+ {step.title} +
+
+ {step.description} +
+
+ {idx < steps.length - 1 && ( +
+ )} +
+ ); + })} +
+ + {stepContent()} + + {scanResult && ( +
+
目录:{scanResult.base_dir}
+
默认语言:{defaultLanguage || "未选择"}
+
语言包:{relativePath || "未选择"}
+
+ )} + + + + {currentStep > 0 && ( + + )} + {currentStep < steps.length - 1 && ( + + )} + {currentStep === steps.length - 1 && ( + + )} + + +
+ ); +} diff --git a/src/components/ui/menubar.tsx b/src/components/ui/menubar.tsx new file mode 100644 index 0000000..cb45a86 --- /dev/null +++ b/src/components/ui/menubar.tsx @@ -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) { + return ( + + ) +} + +function MenubarMenu({ + ...props +}: React.ComponentProps) { + return +} + +function MenubarGroup({ + ...props +}: React.ComponentProps) { + return +} + +function MenubarPortal({ + ...props +}: React.ComponentProps) { + return +} + +function MenubarRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function MenubarTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function MenubarContent({ + className, + align = "start", + alignOffset = -4, + sideOffset = 8, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function MenubarItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function MenubarCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function MenubarRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function MenubarLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function MenubarSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function MenubarShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function MenubarSub({ + ...props +}: React.ComponentProps) { + return +} + +function MenubarSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function MenubarSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Menubar, + MenubarPortal, + MenubarMenu, + MenubarTrigger, + MenubarContent, + MenubarGroup, + MenubarSeparator, + MenubarLabel, + MenubarItem, + MenubarShortcut, + MenubarCheckboxItem, + MenubarRadioGroup, + MenubarRadioItem, + MenubarSub, + MenubarSubTrigger, + MenubarSubContent, +} diff --git a/src/lib/is-tauri.ts b/src/lib/is-tauri.ts new file mode 100644 index 0000000..cd075da --- /dev/null +++ b/src/lib/is-tauri.ts @@ -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__); +} diff --git a/src/lib/native-sources.ts b/src/lib/native-sources.ts new file mode 100644 index 0000000..9144206 --- /dev/null +++ b/src/lib/native-sources.ts @@ -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 { + ensureTauri(); + + await invoke("register_directory_sources", { + payload: input, + }); + + return loadNativeProjectSources(input.project_id); +} + +export async function loadNativeProjectSources(projectId: string): Promise { + ensureTauri(); + const loaded = await invoke("load_project_sources", { projectId }); + return loaded; +} + +export async function saveNativeLanguageFile(projectId: string, language: string, content: string): Promise { + 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 桌面应用中运行。"); + } +} diff --git a/src/pages/editor.tsx b/src/pages/editor.tsx index 2de7a19..0575f97 100644 --- a/src/pages/editor.tsx +++ b/src/pages/editor.tsx @@ -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(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(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> = {}; + 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(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 ok = await writeLanguageToConnectedFile(projectId, lang, jsonText); - if (!ok) throw new Error(lang); - return lang; + 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 (
-
-
-
- {project?.name ?? "编辑器"} -
+
+
{!structure ? ( - + ) : ( - - )} - {languages.length > 0 && ( - +
+ + +
)} {projectId && ( )}
-
@@ -534,7 +661,10 @@ export default function Editor() { 该项目还没有翻译结构。请导入一份 JSON 文件来构建结构,并同时录入该语言的翻译内容。
- +
) : ( @@ -662,6 +792,14 @@ export default function Editor() { + + void; + clear: () => void; + getLanguagePath: (language: string) => string | undefined; +}; + +const initialState: ProjectSourcesState = { + projectId: null, + mode: null, + languages: [], + syncedAt: null, +}; + +export const useProjectSourcesStore = create((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; + }, +}));