forked from 77media/video-flow
- 新增任务统计数据接口和消息队列状态接口 - 添加任务监控页面 (app/task-monitor/page.tsx) - 实现任务监控相关组件: - 项目选择器 (project-selector.tsx) - 任务统计卡片 (task-statistics-cards.tsx) - 任务状态图表 (task-status-chart.tsx) - 任务时间线图表 (task-timeline-chart.tsx) - 任务详情表格 (task-detail-table.tsx) - 消息队列面板 (message-queue-panel.tsx) - 扩展 video_flow.ts API 接口,支持项目任务统计和消息队列管理
395 lines
14 KiB
TypeScript
395 lines
14 KiB
TypeScript
'use client';
|
||
|
||
import React, { useState, useEffect } from 'react';
|
||
import { getProjectTaskList, retryTask, TaskItem } from '@/api/video_flow';
|
||
import { cn } from '@/public/lib/utils';
|
||
|
||
interface TaskDetailTableProps {
|
||
projectIds: string[];
|
||
timeRange: number;
|
||
}
|
||
|
||
export default function TaskDetailTable({ projectIds, timeRange }: TaskDetailTableProps) {
|
||
const [tasks, setTasks] = useState<TaskItem[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [sortBy, setSortBy] = useState<'created_at' | 'updated_at' | 'task_name' | 'task_status'>('updated_at');
|
||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [retryingTasks, setRetryingTasks] = useState<Set<string>>(new Set());
|
||
|
||
const itemsPerPage = 20;
|
||
|
||
// 获取任务列表
|
||
const fetchTasks = async () => {
|
||
if (projectIds.length === 0) {
|
||
setTasks([]);
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const allTasks: TaskItem[] = [];
|
||
|
||
// 并行获取所有项目的任务
|
||
const promises = projectIds.map(async (projectId) => {
|
||
try {
|
||
const response = await getProjectTaskList({ project_id: projectId });
|
||
if (response.code === 0 && response.data) {
|
||
return response.data.map(task => ({ ...task, project_id: projectId }));
|
||
}
|
||
return [];
|
||
} catch (err) {
|
||
console.error(`获取项目 ${projectId} 任务失败:`, err);
|
||
return [];
|
||
}
|
||
});
|
||
|
||
const results = await Promise.all(promises);
|
||
results.forEach(projectTasks => {
|
||
allTasks.push(...projectTasks);
|
||
});
|
||
|
||
setTasks(allTasks);
|
||
} catch (err: any) {
|
||
console.error('获取任务列表失败:', err);
|
||
setError(err.message || '获取任务列表失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 重试任务
|
||
const handleRetryTask = async (taskId: string) => {
|
||
if (retryingTasks.has(taskId)) return;
|
||
|
||
setRetryingTasks(prev => new Set(prev).add(taskId));
|
||
|
||
try {
|
||
const response = await retryTask({ task_id: taskId });
|
||
|
||
if (response.code === 0 && response.data?.success) {
|
||
// 乐观更新任务状态
|
||
setTasks(prevTasks =>
|
||
prevTasks.map(task =>
|
||
task.task_id === taskId
|
||
? { ...task, task_status: 'IN_PROGRESS' as const }
|
||
: task
|
||
)
|
||
);
|
||
|
||
// 延迟刷新获取最新状态
|
||
setTimeout(() => {
|
||
fetchTasks();
|
||
}, 2000);
|
||
} else {
|
||
throw new Error(response.message || '重试失败');
|
||
}
|
||
} catch (err: any) {
|
||
console.error('重试任务失败:', err);
|
||
alert(`重试任务失败: ${err.message}`);
|
||
} finally {
|
||
setRetryingTasks(prev => {
|
||
const newSet = new Set(prev);
|
||
newSet.delete(taskId);
|
||
return newSet;
|
||
});
|
||
}
|
||
};
|
||
|
||
// 过滤和排序任务
|
||
const filteredAndSortedTasks = React.useMemo(() => {
|
||
let filtered = tasks;
|
||
|
||
// 状态过滤
|
||
if (filterStatus !== 'all') {
|
||
filtered = filtered.filter(task => task.task_status === filterStatus);
|
||
}
|
||
|
||
// 搜索过滤
|
||
if (searchTerm) {
|
||
filtered = filtered.filter(task =>
|
||
task.task_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
task.task_id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
task.task_message.toLowerCase().includes(searchTerm.toLowerCase())
|
||
);
|
||
}
|
||
|
||
// 排序
|
||
filtered.sort((a, b) => {
|
||
let aValue: any = a[sortBy];
|
||
let bValue: any = b[sortBy];
|
||
|
||
if (sortBy === 'created_at' || sortBy === 'updated_at') {
|
||
aValue = new Date(aValue).getTime();
|
||
bValue = new Date(bValue).getTime();
|
||
}
|
||
|
||
if (sortOrder === 'asc') {
|
||
return aValue > bValue ? 1 : -1;
|
||
} else {
|
||
return aValue < bValue ? 1 : -1;
|
||
}
|
||
});
|
||
|
||
return filtered;
|
||
}, [tasks, filterStatus, searchTerm, sortBy, sortOrder]);
|
||
|
||
// 分页
|
||
const paginatedTasks = React.useMemo(() => {
|
||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||
return filteredAndSortedTasks.slice(startIndex, startIndex + itemsPerPage);
|
||
}, [filteredAndSortedTasks, currentPage]);
|
||
|
||
const totalPages = Math.ceil(filteredAndSortedTasks.length / itemsPerPage);
|
||
|
||
useEffect(() => {
|
||
fetchTasks();
|
||
}, [projectIds, timeRange]);
|
||
|
||
useEffect(() => {
|
||
setCurrentPage(1);
|
||
}, [filterStatus, searchTerm]);
|
||
|
||
const getStatusColor = (status: string) => {
|
||
switch (status) {
|
||
case 'COMPLETED':
|
||
return 'text-emerald-400 bg-emerald-500/10';
|
||
case 'IN_PROGRESS':
|
||
return 'text-blue-400 bg-blue-500/10';
|
||
case 'PENDING':
|
||
return 'text-yellow-400 bg-yellow-500/10';
|
||
case 'FAILED':
|
||
return 'text-red-400 bg-red-500/10';
|
||
default:
|
||
return 'text-gray-400 bg-gray-500/10';
|
||
}
|
||
};
|
||
|
||
const getStatusText = (status: string) => {
|
||
switch (status) {
|
||
case 'COMPLETED':
|
||
return '已完成';
|
||
case 'IN_PROGRESS':
|
||
return '进行中';
|
||
case 'PENDING':
|
||
return '等待中';
|
||
case 'FAILED':
|
||
return '失败';
|
||
default:
|
||
return status;
|
||
}
|
||
};
|
||
|
||
const formatDuration = (startTime: string, endTime?: string) => {
|
||
const start = new Date(startTime);
|
||
const end = endTime ? new Date(endTime) : new Date();
|
||
const duration = end.getTime() - start.getTime();
|
||
|
||
const minutes = Math.floor(duration / 60000);
|
||
const seconds = Math.floor((duration % 60000) / 1000);
|
||
|
||
if (minutes > 0) {
|
||
return `${minutes}分${seconds}秒`;
|
||
}
|
||
return `${seconds}秒`;
|
||
};
|
||
|
||
return (
|
||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg backdrop-blur-sm">
|
||
{/* 表格头部控制 */}
|
||
<div className="p-6 border-b border-gray-700">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-lg font-semibold text-white">任务详情列表</h3>
|
||
<button
|
||
onClick={fetchTasks}
|
||
disabled={loading}
|
||
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white rounded-lg transition-colors text-sm"
|
||
>
|
||
{loading ? '刷新中...' : '刷新'}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-4">
|
||
{/* 搜索框 */}
|
||
<div className="flex-1 min-w-[200px]">
|
||
<input
|
||
type="text"
|
||
placeholder="搜索任务名称、ID或消息..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 text-sm focus:outline-none focus:border-blue-500"
|
||
/>
|
||
</div>
|
||
|
||
{/* 状态过滤 */}
|
||
<select
|
||
value={filterStatus}
|
||
onChange={(e) => setFilterStatus(e.target.value)}
|
||
className="px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-blue-500"
|
||
>
|
||
<option value="all">全部状态</option>
|
||
<option value="COMPLETED">已完成</option>
|
||
<option value="IN_PROGRESS">进行中</option>
|
||
<option value="PENDING">等待中</option>
|
||
<option value="FAILED">失败</option>
|
||
</select>
|
||
|
||
{/* 排序 */}
|
||
<select
|
||
value={`${sortBy}-${sortOrder}`}
|
||
onChange={(e) => {
|
||
const [field, order] = e.target.value.split('-');
|
||
setSortBy(field as any);
|
||
setSortOrder(order as any);
|
||
}}
|
||
className="px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white text-sm focus:outline-none focus:border-blue-500"
|
||
>
|
||
<option value="updated_at-desc">最新更新</option>
|
||
<option value="created_at-desc">最新创建</option>
|
||
<option value="task_name-asc">任务名称</option>
|
||
<option value="task_status-asc">任务状态</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* 统计信息 */}
|
||
<div className="mt-4 text-sm text-gray-400">
|
||
显示 {paginatedTasks.length} / {filteredAndSortedTasks.length} 个任务
|
||
{projectIds.length > 1 && ` (来自 ${projectIds.length} 个项目)`}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 错误提示 */}
|
||
{error && (
|
||
<div className="mx-6 mt-4 p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||
<div className="flex items-center gap-2 text-red-400">
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
<span>{error}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 表格内容 */}
|
||
<div className="overflow-x-auto">
|
||
{loading ? (
|
||
<div className="flex items-center justify-center h-48">
|
||
<div className="text-center text-gray-400">
|
||
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2"></div>
|
||
<div>加载任务数据...</div>
|
||
</div>
|
||
</div>
|
||
) : paginatedTasks.length === 0 ? (
|
||
<div className="flex items-center justify-center h-48 text-gray-400">
|
||
<div className="text-center">
|
||
<div className="text-4xl mb-2">📋</div>
|
||
<div>{filteredAndSortedTasks.length === 0 ? '暂无任务数据' : '没有匹配的任务'}</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<table className="w-full">
|
||
<thead className="bg-gray-700/50">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">任务信息</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">状态</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">进度</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">耗时</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-700">
|
||
{paginatedTasks.map((task, index) => (
|
||
<tr key={task.task_id} className="hover:bg-gray-700/30 transition-colors">
|
||
<td className="px-6 py-4">
|
||
<div className="space-y-1">
|
||
<div className="text-sm font-medium text-white">{task.task_name}</div>
|
||
<div className="text-xs text-gray-400 font-mono">{task.task_id}</div>
|
||
{task.task_message && (
|
||
<div className="text-xs text-gray-500 max-w-xs truncate" title={task.task_message}>
|
||
{task.task_message}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<span className={cn(
|
||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||
getStatusColor(task.task_status)
|
||
)}>
|
||
{getStatusText(task.task_status)}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
{task.task_result?.progress_percentage !== undefined ? (
|
||
<div className="space-y-1">
|
||
<div className="text-sm text-white">{task.task_result.progress_percentage.toFixed(1)}%</div>
|
||
<div className="w-20 h-2 bg-gray-700 rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full bg-blue-500 transition-all duration-300"
|
||
style={{ width: `${task.task_result.progress_percentage}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<span className="text-gray-400 text-sm">-</span>
|
||
)}
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<div className="text-sm text-gray-300">
|
||
{formatDuration(task.created_at, task.updated_at)}
|
||
</div>
|
||
<div className="text-xs text-gray-500">
|
||
{new Date(task.updated_at).toLocaleString()}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
{task.task_status === 'FAILED' && (
|
||
<button
|
||
onClick={() => handleRetryTask(task.task_id)}
|
||
disabled={retryingTasks.has(task.task_id)}
|
||
className="px-3 py-1 bg-red-500 hover:bg-red-600 disabled:opacity-50 text-white rounded text-xs transition-colors"
|
||
>
|
||
{retryingTasks.has(task.task_id) ? '重试中...' : '重试'}
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
|
||
{/* 分页控制 */}
|
||
{totalPages > 1 && (
|
||
<div className="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
|
||
<div className="text-sm text-gray-400">
|
||
第 {currentPage} 页,共 {totalPages} 页
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||
disabled={currentPage === 1}
|
||
className="px-3 py-1 bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white rounded text-sm transition-colors"
|
||
>
|
||
上一页
|
||
</button>
|
||
<button
|
||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||
disabled={currentPage === totalPages}
|
||
className="px-3 py-1 bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white rounded text-sm transition-colors"
|
||
>
|
||
下一页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|