feat(knowledge): 添加知识库文件目录结构支持功能

- 在 KnowledgeItem 模型中增加 relativePath 字段存储相对路径
- 实现文件上传时的目录前缀处理和相对路径构建逻辑
- 添加批量删除知识条目的接口和实现方法
- 重构前端 KnowledgeSetDetail 组件以支持目录浏览和管理
- 实现文件夹创建、删除、导航等目录操作功能
- 更新数据查询逻辑以支持按相对路径进行搜索和过滤
- 添加前端文件夹图标显示和目录层级展示功能
This commit is contained in:
2026-01-31 17:45:43 +08:00
parent c1fb02b0f5
commit 310bc356b1
15 changed files with 664 additions and 123 deletions

View File

@@ -22,6 +22,7 @@ import com.datamate.datamanagement.infrastructure.persistence.repository.Knowled
import com.datamate.datamanagement.infrastructure.persistence.repository.KnowledgeSetRepository;
import com.datamate.datamanagement.interfaces.converter.KnowledgeConverter;
import com.datamate.datamanagement.interfaces.dto.CreateKnowledgeItemRequest;
import com.datamate.datamanagement.interfaces.dto.DeleteKnowledgeItemsRequest;
import com.datamate.datamanagement.interfaces.dto.ImportKnowledgeItemsRequest;
import com.datamate.datamanagement.interfaces.dto.KnowledgeItemPagingQuery;
import com.datamate.datamanagement.interfaces.dto.KnowledgeItemResponse;
@@ -78,6 +79,7 @@ public class KnowledgeItemApplicationService {
private static final int MAX_TITLE_LENGTH = 200;
private static final String KNOWLEDGE_ITEM_UPLOAD_DIR = "knowledge-items";
private static final String DEFAULT_FILE_EXTENSION = "bin";
private static final String PATH_SEPARATOR = "/";
private final KnowledgeItemRepository knowledgeItemRepository;
private final KnowledgeSetRepository knowledgeSetRepository;
@@ -112,6 +114,7 @@ public class KnowledgeItemApplicationService {
List<MultipartFile> files = request.getFiles();
BusinessAssert.isTrue(CollectionUtils.isNotEmpty(files), CommonErrorCode.PARAM_ERROR);
String parentPrefix = normalizeRelativePathPrefix(request.getParentPrefix());
Path uploadRoot = resolveUploadRootPath();
Path setDir = uploadRoot.resolve(KNOWLEDGE_ITEM_UPLOAD_DIR).resolve(setId).normalize();
@@ -145,6 +148,7 @@ public class KnowledgeItemApplicationService {
knowledgeItem.setContentType(KnowledgeContentType.FILE);
knowledgeItem.setSourceType(KnowledgeSourceType.FILE_UPLOAD);
knowledgeItem.setSourceFileId(trimToLength(safeOriginalName, MAX_TITLE_LENGTH));
knowledgeItem.setRelativePath(buildRelativePath(parentPrefix, safeOriginalName));
items.add(knowledgeItem);
}
@@ -182,6 +186,22 @@ public class KnowledgeItemApplicationService {
knowledgeItemRepository.removeById(itemId);
}
public void deleteKnowledgeItems(String setId, DeleteKnowledgeItemsRequest request) {
BusinessAssert.notNull(request, CommonErrorCode.PARAM_ERROR);
List<String> ids = request.getIds();
BusinessAssert.isTrue(CollectionUtils.isNotEmpty(ids), CommonErrorCode.PARAM_ERROR);
List<KnowledgeItem> items = knowledgeItemRepository.listByIds(ids);
BusinessAssert.isTrue(CollectionUtils.isNotEmpty(items), DataManagementErrorCode.KNOWLEDGE_ITEM_NOT_FOUND);
BusinessAssert.isTrue(items.size() == ids.size(), DataManagementErrorCode.KNOWLEDGE_ITEM_NOT_FOUND);
boolean allMatch = items.stream().allMatch(item -> Objects.equals(item.getSetId(), setId));
BusinessAssert.isTrue(allMatch, CommonErrorCode.PARAM_ERROR);
List<String> deleteIds = items.stream().map(KnowledgeItem::getId).toList();
knowledgeItemRepository.removeByIds(deleteIds);
}
@Transactional(readOnly = true)
public KnowledgeItem getKnowledgeItem(String setId, String itemId) {
KnowledgeItem knowledgeItem = knowledgeItemRepository.getById(itemId);
@@ -256,6 +276,7 @@ public class KnowledgeItemApplicationService {
knowledgeItem.setSourceType(KnowledgeSourceType.DATASET_FILE);
knowledgeItem.setSourceDatasetId(dataset.getId());
knowledgeItem.setSourceFileId(datasetFile.getId());
knowledgeItem.setRelativePath(resolveDatasetFileRelativePath(dataset, datasetFile));
items.add(knowledgeItem);
}
@@ -418,6 +439,7 @@ public class KnowledgeItemApplicationService {
knowledgeItem.setContentType(KnowledgeContentType.FILE);
knowledgeItem.setSourceType(KnowledgeSourceType.FILE_UPLOAD);
knowledgeItem.setSourceFileId(sourceFileId);
knowledgeItem.setRelativePath(resolveReplacedRelativePath(knowledgeItem.getRelativePath(), sourceFileId));
knowledgeItemRepository.updateById(knowledgeItem);
deleteFile(oldFilePath);
} catch (Exception e) {
@@ -540,6 +562,84 @@ public class KnowledgeItemApplicationService {
return relativePath.replace(File.separatorChar, '/');
}
private String buildRelativePath(String parentPrefix, String fileName) {
String safeName = sanitizeFileName(fileName);
if (StringUtils.isBlank(safeName)) {
safeName = "file";
}
String normalizedPrefix = normalizeRelativePathPrefix(parentPrefix);
return normalizedPrefix + safeName;
}
private String normalizeRelativePathPrefix(String prefix) {
if (StringUtils.isBlank(prefix)) {
return "";
}
String normalized = prefix.replace("\\", PATH_SEPARATOR).trim();
while (normalized.startsWith(PATH_SEPARATOR)) {
normalized = normalized.substring(1);
}
while (normalized.endsWith(PATH_SEPARATOR)) {
normalized = normalized.substring(0, normalized.length() - 1);
}
BusinessAssert.isTrue(!normalized.contains(".."), CommonErrorCode.PARAM_ERROR);
if (StringUtils.isBlank(normalized)) {
return "";
}
return normalized + PATH_SEPARATOR;
}
private String normalizeRelativePathValue(String relativePath) {
if (StringUtils.isBlank(relativePath)) {
return "";
}
String normalized = relativePath.replace("\\", PATH_SEPARATOR).trim();
while (normalized.startsWith(PATH_SEPARATOR)) {
normalized = normalized.substring(1);
}
while (normalized.endsWith(PATH_SEPARATOR)) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
private String resolveDatasetFileRelativePath(Dataset dataset, DatasetFile datasetFile) {
if (datasetFile == null) {
return "";
}
String fileName = StringUtils.defaultIfBlank(datasetFile.getFileName(), datasetFile.getId());
String datasetPath = dataset == null ? null : dataset.getPath();
String filePath = datasetFile.getFilePath();
if (StringUtils.isBlank(datasetPath) || StringUtils.isBlank(filePath)) {
return buildRelativePath("", fileName);
}
try {
Path datasetRoot = Paths.get(datasetPath).toAbsolutePath().normalize();
Path targetPath = Paths.get(filePath).toAbsolutePath().normalize();
if (targetPath.startsWith(datasetRoot)) {
Path relative = datasetRoot.relativize(targetPath);
String relativeValue = relative.toString().replace(File.separatorChar, '/');
String normalized = normalizeRelativePathValue(relativeValue);
if (!normalized.contains("..") && StringUtils.isNotBlank(normalized)) {
return normalized;
}
}
} catch (Exception e) {
log.warn("resolve dataset file relative path failed, fileId: {}", datasetFile.getId(), e);
}
return buildRelativePath("", fileName);
}
private String resolveReplacedRelativePath(String existingRelativePath, String newFileName) {
String normalized = normalizeRelativePathValue(existingRelativePath);
if (StringUtils.isBlank(normalized)) {
return buildRelativePath("", newFileName);
}
int lastIndex = normalized.lastIndexOf(PATH_SEPARATOR);
String parentPrefix = lastIndex >= 0 ? normalized.substring(0, lastIndex + 1) : "";
return buildRelativePath(parentPrefix, newFileName);
}
private void createDirectories(Path path) {
try {
Files.createDirectories(path);

View File

@@ -38,4 +38,8 @@ public class KnowledgeItem extends BaseEntity<String> {
* 来源文件ID
*/
private String sourceFileId;
/**
* 相对路径(用于目录展示)
*/
private String relativePath;
}

View File

@@ -28,13 +28,16 @@ public interface KnowledgeItemMapper extends BaseMapper<KnowledgeItem> {
WHEN ki.source_type = 'FILE_UPLOAD' THEN ki.content
ELSE NULL
END AS content,
ki.relative_path AS relativePath,
ki.created_at AS createdAt,
ki.updated_at AS updatedAt
FROM t_dm_knowledge_items ki
LEFT JOIN t_dm_knowledge_sets ks ON ki.set_id = ks.id
LEFT JOIN t_dm_dataset_files df ON ki.source_file_id = df.id AND ki.source_type = 'DATASET_FILE'
WHERE (ki.source_type = 'FILE_UPLOAD' AND ki.source_file_id LIKE CONCAT('%', #{keyword}, '%'))
OR (ki.source_type = 'DATASET_FILE' AND df.file_name LIKE CONCAT('%', #{keyword}, '%'))
WHERE (ki.source_type = 'FILE_UPLOAD' AND (ki.source_file_id LIKE CONCAT('%', #{keyword}, '%')
OR ki.relative_path LIKE CONCAT('%', #{keyword}, '%')))
OR (ki.source_type = 'DATASET_FILE' AND (df.file_name LIKE CONCAT('%', #{keyword}, '%')
OR ki.relative_path LIKE CONCAT('%', #{keyword}, '%')))
ORDER BY ki.created_at DESC
""")
IPage<KnowledgeItemSearchResponse> searchFileItems(IPage<?> page, @Param("keyword") String keyword);

View File

@@ -21,21 +21,26 @@ import java.util.List;
@Repository
@RequiredArgsConstructor
public class KnowledgeItemRepositoryImpl extends CrudRepository<KnowledgeItemMapper, KnowledgeItem> implements KnowledgeItemRepository {
private static final String PATH_SEPARATOR = "/";
private final KnowledgeItemMapper knowledgeItemMapper;
@Override
public IPage<KnowledgeItem> findByCriteria(IPage<KnowledgeItem> page, KnowledgeItemPagingQuery query) {
String relativePath = normalizeRelativePathPrefix(query.getRelativePath());
LambdaQueryWrapper<KnowledgeItem> wrapper = new LambdaQueryWrapper<KnowledgeItem>()
.eq(StringUtils.isNotBlank(query.getSetId()), KnowledgeItem::getSetId, query.getSetId())
.eq(query.getContentType() != null, KnowledgeItem::getContentType, query.getContentType())
.eq(query.getSourceType() != null, KnowledgeItem::getSourceType, query.getSourceType())
.eq(StringUtils.isNotBlank(query.getSourceDatasetId()), KnowledgeItem::getSourceDatasetId, query.getSourceDatasetId())
.eq(StringUtils.isNotBlank(query.getSourceFileId()), KnowledgeItem::getSourceFileId, query.getSourceFileId());
.eq(StringUtils.isNotBlank(query.getSourceFileId()), KnowledgeItem::getSourceFileId, query.getSourceFileId())
.likeRight(StringUtils.isNotBlank(relativePath), KnowledgeItem::getRelativePath, relativePath);
if (StringUtils.isNotBlank(query.getKeyword())) {
wrapper.and(w -> w.like(KnowledgeItem::getSourceFileId, query.getKeyword())
.or()
.like(KnowledgeItem::getContent, query.getKeyword()));
.like(KnowledgeItem::getContent, query.getKeyword())
.or()
.like(KnowledgeItem::getRelativePath, query.getKeyword()));
}
wrapper.orderByDesc(KnowledgeItem::getCreatedAt);
@@ -77,4 +82,21 @@ public class KnowledgeItemRepositoryImpl extends CrudRepository<KnowledgeItemMap
public Long sumDatasetFileSize() {
return knowledgeItemMapper.sumDatasetFileSize();
}
private String normalizeRelativePathPrefix(String relativePath) {
if (StringUtils.isBlank(relativePath)) {
return "";
}
String normalized = relativePath.replace("\\", PATH_SEPARATOR).trim();
while (normalized.startsWith(PATH_SEPARATOR)) {
normalized = normalized.substring(1);
}
if (StringUtils.isBlank(normalized)) {
return "";
}
if (!normalized.endsWith(PATH_SEPARATOR)) {
normalized = normalized + PATH_SEPARATOR;
}
return normalized;
}
}

View File

@@ -0,0 +1,20 @@
package com.datamate.datamanagement.interfaces.dto;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 批量删除知识条目请求
*/
@Getter
@Setter
public class DeleteKnowledgeItemsRequest {
/**
* 知识条目ID列表
*/
@NotEmpty(message = "知识条目ID不能为空")
private List<String> ids;
}

View File

@@ -41,4 +41,8 @@ public class KnowledgeItemPagingQuery extends PagingQuery {
* 来源文件ID
*/
private String sourceFileId;
/**
* 相对路径前缀
*/
private String relativePath;
}

View File

@@ -20,6 +20,10 @@ public class KnowledgeItemResponse {
private KnowledgeSourceType sourceType;
private String sourceDatasetId;
private String sourceFileId;
/**
* 相对路径(用于目录展示)
*/
private String relativePath;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String createdBy;

View File

@@ -23,6 +23,10 @@ public class KnowledgeItemSearchResponse {
private String sourceFileId;
private String fileName;
private Long fileSize;
/**
* 相对路径(用于目录展示)
*/
private String relativePath;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;

View File

@@ -17,4 +17,8 @@ public class UploadKnowledgeItemsRequest {
*/
@NotEmpty(message = "文件列表不能为空")
private List<MultipartFile> files;
/**
* 目录前缀(用于目录上传)
*/
private String parentPrefix;
}

View File

@@ -6,6 +6,7 @@ import com.datamate.datamanagement.application.KnowledgeItemApplicationService;
import com.datamate.datamanagement.domain.model.knowledge.KnowledgeItem;
import com.datamate.datamanagement.interfaces.converter.KnowledgeConverter;
import com.datamate.datamanagement.interfaces.dto.CreateKnowledgeItemRequest;
import com.datamate.datamanagement.interfaces.dto.DeleteKnowledgeItemsRequest;
import com.datamate.datamanagement.interfaces.dto.ImportKnowledgeItemsRequest;
import com.datamate.datamanagement.interfaces.dto.KnowledgeItemPagingQuery;
import com.datamate.datamanagement.interfaces.dto.KnowledgeItemResponse;
@@ -108,4 +109,10 @@ public class KnowledgeItemController {
@PathVariable("itemId") String itemId) {
knowledgeItemApplicationService.deleteKnowledgeItem(setId, itemId);
}
@PostMapping("/batch-delete")
public void deleteKnowledgeItems(@PathVariable("setId") String setId,
@RequestBody @Valid DeleteKnowledgeItemsRequest request) {
knowledgeItemApplicationService.deleteKnowledgeItems(setId, request);
}
}

View File

@@ -6,6 +6,7 @@ import {
Card,
Descriptions,
Empty,
Input,
Modal,
Table,
Tooltip,
@@ -14,9 +15,9 @@ import { DeleteOutlined, DownloadOutlined, EditOutlined, EyeOutlined, PlusOutlin
import { useNavigate, useParams } from "react-router";
import DetailHeader from "@/components/DetailHeader";
import { SearchControls } from "@/components/SearchControls";
import useFetchData from "@/hooks/useFetchData";
import {
deleteKnowledgeItemByIdUsingDelete,
deleteKnowledgeItemsByIdsUsingPost,
deleteKnowledgeSetByIdUsingDelete,
downloadKnowledgeItemFileUsingGet,
exportKnowledgeItemsUsingGet,
@@ -41,6 +42,7 @@ import CreateKnowledgeSet from "../components/CreateKnowledgeSet";
import KnowledgeItemEditor from "../components/KnowledgeItemEditor";
import ImportKnowledgeItemsDialog from "../components/ImportKnowledgeItemsDialog";
import { formatDate } from "@/utils/unit";
import { File, Folder } from "lucide-react";
import {
PREVIEW_TEXT_MAX_LENGTH,
resolvePreviewFileType,
@@ -57,9 +59,35 @@ const PREVIEW_TEXT_FONT_SIZE = 12;
const PREVIEW_TEXT_PADDING = 12;
const PREVIEW_AUDIO_PADDING = 40;
type KnowledgeItemRow = KnowledgeItemView & {
isDirectory?: boolean;
displayName?: string;
fullPath?: string;
fileCount?: number;
};
const PATH_SEPARATOR = "/";
const normalizePath = (value?: string) => (value ?? "").replace(/\\/g, PATH_SEPARATOR);
const normalizePrefix = (value?: string) => {
const trimmed = normalizePath(value).replace(/^\/+/, "").trim();
if (!trimmed) {
return "";
}
return trimmed.endsWith(PATH_SEPARATOR) ? trimmed : `${trimmed}${PATH_SEPARATOR}`;
};
const splitRelativePath = (fullPath: string, prefix: string) => {
if (prefix && !fullPath.startsWith(prefix)) {
return [];
}
const remainder = fullPath.slice(prefix.length);
return remainder.split(PATH_SEPARATOR).filter(Boolean);
};
const KnowledgeSetDetail = () => {
const navigate = useNavigate();
const { message } = App.useApp();
const { message, modal } = App.useApp();
const { id } = useParams<{ id: string }>();
const [knowledgeSet, setKnowledgeSet] = useState<KnowledgeSet | null>(null);
const [showEdit, setShowEdit] = useState(false);
@@ -76,33 +104,72 @@ const KnowledgeSetDetail = () => {
const [previewMediaUrl, setPreviewMediaUrl] = useState("");
const [previewLoadingItemId, setPreviewLoadingItemId] = useState<string | null>(null);
const [filePrefix, setFilePrefix] = useState("");
const [fileKeyword, setFileKeyword] = useState("");
const [itemsLoading, setItemsLoading] = useState(false);
const [allItems, setAllItems] = useState<KnowledgeItemView[]>([]);
const [filePagination, setFilePagination] = useState({
current: 1,
pageSize: 10,
});
const fetchKnowledgeSet = useCallback(async () => {
if (!id) return;
const { data } = await queryKnowledgeSetByIdUsingGet(id);
setKnowledgeSet(data);
}, [id]);
const fetchItems = useCallback(async () => {
if (!id) {
setAllItems([]);
return;
}
setItemsLoading(true);
try {
const pageSize = 200;
let page = 1;
let combined: KnowledgeItemView[] = [];
const currentPrefix = normalizePrefix(filePrefix);
const keyword = fileKeyword.trim();
while (true) {
const { data } = await queryKnowledgeItemsUsingGet(id, {
page,
size: pageSize,
...(currentPrefix ? { relativePath: currentPrefix } : {}),
...(keyword ? { keyword } : {}),
});
const content = Array.isArray(data?.content) ? data.content : [];
combined = combined.concat(content.map(mapKnowledgeItem));
if (content.length < pageSize) {
break;
}
if (typeof data?.totalElements === "number" && combined.length >= data.totalElements) {
break;
}
page += 1;
}
setAllItems(combined);
} catch (error) {
console.error("加载知识条目失败", error);
message.error("知识条目加载失败");
} finally {
setItemsLoading(false);
}
}, [fileKeyword, filePrefix, id, message]);
useEffect(() => {
fetchKnowledgeSet();
}, [fetchKnowledgeSet]);
const {
loading,
tableData: items,
searchParams,
pagination,
fetchData,
setSearchParams,
handleFiltersChange,
handleKeywordChange,
} = useFetchData<KnowledgeItemView>(
(params) => (id ? queryKnowledgeItemsUsingGet(id, params) : Promise.resolve({ data: [] })),
mapKnowledgeItem,
30000,
false,
[],
0
);
useEffect(() => {
if (id) {
fetchItems();
}
}, [id, fetchItems]);
useEffect(() => {
setFilePagination((prev) => ({ ...prev, current: 1 }));
}, [filePrefix, fileKeyword]);
const isReadOnly =
knowledgeSet?.status === KnowledgeStatusType.ARCHIVED ||
@@ -119,7 +186,7 @@ const KnowledgeSetDetail = () => {
if (!id) return;
await deleteKnowledgeItemByIdUsingDelete(id, item.id);
message.success("知识条目已删除");
fetchData();
fetchItems();
};
const handleExportItems = async () => {
@@ -141,7 +208,35 @@ const KnowledgeSetDetail = () => {
);
};
const resolveItemRelativePath = useCallback((record: KnowledgeItemView) => {
const rawPath = record.relativePath || "";
return normalizePath(rawPath).replace(/^\/+/, "");
}, []);
const resolveDisplayName = useCallback(
(record: KnowledgeItemView) => {
const relativePath = resolveItemRelativePath(record);
if (relativePath) {
const segments = relativePath.split(PATH_SEPARATOR).filter(Boolean);
const lastSegment = segments[segments.length - 1];
if (lastSegment) {
return lastSegment;
}
}
return record.sourceFileId || "条目";
},
[resolveItemRelativePath]
);
const resolvePreviewFileName = (record: KnowledgeItemView) => {
const relativePath = resolveItemRelativePath(record);
if (relativePath) {
const segments = relativePath.split(PATH_SEPARATOR).filter(Boolean);
const lastSegment = segments[segments.length - 1];
if (lastSegment) {
return lastSegment;
}
}
if (record.sourceFileId) {
return record.sourceFileId;
}
@@ -258,7 +353,7 @@ const KnowledgeSetDetail = () => {
key: "items",
icon: <PlusOutlined className="text-blue-500" />,
label: "条目数",
value: pagination.total || 0,
value: allItems.length,
},
{
key: "updated",
@@ -267,9 +362,148 @@ const KnowledgeSetDetail = () => {
value: knowledgeSet?.updatedAt ? formatDate(knowledgeSet.updatedAt) : "-",
},
],
[pagination.total, knowledgeSet?.updatedAt]
[allItems.length, knowledgeSet?.updatedAt]
);
const handleKeywordChange = (keyword: string) => {
setFileKeyword(keyword);
};
const handleOpenDirectory = (directoryName: string) => {
const currentPrefix = normalizePrefix(filePrefix);
const nextPrefix = normalizePrefix(`${currentPrefix}${directoryName}`);
setFilePrefix(nextPrefix);
};
const handleBackToParent = () => {
const currentPrefix = normalizePrefix(filePrefix);
if (!currentPrefix) {
return;
}
const trimmed = currentPrefix.replace(/\/$/, "");
const parts = trimmed.split(PATH_SEPARATOR).filter(Boolean);
parts.pop();
const parentPrefix = parts.length ? `${parts.join(PATH_SEPARATOR)}${PATH_SEPARATOR}` : "";
setFilePrefix(parentPrefix);
};
const isInvalidDirectoryName = (value: string) =>
!value || value.includes("/") || value.includes("\\") || value.includes("..");
const handleDeleteDirectory = async (directoryName: string) => {
if (!id) {
return;
}
const currentPrefix = normalizePrefix(filePrefix);
const directoryPrefix = normalizePrefix(`${currentPrefix}${directoryName}`);
const targetIds = allItems
.filter((item) => {
const fullPath = resolveItemRelativePath(item);
return fullPath.startsWith(directoryPrefix);
})
.map((item) => item.id);
if (targetIds.length === 0) {
message.info("该文件夹为空");
return;
}
try {
await deleteKnowledgeItemsByIdsUsingPost(id, { ids: targetIds });
message.success(`已删除 ${targetIds.length} 个条目`);
fetchItems();
} catch (error) {
console.error("删除文件夹失败", error);
message.error("文件夹删除失败");
}
};
const normalizedPrefix = useMemo(() => normalizePrefix(filePrefix), [filePrefix]);
const { rows: itemRows, total: itemTotal } = useMemo(() => {
const folderMap = new Map<string, { name: string; fileCount: number }>();
const fileItems: KnowledgeItemRow[] = [];
allItems.forEach((item) => {
const fullPath = resolveItemRelativePath(item);
if (!fullPath) {
if (!normalizedPrefix) {
const displayName = resolveDisplayName(item);
fileItems.push({
...item,
displayName,
fullPath: displayName,
});
}
return;
}
const segments = splitRelativePath(fullPath, normalizedPrefix);
if (segments.length === 0) {
return;
}
const leafName = segments[0];
if (segments.length > 1) {
const entry = folderMap.get(leafName) || {
name: leafName,
fileCount: 0,
};
entry.fileCount += 1;
folderMap.set(leafName, entry);
return;
}
fileItems.push({
...item,
displayName: leafName,
fullPath,
});
});
const folderItems: KnowledgeItemRow[] = Array.from(folderMap.values()).map((entry) => ({
id: `directory-${normalizedPrefix}${entry.name}`,
setId: id || "",
content: "",
contentType: KnowledgeContentType.FILE,
status: null,
rawStatus: KnowledgeStatusType.DRAFT,
tags: [],
createdAt: "",
updatedAt: "",
sourceType: KnowledgeSourceType.FILE_UPLOAD,
sourceDatasetId: "",
sourceFileId: "",
metadata: "",
isDirectory: true,
displayName: entry.name,
fullPath: `${normalizedPrefix}${entry.name}/`,
fileCount: entry.fileCount,
}));
const sortByName = (a: KnowledgeItemRow, b: KnowledgeItemRow) =>
(a.displayName || "").localeCompare(b.displayName || "", "zh-Hans-CN");
folderItems.sort(sortByName);
fileItems.sort(sortByName);
const combined = [...folderItems, ...fileItems];
return { rows: combined, total: combined.length };
}, [allItems, id, normalizedPrefix, resolveDisplayName, resolveItemRelativePath]);
const pageCurrent = filePagination.current;
const pageSize = filePagination.pageSize;
const pagedItemRows = useMemo(() => {
const startIndex = (pageCurrent - 1) * pageSize;
const endIndex = startIndex + pageSize;
return itemRows.slice(startIndex, endIndex);
}, [itemRows, pageCurrent, pageSize]);
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(itemTotal / pageSize));
if (pageCurrent > maxPage) {
setFilePagination((prev) => ({ ...prev, current: maxPage }));
}
}, [itemTotal, pageCurrent, pageSize]);
const itemColumns = [
{
title: "文件名",
@@ -278,14 +512,26 @@ const KnowledgeSetDetail = () => {
fixed: "left" as const,
width: 260,
ellipsis: true,
render: (_: string, record: KnowledgeItemView) => {
if (
record.contentType === KnowledgeContentType.FILE ||
record.sourceType === KnowledgeSourceType.FILE_UPLOAD
) {
return resolvePreviewFileName(record);
render: (_: string, record: KnowledgeItemRow) => {
const displayName = record.displayName || resolveDisplayName(record);
if (record.isDirectory) {
return (
<Button
type="link"
onClick={() => handleOpenDirectory(displayName)}
className="flex items-center gap-2 p-0"
>
<Folder className="w-4 h-4 text-blue-500" />
<span className="truncate">{displayName}</span>
</Button>
);
}
return record.sourceFileId || "-";
return (
<div className="flex items-center gap-2">
<File className="w-4 h-4 text-gray-800" />
<span className="truncate">{displayName}</span>
</div>
);
},
},
{
@@ -293,9 +539,15 @@ const KnowledgeSetDetail = () => {
dataIndex: "contentType",
key: "contentType",
width: 120,
render: (contentType: string) =>
knowledgeContentTypeOptions.find((opt) => opt.value === contentType)?.label ||
contentType,
render: (contentType: string, record: KnowledgeItemRow) => {
if (record.isDirectory) {
return "文件夹";
}
return (
knowledgeContentTypeOptions.find((opt) => opt.value === contentType)?.label ||
contentType
);
},
},
{
title: "来源",
@@ -303,10 +555,16 @@ const KnowledgeSetDetail = () => {
key: "sourceType",
width: 140,
ellipsis: true,
render: (sourceType: string) =>
knowledgeSourceTypeOptions.find((opt) => opt.value === sourceType)?.label ||
sourceType ||
"-",
render: (sourceType: string, record: KnowledgeItemRow) => {
if (record.isDirectory) {
return "-";
}
return (
knowledgeSourceTypeOptions.find((opt) => opt.value === sourceType)?.label ||
sourceType ||
"-"
);
},
},
{
title: "更新时间",
@@ -314,81 +572,109 @@ const KnowledgeSetDetail = () => {
key: "updatedAt",
width: 180,
ellipsis: true,
render: (value: string, record: KnowledgeItemRow) =>
record.isDirectory ? "-" : value || "-",
},
{
title: "操作",
key: "actions",
width: 200,
render: (_: unknown, record: KnowledgeItemView) => (
<div className="flex items-center gap-2">
{isReadableItem(record) && (
<Tooltip title="阅读">
render: (_: unknown, record: KnowledgeItemRow) => {
if (record.isDirectory) {
const displayName = record.displayName || record.sourceFileId || "";
return (
<Tooltip title="删除文件夹">
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => handleReadItem(record)}
loading={readItemId === record.id}
aria-label="阅读"
icon={<DeleteOutlined />}
danger
onClick={() => {
modal.confirm({
title: "确认删除该文件夹吗?",
content: `删除后将移除文件夹 “${displayName}” 下的全部条目,且无法恢复。`,
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: () => handleDeleteDirectory(displayName),
});
}}
disabled={isReadOnly}
aria-label="删除文件夹"
/>
</Tooltip>
)}
{(() => {
const isFileRecord =
record.contentType === KnowledgeContentType.FILE ||
record.sourceType === KnowledgeSourceType.FILE_UPLOAD;
return (
<>
{isFileRecord && (
<Tooltip title="预览">
);
}
return (
<div className="flex items-center gap-2">
{isReadableItem(record) && (
<Tooltip title="阅读">
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => handleReadItem(record)}
loading={readItemId === record.id}
aria-label="阅读"
/>
</Tooltip>
)}
{(() => {
const isFileRecord =
record.contentType === KnowledgeContentType.FILE ||
record.sourceType === KnowledgeSourceType.FILE_UPLOAD;
return (
<>
{isFileRecord && (
<Tooltip title="预览">
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => handlePreviewItemFile(record)}
loading={previewLoadingItemId === record.id}
aria-label="预览"
/>
</Tooltip>
)}
{isFileRecord && (
<Tooltip title="下载文件">
<Button
type="text"
icon={<DownloadOutlined />}
onClick={() => handleDownloadItem(record)}
aria-label="下载文件"
/>
</Tooltip>
)}
{isFileRecord && (
<Button
type="text"
icon={<EyeOutlined />}
onClick={() => handlePreviewItemFile(record)}
loading={previewLoadingItemId === record.id}
aria-label="预览"
icon={<EditOutlined />}
onClick={() => {
setCurrentItem({
...(record as unknown as KnowledgeItem),
status: record.rawStatus,
});
setItemEditorOpen(true);
}}
disabled={
isReadOnly ||
record.rawStatus === KnowledgeStatusType.ARCHIVED ||
record.rawStatus === KnowledgeStatusType.DEPRECATED
}
/>
</Tooltip>
)}
{isFileRecord && (
<Tooltip title="下载文件">
<Button
type="text"
icon={<DownloadOutlined />}
onClick={() => handleDownloadItem(record)}
aria-label="下载文件"
/>
</Tooltip>
)}
{isFileRecord && (
<Button
type="text"
icon={<EditOutlined />}
onClick={() => {
setCurrentItem({
...(record as unknown as KnowledgeItem),
status: record.rawStatus,
});
setItemEditorOpen(true);
}}
disabled={
isReadOnly ||
record.rawStatus === KnowledgeStatusType.ARCHIVED ||
record.rawStatus === KnowledgeStatusType.DEPRECATED
}
/>
)}
</>
);
})()}
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => handleDeleteItem(record)}
disabled={isReadOnly}
/>
</div>
),
)}
</>
);
})()}
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => handleDeleteItem(record)}
disabled={isReadOnly}
/>
</div>
);
},
},
];
@@ -442,16 +728,16 @@ const KnowledgeSetDetail = () => {
]}
/>
<CreateKnowledgeSet
showBtn={false}
isEdit={showEdit}
data={knowledgeSet}
onUpdate={() => {
setShowEdit(false);
fetchKnowledgeSet();
}}
onClose={() => setShowEdit(false)}
/>
<CreateKnowledgeSet
showBtn={false}
isEdit={showEdit}
data={knowledgeSet}
onUpdate={() => {
setShowEdit(false);
fetchKnowledgeSet();
}}
onClose={() => setShowEdit(false)}
/>
<Card className="mt-4">
<Descriptions column={2} size="small">
@@ -469,16 +755,47 @@ const KnowledgeSetDetail = () => {
<div className="flex-1 border-card p-6 mt-4">
<div className="flex items-center justify-between mb-4 gap-3">
<SearchControls
searchTerm={searchParams.keyword}
searchTerm={fileKeyword}
onSearchChange={handleKeywordChange}
searchPlaceholder="搜索文件名或内容"
searchPlaceholder="搜索文件名或目录"
filters={[]}
onFiltersChange={handleFiltersChange}
onClearFilters={() => setSearchParams({ ...searchParams, filter: { type: [], status: [], tags: [] } })}
onFiltersChange={() => {}}
onClearFilters={() => setFileKeyword("")}
showViewToggle={false}
showReload={false}
/>
<div className="flex items-center gap-2">
<Button
onClick={() => {
let dirName = "";
modal.confirm({
title: "新建文件夹",
content: (
<Input
autoFocus
placeholder="请输入文件夹名称"
onChange={(event) => {
dirName = event.target.value?.trim();
}}
/>
),
okText: "确定",
cancelText: "取消",
onOk: async () => {
if (isInvalidDirectoryName(dirName)) {
message.warning("请输入合法的文件夹名称");
return Promise.reject();
}
const currentPrefix = normalizePrefix(filePrefix);
const nextPrefix = normalizePrefix(`${currentPrefix}${dirName}`);
setFilePrefix(nextPrefix);
},
});
}}
disabled={isReadOnly}
>
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
@@ -492,21 +809,58 @@ const KnowledgeSetDetail = () => {
</Button>
<ImportKnowledgeItemsDialog
setId={id || ""}
onImported={fetchData}
onImported={fetchItems}
disabled={isReadOnly}
/>
</div>
</div>
{items.length === 0 ? (
<div className="mb-2">
{normalizedPrefix && (
<Button type="link" onClick={handleBackToParent} className="p-0">
<span className="flex items-center text-blue-500">
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
</span>
</Button>
)}
{normalizedPrefix && (
<span className="ml-2 text-gray-600">: {normalizedPrefix}</span>
)}
</div>
{itemRows.length === 0 ? (
<Empty description="暂无知识条目" />
) : (
<Table
loading={loading}
loading={itemsLoading}
columns={itemColumns}
dataSource={items}
dataSource={pagedItemRows}
rowKey="id"
pagination={pagination}
pagination={{
current: filePagination.current,
pageSize: filePagination.pageSize,
total: itemTotal,
showTotal: (total) => `${total}`,
onChange: (page, pageSize) =>
setFilePagination({
current: page,
pageSize: pageSize || filePagination.pageSize,
}),
}}
scroll={{ y: "calc(100vh - 36rem)" }}
/>
)}
@@ -516,6 +870,7 @@ const KnowledgeSetDetail = () => {
open={itemEditorOpen}
setId={id || ""}
data={currentItem}
parentPrefix={normalizedPrefix}
readOnly={isReadOnly}
onCancel={() => {
setItemEditorOpen(false);
@@ -524,7 +879,7 @@ const KnowledgeSetDetail = () => {
onSuccess={() => {
setItemEditorOpen(false);
setCurrentItem(null);
fetchData();
fetchItems();
}}
/>

View File

@@ -16,6 +16,7 @@ export default function KnowledgeItemEditor({
open,
setId,
data,
parentPrefix,
onCancel,
onSuccess,
readOnly,
@@ -23,6 +24,7 @@ export default function KnowledgeItemEditor({
open: boolean;
setId: string;
data?: Partial<KnowledgeItem> | null;
parentPrefix?: string;
readOnly?: boolean;
onCancel: () => void;
onSuccess: () => void;
@@ -102,6 +104,9 @@ export default function KnowledgeItemEditor({
formData.append("files", origin);
}
});
if (parentPrefix) {
formData.append("parentPrefix", parentPrefix);
}
await uploadKnowledgeItemsUsingPost(setId, formData);
message.success(`已创建 ${fileList.length} 个知识条目`);
} else {

View File

@@ -70,6 +70,11 @@ export function deleteKnowledgeItemByIdUsingDelete(setId: string, itemId: string
return del(`/api/data-management/knowledge-sets/${setId}/items/${itemId}`);
}
// 批量删除知识条目
export function deleteKnowledgeItemsByIdsUsingPost(setId: string, data: { ids: string[] }) {
return post(`/api/data-management/knowledge-sets/${setId}/items/batch-delete`, data);
}
// 上传知识条目文件
export function uploadKnowledgeItemsUsingPost(setId: string, data: FormData) {
return post(`/api/data-management/knowledge-sets/${setId}/items/upload`, data);

View File

@@ -106,6 +106,7 @@ export type KnowledgeItemView = {
sensitivity?: string;
sourceDatasetId?: string;
sourceFileId?: string;
relativePath?: string;
metadata?: string;
createdAt?: string;
updatedAt?: string;
@@ -153,6 +154,7 @@ export function mapKnowledgeItem(data: KnowledgeItem): KnowledgeItemView {
sensitivity: data.sensitivity,
sourceDatasetId: data.sourceDatasetId,
sourceFileId: data.sourceFileId,
relativePath: data.relativePath,
metadata: data.metadata,
createdAt: data.createdAt ? formatDateTime(data.createdAt) : "",
updatedAt: data.updatedAt ? formatDateTime(data.updatedAt) : "",

View File

@@ -61,6 +61,7 @@ export interface KnowledgeItem {
sensitivity?: string;
sourceDatasetId?: string;
sourceFileId?: string;
relativePath?: string;
metadata?: string;
createdAt?: string;
updatedAt?: string;
@@ -84,6 +85,7 @@ export interface KnowledgeItemSearchResult {
sourceFileId?: string;
fileName?: string;
fileSize?: number;
relativePath?: string;
createdAt?: string;
updatedAt?: string;
}