<template>
|
<div class="report-pdf-viewer">
|
<div class="pdf-toolbar">
|
<el-button size="small" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)">上一页</el-button>
|
<span>{{ currentPage }} / {{ pages.length || '—' }}</span>
|
<el-button size="small" :disabled="currentPage >= pages.length" @click="goToPage(currentPage + 1)">下一页</el-button>
|
<span class="toolbar-spacer" />
|
<el-button size="small" :disabled="zoom <= 0.5" @click="zoom = Math.max(0.5, zoom - 0.25)">缩小</el-button>
|
<el-button size="small" @click="zoom = 1">{{ Math.round(zoom * 100) }}%</el-button>
|
<el-button size="small" :disabled="zoom >= 2" @click="zoom = Math.min(2, zoom + 0.25)">放大</el-button>
|
</div>
|
<div ref="viewport" v-loading="loading" element-loading-text="正在加载报告…" class="pdf-viewport" @scroll="updatePage">
|
<el-result v-if="error" icon="warning" title="报告预览失败" :sub-title="error">
|
<template #extra><el-button @click="openDocument">重试</el-button></template>
|
</el-result>
|
<div v-for="page in pages" :key="page.number" class="pdf-paper" :data-page-number="page.number"
|
:style="{ width: pageWidth(page) + 'px', height: pageWidth(page) * page.height / page.width + 'px' }">
|
<canvas :ref="element => setCanvas(element, page.number)" :data-page-number="page.number"
|
role="img" :aria-label="'报告第 ' + page.number + ' 页'" />
|
</div>
|
</div>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
|
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?worker&url'
|
import { loadReportFile, validateReportPreview } from '@/utils/reportPreview'
|
|
const props = defineProps({ source: { default: null }, load: Function })
|
const emit = defineEmits(['ready', 'error'])
|
const viewport = ref(null)
|
const pages = ref([])
|
const loading = ref(false)
|
const error = ref('')
|
const zoom = ref(1)
|
const availableWidth = ref(800)
|
const currentPage = ref(1)
|
const canvases = new Map()
|
const rendering = new Map()
|
const completed = new Set()
|
let generation = 0, renderGeneration = 0, pdf = null, task = null, controller = null
|
let observer = null, resizeObserver = null, resizeTimer = null
|
|
// PDF 的 72pt 转为屏幕 96px:A4 纸默认约 794px,窄窗口只缩小,不自动放大。
|
function pageWidth(page) { return Math.min(page.width * zoom.value, Math.max(1, availableWidth.value - 32)) }
|
function setCanvas(element, number) { if (element) canvases.set(number, element); else canvases.delete(number) }
|
function cancelRendering() {
|
renderGeneration++
|
observer?.disconnect()
|
for (const item of rendering.values()) item.cancel()
|
rendering.clear()
|
completed.clear()
|
}
|
function dispose() {
|
generation++
|
controller?.abort()
|
cancelRendering()
|
task?.destroy().catch(() => {})
|
task = null
|
pdf = null
|
for (const canvas of canvases.values()) { canvas.width = 0; canvas.height = 0 }
|
canvases.clear()
|
}
|
async function renderPage(number, documentGeneration, viewGeneration) {
|
if (!pdf || completed.has(number) || rendering.has(number)) return
|
completed.add(number)
|
try {
|
const page = await pdf.getPage(number)
|
if (documentGeneration !== generation || viewGeneration !== renderGeneration) return
|
const canvas = canvases.get(number)
|
if (!canvas) return
|
delete canvas.dataset.rendered
|
const spec = pages.value[number - 1]
|
const scale = pageWidth(spec) / spec.width * 96 / 72
|
const density = Math.min(window.devicePixelRatio || 1, 2)
|
const size = page.getViewport({ scale })
|
canvas.width = Math.ceil(size.width * density)
|
canvas.height = Math.ceil(size.height * density)
|
const render = page.render({ canvasContext: canvas.getContext('2d'), viewport: size,
|
transform: [density, 0, 0, density, 0, 0], background: '#ffffff' })
|
rendering.set(number, render)
|
await render.promise
|
if (documentGeneration === generation && viewGeneration === renderGeneration) {
|
canvas.dataset.rendered = 'true'
|
if (number === 1) { loading.value = false; emit('ready') }
|
}
|
} catch (cause) {
|
if (documentGeneration !== generation || viewGeneration !== renderGeneration) return
|
if (cause.name !== 'RenderingCancelledException') {
|
error.value = '报告页面加载失败,请重试'
|
loading.value = false
|
emit('error', error.value)
|
}
|
} finally {
|
if (documentGeneration === generation && viewGeneration === renderGeneration) rendering.delete(number)
|
}
|
}
|
async function renderVisiblePages() {
|
if (!pdf || !viewport.value) return
|
const cancelled = [...rendering.values()].map(item => item.promise.catch(() => {}))
|
cancelRendering()
|
const documentGeneration = generation, viewGeneration = renderGeneration
|
await Promise.all(cancelled)
|
await nextTick()
|
if (documentGeneration !== generation || viewGeneration !== renderGeneration) return
|
observer = new IntersectionObserver(entries => {
|
for (const entry of entries) if (entry.isIntersecting) {
|
renderPage(Number(entry.target.dataset.pageNumber), documentGeneration, viewGeneration)
|
}
|
}, { root: viewport.value, rootMargin: '300px' })
|
for (const canvas of canvases.values()) observer.observe(canvas)
|
if (loading.value) renderPage(1, documentGeneration, viewGeneration)
|
}
|
async function openDocument() {
|
dispose()
|
const version = generation
|
controller = new AbortController()
|
pages.value = []
|
currentPage.value = 1
|
zoom.value = 1
|
error.value = ''
|
loading.value = true
|
try {
|
const content = props.load ? await props.load(controller.signal)
|
: props.source instanceof Blob ? props.source : await loadReportFile(props.source, controller.signal)
|
const blob = await validateReportPreview(content, 'pdf')
|
const library = await import('pdfjs-dist/build/pdf.mjs')
|
const bytes = new Uint8Array(await blob.arrayBuffer())
|
if (version !== generation) return
|
library.GlobalWorkerOptions.workerSrc = workerUrl
|
task = library.getDocument({ data: bytes, useWasm: false, useSystemFonts: true, isEvalSupported: false })
|
const document = await task.promise
|
if (version !== generation) return
|
pdf = document
|
const sizes = []
|
for (let number = 1; number <= document.numPages; number++) {
|
const page = await document.getPage(number)
|
if (version !== generation) return
|
const size = page.getViewport({ scale: 96 / 72 })
|
sizes.push({ number, width: size.width, height: size.height })
|
}
|
pages.value = sizes
|
await nextTick()
|
viewport.value.scrollTop = 0
|
await renderVisiblePages()
|
} catch (cause) {
|
if (version !== generation) return
|
error.value = cause.message || '报告加载失败,请重试'
|
loading.value = false
|
emit('error', error.value)
|
}
|
}
|
function goToPage(number) {
|
const target = viewport.value?.querySelector(`[data-page-number="${number}"]`)
|
if (target) { viewport.value.scrollTop = target.offsetTop - 16; currentPage.value = number }
|
}
|
function updatePage() {
|
const top = viewport.value.getBoundingClientRect().top
|
for (const paper of viewport.value.querySelectorAll('.pdf-paper')) {
|
if (paper.getBoundingClientRect().bottom > top + 32) {
|
currentPage.value = Number(paper.dataset.pageNumber)
|
break
|
}
|
}
|
}
|
watch(() => [props.source, props.load], () => { if (viewport.value) openDocument() })
|
watch(zoom, renderVisiblePages)
|
onMounted(() => {
|
availableWidth.value = viewport.value.clientWidth
|
resizeObserver = new ResizeObserver(() => {
|
const width = viewport.value?.clientWidth
|
if (!width || width === availableWidth.value) return
|
const previousWidths = pages.value.map(pageWidth)
|
availableWidth.value = width
|
if (pages.value.every((page, index) => pageWidth(page) === previousWidths[index])) return
|
clearTimeout(resizeTimer)
|
resizeTimer = setTimeout(renderVisiblePages, 100)
|
})
|
resizeObserver.observe(viewport.value)
|
openDocument()
|
})
|
onBeforeUnmount(() => { clearTimeout(resizeTimer); resizeObserver?.disconnect(); dispose() })
|
</script>
|
|
<style scoped>
|
.report-pdf-viewer { display: flex; flex-direction: column; width: 100%; height: 100%; min-height: 0; background: #fff; color: #303133; color-scheme: light; }
|
.pdf-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; flex: 0 0 auto; padding: 8px 12px; background: #fff; border-bottom: 1px solid #ebeef5; }
|
.toolbar-spacer { flex: 1; }
|
.pdf-viewport { flex: 1; min-height: 0; overflow: auto; position: relative; background: #fff; }
|
.pdf-paper { position: relative; margin: 16px auto; background: #fff; box-shadow: 0 0 0 1px #e4e7ed; }
|
.pdf-paper canvas { display: block; width: 100%; height: 100%; background: #fff; }
|
</style>
|