Compare commits

..

No commits in common. "c005964b5dbe2e3c62e96c6cf8b7c8cf0c788f51" and "dc112de8405db79ecb4c9fe03bca443e32f55b47" have entirely different histories.

28 changed files with 141 additions and 1564 deletions

View File

@ -1,5 +1,5 @@
# 使用国内镜像加速
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/python:3.12-slim
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/python:3.9-slim
# 设置工作目录
WORKDIR /app

View File

@ -11,7 +11,6 @@ import os
import uuid
from pathlib import Path
import aiofiles
import secrets
from app.core.database import get_db
from app.core.security import verify_password, get_password_hash, create_access_token
@ -19,17 +18,8 @@ from app.core.deps import get_current_user
from app.core.redis_client import TokenCache
from app.core.config import settings
from app.models.user import User
from app.models.mcp_bot import MCPBot
from app.models.role import Role, UserRole
from app.schemas.user import (
UserCreate,
UserLogin,
UserResponse,
Token,
ChangePassword,
UserUpdate,
MCPBotCredentialResponse,
)
from app.schemas.user import UserCreate, UserLogin, UserResponse, Token, ChangePassword, UserUpdate
from app.schemas.response import success_response, error_response
from app.services.log_service import log_service
from app.core.enums import OperationType, ResourceType
@ -38,14 +28,6 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def generate_bot_id() -> str:
return f"nexbot_{uuid.uuid4().hex[:16]}"
def generate_bot_secret() -> str:
return f"nxbotsec_{secrets.token_urlsafe(24)}"
@router.post("/register", response_model=dict)
async def register(
user_in: UserCreate,
@ -233,58 +215,6 @@ async def logout(
return success_response(message="退出成功")
@router.get("/mcp-credentials", response_model=dict)
async def get_or_create_mcp_credentials(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Get or create MCP credentials for the current user."""
result = await db.execute(select(MCPBot).where(MCPBot.user_id == current_user.id))
mcp_bot = result.scalar_one_or_none()
if not mcp_bot:
mcp_bot = MCPBot(
user_id=current_user.id,
bot_id=generate_bot_id(),
bot_secret=generate_bot_secret(),
status=1,
)
db.add(mcp_bot)
await db.commit()
await db.refresh(mcp_bot)
data = MCPBotCredentialResponse.model_validate(mcp_bot)
return success_response(data=data.model_dump(), message="获取 MCP 凭证成功")
@router.post("/mcp-credentials/rotate-secret", response_model=dict)
async def rotate_mcp_secret(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Rotate MCP secret for the current user."""
result = await db.execute(select(MCPBot).where(MCPBot.user_id == current_user.id))
mcp_bot = result.scalar_one_or_none()
if not mcp_bot:
mcp_bot = MCPBot(
user_id=current_user.id,
bot_id=generate_bot_id(),
bot_secret=generate_bot_secret(),
status=1,
)
db.add(mcp_bot)
else:
mcp_bot.bot_secret = generate_bot_secret()
mcp_bot.status = 1
await db.commit()
await db.refresh(mcp_bot)
data = MCPBotCredentialResponse.model_validate(mcp_bot)
return success_response(data=data.model_dump(), message="MCP Secret 已重新生成")
@router.post("/upload-avatar", response_model=dict)
async def upload_avatar(
file: UploadFile = File(...),

View File

@ -1,3 +0,0 @@
from app.mcp.server import create_mcp_http_app, get_mcp_session_manager, MCPHeaderAuthApp
__all__ = ["create_mcp_http_app", "get_mcp_session_manager", "MCPHeaderAuthApp"]

View File

@ -1,20 +0,0 @@
"""
Context for MCP requests authenticated via bot headers.
"""
from __future__ import annotations
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Optional
@dataclass
class MCPRequestContext:
bot_id: str
user_id: int
current_mcp_request: ContextVar[Optional[MCPRequestContext]] = ContextVar(
"current_mcp_request",
default=None,
)

View File

@ -1,344 +0,0 @@
"""
Backend-integrated MCP Streamable HTTP server.
"""
from __future__ import annotations
from datetime import datetime
import hmac
from pathlib import Path
from typing import Any, Dict, List
from fastapi import HTTPException, Response
from sqlalchemy import select
try:
from mcp.server.fastmcp import FastMCP
except ImportError: # pragma: no cover - runtime dependency
FastMCP = None
from app.core.database import AsyncSessionLocal
from app.models.mcp_bot import MCPBot
from app.models.project import Project, ProjectMember
from app.models.user import User
from app.schemas.project import ProjectResponse
from app.services.notification_service import notification_service
from app.services.search_service import search_service
from app.services.storage import storage_service
from app.services.log_service import log_service
from app.api.v1.projects import get_document_count
from app.api.v1.files import check_project_access
from app.core.config import settings
from app.core.enums import OperationType
from app.mcp.context import MCPRequestContext, current_mcp_request
mcp = (
FastMCP(
"NexDocs MCP",
host=settings.HOST,
port=settings.PORT,
stateless_http=True,
json_response=True,
streamable_http_path="/",
)
if FastMCP
else None
)
async def _get_current_user(db) -> User:
ctx = current_mcp_request.get()
if ctx is None:
raise RuntimeError("MCP request context is missing.")
result = await db.execute(select(User).where(User.id == ctx.user_id, User.status == 1))
user = result.scalar_one_or_none()
if not user:
raise RuntimeError("Authenticated MCP user does not exist or is disabled.")
return user
async def _get_project_with_write_access(project_id: int, current_user: User, db):
return await check_project_access(project_id, current_user, db, require_write=True)
def _ensure_file_exists(file_path: Path, path: str) -> None:
if not file_path.exists():
raise HTTPException(status_code=404, detail=f"文件不存在: {path}")
if not file_path.is_file():
raise HTTPException(status_code=400, detail=f"目标不是文件: {path}")
def _ensure_file_not_exists(file_path: Path, path: str) -> None:
if file_path.exists():
raise HTTPException(status_code=400, detail=f"文件已存在: {path}")
async def _update_markdown_index(project_id: int, path: str, content: str) -> None:
if path.endswith(".md"):
await search_service.update_doc(project_id, path, Path(path).stem, content)
async def _remove_markdown_index(project_id: int, path: str) -> None:
if path.endswith(".md"):
await search_service.remove_doc(project_id, path)
if mcp is not None:
@mcp.tool(name="list_created_projects", description="Get projects created by the authenticated user.")
async def list_created_projects(keyword: str = "", limit: int = 100) -> List[Dict[str, Any]]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
result = await db.execute(
select(Project).where(Project.owner_id == current_user.id, Project.status == 1)
)
projects = result.scalars().all()
items = []
keyword_lower = keyword.strip().lower()
for project in projects:
project_dict = ProjectResponse.from_orm(project).dict()
project_dict["doc_count"] = get_document_count(project.storage_key)
if keyword_lower:
haystack = f"{project.name} {project.description or ''}".lower()
if keyword_lower not in haystack:
continue
items.append(project_dict)
return items[: max(limit, 0)]
@mcp.tool(name="get_project_tree", description="Get the directory tree of a specific project.")
async def get_project_tree(project_id: int) -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
project = await check_project_access(project_id, current_user, db)
project_root = storage_service.get_secure_path(project.storage_key)
tree = storage_service.generate_tree(project_root)
user_role = "owner"
if project.owner_id != current_user.id:
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id,
)
)
member = member_result.scalar_one_or_none()
if member:
user_role = member.role
return {
"tree": [item.model_dump() for item in tree],
"user_role": user_role,
"project_name": project.name,
"project_description": project.description,
}
@mcp.tool(name="get_file", description="Read a file from a specific project.")
async def get_file(project_id: int, path: str) -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
project = await check_project_access(project_id, current_user, db)
file_path = storage_service.get_secure_path(project.storage_key, path)
_ensure_file_exists(file_path, path)
content = await storage_service.read_file(file_path)
return {"path": path, "content": content}
@mcp.tool(name="create_file", description="Create a new file in a specific project path.")
async def create_file(project_id: int, path: str, content: str = "") -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
project = await _get_project_with_write_access(project_id, current_user, db)
file_path = storage_service.get_secure_path(project.storage_key, path)
_ensure_file_not_exists(file_path, path)
await storage_service.write_file(file_path, content)
await _update_markdown_index(project_id, path, content)
await log_service.log_file_operation(
db=db,
operation_type=OperationType.CREATE_FILE,
project_id=project_id,
file_path=path,
user=current_user,
detail={"content_length": len(content), "source": "mcp"},
request=None,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=current_user.id,
title="项目文档创建",
content=(
f"项目 [{project.name}] 中的文档 [{path}] "
f"已被 {current_user.nickname or current_user.username} 通过 MCP 创建。"
),
link=f"/projects/{project_id}/docs?file={path}",
category="project",
)
return {
"message": "文件创建成功",
"project_id": project_id,
"path": path,
}
@mcp.tool(name="update_file", description="Update an existing file in a specific project path.")
async def update_file(project_id: int, path: str, content: str) -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
project = await _get_project_with_write_access(project_id, current_user, db)
file_path = storage_service.get_secure_path(project.storage_key, path)
_ensure_file_exists(file_path, path)
await storage_service.write_file(file_path, content)
await _update_markdown_index(project_id, path, content)
await log_service.log_file_operation(
db=db,
operation_type=OperationType.SAVE_FILE,
project_id=project_id,
file_path=path,
user=current_user,
detail={"content_length": len(content), "source": "mcp"},
request=None,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=current_user.id,
title="项目文档更新",
content=(
f"项目 [{project.name}] 中的文档 [{path}] "
f"已被 {current_user.nickname or current_user.username} 通过 MCP 更新。"
),
link=f"/projects/{project_id}/docs?file={path}",
category="project",
)
return {
"message": "文件更新成功",
"project_id": project_id,
"path": path,
}
@mcp.tool(name="delete_file", description="Delete an existing file in a specific project path.")
async def delete_file(project_id: int, path: str) -> Dict[str, Any]:
async with AsyncSessionLocal() as db:
current_user = await _get_current_user(db)
project = await _get_project_with_write_access(project_id, current_user, db)
file_path = storage_service.get_secure_path(project.storage_key, path)
_ensure_file_exists(file_path, path)
await storage_service.delete_file(file_path)
await _remove_markdown_index(project_id, path)
await log_service.log_file_operation(
db=db,
operation_type=OperationType.DELETE_FILE,
project_id=project_id,
file_path=path,
user=current_user,
detail={"source": "mcp"},
request=None,
)
await notification_service.notify_project_members(
db=db,
project_id=project_id,
exclude_user_id=current_user.id,
title="项目文档删除",
content=(
f"项目 [{project.name}] 中的文档 [{path}] "
f"已被 {current_user.nickname or current_user.username} 通过 MCP 删除。"
),
category="project",
)
return {
"message": "文件删除成功",
"project_id": project_id,
"path": path,
}
def create_mcp_http_app():
"""Return the MCP streamable HTTP ASGI app."""
if mcp is None:
raise RuntimeError("Package 'mcp' is required to run the MCP endpoint.")
return mcp.streamable_http_app()
def get_mcp_session_manager():
"""Return the MCP streamable HTTP session manager."""
if mcp is None:
raise RuntimeError("Package 'mcp' is required to run the MCP endpoint.")
return mcp.session_manager
class MCPHeaderAuthApp:
"""ASGI wrapper that authenticates incoming MCP requests via bot headers."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
headers = {
key.decode("latin-1").lower(): value.decode("latin-1")
for key, value in scope.get("headers", [])
}
bot_id = headers.get("x-bot-id", "").strip()
bot_secret = headers.get("x-bot-secret", "").strip()
if not bot_id or not bot_secret:
response = Response(
content='{"error":"Missing X-Bot-Id or X-Bot-Secret"}',
status_code=401,
media_type="application/json",
)
await response(scope, receive, send)
return
async with AsyncSessionLocal() as db:
result = await db.execute(
select(MCPBot, User)
.join(User, User.id == MCPBot.user_id)
.where(MCPBot.bot_id == bot_id, MCPBot.status == 1, User.status == 1)
)
row = result.first()
if not row:
response = Response(
content='{"error":"Invalid MCP bot"}',
status_code=403,
media_type="application/json",
)
await response(scope, receive, send)
return
mcp_bot, user = row
if not hmac.compare_digest(mcp_bot.bot_secret, bot_secret):
response = Response(
content='{"error":"Invalid MCP secret"}',
status_code=403,
media_type="application/json",
)
await response(scope, receive, send)
return
mcp_bot.last_used_at = datetime.utcnow()
await db.commit()
token = current_mcp_request.set(MCPRequestContext(bot_id=bot_id, user_id=user.id))
try:
await self.app(scope, receive, send)
finally:
current_mcp_request.reset(token)

View File

@ -8,7 +8,6 @@ from app.models.menu import SystemMenu, RoleMenu
from app.models.project import Project, ProjectMember, ProjectMemberRole
from app.models.document import DocumentMeta
from app.models.log import OperationLog
from app.models.mcp_bot import MCPBot
__all__ = [
"Base",
@ -22,5 +21,4 @@ __all__ = [
"ProjectMemberRole",
"DocumentMeta",
"OperationLog",
"MCPBot",
]

View File

@ -1,25 +0,0 @@
"""
MCP bot credential model.
"""
from sqlalchemy import Column, BigInteger, String, DateTime, SmallInteger
from sqlalchemy.sql import func
from app.core.database import Base
class MCPBot(Base):
"""Stores MCP access credentials mapped to a NexDocs user."""
__tablename__ = "mcp_bots"
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="Bot credential ID")
user_id = Column(BigInteger, nullable=False, unique=True, index=True, comment="Owner user ID")
bot_id = Column(String(64), nullable=False, unique=True, index=True, comment="External MCP bot id")
bot_secret = Column(String(255), nullable=False, comment="External MCP bot secret")
status = Column(SmallInteger, default=1, index=True, comment="Status: 0-disabled 1-enabled")
last_used_at = Column(DateTime, comment="Last successful MCP access time")
created_at = Column(DateTime, server_default=func.now(), comment="Created at")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="Updated at")
def __repr__(self):
return f"<MCPBot(user_id={self.user_id}, bot_id='{self.bot_id}')>"

View File

@ -9,8 +9,6 @@ from app.schemas.user import (
UserLogin,
Token,
ChangePassword,
MCPBotCredentialResponse,
MCPBotCredentialCreateResponse,
)
from app.schemas.project import (
ProjectBase,
@ -44,8 +42,6 @@ __all__ = [
"UserLogin",
"Token",
"ChangePassword",
"MCPBotCredentialResponse",
"MCPBotCredentialCreateResponse",
# Project
"ProjectBase",
"ProjectCreate",

View File

@ -57,21 +57,3 @@ class ChangePassword(BaseModel):
"""修改密码 Schema"""
old_password: str = Field(..., description="旧密码")
new_password: str = Field(..., min_length=6, max_length=50, description="新密码")
class MCPBotCredentialResponse(BaseModel):
"""MCP bot credential response."""
bot_id: str
bot_secret: str
status: int
last_used_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class MCPBotCredentialCreateResponse(MCPBotCredentialResponse):
"""Returned when credential is created or rotated."""
message: Optional[str] = None

View File

@ -7,15 +7,6 @@ from contextlib import asynccontextmanager
from app.core.config import settings
from app.core.redis_client import init_redis, close_redis
from app.api.v1 import api_router
from app.mcp import create_mcp_http_app, get_mcp_session_manager, MCPHeaderAuthApp
try:
mcp_http_app = create_mcp_http_app()
mcp_session_manager = get_mcp_session_manager()
except RuntimeError:
mcp_http_app = None
mcp_session_manager = None
@asynccontextmanager
@ -23,10 +14,6 @@ async def lifespan(app: FastAPI):
"""应用生命周期管理"""
# 启动时初始化 Redis
await init_redis()
if mcp_session_manager is not None:
async with mcp_session_manager.run():
yield
else:
yield
# 关闭时清理 Redis 连接
await close_redis()
@ -53,10 +40,6 @@ app.add_middleware(
# 注册 API 路由
app.include_router(api_router, prefix="/api/v1")
# 挂载 MCP Streamable HTTP 入口
if mcp_http_app is not None:
app.mount("/mcp", MCPHeaderAuthApp(mcp_http_app))
@app.get("/")
async def root():

View File

@ -25,13 +25,14 @@ MarkupSafe==3.0.3
passlib==1.7.4
pyasn1==0.6.1
pycparser==2.23
pydantic>=2.11,<3
pydantic-settings>=2.6,<3
pydantic==2.5.3
pydantic-settings==2.1.0
pydantic_core==2.14.6
PyMySQL==1.1.0
python-dotenv==1.0.0
python-jose==3.3.0
python-magic==0.4.27
python-multipart>=0.0.9
python-multipart==0.0.6
PyYAML==6.0.1
redis==5.0.1
rsa==4.9.1
@ -39,7 +40,7 @@ six==1.17.0
SQLAlchemy==2.0.25
starlette==0.35.1
typing_extensions==4.15.0
uvicorn>=0.31.1
uvicorn==0.27.0
uvloop==0.22.1
watchfiles==1.1.1
websockets==15.0.1
@ -47,4 +48,3 @@ Whoosh==2.7.4
markdown==3.5.2
weasyprint==61.2
pydyf<0.11.0
mcp==1.26.0

View File

@ -1,14 +0,0 @@
CREATE TABLE IF NOT EXISTS `mcp_bots` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Bot credential ID',
`user_id` BIGINT NOT NULL COMMENT 'Owner user ID',
`bot_id` VARCHAR(64) NOT NULL COMMENT 'External MCP bot id',
`bot_secret` VARCHAR(255) NOT NULL COMMENT 'External MCP bot secret',
`status` TINYINT DEFAULT 1 COMMENT 'Status: 0-disabled 1-enabled',
`last_used_at` DATETIME DEFAULT NULL COMMENT 'Last successful MCP access time',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Created at',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Updated at',
UNIQUE KEY `uk_mcp_bots_user_id` (`user_id`),
UNIQUE KEY `uk_mcp_bots_bot_id` (`bot_id`),
INDEX `idx_mcp_bots_status` (`status`),
CONSTRAINT `fk_mcp_bots_user_id` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MCP bot credentials';

View File

@ -166,22 +166,6 @@ CREATE TABLE IF NOT EXISTS `operation_logs` (
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';
-- 10. MCP Bot 凭证表
CREATE TABLE IF NOT EXISTS `mcp_bots` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Bot credential ID',
`user_id` BIGINT NOT NULL COMMENT 'Owner user ID',
`bot_id` VARCHAR(64) NOT NULL COMMENT 'External MCP bot id',
`bot_secret` VARCHAR(255) NOT NULL COMMENT 'External MCP bot secret',
`status` TINYINT DEFAULT 1 COMMENT 'Status: 0-disabled 1-enabled',
`last_used_at` DATETIME DEFAULT NULL COMMENT 'Last successful MCP access time',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Created at',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Updated at',
UNIQUE KEY `uk_mcp_bots_user_id` (`user_id`),
UNIQUE KEY `uk_mcp_bots_bot_id` (`bot_id`),
INDEX `idx_mcp_bots_status` (`status`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MCP bot credentials';
-- 插入初始角色数据
INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES
('超级管理员', 'super_admin', '拥有系统所有权限', 1),

View File

@ -1,295 +0,0 @@
# NexDocs MCP 使用文档
## 1. 方案说明
NexDocs 现在不再使用项目根目录下的独立 `mcp_server/`
当前方案为后端内集成 MCP
- 业务 REST API 继续走 `/api/v1/...`
- MCP 入口挂载在同一个 backend 服务上
- 传输协议为 `streamableHttp`
- MCP 地址为 `/mcp/`
- 认证方式为 `X-Bot-Id` + `X-Bot-Secret`
这意味着:
- 不需要单独部署一套 MCP 服务
- 不需要让 MCP client 传业务账号密码
- 不需要让远程 agent 访问本地脚本进程
## 2. 接口地址
MCP 对外地址:
```text
http://<backend-host>:<backend-port>/mcp/
```
例如:
```text
http://backend.internal:8000/mcp/
```
说明:
- 请直接使用带尾部 `/` 的地址
- 请求 `/mcp` 会被后端重定向到 `/mcp/`
- 未携带 bot 凭证时,访问 `/mcp/` 会返回 `401`
## 3. 认证模型
MCP client 只需要传两个 header
- `X-Bot-Id`
- `X-Bot-Secret`
后端会执行以下逻辑:
1. 根据 `X-Bot-Id` 查询 `mcp_bots`
2. 校验 `X-Bot-Secret`
3. 将该 bot 映射到 NexDocs 用户
4. 以该用户身份执行 MCP 工具
因此 client 不需要再传:
- NexDocs 用户名
- NexDocs 密码
- NexDocs access token
## 4. 用户如何获取凭证
每个用户可在个人中心管理自己的 MCP 凭证。
页面位置:
1. 登录 NexDocs
2. 打开个人中心
3. 进入 `MCP 接入` 标签页
可执行操作:
- 查看 `X-Bot-Id`
- 查看并复制 `X-Bot-Secret`
- 重新生成 `X-Bot-Secret`
后端接口:
- `GET /api/v1/auth/mcp-credentials`
- `POST /api/v1/auth/mcp-credentials/rotate-secret`
## 5. 当前支持的 MCP Tools
### 5.1 获取当前用户创建的项目列表
工具名:
```text
list_created_projects
```
参数:
- `keyword`: 可选,按项目名/描述过滤
- `limit`: 可选,默认 100
### 5.2 获取指定项目文件树
工具名:
```text
get_project_tree
```
参数:
- `project_id`: 必填
### 5.3 获取指定文件内容
工具名:
```text
get_file
```
参数:
- `project_id`: 必填
- `path`: 必填,项目内相对路径
### 5.4 在项目中创建新文件
工具名:
```text
create_file
```
参数:
- `project_id`: 必填
- `path`: 必填,项目内相对路径
- `content`: 可选,文件初始内容,默认空字符串
说明:
- 目标路径已存在时会报错
- 会自动创建缺失的上级目录
- 会校验项目写权限
- 写入 Markdown 文件时会更新搜索索引
- 会记录操作日志
- 会通知项目成员
### 5.5 修改指定文件
工具名:
```text
update_file
```
参数:
- `project_id`: 必填
- `path`: 必填,项目内相对路径
- `content`: 必填,新的文件内容
说明:
- 目标文件不存在时会报错
- 只允许更新文件,不允许更新目录
- 会校验项目写权限
- 写入 Markdown 文件时会更新搜索索引
- 会记录操作日志
- 会通知项目成员
### 5.6 删除指定文件
工具名:
```text
delete_file
```
参数:
- `project_id`: 必填
- `path`: 必填,项目内相对路径
说明:
- 目标文件不存在时会报错
- 只允许删除文件,不允许删除目录
- 删除 Markdown 文件时会同步删除搜索索引
- 会记录操作日志
- 会通知项目成员
## 6. 调用端配置示例
如果调用端内置的是 MCP client并支持 `streamableHttp`,可以这样配置:
```json
{
"tools": {
"mcpServers": {
"biz_mcp": {
"type": "streamableHttp",
"url": "http://backend.internal:8000/mcp/",
"headers": {
"X-Bot-Id": "nexbot_xxxxxxxxxxxxxxxx",
"X-Bot-Secret": "nxbotsec_xxxxxxxxxxxxxxxxxxxxxxxx"
},
"toolTimeout": 60
}
}
}
}
```
注意:
- `url` 建议使用 `/mcp/`
- `headers` 中不需要再放业务用户名密码
- `X-Bot-Secret` 只应发给受信任的调用端
## 7. 后端部署要求
### 7.1 Python 版本
后端运行环境需要 Python 3.12。
本地开发建议:
```bash
cd backend
/opt/homebrew/bin/python3.12 -m venv venv312
env -u HTTP_PROXY -u HTTPS_PROXY ./venv312/bin/pip install -r requirements.txt
./venv312/bin/uvicorn main:app --host 0.0.0.0 --port 8000
```
说明:
- 当前 backend 的 MCP 依赖要求 Python 3.10+
- 本项目已在本地按 Python 3.12 调试通过
### 7.2 Docker
`backend/Dockerfile` 已切换到 Python 3.12 基线。
### 7.3 数据表
`mcp_bots` 表需要存在。
如果你已经执行过建表脚本,这一步可以跳过。
相关文件:
- [create_mcp_bots_table.sql](/Users/jiliu/工作/projects/NexDocus/backend/scripts/create_mcp_bots_table.sql)
- [init_database.sql](/Users/jiliu/工作/projects/NexDocus/backend/scripts/init_database.sql)
## 8. 已验证结果
本地调试已验证:
- Python 3.12 环境可正常启动 backend
- `/health` 返回 `200`
- `/mcp/` 已挂载成功
- 未传 `X-Bot-Id` / `X-Bot-Secret` 时返回 `401`
调试使用地址:
```text
http://127.0.0.1:8012/mcp/
```
生产或测试环境请替换为你的 backend 实际域名或 IP。
## 9. 相关代码位置
- MCP 挂载入口:
[main.py](/Users/jiliu/工作/projects/NexDocus/backend/main.py)
- MCP 服务实现:
[server.py](/Users/jiliu/工作/projects/NexDocus/backend/app/mcp/server.py)
- MCP Bot 模型:
[mcp_bot.py](/Users/jiliu/工作/projects/NexDocus/backend/app/models/mcp_bot.py)
- 用户凭证管理接口:
[auth.py](/Users/jiliu/工作/projects/NexDocus/backend/app/api/v1/auth.py)
- 个人中心凭证管理页面:
[ProfilePage.jsx](/Users/jiliu/工作/projects/NexDocus/frontend/src/pages/Profile/ProfilePage.jsx)

View File

@ -57,26 +57,6 @@ export function changePassword(data) {
})
}
/**
* 获取当前用户的 MCP 凭证
*/
export function getMcpCredentials() {
return request({
url: '/auth/mcp-credentials',
method: 'get',
})
}
/**
* 重新生成当前用户的 MCP Secret
*/
export function rotateMcpSecret() {
return request({
url: '/auth/mcp-credentials/rotate-secret',
method: 'post',
})
}
/**
* 上传用户头像
*/

View File

@ -1,77 +0,0 @@
.mode-switch {
position: relative;
display: inline-grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
align-items: center;
padding: 2px;
border-radius: 999px;
background: linear-gradient(180deg, #eef1f6 0%, #e7ebf2 100%);
border: 1px solid rgba(73, 104, 149, 0.2);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.72),
0 1px 2px rgba(10, 24, 46, 0.08);
flex-shrink: 0;
overflow: hidden;
}
.mode-switch-thumb {
position: absolute;
top: 2px;
left: 2px;
height: calc(100% - 4px);
width: calc((100% - 4px) / var(--option-count));
border-radius: 999px;
background: #ffffff;
border: 1px solid rgba(94, 128, 181, 0.22);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
transform: translateX(calc(var(--active-index) * 100%));
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
}
.mode-switch-option {
min-width: 44px;
height: 30px;
border: none;
border-radius: 999px;
background: transparent;
color: #4b5f7f;
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 0 10px;
transition: color 0.2s ease, opacity 0.2s ease;
line-height: 1;
position: relative;
z-index: 1;
opacity: 0.95;
}
.mode-switch-option:hover {
color: #2c4467;
opacity: 1;
}
.mode-switch-option.active {
color: #1f3558;
opacity: 1;
}
body.dark .mode-switch {
background: linear-gradient(180deg, #2e3748 0%, #252d3b 100%);
border-color: rgba(137, 156, 186, 0.24);
}
body.dark .mode-switch-thumb {
background: #3a455a;
border-color: rgba(145, 174, 220, 0.28);
}
body.dark .mode-switch-option {
color: #9bb2d8;
}
body.dark .mode-switch-option:hover,
body.dark .mode-switch-option.active {
color: #e5eeff;
}

View File

@ -1,46 +0,0 @@
import './ModeSwitch.css'
function ModeSwitch({
value = 'view',
onChange,
viewLabel = '浏览',
editLabel = '编辑',
options,
ariaLabel = '模式切换',
}) {
const finalOptions = options || [
{ label: viewLabel, value: 'view' },
{ label: editLabel, value: 'edit' },
]
const activeIndex = Math.max(
0,
finalOptions.findIndex((item) => item.value === value)
)
return (
<div
className="mode-switch"
role="tablist"
aria-label={ariaLabel}
style={{
'--option-count': String(finalOptions.length),
'--active-index': String(activeIndex),
}}
>
<span className="mode-switch-thumb" aria-hidden="true" />
{finalOptions.map((item) => (
<button
key={item.value}
type="button"
className={`mode-switch-option ${value === item.value ? 'active' : ''}`}
aria-selected={value === item.value}
onClick={() => onChange?.(item.value)}
>
{item.label}
</button>
))}
</div>
)
}
export default ModeSwitch

View File

@ -1,12 +1,11 @@
import { Navigate, useLocation } from 'react-router-dom'
import { Navigate } from 'react-router-dom'
import useUserStore from '@/stores/userStore'
function ProtectedRoute({ children }) {
const location = useLocation()
const token = localStorage.getItem('access_token')
if (!token) {
const returnTo = encodeURIComponent(`${location.pathname}${location.search}${location.hash}`)
return <Navigate to={`/login?redirect=${returnTo}`} replace />
return <Navigate to="/login" replace />
}
return children

View File

@ -14,21 +14,11 @@ notification.config({
maxCount: 3,
})
let muteErrorsUntil = 0
function shouldMuteError() {
return Date.now() < muteErrorsUntil
}
/**
* 标准通知反馈组件
* 从右上角滑出默认3秒后消失
*/
const Toast = {
muteErrors: (durationMs = 2000) => {
muteErrorsUntil = Date.now() + durationMs
},
/**
* 成功通知
* @param {string} message - 消息内容
@ -54,11 +44,7 @@ const Toast = {
* @param {string} description - 详细描述可选
* @param {number} duration - 显示时长默认3秒
*/
error: (message, description = '', duration = 3, options = {}) => {
if (!options.force && shouldMuteError()) {
return
}
error: (message, description = '', duration = 3) => {
notification.error({
message,
description,
@ -77,11 +63,7 @@ const Toast = {
* @param {string} description - 详细描述可选
* @param {number} duration - 显示时长默认3秒
*/
warning: (message, description = '', duration = 3, options = {}) => {
if (!options.force && shouldMuteError()) {
return
}
warning: (message, description = '', duration = 3) => {
notification.warning({
message,
description,

View File

@ -44,20 +44,33 @@
align-items: center;
}
.mode-actions-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
.mode-toggle-btn {
font-weight: 500 !important;
height: 32px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 4px 15px !important;
border: none !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.mode-actions-group {
display: inline-flex;
align-items: center;
.mode-toggle-btn.exit-edit {
background: linear-gradient(135deg, #00b96b 0%, #52c41a 100%) !important;
color: white !important;
box-shadow: 0 2px 6px rgba(0, 185, 107, 0.2);
}
.sider-actions .mode-actions-group .ant-btn {
.mode-toggle-btn:hover {
transform: translateY(-1px);
filter: brightness(1.05);
}
.mode-toggle-btn.exit-edit:hover {
box-shadow: 0 4px 10px rgba(0, 185, 107, 0.3);
}
.sider-actions .ant-btn:not(.mode-toggle-btn) {
display: flex;
align-items: center;
justify-content: center;
@ -66,7 +79,7 @@
color: var(--text-color-secondary);
}
.sider-actions .mode-actions-group .ant-btn:hover {
.sider-actions .ant-btn:not(.mode-toggle-btn):hover {
background: var(--item-hover-bg);
border-color: var(--border-color);
color: var(--link-color);
@ -169,7 +182,6 @@
.content-header {
padding: 16px 24px;
min-height: 57px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { useParams, useNavigate } from 'react-router-dom'
import { Layout, Menu, Button, Modal, Input, Space, Tooltip, Dropdown, Upload, Select, Progress, TreeSelect } from 'antd'
import {
FileOutlined,
@ -8,6 +8,7 @@ import {
DeleteOutlined,
EditOutlined,
SaveOutlined,
EyeOutlined,
FileAddOutlined,
FolderAddOutlined,
UploadOutlined,
@ -38,7 +39,6 @@ import {
uploadDocument,
} from '@/api/file'
import Toast from '@/components/Toast/Toast'
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
import './DocumentEditor.css'
const { Sider, Content } = Layout
@ -46,7 +46,6 @@ const { Sider, Content } = Layout
function DocumentEditor() {
const { projectId } = useParams()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const fileInputRef = useRef(null)
const [treeData, setTreeData] = useState([])
const [selectedFile, setSelectedFile] = useState(null)
@ -73,28 +72,7 @@ function DocumentEditor() {
const [linkModalVisible, setLinkModalVisible] = useState(false)
const [linkTarget, setLinkTarget] = useState(null)
const [projectName, setProjectName] = useState('') //
const [userRole, setUserRole] = useState('viewer')
const [modeSwitchValue, setModeSwitchValue] = useState('edit')
const editorCtxRef = useRef(null)
const modeSwitchingRef = useRef(false)
const navigateWithTransition = (to) => {
if (document.startViewTransition) {
document.startViewTransition(() => navigate(to))
return
}
navigate(to)
}
const updateFileParam = (filePath) => {
const nextParams = new URLSearchParams(searchParams)
if (filePath) {
nextParams.set('file', filePath)
} else {
nextParams.delete('file')
}
setSearchParams(nextParams, { replace: true })
}
//
const handleInsertLink = () => {
@ -148,31 +126,13 @@ function DocumentEditor() {
const data = res.data || {}
const tree = data.tree || data || [] //
const name = data.project_name
const role = data.user_role || 'viewer'
setTreeData(tree)
setProjectName(name)
setUserRole(role)
} catch (error) {
Toast.error('加载失败', '加载文件树失败')
}
}
const handleClose = () => {
Modal.confirm({
title: '确认退出',
content: '确定要退出编辑页面吗?未保存的修改可能会丢失。',
okText: '退出',
cancelText: '取消',
onOk: () => {
if (userRole === 'owner') {
navigate('/projects/my')
} else {
navigate('/projects/share')
}
},
})
}
const handleSelectFile = async (selectedKeys, info) => {
//
setSelectedNode(info.node)
@ -213,38 +173,18 @@ function DocumentEditor() {
return null
}
const expandParentFolders = (path) => {
const parts = path.split('/')
if (parts.length <= 1) return
// MenuMenu
const handleMenuClick = async ({ key, domEvent }) => {
const node = findNodeByKey(treeData, key)
if (!node) return
const parentKeys = []
let currentPath = ''
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
parentKeys.push(currentPath)
}
setOpenKeys(prev => Array.from(new Set([...prev, ...parentKeys])))
}
const openNodeByKey = async (key, node = null, syncUrl = false) => {
const targetNode = node || findNodeByKey(treeData, key)
if (!targetNode) return
setSelectedNode(targetNode)
setSelectedMenuKey(key)
if (!targetNode.isLeaf) {
if (syncUrl) {
updateFileParam(null)
}
return
}
if (syncUrl) {
updateFileParam(key)
}
expandParentFolders(key)
//
setSelectedNode(node)
setSelectedMenuKey(key) //
//
if (node.isLeaf) {
// PDF
if (key.toLowerCase().endsWith('.pdf')) {
setSelectedFile(key)
setIsPdfSelected(true)
@ -252,7 +192,9 @@ function DocumentEditor() {
return
}
// PDF
setIsPdfSelected(false)
setLoading(true)
try {
const res = await getFileContent(projectId, key)
@ -264,25 +206,6 @@ function DocumentEditor() {
setLoading(false)
}
}
useEffect(() => {
if (treeData.length === 0) return
const fileParam = searchParams.get('file')
if (!fileParam) return
if (fileParam === selectedFile) return
const targetNode = findNodeByKey(treeData, fileParam)
if (!targetNode) return
openNodeByKey(fileParam, targetNode, false)
}, [treeData, searchParams])
// MenuMenu
const handleMenuClick = async ({ key, domEvent }) => {
const node = findNodeByKey(treeData, key)
if (!node) return
await openNodeByKey(key, node, true)
}
//
@ -971,35 +894,19 @@ function DocumentEditor() {
<h2 style={{ margin: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={projectName}>
{projectName}
</h2>
<Tooltip title="关闭">
<Button
type="text"
icon={<CloseOutlined />}
onClick={handleClose}
style={{ marginLeft: 8 }}
/>
</Tooltip>
</div>
<div className="sider-actions">
<div className="mode-actions-row">
<ModeSwitch
value={modeSwitchValue}
onChange={(mode) => {
if (mode === 'view' && !modeSwitchingRef.current) {
modeSwitchingRef.current = true
setModeSwitchValue('view')
setTimeout(() => {
const params = new URLSearchParams()
if (selectedFile) {
params.set('file', selectedFile)
}
const query = params.toString()
navigateWithTransition(`/projects/${projectId}/docs${query ? `?${query}` : ''}`)
}, 160)
}
}}
/>
<Space.Compact className="mode-actions-group">
<Space size={8}>
<Button
type="primary"
size="middle"
className="mode-toggle-btn exit-edit"
icon={<EyeOutlined />}
onClick={() => navigate(`/projects/${projectId}/docs`)}
>
退出
</Button>
<Space.Compact>
<Tooltip title="添加文件">
<Button
size="middle"
@ -1037,7 +944,7 @@ function DocumentEditor() {
/>
</Tooltip>
</Space.Compact>
</div>
</Space>
</div>
</div>
{/* 上传进度条 */}

View File

@ -40,20 +40,26 @@
align-items: center;
}
.mode-actions-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
.mode-toggle-btn {
font-weight: 500 !important;
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.2);
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%) !important;
border: none !important;
height: 32px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 4px 15px !important;
color: white !important;
}
.mode-actions-group {
display: inline-flex;
align-items: center;
.mode-toggle-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(22, 119, 255, 0.3);
filter: brightness(1.1);
}
.docs-sider-actions .mode-actions-group .ant-btn {
.docs-sider-actions .ant-btn:not(.mode-toggle-btn) {
display: flex;
align-items: center;
justify-content: center;
@ -62,7 +68,7 @@
color: var(--text-color-secondary);
}
.docs-sider-actions .mode-actions-group .ant-btn:hover {
.docs-sider-actions .ant-btn-text:hover {
background: var(--item-hover-bg);
border-color: var(--border-color);
color: var(--link-color);
@ -170,29 +176,6 @@
background: var(--bg-color);
}
.docs-content-header {
position: sticky;
top: 0;
z-index: 5;
min-height: 57px;
padding: 16px 24px;
border-bottom: 1px solid var(--border-color);
background: var(--header-bg);
display: flex;
align-items: center;
}
.docs-content-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.docs-content-wrapper {
max-width: 900px;
margin: 0 auto;

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Space, Dropdown, Empty } from 'antd'
import { VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined, CloseOutlined, MenuOutlined } from '@ant-design/icons'
import { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Switch, Space, Dropdown, Empty } from 'antd'
import { EditOutlined, VerticalAlignTopOutlined, ShareAltOutlined, MenuFoldOutlined, MenuUnfoldOutlined, FileTextOutlined, FolderOutlined, FilePdfOutlined, CopyOutlined, LockOutlined, CloudDownloadOutlined, CloudUploadOutlined, DownOutlined, SearchOutlined, CloseOutlined, MenuOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeRaw from 'rehype-raw'
@ -17,7 +17,6 @@ import { searchDocuments } from '@/api/search'
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
import Toast from '@/components/Toast/Toast'
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
import './DocumentPage.css'
const { Sider, Content } = Layout
@ -38,7 +37,7 @@ const HighlightText = ({ text, keyword }) => {
function DocumentPage() {
const { projectId } = useParams()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const [searchParams] = useSearchParams()
const [fileTree, setFileTree] = useState([])
const [selectedFile, setSelectedFile] = useState('')
const [markdownContent, setMarkdownContent] = useState('')
@ -62,47 +61,19 @@ function DocumentPage() {
const [searchKeyword, setSearchKeyword] = useState('')
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
const [isSearching, setIsSearching] = useState(false)
const [modeSwitchValue, setModeSwitchValue] = useState('view')
const contentRef = useRef(null)
const modeSwitchingRef = useRef(false)
const navigateWithTransition = (to) => {
if (document.startViewTransition) {
document.startViewTransition(() => navigate(to))
return
}
navigate(to)
}
const updateFileParam = (filePath) => {
const nextParams = new URLSearchParams(searchParams)
if (filePath) {
nextParams.set('file', filePath)
} else {
nextParams.delete('file')
}
setSearchParams(nextParams, { replace: true })
}
useEffect(() => {
loadFileTree()
}, [projectId])
const handleClose = () => {
Modal.confirm({
title: '确认退出',
content: '确定要退出当前项目页面吗?',
okText: '退出',
cancelText: '取消',
onOk: () => {
if (userRole === 'owner') {
navigate('/projects/my')
} else {
navigate('/projects/share')
}
},
})
}
// URL
@ -156,7 +127,6 @@ function DocumentPage() {
const readmeNode = findReadme(fileTree)
if (readmeNode) {
setSelectedFile(readmeNode.key)
updateFileParam(readmeNode.key)
loadMarkdown(readmeNode.key)
}
}
@ -342,7 +312,6 @@ function DocumentPage() {
//
const handleMenuClick = ({ key }) => {
setSelectedFile(key)
updateFileParam(key)
// PDF
if (key.toLowerCase().endsWith('.pdf')) {
@ -443,7 +412,6 @@ function DocumentPage() {
//
setSelectedFile(targetPath)
updateFileParam(targetPath)
if (isPdf) {
// PDFPDF
@ -625,12 +593,7 @@ function DocumentPage() {
//
const handleEdit = () => {
const params = new URLSearchParams()
if (selectedFile) {
params.set('file', selectedFile)
}
const query = params.toString()
navigateWithTransition(`/projects/${projectId}/editor${query ? `?${query}` : ''}`)
navigate(`/projects/${projectId}/editor`)
}
//
@ -819,25 +782,20 @@ function DocumentPage() {
</Tooltip>
</div>
<div className="docs-sider-actions">
<div className="mode-actions-row">
<Space size={8}>
{/* 只有 owner/admin/editor 可以编辑和Git操作 */}
{userRole !== 'viewer' ? (
<ModeSwitch
value={modeSwitchValue}
onChange={(mode) => {
if (mode === 'edit' && !modeSwitchingRef.current) {
modeSwitchingRef.current = true
setModeSwitchValue('edit')
setTimeout(() => {
handleEdit()
}, 160)
}
}}
/>
) : (
<div />
{userRole !== 'viewer' && (
<Button
type="primary"
size="middle"
className="mode-toggle-btn"
icon={<EditOutlined />}
onClick={handleEdit}
>
编辑
</Button>
)}
<Space.Compact className="mode-actions-group">
<Space.Compact>
{userRole !== 'viewer' && renderGitActions()}
<Tooltip title="分享">
<Button
@ -847,7 +805,7 @@ function DocumentPage() {
/>
</Tooltip>
</Space.Compact>
</div>
</Space>
</div>
</div>
@ -884,9 +842,6 @@ function DocumentPage() {
{/* 右侧内容区 */}
<Layout className="docs-content-layout">
<Content className="docs-content" ref={contentRef}>
<div className="docs-content-header">
<h3>{selectedFile || 'README.md'}</h3>
</div>
<div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
{loading ? (
<div className="docs-loading">

View File

@ -1,5 +1,5 @@
import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Form, Input, Button, Tabs } from 'antd'
import { UserOutlined, LockOutlined, MailOutlined, ArrowRightOutlined } from '@ant-design/icons'
import { login, register } from '@/api/auth'
@ -13,7 +13,6 @@ function Login() {
const [activeTab, setActiveTab] = useState('login')
const [loginForm] = Form.useForm()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { setUser, setToken } = useUserStore()
const handleLogin = async (values) => {
@ -28,12 +27,6 @@ function Login() {
setToken(res.data.access_token)
setUser(res.data.user)
const redirect = searchParams.get('redirect')
if (redirect) {
navigate(decodeURIComponent(redirect), { replace: true })
return
}
//
try {
const menuRes = await getUserMenus()

View File

@ -8,10 +8,6 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.profile-card .ant-card-body {
padding: 28px 32px;
}
.profile-title {
margin: 0 0 24px 0;
font-size: 24px;
@ -19,45 +15,9 @@
color: rgba(0, 0, 0, 0.88);
}
.profile-tabs > .ant-tabs-nav {
min-width: 180px;
margin-right: 32px;
}
.profile-tabs > .ant-tabs-nav .ant-tabs-nav-list {
gap: 8px;
}
.profile-tabs > .ant-tabs-nav .ant-tabs-tab {
margin: 0;
padding: 12px 16px;
border-radius: 12px;
transition: background-color 0.2s ease, color 0.2s ease;
}
.profile-tabs > .ant-tabs-nav .ant-tabs-tab-active {
background: rgba(22, 119, 255, 0.08);
}
.profile-tabs > .ant-tabs-nav .ant-tabs-tab-btn {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.profile-tabs > .ant-tabs-content-holder {
min-width: 0;
}
.profile-tabs > .ant-tabs-content-holder > .ant-tabs-content > .ant-tabs-tabpane {
padding-left: 8px;
}
.profile-tab-content,
.password-tab-content,
.mcp-tab-content {
padding: 4px 0;
.password-tab-content {
padding: 24px 0;
}
/* 头像部分 */
@ -120,116 +80,16 @@
margin-bottom: 0;
}
.mcp-panel {
display: flex;
flex-direction: column;
gap: 20px;
}
.mcp-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px 24px;
border: 1px solid rgba(22, 119, 255, 0.14);
border-radius: 16px;
background: linear-gradient(180deg, rgba(22, 119, 255, 0.06) 0%, rgba(22, 119, 255, 0.02) 100%);
}
.mcp-panel-header h3 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
}
.mcp-panel-header p {
margin: 0;
color: rgba(0, 0, 0, 0.6);
}
.mcp-field-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.mcp-field-card {
padding: 20px;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 14px;
background: #fff;
}
.mcp-field-card label {
display: block;
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(0, 0, 0, 0.5);
}
.mcp-value {
margin-bottom: 16px !important;
padding: 12px 14px;
border-radius: 10px;
background: #f6f8fb;
font-family: Menlo, Monaco, Consolas, monospace;
word-break: break-all;
}
.mcp-config-tip {
padding: 18px 20px;
border-radius: 14px;
background: #f7f9fc;
border: 1px dashed rgba(22, 119, 255, 0.24);
}
.mcp-config-tip h4 {
margin: 0 0 10px;
font-size: 14px;
font-weight: 600;
}
.mcp-config-tip pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
line-height: 1.6;
font-family: Menlo, Monaco, Consolas, monospace;
}
/* 响应式 */
@media (max-width: 768px) {
.profile-page {
padding: 16px;
}
.profile-card .ant-card-body {
padding: 20px 16px;
}
.profile-title {
font-size: 20px;
}
.profile-tabs.ant-tabs-left {
display: block;
}
.profile-tabs > .ant-tabs-nav {
min-width: 0;
margin-right: 0;
margin-bottom: 16px;
}
.profile-tabs > .ant-tabs-content-holder > .ant-tabs-content > .ant-tabs-tabpane {
padding-left: 0;
}
.profile-form,
.password-form {
max-width: 100%;
@ -238,12 +98,4 @@
.avatar-section {
padding: 16px;
}
.mcp-panel-header {
flex-direction: column;
}
.mcp-field-list {
grid-template-columns: 1fr;
}
}

View File

@ -1,25 +1,21 @@
import { useState, useEffect } from 'react'
import { Card, Tabs, Form, Input, Button, Avatar, Upload, message, Space, Typography, Modal } from 'antd'
import { UserOutlined, LockOutlined, UploadOutlined, ApiOutlined, CopyOutlined, ReloadOutlined } from '@ant-design/icons'
import { Card, Tabs, Form, Input, Button, Avatar, Upload, message } from 'antd'
import { UserOutlined, LockOutlined, UploadOutlined } from '@ant-design/icons'
import ImgCrop from 'antd-img-crop'
import { getCurrentUser, updateProfile, changePassword, uploadAvatar, getMcpCredentials, rotateMcpSecret } from '@/api/auth'
import { getCurrentUser, updateProfile, changePassword, uploadAvatar } from '@/api/auth'
import useUserStore from '@/stores/userStore'
import Toast from '@/components/Toast/Toast'
import './ProfilePage.css'
const { Paragraph, Text } = Typography
function ProfilePage() {
const [loading, setLoading] = useState(false)
const [userInfo, setUserInfo] = useState(null)
const [mcpCredentials, setMcpCredentials] = useState(null)
const [profileForm] = Form.useForm()
const [passwordForm] = Form.useForm()
const { setUser } = useUserStore()
const { user, setUser } = useUserStore()
useEffect(() => {
loadUserInfo()
loadMcpCredentials()
}, [])
//
@ -39,16 +35,6 @@ function ProfilePage() {
}
}
const loadMcpCredentials = async () => {
try {
const res = await getMcpCredentials()
setMcpCredentials(res.data)
} catch (error) {
console.error('Load MCP credentials error:', error)
Toast.error('加载失败', '获取 MCP 凭证失败')
}
}
//
const handleUpdateProfile = async (values) => {
setLoading(true)
@ -138,41 +124,6 @@ function ProfilePage() {
return null
}
const handleCopy = async (value, label) => {
if (!value) return
try {
await navigator.clipboard.writeText(value)
Toast.success('复制成功', `${label} 已复制到剪贴板`)
} catch (error) {
console.error('Copy credential error:', error)
Toast.error('复制失败', `无法复制 ${label}`)
}
}
const handleRotateSecret = () => {
Modal.confirm({
title: '重新生成 MCP Secret',
content: '旧 Secret 会立即失效,依赖该凭证的远程客户端需要同步更新。确认继续?',
okText: '重新生成',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: async () => {
setLoading(true)
try {
const res = await rotateMcpSecret()
setMcpCredentials(res.data)
Toast.success('已生成', '新的 MCP Secret 已生效')
} catch (error) {
console.error('Rotate MCP secret error:', error)
Toast.error('生成失败', error.response?.data?.detail || '重新生成 MCP Secret 失败')
} finally {
setLoading(false)
}
},
})
}
const tabItems = [
{
key: 'profile',
@ -338,86 +289,17 @@ function ProfilePage() {
</div>
),
},
{
key: 'mcp',
label: (
<span>
<ApiOutlined />
MCP 接入
</span>
),
children: (
<div className="mcp-tab-content">
<div className="mcp-panel">
<div className="mcp-panel-header">
<div>
<h3>MCP 访问凭证</h3>
<p>用于远程 MCP Client 通过 `X-Bot-Id` `X-Bot-Secret` 接入你的账号</p>
</div>
<Button
icon={<ReloadOutlined />}
onClick={handleRotateSecret}
loading={loading}
>
重新生成 Secret
</Button>
</div>
<div className="mcp-field-list">
<div className="mcp-field-card">
<label>X-Bot-Id</label>
<Paragraph copyable={false} className="mcp-value">
{mcpCredentials?.bot_id || '-'}
</Paragraph>
<Button
icon={<CopyOutlined />}
onClick={() => handleCopy(mcpCredentials?.bot_id, 'X-Bot-Id')}
disabled={!mcpCredentials?.bot_id}
>
复制 Bot ID
</Button>
</div>
<div className="mcp-field-card">
<label>X-Bot-Secret</label>
<Paragraph copyable={false} className="mcp-value mcp-secret">
{mcpCredentials?.bot_secret || '-'}
</Paragraph>
<Space>
<Button
icon={<CopyOutlined />}
onClick={() => handleCopy(mcpCredentials?.bot_secret, 'X-Bot-Secret')}
disabled={!mcpCredentials?.bot_secret}
>
复制 Secret
</Button>
<Text type="secondary">变更后旧 Secret 立即失效</Text>
</Space>
</div>
</div>
<div className="mcp-config-tip">
<h4>客户端请求头</h4>
<pre>{`X-Bot-Id: ${mcpCredentials?.bot_id || 'your_bot_id'}\nX-Bot-Secret: ${mcpCredentials?.bot_secret || 'your_bot_secret'}`}</pre>
</div>
</div>
</div>
),
},
]
return (
<div className="profile-page">
<Card className="profile-card">
<h2 className="profile-title">个人中心</h2>
<Tabs
items={tabItems}
defaultActiveKey="profile"
tabPosition="left"
className="profile-tabs"
/>
<Tabs items={tabItems} defaultActiveKey="profile" />
</Card>
</div>
)
}

View File

@ -4,14 +4,6 @@
import axios from 'axios'
import Toast from '@/components/Toast/Toast'
let isHandlingUnauthorized = false
function redirectToLoginWithReturnTo() {
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`
const encodedReturnTo = encodeURIComponent(currentPath)
window.location.href = `/login?redirect=${encodedReturnTo}`
}
// 创建 axios 实例
const request = axios.create({
baseURL: '/api/v1', // 使用相对路径开发环境通过vite proxy代理生产环境通过nginx代理
@ -48,22 +40,15 @@ request.interceptors.response.use(
// 如果返回的状态码不是 200说明有错误
if (res.code !== 200) {
Toast.error('请求失败', res.message || '请求失败')
// 401: 未登录或 token 过期
if (res.code === 401) {
if (!window.location.pathname.includes('/login') && !isHandlingUnauthorized) {
isHandlingUnauthorized = true
Toast.muteErrors(2500)
Toast.error('认证失败', res.message || '未登录或登录已过期', 3, { force: true })
localStorage.removeItem('access_token')
localStorage.removeItem('user_info')
setTimeout(() => {
redirectToLoginWithReturnTo()
}, 600)
}
return Promise.reject(new Error(res.message || '未登录或登录已过期'))
window.location.href = '/login'
}
Toast.error('请求失败', res.message || '请求失败')
return Promise.reject(new Error(res.message || '请求失败'))
}
@ -79,15 +64,13 @@ request.interceptors.response.use(
switch (status) {
case 401:
// 只有不在登录页时才显示Toast和跳转
if (!window.location.pathname.includes('/login') && !isHandlingUnauthorized) {
isHandlingUnauthorized = true
Toast.muteErrors(2500)
Toast.error('认证失败', data?.detail || '未登录或登录已过期', 3, { force: true })
if (!window.location.pathname.includes('/login')) {
Toast.error('认证失败', data?.detail || '未登录或登录已过期')
localStorage.removeItem('access_token')
localStorage.removeItem('user_info')
setTimeout(() => {
redirectToLoginWithReturnTo()
}, 600) // 延迟一小段时间,让提示先展
window.location.href = '/login'
}, 1000) // 延迟1秒让Toast有时间显
}
break
case 403: