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

803 lines
23 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-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-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,
ignoreFonts: false,
breakPages: false,
})
this.wrapContentWithArticle(container)
this.injectFontResolver(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
}
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',
]
const elements = Array.from(container.querySelectorAll(selectors.join(',')))
2025-11-11 16:15:06 +08:00
.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.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
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()
},
injectFontResolver(container) {
if (!container || container.querySelector('.docx-fonts-resolver')) return
const resolver = document.createElement('div')
resolver.className = 'docx-fonts-resolver'
resolver.innerHTML = [
'微软雅黑', '宋体', '黑体', '仿宋', '楷体', '等线', 'Times New Roman', 'Arial'
].map(font => `<span style="font-family:${font};">${font}</span>`).join('')
container.appendChild(resolver)
},
wrapContentWithArticle(container) {
if (!container) return
2025-11-12 10:26:29 +08:00
const sections = Array.from(container.querySelectorAll('.docx-wrapper section'))
if (sections.length === 0) {
this.wrapElementsInSections(container)
2025-11-11 17:50:15 +08:00
return
}
2025-11-12 10:26:29 +08:00
sections.forEach((section) => {
this.wrapElementsInSections(section)
})
},
wrapElementsInSections(node) {
if (!node) return
// 获取所有需要包装的元素类型,但排除 header 和 footer 内的
const selectors = [
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'blockquote', 'table'
]
console.log(node);
// 获取所有匹配的元素
const allElements = Array.from(node.querySelectorAll(selectors.join(',')))
// 过滤:只保留最外层的元素(没有父元素也是目标选择器)
const elements = allElements.filter(el => {
// 检查元素是否在 header 或 footer 内
let parent = el.parentElement
while (parent && parent !== node) {
const tag = parent.tagName ? parent.tagName.toLowerCase() : ''
if (tag === 'header' || tag === 'footer') {
return false // 在 header/footer 内,跳过
}
// 如果父元素也是目标选择器之一,说明当前元素是嵌套的,应该跳过
if (selectors.includes(tag)) {
return false
}
parent = parent.parentElement
}
return true
})
if (elements.length === 0) return
// 按连续元素分组
const elementGroups = this.groupConsecutiveElements(elements)
// 为每个元素组创建 article 包装
elementGroups.forEach(group => {
if (group.length > 0) {
this.wrapElementGroupWithArticle(group)
2025-11-11 17:50:15 +08:00
}
})
2025-11-11 16:15:06 +08:00
},
2025-11-12 10:26:29 +08:00
groupConsecutiveElements(elements) {
const groups = []
let currentGroup = []
elements.forEach((el, index) => {
// 如果当前组为空,直接添加
if (currentGroup.length === 0) {
currentGroup.push(el)
return
}
// 检查是否连续(在 DOM 中相邻)
const lastEl = currentGroup[currentGroup.length - 1]
let nextElement = lastEl.nextElementSibling
// 跳过空白文本节点等,找到下一个元素
while (nextElement && nextElement.nodeType !== 1) {
nextElement = nextElement.nextElementSibling
}
if (nextElement === el) {
// 连续的元素
currentGroup.push(el)
} else {
// 不连续,开始新组
groups.push([...currentGroup])
currentGroup = [el]
}
// 如果是最后一个元素,结束当前组
if (index === elements.length - 1) {
groups.push([...currentGroup])
}
})
return groups
},
wrapElementGroupWithArticle(elements) {
if (!elements || elements.length === 0) return
const firstElement = elements[0]
const parent = firstElement.parentElement
// 创建 article 元素
const article = document.createElement('article')
article.className = 'docx-article-wrapper'
// 在第一个元素前插入 article
parent.insertBefore(article, firstElement)
// 将所有元素移动到 article 中
elements.forEach(el => {
article.appendChild(el)
})
}
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;
}
.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;
2025-11-11 17:50:15 +08:00
background: #eef2ff;
2025-11-11 16:15:06 +08:00
position: relative;
}
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);
}
::v-deep .docx-wrapper section {
background: transparent !important;
padding: 48px 56px !important;
line-height: 1.75;
font-size: 14px;
color: #1f2a62;
}
::v-deep .docx-wrapper table {
width: 100% !important;
border-collapse: separate !important;
border-spacing: 0 !important;
margin: 24px 0 !important;
background: rgba(255, 255, 255, 0.96) !important;
border-radius: 14px;
2025-11-11 16:15:06 +08:00
overflow: hidden;
2025-11-11 17:50:15 +08:00
box-shadow: inset 0 0 0 1px rgba(68, 112, 255, 0.2);
}
::v-deep .docx-wrapper table thead th {
background: linear-gradient(135deg, rgba(68, 112, 255, 0.45), rgba(68, 112, 255, 0.22)) !important;
color: #f5f7ff !important;
font-weight: 600 !important;
text-transform: uppercase;
letter-spacing: 0.6px;
padding: 14px 18px !important;
border-bottom: 1px solid rgba(36, 71, 182, 0.3) !important;
}
::v-deep .docx-wrapper table td,
::v-deep .docx-wrapper table th {
border-left: 1px solid rgba(68, 112, 255, 0.22) !important;
border-right: 1px solid rgba(68, 112, 255, 0.22) !important;
padding: 14px 18px !important;
vertical-align: top !important;
background: transparent !important;
color: #27325d !important;
font-size: 13px !important;
}
::v-deep .docx-wrapper table tr:nth-child(even) td {
background: rgba(242, 245, 255, 0.9) !important;
}
::v-deep .docx-wrapper table tr:nth-child(odd) td {
background: rgba(255, 255, 255, 0.98) !important;
}
::v-deep .docx-wrapper table tr:hover td {
background: rgba(68, 112, 255, 0.16) !important;
color: #1e2a62 !important;
}
::v-deep .docx-wrapper table td:first-child,
::v-deep .docx-wrapper table th:first-child {
border-left: none !important;
}
::v-deep .docx-wrapper table td:last-child,
::v-deep .docx-wrapper table th:last-child {
border-right: none !important;
}
::v-deep .docx-wrapper table tr:last-child td {
border-bottom: none !important;
}
::v-deep .docx-wrapper table caption {
caption-side: top !important;
padding: 12px 0 !important;
font-weight: 600 !important;
color: #3f57c0 !important;
font-size: 15px !important;
}
::v-deep .docx-wrapper table strong,
::v-deep .docx-wrapper table b {
color: #2a3aa8 !important;
}
::v-deep .docx-wrapper h1,
::v-deep .docx-wrapper h2,
::v-deep .docx-wrapper h3,
::v-deep .docx-wrapper h4,
::v-deep .docx-wrapper h5,
::v-deep .docx-wrapper h6 {
color: #1b2f72;
}
::v-deep .docx-wrapper .docx span {
color: inherit;
}
.doc-content h1,
.doc-content h2,
.doc-content h3,
.doc-content h4,
.doc-content h5,
.doc-content h6 {
font-weight: 700;
margin: 24px 0 12px;
color: #1b2f72;
}
.doc-content h1 {
font-size: 28px;
}
.doc-content h2 {
font-size: 24px;
}
.doc-content h3 {
font-size: 20px;
}
.doc-content h4 {
font-size: 18px;
}
.doc-content h5 {
font-size: 16px;
}
.doc-content h6 {
font-size: 15px;
}
.doc-content p {
margin: 8px 0;
}
.doc-content ul,
.doc-content ol {
padding-left: 28px;
margin: 8px 0 12px;
}
.doc-content ul {
list-style: disc;
}
.doc-content ol {
list-style: decimal;
}
.doc-content blockquote {
margin: 12px 0;
padding: 12px 18px;
background: rgba(68, 112, 255, 0.08);
border-left: 4px solid rgba(68, 112, 255, 0.45);
color: #3b4d86;
}
.doc-content table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
background: #ffffff;
font-size: 13px;
}
.doc-content table td,
.doc-content table th {
border: 1px solid rgba(0, 0, 0, 0.18);
padding: 10px 12px;
vertical-align: top;
}
.doc-content table th {
background: rgba(68, 112, 255, 0.1);
font-weight: 600;
}
.doc-content img {
max-width: 100%;
height: auto;
display: block;
margin: 12px auto;
}
.doc-content hr {
border: none;
border-top: 1px solid rgba(0, 0, 0, 0.1);
margin: 24px 0;
}
.doc-content sup {
font-size: 12px;
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;
}
.search-highlight {
background: rgba(255, 241, 168, 0.9);
padding: 0 2px;
border-radius: 2px;
}
.search-highlight.is-active {
background: #206dff !important;
color: #ffffff;
}
</style>