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

1599 lines
53 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">
<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="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>
</template>
<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.20
const DEFAULT_PDF_URL = 'http://192.168.0.14:9090/smart-bid/technicalSolutionDatabase/2025/10/30/fe5b46ea37554516a71e7c0c486d3715.pdf'
export default {
name: 'DocumentSearch',
props: {
fileUrl: {
type: String,
default: ''
},
showSearchBar: true
},
computed: {
overlayType() {
if (this.loading) {
return 'loading'
}
if (this.error) {
return 'error'
}
if (!this.pdfUrl) {
return 'no-url'
}
return null
},
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 {
pdfUrl: '',
pdfDoc: null,
keyword: '',
loading: false,
error: null,
scale: DEFAULT_SCALE,
eventBus: new EventBus(),
pageTextDivs: [],
searchResults: [],
currentResultIndex: -1,
pageMatchRanges: [],
renderedPages: new Map(),
observer: null,
pageContainers: [],
totalPages: 0,
isDocumentReady: false,
renderQueue: [],
isProcessingQueue: false,
pageCache: new Map(),
isPrefetching: false,
prefetchHandle: null,
prefetchScheduled: false,
initialPreloadedCount: 0,
cMapUrl: '',
standardFontDataUrl: '',
pdfAssetsBase: '',
searching: false,
scrollAnimationFrame: null,
}
},
mounted() {
const base = (process.env.BASE_URL || '/').replace(/\/+$/, '/')
this.pdfAssetsBase = `${base}pdfjs/`
this.cMapUrl = `${this.pdfAssetsBase}cmaps/`
this.standardFontDataUrl = `${this.pdfAssetsBase}standard_fonts/`
pdfjsLib.GlobalCMapOptions = {
url: this.cMapUrl,
packed: true,
}
if ('GlobalStandardFontDataUrl' in pdfjsLib) {
pdfjsLib.GlobalStandardFontDataUrl = this.standardFontDataUrl
}
if (pdfjsLib.GlobalOptions) {
pdfjsLib.GlobalOptions.disableFontFace = false
if ('useSystemFonts' in pdfjsLib.GlobalOptions) {
pdfjsLib.GlobalOptions.useSystemFonts = true
}
}
if (!this.pdfUrl) {
const initialUrl = this.fileUrl || this.$route?.query?.url || DEFAULT_PDF_URL
this.applyPdfUrl(initialUrl)
}
},
beforeDestroy() {
this.disconnectObserver()
this.cancelScrollAnimation()
},
watch: {
fileUrl: {
immediate: true,
handler(newVal) {
if (newVal) {
this.applyPdfUrl(newVal)
}
}
},
'$route.query.url'(newUrl, oldUrl) {
if (this.fileUrl) return
if (newUrl !== oldUrl) {
const target = newUrl || DEFAULT_PDF_URL
this.applyPdfUrl(target)
}
},
},
methods: {
toggleSearchBar() {
this.showSearchBar = true
this.$nextTick(() => {
if (this.$refs.keywordInput) {
this.$refs.keywordInput.focus()
}
})
},
handleCloseSearch() {
this.keyword = ''
this.resetSearch()
this.showSearchBar = false
},
applyPdfUrl(url) {
const resolved = url || ''
if (resolved === this.pdfUrl) {
if (resolved) {
this.loadDocument()
}
return
}
this.pdfUrl = resolved
if (this.pdfUrl) {
this.loadDocument()
} else {
this.resetViewerState()
}
},
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,
useWorkerFetch: !!resolvedWorkerSrc,
cMapUrl: this.cMapUrl,
cMapPacked: true,
standardFontDataUrl: this.standardFontDataUrl,
useSystemFonts: true,
fontExtraProperties: true,
})
this.pdfDoc = await loadingTask.promise
console.log('PDF 文档加载成功', this.pdfDoc)
await this.renderAllPages()
if (this.keyword) {
await 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 = ''
container.style.minWidth = ''
this.pageTextDivs = []
this.renderedPages.clear()
this.pageContainers = []
this.totalPages = this.pdfDoc.numPages
this.renderQueue = []
this.isProcessingQueue = false
this.pageCache.clear()
this.isPrefetching = false
this.initialPreloadedCount = 0
const fragment = document.createDocumentFragment()
for (let pageNumber = 1; pageNumber <= this.totalPages; pageNumber += 1) {
const placeholder = document.createElement('div')
placeholder.className = 'pdf-page placeholder'
placeholder.style.margin = '0px auto 10px'
placeholder.style.position = 'relative';
placeholder.dataset.page = pageNumber
placeholder.dataset.status = 'placeholder'
fragment.appendChild(placeholder)
this.pageContainers.push(placeholder)
}
container.appendChild(fragment)
container.scrollTop = 0
this.setupIntersectionObserver()
await this.preloadInitialPages()
this.observeInitialPages()
this.schedulePrefetch()
this.isDocumentReady = true
},
ensureContainerDimensions(pageNumber, viewport) {
const index = pageNumber - 1
const container = this.pageContainers[index]
if (!container) return
container.style.width = `${viewport.width}px`
container.style.height = `${viewport.height}px`
container.style.margin = '0px auto 10px'
container.style.backgroundColor = '#fff';
if (!container.dataset.status || container.dataset.status === 'placeholder') {
container.dataset.status = 'prefetched'
}
container.classList.remove('placeholder')
if (container.dataset.status !== 'rendered') {
container.classList.add('prefetched')
}
if (this.$refs.pdfWrapper && !this.$refs.pdfWrapper.style.minWidth) {
this.$refs.pdfWrapper.style.minWidth = `${Math.ceil(viewport.width)}px`
}
},
async ensurePageCached(pageNumber) {
if (!this.pdfDoc) return null
if (this.pageCache.has(pageNumber)) {
return this.pageCache.get(pageNumber)
}
const page = await this.pdfDoc.getPage(pageNumber)
this.pageCache.set(pageNumber, page)
return page
},
async renderCanvas(pageNumber) {
const page = await this.ensurePageCached(pageNumber)
if (!page) return null
const index = pageNumber - 1
const container = this.pageContainers[index]
if (!container) return null
const viewport = page.getViewport({ scale: this.scale })
this.ensureContainerDimensions(pageNumber, viewport)
container.classList.add('is-loading')
const oldCanvas = container.querySelector('.pdf-canvas')
if (oldCanvas) {
oldCanvas.remove()
}
const canvas = document.createElement('canvas')
canvas.className = 'pdf-canvas'
const deviceScale = window.devicePixelRatio || 1
const outputScale = deviceScale > 1 ? deviceScale : 1
canvas.width = viewport.width * outputScale
canvas.height = viewport.height * outputScale
canvas.style.width = `${viewport.width}px`
canvas.style.height = `${viewport.height}px`
container.appendChild(canvas)
const canvasContext = canvas.getContext('2d')
if (canvasContext && 'imageSmoothingEnabled' in canvasContext) {
canvasContext.imageSmoothingEnabled = false
}
const renderContext = {
canvasContext,
viewport,
transform: [outputScale, 0, 0, outputScale, 0, 0],
intent: 'print',
enableWebGL: true,
background: 'rgba(255,255,255,0)',
}
canvasContext.setTransform(outputScale, 0, 0, outputScale, 0, 0)
canvasContext.imageSmoothingEnabled = false
await page.render(renderContext).promise
container.dataset.status = 'rendered'
container.classList.remove('prefetched')
const textLayerDiv = container.querySelector('.textLayer')
if (textLayerDiv) {
textLayerDiv.style.display = ''
}
this.renderedPages.set(pageNumber, { container, viewport })
container.classList.remove('is-loading')
return { page, viewport, container }
},
async renderTextLayer(pageNumber, { visible = true, force = false } = {}) {
const page = await this.ensurePageCached(pageNumber)
if (!page) return
const index = pageNumber - 1
const container = this.pageContainers[index]
if (!container) return
const viewport = page.getViewport({ scale: this.scale })
this.ensureContainerDimensions(pageNumber, viewport)
container.classList.add('is-loading-text')
const existing = container.querySelector('.textLayer')
if (existing && !force && this.pageTextDivs[index]?.length) {
existing.style.display = ''
container.classList.remove('is-loading-text')
return
}
if (existing) {
existing.remove()
}
const textLayerDiv = document.createElement('div')
textLayerDiv.className = 'textLayer'
textLayerDiv.style.width = `${viewport.width}px`
textLayerDiv.style.height = `${viewport.height}px`
textLayerDiv.style.display = ''
container.appendChild(textLayerDiv)
const textLayer = new TextLayerBuilder({
textLayerDiv,
pageIndex: index,
viewport,
eventBus: this.eventBus,
})
try {
const textContent = await page.getTextContent()
textLayer.setTextContent(textContent)
textLayer.render()
const textDivs = [...textLayer.textDivs]
textDivs.forEach((div) => {
div.dataset.originalText = div.textContent
})
this.pageTextDivs[index] = textDivs
} catch (error) {
console.warn(`加载第 ${pageNumber} 页文本失败`, error)
} finally {
container.classList.remove('is-loading-text')
}
if (this.keyword.trim() && this.pageMatchRanges && this.pageMatchRanges.length) {
this.applyHighlightsToPage(index)
}
if (container.dataset.status !== 'rendered') {
container.dataset.status = visible ? 'rendered' : 'text-ready'
if (!visible) {
container.classList.add('prefetched')
}
}
},
async renderSinglePage(pageNumber) {
const container = this.pageContainers[pageNumber - 1]
if (container) {
container.classList.add('is-loading', 'is-loading-text')
}
try {
await this.renderTextLayer(pageNumber, { visible: true, force: true })
await this.renderCanvas(pageNumber)
} finally {
if (container) {
container.classList.remove('is-loading', 'is-loading-text')
}
}
},
scheduleRender(pageNumber, { priority = false } = {}) {
if (!pageNumber || pageNumber > this.totalPages || this.renderedPages.has(pageNumber)) return
if (this.renderQueue.includes(pageNumber)) return
if (priority) {
this.renderQueue.unshift(pageNumber)
} else {
this.renderQueue.push(pageNumber)
}
this.processRenderQueue()
},
async processRenderQueue() {
if (this.isProcessingQueue) return
this.isProcessingQueue = true
try {
while (this.renderQueue.length) {
const pageNumber = this.renderQueue.shift()
await new Promise((resolve) => requestAnimationFrame(resolve))
await this.renderSinglePage(pageNumber)
}
} finally {
this.isProcessingQueue = false
}
},
schedulePrefetch() {
this.cancelPrefetch()
if (!this.totalPages) return
const startPage = Math.min(this.totalPages, Math.max(1, this.initialPreloadedCount + 1))
let pageNumber = startPage
const fetchNext = async () => {
this.prefetchScheduled = false
this.prefetchHandle = null
if (pageNumber > this.totalPages) {
this.isPrefetching = false
return
}
if (!this.pageCache.has(pageNumber)) {
try {
const page = await this.pdfDoc.getPage(pageNumber)
this.pageCache.set(pageNumber, page)
const viewport = page.getViewport({ scale: this.scale })
this.ensureContainerDimensions(pageNumber, viewport)
} catch (error) {
console.warn(`预取第 ${pageNumber} 页失败`, error)
}
}
pageNumber += 1
if (pageNumber <= this.totalPages) {
const schedule = () => {
this.prefetchScheduled = true
if (typeof window.requestIdleCallback === 'function') {
this.prefetchHandle = window.requestIdleCallback(fetchNext, { timeout: 1000 })
} else {
this.prefetchHandle = window.setTimeout(fetchNext, 80)
}
}
schedule()
} else {
this.isPrefetching = false
}
}
this.isPrefetching = true
if (typeof window.requestIdleCallback === 'function') {
this.prefetchScheduled = true
this.prefetchHandle = window.requestIdleCallback(fetchNext, { timeout: 800 })
} else {
this.prefetchScheduled = true
this.prefetchHandle = window.setTimeout(fetchNext, 120)
}
},
cancelPrefetch() {
if (this.prefetchHandle) {
if (typeof window.cancelIdleCallback === 'function' && this.prefetchScheduled) {
window.cancelIdleCallback(this.prefetchHandle)
} else {
clearTimeout(this.prefetchHandle)
}
}
this.prefetchHandle = null
this.prefetchScheduled = false
this.isPrefetching = false
},
async preloadInitialPages() {
const preloadCount = Math.min(3, this.totalPages)
if (!preloadCount) {
this.initialPreloadedCount = 0
return
}
for (let pageNumber = 1; pageNumber <= preloadCount; pageNumber += 1) {
await this.renderTextLayer(pageNumber, { visible: pageNumber === 1 })
}
if (preloadCount >= 1) {
await this.renderCanvas(1)
}
this.initialPreloadedCount = preloadCount
},
unloadPage(pageNumber) {
if (!pageNumber) return
const index = pageNumber - 1
const container = this.pageContainers[index]
if (!container || container.dataset.status !== 'rendered') return
const canvas = container.querySelector('.pdf-canvas')
if (canvas) {
canvas.remove()
}
const textLayer = container.querySelector('.textLayer')
if (textLayer) {
textLayer.style.display = ''
}
container.classList.remove('is-loading')
this.renderedPages.delete(pageNumber)
container.dataset.status = this.pageTextDivs[index]?.length ? 'text-ready' : 'prefetched'
container.classList.add('prefetched')
},
handleSearch: debounce(async function () {
const keyword = this.keyword.trim()
if (!keyword) {
this.resetSearch()
return
}
if (!this.pdfDoc) return
// if (this.searching) return
this.searching = true
await this.$nextTick()
try {
const allPrepared = this.pageTextDivs.length === this.totalPages && this.pageTextDivs.every(items => items && items.length)
if (!allPrepared) {
try {
await this.ensureAllTextLayersLoaded()
} catch (error) {
console.error('全文预处理失败', error)
}
}
await this.highlightMatches()
} finally {
this.searching = false
}
}, 200),
async ensureAllTextLayersLoaded() {
if (!this.pdfDoc || !this.totalPages) return
let total = this.totalPages
for (let pageNumber = 1; pageNumber <= total; pageNumber += 1) {
if (this.totalPages !== total) break
if (!this.pageTextDivs[pageNumber - 1] || !this.pageTextDivs[pageNumber - 1].length) {
await this.renderTextLayer(pageNumber, { visible: pageNumber === 1, force: true })
await this.$nextTick()
}
}
},
async highlightMatches(options = {}) {
const { preserveIndex = false, skipNavigate = false } = options
const keyword = this.keyword.trim()
if (!keyword) {
this.resetSearch()
return
}
const previousIndex = preserveIndex ? this.currentResultIndex : 0
this.clearHighlights()
const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`(${escapedKeyword})`, 'gi')
const results = []
const matchRanges = Array.from({ length: this.totalPages || 0 }, () => [])
for (let pageIndex = 0; pageIndex < this.totalPages; pageIndex += 1) {
let textDivs = this.pageTextDivs[pageIndex]
if (!textDivs || !textDivs.length) {
const container = this.pageContainers[pageIndex]
const isRendered = container?.dataset.status === 'rendered'
await this.renderTextLayer(pageIndex + 1, { visible: !!isRendered, force: true })
await this.$nextTick()
textDivs = this.pageTextDivs[pageIndex]
}
if (!textDivs || !textDivs.length) {
continue
}
const segments = []
let position = 0
textDivs.forEach((div, divIndex) => {
const original = div.dataset.originalText || div.textContent || ''
segments.push({
div,
divIndex,
text: original,
start: position,
end: position + original.length,
})
position += original.length
})
if (!segments.length) {
continue
}
const pageText = segments.map((seg) => seg.text).join('')
if (!pageText) {
continue
}
pattern.lastIndex = 0
const perDivHighlights = new Map()
const matchRecords = []
let match
while ((match = pattern.exec(pageText)) !== null) {
if (!match[0]) continue
const start = match.index
const end = start + match[0].length
const matchIndex = matchRecords.length
matchRecords.push({
pageIndex,
start,
end,
elements: [],
segments: [],
})
let segIndex = segments.findIndex((seg) => start < seg.end && end > seg.start)
if (segIndex === -1) continue
let currentStart = start
while (segIndex < segments.length && currentStart < end) {
const seg = segments[segIndex]
const highlightStart = Math.max(0, currentStart - seg.start)
const highlightEnd = Math.min(seg.text.length, end - seg.start)
if (highlightEnd > highlightStart) {
const ranges = perDivHighlights.get(seg.div) || []
ranges.push({
start: highlightStart,
end: highlightEnd,
matchIndex,
})
perDivHighlights.set(seg.div, ranges)
matchRecords[matchIndex].segments.push({
divIndex: seg.divIndex,
start: highlightStart,
end: highlightEnd,
})
}
if (end <= seg.end) break
currentStart = seg.end
segIndex += 1
}
}
if (!matchRecords.length) {
continue
}
perDivHighlights.forEach((ranges, div) => {
const original = div.dataset.originalText || div.textContent || ''
if (!original) return
const sorted = ranges
.slice()
.sort((a, b) => (a.start === b.start ? a.end - b.end : a.start - b.start))
let cursor = 0
let html = ''
sorted.forEach(({ start, end, matchIndex }) => {
if (start > cursor) {
html += this.escapeForHtml(original.slice(cursor, start))
}
const text = original.slice(start, end)
html += `<mark class="search-highlight" data-match-index="${matchIndex}">${this.escapeForHtml(text)}</mark>`
cursor = end
})
if (cursor < original.length) {
html += this.escapeForHtml(original.slice(cursor))
}
div.innerHTML = html
})
perDivHighlights.forEach((_ranges, div) => {
const marks = div.querySelectorAll('mark.search-highlight')
marks.forEach((mark) => {
const matchIndex = Number(mark.dataset.matchIndex)
if (!Number.isNaN(matchIndex) && matchRecords[matchIndex]) {
matchRecords[matchIndex].elements.push(mark)
mark.dataset.pageIndex = String(pageIndex)
}
})
})
const pageMatches = matchRanges[pageIndex]
matchRecords.forEach((record) => {
if (!record.elements.length) return
const newIndex = results.length
record.elements.forEach((mark) => {
mark.dataset.matchIndex = String(newIndex)
})
results.push({
pageIndex,
element: record.elements[0],
elements: record.elements,
})
pageMatches.push({
matchIndex: newIndex,
segments: record.segments.map((item) => ({ ...item })),
})
})
}
this.pageMatchRanges = matchRanges
this.searchResults = results
if (!results.length) {
this.currentResultIndex = -1
this.$message.info(`未找到“${keyword}”相关内容`)
return
}
if (preserveIndex && previousIndex < results.length && previousIndex >= 0) {
this.currentResultIndex = previousIndex
} else {
this.currentResultIndex = 0
}
if (!skipNavigate) {
await this.navigateToResult(this.currentResultIndex, true, false)
} else {
this.scheduleActiveHighlightRefresh()
}
},
focusCurrentResult() {
this.navigateToResult(this.currentResultIndex)
},
async navigateToResult(index, ensureRendered = false, useSmoothScroll = true) {
if (!this.searchResults.length || index < 0 || index >= this.searchResults.length) return
const currentResult = this.searchResults[index]
const pageNumber = currentResult.pageIndex + 1
if (ensureRendered || !this.pageTextDivs[pageNumber - 1] || !this.pageTextDivs[pageNumber - 1].length) {
await this.renderTextLayer(pageNumber, { visible: true, force: true })
await this.renderCanvas(pageNumber)
await this.$nextTick()
this.applyHighlightsToPage(pageNumber - 1)
this.scheduleActiveHighlightRefresh()
}
this.currentResultIndex = index
this.scheduleActiveHighlightRefresh()
const performScroll = () => {
const target = this.searchResults[this.currentResultIndex]?.element
if (!target) return
const wrapper = this.$refs.pdfWrapper
if (!wrapper) return
if (!useSmoothScroll) {
// 直接跳转到匹配位置,不做平滑滚动动画
try {
target.scrollIntoView({
behavior: 'auto',
block: 'center',
inline: 'nearest',
})
} catch (e) {
// 兼容性兜底
const container = target.closest('.pdf-page') || target
if (!container) return
const wrapperOffsetTop = container.offsetTop
const containerHeight = container.offsetHeight || target.offsetHeight || 0
const desired = wrapperOffsetTop - Math.max((wrapper.clientHeight - containerHeight) / 2, 0)
this.cancelScrollAnimation()
wrapper.scrollTop = desired
}
return
}
const container = target.closest('.pdf-page') || target
if (!container) return
const wrapperOffsetTop = container.offsetTop
const containerHeight = container.offsetHeight || target.offsetHeight || 0
const desired = wrapperOffsetTop - Math.max((wrapper.clientHeight - containerHeight) / 2, 0)
this.smoothScrollTo(wrapper, desired)
}
if (useSmoothScroll) {
this.$nextTick(() => performScroll())
} else {
performScroll()
}
},
applyHighlightsToPage(pageIndex) {
if (!this.keyword || !this.pageMatchRanges || !this.pageMatchRanges.length) return
const textDivs = this.pageTextDivs[pageIndex]
if (!textDivs || !textDivs.length) return
const pageMatches = this.pageMatchRanges[pageIndex]
if (!pageMatches || !pageMatches.length) return
textDivs.forEach((div) => {
const original = div.dataset.originalText
if (typeof original !== 'undefined') {
div.textContent = original
}
})
const perDivRanges = new Map()
pageMatches.forEach((match) => {
if (!match || !Array.isArray(match.segments)) return
match.segments.forEach((segment) => {
const { divIndex, start, end } = segment || {}
if (typeof divIndex !== 'number') return
const ranges = perDivRanges.get(divIndex) || []
ranges.push({
start,
end,
matchIndex: match.matchIndex,
})
perDivRanges.set(divIndex, ranges)
})
})
const updatedElements = new Map()
perDivRanges.forEach((ranges, divIndex) => {
const div = textDivs[divIndex]
if (!div) return
const original = div.dataset.originalText || ''
if (!original) return
const sorted = ranges
.slice()
.filter((item) => typeof item.start === 'number' && typeof item.end === 'number')
.sort((a, b) => (a.start === b.start ? a.end - b.end : a.start - b.start))
let cursor = 0
let html = ''
sorted.forEach(({ start, end, matchIndex }) => {
if (start > cursor) {
html += this.escapeForHtml(original.slice(cursor, start))
}
const text = original.slice(start, end)
html += `<mark class="search-highlight" data-match-index="${matchIndex}">${this.escapeForHtml(text)}</mark>`
cursor = end
})
if (cursor < original.length) {
html += this.escapeForHtml(original.slice(cursor))
}
div.innerHTML = html
const marks = div.querySelectorAll('mark.search-highlight')
marks.forEach((mark) => {
const matchIndex = Number(mark.dataset.matchIndex)
if (Number.isNaN(matchIndex)) return
const elements = updatedElements.get(matchIndex) || []
elements.push(mark)
updatedElements.set(matchIndex, elements)
})
})
updatedElements.forEach((elements, matchIndex) => {
if (!this.searchResults[matchIndex]) return
this.searchResults[matchIndex].elements = elements
this.searchResults[matchIndex].element = elements[0] || null
})
this.scheduleActiveHighlightRefresh()
},
updateCurrentResultActiveState() {
const activePageContainers = new Set()
this.searchResults.forEach((item, idx) => {
if (!item) return
const elements = item.elements && item.elements.length ? item.elements : item.element ? [item.element] : []
elements.forEach((el) => {
if (!el) return
if (idx === this.currentResultIndex) {
el.classList.add('is-active', 'is-current')
const container = el.closest('.pdf-page')
if (container) {
activePageContainers.add(container)
}
} else {
el.classList.remove('is-active', 'is-current')
}
})
})
this.pageContainers.forEach((container) => {
if (!container) return
container.classList.remove('has-active-match')
})
activePageContainers.forEach((container) => {
container.classList.add('has-active-match')
})
},
scheduleActiveHighlightRefresh() {
this.$nextTick(() => {
if (typeof window !== 'undefined' && window.requestAnimationFrame) {
window.requestAnimationFrame(() => {
this.updateCurrentResultActiveState()
})
} else {
this.updateCurrentResultActiveState()
}
})
},
cancelScrollAnimation() {
if (this.scrollAnimationFrame !== null && typeof window !== 'undefined') {
cancelAnimationFrame(this.scrollAnimationFrame)
this.scrollAnimationFrame = null
}
},
smoothScrollTo(container, target, baseDuration = 240) {
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 distanceFactor = Math.min(Math.abs(change) / Math.max(container.clientHeight, 1), 2.4)
const duration = Math.min(460, baseDuration + distanceFactor * 110)
const startTime = performance.now()
const ease = (t) => 1 - Math.pow(1 - t, 4)
const velocityBoost = Math.min(Math.max(Math.abs(change) / 2400, 0), 0.25)
this.cancelScrollAnimation()
const step = (now) => {
const elapsed = now - startTime
const progress = Math.min(elapsed / duration, 1)
const eased = ease(progress)
const basePosition = start + change * eased
const overshoot = velocityBoost * Math.sin(eased * Math.PI)
container.scrollTop = Math.min(Math.max(basePosition + overshoot * change, 0), maxScroll)
if (progress < 1) {
this.scrollAnimationFrame = requestAnimationFrame(step)
} else {
this.scrollAnimationFrame = null
}
}
this.scrollAnimationFrame = requestAnimationFrame(step)
},
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
}
}
await this.navigateToResult(targetIndex, false, false)
},
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
}
}
await this.navigateToResult(targetIndex, false, false)
},
resetSearch() {
this.keyword = ''
this.clearHighlights()
this.searchResults = []
this.currentResultIndex = -1
this.pageMatchRanges = []
},
clearHighlights() {
this.pageTextDivs.forEach((textDivs) => {
if (!textDivs || !textDivs.length) return
textDivs.forEach((div) => {
const original = div.dataset.originalText
if (typeof original !== 'undefined') {
div.textContent = original
}
})
})
},
resetViewerState() {
this.clearHighlights()
this.searchResults = []
this.currentResultIndex = -1
this.pageMatchRanges = []
this.pageTextDivs = []
this.renderedPages.clear()
this.pageContainers = []
this.totalPages = 0
this.isDocumentReady = false
this.disconnectObserver()
this.cancelPrefetch()
this.pageCache.clear()
this.cancelScrollAnimation()
const wrapper = this.$refs.pdfWrapper
if (wrapper) {
wrapper.innerHTML = ''
wrapper.scrollTop = 0
}
},
escapeForHtml(text = '') {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
},
reload() {
if (!this.pdfUrl) return
this.loadDocument()
},
setupIntersectionObserver() {
this.disconnectObserver()
if (!this.$refs.pdfWrapper) return
const options = {
root: this.$refs.pdfWrapper,
rootMargin: '120px 0px',
threshold: 0.01,
}
this.observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const page = Number(entry.target.dataset.page)
if (page) {
this.renderTextLayer(page, { visible: true, force: true })
this.scheduleRender(page, { priority: true })
}
} else {
const page = Number(entry.target.dataset.page)
if (page) {
this.unloadPage(page)
}
}
})
}, options)
this.pageContainers.forEach((container) => this.observer.observe(container))
},
observeInitialPages() {
if (!this.pageContainers.length) return
const initial = this.pageContainers.slice(0, 3)
initial.forEach((container) => {
const page = Number(container.dataset.page)
if (page) {
if (page === 1) {
// 已在预处理中完成
return
}
if (page <= this.initialPreloadedCount) {
this.scheduleRender(page, { priority: true })
} else {
this.scheduleRender(page)
}
}
})
},
disconnectObserver() {
if (this.observer) {
this.observer.disconnect()
this.observer = null
}
this.renderQueue = []
this.isProcessingQueue = false
this.cancelPrefetch()
},
},
}
</script>
<style scoped lang="scss">
.document-search {
display: flex;
flex-direction: column;
height: calc(100vh - 84px);
overflow: hidden;
background: #f4f7ff;
padding: 0;
box-sizing: border-box;
font-size: 14px;
line-height: 1.6;
color: #1f2430;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
.floating-search {
position: absolute;
top: 20px;
right: 20px;
width: auto;
max-width: calc(100% - 40px);
z-index: 6;
pointer-events: none;
}
.floating-search-btn {
position: absolute;
top: 20px;
right: 20px;
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: #2b68ff;
color: #fff;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 26px rgba(31, 114, 234, 0.25);
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
z-index: 5;
}
.floating-search-btn:hover {
transform: translateY(-1px);
box-shadow: 0 12px 30px rgba(31, 114, 234, 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;
}
@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;
}
.search-icon:hover {
background: #1d4fd8;
color: #fff;
}
.search-status {
display: flex;
align-items: center;
gap: 6px;
color: #4f5875;
font-size: 12px;
padding-left: 10px;
border-left: 1px solid #e4e8f2;
}
.result-indicator {
font-weight: 600;
min-width: 48px;
text-align: center;
}
.viewer-container {
flex: 1;
background: #ffffff;
border-radius: 12px;
overflow: hidden;
position: relative;
display: flex;
min-height: 480px;
}
.pdf-wrapper {
flex: 1;
overflow: auto;
padding: 24px;
background: #eaeaea;
position: relative;
scroll-behavior: smooth;
overscroll-behavior: contain;
scrollbar-gutter: stable both-edges;
-webkit-overflow-scrolling: touch;
overflow-x: hidden;
}
.pdf-page {
position: relative;
margin: 0px auto 10px !important;
box-shadow: 0 10px 30px rgba(25, 64, 158, 0.12);
border-radius: 8px;
overflow: hidden;
background: #ffffff;
display: block;
will-change: transform, opacity;
max-width: 100%;
}
.pdf-page:first-of-type::before,
.pdf-page:last-of-type::after {
display: none;
}
.pdf-page::before,
.pdf-page::after {
content: '';
position: absolute;
left: 5%;
right: 5%;
height: 12px;
height: 8px;
border-radius: 6px;
background: linear-gradient(180deg, rgba(206, 216, 232, 0.65) 0%, rgba(208, 216, 230, 0.25) 100%);
box-shadow: 0 2px 6px rgba(102, 125, 160, 0.25);
}
.pdf-page::before {
top: -20px;
}
.pdf-page::after {
bottom: -28px;
}
.pdf-page.placeholder {
box-shadow: none;
background: linear-gradient(135deg, rgba(233, 239, 255, 0.7), rgba(255, 255, 255, 0.7));
border: 1px dashed rgba(112, 143, 255, 0.4);
min-height: 240px;
color: #7f8bff;
font-size: 13px;
letter-spacing: 0.6px;
}
.pdf-page.placeholder::after {
content: '正在准备页面...';
}
.pdf-page.prefetched {
background: #fdfdfd;
box-shadow: inset 0 0 0 1px rgba(112, 143, 255, 0.2);
position: relative;
}
.pdf-page.prefetched::after {
content: '滚动到此处以加载内容';
color: rgba(112, 143, 255, 0.7);
font-size: 12px;
letter-spacing: 0.4px;
}
.pdf-canvas {
width: 100%;
display: block;
margin: 0 auto;
}
.textLayer {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
pointer-events: auto;
user-select: text;
-webkit-user-select: text;
}
.textLayer>span {
cursor: text;
user-select: text;
-webkit-user-select: text;
}
.pdf-page.is-loading::after {
opacity: 0.5;
background: rgba(255, 255, 255, 0.7);
}
.pdf-page.is-loading .textLayer,
.pdf-page.is-loading .pdf-canvas {
opacity: 0.35;
}
@keyframes pdf-page-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes search-glow {
0%,
100% {
box-shadow: 0 0 0 0 rgba(32, 109, 255, 0.45);
}
50% {
box-shadow: 0 0 0 10px rgba(32, 109, 255, 0);
}
}
@keyframes search-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes search-outline {
0%,
100% {
opacity: 0;
transform: scale(0.9);
}
50% {
opacity: 1;
transform: scale(1.05);
}
}
.pdf-page>canvas.pdf-canvas {
position: relative;
top: 0;
left: 50%;
transform: translateX(-50%);
}
.pdf-page>.textLayer {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
}
.search-highlight {
background: rgba(255, 241, 168, 0.9);
padding: 0 2px;
border-radius: 2px;
}
::v-deep .search-highlight.is-active,
::v-deep .search-highlight.is-current {
background: #206dff !important;
color: #ffffff;
box-shadow: 0 0 0 2px rgba(32, 109, 255, 0.35);
border-radius: 4px;
transition: background 0.2s ease, box-shadow 0.2s ease;
}
.pdf-page.has-active-match {
box-shadow: 0 0 0 3px rgba(32, 109, 255, 0.45), 0 12px 36px rgba(32, 109, 255, 0.18);
transition: box-shadow 0.3s ease;
}
::v-deep .textLayer {
user-select: text;
pointer-events: auto;
-webkit-user-select: text;
-moz-user-select: text;
}
::v-deep .textLayer span,
::v-deep .textLayer mark {
user-select: text;
pointer-events: auto;
-webkit-user-select: text;
-moz-user-select: text;
}
.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>