feat: 实现项目管理功能 v0.1.1

新功能:
- 项目创建:支持项目名称和本地路径绑定
- 项目列表:简洁大方的卡片式布局展示
- 项目编辑:支持项目信息修改
- 项目删除:支持项目软删除
- 路径选择:集成系统文件夹选择对话框
- 路径验证:实时验证项目路径有效性

 架构设计:
- 遵循 Tauri 开发规范的四层架构设计
- 基础设施层:数据库管理、文件系统操作
- 数据访问层:项目仓库模式、SQLite 集成
- 业务逻辑层:项目服务、数据验证
- 表示层:Tauri 命令、前端组件

 UI/UX:
- 使用 Tailwind CSS 实现简洁大方的设计风格
- 响应式布局适配不同屏幕尺寸
- 流畅的动画效果和交互反馈
- 完整的错误处理和用户提示

 技术栈:
- 后端:Rust + Tauri + SQLite + 四层架构
- 前端:React + TypeScript + Tailwind CSS + Zustand
- 测试:Rust 单元测试 + Vitest 前端测试
- 工具:pnpm 包管理 + 类型安全保证

 质量保证:
- Rust 单元测试覆盖核心业务逻辑
- 前端组件测试覆盖主要 UI 组件
- TypeScript 严格模式确保类型安全
- 遵循开发规范的代码质量标准

 核心特性:
- 项目管理:创建、查看、编辑、删除项目
- 路径管理:自动验证、绝对路径转换
- 数据持久化:SQLite 本地数据库存储
- 状态管理:Zustand 响应式状态管理
- 错误处理:完整的错误捕获和用户反馈
This commit is contained in:
imeepos
2025-07-13 18:46:58 +08:00
parent dd9882c4ac
commit 42c5dcef8e
41 changed files with 5407 additions and 177 deletions

12
0.1.1.md Normal file
View File

@@ -0,0 +1,12 @@
## 0.1.1 核心功能开发
## 项目管理 功能
- 添加项目 填写表单
1. 填写项目名称
2. 选择绑定的本地路径作为本项目的根目录
首页是项目列表页面
1. 要求简洁/大方

498
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -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"

View File

@@ -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<Option<Database>>,
pub project_repository: Mutex<Option<ProjectRepository>>,
}
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<std::sync::MutexGuard<Option<ProjectRepository>>> {
Ok(self.project_repository.lock().unwrap())
}
}

View File

@@ -0,0 +1 @@
pub mod services;

View File

@@ -0,0 +1 @@
pub mod project_service;

View File

@@ -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<Project> {
// 验证请求数据
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<Vec<Project>> {
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<Option<Project>> {
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<Project> {
// 验证请求数据
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<bool> {
if path.trim().is_empty() {
return Ok(false);
}
FileSystemService::is_valid_project_directory(path)
}
/// 获取目录名称作为默认项目名
pub fn get_default_project_name(path: &str) -> Result<String> {
FileSystemService::get_directory_name(path)
}
}

View File

@@ -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<String>,
pub default_project_path: Option<String>,
}
#[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);
}
}
}

View File

@@ -0,0 +1,2 @@
pub mod models;
pub mod repositories;

View File

@@ -0,0 +1 @@
pub mod project;

View File

@@ -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<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub is_active: bool,
}
impl Project {
/// 创建新项目实例
pub fn new(name: String, path: String, description: Option<String>) -> 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<String>, description: Option<String>) {
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<String>,
}
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<String>,
pub description: Option<String>,
}
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());
}
}

View File

@@ -0,0 +1 @@
pub mod project_repository;

View File

@@ -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<Mutex<Connection>>,
}
impl ProjectRepository {
/// 创建新的项目仓库实例
pub fn new(connection: Arc<Mutex<Connection>>) -> Result<Self> {
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<Option<Project>> {
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<Option<Project>> {
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<Vec<Project>> {
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<bool> {
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<Project> {
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",
})
}
}

View File

@@ -0,0 +1,104 @@
use rusqlite::{Connection, Result};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
/// 数据库管理器
/// 遵循 Tauri 开发规范的数据库设计模式
pub struct Database {
connection: Arc<Mutex<Connection>>,
}
impl Database {
/// 创建新的数据库实例
/// 遵循安全第一原则,确保数据库文件的安全存储
pub fn new() -> Result<Self> {
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<Mutex<Connection>> {
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")
}
}
}

View File

@@ -0,0 +1,170 @@
use std::path::Path;
use anyhow::Result;
/// 文件系统操作工具
/// 遵循 Tauri 开发规范的文件系统安全操作
pub struct FileSystemService;
impl FileSystemService {
/// 验证路径是否存在且可访问
pub fn validate_path(path: &str) -> Result<bool> {
let path = Path::new(path);
Ok(path.exists() && path.is_dir())
}
/// 获取路径的绝对路径
pub fn get_absolute_path(path: &str) -> Result<String> {
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<bool> {
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<String> {
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));
}
}

View File

@@ -0,0 +1,2 @@
pub mod database;
pub mod file_system;

View File

@@ -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<AppState> = 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");
}

View File

@@ -0,0 +1,2 @@
pub mod project_commands;
pub mod system_commands;

View File

@@ -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<Project, String> {
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<Vec<Project>, 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<Option<Project>, 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<Project, String> {
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<bool, String> {
ProjectService::validate_project_path(&path)
.map_err(|e| e.to_string())
}
/// 获取默认项目名称命令
#[command]
pub async fn get_default_project_name(path: String) -> Result<String, String> {
ProjectService::get_default_project_name(&path)
.map_err(|e| e.to_string())
}

View File

@@ -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<Option<String>, 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<AppInfo, String> {
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<bool, String> {
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<String, String> {
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()),
}
}

View File

@@ -0,0 +1 @@
pub mod commands;

View File

@@ -28,6 +28,7 @@
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",

View File

@@ -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;
}
}

View File

@@ -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 (
<main className="container">
<h1>Welcome to Tauri + React</h1>
<div className="row">
<a href="https://vitejs.dev" target="_blank">
<img src="/vite.svg" className="logo vite" alt="Vite logo" />
</a>
<a href="https://tauri.app" target="_blank">
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
</a>
<a href="https://reactjs.org" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<p>Click on the Tauri, Vite, and React logos to learn more.</p>
<form
className="row"
onSubmit={(e) => {
e.preventDefault();
greet();
}}
>
<input
id="greet-input"
onChange={(e) => setName(e.currentTarget.value)}
placeholder="Enter a name..."
/>
<button type="submit">Greet</button>
</form>
<p>{greetMsg}</p>
<div className="min-h-screen bg-gray-50">
<main className="container mx-auto px-6 py-8 max-w-7xl">
<ProjectList />
</main>
{/* 创建项目模态框 */}
{showCreateProjectModal && (
<ProjectForm
onSubmit={handleCreateProject}
onCancel={closeCreateProjectModal}
/>
)}
{/* 编辑项目模态框 */}
{showEditProjectModal && editingProject && (
<ProjectForm
initialData={editingProject}
onSubmit={handleUpdateProject}
onCancel={closeEditProjectModal}
isEdit={true}
/>
)}
</div>
);
}

View File

@@ -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<EmptyStateProps> = ({
title,
description,
actionText,
onAction
}) => {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="text-gray-400 mb-6">
<FolderPlus size={64} />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">{title}</h3>
<p className="text-gray-600 mb-8 max-w-md">{description}</p>
<button className="btn btn-primary" onClick={onAction}>
{actionText}
</button>
</div>
);
};

View File

@@ -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<ErrorMessageProps> = ({
message,
onRetry,
onDismiss
}) => {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<AlertCircle size={20} className="text-red-500 flex-shrink-0" />
<span className="text-red-700">{message}</span>
</div>
<div className="flex items-center gap-2">
{onRetry && (
<button className="btn btn-secondary btn-sm" onClick={onRetry}>
<RefreshCw size={14} />
</button>
)}
{onDismiss && (
<button className="btn btn-ghost btn-sm" onClick={onDismiss}>
<X size={14} />
</button>
)}
</div>
</div>
);
};

View File

@@ -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<LoadingSpinnerProps> = ({
size = 'medium',
text
}) => {
const sizeMap = {
small: 16,
medium: 24,
large: 32
};
return (
<div className={`flex items-center justify-center gap-3 ${size === 'small' ? 'p-4' : 'p-8'}`}>
<Loader2 size={sizeMap[size]} className="spinner text-primary-600" />
{text && <span className="text-gray-600">{text}</span>}
</div>
);
};

View File

@@ -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<ProjectCardProps> = ({
project,
onOpen,
onEdit,
onDelete
}) => {
const [showMenu, setShowMenu] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(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 (
<div className="card p-6 hover:shadow-lg hover:-translate-y-1 transition-all duration-200 group cursor-pointer animate-slide-up">
<div className="flex items-center justify-between mb-4">
<div className="text-primary-600 group-hover:text-primary-700 transition-colors duration-200">
<Folder size={24} />
</div>
<div className="relative" ref={menuRef}>
<button
className="p-1 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors duration-200 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
setShowMenu(!showMenu);
}}
>
<MoreVertical size={16} />
</button>
{showMenu && (
<div className="dropdown-menu animate-fade-in">
<button
className="dropdown-item"
onClick={() => {
onOpen(project);
setShowMenu(false);
}}
>
<ExternalLink size={14} />
</button>
<button
className="dropdown-item"
onClick={() => {
onEdit(project);
setShowMenu(false);
}}
>
<Edit3 size={14} />
</button>
<button
className="dropdown-item danger"
onClick={() => {
onDelete(project.id);
setShowMenu(false);
}}
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
</div>
<div className="mb-4" onClick={() => onOpen(project)}>
<h3 className="text-lg font-semibold text-gray-900 mb-2 line-clamp-1">
{project.name}
</h3>
{project.description && (
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
{project.description}
</p>
)}
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-gray-500">
<MapPin size={12} />
<span className="truncate" title={project.path}>
{getDirectoryName(project.path)}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500">
<Calendar size={12} />
<span>
{formatTime(project.updated_at)}
</span>
</div>
</div>
</div>
<div className="flex gap-2 pt-4 border-t border-gray-100">
<button
className="btn btn-secondary btn-sm flex-1"
onClick={() => onEdit(project)}
>
</button>
<button
className="btn btn-primary btn-sm flex-1"
onClick={() => onOpen(project)}
>
</button>
</div>
</div>
);
};

View File

@@ -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<ProjectFormData>;
onSubmit: (data: ProjectFormData) => Promise<void>;
onCancel: () => void;
isEdit?: boolean;
}
/**
* 项目表单组件
* 遵循 Tauri 开发规范的表单设计模式
*/
export const ProjectForm: React.FC<ProjectFormProps> = ({
initialData,
onSubmit,
onCancel,
isEdit = false
}) => {
const { isLoading, validateProjectPath, getDefaultProjectName } = useProjectStore();
const [formData, setFormData] = useState<ProjectFormData>({
name: initialData?.name || '',
path: initialData?.path || '',
description: initialData?.description || ''
});
const [errors, setErrors] = useState<ProjectFormErrors>({});
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<string | null>('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 (
<div className="modal-overlay">
<div className="modal">
<div className="modal-header">
<h2 className="text-xl font-semibold text-gray-900">
{isEdit ? '编辑项目' : '新建项目'}
</h2>
<button className="modal-close" onClick={onCancel}>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="space-y-2">
<label htmlFor="name" className="form-label">
*
</label>
<input
id="name"
type="text"
className={`form-input ${errors.name ? 'error' : ''}`}
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="输入项目名称"
maxLength={100}
/>
{errors.name && (
<div className="form-error">
<AlertCircle size={14} />
{errors.name}
</div>
)}
</div>
<div className="space-y-2">
<label htmlFor="path" className="form-label">
*
</label>
<div className="flex gap-2">
<input
id="path"
type="text"
className={`form-input ${errors.path ? 'error' : ''}`}
value={formData.path}
onChange={(e) => setFormData(prev => ({ ...prev, path: e.target.value }))}
placeholder="选择或输入项目路径"
readOnly={isEdit}
/>
{!isEdit && (
<button
type="button"
className="btn btn-secondary whitespace-nowrap"
onClick={handleSelectDirectory}
>
<Folder size={16} />
</button>
)}
</div>
{isValidatingPath && (
<div className="form-info">...</div>
)}
{errors.path && (
<div className="form-error">
<AlertCircle size={14} />
{errors.path}
</div>
)}
</div>
<div className="space-y-2">
<label htmlFor="description" className="form-label">
</label>
<textarea
id="description"
className={`form-textarea ${errors.description ? 'error' : ''}`}
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="输入项目描述(可选)"
rows={3}
maxLength={500}
/>
<div className="form-hint">
{formData.description.length}/500
</div>
{errors.description && (
<div className="form-error">
<AlertCircle size={14} />
{errors.description}
</div>
)}
</div>
<div className="flex gap-3 pt-4 border-t border-gray-200">
<button
type="button"
className="btn btn-secondary flex-1"
onClick={onCancel}
disabled={isLoading}
>
</button>
<button
type="submit"
className="btn btn-primary flex-1"
disabled={isLoading || isValidatingPath || Object.keys(errors).length > 0}
>
{isLoading ? '处理中...' : (isEdit ? '保存' : '创建')}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,144 @@
import React, { useEffect } from 'react';
import { useProjectStore } from '../store/projectStore';
import { useUIStore } from '../store/uiStore';
import { ProjectCard } from './ProjectCard';
import { EmptyState } from './EmptyState';
import { LoadingSpinner } from './LoadingSpinner';
import { ErrorMessage } from './ErrorMessage';
import { Plus } from 'lucide-react';
/**
* 项目列表组件
* 遵循 Tauri 开发规范的组件设计模式
*/
export const ProjectList: React.FC = () => {
const {
projects,
isLoading,
error,
loadProjects,
deleteProject,
setCurrentProject,
clearError
} = useProjectStore();
const {
openCreateProjectModal,
openEditProjectModal
} = useUIStore();
// 组件挂载时加载项目
useEffect(() => {
loadProjects();
}, [loadProjects]);
// 处理项目打开
const handleProjectOpen = (project: any) => {
setCurrentProject(project);
// TODO: 导航到项目详情页面
console.log('打开项目:', project);
};
// 处理项目编辑
const handleProjectEdit = (project: any) => {
openEditProjectModal(project);
};
// 处理项目删除
const handleProjectDelete = async (id: string) => {
if (window.confirm('确定要删除这个项目吗?此操作不可撤销。')) {
try {
await deleteProject(id);
} catch (error) {
console.error('删除项目失败:', error);
}
}
};
// 错误状态
if (error) {
return (
<div className="w-full animate-fade-in">
<div className="flex items-center justify-between mb-8 pb-4 border-b border-gray-200">
<h1 className="text-3xl font-bold text-gray-900"></h1>
<button
className="btn btn-primary"
onClick={openCreateProjectModal}
>
<Plus size={20} />
</button>
</div>
<ErrorMessage
message={error}
onRetry={loadProjects}
onDismiss={clearError}
/>
</div>
);
}
// 加载状态
if (isLoading && projects.length === 0) {
return (
<div className="w-full animate-fade-in">
<div className="flex items-center justify-between mb-8 pb-4 border-b border-gray-200">
<h1 className="text-3xl font-bold text-gray-900"></h1>
<button
className="btn btn-primary"
onClick={openCreateProjectModal}
>
<Plus size={20} />
</button>
</div>
<div className="flex items-center justify-center py-16">
<LoadingSpinner text="加载项目中..." />
</div>
</div>
);
}
return (
<div className="w-full">
<div className="flex items-center justify-between mb-8 pb-4 border-b border-gray-200">
<h1 className="text-3xl font-bold text-gray-900"></h1>
<button
className="btn btn-primary"
onClick={openCreateProjectModal}
disabled={isLoading}
>
<Plus size={20} />
</button>
</div>
{projects.length === 0 ? (
<EmptyState
title="还没有项目"
description="创建您的第一个项目开始使用 MixVideo"
actionText="新建项目"
onAction={openCreateProjectModal}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{projects.map((project) => (
<ProjectCard
key={project.id}
project={project}
onOpen={handleProjectOpen}
onEdit={handleProjectEdit}
onDelete={handleProjectDelete}
/>
))}
</div>
)}
{isLoading && projects.length > 0 && (
<div className="fixed inset-0 bg-black bg-opacity-20 flex items-center justify-center z-40">
<LoadingSpinner size="small" />
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ProjectCard } from '../ProjectCard';
import { Project } from '../../types/project';
// Mock date-fns
vi.mock('date-fns', () => ({
formatDistanceToNow: vi.fn(() => '2 天前'),
}));
vi.mock('date-fns/locale', () => ({
zhCN: {},
}));
const mockProject: Project = {
id: '1',
name: 'Test Project',
path: '/path/to/project',
description: 'Test description',
created_at: '2023-01-01T00:00:00Z',
updated_at: '2023-01-02T00:00:00Z',
is_active: true,
};
const mockProps = {
project: mockProject,
onOpen: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
};
describe('ProjectCard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders project information correctly', () => {
render(<ProjectCard {...mockProps} />);
expect(screen.getByText('Test Project')).toBeInTheDocument();
expect(screen.getByText('Test description')).toBeInTheDocument();
expect(screen.getByText('project')).toBeInTheDocument();
expect(screen.getByText('2 天前')).toBeInTheDocument();
});
it('calls onOpen when open button is clicked', () => {
render(<ProjectCard {...mockProps} />);
const openButton = screen.getByText('打开');
fireEvent.click(openButton);
expect(mockProps.onOpen).toHaveBeenCalledWith(mockProject);
});
it('calls onEdit when edit button is clicked', () => {
render(<ProjectCard {...mockProps} />);
const editButton = screen.getByText('编辑');
fireEvent.click(editButton);
expect(mockProps.onEdit).toHaveBeenCalledWith(mockProject);
});
it('renders without description', () => {
const projectWithoutDescription = { ...mockProject, description: undefined };
render(<ProjectCard {...mockProps} project={projectWithoutDescription} />);
expect(screen.getByText('Test Project')).toBeInTheDocument();
expect(screen.queryByText('Test description')).not.toBeInTheDocument();
});
it('shows menu when more button is clicked', () => {
render(<ProjectCard {...mockProps} />);
// 找到更多按钮并点击 - 使用更通用的选择器
const moreButtons = screen.getAllByRole('button');
const moreButton = moreButtons.find(button =>
button.querySelector('svg') && !button.textContent?.includes('编辑') && !button.textContent?.includes('打开')
);
if (moreButton) {
fireEvent.click(moreButton);
// 检查菜单项是否显示
expect(screen.getByText('打开项目')).toBeInTheDocument();
expect(screen.getAllByText('编辑')).toHaveLength(2); // 一个在菜单中,一个在按钮中
expect(screen.getByText('删除')).toBeInTheDocument();
}
});
it('calls onDelete when delete menu item is clicked', () => {
render(<ProjectCard {...mockProps} />);
// 打开菜单
const moreButtons = screen.getAllByRole('button');
const moreButton = moreButtons.find(button =>
button.querySelector('svg') && !button.textContent?.includes('编辑') && !button.textContent?.includes('打开')
);
if (moreButton) {
fireEvent.click(moreButton);
// 点击删除
const deleteButton = screen.getByText('删除');
fireEvent.click(deleteButton);
expect(mockProps.onDelete).toHaveBeenCalledWith(mockProject.id);
}
});
it('extracts directory name correctly', () => {
const projectWithWindowsPath = {
...mockProject,
path: 'C:\\Users\\test\\MyProject',
};
render(<ProjectCard {...mockProps} project={projectWithWindowsPath} />);
expect(screen.getByText('MyProject')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,224 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useProjectStore } from '../projectStore';
import { Project, CreateProjectRequest } from '../../types/project';
// Mock Tauri API
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}));
const { invoke } = await import('@tauri-apps/api/core');
const mockInvoke = vi.mocked(invoke);
const mockProject: Project = {
id: '1',
name: 'Test Project',
path: '/path/to/project',
description: 'Test description',
created_at: '2023-01-01T00:00:00Z',
updated_at: '2023-01-02T00:00:00Z',
is_active: true,
};
describe('ProjectStore', () => {
beforeEach(() => {
vi.clearAllMocks();
// 重置 store 状态
useProjectStore.setState({
projects: [],
currentProject: null,
isLoading: false,
error: null,
});
});
describe('loadProjects', () => {
it('loads projects successfully', async () => {
const mockProjects = [mockProject];
mockInvoke.mockResolvedValueOnce(mockProjects);
const store = useProjectStore.getState();
await store.loadProjects();
const state = useProjectStore.getState();
expect(state.projects).toEqual(mockProjects);
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
expect(mockInvoke).toHaveBeenCalledWith('get_all_projects');
});
it('handles load projects error', async () => {
const errorMessage = 'Failed to load projects';
mockInvoke.mockRejectedValueOnce(errorMessage);
const store = useProjectStore.getState();
await store.loadProjects();
const state = useProjectStore.getState();
expect(state.projects).toEqual([]);
expect(state.isLoading).toBe(false);
expect(state.error).toBe(errorMessage);
});
});
describe('createProject', () => {
it('creates project successfully', async () => {
const createRequest: CreateProjectRequest = {
name: 'New Project',
path: '/new/path',
description: 'New description',
};
mockInvoke.mockResolvedValueOnce(mockProject);
const store = useProjectStore.getState();
await store.createProject(createRequest);
const state = useProjectStore.getState();
expect(state.projects).toContain(mockProject);
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
expect(mockInvoke).toHaveBeenCalledWith('create_project', { request: createRequest });
});
it('handles create project error', async () => {
const createRequest: CreateProjectRequest = {
name: 'New Project',
path: '/new/path',
};
const errorMessage = 'Failed to create project';
mockInvoke.mockRejectedValueOnce(errorMessage);
const store = useProjectStore.getState();
await expect(store.createProject(createRequest)).rejects.toBe(errorMessage);
const state = useProjectStore.getState();
expect(state.isLoading).toBe(false);
expect(state.error).toBe(errorMessage);
});
});
describe('updateProject', () => {
it('updates project successfully', async () => {
const updatedProject = { ...mockProject, name: 'Updated Project' };
// 设置初始状态
useProjectStore.setState({
projects: [mockProject],
currentProject: mockProject,
isLoading: false,
error: null,
});
mockInvoke.mockResolvedValueOnce(updatedProject);
const store = useProjectStore.getState();
await store.updateProject('1', { name: 'Updated Project' });
const state = useProjectStore.getState();
expect(state.projects[0]).toEqual(updatedProject);
expect(state.currentProject).toEqual(updatedProject);
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
});
});
describe('deleteProject', () => {
it('deletes project successfully', async () => {
// 设置初始状态
useProjectStore.setState({
projects: [mockProject],
currentProject: mockProject,
isLoading: false,
error: null,
});
mockInvoke.mockResolvedValueOnce(undefined);
const store = useProjectStore.getState();
await store.deleteProject('1');
const state = useProjectStore.getState();
expect(state.projects).toEqual([]);
expect(state.currentProject).toBeNull();
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
});
});
describe('setCurrentProject', () => {
it('sets current project', () => {
const store = useProjectStore.getState();
store.setCurrentProject(mockProject);
const state = useProjectStore.getState();
expect(state.currentProject).toEqual(mockProject);
});
it('clears current project', () => {
useProjectStore.setState({ currentProject: mockProject });
const store = useProjectStore.getState();
store.setCurrentProject(null);
const state = useProjectStore.getState();
expect(state.currentProject).toBeNull();
});
});
describe('clearError', () => {
it('clears error state', () => {
useProjectStore.setState({ error: 'Some error' });
const store = useProjectStore.getState();
store.clearError();
const state = useProjectStore.getState();
expect(state.error).toBeNull();
});
});
describe('validateProjectPath', () => {
it('validates project path successfully', async () => {
mockInvoke.mockResolvedValueOnce(true);
const store = useProjectStore.getState();
const result = await store.validateProjectPath('/valid/path');
expect(result).toBe(true);
expect(mockInvoke).toHaveBeenCalledWith('validate_project_path', { path: '/valid/path' });
});
it('handles validation error', async () => {
mockInvoke.mockRejectedValueOnce(new Error('Validation failed'));
const store = useProjectStore.getState();
const result = await store.validateProjectPath('/invalid/path');
expect(result).toBe(false);
});
});
describe('getDefaultProjectName', () => {
it('gets default project name successfully', async () => {
mockInvoke.mockResolvedValueOnce('ProjectName');
const store = useProjectStore.getState();
const result = await store.getDefaultProjectName('/path/to/ProjectName');
expect(result).toBe('ProjectName');
expect(mockInvoke).toHaveBeenCalledWith('get_default_project_name', { path: '/path/to/ProjectName' });
});
it('handles get default name error', async () => {
mockInvoke.mockRejectedValueOnce(new Error('Failed to get name'));
const store = useProjectStore.getState();
const result = await store.getDefaultProjectName('/invalid/path');
expect(result).toBe('');
});
});
});

View File

@@ -0,0 +1,134 @@
import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import { Project, CreateProjectRequest, UpdateProjectRequest } from '../types/project';
/**
* 项目状态管理
* 遵循 Tauri 开发规范的状态管理模式
*/
interface ProjectState {
// 状态
projects: Project[];
currentProject: Project | null;
isLoading: boolean;
error: string | null;
// 操作
loadProjects: () => Promise<void>;
createProject: (request: CreateProjectRequest) => Promise<void>;
updateProject: (id: string, request: UpdateProjectRequest) => Promise<void>;
deleteProject: (id: string) => Promise<void>;
setCurrentProject: (project: Project | null) => void;
clearError: () => void;
// 工具方法
validateProjectPath: (path: string) => Promise<boolean>;
getDefaultProjectName: (path: string) => Promise<string>;
}
export const useProjectStore = create<ProjectState>((set, get) => ({
// 初始状态
projects: [],
currentProject: null,
isLoading: false,
error: null,
// 加载所有项目
loadProjects: async () => {
set({ isLoading: true, error: null });
try {
const projects = await invoke<Project[]>('get_all_projects');
set({ projects, isLoading: false });
} catch (error) {
set({
error: error as string,
isLoading: false,
projects: []
});
}
},
// 创建项目
createProject: async (request: CreateProjectRequest) => {
set({ isLoading: true, error: null });
try {
const newProject = await invoke<Project>('create_project', { request });
const { projects } = get();
set({
projects: [newProject, ...projects],
isLoading: false
});
} catch (error) {
set({ error: error as string, isLoading: false });
throw error;
}
},
// 更新项目
updateProject: async (id: string, request: UpdateProjectRequest) => {
set({ isLoading: true, error: null });
try {
const updatedProject = await invoke<Project>('update_project', { id, request });
const { projects } = get();
const updatedProjects = projects.map(p =>
p.id === id ? updatedProject : p
);
set({
projects: updatedProjects,
currentProject: get().currentProject?.id === id ? updatedProject : get().currentProject,
isLoading: false
});
} catch (error) {
set({ error: error as string, isLoading: false });
throw error;
}
},
// 删除项目
deleteProject: async (id: string) => {
set({ isLoading: true, error: null });
try {
await invoke('delete_project', { id });
const { projects, currentProject } = get();
const filteredProjects = projects.filter(p => p.id !== id);
set({
projects: filteredProjects,
currentProject: currentProject?.id === id ? null : currentProject,
isLoading: false
});
} catch (error) {
set({ error: error as string, isLoading: false });
throw error;
}
},
// 设置当前项目
setCurrentProject: (project: Project | null) => {
set({ currentProject: project });
},
// 清除错误
clearError: () => {
set({ error: null });
},
// 验证项目路径
validateProjectPath: async (path: string): Promise<boolean> => {
try {
return await invoke<boolean>('validate_project_path', { path });
} catch (error) {
console.error('验证路径失败:', error);
return false;
}
},
// 获取默认项目名称
getDefaultProjectName: async (path: string): Promise<string> => {
try {
return await invoke<string>('get_default_project_name', { path });
} catch (error) {
console.error('获取默认项目名称失败:', error);
return '';
}
},
}));

View File

@@ -0,0 +1,94 @@
import { create } from 'zustand';
/**
* UI 状态管理
* 遵循 Tauri 开发规范的 UI 状态管理模式
*/
interface UIState {
// 主题
theme: 'light' | 'dark';
// 布局状态
sidebarOpen: boolean;
// 当前视图
currentView: string;
// 模态框状态
showCreateProjectModal: boolean;
showEditProjectModal: boolean;
editingProject: any | null;
// 操作
setTheme: (theme: 'light' | 'dark') => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setCurrentView: (view: string) => void;
// 模态框操作
openCreateProjectModal: () => void;
closeCreateProjectModal: () => void;
openEditProjectModal: (project: any) => void;
closeEditProjectModal: () => void;
}
export const useUIStore = create<UIState>((set) => ({
// 初始状态
theme: 'light',
sidebarOpen: true,
currentView: 'projects',
showCreateProjectModal: false,
showEditProjectModal: false,
editingProject: null,
// 主题操作
setTheme: (theme) => {
set({ theme });
// 保存到本地存储
localStorage.setItem('mixvideo-theme', theme);
},
// 侧边栏操作
toggleSidebar: () => {
set((state) => ({ sidebarOpen: !state.sidebarOpen }));
},
setSidebarOpen: (open) => {
set({ sidebarOpen: open });
},
// 视图操作
setCurrentView: (view) => {
set({ currentView: view });
},
// 创建项目模态框
openCreateProjectModal: () => {
set({ showCreateProjectModal: true });
},
closeCreateProjectModal: () => {
set({ showCreateProjectModal: false });
},
// 编辑项目模态框
openEditProjectModal: (project) => {
set({
showEditProjectModal: true,
editingProject: project
});
},
closeEditProjectModal: () => {
set({
showEditProjectModal: false,
editingProject: null
});
},
}));
// 初始化主题
const savedTheme = localStorage.getItem('mixvideo-theme') as 'light' | 'dark' | null;
if (savedTheme) {
useUIStore.getState().setTheme(savedTheme);
}

View File

@@ -0,0 +1,52 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// Mock Tauri API
global.window = Object.create(window);
Object.defineProperty(window, '__TAURI__', {
value: {
invoke: vi.fn(),
convertFileSrc: vi.fn(),
},
writable: true,
});
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
unobserve() {}
};
// Mock ResizeObserver
global.ResizeObserver = class ResizeObserver {
constructor() {}
disconnect() {}
observe() {}
unobserve() {}
};
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
global.localStorage = localStorageMock;

View File

@@ -0,0 +1,107 @@
/**
* 项目相关类型定义
* 遵循 Tauri 开发规范的类型安全设计
*/
export interface Project {
id: string;
name: string;
path: string;
description?: string;
created_at: string;
updated_at: string;
is_active: boolean;
}
export interface CreateProjectRequest {
name: string;
path: string;
description?: string;
}
export interface UpdateProjectRequest {
name?: string;
description?: string;
}
export interface AppInfo {
name: string;
version: string;
platform: string;
}
/**
* Tauri 命令类型定义
* 确保前后端类型一致性
*/
export interface TauriCommands {
// 项目管理命令
create_project(request: CreateProjectRequest): Promise<Project>;
get_all_projects(): Promise<Project[]>;
get_project_by_id(id: string): Promise<Project | null>;
update_project(id: string, request: UpdateProjectRequest): Promise<Project>;
delete_project(id: string): Promise<void>;
validate_project_path(path: string): Promise<boolean>;
get_default_project_name(path: string): Promise<string>;
// 系统命令
select_directory(): Promise<string | null>;
get_app_info(): Promise<AppInfo>;
validate_directory(path: string): Promise<boolean>;
get_directory_name(path: string): Promise<string>;
}
/**
* 应用状态类型定义
*/
export interface AppState {
projects: Project[];
currentProject: Project | null;
isLoading: boolean;
error: string | null;
}
export interface UIState {
theme: 'light' | 'dark';
sidebarOpen: boolean;
currentView: string;
}
/**
* 表单状态类型
*/
export interface ProjectFormData {
name: string;
path: string;
description: string;
}
export interface ProjectFormErrors {
name?: string;
path?: string;
description?: string;
}
/**
* 组件 Props 类型
*/
export interface ProjectCardProps {
project: Project;
onEdit: (project: Project) => void;
onDelete: (id: string) => void;
onOpen: (project: Project) => void;
}
export interface ProjectFormProps {
initialData?: Partial<ProjectFormData>;
onSubmit: (data: ProjectFormData) => void;
onCancel: () => void;
isLoading?: boolean;
}
export interface ProjectListProps {
projects: Project[];
onProjectSelect: (project: Project) => void;
onProjectEdit: (project: Project) => void;
onProjectDelete: (id: string) => void;
}

View File

@@ -0,0 +1,56 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
gray: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
}
},
fontFamily: {
sans: ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'sans-serif'],
},
animation: {
'spin-slow': 'spin 3s linear infinite',
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,29 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
css: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
'**/*.d.ts',
'**/*.config.*',
'dist/',
],
},
},
resolve: {
alias: {
'@': '/src',
},
},
});

2075
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff