Sfoglia il codice sorgente

feat: AI确认改为+悬浮框;要素分动态/静态及填充规则(直接提取/AI总结/表格OCR)

- 报告右侧: 采纳/忽略确认移入+按钮,el-popover 悬浮展示
- 变量表单: 要素类型为动态/静态,动态支持填充源+填充规则(直接提取、AI总结、表格OCR),静态为固定值;表格OCR支持页码
- 报告要素表: 新增填充规则列,要素类型/填充源/填充规则展示统一
- 后端: Variable 新增 EXTRACT_TYPE_TABLE_OCR,ExtractionService 增加 extractTableByOcr 占位

Co-authored-by: Cursor <cursoragent@cursor.com>
何文松 2 settimane fa
parent
commit
d9659a8c89

+ 6 - 1
backend/extract-service/src/main/java/com/lingyue/extract/entity/Variable.java

@@ -84,7 +84,7 @@ public class Variable {
     
     // ==================== 提取方式 ====================
     
-    @Schema(description = "提取类型: direct-直接提取, ai_extract-AI字段提取, ai_summarize-AI总结")
+    @Schema(description = "提取类型: direct-直接提取, ai_extract-AI字段提取, ai_summarize-AI总结, table_ocr-表格OCR提取")
     @TableField("extract_type")
     private String extractType;
     
@@ -117,9 +117,14 @@ public class Variable {
     
     // ==================== 提取类型常量 ====================
     
+    /** 直接提取(按位置) */
     public static final String EXTRACT_TYPE_DIRECT = "direct";
+    /** AI 字段提取 */
     public static final String EXTRACT_TYPE_AI_EXTRACT = "ai_extract";
+    /** AI 总结(从几个文本段) */
     public static final String EXTRACT_TYPE_AI_SUMMARIZE = "ai_summarize";
+    /** 表格 OCR 提取(某个 PDF 的某页) */
+    public static final String EXTRACT_TYPE_TABLE_OCR = "table_ocr";
     
     // ==================== 值类型常量 ====================
     

+ 16 - 0
backend/extract-service/src/main/java/com/lingyue/extract/service/ExtractionService.java

@@ -174,6 +174,8 @@ public class ExtractionService {
             return extractByAI(variable, documentId);
         } else if (Variable.EXTRACT_TYPE_AI_SUMMARIZE.equals(extractType)) {
             return summarizeByAI(variable, documentId);
+        } else if (Variable.EXTRACT_TYPE_TABLE_OCR.equals(extractType)) {
+            return extractTableByOcr(variable, documentId);
         }
         
         // 默认返回示例值
@@ -248,6 +250,20 @@ public class ExtractionService {
         }
     }
     
+    /**
+     * 表格 OCR 提取(某个 PDF 的某页)
+     * extractConfig 可含: page(Integer), sourceFileAlias(String)
+     */
+    private String extractTableByOcr(Variable variable, String documentId) {
+        Map<String, Object> extractConfig = variable.getExtractConfig();
+        if (extractConfig == null) {
+            return variable.getExampleValue();
+        }
+        // TODO: 根据 variable.getSourceFileAlias() 或 extractConfig 中的 page 调用 OCR 服务,解析表格后返回
+        log.info("表格OCR提取: variable={}, documentId={}, page={}", variable.getName(), documentId, extractConfig.get("page"));
+        return variable.getExampleValue();
+    }
+    
     /**
      * 构建提取提示词
      */

+ 346 - 70
frontend/vue-demo/src/views/Editor.vue

@@ -339,19 +339,45 @@
               <el-button size="small" text @click="ignoreAllAiSuggestions">忽略全部</el-button>
             </div>
           </div>
+          <div class="element-option ai-highlight-option">
+            <el-switch
+              v-model="showAiSuggestionInDocument"
+              size="small"
+              @change="refreshDocumentHighlight"
+            />
+            <span class="option-label">正文中高亮 AI 建议</span>
+          </div>
           <div class="element-body">
             <div class="element-tags-wrap ai-tags">
               <div
                 v-for="entity in aiSuggestedEntities"
                 :key="entity.id"
                 class="var-tag ai-suggestion"
-                :class="[getEntityTypeClass(entity.type)]"
-                :title="`${getEntityTypeName(entity.type)}: ${entity.text} - 点击采纳`"
-                @click.stop="adoptEntity(entity)"
+                :class="[getEntityTypeClass(entity.type), { 'is-pending': pendingConfirmEntity?.id === entity.id }]"
+                :title="`${getEntityTypeName(entity.type)}: ${entity.text} - 点击定位,点击 + 确认采纳/忽略`"
+                @click.stop="selectEntityForConfirm(entity)"
               >
                 <span class="tag-icon">{{ getEntityTypeIcon(entity.type) }}</span>
                 <span class="tag-name">{{ entity.text }}</span>
-                <span class="tag-action">+</span>
+                <el-popover
+                  trigger="click"
+                  placement="top"
+                  :width="220"
+                  popper-class="ai-confirm-popover"
+                >
+                  <template #default>
+                    <div class="ai-confirm-popover-content">
+                      <div class="ai-confirm-text">确认「{{ entity.text }}」?</div>
+                      <div class="ai-confirm-actions">
+                        <el-button size="small" type="primary" @click="confirmEntityFromPopover(entity)">采纳</el-button>
+                        <el-button size="small" @click="ignoreEntityFromPopover(entity)">忽略</el-button>
+                      </div>
+                    </div>
+                  </template>
+                  <template #reference>
+                    <span class="tag-action" @click.stop>+</span>
+                  </template>
+                </el-popover>
               </div>
             </div>
           </div>
@@ -594,30 +620,36 @@
         <el-form-item label="示例值">
           <el-input v-model="variableForm.exampleValue" placeholder="文档中的原始值" />
         </el-form-item>
-        <el-form-item label="来源类型">
-          <el-select v-model="variableForm.sourceType" style="width: 100%">
-            <el-option label="从来源文件提取" value="document" />
-            <el-option label="手动输入" value="manual" />
-            <el-option label="引用其他变量" value="reference" />
-            <el-option label="固定值" value="fixed" />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="来源文件" v-if="variableForm.sourceType === 'document'">
-          <el-select v-model="variableForm.sourceFileAlias" style="width: 100%">
-            <el-option
-              v-for="sf in sourceFiles"
-              :key="sf.id"
-              :label="sf.alias"
-              :value="sf.alias"
-            />
-          </el-select>
+        <el-form-item label="要素类型">
+          <el-radio-group v-model="variableForm.sourceType">
+            <el-radio value="document">动态(有填充源 + 填充规则)</el-radio>
+            <el-radio value="fixed">静态(固定规则)</el-radio>
+          </el-radio-group>
         </el-form-item>
-        <el-form-item label="提取方式" v-if="variableForm.sourceType === 'document'">
-          <el-select v-model="variableForm.extractType" style="width: 100%">
-            <el-option label="直接提取" value="direct" />
-            <el-option label="AI 字段提取" value="ai_extract" />
-            <el-option label="AI 总结" value="ai_summarize" />
-          </el-select>
+        <template v-if="variableForm.sourceType === 'document'">
+          <el-form-item label="填充源">
+            <el-select v-model="variableForm.sourceFileAlias" style="width: 100%" placeholder="选择来源文件">
+              <el-option
+                v-for="sf in sourceFiles"
+                :key="sf.id"
+                :label="sf.alias"
+                :value="sf.alias"
+              />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="填充规则">
+            <el-select v-model="variableForm.extractType" style="width: 100%">
+              <el-option label="直接提取" value="direct" />
+              <el-option label="AI 总结(从几个文本段)" value="ai_summarize" />
+              <el-option label="表格 OCR 提取(某个 PDF 的某页)" value="table_ocr" />
+            </el-select>
+          </el-form-item>
+          <el-form-item v-if="variableForm.extractType === 'table_ocr'" label="页码">
+            <el-input-number v-model="variableForm.extractConfigPage" :min="1" placeholder="PDF 页码" style="width: 100%" />
+          </el-form-item>
+        </template>
+        <el-form-item v-else label="固定值">
+          <el-input v-model="variableForm.fixedValue" placeholder="静态要素的固定值" />
         </el-form-item>
       </el-form>
       <template #footer>
@@ -686,8 +718,8 @@
             </el-table-column>
             <el-table-column prop="elementType" label="要素类型" width="100">
               <template #default="{ row }">
-                <el-tag size="small" :type="row.isDynamic ? 'warning' : 'info'">
-                  {{ row.isDynamic ? '动态' : '静态' }}
+                <el-tag size="small" :type="getElementIsDynamic(row) ? 'warning' : 'info'">
+                  {{ getElementIsDynamic(row) ? '动态' : '静态' }}
                 </el-tag>
               </template>
             </el-table-column>
@@ -706,9 +738,14 @@
                 />
               </template>
             </el-table-column>
-            <el-table-column prop="source" label="填充源" width="140">
+            <el-table-column prop="source" label="填充源" width="120">
               <template #default="{ row }">
-                <span class="element-source">{{ row.source || row.sourceFile || '文档' }}</span>
+                <span class="element-source">{{ getElementFillSource(row) }}</span>
+              </template>
+            </el-table-column>
+            <el-table-column prop="fillRule" label="填充规则" width="160">
+              <template #default="{ row }">
+                <span class="element-fill-rule">{{ getElementFillRuleLabel(row) }}</span>
               </template>
             </el-table-column>
             <el-table-column label="操作" width="80" fixed="right">
@@ -974,6 +1011,9 @@ async function switchReport(report) {
     
     // 清空文档内容,显示空白提示
     blocks.value = []
+    documentParagraphs.value = []
+    documentImages.value = []
+    documentTables.value = []
     tocItems.value = []
     documentContent.value = emptyPlaceholder
     entities.value = []
@@ -1010,6 +1050,9 @@ function unselectReport() {
   
   // 清空文档内容
   blocks.value = []
+  documentParagraphs.value = []
+  documentImages.value = []
+  documentTables.value = []
   tocItems.value = []
   documentContent.value = emptyPlaceholder
   entities.value = []
@@ -1032,16 +1075,26 @@ async function loadDocumentById(documentId) {
     
     if (structuredDoc && structuredDoc.blocks && structuredDoc.blocks.length > 0) {
       blocks.value = structuredDoc.blocks
-      documentContent.value = renderStructuredDocument(structuredDoc)
-      entities.value = extractEntitiesFromBlocks(structuredDoc.blocks)
+      documentParagraphs.value = structuredDoc.paragraphs || []
+      documentImages.value = structuredDoc.images || []
+      documentTables.value = structuredDoc.tables || []
+      const extractedEntities = extractEntitiesFromBlocks(structuredDoc.blocks)
+      entities.value = extractedEntities
+      documentContent.value = renderStructuredDocument({ ...structuredDoc, entities: extractedEntities })
     } else {
       blocks.value = []
+      documentParagraphs.value = []
+      documentImages.value = []
+      documentTables.value = []
       documentContent.value = emptyPlaceholder
       entities.value = []
     }
   } catch (error) {
     console.warn('加载文档失败:', error)
     blocks.value = []
+    documentParagraphs.value = []
+    documentImages.value = []
+    documentTables.value = []
     documentContent.value = emptyPlaceholder
     entities.value = []
   } finally {
@@ -1205,6 +1258,10 @@ const newSourceFile = reactive({
 
 // 文档结构块(用于生成目录等)
 const blocks = ref([])
+// 与 blocks 同源的段落/图片/表格(用于 refreshDocumentHighlight 时保留排版格式)
+const documentParagraphs = ref([])
+const documentImages = ref([])
+const documentTables = ref([])
 
 // 目录数据(从 API 获取)
 const tocItems = ref([])
@@ -1354,6 +1411,20 @@ const myEntities = computed(() => {
   return filteredEntities.value.filter(e => e.confirmed)
 })
 
+// 报告要素 Tab:dynamic | static
+const elementTab = ref('dynamic')
+
+// 动态要素(有填充源 + 填充规则:直接提取 / AI总结 / 表格OCR提取)
+const dynamicEntities = computed(() => myEntities.value.filter(e => getElementIsDynamic(e)))
+
+// 静态要素(固定规则)
+const staticEntities = computed(() => myEntities.value.filter(e => !getElementIsDynamic(e)))
+
+// 切换报告要素 Tab
+function switchElementTab(tab) {
+  elementTab.value = tab
+}
+
 // 计算属性:AI 识别的要素(未确认)
 const aiSuggestedEntities = computed(() => {
   return filteredEntities.value.filter(e => !e.confirmed)
@@ -1371,11 +1442,84 @@ function toggleEntityTypeFilter(type) {
 // 采纳 AI 建议的实体
 function adoptEntity(entity) {
   entity.confirmed = true
-  // 重新渲染文档以更新高亮
   refreshDocumentHighlight()
   ElMessage.success(`已采纳要素「${entity.text}」`)
 }
 
+// 点击 AI 建议:在文档中单独高亮该要素,并显示采纳/忽略确认栏(不直接添加)
+function selectEntityForConfirm(entity) {
+  clearPendingConfirmHighlight()
+  pendingConfirmEntity.value = entity
+  // 未开启「正文高亮 AI 建议」时,先重渲染正文使当前待确认实体出现,再跳转并高亮
+  if (!showAiSuggestionInDocument.value) {
+    refreshDocumentHighlight()
+    // 正文 DOM 由 watch(documentContent) 在 nextTick 里更新,需再等一帧再跳转
+    nextTick(() => {
+      nextTick(() => {
+        scrollToEntityAndHighlightForConfirm(entity.id)
+      })
+    })
+  } else {
+    nextTick(() => {
+      scrollToEntityAndHighlightForConfirm(entity.id)
+    })
+  }
+}
+
+// 滚动到文档中的实体并添加「待确认」高亮(仅该要素)
+function scrollToEntityAndHighlightForConfirm(entityId) {
+  const editorEl = document.querySelector('.editor-content')
+  if (!editorEl) return
+  const entitySpan = editorEl.querySelector(`[data-entity-id="${entityId}"]`)
+  if (entitySpan) {
+    entitySpan.scrollIntoView({ behavior: 'smooth', block: 'center' })
+    entitySpan.classList.add('entity-pending-confirm')
+  }
+}
+
+// 移除文档中「待确认」高亮
+function clearPendingConfirmHighlight() {
+  const editorEl = document.querySelector('.editor-content')
+  if (!editorEl) return
+  editorEl.querySelectorAll('.entity-pending-confirm').forEach(el => el.classList.remove('entity-pending-confirm'))
+}
+
+// 用户确认采纳当前待确认的实体
+function confirmPendingEntity() {
+  if (!pendingConfirmEntity.value) return
+  adoptEntity(pendingConfirmEntity.value)
+  clearPendingConfirmHighlight()
+  pendingConfirmEntity.value = null
+}
+
+// 用户选择忽略当前待确认的实体
+function cancelPendingEntity() {
+  if (!pendingConfirmEntity.value) return
+  ignoreEntity(pendingConfirmEntity.value)
+  clearPendingConfirmHighlight()
+  pendingConfirmEntity.value = null
+  refreshDocumentHighlight()
+}
+
+// 从「+」按钮悬浮框中采纳实体
+function confirmEntityFromPopover(entity) {
+  adoptEntity(entity)
+  clearPendingConfirmHighlight()
+  if (pendingConfirmEntity.value?.id === entity.id) {
+    pendingConfirmEntity.value = null
+  }
+}
+
+// 从「+」按钮悬浮框中忽略实体
+function ignoreEntityFromPopover(entity) {
+  ignoreEntity(entity)
+  clearPendingConfirmHighlight()
+  if (pendingConfirmEntity.value?.id === entity.id) {
+    pendingConfirmEntity.value = null
+  }
+  refreshDocumentHighlight()
+}
+
 // 忽略 AI 建议的实体
 function ignoreEntity(entity) {
   const index = entities.value.findIndex(e => e.id === entity.id)
@@ -1401,7 +1545,7 @@ function adoptAllAiSuggestions() {
   ElMessage.success('已采纳所有 AI 建议')
 }
 
-// 刷新文档高亮
+// 刷新文档高亮(保留段落/图片/表格等排版格式)
 function refreshDocumentHighlight() {
   if (blocks.value && blocks.value.length > 0) {
     // 更新 blocks 中的 confirmed 状态
@@ -1417,8 +1561,14 @@ function refreshDocumentHighlight() {
         })
       }
     })
-    // 重新渲染
-    documentContent.value = renderStructuredDocument({ blocks: blocks.value })
+    // 重新渲染时传入 paragraphs/images/tables,避免丢失排版格式
+    documentContent.value = renderStructuredDocument({
+      blocks: blocks.value,
+      paragraphs: documentParagraphs.value,
+      images: documentImages.value,
+      tables: documentTables.value,
+      entities: entities.value
+    })
   }
 }
 
@@ -1587,7 +1737,9 @@ const variableForm = reactive({
   exampleValue: '',
   sourceType: 'document',
   sourceFileAlias: '',
-  extractType: 'direct'
+  extractType: 'direct',
+  extractConfigPage: undefined,
+  fixedValue: ''
 })
 
 // 右键菜单
@@ -1612,11 +1764,11 @@ const savingElements = ref(false)
 const filteredElementsList = computed(() => {
   let list = myEntities.value || []
   
-  // 按类型筛选
+  // 按类型筛选:动态 = 有填充源+填充规则,静态 = 固定规则
   if (elementsTypeFilter.value === 'dynamic') {
-    list = list.filter(e => e.isDynamic)
+    list = list.filter(e => getElementIsDynamic(e))
   } else if (elementsTypeFilter.value === 'static') {
-    list = list.filter(e => !e.isDynamic)
+    list = list.filter(e => !getElementIsDynamic(e))
   }
   
   // 按关键词搜索
@@ -1651,6 +1803,32 @@ function getDataTypeTagType(dataType) {
   return typeMap[dataType] || ''
 }
 
+// 要素是否动态:有填充源+填充规则为动态,固定规则为静态
+function getElementIsDynamic(row) {
+  if (row.sourceType !== undefined && row.sourceType !== null) {
+    return row.sourceType !== 'fixed'
+  }
+  return (row.isDynamic !== false)
+}
+
+// 要素填充源展示:动态为来源文件别名,静态为「固定值」
+function getElementFillSource(row) {
+  if (row.sourceType === 'fixed') return '固定值'
+  return row.sourceFileAlias || row.source || row.sourceFile || '文档'
+}
+
+// 要素填充规则展示:直接提取 / AI总结 / 表格OCR提取 / 固定值
+function getElementFillRuleLabel(row) {
+  if (row.sourceType === 'fixed') return '固定值'
+  const labels = {
+    direct: '直接提取',
+    ai_summarize: 'AI总结(从几个文本段)',
+    table_ocr: '表格OCR提取(某个PDF的某页)',
+    ai_extract: 'AI字段提取'
+  }
+  return labels[row.extractType] || '直接提取'
+}
+
 // 要素值变更
 function onElementValueChange(element) {
   // 标记为已修改
@@ -1718,6 +1896,12 @@ async function saveAllElements() {
 const showEntityEditModal = ref(false)
 const editingEntity = ref(null)
 
+// AI 建议点击后待确认的实体(点击后高亮文档中的该要素,由用户选择采纳/忽略)
+const pendingConfirmEntity = ref(null)
+
+// 是否在正文中高亮显示 AI 建议(未确认实体),默认关闭
+const showAiSuggestionInDocument = ref(false)
+
 /**
  * 打开实体编辑弹窗
  */
@@ -1830,8 +2014,9 @@ function renderStructuredDocument(structuredDoc) {
   // 将所有元素合并
   const allElements = []
   
-  // 从 blocks 中提取实体映射(按文本内容匹配)
-  const entityMap = buildEntityMap(blocks)
+  // 从 blocks 中提取实体映射;若 structuredDoc.entities 存在则用其过滤(如加载时),否则用当前 entities
+  const entitiesForMap = structuredDoc.entities ?? entities.value
+  const entityMap = buildEntityMap(blocks, entitiesForMap)
   
   // 检查 paragraphs 是否有 runs(带格式信息)
   const hasParagraphsWithRuns = paragraphs.some(p => p.runs && p.runs.length > 0)
@@ -1952,28 +2137,37 @@ function renderTable(table, entityMap) {
 }
 
 /**
- * 从 blocks 中构建实体映射(只包含已确认的实体用于高亮)
- * 返回 { entityText: { entityId, entityType, confirmed } }
+ * 从 blocks 中构建实体映射(包含已确认与未确认实体,用于高亮与定位)
+ * 若传入 entitiesList,仅包含仍在列表中的实体(忽略后不再显示)
+ * 返回 Map<entityText, [{ entityId, entityType, confirmed }]>
  */
-function buildEntityMap(blocks) {
+function buildEntityMap(blocks, entitiesList) {
   const entityMap = new Map()
   
   blocks.forEach(block => {
     if (!block.elements) return
     
     block.elements.forEach(el => {
-      // 只有已确认的实体才会被高亮显示
-      if (el.type === 'entity' && el.entityText && el.confirmed) {
-        // 使用实体文本作为 key(可能有多个相同文本的实体)
-        if (!entityMap.has(el.entityText)) {
-          entityMap.set(el.entityText, [])
-        }
-        entityMap.get(el.entityText).push({
-          entityId: el.entityId,
-          entityType: el.entityType,
-          confirmed: el.confirmed
-        })
+      if (el.type !== 'entity' || !el.entityText) return
+      // 若传入了实体列表,只包含仍在列表中的实体(被忽略的不再显示)
+      if (entitiesList && entitiesList.length > 0) {
+        const inList = entitiesList.some(e => e && e.id === el.entityId)
+        if (!inList) return
+      }
+      // 未确认的 AI 建议:仅当开关打开、或该实体为当前待确认项时在正文中显示(未开开关时也可跳转并高亮)
+      const confirmed = el.confirmed || false
+      if (!confirmed && !showAiSuggestionInDocument.value) {
+        const isPendingConfirm = pendingConfirmEntity.value && pendingConfirmEntity.value.id === el.entityId
+        if (!isPendingConfirm) return
+      }
+      if (!entityMap.has(el.entityText)) {
+        entityMap.set(el.entityText, [])
       }
+      entityMap.get(el.entityText).push({
+        entityId: el.entityId,
+        entityType: el.entityType,
+        confirmed
+      })
     })
   })
   
@@ -2133,7 +2327,9 @@ function escapeNonEntityText(text) {
  * 渲染实体高亮标签
  */
 function renderEntityHighlight(text, entity) {
-  const cssClass = getEntityCssClass(entity.entityType)
+  const baseClass = getEntityCssClass(entity.entityType)
+  const pendingClass = entity.confirmed ? '' : ' ai-suggestion-pending'
+  const cssClass = baseClass + pendingClass
   const confirmedMark = entity.confirmed ? ' ✓' : ''
   
   return `<span class="${cssClass}" ` +
@@ -2438,10 +2634,13 @@ async function handleRegenerateBlocks() {
     // 重新加载文档内容
     const structuredDoc = await documentApi.getStructured(baseDocumentId)
     if (structuredDoc && structuredDoc.blocks && structuredDoc.blocks.length > 0) {
-      blocks.value = structuredDoc.blocks // 更新 blocks
-      documentContent.value = renderStructuredDocument(structuredDoc)
-      // 重新提取实体
-      entities.value = extractEntitiesFromBlocks(structuredDoc.blocks)
+      blocks.value = structuredDoc.blocks
+      documentParagraphs.value = structuredDoc.paragraphs || []
+      documentImages.value = structuredDoc.images || []
+      documentTables.value = structuredDoc.tables || []
+      const extractedEntities = extractEntitiesFromBlocks(structuredDoc.blocks)
+      entities.value = extractedEntities
+      documentContent.value = renderStructuredDocument({ ...structuredDoc, entities: extractedEntities })
     }
   } catch (error) {
     console.error('重新生成失败:', error)
@@ -2637,7 +2836,17 @@ function scrollToEntity(entityId) {
 
 function editVariable(variable) {
   editingVariable.value = variable
-  Object.assign(variableForm, variable)
+  Object.assign(variableForm, {
+    name: variable.name ?? '',
+    displayName: variable.displayName ?? '',
+    category: variable.category ?? 'entity',
+    exampleValue: variable.exampleValue ?? '',
+    sourceType: variable.sourceType ?? 'document',
+    sourceFileAlias: variable.sourceFileAlias ?? '',
+    extractType: variable.extractType ?? 'direct',
+    extractConfigPage: variable.extractConfig?.page,
+    fixedValue: variable.sourceType === 'fixed' ? (variable.exampleValue ?? '') : ''
+  })
   showVariableDialog.value = true
 }
 
@@ -2647,15 +2856,26 @@ async function saveVariable() {
     return
   }
 
+  const payload = {
+    name: variableForm.name,
+    displayName: variableForm.displayName,
+    category: variableForm.category,
+    exampleValue: variableForm.sourceType === 'fixed' ? variableForm.fixedValue : variableForm.exampleValue,
+    sourceType: variableForm.sourceType,
+    sourceFileAlias: variableForm.sourceType === 'document' ? variableForm.sourceFileAlias : undefined,
+    extractType: variableForm.sourceType === 'document' ? variableForm.extractType : undefined,
+    extractConfig: variableForm.sourceType === 'document' && variableForm.extractType === 'table_ocr' && variableForm.extractConfigPage
+      ? { page: variableForm.extractConfigPage }
+      : undefined
+  }
+
   try {
     if (editingVariable.value) {
-      // 更新
-      const updated = await templateStore.updateVariable(editingVariable.value.id, variableForm)
+      const updated = await templateStore.updateVariable(editingVariable.value.id, payload)
       Object.assign(editingVariable.value, updated)
       ElMessage.success('更新成功')
     } else {
-      // 新增
-      const newVar = await templateStore.addVariable(templateId, variableForm)
+      const newVar = await templateStore.addVariable(templateId, payload)
       variables.value.push(newVar)
       ElMessage.success('添加成功')
     }
@@ -2690,7 +2910,9 @@ function resetVariableForm() {
     exampleValue: '',
     sourceType: 'document',
     sourceFileAlias: '',
-    extractType: 'direct'
+    extractType: 'direct',
+    extractConfigPage: undefined,
+    fixedValue: ''
   })
 }
 
@@ -2935,8 +3157,12 @@ async function refreshDocumentContent() {
     const structuredDoc = await documentApi.getStructured(baseDocumentId)
     if (structuredDoc && structuredDoc.blocks && structuredDoc.blocks.length > 0) {
       blocks.value = structuredDoc.blocks
-      documentContent.value = renderStructuredDocument(structuredDoc)
-      entities.value = extractEntitiesFromBlocks(structuredDoc.blocks)
+      documentParagraphs.value = structuredDoc.paragraphs || []
+      documentImages.value = structuredDoc.images || []
+      documentTables.value = structuredDoc.tables || []
+      const extractedEntities = extractEntitiesFromBlocks(structuredDoc.blocks)
+      entities.value = extractedEntities
+      documentContent.value = renderStructuredDocument({ ...structuredDoc, entities: extractedEntities })
     }
   } catch (error) {
     console.error('刷新文档内容失败:', error)
@@ -4155,6 +4381,18 @@ onUnmounted(() => {
         background: rgba(47, 84, 235, 0.1);
         &:hover { background: #2f54eb; color: white; }
       }
+      
+      // 未确认的 AI 建议(文档中虚线样式)
+      &.ai-suggestion-pending {
+        border-style: dashed;
+        opacity: 0.9;
+      }
+      
+      // 点击 AI 建议后,文档中该要素的「待确认」高亮
+      &.entity-pending-confirm {
+        box-shadow: 0 0 0 2px #1890ff;
+        opacity: 1;
+      }
     }
   }
 }
@@ -4261,6 +4499,18 @@ onUnmounted(() => {
       }
     }
     
+    .element-option.ai-highlight-option {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      padding: 8px 16px;
+      font-size: 12px;
+      color: var(--text-2);
+      .option-label {
+        flex: 1;
+      }
+    }
+    
     .element-tags-wrap {
       max-height: 300px;
     }
@@ -4518,7 +4768,16 @@ onUnmounted(() => {
     &.entity-default {
       border-left: 3px solid #8c8c8c;
     }
+    
+    // 当前正在确认的 AI 建议 tag
+    &.is-pending {
+      border-color: var(--primary);
+      background: var(--primary-light);
+      border-style: solid;
+    }
   }
+  
+  // AI 建议确认栏已移至「+」按钮的悬浮框内,此处样式仅作保留注释
 
   .element-hint {
     font-size: 12px;
@@ -5197,3 +5456,20 @@ onUnmounted(() => {
   }
 }
 </style>
+
+<style lang="scss">
+/* AI 确认悬浮框(挂载在 body,需全局样式) */
+.ai-confirm-popover.el-popper {
+  .ai-confirm-popover-content {
+    .ai-confirm-text {
+      font-size: 12px;
+      color: var(--text-2);
+      margin-bottom: 8px;
+    }
+    .ai-confirm-actions {
+      display: flex;
+      gap: 8px;
+    }
+  }
+}
+</style>