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

458 lines
13 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">
<vue-office-docx ref="docxViewer" v-if="docSrc" :src="docSrc" :request-options="docRequestOptions"
:options="docOptions" class="docx-viewer" @rendered="handleDocRendered" @error="handleDocError" />
</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="!docSrc" key="no-url" class="overlay state-panel">
<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 16:15:06 +08:00
import VueOfficeDocx from '@vue-office/docx'
import '@vue-office/docx/lib/index.css'
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
components: {
VueOfficeDocx,
},
data() {
return {
docUrl: '',
docSrc: '',
docOptions: {
inWrapper: true,
},
docRequestOptions: {
method: 'GET',
headers: {},
credentials: 'include',
},
keyword: '',
loading: false,
error: null,
searchResults: [],
currentResultIndex: -1,
searching: false,
searchSegments: [],
docRendered: false,
}
},
mounted() {
this.docUrl = this.$route.query.url || DEFAULT_DOC_URL
if (this.docUrl) {
this.prepareDocumentSource()
}
},
watch: {
'$route.query.url'(newUrl, oldUrl) {
if (newUrl !== oldUrl) {
this.docUrl = newUrl || DEFAULT_DOC_URL
this.resetViewerState()
if (this.docUrl) {
this.prepareDocumentSource()
}
}
},
},
methods: {
prepareDocumentSource() {
if (!this.docUrl) {
this.docSrc = ''
return
}
const headers = {}
const token = this.$store?.getters?.token || window?.sessionStorage?.getItem('token')
if (token) {
headers.Authorization = token.startsWith('Bearer ') ? token : `Bearer ${token}`
}
this.docRequestOptions = {
method: 'GET',
headers,
credentials: 'include',
}
this.loading = true
this.error = null
this.docRendered = false
this.docSrc = this.docUrl
this.prepareSearchSegments(true)
},
handleDocRendered() {
this.loading = false
this.error = null
this.docRendered = true
this.$nextTick(() => {
this.prepareSearchSegments()
if (this.keyword.trim()) {
this.highlightMatches()
}
})
},
handleDocError(err) {
console.error('Word 文档预览失败:', err)
this.loading = false
this.error = err?.message || 'Word 文档加载失败,请稍后重试'
this.docRendered = false
},
prepareSearchSegments(forceReset = false) {
if (!this.docRendered) {
if (forceReset) {
this.searchSegments = []
}
return
}
const wrapper = this.$refs.docWrapper
if (!wrapper) return
const root = wrapper.querySelector('.vue-office-docx-main') || wrapper
if (!root) return
const elements = Array.from(root.querySelectorAll('p, li, td, th, h1, h2, h3, h4, h5, h6, span'))
.filter((el) => (el.textContent || '').trim())
elements.forEach((el, idx) => {
if (typeof el.dataset.originalHtml === 'undefined') {
el.dataset.originalHtml = el.innerHTML
}
el.dataset.segmentIndex = idx
})
this.searchSegments = elements
},
async handleSearch() {
const keyword = this.keyword.trim()
if (!keyword) {
this.resetSearch()
return
}
if (!this.docSrc) return
if (!this.docRendered) {
this.prepareSearchSegments()
}
this.searching = true
await this.$nextTick()
try {
this.highlightMatches()
} finally {
this.searching = false
}
},
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 = []
this.searchSegments.forEach((el) => {
const originalHtml = el.dataset.originalHtml
if (typeof originalHtml === 'undefined') return
const text = el.textContent || ''
pattern.lastIndex = 0
if (!pattern.test(text)) {
return
}
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 })
})
})
this.searchResults = results
if (!results.length) {
this.currentResultIndex = -1
this.$message && this.$message.info(`未找到“${keyword}”相关内容`)
return
}
this.currentResultIndex = 0
this.updateActiveHighlight()
this.navigateToResult(this.currentResultIndex)
},
updateActiveHighlight() {
this.searchResults.forEach((item, idx) => {
if (!item.element) return
if (idx === this.currentResultIndex) {
item.element.classList.add('is-active')
} else {
item.element.classList.remove('is-active')
}
})
},
calculateOffsetTop(element, container) {
let offset = 0
let node = element
while (node && node !== container) {
offset += node.offsetTop || 0
node = node.offsetParent
}
return offset
},
navigateToResult(index) {
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
const absoluteTop = this.calculateOffsetTop(target, wrapper)
const margin = 24
wrapper.scrollTop = Math.max(absoluteTop - margin, 0)
},
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.docSrc = ''
},
reload() {
if (!this.docUrl) return
this.prepareDocumentSource()
},
},
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;
}
.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;
}
.viewer-container {
flex: 1;
background: #ffffff;
border-radius: 12px;
overflow: hidden;
position: relative;
display: flex;
min-height: 480px;
}
.doc-wrapper {
flex: 1;
overflow: auto;
padding: 24px;
background: #eaeaea;
position: relative;
}
::v-deep .vue-office-docx {
background: transparent !important;
box-shadow: 0 10px 30px rgba(25, 64, 158, 0.12);
border-radius: 8px;
margin: 0 auto;
max-width: 960px;
}
::v-deep .vue-office-docx-main {
background: #ffffff;
border-radius: 8px;
overflow: hidden;
}
.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;
}
.search-highlight {
background: rgba(255, 241, 168, 0.9);
padding: 0 2px;
border-radius: 2px;
}
.search-highlight.is-active {
background: #206dff !important;
color: #ffffff;
}
::v-deep .vue-office-docx table {
border-collapse: collapse;
}
::v-deep .vue-office-docx table td,
::v-deep .vue-office-docx table th {
border: 1px solid rgba(0, 0, 0, 0.12);
}
::v-deep .vue-office-docx .page {
margin: 0 auto;
box-shadow: none;
}
::v-deep .vue-office-docx .page-body {
padding: 48px;
}
</style>