Files
AIRouter/frontend/src/features/logs/components/RequestLogList.jsx
2025-11-11 13:18:43 +08:00

317 lines
11 KiB
JavaScript

import React, { useState, useEffect, useRef } from 'react';
import { getRequestLogs, getRequestLogStats } from '../api';
import RequestLogDetailModal from './RequestLogDetailModal';
// 自定义防抖 Hook
const useDebounce = (value, delay) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};
const RequestLogList = () => {
const [logs, setLogs] = useState([]);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [totalPages, setTotalPages] = useState(1);
const [selectedLog, setSelectedLog] = useState(null);
const [showDetailModal, setShowDetailModal] = useState(false);
// 筛选条件
const [filters, setFilters] = useState({
virtualModel: '',
backendModel: '',
startDate: '',
endDate: ''
});
// 使用防抖的筛选条件
const debouncedFilters = useDebounce(filters, 500);
const fetchLogs = async () => {
try {
setLoading(true);
const params = {
page,
page_size: pageSize,
...debouncedFilters
};
// 移除空值
Object.keys(params).forEach(key => {
if (!params[key]) delete params[key];
});
const data = await getRequestLogs(params);
setLogs(data.logs || []);
setTotalPages(Math.ceil((data.total || 0) / pageSize));
setError(null);
} catch (err) {
setError(err.message || '获取日志失败');
console.error('Error fetching logs:', err);
} finally {
setLoading(false);
}
};
const fetchStats = async () => {
try {
const data = await getRequestLogStats(debouncedFilters);
setStats(data);
} catch (err) {
console.error('Error fetching stats:', err);
}
};
useEffect(() => {
fetchLogs();
fetchStats();
}, [page, debouncedFilters]);
const handleFilterChange = (key, value) => {
setFilters(prev => ({ ...prev, [key]: value }));
setPage(1); // 重置到第一页
};
const handleViewDetail = (log) => {
setSelectedLog(log);
setShowDetailModal(true);
};
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
const formatCost = (cost) => {
if (cost === null || cost === undefined) return 'N/A';
return `¥${parseFloat(cost).toFixed(6)}`;
};
if (loading && logs.length === 0) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-gray-600">加载中...</div>
</div>
);
}
return (
<div className="space-y-4">
{/* 统计信息卡片 */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-lg shadow">
<div className="text-sm text-gray-500">总请求数</div>
<div className="text-2xl font-bold text-gray-900">{stats.total_requests || 0}</div>
</div>
<div className="bg-white p-4 rounded-lg shadow">
<div className="text-sm text-gray-500">总Token数</div>
<div className="text-2xl font-bold text-blue-600">{stats.total_tokens || 0}</div>
</div>
<div className="bg-white p-4 rounded-lg shadow">
<div className="text-sm text-gray-500">总成本</div>
<div className="text-2xl font-bold text-green-600">{formatCost(stats.total_cost)}</div>
</div>
<div className="bg-white p-4 rounded-lg shadow">
<div className="text-sm text-gray-500">平均响应时间</div>
<div className="text-2xl font-bold text-purple-600">
{stats.avg_response_time ? `${stats.avg_response_time.toFixed(2)}s` : 'N/A'}
</div>
</div>
</div>
)}
{/* 筛选器 */}
<div className="bg-white p-4 rounded-lg shadow">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
虚拟模型
</label>
<input
type="text"
value={filters.virtualModel}
onChange={(e) => handleFilterChange('virtualModel', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="筛选虚拟模型"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
后端模型
</label>
<input
type="text"
value={filters.backendModel}
onChange={(e) => handleFilterChange('backendModel', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="筛选后端模型"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
开始时间
</label>
<input
type="datetime-local"
value={filters.startDate}
onChange={(e) => handleFilterChange('startDate', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
结束时间
</label>
<input
type="datetime-local"
value={filters.endDate}
onChange={(e) => handleFilterChange('endDate', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
{/* 错误提示 */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
{/* 日志表格 */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
时间
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
虚拟模型
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
后端模型
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
运营商
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
请求Token
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
响应Token
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
成本
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{logs.length === 0 ? (
<tr>
<td colSpan="8" className="px-6 py-4 text-center text-gray-500">
暂无日志记录
</td>
</tr>
) : (
logs.map((log, index) => (
<tr key={log.ID || index} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatDate(log.RequestTimestamp)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{log.VirtualModelName || 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{log.BackendModelName || 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{log.ProviderName || 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{log.RequestTokens || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{log.ResponseTokens || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatCost(log.Cost)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<button
onClick={() => handleViewDetail(log)}
className="text-blue-600 hover:text-blue-800 font-medium transition-colors"
>
查看详情
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* 分页 */}
{totalPages > 1 && (
<div className="flex justify-center items-center space-x-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
上一页
</button>
<span className="text-sm text-gray-700">
{page} / {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
下一页
</button>
</div>
)}
{/* 详情查看模态框 */}
<RequestLogDetailModal
log={selectedLog}
isOpen={showDetailModal}
onClose={() => setShowDetailModal(false)}
/>
</div>
);
};
export default RequestLogList;