feat: 实现异步导入处理和批量导入功能

- 新增异步素材导入服务 (AsyncMaterialService)
- 实现实时进度反馈和事件驱动架构
- 添加批量文件夹选择和扫描功能
- 更新导入对话框支持文件夹批量导入
- 优化用户体验,避免UI阻塞

Features:
1. 异步导入处理:支持实时进度更新,不阻塞UI
2. 批量导入:支持文件夹选择和递归扫描
3. 事件驱动:使用Tauri事件系统进行进度通信
4. 文件类型过滤:支持按扩展名过滤文件
5. 改进的用户界面:新增批量导入配置步骤

遵循promptx/tauri-desktop-app-expert开发规范
This commit is contained in:
imeepos
2025-07-13 23:32:54 +08:00
parent 3553ba3c06
commit 73f542af40
9 changed files with 926 additions and 26 deletions

View File

@@ -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(())
}
}

View File

@@ -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 {
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<crate::data::models::material::SceneDetection> {
@@ -460,7 +460,7 @@ impl MaterialService {
}
/// 切分视频
fn segment_video(
pub fn segment_video(
repository: &MaterialRepository,
material: &Material,
config: &MaterialProcessingConfig,

View File

@@ -1,2 +1,3 @@
pub mod project_service;
pub mod material_service;
pub mod async_material_service;

View File

@@ -18,6 +18,11 @@ impl MaterialRepository {
Ok(Self { connection })
}
/// 获取数据库连接(用于创建新的仓库实例)
pub fn get_connection(&self) -> Arc<Mutex<Connection>> {
Arc::clone(&self.connection)
}
/// 创建素材
pub fn create(&self, material: &Material) -> Result<()> {
let conn = self.connection.lock().unwrap();

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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<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]
pub async fn get_project_materials(

View File

@@ -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<MaterialImportDialogProps> = ({
// isImporting,
importProgress,
error,
importMaterials,
importMaterialsAsync,
updateImportProgress,
selectMaterialFiles,
selectMaterialFolders,
scanFolderMaterials,
validateMaterialFiles,
checkFFmpegAvailable,
getSupportedExtensions,
clearError
} = useMaterialStore();
@@ -35,7 +40,11 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
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<string[]>([]);
const [recursiveScan, setRecursiveScan] = useState(true);
const [selectedFileTypes, setSelectedFileTypes] = useState<string[]>([]);
// 检查 FFmpeg 可用性
useEffect(() => {
@@ -53,6 +62,48 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
}
}, [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<MaterialImportDialogProps> = ({
const validFiles = await validateMaterialFiles(filePaths);
setSelectedFiles(validFiles);
if (validFiles.length > 0) {
setImportMode('files');
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 () => {
if (selectedFiles.length === 0) return;
@@ -83,11 +170,12 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
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<MaterialImportDialogProps> = ({
</div>
)}
{/* 步骤 1: 选择文件 */}
{/* 步骤 1: 选择导入方式 */}
{step === 'select' && (
<div className="space-y-6">
<div className="text-center">
<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>
<button
onClick={handleSelectFiles}
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Upload className="w-5 h-5 mr-2" />
</button>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={handleSelectFiles}
className="p-6 border-2 border-dashed border-gray-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors group"
>
<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>
)}
{/* 步骤 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' && (
<div className="space-y-6">
<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">
{step === 'configure' && (
{step === 'batch' && (
<>
<button
onClick={() => setStep('select')}
@@ -290,6 +451,24 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
>
</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
onClick={handleStartImport}
disabled={selectedFiles.length === 0}
@@ -299,7 +478,7 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
</button>
</>
)}
{(step === 'select' || step === 'complete') && (
<button
onClick={onClose}

View File

@@ -34,11 +34,25 @@ interface MaterialState {
loadMaterials: (projectId: string) => Promise<void>;
loadMaterialStats: (projectId: string) => Promise<void>;
importMaterials: (request: CreateMaterialRequest) => Promise<MaterialImportResult>;
importMaterialsAsync: (request: CreateMaterialRequest) => Promise<MaterialImportResult>;
getMaterialById: (id: string) => Promise<Material | null>;
getMaterialSegments: (materialId: string) => Promise<MaterialSegment[]>;
deleteMaterial: (id: string) => Promise<void>;
processMaterials: (materialIds: 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>;
setCurrentMaterial: (material: Material | null) => void;
clearError: () => void;
@@ -88,7 +102,7 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
}
},
// 导入素材
// 导入素材(同步版本)
importMaterials: async (request: CreateMaterialRequest) => {
set({ isImporting: true, error: null, importProgress: {
current_file: '',
@@ -100,10 +114,10 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
try {
const result = await invoke<MaterialImportResult>('import_materials', { request });
// 更新素材列表
const { materials } = get();
set({
set({
materials: [...materials, ...result.created_materials],
isImporting: false,
importProgress: null
@@ -114,8 +128,8 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
return result;
} catch (error) {
set({
error: error as string,
set({
error: error as string,
isImporting: false,
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) => {
try {
@@ -325,4 +393,28 @@ export const useMaterialStore = create<MaterialState>((set, get) => ({
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 [];
}
},
}));