Compare commits

..

4 Commits

Author SHA1 Message Date
71c604411d feat: deploy skill 2026-04-12 20:15:41 +08:00
6fe04f4893 feat: 添加 Admin 管理后台
- 数据库新增 Role 枚举、disabled 字段和 McpCallLog 调用日志表
- 后端新增 requireAdmin 中间件和 /api/admin/* 管理接口(统计、用户、项目、日志)
- MCP 工具调用自动记录详细日志(耗时、参数、响应大小、客户端IP、token估算)
- 前端新增 /admin 路由区域:仪表盘、用户管理、项目管理、调用日志四个页面
- JWT 携带 role 字段,登录/OAuth 增加禁用账号检查
- nginx 配置补充 X-Forwarded-For 透传真实客户端 IP

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 13:04:44 +08:00
d45cc45815 docs: 更新 CLAUDE.md 补充部署、Docker、OAuth 等上下文
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:36:32 +08:00
f3fbd3876a refactor: OpenAPI URL 抓取改为前端执行 + 服务端 CORS 代理
- 前端直接 fetch URL 支持 localhost/内网地址
- CORS 失败自动回退到服务端代理 /api/fetch-spec
- 添加 js-yaml 支持 YAML 格式解析
- 服务端移除 specUrl 参数,只接收已解析的 spec 对象

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:28:53 +08:00
72 changed files with 3657 additions and 48 deletions

View File

@@ -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`)

View File

@@ -51,6 +51,26 @@ services:
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: .

View File

@@ -54,6 +54,11 @@ services:
server:
condition: service_healthy
docs:
build:
context: ./docs
dockerfile: Dockerfile
web:
build:
context: .
@@ -63,6 +68,7 @@ services:
depends_on:
- server
- mcp
- docs
volumes:
pgdata:

6
docs/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.worktrees
.claude
.git
dist
*.zip

26
docs/Dockerfile Normal file
View 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
View File

@@ -0,0 +1,39 @@
# AgentFox 文档
> **AgentFox** — 为 LLM 而生的 API 文档服务
AgentFox 将你的 OpenAPI / Swagger 文档转换为 MCPModel 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
View 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
View 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
View 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`

View 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。

View 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
View 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
View 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
View 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
```

View 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 泄露。

View File

@@ -0,0 +1,82 @@
# 其他 MCP 客户端
任何支持 MCP 协议 Streamable HTTP 传输的 AI 工具都可以连接 AgentFox。
## 连接要素
连接 AgentFox 只需要三个信息:
| 要素 | 值 |
|------|-----|
| **传输类型** | HTTPStreamable 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发送请求、GETSSE 会话恢复、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
View 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
View File

@@ -0,0 +1,47 @@
# 常见问题
## 什么是 MCPAgentFox 如何使用它?
MCPModel 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 Keybcrypt 哈希加密从不以明文存储。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 即可。

View 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 工具获取所需信息,无需你手动复制文档。

View 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)

View 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) 了。

View 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 服务。

View 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)。

View 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)

View File

@@ -0,0 +1,65 @@
# MCP 协议介绍
MCPModel 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
View File

@@ -0,0 +1,48 @@
# MCP 工具概述
AgentFox 提供 5 个 MCP 工具,采用渐进式下钻设计,让 LLM 按需获取精确信息。
## 设计理念
传统方式是将完整 API 规范一次性提供给 LLM10,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) — 按关键词搜索端点

View 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 消耗最多的工具,但也是信息最丰富的。
## 使用场景
- 需要了解如何调用某个具体端点
- 查看请求参数的类型、是否必填、描述
- 查看响应格式以便解析返回数据

View 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`

View 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 提供了哪些操作

View 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`

View 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
View File

@@ -0,0 +1,9 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

View 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
- 如果怀疑密钥泄露,立即轮换

View 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 更快定位到所需的端点。

View 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 或文件上传

View File

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

View File

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

View File

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

View File

@@ -5,39 +5,43 @@ import { listModules } from './tools/list-modules.js';
import { listEndpoints } from './tools/list-endpoints.js';
import { 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;

View File

@@ -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, () => {

View File

@@ -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 {

View File

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

View File

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

View File

@@ -40,8 +40,8 @@ router.post('/register', async (req, res) => {
data: { email, passwordHash, name },
});
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' } });

View 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;

View File

@@ -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) => {

View File

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

View File

@@ -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) => {

View File

@@ -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;

View File

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

View File

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

View File

@@ -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"

View File

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

View File

@@ -1,9 +1,9 @@
import { useState, useRef, useCallback } from 'react';
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 themes: Array<{ key: 'light' | 'dark' | 'system'; icon: JSX.Element }> = [
const themes: Array<{ key: 'light' | 'dark' | 'system'; icon: ReactNode }> = [
{
key: 'light',
icon: (

View File

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

View File

@@ -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;

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

View File

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

View File

@@ -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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,144 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { apiFetch } from '../../lib/api';
type ProjectItem = {
id: string;
name: string;
description: string | null;
openApiVersion: string;
createdAt: string;
user: { id: string; name: string; email: string };
_count: { endpoints: number; modules: number };
};
type ProjectsResponse = {
projects: ProjectItem[];
total: number;
page: number;
limit: number;
};
export default function AdminProjects() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const limit = 20;
const { data, isLoading } = useQuery({
queryKey: ['admin', 'projects', page, search],
queryFn: () => {
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (search) params.set('search', search);
return apiFetch<ProjectsResponse>(`/admin/projects?${params}`);
},
});
const totalPages = data ? Math.ceil(data.total / limit) : 0;
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
setPage(1);
};
return (
<div className="space-y-4 animate-fade-in">
{/* Header */}
<div>
<h2 className="text-lg font-bold text-text-primary font-heading"></h2>
<p className="text-[12px] text-text-muted mt-0.5"> {data?.total ?? 0} </p>
</div>
{/* Search */}
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="搜索项目名称..."
className="input-base max-w-xs"
/>
<button type="submit" className="btn-primary text-[13px] px-3"></button>
{search && (
<button type="button" className="btn-ghost text-[13px] px-3" onClick={() => { setSearch(''); setSearchInput(''); setPage(1); }}>
</button>
)}
</form>
{/* Table */}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-[13px]">
<thead>
<tr className="border-b border-border-default bg-bg-secondary">
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
<th className="text-right px-4 py-3 font-medium text-text-muted"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border-muted">
{Array.from({ length: 7 }).map((_, j) => (
<td key={j} className="px-4 py-3"><div className="skeleton h-4 w-20" /></td>
))}
</tr>
))
) : data?.projects.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-12 text-center text-text-muted"></td>
</tr>
) : (
data?.projects.map((project) => (
<tr key={project.id} className="border-b border-border-muted hover:bg-bg-secondary/50 transition-colors">
<td className="px-4 py-3">
<div className="font-medium text-text-primary">{project.name}</div>
{project.description && (
<div className="text-[11px] text-text-muted truncate max-w-[200px]">{project.description}</div>
)}
</td>
<td className="px-4 py-3">
<Link to={`/admin/users/${project.user.id}`} className="text-text-secondary hover:text-accent transition-colors">
{project.user.name}
</Link>
</td>
<td className="px-4 py-3">
<span className="font-mono text-[11px] text-text-muted">{project.openApiVersion}</span>
</td>
<td className="px-4 py-3 text-text-secondary">{project._count.modules}</td>
<td className="px-4 py-3 text-text-secondary">{project._count.endpoints}</td>
<td className="px-4 py-3 text-text-muted">{new Date(project.createdAt).toLocaleDateString('zh-CN')}</td>
<td className="px-4 py-3 text-right">
<Link to={`/admin/projects/${project.id}`} className="text-accent hover:text-accent-hover text-[12px] font-medium transition-colors">
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-[12px] text-text-muted"> {page} / {totalPages} </span>
<div className="flex gap-1.5">
<button className="btn-outline text-[12px] px-2.5 py-1.5" disabled={page <= 1} onClick={() => setPage(p => p - 1)}></button>
<button className="btn-outline text-[12px] px-2.5 py-1.5" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}></button>
</div>
</div>
)}
</div>
);
}

View File

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

View File

@@ -0,0 +1,166 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { apiFetch } from '../../lib/api';
type UserItem = {
id: string;
email: string;
name: string;
role: string;
disabled: boolean;
createdAt: string;
avatarUrl: string | null;
_count: { projects: number };
};
type UsersResponse = {
users: UserItem[];
total: number;
page: number;
limit: number;
};
export default function Users() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const limit = 20;
const { data, isLoading } = useQuery({
queryKey: ['admin', 'users', page, search],
queryFn: () => {
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
if (search) params.set('search', search);
return apiFetch<UsersResponse>(`/admin/users?${params}`);
},
});
const totalPages = data ? Math.ceil(data.total / limit) : 0;
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
setPage(1);
};
return (
<div className="space-y-4 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-text-primary font-heading"></h2>
<p className="text-[12px] text-text-muted mt-0.5"> {data?.total ?? 0} </p>
</div>
</div>
{/* Search */}
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="搜索用户名或邮箱..."
className="input-base max-w-xs"
/>
<button type="submit" className="btn-primary text-[13px] px-3"></button>
{search && (
<button type="button" className="btn-ghost text-[13px] px-3" onClick={() => { setSearch(''); setSearchInput(''); setPage(1); }}>
</button>
)}
</form>
{/* Table */}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-[13px]">
<thead>
<tr className="border-b border-border-default bg-bg-secondary">
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
<th className="text-left px-4 py-3 font-medium text-text-muted"></th>
<th className="text-right px-4 py-3 font-medium text-text-muted"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border-muted">
<td className="px-4 py-3"><div className="skeleton h-4 w-40" /></td>
<td className="px-4 py-3"><div className="skeleton h-4 w-16" /></td>
<td className="px-4 py-3"><div className="skeleton h-4 w-8" /></td>
<td className="px-4 py-3"><div className="skeleton h-4 w-24" /></td>
<td className="px-4 py-3"><div className="skeleton h-4 w-16" /></td>
<td className="px-4 py-3"><div className="skeleton h-4 w-12 ml-auto" /></td>
</tr>
))
) : data?.users.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-text-muted"></td>
</tr>
) : (
data?.users.map((user) => (
<tr key={user.id} className="border-b border-border-muted hover:bg-bg-secondary/50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[9px] font-bold shrink-0">
{user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div className="min-w-0">
<div className="font-medium text-text-primary truncate">{user.name}</div>
<div className="text-[11px] text-text-muted truncate">{user.email}</div>
</div>
</div>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide ${
user.role === 'ADMIN' ? 'bg-accent-muted text-accent' : 'bg-bg-tertiary text-text-muted'
}`}>
{user.role}
</span>
</td>
<td className="px-4 py-3 text-text-secondary">{user._count.projects}</td>
<td className="px-4 py-3 text-text-muted">{new Date(user.createdAt).toLocaleDateString('zh-CN')}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1 text-[11px] font-medium ${user.disabled ? 'text-danger' : 'text-success'}`}>
<span className={`w-1.5 h-1.5 rounded-full ${user.disabled ? 'bg-danger' : 'bg-success'}`} />
{user.disabled ? '已禁用' : '正常'}
</span>
</td>
<td className="px-4 py-3 text-right">
<Link to={`/admin/users/${user.id}`} className="text-accent hover:text-accent-hover text-[12px] font-medium transition-colors">
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-[12px] text-text-muted"> {page} / {totalPages} </span>
<div className="flex gap-1.5">
<button
className="btn-outline text-[12px] px-2.5 py-1.5"
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
></button>
<button
className="btn-outline text-[12px] px-2.5 py-1.5"
disabled={page >= totalPages}
onClick={() => setPage(p => p + 1)}
></button>
</div>
</div>
)}
</div>
);
}

View File

@@ -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: '#' },
],
},

View File

@@ -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)',

11
pnpm-lock.yaml generated
View File

@@ -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':

View File

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

View File

@@ -7,12 +7,19 @@ datasource db {
url = env("DATABASE_URL")
}
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,8 +40,26 @@ 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())
id String @id @default(uuid())
userId String
name String
description String?
@@ -43,9 +68,10 @@ model Project {
openApiVersion String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
modules Module[]
endpoints Endpoint[]
mcpCallLogs McpCallLog[]
}
enum ModuleSource {