diff --git a/apps/desktop/src-tauri/src/business/services/video_classification_queue.rs b/apps/desktop/src-tauri/src/business/services/video_classification_queue.rs index 94da47e..d09d421 100644 --- a/apps/desktop/src-tauri/src/business/services/video_classification_queue.rs +++ b/apps/desktop/src-tauri/src/business/services/video_classification_queue.rs @@ -43,7 +43,7 @@ pub struct TaskProgress { /// AI视频分类任务队列 /// 遵循 Tauri 开发规范的业务层设计模式 pub struct VideoClassificationQueue { - service: Arc, + pub service: Arc, status: Arc>, current_task: Arc>>, task_progress: Arc>>, diff --git a/apps/desktop/src-tauri/src/business/services/video_classification_service.rs b/apps/desktop/src-tauri/src/business/services/video_classification_service.rs index 3a80220..c049fbc 100644 --- a/apps/desktop/src-tauri/src/business/services/video_classification_service.rs +++ b/apps/desktop/src-tauri/src/business/services/video_classification_service.rs @@ -102,6 +102,105 @@ impl VideoClassificationService { Ok(tasks) } + /// 为项目创建一键批量分类任务 + pub async fn create_project_batch_classification_tasks(&self, request: ProjectBatchClassificationRequest) -> Result { + println!("🚀 开始项目一键分类"); + println!(" 项目ID: {}", request.project_id); + + // 验证项目是否存在 + let _project = self.material_repo.get_project_by_id(&request.project_id).await? + .ok_or_else(|| anyhow!("项目不存在: {}", request.project_id))?; + + // 获取项目所有素材 + let all_materials = self.material_repo.get_by_project_id(&request.project_id)?; + let total_materials = all_materials.len() as u32; + + println!(" 项目总素材数: {}", total_materials); + + // 过滤符合条件的素材 + let material_types = request.material_types.unwrap_or_else(|| vec![crate::data::models::material::MaterialType::Video]); + let overwrite_existing = request.overwrite_existing.unwrap_or(false); + + let mut eligible_materials = Vec::new(); + let mut skipped_materials = Vec::new(); + + for material in all_materials { + // 检查素材类型 + if !material_types.contains(&material.material_type) { + continue; + } + + // 检查处理状态 - 只处理已完成处理的素材 + if material.processing_status != crate::data::models::material::ProcessingStatus::Completed { + continue; + } + + // 获取素材片段 + let segments = self.material_repo.get_segments(&material.id)?; + if segments.is_empty() { + continue; + } + + // 检查是否已有分类记录 + if !overwrite_existing { + let mut has_classification = false; + for segment in &segments { + if self.video_repo.is_segment_classified(&segment.id).await? { + has_classification = true; + break; + } + } + if has_classification { + skipped_materials.push(material.id.clone()); + continue; + } + } + + eligible_materials.push(material); + } + + let eligible_count = eligible_materials.len() as u32; + println!(" 符合条件的素材数: {}", eligible_count); + println!(" 跳过的素材数: {}", skipped_materials.len()); + + // 为每个符合条件的素材创建批量分类任务 + let mut all_task_ids = Vec::new(); + let mut created_tasks_count = 0u32; + + for material in eligible_materials { + let batch_request = BatchClassificationRequest { + material_id: material.id.clone(), + project_id: request.project_id.clone(), + overwrite_existing, + priority: request.priority, + }; + + match self.create_batch_classification_tasks(batch_request).await { + Ok(tasks) => { + let task_ids: Vec = tasks.iter().map(|t| t.id.clone()).collect(); + created_tasks_count += task_ids.len() as u32; + all_task_ids.extend(task_ids); + println!(" 为素材 {} 创建了 {} 个分类任务", material.name, tasks.len()); + } + Err(e) => { + println!(" 为素材 {} 创建分类任务失败: {}", material.name, e); + // 继续处理其他素材,不因单个素材失败而中断整个流程 + } + } + } + + println!("✅ 项目一键分类任务创建完成"); + println!(" 总共创建任务数: {}", created_tasks_count); + + Ok(ProjectBatchClassificationResponse { + total_materials, + eligible_materials: eligible_count, + created_tasks: created_tasks_count, + task_ids: all_task_ids, + skipped_materials, + }) + } + /// 处理单个分类任务 pub async fn process_classification_task(&self, task_id: &str) -> Result { // 获取任务 diff --git a/apps/desktop/src-tauri/src/data/models/video_classification.rs b/apps/desktop/src-tauri/src/data/models/video_classification.rs index 8bc7213..0e2cbdd 100644 --- a/apps/desktop/src-tauri/src/data/models/video_classification.rs +++ b/apps/desktop/src-tauri/src/data/models/video_classification.rs @@ -117,6 +117,34 @@ pub struct BatchClassificationRequest { pub priority: Option, } +/// 项目一键分类请求 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectBatchClassificationRequest { + /// 项目ID + pub project_id: String, + /// 是否覆盖已有分类 + pub overwrite_existing: Option, + /// 要处理的素材类型(可选,默认只处理视频) + pub material_types: Option>, + /// 任务优先级 + pub priority: Option, +} + +/// 项目一键分类响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectBatchClassificationResponse { + /// 项目中总素材数 + pub total_materials: u32, + /// 符合条件的素材数 + pub eligible_materials: u32, + /// 创建的任务数 + pub created_tasks: u32, + /// 创建的任务ID列表 + pub task_ids: Vec, + /// 跳过的素材ID列表(已有分类) + pub skipped_materials: Vec, +} + /// 分类任务查询参数 #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ClassificationTaskQuery { diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 7c84db3..a7ddd22 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -106,6 +106,7 @@ pub fn run() { commands::ai_classification_commands::validate_ai_classification_name, // AI视频分类命令 commands::video_classification_commands::start_video_classification, + commands::video_classification_commands::start_project_batch_classification, commands::video_classification_commands::get_classification_queue_status, commands::video_classification_commands::get_project_classification_queue_status, commands::video_classification_commands::get_classification_task_progress, diff --git a/apps/desktop/src-tauri/src/presentation/commands/video_classification_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/video_classification_commands.rs index 23453af..98974d4 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/video_classification_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/video_classification_commands.rs @@ -61,6 +61,31 @@ pub async fn start_video_classification( Ok(task_ids) } +/// 启动项目一键AI视频分类 +/// 遍历项目下所有符合条件的素材并添加到分类队列 +#[command] +pub async fn start_project_batch_classification( + request: ProjectBatchClassificationRequest, + state: State<'_, AppState>, +) -> Result { + let queue = get_queue_instance(&state).await; + + // 创建项目批量分类任务 + let response = queue.service.create_project_batch_classification_tasks(request) + .await + .map_err(|e| e.to_string())?; + + // 启动队列(如果尚未启动) + if let Err(e) = queue.start().await { + // 如果队列已经在运行,忽略错误 + if !e.to_string().contains("已经在运行中") { + return Err(e.to_string()); + } + } + + Ok(response) +} + /// 获取分类队列状态 #[command] pub async fn get_classification_queue_status( diff --git a/apps/desktop/src/components/VideoClassificationProgress.tsx b/apps/desktop/src/components/VideoClassificationProgress.tsx index c8ce0ef..0293792 100644 --- a/apps/desktop/src/components/VideoClassificationProgress.tsx +++ b/apps/desktop/src/components/VideoClassificationProgress.tsx @@ -127,10 +127,10 @@ export const VideoClassificationProgress: React.FC { + const getOverallProgress = () => { if (!typedQueueStats || typedQueueStats.total_tasks === 0) return 0; return Math.round(((typedQueueStats.completed_tasks + typedQueueStats.failed_tasks) / typedQueueStats.total_tasks) * 100); - }, [typedQueueStats]); + }; // 过滤相关任务 const relevantTasks = materialId @@ -142,9 +142,7 @@ export const VideoClassificationProgress: React.FC { - return getOverallProgress() - }, [getOverallProgress]); + const overallProgress = getOverallProgress() return (
diff --git a/apps/desktop/src/pages/ProjectDetails.tsx b/apps/desktop/src/pages/ProjectDetails.tsx index a6148fe..5aca77d 100644 --- a/apps/desktop/src/pages/ProjectDetails.tsx +++ b/apps/desktop/src/pages/ProjectDetails.tsx @@ -1,10 +1,12 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { ArrowLeft, FolderOpen, Settings, Upload, FileVideo, FileAudio, FileImage, HardDrive } from 'lucide-react'; +import { ArrowLeft, FolderOpen, Settings, Upload, FileVideo, FileAudio, FileImage, HardDrive, Brain, Loader2 } from 'lucide-react'; import { useProjectStore } from '../store/projectStore'; import { useMaterialStore } from '../store/materialStore'; +import { useVideoClassificationStore } from '../store/videoClassificationStore'; import { Project } from '../types/project'; import { MaterialImportResult } from '../types/material'; +import { ProjectBatchClassificationRequest, ProjectBatchClassificationResponse } from '../types/videoClassification'; import { LoadingSpinner } from '../components/LoadingSpinner'; import { ErrorMessage } from '../components/ErrorMessage'; import { MaterialImportDialog } from '../components/MaterialImportDialog'; @@ -28,9 +30,17 @@ export const ProjectDetails: React.FC = () => { loadMaterialStats, isLoading: materialsLoading } = useMaterialStore(); + const { + startProjectBatchClassification, + isLoading: classificationLoading, + error: classificationError, + queueStats, + getProjectQueueStatus + } = useVideoClassificationStore(); const [project, setProject] = useState(null); const [showImportDialog, setShowImportDialog] = useState(false); const [activeTab, setActiveTab] = useState<'materials' | 'debug' | 'ai-logs'>('materials'); + const [batchClassificationResult, setBatchClassificationResult] = useState(null); // 加载项目详情 useEffect(() => { @@ -53,6 +63,21 @@ export const ProjectDetails: React.FC = () => { } }, [id, projects, loadMaterials, loadMaterialStats]); + // 监控AI分类队列状态 + useEffect(() => { + if (!project) return; + + // 初始加载队列状态 + getProjectQueueStatus(project.id); + + // 设置定时刷新 + const interval = setInterval(() => { + getProjectQueueStatus(project.id); + }, 3000); // 每3秒刷新一次 + + return () => clearInterval(interval); + }, [project, getProjectQueueStatus]); + // 返回项目列表 const handleBack = () => { navigate('/'); @@ -109,6 +134,40 @@ export const ProjectDetails: React.FC = () => { } }; + // 一键AI分类处理 + const handleBatchClassification = async () => { + if (!project) return; + + try { + const request: ProjectBatchClassificationRequest = { + project_id: project.id, + overwrite_existing: false, // 默认不覆盖已有分类 + material_types: undefined, // 使用默认值(只处理视频) + priority: undefined, // 使用默认优先级 + }; + + const response = await startProjectBatchClassification(request); + setBatchClassificationResult(response); + + // 显示结果提示 + const message = `一键分类启动成功!\n` + + `项目总素材数: ${response.total_materials}\n` + + `符合条件的素材数: ${response.eligible_materials}\n` + + `创建的任务数: ${response.created_tasks}\n` + + `跳过的素材数: ${response.skipped_materials.length}`; + + alert(message); + + // 刷新队列状态 + if (project) { + getProjectQueueStatus(project.id); + } + } catch (error) { + console.error('一键分类失败:', error); + alert(`一键分类失败: ${error}`); + } + }; + if (isLoading) { return (
@@ -168,6 +227,21 @@ export const ProjectDetails: React.FC = () => { 导入素材 + +
{/* 项目统计概览 */} -
+
{/* 总素材数 */}
@@ -229,6 +303,29 @@ export const ProjectDetails: React.FC = () => {
+ + {/* AI分类状态 */} +
+
+
+

AI分类队列

+
+

+ {queueStats?.pending_tasks || 0} +

+ 待处理 +
+ {queueStats?.processing_tasks && queueStats.processing_tasks > 0 && ( +

+ {queueStats.processing_tasks} 个正在处理 +

+ )} +
+
+ +
+
+
{/* 主要内容区域 */} diff --git a/apps/desktop/src/store/videoClassificationStore.ts b/apps/desktop/src/store/videoClassificationStore.ts index ce2790f..534058f 100644 --- a/apps/desktop/src/store/videoClassificationStore.ts +++ b/apps/desktop/src/store/videoClassificationStore.ts @@ -1,5 +1,9 @@ import { create } from 'zustand'; import { invoke } from '@tauri-apps/api/core'; +import { + ProjectBatchClassificationRequest, + ProjectBatchClassificationResponse +} from '../types/videoClassification'; // 类型定义 export interface VideoClassificationRecord { @@ -70,6 +74,7 @@ interface VideoClassificationState { // Actions startClassification: (request: BatchClassificationRequest) => Promise; + startProjectBatchClassification: (request: ProjectBatchClassificationRequest) => Promise; getQueueStatus: () => Promise; getProjectQueueStatus: (projectId: string) => Promise; getTaskProgress: (taskId: string) => Promise; @@ -118,6 +123,24 @@ export const useVideoClassificationStore = create((set } }, + startProjectBatchClassification: async (request: ProjectBatchClassificationRequest) => { + set({ isLoading: true, error: null }); + try { + const response = await invoke('start_project_batch_classification', { request }); + + // 刷新队列状态 + await get().refreshQueueStatus(); + await get().refreshTaskProgress(); + + set({ isLoading: false }); + return response; + } catch (error) { + const errorMessage = typeof error === 'string' ? error : '启动项目一键分类失败'; + set({ error: errorMessage, isLoading: false }); + throw new Error(errorMessage); + } + }, + getQueueStatus: async () => { try { const stats = await invoke('get_classification_queue_status'); diff --git a/apps/desktop/src/types/videoClassification.ts b/apps/desktop/src/types/videoClassification.ts new file mode 100644 index 0000000..f3cf0a5 --- /dev/null +++ b/apps/desktop/src/types/videoClassification.ts @@ -0,0 +1,146 @@ +import { MaterialType } from './material'; + +/** + * 任务状态枚举 + */ +export enum TaskStatus { + Pending = 'Pending', + Uploading = 'Uploading', + Analyzing = 'Analyzing', + Completed = 'Completed', + Failed = 'Failed', + Cancelled = 'Cancelled', +} + +/** + * 队列状态枚举 + */ +export enum QueueStatus { + Stopped = 'Stopped', + Running = 'Running', + Paused = 'Paused', +} + +/** + * 分类状态枚举 + */ +export enum ClassificationStatus { + Classified = 'Classified', + Failed = 'Failed', + NeedsReview = 'NeedsReview', +} + +/** + * 批量分类请求 + */ +export interface BatchClassificationRequest { + material_id: string; + project_id: string; + overwrite_existing: boolean; + priority?: number; +} + +/** + * 项目一键分类请求 + */ +export interface ProjectBatchClassificationRequest { + project_id: string; + overwrite_existing?: boolean; + material_types?: MaterialType[]; + priority?: number; +} + +/** + * 项目一键分类响应 + */ +export interface ProjectBatchClassificationResponse { + total_materials: number; + eligible_materials: number; + created_tasks: number; + task_ids: string[]; + skipped_materials: string[]; +} + +/** + * 任务进度信息 + */ +export interface TaskProgress { + task_id: string; + status: TaskStatus; + progress_percentage: number; + current_step: string; + error_message?: string; + started_at?: string; + estimated_completion?: string; +} + +/** + * 队列统计信息 + */ +export interface QueueStats { + status: QueueStatus; + total_tasks: number; + pending_tasks: number; + processing_tasks: number; + completed_tasks: number; + failed_tasks: number; + current_task_id?: string; + processing_rate: number; +} + +/** + * 视频分类记录 + */ +export interface VideoClassificationRecord { + id: string; + task_id: string; + segment_id: string; + material_id: string; + project_id: string; + video_file_path: string; + classification_result: string; + confidence_score: number; + reasoning: string; + features: string[]; + product_match: boolean; + quality_score: number; + gemini_file_uri: string; + raw_response: string; + status: ClassificationStatus; + created_at: string; + updated_at: string; +} + +/** + * 分类统计信息 + */ +export interface ClassificationStats { + total_tasks: number; + pending_tasks: number; + processing_tasks: number; + completed_tasks: number; + failed_tasks: number; + total_classifications: number; + average_confidence_score: number; + average_quality_score: number; +} + +/** + * 视频分类任务 + */ +export interface VideoClassificationTask { + id: string; + segment_id: string; + material_id: string; + project_id: string; + video_file_path: string; + status: TaskStatus; + priority: number; + gemini_file_uri?: string; + prompt_text?: string; + error_message?: string; + created_at: string; + updated_at: string; + started_at?: string; + completed_at?: string; +}