From abcced35b8bb58f2e164e25134ad621399a1fddc Mon Sep 17 00:00:00 2001 From: xiaopeng <1509442308@qq.com> Date: Wed, 27 May 2026 20:51:28 +0800 Subject: [PATCH] feat: RAG inline citations, source highlighting, and admin panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Increase RAG retrieval from 5 to 50 docs with relevance threshold (0.15) - LLM inline citations with [来源N] format and reference list - Clickable citation links scroll to source cards with highlight animation - Source previews with full chunk text and answer-matched highlighting - Message-scoped source IDs to fix cross-round citation targeting - Admin panel pages (knowledge, users, forum, course, settings) - Add rehype-raw dependency for HTML-in-markdown rendering Co-Authored-By: Claude Opus 4.7 --- backend/main.py | 4 + backend/src/api/admin.py | 511 ++++++++++++++++++ backend/src/api/chat.py | 2 +- backend/src/rag/chains.py | 31 +- backend/src/rag/prompts.py | 15 +- backend/src/rag/retrievers.py | 2 +- web/package.json | 1 + web/pnpm-lock.yaml | 50 ++ web/src/app/(main)/admin/course/page.tsx | 166 ++++++ web/src/app/(main)/admin/forum/page.tsx | 268 +++++++++ web/src/app/(main)/admin/knowledge/page.tsx | 139 +++++ web/src/app/(main)/admin/layout.tsx | 115 ++++ web/src/app/(main)/admin/page.tsx | 131 +++++ web/src/app/(main)/admin/settings/page.tsx | 151 ++++++ web/src/app/(main)/admin/users/page.tsx | 185 +++++++ web/src/app/globals.css | 33 ++ web/src/components/chat/message-item.tsx | 38 +- web/src/components/chat/source-references.tsx | 340 +++++++++--- web/src/components/home/navbar.tsx | 13 +- web/src/components/layout/mobile-nav.tsx | 13 +- web/src/lib/api.ts | 129 +++++ web/src/store/chat.ts | 23 + web/src/types/index.ts | 1 + 23 files changed, 2249 insertions(+), 112 deletions(-) create mode 100644 backend/src/api/admin.py create mode 100644 web/src/app/(main)/admin/course/page.tsx create mode 100644 web/src/app/(main)/admin/forum/page.tsx create mode 100644 web/src/app/(main)/admin/knowledge/page.tsx create mode 100644 web/src/app/(main)/admin/layout.tsx create mode 100644 web/src/app/(main)/admin/page.tsx create mode 100644 web/src/app/(main)/admin/settings/page.tsx create mode 100644 web/src/app/(main)/admin/users/page.tsx diff --git a/backend/main.py b/backend/main.py index d7c7184..856a6cb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -185,6 +185,10 @@ from src.api import course_content, forum app.include_router(course_content.router) app.include_router(forum.router) +# 导入并注册后台管理API +from src.api import admin +app.include_router(admin.router) + # 静态文件服务 if os.path.exists(settings.upload_dir): app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads") diff --git a/backend/src/api/admin.py b/backend/src/api/admin.py new file mode 100644 index 0000000..e725e20 --- /dev/null +++ b/backend/src/api/admin.py @@ -0,0 +1,511 @@ +""" +后台管理API +""" +from typing import List, Optional +from datetime import datetime, timedelta + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy.orm import Session +from sqlalchemy import func, desc + +from ..core.database import get_db +from ..core.security import get_current_user +from ..models.user import User +from ..models.chat import ChatSession, ChatMessage +from ..models.document import Document, DocumentChunk +from ..models.knowledge_base import KnowledgeBase +from ..models.forum import ForumCategory, ForumPost, ForumReply + +router = APIRouter(prefix="/admin", tags=["后台管理"]) + + +async def require_admin( + current_user: str = Depends(get_current_user), + db: Session = Depends(get_db) +) -> User: + """验证当前用户是否为管理员""" + user = db.query(User).filter(User.username == current_user).first() + if not user or not user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要管理员权限" + ) + return user + + +# ===== 数据概览 ===== + +class AdminDashboardStats(BaseModel): + total_users: int + active_users_7d: int + total_sessions: int + total_messages: int + total_documents: int + total_knowledge_bases: int + total_forum_posts: int + total_forum_replies: int + total_generated_images: int + + +class AdminTrendItem(BaseModel): + date: str + count: int + + +@router.get("/dashboard", response_model=AdminDashboardStats) +async def get_dashboard( + admin: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """获取管理后台仪表盘数据""" + total_users = db.query(func.count(User.id)).scalar() or 0 + + # 7天内活跃用户(有消息的用户) + week_ago = datetime.utcnow() - timedelta(days=7) + active_users_7d = db.query(func.count(func.distinct(ChatMessage.session_id))).join( + ChatSession + ).filter( + ChatMessage.created_at >= week_ago + ).scalar() or 0 + # 更准确的计算:有消息的独立用户数 + active_users_7d = db.query(func.count(func.distinct(ChatSession.user_id))).join( + ChatMessage + ).filter( + ChatMessage.created_at >= week_ago + ).scalar() or 0 + + total_sessions = db.query(func.count(ChatSession.id)).scalar() or 0 + total_messages = db.query(func.count(ChatMessage.id)).scalar() or 0 + total_documents = db.query(func.count(Document.id)).scalar() or 0 + total_knowledge_bases = db.query(func.count(KnowledgeBase.id)).scalar() or 0 + total_forum_posts = db.query(func.count(ForumPost.id)).scalar() or 0 + total_forum_replies = db.query(func.count(ForumReply.id)).scalar() or 0 + + # 生成图像数(从目录统计) + from pathlib import Path + from ..core.config import get_settings + settings = get_settings() + generated_images_dir = Path(settings.generated_images_dir) + total_generated_images = 0 + if generated_images_dir.exists(): + image_extensions = {'.png', '.jpg', '.jpeg', '.webp', '.gif'} + total_generated_images = sum( + 1 for f in generated_images_dir.iterdir() + if f.is_file() and f.suffix.lower() in image_extensions + ) + + return AdminDashboardStats( + total_users=total_users, + active_users_7d=active_users_7d, + total_sessions=total_sessions, + total_messages=total_messages, + total_documents=total_documents, + total_knowledge_bases=total_knowledge_bases, + total_forum_posts=total_forum_posts, + total_forum_replies=total_forum_replies, + total_generated_images=total_generated_images, + ) + + +@router.get("/trends/users") +async def get_user_trends( + days: int = 30, + admin: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """获取用户注册趋势""" + start_date = datetime.utcnow() - timedelta(days=days) + results = db.query( + func.date(User.created_at).label("date"), + func.count(User.id).label("count") + ).filter( + User.created_at >= start_date + ).group_by( + func.date(User.created_at) + ).order_by("date").all() + + return [{"date": str(r.date), "count": r.count} for r in results] + + +@router.get("/trends/messages") +async def get_message_trends( + days: int = 30, + admin: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """获取消息趋势""" + start_date = datetime.utcnow() - timedelta(days=days) + results = db.query( + func.date(ChatMessage.created_at).label("date"), + func.count(ChatMessage.id).label("count") + ).filter( + ChatMessage.created_at >= start_date + ).group_by( + func.date(ChatMessage.created_at) + ).order_by("date").all() + + return [{"date": str(r.date), "count": r.count} for r in results] + + +# ===== 用户管理 ===== + +class AdminUserItem(BaseModel): + id: int + username: str + email: str + full_name: Optional[str] + is_active: bool + is_superuser: bool + created_at: Optional[str] + last_login: Optional[str] + session_count: int + message_count: int + + class Config: + from_attributes = True + + +@router.get("/users", response_model=List[AdminUserItem]) +async def list_users( + skip: int = 0, + limit: int = 50, + admin: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """获取用户列表""" + users = db.query(User).order_by(desc(User.id)).offset(skip).limit(limit).all() + + result = [] + for u in users: + session_count = db.query(func.count(ChatSession.id)).filter( + ChatSession.user_id == u.id + ).scalar() or 0 + message_count = db.query(func.count(ChatMessage.id)).join( + ChatSession + ).filter(ChatSession.user_id == u.id).scalar() or 0 + + result.append(AdminUserItem( + id=u.id, + username=u.username, + email=u.email, + full_name=u.full_name, + is_active=u.is_active, + is_superuser=u.is_superuser, + created_at=u.created_at.isoformat() if u.created_at else None, + last_login=u.last_login.isoformat() if u.last_login else None, + session_count=session_count, + message_count=message_count, + )) + return result + + +@router.put("/users/{user_id}/toggle-active") +async def toggle_user_active( + user_id: int, + admin: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """启用/禁用用户""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if user.id == admin.id: + raise HTTPException(status_code=400, detail="不能禁用自己") + + user.is_active = not user.is_active + db.commit() + return {"success": True, "is_active": user.is_active} + + +@router.put("/users/{user_id}/toggle-admin") +async def toggle_user_admin( + user_id: int, + admin: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """设为/取消管理员""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if user.id == admin.id: + raise HTTPException(status_code=400, detail="不能修改自己的管理员状态") + + user.is_superuser = not user.is_superuser + db.commit() + return {"success": True, "is_superuser": user.is_superuser} + + +@router.delete("/users/{user_id}") +async def delete_user( + user_id: int, + admin: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """删除用户""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if user.id == admin.id: + raise HTTPException(status_code=400, detail="不能删除自己") + + db.delete(user) + db.commit() + return {"success": True} + + +# ===== 论坛管理 ===== + +class AdminForumCategory(BaseModel): + id: int + slug: str + name: str + description: Optional[str] + post_count: int + + class Config: + from_attributes = True + + +class AdminForumCategoryCreate(BaseModel): + name: str + slug: str + description: Optional[str] = None + + +@router.get("/forum/categories", response_model=List[AdminForumCategory]) +async def list_forum_categories( + admin: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """获取论坛分类列表(管理)""" + categories = db.query(ForumCategory).order_by(ForumCategory.id).all() + result = [] + for c in categories: + post_count = db.query(func.count(ForumPost.id)).filter( + ForumPost.category_id == c.id + ).scalar() or 0 + result.append(AdminForumCategory( + id=c.id, + slug=c.slug, + name=c.name, + description=c.description, + post_count=post_count, + )) + return result + + +@router.post("/forum/categories", response_model=AdminForumCategory) +async def create_forum_category( + data: AdminForumCategoryCreate, + admin: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """创建论坛分类""" + existing = db.query(ForumCategory).filter(ForumCategory.slug == data.slug).first() + if existing: + raise HTTPException(status_code=400, detail="slug 已存在") + category = ForumCategory( + slug=data.slug, + name=data.name, + description=data.description, + ) + db.add(category) + db.commit() + db.refresh(category) + return AdminForumCategory( + id=category.id, + slug=category.slug, + name=category.name, + description=category.description, + post_count=0, + ) + + +@router.put("/forum/categories/{category_id}") +async def update_forum_category( + category_id: int, + data: AdminForumCategoryCreate, + admin: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """更新论坛分类""" + category = db.query(ForumCategory).filter(ForumCategory.id == category_id).first() + if not category: + raise HTTPException(status_code=404, detail="分类不存在") + category.name = data.name + category.slug = data.slug + category.description = data.description + db.commit() + return {"success": True} + + +@router.delete("/forum/categories/{category_id}") +async def delete_forum_category( + category_id: int, + admin: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """删除论坛分类""" + category = db.query(ForumCategory).filter(ForumCategory.id == category_id).first() + if not category: + raise HTTPException(status_code=404, detail="分类不存在") + # 删除该分类下的所有帖子和回复 + posts = db.query(ForumPost).filter(ForumPost.category_id == category_id).all() + for post in posts: + db.query(ForumReply).filter(ForumReply.post_id == post.id).delete() + db.query(ForumPost).filter(ForumPost.category_id == category_id).delete() + db.delete(category) + db.commit() + return {"success": True} + + +class AdminForumPost(BaseModel): + id: int + title: str + author_name: str + category_name: str + reply_count: int + created_at: str + + +@router.get("/forum/posts", response_model=List[AdminForumPost]) +async def list_forum_posts( + skip: int = 0, + limit: int = 50, + admin: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """获取所有帖子列表(管理)""" + posts = db.query(ForumPost).order_by(desc(ForumPost.created_at)).offset(skip).limit(limit).all() + result = [] + for p in posts: + author = db.query(User).filter(User.id == p.user_id).first() + category = db.query(ForumCategory).filter(ForumCategory.id == p.category_id).first() + reply_count = db.query(func.count(ForumReply.id)).filter( + ForumReply.post_id == p.id + ).scalar() or 0 + result.append(AdminForumPost( + id=p.id, + title=p.title, + author_name=author.username if author else "未知", + category_name=category.name if category else "未知", + reply_count=reply_count, + created_at=p.created_at.isoformat() if p.created_at else "", + )) + return result + + +@router.delete("/forum/posts/{post_id}") +async def delete_forum_post( + post_id: int, + admin: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """删除帖子""" + post = db.query(ForumPost).filter(ForumPost.id == post_id).first() + if not post: + raise HTTPException(status_code=404, detail="帖子不存在") + db.query(ForumReply).filter(ForumReply.post_id == post_id).delete() + db.delete(post) + db.commit() + return {"success": True} + + +# ===== 知识库管理 ===== + +class AdminKnowledgeBase(BaseModel): + id: int + name: str + description: Optional[str] + owner_name: str + is_system: bool + document_count: int + chunk_count: int + created_at: str + + +@router.get("/knowledge-bases", response_model=List[AdminKnowledgeBase]) +async def list_knowledge_bases( + admin: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """获取所有知识库""" + kbs = db.query(KnowledgeBase).order_by(desc(KnowledgeBase.id)).all() + result = [] + for kb in kbs: + owner = db.query(User).filter(User.id == kb.user_id).first() + doc_count = db.query(func.count(Document.id)).filter( + Document.knowledge_base_id == kb.id + ).scalar() or 0 + chunk_count = db.query(func.count(DocumentChunk.id)).join( + Document + ).filter(Document.knowledge_base_id == kb.id).scalar() or 0 + result.append(AdminKnowledgeBase( + id=kb.id, + name=kb.name, + description=kb.description, + owner_name=owner.username if owner else "系统", + is_system=kb.is_system, + document_count=doc_count, + chunk_count=chunk_count, + created_at=kb.created_at.isoformat() if kb.created_at else "", + )) + return result + + +@router.delete("/knowledge-bases/{kb_id}") +async def delete_knowledge_base( + kb_id: int, + admin: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """删除知识库""" + kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first() + if not kb: + raise HTTPException(status_code=404, detail="知识库不存在") + # 删除关联文档和chunks + docs = db.query(Document).filter(Document.knowledge_base_id == kb_id).all() + for doc in docs: + db.query(DocumentChunk).filter(DocumentChunk.document_id == doc.id).delete() + db.query(Document).filter(Document.knowledge_base_id == kb_id).delete() + db.delete(kb) + db.commit() + return {"success": True} + + +# ===== 系统状态 ===== + +@router.get("/system/status") +async def get_system_status( + admin: User = Depends(require_admin), + db: Session = Depends(get_db) +): + """获取系统状态""" + from ..core.config import get_settings + settings = get_settings() + + # 数据库状态 + db_ok = True + try: + db.execute(func.now()) + except Exception: + db_ok = False + + # 向量库状态 + vector_store_ok = True + vector_count = 0 + try: + from ..rag.vector_store import VectorStore + vs = VectorStore() + collection = vs.get_or_create_collection("knowledge_base") + vector_count = collection.count() + except Exception: + vector_store_ok = False + + return { + "database": {"status": "ok" if db_ok else "error"}, + "vector_store": {"status": "ok" if vector_store_ok else "error", "vector_count": vector_count}, + "llm_model": settings.siliconflow_model, + "embedding_model": "text2vec-base-chinese", + } diff --git a/backend/src/api/chat.py b/backend/src/api/chat.py index 5a3046c..f56b683 100644 --- a/backend/src/api/chat.py +++ b/backend/src/api/chat.py @@ -242,7 +242,7 @@ async def stream_message( rag_chain = create_rag_chain( knowledge_base_ids=request.knowledge_base_ids, search_type="similarity", - k=5, + k=50, model=request.model ) diff --git a/backend/src/rag/chains.py b/backend/src/rag/chains.py index 8fbd760..ae5233f 100644 --- a/backend/src/rag/chains.py +++ b/backend/src/rag/chains.py @@ -18,8 +18,8 @@ class RAGChain: self, knowledge_base_ids: Optional[List[int]] = None, search_type: str = "similarity", - k: int = 5, - score_threshold: float = 0.1, + k: int = 50, + score_threshold: float = 0.15, model: Optional[str] = None ): """初始化RAG链 @@ -58,8 +58,8 @@ class RAGChain: """创建检索链(LangChain 1.0 Runnable API)""" # 使用LangChain 1.0的runnable API构建RAG链 def format_docs(docs): - """格式化文档""" - return "\n\n".join(doc.page_content for doc in docs) + """格式化文档(使用带编号的上下文格式)""" + return self._format_docs_for_context(docs) # 构建RAG链 - 只检索一次,返回完整结果 def rag_with_sources(input_data): @@ -115,7 +115,7 @@ class RAGChain: """流式调用(只流式输出答案)""" # 先获取文档(这是唯一一次检索) docs = await self.retriever.ainvoke(question) - context = "\n\n".join(doc.page_content for doc in docs) + context = self._format_docs_for_context(docs) # 构建prompt prompt_value = await self.prompt.ainvoke({"context": context, "question": question}) @@ -143,7 +143,7 @@ class RAGChain: # 发送检索结果(只在有文档时) if docs: doc_details = [] - for i, doc in enumerate(docs[:5]): + for i, doc in enumerate(docs[:10]): metadata = doc.metadata if hasattr(doc, 'metadata') else {} title = metadata.get("title", metadata.get("filename", f"文档 {i+1}")) preview = doc.page_content[:100].replace('\n', ' ') @@ -158,7 +158,7 @@ class RAGChain: "details": doc_details } - context = "\n\n".join(doc.page_content for doc in docs) + context = self._format_docs_for_context(docs) # 2. 构建prompt并流式生成 prompt_value = await self.prompt.ainvoke({"context": context, "question": question}) @@ -190,20 +190,31 @@ class RAGChain: def _format_sources(self, documents: List) -> List[Dict]: """格式化来源信息""" sources = [] - for doc in documents: + for i, doc in enumerate(documents, start=1): metadata = doc.metadata if hasattr(doc, 'metadata') else {} sources.append({ + "id": i, "title": metadata.get("title", "未知标题"), "filename": metadata.get("filename", "未知文件"), "page": metadata.get("chunk_index", 0), - "preview": doc.page_content[:200] + "..." if len(doc.page_content) > 200 else doc.page_content + "score": metadata.get("score"), + "preview": doc.page_content }) return sources + @staticmethod + def _format_docs_for_context(docs) -> str: + """将文档列表格式化为带编号的LLM上下文字符串""" + parts = [] + for i, doc in enumerate(docs, start=1): + source_title = doc.metadata.get("title", doc.metadata.get("filename", "未知")) + parts.append(f"[来源{i}](来源:{source_title})\n{doc.page_content}") + return "\n\n".join(parts) + def create_rag_chain( knowledge_base_ids: Optional[List[int]] = None, search_type: str = "similarity", - k: int = 5, + k: int = 50, model: Optional[str] = None ) -> RAGChain: """创建RAG链实例 diff --git a/backend/src/rag/prompts.py b/backend/src/rag/prompts.py index 5f57bae..d51bc9e 100644 --- a/backend/src/rag/prompts.py +++ b/backend/src/rag/prompts.py @@ -7,15 +7,18 @@ from langchain_core.messages import SystemMessage, HumanMessage # RAG系统提示词 RAG_SYSTEM_PROMPT = """你是一个专业的国土空间规划知识问答助手。请基于以下上下文信息回答用户的问题。 -上下文信息: +上下文信息(每个来源已标注编号,如 [来源1]、[来源2]): {context} 要求: -1. 回答要准确、专业、详细 -2. 如果上下文中没有相关信息,请诚实说明 -3. 回答要结构清晰,逻辑性强 -4. 适当引用相关概念和术语 -5. 回答长度控制在500-1000字之间 +1. 综合评估上下文信息的相关性,优先采用与问题最相关的内容,不强行引用不相关的来源 +2. 回答中必须使用 [来源N] 格式(N为上下文中的来源编号)标注所引用的具体来源,例如"根据[来源3],国土空间规划..." +3. 回答要准确、专业、详细,结构清晰,逻辑性强 +4. 如果上下文中没有相关信息,请诚实说明 +5. 在回答末尾,列出所有实际引用的参考来源,格式为: + **参考来源:** + - [来源N] 文档标题 +6. 回答长度控制在500-1000字之间 请基于上述上下文信息回答用户的问题。""" diff --git a/backend/src/rag/retrievers.py b/backend/src/rag/retrievers.py index ec06b3b..7d0b3ba 100644 --- a/backend/src/rag/retrievers.py +++ b/backend/src/rag/retrievers.py @@ -14,7 +14,7 @@ class KnowledgeBaseRetriever(BaseRetriever): knowledge_base_ids: Optional[List[int]] = None search_type: str = "similarity" search_kwargs: dict = {"k": 5} - score_threshold: float = 0.1 + score_threshold: float = 0.15 def _get_relevant_documents( self, diff --git a/web/package.json b/web/package.json index 49e91a5..e8c9115 100644 --- a/web/package.json +++ b/web/package.json @@ -57,6 +57,7 @@ "react-markdown": "^10.1.0", "react-syntax-highlighter": "^15.6.1", "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "sonner": "^2.0.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 19d207f..28c6c06 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -131,6 +131,9 @@ importers: rehype-katex: specifier: ^7.0.1 version: 7.0.1 + rehype-raw: + specifier: ^7.0.0 + version: 7.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -1260,6 +1263,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -2099,9 +2103,15 @@ packages: hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-to-text@4.0.2: resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} @@ -2127,6 +2137,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3000,6 +3013,9 @@ packages: rehype-katex@7.0.1: resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -5450,6 +5466,22 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -5470,6 +5502,16 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-to-text@4.0.2: dependencies: '@types/hast': 3.0.4 @@ -5505,6 +5547,8 @@ snapshots: html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -6552,6 +6596,12 @@ snapshots: unist-util-visit-parents: 6.0.1 vfile: 6.0.3 + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 diff --git a/web/src/app/(main)/admin/course/page.tsx b/web/src/app/(main)/admin/course/page.tsx new file mode 100644 index 0000000..4b121db --- /dev/null +++ b/web/src/app/(main)/admin/course/page.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { BookOpen } from "lucide-react"; +import { courseContentAPI } from "@/lib/api"; + +interface Subsection { + id: number; + subsection_number: number; + title: string; + display_order: number; +} + +interface Section { + id: number; + section_number: number; + title: string; + display_order: number; + subsections: Subsection[]; +} + +interface Chapter { + id: number; + chapter_number: number; + title: string; + display_order: number; + sections: Section[]; +} + +export default function CourseAdminPage() { + const [chapters, setChapters] = useState([]); + const [expanded, setExpanded] = useState>(new Set()); + const [loading, setLoading] = useState(true); + + useEffect(() => { + courseContentAPI.getCourseContent() + .then((data) => setChapters(data.chapters || [])) + .catch(console.error) + .finally(() => setLoading(false)); + }, []); + + const toggle = (key: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + if (loading) { + return
加载中...
; + } + + const totalSections = chapters.reduce((a, c) => a + c.sections.length, 0); + const totalSubsections = chapters.reduce( + (a, c) => a + c.sections.reduce((b, s) => b + s.subsections.length, 0), + 0 + ); + + return ( +
+
+ +

课程内容管理

+
+ +
+
+
{chapters.length}
+
+
+
+
{totalSections}
+
+
+
+
{totalSubsections}
+
知识点
+
+
+ + {chapters.length === 0 ? ( +
+ 暂无课程内容数据 +
+ ) : ( +
+ {chapters.map((chapter) => { + const chKey = `ch-${chapter.id}`; + return ( +
+ + + {expanded.has(chKey) && ( +
+ {chapter.sections.map((section) => { + const secKey = `sec-${section.id}`; + return ( +
+ + + {expanded.has(secKey) && section.subsections.length > 0 && ( +
+ {section.subsections.map((sub) => ( +
+ {sub.subsection_number} + {sub.title} +
+ ))} +
+ )} +
+ ); + })} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/web/src/app/(main)/admin/forum/page.tsx b/web/src/app/(main)/admin/forum/page.tsx new file mode 100644 index 0000000..55fb47c --- /dev/null +++ b/web/src/app/(main)/admin/forum/page.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { adminAPI } from "@/lib/api"; +import { format } from "date-fns"; +import { MessageSquare, Plus, Trash2, RefreshCw, Pencil } from "lucide-react"; + +interface AdminCategory { + id: number; + slug: string; + name: string; + description: string | null; + post_count: number; +} + +interface AdminPost { + id: number; + title: string; + author_name: string; + category_name: string; + reply_count: number; + created_at: string; +} + +export default function ForumAdminPage() { + const [tab, setTab] = useState<"categories" | "posts">("categories"); + const [categories, setCategories] = useState([]); + const [posts, setPosts] = useState([]); + const [loading, setLoading] = useState(true); + const [confirmDelete, setConfirmDelete] = useState<{ type: "cat" | "post"; id: number } | null>(null); + + // Category form + const [showCatForm, setShowCatForm] = useState(false); + const [editingCat, setEditingCat] = useState(null); + const [catForm, setCatForm] = useState({ name: "", slug: "", description: "" }); + + const loadCategories = () => { + adminAPI.listForumCategories() + .then(setCategories) + .catch(console.error); + }; + + const loadPosts = () => { + adminAPI.listForumPosts() + .then(setPosts) + .catch(console.error); + }; + + useEffect(() => { + setLoading(true); + Promise.all([loadCategories(), loadPosts()]).finally(() => setLoading(false)); + }, []); + + const handleSaveCategory = async () => { + try { + if (editingCat) { + await adminAPI.updateForumCategory(editingCat.id, catForm); + } else { + await adminAPI.createForumCategory(catForm); + } + setShowCatForm(false); + setEditingCat(null); + setCatForm({ name: "", slug: "", description: "" }); + loadCategories(); + } catch (e) { console.error(e); } + }; + + const handleDeleteCategory = async (id: number) => { + try { + await adminAPI.deleteForumCategory(id); + setConfirmDelete(null); + loadCategories(); + } catch (e) { console.error(e); } + }; + + const handleDeletePost = async (id: number) => { + try { + await adminAPI.deleteForumPost(id); + setConfirmDelete(null); + loadPosts(); + } catch (e) { console.error(e); } + }; + + const startEditCat = (cat: AdminCategory) => { + setEditingCat(cat); + setCatForm({ name: cat.name, slug: cat.slug, description: cat.description || "" }); + setShowCatForm(true); + }; + + if (loading) { + return
加载中...
; + } + + return ( +
+
+
+ +

论坛管理

+
+
+ + +
+
+ + {tab === "categories" && ( +
+ {!showCatForm ? ( + + ) : ( +
+

{editingCat ? "编辑分类" : "新增分类"}

+
+
+ + setCatForm((f) => ({ ...f, name: e.target.value }))} + className="w-full mt-1 px-3 py-1.5 text-sm border rounded-lg bg-background focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+ + setCatForm((f) => ({ ...f, slug: e.target.value }))} + className="w-full mt-1 px-3 py-1.5 text-sm border rounded-lg bg-background focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+ + setCatForm((f) => ({ ...f, description: e.target.value }))} + className="w-full mt-1 px-3 py-1.5 text-sm border rounded-lg bg-background focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+
+ + +
+
+ )} + +
+ + + + + + + + + + + + {categories.map((cat) => ( + + + + + + + + ))} + +
名称Slug描述帖子数操作
{cat.name}{cat.slug}{cat.description || "—"}{cat.post_count} +
+ + {confirmDelete?.type === "cat" && confirmDelete.id === cat.id ? ( +
+ + +
+ ) : ( + + )} +
+
+
+
+ )} + + {tab === "posts" && ( +
+
+ 共 {posts.length} 个帖子 + +
+
+ + + + + + + + + + + + + {posts.length === 0 ? ( + + ) : posts.map((p) => ( + + + + + + + + + ))} + +
标题作者分类回复数时间操作
暂无帖子
{p.title}{p.author_name}{p.category_name}{p.reply_count} + {format(new Date(p.created_at), "yyyy/M/d HH:mm")} + + {confirmDelete?.type === "post" && confirmDelete.id === p.id ? ( +
+ + +
+ ) : ( + + )} +
+
+
+ )} +
+ ); +} diff --git a/web/src/app/(main)/admin/knowledge/page.tsx b/web/src/app/(main)/admin/knowledge/page.tsx new file mode 100644 index 0000000..3386e0b --- /dev/null +++ b/web/src/app/(main)/admin/knowledge/page.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { adminAPI } from "@/lib/api"; +import { format } from "date-fns"; +import { Database, Trash2, RefreshCw } from "lucide-react"; + +interface AdminKB { + id: number; + name: string; + description: string | null; + owner_name: string; + is_system: boolean; + document_count: number; + chunk_count: number; + created_at: string; +} + +export default function KnowledgeAdminPage() { + const [kbs, setKbs] = useState([]); + const [loading, setLoading] = useState(true); + const [confirmDelete, setConfirmDelete] = useState(null); + + const load = () => { + setLoading(true); + adminAPI.listKnowledgeBases() + .then(setKbs) + .catch(console.error) + .finally(() => setLoading(false)); + }; + + useEffect(() => { load(); }, []); + + const handleDelete = async (kbId: number) => { + try { + await adminAPI.deleteKnowledgeBase(kbId); + setConfirmDelete(null); + setKbs((prev) => prev.filter((kb) => kb.id !== kbId)); + } catch (e) { console.error(e); } + }; + + if (loading) { + return
加载中...
; + } + + return ( +
+
+
+ +

知识库管理

+ 共 {kbs.length} 个知识库 +
+ +
+ + {kbs.length === 0 ? ( +
+ 暂无知识库 +
+ ) : ( +
+ + + + + + + + + + + + + + + {kbs.map((kb) => ( + + + + + + + + + + + ))} + +
名称描述所有者类型文档数向量数创建时间操作
{kb.name} + {kb.description || "—"} + {kb.owner_name} + + {kb.is_system ? "系统" : "用户"} + + {kb.document_count}{kb.chunk_count} + {format(new Date(kb.created_at), "yyyy/M/d HH:mm")} + + {confirmDelete === kb.id ? ( +
+ + +
+ ) : ( + + )} +
+
+ )} +
+ ); +} diff --git a/web/src/app/(main)/admin/layout.tsx b/web/src/app/(main)/admin/layout.tsx new file mode 100644 index 0000000..206f61f --- /dev/null +++ b/web/src/app/(main)/admin/layout.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { usePathname, useRouter } from "next/navigation"; +import { useAuthStore } from "@/store/auth"; +import Link from "next/link"; +import { + LayoutDashboard, + Users, + Database, + MessageSquare, + BookOpen, + Settings, + Shield, +} from "lucide-react"; +import type { ReactNode } from "react"; + +const ADMIN_NAV = [ + { href: "/admin", label: "数据概览", icon: LayoutDashboard, exact: true }, + { href: "/admin/users", label: "用户管理", icon: Users }, + { href: "/admin/knowledge", label: "知识库管理", icon: Database }, + { href: "/admin/forum", label: "论坛管理", icon: MessageSquare }, + { href: "/admin/course", label: "课程内容", icon: BookOpen }, + { href: "/admin/settings", label: "系统设置", icon: Settings }, +]; + +export default function AdminLayout({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + const { user, isAuthenticated } = useAuthStore(); + + if (!isAuthenticated || !user?.is_superuser) { + return ( +
+
+ +

需要管理员权限

+

请联系管理员获取访问权限

+ +
+
+ ); + } + + return ( +
+ {/* Sidebar */} + + + {/* Mobile nav */} +
+
+ {ADMIN_NAV.map((item) => { + const active = item.exact + ? pathname === item.href + : pathname.startsWith(item.href); + return ( + + + {item.label} + + ); + })} +
+
+ + {/* Main content */} +
+
+ {children} +
+
+
+ ); +} diff --git a/web/src/app/(main)/admin/page.tsx b/web/src/app/(main)/admin/page.tsx new file mode 100644 index 0000000..fbebab5 --- /dev/null +++ b/web/src/app/(main)/admin/page.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { adminAPI } from "@/lib/api"; +import { Users, MessageSquare, FileText, TrendingUp, Image, Activity } from "lucide-react"; + +interface DashboardStats { + total_users: number; + active_users_7d: number; + total_sessions: number; + total_messages: number; + total_documents: number; + total_knowledge_bases: number; + total_forum_posts: number; + total_forum_replies: number; + total_generated_images: number; +} + +function TrendChart({ title, icon: Icon, data, color }: { + title: string; + icon: React.ElementType; + data: { date: string; count: number }[]; + color: string; +}) { + return ( +
+

+ {title} +

+
+ {data.length === 0 ? ( +
暂无数据
+ ) : data.map((d) => { + const max = Math.max(...data.map((x) => x.count), 1); + const h = Math.max((d.count / max) * 100, 2); + return ( +
+ {d.count || ""} +
+ {d.date.slice(5)} +
+ ); + })} +
+
+ ); +} + +export default function AdminPage() { + const [stats, setStats] = useState(null); + const [userTrends, setUserTrends] = useState<{ date: string; count: number }[]>([]); + const [msgTrends, setMsgTrends] = useState<{ date: string; count: number }[]>([]); + const [systemStatus, setSystemStatus] = useState<{ + database: { status: string }; + vector_store: { status: string; vector_count: number }; + llm_model: string; + embedding_model: string; + } | null>(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.allSettled([ + adminAPI.getDashboard(), + adminAPI.getUserTrends(14), + adminAPI.getMessageTrends(14), + adminAPI.getSystemStatus(), + ]).then(([dashboard, trendsU, trendsM, status]) => { + if (dashboard.status === "fulfilled") setStats(dashboard.value); + if (trendsU.status === "fulfilled") setUserTrends(trendsU.value); + if (trendsM.status === "fulfilled") setMsgTrends(trendsM.value); + if (status.status === "fulfilled") setSystemStatus(status.value); + setLoading(false); + }); + }, []); + + if (loading) { + return
加载中...
; + } + + const cards = [ + { label: "注册用户", value: stats?.total_users ?? 0, icon: Users, sub: `${stats?.active_users_7d ?? 0} 人近7天活跃` }, + { label: "对话会话", value: stats?.total_sessions ?? 0, icon: MessageSquare, sub: `${stats?.total_messages ?? 0} 条消息` }, + { label: "知识文档", value: stats?.total_documents ?? 0, icon: FileText, sub: `${stats?.total_knowledge_bases ?? 0} 个知识库` }, + { label: "论坛帖子", value: stats?.total_forum_posts ?? 0, icon: TrendingUp, sub: `${stats?.total_forum_replies ?? 0} 条回复` }, + { label: "生成图像", value: stats?.total_generated_images ?? 0, icon: Image, sub: "" }, + ]; + + return ( +
+
+ {cards.map((c) => ( +
+
+ {c.label} + +
+
{c.value.toLocaleString()}
+ {c.sub &&
{c.sub}
} +
+ ))} +
+ +
+ + +
+ + {systemStatus && ( +
+

+ 系统状态 +

+
+ {[ + { label: "数据库", ok: systemStatus.database.status === "ok" }, + { label: "向量库", ok: systemStatus.vector_store.status === "ok", sub: `${systemStatus.vector_store.vector_count} 向量` }, + { label: "LLM 模型", ok: true, sub: systemStatus.llm_model }, + { label: "嵌入模型", ok: true, sub: systemStatus.embedding_model }, + ].map((s) => ( +
+ + {s.label} + {s.sub && {s.sub}} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/web/src/app/(main)/admin/settings/page.tsx b/web/src/app/(main)/admin/settings/page.tsx new file mode 100644 index 0000000..2ee4a25 --- /dev/null +++ b/web/src/app/(main)/admin/settings/page.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { adminAPI } from "@/lib/api"; +import { Settings, RefreshCw } from "lucide-react"; + +interface SystemStatus { + database: { status: string }; + vector_store: { status: string; vector_count: number }; + llm_model: string; + embedding_model: string; +} + +export default function SystemSettingsPage() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + + const load = () => { + setLoading(true); + adminAPI.getSystemStatus() + .then(setStatus) + .catch(console.error) + .finally(() => setLoading(false)); + }; + + useEffect(() => { load(); }, []); + + if (loading) { + return
加载中...
; + } + + const statusItems = status ? [ + { + label: "数据库", + ok: status.database.status === "ok", + detail: status.database.status === "ok" ? "运行正常" : "连接异常", + }, + { + label: "向量存储", + ok: status.vector_store.status === "ok", + detail: `${status.vector_store.vector_count.toLocaleString()} 个向量`, + }, + { + label: "LLM 模型", + ok: true, + detail: status.llm_model, + }, + { + label: "嵌入模型", + ok: true, + detail: status.embedding_model, + }, + ] : []; + + return ( +
+
+
+ +

系统设置

+
+ +
+ + {/* System status */} +
+
+

系统状态

+
+
+ {statusItems.map((s) => ( +
+
+ + {s.label} +
+ {s.detail} +
+ ))} +
+
+ + {/* Runtime info */} +
+
+

运行信息

+
+
+
+ 前端版本 + 1.0.0 +
+
+ 框架 + Next.js 15 + FastAPI +
+
+ 部署模式 + Docker (Supervisor) +
+
+ 向量数据库 + ChromaDB +
+
+
+ + {/* Danger zone */} +
+
+

危险操作

+
+
+
+
+
重建向量索引
+
重新处理所有知识库文档并建立向量索引
+
+ +
+
+
+
清理缓存
+
清除系统缓存和临时文件
+
+ +
+
+
+
+ ); +} diff --git a/web/src/app/(main)/admin/users/page.tsx b/web/src/app/(main)/admin/users/page.tsx new file mode 100644 index 0000000..ff07b47 --- /dev/null +++ b/web/src/app/(main)/admin/users/page.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { adminAPI } from "@/lib/api"; +import { format } from "date-fns"; +import { Users, Search } from "lucide-react"; + +interface AdminUser { + id: number; + username: string; + email: string; + full_name: string | null; + is_active: boolean; + is_superuser: boolean; + created_at: string | null; + last_login: string | null; + session_count: number; + message_count: number; +} + +export default function UsersPage() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [confirmDelete, setConfirmDelete] = useState(null); + const [search, setSearch] = useState(""); + + const loadUsers = () => { + setLoading(true); + adminAPI.listUsers() + .then(setUsers) + .catch(console.error) + .finally(() => setLoading(false)); + }; + + useEffect(() => { loadUsers(); }, []); + + const handleToggleActive = async (userId: number) => { + try { + await adminAPI.toggleUserActive(userId); + setUsers((prev) => + prev.map((u) => (u.id === userId ? { ...u, is_active: !u.is_active } : u)) + ); + } catch (e) { console.error(e); } + }; + + const handleToggleAdmin = async (userId: number) => { + try { + await adminAPI.toggleUserAdmin(userId); + setUsers((prev) => + prev.map((u) => (u.id === userId ? { ...u, is_superuser: !u.is_superuser } : u)) + ); + } catch (e) { console.error(e); } + }; + + const handleDelete = async (userId: number) => { + try { + await adminAPI.deleteUser(userId); + setConfirmDelete(null); + setUsers((prev) => prev.filter((u) => u.id !== userId)); + } catch (e) { console.error(e); } + }; + + const filtered = users.filter( + (u) => + u.username.toLowerCase().includes(search.toLowerCase()) || + u.email.toLowerCase().includes(search.toLowerCase()) || + (u.full_name && u.full_name.toLowerCase().includes(search.toLowerCase())) + ); + + if (loading) { + return
加载中...
; + } + + return ( +
+
+
+ +

用户管理

+ 共 {users.length} 个用户 +
+
+ + setSearch(e.target.value)} + placeholder="搜索用户名、邮箱..." + className="pl-9 pr-3 py-1.5 text-sm border rounded-lg bg-background focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+ +
+
+ + + + + + + + + + + + + + {filtered.length === 0 && ( + + + + )} + {filtered.map((u) => ( + + + + + + + + + + ))} + +
用户邮箱状态角色对话 / 消息注册时间操作
+ {search ? "无匹配用户" : "暂无用户"} +
+
{u.username}
+ {u.full_name &&
{u.full_name}
} +
{u.email} + + + + + {u.session_count} / {u.message_count} + + {u.created_at ? format(new Date(u.created_at), "yyyy/M/d HH:mm") : "—"} + + {confirmDelete === u.id ? ( +
+ + +
+ ) : ( + + )} +
+
+
+
+ ); +} diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 132bd6a..5062436 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -378,6 +378,39 @@ button { margin: 1.5em 0; } +/* 文内引用链接样式 */ +.citation-link { + display: inline-block; + padding: 0 0.125rem; + font-size: 0.7em; + font-weight: 700; + color: hsl(var(--primary)); + background-color: hsl(var(--primary) / 0.08); + border-radius: 0.25rem; + cursor: pointer; + text-decoration: none; + vertical-align: super; + transition: background-color 0.15s ease; +} +.citation-link:hover { + background-color: hsl(var(--primary) / 0.18); + text-decoration: none; +} + +/* 来源卡片锚点滚动偏移(避免被固定header遮挡) */ +.scroll-mt-20 { + scroll-margin-top: 5rem; +} + +/* 来源卡片被引用点击时的高亮动画 */ +@keyframes citation-highlight { + 0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); } + 50% { box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.35); } +} +.citation-highlight { + animation: citation-highlight 1.2s ease-in-out 2; +} + @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; diff --git a/web/src/components/chat/message-item.tsx b/web/src/components/chat/message-item.tsx index 8200eed..949e5e7 100644 --- a/web/src/components/chat/message-item.tsx +++ b/web/src/components/chat/message-item.tsx @@ -5,6 +5,7 @@ import { User, Bot, Copy, Edit, RotateCcw, ThumbsUp, ThumbsDown, Loader2, Brain import { cn } from "@/lib/utils"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import rehypeRaw from "rehype-raw"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism"; import { format } from "date-fns"; @@ -96,6 +97,13 @@ const ThinkingProcess = ({ thinking, isStreaming }: { thinking: ThinkingStep[]; ); }; +function preprocessCitations(content: string, messageId: string): string { + return content.replace( + /\[来源\s*(\d+)\]/g, + `[$1]` + ); +} + export default function MessageItem({ message, selectedModel }: MessageItemProps) { const isUser = message.role === "user"; const isAssistant = message.role === "assistant"; @@ -190,7 +198,33 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps )}> { + if (className === 'citation-link') { + const sourceId = (props as any)['data-source-id']; + const messageId = (props as any)['data-message-id']; + const fullId = `source-${messageId}-${sourceId}`; + return ( + + ); + } + return {children}; + }, code({ node, inline, className, children, ...props }: any) { const match = /language-(\w+)/.exec(className || ""); return !inline && match ? ( @@ -220,13 +254,13 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps ), }} > - {message.content} + {preprocessCitations(message.content, String(message.id))}
{isAssistant && message.metadata?.sources && message.metadata.sources.length > 0 && (
- +
)} diff --git a/web/src/components/chat/source-references.tsx b/web/src/components/chat/source-references.tsx index 287f889..bf9eead 100644 --- a/web/src/components/chat/source-references.tsx +++ b/web/src/components/chat/source-references.tsx @@ -1,10 +1,13 @@ "use client"; -import { FileText, ExternalLink, Star, Database, Globe } from "lucide-react"; +import { useState, useMemo } from "react"; +import { Database, Globe, ChevronDown, ChevronRight, Star } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; interface SourceReference { + id?: number; title: string; filename?: string; page?: number; @@ -17,101 +20,260 @@ interface SourceReference { interface SourceReferencesProps { sources: SourceReference[]; maxSources?: number; + answerContent?: string; + messageId?: string; } -export default function SourceReferences({ sources, maxSources = 5 }: SourceReferencesProps) { - if (!sources || sources.length === 0) { - return null; +/** 从 answer 中提取长度 >= minLen 的不重叠片段,用于在来源原文中高亮匹配 */ +function findMatchedSpans( + answer: string, + sourceText: string, + minLen = 15 +): Array<{ start: number; end: number }> { + if (!answer || !sourceText) return []; + + // 从 answer 中切分出所有有意义的中文/英文片段 + const segments: string[] = []; + // 按句号、换行等断开 + const sentences = answer.split(/[。,;!?\n、:()]/); + for (const s of sentences) { + const trimmed = s.trim(); + if (trimmed.length >= minLen) { + segments.push(trimmed); + } } - const displaySources = sources.slice(0, maxSources); - const ragSources = displaySources.filter(s => s.source_type !== "web"); - const webSources = displaySources.filter(s => s.source_type === "web"); + // 在 sourceText 中查找每个片段 + const spans: Array<{ start: number; end: number }> = []; + for (const seg of segments) { + let pos = 0; + while (pos < sourceText.length) { + const idx = sourceText.indexOf(seg, pos); + if (idx === -1) break; + const end = idx + seg.length; + // 检查是否与已有 span 重叠,有则合并 + const overlapping = spans.find( + (s) => !(end <= s.start || idx >= s.end) + ); + if (overlapping) { + overlapping.start = Math.min(overlapping.start, idx); + overlapping.end = Math.max(overlapping.end, end); + } else { + spans.push({ start: idx, end }); + } + pos = end; + } + } + + return spans.sort((a, b) => a.start - b.start); +} + +/** 将匹配的 span 用 包裹 */ +function highlightText( + text: string, + spans: Array<{ start: number; end: number }> +): React.ReactNode { + if (!spans.length) return text; + + // 合并重叠/相邻的 span + const merged: Array<{ start: number; end: number }> = []; + for (const span of spans) { + const last = merged[merged.length - 1]; + if (last && span.start <= last.end + 3) { + last.end = Math.max(last.end, span.end); + } else { + merged.push({ ...span }); + } + } + + const parts: React.ReactNode[] = []; + let last = 0; + for (const span of merged) { + if (span.start > last) { + parts.push(text.slice(last, span.start)); + } + parts.push( + + {text.slice(span.start, span.end)} + + ); + last = span.end; + } + if (last < text.length) { + parts.push(text.slice(last)); + } + return <>{parts}; +} + +function SourceRow({ + source, + answerContent, + messageId, +}: { + source: SourceReference; + answerContent?: string; + messageId?: string; +}) { + const [showPreview, setShowPreview] = useState(false); + + const matchedSpans = useMemo(() => { + if (!answerContent || !source.preview) return []; + return findMatchedSpans(answerContent, source.preview); + }, [answerContent, source.preview]); + + const previewContent = useMemo(() => { + if (!matchedSpans.length) return source.preview; + return highlightText(source.preview, matchedSpans); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showPreview, matchedSpans]); return ( -
- {ragSources.length > 0 && ( -
-
- - 知识库来源 ({ragSources.length}) -
- -
- {ragSources.map((source, index) => ( - - -
- - {source.title} - - {source.score != null && source.score > 0 && source.score < 1 && ( -
- - - {(source.score * 100).toFixed(1)}% - -
- )} -
-
- {source.filename} - {source.page && ` • 第 ${source.page} 页`} -
-
- -

- {source.preview} -

-
-
- ))} -
-
- )} - - {webSources.length > 0 && ( -
-
- - 网络来源 ({webSources.length}) -
- -
- {webSources.map((source, index) => ( - - - - {source.title} - - - -

- {source.preview} -

- {source.url && ( - - )} -
-
- ))} -
-
- )} - - {sources.length > maxSources && ( -
- 还有 {sources.length - maxSources} 个相关来源 +
+ + + {showPreview && ( +
+ {previewContent}
)}
); } + +export default function SourceReferences({ + sources, + maxSources = 20, + answerContent, + messageId, +}: SourceReferencesProps) { + const [expanded, setExpanded] = useState(false); + + if (!sources || sources.length === 0) { + return null; + } + + const displaySources = sources.slice(0, maxSources); + const showExpandButton = sources.length > maxSources; + const visibleSources = expanded ? sources : displaySources; + + const ragSources = visibleSources.filter((s) => s.source_type !== "web"); + const webSources = visibleSources.filter((s) => s.source_type === "web"); + + const sourceSection = ( +
+ {ragSources.length > 0 && ( +
+
+ + + 知识库来源 ({ragSources.length} + {expanded && showExpandButton + ? `/${sources.filter((s) => s.source_type !== "web").length}` + : ""} + ) + +
+
+ {ragSources.map((source, i) => ( + + ))} +
+
+ )} + + {webSources.length > 0 && ( +
+
+ + + 网络来源 ({webSources.length} + {expanded && showExpandButton + ? `/${sources.filter((s) => s.source_type === "web").length}` + : ""} + ) + +
+
+ {webSources.map((source, i) => ( + + ))} +
+
+ )} +
+ ); + + return ( +
+ {expanded ? ( + +
{sourceSection}
+
+ ) : ( + sourceSection + )} + + {showExpandButton && ( + + )} +
+ ); +} diff --git a/web/src/components/home/navbar.tsx b/web/src/components/home/navbar.tsx index d9e6d9c..9674fe4 100644 --- a/web/src/components/home/navbar.tsx +++ b/web/src/components/home/navbar.tsx @@ -7,7 +7,7 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { ThemeToggle } from "@/components/ui/theme-toggle"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { useAuthStore } from "@/store/auth"; -import { BookOpen, LogOut, User as UserIcon, Settings, ChevronDown, MessageSquare, Database, Image, TrendingUp, GraduationCap, Users } from "lucide-react"; +import { BookOpen, LogOut, User as UserIcon, Settings, ChevronDown, MessageSquare, Database, Image, TrendingUp, GraduationCap, Users, ShieldCheck } from "lucide-react"; import { User } from "@/types"; interface NavbarProps { @@ -21,6 +21,7 @@ const NAV_LINKS = [ { href: "/knowledge", label: "知识库", icon: Database }, { href: "/spatial", label: "空间设计", icon: Image }, { href: "/forum", label: "课程社区", icon: Users }, + { href: "/admin", label: "后台管理", icon: ShieldCheck, adminOnly: true }, ]; export default function Navbar({ isAuthenticated, user }: NavbarProps) { @@ -54,7 +55,7 @@ export default function Navbar({ isAuthenticated, user }: NavbarProps) { {/* 导航链接 — 带底部下划线指示 */} {isAuthenticated && (
- {NAV_LINKS.map((item) => { + {NAV_LINKS.filter((item) => !item.adminOnly || user?.is_superuser).map((item) => { const active = isActive(item.href); return ( + {user.is_superuser && ( + + + + 后台管理 + + + )} diff --git a/web/src/components/layout/mobile-nav.tsx b/web/src/components/layout/mobile-nav.tsx index 3b79477..8d2b04a 100644 --- a/web/src/components/layout/mobile-nav.tsx +++ b/web/src/components/layout/mobile-nav.tsx @@ -15,7 +15,8 @@ import { LogOut, GraduationCap, TrendingUp, - Users + Users, + ShieldCheck } from "lucide-react"; const navItems = [ @@ -131,6 +132,16 @@ export default function MobileNav() { {/* 底部操作 */}
+ {user?.is_superuser && ( + + )}