Sfoglia il codice sorgente

feat: 添加表格渲染支持

后端改进 (StructuredDocumentDTO + StructuredDocumentService):
1. 新增 TableDTO 和 TableCellDTO 定义
2. buildTableList() 构建表格列表
3. convertToTableDTO() 转换表格数据
4. StructuredDocumentDTO 新增 tables 字段

前端改进 (Editor.vue):
1. renderStructuredDocument 添加表格处理
2. renderTable() 渲染表格 HTML:
   - 支持行合并/列合并
   - 支持单元格样式
   - 支持单元格内的 runs 格式
   - 支持实体高亮
3. 新增表格 CSS 样式:
   - 边框、内边距
   - 表头高亮
   - 斑马纹效果
   - 悬停高亮
何文松 4 settimane fa
parent
commit
51c0b7f810

+ 52 - 0
backend/document-service/src/main/java/com/lingyue/document/dto/StructuredDocumentDTO.java

@@ -47,6 +47,9 @@ public class StructuredDocumentDTO {
     @Schema(description = "图片列表(从 document_elements 中提取)")
     private List<ImageDTO> images;
     
+    @Schema(description = "表格列表(从 document_elements 中提取)")
+    private List<TableDTO> tables;
+    
     @Schema(description = "段落列表(从 document_elements 中提取,包含格式信息)")
     private List<ParagraphDTO> paragraphs;
     
@@ -123,6 +126,55 @@ public class StructuredDocumentDTO {
         private String format;
     }
     
+    /**
+     * 表格 DTO
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @Schema(description = "表格信息")
+    public static class TableDTO {
+        
+        @Schema(description = "表格在文档中的顺序索引")
+        private Integer index;
+        
+        @Schema(description = "行数")
+        private Integer rowCount;
+        
+        @Schema(description = "列数")
+        private Integer colCount;
+        
+        @Schema(description = "表格数据(二维数组)")
+        private java.util.List<java.util.List<TableCellDTO>> rows;
+    }
+    
+    /**
+     * 表格单元格 DTO
+     */
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @Schema(description = "表格单元格")
+    public static class TableCellDTO {
+        
+        @Schema(description = "单元格文本内容")
+        private String text;
+        
+        @Schema(description = "行合并数")
+        private Integer rowSpan;
+        
+        @Schema(description = "列合并数")
+        private Integer colSpan;
+        
+        @Schema(description = "单元格样式")
+        private java.util.Map<String, Object> style;
+        
+        @Schema(description = "文本片段列表(保留格式)")
+        private java.util.List<TextRunDTO> runs;
+    }
+    
     /**
      * 文本片段(Run)DTO,保留字符级样式
      */

+ 77 - 1
backend/document-service/src/main/java/com/lingyue/document/service/StructuredDocumentService.java

@@ -61,7 +61,10 @@ public class StructuredDocumentService {
         // 5. 获取图片列表
         List<ImageDTO> images = buildImageList(documentId);
         
-        // 6. 获取段落列表(包含格式信息)
+        // 6. 获取表格列表
+        List<StructuredDocumentDTO.TableDTO> tables = buildTableList(documentId);
+        
+        // 7. 获取段落列表(包含格式信息)
         List<StructuredDocumentDTO.ParagraphDTO> paragraphs = buildParagraphList(documentId);
         
         return StructuredDocumentDTO.builder()
@@ -71,6 +74,7 @@ public class StructuredDocumentService {
                 .status(document.getStatus())
                 .blocks(blockDTOs)
                 .images(images)
+                .tables(tables)
                 .paragraphs(paragraphs)
                 .entityStats(stats)
                 .updatedAt(document.getUpdateTime())
@@ -95,6 +99,78 @@ public class StructuredDocumentService {
                 .collect(Collectors.toList());
     }
     
+    /**
+     * 构建表格列表(从 document_elements 表获取)
+     */
+    @SuppressWarnings("unchecked")
+    private List<StructuredDocumentDTO.TableDTO> buildTableList(String documentId) {
+        List<DocumentElement> tableElements = documentElementService.getTablesByDocumentId(documentId);
+        
+        return tableElements.stream()
+                .map(this::convertToTableDTO)
+                .collect(Collectors.toList());
+    }
+    
+    /**
+     * 将 DocumentElement 转换为 TableDTO
+     */
+    @SuppressWarnings("unchecked")
+    private StructuredDocumentDTO.TableDTO convertToTableDTO(DocumentElement el) {
+        List<List<StructuredDocumentDTO.TableCellDTO>> rowDTOs = new ArrayList<>();
+        
+        if (el.getTableData() != null) {
+            for (Object rowObj : el.getTableData()) {
+                List<StructuredDocumentDTO.TableCellDTO> cellDTOs = new ArrayList<>();
+                
+                if (rowObj instanceof List) {
+                    List<?> row = (List<?>) rowObj;
+                    for (Object cellObj : row) {
+                        if (cellObj instanceof Map) {
+                            Map<String, Object> cell = (Map<String, Object>) cellObj;
+                            
+                            // 转换 runs
+                            List<StructuredDocumentDTO.TextRunDTO> runDTOs = null;
+                            Object runsObj = cell.get("runs");
+                            if (runsObj instanceof List) {
+                                runDTOs = ((List<Map<String, Object>>) runsObj).stream()
+                                        .map(run -> StructuredDocumentDTO.TextRunDTO.builder()
+                                                .text((String) run.get("text"))
+                                                .fontFamily((String) run.get("fontFamily"))
+                                                .fontSize(run.get("fontSize") instanceof Number ? ((Number) run.get("fontSize")).doubleValue() : null)
+                                                .bold((Boolean) run.get("bold"))
+                                                .italic((Boolean) run.get("italic"))
+                                                .underline((String) run.get("underline"))
+                                                .color((String) run.get("color"))
+                                                .strikeThrough((Boolean) run.get("strikeThrough"))
+                                                .verticalAlign((String) run.get("verticalAlign"))
+                                                .highlightColor((String) run.get("highlightColor"))
+                                                .build())
+                                        .collect(Collectors.toList());
+                            }
+                            
+                            cellDTOs.add(StructuredDocumentDTO.TableCellDTO.builder()
+                                    .text((String) cell.get("text"))
+                                    .rowSpan(cell.get("rowSpan") instanceof Number ? ((Number) cell.get("rowSpan")).intValue() : null)
+                                    .colSpan(cell.get("colSpan") instanceof Number ? ((Number) cell.get("colSpan")).intValue() : null)
+                                    .style((Map<String, Object>) cell.get("style"))
+                                    .runs(runDTOs)
+                                    .build());
+                        }
+                    }
+                }
+                
+                rowDTOs.add(cellDTOs);
+            }
+        }
+        
+        return StructuredDocumentDTO.TableDTO.builder()
+                .index(el.getElementIndex())
+                .rowCount(el.getTableRowCount())
+                .colCount(el.getTableColCount())
+                .rows(rowDTOs)
+                .build();
+    }
+    
     /**
      * 构建段落列表(从 document_elements 表获取,包含格式信息)
      */

+ 106 - 0
frontend/vue-demo/src/views/Editor.vue

@@ -475,6 +475,7 @@ const emptyPlaceholder = `
 function renderStructuredDocument(structuredDoc) {
   const blocks = structuredDoc.blocks || []
   const images = structuredDoc.images || []
+  const tables = structuredDoc.tables || []
   const paragraphs = structuredDoc.paragraphs || []
   
   // 将所有元素合并
@@ -523,6 +524,15 @@ function renderStructuredDocument(structuredDoc) {
     })
   })
   
+  // 添加表格
+  tables.forEach(table => {
+    allElements.push({
+      type: 'table',
+      index: table.index,
+      html: renderTable(table, entityMap)
+    })
+  })
+  
   // 按 index 排序
   allElements.sort((a, b) => (a.index || 0) - (b.index || 0))
   
@@ -530,6 +540,61 @@ function renderStructuredDocument(structuredDoc) {
   return allElements.map(el => el.html).join('')
 }
 
+/**
+ * 渲染表格
+ */
+function renderTable(table, entityMap) {
+  if (!table.rows || table.rows.length === 0) {
+    return '<div class="doc-table-empty">空表格</div>'
+  }
+  
+  let html = '<div class="doc-table-container"><table class="doc-table">'
+  
+  table.rows.forEach((row, rowIndex) => {
+    html += '<tr>'
+    row.forEach((cell, colIndex) => {
+      const tag = rowIndex === 0 ? 'th' : 'td'
+      const attrs = []
+      
+      if (cell.rowSpan && cell.rowSpan > 1) {
+        attrs.push(`rowspan="${cell.rowSpan}"`)
+      }
+      if (cell.colSpan && cell.colSpan > 1) {
+        attrs.push(`colspan="${cell.colSpan}"`)
+      }
+      
+      // 单元格样式
+      const styleAttrs = []
+      if (cell.style) {
+        if (cell.style.alignment) {
+          const alignMap = { 'left': 'left', 'center': 'center', 'right': 'right', 'both': 'justify' }
+          styleAttrs.push(`text-align:${alignMap[cell.style.alignment] || cell.style.alignment}`)
+        }
+        if (cell.style.backgroundColor) {
+          styleAttrs.push(`background-color:#${cell.style.backgroundColor}`)
+        }
+      }
+      if (styleAttrs.length > 0) {
+        attrs.push(`style="${styleAttrs.join(';')}"`)
+      }
+      
+      // 单元格内容(支持 runs 格式)
+      let content = ''
+      if (cell.runs && cell.runs.length > 0) {
+        content = cell.runs.map(run => renderTextRunWithEntities(run, entityMap)).join('')
+      } else {
+        content = highlightEntitiesInText(cell.text || '', entityMap)
+      }
+      
+      html += `<${tag} ${attrs.join(' ')}>${content}</${tag}>`
+    })
+    html += '</tr>'
+  })
+  
+  html += '</table></div>'
+  return html
+}
+
 /**
  * 从 blocks 中构建实体映射
  * 返回 { entityText: { entityId, entityType, confirmed } }
@@ -1536,6 +1601,47 @@ onUnmounted(() => {
       }
     }
     
+    // 表格样式
+    :deep(.doc-table-container) {
+      margin: 16px 0;
+      overflow-x: auto;
+    }
+    
+    :deep(.doc-table) {
+      width: 100%;
+      border-collapse: collapse;
+      font-size: 14px;
+      
+      th, td {
+        border: 1px solid #ddd;
+        padding: 8px 12px;
+        text-align: left;
+        vertical-align: top;
+        line-height: 1.5;
+      }
+      
+      th {
+        background-color: #f5f5f5;
+        font-weight: bold;
+      }
+      
+      tr:nth-child(even) td {
+        background-color: #fafafa;
+      }
+      
+      tr:hover td {
+        background-color: #f0f7ff;
+      }
+    }
+    
+    :deep(.doc-table-empty) {
+      padding: 20px;
+      text-align: center;
+      color: #999;
+      border: 1px dashed #ddd;
+      margin: 16px 0;
+    }
+    
     // 列表项样式
     :deep(.doc-list-item) {
       position: relative;