diff --git a/apps/desktop/src-tauri/src/business/services/ai_analysis_log_service.rs b/apps/desktop/src-tauri/src/business/services/ai_analysis_log_service.rs new file mode 100644 index 0000000..8e63c96 --- /dev/null +++ b/apps/desktop/src-tauri/src/business/services/ai_analysis_log_service.rs @@ -0,0 +1,302 @@ +use crate::data::models::video_classification::*; +use crate::data::repositories::video_classification_repository::VideoClassificationRepository; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use chrono::{DateTime, Utc}; + +/// AI分析日志查询参数 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AiAnalysisLogQuery { + /// 项目ID + pub project_id: String, + /// 日志类型(records或tasks) + pub log_type: String, + /// 状态过滤 + pub status_filter: Option, + /// 搜索关键词 + pub search_keyword: Option, + /// 页码(从1开始) + pub page: u32, + /// 每页大小 + pub page_size: u32, +} + +impl Default for AiAnalysisLogQuery { + fn default() -> Self { + Self { + project_id: String::new(), + log_type: "records".to_string(), + status_filter: None, + search_keyword: None, + page: 1, + page_size: 20, + } + } +} + +/// AI分析日志响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AiAnalysisLogResponse { + /// 日志数据 + pub logs: Vec, + /// 总数 + pub total_count: u64, + /// 当前页 + pub current_page: u32, + /// 每页大小 + pub page_size: u32, + /// 总页数 + pub total_pages: u32, +} + +/// AI分析日志项 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AiAnalysisLogItem { + /// 日志ID + pub id: String, + /// 日志类型 + pub log_type: String, + /// 标题 + pub title: String, + /// 状态 + pub status: String, + /// 状态显示名称 + pub status_display: String, + /// 详细信息 + pub details: String, + /// 错误信息 + pub error_message: Option, + /// 置信度(仅分类记录) + pub confidence: Option, + /// 质量评分(仅分类记录) + pub quality_score: Option, + /// 分类结果(仅分类记录) + pub category: Option, + /// 视频文件路径(仅任务记录) + pub video_file_path: Option, + /// 重试次数(仅任务记录) + pub retry_count: Option, + /// 创建时间 + pub created_at: DateTime, + /// 更新时间 + pub updated_at: DateTime, +} + +/// AI分析日志统计 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AiAnalysisLogStats { + /// 总记录数 + pub total_records: u64, + /// 成功分类数 + pub successful_classifications: u64, + /// 失败分类数 + pub failed_classifications: u64, + /// 需要审核数 + pub needs_review: u64, + /// 总任务数 + pub total_tasks: u64, + /// 完成任务数 + pub completed_tasks: u64, + /// 失败任务数 + pub failed_tasks: u64, + /// 处理中任务数 + pub processing_tasks: u64, + /// 平均置信度 + pub average_confidence: f64, + /// 平均质量评分 + pub average_quality_score: f64, + /// 最近24小时活动数 + pub recent_24h_activity: u64, +} + +/// AI分析日志服务 +/// 遵循 Tauri 开发规范的业务逻辑层设计模式 +pub struct AiAnalysisLogService { + video_repo: Arc, +} + +impl AiAnalysisLogService { + /// 创建新的AI分析日志服务实例 + pub fn new(video_repo: Arc) -> Self { + Self { video_repo } + } + + /// 获取AI分析日志 + pub async fn get_analysis_logs(&self, query: AiAnalysisLogQuery) -> Result { + let logs = match query.log_type.as_str() { + "records" => self.get_classification_records(&query).await?, + "tasks" => self.get_classification_tasks(&query).await?, + _ => return Err(anyhow::anyhow!("不支持的日志类型: {}", query.log_type)), + }; + + let total_pages = ((logs.1 as f64) / (query.page_size as f64)).ceil() as u32; + + Ok(AiAnalysisLogResponse { + logs: logs.0, + total_count: logs.1, + current_page: query.page, + page_size: query.page_size, + total_pages, + }) + } + + /// 获取分类记录日志 + async fn get_classification_records(&self, query: &AiAnalysisLogQuery) -> Result<(Vec, u64)> { + let status_filter = query.status_filter.as_ref().and_then(|s| { + match s.as_str() { + "Classified" => Some(ClassificationStatus::Classified), + "Failed" => Some(ClassificationStatus::Failed), + "NeedsReview" => Some(ClassificationStatus::NeedsReview), + _ => None, + } + }); + + let (records, total_count) = self.video_repo.get_by_project_id_with_pagination( + &query.project_id, + query.page, + query.page_size, + status_filter.as_ref(), + query.search_keyword.as_deref(), + ).await?; + + let logs = records.into_iter().map(|record| { + let (status_display, details) = match record.status { + ClassificationStatus::Classified => { + ("分类成功".to_string(), format!("分类结果: {} (置信度: {:.1}%, 质量: {:.1}/10)", + record.category, record.confidence * 100.0, record.quality_score * 10.0)) + }, + ClassificationStatus::Failed => { + ("分类失败".to_string(), record.error_message.clone().unwrap_or_else(|| "未知错误".to_string())) + }, + ClassificationStatus::NeedsReview => { + ("需要审核".to_string(), format!("低置信度或质量评分 (置信度: {:.1}%, 质量: {:.1}/10)", + record.confidence * 100.0, record.quality_score * 10.0)) + }, + }; + + AiAnalysisLogItem { + id: record.id, + log_type: "record".to_string(), + title: format!("视频分类 - {}", record.category), + status: serde_json::to_string(&record.status).unwrap_or_default(), + status_display, + details, + error_message: record.error_message, + confidence: Some(record.confidence), + quality_score: Some(record.quality_score), + category: Some(record.category), + video_file_path: None, + retry_count: None, + created_at: record.created_at, + updated_at: record.updated_at, + } + }).collect(); + + Ok((logs, total_count)) + } + + /// 获取分类任务日志 + async fn get_classification_tasks(&self, query: &AiAnalysisLogQuery) -> Result<(Vec, u64)> { + let status_filter = query.status_filter.as_ref().and_then(|s| { + match s.as_str() { + "Pending" => Some(TaskStatus::Pending), + "Uploading" => Some(TaskStatus::Uploading), + "Analyzing" => Some(TaskStatus::Analyzing), + "Completed" => Some(TaskStatus::Completed), + "Failed" => Some(TaskStatus::Failed), + "Cancelled" => Some(TaskStatus::Cancelled), + _ => None, + } + }); + + let (tasks, total_count) = self.video_repo.get_tasks_by_project_id_with_pagination( + &query.project_id, + query.page, + query.page_size, + status_filter.as_ref(), + query.search_keyword.as_deref(), + ).await?; + + let logs = tasks.into_iter().map(|task| { + let (status_display, details) = match task.status { + TaskStatus::Pending => ("等待处理".to_string(), "任务已创建,等待处理".to_string()), + TaskStatus::Uploading => ("上传中".to_string(), "正在上传视频到Gemini".to_string()), + TaskStatus::Analyzing => ("分析中".to_string(), "正在进行AI分析".to_string()), + TaskStatus::Completed => ("已完成".to_string(), "任务处理完成".to_string()), + TaskStatus::Failed => { + ("处理失败".to_string(), task.error_message.clone().unwrap_or_else(|| "未知错误".to_string())) + }, + TaskStatus::Cancelled => ("已取消".to_string(), "任务已被取消".to_string()), + }; + + let file_name = std::path::Path::new(&task.video_file_path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&task.video_file_path); + + AiAnalysisLogItem { + id: task.id, + log_type: "task".to_string(), + title: format!("分类任务 - {}", file_name), + status: serde_json::to_string(&task.status).unwrap_or_default(), + status_display, + details, + error_message: task.error_message, + confidence: None, + quality_score: None, + category: None, + video_file_path: Some(task.video_file_path), + retry_count: Some(task.retry_count), + created_at: task.created_at, + updated_at: task.updated_at, + } + }).collect(); + + Ok((logs, total_count)) + } + + /// 获取AI分析统计信息 + pub async fn get_analysis_stats(&self, project_id: &str) -> Result { + // 获取分类统计 + let classification_stats = self.video_repo.get_classification_stats(Some(project_id)).await?; + + // 计算最近24小时活动(这里简化处理,实际应该查询数据库) + let recent_24h_activity = 0; // TODO: 实现24小时内的活动统计 + + Ok(AiAnalysisLogStats { + total_records: classification_stats.total_classifications as u64, + successful_classifications: (classification_stats.total_classifications - classification_stats.failed_tasks) as u64, + failed_classifications: classification_stats.failed_tasks as u64, + needs_review: 0, // TODO: 从数据库查询需要审核的数量 + total_tasks: classification_stats.total_tasks as u64, + completed_tasks: classification_stats.completed_tasks as u64, + failed_tasks: classification_stats.failed_tasks as u64, + processing_tasks: classification_stats.processing_tasks as u64, + average_confidence: classification_stats.average_confidence, + average_quality_score: classification_stats.average_quality_score, + recent_24h_activity, + }) + } + + /// 导出分析日志 + pub async fn export_analysis_logs(&self, project_id: &str, format: &str) -> Result { + match format { + "csv" => self.export_to_csv(project_id).await, + "json" => self.export_to_json(project_id).await, + _ => Err(anyhow::anyhow!("不支持的导出格式: {}", format)), + } + } + + /// 导出为CSV格式 + async fn export_to_csv(&self, project_id: &str) -> Result { + // TODO: 实现CSV导出 + Ok(format!("CSV导出功能待实现 - 项目ID: {}", project_id)) + } + + /// 导出为JSON格式 + async fn export_to_json(&self, project_id: &str) -> Result { + // TODO: 实现JSON导出 + Ok(format!("JSON导出功能待实现 - 项目ID: {}", project_id)) + } +} diff --git a/apps/desktop/src-tauri/src/business/services/mod.rs b/apps/desktop/src-tauri/src/business/services/mod.rs index a4d6e85..0943e56 100644 --- a/apps/desktop/src-tauri/src/business/services/mod.rs +++ b/apps/desktop/src-tauri/src/business/services/mod.rs @@ -5,3 +5,4 @@ pub mod async_material_service; pub mod ai_classification_service; pub mod video_classification_service; pub mod video_classification_queue; +pub mod ai_analysis_log_service; diff --git a/apps/desktop/src-tauri/src/data/repositories/video_classification_repository.rs b/apps/desktop/src-tauri/src/data/repositories/video_classification_repository.rs index ee22f5d..761df41 100644 --- a/apps/desktop/src-tauri/src/data/repositories/video_classification_repository.rs +++ b/apps/desktop/src-tauri/src/data/repositories/video_classification_repository.rs @@ -144,6 +144,97 @@ impl VideoClassificationRepository { Ok(records) } + /// 根据项目ID获取分类记录(支持分页和过滤) + pub async fn get_by_project_id_with_pagination( + &self, + project_id: &str, + page: u32, + page_size: u32, + status_filter: Option<&ClassificationStatus>, + search_keyword: Option<&str> + ) -> Result<(Vec, u64)> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + // 直接查询项目的所有分类记录 + let mut stmt = conn.prepare( + "SELECT id, segment_id, material_id, project_id, category, confidence, reasoning, + features, product_match, quality_score, gemini_file_uri, raw_response, + status, error_message, created_at, updated_at + FROM video_classification_records WHERE project_id = ?1 ORDER BY created_at DESC" + )?; + + let rows = stmt.query_map([project_id], |row| { + let features_json: String = row.get(7)?; + let features: Vec = serde_json::from_str(&features_json).unwrap_or_default(); + + let status_json: String = row.get(12)?; + let status: ClassificationStatus = serde_json::from_str(&status_json).unwrap_or_default(); + + Ok(VideoClassificationRecord { + id: row.get(0)?, + segment_id: row.get(1)?, + material_id: row.get(2)?, + project_id: row.get(3)?, + category: row.get(4)?, + confidence: row.get(5)?, + reasoning: row.get(6)?, + features, + product_match: row.get(8)?, + quality_score: row.get(9)?, + gemini_file_uri: row.get(10)?, + raw_response: row.get(11)?, + status, + error_message: row.get(13)?, + created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(14)?).map_err(|_e| rusqlite::Error::InvalidColumnType(14, "created_at".to_string(), rusqlite::types::Type::Text))?.with_timezone(&Utc), + updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(15)?).map_err(|_e| rusqlite::Error::InvalidColumnType(15, "updated_at".to_string(), rusqlite::types::Type::Text))?.with_timezone(&Utc), + }) + })?; + + let mut project_records = Vec::new(); + for row in rows { + project_records.push(row?); + } + + // 在内存中应用过滤 + let filtered_records: Vec = project_records.into_iter() + .filter(|record| { + // 状态过滤 + if let Some(filter_status) = status_filter { + if &record.status != filter_status { + return false; + } + } + + // 关键词搜索 + if let Some(keyword) = search_keyword { + let keyword_lower = keyword.to_lowercase(); + let matches = record.category.to_lowercase().contains(&keyword_lower) || + record.reasoning.to_lowercase().contains(&keyword_lower) || + record.error_message.as_ref().map_or(false, |msg| msg.to_lowercase().contains(&keyword_lower)); + if !matches { + return false; + } + } + + true + }) + .collect(); + + let total_count = filtered_records.len() as u64; + + // 应用分页 + let offset = ((page - 1) * page_size) as usize; + let end = std::cmp::min(offset + page_size as usize, filtered_records.len()); + let page_records = if offset < filtered_records.len() { + filtered_records[offset..end].to_vec() + } else { + Vec::new() + }; + + Ok((page_records, total_count)) + } + /// 创建分类任务 pub async fn create_classification_task(&self, task: VideoClassificationTask) -> Result { let conn = self.database.get_connection(); @@ -261,6 +352,101 @@ impl VideoClassificationRepository { Ok(tasks) } + /// 根据项目ID获取分类任务(支持分页和过滤) + pub async fn get_tasks_by_project_id_with_pagination( + &self, + project_id: &str, + page: u32, + page_size: u32, + status_filter: Option<&TaskStatus>, + search_keyword: Option<&str> + ) -> Result<(Vec, u64)> { + let conn = self.database.get_connection(); + let conn = conn.lock().unwrap(); + + // 简化查询,获取项目的所有任务 + let mut stmt = conn.prepare( + "SELECT id, segment_id, material_id, project_id, video_file_path, status, priority, + retry_count, max_retries, gemini_file_uri, prompt_text, error_message, + started_at, completed_at, created_at, updated_at + FROM video_classification_tasks + WHERE project_id = ?1 + ORDER BY created_at DESC" + )?; + + let rows = stmt.query_map([project_id], |row| { + let status_json: String = row.get(5)?; + let status: TaskStatus = serde_json::from_str(&status_json).unwrap_or_default(); + + let started_at_str: Option = row.get(12)?; + let started_at = started_at_str.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok().map(|dt| dt.with_timezone(&Utc))); + + let completed_at_str: Option = row.get(13)?; + let completed_at = completed_at_str.and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok().map(|dt| dt.with_timezone(&Utc))); + + Ok(VideoClassificationTask { + id: row.get(0)?, + segment_id: row.get(1)?, + material_id: row.get(2)?, + project_id: row.get(3)?, + video_file_path: row.get(4)?, + status, + priority: row.get(6)?, + retry_count: row.get(7)?, + max_retries: row.get(8)?, + gemini_file_uri: row.get(9)?, + prompt_text: row.get(10)?, + error_message: row.get(11)?, + started_at, + completed_at, + created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(14)?).map_err(|_e| rusqlite::Error::InvalidColumnType(14, "created_at".to_string(), rusqlite::types::Type::Text))?.with_timezone(&Utc), + updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(15)?).map_err(|_e| rusqlite::Error::InvalidColumnType(15, "updated_at".to_string(), rusqlite::types::Type::Text))?.with_timezone(&Utc), + }) + })?; + + let mut all_tasks = Vec::new(); + for row in rows { + all_tasks.push(row?); + } + + // 在内存中应用过滤 + let filtered_tasks: Vec = all_tasks.into_iter() + .filter(|task| { + // 状态过滤 + if let Some(filter_status) = status_filter { + if &task.status != filter_status { + return false; + } + } + + // 关键词搜索 + if let Some(keyword) = search_keyword { + let keyword_lower = keyword.to_lowercase(); + let matches = task.video_file_path.to_lowercase().contains(&keyword_lower) || + task.error_message.as_ref().map_or(false, |msg| msg.to_lowercase().contains(&keyword_lower)); + if !matches { + return false; + } + } + + true + }) + .collect(); + + let total_count = filtered_tasks.len() as u64; + + // 应用分页 + let offset = ((page - 1) * page_size) as usize; + let end = std::cmp::min(offset + page_size as usize, filtered_tasks.len()); + let page_tasks = if offset < filtered_tasks.len() { + filtered_tasks[offset..end].to_vec() + } else { + Vec::new() + }; + + Ok((page_tasks, total_count)) + } + /// 获取分类统计信息 pub async fn get_classification_stats(&self, project_id: Option<&str>) -> Result { let conn = self.database.get_connection(); @@ -385,4 +571,9 @@ impl VideoClassificationRepository { None => Ok(None), } } + + /// 根据ID获取分类任务 + pub async fn get_classification_task_by_id(&self, task_id: &str) -> Result> { + self.get_task_by_id(task_id).await + } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 94d8803..526b010 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -117,7 +117,17 @@ pub fn run() { commands::video_classification_commands::is_segment_classified, commands::video_classification_commands::cancel_classification_task, commands::video_classification_commands::retry_classification_task, - commands::video_classification_commands::test_gemini_connection + commands::video_classification_commands::test_gemini_connection, + // AI分析日志命令 + commands::ai_analysis_log_commands::get_ai_analysis_logs, + commands::ai_analysis_log_commands::get_ai_analysis_stats, + commands::ai_analysis_log_commands::export_ai_analysis_logs, + commands::ai_analysis_log_commands::cleanup_ai_analysis_logs, + commands::ai_analysis_log_commands::retry_failed_classification_task, + commands::ai_analysis_log_commands::get_classification_record_detail, + commands::ai_analysis_log_commands::delete_classification_records, + commands::ai_analysis_log_commands::get_ai_analysis_log_filters, + commands::ai_analysis_log_commands::create_test_ai_analysis_logs ]) .setup(|app| { // 初始化日志系统 diff --git a/apps/desktop/src-tauri/src/presentation/commands/ai_analysis_log_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/ai_analysis_log_commands.rs new file mode 100644 index 0000000..cdd8557 --- /dev/null +++ b/apps/desktop/src-tauri/src/presentation/commands/ai_analysis_log_commands.rs @@ -0,0 +1,449 @@ +use crate::app_state::AppState; +use crate::business::services::ai_analysis_log_service::{ + AiAnalysisLogService, AiAnalysisLogQuery, AiAnalysisLogResponse, AiAnalysisLogStats +}; +use crate::data::repositories::video_classification_repository::VideoClassificationRepository; +use std::sync::Arc; +use tauri::State; + +/// 获取AI分析日志 +/// +/// 遵循 Tauri 开发规范的命令设计原则: +/// - 输入验证和错误处理 +/// - 异步处理和性能优化 +/// - 安全的数据访问控制 +#[tauri::command] +pub async fn get_ai_analysis_logs( + query: AiAnalysisLogQuery, + state: State<'_, AppState>, +) -> Result { + // 输入验证 + if query.project_id.is_empty() { + return Err("项目ID不能为空".to_string()); + } + + if query.page == 0 { + return Err("页码必须大于0".to_string()); + } + + if query.page_size == 0 || query.page_size > 100 { + return Err("每页大小必须在1-100之间".to_string()); + } + + if !["records", "tasks"].contains(&query.log_type.as_str()) { + return Err("日志类型必须是records或tasks".to_string()); + } + + // 获取数据库连接 + let database = state.get_database(); + + // 创建仓库和服务实例 + let video_repo = Arc::new(VideoClassificationRepository::new(database)); + let log_service = AiAnalysisLogService::new(video_repo); + + // 执行查询 + match log_service.get_analysis_logs(query).await { + Ok(response) => Ok(response), + Err(e) => { + eprintln!("获取AI分析日志失败: {}", e); + Err(format!("获取AI分析日志失败: {}", e)) + } + } +} + +/// 获取AI分析统计信息 +/// +/// 提供项目的AI分析概览数据 +#[tauri::command] +pub async fn get_ai_analysis_stats( + project_id: String, + state: State<'_, AppState>, +) -> Result { + // 输入验证 + if project_id.is_empty() { + return Err("项目ID不能为空".to_string()); + } + + // 获取数据库连接 + let database = state.get_database(); + + // 创建仓库和服务实例 + let video_repo = Arc::new(VideoClassificationRepository::new(database)); + let log_service = AiAnalysisLogService::new(video_repo); + + // 执行查询 + match log_service.get_analysis_stats(&project_id).await { + Ok(stats) => Ok(stats), + Err(e) => { + eprintln!("获取AI分析统计失败: {}", e); + Err(format!("获取AI分析统计失败: {}", e)) + } + } +} + +/// 导出AI分析日志 +/// +/// 支持CSV和JSON格式的日志导出 +#[tauri::command] +pub async fn export_ai_analysis_logs( + project_id: String, + format: String, + state: State<'_, AppState>, +) -> Result { + // 输入验证 + if project_id.is_empty() { + return Err("项目ID不能为空".to_string()); + } + + if !["csv", "json"].contains(&format.as_str()) { + return Err("导出格式必须是csv或json".to_string()); + } + + // 获取数据库连接 + let database = state.get_database(); + + // 创建仓库和服务实例 + let video_repo = Arc::new(VideoClassificationRepository::new(database)); + let log_service = AiAnalysisLogService::new(video_repo); + + // 执行导出 + match log_service.export_analysis_logs(&project_id, &format).await { + Ok(result) => Ok(result), + Err(e) => { + eprintln!("导出AI分析日志失败: {}", e); + Err(format!("导出AI分析日志失败: {}", e)) + } + } +} + +/// 清理过期的AI分析日志 +/// +/// 删除指定天数之前的日志记录,释放存储空间 +#[tauri::command] +pub async fn cleanup_ai_analysis_logs( + project_id: String, + days_to_keep: u32, + _state: State<'_, AppState>, +) -> Result { + // 输入验证 + if project_id.is_empty() { + return Err("项目ID不能为空".to_string()); + } + + if days_to_keep == 0 { + return Err("保留天数必须大于0".to_string()); + } + + if days_to_keep > 365 { + return Err("保留天数不能超过365天".to_string()); + } + + // TODO: 实现日志清理功能 + // 这里应该调用仓库层的清理方法 + + // 暂时返回模拟结果 + Ok(0) +} + +/// 重试失败的分类任务 +/// +/// 重新启动失败的AI分类任务 +#[tauri::command] +pub async fn retry_failed_classification_task( + task_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + // 输入验证 + if task_id.is_empty() { + return Err("任务ID不能为空".to_string()); + } + + // 获取数据库连接 + let database = state.get_database(); + + // 创建仓库实例 + let video_repo = Arc::new(VideoClassificationRepository::new(database)); + + // 获取任务信息 + match video_repo.get_classification_task_by_id(&task_id).await { + Ok(Some(mut task)) => { + // 检查任务是否可以重试 + if !task.can_retry() { + return Err("任务已达到最大重试次数或状态不允许重试".to_string()); + } + + // 重置任务状态 + task.reset_for_retry(); + + // 更新任务 + match video_repo.update_classification_task(&task).await { + Ok(_) => { + println!("任务 {} 已重置为等待状态,将在队列中重新处理", task_id); + Ok(()) + } + Err(e) => { + eprintln!("更新任务状态失败: {}", e); + Err(format!("更新任务状态失败: {}", e)) + } + } + } + Ok(None) => Err("未找到指定的任务".to_string()), + Err(e) => { + eprintln!("获取任务信息失败: {}", e); + Err(format!("获取任务信息失败: {}", e)) + } + } +} + +/// 获取分类记录详情 +/// +/// 获取单个分类记录的详细信息 +#[tauri::command] +pub async fn get_classification_record_detail( + record_id: String, + state: State<'_, AppState>, +) -> Result { + // 输入验证 + if record_id.is_empty() { + return Err("记录ID不能为空".to_string()); + } + + // 获取数据库连接 + let database = state.get_database(); + + // 创建仓库实例 + let _video_repo = Arc::new(VideoClassificationRepository::new(database)); + + // 查询记录详情 + // TODO: 实现根据ID查询单个记录的方法 + + // 暂时返回模拟数据 + let detail = serde_json::json!({ + "id": record_id, + "message": "记录详情功能待实现" + }); + + Ok(detail) +} + +/// 批量删除分类记录 +/// +/// 删除选中的分类记录 +#[tauri::command] +pub async fn delete_classification_records( + record_ids: Vec, + _state: State<'_, AppState>, +) -> Result { + // 输入验证 + if record_ids.is_empty() { + return Err("记录ID列表不能为空".to_string()); + } + + if record_ids.len() > 100 { + return Err("一次最多只能删除100条记录".to_string()); + } + + // 验证所有ID都不为空 + for id in &record_ids { + if id.is_empty() { + return Err("记录ID不能为空".to_string()); + } + } + + // TODO: 实现批量删除功能 + // 这里应该调用仓库层的批量删除方法 + + // 暂时返回模拟结果 + Ok(record_ids.len() as u64) +} + +/// 获取AI分析日志的可用过滤选项 +/// +/// 返回状态、类型等过滤选项 +#[tauri::command] +pub async fn get_ai_analysis_log_filters() -> Result { + let filters = serde_json::json!({ + "log_types": [ + {"value": "records", "label": "分类记录"}, + {"value": "tasks", "label": "分类任务"} + ], + "record_statuses": [ + {"value": "Classified", "label": "分类成功"}, + {"value": "Failed", "label": "分类失败"}, + {"value": "NeedsReview", "label": "需要审核"} + ], + "task_statuses": [ + {"value": "Pending", "label": "等待处理"}, + {"value": "Uploading", "label": "上传中"}, + {"value": "Analyzing", "label": "分析中"}, + {"value": "Completed", "label": "已完成"}, + {"value": "Failed", "label": "处理失败"}, + {"value": "Cancelled", "label": "已取消"} + ], + "export_formats": [ + {"value": "csv", "label": "CSV格式"}, + {"value": "json", "label": "JSON格式"} + ] + }); + + Ok(filters) +} + +/// 创建测试AI分析日志数据 +/// +/// 仅用于开发测试,创建一些示例数据 +#[tauri::command] +pub async fn create_test_ai_analysis_logs( + project_id: String, + state: State<'_, AppState>, +) -> Result { + use crate::data::models::video_classification::*; + use chrono::Utc; + use uuid::Uuid; + + // 输入验证 + if project_id.is_empty() { + return Err("项目ID不能为空".to_string()); + } + + // 获取数据库连接 + let database = state.get_database(); + + // 创建仓库实例 + let video_repo = Arc::new(VideoClassificationRepository::new(database)); + + // 创建测试分类记录 + let test_records = vec![ + VideoClassificationRecord { + id: Uuid::new_v4().to_string(), + segment_id: format!("segment_{}", Uuid::new_v4()), + material_id: format!("material_{}", Uuid::new_v4()), + project_id: project_id.clone(), + category: "全身".to_string(), + confidence: 0.85, + reasoning: "视频显示完整的人体轮廓,动作清晰可见".to_string(), + features: vec!["全身动作".to_string(), "清晰画质".to_string()], + product_match: true, + quality_score: 0.9, + gemini_file_uri: Some("gs://test-bucket/video1.mp4".to_string()), + raw_response: Some("{\"category\":\"全身\",\"confidence\":0.85}".to_string()), + status: ClassificationStatus::Classified, + error_message: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }, + VideoClassificationRecord { + id: Uuid::new_v4().to_string(), + segment_id: format!("segment_{}", Uuid::new_v4()), + material_id: format!("material_{}", Uuid::new_v4()), + project_id: project_id.clone(), + category: "半身".to_string(), + confidence: 0.72, + reasoning: "视频主要显示上半身,部分下肢不可见".to_string(), + features: vec!["上半身".to_string(), "中等画质".to_string()], + product_match: false, + quality_score: 0.7, + gemini_file_uri: Some("gs://test-bucket/video2.mp4".to_string()), + raw_response: Some("{\"category\":\"半身\",\"confidence\":0.72}".to_string()), + status: ClassificationStatus::NeedsReview, + error_message: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }, + VideoClassificationRecord { + id: Uuid::new_v4().to_string(), + segment_id: format!("segment_{}", Uuid::new_v4()), + material_id: format!("material_{}", Uuid::new_v4()), + project_id: project_id.clone(), + category: "未分类".to_string(), + confidence: 0.3, + reasoning: "视频质量较差,无法准确识别".to_string(), + features: vec!["模糊画质".to_string()], + product_match: false, + quality_score: 0.3, + gemini_file_uri: Some("gs://test-bucket/video3.mp4".to_string()), + raw_response: Some("{\"error\":\"分析失败\"}".to_string()), + status: ClassificationStatus::Failed, + error_message: Some("视频质量过低,无法进行有效分析".to_string()), + created_at: Utc::now(), + updated_at: Utc::now(), + }, + ]; + + // 创建测试任务记录 + let test_tasks = vec![ + VideoClassificationTask { + id: Uuid::new_v4().to_string(), + segment_id: format!("segment_{}", Uuid::new_v4()), + material_id: format!("material_{}", Uuid::new_v4()), + project_id: project_id.clone(), + video_file_path: "/test/videos/sample1.mp4".to_string(), + status: TaskStatus::Completed, + priority: 1, + retry_count: 0, + max_retries: 3, + gemini_file_uri: Some("gs://test-bucket/video1.mp4".to_string()), + prompt_text: Some("请分析这个视频的内容".to_string()), + error_message: None, + started_at: Some(Utc::now()), + completed_at: Some(Utc::now()), + created_at: Utc::now(), + updated_at: Utc::now(), + }, + VideoClassificationTask { + id: Uuid::new_v4().to_string(), + segment_id: format!("segment_{}", Uuid::new_v4()), + material_id: format!("material_{}", Uuid::new_v4()), + project_id: project_id.clone(), + video_file_path: "/test/videos/sample2.mp4".to_string(), + status: TaskStatus::Failed, + priority: 1, + retry_count: 2, + max_retries: 3, + gemini_file_uri: None, + prompt_text: Some("请分析这个视频的内容".to_string()), + error_message: Some("网络连接超时".to_string()), + started_at: Some(Utc::now()), + completed_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }, + VideoClassificationTask { + id: Uuid::new_v4().to_string(), + segment_id: format!("segment_{}", Uuid::new_v4()), + material_id: format!("material_{}", Uuid::new_v4()), + project_id: project_id.clone(), + video_file_path: "/test/videos/sample3.mp4".to_string(), + status: TaskStatus::Analyzing, + priority: 1, + retry_count: 0, + max_retries: 3, + gemini_file_uri: Some("gs://test-bucket/video3.mp4".to_string()), + prompt_text: Some("请分析这个视频的内容".to_string()), + error_message: None, + started_at: Some(Utc::now()), + completed_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }, + ]; + + // 保存测试数据 + let mut created_count = 0; + + for record in test_records { + match video_repo.create_classification_record(record).await { + Ok(_) => created_count += 1, + Err(e) => eprintln!("创建测试记录失败: {}", e), + } + } + + for task in test_tasks { + match video_repo.create_classification_task(task).await { + Ok(_) => created_count += 1, + Err(e) => eprintln!("创建测试任务失败: {}", e), + } + } + + Ok(format!("成功创建 {} 条测试数据", created_count)) +} diff --git a/apps/desktop/src-tauri/src/presentation/commands/mod.rs b/apps/desktop/src-tauri/src/presentation/commands/mod.rs index 161b43f..c038ef7 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/mod.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/mod.rs @@ -4,3 +4,4 @@ pub mod material_commands; pub mod model_commands; pub mod ai_classification_commands; pub mod video_classification_commands; +pub mod ai_analysis_log_commands; diff --git a/apps/desktop/src/components/AiAnalysisLogViewer.tsx b/apps/desktop/src/components/AiAnalysisLogViewer.tsx new file mode 100644 index 0000000..ff05bba --- /dev/null +++ b/apps/desktop/src/components/AiAnalysisLogViewer.tsx @@ -0,0 +1,469 @@ +import React, { useState, useEffect } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { + Search, + Filter, + Download, + RefreshCw, + AlertCircle, + CheckCircle, + Clock, + XCircle, + Eye, + Trash2, + RotateCcw +} from 'lucide-react'; +import { LoadingSpinner } from './LoadingSpinner'; +import { ErrorMessage } from './ErrorMessage'; + +// 类型定义 +interface AiAnalysisLogItem { + id: string; + log_type: string; + title: string; + status: string; + status_display: string; + details: string; + error_message?: string; + confidence?: number; + quality_score?: number; + category?: string; + video_file_path?: string; + retry_count?: number; + created_at: string; + updated_at: string; +} + +interface AiAnalysisLogResponse { + logs: AiAnalysisLogItem[]; + total_count: number; + current_page: number; + page_size: number; + total_pages: number; +} + +interface AiAnalysisLogStats { + total_records: number; + successful_classifications: number; + failed_classifications: number; + needs_review: number; + total_tasks: number; + completed_tasks: number; + failed_tasks: number; + processing_tasks: number; + average_confidence: number; + average_quality_score: number; + recent_24h_activity: number; +} + +interface AiAnalysisLogViewerProps { + projectId: string; +} + +/** + * AI分析日志查看器组件 + * 遵循 Tauri 开发规范的前端组件设计模式 + */ +export const AiAnalysisLogViewer: React.FC = ({ projectId }) => { + const [logs, setLogs] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // 查询参数 + const [logType, setLogType] = useState<'records' | 'tasks'>('records'); + const [statusFilter, setStatusFilter] = useState(''); + const [searchKeyword, setSearchKeyword] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize] = useState(20); + const [totalPages, setTotalPages] = useState(0); + const [totalCount, setTotalCount] = useState(0); + + // 过滤选项 + const [filterOptions, setFilterOptions] = useState(null); + + // 加载过滤选项 + useEffect(() => { + const loadFilterOptions = async () => { + try { + const options = await invoke('get_ai_analysis_log_filters'); + setFilterOptions(options); + } catch (err) { + console.error('加载过滤选项失败:', err); + } + }; + + loadFilterOptions(); + }, []); + + // 加载日志数据 + const loadLogs = async () => { + if (!projectId) return; + + setLoading(true); + setError(null); + + try { + const query = { + project_id: projectId, + log_type: logType, + status_filter: statusFilter || null, + search_keyword: searchKeyword || null, + page: currentPage, + page_size: pageSize, + }; + + const response: AiAnalysisLogResponse = await invoke('get_ai_analysis_logs', { query }); + + setLogs(response.logs); + setTotalCount(response.total_count); + setTotalPages(response.total_pages); + } catch (err) { + setError(err as string); + } finally { + setLoading(false); + } + }; + + // 加载统计数据 + const loadStats = async () => { + if (!projectId) return; + + try { + const statsData: AiAnalysisLogStats = await invoke('get_ai_analysis_stats', { + projectId + }); + setStats(statsData); + } catch (err) { + console.error('加载统计数据失败:', err); + } + }; + + // 初始加载 + useEffect(() => { + loadLogs(); + loadStats(); + }, [projectId, logType, statusFilter, currentPage]); + + // 搜索处理 + const handleSearch = () => { + setCurrentPage(1); + loadLogs(); + }; + + // 重置搜索 + const handleResetSearch = () => { + setSearchKeyword(''); + setStatusFilter(''); + setCurrentPage(1); + loadLogs(); + }; + + // 导出日志 + const handleExport = async (format: 'csv' | 'json') => { + try { + const result = await invoke('export_ai_analysis_logs', { + projectId, + format, + }); + console.log('导出结果:', result); + // TODO: 处理导出结果,可能需要保存文件 + } catch (err) { + console.error('导出失败:', err); + } + }; + + // 创建测试数据 + const handleCreateTestData = async () => { + try { + const result = await invoke('create_test_ai_analysis_logs', { + projectId, + }); + console.log('创建测试数据结果:', result); + // 重新加载数据 + loadLogs(); + loadStats(); + } catch (err) { + console.error('创建测试数据失败:', err); + } + }; + + // 重试失败任务 + const handleRetryTask = async (taskId: string) => { + try { + await invoke('retry_failed_classification_task', { taskId }); + loadLogs(); // 重新加载数据 + } catch (err) { + console.error('重试任务失败:', err); + } + }; + + // 获取状态图标 + const getStatusIcon = (status: string, logType: string) => { + if (logType === 'record') { + switch (status) { + case '"Classified"': + return ; + case '"Failed"': + return ; + case '"NeedsReview"': + return ; + default: + return ; + } + } else { + switch (status) { + case '"Completed"': + return ; + case '"Failed"': + return ; + case '"Pending"': + case '"Uploading"': + case '"Analyzing"': + return ; + default: + return ; + } + } + }; + + // 获取状态颜色类 + const getStatusColorClass = (status: string) => { + if (status.includes('Classified') || status.includes('Completed')) { + return 'bg-green-100 text-green-800'; + } else if (status.includes('Failed')) { + return 'bg-red-100 text-red-800'; + } else if (status.includes('NeedsReview')) { + return 'bg-yellow-100 text-yellow-800'; + } else { + return 'bg-blue-100 text-blue-800'; + } + }; + + if (error) { + return ; + } + + return ( +
+ {/* 统计卡片 */} + {stats && ( +
+
+
总记录数
+
{stats.total_records}
+
+
+
成功分类
+
{stats.successful_classifications}
+
+
+
失败任务
+
{stats.failed_tasks}
+
+
+
平均置信度
+
+ {(stats.average_confidence * 100).toFixed(1)}% +
+
+
+ )} + + {/* 控制栏 */} +
+
+ {/* 日志类型切换 */} +
+ + +
+ + {/* 搜索和过滤 */} +
+
+ + setSearchKeyword(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ + {/* 状态过滤 */} + {filterOptions && ( + + )} + + + + +
+ + {/* 操作按钮 */} +
+ + +
+
+
+ + {/* 日志列表 */} +
+ {loading ? ( +
+ +

加载日志中...

+
+ ) : logs.length === 0 ? ( +
+ 暂无日志数据 +
+ ) : ( +
+ {logs.map((log) => ( +
+
+
+ {getStatusIcon(log.status, log.log_type)} +
+
+

+ {log.title} +

+ + {log.status_display} + +
+

{log.details}

+ {log.error_message && ( +

{log.error_message}

+ )} +
+ {new Date(log.created_at).toLocaleString('zh-CN')} + {log.confidence && ( + 置信度: {(log.confidence * 100).toFixed(1)}% + )} + {log.quality_score && ( + 质量: {(log.quality_score * 10).toFixed(1)}/10 + )} + {log.retry_count !== undefined && log.retry_count > 0 && ( + 重试: {log.retry_count}次 + )} +
+
+
+ + {/* 操作按钮 */} +
+ {log.log_type === 'task' && log.status.includes('Failed') && ( + + )} + +
+
+
+ ))} +
+ )} + + {/* 分页 */} + {totalPages > 1 && ( +
+
+ 显示 {((currentPage - 1) * pageSize) + 1} 到 {Math.min(currentPage * pageSize, totalCount)} 条, + 共 {totalCount} 条记录 +
+
+ + + {currentPage} / {totalPages} + + +
+
+ )} +
+
+ ); +}; diff --git a/apps/desktop/src/pages/ProjectDetails.tsx b/apps/desktop/src/pages/ProjectDetails.tsx index c23e92e..b7993b8 100644 --- a/apps/desktop/src/pages/ProjectDetails.tsx +++ b/apps/desktop/src/pages/ProjectDetails.tsx @@ -11,6 +11,7 @@ import { MaterialImportDialog } from '../components/MaterialImportDialog'; import { FFmpegDebugPanel } from '../components/FFmpegDebugPanel'; import { MaterialCard } from '../components/MaterialCard'; import { VideoClassificationProgress } from '../components/VideoClassificationProgress'; +import { AiAnalysisLogViewer } from '../components/AiAnalysisLogViewer'; import MaterialCardSkeleton from '../components/MaterialCardSkeleton'; /** @@ -30,7 +31,7 @@ export const ProjectDetails: React.FC = () => { } = useMaterialStore(); const [project, setProject] = useState(null); const [showImportDialog, setShowImportDialog] = useState(false); - const [activeTab, setActiveTab] = useState<'materials' | 'debug'>('materials'); + const [activeTab, setActiveTab] = useState<'materials' | 'debug' | 'ai-logs'>('materials'); // 加载项目详情 useEffect(() => { @@ -141,63 +142,64 @@ export const ProjectDetails: React.FC = () => {
{/* 页面头部 */}
-
+
- -
+ +
- + - -
{/* 项目基本信息 */} -
-
-
-

{project.name}

+
+
+
+

{project.name}

{project.description && ( -

{project.description}

+

{project.description}

)} - -
-
- - {project.path} + +
+
+ + {project.path}
- - 创建于 {new Date(project.created_at).toLocaleDateString('zh-CN')} + + 创建于 {new Date(project.created_at).toLocaleDateString('zh-CN')}
- -
{project.is_active ? '活跃' : '非活跃'} @@ -206,164 +208,196 @@ export const ProjectDetails: React.FC = () => {
- {/* 项目内容区域 */} -
- {/* 素材管理 */} -
-
- {/* 选项卡导航 */} -
-
- - -
+ {/* 项目统计概览 */} +
+ {/* 总素材数 */} +
+
+
+

总素材数

+

{stats?.total_materials || 0}

- - {/* 选项卡内容 */} +
+ +
+
+
+ + {/* 视频文件 */} +
+
+
+

视频文件

+

{stats?.video_count || 0}

+
+
+ +
+
+
+ + {/* 音频文件 */} +
+
+
+

音频文件

+

{stats?.audio_count || 0}

+
+
+ +
+
+
+ + {/* 图片文件 */} +
+
+
+

图片文件

+

{stats?.image_count || 0}

+
+
+ +
+
+
+
+ + {/* 主要内容区域 */} +
+ {/* 选项卡导航 */} +
+
+ +
+ + {/* 选项卡内容 */} +
+ {/* 素材管理选项卡 */} {activeTab === 'materials' && ( -
+
+ {/* AI视频分类进度 */} + {project && ( + + )} + {/* 素材列表 */}
-

项目素材

+
+

项目素材

+ +
+ {materialsLoading ? ( -
- +
+
) : materials.length > 0 ? ( -
+
{materials.map((material) => ( ))}
) : ( -
-
- +
+
+
-

暂无素材

-

请导入素材文件开始使用

+

暂无素材

+

+ 开始导入视频、音频或图片素材,让AI帮助您进行智能分类和管理 +

)}
- - {/* AI视频分类进度 */} - {project && ( -
- -
- )}
)} {/* 调试工具选项卡 */} {activeTab === 'debug' && ( - - )} -
-
- - {/* 项目信息侧边栏 */} -
- {/* 项目统计 */} -
-

项目统计

- {materialsLoading ? ( -
- {Array.from({ length: 5 }).map((_, index) => ( -
-
-
-
- ))} -
- ) : ( -
-
- 总素材数 -
- {stats?.total_materials || 0} - {(stats?.total_materials || 0) > 0 && ( -
- )} -
-
-
- 视频文件 -
- {stats?.video_count || 0} - {(stats?.video_count || 0) > 0 && ( - - )} -
-
-
- 音频文件 -
- {stats?.audio_count || 0} - {(stats?.audio_count || 0) > 0 && ( - - )} -
-
-
- 图片文件 -
- {stats?.image_count || 0} - {(stats?.image_count || 0) > 0 && ( - - )} -
-
-
- 总大小 -
- - {stats ? (stats.total_size / 1024 / 1024 / 1024).toFixed(2) + ' GB' : '0 GB'} - - {(stats?.total_size || 0) > 0 && ( - - )} -
+
+
+

调试工具

+

+ 用于开发和调试的工具集合,包括FFmpeg测试和系统诊断功能。 +

+
)} -
- {/* 最近活动 */} -
-

最近活动

-
-

暂无活动记录

-
+ {/* AI分析日志选项卡 */} + {activeTab === 'ai-logs' && project && ( +
+
+

AI分析日志

+

+ 查看项目中所有AI视频分类的详细日志,包括分类记录和任务执行情况。 +

+
+ +
+ )}