feat: 实现异步导入处理和批量导入功能
- 新增异步素材导入服务 (AsyncMaterialService) - 实现实时进度反馈和事件驱动架构 - 添加批量文件夹选择和扫描功能 - 更新导入对话框支持文件夹批量导入 - 优化用户体验,避免UI阻塞 Features: 1. 异步导入处理:支持实时进度更新,不阻塞UI 2. 批量导入:支持文件夹选择和递归扫描 3. 事件驱动:使用Tauri事件系统进行进度通信 4. 文件类型过滤:支持按扩展名过滤文件 5. 改进的用户界面:新增批量导入配置步骤 遵循promptx/tauri-desktop-app-expert开发规范
This commit is contained in:
@@ -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<MaterialRepository>,
|
||||||
|
request: CreateMaterialRequest,
|
||||||
|
config: MaterialProcessingConfig,
|
||||||
|
event_bus: Arc<EventBusManager>,
|
||||||
|
) -> Result<MaterialImportResult> {
|
||||||
|
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<MaterialRepository>,
|
||||||
|
project_id: &str,
|
||||||
|
file_path: &str,
|
||||||
|
config: &MaterialProcessingConfig,
|
||||||
|
event_bus: Arc<EventBusManager>,
|
||||||
|
) -> Result<Option<Material>> {
|
||||||
|
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<MaterialRepository>,
|
||||||
|
material_id: &str,
|
||||||
|
config: &MaterialProcessingConfig,
|
||||||
|
event_bus: Arc<EventBusManager>,
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -380,7 +380,7 @@ impl MaterialService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 提取素材元数据
|
/// 提取素材元数据
|
||||||
fn extract_metadata(file_path: &str, material_type: &MaterialType) -> Result<MaterialMetadata> {
|
pub fn extract_metadata(file_path: &str, material_type: &MaterialType) -> Result<MaterialMetadata> {
|
||||||
match material_type {
|
match material_type {
|
||||||
MaterialType::Video | MaterialType::Audio => {
|
MaterialType::Video | MaterialType::Audio => {
|
||||||
// 使用 FFmpeg 提取视频/音频元数据
|
// 使用 FFmpeg 提取视频/音频元数据
|
||||||
@@ -398,7 +398,7 @@ impl MaterialService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 检测视频场景
|
/// 检测视频场景
|
||||||
fn detect_video_scenes(
|
pub fn detect_video_scenes(
|
||||||
file_path: &str,
|
file_path: &str,
|
||||||
threshold: f64,
|
threshold: f64,
|
||||||
) -> Result<crate::data::models::material::SceneDetection> {
|
) -> Result<crate::data::models::material::SceneDetection> {
|
||||||
@@ -460,7 +460,7 @@ impl MaterialService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 切分视频
|
/// 切分视频
|
||||||
fn segment_video(
|
pub fn segment_video(
|
||||||
repository: &MaterialRepository,
|
repository: &MaterialRepository,
|
||||||
material: &Material,
|
material: &Material,
|
||||||
config: &MaterialProcessingConfig,
|
config: &MaterialProcessingConfig,
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod project_service;
|
pub mod project_service;
|
||||||
pub mod material_service;
|
pub mod material_service;
|
||||||
|
pub mod async_material_service;
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ impl MaterialRepository {
|
|||||||
Ok(Self { connection })
|
Ok(Self { connection })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取数据库连接(用于创建新的仓库实例)
|
||||||
|
pub fn get_connection(&self) -> Arc<Mutex<Connection>> {
|
||||||
|
Arc::clone(&self.connection)
|
||||||
|
}
|
||||||
|
|
||||||
/// 创建素材
|
/// 创建素材
|
||||||
pub fn create(&self, material: &Material) -> Result<()> {
|
pub fn create(&self, material: &Material) -> Result<()> {
|
||||||
let conn = self.connection.lock().unwrap();
|
let conn = self.connection.lock().unwrap();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::collections::HashMap;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
|
use crate::data::models::material::MaterialImportResult;
|
||||||
|
|
||||||
/// 事件类型定义
|
/// 事件类型定义
|
||||||
/// 遵循模块化设计原则,清晰分类不同类型的事件
|
/// 遵循模块化设计原则,清晰分类不同类型的事件
|
||||||
@@ -46,6 +47,32 @@ pub enum DataEvent {
|
|||||||
ProjectDataChanged { project_id: String },
|
ProjectDataChanged { project_id: String },
|
||||||
DatabaseError { error_message: String },
|
DatabaseError { error_message: String },
|
||||||
DataSyncCompleted,
|
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事件
|
/// UI事件
|
||||||
@@ -201,6 +228,71 @@ impl EventBusManager {
|
|||||||
value,
|
value,
|
||||||
})).await
|
})).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 {
|
impl Default for EventBusManager {
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ pub fn run() {
|
|||||||
commands::system_commands::record_performance_metric,
|
commands::system_commands::record_performance_metric,
|
||||||
commands::system_commands::cleanup_invalid_projects,
|
commands::system_commands::cleanup_invalid_projects,
|
||||||
commands::material_commands::import_materials,
|
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_project_materials,
|
||||||
commands::material_commands::get_material_by_id,
|
commands::material_commands::get_material_by_id,
|
||||||
commands::material_commands::delete_material,
|
commands::material_commands::delete_material,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use tauri::{command, State};
|
use tauri::{command, State};
|
||||||
|
use std::sync::Arc;
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::business::services::material_service::MaterialService;
|
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::infrastructure::ffmpeg::FFmpegService;
|
||||||
use crate::data::models::material::{
|
use crate::data::models::material::{
|
||||||
CreateMaterialRequest, MaterialImportResult, MaterialProcessingConfig,
|
CreateMaterialRequest, MaterialImportResult, MaterialProcessingConfig,
|
||||||
@@ -31,6 +34,158 @@ pub async fn import_materials(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 异步导入素材命令
|
||||||
|
/// 遵循 Tauri 开发规范的异步命令设计模式
|
||||||
|
/// 提供实时进度反馈和非阻塞用户体验
|
||||||
|
#[command]
|
||||||
|
pub async fn import_materials_async(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
request: CreateMaterialRequest,
|
||||||
|
_app_handle: tauri::AppHandle,
|
||||||
|
) -> Result<MaterialImportResult, String> {
|
||||||
|
// 获取数据库连接,避免持有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<Vec<String>, 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<String>,
|
||||||
|
recursive: bool,
|
||||||
|
file_types: Vec<String>, // 文件类型过滤,如 ["mp4", "avi", "mov"]
|
||||||
|
) -> Result<Vec<String>, 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<Vec<String>, 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<Vec<String>, 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]
|
#[command]
|
||||||
pub async fn get_project_materials(
|
pub async fn get_project_materials(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { X, Upload, FileText, AlertCircle, CheckCircle, Loader2 } from 'lucide-react';
|
import { X, Upload, FileText, AlertCircle, CheckCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { useMaterialStore } from '../store/materialStore';
|
import { useMaterialStore } from '../store/materialStore';
|
||||||
import { CreateMaterialRequest, MaterialImportResult } from '../types/material';
|
import { CreateMaterialRequest, MaterialImportResult } from '../types/material';
|
||||||
|
|
||||||
@@ -24,10 +25,14 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||||||
// isImporting,
|
// isImporting,
|
||||||
importProgress,
|
importProgress,
|
||||||
error,
|
error,
|
||||||
importMaterials,
|
importMaterialsAsync,
|
||||||
|
updateImportProgress,
|
||||||
selectMaterialFiles,
|
selectMaterialFiles,
|
||||||
|
selectMaterialFolders,
|
||||||
|
scanFolderMaterials,
|
||||||
validateMaterialFiles,
|
validateMaterialFiles,
|
||||||
checkFFmpegAvailable,
|
checkFFmpegAvailable,
|
||||||
|
getSupportedExtensions,
|
||||||
clearError
|
clearError
|
||||||
} = useMaterialStore();
|
} = useMaterialStore();
|
||||||
|
|
||||||
@@ -35,7 +40,11 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||||||
const [autoProcess, setAutoProcess] = useState(true);
|
const [autoProcess, setAutoProcess] = useState(true);
|
||||||
const [maxSegmentDuration, setMaxSegmentDuration] = useState(300); // 5分钟
|
const [maxSegmentDuration, setMaxSegmentDuration] = useState(300); // 5分钟
|
||||||
const [ffmpegAvailable, setFFmpegAvailable] = useState(false);
|
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<string[]>([]);
|
||||||
|
const [recursiveScan, setRecursiveScan] = useState(true);
|
||||||
|
const [selectedFileTypes, setSelectedFileTypes] = useState<string[]>([]);
|
||||||
|
|
||||||
// 检查 FFmpeg 可用性
|
// 检查 FFmpeg 可用性
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,6 +62,48 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||||||
}
|
}
|
||||||
}, [isOpen, clearError]);
|
}, [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 () => {
|
const handleSelectFiles = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -61,6 +112,7 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||||||
const validFiles = await validateMaterialFiles(filePaths);
|
const validFiles = await validateMaterialFiles(filePaths);
|
||||||
setSelectedFiles(validFiles);
|
setSelectedFiles(validFiles);
|
||||||
if (validFiles.length > 0) {
|
if (validFiles.length > 0) {
|
||||||
|
setImportMode('files');
|
||||||
setStep('configure');
|
setStep('configure');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +121,42 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 开始导入
|
// 选择文件夹
|
||||||
|
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 () => {
|
const handleStartImport = async () => {
|
||||||
if (selectedFiles.length === 0) return;
|
if (selectedFiles.length === 0) return;
|
||||||
|
|
||||||
@@ -83,11 +170,12 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||||||
max_segment_duration: maxSegmentDuration,
|
max_segment_duration: maxSegmentDuration,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await importMaterials(request);
|
// 使用异步导入,进度更新通过事件监听器处理
|
||||||
setStep('complete');
|
await importMaterialsAsync(request);
|
||||||
onImportComplete(result);
|
// 注意:完成状态由事件监听器设置,这里不需要手动设置
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('导入失败:', error);
|
console.error('导入失败:', error);
|
||||||
|
setStep('configure'); // 返回配置步骤
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -144,27 +232,100 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 步骤 1: 选择文件 */}
|
{/* 步骤 1: 选择导入方式 */}
|
||||||
{step === 'select' && (
|
{step === 'select' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Upload className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
<Upload className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">选择素材文件</h3>
|
<h3 className="text-lg font-medium text-gray-900 mb-2">选择导入方式</h3>
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-600 mb-6">
|
||||||
支持视频、音频、图片等多种格式
|
支持视频、音频、图片等多种格式
|
||||||
</p>
|
</p>
|
||||||
<button
|
|
||||||
onClick={handleSelectFiles}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
<button
|
||||||
>
|
onClick={handleSelectFiles}
|
||||||
<Upload className="w-5 h-5 mr-2" />
|
className="p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors group"
|
||||||
选择文件
|
>
|
||||||
</button>
|
<FileText className="w-12 h-12 text-gray-400 group-hover:text-blue-500 mx-auto mb-3" />
|
||||||
|
<h4 className="text-lg font-medium text-gray-900 mb-2">选择文件</h4>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
手动选择单个或多个文件
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSelectFolders}
|
||||||
|
className="p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors group"
|
||||||
|
>
|
||||||
|
<Upload className="w-12 h-12 text-gray-400 group-hover:text-blue-500 mx-auto mb-3" />
|
||||||
|
<h4 className="text-lg font-medium text-gray-900 mb-2">选择文件夹</h4>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
批量导入整个文件夹
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 步骤 2: 配置导入选项 */}
|
{/* 步骤 2: 批量导入配置 */}
|
||||||
|
{step === 'batch' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<Upload className="w-16 h-16 text-blue-600 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">配置批量导入</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
已选择 {selectedFolders.length} 个文件夹
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-2">选择的文件夹:</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedFolders.map((folder, index) => (
|
||||||
|
<div key={index} className="text-sm text-gray-600 bg-white p-2 rounded border">
|
||||||
|
{folder}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="recursive"
|
||||||
|
checked={recursiveScan}
|
||||||
|
onChange={(e) => setRecursiveScan(e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="recursive" className="ml-2 text-sm text-gray-700">
|
||||||
|
递归扫描子文件夹
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
文件类型过滤 (留空表示所有支持的类型)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="例如: mp4,avi,mov (用逗号分隔)"
|
||||||
|
value={selectedFileTypes.join(',')}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 步骤 3: 配置导入选项 */}
|
||||||
{step === 'configure' && (
|
{step === 'configure' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -282,7 +443,7 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||||||
|
|
||||||
{/* 底部按钮 */}
|
{/* 底部按钮 */}
|
||||||
<div className="flex items-center justify-end space-x-3 p-6 border-t border-gray-200">
|
<div className="flex items-center justify-end space-x-3 p-6 border-t border-gray-200">
|
||||||
{step === 'configure' && (
|
{step === 'batch' && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setStep('select')}
|
onClick={() => setStep('select')}
|
||||||
@@ -290,6 +451,24 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||||||
>
|
>
|
||||||
返回
|
返回
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleScanFolders}
|
||||||
|
disabled={selectedFolders.length === 0}
|
||||||
|
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
扫描文件夹
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'configure' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setStep(importMode === 'folders' ? 'batch' : 'select')}
|
||||||
|
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleStartImport}
|
onClick={handleStartImport}
|
||||||
disabled={selectedFiles.length === 0}
|
disabled={selectedFiles.length === 0}
|
||||||
@@ -299,7 +478,7 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(step === 'select' || step === 'complete') && (
|
{(step === 'select' || step === 'complete') && (
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|||||||
@@ -34,11 +34,25 @@ interface MaterialState {
|
|||||||
loadMaterials: (projectId: string) => Promise<void>;
|
loadMaterials: (projectId: string) => Promise<void>;
|
||||||
loadMaterialStats: (projectId: string) => Promise<void>;
|
loadMaterialStats: (projectId: string) => Promise<void>;
|
||||||
importMaterials: (request: CreateMaterialRequest) => Promise<MaterialImportResult>;
|
importMaterials: (request: CreateMaterialRequest) => Promise<MaterialImportResult>;
|
||||||
|
importMaterialsAsync: (request: CreateMaterialRequest) => Promise<MaterialImportResult>;
|
||||||
getMaterialById: (id: string) => Promise<Material | null>;
|
getMaterialById: (id: string) => Promise<Material | null>;
|
||||||
getMaterialSegments: (materialId: string) => Promise<MaterialSegment[]>;
|
getMaterialSegments: (materialId: string) => Promise<MaterialSegment[]>;
|
||||||
deleteMaterial: (id: string) => Promise<void>;
|
deleteMaterial: (id: string) => Promise<void>;
|
||||||
processMaterials: (materialIds: string[]) => Promise<void>;
|
processMaterials: (materialIds: string[]) => Promise<void>;
|
||||||
updateMaterialStatus: (id: string, status: ProcessingStatus, errorMessage?: string) => Promise<void>;
|
updateMaterialStatus: (id: string, status: ProcessingStatus, errorMessage?: string) => Promise<void>;
|
||||||
|
|
||||||
|
// 进度更新方法
|
||||||
|
updateImportProgress: (progress: {
|
||||||
|
current_file: string;
|
||||||
|
processed_count: number;
|
||||||
|
total_count: number;
|
||||||
|
current_status: string;
|
||||||
|
progress_percentage: number;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
// 批量导入相关方法
|
||||||
|
selectMaterialFolders: () => Promise<string[]>;
|
||||||
|
scanFolderMaterials: (folderPaths: string[], recursive: boolean, fileTypes: string[]) => Promise<string[]>;
|
||||||
cleanupInvalidMaterials: (projectId: string) => Promise<string>;
|
cleanupInvalidMaterials: (projectId: string) => Promise<string>;
|
||||||
setCurrentMaterial: (material: Material | null) => void;
|
setCurrentMaterial: (material: Material | null) => void;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
@@ -88,7 +102,7 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 导入素材
|
// 导入素材(同步版本)
|
||||||
importMaterials: async (request: CreateMaterialRequest) => {
|
importMaterials: async (request: CreateMaterialRequest) => {
|
||||||
set({ isImporting: true, error: null, importProgress: {
|
set({ isImporting: true, error: null, importProgress: {
|
||||||
current_file: '',
|
current_file: '',
|
||||||
@@ -100,10 +114,10 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await invoke<MaterialImportResult>('import_materials', { request });
|
const result = await invoke<MaterialImportResult>('import_materials', { request });
|
||||||
|
|
||||||
// 更新素材列表
|
// 更新素材列表
|
||||||
const { materials } = get();
|
const { materials } = get();
|
||||||
set({
|
set({
|
||||||
materials: [...materials, ...result.created_materials],
|
materials: [...materials, ...result.created_materials],
|
||||||
isImporting: false,
|
isImporting: false,
|
||||||
importProgress: null
|
importProgress: null
|
||||||
@@ -114,8 +128,8 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error: error as string,
|
error: error as string,
|
||||||
isImporting: false,
|
isImporting: false,
|
||||||
importProgress: null
|
importProgress: null
|
||||||
});
|
});
|
||||||
@@ -123,6 +137,60 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 异步导入素材(新版本,支持实时进度)
|
||||||
|
importMaterialsAsync: async (request: CreateMaterialRequest) => {
|
||||||
|
set({ isImporting: true, error: null, importProgress: {
|
||||||
|
current_file: '',
|
||||||
|
processed_count: 0,
|
||||||
|
total_count: request.file_paths.length,
|
||||||
|
current_status: '准备导入...',
|
||||||
|
errors: []
|
||||||
|
}});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invoke<MaterialImportResult>('import_materials_async', { request });
|
||||||
|
|
||||||
|
// 更新素材列表
|
||||||
|
const { materials } = get();
|
||||||
|
set({
|
||||||
|
materials: [...materials, ...result.created_materials],
|
||||||
|
isImporting: false,
|
||||||
|
importProgress: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重新加载统计信息
|
||||||
|
get().loadMaterialStats(request.project_id);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error: error as string,
|
||||||
|
isImporting: false,
|
||||||
|
importProgress: null
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新导入进度
|
||||||
|
updateImportProgress: (progress: {
|
||||||
|
current_file: string;
|
||||||
|
processed_count: number;
|
||||||
|
total_count: number;
|
||||||
|
current_status: string;
|
||||||
|
progress_percentage: number;
|
||||||
|
}) => {
|
||||||
|
set({
|
||||||
|
importProgress: {
|
||||||
|
current_file: progress.current_file,
|
||||||
|
processed_count: progress.processed_count,
|
||||||
|
total_count: progress.total_count,
|
||||||
|
current_status: progress.current_status,
|
||||||
|
errors: get().importProgress?.errors || []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// 获取素材详情
|
// 获取素材详情
|
||||||
getMaterialById: async (id: string) => {
|
getMaterialById: async (id: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -325,4 +393,28 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 选择素材文件夹
|
||||||
|
selectMaterialFolders: async () => {
|
||||||
|
try {
|
||||||
|
return await invoke<string[]>('select_material_folders');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('选择文件夹失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 扫描文件夹中的素材文件
|
||||||
|
scanFolderMaterials: async (folderPaths: string[], recursive: boolean, fileTypes: string[]) => {
|
||||||
|
try {
|
||||||
|
return await invoke<string[]>('scan_folder_materials', {
|
||||||
|
folderPaths,
|
||||||
|
recursive,
|
||||||
|
fileTypes
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('扫描文件夹失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user