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