# 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>} */ 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} 匹配结果列表(按相关性排序) */ 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} 高亮实例映射 (id → Highlight) */ this.activeHighlights = new Map() /** @type {number} 当前激活的高亮索引 */ this.currentIndex = -1 console.log('✨ HighlightRenderer 初始化完成') } } export default new HighlightRenderer() ``` - [ ] **Step 2: 实现主渲染方法和分发逻辑** ```javascript /** * 渲染所有搜索结果的高亮 * @param {Array} 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 = ` ${index + 1} ${range.toString()} ` // 包裹选中的文本 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} 函数返回值 * @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} */ 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** 在``之前(即reader-container闭合标签内部最后面)添加: ```html
``` - [ ] **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: 添加高亮基础样式** 在``之前)添加: ```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)