feat: RAG inline citations, source highlighting, and admin panel

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 20:51:28 +08:00
parent 5d42a0573a
commit abcced35b8
23 changed files with 2249 additions and 112 deletions
+4
View File
@@ -185,6 +185,10 @@ from src.api import course_content, forum
app.include_router(course_content.router) app.include_router(course_content.router)
app.include_router(forum.router) app.include_router(forum.router)
# 导入并注册后台管理API
from src.api import admin
app.include_router(admin.router)
# 静态文件服务 # 静态文件服务
if os.path.exists(settings.upload_dir): if os.path.exists(settings.upload_dir):
app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads") app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads")
+511
View File
@@ -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",
}
+1 -1
View File
@@ -242,7 +242,7 @@ async def stream_message(
rag_chain = create_rag_chain( rag_chain = create_rag_chain(
knowledge_base_ids=request.knowledge_base_ids, knowledge_base_ids=request.knowledge_base_ids,
search_type="similarity", search_type="similarity",
k=5, k=50,
model=request.model model=request.model
) )
+21 -10
View File
@@ -18,8 +18,8 @@ class RAGChain:
self, self,
knowledge_base_ids: Optional[List[int]] = None, knowledge_base_ids: Optional[List[int]] = None,
search_type: str = "similarity", search_type: str = "similarity",
k: int = 5, k: int = 50,
score_threshold: float = 0.1, score_threshold: float = 0.15,
model: Optional[str] = None model: Optional[str] = None
): ):
"""初始化RAG链 """初始化RAG链
@@ -58,8 +58,8 @@ class RAGChain:
"""创建检索链(LangChain 1.0 Runnable API""" """创建检索链(LangChain 1.0 Runnable API"""
# 使用LangChain 1.0的runnable API构建RAG链 # 使用LangChain 1.0的runnable API构建RAG链
def format_docs(docs): def format_docs(docs):
"""格式化文档""" """格式化文档(使用带编号的上下文格式)"""
return "\n\n".join(doc.page_content for doc in docs) return self._format_docs_for_context(docs)
# 构建RAG链 - 只检索一次,返回完整结果 # 构建RAG链 - 只检索一次,返回完整结果
def rag_with_sources(input_data): def rag_with_sources(input_data):
@@ -115,7 +115,7 @@ class RAGChain:
"""流式调用(只流式输出答案)""" """流式调用(只流式输出答案)"""
# 先获取文档(这是唯一一次检索) # 先获取文档(这是唯一一次检索)
docs = await self.retriever.ainvoke(question) 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
prompt_value = await self.prompt.ainvoke({"context": context, "question": question}) prompt_value = await self.prompt.ainvoke({"context": context, "question": question})
@@ -143,7 +143,7 @@ class RAGChain:
# 发送检索结果(只在有文档时) # 发送检索结果(只在有文档时)
if docs: if docs:
doc_details = [] doc_details = []
for i, doc in enumerate(docs[:5]): for i, doc in enumerate(docs[:10]):
metadata = doc.metadata if hasattr(doc, 'metadata') else {} metadata = doc.metadata if hasattr(doc, 'metadata') else {}
title = metadata.get("title", metadata.get("filename", f"文档 {i+1}")) title = metadata.get("title", metadata.get("filename", f"文档 {i+1}"))
preview = doc.page_content[:100].replace('\n', ' ') preview = doc.page_content[:100].replace('\n', ' ')
@@ -158,7 +158,7 @@ class RAGChain:
"details": doc_details "details": doc_details
} }
context = "\n\n".join(doc.page_content for doc in docs) context = self._format_docs_for_context(docs)
# 2. 构建prompt并流式生成 # 2. 构建prompt并流式生成
prompt_value = await self.prompt.ainvoke({"context": context, "question": question}) prompt_value = await self.prompt.ainvoke({"context": context, "question": question})
@@ -190,20 +190,31 @@ class RAGChain:
def _format_sources(self, documents: List) -> List[Dict]: def _format_sources(self, documents: List) -> List[Dict]:
"""格式化来源信息""" """格式化来源信息"""
sources = [] sources = []
for doc in documents: for i, doc in enumerate(documents, start=1):
metadata = doc.metadata if hasattr(doc, 'metadata') else {} metadata = doc.metadata if hasattr(doc, 'metadata') else {}
sources.append({ sources.append({
"id": i,
"title": metadata.get("title", "未知标题"), "title": metadata.get("title", "未知标题"),
"filename": metadata.get("filename", "未知文件"), "filename": metadata.get("filename", "未知文件"),
"page": metadata.get("chunk_index", 0), "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 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( def create_rag_chain(
knowledge_base_ids: Optional[List[int]] = None, knowledge_base_ids: Optional[List[int]] = None,
search_type: str = "similarity", search_type: str = "similarity",
k: int = 5, k: int = 50,
model: Optional[str] = None model: Optional[str] = None
) -> RAGChain: ) -> RAGChain:
"""创建RAG链实例 """创建RAG链实例
+9 -6
View File
@@ -7,15 +7,18 @@ from langchain_core.messages import SystemMessage, HumanMessage
# RAG系统提示词 # RAG系统提示词
RAG_SYSTEM_PROMPT = """你是一个专业的国土空间规划知识问答助手。请基于以下上下文信息回答用户的问题。 RAG_SYSTEM_PROMPT = """你是一个专业的国土空间规划知识问答助手。请基于以下上下文信息回答用户的问题。
上下文信息: 上下文信息(每个来源已标注编号,如 [来源1]、[来源2])
{context} {context}
要求: 要求:
1. 回答要准确、专业、详细 1. 综合评估上下文信息的相关性,优先采用与问题最相关的内容,不强行引用不相关的来源
2. 如果上下文中没有相关信息,请诚实说明 2. 回答中必须使用 [来源N] 格式(N为上下文中的来源编号)标注所引用的具体来源,例如"根据[来源3],国土空间规划..."
3. 回答要结构清晰,逻辑性强 3. 回答要准确、专业、详细,结构清晰,逻辑性强
4. 适当引用相关概念和术语 4. 如果上下文中没有相关信息,请诚实说明
5. 回答长度控制在500-1000字之间 5. 回答末尾,列出所有实际引用的参考来源,格式为:
**参考来源:**
- [来源N] 文档标题
6. 回答长度控制在500-1000字之间
请基于上述上下文信息回答用户的问题。""" 请基于上述上下文信息回答用户的问题。"""
+1 -1
View File
@@ -14,7 +14,7 @@ class KnowledgeBaseRetriever(BaseRetriever):
knowledge_base_ids: Optional[List[int]] = None knowledge_base_ids: Optional[List[int]] = None
search_type: str = "similarity" search_type: str = "similarity"
search_kwargs: dict = {"k": 5} search_kwargs: dict = {"k": 5}
score_threshold: float = 0.1 score_threshold: float = 0.15
def _get_relevant_documents( def _get_relevant_documents(
self, self,
+1
View File
@@ -57,6 +57,7 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"sonner": "^2.0.3", "sonner": "^2.0.3",
+50
View File
@@ -131,6 +131,9 @@ importers:
rehype-katex: rehype-katex:
specifier: ^7.0.1 specifier: ^7.0.1
version: 7.0.1 version: 7.0.1
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
remark-gfm: remark-gfm:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
@@ -1260,6 +1263,7 @@ packages:
'@ungap/structured-clone@1.3.0': '@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} 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': '@unrs/resolver-binding-android-arm-eabi@1.11.1':
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
@@ -2099,9 +2103,15 @@ packages:
hast-util-parse-selector@4.0.0: hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
hast-util-raw@9.1.0:
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
hast-util-to-jsx-runtime@2.3.6: hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} 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: hast-util-to-text@4.0.2:
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
@@ -2127,6 +2137,9 @@ packages:
html-url-attributes@3.0.1: html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@@ -3000,6 +3013,9 @@ packages:
rehype-katex@7.0.1: rehype-katex@7.0.1:
resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==}
rehype-raw@7.0.0:
resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==}
remark-gfm@4.0.1: remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -5450,6 +5466,22 @@ snapshots:
dependencies: dependencies:
'@types/hast': 3.0.4 '@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: hast-util-to-jsx-runtime@2.3.6:
dependencies: dependencies:
'@types/estree': 1.0.8 '@types/estree': 1.0.8
@@ -5470,6 +5502,16 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: hast-util-to-text@4.0.2:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
@@ -5505,6 +5547,8 @@ snapshots:
html-url-attributes@3.0.1: {} html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
ignore@5.3.2: {} ignore@5.3.2: {}
ignore@7.0.5: {} ignore@7.0.5: {}
@@ -6552,6 +6596,12 @@ snapshots:
unist-util-visit-parents: 6.0.1 unist-util-visit-parents: 6.0.1
vfile: 6.0.3 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: remark-gfm@4.0.1:
dependencies: dependencies:
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
+166
View File
@@ -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<Chapter[]>([]);
const [expanded, setExpanded] = useState<Set<string>>(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 <div className="text-muted-foreground text-sm py-12 text-center">...</div>;
}
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 (
<div className="space-y-4">
<div className="flex items-center gap-3">
<BookOpen className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold"></h2>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="rounded-xl border p-4 text-center">
<div className="text-2xl font-semibold font-mono tabular-nums">{chapters.length}</div>
<div className="text-xs text-muted-foreground mt-1"></div>
</div>
<div className="rounded-xl border p-4 text-center">
<div className="text-2xl font-semibold font-mono tabular-nums">{totalSections}</div>
<div className="text-xs text-muted-foreground mt-1"></div>
</div>
<div className="rounded-xl border p-4 text-center">
<div className="text-2xl font-semibold font-mono tabular-nums">{totalSubsections}</div>
<div className="text-xs text-muted-foreground mt-1"></div>
</div>
</div>
{chapters.length === 0 ? (
<div className="rounded-xl border py-12 text-center text-muted-foreground text-sm">
</div>
) : (
<div className="space-y-2">
{chapters.map((chapter) => {
const chKey = `ch-${chapter.id}`;
return (
<div key={chapter.id} className="rounded-xl border overflow-hidden">
<button
onClick={() => toggle(chKey)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors text-left"
>
<div className="flex items-center gap-3">
<span className="text-xs font-mono text-muted-foreground w-8">
{chapter.chapter_number}
</span>
<span className="font-medium text-sm">{chapter.title}</span>
<span className="text-xs text-muted-foreground">
{chapter.sections.length}
</span>
</div>
<svg
className={`w-4 h-4 text-muted-foreground transition-transform ${expanded.has(chKey) ? "rotate-90" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{expanded.has(chKey) && (
<div className="border-t">
{chapter.sections.map((section) => {
const secKey = `sec-${section.id}`;
return (
<div key={section.id} className="border-b last:border-0">
<button
onClick={() => toggle(secKey)}
className="w-full flex items-center justify-between px-4 py-2.5 pl-10 hover:bg-muted/20 transition-colors text-left"
>
<div className="flex items-center gap-3">
<span className="text-xs font-mono text-muted-foreground w-8">
{section.section_number}
</span>
<span className="text-sm">{section.title}</span>
<span className="text-xs text-muted-foreground">
{section.subsections.length}
</span>
</div>
<svg
className={`w-3.5 h-3.5 text-muted-foreground transition-transform ${expanded.has(secKey) ? "rotate-90" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{expanded.has(secKey) && section.subsections.length > 0 && (
<div className="border-t bg-muted/10">
{section.subsections.map((sub) => (
<div
key={sub.id}
className="flex items-center gap-3 px-4 py-2 pl-16 text-sm text-muted-foreground"
>
<span className="text-xs font-mono w-8">{sub.subsection_number}</span>
<span>{sub.title}</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
+268
View File
@@ -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<AdminCategory[]>([]);
const [posts, setPosts] = useState<AdminPost[]>([]);
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<AdminCategory | null>(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 <div className="text-muted-foreground text-sm py-12 text-center">...</div>;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<MessageSquare className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold"></h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setTab("categories")}
className={`px-3 py-1 text-sm rounded-lg transition-colors ${
tab === "categories" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"
}`}
>
</button>
<button
onClick={() => setTab("posts")}
className={`px-3 py-1 text-sm rounded-lg transition-colors ${
tab === "posts" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"
}`}
>
</button>
</div>
</div>
{tab === "categories" && (
<div className="space-y-4">
{!showCatForm ? (
<button
onClick={() => { setEditingCat(null); setCatForm({ name: "", slug: "", description: "" }); setShowCatForm(true); }}
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
>
<Plus className="w-4 h-4" />
</button>
) : (
<div className="rounded-xl border p-4 space-y-3">
<h3 className="text-sm font-medium">{editingCat ? "编辑分类" : "新增分类"}</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={catForm.name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Slug</label>
<input
value={catForm.slug}
onChange={(e) => 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"
/>
</div>
<div className="col-span-2">
<label className="text-xs text-muted-foreground"></label>
<input
value={catForm.description}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex gap-2">
<button onClick={handleSaveCategory} className="px-4 py-1.5 text-sm bg-primary text-primary-foreground rounded-lg hover:opacity-90">
</button>
<button onClick={() => { setShowCatForm(false); setEditingCat(null); }} className="px-4 py-1.5 text-sm border rounded-lg hover:bg-muted">
</button>
</div>
</div>
)}
<div className="rounded-xl border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium">Slug</th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{categories.map((cat) => (
<tr key={cat.id} className="border-b last:border-0 hover:bg-muted/20">
<td className="px-4 py-3 font-medium">{cat.name}</td>
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">{cat.slug}</td>
<td className="px-4 py-3 text-muted-foreground text-xs max-w-40 truncate">{cat.description || "—"}</td>
<td className="px-4 py-3 text-center font-mono tabular-nums">{cat.post_count}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-2">
<button onClick={() => startEditCat(cat)} className="text-muted-foreground hover:text-foreground">
<Pencil className="w-3.5 h-3.5" />
</button>
{confirmDelete?.type === "cat" && confirmDelete.id === cat.id ? (
<div className="flex items-center gap-1">
<button onClick={() => handleDeleteCategory(cat.id)} className="text-xs px-1.5 py-0.5 bg-red-600 text-white rounded"></button>
<button onClick={() => setConfirmDelete(null)} className="text-xs px-1.5 py-0.5 border rounded"></button>
</div>
) : (
<button onClick={() => setConfirmDelete({ type: "cat", id: cat.id })} className="text-red-500 hover:text-red-700">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{tab === "posts" && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"> {posts.length} </span>
<button onClick={loadPosts} className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
<div className="rounded-xl border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{posts.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground"></td></tr>
) : posts.map((p) => (
<tr key={p.id} className="border-b last:border-0 hover:bg-muted/20">
<td className="px-4 py-3 font-medium max-w-64 truncate">{p.title}</td>
<td className="px-4 py-3 text-muted-foreground">{p.author_name}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{p.category_name}</td>
<td className="px-4 py-3 text-center font-mono tabular-nums">{p.reply_count}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">
{format(new Date(p.created_at), "yyyy/M/d HH:mm")}
</td>
<td className="px-4 py-3 text-center">
{confirmDelete?.type === "post" && confirmDelete.id === p.id ? (
<div className="flex items-center justify-center gap-1">
<button onClick={() => handleDeletePost(p.id)} className="text-xs px-2 py-1 bg-red-600 text-white rounded"></button>
<button onClick={() => setConfirmDelete(null)} className="text-xs px-2 py-1 border rounded"></button>
</div>
) : (
<button onClick={() => setConfirmDelete({ type: "post", id: p.id })} className="text-red-500 hover:text-red-700">
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
+139
View File
@@ -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<AdminKB[]>([]);
const [loading, setLoading] = useState(true);
const [confirmDelete, setConfirmDelete] = useState<number | null>(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 <div className="text-muted-foreground text-sm py-12 text-center">...</div>;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Database className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold"></h2>
<span className="text-sm text-muted-foreground"> {kbs.length} </span>
</div>
<button
onClick={load}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{kbs.length === 0 ? (
<div className="rounded-xl border py-12 text-center text-muted-foreground text-sm">
</div>
) : (
<div className="rounded-xl border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{kbs.map((kb) => (
<tr key={kb.id} className="border-b last:border-0 hover:bg-muted/20">
<td className="px-4 py-3 font-medium">{kb.name}</td>
<td className="px-4 py-3 text-muted-foreground text-xs max-w-48 truncate">
{kb.description || "—"}
</td>
<td className="px-4 py-3 text-center text-muted-foreground">{kb.owner_name}</td>
<td className="px-4 py-3 text-center">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
kb.is_system
? "bg-blue-100 text-blue-700"
: "bg-slate-100 text-slate-600"
}`}
>
{kb.is_system ? "系统" : "用户"}
</span>
</td>
<td className="px-4 py-3 text-center font-mono tabular-nums">{kb.document_count}</td>
<td className="px-4 py-3 text-center font-mono tabular-nums">{kb.chunk_count}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">
{format(new Date(kb.created_at), "yyyy/M/d HH:mm")}
</td>
<td className="px-4 py-3 text-center">
{confirmDelete === kb.id ? (
<div className="flex items-center justify-center gap-1">
<button
onClick={() => handleDelete(kb.id)}
className="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700"
>
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-xs px-2 py-1 border rounded hover:bg-accent"
>
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(kb.id)}
className="text-red-500 hover:text-red-700 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+115
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-4">
<Shield className="w-16 h-16 mx-auto text-muted-foreground" />
<h2 className="text-xl font-semibold text-foreground"></h2>
<p className="text-sm text-muted-foreground">访</p>
<button
onClick={() => router.push("/")}
className="text-primary hover:underline text-sm"
>
</button>
</div>
</div>
);
}
return (
<div className="flex min-h-[calc(100vh-4rem)]">
{/* Sidebar */}
<aside className="w-56 shrink-0 border-r bg-background hidden md:block">
<div className="p-4 border-b">
<h2 className="text-sm font-semibold text-foreground flex items-center gap-2">
<Shield className="w-4 h-4 text-primary" />
</h2>
</div>
<nav className="p-2 space-y-0.5">
{ADMIN_NAV.map((item) => {
const active = item.exact
? pathname === item.href
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`
flex items-center gap-2.5 px-3 py-2 text-sm rounded-lg transition-colors
${active
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}
`}
>
<item.icon className="w-4 h-4" />
{item.label}
</Link>
);
})}
</nav>
</aside>
{/* Mobile nav */}
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-background border-t z-50">
<div className="flex items-center justify-around py-2">
{ADMIN_NAV.map((item) => {
const active = item.exact
? pathname === item.href
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center gap-0.5 px-2 py-1 text-[10px] ${
active ? "text-primary" : "text-muted-foreground"
}`}
>
<item.icon className="w-4 h-4" />
<span>{item.label}</span>
</Link>
);
})}
</div>
</div>
{/* Main content */}
<main className="flex-1 overflow-auto pb-16 md:pb-0">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{children}
</div>
</main>
</div>
);
}
+131
View File
@@ -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 (
<div className="rounded-xl border p-5 space-y-3">
<h3 className="text-sm font-medium flex items-center gap-2">
<Icon className="w-4 h-4" />{title}
</h3>
<div className="h-40 flex items-end gap-1">
{data.length === 0 ? (
<div className="w-full text-center text-xs text-muted-foreground py-8"></div>
) : data.map((d) => {
const max = Math.max(...data.map((x) => x.count), 1);
const h = Math.max((d.count / max) * 100, 2);
return (
<div key={d.date} className="flex-1 flex flex-col items-center gap-1">
<span className="text-[9px] text-muted-foreground tabular-nums">{d.count || ""}</span>
<div className="w-full rounded-t" style={{ height: `${h}%`, backgroundColor: color, opacity: 0.35 }} title={`${d.date}: ${d.count}`} />
<span className="text-[9px] text-muted-foreground">{d.date.slice(5)}</span>
</div>
);
})}
</div>
</div>
);
}
export default function AdminPage() {
const [stats, setStats] = useState<DashboardStats | null>(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 <div className="py-12 text-center text-muted-foreground text-sm">...</div>;
}
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 (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
{cards.map((c) => (
<div key={c.label} className="rounded-xl border p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{c.label}</span>
<c.icon className="w-4 h-4 text-muted-foreground" />
</div>
<div className="text-2xl font-semibold font-mono tabular-nums">{c.value.toLocaleString()}</div>
{c.sub && <div className="text-[11px] text-muted-foreground">{c.sub}</div>}
</div>
))}
</div>
<div className="grid md:grid-cols-2 gap-6">
<TrendChart title="近14天用户注册" icon={Users} data={userTrends} color="#2563eb" />
<TrendChart title="近14天消息数" icon={MessageSquare} data={msgTrends} color="#16a34a" />
</div>
{systemStatus && (
<div className="rounded-xl border p-5 space-y-3">
<h3 className="text-sm font-medium flex items-center gap-2">
<Activity className="w-4 h-4" />
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ 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) => (
<div key={s.label} className="flex items-center gap-2 text-sm">
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: s.ok ? "#22c55e" : "#ef4444" }} />
<span>{s.label}</span>
{s.sub && <span className="text-muted-foreground text-xs truncate">{s.sub}</span>}
</div>
))}
</div>
</div>
)}
</div>
);
}
+151
View File
@@ -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<SystemStatus | null>(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 <div className="text-muted-foreground text-sm py-12 text-center">...</div>;
}
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Settings className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold"></h2>
</div>
<button
onClick={load}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{/* System status */}
<div className="rounded-xl border">
<div className="px-5 py-3 border-b">
<h3 className="text-sm font-medium"></h3>
</div>
<div className="divide-y">
{statusItems.map((s) => (
<div key={s.label} className="flex items-center justify-between px-5 py-3">
<div className="flex items-center gap-3">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: s.ok ? "#22c55e" : "#ef4444" }}
/>
<span className="text-sm">{s.label}</span>
</div>
<span className="text-sm text-muted-foreground">{s.detail}</span>
</div>
))}
</div>
</div>
{/* Runtime info */}
<div className="rounded-xl border">
<div className="px-5 py-3 border-b">
<h3 className="text-sm font-medium"></h3>
</div>
<div className="divide-y">
<div className="flex items-center justify-between px-5 py-3">
<span className="text-sm"></span>
<span className="text-sm text-muted-foreground font-mono">1.0.0</span>
</div>
<div className="flex items-center justify-between px-5 py-3">
<span className="text-sm"></span>
<span className="text-sm text-muted-foreground">Next.js 15 + FastAPI</span>
</div>
<div className="flex items-center justify-between px-5 py-3">
<span className="text-sm"></span>
<span className="text-sm text-muted-foreground">Docker (Supervisor)</span>
</div>
<div className="flex items-center justify-between px-5 py-3">
<span className="text-sm"></span>
<span className="text-sm text-muted-foreground">ChromaDB</span>
</div>
</div>
</div>
{/* Danger zone */}
<div className="rounded-xl border border-red-200">
<div className="px-5 py-3 border-b border-red-200">
<h3 className="text-sm font-medium text-red-600"></h3>
</div>
<div className="px-5 py-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm"></div>
<div className="text-xs text-muted-foreground"></div>
</div>
<button
className="px-3 py-1.5 text-sm border border-red-200 text-red-600 rounded-lg hover:bg-red-50 transition-colors"
onClick={() => alert("此功能需要通过后端命令行执行")}
>
</button>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm"></div>
<div className="text-xs text-muted-foreground"></div>
</div>
<button
className="px-3 py-1.5 text-sm border border-red-200 text-red-600 rounded-lg hover:bg-red-50 transition-colors"
onClick={() => alert("此功能需要通过后端命令行执行")}
>
</button>
</div>
</div>
</div>
</div>
);
}
+185
View File
@@ -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<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [confirmDelete, setConfirmDelete] = useState<number | null>(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 <div className="text-muted-foreground text-sm py-12 text-center">...</div>;
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Users className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold"></h2>
<span className="text-sm text-muted-foreground"> {users.length} </span>
</div>
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
value={search}
onChange={(e) => 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"
/>
</div>
</div>
<div className="rounded-xl border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"> / </th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
{search ? "无匹配用户" : "暂无用户"}
</td>
</tr>
)}
{filtered.map((u) => (
<tr key={u.id} className="border-b last:border-0 hover:bg-muted/20">
<td className="px-4 py-3">
<div className="font-medium">{u.username}</div>
{u.full_name && <div className="text-xs text-muted-foreground">{u.full_name}</div>}
</td>
<td className="px-4 py-3 text-muted-foreground">{u.email}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleToggleActive(u.id)}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
u.is_active
? "bg-emerald-100 text-emerald-700 hover:bg-emerald-200"
: "bg-red-100 text-red-700 hover:bg-red-200"
}`}
>
{u.is_active ? "正常" : "禁用"}
</button>
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleToggleAdmin(u.id)}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium transition-colors ${
u.is_superuser
? "bg-amber-100 text-amber-700 hover:bg-amber-200"
: "bg-slate-100 text-slate-600 hover:bg-slate-200"
}`}
>
{u.is_superuser ? "管理员" : "用户"}
</button>
</td>
<td className="px-4 py-3 text-center font-mono tabular-nums text-muted-foreground">
{u.session_count} / {u.message_count}
</td>
<td className="px-4 py-3 text-muted-foreground text-xs">
{u.created_at ? format(new Date(u.created_at), "yyyy/M/d HH:mm") : "—"}
</td>
<td className="px-4 py-3 text-center">
{confirmDelete === u.id ? (
<div className="flex items-center justify-center gap-1">
<button
onClick={() => handleDelete(u.id)}
className="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700"
>
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-xs px-2 py-1 border rounded hover:bg-accent"
>
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(u.id)}
className="text-xs text-red-500 hover:text-red-700 transition-colors"
>
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
+33
View File
@@ -378,6 +378,39 @@ button {
margin: 1.5em 0; 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) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { *, *::before, *::after {
animation-duration: 0.01ms !important; animation-duration: 0.01ms !important;
+36 -2
View File
@@ -5,6 +5,7 @@ import { User, Bot, Copy, Edit, RotateCcw, ThumbsUp, ThumbsDown, Loader2, Brain
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
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 { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism"; import { tomorrow } from "react-syntax-highlighter/dist/esm/styles/prism";
import { format } from "date-fns"; 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,
`<a class="citation-link" href="#source-${messageId}-$1" data-message-id="${messageId}" data-source-id="$1">[$1]</a>`
);
}
export default function MessageItem({ message, selectedModel }: MessageItemProps) { export default function MessageItem({ message, selectedModel }: MessageItemProps) {
const isUser = message.role === "user"; const isUser = message.role === "user";
const isAssistant = message.role === "assistant"; const isAssistant = message.role === "assistant";
@@ -190,7 +198,33 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
)}> )}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{ components={{
a: ({ href, children, className, ...props }: any) => {
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 (
<button
className="citation-link"
onClick={(e) => {
e.preventDefault();
const target = document.getElementById(fullId);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('citation-highlight');
setTimeout(() => target.classList.remove('citation-highlight'), 2500);
}
}}
title={`跳转到来源 ${sourceId}`}
>
[{sourceId}]
</button>
);
}
return <a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline" {...props}>{children}</a>;
},
code({ node, inline, className, children, ...props }: any) { code({ node, inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || ""); const match = /language-(\w+)/.exec(className || "");
return !inline && match ? ( return !inline && match ? (
@@ -220,13 +254,13 @@ export default function MessageItem({ message, selectedModel }: MessageItemProps
), ),
}} }}
> >
{message.content} {preprocessCitations(message.content, String(message.id))}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
{isAssistant && message.metadata?.sources && message.metadata.sources.length > 0 && ( {isAssistant && message.metadata?.sources && message.metadata.sources.length > 0 && (
<div className="mt-2.5 pt-2.5 border-t border-border/30"> <div className="mt-2.5 pt-2.5 border-t border-border/30">
<SourceReferences sources={message.metadata.sources} maxSources={3} /> <SourceReferences sources={message.metadata.sources} answerContent={message.content} messageId={String(message.id)} />
</div> </div>
)} )}
</div> </div>
+236 -74
View File
@@ -1,10 +1,13 @@
"use client"; "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 { 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 { interface SourceReference {
id?: number;
title: string; title: string;
filename?: string; filename?: string;
page?: number; page?: number;
@@ -17,100 +20,259 @@ interface SourceReference {
interface SourceReferencesProps { interface SourceReferencesProps {
sources: SourceReference[]; sources: SourceReference[];
maxSources?: number; maxSources?: number;
answerContent?: string;
messageId?: string;
} }
export default function SourceReferences({ sources, maxSources = 5 }: SourceReferencesProps) { /** 从 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);
}
}
// 在 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 用 <mark> 包裹 */
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(
<mark key={span.start} className="bg-yellow-200/70 dark:bg-yellow-500/30 rounded-sm px-0.5">
{text.slice(span.start, span.end)}
</mark>
);
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 (
<div
id={source.id != null && messageId ? `source-${messageId}-${source.id}` : undefined}
className="scroll-mt-20"
>
<button
className={cn(
"w-full flex items-center gap-1.5 px-2 py-1 -mx-2 rounded text-left",
"hover:bg-muted/60 transition-colors group text-xs"
)}
onClick={() => setShowPreview(!showPreview)}
>
<ChevronRight
className={cn(
"h-3 w-3 flex-shrink-0 text-muted-foreground/60 transition-transform",
showPreview && "rotate-90"
)}
/>
{source.id != null && (
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] rounded-full bg-primary/10 text-primary text-[10px] font-bold flex-shrink-0">
{source.id}
</span>
)}
<span className="font-medium truncate">{source.title}</span>
{source.filename && (
<span className="text-muted-foreground/70 truncate hidden sm:inline">
{source.filename.replace(/\.(pdf|docx?|txt|md)$/i, "")}
</span>
)}
{source.score != null && source.score > 0 && source.score < 1 && (
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground flex-shrink-0 ml-auto">
<Star className="h-2.5 w-2.5 text-yellow-500" />
{(source.score * 100).toFixed(0)}%
</span>
)}
</button>
{showPreview && (
<div className="ml-7 pl-3 pr-2 py-1.5 mb-0.5 border-l-2 border-primary/20 bg-muted/30 rounded-r text-xs text-muted-foreground leading-relaxed max-h-48 overflow-y-auto">
{previewContent}
</div>
)}
</div>
);
}
export default function SourceReferences({
sources,
maxSources = 20,
answerContent,
messageId,
}: SourceReferencesProps) {
const [expanded, setExpanded] = useState(false);
if (!sources || sources.length === 0) { if (!sources || sources.length === 0) {
return null; return null;
} }
const displaySources = sources.slice(0, maxSources); const displaySources = sources.slice(0, maxSources);
const ragSources = displaySources.filter(s => s.source_type !== "web"); const showExpandButton = sources.length > maxSources;
const webSources = displaySources.filter(s => s.source_type === "web"); const visibleSources = expanded ? sources : displaySources;
return ( const ragSources = visibleSources.filter((s) => s.source_type !== "web");
<div className="mt-4 space-y-3"> const webSources = visibleSources.filter((s) => s.source_type === "web");
const sourceSection = (
<div className="space-y-1">
{ragSources.length > 0 && ( {ragSources.length > 0 && (
<div className="space-y-2"> <div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-blue-600"> <div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground px-0.5">
<Database className="h-4 w-4" /> <Database className="h-3 w-3 text-blue-500" />
<span> ({ragSources.length})</span> <span>
({ragSources.length}
{expanded && showExpandButton
? `/${sources.filter((s) => s.source_type !== "web").length}`
: ""}
)
</span>
</div> </div>
<div className="divide-y divide-border/30">
<div className="space-y-2"> {ragSources.map((source, i) => (
{ragSources.map((source, index) => ( <SourceRow
<Card key={index} className="border border-blue-200 bg-blue-50/30 hover:border-blue-300 transition-colors"> key={i}
<CardHeader className="pb-2"> source={source}
<div className="flex items-start justify-between"> answerContent={answerContent}
<CardTitle className="text-sm font-medium line-clamp-2"> messageId={messageId}
{source.title} />
</CardTitle>
{source.score != null && source.score > 0 && source.score < 1 && (
<div className="flex items-center gap-1 ml-2">
<Star className="h-3 w-3 text-yellow-500" />
<span className="text-xs text-gray-500">
{(source.score * 100).toFixed(1)}%
</span>
</div>
)}
</div>
<div className="text-xs text-gray-500">
{source.filename}
{source.page && ` • 第 ${source.page}`}
</div>
</CardHeader>
<CardContent className="pt-0">
<p className="text-xs text-gray-600 line-clamp-2">
{source.preview}
</p>
</CardContent>
</Card>
))} ))}
</div> </div>
</div> </div>
)} )}
{webSources.length > 0 && ( {webSources.length > 0 && (
<div className="space-y-2"> <div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-green-600"> <div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground px-0.5">
<Globe className="h-4 w-4" /> <Globe className="h-3 w-3 text-green-500" />
<span> ({webSources.length})</span> <span>
({webSources.length}
{expanded && showExpandButton
? `/${sources.filter((s) => s.source_type === "web").length}`
: ""}
)
</span>
</div> </div>
<div className="divide-y divide-border/30">
<div className="space-y-2"> {webSources.map((source, i) => (
{webSources.map((source, index) => ( <SourceRow
<Card key={index} className="border border-green-200 bg-green-50/30 hover:border-green-300 transition-colors"> key={i}
<CardHeader className="pb-2"> source={source}
<CardTitle className="text-sm font-medium line-clamp-2"> answerContent={answerContent}
{source.title} messageId={messageId}
</CardTitle> />
</CardHeader>
<CardContent className="pt-0">
<p className="text-xs text-gray-600 line-clamp-2 mb-2">
{source.preview}
</p>
{source.url && (
<Button
size="sm"
variant="outline"
className="h-6 text-xs"
onClick={() => window.open(source.url, '_blank')}
>
<ExternalLink className="h-3 w-3 mr-1" />
</Button>
)}
</CardContent>
</Card>
))} ))}
</div> </div>
</div> </div>
)} )}
</div>
);
{sources.length > maxSources && ( return (
<div className="text-xs text-gray-500 text-center"> <div className="mt-3 space-y-2">
{sources.length - maxSources} {expanded ? (
</div> <ScrollArea className="max-h-96">
<div className="pr-3">{sourceSection}</div>
</ScrollArea>
) : (
sourceSection
)}
{showExpandButton && (
<Button
variant="ghost"
size="sm"
className="w-full h-7 text-[11px] text-muted-foreground"
onClick={() => setExpanded(!expanded)}
>
{expanded
? `收起(共 ${sources.length} 个)`
: `展开全部 ${sources.length} 个来源`}
<ChevronDown
className={cn(
"ml-1 h-3 w-3 transition-transform",
expanded && "rotate-180"
)}
/>
</Button>
)} )}
</div> </div>
); );
+11 -2
View File
@@ -7,7 +7,7 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { ThemeToggle } from "@/components/ui/theme-toggle"; import { ThemeToggle } from "@/components/ui/theme-toggle";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { useAuthStore } from "@/store/auth"; 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"; import { User } from "@/types";
interface NavbarProps { interface NavbarProps {
@@ -21,6 +21,7 @@ const NAV_LINKS = [
{ href: "/knowledge", label: "知识库", icon: Database }, { href: "/knowledge", label: "知识库", icon: Database },
{ href: "/spatial", label: "空间设计", icon: Image }, { href: "/spatial", label: "空间设计", icon: Image },
{ href: "/forum", label: "课程社区", icon: Users }, { href: "/forum", label: "课程社区", icon: Users },
{ href: "/admin", label: "后台管理", icon: ShieldCheck, adminOnly: true },
]; ];
export default function Navbar({ isAuthenticated, user }: NavbarProps) { export default function Navbar({ isAuthenticated, user }: NavbarProps) {
@@ -54,7 +55,7 @@ export default function Navbar({ isAuthenticated, user }: NavbarProps) {
{/* 导航链接 — 带底部下划线指示 */} {/* 导航链接 — 带底部下划线指示 */}
{isAuthenticated && ( {isAuthenticated && (
<div className="hidden md:flex items-center h-full -mb-px"> <div className="hidden md:flex items-center h-full -mb-px">
{NAV_LINKS.map((item) => { {NAV_LINKS.filter((item) => !item.adminOnly || user?.is_superuser).map((item) => {
const active = isActive(item.href); const active = isActive(item.href);
return ( return (
<Link <Link
@@ -148,6 +149,14 @@ export default function Navbar({ isAuthenticated, user }: NavbarProps) {
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
{user.is_superuser && (
<DropdownMenuItem asChild>
<Link href="/admin" className="flex items-center cursor-pointer">
<ShieldCheck className="w-4 h-4 mr-2" />
</Link>
</DropdownMenuItem>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={logout} className="text-destructive cursor-pointer"> <DropdownMenuItem onClick={logout} className="text-destructive cursor-pointer">
<LogOut className="w-4 h-4 mr-2" /> <LogOut className="w-4 h-4 mr-2" />
+12 -1
View File
@@ -15,7 +15,8 @@ import {
LogOut, LogOut,
GraduationCap, GraduationCap,
TrendingUp, TrendingUp,
Users Users,
ShieldCheck
} from "lucide-react"; } from "lucide-react";
const navItems = [ const navItems = [
@@ -131,6 +132,16 @@ export default function MobileNav() {
{/* 底部操作 */} {/* 底部操作 */}
<div className="p-6 border-t border-gray-200 space-y-2"> <div className="p-6 border-t border-gray-200 space-y-2">
{user?.is_superuser && (
<Button
variant="ghost"
onClick={() => handleNavClick("/admin")}
className="w-full justify-start"
>
<ShieldCheck className="w-5 h-5 mr-3" />
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
onClick={() => handleNavClick("/analytics")} onClick={() => handleNavClick("/analytics")}
+129
View File
@@ -583,6 +583,9 @@ export const chatAPI = {
} else if (data.type === "chunk") { } else if (data.type === "chunk") {
console.log("[DEBUG-STREAM] 接收chunk:", data.content); console.log("[DEBUG-STREAM] 接收chunk:", data.content);
onChunk?.(data.content); onChunk?.(data.content);
} else if (data.type === "sources") {
console.log("[DEBUG-STREAM] 收到sources:", data.sources?.length, "个来源");
onChunk?.(JSON.stringify(data));
} else if (data.type === "done") { } else if (data.type === "done") {
console.log("[DEBUG-STREAM] 流式完成, session_id:", data.session_id, "message_id:", data.message_id, "user_message_id:", data.user_message_id); console.log("[DEBUG-STREAM] 流式完成, session_id:", data.session_id, "message_id:", data.message_id, "user_message_id:", data.user_message_id);
onComplete?.(data.session_id, data.message_id, data.user_message_id); onComplete?.(data.session_id, data.message_id, data.user_message_id);
@@ -1067,3 +1070,129 @@ export const bookAPI = {
return `${API_BASE_URL}/books/${bookId}/file`; return `${API_BASE_URL}/books/${bookId}/file`;
}, },
}; };
// ===== 后台管理 API =====
export const adminAPI = {
// 仪表盘
async getDashboard() {
return apiRequest<{
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;
}>("/admin/dashboard");
},
async getUserTrends(days = 30) {
return apiRequest<{ date: string; count: number }[]>(`/admin/trends/users?days=${days}`);
},
async getMessageTrends(days = 30) {
return apiRequest<{ date: string; count: number }[]>(`/admin/trends/messages?days=${days}`);
},
// 用户管理
async listUsers(skip = 0, limit = 50) {
return apiRequest<{
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;
}[]>(`/admin/users?skip=${skip}&limit=${limit}`);
},
async toggleUserActive(userId: number) {
return apiRequest<{ success: boolean; is_active: boolean }>(`/admin/users/${userId}/toggle-active`, { method: "PUT" });
},
async toggleUserAdmin(userId: number) {
return apiRequest<{ success: boolean; is_superuser: boolean }>(`/admin/users/${userId}/toggle-admin`, { method: "PUT" });
},
async deleteUser(userId: number) {
return apiRequest<{ success: boolean }>(`/admin/users/${userId}`, { method: "DELETE" });
},
// 论坛管理
async listForumCategories() {
return apiRequest<{
id: number;
slug: string;
name: string;
description: string | null;
post_count: number;
}[]>("/admin/forum/categories");
},
async createForumCategory(data: { name: string; slug: string; description?: string }) {
return apiRequest("/admin/forum/categories", {
method: "POST",
body: JSON.stringify(data),
});
},
async updateForumCategory(categoryId: number, data: { name: string; slug: string; description?: string }) {
return apiRequest(`/admin/forum/categories/${categoryId}`, {
method: "PUT",
body: JSON.stringify(data),
});
},
async deleteForumCategory(categoryId: number) {
return apiRequest<{ success: boolean }>(`/admin/forum/categories/${categoryId}`, { method: "DELETE" });
},
async listForumPosts(skip = 0, limit = 50) {
return apiRequest<{
id: number;
title: string;
author_name: string;
category_name: string;
reply_count: number;
created_at: string;
}[]>(`/admin/forum/posts?skip=${skip}&limit=${limit}`);
},
async deleteForumPost(postId: number) {
return apiRequest<{ success: boolean }>(`/admin/forum/posts/${postId}`, { method: "DELETE" });
},
// 知识库管理
async listKnowledgeBases() {
return apiRequest<{
id: number;
name: string;
description: string | null;
owner_name: string;
is_system: boolean;
document_count: number;
chunk_count: number;
created_at: string;
}[]>("/admin/knowledge-bases");
},
async deleteKnowledgeBase(kbId: number) {
return apiRequest<{ success: boolean }>(`/admin/knowledge-bases/${kbId}`, { method: "DELETE" });
},
// 系统状态
async getSystemStatus() {
return apiRequest<{
database: { status: string };
vector_store: { status: string; vector_count: number };
llm_model: string;
embedding_model: string;
}>("/admin/system/status");
},
};
+23
View File
@@ -309,6 +309,18 @@ export const useChatStore = create<ChatStore>((set, get) => ({
thinkingBuffer.push(step); thinkingBuffer.push(step);
scheduleFlush(); scheduleFlush();
return; return;
} else if (data.type === 'sources') {
// 更新助手消息的元数据(sources)
set((state) => ({
messages: state.messages.map(msg => {
if (msg.id !== assistantMessage.id) return msg;
return {
...msg,
metadata: { ...(msg.metadata || {}), sources: data.sources },
};
}),
}));
return;
} else if (data.type === 'chunk') { } else if (data.type === 'chunk') {
chunkCount++; chunkCount++;
totalChars += data.content.length; totalChars += data.content.length;
@@ -500,6 +512,17 @@ export const useChatStore = create<ChatStore>((set, get) => ({
regenThinkingBuffer.push(step); regenThinkingBuffer.push(step);
regenSchedule(); regenSchedule();
return; return;
} else if (data.type === 'sources') {
set((state) => ({
messages: state.messages.map(msg => {
if (msg.id !== assistantPlaceholder.id) return msg;
return {
...msg,
metadata: { ...(msg.metadata || {}), sources: data.sources },
};
}),
}));
return;
} else if (data.type === 'chunk') { } else if (data.type === 'chunk') {
regenContentBuffer += data.content; regenContentBuffer += data.content;
regenSchedule(); regenSchedule();
+1
View File
@@ -94,6 +94,7 @@ export interface ChatMessage {
} }
export interface SourceInfo { export interface SourceInfo {
id?: number;
title: string; title: string;
filename?: string; filename?: string;
page?: number; page?: number;