feat: 优化前端loading状态和用户体验

- 新增MaterialCardSkeleton骨架屏组件
- 优化MaterialImportDialog的进度显示
- 添加详细的导入进度动画和状态指示器
- 改进项目统计信息的loading状态
- 新增LoadingState通用加载组件
- 优化按钮状态和禁用逻辑
- 改进空状态显示和交互

UI/UX改进:
1. 骨架屏loading:替换简单spinner为详细骨架屏
2. 进度指示器:添加阶段指示器和动画效果
3. 状态反馈:实时显示当前处理文件和进度
4. 按钮状态:导入过程中禁用相关按钮
5. 视觉优化:添加图标、颜色和动画效果
This commit is contained in:
imeepos 2025-07-13 23:51:39 +08:00
parent 73f542af40
commit 91062ccf4c
7 changed files with 623 additions and 106 deletions

View File

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

2
Cargo.lock generated
View File

@ -2173,7 +2173,7 @@ dependencies = [
[[package]]
name = "mixvideo-desktop"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"anyhow",
"chrono",

View File

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

View 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;

View 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;

View File

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

View File

@ -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>
{/* 最近活动 */}