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-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
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
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) => {
|
|
|
|
|
|
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
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
const markIndexRef = { value: 0 }
|
|
|
|
|
|
const children = Array.from(el.childNodes)
|
|
|
|
|
|
children.forEach(child => {
|
|
|
|
|
|
this.highlightTextInNode(child, pattern, results, segmentIndex, el, markIndexRef)
|
2025-11-11 16:15:06 +08:00
|
|
|
|
})
|
2025-11-12 15:40:30 +08:00
|
|
|
|
|
|
|
|
|
|
const createdMarks = el.querySelectorAll('mark.search-highlight')
|
|
|
|
|
|
if (createdMarks.length !== textMatchCount) {
|
|
|
|
|
|
console.warn(`Segment ${segmentIndex}: expected ${textMatchCount} marks, created ${createdMarks.length}`)
|
|
|
|
|
|
}
|
2025-11-11 16:15:06 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2025-11-12 15:40:30 +08:00
|
|
|
|
if (results.length !== totalTextMatches) {
|
|
|
|
|
|
console.warn(`Total matches mismatch: expected ${totalTextMatches}, got ${results.length}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
smoothScrollTo(container, target, baseDuration = 300) {
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const distanceFactor = Math.min(Math.abs(change) / Math.max(container.clientHeight, 1), 2.4)
|
|
|
|
|
|
const duration = Math.min(600, baseDuration + distanceFactor * 120)
|
|
|
|
|
|
|
|
|
|
|
|
const startTime = performance.now()
|
|
|
|
|
|
const ease = (t) => 1 - Math.pow(1 - t, 3)
|
|
|
|
|
|
const velocityBoost = Math.min(Math.max(Math.abs(change) / 2400, 0), 0.25)
|
|
|
|
|
|
|
|
|
|
|
|
this.cancelScrollAnimation()
|
|
|
|
|
|
|
|
|
|
|
|
const step = (now) => {
|
|
|
|
|
|
const elapsed = now - startTime
|
|
|
|
|
|
const progress = Math.min(elapsed / duration, 1)
|
|
|
|
|
|
const eased = ease(progress)
|
|
|
|
|
|
const basePosition = start + change * eased
|
|
|
|
|
|
const overshoot = velocityBoost * Math.sin(eased * Math.PI)
|
|
|
|
|
|
container.scrollTop = Math.min(Math.max(basePosition + overshoot * change, 0), maxScroll)
|
|
|
|
|
|
|
|
|
|
|
|
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:49:02 +08:00
|
|
|
|
const performScroll = () => {
|
2025-11-12 15:40:30 +08:00
|
|
|
|
try {
|
2025-11-12 15:49:02 +08:00
|
|
|
|
const wrapperRect = wrapper.getBoundingClientRect()
|
|
|
|
|
|
const currentScrollTop = wrapper.scrollTop
|
|
|
|
|
|
const targetTopRelativeToWrapper = targetRect.top - wrapperRect.top + currentScrollTop
|
2025-11-12 15:40:30 +08:00
|
|
|
|
const margin = 24
|
2025-11-12 15:49:02 +08:00
|
|
|
|
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 margin = 24
|
|
|
|
|
|
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-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>
|