feat: 实现项目-模板绑定和素材-模特绑定管理功能

新功能:
- 项目-模板绑定管理系统
  - 支持主要/次要模板绑定类型
  - 绑定优先级和状态管理
  - 批量绑定操作
  - 绑定关系的CRUD操作

- 素材-模特绑定管理系统
  - 素材与模特的关联管理
  - 批量绑定/解绑操作
  - 绑定统计和分析
  - 素材编辑对话框

 架构改进:
- 新增项目-模板绑定数据模型和仓库层
- 新增素材-模特绑定业务服务层
- 完善的API命令层实现
- 响应式前端界面设计

 用户体验优化:
- 统一的通知系统
- 增强的加载状态组件
- 流畅的交互动画
- 优雅的确认对话框

 测试覆盖:
- 单元测试和集成测试
- 业务逻辑验证
- API接口测试

 技术栈:
- 后端: Rust + Tauri + SQLite
- 前端: React + TypeScript + TailwindCSS
- 状态管理: Zustand
- 测试: Vitest + Rust测试框架

 配置更新:
- 更新数据库迁移脚本
- 完善测试配置
- 优化构建流程
This commit is contained in:
imeepos
2025-07-15 12:50:30 +08:00
parent 9f222b562d
commit 730402aba0
41 changed files with 7259 additions and 49 deletions

View File

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

View File

@@ -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 "✨ 绑定管理功能测试全部通过!"

View File

@@ -72,6 +72,12 @@ impl AppState {
}).clone()
}
/// 获取数据库连接
pub fn get_connection(&self) -> anyhow::Result<std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>> {
let database = self.get_database();
Ok(database.get_connection())
}
/// 用于测试的构造函数
#[cfg(test)]
pub fn new_with_database(database: Arc<Database>) -> Self {

View File

@@ -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};

View File

@@ -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<Vec<Material>> {
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<Vec<Material>> {
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<crate::data::repositories::material_repository::ModelMaterialStatistics> {
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<ProjectModelBindingStats> {
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, // 绑定率百分比
}

View File

@@ -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;

View File

@@ -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<Mutex<Connection>>) -> Result<Self, BusinessError> {
Ok(Self {
binding_repository: ProjectTemplateBindingRepository::new(connection),
})
}
/// 创建项目-模板绑定
pub async fn create_binding(&self, request: CreateProjectTemplateBindingRequest) -> Result<ProjectTemplateBinding, BusinessError> {
// 验证请求数据
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<ProjectTemplateBinding, BusinessError> {
// 验证请求数据
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<ProjectTemplateBinding, BusinessError> {
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<Vec<ProjectTemplateBinding>, 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<Vec<ProjectTemplateBindingDetail>, 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<Vec<ProjectTemplateBindingDetail>, 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<Vec<ProjectTemplateBinding>, 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<u32, BusinessError> {
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<ProjectTemplateBinding, BusinessError> {
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<ProjectTemplateBinding, BusinessError> {
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<Option<ProjectTemplateBinding>, 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<ProjectTemplateBinding, BusinessError> {
// 移除现有的主要绑定
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<bool, BusinessError> {
let exists = self.binding_repository.exists(project_id, template_id)
.map_err(|e| BusinessError::BusinessRuleViolation(e.to_string()))?;
Ok(exists)
}
}

View File

@@ -4,3 +4,4 @@ pub mod model;
pub mod ai_classification;
pub mod video_classification;
pub mod template;
pub mod project_template_binding;

View File

@@ -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<String>, // 绑定的自定义名称
pub description: Option<String>,
pub priority: u32, // 绑定优先级,数值越小优先级越高
pub is_active: bool,
pub binding_type: BindingType,
pub binding_status: BindingStatus,
pub metadata: Option<String>, // JSON格式的额外元数据
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// 绑定类型
#[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<String>,
description: Option<String>,
priority: Option<u32>,
binding_type: Option<BindingType>,
binding_status: Option<BindingStatus>,
) {
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<String>,
pub description: Option<String>,
pub priority: Option<u32>,
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<String>,
pub description: Option<String>,
pub priority: Option<u32>,
pub binding_type: Option<BindingType>,
pub binding_status: Option<BindingStatus>,
pub is_active: Option<bool>,
}
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<String>,
pub template_id: Option<String>,
pub binding_type: Option<BindingType>,
pub binding_status: Option<BindingStatus>,
pub is_active: Option<bool>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
/// 批量创建项目-模板绑定请求
#[derive(Debug, Deserialize)]
pub struct BatchCreateProjectTemplateBindingRequest {
pub project_id: String,
pub template_ids: Vec<String>,
pub binding_type: BindingType,
pub priority_start: Option<u32>, // 起始优先级,后续模板优先级递增
}
/// 批量删除项目-模板绑定请求
#[derive(Debug, Deserialize)]
pub struct BatchDeleteProjectTemplateBindingRequest {
pub binding_ids: Vec<String>,
}
/// 项目-模板绑定详细信息(包含关联的项目和模板信息)
#[derive(Debug, Serialize)]
pub struct ProjectTemplateBindingDetail {
pub binding: ProjectTemplateBinding,
pub project_name: String,
pub template_name: String,
pub template_description: Option<String>,
}

View File

@@ -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<Vec<Material>> {
self.get_materials_by_model_id(model_id)
}
/// 获取未绑定模特的素材别名方法为了API一致性
pub fn get_unbound_materials(&self) -> Result<Vec<Material>> {
self.get_unassociated_materials(None)
}
/// 获取模特的素材统计信息
pub fn get_model_statistics(&self, model_id: &str) -> Result<ModelMaterialStatistics> {
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<bool> {
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<u32> {
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<u32> {
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,
}

View File

@@ -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;

View File

@@ -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<Mutex<Connection>>,
}
impl ProjectTemplateBindingRepository {
/// 创建新的项目-模板绑定仓储实例
pub fn new(connection: Arc<Mutex<Connection>>) -> Self {
Self { connection }
}
/// 创建项目-模板绑定
pub fn create(&self, request: CreateProjectTemplateBindingRequest) -> Result<ProjectTemplateBinding> {
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<Option<ProjectTemplateBinding>> {
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<Option<ProjectTemplateBinding>> {
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<bool> {
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<bool> {
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<Vec<ProjectTemplateBinding>> {
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<String> = 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<Vec<ProjectTemplateBindingDetail>> {
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<String> = 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<Vec<ProjectTemplateBindingDetail>> {
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<String> = 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<Vec<ProjectTemplateBinding>> {
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<u32> {
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<bool> {
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<ProjectTemplateBinding> {
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),
})
}
}

View File

@@ -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)?))
})?;

View File

@@ -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();

View File

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

View File

@@ -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<String>,
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<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_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<crate::data::repositories::material_repository::ModelMaterialStatistics, String> {
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<crate::business::services::material_service::ProjectModelBindingStats, String> {
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<String>,
pub name: Option<String>,
}

View File

@@ -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;

View File

@@ -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<ProjectTemplateBindingService, String> {
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<ProjectTemplateBinding, String> {
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<ProjectTemplateBinding, String> {
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<ProjectTemplateBinding, String> {
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<Vec<ProjectTemplateBinding>, 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<Vec<ProjectTemplateBindingDetail>, 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<Vec<ProjectTemplateBindingDetail>, 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<Vec<ProjectTemplateBinding>, 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<u32, String> {
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<ProjectTemplateBinding, String> {
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<ProjectTemplateBinding, String> {
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<Option<ProjectTemplateBinding>, 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<ProjectTemplateBinding, String> {
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<bool, String> {
let service = create_service(&state)?;
service.binding_exists(&project_id, &template_id)
.await
.map_err(|e| e.to_string())
}

View File

@@ -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<Mutex<rusqlite::Connection>> {
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<Template>,
materials: Vec<Material>,
models: Vec<Model>,
}
fn create_test_data(conn: &Arc<Mutex<rusqlite::Connection>>) -> TestData {
let project_repo = ProjectRepository::new(conn.clone());
let template_repo = TemplateRepository::new(conn.clone());
let material_repo = MaterialRepository::new(conn.clone());
let model_repo = ModelRepository::new(conn.clone());
// 创建项目
let project = Project {
id: Uuid::new_v4().to_string(),
name: "测试项目".to_string(),
path: "/path/to/project".to_string(),
description: Some("这是一个测试项目".to_string()),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
let project = project_repo.create(project).unwrap();
// 创建模板
let templates = vec![
Template {
id: Uuid::new_v4().to_string(),
name: "主要模板".to_string(),
description: Some("这是主要模板".to_string()),
file_path: "/path/to/template1".to_string(),
import_status: crate::data::models::template::ImportStatus::Completed,
project_id: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
},
Template {
id: Uuid::new_v4().to_string(),
name: "次要模板".to_string(),
description: Some("这是次要模板".to_string()),
file_path: "/path/to/template2".to_string(),
import_status: crate::data::models::template::ImportStatus::Completed,
project_id: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
},
];
let templates: Vec<Template> = templates.into_iter()
.map(|t| template_repo.create(t).unwrap())
.collect();
// 创建模特
let models = vec![
Model {
id: Uuid::new_v4().to_string(),
name: "模特A".to_string(),
stage_name: Some("艺名A".to_string()),
description: Some("这是模特A".to_string()),
avatar_url: None,
tags: vec!["标签1".to_string(), "标签2".to_string()],
is_active: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
},
Model {
id: Uuid::new_v4().to_string(),
name: "模特B".to_string(),
stage_name: Some("艺名B".to_string()),
description: Some("这是模特B".to_string()),
avatar_url: None,
tags: vec!["标签3".to_string()],
is_active: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
},
];
let models: Vec<Model> = models.into_iter()
.map(|m| model_repo.create(m).unwrap())
.collect();
// 创建素材
let materials = vec![
Material {
id: Uuid::new_v4().to_string(),
project_id: project.id.clone(),
name: "视频素材1.mp4".to_string(),
original_path: "/path/to/video1.mp4".to_string(),
file_size: 1024000,
md5_hash: "hash1".to_string(),
material_type: MaterialType::Video,
processing_status: ProcessingStatus::Completed,
metadata: None,
scene_detection: None,
model_id: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
processed_at: Some(chrono::Utc::now()),
error_message: None,
},
Material {
id: Uuid::new_v4().to_string(),
project_id: project.id.clone(),
name: "视频素材2.mp4".to_string(),
original_path: "/path/to/video2.mp4".to_string(),
file_size: 2048000,
md5_hash: "hash2".to_string(),
material_type: MaterialType::Video,
processing_status: ProcessingStatus::Completed,
metadata: None,
scene_detection: None,
model_id: None,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
processed_at: Some(chrono::Utc::now()),
error_message: None,
},
];
let materials: Vec<Material> = materials.into_iter()
.map(|m| material_repo.create(m).unwrap())
.collect();
TestData {
project,
templates,
materials,
models,
}
}
#[tokio::test]
async fn test_complete_project_template_binding_workflow() {
let conn = create_integration_test_database();
let test_data = create_test_data(&conn);
let binding_service = ProjectTemplateBindingService::new(conn.clone()).unwrap();
// 1. 创建主要模板绑定
let primary_binding_request = CreateProjectTemplateBindingRequest {
project_id: test_data.project.id.clone(),
template_id: test_data.templates[0].id.clone(),
binding_name: Some("主要绑定".to_string()),
description: Some("这是主要模板绑定".to_string()),
priority: Some(0),
binding_type: BindingType::Primary,
};
let primary_binding = binding_service.create_binding(primary_binding_request).await.unwrap();
assert_eq!(primary_binding.binding_type, BindingType::Primary);
assert_eq!(primary_binding.priority, 0);
// 2. 创建次要模板绑定
let secondary_binding_request = CreateProjectTemplateBindingRequest {
project_id: test_data.project.id.clone(),
template_id: test_data.templates[1].id.clone(),
binding_name: Some("次要绑定".to_string()),
description: Some("这是次要模板绑定".to_string()),
priority: Some(1),
binding_type: BindingType::Secondary,
};
let secondary_binding = binding_service.create_binding(secondary_binding_request).await.unwrap();
assert_eq!(secondary_binding.binding_type, BindingType::Secondary);
assert_eq!(secondary_binding.priority, 1);
// 3. 获取项目的所有绑定
let bindings = binding_service.get_bindings_by_project(&test_data.project.id).await.unwrap();
assert_eq!(bindings.len(), 2);
// 验证排序(按优先级)
assert_eq!(bindings[0].priority, 0);
assert_eq!(bindings[1].priority, 1);
// 4. 更新绑定状态
let updated_binding = binding_service.toggle_binding_status(&secondary_binding.id).await.unwrap();
assert_eq!(updated_binding.binding_status, BindingStatus::Paused);
// 5. 删除绑定
let deleted = binding_service.delete_binding(&secondary_binding.id).await.unwrap();
assert!(deleted);
// 验证删除
let remaining_bindings = binding_service.get_bindings_by_project(&test_data.project.id).await.unwrap();
assert_eq!(remaining_bindings.len(), 1);
assert_eq!(remaining_bindings[0].id, primary_binding.id);
}
#[tokio::test]
async fn test_complete_material_model_binding_workflow() {
let conn = create_integration_test_database();
let test_data = create_test_data(&conn);
let material_service = MaterialService::new(conn.clone()).unwrap();
// 1. 绑定第一个素材到第一个模特
let result = material_service.bind_material_to_model(
&test_data.materials[0].id,
&test_data.models[0].id
).await;
assert!(result.is_ok());
// 验证绑定
let material = material_service.get_material_by_id(&test_data.materials[0].id).await.unwrap().unwrap();
assert_eq!(material.model_id, Some(test_data.models[0].id.clone()));
// 2. 绑定第二个素材到第二个模特
let result = material_service.bind_material_to_model(
&test_data.materials[1].id,
&test_data.models[1].id
).await;
assert!(result.is_ok());
// 3. 获取第一个模特的所有素材
let model_materials = material_service.get_materials_by_model(&test_data.models[0].id).await.unwrap();
assert_eq!(model_materials.len(), 1);
assert_eq!(model_materials[0].id, test_data.materials[0].id);
// 4. 切换第一个素材到第二个模特
let result = material_service.bind_material_to_model(
&test_data.materials[0].id,
&test_data.models[1].id
).await;
assert!(result.is_ok());
// 验证切换
let updated_material = material_service.get_material_by_id(&test_data.materials[0].id).await.unwrap().unwrap();
assert_eq!(updated_material.model_id, Some(test_data.models[1].id.clone()));
// 验证第二个模特现在有两个素材
let model2_materials = material_service.get_materials_by_model(&test_data.models[1].id).await.unwrap();
assert_eq!(model2_materials.len(), 2);
// 验证第一个模特现在没有素材
let model1_materials = material_service.get_materials_by_model(&test_data.models[0].id).await.unwrap();
assert_eq!(model1_materials.len(), 0);
// 5. 解除绑定
let result = material_service.unbind_material_from_model(&test_data.materials[0].id).await;
assert!(result.is_ok());
// 验证解除绑定
let unbound_material = material_service.get_material_by_id(&test_data.materials[0].id).await.unwrap().unwrap();
assert_eq!(unbound_material.model_id, None);
}
#[tokio::test]
async fn test_cross_feature_integration() {
let conn = create_integration_test_database();
let test_data = create_test_data(&conn);
let binding_service = ProjectTemplateBindingService::new(conn.clone()).unwrap();
let material_service = MaterialService::new(conn.clone()).unwrap();
// 1. 创建项目-模板绑定
let binding_request = CreateProjectTemplateBindingRequest {
project_id: test_data.project.id.clone(),
template_id: test_data.templates[0].id.clone(),
binding_name: Some("集成测试绑定".to_string()),
description: None,
priority: Some(0),
binding_type: BindingType::Primary,
};
let binding = binding_service.create_binding(binding_request).await.unwrap();
// 2. 绑定素材到模特
material_service.bind_material_to_model(
&test_data.materials[0].id,
&test_data.models[0].id
).await.unwrap();
// 3. 验证项目现在有绑定的模板和素材
let project_bindings = binding_service.get_bindings_by_project(&test_data.project.id).await.unwrap();
assert_eq!(project_bindings.len(), 1);
let project_materials = material_service.get_materials_by_project(&test_data.project.id).await.unwrap();
let bound_materials: Vec<_> = project_materials.iter()
.filter(|m| m.model_id.is_some())
.collect();
assert_eq!(bound_materials.len(), 1);
// 4. 模拟完整的工作流:项目有模板绑定,素材有模特绑定
// 这种情况下,项目可以使用绑定的模板进行视频制作,
// 同时素材已经标识了相关的模特
assert_eq!(binding.project_id, test_data.project.id);
assert_eq!(bound_materials[0].project_id, test_data.project.id);
assert_eq!(bound_materials[0].model_id, Some(test_data.models[0].id.clone()));
}
#[test]
fn test_binding_constraints_and_validation() {
let conn = create_integration_test_database();
let test_data = create_test_data(&conn);
let binding_repo = ProjectTemplateBindingRepository::new(conn.clone());
// 1. 测试唯一约束:不能重复绑定相同的项目-模板组合
let request1 = CreateProjectTemplateBindingRequest {
project_id: test_data.project.id.clone(),
template_id: test_data.templates[0].id.clone(),
binding_name: Some("第一个绑定".to_string()),
description: None,
priority: Some(0),
binding_type: BindingType::Primary,
};
let binding1 = binding_repo.create(request1.clone()).unwrap();
assert!(binding1.id.len() > 0);
// 尝试创建重复绑定应该失败
let result2 = binding_repo.create(request1);
assert!(result2.is_err());
// 2. 测试数据完整性
let retrieved_binding = binding_repo.get_by_id(&binding1.id).unwrap().unwrap();
assert_eq!(retrieved_binding.project_id, test_data.project.id);
assert_eq!(retrieved_binding.template_id, test_data.templates[0].id);
assert_eq!(retrieved_binding.binding_type, BindingType::Primary);
assert_eq!(retrieved_binding.binding_status, BindingStatus::Active);
assert!(retrieved_binding.is_active);
}
}

View File

@@ -0,0 +1,360 @@
/**
* 素材-模特绑定功能测试
* 遵循 Tauri 开发规范的测试设计原则
*/
#[cfg(test)]
mod tests {
use super::*;
use crate::data::models::material::Material;
use crate::data::models::model::Model;
use crate::data::repositories::material_repository::MaterialRepository;
use crate::data::repositories::model_repository::ModelRepository;
use crate::business::services::material_service::MaterialService;
use crate::infrastructure::database::Database;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
/// 创建测试数据库连接
fn create_test_database() -> Arc<Mutex<rusqlite::Connection>> {
let conn = rusqlite::Connection::open_in_memory().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();
Arc::new(Mutex::new(conn))
}
/// 创建测试模特
fn create_test_model(conn: &Arc<Mutex<rusqlite::Connection>>) -> Model {
let model_repository = ModelRepository::new(conn.clone());
let model = Model {
id: Uuid::new_v4().to_string(),
name: "测试模特".to_string(),
stage_name: Some("艺名".to_string()),
description: Some("这是一个测试模特".to_string()),
avatar_url: None,
tags: vec!["标签1".to_string(), "标签2".to_string()],
is_active: true,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
model_repository.create(model).unwrap()
}
/// 创建测试素材
fn create_test_material(
conn: &Arc<Mutex<rusqlite::Connection>>,
model_id: Option<String>,
) -> Material {
let material_repository = MaterialRepository::new(conn.clone());
let material = Material {
id: Uuid::new_v4().to_string(),
project_id: Uuid::new_v4().to_string(),
name: "测试素材.mp4".to_string(),
original_path: "/path/to/test.mp4".to_string(),
file_size: 1024000,
md5_hash: "abcd1234".to_string(),
material_type: crate::data::models::material::MaterialType::Video,
processing_status: crate::data::models::material::ProcessingStatus::Completed,
metadata: None,
scene_detection: None,
model_id,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
processed_at: Some(chrono::Utc::now()),
error_message: None,
};
material_repository.create(material).unwrap()
}
#[test]
fn test_bind_material_to_model() {
let conn = create_test_database();
let material_repository = MaterialRepository::new(conn.clone());
// 创建模特和素材
let model = create_test_model(&conn);
let material = create_test_material(&conn, None);
// 绑定素材到模特
let result = material_repository.update_model_binding(&material.id, Some(&model.id));
assert!(result.is_ok());
// 验证绑定
let updated_material = material_repository.get_by_id(&material.id).unwrap().unwrap();
assert_eq!(updated_material.model_id, Some(model.id));
}
#[test]
fn test_unbind_material_from_model() {
let conn = create_test_database();
let material_repository = MaterialRepository::new(conn.clone());
// 创建模特和已绑定的素材
let model = create_test_model(&conn);
let material = create_test_material(&conn, Some(model.id.clone()));
// 验证初始绑定
assert_eq!(material.model_id, Some(model.id));
// 解除绑定
let result = material_repository.update_model_binding(&material.id, None);
assert!(result.is_ok());
// 验证解除绑定
let updated_material = material_repository.get_by_id(&material.id).unwrap().unwrap();
assert_eq!(updated_material.model_id, None);
}
#[test]
fn test_get_materials_by_model() {
let conn = create_test_database();
let material_repository = MaterialRepository::new(conn.clone());
// 创建模特
let model = create_test_model(&conn);
// 创建多个素材,其中一些绑定到模特
let material1 = create_test_material(&conn, Some(model.id.clone()));
let material2 = create_test_material(&conn, Some(model.id.clone()));
let _material3 = create_test_material(&conn, None); // 未绑定
// 获取绑定到模特的素材
let result = material_repository.get_by_model_id(&model.id);
assert!(result.is_ok());
let materials = result.unwrap();
assert_eq!(materials.len(), 2);
let material_ids: Vec<String> = materials.iter().map(|m| m.id.clone()).collect();
assert!(material_ids.contains(&material1.id));
assert!(material_ids.contains(&material2.id));
}
#[test]
fn test_batch_bind_materials_to_model() {
let conn = create_test_database();
let material_repository = MaterialRepository::new(conn.clone());
// 创建模特和多个素材
let model = create_test_model(&conn);
let material1 = create_test_material(&conn, None);
let material2 = create_test_material(&conn, None);
let material3 = create_test_material(&conn, None);
let material_ids = vec![material1.id.clone(), material2.id.clone(), material3.id.clone()];
// 批量绑定
let result = material_repository.batch_update_model_binding(&material_ids, Some(&model.id));
assert!(result.is_ok());
// 验证所有素材都已绑定
for material_id in &material_ids {
let material = material_repository.get_by_id(material_id).unwrap().unwrap();
assert_eq!(material.model_id, Some(model.id.clone()));
}
}
#[test]
fn test_batch_unbind_materials_from_model() {
let conn = create_test_database();
let material_repository = MaterialRepository::new(conn.clone());
// 创建模特和已绑定的素材
let model = create_test_model(&conn);
let material1 = create_test_material(&conn, Some(model.id.clone()));
let material2 = create_test_material(&conn, Some(model.id.clone()));
let material3 = create_test_material(&conn, Some(model.id.clone()));
let material_ids = vec![material1.id.clone(), material2.id.clone(), material3.id.clone()];
// 批量解除绑定
let result = material_repository.batch_update_model_binding(&material_ids, None);
assert!(result.is_ok());
// 验证所有素材都已解除绑定
for material_id in &material_ids {
let material = material_repository.get_by_id(material_id).unwrap().unwrap();
assert_eq!(material.model_id, None);
}
}
#[test]
fn test_get_model_statistics() {
let conn = create_test_database();
let material_repository = MaterialRepository::new(conn.clone());
// 创建模特
let model = create_test_model(&conn);
// 创建不同类型的素材
let _video_material = create_test_material(&conn, Some(model.id.clone()));
let _audio_material = {
let mut material = create_test_material(&conn, Some(model.id.clone()));
material.material_type = crate::data::models::material::MaterialType::Audio;
material_repository.update(&material.id, material).unwrap()
};
let _image_material = {
let mut material = create_test_material(&conn, Some(model.id.clone()));
material.material_type = crate::data::models::material::MaterialType::Image;
material_repository.update(&material.id, material).unwrap()
};
// 获取模特统计信息
let result = material_repository.get_model_statistics(&model.id);
assert!(result.is_ok());
let stats = result.unwrap();
assert_eq!(stats.total_materials, 3);
assert_eq!(stats.video_count, 1);
assert_eq!(stats.audio_count, 1);
assert_eq!(stats.image_count, 1);
}
#[tokio::test]
async fn test_business_service_bind_material() {
let conn = create_test_database();
let service = MaterialService::new(conn.clone()).unwrap();
// 创建模特和素材
let model = create_test_model(&conn);
let material = create_test_material(&conn, None);
// 通过业务服务绑定
let result = service.bind_material_to_model(&material.id, &model.id).await;
assert!(result.is_ok());
// 验证绑定
let updated_material = service.get_material_by_id(&material.id).await.unwrap().unwrap();
assert_eq!(updated_material.model_id, Some(model.id));
}
#[tokio::test]
async fn test_business_service_validate_model_exists() {
let conn = create_test_database();
let service = MaterialService::new(conn.clone()).unwrap();
// 创建素材
let material = create_test_material(&conn, None);
let non_existent_model_id = Uuid::new_v4().to_string();
// 尝试绑定到不存在的模特应该失败
let result = service.bind_material_to_model(&material.id, &non_existent_model_id).await;
assert!(result.is_err());
}
#[test]
fn test_material_model_binding_constraints() {
let conn = create_test_database();
let material_repository = MaterialRepository::new(conn.clone());
// 创建素材
let material = create_test_material(&conn, None);
let non_existent_model_id = Uuid::new_v4().to_string();
// 尝试绑定到不存在的模特ID在实际应用中应该有外键约束
let result = material_repository.update_model_binding(&material.id, Some(&non_existent_model_id));
// 注意:在内存数据库中没有外键约束,所以这个测试可能会成功
// 在实际应用中,应该有适当的约束和验证
println!("绑定结果: {:?}", result);
}
#[test]
fn test_material_search_by_model() {
let conn = create_test_database();
let material_repository = MaterialRepository::new(conn.clone());
// 创建多个模特
let model1 = create_test_model(&conn);
let model2 = create_test_model(&conn);
// 创建素材并绑定到不同模特
let _material1 = create_test_material(&conn, Some(model1.id.clone()));
let _material2 = create_test_material(&conn, Some(model1.id.clone()));
let _material3 = create_test_material(&conn, Some(model2.id.clone()));
let _material4 = create_test_material(&conn, None); // 未绑定
// 搜索绑定到model1的素材
let materials1 = material_repository.get_by_model_id(&model1.id).unwrap();
assert_eq!(materials1.len(), 2);
// 搜索绑定到model2的素材
let materials2 = material_repository.get_by_model_id(&model2.id).unwrap();
assert_eq!(materials2.len(), 1);
// 搜索未绑定的素材
let unbound_materials = material_repository.get_unbound_materials().unwrap();
assert_eq!(unbound_materials.len(), 1);
}
#[test]
fn test_material_binding_history() {
let conn = create_test_database();
let material_repository = MaterialRepository::new(conn.clone());
// 创建模特和素材
let model1 = create_test_model(&conn);
let model2 = create_test_model(&conn);
let material = create_test_material(&conn, None);
// 记录初始状态
let initial_material = material_repository.get_by_id(&material.id).unwrap().unwrap();
assert_eq!(initial_material.model_id, None);
// 绑定到第一个模特
material_repository.update_model_binding(&material.id, Some(&model1.id)).unwrap();
let bound_material1 = material_repository.get_by_id(&material.id).unwrap().unwrap();
assert_eq!(bound_material1.model_id, Some(model1.id.clone()));
assert!(bound_material1.updated_at > initial_material.updated_at);
// 切换到第二个模特
material_repository.update_model_binding(&material.id, Some(&model2.id)).unwrap();
let bound_material2 = material_repository.get_by_id(&material.id).unwrap().unwrap();
assert_eq!(bound_material2.model_id, Some(model2.id));
assert!(bound_material2.updated_at > bound_material1.updated_at);
// 解除绑定
material_repository.update_model_binding(&material.id, None).unwrap();
let unbound_material = material_repository.get_by_id(&material.id).unwrap().unwrap();
assert_eq!(unbound_material.model_id, None);
assert!(unbound_material.updated_at > bound_material2.updated_at);
}
}

View File

@@ -2,3 +2,10 @@
/// 遵循 Tauri 开发规范的测试组织结构
pub mod material_tests;
pub mod project_template_binding_tests;
pub mod material_model_binding_tests;
/// 集成测试模块
pub mod integration {
pub mod binding_management_integration_tests;
}

View File

@@ -0,0 +1,379 @@
/**
* 项目-模板绑定功能测试
* 遵循 Tauri 开发规范的测试设计原则
*/
#[cfg(test)]
mod tests {
use super::*;
use crate::data::models::project_template_binding::{
ProjectTemplateBinding, BindingType, BindingStatus,
CreateProjectTemplateBindingRequest, UpdateProjectTemplateBindingRequest,
ProjectTemplateBindingQueryParams, BatchCreateProjectTemplateBindingRequest,
};
use crate::data::repositories::project_template_binding_repository::ProjectTemplateBindingRepository;
use crate::business::services::project_template_binding_service::ProjectTemplateBindingService;
use crate::infrastructure::database::Database;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
/// 创建测试数据库连接
fn create_test_database() -> Arc<Mutex<rusqlite::Connection>> {
let conn = rusqlite::Connection::open_in_memory().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))
}
/// 创建测试绑定请求
fn create_test_binding_request() -> CreateProjectTemplateBindingRequest {
CreateProjectTemplateBindingRequest {
project_id: Uuid::new_v4().to_string(),
template_id: Uuid::new_v4().to_string(),
binding_name: Some("测试绑定".to_string()),
description: Some("这是一个测试绑定".to_string()),
priority: Some(1),
binding_type: BindingType::Primary,
}
}
#[test]
fn test_create_project_template_binding() {
let conn = create_test_database();
let repository = ProjectTemplateBindingRepository::new(conn);
let request = create_test_binding_request();
let result = repository.create(request.clone());
assert!(result.is_ok());
let binding = result.unwrap();
assert_eq!(binding.project_id, request.project_id);
assert_eq!(binding.template_id, request.template_id);
assert_eq!(binding.binding_name, request.binding_name);
assert_eq!(binding.description, request.description);
assert_eq!(binding.priority, request.priority.unwrap());
assert_eq!(binding.binding_type, request.binding_type);
assert_eq!(binding.binding_status, BindingStatus::Active);
assert!(binding.is_active);
}
#[test]
fn test_get_binding_by_id() {
let conn = create_test_database();
let repository = ProjectTemplateBindingRepository::new(conn);
let request = create_test_binding_request();
// 创建绑定
let created_binding = repository.create(request).unwrap();
// 获取绑定
let result = repository.get_by_id(&created_binding.id);
assert!(result.is_ok());
let binding = result.unwrap();
assert!(binding.is_some());
let binding = binding.unwrap();
assert_eq!(binding.id, created_binding.id);
assert_eq!(binding.project_id, created_binding.project_id);
assert_eq!(binding.template_id, created_binding.template_id);
}
#[test]
fn test_update_binding() {
let conn = create_test_database();
let repository = ProjectTemplateBindingRepository::new(conn);
let request = create_test_binding_request();
// 创建绑定
let created_binding = repository.create(request).unwrap();
// 更新绑定
let update_request = UpdateProjectTemplateBindingRequest {
binding_name: Some("更新后的绑定名称".to_string()),
description: Some("更新后的描述".to_string()),
priority: Some(5),
binding_type: Some(BindingType::Secondary),
binding_status: Some(BindingStatus::Paused),
is_active: Some(false),
};
let result = repository.update(&created_binding.id, update_request);
assert!(result.is_ok());
let updated_binding = result.unwrap();
assert!(updated_binding.is_some());
let updated_binding = updated_binding.unwrap();
assert_eq!(updated_binding.binding_name, Some("更新后的绑定名称".to_string()));
assert_eq!(updated_binding.description, Some("更新后的描述".to_string()));
assert_eq!(updated_binding.priority, 5);
assert_eq!(updated_binding.binding_type, BindingType::Secondary);
assert_eq!(updated_binding.binding_status, BindingStatus::Paused);
assert!(!updated_binding.is_active);
}
#[test]
fn test_delete_binding() {
let conn = create_test_database();
let repository = ProjectTemplateBindingRepository::new(conn);
let request = create_test_binding_request();
// 创建绑定
let created_binding = repository.create(request).unwrap();
// 删除绑定
let result = repository.delete(&created_binding.id);
assert!(result.is_ok());
assert!(result.unwrap());
// 验证绑定已被删除
let get_result = repository.get_by_id(&created_binding.id);
assert!(get_result.is_ok());
assert!(get_result.unwrap().is_none());
}
#[test]
fn test_list_bindings_with_filters() {
let conn = create_test_database();
let repository = ProjectTemplateBindingRepository::new(conn);
let project_id = Uuid::new_v4().to_string();
// 创建多个绑定
let requests = vec![
CreateProjectTemplateBindingRequest {
project_id: project_id.clone(),
template_id: Uuid::new_v4().to_string(),
binding_name: Some("主要绑定".to_string()),
description: None,
priority: Some(0),
binding_type: BindingType::Primary,
},
CreateProjectTemplateBindingRequest {
project_id: project_id.clone(),
template_id: Uuid::new_v4().to_string(),
binding_name: Some("次要绑定".to_string()),
description: None,
priority: Some(1),
binding_type: BindingType::Secondary,
},
CreateProjectTemplateBindingRequest {
project_id: Uuid::new_v4().to_string(), // 不同项目
template_id: Uuid::new_v4().to_string(),
binding_name: Some("其他项目绑定".to_string()),
description: None,
priority: Some(0),
binding_type: BindingType::Primary,
},
];
for request in requests {
repository.create(request).unwrap();
}
// 测试按项目ID过滤
let query_params = ProjectTemplateBindingQueryParams {
project_id: Some(project_id.clone()),
template_id: None,
binding_type: None,
binding_status: None,
is_active: None,
limit: None,
offset: None,
};
let result = repository.list(query_params);
assert!(result.is_ok());
let bindings = result.unwrap();
assert_eq!(bindings.len(), 2);
// 验证排序(按优先级排序)
assert_eq!(bindings[0].priority, 0);
assert_eq!(bindings[1].priority, 1);
// 测试按绑定类型过滤
let query_params = ProjectTemplateBindingQueryParams {
project_id: Some(project_id),
template_id: None,
binding_type: Some(BindingType::Primary),
binding_status: None,
is_active: None,
limit: None,
offset: None,
};
let result = repository.list(query_params);
assert!(result.is_ok());
let bindings = result.unwrap();
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].binding_type, BindingType::Primary);
}
#[test]
fn test_batch_create_bindings() {
let conn = create_test_database();
let repository = ProjectTemplateBindingRepository::new(conn);
let project_id = Uuid::new_v4().to_string();
let template_ids = vec![
Uuid::new_v4().to_string(),
Uuid::new_v4().to_string(),
Uuid::new_v4().to_string(),
];
let request = BatchCreateProjectTemplateBindingRequest {
project_id: project_id.clone(),
template_ids: template_ids.clone(),
binding_type: BindingType::Secondary,
priority_start: Some(10),
};
let result = repository.batch_create(request);
assert!(result.is_ok());
let bindings = result.unwrap();
assert_eq!(bindings.len(), 3);
// 验证优先级递增
for (i, binding) in bindings.iter().enumerate() {
assert_eq!(binding.project_id, project_id);
assert_eq!(binding.template_id, template_ids[i]);
assert_eq!(binding.binding_type, BindingType::Secondary);
assert_eq!(binding.priority, 10 + i as u32);
}
}
#[test]
fn test_binding_exists() {
let conn = create_test_database();
let repository = ProjectTemplateBindingRepository::new(conn);
let request = create_test_binding_request();
// 绑定不存在时
let result = repository.exists(&request.project_id, &request.template_id);
assert!(result.is_ok());
assert!(!result.unwrap());
// 创建绑定
repository.create(request.clone()).unwrap();
// 绑定存在时
let result = repository.exists(&request.project_id, &request.template_id);
assert!(result.is_ok());
assert!(result.unwrap());
}
#[test]
fn test_unique_constraint() {
let conn = create_test_database();
let repository = ProjectTemplateBindingRepository::new(conn);
let request = create_test_binding_request();
// 第一次创建应该成功
let result1 = repository.create(request.clone());
assert!(result1.is_ok());
// 第二次创建相同的项目-模板绑定应该失败
let result2 = repository.create(request);
assert!(result2.is_err());
}
#[tokio::test]
async fn test_business_service_create_binding() {
let conn = create_test_database();
let service = ProjectTemplateBindingService::new(conn).unwrap();
let request = create_test_binding_request();
let result = service.create_binding(request.clone()).await;
assert!(result.is_ok());
let binding = result.unwrap();
assert_eq!(binding.project_id, request.project_id);
assert_eq!(binding.template_id, request.template_id);
}
#[tokio::test]
async fn test_business_service_prevent_duplicate_primary() {
let conn = create_test_database();
let service = ProjectTemplateBindingService::new(conn).unwrap();
let project_id = Uuid::new_v4().to_string();
// 创建第一个主要绑定
let request1 = CreateProjectTemplateBindingRequest {
project_id: project_id.clone(),
template_id: Uuid::new_v4().to_string(),
binding_name: Some("第一个主要绑定".to_string()),
description: None,
priority: Some(0),
binding_type: BindingType::Primary,
};
let result1 = service.create_binding(request1).await;
assert!(result1.is_ok());
// 尝试创建第二个主要绑定应该失败
let request2 = CreateProjectTemplateBindingRequest {
project_id: project_id.clone(),
template_id: Uuid::new_v4().to_string(),
binding_name: Some("第二个主要绑定".to_string()),
description: None,
priority: Some(0),
binding_type: BindingType::Primary,
};
let result2 = service.create_binding(request2).await;
assert!(result2.is_err());
}
#[test]
fn test_binding_validation() {
let mut binding = ProjectTemplateBinding::new(
"project_id".to_string(),
"template_id".to_string(),
BindingType::Primary,
);
// 有效的绑定应该通过验证
assert!(binding.validate().is_ok());
// 空的项目ID应该失败
binding.project_id = "".to_string();
assert!(binding.validate().is_err());
// 重置项目ID
binding.project_id = "project_id".to_string();
// 空的模板ID应该失败
binding.template_id = "".to_string();
assert!(binding.validate().is_err());
// 重置模板ID
binding.template_id = "template_id".to_string();
// 过长的绑定名称应该失败
binding.binding_name = Some("a".repeat(101));
assert!(binding.validate().is_err());
// 重置绑定名称
binding.binding_name = Some("正常名称".to_string());
// 过长的描述应该失败
binding.description = Some("a".repeat(501));
assert!(binding.validate().is_err());
}
}

View File

@@ -6,12 +6,15 @@ import { ProjectDetails } from './pages/ProjectDetails';
import Models from './pages/Models';
import AiClassificationSettings from './pages/AiClassificationSettings';
import TemplateManagement from './pages/TemplateManagement';
import { MaterialModelBinding } from './pages/MaterialModelBinding';
import Navigation from './components/Navigation';
import { NotificationSystem, useNotifications } from './components/NotificationSystem';
import { useProjectStore } from './store/projectStore';
import { useUIStore } from './store/uiStore';
import { CreateProjectRequest, UpdateProjectRequest } from './types/project';
import "./App.css";
import './styles/design-system.css';
import './styles/animations.css';
/**
* 主应用组件
* 遵循 Tauri 开发规范的应用架构设计
@@ -26,14 +29,19 @@ function App() {
closeEditProjectModal
} = useUIStore();
// 通知系统
const { notifications, removeNotification, success, error } = useNotifications();
// 处理创建项目
const handleCreateProject = async (data: CreateProjectRequest) => {
try {
await createProject(data);
success('项目创建成功', `项目"${data.name}"已创建`);
closeCreateProjectModal();
} catch (error) {
console.error('创建项目失败:', error);
throw error;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '未知错误';
error('项目创建失败', errorMessage);
throw err;
}
};
@@ -43,10 +51,12 @@ function App() {
try {
await updateProject(editingProject.id, data);
success('项目更新成功', `项目"${data.name}"已更新`);
closeEditProjectModal();
} catch (error) {
console.error('更新项目失败:', error);
throw error;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : '未知错误';
error('项目更新失败', errorMessage);
throw err;
}
};
@@ -64,6 +74,7 @@ function App() {
<Route path="/models" element={<Models />} />
<Route path="/ai-classification-settings" element={<AiClassificationSettings />} />
<Route path="/templates" element={<TemplateManagement />} />
<Route path="/material-model-binding" element={<MaterialModelBinding />} />
</Routes>
</main>
@@ -84,6 +95,14 @@ function App() {
isEdit={true}
/>
)}
{/* 通知系统 */}
<NotificationSystem
notifications={notifications}
onRemove={removeNotification}
position="top-right"
maxNotifications={5}
/>
</div>
</Router>
);

View File

@@ -2,18 +2,20 @@ import { ChevronDownIcon } from "lucide-react";
// 自定义下拉选择组件
export const CustomSelect: React.FC<{
value: string;
value: string | any;
onChange: (value: string) => void;
options: { value: string; label: string }[];
options: { value: string | any; label: string; description?: string }[];
placeholder?: string;
className?: string;
}> = ({ value, onChange, options, placeholder, className = '' }) => {
disabled?: boolean;
}> = ({ value, onChange, options, placeholder, className = '', disabled = false }) => {
return (
<div className={`relative ${className}`}>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full appearance-none bg-white border border-gray-200 rounded-lg px-3 py-2 pr-8 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200 hover:border-gray-300 cursor-pointer"
disabled={disabled}
className={`w-full appearance-none bg-white border border-gray-200 rounded-lg px-3 py-2 pr-8 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200 hover:border-gray-300 cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{placeholder && <option value="">{placeholder}</option>}
{options.map((option) => (

View File

@@ -0,0 +1,315 @@
/**
* 增强的加载状态组件
* 提供更丰富的加载体验遵循UI/UX设计规范
*/
import React from 'react';
import { Loader2, RefreshCw, Download, Upload, Search, Zap, AlertCircle } from 'lucide-react';
export type LoadingType = 'default' | 'refresh' | 'download' | 'upload' | 'search' | 'processing';
interface EnhancedLoadingStateProps {
type?: LoadingType;
size?: 'small' | 'medium' | 'large';
message?: string;
progress?: number;
showProgress?: boolean;
className?: string;
overlay?: boolean;
children?: React.ReactNode;
}
export const EnhancedLoadingState: React.FC<EnhancedLoadingStateProps> = ({
type = 'default',
size = 'medium',
message,
progress,
showProgress = false,
className = '',
overlay = false,
children,
}) => {
const getIcon = () => {
switch (type) {
case 'refresh':
return <RefreshCw className="animate-spin" />;
case 'download':
return <Download className="animate-bounce" />;
case 'upload':
return <Upload className="animate-bounce" />;
case 'search':
return <Search className="animate-pulse" />;
case 'processing':
return <Zap className="animate-pulse" />;
default:
return <Loader2 className="animate-spin" />;
}
};
const getSizeClasses = () => {
switch (size) {
case 'small':
return 'w-4 h-4';
case 'large':
return 'w-8 h-8';
default:
return 'w-6 h-6';
}
};
const getContainerClasses = () => {
const baseClasses = 'flex flex-col items-center justify-center space-y-3';
const sizeClasses = size === 'small' ? 'p-2' : size === 'large' ? 'p-8' : 'p-4';
if (overlay) {
return `${baseClasses} ${sizeClasses} absolute inset-0 bg-white bg-opacity-90 backdrop-blur-sm z-10`;
}
return `${baseClasses} ${sizeClasses} ${className}`;
};
const getMessageClasses = () => {
switch (size) {
case 'small':
return 'text-xs text-gray-600';
case 'large':
return 'text-lg text-gray-700 font-medium';
default:
return 'text-sm text-gray-600';
}
};
const getTypeMessage = () => {
if (message) return message;
switch (type) {
case 'refresh':
return '刷新中...';
case 'download':
return '下载中...';
case 'upload':
return '上传中...';
case 'search':
return '搜索中...';
case 'processing':
return '处理中...';
default:
return '加载中...';
}
};
const content = (
<div className={getContainerClasses()}>
{/* 加载图标 */}
<div className={`text-blue-500 ${getSizeClasses()}`}>
{getIcon()}
</div>
{/* 加载消息 */}
<div className={getMessageClasses()}>
{getTypeMessage()}
</div>
{/* 进度条 */}
{showProgress && typeof progress === 'number' && (
<div className="w-full max-w-xs">
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span></span>
<span>{Math.round(progress)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-300 ease-out progress-bar"
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
/>
</div>
</div>
)}
{/* 自定义内容 */}
{children}
</div>
);
if (overlay) {
return (
<div className="relative">
{content}
</div>
);
}
return content;
};
// 骨架屏加载组件
interface SkeletonProps {
className?: string;
width?: string | number;
height?: string | number;
rounded?: boolean;
animate?: boolean;
}
export const Skeleton: React.FC<SkeletonProps> = ({
className = '',
width = '100%',
height = '1rem',
rounded = false,
animate = true,
}) => {
const baseClasses = 'bg-gray-200';
const animateClasses = animate ? 'loading-shimmer' : '';
const roundedClasses = rounded ? 'rounded-full' : 'rounded';
const style = {
width: typeof width === 'number' ? `${width}px` : width,
height: typeof height === 'number' ? `${height}px` : height,
};
return (
<div
className={`${baseClasses} ${animateClasses} ${roundedClasses} ${className}`}
style={style}
/>
);
};
// 卡片骨架屏
export const CardSkeleton: React.FC<{ className?: string }> = ({ className = '' }) => (
<div className={`p-4 border border-gray-200 rounded-lg ${className}`}>
<div className="space-y-3">
<div className="flex items-center space-x-3">
<Skeleton width={40} height={40} rounded />
<div className="flex-1 space-y-2">
<Skeleton height={16} width="60%" />
<Skeleton height={12} width="40%" />
</div>
</div>
<Skeleton height={12} />
<Skeleton height={12} width="80%" />
<div className="flex space-x-2">
<Skeleton height={32} width={80} rounded />
<Skeleton height={32} width={80} rounded />
</div>
</div>
</div>
);
// 列表骨架屏
export const ListSkeleton: React.FC<{
items?: number;
className?: string;
}> = ({
items = 3,
className = ''
}) => (
<div className={`space-y-3 ${className}`}>
{Array.from({ length: items }).map((_, index) => (
<div key={index} className="flex items-center space-x-3 p-3 border border-gray-200 rounded-lg">
<Skeleton width={48} height={48} rounded />
<div className="flex-1 space-y-2">
<Skeleton height={16} width="70%" />
<Skeleton height={12} width="50%" />
</div>
<Skeleton width={80} height={32} rounded />
</div>
))}
</div>
);
// 表格骨架屏
export const TableSkeleton: React.FC<{
rows?: number;
columns?: number;
className?: string;
}> = ({
rows = 5,
columns = 4,
className = ''
}) => (
<div className={`space-y-2 ${className}`}>
{/* 表头 */}
<div className="grid gap-4 p-3 bg-gray-50 rounded-lg" style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
{Array.from({ length: columns }).map((_, index) => (
<Skeleton key={index} height={16} width="80%" />
))}
</div>
{/* 表格行 */}
{Array.from({ length: rows }).map((_, rowIndex) => (
<div key={rowIndex} className="grid gap-4 p-3 border border-gray-200 rounded-lg" style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
{Array.from({ length: columns }).map((_, colIndex) => (
<Skeleton key={colIndex} height={14} width={colIndex === 0 ? "90%" : "70%"} />
))}
</div>
))}
</div>
);
// 加载状态容器
interface LoadingContainerProps {
loading: boolean;
error?: string | null;
empty?: boolean;
emptyMessage?: string;
loadingComponent?: React.ReactNode;
errorComponent?: React.ReactNode;
emptyComponent?: React.ReactNode;
children: React.ReactNode;
className?: string;
}
export const LoadingContainer: React.FC<LoadingContainerProps> = ({
loading,
error,
empty = false,
emptyMessage = '暂无数据',
loadingComponent,
errorComponent,
emptyComponent,
children,
className = '',
}) => {
if (loading) {
return (
<div className={className}>
{loadingComponent || <EnhancedLoadingState />}
</div>
);
}
if (error) {
return (
<div className={className}>
{errorComponent || (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="text-red-500 mb-2">
<AlertCircle className="w-8 h-8" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-1"></h3>
<p className="text-sm text-gray-600">{error}</p>
</div>
)}
</div>
);
}
if (empty) {
return (
<div className={className}>
{emptyComponent || (
<div className="flex flex-col items-center justify-center p-8 text-center">
<div className="text-gray-400 mb-2">
<Search className="w-8 h-8" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-1"></h3>
<p className="text-sm text-gray-600">{emptyMessage}</p>
</div>
)}
</div>
);
}
return <div className={className}>{children}</div>;
};

View File

@@ -1,14 +1,17 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import {
FileVideo, FileAudio, FileImage, File, Clock, ExternalLink, ChevronDown, ChevronUp,
Monitor, Volume2, Palette, Calendar, Hash, Zap, HardDrive, Film, Eye, Brain, Loader2
Monitor, Volume2, Palette, Calendar, Hash, Zap, HardDrive, Film, Eye, Brain, Loader2, User, Edit2
} from 'lucide-react';
import { Material, MaterialSegment } from '../types/material';
import { useMaterialStore } from '../store/materialStore';
import { useVideoClassificationStore } from '../store/videoClassificationStore';
import { Model } from '../types/model';
import { invoke } from '@tauri-apps/api/core';
interface MaterialCardProps {
material: Material;
onEdit?: (material: Material) => void;
}
// 格式化时间(秒转为 mm:ss 格式)
@@ -57,13 +60,15 @@ const formatDate = (dateString: string): string => {
* 素材卡片组件
* 显示素材信息和切分片段
*/
export const MaterialCard: React.FC<MaterialCardProps> = ({ material }) => {
export const MaterialCard: React.FC<MaterialCardProps> = ({ material, onEdit }) => {
const { getMaterialSegments } = useMaterialStore();
const { startClassification, isLoading: classificationLoading } = useVideoClassificationStore();
const [segments, setSegments] = useState<MaterialSegment[]>([]);
const [showSegments, setShowSegments] = useState(false);
const [loadingSegments, setLoadingSegments] = useState(false);
const [isClassifying, setIsClassifying] = useState(false);
const [associatedModel, setAssociatedModel] = useState<Model | null>(null);
const [loadingModel, setLoadingModel] = useState(false);
// 获取素材类型图标
const getTypeIcon = (type: string) => {
@@ -95,7 +100,28 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({ material }) => {
}
};
// 获取关联的模特信息
useEffect(() => {
const fetchAssociatedModel = async () => {
if (!material.model_id) {
setAssociatedModel(null);
return;
}
setLoadingModel(true);
try {
const model = await invoke<Model>('get_model', { id: material.model_id });
setAssociatedModel(model);
} catch (error) {
console.error('获取关联模特失败:', error);
setAssociatedModel(null);
} finally {
setLoadingModel(false);
}
};
fetchAssociatedModel();
}, [material.model_id]);
// 加载切分片段
const loadSegments = async () => {
@@ -191,9 +217,20 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({ material }) => {
{getTypeIcon(material.material_type)}
<h4 className="font-medium text-gray-900 truncate">{material.name}</h4>
</div>
<span className={`px-2 py-1 text-xs rounded-full ${getStatusColor(material.processing_status)}`}>
{material.processing_status}
</span>
<div className="flex items-center space-x-2">
{onEdit && (
<button
onClick={() => onEdit(material)}
className="text-gray-400 hover:text-blue-500 transition-colors"
title="编辑素材"
>
<Edit2 className="w-4 h-4" />
</button>
)}
<span className={`px-2 py-1 text-xs rounded-full ${getStatusColor(material.processing_status)}`}>
{material.processing_status}
</span>
</div>
</div>
{/* 素材详细信息 */}
@@ -210,6 +247,34 @@ export const MaterialCard: React.FC<MaterialCardProps> = ({ material }) => {
</div>
</div>
{/* 关联模特信息 */}
{material.model_id && (
<div className="bg-purple-50 rounded-lg p-3">
<div className="flex items-center space-x-2">
<User className="w-4 h-4 text-purple-600" />
<span className="text-sm font-medium text-purple-700"></span>
</div>
{loadingModel ? (
<div className="flex items-center space-x-2 mt-2">
<Loader2 className="w-3 h-3 animate-spin text-purple-600" />
<span className="text-xs text-purple-600">...</span>
</div>
) : associatedModel ? (
<div className="mt-2 space-y-1">
<p className="text-sm font-medium text-gray-900">{associatedModel.name}</p>
{associatedModel.stage_name && (
<p className="text-xs text-gray-600">: {associatedModel.stage_name}</p>
)}
{associatedModel.description && (
<p className="text-xs text-gray-600">{associatedModel.description}</p>
)}
</div>
) : (
<p className="text-xs text-red-600 mt-2"></p>
)}
</div>
)}
{/* 元数据信息 */}
{material.metadata !== 'None' && (
<div className="space-y-2">

View File

@@ -0,0 +1,208 @@
/**
* 素材编辑对话框组件
* 用于编辑素材信息,包括模特绑定
*/
import React, { useState, useEffect } from 'react';
import { X, Save, User } from 'lucide-react';
import { Material } from '../types/material';
import { Model } from '../types/model';
import { CustomSelect } from './CustomSelect';
import { LoadingSpinner } from './LoadingSpinner';
import { ErrorMessage } from './ErrorMessage';
import { invoke } from '@tauri-apps/api/core';
interface MaterialEditDialogProps {
isOpen: boolean;
onClose: () => void;
material: Material | null;
onSave: (materialId: string, updates: Partial<Material>) => Promise<void>;
}
export const MaterialEditDialog: React.FC<MaterialEditDialogProps> = ({
isOpen,
onClose,
material,
onSave,
}) => {
const [models, setModels] = useState<Model[]>([]);
const [selectedModelId, setSelectedModelId] = useState<string>('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// 加载模特列表
useEffect(() => {
if (isOpen) {
loadModels();
}
}, [isOpen]);
// 初始化选中的模特
useEffect(() => {
if (material) {
setSelectedModelId(material.model_id || '');
}
}, [material]);
const loadModels = async () => {
setLoading(true);
setError(null);
try {
const modelList = await invoke<Model[]>('list_models');
setModels(modelList);
} catch (error) {
console.error('加载模特列表失败:', error);
setError('加载模特列表失败');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (!material) return;
setSaving(true);
setError(null);
try {
const updates: Partial<Material> = {
model_id: selectedModelId || undefined,
};
await onSave(material.id, updates);
onClose();
} catch (error) {
console.error('保存素材失败:', error);
setError(error instanceof Error ? error.message : '保存失败');
} finally {
setSaving(false);
}
};
const getModelOptions = () => {
const options = [
{ value: '', label: '无关联模特' },
...models.map(model => ({
value: model.id,
label: model.stage_name || model.name,
description: model.description,
})),
];
return options;
};
const getSelectedModel = () => {
return models.find(m => m.id === selectedModelId);
};
if (!isOpen || !material) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
{/* 头部 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900"></h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
disabled={saving}
>
<X className="w-6 h-6" />
</button>
</div>
{/* 内容 */}
<div className="p-6 space-y-6">
{/* 错误信息 */}
{error && <ErrorMessage message={error} />}
{/* 素材信息 */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-700"></h3>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-sm font-medium text-gray-900">{material.name}</p>
<p className="text-xs text-gray-600 mt-1">
: {material.material_type} | : {material.processing_status}
</p>
</div>
</div>
{/* 模特绑定 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
<div className="flex items-center space-x-2">
<User className="w-4 h-4" />
<span></span>
</div>
</label>
{loading ? (
<div className="flex items-center justify-center py-4">
<LoadingSpinner size="small" />
<span className="ml-2 text-sm text-gray-600">...</span>
</div>
) : (
<>
<CustomSelect
value={selectedModelId}
onChange={setSelectedModelId}
options={getModelOptions()}
placeholder="选择关联的模特"
disabled={saving}
className="w-full"
/>
{selectedModelId && (
<div className="text-sm text-gray-600">
{getSelectedModel()?.description && (
<p className="mt-1">{getSelectedModel()?.description}</p>
)}
</div>
)}
</>
)}
</div>
{/* 说明 */}
<div className="bg-blue-50 rounded-lg p-3">
<p className="text-xs text-blue-700">
<strong>:</strong>
"无关联模特"
</p>
</div>
</div>
{/* 操作按钮 */}
<div className="flex justify-end space-x-3 p-6 border-t border-gray-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
disabled={saving}
>
</button>
<button
type="button"
onClick={handleSave}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
disabled={saving || loading}
>
{saving ? (
<>
<LoadingSpinner size="small" />
<span>...</span>
</>
) : (
<>
<Save className="w-4 h-4" />
<span></span>
</>
)}
</button>
</div>
</div>
</div>
);
};

View File

@@ -3,11 +3,9 @@ import { Link, useLocation } from 'react-router-dom';
import {
FolderIcon,
UserGroupIcon,
PhotoIcon,
ChartBarIcon,
CogIcon,
CpuChipIcon,
DocumentDuplicateIcon
DocumentDuplicateIcon,
LinkIcon
} from '@heroicons/react/24/outline';
const Navigation: React.FC = () => {
@@ -33,28 +31,16 @@ const Navigation: React.FC = () => {
description: '管理剪映模板'
},
{
name: '素材',
href: '/materials',
icon: PhotoIcon,
description: '浏览所有素材'
name: '素材绑定',
href: '/material-model-binding',
icon: LinkIcon,
description: '管理素材与模特的绑定关系'
},
{
name: 'AI分类设置',
href: '/ai-classification-settings',
icon: CpuChipIcon,
description: '管理AI视频分类规则'
},
{
name: '统计分析',
href: '/analytics',
icon: ChartBarIcon,
description: '查看数据统计'
},
{
name: '设置',
href: '/settings',
icon: CogIcon,
description: '应用设置'
}
];

View File

@@ -0,0 +1,304 @@
/**
* 通知系统组件
* 提供统一的用户反馈机制遵循UI/UX设计规范
*/
import React, { useState, useEffect, useCallback } from 'react';
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
export type NotificationType = 'success' | 'error' | 'warning' | 'info';
export interface Notification {
id: string;
type: NotificationType;
title: string;
message?: string;
duration?: number;
persistent?: boolean;
action?: {
label: string;
onClick: () => void;
};
}
interface NotificationSystemProps {
notifications: Notification[];
onRemove: (id: string) => void;
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center';
maxNotifications?: number;
}
export const NotificationSystem: React.FC<NotificationSystemProps> = ({
notifications,
onRemove,
position = 'top-right',
maxNotifications = 5,
}) => {
const [visibleNotifications, setVisibleNotifications] = useState<string[]>([]);
// 显示通知动画
useEffect(() => {
notifications.forEach((notification) => {
if (!visibleNotifications.includes(notification.id)) {
setTimeout(() => {
setVisibleNotifications(prev => [...prev, notification.id]);
}, 100);
}
});
}, [notifications, visibleNotifications]);
// 自动移除通知
useEffect(() => {
notifications.forEach((notification) => {
if (!notification.persistent && notification.duration !== 0) {
const duration = notification.duration || 5000;
const timer = setTimeout(() => {
handleRemove(notification.id);
}, duration);
return () => clearTimeout(timer);
}
});
}, [notifications]);
const handleRemove = useCallback((id: string) => {
setVisibleNotifications(prev => prev.filter(nId => nId !== id));
setTimeout(() => {
onRemove(id);
}, 300); // 等待动画完成
}, [onRemove]);
const getIcon = (type: NotificationType) => {
switch (type) {
case 'success':
return <CheckCircle className="w-5 h-5 text-green-500" />;
case 'error':
return <AlertCircle className="w-5 h-5 text-red-500" />;
case 'warning':
return <AlertTriangle className="w-5 h-5 text-yellow-500" />;
case 'info':
return <Info className="w-5 h-5 text-blue-500" />;
default:
return <Info className="w-5 h-5 text-gray-500" />;
}
};
const getTypeStyles = (type: NotificationType) => {
switch (type) {
case 'success':
return 'bg-green-50 border-green-200 text-green-800';
case 'error':
return 'bg-red-50 border-red-200 text-red-800';
case 'warning':
return 'bg-yellow-50 border-yellow-200 text-yellow-800';
case 'info':
return 'bg-blue-50 border-blue-200 text-blue-800';
default:
return 'bg-gray-50 border-gray-200 text-gray-800';
}
};
const getPositionStyles = () => {
switch (position) {
case 'top-right':
return 'top-4 right-4';
case 'top-left':
return 'top-4 left-4';
case 'bottom-right':
return 'bottom-4 right-4';
case 'bottom-left':
return 'bottom-4 left-4';
case 'top-center':
return 'top-4 left-1/2 transform -translate-x-1/2';
case 'bottom-center':
return 'bottom-4 left-1/2 transform -translate-x-1/2';
default:
return 'top-4 right-4';
}
};
const displayedNotifications = notifications.slice(0, maxNotifications);
if (displayedNotifications.length === 0) {
return null;
}
return (
<div className={`fixed z-50 ${getPositionStyles()}`}>
<div className="space-y-2 w-80">
{displayedNotifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
isVisible={visibleNotifications.includes(notification.id)}
onRemove={() => handleRemove(notification.id)}
getIcon={getIcon}
getTypeStyles={getTypeStyles}
/>
))}
</div>
</div>
);
};
interface NotificationItemProps {
notification: Notification;
isVisible: boolean;
onRemove: () => void;
getIcon: (type: NotificationType) => React.ReactNode;
getTypeStyles: (type: NotificationType) => string;
}
const NotificationItem: React.FC<NotificationItemProps> = ({
notification,
isVisible,
onRemove,
getIcon,
getTypeStyles,
}) => {
const [isRemoving, setIsRemoving] = useState(false);
const handleRemove = () => {
setIsRemoving(true);
setTimeout(onRemove, 300);
};
return (
<div
className={`
transform transition-all duration-300 ease-out
${isVisible && !isRemoving
? 'translate-x-0 opacity-100 scale-100'
: 'translate-x-full opacity-0 scale-95'
}
`}
>
<div
className={`
relative rounded-lg border shadow-lg p-4
${getTypeStyles(notification.type)}
hover:shadow-xl transition-shadow duration-200
`}
>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
{getIcon(notification.type)}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium">{notification.title}</h4>
{notification.message && (
<p className="mt-1 text-sm opacity-90">{notification.message}</p>
)}
{notification.action && (
<div className="mt-3">
<button
onClick={notification.action.onClick}
className="text-sm font-medium underline hover:no-underline transition-all duration-200"
>
{notification.action.label}
</button>
</div>
)}
</div>
<button
onClick={handleRemove}
className="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors duration-200"
>
<X className="w-4 h-4" />
</button>
</div>
{/* 进度条(如果有持续时间) */}
{!notification.persistent && notification.duration && notification.duration > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black bg-opacity-10 rounded-b-lg overflow-hidden">
<div
className="h-full bg-current opacity-30 animate-progress-bar"
style={{
animation: `progressBar ${notification.duration}ms linear forwards`,
}}
/>
</div>
)}
</div>
</div>
);
};
// 通知管理 Hook
export const useNotifications = () => {
const [notifications, setNotifications] = useState<Notification[]>([]);
const addNotification = useCallback((notification: Omit<Notification, 'id'>) => {
const id = Math.random().toString(36).substr(2, 9);
const newNotification: Notification = {
id,
duration: 5000,
...notification,
};
setNotifications(prev => [...prev, newNotification]);
return id;
}, []);
const removeNotification = useCallback((id: string) => {
setNotifications(prev => prev.filter(n => n.id !== id));
}, []);
const clearAll = useCallback(() => {
setNotifications([]);
}, []);
// 便捷方法
const success = useCallback((title: string, message?: string, options?: Partial<Notification>) => {
return addNotification({ type: 'success', title, message, ...options });
}, [addNotification]);
const error = useCallback((title: string, message?: string, options?: Partial<Notification>) => {
return addNotification({ type: 'error', title, message, persistent: true, ...options });
}, [addNotification]);
const warning = useCallback((title: string, message?: string, options?: Partial<Notification>) => {
return addNotification({ type: 'warning', title, message, ...options });
}, [addNotification]);
const info = useCallback((title: string, message?: string, options?: Partial<Notification>) => {
return addNotification({ type: 'info', title, message, ...options });
}, [addNotification]);
return {
notifications,
addNotification,
removeNotification,
clearAll,
success,
error,
warning,
info,
};
};
// CSS 动画样式
const styles = `
@keyframes progressBar {
from {
width: 100%;
}
to {
width: 0%;
}
}
.animate-progress-bar {
animation: progressBar var(--duration) linear forwards;
}
`;
// 注入样式
if (typeof document !== 'undefined') {
const styleElement = document.createElement('style');
styleElement.textContent = styles;
document.head.appendChild(styleElement);
}

View File

@@ -0,0 +1,303 @@
/**
* 项目-模板绑定表单组件
* 遵循前端开发规范的组件设计原则
*/
import React, { useState, useEffect } from 'react';
import { X, Save } from 'lucide-react';
import {
ProjectTemplateBinding,
CreateProjectTemplateBindingRequest,
UpdateProjectTemplateBindingRequest,
BindingType,
BINDING_TYPE_OPTIONS,
validateBindingFormData,
ProjectTemplateBindingFormData,
} from '../types/projectTemplateBinding';
import { Template } from '../types/template';
import { CustomSelect } from './CustomSelect';
import { LoadingSpinner } from './LoadingSpinner';
import { ErrorMessage } from './ErrorMessage';
interface ProjectTemplateBindingFormProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: CreateProjectTemplateBindingRequest | UpdateProjectTemplateBindingRequest) => Promise<void>;
projectId: string;
templates: Template[];
binding?: ProjectTemplateBinding;
loading?: boolean;
error?: string | null;
}
export const ProjectTemplateBindingForm: React.FC<ProjectTemplateBindingFormProps> = ({
isOpen,
onClose,
onSubmit,
projectId,
templates,
binding,
loading = false,
error = null,
}) => {
const [formData, setFormData] = useState<ProjectTemplateBindingFormData>({
template_id: '',
binding_name: '',
description: '',
priority: 0,
binding_type: BindingType.Secondary,
});
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
// 初始化表单数据
useEffect(() => {
if (binding) {
setFormData({
template_id: binding.template_id,
binding_name: binding.binding_name || '',
description: binding.description || '',
priority: binding.priority,
binding_type: binding.binding_type,
});
} else {
setFormData({
template_id: '',
binding_name: '',
description: '',
priority: 0,
binding_type: BindingType.Secondary,
});
}
setValidationErrors([]);
}, [binding, isOpen]);
// 处理表单字段变化
const handleFieldChange = (field: keyof ProjectTemplateBindingFormData, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
// 清除相关的验证错误
setValidationErrors([]);
};
// 验证表单
const validateForm = (): boolean => {
const errors = validateBindingFormData(formData);
setValidationErrors(errors);
return errors.length === 0;
};
// 处理表单提交
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setIsSubmitting(true);
try {
if (binding) {
// 更新模式
const updateData: UpdateProjectTemplateBindingRequest = {
binding_name: formData.binding_name || undefined,
description: formData.description || undefined,
priority: formData.priority,
binding_type: formData.binding_type,
};
await onSubmit(updateData);
} else {
// 创建模式
const createData: CreateProjectTemplateBindingRequest = {
project_id: projectId,
template_id: formData.template_id,
binding_name: formData.binding_name || undefined,
description: formData.description || undefined,
priority: formData.priority,
binding_type: formData.binding_type,
};
await onSubmit(createData);
}
onClose();
} catch (error) {
// 错误由父组件处理
} finally {
setIsSubmitting(false);
}
};
// 获取可用的模板选项
const getTemplateOptions = () => {
return templates.map(template => ({
value: template.id,
label: template.name,
description: template.description,
}));
};
// 获取选中的模板信息
const getSelectedTemplate = () => {
return templates.find(t => t.id === formData.template_id);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
{/* 头部 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">
{binding ? '编辑模板绑定' : '添加模板绑定'}
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
disabled={isSubmitting}
>
<X className="w-6 h-6" />
</button>
</div>
{/* 表单内容 */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* 错误信息 */}
{(error || validationErrors.length > 0) && (
<div className="space-y-2">
{error && <ErrorMessage message={error} />}
{validationErrors.map((err, index) => (
<ErrorMessage key={index} message={err} />
))}
</div>
)}
{/* 模板选择 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
<span className="text-red-500">*</span>
</label>
<CustomSelect
value={formData.template_id}
onChange={(value) => handleFieldChange('template_id', value)}
options={getTemplateOptions()}
placeholder="请选择模板"
disabled={!!binding || isSubmitting}
className="w-full"
/>
{formData.template_id && (
<div className="text-sm text-gray-600">
{getSelectedTemplate()?.description && (
<p className="mt-1">{getSelectedTemplate()?.description}</p>
)}
</div>
)}
</div>
{/* 绑定名称 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
</label>
<input
type="text"
value={formData.binding_name}
onChange={(e) => handleFieldChange('binding_name', e.target.value)}
placeholder="为此绑定设置一个自定义名称(可选)"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={isSubmitting}
maxLength={100}
/>
<p className="text-xs text-gray-500">
{formData.binding_name.length}/100
</p>
</div>
{/* 绑定类型 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
<span className="text-red-500">*</span>
</label>
<CustomSelect
value={formData.binding_type}
onChange={(value) => handleFieldChange('binding_type', value as BindingType)}
options={BINDING_TYPE_OPTIONS.map(option => ({
value: option.value,
label: option.label,
description: option.description,
}))}
disabled={isSubmitting}
className="w-full"
/>
</div>
{/* 优先级 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
</label>
<input
type="number"
value={formData.priority}
onChange={(e) => handleFieldChange('priority', parseInt(e.target.value) || 0)}
min="0"
max="999"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={isSubmitting}
/>
<p className="text-xs text-gray-500">
0-999
</p>
</div>
{/* 描述 */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
</label>
<textarea
value={formData.description}
onChange={(e) => handleFieldChange('description', e.target.value)}
placeholder="描述此绑定的用途或备注信息(可选)"
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
disabled={isSubmitting}
maxLength={500}
/>
<p className="text-xs text-gray-500">
{formData.description.length}/500
</p>
</div>
{/* 操作按钮 */}
<div className="flex justify-end space-x-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
disabled={isSubmitting}
>
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
disabled={isSubmitting || loading}
>
{(isSubmitting || loading) ? (
<>
<LoadingSpinner size="small" />
<span>...</span>
</>
) : (
<>
<Save className="w-4 h-4" />
<span>{binding ? '更新绑定' : '创建绑定'}</span>
</>
)}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,462 @@
/**
* 项目-模板绑定列表组件
* 遵循前端开发规范的组件设计原则
*/
import React, { useState } from 'react';
import {
Plus,
Edit2,
Trash2,
Power,
PowerOff,
Star,
StarOff,
Search,
Filter,
CheckSquare,
Square
} from 'lucide-react';
import {
ProjectTemplateBindingDetail,
BindingType,
BindingStatus,
getBindingTypeDisplay,
getBindingStatusDisplay,
getBindingTypeColor,
getBindingStatusColor,
BINDING_TYPE_OPTIONS,
BINDING_STATUS_OPTIONS,
} from '../types/projectTemplateBinding';
import { CustomSelect } from './CustomSelect';
import { LoadingSpinner } from './LoadingSpinner';
import { EmptyState } from './EmptyState';
import { DeleteConfirmDialog } from './DeleteConfirmDialog';
import { useNotifications } from './NotificationSystem';
interface ProjectTemplateBindingListProps {
bindings: ProjectTemplateBindingDetail[];
loading?: boolean;
selectedIds?: string[];
onSelectionChange?: (ids: string[]) => void;
onAdd?: () => void;
onEdit?: (binding: ProjectTemplateBindingDetail) => void;
onDelete?: (id: string) => void;
onBatchDelete?: (ids: string[]) => void;
onToggleStatus?: (id: string) => void;
onSetPrimary?: (projectId: string, templateId: string) => void;
searchQuery?: string;
onSearchChange?: (query: string) => void;
typeFilter?: BindingType | '';
onTypeFilterChange?: (type: BindingType | '') => void;
statusFilter?: BindingStatus | '';
onStatusFilterChange?: (status: BindingStatus | '') => void;
activeFilter?: boolean | null;
onActiveFilterChange?: (active: boolean | null) => void;
}
export const ProjectTemplateBindingList: React.FC<ProjectTemplateBindingListProps> = ({
bindings,
loading = false,
selectedIds = [],
onSelectionChange,
onAdd,
onEdit,
onDelete,
onBatchDelete,
onToggleStatus,
onSetPrimary,
searchQuery = '',
onSearchChange,
typeFilter = '',
onTypeFilterChange,
statusFilter = '',
onStatusFilterChange,
activeFilter = null,
onActiveFilterChange,
}) => {
const { success, error } = useNotifications();
const [deleteConfirm, setDeleteConfirm] = useState<{
isOpen: boolean;
id?: string;
name?: string;
isBatch?: boolean;
}>({ isOpen: false });
// 处理全选/取消全选
const handleSelectAll = () => {
if (!onSelectionChange) return;
if (selectedIds.length === bindings.length) {
onSelectionChange([]);
} else {
onSelectionChange(bindings.map(b => b.binding.id));
}
};
// 处理单个选择
const handleSelectItem = (id: string) => {
if (!onSelectionChange) return;
if (selectedIds.includes(id)) {
onSelectionChange(selectedIds.filter(selectedId => selectedId !== id));
} else {
onSelectionChange([...selectedIds, id]);
}
};
// 处理删除确认
const handleDeleteConfirm = (id: string, name: string) => {
setDeleteConfirm({ isOpen: true, id, name });
};
// 处理批量删除确认
const handleBatchDeleteConfirm = () => {
setDeleteConfirm({ isOpen: true, isBatch: true });
};
// 执行删除
const executeDelete = () => {
try {
if (deleteConfirm.isBatch && onBatchDelete) {
onBatchDelete(selectedIds);
success('批量删除成功', `已删除 ${selectedIds.length} 个绑定`);
} else if (deleteConfirm.id && onDelete) {
onDelete(deleteConfirm.id);
success('删除成功', `绑定"${deleteConfirm.name}"已删除`);
}
} catch (err) {
error('删除失败', err instanceof Error ? err.message : '未知错误');
} finally {
setDeleteConfirm({ isOpen: false });
}
};
// 获取过滤选项
const getTypeFilterOptions = () => [
{ value: '', label: '全部类型' },
...BINDING_TYPE_OPTIONS.map(option => ({
value: option.value,
label: option.label,
})),
];
const getStatusFilterOptions = () => [
{ value: '', label: '全部状态' },
...BINDING_STATUS_OPTIONS.map(option => ({
value: option.value,
label: option.label,
})),
];
const getActiveFilterOptions = () => [
{ value: null, label: '全部' },
{ value: true, label: '已激活' },
{ value: false, label: '已停用' },
];
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<LoadingSpinner size="large" />
</div>
);
}
return (
<div className="space-y-6">
{/* 工具栏 */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
{/* 搜索和过滤 */}
<div className="flex flex-1 gap-3 items-center">
{/* 搜索框 */}
{onSearchChange && (
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="搜索绑定名称或描述..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
)}
{/* 过滤器 */}
<div className="flex gap-2 items-center">
<Filter className="w-4 h-4 text-gray-500" />
{onTypeFilterChange && (
<CustomSelect
value={typeFilter}
onChange={(value) => onTypeFilterChange(value as BindingType | '')}
options={getTypeFilterOptions()}
className="min-w-[120px]"
/>
)}
{onStatusFilterChange && (
<CustomSelect
value={statusFilter}
onChange={(value) => onStatusFilterChange(value as BindingStatus | '')}
options={getStatusFilterOptions()}
className="min-w-[120px]"
/>
)}
{onActiveFilterChange && (
<CustomSelect
value={activeFilter}
onChange={(value) => onActiveFilterChange(value === 'true' ? true : value === 'false' ? false : null)}
options={getActiveFilterOptions()}
className="min-w-[100px]"
/>
)}
</div>
</div>
{/* 操作按钮 */}
<div className="flex gap-2 items-center">
{selectedIds.length > 0 && onBatchDelete && (
<button
onClick={handleBatchDeleteConfirm}
className="px-3 py-2 text-sm font-medium text-red-700 bg-red-50 border border-red-200 rounded-md hover:bg-red-100 transition-colors flex items-center space-x-1"
>
<Trash2 className="w-4 h-4" />
<span> ({selectedIds.length})</span>
</button>
)}
{onAdd && (
<button
onClick={onAdd}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 transition-colors flex items-center space-x-2"
>
<Plus className="w-4 h-4" />
<span></span>
</button>
)}
</div>
</div>
{/* 绑定列表 */}
{bindings.length === 0 ? (
<EmptyState
title="暂无模板绑定"
description="还没有为此项目绑定任何模板,点击上方按钮开始添加。"
actionText="添加绑定"
onAction={onAdd || (() => {})}
/>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
{/* 表头 */}
<div className="bg-gray-50 px-6 py-3 border-b border-gray-200">
<div className="flex items-center">
{onSelectionChange && (
<div className="flex items-center mr-4">
<button
onClick={handleSelectAll}
className="text-gray-500 hover:text-gray-700"
>
{selectedIds.length === bindings.length ? (
<CheckSquare className="w-5 h-5" />
) : (
<Square className="w-5 h-5" />
)}
</button>
</div>
)}
<div className="flex-1 grid grid-cols-12 gap-4 text-xs font-medium text-gray-500 uppercase tracking-wider">
<div className="col-span-3"></div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
<div className="col-span-1"></div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
</div>
</div>
</div>
{/* 表体 */}
<div className="divide-y divide-gray-200">
{bindings.map((detail) => (
<BindingListItem
key={detail.binding.id}
detail={detail}
isSelected={selectedIds.includes(detail.binding.id)}
onSelect={() => handleSelectItem(detail.binding.id)}
onEdit={onEdit}
onDelete={(id, name) => handleDeleteConfirm(id, name)}
onToggleStatus={onToggleStatus}
onSetPrimary={onSetPrimary}
showSelection={!!onSelectionChange}
/>
))}
</div>
</div>
)}
{/* 删除确认对话框 */}
<DeleteConfirmDialog
isOpen={deleteConfirm.isOpen}
onCancel={() => setDeleteConfirm({ isOpen: false })}
onConfirm={executeDelete}
deleting={false}
title={deleteConfirm.isBatch ? '批量删除绑定' : '删除绑定'}
message={
deleteConfirm.isBatch
? `确定要删除选中的 ${selectedIds.length} 个绑定吗?此操作不可撤销。`
: `确定要删除绑定"${deleteConfirm.name}"吗?此操作不可撤销。`
}
/>
</div>
);
};
// 绑定列表项组件
interface BindingListItemProps {
detail: ProjectTemplateBindingDetail;
isSelected: boolean;
onSelect: () => void;
onEdit?: (binding: ProjectTemplateBindingDetail) => void;
onDelete?: (id: string, name: string) => void;
onToggleStatus?: (id: string) => void;
onSetPrimary?: (projectId: string, templateId: string) => void;
showSelection: boolean;
}
const BindingListItem: React.FC<BindingListItemProps> = ({
detail,
isSelected,
onSelect,
onEdit,
onDelete,
onToggleStatus,
onSetPrimary,
showSelection,
}) => {
const { binding } = detail;
const isPrimary = binding.binding_type === BindingType.Primary;
return (
<div className="px-6 py-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center">
{showSelection && (
<div className="flex items-center mr-4">
<button onClick={onSelect} className="text-gray-500 hover:text-gray-700">
{isSelected ? (
<CheckSquare className="w-5 h-5 text-blue-600" />
) : (
<Square className="w-5 h-5" />
)}
</button>
</div>
)}
<div className="flex-1 grid grid-cols-12 gap-4 items-center">
{/* 模板信息 */}
<div className="col-span-3">
<div className="flex items-center space-x-2">
{isPrimary && (
<Star className="w-4 h-4 text-yellow-500 fill-current" />
)}
<div>
<p className="text-sm font-medium text-gray-900">
{binding.binding_name || detail.template_name}
</p>
<p className="text-xs text-gray-500">{detail.template_name}</p>
</div>
</div>
</div>
{/* 绑定类型 */}
<div className="col-span-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getBindingTypeColor(binding.binding_type)}`}>
{getBindingTypeDisplay(binding.binding_type)}
</span>
</div>
{/* 状态 */}
<div className="col-span-2">
<div className="flex items-center space-x-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getBindingStatusColor(binding.binding_status)}`}>
{getBindingStatusDisplay(binding.binding_status)}
</span>
{binding.is_active ? (
<Power className="w-4 h-4 text-green-500" />
) : (
<PowerOff className="w-4 h-4 text-gray-400" />
)}
</div>
</div>
{/* 优先级 */}
<div className="col-span-1">
<span className="text-sm text-gray-900">{binding.priority}</span>
</div>
{/* 创建时间 */}
<div className="col-span-2">
<span className="text-sm text-gray-500">
{new Date(binding.created_at).toLocaleDateString()}
</span>
</div>
{/* 操作 */}
<div className="col-span-2">
<div className="flex items-center space-x-2">
{!isPrimary && onSetPrimary && (
<button
onClick={() => onSetPrimary(binding.project_id, binding.template_id)}
className="text-gray-400 hover:text-yellow-500 transition-colors"
title="设为主要模板"
>
<StarOff className="w-4 h-4" />
</button>
)}
{onToggleStatus && (
<button
onClick={() => onToggleStatus(binding.id)}
className={`transition-colors ${
binding.is_active
? 'text-gray-400 hover:text-red-500'
: 'text-gray-400 hover:text-green-500'
}`}
title={binding.is_active ? '停用绑定' : '激活绑定'}
>
{binding.is_active ? (
<PowerOff className="w-4 h-4" />
) : (
<Power className="w-4 h-4" />
)}
</button>
)}
{onEdit && (
<button
onClick={() => onEdit(detail)}
className="text-gray-400 hover:text-blue-500 transition-colors"
title="编辑绑定"
>
<Edit2 className="w-4 h-4" />
</button>
)}
{onDelete && (
<button
onClick={() => onDelete(binding.id, binding.binding_name || detail.template_name)}
className="text-gray-400 hover:text-red-500 transition-colors"
title="删除绑定"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,389 @@
/**
* 素材-模特绑定管理页面
* 遵循前端开发规范的页面设计原则
*/
import React, { useState, useEffect } from 'react';
import {
FileVideo,
Link,
Unlink,
Search,
Filter,
BarChart3,
ArrowLeft
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Material } from '../types/material';
import { Model } from '../types/model';
import { MaterialCard } from '../components/MaterialCard';
import { MaterialEditDialog } from '../components/MaterialEditDialog';
import { CustomSelect } from '../components/CustomSelect';
import { LoadingSpinner } from '../components/LoadingSpinner';
import { EmptyState } from '../components/EmptyState';
import { useNotifications } from '../components/NotificationSystem';
import {
MaterialModelBindingService,
MaterialModelBindingStats
} from '../services/materialModelBindingService';
export const MaterialModelBinding: React.FC = () => {
const navigate = useNavigate();
const { success, error } = useNotifications();
const [materials, setMaterials] = useState<Material[]>([]);
const [models, setModels] = useState<Model[]>([]);
const [stats] = useState<MaterialModelBindingStats | null>(null);
const [selectedModel, setSelectedModel] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [filterType, setFilterType] = useState<'all' | 'bound' | 'unbound'>('all');
const [loading, setLoading] = useState(true);
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([]);
const [showEditDialog, setShowEditDialog] = useState(false);
const [editingMaterial, setEditingMaterial] = useState<Material | null>(null);
const [showStats, setShowStats] = useState(false);
// 加载数据
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setLoading(true);
try {
await Promise.all([
loadMaterials(),
loadModels(),
loadStats(),
]);
} catch (err) {
error('加载数据失败', err instanceof Error ? err.message : '未知错误');
} finally {
setLoading(false);
}
};
const loadMaterials = async () => {
try {
const materialsData = await MaterialModelBindingService.getMaterialsByFilter({
modelId: filterType === 'bound' ? selectedModel : undefined,
bound: filterType === 'bound' ? true : filterType === 'unbound' ? false : undefined,
search: searchQuery,
});
setMaterials(materialsData);
} catch (err) {
console.error('加载素材失败:', err);
throw err;
}
};
const loadModels = async () => {
try {
const modelsData = await MaterialModelBindingService.getAllModels();
setModels(modelsData);
} catch (err) {
console.error('加载模特失败:', err);
throw err;
}
};
const loadStats = async () => {
try {
// 这里需要一个获取全局统计的API暂时跳过
// const statsData = await invoke<MaterialModelBindingStats>('get_global_model_binding_stats');
// setStats(statsData);
} catch (err) {
console.error('加载统计失败:', err);
}
};
// 处理绑定操作
const handleBatchBind = async () => {
if (!selectedModel || selectedMaterials.length === 0) {
error('请选择模特和素材', '请先选择要绑定的模特和素材');
return;
}
try {
await MaterialModelBindingService.batchBindMaterialsToModel(selectedMaterials, selectedModel);
success('批量绑定成功', `已将 ${selectedMaterials.length} 个素材绑定到模特`);
setSelectedMaterials([]);
await loadMaterials();
} catch (err) {
error('批量绑定失败', err instanceof Error ? err.message : '未知错误');
}
};
const handleBatchUnbind = async () => {
if (selectedMaterials.length === 0) {
error('请选择素材', '请先选择要解除绑定的素材');
return;
}
try {
await MaterialModelBindingService.batchUnbindMaterialsFromModel(selectedMaterials);
success('批量解绑成功', `已解除 ${selectedMaterials.length} 个素材的绑定`);
setSelectedMaterials([]);
await loadMaterials();
} catch (err) {
error('批量解绑失败', err instanceof Error ? err.message : '未知错误');
}
};
const handleEditMaterial = (material: Material) => {
setEditingMaterial(material);
setShowEditDialog(true);
};
const handleMaterialSave = async (materialId: string, updates: Partial<Material>) => {
try {
await MaterialModelBindingService.updateMaterial(materialId, {
model_id: updates.model_id,
name: updates.name,
});
await loadMaterials();
setShowEditDialog(false);
setEditingMaterial(null);
success('素材更新成功', '素材信息已更新');
} catch (err) {
error('素材更新失败', err instanceof Error ? err.message : '未知错误');
throw err;
}
};
// 过滤素材
const filteredMaterials = materials.filter(material => {
const matchesSearch = material.name.toLowerCase().includes(searchQuery.toLowerCase());
switch (filterType) {
case 'bound':
return matchesSearch && material.model_id;
case 'unbound':
return matchesSearch && !material.model_id;
default:
return matchesSearch;
}
});
// 获取模特选项
const getModelOptions = () => [
{ value: '', label: '选择模特' },
...models.map(model => ({
value: model.id,
label: model.stage_name || model.name,
})),
];
const getFilterOptions = () => [
{ value: 'all', label: '全部素材' },
{ value: 'bound', label: '已绑定' },
{ value: 'unbound', label: '未绑定' },
];
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<LoadingSpinner size="large" />
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* 头部 */}
<div className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center space-x-4">
<button
onClick={() => navigate(-1)}
className="text-gray-500 hover:text-gray-700 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex items-center space-x-2">
<Link className="w-6 h-6 text-blue-600" />
<h1 className="text-xl font-semibold text-gray-900">-</h1>
</div>
</div>
<div className="flex items-center space-x-3">
<button
onClick={() => setShowStats(!showStats)}
className="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors flex items-center space-x-2"
>
<BarChart3 className="w-4 h-4" />
<span></span>
</button>
</div>
</div>
</div>
</div>
{/* 统计面板 */}
{showStats && stats && (
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-blue-50 rounded-lg p-4">
<div className="flex items-center">
<FileVideo className="w-8 h-8 text-blue-600" />
<div className="ml-3">
<p className="text-sm font-medium text-blue-600"></p>
<p className="text-2xl font-bold text-blue-900">{stats.total_materials}</p>
</div>
</div>
</div>
<div className="bg-green-50 rounded-lg p-4">
<div className="flex items-center">
<Link className="w-8 h-8 text-green-600" />
<div className="ml-3">
<p className="text-sm font-medium text-green-600"></p>
<p className="text-2xl font-bold text-green-900">{stats.bound_materials}</p>
</div>
</div>
</div>
<div className="bg-yellow-50 rounded-lg p-4">
<div className="flex items-center">
<Unlink className="w-8 h-8 text-yellow-600" />
<div className="ml-3">
<p className="text-sm font-medium text-yellow-600"></p>
<p className="text-2xl font-bold text-yellow-900">{stats.unbound_materials}</p>
</div>
</div>
</div>
<div className="bg-purple-50 rounded-lg p-4">
<div className="flex items-center">
<BarChart3 className="w-8 h-8 text-purple-600" />
<div className="ml-3">
<p className="text-sm font-medium text-purple-600"></p>
<p className="text-2xl font-bold text-purple-900">{stats.binding_rate.toFixed(1)}%</p>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* 主内容 */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* 工具栏 */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center justify-between">
{/* 搜索和过滤 */}
<div className="flex flex-1 gap-3 items-center">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索素材名称..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="flex gap-2 items-center">
<Filter className="w-4 h-4 text-gray-500" />
<CustomSelect
value={filterType}
onChange={(value) => setFilterType(value as any)}
options={getFilterOptions()}
className="min-w-[120px]"
/>
</div>
</div>
{/* 批量操作 */}
<div className="flex gap-2 items-center">
<CustomSelect
value={selectedModel}
onChange={setSelectedModel}
options={getModelOptions()}
className="min-w-[150px]"
/>
{selectedMaterials.length > 0 && (
<>
<button
onClick={handleBatchBind}
disabled={!selectedModel}
className="px-3 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-1"
>
<Link className="w-4 h-4" />
<span> ({selectedMaterials.length})</span>
</button>
<button
onClick={handleBatchUnbind}
className="px-3 py-2 text-sm font-medium text-red-700 bg-red-50 border border-red-200 rounded-md hover:bg-red-100 transition-colors flex items-center space-x-1"
>
<Unlink className="w-4 h-4" />
<span> ({selectedMaterials.length})</span>
</button>
</>
)}
</div>
</div>
</div>
{/* 素材列表 */}
{filteredMaterials.length === 0 ? (
<EmptyState
title="暂无素材"
description="没有找到符合条件的素材"
actionText="刷新"
onAction={loadData}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{filteredMaterials.map((material) => (
<div key={material.id} className="relative">
<div className="absolute top-2 left-2 z-10">
<input
type="checkbox"
checked={selectedMaterials.includes(material.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedMaterials(prev => [...prev, material.id]);
} else {
setSelectedMaterials(prev => prev.filter(id => id !== material.id));
}
}}
className="w-4 h-4 text-blue-600 bg-white border-gray-300 rounded focus:ring-blue-500"
/>
</div>
<MaterialCard
material={material}
onEdit={handleEditMaterial}
/>
</div>
))}
</div>
)}
</div>
{/* 素材编辑对话框 */}
<MaterialEditDialog
isOpen={showEditDialog}
onClose={() => {
setShowEditDialog(false);
setEditingMaterial(null);
}}
material={editingMaterial}
onSave={handleMaterialSave}
/>
</div>
);
};

View File

@@ -1,19 +1,30 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, FolderOpen, Upload, FileVideo, FileAudio, FileImage, HardDrive, Brain, Loader2 } from 'lucide-react';
import { ArrowLeft, FolderOpen, Upload, FileVideo, FileAudio, FileImage, HardDrive, Brain, Loader2, Link } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { useProjectStore } from '../store/projectStore';
import { useMaterialStore } from '../store/materialStore';
import { useVideoClassificationStore } from '../store/videoClassificationStore';
import { Project } from '../types/project';
import { MaterialImportResult } from '../types/material';
import { Material, MaterialImportResult } from '../types/material';
import { ProjectBatchClassificationRequest, ProjectBatchClassificationResponse } from '../types/videoClassification';
import { LoadingSpinner } from '../components/LoadingSpinner';
import { ErrorMessage } from '../components/ErrorMessage';
import { MaterialImportDialog } from '../components/MaterialImportDialog';
import { MaterialCard } from '../components/MaterialCard';
import { MaterialEditDialog } from '../components/MaterialEditDialog';
import { VideoClassificationProgress } from '../components/VideoClassificationProgress';
import { AiAnalysisLogViewer } from '../components/AiAnalysisLogViewer';
import MaterialCardSkeleton from '../components/MaterialCardSkeleton';
import { ProjectTemplateBindingList } from '../components/ProjectTemplateBindingList';
import { ProjectTemplateBindingForm } from '../components/ProjectTemplateBindingForm';
import { useProjectTemplateBindingStore } from '../stores/projectTemplateBindingStore';
import { useTemplateStore } from '../stores/templateStore';
import {
ProjectTemplateBindingDetail,
CreateProjectTemplateBindingRequest,
UpdateProjectTemplateBindingRequest
} from '../types/projectTemplateBinding';
/**
* 项目详情页面组件
@@ -37,9 +48,27 @@ export const ProjectDetails: React.FC = () => {
queueStats,
getProjectQueueStatus
} = useVideoClassificationStore();
// 模板绑定状态管理
const {
bindingDetails,
loading: bindingLoading,
error: bindingError,
selectedBindingIds,
filters: bindingFilters,
actions: bindingActions
} = useProjectTemplateBindingStore();
// 模板状态管理
const { templates, fetchTemplates } = useTemplateStore();
const [project, setProject] = useState<Project | null>(null);
const [showImportDialog, setShowImportDialog] = useState(false);
const [activeTab, setActiveTab] = useState<'materials' | 'debug' | 'ai-logs'>('materials');
const [showBindingForm, setShowBindingForm] = useState(false);
const [editingBinding, setEditingBinding] = useState<ProjectTemplateBindingDetail | null>(null);
const [showMaterialEditDialog, setShowMaterialEditDialog] = useState(false);
const [editingMaterial, setEditingMaterial] = useState<Material | null>(null);
const [activeTab, setActiveTab] = useState<'materials' | 'templates' | 'debug' | 'ai-logs'>('materials');
const [_batchClassificationResult, setBatchClassificationResult] = useState<ProjectBatchClassificationResponse | null>(null);
// 加载项目详情
@@ -59,9 +88,16 @@ export const ProjectDetails: React.FC = () => {
if (foundProject) {
loadMaterials(foundProject.id);
loadMaterialStats(foundProject.id);
// 加载项目的模板绑定
bindingActions.fetchTemplatesByProject(foundProject.id);
}
}
}, [id, projects, loadMaterials, loadMaterialStats]);
}, [id, projects, loadMaterials, loadMaterialStats, bindingActions]);
// 加载模板列表
useEffect(() => {
fetchTemplates();
}, [fetchTemplates]);
// 监控AI分类队列状态
useEffect(() => {
@@ -168,6 +204,89 @@ export const ProjectDetails: React.FC = () => {
}
};
// 模板绑定处理函数
const handleAddBinding = () => {
setEditingBinding(null);
setShowBindingForm(true);
};
const handleEditBinding = (detail: ProjectTemplateBindingDetail) => {
setEditingBinding(detail);
setShowBindingForm(true);
};
const handleDeleteBinding = async (id: string) => {
try {
await bindingActions.deleteBinding(id);
if (project) {
bindingActions.fetchTemplatesByProject(project.id);
}
} catch (error) {
console.error('删除绑定失败:', error);
}
};
const handleToggleBindingStatus = async (id: string) => {
try {
await bindingActions.toggleBindingStatus(id);
if (project) {
bindingActions.fetchTemplatesByProject(project.id);
}
} catch (error) {
console.error('切换绑定状态失败:', error);
}
};
const handleSetPrimaryTemplate = async (projectId: string, templateId: string) => {
try {
await bindingActions.setPrimaryTemplate(projectId, templateId);
} catch (error) {
console.error('设置主要模板失败:', error);
}
};
const handleBindingFormSubmit = async (data: CreateProjectTemplateBindingRequest | UpdateProjectTemplateBindingRequest) => {
try {
if (editingBinding) {
await bindingActions.updateBinding(editingBinding.binding.id, data as UpdateProjectTemplateBindingRequest);
} else {
await bindingActions.createBinding(data as CreateProjectTemplateBindingRequest);
}
if (project) {
bindingActions.fetchTemplatesByProject(project.id);
}
setShowBindingForm(false);
setEditingBinding(null);
} catch (error) {
console.error('保存绑定失败:', error);
throw error;
}
};
// 素材编辑处理函数
const handleEditMaterial = (material: Material) => {
setEditingMaterial(material);
setShowMaterialEditDialog(true);
};
const handleMaterialSave = async (materialId: string, updates: Partial<Material>) => {
try {
// 调用后端API更新素材
await invoke('update_material', { id: materialId, updates });
// 重新加载素材列表
if (project) {
loadMaterials(project.id);
}
setShowMaterialEditDialog(false);
setEditingMaterial(null);
} catch (error) {
console.error('更新素材失败:', error);
throw error;
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
@@ -343,6 +462,20 @@ export const ProjectDetails: React.FC = () => {
<span className="sm:hidden"></span>
</div>
</button>
<button
onClick={() => setActiveTab('templates')}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors whitespace-nowrap ${
activeTab === 'templates'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<div className="flex items-center space-x-2">
<Link className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</div>
</button>
<button
onClick={() => setActiveTab('ai-logs')}
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors whitespace-nowrap ${
@@ -394,7 +527,7 @@ export const ProjectDetails: React.FC = () => {
) : materials.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3 md:gap-4">
{materials.map((material) => (
<MaterialCard key={material.id} material={material} />
<MaterialCard key={material.id} material={material} onEdit={handleEditMaterial} />
))}
</div>
) : (
@@ -418,6 +551,46 @@ export const ProjectDetails: React.FC = () => {
</div>
</div>
)}
{/* 模板绑定选项卡 */}
{activeTab === 'templates' && project && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium text-gray-900"></h3>
<p className="text-sm text-gray-600 mt-1">
</p>
</div>
</div>
{bindingError && (
<ErrorMessage message={bindingError} />
)}
<ProjectTemplateBindingList
bindings={bindingDetails}
loading={bindingLoading}
selectedIds={selectedBindingIds}
onSelectionChange={bindingActions.setSelectedBindingIds}
onAdd={handleAddBinding}
onEdit={handleEditBinding}
onDelete={handleDeleteBinding}
onBatchDelete={(ids) => bindingActions.batchDeleteBindings(ids)}
onToggleStatus={handleToggleBindingStatus}
onSetPrimary={handleSetPrimaryTemplate}
searchQuery={bindingFilters.search || ''}
onSearchChange={(query) => bindingActions.setFilters({ search: query })}
typeFilter={bindingFilters.binding_type || ''}
onTypeFilterChange={(type) => bindingActions.setFilters({ binding_type: type || undefined })}
statusFilter={bindingFilters.binding_status || ''}
onStatusFilterChange={(status) => bindingActions.setFilters({ binding_status: status || undefined })}
activeFilter={bindingFilters.is_active ?? null}
onActiveFilterChange={(active) => bindingActions.setFilters({ is_active: active ?? undefined })}
/>
</div>
)}
{/* AI分析日志选项卡 */}
{activeTab === 'ai-logs' && project && (
<div>
@@ -443,6 +616,34 @@ export const ProjectDetails: React.FC = () => {
onImportComplete={handleImportComplete}
/>
)}
{/* 模板绑定表单对话框 */}
{project && (
<ProjectTemplateBindingForm
isOpen={showBindingForm}
onClose={() => {
setShowBindingForm(false);
setEditingBinding(null);
}}
onSubmit={handleBindingFormSubmit}
projectId={project.id}
templates={templates}
binding={editingBinding?.binding}
loading={bindingLoading}
error={bindingError}
/>
)}
{/* 素材编辑对话框 */}
<MaterialEditDialog
isOpen={showMaterialEditDialog}
onClose={() => {
setShowMaterialEditDialog(false);
setEditingMaterial(null);
}}
material={editingMaterial}
onSave={handleMaterialSave}
/>
</div>
);
};

View File

@@ -0,0 +1,318 @@
/**
* 素材-模特绑定服务
* 遵循前端开发规范的服务层设计
*/
import { invoke } from '@tauri-apps/api/core';
import { Material } from '../types/material';
import { Model } from '../types/model';
export interface MaterialModelBindingStats {
total_materials: number;
bound_materials: number;
unbound_materials: number;
binding_rate: number;
}
export interface ModelMaterialStatistics {
total_materials: number;
video_count: number;
audio_count: number;
image_count: number;
total_size: number;
}
export interface MaterialUpdateRequest {
model_id?: string;
name?: string;
}
export class MaterialModelBindingService {
/**
* 绑定素材到模特
*/
static async bindMaterialToModel(materialId: string, modelId: string): Promise<void> {
await invoke('associate_material_to_model', {
materialId,
modelId,
});
}
/**
* 解除素材与模特的绑定
*/
static async unbindMaterialFromModel(materialId: string): Promise<void> {
await invoke('disassociate_material_from_model', {
materialId,
});
}
/**
* 批量绑定素材到模特
*/
static async batchBindMaterialsToModel(materialIds: string[], modelId: string): Promise<void> {
await invoke('batch_bind_materials_to_model', {
materialIds,
modelId,
});
}
/**
* 批量解除素材与模特的绑定
*/
static async batchUnbindMaterialsFromModel(materialIds: string[]): Promise<void> {
await invoke('batch_unbind_materials_from_model', {
materialIds,
});
}
/**
* 切换素材的模特绑定
*/
static async switchMaterialModel(materialId: string, newModelId: string): Promise<void> {
await invoke('switch_material_model', {
materialId,
newModelId,
});
}
/**
* 获取模特的所有素材
*/
static async getMaterialsByModel(modelId: string): Promise<Material[]> {
return await invoke<Material[]>('get_materials_by_model_id', {
modelId,
});
}
/**
* 获取未绑定模特的素材
*/
static async getUnboundMaterials(projectId?: string): Promise<Material[]> {
return await invoke<Material[]>('get_unassociated_materials', {
projectId: projectId || null,
});
}
/**
* 获取模特的素材统计信息
*/
static async getModelMaterialStatistics(modelId: string): Promise<ModelMaterialStatistics> {
return await invoke<ModelMaterialStatistics>('get_model_material_statistics', {
modelId,
});
}
/**
* 获取项目中模特绑定统计信息
*/
static async getProjectModelBindingStats(projectId: string): Promise<MaterialModelBindingStats> {
return await invoke<MaterialModelBindingStats>('get_project_model_binding_stats', {
projectId,
});
}
/**
* 更新素材信息
*/
static async updateMaterial(id: string, updates: MaterialUpdateRequest): Promise<void> {
await invoke('update_material', {
id,
updates,
});
}
/**
* 获取所有模特
*/
static async getAllModels(): Promise<Model[]> {
return await invoke<Model[]>('get_all_models');
}
/**
* 搜索模特
*/
static async searchModels(query: string): Promise<Model[]> {
return await invoke<Model[]>('search_models', {
query,
});
}
/**
* 获取项目的所有素材
*/
static async getProjectMaterials(projectId: string): Promise<Material[]> {
return await invoke<Material[]>('get_project_materials', {
projectId,
});
}
/**
* 根据过滤条件获取素材
*/
static async getMaterialsByFilter(filter: {
projectId?: string;
modelId?: string;
bound?: boolean;
search?: string;
}): Promise<Material[]> {
if (filter.modelId) {
return this.getMaterialsByModel(filter.modelId);
}
if (filter.bound === false) {
return this.getUnboundMaterials(filter.projectId);
}
if (filter.projectId) {
const materials = await this.getProjectMaterials(filter.projectId);
if (filter.bound === true) {
return materials.filter(m => m.model_id);
}
if (filter.search) {
return materials.filter(m =>
m.name.toLowerCase().includes(filter.search!.toLowerCase())
);
}
return materials;
}
// 如果没有指定项目,返回空数组
return [];
}
/**
* 验证模特是否存在
*/
static async validateModelExists(modelId: string): Promise<boolean> {
try {
const model = await invoke<Model | null>('get_model_by_id', { id: modelId });
return model !== null;
} catch {
return false;
}
}
/**
* 验证素材是否存在
*/
static async validateMaterialExists(materialId: string): Promise<boolean> {
try {
const material = await invoke<Material | null>('get_material_by_id', { id: materialId });
return material !== null;
} catch {
return false;
}
}
/**
* 获取绑定操作的建议
*/
static async getBindingSuggestions(_materialId: string): Promise<Model[]> {
try {
// 这里可以实现基于素材内容的模特推荐逻辑
// 暂时返回所有活跃的模特
const allModels = await this.getAllModels();
return allModels.filter(model => model.is_active);
} catch {
return [];
}
}
/**
* 批量操作验证
*/
static async validateBatchOperation(materialIds: string[], modelId?: string): Promise<{
validMaterials: string[];
invalidMaterials: string[];
modelValid: boolean;
}> {
const validMaterials: string[] = [];
const invalidMaterials: string[] = [];
let modelValid = true;
// 验证素材
for (const materialId of materialIds) {
const exists = await this.validateMaterialExists(materialId);
if (exists) {
validMaterials.push(materialId);
} else {
invalidMaterials.push(materialId);
}
}
// 验证模特
if (modelId) {
modelValid = await this.validateModelExists(modelId);
}
return {
validMaterials,
invalidMaterials,
modelValid,
};
}
/**
* 获取绑定历史统计
*/
static async getBindingHistory(_days: number = 30): Promise<{
daily_bindings: Array<{ date: string; count: number }>;
total_operations: number;
most_active_model: string | null;
}> {
// 这里需要后端支持绑定历史统计
// 暂时返回模拟数据
return {
daily_bindings: [],
total_operations: 0,
most_active_model: null,
};
}
/**
* 导出绑定关系
*/
static async exportBindingRelations(format: 'json' | 'csv' = 'json'): Promise<string> {
try {
// 获取所有绑定关系
const allModels = await this.getAllModels();
const bindingData = [];
for (const model of allModels) {
const materials = await this.getMaterialsByModel(model.id);
for (const material of materials) {
bindingData.push({
model_id: model.id,
model_name: model.name,
model_stage_name: model.stage_name,
material_id: material.id,
material_name: material.name,
material_type: material.material_type,
created_at: material.created_at,
});
}
}
if (format === 'json') {
return JSON.stringify(bindingData, null, 2);
} else {
// CSV 格式
const headers = Object.keys(bindingData[0] || {});
const csvContent = [
headers.join(','),
...bindingData.map(row =>
headers.map(header => `"${row[header as keyof typeof row] || ''}"`).join(',')
)
].join('\n');
return csvContent;
}
} catch (error) {
throw new Error(`导出绑定关系失败: ${error}`);
}
}
}

View File

@@ -0,0 +1,291 @@
/**
* 项目-模板绑定服务
* 遵循前端开发规范的服务层设计原则
*/
import { invoke } from '@tauri-apps/api/core';
import {
ProjectTemplateBinding,
CreateProjectTemplateBindingRequest,
UpdateProjectTemplateBindingRequest,
ProjectTemplateBindingQueryParams,
ProjectTemplateBindingDetail,
BatchCreateProjectTemplateBindingRequest,
BatchDeleteProjectTemplateBindingRequest,
} from '../types/projectTemplateBinding';
export class ProjectTemplateBindingService {
/**
* 创建项目-模板绑定
*/
static async createBinding(request: CreateProjectTemplateBindingRequest): Promise<ProjectTemplateBinding> {
try {
return await invoke('create_project_template_binding', { request });
} catch (error) {
console.error('创建项目-模板绑定失败:', error);
throw new Error(`创建绑定失败: ${error}`);
}
}
/**
* 更新项目-模板绑定
*/
static async updateBinding(id: string, request: UpdateProjectTemplateBindingRequest): Promise<ProjectTemplateBinding> {
try {
return await invoke('update_project_template_binding', { id, request });
} catch (error) {
console.error('更新项目-模板绑定失败:', error);
throw new Error(`更新绑定失败: ${error}`);
}
}
/**
* 删除项目-模板绑定
*/
static async deleteBinding(id: string): Promise<void> {
try {
await invoke('delete_project_template_binding', { id });
} catch (error) {
console.error('删除项目-模板绑定失败:', error);
throw new Error(`删除绑定失败: ${error}`);
}
}
/**
* 根据项目ID和模板ID删除绑定
*/
static async deleteBindingByIds(projectId: string, templateId: string): Promise<void> {
try {
await invoke('delete_project_template_binding_by_ids', {
project_id: projectId,
template_id: templateId
});
} catch (error) {
console.error('删除项目-模板绑定失败:', error);
throw new Error(`删除绑定失败: ${error}`);
}
}
/**
* 获取项目-模板绑定详情
*/
static async getBinding(id: string): Promise<ProjectTemplateBinding> {
try {
return await invoke('get_project_template_binding', { id });
} catch (error) {
console.error('获取项目-模板绑定失败:', error);
throw new Error(`获取绑定失败: ${error}`);
}
}
/**
* 查询项目-模板绑定列表
*/
static async listBindings(params: ProjectTemplateBindingQueryParams = {}): Promise<ProjectTemplateBinding[]> {
try {
return await invoke('list_project_template_bindings', { params });
} catch (error) {
console.error('查询项目-模板绑定列表失败:', error);
throw new Error(`查询绑定列表失败: ${error}`);
}
}
/**
* 获取项目的模板列表
*/
static async getTemplatesByProject(projectId: string): Promise<ProjectTemplateBindingDetail[]> {
try {
return await invoke('get_templates_by_project', { project_id: projectId });
} catch (error) {
console.error('获取项目模板列表失败:', error);
throw new Error(`获取项目模板列表失败: ${error}`);
}
}
/**
* 获取模板的项目列表
*/
static async getProjectsByTemplate(templateId: string): Promise<ProjectTemplateBindingDetail[]> {
try {
return await invoke('get_projects_by_template', { template_id: templateId });
} catch (error) {
console.error('获取模板项目列表失败:', error);
throw new Error(`获取模板项目列表失败: ${error}`);
}
}
/**
* 批量创建项目-模板绑定
*/
static async batchCreateBindings(request: BatchCreateProjectTemplateBindingRequest): Promise<ProjectTemplateBinding[]> {
try {
return await invoke('batch_create_project_template_bindings', { request });
} catch (error) {
console.error('批量创建项目-模板绑定失败:', error);
throw new Error(`批量创建绑定失败: ${error}`);
}
}
/**
* 批量删除项目-模板绑定
*/
static async batchDeleteBindings(request: BatchDeleteProjectTemplateBindingRequest): Promise<number> {
try {
return await invoke('batch_delete_project_template_bindings', { request });
} catch (error) {
console.error('批量删除项目-模板绑定失败:', error);
throw new Error(`批量删除绑定失败: ${error}`);
}
}
/**
* 激活项目-模板绑定
*/
static async activateBinding(id: string): Promise<ProjectTemplateBinding> {
try {
return await invoke('activate_project_template_binding', { id });
} catch (error) {
console.error('激活项目-模板绑定失败:', error);
throw new Error(`激活绑定失败: ${error}`);
}
}
/**
* 停用项目-模板绑定
*/
static async deactivateBinding(id: string): Promise<ProjectTemplateBinding> {
try {
return await invoke('deactivate_project_template_binding', { id });
} catch (error) {
console.error('停用项目-模板绑定失败:', error);
throw new Error(`停用绑定失败: ${error}`);
}
}
/**
* 获取项目的主要模板绑定
*/
static async getPrimaryBindingForProject(projectId: string): Promise<ProjectTemplateBinding | null> {
try {
return await invoke('get_primary_template_binding_for_project', { project_id: projectId });
} catch (error) {
console.error('获取项目主要模板绑定失败:', error);
throw new Error(`获取主要模板绑定失败: ${error}`);
}
}
/**
* 设置项目的主要模板
*/
static async setPrimaryTemplate(projectId: string, templateId: string): Promise<ProjectTemplateBinding> {
try {
return await invoke('set_primary_template_for_project', {
project_id: projectId,
template_id: templateId
});
} catch (error) {
console.error('设置项目主要模板失败:', error);
throw new Error(`设置主要模板失败: ${error}`);
}
}
/**
* 检查项目-模板绑定是否存在
*/
static async checkBindingExists(projectId: string, templateId: string): Promise<boolean> {
try {
return await invoke('check_project_template_binding_exists', {
project_id: projectId,
template_id: templateId
});
} catch (error) {
console.error('检查项目-模板绑定是否存在失败:', error);
throw new Error(`检查绑定是否存在失败: ${error}`);
}
}
/**
* 获取项目的活跃模板绑定
*/
static async getActiveBindingsForProject(projectId: string): Promise<ProjectTemplateBindingDetail[]> {
try {
const bindings = await this.getTemplatesByProject(projectId);
return bindings.filter(detail =>
detail.binding.is_active &&
detail.binding.binding_status === 'Active'
);
} catch (error) {
console.error('获取项目活跃模板绑定失败:', error);
throw new Error(`获取活跃模板绑定失败: ${error}`);
}
}
/**
* 获取项目的主要模板详情
*/
static async getPrimaryTemplateForProject(projectId: string): Promise<ProjectTemplateBindingDetail | null> {
try {
const bindings = await this.getTemplatesByProject(projectId);
const primaryBinding = bindings.find(detail =>
detail.binding.binding_type === 'Primary' &&
detail.binding.is_active
);
return primaryBinding || null;
} catch (error) {
console.error('获取项目主要模板详情失败:', error);
throw new Error(`获取主要模板详情失败: ${error}`);
}
}
/**
* 切换绑定状态(激活/停用)
*/
static async toggleBindingStatus(id: string, currentStatus: boolean): Promise<ProjectTemplateBinding> {
try {
if (currentStatus) {
return await this.deactivateBinding(id);
} else {
return await this.activateBinding(id);
}
} catch (error) {
console.error('切换绑定状态失败:', error);
throw new Error(`切换绑定状态失败: ${error}`);
}
}
/**
* 重新排序绑定优先级
*/
static async reorderBindings(_projectId: string, bindingIds: string[]): Promise<void> {
try {
const updatePromises = bindingIds.map((id, index) =>
this.updateBinding(id, { priority: index })
);
await Promise.all(updatePromises);
} catch (error) {
console.error('重新排序绑定失败:', error);
throw new Error(`重新排序绑定失败: ${error}`);
}
}
/**
* 复制绑定到其他项目
*/
static async copyBindingToProject(bindingId: string, targetProjectId: string): Promise<ProjectTemplateBinding> {
try {
const sourceBinding = await this.getBinding(bindingId);
const createRequest: CreateProjectTemplateBindingRequest = {
project_id: targetProjectId,
template_id: sourceBinding.template_id,
binding_name: sourceBinding.binding_name,
description: sourceBinding.description,
priority: sourceBinding.priority,
binding_type: sourceBinding.binding_type === 'Primary' ? 'Secondary' as any : sourceBinding.binding_type,
};
return await this.createBinding(createRequest);
} catch (error) {
console.error('复制绑定到其他项目失败:', error);
throw new Error(`复制绑定失败: ${error}`);
}
}
}

View File

@@ -0,0 +1,382 @@
/**
* 项目-模板绑定状态管理
* 遵循前端开发规范的状态管理设计原则
*/
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import {
ProjectTemplateBinding,
ProjectTemplateBindingDetail,
CreateProjectTemplateBindingRequest,
UpdateProjectTemplateBindingRequest,
ProjectTemplateBindingQueryParams,
BindingType,
BindingStatus,
sortBindingsByPriority,
filterBindings,
getBindingStats,
} from '../types/projectTemplateBinding';
import { ProjectTemplateBindingService } from '../services/projectTemplateBindingService';
interface ProjectTemplateBindingState {
// 数据状态
bindings: ProjectTemplateBinding[];
bindingDetails: ProjectTemplateBindingDetail[];
currentBinding: ProjectTemplateBinding | null;
// UI 状态
loading: boolean;
error: string | null;
selectedBindingIds: string[];
// 过滤和搜索状态
filters: {
binding_type?: BindingType;
binding_status?: BindingStatus;
is_active?: boolean;
search?: string;
};
// 分页状态
pagination: {
page: number;
pageSize: number;
total: number;
};
// 操作方法
actions: {
// 数据获取
fetchBindings: (params?: ProjectTemplateBindingQueryParams) => Promise<void>;
fetchTemplatesByProject: (projectId: string) => Promise<void>;
fetchProjectsByTemplate: (templateId: string) => Promise<void>;
fetchBinding: (id: string) => Promise<void>;
// 数据操作
createBinding: (request: CreateProjectTemplateBindingRequest) => Promise<ProjectTemplateBinding>;
updateBinding: (id: string, request: UpdateProjectTemplateBindingRequest) => Promise<ProjectTemplateBinding>;
deleteBinding: (id: string) => Promise<void>;
batchDeleteBindings: (ids: string[]) => Promise<void>;
// 状态操作
activateBinding: (id: string) => Promise<void>;
deactivateBinding: (id: string) => Promise<void>;
toggleBindingStatus: (id: string) => Promise<void>;
setPrimaryTemplate: (projectId: string, templateId: string) => Promise<void>;
// UI 操作
setFilters: (filters: Partial<ProjectTemplateBindingState['filters']>) => void;
clearFilters: () => void;
setSelectedBindingIds: (ids: string[]) => void;
clearSelection: () => void;
setCurrentBinding: (binding: ProjectTemplateBinding | null) => void;
// 分页操作
setPage: (page: number) => void;
setPageSize: (pageSize: number) => void;
// 工具方法
getFilteredBindings: () => ProjectTemplateBinding[];
getSortedBindings: () => ProjectTemplateBinding[];
getBindingStats: () => ReturnType<typeof getBindingStats>;
// 错误处理
setError: (error: string | null) => void;
clearError: () => void;
};
}
export const useProjectTemplateBindingStore = create<ProjectTemplateBindingState>()(
devtools(
(set, get) => ({
// 初始状态
bindings: [],
bindingDetails: [],
currentBinding: null,
loading: false,
error: null,
selectedBindingIds: [],
filters: {},
pagination: {
page: 1,
pageSize: 20,
total: 0,
},
actions: {
// 获取绑定列表
fetchBindings: async (params = {}) => {
set({ loading: true, error: null });
try {
const bindings = await ProjectTemplateBindingService.listBindings(params);
set({
bindings: sortBindingsByPriority(bindings),
pagination: { ...get().pagination, total: bindings.length },
loading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : '获取绑定列表失败',
loading: false
});
}
},
// 获取项目的模板列表
fetchTemplatesByProject: async (projectId: string) => {
set({ loading: true, error: null });
try {
const details = await ProjectTemplateBindingService.getTemplatesByProject(projectId);
set({
bindingDetails: details,
bindings: details.map(d => d.binding),
loading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : '获取项目模板列表失败',
loading: false
});
}
},
// 获取模板的项目列表
fetchProjectsByTemplate: async (templateId: string) => {
set({ loading: true, error: null });
try {
const details = await ProjectTemplateBindingService.getProjectsByTemplate(templateId);
set({
bindingDetails: details,
bindings: details.map(d => d.binding),
loading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : '获取模板项目列表失败',
loading: false
});
}
},
// 获取单个绑定
fetchBinding: async (id: string) => {
set({ loading: true, error: null });
try {
const binding = await ProjectTemplateBindingService.getBinding(id);
set({ currentBinding: binding, loading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : '获取绑定详情失败',
loading: false
});
}
},
// 创建绑定
createBinding: async (request: CreateProjectTemplateBindingRequest) => {
set({ loading: true, error: null });
try {
const newBinding = await ProjectTemplateBindingService.createBinding(request);
const currentBindings = get().bindings;
set({
bindings: sortBindingsByPriority([...currentBindings, newBinding]),
loading: false
});
return newBinding;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '创建绑定失败';
set({ error: errorMessage, loading: false });
throw new Error(errorMessage);
}
},
// 更新绑定
updateBinding: async (id: string, request: UpdateProjectTemplateBindingRequest) => {
set({ loading: true, error: null });
try {
const updatedBinding = await ProjectTemplateBindingService.updateBinding(id, request);
const currentBindings = get().bindings;
const updatedBindings = currentBindings.map(binding =>
binding.id === id ? updatedBinding : binding
);
set({
bindings: sortBindingsByPriority(updatedBindings),
currentBinding: get().currentBinding?.id === id ? updatedBinding : get().currentBinding,
loading: false
});
return updatedBinding;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '更新绑定失败';
set({ error: errorMessage, loading: false });
throw new Error(errorMessage);
}
},
// 删除绑定
deleteBinding: async (id: string) => {
set({ loading: true, error: null });
try {
await ProjectTemplateBindingService.deleteBinding(id);
const currentBindings = get().bindings;
set({
bindings: currentBindings.filter(binding => binding.id !== id),
selectedBindingIds: get().selectedBindingIds.filter(selectedId => selectedId !== id),
currentBinding: get().currentBinding?.id === id ? null : get().currentBinding,
loading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : '删除绑定失败',
loading: false
});
}
},
// 批量删除绑定
batchDeleteBindings: async (ids: string[]) => {
set({ loading: true, error: null });
try {
await ProjectTemplateBindingService.batchDeleteBindings({ binding_ids: ids });
const currentBindings = get().bindings;
set({
bindings: currentBindings.filter(binding => !ids.includes(binding.id)),
selectedBindingIds: [],
loading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : '批量删除绑定失败',
loading: false
});
}
},
// 激活绑定
activateBinding: async (id: string) => {
try {
const updatedBinding = await ProjectTemplateBindingService.activateBinding(id);
const currentBindings = get().bindings;
const updatedBindings = currentBindings.map(binding =>
binding.id === id ? updatedBinding : binding
);
set({ bindings: sortBindingsByPriority(updatedBindings) });
} catch (error) {
set({ error: error instanceof Error ? error.message : '激活绑定失败' });
}
},
// 停用绑定
deactivateBinding: async (id: string) => {
try {
const updatedBinding = await ProjectTemplateBindingService.deactivateBinding(id);
const currentBindings = get().bindings;
const updatedBindings = currentBindings.map(binding =>
binding.id === id ? updatedBinding : binding
);
set({ bindings: sortBindingsByPriority(updatedBindings) });
} catch (error) {
set({ error: error instanceof Error ? error.message : '停用绑定失败' });
}
},
// 切换绑定状态
toggleBindingStatus: async (id: string) => {
const binding = get().bindings.find(b => b.id === id);
if (!binding) return;
try {
if (binding.is_active) {
await get().actions.deactivateBinding(id);
} else {
await get().actions.activateBinding(id);
}
} catch (error) {
set({ error: error instanceof Error ? error.message : '切换绑定状态失败' });
}
},
// 设置主要模板
setPrimaryTemplate: async (projectId: string, templateId: string) => {
set({ loading: true, error: null });
try {
await ProjectTemplateBindingService.setPrimaryTemplate(projectId, templateId);
// 重新获取项目的模板列表以更新状态
await get().actions.fetchTemplatesByProject(projectId);
} catch (error) {
set({
error: error instanceof Error ? error.message : '设置主要模板失败',
loading: false
});
}
},
// 设置过滤器
setFilters: (filters) => {
set({ filters: { ...get().filters, ...filters } });
},
// 清除过滤器
clearFilters: () => {
set({ filters: {} });
},
// 设置选中的绑定ID
setSelectedBindingIds: (ids) => {
set({ selectedBindingIds: ids });
},
// 清除选择
clearSelection: () => {
set({ selectedBindingIds: [] });
},
// 设置当前绑定
setCurrentBinding: (binding) => {
set({ currentBinding: binding });
},
// 设置页码
setPage: (page) => {
set({ pagination: { ...get().pagination, page } });
},
// 设置页面大小
setPageSize: (pageSize) => {
set({ pagination: { ...get().pagination, pageSize, page: 1 } });
},
// 获取过滤后的绑定
getFilteredBindings: () => {
const { bindings, filters } = get();
return filterBindings(bindings, filters);
},
// 获取排序后的绑定
getSortedBindings: () => {
const filteredBindings = get().actions.getFilteredBindings();
return sortBindingsByPriority(filteredBindings);
},
// 获取绑定统计
getBindingStats: () => {
const bindings = get().bindings;
return getBindingStats(bindings);
},
// 设置错误
setError: (error) => {
set({ error });
},
// 清除错误
clearError: () => {
set({ error: null });
},
},
}),
{
name: 'project-template-binding-store',
}
)
);

View File

@@ -0,0 +1,343 @@
/**
* 绑定管理功能动画样式
* 遵循前端开发规范的动画设计原则
*/
/* 淡入动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 淡出动画 */
@keyframes fadeOut {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-10px);
}
}
/* 滑入动画 */
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* 滑出动画 */
@keyframes slideOut {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(20px);
}
}
/* 缩放动画 */
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* 弹跳动画 */
@keyframes bounce {
0%, 20%, 53%, 80%, 100% {
transform: translate3d(0, 0, 0);
}
40%, 43% {
transform: translate3d(0, -8px, 0);
}
70% {
transform: translate3d(0, -4px, 0);
}
90% {
transform: translate3d(0, -2px, 0);
}
}
/* 脉冲动画 */
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}
/* 摇摆动画 */
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-2px);
}
20%, 40%, 60%, 80% {
transform: translateX(2px);
}
}
/* 旋转动画 */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 动画类 */
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
.animate-fade-out {
animation: fadeOut 0.3s ease-out forwards;
}
.animate-slide-in {
animation: slideIn 0.3s ease-out forwards;
}
.animate-slide-out {
animation: slideOut 0.3s ease-out forwards;
}
.animate-scale-in {
animation: scaleIn 0.2s ease-out forwards;
}
.animate-bounce {
animation: bounce 0.6s ease-out;
}
.animate-pulse {
animation: pulse 1s ease-in-out infinite;
}
.animate-shake {
animation: shake 0.5s ease-in-out;
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* 悬停效果 */
.hover-lift {
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
}
.hover-lift:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* 按钮动画 */
.button-press {
transition: transform 0.1s ease-out;
}
.button-press:active {
transform: scale(0.98);
}
/* 加载状态 */
.loading-shimmer {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* 成功状态动画 */
.success-flash {
animation: successFlash 0.6s ease-out;
}
@keyframes successFlash {
0% {
background-color: transparent;
}
50% {
background-color: rgba(34, 197, 94, 0.2);
}
100% {
background-color: transparent;
}
}
/* 错误状态动画 */
.error-flash {
animation: errorFlash 0.6s ease-out;
}
@keyframes errorFlash {
0% {
background-color: transparent;
}
50% {
background-color: rgba(239, 68, 68, 0.2);
}
100% {
background-color: transparent;
}
}
/* 进度条动画 */
.progress-bar {
position: relative;
overflow: hidden;
}
.progress-bar::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
animation: progressShine 2s infinite;
}
@keyframes progressShine {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
/* 响应式动画 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* 焦点动画 */
.focus-ring {
transition: box-shadow 0.2s ease-out;
}
.focus-ring:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.5);
}
/* 状态指示器 */
.status-indicator {
position: relative;
}
.status-indicator::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 8px;
height: 8px;
border-radius: 50%;
animation: statusPulse 2s infinite;
}
.status-indicator.active::before {
background-color: #10b981;
}
.status-indicator.inactive::before {
background-color: #6b7280;
}
.status-indicator.error::before {
background-color: #ef4444;
}
@keyframes statusPulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.2);
}
}
/* 工具提示动画 */
.tooltip {
opacity: 0;
transform: translateY(4px);
transition: opacity 0.2s ease-out, transform 0.2s ease-out;
pointer-events: none;
}
.tooltip.show {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
/* 模态框动画 */
.modal-backdrop {
opacity: 0;
transition: opacity 0.3s ease-out;
}
.modal-backdrop.show {
opacity: 1;
}
.modal-content {
opacity: 0;
transform: scale(0.9) translateY(-20px);
transition: opacity 0.3s ease-out, transform 0.3s ease-out;
}
.modal-content.show {
opacity: 1;
transform: scale(1) translateY(0);
}

View File

@@ -0,0 +1,276 @@
/**
* 测试环境设置
* 遵循前端开发规范的测试配置
*/
import { vi, beforeAll, afterAll, expect } from 'vitest';
import '@testing-library/jest-dom';
// Mock Tauri API
global.window = Object.create(window);
(global.window as any).__TAURI__ = {
invoke: vi.fn(),
event: {
listen: vi.fn(),
emit: vi.fn(),
},
path: {
join: vi.fn(),
dirname: vi.fn(),
},
fs: {
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
exists: vi.fn(),
},
};
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock scrollTo
Object.defineProperty(window, 'scrollTo', {
writable: true,
value: vi.fn(),
});
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});
// Mock sessionStorage
const sessionStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
Object.defineProperty(window, 'sessionStorage', {
value: sessionStorageMock,
});
// Mock URL.createObjectURL
Object.defineProperty(URL, 'createObjectURL', {
writable: true,
value: vi.fn(() => 'mocked-url'),
});
// Mock URL.revokeObjectURL
Object.defineProperty(URL, 'revokeObjectURL', {
writable: true,
value: vi.fn(),
});
// Mock console methods for cleaner test output
const originalError = console.error;
beforeAll(() => {
console.error = (...args: any[]) => {
if (
typeof args[0] === 'string' &&
args[0].includes('Warning: ReactDOM.render is deprecated')
) {
return;
}
originalError.call(console, ...args);
};
});
afterAll(() => {
console.error = originalError;
});
// Global test utilities
export const createMockInvoke = (responses: Record<string, any>) => {
return vi.fn().mockImplementation((command: string, args?: any) => {
if (responses[command]) {
if (typeof responses[command] === 'function') {
return Promise.resolve(responses[command](args));
}
return Promise.resolve(responses[command]);
}
return Promise.reject(new Error(`Mock not found for command: ${command}`));
});
};
export const createMockTemplate = (overrides = {}) => ({
id: 'template-1',
name: '测试模板',
description: '这是一个测试模板',
file_path: '/path/to/template',
import_status: 'Completed',
project_id: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
...overrides,
});
export const createMockModel = (overrides = {}) => ({
id: 'model-1',
name: '测试模特',
stage_name: '艺名',
description: '这是一个测试模特',
avatar_url: null,
tags: ['标签1', '标签2'],
is_active: true,
created_at: new Date('2024-01-01T00:00:00Z'),
updated_at: new Date('2024-01-01T00:00:00Z'),
...overrides,
});
export const createMockMaterial = (overrides = {}) => ({
id: 'material-1',
project_id: 'project-1',
name: '测试素材.mp4',
original_path: '/path/to/material.mp4',
file_size: 1024000,
md5_hash: 'abcd1234',
material_type: 'Video',
processing_status: 'Completed',
metadata: {
Video: {
duration: 120,
width: 1920,
height: 1080,
fps: 30,
bitrate: 5000000,
codec: 'h264',
format: 'mp4',
has_audio: true,
},
},
scene_detection: null,
segments: [],
model_id: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
processed_at: '2024-01-01T00:00:00Z',
error_message: null,
...overrides,
});
export const createMockProjectTemplateBinding = (overrides = {}) => ({
id: 'binding-1',
project_id: 'project-1',
template_id: 'template-1',
binding_name: '测试绑定',
description: '这是一个测试绑定',
priority: 0,
is_active: true,
binding_type: 'Primary',
binding_status: 'Active',
metadata: null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
...overrides,
});
export const createMockProject = (overrides = {}) => ({
id: 'project-1',
name: '测试项目',
path: '/path/to/project',
description: '这是一个测试项目',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
...overrides,
});
// Test data generators
export const generateTestData = {
templates: (count: number) =>
Array.from({ length: count }, (_, i) =>
createMockTemplate({
id: `template-${i + 1}`,
name: `模板${i + 1}`,
})
),
models: (count: number) =>
Array.from({ length: count }, (_, i) =>
createMockModel({
id: `model-${i + 1}`,
name: `模特${i + 1}`,
stage_name: `艺名${i + 1}`,
})
),
materials: (count: number, projectId = 'project-1') =>
Array.from({ length: count }, (_, i) =>
createMockMaterial({
id: `material-${i + 1}`,
project_id: projectId,
name: `素材${i + 1}.mp4`,
})
),
bindings: (count: number, projectId = 'project-1') =>
Array.from({ length: count }, (_, i) =>
createMockProjectTemplateBinding({
id: `binding-${i + 1}`,
project_id: projectId,
template_id: `template-${i + 1}`,
binding_name: `绑定${i + 1}`,
priority: i,
})
),
};
// Custom matchers
expect.extend({
toBeValidUUID(received: string) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const pass = uuidRegex.test(received);
if (pass) {
return {
message: () => `expected ${received} not to be a valid UUID`,
pass: true,
};
} else {
return {
message: () => `expected ${received} to be a valid UUID`,
pass: false,
};
}
},
});
declare global {
namespace jest {
interface Matchers<R> {
toBeValidUUID(): R;
}
}
}

View File

@@ -84,6 +84,7 @@ export interface Material {
metadata: MaterialMetadata;
scene_detection?: SceneDetection;
segments: MaterialSegment[];
model_id?: string; // 关联的模特ID
created_at: string;
updated_at: string;
processed_at?: string;

View File

@@ -0,0 +1,246 @@
/**
* 项目-模板绑定相关类型定义
* 遵循前端开发规范的类型设计原则
*/
export interface ProjectTemplateBinding {
id: string;
project_id: string;
template_id: string;
binding_name?: string;
description?: string;
priority: number;
is_active: boolean;
binding_type: BindingType;
binding_status: BindingStatus;
metadata?: string;
created_at: string;
updated_at: string;
}
export enum BindingType {
Primary = 'Primary',
Secondary = 'Secondary',
Reference = 'Reference',
Test = 'Test',
}
export enum BindingStatus {
Active = 'Active',
Paused = 'Paused',
Completed = 'Completed',
Cancelled = 'Cancelled',
}
export interface CreateProjectTemplateBindingRequest {
project_id: string;
template_id: string;
binding_name?: string;
description?: string;
priority?: number;
binding_type: BindingType;
}
export interface UpdateProjectTemplateBindingRequest {
binding_name?: string;
description?: string;
priority?: number;
binding_type?: BindingType;
binding_status?: BindingStatus;
is_active?: boolean;
}
export interface ProjectTemplateBindingQueryParams {
project_id?: string;
template_id?: string;
binding_type?: BindingType;
binding_status?: BindingStatus;
is_active?: boolean;
limit?: number;
offset?: number;
}
export interface ProjectTemplateBindingDetail {
binding: ProjectTemplateBinding;
project_name: string;
template_name: string;
template_description?: string;
}
export interface BatchCreateProjectTemplateBindingRequest {
project_id: string;
template_ids: string[];
binding_type: BindingType;
priority_start?: number;
}
export interface BatchDeleteProjectTemplateBindingRequest {
binding_ids: string[];
}
// UI 相关类型
export interface ProjectTemplateBindingFormData {
template_id: string;
binding_name: string;
description: string;
priority: number;
binding_type: BindingType;
}
export interface ProjectTemplateBindingListItem extends ProjectTemplateBindingDetail {
// 扩展用于列表显示的字段
binding_type_display: string;
binding_status_display: string;
is_primary: boolean;
can_edit: boolean;
can_delete: boolean;
}
// 绑定类型选项
export const BINDING_TYPE_OPTIONS = [
{ value: BindingType.Primary, label: '主要绑定', description: '项目的主要模板' },
{ value: BindingType.Secondary, label: '次要绑定', description: '项目的备用模板' },
{ value: BindingType.Reference, label: '参考绑定', description: '仅作为参考的模板' },
{ value: BindingType.Test, label: '测试绑定', description: '用于测试的模板' },
];
// 绑定状态选项
export const BINDING_STATUS_OPTIONS = [
{ value: BindingStatus.Active, label: '活跃', description: '正在使用' },
{ value: BindingStatus.Paused, label: '暂停', description: '暂时不使用' },
{ value: BindingStatus.Completed, label: '已完成', description: '已完成使用' },
{ value: BindingStatus.Cancelled, label: '已取消', description: '取消绑定' },
];
// 工具函数
export const getBindingTypeDisplay = (type: BindingType): string => {
const option = BINDING_TYPE_OPTIONS.find(opt => opt.value === type);
return option?.label || type;
};
export const getBindingStatusDisplay = (status: BindingStatus): string => {
const option = BINDING_STATUS_OPTIONS.find(opt => opt.value === status);
return option?.label || status;
};
export const getBindingTypeColor = (type: BindingType): string => {
switch (type) {
case BindingType.Primary:
return 'bg-blue-100 text-blue-800';
case BindingType.Secondary:
return 'bg-green-100 text-green-800';
case BindingType.Reference:
return 'bg-yellow-100 text-yellow-800';
case BindingType.Test:
return 'bg-purple-100 text-purple-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
export const getBindingStatusColor = (status: BindingStatus): string => {
switch (status) {
case BindingStatus.Active:
return 'bg-green-100 text-green-800';
case BindingStatus.Paused:
return 'bg-yellow-100 text-yellow-800';
case BindingStatus.Completed:
return 'bg-blue-100 text-blue-800';
case BindingStatus.Cancelled:
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
// 验证函数
export const validateBindingFormData = (data: Partial<ProjectTemplateBindingFormData>): string[] => {
const errors: string[] = [];
if (!data.template_id?.trim()) {
errors.push('请选择模板');
}
if (data.binding_name && data.binding_name.length > 100) {
errors.push('绑定名称不能超过100个字符');
}
if (data.description && data.description.length > 500) {
errors.push('绑定描述不能超过500个字符');
}
if (data.priority !== undefined && (data.priority < 0 || data.priority > 999)) {
errors.push('优先级必须在0-999之间');
}
return errors;
};
// 排序函数
export const sortBindingsByPriority = (bindings: ProjectTemplateBinding[]): ProjectTemplateBinding[] => {
return [...bindings].sort((a, b) => {
// 首先按优先级排序(数值越小优先级越高)
if (a.priority !== b.priority) {
return a.priority - b.priority;
}
// 然后按绑定类型排序(主要绑定优先)
const typeOrder = {
[BindingType.Primary]: 0,
[BindingType.Secondary]: 1,
[BindingType.Reference]: 2,
[BindingType.Test]: 3,
};
if (typeOrder[a.binding_type] !== typeOrder[b.binding_type]) {
return typeOrder[a.binding_type] - typeOrder[b.binding_type];
}
// 最后按创建时间排序(新的在前)
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
};
// 过滤函数
export const filterBindings = (
bindings: ProjectTemplateBinding[],
filters: {
binding_type?: BindingType;
binding_status?: BindingStatus;
is_active?: boolean;
search?: string;
}
): ProjectTemplateBinding[] => {
return bindings.filter(binding => {
if (filters.binding_type && binding.binding_type !== filters.binding_type) {
return false;
}
if (filters.binding_status && binding.binding_status !== filters.binding_status) {
return false;
}
if (filters.is_active !== undefined && binding.is_active !== filters.is_active) {
return false;
}
if (filters.search) {
const searchLower = filters.search.toLowerCase();
return (
binding.binding_name?.toLowerCase().includes(searchLower) ||
binding.description?.toLowerCase().includes(searchLower)
);
}
return true;
});
};
// 统计函数
export const getBindingStats = (bindings: ProjectTemplateBinding[]) => {
const total = bindings.length;
const active = bindings.filter(b => b.is_active && b.binding_status === BindingStatus.Active).length;
const primary = bindings.filter(b => b.binding_type === BindingType.Primary).length;
const paused = bindings.filter(b => b.binding_status === BindingStatus.Paused).length;
return {
total,
active,
primary,
paused,
inactive: total - active,
};
};

View File

@@ -1,29 +1,52 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
// @ts-ignore
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
setupFiles: ['./src/tests/setup.ts'],
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
exclude: [
'node_modules',
'dist',
'.idea',
'.git',
'.cache',
'src-tauri',
],
css: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'src/test/',
'src/tests/',
'**/*.d.ts',
'**/*.config.*',
'dist/',
'**/coverage/**',
'**/dist/**',
'**/build/**',
],
},
testTimeout: 10000,
},
resolve: {
alias: {
'@': '/src',
'@': path.resolve(__dirname, './src'),
'@/components': path.resolve(__dirname, './src/components'),
'@/types': path.resolve(__dirname, './src/types'),
'@/stores': path.resolve(__dirname, './src/stores'),
'@/services': path.resolve(__dirname, './src/services'),
'@/utils': path.resolve(__dirname, './src/utils'),
'@/tests': path.resolve(__dirname, './src/tests'),
},
},
define: {
global: 'globalThis',
},
});