smart-bid-web/src/views/common/DocumentSearchWord.vue

1007 lines
37 KiB
Vue
Raw Normal View History

2025-11-11 10:56:53 +08:00
<template>
<div class="document-search-word">
2025-11-11 16:15:06 +08:00
<div class="search-toolbar">
<el-input v-model="keyword" class="search-input" placeholder="请输入关键字" clearable
@keyup.enter.native="handleSearch" @clear="resetSearch">
<el-button slot="append" class="search-btn" icon="el-icon-search" @click="handleSearch"
:loading="searching" :disabled="searching">
<span v-if="!searching">搜索</span>
<span v-else>搜索中...</span>
</el-button>
</el-input>
<div class="search-status" v-if="searchResults.length">
<span class="result-indicator">{{ currentResultIndex + 1 }}/{{ searchResults.length }}</span>
<el-button class="nav-btn" type="text" icon="el-icon-arrow-up" :disabled="!searchResults.length"
@click="goToPrevious">
上一处
</el-button>
<el-button class="nav-btn" type="text" icon="el-icon-arrow-down" :disabled="!searchResults.length"
@click="goToNext">
下一处
</el-button>
2025-11-11 10:56:53 +08:00
</div>
</div>
2025-11-11 16:15:06 +08:00
<div class="viewer-container">
<div ref="docWrapper" class="doc-wrapper">
2025-11-11 17:50:15 +08:00
<div ref="docxContainer" class="doc-content"></div>
2025-11-11 16:15:06 +08:00
</div>
<transition name="fade">
<div v-if="loading" key="loading" class="overlay state-panel">
<i class="el-icon-loading state-icon"></i>
<p>正在加载 Word 文档请稍候...</p>
</div>
<div v-else-if="error" key="error" class="overlay state-panel">
<i class="el-icon-warning-outline state-icon"></i>
<p>{{ error }}</p>
<el-button type="primary" @click="reload">重新加载</el-button>
</div>
2025-11-11 17:50:15 +08:00
<div v-else-if="!docUrl" key="no-url" class="overlay state-panel">
2025-11-11 16:15:06 +08:00
<i class="el-icon-document state-icon"></i>
<p>暂未指定 Word 文件请通过路由参数 url 传入文件地址</p>
</div>
</transition>
</div>
2025-11-11 10:56:53 +08:00
</div>
</template>
2025-11-11 16:15:06 +08:00
2025-11-11 10:56:53 +08:00
<script>
2025-11-11 17:50:15 +08:00
import * as docxPreview from 'docx-preview/dist/docx-preview.js'
2025-11-11 16:15:06 +08:00
const DEFAULT_DOC_URL = 'http://192.168.0.14:9090/smart-bid/technicalSolutionDatabase/2025/11/11/887b35d28b2149b6a7555fb639be9411.docx'
2025-11-11 10:56:53 +08:00
export default {
name: 'DocumentSearchWord',
2025-11-11 16:15:06 +08:00
data() {
return {
docUrl: '',
keyword: '',
loading: false,
error: null,
searchResults: [],
currentResultIndex: -1,
searching: false,
searchSegments: [],
docRendered: false,
2025-11-11 17:50:15 +08:00
abortController: null,
2025-11-12 15:49:02 +08:00
scrollAnimationFrame: null,
2025-11-11 16:15:06 +08:00
}
},
mounted() {
this.docUrl = this.$route.query.url || DEFAULT_DOC_URL
if (this.docUrl) {
2025-11-11 17:50:15 +08:00
this.loadDocument()
2025-11-11 16:15:06 +08:00
}
},
2025-11-11 17:50:15 +08:00
beforeDestroy() {
this.cancelFetch()
2025-11-12 15:49:02 +08:00
this.cancelScrollAnimation()
2025-11-11 17:50:15 +08:00
},
2025-11-11 16:15:06 +08:00
watch: {
'$route.query.url'(newUrl, oldUrl) {
if (newUrl !== oldUrl) {
this.docUrl = newUrl || DEFAULT_DOC_URL
this.resetViewerState()
if (this.docUrl) {
2025-11-11 17:50:15 +08:00
this.loadDocument()
2025-11-11 16:15:06 +08:00
}
}
},
},
methods: {
2025-11-11 17:50:15 +08:00
cancelFetch() {
if (this.abortController) {
this.abortController.abort()
this.abortController = null
2025-11-11 16:15:06 +08:00
}
},
2025-11-11 17:50:15 +08:00
async loadDocument() {
if (!this.docUrl) return
this.loading = true
this.error = null
2025-11-11 16:15:06 +08:00
this.docRendered = false
2025-11-11 17:50:15 +08:00
this.clearHighlights()
this.searchSegments = []
this.cancelFetch()
const container = this.$refs.docxContainer
if (container) {
container.innerHTML = ''
}
try {
const headers = {}
const token = this.$store?.getters?.token || window?.sessionStorage?.getItem('token')
if (token) {
headers.Authorization = token.startsWith('Bearer ') ? token : `Bearer ${token}`
}
this.abortController = new AbortController()
const response = await fetch(this.docUrl, {
headers,
credentials: 'include',
signal: this.abortController.signal,
})
if (!response.ok) {
throw new Error(`加载 Word 文档失败:${response.statusText}`)
}
const arrayBuffer = await response.arrayBuffer()
if (!container) {
throw new Error('未找到文档容器')
}
await docxPreview.renderAsync(arrayBuffer, container, null, {
className: 'docx-viewer',
inWrapper: true,
2025-11-12 15:40:30 +08:00
hideWrapperOnPrint: false,
2025-11-12 11:26:00 +08:00
ignoreWidth: false,
ignoreHeight: false,
2025-11-12 15:40:30 +08:00
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,
2025-11-11 17:50:15 +08:00
})
2025-11-12 11:26:00 +08:00
this.normalizeTableStyles(container)
2025-11-11 17:50:15 +08:00
this.docRendered = true
this.loading = false
this.$nextTick(() => {
this.prepareSearchSegments()
if (this.keyword.trim()) {
this.highlightMatches()
}
})
} catch (error) {
if (error.name === 'AbortError') return
console.error('Word 文档预览失败:', error)
this.loading = false
this.error = error?.message || 'Word 文档加载失败,请稍后重试'
this.docRendered = false
} finally {
this.abortController = null
}
2025-11-11 16:15:06 +08:00
},
2025-11-12 15:40:30 +08:00
getElementTextContent(element) {
if (!element) return ''
return (element.textContent || '').trim()
},
2025-11-12 16:09:23 +08:00
getSearchableTextContent(element) {
if (!element) return ''
let text = ''
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
const parent = node.parentElement
if (!parent) return NodeFilter.FILTER_REJECT
const tagName = parent.tagName ? parent.tagName.toLowerCase() : ''
if (tagName === 'mark' || tagName === 'script' || tagName === 'style' || tagName === 'noscript') {
return NodeFilter.FILTER_REJECT
}
return NodeFilter.FILTER_ACCEPT
}
}
)
let node
while ((node = walker.nextNode())) {
if (node.textContent) {
text += node.textContent
}
}
return text
},
2025-11-11 16:15:06 +08:00
prepareSearchSegments(forceReset = false) {
if (!this.docRendered) {
if (forceReset) {
this.searchSegments = []
}
return
}
2025-11-11 17:50:15 +08:00
const container = this.$refs.docxContainer
if (!container) return
const selectors = [
'.docx-viewer p',
'.docx-viewer li',
'.docx-viewer td',
'.docx-viewer th',
'.docx-viewer h1',
'.docx-viewer h2',
'.docx-viewer h3',
'.docx-viewer h4',
'.docx-viewer h5',
'.docx-viewer h6',
'.docx-viewer span',
]
2025-11-12 15:40:30 +08:00
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
})
2025-11-11 16:15:06 +08:00
elements.forEach((el, idx) => {
if (typeof el.dataset.originalHtml === 'undefined') {
2025-11-12 15:40:30 +08:00
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)
2025-11-11 16:15:06 +08:00
}
el.dataset.segmentIndex = idx
})
this.searchSegments = elements
},
async handleSearch() {
const keyword = this.keyword.trim()
if (!keyword) {
this.resetSearch()
return
}
if (!this.docRendered) {
this.prepareSearchSegments()
}
this.searching = true
await this.$nextTick()
try {
this.highlightMatches()
} finally {
this.searching = false
}
},
2025-11-12 15:40:30 +08:00
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
},
2025-11-12 16:28:39 +08:00
collectTextNodes(element, textNodes) {
if (!element) return
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
const parent = node.parentElement
if (!parent) return NodeFilter.FILTER_REJECT
const tagName = parent.tagName ? parent.tagName.toLowerCase() : ''
if (tagName === 'mark' || tagName === 'script' || tagName === 'style' || tagName === 'noscript') {
return NodeFilter.FILTER_REJECT
2025-11-12 15:40:30 +08:00
}
2025-11-12 16:28:39 +08:00
return NodeFilter.FILTER_ACCEPT
2025-11-12 15:40:30 +08:00
}
}
2025-11-12 16:28:39 +08:00
)
let node
while ((node = walker.nextNode())) {
if (node.textContent) {
textNodes.push(node)
2025-11-12 15:40:30 +08:00
}
}
},
2025-11-11 16:15:06 +08:00
highlightMatches() {
const keyword = this.keyword.trim()
if (!keyword) {
this.resetSearch()
return
}
if (!this.searchSegments.length) {
this.prepareSearchSegments()
}
const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`(${escapedKeyword})`, 'gi')
this.clearHighlights()
const results = []
2025-11-12 15:40:30 +08:00
let totalTextMatches = 0
this.searchSegments.forEach((el, segmentIndex) => {
2025-11-11 16:15:06 +08:00
const originalHtml = el.dataset.originalHtml
if (typeof originalHtml === 'undefined') return
2025-11-12 15:40:30 +08:00
const cleanHtml = this.cleanHtmlFromMarks(originalHtml)
el.innerHTML = cleanHtml
el.dataset.originalHtml = cleanHtml
2025-11-12 16:09:23 +08:00
const searchableText = this.getSearchableTextContent(el)
if (!searchableText) return
pattern.lastIndex = 0
const textMatchCount = (searchableText.match(pattern) || []).length
if (textMatchCount === 0) return
totalTextMatches += textMatchCount
2025-11-12 15:40:30 +08:00
const existingMarks = el.querySelectorAll('mark')
if (existingMarks.length > 0) {
console.warn(`Segment ${segmentIndex} still has ${existingMarks.length} mark tags after cleaning`)
2025-11-11 16:15:06 +08:00
}
2025-11-12 15:40:30 +08:00
2025-11-12 16:28:39 +08:00
const textNodes = []
this.collectTextNodes(el, textNodes)
if (textNodes.length === 0) return
2025-11-12 15:40:30 +08:00
const markIndexRef = { value: 0 }
2025-11-12 16:28:39 +08:00
const nodeMap = []
let fullText = ''
let currentOffset = 0
textNodes.forEach((textNode, nodeIndex) => {
const text = textNode.textContent || ''
const startOffset = currentOffset
const endOffset = currentOffset + text.length
nodeMap.push({
textNode,
text,
startOffset,
endOffset,
nodeIndex
})
fullText += text
currentOffset = endOffset
2025-11-11 16:15:06 +08:00
})
2025-11-12 15:40:30 +08:00
2025-11-12 16:28:39 +08:00
if (!fullText) return
pattern.lastIndex = 0
const allMatches = []
let match
while ((match = pattern.exec(fullText)) !== null) {
allMatches.push({
start: match.index,
end: match.index + match[0].length,
text: match[0]
})
}
if (allMatches.length === 0) return
const nodeReplacements = new Map()
const processedMatches = new Set()
2025-11-12 16:09:23 +08:00
2025-11-12 16:28:39 +08:00
allMatches.forEach((matchInfo, matchIndex) => {
const matchStart = matchInfo.start
const matchEnd = matchInfo.end
const matchKey = `${matchStart}-${matchEnd}`
if (processedMatches.has(matchKey)) {
return
}
processedMatches.add(matchKey)
const affectedNodes = []
for (let i = 0; i < nodeMap.length; i++) {
const nodeInfo = nodeMap[i]
if (nodeInfo.endOffset > matchStart && nodeInfo.startOffset < matchEnd) {
affectedNodes.push({
...nodeInfo,
matchStartInNode: Math.max(0, matchStart - nodeInfo.startOffset),
matchEndInNode: Math.min(nodeInfo.text.length, matchEnd - nodeInfo.startOffset)
})
}
}
if (affectedNodes.length === 0) return
affectedNodes.forEach((nodeInfo, idx) => {
if (!nodeReplacements.has(nodeInfo.nodeIndex)) {
nodeReplacements.set(nodeInfo.nodeIndex, {
textNode: nodeInfo.textNode,
ranges: [],
matchIndices: new Set()
})
}
const replacement = nodeReplacements.get(nodeInfo.nodeIndex)
const isFirst = idx === 0
const isLast = idx === affectedNodes.length - 1
if (isFirst && isLast) {
const rangeKey = `${nodeInfo.matchStartInNode}-${nodeInfo.matchEndInNode}-${matchIndex}`
if (!replacement.matchIndices.has(rangeKey)) {
replacement.ranges.push({
start: nodeInfo.matchStartInNode,
end: nodeInfo.matchEndInNode,
isFullMatch: true,
matchIndex: matchIndex,
shouldAddToResults: true
})
replacement.matchIndices.add(rangeKey)
}
} else if (isFirst) {
const rangeKey = `${nodeInfo.matchStartInNode}-${nodeInfo.text.length}-${matchIndex}`
if (!replacement.matchIndices.has(rangeKey)) {
replacement.ranges.push({
start: nodeInfo.matchStartInNode,
end: nodeInfo.text.length,
isStart: true,
matchIndex: matchIndex,
shouldAddToResults: true
})
replacement.matchIndices.add(rangeKey)
}
} else if (isLast) {
const rangeKey = `0-${nodeInfo.matchEndInNode}-${matchIndex}`
if (!replacement.matchIndices.has(rangeKey)) {
replacement.ranges.push({
start: 0,
end: nodeInfo.matchEndInNode,
isEnd: true,
matchIndex: matchIndex,
shouldAddToResults: false
})
replacement.matchIndices.add(rangeKey)
}
} else {
const rangeKey = `0-${nodeInfo.text.length}-${matchIndex}`
if (!replacement.matchIndices.has(rangeKey)) {
replacement.ranges.push({
start: 0,
end: nodeInfo.text.length,
isMiddle: true,
matchIndex: matchIndex,
shouldAddToResults: false
})
replacement.matchIndices.add(rangeKey)
}
}
})
2025-11-12 16:09:23 +08:00
})
2025-11-12 16:28:39 +08:00
for (let i = textNodes.length - 1; i >= 0; i--) {
const textNode = textNodes[i]
const parent = textNode.parentNode
if (!parent || !document.body.contains(textNode)) continue
if (nodeReplacements.has(i)) {
const replacement = nodeReplacements.get(i)
const originalText = replacement.textNode.textContent
let ranges = replacement.ranges
ranges.sort((a, b) => {
if (a.start !== b.start) return a.start - b.start
return a.end - b.end
})
const mergedRanges = []
ranges.forEach(range => {
if (mergedRanges.length === 0) {
mergedRanges.push({ ...range })
} else {
const lastRange = mergedRanges[mergedRanges.length - 1]
if (range.start <= lastRange.end) {
lastRange.end = Math.max(lastRange.end, range.end)
} else {
mergedRanges.push({ ...range })
}
}
})
const fragment = document.createDocumentFragment()
let lastIndex = 0
mergedRanges.forEach(range => {
if (lastIndex < range.start) {
const beforeText = originalText.substring(lastIndex, range.start)
if (beforeText) {
fragment.appendChild(document.createTextNode(beforeText))
}
}
const mark = document.createElement('mark')
mark.className = 'search-highlight'
mark.textContent = originalText.substring(range.start, range.end)
fragment.appendChild(mark)
if (range.shouldAddToResults !== false) {
results.push({
element: mark,
segmentIndex: segmentIndex,
markIndex: markIndexRef.value,
parentElement: el
})
}
markIndexRef.value++
lastIndex = range.end
})
if (lastIndex < originalText.length) {
const afterText = originalText.substring(lastIndex)
if (afterText) {
fragment.appendChild(document.createTextNode(afterText))
}
}
if (fragment.childNodes.length > 0) {
try {
parent.replaceChild(fragment, textNode)
} catch (e) {
console.warn(`Failed to replace text node in segment ${segmentIndex}:`, e)
}
}
}
2025-11-12 15:40:30 +08:00
}
2025-11-11 16:15:06 +08:00
})
2025-11-12 15:40:30 +08:00
if (results.length !== totalTextMatches) {
2025-11-12 16:28:39 +08:00
console.warn(`Total matches mismatch: expected ${totalTextMatches}, got ${results.length}`)
2025-11-12 15:40:30 +08:00
}
2025-11-11 16:15:06 +08:00
this.searchResults = results
2025-11-12 15:40:30 +08:00
2025-11-11 16:15:06 +08:00
if (!results.length) {
this.currentResultIndex = -1
2025-11-12 15:40:30 +08:00
this.$message && this.$message.info(`未找到"${keyword}"相关内容`)
2025-11-11 16:15:06 +08:00
return
}
this.currentResultIndex = 0
this.updateActiveHighlight()
2025-11-12 15:40:30 +08:00
this.$nextTick(() => {
2025-11-12 15:49:02 +08:00
this.navigateToResult(this.currentResultIndex, false)
2025-11-12 15:40:30 +08:00
})
},
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
2025-11-11 16:15:06 +08:00
},
updateActiveHighlight() {
this.searchResults.forEach((item, idx) => {
2025-11-12 15:40:30 +08:00
const element = this.getResultElement(item)
if (!element) return
2025-11-11 16:15:06 +08:00
if (idx === this.currentResultIndex) {
2025-11-12 15:40:30 +08:00
element.classList.add('is-active', 'is-current')
2025-11-11 16:15:06 +08:00
} else {
2025-11-12 15:40:30 +08:00
element.classList.remove('is-active', 'is-current')
2025-11-11 16:15:06 +08:00
}
})
},
calculateOffsetTop(element, container) {
let offset = 0
let node = element
while (node && node !== container) {
offset += node.offsetTop || 0
node = node.offsetParent
}
return offset
},
2025-11-12 15:49:02 +08:00
cancelScrollAnimation() {
if (this.scrollAnimationFrame) {
cancelAnimationFrame(this.scrollAnimationFrame)
this.scrollAnimationFrame = null
}
},
2025-11-12 16:00:55 +08:00
smoothScrollTo(container, target, baseDuration = 350) {
2025-11-12 15:49:02 +08:00
if (!container) return
const maxScroll = container.scrollHeight - container.clientHeight
const finalTarget = Math.min(Math.max(target, 0), Math.max(maxScroll, 0))
const start = container.scrollTop
const change = finalTarget - start
if (Math.abs(change) < 1) {
container.scrollTop = finalTarget
return
}
2025-11-12 16:00:55 +08:00
const distance = Math.abs(change)
const viewportHeight = container.clientHeight
const distanceFactor = Math.min(distance / Math.max(viewportHeight, 1), 2.2)
const duration = Math.min(600, baseDuration + distanceFactor * 90)
2025-11-12 15:49:02 +08:00
const startTime = performance.now()
this.cancelScrollAnimation()
2025-11-12 16:00:55 +08:00
const easeOutExpo = (t) => {
return t === 1 ? 1 : 1 - Math.pow(2, -10 * t)
}
2025-11-12 15:49:02 +08:00
const step = (now) => {
const elapsed = now - startTime
const progress = Math.min(elapsed / duration, 1)
2025-11-12 16:00:55 +08:00
const eased = easeOutExpo(progress)
const currentPosition = start + change * eased
container.scrollTop = Math.min(Math.max(currentPosition, 0), maxScroll)
2025-11-12 15:49:02 +08:00
if (progress < 1) {
this.scrollAnimationFrame = requestAnimationFrame(step)
} else {
container.scrollTop = finalTarget
this.scrollAnimationFrame = null
}
}
this.scrollAnimationFrame = requestAnimationFrame(step)
},
navigateToResult(index, useSmoothScroll = true) {
2025-11-11 16:15:06 +08:00
if (!this.searchResults.length || index < 0 || index >= this.searchResults.length) return
this.currentResultIndex = index
this.updateActiveHighlight()
2025-11-12 15:40:30 +08:00
this.$nextTick(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const result = this.searchResults[this.currentResultIndex]
const target = this.getResultElement(result)
const wrapper = this.$refs.docWrapper
if (!target || !wrapper) return
if (!document.body.contains(target)) {
return
}
const targetRect = target.getBoundingClientRect()
if (targetRect.width === 0 && targetRect.height === 0) {
return
}
2025-11-12 15:52:11 +08:00
const wrapperRect = wrapper.getBoundingClientRect()
const margin = 24
const isInViewport = targetRect.top >= wrapperRect.top + margin &&
targetRect.bottom <= wrapperRect.bottom - margin &&
targetRect.left >= wrapperRect.left &&
targetRect.right <= wrapperRect.right
if (isInViewport) {
return
}
2025-11-12 15:49:02 +08:00
const performScroll = () => {
2025-11-12 15:40:30 +08:00
try {
2025-11-12 15:49:02 +08:00
const currentScrollTop = wrapper.scrollTop
const targetTopRelativeToWrapper = targetRect.top - wrapperRect.top + currentScrollTop
const desiredScrollTop = Math.max(targetTopRelativeToWrapper - margin, 0)
if (useSmoothScroll) {
this.smoothScrollTo(wrapper, desiredScrollTop)
} else {
this.cancelScrollAnimation()
wrapper.scrollTop = desiredScrollTop
}
} catch (error) {
try {
let absoluteTop = 0
let node = target
while (node && node !== wrapper) {
absoluteTop += node.offsetTop || 0
node = node.offsetParent
}
const desiredScrollTop = Math.max(absoluteTop - margin, 0)
if (useSmoothScroll) {
this.smoothScrollTo(wrapper, desiredScrollTop)
} else {
this.cancelScrollAnimation()
wrapper.scrollTop = desiredScrollTop
}
} catch (e) {
console.error('Scroll error:', e)
}
2025-11-12 15:40:30 +08:00
}
}
2025-11-12 15:49:02 +08:00
performScroll()
2025-11-12 15:40:30 +08:00
})
})
})
2025-11-11 16:15:06 +08:00
},
async goToPrevious() {
if (!this.searchResults.length) return
let targetIndex = this.currentResultIndex
if (targetIndex === -1) {
targetIndex = this.searchResults.length - 1
} else {
targetIndex -= 1
if (targetIndex < 0) {
targetIndex = this.searchResults.length - 1
}
}
this.navigateToResult(targetIndex)
},
async goToNext() {
if (!this.searchResults.length) return
let targetIndex = this.currentResultIndex
if (targetIndex === -1) {
targetIndex = 0
} else {
targetIndex += 1
if (targetIndex > this.searchResults.length - 1) {
targetIndex = 0
}
}
this.navigateToResult(targetIndex)
},
clearHighlights() {
if (!this.searchSegments.length) return
this.searchSegments.forEach((el) => {
if (typeof el.dataset.originalHtml !== 'undefined') {
el.innerHTML = el.dataset.originalHtml
}
})
},
resetSearch() {
this.clearHighlights()
this.searchResults = []
this.currentResultIndex = -1
},
resetViewerState() {
this.clearHighlights()
this.searchResults = []
this.currentResultIndex = -1
this.searchSegments = []
this.docRendered = false
2025-11-11 17:50:15 +08:00
this.loading = !!this.docUrl
this.error = null
const container = this.$refs.docxContainer
if (container) {
container.innerHTML = ''
}
2025-11-11 16:15:06 +08:00
},
reload() {
if (!this.docUrl) return
2025-11-11 17:50:15 +08:00
this.loadDocument()
},
2025-11-12 11:26:00 +08:00
normalizeTableStyles(container) {
if (!container) return
const tables = container.querySelectorAll('table')
tables.forEach((table) => {
table.style.width = 'auto'
table.style.tableLayout = 'auto'
table.style.textAlign = 'left'
table.style.margin = '0 auto'
2025-11-12 15:40:30 +08:00
const pTags = table.querySelectorAll('td p.docx-viewer_tableparagraph')
2025-11-12 11:26:00 +08:00
pTags.forEach((p) => {
p.style.lineHeight = '1.2'
})
})
2025-11-12 10:26:29 +08:00
}
2025-11-11 16:15:06 +08:00
},
2025-11-11 10:56:53 +08:00
}
</script>
2025-11-11 16:15:06 +08:00
<style scoped lang="scss">
.document-search-word {
display: flex;
flex-direction: column;
height: calc(100vh - 84px);
background: linear-gradient(180deg, #f4f7ff 0%, #ffffff 100%);
padding: 16px;
box-sizing: border-box;
}
2025-11-11 17:50:15 +08:00
.document-search-word article.docx-article-wrapper {
2025-11-12 10:26:29 +08:00
display: block;
margin: 0 auto 32px;
2025-11-11 17:50:15 +08:00
background: #ffffff;
border-radius: 16px;
2025-11-12 10:26:29 +08:00
box-shadow: 0 20px 48px rgba(25, 64, 158, 0.12);
2025-11-11 17:50:15 +08:00
padding: 48px 56px;
2025-11-12 10:26:29 +08:00
box-sizing: border-box;
color: #1f2a62;
line-height: 1.75;
font-size: 14px;
2025-11-11 17:50:15 +08:00
}
2025-11-11 16:15:06 +08:00
.search-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.search-input {
flex: 0 0 360px;
width: 360px;
max-width: 100%;
}
.search-status {
display: flex;
align-items: center;
gap: 8px;
color: #506dff;
}
.result-indicator {
font-weight: 600;
}
.nav-btn {
padding: 0 8px;
}
2025-11-12 11:26:00 +08:00
2025-11-11 16:15:06 +08:00
.viewer-container {
flex: 1;
background: #ffffff;
border-radius: 12px;
overflow: hidden;
position: relative;
display: flex;
2025-11-12 15:40:30 +08:00
flex-direction: column;
min-height: 0;
max-height: 100%;
height: 0;
2025-11-11 16:15:06 +08:00
}
.doc-wrapper {
flex: 1;
padding: 24px;
2025-11-11 17:50:15 +08:00
background: #eef2ff;
2025-11-11 16:15:06 +08:00
position: relative;
2025-11-12 15:40:30 +08:00
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
2025-11-12 16:00:55 +08:00
scroll-behavior: auto;
-webkit-overflow-scrolling: touch;
will-change: scroll-position;
transform: translateZ(0);
backface-visibility: hidden;
2025-11-11 16:15:06 +08:00
}
2025-11-11 17:50:15 +08:00
.doc-content {
2025-11-11 16:15:06 +08:00
max-width: 960px;
2025-11-11 17:50:15 +08:00
margin: 0 auto;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 18px 40px rgba(25, 64, 158, 0.12);
padding: 48px 56px;
2025-11-11 16:15:06 +08:00
}
2025-11-11 17:50:15 +08:00
::v-deep .docx-wrapper {
margin: 0 auto;
background: linear-gradient(180deg, #fdfdff 0%, #f6f8ff 100%);
border-radius: 16px;
box-shadow: 0 24px 48px rgba(25, 64, 158, 0.16);
overflow: hidden;
border: 1px solid rgba(68, 112, 255, 0.18);
}
2025-11-12 15:40:30 +08:00
::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;
2025-11-11 17:50:15 +08:00
}
2025-11-11 16:15:06 +08:00
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(245, 247, 255, 0.92);
z-index: 10;
text-align: center;
}
.state-panel {
flex-direction: column;
gap: 12px;
color: #6072a1;
}
.state-icon {
font-size: 28px;
color: #1f72ea;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
2025-11-12 15:40:30 +08:00
::v-deep .search-highlight {
2025-11-11 16:15:06 +08:00
background: rgba(255, 241, 168, 0.9);
padding: 0 2px;
border-radius: 2px;
}
2025-11-12 15:40:30 +08:00
::v-deep .search-highlight.is-active,
::v-deep .search-highlight.is-current {
2025-11-11 16:15:06 +08:00
background: #206dff !important;
color: #ffffff;
2025-11-12 15:40:30 +08:00
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;
2025-11-11 16:15:06 +08:00
}
</style>