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:
108
docs/superpowers/specs/2026-04-04-admin-dashboard-design.md
Normal file
108
docs/superpowers/specs/2026-04-04-admin-dashboard-design.md
Normal 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
|
||||
@@ -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 transport.handleRequest(req, res, req.body);
|
||||
});
|
||||
|
||||
45
packages/mcp/src/lib/call-logger.ts
Normal file
45
packages/mcp/src/lib/call-logger.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,39 +5,43 @@ import { listModules } from './tools/list-modules.js';
|
||||
import { listEndpoints } from './tools/list-endpoints.js';
|
||||
import { getEndpointDetail } from './tools/get-endpoint-detail.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({
|
||||
name: 'agent-fox',
|
||||
version: '0.1.0',
|
||||
});
|
||||
|
||||
const ctx = (toolName: string, requestParams: Record<string, unknown> = {}) =>
|
||||
({ projectId, toolName, requestParams, clientIp });
|
||||
|
||||
server.tool(
|
||||
'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.',
|
||||
{},
|
||||
async () => getProjectOverview(projectId),
|
||||
async () => logMcpCall(ctx('get_project_overview'), () => getProjectOverview(projectId)),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'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.',
|
||||
{},
|
||||
async () => listModules(projectId),
|
||||
async () => logMcpCall(ctx('list_modules'), () => listModules(projectId)),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'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.',
|
||||
{ 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(
|
||||
'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.',
|
||||
{ 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(
|
||||
@@ -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.'),
|
||||
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;
|
||||
|
||||
@@ -7,6 +7,7 @@ import importRouter from './routes/import.js';
|
||||
import moduleRouter from './routes/modules.js';
|
||||
import endpointRouter from './routes/endpoints.js';
|
||||
import fetchSpecRouter from './routes/fetch-spec.js';
|
||||
import adminRouter from './routes/admin.js';
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
@@ -24,6 +25,7 @@ app.use('/api/projects', projectRouter);
|
||||
app.use('/api/projects', importRouter);
|
||||
app.use('/api/projects', moduleRouter);
|
||||
app.use('/api/projects', endpointRouter);
|
||||
app.use('/api/admin', adminRouter);
|
||||
|
||||
const port = process.env.SERVER_PORT || 3000;
|
||||
app.listen(port, () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { Role } from '@agent-fox/shared';
|
||||
|
||||
const ACCESS_SECRET = process.env.JWT_SECRET || 'dev-secret';
|
||||
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret';
|
||||
@@ -8,6 +9,7 @@ const REFRESH_EXPIRY = '7d';
|
||||
export type TokenPayload = {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
};
|
||||
|
||||
export function generateAccessToken(payload: TokenPayload): string {
|
||||
|
||||
9
packages/server/src/middleware/admin.ts
Normal file
9
packages/server/src/middleware/admin.ts
Normal 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();
|
||||
}
|
||||
305
packages/server/src/routes/admin.ts
Normal file
305
packages/server/src/routes/admin.ts
Normal 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;
|
||||
@@ -40,8 +40,8 @@ router.post('/register', async (req, res) => {
|
||||
data: { email, passwordHash, name },
|
||||
});
|
||||
|
||||
const tokens = generateTokenPair({ userId: user.id, email: user.email });
|
||||
res.status(201).json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } });
|
||||
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, role: user.role }, ...tokens } });
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
@@ -59,14 +59,19 @@ router.post('/login', async (req, res) => {
|
||||
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);
|
||||
if (!valid) {
|
||||
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const tokens = generateTokenPair({ userId: user.id, email: user.email });
|
||||
res.json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } });
|
||||
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, role: user.role }, ...tokens } });
|
||||
});
|
||||
|
||||
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' } });
|
||||
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 });
|
||||
} catch {
|
||||
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) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
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) {
|
||||
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
|
||||
|
||||
@@ -72,7 +72,11 @@ async function handleOAuthCallback(
|
||||
}
|
||||
|
||||
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)}` : '';
|
||||
res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}${redirectParam}`);
|
||||
|
||||
@@ -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> = {
|
||||
success: boolean;
|
||||
|
||||
@@ -7,12 +7,14 @@ server {
|
||||
proxy_pass http://server:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location /mcp/ {
|
||||
proxy_pass http://mcp:3001;
|
||||
proxy_set_header Host $host;
|
||||
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_set_header Connection '';
|
||||
proxy_buffering off;
|
||||
|
||||
@@ -9,6 +9,14 @@ import Layout from './pages/Layout';
|
||||
import Projects from './pages/Projects';
|
||||
import ProjectDetail from './pages/ProjectDetail';
|
||||
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();
|
||||
|
||||
export default function App() {
|
||||
@@ -26,6 +34,14 @@ export default function App() {
|
||||
<Route index element={<Projects />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
</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 />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -302,6 +302,13 @@ body {
|
||||
&::placeholder { color: var(--text-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 {
|
||||
@apply rounded-xl transition-all duration-200;
|
||||
background: var(--bg-elevated);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
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 = {
|
||||
user: User | null;
|
||||
|
||||
@@ -17,7 +17,7 @@ type ProjectSummary = {
|
||||
_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 [confirmLogout, setConfirmLogout] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -67,6 +67,19 @@ function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string;
|
||||
|
||||
{/* Actions */}
|
||||
<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
|
||||
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"
|
||||
|
||||
187
packages/web/src/pages/admin/AdminLayout.tsx
Normal file
187
packages/web/src/pages/admin/AdminLayout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
172
packages/web/src/pages/admin/CallLogs.tsx
Normal file
172
packages/web/src/pages/admin/CallLogs.tsx
Normal 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`;
|
||||
}
|
||||
243
packages/web/src/pages/admin/Dashboard.tsx
Normal file
243
packages/web/src/pages/admin/Dashboard.tsx
Normal 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)}天前`;
|
||||
}
|
||||
152
packages/web/src/pages/admin/ProjectDetail.tsx
Normal file
152
packages/web/src/pages/admin/ProjectDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
packages/web/src/pages/admin/Projects.tsx
Normal file
144
packages/web/src/pages/admin/Projects.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
packages/web/src/pages/admin/UserDetail.tsx
Normal file
175
packages/web/src/pages/admin/UserDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
packages/web/src/pages/admin/Users.tsx
Normal file
166
packages/web/src/pages/admin/Users.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -7,12 +7,19 @@ datasource db {
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum Role {
|
||||
USER
|
||||
ADMIN
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
passwordHash String?
|
||||
name String
|
||||
avatarUrl String?
|
||||
role Role @default(USER)
|
||||
disabled Boolean @default(false)
|
||||
apiKeyHash String?
|
||||
apiKeyEncrypted String?
|
||||
apiKeyPrefix String?
|
||||
@@ -33,6 +40,24 @@ model OAuthAccount {
|
||||
@@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 {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
@@ -46,6 +71,7 @@ model Project {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
modules Module[]
|
||||
endpoints Endpoint[]
|
||||
mcpCallLogs McpCallLog[]
|
||||
}
|
||||
|
||||
enum ModuleSource {
|
||||
|
||||
Reference in New Issue
Block a user