feat: 添加 Admin 管理后台

- 数据库新增 Role 枚举、disabled 字段和 McpCallLog 调用日志表
- 后端新增 requireAdmin 中间件和 /api/admin/* 管理接口(统计、用户、项目、日志)
- MCP 工具调用自动记录详细日志(耗时、参数、响应大小、客户端IP、token估算)
- 前端新增 /admin 路由区域:仪表盘、用户管理、项目管理、调用日志四个页面
- JWT 携带 role 字段,登录/OAuth 增加禁用账号检查
- nginx 配置补充 X-Forwarded-For 透传真实客户端 IP

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 13:04:44 +08:00
parent d45cc45815
commit 6fe04f4893
25 changed files with 1847 additions and 20 deletions

View File

@@ -0,0 +1,144 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { apiFetch } from '../../lib/api';
type ProjectItem = {
id: string;
name: string;
description: string | null;
openApiVersion: string;
createdAt: string;
user: { id: string; name: string; email: string };
_count: { endpoints: number; modules: number };
};
type ProjectsResponse = {
projects: ProjectItem[];
total: number;
page: number;
limit: number;
};
export default function AdminProjects() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const limit = 20;
const { data, isLoading } = useQuery({
queryKey: ['admin', 'projects', page, search],
queryFn: () => {
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (search) params.set('search', search);
return apiFetch<ProjectsResponse>(`/admin/projects?${params}`);
},
});
const totalPages = data ? Math.ceil(data.total / limit) : 0;
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
setPage(1);
};
return (
<div className="space-y-4 animate-fade-in">
{/* Header */}
<div>
<h2 className="text-lg font-bold text-text-primary font-heading"></h2>
<p className="text-[12px] text-text-muted mt-0.5"> {data?.total ?? 0} </p>
</div>
{/* Search */}
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="搜索项目名称..."
className="input-base max-w-xs"
/>
<button type="submit" className="btn-primary text-[13px] px-3"></button>
{search && (
<button type="button" className="btn-ghost text-[13px] px-3" onClick={() => { setSearch(''); setSearchInput(''); setPage(1); }}>
</button>
)}
</form>
{/* Table */}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-[13px]">
<thead>
<tr className="border-b border-border-default bg-bg-secondary">
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
<th className="text-right px-4 py-3 font-medium text-text-muted"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border-muted">
{Array.from({ length: 7 }).map((_, j) => (
<td key={j} className="px-4 py-3"><div className="skeleton h-4 w-20" /></td>
))}
</tr>
))
) : data?.projects.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-12 text-center text-text-muted"></td>
</tr>
) : (
data?.projects.map((project) => (
<tr key={project.id} className="border-b border-border-muted hover:bg-bg-secondary/50 transition-colors">
<td className="px-4 py-3">
<div className="font-medium text-text-primary">{project.name}</div>
{project.description && (
<div className="text-[11px] text-text-muted truncate max-w-[200px]">{project.description}</div>
)}
</td>
<td className="px-4 py-3">
<Link to={`/admin/users/${project.user.id}`} className="text-text-secondary hover:text-accent transition-colors">
{project.user.name}
</Link>
</td>
<td className="px-4 py-3">
<span className="font-mono text-[11px] text-text-muted">{project.openApiVersion}</span>
</td>
<td className="px-4 py-3 text-text-secondary">{project._count.modules}</td>
<td className="px-4 py-3 text-text-secondary">{project._count.endpoints}</td>
<td className="px-4 py-3 text-text-muted">{new Date(project.createdAt).toLocaleDateString('zh-CN')}</td>
<td className="px-4 py-3 text-right">
<Link to={`/admin/projects/${project.id}`} className="text-accent hover:text-accent-hover text-[12px] font-medium transition-colors">
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-[12px] text-text-muted"> {page} / {totalPages} </span>
<div className="flex gap-1.5">
<button className="btn-outline text-[12px] px-2.5 py-1.5" disabled={page <= 1} onClick={() => setPage(p => p - 1)}></button>
<button className="btn-outline text-[12px] px-2.5 py-1.5" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}></button>
</div>
</div>
)}
</div>
);
}