2461 lines
63 KiB
Markdown
2461 lines
63 KiB
Markdown
# 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: Commit(script部分修改)**
|
||
|
||
```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: Commit(template部分修改)**
|
||
|
||
```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)
|