Browse Source

feat: 静态要素高亮支持

- 从模板提取145个静态要素(纯静态段落+混合段落中的固定文本)
- 全部插入数据库(element_type='static', is_static=true)
- 前端区分静态/动态要素:动态用实线彩色边框,静态用虚线灰色边框+低透明度
- 静态要素hover时恢复正常透明度
何文松 1 week ago
parent
commit
e2ee77a357
1 changed files with 37 additions and 12 deletions
  1. 37 12
      frontend/vue-demo/src/views/Editor.vue

+ 37 - 12
frontend/vue-demo/src/views/Editor.vue

@@ -739,10 +739,11 @@ function stripValueKeyPrefix(valueElementKey) {
   return idx >= 0 ? valueElementKey.substring(idx + 1) : valueElementKey
 }
 
-// 构建要素值映射表,分为长文本和短文本两
+// 构建要素值映射表,分为长文本、短文本、静态文本三
 function buildElementValueMap() {
-  const longTexts = []   // paragraph/table 类型的长文本要素
-  const shortTexts = []  // text 类型的短文本要素
+  const longTexts = []    // paragraph/table 类型的长文本要素
+  const shortTexts = []   // text 类型的短文本要素(动态)
+  const staticTexts = []  // static 类型的静态要素
   const colors = [
     '#fff3cd', '#cce5ff', '#d4edda', '#f8d7da', '#e2d5f1',
     '#d1ecf1', '#ffeeba', '#c3e6cb', '#f5c6cb', '#d6d8db'
@@ -751,6 +752,7 @@ function buildElementValueMap() {
   for (const elem of elements.value) {
     const elemValues = values.value.filter(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)
     const elemType = elem.elementType || 'text'
+    const isStatic = elemType === 'static'
     for (const val of elemValues) {
       const text = val.valueText
       if (!text || text.length < 2) continue
@@ -761,29 +763,35 @@ function buildElementValueMap() {
         elementName: elem.elementName,
         valueId: val.valueId,
         elemType,
-        color: colors[colorIdx % colors.length]
+        isStatic,
+        color: isStatic ? '#e8e8e8' : colors[colorIdx % colors.length]
       }
-      // paragraph/table 类型或多行文本视为长文本
-      if (elemType === 'paragraph' || elemType === 'table' || text.includes('\n') || text.length > 100) {
+      if (isStatic) {
+        staticTexts.push(entry)
+      } else if (elemType === 'paragraph' || elemType === 'table' || text.includes('\n') || text.length > 100) {
         longTexts.push(entry)
       } else {
         shortTexts.push(entry)
       }
     }
-    colorIdx++
+    if (!isStatic) colorIdx++
   }
   // 长文本按长度降序
   longTexts.sort((a, b) => b.text.length - a.text.length)
   // 短文本按长度降序
   shortTexts.sort((a, b) => b.text.length - a.text.length)
-  return { longTexts, shortTexts }
+  // 静态文本按长度降序
+  staticTexts.sort((a, b) => b.text.length - a.text.length)
+  return { longTexts, shortTexts, staticTexts }
 }
 
 // 将文档 blocks 渲染为 HTML 字符串(含要素高亮)
 function renderDocHtml() {
   if (!docContent.value?.blocks) { docHtml.value = ''; return }
   const blocks = docContent.value.blocks
-  const { longTexts, shortTexts } = highlightEnabled.value ? buildElementValueMap() : { longTexts: [], shortTexts: [] }
+  const { longTexts, shortTexts, staticTexts } = highlightEnabled.value ? buildElementValueMap() : { longTexts: [], shortTexts: [], staticTexts: [] }
+  // 合并短文本和静态文本用于 runs 级别匹配(动态优先,静态在后)
+  const allShortTexts = [...shortTexts, ...staticTexts]
   let highlightCount = 0
   const parts = []
 
@@ -849,7 +857,7 @@ function renderDocHtml() {
       } else {
         // 非长文本 block,先 flush 再正常渲染
         flushLongGroup()
-        parts.push(renderBlockHtml(block, shortTexts, null, (n) => { highlightCount += n }))
+        parts.push(renderBlockHtml(block, allShortTexts, null, (n) => { highlightCount += n }))
       }
     }
   }
@@ -1071,9 +1079,14 @@ function highlightRunsWithElements(runs, shortMap) {
     }
 
     if (seg.em) {
-      // 高亮段:先开 highlight span,内部保留各 run 的样式
+      // 高亮段:静态要素用虚线淡色边框,动态要素用实线彩色边框
       const em = seg.em
-      html += `<span class="elem-highlight" data-elem-key="${em.elementKey}" data-value-id="${em.valueId || ''}" style="border:1.5px solid ${darkenColor(em.color)};border-radius:3px;padding:0 2px;cursor:pointer;" contenteditable="false" title="${escapeAttr(em.elementName)}">`
+      const isStatic = em.isStatic
+      const borderStyle = isStatic
+        ? 'border:1px dashed #ccc;border-radius:3px;padding:0 2px;cursor:pointer;opacity:0.7;'
+        : `border:1.5px solid ${darkenColor(em.color)};border-radius:3px;padding:0 2px;cursor:pointer;`
+      const hlClass = isStatic ? 'elem-highlight elem-highlight-static' : 'elem-highlight'
+      html += `<span class="${hlClass}" data-elem-key="${em.elementKey}" data-value-id="${em.valueId || ''}" style="${borderStyle}" contenteditable="false" title="${escapeAttr(em.elementName)}">`
       for (const g of groups) {
         const run = runs[g.runIdx]
         const slice = escapeHtml(run.text.substring(g.startOffset, g.endOffset))
@@ -3802,5 +3815,17 @@ onMounted(async () => {
     margin: 0 auto;
   }
 }
+
+// 静态要素淡色高亮
+.elem-highlight-static {
+  opacity: 0.6;
+  transition: opacity 0.2s, border-color 0.2s;
+
+  &:hover {
+    opacity: 1;
+    border-color: #999 !important;
+    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
+  }
+}
 </style>