From d6bac94fda674d38ad5b0e4a14f4b278314795d7 Mon Sep 17 00:00:00 2001 From: cwchen <1048842385@qq.com> Date: Wed, 12 Nov 2025 15:40:30 +0800 Subject: [PATCH] =?UTF-8?q?word=20=E6=90=9C=E7=B4=A2=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src/views/common/DocumentSearchWord.vue | 536 ++++++++++++------------ 2 files changed, 271 insertions(+), 267 deletions(-) diff --git a/package.json b/package.json index 5ca8d6e..99c2817 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "clipboard": "2.0.8", "core-js": "3.37.1", "crypto-js": "^4.2.0", - "docx-preview": "^0.1.7", + "docx-preview": "^0.3.7", "echarts": "5.4.0", "element-ui": "2.15.14", "file-saver": "2.0.5", diff --git a/src/views/common/DocumentSearchWord.vue b/src/views/common/DocumentSearchWord.vue index 54fd934..5c11a83 100644 --- a/src/views/common/DocumentSearchWord.vue +++ b/src/views/common/DocumentSearchWord.vue @@ -50,7 +50,6 @@ import * as docxPreview from 'docx-preview/dist/docx-preview.js' const DEFAULT_DOC_URL = 'http://192.168.0.14:9090/smart-bid/technicalSolutionDatabase/2025/11/11/887b35d28b2149b6a7555fb639be9411.docx' -// const DEFAULT_DOC_URL = 'http://192.168.0.14:9090/smart-bid/technicalSolutionDatabase/2025/11/12/efec85cd3793469d9d0a6991d7d08f9b.doc' export default { name: 'DocumentSearchWord', @@ -131,14 +130,26 @@ export default { await docxPreview.renderAsync(arrayBuffer, container, null, { className: 'docx-viewer', inWrapper: true, - ignoreFonts: false, - breakPages: false, + hideWrapperOnPrint: false, ignoreWidth: false, ignoreHeight: false, + ignoreFonts: false, + breakPages: false, + ignoreLastRenderedPageBreak: true, + experimental: false, + trimXmlDeclaration: true, + useBase64URL: false, + renderChanges: false, + renderHeaders: true, + renderFooters: true, + renderFootnotes: true, + renderEndnotes: true, + renderComments: false, + renderAltChunks: true, + useMathMLPolyfill: false, + debug: false, }) - this.wrapContentWithArticle(container) this.normalizeTableStyles(container) - this.injectFontResolver(container) this.docRendered = true this.loading = false this.$nextTick(() => { @@ -158,6 +169,11 @@ export default { } }, + getElementTextContent(element) { + if (!element) return '' + return (element.textContent || '').trim() + }, + prepareSearchSegments(forceReset = false) { if (!this.docRendered) { if (forceReset) { @@ -182,12 +198,33 @@ export default { '.docx-viewer span', ] - const elements = Array.from(container.querySelectorAll(selectors.join(','))) - .filter((el) => (el.textContent || '').trim()) + const allElements = Array.from(container.querySelectorAll(selectors.join(','))) + const targetTags = ['p', 'li', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span'] + + const elements = allElements.filter((el) => { + const text = this.getElementTextContent(el) + if (text.length === 0) return false + + let parent = el.parentElement + while (parent && parent !== container) { + const parentTag = parent.tagName ? parent.tagName.toLowerCase() : '' + if (targetTags.includes(parentTag)) { + return false + } + parent = parent.parentElement + } + return true + }) elements.forEach((el, idx) => { if (typeof el.dataset.originalHtml === 'undefined') { - el.dataset.originalHtml = el.innerHTML + const cleanHtml = this.cleanHtmlFromMarks(el.innerHTML) + el.dataset.originalHtml = cleanHtml + } else { + el.dataset.originalHtml = this.cleanHtmlFromMarks(el.dataset.originalHtml) + } + if (typeof el.dataset.originalText === 'undefined') { + el.dataset.originalText = this.getElementTextContent(el) } el.dataset.segmentIndex = idx }) @@ -213,6 +250,92 @@ export default { } }, + cleanHtmlFromMarks(html) { + if (!html) return html + const tempDiv = document.createElement('div') + tempDiv.innerHTML = html + const marks = tempDiv.querySelectorAll('mark') + marks.forEach(mark => { + const parent = mark.parentNode + while (mark.firstChild) { + parent.insertBefore(mark.firstChild, mark) + } + parent.removeChild(mark) + }) + return tempDiv.innerHTML + }, + + highlightTextInNode(node, pattern, results, segmentIndex, parentElement, markIndexRef) { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent + if (!text || !text.trim()) return + + pattern.lastIndex = 0 + const textMatches = [] + let match + while ((match = pattern.exec(text)) !== null) { + textMatches.push({ + index: match.index, + length: match[0].length, + text: match[0] + }) + } + + if (textMatches.length === 0) return + + const parent = node.parentNode + if (!parent) return + + let lastIndex = 0 + const fragment = document.createDocumentFragment() + + textMatches.forEach((matchInfo) => { + if (lastIndex < matchInfo.index) { + const beforeText = text.substring(lastIndex, matchInfo.index) + if (beforeText) { + fragment.appendChild(document.createTextNode(beforeText)) + } + } + + const mark = document.createElement('mark') + mark.className = 'search-highlight' + mark.textContent = matchInfo.text + fragment.appendChild(mark) + + results.push({ + element: mark, + segmentIndex: segmentIndex, + markIndex: markIndexRef.value, + parentElement: parentElement + }) + + markIndexRef.value++ + lastIndex = matchInfo.index + matchInfo.length + }) + + if (lastIndex < text.length) { + const afterText = text.substring(lastIndex) + if (afterText) { + fragment.appendChild(document.createTextNode(afterText)) + } + } + + if (fragment.childNodes.length > 0) { + parent.replaceChild(fragment, node) + } + } else if (node.nodeType === Node.ELEMENT_NODE) { + const tagName = node.tagName ? node.tagName.toLowerCase() : '' + if (tagName === 'mark' || tagName === 'script' || tagName === 'style' || tagName === 'noscript') { + return + } + + const children = Array.from(node.childNodes) + children.forEach(child => { + this.highlightTextInNode(child, pattern, results, segmentIndex, parentElement, markIndexRef) + }) + } + }, + highlightMatches() { const keyword = this.keyword.trim() if (!keyword) { @@ -229,42 +352,103 @@ export default { this.clearHighlights() const results = [] - this.searchSegments.forEach((el) => { + let totalTextMatches = 0 + + this.searchSegments.forEach((el, segmentIndex) => { + const originalText = el.dataset.originalText + if (typeof originalText === 'undefined' || !originalText) return + + pattern.lastIndex = 0 + const textMatchCount = (originalText.match(pattern) || []).length + if (textMatchCount === 0) return + + totalTextMatches += textMatchCount + const originalHtml = el.dataset.originalHtml if (typeof originalHtml === 'undefined') return - const text = el.textContent || '' - pattern.lastIndex = 0 - if (!pattern.test(text)) { - return + + const cleanHtml = this.cleanHtmlFromMarks(originalHtml) + el.innerHTML = cleanHtml + el.dataset.originalHtml = cleanHtml + + const existingMarks = el.querySelectorAll('mark') + if (existingMarks.length > 0) { + console.warn(`Segment ${segmentIndex} still has ${existingMarks.length} mark tags after cleaning`) } - pattern.lastIndex = 0 - const highlightedHtml = originalHtml.replace(pattern, '$1') - el.innerHTML = highlightedHtml - const marks = el.querySelectorAll('mark.search-highlight') - marks.forEach((mark) => { - results.push({ element: mark }) + + const markIndexRef = { value: 0 } + const children = Array.from(el.childNodes) + children.forEach(child => { + this.highlightTextInNode(child, pattern, results, segmentIndex, el, markIndexRef) }) + + const createdMarks = el.querySelectorAll('mark.search-highlight') + if (createdMarks.length !== textMatchCount) { + console.warn(`Segment ${segmentIndex}: expected ${textMatchCount} marks, created ${createdMarks.length}`) + } }) + if (results.length !== totalTextMatches) { + console.warn(`Total matches mismatch: expected ${totalTextMatches}, got ${results.length}`) + } + this.searchResults = results + if (!results.length) { this.currentResultIndex = -1 - this.$message && this.$message.info(`未找到“${keyword}”相关内容`) + this.$message && this.$message.info(`未找到"${keyword}"相关内容`) return } this.currentResultIndex = 0 this.updateActiveHighlight() - this.navigateToResult(this.currentResultIndex) + this.$nextTick(() => { + this.navigateToResult(this.currentResultIndex) + }) + }, + + getResultElement(result) { + if (!result) return null + + if (result.element && document.body.contains(result.element)) { + return result.element + } + + if (result.parentElement && result.segmentIndex !== undefined && result.markIndex !== undefined) { + const parent = result.parentElement + if (document.body.contains(parent)) { + const marks = parent.querySelectorAll('mark.search-highlight') + if (marks[result.markIndex]) { + result.element = marks[result.markIndex] + return result.element + } + } + } + + if (result.segmentIndex !== undefined && result.markIndex !== undefined && this.searchSegments[result.segmentIndex]) { + const segment = this.searchSegments[result.segmentIndex] + if (document.body.contains(segment)) { + const marks = segment.querySelectorAll('mark.search-highlight') + if (marks[result.markIndex]) { + result.element = marks[result.markIndex] + result.parentElement = segment + return result.element + } + } + } + + return null }, updateActiveHighlight() { this.searchResults.forEach((item, idx) => { - if (!item.element) return + const element = this.getResultElement(item) + if (!element) return + if (idx === this.currentResultIndex) { - item.element.classList.add('is-active') + element.classList.add('is-active', 'is-current') } else { - item.element.classList.remove('is-active') + element.classList.remove('is-active', 'is-current') } }) }, @@ -279,18 +463,53 @@ export default { return offset }, - navigateToResult(index) { + navigateToResult(index, useSmoothScroll = false) { if (!this.searchResults.length || index < 0 || index >= this.searchResults.length) return this.currentResultIndex = index this.updateActiveHighlight() - const target = this.searchResults[index]?.element - const wrapper = this.$refs.docWrapper - if (!target || !wrapper) return + this.$nextTick(() => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const result = this.searchResults[this.currentResultIndex] + const target = this.getResultElement(result) + const wrapper = this.$refs.docWrapper + if (!target || !wrapper) return - const absoluteTop = this.calculateOffsetTop(target, wrapper) - const margin = 24 - wrapper.scrollTop = Math.max(absoluteTop - margin, 0) + if (!document.body.contains(target)) { + return + } + + const targetRect = target.getBoundingClientRect() + if (targetRect.width === 0 && targetRect.height === 0) { + return + } + + try { + const wrapperRect = wrapper.getBoundingClientRect() + const currentScrollTop = wrapper.scrollTop + const targetTopRelativeToWrapper = targetRect.top - wrapperRect.top + currentScrollTop + const margin = 24 + const desiredScrollTop = Math.max(targetTopRelativeToWrapper - margin, 0) + + wrapper.scrollTop = desiredScrollTop + } catch (error) { + try { + let absoluteTop = 0 + let node = target + while (node && node !== wrapper) { + absoluteTop += node.offsetTop || 0 + node = node.offsetParent + } + const margin = 24 + wrapper.scrollTop = Math.max(absoluteTop - margin, 0) + } catch (e) { + console.error('Scroll error:', e) + } + } + }) + }) + }) }, async goToPrevious() { @@ -355,139 +574,15 @@ export default { this.loadDocument() }, - injectFontResolver(container) { - if (!container || container.querySelector('.docx-fonts-resolver')) return - const resolver = document.createElement('div') - resolver.className = 'docx-fonts-resolver' - resolver.innerHTML = [ - '微软雅黑', '宋体', '黑体', '仿宋', '楷体', '等线', 'Times New Roman', 'Arial' - ].map(font => `${font}`).join('') - container.appendChild(resolver) - }, - - wrapContentWithArticle(container) { - if (!container) return - const sections = Array.from(container.querySelectorAll('.docx-wrapper section')) - if (sections.length === 0) { - this.wrapElementsInSections(container) - return - } - sections.forEach((section) => { - this.wrapElementsInSections(section) - }) - }, - - wrapElementsInSections(node) { - if (!node) return - - // 获取所有需要包装的元素类型,但排除 header 和 footer 内的 - const selectors = [ - 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', - 'ul', 'ol', 'blockquote', 'table' - ] - - // 获取所有匹配的元素 - const allElements = Array.from(node.querySelectorAll(selectors.join(','))) - - // 过滤:只保留最外层的元素(没有父元素也是目标选择器) - const elements = allElements.filter(el => { - // 检查元素是否在 header 或 footer 内 - let parent = el.parentElement - while (parent && parent !== node) { - const tag = parent.tagName ? parent.tagName.toLowerCase() : '' - if (tag === 'header' || tag === 'footer') { - return false // 在 header/footer 内,跳过 - } - - // 如果父元素也是目标选择器之一,说明当前元素是嵌套的,应该跳过 - if (selectors.includes(tag)) { - return false - } - - parent = parent.parentElement - } - return true - }) - - if (elements.length === 0) return - - // 按连续元素分组 - const elementGroups = this.groupConsecutiveElements(elements) - - // 为每个元素组创建 article 包装 - elementGroups.forEach(group => { - if (group.length > 0) { - this.wrapElementGroupWithArticle(group) - } - }) - }, - - groupConsecutiveElements(elements) { - const groups = [] - let currentGroup = [] - - elements.forEach((el, index) => { - // 如果当前组为空,直接添加 - if (currentGroup.length === 0) { - currentGroup.push(el) - return - } - // 检查是否连续(在 DOM 中相邻) - const lastEl = currentGroup[currentGroup.length - 1] - let nextElement = lastEl.nextElementSibling - - // 跳过空白文本节点等,找到下一个元素 - while (nextElement && nextElement.nodeType !== 1) { - nextElement = nextElement.nextElementSibling - } - - if (nextElement === el) { - // 连续的元素 - currentGroup.push(el) - } else { - // 不连续,开始新组 - groups.push([...currentGroup]) - currentGroup = [el] - } - - // 如果是最后一个元素,结束当前组 - if (index === elements.length - 1) { - groups.push([...currentGroup]) - } - }) - - return groups - }, - - wrapElementGroupWithArticle(elements) { - if (!elements || elements.length === 0) return - - const firstElement = elements[0] - const parent = firstElement.parentElement - - // 创建 article 元素 - const article = document.createElement('article') - article.className = 'docx-article-wrapper' - - // 在第一个元素前插入 article - parent.insertBefore(article, firstElement) - - // 将所有元素移动到 article 中 - elements.forEach(el => { - article.appendChild(el) - }) - }, - normalizeTableStyles(container) { if (!container) return const tables = container.querySelectorAll('table') tables.forEach((table) => { table.style.width = 'auto' - table.style.display = 'table' table.style.tableLayout = 'auto' table.style.textAlign = 'left' table.style.margin = '0 auto' - const pTags = table.querySelectorAll('td p.docx-viewer.docx-viewer_TableParagraph') + const pTags = table.querySelectorAll('td p.docx-viewer_tableparagraph') pTags.forEach((p) => { p.style.lineHeight = '1.2' }) @@ -558,15 +653,22 @@ export default { overflow: hidden; position: relative; display: flex; - min-height: 480px; + flex-direction: column; + min-height: 0; + max-height: 100%; + height: 0; } .doc-wrapper { flex: 1; - overflow: auto; padding: 24px; background: #eef2ff; position: relative; + width: 100%; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; } .doc-content { @@ -587,115 +689,13 @@ export default { border: 1px solid rgba(68, 112, 255, 0.18); } -::v-deep .docx-wrapper section { - background: transparent !important; - padding: 48px 56px !important; - line-height: 1.75; - font-size: 14px; - color: #1f2a62; +::v-deep .docx-viewer-wrapper>section.docx-viewer { + background: white; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + margin-bottom: 5px !important; } -::v-deep .docx-wrapper h1, -::v-deep .docx-wrapper h2, -::v-deep .docx-wrapper h3, -::v-deep .docx-wrapper h4, -::v-deep .docx-wrapper h5, -::v-deep .docx-wrapper h6 { - color: #1b2f72; -} - -::v-deep .docx-wrapper .docx span { - color: inherit; -} - -.doc-content h1, -.doc-content h2, -.doc-content h3, -.doc-content h4, -.doc-content h5, -.doc-content h6 { - font-weight: 700; - margin: 24px 0 12px; - color: #1b2f72; -} - -.doc-content h1 { - font-size: 28px; -} - -.doc-content h2 { - font-size: 24px; -} - -.doc-content h3 { - font-size: 20px; -} - -.doc-content h4 { - font-size: 18px; -} - -.doc-content h5 { - font-size: 16px; -} - -.doc-content h6 { - font-size: 15px; -} - -.doc-content p { - margin: 8px 0; -} - -.doc-content ul, -.doc-content ol { - padding-left: 28px; - margin: 8px 0 12px; -} - -.doc-content ul { - list-style: disc; -} - -.doc-content ol { - list-style: decimal; -} - -.doc-content blockquote { - margin: 12px 0; - padding: 12px 18px; - background: rgba(68, 112, 255, 0.08); - border-left: 4px solid rgba(68, 112, 255, 0.45); - color: #3b4d86; -} - -.doc-content table { - width: 100%; - border-collapse: collapse; - margin: 16px 0; - background: #ffffff; - font-size: 13px; -} - - -.doc-content img { - max-width: 100%; - height: auto; - display: block; - margin: 12px auto; -} - -.doc-content hr { - border: none; - border-top: 1px solid rgba(0, 0, 0, 0.1); - margin: 24px 0; -} - -.doc-content sup { - font-size: 12px; -} - .overlay { position: absolute; inset: 0; @@ -728,14 +728,18 @@ export default { opacity: 0; } -.search-highlight { +::v-deep .search-highlight { background: rgba(255, 241, 168, 0.9); padding: 0 2px; border-radius: 2px; } -.search-highlight.is-active { +::v-deep .search-highlight.is-active, +::v-deep .search-highlight.is-current { background: #206dff !important; color: #ffffff; + box-shadow: 0 0 0 2px rgba(32, 109, 255, 0.35); + border-radius: 4px; + transition: background 0.2s ease, box-shadow 0.2s ease; } \ No newline at end of file