word 预览
This commit is contained in:
parent
2f16e92da8
commit
1f42b538aa
|
|
@ -25,10 +25,13 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@riophae/vue-treeselect": "0.4.0",
|
||||
"@vue-office/docx": "^1.6.3",
|
||||
"@vue/composition-api": "^1.7.2",
|
||||
"axios": "0.28.1",
|
||||
"clipboard": "2.0.8",
|
||||
"core-js": "3.37.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"docx-preview": "^0.3.7",
|
||||
"echarts": "5.4.0",
|
||||
"element-ui": "2.15.14",
|
||||
"file-saver": "2.0.5",
|
||||
|
|
@ -38,6 +41,7 @@
|
|||
"js-cookie": "3.0.1",
|
||||
"jsencrypt": "3.0.0-rc.1",
|
||||
"lodash": "^4.17.21",
|
||||
"mammoth": "^1.11.0",
|
||||
"nprogress": "0.2.0",
|
||||
"pdfjs-dist": "^2.16.105",
|
||||
"quill": "2.0.2",
|
||||
|
|
@ -49,6 +53,7 @@
|
|||
"vue": "2.6.12",
|
||||
"vue-count-to": "1.0.13",
|
||||
"vue-cropper": "0.5.5",
|
||||
"vue-demi": "^0.14.10",
|
||||
"vue-pdf": "^4.3.0",
|
||||
"vue-router": "3.4.9",
|
||||
"vuedraggable": "2.24.3",
|
||||
|
|
|
|||
|
|
@ -1,15 +1,458 @@
|
|||
<template>
|
||||
<div class="document-search-word">
|
||||
<div class="document-search-word-header">
|
||||
<div class="document-search-word-header-title">
|
||||
<span>word文档搜索</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
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'
|
||||
|
||||
export default {
|
||||
name: 'DocumentSearchWord',
|
||||
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()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style scoped></style>
|
||||
|
||||
<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>
|
||||
Loading…
Reference in New Issue