feat(知识库): 实现文件夹功能支持
添加文件夹创建、移动和删除功能 - 后端添加文件夹相关API和逻辑处理 - 前端实现文件夹树形展示和操作界面 - 支持文件上传到指定文件夹 - 添加拖拽移动文件功能
This commit is contained in:
parent
834202fc20
commit
192da56fca
@ -394,10 +394,17 @@ async def get_document_content(db_id: str, doc_id: str, current_user: User = Dep
|
||||
|
||||
@knowledge.delete("/databases/{db_id}/documents/{doc_id}")
|
||||
async def delete_document(db_id: str, doc_id: str, current_user: User = Depends(get_admin_user)):
|
||||
"""删除文档"""
|
||||
"""删除文档或文件夹"""
|
||||
logger.debug(f"DELETE document {doc_id} info in {db_id}")
|
||||
try:
|
||||
file_meta_info = await knowledge_base.get_file_basic_info(db_id, doc_id)
|
||||
|
||||
# Check if it is a folder
|
||||
is_folder = file_meta_info.get("meta", {}).get("is_folder", False)
|
||||
if is_folder:
|
||||
await knowledge_base.delete_folder(db_id, doc_id)
|
||||
return {"message": "文件夹删除成功"}
|
||||
|
||||
file_name = file_meta_info.get("meta", {}).get("filename")
|
||||
|
||||
# 尝试从MinIO删除文件,如果失败(例如旧知识库没有MinIO实例),则忽略
|
||||
@ -1150,6 +1157,38 @@ async def get_sample_questions(db_id: str, current_user: User = Depends(get_admi
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@knowledge.post("/databases/{db_id}/folders")
|
||||
async def create_folder(
|
||||
db_id: str,
|
||||
folder_name: str = Body(..., embed=True),
|
||||
parent_id: str | None = Body(None, embed=True),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""创建文件夹"""
|
||||
try:
|
||||
return await knowledge_base.create_folder(db_id, folder_name, parent_id)
|
||||
except Exception as e:
|
||||
logger.error(f"创建文件夹失败 {e}, {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@knowledge.put("/databases/{db_id}/documents/{doc_id}/move")
|
||||
async def move_document(
|
||||
db_id: str,
|
||||
doc_id: str,
|
||||
new_parent_id: str | None = Body(..., embed=True),
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""移动文件或文件夹"""
|
||||
logger.debug(f"Move document {doc_id} to {new_parent_id} in {db_id}")
|
||||
try:
|
||||
return await knowledge_base.move_file(db_id, doc_id, new_parent_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"移动文件失败 {e}, {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@knowledge.post("/files/upload")
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
|
||||
@ -210,6 +210,25 @@ class KnowledgeBase(ABC):
|
||||
|
||||
return {"message": "删除成功"}
|
||||
|
||||
def create_folder(self, db_id: str, folder_name: str, parent_id: str | None = None) -> dict:
|
||||
"""Create a folder in the database."""
|
||||
import uuid
|
||||
folder_id = f"folder-{uuid.uuid4()}"
|
||||
|
||||
self.files_meta[folder_id] = {
|
||||
"file_id": folder_id,
|
||||
"filename": folder_name,
|
||||
"is_folder": True,
|
||||
"parent_id": parent_id,
|
||||
"database_id": db_id,
|
||||
"created_at": utc_isoformat(),
|
||||
"status": "done",
|
||||
"path": folder_name,
|
||||
"file_type": "folder"
|
||||
}
|
||||
self._save_metadata()
|
||||
return self.files_meta[folder_id]
|
||||
|
||||
@abstractmethod
|
||||
async def add_content(self, db_id: str, items: list[str], params: dict | None = None) -> list[dict]:
|
||||
"""
|
||||
@ -300,14 +319,16 @@ class KnowledgeBase(ABC):
|
||||
if file_info.get("database_id") == db_id:
|
||||
created_at = self._normalize_timestamp(file_info.get("created_at"))
|
||||
db_files[file_id] = {
|
||||
"file_id": file_id,
|
||||
"filename": file_info.get("filename", ""),
|
||||
"path": file_info.get("path", ""),
|
||||
"type": file_info.get("file_type", ""),
|
||||
"status": file_info.get("status", "done"),
|
||||
"created_at": created_at,
|
||||
"processing_params": file_info.get("processing_params", None),
|
||||
}
|
||||
"file_id": file_id,
|
||||
"filename": file_info.get("filename", ""),
|
||||
"path": file_info.get("path", ""),
|
||||
"type": file_info.get("file_type", ""),
|
||||
"status": file_info.get("status", "done"),
|
||||
"created_at": created_at,
|
||||
"processing_params": file_info.get("processing_params", None),
|
||||
"is_folder": file_info.get("is_folder", False),
|
||||
"parent_id": file_info.get("parent_id", None),
|
||||
}
|
||||
|
||||
# 按创建时间倒序排序文件列表
|
||||
sorted_files = dict(
|
||||
@ -350,6 +371,8 @@ class KnowledgeBase(ABC):
|
||||
"type": file_info.get("file_type", ""),
|
||||
"status": file_info.get("status", "done"),
|
||||
"created_at": created_at,
|
||||
"is_folder": file_info.get("is_folder", False),
|
||||
"parent_id": file_info.get("parent_id", None),
|
||||
}
|
||||
|
||||
# 按创建时间倒序排序文件列表
|
||||
@ -439,6 +462,71 @@ class KnowledgeBase(ABC):
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking processing status for database {db_id}: {e}")
|
||||
|
||||
async def delete_folder(self, db_id: str, folder_id: str) -> None:
|
||||
"""
|
||||
Recursively delete a folder and its content.
|
||||
|
||||
Args:
|
||||
db_id: Database ID
|
||||
folder_id: Folder ID to delete
|
||||
"""
|
||||
# Find all children
|
||||
children = [
|
||||
fid for fid, meta in self.files_meta.items()
|
||||
if meta.get("database_id") == db_id and meta.get("parent_id") == folder_id
|
||||
]
|
||||
|
||||
for child_id in children:
|
||||
child_meta = self.files_meta.get(child_id)
|
||||
if child_meta and child_meta.get("is_folder"):
|
||||
await self.delete_folder(db_id, child_id)
|
||||
else:
|
||||
await self.delete_file(db_id, child_id)
|
||||
|
||||
# Delete the folder itself
|
||||
# We call delete_file which should handle the actual removal.
|
||||
# Implementations should ensure they handle folder deletion gracefully (e.g. skip vector deletion)
|
||||
await self.delete_file(db_id, folder_id)
|
||||
|
||||
async def move_file(self, db_id: str, file_id: str, new_parent_id: str | None) -> dict:
|
||||
"""
|
||||
Move a file or folder to a new parent folder.
|
||||
|
||||
Args:
|
||||
db_id: Database ID
|
||||
file_id: File/Folder ID to move
|
||||
new_parent_id: New parent folder ID (None for root)
|
||||
|
||||
Returns:
|
||||
dict: Updated metadata
|
||||
"""
|
||||
if file_id not in self.files_meta:
|
||||
raise ValueError(f"File {file_id} not found")
|
||||
|
||||
meta = self.files_meta[file_id]
|
||||
if meta.get("database_id") != db_id:
|
||||
raise ValueError(f"File {file_id} does not belong to database {db_id}")
|
||||
|
||||
# Basic cycle detection for folders
|
||||
if meta.get("is_folder") and new_parent_id:
|
||||
# Check if new_parent_id is a child of file_id (or is file_id itself)
|
||||
if new_parent_id == file_id:
|
||||
raise ValueError("Cannot move a folder into itself")
|
||||
|
||||
# Walk up the tree from new_parent_id
|
||||
current = new_parent_id
|
||||
while current:
|
||||
parent_meta = self.files_meta.get(current)
|
||||
if not parent_meta:
|
||||
break # Should not happen if integrity is maintained
|
||||
if current == file_id:
|
||||
raise ValueError("Cannot move a folder into its own subfolder")
|
||||
current = parent_meta.get("parent_id")
|
||||
|
||||
meta["parent_id"] = new_parent_id
|
||||
self._save_metadata()
|
||||
return meta
|
||||
|
||||
@abstractmethod
|
||||
async def delete_file(self, db_id: str, file_id: str) -> None:
|
||||
"""
|
||||
|
||||
@ -174,6 +174,13 @@ class KnowledgeBaseManager:
|
||||
logger.info(f"Created {kb_type} knowledge base instance")
|
||||
return kb_instance
|
||||
|
||||
async def move_file(self, db_id: str, file_id: str, new_parent_id: str | None) -> dict:
|
||||
"""
|
||||
移动文件/文件夹
|
||||
"""
|
||||
kb_instance = self._get_kb_for_database(db_id)
|
||||
return await kb_instance.move_file(db_id, file_id, new_parent_id)
|
||||
|
||||
def _get_kb_for_database(self, db_id: str) -> KnowledgeBase:
|
||||
"""
|
||||
根据数据库ID获取对应的知识库实例
|
||||
@ -220,8 +227,13 @@ class KnowledgeBaseManager:
|
||||
|
||||
return {"databases": all_databases}
|
||||
|
||||
async def create_folder(self, db_id: str, folder_name: str, parent_id: str = None) -> dict:
|
||||
"""Create a folder in the database."""
|
||||
kb_instance = self._get_kb_for_database(db_id)
|
||||
return kb_instance.create_folder(db_id, folder_name, parent_id)
|
||||
|
||||
async def create_database(
|
||||
self, database_name: str, description: str, kb_type: str, embed_info: dict | None = None, **kwargs
|
||||
self, database_name: str, description: str, kb_type: str = "lightrag", embed_info: dict | None = None, **kwargs
|
||||
) -> dict:
|
||||
"""
|
||||
创建数据库
|
||||
@ -315,6 +327,11 @@ class KnowledgeBaseManager:
|
||||
except KBNotFoundError:
|
||||
return None
|
||||
|
||||
async def delete_folder(self, db_id: str, folder_id: str) -> None:
|
||||
"""递归删除文件夹"""
|
||||
kb_instance = self._get_kb_for_database(db_id)
|
||||
await kb_instance.delete_folder(db_id, folder_id)
|
||||
|
||||
async def delete_file(self, db_id: str, file_id: str) -> None:
|
||||
"""删除文件"""
|
||||
kb_instance = self._get_kb_for_database(db_id)
|
||||
|
||||
@ -213,6 +213,7 @@ async def prepare_item_metadata(item: str, content_type: str, db_id: str, params
|
||||
"created_at": utc_isoformat(),
|
||||
"file_id": file_id,
|
||||
"content_hash": content_hash,
|
||||
"parent_id": params.get("parent_id") if params else None,
|
||||
}
|
||||
|
||||
# 保存处理参数到元数据
|
||||
|
||||
@ -74,6 +74,34 @@ export const databaseApi = {
|
||||
// =============================================================================
|
||||
|
||||
export const documentApi = {
|
||||
/**
|
||||
* 创建文件夹
|
||||
* @param {string} dbId - 知识库ID
|
||||
* @param {string} folderName - 文件夹名称
|
||||
* @param {string} parentId - 父文件夹ID
|
||||
* @returns {Promise} - 创建结果
|
||||
*/
|
||||
createFolder: async (dbId, folderName, parentId = null) => {
|
||||
return apiAdminPost(`/api/knowledge/databases/${dbId}/folders`, {
|
||||
folder_name: folderName,
|
||||
parent_id: parentId
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 移动文档/文件夹
|
||||
* @param {string} dbId - 知识库ID
|
||||
* @param {string} docId - 文档/文件夹ID
|
||||
* @param {string} newParentId - 新的父文件夹ID
|
||||
* @returns {Promise} - 移动结果
|
||||
*/
|
||||
moveDocument: async (dbId, docId, newParentId) => {
|
||||
return apiAdminPut(`/api/knowledge/databases/${dbId}/documents/${docId}/move`, {
|
||||
new_parent_id: newParentId
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* 添加文档到知识库
|
||||
* @param {string} dbId - 知识库ID
|
||||
|
||||
@ -8,6 +8,13 @@
|
||||
:loading="refreshing"
|
||||
:icon="h(PlusOutlined)"
|
||||
>添加文件</a-button>
|
||||
<a-button
|
||||
type="secondary"
|
||||
@click="showCreateFolderModal"
|
||||
:loading="refreshing"
|
||||
:icon="h(FolderOutlined)"
|
||||
style="margin-left: 8px;"
|
||||
>新建文件夹</a-button>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<a-input
|
||||
@ -100,6 +107,20 @@
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 新建文件夹模态框 -->
|
||||
<a-modal
|
||||
v-model:open="createFolderModalVisible"
|
||||
title="新建文件夹"
|
||||
:confirm-loading="createFolderLoading"
|
||||
@ok="handleCreateFolder"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="newFolderName"
|
||||
placeholder="请输入文件夹名称"
|
||||
@pressEnter="handleCreateFolder"
|
||||
/>
|
||||
</a-modal>
|
||||
|
||||
<a-table
|
||||
:columns="columnsCompact"
|
||||
:data-source="filteredFiles"
|
||||
@ -107,6 +128,8 @@
|
||||
class="my-table"
|
||||
size="small"
|
||||
:pagination="paginationCompact"
|
||||
v-model:expandedRowKeys="expandedRowKeys"
|
||||
:custom-row="customRow"
|
||||
:row-selection="{
|
||||
selectedRowKeys: selectedRowKeys,
|
||||
onChange: onSelectChange,
|
||||
@ -116,16 +139,28 @@
|
||||
emptyText: emptyText
|
||||
}">
|
||||
<template #bodyCell="{ column, text, record }">
|
||||
<a-button v-if="column.key === 'filename'" class="main-btn" type="link" @click="openFileDetail(record)">
|
||||
<component :is="getFileIcon(text)" :style="{ marginRight: '6px', color: getFileIconColor(text) }" />
|
||||
{{ text }}
|
||||
</a-button>
|
||||
<span v-else-if="column.key === 'type'" :class="['span-type', text]">{{ text?.toUpperCase() }}</span>
|
||||
<div v-if="column.key === 'filename'">
|
||||
<template v-if="record.is_folder">
|
||||
<span class="folder-row" style="padding-left: 0; color: var(--gray-900); font-weight: 500; cursor: pointer;" @click="toggleExpand(record)">
|
||||
<component :is="expandedRowKeys.includes(record.file_id) ? h(FolderOpenOutlined) : h(FolderOutlined)" style="margin-right: 6px; color: #ffb800;" />
|
||||
{{ record.filename }}
|
||||
</span>
|
||||
</template>
|
||||
<a-button v-else class="main-btn" type="link" @click="openFileDetail(record)">
|
||||
<component :is="getFileIcon(record.displayName || text)" :style="{ marginRight: '6px', color: getFileIconColor(record.displayName || text) }" />
|
||||
{{ record.displayName || text }}
|
||||
</a-button>
|
||||
</div>
|
||||
<span v-else-if="column.key === 'type'">
|
||||
<span v-if="!record.is_folder" :class="['span-type', text]">{{ text?.toUpperCase() }}</span>
|
||||
</span>
|
||||
<div v-else-if="column.key === 'status'" style="display: flex; align-items: center; justify-content: flex-end;">
|
||||
<CheckCircleFilled v-if="text === 'done'" style="color: var(--color-success-500);"/>
|
||||
<CloseCircleFilled v-else-if="text === 'failed'" style="color: var(--color-error-500);"/>
|
||||
<HourglassFilled v-else-if="text === 'processing'" style="color: var(--color-info-500);"/>
|
||||
<ClockCircleFilled v-else-if="text === 'waiting'" style="color: var(--color-warning-500);"/>
|
||||
<template v-if="!record.is_folder">
|
||||
<CheckCircleFilled v-if="text === 'done'" style="color: var(--color-success-500);"/>
|
||||
<CloseCircleFilled v-else-if="text === 'failed'" style="color: var(--color-error-500);"/>
|
||||
<HourglassFilled v-else-if="text === 'processing'" style="color: var(--color-info-500);"/>
|
||||
<ClockCircleFilled v-else-if="text === 'waiting'" style="color: var(--color-warning-500);"/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<a-tooltip v-else-if="column.key === 'created_at'" :title="formatRelativeTime(text)" placement="left">
|
||||
@ -136,18 +171,30 @@
|
||||
<a-popover placement="bottomRight" trigger="click" overlayClassName="file-action-popover">
|
||||
<template #content>
|
||||
<div class="file-action-list">
|
||||
<a-button type="text" block @click="handleDownloadFile(record)" :disabled="lock || record.status !== 'done'">
|
||||
<template #icon><component :is="h(Download)" style="width: 14px; height: 14px;" /></template>
|
||||
下载文件
|
||||
</a-button>
|
||||
<a-button v-if="!isLightRAG" type="text" block @click="handleRechunkFile(record)" :disabled="lock || record.status === 'processing' || record.status === 'waiting'">
|
||||
<template #icon><component :is="h(RefreshCw)" style="width: 14px; height: 14px;" /></template>
|
||||
重新分块
|
||||
</a-button>
|
||||
<a-button type="text" block danger @click="handleDeleteFile(record.file_id)" :disabled="lock || record.status === 'processing' || record.status === 'waiting'">
|
||||
<template #icon><component :is="h(Trash2)" style="width: 14px; height: 14px;" /></template>
|
||||
删除文件
|
||||
</a-button>
|
||||
<template v-if="record.is_folder">
|
||||
<a-button type="text" block @click="showCreateFolderModal(record.file_id)">
|
||||
<template #icon><component :is="h(PlusOutlined)" style="width: 14px; height: 14px;" /></template>
|
||||
新建子文件夹
|
||||
</a-button>
|
||||
<a-button type="text" block danger @click="handleDeleteFolder(record)">
|
||||
<template #icon><component :is="h(Trash2)" style="width: 14px; height: 14px;" /></template>
|
||||
删除文件夹
|
||||
</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-button type="text" block @click="handleDownloadFile(record)" :disabled="lock || record.status !== 'done'">
|
||||
<template #icon><component :is="h(Download)" style="width: 14px; height: 14px;" /></template>
|
||||
下载文件
|
||||
</a-button>
|
||||
<a-button v-if="!isLightRAG" type="text" block @click="handleRechunkFile(record)" :disabled="lock || record.status === 'processing' || record.status === 'waiting'">
|
||||
<template #icon><component :is="h(RefreshCw)" style="width: 14px; height: 14px;" /></template>
|
||||
重新分块
|
||||
</a-button>
|
||||
<a-button type="text" block danger @click="handleDeleteFile(record.file_id)" :disabled="lock || record.status === 'processing' || record.status === 'waiting'">
|
||||
<template #icon><component :is="h(Trash2)" style="width: 14px; height: 14px;" /></template>
|
||||
删除文件
|
||||
</a-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<a-button type="text" :icon="h(Ellipsis)" class="action-trigger-btn" />
|
||||
@ -162,7 +209,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, h } from 'vue';
|
||||
import { useDatabaseStore } from '@/stores/database';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { documentApi } from '@/apis/knowledge_api';
|
||||
import {
|
||||
@ -172,6 +219,8 @@ import {
|
||||
ClockCircleFilled,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
FolderOpenOutlined,
|
||||
FolderOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
Trash2,
|
||||
@ -208,6 +257,119 @@ const selectedRowKeys = computed({
|
||||
set: (keys) => store.selectedRowKeys = keys,
|
||||
});
|
||||
|
||||
const expandedRowKeys = ref([]);
|
||||
|
||||
// 新建文件夹相关
|
||||
const createFolderModalVisible = ref(false);
|
||||
const newFolderName = ref('');
|
||||
const createFolderLoading = ref(false);
|
||||
const currentParentId = ref(null);
|
||||
|
||||
const showCreateFolderModal = (parentId = null) => {
|
||||
newFolderName.value = '';
|
||||
// 如果是事件对象(来自顶部按钮点击),则设为null
|
||||
if (parentId && typeof parentId === 'object') {
|
||||
parentId = null;
|
||||
}
|
||||
currentParentId.value = parentId;
|
||||
createFolderModalVisible.value = true;
|
||||
};
|
||||
|
||||
const toggleExpand = (record) => {
|
||||
if (!record.is_folder) return;
|
||||
|
||||
const index = expandedRowKeys.value.indexOf(record.file_id);
|
||||
if (index > -1) {
|
||||
expandedRowKeys.value.splice(index, 1);
|
||||
} else {
|
||||
expandedRowKeys.value.push(record.file_id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (!newFolderName.value.trim()) {
|
||||
message.warning('请输入文件夹名称');
|
||||
return;
|
||||
}
|
||||
|
||||
createFolderLoading.value = true;
|
||||
try {
|
||||
await documentApi.createFolder(store.databaseId, newFolderName.value, currentParentId.value);
|
||||
message.success('创建成功');
|
||||
createFolderModalVisible.value = false;
|
||||
handleRefresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error('创建失败: ' + (error.message || '未知错误'));
|
||||
} finally {
|
||||
createFolderLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 拖拽相关逻辑
|
||||
const customRow = (record) => {
|
||||
return {
|
||||
draggable: true,
|
||||
onDragstart: (event) => {
|
||||
// 检查是否是真实文件/文件夹(存在于 store 中)
|
||||
const files = store.database?.files || {};
|
||||
if (!files[record.file_id]) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.dataTransfer.setData('application/json', JSON.stringify({
|
||||
file_id: record.file_id,
|
||||
filename: record.filename
|
||||
}));
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
// 可以设置一个更好看的拖拽图像
|
||||
},
|
||||
onDragover: (event) => {
|
||||
// 仅允许放置到真实文件夹中
|
||||
if (record.is_folder) {
|
||||
const files = store.database?.files || {};
|
||||
// 确保是真实的文件夹(有 ID 且在 store 中)
|
||||
if (files[record.file_id]) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
event.currentTarget.classList.add('drop-over-folder');
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragleave: (event) => {
|
||||
event.currentTarget.classList.remove('drop-over-folder');
|
||||
},
|
||||
onDrop: async (event) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.remove('drop-over-folder');
|
||||
|
||||
const data = event.dataTransfer.getData('application/json');
|
||||
if (!data) return;
|
||||
|
||||
try {
|
||||
const { file_id, filename } = JSON.parse(data);
|
||||
if (file_id === record.file_id) return;
|
||||
|
||||
// 确认移动
|
||||
Modal.confirm({
|
||||
title: '移动文件',
|
||||
content: `确定要将 "${filename}" 移动到 "${record.filename}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await store.moveFile(file_id, record.file_id);
|
||||
} catch (error) {
|
||||
// error handled in store
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Drop error:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// 重新分块参数配置相关
|
||||
const rechunkModalVisible = ref(false);
|
||||
const rechunkModalLoading = computed(() => store.state.chunkLoading);
|
||||
@ -232,7 +394,11 @@ const columnsCompact = [
|
||||
key: 'filename',
|
||||
ellipsis: true,
|
||||
width: undefined, // 不设置宽度,让它占据剩余空间
|
||||
sorter: (a, b) => (a.filename || '').localeCompare(b.filename || ''),
|
||||
sorter: (a, b) => {
|
||||
if (a.is_folder && !b.is_folder) return -1;
|
||||
if (!a.is_folder && b.is_folder) return 1;
|
||||
return (a.filename || '').localeCompare(b.filename || '');
|
||||
},
|
||||
sortDirections: ['ascend', 'descend']
|
||||
},
|
||||
{
|
||||
@ -266,6 +432,110 @@ const columnsCompact = [
|
||||
{ title: '', key: 'action', dataIndex: 'file_id', width: 40, align: 'center' }
|
||||
];
|
||||
|
||||
// 构建文件树
|
||||
const buildFileTree = (fileList) => {
|
||||
const nodeMap = new Map();
|
||||
const roots = [];
|
||||
const processedIds = new Set();
|
||||
|
||||
// 1. 初始化节点映射,确保 explicit folder 有 children
|
||||
fileList.forEach(file => {
|
||||
const item = { ...file, displayName: file.filename };
|
||||
if (item.is_folder && !item.children) {
|
||||
item.children = [];
|
||||
}
|
||||
nodeMap.set(item.file_id, item);
|
||||
});
|
||||
|
||||
// 2. 处理 parent_id (强关联)
|
||||
fileList.forEach(file => {
|
||||
if (file.parent_id && nodeMap.has(file.parent_id)) {
|
||||
const parent = nodeMap.get(file.parent_id);
|
||||
const child = nodeMap.get(file.file_id);
|
||||
if (parent && child) {
|
||||
if (!parent.children) parent.children = [];
|
||||
parent.children.push(child);
|
||||
processedIds.add(file.file_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. 处理剩余项 (Roots 或 路径解析)
|
||||
fileList.forEach(file => {
|
||||
if (processedIds.has(file.file_id)) return;
|
||||
|
||||
const item = nodeMap.get(file.file_id);
|
||||
const normalizedName = file.filename.replace(/\\/g, '/');
|
||||
const parts = normalizedName.split('/');
|
||||
|
||||
if (parts.length === 1) {
|
||||
// Root item
|
||||
// Check if it's an explicit folder that should merge with an existing implicit one?
|
||||
if (item.is_folder) {
|
||||
const existingIndex = roots.findIndex(n => n.is_folder && n.filename === item.filename);
|
||||
if (existingIndex !== -1) {
|
||||
const existing = roots[existingIndex];
|
||||
// Merge children from implicit to explicit
|
||||
if (existing.children && existing.children.length > 0) {
|
||||
item.children = [...(item.children || []), ...existing.children];
|
||||
}
|
||||
// Replace implicit with explicit
|
||||
roots[existingIndex] = item;
|
||||
} else {
|
||||
roots.push(item);
|
||||
}
|
||||
} else {
|
||||
roots.push(item);
|
||||
}
|
||||
} else {
|
||||
// Path based logic for files like "A/B.txt"
|
||||
let currentLevel = roots;
|
||||
let currentPath = '';
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const part = parts[i];
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||
|
||||
// Find existing node in currentLevel
|
||||
let node = currentLevel.find(n => n.filename === part && n.is_folder);
|
||||
|
||||
if (!node) {
|
||||
node = {
|
||||
file_id: `folder-${currentPath}`,
|
||||
filename: part,
|
||||
displayName: part,
|
||||
is_folder: true,
|
||||
children: [],
|
||||
created_at: file.created_at,
|
||||
status: 'done',
|
||||
};
|
||||
currentLevel.push(node);
|
||||
}
|
||||
currentLevel = node.children;
|
||||
}
|
||||
|
||||
const fileName = parts[parts.length - 1];
|
||||
item.displayName = fileName;
|
||||
currentLevel.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// 排序:文件夹在前,文件在后,按名称排序
|
||||
const sortNodes = (nodes) => {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.is_folder && !b.is_folder) return -1;
|
||||
if (!a.is_folder && b.is_folder) return 1;
|
||||
return (a.filename || '').localeCompare(b.filename || '');
|
||||
});
|
||||
nodes.forEach(node => {
|
||||
if (node.children) sortNodes(node.children);
|
||||
});
|
||||
};
|
||||
|
||||
sortNodes(roots);
|
||||
return roots;
|
||||
};
|
||||
|
||||
// 过滤后的文件列表
|
||||
const filteredFiles = computed(() => {
|
||||
let filtered = files.value;
|
||||
@ -273,12 +543,13 @@ const filteredFiles = computed(() => {
|
||||
// 应用文件名过滤
|
||||
if (filenameFilter.value.trim()) {
|
||||
const filterText = filenameFilter.value.toLowerCase().trim();
|
||||
filtered = files.value.filter(file =>
|
||||
// 搜索模式下使用扁平列表
|
||||
return files.value.filter(file =>
|
||||
file.filename && file.filename.toLowerCase().includes(filterText)
|
||||
);
|
||||
).map(f => ({ ...f, displayName: f.filename }));
|
||||
}
|
||||
|
||||
return filtered;
|
||||
return buildFileTree(filtered);
|
||||
});
|
||||
|
||||
// 空状态文本
|
||||
@ -340,12 +611,17 @@ const toggleRightPanel = () => {
|
||||
emit('toggleRightPanel');
|
||||
};
|
||||
|
||||
const onSelectChange = (keys) => {
|
||||
selectedRowKeys.value = keys;
|
||||
const onSelectChange = (keys, selectedRows) => {
|
||||
// 只保留非文件夹的文件ID
|
||||
const fileKeys = selectedRows
|
||||
.filter(row => !row.is_folder)
|
||||
.map(row => row.file_id);
|
||||
|
||||
selectedRowKeys.value = fileKeys;
|
||||
};
|
||||
|
||||
const getCheckboxProps = (record) => ({
|
||||
disabled: lock.value || record.status === 'processing' || record.status === 'waiting',
|
||||
disabled: lock.value || record.status === 'processing' || record.status === 'waiting' || record.is_folder,
|
||||
});
|
||||
|
||||
const onFilterChange = (e) => {
|
||||
@ -356,6 +632,23 @@ const handleDeleteFile = (fileId) => {
|
||||
store.handleDeleteFile(fileId);
|
||||
};
|
||||
|
||||
const handleDeleteFolder = (record) => {
|
||||
Modal.confirm({
|
||||
title: '删除文件夹',
|
||||
content: `确定要删除文件夹 "${record.filename}" 及其包含的所有内容吗?`,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await store.deleteFile(record.file_id);
|
||||
message.success('删除成功');
|
||||
} catch (error) {
|
||||
// Error handled in store but we can add extra handling if needed
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
store.handleBatchDelete();
|
||||
}
|
||||
@ -773,6 +1066,26 @@ import ChunkParamsConfig from '@/components/ChunkParamsConfig.vue';
|
||||
:deep(.ant-table-tbody > tr:hover > td) {
|
||||
background-color: var(--main-5);
|
||||
}
|
||||
|
||||
.folder-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--main-color);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.drop-over-folder) {
|
||||
background-color: var(--primary-50) !important;
|
||||
outline: 2px dashed var(--main-color);
|
||||
outline-offset: -2px;
|
||||
z-index: 10;
|
||||
|
||||
td {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less">
|
||||
|
||||
@ -44,6 +44,23 @@
|
||||
|
||||
<div class="ocr-config" v-if="uploadMode === 'file'">
|
||||
<a-form layout="horizontal">
|
||||
<a-form-item label="目标文件夹" style="margin-bottom: 12px;">
|
||||
<a-tree-select
|
||||
v-model:value="selectedFolderId"
|
||||
show-search
|
||||
style="width: 100%"
|
||||
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }"
|
||||
placeholder="请选择目标文件夹(默认为根目录)"
|
||||
allow-clear
|
||||
tree-default-expand-all
|
||||
:tree-data="folderTreeData"
|
||||
tree-node-filter-prop="title"
|
||||
>
|
||||
</a-tree-select>
|
||||
</a-form-item>
|
||||
<a-form-item style="margin-bottom: 0;">
|
||||
<a-checkbox v-model:checked="isFolderUpload">上传文件夹</a-checkbox>
|
||||
</a-form-item>
|
||||
<a-form-item label="使用OCR" name="enable_ocr">
|
||||
<div class="ocr-controls">
|
||||
<a-select
|
||||
@ -84,9 +101,11 @@
|
||||
v-model:fileList="fileList"
|
||||
name="file"
|
||||
:multiple="true"
|
||||
:directory="isFolderUpload"
|
||||
:disabled="chunkLoading"
|
||||
:accept="acceptedFileTypes"
|
||||
:before-upload="beforeUpload"
|
||||
:customRequest="customRequest"
|
||||
:action="'/api/knowledge/files/upload?db_id=' + databaseId"
|
||||
:headers="getAuthHeaders()"
|
||||
@change="handleFileUpload"
|
||||
@ -170,12 +189,44 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
folderTree: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentFolderId: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible', 'success']);
|
||||
|
||||
const store = useDatabaseStore();
|
||||
|
||||
// 文件夹选择相关
|
||||
const selectedFolderId = ref(null);
|
||||
const folderTreeData = computed(() => {
|
||||
// 转换 folderTree 数据为 TreeSelect 需要的格式
|
||||
const transformData = (nodes) => {
|
||||
return nodes.map(node => {
|
||||
if (!node.is_folder) return null;
|
||||
return {
|
||||
title: node.filename,
|
||||
value: node.file_id,
|
||||
key: node.file_id,
|
||||
children: node.children ? transformData(node.children).filter(Boolean) : []
|
||||
};
|
||||
}).filter(Boolean);
|
||||
};
|
||||
return transformData(props.folderTree);
|
||||
});
|
||||
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal) {
|
||||
selectedFolderId.value = props.currentFolderId;
|
||||
}
|
||||
});
|
||||
|
||||
const DEFAULT_SUPPORTED_TYPES = [
|
||||
'.txt',
|
||||
'.pdf',
|
||||
@ -340,6 +391,8 @@ const isGraphBased = computed(() => {
|
||||
return type === 'lightrag';
|
||||
});
|
||||
|
||||
const isFolderUpload = ref(false);
|
||||
|
||||
// 计算属性:是否启用了OCR
|
||||
const isOcrEnabled = computed(() => {
|
||||
return chunkParams.value.enable_ocr !== 'disable';
|
||||
@ -607,6 +660,59 @@ const deleteSameNameFile = (file) => {
|
||||
});
|
||||
};
|
||||
|
||||
const customRequest = async (options) => {
|
||||
const { file, onProgress, onSuccess, onError } = options;
|
||||
|
||||
const formData = new FormData();
|
||||
// 如果是文件夹上传,使用相对路径作为文件名
|
||||
const filename = (isFolderUpload.value && file.webkitRelativePath) ? file.webkitRelativePath : file.name;
|
||||
formData.append('file', file, filename);
|
||||
|
||||
const dbId = databaseId.value;
|
||||
if (!dbId) {
|
||||
onError(new Error('Database ID is missing'));
|
||||
return;
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `/api/knowledge/files/upload?db_id=${dbId}`);
|
||||
|
||||
const headers = getAuthHeaders();
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
xhr.setRequestHeader(key, value);
|
||||
}
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
onProgress({ percent: (e.loaded / e.total) * 100 });
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
onSuccess(response, xhr);
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const errorResp = JSON.parse(xhr.responseText);
|
||||
onError(new Error(errorResp.detail || 'Upload failed'));
|
||||
} catch (e) {
|
||||
onError(new Error(xhr.responseText || 'Upload failed'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = (e) => {
|
||||
onError(e);
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
};
|
||||
|
||||
const handleFileUpload = (info) => {
|
||||
if (info?.file?.status === 'error') {
|
||||
const errorMessage = info.file?.response?.detail || `文件上传失败:${info.file.name}`;
|
||||
@ -715,7 +821,19 @@ const chunkData = async () => {
|
||||
|
||||
try {
|
||||
store.state.chunkLoading = true;
|
||||
success = await store.addFiles({ items: validFiles, contentType: 'file', params: chunkParams.value });
|
||||
// 调用 store 的 addFiles 方法
|
||||
await store.addFiles({
|
||||
items: validFiles,
|
||||
contentType: 'file',
|
||||
params: { ...chunkParams.value },
|
||||
parentId: selectedFolderId.value // 传递选中的文件夹 ID
|
||||
});
|
||||
|
||||
emit('success');
|
||||
handleCancel();
|
||||
fileList.value = [];
|
||||
sameNameFiles.value = [];
|
||||
success = true;
|
||||
} catch (error) {
|
||||
console.error('文件上传失败:', error);
|
||||
message.error('文件上传失败: ' + (error.message || '未知错误'));
|
||||
|
||||
@ -277,7 +277,22 @@ export const useDatabaseStore = defineStore('database', () => {
|
||||
return hasPending;
|
||||
}
|
||||
|
||||
async function addFiles({ items, contentType, params }) {
|
||||
async function moveFile(fileId, newParentId) {
|
||||
state.lock = true;
|
||||
try {
|
||||
await documentApi.moveDocument(databaseId.value, fileId, newParentId);
|
||||
await getDatabaseInfo(undefined, true); // Skip query params for file movement
|
||||
message.success('移动成功');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(error.message || '移动失败');
|
||||
throw error;
|
||||
} finally {
|
||||
state.lock = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addFiles({ items, contentType, params, parentId }) {
|
||||
if (items.length === 0) {
|
||||
message.error(contentType === 'file' ? '请先上传文件' : '请输入有效的网页链接');
|
||||
return;
|
||||
@ -285,7 +300,11 @@ export const useDatabaseStore = defineStore('database', () => {
|
||||
|
||||
state.chunkLoading = true;
|
||||
try {
|
||||
const data = await documentApi.addDocuments(databaseId.value, items, { ...params, content_type: contentType });
|
||||
const requestParams = { ...params, content_type: contentType };
|
||||
if (parentId) {
|
||||
requestParams.parent_id = parentId;
|
||||
}
|
||||
const data = await documentApi.addDocuments(databaseId.value, items, requestParams);
|
||||
if (data.status === 'success' || data.status === 'queued') {
|
||||
const itemType = contentType === 'file' ? '文件' : 'URL';
|
||||
enableAutoRefresh('auto');
|
||||
@ -482,6 +501,7 @@ export const useDatabaseStore = defineStore('database', () => {
|
||||
deleteFile,
|
||||
handleDeleteFile,
|
||||
handleBatchDelete,
|
||||
moveFile,
|
||||
addFiles,
|
||||
rechunksFiles,
|
||||
openFileDetail,
|
||||
|
||||
@ -3,9 +3,11 @@
|
||||
<FileDetailModal />
|
||||
|
||||
<FileUploadModal
|
||||
v-model:visible="addFilesModalVisible"
|
||||
@success="onFileUploadSuccess"
|
||||
/>
|
||||
v-model:visible="addFilesModalVisible"
|
||||
:folder-tree="folderTree"
|
||||
:current-folder-id="currentFolderId"
|
||||
@success="onFileUploadSuccess"
|
||||
/>
|
||||
|
||||
<div class="unified-layout">
|
||||
<div class="left-panel" :style="{ width: leftPanelWidth + '%' }">
|
||||
@ -189,6 +191,7 @@ const resizeHandle = ref(null);
|
||||
|
||||
// 添加文件弹窗
|
||||
const addFilesModalVisible = ref(false);
|
||||
const currentFolderId = ref(null);
|
||||
|
||||
// 标记是否是初次加载
|
||||
const isInitialLoad = ref(true);
|
||||
@ -196,8 +199,53 @@ const isInitialLoad = ref(true);
|
||||
// 显示添加文件弹窗
|
||||
const showAddFilesModal = () => {
|
||||
addFilesModalVisible.value = true;
|
||||
currentFolderId.value = null; // 重置
|
||||
};
|
||||
|
||||
// 传递给 FileUploadModal 的文件夹树
|
||||
const folderTree = computed(() => {
|
||||
// 复用 FileTable 中构建文件树的逻辑,或者从 store 中获取
|
||||
// 简单起见,这里假设 store.database.files 是扁平列表,我们在 FileTable 中已经有了构建好的树
|
||||
// 但 FileTable 是子组件,最好将树的构建逻辑放到 store 或 composable 中,或者在这里重新构建
|
||||
// 既然 FileTable 中已经实现了 buildFileTree,我们可以考虑将其提取出来
|
||||
// 为了快速实现,我们这里简单实现一个仅用于选择的树构建
|
||||
const files = store.database.files || {};
|
||||
const fileList = Object.values(files);
|
||||
|
||||
// 构建树的简化版逻辑 (只关心文件夹)
|
||||
const nodeMap = new Map();
|
||||
const roots = [];
|
||||
|
||||
// 1. 初始化节点
|
||||
fileList.forEach(file => {
|
||||
if (file.is_folder) {
|
||||
const item = { ...file, title: file.filename, value: file.file_id, children: [] };
|
||||
nodeMap.set(file.file_id, item);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 构建层级
|
||||
fileList.forEach(file => {
|
||||
if (file.is_folder && file.parent_id && nodeMap.has(file.parent_id)) {
|
||||
const parent = nodeMap.get(file.parent_id);
|
||||
const child = nodeMap.get(file.file_id);
|
||||
if (parent && child) {
|
||||
parent.children.push(child);
|
||||
}
|
||||
} else if (file.is_folder && !file.parent_id) {
|
||||
// 只有显式根文件夹才放入 roots
|
||||
// 对于隐式路径生成的文件夹,目前简化处理暂不支持在上传时选择(因为它们没有物理ID)
|
||||
// 除非我们复用 FileTable 的复杂逻辑。
|
||||
// 如果用户只用新建文件夹功能创建文件夹,那么逻辑是够用的。
|
||||
if (nodeMap.has(file.file_id)) {
|
||||
roots.push(nodeMap.get(file.file_id));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return roots;
|
||||
});
|
||||
|
||||
// 文件上传成功回调
|
||||
const onFileUploadSuccess = () => {
|
||||
taskerStore.loadTasks();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user