- 数据库新增 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>
167 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|