Compare commits
6 Commits
5d199c4c5c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 71c604411d | |||
| 6fe04f4893 | |||
| d45cc45815 | |||
| f3fbd3876a | |||
| 49ca1f6e1f | |||
| d1ee0bbad2 |
@@ -2,5 +2,11 @@ node_modules
|
||||
.worktrees
|
||||
.claude
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
dist
|
||||
docs
|
||||
*.zip
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
19
.env.example
19
.env.example
@@ -1,18 +1,9 @@
|
||||
DATABASE_URL=postgresql://agentfox:agentfox@localhost:5432/agentfox
|
||||
JWT_SECRET=change-me-to-a-random-secret
|
||||
JWT_REFRESH_SECRET=change-me-to-another-random-secret
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
API_KEY_ENCRYPTION_SECRET=change-me-to-a-64-char-hex-string
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
MCP_BASE_URL=http://localhost:3001
|
||||
SERVER_PORT=3000
|
||||
MCP_PORT=3001
|
||||
WEB_PORT=5173
|
||||
REDIS_URL=redis://localhost:6379
|
||||
APPLE_CLIENT_ID=
|
||||
APPLE_TEAM_ID=
|
||||
APPLE_KEY_ID=
|
||||
APPLE_PRIVATE_KEY=
|
||||
OAUTH_CALLBACK_BASE_URL=http://localhost:3000
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
OAUTH_CALLBACK_BASE_URL=https://your-domain.com
|
||||
FRONTEND_URL=https://your-domain.com
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -38,7 +38,8 @@ pnpm monorepo with 4 packages sharing TypeScript config (`tsconfig.base.json`):
|
||||
### Data Flow
|
||||
|
||||
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
|
||||
4. User gets a project ID + 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 } }`
|
||||
- **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)`.
|
||||
- **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`)
|
||||
|
||||
|
||||
@@ -3,10 +3,6 @@ services:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
redis:
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
@@ -28,7 +24,6 @@ services:
|
||||
- ./prisma:/app/prisma
|
||||
environment:
|
||||
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
|
||||
REDIS_URL: redis://redis:6379
|
||||
JWT_SECRET: dev-secret
|
||||
JWT_REFRESH_SECRET: dev-refresh-secret
|
||||
SERVER_PORT: "3000"
|
||||
@@ -53,10 +48,29 @@ services:
|
||||
- ./prisma:/app/prisma
|
||||
environment:
|
||||
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
|
||||
REDIS_URL: redis://redis:6379
|
||||
MCP_PORT: "3001"
|
||||
NODE_ENV: development
|
||||
|
||||
docs:
|
||||
build:
|
||||
context: ./docs
|
||||
dockerfile: Dockerfile
|
||||
target: build
|
||||
command: >
|
||||
sh -c "honkit serve --port 4000"
|
||||
volumes:
|
||||
- ./docs/book.json:/book/book.json
|
||||
- ./docs/SUMMARY.md:/book/SUMMARY.md
|
||||
- ./docs/README.md:/book/README.md
|
||||
- ./docs/faq.md:/book/faq.md
|
||||
- ./docs/introduction:/book/introduction
|
||||
- ./docs/getting-started:/book/getting-started
|
||||
- ./docs/mcp-tools:/book/mcp-tools
|
||||
- ./docs/clients:/book/clients
|
||||
- ./docs/project-management:/book/project-management
|
||||
ports:
|
||||
- "4000:4000"
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
|
||||
@@ -7,33 +7,18 @@ services:
|
||||
POSTGRES_DB: agentfox
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U agentfox"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: packages/server/Dockerfile
|
||||
environment:
|
||||
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
|
||||
REDIS_URL: redis://redis:6379
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-refresh-in-production}
|
||||
API_KEY_ENCRYPTION_SECRET: ${API_KEY_ENCRYPTION_SECRET:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}
|
||||
@@ -49,8 +34,12 @@ services:
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
mcp:
|
||||
build:
|
||||
@@ -58,16 +47,18 @@ services:
|
||||
dockerfile: packages/mcp/Dockerfile
|
||||
environment:
|
||||
DATABASE_URL: postgresql://agentfox:agentfox@postgres:5432/agentfox
|
||||
REDIS_URL: redis://redis:6379
|
||||
MCP_PORT: "3001"
|
||||
ports:
|
||||
- "3001:3001"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
server:
|
||||
condition: service_healthy
|
||||
|
||||
docs:
|
||||
build:
|
||||
context: ./docs
|
||||
dockerfile: Dockerfile
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
@@ -77,7 +68,7 @@ services:
|
||||
depends_on:
|
||||
- server
|
||||
- mcp
|
||||
- docs
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
redisdata:
|
||||
|
||||
6
docs/.dockerignore
Normal file
6
docs/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.worktrees
|
||||
.claude
|
||||
.git
|
||||
dist
|
||||
*.zip
|
||||
26
docs/Dockerfile
Normal file
26
docs/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
# Stage 1: Build environment with HonKit
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /book
|
||||
|
||||
# Install HonKit globally
|
||||
RUN npm install -g honkit
|
||||
|
||||
# Copy GitBook source files
|
||||
COPY book.json SUMMARY.md README.md faq.md ./
|
||||
COPY introduction/ ./introduction/
|
||||
COPY getting-started/ ./getting-started/
|
||||
COPY mcp-tools/ ./mcp-tools/
|
||||
COPY clients/ ./clients/
|
||||
COPY project-management/ ./project-management/
|
||||
|
||||
# Build static HTML
|
||||
RUN honkit build . /output
|
||||
|
||||
# Stage 2: Serve with nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /output /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
39
docs/README.md
Normal file
39
docs/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# AgentFox 文档
|
||||
|
||||
> **AgentFox** — 为 LLM 而生的 API 文档服务
|
||||
|
||||
AgentFox 将你的 OpenAPI / Swagger 文档转换为 MCP(Model Context Protocol)服务,让 AI 编程助手能够按需查询 API 文档,而不是将整个规范塞进上下文窗口。
|
||||
|
||||
## 为什么选择 AgentFox?
|
||||
|
||||
| 传统方式 | AgentFox |
|
||||
|---------|----------|
|
||||
| 将完整 API 规范粘贴到对话中 | LLM 通过 MCP 按需查询 |
|
||||
| 每次消耗 10,000+ tokens | 每次调用仅 200-2,000 tokens |
|
||||
| 手动复制粘贴,容易遗漏 | 5 个工具自动渐进式下钻 |
|
||||
|
||||
## 快速导航
|
||||
|
||||
- **[什么是 AgentFox](introduction/what-is-agentfox.md)** — 了解产品核心概念
|
||||
- **[MCP 协议介绍](introduction/what-is-mcp.md)** — 了解底层协议
|
||||
- **[快速开始](getting-started/README.md)** — 5 分钟完成首次配置
|
||||
- **[客户端配置](clients/README.md)** — Claude Code、Cursor、Copilot 等配置指南
|
||||
- **[MCP 工具](mcp-tools/README.md)** — 5 个 MCP 工具详细文档
|
||||
- **[常见问题](faq.md)** — FAQ
|
||||
|
||||
## 支持的 AI 工具
|
||||
|
||||
AgentFox 兼容所有支持 MCP 协议的 AI 工具,包括:
|
||||
|
||||
- Claude Desktop / Claude Code
|
||||
- Cursor
|
||||
- GitHub Copilot
|
||||
- Windsurf
|
||||
- Cline
|
||||
- OpenAI Codex CLI
|
||||
- Gemini CLI
|
||||
- 以及更多...
|
||||
|
||||
---
|
||||
|
||||
访问 [www.agentfoxapp.com](https://www.agentfoxapp.com) 免费开始使用。
|
||||
47
docs/SUMMARY.md
Normal file
47
docs/SUMMARY.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 目录
|
||||
|
||||
* [欢迎使用 AgentFox](README.md)
|
||||
|
||||
## 介绍 <a href="#introduction" id="introduction"></a>
|
||||
|
||||
* [什么是 AgentFox](introduction/what-is-agentfox.md)
|
||||
* [MCP 协议介绍](introduction/what-is-mcp.md)
|
||||
|
||||
## 快速开始 <a href="#getting-started" id="getting-started"></a>
|
||||
|
||||
* [概述](getting-started/README.md)
|
||||
* [注册与登录](getting-started/register-and-login.md)
|
||||
* [导入 API 文档](getting-started/import-api-docs.md)
|
||||
* [生成 API Key](getting-started/generate-api-key.md)
|
||||
* [连接第一个 LLM 客户端](getting-started/connect-first-client.md)
|
||||
|
||||
## MCP 工具 <a href="#mcp-tools" id="mcp-tools"></a>
|
||||
|
||||
* [工具概述](mcp-tools/README.md)
|
||||
* [get_project_overview](mcp-tools/get-project-overview.md)
|
||||
* [list_modules](mcp-tools/list-modules.md)
|
||||
* [list_endpoints](mcp-tools/list-endpoints.md)
|
||||
* [get_endpoint_detail](mcp-tools/get-endpoint-detail.md)
|
||||
* [search_endpoints](mcp-tools/search-endpoints.md)
|
||||
|
||||
## 客户端配置 <a href="#clients" id="clients"></a>
|
||||
|
||||
* [配置概述](clients/README.md)
|
||||
* [Claude Desktop](clients/claude-desktop.md)
|
||||
* [Claude Code](clients/claude-code.md)
|
||||
* [Cursor](clients/cursor.md)
|
||||
* [Windsurf](clients/windsurf.md)
|
||||
* [GitHub Copilot](clients/github-copilot.md)
|
||||
* [Cline](clients/cline.md)
|
||||
* [Codex](clients/codex.md)
|
||||
* [其他 MCP 客户端](clients/other-clients.md)
|
||||
|
||||
## 项目管理 <a href="#project-management" id="project-management"></a>
|
||||
|
||||
* [模块管理](project-management/module-management.md)
|
||||
* [重新导入文档](project-management/reimport-docs.md)
|
||||
* [API Key 管理](project-management/api-key-management.md)
|
||||
|
||||
## 其他 <a href="#other" id="other"></a>
|
||||
|
||||
* [常见问题](faq.md)
|
||||
11
docs/book.json
Normal file
11
docs/book.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"title": "AgentFox 文档",
|
||||
"description": "AgentFox — 为 LLM 而生的 API 文档服务",
|
||||
"language": "zh-hans",
|
||||
"links": {
|
||||
"sidebar": {
|
||||
"AgentFox 官网": "https://www.agentfoxapp.com"
|
||||
}
|
||||
},
|
||||
"plugins": ["-sharing"]
|
||||
}
|
||||
87
docs/clients/README.md
Normal file
87
docs/clients/README.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# 客户端配置概述
|
||||
|
||||
AgentFox 使用 MCP 协议的 Streamable HTTP 传输方式,兼容所有支持 MCP 的 AI 工具。
|
||||
|
||||
## 通用配置模板
|
||||
|
||||
所有 MCP 客户端都使用类似的 JSON 配置格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {your-api-key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 配置字段说明
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `mcpServers` | MCP 服务器配置的顶层对象 |
|
||||
| `my-api` | 服务器名称,可自定义(建议使用 API 名称,如 `stripe-api`) |
|
||||
| `type` | 传输类型,必须为 `http` |
|
||||
| `url` | MCP 服务 URL,从项目详情的 MCP 标签页复制 |
|
||||
| `headers.Authorization` | 认证头,格式为 `Bearer {你的API-Key}` |
|
||||
|
||||
### 获取配置信息
|
||||
|
||||
1. 登录 [AgentFox 控制台](https://www.agentfoxapp.com)
|
||||
2. 进入项目详情页 → 「MCP」标签
|
||||
3. 复制 MCP 服务 URL
|
||||
4. 从「设置」中获取 API Key
|
||||
|
||||
> **提示**:MCP 标签页中有预生成的配置代码片段,可一键复制。
|
||||
|
||||
## 多项目配置
|
||||
|
||||
如果你有多个项目,可以在 `mcpServers` 中添加多个服务器:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"stripe-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/project-id-1",
|
||||
"headers": {
|
||||
"Authorization": "Bearer afk_your-api-key"
|
||||
}
|
||||
},
|
||||
"github-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/project-id-2",
|
||||
"headers": {
|
||||
"Authorization": "Bearer afk_your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 支持的客户端
|
||||
|
||||
| 客户端 | 类型 | 配置指南 |
|
||||
|--------|------|---------|
|
||||
| [Claude Desktop](claude-desktop.md) | 桌面应用 | JSON 配置文件 |
|
||||
| [Claude Code](claude-code.md) | CLI 工具 | 项目级或全局配置 |
|
||||
| [Cursor](cursor.md) | AI 编辑器 | 设置或配置文件 |
|
||||
| [Windsurf](windsurf.md) | AI 编辑器 | JSON 配置文件 |
|
||||
| [GitHub Copilot](github-copilot.md) | VS Code 扩展 | VS Code 配置 |
|
||||
| [Cline](cline.md) | VS Code 扩展 | 扩展设置 |
|
||||
| [Codex](codex.md) | CLI 工具 | CLI 参数或配置文件 |
|
||||
| [其他客户端](other-clients.md) | — | 通用配置 |
|
||||
|
||||
## 连接排障
|
||||
|
||||
如果连接失败,请检查:
|
||||
|
||||
1. **URL 是否正确**:确认 projectId 无误
|
||||
2. **API Key 是否有效**:确认 Key 未被轮换
|
||||
3. **网络是否通畅**:确认能访问 `www.agentfoxapp.com`
|
||||
4. **配置格式**:确认 `type` 为 `http`(不是 `sse` 或 `stdio`)
|
||||
70
docs/clients/claude-code.md
Normal file
70
docs/clients/claude-code.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Claude Code
|
||||
|
||||
Claude Code 是 Anthropic 的 CLI 编程助手,支持项目级和全局两种 MCP 配置方式。
|
||||
|
||||
## 方式一:项目级配置(推荐)
|
||||
|
||||
在项目根目录创建 `.mcp.json` 文件:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {your-api-key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
优点:配置跟随项目,团队成员可共享(注意不要将 API Key 提交到版本控制)。
|
||||
|
||||
## 方式二:全局配置
|
||||
|
||||
编辑 `~/.claude.json`,在顶层添加 `mcpServers`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {your-api-key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
全局配置对所有项目生效。
|
||||
|
||||
## 验证连接
|
||||
|
||||
启动 Claude Code 后,可以通过以下方式验证:
|
||||
|
||||
```bash
|
||||
# 列出已配置的 MCP 服务器
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
或在对话中直接使用:
|
||||
|
||||
```
|
||||
你:帮我看看这个 API 有哪些模块
|
||||
Claude:[调用 get_project_overview]
|
||||
这个 API 包含以下模块:...
|
||||
```
|
||||
|
||||
## 安全提示
|
||||
|
||||
如果使用项目级配置,建议将 `.mcp.json` 添加到 `.gitignore`,避免 API Key 泄露:
|
||||
|
||||
```bash
|
||||
echo ".mcp.json" >> .gitignore
|
||||
```
|
||||
|
||||
或者使用环境变量(如果客户端支持)来管理 API Key。
|
||||
77
docs/clients/claude-desktop.md
Normal file
77
docs/clients/claude-desktop.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Claude Desktop
|
||||
|
||||
Claude Desktop 是 Anthropic 的桌面客户端,原生支持 MCP 协议。
|
||||
|
||||
## 配置步骤
|
||||
|
||||
### 1. 找到配置文件
|
||||
|
||||
| 系统 | 路径 |
|
||||
|------|------|
|
||||
| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
|
||||
| Windows | `%APPDATA%\Claude\claude_desktop_config.json` |
|
||||
|
||||
如果文件不存在,手动创建即可。
|
||||
|
||||
### 2. 编辑配置文件
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {your-api-key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
将 `{projectId}` 和 `{your-api-key}` 替换为实际值。
|
||||
|
||||
### 3. 重启 Claude Desktop
|
||||
|
||||
保存配置文件后,完全退出并重新打开 Claude Desktop。
|
||||
|
||||
### 4. 验证连接
|
||||
|
||||
在 Claude Desktop 中发送消息:
|
||||
|
||||
```
|
||||
请调用 get_project_overview 查看 API 概览
|
||||
```
|
||||
|
||||
如果看到项目信息,说明配置成功。
|
||||
|
||||
## 多项目配置
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"stripe-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/project-id-1",
|
||||
"headers": {
|
||||
"Authorization": "Bearer afk_your-key"
|
||||
}
|
||||
},
|
||||
"internal-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/project-id-2",
|
||||
"headers": {
|
||||
"Authorization": "Bearer afk_your-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 修改配置后需要重启吗?**
|
||||
A: 是的,Claude Desktop 需要重启才能加载新的 MCP 配置。
|
||||
|
||||
**Q: 如何确认 MCP 服务已连接?**
|
||||
A: 在对话中,Claude 会自动识别可用的 MCP 工具。你可以直接要求它调用 AgentFox 的工具。
|
||||
51
docs/clients/cline.md
Normal file
51
docs/clients/cline.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Cline
|
||||
|
||||
Cline 是一个 VS Code 扩展,提供 AI 编程助手功能,支持 MCP 协议。
|
||||
|
||||
## 配置步骤
|
||||
|
||||
### 1. 打开 MCP 设置
|
||||
|
||||
1. 打开 VS Code
|
||||
2. 点击侧栏的 Cline 图标
|
||||
3. 点击 MCP Servers 设置(齿轮图标)
|
||||
|
||||
### 2. 添加 AgentFox 服务器
|
||||
|
||||
在 MCP 配置中添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {your-api-key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 保存并验证
|
||||
|
||||
保存配置后,Cline 会自动连接 MCP 服务器。你可以在 MCP Servers 列表中看到连接状态。
|
||||
|
||||
## 使用方法
|
||||
|
||||
在 Cline 的对话中直接使用:
|
||||
|
||||
```
|
||||
你:根据 API 文档帮我实现用户登录功能
|
||||
Cline:让我先查看 API 文档...
|
||||
[调用 search_endpoints keyword="login"]
|
||||
[调用 get_endpoint_detail endpointId="..."]
|
||||
根据文档,登录接口是 POST /auth/login...
|
||||
```
|
||||
|
||||
## 验证连接
|
||||
|
||||
在 Cline 的 MCP Servers 面板中:
|
||||
- 绿色状态表示已连接
|
||||
- 可以查看已注册的 5 个 AgentFox 工具
|
||||
61
docs/clients/codex.md
Normal file
61
docs/clients/codex.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Codex (OpenAI)
|
||||
|
||||
Codex 是 OpenAI 推出的 CLI 编程助手,支持 MCP 协议。
|
||||
|
||||
## 配置步骤
|
||||
|
||||
### 方式一:通过 CLI 参数
|
||||
|
||||
```bash
|
||||
codex --mcp-config '{"my-api":{"type":"http","url":"https://www.agentfoxapp.com/mcp/{projectId}","headers":{"Authorization":"Bearer {your-api-key}"}}}'
|
||||
```
|
||||
|
||||
### 方式二:通过配置文件
|
||||
|
||||
编辑 `~/.codex/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {your-api-key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 方式三:项目级配置
|
||||
|
||||
在项目根目录创建 `.codex/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {your-api-key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 验证连接
|
||||
|
||||
启动 Codex 后,在对话中测试:
|
||||
|
||||
```
|
||||
你:调用 get_project_overview 查看 API 信息
|
||||
Codex:[调用 get_project_overview]
|
||||
项目信息如下:...
|
||||
```
|
||||
|
||||
## 安全提示
|
||||
|
||||
使用项目级配置时,建议将 `.codex/` 添加到 `.gitignore`。
|
||||
56
docs/clients/cursor.md
Normal file
56
docs/clients/cursor.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Cursor
|
||||
|
||||
Cursor 是一款 AI 代码编辑器,支持通过 MCP 协议连接外部工具。
|
||||
|
||||
## 配置步骤
|
||||
|
||||
### 方式一:通过配置文件
|
||||
|
||||
在项目根目录创建 `.cursor/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {your-api-key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 方式二:通过设置界面
|
||||
|
||||
1. 打开 Cursor 设置(`Cmd/Ctrl + ,`)
|
||||
2. 搜索 "MCP"
|
||||
3. 在 MCP Servers 配置区域添加服务器信息
|
||||
|
||||
## 使用方法
|
||||
|
||||
配置完成后,在 Cursor 的 AI 对话中(Agent 模式)即可使用 AgentFox 的 MCP 工具:
|
||||
|
||||
```
|
||||
你:帮我调用用户注册接口,参考 API 文档
|
||||
Cursor:[调用 search_endpoints keyword="register"]
|
||||
[调用 get_endpoint_detail endpointId="..."]
|
||||
根据 API 文档,注册接口是 POST /api/users/register...
|
||||
```
|
||||
|
||||
> **提示**:确保使用 Cursor 的 Agent 模式(而非 Ask 模式),Agent 模式才能调用 MCP 工具。
|
||||
|
||||
## 验证连接
|
||||
|
||||
1. 打开 Cursor 设置 → MCP 区域
|
||||
2. 检查 AgentFox 服务器状态是否显示为已连接(绿色指示灯)
|
||||
3. 在对话中要求调用 `get_project_overview` 测试
|
||||
|
||||
## 安全提示
|
||||
|
||||
建议将 `.cursor/mcp.json` 添加到 `.gitignore`:
|
||||
|
||||
```bash
|
||||
echo ".cursor/mcp.json" >> .gitignore
|
||||
```
|
||||
71
docs/clients/github-copilot.md
Normal file
71
docs/clients/github-copilot.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# GitHub Copilot
|
||||
|
||||
GitHub Copilot 通过 VS Code 的 MCP 支持连接 AgentFox。
|
||||
|
||||
## 前提条件
|
||||
|
||||
- VS Code 版本 1.99 或更高
|
||||
- GitHub Copilot 扩展已安装并激活
|
||||
- GitHub Copilot Chat 扩展已安装
|
||||
|
||||
## 配置步骤
|
||||
|
||||
### 方式一:项目级配置(推荐)
|
||||
|
||||
在项目根目录创建 `.vscode/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"my-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {your-api-key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:VS Code MCP 配置的顶层键是 `servers`,不是 `mcpServers`。
|
||||
|
||||
### 方式二:用户级配置
|
||||
|
||||
在 VS Code 的 `settings.json` 中添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"my-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {your-api-key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
在 Copilot Chat 中使用 Agent 模式(`@workspace` 或直接对话):
|
||||
|
||||
```
|
||||
你:帮我查看支付相关的 API 端点
|
||||
Copilot:[调用 search_endpoints keyword="payment"]
|
||||
找到以下支付相关端点:...
|
||||
```
|
||||
|
||||
## 验证连接
|
||||
|
||||
1. 打开 VS Code 命令面板(`Cmd/Ctrl + Shift + P`)
|
||||
2. 搜索 "MCP: List Servers"
|
||||
3. 确认 AgentFox 服务器状态为已连接
|
||||
|
||||
## 安全提示
|
||||
|
||||
建议将 `.vscode/mcp.json` 添加到 `.gitignore`,或使用 VS Code 的用户级配置来避免 API Key 泄露。
|
||||
82
docs/clients/other-clients.md
Normal file
82
docs/clients/other-clients.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 其他 MCP 客户端
|
||||
|
||||
任何支持 MCP 协议 Streamable HTTP 传输的 AI 工具都可以连接 AgentFox。
|
||||
|
||||
## 连接要素
|
||||
|
||||
连接 AgentFox 只需要三个信息:
|
||||
|
||||
| 要素 | 值 |
|
||||
|------|-----|
|
||||
| **传输类型** | HTTP(Streamable HTTP) |
|
||||
| **URL** | `https://www.agentfoxapp.com/mcp/{projectId}` |
|
||||
| **认证** | `Authorization: Bearer {your-api-key}` |
|
||||
|
||||
## 通用配置格式
|
||||
|
||||
大多数 MCP 客户端使用 JSON 配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {your-api-key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 其他已知兼容客户端
|
||||
|
||||
### Gemini CLI
|
||||
|
||||
Google 的 Gemini CLI 工具支持 MCP 协议。编辑 `~/.gemini/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {your-api-key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Antigravity
|
||||
|
||||
Antigravity AI 开发平台支持 MCP。在平台的 MCP 配置中添加 AgentFox 服务器即可。
|
||||
|
||||
### OpenClaw
|
||||
|
||||
OpenClaw AI 开发平台同样支持 MCP 协议,配置方式类似。
|
||||
|
||||
## 技术细节
|
||||
|
||||
如果你的工具需要手动实现 MCP 客户端连接,以下是关键技术参数:
|
||||
|
||||
| 参数 | 值 |
|
||||
|------|-----|
|
||||
| 协议 | MCP (Model Context Protocol) |
|
||||
| 传输方式 | Streamable HTTP |
|
||||
| HTTP 方法 | POST(发送请求)、GET(SSE 会话恢复)、DELETE(终止会话) |
|
||||
| 会话管理 | 通过 `mcp-session-id` 响应头建立,后续请求携带 |
|
||||
| 认证方式 | HTTP Bearer Token |
|
||||
| Content-Type | `application/json` |
|
||||
|
||||
## 自检清单
|
||||
|
||||
如果连接不成功,请检查:
|
||||
|
||||
- [ ] 传输类型是 `http`(不是 `sse` 或 `stdio`)
|
||||
- [ ] URL 包含正确的 projectId
|
||||
- [ ] API Key 以 `afk_` 开头
|
||||
- [ ] Authorization 头格式为 `Bearer {key}`(注意 Bearer 后有空格)
|
||||
- [ ] 网络可以访问 `www.agentfoxapp.com`
|
||||
45
docs/clients/windsurf.md
Normal file
45
docs/clients/windsurf.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Windsurf
|
||||
|
||||
Windsurf 是 Codeium 推出的 AI 代码编辑器,支持 MCP 协议。
|
||||
|
||||
## 配置步骤
|
||||
|
||||
### 1. 找到配置文件
|
||||
|
||||
| 系统 | 路径 |
|
||||
|------|------|
|
||||
| macOS | `~/.codeium/windsurf/mcp_config.json` |
|
||||
| Windows | `%USERPROFILE%\.codeium\windsurf\mcp_config.json` |
|
||||
| Linux | `~/.codeium/windsurf/mcp_config.json` |
|
||||
|
||||
如果文件不存在,手动创建即可。
|
||||
|
||||
### 2. 编辑配置文件
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {your-api-key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 重启 Windsurf
|
||||
|
||||
保存配置后,重启 Windsurf 使配置生效。
|
||||
|
||||
## 验证连接
|
||||
|
||||
在 Windsurf 的 Cascade 对话中:
|
||||
|
||||
```
|
||||
你:请调用 get_project_overview 查看我的 API 概览
|
||||
```
|
||||
|
||||
如果返回了项目信息,说明配置成功。
|
||||
47
docs/faq.md
Normal file
47
docs/faq.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 常见问题
|
||||
|
||||
## 什么是 MCP?AgentFox 如何使用它?
|
||||
|
||||
MCP(Model Context Protocol)是一个开放标准,让 AI 助手能够连接外部工具和数据源。AgentFox 通过 MCP 工具暴露你的 API 文档,让 Claude Code、Cursor、Copilot 等 AI 编程助手可以按需查询端点详情,而无需将整个规范放入上下文窗口。
|
||||
|
||||
## 支持哪些 OpenAPI 格式?
|
||||
|
||||
AgentFox 支持 OpenAPI 3.x 和 Swagger 2.0 规范。你可以导入 JSON 或 YAML 格式的文档,也可以提供 URL 直接获取。导入时所有 `$ref` 引用会自动解引用。
|
||||
|
||||
## 能减少多少 Token 消耗?
|
||||
|
||||
每次 MCP 工具调用返回约 200-2,000 tokens 的精准信息,相比全量 API 规范的 10,000+ tokens。对于典型的集成任务,这意味着 **80-95%** 的 token 消耗降低。
|
||||
|
||||
## 我的 API 文档安全吗?
|
||||
|
||||
是的。每个账号拥有独立的 API Key(bcrypt 哈希加密,从不以明文存储)。MCP 端点每次请求都需要认证。用户控制台使用 JWT 并自动轮换 token。
|
||||
|
||||
## 兼容哪些 AI 工具?
|
||||
|
||||
任何支持 MCP 协议的工具都可以连接 AgentFox,包括 Claude Desktop、Claude Code、Cursor、Windsurf、GitHub Copilot、Cline、OpenAI Codex CLI、Gemini CLI 等。如果你的工具支持 MCP,就能与 AgentFox 配合使用。
|
||||
|
||||
## 可以私有化部署吗?
|
||||
|
||||
可以。AgentFox 支持云端和私有化部署。企业版包含完整的 Docker Compose 私有化部署支持,以及 SSO 集成和专属技术支持。
|
||||
|
||||
## 如何更新已导入的 API 文档?
|
||||
|
||||
在项目详情的「设置」标签中,点击「重新导入文档」。重新导入会替换所有模块和端点数据,但项目 ID 和 API Key 保持不变,LLM 客户端无需重新配置。详见 [重新导入文档](project-management/reimport-docs.md)。
|
||||
|
||||
## 丢失了 API Key 怎么办?
|
||||
|
||||
如果你是邮箱注册的用户,可以在「设置」中通过密码验证查看已有 Key。如果完全无法找回,可以轮换生成新的 Key(旧 Key 会立即失效)。详见 [API Key 管理](project-management/api-key-management.md)。
|
||||
|
||||
## MCP 连接失败怎么排查?
|
||||
|
||||
1. 确认 `type` 是 `http`(不是 `sse` 或 `stdio`)
|
||||
2. 确认 URL 中的 projectId 正确
|
||||
3. 确认 API Key 有效且格式为 `Bearer {key}`
|
||||
4. 确认网络可以访问 `www.agentfoxapp.com`
|
||||
5. 尝试重启 AI 工具
|
||||
|
||||
详见 [客户端配置概述](clients/README.md) 中的连接排障部分。
|
||||
|
||||
## 一个 API Key 可以用于多个项目吗?
|
||||
|
||||
是的。AgentFox 的 API Key 是账号级别的,一个 Key 对你账号下的所有项目生效。你只需要在 MCP 配置中使用不同的项目 URL 即可。
|
||||
39
docs/getting-started/README.md
Normal file
39
docs/getting-started/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 快速开始
|
||||
|
||||
只需 4 步,即可让你的 AI 编程助手通过 MCP 查询 API 文档。
|
||||
|
||||
## 准备工作
|
||||
|
||||
- 一个浏览器(用于访问 AgentFox 控制台)
|
||||
- 一份 OpenAPI 3.x 或 Swagger 2.0 文档(JSON/YAML 文件或可访问的 URL)
|
||||
- 一个支持 MCP 的 AI 工具(Claude Code、Cursor、Copilot 等)
|
||||
|
||||
## 步骤
|
||||
|
||||
| 步骤 | 说明 | 耗时 |
|
||||
|------|------|------|
|
||||
| 1. [注册与登录](register-and-login.md) | 创建 AgentFox 账号 | 1 分钟 |
|
||||
| 2. [导入 API 文档](import-api-docs.md) | 上传 OpenAPI 文档 | 1 分钟 |
|
||||
| 3. [生成 API Key](generate-api-key.md) | 获取 MCP 认证密钥 | 30 秒 |
|
||||
| 4. [连接 LLM 客户端](connect-first-client.md) | 配置 AI 工具 | 2 分钟 |
|
||||
|
||||
完成后,你的 AI 助手就可以直接查询 API 文档了。
|
||||
|
||||
## 完成效果
|
||||
|
||||
配置完成后,你可以在 AI 工具中这样使用:
|
||||
|
||||
```
|
||||
你:帮我调用 Stripe 的创建支付接口
|
||||
|
||||
AI:让我先查看一下 API 文档...
|
||||
[调用 get_project_overview]
|
||||
[调用 list_endpoints moduleId="payments"]
|
||||
[调用 get_endpoint_detail endpointId="create-charge"]
|
||||
|
||||
根据 API 文档,创建支付的接口是:
|
||||
POST /v1/charges
|
||||
需要参数:amount, currency, source...
|
||||
```
|
||||
|
||||
AI 助手会自动通过 MCP 工具获取所需信息,无需你手动复制文档。
|
||||
75
docs/getting-started/connect-first-client.md
Normal file
75
docs/getting-started/connect-first-client.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# 连接第一个 LLM 客户端
|
||||
|
||||
现在你已经有了项目和 API Key,可以将 AI 工具连接到 AgentFox 的 MCP 服务。
|
||||
|
||||
## 获取 MCP 配置信息
|
||||
|
||||
在项目详情页的「MCP」标签页中,你可以找到:
|
||||
|
||||
1. **MCP 服务 URL**:`https://www.agentfoxapp.com/mcp/{你的项目ID}`
|
||||
2. **配置代码片段**:可一键复制的 JSON 配置
|
||||
|
||||
## 通用配置模板
|
||||
|
||||
所有支持 MCP 的 AI 工具都使用类似的配置格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/{projectId}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer {your-api-key}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
将 `{projectId}` 替换为你的项目 ID(从 MCP 标签页复制),将 `{your-api-key}` 替换为你的 API Key。
|
||||
|
||||
## 快速示例:Claude Code
|
||||
|
||||
以 Claude Code 为例,最快的连接方式:
|
||||
|
||||
1. 在项目根目录创建 `.mcp.json` 文件:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-api": {
|
||||
"type": "http",
|
||||
"url": "https://www.agentfoxapp.com/mcp/你的项目ID",
|
||||
"headers": {
|
||||
"Authorization": "Bearer 你的API-Key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. 重启 Claude Code,即可使用
|
||||
|
||||
## 验证连接
|
||||
|
||||
连接成功后,你可以让 AI 助手执行一个简单的测试:
|
||||
|
||||
```
|
||||
你:调用 get_project_overview 查看 API 概览
|
||||
```
|
||||
|
||||
如果返回了项目名称、版本和模块列表,说明连接成功。
|
||||
|
||||
## 各客户端详细配置
|
||||
|
||||
不同 AI 工具的配置方式略有差异,请参考对应的详细指南:
|
||||
|
||||
- [Claude Desktop](../clients/claude-desktop.md)
|
||||
- [Claude Code](../clients/claude-code.md)
|
||||
- [Cursor](../clients/cursor.md)
|
||||
- [Windsurf](../clients/windsurf.md)
|
||||
- [GitHub Copilot](../clients/github-copilot.md)
|
||||
- [Cline](../clients/cline.md)
|
||||
- [Codex (OpenAI)](../clients/codex.md)
|
||||
- [其他客户端](../clients/other-clients.md)
|
||||
49
docs/getting-started/generate-api-key.md
Normal file
49
docs/getting-started/generate-api-key.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 生成 API Key
|
||||
|
||||
API Key 是 LLM 客户端连接 AgentFox MCP 服务的认证凭证。你需要先生成一个 API Key 才能使用 MCP 服务。
|
||||
|
||||
## 生成步骤
|
||||
|
||||
1. 进入任意项目的详情页
|
||||
2. 点击页面顶部的「设置」(用户头像旁)
|
||||
3. 在「API Key」区域,点击「生成 API Key」
|
||||
4. 生成的密钥会立即显示
|
||||
|
||||
> **重要**:API Key 仅在生成时显示一次,之后无法再次查看完整密钥。请立即复制并安全保存。
|
||||
|
||||
## API Key 格式
|
||||
|
||||
API Key 以 `afk_` 为前缀,例如:
|
||||
|
||||
```
|
||||
afk_dGhpcyBpcyBhIHNhbXBsZQ
|
||||
```
|
||||
|
||||
## 安全说明
|
||||
|
||||
- API Key 使用 bcrypt 哈希加密存储,AgentFox 不保存明文密钥
|
||||
- 一个账号只有一个 API Key,对该账号下所有项目生效
|
||||
- 如果你通过第三方登录(Google/GitHub),需要先在「设置」中设置密码,才能查看或复制 API Key
|
||||
|
||||
## 查看和复制已有 Key
|
||||
|
||||
如果你之前已生成过 API Key:
|
||||
|
||||
1. 打开「设置」
|
||||
2. 在 API Key 区域,点击「查看」或「复制」
|
||||
3. 输入账号密码进行验证
|
||||
4. 验证通过后可查看或复制完整密钥
|
||||
|
||||
## 轮换 API Key
|
||||
|
||||
如果密钥泄露或需要更新:
|
||||
|
||||
1. 打开「设置」→ API Key 区域
|
||||
2. 点击「轮换 API Key」
|
||||
3. 确认操作
|
||||
|
||||
> **注意**:轮换后旧密钥立即失效,所有使用旧密钥的 MCP 客户端需要更新配置。
|
||||
|
||||
## 下一步
|
||||
|
||||
拿到 API Key 后,就可以 [连接你的第一个 LLM 客户端](connect-first-client.md) 了。
|
||||
59
docs/getting-started/import-api-docs.md
Normal file
59
docs/getting-started/import-api-docs.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 导入 API 文档
|
||||
|
||||
AgentFox 支持导入 OpenAPI 3.x 和 Swagger 2.0 格式的 API 文档。
|
||||
|
||||
## 导入方式
|
||||
|
||||
### 方式一:从 URL 导入
|
||||
|
||||
1. 在控制台点击「导入 API 文档」按钮
|
||||
2. 选择「从 URL」标签
|
||||
3. 粘贴 OpenAPI 文档的 URL
|
||||
4. 点击「导入」
|
||||
|
||||
> **提示**:支持 localhost 和内网地址。AgentFox 会先尝试在浏览器端直接获取,如果遇到 CORS 限制,会自动通过服务端代理获取。
|
||||
|
||||
### 方式二:上传文件
|
||||
|
||||
1. 在控制台点击「导入 API 文档」按钮
|
||||
2. 选择「上传文件」标签
|
||||
3. 拖放文件到上传区域,或点击选择文件
|
||||
4. 支持 `.json`、`.yaml`、`.yml` 格式
|
||||
5. 点击「导入」
|
||||
|
||||
## 支持的格式
|
||||
|
||||
| 格式 | 版本 | 说明 |
|
||||
|------|------|------|
|
||||
| OpenAPI | 3.0 / 3.1 | 推荐,功能最完整 |
|
||||
| Swagger | 2.0 | 完整支持,自动转换 body 参数为 requestBody 格式 |
|
||||
|
||||
支持的文件类型:
|
||||
- JSON(`.json`)
|
||||
- YAML(`.yaml` / `.yml`)
|
||||
|
||||
## 导入后会发生什么?
|
||||
|
||||
1. **验证**:AgentFox 使用 `swagger-parser` 验证文档格式
|
||||
2. **解引用**:所有 `$ref` 引用被自动展开
|
||||
3. **分组**:端点按 OpenAPI tags 或 URL 路径前缀自动分组为模块
|
||||
4. **索引**:所有端点的参数、请求体、响应格式被索引存储
|
||||
|
||||
导入成功后,你将看到:
|
||||
- 项目名称(来自文档的 `info.title`)
|
||||
- 解析出的模块数量
|
||||
- 解析出的端点数量
|
||||
|
||||
## 示例:导入 Petstore API
|
||||
|
||||
你可以用以下公开的 OpenAPI 文档来测试:
|
||||
|
||||
```
|
||||
https://petstore3.swagger.io/api/v3/openapi.json
|
||||
```
|
||||
|
||||
导入后将创建包含多个模块(pet、store、user)和对应端点的项目。
|
||||
|
||||
## 下一步
|
||||
|
||||
文档导入成功后,接下来 [生成 API Key](generate-api-key.md) 以启用 MCP 服务。
|
||||
31
docs/getting-started/register-and-login.md
Normal file
31
docs/getting-started/register-and-login.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 注册与登录
|
||||
|
||||
## 创建账号
|
||||
|
||||
访问 [www.agentfoxapp.com](https://www.agentfoxapp.com),点击右上角的「免费开始」按钮。
|
||||
|
||||
### 方式一:邮箱注册
|
||||
|
||||
1. 点击「注册」
|
||||
2. 填写姓名、邮箱和密码(至少 8 个字符)
|
||||
3. 点击「创建账号」
|
||||
4. 自动跳转到控制台
|
||||
|
||||
### 方式二:第三方登录
|
||||
|
||||
AgentFox 支持以下第三方登录:
|
||||
|
||||
- **Google** — 使用 Google 账号快速登录
|
||||
- **GitHub** — 使用 GitHub 账号快速登录
|
||||
|
||||
点击对应的图标即可跳转到授权页面,授权后自动返回 AgentFox 控制台。
|
||||
|
||||
> **提示**:通过第三方登录的用户,如需查看或复制 API Key,需要先在「设置」中设置密码。
|
||||
|
||||
## 登录
|
||||
|
||||
已有账号的用户,直接在登录页输入邮箱和密码,或使用之前关联的第三方账号登录。
|
||||
|
||||
## 下一步
|
||||
|
||||
登录成功后,你将进入控制台。接下来 [导入你的第一份 API 文档](import-api-docs.md)。
|
||||
69
docs/introduction/what-is-agentfox.md
Normal file
69
docs/introduction/what-is-agentfox.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# 什么是 AgentFox
|
||||
|
||||
AgentFox 是一个 MCP 驱动的 API 文档服务,让 AI 编程助手(如 Claude Code、Cursor、GitHub Copilot)能够高效地查询你的 API 文档。
|
||||
|
||||
## 解决什么问题?
|
||||
|
||||
当你使用 AI 编程助手调用第三方 API 时,通常需要将 API 文档提供给 LLM。传统做法是将整个 OpenAPI 规范复制粘贴到对话中,这带来了几个问题:
|
||||
|
||||
- **Token 浪费**:一份完整的 OpenAPI 规范可能消耗 10,000-100,000+ tokens,而你可能只需要其中一个端点的信息
|
||||
- **上下文污染**:大量无关信息会降低 LLM 的理解准确度
|
||||
- **手动操作**:每次都需要手动查找、复制文档
|
||||
|
||||
## AgentFox 如何工作?
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ OpenAPI 文档 │───▶│ AgentFox │───▶│ MCP 端点 │
|
||||
│ (JSON/YAML) │ │ 解析 & 索引 │ │ /mcp/:id │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ LLM 按需查询 │
|
||||
│ ~200-2K tokens │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
1. **导入**:上传 OpenAPI 3.x 或 Swagger 2.0 文档(JSON/YAML 文件或 URL)
|
||||
2. **解析**:AgentFox 自动解引用所有 `$ref`,将文档解析为模块和端点
|
||||
3. **生成 MCP 端点**:每个项目获得唯一的 MCP 服务 URL
|
||||
4. **LLM 按需查询**:AI 工具通过 5 个 MCP 工具渐进式获取所需信息
|
||||
|
||||
## 核心优势
|
||||
|
||||
### 渐进式下钻
|
||||
|
||||
LLM 不需要一次获取所有信息。它可以:
|
||||
- 先查看项目概览,了解有哪些模块
|
||||
- 再查看特定模块的端点列表
|
||||
- 最后获取具体端点的完整参数和响应格式
|
||||
|
||||
### Token 高效
|
||||
|
||||
| 操作 | Token 消耗 |
|
||||
|------|-----------|
|
||||
| `get_project_overview` | ~200 tokens |
|
||||
| `list_modules` | ~100-300 tokens |
|
||||
| `list_endpoints` | ~200-500 tokens |
|
||||
| `get_endpoint_detail` | ~500-2,000 tokens |
|
||||
| `search_endpoints` | ~200-500 tokens |
|
||||
| **全量 OpenAPI 规范** | **10,000-100,000+ tokens** |
|
||||
|
||||
典型的 API 集成任务只需 2-3 次工具调用(约 1,300 tokens),相比全量规范节省 **80-95%** 的 token 消耗。
|
||||
|
||||
### 全规范支持
|
||||
|
||||
- OpenAPI 3.0 / 3.1
|
||||
- Swagger 2.0
|
||||
- JSON 和 YAML 格式
|
||||
- 所有 `$ref` 引用自动解引用
|
||||
|
||||
### 一键导入
|
||||
|
||||
粘贴 URL 或上传文件,API 文档即时解析并索引。支持从 localhost 和内网地址获取文档。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [了解 MCP 协议](what-is-mcp.md)
|
||||
- [快速开始](../getting-started/README.md)
|
||||
65
docs/introduction/what-is-mcp.md
Normal file
65
docs/introduction/what-is-mcp.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# MCP 协议介绍
|
||||
|
||||
MCP(Model Context Protocol)是由 Anthropic 推出的开放标准协议,用于连接 AI 助手与外部工具和数据源。
|
||||
|
||||
## 什么是 MCP?
|
||||
|
||||
你可以把 MCP 理解为 AI 世界的 "USB 接口":
|
||||
|
||||
- **统一标准**:一个协议连接所有 AI 工具和数据源
|
||||
- **双向通信**:AI 助手可以调用工具,也可以接收数据
|
||||
- **安全可控**:每个连接都有明确的权限和认证机制
|
||||
|
||||
```
|
||||
┌────────────┐ MCP 协议 ┌────────────┐
|
||||
│ │◀────────────────────────▶│ │
|
||||
│ MCP 客户端 │ Streamable HTTP │ MCP 服务器 │
|
||||
│ (AI 工具) │ │ (数据源/工具) │
|
||||
│ │ Tools / Resources │ │
|
||||
└────────────┘ └────────────┘
|
||||
|
||||
Claude Code AgentFox
|
||||
Cursor 数据库
|
||||
Copilot 文件系统
|
||||
... ...
|
||||
```
|
||||
|
||||
## MCP 的核心概念
|
||||
|
||||
### Tools(工具)
|
||||
|
||||
MCP 服务器可以向客户端暴露一组工具(函数),AI 助手可以调用这些工具来获取信息或执行操作。
|
||||
|
||||
AgentFox 提供 5 个工具:
|
||||
- `get_project_overview` — 获取项目概览
|
||||
- `list_modules` — 列出所有模块
|
||||
- `list_endpoints` — 列出模块中的端点
|
||||
- `get_endpoint_detail` — 获取端点详情
|
||||
- `search_endpoints` — 搜索端点
|
||||
|
||||
### Transport(传输层)
|
||||
|
||||
MCP 支持多种传输方式。AgentFox 使用 **Streamable HTTP** 传输,这意味着:
|
||||
- 无需安装任何本地插件
|
||||
- 通过标准 HTTP 请求通信
|
||||
- 支持远程连接,适合云端部署
|
||||
|
||||
## AgentFox 如何使用 MCP?
|
||||
|
||||
AgentFox 是一个 **MCP 服务器**,它:
|
||||
|
||||
1. 接收你的 OpenAPI 文档并解析索引
|
||||
2. 为每个项目生成唯一的 MCP 端点 URL
|
||||
3. 通过 5 个 MCP 工具提供按需查询能力
|
||||
4. AI 工具(MCP 客户端)连接到这个端点,即可按需查询 API 文档
|
||||
|
||||
你的 AI 工具(如 Claude Code、Cursor)就是 **MCP 客户端**,它们通过 MCP 协议连接 AgentFox,获取所需的 API 信息。
|
||||
|
||||
## 了解更多
|
||||
|
||||
- [MCP 官方文档](https://modelcontextprotocol.io/)
|
||||
- [MCP 规范](https://spec.modelcontextprotocol.io/)
|
||||
|
||||
## 下一步
|
||||
|
||||
- [快速开始](../getting-started/README.md) — 5 分钟完成首次配置
|
||||
48
docs/mcp-tools/README.md
Normal file
48
docs/mcp-tools/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# MCP 工具概述
|
||||
|
||||
AgentFox 提供 5 个 MCP 工具,采用渐进式下钻设计,让 LLM 按需获取精确信息。
|
||||
|
||||
## 设计理念
|
||||
|
||||
传统方式是将完整 API 规范一次性提供给 LLM(10,000+ tokens)。AgentFox 将信息分层,LLM 按需逐层获取:
|
||||
|
||||
```
|
||||
get_project_overview ← 项目级:名称、版本、模块摘要(~200 tokens)
|
||||
│
|
||||
├── list_modules ← 模块级:模块描述和端点数量(~100-300 tokens)
|
||||
│ │
|
||||
│ └── list_endpoints ← 端点列表:方法、路径、摘要(~200-500 tokens)
|
||||
│ │
|
||||
│ └── get_endpoint_detail ← 端点详情:参数、请求体、响应(~500-2000 tokens)
|
||||
│
|
||||
└── search_endpoints ← 关键词搜索:跨模块搜索端点(~200-500 tokens)
|
||||
```
|
||||
|
||||
## 推荐调用流程
|
||||
|
||||
对于典型的 API 集成任务,LLM 通常会按以下顺序调用:
|
||||
|
||||
1. **`get_project_overview`** — 了解项目结构和可用模块
|
||||
2. **`list_endpoints`** — 浏览目标模块的端点列表(或用 `search_endpoints` 搜索)
|
||||
3. **`get_endpoint_detail`** — 获取目标端点的完整信息
|
||||
|
||||
大多数任务只需要 2-3 次调用,总共约 1,000-1,500 tokens。
|
||||
|
||||
## Token 消耗对比
|
||||
|
||||
| 工具 | 平均 Token 消耗 | 说明 |
|
||||
|------|----------------|------|
|
||||
| `get_project_overview` | ~200 | 最轻量,建议首先调用 |
|
||||
| `list_modules` | ~100-300 | 按模块数量线性增长 |
|
||||
| `list_endpoints` | ~200-500 | 按端点数量线性增长 |
|
||||
| `get_endpoint_detail` | ~500-2,000 | 取决于参数和响应 schema 复杂度 |
|
||||
| `search_endpoints` | ~200-500 | 最多返回 20 条结果 |
|
||||
| **全量 OpenAPI 规范** | **10,000-100,000+** | 传统方式 |
|
||||
|
||||
## 工具列表
|
||||
|
||||
- [`get_project_overview`](get-project-overview.md) — 获取项目概览
|
||||
- [`list_modules`](list-modules.md) — 列出所有模块
|
||||
- [`list_endpoints`](list-endpoints.md) — 列出模块中的端点
|
||||
- [`get_endpoint_detail`](get-endpoint-detail.md) — 获取端点完整详情
|
||||
- [`search_endpoints`](search-endpoints.md) — 按关键词搜索端点
|
||||
92
docs/mcp-tools/get-endpoint-detail.md
Normal file
92
docs/mcp-tools/get-endpoint-detail.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# get_endpoint_detail
|
||||
|
||||
获取端点的完整详细信息,包括参数、请求体和响应格式。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `endpointId` | string | 是 | 端点 ID,可从 `list_endpoints` 或 `search_endpoints` 获取 |
|
||||
|
||||
## 返回结果
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "ep_001",
|
||||
"method": "POST",
|
||||
"path": "/v1/charges",
|
||||
"summary": "Create a charge",
|
||||
"description": "Creates a new charge object. If the charge fails, the API returns an error.",
|
||||
"operationId": "createCharge",
|
||||
"moduleName": "Payments",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Idempotency-Key",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": { "type": "string" },
|
||||
"description": "Unique key for idempotent requests"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["amount", "currency"],
|
||||
"properties": {
|
||||
"amount": { "type": "integer", "description": "Amount in cents" },
|
||||
"currency": { "type": "string", "description": "Three-letter ISO currency code" },
|
||||
"source": { "type": "string", "description": "Payment source token" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Charge created successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"amount": { "type": "integer" },
|
||||
"status": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated": false
|
||||
}
|
||||
```
|
||||
|
||||
## 返回字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | string | 端点 ID |
|
||||
| `method` | string | HTTP 方法 |
|
||||
| `path` | string | API 路径 |
|
||||
| `summary` | string | 端点摘要 |
|
||||
| `description` | string | 端点详细描述 |
|
||||
| `operationId` | string | 操作标识符 |
|
||||
| `moduleName` | string | 所属模块名称 |
|
||||
| `parameters` | array | URL/查询/头部/Cookie 参数列表(原始 OpenAPI schema) |
|
||||
| `requestBody` | object | 请求体规范 |
|
||||
| `responses` | object | HTTP 响应规范(按状态码分组) |
|
||||
| `deprecated` | boolean | 是否已弃用 |
|
||||
|
||||
## Token 消耗
|
||||
|
||||
约 **500-2,000 tokens**,取决于参数数量和响应 schema 的复杂程度。这是 token 消耗最多的工具,但也是信息最丰富的。
|
||||
|
||||
## 使用场景
|
||||
|
||||
- 需要了解如何调用某个具体端点
|
||||
- 查看请求参数的类型、是否必填、描述
|
||||
- 查看响应格式以便解析返回数据
|
||||
52
docs/mcp-tools/get-project-overview.md
Normal file
52
docs/mcp-tools/get-project-overview.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# get_project_overview
|
||||
|
||||
获取项目的基本信息和模块摘要。这是推荐的第一个调用工具。
|
||||
|
||||
## 参数
|
||||
|
||||
无参数。
|
||||
|
||||
## 返回结果
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Stripe API",
|
||||
"description": "The Stripe REST API",
|
||||
"version": "3.0.0",
|
||||
"baseUrl": "https://api.stripe.com",
|
||||
"totalEndpoints": 247,
|
||||
"modules": [
|
||||
{
|
||||
"id": "mod_abc123",
|
||||
"name": "Payments",
|
||||
"endpointCount": 15
|
||||
},
|
||||
{
|
||||
"id": "mod_def456",
|
||||
"name": "Customers",
|
||||
"endpointCount": 8
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 返回字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `name` | string | 项目名称(来自 OpenAPI 的 `info.title`) |
|
||||
| `description` | string | 项目描述 |
|
||||
| `version` | string | OpenAPI 规范版本 |
|
||||
| `baseUrl` | string \| null | API 基础 URL |
|
||||
| `totalEndpoints` | number | 端点总数 |
|
||||
| `modules` | array | 模块列表(含 id、名称和端点数量) |
|
||||
|
||||
## Token 消耗
|
||||
|
||||
约 **200 tokens**,是最轻量的工具。
|
||||
|
||||
## 使用场景
|
||||
|
||||
- 初次连接时了解 API 的整体结构
|
||||
- 查看有哪些模块可供探索
|
||||
- 获取模块 ID 以供后续调用 `list_endpoints`
|
||||
59
docs/mcp-tools/list-endpoints.md
Normal file
59
docs/mcp-tools/list-endpoints.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# list_endpoints
|
||||
|
||||
列出指定模块中的所有端点摘要信息。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `moduleId` | string | 是 | 模块 ID,可从 `get_project_overview` 或 `list_modules` 获取 |
|
||||
|
||||
## 返回结果
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "ep_001",
|
||||
"method": "POST",
|
||||
"path": "/v1/charges",
|
||||
"summary": "Create a charge",
|
||||
"deprecated": false
|
||||
},
|
||||
{
|
||||
"id": "ep_002",
|
||||
"method": "GET",
|
||||
"path": "/v1/charges/{id}",
|
||||
"summary": "Retrieve a charge",
|
||||
"deprecated": false
|
||||
},
|
||||
{
|
||||
"id": "ep_003",
|
||||
"method": "POST",
|
||||
"path": "/v1/refunds",
|
||||
"summary": "Create a refund",
|
||||
"deprecated": false
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 返回字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | string | 端点 ID,用于 `get_endpoint_detail` |
|
||||
| `method` | string | HTTP 方法(GET、POST、PUT、DELETE 等) |
|
||||
| `path` | string | API 路径 |
|
||||
| `summary` | string | 端点简短描述 |
|
||||
| `deprecated` | boolean | 是否已弃用 |
|
||||
|
||||
结果按路径字母排序,然后按 HTTP 方法排序。
|
||||
|
||||
## Token 消耗
|
||||
|
||||
约 **200-500 tokens**,取决于模块中的端点数量。
|
||||
|
||||
## 使用场景
|
||||
|
||||
- 浏览特定模块中的所有端点
|
||||
- 找到目标端点的 ID,用于调用 `get_endpoint_detail`
|
||||
- 了解 API 提供了哪些操作
|
||||
49
docs/mcp-tools/list-modules.md
Normal file
49
docs/mcp-tools/list-modules.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# list_modules
|
||||
|
||||
列出项目中的所有模块及其描述信息。
|
||||
|
||||
## 参数
|
||||
|
||||
无参数。
|
||||
|
||||
## 返回结果
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "mod_abc123",
|
||||
"name": "Payments",
|
||||
"description": "Manage charges, refunds, and payment intents",
|
||||
"endpointCount": 15
|
||||
},
|
||||
{
|
||||
"id": "mod_def456",
|
||||
"name": "Customers",
|
||||
"description": "Create and manage customer records",
|
||||
"endpointCount": 8
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 返回字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | string | 模块 ID,用于 `list_endpoints` 和 `search_endpoints` |
|
||||
| `name` | string | 模块名称 |
|
||||
| `description` | string | 模块描述 |
|
||||
| `endpointCount` | number | 模块中的端点数量 |
|
||||
|
||||
## Token 消耗
|
||||
|
||||
约 **100-300 tokens**,取决于模块数量。
|
||||
|
||||
## 使用场景
|
||||
|
||||
- 需要查看模块的详细描述(`get_project_overview` 不包含描述)
|
||||
- 决定要探索哪个模块时
|
||||
- 需要获取模块 ID 用于后续调用
|
||||
|
||||
## 与 get_project_overview 的区别
|
||||
|
||||
`get_project_overview` 返回的模块列表只包含 id、名称和端点数量,不包含描述。如果需要模块描述来判断探索哪个模块,使用 `list_modules`。
|
||||
64
docs/mcp-tools/search-endpoints.md
Normal file
64
docs/mcp-tools/search-endpoints.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# search_endpoints
|
||||
|
||||
按关键词搜索端点。支持跨模块搜索,也可以限定在特定模块内。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `keyword` | string | 是 | 搜索关键词 |
|
||||
| `moduleId` | string | 否 | 限定搜索范围的模块 ID,不填则搜索所有模块 |
|
||||
|
||||
## 搜索范围
|
||||
|
||||
关键词会匹配以下字段(不区分大小写):
|
||||
- 端点路径(`path`)
|
||||
- 端点摘要(`summary`)
|
||||
- 端点描述(`description`)
|
||||
- 操作标识符(`operationId`)
|
||||
|
||||
## 返回结果
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "ep_001",
|
||||
"method": "POST",
|
||||
"path": "/v1/charges",
|
||||
"summary": "Create a charge",
|
||||
"moduleName": "Payments",
|
||||
"deprecated": false
|
||||
},
|
||||
{
|
||||
"id": "ep_005",
|
||||
"method": "POST",
|
||||
"path": "/v1/payment_intents",
|
||||
"summary": "Create a PaymentIntent",
|
||||
"moduleName": "Payments",
|
||||
"deprecated": false
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 返回字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | string | 端点 ID,可传给 `get_endpoint_detail` |
|
||||
| `method` | string | HTTP 方法 |
|
||||
| `path` | string | API 路径 |
|
||||
| `summary` | string | 端点摘要 |
|
||||
| `moduleName` | string | 所属模块名称 |
|
||||
| `deprecated` | boolean | 是否已弃用 |
|
||||
|
||||
最多返回 **20 条**结果,按路径和方法排序。
|
||||
|
||||
## Token 消耗
|
||||
|
||||
约 **200-500 tokens**。
|
||||
|
||||
## 使用场景
|
||||
|
||||
- 不知道端点在哪个模块,通过关键词搜索
|
||||
- 快速定位与某个功能相关的端点
|
||||
- 模糊搜索,如搜索 "user" 找到所有用户相关端点
|
||||
9
docs/nginx.conf
Normal file
9
docs/nginx.conf
Normal file
@@ -0,0 +1,9 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
51
docs/project-management/api-key-management.md
Normal file
51
docs/project-management/api-key-management.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# API Key 管理
|
||||
|
||||
API Key 是 LLM 客户端连接 AgentFox MCP 服务的唯一认证凭证。
|
||||
|
||||
## 基本信息
|
||||
|
||||
- 每个账号只有一个 API Key
|
||||
- 该 Key 对账号下所有项目生效
|
||||
- Key 以 `afk_` 为前缀
|
||||
- 使用 bcrypt 哈希加密存储,不保存明文
|
||||
|
||||
## 生成 API Key
|
||||
|
||||
首次使用时需要生成:
|
||||
|
||||
1. 打开「设置」(点击右上角头像旁的齿轮图标)
|
||||
2. 在「API Key」区域,点击「生成 API Key」
|
||||
3. **立即复制并保存**生成的密钥
|
||||
|
||||
> **重要**:API Key 仅在生成时完整显示一次。
|
||||
|
||||
## 查看/复制已有 Key
|
||||
|
||||
如果之前已生成过 API Key:
|
||||
|
||||
1. 打开「设置」→ API Key 区域
|
||||
2. 点击「查看」或「复制」
|
||||
3. 输入账号密码进行身份验证
|
||||
4. 验证通过后可查看或复制
|
||||
|
||||
> **提示**:通过 Google/GitHub 登录的用户,需要先在「设置」中设置密码,才能执行此操作。
|
||||
|
||||
## 轮换 API Key
|
||||
|
||||
如果密钥泄露或出于安全考虑需要更换:
|
||||
|
||||
1. 打开「设置」→ API Key 区域
|
||||
2. 点击「轮换 API Key」
|
||||
3. 确认操作
|
||||
|
||||
轮换后的影响:
|
||||
- 旧密钥**立即失效**
|
||||
- 所有使用旧密钥的 MCP 客户端需要更新配置
|
||||
- 新密钥同样只显示一次,请立即保存
|
||||
|
||||
## 安全建议
|
||||
|
||||
- 不要将 API Key 提交到版本控制系统
|
||||
- 将包含 API Key 的 MCP 配置文件添加到 `.gitignore`
|
||||
- 定期轮换 API Key
|
||||
- 如果怀疑密钥泄露,立即轮换
|
||||
45
docs/project-management/module-management.md
Normal file
45
docs/project-management/module-management.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 模块管理
|
||||
|
||||
模块是 AgentFox 中端点的分组方式。导入 API 文档时,端点会自动按规则分组到不同模块中。
|
||||
|
||||
## 自动分组规则
|
||||
|
||||
导入 API 文档时,AgentFox 按以下优先级自动创建模块:
|
||||
|
||||
1. **OpenAPI Tags**:如果端点定义了 `tags`,按 tag 名称分组
|
||||
2. **路径前缀**:如果没有 tags,按 URL 路径的第一段分组(如 `/users/...` → `users` 模块)
|
||||
|
||||
## 查看模块
|
||||
|
||||
在项目详情页的「模块」标签中,可以查看所有模块:
|
||||
|
||||
- 模块名称
|
||||
- 模块中的端点数量
|
||||
- 模块来源类型(tag / path_prefix / manual)
|
||||
|
||||
## 手动添加模块
|
||||
|
||||
1. 在「模块」标签页顶部,输入模块名称
|
||||
2. 点击「添加」按钮
|
||||
3. 新模块将显示在列表中(端点数量为 0)
|
||||
|
||||
手动添加的模块可用于重新组织端点。
|
||||
|
||||
## 删除模块
|
||||
|
||||
1. 在模块列表中,找到要删除的模块
|
||||
2. 点击删除按钮
|
||||
3. 确认删除
|
||||
|
||||
> **注意**:删除模块会同时删除模块中的所有端点。此操作不可撤销。
|
||||
|
||||
## 模块对 MCP 工具的影响
|
||||
|
||||
模块直接影响 LLM 的查询体验:
|
||||
|
||||
- `get_project_overview` 返回所有模块的 ID 和端点数量
|
||||
- `list_modules` 返回模块的详细描述
|
||||
- `list_endpoints` 需要传入 `moduleId` 来查看特定模块的端点
|
||||
- `search_endpoints` 可选传入 `moduleId` 来限定搜索范围
|
||||
|
||||
合理的模块划分有助于 LLM 更快定位到所需的端点。
|
||||
34
docs/project-management/reimport-docs.md
Normal file
34
docs/project-management/reimport-docs.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 重新导入文档
|
||||
|
||||
当 API 文档更新后,你可以重新导入来更新 AgentFox 中的数据。
|
||||
|
||||
## 何时需要重新导入?
|
||||
|
||||
- API 新增了端点
|
||||
- 端点的参数或响应格式发生了变化
|
||||
- 模块结构需要重新组织
|
||||
|
||||
## 重新导入步骤
|
||||
|
||||
1. 进入项目详情页 → 「设置」标签
|
||||
2. 在「重新导入 API 文档」区域,点击「重新导入文档」
|
||||
3. 选择导入方式(URL 或上传文件)
|
||||
4. 确认导入
|
||||
|
||||
## 重要提醒
|
||||
|
||||
> **注意**:重新导入会删除当前项目中的所有模块和端点,然后根据新文档重新创建。
|
||||
|
||||
重新导入时:
|
||||
- 所有现有模块将被删除
|
||||
- 所有现有端点将被删除
|
||||
- **API Key 保持不变**(LLM 客户端无需更新配置)
|
||||
- **项目 ID 保持不变**(MCP URL 不变)
|
||||
|
||||
## 支持的格式
|
||||
|
||||
与首次导入相同:
|
||||
- OpenAPI 3.0 / 3.1
|
||||
- Swagger 2.0
|
||||
- JSON 和 YAML 格式
|
||||
- URL 或文件上传
|
||||
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;
|
||||
|
||||
@@ -6,6 +6,8 @@ import projectRouter from './routes/projects.js';
|
||||
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());
|
||||
@@ -18,10 +20,12 @@ app.get('/api/health', (_req, res) => {
|
||||
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/auth/oauth', oauthRouter);
|
||||
app.use('/api/fetch-spec', fetchSpecRouter);
|
||||
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 {
|
||||
|
||||
@@ -57,7 +57,7 @@ function getCallbackUrl(provider: Provider): string {
|
||||
return `${base}/api/auth/oauth/${provider}/callback`;
|
||||
}
|
||||
|
||||
const stateStore = new Map<string, { provider: string; createdAt: number }>();
|
||||
const stateStore = new Map<string, { provider: string; createdAt: number; redirect?: string }>();
|
||||
|
||||
const cleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
@@ -67,22 +67,28 @@ const cleanupTimer = setInterval(() => {
|
||||
}, 5 * 60 * 1000);
|
||||
cleanupTimer.unref();
|
||||
|
||||
function generateState(provider: Provider): string {
|
||||
function isValidRedirect(redirect: string): boolean {
|
||||
return redirect.startsWith('/') && !redirect.startsWith('//');
|
||||
}
|
||||
|
||||
function generateState(provider: Provider, redirect?: string): string {
|
||||
const state = crypto.randomBytes(32).toString('hex');
|
||||
stateStore.set(state, { provider, createdAt: Date.now() });
|
||||
const safeRedirect = redirect && isValidRedirect(redirect) ? redirect : undefined;
|
||||
stateStore.set(state, { provider, createdAt: Date.now(), redirect: safeRedirect });
|
||||
return state;
|
||||
}
|
||||
|
||||
function validateState(state: string, provider: Provider): boolean {
|
||||
function validateState(state: string, provider: Provider): { valid: boolean; redirect?: string } {
|
||||
const entry = stateStore.get(state);
|
||||
if (!entry) return false;
|
||||
if (entry.provider !== provider) return false;
|
||||
if (!entry) return { valid: false };
|
||||
if (entry.provider !== provider) return { valid: false };
|
||||
if (Date.now() - entry.createdAt > 10 * 60 * 1000) {
|
||||
stateStore.delete(state);
|
||||
return false;
|
||||
return { valid: false };
|
||||
}
|
||||
const redirect = entry.redirect;
|
||||
stateStore.delete(state);
|
||||
return true;
|
||||
return { valid: true, redirect };
|
||||
}
|
||||
|
||||
function buildAppleClientSecret(): string {
|
||||
@@ -106,11 +112,11 @@ function buildAppleClientSecret(): string {
|
||||
return `${signingInput}.${sig.toString('base64url')}`;
|
||||
}
|
||||
|
||||
export function buildAuthUrl(provider: Provider): string {
|
||||
export function buildAuthUrl(provider: Provider, redirect?: string): string {
|
||||
const config = providers[provider];
|
||||
if (!config) throw new Error(`Unknown provider: ${provider}`);
|
||||
|
||||
const state = generateState(provider);
|
||||
const state = generateState(provider, redirect);
|
||||
const params = new URLSearchParams({
|
||||
client_id: getClientId(provider),
|
||||
redirect_uri: getCallbackUrl(provider),
|
||||
|
||||
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' } });
|
||||
|
||||
30
packages/server/src/routes/fetch-spec.ts
Normal file
30
packages/server/src/routes/fetch-spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Router, type Router as RouterType } from 'express';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router: RouterType = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// CORS proxy: frontend calls this when direct fetch is blocked by CORS
|
||||
router.get('/', async (req, res) => {
|
||||
const specUrl = req.query.url as string;
|
||||
if (!specUrl || !specUrl.startsWith('http')) {
|
||||
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide a valid URL' } });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(specUrl, {
|
||||
headers: { Accept: 'application/json, application/yaml, text/yaml, text/plain, */*' },
|
||||
});
|
||||
if (!response.ok) {
|
||||
res.status(502).json({ success: false, error: { code: 'FETCH_FAILED', message: `Remote server returned ${response.status}` } });
|
||||
return;
|
||||
}
|
||||
const text = await response.text();
|
||||
res.json({ success: true, data: { content: text, contentType: response.headers.get('content-type') || '' } });
|
||||
} catch (err) {
|
||||
res.status(502).json({ success: false, error: { code: 'FETCH_FAILED', message: err instanceof Error ? err.message : 'Failed to fetch URL' } });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -7,9 +7,9 @@ const router: RouterType = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
router.post('/:id/reimport', async (req, res) => {
|
||||
const { spec, specUrl } = req.body;
|
||||
if (!spec && !specUrl) {
|
||||
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec or specUrl' } });
|
||||
const { spec } = req.body;
|
||||
if (!spec) {
|
||||
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec (JSON or YAML object)' } });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ router.post('/:id/reimport', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const input = specUrl || spec;
|
||||
const input = spec;
|
||||
const parsed = await parseOpenApiDocument(input);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
|
||||
@@ -20,7 +20,8 @@ router.get('/:provider', (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const url = buildAuthUrl(provider);
|
||||
const redirect = req.query.redirect as string | undefined;
|
||||
const url = buildAuthUrl(provider, redirect);
|
||||
res.redirect(url);
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: { code: 'OAUTH_ERROR', message: err instanceof Error ? err.message : 'Failed to build auth URL' } });
|
||||
@@ -56,7 +57,8 @@ async function handleOAuthCallback(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateState(state, provider)) {
|
||||
const stateResult = validateState(state, provider);
|
||||
if (!stateResult.valid) {
|
||||
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Invalid or expired state')}`);
|
||||
return;
|
||||
}
|
||||
@@ -70,9 +72,14 @@ 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 });
|
||||
|
||||
res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}`);
|
||||
const redirectParam = stateResult.redirect ? `&redirect=${encodeURIComponent(stateResult.redirect)}` : '';
|
||||
res.redirect(`${FRONTEND_URL}/login/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}${redirectParam}`);
|
||||
} catch (err) {
|
||||
console.error(`OAuth callback error (${provider}):`, err);
|
||||
res.redirect(`${FRONTEND_URL}/login/callback?error=${encodeURIComponent('Authentication failed')}`);
|
||||
|
||||
@@ -8,14 +8,14 @@ const router: RouterType = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const { spec, specUrl } = req.body;
|
||||
if (!spec && !specUrl) {
|
||||
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec (JSON object) or specUrl (URL string)' } });
|
||||
const { spec } = req.body;
|
||||
if (!spec) {
|
||||
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Provide spec (JSON or YAML object)' } });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const input = specUrl || spec;
|
||||
const input = spec;
|
||||
const parsed = await parseOpenApiDocument(input);
|
||||
|
||||
const project = await prisma.$transaction(async (tx) => {
|
||||
|
||||
@@ -115,18 +115,8 @@ function parseOpenApi3Endpoints(api: OpenApiDoc): { endpoints: ParsedEndpoint[];
|
||||
}
|
||||
|
||||
export async function parseOpenApiDocument(input: string | object): Promise<ParseResult> {
|
||||
let specInput: string | object = input;
|
||||
|
||||
// If input is a URL, fetch the content first so that swagger-parser
|
||||
// works on a plain object and doesn't need network access for $ref resolution
|
||||
if (typeof input === 'string' && input.startsWith('http')) {
|
||||
const res = await fetch(input);
|
||||
if (!res.ok) throw new Error(`Failed to fetch spec from URL: ${res.status} ${res.statusText}`);
|
||||
specInput = await res.json() as object;
|
||||
}
|
||||
|
||||
// Bundle resolves all $refs into a single document, then dereference inlines them
|
||||
const bundled = await SwaggerParser.bundle(specInput as any) as OpenAPI.Document;
|
||||
// SwaggerParser.bundle handles URLs, JSON objects, and YAML strings natively
|
||||
const bundled = await SwaggerParser.bundle(input as any) as OpenAPI.Document;
|
||||
const api = await SwaggerParser.dereference(bundled, {
|
||||
dereference: { circular: 'ignore' },
|
||||
}) as OpenAPI.Document;
|
||||
|
||||
@@ -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,18 +7,27 @@ 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;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
location /docs/ {
|
||||
proxy_pass http://docs:80/;
|
||||
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 / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
@@ -19,6 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.96.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.2"
|
||||
|
||||
@@ -4,12 +4,19 @@ import { AuthProvider } from './lib/auth';
|
||||
import { ThemeProvider } from './lib/theme';
|
||||
import { I18nProvider } from './lib/i18n';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import LoginCallback from './pages/LoginCallback';
|
||||
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() {
|
||||
@@ -22,12 +29,19 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/login/callback" element={<LoginCallback />} />
|
||||
<Route path="/dashboard" element={<Layout />}>
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useI18n, type Locale } from '../lib/i18n';
|
||||
import { useClickOutside } from '../hooks/useClickOutside';
|
||||
|
||||
const languages: { locale: Locale; flag: string; label: string }[] = [
|
||||
{ locale: 'en', flag: '🇺🇸', label: 'English' },
|
||||
@@ -10,15 +11,7 @@ export default function LanguageToggle() {
|
||||
const { locale, setLocale } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
useClickOutside(ref, useCallback(() => setOpen(false), []), open);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
|
||||
@@ -20,25 +20,17 @@ function GitHubIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function AppleIcon() {
|
||||
return (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OAuthButtons() {
|
||||
export default function OAuthButtons({ redirectTo }: { redirectTo?: string }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleOAuth = (provider: string) => {
|
||||
window.location.href = `${API_BASE}/auth/oauth/${provider}`;
|
||||
const params = redirectTo ? `?redirect=${encodeURIComponent(redirectTo)}` : '';
|
||||
window.location.href = `${API_BASE}/auth/oauth/${provider}${params}`;
|
||||
};
|
||||
|
||||
const buttons = [
|
||||
{ provider: 'google', icon: GoogleIcon, label: t('auth.oauth.google') },
|
||||
{ provider: 'github', icon: GitHubIcon, label: t('auth.oauth.github') },
|
||||
{ provider: 'apple', icon: AppleIcon, label: t('auth.oauth.apple') },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,46 +1,82 @@
|
||||
import { useState, useRef, useCallback, type ReactNode } from 'react';
|
||||
import { useTheme } from '../lib/theme';
|
||||
import { useI18n, type TranslationKey } from '../lib/i18n';
|
||||
import { useClickOutside } from '../hooks/useClickOutside';
|
||||
|
||||
const icons = {
|
||||
light: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
const themes: Array<{ key: 'light' | 'dark' | 'system'; icon: ReactNode }> = [
|
||||
{
|
||||
key: 'light',
|
||||
icon: (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="12" cy="12" r="5" /><path d="M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72l1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||
</svg>
|
||||
),
|
||||
dark: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
},
|
||||
{
|
||||
key: 'dark',
|
||||
icon: (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
),
|
||||
system: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
icon: (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8m-4-4v4" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const order: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
|
||||
},
|
||||
];
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, useCallback(() => setOpen(false), []), open);
|
||||
|
||||
const current = themes.find(th => th.key === theme)!;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 p-1 rounded-lg bg-bg-tertiary">
|
||||
{order.map((key) => (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTheme(key)}
|
||||
title={t(`theme.${key}` as TranslationKey)}
|
||||
className={`flex items-center justify-center w-8 h-7 rounded-md transition-all duration-150 ${
|
||||
theme === key
|
||||
? 'bg-bg-elevated text-text-primary shadow-sm'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-1 px-2 py-1.5 rounded-lg text-xs font-medium transition-all duration-150 cursor-pointer hover:bg-bg-tertiary"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
aria-label="Switch theme"
|
||||
>
|
||||
{icons[key]}
|
||||
{current.icon}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute top-full right-0 mt-1.5 min-w-[140px] rounded-xl py-1 z-50 animate-slide-down"
|
||||
style={{
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border-default)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
}}
|
||||
>
|
||||
{themes.map(th => (
|
||||
<button
|
||||
key={th.key}
|
||||
onClick={() => { setTheme(th.key); setOpen(false); }}
|
||||
className="flex items-center gap-2.5 w-full px-3 py-2 text-[13px] transition-colors cursor-pointer rounded-lg mx-0.5"
|
||||
style={{
|
||||
width: 'calc(100% - 4px)',
|
||||
color: theme === th.key ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontWeight: theme === th.key ? 500 : 400,
|
||||
background: theme === th.key ? 'var(--bg-tertiary)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<span className="leading-none">{th.icon}</span>
|
||||
{t(`theme.${th.key}` as TranslationKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
12
packages/web/src/hooks/useClickOutside.ts
Normal file
12
packages/web/src/hooks/useClickOutside.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useEffect, type RefObject } from 'react';
|
||||
|
||||
export function useClickOutside(ref: RefObject<HTMLElement | null>, onClose: () => void, active: boolean) {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [active, ref, onClose]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
32
packages/web/src/lib/fetch-spec.ts
Normal file
32
packages/web/src/lib/fetch-spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import yaml from 'js-yaml';
|
||||
import { apiFetch } from './api';
|
||||
|
||||
function parseSpecText(text: string): object {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return yaml.load(text) as object;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an OpenAPI spec from a URL and parse it.
|
||||
* 1. Try direct fetch from browser (works for localhost/intranet)
|
||||
* 2. If CORS blocks it, fall back to server-side proxy
|
||||
* Returns a parsed spec object (JSON or YAML).
|
||||
*/
|
||||
export async function fetchSpecFromUrl(url: string): Promise<object> {
|
||||
// Try direct fetch first (handles localhost, intranet, CORS-friendly APIs)
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: 'application/json, application/yaml, text/yaml, */*' },
|
||||
});
|
||||
if (res.ok) return parseSpecText(await res.text());
|
||||
} catch {
|
||||
// CORS or network error — fall through to server proxy
|
||||
}
|
||||
|
||||
// Fall back to server-side proxy for CORS-restricted URLs
|
||||
const data = await apiFetch<{ content: string }>(`/fetch-spec?url=${encodeURIComponent(url)}`);
|
||||
return parseSpecText(data.content);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import { fetchSpecFromUrl } from '../lib/fetch-spec';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import Modal from '../components/Modal';
|
||||
|
||||
@@ -44,7 +45,8 @@ export default function ImportDialog({ onClose }: { onClose: () => void }) {
|
||||
try {
|
||||
let body: Record<string, unknown>;
|
||||
if (mode === 'url') {
|
||||
body = { specUrl: url };
|
||||
const spec = await fetchSpecFromUrl(url);
|
||||
body = { spec };
|
||||
} else {
|
||||
try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; }
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import AuthBranding, { MobileBranding } from '../components/AuthBranding';
|
||||
@@ -119,13 +119,10 @@ export default function Login() {
|
||||
<div className="flex-1 h-px bg-border-default" />
|
||||
</div>
|
||||
|
||||
<OAuthButtons />
|
||||
<OAuthButtons redirectTo={redirectTo} />
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[13px] text-text-muted mt-6">
|
||||
{t('auth.login.noAccount')}{' '}
|
||||
<Link to="/register" className="text-accent hover:underline font-medium">{t('auth.login.signUp')}</Link>
|
||||
</p>
|
||||
{/* Sign Up 入口暂时隐藏,待添加验证码后恢复 */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ export default function LoginCallback() {
|
||||
const accessToken = searchParams.get('accessToken');
|
||||
const refreshToken = searchParams.get('refreshToken');
|
||||
const errorParam = searchParams.get('error');
|
||||
const redirectTo = searchParams.get('redirect') || '/dashboard';
|
||||
|
||||
if (errorParam) {
|
||||
setError(errorParam);
|
||||
@@ -29,7 +30,7 @@ export default function LoginCallback() {
|
||||
window.history.replaceState({}, '', '/login/callback');
|
||||
|
||||
loginWithTokens(accessToken, refreshToken)
|
||||
.then(() => navigate('/dashboard', { replace: true }))
|
||||
.then(() => navigate(redirectTo, { replace: true }))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : 'Authentication failed'));
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import { fetchSpecFromUrl } from '../lib/fetch-spec';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import Modal from '../components/Modal';
|
||||
|
||||
@@ -49,7 +50,8 @@ export default function ReimportDialog({ projectId, currentStats, onClose, onSuc
|
||||
try {
|
||||
let body: Record<string, unknown>;
|
||||
if (mode === 'url') {
|
||||
body = { specUrl: url };
|
||||
const spec = await fetchSpecFromUrl(url);
|
||||
body = { spec };
|
||||
} else {
|
||||
try { body = { spec: JSON.parse(fileContent) }; } catch { body = { spec: fileContent }; }
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export default function FooterSection() {
|
||||
links: [
|
||||
{ label: t('footer.features'), href: '#features' },
|
||||
{ label: t('footer.pricing'), href: '#pricing' },
|
||||
{ label: t('footer.docs'), href: '#' },
|
||||
{ label: t('footer.docs'), href: '/docs/' },
|
||||
{ label: t('footer.changelog'), href: '#' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -131,7 +131,7 @@ export default function HeroSection() {
|
||||
{t('hero.cta')}
|
||||
</Link>
|
||||
<a
|
||||
href="#features"
|
||||
href="/docs/"
|
||||
className="px-7 py-3.5 rounded-xl text-base font-medium transition-all duration-200 hover:-translate-y-0.5"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function LandingNav({ scrollRef }: { scrollRef: React.RefObject<H
|
||||
{/* Logged out: Sign In + Get Started */}
|
||||
{!loading && !user && (
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<Link to="/login" className="px-3 py-1.5 rounded-lg text-sm text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all duration-150">
|
||||
<Link to="/login?redirect=/" className="px-3 py-1.5 rounded-lg text-sm text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all duration-150">
|
||||
{t('nav.signIn')}
|
||||
</Link>
|
||||
<Link
|
||||
@@ -228,7 +228,7 @@ export default function LandingNav({ scrollRef }: { scrollRef: React.RefObject<H
|
||||
<div className="border-t border-border-muted my-4!" />
|
||||
{!loading && !user && (
|
||||
<div className="space-y-2 px-4">
|
||||
<Link to="/login" onClick={() => setMobileOpen(false)} className="block w-full text-center px-4 py-3 rounded-xl text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all text-lg">
|
||||
<Link to="/login?redirect=/" onClick={() => setMobileOpen(false)} className="block w-full text-center px-4 py-3 rounded-xl text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all text-lg">
|
||||
{t('nav.signIn')}
|
||||
</Link>
|
||||
<Link
|
||||
|
||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -117,6 +117,9 @@ importers:
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.96.1
|
||||
version: 5.96.1(react@19.2.4)
|
||||
js-yaml:
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1
|
||||
react:
|
||||
specifier: ^19.2.4
|
||||
version: 19.2.4
|
||||
@@ -130,6 +133,9 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.5)(jiti@2.6.1)(tsx@4.21.0))
|
||||
'@types/js-yaml':
|
||||
specifier: ^4.0.9
|
||||
version: 4.0.9
|
||||
'@types/react':
|
||||
specifier: ^19.2.14
|
||||
version: 19.2.14
|
||||
@@ -633,6 +639,9 @@ packages:
|
||||
'@types/http-errors@2.0.5':
|
||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||
|
||||
'@types/js-yaml@4.0.9':
|
||||
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
@@ -1830,6 +1839,8 @@ snapshots:
|
||||
|
||||
'@types/http-errors@2.0.5': {}
|
||||
|
||||
'@types/js-yaml@4.0.9': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/jsonwebtoken@9.0.10':
|
||||
|
||||
@@ -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