diff --git a/0.1.1.md b/0.1.1.md index fb9105b..fd0e310 100644 --- a/0.1.1.md +++ b/0.1.1.md @@ -28,10 +28,18 @@ - 镜头切换算法 我指导python中有第三方库叫 PySceneDetect‌ 检查下 rust有无同类库 如果有直接用 避免造轮子 -## 0.1.3 核心功能开发 +## 0.1.4 核心功能开发 新建feature分支完成一下功能开发: 根据promptx\tauri-desktop-app-expert规定的开发规范 完成下面功能的开发 -1. feature: 开发批量导入功能 + +1. feature: 在导入时启动异步处理(更好的用户体验) +2. feature: 开发批量导入功能 + + +## 0.1.5 UI美化 UX改进 +新建feature分支完成一下功能开发 +根据promptx\frontend-developer规定的前端开发规范 优化现有UI和UX操作体验 +要求界面美观 操作流畅 动画优美 合理化信息展示及布局 符合用户操作习惯和大众审美习惯 ## BUG diff --git a/Cargo.lock b/Cargo.lock index ccf49ef..bdc27d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2173,7 +2173,7 @@ dependencies = [ [[package]] name = "mixvideo-desktop" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "chrono", 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 637761e..6abee4d 100644 --- a/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs +++ b/apps/desktop/src-tauri/src/presentation/commands/material_commands.rs @@ -1,8 +1,7 @@ -use tauri::{command, State}; +use tauri::{command, State, Emitter}; 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::{ @@ -41,7 +40,7 @@ pub async fn import_materials( pub async fn import_materials_async( state: State<'_, AppState>, request: CreateMaterialRequest, - _app_handle: tauri::AppHandle, + app_handle: tauri::AppHandle, ) -> Result { // 获取数据库连接,避免持有MutexGuard let connection = { @@ -65,18 +64,192 @@ pub async fn import_materials_async( .map_err(|e| format!("创建异步仓库失败: {}", e))?; let repository_arc = Arc::new(async_repository); - // 获取事件总线 - let event_bus = state.event_bus_manager.clone(); - - // 直接执行异步导入 - AsyncMaterialService::import_materials_async( + // 使用简化的异步导入,直接通过 Tauri 事件发送进度 + import_materials_with_tauri_events( repository_arc, request, config, - event_bus, + app_handle, ).await.map_err(|e| e.to_string()) } +/// 使用 Tauri 事件系统的异步导入实现 +async fn import_materials_with_tauri_events( + repository: Arc, + request: CreateMaterialRequest, + config: MaterialProcessingConfig, + app_handle: tauri::AppHandle, +) -> Result { + use anyhow::anyhow; + use std::path::Path; + use std::time::Instant; + use tracing::{info, warn, error, debug}; + use crate::data::models::material::Material; + + 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 _ = app_handle.emit("material_import_progress", serde_json::json!({ + "current_file": "准备导入...", + "processed_count": 0, + "total_count": result.total_files, + "current_status": "正在准备导入文件", + "progress_percentage": 0.0 + })); + + // 异步处理每个文件 + for (index, file_path) in request.file_paths.iter().enumerate() { + debug!(file_path = %file_path, "异步处理文件"); + + // 发布当前文件处理进度 + let _ = app_handle.emit("material_import_progress", serde_json::json!({ + "current_file": file_path, + "processed_count": index, + "total_count": result.total_files, + "current_status": format!("正在处理文件: {}", Path::new(file_path).file_name() + .and_then(|n| n.to_str()).unwrap_or("unknown")), + "progress_percentage": (index as f64 / result.total_files as f64) * 100.0 + })); + + // 处理单个文件 + match process_single_file_simple( + Arc::clone(&repository), + &request.project_id, + file_path, + &config, + ).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; + + info!( + processed_files = result.processed_files, + skipped_files = result.skipped_files, + failed_files = result.failed_files, + processing_time = result.processing_time, + "异步素材导入完成" + ); + + // 发布导入完成事件 + if success { + let _ = app_handle.emit("material_import_completed", &result); + } else { + let _ = app_handle.emit("material_import_failed", + format!("导入失败,{} 个文件处理失败", result.failed_files)); + } + + Ok(result) +} + +/// 简化的单文件处理函数 +async fn process_single_file_simple( + repository: Arc, + project_id: &str, + file_path: &str, + _config: &MaterialProcessingConfig, +) -> Result, anyhow::Error> { + use anyhow::anyhow; + use std::path::Path; + use std::fs; + use tokio::task; + use crate::data::models::material::{Material, MaterialType}; + use crate::business::errors::error_utils; + + // 验证文件路径 + 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)?; + + Ok(Some(material)) +} + /// 选择文件夹进行批量导入 #[command] pub async fn select_material_folders(app_handle: tauri::AppHandle) -> Result, String> { diff --git a/apps/desktop/src/components/LoadingState.tsx b/apps/desktop/src/components/LoadingState.tsx new file mode 100644 index 0000000..5f9b269 --- /dev/null +++ b/apps/desktop/src/components/LoadingState.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { Loader2 } from 'lucide-react'; + +interface LoadingStateProps { + message?: string; + size?: 'small' | 'medium' | 'large'; + variant?: 'spinner' | 'dots' | 'pulse'; + className?: string; +} + +const LoadingState: React.FC = ({ + message = '加载中...', + size = 'medium', + variant = 'spinner', + className = '' +}) => { + const getSizeClasses = () => { + switch (size) { + case 'small': + return 'w-4 h-4'; + case 'large': + return 'w-8 h-8'; + default: + return 'w-6 h-6'; + } + }; + + const getTextSizeClasses = () => { + switch (size) { + case 'small': + return 'text-xs'; + case 'large': + return 'text-lg'; + default: + return 'text-sm'; + } + }; + + const renderSpinner = () => ( +
+ + {message && ( + + {message} + + )} +
+ ); + + const renderDots = () => ( +
+ {message && ( + + {message} + + )} +
+
+
+
+
+
+ ); + + const renderPulse = () => ( +
+
+ {message && ( + + {message} + + )} +
+ ); + + switch (variant) { + case 'dots': + return renderDots(); + case 'pulse': + return renderPulse(); + default: + return renderSpinner(); + } +}; + +export default LoadingState; diff --git a/apps/desktop/src/components/MaterialCardSkeleton.tsx b/apps/desktop/src/components/MaterialCardSkeleton.tsx new file mode 100644 index 0000000..1698548 --- /dev/null +++ b/apps/desktop/src/components/MaterialCardSkeleton.tsx @@ -0,0 +1,82 @@ +import React from 'react'; + +interface MaterialCardSkeletonProps { + count?: number; +} + +const MaterialCardSkeleton: React.FC = ({ count = 6 }) => { + return ( + <> + {Array.from({ length: count }).map((_, index) => ( +
+ {/* 头部 */} +
+
+ {/* 文件类型图标 */} +
+
+ {/* 文件名 */} +
+ {/* 状态 */} +
+
+
+ {/* 操作按钮 */} +
+
+ + {/* 基本信息 */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {/* 元数据信息 */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {/* 底部操作区域 */} +
+
+
+
+
+
+
+
+
+
+ ))} + + ); +}; + +export default MaterialCardSkeleton; diff --git a/apps/desktop/src/components/MaterialImportDialog.tsx b/apps/desktop/src/components/MaterialImportDialog.tsx index e9c133b..dd0c2e2 100644 --- a/apps/desktop/src/components/MaterialImportDialog.tsx +++ b/apps/desktop/src/components/MaterialImportDialog.tsx @@ -22,7 +22,7 @@ export const MaterialImportDialog: React.FC = ({ onImportComplete }) => { const { - // isImporting, + isImporting, importProgress, error, importMaterialsAsync, @@ -66,42 +66,53 @@ export const MaterialImportDialog: React.FC = ({ useEffect(() => { if (!isOpen) return; + let unlistenProgress: (() => void) | null = null; + let unlistenCompleted: (() => void) | null = null; + let unlistenFailed: (() => void) | null = null; + 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, + try { + // 监听导入进度事件 + unlistenProgress = await listen('material_import_progress', (event: any) => { + console.log('收到进度事件:', event.payload); + 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); - }); + // 监听导入完成事件 + unlistenCompleted = await listen('material_import_completed', (event: any) => { + console.log('收到完成事件:', event.payload); + 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(); - }; + // 监听导入失败事件 + unlistenFailed = await listen('material_import_failed', (event: any) => { + console.log('收到失败事件:', event.payload); + const errorMessage = event.payload; + console.error('导入失败:', errorMessage); + setStep('configure'); // 返回配置步骤 + }); + } catch (error) { + console.error('设置事件监听器失败:', error); + } }; setupEventListeners(); + + // 清理函数 + return () => { + if (unlistenProgress) unlistenProgress(); + if (unlistenCompleted) unlistenCompleted(); + if (unlistenFailed) unlistenFailed(); + }; }, [isOpen, updateImportProgress, onImportComplete]); // 选择文件 @@ -170,9 +181,15 @@ export const MaterialImportDialog: React.FC = ({ max_segment_duration: maxSegmentDuration, }; + console.log('开始异步导入:', request); + // 使用异步导入,进度更新通过事件监听器处理 - await importMaterialsAsync(request); - // 注意:完成状态由事件监听器设置,这里不需要手动设置 + const result = await importMaterialsAsync(request); + + // 如果没有通过事件监听器收到完成事件,手动处理完成状态 + console.log('异步导入完成:', result); + setStep('complete'); + onImportComplete(result); } catch (error) { console.error('导入失败:', error); setStep('configure'); // 返回配置步骤 @@ -383,44 +400,133 @@ export const MaterialImportDialog: React.FC = ({ {step === 'importing' && (
- -

正在导入素材

- - {importProgress && ( -
-
- {importProgress.current_status} -
- -
-
-
- -
- {importProgress.processed_count} / {importProgress.total_count} 文件已处理 + {/* 主要加载动画 */} +
+
+
+
+
+
+
+
- {importProgress.current_file && ( -
- 当前文件: {getFileName(importProgress.current_file)} +

正在导入素材

+ + {importProgress ? ( +
+ {/* 状态描述 */} +
+
+
+ {importProgress.current_status}
- )} +
- {importProgress.errors.length > 0 && ( -
-

错误信息:

-
- {importProgress.errors.map((error, index) => ( -
{error}
- ))} + {/* 进度条 */} +
+
+ 进度 + + {Math.round((importProgress.processed_count / importProgress.total_count) * 100)}% + +
+ +
+
+
+
+
+ +
+ {importProgress.processed_count} / {importProgress.total_count} 文件 + 剩余 {importProgress.total_count - importProgress.processed_count} 个 +
+
+ + {/* 当前处理文件 */} + {importProgress.current_file && ( +
+
+
+ +
+
+

正在处理

+

+ {getFileName(importProgress.current_file)} +

+
+
+ +
)} + + {/* 处理阶段指示器 */} +
+
+
0 ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400' + }`}> + +
+ 文件扫描 +
+
+
0 ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-400' + }`}> + 0 ? 'animate-spin' : ''}`} /> +
+ 数据处理 +
+
+
+ +
+ 导入完成 +
+
+ + {/* 错误信息 */} + {importProgress.errors.length > 0 && ( +
+
+ +
+

+ 处理错误 ({importProgress.errors.length}) +

+
+ {importProgress.errors.map((error, index) => ( +
+ {error} +
+ ))} +
+
+
+
+ )} +
+ ) : ( + /* 初始化状态 */ +
+
正在初始化导入流程...
+
+
+
+
+
)}
@@ -453,10 +559,17 @@ export const MaterialImportDialog: React.FC = ({ )} @@ -471,10 +584,17 @@ export const MaterialImportDialog: React.FC = ({ )} diff --git a/apps/desktop/src/pages/ProjectDetails.tsx b/apps/desktop/src/pages/ProjectDetails.tsx index 380d09d..5083987 100644 --- a/apps/desktop/src/pages/ProjectDetails.tsx +++ b/apps/desktop/src/pages/ProjectDetails.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { ArrowLeft, FolderOpen, Calendar, Settings, Upload } from 'lucide-react'; +import { ArrowLeft, FolderOpen, Calendar, Settings, Upload, FileVideo, FileAudio, FileImage, HardDrive } from 'lucide-react'; import { useProjectStore } from '../store/projectStore'; import { useMaterialStore } from '../store/materialStore'; import { Project } from '../types/project'; @@ -10,6 +10,7 @@ import { ErrorMessage } from '../components/ErrorMessage'; import { MaterialImportDialog } from '../components/MaterialImportDialog'; import { FFmpegDebugPanel } from '../components/FFmpegDebugPanel'; import { MaterialCard } from '../components/MaterialCard'; +import MaterialCardSkeleton from '../components/MaterialCardSkeleton'; /** * 项目详情页面组件 @@ -242,8 +243,8 @@ export const ProjectDetails: React.FC = () => {

项目素材

{materialsLoading ? ( -
- +
+
) : materials.length > 0 ? (
@@ -252,8 +253,19 @@ export const ProjectDetails: React.FC = () => { ))}
) : ( -
-

暂无素材,请导入素材文件开始使用

+
+
+ +
+

暂无素材

+

请导入素材文件开始使用

+
)}
@@ -272,30 +284,66 @@ export const ProjectDetails: React.FC = () => { {/* 项目统计 */}

项目统计

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