pdf 文档搜索
This commit is contained in:
parent
2f0fa7f407
commit
c88199a40c
|
|
@ -39,7 +39,7 @@
|
||||||
"jsencrypt": "3.0.0-rc.1",
|
"jsencrypt": "3.0.0-rc.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"nprogress": "0.2.0",
|
"nprogress": "0.2.0",
|
||||||
"pdfjs-dist": "^5.4.149",
|
"pdfjs-dist": "2.16.105",
|
||||||
"quill": "2.0.2",
|
"quill": "2.0.2",
|
||||||
"screenfull": "5.0.2",
|
"screenfull": "5.0.2",
|
||||||
"sm-crypto": "^0.3.13",
|
"sm-crypto": "^0.3.13",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,484 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="document-search">
|
||||||
<h1>文档搜索</h1>
|
<div class="search-toolbar">
|
||||||
|
<el-input v-model="keyword" class="search-input" placeholder="输入关键字搜索 PDF" clearable
|
||||||
|
@keyup.enter.native="handleSearch" @clear="resetSearch">
|
||||||
|
<el-button slot="append" icon="el-icon-search" @click="handleSearch">搜索</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="pdfWrapper" class="pdf-wrapper"></div>
|
||||||
|
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="overlayType === 'loading'" key="loading" class="state-panel overlay">
|
||||||
|
<i class="el-icon-loading state-icon"></i>
|
||||||
|
<p>正在加载 PDF,请稍候...</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="overlayType === 'error'" key="error" class="state-panel overlay">
|
||||||
|
<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="overlayType === 'no-url'" key="no-url" class="state-panel overlay">
|
||||||
|
<i class="el-icon-document state-icon"></i>
|
||||||
|
<p>暂未指定 PDF 文件,请通过路由参数 url 传入文件地址。</p>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import debounce from 'lodash/debounce'
|
||||||
|
import 'pdfjs-dist/legacy/web/pdf_viewer.css'
|
||||||
|
import { EventBus, TextLayerBuilder } from 'pdfjs-dist/legacy/web/pdf_viewer'
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf'
|
||||||
|
import pdfWorker from 'pdfjs-dist/legacy/build/pdf.worker.entry'
|
||||||
|
|
||||||
|
const resolvedWorkerSrc =
|
||||||
|
typeof pdfWorker === 'string'
|
||||||
|
? pdfWorker
|
||||||
|
: pdfWorker && pdfWorker.default
|
||||||
|
? pdfWorker.default
|
||||||
|
: (pdfWorker && pdfWorker.workerSrc) || null
|
||||||
|
|
||||||
|
if (resolvedWorkerSrc) {
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = resolvedWorkerSrc
|
||||||
|
} else {
|
||||||
|
console.warn('未能解析 PDF.js Worker 地址,将在主线程解析 PDF')
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SCALE = 1.25
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'DocumentSearch',
|
name: 'DocumentSearch',
|
||||||
|
computed: {
|
||||||
|
overlayType() {
|
||||||
|
if (this.loading) {
|
||||||
|
return 'loading'
|
||||||
|
}
|
||||||
|
if (this.error) {
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
if (!this.pdfUrl) {
|
||||||
|
return 'no-url'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
pdfUrl: '',
|
||||||
|
pdfDoc: null,
|
||||||
|
keyword: '',
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
scale: DEFAULT_SCALE,
|
||||||
|
eventBus: new EventBus(),
|
||||||
|
pageTextDivs: [],
|
||||||
|
searchResults: [],
|
||||||
|
currentResultIndex: -1,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.pdfUrl = this.$route.query.url || 'http://192.168.0.14:9090/smart-bid/technicalSolutionDatabase/2025/10/30/fe5b46ea37554516a71e7c0c486d3715.pdf'
|
||||||
|
if (this.pdfUrl) {
|
||||||
|
this.loadDocument()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'$route.query.url'(newUrl, oldUrl) {
|
||||||
|
if (newUrl !== oldUrl) {
|
||||||
|
this.pdfUrl = newUrl || ''
|
||||||
|
this.resetViewerState()
|
||||||
|
if (this.pdfUrl) {
|
||||||
|
this.loadDocument()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadDocument() {
|
||||||
|
if (!this.pdfUrl) return
|
||||||
|
this.loading = true
|
||||||
|
this.error = null
|
||||||
|
this.resetViewerState()
|
||||||
|
await this.$nextTick()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = {}
|
||||||
|
const token = this.$store?.getters?.token || window?.sessionStorage?.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = token.startsWith('Bearer ') ? token : `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingTask = pdfjsLib.getDocument({
|
||||||
|
url: this.pdfUrl,
|
||||||
|
withCredentials: true,
|
||||||
|
httpHeaders: headers,
|
||||||
|
disableWorker: !resolvedWorkerSrc,
|
||||||
|
})
|
||||||
|
this.pdfDoc = await loadingTask.promise
|
||||||
|
console.log('PDF 文档加载成功', this.pdfDoc)
|
||||||
|
await this.renderAllPages()
|
||||||
|
if (this.keyword) {
|
||||||
|
this.highlightMatches()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载 PDF 失败:', err)
|
||||||
|
this.error = err?.message || 'PDF 文件加载失败,请稍后再试'
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async renderAllPages() {
|
||||||
|
if (!this.pdfDoc) return
|
||||||
|
|
||||||
|
const container = this.$refs.pdfWrapper
|
||||||
|
if (!container) return
|
||||||
|
container.innerHTML = ''
|
||||||
|
this.pageTextDivs = []
|
||||||
|
|
||||||
|
const pageCount = this.pdfDoc.numPages
|
||||||
|
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
|
||||||
|
const pageViewport = await this.renderSinglePage(pageNumber, container)
|
||||||
|
// 设置容器最小宽度以避免文本层换行异常
|
||||||
|
container.style.minWidth = `${Math.ceil(pageViewport.width)}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
container.scrollTop = 0
|
||||||
|
},
|
||||||
|
|
||||||
|
async renderSinglePage(pageNumber, container) {
|
||||||
|
const page = await this.pdfDoc.getPage(pageNumber)
|
||||||
|
const viewport = page.getViewport({ scale: this.scale })
|
||||||
|
|
||||||
|
const pageWrapper = document.createElement('div')
|
||||||
|
pageWrapper.className = 'pdf-page'
|
||||||
|
pageWrapper.style.width = `${viewport.width}px`
|
||||||
|
pageWrapper.style.height = `${viewport.height}px`
|
||||||
|
container.appendChild(pageWrapper)
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.className = 'pdf-canvas'
|
||||||
|
const outputScale = window.devicePixelRatio || 1
|
||||||
|
canvas.width = viewport.width * outputScale
|
||||||
|
canvas.height = viewport.height * outputScale
|
||||||
|
canvas.style.width = `${viewport.width}px`
|
||||||
|
canvas.style.height = `${viewport.height}px`
|
||||||
|
pageWrapper.appendChild(canvas)
|
||||||
|
|
||||||
|
const canvasContext = canvas.getContext('2d')
|
||||||
|
const renderContext = {
|
||||||
|
canvasContext,
|
||||||
|
viewport,
|
||||||
|
}
|
||||||
|
if (outputScale !== 1) {
|
||||||
|
renderContext.transform = [outputScale, 0, 0, outputScale, 0, 0]
|
||||||
|
}
|
||||||
|
await page.render(renderContext).promise
|
||||||
|
|
||||||
|
const textLayerDiv = document.createElement('div')
|
||||||
|
textLayerDiv.className = 'textLayer'
|
||||||
|
textLayerDiv.style.width = `${viewport.width}px`
|
||||||
|
textLayerDiv.style.height = `${viewport.height}px`
|
||||||
|
pageWrapper.appendChild(textLayerDiv)
|
||||||
|
|
||||||
|
const textLayer = new TextLayerBuilder({
|
||||||
|
textLayerDiv,
|
||||||
|
pageIndex: pageNumber - 1,
|
||||||
|
viewport,
|
||||||
|
eventBus: this.eventBus,
|
||||||
|
})
|
||||||
|
|
||||||
|
const textContent = await page.getTextContent()
|
||||||
|
textLayer.setTextContent(textContent)
|
||||||
|
textLayer.render()
|
||||||
|
|
||||||
|
const textDivs = [...textLayer.textDivs]
|
||||||
|
textDivs.forEach((div) => {
|
||||||
|
div.dataset.originalText = div.textContent
|
||||||
|
})
|
||||||
|
this.pageTextDivs.push(textDivs)
|
||||||
|
|
||||||
|
return viewport
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSearch: debounce(function () {
|
||||||
|
if (!this.keyword) {
|
||||||
|
this.resetSearch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.pageTextDivs.length) {
|
||||||
|
this.$message.warning('PDF 正在加载,请稍候再试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.highlightMatches()
|
||||||
|
}, 200),
|
||||||
|
|
||||||
|
highlightMatches() {
|
||||||
|
const keyword = this.keyword.trim()
|
||||||
|
if (!keyword) {
|
||||||
|
this.resetSearch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearHighlights()
|
||||||
|
const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const pattern = new RegExp(`(${escapedKeyword})`, 'gi')
|
||||||
|
const results = []
|
||||||
|
|
||||||
|
this.pageTextDivs.forEach((textDivs, pageIndex) => {
|
||||||
|
textDivs.forEach((div) => {
|
||||||
|
const original = div.dataset.originalText || div.textContent || ''
|
||||||
|
if (!original) return
|
||||||
|
pattern.lastIndex = 0
|
||||||
|
if (!pattern.test(original)) {
|
||||||
|
pattern.lastIndex = 0
|
||||||
|
div.textContent = original
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pattern.lastIndex = 0
|
||||||
|
|
||||||
|
const highlighted = original.replace(pattern, '<mark class="search-highlight">$1</mark>')
|
||||||
|
div.innerHTML = highlighted
|
||||||
|
const marks = div.querySelectorAll('mark.search-highlight')
|
||||||
|
marks.forEach((mark) => {
|
||||||
|
results.push({
|
||||||
|
pageIndex,
|
||||||
|
element: mark,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
this.searchResults = results
|
||||||
|
if (!results.length) {
|
||||||
|
this.currentResultIndex = -1
|
||||||
|
this.$message.info(`未找到“${keyword}”相关内容`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentResultIndex = 0
|
||||||
|
this.focusCurrentResult()
|
||||||
|
},
|
||||||
|
|
||||||
|
focusCurrentResult() {
|
||||||
|
if (!this.searchResults.length || this.currentResultIndex < 0) return
|
||||||
|
this.searchResults.forEach((item, index) => {
|
||||||
|
if (index === this.currentResultIndex) {
|
||||||
|
item.element.classList.add('is-active')
|
||||||
|
} else {
|
||||||
|
item.element.classList.remove('is-active')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const target = this.searchResults[this.currentResultIndex]?.element
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
const wrapper = this.$refs.pdfWrapper
|
||||||
|
if (!wrapper) return
|
||||||
|
const targetRect = target.getBoundingClientRect()
|
||||||
|
const wrapperRect = wrapper.getBoundingClientRect()
|
||||||
|
|
||||||
|
const offset = targetRect.top - wrapperRect.top - wrapper.clientHeight / 2
|
||||||
|
wrapper.scrollTo({
|
||||||
|
top: wrapper.scrollTop + offset,
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
goToPrevious() {
|
||||||
|
if (!this.searchResults.length) return
|
||||||
|
this.currentResultIndex =
|
||||||
|
(this.currentResultIndex - 1 + this.searchResults.length) % this.searchResults.length
|
||||||
|
this.focusCurrentResult()
|
||||||
|
},
|
||||||
|
|
||||||
|
goToNext() {
|
||||||
|
if (!this.searchResults.length) return
|
||||||
|
this.currentResultIndex = (this.currentResultIndex + 1) % this.searchResults.length
|
||||||
|
this.focusCurrentResult()
|
||||||
|
},
|
||||||
|
|
||||||
|
resetSearch() {
|
||||||
|
this.keyword = ''
|
||||||
|
this.clearHighlights()
|
||||||
|
this.searchResults = []
|
||||||
|
this.currentResultIndex = -1
|
||||||
|
},
|
||||||
|
|
||||||
|
clearHighlights() {
|
||||||
|
this.pageTextDivs.forEach((textDivs) => {
|
||||||
|
textDivs.forEach((div) => {
|
||||||
|
const original = div.dataset.originalText
|
||||||
|
if (typeof original !== 'undefined') {
|
||||||
|
div.textContent = original
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
resetViewerState() {
|
||||||
|
this.clearHighlights()
|
||||||
|
this.searchResults = []
|
||||||
|
this.currentResultIndex = -1
|
||||||
|
this.pageTextDivs = []
|
||||||
|
const wrapper = this.$refs.pdfWrapper
|
||||||
|
if (wrapper) {
|
||||||
|
wrapper.innerHTML = ''
|
||||||
|
wrapper.scrollTop = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reload() {
|
||||||
|
if (!this.pdfUrl) return
|
||||||
|
this.loadDocument()
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.document-search {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 84px);
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f4f7ff;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
height: 100%;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-height: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 24px;
|
||||||
|
background: linear-gradient(180deg, #eef3ff 0%, #ffffff 100%);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
box-shadow: 0 10px 30px rgba(25, 64, 158, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-canvas {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textLayer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textLayer>span {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-highlight {
|
||||||
|
background: rgba(255, 241, 168, 0.9);
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-highlight.is-active {
|
||||||
|
background: rgba(32, 109, 255, 0.85);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #6072a1;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #1f72ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: linear-gradient(180deg, rgba(238, 243, 255, 0.92) 0%, rgba(255, 255, 255, 0.96) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in New Issue