6
This commit is contained in:
@@ -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='父分类 ID(NULL = 顶层)'))
|
||||||
|
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
@@ -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("知识库不存在。")
|
||||||
@@ -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(...),
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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.2:document_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="父分类 ID(NULL = 顶层)",
|
||||||
|
)
|
||||||
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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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 12:SQLite LIKE / FTS5)。"""
|
"""关键词搜索文档(Phase 12:SQLite 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
+893
-223
File diff suppressed because it is too large
Load Diff
@@ -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,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user