Browse Source

feat(document): 文档列表支持移动到目录(统一分类根/目录)

- 后端 moveDocuments 统一移动:目标为目录时校验存在并强制采用目录所属分类,分类变更时同步 vector_store 的 categoryId metadata
- DocumentController 批量移动接口新增 folderId 参数(extractLong 兼容雪花 ID 字符串)
- 前端 DocList 新增单行/批量「移动」弹窗,复用目录树 t-tree 选目标,替换原「移动分类」
Spring-AI-1.1.2
wanghanlin 2 weeks ago
parent
commit
2668b8e73b
  1. 6
      frontend/src/api/document.ts
  2. 81
      frontend/src/views/DocList.vue
  3. 28
      src/main/java/com/wok/supportbot/controller/DocumentController.java
  4. 55
      src/main/java/com/wok/supportbot/service/DocumentService.java

6
frontend/src/api/document.ts

@ -98,9 +98,9 @@ export function batchToggleDocuments(ids: string[], enabled: boolean): Promise<A
return request.post('/document/batch/toggle', { ids, enabled }).then(r => r.data) return request.post('/document/batch/toggle', { ids, enabled }).then(r => r.data)
} }
/** 批量移动分类 */
export function batchMoveDocuments(ids: string[], categoryId: string): Promise<ApiResponse> {
return request.post('/document/batch/move', { ids, categoryId }).then(r => r.data)
/** 批量移动分类根或目录,folderId 为空/0 表示移动到分类根) */
export function batchMoveDocuments(ids: string[], categoryId: string, folderId?: string): Promise<ApiResponse> {
return request.post('/document/batch/move', { ids, categoryId, folderId }).then(r => r.data)
} }
/** 更新单个分块内容 */ /** 更新单个分块内容 */

81
frontend/src/views/DocList.vue

@ -59,8 +59,7 @@
<t-button variant="outline" size="small" @click="batchReprocess">批量重新处理</t-button> <t-button variant="outline" size="small" @click="batchReprocess">批量重新处理</t-button>
<t-button variant="outline" size="small" @click="batchEnable">批量启用</t-button> <t-button variant="outline" size="small" @click="batchEnable">批量启用</t-button>
<t-button variant="outline" size="small" @click="batchDisable">批量禁用</t-button> <t-button variant="outline" size="small" @click="batchDisable">批量禁用</t-button>
<t-select v-model="moveCategoryId" :options="moveCategoryOptions" placeholder="移动到分类..." size="small" style="width:140px;" />
<t-button variant="outline" size="small" @click="batchMove" :disabled="moveCategoryId === ''">移动</t-button>
<t-button variant="outline" size="small" @click="openMove(Array.from(selectedIds))">移动</t-button>
<t-button variant="text" size="small" @click="clearSelection">取消选择</t-button> <t-button variant="text" size="small" @click="clearSelection">取消选择</t-button>
</template> </template>
</div> </div>
@ -96,6 +95,7 @@
<t-space :size="2"> <t-space :size="2">
<t-button size="small" variant="text" @click="viewDetail(row.id)">查看</t-button> <t-button size="small" variant="text" @click="viewDetail(row.id)">查看</t-button>
<t-button v-show="row.filePath" size="small" variant="text" @click="download(row.id)">下载</t-button> <t-button v-show="row.filePath" size="small" variant="text" @click="download(row.id)">下载</t-button>
<t-button size="small" variant="text" @click="openMove([row.id])">移动</t-button>
<t-button size="small" variant="text" @click="reprocess(row.id)">重新处理</t-button> <t-button size="small" variant="text" @click="reprocess(row.id)">重新处理</t-button>
<t-button size="small" variant="text" theme="danger" @click="remove(row.id)">删除</t-button> <t-button size="small" variant="text" theme="danger" @click="remove(row.id)">删除</t-button>
</t-space> </t-space>
@ -116,6 +116,27 @@
<t-button theme="primary" @click="submitFolderDialog">保存</t-button> <t-button theme="primary" @click="submitFolderDialog">保存</t-button>
</div> </div>
</t-dialog> </t-dialog>
<!-- 移动文档弹窗选择目标位置分类根或目录 -->
<t-dialog v-model:visible="moveDialog.visible" header="移动文档" width="440px" :footer="false">
<div class="move-tree-tip">选择目标位置分类根或目录</div>
<t-tree
v-if="moveDialog.visible"
:data="moveTreeData"
:keys="{ value: 'value', label: 'label', children: 'children' }"
:activable="true"
:active-multiple="false"
hover
expand-all
v-model:actived="moveActiveKeys"
@active="onMoveTreeChange"
style="max-height:360px;overflow:auto;"
/>
<div class="dialog-footer">
<t-button variant="outline" @click="moveDialog.visible = false">取消</t-button>
<t-button theme="primary" :disabled="!moveDialog.target" @click="confirmMove">移动</t-button>
</div>
</t-dialog>
</t-card> </t-card>
</template> </template>
@ -150,9 +171,12 @@ const filterStatus = ref('')
const keyword = ref('') const keyword = ref('')
const filterTag = ref('') const filterTag = ref('')
const selectedIds = ref(new Set<string>()) const selectedIds = ref(new Set<string>())
const moveCategoryId = ref('')
const hasProcessing = ref(false) const hasProcessing = ref(false)
//
const moveDialog = ref({ visible: false, ids: [] as string[], target: null as any })
const moveActiveKeys = ref<string[]>([])
// //
const treeData = ref<any[]>([]) const treeData = ref<any[]>([])
const activedKeys = ref<string[]>([]) const activedKeys = ref<string[]>([])
@ -206,14 +230,19 @@ const columns = [
{ colKey: 'enabled', title: '启用', width: 60 }, { colKey: 'enabled', title: '启用', width: 60 },
{ colKey: 'chunkCount', title: '分块', width: 60, sorter: true }, { colKey: 'chunkCount', title: '分块', width: 60, sorter: true },
{ colKey: 'createTime', title: '创建时间', width: 160, sorter: true, cell: (_:any,{row}:any) => formatDate(row.createTime) }, { 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 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 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 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[] { function getDocTags(doc: any): string[] {
@ -502,18 +531,37 @@ async function batchDisable() {
} catch (e: any) { toast('批量禁用失败:' + e.message, 'error') } } 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=IDcategoryId= folderId=0categoryId=
const folderId = t.nodeType === 'folder' ? String(t.folderId ?? '0') : '0'
const categoryId = String(t.categoryId ?? '0')
const ids = moveDialog.value.ids
try { 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; } .tree-node:hover .tree-ops { display: inline-flex; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; } .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; }
</style> </style>

28
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") @PostMapping("/document/batch/move")
@PreAuthorize("hasAnyRole('admin','kb_operator')") @PreAuthorize("hasAnyRole('admin','kb_operator')")
public ResponseEntity<Map<String, Object>> batchMoveDocuments(@RequestBody Map<String, Object> body) { public ResponseEntity<Map<String, Object>> batchMoveDocuments(@RequestBody Map<String, Object> body) {
try { try {
List<Long> ids = extractIds(body); List<Long> ids = extractIds(body);
Long categoryId = extractCategoryId(body);
Long categoryId = extractLong(body, "categoryId");
Long folderId = extractLong(body, "folderId");
if (ids.isEmpty()) { if (ids.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of( return ResponseEntity.badRequest().body(Map.of(
"success", false, "success", false,
"message", "请提供文档ID列表" "message", "请提供文档ID列表"
)); ));
} }
int updatedCount = documentService.batchMoveDocuments(ids, categoryId);
int updatedCount = documentService.moveDocuments(ids, categoryId, folderId);
int requestedCount = ids.size(); int requestedCount = ids.size();
String message; String message;
if (updatedCount == requestedCount) { if (updatedCount == requestedCount) {
message = String.format("已将 %d 个文档移动到目标分类", updatedCount);
message = String.format("已将 %d 个文档移动到目标位置", updatedCount);
} else { } else {
message = String.format("移动完成:成功 %d 个,%d 个文档未找到", message = String.format("移动完成:成功 %d 个,%d 个文档未找到",
updatedCount, requestedCount - updatedCount); updatedCount, requestedCount - updatedCount);
@ -656,27 +657,28 @@ public class DocumentController {
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.status(500).body(Map.of( return ResponseEntity.status(500).body(Map.of(
"success", false, "success", false,
"message", "批量移动失败:" + e.getMessage()
"message", "移动失败:" + e.getMessage()
)); ));
} }
} }
/** /**
* 从请求体中提取 categoryId兼容 Number String 两种类型
* 从请求体中提取指定 key Long 兼容 Number String 两种类型
* 前端雪花 ID 超出 JS Number.MAX_SAFE_INTEGER需以 String 传递以避免精度丢失 * 前端雪花 ID 超出 JS Number.MAX_SAFE_INTEGER需以 String 传递以避免精度丢失
* 缺失或空字符串时返回 null由调用方按未指定处理
*/ */
private Long extractCategoryId(Map<String, Object> body) {
Object raw = body.get("categoryId");
private Long extractLong(Map<String, Object> body, String key) {
Object raw = body.get(key);
if (raw == null) { if (raw == null) {
return 0L;
return null;
} }
if (raw instanceof Number) { if (raw instanceof Number) {
return ((Number) raw).longValue(); return ((Number) raw).longValue();
} }
if (raw instanceof String str) { if (raw instanceof String str) {
return str.isEmpty() ? 0L : Long.parseLong(str);
return str.isEmpty() ? null : Long.parseLong(str);
} }
return 0L;
return null;
} }
// ==================== 语义搜索 ==================== // ==================== 语义搜索 ====================

55
src/main/java/com/wok/supportbot/service/DocumentService.java

@ -810,28 +810,67 @@ public class DocumentService {
jdbcTemplate.update(sql, enabled ? "true" : "false", documentId); jdbcTemplate.update(sql, enabled ? "true" : "false", documentId);
} }
// ==================== 批量移动分类 ====================
// ==================== 移动文档分类 / 目录 ====================
/** /**
* P1-2.2: 批量移动文档到目标分类
* 仅更新 category_id不影响向量数据
* 统一移动文档目标既可以是分类根folderId=0也可以是具体目录folderId>0
* <p>约定与上传一致目标为目录时强制使用目录所属分类覆盖 categoryId
* 目标为分类根时使用传入的 categoryIdnull/0 表示未分类</p>
* <p>categoryId 变更时同步 vector_store categoryId metadata保证 RAG 分类过滤正确</p>
*/ */
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public int batchMoveDocuments(List<Long> ids, Long categoryId) {
Long targetCategoryId = categoryId != null ? categoryId : 0L;
public int moveDocuments(List<Long> 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; int updated = 0;
for (Long id : ids) { for (Long id : ids) {
KnowledgeDocument doc = documentMapper.selectById(id); KnowledgeDocument doc = documentMapper.selectById(id);
if (doc != null) {
if (doc == null) {
continue;
}
Long oldCategoryId = doc.getCategoryId();
doc.setFolderId(targetFolderId);
doc.setCategoryId(targetCategoryId); doc.setCategoryId(targetCategoryId);
documentMapper.updateById(doc); documentMapper.updateById(doc);
updated++;
// 分类变更时同步向量 metadata categoryIdfolderId 不参与 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; return updated;
} }
/**
* 同步 vector_store 中指定文档所有分块的 categoryId metadata
* <p> {@link #syncVectorEnabledMetadata} 同构metadata 列是 json 类型 jsonb 运算后转回 json
* categoryId DocumentProcessingService 中以字符串写入故同步时写字符串
* 目标为未分类categoryId<=0时移除该键原写入逻辑仅在 categoryId>0 时写键</p>
*/
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);
}
}
// ==================== 标签管理 ==================== // ==================== 标签管理 ====================
/** /**

Loading…
Cancel
Save