mixvideo-v2/apps/desktop/src/components/AiAnalysisLogViewer.tsx

402 lines
14 KiB
TypeScript
Raw 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.

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