feat:[FL-165][给系统开发一个操作文档管理和展示功能]
- 创建后端数据库模型:DocumentChapter 和 Document,支持按章节组织的树形文档结构 - 创建数据库迁移文件:002_add_document_management.sql - 创建 Pydantic schemas:定义文档和章节的请求/响应模型 - 创建后端服务层:document_service.py 实现 CRUD 和树形结构构建 - 创建 API 路由:/api/v1/documents 和 /api/v1/documents/chapters,支持完整的 RESTful 操作 - 创建前端类型定义:document.ts - 创建文档管理页面:/admin/documents,包含章节树形目录和文档表格,支持增删改查 - 创建文档展示页面:/admin/docs-view,左侧目录树右侧内容展示,支持 Markdown 渲染 - 安装 react-markdown 依赖用于文档内容展示 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -6,6 +6,7 @@ from .v1.ai_chat import router as ai_chat_router
|
||||
from .v1.atp_assets import router as atp_assets_router
|
||||
from .v1.atp_models import router as atp_models_router
|
||||
from .v1.auth import router as auth_router
|
||||
from .v1.documents import router as documents_router
|
||||
from .v1.elevation import router as elevation_router
|
||||
from .v1.fault_recurrence import router as fault_recurrence_router
|
||||
from .v1.fl_analysis import router as fl_analysis_router
|
||||
@@ -30,6 +31,7 @@ v1_router.include_router(admin_files_router)
|
||||
v1_router.include_router(ai_chat_router)
|
||||
v1_router.include_router(atp_assets_router)
|
||||
v1_router.include_router(atp_models_router)
|
||||
v1_router.include_router(documents_router)
|
||||
v1_router.include_router(task_monitor_router)
|
||||
v1_router.include_router(scheduled_tasks_router)
|
||||
v1_router.include_router(system_messages_router)
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.dependencies import (
|
||||
CurrentUser,
|
||||
get_current_user,
|
||||
require_enabled_menu_route,
|
||||
)
|
||||
from ...schemas.document import (
|
||||
DocumentChapterCreateRequest,
|
||||
DocumentChapterListResponse,
|
||||
DocumentChapterPublic,
|
||||
DocumentChapterTreeItem,
|
||||
DocumentChapterUpdateRequest,
|
||||
DocumentCreateRequest,
|
||||
DocumentListResponse,
|
||||
DocumentPublic,
|
||||
DocumentUpdateRequest,
|
||||
)
|
||||
from ...services.document_service import (
|
||||
create_document,
|
||||
create_document_chapter,
|
||||
delete_document,
|
||||
delete_document_chapter,
|
||||
get_document_by_id,
|
||||
get_document_chapter_by_id,
|
||||
get_document_chapter_tree,
|
||||
list_document_chapters,
|
||||
list_documents,
|
||||
serialize_document,
|
||||
serialize_document_chapter,
|
||||
update_document,
|
||||
update_document_chapter,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/documents", tags=["documents"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/chapters",
|
||||
response_model=DocumentChapterListResponse,
|
||||
dependencies=[Depends(require_enabled_menu_route)],
|
||||
)
|
||||
def get_document_chapters(
|
||||
keyword: str | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DocumentChapterListResponse:
|
||||
return list_document_chapters(db, keyword=keyword, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/chapters/tree",
|
||||
response_model=list[DocumentChapterTreeItem],
|
||||
dependencies=[Depends(require_enabled_menu_route)],
|
||||
)
|
||||
def get_chapters_tree(
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[DocumentChapterTreeItem]:
|
||||
return get_document_chapter_tree(db)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/chapters/{chapter_id}",
|
||||
response_model=DocumentChapterPublic,
|
||||
dependencies=[Depends(require_enabled_menu_route)],
|
||||
)
|
||||
def get_chapter_detail(
|
||||
chapter_id: int,
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DocumentChapterPublic:
|
||||
chapter = get_document_chapter_by_id(db, chapter_id)
|
||||
if not chapter:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="章节不存在",
|
||||
)
|
||||
return serialize_document_chapter(chapter)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/chapters",
|
||||
response_model=DocumentChapterPublic,
|
||||
dependencies=[Depends(require_enabled_menu_route)],
|
||||
)
|
||||
def create_chapter(
|
||||
payload: DocumentChapterCreateRequest,
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DocumentChapterPublic:
|
||||
chapter = create_document_chapter(db, payload)
|
||||
return serialize_document_chapter(chapter)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/chapters/{chapter_id}",
|
||||
response_model=DocumentChapterPublic,
|
||||
dependencies=[Depends(require_enabled_menu_route)],
|
||||
)
|
||||
def update_chapter(
|
||||
chapter_id: int,
|
||||
payload: DocumentChapterUpdateRequest,
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DocumentChapterPublic:
|
||||
chapter = update_document_chapter(db, chapter_id, payload)
|
||||
if not chapter:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="章节不存在",
|
||||
)
|
||||
return serialize_document_chapter(chapter)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/chapters/{chapter_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_enabled_menu_route)],
|
||||
)
|
||||
def delete_chapter(
|
||||
chapter_id: int,
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
success = delete_document_chapter(db, chapter_id)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="章节不存在",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=DocumentListResponse,
|
||||
dependencies=[Depends(require_enabled_menu_route)],
|
||||
)
|
||||
def get_documents(
|
||||
keyword: str | None = Query(default=None),
|
||||
chapter_id: int | None = Query(default=None),
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DocumentListResponse:
|
||||
return list_documents(
|
||||
db,
|
||||
keyword=keyword,
|
||||
chapter_id=chapter_id,
|
||||
status=status_filter,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{document_id}",
|
||||
response_model=DocumentPublic,
|
||||
dependencies=[Depends(require_enabled_menu_route)],
|
||||
)
|
||||
def get_document_detail(
|
||||
document_id: int,
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DocumentPublic:
|
||||
document = get_document_by_id(db, document_id)
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="文档不存在",
|
||||
)
|
||||
return serialize_document(document)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=DocumentPublic,
|
||||
dependencies=[Depends(require_enabled_menu_route)],
|
||||
)
|
||||
def create_document_endpoint(
|
||||
payload: DocumentCreateRequest,
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DocumentPublic:
|
||||
document = create_document(db, payload)
|
||||
return serialize_document(document)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{document_id}",
|
||||
response_model=DocumentPublic,
|
||||
dependencies=[Depends(require_enabled_menu_route)],
|
||||
)
|
||||
def update_document_endpoint(
|
||||
document_id: int,
|
||||
payload: DocumentUpdateRequest,
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DocumentPublic:
|
||||
document = update_document(db, document_id, payload)
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="文档不存在",
|
||||
)
|
||||
return serialize_document(document)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{document_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_enabled_menu_route)],
|
||||
)
|
||||
def delete_document_endpoint(
|
||||
document_id: int,
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
success = delete_document(db, document_id)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="文档不存在",
|
||||
)
|
||||
@@ -4,7 +4,7 @@ Import all model modules during package initialization so SQLAlchemy can
|
||||
resolve string-based relationships regardless of route/service import order.
|
||||
"""
|
||||
|
||||
from . import ai_chat, atp_asset, atp_model, audit_log, auth_session, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry
|
||||
from . import ai_chat, atp_asset, atp_model, audit_log, auth_session, document, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_message, system_param, tower_model, tower_profile, user, wine, worker_registry
|
||||
|
||||
__all__ = [
|
||||
"ai_chat",
|
||||
@@ -12,6 +12,7 @@ __all__ = [
|
||||
"atp_model",
|
||||
"audit_log",
|
||||
"auth_session",
|
||||
"document",
|
||||
"elevation",
|
||||
"file_storage",
|
||||
"fl_analysis",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, Text, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class DocumentChapter(Base):
|
||||
__tablename__ = "document_chapters"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(128), index=True)
|
||||
description: Mapped[str | None] = mapped_column(String(512))
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("document_chapters.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[DateTime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
parent: Mapped[DocumentChapter | None] = relationship(
|
||||
"DocumentChapter",
|
||||
remote_side=lambda: [DocumentChapter.id],
|
||||
back_populates="children",
|
||||
lazy="selectin",
|
||||
)
|
||||
children: Mapped[list[DocumentChapter]] = relationship(
|
||||
"DocumentChapter",
|
||||
back_populates="parent",
|
||||
lazy="selectin",
|
||||
order_by="DocumentChapter.sort_order",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
documents: Mapped[list[Document]] = relationship(
|
||||
"Document",
|
||||
back_populates="chapter",
|
||||
lazy="selectin",
|
||||
order_by="Document.sort_order",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class Document(Base):
|
||||
__tablename__ = "documents"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(256), index=True)
|
||||
content: Mapped[str] = mapped_column(Text)
|
||||
chapter_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("document_chapters.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), default="draft", index=True
|
||||
)
|
||||
created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[DateTime] = mapped_column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
chapter: Mapped[DocumentChapter | None] = relationship(
|
||||
"DocumentChapter",
|
||||
back_populates="documents",
|
||||
lazy="selectin",
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DocumentChapterPublic(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
parent_id: int | None = None
|
||||
sort_order: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class DocumentChapterTreeItem(DocumentChapterPublic):
|
||||
children: list["DocumentChapterTreeItem"] = Field(default_factory=list)
|
||||
documents: list["DocumentPublic"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DocumentChapterListResponse(BaseModel):
|
||||
items: list[DocumentChapterPublic]
|
||||
total: int
|
||||
|
||||
|
||||
class DocumentChapterCreateRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
description: str | None = Field(default=None, max_length=512)
|
||||
parent_id: int | None = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class DocumentChapterUpdateRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
description: str | None = Field(default=None, max_length=512)
|
||||
parent_id: int | None = None
|
||||
sort_order: int | None = None
|
||||
|
||||
|
||||
class DocumentPublic(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
chapter_id: int | None = None
|
||||
sort_order: int
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class DocumentListResponse(BaseModel):
|
||||
items: list[DocumentPublic]
|
||||
total: int
|
||||
|
||||
|
||||
class DocumentCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=256)
|
||||
content: str
|
||||
chapter_id: int | None = None
|
||||
sort_order: int = 0
|
||||
status: Literal["draft", "published"] = "draft"
|
||||
|
||||
|
||||
class DocumentUpdateRequest(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=256)
|
||||
content: str | None = None
|
||||
chapter_id: int | None = None
|
||||
sort_order: int | None = None
|
||||
status: Literal["draft", "published"] | None = None
|
||||
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models.document import Document, DocumentChapter
|
||||
from ..schemas.document import (
|
||||
DocumentChapterCreateRequest,
|
||||
DocumentChapterListResponse,
|
||||
DocumentChapterPublic,
|
||||
DocumentChapterTreeItem,
|
||||
DocumentChapterUpdateRequest,
|
||||
DocumentCreateRequest,
|
||||
DocumentListResponse,
|
||||
DocumentPublic,
|
||||
DocumentUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
def serialize_document_chapter(chapter: DocumentChapter) -> DocumentChapterPublic:
|
||||
return DocumentChapterPublic(
|
||||
id=chapter.id,
|
||||
name=chapter.name,
|
||||
description=chapter.description,
|
||||
parent_id=chapter.parent_id,
|
||||
sort_order=chapter.sort_order,
|
||||
created_at=chapter.created_at,
|
||||
updated_at=chapter.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def serialize_document(document: Document) -> DocumentPublic:
|
||||
return DocumentPublic(
|
||||
id=document.id,
|
||||
title=document.title,
|
||||
content=document.content,
|
||||
chapter_id=document.chapter_id,
|
||||
sort_order=document.sort_order,
|
||||
status=document.status,
|
||||
created_at=document.created_at,
|
||||
updated_at=document.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def build_chapter_tree(chapters: list[DocumentChapter]) -> list[DocumentChapterTreeItem]:
|
||||
chapter_map = {c.id: c for c in chapters}
|
||||
tree = []
|
||||
|
||||
for chapter in chapters:
|
||||
if chapter.parent_id is None:
|
||||
tree_item = DocumentChapterTreeItem(
|
||||
id=chapter.id,
|
||||
name=chapter.name,
|
||||
description=chapter.description,
|
||||
parent_id=chapter.parent_id,
|
||||
sort_order=chapter.sort_order,
|
||||
created_at=chapter.created_at,
|
||||
updated_at=chapter.updated_at,
|
||||
children=[],
|
||||
documents=[serialize_document(d) for d in chapter.documents],
|
||||
)
|
||||
tree.append(tree_item)
|
||||
|
||||
def add_children(node: DocumentChapterTreeItem):
|
||||
for chapter in chapters:
|
||||
if chapter.parent_id == node.id:
|
||||
child_item = DocumentChapterTreeItem(
|
||||
id=chapter.id,
|
||||
name=chapter.name,
|
||||
description=chapter.description,
|
||||
parent_id=chapter.parent_id,
|
||||
sort_order=chapter.sort_order,
|
||||
created_at=chapter.created_at,
|
||||
updated_at=chapter.updated_at,
|
||||
children=[],
|
||||
documents=[serialize_document(d) for d in chapter.documents],
|
||||
)
|
||||
node.children.append(child_item)
|
||||
add_children(child_item)
|
||||
|
||||
for item in tree:
|
||||
add_children(item)
|
||||
|
||||
return tree
|
||||
|
||||
|
||||
def list_document_chapters(
|
||||
db: Session,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
keyword: str | None = None,
|
||||
) -> DocumentChapterListResponse:
|
||||
stmt = select(DocumentChapter)
|
||||
if keyword:
|
||||
stmt = stmt.where(DocumentChapter.name.ilike(f"%{keyword}%"))
|
||||
stmt = stmt.order_by(DocumentChapter.sort_order, DocumentChapter.id)
|
||||
stmt = stmt.limit(limit).offset(offset)
|
||||
|
||||
total_stmt = select(func.count()).select_from(DocumentChapter)
|
||||
if keyword:
|
||||
total_stmt = total_stmt.where(DocumentChapter.name.ilike(f"%{keyword}%"))
|
||||
|
||||
total = db.scalar(total_stmt) or 0
|
||||
chapters = list(db.scalars(stmt).all())
|
||||
|
||||
return DocumentChapterListResponse(
|
||||
items=[serialize_document_chapter(c) for c in chapters],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
def get_document_chapter_tree(db: Session) -> list[DocumentChapterTreeItem]:
|
||||
stmt = select(DocumentChapter).order_by(DocumentChapter.sort_order, DocumentChapter.id)
|
||||
chapters = list(db.scalars(stmt).all())
|
||||
return build_chapter_tree(chapters)
|
||||
|
||||
|
||||
def get_document_chapter_by_id(db: Session, chapter_id: int) -> DocumentChapter | None:
|
||||
return db.get(DocumentChapter, chapter_id)
|
||||
|
||||
|
||||
def create_document_chapter(
|
||||
db: Session, payload: DocumentChapterCreateRequest
|
||||
) -> DocumentChapter:
|
||||
chapter = DocumentChapter(
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
parent_id=payload.parent_id,
|
||||
sort_order=payload.sort_order,
|
||||
)
|
||||
db.add(chapter)
|
||||
db.commit()
|
||||
db.refresh(chapter)
|
||||
return chapter
|
||||
|
||||
|
||||
def update_document_chapter(
|
||||
db: Session, chapter_id: int, payload: DocumentChapterUpdateRequest
|
||||
) -> DocumentChapter | None:
|
||||
chapter = db.get(DocumentChapter, chapter_id)
|
||||
if not chapter:
|
||||
return None
|
||||
|
||||
if payload.name is not None:
|
||||
chapter.name = payload.name
|
||||
if payload.description is not None:
|
||||
chapter.description = payload.description
|
||||
if payload.parent_id is not None:
|
||||
chapter.parent_id = payload.parent_id
|
||||
if payload.sort_order is not None:
|
||||
chapter.sort_order = payload.sort_order
|
||||
|
||||
db.commit()
|
||||
db.refresh(chapter)
|
||||
return chapter
|
||||
|
||||
|
||||
def delete_document_chapter(db: Session, chapter_id: int) -> bool:
|
||||
chapter = db.get(DocumentChapter, chapter_id)
|
||||
if not chapter:
|
||||
return False
|
||||
db.delete(chapter)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def list_documents(
|
||||
db: Session,
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
keyword: str | None = None,
|
||||
chapter_id: int | None = None,
|
||||
status: str | None = None,
|
||||
) -> DocumentListResponse:
|
||||
stmt = select(Document)
|
||||
if keyword:
|
||||
stmt = stmt.where(Document.title.ilike(f"%{keyword}%"))
|
||||
if chapter_id is not None:
|
||||
stmt = stmt.where(Document.chapter_id == chapter_id)
|
||||
if status:
|
||||
stmt = stmt.where(Document.status == status)
|
||||
stmt = stmt.order_by(Document.sort_order, Document.id)
|
||||
stmt = stmt.limit(limit).offset(offset)
|
||||
|
||||
total_stmt = select(func.count()).select_from(Document)
|
||||
if keyword:
|
||||
total_stmt = total_stmt.where(Document.title.ilike(f"%{keyword}%"))
|
||||
if chapter_id is not None:
|
||||
total_stmt = total_stmt.where(Document.chapter_id == chapter_id)
|
||||
if status:
|
||||
total_stmt = total_stmt.where(Document.status == status)
|
||||
|
||||
total = db.scalar(total_stmt) or 0
|
||||
documents = list(db.scalars(stmt).all())
|
||||
|
||||
return DocumentListResponse(
|
||||
items=[serialize_document(d) for d in documents],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
def get_document_by_id(db: Session, document_id: int) -> Document | None:
|
||||
return db.get(Document, document_id)
|
||||
|
||||
|
||||
def create_document(db: Session, payload: DocumentCreateRequest) -> Document:
|
||||
document = Document(
|
||||
title=payload.title,
|
||||
content=payload.content,
|
||||
chapter_id=payload.chapter_id,
|
||||
sort_order=payload.sort_order,
|
||||
status=payload.status,
|
||||
)
|
||||
db.add(document)
|
||||
db.commit()
|
||||
db.refresh(document)
|
||||
return document
|
||||
|
||||
|
||||
def update_document(
|
||||
db: Session, document_id: int, payload: DocumentUpdateRequest
|
||||
) -> Document | None:
|
||||
document = db.get(Document, document_id)
|
||||
if not document:
|
||||
return None
|
||||
|
||||
if payload.title is not None:
|
||||
document.title = payload.title
|
||||
if payload.content is not None:
|
||||
document.content = payload.content
|
||||
if payload.chapter_id is not None:
|
||||
document.chapter_id = payload.chapter_id
|
||||
if payload.sort_order is not None:
|
||||
document.sort_order = payload.sort_order
|
||||
if payload.status is not None:
|
||||
document.status = payload.status
|
||||
|
||||
db.commit()
|
||||
db.refresh(document)
|
||||
return document
|
||||
|
||||
|
||||
def delete_document(db: Session, document_id: int) -> bool:
|
||||
document = db.get(Document, document_id)
|
||||
if not document:
|
||||
return False
|
||||
db.delete(document)
|
||||
db.commit()
|
||||
return True
|
||||
@@ -0,0 +1,65 @@
|
||||
-- Migration: Add document management tables
|
||||
-- Date: 2026-06-20
|
||||
-- Description: Create tables for managing operational documents organized by chapters
|
||||
|
||||
-- Step 1: Create document_chapters table
|
||||
CREATE TABLE IF NOT EXISTS document_chapters (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
description VARCHAR(512),
|
||||
parent_id INTEGER REFERENCES document_chapters(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
-- Create indexes for document_chapters
|
||||
CREATE INDEX IF NOT EXISTS idx_document_chapters_name ON document_chapters(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_chapters_parent_id ON document_chapters(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_chapters_sort_order ON document_chapters(sort_order);
|
||||
|
||||
-- Step 2: Create documents table
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(256) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
chapter_id INTEGER REFERENCES document_chapters(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
status VARCHAR(16) DEFAULT 'draft' NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
-- Create indexes for documents
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_title ON documents(title);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_chapter_id ON documents(chapter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_sort_order ON documents(sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(status);
|
||||
|
||||
-- Step 3: Create trigger to update updated_at on document_chapters
|
||||
CREATE OR REPLACE FUNCTION update_document_chapters_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_update_document_chapters_updated_at
|
||||
BEFORE UPDATE ON document_chapters
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_document_chapters_updated_at();
|
||||
|
||||
-- Step 4: Create trigger to update updated_at on documents
|
||||
CREATE OR REPLACE FUNCTION update_documents_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_update_documents_updated_at
|
||||
BEFORE UPDATE ON documents
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_documents_updated_at();
|
||||
Generated
+1213
-36
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@
|
||||
"next": "16.2.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.3.0",
|
||||
"recharts": "^3.8.1",
|
||||
"reselect": "^5.2.0",
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Layout,
|
||||
Menu,
|
||||
Row,
|
||||
Spin,
|
||||
Typography,
|
||||
theme,
|
||||
} from "antd";
|
||||
import {
|
||||
FolderOutlined,
|
||||
FileTextOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useState } from "react";
|
||||
import type { MenuProps } from "antd";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { readApiError } from "@/lib/api";
|
||||
import type {
|
||||
Document,
|
||||
DocumentChapterTreeItem,
|
||||
} from "@/types/document";
|
||||
|
||||
const { Content, Sider } = Layout;
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
type MenuItem = Required<MenuProps>["items"][number];
|
||||
|
||||
export default function DocsViewPage() {
|
||||
const { fetchWithAuth } = useAuth();
|
||||
const { token } = theme.useToken();
|
||||
const [selectedDocumentId, setSelectedDocumentId] = useState<number | null>(null);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const { data: treeData, isLoading: treeLoading } = useQuery({
|
||||
queryKey: ["/api/v1/documents/chapters/tree"],
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth("/api/v1/documents/chapters/tree");
|
||||
if (!response.ok) throw new Error(await readApiError(response));
|
||||
return response.json() as Promise<DocumentChapterTreeItem[]>;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: selectedDocument, isLoading: documentLoading } = useQuery({
|
||||
queryKey: ["/api/v1/documents", selectedDocumentId],
|
||||
queryFn: async () => {
|
||||
if (!selectedDocumentId) return null;
|
||||
const response = await fetchWithAuth(`/api/v1/documents/${selectedDocumentId}`);
|
||||
if (!response.ok) throw new Error(await readApiError(response));
|
||||
return response.json() as Promise<Document>;
|
||||
},
|
||||
enabled: !!selectedDocumentId,
|
||||
});
|
||||
|
||||
const convertToMenuItems = (chapters: DocumentChapterTreeItem[]): MenuItem[] => {
|
||||
return chapters
|
||||
.filter((chapter) => {
|
||||
// Only show chapters with published documents or published children
|
||||
const hasPublishedDocs = chapter.documents?.some((doc) => doc.status === "published");
|
||||
const hasPublishedChildren = chapter.children?.some((child) =>
|
||||
child.documents?.some((doc) => doc.status === "published")
|
||||
);
|
||||
return hasPublishedDocs || hasPublishedChildren;
|
||||
})
|
||||
.map((chapter) => {
|
||||
const hasChildren = chapter.children && chapter.children.length > 0;
|
||||
const publishedDocs = chapter.documents?.filter((doc) => doc.status === "published") || [];
|
||||
|
||||
const docItems: MenuItem[] = publishedDocs.map((doc) => ({
|
||||
key: `doc-${doc.id}`,
|
||||
icon: <FileTextOutlined />,
|
||||
label: doc.title,
|
||||
}));
|
||||
|
||||
const childItems = hasChildren ? convertToMenuItems(chapter.children) : [];
|
||||
|
||||
return {
|
||||
key: `chapter-${chapter.id}`,
|
||||
icon: <FolderOutlined />,
|
||||
label: chapter.name,
|
||||
children: [...docItems, ...childItems],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleMenuClick: MenuProps["onClick"] = (e) => {
|
||||
if (e.key.startsWith("doc-")) {
|
||||
const docId = parseInt(e.key.replace("doc-", ""), 10);
|
||||
setSelectedDocumentId(docId);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-select first document on load
|
||||
const selectFirstDocument = useCallback(() => {
|
||||
if (!treeData || treeData.length === 0) return;
|
||||
|
||||
const findFirstPublishedDoc = (chapters: DocumentChapterTreeItem[]): Document | null => {
|
||||
for (const chapter of chapters) {
|
||||
const publishedDoc = chapter.documents?.find((doc) => doc.status === "published");
|
||||
if (publishedDoc) return publishedDoc;
|
||||
|
||||
if (chapter.children) {
|
||||
const childDoc = findFirstPublishedDoc(chapter.children);
|
||||
if (childDoc) return childDoc;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const firstDoc = findFirstPublishedDoc(treeData);
|
||||
if (firstDoc && !selectedDocumentId) {
|
||||
setSelectedDocumentId(firstDoc.id);
|
||||
}
|
||||
}, [treeData, selectedDocumentId]);
|
||||
|
||||
useState(() => {
|
||||
selectFirstDocument();
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: "calc(100vh - 64px)" }}>
|
||||
<Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
width={280}
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: "16px", borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
{!collapsed && "操作文档"}
|
||||
</Title>
|
||||
</div>
|
||||
{treeLoading ? (
|
||||
<div style={{ padding: "24px", textAlign: "center" }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : treeData && treeData.length > 0 ? (
|
||||
<Menu
|
||||
mode="inline"
|
||||
items={convertToMenuItems(treeData)}
|
||||
onClick={handleMenuClick}
|
||||
selectedKeys={selectedDocumentId ? [`doc-${selectedDocumentId}`] : []}
|
||||
style={{ borderRight: 0 }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: "24px" }}>
|
||||
<Empty description="暂无文档" />
|
||||
</div>
|
||||
)}
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Content style={{ padding: "24px", background: token.colorBgContainer }}>
|
||||
{documentLoading ? (
|
||||
<div style={{ textAlign: "center", padding: "48px" }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : selectedDocument ? (
|
||||
<Card>
|
||||
<Title level={2}>{selectedDocument.title}</Title>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "24px",
|
||||
lineHeight: "1.8",
|
||||
fontSize: "15px",
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h1: ({ children }) => <Title level={2}>{children}</Title>,
|
||||
h2: ({ children }) => <Title level={3}>{children}</Title>,
|
||||
h3: ({ children }) => <Title level={4}>{children}</Title>,
|
||||
h4: ({ children }) => <Title level={5}>{children}</Title>,
|
||||
p: ({ children }) => <Paragraph>{children}</Paragraph>,
|
||||
code: ({ children, className }) => {
|
||||
const isBlock = className?.includes("language-");
|
||||
return isBlock ? (
|
||||
<pre
|
||||
style={{
|
||||
background: token.colorBgLayout,
|
||||
padding: "12px",
|
||||
borderRadius: "4px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<code
|
||||
style={{
|
||||
background: token.colorBgLayout,
|
||||
padding: "2px 6px",
|
||||
borderRadius: "3px",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{selectedDocument.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div style={{ textAlign: "center", padding: "48px" }}>
|
||||
<Empty description="请从左侧目录选择要查看的文档" />
|
||||
</div>
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Tree,
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
FolderOutlined,
|
||||
FileTextOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { DataNode } from "antd/es/tree";
|
||||
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { readApiError } from "@/lib/api";
|
||||
import type {
|
||||
Document,
|
||||
DocumentChapter,
|
||||
DocumentChapterCreateRequest,
|
||||
DocumentChapterTreeItem,
|
||||
DocumentChapterUpdateRequest,
|
||||
DocumentCreateRequest,
|
||||
DocumentListResponse,
|
||||
DocumentUpdateRequest,
|
||||
} from "@/types/document";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Title } = Typography;
|
||||
|
||||
type ChapterFormValues = {
|
||||
name: string;
|
||||
description?: string;
|
||||
parent_id?: number;
|
||||
sort_order: number;
|
||||
};
|
||||
|
||||
type DocumentFormValues = {
|
||||
title: string;
|
||||
content: string;
|
||||
chapter_id?: number;
|
||||
sort_order: number;
|
||||
status: "draft" | "published";
|
||||
};
|
||||
|
||||
export default function AdminDocumentsPage() {
|
||||
const { fetchWithAuth } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [chapterDialogOpen, setChapterDialogOpen] = useState(false);
|
||||
const [documentDrawerOpen, setDocumentDrawerOpen] = useState(false);
|
||||
const [editingChapterId, setEditingChapterId] = useState<number | null>(null);
|
||||
const [editingDocumentId, setEditingDocumentId] = useState<number | null>(null);
|
||||
const [selectedChapterId, setSelectedChapterId] = useState<number | null>(null);
|
||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||||
|
||||
const [chapterForm] = Form.useForm<ChapterFormValues>();
|
||||
const [documentForm] = Form.useForm<DocumentFormValues>();
|
||||
|
||||
const { data: treeData, isLoading: treeLoading } = useQuery({
|
||||
queryKey: ["/api/v1/documents/chapters/tree"],
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth("/api/v1/documents/chapters/tree");
|
||||
if (!response.ok) throw new Error(await readApiError(response));
|
||||
return response.json() as Promise<DocumentChapterTreeItem[]>;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: documentsData, isLoading: documentsLoading } = useQuery({
|
||||
queryKey: ["/api/v1/documents", selectedChapterId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedChapterId !== null) {
|
||||
params.set("chapter_id", String(selectedChapterId));
|
||||
}
|
||||
params.set("limit", "200");
|
||||
const response = await fetchWithAuth(`/api/v1/documents?${params}`);
|
||||
if (!response.ok) throw new Error(await readApiError(response));
|
||||
return response.json() as Promise<DocumentListResponse>;
|
||||
},
|
||||
});
|
||||
|
||||
const createChapterMutation = useMutation({
|
||||
mutationFn: async (payload: DocumentChapterCreateRequest) => {
|
||||
const response = await fetchWithAuth("/api/v1/documents/chapters", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error(await readApiError(response));
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success("章节创建成功");
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/v1/documents/chapters/tree"] });
|
||||
setChapterDialogOpen(false);
|
||||
chapterForm.resetFields();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
message.error(`创建失败: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const updateChapterMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
payload,
|
||||
}: {
|
||||
id: number;
|
||||
payload: DocumentChapterUpdateRequest;
|
||||
}) => {
|
||||
const response = await fetchWithAuth(`/api/v1/documents/chapters/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error(await readApiError(response));
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success("章节更新成功");
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/v1/documents/chapters/tree"] });
|
||||
setChapterDialogOpen(false);
|
||||
setEditingChapterId(null);
|
||||
chapterForm.resetFields();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
message.error(`更新失败: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteChapterMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const response = await fetchWithAuth(`/api/v1/documents/chapters/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) throw new Error(await readApiError(response));
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success("章节删除成功");
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/v1/documents/chapters/tree"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
message.error(`删除失败: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const createDocumentMutation = useMutation({
|
||||
mutationFn: async (payload: DocumentCreateRequest) => {
|
||||
const response = await fetchWithAuth("/api/v1/documents", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error(await readApiError(response));
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success("文档创建成功");
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/v1/documents"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/v1/documents/chapters/tree"] });
|
||||
setDocumentDrawerOpen(false);
|
||||
documentForm.resetFields();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
message.error(`创建失败: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const updateDocumentMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
payload,
|
||||
}: {
|
||||
id: number;
|
||||
payload: DocumentUpdateRequest;
|
||||
}) => {
|
||||
const response = await fetchWithAuth(`/api/v1/documents/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error(await readApiError(response));
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success("文档更新成功");
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/v1/documents"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/v1/documents/chapters/tree"] });
|
||||
setDocumentDrawerOpen(false);
|
||||
setEditingDocumentId(null);
|
||||
documentForm.resetFields();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
message.error(`更新失败: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteDocumentMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const response = await fetchWithAuth(`/api/v1/documents/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) throw new Error(await readApiError(response));
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success("文档删除成功");
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/v1/documents"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/v1/documents/chapters/tree"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
message.error(`删除失败: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateChapter = () => {
|
||||
setEditingChapterId(null);
|
||||
chapterForm.resetFields();
|
||||
setChapterDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditChapter = (chapter: DocumentChapter) => {
|
||||
setEditingChapterId(chapter.id);
|
||||
chapterForm.setFieldsValue({
|
||||
name: chapter.name,
|
||||
description: chapter.description || "",
|
||||
parent_id: chapter.parent_id || undefined,
|
||||
sort_order: chapter.sort_order,
|
||||
});
|
||||
setChapterDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleChapterFormSubmit = async () => {
|
||||
try {
|
||||
const values = await chapterForm.validateFields();
|
||||
if (editingChapterId) {
|
||||
updateChapterMutation.mutate({ id: editingChapterId, payload: values });
|
||||
} else {
|
||||
createChapterMutation.mutate(values);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Form validation failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDocument = () => {
|
||||
setEditingDocumentId(null);
|
||||
documentForm.resetFields();
|
||||
if (selectedChapterId !== null) {
|
||||
documentForm.setFieldValue("chapter_id", selectedChapterId);
|
||||
}
|
||||
setDocumentDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleEditDocument = (document: Document) => {
|
||||
setEditingDocumentId(document.id);
|
||||
documentForm.setFieldsValue({
|
||||
title: document.title,
|
||||
content: document.content,
|
||||
chapter_id: document.chapter_id || undefined,
|
||||
sort_order: document.sort_order,
|
||||
status: document.status,
|
||||
});
|
||||
setDocumentDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleDocumentFormSubmit = async () => {
|
||||
try {
|
||||
const values = await documentForm.validateFields();
|
||||
if (editingDocumentId) {
|
||||
updateDocumentMutation.mutate({ id: editingDocumentId, payload: values });
|
||||
} else {
|
||||
createDocumentMutation.mutate(values);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Form validation failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const convertToTreeData = (chapters: DocumentChapterTreeItem[]): DataNode[] => {
|
||||
return chapters.map((chapter) => ({
|
||||
key: `chapter-${chapter.id}`,
|
||||
title: (
|
||||
<Space>
|
||||
<FolderOutlined />
|
||||
<span>{chapter.name}</span>
|
||||
<span style={{ color: "#999", fontSize: "12px" }}>
|
||||
({chapter.documents?.length || 0})
|
||||
</span>
|
||||
</Space>
|
||||
),
|
||||
children: chapter.children ? convertToTreeData(chapter.children) : [],
|
||||
}));
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Document> = [
|
||||
{
|
||||
title: "标题",
|
||||
dataIndex: "title",
|
||||
key: "title",
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
key: "status",
|
||||
width: 100,
|
||||
render: (status: string) => (
|
||||
<Tag color={status === "published" ? "green" : "orange"}>
|
||||
{status === "published" ? "已发布" : "草稿"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "排序",
|
||||
dataIndex: "sort_order",
|
||||
key: "sort_order",
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEditDocument(record)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除?"
|
||||
onConfirm={() => deleteDocumentMutation.mutate(record.id)}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const flattenChapters = (chapters: DocumentChapterTreeItem[]): DocumentChapter[] => {
|
||||
const result: DocumentChapter[] = [];
|
||||
const traverse = (items: DocumentChapterTreeItem[]) => {
|
||||
items.forEach((item) => {
|
||||
result.push(item);
|
||||
if (item.children) {
|
||||
traverse(item.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
traverse(chapters);
|
||||
return result;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: "24px" }}>
|
||||
<Title level={3}>文档管理</Title>
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Card
|
||||
title="章节目录"
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreateChapter}
|
||||
>
|
||||
新建章节
|
||||
</Button>
|
||||
}
|
||||
style={{ height: "calc(100vh - 180px)", overflow: "auto" }}
|
||||
>
|
||||
{treeLoading ? (
|
||||
<Spin />
|
||||
) : treeData && treeData.length > 0 ? (
|
||||
<Tree
|
||||
showLine
|
||||
defaultExpandAll
|
||||
expandedKeys={expandedKeys}
|
||||
onExpand={(keys) => setExpandedKeys(keys)}
|
||||
treeData={convertToTreeData(treeData)}
|
||||
onSelect={(keys) => {
|
||||
if (keys.length > 0) {
|
||||
const key = keys[0] as string;
|
||||
if (key.startsWith("chapter-")) {
|
||||
const id = parseInt(key.replace("chapter-", ""), 10);
|
||||
setSelectedChapterId(id);
|
||||
}
|
||||
} else {
|
||||
setSelectedChapterId(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无章节" />
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={18}>
|
||||
<Card
|
||||
title={selectedChapterId ? "章节文档" : "全部文档"}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleCreateDocument}
|
||||
>
|
||||
新建文档
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
dataSource={documentsData?.items || []}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={documentsLoading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Modal
|
||||
title={editingChapterId ? "编辑章节" : "新建章节"}
|
||||
open={chapterDialogOpen}
|
||||
onOk={handleChapterFormSubmit}
|
||||
onCancel={() => {
|
||||
setChapterDialogOpen(false);
|
||||
setEditingChapterId(null);
|
||||
chapterForm.resetFields();
|
||||
}}
|
||||
confirmLoading={
|
||||
createChapterMutation.isPending || updateChapterMutation.isPending
|
||||
}
|
||||
>
|
||||
<Form form={chapterForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="章节名称"
|
||||
rules={[{ required: true, message: "请输入章节名称" }]}
|
||||
>
|
||||
<Input placeholder="请输入章节名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<TextArea rows={3} placeholder="请输入描述" />
|
||||
</Form.Item>
|
||||
<Form.Item name="parent_id" label="父章节">
|
||||
<Select placeholder="选择父章节(可选)" allowClear>
|
||||
{treeData &&
|
||||
flattenChapters(treeData)
|
||||
.filter((c) => c.id !== editingChapterId)
|
||||
.map((chapter) => (
|
||||
<Select.Option key={chapter.id} value={chapter.id}>
|
||||
{chapter.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0}>
|
||||
<InputNumber min={0} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title={editingDocumentId ? "编辑文档" : "新建文档"}
|
||||
width={720}
|
||||
open={documentDrawerOpen}
|
||||
onClose={() => {
|
||||
setDocumentDrawerOpen(false);
|
||||
setEditingDocumentId(null);
|
||||
documentForm.resetFields();
|
||||
}}
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDocumentDrawerOpen(false);
|
||||
setEditingDocumentId(null);
|
||||
documentForm.resetFields();
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleDocumentFormSubmit}
|
||||
loading={
|
||||
createDocumentMutation.isPending || updateDocumentMutation.isPending
|
||||
}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={documentForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="文档标题"
|
||||
rules={[{ required: true, message: "请输入文档标题" }]}
|
||||
>
|
||||
<Input placeholder="请输入文档标题" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="文档内容"
|
||||
rules={[{ required: true, message: "请输入文档内容" }]}
|
||||
>
|
||||
<TextArea rows={15} placeholder="请输入文档内容,支持 Markdown 格式" />
|
||||
</Form.Item>
|
||||
<Form.Item name="chapter_id" label="所属章节">
|
||||
<Select placeholder="选择所属章节(可选)" allowClear>
|
||||
{treeData &&
|
||||
flattenChapters(treeData).map((chapter) => (
|
||||
<Select.Option key={chapter.id} value={chapter.id}>
|
||||
{chapter.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" initialValue="draft">
|
||||
<Select>
|
||||
<Select.Option value="draft">草稿</Select.Option>
|
||||
<Select.Option value="published">已发布</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0}>
|
||||
<InputNumber min={0} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export interface DocumentChapter {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
parent_id: number | null;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DocumentChapterTreeItem extends DocumentChapter {
|
||||
children: DocumentChapterTreeItem[];
|
||||
documents: Document[];
|
||||
}
|
||||
|
||||
export interface DocumentChapterListResponse {
|
||||
items: DocumentChapter[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DocumentChapterCreateRequest {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
parent_id?: number | null;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface DocumentChapterUpdateRequest {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
parent_id?: number | null;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
chapter_id: number | null;
|
||||
sort_order: number;
|
||||
status: "draft" | "published";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DocumentListResponse {
|
||||
items: Document[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DocumentCreateRequest {
|
||||
title: string;
|
||||
content: string;
|
||||
chapter_id?: number | null;
|
||||
sort_order?: number;
|
||||
status?: "draft" | "published";
|
||||
}
|
||||
|
||||
export interface DocumentUpdateRequest {
|
||||
title?: string;
|
||||
content?: string;
|
||||
chapter_id?: number | null;
|
||||
sort_order?: number;
|
||||
status?: "draft" | "published";
|
||||
}
|
||||
Reference in New Issue
Block a user