video-flow-b/components/task-monitor/task-detail-table.tsx
qikongjian 9258f50f8e feat: 添加任务监控功能
- 新增任务统计数据接口和消息队列状态接口
- 添加任务监控页面 (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 接口,支持项目任务统计和消息队列管理
2025-08-26 22:03:57 +08:00

395 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}