feat: 实现项目-模板绑定和素材-模特绑定管理功能
新功能: - 项目-模板绑定管理系统 - 支持主要/次要模板绑定类型 - 绑定优先级和状态管理 - 批量绑定操作 - 绑定关系的CRUD操作 - 素材-模特绑定管理系统 - 素材与模特的关联管理 - 批量绑定/解绑操作 - 绑定统计和分析 - 素材编辑对话框 架构改进: - 新增项目-模板绑定数据模型和仓库层 - 新增素材-模特绑定业务服务层 - 完善的API命令层实现 - 响应式前端界面设计 用户体验优化: - 统一的通知系统 - 增强的加载状态组件 - 流畅的交互动画 - 优雅的确认对话框 测试覆盖: - 单元测试和集成测试 - 业务逻辑验证 - API接口测试 技术栈: - 后端: Rust + Tauri + SQLite - 前端: React + TypeScript + TailwindCSS - 状态管理: Zustand - 测试: Vitest + Rust测试框架 配置更新: - 更新数据库迁移脚本 - 完善测试配置 - 优化构建流程
This commit is contained in:
@@ -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",
|
||||
|
||||
147
apps/desktop/scripts/run-tests.sh
Normal file
147
apps/desktop/scripts/run-tests.sh
Normal 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 "✨ 绑定管理功能测试全部通过!"
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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, // 绑定率百分比
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,4 @@ pub mod model;
|
||||
pub mod ai_classification;
|
||||
pub mod video_classification;
|
||||
pub mod template;
|
||||
pub mod project_template_binding;
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)?))
|
||||
})?;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
360
apps/desktop/src-tauri/src/tests/material_model_binding_tests.rs
Normal file
360
apps/desktop/src-tauri/src/tests/material_model_binding_tests.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
315
apps/desktop/src/components/EnhancedLoadingState.tsx
Normal file
315
apps/desktop/src/components/EnhancedLoadingState.tsx
Normal 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>;
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
208
apps/desktop/src/components/MaterialEditDialog.tsx
Normal file
208
apps/desktop/src/components/MaterialEditDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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: '应用设置'
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
304
apps/desktop/src/components/NotificationSystem.tsx
Normal file
304
apps/desktop/src/components/NotificationSystem.tsx
Normal 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);
|
||||
}
|
||||
303
apps/desktop/src/components/ProjectTemplateBindingForm.tsx
Normal file
303
apps/desktop/src/components/ProjectTemplateBindingForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
462
apps/desktop/src/components/ProjectTemplateBindingList.tsx
Normal file
462
apps/desktop/src/components/ProjectTemplateBindingList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
389
apps/desktop/src/pages/MaterialModelBinding.tsx
Normal file
389
apps/desktop/src/pages/MaterialModelBinding.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
318
apps/desktop/src/services/materialModelBindingService.ts
Normal file
318
apps/desktop/src/services/materialModelBindingService.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
291
apps/desktop/src/services/projectTemplateBindingService.ts
Normal file
291
apps/desktop/src/services/projectTemplateBindingService.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
382
apps/desktop/src/stores/projectTemplateBindingStore.ts
Normal file
382
apps/desktop/src/stores/projectTemplateBindingStore.ts
Normal 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',
|
||||
}
|
||||
)
|
||||
);
|
||||
343
apps/desktop/src/styles/animations.css
Normal file
343
apps/desktop/src/styles/animations.css
Normal 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);
|
||||
}
|
||||
276
apps/desktop/src/tests/setup.ts
Normal file
276
apps/desktop/src/tests/setup.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
246
apps/desktop/src/types/projectTemplateBinding.ts
Normal file
246
apps/desktop/src/types/projectTemplateBinding.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user