Compare commits
2 Commits
dc112de840
...
c005964b5d
| Author | SHA1 | Date |
|---|---|---|
|
|
c005964b5d | |
|
|
b78fc3b269 |
|
|
@ -1,5 +1,5 @@
|
|||
# 使用国内镜像加速
|
||||
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/python:3.9-slim
|
||||
FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/python:3.12-slim
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ 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
|
||||
|
|
@ -18,8 +19,17 @@ 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
|
||||
from app.schemas.user import (
|
||||
UserCreate,
|
||||
UserLogin,
|
||||
UserResponse,
|
||||
Token,
|
||||
ChangePassword,
|
||||
UserUpdate,
|
||||
MCPBotCredentialResponse,
|
||||
)
|
||||
from app.schemas.response import success_response, error_response
|
||||
from app.services.log_service import log_service
|
||||
from app.core.enums import OperationType, ResourceType
|
||||
|
|
@ -28,6 +38,14 @@ 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,
|
||||
|
|
@ -215,6 +233,58 @@ 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(...),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
from app.mcp.server import create_mcp_http_app, get_mcp_session_manager, MCPHeaderAuthApp
|
||||
|
||||
__all__ = ["create_mcp_http_app", "get_mcp_session_manager", "MCPHeaderAuthApp"]
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
"""
|
||||
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,
|
||||
)
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
"""
|
||||
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)
|
||||
|
|
@ -8,6 +8,7 @@ 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",
|
||||
|
|
@ -21,4 +22,5 @@ __all__ = [
|
|||
"ProjectMemberRole",
|
||||
"DocumentMeta",
|
||||
"OperationLog",
|
||||
"MCPBot",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
"""
|
||||
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}')>"
|
||||
|
|
@ -9,6 +9,8 @@ from app.schemas.user import (
|
|||
UserLogin,
|
||||
Token,
|
||||
ChangePassword,
|
||||
MCPBotCredentialResponse,
|
||||
MCPBotCredentialCreateResponse,
|
||||
)
|
||||
from app.schemas.project import (
|
||||
ProjectBase,
|
||||
|
|
@ -42,6 +44,8 @@ __all__ = [
|
|||
"UserLogin",
|
||||
"Token",
|
||||
"ChangePassword",
|
||||
"MCPBotCredentialResponse",
|
||||
"MCPBotCredentialCreateResponse",
|
||||
# Project
|
||||
"ProjectBase",
|
||||
"ProjectCreate",
|
||||
|
|
|
|||
|
|
@ -57,3 +57,21 @@ 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
|
||||
|
|
|
|||
|
|
@ -7,6 +7,15 @@ 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
|
||||
|
|
@ -14,6 +23,10 @@ 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()
|
||||
|
|
@ -40,6 +53,10 @@ 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():
|
||||
|
|
|
|||
|
|
@ -25,14 +25,13 @@ MarkupSafe==3.0.3
|
|||
passlib==1.7.4
|
||||
pyasn1==0.6.1
|
||||
pycparser==2.23
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
pydantic_core==2.14.6
|
||||
pydantic>=2.11,<3
|
||||
pydantic-settings>=2.6,<3
|
||||
PyMySQL==1.1.0
|
||||
python-dotenv==1.0.0
|
||||
python-jose==3.3.0
|
||||
python-magic==0.4.27
|
||||
python-multipart==0.0.6
|
||||
python-multipart>=0.0.9
|
||||
PyYAML==6.0.1
|
||||
redis==5.0.1
|
||||
rsa==4.9.1
|
||||
|
|
@ -40,7 +39,7 @@ six==1.17.0
|
|||
SQLAlchemy==2.0.25
|
||||
starlette==0.35.1
|
||||
typing_extensions==4.15.0
|
||||
uvicorn==0.27.0
|
||||
uvicorn>=0.31.1
|
||||
uvloop==0.22.1
|
||||
watchfiles==1.1.1
|
||||
websockets==15.0.1
|
||||
|
|
@ -48,3 +47,4 @@ Whoosh==2.7.4
|
|||
markdown==3.5.2
|
||||
weasyprint==61.2
|
||||
pydyf<0.11.0
|
||||
mcp==1.26.0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
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';
|
||||
|
|
@ -166,6 +166,22 @@ 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),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,295 @@
|
|||
# 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)
|
||||
|
|
@ -57,6 +57,26 @@ 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',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传用户头像
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
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
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import { Navigate } from 'react-router-dom'
|
||||
import useUserStore from '@/stores/userStore'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const location = useLocation()
|
||||
const token = localStorage.getItem('access_token')
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />
|
||||
const returnTo = encodeURIComponent(`${location.pathname}${location.search}${location.hash}`)
|
||||
return <Navigate to={`/login?redirect=${returnTo}`} replace />
|
||||
}
|
||||
|
||||
return children
|
||||
|
|
|
|||
|
|
@ -14,11 +14,21 @@ 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 - 消息内容
|
||||
|
|
@ -44,7 +54,11 @@ const Toast = {
|
|||
* @param {string} description - 详细描述(可选)
|
||||
* @param {number} duration - 显示时长(秒),默认3秒
|
||||
*/
|
||||
error: (message, description = '', duration = 3) => {
|
||||
error: (message, description = '', duration = 3, options = {}) => {
|
||||
if (!options.force && shouldMuteError()) {
|
||||
return
|
||||
}
|
||||
|
||||
notification.error({
|
||||
message,
|
||||
description,
|
||||
|
|
@ -63,7 +77,11 @@ const Toast = {
|
|||
* @param {string} description - 详细描述(可选)
|
||||
* @param {number} duration - 显示时长(秒),默认3秒
|
||||
*/
|
||||
warning: (message, description = '', duration = 3) => {
|
||||
warning: (message, description = '', duration = 3, options = {}) => {
|
||||
if (!options.force && shouldMuteError()) {
|
||||
return
|
||||
}
|
||||
|
||||
notification.warning({
|
||||
message,
|
||||
description,
|
||||
|
|
|
|||
|
|
@ -44,33 +44,20 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.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-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.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);
|
||||
.mode-actions-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.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) {
|
||||
.sider-actions .mode-actions-group .ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -79,7 +66,7 @@
|
|||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.sider-actions .ant-btn:not(.mode-toggle-btn):hover {
|
||||
.sider-actions .mode-actions-group .ant-btn:hover {
|
||||
background: var(--item-hover-bg);
|
||||
border-color: var(--border-color);
|
||||
color: var(--link-color);
|
||||
|
|
@ -182,6 +169,7 @@
|
|||
|
||||
.content-header {
|
||||
padding: 16px 24px;
|
||||
min-height: 57px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Layout, Menu, Button, Modal, Input, Space, Tooltip, Dropdown, Upload, Select, Progress, TreeSelect } from 'antd'
|
||||
import {
|
||||
FileOutlined,
|
||||
|
|
@ -8,7 +8,6 @@ import {
|
|||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
SaveOutlined,
|
||||
EyeOutlined,
|
||||
FileAddOutlined,
|
||||
FolderAddOutlined,
|
||||
UploadOutlined,
|
||||
|
|
@ -39,6 +38,7 @@ 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,6 +46,7 @@ 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)
|
||||
|
|
@ -72,7 +73,28 @@ 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 = () => {
|
||||
|
|
@ -126,13 +148,31 @@ 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)
|
||||
|
|
@ -173,18 +213,38 @@ function DocumentEditor() {
|
|||
return null
|
||||
}
|
||||
|
||||
// Menu点击处理(适配Menu组件)
|
||||
const handleMenuClick = async ({ key, domEvent }) => {
|
||||
const node = findNodeByKey(treeData, key)
|
||||
if (!node) return
|
||||
const expandParentFolders = (path) => {
|
||||
const parts = path.split('/')
|
||||
if (parts.length <= 1) return
|
||||
|
||||
// 始终记录选中的节点(包括文件夹)
|
||||
setSelectedNode(node)
|
||||
setSelectedMenuKey(key) // 高亮显示选中项
|
||||
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)
|
||||
|
||||
// 只处理文件(叶子节点)加载内容
|
||||
if (node.isLeaf) {
|
||||
// 检查是否是PDF文件
|
||||
if (key.toLowerCase().endsWith('.pdf')) {
|
||||
setSelectedFile(key)
|
||||
setIsPdfSelected(true)
|
||||
|
|
@ -192,9 +252,7 @@ function DocumentEditor() {
|
|||
return
|
||||
}
|
||||
|
||||
// 重置PDF标记
|
||||
setIsPdfSelected(false)
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getFileContent(projectId, key)
|
||||
|
|
@ -206,6 +264,25 @@ 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])
|
||||
|
||||
// Menu点击处理(适配Menu组件)
|
||||
const handleMenuClick = async ({ key, domEvent }) => {
|
||||
const node = findNodeByKey(treeData, key)
|
||||
if (!node) return
|
||||
await openNodeByKey(key, node, true)
|
||||
}
|
||||
|
||||
// 重置当前编辑内容(重新从服务器加载)
|
||||
|
|
@ -894,19 +971,35 @@ 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">
|
||||
<Space size={8}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="middle"
|
||||
className="mode-toggle-btn exit-edit"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => navigate(`/projects/${projectId}/docs`)}
|
||||
>
|
||||
退出
|
||||
</Button>
|
||||
<Space.Compact>
|
||||
<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">
|
||||
<Tooltip title="添加文件">
|
||||
<Button
|
||||
size="middle"
|
||||
|
|
@ -944,7 +1037,7 @@ function DocumentEditor() {
|
|||
/>
|
||||
</Tooltip>
|
||||
</Space.Compact>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 上传进度条 */}
|
||||
|
|
|
|||
|
|
@ -40,26 +40,20 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.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-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mode-toggle-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 10px rgba(22, 119, 255, 0.3);
|
||||
filter: brightness(1.1);
|
||||
.mode-actions-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.docs-sider-actions .ant-btn:not(.mode-toggle-btn) {
|
||||
.docs-sider-actions .mode-actions-group .ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -68,7 +62,7 @@
|
|||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.docs-sider-actions .ant-btn-text:hover {
|
||||
.docs-sider-actions .mode-actions-group .ant-btn:hover {
|
||||
background: var(--item-hover-bg);
|
||||
border-color: var(--border-color);
|
||||
color: var(--link-color);
|
||||
|
|
@ -176,6 +170,29 @@
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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, 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 { 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 ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
|
|
@ -17,6 +17,7 @@ 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
|
||||
|
|
@ -37,7 +38,7 @@ const HighlightText = ({ text, keyword }) => {
|
|||
function DocumentPage() {
|
||||
const { projectId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [fileTree, setFileTree] = useState([])
|
||||
const [selectedFile, setSelectedFile] = useState('')
|
||||
const [markdownContent, setMarkdownContent] = useState('')
|
||||
|
|
@ -61,19 +62,47 @@ 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 参数变化,处理文件导航和搜索
|
||||
|
|
@ -127,6 +156,7 @@ function DocumentPage() {
|
|||
const readmeNode = findReadme(fileTree)
|
||||
if (readmeNode) {
|
||||
setSelectedFile(readmeNode.key)
|
||||
updateFileParam(readmeNode.key)
|
||||
loadMarkdown(readmeNode.key)
|
||||
}
|
||||
}
|
||||
|
|
@ -312,6 +342,7 @@ function DocumentPage() {
|
|||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }) => {
|
||||
setSelectedFile(key)
|
||||
updateFileParam(key)
|
||||
|
||||
// 检查是否是PDF文件
|
||||
if (key.toLowerCase().endsWith('.pdf')) {
|
||||
|
|
@ -412,6 +443,7 @@ function DocumentPage() {
|
|||
|
||||
// 选中文件并加载
|
||||
setSelectedFile(targetPath)
|
||||
updateFileParam(targetPath)
|
||||
|
||||
if (isPdf) {
|
||||
// PDF文件:切换到PDF模式
|
||||
|
|
@ -593,7 +625,12 @@ function DocumentPage() {
|
|||
|
||||
// 进入编辑模式
|
||||
const handleEdit = () => {
|
||||
navigate(`/projects/${projectId}/editor`)
|
||||
const params = new URLSearchParams()
|
||||
if (selectedFile) {
|
||||
params.set('file', selectedFile)
|
||||
}
|
||||
const query = params.toString()
|
||||
navigateWithTransition(`/projects/${projectId}/editor${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
// 打开分享设置
|
||||
|
|
@ -782,20 +819,25 @@ function DocumentPage() {
|
|||
</Tooltip>
|
||||
</div>
|
||||
<div className="docs-sider-actions">
|
||||
<Space size={8}>
|
||||
<div className="mode-actions-row">
|
||||
{/* 只有 owner/admin/editor 可以编辑和Git操作 */}
|
||||
{userRole !== 'viewer' && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="middle"
|
||||
className="mode-toggle-btn"
|
||||
icon={<EditOutlined />}
|
||||
onClick={handleEdit}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{userRole !== 'viewer' ? (
|
||||
<ModeSwitch
|
||||
value={modeSwitchValue}
|
||||
onChange={(mode) => {
|
||||
if (mode === 'edit' && !modeSwitchingRef.current) {
|
||||
modeSwitchingRef.current = true
|
||||
setModeSwitchValue('edit')
|
||||
setTimeout(() => {
|
||||
handleEdit()
|
||||
}, 160)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Space.Compact>
|
||||
<Space.Compact className="mode-actions-group">
|
||||
{userRole !== 'viewer' && renderGitActions()}
|
||||
<Tooltip title="分享">
|
||||
<Button
|
||||
|
|
@ -805,7 +847,7 @@ function DocumentPage() {
|
|||
/>
|
||||
</Tooltip>
|
||||
</Space.Compact>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -842,6 +884,9 @@ 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">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useSearchParams } 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,6 +13,7 @@ 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) => {
|
||||
|
|
@ -27,6 +28,12 @@ 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()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@
|
|||
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;
|
||||
|
|
@ -15,9 +19,45 @@
|
|||
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 {
|
||||
padding: 24px 0;
|
||||
.password-tab-content,
|
||||
.mcp-tab-content {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* 头像部分 */
|
||||
|
|
@ -80,16 +120,116 @@
|
|||
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%;
|
||||
|
|
@ -98,4 +238,12 @@
|
|||
.avatar-section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mcp-panel-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mcp-field-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,25 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Card, Tabs, Form, Input, Button, Avatar, Upload, message } from 'antd'
|
||||
import { UserOutlined, LockOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
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 ImgCrop from 'antd-img-crop'
|
||||
import { getCurrentUser, updateProfile, changePassword, uploadAvatar } from '@/api/auth'
|
||||
import { getCurrentUser, updateProfile, changePassword, uploadAvatar, getMcpCredentials, rotateMcpSecret } 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 { user, setUser } = useUserStore()
|
||||
const { setUser } = useUserStore()
|
||||
|
||||
useEffect(() => {
|
||||
loadUserInfo()
|
||||
loadMcpCredentials()
|
||||
}, [])
|
||||
|
||||
// 加载用户信息
|
||||
|
|
@ -35,6 +39,16 @@ 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)
|
||||
|
|
@ -124,6 +138,41 @@ 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',
|
||||
|
|
@ -289,17 +338,86 @@ 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" />
|
||||
<Tabs
|
||||
items={tabItems}
|
||||
defaultActiveKey="profile"
|
||||
tabPosition="left"
|
||||
className="profile-tabs"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@
|
|||
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代理
|
||||
|
|
@ -40,15 +48,22 @@ 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')
|
||||
window.location.href = '/login'
|
||||
setTimeout(() => {
|
||||
redirectToLoginWithReturnTo()
|
||||
}, 600)
|
||||
}
|
||||
return Promise.reject(new Error(res.message || '未登录或登录已过期'))
|
||||
}
|
||||
|
||||
Toast.error('请求失败', res.message || '请求失败')
|
||||
return Promise.reject(new Error(res.message || '请求失败'))
|
||||
}
|
||||
|
||||
|
|
@ -64,13 +79,15 @@ request.interceptors.response.use(
|
|||
switch (status) {
|
||||
case 401:
|
||||
// 只有不在登录页时才显示Toast和跳转
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
Toast.error('认证失败', data?.detail || '未登录或登录已过期')
|
||||
if (!window.location.pathname.includes('/login') && !isHandlingUnauthorized) {
|
||||
isHandlingUnauthorized = true
|
||||
Toast.muteErrors(2500)
|
||||
Toast.error('认证失败', data?.detail || '未登录或登录已过期', 3, { force: true })
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('user_info')
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login'
|
||||
}, 1000) // 延迟1秒,让Toast有时间显示
|
||||
redirectToLoginWithReturnTo()
|
||||
}, 600) // 延迟一小段时间,让提示先展示
|
||||
}
|
||||
break
|
||||
case 403:
|
||||
|
|
|
|||
Loading…
Reference in New Issue