forked from 77media/video-flow
- Added custom scrollbar styles for better UX - Optimized dashboard layout components - Enhanced network timeline functionality - Cleaned up redundant dashboard components - Improved API integration for video flow
1066 lines
42 KiB
TypeScript
1066 lines
42 KiB
TypeScript
'use client';
|
||
|
||
import React, { useState, useMemo, useEffect } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import {
|
||
Network,
|
||
Clock,
|
||
CheckCircle,
|
||
Search,
|
||
ChevronRight,
|
||
ChevronDown,
|
||
RefreshCw,
|
||
XCircle,
|
||
Info,
|
||
Activity,
|
||
Pause
|
||
} from 'lucide-react';
|
||
import { cn } from '@/public/lib/utils';
|
||
|
||
interface TaskExecution {
|
||
id: string;
|
||
name: string;
|
||
displayName: string;
|
||
status: string;
|
||
statusCode: number;
|
||
type: string;
|
||
dataSize: number;
|
||
executionTime: number;
|
||
startTime: number;
|
||
endTime: number;
|
||
progress: number;
|
||
level: number; // 层级:0=主任务,1=子任务
|
||
parentId?: string; // 父任务ID
|
||
isExpanded?: boolean; // 是否展开子任务
|
||
subTasks?: TaskExecution[]; // 子任务列表
|
||
phases: {
|
||
initialization?: number;
|
||
dataLoading?: number;
|
||
aiProcessing?: number;
|
||
resultGeneration?: number;
|
||
dataTransfer?: number;
|
||
completion?: number;
|
||
};
|
||
taskResult?: any;
|
||
}
|
||
|
||
interface TaskTimelineProps {
|
||
tasks: any[];
|
||
className?: string;
|
||
onRefresh?: () => void;
|
||
isRefreshing?: boolean;
|
||
onRetryTask?: (taskId: string) => Promise<void>;
|
||
// 实时监控相关
|
||
isPolling?: boolean;
|
||
lastUpdate?: Date;
|
||
onTogglePolling?: () => void;
|
||
}
|
||
|
||
export function NetworkTimeline({
|
||
tasks,
|
||
className,
|
||
onRefresh,
|
||
isRefreshing = false,
|
||
onRetryTask,
|
||
isPolling = false,
|
||
lastUpdate,
|
||
onTogglePolling
|
||
}: TaskTimelineProps) {
|
||
const [selectedTask, setSelectedTask] = useState<string | null>(null);
|
||
const [filterType, setFilterType] = useState<string>('all');
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
|
||
const [timeAgo, setTimeAgo] = useState<string>('');
|
||
|
||
// 更新时间显示
|
||
useEffect(() => {
|
||
if (!lastUpdate) return;
|
||
|
||
const updateTimeAgo = () => {
|
||
const now = new Date();
|
||
const diff = Math.floor((now.getTime() - lastUpdate.getTime()) / 1000);
|
||
|
||
if (diff < 60) {
|
||
setTimeAgo(`${diff}秒前`);
|
||
} else if (diff < 3600) {
|
||
setTimeAgo(`${Math.floor(diff / 60)}分钟前`);
|
||
} else {
|
||
setTimeAgo(`${Math.floor(diff / 3600)}小时前`);
|
||
}
|
||
};
|
||
|
||
updateTimeAgo();
|
||
const interval = setInterval(updateTimeAgo, 1000);
|
||
return () => clearInterval(interval);
|
||
}, [lastUpdate]);
|
||
|
||
// 计算任务统计信息
|
||
const taskStats = useMemo(() => {
|
||
if (!tasks || tasks.length === 0) {
|
||
return { total: 0, completed: 0, inProgress: 0, failed: 0, pending: 0 };
|
||
}
|
||
|
||
const stats = {
|
||
total: tasks.length,
|
||
completed: 0,
|
||
inProgress: 0,
|
||
failed: 0,
|
||
pending: 0
|
||
};
|
||
|
||
tasks.forEach((task: any) => {
|
||
switch (task.task_status) {
|
||
case 'COMPLETED':
|
||
stats.completed++;
|
||
break;
|
||
case 'IN_PROGRESS':
|
||
stats.inProgress++;
|
||
break;
|
||
case 'FAILED':
|
||
stats.failed++;
|
||
break;
|
||
case 'PENDING':
|
||
default:
|
||
stats.pending++;
|
||
break;
|
||
}
|
||
});
|
||
|
||
return stats;
|
||
}, [tasks]);
|
||
|
||
// 将任务转换为执行时间线格式(支持层级结构)
|
||
const taskExecutions = useMemo((): TaskExecution[] => {
|
||
if (!tasks || tasks.length === 0) return [];
|
||
|
||
const startTime = Math.min(...tasks.map(task => new Date(task.created_at).getTime()));
|
||
const result: TaskExecution[] = [];
|
||
|
||
tasks.forEach((task: any) => {
|
||
const taskStartTime = new Date(task.created_at).getTime();
|
||
const taskEndTime = new Date(task.updated_at).getTime();
|
||
const duration = taskEndTime - taskStartTime;
|
||
|
||
// 任务执行阶段分解
|
||
const phases = {
|
||
initialization: Math.min(duration * 0.05, 1000),
|
||
dataLoading: Math.min(duration * 0.1, 2000),
|
||
aiProcessing: duration * 0.7,
|
||
resultGeneration: Math.min(duration * 0.1, 1000),
|
||
dataTransfer: Math.min(duration * 0.05, 500),
|
||
completion: Math.min(duration * 0.05, 500)
|
||
};
|
||
|
||
// 主任务
|
||
const mainTask: TaskExecution = {
|
||
id: task.task_id,
|
||
name: task.task_name,
|
||
displayName: getTaskDisplayName(task.task_name),
|
||
status: task.task_status,
|
||
statusCode: getTaskStatusCode(task.task_status),
|
||
type: getTaskType(task.task_name),
|
||
dataSize: getTaskDataSize(task),
|
||
executionTime: duration,
|
||
startTime: taskStartTime - startTime,
|
||
endTime: taskEndTime - startTime,
|
||
progress: task.task_result?.progress_percentage || 0,
|
||
level: 0,
|
||
isExpanded: expandedTasks.has(task.task_id),
|
||
subTasks: [],
|
||
phases,
|
||
taskResult: task.task_result
|
||
};
|
||
|
||
result.push(mainTask);
|
||
|
||
// 处理子任务(如果有的话)
|
||
if (task.task_result?.data && Array.isArray(task.task_result.data)) {
|
||
const subTasks = task.task_result.data.map((subItem: any, subIndex: number) => {
|
||
// 为子任务计算时间分布
|
||
const totalCount = task.task_result.total_count || task.task_result.data.length;
|
||
const subDuration = duration / totalCount;
|
||
const subStartTime = taskStartTime - startTime + (subIndex * subDuration);
|
||
const subEndTime = subStartTime + subDuration;
|
||
|
||
const subPhases = {
|
||
initialization: subDuration * 0.05,
|
||
dataLoading: subDuration * 0.1,
|
||
aiProcessing: subDuration * 0.7,
|
||
resultGeneration: subDuration * 0.1,
|
||
dataTransfer: subDuration * 0.03,
|
||
completion: subDuration * 0.02
|
||
};
|
||
|
||
return {
|
||
id: subItem.video_id || `${task.task_id}-sub-${subIndex}`,
|
||
name: `${task.task_name}_item_${subIndex + 1}`,
|
||
displayName: `${getTaskDisplayName(task.task_name)} - 项目 ${subIndex + 1}`,
|
||
status: subItem.video_status === 1 ? 'COMPLETED' :
|
||
subIndex < (task.task_result.completed_count || 0) ? 'COMPLETED' :
|
||
subIndex === (task.task_result.completed_count || 0) ? 'IN_PROGRESS' : 'PENDING',
|
||
statusCode: subItem.video_status === 1 ? 200 :
|
||
subIndex < (task.task_result.completed_count || 0) ? 200 :
|
||
subIndex === (task.task_result.completed_count || 0) ? 202 : 100,
|
||
type: getTaskType(task.task_name),
|
||
dataSize: getSubTaskDataSize(subItem),
|
||
executionTime: subDuration,
|
||
startTime: subStartTime,
|
||
endTime: subEndTime,
|
||
progress: subItem.video_status === 1 ? 100 :
|
||
subIndex < (task.task_result.completed_count || 0) ? 100 :
|
||
subIndex === (task.task_result.completed_count || 0) ? (task.task_result.progress_percentage || 0) : 0,
|
||
level: 1,
|
||
parentId: task.task_id,
|
||
phases: subPhases,
|
||
taskResult: subItem
|
||
};
|
||
});
|
||
|
||
mainTask.subTasks = subTasks;
|
||
|
||
// 如果主任务展开,将子任务添加到结果中
|
||
if (expandedTasks.has(task.task_id)) {
|
||
result.push(...subTasks);
|
||
}
|
||
}
|
||
});
|
||
|
||
return result;
|
||
}, [tasks, expandedTasks]);
|
||
|
||
// 过滤后的任务执行列表
|
||
const filteredTaskExecutions = useMemo(() => {
|
||
let filtered = taskExecutions;
|
||
|
||
// 按类型过滤
|
||
if (filterType !== 'all') {
|
||
filtered = filtered.filter(task => task.type === filterType);
|
||
}
|
||
|
||
// 按搜索词过滤
|
||
if (searchTerm.trim()) {
|
||
const searchLower = searchTerm.toLowerCase();
|
||
filtered = filtered.filter(task =>
|
||
task.displayName.toLowerCase().includes(searchLower) ||
|
||
task.name.toLowerCase().includes(searchLower) ||
|
||
task.status.toLowerCase().includes(searchLower) ||
|
||
task.type.toLowerCase().includes(searchLower)
|
||
);
|
||
}
|
||
|
||
return filtered;
|
||
}, [taskExecutions, filterType, searchTerm]);
|
||
|
||
// 辅助函数
|
||
function getTaskDisplayName(taskName: string): string {
|
||
const nameMap: Record<string, string> = {
|
||
'generate_character': '角色生成',
|
||
'generate_sketch': '草图生成',
|
||
'generate_shot_sketch': '分镜生成',
|
||
'generate_video': '视频生成',
|
||
'generate_videos': '视频生成',
|
||
'generate_music': '音乐生成',
|
||
'final_composition': '最终合成'
|
||
};
|
||
return nameMap[taskName] || taskName;
|
||
}
|
||
|
||
function getTaskMethod(taskName: string): string {
|
||
return taskName.includes('generate') ? 'POST' : 'GET';
|
||
}
|
||
|
||
function getTaskStatusCode(status: string): number {
|
||
const statusMap: Record<string, number> = {
|
||
'COMPLETED': 200,
|
||
'IN_PROGRESS': 202,
|
||
'PENDING': 100,
|
||
'FAILED': 500
|
||
};
|
||
return statusMap[status] || 200;
|
||
}
|
||
|
||
function getTaskType(taskName: string): string {
|
||
const typeMap: Record<string, string> = {
|
||
'generate_character': 'AI',
|
||
'generate_sketch': 'AI',
|
||
'generate_shot_sketch': 'AI',
|
||
'generate_video': 'Video',
|
||
'generate_videos': 'Video',
|
||
'generate_music': 'Audio',
|
||
'final_composition': 'Comp'
|
||
};
|
||
return typeMap[taskName] || 'Task';
|
||
}
|
||
|
||
function getTaskDataSize(task: any): number {
|
||
const baseSize = 1024; // 1KB base
|
||
const dataCount = task.task_result?.total_count || 1;
|
||
const completedCount = task.task_result?.completed_count || 0;
|
||
return baseSize * completedCount * (Math.random() * 5 + 1);
|
||
}
|
||
|
||
function getSubTaskDataSize(subItem: any): number {
|
||
const baseSize = 512; // 0.5KB base for sub-items
|
||
if (subItem.urls && subItem.urls.length > 0) {
|
||
// 如果有URL,估算为较大的文件
|
||
return baseSize * 10 * (Math.random() * 3 + 1);
|
||
}
|
||
return baseSize * (Math.random() * 2 + 1);
|
||
}
|
||
|
||
// 获取任务进度数量显示 - 与任务执行状态面板完全一致
|
||
function getTaskProgressCount(task: any): string {
|
||
const completed = task.task_result?.completed_count;
|
||
const total = task.task_result?.total_count || 0;
|
||
|
||
// 如果任务已完成但没有子任务数据,显示 1/1
|
||
if (task.task_status === 'COMPLETED' && total === 0) {
|
||
return '1/1';
|
||
}
|
||
|
||
// 如果任务已完成且有子任务数据,确保completed等于total
|
||
if (task.task_status === 'COMPLETED' && total > 0) {
|
||
// 如果没有completed_count字段,但任务已完成,说明全部完成
|
||
const actualCompleted = completed !== undefined ? completed : total;
|
||
return `${actualCompleted}/${total}`;
|
||
}
|
||
|
||
// 其他情况显示实际数据
|
||
const actualCompleted = completed || 0;
|
||
return `${actualCompleted}/${total}`;
|
||
}
|
||
|
||
// 获取任务进度百分比显示 - 与任务执行状态面板完全一致
|
||
function getTaskProgressPercentage(task: any): string {
|
||
const completed = task.task_result?.completed_count || 0;
|
||
const total = task.task_result?.total_count || 0;
|
||
const progress = task.task_result?.progress_percentage || 0;
|
||
|
||
// 如果任务已完成,显示100%
|
||
if (task.task_status === 'COMPLETED') {
|
||
return '100%';
|
||
}
|
||
|
||
// 如果任务进行中,优先使用API返回的progress_percentage
|
||
if (task.task_status === 'IN_PROGRESS') {
|
||
if (progress > 0) {
|
||
return `${progress}%`;
|
||
}
|
||
// 如果没有progress_percentage,根据completed/total计算
|
||
if (total > 0) {
|
||
return `${Math.round((completed / total) * 100)}%`;
|
||
}
|
||
}
|
||
|
||
// 其他情况显示0%
|
||
return '0%';
|
||
}
|
||
|
||
// 获取任务状态显示文本 - 与任务执行状态面板完全一致
|
||
function getTaskStatusText(status: string): string {
|
||
switch (status) {
|
||
case 'COMPLETED':
|
||
return 'COMPLETED';
|
||
case 'IN_PROGRESS':
|
||
return 'IN_PROGRESS';
|
||
case 'FAILED':
|
||
return 'FAILED';
|
||
case 'PENDING':
|
||
default:
|
||
return 'PENDING';
|
||
}
|
||
}
|
||
|
||
// 获取任务状态颜色 - 更明显的颜色区分
|
||
function getTaskStatusColor(status: string): string {
|
||
switch (status) {
|
||
case 'COMPLETED':
|
||
return 'text-emerald-400'; // 更亮的绿色
|
||
case 'IN_PROGRESS':
|
||
return 'text-cyan-400'; // 更亮的蓝色
|
||
case 'FAILED':
|
||
return 'text-rose-400'; // 更亮的红色
|
||
case 'PENDING':
|
||
default:
|
||
return 'text-amber-400'; // 黄色表示等待
|
||
}
|
||
}
|
||
|
||
// 获取进度条背景颜色类名
|
||
function getProgressBarColor(status: string): string {
|
||
switch (status) {
|
||
case 'COMPLETED':
|
||
return 'bg-emerald-500'; // 绿色
|
||
case 'IN_PROGRESS':
|
||
return 'bg-cyan-500'; // 蓝色
|
||
case 'FAILED':
|
||
return 'bg-rose-500'; // 红色
|
||
case 'PENDING':
|
||
default:
|
||
return 'bg-amber-500'; // 黄色
|
||
}
|
||
}
|
||
|
||
// 获取任务错误信息
|
||
function getTaskErrorInfo(task: any): { hasError: boolean; errorMessage: string; errorCode?: string } {
|
||
if (task.task_status !== 'FAILED') {
|
||
return { hasError: false, errorMessage: '' };
|
||
}
|
||
|
||
// 从task_result中提取错误信息
|
||
const errorMessage = task.task_result?.error_message ||
|
||
task.task_result?.message ||
|
||
task.error_message ||
|
||
'任务执行失败,请重试';
|
||
|
||
const errorCode = task.task_result?.error_code ||
|
||
task.error_code ||
|
||
'UNKNOWN_ERROR';
|
||
|
||
return {
|
||
hasError: true,
|
||
errorMessage,
|
||
errorCode
|
||
};
|
||
}
|
||
|
||
// 处理重试任务
|
||
const handleRetryTask = async (taskId: string) => {
|
||
if (onRetryTask) {
|
||
try {
|
||
await onRetryTask(taskId);
|
||
// 重试成功后可以显示成功提示
|
||
} catch (error) {
|
||
console.error('重试任务失败:', error);
|
||
// 可以显示错误提示
|
||
}
|
||
}
|
||
};
|
||
|
||
// 格式化文件大小
|
||
function formatSize(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
// 智能时间格式化 - 提升用户体验
|
||
function formatTime(ms: number): string {
|
||
if (ms < 1000) {
|
||
return `${Math.round(ms)}ms`;
|
||
}
|
||
|
||
const seconds = Math.floor(ms / 1000);
|
||
|
||
// 小于1分钟:显示秒
|
||
if (seconds < 60) {
|
||
return `${seconds}s`;
|
||
}
|
||
|
||
// 1分钟到59分钟:显示分钟和秒
|
||
if (seconds < 3600) {
|
||
const minutes = Math.floor(seconds / 60);
|
||
const remainingSeconds = seconds % 60;
|
||
if (remainingSeconds === 0) {
|
||
return `${minutes}m`;
|
||
}
|
||
return `${minutes}m ${remainingSeconds}s`;
|
||
}
|
||
|
||
// 1小时到23小时:显示小时和分钟
|
||
if (seconds < 86400) {
|
||
const hours = Math.floor(seconds / 3600);
|
||
const remainingMinutes = Math.floor((seconds % 3600) / 60);
|
||
if (remainingMinutes === 0) {
|
||
return `${hours}h`;
|
||
}
|
||
return `${hours}h ${remainingMinutes}m`;
|
||
}
|
||
|
||
// 24小时以上:显示天和小时
|
||
const days = Math.floor(seconds / 86400);
|
||
const remainingHours = Math.floor((seconds % 86400) / 3600);
|
||
if (remainingHours === 0) {
|
||
return `${days}d`;
|
||
}
|
||
return `${days}d ${remainingHours}h`;
|
||
}
|
||
|
||
// 获取时间显示的颜色类名(基于时长)
|
||
function getTimeDisplayColor(ms: number): string {
|
||
const seconds = Math.floor(ms / 1000);
|
||
|
||
if (ms < 1000) return 'text-gray-400'; // 毫秒 - 灰色
|
||
if (seconds < 60) return 'text-gray-200'; // 秒 - 浅灰
|
||
if (seconds < 3600) return 'text-white'; // 分钟 - 白色
|
||
if (seconds < 86400) return 'text-amber-300'; // 小时 - 琥珀色
|
||
return 'text-rose-300'; // 天 - 玫瑰色(警示长时间)
|
||
}
|
||
|
||
// 估算剩余时间(基于当前进度和已用时间)
|
||
function estimateRemainingTime(task: any): string | null {
|
||
if (task.task_status !== 'IN_PROGRESS') return null;
|
||
|
||
const progress = task.task_result?.progress_percentage || 0;
|
||
if (progress <= 0 || progress >= 100) return null;
|
||
|
||
const now = new Date().getTime();
|
||
const startTime = new Date(task.created_at).getTime();
|
||
const elapsedTime = now - startTime;
|
||
|
||
// 基于当前进度估算总时间
|
||
const estimatedTotalTime = (elapsedTime / progress) * 100;
|
||
const remainingTime = estimatedTotalTime - elapsedTime;
|
||
|
||
if (remainingTime <= 0) return null;
|
||
|
||
return formatTime(remainingTime);
|
||
}
|
||
|
||
// 获取状态颜色
|
||
function getStatusColor(status: number): string {
|
||
if (status >= 200 && status < 300) return 'text-green-400';
|
||
if (status >= 300 && status < 400) return 'text-yellow-400';
|
||
if (status >= 400) return 'text-red-400';
|
||
return 'text-blue-400';
|
||
}
|
||
|
||
// 展开/折叠任务
|
||
const toggleTaskExpansion = (taskId: string) => {
|
||
setExpandedTasks(prev => {
|
||
const newSet = new Set(prev);
|
||
if (newSet.has(taskId)) {
|
||
newSet.delete(taskId);
|
||
} else {
|
||
newSet.add(taskId);
|
||
}
|
||
return newSet;
|
||
});
|
||
};
|
||
|
||
// 计算时间线位置
|
||
const maxTime = Math.max(...(filteredTaskExecutions.length > 0 ? filteredTaskExecutions.map((task: TaskExecution) => task.endTime) : [0]));
|
||
|
||
return (
|
||
<div className={cn("h-full flex flex-col bg-gray-950 overflow-hidden", className)}>
|
||
{/* 工具栏 */}
|
||
<div className="flex items-center justify-between px-6 py-4 bg-gray-900 border-b border-gray-800 flex-shrink-0">
|
||
<div className="flex items-center gap-4">
|
||
<Network className="w-5 h-5 text-cyan-400" />
|
||
<h3 className="text-lg font-semibold text-white">任务执行时间线</h3>
|
||
|
||
{/* 任务统计 */}
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center gap-1 px-3 py-1.5 bg-gray-800 rounded-md">
|
||
<span className="text-sm text-gray-400">总任务:</span>
|
||
<span className="text-sm font-bold text-white">{taskStats.total}</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600/20 rounded-md border border-emerald-600/30">
|
||
<CheckCircle className="w-4 h-4 text-emerald-400" />
|
||
<span className="text-sm text-emerald-400 font-bold">{taskStats.completed}</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-cyan-600/20 rounded-md border border-cyan-600/30">
|
||
<Clock className="w-4 h-4 text-cyan-400 animate-pulse" />
|
||
<span className="text-sm text-cyan-400 font-bold">{taskStats.inProgress}</span>
|
||
</div>
|
||
{taskStats.failed > 0 && (
|
||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-rose-600/20 rounded-md border border-rose-600/30">
|
||
<XCircle className="w-4 h-4 text-rose-400" />
|
||
<span className="text-sm text-rose-400 font-bold">{taskStats.failed}</span>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-600/20 rounded-md border border-amber-600/30">
|
||
<Pause className="w-4 h-4 text-amber-400" />
|
||
<span className="text-sm text-amber-400 font-bold">{taskStats.pending}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
{/* 实时监控控制 */}
|
||
{onTogglePolling && (
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={onTogglePolling}
|
||
className={cn(
|
||
"flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-all",
|
||
isPolling
|
||
? "bg-green-600/20 text-green-400 hover:bg-green-600/30"
|
||
: "bg-gray-600/20 text-gray-400 hover:bg-gray-600/30"
|
||
)}
|
||
>
|
||
{isPolling ? (
|
||
<>
|
||
<Activity className="w-4 h-4 animate-pulse" />
|
||
<span>实时监控</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Pause className="w-4 h-4" />
|
||
<span>已暂停</span>
|
||
</>
|
||
)}
|
||
</button>
|
||
|
||
{lastUpdate && (
|
||
<span className="text-xs text-gray-500">
|
||
最后更新: {timeAgo}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 搜索框 */}
|
||
<div className="relative group">
|
||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400 group-focus-within:text-cyan-400 transition-colors" />
|
||
<input
|
||
type="text"
|
||
placeholder="搜索任务名称、状态、类型..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="pl-10 pr-10 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-white placeholder-gray-400 focus:outline-none focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20 transition-all duration-200 hover:border-gray-600"
|
||
/>
|
||
{searchTerm && (
|
||
<button
|
||
onClick={() => setSearchTerm('')}
|
||
className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400 hover:text-white transition-colors"
|
||
title="清除搜索"
|
||
>
|
||
<XCircle className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 类型过滤 */}
|
||
<div className="relative">
|
||
<select
|
||
value={filterType}
|
||
onChange={(e) => setFilterType(e.target.value)}
|
||
className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-white focus:outline-none focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20 appearance-none pr-10 hover:border-gray-600 transition-all duration-200"
|
||
>
|
||
<option value="all">所有类型</option>
|
||
<option value="AI">AI 任务</option>
|
||
<option value="Video">视频任务</option>
|
||
<option value="Audio">音频任务</option>
|
||
<option value="Comp">合成任务</option>
|
||
<option value="Task">其他任务</option>
|
||
</select>
|
||
<ChevronDown className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||
</div>
|
||
|
||
{/* 重置过滤器按钮 */}
|
||
{(searchTerm || filterType !== 'all') && (
|
||
<button
|
||
onClick={() => {
|
||
setSearchTerm('');
|
||
setFilterType('all');
|
||
}}
|
||
className="flex items-center gap-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 hover:text-white text-sm rounded-lg transition-all duration-200 hover:scale-105"
|
||
title="重置所有过滤器"
|
||
>
|
||
<RefreshCw className="w-4 h-4" />
|
||
重置
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 主内容区域 */}
|
||
<div className="flex-1 flex min-h-0">
|
||
{/* 左侧任务列表 */}
|
||
<div className="w-1/2 border-r border-gray-800 flex flex-col min-h-0">
|
||
{/* 列表头部 */}
|
||
<div className="flex items-center px-4 py-2 bg-gray-800/50 text-xs font-medium text-gray-400 border-b border-gray-800 flex-shrink-0">
|
||
<div className="w-8">状态</div>
|
||
<div className="w-6"></div> {/* 展开/折叠按钮列 */}
|
||
<div className="flex-1">任务名称</div>
|
||
<div className="w-24 text-center">状态</div>
|
||
<div className="w-16 text-center">类型</div>
|
||
<div className="w-20 text-center">进度</div>
|
||
<div className="w-20 text-center">时间</div>
|
||
</div>
|
||
|
||
{/* 任务列表 */}
|
||
<div className="flex-1 overflow-y-auto min-h-0" style={{ maxHeight: 'calc(100vh - 200px)' }}>
|
||
{filteredTaskExecutions.length === 0 ? (
|
||
<div className="flex items-center justify-center py-8 text-gray-400">
|
||
<div className="text-center">
|
||
<Search className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||
<p className="text-sm">没有找到匹配的任务</p>
|
||
{(searchTerm || filterType !== 'all') && (
|
||
<p className="text-xs mt-1">尝试调整搜索条件或过滤器</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
filteredTaskExecutions.map((task, index) => (
|
||
<motion.div
|
||
key={task.id}
|
||
className={cn(
|
||
"flex items-center px-4 py-2 text-sm border-b border-gray-800/30 cursor-pointer hover:bg-gray-800/50",
|
||
selectedTask === task.id && "bg-blue-600/20"
|
||
)}
|
||
onClick={() => setSelectedTask(selectedTask === task.id ? null : task.id)}
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
transition={{ delay: index * 0.05 }}
|
||
>
|
||
{/* 状态图标 */}
|
||
<div className="w-8 flex justify-center">
|
||
{task.statusCode === 200 && <CheckCircle className="w-5 h-5 text-emerald-400" />}
|
||
{task.statusCode === 202 && <Clock className="w-5 h-5 text-cyan-400 animate-pulse" />}
|
||
{task.statusCode === 100 && <Pause className="w-5 h-5 text-amber-400" />}
|
||
{task.statusCode >= 400 && <XCircle className="w-5 h-5 text-rose-400" />}
|
||
</div>
|
||
|
||
{/* 展开/折叠按钮 */}
|
||
<div className="w-6 flex justify-center">
|
||
{task.level === 0 && task.subTasks && task.subTasks.length > 0 && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
toggleTaskExpansion(task.id);
|
||
}}
|
||
className="p-0.5 hover:bg-gray-700 rounded text-gray-400 hover:text-white"
|
||
>
|
||
{expandedTasks.has(task.id) ?
|
||
<ChevronDown className="w-3 h-3" /> :
|
||
<ChevronRight className="w-3 h-3" />
|
||
}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 任务名称 */}
|
||
<div className={cn(
|
||
"flex-1 flex items-center gap-2",
|
||
task.level === 1 && "pl-4"
|
||
)}>
|
||
<span className={cn(
|
||
"truncate font-medium",
|
||
task.level === 0 ? "text-white text-base" : "text-sm text-gray-300"
|
||
)}>
|
||
{task.level === 1 && "└ "}
|
||
{task.displayName}
|
||
</span>
|
||
|
||
{/* 错误信息和重试按钮 */}
|
||
{task.statusCode >= 400 && (
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
const originalTask = tasks.find((t: any) => t.task_id === task.id);
|
||
if (originalTask) {
|
||
const errorInfo = getTaskErrorInfo(originalTask);
|
||
alert(`错误信息: ${errorInfo.errorMessage}\n错误代码: ${errorInfo.errorCode || 'UNKNOWN'}`);
|
||
}
|
||
}}
|
||
className="p-1 hover:bg-gray-700 rounded text-red-400 hover:text-red-300"
|
||
title="查看错误详情"
|
||
>
|
||
<Info className="w-3 h-3" />
|
||
</button>
|
||
{onRetryTask && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleRetryTask(task.id);
|
||
}}
|
||
className="p-1 hover:bg-gray-700 rounded text-blue-400 hover:text-blue-300"
|
||
title="重试任务"
|
||
>
|
||
<RefreshCw className="w-3 h-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 状态 */}
|
||
<div className="w-24 text-center">
|
||
<span className={cn("text-sm font-bold", getTaskStatusColor(task.status))}>
|
||
{getTaskStatusText(task.status)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 类型 */}
|
||
<div className="w-16 text-center">
|
||
<span className="text-sm font-medium text-gray-300">{task.type}</span>
|
||
</div>
|
||
|
||
{/* 进度 */}
|
||
<div className="w-20 text-center">
|
||
<span className="text-sm font-medium text-gray-200">
|
||
{task.level === 0 ?
|
||
getTaskProgressCount(tasks.find((t: any) => t.task_id === task.id) || {}) :
|
||
task.status === 'IN_PROGRESS' ? `${task.progress}%` :
|
||
task.status === 'COMPLETED' ? '100%' : '0%'
|
||
}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 时间 */}
|
||
<div className="w-20 text-center">
|
||
<div className="flex flex-col items-center">
|
||
<span className={cn("text-sm font-medium", getTimeDisplayColor(task.executionTime))}>
|
||
{formatTime(task.executionTime)}
|
||
</span>
|
||
{/* 剩余时间估算 */}
|
||
{task.level === 0 && (() => {
|
||
const originalTask = tasks.find((t: any) => t.task_id === task.id);
|
||
const remainingTime = originalTask ? estimateRemainingTime(originalTask) : null;
|
||
return remainingTime ? (
|
||
<span className="text-xs text-cyan-400" title="预计剩余时间">
|
||
~{remainingTime}
|
||
</span>
|
||
) : null;
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧时间线 */}
|
||
<div className="w-1/2 flex flex-col min-h-0">
|
||
<div className="px-4 py-3 bg-gray-900/50 border-b border-gray-800 flex-shrink-0">
|
||
<div className="text-sm font-medium text-gray-300 text-center">
|
||
时间线视图 (总耗时: {formatTime(maxTime)})
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 p-4 overflow-y-auto min-h-0" style={{ maxHeight: 'calc(100vh - 200px)' }}>
|
||
|
||
{/* 时间刻度 */}
|
||
<div className="relative mb-6">
|
||
<div className="flex justify-between text-xs mb-3">
|
||
{Array.from({ length: 6 }, (_, i) => {
|
||
const timeValue = (maxTime / 5) * i;
|
||
return (
|
||
<span
|
||
key={i}
|
||
className={cn("font-medium", getTimeDisplayColor(timeValue))}
|
||
title={`${formatTime(timeValue)} (${Math.round(timeValue)}ms)`}
|
||
>
|
||
{formatTime(timeValue)}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="h-px bg-gradient-to-r from-gray-700 via-gray-600 to-gray-700"></div>
|
||
{/* 时间刻度线 */}
|
||
<div className="absolute top-8 left-0 right-0 flex justify-between">
|
||
{Array.from({ length: 6 }, (_, i) => (
|
||
<div key={i} className="w-px h-2 bg-gray-600"></div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 瀑布图 */}
|
||
<div className="space-y-1">
|
||
{filteredTaskExecutions.length === 0 ? (
|
||
<div className="flex items-center justify-center py-8 text-gray-400">
|
||
<div className="text-center">
|
||
<Activity className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||
<p className="text-sm">没有任务数据</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
filteredTaskExecutions.map((task) => (
|
||
<div key={task.id} className={cn(
|
||
"relative",
|
||
task.level === 0 ? "h-6" : "h-4"
|
||
)}>
|
||
{/* 任务条 */}
|
||
<div
|
||
className={cn(
|
||
"absolute rounded-sm cursor-pointer transition-opacity",
|
||
task.level === 0 ? "top-1 h-4" : "top-0.5 h-3",
|
||
task.statusCode === 200 ? "bg-emerald-500" :
|
||
task.statusCode === 202 ? "bg-cyan-500" :
|
||
task.statusCode >= 400 ? "bg-rose-500" : "bg-amber-500",
|
||
selectedTask === task.id ? "opacity-100" : "opacity-80 hover:opacity-95",
|
||
task.level === 1 && "opacity-70"
|
||
)}
|
||
style={{
|
||
left: `${(task.startTime / maxTime) * 100}%`,
|
||
width: `${Math.max((task.executionTime / maxTime) * 100, 0.5)}%`
|
||
}}
|
||
onClick={() => setSelectedTask(task.id)}
|
||
title={`${task.displayName}
|
||
执行时间: ${formatTime(task.executionTime)}
|
||
状态: ${getTaskStatusText(task.status)}
|
||
${task.status === 'IN_PROGRESS' ? `进度: ${task.progress}%` : ''}`}
|
||
>
|
||
{/* 基于状态的单色进度条 */}
|
||
<div className="w-full h-full rounded-sm overflow-hidden relative">
|
||
{/* 背景条 */}
|
||
<div className="w-full h-full bg-gray-700/30"></div>
|
||
|
||
{/* 进度条 - 根据任务状态和进度显示 */}
|
||
{task.status === 'IN_PROGRESS' && (
|
||
<div
|
||
className={cn(
|
||
"absolute top-0 left-0 h-full transition-all duration-500 ease-out",
|
||
getProgressBarColor(task.status)
|
||
)}
|
||
style={{ width: `${task.progress}%` }}
|
||
title={`进度: ${task.progress}%`}
|
||
/>
|
||
)}
|
||
|
||
{/* 完成状态显示满进度 */}
|
||
{task.status === 'COMPLETED' && (
|
||
<div
|
||
className={cn(
|
||
"absolute top-0 left-0 h-full w-full",
|
||
getProgressBarColor(task.status)
|
||
)}
|
||
title="已完成"
|
||
/>
|
||
)}
|
||
|
||
{/* 失败状态显示部分进度 */}
|
||
{task.status === 'FAILED' && (
|
||
<div
|
||
className={cn(
|
||
"absolute top-0 left-0 h-full opacity-70",
|
||
getProgressBarColor(task.status)
|
||
)}
|
||
style={{ width: `${Math.max(task.progress, 15)}%` }}
|
||
title={`失败于 ${task.progress}%`}
|
||
/>
|
||
)}
|
||
|
||
{/* 等待状态显示微弱指示 */}
|
||
{task.status === 'PENDING' && (
|
||
<div
|
||
className={cn(
|
||
"absolute top-0 left-0 h-full opacity-40",
|
||
getProgressBarColor(task.status)
|
||
)}
|
||
style={{ width: '8%' }}
|
||
title="等待开始"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* 子任务进度指示器 */}
|
||
{task.level === 1 && task.status === 'IN_PROGRESS' && (
|
||
<div
|
||
className="absolute top-0 left-0 h-full bg-white/30 rounded-sm"
|
||
style={{ width: `${task.progress}%` }}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 详细信息面板 */}
|
||
<AnimatePresence>
|
||
{selectedTask && (
|
||
<motion.div
|
||
initial={{ opacity: 0, height: 0 }}
|
||
animate={{ opacity: 1, height: 'auto' }}
|
||
exit={{ opacity: 0, height: 0 }}
|
||
className="border-t border-gray-800 p-4"
|
||
>
|
||
{(() => {
|
||
const task = taskExecutions.find((t: TaskExecution) => t.id === selectedTask);
|
||
if (!task) return null;
|
||
|
||
return (
|
||
<div className="grid grid-cols-2 gap-6">
|
||
<div>
|
||
<h4 className="text-sm font-medium text-white mb-2">
|
||
{task.level === 0 ? '主任务详情' : '子任务详情'}
|
||
</h4>
|
||
<div className="space-y-1 text-xs">
|
||
<div><span className="text-gray-400">任务名称:</span> <span className="text-white">{task.displayName}</span></div>
|
||
<div><span className="text-gray-400">任务ID:</span> <span className="text-white font-mono text-[10px]">{task.name}</span></div>
|
||
<div><span className="text-gray-400">状态:</span> <span className={getTaskStatusColor(task.status)}>{getTaskStatusText(task.status)}</span></div>
|
||
<div><span className="text-gray-400">类型:</span> <span className="text-white">{task.type}</span></div>
|
||
<div><span className="text-gray-400">层级:</span> <span className="text-white">{task.level === 0 ? '主任务' : '子任务'}</span></div>
|
||
{task.status === 'IN_PROGRESS' && (
|
||
<>
|
||
<div><span className="text-gray-400">进度:</span> <span className="text-white">{task.progress}%</span></div>
|
||
{(() => {
|
||
const originalTask = tasks.find((t: any) => t.task_id === task.id);
|
||
const remainingTime = originalTask ? estimateRemainingTime(originalTask) : null;
|
||
return remainingTime ? (
|
||
<div><span className="text-gray-400">预计剩余:</span> <span className="text-cyan-400">{remainingTime}</span></div>
|
||
) : null;
|
||
})()}
|
||
</>
|
||
)}
|
||
{task.parentId && (
|
||
<div><span className="text-gray-400">父任务:</span> <span className="text-white font-mono text-[10px]">{task.parentId}</span></div>
|
||
)}
|
||
|
||
{/* 错误信息显示 */}
|
||
{task.statusCode >= 400 && (() => {
|
||
const originalTask = tasks.find((t: any) => t.task_id === task.id);
|
||
if (originalTask) {
|
||
const errorInfo = getTaskErrorInfo(originalTask);
|
||
return (
|
||
<div className="mt-2 p-2 bg-red-600/10 border border-red-600/20 rounded">
|
||
<div className="flex items-center gap-1 mb-1">
|
||
<XCircle className="w-3 h-3 text-red-400" />
|
||
<span className="text-red-400 font-medium">错误信息</span>
|
||
</div>
|
||
<div className="text-red-300 text-[10px] break-words">{errorInfo.errorMessage}</div>
|
||
{errorInfo.errorCode && (
|
||
<div className="text-red-400 text-[10px] font-mono mt-1">代码: {errorInfo.errorCode}</div>
|
||
)}
|
||
{onRetryTask && (
|
||
<button
|
||
onClick={() => handleRetryTask(task.id)}
|
||
className="mt-2 flex items-center gap-1 px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white text-[10px] rounded transition-colors"
|
||
>
|
||
<RefreshCw className="w-3 h-3" />
|
||
重试任务
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
return null;
|
||
})()}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<h4 className="text-sm font-medium text-white mb-2">执行分析</h4>
|
||
<div className="space-y-1 text-xs">
|
||
<div><span className="text-gray-400">总时间:</span> <span className="text-white">{formatTime(task.executionTime)}</span></div>
|
||
<div><span className="text-gray-400">AI处理:</span> <span className="text-white">{formatTime(task.phases.aiProcessing || 0)}</span></div>
|
||
<div><span className="text-gray-400">数据大小:</span> <span className="text-white">{formatSize(task.dataSize)}</span></div>
|
||
{task.level === 0 && task.taskResult?.total_count && (
|
||
<div><span className="text-gray-400">子任务数:</span> <span className="text-white">{task.taskResult.total_count}</span></div>
|
||
)}
|
||
{task.level === 0 && task.taskResult?.completed_count !== undefined && (
|
||
<div><span className="text-gray-400">已完成:</span> <span className="text-white">{task.taskResult.completed_count}/{task.taskResult.total_count}</span></div>
|
||
)}
|
||
{task.level === 1 && task.taskResult?.urls && (
|
||
<div><span className="text-gray-400">输出文件:</span> <span className="text-white">{task.taskResult.urls.length} 个</span></div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default NetworkTimeline;
|