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:
chengkai3
2026-06-20 23:21:35 +08:00
parent 21f9839dd6
commit 483fdb982b
12 changed files with 2764 additions and 37 deletions
+2
View File
@@ -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)
+230
View File
@@ -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="文档不存在",
)
+2 -1
View File
@@ -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",
+76
View File
@@ -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",
)
+72
View File
@@ -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
+251
View File
@@ -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();
+1213 -36
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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",
+225
View File
@@ -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>
);
}
+562
View File
@@ -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>
);
}
+65
View File
@@ -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";
}