word 搜索优化

This commit is contained in:
cwchen 2025-11-12 15:40:30 +08:00
parent 3d8ea635a8
commit d6bac94fda
2 changed files with 271 additions and 267 deletions

View File

@ -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",

View File

@ -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, '<mark class="search-highlight">$1</mark>')
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 => `<span style="font-family:${font};">${font}</span>`).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;
}
</style>