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:
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -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
@@ -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链实例
|
||||||
|
|||||||
@@ -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字之间
|
||||||
|
|
||||||
请基于上述上下文信息回答用户的问题。"""
|
请基于上述上下文信息回答用户的问题。"""
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+50
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,101 +20,260 @@ 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 的不重叠片段,用于在来源原文中高亮匹配 */
|
||||||
if (!sources || sources.length === 0) {
|
function findMatchedSpans(
|
||||||
return null;
|
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);
|
// 在 sourceText 中查找每个片段
|
||||||
const ragSources = displaySources.filter(s => s.source_type !== "web");
|
const spans: Array<{ start: number; end: number }> = [];
|
||||||
const webSources = displaySources.filter(s => s.source_type === "web");
|
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 (
|
return (
|
||||||
<div className="mt-4 space-y-3">
|
<div
|
||||||
{ragSources.length > 0 && (
|
id={source.id != null && messageId ? `source-${messageId}-${source.id}` : undefined}
|
||||||
<div className="space-y-2">
|
className="scroll-mt-20"
|
||||||
<div className="flex items-center gap-2 text-sm text-blue-600">
|
>
|
||||||
<Database className="h-4 w-4" />
|
<button
|
||||||
<span>知识库来源 ({ragSources.length})</span>
|
className={cn(
|
||||||
</div>
|
"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"
|
||||||
<div className="space-y-2">
|
)}
|
||||||
{ragSources.map((source, index) => (
|
onClick={() => setShowPreview(!showPreview)}
|
||||||
<Card key={index} className="border border-blue-200 bg-blue-50/30 hover:border-blue-300 transition-colors">
|
>
|
||||||
<CardHeader className="pb-2">
|
<ChevronRight
|
||||||
<div className="flex items-start justify-between">
|
className={cn(
|
||||||
<CardTitle className="text-sm font-medium line-clamp-2">
|
"h-3 w-3 flex-shrink-0 text-muted-foreground/60 transition-transform",
|
||||||
{source.title}
|
showPreview && "rotate-90"
|
||||||
</CardTitle>
|
)}
|
||||||
{source.score != null && source.score > 0 && source.score < 1 && (
|
/>
|
||||||
<div className="flex items-center gap-1 ml-2">
|
{source.id != null && (
|
||||||
<Star className="h-3 w-3 text-yellow-500" />
|
<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">
|
||||||
<span className="text-xs text-gray-500">
|
{source.id}
|
||||||
{(source.score * 100).toFixed(1)}%
|
</span>
|
||||||
</span>
|
)}
|
||||||
</div>
|
<span className="font-medium truncate">{source.title}</span>
|
||||||
)}
|
{source.filename && (
|
||||||
</div>
|
<span className="text-muted-foreground/70 truncate hidden sm:inline">
|
||||||
<div className="text-xs text-gray-500">
|
{source.filename.replace(/\.(pdf|docx?|txt|md)$/i, "")}
|
||||||
{source.filename}
|
</span>
|
||||||
{source.page && ` • 第 ${source.page} 页`}
|
)}
|
||||||
</div>
|
{source.score != null && source.score > 0 && source.score < 1 && (
|
||||||
</CardHeader>
|
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground flex-shrink-0 ml-auto">
|
||||||
<CardContent className="pt-0">
|
<Star className="h-2.5 w-2.5 text-yellow-500" />
|
||||||
<p className="text-xs text-gray-600 line-clamp-2">
|
{(source.score * 100).toFixed(0)}%
|
||||||
{source.preview}
|
</span>
|
||||||
</p>
|
)}
|
||||||
</CardContent>
|
</button>
|
||||||
</Card>
|
|
||||||
))}
|
{showPreview && (
|
||||||
</div>
|
<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">
|
||||||
</div>
|
{previewContent}
|
||||||
)}
|
|
||||||
|
|
||||||
{webSources.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
|
||||||
<Globe className="h-4 w-4" />
|
|
||||||
<span>网络来源 ({webSources.length})</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{webSources.map((source, index) => (
|
|
||||||
<Card key={index} className="border border-green-200 bg-green-50/30 hover:border-green-300 transition-colors">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium line-clamp-2">
|
|
||||||
{source.title}
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sources.length > maxSources && (
|
|
||||||
<div className="text-xs text-gray-500 text-center">
|
|
||||||
还有 {sources.length - maxSources} 个相关来源
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{ragSources.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground px-0.5">
|
||||||
|
<Database className="h-3 w-3 text-blue-500" />
|
||||||
|
<span>
|
||||||
|
知识库来源 ({ragSources.length}
|
||||||
|
{expanded && showExpandButton
|
||||||
|
? `/${sources.filter((s) => s.source_type !== "web").length}`
|
||||||
|
: ""}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border/30">
|
||||||
|
{ragSources.map((source, i) => (
|
||||||
|
<SourceRow
|
||||||
|
key={i}
|
||||||
|
source={source}
|
||||||
|
answerContent={answerContent}
|
||||||
|
messageId={messageId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{webSources.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground px-0.5">
|
||||||
|
<Globe className="h-3 w-3 text-green-500" />
|
||||||
|
<span>
|
||||||
|
网络来源 ({webSources.length}
|
||||||
|
{expanded && showExpandButton
|
||||||
|
? `/${sources.filter((s) => s.source_type === "web").length}`
|
||||||
|
: ""}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border/30">
|
||||||
|
{webSources.map((source, i) => (
|
||||||
|
<SourceRow
|
||||||
|
key={i}
|
||||||
|
source={source}
|
||||||
|
answerContent={answerContent}
|
||||||
|
messageId={messageId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{expanded ? (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user