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:
@@ -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="min-h-screen bg-gray-50">
|
||||
<main className="container mx-auto px-6 py-8 max-w-7xl">
|
||||
<ProjectList />
|
||||
</main>
|
||||
|
||||
<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..."
|
||||
{/* 创建项目模态框 */}
|
||||
{showCreateProjectModal && (
|
||||
<ProjectForm
|
||||
onSubmit={handleCreateProject}
|
||||
onCancel={closeCreateProjectModal}
|
||||
/>
|
||||
<button type="submit">Greet</button>
|
||||
</form>
|
||||
<p>{greetMsg}</p>
|
||||
</main>
|
||||
)}
|
||||
|
||||
{/* 编辑项目模态框 */}
|
||||
{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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user