From 730402aba0cb9fafb265698e46d82cd6a2bd4795 Mon Sep 17 00:00:00 2001 From: imeepos Date: Tue, 15 Jul 2025 12:50:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E9=A1=B9=E7=9B=AE-?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E7=BB=91=E5=AE=9A=E5=92=8C=E7=B4=A0=E6=9D=90?= =?UTF-8?q?-=E6=A8=A1=E7=89=B9=E7=BB=91=E5=AE=9A=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新功能: - 项目-模板绑定管理系统 - 支持主要/次要模板绑定类型 - 绑定优先级和状态管理 - 批量绑定操作 - 绑定关系的CRUD操作 - 素材-模特绑定管理系统 - 素材与模特的关联管理 - 批量绑定/解绑操作 - 绑定统计和分析 - 素材编辑对话框 架构改进: - 新增项目-模板绑定数据模型和仓库层 - 新增素材-模特绑定业务服务层 - 完善的API命令层实现 - 响应式前端界面设计 用户体验优化: - 统一的通知系统 - 增强的加载状态组件 - 流畅的交互动画 - 优雅的确认对话框 测试覆盖: - 单元测试和集成测试 - 业务逻辑验证 - API接口测试 技术栈: - 后端: Rust + Tauri + SQLite - 前端: React + TypeScript + TailwindCSS - 状态管理: Zustand - 测试: Vitest + Rust测试框架 配置更新: - 更新数据库迁移脚本 - 完善测试配置 - 优化构建流程 --- apps/desktop/package.json | 7 +- apps/desktop/scripts/run-tests.sh | 147 ++++++ apps/desktop/src-tauri/src/app_state.rs | 6 + .../enhanced_template_import_service.rs | 2 +- .../src/business/services/material_service.rs | 236 +++++++++ .../src-tauri/src/business/services/mod.rs | 1 + .../project_template_binding_service.rs | 299 ++++++++++++ apps/desktop/src-tauri/src/data/models/mod.rs | 1 + .../data/models/project_template_binding.rs | 278 +++++++++++ .../data/repositories/material_repository.rs | 113 +++++ .../src-tauri/src/data/repositories/mod.rs | 1 + .../project_template_binding_repository.rs | 386 +++++++++++++++ .../video_classification_repository.rs | 4 +- .../src-tauri/src/infrastructure/database.rs | 59 +++ apps/desktop/src-tauri/src/lib.rs | 22 + .../commands/material_commands.rs | 125 +++++ .../src/presentation/commands/mod.rs | 1 + .../project_template_binding_commands.rs | 213 ++++++++ .../binding_management_integration_tests.rs | 437 +++++++++++++++++ .../src/tests/material_model_binding_tests.rs | 360 ++++++++++++++ apps/desktop/src-tauri/src/tests/mod.rs | 7 + .../tests/project_template_binding_tests.rs | 379 ++++++++++++++ apps/desktop/src/App.tsx | 31 +- apps/desktop/src/components/CustomSelect.tsx | 10 +- .../src/components/EnhancedLoadingState.tsx | 315 ++++++++++++ apps/desktop/src/components/MaterialCard.tsx | 77 ++- .../src/components/MaterialEditDialog.tsx | 208 ++++++++ apps/desktop/src/components/Navigation.tsx | 26 +- .../src/components/NotificationSystem.tsx | 304 ++++++++++++ .../components/ProjectTemplateBindingForm.tsx | 303 ++++++++++++ .../components/ProjectTemplateBindingList.tsx | 462 ++++++++++++++++++ .../src/pages/MaterialModelBinding.tsx | 389 +++++++++++++++ apps/desktop/src/pages/ProjectDetails.tsx | 211 +++++++- .../services/materialModelBindingService.ts | 318 ++++++++++++ .../services/projectTemplateBindingService.ts | 291 +++++++++++ .../src/stores/projectTemplateBindingStore.ts | 382 +++++++++++++++ apps/desktop/src/styles/animations.css | 343 +++++++++++++ apps/desktop/src/tests/setup.ts | 276 +++++++++++ apps/desktop/src/types/material.ts | 1 + .../src/types/projectTemplateBinding.ts | 246 ++++++++++ apps/desktop/vitest.config.ts | 31 +- 41 files changed, 7259 insertions(+), 49 deletions(-) create mode 100644 apps/desktop/scripts/run-tests.sh create mode 100644 apps/desktop/src-tauri/src/business/services/project_template_binding_service.rs create mode 100644 apps/desktop/src-tauri/src/data/models/project_template_binding.rs create mode 100644 apps/desktop/src-tauri/src/data/repositories/project_template_binding_repository.rs create mode 100644 apps/desktop/src-tauri/src/presentation/commands/project_template_binding_commands.rs create mode 100644 apps/desktop/src-tauri/src/tests/integration/binding_management_integration_tests.rs create mode 100644 apps/desktop/src-tauri/src/tests/material_model_binding_tests.rs create mode 100644 apps/desktop/src-tauri/src/tests/project_template_binding_tests.rs create mode 100644 apps/desktop/src/components/EnhancedLoadingState.tsx create mode 100644 apps/desktop/src/components/MaterialEditDialog.tsx create mode 100644 apps/desktop/src/components/NotificationSystem.tsx create mode 100644 apps/desktop/src/components/ProjectTemplateBindingForm.tsx create mode 100644 apps/desktop/src/components/ProjectTemplateBindingList.tsx create mode 100644 apps/desktop/src/pages/MaterialModelBinding.tsx create mode 100644 apps/desktop/src/services/materialModelBindingService.ts create mode 100644 apps/desktop/src/services/projectTemplateBindingService.ts create mode 100644 apps/desktop/src/stores/projectTemplateBindingStore.ts create mode 100644 apps/desktop/src/styles/animations.css create mode 100644 apps/desktop/src/tests/setup.ts create mode 100644 apps/desktop/src/types/projectTemplateBinding.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8ff3a85..dc350b3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,7 +12,12 @@ "tauri:build": "tauri build", "test": "vitest", "test:ui": "vitest --ui", - "test:coverage": "vitest --coverage" + "test:coverage": "vitest --coverage", + "test:unit": "vitest run src/tests/components", + "test:integration": "vitest run src/tests/integration", + "test:performance": "vitest run src/tests/performance", + "test:e2e": "vitest run src/tests/e2e", + "test:all": "bash scripts/run-tests.sh" }, "dependencies": { "@heroicons/react": "^2.2.0", diff --git a/apps/desktop/scripts/run-tests.sh b/apps/desktop/scripts/run-tests.sh new file mode 100644 index 0000000..77b418d --- /dev/null +++ b/apps/desktop/scripts/run-tests.sh @@ -0,0 +1,147 @@ +#!/bin/bash + +# 绑定管理功能测试运行脚本 +# 遵循 Tauri 开发规范的测试执行流程 + +set -e + +echo "🧪 开始运行绑定管理功能测试..." + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 函数:打印带颜色的消息 +print_message() { + local color=$1 + local message=$2 + echo -e "${color}${message}${NC}" +} + +# 函数:运行命令并检查结果 +run_command() { + local description=$1 + local command=$2 + + print_message $BLUE "📋 $description" + + if eval $command; then + print_message $GREEN "✅ $description - 成功" + return 0 + else + print_message $RED "❌ $description - 失败" + return 1 + fi +} + +# 检查依赖 +print_message $YELLOW "🔍 检查测试环境..." + +# 检查 Rust 环境 +if ! command -v cargo &> /dev/null; then + print_message $RED "❌ Cargo 未安装,请先安装 Rust" + exit 1 +fi + +# 检查 Node.js 环境 +if ! command -v node &> /dev/null; then + print_message $RED "❌ Node.js 未安装" + exit 1 +fi + +# 检查 pnpm +if ! command -v pnpm &> /dev/null; then + print_message $RED "❌ pnpm 未安装" + exit 1 +fi + +print_message $GREEN "✅ 测试环境检查完成" + +# 切换到项目目录 +cd "$(dirname "$0")/.." + +# 运行后端测试 +print_message $YELLOW "🦀 运行 Rust 后端测试..." + +# 运行项目-模板绑定测试 +run_command "项目-模板绑定单元测试" "cargo test project_template_binding_tests --lib" + +# 运行素材-模特绑定测试 +run_command "素材-模特绑定单元测试" "cargo test material_model_binding_tests --lib" + +# 运行集成测试 +run_command "绑定管理集成测试" "cargo test integration::binding_management_integration_tests --lib" + +# 运行所有后端测试 +run_command "所有后端测试" "cargo test --lib" + +# 运行前端测试 +print_message $YELLOW "⚛️ 运行前端测试..." + +# 安装依赖(如果需要) +if [ ! -d "node_modules" ]; then + run_command "安装前端依赖" "pnpm install" +fi + +# 运行前端单元测试 +run_command "前端组件测试" "pnpm test:unit" + +# 运行前端集成测试 +run_command "前端集成测试" "pnpm test:integration" + +# 运行所有前端测试 +run_command "所有前端测试" "pnpm test" + +# 生成测试覆盖率报告 +print_message $YELLOW "📊 生成测试覆盖率报告..." + +# 后端覆盖率 +run_command "后端测试覆盖率" "cargo tarpaulin --out Html --output-dir target/coverage" + +# 前端覆盖率 +run_command "前端测试覆盖率" "pnpm test:coverage" + +# 运行性能测试 +print_message $YELLOW "⚡ 运行性能测试..." + +# 后端性能测试 +run_command "后端性能测试" "cargo bench" + +# 前端性能测试 +run_command "前端性能测试" "pnpm test:performance" + +# 运行端到端测试 +print_message $YELLOW "🔄 运行端到端测试..." + +# 构建应用 +run_command "构建应用" "pnpm tauri build" + +# 运行 E2E 测试 +run_command "端到端测试" "pnpm test:e2e" + +# 测试总结 +print_message $GREEN "🎉 所有测试完成!" + +# 显示测试结果摘要 +echo "" +print_message $BLUE "📈 测试结果摘要:" +echo " - 项目-模板绑定功能: ✅" +echo " - 素材-模特绑定功能: ✅" +echo " - 前端组件测试: ✅" +echo " - 集成测试: ✅" +echo " - 性能测试: ✅" +echo " - 端到端测试: ✅" + +# 显示覆盖率信息 +if [ -f "target/coverage/tarpaulin-report.html" ]; then + print_message $BLUE "📊 后端覆盖率报告: target/coverage/tarpaulin-report.html" +fi + +if [ -f "coverage/index.html" ]; then + print_message $BLUE "📊 前端覆盖率报告: coverage/index.html" +fi + +print_message $GREEN "✨ 绑定管理功能测试全部通过!" diff --git a/apps/desktop/src-tauri/src/app_state.rs b/apps/desktop/src-tauri/src/app_state.rs index ba6cfd1..e32d30e 100644 --- a/apps/desktop/src-tauri/src/app_state.rs +++ b/apps/desktop/src-tauri/src/app_state.rs @@ -72,6 +72,12 @@ impl AppState { }).clone() } + /// 获取数据库连接 + pub fn get_connection(&self) -> anyhow::Result>> { + let database = self.get_database(); + Ok(database.get_connection()) + } + /// 用于测试的构造函数 #[cfg(test)] pub fn new_with_database(database: Arc) -> Self { diff --git a/apps/desktop/src-tauri/src/business/services/enhanced_template_import_service.rs b/apps/desktop/src-tauri/src/business/services/enhanced_template_import_service.rs index 7f2907c..58d0afd 100644 --- a/apps/desktop/src-tauri/src/business/services/enhanced_template_import_service.rs +++ b/apps/desktop/src-tauri/src/business/services/enhanced_template_import_service.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use std::path::Path; use anyhow::Result; use tokio::time::Instant; -use tracing::{info, warn, error, debug}; +use tracing::{info, error}; use crate::data::models::template::{Template, ImportTemplateRequest}; use crate::business::services::template_import_service::{TemplateImportService, ImportEventCallback}; diff --git a/apps/desktop/src-tauri/src/business/services/material_service.rs b/apps/desktop/src-tauri/src/business/services/material_service.rs index 073f8eb..7f35be0 100644 --- a/apps/desktop/src-tauri/src/business/services/material_service.rs +++ b/apps/desktop/src-tauri/src/business/services/material_service.rs @@ -687,4 +687,240 @@ impl MaterialService { ProjectService::get_project_by_id(&project_repo, project_id)? .ok_or_else(|| anyhow!("找不到项目: {}", project_id)) } + + /// 绑定素材到模特 + pub fn bind_material_to_model( + repository: &MaterialRepository, + material_id: &str, + model_id: &str, + ) -> Result<()> { + info!( + material_id = %material_id, + model_id = %model_id, + "开始绑定素材到模特" + ); + + // 验证素材是否存在 + let material = repository.get_by_id(material_id)? + .ok_or_else(|| anyhow!("找不到素材: {}", material_id))?; + + // 验证模特是否存在 + Self::validate_model_exists(model_id)?; + + // 执行绑定 + repository.update_model_binding(material_id, Some(model_id))?; + + info!( + material_id = %material_id, + model_id = %model_id, + material_name = %material.name, + "素材绑定到模特成功" + ); + + Ok(()) + } + + /// 解除素材与模特的绑定 + pub fn unbind_material_from_model( + repository: &MaterialRepository, + material_id: &str, + ) -> Result<()> { + info!(material_id = %material_id, "开始解除素材与模特的绑定"); + + // 验证素材是否存在 + let material = repository.get_by_id(material_id)? + .ok_or_else(|| anyhow!("找不到素材: {}", material_id))?; + + // 执行解绑 + repository.update_model_binding(material_id, None)?; + + info!( + material_id = %material_id, + material_name = %material.name, + "素材与模特解绑成功" + ); + + Ok(()) + } + + /// 批量绑定素材到模特 + pub fn batch_bind_materials_to_model( + repository: &MaterialRepository, + material_ids: &[String], + model_id: &str, + ) -> Result<()> { + info!( + material_count = material_ids.len(), + model_id = %model_id, + "开始批量绑定素材到模特" + ); + + // 验证模特是否存在 + Self::validate_model_exists(model_id)?; + + // 验证所有素材是否存在 + for material_id in material_ids { + if repository.get_by_id(material_id)?.is_none() { + return Err(anyhow!("找不到素材: {}", material_id)); + } + } + + // 执行批量绑定 + repository.batch_update_model_binding(material_ids, Some(model_id))?; + + info!( + material_count = material_ids.len(), + model_id = %model_id, + "批量绑定素材到模特成功" + ); + + Ok(()) + } + + /// 批量解除素材与模特的绑定 + pub fn batch_unbind_materials_from_model( + repository: &MaterialRepository, + material_ids: &[String], + ) -> Result<()> { + info!( + material_count = material_ids.len(), + "开始批量解除素材与模特的绑定" + ); + + // 验证所有素材是否存在 + for material_id in material_ids { + if repository.get_by_id(material_id)?.is_none() { + return Err(anyhow!("找不到素材: {}", material_id)); + } + } + + // 执行批量解绑 + repository.batch_update_model_binding(material_ids, None)?; + + info!( + material_count = material_ids.len(), + "批量解除素材与模特绑定成功" + ); + + Ok(()) + } + + /// 获取模特的所有素材 + pub fn get_materials_by_model( + repository: &MaterialRepository, + model_id: &str, + ) -> Result> { + debug!(model_id = %model_id, "获取模特的所有素材"); + + // 验证模特是否存在 + Self::validate_model_exists(model_id)?; + + repository.get_by_model_id(model_id) + } + + /// 获取未绑定模特的素材 + pub fn get_unbound_materials( + repository: &MaterialRepository, + project_id: Option<&str>, + ) -> Result> { + debug!(project_id = ?project_id, "获取未绑定模特的素材"); + + if let Some(pid) = project_id { + repository.get_unassociated_materials(Some(pid)) + } else { + repository.get_unbound_materials() + } + } + + /// 获取模特的素材统计信息 + pub fn get_model_material_statistics( + repository: &MaterialRepository, + model_id: &str, + ) -> Result { + debug!(model_id = %model_id, "获取模特的素材统计信息"); + + // 验证模特是否存在 + Self::validate_model_exists(model_id)?; + + repository.get_model_statistics(model_id) + } + + /// 验证模特是否存在 + fn validate_model_exists(model_id: &str) -> Result<()> { + use crate::infrastructure::database::Database; + use crate::data::repositories::model_repository::ModelRepository; + + let db = Database::new()?; + let model_repo = ModelRepository::new(db.get_connection()); + + if model_repo.get_by_id(model_id)?.is_none() { + return Err(anyhow!("找不到模特: {}", model_id)); + } + + Ok(()) + } + + /// 切换素材的模特绑定 + pub fn switch_material_model( + repository: &MaterialRepository, + material_id: &str, + new_model_id: &str, + ) -> Result<()> { + info!( + material_id = %material_id, + new_model_id = %new_model_id, + "开始切换素材的模特绑定" + ); + + // 验证素材是否存在 + let material = repository.get_by_id(material_id)? + .ok_or_else(|| anyhow!("找不到素材: {}", material_id))?; + + // 验证新模特是否存在 + Self::validate_model_exists(new_model_id)?; + + // 执行切换 + repository.update_model_binding(material_id, Some(new_model_id))?; + + info!( + material_id = %material_id, + material_name = %material.name, + new_model_id = %new_model_id, + "素材模特绑定切换成功" + ); + + Ok(()) + } + + /// 获取项目中绑定模特的素材统计 + pub fn get_project_model_binding_stats( + repository: &MaterialRepository, + project_id: &str, + ) -> Result { + debug!(project_id = %project_id, "获取项目中模特绑定统计"); + + let bound_count = repository.count_bound_materials_in_project(project_id)?; + let unbound_count = repository.count_unbound_materials_in_project(project_id)?; + let total_count = bound_count + unbound_count; + + Ok(ProjectModelBindingStats { + total_materials: total_count, + bound_materials: bound_count, + unbound_materials: unbound_count, + binding_rate: if total_count > 0 { + (bound_count as f64 / total_count as f64) * 100.0 + } else { + 0.0 + }, + }) + } +} + +/// 项目模特绑定统计信息 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ProjectModelBindingStats { + pub total_materials: u32, + pub bound_materials: u32, + pub unbound_materials: u32, + pub binding_rate: f64, // 绑定率百分比 } diff --git a/apps/desktop/src-tauri/src/business/services/mod.rs b/apps/desktop/src-tauri/src/business/services/mod.rs index 7a091c0..e6dc4eb 100644 --- a/apps/desktop/src-tauri/src/business/services/mod.rs +++ b/apps/desktop/src-tauri/src/business/services/mod.rs @@ -15,6 +15,7 @@ pub mod chunked_upload_service; pub mod template_cache_service; pub mod performance_monitor; pub mod enhanced_template_import_service; +pub mod project_template_binding_service; #[cfg(test)] pub mod tests; diff --git a/apps/desktop/src-tauri/src/business/services/project_template_binding_service.rs b/apps/desktop/src-tauri/src/business/services/project_template_binding_service.rs new file mode 100644 index 0000000..f890038 --- /dev/null +++ b/apps/desktop/src-tauri/src/business/services/project_template_binding_service.rs @@ -0,0 +1,299 @@ +use std::sync::{Arc, Mutex}; +use rusqlite::Connection; + +use crate::business::errors::BusinessError; +use crate::data::models::project_template_binding::{ + ProjectTemplateBinding, BindingType, BindingStatus, + CreateProjectTemplateBindingRequest, UpdateProjectTemplateBindingRequest, + ProjectTemplateBindingQueryParams, ProjectTemplateBindingDetail, + BatchCreateProjectTemplateBindingRequest, BatchDeleteProjectTemplateBindingRequest, +}; +use crate::data::repositories::project_template_binding_repository::ProjectTemplateBindingRepository; + +/// 项目-模板绑定业务服务 +/// 遵循 Tauri 开发规范的业务逻辑层设计原则 +pub struct ProjectTemplateBindingService { + binding_repository: ProjectTemplateBindingRepository, +} + +impl ProjectTemplateBindingService { + /// 创建新的项目-模板绑定服务实例 + pub fn new(connection: Arc>) -> Result { + Ok(Self { + binding_repository: ProjectTemplateBindingRepository::new(connection), + }) + } + + /// 创建项目-模板绑定 + pub async fn create_binding(&self, request: CreateProjectTemplateBindingRequest) -> Result { + // 验证请求数据 + request.validate().map_err(|e| BusinessError::InvalidInput(e))?; + + // 检查绑定是否已存在 + if self.binding_repository.exists(&request.project_id, &request.template_id) + .map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))? { + return Err(BusinessError::DuplicateName("项目和模板的绑定关系已存在".to_string())); + } + + // 如果是主要绑定,检查项目是否已有主要绑定 + if request.binding_type == BindingType::Primary { + let existing_primary = self.get_primary_binding_for_project(&request.project_id).await?; + if existing_primary.is_some() { + return Err(BusinessError::BusinessRuleViolation("项目已存在主要模板绑定,请先移除现有主要绑定".to_string())); + } + } + + // 创建绑定 + let binding = self.binding_repository.create(request) + .map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))?; + + Ok(binding) + } + + /// 更新项目-模板绑定 + pub async fn update_binding(&self, id: &str, request: UpdateProjectTemplateBindingRequest) -> Result { + // 验证请求数据 + request.validate().map_err(|e| BusinessError::InvalidInput(e))?; + + // 获取现有绑定 + let existing_binding = self.binding_repository.get_by_id(id) + .map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))? + .ok_or_else(|| BusinessError::NotFound("绑定不存在".to_string()))?; + + // 如果要更新为主要绑定,检查项目是否已有其他主要绑定 + if let Some(BindingType::Primary) = request.binding_type { + if existing_binding.binding_type != BindingType::Primary { + let existing_primary = self.get_primary_binding_for_project(&existing_binding.project_id).await?; + if existing_primary.is_some() { + return Err(BusinessError::BusinessRuleViolation("项目已存在主要模板绑定,请先移除现有主要绑定".to_string())); + } + } + } + + // 更新绑定 + let updated_binding = self.binding_repository.update(id, request) + .map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))? + .ok_or_else(|| BusinessError::NotFound("绑定不存在".to_string()))?; + + Ok(updated_binding) + } + + /// 删除项目-模板绑定 + pub async fn delete_binding(&self, id: &str) -> Result<(), BusinessError> { + let deleted = self.binding_repository.delete(id) + .map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))?; + + if !deleted { + return Err(BusinessError::NotFound("绑定不存在".to_string())); + } + + Ok(()) + } + + /// 根据项目ID和模板ID删除绑定 + pub async fn delete_binding_by_project_and_template(&self, project_id: &str, template_id: &str) -> Result<(), BusinessError> { + let deleted = self.binding_repository.delete_by_project_and_template(project_id, template_id) + .map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))?; + + if !deleted { + return Err(BusinessError::NotFound("绑定不存在".to_string())); + } + + Ok(()) + } + + /// 获取项目-模板绑定详情 + pub async fn get_binding(&self, id: &str) -> Result { + let binding = self.binding_repository.get_by_id(id) + .map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))? + .ok_or_else(|| BusinessError::NotFound("绑定不存在".to_string()))?; + + Ok(binding) + } + + /// 查询项目-模板绑定列表 + pub async fn list_bindings(&self, params: ProjectTemplateBindingQueryParams) -> Result, BusinessError> { + let bindings = self.binding_repository.list(params) + .map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))?; + + Ok(bindings) + } + + /// 获取项目的模板列表 + pub async fn get_templates_by_project(&self, project_id: &str) -> Result, BusinessError> { + let templates = self.binding_repository.get_templates_by_project(project_id) + .map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))?; + + Ok(templates) + } + + /// 获取模板的项目列表 + pub async fn get_projects_by_template(&self, template_id: &str) -> Result, BusinessError> { + let projects = self.binding_repository.get_projects_by_template(template_id) + .map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))?; + + Ok(projects) + } + + /// 批量创建项目-模板绑定 + pub async fn batch_create_bindings(&self, request: BatchCreateProjectTemplateBindingRequest) -> Result, BusinessError> { + + // 检查是否有重复的模板ID + let mut unique_template_ids = std::collections::HashSet::new(); + for template_id in &request.template_ids { + if !unique_template_ids.insert(template_id) { + return Err(BusinessError::InvalidInput("模板ID列表中存在重复项".to_string())); + } + } + + // 检查是否已存在绑定 + for template_id in &request.template_ids { + if self.binding_repository.exists(&request.project_id, template_id) + .map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))? { + return Err(BusinessError::DuplicateName(format!("项目和模板 {} 的绑定关系已存在", template_id))); + } + } + + // 如果是主要绑定且有多个模板,只允许第一个为主要绑定 + if request.binding_type == BindingType::Primary && request.template_ids.len() > 1 { + return Err(BusinessError::InvalidInput("批量创建时只能有一个主要绑定".to_string())); + } + + // 如果是主要绑定,检查项目是否已有主要绑定 + if request.binding_type == BindingType::Primary { + let existing_primary = self.get_primary_binding_for_project(&request.project_id).await?; + if existing_primary.is_some() { + return Err(BusinessError::BusinessRuleViolation("项目已存在主要模板绑定,请先移除现有主要绑定".to_string())); + } + } + + // 批量创建绑定 + let bindings = self.binding_repository.batch_create(request) + .map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))?; + + Ok(bindings) + } + + /// 批量删除项目-模板绑定 + pub async fn batch_delete_bindings(&self, request: BatchDeleteProjectTemplateBindingRequest) -> Result { + let deleted_count = self.binding_repository.batch_delete(request) + .map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))?; + + Ok(deleted_count) + } + + /// 激活绑定 + pub async fn activate_binding(&self, id: &str) -> Result { + let mut binding = self.get_binding(id).await?; + binding.activate(); + + let updated_binding = self.binding_repository.update(id, UpdateProjectTemplateBindingRequest { + binding_name: None, + description: None, + priority: None, + binding_type: None, + binding_status: Some(binding.binding_status.clone()), + is_active: Some(binding.is_active), + }).map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))? + .ok_or_else(|| BusinessError::NotFound("绑定不存在".to_string()))?; + + Ok(updated_binding) + } + + /// 停用绑定 + pub async fn deactivate_binding(&self, id: &str) -> Result { + let mut binding = self.get_binding(id).await?; + binding.deactivate(); + + let updated_binding = self.binding_repository.update(id, UpdateProjectTemplateBindingRequest { + binding_name: None, + description: None, + priority: None, + binding_type: None, + binding_status: Some(binding.binding_status.clone()), + is_active: Some(binding.is_active), + }).map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))? + .ok_or_else(|| BusinessError::NotFound("绑定不存在".to_string()))?; + + Ok(updated_binding) + } + + /// 获取项目的主要模板绑定 + pub async fn get_primary_binding_for_project(&self, project_id: &str) -> Result, BusinessError> { + let params = ProjectTemplateBindingQueryParams { + project_id: Some(project_id.to_string()), + template_id: None, + binding_type: Some(BindingType::Primary), + binding_status: Some(BindingStatus::Active), + is_active: Some(true), + limit: Some(1), + offset: None, + }; + + let bindings = self.list_bindings(params).await?; + Ok(bindings.into_iter().next()) + } + + /// 设置项目的主要模板 + pub async fn set_primary_template(&self, project_id: &str, template_id: &str) -> Result { + + // 移除现有的主要绑定 + if let Some(existing_primary) = self.get_primary_binding_for_project(project_id).await? { + self.update_binding(&existing_primary.id, UpdateProjectTemplateBindingRequest { + binding_name: None, + description: None, + priority: None, + binding_type: Some(BindingType::Secondary), + binding_status: None, + is_active: None, + }).await?; + } + + // 检查目标绑定是否存在 + if self.binding_repository.exists(project_id, template_id) + .map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))? { + // 更新现有绑定为主要绑定 + let params = ProjectTemplateBindingQueryParams { + project_id: Some(project_id.to_string()), + template_id: Some(template_id.to_string()), + binding_type: None, + binding_status: None, + is_active: None, + limit: Some(1), + offset: None, + }; + + let bindings = self.list_bindings(params).await?; + if let Some(binding) = bindings.into_iter().next() { + return self.update_binding(&binding.id, UpdateProjectTemplateBindingRequest { + binding_name: None, + description: None, + priority: None, + binding_type: Some(BindingType::Primary), + binding_status: Some(BindingStatus::Active), + is_active: Some(true), + }).await; + } + } else { + // 创建新的主要绑定 + return self.create_binding(CreateProjectTemplateBindingRequest { + project_id: project_id.to_string(), + template_id: template_id.to_string(), + binding_name: None, + description: None, + priority: Some(0), + binding_type: BindingType::Primary, + }).await; + } + + Err(BusinessError::NotFound("无法设置主要模板".to_string())) + } + + /// 检查绑定是否存在 + pub async fn binding_exists(&self, project_id: &str, template_id: &str) -> Result { + let exists = self.binding_repository.exists(project_id, template_id) + .map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))?; + + Ok(exists) + } +} diff --git a/apps/desktop/src-tauri/src/data/models/mod.rs b/apps/desktop/src-tauri/src/data/models/mod.rs index 6b4950b..fb848f5 100644 --- a/apps/desktop/src-tauri/src/data/models/mod.rs +++ b/apps/desktop/src-tauri/src/data/models/mod.rs @@ -4,3 +4,4 @@ pub mod model; pub mod ai_classification; pub mod video_classification; pub mod template; +pub mod project_template_binding; diff --git a/apps/desktop/src-tauri/src/data/models/project_template_binding.rs b/apps/desktop/src-tauri/src/data/models/project_template_binding.rs new file mode 100644 index 0000000..d05e880 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/models/project_template_binding.rs @@ -0,0 +1,278 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; + +/// 项目-模板绑定实体模型 +/// 遵循 Tauri 开发规范的数据模型设计原则 +/// 支持多对多关系管理,一个项目可以绑定多个模板,一个模板可以被多个项目使用 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectTemplateBinding { + pub id: String, + pub project_id: String, + pub template_id: String, + pub binding_name: Option, // 绑定的自定义名称 + pub description: Option, + pub priority: u32, // 绑定优先级,数值越小优先级越高 + pub is_active: bool, + pub binding_type: BindingType, + pub binding_status: BindingStatus, + pub metadata: Option, // JSON格式的额外元数据 + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 绑定类型 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum BindingType { + /// 主要绑定 - 项目的主要模板 + Primary, + /// 次要绑定 - 项目的备用模板 + Secondary, + /// 参考绑定 - 仅作为参考的模板 + Reference, + /// 测试绑定 - 用于测试的模板 + Test, +} + +/// 绑定状态 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum BindingStatus { + /// 活跃状态 - 正在使用 + Active, + /// 暂停状态 - 暂时不使用 + Paused, + /// 已完成 - 已完成使用 + Completed, + /// 已取消 - 取消绑定 + Cancelled, +} + +impl ProjectTemplateBinding { + /// 创建新的项目-模板绑定实例 + pub fn new( + project_id: String, + template_id: String, + binding_type: BindingType, + ) -> Self { + let now = Utc::now(); + Self { + id: uuid::Uuid::new_v4().to_string(), + project_id, + template_id, + binding_name: None, + description: None, + priority: 0, + is_active: true, + binding_type, + binding_status: BindingStatus::Active, + metadata: None, + created_at: now, + updated_at: now, + } + } + + /// 更新绑定信息 + pub fn update( + &mut self, + binding_name: Option, + description: Option, + priority: Option, + binding_type: Option, + binding_status: Option, + ) { + if binding_name.is_some() { + self.binding_name = binding_name; + } + if description.is_some() { + self.description = description; + } + if let Some(priority) = priority { + self.priority = priority; + } + if let Some(binding_type) = binding_type { + self.binding_type = binding_type; + } + if let Some(binding_status) = binding_status { + self.binding_status = binding_status; + } + self.updated_at = Utc::now(); + } + + /// 激活绑定 + pub fn activate(&mut self) { + self.is_active = true; + self.binding_status = BindingStatus::Active; + self.updated_at = Utc::now(); + } + + /// 停用绑定 + pub fn deactivate(&mut self) { + self.is_active = false; + self.binding_status = BindingStatus::Paused; + self.updated_at = Utc::now(); + } + + /// 完成绑定 + pub fn complete(&mut self) { + self.binding_status = BindingStatus::Completed; + self.updated_at = Utc::now(); + } + + /// 取消绑定 + pub fn cancel(&mut self) { + self.is_active = false; + self.binding_status = BindingStatus::Cancelled; + self.updated_at = Utc::now(); + } + + /// 验证绑定数据 + pub fn validate(&self) -> Result<(), String> { + if self.project_id.trim().is_empty() { + return Err("项目ID不能为空".to_string()); + } + + if self.template_id.trim().is_empty() { + return Err("模板ID不能为空".to_string()); + } + + if let Some(ref name) = self.binding_name { + 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(()) + } + + /// 获取绑定类型的显示名称 + pub fn get_binding_type_display(&self) -> &str { + match self.binding_type { + BindingType::Primary => "主要绑定", + BindingType::Secondary => "次要绑定", + BindingType::Reference => "参考绑定", + BindingType::Test => "测试绑定", + } + } + + /// 获取绑定状态的显示名称 + pub fn get_binding_status_display(&self) -> &str { + match self.binding_status { + BindingStatus::Active => "活跃", + BindingStatus::Paused => "暂停", + BindingStatus::Completed => "已完成", + BindingStatus::Cancelled => "已取消", + } + } + + /// 检查绑定是否可用 + pub fn is_available(&self) -> bool { + self.is_active && matches!(self.binding_status, BindingStatus::Active) + } +} + +/// 创建项目-模板绑定请求模型 +#[derive(Debug, Deserialize)] +pub struct CreateProjectTemplateBindingRequest { + pub project_id: String, + pub template_id: String, + pub binding_name: Option, + pub description: Option, + pub priority: Option, + pub binding_type: BindingType, +} + +impl CreateProjectTemplateBindingRequest { + pub fn validate(&self) -> Result<(), String> { + if self.project_id.trim().is_empty() { + return Err("项目ID不能为空".to_string()); + } + + if self.template_id.trim().is_empty() { + return Err("模板ID不能为空".to_string()); + } + + if let Some(ref name) = self.binding_name { + 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(()) + } +} + +/// 更新项目-模板绑定请求模型 +#[derive(Debug, Deserialize)] +pub struct UpdateProjectTemplateBindingRequest { + pub binding_name: Option, + pub description: Option, + pub priority: Option, + pub binding_type: Option, + pub binding_status: Option, + pub is_active: Option, +} + +impl UpdateProjectTemplateBindingRequest { + pub fn validate(&self) -> Result<(), String> { + if let Some(ref name) = self.binding_name { + 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(()) + } +} + +/// 项目-模板绑定查询参数 +#[derive(Debug, Deserialize)] +pub struct ProjectTemplateBindingQueryParams { + pub project_id: Option, + pub template_id: Option, + pub binding_type: Option, + pub binding_status: Option, + pub is_active: Option, + pub limit: Option, + pub offset: Option, +} + +/// 批量创建项目-模板绑定请求 +#[derive(Debug, Deserialize)] +pub struct BatchCreateProjectTemplateBindingRequest { + pub project_id: String, + pub template_ids: Vec, + pub binding_type: BindingType, + pub priority_start: Option, // 起始优先级,后续模板优先级递增 +} + +/// 批量删除项目-模板绑定请求 +#[derive(Debug, Deserialize)] +pub struct BatchDeleteProjectTemplateBindingRequest { + pub binding_ids: Vec, +} + +/// 项目-模板绑定详细信息(包含关联的项目和模板信息) +#[derive(Debug, Serialize)] +pub struct ProjectTemplateBindingDetail { + pub binding: ProjectTemplateBinding, + pub project_name: String, + pub template_name: String, + pub template_description: Option, +} diff --git a/apps/desktop/src-tauri/src/data/repositories/material_repository.rs b/apps/desktop/src-tauri/src/data/repositories/material_repository.rs index c921eb8..7646247 100644 --- a/apps/desktop/src-tauri/src/data/repositories/material_repository.rs +++ b/apps/desktop/src-tauri/src/data/repositories/material_repository.rs @@ -537,4 +537,117 @@ impl MaterialRepository { None => Ok(None), } } + + /// 批量更新素材的模特绑定 + pub fn batch_update_model_binding(&self, material_ids: &[String], model_id: Option<&str>) -> Result<()> { + let conn = self.connection.lock().unwrap(); + let updated_at = chrono::Utc::now().to_rfc3339(); + + for material_id in material_ids { + conn.execute( + "UPDATE materials SET model_id = ?1, updated_at = ?2 WHERE id = ?3", + (model_id, &updated_at, material_id), + )?; + } + + Ok(()) + } + + /// 更新素材的模特绑定 + pub fn update_model_binding(&self, material_id: &str, model_id: Option<&str>) -> Result<()> { + let conn = self.connection.lock().unwrap(); + + conn.execute( + "UPDATE materials SET model_id = ?1, updated_at = ?2 WHERE id = ?3", + (model_id, chrono::Utc::now().to_rfc3339(), material_id), + )?; + + Ok(()) + } + + /// 根据模特ID获取素材(别名方法,为了API一致性) + pub fn get_by_model_id(&self, model_id: &str) -> Result> { + self.get_materials_by_model_id(model_id) + } + + /// 获取未绑定模特的素材(别名方法,为了API一致性) + pub fn get_unbound_materials(&self) -> Result> { + self.get_unassociated_materials(None) + } + + /// 获取模特的素材统计信息 + pub fn get_model_statistics(&self, model_id: &str) -> Result { + let conn = self.connection.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT + COUNT(*) as total_materials, + SUM(CASE WHEN material_type = 'Video' THEN 1 ELSE 0 END) as video_count, + SUM(CASE WHEN material_type = 'Audio' THEN 1 ELSE 0 END) as audio_count, + SUM(CASE WHEN material_type = 'Image' THEN 1 ELSE 0 END) as image_count, + SUM(file_size) as total_size + FROM materials WHERE model_id = ?1" + )?; + + let row = stmt.query_row([model_id], |row| { + Ok(ModelMaterialStatistics { + total_materials: row.get::<_, i64>("total_materials")? as u32, + video_count: row.get::<_, i64>("video_count")? as u32, + audio_count: row.get::<_, i64>("audio_count")? as u32, + image_count: row.get::<_, i64>("image_count")? as u32, + total_size: row.get::<_, i64>("total_size")? as u64, + }) + })?; + + Ok(row) + } + + /// 检查模特是否有关联的素材 + pub fn has_materials_for_model(&self, model_id: &str) -> Result { + let conn = self.connection.lock().unwrap(); + + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM materials WHERE model_id = ?1", + [model_id], + |row| row.get(0), + )?; + + Ok(count > 0) + } + + /// 获取项目中绑定了模特的素材数量 + pub fn count_bound_materials_in_project(&self, project_id: &str) -> Result { + let conn = self.connection.lock().unwrap(); + + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM materials WHERE project_id = ?1 AND model_id IS NOT NULL", + [project_id], + |row| row.get(0), + )?; + + Ok(count as u32) + } + + /// 获取项目中未绑定模特的素材数量 + pub fn count_unbound_materials_in_project(&self, project_id: &str) -> Result { + let conn = self.connection.lock().unwrap(); + + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM materials WHERE project_id = ?1 AND model_id IS NULL", + [project_id], + |row| row.get(0), + )?; + + Ok(count as u32) + } +} + +/// 模特素材统计信息 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ModelMaterialStatistics { + pub total_materials: u32, + pub video_count: u32, + pub audio_count: u32, + pub image_count: u32, + pub total_size: u64, } diff --git a/apps/desktop/src-tauri/src/data/repositories/mod.rs b/apps/desktop/src-tauri/src/data/repositories/mod.rs index ea91205..77aea09 100644 --- a/apps/desktop/src-tauri/src/data/repositories/mod.rs +++ b/apps/desktop/src-tauri/src/data/repositories/mod.rs @@ -3,3 +3,4 @@ pub mod material_repository; pub mod model_repository; pub mod ai_classification_repository; pub mod video_classification_repository; +pub mod project_template_binding_repository; diff --git a/apps/desktop/src-tauri/src/data/repositories/project_template_binding_repository.rs b/apps/desktop/src-tauri/src/data/repositories/project_template_binding_repository.rs new file mode 100644 index 0000000..cbde134 --- /dev/null +++ b/apps/desktop/src-tauri/src/data/repositories/project_template_binding_repository.rs @@ -0,0 +1,386 @@ +use rusqlite::{Connection, Result, Row}; +use std::sync::{Arc, Mutex}; +use chrono::{DateTime, Utc}; + +use crate::data::models::project_template_binding::{ + ProjectTemplateBinding, BindingType, BindingStatus, + CreateProjectTemplateBindingRequest, UpdateProjectTemplateBindingRequest, + ProjectTemplateBindingQueryParams, ProjectTemplateBindingDetail, + BatchCreateProjectTemplateBindingRequest, BatchDeleteProjectTemplateBindingRequest, +}; + +/// 项目-模板绑定数据库操作仓储 +/// 遵循 Tauri 开发规范的数据访问层设计原则 +pub struct ProjectTemplateBindingRepository { + connection: Arc>, +} + +impl ProjectTemplateBindingRepository { + /// 创建新的项目-模板绑定仓储实例 + pub fn new(connection: Arc>) -> Self { + Self { connection } + } + + /// 创建项目-模板绑定 + pub fn create(&self, request: CreateProjectTemplateBindingRequest) -> Result { + let conn = self.connection.lock().unwrap(); + + let binding = ProjectTemplateBinding::new( + request.project_id, + request.template_id, + request.binding_type, + ); + + let mut final_binding = binding; + if let Some(name) = request.binding_name { + final_binding.binding_name = Some(name); + } + if let Some(desc) = request.description { + final_binding.description = Some(desc); + } + if let Some(priority) = request.priority { + final_binding.priority = priority; + } + + conn.execute( + "INSERT INTO project_template_bindings ( + id, project_id, template_id, binding_name, description, priority, + is_active, binding_type, binding_status, metadata, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + rusqlite::params![ + &final_binding.id, + &final_binding.project_id, + &final_binding.template_id, + &final_binding.binding_name, + &final_binding.description, + &final_binding.priority.to_string(), + &(final_binding.is_active as i32).to_string(), + &serde_json::to_string(&final_binding.binding_type).unwrap(), + &serde_json::to_string(&final_binding.binding_status).unwrap(), + &final_binding.metadata, + &final_binding.created_at.to_rfc3339(), + &final_binding.updated_at.to_rfc3339(), + ], + )?; + + Ok(final_binding) + } + + /// 根据ID获取项目-模板绑定 + pub fn get_by_id(&self, id: &str) -> Result> { + let conn = self.connection.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT id, project_id, template_id, binding_name, description, priority, + is_active, binding_type, binding_status, metadata, created_at, updated_at + FROM project_template_bindings WHERE id = ?1" + )?; + + let binding_iter = stmt.query_map([id], |row| { + self.row_to_binding(row) + })?; + + for binding in binding_iter { + return Ok(Some(binding?)); + } + + Ok(None) + } + + /// 更新项目-模板绑定 + pub fn update(&self, id: &str, request: UpdateProjectTemplateBindingRequest) -> Result> { + let conn = self.connection.lock().unwrap(); + + // 首先获取现有绑定 + if let Some(mut binding) = self.get_by_id(id)? { + // 更新字段 + binding.update( + request.binding_name, + request.description, + request.priority, + request.binding_type, + request.binding_status, + ); + + if let Some(is_active) = request.is_active { + binding.is_active = is_active; + } + + // 保存到数据库 + conn.execute( + "UPDATE project_template_bindings SET + binding_name = ?1, description = ?2, priority = ?3, + is_active = ?4, binding_type = ?5, binding_status = ?6, + updated_at = ?7 + WHERE id = ?8", + rusqlite::params![ + &binding.binding_name, + &binding.description, + &binding.priority.to_string(), + &(binding.is_active as i32).to_string(), + &serde_json::to_string(&binding.binding_type).unwrap(), + &serde_json::to_string(&binding.binding_status).unwrap(), + &binding.updated_at.to_rfc3339(), + id, + ], + )?; + + Ok(Some(binding)) + } else { + Ok(None) + } + } + + /// 删除项目-模板绑定 + pub fn delete(&self, id: &str) -> Result { + let conn = self.connection.lock().unwrap(); + + let rows_affected = conn.execute( + "DELETE FROM project_template_bindings WHERE id = ?1", + [id], + )?; + + Ok(rows_affected > 0) + } + + /// 根据项目ID和模板ID删除绑定 + pub fn delete_by_project_and_template(&self, project_id: &str, template_id: &str) -> Result { + let conn = self.connection.lock().unwrap(); + + let rows_affected = conn.execute( + "DELETE FROM project_template_bindings WHERE project_id = ?1 AND template_id = ?2", + [project_id, template_id], + )?; + + Ok(rows_affected > 0) + } + + /// 查询项目-模板绑定列表 + pub fn list(&self, params: ProjectTemplateBindingQueryParams) -> Result> { + let conn = self.connection.lock().unwrap(); + + let mut query = "SELECT id, project_id, template_id, binding_name, description, priority, + is_active, binding_type, binding_status, metadata, created_at, updated_at + FROM project_template_bindings WHERE 1=1".to_string(); + let mut query_params: Vec = Vec::new(); + + if let Some(project_id) = params.project_id { + query.push_str(" AND project_id = ?"); + query_params.push(project_id); + } + + if let Some(template_id) = params.template_id { + query.push_str(" AND template_id = ?"); + query_params.push(template_id); + } + + if let Some(binding_type) = params.binding_type { + query.push_str(" AND binding_type = ?"); + query_params.push(serde_json::to_string(&binding_type).unwrap()); + } + + if let Some(binding_status) = params.binding_status { + query.push_str(" AND binding_status = ?"); + query_params.push(serde_json::to_string(&binding_status).unwrap()); + } + + if let Some(is_active) = params.is_active { + query.push_str(" AND is_active = ?"); + query_params.push((is_active as i32).to_string()); + } + + query.push_str(" ORDER BY priority ASC, created_at DESC"); + + if let Some(limit) = params.limit { + query.push_str(&format!(" LIMIT {}", limit)); + } + + if let Some(offset) = params.offset { + query.push_str(&format!(" OFFSET {}", offset)); + } + + let mut stmt = conn.prepare(&query)?; + let params_refs: Vec<&dyn rusqlite::ToSql> = query_params.iter().map(|s| s as &dyn rusqlite::ToSql).collect(); + let binding_iter = stmt.query_map( + params_refs.as_slice(), + |row| self.row_to_binding(row) + )?; + + let mut bindings = Vec::new(); + for binding in binding_iter { + bindings.push(binding?); + } + + Ok(bindings) + } + + /// 获取项目的模板列表 + pub fn get_templates_by_project(&self, project_id: &str) -> Result> { + let conn = self.connection.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT ptb.id, ptb.project_id, ptb.template_id, ptb.binding_name, ptb.description, ptb.priority, + ptb.is_active, ptb.binding_type, ptb.binding_status, ptb.metadata, ptb.created_at, ptb.updated_at, + p.name as project_name, t.name as template_name, t.description as template_description + FROM project_template_bindings ptb + JOIN projects p ON ptb.project_id = p.id + JOIN templates t ON ptb.template_id = t.id + WHERE ptb.project_id = ?1 + ORDER BY ptb.priority ASC, ptb.created_at DESC" + )?; + + let detail_iter = stmt.query_map([project_id], |row| { + let binding = self.row_to_binding(row)?; + let project_name: String = row.get("project_name")?; + let template_name: String = row.get("template_name")?; + let template_description: Option = row.get("template_description")?; + + Ok(ProjectTemplateBindingDetail { + binding, + project_name, + template_name, + template_description, + }) + })?; + + let mut details = Vec::new(); + for detail in detail_iter { + details.push(detail?); + } + + Ok(details) + } + + /// 获取模板的项目列表 + pub fn get_projects_by_template(&self, template_id: &str) -> Result> { + let conn = self.connection.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT ptb.id, ptb.project_id, ptb.template_id, ptb.binding_name, ptb.description, ptb.priority, + ptb.is_active, ptb.binding_type, ptb.binding_status, ptb.metadata, ptb.created_at, ptb.updated_at, + p.name as project_name, t.name as template_name, t.description as template_description + FROM project_template_bindings ptb + JOIN projects p ON ptb.project_id = p.id + JOIN templates t ON ptb.template_id = t.id + WHERE ptb.template_id = ?1 + ORDER BY ptb.priority ASC, ptb.created_at DESC" + )?; + + let detail_iter = stmt.query_map([template_id], |row| { + let binding = self.row_to_binding(row)?; + let project_name: String = row.get("project_name")?; + let template_name: String = row.get("template_name")?; + let template_description: Option = row.get("template_description")?; + + Ok(ProjectTemplateBindingDetail { + binding, + project_name, + template_name, + template_description, + }) + })?; + + let mut details = Vec::new(); + for detail in detail_iter { + details.push(detail?); + } + + Ok(details) + } + + /// 批量创建项目-模板绑定 + pub fn batch_create(&self, request: BatchCreateProjectTemplateBindingRequest) -> Result> { + let conn = self.connection.lock().unwrap(); + let mut bindings = Vec::new(); + + let mut priority = request.priority_start.unwrap_or(0); + + for template_id in request.template_ids { + let binding = ProjectTemplateBinding::new( + request.project_id.clone(), + template_id, + request.binding_type.clone(), + ); + + let mut final_binding = binding; + final_binding.priority = priority; + + conn.execute( + "INSERT INTO project_template_bindings ( + id, project_id, template_id, binding_name, description, priority, + is_active, binding_type, binding_status, metadata, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + rusqlite::params![ + &final_binding.id, + &final_binding.project_id, + &final_binding.template_id, + &final_binding.binding_name, + &final_binding.description, + &final_binding.priority.to_string(), + &(final_binding.is_active as i32).to_string(), + &serde_json::to_string(&final_binding.binding_type).unwrap(), + &serde_json::to_string(&final_binding.binding_status).unwrap(), + &final_binding.metadata, + &final_binding.created_at.to_rfc3339(), + &final_binding.updated_at.to_rfc3339(), + ], + )?; + + bindings.push(final_binding); + priority += 1; + } + + Ok(bindings) + } + + /// 批量删除项目-模板绑定 + pub fn batch_delete(&self, request: BatchDeleteProjectTemplateBindingRequest) -> Result { + let conn = self.connection.lock().unwrap(); + let mut deleted_count = 0; + + for binding_id in request.binding_ids { + let rows_affected = conn.execute( + "DELETE FROM project_template_bindings WHERE id = ?1", + [&binding_id], + )?; + deleted_count += rows_affected; + } + + Ok(deleted_count as u32) + } + + /// 检查绑定是否存在 + pub fn exists(&self, project_id: &str, template_id: &str) -> Result { + let conn = self.connection.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT COUNT(*) FROM project_template_bindings WHERE project_id = ?1 AND template_id = ?2" + )?; + + let count: i64 = stmt.query_row([project_id, template_id], |row| row.get(0))?; + Ok(count > 0) + } + + /// 将数据库行转换为绑定实体 + fn row_to_binding(&self, row: &Row) -> Result { + let binding_type_str: String = row.get("binding_type")?; + let binding_status_str: String = row.get("binding_status")?; + let created_at_str: String = row.get("created_at")?; + let updated_at_str: String = row.get("updated_at")?; + + Ok(ProjectTemplateBinding { + id: row.get("id")?, + project_id: row.get("project_id")?, + template_id: row.get("template_id")?, + binding_name: row.get("binding_name")?, + description: row.get("description")?, + priority: row.get::<_, String>("priority")?.parse().unwrap_or(0), + is_active: row.get::<_, i32>("is_active")? != 0, + binding_type: serde_json::from_str(&binding_type_str).unwrap_or(BindingType::Primary), + binding_status: serde_json::from_str(&binding_status_str).unwrap_or(BindingStatus::Active), + metadata: row.get("metadata")?, + created_at: DateTime::parse_from_rfc3339(&created_at_str).unwrap().with_timezone(&Utc), + updated_at: DateTime::parse_from_rfc3339(&updated_at_str).unwrap().with_timezone(&Utc), + }) + } +} diff --git a/apps/desktop/src-tauri/src/data/repositories/video_classification_repository.rs b/apps/desktop/src-tauri/src/data/repositories/video_classification_repository.rs index b7b89ec..8950fb9 100644 --- a/apps/desktop/src-tauri/src/data/repositories/video_classification_repository.rs +++ b/apps/desktop/src-tauri/src/data/repositories/video_classification_repository.rs @@ -301,7 +301,7 @@ impl VideoClassificationRepository { let conn = conn.lock().unwrap(); // 调试:检查TaskStatus::Pending的序列化结果 - let pending_status_json = serde_json::to_string(&TaskStatus::Pending)?; + let _pending_status_json = serde_json::to_string(&TaskStatus::Pending)?; // 使用正确的JSON序列化格式查询 let pending_status_json = serde_json::to_string(&TaskStatus::Pending)?; @@ -495,7 +495,7 @@ impl VideoClassificationRepository { }; let mut debug_stmt = conn.prepare(&debug_query)?; - let debug_rows = debug_stmt.query_map([], |row| { + let _debug_rows = debug_stmt.query_map([], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?)) })?; diff --git a/apps/desktop/src-tauri/src/infrastructure/database.rs b/apps/desktop/src-tauri/src/infrastructure/database.rs index 093467f..bbda94b 100644 --- a/apps/desktop/src-tauri/src/infrastructure/database.rs +++ b/apps/desktop/src-tauri/src/infrastructure/database.rs @@ -340,6 +340,30 @@ impl Database { [], )?; + // 创建项目-模板绑定表 + conn.execute( + "CREATE TABLE IF NOT EXISTS project_template_bindings ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + template_id TEXT NOT NULL, + binding_name TEXT, + description TEXT, + priority INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + binding_type TEXT NOT NULL, + binding_status TEXT NOT NULL, + metadata TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE, + FOREIGN KEY (template_id) REFERENCES templates (id) ON DELETE CASCADE, + UNIQUE (project_id, template_id) + )", + [], + )?; + + + // 创建性能监控表 conn.execute( "CREATE TABLE IF NOT EXISTS performance_metrics ( @@ -389,6 +413,29 @@ impl Database { [], )?; + // 创建项目-模板绑定表索引 + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_project_template_bindings_project_id ON project_template_bindings (project_id)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_project_template_bindings_template_id ON project_template_bindings (template_id)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_project_template_bindings_binding_type ON project_template_bindings (binding_type)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_project_template_bindings_priority ON project_template_bindings (priority)", + [], + )?; + + + // 添加新字段(如果不存在)- 数据库迁移 let _ = conn.execute( "ALTER TABLE template_materials ADD COLUMN file_exists BOOLEAN DEFAULT FALSE", @@ -400,6 +447,18 @@ impl Database { [], ); + // 为素材表添加模特ID字段 + let _ = conn.execute( + "ALTER TABLE materials ADD COLUMN model_id TEXT", + [], + ); + + // 为素材表添加模特ID索引 + let _ = conn.execute( + "CREATE INDEX IF NOT EXISTS idx_materials_model_id ON materials (model_id)", + [], + ); + // 检查是否需要移除模板表的 project_id 字段 // 只有在表中存在 project_id 字段时才需要重建表 let has_project_id_column = conn.prepare("SELECT project_id FROM templates LIMIT 1").is_ok(); diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 2c7ff64..4bcc365 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -75,6 +75,12 @@ pub fn run() { commands::material_commands::disassociate_material_from_model, commands::material_commands::get_materials_by_model_id, commands::material_commands::get_unassociated_materials, + commands::material_commands::batch_bind_materials_to_model, + commands::material_commands::batch_unbind_materials_from_model, + commands::material_commands::switch_material_model, + commands::material_commands::get_model_material_statistics, + commands::material_commands::get_project_model_binding_stats, + commands::material_commands::update_material, // 模特管理命令 commands::model_commands::create_model, commands::model_commands::get_model_by_id, @@ -157,6 +163,22 @@ pub fn run() { commands::template_commands::import_template_optimized, commands::template_commands::update_segment_matching_rule, commands::template_commands::get_segment_matching_rule, + // 项目-模板绑定管理命令 + commands::project_template_binding_commands::create_project_template_binding, + commands::project_template_binding_commands::update_project_template_binding, + commands::project_template_binding_commands::delete_project_template_binding, + commands::project_template_binding_commands::delete_project_template_binding_by_ids, + commands::project_template_binding_commands::get_project_template_binding, + commands::project_template_binding_commands::list_project_template_bindings, + commands::project_template_binding_commands::get_templates_by_project, + commands::project_template_binding_commands::get_projects_by_template, + commands::project_template_binding_commands::batch_create_project_template_bindings, + commands::project_template_binding_commands::batch_delete_project_template_bindings, + commands::project_template_binding_commands::activate_project_template_binding, + commands::project_template_binding_commands::deactivate_project_template_binding, + commands::project_template_binding_commands::get_primary_template_binding_for_project, + commands::project_template_binding_commands::set_primary_template_for_project, + commands::project_template_binding_commands::check_project_template_binding_exists, // 测试命令 commands::test_commands::test_database_connection, commands::test_commands::test_template_table, diff --git a/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs index 14ec0c6..8af2c35 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs @@ -922,3 +922,128 @@ pub async fn get_unassociated_materials( repository.get_unassociated_materials(project_id.as_deref()) .map_err(|e| format!("获取未关联素材失败: {}", e)) } + +/// 批量绑定素材到模特命令 +#[command] +pub async fn batch_bind_materials_to_model( + state: State<'_, AppState>, + material_ids: Vec, + model_id: String, +) -> Result<(), String> { + let repository_guard = state.get_material_repository() + .map_err(|e| format!("获取素材仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("素材仓库未初始化")?; + + MaterialService::batch_bind_materials_to_model(repository, &material_ids, &model_id) + .map_err(|e| format!("批量绑定素材到模特失败: {}", e)) +} + +/// 批量解除素材与模特的绑定命令 +#[command] +pub async fn batch_unbind_materials_from_model( + state: State<'_, AppState>, + material_ids: Vec, +) -> Result<(), String> { + let repository_guard = state.get_material_repository() + .map_err(|e| format!("获取素材仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("素材仓库未初始化")?; + + MaterialService::batch_unbind_materials_from_model(repository, &material_ids) + .map_err(|e| format!("批量解除绑定失败: {}", e)) +} + +/// 切换素材的模特绑定命令 +#[command] +pub async fn switch_material_model( + state: State<'_, AppState>, + material_id: String, + new_model_id: String, +) -> Result<(), String> { + let repository_guard = state.get_material_repository() + .map_err(|e| format!("获取素材仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("素材仓库未初始化")?; + + MaterialService::switch_material_model(repository, &material_id, &new_model_id) + .map_err(|e| format!("切换素材模特绑定失败: {}", e)) +} + +/// 获取模特的素材统计信息命令 +#[command] +pub async fn get_model_material_statistics( + state: State<'_, AppState>, + model_id: String, +) -> Result { + let repository_guard = state.get_material_repository() + .map_err(|e| format!("获取素材仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("素材仓库未初始化")?; + + MaterialService::get_model_material_statistics(repository, &model_id) + .map_err(|e| format!("获取模特素材统计失败: {}", e)) +} + +/// 获取项目中模特绑定统计信息命令 +#[command] +pub async fn get_project_model_binding_stats( + state: State<'_, AppState>, + project_id: String, +) -> Result { + let repository_guard = state.get_material_repository() + .map_err(|e| format!("获取素材仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("素材仓库未初始化")?; + + MaterialService::get_project_model_binding_stats(repository, &project_id) + .map_err(|e| format!("获取项目模特绑定统计失败: {}", e)) +} + +/// 更新素材信息命令(包括模特绑定) +#[command] +pub async fn update_material( + state: State<'_, AppState>, + id: String, + updates: MaterialUpdateRequest, +) -> Result<(), String> { + let repository_guard = state.get_material_repository() + .map_err(|e| format!("获取素材仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("素材仓库未初始化")?; + + // 获取现有素材 + let mut material = repository.get_by_id(&id) + .map_err(|e| format!("获取素材失败: {}", e))? + .ok_or("找不到指定的素材")?; + + // 更新字段 + if let Some(model_id) = updates.model_id { + material.model_id = if model_id.is_empty() { None } else { Some(model_id) }; + } + if let Some(name) = updates.name { + material.name = name; + } + + // 更新时间戳 + material.updated_at = chrono::Utc::now(); + + // 保存更新 + repository.update(&material) + .map_err(|e| format!("更新素材失败: {}", e))?; + + Ok(()) +} + +/// 素材更新请求 +#[derive(Debug, serde::Deserialize)] +pub struct MaterialUpdateRequest { + pub model_id: Option, + pub name: Option, +} diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index 78c4f24..50b1754 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -8,3 +8,4 @@ pub mod ai_analysis_log_commands; pub mod template_commands; pub mod test_commands; pub mod debug_commands; +pub mod project_template_binding_commands; diff --git a/apps/desktop/src-tauri/src/presentation/commands/project_template_binding_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/project_template_binding_commands.rs new file mode 100644 index 0000000..8c92788 --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/project_template_binding_commands.rs @@ -0,0 +1,213 @@ +use tauri::State; +use crate::app_state::AppState; +use crate::business::services::project_template_binding_service::ProjectTemplateBindingService; +use crate::data::models::project_template_binding::{ + ProjectTemplateBinding, CreateProjectTemplateBindingRequest, UpdateProjectTemplateBindingRequest, + ProjectTemplateBindingQueryParams, ProjectTemplateBindingDetail, + BatchCreateProjectTemplateBindingRequest, BatchDeleteProjectTemplateBindingRequest, +}; + +/// 辅助函数:创建服务实例 +fn create_service(state: &State<'_, AppState>) -> Result { + let connection = state.get_connection().map_err(|e| e.to_string())?; + ProjectTemplateBindingService::new(connection).map_err(|e| e.to_string()) +} + +/// 创建项目-模板绑定 +#[tauri::command] +pub async fn create_project_template_binding( + request: CreateProjectTemplateBindingRequest, + state: State<'_, AppState>, +) -> Result { + let service = create_service(&state)?; + + service.create_binding(request) + .await + .map_err(|e| e.to_string()) +} + +/// 更新项目-模板绑定 +#[tauri::command] +pub async fn update_project_template_binding( + id: String, + request: UpdateProjectTemplateBindingRequest, + state: State<'_, AppState>, +) -> Result { + let service = create_service(&state)?; + + service.update_binding(&id, request) + .await + .map_err(|e| e.to_string()) +} + +/// 删除项目-模板绑定 +#[tauri::command] +pub async fn delete_project_template_binding( + id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let service = create_service(&state)?; + + service.delete_binding(&id) + .await + .map_err(|e| e.to_string()) +} + +/// 根据项目ID和模板ID删除绑定 +#[tauri::command] +pub async fn delete_project_template_binding_by_ids( + project_id: String, + template_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let service = create_service(&state)?; + + service.delete_binding_by_project_and_template(&project_id, &template_id) + .await + .map_err(|e| e.to_string()) +} + +/// 获取项目-模板绑定详情 +#[tauri::command] +pub async fn get_project_template_binding( + id: String, + state: State<'_, AppState>, +) -> Result { + let service = create_service(&state)?; + + service.get_binding(&id) + .await + .map_err(|e| e.to_string()) +} + +/// 查询项目-模板绑定列表 +#[tauri::command] +pub async fn list_project_template_bindings( + params: ProjectTemplateBindingQueryParams, + state: State<'_, AppState>, +) -> Result, String> { + let service = create_service(&state)?; + + service.list_bindings(params) + .await + .map_err(|e| e.to_string()) +} + +/// 获取项目的模板列表 +#[tauri::command] +pub async fn get_templates_by_project( + project_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let service = create_service(&state)?; + + service.get_templates_by_project(&project_id) + .await + .map_err(|e| e.to_string()) +} + +/// 获取模板的项目列表 +#[tauri::command] +pub async fn get_projects_by_template( + template_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let service = create_service(&state)?; + + service.get_projects_by_template(&template_id) + .await + .map_err(|e| e.to_string()) +} + +/// 批量创建项目-模板绑定 +#[tauri::command] +pub async fn batch_create_project_template_bindings( + request: BatchCreateProjectTemplateBindingRequest, + state: State<'_, AppState>, +) -> Result, String> { + let service = create_service(&state)?; + + service.batch_create_bindings(request) + .await + .map_err(|e| e.to_string()) +} + +/// 批量删除项目-模板绑定 +#[tauri::command] +pub async fn batch_delete_project_template_bindings( + request: BatchDeleteProjectTemplateBindingRequest, + state: State<'_, AppState>, +) -> Result { + let service = create_service(&state)?; + + service.batch_delete_bindings(request) + .await + .map_err(|e| e.to_string()) +} + +/// 激活项目-模板绑定 +#[tauri::command] +pub async fn activate_project_template_binding( + id: String, + state: State<'_, AppState>, +) -> Result { + let service = create_service(&state)?; + + service.activate_binding(&id) + .await + .map_err(|e| e.to_string()) +} + +/// 停用项目-模板绑定 +#[tauri::command] +pub async fn deactivate_project_template_binding( + id: String, + state: State<'_, AppState>, +) -> Result { + let service = create_service(&state)?; + + service.deactivate_binding(&id) + .await + .map_err(|e| e.to_string()) +} + +/// 获取项目的主要模板绑定 +#[tauri::command] +pub async fn get_primary_template_binding_for_project( + project_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let service = create_service(&state)?; + + service.get_primary_binding_for_project(&project_id) + .await + .map_err(|e| e.to_string()) +} + +/// 设置项目的主要模板 +#[tauri::command] +pub async fn set_primary_template_for_project( + project_id: String, + template_id: String, + state: State<'_, AppState>, +) -> Result { + let service = create_service(&state)?; + + service.set_primary_template(&project_id, &template_id) + .await + .map_err(|e| e.to_string()) +} + +/// 检查项目-模板绑定是否存在 +#[tauri::command] +pub async fn check_project_template_binding_exists( + project_id: String, + template_id: String, + state: State<'_, AppState>, +) -> Result { + let service = create_service(&state)?; + + service.binding_exists(&project_id, &template_id) + .await + .map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/tests/integration/binding_management_integration_tests.rs b/apps/desktop/src-tauri/src/tests/integration/binding_management_integration_tests.rs new file mode 100644 index 0000000..ecfda42 --- /dev/null +++ b/apps/desktop/src-tauri/src/tests/integration/binding_management_integration_tests.rs @@ -0,0 +1,437 @@ +/** + * 绑定管理功能集成测试 + * 测试项目-模板绑定和素材-模特绑定的完整工作流 + */ + +#[cfg(test)] +mod tests { + use super::*; + use crate::data::models::{ + project::Project, + template::Template, + material::{Material, MaterialType, ProcessingStatus}, + model::Model, + project_template_binding::{ProjectTemplateBinding, BindingType, BindingStatus, CreateProjectTemplateBindingRequest}, + }; + use crate::data::repositories::{ + project_repository::ProjectRepository, + template_repository::TemplateRepository, + material_repository::MaterialRepository, + model_repository::ModelRepository, + project_template_binding_repository::ProjectTemplateBindingRepository, + }; + use crate::business::services::{ + project_template_binding_service::ProjectTemplateBindingService, + material_service::MaterialService, + }; + use crate::infrastructure::database::Database; + use std::sync::{Arc, Mutex}; + use uuid::Uuid; + + /// 创建完整的测试数据库 + fn create_integration_test_database() -> Arc> { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + + // 创建所有必要的表 + conn.execute( + "CREATE TABLE projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + path TEXT NOT NULL, + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )", + [], + ).unwrap(); + + conn.execute( + "CREATE TABLE templates ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + file_path TEXT NOT NULL, + import_status TEXT NOT NULL, + project_id TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )", + [], + ).unwrap(); + + conn.execute( + "CREATE TABLE materials ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + original_path TEXT NOT NULL, + file_size INTEGER NOT NULL, + md5_hash TEXT NOT NULL, + material_type TEXT NOT NULL, + processing_status TEXT NOT NULL, + metadata TEXT, + scene_detection TEXT, + model_id TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + processed_at DATETIME, + error_message TEXT + )", + [], + ).unwrap(); + + conn.execute( + "CREATE TABLE models ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + stage_name TEXT, + description TEXT, + avatar_url TEXT, + tags TEXT, + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )", + [], + ).unwrap(); + + conn.execute( + "CREATE TABLE project_template_bindings ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + template_id TEXT NOT NULL, + binding_name TEXT, + description TEXT, + priority INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + binding_type TEXT NOT NULL, + binding_status TEXT NOT NULL, + metadata TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (project_id, template_id) + )", + [], + ).unwrap(); + + Arc::new(Mutex::new(conn)) + } + + /// 创建测试数据 + struct TestData { + project: Project, + templates: Vec