Compare commits
No commits in common. "c005964b5dbe2e3c62e96c6cf8b7c8cf0c788f51" and "dc112de8405db79ecb4c9fe03bca443e32f55b47" have entirely different histories.
c005964b5d
...
dc112de840
|
|
@ -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
|
WORKDIR /app
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import os
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import secrets
|
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import verify_password, get_password_hash, create_access_token
|
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.redis_client import TokenCache
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.mcp_bot import MCPBot
|
|
||||||
from app.models.role import Role, UserRole
|
from app.models.role import Role, UserRole
|
||||||
from app.schemas.user import (
|
from app.schemas.user import UserCreate, UserLogin, UserResponse, Token, ChangePassword, UserUpdate
|
||||||
UserCreate,
|
|
||||||
UserLogin,
|
|
||||||
UserResponse,
|
|
||||||
Token,
|
|
||||||
ChangePassword,
|
|
||||||
UserUpdate,
|
|
||||||
MCPBotCredentialResponse,
|
|
||||||
)
|
|
||||||
from app.schemas.response import success_response, error_response
|
from app.schemas.response import success_response, error_response
|
||||||
from app.services.log_service import log_service
|
from app.services.log_service import log_service
|
||||||
from app.core.enums import OperationType, ResourceType
|
from app.core.enums import OperationType, ResourceType
|
||||||
|
|
@ -38,14 +28,6 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
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)
|
@router.post("/register", response_model=dict)
|
||||||
async def register(
|
async def register(
|
||||||
user_in: UserCreate,
|
user_in: UserCreate,
|
||||||
|
|
@ -233,58 +215,6 @@ async def logout(
|
||||||
return success_response(message="退出成功")
|
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)
|
@router.post("/upload-avatar", response_model=dict)
|
||||||
async def upload_avatar(
|
async def upload_avatar(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = 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"]
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -8,7 +8,6 @@ from app.models.menu import SystemMenu, RoleMenu
|
||||||
from app.models.project import Project, ProjectMember, ProjectMemberRole
|
from app.models.project import Project, ProjectMember, ProjectMemberRole
|
||||||
from app.models.document import DocumentMeta
|
from app.models.document import DocumentMeta
|
||||||
from app.models.log import OperationLog
|
from app.models.log import OperationLog
|
||||||
from app.models.mcp_bot import MCPBot
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
|
|
@ -22,5 +21,4 @@ __all__ = [
|
||||||
"ProjectMemberRole",
|
"ProjectMemberRole",
|
||||||
"DocumentMeta",
|
"DocumentMeta",
|
||||||
"OperationLog",
|
"OperationLog",
|
||||||
"MCPBot",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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}')>"
|
|
||||||
|
|
@ -9,8 +9,6 @@ from app.schemas.user import (
|
||||||
UserLogin,
|
UserLogin,
|
||||||
Token,
|
Token,
|
||||||
ChangePassword,
|
ChangePassword,
|
||||||
MCPBotCredentialResponse,
|
|
||||||
MCPBotCredentialCreateResponse,
|
|
||||||
)
|
)
|
||||||
from app.schemas.project import (
|
from app.schemas.project import (
|
||||||
ProjectBase,
|
ProjectBase,
|
||||||
|
|
@ -44,8 +42,6 @@ __all__ = [
|
||||||
"UserLogin",
|
"UserLogin",
|
||||||
"Token",
|
"Token",
|
||||||
"ChangePassword",
|
"ChangePassword",
|
||||||
"MCPBotCredentialResponse",
|
|
||||||
"MCPBotCredentialCreateResponse",
|
|
||||||
# Project
|
# Project
|
||||||
"ProjectBase",
|
"ProjectBase",
|
||||||
"ProjectCreate",
|
"ProjectCreate",
|
||||||
|
|
|
||||||
|
|
@ -57,21 +57,3 @@ class ChangePassword(BaseModel):
|
||||||
"""修改密码 Schema"""
|
"""修改密码 Schema"""
|
||||||
old_password: str = Field(..., description="旧密码")
|
old_password: str = Field(..., description="旧密码")
|
||||||
new_password: str = Field(..., min_length=6, max_length=50, 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,15 +7,6 @@ from contextlib import asynccontextmanager
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.redis_client import init_redis, close_redis
|
from app.core.redis_client import init_redis, close_redis
|
||||||
from app.api.v1 import api_router
|
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
|
@asynccontextmanager
|
||||||
|
|
@ -23,10 +14,6 @@ async def lifespan(app: FastAPI):
|
||||||
"""应用生命周期管理"""
|
"""应用生命周期管理"""
|
||||||
# 启动时初始化 Redis
|
# 启动时初始化 Redis
|
||||||
await init_redis()
|
await init_redis()
|
||||||
if mcp_session_manager is not None:
|
|
||||||
async with mcp_session_manager.run():
|
|
||||||
yield
|
|
||||||
else:
|
|
||||||
yield
|
yield
|
||||||
# 关闭时清理 Redis 连接
|
# 关闭时清理 Redis 连接
|
||||||
await close_redis()
|
await close_redis()
|
||||||
|
|
@ -53,10 +40,6 @@ app.add_middleware(
|
||||||
# 注册 API 路由
|
# 注册 API 路由
|
||||||
app.include_router(api_router, prefix="/api/v1")
|
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("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,14 @@ MarkupSafe==3.0.3
|
||||||
passlib==1.7.4
|
passlib==1.7.4
|
||||||
pyasn1==0.6.1
|
pyasn1==0.6.1
|
||||||
pycparser==2.23
|
pycparser==2.23
|
||||||
pydantic>=2.11,<3
|
pydantic==2.5.3
|
||||||
pydantic-settings>=2.6,<3
|
pydantic-settings==2.1.0
|
||||||
|
pydantic_core==2.14.6
|
||||||
PyMySQL==1.1.0
|
PyMySQL==1.1.0
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
python-jose==3.3.0
|
python-jose==3.3.0
|
||||||
python-magic==0.4.27
|
python-magic==0.4.27
|
||||||
python-multipart>=0.0.9
|
python-multipart==0.0.6
|
||||||
PyYAML==6.0.1
|
PyYAML==6.0.1
|
||||||
redis==5.0.1
|
redis==5.0.1
|
||||||
rsa==4.9.1
|
rsa==4.9.1
|
||||||
|
|
@ -39,7 +40,7 @@ six==1.17.0
|
||||||
SQLAlchemy==2.0.25
|
SQLAlchemy==2.0.25
|
||||||
starlette==0.35.1
|
starlette==0.35.1
|
||||||
typing_extensions==4.15.0
|
typing_extensions==4.15.0
|
||||||
uvicorn>=0.31.1
|
uvicorn==0.27.0
|
||||||
uvloop==0.22.1
|
uvloop==0.22.1
|
||||||
watchfiles==1.1.1
|
watchfiles==1.1.1
|
||||||
websockets==15.0.1
|
websockets==15.0.1
|
||||||
|
|
@ -47,4 +48,3 @@ Whoosh==2.7.4
|
||||||
markdown==3.5.2
|
markdown==3.5.2
|
||||||
weasyprint==61.2
|
weasyprint==61.2
|
||||||
pydyf<0.11.0
|
pydyf<0.11.0
|
||||||
mcp==1.26.0
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
|
||||||
|
|
@ -166,22 +166,6 @@ CREATE TABLE IF NOT EXISTS `operation_logs` (
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';
|
) 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
|
INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES
|
||||||
('超级管理员', 'super_admin', '拥有系统所有权限', 1),
|
('超级管理员', 'super_admin', '拥有系统所有权限', 1),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传用户头像
|
* 上传用户头像
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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 }) {
|
function ProtectedRoute({ children }) {
|
||||||
const location = useLocation()
|
|
||||||
const token = localStorage.getItem('access_token')
|
const token = localStorage.getItem('access_token')
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
const returnTo = encodeURIComponent(`${location.pathname}${location.search}${location.hash}`)
|
return <Navigate to="/login" replace />
|
||||||
return <Navigate to={`/login?redirect=${returnTo}`} replace />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return children
|
return children
|
||||||
|
|
|
||||||
|
|
@ -14,21 +14,11 @@ notification.config({
|
||||||
maxCount: 3,
|
maxCount: 3,
|
||||||
})
|
})
|
||||||
|
|
||||||
let muteErrorsUntil = 0
|
|
||||||
|
|
||||||
function shouldMuteError() {
|
|
||||||
return Date.now() < muteErrorsUntil
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标准通知反馈组件
|
* 标准通知反馈组件
|
||||||
* 从右上角滑出,默认3秒后消失
|
* 从右上角滑出,默认3秒后消失
|
||||||
*/
|
*/
|
||||||
const Toast = {
|
const Toast = {
|
||||||
muteErrors: (durationMs = 2000) => {
|
|
||||||
muteErrorsUntil = Date.now() + durationMs
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 成功通知
|
* 成功通知
|
||||||
* @param {string} message - 消息内容
|
* @param {string} message - 消息内容
|
||||||
|
|
@ -54,11 +44,7 @@ const Toast = {
|
||||||
* @param {string} description - 详细描述(可选)
|
* @param {string} description - 详细描述(可选)
|
||||||
* @param {number} duration - 显示时长(秒),默认3秒
|
* @param {number} duration - 显示时长(秒),默认3秒
|
||||||
*/
|
*/
|
||||||
error: (message, description = '', duration = 3, options = {}) => {
|
error: (message, description = '', duration = 3) => {
|
||||||
if (!options.force && shouldMuteError()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
notification.error({
|
notification.error({
|
||||||
message,
|
message,
|
||||||
description,
|
description,
|
||||||
|
|
@ -77,11 +63,7 @@ const Toast = {
|
||||||
* @param {string} description - 详细描述(可选)
|
* @param {string} description - 详细描述(可选)
|
||||||
* @param {number} duration - 显示时长(秒),默认3秒
|
* @param {number} duration - 显示时长(秒),默认3秒
|
||||||
*/
|
*/
|
||||||
warning: (message, description = '', duration = 3, options = {}) => {
|
warning: (message, description = '', duration = 3) => {
|
||||||
if (!options.force && shouldMuteError()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
notification.warning({
|
notification.warning({
|
||||||
message,
|
message,
|
||||||
description,
|
description,
|
||||||
|
|
|
||||||
|
|
@ -44,20 +44,33 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-actions-row {
|
.mode-toggle-btn {
|
||||||
width: 100%;
|
font-weight: 500 !important;
|
||||||
display: flex;
|
height: 32px !important;
|
||||||
align-items: center;
|
display: flex !important;
|
||||||
justify-content: space-between;
|
align-items: center !important;
|
||||||
gap: 12px;
|
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 {
|
.mode-toggle-btn.exit-edit {
|
||||||
display: inline-flex;
|
background: linear-gradient(135deg, #00b96b 0%, #52c41a 100%) !important;
|
||||||
align-items: center;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -66,7 +79,7 @@
|
||||||
color: var(--text-color-secondary);
|
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);
|
background: var(--item-hover-bg);
|
||||||
border-color: var(--border-color);
|
border-color: var(--border-color);
|
||||||
color: var(--link-color);
|
color: var(--link-color);
|
||||||
|
|
@ -169,7 +182,6 @@
|
||||||
|
|
||||||
.content-header {
|
.content-header {
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
min-height: 57px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
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 { Layout, Menu, Button, Modal, Input, Space, Tooltip, Dropdown, Upload, Select, Progress, TreeSelect } from 'antd'
|
||||||
import {
|
import {
|
||||||
FileOutlined,
|
FileOutlined,
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
|
EyeOutlined,
|
||||||
FileAddOutlined,
|
FileAddOutlined,
|
||||||
FolderAddOutlined,
|
FolderAddOutlined,
|
||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
|
|
@ -38,7 +39,6 @@ import {
|
||||||
uploadDocument,
|
uploadDocument,
|
||||||
} from '@/api/file'
|
} from '@/api/file'
|
||||||
import Toast from '@/components/Toast/Toast'
|
import Toast from '@/components/Toast/Toast'
|
||||||
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
|
|
||||||
import './DocumentEditor.css'
|
import './DocumentEditor.css'
|
||||||
|
|
||||||
const { Sider, Content } = Layout
|
const { Sider, Content } = Layout
|
||||||
|
|
@ -46,7 +46,6 @@ const { Sider, Content } = Layout
|
||||||
function DocumentEditor() {
|
function DocumentEditor() {
|
||||||
const { projectId } = useParams()
|
const { projectId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
const [treeData, setTreeData] = useState([])
|
const [treeData, setTreeData] = useState([])
|
||||||
const [selectedFile, setSelectedFile] = useState(null)
|
const [selectedFile, setSelectedFile] = useState(null)
|
||||||
|
|
@ -73,28 +72,7 @@ function DocumentEditor() {
|
||||||
const [linkModalVisible, setLinkModalVisible] = useState(false)
|
const [linkModalVisible, setLinkModalVisible] = useState(false)
|
||||||
const [linkTarget, setLinkTarget] = useState(null)
|
const [linkTarget, setLinkTarget] = useState(null)
|
||||||
const [projectName, setProjectName] = useState('') // 项目名称
|
const [projectName, setProjectName] = useState('') // 项目名称
|
||||||
const [userRole, setUserRole] = useState('viewer')
|
|
||||||
const [modeSwitchValue, setModeSwitchValue] = useState('edit')
|
|
||||||
const editorCtxRef = useRef(null)
|
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 = () => {
|
const handleInsertLink = () => {
|
||||||
|
|
@ -148,31 +126,13 @@ function DocumentEditor() {
|
||||||
const data = res.data || {}
|
const data = res.data || {}
|
||||||
const tree = data.tree || data || [] // 兼容新旧格式
|
const tree = data.tree || data || [] // 兼容新旧格式
|
||||||
const name = data.project_name
|
const name = data.project_name
|
||||||
const role = data.user_role || 'viewer'
|
|
||||||
setTreeData(tree)
|
setTreeData(tree)
|
||||||
setProjectName(name)
|
setProjectName(name)
|
||||||
setUserRole(role)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.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) => {
|
const handleSelectFile = async (selectedKeys, info) => {
|
||||||
// 记录选中的节点(无论是文件还是目录)
|
// 记录选中的节点(无论是文件还是目录)
|
||||||
setSelectedNode(info.node)
|
setSelectedNode(info.node)
|
||||||
|
|
@ -213,38 +173,18 @@ function DocumentEditor() {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const expandParentFolders = (path) => {
|
// Menu点击处理(适配Menu组件)
|
||||||
const parts = path.split('/')
|
const handleMenuClick = async ({ key, domEvent }) => {
|
||||||
if (parts.length <= 1) return
|
const node = findNodeByKey(treeData, key)
|
||||||
|
if (!node) return
|
||||||
|
|
||||||
const parentKeys = []
|
// 始终记录选中的节点(包括文件夹)
|
||||||
let currentPath = ''
|
setSelectedNode(node)
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
setSelectedMenuKey(key) // 高亮显示选中项
|
||||||
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')) {
|
if (key.toLowerCase().endsWith('.pdf')) {
|
||||||
setSelectedFile(key)
|
setSelectedFile(key)
|
||||||
setIsPdfSelected(true)
|
setIsPdfSelected(true)
|
||||||
|
|
@ -252,7 +192,9 @@ function DocumentEditor() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 重置PDF标记
|
||||||
setIsPdfSelected(false)
|
setIsPdfSelected(false)
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await getFileContent(projectId, key)
|
const res = await getFileContent(projectId, key)
|
||||||
|
|
@ -264,25 +206,6 @@ function DocumentEditor() {
|
||||||
setLoading(false)
|
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置当前编辑内容(重新从服务器加载)
|
// 重置当前编辑内容(重新从服务器加载)
|
||||||
|
|
@ -971,35 +894,19 @@ function DocumentEditor() {
|
||||||
<h2 style={{ margin: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={projectName}>
|
<h2 style={{ margin: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={projectName}>
|
||||||
{projectName}
|
{projectName}
|
||||||
</h2>
|
</h2>
|
||||||
<Tooltip title="关闭">
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={<CloseOutlined />}
|
|
||||||
onClick={handleClose}
|
|
||||||
style={{ marginLeft: 8 }}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="sider-actions">
|
<div className="sider-actions">
|
||||||
<div className="mode-actions-row">
|
<Space size={8}>
|
||||||
<ModeSwitch
|
<Button
|
||||||
value={modeSwitchValue}
|
type="primary"
|
||||||
onChange={(mode) => {
|
size="middle"
|
||||||
if (mode === 'view' && !modeSwitchingRef.current) {
|
className="mode-toggle-btn exit-edit"
|
||||||
modeSwitchingRef.current = true
|
icon={<EyeOutlined />}
|
||||||
setModeSwitchValue('view')
|
onClick={() => navigate(`/projects/${projectId}/docs`)}
|
||||||
setTimeout(() => {
|
>
|
||||||
const params = new URLSearchParams()
|
退出
|
||||||
if (selectedFile) {
|
</Button>
|
||||||
params.set('file', selectedFile)
|
<Space.Compact>
|
||||||
}
|
|
||||||
const query = params.toString()
|
|
||||||
navigateWithTransition(`/projects/${projectId}/docs${query ? `?${query}` : ''}`)
|
|
||||||
}, 160)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Space.Compact className="mode-actions-group">
|
|
||||||
<Tooltip title="添加文件">
|
<Tooltip title="添加文件">
|
||||||
<Button
|
<Button
|
||||||
size="middle"
|
size="middle"
|
||||||
|
|
@ -1037,7 +944,7 @@ function DocumentEditor() {
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</div>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 上传进度条 */}
|
{/* 上传进度条 */}
|
||||||
|
|
|
||||||
|
|
@ -40,20 +40,26 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-actions-row {
|
.mode-toggle-btn {
|
||||||
width: 100%;
|
font-weight: 500 !important;
|
||||||
display: flex;
|
box-shadow: 0 2px 6px rgba(22, 119, 255, 0.2);
|
||||||
align-items: center;
|
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%) !important;
|
||||||
justify-content: space-between;
|
border: none !important;
|
||||||
gap: 12px;
|
height: 32px !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
padding: 4px 15px !important;
|
||||||
|
color: white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mode-actions-group {
|
.mode-toggle-btn:hover {
|
||||||
display: inline-flex;
|
transform: translateY(-1px);
|
||||||
align-items: center;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -62,7 +68,7 @@
|
||||||
color: var(--text-color-secondary);
|
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);
|
background: var(--item-hover-bg);
|
||||||
border-color: var(--border-color);
|
border-color: var(--border-color);
|
||||||
color: var(--link-color);
|
color: var(--link-color);
|
||||||
|
|
@ -170,29 +176,6 @@
|
||||||
background: var(--bg-color);
|
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 {
|
.docs-content-wrapper {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
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 { Layout, Menu, Spin, FloatButton, Button, Tooltip, message, Anchor, Modal, Input, Switch, 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 { 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 ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import rehypeRaw from 'rehype-raw'
|
import rehypeRaw from 'rehype-raw'
|
||||||
|
|
@ -17,7 +17,6 @@ import { searchDocuments } from '@/api/search'
|
||||||
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
||||||
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
|
import DocFloatActions from '@/components/DocFloatActions/DocFloatActions'
|
||||||
import Toast from '@/components/Toast/Toast'
|
import Toast from '@/components/Toast/Toast'
|
||||||
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
|
|
||||||
import './DocumentPage.css'
|
import './DocumentPage.css'
|
||||||
|
|
||||||
const { Sider, Content } = Layout
|
const { Sider, Content } = Layout
|
||||||
|
|
@ -38,7 +37,7 @@ const HighlightText = ({ text, keyword }) => {
|
||||||
function DocumentPage() {
|
function DocumentPage() {
|
||||||
const { projectId } = useParams()
|
const { projectId } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const [fileTree, setFileTree] = useState([])
|
const [fileTree, setFileTree] = useState([])
|
||||||
const [selectedFile, setSelectedFile] = useState('')
|
const [selectedFile, setSelectedFile] = useState('')
|
||||||
const [markdownContent, setMarkdownContent] = useState('')
|
const [markdownContent, setMarkdownContent] = useState('')
|
||||||
|
|
@ -62,47 +61,19 @@ function DocumentPage() {
|
||||||
const [searchKeyword, setSearchKeyword] = useState('')
|
const [searchKeyword, setSearchKeyword] = useState('')
|
||||||
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
|
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
const [modeSwitchValue, setModeSwitchValue] = useState('view')
|
|
||||||
|
|
||||||
const contentRef = useRef(null)
|
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(() => {
|
useEffect(() => {
|
||||||
loadFileTree()
|
loadFileTree()
|
||||||
}, [projectId])
|
}, [projectId])
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
Modal.confirm({
|
|
||||||
title: '确认退出',
|
|
||||||
content: '确定要退出当前项目页面吗?',
|
|
||||||
okText: '退出',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: () => {
|
|
||||||
if (userRole === 'owner') {
|
if (userRole === 'owner') {
|
||||||
navigate('/projects/my')
|
navigate('/projects/my')
|
||||||
} else {
|
} else {
|
||||||
navigate('/projects/share')
|
navigate('/projects/share')
|
||||||
}
|
}
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听 URL 参数变化,处理文件导航和搜索
|
// 监听 URL 参数变化,处理文件导航和搜索
|
||||||
|
|
@ -156,7 +127,6 @@ function DocumentPage() {
|
||||||
const readmeNode = findReadme(fileTree)
|
const readmeNode = findReadme(fileTree)
|
||||||
if (readmeNode) {
|
if (readmeNode) {
|
||||||
setSelectedFile(readmeNode.key)
|
setSelectedFile(readmeNode.key)
|
||||||
updateFileParam(readmeNode.key)
|
|
||||||
loadMarkdown(readmeNode.key)
|
loadMarkdown(readmeNode.key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -342,7 +312,6 @@ function DocumentPage() {
|
||||||
// 处理菜单点击
|
// 处理菜单点击
|
||||||
const handleMenuClick = ({ key }) => {
|
const handleMenuClick = ({ key }) => {
|
||||||
setSelectedFile(key)
|
setSelectedFile(key)
|
||||||
updateFileParam(key)
|
|
||||||
|
|
||||||
// 检查是否是PDF文件
|
// 检查是否是PDF文件
|
||||||
if (key.toLowerCase().endsWith('.pdf')) {
|
if (key.toLowerCase().endsWith('.pdf')) {
|
||||||
|
|
@ -443,7 +412,6 @@ function DocumentPage() {
|
||||||
|
|
||||||
// 选中文件并加载
|
// 选中文件并加载
|
||||||
setSelectedFile(targetPath)
|
setSelectedFile(targetPath)
|
||||||
updateFileParam(targetPath)
|
|
||||||
|
|
||||||
if (isPdf) {
|
if (isPdf) {
|
||||||
// PDF文件:切换到PDF模式
|
// PDF文件:切换到PDF模式
|
||||||
|
|
@ -625,12 +593,7 @@ function DocumentPage() {
|
||||||
|
|
||||||
// 进入编辑模式
|
// 进入编辑模式
|
||||||
const handleEdit = () => {
|
const handleEdit = () => {
|
||||||
const params = new URLSearchParams()
|
navigate(`/projects/${projectId}/editor`)
|
||||||
if (selectedFile) {
|
|
||||||
params.set('file', selectedFile)
|
|
||||||
}
|
|
||||||
const query = params.toString()
|
|
||||||
navigateWithTransition(`/projects/${projectId}/editor${query ? `?${query}` : ''}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开分享设置
|
// 打开分享设置
|
||||||
|
|
@ -819,25 +782,20 @@ function DocumentPage() {
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="docs-sider-actions">
|
<div className="docs-sider-actions">
|
||||||
<div className="mode-actions-row">
|
<Space size={8}>
|
||||||
{/* 只有 owner/admin/editor 可以编辑和Git操作 */}
|
{/* 只有 owner/admin/editor 可以编辑和Git操作 */}
|
||||||
{userRole !== 'viewer' ? (
|
{userRole !== 'viewer' && (
|
||||||
<ModeSwitch
|
<Button
|
||||||
value={modeSwitchValue}
|
type="primary"
|
||||||
onChange={(mode) => {
|
size="middle"
|
||||||
if (mode === 'edit' && !modeSwitchingRef.current) {
|
className="mode-toggle-btn"
|
||||||
modeSwitchingRef.current = true
|
icon={<EditOutlined />}
|
||||||
setModeSwitchValue('edit')
|
onClick={handleEdit}
|
||||||
setTimeout(() => {
|
>
|
||||||
handleEdit()
|
编辑
|
||||||
}, 160)
|
</Button>
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div />
|
|
||||||
)}
|
)}
|
||||||
<Space.Compact className="mode-actions-group">
|
<Space.Compact>
|
||||||
{userRole !== 'viewer' && renderGitActions()}
|
{userRole !== 'viewer' && renderGitActions()}
|
||||||
<Tooltip title="分享">
|
<Tooltip title="分享">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -847,7 +805,7 @@ function DocumentPage() {
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</div>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -884,9 +842,6 @@ function DocumentPage() {
|
||||||
{/* 右侧内容区 */}
|
{/* 右侧内容区 */}
|
||||||
<Layout className="docs-content-layout">
|
<Layout className="docs-content-layout">
|
||||||
<Content className="docs-content" ref={contentRef}>
|
<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' : ''}`}>
|
<div className={`docs-content-wrapper ${viewMode === 'pdf' ? 'pdf-mode' : ''}`}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="docs-loading">
|
<div className="docs-loading">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Form, Input, Button, Tabs } from 'antd'
|
import { Form, Input, Button, Tabs } from 'antd'
|
||||||
import { UserOutlined, LockOutlined, MailOutlined, ArrowRightOutlined } from '@ant-design/icons'
|
import { UserOutlined, LockOutlined, MailOutlined, ArrowRightOutlined } from '@ant-design/icons'
|
||||||
import { login, register } from '@/api/auth'
|
import { login, register } from '@/api/auth'
|
||||||
|
|
@ -13,7 +13,6 @@ function Login() {
|
||||||
const [activeTab, setActiveTab] = useState('login')
|
const [activeTab, setActiveTab] = useState('login')
|
||||||
const [loginForm] = Form.useForm()
|
const [loginForm] = Form.useForm()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams] = useSearchParams()
|
|
||||||
const { setUser, setToken } = useUserStore()
|
const { setUser, setToken } = useUserStore()
|
||||||
|
|
||||||
const handleLogin = async (values) => {
|
const handleLogin = async (values) => {
|
||||||
|
|
@ -28,12 +27,6 @@ function Login() {
|
||||||
setToken(res.data.access_token)
|
setToken(res.data.access_token)
|
||||||
setUser(res.data.user)
|
setUser(res.data.user)
|
||||||
|
|
||||||
const redirect = searchParams.get('redirect')
|
|
||||||
if (redirect) {
|
|
||||||
navigate(decodeURIComponent(redirect), { replace: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取用户菜单并跳转到第一个菜单
|
// 获取用户菜单并跳转到第一个菜单
|
||||||
try {
|
try {
|
||||||
const menuRes = await getUserMenus()
|
const menuRes = await getUserMenus()
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,6 @@
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card .ant-card-body {
|
|
||||||
padding: 28px 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-title {
|
.profile-title {
|
||||||
margin: 0 0 24px 0;
|
margin: 0 0 24px 0;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
|
|
@ -19,45 +15,9 @@
|
||||||
color: rgba(0, 0, 0, 0.88);
|
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,
|
.profile-tab-content,
|
||||||
.password-tab-content,
|
.password-tab-content {
|
||||||
.mcp-tab-content {
|
padding: 24px 0;
|
||||||
padding: 4px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 头像部分 */
|
/* 头像部分 */
|
||||||
|
|
@ -120,116 +80,16 @@
|
||||||
margin-bottom: 0;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.profile-page {
|
.profile-page {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card .ant-card-body {
|
|
||||||
padding: 20px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-title {
|
.profile-title {
|
||||||
font-size: 20px;
|
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,
|
.profile-form,
|
||||||
.password-form {
|
.password-form {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
@ -238,12 +98,4 @@
|
||||||
.avatar-section {
|
.avatar-section {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mcp-panel-header {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mcp-field-list {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,21 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Card, Tabs, Form, Input, Button, Avatar, Upload, message, Space, Typography, Modal } from 'antd'
|
import { Card, Tabs, Form, Input, Button, Avatar, Upload, message } from 'antd'
|
||||||
import { UserOutlined, LockOutlined, UploadOutlined, ApiOutlined, CopyOutlined, ReloadOutlined } from '@ant-design/icons'
|
import { UserOutlined, LockOutlined, UploadOutlined } from '@ant-design/icons'
|
||||||
import ImgCrop from 'antd-img-crop'
|
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 useUserStore from '@/stores/userStore'
|
||||||
import Toast from '@/components/Toast/Toast'
|
import Toast from '@/components/Toast/Toast'
|
||||||
import './ProfilePage.css'
|
import './ProfilePage.css'
|
||||||
|
|
||||||
const { Paragraph, Text } = Typography
|
|
||||||
|
|
||||||
function ProfilePage() {
|
function ProfilePage() {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [userInfo, setUserInfo] = useState(null)
|
const [userInfo, setUserInfo] = useState(null)
|
||||||
const [mcpCredentials, setMcpCredentials] = useState(null)
|
|
||||||
const [profileForm] = Form.useForm()
|
const [profileForm] = Form.useForm()
|
||||||
const [passwordForm] = Form.useForm()
|
const [passwordForm] = Form.useForm()
|
||||||
const { setUser } = useUserStore()
|
const { user, setUser } = useUserStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUserInfo()
|
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) => {
|
const handleUpdateProfile = async (values) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -138,41 +124,6 @@ function ProfilePage() {
|
||||||
return null
|
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 = [
|
const tabItems = [
|
||||||
{
|
{
|
||||||
key: 'profile',
|
key: 'profile',
|
||||||
|
|
@ -338,86 +289,17 @@ function ProfilePage() {
|
||||||
</div>
|
</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 (
|
return (
|
||||||
|
|
||||||
<div className="profile-page">
|
<div className="profile-page">
|
||||||
<Card className="profile-card">
|
<Card className="profile-card">
|
||||||
<h2 className="profile-title">个人中心</h2>
|
<h2 className="profile-title">个人中心</h2>
|
||||||
<Tabs
|
<Tabs items={tabItems} defaultActiveKey="profile" />
|
||||||
items={tabItems}
|
|
||||||
defaultActiveKey="profile"
|
|
||||||
tabPosition="left"
|
|
||||||
className="profile-tabs"
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,6 @@
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import Toast from '@/components/Toast/Toast'
|
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 实例
|
// 创建 axios 实例
|
||||||
const request = axios.create({
|
const request = axios.create({
|
||||||
baseURL: '/api/v1', // 使用相对路径,开发环境通过vite proxy代理,生产环境通过nginx代理
|
baseURL: '/api/v1', // 使用相对路径,开发环境通过vite proxy代理,生产环境通过nginx代理
|
||||||
|
|
@ -48,22 +40,15 @@ request.interceptors.response.use(
|
||||||
|
|
||||||
// 如果返回的状态码不是 200,说明有错误
|
// 如果返回的状态码不是 200,说明有错误
|
||||||
if (res.code !== 200) {
|
if (res.code !== 200) {
|
||||||
|
Toast.error('请求失败', res.message || '请求失败')
|
||||||
|
|
||||||
// 401: 未登录或 token 过期
|
// 401: 未登录或 token 过期
|
||||||
if (res.code === 401) {
|
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('access_token')
|
||||||
localStorage.removeItem('user_info')
|
localStorage.removeItem('user_info')
|
||||||
setTimeout(() => {
|
window.location.href = '/login'
|
||||||
redirectToLoginWithReturnTo()
|
|
||||||
}, 600)
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(res.message || '未登录或登录已过期'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Toast.error('请求失败', res.message || '请求失败')
|
|
||||||
return Promise.reject(new Error(res.message || '请求失败'))
|
return Promise.reject(new Error(res.message || '请求失败'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,15 +64,13 @@ request.interceptors.response.use(
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 401:
|
case 401:
|
||||||
// 只有不在登录页时才显示Toast和跳转
|
// 只有不在登录页时才显示Toast和跳转
|
||||||
if (!window.location.pathname.includes('/login') && !isHandlingUnauthorized) {
|
if (!window.location.pathname.includes('/login')) {
|
||||||
isHandlingUnauthorized = true
|
Toast.error('认证失败', data?.detail || '未登录或登录已过期')
|
||||||
Toast.muteErrors(2500)
|
|
||||||
Toast.error('认证失败', data?.detail || '未登录或登录已过期', 3, { force: true })
|
|
||||||
localStorage.removeItem('access_token')
|
localStorage.removeItem('access_token')
|
||||||
localStorage.removeItem('user_info')
|
localStorage.removeItem('user_info')
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
redirectToLoginWithReturnTo()
|
window.location.href = '/login'
|
||||||
}, 600) // 延迟一小段时间,让提示先展示
|
}, 1000) // 延迟1秒,让Toast有时间显示
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 403:
|
case 403:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue