feat: 重新设计视频生成工作台布局

采用专业视频制作工具的布局方式,提升用户操作体验:

 新布局设计:
- 左侧:素材区(tab切换:模特/产品/场景/动作/音乐/提示词)
- 中央:视频预览区(实时预览、播放控制、素材概览)
- 底部:紧凑型参数设置区(一行显示所有关键参数)
- 右侧:可折叠任务状态区(实时显示生成任务进度)

 新增组件:
- CentralVideoPreview: 专业的中央预览组件
- CompactVideoConfigPanel: 紧凑型配置面板
- TaskStatusPanel: 任务状态管理面板

 功能增强:
- 实时任务进度显示
- 任务管理(取消、重试、删除)
- 更直观的素材选择流程
- 专业的视频预览体验
- 响应式布局优化

 用户体验提升:
- 一屏显示所有关键信息
- 减少页面切换和滚动
- 符合专业视频制作工具习惯
- 提高工作效率
This commit is contained in:
imeepos
2025-07-29 14:26:17 +08:00
parent a40bb24adc
commit f906b47e9f
5 changed files with 924 additions and 166 deletions

View File

@@ -0,0 +1,234 @@
import React, { useState } from 'react';
import {
PlayIcon,
PauseIcon,
SpeakerWaveIcon,
SpeakerXMarkIcon,
ArrowsPointingOutIcon,
EyeIcon
} from '@heroicons/react/24/outline';
import {
VideoGenerationProject,
VideoGenerationConfig,
MaterialCategory,
MATERIAL_CATEGORY_CONFIG
} from '../../types/videoGeneration';
interface CentralVideoPreviewProps {
project: VideoGenerationProject;
onConfigChange?: (config: VideoGenerationConfig) => void;
}
/**
* 中央视频预览组件
* 专门用于工作台中央预览区域
*/
export const CentralVideoPreview: React.FC<CentralVideoPreviewProps> = ({
project,
onConfigChange
}) => {
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
// 获取所有选中的素材
const getAllSelectedAssets = () => {
const allAssets: any[] = [];
Object.entries(project.selected_assets).forEach(([category, assets]) => {
if (assets && assets.length > 0) {
allAssets.push(...assets.map(asset => ({ ...asset, category: category as MaterialCategory })));
}
});
return allAssets;
};
const selectedAssets = getAllSelectedAssets();
// 模拟播放控制
const handlePlayPause = () => {
setIsPlaying(!isPlaying);
};
const handleMuteToggle = () => {
setIsMuted(!isMuted);
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
const newTime = percent * project.generation_config.duration;
setCurrentTime(Math.max(0, Math.min(newTime, project.generation_config.duration)));
};
return (
<div className="h-full flex flex-col">
{/* 预览标题 */}
<div className="flex-shrink-0 flex items-center justify-between p-4 border-b border-gray-200">
<div className="flex items-center gap-2">
<EyeIcon className="h-5 w-5 text-primary-600" />
<h3 className="text-lg font-medium text-gray-900"></h3>
</div>
<div className="text-sm text-gray-500">
{project.generation_config.resolution} {project.generation_config.frame_rate}fps {project.generation_config.duration}s
</div>
</div>
{/* 主预览区域 */}
<div className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-4xl">
{/* 视频预览窗口 */}
<div className="aspect-video bg-gradient-to-br from-gray-900 to-gray-800 rounded-lg overflow-hidden relative shadow-lg">
{/* 模拟视频内容 */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center text-white">
<div className="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center mb-4 mx-auto">
<PlayIcon className="h-10 w-10" />
</div>
<p className="text-xl font-medium"></p>
<p className="text-sm text-gray-300 mt-2">
{selectedAssets.length}
</p>
</div>
</div>
{/* 播放控制覆盖层 */}
<div className="absolute inset-0 bg-black/0 hover:bg-black/20 transition-colors duration-200 group">
<div className="absolute bottom-4 left-4 right-4">
<div className="flex items-center justify-between text-white mb-3">
{/* 播放控制 */}
<div className="flex items-center gap-3">
<button
onClick={handlePlayPause}
className="w-12 h-12 bg-white/20 hover:bg-white/30 rounded-full flex items-center justify-center transition-colors duration-200"
>
{isPlaying ? (
<PauseIcon className="h-6 w-6" />
) : (
<PlayIcon className="h-6 w-6 ml-1" />
)}
</button>
<button
onClick={handleMuteToggle}
className="w-10 h-10 hover:bg-white/20 rounded-full flex items-center justify-center transition-colors duration-200"
>
{isMuted ? (
<SpeakerXMarkIcon className="h-5 w-5" />
) : (
<SpeakerWaveIcon className="h-5 w-5" />
)}
</button>
{/* 时间显示 */}
<div className="text-sm">
{Math.floor(currentTime)}s / {project.generation_config.duration}s
</div>
</div>
{/* 全屏按钮 */}
<button className="w-10 h-10 hover:bg-white/20 rounded-full flex items-center justify-center transition-colors duration-200">
<ArrowsPointingOutIcon className="h-5 w-5" />
</button>
</div>
{/* 进度条 */}
<div
className="w-full bg-white/20 rounded-full h-2 cursor-pointer"
onClick={handleSeek}
>
<div
className="bg-primary-500 h-2 rounded-full transition-all duration-200"
style={{ width: `${(currentTime / project.generation_config.duration) * 100}%` }}
></div>
</div>
</div>
</div>
</div>
</div>
</div>
{/* 选中素材概览 */}
<div className="flex-shrink-0 border-t border-gray-200 p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-900"></h4>
<span className="text-xs text-gray-500">{selectedAssets.length} </span>
</div>
{selectedAssets.length === 0 ? (
<div className="text-center py-4 text-gray-500">
<p className="text-sm"></p>
</div>
) : (
<div className="grid grid-cols-6 gap-2 max-h-32 overflow-y-auto custom-scrollbar">
{Object.values(MaterialCategory).map((category) => {
const categoryAssets = project.selected_assets[category] || [];
if (categoryAssets.length === 0) return null;
const config = MATERIAL_CATEGORY_CONFIG[category];
return categoryAssets.map((asset) => (
<div key={asset.id} className="group relative">
<div className="aspect-square bg-gray-100 rounded-lg overflow-hidden">
{asset.thumbnail_path ? (
<img
src={asset.thumbnail_path}
alt={asset.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<span className="text-lg">{config.icon}</span>
</div>
)}
</div>
{/* 素材信息提示 */}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center">
<div className="text-center text-white">
<div className="text-xs font-medium truncate px-1">{asset.name}</div>
<div className="text-xs text-gray-300">{config.label}</div>
</div>
</div>
</div>
));
})}
</div>
)}
</div>
{/* 生成统计 */}
<div className="flex-shrink-0 bg-gradient-to-r from-primary-50 to-primary-100 border-t border-primary-200 p-3">
<div className="grid grid-cols-4 gap-4 text-center">
<div>
<div className="text-lg font-bold text-primary-700">{selectedAssets.length}</div>
<div className="text-xs text-primary-600"></div>
</div>
<div>
<div className="text-lg font-bold text-primary-700">{project.generation_config.duration}s</div>
<div className="text-xs text-primary-600"></div>
</div>
<div>
<div className="text-lg font-bold text-primary-700">
{(() => {
const baseSize = project.generation_config.duration * 2;
const qualityMultiplier = project.generation_config.quality === 'high' ? 2 :
project.generation_config.quality === 'medium' ? 1.5 : 1;
const resolutionMultiplier = project.generation_config.resolution === '4k' ? 4 :
project.generation_config.resolution === '1080p' ? 2 : 1;
return `${(baseSize * qualityMultiplier * resolutionMultiplier).toFixed(1)}MB`;
})()}
</div>
<div className="text-xs text-primary-600"></div>
</div>
<div>
<div className="text-lg font-bold text-primary-700">
{project.generation_config.quality === 'high' ? '高' :
project.generation_config.quality === 'medium' ? '中' : '低'}
</div>
<div className="text-xs text-primary-600"></div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,222 @@
import React from 'react';
import {
CogIcon,
FilmIcon,
SpeakerWaveIcon,
ClockIcon
} from '@heroicons/react/24/outline';
import { VideoGenerationConfig } from '../../types/videoGeneration';
interface CompactVideoConfigPanelProps {
config: VideoGenerationConfig;
onConfigChange: (config: VideoGenerationConfig) => void;
}
/**
* 紧凑型视频配置面板组件
* 适用于底部参数设置区域
*/
export const CompactVideoConfigPanel: React.FC<CompactVideoConfigPanelProps> = ({
config,
onConfigChange
}) => {
const handleConfigUpdate = (updates: Partial<VideoGenerationConfig>) => {
onConfigChange({ ...config, ...updates });
};
return (
<div className="space-y-4">
{/* 标题 */}
<div className="flex items-center gap-2">
<CogIcon className="h-4 w-4 text-primary-600" />
<h3 className="text-sm font-medium text-gray-900"></h3>
</div>
{/* 参数控制区 */}
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 gap-4">
{/* 输出格式 */}
<div className="space-y-1">
<label className="block text-xs font-medium text-gray-700">
<FilmIcon className="inline h-3 w-3 mr-1" />
</label>
<select
value={config.output_format}
onChange={(e) => handleConfigUpdate({
output_format: e.target.value as 'mp4' | 'mov' | 'avi'
})}
className="w-full px-2 py-1 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-primary-500 focus:border-transparent"
>
<option value="mp4">MP4</option>
<option value="mov">MOV</option>
<option value="avi">AVI</option>
</select>
</div>
{/* 分辨率 */}
<div className="space-y-1">
<label className="block text-xs font-medium text-gray-700"></label>
<select
value={config.resolution}
onChange={(e) => handleConfigUpdate({
resolution: e.target.value as '720p' | '1080p' | '4k'
})}
className="w-full px-2 py-1 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-primary-500 focus:border-transparent"
>
<option value="720p">720p</option>
<option value="1080p">1080p</option>
<option value="4k">4K</option>
</select>
</div>
{/* 帧率 */}
<div className="space-y-1">
<label className="block text-xs font-medium text-gray-700"></label>
<select
value={config.frame_rate}
onChange={(e) => handleConfigUpdate({
frame_rate: parseInt(e.target.value) as 24 | 30 | 60
})}
className="w-full px-2 py-1 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-primary-500 focus:border-transparent"
>
<option value={24}>24fps</option>
<option value={30}>30fps</option>
<option value={60}>60fps</option>
</select>
</div>
{/* 时长 */}
<div className="space-y-1">
<label className="block text-xs font-medium text-gray-700">
<ClockIcon className="inline h-3 w-3 mr-1" />
()
</label>
<input
type="number"
min="5"
max="300"
value={config.duration}
onChange={(e) => handleConfigUpdate({
duration: parseInt(e.target.value) || 30
})}
className="w-full px-2 py-1 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-primary-500 focus:border-transparent"
/>
</div>
{/* 质量 */}
<div className="space-y-1">
<label className="block text-xs font-medium text-gray-700"></label>
<select
value={config.quality}
onChange={(e) => handleConfigUpdate({
quality: e.target.value as 'low' | 'medium' | 'high'
})}
className="w-full px-2 py-1 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-primary-500 focus:border-transparent"
>
<option value="low"></option>
<option value="medium"></option>
<option value="high"></option>
</select>
</div>
{/* 音频开关 */}
<div className="space-y-1">
<label className="block text-xs font-medium text-gray-700">
<SpeakerWaveIcon className="inline h-3 w-3 mr-1" />
</label>
<div className="flex items-center">
<button
onClick={() => handleConfigUpdate({
audio_enabled: !config.audio_enabled
})}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-200 ${
config.audio_enabled ? 'bg-primary-600' : 'bg-gray-200'
}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform duration-200 ${
config.audio_enabled ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</button>
<span className="ml-2 text-xs text-gray-600">
{config.audio_enabled ? '开启' : '关闭'}
</span>
</div>
</div>
</div>
{/* 预估信息 */}
<div className="flex items-center justify-between text-xs text-gray-500 pt-2 border-t border-gray-100">
<div className="flex items-center gap-4">
<span>
: {(() => {
const baseSize = config.duration * 2;
const qualityMultiplier = config.quality === 'high' ? 2 : config.quality === 'medium' ? 1.5 : 1;
const resolutionMultiplier = config.resolution === '4k' ? 4 : config.resolution === '1080p' ? 2 : 1;
const estimatedSize = baseSize * qualityMultiplier * resolutionMultiplier;
return `${estimatedSize.toFixed(1)} MB`;
})()}
</span>
<span>
: {(() => {
const baseTime = config.duration * 0.5;
const qualityMultiplier = config.quality === 'high' ? 2 : config.quality === 'medium' ? 1.5 : 1;
const estimatedTime = baseTime * qualityMultiplier;
return `${Math.ceil(estimatedTime)}`;
})()}
</span>
</div>
<div className="text-xs text-gray-400">
{config.resolution} {config.frame_rate}fps {config.output_format.toUpperCase()}
</div>
</div>
{/* 高级选项(可展开) */}
<details className="group">
<summary className="flex items-center gap-2 text-xs font-medium text-gray-700 cursor-pointer hover:text-gray-900">
<span></span>
<svg className="h-3 w-3 transition-transform group-open:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="mt-3 grid grid-cols-2 lg:grid-cols-4 gap-3">
{/* 特效选项 */}
{[
{ id: 'fade', label: '淡入淡出' },
{ id: 'zoom', label: '缩放效果' },
{ id: 'slide', label: '滑动转场' },
{ id: 'blur', label: '模糊背景' }
].map((effect) => (
<label key={effect.id} className="flex items-center text-xs">
<input
type="checkbox"
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500 mr-2"
onChange={(e) => {
const effects = config.effects || [];
if (e.target.checked) {
handleConfigUpdate({
effects: [...effects, {
type: 'transition',
name: effect.id,
parameters: {}
}]
});
} else {
handleConfigUpdate({
effects: effects.filter(eff => eff.name !== effect.id)
});
}
}}
/>
<span className="text-gray-700">{effect.label}</span>
</label>
))}
</div>
</details>
</div>
);
};

View File

@@ -0,0 +1,253 @@
import React from 'react';
import {
ClockIcon,
CheckCircleIcon,
ExclamationCircleIcon,
XCircleIcon,
PlayIcon,
PauseIcon,
TrashIcon
} from '@heroicons/react/24/outline';
export interface VideoGenerationTask {
id: string;
name: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
progress: number; // 0-100
startTime?: string;
endTime?: string;
duration?: number;
errorMessage?: string;
outputPath?: string;
}
interface TaskStatusPanelProps {
tasks: VideoGenerationTask[];
onCancelTask?: (taskId: string) => void;
onRetryTask?: (taskId: string) => void;
onDeleteTask?: (taskId: string) => void;
}
/**
* 任务状态面板组件
* 显示视频生成任务的运行状况
*/
export const TaskStatusPanel: React.FC<TaskStatusPanelProps> = ({
tasks,
onCancelTask,
onRetryTask,
onDeleteTask
}) => {
const getStatusIcon = (status: VideoGenerationTask['status']) => {
switch (status) {
case 'pending':
return <ClockIcon className="h-4 w-4 text-yellow-500" />;
case 'running':
return <PlayIcon className="h-4 w-4 text-blue-500" />;
case 'completed':
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case 'failed':
return <XCircleIcon className="h-4 w-4 text-red-500" />;
case 'cancelled':
return <PauseIcon className="h-4 w-4 text-gray-500" />;
default:
return <ClockIcon className="h-4 w-4 text-gray-400" />;
}
};
const getStatusText = (status: VideoGenerationTask['status']) => {
switch (status) {
case 'pending':
return '等待中';
case 'running':
return '运行中';
case 'completed':
return '已完成';
case 'failed':
return '失败';
case 'cancelled':
return '已取消';
default:
return '未知';
}
};
const getStatusColor = (status: VideoGenerationTask['status']) => {
switch (status) {
case 'pending':
return 'bg-yellow-50 border-yellow-200';
case 'running':
return 'bg-blue-50 border-blue-200';
case 'completed':
return 'bg-green-50 border-green-200';
case 'failed':
return 'bg-red-50 border-red-200';
case 'cancelled':
return 'bg-gray-50 border-gray-200';
default:
return 'bg-gray-50 border-gray-200';
}
};
const formatDuration = (seconds?: number) => {
if (!seconds) return '';
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatTime = (timeString?: string) => {
if (!timeString) return '';
const date = new Date(timeString);
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
return (
<div className="h-full flex flex-col">
{/* 任务统计 */}
<div className="flex-shrink-0 p-3 border-b border-gray-200">
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-center">
<div className="font-medium text-gray-900">
{tasks.filter(t => t.status === 'running').length}
</div>
<div className="text-gray-500"></div>
</div>
<div className="text-center">
<div className="font-medium text-gray-900">
{tasks.filter(t => t.status === 'pending').length}
</div>
<div className="text-gray-500"></div>
</div>
</div>
</div>
{/* 任务列表 */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500 p-4">
<ClockIcon className="h-8 w-8 mb-2 text-gray-300" />
<div className="text-sm text-center"></div>
<div className="text-xs text-center mt-1">
"开始生成"
</div>
</div>
) : (
<div className="p-3 space-y-3">
{tasks.map((task) => (
<div
key={task.id}
className={`p-3 rounded-lg border transition-all duration-200 ${getStatusColor(task.status)}`}
>
{/* 任务标题和状态 */}
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">
{task.name}
</div>
<div className="flex items-center gap-1 mt-1">
{getStatusIcon(task.status)}
<span className="text-xs text-gray-600">
{getStatusText(task.status)}
</span>
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-1 ml-2">
{task.status === 'running' && onCancelTask && (
<button
onClick={() => onCancelTask(task.id)}
className="p-1 text-gray-400 hover:text-red-600 rounded"
title="取消任务"
>
<PauseIcon className="h-3 w-3" />
</button>
)}
{task.status === 'failed' && onRetryTask && (
<button
onClick={() => onRetryTask(task.id)}
className="p-1 text-gray-400 hover:text-blue-600 rounded"
title="重试任务"
>
<PlayIcon className="h-3 w-3" />
</button>
)}
{(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && onDeleteTask && (
<button
onClick={() => onDeleteTask(task.id)}
className="p-1 text-gray-400 hover:text-red-600 rounded"
title="删除任务"
>
<TrashIcon className="h-3 w-3" />
</button>
)}
</div>
</div>
{/* 进度条 */}
{task.status === 'running' && (
<div className="mb-2">
<div className="flex items-center justify-between text-xs text-gray-600 mb-1">
<span></span>
<span>{task.progress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${task.progress}%` }}
></div>
</div>
</div>
)}
{/* 任务详情 */}
<div className="text-xs text-gray-500 space-y-1">
{task.startTime && (
<div>: {formatTime(task.startTime)}</div>
)}
{task.endTime && (
<div>: {formatTime(task.endTime)}</div>
)}
{task.duration && (
<div>: {formatDuration(task.duration)}</div>
)}
{task.status === 'failed' && task.errorMessage && (
<div className="text-red-600 mt-1">
<ExclamationCircleIcon className="h-3 w-3 inline mr-1" />
{task.errorMessage}
</div>
)}
{task.status === 'completed' && task.outputPath && (
<div className="text-green-600 mt-1">
<CheckCircleIcon className="h-3 w-3 inline mr-1" />
: {task.outputPath.split('/').pop()}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* 底部操作 */}
<div className="flex-shrink-0 p-3 border-t border-gray-200">
<button
onClick={() => {
const completedTasks = tasks.filter(t => t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled');
completedTasks.forEach(task => onDeleteTask?.(task.id));
}}
disabled={!tasks.some(t => t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled')}
className="w-full px-3 py-2 text-xs text-gray-600 border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
>
</button>
</div>
</div>
);
};

View File

@@ -15,8 +15,9 @@ import {
MATERIAL_CATEGORY_CONFIG
} from '../types/videoGeneration';
import { MaterialSelector } from '../components/video-generation/MaterialSelector';
import { VideoConfigPanel } from '../components/video-generation/VideoConfigPanel';
import { VideoPreview } from '../components/video-generation/VideoPreview';
import { CompactVideoConfigPanel } from '../components/video-generation/CompactVideoConfigPanel';
import { CentralVideoPreview } from '../components/video-generation/CentralVideoPreview';
import { TaskStatusPanel, VideoGenerationTask } from '../components/video-generation/TaskStatusPanel';
/**
* 视频生成页面
@@ -26,8 +27,10 @@ const VideoGeneration: React.FC = () => {
const { projectId } = useParams<{ projectId?: string }>();
const [project, setProject] = useState<VideoGenerationProject | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [activeStep, setActiveStep] = useState<'select' | 'config' | 'preview'>('select');
const [activeMaterialTab, setActiveMaterialTab] = useState<MaterialCategory>(MaterialCategory.Model);
const [isGenerating, setIsGenerating] = useState(false);
const [isTaskPanelCollapsed, setIsTaskPanelCollapsed] = useState(false);
const [tasks, setTasks] = useState<VideoGenerationTask[]>([]);
// 初始化项目
useEffect(() => {
@@ -123,21 +126,66 @@ const VideoGeneration: React.FC = () => {
// 生成视频
const handleGenerateVideo = async () => {
if (!project) return;
setIsGenerating(true);
// 创建新任务
const newTask: VideoGenerationTask = {
id: Date.now().toString(),
name: `视频生成 - ${project.name}`,
status: 'running',
progress: 0,
startTime: new Date().toISOString()
};
setTasks(prev => [newTask, ...prev]);
try {
// 模拟视频生成过程
await new Promise(resolve => setTimeout(resolve, 3000));
for (let i = 0; i <= 100; i += 10) {
await new Promise(resolve => setTimeout(resolve, 300));
setTasks(prev => prev.map(task =>
task.id === newTask.id
? { ...task, progress: i }
: task
));
}
// 完成任务
setTasks(prev => prev.map(task =>
task.id === newTask.id
? {
...task,
status: 'completed' as const,
progress: 100,
endTime: new Date().toISOString(),
duration: Math.floor((Date.now() - new Date(newTask.startTime!).getTime()) / 1000),
outputPath: `/outputs/video_${newTask.id}.mp4`
}
: task
));
setProject(prev => ({
...prev!,
status: VideoGenerationStatus.Completed,
updated_at: new Date().toISOString()
}));
setActiveStep('preview');
} catch (error) {
console.error('Failed to generate video:', error);
// 标记任务失败
setTasks(prev => prev.map(task =>
task.id === newTask.id
? {
...task,
status: 'failed' as const,
endTime: new Date().toISOString(),
errorMessage: '视频生成失败,请重试'
}
: task
));
setProject(prev => ({
...prev!,
status: VideoGenerationStatus.Failed,
@@ -148,16 +196,32 @@ const VideoGeneration: React.FC = () => {
}
};
// 任务管理函数
const handleCancelTask = (taskId: string) => {
setTasks(prev => prev.map(task =>
task.id === taskId
? { ...task, status: 'cancelled' as const, endTime: new Date().toISOString() }
: task
));
};
const handleRetryTask = (taskId: string) => {
const task = tasks.find(t => t.id === taskId);
if (task) {
handleGenerateVideo();
}
};
const handleDeleteTask = (taskId: string) => {
setTasks(prev => prev.filter(task => task.id !== taskId));
};
// 检查是否可以进行下一步
const canProceedToConfig = () => {
if (!project) return false;
return Object.keys(project.selected_assets).length > 0;
};
const canProceedToPreview = () => {
return canProceedToConfig();
};
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
@@ -184,183 +248,128 @@ const VideoGeneration: React.FC = () => {
}
return (
<div className="app-layout bg-gradient-to-br from-gray-50 via-white to-gray-50">
{/* 页面标题和步骤指示器 */}
<div className="app-header page-header">
<div className="h-full flex flex-col bg-gradient-to-br from-gray-50 via-white to-gray-50">
{/* 顶部工具栏 */}
<div className="flex-shrink-0 bg-white/95 backdrop-blur-sm border-b border-gray-200/50 px-6 py-3">
<div className="flex items-center justify-between">
<div className="relative z-10">
<h1 className="text-heading-2 text-gradient-primary">
<div>
<h1 className="text-heading-3 text-gradient-primary">
</h1>
<p className="text-body-small text-medium-emphasis mt-1">
{project.name} -
<p className="text-caption text-medium-emphasis">
{project.name}
</p>
</div>
{/* 步骤指示器 */}
<div className="flex items-center gap-4 relative z-10">
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex-center text-caption font-medium transition-all duration-200 ${
activeStep === 'select'
? 'bg-gradient-primary text-white shadow-medium'
: canProceedToConfig()
? 'bg-success-100 text-success-600'
: 'bg-gray-100 text-gray-400'
}`}>
1
</div>
<span className={`text-body-small font-medium ${
activeStep === 'select' ? 'text-primary-600' : 'text-gray-500'
}`}>
</span>
</div>
<ArrowRightIcon className="h-4 w-4 text-gray-300" />
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-all duration-200 ${
activeStep === 'config'
? 'bg-primary-600 text-white'
: canProceedToConfig()
? 'bg-gray-200 text-gray-600'
: 'bg-gray-100 text-gray-400'
}`}>
2
</div>
<span className={`text-sm font-medium ${
activeStep === 'config' ? 'text-primary-600' : 'text-gray-500'
}`}>
</span>
</div>
<ArrowRightIcon className="h-4 w-4 text-gray-300" />
<div className="flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-all duration-200 ${
activeStep === 'preview'
? 'bg-primary-600 text-white'
: canProceedToPreview()
? 'bg-gray-200 text-gray-600'
: 'bg-gray-100 text-gray-400'
}`}>
3
</div>
<span className={`text-sm font-medium ${
activeStep === 'preview' ? 'text-primary-600' : 'text-gray-500'
}`}>
</span>
</div>
<div className="flex items-center gap-3">
<button
onClick={handleGenerateVideo}
disabled={isGenerating}
className="flex items-center gap-2 px-4 py-2 bg-gradient-primary text-white rounded-lg hover:shadow-medium transition-all duration-200 hover-lift touch-target disabled:opacity-50"
>
<SparklesIcon className="h-4 w-4" />
{isGenerating ? '生成中...' : '开始生成'}
</button>
</div>
</div>
</div>
{/* 主要内容区域 */}
<div className="flex-1 overflow-hidden">
{activeStep === 'select' && (
<div className="h-full p-6">
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6 h-full">
{/* 主要工作区域 */}
<div className="flex-1 flex overflow-hidden">
{/* 左侧素材区 */}
<div className="w-80 bg-white border-r border-gray-200 flex flex-col">
{/* 素材分类标签 */}
<div className="flex-shrink-0 border-b border-gray-200">
<div className="flex overflow-x-auto scrollbar-hidden">
{Object.values(MaterialCategory).map((category) => {
const config = MATERIAL_CATEGORY_CONFIG[category];
const selectedAssets = project.selected_assets[category] || [];
const isActive = activeMaterialTab === category;
return (
<div key={category} className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className={`px-4 py-3 border-b border-gray-200 ${config.bgColor}`}>
<div className="flex items-center gap-2">
<span className="text-lg">{config.icon}</span>
<h3 className={`font-medium ${config.color}`}>
{config.label}
</h3>
<span className="text-xs text-gray-500">
({selectedAssets.length} )
</span>
<button
key={category}
onClick={() => setActiveMaterialTab(category)}
className={`flex-shrink-0 flex items-center gap-2 px-4 py-3 border-b-2 transition-all duration-200 ${
isActive
? 'border-primary-500 text-primary-600 bg-primary-50'
: 'border-transparent text-gray-600 hover:text-gray-900 hover:bg-gray-50'
}`}
>
<span className="text-lg">{config.icon}</span>
<div className="text-left">
<div className="text-sm font-medium">{config.label}</div>
<div className="text-xs text-gray-500">
{selectedAssets.length}
</div>
</div>
<div className="h-96">
<MaterialSelector
category={category}
selectedAssets={selectedAssets}
onAssetsChange={(assets) => handleAssetsChange(category, assets)}
allowMultiple={true}
/>
</div>
</div>
</button>
);
})}
</div>
{/* 下一步按钮 */}
<div className="flex justify-end mt-6">
<button
onClick={() => setActiveStep('config')}
disabled={!canProceedToConfig()}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-lg hover:from-primary-600 hover:to-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow-md"
>
<CogIcon className="h-5 w-5" />
<ArrowRightIcon className="h-4 w-4" />
</button>
</div>
</div>
)}
{activeStep === 'config' && (
<div className="h-full p-6">
<div className="max-w-4xl mx-auto">
<VideoConfigPanel
config={project.generation_config}
onConfigChange={handleConfigChange}
{/* 素材列表 */}
<div className="flex-1 overflow-hidden">
<MaterialSelector
category={activeMaterialTab}
selectedAssets={project.selected_assets[activeMaterialTab] || []}
onAssetsChange={(assets) => handleAssetsChange(activeMaterialTab, assets)}
allowMultiple={true}
/>
</div>
</div>
{/* 中央预览区 */}
<div className="flex-1 flex flex-col">
{/* 视频预览区域 */}
<div className="flex-1">
<CentralVideoPreview
project={project}
onConfigChange={handleConfigChange}
/>
</div>
{/* 底部参数设置区 */}
<div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
<CompactVideoConfigPanel
config={project.generation_config}
onConfigChange={handleConfigChange}
/>
</div>
</div>
{/* 右侧任务状态区(可折叠) */}
<div className={`bg-white border-l border-gray-200 transition-all duration-300 ${
isTaskPanelCollapsed ? 'w-12' : 'w-80'
}`}>
{/* 折叠按钮 */}
<div className="flex items-center justify-between p-3 border-b border-gray-200">
{!isTaskPanelCollapsed && (
<h3 className="text-sm font-medium text-gray-900"></h3>
)}
<button
onClick={() => setIsTaskPanelCollapsed(!isTaskPanelCollapsed)}
className="p-1 text-gray-500 hover:text-gray-700 rounded"
>
<ArrowRightIcon className={`h-4 w-4 transition-transform duration-200 ${
isTaskPanelCollapsed ? 'rotate-180' : ''
}`} />
</button>
</div>
{/* 任务列表 */}
{!isTaskPanelCollapsed && (
<div className="flex-1 overflow-hidden">
<TaskStatusPanel
tasks={tasks}
onCancelTask={handleCancelTask}
onRetryTask={handleRetryTask}
onDeleteTask={handleDeleteTask}
/>
{/* 操作按钮 */}
<div className="flex items-center justify-between mt-8">
<button
onClick={() => setActiveStep('select')}
className="flex items-center gap-2 px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors duration-200"
>
</button>
<button
onClick={() => setActiveStep('preview')}
disabled={!canProceedToPreview()}
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-lg hover:from-primary-600 hover:to-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow-md"
>
<PlayIcon className="h-5 w-5" />
<ArrowRightIcon className="h-4 w-4" />
</button>
</div>
</div>
</div>
)}
{activeStep === 'preview' && (
<div className="h-full p-6">
<div className="max-w-6xl mx-auto">
<VideoPreview
project={project}
onConfigChange={handleConfigChange}
/>
{/* 生成按钮 */}
<div className="flex items-center justify-center mt-8">
<button
onClick={handleGenerateVideo}
disabled={isGenerating}
className="flex items-center gap-3 px-8 py-4 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-xl hover:from-green-600 hover:to-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg hover:shadow-xl text-lg font-medium"
>
<SparklesIcon className="h-6 w-6" />
{isGenerating ? '生成中...' : '开始生成视频'}
</button>
</div>
</div>
</div>
)}
)}
</div>
</div>
</div>
);

View File

@@ -2494,3 +2494,43 @@ main::-webkit-scrollbar-thumb:hover {
border-color: rgb(var(--color-yellow-500));
background-color: rgb(var(--color-yellow-50));
}
/* 视频生成工作台专用样式 */
/* 滚动条隐藏 */
.scrollbar-hidden {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hidden::-webkit-scrollbar {
display: none;
}
/* 自定义滚动条 */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.7);
}
/* 缩略图容器 */
.thumbnail-container {
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
}