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 接口,支持项目任务统计和消息队列管理
This commit is contained in:
qikongjian 2025-08-26 22:03:57 +08:00
parent 6d703aef5e
commit 9258f50f8e
8 changed files with 1981 additions and 0 deletions

View File

@ -823,6 +823,116 @@ export const retryTask = async (request: {
return post("/task/retry_task", request);
};
/**
*
*/
export interface TaskStatistics {
/** 项目ID */
project_id: string;
/** 项目名称 */
project_name?: string;
/** 总任务数 */
total_tasks: number;
/** 各状态任务统计 */
status_stats: {
/** 已完成任务数 */
completed: number;
/** 进行中任务数 */
in_progress: number;
/** 等待中任务数 */
pending: number;
/** 失败任务数 */
failed: number;
/** 阻塞任务数 */
blocked: number;
};
/** 成功率 */
success_rate: number;
/** 平均执行时间(秒) */
avg_execution_time: number;
/** 最长执行时间(秒) */
max_execution_time: number;
/** 最短执行时间(秒) */
min_execution_time: number;
/** 最近更新时间 */
last_updated: string;
}
/**
*
*/
export interface MessageQueueStatus {
/** 队列名称 */
queue_name: string;
/** 队列状态 */
status: 'healthy' | 'warning' | 'error';
/** 队列中消息数量 */
message_count: number;
/** 消费者数量 */
consumer_count: number;
/** 最后心跳时间 */
last_heartbeat: string;
/** 错误信息 */
error_message?: string;
}
/**
*
* @param request -
* @returns Promise<ApiResponse<任务统计数据>>
*/
export const getProjectTaskStatistics = async (request: {
/** 项目ID */
project_id: string;
/** 时间范围小时默认24小时 */
time_range?: number;
}): Promise<ApiResponse<TaskStatistics>> => {
return post("/task/get_project_statistics", request);
};
/**
*
* @param request -
* @returns Promise<ApiResponse<任务统计数据列表>>
*/
export const getMultiProjectTaskStatistics = async (request: {
/** 项目ID列表 */
project_ids: string[];
/** 时间范围小时默认24小时 */
time_range?: number;
}): Promise<ApiResponse<TaskStatistics[]>> => {
return post("/task/get_multi_project_statistics", request);
};
/**
*
* @returns Promise<ApiResponse<消息队列状态列表>>
*/
export const getMessageQueueStatus = async (): Promise<ApiResponse<MessageQueueStatus[]>> => {
return post("/system/get_queue_status", {});
};
/**
*
* @param request -
* @returns Promise<ApiResponse<重启结果>>
*/
export const restartMessageQueue = async (request: {
/** 队列名称 */
queue_name: string;
}): Promise<ApiResponse<{
/** 队列名称 */
queue_name: string;
/** 重启状态 */
status: string;
/** 状态描述 */
message: string;
/** 是否成功重启 */
success: boolean;
}>> => {
return post("/system/restart_queue", request);
};
export const resumePlanFlow = async (request: {
/** 项目ID */
project_id: string;

289
app/task-monitor/page.tsx Normal file
View File

@ -0,0 +1,289 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'next/navigation';
import {
getProjectTaskStatistics,
getMultiProjectTaskStatistics,
getMessageQueueStatus,
restartMessageQueue,
TaskStatistics,
MessageQueueStatus
} from '@/api/video_flow';
import { cn } from '@/public/lib/utils';
import TaskStatisticsCards from '@/components/task-monitor/task-statistics-cards';
import TaskStatusChart from '@/components/task-monitor/task-status-chart';
import TaskTimelineChart from '@/components/task-monitor/task-timeline-chart';
import MessageQueuePanel from '@/components/task-monitor/message-queue-panel';
import TaskDetailTable from '@/components/task-monitor/task-detail-table';
import ProjectSelector from '@/components/task-monitor/project-selector';
export default function TaskMonitorPage() {
const searchParams = useSearchParams();
const initialProjectId = searchParams.get('project_id') || 'bc43bc81-c781-4caa-8256-9710fd5bee80';
// 状态管理
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([initialProjectId]);
const [timeRange, setTimeRange] = useState<number>(24); // 24小时
const [taskStatistics, setTaskStatistics] = useState<TaskStatistics[]>([]);
const [queueStatus, setQueueStatus] = useState<MessageQueueStatus[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
const [autoRefresh, setAutoRefresh] = useState(true);
// 刷新控制
const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null);
// 获取任务统计数据
const fetchTaskStatistics = async () => {
try {
setError(null);
if (selectedProjectIds.length === 1) {
const response = await getProjectTaskStatistics({
project_id: selectedProjectIds[0],
time_range: timeRange
});
if (response.code === 0 && response.data) {
setTaskStatistics([response.data]);
} else {
throw new Error(response.message || '获取任务统计失败');
}
} else if (selectedProjectIds.length > 1) {
const response = await getMultiProjectTaskStatistics({
project_ids: selectedProjectIds,
time_range: timeRange
});
if (response.code === 0 && response.data) {
setTaskStatistics(response.data);
} else {
throw new Error(response.message || '获取多项目统计失败');
}
}
setLastUpdateTime(new Date());
} catch (err: any) {
console.error('获取任务统计失败:', err);
setError(err.message || '获取数据失败');
}
};
// 获取消息队列状态
const fetchQueueStatus = async () => {
try {
const response = await getMessageQueueStatus();
if (response.code === 0 && response.data) {
setQueueStatus(response.data);
} else {
console.warn('获取队列状态失败:', response.message);
}
} catch (err: any) {
console.error('获取队列状态失败:', err);
}
};
// 初始化数据加载
const initializeData = async () => {
setLoading(true);
try {
await Promise.all([
fetchTaskStatistics(),
fetchQueueStatus()
]);
} finally {
setLoading(false);
}
};
// 手动刷新
const handleRefresh = async () => {
await initializeData();
};
// 重启消息队列
const handleRestartQueue = async (queueName: string) => {
try {
const response = await restartMessageQueue({ queue_name: queueName });
if (response.code === 0 && response.data?.success) {
console.log('队列重启成功:', response.data);
// 延迟刷新队列状态
setTimeout(() => {
fetchQueueStatus();
}, 2000);
} else {
throw new Error(response.message || '重启失败');
}
} catch (err: any) {
console.error('重启队列失败:', err);
alert(`重启队列失败: ${err.message}`);
}
};
// 项目选择变化
const handleProjectChange = (projectIds: string[]) => {
setSelectedProjectIds(projectIds);
};
// 时间范围变化
const handleTimeRangeChange = (range: number) => {
setTimeRange(range);
};
// 自动刷新控制
useEffect(() => {
if (autoRefresh) {
refreshIntervalRef.current = setInterval(() => {
fetchTaskStatistics();
fetchQueueStatus();
}, 30000); // 30秒刷新一次
} else {
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current);
refreshIntervalRef.current = null;
}
}
return () => {
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current);
}
};
}, [autoRefresh, selectedProjectIds, timeRange]);
// 初始化和依赖变化时重新加载数据
useEffect(() => {
initializeData();
}, [selectedProjectIds, timeRange]);
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-lg text-gray-600">...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
{/* 顶部导航栏 */}
<div className="bg-gray-900 border-b border-gray-800 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-emerald-400 animate-pulse"></div>
<h1 className="text-xl font-bold text-white"></h1>
</div>
<div className="text-sm text-gray-400">
</div>
</div>
{/* 控制面板 */}
<div className="flex items-center gap-4">
{/* 自动刷新开关 */}
<label className="flex items-center gap-2 text-sm text-gray-400">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="rounded"
/>
</label>
{/* 手动刷新按钮 */}
<button
onClick={handleRefresh}
disabled={loading}
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white rounded-lg transition-colors flex items-center gap-2"
>
<svg className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
{/* 最后更新时间 */}
{lastUpdateTime && (
<div className="text-sm text-gray-500">
{lastUpdateTime.toLocaleTimeString()}
</div>
)}
</div>
</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="p-6 space-y-6">
{/* 项目选择和时间范围控制 */}
<div className="flex items-center gap-4">
<ProjectSelector
selectedProjectIds={selectedProjectIds}
onProjectChange={handleProjectChange}
/>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-400">:</label>
<select
value={timeRange}
onChange={(e) => handleTimeRangeChange(Number(e.target.value))}
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm"
>
<option value={1}>1</option>
<option value={6}>6</option>
<option value={24}>24</option>
<option value={72}>3</option>
<option value={168}>7</option>
</select>
</div>
</div>
{/* 核心指标卡片 */}
<TaskStatisticsCards statistics={taskStatistics} />
{/* 数据可视化和控制面板 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 图表区域 */}
<div className="lg:col-span-2 space-y-6">
<TaskStatusChart statistics={taskStatistics} />
<TaskTimelineChart projectIds={selectedProjectIds} timeRange={timeRange} />
</div>
{/* 控制面板 */}
<div className="space-y-6">
<MessageQueuePanel
queueStatus={queueStatus}
onRestartQueue={handleRestartQueue}
/>
</div>
</div>
{/* 任务详情表格 */}
<TaskDetailTable
projectIds={selectedProjectIds}
timeRange={timeRange}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,267 @@
'use client';
import React, { useState } from 'react';
import { MessageQueueStatus } from '@/api/video_flow';
import { cn } from '@/public/lib/utils';
interface MessageQueuePanelProps {
queueStatus: MessageQueueStatus[];
onRestartQueue: (queueName: string) => Promise<void>;
}
export default function MessageQueuePanel({ queueStatus, onRestartQueue }: MessageQueuePanelProps) {
const [restartingQueues, setRestartingQueues] = useState<Set<string>>(new Set());
const handleRestart = async (queueName: string) => {
if (restartingQueues.has(queueName)) return;
setRestartingQueues(prev => new Set(prev).add(queueName));
try {
await onRestartQueue(queueName);
} finally {
setRestartingQueues(prev => {
const newSet = new Set(prev);
newSet.delete(queueName);
return newSet;
});
}
};
const getStatusColor = (status: MessageQueueStatus['status']) => {
switch (status) {
case 'healthy':
return 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20';
case 'warning':
return 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20';
case 'error':
return 'text-red-400 bg-red-500/10 border-red-500/20';
default:
return 'text-gray-400 bg-gray-500/10 border-gray-500/20';
}
};
const getStatusIcon = (status: MessageQueueStatus['status']) => {
switch (status) {
case 'healthy':
return '✅';
case 'warning':
return '⚠️';
case 'error':
return '❌';
default:
return '❓';
}
};
const getStatusText = (status: MessageQueueStatus['status']) => {
switch (status) {
case 'healthy':
return '健康';
case 'warning':
return '警告';
case 'error':
return '错误';
default:
return '未知';
}
};
// 计算整体健康度
const overallHealth = React.useMemo(() => {
if (queueStatus.length === 0) return { status: 'unknown', percentage: 0 };
const healthyCount = queueStatus.filter(q => q.status === 'healthy').length;
const warningCount = queueStatus.filter(q => q.status === 'warning').length;
const errorCount = queueStatus.filter(q => q.status === 'error').length;
const percentage = (healthyCount / queueStatus.length) * 100;
if (errorCount > 0) return { status: 'error', percentage };
if (warningCount > 0) return { status: 'warning', percentage };
return { status: 'healthy', percentage };
}, [queueStatus]);
return (
<div className="space-y-6">
{/* 消息队列总览 */}
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6 backdrop-blur-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white"></h3>
<div className="flex items-center gap-2">
<div className={cn(
'w-2 h-2 rounded-full',
overallHealth.status === 'healthy' ? 'bg-emerald-400' :
overallHealth.status === 'warning' ? 'bg-yellow-400' :
overallHealth.status === 'error' ? 'bg-red-400' : 'bg-gray-400'
)}></div>
<span className="text-sm text-gray-400">
{queueStatus.length}
</span>
</div>
</div>
{/* 整体健康度指示器 */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-400"></span>
<span className="text-sm font-medium text-white">
{overallHealth.percentage.toFixed(0)}%
</span>
</div>
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className={cn(
'h-full transition-all duration-500',
overallHealth.status === 'healthy' ? 'bg-emerald-400' :
overallHealth.status === 'warning' ? 'bg-yellow-400' :
'bg-red-400'
)}
style={{ width: `${overallHealth.percentage}%` }}
/>
</div>
</div>
{/* 队列列表 */}
{queueStatus.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<div className="text-4xl mb-2">🔄</div>
<div></div>
</div>
) : (
<div className="space-y-3">
{queueStatus.map((queue, index) => (
<div
key={index}
className={cn(
'p-4 rounded-lg border transition-all duration-200',
getStatusColor(queue.status)
)}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<span className="text-lg">{getStatusIcon(queue.status)}</span>
<div>
<div className="font-medium">{queue.queue_name}</div>
<div className="text-xs opacity-80">
{getStatusText(queue.status)}
</div>
</div>
</div>
<button
onClick={() => handleRestart(queue.queue_name)}
disabled={restartingQueues.has(queue.queue_name) || queue.status === 'healthy'}
className={cn(
'px-3 py-1 rounded text-xs font-medium transition-colors',
queue.status === 'healthy'
? 'bg-gray-600 text-gray-400 cursor-not-allowed'
: 'bg-blue-500 hover:bg-blue-600 text-white disabled:opacity-50'
)}
>
{restartingQueues.has(queue.queue_name) ? (
<div className="flex items-center gap-1">
<div className="w-3 h-3 animate-spin rounded-full border border-white border-t-transparent"></div>
<span></span>
</div>
) : (
'重启'
)}
</button>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="opacity-80"></div>
<div className="font-medium">{queue.message_count.toLocaleString()}</div>
</div>
<div>
<div className="opacity-80"></div>
<div className="font-medium">{queue.consumer_count}</div>
</div>
</div>
<div className="mt-3 text-xs opacity-60">
: {new Date(queue.last_heartbeat).toLocaleString()}
</div>
{queue.error_message && (
<div className="mt-2 p-2 bg-red-500/10 border border-red-500/20 rounded text-xs text-red-300">
{queue.error_message}
</div>
)}
</div>
))}
</div>
)}
</div>
{/* 快速操作面板 */}
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6 backdrop-blur-sm">
<h4 className="text-md font-semibold text-white mb-4"></h4>
<div className="space-y-3">
<button
onClick={() => {
queueStatus
.filter(q => q.status === 'error')
.forEach(q => handleRestart(q.queue_name));
}}
disabled={queueStatus.filter(q => q.status === 'error').length === 0}
className="w-full px-4 py-2 bg-red-500 hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors text-sm"
>
</button>
<button
onClick={() => {
queueStatus
.filter(q => q.status !== 'healthy')
.forEach(q => handleRestart(q.queue_name));
}}
disabled={queueStatus.filter(q => q.status !== 'healthy').length === 0}
className="w-full px-4 py-2 bg-yellow-500 hover:bg-yellow-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors text-sm"
>
</button>
<div className="pt-3 border-t border-gray-700">
<div className="text-xs text-gray-400 space-y-1">
<div> </div>
<div> </div>
<div> </div>
</div>
</div>
</div>
</div>
{/* 队列统计 */}
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6 backdrop-blur-sm">
<h4 className="text-md font-semibold text-white mb-4"></h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="text-center p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-lg">
<div className="text-lg font-bold text-emerald-400">
{queueStatus.filter(q => q.status === 'healthy').length}
</div>
<div className="text-emerald-300"></div>
</div>
<div className="text-center p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
<div className="text-lg font-bold text-red-400">
{queueStatus.filter(q => q.status === 'error').length}
</div>
<div className="text-red-300"></div>
</div>
</div>
<div className="mt-4 text-center">
<div className="text-2xl font-bold text-white">
{queueStatus.reduce((sum, q) => sum + q.message_count, 0).toLocaleString()}
</div>
<div className="text-sm text-gray-400"></div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,219 @@
'use client';
import React, { useState, useEffect } from 'react';
import { cn } from '@/public/lib/utils';
interface Project {
id: string;
name: string;
status: 'active' | 'inactive';
last_activity: string;
}
interface ProjectSelectorProps {
selectedProjectIds: string[];
onProjectChange: (projectIds: string[]) => void;
}
export default function ProjectSelector({ selectedProjectIds, onProjectChange }: ProjectSelectorProps) {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(false);
// 模拟项目数据 - 在实际应用中应该从API获取
const mockProjects: Project[] = [
{
id: 'bc43bc81-c781-4caa-8256-9710fd5bee80',
name: '视频制作项目 A',
status: 'active',
last_activity: '2024-01-15T10:30:00Z'
},
{
id: 'd203016d-6f7e-4d1c-b66b-1b7d33632800',
name: '视频制作项目 B',
status: 'active',
last_activity: '2024-01-15T09:15:00Z'
},
{
id: '029bbc09-6d83-440b-97fe-e2aa37b8042d',
name: '视频制作项目 C',
status: 'active',
last_activity: '2024-01-14T16:45:00Z'
},
{
id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
name: '视频制作项目 D',
status: 'inactive',
last_activity: '2024-01-10T14:20:00Z'
},
];
useEffect(() => {
// 模拟加载项目列表
setLoading(true);
setTimeout(() => {
setProjects(mockProjects);
setLoading(false);
}, 500);
}, []);
const filteredProjects = projects.filter(project =>
project.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.id.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleProjectToggle = (projectId: string) => {
if (selectedProjectIds.includes(projectId)) {
// 移除项目
onProjectChange(selectedProjectIds.filter(id => id !== projectId));
} else {
// 添加项目
onProjectChange([...selectedProjectIds, projectId]);
}
};
const handleSelectAll = () => {
const activeProjects = filteredProjects.filter(p => p.status === 'active');
onProjectChange(activeProjects.map(p => p.id));
};
const handleClearAll = () => {
onProjectChange([]);
};
const getSelectedProjectNames = () => {
return selectedProjectIds
.map(id => projects.find(p => p.id === id)?.name || id.slice(0, 8))
.join(', ');
};
return (
<div className="relative">
{/* 选择器按钮 */}
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white hover:bg-gray-700 transition-colors min-w-[200px]"
>
<div className="flex-1 text-left truncate">
{selectedProjectIds.length === 0 ? (
<span className="text-gray-400">...</span>
) : selectedProjectIds.length === 1 ? (
<span>{getSelectedProjectNames()}</span>
) : (
<span>{selectedProjectIds.length} </span>
)}
</div>
<svg
className={cn('w-4 h-4 transition-transform', isOpen && 'rotate-180')}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* 下拉面板 */}
{isOpen && (
<div className="absolute top-full left-0 mt-2 w-80 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-50">
{/* 搜索框 */}
<div className="p-3 border-b border-gray-700">
<input
type="text"
placeholder="搜索项目..."
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>
{/* 批量操作 */}
<div className="p-3 border-b border-gray-700 flex gap-2">
<button
onClick={handleSelectAll}
className="px-3 py-1 bg-blue-500 hover:bg-blue-600 text-white rounded text-sm transition-colors"
>
</button>
<button
onClick={handleClearAll}
className="px-3 py-1 bg-gray-600 hover:bg-gray-500 text-white rounded text-sm transition-colors"
>
</button>
<div className="flex-1 text-right text-sm text-gray-400 flex items-center justify-end">
{selectedProjectIds.length}
</div>
</div>
{/* 项目列表 */}
<div className="max-h-64 overflow-y-auto">
{loading ? (
<div className="p-4 text-center text-gray-400">
<div className="animate-spin w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2"></div>
...
</div>
) : filteredProjects.length === 0 ? (
<div className="p-4 text-center text-gray-400">
{searchTerm ? '未找到匹配的项目' : '暂无项目'}
</div>
) : (
<div className="p-2">
{filteredProjects.map((project) => (
<div
key={project.id}
className={cn(
'flex items-center gap-3 p-2 rounded hover:bg-gray-700 cursor-pointer transition-colors',
selectedProjectIds.includes(project.id) && 'bg-blue-500/20'
)}
onClick={() => handleProjectToggle(project.id)}
>
<input
type="checkbox"
checked={selectedProjectIds.includes(project.id)}
onChange={() => {}} // 由父级点击处理
className="rounded"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-white font-medium truncate">{project.name}</span>
<div className={cn(
'w-2 h-2 rounded-full',
project.status === 'active' ? 'bg-emerald-400' : 'bg-gray-500'
)}></div>
</div>
<div className="text-xs text-gray-400 truncate">
ID: {project.id}
</div>
<div className="text-xs text-gray-500">
: {new Date(project.last_activity).toLocaleDateString()}
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* 底部信息 */}
<div className="p-3 border-t border-gray-700 text-xs text-gray-400">
<div className="flex items-center justify-between">
<span> {projects.length} </span>
<span></span>
</div>
</div>
</div>
)}
{/* 点击外部关闭 */}
{isOpen && (
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
)}
</div>
);
}

View File

@ -0,0 +1,394 @@
'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>
);
}

View File

@ -0,0 +1,212 @@
'use client';
import React from 'react';
import { TaskStatistics } from '@/api/video_flow';
import { cn } from '@/public/lib/utils';
interface TaskStatisticsCardsProps {
statistics: TaskStatistics[];
}
export default function TaskStatisticsCards({ statistics }: TaskStatisticsCardsProps) {
// 聚合多个项目的统计数据
const aggregatedStats = React.useMemo(() => {
if (statistics.length === 0) {
return {
total_tasks: 0,
status_stats: {
completed: 0,
in_progress: 0,
pending: 0,
failed: 0,
blocked: 0,
},
success_rate: 0,
avg_execution_time: 0,
project_count: 0,
};
}
const totals = statistics.reduce(
(acc, stat) => ({
total_tasks: acc.total_tasks + stat.total_tasks,
completed: acc.completed + stat.status_stats.completed,
in_progress: acc.in_progress + stat.status_stats.in_progress,
pending: acc.pending + stat.status_stats.pending,
failed: acc.failed + stat.status_stats.failed,
blocked: acc.blocked + stat.status_stats.blocked,
total_execution_time: acc.total_execution_time + (stat.avg_execution_time * stat.total_tasks),
}),
{
total_tasks: 0,
completed: 0,
in_progress: 0,
pending: 0,
failed: 0,
blocked: 0,
total_execution_time: 0,
}
);
const success_rate = totals.total_tasks > 0
? (totals.completed / totals.total_tasks) * 100
: 0;
const avg_execution_time = totals.total_tasks > 0
? totals.total_execution_time / totals.total_tasks
: 0;
return {
total_tasks: totals.total_tasks,
status_stats: {
completed: totals.completed,
in_progress: totals.in_progress,
pending: totals.pending,
failed: totals.failed,
blocked: totals.blocked,
},
success_rate,
avg_execution_time,
project_count: statistics.length,
};
}, [statistics]);
const cards = [
{
title: '总任务数',
value: aggregatedStats.total_tasks.toLocaleString(),
icon: '📊',
color: 'blue',
subtitle: `${aggregatedStats.project_count} 个项目`,
},
{
title: '成功率',
value: `${aggregatedStats.success_rate.toFixed(1)}%`,
icon: '✅',
color: aggregatedStats.success_rate >= 90 ? 'green' : aggregatedStats.success_rate >= 70 ? 'yellow' : 'red',
subtitle: `${aggregatedStats.status_stats.completed} 个成功`,
},
{
title: '进行中',
value: aggregatedStats.status_stats.in_progress.toLocaleString(),
icon: '⚡',
color: 'blue',
subtitle: '正在执行',
},
{
title: '失败任务',
value: aggregatedStats.status_stats.failed.toLocaleString(),
icon: '❌',
color: 'red',
subtitle: '需要处理',
},
{
title: '平均耗时',
value: `${(aggregatedStats.avg_execution_time / 60).toFixed(1)}`,
icon: '⏱️',
color: 'purple',
subtitle: '执行时间',
},
{
title: '阻塞任务',
value: aggregatedStats.status_stats.blocked.toLocaleString(),
icon: '🚫',
color: 'orange',
subtitle: '等待处理',
},
];
const getColorClasses = (color: string) => {
switch (color) {
case 'green':
return 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400';
case 'red':
return 'bg-red-500/10 border-red-500/20 text-red-400';
case 'yellow':
return 'bg-yellow-500/10 border-yellow-500/20 text-yellow-400';
case 'blue':
return 'bg-blue-500/10 border-blue-500/20 text-blue-400';
case 'purple':
return 'bg-purple-500/10 border-purple-500/20 text-purple-400';
case 'orange':
return 'bg-orange-500/10 border-orange-500/20 text-orange-400';
default:
return 'bg-gray-500/10 border-gray-500/20 text-gray-400';
}
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
{cards.map((card, index) => (
<div
key={index}
className={cn(
'p-4 rounded-lg border backdrop-blur-sm transition-all duration-200 hover:scale-105',
getColorClasses(card.color)
)}
>
<div className="flex items-center justify-between mb-2">
<div className="text-2xl">{card.icon}</div>
<div className="text-xs opacity-60">{card.title}</div>
</div>
<div className="space-y-1">
<div className="text-2xl font-bold">{card.value}</div>
<div className="text-xs opacity-80">{card.subtitle}</div>
</div>
</div>
))}
</div>
);
}
// 单个项目的详细统计卡片
export function ProjectStatisticsCard({ statistics }: { statistics: TaskStatistics }) {
const statusItems = [
{ label: '已完成', value: statistics.status_stats.completed, color: 'text-emerald-400' },
{ label: '进行中', value: statistics.status_stats.in_progress, color: 'text-blue-400' },
{ label: '等待中', value: statistics.status_stats.pending, color: 'text-yellow-400' },
{ label: '失败', value: statistics.status_stats.failed, color: 'text-red-400' },
{ label: '阻塞', value: statistics.status_stats.blocked, color: 'text-orange-400' },
];
return (
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6 backdrop-blur-sm">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-white">
{statistics.project_name || statistics.project_id}
</h3>
<p className="text-sm text-gray-400">ID: {statistics.project_id}</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-white">{statistics.total_tasks}</div>
<div className="text-sm text-gray-400"></div>
</div>
</div>
<div className="grid grid-cols-5 gap-3 mb-4">
{statusItems.map((item, index) => (
<div key={index} className="text-center">
<div className={cn('text-lg font-semibold', item.color)}>{item.value}</div>
<div className="text-xs text-gray-500">{item.label}</div>
</div>
))}
</div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-4">
<span className="text-gray-400">
: <span className="text-emerald-400 font-semibold">{statistics.success_rate.toFixed(1)}%</span>
</span>
<span className="text-gray-400">
: <span className="text-blue-400 font-semibold">{(statistics.avg_execution_time / 60).toFixed(1)}</span>
</span>
</div>
<div className="text-xs text-gray-500">
{new Date(statistics.last_updated).toLocaleTimeString()}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,205 @@
'use client';
import React from 'react';
import { TaskStatistics } from '@/api/video_flow';
interface TaskStatusChartProps {
statistics: TaskStatistics[];
}
export default function TaskStatusChart({ statistics }: TaskStatusChartProps) {
// 聚合所有项目的状态数据
const aggregatedData = React.useMemo(() => {
if (statistics.length === 0) {
return [];
}
const totals = statistics.reduce(
(acc, stat) => ({
completed: acc.completed + stat.status_stats.completed,
in_progress: acc.in_progress + stat.status_stats.in_progress,
pending: acc.pending + stat.status_stats.pending,
failed: acc.failed + stat.status_stats.failed,
blocked: acc.blocked + stat.status_stats.blocked,
}),
{
completed: 0,
in_progress: 0,
pending: 0,
failed: 0,
blocked: 0,
}
);
const total = Object.values(totals).reduce((sum, val) => sum + val, 0);
return [
{ name: '已完成', value: totals.completed, color: '#10b981', percentage: total > 0 ? (totals.completed / total) * 100 : 0 },
{ name: '进行中', value: totals.in_progress, color: '#3b82f6', percentage: total > 0 ? (totals.in_progress / total) * 100 : 0 },
{ name: '等待中', value: totals.pending, color: '#f59e0b', percentage: total > 0 ? (totals.pending / total) * 100 : 0 },
{ name: '失败', value: totals.failed, color: '#ef4444', percentage: total > 0 ? (totals.failed / total) * 100 : 0 },
{ name: '阻塞', value: totals.blocked, color: '#f97316', percentage: total > 0 ? (totals.blocked / total) * 100 : 0 },
].filter(item => item.value > 0);
}, [statistics]);
// 简单的饼图实现
const PieChart = ({ data }: { data: typeof aggregatedData }) => {
const total = data.reduce((sum, item) => sum + item.value, 0);
let cumulativePercentage = 0;
return (
<div className="relative w-48 h-48 mx-auto">
<svg width="192" height="192" viewBox="0 0 192 192" className="transform -rotate-90">
<circle
cx="96"
cy="96"
r="80"
fill="none"
stroke="#374151"
strokeWidth="2"
/>
{data.map((item, index) => {
const percentage = (item.value / total) * 100;
const strokeDasharray = `${percentage * 5.03} 502`;
const strokeDashoffset = -cumulativePercentage * 5.03;
cumulativePercentage += percentage;
return (
<circle
key={index}
cx="96"
cy="96"
r="80"
fill="none"
stroke={item.color}
strokeWidth="16"
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
className="transition-all duration-300"
/>
);
})}
</svg>
{/* 中心文字 */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<div className="text-2xl font-bold text-white">{total.toLocaleString()}</div>
<div className="text-sm text-gray-400"></div>
</div>
</div>
);
};
// 柱状图实现
const BarChart = ({ data }: { data: typeof aggregatedData }) => {
const maxValue = Math.max(...data.map(item => item.value));
return (
<div className="space-y-3">
{data.map((item, index) => (
<div key={index} className="flex items-center gap-3">
<div className="w-16 text-sm text-gray-400 text-right">{item.name}</div>
<div className="flex-1 relative">
<div className="h-6 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
backgroundColor: item.color,
width: `${maxValue > 0 ? (item.value / maxValue) * 100 : 0}%`,
}}
/>
</div>
<div className="absolute inset-0 flex items-center justify-between px-2">
<span className="text-xs text-white font-medium">{item.value}</span>
<span className="text-xs text-gray-300">{item.percentage.toFixed(1)}%</span>
</div>
</div>
</div>
))}
</div>
);
};
if (statistics.length === 0) {
return (
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6 backdrop-blur-sm">
<h3 className="text-lg font-semibold text-white mb-4"></h3>
<div className="flex items-center justify-center h-48 text-gray-400">
<div className="text-center">
<div className="text-4xl mb-2">📊</div>
<div></div>
</div>
</div>
</div>
);
}
return (
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6 backdrop-blur-sm">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-white"></h3>
<div className="text-sm text-gray-400">
{statistics.length > 1 ? `${statistics.length} 个项目` : '单项目视图'}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 饼图 */}
<div className="text-center">
<h4 className="text-sm font-medium text-gray-300 mb-4"></h4>
<PieChart data={aggregatedData} />
</div>
{/* 柱状图 */}
<div>
<h4 className="text-sm font-medium text-gray-300 mb-4"></h4>
<BarChart data={aggregatedData} />
</div>
</div>
{/* 图例 */}
<div className="mt-6 pt-4 border-t border-gray-700">
<div className="flex flex-wrap gap-4 justify-center">
{aggregatedData.map((item, index) => (
<div key={index} className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: item.color }}
/>
<span className="text-sm text-gray-300">{item.name}</span>
<span className="text-sm text-gray-500">({item.value})</span>
</div>
))}
</div>
</div>
{/* 多项目详情 */}
{statistics.length > 1 && (
<div className="mt-6 pt-4 border-t border-gray-700">
<h4 className="text-sm font-medium text-gray-300 mb-3"></h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{statistics.map((stat, index) => (
<div key={index} className="bg-gray-700/30 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<div className="text-sm font-medium text-white truncate">
{stat.project_name || stat.project_id}
</div>
<div className="text-sm text-gray-400">{stat.total_tasks}</div>
</div>
<div className="flex gap-2 text-xs">
<span className="text-emerald-400">{stat.status_stats.completed}</span>
<span className="text-blue-400">{stat.status_stats.in_progress}</span>
<span className="text-yellow-400">{stat.status_stats.pending}</span>
<span className="text-red-400">{stat.status_stats.failed}</span>
{stat.status_stats.blocked > 0 && (
<span className="text-orange-400">🚫{stat.status_stats.blocked}</span>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,285 @@
'use client';
import React, { useState, useEffect } from 'react';
interface TaskTimelineData {
timestamp: string;
completed: number;
failed: number;
in_progress: number;
total: number;
}
interface TaskTimelineChartProps {
projectIds: string[];
timeRange: number; // 小时
}
export default function TaskTimelineChart({ projectIds, timeRange }: TaskTimelineChartProps) {
const [timelineData, setTimelineData] = useState<TaskTimelineData[]>([]);
const [loading, setLoading] = useState(false);
const [selectedMetric, setSelectedMetric] = useState<'total' | 'completed' | 'failed' | 'success_rate'>('total');
// 模拟生成时间线数据
const generateMockData = () => {
const data: TaskTimelineData[] = [];
const now = new Date();
const intervalHours = timeRange <= 24 ? 1 : timeRange <= 72 ? 3 : 6;
const points = Math.floor(timeRange / intervalHours);
for (let i = points; i >= 0; i--) {
const timestamp = new Date(now.getTime() - i * intervalHours * 60 * 60 * 1000);
// 模拟数据波动
const baseCompleted = 50 + Math.random() * 30;
const baseFailed = 5 + Math.random() * 10;
const baseInProgress = 10 + Math.random() * 15;
// 根据项目数量调整
const multiplier = projectIds.length;
data.push({
timestamp: timestamp.toISOString(),
completed: Math.floor(baseCompleted * multiplier),
failed: Math.floor(baseFailed * multiplier),
in_progress: Math.floor(baseInProgress * multiplier),
total: Math.floor((baseCompleted + baseFailed + baseInProgress) * multiplier),
});
}
return data;
};
useEffect(() => {
setLoading(true);
// 模拟API调用
setTimeout(() => {
setTimelineData(generateMockData());
setLoading(false);
}, 500);
}, [projectIds, timeRange]);
const getMetricValue = (data: TaskTimelineData, metric: string) => {
switch (metric) {
case 'total':
return data.total;
case 'completed':
return data.completed;
case 'failed':
return data.failed;
case 'success_rate':
return data.total > 0 ? (data.completed / data.total) * 100 : 0;
default:
return 0;
}
};
const getMetricColor = (metric: string) => {
switch (metric) {
case 'total':
return '#3b82f6';
case 'completed':
return '#10b981';
case 'failed':
return '#ef4444';
case 'success_rate':
return '#8b5cf6';
default:
return '#6b7280';
}
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
if (timeRange <= 24) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
} else {
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
};
const LineChart = () => {
if (timelineData.length === 0) return null;
const values = timelineData.map(d => getMetricValue(d, selectedMetric));
const maxValue = Math.max(...values);
const minValue = Math.min(...values);
const range = maxValue - minValue || 1;
const width = 600;
const height = 200;
const padding = 40;
const points = timelineData.map((data, index) => {
const x = padding + (index / (timelineData.length - 1)) * (width - 2 * padding);
const value = getMetricValue(data, selectedMetric);
const y = height - padding - ((value - minValue) / range) * (height - 2 * padding);
return { x, y, value, data };
});
const pathData = points.map((point, index) =>
`${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`
).join(' ');
return (
<div className="relative">
<svg width={width} height={height} className="overflow-visible">
{/* 网格线 */}
<defs>
<pattern id="grid" width="50" height="40" patternUnits="userSpaceOnUse">
<path d="M 50 0 L 0 0 0 40" fill="none" stroke="#374151" strokeWidth="0.5" opacity="0.3"/>
</pattern>
</defs>
<rect width={width} height={height} fill="url(#grid)" />
{/* Y轴标签 */}
{[0, 0.25, 0.5, 0.75, 1].map((ratio, index) => {
const y = height - padding - ratio * (height - 2 * padding);
const value = minValue + ratio * range;
return (
<g key={index}>
<line x1={padding} y1={y} x2={width - padding} y2={y} stroke="#374151" strokeWidth="0.5" opacity="0.5" />
<text x={padding - 10} y={y + 4} fill="#9ca3af" fontSize="12" textAnchor="end">
{selectedMetric === 'success_rate' ? `${value.toFixed(0)}%` : Math.round(value)}
</text>
</g>
);
})}
{/* 主线条 */}
<path
d={pathData}
fill="none"
stroke={getMetricColor(selectedMetric)}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* 填充区域 */}
<path
d={`${pathData} L ${points[points.length - 1].x} ${height - padding} L ${padding} ${height - padding} Z`}
fill={getMetricColor(selectedMetric)}
fillOpacity="0.1"
/>
{/* 数据点 */}
{points.map((point, index) => (
<circle
key={index}
cx={point.x}
cy={point.y}
r="4"
fill={getMetricColor(selectedMetric)}
stroke="white"
strokeWidth="2"
className="hover:r-6 transition-all cursor-pointer"
>
<title>
{formatTimestamp(point.data.timestamp)}: {
selectedMetric === 'success_rate'
? `${point.value.toFixed(1)}%`
: Math.round(point.value)
}
</title>
</circle>
))}
{/* X轴标签 */}
{points.filter((_, index) => index % Math.ceil(points.length / 6) === 0).map((point, index) => (
<text
key={index}
x={point.x}
y={height - 10}
fill="#9ca3af"
fontSize="12"
textAnchor="middle"
>
{formatTimestamp(point.data.timestamp)}
</text>
))}
</svg>
</div>
);
};
const metrics = [
{ key: 'total', label: '总任务数', color: '#3b82f6' },
{ key: 'completed', label: '完成数', color: '#10b981' },
{ key: 'failed', label: '失败数', color: '#ef4444' },
{ key: 'success_rate', label: '成功率', color: '#8b5cf6' },
];
return (
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-6 backdrop-blur-sm">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-white"></h3>
<div className="flex items-center gap-2">
{metrics.map((metric) => (
<button
key={metric.key}
onClick={() => setSelectedMetric(metric.key as any)}
className={`px-3 py-1 rounded text-sm transition-colors ${
selectedMetric === metric.key
? 'bg-blue-500 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
{metric.label}
</button>
))}
</div>
</div>
{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>
) : timelineData.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></div>
</div>
</div>
) : (
<div>
<LineChart />
{/* 统计信息 */}
<div className="mt-6 pt-4 border-t border-gray-700">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
{metrics.map((metric) => {
const values = timelineData.map(d => getMetricValue(d, metric.key));
const latest = values[values.length - 1] || 0;
const previous = values[values.length - 2] || 0;
const change = previous !== 0 ? ((latest - previous) / previous) * 100 : 0;
return (
<div key={metric.key} className="text-center">
<div className="flex items-center justify-center gap-1 mb-1">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: metric.color }}
/>
<span className="text-gray-300">{metric.label}</span>
</div>
<div className="text-lg font-bold text-white">
{metric.key === 'success_rate' ? `${latest.toFixed(1)}%` : Math.round(latest)}
</div>
<div className={`text-xs ${change >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{change >= 0 ? '↗' : '↘'} {Math.abs(change).toFixed(1)}%
</div>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
);
}