diff --git a/src/main/java/com/wok/supportbot/controller/DocumentController.java b/src/main/java/com/wok/supportbot/controller/DocumentController.java index 6594638..c65fe57 100644 --- a/src/main/java/com/wok/supportbot/controller/DocumentController.java +++ b/src/main/java/com/wok/supportbot/controller/DocumentController.java @@ -86,7 +86,7 @@ public class DocumentController { KnowledgeDocument doc = documentService.uploadFile(file, title, categoryId, tags); return ResponseEntity.ok(Map.of( "success", true, - "message", "文件上传并向量化成功", + "message", "文件上传成功,正在后台处理", "data", doc )); } catch (Exception e) { @@ -117,7 +117,7 @@ public class DocumentController { KnowledgeDocument doc = documentService.uploadString(content, title, categoryId, tags); return ResponseEntity.ok(Map.of( "success", true, - "message", "文本内容上传并向量化成功", + "message", "文本内容上传成功,正在后台处理", "data", doc )); } catch (Exception e) { @@ -143,7 +143,7 @@ public class DocumentController { KnowledgeDocument doc = documentService.uploadMarkdown(file, title, categoryId, tags); return ResponseEntity.ok(Map.of( "success", true, - "message", "Markdown文件上传并向量化成功", + "message", "Markdown文件上传成功,正在后台处理", "data", doc )); } catch (Exception e) { @@ -169,7 +169,7 @@ public class DocumentController { KnowledgeDocument doc = documentService.uploadJsonBasic(file, title, categoryId, tags); return ResponseEntity.ok(Map.of( "success", true, - "message", "JSON文件(基本方式)上传并向量化成功", + "message", "JSON文件上传成功,正在后台处理", "data", doc )); } catch (Exception e) { @@ -196,7 +196,7 @@ public class DocumentController { KnowledgeDocument doc = documentService.uploadJsonFields(file, fields, title, categoryId, tags); return ResponseEntity.ok(Map.of( "success", true, - "message", "JSON文件(按字段)上传并向量化成功", + "message", "JSON文件(按字段)上传成功,正在后台处理", "data", doc, "extractedFields", fields )); @@ -224,7 +224,7 @@ public class DocumentController { KnowledgeDocument doc = documentService.uploadJsonPointer(file, pointer, title, categoryId, tags); return ResponseEntity.ok(Map.of( "success", true, - "message", "JSON文件(按指针)上传并向量化成功", + "message", "JSON文件(按指针)上传成功,正在后台处理", "data", doc, "pointer", pointer )); @@ -239,12 +239,13 @@ public class DocumentController { // ==================== 文档管理 ==================== /** - * 查询文档列表(分页 + 过滤) + * 查询文档列表(分页 + 过滤 + 关键词搜索) * * @param page 页码(默认1) * @param size 每页大小(默认10) * @param categoryId 分类ID过滤(可选) * @param status 状态过滤(PROCESSING/READY/FAILED,可选) + * @param keyword 关键词搜索(模糊匹配标题和文件名,可选) * @return 分页文档列表 */ @GetMapping("/document/list") @@ -253,9 +254,10 @@ public class DocumentController { @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) Long categoryId, - @RequestParam(required = false) String status) { + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword) { try { - Map result = documentService.listDocuments(page, size, categoryId, status); + Map result = documentService.listDocuments(page, size, categoryId, status, keyword); Map data = new HashMap<>(); data.put("success", true); data.put("data", result.get("records")); @@ -452,7 +454,7 @@ public class DocumentController { KnowledgeDocument doc = documentService.reprocessDocument(id); return ResponseEntity.ok(Map.of( "success", true, - "message", "重新处理成功", + "message", "已提交重新处理,正在后台执行", "data", doc )); } catch (Exception e) { diff --git a/src/main/java/com/wok/supportbot/service/DocumentProcessingService.java b/src/main/java/com/wok/supportbot/service/DocumentProcessingService.java new file mode 100644 index 0000000..24ad357 --- /dev/null +++ b/src/main/java/com/wok/supportbot/service/DocumentProcessingService.java @@ -0,0 +1,194 @@ +package com.wok.supportbot.service; + +import com.wok.supportbot.dao.KnowledgeDocumentMapper; +import com.wok.supportbot.document.transform.MyKeywordEnricher; +import com.wok.supportbot.document.transform.MyTokenTextSplitter; +import com.wok.supportbot.entity.KnowledgeDocument; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.document.Document; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 文档异步处理服务 + * 负责文档的分块、关键词提取、向量化等耗时操作,在后台线程中执行 + */ +@Service +@Slf4j +public class DocumentProcessingService { + + @Autowired + private KnowledgeDocumentMapper documentMapper; + + @Autowired + private MyTokenTextSplitter myTokenTextSplitter; + + @Autowired + private MyKeywordEnricher myKeywordEnricher; + + @Autowired + private VectorStore pgVectorVectorStore; + + @Autowired + private JdbcTemplate jdbcTemplate; + + /** + * 异步处理文档:分块 → 关键词提取 → 向量化 → 更新状态 + * 使用 REQUIRES_NEW 在独立事务中执行,避免与主事务冲突 + * + * @param docId 文档ID + * @param documents 已解析的原始文档列表 + * @param sourceName 源文件名 + * @param title 文档标题 + * @param categoryId 分类ID + * @param tags 标签列表 + */ + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) + public void processDocumentAsync(Long docId, List documents, String sourceName, + String title, Long categoryId, List tags) { + // 等待主事务提交,确保文档记录可见(最多重试 5 次,每次 200ms) + KnowledgeDocument doc = waitForDocument(docId); + if (doc == null) { + log.error("异步处理文档失败:文档不存在(等待超时), id={}", docId); + return; + } + + try { + // 1. 分块处理 + List splitDocuments = myTokenTextSplitter.splitDocuments(documents); + + // 2. 为每个分块设置 metadata + for (int i = 0; i < splitDocuments.size(); i++) { + Document d = splitDocuments.get(i); + Map meta = new HashMap<>(d.getMetadata()); + meta.put("documentId", String.valueOf(docId)); + meta.put("chunkIndex", i); + meta.put("sourceName", sourceName); + meta.put("title", title); + if (categoryId != null && categoryId > 0) { + meta.put("categoryId", String.valueOf(categoryId)); + } + if (tags != null && !tags.isEmpty()) { + meta.put("tags", tags); + } + splitDocuments.set(i, new Document(d.getId(), d.getText(), meta)); + } + + // 3. 关键词提取 + List enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments); + + // 4. 向量化存储 + pgVectorVectorStore.add(enrichedDocuments); + + // 5. 更新状态为 READY + doc.setStatus("READY"); + doc.setChunkCount(enrichedDocuments.size()); + documentMapper.updateById(doc); + + log.info("异步处理文档完成: id={}, title={}, chunks={}", docId, title, enrichedDocuments.size()); + + } catch (Exception e) { + doc.setStatus("FAILED"); + doc.setErrorMessage(e.getMessage()); + documentMapper.updateById(doc); + log.error("异步处理文档失败: id={}, title={}", docId, title, e); + } + } + + /** + * 异步重新处理文档(重新分块 + 向量化) + * + * @param docId 文档ID + * @param documents 解析后的文档列表 + */ + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) + public void reprocessDocumentAsync(Long docId, List documents) { + // 等待主事务提交,确保文档记录可见 + KnowledgeDocument doc = waitForDocument(docId); + if (doc == null) { + log.error("异步重新处理文档失败:文档不存在(等待超时), id={}", docId); + return; + } + + try { + // 删除旧向量 + String sql = "SELECT id::text FROM vector_store WHERE metadata->>'documentId' = ?"; + List oldIds = jdbcTemplate.queryForList(sql, String.class, String.valueOf(docId)); + if (!oldIds.isEmpty()) { + pgVectorVectorStore.delete(oldIds); + } + + // 重新分块 + List splitDocuments = myTokenTextSplitter.splitDocuments(documents); + + for (int i = 0; i < splitDocuments.size(); i++) { + Document d = splitDocuments.get(i); + Map meta = new HashMap<>(d.getMetadata()); + meta.put("documentId", String.valueOf(docId)); + meta.put("chunkIndex", i); + meta.put("sourceName", doc.getSourceName()); + meta.put("title", doc.getTitle()); + if (doc.getCategoryId() != null && doc.getCategoryId() > 0) { + meta.put("categoryId", String.valueOf(doc.getCategoryId())); + } + if (doc.getTags() != null && doc.getTags().containsKey("tags")) { + meta.put("tags", doc.getTags().get("tags")); + } + splitDocuments.set(i, new Document(d.getId(), d.getText(), meta)); + } + + List enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments); + pgVectorVectorStore.add(enrichedDocuments); + + doc.setStatus("READY"); + doc.setChunkCount(enrichedDocuments.size()); + doc.setErrorMessage(null); + documentMapper.updateById(doc); + + log.info("异步重新处理文档成功: id={}, title={}, chunks={}", docId, doc.getTitle(), enrichedDocuments.size()); + + } catch (Exception e) { + doc.setStatus("FAILED"); + doc.setErrorMessage(e.getMessage()); + documentMapper.updateById(doc); + log.error("异步重新处理文档失败: id={}, title={}", docId, doc.getTitle(), e); + } + } + + /** + * 等待主事务提交后文档可见(最多重试 5 次,每次 200ms,总计 1 秒) + * 解决 @Async 任务可能在主事务提交前启动的竞态条件 + * 同时检查文档是否已被删除(用户可能在异步处理前删除了文档) + */ + private KnowledgeDocument waitForDocument(Long docId) { + for (int i = 0; i < 5; i++) { + KnowledgeDocument doc = documentMapper.selectById(docId); + if (doc != null) { + // 检查是否已被逻辑删除(用户可能在处理前删除了文档) + if (doc.isDelete()) { + log.info("文档已被删除,跳过异步处理: id={}", docId); + return null; + } + return doc; + } + try { + Thread.sleep(200); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } + } + 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 0fb5f90..d3d6c4e 100644 --- a/src/main/java/com/wok/supportbot/service/DocumentService.java +++ b/src/main/java/com/wok/supportbot/service/DocumentService.java @@ -68,10 +68,15 @@ public class DocumentService { @Autowired private JsonDocumentLoader jsonDocumentLoader; + @Autowired + private DocumentProcessingService documentProcessingService; + // ==================== 文档上传 ==================== /** - * 统一文档上传流程:创建记录 -> 分块 -> 关键词 -> 向量化 -> 更新状态 + * 统一文档上传流程:创建记录(PROCESSING)→ 触发异步处理 + * 同步阶段:内容去重检查 + 创建 PROCESSING 记录 + * 异步阶段:分块 → 关键词提取 → 向量化 → 更新状态为 READY * * @param documents 解析后的文档列表 * @param title 文档标题 @@ -81,7 +86,7 @@ public class DocumentService { * @param content 原文内容(截断预览) * @param categoryId 分类ID * @param tags 标签列表 - * @return 创建完成的文档记录 + * @return 创建完成的文档记录(status=PROCESSING) */ @Transactional(rollbackFor = Exception.class) public KnowledgeDocument uploadDocument(List documents, String title, String sourceName, @@ -111,49 +116,11 @@ public class DocumentService { .build(); documentMapper.insert(docRecord); - try { - // 2. 分块处理 - List splitDocuments = myTokenTextSplitter.splitDocuments(documents); - - // 3. 为每个分块设置 documentId 等元数据 - for (int i = 0; i < splitDocuments.size(); i++) { - Document doc = splitDocuments.get(i); - Map meta = new HashMap<>(doc.getMetadata()); - meta.put("documentId", String.valueOf(docRecord.getId())); - meta.put("chunkIndex", i); - meta.put("sourceName", sourceName); - meta.put("title", title != null ? title : sourceName); - if (categoryId != null) { - meta.put("categoryId", String.valueOf(categoryId)); - } - if (tags != null && !tags.isEmpty()) { - meta.put("tags", tags); - } - splitDocuments.set(i, new Document(doc.getId(), doc.getText(), meta)); - } - - // 4. 关键词提取 - List enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments); - - // 5. 向量化存储 - pgVectorVectorStore.add(enrichedDocuments); - - // 6. 更新文档状态为 READY - docRecord.setStatus("READY"); - docRecord.setChunkCount(enrichedDocuments.size()); - documentMapper.updateById(docRecord); - - log.info("文档上传成功: id={}, title={}, chunks={}", docRecord.getId(), docRecord.getTitle(), enrichedDocuments.size()); - - } catch (Exception e) { - // 标记为失败 - docRecord.setStatus("FAILED"); - docRecord.setErrorMessage(e.getMessage()); - documentMapper.updateById(docRecord); - log.error("文档上传失败: id={}, title={}", docRecord.getId(), docRecord.getTitle(), e); - throw new RuntimeException("文档处理失败: " + e.getMessage(), e); - } + // 2. 触发异步处理(分块 → 关键词 → 向量化 → 更新状态) + documentProcessingService.processDocumentAsync( + docRecord.getId(), documents, sourceName, title, categoryId, tags); + log.info("文档已创建,后台处理中: id={}, title={}", docRecord.getId(), docRecord.getTitle()); return docRecord; } @@ -249,9 +216,16 @@ public class DocumentService { // ==================== 文档管理 ==================== /** - * 分页查询文档列表(手动分页) + * 分页查询文档列表(手动分页,支持关键词搜索) */ - public Map listDocuments(int page, int size, Long categoryId, String status) { + public Map listDocuments(int page, int size, Long categoryId, String status, String keyword) { + // 参数安全校验 + if (page < 1) page = 1; + if (size < 1 || size > 100) size = 10; + // LIKE 通配符转义 + String kw = (keyword != null && !keyword.trim().isEmpty()) + ? keyword.trim().replace("%", "\\%").replace("_", "\\_") : null; + // 构建基础条件(用于 count 和 list) QueryWrapper countWrapper = new QueryWrapper<>(); if (categoryId != null && categoryId > 0) { @@ -260,6 +234,9 @@ public class DocumentService { if (status != null && !status.isEmpty()) { countWrapper.eq("status", status); } + if (kw != null) { + countWrapper.and(w -> w.like("title", kw).or().like("source_name", kw)); + } // 先查询总数(不加 ORDER BY) Long total = documentMapper.selectCount(countWrapper); @@ -272,6 +249,9 @@ public class DocumentService { if (status != null && !status.isEmpty()) { listWrapper.eq("status", status); } + if (kw != null) { + listWrapper.and(w -> w.like("title", kw).or().like("source_name", kw)); + } listWrapper.orderByDesc("create_time"); listWrapper.last("LIMIT " + size + " OFFSET " + (page - 1) * size); List records = documentMapper.selectList(listWrapper); @@ -422,7 +402,7 @@ public class DocumentService { } /** - * 重新处理文档(重新分块 + 向量化) + * 重新处理文档(异步:先标记为 PROCESSING,后台重新分块 + 向量化) */ @Transactional(rollbackFor = Exception.class) public KnowledgeDocument reprocessDocument(Long id) { @@ -434,53 +414,17 @@ public class DocumentService { throw new RuntimeException("文档无内容,无法重新处理"); } - // 删除旧向量 - deleteVectorsByDocumentId(String.valueOf(id)); - - // 重新解析并处理 - List documents = simpleStringDocumentReader.read(doc.getContent()); - + // 标记为 PROCESSING,清空旧状态 doc.setStatus("PROCESSING"); doc.setChunkCount(0); doc.setErrorMessage(null); documentMapper.updateById(doc); - try { - List splitDocuments = myTokenTextSplitter.splitDocuments(documents); - - for (int i = 0; i < splitDocuments.size(); i++) { - Document d = splitDocuments.get(i); - Map meta = new HashMap<>(d.getMetadata()); - meta.put("documentId", String.valueOf(doc.getId())); - meta.put("chunkIndex", i); - meta.put("sourceName", doc.getSourceName()); - meta.put("title", doc.getTitle()); - if (doc.getCategoryId() != null && doc.getCategoryId() > 0) { - meta.put("categoryId", String.valueOf(doc.getCategoryId())); - } - if (doc.getTags() != null && doc.getTags().containsKey("tags")) { - meta.put("tags", doc.getTags().get("tags")); - } - splitDocuments.set(i, new Document(d.getId(), d.getText(), meta)); - } - - List enrichedDocuments = myKeywordEnricher.enrichDocuments(splitDocuments); - pgVectorVectorStore.add(enrichedDocuments); - - doc.setStatus("READY"); - doc.setChunkCount(enrichedDocuments.size()); - documentMapper.updateById(doc); - - log.info("重新处理文档成功: id={}, title={}, chunks={}", doc.getId(), doc.getTitle(), enrichedDocuments.size()); - - } catch (Exception e) { - doc.setStatus("FAILED"); - doc.setErrorMessage(e.getMessage()); - documentMapper.updateById(doc); - log.error("重新处理文档失败: id={}, title={}", doc.getId(), doc.getTitle(), e); - throw new RuntimeException("重新处理失败: " + e.getMessage(), e); - } + // 解析内容并触发异步处理 + List documents = simpleStringDocumentReader.read(doc.getContent()); + documentProcessingService.reprocessDocumentAsync(id, documents); + log.info("文档重新处理已提交: id={}, title={}", doc.getId(), doc.getTitle()); return doc; } diff --git a/src/main/resources/static/components/CategoryManager.js b/src/main/resources/static/components/CategoryManager.js index 6353725..e8b3a7d 100644 --- a/src/main/resources/static/components/CategoryManager.js +++ b/src/main/resources/static/components/CategoryManager.js @@ -1,53 +1,145 @@ /** * 🏷️ 分类管理 + * P0-3: 支持编辑 + 树形层级展示 + 创建时选择父分类 */ -import { ref } from 'vue' +import { ref, computed } from 'vue' import { store } from '../js/store.js' -import { createCategory, deleteCategory } from '../js/api.js' +import { createCategory, updateCategory, deleteCategory } from '../js/api.js' import { toast } from '../js/utils.js' export default { template: `

分类管理

-
- - - - + + +
+
创建新分类
+
+ + + + + +
+
+ + +
+ 共 {{ store.categories.length }} 个分类
-
暂无分类
-
-
- {{ c.name }} {{ c.description || '' }} - +
暂无分类,请在上方创建
+
+
+ + +
+ + + {{ expandedIds.has(node.id) ? '▼' : '▶' }} + + + + +
+ {{ node.name }} + {{ node.description }} + + ({{ node.documentCount || 0 }} 篇文档) + +
+
+ + +
+ + +
+
+
+ + +
+
+

编辑分类

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
`, setup() { - const name = ref('') - const description = ref('') - const sortOrder = ref(0) + // ==================== 创建分类 ==================== + const newName = ref('') + const newDescription = ref('') + const newParentId = ref('') + const newSortOrder = ref(0) + + // 父分类选项(排除顶级,只显示可以成为父分类的节点,限制最多2级) + const parentOptions = computed(() => { + const opts = [] + function addOptions(categories, level) { + for (const cat of categories) { + opts.push({ id: cat.id, name: cat.name, level }) + if (cat.children && cat.children.length > 0 && level < 2) { + addOptions(cat.children, level + 1) + } + } + } + addOptions(categoryTree.value, 0) + return opts + }) + + // 父分类下拉显示文本(用 └ 前缀表示层级) + function parentLabel(c) { + return c.level > 0 ? '─'.repeat(c.level) + ' ' + c.name : c.name + } async function create() { - if (!name.value.trim()) { + if (!newName.value.trim()) { toast('请输入分类名称', 'error') return } try { const json = await createCategory({ - name: name.value, - description: description.value, - sortOrder: sortOrder.value + name: newName.value.trim(), + description: newDescription.value.trim(), + parentId: newParentId.value || null, + sortOrder: newSortOrder.value }) if (json.success) { toast('分类创建成功', 'success') - name.value = '' - description.value = '' - store.loadCategories() + newName.value = '' + newDescription.value = '' + newParentId.value = '' + newSortOrder.value = 0 + await store.loadCategories() } else { toast(json.message || '创建失败', 'error') } @@ -56,13 +148,154 @@ export default { } } - async function remove(id) { - if (!confirm('确定删除此分类?关联文档将变为未分类')) return + // ==================== 树形展示 ==================== + const expandedIds = ref(new Set()) + + // 构建分类树(从扁平列表构建层级结构) + const categoryTree = computed(() => { + const categories = store.categories || [] + const nodeMap = new Map() + const roots = [] + + // 先创建所有节点 + for (const cat of categories) { + nodeMap.set(cat.id, { ...cat, children: [], level: 0 }) + } + + // 构建父子关系 + for (const node of nodeMap.values()) { + if (node.parentId && node.parentId !== '0' && node.parentId !== 0) { + const parent = nodeMap.get(node.parentId) + if (parent) { + parent.children.push(node) + } else { + roots.push(node) + } + } else { + roots.push(node) + } + } + + // 按 sortOrder 升序排列(与后端一致) + function sortChildren(nodes) { + nodes.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) + for (const node of nodes) { + sortChildren(node.children) + } + } + sortChildren(roots) + + return roots + }) + + // 展平树为列表(带层级信息) + const flatTree = computed(() => { + const result = [] + function traverse(nodes, level) { + for (const node of nodes) { + result.push({ + ...node, + level, + hasChildren: node.children && node.children.length > 0 + }) + // 只展示已展开节点的子节点 + if (node.children && node.children.length > 0 && expandedIds.value.has(node.id)) { + traverse(node.children, level + 1) + } + } + } + traverse(categoryTree.value, 0) + return result + }) + + function toggleExpand(id) { + const newSet = new Set(expandedIds.value) + if (newSet.has(id)) { + newSet.delete(id) + } else { + newSet.add(id) + } + expandedIds.value = newSet + } + + // 初始时展开所有有子节点的分类 + function expandAll() { + const ids = new Set() + function collectIds(nodes) { + for (const node of nodes) { + if (node.children && node.children.length > 0) { + ids.add(node.id) + collectIds(node.children) + } + } + } + collectIds(categoryTree.value) + expandedIds.value = ids + } + + // 加载分类后自动展开 + store.loadCategories().then(() => { + setTimeout(expandAll, 100) + }) + + // ==================== 编辑分类 ==================== + const editModal = ref({ + visible: false, + id: null, + name: '', + description: '', + sortOrder: 0 + }) + + function openEdit(node) { + editModal.value = { + visible: true, + id: node.id, + name: node.name, + description: node.description || '', + sortOrder: node.sortOrder || 0 + } + } + + function closeEdit() { + editModal.value = { visible: false, id: null, name: '', description: '', sortOrder: 0 } + } + + async function saveEdit() { + if (!editModal.value.name.trim()) { + toast('请输入分类名称', 'error') + return + } + try { + const json = await updateCategory(editModal.value.id, { + name: editModal.value.name.trim(), + description: editModal.value.description.trim(), + sortOrder: editModal.value.sortOrder + }) + if (json.success) { + toast('分类更新成功', 'success') + closeEdit() + await store.loadCategories() + setTimeout(expandAll, 100) + } else { + toast(json.message || '更新失败', 'error') + } + } catch (e) { + toast('更新分类失败:' + e.message, 'error') + } + } + + // ==================== 删除分类 ==================== + async function remove(node) { + const childCount = countDescendants(node) + const childMsg = childCount > 0 ? `\n注意:此分类下有 ${childCount} 个子分类,将一并清空关联` : '' + if (!confirm(`确定删除分类"${node.name}"?关联文档将变为未分类${childMsg}`)) return try { - const json = await deleteCategory(id) + const json = await deleteCategory(node.id) if (json.success) { toast('分类已删除', 'success') - store.loadCategories() + await store.loadCategories() + setTimeout(expandAll, 100) } else { toast(json.message || '删除失败', 'error') } @@ -71,6 +304,19 @@ export default { } } - return { name, description, sortOrder, store, create, remove } + function countDescendants(node) { + if (!node.children || node.children.length === 0) return 0 + let count = node.children.length + for (const child of node.children) { + count += countDescendants(child) + } + return count + } + + return { + newName, newDescription, newParentId, newSortOrder, parentOptions, parentLabel, + create, store, flatTree, expandedIds, toggleExpand, + editModal, openEdit, closeEdit, saveEdit, remove + } } } diff --git a/src/main/resources/static/components/DocList.js b/src/main/resources/static/components/DocList.js index 3c0324a..7197958 100644 --- a/src/main/resources/static/components/DocList.js +++ b/src/main/resources/static/components/DocList.js @@ -1,16 +1,20 @@ /** * 📋 文档列表 + 分页 + 批量操作 + * P0-1: 关键词搜索(标题/文件名模糊匹配) + * P0-2: 文件大小列 + 分类名称列 + * P0-4: 处理中状态自动轮询刷新 */ -import { ref } from 'vue' +import { ref, computed, onUnmounted } from 'vue' import { store } from '../js/store.js' import { listDocuments, deleteDocument, reprocessDocument, batchDeleteDocuments, batchReprocessDocuments } from '../js/api.js' -import { toast, formatDate } from '../js/utils.js' +import { toast, formatDate, formatBytes } from '../js/utils.js' export default { template: `

文档列表

+ + + ⏳ 有文档处理中... +