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

2461 lines
63 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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依赖**
```bash
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: 验证安装**
```bash
npm list fuse.js
```
Run: `npm list fuse.js`
Expected: `gkpt-frontend-vue@0.0.0 └── fuse.js@7.0.0`
- [ ] **Step 3: 创建utils目录如不存在**
```bash
mkdir -p src/utils
```
- [ ] **Step 4: Commit**
```bash
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类基础结构**
```javascript
// 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`方法之后添加:
```javascript
/**
* 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文本提取方法**
```javascript
/**
* 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文本提取方法**
```javascript
/**
* 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文本提取方法**
```javascript
/**
* 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文本提取方法**
```javascript
/**
* 通用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**
打开浏览器控制台,执行以下代码进行验证:
```javascript
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**
```bash
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**
```javascript
// 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`方法之后添加:
```javascript
/**
* 关键词预处理(提升中文匹配率)
* @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: 实现核心搜索方法**
```javascript
/**
* 执行模糊搜索
* @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: 实现结果格式化方法**
```javascript
/**
* 格式化原始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**
```javascript
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**
```bash
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类基础结构和状态管理**
```javascript
// 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: 实现主渲染方法和分发逻辑**
```javascript
/**
* 渲染所有搜索结果的高亮
* @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类型高亮最复杂的核心逻辑**
```javascript
/**
* 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: 实现辅助方法(文本查找)**
```javascript
/**
* 在父元素中查找包含目标文本的文本节点
* @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层方式**
```javascript
/**
* 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: 实现表格高亮**
```javascript
/**
* 表格类型高亮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: 实现导航控制方法**
```javascript
/**
* 导航到指定高亮(滚动到视图中心 + 激活样式)
* @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: 实现清除和动画方法**
```javascript
/**
* 清除所有高亮标记
*/
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**
```bash
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类并导入依赖**
```javascript
// 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: 实现主执行方法**
```javascript
/**
* 主入口:执行完整的搜索+高亮流程
*
* @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: 实现重试机制和辅助方法**
```javascript
/**
* 重试机制包装器
* @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: 添加代理方法**
```javascript
/**
* 导航到下一个高亮代理给HighlightRenderer
*/
nextHighlight() {
this.renderer.next()
}
/**
* 导航到上一个高亮代理给HighlightRenderer
*/
prevHighlight() {
this.renderer.prev()
}
/**
* 清除所有高亮代理给HighlightRenderer
*/
clearAllHighlights() {
this.renderer.clearAll()
}
/**
* 获取当前统计信息
*/
get currentStats() {
return { ...this.searchStats }
}
```
- [ ] **Step 5: Commit**
```bash
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区域添加
```javascript
// 新增导入
import searchCoordinator from '@/utils/searchCoordinator'
import { SearchOutlined, UpOutlined, DownOutlined, CloseOutlined } from '@ant-design/icons-vue'
```
在setup函数内的响应式变量声明区域添加
```javascript
// 搜索导航相关状态
const showNavigationControls = ref(false)
const searchResultCount = ref(0)
const currentHighlightIndex = ref(0)
const searchKeywordPreview = ref('')
```
- [ ] **Step 2: 修改onMounted逻辑集成自动搜索**
找到现有的`onMounted(() => { ... })`代码块,在`loadFile`调用之后添加:
```javascript
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`末尾添加高亮导航事件监听:
```javascript
// 监听高亮导航事件由HighlightRenderer触发
const handleHighlightNavigate = (e) => {
const { currentIndex, total } = e.detail
currentHighlightIndex.value = currentIndex
searchResultCount.value = total
}
window.addEventListener('highlightNavigate', handleHighlightNavigate)
```
修改`onUnmounted`添加清理逻辑:
```javascript
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: 添加导航控制方法**
```javascript
// 搜索导航控制方法
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部分修改**
```bash
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闭合标签内部最后面添加
```html
<!-- ===== 搜索结果导航栏 ===== -->
<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部分修改**
```bash
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>`之前)添加:
```css
/* ========================================
统一搜索高亮样式系统
======================================== */
/* ----- 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样式修改**
```bash
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行的代码块替换为优化后的版本
```javascript
// 优化后的关键词提取逻辑原第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**
```bash
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: 启动开发服务器**
```bash
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**
```bash
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`
```markdown
# 统一搜索引擎使用指南
## 功能说明
用户在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可选**
```bash
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**
```bash
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](../specs/2026-05-30-ai-chat-link-highlight-design.md)