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

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