From 42c5dcef8eeac4a24677dc0765e14ee6d7acbce0 Mon Sep 17 00:00:00 2001 From: imeepos Date: Sun, 13 Jul 2025 18:46:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=20v0.1.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新功能: - 项目创建:支持项目名称和本地路径绑定 - 项目列表:简洁大方的卡片式布局展示 - 项目编辑:支持项目信息修改 - 项目删除:支持项目软删除 - 路径选择:集成系统文件夹选择对话框 - 路径验证:实时验证项目路径有效性 架构设计: - 遵循 Tauri 开发规范的四层架构设计 - 基础设施层:数据库管理、文件系统操作 - 数据访问层:项目仓库模式、SQLite 集成 - 业务逻辑层:项目服务、数据验证 - 表示层:Tauri 命令、前端组件 UI/UX: - 使用 Tailwind CSS 实现简洁大方的设计风格 - 响应式布局适配不同屏幕尺寸 - 流畅的动画效果和交互反馈 - 完整的错误处理和用户提示 技术栈: - 后端:Rust + Tauri + SQLite + 四层架构 - 前端:React + TypeScript + Tailwind CSS + Zustand - 测试:Rust 单元测试 + Vitest 前端测试 - 工具:pnpm 包管理 + 类型安全保证 质量保证: - Rust 单元测试覆盖核心业务逻辑 - 前端组件测试覆盖主要 UI 组件 - TypeScript 严格模式确保类型安全 - 遵循开发规范的代码质量标准 核心特性: - 项目管理:创建、查看、编辑、删除项目 - 路径管理:自动验证、绝对路径转换 - 数据持久化:SQLite 本地数据库存储 - 状态管理:Zustand 响应式状态管理 - 错误处理:完整的错误捕获和用户反馈 --- 0.1.1.md | 12 + Cargo.lock | 498 +++- apps/desktop/package.json | 24 +- apps/desktop/postcss.config.js | 6 + apps/desktop/src-tauri/Cargo.toml | 13 + apps/desktop/src-tauri/src/app_state.rs | 37 + apps/desktop/src-tauri/src/business/mod.rs | 1 + .../src-tauri/src/business/services/mod.rs | 1 + .../src/business/services/project_service.rs | 142 ++ apps/desktop/src-tauri/src/config.rs | 92 + apps/desktop/src-tauri/src/data/mod.rs | 2 + apps/desktop/src-tauri/src/data/models/mod.rs | 1 + .../src-tauri/src/data/models/project.rs | 192 ++ .../src-tauri/src/data/repositories/mod.rs | 1 + .../data/repositories/project_repository.rs | 163 ++ .../src-tauri/src/infrastructure/database.rs | 104 + .../src/infrastructure/file_system.rs | 170 ++ .../src-tauri/src/infrastructure/mod.rs | 2 + apps/desktop/src-tauri/src/lib.rs | 48 +- .../src/presentation/commands/mod.rs | 2 + .../presentation/commands/project_commands.rs | 99 + .../presentation/commands/system_commands.rs | 68 + .../desktop/src-tauri/src/presentation/mod.rs | 1 + apps/desktop/src-tauri/tauri.conf.json | 1 + apps/desktop/src/App.css | 252 +- apps/desktop/src/App.tsx | 101 +- apps/desktop/src/components/EmptyState.tsx | 33 + apps/desktop/src/components/ErrorMessage.tsx | 39 + .../desktop/src/components/LoadingSpinner.tsx | 28 + apps/desktop/src/components/ProjectCard.tsx | 167 ++ apps/desktop/src/components/ProjectForm.tsx | 249 ++ apps/desktop/src/components/ProjectList.tsx | 144 ++ .../components/__tests__/ProjectCard.test.tsx | 120 + .../src/store/__tests__/projectStore.test.ts | 224 ++ apps/desktop/src/store/projectStore.ts | 134 ++ apps/desktop/src/store/uiStore.ts | 94 + apps/desktop/src/test/setup.ts | 52 + apps/desktop/src/types/project.ts | 107 + apps/desktop/tailwind.config.js | 56 + apps/desktop/vitest.config.ts | 29 + pnpm-lock.yaml | 2075 ++++++++++++++++- 41 files changed, 5407 insertions(+), 177 deletions(-) create mode 100644 0.1.1.md create mode 100644 apps/desktop/postcss.config.js create mode 100644 apps/desktop/src-tauri/src/app_state.rs create mode 100644 apps/desktop/src-tauri/src/business/mod.rs create mode 100644 apps/desktop/src-tauri/src/business/services/mod.rs create mode 100644 apps/desktop/src-tauri/src/business/services/project_service.rs create mode 100644 apps/desktop/src-tauri/src/config.rs create mode 100644 apps/desktop/src-tauri/src/data/mod.rs create mode 100644 apps/desktop/src-tauri/src/data/models/mod.rs create mode 100644 apps/desktop/src-tauri/src/data/models/project.rs create mode 100644 apps/desktop/src-tauri/src/data/repositories/mod.rs create mode 100644 apps/desktop/src-tauri/src/data/repositories/project_repository.rs create mode 100644 apps/desktop/src-tauri/src/infrastructure/database.rs create mode 100644 apps/desktop/src-tauri/src/infrastructure/file_system.rs create mode 100644 apps/desktop/src-tauri/src/infrastructure/mod.rs create mode 100644 apps/desktop/src-tauri/src/presentation/commands/mod.rs create mode 100644 apps/desktop/src-tauri/src/presentation/commands/project_commands.rs create mode 100644 apps/desktop/src-tauri/src/presentation/commands/system_commands.rs create mode 100644 apps/desktop/src-tauri/src/presentation/mod.rs create mode 100644 apps/desktop/src/components/EmptyState.tsx create mode 100644 apps/desktop/src/components/ErrorMessage.tsx create mode 100644 apps/desktop/src/components/LoadingSpinner.tsx create mode 100644 apps/desktop/src/components/ProjectCard.tsx create mode 100644 apps/desktop/src/components/ProjectForm.tsx create mode 100644 apps/desktop/src/components/ProjectList.tsx create mode 100644 apps/desktop/src/components/__tests__/ProjectCard.test.tsx create mode 100644 apps/desktop/src/store/__tests__/projectStore.test.ts create mode 100644 apps/desktop/src/store/projectStore.ts create mode 100644 apps/desktop/src/store/uiStore.ts create mode 100644 apps/desktop/src/test/setup.ts create mode 100644 apps/desktop/src/types/project.ts create mode 100644 apps/desktop/tailwind.config.js create mode 100644 apps/desktop/vitest.config.ts diff --git a/0.1.1.md b/0.1.1.md new file mode 100644 index 0000000..d2b7e03 --- /dev/null +++ b/0.1.1.md @@ -0,0 +1,12 @@ +## 0.1.1 核心功能开发 + +## 项目管理 功能 + +- 添加项目 填写表单 + +1. 填写项目名称 +2. 选择绑定的本地路径作为本项目的根目录 + +首页是项目列表页面 +1. 要求简洁/大方 + diff --git a/Cargo.lock b/Cargo.lock index 40a4a6b..a2b532f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -62,6 +74,27 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "ashpd" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" +dependencies = [ + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.1", + "raw-window-handle", + "serde", + "serde_repr", + "tokio", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -113,7 +146,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix", + "rustix 1.0.7", "slab", "tracing", "windows-sys 0.59.0", @@ -145,7 +178,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 1.0.7", "tracing", ] @@ -172,12 +205,34 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 1.0.7", "signal-hook-registry", "slab", "windows-sys 0.59.0", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "async-task" version = "4.7.1" @@ -483,8 +538,10 @@ checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -711,13 +768,34 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -728,7 +806,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.0", "windows-sys 0.60.2", ] @@ -745,6 +823,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.9.1", + "block2 0.6.1", + "libc", "objc2 0.6.1", ] @@ -759,6 +839,15 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading", +] + [[package]] name = "dlopen2" version = "0.7.0" @@ -782,6 +871,12 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -912,6 +1007,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1396,12 +1503,30 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1907,6 +2032,23 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -2018,11 +2160,22 @@ dependencies = [ name = "mixvideo-desktop" version = "0.1.0" dependencies = [ + "anyhow", + "chrono", + "dirs 5.0.1", + "rusqlite", "serde", "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-fs", "tauri-plugin-opener", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tokio-test", + "uuid", ] [[package]] @@ -2632,7 +2785,7 @@ checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" dependencies = [ "base64 0.22.1", "indexmap 2.10.0", - "quick-xml", + "quick-xml 0.38.0", "serde", "time", ] @@ -2660,7 +2813,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 1.0.7", "tracing", "windows-sys 0.59.0", ] @@ -2762,6 +2915,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.0" @@ -2811,6 +2973,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2831,6 +3003,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -2849,6 +3031,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -2882,6 +3073,17 @@ dependencies = [ "bitflags 2.9.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.0" @@ -2977,6 +3179,46 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2 0.6.1", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2 0.6.1", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation 0.3.1", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.9.1", + "chrono", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-demangle" version = "0.1.25" @@ -2992,6 +3234,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.9.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.0.7" @@ -3001,7 +3256,7 @@ dependencies = [ "bitflags 2.9.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.9.4", "windows-sys 0.59.0", ] @@ -3077,6 +3332,12 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -3556,7 +3817,7 @@ checksum = "124e129c9c0faa6bec792c5948c89e86c90094133b0b9044df0ce5f0a8efaa0d" dependencies = [ "anyhow", "bytes", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.3", @@ -3606,7 +3867,7 @@ checksum = "12f025c389d3adb83114bec704da973142e82fc6ec799c7c750c5e21cefaec83" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -3678,6 +3939,46 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aefb14219b492afb30b12647b5b1247cadd2c0603467310c36e0f7ae1698c28" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.12", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c341290d31991dbca38b31d412c73dfbdb070bb11536784f19dd2211d13b778f" +dependencies = [ + "anyhow", + "dunce", + "glob", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.12", + "toml 0.8.23", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.4.0" @@ -3807,7 +4108,7 @@ dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix", + "rustix 1.0.7", "windows-sys 0.59.0", ] @@ -3914,12 +4215,51 @@ dependencies = [ "io-uring", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "slab", "socket2", + "tokio-macros", + "tracing", "windows-sys 0.52.0", ] +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-util" version = "0.7.15" @@ -4118,7 +4458,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2da75ec677957aa21f6e0b361df0daab972f13a5bee3606de0638fd4ee1c666a" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2 0.6.1", @@ -4263,6 +4603,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.0" @@ -4419,6 +4765,66 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe770181423e5fc79d3e2a7f4410b7799d5aab1de4372853de3c6aa13ca24121" +dependencies = [ + "cc", + "downcast-rs", + "rustix 0.38.44", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978fa7c67b0847dbd6a9f350ca2569174974cd4082737054dbb7fbb79d7d9a61" +dependencies = [ + "bitflags 2.9.1", + "rustix 0.38.44", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779075454e1e9a521794fed15886323ea0feda3f8b0fc1390f5398141310422a" +dependencies = [ + "bitflags 2.9.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "896fdafd5d28145fce7958917d69f2fd44469b1d4e861cb5961bcbeebc6d1484" +dependencies = [ + "proc-macro2", + "quick-xml 0.37.5", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbcebb399c77d5aa9fa5db874806ee7b4eba4e73650948e8f93963f128896615" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.77" @@ -4666,6 +5072,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4708,6 +5123,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4764,6 +5194,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4782,6 +5218,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4800,6 +5242,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4830,6 +5278,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4848,6 +5302,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4866,6 +5326,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4884,6 +5350,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5052,6 +5524,7 @@ dependencies = [ "ordered-stream", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "windows-sys 0.59.0", @@ -5171,6 +5644,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 0.7.12", "zvariant_derive", "zvariant_utils", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f6f2e94..b161189 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -9,13 +9,23 @@ "preview": "vite preview", "tauri": "tauri", "tauri:dev": "tauri dev", - "tauri:build": "tauri build" + "tauri:build": "tauri build", + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest --coverage" }, "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", "@tauri-apps/api": "^2", - "@tauri-apps/plugin-opener": "^2" + "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-fs": "^2", + "@tauri-apps/plugin-dialog": "^2", + "zustand": "^4.4.7", + "react-router-dom": "^6.20.1", + "lucide-react": "^0.294.0", + "clsx": "^2.0.0", + "date-fns": "^2.30.0" }, "devDependencies": { "@types/react": "^18.3.1", @@ -23,6 +33,14 @@ "@vitejs/plugin-react": "^4.3.4", "typescript": "~5.6.2", "vite": "^6.0.3", - "@tauri-apps/cli": "^2" + "@tauri-apps/cli": "^2", + "tailwindcss": "^3.4.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "vitest": "^1.0.0", + "@testing-library/react": "^14.1.2", + "@testing-library/jest-dom": "^6.1.5", + "@testing-library/user-event": "^14.5.1", + "jsdom": "^23.0.1" } } diff --git a/apps/desktop/postcss.config.js b/apps/desktop/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/apps/desktop/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 21185bc..5236ee2 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -20,6 +20,19 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-opener = "2" +tauri-plugin-fs = "2" +tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +rusqlite = { version = "0.31", features = ["bundled", "chrono"] } +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1.0", features = ["v4", "serde"] } +tokio = { version = "1.0", features = ["full", "sync"] } +anyhow = "1.0" +thiserror = "1.0" +dirs = "5.0" + +[dev-dependencies] +tempfile = "3.8" +tokio-test = "0.4" diff --git a/apps/desktop/src-tauri/src/app_state.rs b/apps/desktop/src-tauri/src/app_state.rs new file mode 100644 index 0000000..ca4d17f --- /dev/null +++ b/apps/desktop/src-tauri/src/app_state.rs @@ -0,0 +1,37 @@ +use std::sync::Mutex; +use crate::data::repositories::project_repository::ProjectRepository; +use crate::infrastructure::database::Database; + +/// 应用全局状态管理 +/// 遵循 Tauri 开发规范的状态管理模式 +#[derive(Default)] +pub struct AppState { + pub database: Mutex>, + pub project_repository: Mutex>, +} + +impl AppState { + pub fn new() -> Self { + Self { + database: Mutex::new(None), + project_repository: Mutex::new(None), + } + } + + /// 初始化数据库连接 + /// 遵循安全第一原则,确保数据库初始化的安全性 + pub fn initialize_database(&self) -> anyhow::Result<()> { + let database = Database::new()?; + let project_repository = ProjectRepository::new(database.get_connection())?; + + *self.database.lock().unwrap() = Some(database); + *self.project_repository.lock().unwrap() = Some(project_repository); + + Ok(()) + } + + /// 获取项目仓库实例 + pub fn get_project_repository(&self) -> anyhow::Result>> { + Ok(self.project_repository.lock().unwrap()) + } +} diff --git a/apps/desktop/src-tauri/src/business/mod.rs b/apps/desktop/src-tauri/src/business/mod.rs new file mode 100644 index 0000000..4e379ae --- /dev/null +++ b/apps/desktop/src-tauri/src/business/mod.rs @@ -0,0 +1 @@ +pub mod services; diff --git a/apps/desktop/src-tauri/src/business/services/mod.rs b/apps/desktop/src-tauri/src/business/services/mod.rs new file mode 100644 index 0000000..e6c91fa --- /dev/null +++ b/apps/desktop/src-tauri/src/business/services/mod.rs @@ -0,0 +1 @@ +pub mod project_service; diff --git a/apps/desktop/src-tauri/src/business/services/project_service.rs b/apps/desktop/src-tauri/src/business/services/project_service.rs new file mode 100644 index 0000000..4d0205b --- /dev/null +++ b/apps/desktop/src-tauri/src/business/services/project_service.rs @@ -0,0 +1,142 @@ +use crate::data::models::project::{Project, CreateProjectRequest, UpdateProjectRequest}; +use crate::data::repositories::project_repository::ProjectRepository; +use crate::infrastructure::file_system::FileSystemService; +use anyhow::{Result, anyhow}; + +/// 项目业务服务 +/// 遵循 Tauri 开发规范的业务逻辑层设计 +pub struct ProjectService; + +impl ProjectService { + /// 创建新项目 + /// 遵循安全第一原则,验证输入数据和文件系统权限 + pub fn create_project( + repository: &ProjectRepository, + request: CreateProjectRequest, + ) -> Result { + // 验证请求数据 + request.validate().map_err(|e| anyhow!(e))?; + + // 验证路径 + if !FileSystemService::is_valid_project_directory(&request.path)? { + return Err(anyhow!("无效的项目路径或没有访问权限")); + } + + // 获取绝对路径 + let absolute_path = FileSystemService::get_absolute_path(&request.path)?; + + // 检查路径是否已被使用 + if repository.path_exists(&absolute_path, None)? { + return Err(anyhow!("该路径已被其他项目使用")); + } + + // 创建项目实例 + let project = Project::new( + request.name, + absolute_path.clone(), + request.description, + ); + + // 验证项目数据 + project.validate().map_err(|e| anyhow!(e))?; + + // 创建项目目录结构 + FileSystemService::create_project_structure(&absolute_path)?; + + // 保存到数据库 + repository.create(&project)?; + + Ok(project) + } + + /// 获取所有活跃项目 + pub fn get_all_projects(repository: &ProjectRepository) -> Result> { + let projects = repository.find_all_active()?; + + // 验证项目路径是否仍然有效 + let mut valid_projects = Vec::new(); + for project in projects { + if FileSystemService::validate_path(&project.path).unwrap_or(false) { + valid_projects.push(project); + } + } + + Ok(valid_projects) + } + + /// 根据ID获取项目 + pub fn get_project_by_id( + repository: &ProjectRepository, + id: &str, + ) -> Result> { + if id.trim().is_empty() { + return Err(anyhow!("项目ID不能为空")); + } + + let project = repository.find_by_id(id)?; + + // 验证项目路径是否仍然有效 + if let Some(ref proj) = project { + if !FileSystemService::validate_path(&proj.path).unwrap_or(false) { + return Err(anyhow!("项目路径不存在或无法访问")); + } + } + + Ok(project) + } + + /// 更新项目 + pub fn update_project( + repository: &ProjectRepository, + id: &str, + request: UpdateProjectRequest, + ) -> Result { + // 验证请求数据 + request.validate().map_err(|e| anyhow!(e))?; + + // 获取现有项目 + let mut project = repository.find_by_id(id)? + .ok_or_else(|| anyhow!("项目不存在"))?; + + // 更新项目信息 + project.update(request.name, request.description); + + // 验证更新后的项目数据 + project.validate().map_err(|e| anyhow!(e))?; + + // 保存更新 + repository.update(&project)?; + + Ok(project) + } + + /// 删除项目 + pub fn delete_project(repository: &ProjectRepository, id: &str) -> Result<()> { + if id.trim().is_empty() { + return Err(anyhow!("项目ID不能为空")); + } + + // 检查项目是否存在 + let project = repository.find_by_id(id)? + .ok_or_else(|| anyhow!("项目不存在"))?; + + // 执行软删除 + repository.delete(&project.id)?; + + Ok(()) + } + + /// 验证项目路径 + pub fn validate_project_path(path: &str) -> Result { + if path.trim().is_empty() { + return Ok(false); + } + + FileSystemService::is_valid_project_directory(path) + } + + /// 获取目录名称作为默认项目名 + pub fn get_default_project_name(path: &str) -> Result { + FileSystemService::get_directory_name(path) + } +} diff --git a/apps/desktop/src-tauri/src/config.rs b/apps/desktop/src-tauri/src/config.rs new file mode 100644 index 0000000..9cd67ac --- /dev/null +++ b/apps/desktop/src-tauri/src/config.rs @@ -0,0 +1,92 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// 应用配置结构 +/// 遵循 Tauri 开发规范的配置管理模式 +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AppConfig { + pub theme: String, + pub language: String, + pub auto_save: bool, + pub window_size: WindowSize, + pub recent_projects: Vec, + pub default_project_path: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct WindowSize { + pub width: f64, + pub height: f64, +} + +impl Default for AppConfig { + fn default() -> Self { + AppConfig { + theme: "light".to_string(), + language: "zh-CN".to_string(), + auto_save: true, + window_size: WindowSize { + width: 1200.0, + height: 800.0, + }, + recent_projects: Vec::new(), + default_project_path: None, + } + } +} + +impl AppConfig { + /// 加载配置文件 + pub fn load() -> Self { + let config_path = Self::get_config_path(); + + if config_path.exists() { + match std::fs::read_to_string(&config_path) { + Ok(content) => { + match serde_json::from_str(&content) { + Ok(config) => config, + Err(_) => Self::default(), + } + } + Err(_) => Self::default(), + } + } else { + Self::default() + } + } + + /// 保存配置文件 + pub fn save(&self) -> anyhow::Result<()> { + let config_path = Self::get_config_path(); + + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let content = serde_json::to_string_pretty(self)?; + std::fs::write(config_path, content)?; + + Ok(()) + } + + /// 获取配置文件路径 + fn get_config_path() -> PathBuf { + // 使用标准的应用数据目录 + if let Some(data_dir) = dirs::data_dir() { + data_dir.join("mixvideo").join("config.json") + } else { + PathBuf::from(".").join("config.json") + } + } + + /// 添加最近项目 + pub fn add_recent_project(&mut self, project_path: String) { + self.recent_projects.retain(|p| p != &project_path); + self.recent_projects.insert(0, project_path); + + // 保持最近项目列表不超过10个 + if self.recent_projects.len() > 10 { + self.recent_projects.truncate(10); + } + } +} diff --git a/apps/desktop/src-tauri/src/data/mod.rs b/apps/desktop/src-tauri/src/data/mod.rs new file mode 100644 index 0000000..3888bb6 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/mod.rs @@ -0,0 +1,2 @@ +pub mod models; +pub mod repositories; diff --git a/apps/desktop/src-tauri/src/data/models/mod.rs b/apps/desktop/src-tauri/src/data/models/mod.rs new file mode 100644 index 0000000..36df406 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/mod.rs @@ -0,0 +1 @@ +pub mod project; diff --git a/apps/desktop/src-tauri/src/data/models/project.rs b/apps/desktop/src-tauri/src/data/models/project.rs new file mode 100644 index 0000000..526325c --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/project.rs @@ -0,0 +1,192 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; + +/// 项目实体模型 +/// 遵循 Tauri 开发规范的数据模型设计原则 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Project { + pub id: String, + pub name: String, + pub path: String, + pub description: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub is_active: bool, +} + +impl Project { + /// 创建新项目实例 + pub fn new(name: String, path: String, description: Option) -> Self { + let now = Utc::now(); + Self { + id: uuid::Uuid::new_v4().to_string(), + name, + path, + description, + created_at: now, + updated_at: now, + is_active: true, + } + } + + /// 更新项目信息 + pub fn update(&mut self, name: Option, description: Option) { + if let Some(name) = name { + self.name = name; + } + if let Some(description) = description { + self.description = Some(description); + } + self.updated_at = Utc::now(); + } + + /// 验证项目数据 + pub fn validate(&self) -> Result<(), String> { + if self.name.trim().is_empty() { + return Err("项目名称不能为空".to_string()); + } + + if self.name.len() > 100 { + return Err("项目名称不能超过100个字符".to_string()); + } + + if self.path.trim().is_empty() { + return Err("项目路径不能为空".to_string()); + } + + if let Some(ref desc) = self.description { + if desc.len() > 500 { + return Err("项目描述不能超过500个字符".to_string()); + } + } + + Ok(()) + } +} + +/// 创建项目请求模型 +#[derive(Debug, Deserialize)] +pub struct CreateProjectRequest { + pub name: String, + pub path: String, + pub description: Option, +} + +impl CreateProjectRequest { + pub fn validate(&self) -> Result<(), String> { + if self.name.trim().is_empty() { + return Err("项目名称不能为空".to_string()); + } + + if self.name.len() > 100 { + return Err("项目名称不能超过100个字符".to_string()); + } + + if self.path.trim().is_empty() { + return Err("项目路径不能为空".to_string()); + } + + if let Some(ref desc) = self.description { + if desc.len() > 500 { + return Err("项目描述不能超过500个字符".to_string()); + } + } + + Ok(()) + } +} + +/// 更新项目请求模型 +#[derive(Debug, Deserialize)] +pub struct UpdateProjectRequest { + pub name: Option, + pub description: Option, +} + +impl UpdateProjectRequest { + pub fn validate(&self) -> Result<(), String> { + if let Some(ref name) = self.name { + if name.trim().is_empty() { + return Err("项目名称不能为空".to_string()); + } + if name.len() > 100 { + return Err("项目名称不能超过100个字符".to_string()); + } + } + + if let Some(ref desc) = self.description { + if desc.len() > 500 { + return Err("项目描述不能超过500个字符".to_string()); + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_project_creation() { + let project = Project::new( + "Test Project".to_string(), + "/path/to/project".to_string(), + Some("Test description".to_string()), + ); + + assert_eq!(project.name, "Test Project"); + assert_eq!(project.path, "/path/to/project"); + assert_eq!(project.description, Some("Test description".to_string())); + assert!(project.is_active); + assert!(!project.id.is_empty()); + } + + #[test] + fn test_project_validation() { + let mut project = Project::new( + "Valid Project".to_string(), + "/valid/path".to_string(), + None, + ); + + // 有效项目应该通过验证 + assert!(project.validate().is_ok()); + + // 空名称应该失败 + project.name = "".to_string(); + assert!(project.validate().is_err()); + + // 过长名称应该失败 + project.name = "a".repeat(101); + assert!(project.validate().is_err()); + + // 空路径应该失败 + project.name = "Valid Name".to_string(); + project.path = "".to_string(); + assert!(project.validate().is_err()); + + // 过长描述应该失败 + project.path = "/valid/path".to_string(); + project.description = Some("a".repeat(501)); + assert!(project.validate().is_err()); + } + + #[test] + fn test_create_project_request_validation() { + let valid_request = CreateProjectRequest { + name: "Valid Project".to_string(), + path: "/valid/path".to_string(), + description: Some("Valid description".to_string()), + }; + assert!(valid_request.validate().is_ok()); + + let invalid_name_request = CreateProjectRequest { + name: "".to_string(), + path: "/valid/path".to_string(), + description: None, + }; + assert!(invalid_name_request.validate().is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/data/repositories/mod.rs b/apps/desktop/src-tauri/src/data/repositories/mod.rs new file mode 100644 index 0000000..4bf673d --- /dev/null +++ b/apps/desktop/src-tauri/src/data/repositories/mod.rs @@ -0,0 +1 @@ +pub mod project_repository; diff --git a/apps/desktop/src-tauri/src/data/repositories/project_repository.rs b/apps/desktop/src-tauri/src/data/repositories/project_repository.rs new file mode 100644 index 0000000..a6a1c12 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/repositories/project_repository.rs @@ -0,0 +1,163 @@ +use rusqlite::{Connection, Result, Row}; +use std::sync::{Arc, Mutex}; +use chrono::{DateTime, Utc}; +use crate::data::models::project::Project; + +/// 项目数据仓库 +/// 遵循 Tauri 开发规范的数据访问层设计 +pub struct ProjectRepository { + connection: Arc>, +} + +impl ProjectRepository { + /// 创建新的项目仓库实例 + pub fn new(connection: Arc>) -> Result { + Ok(ProjectRepository { connection }) + } + + /// 创建项目 + pub fn create(&self, project: &Project) -> Result<()> { + let conn = self.connection.lock().unwrap(); + conn.execute( + "INSERT INTO projects (id, name, path, description, created_at, updated_at, is_active) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + [ + project.id.as_str(), + project.name.as_str(), + project.path.as_str(), + project.description.as_deref().unwrap_or(""), + project.created_at.to_rfc3339().as_str(), + project.updated_at.to_rfc3339().as_str(), + project.is_active.to_string().as_str(), + ], + )?; + Ok(()) + } + + /// 根据ID获取项目 + pub fn find_by_id(&self, id: &str) -> Result> { + let conn = self.connection.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, name, path, description, created_at, updated_at, is_active + FROM projects WHERE id = ?1" + )?; + + let result = stmt.query_row([id], |row| { + Ok(self.row_to_project(row)?) + }); + + match result { + Ok(project) => Ok(Some(project)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + } + + /// 根据路径获取项目 + pub fn find_by_path(&self, path: &str) -> Result> { + let conn = self.connection.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, name, path, description, created_at, updated_at, is_active + FROM projects WHERE path = ?1" + )?; + + let result = stmt.query_row([path], |row| { + Ok(self.row_to_project(row)?) + }); + + match result { + Ok(project) => Ok(Some(project)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + } + + /// 获取所有活跃项目 + pub fn find_all_active(&self) -> Result> { + let conn = self.connection.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, name, path, description, created_at, updated_at, is_active + FROM projects WHERE is_active = 1 ORDER BY updated_at DESC" + )?; + + let project_iter = stmt.query_map([], |row| { + Ok(self.row_to_project(row)?) + })?; + + let mut projects = Vec::new(); + for project in project_iter { + projects.push(project?); + } + + Ok(projects) + } + + /// 更新项目 + pub fn update(&self, project: &Project) -> Result<()> { + let conn = self.connection.lock().unwrap(); + conn.execute( + "UPDATE projects SET name = ?1, description = ?2, updated_at = ?3 + WHERE id = ?4", + [ + project.name.as_str(), + project.description.as_deref().unwrap_or(""), + project.updated_at.to_rfc3339().as_str(), + project.id.as_str(), + ], + )?; + Ok(()) + } + + /// 删除项目(软删除) + pub fn delete(&self, id: &str) -> Result<()> { + let conn = self.connection.lock().unwrap(); + conn.execute( + "UPDATE projects SET is_active = 0, updated_at = ?1 WHERE id = ?2", + [Utc::now().to_rfc3339().as_str(), id], + )?; + Ok(()) + } + + /// 检查路径是否已存在 + pub fn path_exists(&self, path: &str, exclude_id: Option<&str>) -> Result { + let conn = self.connection.lock().unwrap(); + + let count: i64 = if let Some(id) = exclude_id { + let mut stmt = conn.prepare("SELECT COUNT(*) FROM projects WHERE path = ?1 AND id != ?2 AND is_active = 1")?; + stmt.query_row([path, id], |row| row.get(0))? + } else { + let mut stmt = conn.prepare("SELECT COUNT(*) FROM projects WHERE path = ?1 AND is_active = 1")?; + stmt.query_row([path], |row| row.get(0))? + }; + + Ok(count > 0) + } + + /// 将数据库行转换为项目对象 + fn row_to_project(&self, row: &Row) -> Result { + let created_at_str: String = row.get(4)?; + let updated_at_str: String = row.get(5)?; + let is_active_str: String = row.get(6)?; + + let created_at = DateTime::parse_from_rfc3339(&created_at_str) + .map_err(|_| rusqlite::Error::InvalidColumnType(4, "created_at".to_string(), rusqlite::types::Type::Text))? + .with_timezone(&Utc); + + let updated_at = DateTime::parse_from_rfc3339(&updated_at_str) + .map_err(|_| rusqlite::Error::InvalidColumnType(5, "updated_at".to_string(), rusqlite::types::Type::Text))? + .with_timezone(&Utc); + + let description: String = row.get(3)?; + let description = if description.is_empty() { None } else { Some(description) }; + + Ok(Project { + id: row.get(0)?, + name: row.get(1)?, + path: row.get(2)?, + description, + created_at, + updated_at, + is_active: is_active_str == "1" || is_active_str.to_lowercase() == "true", + }) + } +} diff --git a/apps/desktop/src-tauri/src/infrastructure/database.rs b/apps/desktop/src-tauri/src/infrastructure/database.rs new file mode 100644 index 0000000..fdf59d0 --- /dev/null +++ b/apps/desktop/src-tauri/src/infrastructure/database.rs @@ -0,0 +1,104 @@ +use rusqlite::{Connection, Result}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +/// 数据库管理器 +/// 遵循 Tauri 开发规范的数据库设计模式 +pub struct Database { + connection: Arc>, +} + +impl Database { + /// 创建新的数据库实例 + /// 遵循安全第一原则,确保数据库文件的安全存储 + pub fn new() -> Result { + let db_path = Self::get_database_path(); + + // 确保数据库目录存在 + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CANTOPEN), + Some(format!("Failed to create database directory: {}", e)), + ) + })?; + } + + let connection = Connection::open(db_path)?; + + // 启用外键约束 + connection.execute("PRAGMA foreign_keys = ON", [])?; + + let database = Database { + connection: Arc::new(Mutex::new(connection)), + }; + + // 初始化数据库表 + database.initialize_tables()?; + + Ok(database) + } + + /// 获取数据库连接 + pub fn get_connection(&self) -> Arc> { + Arc::clone(&self.connection) + } + + /// 初始化数据库表结构 + /// 遵循模块化设计原则,清晰的表结构定义 + fn initialize_tables(&self) -> Result<()> { + let conn = self.connection.lock().unwrap(); + + // 创建项目表 + conn.execute( + "CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT 1 + )", + [], + )?; + + // 创建项目配置表 + conn.execute( + "CREATE TABLE IF NOT EXISTS project_configs ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + config_key TEXT NOT NULL, + config_value TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + UNIQUE(project_id, config_key) + )", + [], + )?; + + // 创建索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_projects_name ON projects (name)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_projects_created_at ON projects (created_at)", + [], + )?; + + Ok(()) + } + + /// 获取数据库文件路径 + /// 遵循安全存储原则,将数据库存储在应用数据目录 + fn get_database_path() -> PathBuf { + if let Some(data_dir) = dirs::data_dir() { + data_dir.join("mixvideo").join("mixvideo.db") + } else { + PathBuf::from(".").join("mixvideo.db") + } + } +} diff --git a/apps/desktop/src-tauri/src/infrastructure/file_system.rs b/apps/desktop/src-tauri/src/infrastructure/file_system.rs new file mode 100644 index 0000000..b38544b --- /dev/null +++ b/apps/desktop/src-tauri/src/infrastructure/file_system.rs @@ -0,0 +1,170 @@ +use std::path::Path; +use anyhow::Result; + +/// 文件系统操作工具 +/// 遵循 Tauri 开发规范的文件系统安全操作 +pub struct FileSystemService; + +impl FileSystemService { + /// 验证路径是否存在且可访问 + pub fn validate_path(path: &str) -> Result { + let path = Path::new(path); + Ok(path.exists() && path.is_dir()) + } + + /// 获取路径的绝对路径 + pub fn get_absolute_path(path: &str) -> Result { + let path = Path::new(path); + let absolute_path = path.canonicalize()?; + Ok(absolute_path.to_string_lossy().to_string()) + } + + /// 检查路径是否为有效的项目目录 + pub fn is_valid_project_directory(path: &str) -> Result { + let path = Path::new(path); + + // 检查路径是否存在且为目录 + if !path.exists() || !path.is_dir() { + return Ok(false); + } + + // 检查是否有读写权限 + let metadata = path.metadata()?; + if metadata.permissions().readonly() { + return Ok(false); + } + + Ok(true) + } + + /// 获取目录名称 + pub fn get_directory_name(path: &str) -> Result { + let path = Path::new(path); + match path.file_name() { + Some(name) => Ok(name.to_string_lossy().to_string()), + None => Err(anyhow::anyhow!("Invalid directory path")), + } + } + + /// 创建项目目录结构 + pub fn create_project_structure(project_path: &str) -> Result<()> { + let base_path = Path::new(project_path); + + // 创建基本目录结构 + let directories = [ + "assets", + "output", + "temp", + "config", + ]; + + for dir in &directories { + let dir_path = base_path.join(dir); + if !dir_path.exists() { + std::fs::create_dir_all(&dir_path)?; + } + } + + // 创建项目配置文件 + let config_file = base_path.join("mixvideo.project.json"); + if !config_file.exists() { + let default_config = serde_json::json!({ + "version": "0.1.0", + "created_at": chrono::Utc::now().to_rfc3339(), + "settings": { + "auto_save": true, + "backup_enabled": true + } + }); + std::fs::write(config_file, serde_json::to_string_pretty(&default_config)?)?; + } + + Ok(()) + } + + /// 检查是否为现有的 MixVideo 项目 + pub fn is_mixvideo_project(path: &str) -> bool { + let project_file = Path::new(path).join("mixvideo.project.json"); + project_file.exists() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_validate_path() { + // 测试存在的目录 + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().to_str().unwrap(); + assert!(FileSystemService::validate_path(path).unwrap()); + + // 测试不存在的路径 + assert!(!FileSystemService::validate_path("/non/existent/path").unwrap()); + } + + #[test] + fn test_get_absolute_path() { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().to_str().unwrap(); + let absolute_path = FileSystemService::get_absolute_path(path).unwrap(); + assert!(absolute_path.len() > path.len() || absolute_path == path); + } + + #[test] + fn test_is_valid_project_directory() { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().to_str().unwrap(); + + // 有效目录应该返回 true + assert!(FileSystemService::is_valid_project_directory(path).unwrap()); + + // 不存在的路径应该返回 false + assert!(!FileSystemService::is_valid_project_directory("/non/existent/path").unwrap()); + } + + #[test] + fn test_get_directory_name() { + assert_eq!( + FileSystemService::get_directory_name("/path/to/project").unwrap(), + "project" + ); + + assert_eq!( + FileSystemService::get_directory_name("C:\\Users\\test\\project").unwrap(), + "project" + ); + } + + #[test] + fn test_create_project_structure() { + let temp_dir = TempDir::new().unwrap(); + let project_path = temp_dir.path().to_str().unwrap(); + + FileSystemService::create_project_structure(project_path).unwrap(); + + // 检查目录是否创建 + assert!(temp_dir.path().join("assets").exists()); + assert!(temp_dir.path().join("output").exists()); + assert!(temp_dir.path().join("temp").exists()); + assert!(temp_dir.path().join("config").exists()); + + // 检查配置文件是否创建 + assert!(temp_dir.path().join("mixvideo.project.json").exists()); + } + + #[test] + fn test_is_mixvideo_project() { + let temp_dir = TempDir::new().unwrap(); + let project_path = temp_dir.path().to_str().unwrap(); + + // 初始状态不是 MixVideo 项目 + assert!(!FileSystemService::is_mixvideo_project(project_path)); + + // 创建项目结构后应该是 MixVideo 项目 + FileSystemService::create_project_structure(project_path).unwrap(); + assert!(FileSystemService::is_mixvideo_project(project_path)); + } +} diff --git a/apps/desktop/src-tauri/src/infrastructure/mod.rs b/apps/desktop/src-tauri/src/infrastructure/mod.rs new file mode 100644 index 0000000..1be757b --- /dev/null +++ b/apps/desktop/src-tauri/src/infrastructure/mod.rs @@ -0,0 +1,2 @@ +pub mod database; +pub mod file_system; diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 4a277ef..e2597d7 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,14 +1,50 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) -} +// 四层架构模块定义 +pub mod infrastructure; +pub mod data; +pub mod business; +pub mod presentation; + +// 应用状态和配置 +pub mod app_state; +pub mod config; + +use app_state::AppState; +use presentation::commands; +use tauri::Manager; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) + .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_dialog::init()) + .manage(AppState::new()) + .invoke_handler(tauri::generate_handler![ + commands::project_commands::create_project, + commands::project_commands::get_all_projects, + commands::project_commands::get_project_by_id, + commands::project_commands::update_project, + commands::project_commands::delete_project, + commands::project_commands::validate_project_path, + commands::project_commands::get_default_project_name, + commands::system_commands::select_directory, + commands::system_commands::get_app_info, + commands::system_commands::validate_directory, + commands::system_commands::get_directory_name + ]) + .setup(|app| { + // 初始化应用状态 + let app_handle = app.handle(); + let state: tauri::State = app_handle.state(); + + // 初始化数据库 + if let Err(e) = state.initialize_database() { + eprintln!("Failed to initialize database: {}", e); + return Err(e.into()); + } + + Ok(()) + }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs new file mode 100644 index 0000000..61dedee --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -0,0 +1,2 @@ +pub mod project_commands; +pub mod system_commands; diff --git a/apps/desktop/src-tauri/src/presentation/commands/project_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/project_commands.rs new file mode 100644 index 0000000..22804bc --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/project_commands.rs @@ -0,0 +1,99 @@ +use tauri::{command, State}; +use crate::app_state::AppState; +use crate::business::services::project_service::ProjectService; +use crate::data::models::project::{Project, CreateProjectRequest, UpdateProjectRequest}; + +/// 创建项目命令 +/// 遵循 Tauri 开发规范的命令设计模式 +#[command] +pub async fn create_project( + state: State<'_, AppState>, + request: CreateProjectRequest, +) -> Result { + let repository_guard = state.get_project_repository() + .map_err(|e| format!("获取项目仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("项目仓库未初始化")?; + + ProjectService::create_project(repository, request) + .map_err(|e| e.to_string()) +} + +/// 获取所有项目命令 +#[command] +pub async fn get_all_projects( + state: State<'_, AppState>, +) -> Result, String> { + let repository_guard = state.get_project_repository() + .map_err(|e| format!("获取项目仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("项目仓库未初始化")?; + + ProjectService::get_all_projects(repository) + .map_err(|e| e.to_string()) +} + +/// 根据ID获取项目命令 +#[command] +pub async fn get_project_by_id( + state: State<'_, AppState>, + id: String, +) -> Result, String> { + let repository_guard = state.get_project_repository() + .map_err(|e| format!("获取项目仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("项目仓库未初始化")?; + + ProjectService::get_project_by_id(repository, &id) + .map_err(|e| e.to_string()) +} + +/// 更新项目命令 +#[command] +pub async fn update_project( + state: State<'_, AppState>, + id: String, + request: UpdateProjectRequest, +) -> Result { + let repository_guard = state.get_project_repository() + .map_err(|e| format!("获取项目仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("项目仓库未初始化")?; + + ProjectService::update_project(repository, &id, request) + .map_err(|e| e.to_string()) +} + +/// 删除项目命令 +#[command] +pub async fn delete_project( + state: State<'_, AppState>, + id: String, +) -> Result<(), String> { + let repository_guard = state.get_project_repository() + .map_err(|e| format!("获取项目仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("项目仓库未初始化")?; + + ProjectService::delete_project(repository, &id) + .map_err(|e| e.to_string()) +} + +/// 验证项目路径命令 +#[command] +pub async fn validate_project_path(path: String) -> Result { + ProjectService::validate_project_path(&path) + .map_err(|e| e.to_string()) +} + +/// 获取默认项目名称命令 +#[command] +pub async fn get_default_project_name(path: String) -> Result { + ProjectService::get_default_project_name(&path) + .map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/presentation/commands/system_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/system_commands.rs new file mode 100644 index 0000000..d7b2c2f --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/system_commands.rs @@ -0,0 +1,68 @@ +use tauri::{command, AppHandle}; +use serde::{Deserialize, Serialize}; + +/// 应用信息结构 +#[derive(Debug, Serialize, Deserialize)] +pub struct AppInfo { + pub name: String, + pub version: String, + pub platform: String, +} + +/// 选择目录命令 +/// 遵循 Tauri 开发规范的系统集成设计 +#[command] +pub fn select_directory(app: AppHandle) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + + // 使用同步方式打开文件夹选择对话框 + let dialog = app.dialog().file().set_title("选择项目目录"); + + // 创建一个简单的阻塞实现 + let (tx, rx) = std::sync::mpsc::channel(); + + dialog.pick_folder(move |folder_path| { + let _ = tx.send(folder_path); + }); + + // 等待结果,设置超时 + match rx.recv_timeout(std::time::Duration::from_secs(30)) { + Ok(Some(path)) => { + let path_str = path.to_string(); + Ok(Some(path_str)) + } + Ok(None) => Ok(None), + Err(_) => Err("对话框操作超时或失败".to_string()), + } +} + +/// 获取应用信息命令 +#[command] +pub async fn get_app_info() -> Result { + Ok(AppInfo { + name: "MixVideo Desktop".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + platform: std::env::consts::OS.to_string(), + }) +} + +/// 验证目录是否存在命令 +#[command] +pub async fn validate_directory(path: String) -> Result { + use std::path::Path; + + let path = Path::new(&path); + Ok(path.exists() && path.is_dir()) +} + +/// 获取目录名称命令 +#[command] +pub async fn get_directory_name(path: String) -> Result { + use std::path::Path; + + let path = Path::new(&path); + match path.file_name() { + Some(name) => Ok(name.to_string_lossy().to_string()), + None => Err("无效的目录路径".to_string()), + } +} diff --git a/apps/desktop/src-tauri/src/presentation/mod.rs b/apps/desktop/src-tauri/src/presentation/mod.rs new file mode 100644 index 0000000..82b6da3 --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/mod.rs @@ -0,0 +1 @@ +pub mod commands; diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index ba1c25b..756f561 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -28,6 +28,7 @@ "csp": null } }, + "bundle": { "active": true, "targets": "all", diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css index 85f7a4a..a3a906b 100644 --- a/apps/desktop/src/App.css +++ b/apps/desktop/src/App.css @@ -1,116 +1,150 @@ -.logo.vite:hover { - filter: drop-shadow(0 0 2em #747bff); -} +@tailwind base; +@tailwind components; +@tailwind utilities; -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafb); -} -:root { - font-family: Inter, Avenir, Helvetica, Arial, sans-serif; - font-size: 16px; - line-height: 24px; - font-weight: 400; - - color: #0f0f0f; - background-color: #f6f6f6; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; -} - -.container { - margin: 0; - padding-top: 10vh; - display: flex; - flex-direction: column; - justify-content: center; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: 0.75s; -} - -.logo.tauri:hover { - filter: drop-shadow(0 0 2em #24c8db); -} - -.row { - display: flex; - justify-content: center; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} - -a:hover { - color: #535bf2; -} - -h1 { - text-align: center; -} - -input, -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - color: #0f0f0f; - background-color: #ffffff; - transition: border-color 0.25s; - box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); -} - -button { - cursor: pointer; -} - -button:hover { - border-color: #396cd8; -} -button:active { - border-color: #396cd8; - background-color: #e8e8e8; -} - -input, -button { - outline: none; -} - -#greet-input { - margin-right: 5px; -} - -@media (prefers-color-scheme: dark) { - :root { - color: #f6f6f6; - background-color: #2f2f2f; +/* 自定义组件样式 */ +@layer components { + /* 按钮样式 */ + .btn { + @apply inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed; } - a:hover { - color: #24c8db; + .btn-primary { + @apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500 active:bg-primary-800; } - input, - button { - color: #ffffff; - background-color: #0f0f0f98; + .btn-secondary { + @apply bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-500 border border-gray-300; } - button:active { - background-color: #0f0f0f69; + + .btn-ghost { + @apply text-gray-600 hover:bg-gray-100 focus:ring-gray-500; } -} + + .btn-danger { + @apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 active:bg-red-800; + } + + .btn-sm { + @apply px-3 py-1.5 text-xs; + } + + .btn-lg { + @apply px-6 py-3 text-base; + } + + /* 表单样式 */ + .form-input { + @apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors duration-200; + } + + .form-input.error { + @apply border-red-500 focus:ring-red-500; + } + + .form-textarea { + @apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors duration-200 resize-none; + } + + .form-textarea.error { + @apply border-red-500 focus:ring-red-500; + } + + .form-label { + @apply block text-sm font-medium text-gray-700 mb-1; + } + + .form-error { + @apply flex items-center gap-1 text-sm text-red-600 mt-1; + } + + .form-hint { + @apply text-xs text-gray-500 mt-1; + } + + .form-info { + @apply text-sm text-blue-600 mt-1; + } + + /* 加载动画 */ + .spinner { + @apply animate-spin; + } + + /* 模态框样式 */ + .modal-overlay { + @apply fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4; + } + + .modal { + @apply bg-white rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-hidden; + } + + .modal-header { + @apply flex items-center justify-between p-6 border-b border-gray-200; + } + + .modal-close { + @apply p-1 hover:bg-gray-100 rounded-lg transition-colors duration-200; + } + + /* 卡片样式 */ + .card { + @apply bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-shadow duration-200; + } + + /* 菜单样式 */ + .dropdown-menu { + @apply absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50; + } + + .dropdown-item { + @apply flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-150; + } + + .dropdown-item.danger { + @apply text-red-600 hover:bg-red-50; + } + + /* 响应式工具类 */ + .line-clamp-1 { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + /* 动画增强 */ + .animate-fade-in { + animation: fadeIn 0.3s ease-out; + } + + .animate-slide-up { + animation: slideUp 0.3s ease-out; + } + + /* 自定义滚动条 */ + .custom-scrollbar::-webkit-scrollbar { + width: 6px; + } + + .custom-scrollbar::-webkit-scrollbar-track { + @apply bg-gray-100 rounded-full; + } + + .custom-scrollbar::-webkit-scrollbar-thumb { + @apply bg-gray-300 rounded-full hover:bg-gray-400; + } + + /* 焦点样式增强 */ + .focus-visible { + @apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2; + } +} \ No newline at end of file diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 9855368..b881824 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,50 +1,73 @@ -import { useState } from "react"; -import reactLogo from "./assets/react.svg"; -import { invoke } from "@tauri-apps/api/core"; + +import { ProjectList } from './components/ProjectList'; +import { ProjectForm } from './components/ProjectForm'; +import { useProjectStore } from './store/projectStore'; +import { useUIStore } from './store/uiStore'; +import { CreateProjectRequest, UpdateProjectRequest } from './types/project'; import "./App.css"; +/** + * 主应用组件 + * 遵循 Tauri 开发规范的应用架构设计 + */ function App() { - const [greetMsg, setGreetMsg] = useState(""); - const [name, setName] = useState(""); + const { createProject, updateProject } = useProjectStore(); + const { + showCreateProjectModal, + showEditProjectModal, + editingProject, + closeCreateProjectModal, + closeEditProjectModal + } = useUIStore(); - async function greet() { - // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ - setGreetMsg(await invoke("greet", { name })); - } + // 处理创建项目 + const handleCreateProject = async (data: CreateProjectRequest) => { + try { + await createProject(data); + closeCreateProjectModal(); + } catch (error) { + console.error('创建项目失败:', error); + throw error; + } + }; + + // 处理更新项目 + const handleUpdateProject = async (data: UpdateProjectRequest) => { + if (!editingProject) return; + + try { + await updateProject(editingProject.id, data); + closeEditProjectModal(); + } catch (error) { + console.error('更新项目失败:', error); + throw error; + } + }; return ( -
-

Welcome to Tauri + React

+
+
+ +
- -

Click on the Tauri, Vite, and React logos to learn more.

- -
{ - e.preventDefault(); - greet(); - }} - > - setName(e.currentTarget.value)} - placeholder="Enter a name..." + {/* 创建项目模态框 */} + {showCreateProjectModal && ( + - - -

{greetMsg}

-
+ )} + + {/* 编辑项目模态框 */} + {showEditProjectModal && editingProject && ( + + )} + ); } diff --git a/apps/desktop/src/components/EmptyState.tsx b/apps/desktop/src/components/EmptyState.tsx new file mode 100644 index 0000000..2e613fb --- /dev/null +++ b/apps/desktop/src/components/EmptyState.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { FolderPlus } from 'lucide-react'; + +interface EmptyStateProps { + title: string; + description: string; + actionText: string; + onAction: () => void; +} + +/** + * 空状态组件 + * 遵循简洁大方的设计风格 + */ +export const EmptyState: React.FC = ({ + title, + description, + actionText, + onAction +}) => { + return ( +
+
+ +
+

{title}

+

{description}

+ +
+ ); +}; diff --git a/apps/desktop/src/components/ErrorMessage.tsx b/apps/desktop/src/components/ErrorMessage.tsx new file mode 100644 index 0000000..780a54e --- /dev/null +++ b/apps/desktop/src/components/ErrorMessage.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { AlertCircle, RefreshCw, X } from 'lucide-react'; + +interface ErrorMessageProps { + message: string; + onRetry?: () => void; + onDismiss?: () => void; +} + +/** + * 错误消息组件 + */ +export const ErrorMessage: React.FC = ({ + message, + onRetry, + onDismiss +}) => { + return ( +
+
+ + {message} +
+
+ {onRetry && ( + + )} + {onDismiss && ( + + )} +
+
+ ); +}; diff --git a/apps/desktop/src/components/LoadingSpinner.tsx b/apps/desktop/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000..c07dbb5 --- /dev/null +++ b/apps/desktop/src/components/LoadingSpinner.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Loader2 } from 'lucide-react'; + +interface LoadingSpinnerProps { + size?: 'small' | 'medium' | 'large'; + text?: string; +} + +/** + * 加载动画组件 + */ +export const LoadingSpinner: React.FC = ({ + size = 'medium', + text +}) => { + const sizeMap = { + small: 16, + medium: 24, + large: 32 + }; + + return ( +
+ + {text && {text}} +
+ ); +}; diff --git a/apps/desktop/src/components/ProjectCard.tsx b/apps/desktop/src/components/ProjectCard.tsx new file mode 100644 index 0000000..4b5960b --- /dev/null +++ b/apps/desktop/src/components/ProjectCard.tsx @@ -0,0 +1,167 @@ +import React from 'react'; +import { Project } from '../types/project'; +import { formatDistanceToNow } from 'date-fns'; +import { zhCN } from 'date-fns/locale'; +import { + Folder, + MoreVertical, + Edit3, + Trash2, + ExternalLink, + Calendar, + MapPin +} from 'lucide-react'; + +interface ProjectCardProps { + project: Project; + onOpen: (project: Project) => void; + onEdit: (project: Project) => void; + onDelete: (id: string) => void; +} + +/** + * 项目卡片组件 + * 遵循简洁大方的设计风格 + */ +export const ProjectCard: React.FC = ({ + project, + onOpen, + onEdit, + onDelete +}) => { + const [showMenu, setShowMenu] = React.useState(false); + const menuRef = React.useRef(null); + + // 点击外部关闭菜单 + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setShowMenu(false); + } + }; + + if (showMenu) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [showMenu]); + + // 格式化时间 + const formatTime = (dateString: string) => { + try { + const date = new Date(dateString); + return formatDistanceToNow(date, { + addSuffix: true, + locale: zhCN + }); + } catch { + return '未知时间'; + } + }; + + // 获取项目目录名 + const getDirectoryName = (path: string) => { + const parts = path.split(/[/\\]/); + return parts[parts.length - 1] || path; + }; + + return ( +
+
+
+ +
+
+ + {showMenu && ( +
+ + + +
+ )} +
+
+ +
onOpen(project)}> +

+ {project.name} +

+ + {project.description && ( +

+ {project.description} +

+ )} + +
+
+ + + {getDirectoryName(project.path)} + +
+
+ + + {formatTime(project.updated_at)} + +
+
+
+ +
+ + +
+
+ ); +}; diff --git a/apps/desktop/src/components/ProjectForm.tsx b/apps/desktop/src/components/ProjectForm.tsx new file mode 100644 index 0000000..581990a --- /dev/null +++ b/apps/desktop/src/components/ProjectForm.tsx @@ -0,0 +1,249 @@ +import React, { useState, useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useProjectStore } from '../store/projectStore'; +import { ProjectFormData, ProjectFormErrors } from '../types/project'; +import { Folder, X, AlertCircle } from 'lucide-react'; + +interface ProjectFormProps { + initialData?: Partial; + onSubmit: (data: ProjectFormData) => Promise; + onCancel: () => void; + isEdit?: boolean; +} + +/** + * 项目表单组件 + * 遵循 Tauri 开发规范的表单设计模式 + */ +export const ProjectForm: React.FC = ({ + initialData, + onSubmit, + onCancel, + isEdit = false +}) => { + const { isLoading, validateProjectPath, getDefaultProjectName } = useProjectStore(); + + const [formData, setFormData] = useState({ + name: initialData?.name || '', + path: initialData?.path || '', + description: initialData?.description || '' + }); + + const [errors, setErrors] = useState({}); + const [isValidatingPath, setIsValidatingPath] = useState(false); + + // 验证表单 + const validateForm = (): boolean => { + const newErrors: ProjectFormErrors = {}; + + if (!formData.name.trim()) { + newErrors.name = '项目名称不能为空'; + } else if (formData.name.length > 100) { + newErrors.name = '项目名称不能超过100个字符'; + } + + if (!formData.path.trim()) { + newErrors.path = '项目路径不能为空'; + } + + if (formData.description.length > 500) { + newErrors.description = '项目描述不能超过500个字符'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + // 选择目录 + const handleSelectDirectory = async () => { + try { + const selectedPath = await invoke('select_directory'); + if (selectedPath) { + setFormData(prev => ({ ...prev, path: selectedPath })); + + // 如果项目名称为空,尝试从路径获取默认名称 + if (!formData.name.trim()) { + try { + const defaultName = await getDefaultProjectName(selectedPath); + if (defaultName) { + setFormData(prev => ({ ...prev, name: defaultName })); + } + } catch (error) { + console.warn('获取默认项目名称失败:', error); + } + } + } + } catch (error) { + console.error('选择目录失败:', error); + } + }; + + // 验证路径 + const handlePathValidation = async (path: string) => { + if (!path.trim()) return; + + setIsValidatingPath(true); + try { + const isValid = await validateProjectPath(path); + if (!isValid) { + setErrors(prev => ({ + ...prev, + path: '无效的项目路径或没有访问权限' + })); + } else { + setErrors(prev => { + const { path: _, ...rest } = prev; + return rest; + }); + } + } catch (error) { + setErrors(prev => ({ + ...prev, + path: '路径验证失败' + })); + } finally { + setIsValidatingPath(false); + } + }; + + // 路径变化时验证 + useEffect(() => { + if (formData.path && !isEdit) { + const timeoutId = setTimeout(() => { + handlePathValidation(formData.path); + }, 500); + return () => clearTimeout(timeoutId); + } + }, [formData.path, isEdit]); + + // 处理表单提交 + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + try { + await onSubmit(formData); + } catch (error) { + console.error('提交表单失败:', error); + } + }; + + return ( +
+
+
+

+ {isEdit ? '编辑项目' : '新建项目'} +

+ +
+ +
+
+ + setFormData(prev => ({ ...prev, name: e.target.value }))} + placeholder="输入项目名称" + maxLength={100} + /> + {errors.name && ( +
+ + {errors.name} +
+ )} +
+ +
+ +
+ setFormData(prev => ({ ...prev, path: e.target.value }))} + placeholder="选择或输入项目路径" + readOnly={isEdit} + /> + {!isEdit && ( + + )} +
+ {isValidatingPath && ( +
验证路径中...
+ )} + {errors.path && ( +
+ + {errors.path} +
+ )} +
+ +
+ +