diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeItemApplicationService.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeItemApplicationService.java index e2f60c4..74c0ee0 100644 --- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeItemApplicationService.java +++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/application/KnowledgeItemApplicationService.java @@ -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 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 ids = request.getIds(); + BusinessAssert.isTrue(CollectionUtils.isNotEmpty(ids), CommonErrorCode.PARAM_ERROR); + + List 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 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); diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/domain/model/knowledge/KnowledgeItem.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/domain/model/knowledge/KnowledgeItem.java index 077d73f..f552300 100644 --- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/domain/model/knowledge/KnowledgeItem.java +++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/domain/model/knowledge/KnowledgeItem.java @@ -38,4 +38,8 @@ public class KnowledgeItem extends BaseEntity { * 来源文件ID */ private String sourceFileId; + /** + * 相对路径(用于目录展示) + */ + private String relativePath; } diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/mapper/KnowledgeItemMapper.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/mapper/KnowledgeItemMapper.java index 6b48c98..64972a8 100644 --- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/mapper/KnowledgeItemMapper.java +++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/mapper/KnowledgeItemMapper.java @@ -28,13 +28,16 @@ public interface KnowledgeItemMapper extends BaseMapper { 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 searchFileItems(IPage page, @Param("keyword") String keyword); diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/impl/KnowledgeItemRepositoryImpl.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/impl/KnowledgeItemRepositoryImpl.java index 7ee07a7..df5e07c 100644 --- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/impl/KnowledgeItemRepositoryImpl.java +++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/infrastructure/persistence/repository/impl/KnowledgeItemRepositoryImpl.java @@ -21,21 +21,26 @@ import java.util.List; @Repository @RequiredArgsConstructor public class KnowledgeItemRepositoryImpl extends CrudRepository implements KnowledgeItemRepository { + private static final String PATH_SEPARATOR = "/"; private final KnowledgeItemMapper knowledgeItemMapper; @Override public IPage findByCriteria(IPage page, KnowledgeItemPagingQuery query) { + String relativePath = normalizeRelativePathPrefix(query.getRelativePath()); LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .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 ids; +} diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemPagingQuery.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemPagingQuery.java index b34a84c..fae1313 100644 --- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemPagingQuery.java +++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemPagingQuery.java @@ -41,4 +41,8 @@ public class KnowledgeItemPagingQuery extends PagingQuery { * 来源文件ID */ private String sourceFileId; + /** + * 相对路径前缀 + */ + private String relativePath; } diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemResponse.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemResponse.java index 623c65c..8afbfad 100644 --- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemResponse.java +++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemResponse.java @@ -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; diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemSearchResponse.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemSearchResponse.java index b9308bd..effacc3 100644 --- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemSearchResponse.java +++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/KnowledgeItemSearchResponse.java @@ -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; diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/UploadKnowledgeItemsRequest.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/UploadKnowledgeItemsRequest.java index 5568a1d..9e96fa0 100644 --- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/UploadKnowledgeItemsRequest.java +++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/dto/UploadKnowledgeItemsRequest.java @@ -17,4 +17,8 @@ public class UploadKnowledgeItemsRequest { */ @NotEmpty(message = "文件列表不能为空") private List files; + /** + * 目录前缀(用于目录上传) + */ + private String parentPrefix; } diff --git a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/rest/KnowledgeItemController.java b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/rest/KnowledgeItemController.java index dc3e497..34663f9 100644 --- a/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/rest/KnowledgeItemController.java +++ b/backend/services/data-management-service/src/main/java/com/datamate/datamanagement/interfaces/rest/KnowledgeItemController.java @@ -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); + } } diff --git a/frontend/src/pages/KnowledgeManagement/Detail/KnowledgeSetDetail.tsx b/frontend/src/pages/KnowledgeManagement/Detail/KnowledgeSetDetail.tsx index 2ac6afa..923e6a6 100644 --- a/frontend/src/pages/KnowledgeManagement/Detail/KnowledgeSetDetail.tsx +++ b/frontend/src/pages/KnowledgeManagement/Detail/KnowledgeSetDetail.tsx @@ -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(null); const [showEdit, setShowEdit] = useState(false); @@ -76,33 +104,72 @@ const KnowledgeSetDetail = () => { const [previewMediaUrl, setPreviewMediaUrl] = useState(""); const [previewLoadingItemId, setPreviewLoadingItemId] = useState(null); + const [filePrefix, setFilePrefix] = useState(""); + const [fileKeyword, setFileKeyword] = useState(""); + const [itemsLoading, setItemsLoading] = useState(false); + const [allItems, setAllItems] = useState([]); + 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( - (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: , 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(); + 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 ( + + ); } - return record.sourceFileId || "-"; + return ( +
+ + {displayName} +
+ ); }, }, { @@ -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) => ( -
- {isReadableItem(record) && ( - + render: (_: unknown, record: KnowledgeItemRow) => { + if (record.isDirectory) { + const displayName = record.displayName || record.sourceFileId || ""; + return ( +
- ), + )} + + ); + })()} + - {items.length === 0 ? ( +
+ {normalizedPrefix && ( + + )} + {normalizedPrefix && ( + 当前路径: {normalizedPrefix} + )} +
+ + {itemRows.length === 0 ? ( ) : ( `共 ${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(); }} /> diff --git a/frontend/src/pages/KnowledgeManagement/components/KnowledgeItemEditor.tsx b/frontend/src/pages/KnowledgeManagement/components/KnowledgeItemEditor.tsx index 37aa0f3..e1bed88 100644 --- a/frontend/src/pages/KnowledgeManagement/components/KnowledgeItemEditor.tsx +++ b/frontend/src/pages/KnowledgeManagement/components/KnowledgeItemEditor.tsx @@ -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 | 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 { diff --git a/frontend/src/pages/KnowledgeManagement/knowledge-management.api.ts b/frontend/src/pages/KnowledgeManagement/knowledge-management.api.ts index 70b29a7..4930f0b 100644 --- a/frontend/src/pages/KnowledgeManagement/knowledge-management.api.ts +++ b/frontend/src/pages/KnowledgeManagement/knowledge-management.api.ts @@ -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); diff --git a/frontend/src/pages/KnowledgeManagement/knowledge-management.const.tsx b/frontend/src/pages/KnowledgeManagement/knowledge-management.const.tsx index 06b4a58..e65685e 100644 --- a/frontend/src/pages/KnowledgeManagement/knowledge-management.const.tsx +++ b/frontend/src/pages/KnowledgeManagement/knowledge-management.const.tsx @@ -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) : "", diff --git a/frontend/src/pages/KnowledgeManagement/knowledge-management.model.ts b/frontend/src/pages/KnowledgeManagement/knowledge-management.model.ts index dd9ef56..b4770c7 100644 --- a/frontend/src/pages/KnowledgeManagement/knowledge-management.model.ts +++ b/frontend/src/pages/KnowledgeManagement/knowledge-management.model.ts @@ -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; }