402 lines
14 KiB
TypeScript
402 lines
14 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import { invoke } from '@tauri-apps/api/core';
|
||
import {
|
||
Search,
|
||
Download,
|
||
RefreshCw,
|
||
AlertCircle,
|
||
CheckCircle,
|
||
Clock,
|
||
XCircle,
|
||
Eye,
|
||
RotateCcw
|
||
} from 'lucide-react';
|
||
import { LoadingSpinner } from './LoadingSpinner';
|
||
import { ErrorMessage } from './ErrorMessage';
|
||
import { CustomSelect } from './CustomSelect';
|
||
|
||
// 类型定义
|
||
interface AiAnalysisLogItem {
|
||
id: string;
|
||
log_type: string;
|
||
title: string;
|
||
status: string;
|
||
status_display: string;
|
||
details: string;
|
||
error_message?: string;
|
||
confidence?: number;
|
||
quality_score?: number;
|
||
category?: string;
|
||
video_file_path?: string;
|
||
retry_count?: number;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
interface AiAnalysisLogResponse {
|
||
logs: AiAnalysisLogItem[];
|
||
total_count: number;
|
||
current_page: number;
|
||
page_size: number;
|
||
total_pages: number;
|
||
}
|
||
|
||
interface AiAnalysisLogStats {
|
||
total_records: number;
|
||
successful_classifications: number;
|
||
failed_classifications: number;
|
||
needs_review: number;
|
||
total_tasks: number;
|
||
completed_tasks: number;
|
||
failed_tasks: number;
|
||
processing_tasks: number;
|
||
average_confidence: number;
|
||
average_quality_score: number;
|
||
recent_24h_activity: number;
|
||
}
|
||
|
||
interface AiAnalysisLogViewerProps {
|
||
projectId: string;
|
||
}
|
||
|
||
/**
|
||
* AI分析日志查看器组件
|
||
* 遵循 Tauri 开发规范的前端组件设计模式
|
||
*/
|
||
export const AiAnalysisLogViewer: React.FC<AiAnalysisLogViewerProps> = ({ projectId }) => {
|
||
const [logs, setLogs] = useState<AiAnalysisLogItem[]>([]);
|
||
const [stats, setStats] = useState<AiAnalysisLogStats | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// 查询参数
|
||
const [logType, setLogType] = useState<'records' | 'tasks'>('records');
|
||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||
const [searchKeyword, setSearchKeyword] = useState('');
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [pageSize] = useState(20);
|
||
const [totalPages, setTotalPages] = useState(0);
|
||
const [totalCount, setTotalCount] = useState(0);
|
||
|
||
// 过滤选项
|
||
const [filterOptions, setFilterOptions] = useState<any>(null);
|
||
|
||
// 加载过滤选项
|
||
useEffect(() => {
|
||
const loadFilterOptions = async () => {
|
||
try {
|
||
const options = await invoke('get_ai_analysis_log_filters');
|
||
setFilterOptions(options);
|
||
} catch (err) {
|
||
console.error('加载过滤选项失败:', err);
|
||
}
|
||
};
|
||
|
||
loadFilterOptions();
|
||
}, []);
|
||
|
||
// 加载日志数据
|
||
const loadLogs = async () => {
|
||
if (!projectId) return;
|
||
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const query = {
|
||
project_id: projectId,
|
||
log_type: logType,
|
||
status_filter: statusFilter || null,
|
||
search_keyword: searchKeyword || null,
|
||
page: currentPage,
|
||
page_size: pageSize,
|
||
};
|
||
|
||
const response: AiAnalysisLogResponse = await invoke('get_ai_analysis_logs', { query });
|
||
|
||
setLogs(response.logs);
|
||
setTotalCount(response.total_count);
|
||
setTotalPages(response.total_pages);
|
||
} catch (err) {
|
||
setError(err as string);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// 加载统计数据
|
||
const loadStats = async () => {
|
||
if (!projectId) return;
|
||
|
||
try {
|
||
const statsData: AiAnalysisLogStats = await invoke('get_ai_analysis_stats', {
|
||
projectId
|
||
});
|
||
setStats(statsData);
|
||
} catch (err) {
|
||
console.error('加载统计数据失败:', err);
|
||
}
|
||
};
|
||
|
||
// 初始加载
|
||
useEffect(() => {
|
||
loadLogs();
|
||
loadStats();
|
||
}, [projectId, logType, statusFilter, currentPage]);
|
||
|
||
// 搜索处理
|
||
const handleSearch = () => {
|
||
setCurrentPage(1);
|
||
loadLogs();
|
||
};
|
||
|
||
// 重置搜索
|
||
const handleResetSearch = () => {
|
||
setSearchKeyword('');
|
||
setStatusFilter('');
|
||
setCurrentPage(1);
|
||
loadLogs();
|
||
};
|
||
|
||
// 重试失败任务
|
||
const handleRetryTask = async (taskId: string) => {
|
||
try {
|
||
await invoke('retry_failed_classification_task', { taskId });
|
||
loadLogs(); // 重新加载数据
|
||
} catch (err) {
|
||
console.error('重试任务失败:', err);
|
||
}
|
||
};
|
||
|
||
// 获取状态图标
|
||
const getStatusIcon = (status: string, logType: string) => {
|
||
if (logType === 'record') {
|
||
switch (status) {
|
||
case '"Classified"':
|
||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||
case '"Failed"':
|
||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||
case '"NeedsReview"':
|
||
return <AlertCircle className="w-4 h-4 text-yellow-500" />;
|
||
default:
|
||
return <Clock className="w-4 h-4 text-gray-500" />;
|
||
}
|
||
} else {
|
||
switch (status) {
|
||
case '"Completed"':
|
||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||
case '"Failed"':
|
||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||
case '"Pending"':
|
||
case '"Uploading"':
|
||
case '"Analyzing"':
|
||
return <Clock className="w-4 h-4 text-blue-500" />;
|
||
default:
|
||
return <Clock className="w-4 h-4 text-gray-500" />;
|
||
}
|
||
}
|
||
};
|
||
|
||
// 获取状态颜色类
|
||
const getStatusColorClass = (status: string) => {
|
||
if (status.includes('Classified') || status.includes('Completed')) {
|
||
return 'bg-green-100 text-green-800';
|
||
} else if (status.includes('Failed')) {
|
||
return 'bg-red-100 text-red-800';
|
||
} else if (status.includes('NeedsReview')) {
|
||
return 'bg-yellow-100 text-yellow-800';
|
||
} else {
|
||
return 'bg-blue-100 text-blue-800';
|
||
}
|
||
};
|
||
|
||
if (error) {
|
||
return <ErrorMessage message={error} />;
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* 统计卡片 */}
|
||
{stats && (
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||
<div className="text-sm text-gray-600">总记录数</div>
|
||
<div className="text-2xl font-bold text-gray-900">{stats.total_records}</div>
|
||
</div>
|
||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||
<div className="text-sm text-gray-600">成功分类</div>
|
||
<div className="text-2xl font-bold text-green-600">{stats.successful_classifications}</div>
|
||
</div>
|
||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||
<div className="text-sm text-gray-600">失败任务</div>
|
||
<div className="text-2xl font-bold text-red-600">{stats.failed_tasks}</div>
|
||
</div>
|
||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||
<div className="text-sm text-gray-600">平均置信度</div>
|
||
<div className="text-2xl font-bold text-blue-600">
|
||
{(stats.average_confidence * 100).toFixed(1)}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 控制栏 */}
|
||
<div className="bg-white p-4 rounded-lg border border-gray-200">
|
||
<div className="flex flex-col md:flex-row gap-4">
|
||
{/* 日志类型切换 */}
|
||
<div className="flex space-x-2">
|
||
<button
|
||
onClick={() => setLogType('records')}
|
||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${logType === 'records'
|
||
? 'bg-blue-100 text-blue-700'
|
||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
分类记录
|
||
</button>
|
||
<button
|
||
onClick={() => setLogType('tasks')}
|
||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${logType === 'tasks'
|
||
? 'bg-blue-100 text-blue-700'
|
||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
分类任务
|
||
</button>
|
||
</div>
|
||
|
||
{/* 搜索和过滤 */}
|
||
<div className="flex flex-1 gap-2">
|
||
<div className="relative flex-1">
|
||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||
<input
|
||
type="text"
|
||
placeholder="搜索日志..."
|
||
value={searchKeyword}
|
||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
/>
|
||
</div>
|
||
|
||
{/* 状态过滤 */}
|
||
{filterOptions && (
|
||
<CustomSelect options={logType === 'records'
|
||
? filterOptions.record_statuses
|
||
: filterOptions.task_statuses} value={statusFilter} onChange={e => setStatusFilter(e)} />
|
||
)}
|
||
|
||
<button
|
||
onClick={handleSearch}
|
||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||
>
|
||
<Search className="w-4 h-4" />
|
||
</button>
|
||
|
||
<button
|
||
onClick={handleResetSearch}
|
||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||
>
|
||
<RefreshCw className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 日志列表 */}
|
||
<div className="bg-white rounded-lg border border-gray-200">
|
||
{loading ? (
|
||
<div className="p-8 text-center">
|
||
<LoadingSpinner />
|
||
<p className="mt-2 text-gray-600">加载日志中...</p>
|
||
</div>
|
||
) : logs.length === 0 ? (
|
||
<div className="p-8 text-center text-gray-500">
|
||
暂无日志数据
|
||
</div>
|
||
) : (
|
||
<div className="divide-y divide-gray-200">
|
||
{logs.map((log) => (
|
||
<div key={log.id} className="p-4 hover:bg-gray-50">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex items-start space-x-3 flex-1">
|
||
{getStatusIcon(log.status, log.log_type)}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center space-x-2">
|
||
<h4 className="text-sm font-medium text-gray-900 truncate">
|
||
{log.title}
|
||
</h4>
|
||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColorClass(log.status)}`}>
|
||
{log.status_display}
|
||
</span>
|
||
</div>
|
||
<p className="mt-1 text-sm text-gray-600">{log.details}</p>
|
||
{log.error_message && (
|
||
<p className="mt-1 text-sm text-red-600">{log.error_message}</p>
|
||
)}
|
||
<div className="mt-2 flex items-center space-x-4 text-xs text-gray-500">
|
||
<span>{new Date(log.created_at).toLocaleString('zh-CN')}</span>
|
||
{log.confidence && (
|
||
<span>置信度: {(log.confidence * 100).toFixed(1)}%</span>
|
||
)}
|
||
{log.quality_score && (
|
||
<span>质量: {(log.quality_score * 10).toFixed(1)}/10</span>
|
||
)}
|
||
{log.retry_count !== undefined && log.retry_count > 0 && (
|
||
<span>重试: {log.retry_count}次</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 操作按钮 */}
|
||
<div className="flex items-center space-x-2 ml-4">
|
||
{log.log_type === 'task' && log.status.includes('Failed') && (
|
||
<button
|
||
onClick={() => handleRetryTask(log.id)}
|
||
className="p-1 text-blue-600 hover:text-blue-800"
|
||
title="重试任务"
|
||
>
|
||
<RotateCcw className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 分页 */}
|
||
{totalPages > 1 && (
|
||
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
|
||
<div className="text-sm text-gray-700">
|
||
显示 {((currentPage - 1) * pageSize) + 1} 到 {Math.min(currentPage * pageSize, totalCount)} 条,
|
||
共 {totalCount} 条记录
|
||
</div>
|
||
<div className="flex space-x-2">
|
||
<button
|
||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||
disabled={currentPage === 1}
|
||
className="px-3 py-1 text-sm border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||
>
|
||
上一页
|
||
</button>
|
||
<span className="px-3 py-1 text-sm">
|
||
{currentPage} / {totalPages}
|
||
</span>
|
||
<button
|
||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||
disabled={currentPage === totalPages}
|
||
className="px-3 py-1 text-sm border border-gray-300 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||
>
|
||
下一页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|