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

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

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

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

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

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

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

View File

@@ -9,13 +9,23 @@
"preview": "vite preview",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
"tauri:build": "tauri build",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2"
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-fs": "^2",
"@tauri-apps/plugin-dialog": "^2",
"zustand": "^4.4.7",
"react-router-dom": "^6.20.1",
"lucide-react": "^0.294.0",
"clsx": "^2.0.0",
"date-fns": "^2.30.0"
},
"devDependencies": {
"@types/react": "^18.3.1",
@@ -23,6 +33,14 @@
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"@tauri-apps/cli": "^2"
"@tauri-apps/cli": "^2",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"vitest": "^1.0.0",
"@testing-library/react": "^14.1.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/user-event": "^14.5.1",
"jsdom": "^23.0.1"
}
}

View File

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

View File

@@ -20,6 +20,19 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.31", features = ["bundled", "chrono"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
tokio = { version = "1.0", features = ["full", "sync"] }
anyhow = "1.0"
thiserror = "1.0"
dirs = "5.0"
[dev-dependencies]
tempfile = "3.8"
tokio-test = "0.4"

View File

@@ -0,0 +1,37 @@
use std::sync::Mutex;
use crate::data::repositories::project_repository::ProjectRepository;
use crate::infrastructure::database::Database;
/// 应用全局状态管理
/// 遵循 Tauri 开发规范的状态管理模式
#[derive(Default)]
pub struct AppState {
pub database: Mutex<Option<Database>>,
pub project_repository: Mutex<Option<ProjectRepository>>,
}
impl AppState {
pub fn new() -> Self {
Self {
database: Mutex::new(None),
project_repository: Mutex::new(None),
}
}
/// 初始化数据库连接
/// 遵循安全第一原则,确保数据库初始化的安全性
pub fn initialize_database(&self) -> anyhow::Result<()> {
let database = Database::new()?;
let project_repository = ProjectRepository::new(database.get_connection())?;
*self.database.lock().unwrap() = Some(database);
*self.project_repository.lock().unwrap() = Some(project_repository);
Ok(())
}
/// 获取项目仓库实例
pub fn get_project_repository(&self) -> anyhow::Result<std::sync::MutexGuard<Option<ProjectRepository>>> {
Ok(self.project_repository.lock().unwrap())
}
}

View File

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

View File

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

View File

@@ -0,0 +1,142 @@
use crate::data::models::project::{Project, CreateProjectRequest, UpdateProjectRequest};
use crate::data::repositories::project_repository::ProjectRepository;
use crate::infrastructure::file_system::FileSystemService;
use anyhow::{Result, anyhow};
/// 项目业务服务
/// 遵循 Tauri 开发规范的业务逻辑层设计
pub struct ProjectService;
impl ProjectService {
/// 创建新项目
/// 遵循安全第一原则,验证输入数据和文件系统权限
pub fn create_project(
repository: &ProjectRepository,
request: CreateProjectRequest,
) -> Result<Project> {
// 验证请求数据
request.validate().map_err(|e| anyhow!(e))?;
// 验证路径
if !FileSystemService::is_valid_project_directory(&request.path)? {
return Err(anyhow!("无效的项目路径或没有访问权限"));
}
// 获取绝对路径
let absolute_path = FileSystemService::get_absolute_path(&request.path)?;
// 检查路径是否已被使用
if repository.path_exists(&absolute_path, None)? {
return Err(anyhow!("该路径已被其他项目使用"));
}
// 创建项目实例
let project = Project::new(
request.name,
absolute_path.clone(),
request.description,
);
// 验证项目数据
project.validate().map_err(|e| anyhow!(e))?;
// 创建项目目录结构
FileSystemService::create_project_structure(&absolute_path)?;
// 保存到数据库
repository.create(&project)?;
Ok(project)
}
/// 获取所有活跃项目
pub fn get_all_projects(repository: &ProjectRepository) -> Result<Vec<Project>> {
let projects = repository.find_all_active()?;
// 验证项目路径是否仍然有效
let mut valid_projects = Vec::new();
for project in projects {
if FileSystemService::validate_path(&project.path).unwrap_or(false) {
valid_projects.push(project);
}
}
Ok(valid_projects)
}
/// 根据ID获取项目
pub fn get_project_by_id(
repository: &ProjectRepository,
id: &str,
) -> Result<Option<Project>> {
if id.trim().is_empty() {
return Err(anyhow!("项目ID不能为空"));
}
let project = repository.find_by_id(id)?;
// 验证项目路径是否仍然有效
if let Some(ref proj) = project {
if !FileSystemService::validate_path(&proj.path).unwrap_or(false) {
return Err(anyhow!("项目路径不存在或无法访问"));
}
}
Ok(project)
}
/// 更新项目
pub fn update_project(
repository: &ProjectRepository,
id: &str,
request: UpdateProjectRequest,
) -> Result<Project> {
// 验证请求数据
request.validate().map_err(|e| anyhow!(e))?;
// 获取现有项目
let mut project = repository.find_by_id(id)?
.ok_or_else(|| anyhow!("项目不存在"))?;
// 更新项目信息
project.update(request.name, request.description);
// 验证更新后的项目数据
project.validate().map_err(|e| anyhow!(e))?;
// 保存更新
repository.update(&project)?;
Ok(project)
}
/// 删除项目
pub fn delete_project(repository: &ProjectRepository, id: &str) -> Result<()> {
if id.trim().is_empty() {
return Err(anyhow!("项目ID不能为空"));
}
// 检查项目是否存在
let project = repository.find_by_id(id)?
.ok_or_else(|| anyhow!("项目不存在"))?;
// 执行软删除
repository.delete(&project.id)?;
Ok(())
}
/// 验证项目路径
pub fn validate_project_path(path: &str) -> Result<bool> {
if path.trim().is_empty() {
return Ok(false);
}
FileSystemService::is_valid_project_directory(path)
}
/// 获取目录名称作为默认项目名
pub fn get_default_project_name(path: &str) -> Result<String> {
FileSystemService::get_directory_name(path)
}
}

View File

@@ -0,0 +1,92 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// 应用配置结构
/// 遵循 Tauri 开发规范的配置管理模式
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppConfig {
pub theme: String,
pub language: String,
pub auto_save: bool,
pub window_size: WindowSize,
pub recent_projects: Vec<String>,
pub default_project_path: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct WindowSize {
pub width: f64,
pub height: f64,
}
impl Default for AppConfig {
fn default() -> Self {
AppConfig {
theme: "light".to_string(),
language: "zh-CN".to_string(),
auto_save: true,
window_size: WindowSize {
width: 1200.0,
height: 800.0,
},
recent_projects: Vec::new(),
default_project_path: None,
}
}
}
impl AppConfig {
/// 加载配置文件
pub fn load() -> Self {
let config_path = Self::get_config_path();
if config_path.exists() {
match std::fs::read_to_string(&config_path) {
Ok(content) => {
match serde_json::from_str(&content) {
Ok(config) => config,
Err(_) => Self::default(),
}
}
Err(_) => Self::default(),
}
} else {
Self::default()
}
}
/// 保存配置文件
pub fn save(&self) -> anyhow::Result<()> {
let config_path = Self::get_config_path();
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(self)?;
std::fs::write(config_path, content)?;
Ok(())
}
/// 获取配置文件路径
fn get_config_path() -> PathBuf {
// 使用标准的应用数据目录
if let Some(data_dir) = dirs::data_dir() {
data_dir.join("mixvideo").join("config.json")
} else {
PathBuf::from(".").join("config.json")
}
}
/// 添加最近项目
pub fn add_recent_project(&mut self, project_path: String) {
self.recent_projects.retain(|p| p != &project_path);
self.recent_projects.insert(0, project_path);
// 保持最近项目列表不超过10个
if self.recent_projects.len() > 10 {
self.recent_projects.truncate(10);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,192 @@
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
/// 项目实体模型
/// 遵循 Tauri 开发规范的数据模型设计原则
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: String,
pub name: String,
pub path: String,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub is_active: bool,
}
impl Project {
/// 创建新项目实例
pub fn new(name: String, path: String, description: Option<String>) -> Self {
let now = Utc::now();
Self {
id: uuid::Uuid::new_v4().to_string(),
name,
path,
description,
created_at: now,
updated_at: now,
is_active: true,
}
}
/// 更新项目信息
pub fn update(&mut self, name: Option<String>, description: Option<String>) {
if let Some(name) = name {
self.name = name;
}
if let Some(description) = description {
self.description = Some(description);
}
self.updated_at = Utc::now();
}
/// 验证项目数据
pub fn validate(&self) -> Result<(), String> {
if self.name.trim().is_empty() {
return Err("项目名称不能为空".to_string());
}
if self.name.len() > 100 {
return Err("项目名称不能超过100个字符".to_string());
}
if self.path.trim().is_empty() {
return Err("项目路径不能为空".to_string());
}
if let Some(ref desc) = self.description {
if desc.len() > 500 {
return Err("项目描述不能超过500个字符".to_string());
}
}
Ok(())
}
}
/// 创建项目请求模型
#[derive(Debug, Deserialize)]
pub struct CreateProjectRequest {
pub name: String,
pub path: String,
pub description: Option<String>,
}
impl CreateProjectRequest {
pub fn validate(&self) -> Result<(), String> {
if self.name.trim().is_empty() {
return Err("项目名称不能为空".to_string());
}
if self.name.len() > 100 {
return Err("项目名称不能超过100个字符".to_string());
}
if self.path.trim().is_empty() {
return Err("项目路径不能为空".to_string());
}
if let Some(ref desc) = self.description {
if desc.len() > 500 {
return Err("项目描述不能超过500个字符".to_string());
}
}
Ok(())
}
}
/// 更新项目请求模型
#[derive(Debug, Deserialize)]
pub struct UpdateProjectRequest {
pub name: Option<String>,
pub description: Option<String>,
}
impl UpdateProjectRequest {
pub fn validate(&self) -> Result<(), String> {
if let Some(ref name) = self.name {
if name.trim().is_empty() {
return Err("项目名称不能为空".to_string());
}
if name.len() > 100 {
return Err("项目名称不能超过100个字符".to_string());
}
}
if let Some(ref desc) = self.description {
if desc.len() > 500 {
return Err("项目描述不能超过500个字符".to_string());
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_project_creation() {
let project = Project::new(
"Test Project".to_string(),
"/path/to/project".to_string(),
Some("Test description".to_string()),
);
assert_eq!(project.name, "Test Project");
assert_eq!(project.path, "/path/to/project");
assert_eq!(project.description, Some("Test description".to_string()));
assert!(project.is_active);
assert!(!project.id.is_empty());
}
#[test]
fn test_project_validation() {
let mut project = Project::new(
"Valid Project".to_string(),
"/valid/path".to_string(),
None,
);
// 有效项目应该通过验证
assert!(project.validate().is_ok());
// 空名称应该失败
project.name = "".to_string();
assert!(project.validate().is_err());
// 过长名称应该失败
project.name = "a".repeat(101);
assert!(project.validate().is_err());
// 空路径应该失败
project.name = "Valid Name".to_string();
project.path = "".to_string();
assert!(project.validate().is_err());
// 过长描述应该失败
project.path = "/valid/path".to_string();
project.description = Some("a".repeat(501));
assert!(project.validate().is_err());
}
#[test]
fn test_create_project_request_validation() {
let valid_request = CreateProjectRequest {
name: "Valid Project".to_string(),
path: "/valid/path".to_string(),
description: Some("Valid description".to_string()),
};
assert!(valid_request.validate().is_ok());
let invalid_name_request = CreateProjectRequest {
name: "".to_string(),
path: "/valid/path".to_string(),
description: None,
};
assert!(invalid_name_request.validate().is_err());
}
}

View File

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

View File

@@ -0,0 +1,163 @@
use rusqlite::{Connection, Result, Row};
use std::sync::{Arc, Mutex};
use chrono::{DateTime, Utc};
use crate::data::models::project::Project;
/// 项目数据仓库
/// 遵循 Tauri 开发规范的数据访问层设计
pub struct ProjectRepository {
connection: Arc<Mutex<Connection>>,
}
impl ProjectRepository {
/// 创建新的项目仓库实例
pub fn new(connection: Arc<Mutex<Connection>>) -> Result<Self> {
Ok(ProjectRepository { connection })
}
/// 创建项目
pub fn create(&self, project: &Project) -> Result<()> {
let conn = self.connection.lock().unwrap();
conn.execute(
"INSERT INTO projects (id, name, path, description, created_at, updated_at, is_active)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
[
project.id.as_str(),
project.name.as_str(),
project.path.as_str(),
project.description.as_deref().unwrap_or(""),
project.created_at.to_rfc3339().as_str(),
project.updated_at.to_rfc3339().as_str(),
project.is_active.to_string().as_str(),
],
)?;
Ok(())
}
/// 根据ID获取项目
pub fn find_by_id(&self, id: &str) -> Result<Option<Project>> {
let conn = self.connection.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, name, path, description, created_at, updated_at, is_active
FROM projects WHERE id = ?1"
)?;
let result = stmt.query_row([id], |row| {
Ok(self.row_to_project(row)?)
});
match result {
Ok(project) => Ok(Some(project)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
}
/// 根据路径获取项目
pub fn find_by_path(&self, path: &str) -> Result<Option<Project>> {
let conn = self.connection.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, name, path, description, created_at, updated_at, is_active
FROM projects WHERE path = ?1"
)?;
let result = stmt.query_row([path], |row| {
Ok(self.row_to_project(row)?)
});
match result {
Ok(project) => Ok(Some(project)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
}
/// 获取所有活跃项目
pub fn find_all_active(&self) -> Result<Vec<Project>> {
let conn = self.connection.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, name, path, description, created_at, updated_at, is_active
FROM projects WHERE is_active = 1 ORDER BY updated_at DESC"
)?;
let project_iter = stmt.query_map([], |row| {
Ok(self.row_to_project(row)?)
})?;
let mut projects = Vec::new();
for project in project_iter {
projects.push(project?);
}
Ok(projects)
}
/// 更新项目
pub fn update(&self, project: &Project) -> Result<()> {
let conn = self.connection.lock().unwrap();
conn.execute(
"UPDATE projects SET name = ?1, description = ?2, updated_at = ?3
WHERE id = ?4",
[
project.name.as_str(),
project.description.as_deref().unwrap_or(""),
project.updated_at.to_rfc3339().as_str(),
project.id.as_str(),
],
)?;
Ok(())
}
/// 删除项目(软删除)
pub fn delete(&self, id: &str) -> Result<()> {
let conn = self.connection.lock().unwrap();
conn.execute(
"UPDATE projects SET is_active = 0, updated_at = ?1 WHERE id = ?2",
[Utc::now().to_rfc3339().as_str(), id],
)?;
Ok(())
}
/// 检查路径是否已存在
pub fn path_exists(&self, path: &str, exclude_id: Option<&str>) -> Result<bool> {
let conn = self.connection.lock().unwrap();
let count: i64 = if let Some(id) = exclude_id {
let mut stmt = conn.prepare("SELECT COUNT(*) FROM projects WHERE path = ?1 AND id != ?2 AND is_active = 1")?;
stmt.query_row([path, id], |row| row.get(0))?
} else {
let mut stmt = conn.prepare("SELECT COUNT(*) FROM projects WHERE path = ?1 AND is_active = 1")?;
stmt.query_row([path], |row| row.get(0))?
};
Ok(count > 0)
}
/// 将数据库行转换为项目对象
fn row_to_project(&self, row: &Row) -> Result<Project> {
let created_at_str: String = row.get(4)?;
let updated_at_str: String = row.get(5)?;
let is_active_str: String = row.get(6)?;
let created_at = DateTime::parse_from_rfc3339(&created_at_str)
.map_err(|_| rusqlite::Error::InvalidColumnType(4, "created_at".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc);
let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)
.map_err(|_| rusqlite::Error::InvalidColumnType(5, "updated_at".to_string(), rusqlite::types::Type::Text))?
.with_timezone(&Utc);
let description: String = row.get(3)?;
let description = if description.is_empty() { None } else { Some(description) };
Ok(Project {
id: row.get(0)?,
name: row.get(1)?,
path: row.get(2)?,
description,
created_at,
updated_at,
is_active: is_active_str == "1" || is_active_str.to_lowercase() == "true",
})
}
}

View File

@@ -0,0 +1,104 @@
use rusqlite::{Connection, Result};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
/// 数据库管理器
/// 遵循 Tauri 开发规范的数据库设计模式
pub struct Database {
connection: Arc<Mutex<Connection>>,
}
impl Database {
/// 创建新的数据库实例
/// 遵循安全第一原则,确保数据库文件的安全存储
pub fn new() -> Result<Self> {
let db_path = Self::get_database_path();
// 确保数据库目录存在
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CANTOPEN),
Some(format!("Failed to create database directory: {}", e)),
)
})?;
}
let connection = Connection::open(db_path)?;
// 启用外键约束
connection.execute("PRAGMA foreign_keys = ON", [])?;
let database = Database {
connection: Arc::new(Mutex::new(connection)),
};
// 初始化数据库表
database.initialize_tables()?;
Ok(database)
}
/// 获取数据库连接
pub fn get_connection(&self) -> Arc<Mutex<Connection>> {
Arc::clone(&self.connection)
}
/// 初始化数据库表结构
/// 遵循模块化设计原则,清晰的表结构定义
fn initialize_tables(&self) -> Result<()> {
let conn = self.connection.lock().unwrap();
// 创建项目表
conn.execute(
"CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT 1
)",
[],
)?;
// 创建项目配置表
conn.execute(
"CREATE TABLE IF NOT EXISTS project_configs (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
config_key TEXT NOT NULL,
config_value TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE,
UNIQUE(project_id, config_key)
)",
[],
)?;
// 创建索引
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_projects_name ON projects (name)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_projects_created_at ON projects (created_at)",
[],
)?;
Ok(())
}
/// 获取数据库文件路径
/// 遵循安全存储原则,将数据库存储在应用数据目录
fn get_database_path() -> PathBuf {
if let Some(data_dir) = dirs::data_dir() {
data_dir.join("mixvideo").join("mixvideo.db")
} else {
PathBuf::from(".").join("mixvideo.db")
}
}
}

View File

@@ -0,0 +1,170 @@
use std::path::Path;
use anyhow::Result;
/// 文件系统操作工具
/// 遵循 Tauri 开发规范的文件系统安全操作
pub struct FileSystemService;
impl FileSystemService {
/// 验证路径是否存在且可访问
pub fn validate_path(path: &str) -> Result<bool> {
let path = Path::new(path);
Ok(path.exists() && path.is_dir())
}
/// 获取路径的绝对路径
pub fn get_absolute_path(path: &str) -> Result<String> {
let path = Path::new(path);
let absolute_path = path.canonicalize()?;
Ok(absolute_path.to_string_lossy().to_string())
}
/// 检查路径是否为有效的项目目录
pub fn is_valid_project_directory(path: &str) -> Result<bool> {
let path = Path::new(path);
// 检查路径是否存在且为目录
if !path.exists() || !path.is_dir() {
return Ok(false);
}
// 检查是否有读写权限
let metadata = path.metadata()?;
if metadata.permissions().readonly() {
return Ok(false);
}
Ok(true)
}
/// 获取目录名称
pub fn get_directory_name(path: &str) -> Result<String> {
let path = Path::new(path);
match path.file_name() {
Some(name) => Ok(name.to_string_lossy().to_string()),
None => Err(anyhow::anyhow!("Invalid directory path")),
}
}
/// 创建项目目录结构
pub fn create_project_structure(project_path: &str) -> Result<()> {
let base_path = Path::new(project_path);
// 创建基本目录结构
let directories = [
"assets",
"output",
"temp",
"config",
];
for dir in &directories {
let dir_path = base_path.join(dir);
if !dir_path.exists() {
std::fs::create_dir_all(&dir_path)?;
}
}
// 创建项目配置文件
let config_file = base_path.join("mixvideo.project.json");
if !config_file.exists() {
let default_config = serde_json::json!({
"version": "0.1.0",
"created_at": chrono::Utc::now().to_rfc3339(),
"settings": {
"auto_save": true,
"backup_enabled": true
}
});
std::fs::write(config_file, serde_json::to_string_pretty(&default_config)?)?;
}
Ok(())
}
/// 检查是否为现有的 MixVideo 项目
pub fn is_mixvideo_project(path: &str) -> bool {
let project_file = Path::new(path).join("mixvideo.project.json");
project_file.exists()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_validate_path() {
// 测试存在的目录
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().to_str().unwrap();
assert!(FileSystemService::validate_path(path).unwrap());
// 测试不存在的路径
assert!(!FileSystemService::validate_path("/non/existent/path").unwrap());
}
#[test]
fn test_get_absolute_path() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().to_str().unwrap();
let absolute_path = FileSystemService::get_absolute_path(path).unwrap();
assert!(absolute_path.len() > path.len() || absolute_path == path);
}
#[test]
fn test_is_valid_project_directory() {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().to_str().unwrap();
// 有效目录应该返回 true
assert!(FileSystemService::is_valid_project_directory(path).unwrap());
// 不存在的路径应该返回 false
assert!(!FileSystemService::is_valid_project_directory("/non/existent/path").unwrap());
}
#[test]
fn test_get_directory_name() {
assert_eq!(
FileSystemService::get_directory_name("/path/to/project").unwrap(),
"project"
);
assert_eq!(
FileSystemService::get_directory_name("C:\\Users\\test\\project").unwrap(),
"project"
);
}
#[test]
fn test_create_project_structure() {
let temp_dir = TempDir::new().unwrap();
let project_path = temp_dir.path().to_str().unwrap();
FileSystemService::create_project_structure(project_path).unwrap();
// 检查目录是否创建
assert!(temp_dir.path().join("assets").exists());
assert!(temp_dir.path().join("output").exists());
assert!(temp_dir.path().join("temp").exists());
assert!(temp_dir.path().join("config").exists());
// 检查配置文件是否创建
assert!(temp_dir.path().join("mixvideo.project.json").exists());
}
#[test]
fn test_is_mixvideo_project() {
let temp_dir = TempDir::new().unwrap();
let project_path = temp_dir.path().to_str().unwrap();
// 初始状态不是 MixVideo 项目
assert!(!FileSystemService::is_mixvideo_project(project_path));
// 创建项目结构后应该是 MixVideo 项目
FileSystemService::create_project_structure(project_path).unwrap();
assert!(FileSystemService::is_mixvideo_project(project_path));
}
}

View File

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

View File

@@ -1,14 +1,50 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
// 四层架构模块定义
pub mod infrastructure;
pub mod data;
pub mod business;
pub mod presentation;
// 应用状态和配置
pub mod app_state;
pub mod config;
use app_state::AppState;
use presentation::commands;
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.manage(AppState::new())
.invoke_handler(tauri::generate_handler![
commands::project_commands::create_project,
commands::project_commands::get_all_projects,
commands::project_commands::get_project_by_id,
commands::project_commands::update_project,
commands::project_commands::delete_project,
commands::project_commands::validate_project_path,
commands::project_commands::get_default_project_name,
commands::system_commands::select_directory,
commands::system_commands::get_app_info,
commands::system_commands::validate_directory,
commands::system_commands::get_directory_name
])
.setup(|app| {
// 初始化应用状态
let app_handle = app.handle();
let state: tauri::State<AppState> = app_handle.state();
// 初始化数据库
if let Err(e) = state.initialize_database() {
eprintln!("Failed to initialize database: {}", e);
return Err(e.into());
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

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

View File

@@ -0,0 +1,99 @@
use tauri::{command, State};
use crate::app_state::AppState;
use crate::business::services::project_service::ProjectService;
use crate::data::models::project::{Project, CreateProjectRequest, UpdateProjectRequest};
/// 创建项目命令
/// 遵循 Tauri 开发规范的命令设计模式
#[command]
pub async fn create_project(
state: State<'_, AppState>,
request: CreateProjectRequest,
) -> Result<Project, String> {
let repository_guard = state.get_project_repository()
.map_err(|e| format!("获取项目仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("项目仓库未初始化")?;
ProjectService::create_project(repository, request)
.map_err(|e| e.to_string())
}
/// 获取所有项目命令
#[command]
pub async fn get_all_projects(
state: State<'_, AppState>,
) -> Result<Vec<Project>, String> {
let repository_guard = state.get_project_repository()
.map_err(|e| format!("获取项目仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("项目仓库未初始化")?;
ProjectService::get_all_projects(repository)
.map_err(|e| e.to_string())
}
/// 根据ID获取项目命令
#[command]
pub async fn get_project_by_id(
state: State<'_, AppState>,
id: String,
) -> Result<Option<Project>, String> {
let repository_guard = state.get_project_repository()
.map_err(|e| format!("获取项目仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("项目仓库未初始化")?;
ProjectService::get_project_by_id(repository, &id)
.map_err(|e| e.to_string())
}
/// 更新项目命令
#[command]
pub async fn update_project(
state: State<'_, AppState>,
id: String,
request: UpdateProjectRequest,
) -> Result<Project, String> {
let repository_guard = state.get_project_repository()
.map_err(|e| format!("获取项目仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("项目仓库未初始化")?;
ProjectService::update_project(repository, &id, request)
.map_err(|e| e.to_string())
}
/// 删除项目命令
#[command]
pub async fn delete_project(
state: State<'_, AppState>,
id: String,
) -> Result<(), String> {
let repository_guard = state.get_project_repository()
.map_err(|e| format!("获取项目仓库失败: {}", e))?;
let repository = repository_guard.as_ref()
.ok_or("项目仓库未初始化")?;
ProjectService::delete_project(repository, &id)
.map_err(|e| e.to_string())
}
/// 验证项目路径命令
#[command]
pub async fn validate_project_path(path: String) -> Result<bool, String> {
ProjectService::validate_project_path(&path)
.map_err(|e| e.to_string())
}
/// 获取默认项目名称命令
#[command]
pub async fn get_default_project_name(path: String) -> Result<String, String> {
ProjectService::get_default_project_name(&path)
.map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,68 @@
use tauri::{command, AppHandle};
use serde::{Deserialize, Serialize};
/// 应用信息结构
#[derive(Debug, Serialize, Deserialize)]
pub struct AppInfo {
pub name: String,
pub version: String,
pub platform: String,
}
/// 选择目录命令
/// 遵循 Tauri 开发规范的系统集成设计
#[command]
pub fn select_directory(app: AppHandle) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
// 使用同步方式打开文件夹选择对话框
let dialog = app.dialog().file().set_title("选择项目目录");
// 创建一个简单的阻塞实现
let (tx, rx) = std::sync::mpsc::channel();
dialog.pick_folder(move |folder_path| {
let _ = tx.send(folder_path);
});
// 等待结果,设置超时
match rx.recv_timeout(std::time::Duration::from_secs(30)) {
Ok(Some(path)) => {
let path_str = path.to_string();
Ok(Some(path_str))
}
Ok(None) => Ok(None),
Err(_) => Err("对话框操作超时或失败".to_string()),
}
}
/// 获取应用信息命令
#[command]
pub async fn get_app_info() -> Result<AppInfo, String> {
Ok(AppInfo {
name: "MixVideo Desktop".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
platform: std::env::consts::OS.to_string(),
})
}
/// 验证目录是否存在命令
#[command]
pub async fn validate_directory(path: String) -> Result<bool, String> {
use std::path::Path;
let path = Path::new(&path);
Ok(path.exists() && path.is_dir())
}
/// 获取目录名称命令
#[command]
pub async fn get_directory_name(path: String) -> Result<String, String> {
use std::path::Path;
let path = Path::new(&path);
match path.file_name() {
Some(name) => Ok(name.to_string_lossy().to_string()),
None => Err("无效的目录路径".to_string()),
}
}

View File

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

View File

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

View File

@@ -1,116 +1,150 @@
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
@tailwind base;
@tailwind components;
@tailwind utilities;
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafb);
}
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.container {
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
/* 自定义组件样式 */
@layer components {
/* 按钮样式 */
.btn {
@apply inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
}
a:hover {
color: #24c8db;
.btn-primary {
@apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500 active:bg-primary-800;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
.btn-secondary {
@apply bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-500 border border-gray-300;
}
button:active {
background-color: #0f0f0f69;
.btn-ghost {
@apply text-gray-600 hover:bg-gray-100 focus:ring-gray-500;
}
}
.btn-danger {
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 active:bg-red-800;
}
.btn-sm {
@apply px-3 py-1.5 text-xs;
}
.btn-lg {
@apply px-6 py-3 text-base;
}
/* 表单样式 */
.form-input {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors duration-200;
}
.form-input.error {
@apply border-red-500 focus:ring-red-500;
}
.form-textarea {
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors duration-200 resize-none;
}
.form-textarea.error {
@apply border-red-500 focus:ring-red-500;
}
.form-label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
.form-error {
@apply flex items-center gap-1 text-sm text-red-600 mt-1;
}
.form-hint {
@apply text-xs text-gray-500 mt-1;
}
.form-info {
@apply text-sm text-blue-600 mt-1;
}
/* 加载动画 */
.spinner {
@apply animate-spin;
}
/* 模态框样式 */
.modal-overlay {
@apply fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4;
}
.modal {
@apply bg-white rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-hidden;
}
.modal-header {
@apply flex items-center justify-between p-6 border-b border-gray-200;
}
.modal-close {
@apply p-1 hover:bg-gray-100 rounded-lg transition-colors duration-200;
}
/* 卡片样式 */
.card {
@apply bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-shadow duration-200;
}
/* 菜单样式 */
.dropdown-menu {
@apply absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50;
}
.dropdown-item {
@apply flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors duration-150;
}
.dropdown-item.danger {
@apply text-red-600 hover:bg-red-50;
}
/* 响应式工具类 */
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 动画增强 */
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
/* 自定义滚动条 */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
@apply bg-gray-100 rounded-full;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-gray-300 rounded-full hover:bg-gray-400;
}
/* 焦点样式增强 */
.focus-visible {
@apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
}
}

View File

@@ -1,50 +1,73 @@
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke } from "@tauri-apps/api/core";
import { ProjectList } from './components/ProjectList';
import { ProjectForm } from './components/ProjectForm';
import { useProjectStore } from './store/projectStore';
import { useUIStore } from './store/uiStore';
import { CreateProjectRequest, UpdateProjectRequest } from './types/project';
import "./App.css";
/**
* 主应用组件
* 遵循 Tauri 开发规范的应用架构设计
*/
function App() {
const [greetMsg, setGreetMsg] = useState("");
const [name, setName] = useState("");
const { createProject, updateProject } = useProjectStore();
const {
showCreateProjectModal,
showEditProjectModal,
editingProject,
closeCreateProjectModal,
closeEditProjectModal
} = useUIStore();
async function greet() {
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
setGreetMsg(await invoke("greet", { name }));
}
// 处理创建项目
const handleCreateProject = async (data: CreateProjectRequest) => {
try {
await createProject(data);
closeCreateProjectModal();
} catch (error) {
console.error('创建项目失败:', error);
throw error;
}
};
// 处理更新项目
const handleUpdateProject = async (data: UpdateProjectRequest) => {
if (!editingProject) return;
try {
await updateProject(editingProject.id, data);
closeEditProjectModal();
} catch (error) {
console.error('更新项目失败:', error);
throw error;
}
};
return (
<main className="container">
<h1>Welcome to Tauri + React</h1>
<div className="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>
);
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { FolderPlus } from 'lucide-react';
interface EmptyStateProps {
title: string;
description: string;
actionText: string;
onAction: () => void;
}
/**
* 空状态组件
* 遵循简洁大方的设计风格
*/
export const EmptyState: React.FC<EmptyStateProps> = ({
title,
description,
actionText,
onAction
}) => {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="text-gray-400 mb-6">
<FolderPlus size={64} />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">{title}</h3>
<p className="text-gray-600 mb-8 max-w-md">{description}</p>
<button className="btn btn-primary" onClick={onAction}>
{actionText}
</button>
</div>
);
};

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { AlertCircle, RefreshCw, X } from 'lucide-react';
interface ErrorMessageProps {
message: string;
onRetry?: () => void;
onDismiss?: () => void;
}
/**
* 错误消息组件
*/
export const ErrorMessage: React.FC<ErrorMessageProps> = ({
message,
onRetry,
onDismiss
}) => {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<AlertCircle size={20} className="text-red-500 flex-shrink-0" />
<span className="text-red-700">{message}</span>
</div>
<div className="flex items-center gap-2">
{onRetry && (
<button className="btn btn-secondary btn-sm" onClick={onRetry}>
<RefreshCw size={14} />
</button>
)}
{onDismiss && (
<button className="btn btn-ghost btn-sm" onClick={onDismiss}>
<X size={14} />
</button>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
interface LoadingSpinnerProps {
size?: 'small' | 'medium' | 'large';
text?: string;
}
/**
* 加载动画组件
*/
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = 'medium',
text
}) => {
const sizeMap = {
small: 16,
medium: 24,
large: 32
};
return (
<div className={`flex items-center justify-center gap-3 ${size === 'small' ? 'p-4' : 'p-8'}`}>
<Loader2 size={sizeMap[size]} className="spinner text-primary-600" />
{text && <span className="text-gray-600">{text}</span>}
</div>
);
};

View File

@@ -0,0 +1,167 @@
import React from 'react';
import { Project } from '../types/project';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import {
Folder,
MoreVertical,
Edit3,
Trash2,
ExternalLink,
Calendar,
MapPin
} from 'lucide-react';
interface ProjectCardProps {
project: Project;
onOpen: (project: Project) => void;
onEdit: (project: Project) => void;
onDelete: (id: string) => void;
}
/**
* 项目卡片组件
* 遵循简洁大方的设计风格
*/
export const ProjectCard: React.FC<ProjectCardProps> = ({
project,
onOpen,
onEdit,
onDelete
}) => {
const [showMenu, setShowMenu] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
// 点击外部关闭菜单
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setShowMenu(false);
}
};
if (showMenu) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showMenu]);
// 格式化时间
const formatTime = (dateString: string) => {
try {
const date = new Date(dateString);
return formatDistanceToNow(date, {
addSuffix: true,
locale: zhCN
});
} catch {
return '未知时间';
}
};
// 获取项目目录名
const getDirectoryName = (path: string) => {
const parts = path.split(/[/\\]/);
return parts[parts.length - 1] || path;
};
return (
<div className="card p-6 hover:shadow-lg hover:-translate-y-1 transition-all duration-200 group cursor-pointer animate-slide-up">
<div className="flex items-center justify-between mb-4">
<div className="text-primary-600 group-hover:text-primary-700 transition-colors duration-200">
<Folder size={24} />
</div>
<div className="relative" ref={menuRef}>
<button
className="p-1 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors duration-200 opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
setShowMenu(!showMenu);
}}
>
<MoreVertical size={16} />
</button>
{showMenu && (
<div className="dropdown-menu animate-fade-in">
<button
className="dropdown-item"
onClick={() => {
onOpen(project);
setShowMenu(false);
}}
>
<ExternalLink size={14} />
</button>
<button
className="dropdown-item"
onClick={() => {
onEdit(project);
setShowMenu(false);
}}
>
<Edit3 size={14} />
</button>
<button
className="dropdown-item danger"
onClick={() => {
onDelete(project.id);
setShowMenu(false);
}}
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
</div>
<div className="mb-4" onClick={() => onOpen(project)}>
<h3 className="text-lg font-semibold text-gray-900 mb-2 line-clamp-1">
{project.name}
</h3>
{project.description && (
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
{project.description}
</p>
)}
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-gray-500">
<MapPin size={12} />
<span className="truncate" title={project.path}>
{getDirectoryName(project.path)}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500">
<Calendar size={12} />
<span>
{formatTime(project.updated_at)}
</span>
</div>
</div>
</div>
<div className="flex gap-2 pt-4 border-t border-gray-100">
<button
className="btn btn-secondary btn-sm flex-1"
onClick={() => onEdit(project)}
>
</button>
<button
className="btn btn-primary btn-sm flex-1"
onClick={() => onOpen(project)}
>
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,249 @@
import React, { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { useProjectStore } from '../store/projectStore';
import { ProjectFormData, ProjectFormErrors } from '../types/project';
import { Folder, X, AlertCircle } from 'lucide-react';
interface ProjectFormProps {
initialData?: Partial<ProjectFormData>;
onSubmit: (data: ProjectFormData) => Promise<void>;
onCancel: () => void;
isEdit?: boolean;
}
/**
* 项目表单组件
* 遵循 Tauri 开发规范的表单设计模式
*/
export const ProjectForm: React.FC<ProjectFormProps> = ({
initialData,
onSubmit,
onCancel,
isEdit = false
}) => {
const { isLoading, validateProjectPath, getDefaultProjectName } = useProjectStore();
const [formData, setFormData] = useState<ProjectFormData>({
name: initialData?.name || '',
path: initialData?.path || '',
description: initialData?.description || ''
});
const [errors, setErrors] = useState<ProjectFormErrors>({});
const [isValidatingPath, setIsValidatingPath] = useState(false);
// 验证表单
const validateForm = (): boolean => {
const newErrors: ProjectFormErrors = {};
if (!formData.name.trim()) {
newErrors.name = '项目名称不能为空';
} else if (formData.name.length > 100) {
newErrors.name = '项目名称不能超过100个字符';
}
if (!formData.path.trim()) {
newErrors.path = '项目路径不能为空';
}
if (formData.description.length > 500) {
newErrors.description = '项目描述不能超过500个字符';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 选择目录
const handleSelectDirectory = async () => {
try {
const selectedPath = await invoke<string | null>('select_directory');
if (selectedPath) {
setFormData(prev => ({ ...prev, path: selectedPath }));
// 如果项目名称为空,尝试从路径获取默认名称
if (!formData.name.trim()) {
try {
const defaultName = await getDefaultProjectName(selectedPath);
if (defaultName) {
setFormData(prev => ({ ...prev, name: defaultName }));
}
} catch (error) {
console.warn('获取默认项目名称失败:', error);
}
}
}
} catch (error) {
console.error('选择目录失败:', error);
}
};
// 验证路径
const handlePathValidation = async (path: string) => {
if (!path.trim()) return;
setIsValidatingPath(true);
try {
const isValid = await validateProjectPath(path);
if (!isValid) {
setErrors(prev => ({
...prev,
path: '无效的项目路径或没有访问权限'
}));
} else {
setErrors(prev => {
const { path: _, ...rest } = prev;
return rest;
});
}
} catch (error) {
setErrors(prev => ({
...prev,
path: '路径验证失败'
}));
} finally {
setIsValidatingPath(false);
}
};
// 路径变化时验证
useEffect(() => {
if (formData.path && !isEdit) {
const timeoutId = setTimeout(() => {
handlePathValidation(formData.path);
}, 500);
return () => clearTimeout(timeoutId);
}
}, [formData.path, isEdit]);
// 处理表单提交
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
await onSubmit(formData);
} catch (error) {
console.error('提交表单失败:', error);
}
};
return (
<div className="modal-overlay">
<div className="modal">
<div className="modal-header">
<h2 className="text-xl font-semibold text-gray-900">
{isEdit ? '编辑项目' : '新建项目'}
</h2>
<button className="modal-close" onClick={onCancel}>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="space-y-2">
<label htmlFor="name" className="form-label">
*
</label>
<input
id="name"
type="text"
className={`form-input ${errors.name ? 'error' : ''}`}
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="输入项目名称"
maxLength={100}
/>
{errors.name && (
<div className="form-error">
<AlertCircle size={14} />
{errors.name}
</div>
)}
</div>
<div className="space-y-2">
<label htmlFor="path" className="form-label">
*
</label>
<div className="flex gap-2">
<input
id="path"
type="text"
className={`form-input ${errors.path ? 'error' : ''}`}
value={formData.path}
onChange={(e) => setFormData(prev => ({ ...prev, path: e.target.value }))}
placeholder="选择或输入项目路径"
readOnly={isEdit}
/>
{!isEdit && (
<button
type="button"
className="btn btn-secondary whitespace-nowrap"
onClick={handleSelectDirectory}
>
<Folder size={16} />
</button>
)}
</div>
{isValidatingPath && (
<div className="form-info">...</div>
)}
{errors.path && (
<div className="form-error">
<AlertCircle size={14} />
{errors.path}
</div>
)}
</div>
<div className="space-y-2">
<label htmlFor="description" className="form-label">
</label>
<textarea
id="description"
className={`form-textarea ${errors.description ? 'error' : ''}`}
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="输入项目描述(可选)"
rows={3}
maxLength={500}
/>
<div className="form-hint">
{formData.description.length}/500
</div>
{errors.description && (
<div className="form-error">
<AlertCircle size={14} />
{errors.description}
</div>
)}
</div>
<div className="flex gap-3 pt-4 border-t border-gray-200">
<button
type="button"
className="btn btn-secondary flex-1"
onClick={onCancel}
disabled={isLoading}
>
</button>
<button
type="submit"
className="btn btn-primary flex-1"
disabled={isLoading || isValidatingPath || Object.keys(errors).length > 0}
>
{isLoading ? '处理中...' : (isEdit ? '保存' : '创建')}
</button>
</div>
</form>
</div>
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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