feat: 优化前端loading状态和用户体验
- 新增MaterialCardSkeleton骨架屏组件 - 优化MaterialImportDialog的进度显示 - 添加详细的导入进度动画和状态指示器 - 改进项目统计信息的loading状态 - 新增LoadingState通用加载组件 - 优化按钮状态和禁用逻辑 - 改进空状态显示和交互 UI/UX改进: 1. 骨架屏loading:替换简单spinner为详细骨架屏 2. 进度指示器:添加阶段指示器和动画效果 3. 状态反馈:实时显示当前处理文件和进度 4. 按钮状态:导入过程中禁用相关按钮 5. 视觉优化:添加图标、颜色和动画效果
This commit is contained in:
12
0.1.1.md
12
0.1.1.md
@@ -28,10 +28,18 @@
|
|||||||
- 镜头切换算法 我指导python中有第三方库叫 PySceneDetect 检查下 rust有无同类库 如果有直接用 避免造轮子
|
- 镜头切换算法 我指导python中有第三方库叫 PySceneDetect 检查下 rust有无同类库 如果有直接用 避免造轮子
|
||||||
|
|
||||||
|
|
||||||
## 0.1.3 核心功能开发
|
## 0.1.4 核心功能开发
|
||||||
新建feature分支完成一下功能开发:
|
新建feature分支完成一下功能开发:
|
||||||
根据promptx\tauri-desktop-app-expert规定的开发规范 完成下面功能的开发
|
根据promptx\tauri-desktop-app-expert规定的开发规范 完成下面功能的开发
|
||||||
1. feature: 开发批量导入功能
|
|
||||||
|
1. feature: 在导入时启动异步处理(更好的用户体验)
|
||||||
|
2. feature: 开发批量导入功能
|
||||||
|
|
||||||
|
|
||||||
|
## 0.1.5 UI美化 UX改进
|
||||||
|
新建feature分支完成一下功能开发
|
||||||
|
根据promptx\frontend-developer规定的前端开发规范 优化现有UI和UX操作体验
|
||||||
|
要求界面美观 操作流畅 动画优美 合理化信息展示及布局 符合用户操作习惯和大众审美习惯
|
||||||
|
|
||||||
|
|
||||||
## BUG
|
## BUG
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2173,7 +2173,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mixvideo-desktop"
|
name = "mixvideo-desktop"
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
use tauri::{command, State};
|
use tauri::{command, State, Emitter};
|
||||||
use std::sync::Arc;
|
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::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::{
|
||||||
@@ -41,7 +40,7 @@ pub async fn import_materials(
|
|||||||
pub async fn import_materials_async(
|
pub async fn import_materials_async(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
request: CreateMaterialRequest,
|
request: CreateMaterialRequest,
|
||||||
_app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
) -> Result<MaterialImportResult, String> {
|
) -> Result<MaterialImportResult, String> {
|
||||||
// 获取数据库连接,避免持有MutexGuard
|
// 获取数据库连接,避免持有MutexGuard
|
||||||
let connection = {
|
let connection = {
|
||||||
@@ -65,18 +64,192 @@ pub async fn import_materials_async(
|
|||||||
.map_err(|e| format!("创建异步仓库失败: {}", e))?;
|
.map_err(|e| format!("创建异步仓库失败: {}", e))?;
|
||||||
let repository_arc = Arc::new(async_repository);
|
let repository_arc = Arc::new(async_repository);
|
||||||
|
|
||||||
// 获取事件总线
|
// 使用简化的异步导入,直接通过 Tauri 事件发送进度
|
||||||
let event_bus = state.event_bus_manager.clone();
|
import_materials_with_tauri_events(
|
||||||
|
|
||||||
// 直接执行异步导入
|
|
||||||
AsyncMaterialService::import_materials_async(
|
|
||||||
repository_arc,
|
repository_arc,
|
||||||
request,
|
request,
|
||||||
config,
|
config,
|
||||||
event_bus,
|
app_handle,
|
||||||
).await.map_err(|e| e.to_string())
|
).await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 使用 Tauri 事件系统的异步导入实现
|
||||||
|
async fn import_materials_with_tauri_events(
|
||||||
|
repository: Arc<MaterialRepository>,
|
||||||
|
request: CreateMaterialRequest,
|
||||||
|
config: MaterialProcessingConfig,
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
) -> Result<MaterialImportResult, anyhow::Error> {
|
||||||
|
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<MaterialRepository>,
|
||||||
|
project_id: &str,
|
||||||
|
file_path: &str,
|
||||||
|
_config: &MaterialProcessingConfig,
|
||||||
|
) -> Result<Option<Material>, 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]
|
#[command]
|
||||||
pub async fn select_material_folders(app_handle: tauri::AppHandle) -> Result<Vec<String>, String> {
|
pub async fn select_material_folders(app_handle: tauri::AppHandle) -> Result<Vec<String>, String> {
|
||||||
|
|||||||
86
apps/desktop/src/components/LoadingState.tsx
Normal file
86
apps/desktop/src/components/LoadingState.tsx
Normal file
@@ -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<LoadingStateProps> = ({
|
||||||
|
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 = () => (
|
||||||
|
<div className={`flex items-center justify-center space-x-2 ${className}`}>
|
||||||
|
<Loader2 className={`${getSizeClasses()} text-blue-600 animate-spin`} />
|
||||||
|
{message && (
|
||||||
|
<span className={`${getTextSizeClasses()} text-gray-600 font-medium`}>
|
||||||
|
{message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderDots = () => (
|
||||||
|
<div className={`flex items-center justify-center space-x-2 ${className}`}>
|
||||||
|
{message && (
|
||||||
|
<span className={`${getTextSizeClasses()} text-gray-600 font-medium mr-2`}>
|
||||||
|
{message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex space-x-1">
|
||||||
|
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"></div>
|
||||||
|
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{animationDelay: '0.1s'}}></div>
|
||||||
|
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderPulse = () => (
|
||||||
|
<div className={`flex items-center justify-center space-x-2 ${className}`}>
|
||||||
|
<div className={`${getSizeClasses()} bg-blue-600 rounded-full animate-pulse`}></div>
|
||||||
|
{message && (
|
||||||
|
<span className={`${getTextSizeClasses()} text-gray-600 font-medium`}>
|
||||||
|
{message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (variant) {
|
||||||
|
case 'dots':
|
||||||
|
return renderDots();
|
||||||
|
case 'pulse':
|
||||||
|
return renderPulse();
|
||||||
|
default:
|
||||||
|
return renderSpinner();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingState;
|
||||||
82
apps/desktop/src/components/MaterialCardSkeleton.tsx
Normal file
82
apps/desktop/src/components/MaterialCardSkeleton.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface MaterialCardSkeletonProps {
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MaterialCardSkeleton: React.FC<MaterialCardSkeletonProps> = ({ count = 6 }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
|
<div key={index} className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 animate-pulse">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{/* 文件类型图标 */}
|
||||||
|
<div className="w-10 h-10 bg-gray-200 rounded-lg"></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* 文件名 */}
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-32"></div>
|
||||||
|
{/* 状态 */}
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-16"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="w-8 h-8 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 基本信息 */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-12"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16"></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-12"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20"></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-12"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-24"></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-12"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-18"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 元数据信息 */}
|
||||||
|
<div className="border-t border-gray-100 pt-4">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-16"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20"></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-14"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-18"></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-12"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部操作区域 */}
|
||||||
|
<div className="border-t border-gray-100 pt-4 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-16"></div>
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-20"></div>
|
||||||
|
</div>
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-12"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MaterialCardSkeleton;
|
||||||
@@ -22,7 +22,7 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||||||
onImportComplete
|
onImportComplete
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
// isImporting,
|
isImporting,
|
||||||
importProgress,
|
importProgress,
|
||||||
error,
|
error,
|
||||||
importMaterialsAsync,
|
importMaterialsAsync,
|
||||||
@@ -66,42 +66,53 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
let unlistenProgress: (() => void) | null = null;
|
||||||
|
let unlistenCompleted: (() => void) | null = null;
|
||||||
|
let unlistenFailed: (() => void) | null = null;
|
||||||
|
|
||||||
const setupEventListeners = async () => {
|
const setupEventListeners = async () => {
|
||||||
// 监听导入进度事件
|
try {
|
||||||
const unlistenProgress = await listen('material_import_progress', (event: any) => {
|
// 监听导入进度事件
|
||||||
const progressData = event.payload;
|
unlistenProgress = await listen('material_import_progress', (event: any) => {
|
||||||
updateImportProgress({
|
console.log('收到进度事件:', event.payload);
|
||||||
current_file: progressData.current_file,
|
const progressData = event.payload;
|
||||||
processed_count: progressData.processed_count,
|
updateImportProgress({
|
||||||
total_count: progressData.total_count,
|
current_file: progressData.current_file,
|
||||||
current_status: progressData.current_status,
|
processed_count: progressData.processed_count,
|
||||||
progress_percentage: progressData.progress_percentage,
|
total_count: progressData.total_count,
|
||||||
|
current_status: progressData.current_status,
|
||||||
|
progress_percentage: progressData.progress_percentage,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// 监听导入完成事件
|
// 监听导入完成事件
|
||||||
const unlistenCompleted = await listen('material_import_completed', (event: any) => {
|
unlistenCompleted = await listen('material_import_completed', (event: any) => {
|
||||||
const result = event.payload;
|
console.log('收到完成事件:', event.payload);
|
||||||
setStep('complete');
|
const result = event.payload;
|
||||||
onImportComplete(result);
|
setStep('complete');
|
||||||
});
|
onImportComplete(result);
|
||||||
|
});
|
||||||
|
|
||||||
// 监听导入失败事件
|
// 监听导入失败事件
|
||||||
const unlistenFailed = await listen('material_import_failed', (event: any) => {
|
unlistenFailed = await listen('material_import_failed', (event: any) => {
|
||||||
const errorMessage = event.payload;
|
console.log('收到失败事件:', event.payload);
|
||||||
console.error('导入失败:', errorMessage);
|
const errorMessage = event.payload;
|
||||||
setStep('configure'); // 返回配置步骤
|
console.error('导入失败:', errorMessage);
|
||||||
});
|
setStep('configure'); // 返回配置步骤
|
||||||
|
});
|
||||||
// 清理函数
|
} catch (error) {
|
||||||
return () => {
|
console.error('设置事件监听器失败:', error);
|
||||||
unlistenProgress();
|
}
|
||||||
unlistenCompleted();
|
|
||||||
unlistenFailed();
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
|
|
||||||
|
// 清理函数
|
||||||
|
return () => {
|
||||||
|
if (unlistenProgress) unlistenProgress();
|
||||||
|
if (unlistenCompleted) unlistenCompleted();
|
||||||
|
if (unlistenFailed) unlistenFailed();
|
||||||
|
};
|
||||||
}, [isOpen, updateImportProgress, onImportComplete]);
|
}, [isOpen, updateImportProgress, onImportComplete]);
|
||||||
|
|
||||||
// 选择文件
|
// 选择文件
|
||||||
@@ -170,9 +181,15 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||||||
max_segment_duration: maxSegmentDuration,
|
max_segment_duration: maxSegmentDuration,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('开始异步导入:', request);
|
||||||
|
|
||||||
// 使用异步导入,进度更新通过事件监听器处理
|
// 使用异步导入,进度更新通过事件监听器处理
|
||||||
await importMaterialsAsync(request);
|
const result = await importMaterialsAsync(request);
|
||||||
// 注意:完成状态由事件监听器设置,这里不需要手动设置
|
|
||||||
|
// 如果没有通过事件监听器收到完成事件,手动处理完成状态
|
||||||
|
console.log('异步导入完成:', result);
|
||||||
|
setStep('complete');
|
||||||
|
onImportComplete(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('导入失败:', error);
|
console.error('导入失败:', error);
|
||||||
setStep('configure'); // 返回配置步骤
|
setStep('configure'); // 返回配置步骤
|
||||||
@@ -383,44 +400,133 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||||||
{step === 'importing' && (
|
{step === 'importing' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Loader2 className="w-16 h-16 text-blue-600 mx-auto mb-4 animate-spin" />
|
{/* 主要加载动画 */}
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">正在导入素材</h3>
|
<div className="relative">
|
||||||
|
<div className="w-20 h-20 mx-auto mb-6">
|
||||||
{importProgress && (
|
<div className="absolute inset-0 border-4 border-blue-200 rounded-full"></div>
|
||||||
<div className="space-y-4">
|
<div className="absolute inset-0 border-4 border-blue-600 rounded-full border-t-transparent animate-spin"></div>
|
||||||
<div className="text-sm text-gray-600">
|
<div className="absolute inset-2 flex items-center justify-center">
|
||||||
{importProgress.current_status}
|
<Upload className="w-8 h-8 text-blue-600" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
|
||||||
style={{
|
|
||||||
width: `${(importProgress.processed_count / importProgress.total_count) * 100}%`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
{importProgress.processed_count} / {importProgress.total_count} 文件已处理
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{importProgress.current_file && (
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">正在导入素材</h3>
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
当前文件: {getFileName(importProgress.current_file)}
|
{importProgress ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 状态描述 */}
|
||||||
|
<div className="flex items-center justify-center space-x-2">
|
||||||
|
<div className="w-2 h-2 bg-blue-600 rounded-full animate-pulse"></div>
|
||||||
|
<div className="text-sm text-gray-600 font-medium">
|
||||||
|
{importProgress.current_status}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{importProgress.errors.length > 0 && (
|
{/* 进度条 */}
|
||||||
<div className="text-left">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium text-red-600 mb-2">错误信息:</h4>
|
<div className="flex justify-between items-center text-sm">
|
||||||
<div className="max-h-20 overflow-y-auto text-xs text-red-600 space-y-1">
|
<span className="text-gray-600">进度</span>
|
||||||
{importProgress.errors.map((error, index) => (
|
<span className="font-semibold text-blue-600">
|
||||||
<div key={index}>{error}</div>
|
{Math.round((importProgress.processed_count / importProgress.total_count) * 100)}%
|
||||||
))}
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-blue-500 to-blue-600 h-3 rounded-full transition-all duration-500 ease-out relative"
|
||||||
|
style={{
|
||||||
|
width: `${(importProgress.processed_count / importProgress.total_count) * 100}%`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-white opacity-30 animate-pulse"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between text-xs text-gray-500">
|
||||||
|
<span>{importProgress.processed_count} / {importProgress.total_count} 文件</span>
|
||||||
|
<span>剩余 {importProgress.total_count - importProgress.processed_count} 个</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 当前处理文件 */}
|
||||||
|
{importProgress.current_file && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<FileText className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-blue-900">正在处理</p>
|
||||||
|
<p className="text-sm text-blue-700 truncate" title={importProgress.current_file}>
|
||||||
|
{getFileName(importProgress.current_file)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Loader2 className="w-4 h-4 text-blue-600 animate-spin" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 处理阶段指示器 */}
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||||
|
<div className="flex flex-col items-center space-y-1">
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||||
|
importProgress.processed_count > 0 ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'
|
||||||
|
}`}>
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-600">文件扫描</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center space-y-1">
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||||
|
importProgress.processed_count > 0 ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-400'
|
||||||
|
}`}>
|
||||||
|
<Loader2 className={`w-4 h-4 ${importProgress.processed_count > 0 ? 'animate-spin' : ''}`} />
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-600">数据处理</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center space-y-1">
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||||
|
importProgress.processed_count === importProgress.total_count ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'
|
||||||
|
}`}>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-600">导入完成</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误信息 */}
|
||||||
|
{importProgress.errors.length > 0 && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-sm font-medium text-red-800 mb-2">
|
||||||
|
处理错误 ({importProgress.errors.length})
|
||||||
|
</h4>
|
||||||
|
<div className="max-h-24 overflow-y-auto text-xs text-red-700 space-y-1">
|
||||||
|
{importProgress.errors.map((error, index) => (
|
||||||
|
<div key={index} className="bg-red-100 p-2 rounded border-l-2 border-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 初始化状态 */
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-sm text-gray-600">正在初始化导入流程...</div>
|
||||||
|
<div className="flex justify-center space-x-1">
|
||||||
|
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"></div>
|
||||||
|
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{animationDelay: '0.1s'}}></div>
|
||||||
|
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{animationDelay: '0.2s'}}></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -453,10 +559,17 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleScanFolders}
|
onClick={handleScanFolders}
|
||||||
disabled={selectedFolders.length === 0}
|
disabled={selectedFolders.length === 0 || isImporting}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
扫描文件夹
|
{isImporting ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>处理中...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'扫描文件夹'
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -471,10 +584,17 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleStartImport}
|
onClick={handleStartImport}
|
||||||
disabled={selectedFiles.length === 0}
|
disabled={selectedFiles.length === 0 || isImporting}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
开始导入
|
{isImporting ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>导入中...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'开始导入'
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
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 { useProjectStore } from '../store/projectStore';
|
||||||
import { useMaterialStore } from '../store/materialStore';
|
import { useMaterialStore } from '../store/materialStore';
|
||||||
import { Project } from '../types/project';
|
import { Project } from '../types/project';
|
||||||
@@ -10,6 +10,7 @@ import { ErrorMessage } from '../components/ErrorMessage';
|
|||||||
import { MaterialImportDialog } from '../components/MaterialImportDialog';
|
import { MaterialImportDialog } from '../components/MaterialImportDialog';
|
||||||
import { FFmpegDebugPanel } from '../components/FFmpegDebugPanel';
|
import { FFmpegDebugPanel } from '../components/FFmpegDebugPanel';
|
||||||
import { MaterialCard } from '../components/MaterialCard';
|
import { MaterialCard } from '../components/MaterialCard';
|
||||||
|
import MaterialCardSkeleton from '../components/MaterialCardSkeleton';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 项目详情页面组件
|
* 项目详情页面组件
|
||||||
@@ -242,8 +243,8 @@ export const ProjectDetails: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-3">项目素材</h3>
|
<h3 className="text-lg font-medium text-gray-900 mb-3">项目素材</h3>
|
||||||
{materialsLoading ? (
|
{materialsLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<LoadingSpinner />
|
<MaterialCardSkeleton count={6} />
|
||||||
</div>
|
</div>
|
||||||
) : materials.length > 0 ? (
|
) : materials.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
@@ -252,8 +253,19 @@ export const ProjectDetails: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 text-gray-500">
|
<div className="text-center py-12">
|
||||||
<p>暂无素材,请导入素材文件开始使用</p>
|
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
|
||||||
|
<FolderOpen className="w-8 h-8 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-lg font-medium text-gray-900 mb-2">暂无素材</h4>
|
||||||
|
<p className="text-gray-500 mb-4">请导入素材文件开始使用</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowImportDialog(true)}
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
导入素材
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -272,30 +284,66 @@ export const ProjectDetails: React.FC = () => {
|
|||||||
{/* 项目统计 */}
|
{/* 项目统计 */}
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">项目统计</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">项目统计</h3>
|
||||||
<div className="space-y-3">
|
{materialsLoading ? (
|
||||||
<div className="flex justify-between">
|
<div className="space-y-3 animate-pulse">
|
||||||
<span className="text-gray-600">总素材数</span>
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
<span className="font-medium">{stats?.total_materials || 0}</span>
|
<div key={index} className="flex justify-between">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-12"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
) : (
|
||||||
<span className="text-gray-600">视频文件</span>
|
<div className="space-y-3">
|
||||||
<span className="font-medium">{stats?.video_count || 0}</span>
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-600">总素材数</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="font-medium">{stats?.total_materials || 0}</span>
|
||||||
|
{(stats?.total_materials || 0) > 0 && (
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-600">视频文件</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="font-medium">{stats?.video_count || 0}</span>
|
||||||
|
{(stats?.video_count || 0) > 0 && (
|
||||||
|
<FileVideo className="w-4 h-4 text-blue-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-600">音频文件</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="font-medium">{stats?.audio_count || 0}</span>
|
||||||
|
{(stats?.audio_count || 0) > 0 && (
|
||||||
|
<FileAudio className="w-4 h-4 text-green-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-600">图片文件</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="font-medium">{stats?.image_count || 0}</span>
|
||||||
|
{(stats?.image_count || 0) > 0 && (
|
||||||
|
<FileImage className="w-4 h-4 text-purple-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-600">总大小</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{stats ? (stats.total_size / 1024 / 1024 / 1024).toFixed(2) + ' GB' : '0 GB'}
|
||||||
|
</span>
|
||||||
|
{(stats?.total_size || 0) > 0 && (
|
||||||
|
<HardDrive className="w-4 h-4 text-gray-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
)}
|
||||||
<span className="text-gray-600">音频文件</span>
|
|
||||||
<span className="font-medium">{stats?.audio_count || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">图片文件</span>
|
|
||||||
<span className="font-medium">{stats?.image_count || 0}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">总大小</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{stats ? (stats.total_size / 1024 / 1024 / 1024).toFixed(2) + ' GB' : '0 GB'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 最近活动 */}
|
{/* 最近活动 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user