Bladeren bron

feat: 合并格式和实体高亮渲染逻辑

实现同时保留字体格式和实体高亮:
1. 新增 buildEntityMap - 从 blocks.elements 提取实体映射
2. 新增 renderParagraphWithRunsAndEntities - 同时渲染格式和实体
3. 新增 highlightEntitiesInText - 在文本中查找并高亮实体
4. 新增 renderEntityHighlight - 渲染实体高亮标签
5. 新增 getEntityCssClass - 实体类型对应的 CSS 类
6. 新增 buildRunStyles - 提取 Run 样式构建逻辑
7. 新增 renderTextRunWithEntities - 带实体的 Run 渲染
8. 新增 escapeNonEntityText - 转义非实体文本

合并逻辑:
- 使用 paragraphs 的 runs 保留字体格式
- 使用 blocks 的 elements 获取实体信息
- 在渲染时按实体文本匹配并高亮
何文松 1 maand geleden
bovenliggende
commit
db953678db
1 gewijzigde bestanden met toevoegingen van 223 en 24 verwijderingen
  1. 223 24
      frontend/vue-demo/src/views/Editor.vue

+ 223 - 24
frontend/vue-demo/src/views/Editor.vue

@@ -480,17 +480,19 @@ function renderStructuredDocument(structuredDoc) {
   // 将所有元素合并
   const allElements = []
   
+  // 从 blocks 中提取实体映射(按文本内容匹配)
+  const entityMap = buildEntityMap(blocks)
+  
   // 检查 paragraphs 是否有 runs(带格式信息)
   const hasParagraphsWithRuns = paragraphs.some(p => p.runs && p.runs.length > 0)
   
-  // 优先使用 paragraphs(带格式),如果有 runs 的话
   if (hasParagraphsWithRuns) {
-    // 使用 paragraphs 渲染(保留格式)
+    // 使用 paragraphs 渲染(保留格式 + 合并实体高亮
     paragraphs.forEach(para => {
       allElements.push({
         type: 'paragraph',
         index: para.index,
-        html: renderParagraphWithRuns(para)
+        html: renderParagraphWithRunsAndEntities(para, entityMap)
       })
     })
   } else if (blocks.length > 0) {
@@ -529,63 +531,247 @@ function renderStructuredDocument(structuredDoc) {
 }
 
 /**
- * 渲染带格式的段落(使用 runs)
+ * 从 blocks 中构建实体映射
+ * 返回 { entityText: { entityId, entityType, confirmed } }
+ */
+function buildEntityMap(blocks) {
+  const entityMap = new Map()
+  
+  blocks.forEach(block => {
+    if (!block.elements) return
+    
+    block.elements.forEach(el => {
+      if (el.type === 'entity' && el.entityText) {
+        // 使用实体文本作为 key(可能有多个相同文本的实体)
+        if (!entityMap.has(el.entityText)) {
+          entityMap.set(el.entityText, [])
+        }
+        entityMap.get(el.entityText).push({
+          entityId: el.entityId,
+          entityType: el.entityType,
+          confirmed: el.confirmed
+        })
+      }
+    })
+  })
+  
+  return entityMap
+}
+
+/**
+ * 渲染带格式和实体高亮的段落
+ */
+function renderParagraphWithRunsAndEntities(para, entityMap) {
+  if (!para.runs || para.runs.length === 0) {
+    // 没有 runs,使用纯文本
+    const content = highlightEntitiesInText(para.content || '', entityMap)
+    return wrapWithParagraphTag(content, para.type, para.style)
+  }
+  
+  // 渲染每个 run,同时应用实体高亮
+  const runsHtml = para.runs.map(run => renderTextRunWithEntities(run, entityMap)).join('')
+  return wrapWithParagraphTag(runsHtml, para.type, para.style)
+}
+
+/**
+ * 渲染带格式的段落(使用 runs)- 保留兼容
  */
 function renderParagraphWithRuns(para) {
   if (!para.runs || para.runs.length === 0) {
-    // 没有 runs,使用纯文本(将换行符转为 <br>)
     const content = escapeHtml(para.content || '').replace(/\n/g, '<br>')
     return wrapWithParagraphTag(content, para.type, para.style)
   }
   
-  // 渲染每个 run
   const runsHtml = para.runs.map(run => renderTextRun(run)).join('')
   return wrapWithParagraphTag(runsHtml, para.type, para.style)
 }
 
 /**
- * 渲染单个文本片段(Run)
+ * 渲染单个文本片段(Run)并高亮实体
  */
-function renderTextRun(run) {
+function renderTextRunWithEntities(run, entityMap) {
   if (!run || !run.text) return ''
   
-  // 转义 HTML 并将换行符转换为 <br>
-  let text = escapeHtml(run.text).replace(/\n/g, '<br>')
+  // 先在文本中查找并高亮实体
+  const highlightedText = highlightEntitiesInText(run.text, entityMap)
+  
+  // 如果文本被实体高亮处理过(包含 span 标签),需要特殊处理样式
+  const hasEntityHighlight = highlightedText.includes('entity-highlight')
+  
+  // 构建样式
+  const styles = buildRunStyles(run)
+  
+  // 如果没有样式,直接返回高亮后的文本
+  if (styles.length === 0) {
+    return highlightedText.replace(/\n/g, '<br>')
+  }
+  
+  // 如果有实体高亮,需要用 span 包裹整体样式
+  if (hasEntityHighlight) {
+    return `<span style="${styles.join(';')}">${highlightedText.replace(/\n/g, '<br>')}</span>`
+  }
+  
+  // 普通文本,处理换行并应用样式
+  const text = escapeHtml(run.text).replace(/\n/g, '<br>')
+  
+  // 上下标特殊处理
+  if (run.verticalAlign === 'superscript') {
+    return `<sup style="${styles.join(';')}">${text}</sup>`
+  } else if (run.verticalAlign === 'subscript') {
+    return `<sub style="${styles.join(';')}">${text}</sub>`
+  }
+  
+  return `<span style="${styles.join(';')}">${text}</span>`
+}
+
+/**
+ * 在文本中查找并高亮实体
+ */
+function highlightEntitiesInText(text, entityMap) {
+  if (!text || !entityMap || entityMap.size === 0) {
+    return escapeHtml(text || '')
+  }
+  
+  // 按实体文本长度降序排序(优先匹配长的)
+  const sortedEntities = Array.from(entityMap.keys()).sort((a, b) => b.length - a.length)
+  
+  let result = text
+  const replacements = []
+  
+  // 找出所有需要替换的位置
+  for (const entityText of sortedEntities) {
+    const entities = entityMap.get(entityText)
+    if (!entities || entities.length === 0) continue
+    
+    let searchStart = 0
+    let entityIndex = 0
+    
+    while (true) {
+      const pos = result.indexOf(entityText, searchStart)
+      if (pos === -1) break
+      
+      // 获取对应的实体信息(循环使用)
+      const entity = entities[entityIndex % entities.length]
+      
+      replacements.push({
+        start: pos,
+        end: pos + entityText.length,
+        text: entityText,
+        entity: entity
+      })
+      
+      searchStart = pos + entityText.length
+      entityIndex++
+    }
+  }
+  
+  // 按位置排序,从后往前替换(避免位置偏移)
+  replacements.sort((a, b) => b.start - a.start)
+  
+  // 检查重叠,移除被包含的替换
+  const finalReplacements = []
+  for (const rep of replacements) {
+    const hasOverlap = finalReplacements.some(
+      existing => rep.start < existing.end && rep.end > existing.start
+    )
+    if (!hasOverlap) {
+      finalReplacements.push(rep)
+    }
+  }
+  
+  // 执行替换
+  for (const rep of finalReplacements) {
+    const before = result.substring(0, rep.start)
+    const after = result.substring(rep.end)
+    const highlighted = renderEntityHighlight(rep.text, rep.entity)
+    result = before + highlighted + after
+  }
+  
+  // 对非实体部分进行 HTML 转义
+  // 由于实体部分已经包含 HTML,需要分段处理
+  return escapeNonEntityText(result)
+}
+
+/**
+ * 转义非实体部分的文本
+ */
+function escapeNonEntityText(text) {
+  // 分割出实体标签和普通文本
+  const parts = text.split(/(<span class="entity-highlight[^>]*>.*?<\/span>)/g)
+  
+  return parts.map(part => {
+    if (part.startsWith('<span class="entity-highlight')) {
+      return part // 保留实体标签
+    }
+    return escapeHtml(part) // 转义普通文本
+  }).join('')
+}
+
+/**
+ * 渲染实体高亮标签
+ */
+function renderEntityHighlight(text, entity) {
+  const cssClass = getEntityCssClass(entity.entityType)
+  const confirmedMark = entity.confirmed ? ' ✓' : ''
+  
+  return `<span class="${cssClass}" ` +
+    `data-entity-id="${entity.entityId || ''}" ` +
+    `data-type="${entity.entityType || ''}" ` +
+    `onclick="showEntityEditModal(event,'${entity.entityId || ''}')" ` +
+    `contenteditable="false">${escapeHtml(text)}${confirmedMark}</span>`
+}
+
+/**
+ * 获取实体类型对应的 CSS 类
+ */
+function getEntityCssClass(entityType) {
+  const typeMap = {
+    'PERSON': 'entity-highlight person',
+    'ORG': 'entity-highlight org',
+    'ORGANIZATION': 'entity-highlight org',
+    'LOC': 'entity-highlight location',
+    'LOCATION': 'entity-highlight location',
+    'GPE': 'entity-highlight location',
+    'DATE': 'entity-highlight date',
+    'TIME': 'entity-highlight date',
+    'MONEY': 'entity-highlight data',
+    'NUMBER': 'entity-highlight data',
+    'PERCENT': 'entity-highlight data',
+    'DATA': 'entity-highlight data',
+    'CONCEPT': 'entity-highlight concept',
+    'PRODUCT': 'entity-highlight product',
+    'EVENT': 'entity-highlight event'
+  }
+  return typeMap[entityType?.toUpperCase()] || 'entity-highlight entity'
+}
+
+/**
+ * 构建 Run 的 CSS 样式数组
+ */
+function buildRunStyles(run) {
   const styles = []
   
-  // 字体
   if (run.fontFamily) {
     styles.push(`font-family:${run.fontFamily}`)
   }
-  
-  // 字号
   if (run.fontSize && run.fontSize > 0) {
     styles.push(`font-size:${run.fontSize}pt`)
   }
-  
-  // 颜色
   if (run.color) {
     const color = run.color.startsWith('#') ? run.color : `#${run.color}`
     styles.push(`color:${color}`)
   }
-  
-  // 高亮/背景色
   if (run.highlightColor) {
     const bgColor = getHighlightColor(run.highlightColor)
     styles.push(`background-color:${bgColor}`)
   }
-  
-  // 加粗
   if (run.bold) {
     styles.push('font-weight:bold')
   }
-  
-  // 斜体
   if (run.italic) {
     styles.push('font-style:italic')
   }
   
-  // 下划线和删除线
   const textDecorations = []
   if (run.underline && run.underline !== 'none') {
     const underlineStyle = run.underline === 'double' ? 'double' : 
@@ -601,11 +787,24 @@ function renderTextRun(run) {
     styles.push(`text-decoration:${textDecorations.join(' ')}`)
   }
   
+  return styles
+}
+
+/**
+ * 渲染单个文本片段(Run)- 保留兼容
+ */
+function renderTextRun(run) {
+  if (!run || !run.text) return ''
+  
+  // 转义 HTML 并将换行符转换为 <br>
+  let text = escapeHtml(run.text).replace(/\n/g, '<br>')
+  const styles = buildRunStyles(run)
+  
   // 上下标
   if (run.verticalAlign === 'superscript') {
-    return `<sup>${text}</sup>`
+    return styles.length > 0 ? `<sup style="${styles.join(';')}">${text}</sup>` : `<sup>${text}</sup>`
   } else if (run.verticalAlign === 'subscript') {
-    return `<sub>${text}</sub>`
+    return styles.length > 0 ? `<sub style="${styles.join(';')}">${text}</sub>` : `<sub>${text}</sub>`
   }
   
   // 如果没有样式,直接返回文本