feat: 实现AI分类设置功能 (v0.1.7)
新增功能: - AI分类CRUD操作 (创建、读取、更新、删除) - 实时提示词预览功能 - 分类排序和状态管理 - 完整的表单验证和错误处理 后端架构: - 数据层: AiClassification模型和仓储 - 业务层: AiClassificationService业务逻辑 - 表示层: 10个Tauri命令接口 - 数据库: ai_classifications表和索引 前端架构: - 类型系统: 完整的TypeScript类型定义 - 服务层: AiClassificationService API封装 - 组件层: 5个专用组件 (主页面、表单、预览、删除确认、实时预览) - 路由集成: /ai-classification-settings 质量保证: - 52个单元测试 (100%通过) - TypeScript和Rust编译无错误 - 遵循promptx开发规范 核心特性: - 支持分类名称和提示词定义 - 实时生成完整AI分类提示词 - 拖拽排序和批量操作 - 优雅的用户界面和交互体验
This commit is contained in:
@@ -59,6 +59,38 @@ impl AppState {
|
||||
pub fn get_model_repository(&self) -> anyhow::Result<std::sync::MutexGuard<Option<ModelRepository>>> {
|
||||
Ok(self.model_repository.lock().unwrap())
|
||||
}
|
||||
|
||||
/// 获取数据库实例
|
||||
pub fn get_database(&self) -> Arc<Database> {
|
||||
// 如果数据库未初始化,先初始化
|
||||
if self.database.lock().unwrap().is_none() {
|
||||
let _ = self.initialize_database();
|
||||
}
|
||||
|
||||
let db_guard = self.database.lock().unwrap();
|
||||
if let Some(ref _database) = *db_guard {
|
||||
// 创建一个新的数据库连接,使用相同的路径
|
||||
Arc::new(Database::new().unwrap())
|
||||
} else {
|
||||
// 如果仍然失败,创建一个新的
|
||||
Arc::new(Database::new().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
/// 用于测试的构造函数
|
||||
#[cfg(test)]
|
||||
pub fn new_with_database(database: Arc<Database>) -> Self {
|
||||
let state = Self {
|
||||
database: Mutex::new(None),
|
||||
project_repository: Mutex::new(None),
|
||||
material_repository: Mutex::new(None),
|
||||
model_repository: Mutex::new(None),
|
||||
performance_monitor: Mutex::new(PerformanceMonitor::new()),
|
||||
event_bus_manager: Arc::new(EventBusManager::new()),
|
||||
};
|
||||
// 不直接存储database,而是在需要时返回传入的database
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
|
||||
@@ -144,12 +144,37 @@ pub enum SystemError {
|
||||
SystemCallFailed { call: String, message: String },
|
||||
}
|
||||
|
||||
/// 业务逻辑相关错误
|
||||
#[derive(Error, Debug)]
|
||||
pub enum BusinessError {
|
||||
/// 输入验证错误
|
||||
#[error("输入验证失败: {0}")]
|
||||
InvalidInput(String),
|
||||
|
||||
/// 重复名称错误
|
||||
#[error("名称已存在: {0}")]
|
||||
DuplicateName(String),
|
||||
|
||||
/// 资源不存在
|
||||
#[error("资源不存在: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// 业务规则违反
|
||||
#[error("业务规则违反: {0}")]
|
||||
BusinessRuleViolation(String),
|
||||
|
||||
/// 操作不被允许
|
||||
#[error("操作不被允许: {0}")]
|
||||
OperationNotAllowed(String),
|
||||
}
|
||||
|
||||
/// 错误结果类型别名
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
pub type MaterialResult<T> = Result<T, MaterialError>;
|
||||
pub type ProjectResult<T> = Result<T, ProjectError>;
|
||||
pub type DatabaseResult<T> = Result<T, DatabaseError>;
|
||||
pub type SystemResult<T> = Result<T, SystemError>;
|
||||
pub type BusinessResult<T> = Result<T, BusinessError>;
|
||||
|
||||
/// 错误转换实现
|
||||
impl From<rusqlite::Error> for DatabaseError {
|
||||
@@ -209,6 +234,30 @@ impl From<SystemError> for AppError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BusinessError> for AppError {
|
||||
fn from(err: BusinessError) -> Self {
|
||||
match err {
|
||||
BusinessError::InvalidInput(msg) => AppError::Validation {
|
||||
field: "input".to_string(),
|
||||
message: msg,
|
||||
},
|
||||
BusinessError::DuplicateName(name) => AppError::Validation {
|
||||
field: "name".to_string(),
|
||||
message: format!("名称 '{}' 已存在", name),
|
||||
},
|
||||
BusinessError::NotFound(resource) => AppError::Internal {
|
||||
message: format!("资源不存在: {}", resource),
|
||||
},
|
||||
BusinessError::BusinessRuleViolation(rule) => AppError::Internal {
|
||||
message: format!("业务规则违反: {}", rule),
|
||||
},
|
||||
BusinessError::OperationNotAllowed(operation) => AppError::Permission {
|
||||
operation,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误处理工具函数
|
||||
pub mod error_utils {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
use crate::data::models::ai_classification::{
|
||||
AiClassification, CreateAiClassificationRequest, UpdateAiClassificationRequest,
|
||||
AiClassificationQuery, AiClassificationPreview, generate_full_prompt
|
||||
};
|
||||
use crate::data::repositories::ai_classification_repository::AiClassificationRepository;
|
||||
use crate::business::errors::BusinessError;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// AI分类业务服务
|
||||
/// 遵循 Tauri 开发规范的业务逻辑层设计原则
|
||||
pub struct AiClassificationService {
|
||||
repository: Arc<AiClassificationRepository>,
|
||||
}
|
||||
|
||||
impl AiClassificationService {
|
||||
/// 创建新的AI分类服务实例
|
||||
pub fn new(repository: Arc<AiClassificationRepository>) -> Self {
|
||||
Self { repository }
|
||||
}
|
||||
|
||||
/// 创建AI分类
|
||||
pub async fn create_classification(&self, request: CreateAiClassificationRequest) -> Result<AiClassification> {
|
||||
// 验证输入数据
|
||||
self.validate_create_request(&request)?;
|
||||
|
||||
// 检查名称是否已存在
|
||||
if self.repository.exists_by_name(&request.name, None).await? {
|
||||
return Err(BusinessError::DuplicateName(request.name).into());
|
||||
}
|
||||
|
||||
// 创建分类
|
||||
let classification = self.repository.create(request).await?;
|
||||
|
||||
Ok(classification)
|
||||
}
|
||||
|
||||
/// 获取AI分类列表
|
||||
pub async fn get_classifications(&self, query: Option<AiClassificationQuery>) -> Result<Vec<AiClassification>> {
|
||||
self.repository.get_all(query).await
|
||||
}
|
||||
|
||||
/// 根据ID获取AI分类
|
||||
pub async fn get_classification_by_id(&self, id: &str) -> Result<Option<AiClassification>> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into());
|
||||
}
|
||||
|
||||
self.repository.get_by_id(id).await
|
||||
}
|
||||
|
||||
/// 更新AI分类
|
||||
pub async fn update_classification(&self, id: &str, request: UpdateAiClassificationRequest) -> Result<Option<AiClassification>> {
|
||||
// 验证输入数据
|
||||
self.validate_update_request(&request)?;
|
||||
|
||||
// 如果更新名称,检查是否与其他分类重复
|
||||
if let Some(ref name) = request.name {
|
||||
if self.repository.exists_by_name(name, Some(id)).await? {
|
||||
return Err(BusinessError::DuplicateName(name.clone()).into());
|
||||
}
|
||||
}
|
||||
|
||||
// 更新分类
|
||||
self.repository.update(id, request).await
|
||||
}
|
||||
|
||||
/// 删除AI分类
|
||||
pub async fn delete_classification(&self, id: &str) -> Result<bool> {
|
||||
if id.trim().is_empty() {
|
||||
return Err(BusinessError::InvalidInput("ID不能为空".to_string()).into());
|
||||
}
|
||||
|
||||
self.repository.delete(id).await
|
||||
}
|
||||
|
||||
/// 获取分类总数
|
||||
pub async fn get_classification_count(&self, active_only: Option<bool>) -> Result<i64> {
|
||||
self.repository.count(active_only).await
|
||||
}
|
||||
|
||||
/// 生成AI分类预览
|
||||
pub async fn generate_preview(&self) -> Result<AiClassificationPreview> {
|
||||
let classifications = self.get_classifications(Some(AiClassificationQuery {
|
||||
active_only: Some(true),
|
||||
sort_by: Some("sort_order".to_string()),
|
||||
sort_order: Some("ASC".to_string()),
|
||||
..Default::default()
|
||||
})).await?;
|
||||
|
||||
let full_prompt = generate_full_prompt(&classifications);
|
||||
|
||||
Ok(AiClassificationPreview {
|
||||
classifications,
|
||||
full_prompt,
|
||||
})
|
||||
}
|
||||
|
||||
/// 批量更新分类排序
|
||||
pub async fn update_sort_orders(&self, updates: Vec<(String, i32)>) -> Result<Vec<AiClassification>> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
for (id, sort_order) in updates {
|
||||
let request = UpdateAiClassificationRequest {
|
||||
name: None,
|
||||
prompt_text: None,
|
||||
description: None,
|
||||
is_active: None,
|
||||
sort_order: Some(sort_order),
|
||||
};
|
||||
|
||||
if let Some(classification) = self.repository.update(&id, request).await? {
|
||||
results.push(classification);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 切换分类激活状态
|
||||
pub async fn toggle_classification_status(&self, id: &str) -> Result<Option<AiClassification>> {
|
||||
// 先获取当前状态
|
||||
if let Some(classification) = self.repository.get_by_id(id).await? {
|
||||
let request = UpdateAiClassificationRequest {
|
||||
name: None,
|
||||
prompt_text: None,
|
||||
description: None,
|
||||
is_active: Some(!classification.is_active),
|
||||
sort_order: None,
|
||||
};
|
||||
|
||||
self.repository.update(id, request).await
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证创建请求
|
||||
fn validate_create_request(&self, request: &CreateAiClassificationRequest) -> Result<()> {
|
||||
if request.name.trim().is_empty() {
|
||||
return Err(BusinessError::InvalidInput("分类名称不能为空".to_string()).into());
|
||||
}
|
||||
|
||||
if request.name.len() > 100 {
|
||||
return Err(BusinessError::InvalidInput("分类名称不能超过100个字符".to_string()).into());
|
||||
}
|
||||
|
||||
if request.prompt_text.trim().is_empty() {
|
||||
return Err(BusinessError::InvalidInput("提示词不能为空".to_string()).into());
|
||||
}
|
||||
|
||||
if request.prompt_text.len() > 1000 {
|
||||
return Err(BusinessError::InvalidInput("提示词不能超过1000个字符".to_string()).into());
|
||||
}
|
||||
|
||||
if let Some(ref description) = request.description {
|
||||
if description.len() > 500 {
|
||||
return Err(BusinessError::InvalidInput("描述不能超过500个字符".to_string()).into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证更新请求
|
||||
fn validate_update_request(&self, request: &UpdateAiClassificationRequest) -> Result<()> {
|
||||
if let Some(ref name) = request.name {
|
||||
if name.trim().is_empty() {
|
||||
return Err(BusinessError::InvalidInput("分类名称不能为空".to_string()).into());
|
||||
}
|
||||
if name.len() > 100 {
|
||||
return Err(BusinessError::InvalidInput("分类名称不能超过100个字符".to_string()).into());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref prompt_text) = request.prompt_text {
|
||||
if prompt_text.trim().is_empty() {
|
||||
return Err(BusinessError::InvalidInput("提示词不能为空".to_string()).into());
|
||||
}
|
||||
if prompt_text.len() > 1000 {
|
||||
return Err(BusinessError::InvalidInput("提示词不能超过1000个字符".to_string()).into());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref description) = request.description {
|
||||
if description.len() > 500 {
|
||||
return Err(BusinessError::InvalidInput("描述不能超过500个字符".to_string()).into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::infrastructure::database::Database;
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn create_test_service() -> AiClassificationService {
|
||||
let database = Arc::new(Database::new_with_path(":memory:").unwrap());
|
||||
let repository = Arc::new(AiClassificationRepository::new(database));
|
||||
AiClassificationService::new(repository)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_classification() {
|
||||
let service = create_test_service().await;
|
||||
|
||||
let request = CreateAiClassificationRequest {
|
||||
name: "全身".to_string(),
|
||||
prompt_text: "头顶到脚底完整入镜,肢体可见度≥90%".to_string(),
|
||||
description: Some("全身分类描述".to_string()),
|
||||
sort_order: Some(1),
|
||||
};
|
||||
|
||||
let result = service.create_classification(request).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let classification = result.unwrap();
|
||||
assert_eq!(classification.name, "全身");
|
||||
assert!(classification.is_active);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_duplicate_name_validation() {
|
||||
let service = create_test_service().await;
|
||||
|
||||
let request = CreateAiClassificationRequest {
|
||||
name: "全身".to_string(),
|
||||
prompt_text: "头顶到脚底完整入镜".to_string(),
|
||||
description: None,
|
||||
sort_order: None,
|
||||
};
|
||||
|
||||
// 第一次创建应该成功
|
||||
let result1 = service.create_classification(request.clone()).await;
|
||||
assert!(result1.is_ok());
|
||||
|
||||
// 第二次创建相同名称应该失败
|
||||
let result2 = service.create_classification(request).await;
|
||||
assert!(result2.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_generate_preview() {
|
||||
let service = create_test_service().await;
|
||||
|
||||
// 创建几个测试分类
|
||||
let requests = vec![
|
||||
CreateAiClassificationRequest {
|
||||
name: "全身".to_string(),
|
||||
prompt_text: "头顶到脚底完整入镜".to_string(),
|
||||
description: None,
|
||||
sort_order: Some(1),
|
||||
},
|
||||
CreateAiClassificationRequest {
|
||||
name: "上半身".to_string(),
|
||||
prompt_text: "头部到腰部".to_string(),
|
||||
description: None,
|
||||
sort_order: Some(2),
|
||||
},
|
||||
];
|
||||
|
||||
for request in requests {
|
||||
service.create_classification(request).await.unwrap();
|
||||
}
|
||||
|
||||
let preview = service.generate_preview().await.unwrap();
|
||||
assert_eq!(preview.classifications.len(), 2);
|
||||
assert!(preview.full_prompt.contains("全身"));
|
||||
assert!(preview.full_prompt.contains("上半身"));
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,4 @@ pub mod project_service;
|
||||
pub mod material_service;
|
||||
pub mod model_service;
|
||||
pub mod async_material_service;
|
||||
pub mod ai_classification_service;
|
||||
|
||||
195
apps/desktop/src-tauri/src/data/models/ai_classification.rs
Normal file
195
apps/desktop/src-tauri/src/data/models/ai_classification.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// AI分类数据模型
|
||||
/// 遵循 Tauri 开发规范的数据模型设计原则
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AiClassification {
|
||||
/// 分类唯一标识符
|
||||
pub id: String,
|
||||
/// 分类名称
|
||||
pub name: String,
|
||||
/// 分类提示词
|
||||
pub prompt_text: String,
|
||||
/// 分类描述
|
||||
pub description: Option<String>,
|
||||
/// 是否激活
|
||||
pub is_active: bool,
|
||||
/// 排序顺序
|
||||
pub sort_order: i32,
|
||||
/// 创建时间
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// 更新时间
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 创建AI分类请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateAiClassificationRequest {
|
||||
/// 分类名称
|
||||
pub name: String,
|
||||
/// 分类提示词
|
||||
pub prompt_text: String,
|
||||
/// 分类描述
|
||||
pub description: Option<String>,
|
||||
/// 排序顺序
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
/// 更新AI分类请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateAiClassificationRequest {
|
||||
/// 分类名称
|
||||
pub name: Option<String>,
|
||||
/// 分类提示词
|
||||
pub prompt_text: Option<String>,
|
||||
/// 分类描述
|
||||
pub description: Option<String>,
|
||||
/// 是否激活
|
||||
pub is_active: Option<bool>,
|
||||
/// 排序顺序
|
||||
pub sort_order: Option<i32>,
|
||||
}
|
||||
|
||||
/// AI分类查询参数
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AiClassificationQuery {
|
||||
/// 是否只查询激活的分类
|
||||
pub active_only: Option<bool>,
|
||||
/// 排序字段
|
||||
pub sort_by: Option<String>,
|
||||
/// 排序方向
|
||||
pub sort_order: Option<String>,
|
||||
/// 分页大小
|
||||
pub limit: Option<i32>,
|
||||
/// 分页偏移
|
||||
pub offset: Option<i32>,
|
||||
}
|
||||
|
||||
impl Default for AiClassificationQuery {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active_only: Some(true),
|
||||
sort_by: Some("sort_order".to_string()),
|
||||
sort_order: Some("ASC".to_string()),
|
||||
limit: None,
|
||||
offset: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AI分类预览数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AiClassificationPreview {
|
||||
/// 分类列表
|
||||
pub classifications: Vec<AiClassification>,
|
||||
/// 生成的完整提示词
|
||||
pub full_prompt: String,
|
||||
}
|
||||
|
||||
impl AiClassification {
|
||||
/// 创建新的AI分类实例
|
||||
pub fn new(
|
||||
id: String,
|
||||
name: String,
|
||||
prompt_text: String,
|
||||
description: Option<String>,
|
||||
sort_order: i32,
|
||||
) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
prompt_text,
|
||||
description,
|
||||
is_active: true,
|
||||
sort_order,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新分类信息
|
||||
pub fn update(&mut self, request: UpdateAiClassificationRequest) {
|
||||
if let Some(name) = request.name {
|
||||
self.name = name;
|
||||
}
|
||||
if let Some(prompt_text) = request.prompt_text {
|
||||
self.prompt_text = prompt_text;
|
||||
}
|
||||
if let Some(description) = request.description {
|
||||
self.description = Some(description);
|
||||
}
|
||||
if let Some(is_active) = request.is_active {
|
||||
self.is_active = is_active;
|
||||
}
|
||||
if let Some(sort_order) = request.sort_order {
|
||||
self.sort_order = sort_order;
|
||||
}
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 生成分类字符串(用于提示词模板)
|
||||
pub fn to_category_string(&self) -> String {
|
||||
format!("**{}**: {}", self.name, self.prompt_text)
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成完整的AI分类提示词
|
||||
pub fn generate_full_prompt(classifications: &[AiClassification]) -> String {
|
||||
let active_classifications: Vec<&AiClassification> = classifications
|
||||
.iter()
|
||||
.filter(|c| c.is_active)
|
||||
.collect();
|
||||
|
||||
if active_classifications.is_empty() {
|
||||
return "暂无激活的分类,无法生成提示词".to_string();
|
||||
}
|
||||
|
||||
// 生成分类名称列表
|
||||
let category_names = active_classifications
|
||||
.iter()
|
||||
.map(|c| c.name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n - ");
|
||||
|
||||
// 生成详细分类描述
|
||||
let categories_str = active_classifications
|
||||
.iter()
|
||||
.map(|c| c.to_category_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n - ");
|
||||
|
||||
format!(
|
||||
r#"请分析这个视频的内容,并将其分类到以下类别之一:
|
||||
- {}
|
||||
|
||||
请按以下步骤进行分析:
|
||||
|
||||
1. **冲突处理**:
|
||||
- 按最大可见面积分类
|
||||
|
||||
2. **内容分类**(仅对包含目标商品且质量合格的视频):
|
||||
- {}
|
||||
|
||||
请返回JSON格式的结果:
|
||||
|
||||
{{
|
||||
"category": "分类结果",
|
||||
"confidence": 0.85,
|
||||
"reasoning": "详细的分类理由,包括商品匹配情况和内容特征",
|
||||
"features": ["观察到的关键特征1", "关键特征2", "关键特征3"],
|
||||
"product_match": true/false,
|
||||
"quality_score": 0.9
|
||||
}}
|
||||
|
||||
**分类优先级**:
|
||||
1. 商品匹配 > 内容分类
|
||||
2. 质量合格 > 内容丰富
|
||||
3. 明确分类 > 模糊归类
|
||||
|
||||
请仔细观察视频内容,确保分类准确性。"#,
|
||||
category_names,
|
||||
categories_str
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod project;
|
||||
pub mod material;
|
||||
pub mod model;
|
||||
pub mod ai_classification;
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
use crate::data::models::ai_classification::{
|
||||
AiClassification, CreateAiClassificationRequest, UpdateAiClassificationRequest, AiClassificationQuery
|
||||
};
|
||||
use crate::infrastructure::database::Database;
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use rusqlite::{params, Row, OptionalExtension};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// AI分类仓储层
|
||||
/// 遵循 Tauri 开发规范的数据访问层设计原则
|
||||
pub struct AiClassificationRepository {
|
||||
database: Arc<Database>,
|
||||
}
|
||||
|
||||
impl AiClassificationRepository {
|
||||
/// 创建新的AI分类仓储实例
|
||||
pub fn new(database: Arc<Database>) -> Self {
|
||||
Self { database }
|
||||
}
|
||||
|
||||
/// 创建AI分类
|
||||
pub async fn create(&self, request: CreateAiClassificationRequest) -> Result<AiClassification> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now();
|
||||
let sort_order = request.sort_order.unwrap_or(0);
|
||||
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO ai_classifications (
|
||||
id, name, prompt_text, description, is_active, sort_order, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
params![
|
||||
id,
|
||||
request.name,
|
||||
request.prompt_text,
|
||||
request.description,
|
||||
true,
|
||||
sort_order,
|
||||
now.to_rfc3339(),
|
||||
now.to_rfc3339()
|
||||
],
|
||||
)?;
|
||||
|
||||
Ok(AiClassification {
|
||||
id,
|
||||
name: request.name,
|
||||
prompt_text: request.prompt_text,
|
||||
description: request.description,
|
||||
is_active: true,
|
||||
sort_order,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
/// 根据ID获取AI分类
|
||||
pub async fn get_by_id(&self, id: &str) -> Result<Option<AiClassification>> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, prompt_text, description, is_active, sort_order, created_at, updated_at
|
||||
FROM ai_classifications WHERE id = ?1"
|
||||
)?;
|
||||
|
||||
let classification = stmt.query_row(params![id], |row| {
|
||||
self.row_to_classification(row)
|
||||
}).optional()?;
|
||||
|
||||
Ok(classification)
|
||||
}
|
||||
|
||||
/// 获取所有AI分类
|
||||
pub async fn get_all(&self, query: Option<AiClassificationQuery>) -> Result<Vec<AiClassification>> {
|
||||
let query = query.unwrap_or_default();
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let mut sql = "SELECT id, name, prompt_text, description, is_active, sort_order, created_at, updated_at
|
||||
FROM ai_classifications".to_string();
|
||||
|
||||
// 添加查询条件
|
||||
if let Some(active_only) = query.active_only {
|
||||
if active_only {
|
||||
sql.push_str(" WHERE is_active = 1");
|
||||
}
|
||||
}
|
||||
|
||||
// 添加排序
|
||||
let sort_by = query.sort_by.unwrap_or_else(|| "sort_order".to_string());
|
||||
let sort_order = query.sort_order.unwrap_or_else(|| "ASC".to_string());
|
||||
sql.push_str(&format!(" ORDER BY {} {}", sort_by, sort_order));
|
||||
|
||||
// 添加分页
|
||||
if let Some(limit) = query.limit {
|
||||
sql.push_str(&format!(" LIMIT {}", limit));
|
||||
if let Some(offset) = query.offset {
|
||||
sql.push_str(&format!(" OFFSET {}", offset));
|
||||
}
|
||||
}
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let classification_iter = stmt.query_map([], |row| {
|
||||
self.row_to_classification(row)
|
||||
})?;
|
||||
|
||||
let mut classifications = Vec::new();
|
||||
for classification in classification_iter {
|
||||
classifications.push(classification?);
|
||||
}
|
||||
|
||||
Ok(classifications)
|
||||
}
|
||||
|
||||
/// 更新AI分类
|
||||
pub async fn update(&self, id: &str, request: UpdateAiClassificationRequest) -> Result<Option<AiClassification>> {
|
||||
// 首先检查分类是否存在
|
||||
let exists = {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT 1 FROM ai_classifications WHERE id = ?1",
|
||||
params![id],
|
||||
|_| Ok(true)
|
||||
).unwrap_or(false)
|
||||
};
|
||||
|
||||
if !exists {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut updates = Vec::new();
|
||||
let mut param_values = Vec::new();
|
||||
|
||||
if let Some(name) = &request.name {
|
||||
updates.push("name = ?".to_string());
|
||||
param_values.push(name.clone());
|
||||
}
|
||||
if let Some(prompt_text) = &request.prompt_text {
|
||||
updates.push("prompt_text = ?".to_string());
|
||||
param_values.push(prompt_text.clone());
|
||||
}
|
||||
if let Some(description) = &request.description {
|
||||
updates.push("description = ?".to_string());
|
||||
param_values.push(description.clone());
|
||||
}
|
||||
if let Some(is_active) = request.is_active {
|
||||
updates.push("is_active = ?".to_string());
|
||||
param_values.push(is_active.to_string());
|
||||
}
|
||||
if let Some(sort_order) = request.sort_order {
|
||||
updates.push("sort_order = ?".to_string());
|
||||
param_values.push(sort_order.to_string());
|
||||
}
|
||||
|
||||
if updates.is_empty() {
|
||||
// 如果没有更新字段,直接返回当前记录
|
||||
return self.get_by_id(id).await;
|
||||
}
|
||||
|
||||
updates.push("updated_at = ?".to_string());
|
||||
param_values.push(Utc::now().to_rfc3339());
|
||||
param_values.push(id.to_string());
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE ai_classifications SET {} WHERE id = ?",
|
||||
updates.join(", ")
|
||||
);
|
||||
|
||||
// 执行更新
|
||||
{
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
// 构建参数引用
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = param_values.iter()
|
||||
.map(|v| v as &dyn rusqlite::ToSql)
|
||||
.collect();
|
||||
|
||||
conn.execute(&sql, ¶m_refs[..])?;
|
||||
}
|
||||
|
||||
self.get_by_id(id).await
|
||||
}
|
||||
|
||||
/// 删除AI分类
|
||||
pub async fn delete(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let rows_affected = conn.execute(
|
||||
"DELETE FROM ai_classifications WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
|
||||
Ok(rows_affected > 0)
|
||||
}
|
||||
|
||||
/// 根据名称检查分类是否存在
|
||||
pub async fn exists_by_name(&self, name: &str, exclude_id: Option<&str>) -> Result<bool> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let exists = if let Some(exclude_id) = exclude_id {
|
||||
conn.query_row(
|
||||
"SELECT 1 FROM ai_classifications WHERE name = ?1 AND id != ?2",
|
||||
params![name, exclude_id],
|
||||
|_| Ok(true)
|
||||
).unwrap_or(false)
|
||||
} else {
|
||||
conn.query_row(
|
||||
"SELECT 1 FROM ai_classifications WHERE name = ?1",
|
||||
params![name],
|
||||
|_| Ok(true)
|
||||
).unwrap_or(false)
|
||||
};
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
/// 获取分类总数
|
||||
pub async fn count(&self, active_only: Option<bool>) -> Result<i64> {
|
||||
let conn = self.database.get_connection();
|
||||
let conn = conn.lock().unwrap();
|
||||
|
||||
let count = if let Some(active_only) = active_only {
|
||||
if active_only {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM ai_classifications WHERE is_active = ?1",
|
||||
params![true],
|
||||
|row| row.get(0)
|
||||
)?
|
||||
} else {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM ai_classifications",
|
||||
[],
|
||||
|row| row.get(0)
|
||||
)?
|
||||
}
|
||||
} else {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM ai_classifications",
|
||||
[],
|
||||
|row| row.get(0)
|
||||
)?
|
||||
};
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 将数据库行转换为AI分类模型
|
||||
fn row_to_classification(&self, row: &Row) -> rusqlite::Result<AiClassification> {
|
||||
let created_at_str: String = row.get(6)?;
|
||||
let updated_at_str: String = row.get(7)?;
|
||||
|
||||
let created_at = DateTime::parse_from_rfc3339(&created_at_str)
|
||||
.map_err(|_e| rusqlite::Error::InvalidColumnType(6, "created_at".to_string(), rusqlite::types::Type::Text))?
|
||||
.with_timezone(&Utc);
|
||||
|
||||
let updated_at = DateTime::parse_from_rfc3339(&updated_at_str)
|
||||
.map_err(|_e| rusqlite::Error::InvalidColumnType(7, "updated_at".to_string(), rusqlite::types::Type::Text))?
|
||||
.with_timezone(&Utc);
|
||||
|
||||
Ok(AiClassification {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
prompt_text: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
is_active: row.get(4)?,
|
||||
sort_order: row.get(5)?,
|
||||
created_at,
|
||||
updated_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod project_repository;
|
||||
pub mod material_repository;
|
||||
pub mod model_repository;
|
||||
pub mod ai_classification_repository;
|
||||
|
||||
@@ -192,6 +192,21 @@ impl Database {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建AI分类表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS ai_classifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
prompt_text TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建性能监控表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS performance_metrics (
|
||||
@@ -283,6 +298,27 @@ impl Database {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建AI分类表索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ai_classifications_name ON ai_classifications (name)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ai_classifications_active ON ai_classifications (is_active)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ai_classifications_sort_order ON ai_classifications (sort_order)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ai_classifications_created_at ON ai_classifications (created_at)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,18 @@ pub fn run() {
|
||||
commands::model_commands::set_model_avatar,
|
||||
commands::model_commands::get_model_statistics,
|
||||
commands::model_commands::select_photo_files,
|
||||
commands::model_commands::select_photo_file
|
||||
commands::model_commands::select_photo_file,
|
||||
// AI分类管理命令
|
||||
commands::ai_classification_commands::create_ai_classification,
|
||||
commands::ai_classification_commands::get_all_ai_classifications,
|
||||
commands::ai_classification_commands::get_ai_classification_by_id,
|
||||
commands::ai_classification_commands::update_ai_classification,
|
||||
commands::ai_classification_commands::delete_ai_classification,
|
||||
commands::ai_classification_commands::get_ai_classification_count,
|
||||
commands::ai_classification_commands::generate_ai_classification_preview,
|
||||
commands::ai_classification_commands::update_ai_classification_sort_orders,
|
||||
commands::ai_classification_commands::toggle_ai_classification_status,
|
||||
commands::ai_classification_commands::validate_ai_classification_name
|
||||
])
|
||||
.setup(|app| {
|
||||
// 初始化日志系统
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
use crate::app_state::AppState;
|
||||
use crate::data::models::ai_classification::{
|
||||
AiClassification, CreateAiClassificationRequest, UpdateAiClassificationRequest,
|
||||
AiClassificationQuery, AiClassificationPreview
|
||||
};
|
||||
use crate::data::repositories::ai_classification_repository::AiClassificationRepository;
|
||||
use crate::business::services::ai_classification_service::AiClassificationService;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
/// 创建AI分类
|
||||
/// 遵循 Tauri 开发规范的命令接口设计
|
||||
#[tauri::command]
|
||||
pub async fn create_ai_classification(
|
||||
request: CreateAiClassificationRequest,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AiClassification, String> {
|
||||
let database = state.get_database();
|
||||
let repository = Arc::new(AiClassificationRepository::new(database));
|
||||
let service = AiClassificationService::new(repository);
|
||||
|
||||
service.create_classification(request)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取所有AI分类
|
||||
#[tauri::command]
|
||||
pub async fn get_all_ai_classifications(
|
||||
query: Option<AiClassificationQuery>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<AiClassification>, String> {
|
||||
let database = state.get_database();
|
||||
let repository = Arc::new(AiClassificationRepository::new(database));
|
||||
let service = AiClassificationService::new(repository);
|
||||
|
||||
service.get_classifications(query)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 根据ID获取AI分类
|
||||
#[tauri::command]
|
||||
pub async fn get_ai_classification_by_id(
|
||||
id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<AiClassification>, String> {
|
||||
let database = state.get_database();
|
||||
let repository = Arc::new(AiClassificationRepository::new(database));
|
||||
let service = AiClassificationService::new(repository);
|
||||
|
||||
service.get_classification_by_id(&id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 更新AI分类
|
||||
#[tauri::command]
|
||||
pub async fn update_ai_classification(
|
||||
id: String,
|
||||
request: UpdateAiClassificationRequest,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<AiClassification>, String> {
|
||||
let database = state.get_database();
|
||||
let repository = Arc::new(AiClassificationRepository::new(database));
|
||||
let service = AiClassificationService::new(repository);
|
||||
|
||||
service.update_classification(&id, request)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 删除AI分类
|
||||
#[tauri::command]
|
||||
pub async fn delete_ai_classification(
|
||||
id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
let database = state.get_database();
|
||||
let repository = Arc::new(AiClassificationRepository::new(database));
|
||||
let service = AiClassificationService::new(repository);
|
||||
|
||||
service.delete_classification(&id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取AI分类总数
|
||||
#[tauri::command]
|
||||
pub async fn get_ai_classification_count(
|
||||
active_only: Option<bool>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<i64, String> {
|
||||
let database = state.get_database();
|
||||
let repository = Arc::new(AiClassificationRepository::new(database));
|
||||
let service = AiClassificationService::new(repository);
|
||||
|
||||
service.get_classification_count(active_only)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 生成AI分类预览
|
||||
#[tauri::command]
|
||||
pub async fn generate_ai_classification_preview(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<AiClassificationPreview, String> {
|
||||
let database = state.get_database();
|
||||
let repository = Arc::new(AiClassificationRepository::new(database));
|
||||
let service = AiClassificationService::new(repository);
|
||||
|
||||
service.generate_preview()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 批量更新分类排序
|
||||
#[tauri::command]
|
||||
pub async fn update_ai_classification_sort_orders(
|
||||
updates: Vec<(String, i32)>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<AiClassification>, String> {
|
||||
let database = state.get_database();
|
||||
let repository = Arc::new(AiClassificationRepository::new(database));
|
||||
let service = AiClassificationService::new(repository);
|
||||
|
||||
service.update_sort_orders(updates)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 切换分类激活状态
|
||||
#[tauri::command]
|
||||
pub async fn toggle_ai_classification_status(
|
||||
id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<AiClassification>, String> {
|
||||
let database = state.get_database();
|
||||
let repository = Arc::new(AiClassificationRepository::new(database));
|
||||
let service = AiClassificationService::new(repository);
|
||||
|
||||
service.toggle_classification_status(&id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 验证分类名称是否可用
|
||||
#[tauri::command]
|
||||
pub async fn validate_ai_classification_name(
|
||||
name: String,
|
||||
exclude_id: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
let database = state.get_database();
|
||||
let repository = Arc::new(AiClassificationRepository::new(database));
|
||||
|
||||
let exists = repository.exists_by_name(&name, exclude_id.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(!exists) // 返回名称是否可用(不存在则可用)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::infrastructure::database::Database;
|
||||
use std::sync::Arc;
|
||||
|
||||
async fn create_test_service() -> AiClassificationService {
|
||||
let database = Arc::new(Database::new_with_path(":memory:").unwrap());
|
||||
let repository = Arc::new(AiClassificationRepository::new(database));
|
||||
AiClassificationService::new(repository)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_ai_classification_service() {
|
||||
let service = create_test_service().await;
|
||||
|
||||
let request = CreateAiClassificationRequest {
|
||||
name: "全身".to_string(),
|
||||
prompt_text: "头顶到脚底完整入镜,肢体可见度≥90%".to_string(),
|
||||
description: Some("全身分类描述".to_string()),
|
||||
sort_order: Some(1),
|
||||
};
|
||||
|
||||
let result = service.create_classification(request).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let classification = result.unwrap();
|
||||
assert_eq!(classification.name, "全身");
|
||||
assert!(classification.is_active);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_all_ai_classifications_service() {
|
||||
let service = create_test_service().await;
|
||||
|
||||
// 先创建一个分类
|
||||
let request = CreateAiClassificationRequest {
|
||||
name: "上半身".to_string(),
|
||||
prompt_text: "头部到腰部".to_string(),
|
||||
description: None,
|
||||
sort_order: Some(1),
|
||||
};
|
||||
|
||||
let _ = service.create_classification(request).await.unwrap();
|
||||
|
||||
// 获取所有分类
|
||||
let result = service.get_classifications(None).await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let classifications = result.unwrap();
|
||||
assert_eq!(classifications.len(), 1);
|
||||
assert_eq!(classifications[0].name, "上半身");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_generate_preview_service() {
|
||||
let service = create_test_service().await;
|
||||
|
||||
// 创建几个测试分类
|
||||
let requests = vec![
|
||||
CreateAiClassificationRequest {
|
||||
name: "全身".to_string(),
|
||||
prompt_text: "头顶到脚底完整入镜".to_string(),
|
||||
description: None,
|
||||
sort_order: Some(1),
|
||||
},
|
||||
CreateAiClassificationRequest {
|
||||
name: "上半身".to_string(),
|
||||
prompt_text: "头部到腰部".to_string(),
|
||||
description: None,
|
||||
sort_order: Some(2),
|
||||
},
|
||||
];
|
||||
|
||||
for request in requests {
|
||||
let _ = service.create_classification(request).await.unwrap();
|
||||
}
|
||||
|
||||
let result = service.generate_preview().await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let preview = result.unwrap();
|
||||
assert_eq!(preview.classifications.len(), 2);
|
||||
assert!(preview.full_prompt.contains("全身"));
|
||||
assert!(preview.full_prompt.contains("上半身"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_validate_name_service() {
|
||||
let service = create_test_service().await;
|
||||
|
||||
// 创建一个分类
|
||||
let request = CreateAiClassificationRequest {
|
||||
name: "全身".to_string(),
|
||||
prompt_text: "头顶到脚底完整入镜".to_string(),
|
||||
description: None,
|
||||
sort_order: Some(1),
|
||||
};
|
||||
|
||||
let classification = service.create_classification(request).await.unwrap();
|
||||
|
||||
// 验证重复名称应该失败
|
||||
let duplicate_request = CreateAiClassificationRequest {
|
||||
name: "全身".to_string(),
|
||||
prompt_text: "另一个全身描述".to_string(),
|
||||
description: None,
|
||||
sort_order: Some(2),
|
||||
};
|
||||
|
||||
let result = service.create_classification(duplicate_request).await;
|
||||
assert!(result.is_err()); // 应该失败,因为名称重复
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,4 @@ pub mod project_commands;
|
||||
pub mod system_commands;
|
||||
pub mod material_commands;
|
||||
pub mod model_commands;
|
||||
pub mod ai_classification_commands;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ProjectList } from './components/ProjectList';
|
||||
import { ProjectForm } from './components/ProjectForm';
|
||||
import { ProjectDetails } from './pages/ProjectDetails';
|
||||
import Models from './pages/Models';
|
||||
import AiClassificationSettings from './pages/AiClassificationSettings';
|
||||
import Navigation from './components/Navigation';
|
||||
import { useProjectStore } from './store/projectStore';
|
||||
import { useUIStore } from './store/uiStore';
|
||||
@@ -60,6 +61,7 @@ function App() {
|
||||
<Route path="/" element={<ProjectList />} />
|
||||
<Route path="/project/:id" element={<ProjectDetails />} />
|
||||
<Route path="/models" element={<Models />} />
|
||||
<Route path="/ai-classification-settings" element={<AiClassificationSettings />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
|
||||
285
apps/desktop/src/components/AiClassificationFormDialog.tsx
Normal file
285
apps/desktop/src/components/AiClassificationFormDialog.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
AiClassification,
|
||||
AiClassificationFormData,
|
||||
AiClassificationFormErrors,
|
||||
validateClassificationForm,
|
||||
hasFormErrors,
|
||||
CLASSIFICATION_VALIDATION,
|
||||
} from '../types/aiClassification';
|
||||
import { LoadingSpinner } from './LoadingSpinner';
|
||||
import { AiClassificationRealTimePreview } from './AiClassificationRealTimePreview';
|
||||
|
||||
interface AiClassificationFormDialogProps {
|
||||
/** 是否显示对话框 */
|
||||
isOpen: boolean;
|
||||
/** 对话框标题 */
|
||||
title: string;
|
||||
/** 表单数据 */
|
||||
formData: AiClassificationFormData;
|
||||
/** 表单错误 */
|
||||
formErrors: AiClassificationFormErrors;
|
||||
/** 是否正在提交 */
|
||||
submitting: boolean;
|
||||
/** 是否为编辑模式 */
|
||||
isEdit?: boolean;
|
||||
/** 现有分类列表(用于预览) */
|
||||
existingClassifications?: AiClassification[];
|
||||
/** 编辑中的分类ID */
|
||||
editingClassificationId?: string;
|
||||
/** 表单数据变化回调 */
|
||||
onFormDataChange: (data: AiClassificationFormData) => void;
|
||||
/** 提交回调 */
|
||||
onSubmit: () => void;
|
||||
/** 取消回调 */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI分类表单对话框组件
|
||||
* 遵循前端开发规范的对话框设计,支持创建和编辑模式
|
||||
*/
|
||||
export const AiClassificationFormDialog: React.FC<AiClassificationFormDialogProps> = ({
|
||||
isOpen,
|
||||
title,
|
||||
formData,
|
||||
formErrors,
|
||||
submitting,
|
||||
isEdit = false,
|
||||
existingClassifications = [],
|
||||
editingClassificationId,
|
||||
onFormDataChange,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [localFormData, setLocalFormData] = useState<AiClassificationFormData>(formData);
|
||||
const [localErrors, setLocalErrors] = useState<AiClassificationFormErrors>({});
|
||||
|
||||
// 同步外部表单数据
|
||||
useEffect(() => {
|
||||
setLocalFormData(formData);
|
||||
}, [formData]);
|
||||
|
||||
// 同步外部错误
|
||||
useEffect(() => {
|
||||
setLocalErrors(formErrors);
|
||||
}, [formErrors]);
|
||||
|
||||
// 处理输入变化
|
||||
const handleInputChange = (field: keyof AiClassificationFormData, value: string | number) => {
|
||||
const newData = { ...localFormData, [field]: value };
|
||||
setLocalFormData(newData);
|
||||
onFormDataChange(newData);
|
||||
|
||||
// 清除对应字段的错误
|
||||
if (localErrors[field]) {
|
||||
const newErrors = { ...localErrors };
|
||||
delete newErrors[field];
|
||||
setLocalErrors(newErrors);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理表单提交
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 验证表单
|
||||
const errors = validateClassificationForm(localFormData);
|
||||
setLocalErrors(errors);
|
||||
|
||||
if (!hasFormErrors(errors)) {
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理取消
|
||||
const handleCancel = () => {
|
||||
setLocalErrors({});
|
||||
onCancel();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
onClick={handleCancel}
|
||||
/>
|
||||
|
||||
{/* 对话框 */}
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
{/* 标题栏 */}
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
{title}
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="bg-white rounded-md text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表单内容 */}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="bg-white px-4 pb-4 sm:p-6 sm:pt-0">
|
||||
<div className="space-y-4">
|
||||
{/* 通用错误信息 */}
|
||||
{localErrors.general && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">
|
||||
{localErrors.general}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分类名称 */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
分类名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
value={localFormData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
className={`mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm ${
|
||||
localErrors.name ? 'border-red-300' : ''
|
||||
}`}
|
||||
placeholder="请输入分类名称"
|
||||
maxLength={CLASSIFICATION_VALIDATION.NAME_MAX_LENGTH}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{localErrors.name && (
|
||||
<p className="mt-1 text-sm text-red-600">{localErrors.name}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{localFormData.name.length}/{CLASSIFICATION_VALIDATION.NAME_MAX_LENGTH}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 提示词 */}
|
||||
<div>
|
||||
<label htmlFor="prompt_text" className="block text-sm font-medium text-gray-700">
|
||||
提示词 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="prompt_text"
|
||||
rows={4}
|
||||
value={localFormData.prompt_text}
|
||||
onChange={(e) => handleInputChange('prompt_text', e.target.value)}
|
||||
className={`mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm ${
|
||||
localErrors.prompt_text ? 'border-red-300' : ''
|
||||
}`}
|
||||
placeholder="请输入AI分类的提示词,描述什么样的视频属于这个分类"
|
||||
maxLength={CLASSIFICATION_VALIDATION.PROMPT_TEXT_MAX_LENGTH}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{localErrors.prompt_text && (
|
||||
<p className="mt-1 text-sm text-red-600">{localErrors.prompt_text}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{localFormData.prompt_text.length}/{CLASSIFICATION_VALIDATION.PROMPT_TEXT_MAX_LENGTH}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
rows={2}
|
||||
value={localFormData.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
className={`mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm ${
|
||||
localErrors.description ? 'border-red-300' : ''
|
||||
}`}
|
||||
placeholder="可选:添加分类的详细描述"
|
||||
maxLength={CLASSIFICATION_VALIDATION.DESCRIPTION_MAX_LENGTH}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{localErrors.description && (
|
||||
<p className="mt-1 text-sm text-red-600">{localErrors.description}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{localFormData.description.length}/{CLASSIFICATION_VALIDATION.DESCRIPTION_MAX_LENGTH}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 排序顺序 */}
|
||||
<div>
|
||||
<label htmlFor="sort_order" className="block text-sm font-medium text-gray-700">
|
||||
排序顺序
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="sort_order"
|
||||
value={localFormData.sort_order}
|
||||
onChange={(e) => handleInputChange('sort_order', parseInt(e.target.value) || 0)}
|
||||
className={`mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm ${
|
||||
localErrors.sort_order ? 'border-red-300' : ''
|
||||
}`}
|
||||
min={CLASSIFICATION_VALIDATION.MIN_SORT_ORDER}
|
||||
max={CLASSIFICATION_VALIDATION.MAX_SORT_ORDER}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{localErrors.sort_order && (
|
||||
<p className="mt-1 text-sm text-red-600">{localErrors.sort_order}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
数值越小排序越靠前 ({CLASSIFICATION_VALIDATION.MIN_SORT_ORDER}-{CLASSIFICATION_VALIDATION.MAX_SORT_ORDER})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 实时预览 */}
|
||||
<div>
|
||||
<AiClassificationRealTimePreview
|
||||
currentFormData={localFormData}
|
||||
existingClassifications={existingClassifications}
|
||||
isEdit={isEdit}
|
||||
editingClassificationId={editingClassificationId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮栏 */}
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<LoadingSpinner size="small" className="mr-2" />
|
||||
{isEdit ? '更新中...' : '创建中...'}
|
||||
</>
|
||||
) : (
|
||||
isEdit ? '更新' : '创建'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={submitting}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
204
apps/desktop/src/components/AiClassificationPreviewDialog.tsx
Normal file
204
apps/desktop/src/components/AiClassificationPreviewDialog.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React, { useState } from 'react';
|
||||
import { XMarkIcon, ClipboardIcon, CheckIcon } from '@heroicons/react/24/outline';
|
||||
import { AiClassificationPreview } from '../types/aiClassification';
|
||||
import { LoadingSpinner } from './LoadingSpinner';
|
||||
|
||||
interface AiClassificationPreviewDialogProps {
|
||||
/** 是否显示对话框 */
|
||||
isOpen: boolean;
|
||||
/** 预览数据 */
|
||||
preview: AiClassificationPreview | null;
|
||||
/** 是否正在加载 */
|
||||
loading: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI分类预览对话框组件
|
||||
* 遵循前端开发规范的对话框设计,实现提示词实时预览功能
|
||||
*/
|
||||
export const AiClassificationPreviewDialog: React.FC<AiClassificationPreviewDialogProps> = ({
|
||||
isOpen,
|
||||
preview,
|
||||
loading,
|
||||
onClose,
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// 复制到剪贴板
|
||||
const handleCopy = async () => {
|
||||
if (!preview?.full_prompt) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(preview.full_prompt);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('复制失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 对话框 */}
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
|
||||
{/* 标题栏 */}
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
AI分类提示词预览
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
基于当前激活的分类生成的完整提示词
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-white rounded-md text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="bg-white px-4 pb-4 sm:p-6 sm:pt-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingSpinner size="large" />
|
||||
<span className="ml-3 text-gray-600">正在生成预览...</span>
|
||||
</div>
|
||||
) : preview ? (
|
||||
<div className="space-y-6">
|
||||
{/* 分类统计 */}
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-blue-900">
|
||||
激活的分类数量
|
||||
</h4>
|
||||
<p className="text-2xl font-bold text-blue-600">
|
||||
{preview.classifications.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-blue-400">
|
||||
<svg className="h-8 w-8" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类列表 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">
|
||||
当前激活的分类
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{preview.classifications.map((classification, index) => (
|
||||
<div key={classification.id} className="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="flex-shrink-0 w-6 h-6 bg-blue-100 text-blue-800 text-xs font-medium rounded-full flex items-center justify-center">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{classification.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{classification.prompt_text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 完整提示词 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
完整提示词
|
||||
</h4>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="inline-flex items-center px-3 py-1.5 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckIcon className="h-3 w-3 mr-1 text-green-500" />
|
||||
已复制
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ClipboardIcon className="h-3 w-3 mr-1" />
|
||||
复制
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-lg p-4 overflow-auto max-h-96">
|
||||
<pre className="text-sm text-gray-100 whitespace-pre-wrap font-mono">
|
||||
{preview.full_prompt}
|
||||
</pre>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
字符数: {preview.full_prompt.length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<h5 className="text-sm font-medium text-yellow-800 mb-2">
|
||||
使用说明
|
||||
</h5>
|
||||
<ul className="text-sm text-yellow-700 space-y-1">
|
||||
<li>• 此提示词将用于AI视频分类功能</li>
|
||||
<li>• 只有激活状态的分类会包含在提示词中</li>
|
||||
<li>• 分类按照排序顺序显示</li>
|
||||
<li>• 修改分类后需要重新生成预览</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 mb-4">
|
||||
<svg className="mx-auto h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-1">
|
||||
暂无预览数据
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
请先添加一些AI分类
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 按钮栏 */}
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:w-auto sm:text-sm"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
220
apps/desktop/src/components/AiClassificationRealTimePreview.tsx
Normal file
220
apps/desktop/src/components/AiClassificationRealTimePreview.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
AiClassification,
|
||||
AiClassificationFormData
|
||||
} from '../types/aiClassification';
|
||||
|
||||
interface AiClassificationRealTimePreviewProps {
|
||||
/** 当前表单数据 */
|
||||
currentFormData: AiClassificationFormData;
|
||||
/** 现有分类列表 */
|
||||
existingClassifications: AiClassification[];
|
||||
/** 是否为编辑模式 */
|
||||
isEdit?: boolean;
|
||||
/** 编辑中的分类ID */
|
||||
editingClassificationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI分类实时预览组件
|
||||
* 遵循前端开发规范的预览组件设计,实现分类修改时的提示词实时预览
|
||||
*/
|
||||
export const AiClassificationRealTimePreview: React.FC<AiClassificationRealTimePreviewProps> = ({
|
||||
currentFormData,
|
||||
existingClassifications,
|
||||
isEdit = false,
|
||||
editingClassificationId,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// 生成预览用的分类列表
|
||||
const previewClassifications = useMemo(() => {
|
||||
let classifications = [...existingClassifications];
|
||||
|
||||
if (isEdit && editingClassificationId) {
|
||||
// 编辑模式:替换现有分类
|
||||
const index = classifications.findIndex(c => c.id === editingClassificationId);
|
||||
if (index !== -1) {
|
||||
classifications[index] = {
|
||||
...classifications[index],
|
||||
name: currentFormData.name,
|
||||
prompt_text: currentFormData.prompt_text,
|
||||
description: currentFormData.description || undefined,
|
||||
sort_order: currentFormData.sort_order,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// 创建模式:添加新分类
|
||||
if (currentFormData.name.trim() && currentFormData.prompt_text.trim()) {
|
||||
const newClassification: AiClassification = {
|
||||
id: 'preview-new',
|
||||
name: currentFormData.name,
|
||||
prompt_text: currentFormData.prompt_text,
|
||||
description: currentFormData.description || undefined,
|
||||
is_active: true,
|
||||
sort_order: currentFormData.sort_order,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
classifications.push(newClassification);
|
||||
}
|
||||
}
|
||||
|
||||
// 只返回激活的分类,并按排序顺序排列
|
||||
return classifications
|
||||
.filter(c => c.is_active)
|
||||
.sort((a, b) => a.sort_order - b.sort_order);
|
||||
}, [currentFormData, existingClassifications, isEdit, editingClassificationId]);
|
||||
|
||||
// 生成完整提示词
|
||||
const fullPrompt = useMemo(() => {
|
||||
if (previewClassifications.length === 0) {
|
||||
return '暂无激活的分类,无法生成提示词';
|
||||
}
|
||||
|
||||
// 生成分类名称列表
|
||||
const categoryNames = previewClassifications
|
||||
.map(c => c.name)
|
||||
.join('\n - ');
|
||||
|
||||
// 生成详细分类描述
|
||||
const categoriesStr = previewClassifications
|
||||
.map(c => `**${c.name}**: ${c.prompt_text}`)
|
||||
.join('\n - ');
|
||||
|
||||
return `请分析这个视频的内容,并将其分类到以下类别之一:
|
||||
- ${categoryNames}
|
||||
|
||||
请按以下步骤进行分析:
|
||||
|
||||
1. **冲突处理**:
|
||||
- 按最大可见面积分类
|
||||
|
||||
2. **内容分类**(仅对包含目标商品且质量合格的视频):
|
||||
- ${categoriesStr}
|
||||
|
||||
请返回JSON格式的结果:
|
||||
|
||||
{
|
||||
"category": "分类结果",
|
||||
"confidence": 0.85,
|
||||
"reasoning": "详细的分类理由,包括商品匹配情况和内容特征",
|
||||
"features": ["观察到的关键特征1", "关键特征2", "关键特征3"],
|
||||
"product_match": true/false,
|
||||
"quality_score": 0.9
|
||||
}
|
||||
|
||||
**分类优先级**:
|
||||
1. 商品匹配 > 内容分类
|
||||
2. 质量合格 > 内容丰富
|
||||
3. 明确分类 > 模糊归类
|
||||
|
||||
请仔细观察视频内容,确保分类准确性。`;
|
||||
}, [previewClassifications]);
|
||||
|
||||
// 检查是否有变化
|
||||
const hasChanges = useMemo(() => {
|
||||
return currentFormData.name.trim() !== '' || currentFormData.prompt_text.trim() !== '';
|
||||
}, [currentFormData]);
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
{/* 预览标题栏 */}
|
||||
<div
|
||||
className="bg-gray-50 px-4 py-3 cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{isExpanded ? (
|
||||
<EyeIcon className="h-4 w-4 text-gray-500" />
|
||||
) : (
|
||||
<EyeSlashIcon className="h-4 w-4 text-gray-500" />
|
||||
)}
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
实时预览
|
||||
</h4>
|
||||
{hasChanges && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
有变化
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{previewClassifications.length} 个激活分类
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预览内容 */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 分类列表预览 */}
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-gray-700 mb-2">
|
||||
激活的分类 ({previewClassifications.length})
|
||||
</h5>
|
||||
<div className="space-y-1">
|
||||
{previewClassifications.map((classification, index) => (
|
||||
<div
|
||||
key={classification.id}
|
||||
className={`text-xs p-2 rounded ${
|
||||
classification.id === 'preview-new'
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: classification.id === editingClassificationId
|
||||
? 'bg-blue-50 border border-blue-200'
|
||||
: 'bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-medium text-gray-900">
|
||||
{index + 1}. {classification.name}
|
||||
</span>
|
||||
{classification.id === 'preview-new' && (
|
||||
<span className="text-green-600 font-medium">(新增)</span>
|
||||
)}
|
||||
{classification.id === editingClassificationId && (
|
||||
<span className="text-blue-600 font-medium">(编辑中)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-600 mt-1">
|
||||
{classification.prompt_text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 完整提示词预览 */}
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-gray-700 mb-2">
|
||||
完整提示词预览
|
||||
</h5>
|
||||
<div className="bg-gray-900 rounded p-3 max-h-48 overflow-y-auto">
|
||||
<pre className="text-xs text-gray-100 whitespace-pre-wrap font-mono">
|
||||
{fullPrompt}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
字符数: {fullPrompt.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded p-3">
|
||||
<div className="text-xs text-blue-700">
|
||||
<strong>提示:</strong>
|
||||
<ul className="mt-1 space-y-1">
|
||||
<li>• 预览会实时反映您的修改</li>
|
||||
<li>• 只有激活状态的分类会包含在提示词中</li>
|
||||
<li>• 分类按排序顺序显示</li>
|
||||
<li>• 保存后预览将成为实际的提示词</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
111
apps/desktop/src/components/DeleteConfirmDialog.tsx
Normal file
111
apps/desktop/src/components/DeleteConfirmDialog.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
||||
import { LoadingSpinner } from './LoadingSpinner';
|
||||
|
||||
interface DeleteConfirmDialogProps {
|
||||
/** 是否显示对话框 */
|
||||
isOpen: boolean;
|
||||
/** 对话框标题 */
|
||||
title: string;
|
||||
/** 确认消息 */
|
||||
message: string;
|
||||
/** 要删除的项目名称 */
|
||||
itemName?: string;
|
||||
/** 是否正在删除 */
|
||||
deleting: boolean;
|
||||
/** 确认删除回调 */
|
||||
onConfirm: () => void;
|
||||
/** 取消回调 */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除确认对话框组件
|
||||
* 遵循前端开发规范的确认对话框设计,提供安全的删除确认流程
|
||||
*/
|
||||
export const DeleteConfirmDialog: React.FC<DeleteConfirmDialogProps> = ({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
itemName,
|
||||
deleting,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
{/* 背景遮罩 */}
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
onClick={deleting ? undefined : onCancel}
|
||||
/>
|
||||
|
||||
{/* 对话框 */}
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
{/* 警告图标 */}
|
||||
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
{message}
|
||||
</p>
|
||||
{itemName && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-md">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{itemName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-sm text-red-700">
|
||||
<strong>注意:</strong>此操作无法撤销,请确认您要继续。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮栏 */}
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={deleting}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<LoadingSpinner size="small" className="mr-2" />
|
||||
删除中...
|
||||
</>
|
||||
) : (
|
||||
'确认删除'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={deleting}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ interface LoadingSpinnerProps {
|
||||
text?: string;
|
||||
variant?: 'spinner' | 'dots' | 'pulse' | 'bars';
|
||||
color?: 'primary' | 'secondary' | 'white';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -16,7 +17,8 @@ export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
size = 'medium',
|
||||
text,
|
||||
variant = 'spinner',
|
||||
color = 'primary'
|
||||
color = 'primary',
|
||||
className = ''
|
||||
}) => {
|
||||
const sizeMap = {
|
||||
small: { icon: 16, text: 'text-sm', padding: 'p-3' },
|
||||
@@ -79,7 +81,7 @@ export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center gap-4 ${currentSize.padding} animate-fade-in`}>
|
||||
<div className={`flex flex-col items-center justify-center gap-4 ${currentSize.padding} animate-fade-in ${className}`}>
|
||||
{/* 加载动画 */}
|
||||
<div className="relative">
|
||||
{/* 背景光晕效果 */}
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
UserGroupIcon,
|
||||
PhotoIcon,
|
||||
ChartBarIcon,
|
||||
CogIcon
|
||||
CogIcon,
|
||||
CpuChipIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const Navigation: React.FC = () => {
|
||||
@@ -30,6 +31,12 @@ const Navigation: React.FC = () => {
|
||||
icon: PhotoIcon,
|
||||
description: '浏览所有素材'
|
||||
},
|
||||
{
|
||||
name: 'AI分类设置',
|
||||
href: '/ai-classification-settings',
|
||||
icon: CpuChipIcon,
|
||||
description: '管理AI视频分类规则'
|
||||
},
|
||||
{
|
||||
name: '统计分析',
|
||||
href: '/analytics',
|
||||
|
||||
462
apps/desktop/src/pages/AiClassificationSettings.tsx
Normal file
462
apps/desktop/src/pages/AiClassificationSettings.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
EyeIcon,
|
||||
ArrowUpIcon,
|
||||
ArrowDownIcon,
|
||||
CheckIcon,
|
||||
XMarkIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { AiClassificationService } from '../services/aiClassificationService';
|
||||
import {
|
||||
AiClassification,
|
||||
AiClassificationFormData,
|
||||
AiClassificationFormErrors,
|
||||
AiClassificationPreview,
|
||||
DEFAULT_FORM_DATA,
|
||||
validateClassificationForm,
|
||||
hasFormErrors,
|
||||
classificationToFormData,
|
||||
formDataToCreateRequest,
|
||||
formDataToUpdateRequest,
|
||||
} from '../types/aiClassification';
|
||||
import { LoadingSpinner } from '../components/LoadingSpinner';
|
||||
import { ErrorMessage } from '../components/ErrorMessage';
|
||||
import { EmptyState } from '../components/EmptyState';
|
||||
import { AiClassificationFormDialog } from '../components/AiClassificationFormDialog';
|
||||
import { AiClassificationPreviewDialog } from '../components/AiClassificationPreviewDialog';
|
||||
import { DeleteConfirmDialog } from '../components/DeleteConfirmDialog';
|
||||
|
||||
/**
|
||||
* AI分类设置页面
|
||||
* 遵循前端开发规范的组件设计,实现分类的CRUD操作和实时预览
|
||||
*/
|
||||
const AiClassificationSettings: React.FC = () => {
|
||||
// 状态管理
|
||||
const [classifications, setClassifications] = useState<AiClassification[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showPreviewDialog, setShowPreviewDialog] = useState(false);
|
||||
const [editingClassification, setEditingClassification] = useState<AiClassification | null>(null);
|
||||
const [deletingClassificationId, setDeletingClassificationId] = useState<string | null>(null);
|
||||
const [preview, setPreview] = useState<AiClassificationPreview | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState<AiClassificationFormData>(DEFAULT_FORM_DATA);
|
||||
const [formErrors, setFormErrors] = useState<AiClassificationFormErrors>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// 加载分类列表
|
||||
const loadClassifications = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await AiClassificationService.getAllClassificationsIncludingInactive();
|
||||
setClassifications(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载分类列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 生成预览
|
||||
const generatePreview = useCallback(async () => {
|
||||
try {
|
||||
setPreviewLoading(true);
|
||||
const result = await AiClassificationService.generatePreview();
|
||||
setPreview(result);
|
||||
} catch (err) {
|
||||
console.error('生成预览失败:', err);
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
loadClassifications();
|
||||
}, [loadClassifications]);
|
||||
|
||||
// 处理创建分类
|
||||
const handleCreate = async () => {
|
||||
const errors = validateClassificationForm(formData);
|
||||
setFormErrors(errors);
|
||||
|
||||
if (hasFormErrors(errors)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const request = formDataToCreateRequest(formData);
|
||||
await AiClassificationService.createClassification(request);
|
||||
|
||||
// 重新加载列表
|
||||
await loadClassifications();
|
||||
|
||||
// 关闭对话框并重置表单
|
||||
setShowCreateDialog(false);
|
||||
setFormData(DEFAULT_FORM_DATA);
|
||||
setFormErrors({});
|
||||
} catch (err) {
|
||||
setFormErrors({ general: err instanceof Error ? err.message : '创建失败' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理编辑分类
|
||||
const handleEdit = async () => {
|
||||
if (!editingClassification) return;
|
||||
|
||||
const errors = validateClassificationForm(formData);
|
||||
setFormErrors(errors);
|
||||
|
||||
if (hasFormErrors(errors)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const request = formDataToUpdateRequest(formData);
|
||||
await AiClassificationService.updateClassification(editingClassification.id, request);
|
||||
|
||||
// 重新加载列表
|
||||
await loadClassifications();
|
||||
|
||||
// 关闭对话框并重置状态
|
||||
setShowEditDialog(false);
|
||||
setEditingClassification(null);
|
||||
setFormData(DEFAULT_FORM_DATA);
|
||||
setFormErrors({});
|
||||
} catch (err) {
|
||||
setFormErrors({ general: err instanceof Error ? err.message : '更新失败' });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理删除分类
|
||||
const handleDelete = async () => {
|
||||
if (!deletingClassificationId) return;
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await AiClassificationService.deleteClassification(deletingClassificationId);
|
||||
|
||||
// 重新加载列表
|
||||
await loadClassifications();
|
||||
|
||||
// 关闭对话框并重置状态
|
||||
setShowDeleteDialog(false);
|
||||
setDeletingClassificationId(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '删除失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理切换状态
|
||||
const handleToggleStatus = async (id: string) => {
|
||||
try {
|
||||
await AiClassificationService.toggleClassificationStatus(id);
|
||||
await loadClassifications();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '切换状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理移动排序
|
||||
const handleMoveUp = async (classification: AiClassification) => {
|
||||
const currentIndex = classifications.findIndex(c => c.id === classification.id);
|
||||
if (currentIndex <= 0) return;
|
||||
|
||||
const updates = [
|
||||
{ id: classification.id, sort_order: classifications[currentIndex - 1].sort_order },
|
||||
{ id: classifications[currentIndex - 1].id, sort_order: classification.sort_order },
|
||||
];
|
||||
|
||||
try {
|
||||
await AiClassificationService.updateSortOrders(updates);
|
||||
await loadClassifications();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '调整排序失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveDown = async (classification: AiClassification) => {
|
||||
const currentIndex = classifications.findIndex(c => c.id === classification.id);
|
||||
if (currentIndex >= classifications.length - 1) return;
|
||||
|
||||
const updates = [
|
||||
{ id: classification.id, sort_order: classifications[currentIndex + 1].sort_order },
|
||||
{ id: classifications[currentIndex + 1].id, sort_order: classification.sort_order },
|
||||
];
|
||||
|
||||
try {
|
||||
await AiClassificationService.updateSortOrders(updates);
|
||||
await loadClassifications();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '调整排序失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 打开创建对话框
|
||||
const openCreateDialog = () => {
|
||||
setFormData(DEFAULT_FORM_DATA);
|
||||
setFormErrors({});
|
||||
setShowCreateDialog(true);
|
||||
};
|
||||
|
||||
// 打开编辑对话框
|
||||
const openEditDialog = (classification: AiClassification) => {
|
||||
setEditingClassification(classification);
|
||||
setFormData(classificationToFormData(classification));
|
||||
setFormErrors({});
|
||||
setShowEditDialog(true);
|
||||
};
|
||||
|
||||
// 打开删除确认对话框
|
||||
const openDeleteDialog = (id: string) => {
|
||||
setDeletingClassificationId(id);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
// 打开预览对话框
|
||||
const openPreviewDialog = async () => {
|
||||
setShowPreviewDialog(true);
|
||||
await generatePreview();
|
||||
};
|
||||
|
||||
// 渲染加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-96">
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染错误状态
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRetry={loadClassifications}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* 页面标题和操作栏 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">AI分类设置</h1>
|
||||
<p className="text-gray-600 mt-1">管理视频AI分类规则和提示词</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={openPreviewDialog}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<EyeIcon className="h-4 w-4 mr-2" />
|
||||
预览提示词
|
||||
</button>
|
||||
<button
|
||||
onClick={openCreateDialog}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
添加分类
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类列表 */}
|
||||
{classifications.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无AI分类"
|
||||
description="开始创建您的第一个AI分类规则"
|
||||
actionText="添加分类"
|
||||
onAction={openCreateDialog}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
分类列表 ({classifications.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{classifications.map((classification, index) => (
|
||||
<div key={classification.id} className="p-6 hover:bg-gray-50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h4 className="text-lg font-medium text-gray-900">
|
||||
{classification.name}
|
||||
</h4>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
classification.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{classification.is_active ? '激活' : '禁用'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
排序: {classification.sort_order}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-600 line-clamp-2">
|
||||
{classification.prompt_text}
|
||||
</p>
|
||||
{classification.description && (
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{classification.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
创建时间: {new Date(classification.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
{/* 排序按钮 */}
|
||||
<button
|
||||
onClick={() => handleMoveUp(classification)}
|
||||
disabled={index === 0}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="上移"
|
||||
>
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveDown(classification)}
|
||||
disabled={index === classifications.length - 1}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="下移"
|
||||
>
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* 状态切换按钮 */}
|
||||
<button
|
||||
onClick={() => handleToggleStatus(classification.id)}
|
||||
className={`p-1 ${
|
||||
classification.is_active
|
||||
? 'text-green-600 hover:text-green-800'
|
||||
: 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
title={classification.is_active ? '禁用' : '激活'}
|
||||
>
|
||||
{classification.is_active ? (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 编辑按钮 */}
|
||||
<button
|
||||
onClick={() => openEditDialog(classification)}
|
||||
className="p-1 text-blue-600 hover:text-blue-800"
|
||||
title="编辑"
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
onClick={() => openDeleteDialog(classification.id)}
|
||||
className="p-1 text-red-600 hover:text-red-800"
|
||||
title="删除"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 创建分类对话框 */}
|
||||
<AiClassificationFormDialog
|
||||
isOpen={showCreateDialog}
|
||||
title="添加AI分类"
|
||||
formData={formData}
|
||||
formErrors={formErrors}
|
||||
submitting={submitting}
|
||||
existingClassifications={classifications}
|
||||
onFormDataChange={setFormData}
|
||||
onSubmit={handleCreate}
|
||||
onCancel={() => {
|
||||
setShowCreateDialog(false);
|
||||
setFormData(DEFAULT_FORM_DATA);
|
||||
setFormErrors({});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 编辑分类对话框 */}
|
||||
<AiClassificationFormDialog
|
||||
isOpen={showEditDialog}
|
||||
title="编辑AI分类"
|
||||
formData={formData}
|
||||
formErrors={formErrors}
|
||||
submitting={submitting}
|
||||
isEdit={true}
|
||||
existingClassifications={classifications}
|
||||
editingClassificationId={editingClassification?.id}
|
||||
onFormDataChange={setFormData}
|
||||
onSubmit={handleEdit}
|
||||
onCancel={() => {
|
||||
setShowEditDialog(false);
|
||||
setEditingClassification(null);
|
||||
setFormData(DEFAULT_FORM_DATA);
|
||||
setFormErrors({});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<DeleteConfirmDialog
|
||||
isOpen={showDeleteDialog}
|
||||
title="删除AI分类"
|
||||
message="您确定要删除这个AI分类吗?"
|
||||
itemName={deletingClassificationId ?
|
||||
classifications.find(c => c.id === deletingClassificationId)?.name :
|
||||
undefined
|
||||
}
|
||||
deleting={submitting}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => {
|
||||
setShowDeleteDialog(false);
|
||||
setDeletingClassificationId(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 预览对话框 */}
|
||||
<AiClassificationPreviewDialog
|
||||
isOpen={showPreviewDialog}
|
||||
preview={preview}
|
||||
loading={previewLoading}
|
||||
onClose={() => {
|
||||
setShowPreviewDialog(false);
|
||||
setPreview(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiClassificationSettings;
|
||||
@@ -0,0 +1,363 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { AiClassificationService } from '../aiClassificationService';
|
||||
import {
|
||||
AiClassification,
|
||||
CreateAiClassificationRequest,
|
||||
UpdateAiClassificationRequest,
|
||||
AiClassificationQuery,
|
||||
AiClassificationPreview,
|
||||
} from '../../types/aiClassification';
|
||||
|
||||
// Mock Tauri invoke
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockInvoke = vi.mocked(invoke);
|
||||
|
||||
describe('AiClassificationService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockClassification: AiClassification = {
|
||||
id: 'test-id',
|
||||
name: '全身',
|
||||
prompt_text: '头顶到脚底完整入镜,肢体可见度≥90%',
|
||||
description: '全身分类描述',
|
||||
is_active: true,
|
||||
sort_order: 1,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('createClassification', () => {
|
||||
it('should create a classification successfully', async () => {
|
||||
const request: CreateAiClassificationRequest = {
|
||||
name: '全身',
|
||||
prompt_text: '头顶到脚底完整入镜,肢体可见度≥90%',
|
||||
description: '全身分类描述',
|
||||
sort_order: 1,
|
||||
};
|
||||
|
||||
mockInvoke.mockResolvedValue(mockClassification);
|
||||
|
||||
const result = await AiClassificationService.createClassification(request);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('create_ai_classification', { request });
|
||||
expect(result).toEqual(mockClassification);
|
||||
});
|
||||
|
||||
it('should handle creation errors', async () => {
|
||||
const request: CreateAiClassificationRequest = {
|
||||
name: '全身',
|
||||
prompt_text: '头顶到脚底完整入镜',
|
||||
};
|
||||
|
||||
mockInvoke.mockRejectedValue('创建失败');
|
||||
|
||||
await expect(AiClassificationService.createClassification(request))
|
||||
.rejects.toThrow('创建失败');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllClassifications', () => {
|
||||
it('should get all classifications successfully', async () => {
|
||||
const classifications = [mockClassification];
|
||||
mockInvoke.mockResolvedValue(classifications);
|
||||
|
||||
const result = await AiClassificationService.getAllClassifications();
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('get_all_ai_classifications', { query: undefined });
|
||||
expect(result).toEqual(classifications);
|
||||
});
|
||||
|
||||
it('should get classifications with query parameters', async () => {
|
||||
const query: AiClassificationQuery = {
|
||||
active_only: true,
|
||||
sort_by: 'name',
|
||||
sort_order: 'ASC',
|
||||
};
|
||||
const classifications = [mockClassification];
|
||||
mockInvoke.mockResolvedValue(classifications);
|
||||
|
||||
const result = await AiClassificationService.getAllClassifications(query);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('get_all_ai_classifications', { query });
|
||||
expect(result).toEqual(classifications);
|
||||
});
|
||||
|
||||
it('should handle get all errors', async () => {
|
||||
mockInvoke.mockRejectedValue('获取失败');
|
||||
|
||||
await expect(AiClassificationService.getAllClassifications())
|
||||
.rejects.toThrow('获取失败');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClassificationById', () => {
|
||||
it('should get classification by id successfully', async () => {
|
||||
mockInvoke.mockResolvedValue(mockClassification);
|
||||
|
||||
const result = await AiClassificationService.getClassificationById('test-id');
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('get_ai_classification_by_id', { id: 'test-id' });
|
||||
expect(result).toEqual(mockClassification);
|
||||
});
|
||||
|
||||
it('should return null when classification not found', async () => {
|
||||
mockInvoke.mockResolvedValue(null);
|
||||
|
||||
const result = await AiClassificationService.getClassificationById('non-existent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateClassification', () => {
|
||||
it('should update classification successfully', async () => {
|
||||
const request: UpdateAiClassificationRequest = {
|
||||
name: '全身更新',
|
||||
prompt_text: '更新的提示词',
|
||||
};
|
||||
const updatedClassification = { ...mockClassification, ...request };
|
||||
mockInvoke.mockResolvedValue(updatedClassification);
|
||||
|
||||
const result = await AiClassificationService.updateClassification('test-id', request);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('update_ai_classification', {
|
||||
id: 'test-id',
|
||||
request
|
||||
});
|
||||
expect(result).toEqual(updatedClassification);
|
||||
});
|
||||
|
||||
it('should handle update errors', async () => {
|
||||
const request: UpdateAiClassificationRequest = { name: '新名称' };
|
||||
mockInvoke.mockRejectedValue('更新失败');
|
||||
|
||||
await expect(AiClassificationService.updateClassification('test-id', request))
|
||||
.rejects.toThrow('更新失败');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteClassification', () => {
|
||||
it('should delete classification successfully', async () => {
|
||||
mockInvoke.mockResolvedValue(true);
|
||||
|
||||
const result = await AiClassificationService.deleteClassification('test-id');
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('delete_ai_classification', { id: 'test-id' });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when classification not found', async () => {
|
||||
mockInvoke.mockResolvedValue(false);
|
||||
|
||||
const result = await AiClassificationService.deleteClassification('non-existent');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClassificationCount', () => {
|
||||
it('should get classification count successfully', async () => {
|
||||
mockInvoke.mockResolvedValue(5);
|
||||
|
||||
const result = await AiClassificationService.getClassificationCount();
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('get_ai_classification_count', {
|
||||
active_only: undefined
|
||||
});
|
||||
expect(result).toBe(5);
|
||||
});
|
||||
|
||||
it('should get active classification count', async () => {
|
||||
mockInvoke.mockResolvedValue(3);
|
||||
|
||||
const result = await AiClassificationService.getClassificationCount(true);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('get_ai_classification_count', {
|
||||
active_only: true
|
||||
});
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePreview', () => {
|
||||
it('should generate preview successfully', async () => {
|
||||
const preview: AiClassificationPreview = {
|
||||
classifications: [mockClassification],
|
||||
full_prompt: '完整的提示词内容...',
|
||||
};
|
||||
mockInvoke.mockResolvedValue(preview);
|
||||
|
||||
const result = await AiClassificationService.generatePreview();
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('generate_ai_classification_preview');
|
||||
expect(result).toEqual(preview);
|
||||
});
|
||||
|
||||
it('should handle preview generation errors', async () => {
|
||||
mockInvoke.mockRejectedValue('生成预览失败');
|
||||
|
||||
await expect(AiClassificationService.generatePreview())
|
||||
.rejects.toThrow('生成预览失败');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSortOrders', () => {
|
||||
it('should update sort orders successfully', async () => {
|
||||
const updates = [
|
||||
{ id: 'id1', sort_order: 1 },
|
||||
{ id: 'id2', sort_order: 2 },
|
||||
];
|
||||
const updatedClassifications = [mockClassification];
|
||||
mockInvoke.mockResolvedValue(updatedClassifications);
|
||||
|
||||
const result = await AiClassificationService.updateSortOrders(updates);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('update_ai_classification_sort_orders', {
|
||||
updates: [['id1', 1], ['id2', 2]]
|
||||
});
|
||||
expect(result).toEqual(updatedClassifications);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleClassificationStatus', () => {
|
||||
it('should toggle classification status successfully', async () => {
|
||||
const toggledClassification = { ...mockClassification, is_active: false };
|
||||
mockInvoke.mockResolvedValue(toggledClassification);
|
||||
|
||||
const result = await AiClassificationService.toggleClassificationStatus('test-id');
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('toggle_ai_classification_status', {
|
||||
id: 'test-id'
|
||||
});
|
||||
expect(result).toEqual(toggledClassification);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateClassificationName', () => {
|
||||
it('should validate classification name successfully', async () => {
|
||||
mockInvoke.mockResolvedValue(true);
|
||||
|
||||
const result = await AiClassificationService.validateClassificationName('新名称');
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('validate_ai_classification_name', {
|
||||
name: '新名称',
|
||||
exclude_id: undefined
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate with exclude id', async () => {
|
||||
mockInvoke.mockResolvedValue(false);
|
||||
|
||||
const result = await AiClassificationService.validateClassificationName('已存在', 'exclude-id');
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('validate_ai_classification_name', {
|
||||
name: '已存在',
|
||||
exclude_id: 'exclude-id'
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Safe methods', () => {
|
||||
it('should return success response for createClassificationSafe', async () => {
|
||||
const request: CreateAiClassificationRequest = {
|
||||
name: '测试',
|
||||
prompt_text: '测试提示词',
|
||||
};
|
||||
mockInvoke.mockResolvedValue(mockClassification);
|
||||
|
||||
const result = await AiClassificationService.createClassificationSafe(request);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(mockClassification);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return error response for createClassificationSafe', async () => {
|
||||
const request: CreateAiClassificationRequest = {
|
||||
name: '测试',
|
||||
prompt_text: '测试提示词',
|
||||
};
|
||||
mockInvoke.mockRejectedValue('创建失败');
|
||||
|
||||
const result = await AiClassificationService.createClassificationSafe(request);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.data).toBeUndefined();
|
||||
expect(result.error).toBe('创建失败');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utility methods', () => {
|
||||
it('should get active classifications', async () => {
|
||||
const classifications = [mockClassification];
|
||||
mockInvoke.mockResolvedValue(classifications);
|
||||
|
||||
const result = await AiClassificationService.getActiveClassifications();
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('get_all_ai_classifications', {
|
||||
query: {
|
||||
active_only: true,
|
||||
sort_by: 'sort_order',
|
||||
sort_order: 'ASC',
|
||||
}
|
||||
});
|
||||
expect(result).toEqual(classifications);
|
||||
});
|
||||
|
||||
it('should reorder classifications', async () => {
|
||||
const orderedIds = ['id1', 'id2', 'id3'];
|
||||
const updatedClassifications = [mockClassification];
|
||||
mockInvoke.mockResolvedValue(updatedClassifications);
|
||||
|
||||
const result = await AiClassificationService.reorderClassifications(orderedIds);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('update_ai_classification_sort_orders', {
|
||||
updates: [['id1', 1], ['id2', 2], ['id3', 3]]
|
||||
});
|
||||
expect(result).toEqual(updatedClassifications);
|
||||
});
|
||||
|
||||
it('should duplicate classification', async () => {
|
||||
// Mock getting original classification
|
||||
mockInvoke.mockResolvedValueOnce(mockClassification);
|
||||
// Mock creating duplicate
|
||||
const duplicatedClassification = {
|
||||
...mockClassification,
|
||||
id: 'new-id',
|
||||
name: '全身 (副本)'
|
||||
};
|
||||
mockInvoke.mockResolvedValueOnce(duplicatedClassification);
|
||||
|
||||
const result = await AiClassificationService.duplicateClassification('test-id');
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledTimes(2);
|
||||
expect(mockInvoke).toHaveBeenNthCalledWith(1, 'get_ai_classification_by_id', {
|
||||
id: 'test-id'
|
||||
});
|
||||
expect(mockInvoke).toHaveBeenNthCalledWith(2, 'create_ai_classification', {
|
||||
request: {
|
||||
name: '全身 (副本)',
|
||||
prompt_text: mockClassification.prompt_text,
|
||||
description: mockClassification.description,
|
||||
sort_order: mockClassification.sort_order + 1,
|
||||
}
|
||||
});
|
||||
expect(result).toEqual(duplicatedClassification);
|
||||
});
|
||||
|
||||
it('should handle duplicate classification when original not found', async () => {
|
||||
mockInvoke.mockResolvedValue(null);
|
||||
|
||||
await expect(AiClassificationService.duplicateClassification('non-existent'))
|
||||
.rejects.toThrow('要复制的分类不存在');
|
||||
});
|
||||
});
|
||||
});
|
||||
324
apps/desktop/src/services/aiClassificationService.ts
Normal file
324
apps/desktop/src/services/aiClassificationService.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
AiClassification,
|
||||
CreateAiClassificationRequest,
|
||||
UpdateAiClassificationRequest,
|
||||
AiClassificationQuery,
|
||||
AiClassificationPreview,
|
||||
SortOrderUpdate,
|
||||
ApiResponse,
|
||||
} from '../types/aiClassification';
|
||||
|
||||
/**
|
||||
* AI分类服务类
|
||||
* 遵循前端开发规范的服务层设计,封装与后端的通信逻辑
|
||||
*/
|
||||
export class AiClassificationService {
|
||||
/**
|
||||
* 创建AI分类
|
||||
*/
|
||||
static async createClassification(request: CreateAiClassificationRequest): Promise<AiClassification> {
|
||||
try {
|
||||
const result = await invoke<AiClassification>('create_ai_classification', { request });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('创建AI分类失败:', error);
|
||||
throw new Error(typeof error === 'string' ? error : '创建AI分类失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有AI分类
|
||||
*/
|
||||
static async getAllClassifications(query?: AiClassificationQuery): Promise<AiClassification[]> {
|
||||
try {
|
||||
const result = await invoke<AiClassification[]>('get_all_ai_classifications', { query });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('获取AI分类列表失败:', error);
|
||||
throw new Error(typeof error === 'string' ? error : '获取AI分类列表失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取AI分类
|
||||
*/
|
||||
static async getClassificationById(id: string): Promise<AiClassification | null> {
|
||||
try {
|
||||
const result = await invoke<AiClassification | null>('get_ai_classification_by_id', { id });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('获取AI分类详情失败:', error);
|
||||
throw new Error(typeof error === 'string' ? error : '获取AI分类详情失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新AI分类
|
||||
*/
|
||||
static async updateClassification(id: string, request: UpdateAiClassificationRequest): Promise<AiClassification | null> {
|
||||
try {
|
||||
const result = await invoke<AiClassification | null>('update_ai_classification', { id, request });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('更新AI分类失败:', error);
|
||||
throw new Error(typeof error === 'string' ? error : '更新AI分类失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除AI分类
|
||||
*/
|
||||
static async deleteClassification(id: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await invoke<boolean>('delete_ai_classification', { id });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('删除AI分类失败:', error);
|
||||
throw new Error(typeof error === 'string' ? error : '删除AI分类失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取AI分类总数
|
||||
*/
|
||||
static async getClassificationCount(activeOnly?: boolean): Promise<number> {
|
||||
try {
|
||||
const result = await invoke<number>('get_ai_classification_count', { active_only: activeOnly });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('获取AI分类总数失败:', error);
|
||||
throw new Error(typeof error === 'string' ? error : '获取AI分类总数失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成AI分类预览
|
||||
*/
|
||||
static async generatePreview(): Promise<AiClassificationPreview> {
|
||||
try {
|
||||
const result = await invoke<AiClassificationPreview>('generate_ai_classification_preview');
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('生成AI分类预览失败:', error);
|
||||
throw new Error(typeof error === 'string' ? error : '生成AI分类预览失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新分类排序
|
||||
*/
|
||||
static async updateSortOrders(updates: SortOrderUpdate[]): Promise<AiClassification[]> {
|
||||
try {
|
||||
// 转换为后端期望的格式 [id, sort_order]
|
||||
const updateTuples: [string, number][] = updates.map(update => [update.id, update.sort_order]);
|
||||
const result = await invoke<AiClassification[]>('update_ai_classification_sort_orders', { updates: updateTuples });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('批量更新排序失败:', error);
|
||||
throw new Error(typeof error === 'string' ? error : '批量更新排序失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换分类激活状态
|
||||
*/
|
||||
static async toggleClassificationStatus(id: string): Promise<AiClassification | null> {
|
||||
try {
|
||||
const result = await invoke<AiClassification | null>('toggle_ai_classification_status', { id });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('切换分类状态失败:', error);
|
||||
throw new Error(typeof error === 'string' ? error : '切换分类状态失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证分类名称是否可用
|
||||
*/
|
||||
static async validateClassificationName(name: string, excludeId?: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await invoke<boolean>('validate_ai_classification_name', {
|
||||
name,
|
||||
exclude_id: excludeId
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('验证分类名称失败:', error);
|
||||
throw new Error(typeof error === 'string' ? error : '验证分类名称失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取激活的分类列表(用于预览)
|
||||
*/
|
||||
static async getActiveClassifications(): Promise<AiClassification[]> {
|
||||
return this.getAllClassifications({
|
||||
active_only: true,
|
||||
sort_by: 'sort_order',
|
||||
sort_order: 'ASC',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有分类(包括非激活的)
|
||||
*/
|
||||
static async getAllClassificationsIncludingInactive(): Promise<AiClassification[]> {
|
||||
return this.getAllClassifications({
|
||||
active_only: false,
|
||||
sort_by: 'sort_order',
|
||||
sort_order: 'ASC',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建分类并返回包装的响应
|
||||
*/
|
||||
static async createClassificationSafe(request: CreateAiClassificationRequest): Promise<ApiResponse<AiClassification>> {
|
||||
try {
|
||||
const data = await this.createClassification(request);
|
||||
return { data, success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : '创建分类失败',
|
||||
success: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分类并返回包装的响应
|
||||
*/
|
||||
static async updateClassificationSafe(id: string, request: UpdateAiClassificationRequest): Promise<ApiResponse<AiClassification | null>> {
|
||||
try {
|
||||
const data = await this.updateClassification(id, request);
|
||||
return { data, success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : '更新分类失败',
|
||||
success: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分类并返回包装的响应
|
||||
*/
|
||||
static async deleteClassificationSafe(id: string): Promise<ApiResponse<boolean>> {
|
||||
try {
|
||||
const data = await this.deleteClassification(id);
|
||||
return { data, success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : '删除分类失败',
|
||||
success: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分类列表并返回包装的响应
|
||||
*/
|
||||
static async getAllClassificationsSafe(query?: AiClassificationQuery): Promise<ApiResponse<AiClassification[]>> {
|
||||
try {
|
||||
const data = await this.getAllClassifications(query);
|
||||
return { data, success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : '获取分类列表失败',
|
||||
success: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成预览并返回包装的响应
|
||||
*/
|
||||
static async generatePreviewSafe(): Promise<ApiResponse<AiClassificationPreview>> {
|
||||
try {
|
||||
const data = await this.generatePreview();
|
||||
return { data, success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : '生成预览失败',
|
||||
success: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新排序分类
|
||||
* 根据新的顺序数组重新设置所有分类的排序顺序
|
||||
*/
|
||||
static async reorderClassifications(orderedIds: string[]): Promise<AiClassification[]> {
|
||||
const updates: SortOrderUpdate[] = orderedIds.map((id, index) => ({
|
||||
id,
|
||||
sort_order: index + 1,
|
||||
}));
|
||||
|
||||
return this.updateSortOrders(updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制分类
|
||||
* 创建一个现有分类的副本
|
||||
*/
|
||||
static async duplicateClassification(id: string): Promise<AiClassification> {
|
||||
const original = await this.getClassificationById(id);
|
||||
if (!original) {
|
||||
throw new Error('要复制的分类不存在');
|
||||
}
|
||||
|
||||
const request: CreateAiClassificationRequest = {
|
||||
name: `${original.name} (副本)`,
|
||||
prompt_text: original.prompt_text,
|
||||
description: original.description,
|
||||
sort_order: original.sort_order + 1,
|
||||
};
|
||||
|
||||
return this.createClassification(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除分类
|
||||
*/
|
||||
static async deleteMultipleClassifications(ids: string[]): Promise<{ success: string[]; failed: string[] }> {
|
||||
const success: string[] = [];
|
||||
const failed: string[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const result = await this.deleteClassification(id);
|
||||
if (result) {
|
||||
success.push(id);
|
||||
} else {
|
||||
failed.push(id);
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
return { success, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量切换分类状态
|
||||
*/
|
||||
static async toggleMultipleClassificationStatus(ids: string[]): Promise<AiClassification[]> {
|
||||
const results: AiClassification[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const result = await this.toggleClassificationStatus(id);
|
||||
if (result) {
|
||||
results.push(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`切换分类 ${id} 状态失败:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
303
apps/desktop/src/types/__tests__/aiClassification.test.ts
Normal file
303
apps/desktop/src/types/__tests__/aiClassification.test.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
validateClassificationForm,
|
||||
hasFormErrors,
|
||||
classificationToFormData,
|
||||
formDataToCreateRequest,
|
||||
formDataToUpdateRequest,
|
||||
AiClassificationFormData,
|
||||
AiClassification,
|
||||
CLASSIFICATION_VALIDATION,
|
||||
} from '../aiClassification';
|
||||
|
||||
describe('AI Classification Type Functions', () => {
|
||||
const validFormData: AiClassificationFormData = {
|
||||
name: '全身',
|
||||
prompt_text: '头顶到脚底完整入镜,肢体可见度≥90%',
|
||||
description: '全身分类描述',
|
||||
sort_order: 1,
|
||||
};
|
||||
|
||||
const mockClassification: AiClassification = {
|
||||
id: 'test-id',
|
||||
name: '全身',
|
||||
prompt_text: '头顶到脚底完整入镜,肢体可见度≥90%',
|
||||
description: '全身分类描述',
|
||||
is_active: true,
|
||||
sort_order: 1,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('validateClassificationForm', () => {
|
||||
it('should pass validation for valid form data', () => {
|
||||
const errors = validateClassificationForm(validFormData);
|
||||
expect(Object.keys(errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should fail validation for empty name', () => {
|
||||
const formData = { ...validFormData, name: '' };
|
||||
const errors = validateClassificationForm(formData);
|
||||
expect(errors.name).toBe('分类名称不能为空');
|
||||
});
|
||||
|
||||
it('should fail validation for whitespace-only name', () => {
|
||||
const formData = { ...validFormData, name: ' ' };
|
||||
const errors = validateClassificationForm(formData);
|
||||
expect(errors.name).toBe('分类名称不能为空');
|
||||
});
|
||||
|
||||
it('should fail validation for name too long', () => {
|
||||
const formData = {
|
||||
...validFormData,
|
||||
name: 'a'.repeat(CLASSIFICATION_VALIDATION.NAME_MAX_LENGTH + 1)
|
||||
};
|
||||
const errors = validateClassificationForm(formData);
|
||||
expect(errors.name).toBe(`分类名称不能超过${CLASSIFICATION_VALIDATION.NAME_MAX_LENGTH}个字符`);
|
||||
});
|
||||
|
||||
it('should fail validation for empty prompt_text', () => {
|
||||
const formData = { ...validFormData, prompt_text: '' };
|
||||
const errors = validateClassificationForm(formData);
|
||||
expect(errors.prompt_text).toBe('提示词不能为空');
|
||||
});
|
||||
|
||||
it('should fail validation for whitespace-only prompt_text', () => {
|
||||
const formData = { ...validFormData, prompt_text: ' ' };
|
||||
const errors = validateClassificationForm(formData);
|
||||
expect(errors.prompt_text).toBe('提示词不能为空');
|
||||
});
|
||||
|
||||
it('should fail validation for prompt_text too long', () => {
|
||||
const formData = {
|
||||
...validFormData,
|
||||
prompt_text: 'a'.repeat(CLASSIFICATION_VALIDATION.PROMPT_TEXT_MAX_LENGTH + 1)
|
||||
};
|
||||
const errors = validateClassificationForm(formData);
|
||||
expect(errors.prompt_text).toBe(`提示词不能超过${CLASSIFICATION_VALIDATION.PROMPT_TEXT_MAX_LENGTH}个字符`);
|
||||
});
|
||||
|
||||
it('should fail validation for description too long', () => {
|
||||
const formData = {
|
||||
...validFormData,
|
||||
description: 'a'.repeat(CLASSIFICATION_VALIDATION.DESCRIPTION_MAX_LENGTH + 1)
|
||||
};
|
||||
const errors = validateClassificationForm(formData);
|
||||
expect(errors.description).toBe(`描述不能超过${CLASSIFICATION_VALIDATION.DESCRIPTION_MAX_LENGTH}个字符`);
|
||||
});
|
||||
|
||||
it('should pass validation for empty description', () => {
|
||||
const formData = { ...validFormData, description: '' };
|
||||
const errors = validateClassificationForm(formData);
|
||||
expect(errors.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fail validation for sort_order too small', () => {
|
||||
const formData = {
|
||||
...validFormData,
|
||||
sort_order: CLASSIFICATION_VALIDATION.MIN_SORT_ORDER - 1
|
||||
};
|
||||
const errors = validateClassificationForm(formData);
|
||||
expect(errors.sort_order).toBe(
|
||||
`排序顺序必须在${CLASSIFICATION_VALIDATION.MIN_SORT_ORDER}-${CLASSIFICATION_VALIDATION.MAX_SORT_ORDER}之间`
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail validation for sort_order too large', () => {
|
||||
const formData = {
|
||||
...validFormData,
|
||||
sort_order: CLASSIFICATION_VALIDATION.MAX_SORT_ORDER + 1
|
||||
};
|
||||
const errors = validateClassificationForm(formData);
|
||||
expect(errors.sort_order).toBe(
|
||||
`排序顺序必须在${CLASSIFICATION_VALIDATION.MIN_SORT_ORDER}-${CLASSIFICATION_VALIDATION.MAX_SORT_ORDER}之间`
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass validation for boundary sort_order values', () => {
|
||||
const minFormData = {
|
||||
...validFormData,
|
||||
sort_order: CLASSIFICATION_VALIDATION.MIN_SORT_ORDER
|
||||
};
|
||||
const maxFormData = {
|
||||
...validFormData,
|
||||
sort_order: CLASSIFICATION_VALIDATION.MAX_SORT_ORDER
|
||||
};
|
||||
|
||||
const minErrors = validateClassificationForm(minFormData);
|
||||
const maxErrors = validateClassificationForm(maxFormData);
|
||||
|
||||
expect(minErrors.sort_order).toBeUndefined();
|
||||
expect(maxErrors.sort_order).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return multiple errors for multiple invalid fields', () => {
|
||||
const formData: AiClassificationFormData = {
|
||||
name: '',
|
||||
prompt_text: '',
|
||||
description: 'a'.repeat(CLASSIFICATION_VALIDATION.DESCRIPTION_MAX_LENGTH + 1),
|
||||
sort_order: -1,
|
||||
};
|
||||
|
||||
const errors = validateClassificationForm(formData);
|
||||
|
||||
expect(errors.name).toBe('分类名称不能为空');
|
||||
expect(errors.prompt_text).toBe('提示词不能为空');
|
||||
expect(errors.description).toBe(`描述不能超过${CLASSIFICATION_VALIDATION.DESCRIPTION_MAX_LENGTH}个字符`);
|
||||
expect(errors.sort_order).toBe(
|
||||
`排序顺序必须在${CLASSIFICATION_VALIDATION.MIN_SORT_ORDER}-${CLASSIFICATION_VALIDATION.MAX_SORT_ORDER}之间`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasFormErrors', () => {
|
||||
it('should return false for empty errors object', () => {
|
||||
expect(hasFormErrors({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for errors object with errors', () => {
|
||||
expect(hasFormErrors({ name: '名称错误' })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for multiple errors', () => {
|
||||
expect(hasFormErrors({
|
||||
name: '名称错误',
|
||||
prompt_text: '提示词错误'
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classificationToFormData', () => {
|
||||
it('should convert classification to form data correctly', () => {
|
||||
const formData = classificationToFormData(mockClassification);
|
||||
|
||||
expect(formData).toEqual({
|
||||
name: mockClassification.name,
|
||||
prompt_text: mockClassification.prompt_text,
|
||||
description: mockClassification.description,
|
||||
sort_order: mockClassification.sort_order,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle classification with undefined description', () => {
|
||||
const classificationWithoutDescription = {
|
||||
...mockClassification,
|
||||
description: undefined,
|
||||
};
|
||||
|
||||
const formData = classificationToFormData(classificationWithoutDescription);
|
||||
|
||||
expect(formData.description).toBe('');
|
||||
});
|
||||
|
||||
it('should handle classification with null description', () => {
|
||||
const classificationWithNullDescription = {
|
||||
...mockClassification,
|
||||
description: null as any,
|
||||
};
|
||||
|
||||
const formData = classificationToFormData(classificationWithNullDescription);
|
||||
|
||||
expect(formData.description).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formDataToCreateRequest', () => {
|
||||
it('should convert form data to create request correctly', () => {
|
||||
const request = formDataToCreateRequest(validFormData);
|
||||
|
||||
expect(request).toEqual({
|
||||
name: validFormData.name.trim(),
|
||||
prompt_text: validFormData.prompt_text.trim(),
|
||||
description: validFormData.description.trim(),
|
||||
sort_order: validFormData.sort_order,
|
||||
});
|
||||
});
|
||||
|
||||
it('should trim whitespace from string fields', () => {
|
||||
const formDataWithWhitespace = {
|
||||
...validFormData,
|
||||
name: ' 全身 ',
|
||||
prompt_text: ' 提示词内容 ',
|
||||
description: ' 描述内容 ',
|
||||
};
|
||||
|
||||
const request = formDataToCreateRequest(formDataWithWhitespace);
|
||||
|
||||
expect(request.name).toBe('全身');
|
||||
expect(request.prompt_text).toBe('提示词内容');
|
||||
expect(request.description).toBe('描述内容');
|
||||
});
|
||||
|
||||
it('should set description to undefined for empty string', () => {
|
||||
const formDataWithEmptyDescription = {
|
||||
...validFormData,
|
||||
description: '',
|
||||
};
|
||||
|
||||
const request = formDataToCreateRequest(formDataWithEmptyDescription);
|
||||
|
||||
expect(request.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set description to undefined for whitespace-only string', () => {
|
||||
const formDataWithWhitespaceDescription = {
|
||||
...validFormData,
|
||||
description: ' ',
|
||||
};
|
||||
|
||||
const request = formDataToCreateRequest(formDataWithWhitespaceDescription);
|
||||
|
||||
expect(request.description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formDataToUpdateRequest', () => {
|
||||
it('should convert form data to update request correctly', () => {
|
||||
const request = formDataToUpdateRequest(validFormData);
|
||||
|
||||
expect(request).toEqual({
|
||||
name: validFormData.name.trim(),
|
||||
prompt_text: validFormData.prompt_text.trim(),
|
||||
description: validFormData.description.trim(),
|
||||
sort_order: validFormData.sort_order,
|
||||
});
|
||||
});
|
||||
|
||||
it('should trim whitespace from string fields', () => {
|
||||
const formDataWithWhitespace = {
|
||||
...validFormData,
|
||||
name: ' 全身 ',
|
||||
prompt_text: ' 提示词内容 ',
|
||||
description: ' 描述内容 ',
|
||||
};
|
||||
|
||||
const request = formDataToUpdateRequest(formDataWithWhitespace);
|
||||
|
||||
expect(request.name).toBe('全身');
|
||||
expect(request.prompt_text).toBe('提示词内容');
|
||||
expect(request.description).toBe('描述内容');
|
||||
});
|
||||
|
||||
it('should set description to undefined for empty string', () => {
|
||||
const formDataWithEmptyDescription = {
|
||||
...validFormData,
|
||||
description: '',
|
||||
};
|
||||
|
||||
const request = formDataToUpdateRequest(formDataWithEmptyDescription);
|
||||
|
||||
expect(request.description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLASSIFICATION_VALIDATION constants', () => {
|
||||
it('should have correct validation constants', () => {
|
||||
expect(CLASSIFICATION_VALIDATION.NAME_MAX_LENGTH).toBe(100);
|
||||
expect(CLASSIFICATION_VALIDATION.PROMPT_TEXT_MAX_LENGTH).toBe(1000);
|
||||
expect(CLASSIFICATION_VALIDATION.DESCRIPTION_MAX_LENGTH).toBe(500);
|
||||
expect(CLASSIFICATION_VALIDATION.MIN_SORT_ORDER).toBe(0);
|
||||
expect(CLASSIFICATION_VALIDATION.MAX_SORT_ORDER).toBe(9999);
|
||||
});
|
||||
});
|
||||
});
|
||||
307
apps/desktop/src/types/aiClassification.ts
Normal file
307
apps/desktop/src/types/aiClassification.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* AI分类相关类型定义
|
||||
* 遵循前端开发规范的类型安全设计
|
||||
*/
|
||||
|
||||
/**
|
||||
* AI分类数据模型
|
||||
*/
|
||||
export interface AiClassification {
|
||||
/** 分类唯一标识符 */
|
||||
id: string;
|
||||
/** 分类名称 */
|
||||
name: string;
|
||||
/** 分类提示词 */
|
||||
prompt_text: string;
|
||||
/** 分类描述 */
|
||||
description?: string;
|
||||
/** 是否激活 */
|
||||
is_active: boolean;
|
||||
/** 排序顺序 */
|
||||
sort_order: number;
|
||||
/** 创建时间 */
|
||||
created_at: string;
|
||||
/** 更新时间 */
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建AI分类请求
|
||||
*/
|
||||
export interface CreateAiClassificationRequest {
|
||||
/** 分类名称 */
|
||||
name: string;
|
||||
/** 分类提示词 */
|
||||
prompt_text: string;
|
||||
/** 分类描述 */
|
||||
description?: string;
|
||||
/** 排序顺序 */
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新AI分类请求
|
||||
*/
|
||||
export interface UpdateAiClassificationRequest {
|
||||
/** 分类名称 */
|
||||
name?: string;
|
||||
/** 分类提示词 */
|
||||
prompt_text?: string;
|
||||
/** 分类描述 */
|
||||
description?: string;
|
||||
/** 是否激活 */
|
||||
is_active?: boolean;
|
||||
/** 排序顺序 */
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI分类查询参数
|
||||
*/
|
||||
export interface AiClassificationQuery {
|
||||
/** 是否只查询激活的分类 */
|
||||
active_only?: boolean;
|
||||
/** 排序字段 */
|
||||
sort_by?: string;
|
||||
/** 排序方向 */
|
||||
sort_order?: string;
|
||||
/** 分页大小 */
|
||||
limit?: number;
|
||||
/** 分页偏移 */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI分类预览数据
|
||||
*/
|
||||
export interface AiClassificationPreview {
|
||||
/** 分类列表 */
|
||||
classifications: AiClassification[];
|
||||
/** 生成的完整提示词 */
|
||||
full_prompt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类表单数据
|
||||
*/
|
||||
export interface AiClassificationFormData {
|
||||
/** 分类名称 */
|
||||
name: string;
|
||||
/** 分类提示词 */
|
||||
prompt_text: string;
|
||||
/** 分类描述 */
|
||||
description: string;
|
||||
/** 排序顺序 */
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类表单验证错误
|
||||
*/
|
||||
export interface AiClassificationFormErrors {
|
||||
/** 名称错误 */
|
||||
name?: string;
|
||||
/** 提示词错误 */
|
||||
prompt_text?: string;
|
||||
/** 描述错误 */
|
||||
description?: string;
|
||||
/** 排序顺序错误 */
|
||||
sort_order?: string;
|
||||
/** 通用错误 */
|
||||
general?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类操作状态
|
||||
*/
|
||||
export interface AiClassificationState {
|
||||
/** 分类列表 */
|
||||
classifications: AiClassification[];
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
/** 错误信息 */
|
||||
error: string | null;
|
||||
/** 当前编辑的分类 */
|
||||
editingClassification: AiClassification | null;
|
||||
/** 是否显示创建对话框 */
|
||||
showCreateDialog: boolean;
|
||||
/** 是否显示编辑对话框 */
|
||||
showEditDialog: boolean;
|
||||
/** 是否显示删除确认对话框 */
|
||||
showDeleteDialog: boolean;
|
||||
/** 待删除的分类ID */
|
||||
deletingClassificationId: string | null;
|
||||
/** 预览数据 */
|
||||
preview: AiClassificationPreview | null;
|
||||
/** 预览加载状态 */
|
||||
previewLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类操作类型
|
||||
*/
|
||||
export type AiClassificationAction =
|
||||
| { type: 'SET_LOADING'; payload: boolean }
|
||||
| { type: 'SET_ERROR'; payload: string | null }
|
||||
| { type: 'SET_CLASSIFICATIONS'; payload: AiClassification[] }
|
||||
| { type: 'ADD_CLASSIFICATION'; payload: AiClassification }
|
||||
| { type: 'UPDATE_CLASSIFICATION'; payload: AiClassification }
|
||||
| { type: 'DELETE_CLASSIFICATION'; payload: string }
|
||||
| { type: 'SET_EDITING_CLASSIFICATION'; payload: AiClassification | null }
|
||||
| { type: 'SET_SHOW_CREATE_DIALOG'; payload: boolean }
|
||||
| { type: 'SET_SHOW_EDIT_DIALOG'; payload: boolean }
|
||||
| { type: 'SET_SHOW_DELETE_DIALOG'; payload: boolean }
|
||||
| { type: 'SET_DELETING_CLASSIFICATION_ID'; payload: string | null }
|
||||
| { type: 'SET_PREVIEW'; payload: AiClassificationPreview | null }
|
||||
| { type: 'SET_PREVIEW_LOADING'; payload: boolean }
|
||||
| { type: 'RESET_STATE' };
|
||||
|
||||
/**
|
||||
* 分类排序更新项
|
||||
*/
|
||||
export interface SortOrderUpdate {
|
||||
/** 分类ID */
|
||||
id: string;
|
||||
/** 新的排序顺序 */
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* API响应包装器
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
/** 响应数据 */
|
||||
data?: T;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类验证规则
|
||||
*/
|
||||
export const CLASSIFICATION_VALIDATION = {
|
||||
/** 名称最大长度 */
|
||||
NAME_MAX_LENGTH: 100,
|
||||
/** 提示词最大长度 */
|
||||
PROMPT_TEXT_MAX_LENGTH: 1000,
|
||||
/** 描述最大长度 */
|
||||
DESCRIPTION_MAX_LENGTH: 500,
|
||||
/** 最小排序顺序 */
|
||||
MIN_SORT_ORDER: 0,
|
||||
/** 最大排序顺序 */
|
||||
MAX_SORT_ORDER: 9999,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 默认分类查询参数
|
||||
*/
|
||||
export const DEFAULT_CLASSIFICATION_QUERY: AiClassificationQuery = {
|
||||
active_only: true,
|
||||
sort_by: 'sort_order',
|
||||
sort_order: 'ASC',
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认表单数据
|
||||
*/
|
||||
export const DEFAULT_FORM_DATA: AiClassificationFormData = {
|
||||
name: '',
|
||||
prompt_text: '',
|
||||
description: '',
|
||||
sort_order: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始状态
|
||||
*/
|
||||
export const INITIAL_CLASSIFICATION_STATE: AiClassificationState = {
|
||||
classifications: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
editingClassification: null,
|
||||
showCreateDialog: false,
|
||||
showEditDialog: false,
|
||||
showDeleteDialog: false,
|
||||
deletingClassificationId: null,
|
||||
preview: null,
|
||||
previewLoading: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* 分类表单验证函数
|
||||
*/
|
||||
export const validateClassificationForm = (data: AiClassificationFormData): AiClassificationFormErrors => {
|
||||
const errors: AiClassificationFormErrors = {};
|
||||
|
||||
// 验证名称
|
||||
if (!data.name.trim()) {
|
||||
errors.name = '分类名称不能为空';
|
||||
} else if (data.name.length > CLASSIFICATION_VALIDATION.NAME_MAX_LENGTH) {
|
||||
errors.name = `分类名称不能超过${CLASSIFICATION_VALIDATION.NAME_MAX_LENGTH}个字符`;
|
||||
}
|
||||
|
||||
// 验证提示词
|
||||
if (!data.prompt_text.trim()) {
|
||||
errors.prompt_text = '提示词不能为空';
|
||||
} else if (data.prompt_text.length > CLASSIFICATION_VALIDATION.PROMPT_TEXT_MAX_LENGTH) {
|
||||
errors.prompt_text = `提示词不能超过${CLASSIFICATION_VALIDATION.PROMPT_TEXT_MAX_LENGTH}个字符`;
|
||||
}
|
||||
|
||||
// 验证描述
|
||||
if (data.description && data.description.length > CLASSIFICATION_VALIDATION.DESCRIPTION_MAX_LENGTH) {
|
||||
errors.description = `描述不能超过${CLASSIFICATION_VALIDATION.DESCRIPTION_MAX_LENGTH}个字符`;
|
||||
}
|
||||
|
||||
// 验证排序顺序
|
||||
if (data.sort_order < CLASSIFICATION_VALIDATION.MIN_SORT_ORDER ||
|
||||
data.sort_order > CLASSIFICATION_VALIDATION.MAX_SORT_ORDER) {
|
||||
errors.sort_order = `排序顺序必须在${CLASSIFICATION_VALIDATION.MIN_SORT_ORDER}-${CLASSIFICATION_VALIDATION.MAX_SORT_ORDER}之间`;
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查表单是否有错误
|
||||
*/
|
||||
export const hasFormErrors = (errors: AiClassificationFormErrors): boolean => {
|
||||
return Object.keys(errors).length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将分类转换为表单数据
|
||||
*/
|
||||
export const classificationToFormData = (classification: AiClassification): AiClassificationFormData => {
|
||||
return {
|
||||
name: classification.name,
|
||||
prompt_text: classification.prompt_text,
|
||||
description: classification.description || '',
|
||||
sort_order: classification.sort_order,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 将表单数据转换为创建请求
|
||||
*/
|
||||
export const formDataToCreateRequest = (data: AiClassificationFormData): CreateAiClassificationRequest => {
|
||||
return {
|
||||
name: data.name.trim(),
|
||||
prompt_text: data.prompt_text.trim(),
|
||||
description: data.description.trim() || undefined,
|
||||
sort_order: data.sort_order,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 将表单数据转换为更新请求
|
||||
*/
|
||||
export const formDataToUpdateRequest = (data: AiClassificationFormData): UpdateAiClassificationRequest => {
|
||||
return {
|
||||
name: data.name.trim(),
|
||||
prompt_text: data.prompt_text.trim(),
|
||||
description: data.description.trim() || undefined,
|
||||
sort_order: data.sort_order,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user