Compare commits

...

2 Commits

Author SHA1 Message Date
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
d45cc45815 docs: 更新 CLAUDE.md 补充部署、Docker、OAuth 等上下文
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:36:32 +08:00
26 changed files with 1861 additions and 22 deletions

View File

@@ -38,7 +38,8 @@ pnpm monorepo with 4 packages sharing TypeScript config (`tsconfig.base.json`):
### Data Flow ### Data Flow
1. User imports OpenAPI doc (JSON/YAML/URL) via web UI 1. User imports OpenAPI doc (JSON/YAML/URL) via web UI
2. Server validates with `@apidevtools/swagger-parser`, dereferences all `$ref`s 2. For URL imports, frontend fetches the content (supports localhost/intranet), falling back to server proxy `/api/fetch-spec` for CORS-blocked URLs. Server receives parsed spec objects only.
3. Server validates with `@apidevtools/swagger-parser`, dereferences all `$ref`s
3. Parses into Module (from tags or path prefixes) and Endpoint records in PostgreSQL 3. Parses into Module (from tags or path prefixes) and Endpoint records in PostgreSQL
4. User gets a project ID + API key 4. User gets a project ID + API key
5. LLM connects to MCP service at `/mcp/:projectId` with API key 5. LLM connects to MCP service at `/mcp/:projectId` with API key
@@ -58,9 +59,20 @@ The 5 tools are designed for minimal token usage per call (~200-2000 tokens each
- **API responses**: All endpoints return `{ success: boolean, data?: T, error?: { code, message } }` - **API responses**: All endpoints return `{ success: boolean, data?: T, error?: { code, message } }`
- **Auth**: User auth uses JWT dual-token (15min access + 7d refresh). MCP auth uses project API keys (`afk_` prefix, bcrypt hashed). - **Auth**: User auth uses JWT dual-token (15min access + 7d refresh). MCP auth uses project API keys (`afk_` prefix, bcrypt hashed).
- **OAuth**: Google/GitHub via server-side flow. Redirect URL passed through OAuth state store. `validateState()` returns `{ valid, redirect }`. Apple login not yet implemented.
- **MCP SDK imports**: Use `@modelcontextprotocol/sdk/server/mcp.js` (not `@modelcontextprotocol/server`). Tool registration uses `server.tool(name, description, zodShape, handler)`. - **MCP SDK imports**: Use `@modelcontextprotocol/sdk/server/mcp.js` (not `@modelcontextprotocol/server`). Tool registration uses `server.tool(name, description, zodShape, handler)`.
- **Swagger 2.0 + OpenAPI 3.x**: Parser handles both. For Swagger 2, body params are converted to requestBody format. - **Swagger 2.0 + OpenAPI 3.x**: Parser handles both. For Swagger 2, body params are converted to requestBody format.
- **Docker dev mode**: Server/MCP use `deps` build stage + volume mounts for hot reload. Web uses Vite `build` stage. Shared must be built inside container before server/mcp start. - **Docker dev mode**: Server/MCP use `build` stage + volume mounts for hot reload. Shared must be built inside container before server/mcp start.
- **Docker production**: Dockerfiles use `npm install` + global `tsc` (not pnpm — symlinks break on overlay2). `workspace:*` refs are replaced with `file:` refs via `sed`.
### Deployment
- **Production**: Docker Compose on `ubuntu@43.130.35.66:/opt/1panel/apps/agentfox`. Deploy via `/deploy` skill.
- **Port mapping**: web=8088 (80 is OpenResty), server=3000, mcp=3001. PostgreSQL is internal only (no host port).
- **`.env`**: Local `.env` is the single source of truth, synced to server on deploy. `.env.example` must stay aligned.
- **Nginx**: Web container's nginx proxies `/api/` → server, `/mcp/` → mcp. External Nginx only needs to proxy to port 8088.
- **Migrations**: `scripts/migrate-and-start.sh` auto-runs `prisma migrate deploy` before server starts. Always create migrations for schema changes (`prisma migrate dev --name <name> --create-only`), never rely on `db push` alone.
- **Domain**: `www.agentfoxapp.com` — not hardcoded anywhere, configured via `OAUTH_CALLBACK_BASE_URL` and `FRONTEND_URL` env vars.
### Database (Prisma schema at `prisma/schema.prisma`) ### Database (Prisma schema at `prisma/schema.prisma`)

View File

@@ -0,0 +1,108 @@
# Admin Dashboard Design Spec
## Overview
Add an admin web dashboard to the existing Agent Fox SPA, accessible at `/admin/*` routes. Provides real-time platform statistics, user management, project management, and MCP call log viewing.
## Database Changes
### User model additions
- `role`: enum `Role` (`USER` | `ADMIN`), default `USER`
- `disabled`: Boolean, default `false`
### New model: McpCallLog
| Field | Type | Description |
|-------|------|-------------|
| id | String (UUID) | Primary key |
| projectId | String (FK) | Reference to Project |
| toolName | String | MCP tool name called |
| calledAt | DateTime | Timestamp |
| durationMs | Int | Response time in ms |
| success | Boolean | Whether call succeeded |
| requestParams | Json | Request parameters |
| responseSize | Int | Response size in bytes |
| clientIp | String | Caller IP address |
| estimatedTokens | Int? | Estimated token consumption |
Indexes: `projectId`, `calledAt`, `toolName`
## Backend API
### New middleware
- `requireAdmin`: verifies `role === 'ADMIN'` from JWT payload. Returns 403 if not admin.
- Login check: `disabled === true` users get 403 on login.
### New routes (`/api/admin/`)
| Method | Path | Description |
|--------|------|-------------|
| GET | /stats | Aggregate stats (user count, project count, call count, today's active) |
| GET | /stats/trends | Time-series data (7d/30d call trends) |
| GET | /users | Paginated user list with search/sort |
| GET | /users/:id | User detail + their projects |
| PATCH | /users/:id/disable | Toggle user disabled status |
| GET | /projects | Global paginated project list |
| GET | /projects/:id | Project detail |
| DELETE | /projects/:id | Delete project |
| GET | /call-logs | Paginated call logs with filters |
### MCP call logging
In `packages/mcp`, wrap each tool handler to record a `McpCallLog` entry with timing, params, success/failure, response size, client IP, and token estimate.
## Frontend
### Routes
```
/admin → Dashboard (stats overview)
/admin/users → User management list
/admin/users/:id → User detail
/admin/projects → Project management list
/admin/projects/:id → Project detail
/admin/logs → Call log viewer
```
### AdminLayout
- Left sidebar (200px): nav links for Dashboard / Users / Projects / Logs
- Top header: reuse theme toggle + user menu from existing Layout
- Route guard: redirect non-admin users to `/dashboard`
- Separate from existing `Layout.tsx`, parallel structure
### Dashboard page
| Card | Content |
|------|---------|
| Registered Users | Total + today's new |
| Projects | Total + today's new |
| MCP Calls | Total + today's calls |
| Active Users (7d) | Users with activity in past 7 days |
| Avg Response Time | Mean durationMs of MCP calls |
| Success Rate | Percentage of successful calls |
Below cards: 7-day call trend chart + recent calls table.
### User Management
- Table: name, email, role, projects count, created date, status (active/disabled)
- Search by name/email
- Actions: view detail, toggle disable
- Detail page: user info + list of their projects
### Project Management
- Table: name, owner, endpoints count, modules count, created date
- Search by name
- Actions: view detail, delete (with confirmation)
- Detail page: project info, modules, endpoints summary
### Call Logs
- Table: time, project name, tool name, duration, success, client IP
- Filters: project, tool name, date range, success/failure
- Pagination
## Auth Flow
- JWT payload adds `role` field
- Frontend stores role in auth context
- Admin nav entry only visible to admin users
- Non-admin accessing `/admin/*` → redirect to `/dashboard`
## Tech Stack (frontend)
- Same React 19 + React Router 7 + Tailwind CSS v4
- Reuse existing custom components (Badge, Modal, ConfirmDialog, etc.)
- Charts: lightweight solution (CSS-based or small chart lib)
- No new component library

View File

@@ -40,7 +40,9 @@ app.post('/mcp/:projectId', mcpAuth, async (req, res) => {
} }
}; };
const server = createMcpServer(projectId); const forwarded = req.headers['x-forwarded-for'] as string | undefined;
const clientIp = forwarded?.split(',')[0].trim() || req.socket.remoteAddress || '';
const server = createMcpServer(projectId, clientIp);
await server.connect(transport); await server.connect(transport);
await transport.handleRequest(req, res, req.body); await transport.handleRequest(req, res, req.body);
}); });

View File

@@ -0,0 +1,45 @@
import { prisma } from '@agent-fox/shared';
type CallContext = {
projectId: string;
toolName: string;
requestParams: Record<string, unknown>;
clientIp: string;
};
export async function logMcpCall(ctx: CallContext, fn: () => Promise<any>): Promise<any> {
const start = Date.now();
let success = true;
let result: any;
try {
result = await fn();
if (result?.isError) success = false;
return result;
} catch (err) {
success = false;
throw err;
} finally {
const durationMs = Date.now() - start;
const responseText = result ? JSON.stringify(result) : '';
const responseSize = Buffer.byteLength(responseText, 'utf-8');
// Rough token estimate: ~4 chars per token
const estimatedTokens = Math.ceil(responseText.length / 4);
// Fire-and-forget: don't block the response
prisma.mcpCallLog.create({
data: {
projectId: ctx.projectId,
toolName: ctx.toolName,
durationMs,
success,
requestParams: ctx.requestParams as any,
responseSize,
clientIp: ctx.clientIp,
estimatedTokens,
},
}).catch((err) => {
console.error('Failed to log MCP call:', err);
});
}
}

View File

@@ -5,39 +5,43 @@ import { listModules } from './tools/list-modules.js';
import { listEndpoints } from './tools/list-endpoints.js'; import { listEndpoints } from './tools/list-endpoints.js';
import { getEndpointDetail } from './tools/get-endpoint-detail.js'; import { getEndpointDetail } from './tools/get-endpoint-detail.js';
import { searchEndpoints } from './tools/search-endpoints.js'; import { searchEndpoints } from './tools/search-endpoints.js';
import { logMcpCall } from './lib/call-logger.js';
export function createMcpServer(projectId: string): McpServer { export function createMcpServer(projectId: string, clientIp: string = ''): McpServer {
const server = new McpServer({ const server = new McpServer({
name: 'agent-fox', name: 'agent-fox',
version: '0.1.0', version: '0.1.0',
}); });
const ctx = (toolName: string, requestParams: Record<string, unknown> = {}) =>
({ projectId, toolName, requestParams, clientIp });
server.tool( server.tool(
'get_project_overview', 'get_project_overview',
'Get an overview of this API project including its name, version, base URL, and a summary of available modules with endpoint counts. Call this first to understand what the API offers.', 'Get an overview of this API project including its name, version, base URL, and a summary of available modules with endpoint counts. Call this first to understand what the API offers.',
{}, {},
async () => getProjectOverview(projectId), async () => logMcpCall(ctx('get_project_overview'), () => getProjectOverview(projectId)),
); );
server.tool( server.tool(
'list_modules', 'list_modules',
'List all API modules/groups with their descriptions. Each module contains related endpoints. Use this when you need module descriptions to decide which module to explore.', 'List all API modules/groups with their descriptions. Each module contains related endpoints. Use this when you need module descriptions to decide which module to explore.',
{}, {},
async () => listModules(projectId), async () => logMcpCall(ctx('list_modules'), () => listModules(projectId)),
); );
server.tool( server.tool(
'list_endpoints', 'list_endpoints',
'List all endpoints in a specific module. Returns method, path, and summary for each endpoint. Use get_endpoint_detail to get full information about a specific endpoint.', 'List all endpoints in a specific module. Returns method, path, and summary for each endpoint. Use get_endpoint_detail to get full information about a specific endpoint.',
{ moduleId: z.string().describe('The module ID to list endpoints for. Get module IDs from get_project_overview or list_modules.') }, { moduleId: z.string().describe('The module ID to list endpoints for. Get module IDs from get_project_overview or list_modules.') },
async ({ moduleId }) => listEndpoints(projectId, moduleId), async ({ moduleId }) => logMcpCall(ctx('list_endpoints', { moduleId }), () => listEndpoints(projectId, moduleId)),
); );
server.tool( server.tool(
'get_endpoint_detail', 'get_endpoint_detail',
'Get complete details for a specific endpoint including parameters, request body schema, response schemas. Use this when you need to understand exactly how to call an endpoint.', 'Get complete details for a specific endpoint including parameters, request body schema, response schemas. Use this when you need to understand exactly how to call an endpoint.',
{ endpointId: z.string().describe('The endpoint ID. Get endpoint IDs from list_endpoints or search_endpoints.') }, { endpointId: z.string().describe('The endpoint ID. Get endpoint IDs from list_endpoints or search_endpoints.') },
async ({ endpointId }) => getEndpointDetail(projectId, endpointId), async ({ endpointId }) => logMcpCall(ctx('get_endpoint_detail', { endpointId }), () => getEndpointDetail(projectId, endpointId)),
); );
server.tool( server.tool(
@@ -47,7 +51,7 @@ export function createMcpServer(projectId: string): McpServer {
keyword: z.string().describe('Search keyword to match against endpoint path, summary, description, and operationId.'), keyword: z.string().describe('Search keyword to match against endpoint path, summary, description, and operationId.'),
moduleId: z.string().optional().describe('Optional module ID to limit search scope. Omit to search all modules.'), moduleId: z.string().optional().describe('Optional module ID to limit search scope. Omit to search all modules.'),
}, },
async ({ keyword, moduleId }) => searchEndpoints(projectId, keyword, moduleId), async ({ keyword, moduleId }) => logMcpCall(ctx('search_endpoints', { keyword, moduleId }), () => searchEndpoints(projectId, keyword, moduleId)),
); );
return server; return server;

View File

@@ -7,6 +7,7 @@ import importRouter from './routes/import.js';
import moduleRouter from './routes/modules.js'; import moduleRouter from './routes/modules.js';
import endpointRouter from './routes/endpoints.js'; import endpointRouter from './routes/endpoints.js';
import fetchSpecRouter from './routes/fetch-spec.js'; import fetchSpecRouter from './routes/fetch-spec.js';
import adminRouter from './routes/admin.js';
const app = express(); const app = express();
app.use(cors()); app.use(cors());
@@ -24,6 +25,7 @@ app.use('/api/projects', projectRouter);
app.use('/api/projects', importRouter); app.use('/api/projects', importRouter);
app.use('/api/projects', moduleRouter); app.use('/api/projects', moduleRouter);
app.use('/api/projects', endpointRouter); app.use('/api/projects', endpointRouter);
app.use('/api/admin', adminRouter);
const port = process.env.SERVER_PORT || 3000; const port = process.env.SERVER_PORT || 3000;
app.listen(port, () => { app.listen(port, () => {

View File

@@ -1,4 +1,5 @@
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import type { Role } from '@agent-fox/shared';
const ACCESS_SECRET = process.env.JWT_SECRET || 'dev-secret'; const ACCESS_SECRET = process.env.JWT_SECRET || 'dev-secret';
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret'; const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret';
@@ -8,6 +9,7 @@ const REFRESH_EXPIRY = '7d';
export type TokenPayload = { export type TokenPayload = {
userId: string; userId: string;
email: string; email: string;
role: Role;
}; };
export function generateAccessToken(payload: TokenPayload): string { export function generateAccessToken(payload: TokenPayload): string {

View File

@@ -0,0 +1,9 @@
import type { Request, Response, NextFunction } from 'express';
export function requireAdmin(req: Request, res: Response, next: NextFunction): void {
if (!req.user || req.user.role !== 'ADMIN') {
res.status(403).json({ success: false, error: { code: 'FORBIDDEN', message: 'Admin access required' } });
return;
}
next();
}

View File

@@ -0,0 +1,305 @@
import { Router, type Router as RouterType } from 'express';
import { z } from 'zod';
import { prisma } from '@agent-fox/shared';
import { requireAuth } from '../middleware/auth.js';
import { requireAdmin } from '../middleware/admin.js';
const router: RouterType = Router();
router.use(requireAuth, requireAdmin);
function parsePagination(query: Record<string, any>, defaultLimit = 20) {
return {
page: Math.max(1, parseInt(query.page as string) || 1),
limit: Math.min(100, Math.max(1, parseInt(query.limit as string) || defaultLimit)),
};
}
// ─── Dashboard Stats ────────────────────────────────────────────────
router.get('/stats', async (_req, res) => {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const sevenDaysAgo = new Date(todayStart.getTime() - 7 * 24 * 60 * 60 * 1000);
const [
totalUsers,
todayUsers,
totalProjects,
todayProjects,
totalCalls,
todayCalls,
callStats,
successCount,
activeProjectUsers,
] = await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { createdAt: { gte: todayStart } } }),
prisma.project.count(),
prisma.project.count({ where: { createdAt: { gte: todayStart } } }),
prisma.mcpCallLog.count(),
prisma.mcpCallLog.count({ where: { calledAt: { gte: todayStart } } }),
prisma.mcpCallLog.aggregate({
_avg: { durationMs: true },
_count: { id: true },
where: { calledAt: { gte: sevenDaysAgo } },
}),
prisma.mcpCallLog.count({
where: { calledAt: { gte: sevenDaysAgo }, success: true },
}),
prisma.mcpCallLog.groupBy({
by: ['projectId'],
where: { calledAt: { gte: sevenDaysAgo } },
}),
]);
// Resolve unique user IDs from active projects
const activeProjectIds = activeProjectUsers.map(g => g.projectId);
let activeUserCount = 0;
if (activeProjectIds.length > 0) {
activeUserCount = await prisma.project.groupBy({
by: ['userId'],
where: { id: { in: activeProjectIds } },
}).then(r => r.length);
}
const recentTotal = callStats._count.id;
const successRate = recentTotal > 0 ? Math.round((successCount / recentTotal) * 100) : 100;
res.json({
success: true,
data: {
totalUsers,
todayUsers,
totalProjects,
todayProjects,
totalCalls,
todayCalls,
avgResponseTime: Math.round(callStats._avg.durationMs ?? 0),
successRate,
activeUsers: activeUserCount,
},
});
});
// ─── Trends (7d / 30d) ─────────────────────────────────────────────
router.get('/stats/trends', async (req, res) => {
const days = req.query.days === '30' ? 30 : 7;
const since = new Date();
since.setDate(since.getDate() - days);
since.setHours(0, 0, 0, 0);
const rows = await prisma.$queryRaw<
{ date: string; total: bigint; success_count: bigint; avg_duration: number }[]
>`
SELECT
TO_CHAR("calledAt", 'YYYY-MM-DD') AS date,
COUNT(*)::bigint AS total,
SUM(CASE WHEN success THEN 1 ELSE 0 END)::bigint AS success_count,
COALESCE(AVG("durationMs"), 0)::int AS avg_duration
FROM "McpCallLog"
WHERE "calledAt" >= ${since}
GROUP BY TO_CHAR("calledAt", 'YYYY-MM-DD')
ORDER BY date
`;
// Build full date range with zeros for missing days
const dataMap = new Map(rows.map(r => [r.date, r]));
const trends = [];
for (let i = 0; i < days; i++) {
const d = new Date(since);
d.setDate(d.getDate() + i);
const key = d.toISOString().slice(0, 10);
const row = dataMap.get(key);
const total = row ? Number(row.total) : 0;
const successCnt = row ? Number(row.success_count) : 0;
trends.push({
date: key,
calls: total,
successRate: total > 0 ? Math.round((successCnt / total) * 100) : 100,
avgDuration: row ? row.avg_duration : 0,
});
}
res.json({ success: true, data: trends });
});
// ─── User Management ────────────────────────────────────────────────
router.get('/users', async (req, res) => {
const { page, limit } = parsePagination(req.query);
const search = (req.query.search as string) || '';
const sortBy = (req.query.sortBy as string) || 'createdAt';
const order = req.query.order === 'asc' ? 'asc' as const : 'desc' as const;
const where = search
? { OR: [{ name: { contains: search, mode: 'insensitive' as const } }, { email: { contains: search, mode: 'insensitive' as const } }] }
: {};
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
select: {
id: true, email: true, name: true, role: true, disabled: true, createdAt: true, avatarUrl: true,
_count: { select: { projects: true } },
},
orderBy: { [sortBy]: order },
skip: (page - 1) * limit,
take: limit,
}),
prisma.user.count({ where }),
]);
res.json({ success: true, data: { users, total, page, limit } });
});
router.get('/users/:id', async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: req.params.id },
select: {
id: true, email: true, name: true, role: true, disabled: true, createdAt: true, avatarUrl: true,
oauthAccounts: { select: { provider: true, createdAt: true } },
projects: {
select: { id: true, name: true, description: true, createdAt: true, _count: { select: { endpoints: true, modules: true } } },
orderBy: { createdAt: 'desc' },
},
},
});
if (!user) {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
return;
}
res.json({ success: true, data: user });
});
const disableSchema = z.object({ disabled: z.boolean() });
router.patch('/users/:id/disable', async (req, res) => {
const parsed = disableSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Invalid request body' } });
return;
}
if (req.params.id === req.user!.userId) {
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Cannot disable your own account' } });
return;
}
const user = await prisma.user.update({
where: { id: req.params.id },
data: { disabled: parsed.data.disabled },
select: { id: true, disabled: true },
});
res.json({ success: true, data: user });
});
// ─── Project Management ─────────────────────────────────────────────
router.get('/projects', async (req, res) => {
const { page, limit } = parsePagination(req.query);
const search = (req.query.search as string) || '';
const where = search
? { name: { contains: search, mode: 'insensitive' as const } }
: {};
const [projects, total] = await Promise.all([
prisma.project.findMany({
where,
select: {
id: true, name: true, description: true, openApiVersion: true, createdAt: true,
user: { select: { id: true, name: true, email: true } },
_count: { select: { endpoints: true, modules: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.project.count({ where }),
]);
res.json({ success: true, data: { projects, total, page, limit } });
});
router.get('/projects/:id', async (req, res) => {
const project = await prisma.project.findUnique({
where: { id: req.params.id },
select: {
id: true, name: true, description: true, baseUrl: true, openApiVersion: true, createdAt: true, updatedAt: true,
user: { select: { id: true, name: true, email: true } },
modules: { select: { id: true, name: true, description: true, source: true, _count: { select: { endpoints: true } } }, orderBy: { sortOrder: 'asc' } },
_count: { select: { endpoints: true, modules: true, mcpCallLogs: true } },
},
});
if (!project) {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Project not found' } });
return;
}
res.json({ success: true, data: project });
});
router.delete('/projects/:id', async (req, res) => {
await prisma.project.delete({ where: { id: req.params.id } });
res.json({ success: true, data: { message: 'Project deleted' } });
});
// ─── Call Logs ──────────────────────────────────────────────────────
router.get('/call-logs', async (req, res) => {
const { page, limit } = parsePagination(req.query, 30);
const projectId = req.query.projectId as string | undefined;
const toolName = req.query.toolName as string | undefined;
const success = req.query.success as string | undefined;
const dateStart = req.query.dateStart as string | undefined;
const dateEnd = req.query.dateEnd as string | undefined;
const where: any = {};
if (projectId) where.projectId = projectId;
if (toolName) where.toolName = toolName;
if (success === 'true') where.success = true;
if (success === 'false') where.success = false;
if (dateStart || dateEnd) {
where.calledAt = {};
if (dateStart) where.calledAt.gte = new Date(dateStart);
if (dateEnd) where.calledAt.lte = new Date(dateEnd);
}
const [logs, total] = await Promise.all([
prisma.mcpCallLog.findMany({
where,
select: {
id: true, toolName: true, calledAt: true, durationMs: true, success: true,
responseSize: true, clientIp: true, estimatedTokens: true,
project: { select: { id: true, name: true } },
},
orderBy: { calledAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.mcpCallLog.count({ where }),
]);
res.json({ success: true, data: { logs, total, page, limit } });
});
router.get('/call-logs/recent', async (_req, res) => {
const logs = await prisma.mcpCallLog.findMany({
select: {
id: true, toolName: true, calledAt: true, durationMs: true, success: true,
project: { select: { name: true } },
},
orderBy: { calledAt: 'desc' },
take: 10,
});
res.json({ success: true, data: logs });
});
export default router;

View File

@@ -40,8 +40,8 @@ router.post('/register', async (req, res) => {
data: { email, passwordHash, name }, data: { email, passwordHash, name },
}); });
const tokens = generateTokenPair({ userId: user.id, email: user.email }); const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role });
res.status(201).json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } }); res.status(201).json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name, role: user.role }, ...tokens } });
}); });
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
@@ -59,14 +59,19 @@ router.post('/login', async (req, res) => {
return; return;
} }
if (user.disabled) {
res.status(403).json({ success: false, error: { code: 'DISABLED', message: 'Account has been disabled' } });
return;
}
const valid = await verifyPassword(password, user.passwordHash); const valid = await verifyPassword(password, user.passwordHash);
if (!valid) { if (!valid) {
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } }); res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } });
return; return;
} }
const tokens = generateTokenPair({ userId: user.id, email: user.email }); const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role });
res.json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } }); res.json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name, role: user.role }, ...tokens } });
}); });
router.post('/refresh', async (req, res) => { router.post('/refresh', async (req, res) => {
@@ -83,7 +88,11 @@ router.post('/refresh', async (req, res) => {
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'User not found' } }); res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'User not found' } });
return; return;
} }
const tokens = generateTokenPair({ userId: user.id, email: user.email }); if (user.disabled) {
res.status(403).json({ success: false, error: { code: 'DISABLED', message: 'Account has been disabled' } });
return;
}
const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role });
res.json({ success: true, data: tokens }); res.json({ success: true, data: tokens });
} catch { } catch {
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid refresh token' } }); res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid refresh token' } });
@@ -170,7 +179,7 @@ router.put('/profile', requireAuth, async (req, res) => {
router.get('/me', requireAuth, async (req, res) => { router.get('/me', requireAuth, async (req, res) => {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: req.user!.userId }, where: { id: req.user!.userId },
select: { id: true, email: true, name: true, avatarUrl: true, passwordHash: true }, select: { id: true, email: true, name: true, avatarUrl: true, passwordHash: true, role: true },
}); });
if (!user) { if (!user) {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } }); res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });

View File

@@ -72,7 +72,11 @@ async function handleOAuthCallback(
} }
const user = await findOrCreateUser(provider, providerUser); const user = await findOrCreateUser(provider, providerUser);
const tokens = generateTokenPair({ userId: user.id, email: user.email }); if (user.disabled) {
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Account has been disabled')}`);
return;
}
const tokens = generateTokenPair({ userId: user.id, email: user.email, role: user.role });
const redirectParam = stateResult.redirect ? `&redirect=${encodeURIComponent(stateResult.redirect)}` : ''; const redirectParam = stateResult.redirect ? `&redirect=${encodeURIComponent(stateResult.redirect)}` : '';
res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}${redirectParam}`); res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}${redirectParam}`);

View File

@@ -1,6 +1,6 @@
import type { User, Project, Module, Endpoint, ModuleSource } from '@prisma/client'; import type { User, Project, Module, Endpoint, ModuleSource, Role } from '@prisma/client';
export type { User, Project, Module, Endpoint, ModuleSource }; export type { User, Project, Module, Endpoint, ModuleSource, Role };
export type ApiResponse<T = unknown> = { export type ApiResponse<T = unknown> = {
success: boolean; success: boolean;

View File

@@ -7,12 +7,14 @@ server {
proxy_pass http://server:3000; proxy_pass http://server:3000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
location /mcp/ { location /mcp/ {
proxy_pass http://mcp:3001; proxy_pass http://mcp:3001;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Connection ''; proxy_set_header Connection '';
proxy_buffering off; proxy_buffering off;

View File

@@ -9,6 +9,14 @@ import Layout from './pages/Layout';
import Projects from './pages/Projects'; import Projects from './pages/Projects';
import ProjectDetail from './pages/ProjectDetail'; import ProjectDetail from './pages/ProjectDetail';
import LandingPage from './pages/landing/LandingPage'; import LandingPage from './pages/landing/LandingPage';
import AdminLayout from './pages/admin/AdminLayout';
import AdminDashboard from './pages/admin/Dashboard';
import AdminUsers from './pages/admin/Users';
import AdminUserDetail from './pages/admin/UserDetail';
import AdminProjects from './pages/admin/Projects';
import AdminProjectDetail from './pages/admin/ProjectDetail';
import AdminCallLogs from './pages/admin/CallLogs';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
export default function App() { export default function App() {
@@ -26,6 +34,14 @@ export default function App() {
<Route index element={<Projects />} /> <Route index element={<Projects />} />
<Route path="projects/:id" element={<ProjectDetail />} /> <Route path="projects/:id" element={<ProjectDetail />} />
</Route> </Route>
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<AdminDashboard />} />
<Route path="users" element={<AdminUsers />} />
<Route path="users/:id" element={<AdminUserDetail />} />
<Route path="projects" element={<AdminProjects />} />
<Route path="projects/:id" element={<AdminProjectDetail />} />
<Route path="logs" element={<AdminCallLogs />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@@ -302,6 +302,13 @@ body {
&::placeholder { color: var(--text-muted); } &::placeholder { color: var(--text-muted); }
&:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-muted); } &:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-muted); }
} }
select.input-base {
@apply pr-10 appearance-none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.25rem 1.25rem;
}
.card { .card {
@apply rounded-xl transition-all duration-200; @apply rounded-xl transition-all duration-200;
background: var(--bg-elevated); background: var(--bg-elevated);

View File

@@ -1,7 +1,7 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { getAccessToken, clearTokens, setTokens, apiFetch } from './api'; import { getAccessToken, clearTokens, setTokens, apiFetch } from './api';
type User = { id: string; email: string; name: string; hasPassword?: boolean }; type User = { id: string; email: string; name: string; hasPassword?: boolean; role?: string };
type AuthContextType = { type AuthContextType = {
user: User | null; user: User | null;

View File

@@ -17,7 +17,7 @@ type ProjectSummary = {
_count: { endpoints: number; modules: number }; _count: { endpoints: number; modules: number };
}; };
function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string; email: string }; logout: () => void; onOpenSettings: () => void }) { function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string; email: string; role?: string }; logout: () => void; onOpenSettings: () => void }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [confirmLogout, setConfirmLogout] = useState(false); const [confirmLogout, setConfirmLogout] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@@ -67,6 +67,19 @@ function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string;
{/* Actions */} {/* Actions */}
<div className="py-1"> <div className="py-1">
{user.role === 'ADMIN' && (
<Link
to="/admin"
onClick={() => setOpen(false)}
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-accent hover:bg-accent-muted transition-colors mx-1"
style={{ width: 'calc(100% - 8px)' }}
>
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
Admin
</Link>
)}
<button <button
onClick={() => { setOpen(false); onOpenSettings(); }} onClick={() => { setOpen(false); onOpenSettings(); }}
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-text-secondary hover:text-text-primary hover:bg-bg-tertiary transition-colors mx-1" className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-text-secondary hover:text-text-primary hover:bg-bg-tertiary transition-colors mx-1"

View File

@@ -0,0 +1,187 @@
import { Navigate, Outlet, NavLink, Link } from 'react-router-dom';
import { useAuth } from '../../lib/auth';
import ThemeToggle from '../../components/ThemeToggle';
import { useState, useRef, useEffect } from 'react';
const navItems = [
{
to: '/admin',
label: '仪表盘',
icon: (
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" />
</svg>
),
end: true,
},
{
to: '/admin/users',
label: '用户管理',
icon: (
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
),
},
{
to: '/admin/projects',
label: '项目管理',
icon: (
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
),
},
{
to: '/admin/logs',
label: '调用日志',
icon: (
<svg className="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
),
},
];
export default function AdminLayout() {
const { user, loading, logout } = useAuth();
const [mobileOpen, setMobileOpen] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!mobileOpen) return;
const handler = (e: MouseEvent) => {
if (sidebarRef.current && !sidebarRef.current.contains(e.target as Node)) setMobileOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [mobileOpen]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-bg-secondary">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!user) return <Navigate to="/login" replace />;
if (user.role !== 'ADMIN') return <Navigate to="/dashboard" replace />;
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
return (
<div className="min-h-screen bg-bg-secondary flex">
{/* Desktop Sidebar */}
<aside className="hidden lg:flex flex-col w-[220px] border-r border-border-default bg-bg-sidebar shrink-0 fixed inset-y-0 left-0 z-30">
<SidebarContent initials={initials} user={user} logout={logout} />
</aside>
{/* Mobile overlay */}
{mobileOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div className="absolute inset-0 bg-overlay" />
<aside ref={sidebarRef} className="absolute inset-y-0 left-0 w-[260px] bg-bg-sidebar border-r border-border-default animate-slide-up flex flex-col">
<SidebarContent initials={initials} user={user} logout={logout} onNavClick={() => setMobileOpen(false)} />
</aside>
</div>
)}
{/* Main area */}
<div className="flex-1 lg:ml-[220px] flex flex-col min-h-screen">
{/* Top bar */}
<header className="h-14 border-b border-border-default bg-bg-primary flex items-center justify-between px-4 lg:px-6 shrink-0 sticky top-0 z-20">
<div className="flex items-center gap-3">
<button className="lg:hidden p-1.5 rounded-lg hover:bg-bg-tertiary transition-colors" onClick={() => setMobileOpen(true)}>
<svg className="w-5 h-5 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
<h1 className="text-sm font-semibold text-text-primary">Admin</h1>
</div>
<div className="flex items-center gap-2">
<Link to="/dashboard" className="text-xs text-text-muted hover:text-text-secondary transition-colors px-2 py-1 rounded-md hover:bg-bg-tertiary">
</Link>
<ThemeToggle />
</div>
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto p-4 lg:p-6">
<Outlet />
</main>
</div>
</div>
);
}
function SidebarContent({
initials, user, logout, onNavClick,
}: {
initials: string;
user: { name: string; email: string };
logout: () => void;
onNavClick?: () => void;
}) {
return (
<>
{/* Brand */}
<div className="h-14 flex items-center px-4 border-b border-border-muted shrink-0">
<Link to="/admin" className="flex items-center gap-2.5" onClick={onNavClick}>
<div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
</div>
<span className="font-heading text-[15px] font-bold text-text-primary tracking-tight">Agent Fox</span>
<span className="text-[10px] font-semibold uppercase tracking-widest text-accent bg-accent-muted px-1.5 py-0.5 rounded">Admin</span>
</Link>
</div>
{/* Nav */}
<nav className="flex-1 px-2.5 py-3 space-y-0.5 overflow-y-auto">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
onClick={onNavClick}
className={({ isActive }) =>
`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[13px] font-medium transition-all duration-150 ${
isActive
? 'bg-accent-muted text-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-bg-tertiary'
}`
}
>
{item.icon}
{item.label}
</NavLink>
))}
</nav>
{/* User section at bottom */}
<div className="border-t border-border-muted p-3 shrink-0">
<div className="flex items-center gap-2.5 mb-2">
<div className="w-8 h-8 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[10px] font-bold tracking-wide shrink-0">
{initials}
</div>
<div className="min-w-0 flex-1">
<div className="text-[12px] font-medium text-text-primary truncate">{user.name}</div>
<div className="text-[10px] text-text-muted truncate">{user.email}</div>
</div>
</div>
<button
onClick={logout}
className="flex items-center gap-2 w-full px-2.5 py-1.5 rounded-lg text-[12px] text-text-muted hover:text-danger hover:bg-danger-muted transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
</svg>
退
</button>
</div>
</>
);
}

View File

@@ -0,0 +1,172 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
type CallLogItem = {
id: string;
toolName: string;
calledAt: string;
durationMs: number;
success: boolean;
responseSize: number;
clientIp: string;
estimatedTokens: number | null;
project: { id: string; name: string };
};
type CallLogsResponse = {
logs: CallLogItem[];
total: number;
page: number;
limit: number;
};
const TOOL_NAMES = [
'get_project_overview',
'list_modules',
'list_endpoints',
'get_endpoint_detail',
'search_endpoints',
];
export default function CallLogs() {
const [page, setPage] = useState(1);
const [toolName, setToolName] = useState('');
const [successFilter, setSuccessFilter] = useState('');
const limit = 30;
const { data, isLoading } = useQuery({
queryKey: ['admin', 'call-logs', page, toolName, successFilter],
queryFn: () => {
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (toolName) params.set('toolName', toolName);
if (successFilter) params.set('success', successFilter);
return apiFetch<CallLogsResponse>(`/admin/call-logs?${params}`);
},
});
const totalPages = data ? Math.ceil(data.total / limit) : 0;
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>
{/* Filters */}
<div className="flex flex-wrap gap-2">
<select
value={toolName}
onChange={(e) => { setToolName(e.target.value); setPage(1); }}
className="input-base w-auto text-[13px]"
>
<option value=""></option>
{TOOL_NAMES.map(name => (
<option key={name} value={name}>{name}</option>
))}
</select>
<select
value={successFilter}
onChange={(e) => { setSuccessFilter(e.target.value); setPage(1); }}
className="input-base w-auto text-[13px]"
>
<option value=""></option>
<option value="true"></option>
<option value="false"></option>
</select>
{(toolName || successFilter) && (
<button
className="btn-ghost text-[13px] px-3"
onClick={() => { setToolName(''); setSuccessFilter(''); setPage(1); }}
>
</button>
)}
</div>
{/* 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">Token</th>
<th className="text-left px-4 py-3 font-medium text-text-muted"> IP</th>
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-border-muted">
{Array.from({ length: 8 }).map((_, j) => (
<td key={j} className="px-4 py-3"><div className="skeleton h-4 w-16" /></td>
))}
</tr>
))
) : data?.logs.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-12 text-center text-text-muted"></td>
</tr>
) : (
data?.logs.map((log) => (
<tr key={log.id} className="border-b border-border-muted hover:bg-bg-secondary/50 transition-colors">
<td className="px-4 py-3 text-text-muted whitespace-nowrap">
{new Date(log.calledAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</td>
<td className="px-4 py-3 text-text-secondary max-w-[150px] truncate">{log.project.name}</td>
<td className="px-4 py-3">
<span className="font-mono text-[11px] text-accent">{log.toolName}</span>
</td>
<td className="px-4 py-3">
<span className={`font-mono text-[12px] ${log.durationMs > 1000 ? 'text-warning' : 'text-text-secondary'}`}>
{log.durationMs}ms
</span>
</td>
<td className="px-4 py-3 text-text-muted font-mono text-[12px]">
{formatBytes(log.responseSize)}
</td>
<td className="px-4 py-3 text-text-muted font-mono text-[12px]">
{log.estimatedTokens != null ? log.estimatedTokens.toLocaleString() : '—'}
</td>
<td className="px-4 py-3 text-text-muted font-mono text-[11px]">{log.clientIp || '—'}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1 text-[11px] font-medium ${log.success ? 'text-success' : 'text-danger'}`}>
<span className={`w-1.5 h-1.5 rounded-full ${log.success ? 'bg-success' : 'bg-danger'}`} />
{log.success ? '成功' : '失败'}
</span>
</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>
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}

View File

@@ -0,0 +1,243 @@
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
type Stats = {
totalUsers: number;
todayUsers: number;
totalProjects: number;
todayProjects: number;
totalCalls: number;
todayCalls: number;
avgResponseTime: number;
successRate: number;
activeUsers: number;
};
type TrendPoint = {
date: string;
calls: number;
successRate: number;
avgDuration: number;
};
type RecentCall = {
id: string;
toolName: string;
calledAt: string;
durationMs: number;
success: boolean;
project: { name: string };
};
export default function Dashboard() {
const { data: stats, isLoading: statsLoading } = useQuery({
queryKey: ['admin', 'stats'],
queryFn: () => apiFetch<Stats>('/admin/stats'),
refetchInterval: 30000,
});
const { data: trends } = useQuery({
queryKey: ['admin', 'trends'],
queryFn: () => apiFetch<TrendPoint[]>('/admin/stats/trends'),
refetchInterval: 60000,
});
const { data: recentCalls } = useQuery({
queryKey: ['admin', 'recent-calls'],
queryFn: () => apiFetch<RecentCall[]>('/admin/call-logs/recent'),
refetchInterval: 15000,
});
if (statsLoading) {
return (
<div className="space-y-6 animate-fade-in">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="skeleton h-[108px] rounded-xl" />
))}
</div>
<div className="skeleton h-[280px] rounded-xl" />
</div>
);
}
const statCards = [
{
label: '注册用户',
value: stats?.totalUsers ?? 0,
sub: `今日 +${stats?.todayUsers ?? 0}`,
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
),
color: 'text-blue-500',
bg: 'bg-blue-500/10',
},
{
label: '项目数',
value: stats?.totalProjects ?? 0,
sub: `今日 +${stats?.todayProjects ?? 0}`,
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
),
color: 'text-amber-500',
bg: 'bg-amber-500/10',
},
{
label: 'MCP 调用',
value: stats?.totalCalls ?? 0,
sub: `今日 ${stats?.todayCalls ?? 0}`,
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
</svg>
),
color: 'text-emerald-500',
bg: 'bg-emerald-500/10',
},
{
label: '活跃用户 (7天)',
value: stats?.activeUsers ?? 0,
sub: '有 MCP 调用',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
color: 'text-violet-500',
bg: 'bg-violet-500/10',
},
{
label: '平均响应时间',
value: `${stats?.avgResponseTime ?? 0}ms`,
sub: '近 7 天',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
color: 'text-cyan-500',
bg: 'bg-cyan-500/10',
},
{
label: '调用成功率',
value: `${stats?.successRate ?? 100}%`,
sub: '近 7 天',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
color: (stats?.successRate ?? 100) >= 95 ? 'text-emerald-500' : 'text-amber-500',
bg: (stats?.successRate ?? 100) >= 95 ? 'bg-emerald-500/10' : 'bg-amber-500/10',
},
];
const maxCalls = Math.max(...(trends?.map(t => t.calls) ?? [1]), 1);
return (
<div className="space-y-6 animate-fade-in">
{/* Stats Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 stagger-children">
{statCards.map((card) => (
<div key={card.label} className="card p-4">
<div className={`w-9 h-9 rounded-lg ${card.bg} ${card.color} flex items-center justify-center mb-3`}>
{card.icon}
</div>
<div className="text-2xl font-bold text-text-primary font-heading tracking-tight">
{typeof card.value === 'number' ? card.value.toLocaleString() : card.value}
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-[12px] text-text-muted">{card.label}</span>
<span className="text-[11px] text-text-muted">{card.sub}</span>
</div>
</div>
))}
</div>
{/* Trend Chart + Recent Calls */}
<div className="grid grid-cols-1 xl:grid-cols-5 gap-4">
{/* Trend Chart */}
<div className="xl:col-span-3 card p-5">
<h3 className="section-title mb-4">7 </h3>
{trends && trends.length > 0 ? (
<div className="flex items-end gap-1.5 h-[180px]">
{trends.map((point) => {
const height = maxCalls > 0 ? (point.calls / maxCalls) * 100 : 0;
return (
<div key={point.date} className="flex-1 flex flex-col items-center gap-1 group relative">
{/* Tooltip */}
<div className="absolute bottom-full mb-2 hidden group-hover:block z-10">
<div className="bg-bg-elevated border border-border-default rounded-lg shadow-lg px-3 py-2 text-[11px] whitespace-nowrap">
<div className="font-medium text-text-primary">{point.calls} </div>
<div className="text-text-muted"> {point.successRate}%</div>
<div className="text-text-muted"> {point.avgDuration}ms</div>
</div>
</div>
{/* Bar */}
<div className="w-full flex-1 flex items-end">
<div
className="w-full rounded-t-md bg-accent/70 hover:bg-accent transition-colors cursor-default"
style={{
height: `${Math.max(height, 2)}%`,
transition: 'height 0.5s cubic-bezier(0.16, 1, 0.3, 1)',
}}
/>
</div>
<span className="text-[9px] text-text-muted">{point.date.slice(5)}</span>
</div>
);
})}
</div>
) : (
<div className="h-[180px] flex items-center justify-center text-[13px] text-text-muted">
</div>
)}
</div>
{/* Recent Calls */}
<div className="xl:col-span-2 card p-5">
<h3 className="section-title mb-4"></h3>
{recentCalls && recentCalls.length > 0 ? (
<div className="space-y-2.5">
{recentCalls.map((call) => (
<div key={call.id} className="flex items-center gap-3 text-[12px]">
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${call.success ? 'bg-success' : 'bg-danger'}`} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="font-mono text-text-primary font-medium">{call.toolName}</span>
<span className="text-text-muted">· {call.durationMs}ms</span>
</div>
<div className="text-text-muted truncate">{call.project.name}</div>
</div>
<span className="text-[10px] text-text-muted shrink-0">
{formatTimeAgo(call.calledAt)}
</span>
</div>
))}
</div>
) : (
<div className="h-[180px] flex items-center justify-center text-[13px] text-text-muted">
</div>
)}
</div>
</div>
</div>
);
}
function formatTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return '刚刚';
if (mins < 60) return `${mins}分钟前`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}小时前`;
return `${Math.floor(hours / 24)}天前`;
}

View File

@@ -0,0 +1,152 @@
import { useParams, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
import ConfirmDialog from '../../components/ConfirmDialog';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
type ProjectDetailData = {
id: string;
name: string;
description: string | null;
baseUrl: string | null;
openApiVersion: string;
createdAt: string;
updatedAt: string;
user: { id: string; name: string; email: string };
modules: {
id: string;
name: string;
description: string | null;
source: string;
_count: { endpoints: number };
}[];
_count: { endpoints: number; modules: number; mcpCallLogs: number };
};
export default function AdminProjectDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [confirmDelete, setConfirmDelete] = useState(false);
const { data: project, isLoading } = useQuery({
queryKey: ['admin', 'project', id],
queryFn: () => apiFetch<ProjectDetailData>(`/admin/projects/${id}`),
});
const deleteProject = useMutation({
mutationFn: () => apiFetch(`/admin/projects/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'projects'] });
navigate('/admin/projects');
},
});
if (isLoading) {
return (
<div className="space-y-4 animate-fade-in">
<div className="skeleton h-6 w-32" />
<div className="skeleton h-[200px] rounded-xl" />
</div>
);
}
if (!project) {
return <div className="text-center py-20 text-text-muted"></div>;
}
return (
<div className="space-y-5 animate-fade-in max-w-3xl">
{/* Breadcrumb */}
<div className="flex items-center gap-1.5 text-[12px] text-text-muted">
<Link to="/admin/projects" className="hover:text-text-secondary transition-colors"></Link>
<span>/</span>
<span className="text-text-secondary">{project.name}</span>
</div>
{/* Project Info */}
<div className="card p-5">
<div className="flex items-start justify-between">
<div>
<h2 className="text-lg font-bold text-text-primary font-heading">{project.name}</h2>
{project.description && <p className="text-[13px] text-text-muted mt-1">{project.description}</p>}
</div>
<button onClick={() => setConfirmDelete(true)} className="btn-danger text-[12px] px-3 py-1.5">
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-5 pt-5 border-t border-border-muted">
<InfoItem label="所有者">
<Link to={`/admin/users/${project.user.id}`} className="text-accent hover:text-accent-hover transition-colors">
{project.user.name}
</Link>
</InfoItem>
<InfoItem label="OpenAPI 版本" value={project.openApiVersion} mono />
<InfoItem label="Base URL" value={project.baseUrl || '—'} mono />
<InfoItem label="创建时间" value={new Date(project.createdAt).toLocaleDateString('zh-CN')} />
</div>
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-border-muted">
<StatBadge label="模块" value={project._count.modules} />
<StatBadge label="端点" value={project._count.endpoints} />
<StatBadge label="MCP 调用" value={project._count.mcpCallLogs} />
</div>
</div>
{/* Modules */}
<div className="card">
<div className="px-5 py-3 border-b border-border-muted">
<h3 className="section-title"> ({project.modules.length})</h3>
</div>
{project.modules.length === 0 ? (
<div className="px-5 py-10 text-center text-[13px] text-text-muted"></div>
) : (
<div className="divide-y divide-border-muted">
{project.modules.map((mod) => (
<div key={mod.id} className="flex items-center justify-between px-5 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-text-primary">{mod.name}</span>
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-bg-tertiary text-text-muted">{mod.source}</span>
</div>
{mod.description && <div className="text-[12px] text-text-muted truncate mt-0.5">{mod.description}</div>}
</div>
<span className="text-[12px] text-text-muted shrink-0 ml-4">{mod._count.endpoints} </span>
</div>
))}
</div>
)}
</div>
<ConfirmDialog
open={confirmDelete}
title="删除项目"
description={`确定要删除项目 "${project.name}" 吗?此操作不可恢复,项目下的所有模块、端点和调用日志都将被删除。`}
variant="danger"
confirmText="删除"
onConfirm={() => deleteProject.mutate()}
onCancel={() => setConfirmDelete(false)}
/>
</div>
);
}
function InfoItem({ label, value, mono, children }: { label: string; value?: string; mono?: boolean; children?: React.ReactNode }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider text-text-muted font-medium mb-0.5">{label}</div>
{children || <div className={`text-[13px] text-text-primary ${mono ? 'font-mono text-[12px]' : ''} truncate`}>{value}</div>}
</div>
);
}
function StatBadge({ label, value }: { label: string; value: number }) {
return (
<div className="text-center">
<div className="text-xl font-bold text-text-primary font-heading">{value.toLocaleString()}</div>
<div className="text-[11px] text-text-muted mt-0.5">{label}</div>
</div>
);
}

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

View File

@@ -0,0 +1,175 @@
import { useParams, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../lib/api';
import { useAuth } from '../../lib/auth';
import ConfirmDialog from '../../components/ConfirmDialog';
import { useState } from 'react';
type UserDetailData = {
id: string;
email: string;
name: string;
role: string;
disabled: boolean;
createdAt: string;
avatarUrl: string | null;
oauthAccounts: { provider: string; createdAt: string }[];
projects: {
id: string;
name: string;
description: string | null;
createdAt: string;
_count: { endpoints: number; modules: number };
}[];
};
export default function UserDetail() {
const { id } = useParams<{ id: string }>();
const { user: currentUser } = useAuth();
const queryClient = useQueryClient();
const [confirmDisable, setConfirmDisable] = useState(false);
const { data: user, isLoading } = useQuery({
queryKey: ['admin', 'user', id],
queryFn: () => apiFetch<UserDetailData>(`/admin/users/${id}`),
});
const toggleDisable = useMutation({
mutationFn: () => apiFetch(`/admin/users/${id}/disable`, {
method: 'PATCH',
body: JSON.stringify({ disabled: !user?.disabled }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'user', id] });
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
setConfirmDisable(false);
},
});
if (isLoading) {
return (
<div className="space-y-4 animate-fade-in">
<div className="skeleton h-6 w-32" />
<div className="skeleton h-[200px] rounded-xl" />
<div className="skeleton h-[200px] rounded-xl" />
</div>
);
}
if (!user) {
return (
<div className="text-center py-20 text-text-muted">
</div>
);
}
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
const isSelf = currentUser?.id === user.id;
return (
<div className="space-y-5 animate-fade-in max-w-3xl">
{/* Breadcrumb */}
<div className="flex items-center gap-1.5 text-[12px] text-text-muted">
<Link to="/admin/users" className="hover:text-text-secondary transition-colors"></Link>
<span>/</span>
<span className="text-text-secondary">{user.name}</span>
</div>
{/* User Info Card */}
<div className="card p-5">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-lg font-bold">
{initials}
</div>
<div>
<h2 className="text-lg font-bold text-text-primary font-heading">{user.name}</h2>
<div className="text-[13px] text-text-muted mt-0.5">{user.email}</div>
<div className="flex items-center gap-2 mt-2">
<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>
<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>
</div>
</div>
</div>
{!isSelf && (
<button
onClick={() => setConfirmDisable(true)}
className={user.disabled ? 'btn-primary text-[12px] px-3 py-1.5' : 'btn-danger text-[12px] px-3 py-1.5'}
>
{user.disabled ? '启用账号' : '禁用账号'}
</button>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-5 pt-5 border-t border-border-muted">
<InfoItem label="注册时间" value={new Date(user.createdAt).toLocaleDateString('zh-CN')} />
<InfoItem label="项目数" value={String(user.projects.length)} />
<InfoItem label="OAuth 账号" value={user.oauthAccounts.map(a => a.provider).join(', ') || '无'} />
<InfoItem label="ID" value={user.id.slice(0, 8) + '...'} mono />
</div>
</div>
{/* Projects */}
<div className="card">
<div className="px-5 py-3 border-b border-border-muted">
<h3 className="section-title"> ({user.projects.length})</h3>
</div>
{user.projects.length === 0 ? (
<div className="px-5 py-10 text-center text-[13px] text-text-muted"></div>
) : (
<div className="divide-y divide-border-muted">
{user.projects.map((project) => (
<Link
key={project.id}
to={`/admin/projects/${project.id}`}
className="flex items-center justify-between px-5 py-3 hover:bg-bg-secondary/50 transition-colors"
>
<div className="min-w-0">
<div className="text-[13px] font-medium text-text-primary">{project.name}</div>
{project.description && <div className="text-[12px] text-text-muted truncate mt-0.5">{project.description}</div>}
</div>
<div className="flex items-center gap-4 text-[11px] text-text-muted shrink-0 ml-4">
<span>{project._count.modules} </span>
<span>{project._count.endpoints} </span>
<span>{new Date(project.createdAt).toLocaleDateString('zh-CN')}</span>
</div>
</Link>
))}
</div>
)}
</div>
{/* Confirm Dialog */}
<ConfirmDialog
open={confirmDisable}
title={user.disabled ? '启用账号' : '禁用账号'}
description={user.disabled
? `确定要启用用户 "${user.name}" 的账号吗?启用后该用户可以正常登录和使用系统。`
: `确定要禁用用户 "${user.name}" 的账号吗?禁用后该用户将无法登录。`
}
variant={user.disabled ? 'warning' : 'danger'}
confirmText={user.disabled ? '启用' : '禁用'}
onConfirm={() => toggleDisable.mutate()}
onCancel={() => setConfirmDisable(false)}
/>
</div>
);
}
function InfoItem({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider text-text-muted font-medium mb-0.5">{label}</div>
<div className={`text-[13px] text-text-primary ${mono ? 'font-mono' : ''}`}>{value}</div>
</div>
);
}

View File

@@ -0,0 +1,166 @@
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>
);
}

View File

@@ -0,0 +1,34 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'USER';
ALTER TABLE "User" ADD COLUMN "disabled" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "McpCallLog" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"toolName" TEXT NOT NULL,
"calledAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"durationMs" INTEGER NOT NULL,
"success" BOOLEAN NOT NULL,
"requestParams" JSONB NOT NULL DEFAULT '{}',
"responseSize" INTEGER NOT NULL DEFAULT 0,
"clientIp" TEXT NOT NULL DEFAULT '',
"estimatedTokens" INTEGER,
CONSTRAINT "McpCallLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "McpCallLog_projectId_idx" ON "McpCallLog"("projectId");
-- CreateIndex
CREATE INDEX "McpCallLog_calledAt_idx" ON "McpCallLog"("calledAt");
-- CreateIndex
CREATE INDEX "McpCallLog_toolName_idx" ON "McpCallLog"("toolName");
-- AddForeignKey
ALTER TABLE "McpCallLog" ADD CONSTRAINT "McpCallLog_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -7,12 +7,19 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
enum Role {
USER
ADMIN
}
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String @unique
passwordHash String? passwordHash String?
name String name String
avatarUrl String? avatarUrl String?
role Role @default(USER)
disabled Boolean @default(false)
apiKeyHash String? apiKeyHash String?
apiKeyEncrypted String? apiKeyEncrypted String?
apiKeyPrefix String? apiKeyPrefix String?
@@ -33,6 +40,24 @@ model OAuthAccount {
@@unique([provider, providerAccountId]) @@unique([provider, providerAccountId])
} }
model McpCallLog {
id String @id @default(uuid())
projectId String
toolName String
calledAt DateTime @default(now())
durationMs Int
success Boolean
requestParams Json @default("{}")
responseSize Int @default(0)
clientIp String @default("")
estimatedTokens Int?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId])
@@index([calledAt])
@@index([toolName])
}
model Project { model Project {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
@@ -46,6 +71,7 @@ model Project {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
modules Module[] modules Module[]
endpoints Endpoint[] endpoints Endpoint[]
mcpCallLogs McpCallLog[]
} }
enum ModuleSource { enum ModuleSource {