forked from 77media/video-flow
1657 lines
66 KiB
TypeScript
1657 lines
66 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>('');
|
||
const [retryingTasks, setRetryingTasks] = useState<Set<string>>(new Set());
|
||
|
||
// 自动清理重试状态:当任务状态不再是RETRYING时,移除重试状态
|
||
useEffect(() => {
|
||
if (retryingTasks.size === 0) return;
|
||
|
||
const currentRetryingTaskIds = Array.from(retryingTasks);
|
||
const tasksToRemove: string[] = [];
|
||
|
||
currentRetryingTaskIds.forEach(taskId => {
|
||
// 检查任务是否还在重试状态
|
||
const isStillRetrying = tasks.some(task => {
|
||
// 检查主任务
|
||
if (task.task_id === taskId && task.task_status === 'RETRYING') {
|
||
return true;
|
||
}
|
||
// 检查子任务
|
||
if (task.sub_tasks && Array.isArray(task.sub_tasks)) {
|
||
return task.sub_tasks.some((subTask: { task_id: string; task_status: string }) =>
|
||
subTask.task_id === taskId && subTask.task_status === 'RETRYING'
|
||
);
|
||
}
|
||
return false;
|
||
});
|
||
|
||
if (!isStillRetrying) {
|
||
tasksToRemove.push(taskId);
|
||
}
|
||
});
|
||
|
||
// 移除不再重试的任务
|
||
if (tasksToRemove.length > 0) {
|
||
setRetryingTasks(prev => {
|
||
const newSet = new Set(prev);
|
||
tasksToRemove.forEach(taskId => newSet.delete(taskId));
|
||
return newSet;
|
||
});
|
||
}
|
||
}, [tasks, retryingTasks]);
|
||
|
||
// 更新时间显示
|
||
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) => {
|
||
const status = task.task_status;
|
||
|
||
// 成功状态
|
||
if (['COMPLETED', 'SUCCESS', 'FINISHED'].includes(status)) {
|
||
stats.completed++;
|
||
}
|
||
// 进行中状态
|
||
else if (['IN_PROGRESS', 'RUNNING', 'PROCESSING', 'EXECUTING', 'PAUSED', 'SUSPENDED'].includes(status)) {
|
||
stats.inProgress++;
|
||
}
|
||
// 失败状态
|
||
else if (['FAILED', 'FAILURE', 'ERROR', 'CANCELLED', 'TIMEOUT'].includes(status)) {
|
||
stats.failed++;
|
||
}
|
||
// 等待状态
|
||
else if (['PENDING', 'QUEUED', 'WAITING', 'SCHEDULED', 'INIT'].includes(status)) {
|
||
stats.pending++;
|
||
}
|
||
// 未知状态默认为待处理
|
||
else {
|
||
stats.pending++;
|
||
}
|
||
});
|
||
|
||
return stats;
|
||
}, [tasks]);
|
||
|
||
// 主任务列表 - 不依赖展开状态,避免重复计算
|
||
const mainTaskExecutions = useMemo((): TaskExecution[] => {
|
||
if (!tasks || tasks.length === 0) return [];
|
||
|
||
// 添加性能监控
|
||
const startTime = Date.now();
|
||
console.log(`[性能] 开始计算mainTaskExecutions,任务数量: ${tasks.length}`);
|
||
|
||
// 获取所有任务的真实开始时间,用于计算时间线的基准点
|
||
const allStartTimes = tasks.map(task => {
|
||
const startTime = task.start_time || task.created_at;
|
||
return new Date(startTime).getTime();
|
||
}).filter(time => !isNaN(time));
|
||
|
||
const globalStartTime = allStartTimes.length > 0 ? Math.min(...allStartTimes) : Date.now();
|
||
const result: TaskExecution[] = [];
|
||
|
||
tasks.forEach((task: any) => {
|
||
// 使用真实的开始时间和结束时间
|
||
const realStartTime = task.start_time || task.created_at;
|
||
const realEndTime = task.end_time || task.updated_at;
|
||
|
||
const taskStartTime = new Date(realStartTime).getTime();
|
||
const taskEndTime = new Date(realEndTime).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)
|
||
};
|
||
|
||
// 主任务 - 直接使用新API数据
|
||
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 - globalStartTime,
|
||
endTime: taskEndTime - globalStartTime,
|
||
progress: getTaskProgress(task),
|
||
level: 0,
|
||
isExpanded: false, // 初始状态,后续动态更新
|
||
subTasks: [],
|
||
phases,
|
||
taskResult: task.task_result // 直接使用原始数据,避免不必要的解析
|
||
};
|
||
|
||
// 预处理子任务数据但不添加到结果中(性能优化:限制处理数量)
|
||
if (task.task_result?.data && Array.isArray(task.task_result.data)) {
|
||
// 性能优化:如果子任务过多,只处理前20个,但保留总数信息
|
||
const maxSubTasks = 20;
|
||
const subTaskData = task.task_result.data.length > maxSubTasks
|
||
? task.task_result.data.slice(0, maxSubTasks)
|
||
: task.task_result.data;
|
||
|
||
if (task.task_result.data.length > maxSubTasks) {
|
||
console.log(`[性能] 任务 ${task.task_id} 有 ${task.task_result.data.length} 个子任务,只处理前 ${maxSubTasks} 个`);
|
||
}
|
||
|
||
const subTasks = subTaskData.map((subItem: any, subIndex: number) => {
|
||
const totalCount = task.task_result.total_count || task.task_result.data.length;
|
||
const subDuration = duration / totalCount;
|
||
const subStartTime = taskStartTime - globalStartTime + (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;
|
||
}
|
||
|
||
// 处理真实的子任务(新API的sub_tasks字段)- 优先使用真实子任务
|
||
if (task.sub_tasks && Array.isArray(task.sub_tasks) && task.sub_tasks.length > 0) {
|
||
const realSubTasks = task.sub_tasks.map((subTask: any, subIndex: number) => {
|
||
const subRealStartTime = subTask.start_time || subTask.created_at;
|
||
const subRealEndTime = subTask.end_time || subTask.updated_at;
|
||
|
||
const subTaskStartTime = new Date(subRealStartTime).getTime();
|
||
const subTaskEndTime = new Date(subRealEndTime).getTime();
|
||
const subTaskDuration = subTaskEndTime - subTaskStartTime;
|
||
|
||
const subPhases = {
|
||
initialization: subTaskDuration * 0.05,
|
||
dataLoading: subTaskDuration * 0.1,
|
||
aiProcessing: subTaskDuration * 0.7,
|
||
resultGeneration: subTaskDuration * 0.1,
|
||
dataTransfer: subTaskDuration * 0.03,
|
||
completion: subTaskDuration * 0.02
|
||
};
|
||
|
||
return {
|
||
id: subTask.task_id,
|
||
name: subTask.task_name,
|
||
displayName: getTaskDisplayName(subTask.task_name),
|
||
status: subTask.task_status, // 保持原始状态
|
||
statusCode: getTaskStatusCode(subTask.task_status),
|
||
type: getTaskType(subTask.task_name),
|
||
dataSize: getTaskDataSize(subTask),
|
||
executionTime: subTaskDuration,
|
||
startTime: subTaskStartTime - globalStartTime,
|
||
endTime: subTaskEndTime - globalStartTime,
|
||
progress: getTaskProgress(subTask),
|
||
level: 1,
|
||
parentId: task.task_id,
|
||
isExpanded: false,
|
||
subTasks: [],
|
||
phases: subPhases,
|
||
taskResult: subTask.task_result // 直接使用原始数据,避免不必要的解析
|
||
};
|
||
});
|
||
|
||
// 真实子任务优先:覆盖之前的subTasks
|
||
mainTask.subTasks = realSubTasks;
|
||
}
|
||
|
||
result.push(mainTask);
|
||
});
|
||
|
||
const endTime = Date.now();
|
||
const duration = endTime - startTime;
|
||
console.log(`[性能] mainTaskExecutions计算完成,耗时: ${duration}ms`);
|
||
|
||
// 如果计算时间超过1秒,记录警告
|
||
if (duration > 1000) {
|
||
console.warn(`[性能警告] mainTaskExecutions计算耗时过长: ${duration}ms,任务数量: ${tasks.length}`);
|
||
}
|
||
|
||
return result;
|
||
}, [tasks]);
|
||
|
||
// 完整的任务执行列表 - 包含展开的子任务
|
||
const taskExecutions = useMemo((): TaskExecution[] => {
|
||
const result: TaskExecution[] = [];
|
||
|
||
mainTaskExecutions.forEach(mainTask => {
|
||
// 更新展开状态
|
||
mainTask.isExpanded = expandedTasks.has(mainTask.id);
|
||
result.push(mainTask);
|
||
|
||
// 如果展开,添加子任务
|
||
if (mainTask.isExpanded && mainTask.subTasks && mainTask.subTasks.length > 0) {
|
||
result.push(...mainTask.subTasks);
|
||
}
|
||
});
|
||
|
||
return result;
|
||
}, [mainTaskExecutions, expandedTasks]);
|
||
|
||
// 动态获取所有任务类型 - 基于接口数据
|
||
const availableTaskTypes = useMemo(() => {
|
||
const types = new Set<string>();
|
||
taskExecutions.forEach(task => {
|
||
if (task.type) {
|
||
types.add(task.type);
|
||
}
|
||
});
|
||
return Array.from(types).sort();
|
||
}, [taskExecutions]);
|
||
|
||
// 过滤后的任务执行列表
|
||
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 formatDateTime(dateString: string | null | undefined): string {
|
||
if (!dateString) return '未知';
|
||
|
||
try {
|
||
const date = new Date(dateString);
|
||
if (isNaN(date.getTime())) return '无效时间';
|
||
|
||
// 格式化为本地时间:YYYY-MM-DD HH:mm:ss
|
||
return date.toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
hour12: false
|
||
});
|
||
} catch (error) {
|
||
console.warn('时间格式化失败:', error, dateString);
|
||
return '格式错误';
|
||
}
|
||
}
|
||
|
||
// 解析任务结果 - 处理JSON字符串(性能优化版本)
|
||
function parseTaskResult(taskResult: any) {
|
||
if (!taskResult) return null;
|
||
|
||
if (typeof taskResult === 'string') {
|
||
// 性能优化:快速检查是否为JSON格式
|
||
const trimmed = taskResult.trim();
|
||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||
// 不是JSON格式,直接返回原始字符串,避免JSON.parse异常
|
||
return { raw_text: trimmed };
|
||
}
|
||
|
||
try {
|
||
return JSON.parse(taskResult);
|
||
} catch (error) {
|
||
// 静默处理解析错误,避免大量日志输出影响性能
|
||
return { raw_text: trimmed, parse_error: true };
|
||
}
|
||
}
|
||
|
||
return taskResult;
|
||
}
|
||
|
||
// 获取子任务完成状况文本 (如: "10/12")
|
||
function getSubTaskStatus(task: any): string | null {
|
||
if (!task.sub_tasks || !Array.isArray(task.sub_tasks) || task.sub_tasks.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const completedSubTasks = task.sub_tasks.filter((sub: any) =>
|
||
['SUCCESS', 'COMPLETED', 'FINISHED'].includes(sub.task_status)
|
||
).length;
|
||
|
||
return `${completedSubTasks}/${task.sub_tasks.length}`;
|
||
}
|
||
|
||
// 获取任务进度
|
||
function getTaskProgress(task: any): number {
|
||
// 如果有子任务,优先基于子任务完成情况计算进度
|
||
if (task.sub_tasks && Array.isArray(task.sub_tasks) && task.sub_tasks.length > 0) {
|
||
const completedSubTasks = task.sub_tasks.filter((sub: any) =>
|
||
['SUCCESS', 'COMPLETED', 'FINISHED'].includes(sub.task_status)
|
||
).length;
|
||
return Math.round((completedSubTasks / task.sub_tasks.length) * 100);
|
||
}
|
||
|
||
// 性能优化:直接检查task.progress字段,避免JSON解析
|
||
if (typeof task.progress === 'number' && task.progress >= 0 && task.progress <= 100) {
|
||
return task.progress;
|
||
}
|
||
|
||
// 检查task_result是否已经是对象格式
|
||
if (task.task_result && typeof task.task_result === 'object' && task.task_result.progress_percentage) {
|
||
return task.task_result.progress_percentage;
|
||
}
|
||
|
||
// 最后根据状态推断进度
|
||
switch (task.task_status) {
|
||
case 'SUCCESS':
|
||
case 'COMPLETED':
|
||
case 'FINISHED':
|
||
return 100;
|
||
case 'FAILED':
|
||
case 'FAILURE':
|
||
case 'ERROR':
|
||
case 'CANCELLED':
|
||
return 0;
|
||
case 'IN_PROGRESS':
|
||
case 'RUNNING':
|
||
case 'PROCESSING':
|
||
case 'RETRYING':
|
||
return 50; // 默认50%
|
||
default:
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
// 获取任务的开始和结束时间
|
||
function getTaskTimes(task: TaskExecution) {
|
||
// 首先尝试从主任务列表中查找
|
||
const originalTask = tasks.find((t: any) => t.task_id === task.id);
|
||
|
||
if (originalTask) {
|
||
return {
|
||
startTime: originalTask.start_time || originalTask.created_at,
|
||
endTime: originalTask.end_time || originalTask.updated_at,
|
||
createdAt: originalTask.created_at,
|
||
updatedAt: originalTask.updated_at
|
||
};
|
||
}
|
||
|
||
// 如果是子任务,从父任务的sub_tasks中查找
|
||
if (task.level === 1 && task.parentId) {
|
||
const parentTask = tasks.find((t: any) => t.task_id === task.parentId);
|
||
if (parentTask && parentTask.sub_tasks) {
|
||
const subTask = parentTask.sub_tasks.find((st: any) => st.task_id === task.id);
|
||
if (subTask) {
|
||
return {
|
||
startTime: subTask.start_time || subTask.created_at,
|
||
endTime: subTask.end_time || subTask.updated_at,
|
||
createdAt: subTask.created_at,
|
||
updatedAt: subTask.updated_at
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果都找不到,返回空值
|
||
return {
|
||
startTime: null,
|
||
endTime: null,
|
||
createdAt: null,
|
||
updatedAt: null
|
||
};
|
||
}
|
||
|
||
// 辅助函数
|
||
function getTaskDisplayName(taskName: string): string {
|
||
const nameMap: Record<string, string> = {
|
||
'generate_character': '角色生成',
|
||
'generate_character_image': '角色图片生成',
|
||
'generate_sketch': '草图生成',
|
||
'generate_shot_sketch': '分镜生成',
|
||
'generate_video': '视频生成',
|
||
'generate_videos': '视频生成',
|
||
'generate_music': '音乐生成',
|
||
'final_composition': '最终合成',
|
||
'refine_orginal_script': '脚本优化',
|
||
'generate_production_bible': '制作手册生成',
|
||
'generate_production_bible_json': '制作手册JSON生成'
|
||
};
|
||
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,
|
||
'SUCCESS': 200,
|
||
'FINISHED': 200,
|
||
|
||
// 进行中状态
|
||
'IN_PROGRESS': 202,
|
||
'RUNNING': 202,
|
||
'PROCESSING': 202,
|
||
'EXECUTING': 202,
|
||
'RETRYING': 202, // 新增:重试状态
|
||
|
||
// 等待状态
|
||
'PENDING': 100,
|
||
'QUEUED': 100,
|
||
'WAITING': 100,
|
||
'SCHEDULED': 100,
|
||
'INIT': 100, // 新增:初始化状态
|
||
|
||
// 失败状态
|
||
'FAILED': 500,
|
||
'FAILURE': 500,
|
||
'ERROR': 500,
|
||
'CANCELLED': 499,
|
||
'TIMEOUT': 408,
|
||
|
||
// 暂停状态
|
||
'PAUSED': 202,
|
||
'SUSPENDED': 202
|
||
};
|
||
return statusMap[status] || 200;
|
||
}
|
||
|
||
function getTaskType(taskName: string): string {
|
||
const typeMap: Record<string, string> = {
|
||
'generate_character': 'AI',
|
||
'generate_character_image': 'AI',
|
||
'generate_sketch': 'AI',
|
||
'generate_shot_sketch': 'AI',
|
||
'generate_video': 'Video',
|
||
'generate_videos': 'Video',
|
||
'generate_music': 'Audio',
|
||
'final_composition': 'Comp',
|
||
'refine_orginal_script': 'Script',
|
||
'generate_production_bible': 'Doc',
|
||
'generate_production_bible_json': 'Data'
|
||
};
|
||
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);
|
||
}
|
||
|
||
// 获取任务进度数量显示 - 基于sub_tasks的实际完成状态
|
||
function getTaskProgressCount(task: any): string {
|
||
// 如果有子任务,基于子任务的完成状态计算进度
|
||
if (task.sub_tasks && Array.isArray(task.sub_tasks) && task.sub_tasks.length > 0) {
|
||
const totalSubTasks = task.sub_tasks.length;
|
||
const completedSubTasks = task.sub_tasks.filter((subTask: any) =>
|
||
['SUCCESS', 'COMPLETED', 'FINISHED'].includes(subTask.task_status)
|
||
).length;
|
||
|
||
return `${completedSubTasks}/${totalSubTasks}`;
|
||
}
|
||
|
||
// 如果没有子任务,基于主任务状态
|
||
if (['SUCCESS', 'COMPLETED', 'FINISHED'].includes(task.task_status)) {
|
||
return '1/1'; // 单任务已完成
|
||
}
|
||
|
||
if (['IN_PROGRESS', 'RUNNING', 'PROCESSING'].includes(task.task_status)) {
|
||
return '0/1'; // 单任务进行中
|
||
}
|
||
|
||
if (['FAILED', 'FAILURE', 'ERROR'].includes(task.task_status)) {
|
||
return '0/1'; // 单任务失败
|
||
}
|
||
|
||
// 其他情况(等待、初始化等)
|
||
return '0/1';
|
||
}
|
||
|
||
// 获取任务进度百分比显示 - 与任务执行状态面板完全一致
|
||
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 {
|
||
const statusTextMap: Record<string, string> = {
|
||
// 成功状态
|
||
'SUCCESS': '已完成',
|
||
'COMPLETED': '已完成',
|
||
'FINISHED': '已完成',
|
||
|
||
// 进行中状态
|
||
'IN_PROGRESS': '进行中',
|
||
'RUNNING': '运行中',
|
||
'PROCESSING': '处理中',
|
||
'EXECUTING': '执行中',
|
||
'RETRYING': '重试中', // 新增:重试状态
|
||
|
||
// 等待状态
|
||
'PENDING': '待处理',
|
||
'QUEUED': '队列中',
|
||
'WAITING': '等待中',
|
||
'SCHEDULED': '已调度',
|
||
'INIT': '初始化', // 新增:接口实际返回的状态
|
||
|
||
// 失败状态
|
||
'FAILED': '失败',
|
||
'FAILURE': '失败',
|
||
'ERROR': '错误',
|
||
'CANCELLED': '已取消',
|
||
'TIMEOUT': '超时',
|
||
|
||
// 暂停状态
|
||
'PAUSED': '已暂停',
|
||
'SUSPENDED': '已挂起'
|
||
};
|
||
|
||
return statusTextMap[status] || status;
|
||
}
|
||
|
||
// 获取任务状态颜色 - 更明显的颜色区分
|
||
function getTaskStatusColor(status: string): string {
|
||
// 成功状态 - 绿色系
|
||
if (['SUCCESS', 'COMPLETED', 'FINISHED'].includes(status)) {
|
||
return 'text-emerald-400';
|
||
}
|
||
// 进行中状态 - 蓝色系
|
||
else if (['IN_PROGRESS', 'RUNNING', 'PROCESSING', 'EXECUTING'].includes(status)) {
|
||
return 'text-cyan-400';
|
||
}
|
||
// 等待状态 - 黄色系
|
||
else if (['PENDING', 'QUEUED', 'WAITING', 'SCHEDULED', 'INIT'].includes(status)) {
|
||
return 'text-amber-400';
|
||
}
|
||
// 失败状态 - 红色系
|
||
else if (['FAILED', 'FAILURE', 'ERROR'].includes(status)) {
|
||
return 'text-rose-400';
|
||
}
|
||
// 取消状态 - 橙色系
|
||
else if (['CANCELLED', 'TIMEOUT'].includes(status)) {
|
||
return 'text-orange-400';
|
||
}
|
||
// 暂停状态 - 紫色系
|
||
else if (['PAUSED', 'SUSPENDED'].includes(status)) {
|
||
return 'text-purple-400';
|
||
}
|
||
// 默认状态
|
||
else {
|
||
return 'text-gray-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
|
||
};
|
||
}
|
||
|
||
// 获取子任务错误信息
|
||
function getSubTaskErrorInfo(taskId: string): { hasError: boolean; errorMessage: string; errorCode?: string } {
|
||
// 遍历所有主任务,查找对应的子任务
|
||
for (const mainTask of tasks) {
|
||
if (mainTask.sub_tasks && Array.isArray(mainTask.sub_tasks)) {
|
||
const subTask = mainTask.sub_tasks.find((st: any) => st.task_id === taskId);
|
||
if (subTask && subTask.task_status === 'FAILED') {
|
||
// 从子任务的task_result中提取错误信息
|
||
const errorMessage = subTask.task_result?.error_message ||
|
||
subTask.task_result?.message ||
|
||
subTask.error_message ||
|
||
'子任务执行失败,请重试';
|
||
|
||
const errorCode = subTask.task_result?.error_code ||
|
||
subTask.error_code ||
|
||
'UNKNOWN_ERROR';
|
||
|
||
return {
|
||
hasError: true,
|
||
errorMessage,
|
||
errorCode
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
return { hasError: false, errorMessage: '' };
|
||
}
|
||
|
||
// 处理重试任务
|
||
const handleRetryTask = async (taskId: string) => {
|
||
if (onRetryTask) {
|
||
try {
|
||
// 添加到重试中的任务集合
|
||
setRetryingTasks(prev => new Set(prev).add(taskId));
|
||
|
||
// 调用父组件的重试逻辑(包含乐观更新)
|
||
await onRetryTask(taskId);
|
||
|
||
console.log(`任务 ${taskId} 重试请求已发送`);
|
||
|
||
} catch (error) {
|
||
console.error('重试任务失败:', error);
|
||
// 重试失败时立即移除重试状态
|
||
setRetryingTasks(prev => {
|
||
const newSet = new Set(prev);
|
||
newSet.delete(taskId);
|
||
return newSet;
|
||
});
|
||
}
|
||
// 注意:成功情况下不立即移除重试状态,让乐观更新的RETRYING状态显示一段时间
|
||
// 重试状态会在数据刷新后自然消失(当任务状态从RETRYING变为其他状态时)
|
||
}
|
||
};
|
||
|
||
// 格式化文件大小
|
||
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 {
|
||
// 统一兜底:避免负值/NaN 导致显示 "-Xs"
|
||
if (!Number.isFinite(ms) || ms <= 0) {
|
||
return '0s';
|
||
}
|
||
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 getTaskLogContent(viewTask: TaskExecution): string {
|
||
// 优先从原始任务中提取完整字段
|
||
let origin: any | null = null;
|
||
if (viewTask.level === 0) {
|
||
origin = tasks.find((t: any) => t.task_id === viewTask.id) || null;
|
||
} else if (viewTask.level === 1 && viewTask.parentId) {
|
||
const parent = tasks.find((t: any) => t.task_id === viewTask.parentId);
|
||
origin = parent?.sub_tasks?.find((st: any) => st.task_id === viewTask.id) || null;
|
||
}
|
||
|
||
if (!origin) return '无可用日志';
|
||
|
||
const resultObj = parseTaskResult(origin.task_result);
|
||
const payload: Record<string, any> = {
|
||
task_id: origin.task_id,
|
||
task_name: origin.task_name,
|
||
task_status: origin.task_status,
|
||
task_message: origin.task_message || null,
|
||
error_message: origin.error_message || resultObj?.error_message || null,
|
||
error_traceback: origin.error_traceback || resultObj?.error_traceback || null,
|
||
progress_percentage: resultObj?.progress_percentage ?? origin.progress ?? null,
|
||
params: origin.task_params || null,
|
||
result: resultObj || origin.task_result || null,
|
||
timestamps: {
|
||
created_at: origin.created_at || null,
|
||
updated_at: origin.updated_at || null,
|
||
start_time: origin.start_time || null,
|
||
end_time: origin.end_time || null,
|
||
},
|
||
};
|
||
|
||
try {
|
||
return JSON.stringify(payload, null, 2);
|
||
} catch {
|
||
return typeof origin.task_result === 'string' ? origin.task_result : '无可用日志';
|
||
}
|
||
}
|
||
|
||
// 获取状态颜色
|
||
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 = React.useCallback((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]));
|
||
|
||
// 计算“项目总耗时”应独立于过滤条件,直接基于原始 tasks
|
||
// 规则:
|
||
// 1) 全局开始时间 = 所有任务 start_time/created_at 的最小值
|
||
// 2) 全局结束时间 =
|
||
// - 若存在已结束任务:取已结束任务 end_time 的最大值
|
||
// - 否则:取所有任务 updated_at 的最大值(表示仍在进行中的最近更新时间)
|
||
const projectDurationMs = React.useMemo(() => {
|
||
if (!tasks || tasks.length === 0) return 0;
|
||
|
||
const toMs = (v: any) => {
|
||
const n = new Date(v as string).getTime();
|
||
return Number.isNaN(n) ? NaN : n;
|
||
};
|
||
|
||
// 1) 全局开始
|
||
const allStarts = tasks
|
||
.map((t: any) => toMs(t.start_time || t.created_at))
|
||
.filter((n: number) => !Number.isNaN(n));
|
||
const globalStart = allStarts.length > 0 ? Math.min(...allStarts) : Date.now();
|
||
|
||
// 2) 终止态集合
|
||
const terminal = new Set(['COMPLETED','SUCCESS','FINISHED','FAILED','FAILURE','ERROR','CANCELLED','TIMEOUT']);
|
||
|
||
// 3) 已结束任务的结束时间
|
||
const endedEnds = tasks
|
||
.map((t: any) => terminal.has(t.task_status) ? toMs(t.end_time) : NaN)
|
||
.filter((n: number) => !Number.isNaN(n));
|
||
|
||
let globalEnd: number | null = null;
|
||
if (endedEnds.length > 0) {
|
||
globalEnd = Math.max(...endedEnds);
|
||
} else {
|
||
// 4) 无已结束任务,则取最近一次 updated_at 代表“当前进度”
|
||
const latestUpdated = tasks
|
||
.map((t: any) => toMs(t.updated_at || t.created_at))
|
||
.filter((n: number) => !Number.isNaN(n));
|
||
if (latestUpdated.length > 0) globalEnd = Math.max(...latestUpdated);
|
||
}
|
||
|
||
if (!globalEnd || Number.isNaN(globalEnd)) return 0;
|
||
return Math.max(0, globalEnd - globalStart);
|
||
}, [tasks]);
|
||
|
||
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-3 bg-gray-900 border-b border-gray-800 flex-shrink-0">
|
||
<div className="flex items-center gap-3">
|
||
<Network className="w-5 h-5 text-cyan-400" />
|
||
<h3 className="text-lg font-semibold text-white">任务执行时间线</h3>
|
||
|
||
{/* 任务统计 */}
|
||
<div className="flex items-center gap-2.5">
|
||
<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>
|
||
{availableTaskTypes.map(type => (
|
||
<option key={type} value={type}>
|
||
{type === 'AI' ? 'AI 任务' :
|
||
type === 'Video' ? '视频任务' :
|
||
type === 'Audio' ? '音频任务' :
|
||
type === 'Comp' ? '合成任务' :
|
||
type === 'Script' ? '脚本任务' :
|
||
type === 'Doc' ? '文档任务' :
|
||
type === 'Data' ? '数据任务' :
|
||
`${type} 任务`}
|
||
</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-full flex flex-col min-h-0">
|
||
{/* 列表头部 */}
|
||
<div className="flex items-center px-4 py-1.5 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="w-56">任务名称</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-48 text-center">时间信息</div>
|
||
<div className="flex-1 text-center">时间线</div>
|
||
</div>
|
||
|
||
{/* 任务列表 */}
|
||
<div className="flex-1 overflow-y-auto min-h-0">
|
||
{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) => (
|
||
<div
|
||
key={task.id}
|
||
className={cn(
|
||
"flex items-center px-4 py-1.5 text-sm border-b border-gray-800/30 cursor-pointer hover:bg-gray-800/50 transition-all duration-200 ease-out",
|
||
selectedTask === task.id && "bg-blue-600/20",
|
||
task.level === 1 && "ml-4 bg-gray-900/30" // 子任务缩进和背景区分
|
||
)}
|
||
onClick={() => setSelectedTask(selectedTask === task.id ? null : task.id)}
|
||
>
|
||
{/* 状态图标 */}
|
||
<div className="w-8 flex justify-center">
|
||
{task.statusCode === 200 && <CheckCircle className="w-4 h-4 text-emerald-400" />}
|
||
{task.statusCode === 202 && <Clock className="w-4 h-4 text-cyan-400 animate-pulse" />}
|
||
{task.statusCode === 100 && <Pause className="w-4 h-4 text-amber-400" />}
|
||
{task.statusCode >= 400 && <XCircle className="w-4 h-4 text-rose-400" />}
|
||
</div>
|
||
|
||
{/* 展开/折叠按钮 */}
|
||
<div className="w-6 flex justify-center">
|
||
{task.level === 0 && task.subTasks && task.subTasks.length > 0 && (
|
||
<motion.button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
toggleTaskExpansion(task.id);
|
||
}}
|
||
className="p-0.5 hover:bg-gray-700 rounded text-gray-400 hover:text-white transition-colors duration-150"
|
||
whileHover={{ scale: 1.1 }}
|
||
whileTap={{ scale: 0.95 }}
|
||
>
|
||
<motion.div
|
||
animate={{
|
||
rotate: expandedTasks.has(task.id) ? 90 : 0
|
||
}}
|
||
transition={{
|
||
duration: 0.2,
|
||
ease: "easeOut"
|
||
}}
|
||
>
|
||
<ChevronRight className="w-3 h-3" />
|
||
</motion.div>
|
||
</motion.button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 任务名称 */}
|
||
<div className={cn(
|
||
"w-56 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 || task.status === 'RETRYING') && (
|
||
<div className="flex items-center gap-1">
|
||
{/* 错误信息悬停提示 */}
|
||
<div className="relative group">
|
||
<div className="p-1 hover:bg-gray-700 rounded text-red-400 hover:text-red-300 cursor-help">
|
||
<Info className="w-4 h-4" />
|
||
</div>
|
||
|
||
{/* 悬停时显示的错误信息卡片 */}
|
||
<div className="absolute left-0 top-8 z-50 invisible group-hover:visible opacity-0 group-hover:opacity-100 transition-all duration-200">
|
||
<div className="bg-gray-900 border border-red-500/30 rounded-lg shadow-xl p-4 min-w-80 max-w-96">
|
||
{/* 错误标题 */}
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<XCircle className="w-5 h-5 text-red-400 flex-shrink-0" />
|
||
<h4 className="text-red-400 font-semibold text-base">任务执行失败</h4>
|
||
</div>
|
||
|
||
{/* 错误详情 */}
|
||
{(() => {
|
||
// 根据任务层级选择不同的错误信息获取方式
|
||
let errorInfo;
|
||
if (task.level === 0) {
|
||
// 主任务
|
||
const originalTask = tasks.find((t: any) => t.task_id === task.id);
|
||
if (!originalTask) return null;
|
||
errorInfo = getTaskErrorInfo(originalTask);
|
||
} else {
|
||
// 子任务
|
||
errorInfo = getSubTaskErrorInfo(task.id);
|
||
}
|
||
|
||
if (!errorInfo.hasError) return null;
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{/* 错误消息 */}
|
||
<div>
|
||
<div className="text-gray-400 text-sm mb-1">错误信息:</div>
|
||
<div className="text-white text-base leading-relaxed bg-gray-800 rounded p-2 border-l-4 border-red-500">
|
||
{errorInfo.errorMessage || '未知错误'}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 错误代码 */}
|
||
{errorInfo.errorCode && (
|
||
<div>
|
||
<div className="text-gray-400 text-sm mb-1">错误代码:</div>
|
||
<div className="text-red-300 font-mono text-sm bg-gray-800 rounded px-2 py-1 inline-block">
|
||
{errorInfo.errorCode}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 任务信息 */}
|
||
<div className="pt-2 border-t border-gray-700">
|
||
<div className="text-gray-400 text-sm mb-1">任务详情:</div>
|
||
<div className="text-gray-300 text-sm space-y-1">
|
||
<div>任务ID: <span className="font-mono text-sm">{task.id}</span></div>
|
||
<div>任务类型: <span className="text-white">{task.type}</span></div>
|
||
<div>失败时间: <span className="text-white">{(() => {
|
||
const taskTimes = getTaskTimes(task);
|
||
return taskTimes.endTime ? formatDateTime(taskTimes.endTime) : '未知';
|
||
})()}</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* 小三角箭头 */}
|
||
<div className="absolute -top-2 left-4 w-4 h-4 bg-gray-900 border-l border-t border-red-500/30 transform rotate-45"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 重试按钮 */}
|
||
{onRetryTask && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleRetryTask(task.id);
|
||
}}
|
||
disabled={retryingTasks.has(task.id) || task.status === 'RETRYING'}
|
||
className={cn(
|
||
"p-1 hover:bg-gray-700 rounded transition-colors",
|
||
(retryingTasks.has(task.id) || task.status === 'RETRYING')
|
||
? "text-yellow-400 cursor-not-allowed"
|
||
: "text-blue-400 hover:text-blue-300"
|
||
)}
|
||
title={
|
||
(retryingTasks.has(task.id) || task.status === 'RETRYING')
|
||
? "重试中..."
|
||
: "重试任务"
|
||
}
|
||
>
|
||
<RefreshCw className={cn(
|
||
"w-4 h-4",
|
||
(retryingTasks.has(task.id) || task.status === 'RETRYING') && "animate-spin"
|
||
)} />
|
||
</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 ? (
|
||
// 主任务显示 completed/total 格式 (如: 14/16 或 16/16)
|
||
getTaskProgressCount(tasks.find((t: any) => t.task_id === task.id) || {})
|
||
) : (
|
||
// 子任务显示百分比格式
|
||
task.status === 'SUCCESS' || task.status === 'COMPLETED' ? '100%' :
|
||
task.status === 'IN_PROGRESS' ? `${task.progress}%` :
|
||
task.status === 'FAILED' ? '0%' : '0%'
|
||
)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 时间信息 */}
|
||
<div className="w-48 text-center">
|
||
<div className="flex flex-col items-center">
|
||
<span className={cn("text-sm font-medium", getTimeDisplayColor(task.executionTime))}>
|
||
{formatTime(task.executionTime)}
|
||
</span>
|
||
{/* 开始和结束时间一行显示 */}
|
||
<div className="flex items-center gap-1 text-xs">
|
||
{(() => {
|
||
const taskTimes = getTaskTimes(task);
|
||
return taskTimes.startTime ? (
|
||
<span className="text-green-400" title={`开始时间: ${formatDateTime(taskTimes.startTime)}`}>
|
||
{new Date(taskTimes.startTime).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||
</span>
|
||
) : null;
|
||
})()}
|
||
{(() => {
|
||
const taskTimes = getTaskTimes(task);
|
||
return taskTimes.startTime && taskTimes.endTime ? (
|
||
<span className="text-gray-500">-</span>
|
||
) : null;
|
||
})()}
|
||
{(() => {
|
||
const taskTimes = getTaskTimes(task);
|
||
return taskTimes.endTime ? (
|
||
<span className="text-blue-400" title={`结束时间: ${formatDateTime(taskTimes.endTime)}`}>
|
||
{new Date(taskTimes.endTime).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
||
</span>
|
||
) : null;
|
||
})()}
|
||
</div>
|
||
{/* 剩余时间估算 */}
|
||
{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>
|
||
|
||
{/* 时间线进度条 */}
|
||
<div className="flex-1 px-2">
|
||
<div className="relative h-6 bg-gray-800/50 rounded border border-gray-700/50 overflow-hidden">
|
||
{/* 时间线背景 */}
|
||
<div className="absolute inset-0 bg-gradient-to-r from-gray-700 via-gray-600 to-gray-700 opacity-30"></div>
|
||
|
||
{/* 任务进度条 */}
|
||
<div
|
||
className={cn(
|
||
"absolute top-0 h-full rounded-sm transition-all duration-200",
|
||
task.statusCode === 200 ? "bg-emerald-500" :
|
||
task.statusCode === 202 ? "bg-cyan-500" :
|
||
task.statusCode >= 400 ? "bg-rose-500" : "bg-amber-500",
|
||
selectedTask === task.id && "ring-2 ring-cyan-300/90 shadow-[0_0_8px_rgba(34,211,238,0.6)]"
|
||
)}
|
||
style={{
|
||
left: `${(task.startTime / maxTime) * 100}%`,
|
||
width: `${Math.max((task.executionTime / maxTime) * 100, 0.5)}%`
|
||
}}
|
||
title={`${task.displayName} - ${formatTime(task.executionTime)}`}
|
||
>
|
||
{/* 进度百分比显示 */}
|
||
{(() => {
|
||
const barWidth = (task.executionTime / maxTime) * 100;
|
||
const showPercentage = barWidth > 8; // 只在有足够空间时显示
|
||
|
||
if (!showPercentage) return null;
|
||
|
||
const getPercentageText = () => {
|
||
switch (task.status) {
|
||
case 'IN_PROGRESS':
|
||
return `${task.progress}%`;
|
||
case 'COMPLETED':
|
||
return '100%';
|
||
case 'FAILED':
|
||
return `${task.progress}%`;
|
||
case 'PENDING':
|
||
return '0%';
|
||
default:
|
||
return '';
|
||
}
|
||
};
|
||
|
||
const percentageText = getPercentageText();
|
||
if (!percentageText) return null;
|
||
|
||
return (
|
||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||
<span
|
||
className="text-xs font-semibold text-white"
|
||
style={{
|
||
textShadow: '0 1px 2px rgba(0,0,0,0.8)',
|
||
fontSize: '10px'
|
||
}}
|
||
>
|
||
{percentageText}
|
||
</span>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{/* 时间刻度标记 */}
|
||
<div className="absolute top-0 left-0 right-0 h-full flex justify-between items-end pb-1">
|
||
{Array.from({ length: 5 }, (_, i) => (
|
||
<div key={i} className="w-px h-2 bg-gray-500/50"></div>
|
||
))}
|
||
</div>
|
||
</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-3"
|
||
>
|
||
{(() => {
|
||
const task = taskExecutions.find((t: TaskExecution) => t.id === selectedTask);
|
||
if (!task) return null;
|
||
|
||
return (
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<h4 className="text-base font-medium text-white mb-3">
|
||
{task.level === 0 ? '主任务详情' : '子任务详情'}
|
||
</h4>
|
||
{/* 顶部一行:任务关键信息 + 时间信息 */}
|
||
{(() => {
|
||
const t = getTaskTimes(task);
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm mb-2">
|
||
<span className="px-2 py-1 bg-gray-800/60 border border-gray-700 rounded text-white">
|
||
{task.displayName}
|
||
</span>
|
||
<span className={cn("px-2 py-1 rounded border", getTaskStatusColor(task.status), "border-gray-700/60 bg-gray-800/40 ")}>{getTaskStatusText(task.status)}</span>
|
||
<span className="px-2 py-1 bg-gray-800/40 border border-gray-700 rounded text-gray-300">{task.type}</span>
|
||
{t.startTime && (
|
||
<span className="px-2 py-1 bg-gray-900/40 border border-gray-800 rounded text-green-400" title={`开始时间: ${formatDateTime(t.startTime)}`}>
|
||
开始 {new Date(t.startTime).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||
</span>
|
||
)}
|
||
{t.endTime && (
|
||
<span className="px-2 py-1 bg-gray-900/40 border border-gray-800 rounded text-blue-400" title={`结束时间: ${formatDateTime(t.endTime)}`}>
|
||
结束 {new Date(t.endTime).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||
</span>
|
||
)}
|
||
{t.startTime && t.endTime && (
|
||
<span className="px-2 py-1 bg-gray-900/40 border border-gray-800 rounded text-yellow-400">
|
||
时长 {formatTime(task.executionTime)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
<div className="space-y-2 text-sm">
|
||
<div><span className="text-gray-400">任务ID:</span> <span className="text-white font-mono text-sm">{task.name}</span></div>
|
||
<div><span className="text-gray-400">层级:</span> <span className="text-white">{task.level === 0 ? '主任务' : '子任务'}</span></div>
|
||
|
||
{/* 子任务完成状况 */}
|
||
{task.level === 0 && (() => {
|
||
const originalTask = tasks.find((t: any) => t.task_id === task.id);
|
||
const subTaskStatus = originalTask ? getSubTaskStatus(originalTask) : null;
|
||
return subTaskStatus ? (
|
||
<div><span className="text-gray-400">子任务进度:</span> <span className="text-cyan-400 font-mono">{subTaskStatus}</span></div>
|
||
) : null;
|
||
})()}
|
||
|
||
{/* 时间信息已并入顶部一行,此处不再重复 */}
|
||
{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 && (() => {
|
||
// 根据任务层级选择不同的错误信息获取方式
|
||
let errorInfo;
|
||
if (task.level === 0) {
|
||
// 主任务
|
||
const originalTask = tasks.find((t: any) => t.task_id === task.id);
|
||
if (!originalTask) return null;
|
||
errorInfo = getTaskErrorInfo(originalTask);
|
||
} else {
|
||
// 子任务
|
||
errorInfo = getSubTaskErrorInfo(task.id);
|
||
}
|
||
|
||
if (!errorInfo.hasError) return null;
|
||
|
||
return (
|
||
<div className="mt-2 p-2 bg-red-600/10 border border-red-600/20 rounded flex items-center gap-2 flex-wrap">
|
||
<div className="flex items-center gap-1">
|
||
<XCircle className="w-3 h-3 text-red-400" />
|
||
<span className="text-red-400 font-medium text-[11px]">错误</span>
|
||
</div>
|
||
<span className="text-red-300 text-[10px] truncate max-w-[40%]" title={errorInfo.errorMessage}>{errorInfo.errorMessage}</span>
|
||
{errorInfo.errorCode && (
|
||
<span className="text-red-400 text-[10px] font-mono">代码: {errorInfo.errorCode}</span>
|
||
)}
|
||
{onRetryTask && (
|
||
<button
|
||
onClick={() => handleRetryTask(task.id)}
|
||
disabled={retryingTasks.has(task.id) || task.status === 'RETRYING'}
|
||
className={cn(
|
||
"ml-auto flex items-center gap-1 px-2 py-1 text-white text-[10px] rounded transition-colors",
|
||
(retryingTasks.has(task.id) || task.status === 'RETRYING')
|
||
? "bg-yellow-600 cursor-not-allowed"
|
||
: "bg-blue-600 hover:bg-blue-700"
|
||
)}
|
||
>
|
||
<RefreshCw className={cn(
|
||
"w-3 h-3",
|
||
(retryingTasks.has(task.id) || task.status === 'RETRYING') && "animate-spin"
|
||
)} />
|
||
{(retryingTasks.has(task.id) || task.status === 'RETRYING') ? "重试中..." : "重试任务"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<h4 className="text-base font-medium text-white mb-3">执行日志</h4>
|
||
{/* <div className="space-y-2 text-sm">
|
||
<div className="bg-gray-900/50 border border-gray-800 rounded p-3 min-h-40 max-h-64 overflow-y-auto">
|
||
<pre className="text-xs text-gray-300 font-mono whitespace-pre-wrap break-words">
|
||
{getTaskLogContent(task)}
|
||
</pre>
|
||
</div>
|
||
<div className="text-xs text-gray-500">仅展示与该任务相关的最新字段(状态、消息、错误、参数与结果)。</div>
|
||
</div> */}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default NetworkTimeline;
|