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

feat: 文档可编辑 + 要素高亮

- Editor.vue 文档视图改为 contenteditable 可编辑模式
- 要素值在文档中自动高亮(不同要素不同颜色)
- 点击高亮区域弹出编辑框,可直接修改要素值并保存
- 高亮开关:可切换要素高亮显示/隐藏
- 修复字段映射:valueText/valueId/elementKey前缀匹配
- 修复 mock 数据:使用 value_text 属性键
- URL编码 elementKey 防止特殊字符问题
何文松 1 неделя назад
Родитель
Сommit
4c5487cfcb
2 измененных файлов с 380 добавлено и 92 удалено
  1. 1 1
      frontend/vue-demo/src/api/index.js
  2. 379 91
      frontend/vue-demo/src/views/Editor.vue

+ 1 - 1
frontend/vue-demo/src/api/index.js

@@ -139,7 +139,7 @@ export const valueApi = {
   },
 
   update(projectId, elementKey, data) {
-    return api.put(`/projects/${projectId}/values/${elementKey}`, data)
+    return api.put(`/projects/${projectId}/values/${encodeURIComponent(elementKey)}`, data)
   },
 
   batchUpdate(projectId, values) {

+ 379 - 91
frontend/vue-demo/src/views/Editor.vue

@@ -224,58 +224,32 @@
           <div class="editor-scroll" ref="editorRef" v-loading="loading" element-loading-text="正在加载项目...">
             <!-- 文档视图 -->
             <div class="document-view" v-if="viewMode === 'document'">
-              <!-- 附件选择栏 -->
-              <div class="doc-attachment-bar" v-if="attachments.length > 0">
-                <span class="doc-att-label">查看附件:</span>
-                <el-select v-model="docAttachmentId" placeholder="选择附件" size="small" style="width: 280px;" @change="loadDocContent">
-                  <el-option v-for="att in attachments" :key="att.id" :label="att.displayName" :value="att.id" />
-                </el-select>
+              <!-- 工具栏 -->
+              <div class="doc-toolbar">
+                <div class="doc-toolbar-left" v-if="attachments.length > 0">
+                  <span class="doc-att-label">附件:</span>
+                  <el-select v-model="docAttachmentId" placeholder="选择附件" size="small" style="width: 240px;" @change="loadDocContent">
+                    <el-option v-for="att in attachments" :key="att.id" :label="att.displayName" :value="att.id" />
+                  </el-select>
+                </div>
+                <div class="doc-toolbar-right">
+                  <el-switch v-model="highlightEnabled" active-text="要素高亮" inactive-text="" size="small" style="margin-right: 12px;" @change="renderDocHtml" />
+                  <el-tag v-if="highlightEnabled" size="small" type="info">{{ elementHighlightCount }} 处高亮</el-tag>
+                </div>
               </div>
 
-              <!-- 文档渲染区域 -->
-              <div class="doc-paper" v-if="docContent && docContent.blocks" :style="docPaperStyle">
-                <template v-for="block in docContent.blocks" :key="block.id">
-                  <!-- 段落/标题 -->
-                  <template v-if="block.type !== 'table'">
-                    <component
-                      :is="getBlockTag(block.type)"
-                      :class="['doc-block', 'doc-' + block.type]"
-                      :style="getBlockStyle(block.style)"
-                      :data-block-id="block.id"
-                    >
-                      <template v-if="block.images && block.images.length > 0">
-                        <img
-                          v-for="(img, idx) in block.images"
-                          :key="idx"
-                          :src="img.src"
-                          :style="getImageStyle(img)"
-                          class="doc-inline-image"
-                        />
-                      </template>
-                      <template v-if="block.runs">
-                        <template v-for="(run, ri) in block.runs" :key="ri">
-                          <span v-if="hasRunFormat(run)" :style="getRunStyle(run)">{{ run.text }}</span>
-                          <template v-else>{{ run.text }}</template>
-                        </template>
-                      </template>
-                    </component>
-                  </template>
-
-                  <!-- 表格 -->
-                  <table v-else class="doc-table" :data-block-id="block.id">
-                    <tr v-for="(row, ri) in block.table.data" :key="ri">
-                      <td
-                        v-for="(cell, ci) in row"
-                        :key="ci"
-                        :colspan="cell.colspan > 1 ? cell.colspan : undefined"
-                        class="doc-table-cell"
-                      >
-                        {{ cell.text }}
-                      </td>
-                    </tr>
-                  </table>
-                </template>
-              </div>
+              <!-- 文档渲染区域(可编辑) -->
+              <div
+                class="doc-paper"
+                v-if="docHtml"
+                :style="docPaperStyle"
+                contenteditable="true"
+                spellcheck="false"
+                v-html="docHtml"
+                @input="onDocInput"
+                @click="onDocClick"
+                ref="docPaperRef"
+              ></div>
 
               <!-- 无内容提示 -->
               <div class="doc-empty" v-else-if="!docLoading && docAttachmentId">
@@ -293,6 +267,37 @@
               </div>
             </div>
 
+            <!-- 要素高亮弹出框 -->
+            <div
+              v-if="highlightPopover.visible"
+              class="element-popover"
+              :style="{ top: highlightPopover.y + 'px', left: highlightPopover.x + 'px' }"
+            >
+              <div class="popover-header">
+                <span class="popover-label">{{ highlightPopover.elementName }}</span>
+                <el-tag size="small">{{ highlightPopover.elementKey }}</el-tag>
+              </div>
+              <div class="popover-body">
+                <div class="popover-field">
+                  <span class="popover-field-label">当前值:</span>
+                  <el-input
+                    v-model="highlightPopover.currentValue"
+                    size="small"
+                    placeholder="输入要素值"
+                    @keyup.enter="savePopoverValue"
+                  />
+                </div>
+                <div class="popover-field" v-if="highlightPopover.originalValue">
+                  <span class="popover-field-label">原始值:</span>
+                  <span class="popover-original">{{ highlightPopover.originalValue }}</span>
+                </div>
+              </div>
+              <div class="popover-footer">
+                <el-button size="small" @click="highlightPopover.visible = false">关闭</el-button>
+                <el-button size="small" type="primary" @click="savePopoverValue">保存</el-button>
+              </div>
+            </div>
+
             <!-- 要素视图 -->
             <div class="elements-view" v-if="viewMode === 'elements'">
               <div class="elements-grid">
@@ -302,11 +307,11 @@
                     <el-tag size="small" :type="elem.dataType === 'text' ? '' : 'warning'">{{ elem.dataType || '文本' }}</el-tag>
                   </div>
                   <div class="element-card-body">
-                    <div class="element-value-row" v-for="val in getElementValues(elem.elementKey)" :key="val.id">
-                      <el-input v-model="val.currentValue" :placeholder="val.originalValue || '暂无值'" size="small" @change="onValueChange(val)" />
-                      <div class="value-meta" v-if="val.originalValue">
-                        <span class="original-label">原值:</span>
-                        <span class="original-value">{{ val.originalValue }}</span>
+                    <div class="element-value-row" v-for="val in getElementValues(elem.elementKey)" :key="val.valueId">
+                      <el-input v-model="val.valueText" placeholder="暂无值" size="small" @change="onValueChange(val)" />
+                      <div class="value-meta" v-if="val.fillSource">
+                        <span class="original-label">来源:</span>
+                        <span class="original-value">{{ val.fillSource }}</span>
                       </div>
                       <div class="value-status">
                         <el-tag v-if="val.isFilled" type="success" size="small">已填充</el-tag>
@@ -539,6 +544,14 @@ const executingRules = ref(false)
 const docAttachmentId = ref(null)
 const docContent = ref(null)
 const docLoading = ref(false)
+const docHtml = ref('')
+const docPaperRef = ref(null)
+const highlightEnabled = ref(true)
+const elementHighlightCount = ref(0)
+const highlightPopover = reactive({
+  visible: false, x: 0, y: 0,
+  elementKey: '', fullElementKey: '', elementName: '', currentValue: '', originalValue: '', valueId: null
+})
 
 const showNewProjectDialog = ref(false)
 const showAddElementDialog = ref(false)
@@ -687,18 +700,21 @@ async function handleCopyProject() {
 
 function handleSave() { saved.value = true; ElMessage.success('保存成功') }
 
-// ==================== 文档预览 ====================
+// ==================== 文档预览 + 可编辑 + 要素高亮 ====================
 
 async function loadDocContent(attId) {
   if (!attId) return
   docLoading.value = true
   docContent.value = null
+  docHtml.value = ''
   try {
     const data = await attachmentApi.getDocContent(attId)
     docContent.value = data
+    renderDocHtml()
   } catch (e) {
     console.warn('加载文档内容失败:', e)
     docContent.value = null
+    docHtml.value = ''
   } finally {
     docLoading.value = false
   }
@@ -716,6 +732,125 @@ const docPaperStyle = computed(() => {
   }
 })
 
+// 从值的 elementKey 中提取不含项目前缀的 key
+// 例如 "PRJ-2024-001:basicInfo.projectCode" -> "basicInfo.projectCode"
+function stripValueKeyPrefix(valueElementKey) {
+  const idx = valueElementKey?.indexOf(':')
+  return idx >= 0 ? valueElementKey.substring(idx + 1) : valueElementKey
+}
+
+// 构建要素值映射表,用于在文档中高亮要素值文本
+function buildElementValueMap() {
+  const map = []
+  const colors = [
+    '#fff3cd', '#cce5ff', '#d4edda', '#f8d7da', '#e2d5f1',
+    '#d1ecf1', '#ffeeba', '#c3e6cb', '#f5c6cb', '#d6d8db'
+  ]
+  let colorIdx = 0
+  for (const elem of elements.value) {
+    // 值的 elementKey 带项目前缀,需要匹配
+    const elemValues = values.value.filter(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)
+    for (const val of elemValues) {
+      const text = val.valueText
+      if (text && text.length >= 2) {
+        map.push({
+          text,
+          elementKey: elem.elementKey,
+          fullElementKey: val.elementKey,  // 带前缀,用于 API 调用
+          elementName: elem.elementName,
+          valueId: val.valueId,
+          color: colors[colorIdx % colors.length]
+        })
+      }
+    }
+    colorIdx++
+  }
+  // 按文本长度降序排列,优先匹配长文本
+  map.sort((a, b) => b.text.length - a.text.length)
+  return map
+}
+
+// 将文档 blocks 渲染为 HTML 字符串(含要素高亮)
+function renderDocHtml() {
+  if (!docContent.value?.blocks) { docHtml.value = ''; return }
+  const blocks = docContent.value.blocks
+  const elemMap = highlightEnabled.value ? buildElementValueMap() : []
+  let highlightCount = 0
+  const parts = []
+
+  for (const block of blocks) {
+    if (block.type === 'table') {
+      parts.push(renderTableHtml(block))
+    } else {
+      parts.push(renderBlockHtml(block, elemMap, (n) => { highlightCount += n }))
+    }
+  }
+
+  elementHighlightCount.value = highlightCount
+  docHtml.value = parts.join('')
+}
+
+function renderBlockHtml(block, elemMap, countFn) {
+  const tag = getBlockTag(block.type)
+  const cls = `doc-block doc-${block.type}`
+  const styleStr = buildStyleStr(block.style)
+  const styleAttr = styleStr ? ` style="${styleStr}"` : ''
+
+  let inner = ''
+  // 图片
+  if (block.images?.length > 0) {
+    for (const img of block.images) {
+      const imgStyle = []
+      if (img.widthInch) imgStyle.push(`width:${img.widthInch}in`)
+      if (img.heightInch) imgStyle.push(`height:${img.heightInch}in`)
+      imgStyle.push('max-width:100%', 'display:block', 'margin:8px auto')
+      inner += `<img src="${img.src}" style="${imgStyle.join(';')}" class="doc-inline-image" contenteditable="false" />`
+    }
+  }
+  // Runs
+  if (block.runs) {
+    let runsHtml = ''
+    for (const run of block.runs) {
+      const text = escapeHtml(run.text)
+      const rs = buildRunStyleStr(run)
+      runsHtml += rs ? `<span style="${rs}">${text}</span>` : text
+    }
+    // 注入要素高亮
+    if (elemMap.length > 0 && runsHtml.length > 0) {
+      let count = 0
+      for (const em of elemMap) {
+        const escaped = escapeHtml(em.text)
+        if (runsHtml.includes(escaped)) {
+          const hl = `<span class="elem-highlight" data-elem-key="${em.elementKey}" data-value-id="${em.valueId || ''}" style="background:${em.color};border-bottom:2px solid ${darkenColor(em.color)};cursor:pointer;border-radius:2px;padding:0 1px;" contenteditable="false" title="${escapeAttr(em.elementName)}: ${escapeAttr(em.text)}">${escaped}</span>`
+          runsHtml = runsHtml.split(escaped).join(hl)
+          count++
+        }
+      }
+      if (count > 0) countFn(count)
+    }
+    inner += runsHtml
+  }
+
+  if (!inner) inner = '&nbsp;'
+  return `<${tag} class="${cls}"${styleAttr} data-block-id="${block.id}">${inner}</${tag}>`
+}
+
+function renderTableHtml(block) {
+  const t = block.table
+  if (!t?.data) return ''
+  let html = `<table class="doc-table" data-block-id="${block.id}">`
+  for (let ri = 0; ri < t.data.length; ri++) {
+    html += '<tr>'
+    for (const cell of t.data[ri]) {
+      const cs = cell.colspan > 1 ? ` colspan="${cell.colspan}"` : ''
+      html += `<td class="doc-table-cell"${cs}>${escapeHtml(cell.text)}</td>`
+    }
+    html += '</tr>'
+  }
+  html += '</table>'
+  return html
+}
+
 function getBlockTag(type) {
   if (type === 'heading1') return 'h1'
   if (type === 'heading2') return 'h2'
@@ -724,49 +859,118 @@ function getBlockTag(type) {
   return 'p'
 }
 
-function getBlockStyle(style) {
-  if (!style) return {}
-  const s = {}
+function buildStyleStr(style) {
+  if (!style) return ''
+  const parts = []
   if (style.alignment) {
     const map = { left: 'left', center: 'center', right: 'right', justify: 'justify', both: 'justify' }
-    s.textAlign = map[style.alignment] || style.alignment
+    parts.push(`text-align:${map[style.alignment] || style.alignment}`)
+  }
+  if (style.indentLeft) parts.push(`padding-left:${style.indentLeft / 914400}in`)
+  if (style.indentRight) parts.push(`padding-right:${style.indentRight / 914400}in`)
+  if (style.indentFirstLine) parts.push(`text-indent:${style.indentFirstLine / 914400}in`)
+  if (style.indentHanging) parts.push(`text-indent:-${style.indentHanging / 914400}in`)
+  if (style.spacingBefore) parts.push(`margin-top:${style.spacingBefore / 914400}in`)
+  if (style.spacingAfter) parts.push(`margin-bottom:${style.spacingAfter / 914400}in`)
+  if (style.lineSpacing && style.lineSpacing >= 1 && style.lineSpacing <= 5) parts.push(`line-height:${style.lineSpacing}`)
+  return parts.join(';')
+}
+
+function buildRunStyleStr(run) {
+  const parts = []
+  if (run.fontFamily) parts.push(`font-family:${run.fontFamily}`)
+  if (run.fontSize) parts.push(`font-size:${run.fontSize}pt`)
+  if (run.bold) parts.push('font-weight:bold')
+  if (run.italic) parts.push('font-style:italic')
+  if (run.color) parts.push(`color:${run.color.startsWith('#') ? run.color : '#' + run.color}`)
+  if (run.underline) parts.push('text-decoration:underline')
+  if (run.strikeThrough) parts.push('text-decoration:line-through')
+  return parts.join(';')
+}
+
+function escapeHtml(text) {
+  if (!text) return ''
+  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
+}
+
+function escapeAttr(text) {
+  if (!text) return ''
+  return text.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
+}
+
+function darkenColor(hex) {
+  // 简单加深颜色用于下边框
+  const map = {
+    '#fff3cd': '#e0a800', '#cce5ff': '#3d8bfd', '#d4edda': '#28a745',
+    '#f8d7da': '#dc3545', '#e2d5f1': '#6f42c1', '#d1ecf1': '#17a2b8',
+    '#ffeeba': '#d39e00', '#c3e6cb': '#1e7e34', '#f5c6cb': '#c82333',
+    '#d6d8db': '#6c757d'
   }
-  if (style.indentLeft) s.paddingLeft = `${style.indentLeft / 914400}in`
-  if (style.indentRight) s.paddingRight = `${style.indentRight / 914400}in`
-  if (style.indentFirstLine) s.textIndent = `${style.indentFirstLine / 914400}in`
-  if (style.indentHanging) s.textIndent = `-${style.indentHanging / 914400}in`
-  if (style.spacingBefore) s.marginTop = `${style.spacingBefore / 914400}in`
-  if (style.spacingAfter) s.marginBottom = `${style.spacingAfter / 914400}in`
-  if (style.lineSpacing && style.lineSpacing >= 1 && style.lineSpacing <= 5) s.lineHeight = style.lineSpacing
-  return s
+  return map[hex] || '#999'
 }
 
-function hasRunFormat(run) {
-  return run.bold || run.italic || run.fontSize || run.fontFamily || run.color || run.underline || run.strikeThrough || run.verticalAlign
+// 文档编辑事件
+function onDocInput() {
+  saved.value = false
 }
 
-function getRunStyle(run) {
-  const s = {}
-  if (run.fontFamily) s.fontFamily = run.fontFamily
-  if (run.fontSize) s.fontSize = `${run.fontSize}pt`
-  if (run.bold) s.fontWeight = 'bold'
-  if (run.italic) s.fontStyle = 'italic'
-  if (run.color) s.color = run.color.startsWith('#') ? run.color : `#${run.color}`
-  if (run.underline) s.textDecoration = 'underline'
-  if (run.strikeThrough) s.textDecoration = (s.textDecoration ? s.textDecoration + ' ' : '') + 'line-through'
-  return s
+// 点击文档中的高亮要素
+function onDocClick(e) {
+  const target = e.target.closest('.elem-highlight')
+  if (!target) {
+    highlightPopover.visible = false
+    return
+  }
+
+  const elemKey = target.dataset.elemKey
+  const valueId = target.dataset.valueId
+  const elem = elements.value.find(el => el.elementKey === elemKey)
+  const val = values.value.find(v => String(v.valueId) === String(valueId)) ||
+              values.value.find(v => stripValueKeyPrefix(v.elementKey) === elemKey)
+
+  if (!elem) return
+
+  const rect = target.getBoundingClientRect()
+  const scrollEl = editorRef.value
+  const scrollRect = scrollEl?.getBoundingClientRect() || { top: 0, left: 0 }
+
+  highlightPopover.elementKey = elemKey
+  highlightPopover.fullElementKey = val?.elementKey || ''
+  highlightPopover.elementName = elem.elementName
+  highlightPopover.currentValue = val?.valueText || ''
+  highlightPopover.originalValue = ''
+  highlightPopover.valueId = val?.valueId || null
+  highlightPopover.x = rect.left - scrollRect.left + scrollEl.scrollLeft
+  highlightPopover.y = rect.bottom - scrollRect.top + scrollEl.scrollTop + 4
+  highlightPopover.visible = true
 }
 
-function getImageStyle(img) {
-  const s = { maxWidth: '100%', display: 'block', margin: '8px auto' }
-  if (img.widthInch) s.width = `${img.widthInch}in`
-  if (img.heightInch) s.height = `${img.heightInch}in`
-  return s
+async function savePopoverValue() {
+  if (!highlightPopover.elementKey || !currentProjectId.value) {
+    ElMessage.warning('无法保存:未找到对应的值记录')
+    return
+  }
+  try {
+    // API: PUT /projects/{projectId}/values/{elementKey} with { valueText }
+    const apiKey = highlightPopover.fullElementKey || highlightPopover.elementKey
+    const result = await valueApi.update(currentProjectId.value, apiKey, { valueText: highlightPopover.currentValue })
+    // 更新本地数据
+    const val = values.value.find(v => stripValueKeyPrefix(v.elementKey) === highlightPopover.elementKey)
+    if (val) {
+      val.valueText = highlightPopover.currentValue
+      val.isFilled = !!highlightPopover.currentValue
+    }
+    highlightPopover.visible = false
+    renderDocHtml()
+    ElMessage.success('要素值已更新')
+  } catch (e) {
+    ElMessage.error('保存失败: ' + e.message)
+  }
 }
 
 // 要素/值
-function getElementValues(elementKey) { return values.value.filter(v => v.elementKey === elementKey) }
-function hasFilledValue(elementKey) { return values.value.some(v => v.elementKey === elementKey && v.isFilled) }
+function getElementValues(elementKey) { return values.value.filter(v => stripValueKeyPrefix(v.elementKey) === elementKey) }
+function hasFilledValue(elementKey) { return values.value.some(v => stripValueKeyPrefix(v.elementKey) === elementKey && v.isFilled) }
 function onValueChange(val) { saved.value = false; val.isModified = true }
 
 async function handleAddElement() {
@@ -3132,7 +3336,7 @@ onMounted(async () => {
 }
 
 // ==========================================
-// 文档视图 - Word 排版还原
+// 文档视图 - Word 排版还原 + 可编辑 + 要素高亮
 // ==========================================
 .document-view {
   display: flex;
@@ -3141,19 +3345,27 @@ onMounted(async () => {
   padding: 20px;
   background: #e8e8e8;
   min-height: 100%;
+  position: relative;
 
-  .doc-attachment-bar {
+  .doc-toolbar {
     display: flex;
     align-items: center;
-    gap: 8px;
+    justify-content: space-between;
+    gap: 12px;
     margin-bottom: 16px;
-    padding: 10px 16px;
+    padding: 8px 16px;
     background: var(--white);
     border-radius: var(--radius-md);
     box-shadow: var(--shadow-sm);
     width: 100%;
     max-width: 820px;
 
+    .doc-toolbar-left, .doc-toolbar-right {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+
     .doc-att-label {
       font-size: 13px;
       font-weight: 600;
@@ -3283,5 +3495,81 @@ onMounted(async () => {
     color: var(--text-2);
   }
 }
+
+// ==========================================
+// 要素高亮弹出框
+// ==========================================
+.element-popover {
+  position: absolute;
+  z-index: 1000;
+  background: #fff;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15);
+  width: 320px;
+  overflow: hidden;
+
+  .popover-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 10px 14px;
+    background: var(--bg);
+    border-bottom: 1px solid var(--border);
+
+    .popover-label {
+      font-weight: 600;
+      font-size: 14px;
+      color: var(--text-1);
+    }
+  }
+
+  .popover-body {
+    padding: 12px 14px;
+
+    .popover-field {
+      margin-bottom: 10px;
+
+      &:last-child {
+        margin-bottom: 0;
+      }
+
+      .popover-field-label {
+        display: block;
+        font-size: 12px;
+        color: var(--text-3);
+        margin-bottom: 4px;
+      }
+
+      .popover-original {
+        font-size: 13px;
+        color: var(--text-2);
+        background: var(--bg);
+        padding: 4px 8px;
+        border-radius: var(--radius-sm);
+        display: inline-block;
+      }
+    }
+  }
+
+  .popover-footer {
+    display: flex;
+    justify-content: flex-end;
+    gap: 8px;
+    padding: 8px 14px;
+    border-top: 1px solid var(--border);
+    background: var(--bg);
+  }
+}
+
+// 可编辑文档纸张的光标和选区样式
+.doc-paper[contenteditable="true"] {
+  outline: none;
+  cursor: text;
+
+  &:focus {
+    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15), 0 0 0 2px rgba(24, 144, 255, 0.2);
+  }
+}
 </style>