feat: 优化前端loading状态和用户体验
- 新增MaterialCardSkeleton骨架屏组件 - 优化MaterialImportDialog的进度显示 - 添加详细的导入进度动画和状态指示器 - 改进项目统计信息的loading状态 - 新增LoadingState通用加载组件 - 优化按钮状态和禁用逻辑 - 改进空状态显示和交互 UI/UX改进: 1. 骨架屏loading:替换简单spinner为详细骨架屏 2. 进度指示器:添加阶段指示器和动画效果 3. 状态反馈:实时显示当前处理文件和进度 4. 按钮状态:导入过程中禁用相关按钮 5. 视觉优化:添加图标、颜色和动画效果
This commit is contained in:
parent
73f542af40
commit
91062ccf4c
12
0.1.1.md
12
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
|
||||
|
|
|
|||
|
|
@ -2173,7 +2173,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "mixvideo-desktop"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
|
|||
|
|
@ -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<MaterialImportResult, String> {
|
||||
// 获取数据库连接,避免持有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<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]
|
||||
pub async fn select_material_folders(app_handle: tauri::AppHandle) -> Result<Vec<String>, String> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
}) => {
|
||||
const {
|
||||
// isImporting,
|
||||
isImporting,
|
||||
importProgress,
|
||||
error,
|
||||
importMaterialsAsync,
|
||||
|
|
@ -66,42 +66,53 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||
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<MaterialImportDialogProps> = ({
|
|||
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<MaterialImportDialogProps> = ({
|
|||
{step === 'importing' && (
|
||||
<div className="space-y-6">
|
||||
<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>
|
||||
|
||||
{importProgress && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
{importProgress.current_status}
|
||||
</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 className="relative">
|
||||
<div className="w-20 h-20 mx-auto mb-6">
|
||||
<div className="absolute inset-0 border-4 border-blue-200 rounded-full"></div>
|
||||
<div className="absolute inset-0 border-4 border-blue-600 rounded-full border-t-transparent animate-spin"></div>
|
||||
<div className="absolute inset-2 flex items-center justify-center">
|
||||
<Upload className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importProgress.current_file && (
|
||||
<div className="text-sm text-gray-600">
|
||||
当前文件: {getFileName(importProgress.current_file)}
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">正在导入素材</h3>
|
||||
|
||||
{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>
|
||||
|
||||
{importProgress.errors.length > 0 && (
|
||||
<div className="text-left">
|
||||
<h4 className="text-sm font-medium text-red-600 mb-2">错误信息:</h4>
|
||||
<div className="max-h-20 overflow-y-auto text-xs text-red-600 space-y-1">
|
||||
{importProgress.errors.map((error, index) => (
|
||||
<div key={index}>{error}</div>
|
||||
))}
|
||||
{/* 进度条 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-600">进度</span>
|
||||
<span className="font-semibold text-blue-600">
|
||||
{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 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>
|
||||
|
|
@ -453,10 +559,17 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
扫描文件夹
|
||||
{isImporting ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>处理中...</span>
|
||||
</div>
|
||||
) : (
|
||||
'扫描文件夹'
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -471,10 +584,17 @@ export const MaterialImportDialog: React.FC<MaterialImportDialogProps> = ({
|
|||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
开始导入
|
||||
{isImporting ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>导入中...</span>
|
||||
</div>
|
||||
) : (
|
||||
'开始导入'
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">项目素材</h3>
|
||||
{materialsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<MaterialCardSkeleton count={6} />
|
||||
</div>
|
||||
) : materials.length > 0 ? (
|
||||
<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 className="text-center py-8 text-gray-500">
|
||||
<p>暂无素材,请导入素材文件开始使用</p>
|
||||
<div className="text-center py-12">
|
||||
<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>
|
||||
|
|
@ -272,30 +284,66 @@ export const ProjectDetails: React.FC = () => {
|
|||
{/* 项目统计 */}
|
||||
<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>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">总素材数</span>
|
||||
<span className="font-medium">{stats?.total_materials || 0}</span>
|
||||
{materialsLoading ? (
|
||||
<div className="space-y-3 animate-pulse">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<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 className="flex justify-between">
|
||||
<span className="text-gray-600">视频文件</span>
|
||||
<span className="font-medium">{stats?.video_count || 0}</span>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<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 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>
|
||||
|
||||
{/* 最近活动 */}
|
||||
|
|
|
|||
Loading…
Reference in New Issue