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)
|