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

1476 lines
49 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="document-search-word">
<div class="viewer-container">
<transition name="slide-fade">
<div v-if="showSearchBar" class="floating-search">
<div class="search-toolbar" :class="{ 'is-searching': searching }">
<div class="search-box">
<el-input v-model="keyword" class="search-input" placeholder="输入关键字搜索" clearable
ref="keywordInput" @keyup.enter.native="handleSearch" @clear="resetSearch">
</el-input>
<button class="icon-btn search-icon" :disabled="searching" @click="handleSearch">
<i class="el-icon-search" v-if="!searching"></i>
<i class="el-icon-loading" v-else></i>
</button>
</div>
<div class="search-status">
<span class="result-indicator">{{ resultDisplay }}</span>
<button class="icon-btn" :disabled="!searchResults.length" @click="goToPrevious">
<i class="el-icon-arrow-up"></i>
</button>
<button class="icon-btn" :disabled="!searchResults.length" @click="goToNext">
<i class="el-icon-arrow-down"></i>
</button>
<button class="icon-btn" @click="handleCloseSearch">
<i class="el-icon-close"></i>
</button>
</div>
</div>
</div>
</transition>
<button v-if="!showSearchBar" class="floating-search-btn" @click="toggleSearchBar">
<i class="el-icon-search"></i>
</button>
<div ref="docWrapper" class="doc-wrapper">
<div ref="docxContainer" class="doc-content"></div>
</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>
<div v-else-if="!docUrl" key="no-url" class="overlay state-panel">
<i class="el-icon-document state-icon"></i>
<p>暂未指定 Word 文件,请通过路由参数 url 传入文件地址。</p>
</div>
</transition>
</div>
</div>
</template>
<script>
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'
export default {
name: 'DocumentSearchWord',
props: {
fileUrl: {
type: String,
default: ''
}
},
computed: {
resultDisplay() {
const total = this.searchResults.length
if (!total) return '0/0'
const current = this.currentResultIndex >= 0 ? this.currentResultIndex + 1 : 0
return `${current}/${total}`
}
},
data() {
return {
docUrl: '',
keyword: '',
loading: false,
error: null,
searchResults: [],
currentResultIndex: -1,
searching: false,
searchSegments: [],
docRendered: false,
abortController: null,
scrollAnimationFrame: null,
showSearchBar: false,
}
},
mounted() {
// 优先使用 fileUrl prop然后是路由参数最后是默认 URL
const initialUrl = this.fileUrl || this.$route?.query?.url || DEFAULT_DOC_URL
if (initialUrl && initialUrl !== this.docUrl) {
this.applyDocUrl(initialUrl)
} else if (this.docUrl && !this.docRendered) {
// 如果 docUrl 已设置但文档未渲染,重新加载
this.loadDocument()
}
},
beforeDestroy() {
this.cancelFetch()
this.cancelScrollAnimation()
},
watch: {
fileUrl: {
immediate: true,
handler(newVal, oldVal) {
// 当 fileUrl 变化时,总是重新加载文档
if (newVal && newVal !== oldVal) {
this.applyDocUrl(newVal)
} else if (newVal && !this.docUrl) {
// 如果新值存在但 docUrl 为空,也加载
this.applyDocUrl(newVal)
} else if (!newVal && this.docUrl) {
// 如果新值为空但 docUrl 有值,重置状态
this.resetViewerState()
}
}
},
'$route.query.url'(newUrl, oldUrl) {
if (this.fileUrl) return
if (newUrl !== oldUrl) {
const target = newUrl || DEFAULT_DOC_URL
this.applyDocUrl(target)
}
},
},
methods: {
toggleSearchBar() {
this.showSearchBar = true
this.$nextTick(() => {
if (this.$refs.keywordInput) {
this.$refs.keywordInput.focus()
}
})
},
handleCloseSearch() {
this.keyword = ''
this.resetSearch()
this.showSearchBar = false
},
applyDocUrl(url) {
const resolved = url || ''
// 如果 URL 相同且文档已渲染,不重复加载
if (resolved === this.docUrl && this.docRendered) {
return
}
// 如果 URL 变化,先取消之前的请求和重置状态
if (resolved !== this.docUrl) {
this.cancelFetch()
this.cancelScrollAnimation()
this.resetViewerState()
}
this.docUrl = resolved
if (this.docUrl) {
this.loadDocument()
} else {
this.resetViewerState()
}
},
cancelFetch() {
if (this.abortController) {
this.abortController.abort()
this.abortController = null
}
},
async loadDocument() {
if (!this.docUrl) return
this.loading = true
this.error = null
this.docRendered = false
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,
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.normalizeTableStyles(container)
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
}
},
getElementTextContent(element) {
if (!element) return ''
return (element.textContent || '').trim()
},
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
},
prepareSearchSegments(forceReset = false) {
if (!this.docRendered) {
if (forceReset) {
this.searchSegments = []
}
return
}
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',
]
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') {
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
})
this.searchSegments = elements
},
async handleSearch() {
const keyword = this.keyword.trim()
if (!keyword) {
this.resetSearch()
return
}
if (!this.docRendered) {
this.prepareSearchSegments()
}
// 立即设置 searching 状态,确保动画能够显示
this.searching = true
// 使用 requestAnimationFrame 确保 DOM 更新后再执行搜索
await this.$nextTick()
await new Promise(resolve => requestAnimationFrame(() => {
requestAnimationFrame(resolve)
}))
try {
// 执行搜索,确保有足够时间显示动画
const searchPromise = Promise.resolve(this.highlightMatches())
const minDelayPromise = new Promise(resolve => setTimeout(resolve, 300))
await Promise.all([searchPromise, minDelayPromise])
} finally {
this.searching = false
}
},
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
},
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
}
return NodeFilter.FILTER_ACCEPT
}
}
)
let node
while ((node = walker.nextNode())) {
if (node.textContent) {
textNodes.push(node)
}
}
},
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 = []
let totalTextMatches = 0
this.searchSegments.forEach((el, segmentIndex) => {
const originalHtml = el.dataset.originalHtml
if (typeof originalHtml === 'undefined') return
const cleanHtml = this.cleanHtmlFromMarks(originalHtml)
el.innerHTML = cleanHtml
el.dataset.originalHtml = cleanHtml
const searchableText = this.getSearchableTextContent(el)
if (!searchableText) return
pattern.lastIndex = 0
const textMatchCount = (searchableText.match(pattern) || []).length
if (textMatchCount === 0) return
totalTextMatches += textMatchCount
const existingMarks = el.querySelectorAll('mark')
if (existingMarks.length > 0) {
console.warn(`Segment ${segmentIndex} still has ${existingMarks.length} mark tags after cleaning`)
}
const textNodes = []
this.collectTextNodes(el, textNodes)
if (textNodes.length === 0) return
const markIndexRef = { value: 0 }
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
})
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()
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)
}
}
})
})
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)
}
}
}
}
})
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}"相关内容`)
return
}
this.currentResultIndex = 0
this.updateActiveHighlight()
this.$nextTick(() => {
this.navigateToResult(this.currentResultIndex, false)
})
},
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) => {
const element = this.getResultElement(item)
if (!element) return
if (idx === this.currentResultIndex) {
element.classList.add('is-active', 'is-current')
} else {
element.classList.remove('is-active', 'is-current')
}
})
},
calculateOffsetTop(element, container) {
let offset = 0
let node = element
while (node && node !== container) {
offset += node.offsetTop || 0
node = node.offsetParent
}
return offset
},
cancelScrollAnimation() {
if (this.scrollAnimationFrame) {
cancelAnimationFrame(this.scrollAnimationFrame)
this.scrollAnimationFrame = null
}
},
smoothScrollTo(container, target, baseDuration = 350) {
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 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)
const startTime = performance.now()
this.cancelScrollAnimation()
const easeOutExpo = (t) => {
return t === 1 ? 1 : 1 - Math.pow(2, -10 * t)
}
const step = (now) => {
const elapsed = now - startTime
const progress = Math.min(elapsed / duration, 1)
const eased = easeOutExpo(progress)
const currentPosition = start + change * eased
container.scrollTop = Math.min(Math.max(currentPosition, 0), maxScroll)
if (progress < 1) {
this.scrollAnimationFrame = requestAnimationFrame(step)
} else {
container.scrollTop = finalTarget
this.scrollAnimationFrame = null
}
}
this.scrollAnimationFrame = requestAnimationFrame(step)
},
navigateToResult(index, useSmoothScroll = true) {
if (!this.searchResults.length || index < 0 || index >= this.searchResults.length) return
this.currentResultIndex = index
this.updateActiveHighlight()
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
}
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
}
const performScroll = () => {
try {
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)
}
}
}
performScroll()
})
})
})
},
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
this.loading = !!this.docUrl
this.error = null
const container = this.$refs.docxContainer
if (container) {
container.innerHTML = ''
}
},
reload() {
if (!this.docUrl) return
this.loadDocument()
},
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'
const pTags = table.querySelectorAll('td p.docx-viewer_tableparagraph')
pTags.forEach((p) => {
p.style.lineHeight = '1.2'
})
})
}
},
}
</script>
<style scoped lang="scss">
.document-search-word {
display: flex;
flex-direction: column;
height: calc(100vh - 84px);
background: #f8f9fa;
padding: 0;
box-sizing: border-box;
overflow: hidden;
}
.document-search-word article.docx-article-wrapper {
display: block;
margin: 0;
background: transparent;
border-radius: 0;
box-shadow: none;
padding: 0;
box-sizing: border-box;
color: #303133;
line-height: 1.8;
font-size: 15px;
// 启用文本选择和复制
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
cursor: text;
}
.floating-search {
position: absolute;
top: 20px;
right: 20px;
width: auto;
max-width: calc(100% - 40px);
z-index: 6;
pointer-events: none;
@media (max-width: 768px) {
top: 12px;
right: 12px;
max-width: calc(100% - 24px);
}
}
.floating-search-btn {
position: absolute;
top: 20px;
right: 20px;
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: #2b68ff;
color: #fff;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(43, 104, 255, 0.3);
cursor: pointer;
transition: all 0.2s ease;
z-index: 5;
@media (max-width: 768px) {
top: 12px;
right: 12px;
width: 40px;
height: 40px;
font-size: 16px;
}
}
.floating-search-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(43, 104, 255, 0.4);
background: #1d4fd8;
}
.floating-search-btn:active {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(43, 104, 255, 0.3);
}
.search-toolbar {
display: flex;
align-items: center;
gap: 14px;
background: rgba(255, 255, 255, 0.98);
border-radius: 999px;
padding: 8px 12px;
box-shadow: 0 8px 26px rgba(31, 114, 234, 0.18);
border: 1px solid rgba(226, 231, 239, 0.8);
pointer-events: auto;
position: relative;
overflow: hidden;
}
.search-toolbar::after {
content: '';
position: absolute;
inset: 4px;
border-radius: 999px;
border: 1px solid transparent;
pointer-events: none;
}
.search-toolbar.is-searching {
&::after {
border-color: rgba(43, 104, 255, 0.4);
animation: search-pulse 1.2s ease-in-out infinite;
}
// 确保搜索图标在搜索时显示加载动画
.search-icon {
i.el-icon-loading {
display: inline-block !important;
animation: rotating 1s linear infinite !important;
}
}
}
@keyframes search-pulse {
0% {
opacity: 0.25;
transform: scale(0.98);
}
50% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0.25;
transform: scale(0.98);
}
}
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.slide-fade-enter,
.slide-fade-leave-to {
opacity: 0;
transform: translateY(-6px);
}
.search-box {
display: flex;
align-items: center;
gap: 4px;
background: #fff;
border: 1px solid #e4e7f0;
border-radius: 999px;
padding: 0 6px 0 10px;
}
.search-input {
width: 190px;
}
.search-input ::v-deep .el-input__inner {
border: none;
box-shadow: none;
background: transparent;
padding: 0;
height: 30px;
font-size: 13px;
}
.search-input ::v-deep .el-input__suffix,
.search-input ::v-deep .el-input__prefix {
display: none;
}
.icon-btn {
width: 28px;
height: 28px;
border: none;
background: transparent;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #6c7388;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
}
.icon-btn:hover {
background: rgba(64, 158, 255, 0.12);
color: #2b68ff;
}
.icon-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.search-icon {
background: #2b68ff;
color: #fff;
// 确保加载图标有旋转动画
i.el-icon-loading {
display: inline-block;
animation: rotating 1s linear infinite;
font-size: 16px;
}
i.el-icon-search {
font-size: 16px;
}
}
.search-icon:hover {
background: #1d4fd8;
color: #fff;
}
.search-icon:disabled {
opacity: 0.8;
cursor: wait;
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.search-status {
display: flex;
align-items: center;
gap: 8px;
}
.result-indicator {
font-size: 12px;
color: #4f5875;
padding-left: 10px;
font-weight: 500;
}
.viewer-container {
flex: 1;
background: #ffffff;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
max-height: 100%;
height: 0;
}
.doc-wrapper {
flex: 1;
padding: 40px 20px;
background: #f5f7fa;
position: relative;
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
will-change: scroll-position;
// 启用文本选择
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
// 自定义滚动条
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
transition: background 0.3s;
&:hover {
background: rgba(0, 0, 0, 0.3);
}
}
}
.doc-content {
max-width: 900px;
margin: 0 auto;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
padding: 60px 80px;
min-height: calc(100vh - 200px);
// 启用文本选择和复制
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
// 优化选中文本的样式
::selection {
background: rgba(64, 158, 255, 0.2);
color: #303133;
}
::-moz-selection {
background: rgba(64, 158, 255, 0.2);
color: #303133;
}
@media (max-width: 1200px) {
padding: 50px 60px;
max-width: 95%;
}
@media (max-width: 768px) {
padding: 40px 30px;
max-width: 100%;
border-radius: 0;
}
}
::v-deep .docx-wrapper {
margin: 0;
background: transparent;
border-radius: 0;
box-shadow: none;
overflow: visible;
border: none;
// 启用文本选择
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
}
::v-deep .docx-viewer-wrapper {
background: transparent;
// 启用文本选择
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
}
::v-deep .docx-viewer-wrapper>section.docx-viewer {
background: transparent;
box-shadow: none;
margin-bottom: 0 !important;
padding: 0;
// 启用文本选择
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
}
::v-deep .docx-viewer {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
color: #303133;
line-height: 1.8;
// 启用文本选择和复制
user-select: text !important;
-webkit-user-select: text !important;
-moz-user-select: text !important;
-ms-user-select: text !important;
// 优化选中文本的样式
::selection {
background: rgba(64, 158, 255, 0.25) !important;
color: #303133 !important;
}
::-moz-selection {
background: rgba(64, 158, 255, 0.25) !important;
color: #303133 !important;
}
// 确保所有子元素都可以选择文本
* {
user-select: text !important;
-webkit-user-select: text !important;
-moz-user-select: text !important;
-ms-user-select: text !important;
}
p {
margin: 0.8em 0;
word-wrap: break-word;
word-break: break-word;
cursor: text;
}
h1, h2, h3, h4, h5, h6 {
margin: 1.2em 0 0.8em 0;
font-weight: 600;
line-height: 1.4;
cursor: text;
}
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
cursor: text;
td, th {
padding: 8px 12px;
border: 1px solid #e4e7ed;
cursor: text;
}
th {
background: #f5f7fa;
font-weight: 600;
}
}
ul, ol {
margin: 0.8em 0;
padding-left: 2em;
cursor: text;
}
li {
margin: 0.4em 0;
cursor: text;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 1em auto;
user-select: none;
-webkit-user-select: none;
cursor: default;
}
// 确保链接可以选择文本
a {
cursor: pointer;
user-select: text !important;
}
// 确保代码块可以选择文本
code, pre {
user-select: text !important;
cursor: text;
}
}
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(4px);
z-index: 10;
text-align: center;
}
.state-panel {
flex-direction: column;
gap: 16px;
color: #606266;
p {
margin: 0;
font-size: 14px;
color: #909399;
}
}
.state-icon {
font-size: 24px;
color: #409EFF;
animation: rotate 1s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
::v-deep .search-highlight {
background: rgba(255, 241, 168, 0.85);
padding: 2px 4px;
border-radius: 3px;
transition: all 0.2s ease;
// 确保高亮标记内的文本可以选择
user-select: text !important;
-webkit-user-select: text !important;
-moz-user-select: text !important;
-ms-user-select: text !important;
cursor: text;
}
::v-deep .search-highlight.is-active,
::v-deep .search-highlight.is-current {
background: linear-gradient(135deg, #409EFF 0%, #1d4fd8 100%) !important;
color: #ffffff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.4);
border-radius: 4px;
padding: 2px 4px;
font-weight: 500;
transition: all 0.2s ease;
// 确保激活的高亮标记内的文本可以选择
user-select: text !important;
-webkit-user-select: text !important;
-moz-user-select: text !important;
-ms-user-select: text !important;
cursor: text;
}
</style>