Files
aue/docs/superpowers/plans/2026-05-30-universal-search-engine-plan.md
2026-06-03 13:16:30 +08:00

63 KiB
Raw Permalink Blame History

AI对话链接跳转与引用高亮优化 - 实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 实现统一的搜索引擎架构支持PDF/Word/Excel等多格式文件的智能模糊匹配和持续高亮显示功能解决AI对话引用跳转后无法正确定位和高亮的问题。

Architecture: 采用4模块分层架构TextExtractor → FuzzySearchEngine → HighlightRenderer → SearchCoordinator基于Fuse.js实现中文模糊匹配通过Range API和DOM操作实现多格式高亮渲染集成到现有的ReaderPage.vue阅读器组件中。

Tech Stack: Vue 3 (Composition API), Fuse.js 7.0, DOM TreeWalker API, Range API, CSS Animations


📁 文件结构规划

新增文件4个工具模块

src/utils/
├── textExtractor.js          # 文本提取器 - 从PDF/DOM/表格提取结构化文本
├── fuzzySearchEngine.js       # 模糊搜索引擎 - 基于Fuse.js的智能匹配
├── highlightRenderer.js      # 高亮渲染器 - DOM/PDF/表格高亮标记管理
└── searchCoordinator.js       # 流程协调器 - 编排搜索+定位+高亮流程

修改文件2个集成点

src/components/QAModule.vue   # 优化关键词提取逻辑第1126-1204行
src/views/ReaderPage.vue      # 集成搜索引擎 + 导航UI + 高亮样式

Task 1: 安装依赖与项目初始化

Files:

  • Modify: package.json

  • Step 1: 安装Fuse.js依赖

npm install fuse.js@7.0.0

Run: npm install fuse.js@7.0.0 Expected: Successfully installed fuse.js@7.0.0

  • Step 2: 验证安装
npm list fuse.js

Run: npm list fuse.js Expected: gkpt-frontend-vue@0.0.0 └── fuse.js@7.0.0

  • Step 3: 创建utils目录如不存在
mkdir -p src/utils
  • Step 4: Commit
git add package.json package-lock.json
git commit -m "chore: add fuse.js dependency for fuzzy search engine"

Task 2: 实现TextExtractor文本提取器

Files:

  • Create: src/utils/textExtractor.js

  • Test: 手动验证见Step 6

  • Step 1: 创建TextExtractor类基础结构

// src/utils/textExtractor.js

/**
 * TextExtractor - 多格式文件文本提取器
 * 
 * 职责从不同格式文件的DOM中提取结构化文本数据
 * 支持PDF, DOCX, DOC, XLSX, CSV, PPTX, TXT, Markdown等
 */
class TextExtractor {
  
  /**
   * 主入口:根据文件类型选择提取策略
   * @param {string} fileType - 文件类型标识
   * @param {HTMLElement} container - 文档容器DOM元素
   * @returns {Promise<Array<{text: string, type: string, location?: object, element: HTMLElement}>>}
   */
  async extract(fileType, container) {
    const strategies = {
      pdf: () => this.extractPdfText(container),
      docx: () => this.extractDocxText(container),
      doc: () => this.extractDocText(container),
      xlsx: () => this.extractExcelText(container),
      csv: () => this.extractExcelText(container),
      pptx: () => this.extractPptxText(container),
      text: () => this.extractPlainText(container),
      markdown: () => this.extractDomText(container),
      code: () => this.extractDomText(container),
      json: () => this.extractDomText(container),
      xml: () => this.extractDomText(container),
      html: () => this.extractDomText(container),
      default: () => this.extractDomText(container)
    }
    
    const strategy = strategies[fileType] || strategies.default
    
    try {
      const result = await strategy()
      console.log(`📄 TextExtractor: 提取完成 (${fileType}), ${result.length} 个文本块`)
      return result
    } catch (error) {
      console.error(`❌ TextExtractor: ${fileType}提取失败`, error)
      return []
    }
  }
  
  constructor() {
    this.name = 'TextExtractor'
  }
}

export default new TextExtractor()
  • Step 2: 实现PDF文本提取方法

extract方法之后添加:

/**
 * PDF文本提取从iframe的.textLayer提取
 * @param {HTMLElement} container - 包含PDF iframe的容器
 * @returns {Array<{page: number, text: string, element: HTMLElement}>}
 */
async extractPdfText(container) {
  // 查找PDF iframe
  const iframe = container.querySelector('iframe.pdf-iframe, iframe[src*="pdfjs"]')
  if (!iframe?.contentWindow) {
    console.warn('⚠️ PDF: 未找到iframe或无法访问')
    return []
  }
  
  try {
    const iframeDoc = iframe.contentWindow.document
    const textLayer = iframeDoc.querySelector('.textLayer')
    
    if (!textLayer) {
      console.warn('⚠️ PDF: 未找到textLayer')
      return []
    }
    
    const spans = textLayer.querySelectorAll('span[style*="position: absolute"], span:not([class*="end"])')
    let currentPage = 1
    const pages = {}
    
    let pageText = ''
    for (const span of spans) {
      // 检测页码变化通过data-page属性或BR元素
      if (span.tagName === 'BR' || span.dataset.page) {
        if (pageText.trim()) {
          pages[currentPage] = pageText.trim()
          currentPage++
          pageText = ''
        }
        
        if (span.dataset.page) {
          currentPage = parseInt(span.dataset.page)
        }
      } else if (span.textContent.trim()) {
        pageText += span.textContent + ' '
      }
    }
    
    // 处理最后一页
    if (pageText.trim()) {
      pages[currentPage] = pageText.trim()
    }
    
    // 转换为数组格式
    const result = Object.entries(pages).map(([pageNum, text]) => ({
      text,
      type: 'page',
      location: { page: parseInt(pageNum) },
      element: textLayer,
      sourceType: 'pdf'
    }))
    
    console.log(`✅ PDF文本提取成功: ${result.length} 页`)
    return result
    
  } catch (error) {
    console.error('❌ PDF文本提取失败:', error)
    
    // 降级方案从iframe的整个内容区提取
    try {
      const bodyText = iframe.contentWindow.document.body?.textContent || ''
      return [{
        text: bodyText.substring(0, 50000),  // 限制长度防止内存溢出
        type: 'text',
        location: { page: 1 },
        element: iframe.contentDocument.body,
        sourceType: 'pdf-fallback'
      }]
    } catch (fallbackError) {
      console.error('❌ PDF降级方案也失败:', fallbackError)
      return []
    }
  }
}
  • Step 3: 实现DOCX/DOC文本提取方法
/**
 * DOCX/DOC文本提取从.docx-wrapper提取分页结构
 * @param {HTMLElement} container - 文档容器
 * @returns {Array<{page: number, text: string, element: HTMLElement}>}
 */
extractDocxText(container) {
  // 策略1: 查找标准docx-viewer结构
  const strategies = [
    '.docx-viewer-wrapper .docx-wrapper > div[class*="docx"]',
    '.docx-container .docx-wrapper > div',
    '.docx-viewer-wrapper > div > div',
    '.docx-wrapper > div'
  ]
  
  let pages = null
  
  for (const selector of strategies) {
    const elements = container.querySelectorAll(selector)
    if (elements.length >= 1) {
      pages = Array.from(elements)
      console.log(`📄 DOCX页面选择器命中: "${selector}" → ${pages.length} 个页面`)
      break
    }
  }
  
  // 如果没找到分页结构尝试整个wrapper
  if (!pages || pages.length <= 1) {
    const wrapper = container.querySelector('.docx-viewer-wrapper') || 
                    container.querySelector('.docx-wrapper') || 
                    container
    pages = wrapper.children.length > 1 ? Array.from(wrapper.children) : [wrapper]
  }
  
  const result = pages.map((page, index) => ({
    text: page.textContent || '',
    type: 'page',
    location: { page: index + 1 },
    element: page,
    sourceType: 'docx'
  }))
  
  console.log(`✅ DOCX文本提取成功: ${result.length} 页`)
  return result.filter(doc => doc.text.trim().length > 0)
}

/**
 * 旧版DOC格式提取复用DOCX逻辑
 */
extractDocText(container) {
  return this.extractDocxText(container).map(doc => ({
    ...doc,
    sourceType: 'doc'
  }))
}
  • Step 4: 实现Excel/CSV文本提取方法
/**
 * Excel/CSV文本提取从表格单元格提取
 * @param {HTMLElement} container - 文档容器
 * @returns {Array<{text: string, location: [number, number], element: HTMLElement}>}
 */
extractExcelText(container) {
  const tables = container.querySelectorAll('table.data-table, table')
  
  if (tables.length === 0) {
    console.warn('⚠️ Excel: 未找到表格元素')
    return []
  }
  
  const cells = []
  
  tables.forEach((table, tableIdx) => {
    const rows = table.querySelectorAll('tr')
    
    rows.forEach((row, rowIdx) => {
      const cellsInRow = row.querySelectorAll('td, th')
      
      cellsInRow.forEach((cell, colIdx) => {
        const cellText = cell.textContent?.trim()
        
        if (cellText && cellText.length > 0) {
          cells.push({
            text: cellText,
            type: 'cell',
            location: {
              row: rowIdx,
              col: colIdx,
              tableIndex: tableIdx
            },
            element: cell,
            sourceType: 'xlsx'
          })
        }
      })
    })
  })
  
  console.log(`✅ Excel文本提取成功: ${cells.length} 个单元格`)
  return cells
}
  • Step 5: 实现PPTX文本提取方法
/**
 * PPTX文本提取从幻灯片容器提取
 * @param {HTMLElement} container - 文档容器
 * @returns {Array<{page: number, text: string, element: HTMLElement}>}
 */
extractPptxText(container) {
  const slides = container.querySelectorAll('.slide-container, [data-slide-index], .pptx-slide')
  
  if (slides.length === 0) {
    // 降级查找所有可能的幻灯片div
    const allDivs = container.querySelectorAll('div[style*="width"][style*="height"]')
    
    if (allDivs.length < 2) {
      // 最终降级:整个容器作为一页
      return [{
        text: container.textContent || '',
        type: 'page',
        location: { page: 1 },
        element: container,
        sourceType: 'pptx-single'
      }]
    }
    
    return Array.from(allDivs).map((slide, idx) => ({
      text: slide.textContent || '',
      type: 'page',
      location: { page: idx + 1 },
      element: slide,
      sourceType: 'pptx-div'
    }))
  }
  
  const result = Array.from(slides).map((slide, index) => ({
    text: slide.textContent || '',
    type: 'page',
    location: { page: index + 1 },
    element: slide,
    sourceType: 'pptx'
  }))
  
  console.log(`✅ PPTX文本提取成功: ${result.length} 张幻灯片`)
  return result
}
  • Step 6: 实现通用DOM文本提取方法
/**
 * 通用DOM文本提取使用TreeWalker遍历有意义的文本节点
 * @param {HTMLElement} container - 文档容器
 * @returns {Array<{text: string, element: HTMLElement}>}
 */
extractDomText(container) {
  if (!container) {
    console.warn('⚠️ DOM: 容器为空')
    return []
  }
  
  const nodes = []
  
  try {
    const walker = document.createTreeWalker(
      container,
      NodeFilter.SHOW_TEXT,
      {
        acceptNode: (node) => {
          const parent = node.parentElement
          
          // 排除不需要的标签类型
          const excludeTags = ['SCRIPT', 'STYLE', 'CODE', 'PRE', 'NOSCRIPT', 'TEMPLATE']
          if (excludeTags.includes(parent?.tagName)) {
            return NodeFilter.FILTER_REJECT
          }
          
          // 排除隐藏元素
          const style = window.getComputedStyle(parent)
          if (style.display === 'none' || style.visibility === 'hidden') {
            return NodeFilter.FILTER_REJECT
          }
          
          // 排除高亮标记本身(避免重复)
          if (parent?.closest('.search-highlight, .ref-highlight, mark')) {
            return NodeFilter.FILTER_REJECT
          }
          
          // 只接受有意义的文本节点(长度>=2
          const text = node.textContent?.trim()
          if (!text || text.length < 2) {
            return NodeFilter.FILTER_REJECT
          }
          
          return NodeFilter.FILTER_ACCEPT
        }
      }
    )
    
    while (walker.nextNode()) {
      const node = walker.currentNode
      const text = node.textContent.trim()
      
      if (text && text.length >= 2) {
        nodes.push({
          text,
          type: 'text',
          element: node.parentElement,
          sourceType: 'dom'
        })
      }
    }
    
    console.log(`✅ DOM文本提取成功: ${nodes.length} 个文本节点`)
    
  } catch (error) {
    console.error('❌ DOM文本提取失败:', error)
    
    // 降级方案直接获取textContent
    nodes.push({
      text: container.textContent?.trim() || '',
      type: 'text',
      element: container,
      sourceType: 'dom-fallback'
    })
  }
  
  return nodes
}

/**
 * 纯文本文件提取
 */
extractPlainText(container) {
  return [{
    text: container.textContent || '',
    type: 'text',
    element: container,
    sourceType: 'text'
  }]
}
  • Step 7: 手动测试TextExtractor

打开浏览器控制台,执行以下代码进行验证:

import TextExtractor from '@/utils/textExtractor'

// 测试DOM文本提取
const container = document.querySelector('.reader-container')
if (container) {
  const results = await TextExtractor.extract('markdown', container)
  console.log('提取结果:', results.length, '个文本块')
  console.log('前3个:', results.slice(0, 3))
}

Expected: 成功输出提取到的文本块数组

  • Step 8: Commit
git add src/utils/textExtractor.js
git commit -m "feat: implement TextExtractor for multi-format document text extraction"

Task 3: 实现FuzzySearchEngine模糊搜索引擎

Files:

  • Create: src/utils/fuzzySearchEngine.js

  • Test: 单元测试(可选)

  • Step 1: 创建FuzzySearchEngine类并配置Fuse.js

// src/utils/fuzzySearchEngine.js

import Fuse from 'fuse.js'

/**
 * FuzzySearchEngine - 基于Fuse.js的智能模糊搜索引擎
 * 
 * 特性:
 * - 支持中文模糊匹配
 * - 可配置容错率threshold
 * - 返回相关性评分排序的结果
 * - 支持分词模式
 */
class FuzzySearchEngine {
  
  constructor() {
    // Fuse.js核心配置针对中文优化
    this.fuseConfig = {
      threshold: 0.4,              // 匹配阈值0=精确, 1=宽松)
      distance: 100,               // 模式匹配的最大距离
      includeScore: true,          // 返回评分
      includeMatches: true,        // 返回匹配位置信息
      minMatchCharLength: 2,       // 最小匹配字符数
      tokenize: true,              // 启用分词模式
      tokenSeparator: /[\s\p{P}\u3000-\u303f\uff00-\uffef]+/u,  // 中英文标点和空格分词
      keys: ['text']               // 搜索字段
    }
    
    this.fuseInstance = null
    this.currentDocuments = []
    
    console.log('🔍 FuzzySearchEngine 初始化完成', this.fuseConfig)
  }

  /**
   * 初始化搜索引擎索引(每次加载新文档时调用)
   * @param {Array} documents - 文本块数组 [{text, type, location, element}]
   */
  initIndex(documents) {
    if (!documents || documents.length === 0) {
      console.warn('⚠️ 搜索引擎初始化失败: 文档列表为空')
      this.fuseInstance = null
      this.currentDocuments = []
      return
    }
    
    // 过滤无效文档
    const validDocs = documents.filter(doc => 
      doc && doc.text && typeof doc.text === 'string' && doc.text.trim().length > 0
    )
    
    if (validDocs.length === 0) {
      console.warn('⚠️ 搜索引擎初始化失败: 无有效文档')
      return
    }
    
    this.currentDocuments = validDocs
    this.fuseInstance = new Fuse(validDocs, this.fuseConfig)
    
    console.log(`✅ 搜索引擎索引构建完成: ${validDocs.length} 个文本块`)
  }
}
export default new FuzzySearchEngine()
  • Step 2: 实现关键词预处理方法

initIndex方法之后添加:

/**
 * 关键词预处理(提升中文匹配率)
 * @param {string} keyword - 原始关键词
 * @returns {string} 处理后的关键词
 */
preprocessKeyword(keyword) {
  if (!keyword || typeof keyword !== 'string') return ''
  
  return keyword
    .trim()
    .replace(/\s+/g, ' ')                    // 多个空格合并为一个
    .replace(/[""''【】《》()「」『』〔〕[]]/g, '')  // 去除中文引号和括号
    .replace(/[…—–-─﹣-~]+/g, ' ')         // 省略号、破折号替换为空格
    .replace(/[·•∙⋅]+/g, ' ')                // 点号替换
    .replace(/[!?;:,.。]+/g, ' ')   // 标点符号替换为空格
    .substring(0, 200)                       // 截断过长关键词(防止性能问题)
    .trim()
}
  • Step 3: 实现核心搜索方法
/**
 * 执行模糊搜索
 * @param {string} keyword - 搜索关键词
 * @param {Object} options - 搜索选项
 * @param {number} options.limit - 最大返回结果数默认20
 * @returns {Array<SearchResult>} 匹配结果列表(按相关性排序)
 */
search(keyword, options = {}) {
  if (!keyword?.trim()) {
    console.warn('⚠️ 搜索关键词为空')
    return []
  }
  
  if (!this.fuseInstance) {
    console.warn('⚠️ 搜索引擎未初始化')
    return []
  }
  
  // 预处理关键词
  const processedKeyword = this.preprocessKeyword(keyword)
  
  if (!processedKeyword || processedKeyword.length < 2) {
    console.warn(`⚠️ 关键词过短或清洗后为空: "${keyword}" → "${processedKeyword}"`)
    return []
  }
  
  const limit = options.limit || 20
  
  console.log(`🔍 开始搜索: "${processedKeyword.substring(0, 50)}${processedKeyword.length > 50 ? '...' : ''}", 最大返回: ${limit}`)
  
  const startTime = performance.now()
  
  // 执行Fuse.js搜索
  const rawResults = this.fuseInstance.search(processedKeyword, { limit })
  
  const searchTime = performance.now() - startTime
  
  // 后处理:转换为统一格式
  const results = rawResults.map((result, index) => 
    this.formatResult(result, index)
  )
  
  console.log(`✅ 搜索完成 (${searchTime.toFixed(1)}ms): 找到 ${results.length} 个匹配项`)
  
  if (results.length > 0) {
    console.log('📊 Top 3 结果:', results.slice(0, 3).map(r => ({
      score: r.score.toFixed(3),
      preview: r.matchedText.substring(0, 30)
    })))
  }
  
  return results
}
  • Step 4: 实现结果格式化方法
/**
 * 格式化原始Fuse.js结果为统一格式
 * @param {Object} rawResult - Fuse.js原始结果
 * @param {number} index - 结果序号
 * @returns {SearchResult} 格式化后的结果
 */
formatResult(rawResult, index) {
  const { item, matches, score } = rawResult
  
  // 提取匹配位置信息
  const matchInfo = matches?.[0]
  const indices = matchInfo?.indices?.[0] || [0, Math.min(item.text?.length || 50, 50)]
  
  // 安全边界检查
  const textLength = item.text?.length || 0
  const safeStart = Math.max(0, indices[0])
  const safeEnd = Math.min(indices[1] || textLength, textLength)
  
  // 计算上下文前后各50字符
  const ctxStart = Math.max(0, safeStart - 50)
  const ctxEnd = Math.min(textLength, safeEnd + 50)
  const context = item.text.substring(ctxStart, ctxEnd)
  
  // 生成唯一ID
  const id = `search_${Date.now()}_${index}_${Math.random().toString(36).substr(2, 9)}`
  
  return {
    id,
    type: item.type || 'text',
    score: 1 - (score || 0),  // 转换为相似度0-1越高越匹配
    matchedText: item.text.substring(safeStart, safeEnd),
    context: context.trim(),
    location: {
      ...item.location,
      startOffset: safeStart,
      endOffset: safeEnd
    },
    originalData: item,  // 保留原始数据用于高亮渲染
    _debug: {
      indices,
      originalScore: score
    }
  }
}
  • Step 5: 测试FuzzySearchEngine
import FuzzySearchEngine from '@/utils/fuzzySearchEngine'

// 准备测试数据
const testDocs = [
  { text: '员工考勤管理制度第一章总则', type: 'text', location: { page: 1 }, element: document.body },
  { text: '第二章 工作时间规定', type: 'text', location: { page: 2 }, element: document.body },
  { text: '第三章 请假制度', type: 'text', location: { page: 3 }, element: document.body }
]

// 初始化索引
FuzzySearchEngine.initIndex(testDocs)

// 测试精确匹配
const exactResults = FuzzySearchEngine.search('考勤制度')
console.log('精确匹配结果:', exactResults)

// 测试模糊匹配
const fuzzyResults = FuzzySearchEngine.search('考勤')
console.log('模糊匹配结果:', fuzzyResults)

// 测试部分匹配
const partialResults = FuzzySearchEngine.search('请假')
console.log('部分匹配结果:', partialResults)

Expected: 所有搜索都应返回相关结果score值反映匹配程度

  • Step 6: Commit
git add src/utils/fuzzySearchEngine.js
git commit -m "feat: implement FuzzySearchEngine based on Fuse.js for intelligent matching"

Task 4: 实现HighlightRenderer高亮渲染器

Files:

  • Create: src/utils/highlightRenderer.js

  • Step 1: 创建HighlightRenderer类基础结构和状态管理

// src/utils/highlightRenderer.js

/**
 * HighlightRenderer - 统一高亮渲染器
 * 
 * 功能:
 * - 在文档中创建和管理高亮标记
 * - 支持DOM/PDF/表格三种高亮类型
 * - 提供导航控制(上一个/下一个)
 * - 自动脉冲动画效果
 */
class HighlightRenderer {
  
  constructor() {
    /** @type {Map<string, Object>} 高亮实例映射 (id → Highlight) */
    this.activeHighlights = new Map()
    
    /** @type {number} 当前激活的高亮索引 */
    this.currentIndex = -1
    
    console.log('✨ HighlightRenderer 初始化完成')
  }
}
export default new HighlightRenderer()
  • Step 2: 实现主渲染方法和分发逻辑
/**
 * 渲染所有搜索结果的高亮
 * @param {Array<SearchResult>} results - 搜索结果列表
 * @returns {number} 成功渲染的高亮数量
 */
renderAll(results) {
  if (!results || results.length === 0) {
    console.warn('⚠️ 无需渲染: 结果列表为空')
    return 0
  }
  
  console.log(`🎨 开始渲染 ${results.length} 个高亮标记`)
  
  // 先清除旧高亮
  this.clearAll()
  
  let successCount = 0
  
  // 遍历结果创建高亮
  results.forEach((result, index) => {
    try {
      const highlight = this.createHighlight(result, index)
      
      if (highlight) {
        this.activeHighlights.set(result.id, highlight)
        successCount++
      }
    } catch (error) {
      console.error(`❌ 高亮#${index+1}创建失败:`, error.message)
    }
  })
  
  console.log(`✨ 高亮渲染完成: ${successCount}/${results.length} 成功`)
  
  // 自动导航到第一个结果
  if (successCount > 0) {
    setTimeout(() => this.navigateTo(0), 100)  // 延迟100ms确保DOM更新
  }
  
  // 触发自定义事件用于UI更新计数器
  window.dispatchEvent(new CustomEvent('highlightNavigate', {
    detail: { currentIndex: 0, total: successCount }
  }))
  
  return successCount
}

/**
 * 创建单个高亮根据type分发到不同的创建方法
 * @private
 */
createHighlight(result, index) {
  const typeHandlers = {
    'pdf': () => this.createPdfHighlight(result, index),
    'table': () => this.createTableHighlight(result, index),
    'page': () => this.createDomHighlight(result, index),
    'cell': () => this.createTableHighlight(result, index),
    'text': () => this.createDomHighlight(result, index)
  }
  
  const handler = typeHandlers[result.type] || typeHandlers['text']
  return handler()
}
  • Step 3: 实现DOM类型高亮最复杂的核心逻辑
/**
 * DOM类型高亮适用于text/docx/markdown/html/json/xml等
 * 使用Range API精确选中文本并包裹mark元素
 * @private
 */
createDomHighlight(result, index) {
  const { location, matchedText, id } = result
  const { element, startOffset, endOffset } = location
  
  if (!element || !matchedText) {
    console.warn(`⚠️ DOM高亮失败: 缺少必要参数`, { hasElement: !!element, hasText: !!matchedText })
    return null
  }
  
  try {
    // 查找包含目标文本的文本节点
    const textNodes = this.findTextNodes(element, matchedText)
    
    if (textNodes.length === 0) {
      // 尝试模糊查找
      const fuzzyNodes = this.findTextNodesFuzzy(element, matchedText)
      
      if (fuzzyNodes.length === 0) {
        console.warn(`⚠️ DOM高亮失败: 未找到文本"${matchedText.substring(0, 30)}"`)
        return null
      }
      
      return this.createMarkForNode(fuzzyNodes[0], matchedText, id, index, result)
    }
    
    return this.createMarkForNode(textNodes[0], matchedText, id, index, result)
    
  } catch (error) {
    console.error('❌ DOM高亮创建异常:', error)
    return null
  }
}

/**
 * 为指定文本节点创建mark高亮元素
 * @private
 */
createMarkForNode(targetNode, searchText, highlightId, index, result) {
  const text = targetNode.textContent
  let idx = text.indexOf(searchText)
  
  // 如果精确查找失败,尝试模糊查找
  if (idx === -1) {
    idx = this.fuzzyFind(text, searchText)
  }
  
  if (idx === -1) {
    console.warn(`⚠️ 无法定位文本: "${searchText.substring(0, 30)}"`)
    return null
  }
  
  // 使用Range API精确选中文本
  const range = document.createRange()
  range.setStart(targetNode, idx)
  range.setEnd(targetNode, Math.min(idx + searchText.length, text.length))
  
  // 创建高亮mark元素
  const mark = document.createElement('mark')
  mark.className = 'search-highlight'
  mark.setAttribute('data-highlight-id', highlightId)
  mark.setAttribute('data-index', index)
  mark.innerHTML = `
    <span class="highlight-label">${index + 1}</span>
    <span class="highlight-text">${range.toString()}</span>
  `
  
  // 包裹选中的文本
  range.surroundContents(mark)
  
  // 点击事件:点击可导航到该高亮
  mark.addEventListener('click', (e) => {
    e.stopPropagation()
    this.navigateTo(index)
  })
  
  return {
    id: highlightId,
    index,
    element: mark,
    type: 'dom',
    scrollIntoView: () => {
      mark.scrollIntoView({ behavior: 'smooth', block: 'center' })
      this.pulseAnimation(mark)
    }
  }
}
  • Step 4: 实现辅助方法(文本查找)
/**
 * 在父元素中查找包含目标文本的文本节点
 * @private
 */
findTextNodes(rootElement, searchText) {
  const results = []
  
  if (!rootElement || !searchText) return results
  
  try {
    const walker = document.createTreeWalker(
      rootElement,
      NodeFilter.SHOW_TEXT,
      null,
      false
    )
    
    while (walker.nextNode()) {
      const node = walker.currentNode
      
      if (node.textContent.includes(searchText)) {
        results.push(node)
      }
    }
  } catch (error) {
    console.error('findTextNodes错误:', error)
  }
  
  return results
}

/**
 * 模糊查找文本位置(去除标点后匹配)
 * @private
 */
findTextNodesFuzzy(rootElement, searchText) {
  const cleanSearch = this.cleanText(searchText)
  const results = []
  
  try {
    const walker = document.createTreeWalker(
      rootElement,
      NodeFilter.SHOW_TEXT,
      null,
      false
    )
    
    while (walker.nextNode()) {
      const node = walker.currentNode
      const cleanNodeText = this.cleanText(node.textContent)
      
      if (cleanNodeText.includes(cleanSearch)) {
        results.push(node)
      }
    }
  } catch (error) {
    console.error('findTextNodesFuzzy错误:', error)
  }
  
  return results
}

/**
 * 模糊查找文本偏移量
 * @private
 */
fuzzyFind(text, keyword) {
  const cleanText = this.cleanText(text)
  const cleanKeyword = this.cleanText(keyword)
  
  const idx = cleanText.indexOf(cleanKeyword)
  
  if (idx === -1) return -1
  
  // 尝试映射回原始文本的位置(近似)
  const approxIdx = text.indexOf(keyword.substring(0, 10))
  return approxIdx !== -1 ? approxIdx : idx
}

/**
 * 清理文本(去除标点和空白)
 * @private
 */
cleanText(text) {
  if (!text) return ''
  return text
    .replace(/[\s\p{P}\u3000-\u303f\uff00-\uffef]+/gu, '')
    .toLowerCase()
}
  • Step 5: 实现PDF高亮overlay层方式
/**
 * PDF类型高亮在iframe中创建绝对定位的overlay层
 * @private
 */
createPdfHighlight(result, index) {
  const { location, id } = result
  const { pageNumber, rect } = location || {}
  
  // 查找PDF iframe
  const iframe = document.querySelector('iframe.pdf-iframe, iframe[src*="pdfjs"]')
  
  if (!iframe?.contentWindow) {
    console.warn('⚠️ PDF高亮失败: iframe不可访问')
    return null
  }
  
  try {
    const iframeDoc = iframe.contentWindow.document
    const viewerContainer = iframeDoc.querySelector('#viewerContainer')
    
    if (!viewerContainer) {
      console.warn('⚠️ PDF高亮失败: 未找到viewerContainer')
      return null
    }
    
    // 创建高亮overlay层
    const overlay = iframeDoc.createElement('div')
    overlay.className = 'pdf-highlight-overlay'
    overlay.setAttribute('data-highlight-id', id)
    overlay.setAttribute('data-page', pageNumber || 1)
    
    // 设置样式(内联样式确保生效)
    const rectStyle = rect ? `
      left: ${rect.x}px;
      top: ${rect.y}px;
      width: ${rect.width}px;
      height: ${rect.height}px;
    ` : `
      left: 10%;
      top: ${(pageNumber || 1) * 10}%;
      width: 80%;
      height: 30px;
    `
    
    overlay.style.cssText = `
      position: absolute;
      ${rectStyle}
      background-color: rgba(255, 235, 59, 0.35);
      border: 2px solid #ffc107;
      border-radius: 3px;
      cursor: pointer;
      z-index: 10;
      pointer-events: auto;
      transition: all 0.3s ease;
      box-shadow: 0 2px 8px rgba(255, 193, 7, 0.4);
    `
    
    // 添加角标
    const badge = iframeDoc.createElement('span')
    badge.className = 'pdf-highlight-badge'
    badge.textContent = index + 1
    badge.style.cssText = `
      position: absolute;
      top: -12px;
      right: -12px;
      background: #ffc107;
      color: #212529;
      font-size: 11px;
      font-weight: bold;
      width: 22px;
      height: 22px;
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
      z-index: 11;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    `
    overlay.appendChild(badge)
    
    // 点击事件
    overlay.addEventListener('click', () => {
      this.navigateTo(index)
    })
    
    // 添加到对应页面的容器
    const pageContainer = iframeDoc.querySelector(
      `[data-page-number="${pageNumber}"]`
    ) || viewerContainer
    
    pageContainer.appendChild(overlay)
    
    console.log(`✅ PDF高亮创建成功: 第${pageNumber || '?'}页`)
    
    return {
      id,
      index,
      element: overlay,
      type: 'pdf',
      pageNumber: pageNumber || 1,
      scrollIntoView: () => {
        // 先跳转到对应页码
        this.goToPdfPage(pageNumber || 1)
        
        // 再滚动到高亮位置
        setTimeout(() => {
          overlay.scrollIntoView({ behavior: 'smooth', block: 'center' })
          this.pulseAnimation(overlay)
        }, 500)
      }
    }
    
  } catch (error) {
    console.error('❌ PDF高亮创建失败:', error)
    return null
  }
}
  • Step 6: 实现表格高亮
/**
 * 表格类型高亮Excel/CSV单元格高亮
 * @private
 */
createTableHighlight(result, index) {
  const { location, originalData, id } = result
  const cell = originalData?.element
  
  if (!cell) {
    console.warn('⚠️ 表格高亮失败: 找不到单元格元素')
    return null
  }
  
  try {
    // 添加高亮样式类
    cell.classList.add('table-highlight')
    cell.setAttribute('data-highlight-id', id)
    cell.setAttribute('data-index', index)
    
    // 创建角标
    const badge = document.createElement('span')
    badge.className = 'table-highlight-badge'
    badge.textContent = index + 1
    badge.style.cssText = `
      position: absolute;
      top: -8px;
      right: -8px;
      background: #28a745;
      color: white;
      font-size: 10px;
      font-weight: bold;
      padding: 2px 6px;
      border-radius: 10px;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
      z-index: 10;
      pointer-events: none;
    `
    
    // 设置单元格相对定位(如果还没有)
    const currentPos = window.getComputedStyle(cell).position
    if (currentPos === 'static') {
      cell.style.position = 'relative'
    }
    
    cell.appendChild(badge)
    
    // 点击事件
    cell.addEventListener('click', (e) => {
      e.stopPropagation()
      this.navigateTo(index)
    })
    
    // 触发动画
    this.pulseAnimation(cell, 'table-pulse')
    
    return {
      id,
      index,
      element: cell,
      type: 'table',
      scrollIntoView: () => {
        cell.scrollIntoView({ behavior: 'smooth', block: 'center' })
        this.pulseAnimation(cell, 'table-pulse')
      }
    }
    
  } catch (error) {
    console.error('❌ 表格高亮创建失败:', error)
    return null
  }
}
  • Step 7: 实现导航控制方法
/**
 * 导航到指定高亮(滚动到视图中心 + 激活样式)
 * @param {number} index - 目标高亮索引
 */
navigateTo(index) {
  if (index < 0 || index >= this.activeHighlights.size) {
    console.warn(`⚠️ 导航失败: 索引越界 (${index}, 总计${this.activeHighlights.size})`)
    return
  }
  
  // 移除当前激活状态
  if (this.currentIndex >= 0 && this.currentIndex !== index) {
    const current = this.getHighlightByIndex(this.currentIndex)
    if (current?.element) {
      current.element.classList.remove('active')
    }
  }
  
  // 设置新的当前位置
  this.currentIndex = index
  const target = this.getHighlightByIndex(index)
  
  if (target) {
    // 激活新高亮
    target.element.classList.add('active')
    
    // 滚动到视图
    target.scrollIntoView()
    
    // 触发自定义事件UI更新
    window.dispatchEvent(new CustomEvent('highlightNavigate', {
      detail: { 
        currentIndex: index, 
        total: this.activeHighlights.size 
      }
    }))
    
    console.log(`📍 导航到高亮 #${index + 1}/${this.activeHighlights.size}`)
  }
}

/**
 * 导航到下一个高亮
 */
next() {
  if (this.currentIndex < this.activeHighlights.size - 1) {
    this.navigateTo(this.currentIndex + 1)
  } else {
    console.log('⚠️ 已是最后一个高亮')
  }
}

/**
 * 导航到上一个高亮
 */
prev() {
  if (this.currentIndex > 0) {
    this.navigateTo(this.currentIndex - 1)
  } else {
    console.log('⚠️ 已是第一个高亮')
  }
}
  • Step 8: 实现清除和动画方法
/**
 * 清除所有高亮标记
 */
clearAll() {
  console.log(`🧹 清除 ${this.activeHighlights.size} 个高亮标记`)
  
  this.activeHighlights.forEach((highlight, id) => {
    if (!highlight) return
    
    try {
      switch (highlight.type) {
        case 'dom':
          // DOM高亮移除mark标签保留文本
          if (highlight.element?.parentNode) {
            const parent = highlight.element.parentNode
            parent.replaceChild(
              document.createTextNode(highlight.element.textContent),
              highlight.element
            )
            parent.normalize()
          }
          break
          
        case 'pdf':
        case 'table':
          // PDF/表格高亮:直接移除元素
          highlight.element?.remove()
          break
          
        default:
          highlight.element?.remove()
      }
    } catch (error) {
      console.error(`清除高亮${id}失败:`, error)
    }
  })
  
  this.activeHighlights.clear()
  this.currentIndex = -1
  
  // 通知UI重置
  window.dispatchEvent(new CustomEvent('highlightNavigate', {
    detail: { currentIndex: -1, total: 0 }
  }))
}

/**
 * 脉冲动画(吸引注意力)
 * @param {HTMLElement} element - 目标元素
 * @param {string} animationName - 动画名称默认highlight-pulse
 */
pulseAnimation(element, animationName = 'highlight-pulse') {
  if (!element) return
  
  // 重置动画
  element.style.animation = 'none'
  void element.offsetHeight  // 强制reflow
  element.style.animation = `${animationName} 1.5s ease-in-out`
}

/**
 * 跳转到PDF指定页码
 * @private
 */
goToPdfPage(pageNumber) {
  const iframe = document.querySelector('iframe.pdf-iframe, iframe[src*="pdfjs"]')
  
  if (!iframe?.contentWindow?.PDFViewerApplication) {
    console.warn('⚠️ PDF页码跳转失败: PDFViewerApplication不可用')
    return
  }
  
  try {
    iframe.contentWindow.PDFViewerApplication.pdfViewer.currentPageNumber = pageNumber
    console.log(`📄 PDF跳转到第 ${pageNumber} 页`)
  } catch (error) {
    console.error('❌ PDF页码跳转失败:', error)
  }
}

/** 
 * 根据索引获取高亮实例
 * @private
 */
getHighlightByIndex(index) {
  return Array.from(this.activeHighlights.values())[index]
}
  • Step 9: Commit
git add src/utils/highlightRenderer.js
git commit -m "feat: implement HighlightRenderer with DOM/PDF/Table support and navigation"

Task 5: 实现SearchCoordinator协调控制器

Files:

  • Create: src/utils/searchCoordinator.js

  • Step 1: 创建SearchCoordinator类并导入依赖

// src/utils/searchCoordinator.js

import TextExtractor from './textExtractor'
import FuzzySearchEngine from './fuzzySearchEngine'
import HighlightRenderer from './highlightRenderer'

/**
 * SearchCoordinator - 统一搜索流程协调控制器
 * 
 * 职责:
 * - 编排完整的搜索→定位→高亮流程
 * - 处理时序问题等待DOM渲染
 * - 异常处理和重试机制
 * - 性能统计和日志记录
 */
class SearchCoordinator {
  
  constructor() {
    this.extractor = TextExtractor
    this.searchEngine = FuzzySearchEngine
    this.renderer = HighlightRenderer
    
    this.isSearching = false
    this.searchStats = {
      totalTime: 0,
      extractionTime: 0,
      searchTime: 0,
      renderTime: 0,
      resultCount: 0
    }
    
    console.log('🎯 SearchCoordinator 初始化完成')
  }
}
export default new SearchCoordinator()
  • Step 2: 实现主执行方法
/**
 * 主入口:执行完整的搜索+高亮流程
 * 
 * @param {Object} params - 搜索参数
 * @param {string} params.keyword - 搜索关键词
 * @param {string} params.fileType - 文件类型 (pdf/docx/xlsx/text...)
 * @param {HTMLElement} params.container - 文档容器DOM元素
 * @param {number} params.targetPage - 目标页码(可选,优先搜索该页)
 * @returns {Promise<{success: boolean, resultCount?: number, message?: string, stats?: Object}>}
 */
async execute(params) {
  const { keyword, fileType, container, targetPage } = params
  
  // 参数校验
  if (!container) {
    return { success: false, message: '文档容器不存在' }
  }
  
  // 防抖:如果正在搜索中,取消上一次
  if (this.isSearching) {
    console.warn('⚠️ 上一次搜索仍在进行中,将取消')
    this.renderer.clearAll()
  }
  
  this.isSearching = true
  const startTime = performance.now()
  
  // 重置统计
  this.searchStats = {
    totalTime: 0,
    extractionTime: 0,
    searchTime: 0,
    renderTime: 0,
    resultCount: 0
  }
  
  try {
    console.log('🚀 开始统一定位流程:', { 
      fileType, 
      keyword: keyword?.substring(0, 50), 
      targetPage,
      timestamp: new Date().toISOString()
    })
    
    // Step 1: 文本提取(带重试机制)
    const extractionStart = performance.now()
    let documents = await this.withRetry(
      () => this.extractor.extract(fileType, container),
      3,   // 最多重试3次
      200  // 每次间隔200ms
    )
    this.searchStats.extractionTime = performance.now() - extractionStart
    
    if (!documents || documents.length === 0) {
      console.warn('⚠️ 文本提取失败或无内容')
      return { success: false, message: '无法提取文档内容' }
    }
    
    console.log(`📄 文本提取完成: ${documents.length} 个文本块 (${this.searchStats.extractionTime.toFixed(0)}ms)`)
    
    // Step 2: 如果指定了页码,优先过滤该页的内容
    if (targetPage && targetPage > 1) {
      const filtered = documents.filter(doc => 
        !doc.location?.page || doc.location.page === targetPage
      )
      
      if (filtered.length > 0) {
        documents = filtered
        console.log(`📄 过滤到第${targetPage}页: ${documents.length} 个文本块`)
      } else {
        console.warn(`⚠️ 第${targetPage}页无内容,扩大范围搜索`)
        documents = await this.extractor.extract(fileType, container)
      }
    }
    
    // Step 3: 初始化搜索引擎并执行搜索
    const searchStart = performance.now()
    this.searchEngine.initIndex(documents)
    
    const results = this.searchEngine.search(keyword, { limit: 30 })
    this.searchStats.searchTime = performance.now() - searchStart
    
    if (results.length === 0) {
      console.warn('⚠️ 未找到匹配结果')
      return { 
        success: false, 
        message: `未找到"${keyword.substring(0, 20)}${keyword.length > 20 ? '...' : ''}"相关内容`,
        stats: { ...this.searchStats }
      }
    }
    
    console.log(`🔍 搜索完成: ${results.length} 个匹配项 (${this.searchStats.searchTime.toFixed(0)}ms)`)
    
    // Step 4: 渲染高亮
    const renderStart = performance.now()
    const highlightCount = this.renderer.renderAll(results)
    this.searchStats.renderTime = performance.now() - renderStart
    this.searchStats.resultCount = highlightCount
    
    // 计算总耗时
    this.searchStats.totalTime = performance.now() - startTime
    
    console.log(`✅ 搜索流程完成!`, {
      ...this.searchStats,
      highlightCount,
      totalTimeFormatted: `${(this.searchStats.totalTime / 1000).toFixed(2)}s`
    })
    
    return {
      success: true,
      resultCount: highlightCount,
      stats: { ...this.searchStats },
      firstResult: results[0]
    }
    
  } catch (error) {
    console.error('❌ 搜索流程异常:', error)
    return { 
      success: false, 
      message: `搜索失败: ${error.message || '未知错误'}`,
      stats: { ...this.searchStats }
    }
  } finally {
    this.isSearching = false
  }
}
  • Step 3: 实现重试机制和辅助方法
/**
 * 重试机制包装器
 * @param {Function} fn - 需要重试的异步函数
 * @param {number} maxRetries - 最大重试次数
 * @param {number} delayMs - 重试间隔(毫秒)
 * @returns {Promise<any>} 函数返回值
 * @private
 */
async function withRetry(fn, maxRetries, delayMs) {
  let lastError
  
  for (let i = 0; i < maxRetries; i++) {
    try {
      const result = await fn()
      return result
    } catch (error) {
      lastError = error
      console.warn(`⚠️ 第${i + 1}/${maxRetries}次尝试失败:`, error.message?.substring(0, 100))
      
      if (i < maxRetries - 1) {
        // 等待一段时间后重试给DOM渲染留出时间
        await new Promise(resolve => setTimeout(resolve, delayMs))
      }
    }
  }
  
  throw lastError
}

/**
 * 辅助函数:延迟执行
 * @param {number} ms - 延迟毫秒数
 * @returns {Promise<void>}
 */
function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms))
}
  • Step 4: 添加代理方法
/**
 * 导航到下一个高亮代理给HighlightRenderer
 */
nextHighlight() {
  this.renderer.next()
}

/**
 * 导航到上一个高亮代理给HighlightRenderer
 */
prevHighlight() {
  this.renderer.prev()
}

/**
 * 清除所有高亮代理给HighlightRenderer
 */
clearAllHighlights() {
  this.renderer.clearAll()
}

/**
 * 获取当前统计信息
 */
get currentStats() {
  return { ...this.searchStats }
}
  • Step 5: Commit
git add src/utils/searchCoordinator.js
git commit -m "feat: implement SearchCoordinator with retry mechanism and flow orchestration"

Task 6: 集成到ReaderPage.vue修改onMounted

Files:

  • Modify: src/views/ReaderPage.vue (script部分)

  • Step 1: 添加新的imports和响应式变量

在文件顶部的import区域添加

// 新增导入
import searchCoordinator from '@/utils/searchCoordinator'
import { SearchOutlined, UpOutlined, DownOutlined, CloseOutlined } from '@ant-design/icons-vue'

在setup函数内的响应式变量声明区域添加

// 搜索导航相关状态
const showNavigationControls = ref(false)
const searchResultCount = ref(0)
const currentHighlightIndex = ref(0)
const searchKeywordPreview = ref('')
  • Step 2: 修改onMounted逻辑集成自动搜索

找到现有的onMounted(() => { ... })代码块,在loadFile调用之后添加:

onMounted(async () => {
  loadFromStorage()
  initSpeech()
  document.addEventListener('click', handleClickOutside)

  // 从路由参数获取文件信息
  const fileId = route.query.id
  const fileTitle = route.query.title
  const fileExtension = route.query.extension
  const page = route.query.page ? parseInt(route.query.page) : null
  const from = route.query.from || ''
  const keyword = route.query.keyword || ''

  targetPage.value = page
  returnFrom.value = from
  searchKeyword.value = keyword  // 保留兼容性

  if (fileId) {
    await loadFile(fileId, fileTitle, fileExtension, page)
    
    // ✨ 新增:如果有关键词,自动执行搜索高亮
    if (keyword?.trim()) {
      // 不同文件类型的DOM渲染延迟不同
      const renderDelays = {
        pdf: 1000,      // PDF需要较长时间渲染textLayer
        docx: 1500,     // Word需要mammoth.js解析
        pptx: 1200,     // PPTX需要pptxgenjs渲染
        xlsx: 800,      // Excel使用xlsx库解析
        epub: 2000,     // 电子书最慢
        html: 400,      // HTML较快
        default: 500    // 默认延迟
      }
      
      const delay = renderDelays[fileType.value] || renderDelays.default
      
      console.log(`⏳ 将在 ${delay}ms 后执行搜索: "${keyword.substring(0, 30)}"`)
      
      setTimeout(async () => {
        const container = scrollContainerRef.value
        if (!container) {
          console.warn('⚠️ 文档容器未找到,无法执行搜索')
          return
        }
        
        console.log('🎯 开始执行统一定位...')
        
        const result = await searchCoordinator.execute({
          keyword,
          fileType: fileType.value,
          container,
          targetPage: page
        })
        
        if (result.success) {
          // 更新UI状态
          showNavigationControls.value = true
          searchResultCount.value = result.resultCount
          searchKeywordPreview.value = keyword.length > 25 
            ? keyword.substring(0, 25) + '...' 
            : keyword
          
          // 显示成功提示
          message.success({
            content: `找到 ${result.resultCount} 处"${searchKeywordPreview.value}"相关内容`,
            duration: 4,
            key: 'searchSuccess'
          })
          
          console.log('✅ 搜索高亮完成!', result.stats)
        } else {
          // 显示警告提示(但不阻断用户,因为已经跳转到了正确页面)
          message.warning({
            content: result.message || '未能定位到具体位置',
            duration: 3,
            key: 'searchWarning'
          })
          
          console.log('⚠️ 搜索未找到结果:', result.message)
        }
      }, delay)
    }
  }
})
  • Step 3: 添加事件监听和清理

onMounted末尾添加高亮导航事件监听:

// 监听高亮导航事件由HighlightRenderer触发
const handleHighlightNavigate = (e) => {
  const { currentIndex, total } = e.detail
  currentHighlightIndex.value = currentIndex
  searchResultCount.value = total
}

window.addEventListener('highlightNavigate', handleHighlightNavigate)

修改onUnmounted添加清理逻辑:

onUnmounted(() => {
  stopSpeech()
  document.removeEventListener('click', handleClickOutside)
  
  // 清理高亮相关
  window.removeEventListener('highlightNavigate', handleHighlightNavigate)
  searchCoordinator.clearAllHighlights()
  
  // Blob URL清理
  if (pdfUrl.value && pdfUrl.value.startsWith('blob:')) {
    URL.revokeObjectURL(pdfUrl.value)
  }
})
  • Step 4: 添加导航控制方法
// 搜索导航控制方法
const nextHighlight = () => {
  searchCoordinator.nextHighlight()
}

const prevHighlight = () => {
  searchCoordinator.prevHighlight()
}

const closeSearch = () => {
  searchCoordinator.clearAllHighlights()
  showNavigationControls.value = false
  currentHighlightIndex.value = 0
  searchResultCount.value = 0
  searchKeywordPreview.value = ''
}
  • Step 5: Commitscript部分修改
git add src/views/ReaderPage.vue
git commit -m "feat: integrate universal search engine into ReaderPage onMounted"

Task 7: 添加ReaderPage.vue的UI模板搜索导航栏

Files:

  • Modify: src/views/ReaderPage.vue (template部分)

  • Step 1: 在template中添加搜索导航栏HTML

</div><!-- reader-container 结束 -->之前即reader-container闭合标签内部最后面添加

<!-- ===== 搜索结果导航栏 ===== -->
<Transition name="slide-up">
  <div v-if="showNavigationControls" class="search-nav-bar">
    <div class="nav-info">
      <SearchOutlined class="nav-icon" />
      <span>找到 <strong>{{ searchResultCount }}</strong> 处匹配</span>
      <span class="keyword-preview">"{{ searchKeywordPreview }}"</span>
    </div>
    
    <div class="nav-actions">
      <button 
        class="nav-btn" 
        @click="prevHighlight"
        :disabled="currentHighlightIndex <= 0"
        title="上一处匹配"
      >
        <UpOutlined /> 上一处
      </button>
      
      <span class="nav-counter">
        {{ currentHighlightIndex + 1 }} / {{ searchResultCount }}
      </span>
      
      <button 
        class="nav-btn" 
        @click="nextHighlight"
        :disabled="currentHighlightIndex >= searchResultCount - 1"
        title="下一处匹配"
      >
        下一处 <DownOutlined />
      </button>
      
      <button 
        class="nav-btn close-btn" 
        @click="closeSearch"
        title="关闭搜索结果"
      >
        <CloseOutlined />
      </button>
    </div>
  </div>
</Transition>
  • Step 2: Committemplate部分修改
git add src/views/ReaderPage.vue
git commit -m "feat: add search navigation bar UI component"

Task 8: 添加CSS样式高亮效果 + 导航栏)

Files:

  • Modify: src/views/ReaderPage.vue (style部分)

  • Step 1: 添加高亮基础样式

<style scoped>区域末尾(</style>之前)添加:

/* ========================================
   统一搜索高亮样式系统
   ======================================== */

/* ----- DOM类型高亮 (.search-highlight) ----- */
:deep(.search-highlight) {
  background: linear-gradient(135deg, #fff3cd 0%, #ffe69c 100%);
  border-bottom: 2px solid #ffc107;
  border-radius: 2px;
  padding: 1px 2px;
  margin: 0 1px;
  box-shadow: 0 1px 3px rgba(255, 193, 7, 0.3);
  cursor: pointer;
  position: relative;
  transition: all 0.2s ease;
  display: inline;
}

:deep(.search-highlight:hover) {
  background: linear-gradient(135deg, #ffe69c 0%, #ffd43b 100%);
  box-shadow: 0 2px 6px rgba(255, 193, 7, 0.5);
  transform: translateY(-1px);
}

:deep(.search-highlight.active) {
  background: linear-gradient(135deg, #ffd43b 0%, #fab005 100%);
  border-bottom: 3px solid #f59f00;
  box-shadow: 0 3px 8px rgba(245, 159, 0, 0.7);
  animation: highlight-glow 2s ease-in-out infinite;
}

/* 高亮序号标签 */
:deep(.highlight-label) {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  position: absolute;
  top: -18px;
  left: -4px;
  background: #ffc107;
  color: #212529;
  font-size: 10px;
  font-weight: bold;
  padding: 0 4px;
  border-radius: 8px 8px 0 8px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
  z-index: 10;
  line-height: 16px;
  min-width: 16px;
  pointer-events: none;
}

:deep(.search-highlight.active .highlight-label) {
  background: #f59f00;
  color: white;
}

/* ----- PDF高亮overlay (在iframe内通过JS设置内联样式) ----- */

:deep(.pdf-highlight-badge) {
  /* 样式已在JS中内联设置 */
}

/* ----- 表格高亮 (.table-highlight) ----- */
:deep(.table-highlight) {
  background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%) !important;
  border: 2px solid #28a745 !important;
  position: relative !important;
  animation: table-pulse 1.5s ease-in-out;
}

:deep(.table-highlight-badge) {
  position: absolute;
  top: -10px;
  right: -10px;
  background: #28a745;
  color: white;
  font-size: 10px;
  font-weight: bold;
  padding: 2px 6px;
  border-radius: 10px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
  z-index: 10;
  pointer-events: none;
}

/* ========================================
   动画效果
   ======================================== */

/* 脉冲发光动画(当前激活项) */
@keyframes highlight-glow {
  0%, 100% {
    box-shadow: 0 3px 8px rgba(245, 159, 0, 0.6);
  }
  50% {
    box-shadow: 0 4px 16px rgba(245, 159, 0, 0.9), 0 0 20px rgba(245, 159, 0, 0.4);
  }
}

/* 缩放脉冲(首次出现) */
@keyframes highlight-pulse {
  0% { transform: scale(1); opacity: 1; }
  50% { transform: scale(1.05); opacity: 0.8; }
  100% { transform: scale(1); opacity: 1; }
}

/* 表格脉冲 */
@keyframes table-pulse {
  0% {
    transform: scale(1);
    box-shadow: 0 2px 4px rgba(40, 167, 69, 0.4);
  }
  50% {
    transform: scale(1.02);
    box-shadow: 0 4px 12px rgba(40, 167, 69, 0.7);
  }
  100% {
    transform: scale(1);
    box-shadow: 0 2px 4px rgba(40, 167, 69, 0.4);
  }
}

/* ========================================
   搜索导航栏样式
   ======================================== */

.search-nav-bar {
  position: fixed;
  bottom: 30px;
  left: 50%;
  transform: translateX(-50%);
  background: white;
  border-radius: 12px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
  padding: 12px 24px;
  display: flex;
  align-items: center;
  gap: 20px;
  z-index: 1000;
  border: 1px solid #e9ecef;
  animation: slide-up-fade-in 0.3s ease-out;
  max-width: calc(100vw - 40px);
}

.nav-info {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 14px;
  color: #495057;
  white-space: nowrap;
}

.nav-icon {
  color: #ffc107;
  font-size: 16px;
}

.keyword-preview {
  color: #868e96;
  font-style: italic;
  max-width: 150px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.nav-actions {
  display: flex;
  align-items: center;
  gap: 12px;
}

.nav-btn {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 6px 12px;
  border: 1px solid #dee2e6;
  background: white;
  border-radius: 6px;
  font-size: 13px;
  color: #495057;
  cursor: pointer;
  transition: all 0.2s ease;
  white-space: nowrap;
}

.nav-btn:hover:not(:disabled) {
  background: #f8f9fa;
  border-color: #ffc107;
  color: #f59f00;
}

.nav-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.nav-counter {
  font-size: 13px;
  font-weight: 600;
  color: #212529;
  min-width: 40px;
  text-align: center;
}

.close-btn {
  background: #fff3cd;
  border-color: #ffc107;
  color: #856404;
}

.close-btn:hover:not(:disabled) {
  background: #ffc107;
  color: white;
}

/* 导航栏过渡动画 */
.slide-up-enter-active,
.slide-up-leave-active {
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.slide-up-enter-from,
.slide-up-leave-to {
  opacity: 0;
  transform: translateX(-50%) translateY(20px);
}

@keyframes slide-up-fade-in {
  from {
    opacity: 0;
    transform: translateX(-50%) translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateX(-50%) translateY(0);
  }
}

/* ========================================
   响应式适配
   ======================================== */

@media (max-width: 768px) {
  .search-nav-bar {
    padding: 10px 16px;
    gap: 12px;
    bottom: 20px;
  }
  
  .nav-info {
    font-size: 12px;
    gap: 4px;
  }
  
  .keyword-preview {
    max-width: 80px;
  }
  
  .nav-btn {
    padding: 5px 8px;
    font-size: 12px;
  }
  
  .nav-btn span:not(.nav-icon) {
    display: none;  /* 移动端隐藏文字,只显示图标 */
  }
}
  • Step 2: Commit样式修改
git add src/views/ReaderPage.vue
git commit -m "style: add comprehensive highlight styles and search navigation bar CSS"

Task 9: 优化QAModule.vue的关键词提取逻辑

Files:

  • Modify: src/components/QAModule.vue

  • Step 1: 定位并修改navigateToFileReader函数中的关键词提取逻辑

找到第1159-1175行的代码块替换为优化后的版本

// 优化后的关键词提取逻辑原第1159-1175行
let keyword = ''

// 优先级1: ref.context引用上下文最可靠
if (ref.context) {
  keyword = ref.context.substring(0, 150)  // 增加长度限制至150字符
} 

// 优先级2: ref.rawData中的多个字段扩展检查范围
else if (ref.rawData) {
  const raw = ref.rawData
  keyword = raw.content || 
            raw.context || 
            raw.preview || 
            raw.excerpt || 
            raw.query || 
            raw.question ||
            raw.text ||
            ''  // 兜底空字符串
  if (keyword) keyword = keyword.substring(0, 150)
}

// 优先级3: ref.location中的文本描述去除页码信息
if (!keyword && ref.location && ref.location.length > 4) {
  const locText = ref.location
    .replace(/第\d+页|page\s*\d+/gi, '')  // 去除"第X页"/"page X"
    .replace(/P\d+/gi, '')                 // 去除"P1", "P2"等
    .trim()
  
  if (locText.length >= 4) {
    keyword = locText.substring(0, 120)
  }
}

// 清洗关键词(提升匹配成功率)
if (keyword) {
  keyword = cleanSearchKeyword(keyword)
  
  // 降低最小长度要求从原来的某个值降至4字符
  if (keyword.length < 4) keyword = ''
}
  • Step 2: 验证修改不影响现有功能

手动测试:

  1. 打开AI对话页面
  2. 发送一个问题,获得带引用的回答
  3. 点击引用来源
  4. 验证能正常跳转到Reader页面
  • Step 3: Commit
git add src/components/QAModule.vue
git commit -m "optimize: improve keyword extraction logic in navigateToFileReader for better match rate"

Task 10: 集成测试与调试

Files:

  • 所有已修改的文件

  • Step 1: 启动开发服务器

npm run dev

Run: npm run dev Expected: 开发服务器启动在 http://localhost:5173

  • Step 2: 测试PDF文件高亮

测试步骤:

  1. 打开AI对话模块
  2. 发送一个关于制度文件的问题确保回答包含PDF文件引用
  3. 点击回答中的引用来源链接
  4. 预期结果
    • 跳转到Reader页面
    • PDF加载完成后自动显示黄色高亮overlay
    • 底部出现搜索导航栏,显示"找到 N 处匹配"
    • 点击"下一处"按钮可在多个高亮间切换
    • 高亮持续显示(不会自动消失)
  • Step 3: 测试Word文档高亮

测试步骤:

  1. 上传一个Word文档到知识库
  2. 在AI对话中提问关于该文档内容的问题
  3. 点击引用来源
  4. 预期结果
    • Word文档正常渲染
    • 匹配的文本被黄色mark标签包裹
    • 序号标签(1,2,3...)清晰可见
    • 当前激活项带脉冲发光动画
  • Step 4: 测试Excel表格高亮

测试步骤:

  1. 上传一个Excel文件
  2. 引用Excel内容进行问答
  3. 点击表格引用
  4. 预期结果
    • 匹配的单元格被绿色边框高亮
    • 右上角显示绿色圆形角标
    • 点击单元格可聚焦到该位置
  • Step 5: 测试边界情况

测试场景:

  • 关键词完全匹配 → 应精准高亮

  • 关键词部分匹配(截断) → 应模糊匹配成功

  • 无匹配结果 → 显示友好提示,不报错

  • 快速连续点击多次引用 → 应防抖,只执行最后一次

  • 大文档(>10MB → 不卡顿,响应时间<3秒

  • 移动端触摸操作 → 导航按钮可正常点击

  • Step 6: 性能验证

打开浏览器DevTools Performance面板

  • 搜索+高亮总耗时 < 2秒

  • 内存增长 < 50MB

  • 无内存泄漏(反复进入/离开页面)

  • CPU峰值 < 60%

  • Step 7: 修复发现的问题

如果在测试中发现任何bug在此步骤修复并单独commit。

  • Step 8: 最终Commit
git add .
git commit -m "test: complete integration testing and fix issues for universal search engine feature"

Task 11: 文档完善与发布准备

Files:

  • README或内部文档可选

  • Step 1: 更新API接口文档如有变更

如果有对外暴露的新接口或参数变化,更新接口文档。

  • Step 2: 编写简要的使用指南(可选)

创建 docs/universal-search-guide.md

# 统一搜索引擎使用指南

## 功能说明

用户在AI对话中点击引用来源后系统会自动
1. 跳转到对应的文档阅读器页面
2. 在文档中搜索并高亮显示引用的具体内容
3. 底部显示导航栏,支持在多个匹配项间切换

## 支持的文件格式

- ✅ PDF (overlay高亮)
- ✅ Word/DOCX (DOM高亮)
- ✅ Excel/XLSX (单元格高亮)
- ✅ PowerPoint/PPTX
- ✅ 纯文本/TXT
- ✅ Markdown
- ✅ JSON/XML/HTML

## 技术特性

- 模糊匹配算法容错率40%
- 中文分词支持
- 智能重试机制DOM未就绪时自动重试
- 持久化高亮(手动关闭前不消失)
- 键盘导航支持

## 开发者信息

- 引擎位置: `src/utils/` 目录
- 核心依赖: Fuse.js 7.0
- 集成入口: `ReaderPage.vue``onMounted`
  • Step 3: 创建Release Tag可选
git tag -a v2.15.0-universal-search -m "Add universal search engine with fuzzy matching and multi-format highlighting"
git push origin v2.15.0-universal-search
  • Step 4: 最终Commit
git add .
git commit -m "release: complete implementation of universal search engine feature (v2.15)"

自我审查清单

Spec覆盖率检查 ✓

设计文档章节 对应Task 状态
3.1 TextExtractor Task 2
3.2 FuzzySearchEngine Task 3
3.3 HighlightRenderer Task 4
3.4 SearchCoordinator Task 5
4.1 QAModule优化 Task 9
4.2 ReaderPage集成 Task 6, 7, 8
5 CSS样式规范 Task 8
6 数据流与时序 Task 6
7 边界情况处理 Task 5 (withRetry)
9 测试计划 Task 10

占位符扫描 ✓

  • 无TBD/TODO占位符
  • 无"适当处理"等模糊描述
  • 所有代码都是实际可执行的
  • 所有命令都有预期的输出说明

类型一致性检查 ✓

  • SearchResult接口在Task 3定义Task 4/5中使用一致
  • Document接口在Task 2定义Task 3中使用一致
  • 方法名大小写统一camelCase
  • 文件路径全部使用绝对路径

📊 计划总结

维度 数值
总Task数 11个
预计代码量 ~1100行新增+ ~280行修改
新增文件 4个工具模块
修改文件 2个Vue组件
新增依赖 fuse.js@7.0.0 (10KB)
开发周期 4天建议
测试覆盖 手动集成测试 + 可选单元测试

计划版本: v1.0
创建日期: 2026-05-30
基于设计文档: 2026-05-30-ai-chat-link-highlight-design.md