Просмотр исходного кода

feat: 添加文档目录API接口,支持点击跳转到对应章节

后端:
- DocumentController 添加 /documents/{id}/toc 接口
- DocumentElementService 添加 getTocByDocumentId 方法
- Repository 添加 findTocItemsByDocumentId 查询

前端:
- documentApi 添加 getToc 方法
- Editor 改为从 API 获取目录数据
- 点击目录项通过文本匹配滚动到对应位置并高亮
何文松 4 недель назад
Родитель
Сommit
faf66d0b4f

+ 22 - 0
backend/document-service/src/main/java/com/lingyue/document/controller/DocumentController.java

@@ -257,6 +257,19 @@ public class DocumentController {
         return AjaxResult.success(tables);
     }
     
+    /**
+     * 获取文档目录
+     */
+    @GetMapping("/{documentId}/toc")
+    @Operation(summary = "获取文档目录", description = "获取文档中的目录结构")
+    public AjaxResult<?> getDocumentToc(
+            @Parameter(description = "文档ID", required = true)
+            @PathVariable String documentId) {
+        
+        List<TocItemResponse> tocItems = documentElementService.getTocByDocumentId(documentId);
+        return AjaxResult.success(tocItems);
+    }
+    
     // ==================== 请求/响应 DTO ====================
     
     @Data
@@ -303,4 +316,13 @@ public class DocumentController {
         private int successCount;
         private int failedCount;
     }
+    
+    @Data
+    public static class TocItemResponse {
+        private Integer index;       // 目录项序号
+        private Integer level;       // 层级 (1, 2, 3...)
+        private String title;        // 标题文本
+        private String pageNum;      // 页码
+        private String anchorId;     // 锚点ID(用于跳转)
+    }
 }

+ 7 - 0
backend/document-service/src/main/java/com/lingyue/document/repository/DocumentElementRepository.java

@@ -53,6 +53,13 @@ public interface DocumentElementRepository extends BaseMapper<DocumentElement> {
         return findByDocumentIdAndType(documentId, "table");
     }
     
+    /**
+     * 查询文档中的所有目录项
+     */
+    default List<DocumentElement> findTocItemsByDocumentId(String documentId) {
+        return findByDocumentIdAndType(documentId, "toc_item");
+    }
+    
     /**
      * 删除文档的所有元素
      */

+ 59 - 0
backend/document-service/src/main/java/com/lingyue/document/service/DocumentElementService.java

@@ -174,4 +174,63 @@ public class DocumentElementService {
         
         return stats;
     }
+    
+    /**
+     * 获取文档目录
+     */
+    public List<com.lingyue.document.controller.DocumentController.TocItemResponse> getTocByDocumentId(String documentId) {
+        List<com.lingyue.document.controller.DocumentController.TocItemResponse> tocItems = new ArrayList<>();
+        
+        // 获取所有 toc_item 类型的元素
+        List<DocumentElement> elements = elementRepository.findTocItemsByDocumentId(documentId);
+        
+        int index = 0;
+        for (DocumentElement element : elements) {
+            com.lingyue.document.controller.DocumentController.TocItemResponse item = 
+                new com.lingyue.document.controller.DocumentController.TocItemResponse();
+            
+            item.setIndex(index++);
+            item.setTitle(element.getContent());
+            item.setAnchorId(element.getId());
+            
+            // 从 style 中获取页码
+            if (element.getStyle() != null) {
+                Object pageNum = element.getStyle().get("tocPageNum");
+                if (pageNum != null) {
+                    item.setPageNum(pageNum.toString());
+                }
+            }
+            
+            // 根据标题格式推断层级
+            item.setLevel(detectTocLevel(element.getContent()));
+            
+            tocItems.add(item);
+        }
+        
+        return tocItems;
+    }
+    
+    /**
+     * 检测目录项层级(根据编号格式推断)
+     */
+    private int detectTocLevel(String text) {
+        if (text == null || text.isEmpty()) {
+            return 1;
+        }
+        
+        // 三级标题:1.1.1 xxx
+        if (text.matches("^\\d+\\.\\d+\\.\\d+\\s+.*")) {
+            return 3;
+        }
+        // 二级标题:1.1 xxx, 1-1 xxx
+        if (text.matches("^\\d+\\.\\d+\\s+.*") || text.matches("^\\d+-\\d+\\s+.*")) {
+            return 2;
+        }
+        // 一级标题:1 xxx, 第一章 xxx
+        if (text.matches("^\\d+\\s+[^\\d].*") || text.matches("^第[一二三四五六七八九十\\d]+[章节部篇].*")) {
+            return 1;
+        }
+        // 默认一级
+        return 1;
+    }
 }

+ 5 - 0
frontend/vue-demo/src/api/index.js

@@ -348,6 +348,11 @@ export const documentApi = {
     return api.get(`/documents/${documentId}/structured`)
   },
 
+  // 获取文档目录
+  getToc(documentId) {
+    return api.get(`/documents/${documentId}/toc`)
+  },
+
   // 重新生成并保存文档块结构
   regenerateBlocks(documentId) {
     return api.post(`/ner/documents/${documentId}/regenerate-blocks`)

+ 75 - 50
frontend/vue-demo/src/views/Editor.vue

@@ -113,14 +113,14 @@
           <div class="toc-list" v-if="tocItems.length > 0">
             <div 
               v-for="(item, index) in tocItems" 
-              :key="index"
+              :key="item.index || index"
               class="toc-item"
               :class="['toc-level-' + item.level]"
               @click="scrollToHeading(item)"
             >
               <span class="toc-bullet">{{ getTocBullet(item.level) }}</span>
-              <span class="toc-text">{{ item.text }}</span>
-              <span class="toc-page" v-if="item.page">{{ item.page }}</span>
+              <span class="toc-text">{{ item.title || item.text }}</span>
+              <span class="toc-page" v-if="item.pageNum || item.page">{{ item.pageNum || item.page }}</span>
             </div>
           </div>
           <div class="toc-empty" v-else>
@@ -495,50 +495,18 @@ const newSourceFile = reactive({
 // 文档结构块(用于生成目录等)
 const blocks = ref([])
 
-// 目录数据(从文档结构中提取 toc_item 元素)
-const tocItems = computed(() => {
-  const items = []
-  if (!blocks.value || blocks.value.length === 0) return items
-  
-  for (const block of blocks.value) {
-    // 遍历块中的元素,查找 toc_item 类型
-    if (block.elements && Array.isArray(block.elements)) {
-      for (const el of block.elements) {
-        if (el.type === 'toc_item') {
-          const text = el.content || el.text || ''
-          if (text.trim()) {
-            // 从文本中推断层级(根据开头数字格式)
-            const level = detectTocLevel(text.trim())
-            items.push({
-              level: level,
-              text: text.trim(),
-              page: el.style?.tocPageNum || '',
-              blockId: block.id
-            })
-          }
-        }
-      }
-    }
-  }
-  return items
-})
+// 目录数据(从 API 获取)
+const tocItems = ref([])
 
-// 检测目录项层级(根据编号格式推断)
-function detectTocLevel(text) {
-  // 一级标题:1 xxx, 第一章 xxx
-  if (/^(\d+)\s+[^\d]/.test(text) || /^第[一二三四五六七八九十\d]+[章节部篇]/.test(text)) {
-    return 1
-  }
-  // 二级标题:1.1 xxx, 1-1 xxx
-  if (/^\d+\.\d+\s+/.test(text) || /^\d+-\d+\s+/.test(text)) {
-    return 2
-  }
-  // 三级标题:1.1.1 xxx
-  if (/^\d+\.\d+\.\d+\s+/.test(text)) {
-    return 3
+// 加载文档目录
+async function loadToc(documentId) {
+  try {
+    const items = await documentApi.getToc(documentId)
+    tocItems.value = items || []
+  } catch (error) {
+    console.warn('获取文档目录失败:', error)
+    tocItems.value = []
   }
-  // 默认一级
-  return 1
 }
 
 // 获取目录项的项目符号
@@ -547,11 +515,62 @@ function getTocBullet(level) {
   return bullets[Math.min(level - 1, bullets.length - 1)]
 }
 
-// 滚动到指定标题
+// 滚动到指定章节(通过标题文本匹配)
 function scrollToHeading(item) {
-  if (!item.blockId) return
+  // 优先通过 anchorId 查找
+  if (item.anchorId) {
+    const anchorEl = document.querySelector(`[data-element-id="${item.anchorId}"]`)
+    if (anchorEl) {
+      anchorEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
+      highlightElement(anchorEl)
+      return
+    }
+  }
   
-  const blockEl = document.querySelector(`[data-block-id="${item.blockId}"]`)
+  // 通过标题文本在文档内容中查找
+  const titleText = item.title || item.text
+  if (!titleText) return
+  
+  // 在文档内容区域中查找匹配的文本
+  const editorContent = document.querySelector('.editor-content')
+  if (!editorContent) return
+  
+  // 查找包含该标题文本的元素
+  const walker = document.createTreeWalker(
+    editorContent,
+    NodeFilter.SHOW_TEXT,
+    null,
+    false
+  )
+  
+  let node
+  while ((node = walker.nextNode())) {
+    if (node.textContent && node.textContent.includes(titleText)) {
+      const parent = node.parentElement
+      if (parent) {
+        parent.scrollIntoView({ behavior: 'smooth', block: 'start' })
+        highlightElement(parent)
+        return
+      }
+    }
+  }
+  
+  ElMessage.warning('未找到对应章节')
+}
+
+// 高亮元素
+function highlightElement(el) {
+  el.classList.add('highlight-block')
+  setTimeout(() => {
+    el.classList.remove('highlight-block')
+  }, 2000)
+}
+
+// 旧的滚动方法(用于 blockId)
+function scrollToBlock(blockId) {
+  if (!blockId) return
+  
+  const blockEl = document.querySelector(`[data-block-id="${blockId}"]`)
   if (blockEl) {
     blockEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
     // 高亮一下
@@ -760,10 +779,15 @@ async function fetchTemplateData() {
     const baseDocumentId = templateStore.currentTemplate?.baseDocumentId
     if (baseDocumentId) {
       try {
-        const structuredDoc = await documentApi.getStructured(baseDocumentId)
+        // 并行获取结构化内容和目录
+        const [structuredDoc] = await Promise.all([
+          documentApi.getStructured(baseDocumentId),
+          loadToc(baseDocumentId)
+        ])
+        
         // 将结构化文档的 blocks 和 images 合并渲染
         if (structuredDoc && structuredDoc.blocks && structuredDoc.blocks.length > 0) {
-          blocks.value = structuredDoc.blocks // 保存 blocks 用于目录
+          blocks.value = structuredDoc.blocks // 保存 blocks
           documentContent.value = renderStructuredDocument(structuredDoc)
           // 提取文档中的实体
           entities.value = extractEntitiesFromBlocks(structuredDoc.blocks)
@@ -780,6 +804,7 @@ async function fetchTemplateData() {
       }
     } else {
       blocks.value = []
+      tocItems.value = []
       documentContent.value = emptyPlaceholder
       entities.value = []
     }