feat: 重新设计视频生成工作台布局
采用专业视频制作工具的布局方式,提升用户操作体验: 新布局设计: - 左侧:素材区(tab切换:模特/产品/场景/动作/音乐/提示词) - 中央:视频预览区(实时预览、播放控制、素材概览) - 底部:紧凑型参数设置区(一行显示所有关键参数) - 右侧:可折叠任务状态区(实时显示生成任务进度) 新增组件: - CentralVideoPreview: 专业的中央预览组件 - CompactVideoConfigPanel: 紧凑型配置面板 - TaskStatusPanel: 任务状态管理面板 功能增强: - 实时任务进度显示 - 任务管理(取消、重试、删除) - 更直观的素材选择流程 - 专业的视频预览体验 - 响应式布局优化 用户体验提升: - 一屏显示所有关键信息 - 减少页面切换和滚动 - 符合专业视频制作工具习惯 - 提高工作效率
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
253
apps/desktop/src/components/video-generation/TaskStatusPanel.tsx
Normal file
253
apps/desktop/src/components/video-generation/TaskStatusPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user