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:
12
0.1.1.md
Normal file
12
0.1.1.md
Normal file
@@ -0,0 +1,12 @@
|
||||
## 0.1.1 核心功能开发
|
||||
|
||||
## 项目管理 功能
|
||||
|
||||
- 添加项目 填写表单
|
||||
|
||||
1. 填写项目名称
|
||||
2. 选择绑定的本地路径作为本项目的根目录
|
||||
|
||||
首页是项目列表页面
|
||||
1. 要求简洁/大方
|
||||
|
||||
498
Cargo.lock
generated
498
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
6
apps/desktop/postcss.config.js
Normal file
6
apps/desktop/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
37
apps/desktop/src-tauri/src/app_state.rs
Normal file
37
apps/desktop/src-tauri/src/app_state.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
1
apps/desktop/src-tauri/src/business/mod.rs
Normal file
1
apps/desktop/src-tauri/src/business/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod services;
|
||||
1
apps/desktop/src-tauri/src/business/services/mod.rs
Normal file
1
apps/desktop/src-tauri/src/business/services/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod project_service;
|
||||
142
apps/desktop/src-tauri/src/business/services/project_service.rs
Normal file
142
apps/desktop/src-tauri/src/business/services/project_service.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
92
apps/desktop/src-tauri/src/config.rs
Normal file
92
apps/desktop/src-tauri/src/config.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
apps/desktop/src-tauri/src/data/mod.rs
Normal file
2
apps/desktop/src-tauri/src/data/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod models;
|
||||
pub mod repositories;
|
||||
1
apps/desktop/src-tauri/src/data/models/mod.rs
Normal file
1
apps/desktop/src-tauri/src/data/models/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod project;
|
||||
192
apps/desktop/src-tauri/src/data/models/project.rs
Normal file
192
apps/desktop/src-tauri/src/data/models/project.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
1
apps/desktop/src-tauri/src/data/repositories/mod.rs
Normal file
1
apps/desktop/src-tauri/src/data/repositories/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod project_repository;
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
104
apps/desktop/src-tauri/src/infrastructure/database.rs
Normal file
104
apps/desktop/src-tauri/src/infrastructure/database.rs
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
170
apps/desktop/src-tauri/src/infrastructure/file_system.rs
Normal file
170
apps/desktop/src-tauri/src/infrastructure/file_system.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
2
apps/desktop/src-tauri/src/infrastructure/mod.rs
Normal file
2
apps/desktop/src-tauri/src/infrastructure/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod database;
|
||||
pub mod file_system;
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
2
apps/desktop/src-tauri/src/presentation/commands/mod.rs
Normal file
2
apps/desktop/src-tauri/src/presentation/commands/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod project_commands;
|
||||
pub mod system_commands;
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
1
apps/desktop/src-tauri/src/presentation/mod.rs
Normal file
1
apps/desktop/src-tauri/src/presentation/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod commands;
|
||||
@@ -28,6 +28,7 @@
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
33
apps/desktop/src/components/EmptyState.tsx
Normal file
33
apps/desktop/src/components/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
apps/desktop/src/components/ErrorMessage.tsx
Normal file
39
apps/desktop/src/components/ErrorMessage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
apps/desktop/src/components/LoadingSpinner.tsx
Normal file
28
apps/desktop/src/components/LoadingSpinner.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
167
apps/desktop/src/components/ProjectCard.tsx
Normal file
167
apps/desktop/src/components/ProjectCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
249
apps/desktop/src/components/ProjectForm.tsx
Normal file
249
apps/desktop/src/components/ProjectForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
144
apps/desktop/src/components/ProjectList.tsx
Normal file
144
apps/desktop/src/components/ProjectList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
120
apps/desktop/src/components/__tests__/ProjectCard.test.tsx
Normal file
120
apps/desktop/src/components/__tests__/ProjectCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
224
apps/desktop/src/store/__tests__/projectStore.test.ts
Normal file
224
apps/desktop/src/store/__tests__/projectStore.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
134
apps/desktop/src/store/projectStore.ts
Normal file
134
apps/desktop/src/store/projectStore.ts
Normal 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 '';
|
||||
}
|
||||
},
|
||||
}));
|
||||
94
apps/desktop/src/store/uiStore.ts
Normal file
94
apps/desktop/src/store/uiStore.ts
Normal 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);
|
||||
}
|
||||
52
apps/desktop/src/test/setup.ts
Normal file
52
apps/desktop/src/test/setup.ts
Normal 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;
|
||||
107
apps/desktop/src/types/project.ts
Normal file
107
apps/desktop/src/types/project.ts
Normal 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;
|
||||
}
|
||||
56
apps/desktop/tailwind.config.js
Normal file
56
apps/desktop/tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
29
apps/desktop/vitest.config.ts
Normal file
29
apps/desktop/vitest.config.ts
Normal 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
2075
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user