Files
agent-fox/packages/web/src/pages/admin/Users.tsx
YANG JIANKUAN 6fe04f4893 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>
2026-04-04 13:04:44 +08:00

167 lines
6.9 KiB
TypeScript

import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { apiFetch } from '../../lib/api';
type UserItem = {
id: string;
email: string;
name: string;
role: string;
disabled: boolean;
createdAt: string;
avatarUrl: string | null;
_count: { projects: number };
};
type UsersResponse = {
users: UserItem[];
total: number;
page: number;
limit: number;
};
export default function Users() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const limit = 20;
const { data, isLoading } = useQuery({
queryKey: ['admin', 'users', page, search],
queryFn: () => {
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (search) params.set('search', search);
return apiFetch<UsersResponse>(`/admin/users?${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 className="flex items-center justify-between">
<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>
</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-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">
<td className="px-4 py-3"><div className="skeleton h-4 w-40" /></td>
<td className="px-4 py-3"><div className="skeleton h-4 w-16" /></td>
<td className="px-4 py-3"><div className="skeleton h-4 w-8" /></td>
<td className="px-4 py-3"><div className="skeleton h-4 w-24" /></td>
<td className="px-4 py-3"><div className="skeleton h-4 w-16" /></td>
<td className="px-4 py-3"><div className="skeleton h-4 w-12 ml-auto" /></td>
</tr>
))
) : data?.users.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-text-muted"></td>
</tr>
) : (
data?.users.map((user) => (
<tr key={user.id} className="border-b border-border-muted hover:bg-bg-secondary/50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[9px] font-bold shrink-0">
{user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div className="min-w-0">
<div className="font-medium text-text-primary truncate">{user.name}</div>
<div className="text-[11px] text-text-muted truncate">{user.email}</div>
</div>
</div>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide ${
user.role === 'ADMIN' ? 'bg-accent-muted text-accent' : 'bg-bg-tertiary text-text-muted'
}`}>
{user.role}
</span>
</td>
<td className="px-4 py-3 text-text-secondary">{user._count.projects}</td>
<td className="px-4 py-3 text-text-muted">{new Date(user.createdAt).toLocaleDateString('zh-CN')}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1 text-[11px] font-medium ${user.disabled ? 'text-danger' : 'text-success'}`}>
<span className={`w-1.5 h-1.5 rounded-full ${user.disabled ? 'bg-danger' : 'bg-success'}`} />
{user.disabled ? '已禁用' : '正常'}
</span>
</td>
<td className="px-4 py-3 text-right">
<Link to={`/admin/users/${user.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>
);
}