942 lines
24 KiB
Markdown
942 lines
24 KiB
Markdown
|
|
# 知识库文件夹分层实施计划
|
|||
|
|
|
|||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|||
|
|
|
|||
|
|
**Goal:** 为知识库添加文件夹分层组织功能,支持多层嵌套、CRUD、级联删除
|
|||
|
|
|
|||
|
|
**Architecture:** 使用邻接表模式(parent_id)存储文件夹层级,通过递归 CTE 查询完整树结构。ChromaDB metadata 增加 folder_path 支持按文件夹过滤检索。
|
|||
|
|
|
|||
|
|
**Tech Stack:** FastAPI + SQLAlchemy async + SQLite + ChromaDB + Vue 3 + TypeScript
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 1: 创建 Folder 模型
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `backend/app/models/folder.py`
|
|||
|
|
- Test: `backend/app/models/test_folder.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 Folder 模型**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/app/models/folder.py
|
|||
|
|
from sqlalchemy import Column, String, ForeignKey, UniqueConstraint
|
|||
|
|
from app.models.base import BaseModel
|
|||
|
|
|
|||
|
|
class Folder(BaseModel):
|
|||
|
|
__tablename__ = "folders"
|
|||
|
|
__table_args__ = (
|
|||
|
|
UniqueConstraint('user_id', 'parent_id', 'name', name='uq_user_parent_name'),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
|||
|
|
name = Column(String(255), nullable=False)
|
|||
|
|
parent_id = Column(String(36), ForeignKey("folders.id"), nullable=True)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建测试文件验证模型**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/app/models/test_folder.py
|
|||
|
|
import pytest
|
|||
|
|
from app.models.folder import Folder
|
|||
|
|
|
|||
|
|
def test_folder_model_creation():
|
|||
|
|
folder = Folder(user_id="test-user", name="Test Folder")
|
|||
|
|
assert folder.name == "Test Folder"
|
|||
|
|
assert folder.parent_id is None
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 提交**
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 2: 创建 Folder Schema
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `backend/app/schemas/folder.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建 Pydantic schemas**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/app/schemas/folder.py
|
|||
|
|
from pydantic import BaseModel, Field, field_validator
|
|||
|
|
from typing import Optional, List
|
|||
|
|
from datetime import datetime
|
|||
|
|
|
|||
|
|
class FolderCreate(BaseModel):
|
|||
|
|
name: str = Field(..., min_length=1, max_length=255)
|
|||
|
|
parent_id: Optional[str] = None
|
|||
|
|
|
|||
|
|
@field_validator('name')
|
|||
|
|
@classmethod
|
|||
|
|
def validate_name(cls, v):
|
|||
|
|
forbidden = '/\\*?:'
|
|||
|
|
for c in forbidden:
|
|||
|
|
if c in v:
|
|||
|
|
raise ValueError(f'Folder name cannot contain: {forbidden}')
|
|||
|
|
return v
|
|||
|
|
|
|||
|
|
class FolderUpdate(BaseModel):
|
|||
|
|
name: str = Field(..., min_length=1, max_length=255)
|
|||
|
|
|
|||
|
|
class FolderOut(BaseModel):
|
|||
|
|
id: str
|
|||
|
|
name: str
|
|||
|
|
parent_id: Optional[str]
|
|||
|
|
created_at: datetime
|
|||
|
|
updated_at: datetime
|
|||
|
|
|
|||
|
|
model_config = {"from_attributes": True}
|
|||
|
|
|
|||
|
|
class FolderTreeOut(BaseModel):
|
|||
|
|
id: str
|
|||
|
|
name: str
|
|||
|
|
parent_id: Optional[str]
|
|||
|
|
children: List["FolderTreeOut"] = []
|
|||
|
|
|
|||
|
|
model_config = {"from_attributes": True}
|
|||
|
|
|
|||
|
|
# 递归模型需要 forward ref
|
|||
|
|
FolderTreeOut.model_rebuild()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 提交**
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 3: 创建文件夹路由
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `backend/app/routers/folder.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 实现文件夹 CRUD 路由**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/app/routers/folder.py
|
|||
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
|
from sqlalchemy import select, and_
|
|||
|
|
from typing import List
|
|||
|
|
from app.database import get_db
|
|||
|
|
from app.models.folder import Folder
|
|||
|
|
from app.models.user import User
|
|||
|
|
from app.schemas.folder import FolderCreate, FolderUpdate, FolderOut, FolderTreeOut
|
|||
|
|
from app.services.auth_service import get_current_user
|
|||
|
|
|
|||
|
|
router = APIRouter(prefix="/api/folders", tags=["文件夹"])
|
|||
|
|
|
|||
|
|
def build_folder_tree(folders: list[Folder], parent_id: str = None) -> List[FolderTreeOut]:
|
|||
|
|
"""递归构建文件夹树"""
|
|||
|
|
tree = []
|
|||
|
|
for folder in folders:
|
|||
|
|
if folder.parent_id == parent_id:
|
|||
|
|
children = build_folder_tree(folders, folder.id)
|
|||
|
|
tree.append(FolderTreeOut(
|
|||
|
|
id=folder.id,
|
|||
|
|
name=folder.name,
|
|||
|
|
parent_id=folder.parent_id,
|
|||
|
|
children=children
|
|||
|
|
))
|
|||
|
|
return tree
|
|||
|
|
|
|||
|
|
@router.get("", response_model=List[FolderTreeOut])
|
|||
|
|
async def get_folders(
|
|||
|
|
db: AsyncSession = Depends(get_db),
|
|||
|
|
current_user: User = Depends(get_current_user)
|
|||
|
|
):
|
|||
|
|
"""获取用户的完整文件夹树"""
|
|||
|
|
result = await db.execute(
|
|||
|
|
select(Folder).where(Folder.user_id == current_user.id)
|
|||
|
|
)
|
|||
|
|
folders = result.scalars().all()
|
|||
|
|
return build_folder_tree(list(folders))
|
|||
|
|
|
|||
|
|
@router.post("", response_model=FolderOut, status_code=status.HTTP_201_CREATED)
|
|||
|
|
async def create_folder(
|
|||
|
|
folder_data: FolderCreate,
|
|||
|
|
db: AsyncSession = Depends(get_db),
|
|||
|
|
current_user: User = Depends(get_current_user)
|
|||
|
|
):
|
|||
|
|
"""创建文件夹"""
|
|||
|
|
# 验证父文件夹存在且属于当前用户
|
|||
|
|
if folder_data.parent_id:
|
|||
|
|
result = await db.execute(
|
|||
|
|
select(Folder).where(
|
|||
|
|
and_(Folder.id == folder_data.parent_id, Folder.user_id == current_user.id)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
if not result.scalar_one_or_none():
|
|||
|
|
raise HTTPException(status_code=404, detail="父文件夹不存在")
|
|||
|
|
|
|||
|
|
# 检查同名文件夹
|
|||
|
|
result = await db.execute(
|
|||
|
|
select(Folder).where(
|
|||
|
|
and_(
|
|||
|
|
Folder.user_id == current_user.id,
|
|||
|
|
Folder.parent_id == folder_data.parent_id,
|
|||
|
|
Folder.name == folder_data.name
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
if result.scalar_one_or_none():
|
|||
|
|
raise HTTPException(status_code=400, detail="同名文件夹已存在")
|
|||
|
|
|
|||
|
|
folder = Folder(
|
|||
|
|
user_id=current_user.id,
|
|||
|
|
name=folder_data.name,
|
|||
|
|
parent_id=folder_data.parent_id
|
|||
|
|
)
|
|||
|
|
db.add(folder)
|
|||
|
|
await db.commit()
|
|||
|
|
await db.refresh(folder)
|
|||
|
|
return folder
|
|||
|
|
|
|||
|
|
@router.put("/{folder_id}", response_model=FolderOut)
|
|||
|
|
async def rename_folder(
|
|||
|
|
folder_id: str,
|
|||
|
|
folder_data: FolderUpdate,
|
|||
|
|
db: AsyncSession = Depends(get_db),
|
|||
|
|
current_user: User = Depends(get_current_user)
|
|||
|
|
):
|
|||
|
|
"""重命名文件夹"""
|
|||
|
|
result = await db.execute(
|
|||
|
|
select(Folder).where(
|
|||
|
|
and_(Folder.id == folder_id, Folder.user_id == current_user.id)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
folder = result.scalar_one_or_none()
|
|||
|
|
if not folder:
|
|||
|
|
raise HTTPException(status_code=404, detail="文件夹不存在")
|
|||
|
|
|
|||
|
|
folder.name = folder_data.name
|
|||
|
|
await db.commit()
|
|||
|
|
await db.refresh(folder)
|
|||
|
|
return folder
|
|||
|
|
|
|||
|
|
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|||
|
|
async def delete_folder(
|
|||
|
|
folder_id: str,
|
|||
|
|
db: AsyncSession = Depends(get_db),
|
|||
|
|
current_user: User = Depends(get_current_user)
|
|||
|
|
):
|
|||
|
|
"""删除文件夹(级联删除文档)"""
|
|||
|
|
from app.models.document import Document
|
|||
|
|
from app.services.knowledge_service import KnowledgeService
|
|||
|
|
|
|||
|
|
result = await db.execute(
|
|||
|
|
select(Folder).where(
|
|||
|
|
and_(Folder.id == folder_id, Folder.user_id == current_user.id)
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
folder = result.scalar_one_or_none()
|
|||
|
|
if not folder:
|
|||
|
|
raise HTTPException(status_code=404, detail="文件夹不存在")
|
|||
|
|
|
|||
|
|
async def delete_recursive(fid: str):
|
|||
|
|
# 删除子文件夹(先递归)
|
|||
|
|
children = await db.execute(
|
|||
|
|
select(Folder).where(Folder.parent_id == fid)
|
|||
|
|
)
|
|||
|
|
for child in children.scalars():
|
|||
|
|
await delete_recursive(child.id)
|
|||
|
|
|
|||
|
|
# 删除文档
|
|||
|
|
docs = await db.execute(
|
|||
|
|
select(Document).where(Document.folder_id == fid)
|
|||
|
|
)
|
|||
|
|
for doc in docs.scalars():
|
|||
|
|
knowledge_service = KnowledgeService(db, current_user.id)
|
|||
|
|
await knowledge_service.delete_from_vectorstore(current_user.id, doc.id)
|
|||
|
|
await db.delete(doc)
|
|||
|
|
|
|||
|
|
# 删除文件夹本身
|
|||
|
|
folder_to_delete = await db.get(Folder, fid)
|
|||
|
|
if folder_to_delete:
|
|||
|
|
await db.delete(folder_to_delete)
|
|||
|
|
|
|||
|
|
await delete_recursive(folder_id)
|
|||
|
|
await db.commit()
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 提交**
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 4: 修改 Document 模型
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `backend/app/models/document.py:14`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 添加 folder_id 外键**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/app/models/document.py 第14行后添加
|
|||
|
|
folder_id = Column(String(36), ForeignKey("folders.id"), nullable=True) # 新增
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 提交**
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 5: 修改 Document 路由和服务
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `backend/app/routers/document.py`
|
|||
|
|
- Modify: `backend/app/services/document_service.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 修改 Document 路由**
|
|||
|
|
|
|||
|
|
在 `routers/document.py` 中:
|
|||
|
|
- GET `/api/documents` 添加 `folder_id` 可选查询参数
|
|||
|
|
- POST `/api/documents` 添加 `folder_id` 表单字段
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# GET /api/documents 修改
|
|||
|
|
@router.get("")
|
|||
|
|
async def list_documents(
|
|||
|
|
folder_id: Optional[str] = None, # 新增
|
|||
|
|
db: AsyncSession = Depends(get_db),
|
|||
|
|
current_user: User = Depends(get_current_user)
|
|||
|
|
):
|
|||
|
|
query = select(Document).where(Document.user_id == current_user.id)
|
|||
|
|
if folder_id:
|
|||
|
|
query = query.where(Document.folder_id == folder_id)
|
|||
|
|
# ... 其余不变
|
|||
|
|
|
|||
|
|
# POST /api/documents 修改
|
|||
|
|
@router.post("")
|
|||
|
|
async def upload_document(
|
|||
|
|
file: UploadFile = File(...),
|
|||
|
|
folder_id: Optional[str] = Form(None), # 新增
|
|||
|
|
db: AsyncSession = Depends(get_db),
|
|||
|
|
current_user: User = Depends(get_current_user)
|
|||
|
|
):
|
|||
|
|
# ... 文件处理逻辑 ...
|
|||
|
|
doc = await doc_svc.upload_document(
|
|||
|
|
user_id=current_user.id,
|
|||
|
|
file=file,
|
|||
|
|
folder_id=folder_id # 传递 folder_id
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 修改 DocumentService.upload_document**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/app/services/document_service.py
|
|||
|
|
|
|||
|
|
async def upload_document(
|
|||
|
|
self,
|
|||
|
|
user_id: str,
|
|||
|
|
file: UploadFile,
|
|||
|
|
folder_id: str | None = None, # 新增
|
|||
|
|
) -> Document:
|
|||
|
|
# ... 文件保存逻辑 ...
|
|||
|
|
|
|||
|
|
# 获取文件夹路径(用于 ChromaDB metadata)
|
|||
|
|
folder_path = None
|
|||
|
|
if folder_id:
|
|||
|
|
folder_path = await self._get_folder_path(folder_id)
|
|||
|
|
|
|||
|
|
# 创建文档记录
|
|||
|
|
doc = Document(
|
|||
|
|
user_id=user_id,
|
|||
|
|
title=filename.rsplit('.', 1)[0],
|
|||
|
|
filename=filename,
|
|||
|
|
file_type=file_type,
|
|||
|
|
file_size=file_size,
|
|||
|
|
file_path=file_path,
|
|||
|
|
folder_id=folder_id, # 新增
|
|||
|
|
)
|
|||
|
|
# ... 其余逻辑 ...
|
|||
|
|
return doc
|
|||
|
|
|
|||
|
|
async def _get_folder_path(self, folder_id: str) -> str | None:
|
|||
|
|
"""获取文件夹的完整路径"""
|
|||
|
|
folders = await self.db.execute(
|
|||
|
|
select(Folder).where(Folder.user_id == self.user_id)
|
|||
|
|
)
|
|||
|
|
folder_map = {f.id: f for f in folders.scalars().all()}
|
|||
|
|
|
|||
|
|
path_parts = []
|
|||
|
|
current_id = folder_id
|
|||
|
|
while current_id:
|
|||
|
|
folder = folder_map.get(current_id)
|
|||
|
|
if not folder:
|
|||
|
|
break
|
|||
|
|
path_parts.insert(0, folder.name)
|
|||
|
|
current_id = folder.parent_id
|
|||
|
|
|
|||
|
|
return "/" + "/".join(path_parts) if path_parts else None
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 提交**
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 6: 修改 Knowledge Service
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `backend/app/services/knowledge_service.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: retrieve 方法添加 folder_id 参数**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# backend/app/services/knowledge_service.py
|
|||
|
|
|
|||
|
|
# 在 index_document 方法中添加 folder_path 到 metadata
|
|||
|
|
async def index_document(self, document_id: str, user_id: str, folder_path: str | None = None):
|
|||
|
|
"""将文档 chunks 向量化存入 ChromaDB"""
|
|||
|
|
# ... 现有代码 ...
|
|||
|
|
|
|||
|
|
metadatas = [
|
|||
|
|
{
|
|||
|
|
"document_id": doc.id,
|
|||
|
|
"document_title": doc.title,
|
|||
|
|
"chunk_index": chunk.chunk_index,
|
|||
|
|
"file_type": doc.file_type,
|
|||
|
|
"folder_path": folder_path or "", # 新增
|
|||
|
|
}
|
|||
|
|
for chunk in chunks
|
|||
|
|
]
|
|||
|
|
# ... 其余不变
|
|||
|
|
|
|||
|
|
async def retrieve(
|
|||
|
|
self,
|
|||
|
|
query: str,
|
|||
|
|
user_id: str,
|
|||
|
|
folder_id: str | None = None, # 新增
|
|||
|
|
top_k: int = 5,
|
|||
|
|
use_rerank: bool = True,
|
|||
|
|
) -> list[SearchResult]:
|
|||
|
|
"""混合检索 + Rerank,支持按文件夹过滤"""
|
|||
|
|
collection = self.get_collection(user_id)
|
|||
|
|
|
|||
|
|
# 构建过滤条件
|
|||
|
|
where = None
|
|||
|
|
if folder_id:
|
|||
|
|
folder_path = await self._get_folder_path(folder_id)
|
|||
|
|
if folder_path:
|
|||
|
|
where = {"folder_path": {"$starts_with": folder_path}}
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
results = collection.query(
|
|||
|
|
query_texts=[query],
|
|||
|
|
n_results=top_k * 3,
|
|||
|
|
where=where,
|
|||
|
|
include=["documents", "metadatas", "distances"],
|
|||
|
|
)
|
|||
|
|
except Exception:
|
|||
|
|
return []
|
|||
|
|
# ... 其余不变
|
|||
|
|
|
|||
|
|
async def _get_folder_path(self, folder_id: str) -> str | None:
|
|||
|
|
"""获取文件夹的完整路径"""
|
|||
|
|
result = await self.db.execute(
|
|||
|
|
select(Folder).where(Folder.id == folder_id)
|
|||
|
|
)
|
|||
|
|
folder = result.scalar_one_or_none()
|
|||
|
|
if not folder:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
path_parts = [folder.name]
|
|||
|
|
current_parent_id = folder.parent_id
|
|||
|
|
|
|||
|
|
while current_parent_id:
|
|||
|
|
parent_result = await self.db.execute(
|
|||
|
|
select(Folder).where(Folder.id == current_parent_id)
|
|||
|
|
)
|
|||
|
|
parent = parent_result.scalar_one_or_none()
|
|||
|
|
if not parent:
|
|||
|
|
break
|
|||
|
|
path_parts.insert(0, parent.name)
|
|||
|
|
current_parent_id = parent.parent_id
|
|||
|
|
|
|||
|
|
return "/" + "/".join(path_parts)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 提交**
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 7: 数据库迁移
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- 数据库操作: 添加 folders 表,添加 documents.folder_id 列
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 执行 SQL 迁移**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# 迁移脚本
|
|||
|
|
import asyncio
|
|||
|
|
from app.database import engine
|
|||
|
|
from sqlalchemy import text
|
|||
|
|
|
|||
|
|
async def migrate():
|
|||
|
|
async with engine.begin() as conn:
|
|||
|
|
# 创建 folders 表
|
|||
|
|
await conn.execute(text("""
|
|||
|
|
CREATE TABLE IF NOT EXISTS folders (
|
|||
|
|
id VARCHAR(36) PRIMARY KEY,
|
|||
|
|
name VARCHAR(255) NOT NULL,
|
|||
|
|
parent_id VARCHAR(36),
|
|||
|
|
user_id VARCHAR(36) NOT NULL,
|
|||
|
|
created_at DATETIME NOT NULL,
|
|||
|
|
updated_at DATETIME NOT NULL,
|
|||
|
|
FOREIGN KEY (parent_id) REFERENCES folders(id),
|
|||
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|||
|
|
)
|
|||
|
|
"""))
|
|||
|
|
|
|||
|
|
# 添加 documents.folder_id 列
|
|||
|
|
try:
|
|||
|
|
await conn.execute(text("ALTER TABLE documents ADD COLUMN folder_id VARCHAR(36)"))
|
|||
|
|
except Exception:
|
|||
|
|
pass # 列已存在
|
|||
|
|
|
|||
|
|
asyncio.run(migrate())
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 提交**
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 8: 前端 - 创建 Folder API
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `frontend/src/api/folder.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建文件夹 API 客户端**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// frontend/src/api/folder.ts
|
|||
|
|
import api from './index'
|
|||
|
|
|
|||
|
|
export interface FolderCreate {
|
|||
|
|
name: string
|
|||
|
|
parent_id?: string | null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface FolderUpdate {
|
|||
|
|
name: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface FolderItem {
|
|||
|
|
id: string
|
|||
|
|
name: string
|
|||
|
|
parent_id: string | null
|
|||
|
|
created_at: string
|
|||
|
|
updated_at: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface FolderTree {
|
|||
|
|
id: string
|
|||
|
|
name: string
|
|||
|
|
parent_id: string | null
|
|||
|
|
children: FolderTree[]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const folderApi = {
|
|||
|
|
// 获取文件夹树
|
|||
|
|
getTree() {
|
|||
|
|
return api.get<FolderTree[]>('/api/folders')
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 创建文件夹
|
|||
|
|
create(data: FolderCreate) {
|
|||
|
|
return api.post('/api/folders', data)
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 重命名文件夹
|
|||
|
|
rename(id: string, data: FolderUpdate) {
|
|||
|
|
return api.put(`/api/folders/${id}`, data)
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 删除文件夹
|
|||
|
|
delete(id: string) {
|
|||
|
|
return api.delete(`/api/folders/${id}`)
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 提交**
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 9: 前端 - 修改 Document API
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `frontend/src/api/document.ts`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 添加 folder_id 到 Document 类型**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// frontend/src/api/document.ts
|
|||
|
|
|
|||
|
|
export interface Document {
|
|||
|
|
id: string
|
|||
|
|
title: string
|
|||
|
|
filename: string
|
|||
|
|
file_type: string
|
|||
|
|
file_size: number
|
|||
|
|
file_path: string
|
|||
|
|
summary?: string
|
|||
|
|
chunk_count: number
|
|||
|
|
is_indexed: boolean
|
|||
|
|
folder_id?: string | null // 新增
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 提交**
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 10: 前端 - 创建 FolderTree 组件
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `frontend/src/components/FolderTree.vue`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建递归文件夹树组件**
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<!-- frontend/src/components/FolderTree.vue -->
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, computed } from 'vue'
|
|||
|
|
import { folderApi, type FolderTree } from '@/api/folder'
|
|||
|
|
import { Folder, FolderOpen, ChevronRight, MoreVertical, Plus, Edit2, Trash2 } from 'lucide-vue-next'
|
|||
|
|
|
|||
|
|
const props = defineProps<{
|
|||
|
|
folders: FolderTree[]
|
|||
|
|
selectedId?: string | null
|
|||
|
|
onSelect: (folder: FolderTree) => void
|
|||
|
|
onCreate: (parentId: string | null) => void
|
|||
|
|
onRename: (folder: FolderTree) => void
|
|||
|
|
onDelete: (folder: FolderTree) => void
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
const expandedIds = ref<Set<string>>(new Set())
|
|||
|
|
|
|||
|
|
function toggleExpand(id: string) {
|
|||
|
|
if (expandedIds.value.has(id)) {
|
|||
|
|
expandedIds.value.delete(id)
|
|||
|
|
} else {
|
|||
|
|
expandedIds.value.add(id)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleContextMenu(e: MouseEvent, folder: FolderTree) {
|
|||
|
|
e.preventDefault()
|
|||
|
|
// 显示右键菜单
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="folder-tree">
|
|||
|
|
<div
|
|||
|
|
v-for="folder in folders"
|
|||
|
|
:key="folder.id"
|
|||
|
|
class="folder-item"
|
|||
|
|
>
|
|||
|
|
<div
|
|||
|
|
class="folder-row"
|
|||
|
|
:class="{ selected: folder.id === selectedId }"
|
|||
|
|
@click="props.onSelect(folder)"
|
|||
|
|
@contextmenu="handleContextMenu($event, folder)"
|
|||
|
|
>
|
|||
|
|
<!-- 展开/折叠箭头 -->
|
|||
|
|
<button
|
|||
|
|
v-if="folder.children?.length"
|
|||
|
|
class="expand-btn"
|
|||
|
|
@click.stop="toggleExpand(folder.id)"
|
|||
|
|
>
|
|||
|
|
<ChevronRight
|
|||
|
|
:size="12"
|
|||
|
|
:class="{ rotated: expandedIds.has(folder.id) }"
|
|||
|
|
/>
|
|||
|
|
</button>
|
|||
|
|
<span v-else class="expand-placeholder"></span>
|
|||
|
|
|
|||
|
|
<!-- 文件夹图标 -->
|
|||
|
|
<FolderOpen v-if="expandedIds.has(folder.id)" :size="14" class="folder-icon" />
|
|||
|
|
<Folder v-else :size="14" class="folder-icon" />
|
|||
|
|
|
|||
|
|
<!-- 文件夹名称 -->
|
|||
|
|
<span class="folder-name">{{ folder.name }}</span>
|
|||
|
|
|
|||
|
|
<!-- 操作按钮 -->
|
|||
|
|
<div class="folder-actions">
|
|||
|
|
<button @click.stop="props.onCreate(folder.id)" title="添加子文件夹">
|
|||
|
|
<Plus :size="12" />
|
|||
|
|
</button>
|
|||
|
|
<button @click.stop="props.onRename(folder)" title="重命名">
|
|||
|
|
<Edit2 :size="12" />
|
|||
|
|
</button>
|
|||
|
|
<button @click.stop="props.onDelete(folder)" title="删除">
|
|||
|
|
<Trash2 :size="12" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 子文件夹(递归) -->
|
|||
|
|
<div
|
|||
|
|
v-if="folder.children?.length && expandedIds.has(folder.id)"
|
|||
|
|
class="folder-children"
|
|||
|
|
>
|
|||
|
|
<FolderTree
|
|||
|
|
:folders="folder.children"
|
|||
|
|
:selected-id="selectedId"
|
|||
|
|
:on-select="onSelect"
|
|||
|
|
:on-create="onCreate"
|
|||
|
|
:on-rename="onRename"
|
|||
|
|
:on-delete="onDelete"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
/* sci-fi 风格 */
|
|||
|
|
.folder-tree {
|
|||
|
|
font-family: var(--font-mono);
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.folder-row {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
padding: 6px 8px;
|
|||
|
|
border-radius: var(--radius-sm);
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all var(--transition-fast);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.folder-row:hover {
|
|||
|
|
background: rgba(0, 245, 212, 0.04);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.folder-row.selected {
|
|||
|
|
background: var(--accent-cyan-dim);
|
|||
|
|
border: 1px solid rgba(0, 245, 212, 0.2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.expand-btn {
|
|||
|
|
background: none;
|
|||
|
|
border: none;
|
|||
|
|
padding: 0;
|
|||
|
|
cursor: pointer;
|
|||
|
|
color: var(--text-dim);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.expand-placeholder {
|
|||
|
|
width: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.folder-icon {
|
|||
|
|
color: var(--accent-amber);
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.folder-name {
|
|||
|
|
flex: 1;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.folder-actions {
|
|||
|
|
display: none;
|
|||
|
|
gap: 2px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.folder-row:hover .folder-actions {
|
|||
|
|
display: flex;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.folder-actions button {
|
|||
|
|
background: none;
|
|||
|
|
border: none;
|
|||
|
|
padding: 2px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
color: var(--text-dim);
|
|||
|
|
border-radius: 3px;
|
|||
|
|
transition: all var(--transition-fast);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.folder-actions button:hover {
|
|||
|
|
color: var(--accent-cyan);
|
|||
|
|
background: rgba(0, 245, 212, 0.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.folder-children {
|
|||
|
|
padding-left: 16px;
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 提交**
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 11: 前端 - 改造 KnowledgeView
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `frontend/src/views/KnowledgeView.vue`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 添加文件夹侧边栏和交互逻辑**
|
|||
|
|
|
|||
|
|
主要改动:
|
|||
|
|
- 导入 FolderTree 组件
|
|||
|
|
- 添加文件夹状态和加载逻辑
|
|||
|
|
- 修改上传逻辑(需先选择文件夹)
|
|||
|
|
- 添加新建/重命名/删除文件夹的弹窗
|
|||
|
|
|
|||
|
|
```vue
|
|||
|
|
<!-- KnowledgeView.vue 核心逻辑改动 -->
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
// ... 现有代码 ...
|
|||
|
|
|
|||
|
|
// 新增
|
|||
|
|
import { folderApi, type FolderTree, type FolderCreate } from '@/api/folder'
|
|||
|
|
import FolderTreeComponent from '@/components/FolderTree.vue'
|
|||
|
|
import { Plus, Edit2, Trash2 } from 'lucide-vue-next'
|
|||
|
|
|
|||
|
|
// 状态
|
|||
|
|
const folders = ref<FolderTree[]>([])
|
|||
|
|
const selectedFolderId = ref<string | null>(null)
|
|||
|
|
|
|||
|
|
// 加载文件夹树
|
|||
|
|
async function loadFolders() {
|
|||
|
|
const res = await folderApi.getTree()
|
|||
|
|
folders.value = res.data
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 选择文件夹
|
|||
|
|
function onSelectFolder(folder: FolderTree) {
|
|||
|
|
selectedFolderId.value = folder.id
|
|||
|
|
loadDocumentsByFolder(folder.id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 加载指定文件夹的文档
|
|||
|
|
async function loadDocumentsByFolder(folderId: string) {
|
|||
|
|
const res = await documentApi.list(folderId)
|
|||
|
|
documents.value = res.data
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 新建文件夹弹窗
|
|||
|
|
const showNewFolderDialog = ref(false)
|
|||
|
|
const newFolderName = ref('')
|
|||
|
|
const newFolderParentId = ref<string | null>(null)
|
|||
|
|
|
|||
|
|
function openNewFolderDialog(parentId: string | null = null) {
|
|||
|
|
newFolderParentId.value = parentId
|
|||
|
|
newFolderName.value = ''
|
|||
|
|
showNewFolderDialog.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function createFolder() {
|
|||
|
|
await folderApi.create({
|
|||
|
|
name: newFolderName.value,
|
|||
|
|
parent_id: newFolderParentId.value
|
|||
|
|
})
|
|||
|
|
await loadFolders()
|
|||
|
|
showNewFolderDialog.value = false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 重命名/删除类似...
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="knowledge-view">
|
|||
|
|
<!-- Header -->
|
|||
|
|
<div class="page-header">
|
|||
|
|
<div class="header-left">
|
|||
|
|
<Database :size="20" />
|
|||
|
|
<h1>KNOWLEDGE BASE</h1>
|
|||
|
|
</div>
|
|||
|
|
<div class="header-actions">
|
|||
|
|
<button class="btn" @click="openNewFolderDialog(null)">
|
|||
|
|
<FolderPlus :size="14" /> 新建文件夹
|
|||
|
|
</button>
|
|||
|
|
<button class="btn primary" @click="triggerUpload">
|
|||
|
|
<Upload :size="14" /> 上传文档
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 主布局:侧边栏 + 内容 -->
|
|||
|
|
<div class="main-layout">
|
|||
|
|
<!-- 左侧文件夹树 -->
|
|||
|
|
<aside class="folder-sidebar">
|
|||
|
|
<div class="sidebar-header">
|
|||
|
|
<Folder :size="14" />
|
|||
|
|
<span>文件夹</span>
|
|||
|
|
<button class="add-btn" @click="openNewFolderDialog(null)">
|
|||
|
|
<Plus :size="12" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="folder-list">
|
|||
|
|
<FolderTreeComponent
|
|||
|
|
:folders="folders"
|
|||
|
|
:selected-id="selectedFolderId"
|
|||
|
|
:on-select="onSelectFolder"
|
|||
|
|
:on-create="openNewFolderDialog"
|
|||
|
|
:on-rename="openRenameDialog"
|
|||
|
|
:on-delete="openDeleteDialog"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</aside>
|
|||
|
|
|
|||
|
|
<!-- 右侧内容区 -->
|
|||
|
|
<main class="content-area">
|
|||
|
|
<!-- 搜索栏 -->
|
|||
|
|
<div class="search-panel">...</div>
|
|||
|
|
|
|||
|
|
<!-- 上传区 -->
|
|||
|
|
<div class="upload-zone">...</div>
|
|||
|
|
|
|||
|
|
<!-- 文档列表 -->
|
|||
|
|
<div class="docs-section">...</div>
|
|||
|
|
</main>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 新建文件夹弹窗 -->
|
|||
|
|
<div v-if="showNewFolderDialog" class="dialog-overlay">
|
|||
|
|
<div class="dialog">
|
|||
|
|
<h3>新建文件夹</h3>
|
|||
|
|
<input v-model="newFolderName" placeholder="文件夹名称" @keyup.enter="createFolder" />
|
|||
|
|
<div class="dialog-actions">
|
|||
|
|
<button @click="showNewFolderDialog = false">取消</button>
|
|||
|
|
<button class="primary" @click="createFolder">创建</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 提交**
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 12: 集成测试
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 测试文件夹 CRUD**
|
|||
|
|
- [ ] **Step 2: 测试级联删除**
|
|||
|
|
- [ ] **Step 3: 测试上传文档到指定文件夹**
|
|||
|
|
- [ ] **Step 4: 测试按文件夹搜索**
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 总结
|
|||
|
|
|
|||
|
|
共 12 个 Task,分 4 个 Phase:
|
|||
|
|
- **Phase 1**: 数据层 (Task 1-4)
|
|||
|
|
- **Phase 2**: 后端 API (Task 5-7)
|
|||
|
|
- **Phase 3**: 前端 (Task 8-11)
|
|||
|
|
- **Phase 4**: 测试 (Task 12)
|