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

1108 lines
37 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="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" :loading="searching" :disabled="searching">
搜索
</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>
</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
export default {
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,
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,
}
},
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
}
}
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()
}
},
beforeDestroy() {
this.disconnectObserver()
},
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,
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) {
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,
}
if (outputScale !== 1) {
renderContext.transform = [outputScale, 0, 0, outputScale, 0, 0]
}
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 (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 = 'none'
}
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
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)
}
}
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()
}
}
},
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 = []
this.pageTextDivs.forEach((textDivs, pageIndex) => {
if (!textDivs || !textDivs.length) {
return
}
const segments = []
let position = 0
textDivs.forEach((div) => {
const original = div.dataset.originalText || div.textContent || ''
segments.push({
div,
text: original,
start: position,
end: position + original.length,
})
position += original.length
})
if (!segments.length) {
return
}
const pageText = segments.map((seg) => seg.text).join('')
if (!pageText) {
return
}
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: [],
})
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)
}
if (end <= seg.end) break
currentStart = seg.end
segIndex += 1
}
}
if (!matchRecords.length) {
return
}
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)
}
})
})
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,
})
})
})
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) {
this.navigateToResult(this.currentResultIndex, false)
}
},
focusCurrentResult() {
this.navigateToResult(this.currentResultIndex)
},
async navigateToResult(index, ensureRendered = false) {
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.highlightMatches({ preserveIndex: true, skipNavigate: true })
}
this.currentResultIndex = index
this.searchResults.forEach((item, idx) => {
if (!item) return
const elements = item.elements && item.elements.length ? item.elements : [item.element]
elements.forEach((el) => {
if (!el) return
if (idx === this.currentResultIndex) {
el.classList.add('is-active')
} else {
el.classList.remove('is-active')
}
})
})
const target = this.searchResults[this.currentResultIndex]?.element
if (!target) return
const wrapper = this.$refs.pdfWrapper
if (!wrapper) return
this.$nextTick(() => {
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
const idx = (this.currentResultIndex - 1 + this.searchResults.length) % this.searchResults.length
this.navigateToResult(idx)
},
goToNext() {
if (!this.searchResults.length) return
const idx = (this.currentResultIndex + 1) % this.searchResults.length
this.navigateToResult(idx)
},
resetSearch() {
this.keyword = ''
this.clearHighlights()
this.searchResults = []
this.currentResultIndex = -1
},
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.pageTextDivs = []
this.renderedPages.clear()
this.pageContainers = []
this.totalPages = 0
this.isDocumentReady = false
this.disconnectObserver()
this.cancelPrefetch()
this.pageCache.clear()
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: 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;
}
.search-preparing {
display: flex;
align-items: center;
gap: 8px;
color: #506dff;
font-size: 14px;
margin-top: 10px;
}
.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: linear-gradient(180deg, #eef3ff 0%, #ffffff 100%);
background: #eaeaea;
position: relative;
}
.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;
}
.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);
}
}
.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;
}
.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>