chenpei ea9076df45 feat: 实现统一的下载错误处理系统并修复产品全案数据库Schema
- 创建统一的下载错误处理模块 (lib/download-errors.ts)
- 实现13种错误类型分类和中文错误消息
- 后端统一使用 createDownloadError 创建标准化错误响应
- 前端统一使用 parseDownloadError 解析错误并显示
- 修复正式系统数据库Schema,添加产品全案相关字段和表
- 执行数据库迁移:添加 task_no, task_status, task_fetched_at 字段
- 创建 material_assets, api_keys, api_call_logs 表
2026-01-05 17:52:42 +08:00

326 lines
12 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.

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