- 创建统一的下载错误处理模块 (lib/download-errors.ts) - 实现13种错误类型分类和中文错误消息 - 后端统一使用 createDownloadError 创建标准化错误响应 - 前端统一使用 parseDownloadError 解析错误并显示 - 修复正式系统数据库Schema,添加产品全案相关字段和表 - 执行数据库迁移:添加 task_no, task_status, task_fetched_at 字段 - 创建 material_assets, api_keys, api_call_logs 表
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
/**
|
||
* API 调用日志页面
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { useSession } from 'next-auth/react';
|
||
import { useRouter } from 'next/navigation';
|
||
|
||
interface ApiLog {
|
||
id: string;
|
||
apiKeyName: string;
|
||
endpoint: string;
|
||
method: string;
|
||
statusCode: number;
|
||
responseTime: number;
|
||
requestBody: any;
|
||
responseBody: any;
|
||
taskNo: string | null;
|
||
createdAt: string;
|
||
}
|
||
|
||
export default function ApiLogsPage() {
|
||
const { data: session, status } = useSession();
|
||
const router = useRouter();
|
||
const [logs, setLogs] = useState<ApiLog[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [selectedLog, setSelectedLog] = useState<ApiLog | null>(null);
|
||
|
||
// 筛选条件
|
||
const [taskNo, setTaskNo] = useState('');
|
||
const [startDate, setStartDate] = useState('');
|
||
const [endDate, setEndDate] = useState('');
|
||
const [offset, setOffset] = useState(0);
|
||
const [total, setTotal] = useState(0);
|
||
const limit = 50;
|
||
|
||
// 权限检查
|
||
useEffect(() => {
|
||
if (status === 'unauthenticated') {
|
||
router.push('/login');
|
||
} else if (status === 'authenticated' && session?.user) {
|
||
const userRole = (session.user as any).role;
|
||
if (userRole !== 'ADMIN' && userRole !== 'SYSTEM_ADMIN') {
|
||
router.push('/');
|
||
}
|
||
}
|
||
}, [status, session, router]);
|
||
|
||
// 加载日志
|
||
useEffect(() => {
|
||
if (status === 'authenticated') {
|
||
fetchLogs();
|
||
}
|
||
}, [status, offset]);
|
||
|
||
const fetchLogs = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const params = new URLSearchParams({
|
||
limit: limit.toString(),
|
||
offset: offset.toString(),
|
||
});
|
||
|
||
if (taskNo) params.append('taskNo', taskNo);
|
||
if (startDate) params.append('startDate', startDate);
|
||
if (endDate) params.append('endDate', endDate);
|
||
|
||
const response = await fetch(`/api/admin/api-logs?${params}`);
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
setLogs(result.data.logs);
|
||
setTotal(result.data.total);
|
||
}
|
||
} catch (error) {
|
||
console.error('加载失败:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleSearch = () => {
|
||
setOffset(0);
|
||
fetchLogs();
|
||
};
|
||
|
||
const handleReset = () => {
|
||
setTaskNo('');
|
||
setStartDate('');
|
||
setEndDate('');
|
||
setOffset(0);
|
||
setTimeout(fetchLogs, 0);
|
||
};
|
||
|
||
const getStatusColor = (statusCode: number) => {
|
||
if (statusCode >= 200 && statusCode < 300) return 'text-green-600';
|
||
if (statusCode >= 400 && statusCode < 500) return 'text-yellow-600';
|
||
return 'text-red-600';
|
||
};
|
||
|
||
if (loading && logs.length === 0) {
|
||
return <div className="p-8">加载中...</div>;
|
||
}
|
||
|
||
const totalPages = Math.ceil(total / limit);
|
||
const currentPage = Math.floor(offset / limit) + 1;
|
||
|
||
return (
|
||
<div className="p-8">
|
||
<div className="max-w-7xl mx-auto">
|
||
<h1 className="text-2xl font-bold mb-6">API 调用日志</h1>
|
||
|
||
{/* 筛选条件 */}
|
||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||
<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={taskNo}
|
||
onChange={(e) => setTaskNo(e.target.value)}
|
||
placeholder="输入任务号"
|
||
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="date"
|
||
value={startDate}
|
||
onChange={(e) => setStartDate(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="date"
|
||
value={endDate}
|
||
onChange={(e) => setEndDate(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 className="flex items-end space-x-2">
|
||
<button
|
||
onClick={handleSearch}
|
||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||
>
|
||
查询
|
||
</button>
|
||
<button
|
||
onClick={handleReset}
|
||
className="flex-1 px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
|
||
>
|
||
重置
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 统计信息 */}
|
||
<div className="mb-4 text-sm text-gray-600">
|
||
共 {total} 条记录,当前显示第 {offset + 1} - {Math.min(offset + limit, total)} 条
|
||
</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">时间</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">API Key</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">任务号</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">接口</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">方法</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态码</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">响应时间</th>
|
||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{logs.map((log) => (
|
||
<tr key={log.id} className="hover:bg-gray-50">
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||
{new Date(log.createdAt).toLocaleString('zh-CN')}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm">{log.apiKeyName}</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono">
|
||
{log.taskNo || '-'}
|
||
</td>
|
||
<td className="px-6 py-4 text-sm font-mono">{log.endpoint}</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||
<span className="px-2 py-1 bg-gray-100 rounded text-xs">{log.method}</span>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||
<span className={`font-medium ${getStatusColor(log.statusCode)}`}>
|
||
{log.statusCode}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||
{log.responseTime}ms
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||
<button
|
||
onClick={() => setSelectedLog(log)}
|
||
className="text-blue-600 hover:text-blue-800"
|
||
>
|
||
详情
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 分页 */}
|
||
{totalPages > 1 && (
|
||
<div className="flex justify-center items-center mt-6 space-x-2">
|
||
<button
|
||
onClick={() => setOffset(Math.max(0, offset - limit))}
|
||
disabled={offset === 0}
|
||
className="px-4 py-2 border rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||
>
|
||
上一页
|
||
</button>
|
||
<span className="px-4 py-2">
|
||
第 {currentPage} / {totalPages} 页
|
||
</span>
|
||
<button
|
||
onClick={() => setOffset(Math.min(total - limit, offset + limit))}
|
||
disabled={offset + limit >= total}
|
||
className="px-4 py-2 border rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||
>
|
||
下一页
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 详情模态框 */}
|
||
{selectedLog && (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
<div className="bg-white rounded-lg p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="flex justify-between items-start mb-4">
|
||
<h2 className="text-xl font-bold">调用详情</h2>
|
||
<button
|
||
onClick={() => setSelectedLog(null)}
|
||
className="text-gray-500 hover:text-gray-700"
|
||
>
|
||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<span className="text-sm text-gray-500">时间:</span>
|
||
<span className="text-sm font-medium">
|
||
{new Date(selectedLog.createdAt).toLocaleString('zh-CN')}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-sm text-gray-500">API Key:</span>
|
||
<span className="text-sm font-medium">{selectedLog.apiKeyName}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-sm text-gray-500">任务号:</span>
|
||
<span className="text-sm font-mono">{selectedLog.taskNo || '-'}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-sm text-gray-500">接口:</span>
|
||
<span className="text-sm font-mono">{selectedLog.endpoint}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-sm text-gray-500">方法:</span>
|
||
<span className="text-sm font-medium">{selectedLog.method}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-sm text-gray-500">状态码:</span>
|
||
<span className={`text-sm font-medium ${getStatusColor(selectedLog.statusCode)}`}>
|
||
{selectedLog.statusCode}
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-sm text-gray-500">响应时间:</span>
|
||
<span className="text-sm font-medium">{selectedLog.responseTime}ms</span>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedLog.requestBody && (
|
||
<div>
|
||
<h3 className="text-sm font-medium mb-2">请求体</h3>
|
||
<pre className="bg-gray-100 p-3 rounded text-xs overflow-x-auto">
|
||
{JSON.stringify(selectedLog.requestBody, null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
|
||
{selectedLog.responseBody && (
|
||
<div>
|
||
<h3 className="text-sm font-medium mb-2">响应体</h3>
|
||
<pre className="bg-gray-100 p-3 rounded text-xs overflow-x-auto">
|
||
{JSON.stringify(selectedLog.responseBody, null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|