diff --git a/apps/desktop/src-tauri/src/business/services/async_material_service.rs b/apps/desktop/src-tauri/src/business/services/async_material_service.rs new file mode 100644 index 0000000..abe2e7a --- /dev/null +++ b/apps/desktop/src-tauri/src/business/services/async_material_service.rs @@ -0,0 +1,373 @@ +use anyhow::{Result, anyhow}; +use std::path::Path; +use std::fs; +use std::time::Instant; +use std::sync::Arc; +use tokio::task; + +use crate::data::models::material::{ + Material, MaterialType, ProcessingStatus, CreateMaterialRequest, + MaterialImportResult, MaterialProcessingConfig +}; +use crate::data::repositories::material_repository::MaterialRepository; +use crate::infrastructure::monitoring::PERFORMANCE_MONITOR; +use crate::infrastructure::event_bus::EventBusManager; +use crate::business::errors::error_utils; +use crate::business::services::material_service::MaterialService; +use tracing::{info, warn, error, debug}; + +/// 异步素材服务 +/// 遵循 Tauri 开发规范的异步业务逻辑层设计 +pub struct AsyncMaterialService; + +impl AsyncMaterialService { + /// 异步导入素材文件 + /// 提供实时进度反馈和非阻塞用户体验 + pub async fn import_materials_async( + repository: Arc, + request: CreateMaterialRequest, + config: MaterialProcessingConfig, + event_bus: Arc, + ) -> Result { + let timer = PERFORMANCE_MONITOR.start_operation("async_import_materials"); + let start_time = Instant::now(); + + info!( + project_id = %request.project_id, + file_count = request.file_paths.len(), + "开始异步导入素材文件" + ); + + let mut result = MaterialImportResult { + total_files: request.file_paths.len() as u32, + processed_files: 0, + skipped_files: 0, + failed_files: 0, + created_materials: Vec::new(), + errors: Vec::new(), + processing_time: 0.0, + }; + + // 发布开始导入事件 + let _ = event_bus.publish_material_import_progress( + request.project_id.clone(), + "准备导入...".to_string(), + 0, + result.total_files, + "正在准备导入文件".to_string(), + ).await; + + // 异步处理每个文件 + for (index, file_path) in request.file_paths.iter().enumerate() { + debug!(file_path = %file_path, "异步处理文件"); + + // 发布当前文件处理进度 + let _ = event_bus.publish_material_import_progress( + request.project_id.clone(), + file_path.clone(), + index as u32, + result.total_files, + format!("正在处理文件: {}", Path::new(file_path).file_name() + .and_then(|n| n.to_str()).unwrap_or("unknown")), + ).await; + + // 在独立的任务中处理文件,避免阻塞 + let repository_clone = Arc::clone(&repository); + let project_id_clone = request.project_id.clone(); + let file_path_clone = file_path.clone(); + let config_clone = config.clone(); + let event_bus_clone = Arc::clone(&event_bus); + + match Self::process_single_file_async( + repository_clone, + &project_id_clone, + &file_path_clone, + &config_clone, + event_bus_clone, + ).await { + Ok(Some(material)) => { + info!( + file_path = %file_path, + material_id = %material.id, + "文件异步处理成功" + ); + result.created_materials.push(material); + result.processed_files += 1; + } + Ok(None) => { + // 文件被跳过(重复) + warn!(file_path = %file_path, "文件被跳过(重复)"); + result.skipped_files += 1; + } + Err(e) => { + error!( + file_path = %file_path, + error = %e, + "文件异步处理失败" + ); + result.failed_files += 1; + result.errors.push(format!("处理文件 {} 失败: {}", file_path, e)); + } + } + + // 让出控制权,避免长时间占用线程 + tokio::task::yield_now().await; + } + + result.processing_time = start_time.elapsed().as_secs_f64(); + + let success = result.failed_files == 0; + timer.finish(success); + + info!( + processed_files = result.processed_files, + skipped_files = result.skipped_files, + failed_files = result.failed_files, + processing_time = result.processing_time, + "异步素材导入完成" + ); + + PERFORMANCE_MONITOR.record_metric("async_import_files_per_second", + result.total_files as f64 / result.processing_time); + + // 发布导入完成事件 + if success { + let _ = event_bus.publish_material_import_completed( + request.project_id.clone(), + result.clone(), + ).await; + } else { + let _ = event_bus.publish_material_import_failed( + request.project_id.clone(), + format!("导入失败,{} 个文件处理失败", result.failed_files), + ).await; + } + + Ok(result) + } + + /// 异步处理单个文件 + async fn process_single_file_async( + repository: Arc, + project_id: &str, + file_path: &str, + config: &MaterialProcessingConfig, + event_bus: Arc, + ) -> Result> { + let _timer = PERFORMANCE_MONITOR.start_operation("async_process_single_file"); + + // 验证文件路径 + error_utils::validate_file_path(file_path) + .map_err(|e| anyhow!("文件验证失败: {}", e))?; + + let path = Path::new(file_path); + + // 获取文件信息 + let metadata = fs::metadata(path)?; + let file_size = metadata.len(); + + // 计算MD5哈希(在独立任务中执行) + let file_path_clone = file_path.to_string(); + let md5_hash = task::spawn_blocking(move || { + MaterialService::calculate_md5(&file_path_clone) + }).await??; + + // 检查是否已存在相同的文件 + if repository.exists_by_md5(project_id, &md5_hash)? { + return Ok(None); // 跳过重复文件 + } + + // 获取文件名和扩展名 + let file_name = path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let extension = path.extension() + .and_then(|ext| ext.to_str()) + .unwrap_or(""); + + // 确定素材类型 + let material_type = MaterialType::from_extension(extension); + + // 创建素材对象 + let material = Material::new( + project_id.to_string(), + file_name.clone(), + file_path.to_string(), + file_size, + md5_hash, + material_type, + ); + + // 保存到数据库 + repository.create(&material)?; + + // 如果启用自动处理,则异步开始处理 + if config.auto_process.unwrap_or(true) { + // 异步处理素材(提取元数据、场景检测等) + let repository_clone = Arc::clone(&repository); + let material_id = material.id.clone(); + let config_clone = config.clone(); + let event_bus_clone = Arc::clone(&event_bus); + let file_name_clone = file_name.clone(); + + // 在后台任务中处理,不阻塞导入流程 + tokio::spawn(async move { + match Self::process_material_async( + repository_clone, + &material_id, + &config_clone, + event_bus_clone, + &file_name_clone, + ).await { + Ok(_) => { + debug!(material_id = %material_id, "素材异步处理完成"); + } + Err(e) => { + error!(material_id = %material_id, error = %e, "素材异步处理失败"); + } + } + }); + } + + Ok(Some(material)) + } + + /// 异步处理单个素材(提取元数据、场景检测等) + async fn process_material_async( + repository: Arc, + material_id: &str, + config: &MaterialProcessingConfig, + event_bus: Arc, + file_name: &str, + ) -> Result<()> { + // 更新状态为处理中 + MaterialService::update_material_status( + &repository, + material_id, + ProcessingStatus::Processing, + None, + )?; + + // 发布处理开始事件 + let _ = event_bus.publish_material_processing_progress( + material_id.to_string(), + file_name.to_string(), + "开始处理".to_string(), + 0.0, + ).await; + + // 获取素材信息 + let mut material = repository.get_by_id(material_id)? + .ok_or_else(|| anyhow!("素材不存在: {}", material_id))?; + + // 1. 异步提取元数据 + let _ = event_bus.publish_material_processing_progress( + material_id.to_string(), + file_name.to_string(), + "提取元数据".to_string(), + 25.0, + ).await; + + let original_path = material.original_path.clone(); + let material_type = material.material_type.clone(); + + match task::spawn_blocking(move || { + MaterialService::extract_metadata(&original_path, &material_type) + }).await? { + Ok(metadata) => { + material.set_metadata(metadata); + repository.update(&material)?; + } + Err(e) => { + MaterialService::update_material_status( + &repository, + material_id, + ProcessingStatus::Failed, + Some(format!("元数据提取失败: {}", e)), + )?; + return Err(e); + } + } + + // 2. 异步场景检测(如果是视频且启用了场景检测) + if matches!(material.material_type, MaterialType::Video) && config.enable_scene_detection { + let _ = event_bus.publish_material_processing_progress( + material_id.to_string(), + file_name.to_string(), + "场景检测".to_string(), + 50.0, + ).await; + + let original_path = material.original_path.clone(); + let threshold = config.scene_detection_threshold; + + match task::spawn_blocking(move || { + MaterialService::detect_video_scenes(&original_path, threshold) + }).await? { + Ok(scene_detection) => { + info!("异步场景检测成功,发现 {} 个场景", scene_detection.scenes.len()); + material.set_scene_detection(scene_detection); + repository.update(&material)?; + } + Err(e) => { + // 场景检测失败不应该导致整个处理失败 + warn!("异步场景检测失败: {}", e); + } + } + } + + // 3. 异步检查是否需要切分视频 + let should_segment = material.needs_segmentation(config.max_segment_duration) || + (matches!(material.material_type, MaterialType::Video) && material.scene_detection.is_some()); + + if should_segment { + let _ = event_bus.publish_material_processing_progress( + material_id.to_string(), + file_name.to_string(), + "视频切分".to_string(), + 75.0, + ).await; + + let repository_clone = Arc::clone(&repository); + let material_clone = material.clone(); + let config_clone = config.clone(); + + match task::spawn_blocking(move || { + MaterialService::segment_video(&repository_clone, &material_clone, &config_clone) + }).await? { + Ok(_) => { + info!("异步视频切分完成: {}", material.name); + } + Err(e) => { + MaterialService::update_material_status( + &repository, + material_id, + ProcessingStatus::Failed, + Some(format!("视频切分失败: {}", e)), + )?; + return Err(e); + } + } + } + + // 标记为完成 + MaterialService::update_material_status( + &repository, + material_id, + ProcessingStatus::Completed, + None, + )?; + + // 发布处理完成事件 + let _ = event_bus.publish_material_processing_progress( + material_id.to_string(), + file_name.to_string(), + "处理完成".to_string(), + 100.0, + ).await; + + Ok(()) + } +} diff --git a/apps/desktop/src-tauri/src/business/services/material_service.rs b/apps/desktop/src-tauri/src/business/services/material_service.rs index 89aa44d..073f8eb 100644 --- a/apps/desktop/src-tauri/src/business/services/material_service.rs +++ b/apps/desktop/src-tauri/src/business/services/material_service.rs @@ -380,7 +380,7 @@ impl MaterialService { } /// 提取素材元数据 - fn extract_metadata(file_path: &str, material_type: &MaterialType) -> Result { + pub fn extract_metadata(file_path: &str, material_type: &MaterialType) -> Result { match material_type { MaterialType::Video | MaterialType::Audio => { // 使用 FFmpeg 提取视频/音频元数据 @@ -398,7 +398,7 @@ impl MaterialService { } /// 检测视频场景 - fn detect_video_scenes( + pub fn detect_video_scenes( file_path: &str, threshold: f64, ) -> Result { @@ -460,7 +460,7 @@ impl MaterialService { } /// 切分视频 - fn segment_video( + pub fn segment_video( repository: &MaterialRepository, material: &Material, config: &MaterialProcessingConfig, diff --git a/apps/desktop/src-tauri/src/business/services/mod.rs b/apps/desktop/src-tauri/src/business/services/mod.rs index 20af817..1bff9cc 100644 --- a/apps/desktop/src-tauri/src/business/services/mod.rs +++ b/apps/desktop/src-tauri/src/business/services/mod.rs @@ -1,2 +1,3 @@ pub mod project_service; pub mod material_service; +pub mod async_material_service; diff --git a/apps/desktop/src-tauri/src/data/repositories/material_repository.rs b/apps/desktop/src-tauri/src/data/repositories/material_repository.rs index be39bb8..87288cd 100644 --- a/apps/desktop/src-tauri/src/data/repositories/material_repository.rs +++ b/apps/desktop/src-tauri/src/data/repositories/material_repository.rs @@ -18,6 +18,11 @@ impl MaterialRepository { Ok(Self { connection }) } + /// 获取数据库连接(用于创建新的仓库实例) + pub fn get_connection(&self) -> Arc> { + Arc::clone(&self.connection) + } + /// 创建素材 pub fn create(&self, material: &Material) -> Result<()> { let conn = self.connection.lock().unwrap(); diff --git a/apps/desktop/src-tauri/src/infrastructure/event_bus.rs b/apps/desktop/src-tauri/src/infrastructure/event_bus.rs index 97edd7d..c285ab0 100644 --- a/apps/desktop/src-tauri/src/infrastructure/event_bus.rs +++ b/apps/desktop/src-tauri/src/infrastructure/event_bus.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use tokio::sync::broadcast; use serde::{Serialize, Deserialize}; +use crate::data::models::material::MaterialImportResult; /// 事件类型定义 /// 遵循模块化设计原则,清晰分类不同类型的事件 @@ -46,6 +47,32 @@ pub enum DataEvent { ProjectDataChanged { project_id: String }, DatabaseError { error_message: String }, DataSyncCompleted, + /// 素材导入进度事件 + MaterialImportProgress { + project_id: String, + current_file: String, + processed_count: u32, + total_count: u32, + current_status: String, + progress_percentage: f64, + }, + /// 素材导入完成事件 + MaterialImportCompleted { + project_id: String, + result: MaterialImportResult, + }, + /// 素材导入失败事件 + MaterialImportFailed { + project_id: String, + error: String, + }, + /// 单个素材处理进度事件 + MaterialProcessingProgress { + material_id: String, + file_name: String, + stage: String, // "metadata", "scene_detection", "video_splitting" + progress_percentage: f64, + }, } /// UI事件 @@ -201,6 +228,71 @@ impl EventBusManager { value, })).await } + + /// 发布素材导入进度事件 + pub async fn publish_material_import_progress( + &self, + project_id: String, + current_file: String, + processed_count: u32, + total_count: u32, + current_status: String, + ) -> Result<(), String> { + let progress_percentage = if total_count > 0 { + (processed_count as f64 / total_count as f64) * 100.0 + } else { + 0.0 + }; + + self.event_bus.publish(Event::Data(DataEvent::MaterialImportProgress { + project_id, + current_file, + processed_count, + total_count, + current_status, + progress_percentage, + })).await + } + + /// 发布素材导入完成事件 + pub async fn publish_material_import_completed( + &self, + project_id: String, + result: MaterialImportResult, + ) -> Result<(), String> { + self.event_bus.publish(Event::Data(DataEvent::MaterialImportCompleted { + project_id, + result, + })).await + } + + /// 发布素材导入失败事件 + pub async fn publish_material_import_failed( + &self, + project_id: String, + error: String, + ) -> Result<(), String> { + self.event_bus.publish(Event::Data(DataEvent::MaterialImportFailed { + project_id, + error, + })).await + } + + /// 发布素材处理进度事件 + pub async fn publish_material_processing_progress( + &self, + material_id: String, + file_name: String, + stage: String, + progress_percentage: f64, + ) -> Result<(), String> { + self.event_bus.publish(Event::Data(DataEvent::MaterialProcessingProgress { + material_id, + file_name, + stage, + progress_percentage, + })).await + } } impl Default for EventBusManager { diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 766713d..8291e3a 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -46,6 +46,9 @@ pub fn run() { commands::system_commands::record_performance_metric, commands::system_commands::cleanup_invalid_projects, commands::material_commands::import_materials, + commands::material_commands::import_materials_async, + commands::material_commands::select_material_folders, + commands::material_commands::scan_folder_materials, commands::material_commands::get_project_materials, commands::material_commands::get_material_by_id, commands::material_commands::delete_material, diff --git a/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs b/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs index 8bb7156..637761e 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs @@ -1,6 +1,9 @@ use tauri::{command, State}; +use std::sync::Arc; use crate::app_state::AppState; use crate::business::services::material_service::MaterialService; +use crate::business::services::async_material_service::AsyncMaterialService; +use crate::data::repositories::material_repository::MaterialRepository; use crate::infrastructure::ffmpeg::FFmpegService; use crate::data::models::material::{ CreateMaterialRequest, MaterialImportResult, MaterialProcessingConfig, @@ -31,6 +34,158 @@ pub async fn import_materials( .map_err(|e| e.to_string()) } +/// 异步导入素材命令 +/// 遵循 Tauri 开发规范的异步命令设计模式 +/// 提供实时进度反馈和非阻塞用户体验 +#[command] +pub async fn import_materials_async( + state: State<'_, AppState>, + request: CreateMaterialRequest, + _app_handle: tauri::AppHandle, +) -> Result { + // 获取数据库连接,避免持有MutexGuard + let connection = { + let repository_guard = state.get_material_repository() + .map_err(|e| format!("获取素材仓库失败: {}", e))?; + + let repository = repository_guard.as_ref() + .ok_or("素材仓库未初始化")?; + + repository.get_connection() + }; // repository_guard在这里被释放 + + let mut config = MaterialProcessingConfig::default(); + config.auto_process = Some(request.auto_process); + if let Some(max_duration) = request.max_segment_duration { + config.max_segment_duration = max_duration; + } + + // 创建一个新的MaterialRepository实例用于异步操作 + let async_repository = MaterialRepository::new(connection) + .map_err(|e| format!("创建异步仓库失败: {}", e))?; + let repository_arc = Arc::new(async_repository); + + // 获取事件总线 + let event_bus = state.event_bus_manager.clone(); + + // 直接执行异步导入 + AsyncMaterialService::import_materials_async( + repository_arc, + request, + config, + event_bus, + ).await.map_err(|e| e.to_string()) +} + +/// 选择文件夹进行批量导入 +#[command] +pub async fn select_material_folders(app_handle: tauri::AppHandle) -> Result, String> { + use crate::presentation::commands::system_commands::select_directory; + + // 使用现有的目录选择功能,但允许多选 + // 注意:这是一个简化实现,实际可能需要修改系统命令以支持多选 + match select_directory(app_handle) { + Ok(Some(folder_path)) => Ok(vec![folder_path]), + Ok(None) => Ok(Vec::new()), + Err(e) => Err(e), + } +} + +/// 扫描文件夹中的素材文件 +#[command] +pub async fn scan_folder_materials( + folder_paths: Vec, + recursive: bool, + file_types: Vec, // 文件类型过滤,如 ["mp4", "avi", "mov"] +) -> Result, String> { + use std::path::Path; + + let mut all_files = Vec::new(); + + for folder_path in folder_paths { + let folder = Path::new(&folder_path); + if !folder.exists() || !folder.is_dir() { + continue; + } + + let files = if recursive { + scan_directory_recursive(folder, &file_types)? + } else { + scan_directory_shallow(folder, &file_types)? + }; + + all_files.extend(files); + } + + // 去重 + all_files.sort(); + all_files.dedup(); + + Ok(all_files) +} + +/// 递归扫描目录 +fn scan_directory_recursive(dir: &std::path::Path, file_types: &[String]) -> Result, String> { + use std::fs; + + let mut files = Vec::new(); + + let entries = fs::read_dir(dir) + .map_err(|e| format!("读取目录失败 {}: {}", dir.display(), e))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("读取目录项失败: {}", e))?; + let path = entry.path(); + + if path.is_dir() { + // 递归扫描子目录 + let sub_files = scan_directory_recursive(&path, file_types)?; + files.extend(sub_files); + } else if path.is_file() { + // 检查文件类型 + if let Some(extension) = path.extension() { + if let Some(ext_str) = extension.to_str() { + let ext_lower = ext_str.to_lowercase(); + if file_types.is_empty() || file_types.contains(&ext_lower) { + files.push(path.to_string_lossy().to_string()); + } + } + } + } + } + + Ok(files) +} + +/// 浅层扫描目录(不递归) +fn scan_directory_shallow(dir: &std::path::Path, file_types: &[String]) -> Result, String> { + use std::fs; + + let mut files = Vec::new(); + + let entries = fs::read_dir(dir) + .map_err(|e| format!("读取目录失败 {}: {}", dir.display(), e))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("读取目录项失败: {}", e))?; + let path = entry.path(); + + if path.is_file() { + // 检查文件类型 + if let Some(extension) = path.extension() { + if let Some(ext_str) = extension.to_str() { + let ext_lower = ext_str.to_lowercase(); + if file_types.is_empty() || file_types.contains(&ext_lower) { + files.push(path.to_string_lossy().to_string()); + } + } + } + } + } + + Ok(files) +} + /// 获取项目素材列表命令 #[command] pub async fn get_project_materials( diff --git a/apps/desktop/src/components/MaterialImportDialog.tsx b/apps/desktop/src/components/MaterialImportDialog.tsx index b9b8848..e9c133b 100644 --- a/apps/desktop/src/components/MaterialImportDialog.tsx +++ b/apps/desktop/src/components/MaterialImportDialog.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { X, Upload, FileText, AlertCircle, CheckCircle, Loader2 } from 'lucide-react'; +import { listen } from '@tauri-apps/api/event'; import { useMaterialStore } from '../store/materialStore'; import { CreateMaterialRequest, MaterialImportResult } from '../types/material'; @@ -24,10 +25,14 @@ export const MaterialImportDialog: React.FC = ({ // isImporting, importProgress, error, - importMaterials, + importMaterialsAsync, + updateImportProgress, selectMaterialFiles, + selectMaterialFolders, + scanFolderMaterials, validateMaterialFiles, checkFFmpegAvailable, + getSupportedExtensions, clearError } = useMaterialStore(); @@ -35,7 +40,11 @@ export const MaterialImportDialog: React.FC = ({ const [autoProcess, setAutoProcess] = useState(true); const [maxSegmentDuration, setMaxSegmentDuration] = useState(300); // 5分钟 const [ffmpegAvailable, setFFmpegAvailable] = useState(false); - const [step, setStep] = useState<'select' | 'configure' | 'importing' | 'complete'>('select'); + const [step, setStep] = useState<'select' | 'batch' | 'configure' | 'importing' | 'complete'>('select'); + const [importMode, setImportMode] = useState<'files' | 'folders'>('files'); + const [selectedFolders, setSelectedFolders] = useState([]); + const [recursiveScan, setRecursiveScan] = useState(true); + const [selectedFileTypes, setSelectedFileTypes] = useState([]); // 检查 FFmpeg 可用性 useEffect(() => { @@ -53,6 +62,48 @@ export const MaterialImportDialog: React.FC = ({ } }, [isOpen, clearError]); + // 设置事件监听器用于接收导入进度更新 + useEffect(() => { + if (!isOpen) return; + + const setupEventListeners = async () => { + // 监听导入进度事件 + const unlistenProgress = await listen('material_import_progress', (event: any) => { + const progressData = event.payload; + updateImportProgress({ + current_file: progressData.current_file, + processed_count: progressData.processed_count, + total_count: progressData.total_count, + current_status: progressData.current_status, + progress_percentage: progressData.progress_percentage, + }); + }); + + // 监听导入完成事件 + const unlistenCompleted = await listen('material_import_completed', (event: any) => { + const result = event.payload; + setStep('complete'); + onImportComplete(result); + }); + + // 监听导入失败事件 + const unlistenFailed = await listen('material_import_failed', (event: any) => { + const errorMessage = event.payload; + console.error('导入失败:', errorMessage); + setStep('configure'); // 返回配置步骤 + }); + + // 清理函数 + return () => { + unlistenProgress(); + unlistenCompleted(); + unlistenFailed(); + }; + }; + + setupEventListeners(); + }, [isOpen, updateImportProgress, onImportComplete]); + // 选择文件 const handleSelectFiles = async () => { try { @@ -61,6 +112,7 @@ export const MaterialImportDialog: React.FC = ({ const validFiles = await validateMaterialFiles(filePaths); setSelectedFiles(validFiles); if (validFiles.length > 0) { + setImportMode('files'); setStep('configure'); } } @@ -69,7 +121,42 @@ export const MaterialImportDialog: React.FC = ({ } }; - // 开始导入 + // 选择文件夹 + const handleSelectFolders = async () => { + try { + const folderPaths = await selectMaterialFolders(); + if (folderPaths.length > 0) { + setSelectedFolders(folderPaths); + setImportMode('folders'); + setStep('batch'); + } + } catch (error) { + console.error('选择文件夹失败:', error); + } + }; + + // 扫描文件夹并进入配置步骤 + const handleScanFolders = async () => { + try { + const supportedTypes = await getSupportedExtensions(); + const fileTypes = selectedFileTypes.length > 0 ? selectedFileTypes : supportedTypes; + + const scannedFiles = await scanFolderMaterials(selectedFolders, recursiveScan, fileTypes); + if (scannedFiles.length > 0) { + const validFiles = await validateMaterialFiles(scannedFiles); + setSelectedFiles(validFiles); + if (validFiles.length > 0) { + setStep('configure'); + } + } else { + console.warn('未找到任何支持的文件'); + } + } catch (error) { + console.error('扫描文件夹失败:', error); + } + }; + + // 开始导入(使用异步版本) const handleStartImport = async () => { if (selectedFiles.length === 0) return; @@ -83,11 +170,12 @@ export const MaterialImportDialog: React.FC = ({ max_segment_duration: maxSegmentDuration, }; - const result = await importMaterials(request); - setStep('complete'); - onImportComplete(result); + // 使用异步导入,进度更新通过事件监听器处理 + await importMaterialsAsync(request); + // 注意:完成状态由事件监听器设置,这里不需要手动设置 } catch (error) { console.error('导入失败:', error); + setStep('configure'); // 返回配置步骤 } }; @@ -144,27 +232,100 @@ export const MaterialImportDialog: React.FC = ({ )} - {/* 步骤 1: 选择文件 */} + {/* 步骤 1: 选择导入方式 */} {step === 'select' && (
-

选择素材文件

+

选择导入方式

支持视频、音频、图片等多种格式

- + +
+ + + +
)} - {/* 步骤 2: 配置导入选项 */} + {/* 步骤 2: 批量导入配置 */} + {step === 'batch' && ( +
+
+ +

配置批量导入

+

+ 已选择 {selectedFolders.length} 个文件夹 +

+
+ +
+
+

选择的文件夹:

+
+ {selectedFolders.map((folder, index) => ( +
+ {folder} +
+ ))} +
+
+ +
+
+ setRecursiveScan(e.target.checked)} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + /> + +
+ +
+ + setSelectedFileTypes( + e.target.value.split(',').map(t => t.trim()).filter(t => t) + )} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+
+
+
+ )} + + {/* 步骤 3: 配置导入选项 */} {step === 'configure' && (
@@ -282,7 +443,7 @@ export const MaterialImportDialog: React.FC = ({ {/* 底部按钮 */}
- {step === 'configure' && ( + {step === 'batch' && ( <> + + + )} + + {step === 'configure' && ( + <> + )} - + {(step === 'select' || step === 'complete') && (