This commit is contained in:
amb
2026-09-01 20:51:46 +08:00
parent 70ece67910
commit b80161972a
18 changed files with 1878 additions and 359 deletions
@@ -0,0 +1,47 @@
"""add_category_tree_and_text_content
Revision ID: 4bd4c7f26818
Revises: 4fcdb390ae18
Create Date: 2026-09-01 18:02:12.469005
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4bd4c7f26818'
down_revision: Union[str, None] = '4fcdb390ae18'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# document_categories 新增字段
op.add_column('document_categories', sa.Column('parent_id', sa.String(length=32), nullable=True, comment='父分类 IDNULL = 顶层)'))
op.add_column('document_categories', sa.Column('path', sa.Text(), server_default='/', nullable=False, comment='物化路径'))
op.add_column('document_categories', sa.Column('is_folder', sa.Boolean(), server_default=sa.text('1'), nullable=False, comment='True=文件夹'))
op.create_index(op.f('ix_document_categories_parent_id'), 'document_categories', ['parent_id'], unique=False)
# documents 新增字段
op.add_column('documents', sa.Column('content', sa.Text(), nullable=True, comment='直接输入的文本内容'))
op.add_column('documents', sa.Column('content_format', sa.String(length=16), server_default='markdown', nullable=False, comment='内容格式'))
# SQLite 不支持 ALTER COLUMN,用 batch mode 重建表
with op.batch_alter_table('documents', schema=None) as batch_op:
batch_op.alter_column('storage_path', existing_type=sa.String(1024), nullable=True)
batch_op.alter_column('sha256', existing_type=sa.String(64), nullable=True)
def downgrade() -> None:
with op.batch_alter_table('documents', schema=None) as batch_op:
batch_op.alter_column('sha256', existing_type=sa.String(64), nullable=False)
batch_op.alter_column('storage_path', existing_type=sa.String(1024), nullable=False)
op.drop_column('documents', 'content_format')
op.drop_column('documents', 'content')
op.drop_index(op.f('ix_document_categories_parent_id'), table_name='document_categories')
op.drop_column('document_categories', 'is_folder')
op.drop_column('document_categories', 'path')
op.drop_column('document_categories', 'parent_id')
+199 -20
View File
@@ -1,7 +1,8 @@
"""文档分类 API 路由。""" """文档分类 API 路由(树形目录结构)"""
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db
@@ -13,73 +14,219 @@ from app.repositories.kb_repo import KnowledgeBaseRepository
router = APIRouter(prefix="/knowledge-bases/{kb_id}/categories", tags=["categories"]) router = APIRouter(prefix="/knowledge-bases/{kb_id}/categories", tags=["categories"])
class CategoryRequest(BaseModel): class CategoryCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=255) name: str = Field(min_length=1, max_length=255)
parent_id: str | None = None # NULL = 顶层
sort_order: int = 0 sort_order: int = 0
is_folder: bool = True
class CategoryResponse(BaseModel): class CategoryUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=255)
parent_id: str | None = None # 移动到新父节点
sort_order: int | None = None
is_folder: bool | None = None
class CategoryTreeNode(BaseModel):
id: str id: str
name: str name: str
path: str
is_folder: bool
sort_order: int sort_order: int
doc_count: int = 0
children: list["CategoryTreeNode"] = []
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@router.get("", response_model=list[CategoryResponse]) CategoryTreeNode.model_rebuild()
def list_categories(
@router.get("/tree", response_model=list[CategoryTreeNode])
def get_category_tree(
kb_id: str, kb_id: str,
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> list[CategoryResponse]: ) -> list[CategoryTreeNode]:
"""获取完整目录树(含文档数量)。"""
_check_kb_owner(kb_id, user, db)
# 获取所有分类
stmt = (
select(DocumentCategory)
.where(DocumentCategory.knowledge_base_id == kb_id)
.order_by(DocumentCategory.sort_order, DocumentCategory.name)
)
all_cats = list(db.scalars(stmt).all())
# 获取每个分类的文档数量
from app.models.document import Document
from sqlalchemy import func
count_stmt = (
select(Document.category_id, func.count())
.where(
Document.knowledge_base_id == kb_id,
Document.status != "DELETED",
)
.group_by(Document.category_id)
)
doc_count_map = dict(db.execute(count_stmt).all())
# 构建树
cat_map = {}
for cat in all_cats:
cat_map[cat.id] = CategoryTreeNode(
id=cat.id,
name=cat.name,
path=cat.path or "/",
is_folder=cat.is_folder if hasattr(cat, 'is_folder') else True,
sort_order=cat.sort_order,
doc_count=doc_count_map.get(cat.id, 0),
children=[],
)
# 构建父子关系
roots = []
for cat in all_cats:
node = cat_map[cat.id]
if cat.parent_id and cat.parent_id in cat_map:
cat_map[cat.parent_id].children.append(node)
else:
roots.append(node)
return roots
@router.get("", response_model=list[CategoryTreeNode])
def list_categories_flat(
kb_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> list[dict]:
"""获取平铺的分类列表。"""
_check_kb_owner(kb_id, user, db) _check_kb_owner(kb_id, user, db)
from sqlalchemy import select
stmt = ( stmt = (
select(DocumentCategory) select(DocumentCategory)
.where(DocumentCategory.knowledge_base_id == kb_id) .where(DocumentCategory.knowledge_base_id == kb_id)
.order_by(DocumentCategory.sort_order) .order_by(DocumentCategory.path, DocumentCategory.sort_order)
) )
cats = list(db.scalars(stmt).all()) cats = list(db.scalars(stmt).all())
return [CategoryResponse.model_validate(c) for c in cats] return [
{
"id": c.id,
"name": c.name,
"path": c.path,
"is_folder": c.is_folder if hasattr(c, 'is_folder') else True,
"sort_order": c.sort_order,
"parent_id": c.parent_id,
}
for c in cats
]
@router.post("", response_model=CategoryResponse, status_code=201) @router.post("", response_model=CategoryTreeNode, status_code=201)
def create_category( def create_category(
kb_id: str, kb_id: str,
body: CategoryRequest, body: CategoryCreateRequest,
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> CategoryResponse: ) -> CategoryTreeNode:
"""创建分类(支持子目录)。"""
_check_kb_owner(kb_id, user, db) _check_kb_owner(kb_id, user, db)
# 计算 path
parent_path = "/"
if body.parent_id:
parent = db.get(DocumentCategory, body.parent_id)
if parent is None or parent.knowledge_base_id != kb_id:
raise NotFoundError("父分类不存在。")
parent_path = parent.path
# 生成 path
cat_id_placeholder = "new" # 创建后会更新
path = f"{parent_path}{body.name}/"
cat = DocumentCategory( cat = DocumentCategory(
knowledge_base_id=kb_id, knowledge_base_id=kb_id,
parent_id=body.parent_id,
name=body.name, name=body.name,
path=path,
is_folder=body.is_folder,
sort_order=body.sort_order, sort_order=body.sort_order,
) )
db.add(cat) db.add(cat)
db.flush()
# 更新 path 使用实际 ID
cat.path = f"{parent_path}{body.name}/"
db.commit() db.commit()
db.refresh(cat) db.refresh(cat)
return CategoryResponse.model_validate(cat)
return CategoryTreeNode(
id=cat.id,
name=cat.name,
path=cat.path,
is_folder=cat.is_folder,
sort_order=cat.sort_order,
doc_count=0,
children=[],
)
@router.put("/{cat_id}", response_model=CategoryResponse) @router.put("/{cat_id}", response_model=CategoryTreeNode)
def update_category( def update_category(
kb_id: str, kb_id: str,
cat_id: str, cat_id: str,
body: CategoryRequest, body: CategoryUpdateRequest,
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> CategoryResponse: ) -> CategoryTreeNode:
"""更新分类(改名、移动、改类型)。"""
_check_kb_owner(kb_id, user, db) _check_kb_owner(kb_id, user, db)
cat = db.get(DocumentCategory, cat_id) cat = db.get(DocumentCategory, cat_id)
if cat is None or cat.knowledge_base_id != kb_id: if cat is None or cat.knowledge_base_id != kb_id:
raise NotFoundError("分类不存在。") raise NotFoundError("分类不存在。")
cat.name = body.name
cat.sort_order = body.sort_order old_path = cat.path
if body.name is not None:
cat.name = body.name
if body.sort_order is not None:
cat.sort_order = body.sort_order
if body.is_folder is not None:
cat.is_folder = body.is_folder
if body.parent_id is not None:
cat.parent_id = body.parent_id
# 重新计算 path
if body.parent_id is not None or body.name is not None:
parent_path = "/"
if cat.parent_id:
parent = db.get(DocumentCategory, cat.parent_id)
if parent:
parent_path = parent.path
new_path = f"{parent_path}{cat.name}/"
# 更新所有子节点的 path
if old_path != new_path:
_update_children_paths(db, kb_id, old_path, new_path)
cat.path = new_path
db.commit() db.commit()
db.refresh(cat) db.refresh(cat)
return CategoryResponse.model_validate(cat)
return CategoryTreeNode(
id=cat.id,
name=cat.name,
path=cat.path,
is_folder=cat.is_folder,
sort_order=cat.sort_order,
doc_count=0,
children=[],
)
@router.delete("/{cat_id}", status_code=204) @router.delete("/{cat_id}", status_code=204)
@@ -89,16 +236,48 @@ def delete_category(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> None: ) -> None:
"""删除分类(同时删除子分类,关联文档的 category_id 置 NULL)。"""
_check_kb_owner(kb_id, user, db) _check_kb_owner(kb_id, user, db)
cat = db.get(DocumentCategory, cat_id) cat = db.get(DocumentCategory, cat_id)
if cat is None or cat.knowledge_base_id != kb_id: if cat is None or cat.knowledge_base_id != kb_id:
raise NotFoundError("分类不存在。") raise NotFoundError("分类不存在。")
# 删除所有子分类
if cat.path:
stmt = select(DocumentCategory).where(
DocumentCategory.knowledge_base_id == kb_id,
DocumentCategory.path.startswith(cat.path),
DocumentCategory.id != cat_id,
)
children = list(db.scalars(stmt).all())
for child in children:
db.delete(child)
# 关联文档的 category_id 置 NULL
from app.models.document import Document
doc_stmt = select(Document).where(Document.category_id == cat_id)
docs = list(db.scalars(doc_stmt).all())
for doc in docs:
doc.category_id = None
db.delete(cat) db.delete(cat)
db.commit() db.commit()
def _update_children_paths(db: Session, kb_id: str, old_path: str, new_path: str) -> None:
"""更新所有子节点的 path。"""
stmt = select(DocumentCategory).where(
DocumentCategory.knowledge_base_id == kb_id,
DocumentCategory.path.startswith(old_path),
)
children = list(db.scalars(stmt).all())
for child in children:
child.path = child.path.replace(old_path, new_path, 1)
def _check_kb_owner(kb_id: str, user: User, db: Session) -> None: def _check_kb_owner(kb_id: str, user: User, db: Session) -> None:
repo = KnowledgeBaseRepository(db) repo = KnowledgeBaseRepository(db)
kb = repo.get_by_id(kb_id) kb = repo.get_by_id(kb_id)
if kb is None or kb.user_id != user.id or kb.status == "DELETED": if kb is None or kb.user_id != user.id or kb.status == "DELETED":
raise NotFoundError("知识库不存在。") raise NotFoundError("知识库不存在。")
+30
View File
@@ -1,6 +1,7 @@
"""文档 API 路由。""" """文档 API 路由。"""
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form from fastapi import APIRouter, Depends, Query, UploadFile, File, Form
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db
@@ -16,10 +17,19 @@ from app.services.doc_service import DocumentService
router = APIRouter(prefix="/documents", tags=["documents"]) router = APIRouter(prefix="/documents", tags=["documents"])
class CreateTextRequest(BaseModel):
kb_id: str
title: str = Field(min_length=1, max_length=512)
content: str = Field(min_length=1)
content_format: str = "markdown" # markdown / text
category_id: str | None = None
@router.post("/upload", response_model=DocumentUploadResponse, status_code=201) @router.post("/upload", response_model=DocumentUploadResponse, status_code=201)
async def upload_document( async def upload_document(
kb_id: str = Form(...), kb_id: str = Form(...),
file: UploadFile = File(...), file: UploadFile = File(...),
category_id: str = Form(None),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> DocumentUploadResponse: ) -> DocumentUploadResponse:
@@ -31,6 +41,7 @@ async def upload_document(
kb_id=kb_id, kb_id=kb_id,
filename=file.filename or "unknown", filename=file.filename or "unknown",
content=content, content=content,
category_id=category_id,
) )
return DocumentUploadResponse( return DocumentUploadResponse(
id=doc.id, id=doc.id,
@@ -41,6 +52,25 @@ async def upload_document(
) )
@router.post("/create-text", response_model=DocumentResponse, status_code=201)
def create_text_document(
body: CreateTextRequest,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> DocumentResponse:
"""直接创建文本内容文档(无需上传文件)。"""
svc = DocumentService(db)
doc = svc.create_text(
user=user,
kb_id=body.kb_id,
title=body.title,
content=body.content,
content_format=body.content_format,
category_id=body.category_id,
)
return _to_response(doc)
@router.get("", response_model=DocumentListResponse) @router.get("", response_model=DocumentListResponse)
def list_documents( def list_documents(
kb_id: str = Query(...), kb_id: str = Query(...),
+13 -1
View File
@@ -114,12 +114,24 @@ async def app_error_handler(_: Request, exc: AppError) -> JSONResponse:
async def validation_error_handler(_: Request, exc: RequestValidationError) -> JSONResponse: async def validation_error_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
# 序列化错误详情,过滤不可 JSON 序列化的对象
errors = []
for err in exc.errors():
clean_err = {
"type": err.get("type", ""),
"loc": err.get("loc", []),
"msg": err.get("msg", ""),
}
if "input" in err:
clean_err["input"] = str(err["input"])
errors.append(clean_err)
return JSONResponse( return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={ content={
"code": "VALIDATION_ERROR", "code": "VALIDATION_ERROR",
"message": "请求参数不正确。", "message": "请求参数不正确。",
"detail": exc.errors(), "detail": errors,
}, },
) )
+22 -8
View File
@@ -37,12 +37,23 @@ class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base):
original_filename: Mapped[str] = mapped_column( original_filename: Mapped[str] = mapped_column(
String(512), String(512),
nullable=False, nullable=False,
comment="原始文件名 (仅展示)", comment="原始文件名或文本标题 (仅展示)",
) )
storage_path: Mapped[str] = mapped_column( storage_path: Mapped[str | None] = mapped_column(
String(1024), String(1024),
nullable=True,
comment="相对于 data/ 的物理存储路径(文本内容文档为 NULL)",
)
content: Mapped[str | None] = mapped_column(
Text,
nullable=True,
comment="直接输入的文本内容(非文件上传时使用)",
)
content_format: Mapped[str] = mapped_column(
String(16),
default="markdown",
nullable=False, nullable=False,
comment="相对于 data/ 的物理存储路径", comment="内容格式:markdown / text",
) )
markdown_path: Mapped[str | None] = mapped_column( markdown_path: Mapped[str | None] = mapped_column(
String(1024), String(1024),
@@ -51,23 +62,26 @@ class Document(UUIDPrimaryKeyMixin, TimestampMixin, Base):
) )
file_size: Mapped[int] = mapped_column( file_size: Mapped[int] = mapped_column(
Integer, Integer,
default=0,
nullable=False, nullable=False,
comment="文件大小 (字节)", comment="文件大小 (字节),文本内容文档为内容长度",
) )
mime_type: Mapped[str] = mapped_column( mime_type: Mapped[str] = mapped_column(
String(127), String(127),
default="text/markdown",
nullable=False, nullable=False,
comment="MIME 类型", comment="MIME 类型",
) )
file_ext: Mapped[str] = mapped_column( file_ext: Mapped[str] = mapped_column(
String(16), String(16),
default=".md",
nullable=False, nullable=False,
comment="文件扩展名 (.docx/.pdf)", comment="文件扩展名 (.docx/.pdf/.md)",
) )
sha256: Mapped[str] = mapped_column( sha256: Mapped[str | None] = mapped_column(
String(64), String(64),
nullable=False, nullable=True,
comment="文件 SHA-256 哈希", comment="文件 SHA-256 哈希(文本内容文档为 NULL",
) )
doc_token_hash: Mapped[str] = mapped_column( doc_token_hash: Mapped[str] = mapped_column(
String(64), String(64),
+27 -5
View File
@@ -1,15 +1,16 @@
"""DocumentCategory 文档分类模型。""" """DocumentCategory 文档分类模型(支持树形目录结构)"""
from sqlalchemy import ForeignKey, Integer, String from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin from app.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base): class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""文档分类模型。 """文档分类模型(树形结构)
技术审查 §2.2document_categories 表 通过 parent_id 实现层级关系,path 用于高效查询子树
is_folder=True 表示文件夹(可包含子项),False 表示叶子分类。
""" """
__tablename__ = "document_categories" __tablename__ = "document_categories"
@@ -21,11 +22,30 @@ class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base):
index=True, index=True,
comment="所属知识库 ID", comment="所属知识库 ID",
) )
parent_id: Mapped[str | None] = mapped_column(
String(32),
ForeignKey("document_categories.id"),
nullable=True,
index=True,
comment="父分类 IDNULL = 顶层)",
)
name: Mapped[str] = mapped_column( name: Mapped[str] = mapped_column(
String(255), String(255),
nullable=False, nullable=False,
comment="分类名称", comment="分类名称",
) )
path: Mapped[str] = mapped_column(
Text,
default="/",
nullable=False,
comment="物化路径,如 /01_公司层/04_岗位AI角色/",
)
is_folder: Mapped[bool] = mapped_column(
Boolean,
default=True,
nullable=False,
comment="True=文件夹(可含子项),False=叶子分类",
)
sort_order: Mapped[int] = mapped_column( sort_order: Mapped[int] = mapped_column(
Integer, Integer,
default=0, default=0,
@@ -35,7 +55,9 @@ class DocumentCategory(UUIDPrimaryKeyMixin, TimestampMixin, Base):
# 关系 # 关系
knowledge_base = relationship("KnowledgeBase", back_populates="categories", lazy="selectin") knowledge_base = relationship("KnowledgeBase", back_populates="categories", lazy="selectin")
parent = relationship("DocumentCategory", remote_side="DocumentCategory.id", lazy="selectin")
children = relationship("DocumentCategory", back_populates="parent", lazy="selectin")
documents = relationship("Document", back_populates="category", lazy="selectin") documents = relationship("Document", back_populates="category", lazy="selectin")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<DocumentCategory {self.name!r} (kb={self.knowledge_base_id!r})>" return f"<DocumentCategory {self.name!r} path={self.path!r}>"
+120 -17
View File
@@ -12,12 +12,14 @@ import json
from fastapi import APIRouter, Depends, Query, Request, Response from fastapi import APIRouter, Depends, Query, Request, Response
from fastapi.responses import HTMLResponse, PlainTextResponse from fastapi.responses import HTMLResponse, PlainTextResponse
from fastapi import Path as PathParam
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db from app.api.deps import get_db
from app.core.errors import NotFoundError, RateLimitedError from app.core.errors import NotFoundError, RateLimitedError
from app.core.rate_limit import check_rate_limit from app.core.rate_limit import check_rate_limit
from app.core.security import decrypt_token from app.core.security import decrypt_token
from app.models.document_category import DocumentCategory
from app.services.access_log_service import AccessLogService from app.services.access_log_service import AccessLogService
from app.services.kb_public_service import KbPublicService from app.services.kb_public_service import KbPublicService
@@ -151,10 +153,14 @@ def kb_index_json(
"updated_at": doc.updated_at, "updated_at": doc.updated_at,
}) })
# 获取目录树
category_tree = svc.get_category_tree(kb)
data = { data = {
"name": kb.name, "name": kb.name,
"description": kb.description, "description": kb.description,
"document_count": len(doc_list), "document_count": len(doc_list),
"categories": category_tree,
"documents": doc_list, "documents": doc_list,
} }
@@ -168,15 +174,52 @@ def kb_index_json(
def kb_index_html( def kb_index_html(
token: str, token: str,
request: Request, request: Request,
category: str = Query(None, description="按分类路径过滤,如 /01公司层/公司基本信息/"),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> HTMLResponse: ) -> HTMLResponse:
"""知识库首页(HTML)。""" """知识库首页(HTML)。支持按目录过滤。"""
_rate_limit(request, token) _rate_limit(request, token)
svc = KbPublicService(db) svc = KbPublicService(db)
kb = svc.get_kb_by_token(token) kb = svc.get_kb_by_token(token)
_log_access(db, kb.id, f"/k/{token}", request, req_type="html") _log_access(db, kb.id, f"/k/{token}", request, req_type="html")
docs, total = svc.list_documents(kb, page=page, page_size=50)
# 获取目录树
category_tree = svc.get_category_tree(kb)
# 按分类过滤
category_id = None
if category:
# 根据路径查找分类 ID
from sqlalchemy import select
stmt = select(DocumentCategory).where(
DocumentCategory.knowledge_base_id == kb.id,
DocumentCategory.path == category,
)
cat = db.scalars(stmt).first()
if cat:
category_id = cat.id
docs, total = svc.list_documents(kb, category_id=category_id, page=page, page_size=50)
# 渲染目录树侧边栏
def render_tree(nodes: list, level: int = 0) -> str:
html = ""
for node in nodes:
indent = " " * level
is_active = category == node["path"]
active_class = ' class="active"' if is_active else ""
doc_count = f' <span class="count">({node["doc_count"]})</span>' if node["doc_count"] > 0 else ""
if node["is_folder"]:
html += f'{indent}<li{active_class}><a href="/k/{token}?category={node["path"]}">{node["name"]}</a>{doc_count}</li>\n'
if node["children"]:
html += f'{indent}<ul>\n{render_tree(node["children"], level + 1)}{indent}</ul>\n'
else:
html += f'{indent}<li{active_class}><a href="/k/{token}?category={node["path"]}">{node["name"]}</a>{doc_count}</li>\n'
return html
tree_html = render_tree(category_tree)
doc_rows = "" doc_rows = ""
for doc in docs: for doc in docs:
@@ -208,8 +251,19 @@ def kb_index_html(
{_robots_meta()} {_robots_meta()}
{_referrer_meta()} {_referrer_meta()}
<style> <style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; }} * {{ box-sizing: border-box; }}
h1 {{ color: #333; }} body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 20px; line-height: 1.6; display: flex; gap: 30px; }}
.sidebar {{ width: 280px; flex-shrink: 0; }}
.main {{ flex: 1; max-width: 800px; }}
h1 {{ color: #333; margin-top: 0; }}
.tree {{ list-style: none; padding: 0; margin: 0; }}
.tree ul {{ list-style: none; padding-left: 20px; margin: 0; }}
.tree li {{ padding: 6px 10px; border-radius: 4px; }}
.tree li:hover {{ background: #f5f7fa; }}
.tree li.active {{ background: #ecf5ff; }}
.tree a {{ color: #333; text-decoration: none; font-size: 0.95em; }}
.tree a:hover {{ color: #409eff; }}
.tree .count {{ color: #999; font-size: 0.85em; }}
.doc-item {{ border-bottom: 1px solid #eee; padding: 15px 0; }} .doc-item {{ border-bottom: 1px solid #eee; padding: 15px 0; }}
.doc-item h3 {{ margin: 0 0 5px 0; }} .doc-item h3 {{ margin: 0 0 5px 0; }}
.doc-item a {{ color: #0066cc; text-decoration: none; }} .doc-item a {{ color: #0066cc; text-decoration: none; }}
@@ -220,17 +274,29 @@ def kb_index_html(
.pagination {{ color: #666; font-size: 0.9em; text-align: center; }} .pagination {{ color: #666; font-size: 0.9em; text-align: center; }}
.status-badge {{ color: #e67e22; font-size: 0.8em; font-weight: normal; }} .status-badge {{ color: #e67e22; font-size: 0.8em; font-weight: normal; }}
.footer {{ margin-top: 30px; padding-top: 15px; border-top: 1px solid #eee; color: #999; font-size: 0.85em; }} .footer {{ margin-top: 30px; padding-top: 15px; border-top: 1px solid #eee; color: #999; font-size: 0.85em; }}
.back-link {{ display: inline-block; margin-bottom: 15px; color: #0066cc; text-decoration: none; }}
.back-link:hover {{ text-decoration: underline; }}
</style> </style>
</head> </head>
<body> <body>
<h1>{kb.name}</h1> <div class="sidebar">
{"<p>" + kb.description + "</p>" if kb.description else ""} <h2 style="margin-top: 0; font-size: 1.1em;">目录</h2>
<h2>文档列表</h2> <ul class="tree">
{doc_rows if doc_rows else "<p>暂无文档</p>"} <li{" class='active'" if not category else ""}><a href="/k/{token}">全部文档</a> <span class="count">({total})</span></li>
{pagination} {tree_html}
<div class="footer"> </ul>
<p>This page is an AI-readable knowledge base index. Use the document links above to retrieve specific documents.</p> </div>
<p>本页为 AI 可读知识库目录,请通过上述文档链接获取具体内容。</p> <div class="main">
<h1>{kb.name}</h1>
{"<p>" + kb.description + "</p>" if kb.description else ""}
{"<a href='/k/" + token + "' class='back-link'>← 返回全部文档</a>" if category else ""}
<h2>{"当前分类:" + category if category else "文档列表"}</h2>
{doc_rows if doc_rows else "<p>暂无文档。</p>"}
{pagination}
<div class="footer">
<p>This page is an AI-readable knowledge base index. Use the document links above to retrieve specific documents.</p>
<p>本页为 AI 可读知识库目录,请通过上述文档链接获取具体内容。</p>
</div>
</div> </div>
</body> </body>
</html>""" </html>"""
@@ -240,12 +306,49 @@ def kb_index_html(
# --- 单文档访问 --- # --- 单文档访问 ---
@router.get("/{token}/doc/{doc_token}") @router.get("/{token}/doc/{doc_token}.md")
def doc_page_html( def doc_page_markdown(
token: str, token: str,
doc_token: str, doc_token: str,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> PlainTextResponse:
"""文档(Markdown)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.md", request, doc_id=doc.id, req_type="doc_md")
markdown_content = svc.get_document_content(doc)
return PlainTextResponse(content=markdown_content, media_type="text/markdown")
@router.get("/{token}/doc/{doc_token}.txt")
def doc_page_text(
token: str,
doc_token: str,
request: Request,
db: Session = Depends(get_db),
) -> PlainTextResponse:
"""文档(纯文本)。"""
_rate_limit(request, token)
svc = KbPublicService(db)
kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.txt", request, doc_id=doc.id, req_type="doc_txt")
markdown_content = svc.get_document_content(doc)
import re
text = re.sub(r"[#*_`\[\]()>]", "", markdown_content)
text = re.sub(r"\n{3,}", "\n\n", text)
return PlainTextResponse(content=text.strip(), media_type="text/plain")
@router.get("/{token}/doc/{doc_token}")
def doc_page_html(
token: str,
doc_token: str = PathParam(pattern=r"^[A-Za-z0-9_-]+$"),
request: Request = None,
db: Session = Depends(get_db),
) -> HTMLResponse: ) -> HTMLResponse:
"""文档页面(HTML)。""" """文档页面(HTML)。"""
_rate_limit(request, token) _rate_limit(request, token)
@@ -253,7 +356,7 @@ def doc_page_html(
kb = svc.get_kb_by_token(token) kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token) doc = svc.get_document_by_token(kb, doc_token)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}", request, doc_id=doc.id, req_type="doc_html") _log_access(db, kb.id, f"/k/{token}/doc/{doc_token}", request, doc_id=doc.id, req_type="doc_html")
markdown_content = svc.get_document_markdown(doc) markdown_content = svc.get_document_content(doc)
# Markdown → HTML(简单转换) # Markdown → HTML(简单转换)
html_content = _markdown_to_html(markdown_content) html_content = _markdown_to_html(markdown_content)
@@ -313,7 +416,7 @@ def doc_page_markdown(
kb = svc.get_kb_by_token(token) kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token) doc = svc.get_document_by_token(kb, doc_token)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.md", request, doc_id=doc.id, req_type="doc_md") _log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.md", request, doc_id=doc.id, req_type="doc_md")
markdown_content = svc.get_document_markdown(doc) markdown_content = svc.get_document_content(doc)
return PlainTextResponse(content=markdown_content, media_type="text/markdown") return PlainTextResponse(content=markdown_content, media_type="text/markdown")
@@ -330,7 +433,7 @@ def doc_page_text(
kb = svc.get_kb_by_token(token) kb = svc.get_kb_by_token(token)
doc = svc.get_document_by_token(kb, doc_token) doc = svc.get_document_by_token(kb, doc_token)
_log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.txt", request, doc_id=doc.id, req_type="doc_txt") _log_access(db, kb.id, f"/k/{token}/doc/{doc_token}.txt", request, doc_id=doc.id, req_type="doc_txt")
markdown_content = svc.get_document_markdown(doc) markdown_content = svc.get_document_content(doc)
# 去掉 Markdown 标记 # 去掉 Markdown 标记
import re import re
+4 -1
View File
@@ -21,17 +21,20 @@ class DocumentRepository:
self, self,
kb_id: str, kb_id: str,
*, *,
category_id: str | None = None,
page: int = 1, page: int = 1,
page_size: int = 50, page_size: int = 50,
status: str | None = None, status: str | None = None,
) -> tuple[list[Document], int]: ) -> tuple[list[Document], int]:
"""分页获取知识库的文档列表。""" """分页获取知识库的文档列表。支持按分类过滤。"""
conditions = [ conditions = [
Document.knowledge_base_id == kb_id, Document.knowledge_base_id == kb_id,
Document.status != "DELETED", Document.status != "DELETED",
] ]
if status: if status:
conditions.append(Document.status == status) conditions.append(Document.status == status)
if category_id:
conditions.append(Document.category_id == category_id)
count_stmt = select(func.count()).select_from(Document).where(*conditions) count_stmt = select(func.count()).select_from(Document).where(*conditions)
total = self._session.scalar(count_stmt) or 0 total = self._session.scalar(count_stmt) or 0
+86
View File
@@ -41,6 +41,7 @@ class DocumentService:
kb_id: str, kb_id: str,
filename: str, filename: str,
content: bytes, content: bytes,
category_id: str | None = None,
) -> Document: ) -> Document:
"""上传文档。 """上传文档。
@@ -111,6 +112,7 @@ class DocumentService:
doc_token_hash=doc_token_hash, doc_token_hash=doc_token_hash,
doc_token_encrypted=doc_token_encrypted, doc_token_encrypted=doc_token_encrypted,
doc_token_hint=doc_token_hint, doc_token_hint=doc_token_hint,
category_id=category_id,
) )
# 更新存储路径中的 document_id # 更新存储路径中的 document_id
@@ -200,3 +202,87 @@ class DocumentService:
processor = LocalDocumentProcessor(self._session) processor = LocalDocumentProcessor(self._session)
processor.process(doc.id) processor.process(doc.id)
def create_text(
self,
user: User,
kb_id: str,
title: str,
content: str,
content_format: str = "markdown",
category_id: str | None = None,
) -> Document:
"""创建文本内容文档(无需上传文件)。"""
# 校验 KB 归属
kb = self._kb_repo.get_by_id(kb_id)
if kb is None or kb.user_id != user.id or kb.status == "DELETED":
raise NotFoundError("知识库不存在。")
# 生成文档 token
doc_token = generate_token()
doc_token_hash = hash_token(doc_token)
doc_token_encrypted = encrypt_token(doc_token)
doc_token_hint = doc_token[-8:] if len(doc_token) >= 8 else doc_token
# 计算内容大小
content_size = len(content.encode("utf-8"))
# 配额检查
self._check_quota(user, content_size)
# 从内容提取摘要和关键词
summary = self._extract_summary(content)
keywords = self._extract_keywords(content)
# 创建文档记录
doc = Document(
knowledge_base_id=kb_id,
user_id=user.id,
category_id=category_id,
original_filename=title,
storage_path=None, # 文本内容不存储文件
markdown_path=None,
content=content,
content_format=content_format,
file_size=content_size,
mime_type="text/markdown" if content_format == "markdown" else "text/plain",
file_ext=".md" if content_format == "markdown" else ".txt",
sha256=None,
doc_token_hash=doc_token_hash,
doc_token_encrypted=doc_token_encrypted,
doc_token_hint=doc_token_hint,
title=title,
content_summary=summary,
keywords=keywords,
status="READY", # 文本内容直接就绪
)
self._session.add(doc)
# 扣减配额
self._deduct_quota(user, content_size)
self._session.commit()
return doc
def _extract_summary(self, content: str, max_len: int = 200) -> str:
"""提取摘要。"""
import re
text = re.sub(r"[#*_`\[\]()>]", "", content)
text = re.sub(r"\s+", " ", text).strip()
if len(text) <= max_len:
return text
return text[:max_len] + "..."
def _extract_keywords(self, content: str, top_k: int = 10) -> str:
"""提取关键词。"""
try:
import jieba.analyse
import re
text = re.sub(r"[#*_`\[\]()>]", "", content)
text = re.sub(r"\s+", " ", text).strip()
keywords = jieba.analyse.extract_tags(text, topK=top_k)
return ",".join(keywords)
except Exception:
return ""
+77 -19
View File
@@ -1,14 +1,17 @@
"""公共知识库服务(Phase 10/11/12 统一数据来源)。 """公共知识库服务(Phase 10/11/12 统一数据来源)。
HTML/MD/TXT/JSON/搜索全部通过此 Service 获取数据,不各自写查询逻辑。 HTML/MD/TXT/JSON/搜索全部通过此 Service 获取数据,不各自写查询逻辑。
支持目录树结构和分类过滤。
""" """
from app.core.errors import NotFoundError from app.core.errors import NotFoundError
from app.core.security import hash_token from app.core.security import hash_token
from app.models.document import Document from app.models.document import Document
from app.models.document_category import DocumentCategory
from app.models.knowledge_base import KnowledgeBase from app.models.knowledge_base import KnowledgeBase
from app.repositories.doc_repo import DocumentRepository from app.repositories.doc_repo import DocumentRepository
from app.repositories.kb_repo import KnowledgeBaseRepository from app.repositories.kb_repo import KnowledgeBaseRepository
from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -26,12 +29,55 @@ class KbPublicService:
raise NotFoundError("知识库不存在。") raise NotFoundError("知识库不存在。")
return kb return kb
def get_category_tree(self, kb: KnowledgeBase) -> list[dict]:
"""获取目录树(含文档数量)。"""
stmt = (
select(DocumentCategory)
.where(DocumentCategory.knowledge_base_id == kb.id)
.order_by(DocumentCategory.sort_order, DocumentCategory.name)
)
all_cats = list(self._session.scalars(stmt).all())
# 获取每个分类的文档数量
count_stmt = (
select(Document.category_id, func.count())
.where(
Document.knowledge_base_id == kb.id,
Document.status != "DELETED",
)
.group_by(Document.category_id)
)
doc_count_map = dict(self._session.execute(count_stmt).all())
# 构建树
cat_map = {}
for cat in all_cats:
cat_map[cat.id] = {
"id": cat.id,
"name": cat.name,
"path": cat.path or "/",
"is_folder": getattr(cat, 'is_folder', True),
"sort_order": cat.sort_order,
"doc_count": doc_count_map.get(cat.id, 0),
"children": [],
}
roots = []
for cat in all_cats:
node = cat_map[cat.id]
if cat.parent_id and cat.parent_id in cat_map:
cat_map[cat.parent_id]["children"].append(node)
else:
roots.append(node)
return roots
def list_documents( def list_documents(
self, kb: KnowledgeBase, *, page: int = 1, page_size: int = 50 self, kb: KnowledgeBase, *, category_id: str | None = None, page: int = 1, page_size: int = 50
) -> tuple[list[Document], int]: ) -> tuple[list[Document], int]:
"""获取知识库的文档列表(排除 DELETED)。""" """获取知识库的文档列表(排除 DELETED)。支持按分类过滤。"""
return self._doc_repo.list_by_knowledge_base( return self._doc_repo.list_by_knowledge_base(
kb.id, page=page, page_size=page_size kb.id, category_id=category_id, page=page, page_size=page_size
) )
def get_document_by_token(self, kb: KnowledgeBase, doc_token: str) -> Document: def get_document_by_token(self, kb: KnowledgeBase, doc_token: str) -> Document:
@@ -42,34 +88,38 @@ class KbPublicService:
raise NotFoundError("文档不存在。") raise NotFoundError("文档不存在。")
return doc return doc
def get_document_markdown(self, doc: Document) -> str: def get_document_content(self, doc: Document) -> str:
"""取文档 Markdown 内容""" """取文档内容(优先直接内容,其次 Markdown 文件)"""
from app.storage.local_storage import get_storage # 直接文本内容
if doc.content:
return doc.content
if not doc.markdown_path: # 从文件读取 Markdown
return "" if doc.markdown_path:
storage = get_storage() from app.storage.local_storage import get_storage
content = storage.read(doc.markdown_path)
return content.decode("utf-8") storage = get_storage()
content = storage.read(doc.markdown_path)
return content.decode("utf-8")
return ""
def search_documents( def search_documents(
self, kb: KnowledgeBase, query: str, *, page: int = 1, page_size: int = 20 self, kb: KnowledgeBase, query: str, *, page: int = 1, page_size: int = 20
) -> tuple[list[dict], int]: ) -> tuple[list[dict], int]:
"""关键词搜索文档(Phase 12SQLite LIKE / FTS5)。""" """关键词搜索文档(Phase 12SQLite LIKE / FTS5)。"""
# MVP 简化:使用 LIKE 搜索标题+描述+关键词
from sqlalchemy import func, or_, select
conditions = [ conditions = [
Document.knowledge_base_id == kb.id, Document.knowledge_base_id == kb.id,
Document.status == "READY", Document.status == "READY",
] ]
like_pattern = f"%{query}%" like_pattern = f"%{query}%"
search_condition = or_( search_condition = (
Document.title.like(like_pattern), Document.title.like(like_pattern)
Document.description.like(like_pattern), | Document.description.like(like_pattern)
Document.keywords.like(like_pattern), | Document.keywords.like(like_pattern)
Document.content_summary.like(like_pattern), | Document.content_summary.like(like_pattern)
| Document.content.like(like_pattern)
) )
conditions.append(search_condition) conditions.append(search_condition)
@@ -87,6 +137,13 @@ class KbPublicService:
results = [] results = []
for doc in docs: for doc in docs:
# 获取分类路径
category_path = ""
if doc.category_id:
cat = self._session.get(DocumentCategory, doc.category_id)
if cat:
category_path = cat.path or ""
results.append({ results.append({
"id": doc.id, "id": doc.id,
"title": doc.title or doc.original_filename, "title": doc.title or doc.original_filename,
@@ -95,6 +152,7 @@ class KbPublicService:
"file_type": doc.file_ext, "file_type": doc.file_ext,
"updated_at": doc.updated_at, "updated_at": doc.updated_at,
"url_hint": doc.doc_token_hint, "url_hint": doc.doc_token_hint,
"category_path": category_path,
}) })
return results, total return results, total
+40 -1
View File
@@ -1,6 +1,7 @@
"""知识库服务:CRUD + Token 管理。""" """知识库服务:CRUD + Token 管理 + 默认目录树"""
from app.core.errors import NotFoundError, PermissionDeniedError from app.core.errors import NotFoundError, PermissionDeniedError
from app.models.document_category import DocumentCategory
from app.models.knowledge_base import KnowledgeBase from app.models.knowledge_base import KnowledgeBase
from app.models.user import User from app.models.user import User
from app.repositories.kb_repo import KnowledgeBaseRepository from app.repositories.kb_repo import KnowledgeBaseRepository
@@ -18,6 +19,7 @@ class KnowledgeBaseService:
"""创建知识库。返回 (kb, full_token)。 """创建知识库。返回 (kb, full_token)。
full_token 仅此一次返回,用于构建完整 AI URL。 full_token 仅此一次返回,用于构建完整 AI URL。
自动创建默认目录树结构。
""" """
token, token_hash, token_encrypted, token_hint = self._token_svc.create_token_pair() token, token_hash, token_encrypted, token_hint = self._token_svc.create_token_pair()
kb = self._kb_repo.create( kb = self._kb_repo.create(
@@ -28,9 +30,46 @@ class KnowledgeBaseService:
token_encrypted=token_encrypted, token_encrypted=token_encrypted,
token_hint=token_hint, token_hint=token_hint,
) )
self._seed_default_categories(kb.id)
self._session.commit() self._session.commit()
return kb, token return kb, token
def _seed_default_categories(self, kb_id: str) -> None:
"""创建默认目录树结构。"""
default_tree = [
("01 公司层", ["公司基本信息", "经营理念", "四大价值", "A/M/B三态"]),
("02 战略层", ["公司战略", "客户战略", "AI战略", "产品战略"]),
("03 部门层", ["企划", "技术", "交付", "市场"]),
("04 岗位/AI角色", []),
("05 业务知识", ["价值创造", "价值传递", "价值交付", "价值支持"]),
("06 资产库", ["文案", "SOP", "模板", "案例", "Prompt"]),
]
for i, (folder_name, children) in enumerate(default_tree):
# 创建顶层文件夹
folder = DocumentCategory(
knowledge_base_id=kb_id,
parent_id=None,
name=folder_name,
path=f"/{folder_name}/",
is_folder=True,
sort_order=i,
)
self._session.add(folder)
self._session.flush()
# 创建子分类
for j, child_name in enumerate(children):
child = DocumentCategory(
knowledge_base_id=kb_id,
parent_id=folder.id,
name=child_name,
path=f"/{folder_name}/{child_name}/",
is_folder=False,
sort_order=j,
)
self._session.add(child)
def get_or_404(self, kb_id: str, user: User) -> KnowledgeBase: def get_or_404(self, kb_id: str, user: User) -> KnowledgeBase:
"""获取知识库,校验所有权。不存在或无权 → 404。""" """获取知识库,校验所有权。不存在或无权 → 404。"""
kb = self._kb_repo.get_by_id(kb_id) kb = self._kb_repo.get_by_id(kb_id)
+10 -6
View File
@@ -35,15 +35,19 @@ def test_create_category() -> None:
def test_list_categories() -> None: def test_list_categories() -> None:
with _client() as client: with _client() as client:
kb_id = _setup(client) kb_id = _setup(client)
client.post(f"/api/knowledge-bases/{kb_id}/categories", json={"name": "A", "sort_order": 2}) # 创建知识库时会自动 seed 默认目录树
client.post(f"/api/knowledge-bases/{kb_id}/categories", json={"name": "B", "sort_order": 1}) # 再手动添加两个分类
client.post(f"/api/knowledge-bases/{kb_id}/categories", json={"name": "A", "sort_order": 2, "is_folder": False})
client.post(f"/api/knowledge-bases/{kb_id}/categories", json={"name": "B", "sort_order": 1, "is_folder": False})
resp = client.get(f"/api/knowledge-bases/{kb_id}/categories") resp = client.get(f"/api/knowledge-bases/{kb_id}/categories")
assert resp.status_code == 200 assert resp.status_code == 200
cats = resp.json() cats = resp.json()
assert len(cats) == 2 # 应包含默认 seed 的分类 + 手动添加的 2 个
# 按 sort_order 排序 assert len(cats) >= 2
assert cats[0]["name"] == "B" # 验证手动添加的分类存在
assert cats[1]["name"] == "A" names = [c["name"] for c in cats]
assert "A" in names
assert "B" in names
def test_update_category() -> None: def test_update_category() -> None:
+10 -1
View File
@@ -21,7 +21,16 @@ apiClient.interceptors.response.use((response) => response, (error) => {
return Promise.reject(error); return Promise.reject(error);
} }
// 业务错误:显示服务端中文文案 // 业务错误:显示服务端中文文案
const message = data?.message || '请求失败'; let message = data?.message || '请求失败';
// 验证错误:拼接具体字段错误
if (data?.detail && Array.isArray(data.detail)) {
const fieldErrors = data.detail.map((e) => {
const field = e.loc?.join('.') || '';
return field ? `${field}: ${e.msg}` : e.msg;
}).join('\n');
if (fieldErrors)
message = fieldErrors;
}
ElMessage.error(message); ElMessage.error(message);
} }
else if (error.request) { else if (error.request) {
+9 -1
View File
@@ -27,7 +27,15 @@ apiClient.interceptors.response.use(
} }
// 业务错误:显示服务端中文文案 // 业务错误:显示服务端中文文案
const message = data?.message || '请求失败' let message = data?.message || '请求失败'
// 验证错误:拼接具体字段错误
if (data?.detail && Array.isArray(data.detail)) {
const fieldErrors = data.detail.map((e: any) => {
const field = e.loc?.join('.') || ''
return field ? `${field}: ${e.msg}` : e.msg
}).join('\n')
if (fieldErrors) message = fieldErrors
}
ElMessage.error(message) ElMessage.error(message)
} else if (error.request) { } else if (error.request) {
ElMessage.error('网络错误,请检查连接。') ElMessage.error('网络错误,请检查连接。')
+289 -53
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import apiClient from '@/api/client' import apiClient from '@/api/client'
@@ -9,12 +9,32 @@ const kbId = route.params.id as string
const kb = ref<any>(null) const kb = ref<any>(null)
const docs = ref<any[]>([]) const docs = ref<any[]>([])
const categories = ref<any[]>([])
const loading = ref(false) const loading = ref(false)
const uploading = ref(false) const uploading = ref(false)
const aiUrl = ref('') const aiUrl = ref('')
const selectedCategoryId = ref<string | null>(null)
const selectedCategoryPath = ref<string | null>(null)
// 文本内容对话框
const showTextDialog = ref(false)
const textForm = ref({
title: '',
content: '',
content_format: 'markdown',
category_id: null as string | null,
})
const textLoading = ref(false)
// 目录管理
const showCatDialog = ref(false)
const catForm = ref({ name: '', parent_id: null as string | null, is_folder: true })
const catLoading = ref(false)
const editingCatId = ref<string | null>(null)
onMounted(async () => { onMounted(async () => {
await loadKb() await loadKb()
await loadCategories()
await loadDocs() await loadDocs()
await loadLink() await loadLink()
}) })
@@ -26,10 +46,21 @@ async function loadKb() {
} catch { /* ignore */ } } catch { /* ignore */ }
} }
async function loadCategories() {
try {
const { data } = await apiClient.get(`/knowledge-bases/${kbId}/categories/tree`)
categories.value = data
} catch { /* ignore */ }
}
async function loadDocs() { async function loadDocs() {
loading.value = true loading.value = true
try { try {
const { data } = await apiClient.get(`/documents?kb_id=${kbId}`) let url = `/documents?kb_id=${kbId}`
if (selectedCategoryId.value) {
url += `&category_id=${selectedCategoryId.value}`
}
const { data } = await apiClient.get(url)
docs.value = data.items docs.value = data.items
} catch { /* ignore */ } } catch { /* ignore */ }
loading.value = false loading.value = false
@@ -42,27 +73,69 @@ async function loadLink() {
} catch { /* ignore */ } } catch { /* ignore */ }
} }
function selectCategory(cat: any) {
selectedCategoryId.value = cat.id
selectedCategoryPath.value = cat.path
loadDocs()
}
function clearCategoryFilter() {
selectedCategoryId.value = null
selectedCategoryPath.value = null
loadDocs()
}
// 上传文档
async function handleUpload(options: any) { async function handleUpload(options: any) {
uploading.value = true uploading.value = true
try { try {
const formData = new FormData() const formData = new FormData()
formData.append('kb_id', kbId) formData.append('kb_id', kbId)
formData.append('file', options.file) formData.append('file', options.file)
if (selectedCategoryId.value) {
formData.append('category_id', selectedCategoryId.value)
}
await apiClient.post('/documents/upload', formData, { await apiClient.post('/documents/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
}) })
ElMessage.success('文档上传成功!') ElMessage.success('文档上传成功!')
loadDocs() loadDocs()
loadCategories()
} catch { /* ignore */ } } catch { /* ignore */ }
uploading.value = false uploading.value = false
} }
// 创建文本内容
async function handleCreateText() {
if (!textForm.value.title.trim() || !textForm.value.content.trim()) {
ElMessage.warning('请输入标题和内容。')
return
}
textLoading.value = true
try {
await apiClient.post('/documents/create-text', {
kb_id: kbId,
title: textForm.value.title,
content: textForm.value.content,
content_format: textForm.value.content_format,
category_id: selectedCategoryId.value || textForm.value.category_id,
})
ElMessage.success('文本内容已创建!')
showTextDialog.value = false
textForm.value = { title: '', content: '', content_format: 'markdown', category_id: null }
loadDocs()
loadCategories()
} catch { /* ignore */ }
textLoading.value = false
}
async function handleDeleteDoc(doc: any) { async function handleDeleteDoc(doc: any) {
try { try {
await ElMessageBox.confirm(`确定删除文档${doc.original_filename}」?`, '确认删除', { type: 'warning' }) await ElMessageBox.confirm(`确定删除「${doc.original_filename}」?`, '确认删除', { type: 'warning' })
await apiClient.delete(`/documents/${doc.id}`) await apiClient.delete(`/documents/${doc.id}`)
ElMessage.success('已删除。') ElMessage.success('已删除。')
loadDocs() loadDocs()
loadCategories()
} catch { /* ignore */ } } catch { /* ignore */ }
} }
@@ -81,6 +154,51 @@ async function handleCopyLink() {
} catch { /* ignore */ } } catch { /* ignore */ }
} }
// 目录管理
function openAddCategory(parentId: string | null = null) {
editingCatId.value = null
catForm.value = { name: '', parent_id: parentId, is_folder: true }
showCatDialog.value = true
}
function openEditCategory(cat: any) {
editingCatId.value = cat.id
catForm.value = { name: cat.name, parent_id: cat.parent_id || null, is_folder: cat.is_folder }
showCatDialog.value = true
}
async function handleSaveCategory() {
if (!catForm.value.name.trim()) {
ElMessage.warning('请输入目录名称。')
return
}
catLoading.value = true
try {
if (editingCatId.value) {
await apiClient.put(`/knowledge-bases/${kbId}/categories/${editingCatId.value}`, catForm.value)
ElMessage.success('目录已更新。')
} else {
await apiClient.post(`/knowledge-bases/${kbId}/categories`, catForm.value)
ElMessage.success('目录已创建。')
}
showCatDialog.value = false
loadCategories()
} catch { /* ignore */ }
catLoading.value = false
}
async function handleDeleteCategory(cat: any) {
try {
await ElMessageBox.confirm(`确定删除目录「${cat.name}」及其所有子目录?`, '确认删除', { type: 'warning' })
await apiClient.delete(`/knowledge-bases/${kbId}/categories/${cat.id}`)
ElMessage.success('已删除。')
if (selectedCategoryId.value === cat.id) {
clearCategoryFilter()
}
loadCategories()
} catch { /* ignore */ }
}
async function handleRegenerate() { async function handleRegenerate() {
try { try {
await ElMessageBox.confirm('重新生成链接后,旧链接将立即失效。确定继续?', '确认', { type: 'warning' }) await ElMessageBox.confirm('重新生成链接后,旧链接将立即失效。确定继续?', '确认', { type: 'warning' })
@@ -101,64 +219,182 @@ function statusType(status: string) {
if (status === 'FAILED') return 'danger' if (status === 'FAILED') return 'danger'
return 'warning' return 'warning'
} }
// 递归获取所有叶子节点(用于文本对话框的分类选择器)
function flattenCategories(nodes: any[], level = 0): any[] {
const result: any[] = []
for (const node of nodes) {
result.push({ ...node, level })
if (node.children?.length) {
result.push(...flattenCategories(node.children, level + 1))
}
}
return result
}
const flatCategories = computed(() => flattenCategories(categories.value))
</script> </script>
<template> <template>
<div v-if="kb"> <div v-if="kb" style="display: flex; gap: 20px">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px"> <!-- 左侧目录树 -->
<h1 style="margin: 0">{{ kb.name }}</h1> <div style="width: 280px; flex-shrink: 0">
<el-button type="primary" @click="handleCopyLink">复制 AI 链接</el-button> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px">
<h3 style="margin: 0">目录</h3>
<el-button size="small" @click="openAddCategory(null)">+ 新建</el-button>
</div>
<el-tree
:data="categories"
node-key="id"
default-expand-all
:expand-on-click-node="false"
>
<template #default="{ node, data }">
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%; padding: 4px 0">
<span
:style="{ cursor: 'pointer', color: selectedCategoryId === data.id ? '#409eff' : '#333', fontWeight: selectedCategoryId === data.id ? 'bold' : 'normal' }"
@click="selectCategory(data)"
>
{{ data.name }}
<span v-if="data.doc_count > 0" style="color: #999; font-size: 0.85em">({{ data.doc_count }})</span>
</span>
<span>
<el-button size="small" text @click.stop="openAddCategory(data.id)">+</el-button>
<el-button size="small" text @click.stop="openEditCategory(data)"></el-button>
<el-button size="small" text type="danger" @click.stop="handleDeleteCategory(data)">×</el-button>
</span>
</div>
</template>
</el-tree>
<!-- 全部文档 -->
<div
style="margin-top: 10px; padding: 8px 10px; cursor: pointer; border-radius: 4px; background: #f5f7fa"
:style="{ background: !selectedCategoryId ? '#ecf5ff' : '#f5f7fa' }"
@click="clearCategoryFilter"
>
全部文档
</div>
</div> </div>
<el-card style="margin-bottom: 20px"> <!-- 右侧文档列表 -->
<el-descriptions :column="2" border> <div style="flex: 1">
<el-descriptions-item label="描述">{{ kb.description || '无' }}</el-descriptions-item> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px">
<el-descriptions-item label="状态"> <h1 style="margin: 0">{{ kb.name }}</h1>
<el-tag :type="kb.enabled ? 'success' : 'danger'">{{ kb.enabled ? '启用' : '禁用' }}</el-tag> <el-button type="primary" @click="handleCopyLink">复制 AI 链接</el-button>
</el-descriptions-item> </div>
<el-descriptions-item label="AI 链接">
<div style="display: flex; align-items: center; gap: 8px"> <el-card style="margin-bottom: 20px">
<el-input :model-value="aiUrl" readonly size="small" style="flex: 1" /> <el-descriptions :column="2" border size="small">
<el-button size="small" @click="handleRegenerate">重新生成</el-button> <el-descriptions-item label="描述">{{ kb.description || '无' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="kb.enabled ? 'success' : 'danger'">{{ kb.enabled ? '启用' : '禁用' }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card>
<template #header>
<div style="display: flex; justify-content: space-between; align-items: center">
<span>
文档列表
<el-tag v-if="selectedCategoryPath" closable @close="clearCategoryFilter" style="margin-left: 8px">
{{ selectedCategoryPath }}
</el-tag>
</span>
<div style="display: flex; gap: 8px">
<el-button @click="showTextDialog = true">添加文本</el-button>
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf">
<el-button type="primary" :loading="uploading">上传文档</el-button>
</el-upload>
</div>
</div> </div>
</el-descriptions-item> </template>
<el-descriptions-item label="文档数">{{ docs.length }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card> <el-table :data="docs" v-loading="loading" style="width: 100%">
<template #header> <el-table-column prop="original_filename" label="标题/文件名" min-width="200" show-overflow-tooltip />
<div style="display: flex; justify-content: space-between; align-items: center"> <el-table-column label="状态" width="100" align="center">
<span>文档列表</span> <template #default="{ row }">
<el-upload <el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag>
:show-file-list="false" </template>
:http-request="handleUpload" </el-table-column>
accept=".docx,.pdf" <el-table-column label="类型" width="80" align="center">
> <template #default="{ row }">{{ row.file_ext }}</template>
<el-button type="primary" :loading="uploading">上传文档</el-button> </el-table-column>
</el-upload> <el-table-column label="大小" width="100" align="center">
</div> <template #default="{ row }">{{ formatSize(row.file_size) }}</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip />
<el-table-column prop="keywords" label="关键词" min-width="150" show-overflow-tooltip />
<el-table-column label="操作" width="200" align="center">
<template #default="{ row }">
<el-button size="small" @click="handleReprocess(row)">重新解析</el-button>
<el-button size="small" type="danger" @click="handleDeleteDoc(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<!-- 文本内容对话框 -->
<el-dialog v-model="showTextDialog" title="添加文本内容" width="700px">
<el-form :model="textForm" label-position="top">
<el-form-item label="标题" required>
<el-input v-model="textForm.title" placeholder="文档标题" />
</el-form-item>
<el-form-item label="所属目录">
<el-select v-model="textForm.category_id" placeholder="选择目录(可选)" clearable style="width: 100%">
<el-option
v-for="cat in flatCategories"
:key="cat.id"
:label="cat.name"
:value="cat.id"
>
<span :style="{ paddingLeft: cat.level * 20 + 'px' }">{{ cat.name }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="格式">
<el-radio-group v-model="textForm.content_format">
<el-radio value="markdown">Markdown</el-radio>
<el-radio value="text">纯文本</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="内容" required>
<el-input
v-model="textForm.content"
type="textarea"
:rows="12"
placeholder="输入文本内容(支持 Markdown 格式)"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showTextDialog = false">取消</el-button>
<el-button type="primary" :loading="textLoading" @click="handleCreateText">创建</el-button>
</template> </template>
</el-dialog>
<el-table :data="docs" v-loading="loading" style="width: 100%"> <!-- 目录管理对话框 -->
<el-table-column prop="original_filename" label="文件名" min-width="200" show-overflow-tooltip /> <el-dialog v-model="showCatDialog" :title="editingCatId ? '编辑目录' : '新建目录'" width="400px">
<el-table-column label="状态" width="120" align="center"> <el-form :model="catForm" label-position="top">
<template #default="{ row }"> <el-form-item label="目录名称" required>
<el-tag :type="statusType(row.status)" size="small">{{ row.status }}</el-tag> <el-input v-model="catForm.name" placeholder="如:公司基本信息" />
</template> </el-form-item>
</el-table-column> <el-form-item label="类型">
<el-table-column label="大小" width="100" align="center"> <el-radio-group v-model="catForm.is_folder">
<template #default="{ row }">{{ formatSize(row.file_size) }}</template> <el-radio :value="true">文件夹可包含子目录</el-radio>
</el-table-column> <el-radio :value="false">叶子分类存放文档</el-radio>
<el-table-column prop="title" label="标题" min-width="150" show-overflow-tooltip /> </el-radio-group>
<el-table-column prop="keywords" label="关键词" min-width="150" show-overflow-tooltip /> </el-form-item>
<el-table-column label="操作" width="200" align="center"> </el-form>
<template #default="{ row }"> <template #footer>
<el-button size="small" @click="handleReprocess(row)">重新解析</el-button> <el-button @click="showCatDialog = false">取消</el-button>
<el-button size="small" type="danger" @click="handleDeleteDoc(row)">删除</el-button> <el-button type="primary" :loading="catLoading" @click="handleSaveCategory">
</template> {{ editingCatId ? '保存' : '创建' }}
</el-table-column> </el-button>
</el-table> </template>
</el-card> </el-dialog>
</div> </div>
</template> </template>
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import apiClient from '@/api/client' import apiClient from '@/api/client'
@@ -35,8 +36,6 @@ async function handleRegister() {
loading.value = false loading.value = false
} }
} }
import { ElMessage } from 'element-plus'
</script> </script>
<template> <template>
+1 -1
View File
@@ -1,5 +1,6 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useUserStore } from '@/stores/user'; import { useUserStore } from '@/stores/user';
import apiClient from '@/api/client'; import apiClient from '@/api/client';
const router = useRouter(); const router = useRouter();
@@ -33,7 +34,6 @@ async function handleRegister() {
loading.value = false; loading.value = false;
} }
} }
import { ElMessage } from 'element-plus';
debugger; /* PartiallyEnd: #3632/scriptSetup.vue */ debugger; /* PartiallyEnd: #3632/scriptSetup.vue */
const __VLS_ctx = {}; const __VLS_ctx = {};
let __VLS_components; let __VLS_components;