ソースを参照

feat(editor): add source viewing and report elements panel redesign

Source viewing feature:
- Add formatInputSource() to display ZIP entry paths
- Add getInputSourceText() to get source excerpt from rule inputs
- Add openSourceInViewer() to open attachment viewer with highlight
- Add highlightSourceText ref and scrollToHighlight() for auto-scroll
- Source text highlighting with pulse animation in viewer
- Clickable attachment links in element popover

Report elements panel redesign:
- Group elements by namespace (project, basicInfo, etc.)
- Collapsible groups with expand/collapse toggle
- Element type indicators (T=text, P=paragraph, ▦=table)
- Search functionality for filtering elements
- Value preview in element list
- Click to scroll and open element in document
- Active state highlighting for selected element
何文松 13 時間 前
コミット
923f2218d1
1 ファイル変更561 行追加149 行削除
  1. 561 149
      frontend/vue-demo/src/views/Editor.vue

+ 561 - 149
frontend/vue-demo/src/views/Editor.vue

@@ -253,9 +253,20 @@
                     <div class="rule-trace-info">
                       <div class="rule-trace-name">{{ rule.ruleName }}</div>
                       <div v-if="rule.inputs && rule.inputs.length" class="rule-trace-sources">
-                        <span v-for="inp in rule.inputs" :key="inp.inputId" class="rule-trace-att">📎 {{ inp.sourceName }}</span>
+                        <span 
+                          v-for="inp in rule.inputs" 
+                          :key="inp.inputId" 
+                          class="rule-trace-att clickable"
+                          @click="openSourceInViewer(inp)"
+                          title="点击查看来源"
+                        >📎 {{ formatInputSource(inp) }}</span>
                       </div>
-                      <div v-if="ruleSourceText(rule)" class="rule-trace-excerpt">
+                      <!-- 显示来源摘要 -->
+                      <div v-if="getInputSourceText(rule)" class="rule-trace-source-text">
+                        <span class="source-text-label">来源段落:</span>
+                        <span class="source-text-content">{{ getInputSourceText(rule) }}</span>
+                      </div>
+                      <div v-else-if="ruleSourceText(rule)" class="rule-trace-excerpt">
                         <span class="rule-trace-excerpt-text">{{ ruleSourceText(rule) }}</span>
                       </div>
                     </div>
@@ -300,22 +311,59 @@
             <div class="rp-elements-title">
               <span class="rp-title-icon">📋</span>
               <span class="rp-title-text">报告要素</span>
+              <span class="rp-title-count">{{ filledValues.length }}</span>
+            </div>
+            <div class="rp-header-actions">
               <el-button text circle size="small" title="附件" @click="showAttachmentDialog = true"><el-icon><CopyDocument /></el-icon></el-button>
               <el-button text circle size="small" title="规则" @click="showRuleDialog = true"><el-icon><MoreFilled /></el-icon></el-button>
+              <el-input
+                v-if="elementSearchVisible"
+                v-model="elementSearchQuery"
+                size="small"
+                placeholder="搜索要素..."
+                clearable
+                style="width: 120px"
+                @blur="elementSearchVisible = elementSearchQuery.length > 0"
+              />
+              <el-button v-else text circle size="small" title="搜索" @click="elementSearchVisible = true"><el-icon><Search /></el-icon></el-button>
             </div>
-            <el-button text circle size="small" title="搜索"><el-icon><Search /></el-icon></el-button>
           </div>
           <div class="rp-elements-body">
-            <div class="rp-value-tags" v-if="filledValues.length > 0">
-              <span
-                v-for="val in filledValues"
-                :key="val.valueId"
-                class="rp-value-tag"
-                :title="val.elementName"
-              >{{ val.valueText }}</span>
+            <div class="rp-element-list" v-if="groupedElements.length > 0">
+              <div 
+                v-for="group in groupedElements" 
+                :key="group.namespace" 
+                class="rp-element-group"
+              >
+                <div class="rp-group-header" @click="toggleElementGroup(group.namespace)">
+                  <span class="rp-group-icon">{{ elementGroupExpanded[group.namespace] ? '▼' : '▶' }}</span>
+                  <span class="rp-group-name">{{ group.label }}</span>
+                  <span class="rp-group-count">{{ group.items.length }}</span>
+                </div>
+                <div class="rp-group-items" v-show="elementGroupExpanded[group.namespace]">
+                  <div 
+                    v-for="item in group.items" 
+                    :key="item.elementKey"
+                    class="rp-element-item"
+                    :class="{ 
+                      'is-text': item.elementType === 'text',
+                      'is-paragraph': item.elementType === 'paragraph',
+                      'is-table': item.elementType === 'table',
+                      'has-value': item.hasValue,
+                      'is-active': highlightPopover.elementKey === item.elementKey
+                    }"
+                    @click="scrollToElement(item)"
+                    :title="item.valueText || '暂无值'"
+                  >
+                    <span class="rp-item-type">{{ item.elementType === 'text' ? 'T' : item.elementType === 'paragraph' ? 'P' : '▦' }}</span>
+                    <span class="rp-item-name">{{ item.elementName }}</span>
+                    <span v-if="item.hasValue" class="rp-item-preview">{{ truncateValue(item.valueText, 20) }}</span>
+                  </div>
+                </div>
+              </div>
             </div>
             <div class="rp-elements-empty" v-else>
-              <span>暂无要素值</span>
+              <span>暂无要素</span>
             </div>
           </div>
         </div>
@@ -649,7 +697,7 @@
     <el-dialog
       v-model="showRuleDialog"
       title="⚙️ 规则管理"
-      width="720"
+      width="800"
       :close-on-click-modal="true"
       class="floating-panel-dialog rule-manage-dialog"
       align-center
@@ -660,7 +708,7 @@
           size="small"
           placeholder="搜索规则名称 / 要素标识..."
           clearable
-          style="width: 220px"
+          style="width: 280px"
           :prefix-icon="Search"
         />
         <el-button size="small" :icon="Plus" @click="showNewRuleDialog = true">添加规则</el-button>
@@ -724,7 +772,7 @@
                 <span class="rule-name">{{ rule.ruleName }}</span>
                 <el-tag size="small" type="info" effect="plain" class="rule-elem-key">{{ rule.elementKey }}</el-tag>
               </div>
-              <div class="rule-desc" v-if="rule.description">{{ rule.description }}</div>
+              <div class="rule-desc" v-if="ruleSourceSummary(rule)">来源:{{ ruleSourceSummary(rule) }}</div>
             </div>
             <div class="rule-actions">
               <el-button v-if="rule.actionType !== 'use_entity_value'" size="small" type="primary" text @click.stop="handleExecuteRule(rule)" title="执行" :loading="rule._executing">▶</el-button>
@@ -737,11 +785,11 @@
               <span class="rule-detail-label">取值规则</span>
               <span class="rule-detail-value">{{ rule.dslContent }}</span>
             </div>
-            <div class="rule-detail-row" v-if="rule.inputs && rule.inputs.length > 0">
+            <div class="rule-detail-row" v-if="ruleInputDisplayList(rule).length > 0">
               <span class="rule-detail-label">输入来源</span>
               <div class="rule-detail-inputs">
-                <span v-for="inp in rule.inputs" :key="inp.inputId" class="rule-input-chip">
-                  📎 {{ inp.inputName || inp.sourceName }}
+                <span v-for="(src, idx) in ruleInputDisplayList(rule)" :key="`${rule.id}-src-${idx}`" class="rule-input-chip">
+                  📎 {{ src }}
                 </span>
               </div>
             </div>
@@ -916,6 +964,91 @@ const filledValues = computed(() => {
   return result
 })
 
+// 要素搜索和分组
+const elementSearchVisible = ref(false)
+const elementSearchQuery = ref('')
+const elementGroupExpanded = reactive({})
+
+const NAMESPACE_LABELS = {
+  'project': '项目信息',
+  'basicInfo': '基本信息',
+  'review': '评审信息',
+  'score': '评分信息',
+  '+': '扩展要素',
+  'other': '其他'
+}
+
+const groupedElements = computed(() => {
+  const groups = {}
+  const query = elementSearchQuery.value.toLowerCase()
+  
+  for (const elem of elements.value) {
+    // 搜索过滤
+    if (query && !elem.elementName.toLowerCase().includes(query) && !elem.elementKey.toLowerCase().includes(query)) {
+      continue
+    }
+    
+    // 获取命名空间
+    let namespace = 'other'
+    if (elem.elementKey.startsWith('+')) {
+      namespace = '+'
+    } else if (elem.elementKey.includes('.')) {
+      namespace = elem.elementKey.split('.')[0]
+    }
+    
+    if (!groups[namespace]) {
+      groups[namespace] = {
+        namespace,
+        label: NAMESPACE_LABELS[namespace] || namespace,
+        items: []
+      }
+      // 默认展开第一个分组
+      if (elementGroupExpanded[namespace] === undefined) {
+        elementGroupExpanded[namespace] = Object.keys(groups).length === 1
+      }
+    }
+    
+    // 查找对应的值
+    const val = values.value.find(v => stripValueKeyPrefix(v.elementKey) === elem.elementKey)
+    
+    groups[namespace].items.push({
+      elementKey: elem.elementKey,
+      elementName: elem.elementName,
+      elementType: elem.elementType || 'text',
+      hasValue: !!(val?.valueText),
+      valueText: val?.valueText || ''
+    })
+  }
+  
+  // 按顺序排列
+  const order = ['project', 'basicInfo', 'review', 'score', '+', 'other']
+  return order.filter(ns => groups[ns]).map(ns => groups[ns])
+})
+
+function toggleElementGroup(namespace) {
+  elementGroupExpanded[namespace] = !elementGroupExpanded[namespace]
+}
+
+function truncateValue(text, maxLen = 20) {
+  if (!text) return ''
+  const s = String(text).replace(/\n/g, ' ').trim()
+  return s.length > maxLen ? s.slice(0, maxLen) + '...' : s
+}
+
+function scrollToElement(item) {
+  // 查找文档中对应的高亮元素并滚动到视图
+  const docPaper = docPaperRef.value
+  if (!docPaper) return
+  
+  const selector = `[data-element-key="${item.elementKey}"]`
+  const el = docPaper.querySelector(selector)
+  if (el) {
+    el.scrollIntoView({ behavior: 'smooth', block: 'center' })
+    // 触发点击以打开弹窗
+    el.click()
+  }
+}
+
 // 文档预览状态
 const docContent = ref(null)
 const docLoading = ref(false)
@@ -965,7 +1098,8 @@ const filteredRules = computed(() => {
     list = list.filter(r =>
       (r.ruleName || '').toLowerCase().includes(q) ||
       (r.elementKey || '').toLowerCase().includes(q) ||
-      (r.description || '').toLowerCase().includes(q)
+      (r.description || '').toLowerCase().includes(q) ||
+      ruleSourceSummary(r).toLowerCase().includes(q)
     )
   }
   return list
@@ -984,12 +1118,131 @@ function toggleRuleExpand(ruleId) {
   expandedRuleId.value = expandedRuleId.value === ruleId ? null : ruleId
 }
 
-function ruleInputSummary(rule) {
-  if (!rule.inputs || rule.inputs.length === 0) return ''
-  return rule.inputs.map(i => i.inputName || i.sourceName || '').filter(Boolean).join('、')
+function normalizeRuleSourceName(name) {
+  const s = String(name || '').trim()
+  if (!s) return ''
+  return s.replace(/^来源[::]\s*/i, '')
+}
+
+function formatInputSource(inp) {
+  const sourceName = inp?.sourceName || inp?.inputName || ''
+  const entryPath = inp?.entryPath
+  if (entryPath) {
+    // 有 entryPath 时,显示 "附件名 → 文件名"
+    const fileName = entryPath.split('/').pop()
+    return `${sourceName} → ${fileName}`
+  }
+  return sourceName
+}
+
+function getInputSourceText(rule) {
+  // 从规则的 inputs 中获取 sourceText
+  const inputs = Array.isArray(rule?.inputs) ? rule.inputs : []
+  for (const inp of inputs) {
+    if (inp.sourceText) return inp.sourceText
+  }
+  return ''
+}
+
+async function openSourceInViewer(inp) {
+  // 关闭弹窗
+  highlightPopover.visible = false
+  
+  const attId = inp.sourceNodeId
+  const entryPath = inp.entryPath
+  const sourceText = inp.sourceText
+  
+  // 查找附件
+  const att = attachments.value.find(a => a.id === attId)
+  if (!att) {
+    ElMessage.warning('未找到来源附件')
+    return
+  }
+  
+  // 设置高亮文本(用于在查看器中高亮)
+  highlightSourceText.value = sourceText || ''
+  
+  // 判断是否是 ZIP 文件
+  const ext = (att.fileType || '').toLowerCase()
+  if (ext === 'zip' && entryPath) {
+    // 打开 ZIP 内容查看器,并定位到指定文件
+    await handleZipAttachment(att)
+    // 等待 ZIP 内容加载完成后,自动打开指定的 entry
+    setTimeout(() => {
+      const fileName = entryPath.split('/').pop()
+      const zf = zipFileList.value.find(f => f.name === entryPath || f.name.endsWith('/' + fileName) || f.name.endsWith(fileName))
+      if (zf && zf.parsed) {
+        viewZipEntryResult(zf)
+      } else if (zf) {
+        // 如果还没解析,先解析
+        parseZipEntry(zf)
+      }
+    }, 800)
+  } else {
+    // 直接打开附件查看器
+    const state = parseStates[att.id]
+    if (state?.status === 'completed') {
+      viewParseResult(att)
+    } else {
+      // 先解析再查看
+      await triggerParse(att)
+    }
+  }
+}
+
+function ruleSourceFromActionConfig(rule) {
+  if (!rule.actionConfig) return ''
+  try {
+    const cfg = typeof rule.actionConfig === 'string' ? JSON.parse(rule.actionConfig) : rule.actionConfig
+    const zipName = normalizeRuleSourceName(cfg.zipName || cfg.zipFileName || cfg.archiveName)
+    const entryName = normalizeRuleSourceName(cfg.zipEntryName || cfg.entryName || cfg.fileName || cfg.attachmentName)
+    if (zipName && entryName && zipName !== entryName) return `${zipName}/${entryName}`
+    return entryName || zipName || ''
+  } catch (_) {
+    return ''
+  }
+}
+
+function ruleInputDisplayList(rule) {
+  const list = []
+  const inputs = Array.isArray(rule?.inputs) ? rule.inputs : []
+  for (const inp of inputs) {
+    // 优先使用 entryPath(ZIP内文件路径),否则使用 inputName/sourceName
+    const entryPath = inp?.entryPath
+    const inputName = normalizeRuleSourceName(inp?.inputName || inp?.sourceName || '')
+    
+    if (entryPath) {
+      // 有 entryPath 时,显示 "附件名 → 文件名"
+      const fileName = entryPath.split('/').pop()
+      const value = inputName ? `${inputName} → ${fileName}` : fileName
+      if (value && !list.includes(value)) list.push(value)
+    } else if (inputName) {
+      if (!list.includes(inputName)) list.push(inputName)
+    }
+  }
+  return list
+}
+
+function ruleSourceSummary(rule) {
+  const fromInputs = ruleInputDisplayList(rule)
+  if (fromInputs.length) return fromInputs.join('、')
+
+  const fromCfg = ruleSourceFromActionConfig(rule)
+  if (fromCfg) return fromCfg
+
+  const desc = normalizeRuleSourceName(rule?.description)
+  if (desc.startsWith('从附件「')) {
+    const m = desc.match(/从附件「([^」]+)」/)
+    if (m?.[1]) return m[1]
+  }
+  if (desc.startsWith('来源:') || desc.startsWith('来源:')) {
+    return desc.replace(/^来源[::]\s*/, '')
+  }
+  return desc
 }
 // 附件解析状态: { [attachmentId]: { status: 'idle'|'uploading'|'parsing'|'completed'|'failed', progress: '', markdown: '' } }
 const parseStates = reactive({})
+const highlightSourceText = ref('') // 用于在附件查看器中高亮的来源文本
 const showParseResultDialog = ref(false)
 const parseResultAttName = ref('')
 const parseResultContent = ref('')
@@ -997,14 +1250,25 @@ const parseResultViewMode = ref('rendered')
 const parseResultIsHtml = ref(false)
 const parseResultHtml = computed(() => {
   if (!parseResultContent.value) return ''
+  let html = ''
   // DOCX 解析结果已经是 HTML,直接使用
-  if (parseResultIsHtml.value) return parseResultContent.value
-  // PDF/图片解析结果是 markdown,用 marked 渲染
-  try {
-    return marked(parseResultContent.value)
-  } catch (e) {
-    return `<pre>${parseResultContent.value}</pre>`
+  if (parseResultIsHtml.value) {
+    html = parseResultContent.value
+  } else {
+    // PDF/图片解析结果是 markdown,用 marked 渲染
+    try {
+      html = marked(parseResultContent.value)
+    } catch (e) {
+      html = `<pre>${parseResultContent.value}</pre>`
+    }
   }
+  // 如果有高亮文本,在 HTML 中高亮显示
+  if (highlightSourceText.value && highlightSourceText.value.length > 5) {
+    const escaped = highlightSourceText.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+    const regex = new RegExp(`(${escaped})`, 'gi')
+    html = html.replace(regex, '<mark class="source-highlight">$1</mark>')
+  }
+  return html
 })
 const parseResultSource = computed(() => {
   if (!parseResultContent.value) return ''
@@ -1098,19 +1362,6 @@ function unselectProject() {
   leftPanelTab.value = 'projects'
 }
 
-// 本地真实附件 mock 数据
-const mockAttachments = [
-  { id: 'att-01', displayName: '附件1 成都院核心要素评审情况记录表.docx', fileType: 'docx', fileSize: 376919, fileUrl: '/attachments/att01-核心要素评审情况记录表.docx' },
-  { id: 'att-02', displayName: '附件2 成都院现场评审分工表.zip', fileType: 'zip', fileSize: 2171136, fileUrl: '/attachments/att02-现场评审分工表.zip' },
-  { id: 'att-03', displayName: '附件3 安全生产标准化建设通知.pdf', fileType: 'pdf', fileSize: 360940, fileUrl: '/attachments/att03-安全生产标准化通知.pdf' },
-  { id: 'att-04', displayName: '附件4 成都院材料真实性说明.png', fileType: 'png', fileSize: 514264, fileUrl: '/attachments/att04-材料真实性说明.png' },
-  { id: 'att-05', displayName: '附件5 成都院在建项目一览表.docx', fileType: 'docx', fileSize: 16056, fileUrl: '/attachments/att05-在建项目一览表.docx' },
-  { id: 'att-06', displayName: '附件6 成都院安全管理制度清单.docx', fileType: 'docx', fileSize: 16942, fileUrl: '/attachments/att06-安全管理制度清单.docx' },
-  { id: 'att-07', displayName: '附件7 成都院现场评审末次会签到表.png', fileType: 'png', fileSize: 564269, fileUrl: '/attachments/att07-现场评审末次会签到表.png' },
-  { id: 'att-08', displayName: '附件8 工作方案.zip', fileType: 'zip', fileSize: 299279, fileUrl: '/attachments/att08-工作方案.zip' },
-  { id: 'att-09', displayName: '附件9 复审问题建议表.docx', fileType: 'docx', fileSize: 29034, fileUrl: '/attachments/att09-复审问题建议表.docx' },
-]
-
 async function loadProjectData(projectId) {
   loading.value = true
   try {
@@ -1121,8 +1372,7 @@ async function loadProjectData(projectId) {
       ruleApi.list(projectId).catch(() => [])
     ])
     elements.value = elemData || []; values.value = valData || []
-    // 使用本地 mock 附件(含真实文件 URL,用于解析)
-    attachments.value = [...mockAttachments]
+    attachments.value = attData || []
     // 恢复已持久化的解析状态
     restoreParseStates()
     rules.value = ruleData || []
@@ -2185,6 +2435,38 @@ function getFileExt(att) {
   const ext = name.split('.').pop()?.toLowerCase()
   return ext || ''
 }
+
+function getAttachmentFetchUrl(att) {
+  if (att?.fileUrl) return att.fileUrl
+  if (att?.fileKey) return `/api/v1/files/${encodeURIComponent(att.fileKey)}`
+  if (att?.filePath && /^https?:\/\//i.test(att.filePath)) return att.filePath
+  return ''
+}
+
+async function loadAttachmentFile(att, fallbackName = 'file') {
+  const cached = attachmentFileCache.get(att.id)
+  if (cached) return cached
+
+  const url = getAttachmentFetchUrl(att)
+  if (!url) return null
+
+  try {
+    const token = localStorage.getItem('accessToken')
+    const headers = token ? { Authorization: `Bearer ${token}` } : {}
+    const resp = await fetch(url, { headers })
+    if (!resp.ok) throw new Error(`下载失败(${resp.status})`)
+    const blob = await resp.blob()
+    const file = new File([blob], att.displayName || att.fileName || fallbackName, {
+      type: blob.type || 'application/octet-stream'
+    })
+    attachmentFileCache.set(att.id, file)
+    return file
+  } catch (e) {
+    console.warn('获取附件文件失败:', e)
+    return null
+  }
+}
+
 function getFileTypeClass(att) {
   const ext = getFileExt(att)
   if (ext === 'pdf') return 'type-pdf'
@@ -2284,6 +2566,18 @@ function viewParseResult(att) {
   previewContentType.value = ''
   parseResultViewMode.value = 'rendered'
   showParseResultDialog.value = true
+  
+  // 如果有高亮文本,延迟滚动到高亮位置
+  if (highlightSourceText.value) {
+    setTimeout(scrollToHighlight, 300)
+  }
+}
+
+function scrollToHighlight() {
+  const highlight = document.querySelector('.parse-result-rendered mark.source-highlight')
+  if (highlight) {
+    highlight.scrollIntoView({ behavior: 'smooth', block: 'center' })
+  }
 }
 
 function copyParseResult() {
@@ -2344,27 +2638,10 @@ function canParse(att) {
 async function handleZipAttachment(att) {
   // 获取 ZIP 文件
   let file = attachmentFileCache.get(att.id)
-  if (!file && att.fileUrl) {
-    try {
-      const resp = await fetch(att.fileUrl)
-      if (resp.ok) {
-        const blob = await resp.blob()
-        file = new File([blob], att.displayName || 'file.zip', { type: blob.type })
-        attachmentFileCache.set(att.id, file)
-      }
-    } catch (e) { console.warn('获取 ZIP 文件失败:', e) }
-  }
+  if (!file) file = await loadAttachmentFile(att, 'file.zip')
   if (!file) {
-    const picked = await new Promise((resolve) => {
-      const input = document.createElement('input')
-      input.type = 'file'
-      input.accept = '.zip'
-      input.onchange = (e) => resolve(e.target.files[0] || null)
-      input.click()
-    })
-    if (!picked) return
-    file = picked
-    attachmentFileCache.set(att.id, file)
+    ElMessage.error('未找到后端附件文件,请先确认附件文件已在后端持久化')
+    return
   }
 
   try {
@@ -2572,17 +2849,10 @@ async function loadPreviewFromAtt(att) {
   }
   const ext = getFileExt(att)
   try {
-    // 获取原始文件:优先缓存,其次 fileUrl
-    let file = attachmentFileCache.get(att.id)
-    if (!file && att.fileUrl) {
-      const resp = await fetch(att.fileUrl)
-      if (resp.ok) {
-        const blob = await resp.blob()
-        file = new File([blob], att.displayName || 'file', { type: blob.type })
-      }
-    }
+    // 获取原始文件:优先缓存,其次后端文件服务
+    const file = await loadAttachmentFile(att)
     if (!file) {
-      ElMessage.warning('原始文件不可用,请重新上传后再预览')
+      ElMessage.warning('原始文件不可用,请确认后端附件文件已持久化')
       previewContentType.value = 'unsupported'
       return
     }
@@ -2671,6 +2941,7 @@ function cleanupPreviewContent() {
   parseResultPreviewAvailable.value = false
   parseResultOriginAtt.value = null
   parseResultOriginZf.value = null
+  highlightSourceText.value = '' // 清除高亮文本
 }
 
 async function handleParseAttachment(att) {
@@ -2693,41 +2964,17 @@ async function handleParseAttachment(att) {
   }
 
   try {
-    // 1. 获取缓存的原始文件
+    // 1. 获取后端持久化的原始文件
     state.status = 'uploading'
     state.progress = '正在准备文件...'
 
-    let file = attachmentFileCache.get(att.id)
-    if (!file && att.fileUrl) {
-      // 从静态资源目录获取真实文件(mock 附件或有 fileUrl 的附件)
-      state.progress = '正在获取文件...'
-      try {
-        const resp = await fetch(att.fileUrl)
-        if (resp.ok) {
-          const blob = await resp.blob()
-          file = new File([blob], att.displayName || `file.${ext}`, { type: blob.type })
-          attachmentFileCache.set(att.id, file)
-        }
-      } catch (fetchErr) {
-        console.warn('获取附件文件失败:', fetchErr)
-      }
-    }
+    state.progress = '正在获取文件...'
+    const file = await loadAttachmentFile(att, `file.${ext}`)
     if (!file) {
-      // 缓存中没有且无 fileUrl,提示用户重新选择文件
-      const reselected = await new Promise((resolve) => {
-        const input = document.createElement('input')
-        input.type = 'file'
-        input.accept = '.pdf,.png,.jpg,.jpeg,.docx,.doc'
-        input.onchange = (e) => resolve(e.target.files[0] || null)
-        input.click()
-      })
-      if (!reselected) {
-        state.status = 'idle'
-        state.progress = ''
-        return
-      }
-      file = reselected
-      attachmentFileCache.set(att.id, file)
+      state.status = 'failed'
+      state.progress = '未找到后端附件文件'
+      ElMessage.error('未找到后端附件文件,请先确认附件文件已在后端持久化')
+      return
     }
 
     // 2. DOCX 走后端 Java 解析,PDF/图片走 GPU 解析服务
@@ -4195,7 +4442,7 @@ onMounted(async () => {
     display: flex;
     align-items: center;
     justify-content: space-between;
-    padding: 14px 16px 10px;
+    padding: 12px 14px 8px;
     flex-shrink: 0;
 
     .rp-elements-title {
@@ -4203,41 +4450,139 @@ onMounted(async () => {
       align-items: center;
       gap: 6px;
 
-      .rp-title-icon { font-size: 16px; }
-      .rp-title-text { font-size: 14px; font-weight: 700; color: var(--text-1); }
+      .rp-title-icon { font-size: 15px; }
+      .rp-title-text { font-size: 13px; font-weight: 600; color: var(--text-1); }
+      .rp-title-count {
+        font-size: 11px;
+        color: #fff;
+        background: var(--el-color-primary);
+        padding: 1px 6px;
+        border-radius: 10px;
+        font-weight: 500;
+      }
+    }
+    
+    .rp-header-actions {
+      display: flex;
+      align-items: center;
+      gap: 2px;
     }
   }
 
   .rp-elements-body {
     flex: 1;
     overflow-y: auto;
-    padding: 0 16px 14px;
+    padding: 0 8px 12px;
   }
 
-  .rp-value-tags {
+  .rp-element-list {
     display: flex;
-    flex-wrap: wrap;
-    gap: 8px;
+    flex-direction: column;
+    gap: 4px;
   }
 
-  .rp-value-tag {
-    display: inline-block;
-    padding: 5px 14px;
-    background: #e8f4ff;
-    color: #1677ff;
-    border-radius: 16px;
-    font-size: 13px;
-    font-weight: 500;
-    line-height: 1.4;
-    cursor: default;
-    transition: background 0.15s;
-    max-width: 100%;
-    overflow: hidden;
-    text-overflow: ellipsis;
-    white-space: nowrap;
+  .rp-element-group {
+    .rp-group-header {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      padding: 6px 8px;
+      cursor: pointer;
+      border-radius: 4px;
+      transition: background 0.15s;
+      
+      &:hover { background: var(--bg-2); }
+      
+      .rp-group-icon {
+        font-size: 10px;
+        color: var(--text-3);
+        width: 12px;
+      }
+      .rp-group-name {
+        font-size: 12px;
+        font-weight: 600;
+        color: var(--text-2);
+        flex: 1;
+      }
+      .rp-group-count {
+        font-size: 10px;
+        color: var(--text-3);
+        background: var(--bg-2);
+        padding: 1px 5px;
+        border-radius: 8px;
+      }
+    }
+    
+    .rp-group-items {
+      padding-left: 12px;
+    }
+  }
 
+  .rp-element-item {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    padding: 5px 8px;
+    border-radius: 4px;
+    cursor: pointer;
+    transition: all 0.15s;
+    border-left: 2px solid transparent;
+    
     &:hover {
-      background: #d0e8ff;
+      background: var(--bg-2);
+    }
+    
+    &.is-active {
+      background: #e6f4ff;
+      border-left-color: var(--el-color-primary);
+    }
+    
+    &.has-value {
+      .rp-item-name { color: var(--text-1); }
+    }
+    
+    .rp-item-type {
+      font-size: 9px;
+      font-weight: 600;
+      width: 14px;
+      height: 14px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      border-radius: 3px;
+      flex-shrink: 0;
+    }
+    
+    &.is-text .rp-item-type {
+      background: #e6f7ff;
+      color: #1890ff;
+    }
+    &.is-paragraph .rp-item-type {
+      background: #f6ffed;
+      color: #52c41a;
+    }
+    &.is-table .rp-item-type {
+      background: #fff7e6;
+      color: #fa8c16;
+    }
+    
+    .rp-item-name {
+      font-size: 12px;
+      color: var(--text-3);
+      flex: 1;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+    
+    .rp-item-preview {
+      font-size: 11px;
+      color: var(--text-3);
+      max-width: 80px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      opacity: 0.7;
     }
   }
 
@@ -4245,7 +4590,7 @@ onMounted(async () => {
     padding: 20px;
     text-align: center;
     color: var(--text-3);
-    font-size: 13px;
+    font-size: 12px;
   }
 
   // ---- AI 助手区 ----
@@ -5903,6 +6248,42 @@ onMounted(async () => {
             font-size: 11px;
             color: var(--text-3);
             word-break: break-all;
+            
+            &.clickable {
+              cursor: pointer;
+              color: var(--el-color-primary);
+              &:hover {
+                text-decoration: underline;
+              }
+            }
+          }
+        }
+
+        .rule-trace-source-text {
+          margin-top: 4px;
+          padding: 6px 8px;
+          background: #fffbe6;
+          border-left: 3px solid #faad14;
+          border-radius: 2px;
+          
+          .source-text-label {
+            font-size: 10px;
+            color: #d48806;
+            font-weight: 500;
+            display: block;
+            margin-bottom: 2px;
+          }
+          
+          .source-text-content {
+            font-size: 12px;
+            color: #614700;
+            line-height: 1.5;
+            display: -webkit-box;
+            -webkit-line-clamp: 4;
+            line-clamp: 4;
+            -webkit-box-orient: vertical;
+            overflow: hidden;
+            word-break: break-all;
           }
         }
 
@@ -6020,24 +6401,33 @@ onMounted(async () => {
   .fp-toolbar {
     display: flex;
     align-items: center;
-    gap: 8px;
-    padding: 12px 20px;
+    gap: 10px;
+    padding: 14px 20px;
     border-bottom: 1px solid var(--border);
     background: #fafbfc;
+    flex-wrap: wrap;
 
     .fp-count {
       margin-left: auto;
       font-size: 12px;
       color: #999;
+      line-height: 1;
     }
   }
 
   .fp-list {
-    padding: 8px 12px;
+    padding: 12px 16px 14px;
+  }
+
+  &.rule-manage-dialog .el-dialog__body {
+    max-height: 72vh;
+    display: flex;
+    flex-direction: column;
   }
 
   &.rule-manage-dialog .fp-list {
-    max-height: 480px;
+    max-height: none;
+    flex: 1;
     overflow-y: auto;
   }
 
@@ -6201,6 +6591,21 @@ onMounted(async () => {
         color: #374151;
       }
 
+      // 来源文本高亮样式
+      mark.source-highlight {
+        background: linear-gradient(180deg, #fff3cd, #ffeeba);
+        color: #856404;
+        padding: 2px 4px;
+        border-radius: 3px;
+        box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+        animation: highlight-pulse 2s ease-in-out;
+      }
+      
+      @keyframes highlight-pulse {
+        0%, 100% { background: linear-gradient(180deg, #fff3cd, #ffeeba); }
+        50% { background: linear-gradient(180deg, #ffe066, #ffc107); }
+      }
+
       code {
         background: #f3f4f6;
         padding: 1px 4px;
@@ -6298,14 +6703,14 @@ onMounted(async () => {
   // ---- 规则筛选栏 ----
   .rule-filter-bar {
     display: flex;
-    gap: 4px;
-    padding: 0 16px 10px;
+    gap: 8px;
+    padding: 8px 16px 12px;
     border-bottom: 1px solid #f0f0f0;
     flex-wrap: wrap;
 
     .rule-filter-tab {
       font-size: 12px;
-      padding: 4px 10px;
+      padding: 5px 12px;
       border-radius: 14px;
       cursor: pointer;
       color: #666;
@@ -6334,27 +6739,30 @@ onMounted(async () => {
 
   // ---- 规则项 ----
   .fp-rule-item {
-    border-radius: 8px;
+    border-radius: 10px;
+    border: 1px solid #eef1f5;
+    background: #fff;
     transition: background 0.15s;
     cursor: pointer;
+    margin-bottom: 10px;
 
-    &:hover { background: #f5f7fa; }
+    &:hover { background: #f8fafc; }
     &.expanded { background: #fafbfc; }
 
     .rule-item-main {
       display: flex;
       align-items: center;
-      gap: 10px;
-      padding: 10px 12px;
+      gap: 12px;
+      padding: 12px 14px;
     }
 
     .rule-action-badge {
       flex-shrink: 0;
-      font-size: 11px;
+      font-size: 12px;
       font-weight: 600;
-      padding: 3px 8px;
-      border-radius: 12px;
-      line-height: 16px;
+      padding: 4px 10px;
+      border-radius: 13px;
+      line-height: 18px;
       white-space: nowrap;
 
       &.action-quote           { background: #e6f4ff; color: #1677ff; }
@@ -6375,7 +6783,7 @@ onMounted(async () => {
         flex-wrap: wrap;
 
         .rule-name {
-          font-size: 13px;
+          font-size: 14px;
           font-weight: 500;
           color: #1f2937;
         }
@@ -6389,27 +6797,31 @@ onMounted(async () => {
       }
 
       .rule-desc {
-        font-size: 11px;
+        font-size: 12px;
+        line-height: 1.5;
         color: #999;
-        margin-top: 2px;
+        margin-top: 4px;
         overflow: hidden;
         text-overflow: ellipsis;
-        white-space: nowrap;
+        display: -webkit-box;
+        line-clamp: 2;
+        -webkit-line-clamp: 2;
+        -webkit-box-orient: vertical;
       }
     }
 
     .rule-actions {
       display: flex;
       align-items: center;
-      gap: 2px;
+      gap: 6px;
       flex-shrink: 0;
     }
 
     // 展开详情区域
     .rule-detail {
-      padding: 0 12px 10px 12px;
+      padding: 2px 14px 12px;
       border-top: 1px dashed #e8e8e8;
-      margin: 0 12px;
+      margin: 0 12px 4px;
 
       .rule-detail-row {
         display: flex;