diff --git a/frontend/src/api/document.ts b/frontend/src/api/document.ts index 8b12a60..2b7549d 100644 --- a/frontend/src/api/document.ts +++ b/frontend/src/api/document.ts @@ -98,9 +98,9 @@ export function batchToggleDocuments(ids: string[], enabled: boolean): Promise r.data) } -/** 批量移动分类 */ -export function batchMoveDocuments(ids: string[], categoryId: string): Promise { - return request.post('/document/batch/move', { ids, categoryId }).then(r => r.data) +/** 批量移动(分类根或目录,folderId 为空/0 表示移动到分类根) */ +export function batchMoveDocuments(ids: string[], categoryId: string, folderId?: string): Promise { + return request.post('/document/batch/move', { ids, categoryId, folderId }).then(r => r.data) } /** 更新单个分块内容 */ diff --git a/frontend/src/views/DocList.vue b/frontend/src/views/DocList.vue index a89d473..6fb56e0 100644 --- a/frontend/src/views/DocList.vue +++ b/frontend/src/views/DocList.vue @@ -59,8 +59,7 @@ 批量重新处理 批量启用 批量禁用 - - 移动 + 移动 取消选择 @@ -96,6 +95,7 @@ 查看 下载 + 移动 重新处理 删除 @@ -116,6 +116,27 @@ 保存 + + + +
选择目标位置(分类根或目录):
+ + +
@@ -150,9 +171,12 @@ const filterStatus = ref('') const keyword = ref('') const filterTag = ref('') const selectedIds = ref(new Set()) -const moveCategoryId = ref('') const hasProcessing = ref(false) +// 移动文档弹窗状态 +const moveDialog = ref({ visible: false, ids: [] as string[], target: null as any }) +const moveActiveKeys = ref([]) + // 目录树相关状态 const treeData = ref([]) const activedKeys = ref([]) @@ -206,14 +230,19 @@ const columns = [ { colKey: 'enabled', title: '启用', width: 60 }, { colKey: 'chunkCount', title: '分块', width: 60, sorter: true }, { colKey: 'createTime', title: '创建时间', width: 160, sorter: true, cell: (_:any,{row}:any) => formatDate(row.createTime) }, - { colKey: 'op', title: '操作', width: 200 }, + { colKey: 'op', title: '操作', width: 240 }, ] // ==================== 选项 ==================== const categoryOptions = computed(() => [{ label: '全部分类', value: '' }, ...categoryStore.categories.map(c => ({ label: c.name, value: String(c.id) }))]) const statusOptions = [{ label: '全部状态', value: '' }, { label: '已完成', value: 'READY' }, { label: '处理中', value: 'PROCESSING' }, { label: '失败', value: 'FAILED' }] const tagOptions = computed(() => [{ label: '全部标签', value: '' }, ...(documentStore.tags || []).map((t: any) => ({ label: `${t.tag} (${t.count})`, value: t.tag }))]) -const moveCategoryOptions = computed(() => [{ label: '移动到分类...', value: '' }, ...categoryStore.categories.map(c => ({ label: c.name, value: String(c.id) })), { label: '未分类', value: '0' }]) +// 移动弹窗目录树:确保有「未分类」节点,保留移出到未分类的能力 +const moveTreeData = computed(() => { + const hasUnclassified = treeData.value.some(n => n.value === 'cat:0') + if (hasUnclassified) return treeData.value + return [{ value: 'cat:0', label: '未分类', nodeType: 'category', categoryId: '0', children: [] }, ...treeData.value] +}) // ==================== 工具函数 ==================== function getDocTags(doc: any): string[] { @@ -502,18 +531,37 @@ async function batchDisable() { } catch (e: any) { toast('批量禁用失败:' + e.message, 'error') } } -async function batchMove() { - const ids = Array.from(selectedIds.value) - const catId = moveCategoryId.value - if (!catId && catId !== '0') { toast('请选择目标分类', 'error'); return } - const catName = catId === '0' ? '未分类' : categoryStore.getCategoryName(catId) - if (!await confirm(`确定将选中的 ${ids.length} 个文档移动到「${catName}」?`)) return +function openMove(ids: string[]) { + moveDialog.value = { visible: true, ids, target: null } + moveActiveKeys.value = [] +} + +function onMoveTreeChange(_value: any[], ctx: any) { + moveDialog.value.target = ctx?.node?.data ?? null +} + +async function confirmMove() { + const t = moveDialog.value.target + if (!t) { toast('请选择目标位置', 'error'); return } + // 目标为目录 → folderId=目录ID、categoryId=目录所属分类;目标为分类根 → folderId=0、categoryId=分类 + const folderId = t.nodeType === 'folder' ? String(t.folderId ?? '0') : '0' + const categoryId = String(t.categoryId ?? '0') + const ids = moveDialog.value.ids try { - // 雪花 ID 精度:0 保持为字符串 0,其余保持字符串 - const json = await batchMoveDocuments(ids, catId) - if (json.success) { toast(json.message, 'success'); selectedIds.value = new Set(); moveCategoryId.value = ''; loadData(); documentStore.loadStats() } - else toast(json.message || '批量移动失败', 'error') - } catch (e: any) { toast('批量移动失败:' + e.message, 'error') } + const json = await batchMoveDocuments(ids, categoryId, folderId) + if (json.success) { + toast(json.message, 'success') + moveDialog.value.visible = false + moveActiveKeys.value = [] + selectedIds.value = new Set() + loadData() + documentStore.loadStats() + } else { + toast(json.message || '移动失败', 'error') + } + } catch (e: any) { + toast('移动失败:' + e.message, 'error') + } } // 初始化 @@ -553,4 +601,5 @@ loadData() .tree-node:hover .tree-ops { display: inline-flex; } .dialog-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; } +.move-tree-tip { font-size: 12px; color: var(--td-text-color-placeholder); margin-bottom: 8px; } diff --git a/src/main/java/com/wok/supportbot/controller/DocumentController.java b/src/main/java/com/wok/supportbot/controller/DocumentController.java index 4058951..54265e2 100644 --- a/src/main/java/com/wok/supportbot/controller/DocumentController.java +++ b/src/main/java/com/wok/supportbot/controller/DocumentController.java @@ -623,27 +623,28 @@ public class DocumentController { } /** - * P1-2.2: 批量移动文档分类 - * body: {ids: [Long], categoryId: Long | String} - * 注意:categoryId 兼容 String 类型,避免前端雪花 ID 经 parseInt 丢失精度 + * 统一移动文档:目标为「分类根」或「具体目录」 + * body: {ids: [Long], categoryId: Long|String, folderId: Long|String} + * 注意:categoryId/folderId 兼容 String 类型,避免前端雪花 ID 经 parseInt 丢失精度 */ @PostMapping("/document/batch/move") @PreAuthorize("hasAnyRole('admin','kb_operator')") public ResponseEntity> batchMoveDocuments(@RequestBody Map body) { try { List ids = extractIds(body); - Long categoryId = extractCategoryId(body); + Long categoryId = extractLong(body, "categoryId"); + Long folderId = extractLong(body, "folderId"); if (ids.isEmpty()) { return ResponseEntity.badRequest().body(Map.of( "success", false, "message", "请提供文档ID列表" )); } - int updatedCount = documentService.batchMoveDocuments(ids, categoryId); + int updatedCount = documentService.moveDocuments(ids, categoryId, folderId); int requestedCount = ids.size(); String message; if (updatedCount == requestedCount) { - message = String.format("已将 %d 个文档移动到目标分类", updatedCount); + message = String.format("已将 %d 个文档移动到目标位置", updatedCount); } else { message = String.format("移动完成:成功 %d 个,%d 个文档未找到", updatedCount, requestedCount - updatedCount); @@ -656,27 +657,28 @@ public class DocumentController { } catch (Exception e) { return ResponseEntity.status(500).body(Map.of( "success", false, - "message", "批量移动失败:" + e.getMessage() + "message", "移动失败:" + e.getMessage() )); } } /** - * 从请求体中提取 categoryId,兼容 Number 和 String 两种类型 + * 从请求体中提取指定 key 的 Long 值,兼容 Number 和 String 两种类型 * 前端雪花 ID 超出 JS Number.MAX_SAFE_INTEGER,需以 String 传递以避免精度丢失 + * 缺失或空字符串时返回 null(由调用方按未指定处理) */ - private Long extractCategoryId(Map body) { - Object raw = body.get("categoryId"); + private Long extractLong(Map body, String key) { + Object raw = body.get(key); if (raw == null) { - return 0L; + return null; } if (raw instanceof Number) { return ((Number) raw).longValue(); } if (raw instanceof String str) { - return str.isEmpty() ? 0L : Long.parseLong(str); + return str.isEmpty() ? null : Long.parseLong(str); } - return 0L; + return null; } // ==================== 语义搜索 ==================== diff --git a/src/main/java/com/wok/supportbot/service/DocumentService.java b/src/main/java/com/wok/supportbot/service/DocumentService.java index 65b4e84..84a6250 100644 --- a/src/main/java/com/wok/supportbot/service/DocumentService.java +++ b/src/main/java/com/wok/supportbot/service/DocumentService.java @@ -810,28 +810,67 @@ public class DocumentService { jdbcTemplate.update(sql, enabled ? "true" : "false", documentId); } - // ==================== 批量移动分类 ==================== + // ==================== 移动文档(分类根 / 目录) ==================== /** - * P1-2.2: 批量移动文档到目标分类 - * 仅更新 category_id,不影响向量数据 + * 统一移动文档:目标既可以是「分类根」(folderId=0),也可以是「具体目录」(folderId>0)。 + *

约定与上传一致:目标为目录时,强制使用目录所属分类覆盖 categoryId; + * 目标为分类根时,使用传入的 categoryId(null/0 表示未分类)。

+ *

categoryId 变更时同步 vector_store 的 categoryId metadata,保证 RAG 分类过滤正确。

*/ @Transactional(rollbackFor = Exception.class) - public int batchMoveDocuments(List ids, Long categoryId) { - Long targetCategoryId = categoryId != null ? categoryId : 0L; + public int moveDocuments(List ids, Long categoryId, Long folderId) { + Long targetFolderId = (folderId != null && folderId > 0) ? folderId : 0L; + Long targetCategoryId; + if (targetFolderId > 0) { + KnowledgeFolder folder = folderService.getFolderById(targetFolderId); + if (folder == null) { + throw new RuntimeException("目标目录不存在"); + } + targetCategoryId = folder.getCategoryId(); + } else { + targetCategoryId = (categoryId != null) ? categoryId : 0L; + } + int updated = 0; for (Long id : ids) { KnowledgeDocument doc = documentMapper.selectById(id); - if (doc != null) { - doc.setCategoryId(targetCategoryId); - documentMapper.updateById(doc); - updated++; + if (doc == null) { + continue; } + Long oldCategoryId = doc.getCategoryId(); + doc.setFolderId(targetFolderId); + doc.setCategoryId(targetCategoryId); + documentMapper.updateById(doc); + // 分类变更时同步向量 metadata 的 categoryId(folderId 不参与 RAG,无需同步) + if (oldCategoryId == null || !oldCategoryId.equals(targetCategoryId)) { + syncVectorCategoryMetadata(String.valueOf(id), targetCategoryId); + } + updated++; } - log.info("批量移动文档分类: ids={}, categoryId={}, 实际更新={}", ids, targetCategoryId, updated); + log.info("移动文档: ids={}, folderId={}, categoryId={}, 实际更新={}", ids, targetFolderId, targetCategoryId, updated); return updated; } + /** + * 同步 vector_store 中指定文档所有分块的 categoryId metadata。 + *

与 {@link #syncVectorEnabledMetadata} 同构:metadata 列是 json 类型,用 jsonb 运算后转回 json。 + * categoryId 在 DocumentProcessingService 中以字符串写入,故同步时写字符串; + * 目标为未分类(categoryId<=0)时移除该键(原写入逻辑仅在 categoryId>0 时写键)。

+ */ + private void syncVectorCategoryMetadata(String documentId, Long categoryId) { + if (categoryId == null || categoryId <= 0) { + jdbcTemplate.update( + "UPDATE vector_store SET metadata = (metadata::jsonb - 'categoryId')::json WHERE metadata->>'documentId' = ?", + documentId); + } else { + jdbcTemplate.update( + "UPDATE vector_store SET metadata = (jsonb_set(metadata::jsonb, '{categoryId}', " + + "to_jsonb(?::text)))::json WHERE metadata->>'documentId' = ?", + String.valueOf(categoryId), documentId); + } + } + // ==================== 标签管理 ==================== /**